Skills に戻る
zenstory-ai/oh-story-claudecode実行前に内容を確認

SKILL DETAIL

story-short-scan

zenstory-ai/oh-story-claudecode/story-short-scan

短篇网文扫榜。分析知乎盐言、七猫、黑岩、点众等平台热门短篇数据,捕捉风口题材。触发方式:/story-short-scan、/短篇扫榜、「短篇什么火」「知乎故事排行」。

インストール · 87出典を見る

Installation

npx skills add https://github.com/zenstory-ai/oh-story-claudecode --skill story-short-scan

スキルファイル

SKILL.md

最終同期 · 2026/08/30

references/real-market-data.md
# 短篇网文跨平台写作参考

> 选平台/写简介/定题材时加载。先看决策路由选操作区域。

## 决策路由

| 你需要什么 | 查看哪个部分 | 核心操作 |
|------------|-------------|---------|
| 对比各平台差异 | 跨平台差异对照表 | 按维度横向对比选平台 |
| 写平台简介/导语 | 各平台简介写作公式 | 套对应平台公式 |
| 找爆款题材组合 | 题材爆款公式 | 按题材找公式 + 目标平台 |
| 了解平台独特规则 | 平台独特特征 | 七猫/知乎/点众各有要点 |

---

基于四大平台(七猫、黑岩、知乎、点众)历史热门样本整理。使用前必须用当前 scan 结果校验;未校验时只作为候选规则,不作为当前市场事实。

---

## 跨平台短篇写作差异对照表

| 维度 | 七猫 | 黑岩 | 点众 | 知乎 |
|------|------|------|------|------|
| 主力字数 | 1-2万字 | 8千-4万字 | 1-2万字(10章x1000字) | 5千-1.5万字(中位1.2万) |
| 章节数 | 7-19 | 4-14段(或1章4万字) | 5-10(标准10章) | 6-10 |
| 人称 | 第一人称95%+ | 第一人称85%(女)/50%(男) | 第一人称95%+ | 混合(古言用第三人称) |
| 章节字数 | 1000-1500 | 2000-5000 | 800-1500 | 800-1500 |
| 对话占比 | 20-38%(均值27.7%) | 30-50% | 9%平均(功能化) | 20-40%(金句多) |
| 段落结构 | 1-3句/段 | 1-3句/段 | 1-2句/段(16-37字) | 1-3句/段(30-60字) |
| 导语/简介 | 必须(100-200字) | 必须(100-363字) | 必须(150-300字) | 简介即作品(200-500字) |
| 开头模式 | 重生回溯/冲突前置 | 极端冲击/道德踩踏 | 标题即剧情/极端事件 | 一句话炸弹/金句开场 |
| 结尾模式 | HE为主(50%+新恋情) | HE/BE各半 | HE为主(悔恨/打脸) | 多样化(开放式多) |
| 独特元素 | 重生、穿书、心声流 | 灵魂视角、虐恋极致 | 弹幕流、读心、系统流 | 专业细节、脑洞、BL |
| 反转手法 | 态度反转(重生后180度转变) | 道德反转+恐怖反转 | 身份反转+死亡反转 | 信息差+情感深层揭露 |
| 爽点节奏 | 虐30%->觉醒15%->爽35%->治愈20% | 女:300-500字小反转/男:1000-2000字大爆发 | 每3章小转折/第10章大爆发 | 层层揭露/慢节奏但后劲大 |
| 情绪密度 | 中-高 | 最高(女频3.23次/千字) | 极高(短句占比33-66%) | 中(克制但有力) |
| 对话引号 | 「」或"" | 「」或"" | ""为主 | 「」为主 |
| 核心定位 | 重生幻想 | 极致刺激 | 情绪快餐 | 情感精品 |

---

## 各平台简介写作公式

| 平台 | 简介公式 | 字数 | 核心特征 |
|------|---------|------|---------|
| 七猫总裁 | 正常生活->伤害事件->最残忍对话->女主反转->悬念钩子 | 133-322字 | 70%含对话,对话选最伤人那句 |
| 黑岩女频 | 递进式控诉/心声揭穿/绝症倒计时/重生对比/公开羞辱+底牌 | 103-363字 | 核心是"这一次我..." |
| 黑岩男频 | 极简对白+信息炸弹/前世被害+重生/职场压榨+超自然 | 109-401字 | 第一段直接上最炸裂的场景或对白 |
| 点众 | 场景设定+信任陷阱+致命真相+反击暗示 | 150-300字 | 简介浓缩前1/3精华 |
| 知乎 | 人物关系+核心冲突+2-3转折+金句/留白 | 200-500字 | 简介即独立微小说,有金句收尾 |

---

## 题材爆款公式

| 题材 | 爆款公式 | 核心情绪 | 目标平台 |
|------|----------|----------|----------|
| 总裁豪门 | 契约婚姻+白月光+觉醒离开 | 虐->爽 | 七猫/黑岩 |
| 现实生活 | 家庭不公+重生/觉醒+反杀 | 愤怒->解气 | 七猫 |
| 宫闱宅斗 | 被害+隐忍布局+反攻称帝 | 压抑->极致释放 | 七猫 |
| 年代重生 | 替罪冤屈+重生+选不同路 | 憋屈->痛快 | 七猫 |
| 现代悬疑 | 超自然设定+犯罪+反转 | 紧张->震撼 | 七猫/知乎 |
| 玄幻仙侠 | 重生+反常行为+对手自乱 | 爽->更爽 | 七猫 |
| 灵魂虐文 | 极致虐待+灵魂旁观+悔恨 | 愤怒->意难平 | 点众/黑岩 |
| 职场逆袭 | 重生/穿越+投资+前夫后悔 | 憋屈->极爽 | 点众 |
| 架空历史 | 性别反转/身份错位+离开+后悔 | 憋屈->释然 | 七猫 |
| 家庭复仇 | 出轨/背叛+收集证据+当众打脸 | 愤怒->痛快 | 七猫/黑岩 |

---

## 平台独特特征

