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. 8. 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 未连/未登录/超时/接口错误并带书名命中率质量门 |
---
## 语言
- 跟随用户的语言回复,用户用什么语言就用什么语言回复
- 中文回复遵循《中文文案排版指北》