### 七猫

- 第一人称95%+,女性主角80%+
- 每章1000-1500字,7-10章为主力区间
- 几乎都有明确导语(前言概括全文核心冲突)

### 知乎

- 专业细节丰富(医疗有医学知识,法律有法律细节)
- 社会议题导向(婆媳、重男轻女、医疗纠纷)
- 情绪更克制但后劲更大,对话更真实
- 简介即作品:200-500字微小说,有金句收尾

### 点众标题公式

- `白眼狼妹妹把家产送人,我让她净身出户` = 人物+行为+反杀
- `愚人节死在棺材里后,三个哥哥都悔疯了` = 事件+后果+情绪
- `春节返程,老公一家把我女儿挂车外狗笼子里` = 场景+冲突+愤怒
scripts/cdp-utils.js
/**
 * CDP 工具函数 — 各平台采集脚本的公共依赖
 *
 * 使用方式:
 *   const { ab, sleep, evalJSON, evalJSONBase64, scrollLoad, getArg, safeStr, localDateStamp } = require("./cdp-utils");
 *
 * 前置:
 *   node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
 */

const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");

/**
 * On Windows `agent-browser` is an npm shim (agent-browser.cmd/.ps1) that
 * forwards to the real target — the native agent-browser-win32-*.exe or a
 * bundled Node CLI. Node refuses to execFile the `.cmd` without a shell
 * (CVE-2024-27980), and routing the argv array through a shell mangles it: the
 * `.cmd`'s `%*` is re-tokenized by cmd.exe (splitting on spaces, breaking on
 * & | ^), and calling the shim by bare name from powershell.exe collapses the
 * whole array into a single space-joined argument. The exact locus differs by
 * runtime, so instead of hardening any one shell path we bypass shells entirely:
 * read the `.cmd` shim, recover the real program plus its fixed leading args,
 * and execFile that target directly with the argv array — verbatim, no shell.
 */
function resolveWindowsAgentBrowser(argv) {
  const dirs = String(process.env.PATH || "").split(path.delimiter);
  let cmdPath = null;
  for (const dir of dirs) {
    if (!dir) continue;
    const candidate = path.join(dir, "agent-browser.cmd");
    if (fs.existsSync(candidate)) {
      cmdPath = candidate;
      break;
    }
  }
  if (!cmdPath) return { file: "agent-browser", args: argv };
  const dir = path.dirname(cmdPath);
  const forwardLine =
    fs
      .readFileSync(cmdPath, "utf8")
      .split(/\r?\n/)
      .find((line) => line.includes("%*")) || "";
  const tokens = [...forwardLine.matchAll(/"([^"]*)"/g)]
    .map((m) => m[1])
    .map((t) =>
      t
        .replace(/%~dp0/gi, () => dir + path.sep)
        .replace(/%dp0%/gi, () => dir + path.sep)
    );
  const jsIndex = tokens.findIndex((t) => /\.[cm]?js$/i.test(t));
  if (jsIndex >= 0) {
    return { file: process.execPath, args: [...tokens.slice(jsIndex), ...argv] };
  }
  if (tokens.length > 0) {
    return { file: tokens[0], args: [...tokens.slice(1), ...argv] };
  }
  return { file: "agent-browser", args: argv };
}

/**
 * Build a shell-free invocation. POSIX runs the native `agent-browser` binary
 * directly; Windows resolves the npm `.cmd` shim to that native target so the
 * argument array is passed verbatim, never routed through cmd.exe/PowerShell.
 */
function buildAgentBrowserInvocation(port, args, platform = process.platform) {
  const argv = ["--cdp", String(port), ...args.map(String)];
  if (platform !== "win32") {
    return { file: "agent-browser", args: argv };
  }
  return resolveWindowsAgentBrowser(argv);
}

// ---------------------------------------------------------------------------
// agent-browser 工具函数
// ---------------------------------------------------------------------------

/**
 * 调用 agent-browser CLI
 * @param {number} port - CDP 端口
 * @param  {...string} args - agent-browser 参数
 * @returns {string} stdout(trim 后)
 */
function ab(port, ...args) {
  const invocation = buildAgentBrowserInvocation(port, args);
  try {
    return execFileSync(
      invocation.file,
      invocation.args,
      {
        encoding: "utf-8",
        timeout: 20000,
        stdio: ["pipe", "pipe", "pipe"],
        windowsHide: true,
      }
    ).trim();
  } catch (error) {
    const stderr = error && error.stderr ? String(error.stderr).trim() : "";
    const stdout = error && error.stdout ? String(error.stdout).trim() : "";
    const detail = stderr || stdout || (error && error.message) || "unknown error";
    throw new Error(`agent-browser failed: ${detail}`, { cause: error });
  }
}

/** 等待 ms 毫秒(跨平台,不依赖系统 sleep 命令) */
function sleep(ms) {
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

function parseJSONResult(raw) {
  if (!raw || raw === "ERR") {
    throw new Error("agent-browser returned no JSON result");
  }
  try {
    let parsed = JSON.parse(raw);
    if (typeof parsed === "string") {
      try { parsed = JSON.parse(parsed); } catch {}
    }
    return parsed;
  } catch (error) {
    throw new Error(`agent-browser returned invalid JSON: ${String(raw).slice(0, 160)}`, {
      cause: error,
    });
  }
}

/**
 * 在浏览器内执行 JS,并解析 JSON 返回值。
 * 一律走 base64(-b):正文提取用的 JS 常含引号、反斜杠等,作为命令行参数时在 Windows 上
 * 无法逐字透传(.cmd 的 %* 与 PowerShell 都会二次解析)。base64 让参数只含 [A-Za-z0-9+/=],
 * 和各采集脚本已在用的 evalJSONBase64 走同一条安全通道。
 */
function evalJSON(port, js) {
  return evalJSONBase64(port, js);
}

/**
 * 通过 agent-browser 的 base64 参数执行复杂 JS,避免命令行转义和参数边界问题。
 */
function evalJSONBase64(port, js) {
  const encoded = Buffer.from(String(js), "utf8").toString("base64");
  return parseJSONResult(ab(port, "eval", "-b", encoded));
}

/**
 * 安全地将值插入浏览器 eval 字符串。
 * 使用 JSON.stringify 确保值不会因特殊字符(引号、反斜杠等)破坏 eval 字符串。
 * @param {*} val - 要插入的值
 * @returns {string} JSON 字符串表示(含引号)
 */
function safeStr(val) {
  return JSON.stringify(String(val));
}

/**
 * 滚动页面加载更多内容
 * @param {number} port - CDP 端口
 * @param {number} times - 滚动次数
 * @param {number} [interval=1000] - 每次滚动间隔(ms)
 */
function scrollLoad(port, times, interval = 1000) {
  for (let i = 0; i < times; i++) {
    ab(port, "eval", "window.scrollBy(0, window.innerHeight)");
    sleep(interval);
  }
}

/** 解析 --xxx 参数 */
function getArg(args, name) {
  const i = args.indexOf(name);
  if (i >= 0) return i + 1 < args.length ? args[i + 1] : null;
  const prefix = `${name}=`;
  const inline = args.find((arg) => String(arg).startsWith(prefix));
  return inline === undefined ? null : String(inline).slice(prefix.length);
}

/**
 * 输出文件名用的日期戳(YYYYMMDD),一律取**本地日历日**。
 * 不能用 new Date().toISOString().slice(0,10):那是 UTC 日期,比 UTC+8 晚 8 小时。
 * 文件名是各采集脚本唯一的去重键(一个榜单一天一份),北京时间 00:00-08:00 之间的采集
 * 会退回「昨天」的文件名,静默覆盖前一晚采到的同名报告,且这份数据被标成前一天。
 * @param {Date} [date] - 默认当前时间
 * @returns {string} YYYYMMDD
 */
function localDateStamp(date) {
  const d = date instanceof Date ? date : new Date();
  const y = String(d.getFullYear()).padStart(4, "0");
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}${m}${day}`;
}

/**
 * Run a scraper entrypoint and turn empty/partial output into machine-readable
 * CLI status. Legacy entrypoints may return an integer; multi-target scrapers
 * return {planned,written,failed,partial,partialReasons}.
 */
function runCli(main, label) {
  Promise.resolve()
    .then(main)
    .then((result) => {
      const outcome = Number.isInteger(result)
        ? { planned: result, written: result, failed: 0, partial: false, partialReasons: [] }
        : result;
      if (!outcome || !Number.isInteger(outcome.written) || outcome.written < 1) {
        throw new Error("no output was written");
      }
      const failed = Number.isInteger(outcome.failed) ? outcome.failed : 0;
      const planned = Number.isInteger(outcome.planned)
        ? outcome.planned
        : outcome.written + failed;
      const reasons = Array.isArray(outcome.partialReasons)
        ? outcome.partialReasons.filter(Boolean).map(String)
        : [];
      if (outcome.partial || failed > 0) {
        const details = [`wrote ${outcome.written}/${planned}`];
        if (failed > 0) details.push(`failed ${failed}`);
        details.push(...reasons);
        console.error(`${label} partial: ${details.join("; ")}`);
        process.exitCode = 2;
      }
    })
    .catch((error) => {
      const message = error && error.message ? error.message : String(error);
      console.error(`${label} failed: ${message}`);
      process.exitCode = 1;
    });
}

module.exports = {
  ab,
  sleep,
  evalJSON,
  evalJSONBase64,
  buildAgentBrowserInvocation,
  safeStr,
  scrollLoad,
  getArg,
  localDateStamp,
  runCli,
};
scripts/dz-browse-scraper.js
#!/usr/bin/env node
/**
 * 点众阅读短篇采集脚本
 *
 * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
 * 采集策略:以 /book/{id} 链接为骨架,按 bookId 聚合每本书的多个 anchor
 * (封面/书名+评分/简介各一个),从中解出书名、评分、简介、作品页,再从卡片
 * 容器文本里解出 作者·标签·状态·字数 与最新章节。避免纯 innerText 行序解析把
 * UI 文字或简介误当书名。
 * 输出 Markdown 格式。
 *
 * 用法:
 *   node dz-browse-scraper.js --channel male              # 男频
 *   node dz-browse-scraper.js --channel female             # 女频
 *   node dz-browse-scraper.js --channel all                # 全部
 *
 * 前置:
 *   node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
 */

const fs = require("fs");
const path = require("path");
const { ab, sleep, evalJSONBase64, safeStr, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");

const CHANNELS = [
  { id: "male", label: "男频", tab: "男频", url: "https://www.ishugui.com/browse" },
  { id: "female", label: "女频", tab: "女频", url: "https://www.ishugui.com/browse/on3" },
];

// ---------------------------------------------------------------------------
// 页面操作
// ---------------------------------------------------------------------------

/** 连通性 + 页面就绪自检 */
function probePage(port) {
  return evalJSONBase64(
    port,
    "JSON.stringify({host:location.host,len:(document.body&&document.body.innerText||'').length})"
  );
}

/** 点击指定文本的 tab */
function clickTab(port, text) {
  const js =
    "JSON.stringify((function(){" +
    "var all=document.querySelectorAll('div,span,a,button,li');" +
    "var el=Array.from(all).find(function(e){return (e.textContent||'').trim()===" + safeStr(text) + "});" +
    "if(el){el.click();return true}return false" +
    "})())";
  return evalJSONBase64(port, js);
}

/**
 * 以 /book/{id} 链接为骨架聚合每本书的字段。
 * 书名取自“书名+评分”anchor(去掉尾部 X.X分),简介取自最长 anchor,
 * 作者/标签/状态/字数从卡片容器文本里正则提取。
 */
function buildStoriesJS() {
  return `JSON.stringify((function(){
    var anchors=Array.from(document.querySelectorAll('a')).filter(function(a){
      return /\\/book\\/[0-9]+/.test(a.getAttribute('href')||'');
    });
    var byId={};var order=[];
    anchors.forEach(function(a){
      var m=(a.getAttribute('href')||'').match(/\\/book\\/([0-9]+)/);if(!m)return;
      var id=m[1];var txt=(a.innerText||a.textContent||'').replace(/\\s+/g,' ').trim();
      if(!byId[id]){byId[id]={id:id,texts:[],node:a};order.push(id);}
      if(txt)byId[id].texts.push(txt);
    });
    var out=[];
    order.forEach(function(id){
      var g=byId[id];
      var title='',score='';
      for(var i=0;i<g.texts.length;i++){
        var tm=g.texts[i].match(/^(.+?)\\s*([0-9]+(?:\\.[0-9]+)?)分$/);
        if(tm){title=tm[1].trim();score=tm[2]+'分';break;}
      }
      if(!title){
        var cand=g.texts.filter(Boolean).slice().sort(function(a,b){return a.length-b.length;});
        title=cand.length?cand[0]:'';
      }
      // 简介:最长的、不是“书名+评分”的 anchor 文本
      var desc='';
      g.texts.forEach(function(t){ if(/分$/.test(t))return; if(t.length>desc.length)desc=t; });
      // 卡片容器:从任一 anchor 向上找到含“字”的祖先
      var el=g.node;
      for(var j=0;j<6;j++){ if(el.parentElement){el=el.parentElement; if((el.innerText||'').indexOf('字')>-1)break;} }
      var card=(el.innerText||'').replace(/\\s+/g,' ');
      var tail=(desc&&card.indexOf(desc)>-1)?card.slice(card.indexOf(desc)+desc.length):card;
      var meta=tail.match(/([^·]{1,20}?)\\s*·\\s*([^·]{1,20}?)\\s*·\\s*(完结|完本|连载)\\s*·\\s*([0-9]+)\\s*字/);
      var author=meta?meta[1].trim():'';
      var tag=meta?meta[2].trim():'';
      var status=meta?meta[3]:'';
      var words=meta?meta[4]+'字':'';
      var um=card.match(/最新章节[::\\s]*([^·]{1,40})/);
      var update=um?um[1].trim():'';
      out.push({rank:out.length+1,bookId:id,title:title,score:score,author:author,tag:tag,status:status,words:words,update:update,desc:desc.slice(0,200),url:'https://www.ishugui.com/book/'+id});
    });
    return out;
  })())`;
}

function extractStories(port) {
  const list = evalJSONBase64(port, buildStoriesJS());
  return Array.isArray(list) ? list : [];
}

// ---------------------------------------------------------------------------
// 主流程
// ---------------------------------------------------------------------------

const args = process.argv.slice(2);
const PORT = parseInt(getArg(args, "--port") || "9222", 10);
const OUTDIR = getArg(args, "--outdir") || ".";
const CHANNEL = getArg(args, "--channel") || "male";

function scrapeChannel(port, channelId) {
  const ch = CHANNELS.find((c) => c.id === channelId);
  if (!ch) return null;

  console.log(`\n→ 采集 点众${ch.label}短篇...`);

  let stories;
  try {
    ab(port, "open", ch.url);
    sleep(3000);

    // 连通性自检:CDP 未起/被重定向时给可操作报错,而非静默产空
    const probe = probePage(port);
    if (!probe) {
      console.error(
        `  ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${port}),且 agent-browser 可用。`
      );
      return null;
    }
    if (probe.host && probe.host.indexOf("ishugui") === -1) {
      console.error(`  ✗ 当前页面非点众(host=${probe.host}),可能被重定向,已跳过。`);
      return null;
    }

    // 切换频道(female 已有独立 URL,tab 失败不致命)
    try {
      if (clickTab(port, ch.tab)) {
        console.log(`  ✓ 切换到${ch.tab}`);
        sleep(2000);
      }
    } catch (tabErr) {
      console.error(`[dz] ${ch.label} tab切换出错,继续采集: ${tabErr.message}`);
    }

    scrollLoad(port, 8);
    sleep(1000);

    stories = extractStories(port);
  } catch (err) {
    console.error(`[dz] ${ch.label} 页面加载或提取出错: ${err.message}`);
    return null;
  }

  if (!stories.length) {
    console.error(
      `[dz] 采集失败:未解析到书目(页面结构可能变动或未加载)。请人工打开 ${ch.url} 确认页面正常。`
    );
    return null;
  }

  // 质量门:书名命中率是点众采集成败的核心信号
  const titled = stories.filter((s) => s.title).length;
  const ratio = titled / stories.length;
  const quality = ratio < 0.5 ? "[书名解析异常]" : "[OK]";
  console.log(`  ✓ 提取 ${stories.length} 条(书名 ${titled}/${stories.length})`);
  if (ratio < 0.5) {
    console.error(`  ⚠ 书名解析率偏低(${titled}/${stories.length}),结果质量已标注。`);
  }

  const now = new Date().toISOString();
  const lines = [
    `# 点众 · ${ch.label}短篇`,
    "",
    `- 来源:${ch.url}`,
    `- 抓取时间:${now}`,
    `- 条目数:${stories.length}`,
    `- 书名解析:${titled} / ${stories.length}`,
    `- 数据质量:${quality}`,
    "",
    "---",
    "",
  ];

  stories.forEach((s, i) => {
    try {
      lines.push(`### #${i + 1} ${s.title || "(书名待解析)"}`);
      const meta = [s.author, s.tag, s.status, s.words, s.score].filter(Boolean).join(" · ");
      if (meta) lines.push(`*${meta}*`);
      if (s.update) lines.push(`**最新:** ${s.update}`);
      if (s.url) lines.push(`[作品页](${s.url})`);
      if (s.desc) {
        lines.push("");
        lines.push(`> ${s.desc.substring(0, 150)}${s.desc.length > 150 ? "..." : ""}`);
      }
      lines.push("", "---", "");
    } catch (storyErr) {
      console.error(`[dz] ${ch.label} 第${i + 1}条处理出错: ${storyErr.message}`);
      lines.push("", "---", "");
    }
  });

  return lines.join("\n");
}

function main() {
  const channels = CHANNEL === "all" ? CHANNELS.map((c) => c.id) : [CHANNEL];
  let written = 0;

  for (const ch of channels) {
    const content = scrapeChannel(PORT, ch);
    if (!content) continue;

    const chInfo = CHANNELS.find((c) => c.id === ch);
    const filename = `点众${chInfo.label}短篇_${localDateStamp()}.md`;
    fs.mkdirSync(OUTDIR, { recursive: true });
    const filepath = path.join(OUTDIR, filename);
    fs.writeFileSync(filepath, content, "utf-8");
    written++;
    console.log(`  ✓ 已保存: ${filepath}`);
  }
  return written;
}

if (require.main === module) {
  runCli(main, "点众采集");
}

module.exports = { buildStoriesJS };
scripts/heiyan-booklist-scraper.js
#!/usr/bin/env node
/**
 * 黑岩短篇书库列表采集脚本
 *
 * ⚠️ 前置条件(必须):
 *   1. 启动 Chrome CDP 环境
 *   2. 在 Chrome 中手动登录 https://manage.zhangwenpindu.cn
 *      登录后会生成 Admin-Token cookie,脚本需要它调用后端 API
 *      未登录 → 脚本报错「未检测到 Admin-Token」
 *
 * 采集策略:从 Cookie 提取 Bearer token,调用 ms.zhangwenpindu.cn 后端 API
 * 获取结构化 JSON 数据(书名、作者、字数、分类、标签等)。
 *
 * 用法:
 *   node heiyan-booklist-scraper.js --pages 5              # 采集前5页(每页20条)
 *   node heiyan-booklist-scraper.js --pages 3 --channel male   # 仅男频
 *   node heiyan-booklist-scraper.js --pages 2 --detail         # 含逐本详情(标签等)
 */

const fs = require("fs");
const path = require("path");
const { ab, sleep, evalJSON, safeStr, getArg, localDateStamp, runCli } = require("./cdp-utils");

const BOOKLIST_URL = "https://manage.zhangwenpindu.cn/books/booklist";
const API_BASE = "https://ms.zhangwenpindu.cn";
const PAGE_SIZE = 20;

// ---------------------------------------------------------------------------
// API 调用
// ---------------------------------------------------------------------------

/** 连通性自检:区分 CDP 未连 vs 已连但未登录 */
function probePage(port) {
  return evalJSON(port, "JSON.stringify({host:location.host})");
}

/** 从 Cookie 中提取 Admin-Token */
function getToken(port) {
  const js =
    "JSON.stringify((()=>{" +
    "var m=document.cookie.match(/Admin-Token=([^;]+)/);" +
    "return m?m[1]:''" +
    "})())";
  return evalJSON(port, js) || "";
}

/** 调用后端 API 获取书籍列表 */
function fetchBookList(port, token, pageNum) {
  const t = safeStr(token);
  const js =
    "fetch(" + safeStr(API_BASE + "/manage/book/list") + "," +
    "{method:'POST'," +
    "headers:{" +
    "'Content-Type':'application/x-www-form-urlencoded'," +
    "'Authorization':'Bearer '+" + t +
    "}," +
    "body:new URLSearchParams({pageNum:" + safeStr(pageNum) + ",pageSize:" + safeStr(PAGE_SIZE) + ",language:'zh_TW'})" +
    "}).then(function(r){return r.json()})";
  return evalJSON(port, js);
}

/** 调用后端 API 获取书籍详情(标签等) */
function fetchBookDetail(port, token, bookId) {
  const t = safeStr(token);
  const js =
    "fetch(" + safeStr(API_BASE + "/manage/book/" + encodeURIComponent(bookId)) + "," +
    "{headers:{'Authorization':'Bearer '+" + t + "}}" +
    ").then(function(r){return r.json()})";
  return evalJSON(port, js);
}

// ---------------------------------------------------------------------------
// 主流程
// ---------------------------------------------------------------------------

const args = process.argv.slice(2);
const PORT = parseInt(getArg(args, "--port") || "9222", 10);
const OUTDIR = getArg(args, "--outdir") || ".";
const PAGES = parseInt(getArg(args, "--pages") || "5", 10);
const CHANNEL = getArg(args, "--channel") || "all";
const DETAIL = args.includes("--detail");

/**
 * 字数格式化:手写千分位,不能用 toLocaleString()。
 * 后者按宿主 ICU locale 取分隔符,de_* 会写成「123.456字」(读起来像 123 字),
 * fr_* 用 U+202F、en-IN 会分成「1,23,456」——同一份报告在不同机器上数字不一样。
 * 兼容接口把 words 返回成字符串的情况。
 */
function fmtWords(words) {
  const n =
    typeof words === "number"
      ? Math.trunc(words)
      : parseInt(String(words == null ? "" : words).replace(/[^0-9]/g, ""), 10);
  if (!Number.isFinite(n) || n <= 0) return "";
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",") + "字";
}

function outputFilename(channel, date) {
  if (!["all", "male", "female"].includes(channel)) {
    throw new Error(`未知 --channel: ${channel}(支持 male/female/all)`);
  }
  return `黑岩书库列表_${channel}_${date}.md`;
}

function buildAndSave(allBooks, total, filtered, filepath) {
  const now = new Date().toISOString();
  const maleBooks = filtered.filter((b) => b.classifyStr === "男频");
  const femaleBooks = filtered.filter((b) => b.classifyStr === "女频");
  const otherBooks = filtered.filter(
    (b) => b.classifyStr !== "男频" && b.classifyStr !== "女频"
  );

  const groups = [
    { label: "男频", books: maleBooks },
    { label: "女频", books: femaleBooks },
  ];
  if (otherBooks.length) {
    groups.push({ label: "其他", books: otherBooks });
  }

  const lines = [
    `# 黑岩 · 书库列表`,
    "",
    `- 来源:${BOOKLIST_URL}`,
    `- 抓取时间:${now}`,
    `- 总条目:${total}`,
    `- 已采集:${filtered.length} 条(${PAGES} 页)`,
    DETAIL ? "- 含详情(标签、简介)" : "- 列表模式(加 --detail 获取标签和简介)",
    "",
    "---",
    "",
  ];

  for (const g of groups) {
    if (!g.books.length) continue;
    lines.push(`## ${g.label}短篇 — ${g.books.length} 本`, "");

    for (let i = 0; i < g.books.length; i++) {
      try {
        const b = g.books[i];
        lines.push(`### #${i + 1} ${b.name}`);
        const meta = [
          b.userName,
          // 分别入数组再拼:预先 classifyStr + "/" + typeDesc 会把缺字段拼成
          // 「undefined/undefined」这种真值字符串,filter(Boolean) 拦不住,直接写进报告
          [b.classifyStr, b.typeDesc].filter(Boolean).join("/"),
          fmtWords(b.words),
          b.price ? b.price + "钻" : "",
          b.open ? "公开" : "未公开",
        ].filter(Boolean).join(" · ");
        if (meta) lines.push(`*${meta}*`);

        if (b.createTime) lines.push(`**创建:** ${b.createTime}`);
        if (b.updateTime) lines.push(`**更新:** ${b.updateTime}`);

        if (b.tags && b.tags.length) {
          lines.push(`**标签:** ${b.tags.join("、")}`);
        }

        if (b.description) {
          lines.push("");
          lines.push(`> ${b.description.substring(0, 200)}${b.description.length > 200 ? "..." : ""}`);
        }

        lines.push("");
      } catch (bookErr) {
        console.error(`[heiyan] ${g.label} 第${i + 1}条处理出错: ${bookErr.message}`);
        lines.push("");
      }
    }

    lines.push("---", "");
  }

  fs.mkdirSync(OUTDIR, { recursive: true });
  fs.writeFileSync(filepath, lines.join("\n"), "utf-8");
  console.log(`  ✓ 已保存: ${filepath}`);
}

function main() {
  console.log("\n→ 采集 黑岩书库列表(API 模式)...");
  console.log(`  计划采集: ${PAGES} 页(每页 ${PAGE_SIZE} 条)`);

  const filename = outputFilename(CHANNEL, localDateStamp());
  const filepath = path.join(OUTDIR, filename);

  // 先导航到管理后台获取 token
  let token;
  try {
    ab(PORT, "open", BOOKLIST_URL);
    sleep(3000);

    // 连通性自检:把"CDP 没起来"和"没登录"分开,避免误导用户去登录
    const probe = probePage(PORT);
    if (!probe) {
      console.error(
        `  ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${PORT}),且 agent-browser 可用。`
      );
      return 0;
    }

    token = getToken(PORT);
  } catch (err) {
    console.error(`[heiyan] 页面加载或 token 提取出错: ${err.message}`);
    return 0;
  }

  if (!token) {
    console.log("  ✗ 未检测到 Admin-Token(CDP 已连,但当前未登录)");
    console.log("  → 请先在 Chrome 中打开 https://manage.zhangwenpindu.cn 并登录");
    console.log("  → 登录后重新运行本脚本");
    return 0;
  }
  console.log("  ✓ 获取到认证 token");

  // 分页采集
  const allBooks = [];
  let total = 0;

  for (let p = 1; p <= PAGES; p++) {
    try {
      sleep(800);
      const resp = fetchBookList(PORT, token, p);

      // 区分失败:接口无响应(超时/CDP) / 401 未授权
      if (!resp) {
        console.error(`  ✗ 第${p}页接口无响应(请求超时或 CDP 中断),已停止。`);
        break;
      }
      if (resp.code === 401) {
        console.log(`  ⚠ 第${p}页认证失败(401),请重新登录后重试。`);
        break;
      }

      const rows = resp?.data?.rows;
      if (!rows || !rows.length) {
        // 仅在无数据时才用 code 区分"服务端错误"与"正常到底",避免把
        // 携带非常规成功 code 的有效响应误判为错误(成功带 rows 一律放行)
        if (resp.code != null && resp.code !== 0 && resp.code !== 200) {
          console.error(`  ✗ 第${p}页接口返回错误 code=${resp.code} ${resp.msg || ""},已停止。`);
        } else {
          console.log(`  第${p}页无数据,停止`);
        }
        break;
      }

      if (p === 1) {
        total = parseInt(resp.data.total) || 0;
        console.log(`  总条目: ${total}`);
      }

      allBooks.push(...rows);
      console.log(`  第${p}页: ${rows.length} 条 (累计 ${allBooks.length})`);
    } catch (pageErr) {
      console.error(`[heiyan] 第${p}页采集出错,跳过: ${pageErr.message}`);
      if (allBooks.length > 0) {
        console.log(`  已采集 ${allBooks.length} 条,继续处理已有数据`);
      }
      break;
    }
  }

  if (!allBooks.length) {
    console.error("[heiyan] 采集失败:未取到任何书目。多为登录态过期或接口变动,请重新登录后重试。");
    return 0;
  }

  // 质量门:核心字段命中率。API 改字段名时会整片 undefined,必须拦截而非静默写盘。
  // classifyStr 同样要查:它决定男频/女频分组和 --channel 筛选,字段一改全部书都掉进
  // 「其他」、--channel male 直接筛成 0 条,写出一份「已采集:0 条」的假成功报告。
  const CORE_FIELDS = [
    { key: "name", label: "书名" },
    { key: "classifyStr", label: "频道(classifyStr)" },
  ];
  for (const f of CORE_FIELDS) {
    const hit = allBooks.filter((b) => b && b[f.key]).length;
    if (hit / allBooks.length < 0.5) {
      console.error(
        `[heiyan] 采集失败:${allBooks.length} 条里仅 ${hit} 条有${f.label},疑似接口字段变动,已放弃写盘。`
      );
      return 0;
    }
  }

  // 频道筛选
  let filtered = allBooks;
  if (CHANNEL === "male" || CHANNEL === "female") {
    const want = CHANNEL === "male" ? "男频" : "女频";
    filtered = allBooks.filter((b) => b.classifyStr === want);
    if (!filtered.length) {
      // 筛成 0 条不能算成功:把实际 classifyStr 分布打出来,区分「接口字段变了」
      // 和「这个频道确实没作品」,而不是写一份看不出差别的空报告
      const seen = {};
      for (const b of allBooks) {
        const k = b && b.classifyStr ? b.classifyStr : "(空)";
        seen[k] = (seen[k] || 0) + 1;
      }
      const dist = Object.keys(seen).map((k) => `${k}×${seen[k]}`).join("、");
      console.error(
        `[heiyan] 采集失败:--channel ${CHANNEL} 筛选后 0 条(${allBooks.length} 条的 classifyStr 取值:${dist}),已放弃写盘。`
      );
      console.error(`  → 若确实没有${want}作品,去掉 --channel 重跑即可拿到全部书目。`);
      return 0;
    }
  }

  // 可选:逐本获取详情(标签等)
  if (DETAIL && filtered.length) {
    console.log(`  获取 ${filtered.length} 本详情...`);
    for (let i = 0; i < filtered.length; i++) {
      try {
        sleep(500);
        const detail = fetchBookDetail(PORT, token, filtered[i].id);
        if (detail?.data) {
          filtered[i].tags = detail.data.tags || [];
          filtered[i].description = detail.data.description || "";
          filtered[i].chapterCount = detail.data.chapterCount || 0;
        }
        if ((i + 1) % 10 === 0) {
          console.log(`    已获取 ${i + 1}/${filtered.length}`);
        }
      } catch (detailErr) {
        console.error(`[heiyan] 第${i + 1}本详情获取出错,跳过: ${detailErr.message}`);
      }
    }
    console.log("  ✓ 详情获取完成");
  }

  buildAndSave(allBooks, total, filtered, filepath);
  return 1;
}

if (require.main === module) {
  runCli(main, "黑岩采集");
}

module.exports = {
  probePage,
  getToken,
  fetchBookList,
  fetchBookDetail,
  fmtWords,
  outputFilename,
  buildAndSave,
};
SKILL.md
---
name: story-short-scan
version: 1.0.0
description: "短篇网文扫榜。分析知乎盐言、七猫、黑岩、点众等平台热门短篇数据,捕捉风口题材。触发方式:/story-short-scan、/短篇扫榜、「短篇什么火」「知乎故事排行」。"
metadata: {"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
---
# story-short-scan:短篇网文扫榜

你是短篇网文市场分析师。你的任务是基于榜单样本识别短篇市场格局,并输出可执行的情绪方向、题材候选、风险阈值和验证动作。

**核心信念:短篇市场变化快,题材信号有效期短。** 扫榜报告必须标注样本日期、趋势可信度和下次重新扫榜的时间。

---

## 核心哲学

### 原则 1:短篇市场是情绪市场

短篇网文的核心是情绪交付。读者在短时间内完成一次情绪体验;扫榜要提取高频情绪、触发场景、情绪爆发点和读者愿意转发的点,而不是只记录题材名。

### 原则 2:短篇的生命力在传播

短篇不像长篇靠追读赚钱。短篇靠的是单篇完读率和传播(分享、收藏、点赞)。完读率高 = 情绪拉扯到位;传播率高 = 有共鸣或反转让人想转发。

### 原则 3:短篇风口来得快去得快

短篇题材信号可能在数周内失效。输出风口候选时必须给出有效期、饱和风险和下次复扫时间;未复扫前不得当作长期趋势。

---

## 扫榜流程

### Phase 1:确认平台和方向

问用户:**「你想看哪个平台?(知乎盐言/番茄短篇/七猫短篇/其他)有没有想写的类型方向?」**

关键判断:
- 用户已有方向 → 针对该方向做深度扫榜
- 用户没有方向 → 做全榜概览 + 找趋势
- 用户想跨平台比较 → 做平台对比分析

---

### Phase 2:确定数据来源

**扫榜需要真实数据支撑。** 根据当前环境选择数据来源:

| 优先级 | 模式 | 说明 | 何时用 |
|--------|------|------|--------|
| 1 | **browser-cdp 采集** | 直接抓取平台页面,产出结构化文件 | 有 Chrome 环境时(优先) |
| 2 | **用户提供** | 用户粘贴榜单截图/文字/链接 | 用户已有数据时 |
| 3 | **内置知识** | 基于知识库中的趋势数据和方法论做分析 | 无法联网、用户无数据时 |

#### browser-cdp 采集模式

使用 `/browser-cdp` 启动 Chrome,直接抓取平台页面的结构化数据。适用于需要登录才能看到的数据(知乎个人中心、番茄书架等)。

**点众采集目标**:

| 页面 | URL | 核心字段 |
|------|-----|----------|
| 男频短篇 | ishugui.com/browse | 书名·作者·标签·状态·字数·评分·最新章节 |
| 女频短篇 | ishugui.com/browse/on3 | 书名·作者·标签·状态·字数·评分·最新章节 |

**黑岩采集目标**:

| 页面 | URL | 核心字段 |
|------|-----|----------|
| 书库列表 | manage.zhangwenpindu.cn/books/booklist | 书名·作者·字数·分类·类型·价格·创建/更新时间·标签(详情模式) |

> **黑岩需要登录!** 必须先在 Chrome 中手动登录 `manage.zhangwenpindu.cn`,脚本才能从 Cookie 中提取 Bearer token 调用后端 API。未登录会报错提示。**黑岩采集失败时标记为 SKIP,继续其他平台采集,不中断本轮数据采集。**

- 黑岩专用:`--pages N`(每页 20 条)、`--detail`(逐本详情,含标签/简介,速度较慢)、`--channel male/female`
- 点众专用:`--channel male/female/all`

**文件命名**:`{平台}{类型}_{YYYYMMDD}.md`,例:`点众男频短篇_20260501.md`

**用户提供操作指引:**
- 请用户截图或复制粘贴榜单内容
- 如果用户提供链接,用 WebFetch 抓取页面内容
- 如果用户只提供故事名列表,直接进入分析

**内置知识操作指引:**
- 加载 `references/real-market-data.md`(跨平台写作差异对照)
- 明确标注:「以下分析基于历史趋势数据;未完成实时榜单校验前只能作为候选假设。」并列出需要复扫的平台页面。

---

### Phase 3:数据分析

#### 知乎盐言故事分析维度

| 维度 | 看什么 |
|---|---|
| 热门榜单 | 当前最受关注的故事 |
| 高赞故事 | 口碑最好的作品结构 |
| 新作者上榜 | 非头部账号的题材选择与开篇模式 |
| 付费转化率 | 哪些题材读者愿意付费 |
| 标签分布 | 热门标签的变化趋势 |

#### 通用分析维度

对每个平台提取:

1. **情绪类型分布**:当前哪种情绪拉扯最火(虐恋/反转/悬疑/治愈/打脸)
2. **题材热点**:具体什么设定/场景反复出现
3. **篇幅分布**:热门短篇集中在多少字
4. **开头模式**:热门短篇的第一段/第一句怎么写
5. **结尾类型**:HE(好结局)/BE(坏结局)/开放式 的比例
6. **标题模式**:热门短篇的命名规律
7. **人设模型**:反复出现的主角类型

---

### Phase 4:输出扫榜报告

```
# 短篇网文扫榜报告:{平台名称}

## 市场概况
- 扫榜时间:{日期}
- 核心发现:{一句话总结}

## 情绪热度排行
| 排名 | 情绪类型 | 榜上数量 | 趋势 | 代表作 |
|------|----------|----------|------|--------|
| 1 | {类型} | {N篇} | ↑/→/↓ | {标题} |

## 题材热点
| 题材 | 热度 | 竞争程度 | 门槛 | 代表作 |
|------|------|----------|------|--------|
| {题材} | 高/中/低 | 激烈/一般/蓝海 | 高/中/低 | {标题} |

## 关键数据洞察
- 篇幅区间:热门短篇集中在 {X}-{Y} 字
- 开头模式:{高频开头模式}
- 结尾偏好:{HE/BE/开放式的比例}
- 标题特征:{命名规律}
- 人设热词:{高频主角类型}

## 风口预警
- 🔥 正在爆发:{题材} — {依据}
- ⚡ 即将起风:{题材} — {依据}
- ⚠️ 即将饱和:{题材} — {依据}

## 值得写的方向
1. {方向 + 情绪拉扯方式 + 可行性}
2. {方向 + 情绪拉扯方式 + 可行性}
3. {方向 + 情绪拉扯方式 + 可行性}

## 一句话
{犀利总结}
```

---

### Phase 5:选题匹配

根据扫榜结果,结合项目条件输出选题匹配:

- 低复杂度候选:反转类、打脸类(结构清晰、验证成本低)
- 高复杂度候选:悬疑类、虐恋类(技术壁垒高,需要伏笔、反转和情绪控制证据)
- 优先候选:当前样本强信号 × 项目素材/能力约束可支撑的交叉点

**关键判断**:
- 情绪拉扯力 > 题材创新力(短篇读者更看重情绪体验)
- 开头 3 句话是留存高风险区,必须建立冲突、身份差或情绪钩子
- 反转是短篇常见传播引擎;若不使用反转,必须用强共鸣、强话题或强余韵补足传播风险

---

## 平台特性速查

| 平台 | 调性 | 核心指标 | 主力读者 | 适合类型 | 短篇主力字数 |
|------|------|----------|----------|----------|-------------|
| 知乎盐言故事 | 精品短篇,情绪深度 | 付费转化、收藏 | 20-35 都市人群 | 虐恋、反转、悬疑、现实 | 5千-1.5万字 |
| 七猫短篇 | 下沉市场,女频为主 | 完读率 | 女性为主(80%+) | 总裁/现实/宅斗/年代/悬疑 | 1-2万字(7-19章) |
| 黑岩短篇 | 极端情绪,快节奏 | 完读率、付费 | 混合 | 虐恋、复仇、身份反转 | 8千-4万字 |
| 点众短篇 | 精品快节奏 | 完读率 | 混合 | 家庭复仇、假千金、弹幕流 | 1-2万字(5-10章) |

---

## 流程衔接

**流水线:** 短篇
**位置:** 扫榜(第 1/3 步)

| 时机 | 跳转到 | 命令 |
|---|---|---|
| 找到方向 | story-short-analyze | `/story-short-analyze` |
| 直接开写 | story-short-write | `/story-short-write` |
| 更适合长篇 | story-long-scan | `/story-long-scan` |

---

## 参考资料

按需加载以下文件:

| 文件 | 何时加载 |
|------|----------|
| [references/real-market-data.md](references/real-market-data.md) | **核心参考**:跨平台写作差异对照表、各平台简介公式速查、题材爆款公式速查表、各平台写作特征 |
| [scripts/cdp-utils.js](scripts/cdp-utils.js) | CDP 公共工具函数(ab/sleep/evalJSON/safeStr/scrollLoad/getArg),各采集脚本共用 |
| [scripts/dz-browse-scraper.js](scripts/dz-browse-scraper.js) | 点众短篇采集(男频/女频),按 bookId 聚合 anchor 解出书名/评分/简介/作品页(避免把 UI 文字或简介误当书名),带连通性自检+书名解析率质量门,配合 browser-cdp 使用 |
| [scripts/heiyan-booklist-scraper.js](scripts/heiyan-booklist-scraper.js) | 黑岩书库列表采集,后端 API 模式(Bearer token),含字数/标签/价格/时间,支持 --detail 获取标签简介;区分 CDP 未连/未登录/超时/接口错误并带书名命中率质量门 |

---

## 语言

- 跟随用户的语言回复,用户用什么语言就用什么语言回复
- 中文回复遵循《中文文案排版指北》