Claude Skill

story-long-scan

长篇网文扫榜。分析起点、番茄、晋江等平台排行榜数据,提炼市场趋势与热门题材。触发方式:/story-long-scan、/长篇扫榜、「长篇什么火」「起点排行」。

LLM Mart · 0 points · 41 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zenstory-ai-oh-story-claudecode-skills_story-long-scan-d1f8858.zip · 57 KB
Part of zenstory-ai/oh-story-claudecode — 26 skills

Install

skills CLI npx skills add https://github.com/zenstory-ai/oh-story-claudecode/tree/main/skills/story-long-scan
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-oh-story-claudecode@llmmart
Git git clone https://github.com/zenstory-ai/oh-story-claudecode.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zenstory-ai/oh-story-claudecode collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

story-long-scan:长篇网文扫榜

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

核心信念:单本排名只提供线索;跨样本重复模式才算信号。 排行榜只能证明样本存在;必须通过多榜单、多作品和近期数据判断需求强度。


核心哲学

原则 1:扫榜看模式,别只看排名

排名会波动,模式必须用重复样本验证。扫榜要提取:反复出现的题材、设定、套路、书名词和开篇卖点。单本上榜只能记为个例;同类样本达到可比数量后,才能标记为趋势候选。

原则 2:流量型平台和付费型平台看的东西不同

番茄看的是流量和完读率,起点看的是订阅和追读,晋江看的是收藏和积分。不同平台的成功标准不同,扫榜方法也不同。

原则 3:扫榜的目的是找到你能写的爆款题材

不按热度直接给结论。每个方向都要做项目可行性判断:素材储备、题材边界、篇幅承载、目标平台样本是否足够。


扫榜流程

Phase 1:确认平台和方向

问用户:「你想看哪个平台?(起点/番茄/晋江/其他)有没有关注的题材方向?」

关键判断:

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

Phase 2:确定数据来源

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

优先级 模式 说明 何时用
1 脚本采集 直接抓取平台页面/SSR 数据,产出结构化文件 优先;起点默认不需要 Chrome
2 用户提供 用户粘贴榜单截图/文字/链接 用户已有数据时
3 内置知识 基于知识库趋势数据做分析 无法联网、用户无数据时

脚本采集模式

优先运行对应平台脚本直接采集结构化数据。起点使用移动端 SSR pageContext,默认不需要 Chrome/CDP;番茄等需要浏览器态的平台再使用 /browser-cdp 启动 Chrome。

采集流程:

  1. 选择平台脚本;起点直接运行 scripts/qidian-rank-scraper.js,番茄/七猫/晋江等按需启动 browser-cdp
  2. 等待列表元素或 SSR 数据加载,逐条提取字段(排名、书名、作者、题材、字数、推荐/在读数等),判断翻页(起点通常单页50-100条,番茄按题材逐页cap≈20)
  3. 需要补充数据时(标签、简介、最新更新),进入详情页提取
  4. 按规范格式写入 Markdown 文件
  5. 多榜单/多题材时,逐组采集并保存

输出规范:详见 references/scan-output-format.md,包含各平台字段定义、输出模板。

起点采集目标(优先运行 node scripts/qidian-rank-scraper.js --type {榜单} --outdir {输出目录};默认 --mode auto 会先用 https://m.qidian.com 移动端 SSR,PC/CDP 只作回退):

榜单 URL 核心字段
新人签约新书榜 qidian.com/rank/newsign/ 作者·题材·签约·免费/VIP·字数·总推荐·标签·简介
签约作者新书榜 qidian.com/rank/signnewbook/ 已签约作者新书,新风向信号
公众作者新书榜 qidian.com/rank/pubnewbook/ 公众作者新书,发现潜力作者
新人作者新书榜 qidian.com/rank/newauthor/ 新人作品,新人赛道风向
三江推荐 qidian.com/sanjiang/ 编辑推荐,按周分组(注意:非 /rank/ 路径)
月票榜 qidian.com/rank/yuepiao/ 付费认可度最高指标
畅销榜 qidian.com/rank/hotsales/ 真金白银投票
阅读指数榜 qidian.com/rank/readindex/ 阅读量综合指标
收藏榜 qidian.com/rank/collect/ 读者关注热度
原创推荐榜 qidian.com/rank/recom/

番茄采集目标:

榜单 URL格式 核心字段
男频阅读榜 fanqienovel.com/rank/1_2_ 按题材逐页采集,在读数为核心指标
女频阅读榜 fanqienovel.com/rank/0_2_ 按题材逐页采集
男频新书榜 fanqienovel.com/rank/1_1_ 新风向信号
女频新书榜 fanqienovel.com/rank/0_1_ 新风向信号

URL 参数:/rank/{channel}_{type}_{cat_id},channel 0=女频/1=男频,type 1=新书榜/2=阅读榜。番茄列表页有字体反爬,须用 scripts/fanqie-rank-scraper.js 从详情页多策略解码书名/作者/题材/评分/标签/简介,配合 browser-cdp 使用:

node scripts/fanqie-rank-scraper.js --channel 1 --type 2 --outdir {输出目录}   # 男频阅读榜
node scripts/fanqie-rank-scraper.js --channel all --top 15 --outdir {输出目录}   # 男女频,每题材前 15 本

番茄采集后必查文件头 数据质量,异常排查步骤见 references/scan-output-format.md。

七猫采集目标:

榜单 URL 核心字段
排行榜总入口 qimao.com/paihang 大热榜/新书榜/完结榜,热度为核心指标

榜单类型:大热榜(日榜/月榜)、新书榜、完结榜、收藏榜、更新榜,支持男生榜/女生榜切换。

大热榜用 --period day|month|all 显式选择日榜、月榜或两者(默认 day);周期会进入文件头与文件名。非大热榜不区分周期,--period 不会重复采集。

晋江采集目标(scripts/jjwxc-rank-scraper.js,默认列表 + 详情两步走):

榜单 URL 核心字段
收入金榜 jjwxc.net/topten.php?orderstr=12&t=0 收藏数、营养液、积分、字数、状态(详情页 onebook.php 补采)
node scripts/jjwxc-rank-scraper.js --type 12 --outdir {输出目录}        # 列表+详情(默认每频道前10,详情上限100)
node scripts/jjwxc-rank-scraper.js --type 12 --top 15 --detail-limit 60  # 调整每频道本数/详情总量
node scripts/jjwxc-rank-scraper.js --type 12 --list-only                 # 只采列表(快,无核心指标)

晋江硬性要求:必须有详情页核心指标(收藏数/营养液/积分/字数),脚本默认已补采;采集要点见 references/scan-output-format.md。

文件命名:{平台}{榜单名称}_{YYYYMMDD}.md,例:起点新人签约新书榜_20260425.md

采集质量检查(「确定数据来源」完成后必须执行)

每完成一个榜单的采集,立即执行以下检查。发现问题当场修复,不留给后续分析。详细规则见 references/scan-output-format.md「数据清洗与字段约束」。

1. 数据完整性

检查项 标准 处理
条目数量 >= 15 条有效数据(小平台 >= 10) 不足则在文件头注明 [数据稀疏] 实际采集 N 条
必填字段 排名、书名、作者(缺任一项视为无效) 无效条目移除,条目数重新计算
字段一致性 同一榜单内所有条目字段集必须一致 不一致条目标记 [字段缺失: {字段名}]

2. 数据清洗

污染类型 处理
平台模板文本(番茄「提供XXX完整版在线免费阅读」、七猫「上一页」等) 删除模板文本,保留正文
解析串行(同一条目出现两个不同作品的数据) 标记 [解析异常],删除并重新采集
空字段(空白、--、未知) 标记 [待补],优先通过详情页补采

3. 简介截断

  • 清洗后超过 100 字的简介,在最近的句号/问号/感叹号处截断,加 ...
  • 平台模板文本不计入 100 字限制(先删除模板,再截断)

4. 文件头质量状态

每个采集文件头部必须包含:

- 数据质量:[OK / 存在问题]
- 有效条目:{N} / {总数}
- 问题摘要:{无 / 具体问题描述}

其他数据来源

用户提供操作指引:

  • 用户提供已有的扫描结果文件路径 → 直接加载进入「数据分析」
  • 用户提供链接 → 用 WebFetch 抓取
  • 用户粘贴/截图 → 手动解析进入分析

内置知识操作指引:

  • 加载 references/genre-trends.md
  • 明确标注:「以下分析基于历史趋势数据;未完成实时榜单校验前只能作为候选假设。」并列出需要复扫的榜单。

Phase 3:数据分析

根据用户选择的平台,结合已获取的数据做以下分析:

起点中文网分析维度

维度 看什么
月票榜/推荐票榜 付费用户认可度高、持续追读强
畅销榜 真金白银投票,最硬核的指标
签约作者新书榜 已签约作者的新作风向
公众作者新书榜 公众作者的新作,发现潜力股
新人作者新书榜 新作者作品与新题材信号
三江推荐 编辑精选推荐,按周分组,发现平台力推作品
分类榜单 各垂直题材的竞争格局
追读率 核心指标,决定推荐位分配

番茄小说分析维度

维度 看什么
阅读榜 流量与读者规模,在读数为核心指标
新书榜 新题材、新风向的早期信号
题材分布 各品类在读数集中度
在读数趋势 同题材不同作品的流量差距
标签热词 简介开头【】内的标签组合,揭示题材细分卖点(如「种田+慢热+西幻」)

七猫小说分析维度

维度 看什么
大热榜 热度排名,反映流量集中度
新书榜 新流量风口
完结榜 长尾价值作品
热度指标 七猫核心指标,反映读者活跃度

晋江文学城分析维度

采集硬性要求:若用了 --list-only 或文件头标 [仅列表-无核心指标],则该数据不足以支撑以下分析维度,视为不合格。

维度 看什么
金榜 综合热度最高
季度榜 中期趋势
红字/黑字 积分与负面评价
收藏/营养液 女频市场的核心指标

通用分析维度

对每个平台的榜单数据,提取:

  1. 题材分布:当前榜上哪些题材最多
  2. 新题材信号:最近新出现的题材类型
  3. 经典题材变化:老牌题材的走势(上升/稳定/下降)
  4. 字数与更新:上榜作品的字数区间和更新频率
  5. 书名模式:上榜作品的命名规律
  6. 开头卖点:简介/标签中反复出现的关键词
  7. 新元素对比:与上期/同类榜单对比,标注新出现的人物设定、开篇切入点、桥段套路

Phase 4:输出扫榜报告

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

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

## 题材热度排行
| 排名 | 题材 | 榜上数量 | 趋势 | 代表作 |
|------|------|----------|------|--------|
| 1 | {题材} | {N本} | ↑/→/↓ | {书名} |

## 新题材信号
- {新出现或正在上升的题材,附依据}

## 经典题材动态
- {老牌题材的现状,附依据}

## 新元素提取
### 新人物设定模式
- {新模式描述 + 代表作}

### 新开篇切入点
- {新切入点描述 + 代表作}

### 新桥段/套路
- {新桥段描述 + 代表作}

## 关键数据洞察
- 字数区间:上榜作品集中在 {X}-{Y} 万字
- 更新频率:日均 {X} 字为主流
- 书名特征:{命名模式总结}
- 标签热词:{高频标签词}

## 值得关注的方向
1. {方向 + 为什么值得关注 + 可行性评估}
2. {方向 + 为什么值得关注 + 可行性评估}
3. {方向 + 为什么值得关注 + 可行性评估}

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

Phase 5:选题决策

把扫榜结果变成能直接用的选题建议,产出 选题决策.md。完整方法(选题四步 + 可行性判断 + 输出模板)见 references/topic-decision.md。

如信息不足,向用户补齐项目条件:「目标平台、已有素材、擅长题材/写作约束、计划篇幅是什么?」

按 topic-decision.md 的选题四步产出 2-3 个推荐选题(能爆的原因 → 市场验证 → 差异化定位 → 可行性+失败风险+验证动作),写入本次扫榜输出目录 {outdir}/选题决策.md

硬规则:

  • 可行性上限:背靠榜单标了 [数据稀疏] 或同方向样本 <15(小平台<10)⇒ 不许给"高",强制降到"中" + 写明先验证;内置知识模式一律给"中"。
  • 不输出项目素材无法支撑的题材;不只看热度,必须给可行性和失败风险;不忽略平台调性差异(起点男频和晋江女频审美完全不同)。

平台特性速查

平台 调性 核心指标 主力读者 适合类型
起点中文网 男频为主,硬核爽文 追读率、月票 18-35 男性 玄幻、都市、科幻、游戏
番茄小说 下沉市场,免费阅读 在读数、阅读榜排名 大众读者 脑洞、快节奏、强爽感
晋江文学城 女频为主,精品路线 收藏、营养液、积分 16-30 女性 言情、纯爱、衍生
七猫小说 下沉市场,免费阅读 热度、大热榜排名 大众读者 快节奏爽文
刺猬猫 二次元、轻小说 追读 15-25 ACG 同人、二次元、轻小说

流程衔接

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

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

参考资料

按需加载以下文件:

文件 何时加载
references/topic-decision.md 「选题决策」:选题四步 + 可行性判断 + 选题决策.md 模板
references/reader-profiling.md 需要分析目标读者画像时
references/genre-trends.md 查看题材趋势候选、切入约束和样本校验规则时
references/publishing-guide.md 平台适配+推荐机制校验+数据指标+简介设计
references/scan-output-format.md 脚本/CDP 采集字段定义+输出模板
scripts/cdp-utils.js CDP 公共工具函数(ab/sleep/evalJSON/safeStr/scrollLoad/getArg),各采集脚本共用
scripts/fanqie-rank-scraper.js 番茄榜单采集,分批请求防超时,带连通性自检+标题解析率质量标注,配合 browser-cdp 使用
scripts/qidian-rank-scraper.js 起点榜单采集(畅销/月票/新书等),默认移动端 SSR 提取,PC/CDP 回退
scripts/qimao-rank-scraper.js 七猫榜单采集(大热/新书/完结等),tab 切换(失败重试)+滚动加载,按 bookId 取书名回填作品页链接,带连通性自检+链接/热度命中率标注
scripts/jjwxc-rank-scraper.js 晋江榜单采集(收入金榜/月榜等),按频道分组
scripts/ciweimao-rank-scraper.js 刺猬猫榜单采集(点击/收藏/月票等),单页 9 榜提取,按 bookId 归一书名回填作品页链接,带连通性自检+空结果重试+链接命中率标注

语言

  • 跟随用户的语言回复,用户用什么语言就用什么语言回复
  • 中文回复遵循《中文文案排版指北》
Files (oh-story-claudecode)
  • references
    • genre-trends.md 7 KB
      # 网文题材趋势与流派参考
      题材选择、流派写作要点、扫榜技巧的速查手册。
      
      ---
      
      ## 脑洞文创作
      
      ### 从灵感到成书
      
      1. 得到点子(金手指雏形/画面/切入点)
      2. 评估潜力(纯原创?有市场铺垫?)
      3. 匹配题材(放进熟悉题材看效果)
      4. 提炼核心卖点(能否转化为情绪缺口/期待感)
      5. 梳理故事雏形(框架/金手指/切入点)
      6. 动笔验证
      
      ### 金手指创新
      
      基础=满足条件→得到反馈。变体:多条件一反馈 / 阶段性反馈 / 概率性反馈 / 反馈可叠加
      
      ### 写长方法
      
      1. 从核心卖点找多角度,归纳不同矛盾套路,每套写1-2卷
      2. 叠加框架:多角度交织循环支撑几百万字
      3. 框架+小剧情循环:大目标→中目标→小目标→细纲
      
      ### 跟风 = 换皮
      
      照搬核心设定换熟悉题材。金手指类兼容所有题材;同题材跟风保留底层情绪另换角度。
      
      ---
      
      ## 题材选择
      
      85%熟悉+15%惊喜。选择数据充分+可构建完整世界观的赛道。
      
      ---
      
      ## 边界感三层圈
      
      | 圈层 | 示例 | 规则 |
      |------|------|------|
      | 题材圈 | 都市、玄幻、仙侠 | 大分类 |
      | 流派圈 | 极道流、凡人流、种田流 | 细分 |
      | 核心梗圈 | 具体卖点/金手指/情绪点 | 内容不能踏出此圈 |
      
      | 错误 | 后果 |
      |------|------|
      | 高武文写官场 | 踏破边界 |
      | 种田文写深度商战 | 串味 |
      | 悬疑文去掉悬念 | 核心卖点消失 |
      
      ---
      
      ## 扫榜
      
      ### 三级分类
      
      | 级别 | 频率 | 要点 |
      |------|------|------|
      | 新题材 | 极少 | 只有出现跨榜单、跨作者重复样本时才标记为趋势;否则作为观察项 |
      | 新金手指 | 较少,翻新为主 | 适合作为低成本验证点,但必须换题材外壳和情绪触发方式 |
      | 新噱头 | 常见 | 高同质化风险;没有差异化机制时降低优先级 |
      
      ### 各网站方法
      
      **起点**:三江→首订图→签约新书榜前15→拆精品到万订之间的书
      **番茄**:新书榜拉到底→在读30万以上为标准
      
      ### 起点热门题材
      
      | 题材 | 要点 |
      |------|------|
      | 都市 | 重生/种田/娱乐;重生可与任何元素融合 |
      | 玄幻 | 极道流、加点横推等强反馈样本优先;传统玄幻若近期样本稀疏,标记为高风险 |
      | 仙侠 | 优先检查脑洞融合路线的近期样本;纯传统路线需额外验证受众 |
      | 历史 | 秦/唐/明/宋/三国;类同人写法 |
      | 诸天无限 | 不要写原创副本,用同人 |
      | 科幻 | 末世类(含脑洞/种田/游戏设定) |
      
      ---
      
      ## 流派写作要点
      
      | 流派 | 核心要点 |
      |------|----------|
      | 极道流 | 金手指属性加点,横推一切;边界清楚、验证成本低,适合作为低复杂度候选 |
      | 凡人流 | 小人物攀爬+精打细算;战力数据化+寿命驱动 |
      | 巫师流 | 知识体系+实验探索升级,门槛较高 |
      | 种田流 | 经营餐馆/书店/渔场等;复杂度较低,可与多界穿梭结合,但仍需样本校验 |
      | 都市高武 | 高频:武馆+学校双线/死党对比/师姐诱惑/模拟考装逼;《武圣》前三章是黄金模板 |
      | 同人文 | 仅在用户明确目标 IP、平台允许且近期样本有效时使用;默认只抽“已知世界+新变量+名场面改写”结构 |
      | 盘点文 | 难度低,找对切入点 |
      | 模拟文 | 模拟制造情绪缺口→得奖励→现实满足 |
      | 两界穿梭 | 标准模板:金手指+震惊历史人物+改变命运 |
      | 历史文 | 秦/唐/明/宋/三国主流;需危机感+从小到大+清晰主线;宋朝以后审核风险高 |
      
      ---
      
      ## 都市重生文
      
      | 维度 | 要点 |
      |------|------|
      | 事业感情配比 | 五五开上限最高但要求极高;感情弱→8:2,感情行→2:8起步 |
      | 期待感 | 没有升级线必须持续维持期待感;悬疑反转少用;保持两万字规划连续性 |
      | 短视频化 | 每章有几个逗人一笑的点;搭建正反馈人际网循环玩梗;同一梗反复出现读者不厌倦 |
      | 同质化 | 微创新开头+中正内容=足够精品;缺的是流量不是内容 |
      
      ---
      
      ## 轻小说
      
      核心=卖有戏剧性的人设(尤其美少女人设)。
      
      - 事业线:美少女当前困境+她与别人的恩怨
      - 爱情线:有戏剧性的发展+人设搭配造成事件
      - 核心竞争力:人设设计+互动状态是否令人羡慕
      
      ---
      
      ## 喜剧/沙雕文
      
      套路:中计掉坑+弄巧成拙 / 降智反派 / 误解错位,可组合。
      底层原理:根据读者对角色好感度,让剧情对角色的忧惧因素产生攻击性。
      
      ---
      
      ## 霸道总裁文(女频)
      
      | 维度 | 传统 | 现代 |
      |------|------|------|
      | 女主 | 傻白甜 | 独立职业女性 |
      | 相遇逻辑 | 善良行为偶遇 | 职业交集+独特价值 |
      | 男主便利 | 全方位照顾 | 情绪价值+锦上添花 |
      | 底层逻辑 | 嫁好男人不用干 | 我很强+征服了强者的心 |
      
      情敌反派避免传统"绿茶"模式。
      
      ---
      
      ## 爆款灵感素材库
      
      ### 古言/宫闱宅斗
      
      | 标题 | 一句话卖点 |
      |------|-----------|
      | 重生后公府长媳要跑路,首辅大人疯魔了 | 前世替公府操碎心还被逼腾位,重生后决定卷走一切直接跑路 |
      | 权臣偏宠掌上娇,重生真千金杀疯了 | 真千金重生归来,先盯上将来权倾天下的养子 |
      | 惨死重生,捂不热的权臣我不要了 | 为妾五年被锁枯院,重生后捂不热的权臣不要了 |
      | 被逼替嫁我不慌,我送全家火葬场 | 被逼替嫁我不慌,我送全家火葬场 |
      
      ### 现言/总裁豪门
      
      | 标题 | 一句话卖点 |
      |------|-----------|
      | 逼我给青梅打官司,离婚你哭什么 | 逼我给青梅打官司,离婚你哭什么 |
      | 真千金出狱后,全家悔哭求原谅 | 亲爹亲哥联手送进监狱为假千金顶罪 |
      
      ### 世情复仇
      
      | 标题 | 一句话卖点 |
      |------|-----------|
      | 班主任断我舞台梦,十五年后我封杀她女儿 | 年会试镜在评委席亲手刷掉班主任的女儿 |
      | 伤害转移100次,我妈终于杀疯了 | 病床上挨满百次剧痛后,妈妈说该你们还了 |
      
      ### 都市/年代重生
      
      | 标题 | 一句话卖点 |
      |------|-----------|
      | 高武:开局就氪命,你小子是个狠人! | 开局被亲人陷害,拿寿命换武学 |
      | 重生八零,我选择成全教授妻子和妹夫 | 重生回龙凤胎惨死前夜,先拍离婚书 |
      
      ### 玄幻/历史/末世
      
      | 标题 | 一句话卖点 |
      |------|-----------|
      | 剑仙:从杀敌爆经验开始无敌 | 靠杀敌爆经验从苟命少年杀成无敌剑仙 |
      | 全宗通魔,唯有小师弟正的发邪 | "那便恭请师尊赴死" |
      | 被妹妹推去喂丧尸后,我重生囤满物资 | 被推进丧尸群死过一次后,先在地下藏满十年物资 |
      
      ---
      
      ## 命名规律
      
      ### 短篇公式
      
      `[极端事件] + [被逼/死亡] + [反击手段]`
      
      高频词:疯了/杀疯了/悔疯了(17次) | 我反手/我让他(4次) | 全家/所有人都(4次) | 清明节/愚人节/除夕夜(4次)
      
      ### 长篇公式
      
      高频词:快穿、系统、一见钟情、离婚后、反派、开局秒杀
      
      ### 知乎风格
      
      平均7.9字。类型:事件型 / 反差型 / 互动型
      
    • publishing-guide.md 5.1 KB
      # 网文平台运营与书名简介指南
      
      ## 目录
      
      1. [平台选择与定位](#平台选择与定位)
      2. [推荐机制](#推荐机制)
      3. [数据指标解读](#数据指标解读)
      4. [简介写法](#简介写法)
      
      ---
      
      ## 平台选择与定位
      
      ### 主流平台对比
      
      | 平台 | 特点 | 适配条件 |
      |------|------|----------|
      | 起点 | 主站标杆,订阅制,推荐机制完善 | 男频长线、追读驱动、能持续展开主线与爽点循环 |
      | 番茄 | 免费阅读,流量大,强完读/强标签导向 | 吸量书名、快节奏、强情绪或强脑洞,需用当前榜单校验 AI 同质化风险 |
      | 七猫 | 下沉市场,强情绪流 | 强感情线、甜虐推拉、情绪密度高;合同/收益规则必须以最新官方口径为准 |
      | 晋江 | 女频/纯爱/言情主阵地 | 主体性、文案安全感、人物细节和收藏/营养液等指标可支撑时优先 |
      
      ### 选择原则
      
      - 先匹配题材与平台读者,再比较流量;不把平台规模直接等同于项目成功率
      - 合同、保底、分成、全勤等收益相关判断必须以平台最新官方规则或编辑确认信息为准,本文件只提供适配维度
      - 可同时保留主站/渠道候选,但每个平台都要列出:目标读者、题材样本、核心指标、失败风险
      - 平台选择至少检查三个方向:写法节奏、题材噱头、样本匹配度
      
      ### 女频平台收稿倾向
      
      | 平台 | 收稿倾向 |
      |------|----------|
      | 七猫 | 强情绪流,甜虐交替极限推拉;爹系男主VS大美人、追妻火葬场、60-90年代甜宠+对照组、多男主修罗场 |
      | 起点女频 | 候选前必须复核近期榜单与推荐位样本;若目标题材样本稀疏,标记为高流量风险 |
      
      ---
      
      ## 推荐机制
      
      ### 起点推荐流程
      
      ```
      出库 -> 试水期(7天) -> 培育期(21-42天) -> 三江推荐/上架
      ```
      
      **试水期**:同期新书在同一流量池,七天总流量恒定。
      
      **培育期**:四个等级:放量包++ > 放量包+ > 放量包 > 扶持包(安慰奖)。每1-2天一个流量包,共21个。下一个流量包大小取决于上一个的成绩(追读+留存)。噱头强但留存差 -> 恶性循环。
      
      **关键数据**:
      - 出库时300假追可无缝衔接试水
      - 冲三江至少500假追
      - 1000假追以下全是扶贫包,2000以上有放量+
      
      ### 起点新书榜规则
      
      上榜条件(三选一即下榜):满20万字 / 签约满30天 / 断更超3天
      
      **多待榜单技巧**:内投通过后和编辑商量,先发到3万字左右再完成签约流程,可多待4-5天。
      
      ### 番茄推荐机制
      
      - 推荐节点、测试字数、流量包规则变化频繁;执行前必须用官方后台、编辑口径或最新作者公告复核
      - 分析番茄样本时记录:验证期字数、在读数、完读率/追更、书名标签、简介钩子
      - 若当前 scan 发现 AI 同质化样本密集,输出同质化风险和差异化检查,不直接沿用历史给量数字
      - 观察数据时按项目实际字数节点和平台最新规则设置复盘点,不使用固定字数作为硬规则
      
      ### 书名简介的重要性
      
      起点新推荐机制下,书名和简介是第一道筛选:
      - 书名点明题材可保下限
      - 书名简介吸量 >> 追读 >> 读完率/书架比
      - 文艺书名在新机制下会被拉长培育期
      
      ---
      
      ## 数据指标解读
      
      ### 番茄核心数据
      
      **追更人数** = 阅读最近 7 日更新章节的有效读者数。该指标只能和同题材、同字数段、同推荐阶段样本横向比较。
      
      | 指标 | 用法 |
      |------|------|
      | 追更人数 | 判断近期读者留存和更新期待,必须标注统计周期 |
      | 在读数 | 判断流量规模,需结合书龄和推荐阶段 |
      | 完读/读完率 | 判断章节承接是否掉点;若无法获取,报告中标记为缺失 |
      | 书架/收藏 | 判断吸量后是否留下,需和书名/简介一起分析 |
      
      历史追更区间只能作为样本注释;不输出固定首秀量预测,除非有当前平台同阶段数据支撑。
      
      ### 起点核心数据
      
      - **收订比**:收藏与首订的比例,反映转化效率
      - **24小时追读**:VIP读者的实时追读,上推荐看这个数据
      - **首订数据参考**:上架首订不到100属中游水平
      
      ### 通用数据逻辑
      
      - 读完率高 = 内容质量好,追更高 = 期待感足
      - 书架比好但完读差 = 中间内容出了问题
      - 收益与追更/在读等指标相关,但具体收益受平台规则、合同和推荐阶段影响,必须另行核实
      - 数据不好时优先检查:开头吸量、章节留存、期待感断档
      
      ---
      
      ## 简介写法
      
      核心结构:主角处境 -> 金手指能干嘛 -> 第一个爽点预览。不要写成设定说明书,要写成广告。好处要说具体。
      
      **常见错误**:东扯一句西塞一句没有核心主题 | 创意名字但实际是普通积分类系统 | "且看主角如何如何"空话套话 | 自称用"俺"等过于出戏
      
      **简介结构提取**:参考三江推荐(qidian.com/sanjiang/)或畅销榜同类型爆款简介结构,只提取功能位:问题、利益点、爽点预告、安全感、悬念。禁止复刻具体剧情和表达。
      
    • reader-profiling.md 3.9 KB
      # 读者画像系统
      
      ## 9 维画像
      
      | 维度 | 包含什么 | 示例 |
      |------|----------|------|
      | 平台 | 起点/番茄/晋江/知乎/七猫 | 起点=付费硬核,番茄=免费下沉 |
      | 标签 | 题材标签决定读者预期 | 看到「系统文」期待升级节奏 |
      | 阅读量 | 低阅读量 vs 高阅读量读者,耐受度不同 | 高阅读量读者能识别套路模式,低阅读量读者对套路更敏感 |
      | 性别 | 男频女频审美差异巨大 | 男频重爽,女频重情感 |
      | 年龄 | 16-25 vs 25-35,代入角色不同 | 05后大学生 vs 90后打工人 |
      | 地域 | 一线城市 vs 下沉市场 | 北上广深 vs 三四线城市 |
      | 学历 | 影响对文笔和逻辑的要求 | 大学生 vs 初中辍学 |
      | 阶层 | 底层/中产/富人 | 决定什么样的逆袭有共鸣 |
      | 社会角色 | 学生/宝妈/打工者/自由职业 | 决定什么样的日常有代入感 |
      
      画像组合示例:「起点+古代官场+夺嫡+文抄+高阅读量+女+05后+二线城市+大学生+中产」-> 节奏快、权谋爽、感情线甜但不傻白甜、文化梗要新
      
      ---
      
      ## 画像关联元素法(70/20/10)
      
      构建读者共鸣的三层比例:
      
      - **70% 怀旧元素**:读者有过类似经历的场景
      - **20% 当前生活**:读者正在面对的压力
      - **10% 热点话题**:当下流行的梗和话题
      
      ### 70% 怀旧元素来源
      
      | 来源 | 内容 | 适用题材 |
      |------|------|----------|
      | 共同代际记忆 | 90后的网吧/MP3/非主流 | 都市重生、年代文 |
      | 流行文化 | 经典影视剧、游戏、音乐 | 同人、穿越 |
      | 校园生活 | 高考、宿舍、社团 | 校园文 |
      | 童年回忆 | 村里/小区里的事 | 乡村、都市日常 |
      
      ### 20% 当前生活来源
      
      | 来源 | 内容 | 适用题材 |
      |------|------|----------|
      | 工作压力 | 996、内卷、副业 | 都市、职场 |
      | 感情状态 | 相亲、催婚、丁克 | 言情、世情 |
      | 经济压力 | 房贷、消费降级 | 都市、种田 |
      | 社交焦虑 | 社恐、独居、社交恐惧 | 治愈、日常 |
      
      ### 10% 热点话题来源
      
      | 来源 | 内容 | 适用题材 |
      |------|------|----------|
      | 社会热点 | 新闻事件、社会议题 | 都市、现实 |
      | 网络热梗 | 梗、表情包、流行语 | 轻松向 |
      | 影视联动 | 热门影视带动题材 | 蹭热度 |
      
      ---
      
      ## 平台读者画像速查
      
      ### 起点男频读者
      
      - 核心需求:爽感、升级、碾压
      - 容忍度:开头慢可以,但不能超过5章
      - 爽点偏好:实力碾压 > 智谋取胜 > 运气好
      - 雷区:绿帽、憋屈、女主太强压过男主
      - 黄金标准:三章一爽点,十章一大高潮
      
      ### 番茄读者
      
      - 核心需求:快节奏、强爽感、不费脑
      - 容忍度:3章不爽就走
      - 爽点偏好:打脸 > 装逼 > 获得机缘
      - 雷区:慢热、虐主、需要思考的剧情
      - 黄金标准:每章都有爽点,3000字内必须爆
      
      ### 晋江女频读者
      
      - 核心需求:情感细腻、人设好、有共鸣
      - 容忍度:开头可以慢,但人设必须立住
      - 爽点偏好:虐完后的甜 > 反转打脸 > 身份翻盘
      - 雷区:女主傻白甜、男主渣且不追妻、三观不正
      - 黄金标准:人物有弧光,情感有层次
      
      ### 知乎盐言读者
      
      - 核心需求:情绪冲击、反转、代入感
      - 容忍度:前500字决定生死
      - 爽点偏好:反转震撼 > 虐后治愈 > 现实共鸣
      - 雷区:说教、道德绑架、结局强行HE
      - 黄金标准:情绪拉扯 + 一个炸裂反转
      
      ### 七猫读者
      
      - 核心需求:快节奏、强剧情、不拖沓
      - 容忍度:3章定生死
      - 爽点偏好:逆袭 > 打脸 > 获得认可
      - 雷区:文艺腔、节奏慢、不爽
      - 黄金标准:开篇即冲突,绝不平淡
      
      ### 刺猬猫读者
      
      - 核心需求:二次元、轻小说、有趣
      - 容忍度:开头必须有趣
      - 爽点偏好:有趣 > 有梗 > 有梗但意想不到
      - 雷区:太严肃、太现实、没有二次元元素
      - 黄金标准:有梗有笑点,偶尔有深度
      
    • scan-output-format.md 7.5 KB
      # 扫榜数据采集格式规范
      定义起点/番茄/七猫/晋江的采集字段、输出模板和清洗规则。
      
      ---
      
      ## 起点
      
      ### 起点采集说明
      
      榜单清单与 URL 见 SKILL.md「起点采集目标」表。
      
      优先使用 `scripts/qidian-rank-scraper.js` 的默认 `--mode auto`。脚本先读取 `https://m.qidian.com` 移动端 SSR pageContext JSON,规避 PC 站风控页;移动端不可用时才回退到 CDP/PC 页面。输出头部会标注 `抓取方式:mobile-ssr` 或 `cdp-pc`。
      
      
      ### 字段
      
      排名 | 书名 | 作者 | 题材 | 状态 | 签约 | 收费模式 | 字数(万字) | 总推荐 | 标签(详情页) | 最新更新(详情页) | 作品页链接 | 简介(详情页,截断100字)
      
      ### 输出模板
      
      ```markdown
      # qidian · {榜单名称}
      - 来源:{榜单URL}
      - 抓取时间:{ISO 8601}
      - 条目数:{N}
      
      ---
      
      ## #{排名} {书名}
      *{作者} · {题材} · {状态} · {签约} · {免费/VIP} · {字数}万字 · {推荐数}总推荐*
      **标签:** {标签}
      **最新更新:** {YYYY-MM-DD HH:MM:SS} · {章节标题}
      
      [作品页]({URL})
      
      **简介**
      {简介原文}
      ```
      
      ### 采集要点
      
      榜单页含:排名/书名/作者/题材/字数/推荐/签约/免费VIP。详情页需:标签/最新更新/简介。三江按周分组。
      
      ---
      
      ## 番茄小说
      
      榜单 URL 格式与参数说明见 SKILL.md「番茄采集目标」表。
      
      ### 题材cat_id
      
      男频19个:西方奇幻(1141) / 东方仙侠(1140) / 科幻末世(8) / 都市日常(261) / 都市修真(124) / 都市高武(1014) / 历史古代(273) / 战神赘婿(27) / 都市种田(263) / 传统玄幻(258) / 历史脑洞(272) / 悬疑脑洞(539) / 都市脑洞(262) / 玄幻脑洞(257) / 悬疑灵异(751) / 抗战谍战(504) / 游戏体育(746) / 动漫衍生(718) / 男频衍生(1016)
      
      女频18个:古风世情(1139) / 科幻末世(8) / 游戏体育(746) / 女频衍生(1015) / 玄幻言情(248) / 种田(23) / 年代(79) / 现言脑洞(267) / 宫斗宅斗(246) / 悬疑脑洞(539) / 古言脑洞(253) / 快穿(24) / 青春甜宠(749) / 星光璀璨(745) / 女频悬疑(747) / 职场婚恋(750) / 豪门总裁(748) / 民国言情(1017)
      
      ### 字段
      
      排名 | 书名(需详情页解码) | 作者(需详情页解码) | 题材(详情页 categoryV2) | 状态 | 在读(核心指标) | 字数 | 标签(简介内【】) | 最新更新 | bookId | 作品页链接 | 简介(截断100字)
      
      > 番茄 SSR 详情页**没有数字评分**,故不输出评分。题材取详情页 `categoryV2`(转义 JSON 的首个 `Name`,如「西方奇幻」);标签取简介开头的 `【tag+tag+...】`(如「种田、慢热、西幻」),是题材细分的真实信号。
      
      ### 输出模板
      
      ```markdown
      # 番茄 · {频道}{榜单名} · 全 {N} 题材
      - 频道参数:channel={0女频/1男频},type={1新书榜/2阅读榜}
      - 抓取时间:{ISO 8601}
      - 标题解析:成功 {X} / 共 {Y}
      - 数据质量:[OK / 标题解析异常 / 无数据]
      - 每题材上限 ≈ {N}(cap≈20)
      
      ---
      
      ## {题材名称} — {N} 本
      
      ### #{排名} {书名}
      *{作者} · {题材} · {状态} · {在读数} 在读 · {字数}字*
      **标签:** {标签1、标签2}
      **最新更新:** {章节}
      **bookId:** {bookId}
      
      [作品页]({URL})
      
      **简介**
      {简介原文}
      ```
      
      > 标题/作者/题材/标签/简介均为可选字段:详情页拿到才输出。书名解码失败时书名显示 `(标题待解析)`,但 bookId 与作品页链接始终保留,便于人工回查。
      
      ### 采集要点
      
      字体反爬:列表页 innerText 被自定义字体混淆,`scripts/fanqie-rank-scraper.js` 改从详情页 HTML(内嵌 JSON `bookName`/`author`/`abstract`/`categoryV2` + `<title>` + og:meta)多策略解码明文,规避字体反爬。流程:访问品类页 → 提取品类链接 → 逐品类取 `__INITIAL_STATE__` 列表 → 分批(每 5 本)请求详情页解码。单页上限约 20 本需滚动加载;`--top N` 可调每题材上限。
      
      **故障排查(书名全是 `bookId:xxx` / `(标题待解析)`)**:
      - 看文件头 `数据质量`:标 `[标题解析异常]` 说明详情页解码失败率高。
      - 多为详情页结构变动或被登录/验证页拦截。在已登录的 Chrome 里手动打开任一 `https://fanqienovel.com/page/{bookId}` 确认页面正常、非验证页。
      - 控制台若报 `CDP 无响应`,说明 Chrome/CDP 没起来或端口不对,按 browser-cdp skill 重新启动。确认正常后重采。
      
      ---
      
      ## 七猫
      
      ### 榜单
      
      入口:qimao.com/paihang,男生榜/女生榜tab切换。类型:大热榜(日/月) / 新书榜 / 完结榜 / 收藏榜 / 更新榜
      
      ### 字段
      
      排名 | 书名 | 作者 | 题材 | 分类标签 | 状态 | 字数(万字) | 热度(核心指标) | 最新更新 | 作品页链接 | 简介(截断100字)
      
      ### 输出模板
      
      ```markdown
      # 七猫 · {男/女}频 · {榜单名称}
      - 来源:qimao.com/paihang
      - 抓取时间:{ISO 8601}
      - 条目数:{N}
      
      ---
      
      ### #{排名} {书名}
      *{作者} · {题材} · {分类标签} · {状态} · {字数}万字 · {热度}万热度*
      **最新更新:** {时间} · {章节}
      
      [作品页]({URL})
      
      **简介**
      {简介原文}
      ```
      
      ### 采集要点
      
      无明显反爬需滚动加载。男生榜/女生榜tab切换,大热榜有日/月切换。
      
      ---
      
      ## 晋江
      
      ### 榜单URL
      
      `jjwxc.net/topten.php?orderstr={榜单ID}&t={频道ID}`(t=0全站,各频道ID从页面获取)
      
      | 榜单 | orderstr |
      |------|----------|
      | 收入金榜 | 12 |
      | 月榜 | 7 |
      | 季度榜 | 8 |
      | 完结金榜 | 14 |
      | 新手金榜 | 15 |
      | 千字金榜 | 17 |
      
      ### 字段
      
      频道 | 排名 | 书名 | 作者 | novelid | 收藏数(核心) | 营养液 | 积分 | 字数 | 状态 | 作品页链接
      
      ### 输出模板
      
      ```markdown
      # 晋江 · {榜单名}
      - 来源:{topten URL}
      - 抓取时间:{ISO 8601}
      - 频道数:{N} / 总条目数:{M}
      - 详情采集:{命中收藏数} / {计划数}(每频道前 {top},上限 {limit})
      - 数据质量:[OK / 详情解析异常·登录态缺失 / 仅列表-无核心指标]
      
      ---
      
      ## {频道名} — {N} 本
      
      ### #{排名} {书名}
      *{作者} · 收藏 {X} · 营养液 {Y} · 积分 {Z} · 字数 {W}字 · {状态}*
      [作品页](https://www.jjwxc.net/onebook.php?novelid={id})
      ```
      
      ### 采集要点
      
      两步:① 列表页 `topten.php` 取频道分组 + 书名/作者,从书名 anchor 取 `novelid`(排除"X向《书名》投了Y"霸王票记录);② 进 `onebook.php?novelid=` 详情页补采核心指标。
      - **编码**:晋江是 gb18030,详情页必须 `fetch+arrayBuffer+TextDecoder('gb18030')` 解码(同步 XHR 的 responseText 按 UTF-8 解码会乱码)。
      - **字段来源**:详情页 `itemprop` 微数据——`collectedCount`(收藏)/`nutritionCount`(营养液)/`scoreCount`(积分)/`wordCount`(字数)/`updataStatus`(状态)。这些是公开指标,**无需登录**。
      - **控量**:列表全量保留,仅每频道前 `--top` 本(受 `--detail-limit` 总量约束)补详情,避免对全站数百本逐一请求。
      
      ---
      
      ## 数据清洗
      
      通用:移除平台模板文本→简介超100字在句号处截断加`...`→空值标`[待补]`
      
      | 平台 | 额外必填 |
      |------|----------|
      | 起点 | 题材、字数、总推荐 |
      | 番茄 | 在读数 |
      | 七猫 | 热度 |
      | 晋江 | 收藏数、营养液(或积分)、字数 |
      
      最低采集量:主流平台15条,小平台10条。低于底线标`[数据稀疏]`。
      
      ---
      
      ## 批量采集
      
      | 平台 | 默认组合 |
      |------|----------|
      | 起点 | 新人签约新书榜+签约作者新书榜前20+月票榜前20+畅销榜前20 |
      | 番茄 | 男频阅读榜全题材+女频阅读榜全题材 |
      | 七猫 | 男频大热榜日榜+女频大热榜日榜 |
      | 晋江 | 收入金榜+月榜 |
      | 全平台 | 起点+番茄+七猫默认组合 |
      
    • topic-decision.md 4.1 KB
      # 选题决策:从扫榜数据到"写什么能爆"
      
      把扫榜结果变成能直接用的选题建议:**推荐写什么、为什么能爆、行不行、怎么验证**。
      Phase 5 用本文方法产出 `选题决策.md`——市场角度的选题初稿:"能爆的原因"先当假设,拆文(story-long-analyze)后回填验证,写作(story-long-write)开书时直接用。
      
      ---
      
      ## 决策路由
      
      | 你在做什么 | 看这节 |
      |-----------|--------|
      | 把扫榜结果变成选题建议 | 选题四步 |
      | 判断一个方向行不行 | 可行性判断 |
      | 没联网/没榜单数据 | 内置知识模式 |
      | 落盘交付 | 选题决策.md 模板 + 交付 |
      
      ---
      
      ## 选题四步
      
      每个推荐选题都走完这四步,缺一不可:
      
      1. **能爆的原因(先当假设)**:从榜上**重复出现的样本**(排除单本个例)+ 新元素提取,推出"这个方向能爆,依靠 X 结构/梗/人设吃这波读者"。单本上榜只是个例(扫榜原则 1:多本重复才算信号)。这里只写假设,标 `待拆文验证`;要坐实得靠拆文回填。
      2. **市场验证**:榜上同方向有几本 + 趋势(↑/→/↓)+ 反例(同方向有没有扑街/烂尾的)。样本越多、趋势越稳,越可信。
      3. **差异化定位**:作者优势 × 市场缺口 = "你的版本和榜上现有的不同在哪"。没有差异化 = 同质化挤不进去。
      4. **可行性 + 风险 + 验证动作**:给出可行性高/中/低(下节规则)、最可能的失败点、开写前怎么低成本验证(通常:"先写黄金三章测追读,不行就换")。
      
      ---
      
      ## 可行性判断
      
      分三档;样本不够时不许给"高",别让几条数据撑出假信心:
      
      | 可行性 | 含义 | 条件 |
      |--------|------|------|
      | 高 | 可放心写 | 同方向榜上样本够(≥15,小平台≥10)+ 趋势↑或→ + 作者素材能撑 + 有差异化空间 |
      | 中 | 可写但先验证 | 样本够但趋势↓ / 或差异化不清 / 或作者素材半撑 |
      | 低 | 不建议 | 同方向已饱和(扑街反例多)/ 或作者素材撑不住 / 或平台调性不符 |
      
      **硬规则**:某方向背靠的榜单若标了 `[数据稀疏]`(有效条目 < 15,小平台 < 10,即扫榜采集质量检查标的稀疏阈值),该方向**不许给"高"**,强制降到"中" + 写明"样本不足,先扫够样本或试水再定"。
      
      **内置知识模式**:无榜单、纯凭知识库趋势时,**所有方向一律给"中"**,理由写"基于通用认知,无榜单验证,开写前必须先扫榜或小成本试水"。不许给"高"。
      
      ---
      
      ## 选题决策.md 模板
      
      ```
      # 选题决策:{平台/方向}
      - 扫榜日期:{YYYYMMDD}     # 数据新鲜度;写作读取时若过期会提示复扫
      - 数据来源:{榜单文件名 / 内置知识}
      
      ## 推荐选题
      
      ### 选题 1:{一句话方向}
      - 题材组合:{主类型 + 副类型/梗}
      - 目标读者:{画像}
      - 核心卖点:{读者为什么追}
      - 能爆的原因:{X 结构/梗为什么吃这波}(假设,`待拆文验证`)
      - 差异化定位:{和榜上现有的不同在哪}
      - 可行性:高/中/低 — {理由,写清同方向几本 + 趋势}
      - 失败风险:{最可能崩在哪}
      - 验证动作:{开写前怎么低成本验证}
      - 篇幅/平台:{建议字数区间 + 目标平台}
      
      ### 选题 2 ...
      ### 选题 3 ...
      ```
      
      2-3 个选题为宜,按可行性排序(高的在前)。
      
      ---
      
      ## 交付
      
      1. 写到本次扫榜的输出目录(和榜单文件同一个目录):`{outdir}/选题决策.md`。扫榜常在还没有小说项目的目录下跑,所以产物落在这里,不直接写进项目。
      2. 告知用户路径,并说明下一步:「开书时如果小说项目就建在这个目录里、或和这个目录同级(共用一个上级目录),`/story-long-write` 会自动找到并让你确认;隔得更远就把 `选题决策.md` 复制到小说项目根目录,或开书时把路径粘给它。想确认"能爆的原因"就先 `/story-long-analyze` 拆对标书,拆完会回填。」
      3. 后续衔接(本文件不执行,仅说明):拆文在汇总报告产出后回填对应选题的"能爆的原因";写作 Phase 1 读取 `选题决策.md` 作为开书起点。
      
  • scripts
    • cdp-utils.js 8.4 KB
      /**
       * 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,
      };
      
    • ciweimao-rank-scraper.js 8.8 KB
      #!/usr/bin/env node
      /**
       * 刺猬猫阅读排行榜采集脚本
       *
       * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
       * 采集策略:刺猬猫 rank-index 页面单页展示所有榜单,文本解析提取结构化数据。
       * 输出 Markdown 格式匹配 scan-output-format.md 规范。
       *
       * 用法:
       *   node ciweimao-rank-scraper.js --type click       # 点击榜
       *   node ciweimao-rank-scraper.js --type monthly      # 月票榜
       *   node ciweimao-rank-scraper.js --type all           # 全部榜单
       *
       * 前置:
       *   node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
       */
      
      const fs = require("fs");
      const path = require("path");
      const { ab, sleep, evalJSONBase64, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");
      
      const RANK_URL = "https://www.ciweimao.com/rank-index";
      
      /** 连通性 + 页面就绪自检 */
      function probePage(port) {
        return evalJSONBase64(
          port,
          "JSON.stringify({host:location.host,len:(document.body&&document.body.innerText||'').length})"
        );
      }
      
      const RANK_TYPES = [
        { id: "click", label: "点击榜", header: "点击榜" },
        { id: "favor", label: "收藏榜", header: "收藏榜" },
        { id: "recommend", label: "推荐榜", header: "推荐榜" },
        { id: "subscribe", label: "订阅榜", header: "订阅榜" },
        { id: "monthly", label: "月票榜", header: "月票榜" },
        { id: "tsukkomi", label: "吐槽榜", header: "吐槽榜" },
        { id: "newbook", label: "新书榜", header: "新书榜" },
        { id: "blade", label: "刀片榜", header: "刀片榜" },
        { id: "update", label: "更新榜", header: "更新榜" },
      ];
      
      // ---------------------------------------------------------------------------
      // 页面提取
      // ---------------------------------------------------------------------------
      
      /**
       * 从 rank-index 单页解析所有榜单。
       * 页面结构:每个榜单有标题行(如"点击榜"),后跟 NO.1 特殊条目 + #2-10 普通条目。
       * NO.1 格式:标题 / 作者 / 指标值(三行)
       * #2-10 格式:N[题材]书名 / 指标值(两行)
       */
      function extractAllRanks(port) {
        const js =
          "JSON.stringify((()=>{" +
          "var text=document.body.innerText||'';" +
          "var lines=text.split(/\\n/).map(function(l){return l.trim()}).filter(Boolean);" +
          "var headers=['点击榜','收藏榜','推荐榜','订阅榜','月票榜','吐槽榜','新书榜','刀片榜','更新榜'];" +
          "var sections=[];var curName='';var curEntries=[];" +
          "for(var i=0;i<lines.length;i++){" +
          "  var line=lines[i];" +
          // 检测新 section
          "  var headerIdx=headers.indexOf(line);" +
          "  if(headerIdx>=0){" +
          "    if(curName&&curEntries.length)sections.push({name:curName,entries:curEntries});" +
          "    curName=headers[headerIdx];curEntries=[];continue" +
          "  }" +
          "  if(!curName)continue;" +
          // 跳过周期 tab 和 UI 文字
          "  if(/^(周榜|月榜|总榜)$/.test(line))continue;" +
          // NO.1 条目
          "  if(line==='NO.1'&&i+3<lines.length){" +
          "    var t=lines[i+1]||'';var a=lines[i+2]||'';var v=lines[i+3]||'';" +
          "    if(headers.indexOf(v)>=0)continue;" +
          "    curEntries.push({rank:1,title:t,author:a,genre:'',metric:v});" +
          "    i+=2;continue" +
          "  }" +
          // #2-10 条目:N[题材]书名
          "  var rm=line.match(/^(\\d{1,2})\\[(.+?)\\](.+)$/);" +
          "  if(rm){" +
          "    var nextVal=i+1<lines.length?lines[i+1]:'';" +
          "    var metric='';" +
          "    if(/^[\\d.]+(万)?$/.test(nextVal)){metric=nextVal;i++}" +
          "    curEntries.push({rank:parseInt(rm[1]),title:rm[3],author:'',genre:rm[2],metric:metric});" +
          "    continue" +
          "  }" +
          "}" +
          "if(curName&&curEntries.length)sections.push({name:curName,entries:curEntries});" +
          "return sections" +
          "})())";
        return evalJSONBase64(port, js) || [];
      }
      
      /**
       * 从 DOM 获取书籍链接。每本书常有封面图 anchor(textContent 为空)和书名 anchor,
       * 按 bookId 聚合后取最长的非空文本作为书名,避免空封面 anchor 覆盖书名导致回填全失败。
       */
      function extractBookUrls(port) {
        const js = `JSON.stringify((function(){
          function clean(t){return t.replace(/^[0-9]+\\[[^\\]]*\\]/,'').replace(/\\s+[0-9.]+(?:万|亿)?$/,'').trim();}
          var byId={};var order=[];
          Array.from(document.querySelectorAll('a[href*="/book/"]')).forEach(function(a){
            var h=a.getAttribute('href')||a.href||'';
            var m=h.match(/\\/book\\/([0-9]+)/);
            if(!m)return; var id=m[1];
            var t=clean((a.innerText||a.textContent||'').replace(/\\s+/g,' ').trim());
            if(!byId[id]){byId[id]='';order.push(id);}
            if(t&&t.length>byId[id].length)byId[id]=t;
          });
          return order.map(function(id){return {bookId:id,title:byId[id],url:'https://www.ciweimao.com/book/'+id};});
        })())`;
        return evalJSONBase64(port, js) || [];
      }
      
      // ---------------------------------------------------------------------------
      // 主流程
      // ---------------------------------------------------------------------------
      
      const args = process.argv.slice(2);
      const PORT = parseInt(getArg(args, "--port") || "9222", 10);
      const OUTDIR = getArg(args, "--outdir") || ".";
      const RANKTYPE = getArg(args, "--type") || "all";
      
      function main() {
        console.log("\n→ 采集 刺猬猫排行榜...");
        console.log(`  URL: ${RANK_URL}`);
      
        let sections, urls;
        try {
          ab(PORT, "open", RANK_URL);
          sleep(4000);
      
          // 连通性自检:CDP 未起/被重定向时给可操作报错,而非误报"结构已变"
          const probe = probePage(PORT);
          if (!probe) {
            console.error(
              `  ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${PORT}),且 agent-browser 可用。`
            );
            return 0;
          }
          if (probe.host && probe.host.indexOf("ciweimao") === -1) {
            console.error(`  ✗ 当前页面非刺猬猫(host=${probe.host}),可能被重定向,已跳过。`);
            return 0;
          }
      
          scrollLoad(PORT, 3);
          sleep(1000);
      
          sections = extractAllRanks(PORT);
          if (!sections.length) {
            // 懒加载可能未触发,再滚动重试一次
            scrollLoad(PORT, 2);
            sleep(1000);
            sections = extractAllRanks(PORT);
          }
          if (!sections.length) {
            console.error("[ciweimao] 采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开榜单页确认。");
            return 0;
          }
      
          urls = extractBookUrls(PORT);
        } catch (err) {
          console.error(`[ciweimao] 采集失败(页面加载或提取阶段): ${err.message}`);
          return 0;
        }
      
        console.log(`  ✓ 提取 ${sections.length} 个榜单,${urls.length} 个书籍链接`);
      
        // 筛选需要的榜单类型
        const targetTypes =
          RANKTYPE === "all"
            ? RANK_TYPES
            : RANK_TYPES.filter((r) => r.id === RANKTYPE);
      
        let written = 0;
        for (const rt of targetTypes) {
          try {
            const section = sections.find((s) => s.name === rt.header);
            if (!section || !section.entries.length) {
              console.log(`  ⚠ ${rt.label} 无数据,跳过`);
              continue;
            }
      
            const now = new Date().toISOString();
            const norm = (s) => (s || "").replace(/\s+/g, "");
            const linked = section.entries.filter((e) =>
              urls.some((u) => norm(u.title) === norm(e.title))
            ).length;
            const lines = [
              `# 刺猬猫 · ${rt.label}`,
              "",
              `- 来源:${RANK_URL}`,
              `- 抓取时间:${now}`,
              `- 条目数:${section.entries.length}`,
              `- 作品页链接:${linked} / ${section.entries.length}`,
              "",
              "---",
              "",
            ];
      
            for (const entry of section.entries) {
              try {
                lines.push(`### #${entry.rank} ${entry.title}`);
                const meta = [
                  entry.author,
                  entry.genre,
                  entry.metric || "",
                ].filter(Boolean).join(" · ");
                if (meta) lines.push(`*${meta}*`);
      
                // 按标题匹配书籍链接(归一后比对)
                const matched = urls.find((u) => norm(u.title) === norm(entry.title));
                if (matched) {
                  lines.push(`[作品页](${matched.url})`);
                }
      
                lines.push("", "---", "");
              } catch (entryErr) {
                console.error(`[ciweimao] ${rt.label} 条目处理出错(#${entry.rank} ${entry.title}): ${entryErr.message}`);
                lines.push("", "---", "");
              }
            }
      
            const filename = `刺猬猫${rt.label}_${localDateStamp()}.md`;
            fs.mkdirSync(OUTDIR, { recursive: true });
            const filepath = path.join(OUTDIR, filename);
            fs.writeFileSync(filepath, lines.join("\n"), "utf-8");
            written++;
            console.log(`  ✓ ${rt.label}:${section.entries.length} 条 → ${filepath}`);
          } catch (rankErr) {
            console.error(`[ciweimao] ${rt.label} 处理出错,跳过: ${rankErr.message}`);
          }
        }
        return written;
      }
      
      if (require.main === module) {
        runCli(main, "刺猬猫采集");
      }
      
      module.exports = { extractAllRanks, extractBookUrls };
      
    • fanqie-rank-scraper.js 15.7 KB
      #!/usr/bin/env node
      /**
       * 番茄小说排行榜采集脚本
       *
       * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
       * 采集策略:从榜单页 __INITIAL_STATE__ 取结构化列表,再逐本请求详情页解码真实
       * 书名/作者/简介/题材/标签(番茄列表页有字体反爬,详情页 HTML 里是明文)。
       * 输出 Markdown 格式匹配 scan-output-format.md 规范。
       *
       * 用法:
       *   node fanqie-rank-scraper.js --channel 1 --type 2              # 男频阅读榜
       *   node fanqie-rank-scraper.js --channel 0 --type 1              # 女频新书榜
       *   node fanqie-rank-scraper.js --channel 1 --type 2 --outdir ./  # 指定输出目录
       *   node fanqie-rank-scraper.js --channel all                     # 全部采集
       *   node fanqie-rank-scraper.js --channel 1 --top 15              # 每题材只取前 15 本
       *
       * 前置:
       *   node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
       */
      
      const fs = require("fs");
      const path = require("path");
      const { ab, sleep, evalJSONBase64, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");
      
      // 一次详情请求的并发批大小。番茄详情页用同步 XHR 拉取,批太大会撞上
      // cdp-utils 里 ab() 的 20s 超时;超时会显式失败,这里分批是为了避免整个题材被中断。
      const DETAIL_CHUNK = 5;
      
      // ---------------------------------------------------------------------------
      // 页面提取
      // ---------------------------------------------------------------------------
      
      /** 连通性 + 页面就绪自检 */
      function probePage(port) {
        return evalJSONBase64(
          port,
          "JSON.stringify({host:location.host,hasState:!!window.__INITIAL_STATE__})"
        );
      }
      
      /** 构建:提取侧边菜单品类链接的浏览器 JS */
      function buildCategoriesJS(prefix) {
        return `JSON.stringify((function(){
          var prefix=${JSON.stringify(prefix)};
          var out=[];var seen={};
          Array.from(document.querySelectorAll('a')).forEach(function(a){
            var href=a.getAttribute('href')||'';
            if(href.indexOf(prefix)===-1)return;
            var name=(a.innerText||a.textContent||'').trim();
            if(!name)return;
            if(seen[href])return;seen[href]=1;
            out.push({name:name,href:href});
          });
          return out;
        })())`;
      }
      
      /** 提取侧边菜单品类链接 */
      function extractCategories(port, channel, type) {
        const prefix = `/rank/${channel}_${type}_`;
        return evalJSONBase64(port, buildCategoriesJS(prefix)) || [];
      }
      
      /**
       * 从 __INITIAL_STATE__ 提取当前品类页的作品列表。
       * 多路径尝试 + 深度兜底扫描,并把字段名归一,避免站点改 state 结构就全盘失败。
       */
      function buildBookListJS() {
        return `JSON.stringify((function(){
          var s=window.__INITIAL_STATE__||{};
          var cands=[
            s.rank&&s.rank.book_list, s.rank&&s.rank.bookList, s.rank&&s.rank.rankList,
            s.rankData&&s.rankData.book_list, s.page&&s.page.book_list
          ];
          var list=null;
          for(var i=0;i<cands.length;i++){ if(Array.isArray(cands[i])&&cands[i].length){list=cands[i];break;} }
          if(!list){
            var found=null;
            (function walk(o,d){
              if(found||!o||d>6)return;
              if(Array.isArray(o)){
                if(o.length&&o[0]&&typeof o[0]==='object'&&(o[0].bookId||o[0].book_id)){found=o;return;}
                for(var j=0;j<o.length&&!found;j++)walk(o[j],d+1);return;
              }
              if(typeof o==='object'){ for(var k in o){ if(found)break; try{walk(o[k],d+1)}catch(e){} } }
            })(s,0);
            list=found||[];
          }
          return list.map(function(b){return {
            bookId:String(b.bookId||b.book_id||''),
            read_count:b.read_count||b.readCount||b.read||'',
            wordNumber:b.wordNumber||b.word_number||b.wordCount||'',
            creationStatus:(b.creationStatus!=null?b.creationStatus:(b.creation_status!=null?b.creation_status:b.status)),
            lastChapterTitle:b.lastChapterTitle||b.last_chapter_title||b.lastChapter||'',
            category:b.category||b.categoryName||b.category_name||''
          };}).filter(function(b){return b.bookId;});
        })())`;
      }
      
      function extractBookList(port) {
        const list = evalJSONBase64(port, buildBookListJS());
        return Array.isArray(list) ? list : [];
      }
      
      /**
       * 批量解码详情:逐本同步 XHR 请求 /page/{id},多策略解析明文字段。
       * 番茄列表页书名/作者被字体反爬,详情页 HTML 内嵌 JSON 与 <title> 是明文。
       * 字段名以真实 SSR(__INITIAL_STATE__) 为准:bookName/author/abstract 明文,
       * 题材在 categoryV2(转义 JSON 数组的首个 Name),番茄 SSR 不含数字评分。
       * 返回 { id: {title, author, desc, category, tags} }。
       */
      function buildDetailJS(ids) {
        return `JSON.stringify((function(){
          var ids=${JSON.stringify(ids)};
          var map={};
          function pick(h,res){for(var i=0;i<res.length;i++){var m=h.match(res[i]);if(m&&m[1])return m[1].trim();}return '';}
          for(var k=0;k<ids.length;k++){
            var id=ids[k];
            try{
              var x=new XMLHttpRequest();
              x.open('GET','/page/'+id,false);
              x.send();
              var h=x.responseText||'';
              var title=pick(h,[
                /"bookName"\\s*:\\s*"([^"]+)"/,
                /<title>([^<]*?)(?:完整版|最新章节|在线阅读|_番茄小说|-番茄小说|_番茄|-番茄)/,
                /<meta[^>]+property="og:title"[^>]+content="([^"]+)"/,
                /<title>([^<|_]{1,40})/
              ]);
              var author=pick(h,[
                /"author"\\s*:\\s*"([^"]+)"/,
                /"authorName"\\s*:\\s*"([^"]+)"/,
                /<meta[^>]+property="og:novel:author"[^>]+content="([^"]+)"/
              ]);
              // abstract(真实简介)优先;meta description 是平台模板("番茄小说提供..."),
              // 且常带 data-rh 属性,故用宽松属性匹配兜底。
              var abs=pick(h,[/"abstract"\\s*:\\s*"([^"]{6,}?)"/]);
              var desc=abs||pick(h,[
                /<meta[^>]+name="description"[^>]+content="([^"]+)"/,
                /<meta[^>]+property="og:description"[^>]+content="([^"]+)"/
              ]);
              // 题材:category 常为空字符串,真实题材在 categoryV2(转义 JSON)首个 Name。
              var category=pick(h,[
                /"categoryV2":"\\[\\{[\\s\\S]*?\\\\"Name\\\\":\\\\"([^"\\\\]+)/,
                /"category"\\s*:\\s*"([^"]{1,20})"/,
                /<meta[^>]+property="og:novel:category"[^>]+content="([^"]+)"/
              ]);
              // 标签:番茄简介开头常带【tag+tag+...】,是题材细分的真实信号。
              var tags='';
              var bm=(abs||desc||'').match(/[【\\[]([^】\\]]{2,40})[】\\]]/);
              if(bm){tags=bm[1].split(/[+、,\\/\\s]+/).filter(Boolean).slice(0,6).join('、');}
              map[id]={title:title,author:author,desc:desc,category:category,tags:tags};
            }catch(e){
              map[id]={title:'',author:'',desc:'',category:'',tags:'',err:String(e&&e.message||e)};
            }
          }
          return map;
        })())`;
      }
      
      function fetchDetailsChunk(port, ids) {
        return evalJSONBase64(port, buildDetailJS(ids)) || {};
      }
      
      /** 分批解码,避免单次 eval 超时;返回合并后的 map */
      function fetchDetails(port, bookIds) {
        const map = {};
        for (let i = 0; i < bookIds.length; i += DETAIL_CHUNK) {
          const chunk = bookIds.slice(i, i + DETAIL_CHUNK);
          const part = fetchDetailsChunk(port, chunk);
          Object.assign(map, part);
          sleep(300);
        }
        return map;
      }
      
      // ---------------------------------------------------------------------------
      // 格式化
      // ---------------------------------------------------------------------------
      
      function fmtReads(count) {
        if (!count || count === "0") return "未知";
        const n = parseInt(count, 10);
        if (isNaN(n)) return "未知";
        if (n >= 10000) return (n / 10000).toFixed(1) + "万";
        return String(n);
      }
      
      function fmtWords(count) {
        if (!count) return "未知";
        const n = parseInt(count, 10);
        if (isNaN(n)) return "未知";
        if (n >= 10000) return (n / 10000).toFixed(1) + "万";
        return String(n);
      }
      
      function fmtStatus(s) {
        const v = String(s);
        if (v === "1") return "连载中";
        if (v === "0" || v === "2") return "已完结";
        return s ? String(s) : "未知";
      }
      
      /** 清洗简介:去平台模板文本 → 折叠空白 → 句末截断 100 字 */
      function cleanDesc(raw) {
        if (!raw) return "";
        let d = String(raw)
          // 简介取自 JSON 字符串原文,先还原常见转义(\n \uXXXX \" 等)
          .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
          .replace(/\\[nrt]/g, " ")
          .replace(/\\"/g, '"')
          .replace(/番茄小说[^。!?]*?(?:免费阅读|完整版|在线阅读)[^。!?]*[。!?]/g, "")
          .replace(/番茄小说[^。!?]*?(?:免费阅读|完整版|在线阅读)[^。!?]*$/g, "")
          .replace(/\s+/g, " ")
          .trim();
        if (d.length <= 100) return d;
        const cut = d.slice(0, 100);
        const m = cut.match(/^[\s\S]*[。!?]/);
        return (m ? m[0] : cut) + "...";
      }
      
      // ---------------------------------------------------------------------------
      // 主流程
      // ---------------------------------------------------------------------------
      
      const args = process.argv.slice(2);
      const PORT = parseInt(getArg(args, "--port") || "9222", 10);
      const OUTDIR = getArg(args, "--outdir") || ".";
      const CHANNEL = getArg(args, "--channel") || "1";
      const TYPE = getArg(args, "--type") || "2";
      const TOP = parseInt(getArg(args, "--top") || "20", 10);
      
      function channelLabel(ch) {
        return ch === "1" ? "男频" : "女频";
      }
      
      function typeLabel(t) {
        return t === "2" ? "阅读榜" : "新书榜";
      }
      
      function scrapeChannel(ch, type) {
        const chLabel = channelLabel(ch);
        const tyLabel = typeLabel(type);
        console.log(`\n→ 采集 ${chLabel}${tyLabel}...`);
      
        // 用已知品类 ID 作为入口,确保菜单只显示当前频道/类型的品类
        const initCatId = ch === "1" ? "1141" : "1139"; // 男频:西方奇幻 / 女频:古风世情
        const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
        ab(PORT, "open", initUrl);
        sleep(3000);
      
        // 连通性自检:把"静默写出一堆 bookId"变成可操作的报错
        const probe = probePage(PORT);
        if (!probe) {
          console.error(
            `  ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${PORT}),且 agent-browser 可用。`
          );
          return null;
        }
        if (probe.host && probe.host.indexOf("fanqie") === -1) {
          console.error(
            `  ✗ 当前页面非番茄(host=${probe.host}),可能被重定向到登录/验证页,已跳过。`
          );
          return null;
        }
        if (!probe.hasState) {
          console.error(`  ⚠ 页面未挂载 __INITIAL_STATE__,将尝试兜底扫描,结果可能不完整。`);
        }
      
        let categories = extractCategories(PORT, ch, type);
        if (!categories.length) {
          // 菜单可能懒加载,滚动后重试一次
          scrollLoad(PORT, 2);
          sleep(1000);
          categories = extractCategories(PORT, ch, type);
        }
        if (!categories.length) {
          // 仍失败:降级为只采当前入口页,至少产出数据而不是空跑
          console.log(`  ⚠ 未提取到品类菜单,降级为单题材采集(入口页)`);
          categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
        } else {
          console.log(`  发现 ${categories.length} 个品类`);
        }
      
        const now = new Date().toISOString();
        const lines = [
          `# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
          "",
          `- 频道参数:channel=${ch},type=${type}`,
          `- 抓取时间:${now}`,
          `- 每题材上限 ≈ ${TOP}`,
          "",
          "---",
          "",
        ];
      
        let totalBooks = 0;
        let resolvedTitles = 0;
        const bodyLines = [];
      
        for (let ci = 0; ci < categories.length; ci++) {
          const cat = categories[ci];
          console.log(`  [${ci + 1}/${categories.length}] ${cat.name}`);
      
          try {
            ab(PORT, "open", `https://fanqienovel.com${cat.href}`);
            sleep(2500);
            scrollLoad(PORT, 2);
      
            let books = extractBookList(PORT);
            if (!Array.isArray(books) || !books.length) {
              bodyLines.push(`## ${cat.name} — 0 本`, "", "---", "");
              continue;
            }
            if (books.length > TOP) books = books.slice(0, TOP);
      
            // 分批解码真实书名/作者/简介/题材/评分/标签
            const bookIds = books.map((b) => String(b.bookId));
            const details = fetchDetails(PORT, bookIds);
      
            bodyLines.push(`## ${cat.name} — ${books.length} 本`, "");
      
            for (let i = 0; i < books.length; i++) {
              const b = books[i];
              const info = details[String(b.bookId)] || {};
              totalBooks++;
              const resolved = !!info.title;
              if (resolved) resolvedTitles++;
      
              const title = info.title || "(标题待解析)";
              const author = info.author || "未知";
              const category = info.category || b.category || "";
              const catSeg = category ? ` · ${category}` : "";
      
              bodyLines.push(`### #${i + 1} ${title}`);
              bodyLines.push(
                `*${author}${catSeg} · ${fmtStatus(b.creationStatus)} · ${fmtReads(b.read_count)} 在读 · ${fmtWords(b.wordNumber)}字*`
              );
              if (info.tags) bodyLines.push(`**标签:** ${info.tags}`);
              bodyLines.push(`**最新更新:** ${b.lastChapterTitle || "未知"}`);
              bodyLines.push(`**bookId:** ${b.bookId}`);
              bodyLines.push(`[作品页](https://fanqienovel.com/page/${b.bookId})`);
              const desc = cleanDesc(info.desc);
              if (desc) {
                bodyLines.push("");
                bodyLines.push("**简介**");
                bodyLines.push("");
                bodyLines.push(desc);
              }
              bodyLines.push("");
            }
      
            bodyLines.push("---", "");
          } catch (catErr) {
            console.error(
              `  [fanqie] 品类 ${cat.name} 处理出错,跳过: ${catErr && catErr.message ? catErr.message : catErr}`
            );
            bodyLines.push(`## ${cat.name} — 采集失败`, "", "---", "");
          }
        }
      
        // 质量状态:标题解析比例是番茄采集成败的核心信号
        const ratio = totalBooks ? resolvedTitles / totalBooks : 0;
        const quality = totalBooks === 0
          ? "[无数据]"
          : ratio < 0.5
            ? "[标题解析异常]"
            : "[OK]";
        lines.splice(5, 0,
          `- 标题解析:成功 ${resolvedTitles} / 共 ${totalBooks}`,
          `- 数据质量:${quality}`
        );
      
        if (totalBooks > 0 && resolvedTitles === 0) {
          console.error(
            `  ✗ ${chLabel}${tyLabel}:${totalBooks} 本全部标题解析失败。多为详情页结构变动或登录/验证拦截,` +
            `请在 Chrome 内手动打开任一 https://fanqienovel.com/page/{bookId} 确认页面正常。`
          );
        } else if (ratio < 0.5) {
          console.error(
            `  ⚠ ${chLabel}${tyLabel}:标题解析率偏低(${resolvedTitles}/${totalBooks}),结果质量已标注。`
          );
        }
      
        return lines.concat(bodyLines).join("\n");
      }
      
      function main() {
        if (!["0", "1", "all"].includes(CHANNEL)) {
          throw new Error(`未知 --channel: ${CHANNEL}`);
        }
        if (!["1", "2", "all"].includes(TYPE)) {
          throw new Error(`未知 --type: ${TYPE}`);
        }
        const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
        const types = TYPE === "all" ? ["2", "1"] : [TYPE];
        let written = 0;
      
        for (const ch of channels) {
          for (const ty of types) {
            try {
              const content = scrapeChannel(ch, ty);
              if (!content) continue;
      
              const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${localDateStamp()}.md`;
              fs.mkdirSync(OUTDIR, { recursive: true });
              const filepath = path.join(OUTDIR, filename);
              fs.writeFileSync(filepath, content, "utf-8");
              written++;
              console.log(`  ✓ 已保存: ${filepath}`);
            } catch (chErr) {
              console.error(
                `[fanqie] ${channelLabel(ch)}${typeLabel(ty)} 采集失败,跳过: ${chErr && chErr.message ? chErr.message : chErr}`
              );
            }
          }
        }
        return written;
      }
      
      if (require.main === module) {
        runCli(main, "番茄采集");
      }
      
      // 导出纯函数/JS 构建器,供测试在 sandbox 内验证解析逻辑
      module.exports = {
        buildCategoriesJS,
        buildBookListJS,
        buildDetailJS,
        fmtReads,
        fmtWords,
        fmtStatus,
        cleanDesc,
      };
      
    • jjwxc-rank-scraper.js 16.2 KB
      #!/usr/bin/env node
      /**
       * 晋江文学城排行榜采集脚本
       *
       * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
       * 采集策略:
       *   1) topten.php 列表页(纯文本,频道名直接出现,书名/作者交替行)解出频道分组。
       *   2) 从书名 anchor 取 novelid,逐本进 onebook.php 详情页补采核心指标
       *      (收藏数/营养液/积分/字数/状态),满足规范对晋江的硬性要求。
       * 晋江页面为 gb18030 编码:详情页用 fetch+arrayBuffer+TextDecoder('gb18030') 解码
       *   (同步 XHR 的 responseText 会按 UTF-8 解码导致中文乱码)。
       * 详情采集默认开启但有上限(每频道前 N + 总量上限),用 --list-only 可只采列表。
       *
       * 用法:
       *   node jjwxc-rank-scraper.js --type 12                  # 收入金榜(默认含详情)
       *   node jjwxc-rank-scraper.js --type 12 --top 15         # 每频道补采前 15 本
       *   node jjwxc-rank-scraper.js --type 12 --detail-limit 60 # 详情总量上限 60
       *   node jjwxc-rank-scraper.js --type 12 --list-only      # 只采列表(快,无核心指标)
       *   node jjwxc-rank-scraper.js --type all                 # 全部榜单
       *
       * 前置:
       *   node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
       */
      
      const fs = require("fs");
      const path = require("path");
      const { ab, sleep, evalJSONBase64, getArg, localDateStamp, runCli } = require("./cdp-utils");
      
      const BASE_URL = "https://www.jjwxc.net/topten.php";
      
      const RANK_TYPES = [
        { id: "12", label: "收入金榜" },
        { id: "7", label: "月榜" },
        { id: "8", label: "季度榜" },
        { id: "14", label: "完结金榜" },
        { id: "15", label: "新手金榜" },
        { id: "17", label: "千字金榜" },
      ];
      
      // 详情请求批大小(async fetch 并发,整批控制在 ab() 20s 超时内)
      const DETAIL_CHUNK = 6;
      
      /** 连通性 + 页面就绪自检 */
      function probePage(port) {
        return evalJSONBase64(
          port,
          "JSON.stringify({host:location.host,len:(document.body&&document.body.innerText||'').length})"
        );
      }
      
      // ---------------------------------------------------------------------------
      // 列表页提取
      // ---------------------------------------------------------------------------
      
      /**
       * 提取晋江榜单数据(频道分组 + 书名/作者交替),并从书名 anchor 附上 novelid。
       */
      function extractRankData(port) {
        const js =
          "JSON.stringify((function(){" +
          "var result={channels:[]};" +
          "var text=document.body.innerText||'';" +
          "var lines=text.split(/\\n/).map(function(l){return l.trim()}).filter(Boolean);" +
          // 书名 anchor → novelid(排除霸王票"X向《书名》投了Y"这类记录)
          "var idMap={};" +
          "Array.from(document.querySelectorAll('a')).forEach(function(a){" +
          "  var hm=(a.getAttribute('href')||'').match(/novelid=([0-9]+)/);if(!hm)return;" +
          "  var t=(a.innerText||a.textContent||'').trim();" +
          "  if(!t||t.indexOf('向《')>-1||t.indexOf('投')>-1||t.length>30)return;" +
          "  if(!idMap[t])idMap[t]=hm[1];" +
          "});" +
          "var channels=['古代言情','现代言情','古代穿越','现代都市纯爱','现代幻想纯爱','古代纯爱','衍生纯爱','幻想现言','奇幻言情','未来游戏悬疑','百合','无CP','二次元言情','衍生言情','衍生无cp','未来幻想纯爱','原创轻小说','多元'];" +
          "var channelSet={};channels.forEach(function(c){channelSet[c]=true});" +
          "var curChannel='';" +
          "var channelBooks={};" +
          "var expectTitle=true;" +
          "var pendingTitle='';" +
          "for(var i=0;i<lines.length;i++){" +
          "  var line=lines[i];" +
          "  if(/上榜天数记录|榜单说明/.test(line)){break}" +
          "  if(/^(免费强推|vip强推|新晋作者|月榜|季榜|半年榜|长生殿|总分榜|字数榜|收入金榜|霸王票|霸王总榜|勤奋指数|完结金榜|新手金榜|栽培月榜|驻站|完结高分|千字金榜|完结全订榜)$/.test(line)){continue}" +
          "  if(line.length>30&&line.indexOf('·')>0)continue;" +
          "  if(channelSet[line]){" +
          "    if(curChannel&&channelBooks[curChannel])channelBooks[curChannel]._finished=true;" +
          "    curChannel=line;" +
          "    if(!channelBooks[curChannel])channelBooks[curChannel]={books:[]};" +
          "    expectTitle=true;pendingTitle='';continue" +
          "  }" +
          "  if(!curChannel)continue;" +
          "  if(expectTitle){" +
          "    pendingTitle=line;expectTitle=false" +
          "  }else{" +
          "    if(pendingTitle){" +
          "      channelBooks[curChannel].books.push({title:pendingTitle,author:line,novelid:idMap[pendingTitle]||''})" +
          "    }" +
          "    expectTitle=true;pendingTitle=''" +
          "  }" +
          "}" +
          "for(var name in channelBooks){" +
          "  if(channelBooks[name].books.length>0){" +
          "    result.channels.push({name:name,books:channelBooks[name].books})" +
          "  }" +
          "}" +
          "return result" +
          "})())";
        return evalJSONBase64(port, js);
      }
      
      // ---------------------------------------------------------------------------
      // 详情页提取(gb18030 + itemprop 微数据)
      // ---------------------------------------------------------------------------
      
      /** 构建:一批 novelid 的详情解码 JS(async fetch + TextDecoder,返回 JSON 字符串) */
      function buildDetailJS(ids) {
        return `Promise.all(${JSON.stringify(ids)}.map(function(id){
          return fetch('/onebook.php?novelid='+id)
            .then(function(r){return r.arrayBuffer()})
            .then(function(b){
              var h=new TextDecoder('gb18030').decode(new Uint8Array(b));
              function prop(n){var m=h.match(new RegExp('itemprop="'+n+'"[^>]*>([^<]*)<'));return m?m[1].trim():'';}
              var status=(h.match(/itemprop="updataStatus"[^>]*>\\s*([^<\\s]{1,6})/)||[,''])[1]
                       ||(h.match(/(连载中|已完结|完结)/)||[,''])[1]||'';
              return {id:id,collect:prop('collectedCount'),nutrition:prop('nutritionCount'),
                      score:prop('scoreCount'),review:prop('reviewCount'),words:prop('wordCount'),status:status};
            })
            .catch(function(e){return {id:id,err:String(e&&e.message||e)}});
        })).then(function(arr){var map={};arr.forEach(function(o){map[o.id]=o});return JSON.stringify(map);})`;
      }
      
      /**
       * 分批解码详情,合并结果。
       * 每批单独 try/catch:整批的并发 fetch 贴着 ab() 的 20s 超时线,一次瞬时超时(或
       * 返回非 JSON)只该丢这 6 本,不能连坐后面几十本,更不能把已解析好的列表带走。
       */
      function fetchDetails(port, ids) {
        const map = {};
        let failedChunks = 0;
        for (let i = 0; i < ids.length; i += DETAIL_CHUNK) {
          const chunk = ids.slice(i, i + DETAIL_CHUNK);
          try {
            const part = evalJSONBase64(port, buildDetailJS(chunk)) || {};
            Object.assign(map, part);
          } catch (chunkErr) {
            failedChunks++;
            console.error(
              `  ⚠ 详情批次 ${Math.floor(i / DETAIL_CHUNK) + 1}(${chunk.length} 本)获取失败,跳过: ${chunkErr.message}`
            );
          }
          sleep(400);
        }
        if (failedChunks > 0) {
          console.error(`  ⚠ 共 ${failedChunks} 个详情批次失败,这部分书只有列表数据。`);
        }
        return { map, failedChunks };
      }
      
      // ---------------------------------------------------------------------------
      // 格式化
      // ---------------------------------------------------------------------------
      
      function fmtWan(s, unit) {
        if (s == null || s === "") return "";
        const n = parseInt(String(s).replace(/[^0-9]/g, ""), 10);
        if (isNaN(n)) return "";
        if (n >= 10000) return (n / 10000).toFixed(1) + "万" + (unit || "");
        return n + (unit || "");
      }
      
      // ---------------------------------------------------------------------------
      // 主流程
      // ---------------------------------------------------------------------------
      
      const args = process.argv.slice(2);
      const PORT = parseInt(getArg(args, "--port") || "9222", 10);
      const OUTDIR = getArg(args, "--outdir") || ".";
      const RANKTYPE = getArg(args, "--type") || "12";
      const CHANNEL = getArg(args, "--channel") || "0";
      const TOP = parseInt(getArg(args, "--top") || "10", 10);
      const DETAIL_LIMIT = parseInt(getArg(args, "--detail-limit") || "100", 10);
      const LIST_ONLY = args.includes("--list-only");
      
      function scrapeRank(port, rankTypeId, channelId) {
        const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
        if (!rt) {
          console.log(`  ⚠ 未知榜单类型: ${rankTypeId}`);
          return null;
        }
      
        const url = `${BASE_URL}?orderstr=${rankTypeId}&t=${channelId}`;
        const chLabel = channelId === "0" ? "全站" : `频道${channelId}`;
        console.log(`\n→ 采集 晋江${rt.label}(${chLabel})...`);
        console.log(`  URL: ${url}`);
      
        let data;
        try {
          ab(port, "open", url);
          sleep(4000);
      
          // 连通性自检:CDP 未起/被重定向时给可操作报错,而非误报"结构已变"
          const probe = probePage(port);
          if (!probe) {
            console.error(
              `  ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${port}),且 agent-browser 可用。`
            );
            return null;
          }
          if (probe.host && probe.host.indexOf("jjwxc") === -1) {
            console.error(`  ✗ 当前页面非晋江(host=${probe.host}),可能被重定向,已跳过。`);
            return null;
          }
      
          data = extractRankData(port);
          if (!data?.channels?.length) {
            console.error(`[jjwxc] 采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开 ${url} 确认。`);
            return null;
          }
        } catch (err) {
          console.error(`[jjwxc] ${rt.label} 页面加载或提取出错: ${err.message}`);
          return null;
        }
      
        let totalBooks = 0;
        data.channels.forEach((ch) => {
          totalBooks += ch.books.length;
          const authors = new Set(ch.books.map((b) => b.author));
          if (ch.books.length >= 5 && authors.size / ch.books.length < 0.2) {
            console.log(`  ⚠ ${ch.name}:${ch.books.length} 本只有 ${authors.size} 个唯一作者,可能提取有误`);
          }
        });
        console.log(`  ✓ 列表:${data.channels.length} 个频道,共 ${totalBooks} 本`);
      
        // 选取每频道前 TOP 本(有 novelid 的)补采详情,受 DETAIL_LIMIT 总量约束
        let detailMap = {};
        let detailPlanned = 0;
        let detailOk = 0;
        let detailFailedChunks = 0;
        if (!LIST_ONLY) {
          const picked = [];
          for (const ch of data.channels) {
            let n = 0;
            for (const b of ch.books) {
              if (picked.length >= DETAIL_LIMIT) break;
              if (n >= TOP) break;
              if (b.novelid) { picked.push(b.novelid); n++; }
            }
            if (picked.length >= DETAIL_LIMIT) break;
          }
          detailPlanned = picked.length;
          if (picked.length) {
            console.log(`  → 补采详情 ${picked.length} 本(每频道前 ${TOP},上限 ${DETAIL_LIMIT})...`);
            // 详情是列表的增补,不是前提:整段失败也要保住已解析好的列表落盘
            // (下面的质量门会把 detailOk===0 标成 [详情解析异常/登录态缺失])
            try {
              const detailResult = fetchDetails(port, picked);
              detailMap = detailResult.map;
              detailFailedChunks = detailResult.failedChunks;
            } catch (detailErr) {
              detailMap = {};
              detailFailedChunks = Math.max(1, Math.ceil(picked.length / DETAIL_CHUNK));
              console.error(`  ⚠ 详情补采整体失败,仅保留列表数据: ${detailErr.message}`);
            }
            detailOk = Object.values(detailMap).filter((d) => d && d.collect).length;
            console.log(`  ✓ 详情命中收藏数 ${detailOk}/${picked.length}`);
          }
        }
      
        // 质量状态:详情开启时,收藏数命中率是核心信号
        let quality = "[OK]";
        const detailPartial =
          !LIST_ONLY &&
          detailPlanned > 0 &&
          (detailFailedChunks > 0 || detailOk < detailPlanned);
        if (!LIST_ONLY && detailPlanned > 0 && detailOk === 0) {
          quality = "[详情解析异常/登录态缺失]";
          console.error(`  ⚠ 详情全部无收藏数:可能页面结构变动或需登录,已在文件头标注。`);
        } else if (detailPartial) {
          quality = "[部分详情缺失]";
          console.error(`  ⚠ 详情仅命中 ${detailOk}/${detailPlanned},已按部分结果标注。`);
        } else if (LIST_ONLY) {
          quality = "[仅列表-无核心指标]";
        }
      
        const now = new Date().toISOString();
        const lines = [
          `# 晋江 · ${rt.label}`,
          "",
          `- 来源:${url}`,
          `- 抓取时间:${now}`,
          `- 频道数:${data.channels.length}`,
          `- 总条目数:${totalBooks}`,
          `- 详情采集:${detailOk} / ${detailPlanned}(每频道前 ${TOP},上限 ${DETAIL_LIMIT})`,
          `- 数据质量:${quality}`,
          "",
          "---",
          "",
        ];
      
        for (const ch of data.channels) {
          try {
            lines.push(`## ${ch.name} — ${ch.books.length} 本`, "");
            for (let i = 0; i < ch.books.length; i++) {
              try {
                const b = ch.books[i];
                lines.push(`### #${i + 1} ${b.title}`);
                const d = b.novelid ? detailMap[b.novelid] : null;
                const seg = [b.author || ""];
                if (d) {
                  if (d.collect) seg.push("收藏 " + fmtWan(d.collect));
                  if (d.nutrition) seg.push("营养液 " + fmtWan(d.nutrition));
                  if (d.score) seg.push("积分 " + d.score);
                  if (d.words) seg.push("字数 " + fmtWan(d.words, "字"));
                  if (d.status) seg.push(d.status);
                }
                const meta = seg.filter(Boolean).join(" · ");
                if (meta) lines.push(`*${meta}*`);
                if (b.novelid) lines.push(`[作品页](https://www.jjwxc.net/onebook.php?novelid=${b.novelid})`);
                lines.push("");
              } catch (bookErr) {
                console.error(`[jjwxc] ${rt.label} ${ch.name} 第${i + 1}条处理出错: ${bookErr.message}`);
                lines.push("");
              }
            }
            lines.push("---", "");
          } catch (chErr) {
            console.error(`[jjwxc] ${rt.label} 频道「${ch.name}」处理出错,跳过: ${chErr.message}`);
          }
        }
      
        return {
          content: lines.join("\n"),
          partial: detailPartial,
          partialReason: detailPartial
            ? `${rt.label}: detail ${detailOk}/${detailPlanned}, failed chunks ${detailFailedChunks}`
            : "",
        };
      }
      
      function main() {
        if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
          throw new Error(`未知 --type: ${RANKTYPE}`);
        }
        // 当前脚本只实现全站榜(t=0);不能把任意数字静默标成“频道 N”。
        // 若后续支持分频道,先从页面提取并维护明确 ID 白名单再开放。
        if (CHANNEL !== "0") {
          throw new Error(`未知 --channel: ${CHANNEL}(当前仅支持 0=全站)`);
        }
        const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
        const channels = [CHANNEL]; // 晋江频道 ID 需从页面获取,默认全站
        let written = 0;
        let failed = 0;
        let partial = false;
        const partialReasons = [];
      
        for (const rt of rankTypes) {
          for (const ch of channels) {
            // per-榜单隔离:一个榜单出错不该掐掉 --type all 后面的榜单(与番茄/刺猬猫一致)
            try {
              const result = scrapeRank(PORT, rt, ch);
              if (!result) {
                failed++;
                const rtInfo = RANK_TYPES.find((r) => r.id === rt);
                partialReasons.push(`${rtInfo ? rtInfo.label : rt}: no usable data`);
                continue;
              }
              if (result.partial) {
                partial = true;
                if (result.partialReason) partialReasons.push(result.partialReason);
              }
      
              const rtInfo = RANK_TYPES.find((r) => r.id === rt);
              const chLabel = ch === "0" ? "全站" : `频道${ch}`;
              const filename = `晋江${rtInfo.label}_${chLabel}_${localDateStamp()}.md`;
              fs.mkdirSync(OUTDIR, { recursive: true });
              const filepath = path.join(OUTDIR, filename);
              fs.writeFileSync(filepath, result.content, "utf-8");
              written++;
              console.log(`  ✓ 已保存: ${filepath}`);
            } catch (rankErr) {
              failed++;
              const rtInfo = RANK_TYPES.find((r) => r.id === rt);
              const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
              partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${message}`);
              console.error(
                `[jjwxc] ${rtInfo ? rtInfo.label : rt} 采集失败,跳过: ${message}`
              );
            }
          }
        }
        return {
          planned: rankTypes.length * channels.length,
          written,
          failed,
          partial: partial || failed > 0,
          partialReasons,
        };
      }
      
      if (require.main === module) {
        runCli(main, "晋江采集");
      }
      
      module.exports = { buildDetailJS, fmtWan };
      
    • qidian-rank-scraper.js 21.9 KB
      #!/usr/bin/env node
      /**
       * 起点中文网 排行榜采集脚本
       *
       * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
       * 采集策略:
       *   1. 默认优先读取 m.qidian.com 的 SSR pageContext JSON(不依赖 CDP,规避 PC 站风控页)。
       *   2. 移动端不可用时再回退到 Chrome CDP 采集 PC 页面。
       * 输出 Markdown 格式匹配 scan-output-format.md 规范。
       *
       * 用法:
       *   node qidian-rank-scraper.js --type hotsales               # 畅销榜
       *   node qidian-rank-scraper.js --type yuepiao                 # 月票榜
       *   node qidian-rank-scraper.js --type signnewbook             # 签约作者新书榜
       *   node qidian-rank-scraper.js --type pubnewbook              # 公众作者新书榜
       *   node qidian-rank-scraper.js --type newauthor               # 新人作者新书榜
       *   node qidian-rank-scraper.js --type newsign                 # 新人签约新书榜
       *   node qidian-rank-scraper.js --type recom                   # 原创推荐榜
       *   node qidian-rank-scraper.js --type sanjiang                 # 三江推荐(/sanjiang/,非 /rank/ 路径)
       *   node qidian-rank-scraper.js --type all                     # 全部榜单
       *   node qidian-rank-scraper.js --type hotsales --mode mobile  # 仅使用移动端 SSR
       *   node qidian-rank-scraper.js --type hotsales --mode cdp     # 仅使用备用 CDP/PC 页面
       *
       * 前置:
       *   默认 mobile/auto 模式不需要 Chrome。
       *   cdp 模式需要:node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
       */
      
      const fs = require("fs");
      const https = require("https");
      const path = require("path");
      const { ab, sleep, evalJSON, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");
      
      const PC_BASE_URL = "https://www.qidian.com/rank";
      const MOBILE_BASE_URL = "https://m.qidian.com";
      
      /** 验证码自动重试最大次数 */
      const MAX_CAPTCHA_RETRIES = 3;
      /** 等待用户手动解决验证码的最大秒数 */
      const MAX_CAPTCHA_WAIT_SEC = 120;
      /** 轮询验证码是否解除的间隔(毫秒) */
      const CAPTCHA_POLL_INTERVAL = 5000;
      
      const MOBILE_HEADERS = {
        "User-Agent":
          "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
          "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
        Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        "Accept-Encoding": "identity",
      };
      
      const RANK_TYPES = [
        { id: "hotsales", label: "畅销榜", mobilePath: "/rank/hotsales/" },
        { id: "yuepiao", label: "月票榜", mobilePath: "/rank/yuepiao/" },
        {
          id: "signnewbook",
          label: "签约作者新书榜",
          mobilePath: "/rank/sign/",
          mobileLabel: "签约榜",
        },
        {
          id: "pubnewbook",
          label: "公众作者新书榜",
          mobilePath: "/rank/newbook/",
          mobileLabel: "新书榜",
        },
        { id: "newauthor", label: "新人作者新书榜", mobilePath: "/rank/newauthor/", mobileLabel: "新人榜" },
        {
          id: "newsign",
          label: "新人签约新书榜",
          mobilePath: "/rank/sign/",
          mobileLabel: "签约榜",
        },
        { id: "recom", label: "原创推荐榜", mobilePath: "/rank/rec/", mobileLabel: "推荐榜" },
        { id: "readindex", label: "阅读指数榜", mobilePath: "/rank/readindex/" },
        {
          id: "collect",
          label: "收藏榜",
          mobilePath: "/rank/newfans/",
          mobileLabel: "书友榜(移动端替代)",
        },
        {
          id: "sanjiang",
          label: "三江推荐",
          baseUrl: "https://www.qidian.com/sanjiang/",
          mobilePath: "/sanjiang/",
        },
      ];
      
      // ---------------------------------------------------------------------------
      // 页面提取
      // ---------------------------------------------------------------------------
      
      /**
       * 提取起点 SSR 榜单页面的书籍列表。
       * 起点页面结构:.book-img-text ul > li,每个 li 内:
       *   h2 > a          → 书名+链接
       *   p.author         → 作者 | 题材 · 子题材 | 状态
       *   p.intro          → 简介
       *   p.update > a+span → 最新更新章节+日期
       */
      function extractBookList(port) {
        const js =
          "JSON.stringify((()=>{" +
          "var items=[];" +
          "function metrics(text){" +
          "  var flat=String(text||'').replace(/\\s+/g,' ').trim();" +
          "  var words=flat.match(/([\\d.]+\\s*万?字)/);" +
          "  var total=flat.match(/([\\d.,]+\\s*万?)\\s*总推荐/);" +
          "  var signing=flat.match(/(?:^|\\s|·)(已签约|未签约|签约)(?=\\s|·|$)/);" +
          "  var pricing=flat.match(/(?:^|\\s|·)(VIP|免费)(?=\\s|·|$)/i);" +
          "  return {words:words?words[1].replace(/\\s+/g,''):'',totalRecommendations:total?total[1]:'',signing:signing?signing[1]:'',pricing:pricing?pricing[1].toUpperCase()==='VIP'?'VIP':pricing[1]:''};" +
          "}" +
          "var lis=document.querySelectorAll('.book-img-text ul li');" +
          "if(!lis.length){" +
          // 兜底:用 H2 链接定位
          "  var h2s=document.querySelectorAll('h2 a[href*=\"/book/\"]');" +
          "  h2s.forEach(function(a,idx){" +
          "    var c=a.parentElement;" +
          "    for(var j=0;j<3;j++){if(c.parentElement)c=c.parentElement}" +
          "    var text=c.innerText||'';" +
          "    var metric=metrics(text);" +
          "    var href=a.getAttribute('href')||a.href||'';" +
          "    var url=href?(href.indexOf('http')===0?href:'https:'+href):'';" +
          "    items.push({rank:idx+1,title:a.textContent.trim(),url:url,author:'',genre:'',status:'',words:metric.words,rankValue:'',totalRecommendations:metric.totalRecommendations,signing:metric.signing,pricing:metric.pricing,descText:'',updateText:text.replace(/\\s+/g,' ').trim().substring(0,300)})" +
          "  });" +
          "  return items" +
          "}" +
          "lis.forEach(function(li,idx){" +
          "  var titleEl=li.querySelector('h2 a');" +
          "  if(!titleEl)return;" +
          "  var title=titleEl.textContent.trim();" +
          "  var href=titleEl.getAttribute('href')||titleEl.href||'';" +
          "  var url=href?(href.indexOf('http')===0?href:'https:'+href):'';" +
          // 作者:p.author > a.name
          "  var authorEl=li.querySelector('p.author a.name');" +
          "  var author=authorEl?authorEl.textContent.trim():'';" +
          // 题材:p.author > a (非 .name 非 .go-sub-type)
          "  var genreEls=li.querySelectorAll('p.author a');" +
          "  var genre='';var subGenre='';" +
          "  genreEls.forEach(function(a){" +
          "    if(a.classList.contains('name'))return;" +
          "    if(!genre){genre=a.textContent.trim()}else if(!subGenre){subGenre=a.textContent.trim()}" +
          "  });" +
          // 状态:p.author > span:last-child
          "  var statusEl=li.querySelector('p.author span');" +
          "  var status=statusEl?statusEl.textContent.trim():'';" +
          // 简介:p.intro
          "  var introEl=li.querySelector('p.intro');" +
          "  var descText=introEl?introEl.textContent.trim():'';" +
          // 更新:p.update
          "  var updateEl=li.querySelector('p.update');" +
          "  var updateText=updateEl?updateEl.textContent.replace(/\\s+/g,' ').trim():'';" +
          "  var metric=metrics(li.innerText||'');" +
          "  if(title){" +
          "    items.push({rank:idx+1,title:title,url:url,author:author,genre:genre+(subGenre?'·'+subGenre:''),status:status,words:metric.words,rankValue:'',totalRecommendations:metric.totalRecommendations,signing:metric.signing,pricing:metric.pricing,descText:descText,updateText:updateText})" +
          "  }" +
          "});" +
          "return items" +
          "})())";
        return evalJSON(port, js) || [];
      }
      
      /** 从详情页提取标签和简介 */
      function extractDetail(port) {
        const js =
          "JSON.stringify((()=>{" +
          "var tags=Array.from(document.querySelectorAll('[class*=\"tag\"] a,[class*=\"label\"] a')).map(function(a){return a.textContent.trim()});" +
          "var intro=document.querySelector('[class*=\"intro\"],[class*=\"summary\"],[class*=\"desc\"]');" +
          "var introText=intro?intro.textContent.trim():'';" +
          "var update=document.querySelector('[class*=\"update\"],[class*=\"latest\"]');" +
          "var updateText=update?update.textContent.trim():'';" +
          "var info=document.querySelector('.book-info,[class*=\"book-info\"],[class*=\"bookInfo\"]');" +
          "var infoText=info?(info.innerText||info.textContent||''):(document.body?document.body.innerText||'':'');" +
          "var words=infoText.match(/([\\d.]+\\s*万?字)/);" +
          "var total=infoText.match(/([\\d.,]+\\s*万?)\\s*总推荐/);" +
          "var signing=infoText.match(/(?:^|\\s|·)(已签约|未签约|签约)(?=\\s|·|$)/);" +
          "var pricing=infoText.match(/(?:^|\\s|·)(VIP|免费)(?=\\s|·|$)/i);" +
          "return {tags:tags,intro:introText,update:updateText,words:words?words[1].replace(/\\s+/g,''):'',totalRecommendations:total?total[1]:'',signing:signing?signing[1]:'',pricing:pricing?pricing[1].toUpperCase()==='VIP'?'VIP':pricing[1]:''}" +
          "})())";
        return evalJSON(port, js);
      }
      
      /**
       * 检测当前页面是否被验证码/安全验证拦截。
       * 起点常见拦截页面特征:页面中出现验证码关键词,或页面缺少榜单 DOM 元素。
       * @returns {{ blocked: boolean, reason: string } | null} 若被拦截返回原因对象,否则 null
       */
      function isCaptchaPage(port) {
        const js =
          "JSON.stringify((()=>{" +
          "var bodyText=document.body?(document.body.innerText||'').substring(0,3000):'';" +
          "var lower=bodyText.toLowerCase();" +
          "var keywords=['验证','captcha','verify','安全验证','滑块','拖动','请完成验证'," +
          "'混元','人机验证','异常请求','访问验证','操作频繁','请求过于频繁','waf','请稍后再试'];" +
          "for(var i=0;i<keywords.length;i++){" +
          "  if(lower.indexOf(keywords[i])>-1){" +
          "    return {blocked:true,reason:keywords[i]};" +
          "  }" +
          "}" +
          "var hasContent=document.querySelector('.book-img-text ul li,.rank-body,.rank-list,.book-img-text');" +
          "if(!hasContent){" +
          "  return {blocked:true,reason:'页面无榜单内容(可能被拦截)'};" +
          "}" +
          "return {blocked:false,reason:''};" +
          "})())";
        const result = evalJSON(port, js);
        return result && result.blocked === true ? result : null;
      }
      
      /**
       * 打开 URL 并等待页面加载,自动处理验证码拦截。
       * 重试策略:
       *   1. 正常加载页面
       *   2. 检测到验证码 → 等待递增延时后刷新重试(最多 MAX_CAPTCHA_RETRIES 次)
       *   3. 仍被拦截 → 提示用户在 Chrome CDP 窗口手动完成验证,轮询等待直到解除或超时
       *
       * @returns {boolean} true=页面已就绪,false=无法通过验证码
       */
      function openWithCaptchaHandling(port, url) {
        for (let attempt = 1; attempt <= MAX_CAPTCHA_RETRIES; attempt++) {
          ab(port, "open", url);
          // 首次 3 秒,后续每次多等 2 秒
          sleep(3000 + (attempt - 1) * 2000);
      
          const captcha = isCaptchaPage(port);
          if (!captcha) {
            return true;
          }
          console.log(`  ⚠ 检测到安全拦截 (${captcha.reason}),第 ${attempt}/${MAX_CAPTCHA_RETRIES} 次重试...`);
          // 递增等待后再次尝试
          sleep(attempt * 5000);
        }
      
        // 自动重试全部失败 → 等待用户手动处理
        console.log(`  ⚠ 自动重试未通过验证码,请在 Chrome CDP 窗口手动完成验证`);
        console.log(`  ⏳ 等待手动验证(最长 ${MAX_CAPTCHA_WAIT_SEC} 秒)...`);
      
        const startTime = Date.now();
        while (Date.now() - startTime < MAX_CAPTCHA_WAIT_SEC * 1000) {
          sleep(CAPTCHA_POLL_INTERVAL);
          // 刷新页面检查验证码是否已解除
          ab(port, "open", url);
          sleep(3000);
          const captcha = isCaptchaPage(port);
          if (!captcha) {
            console.log(`  ✓ 验证码已解除,继续采集`);
            return true;
          }
          const elapsed = Math.round((Date.now() - startTime) / 1000);
          process.stdout.write(`  等待中... (${elapsed}s)\r`);
        }
        console.log(`  ✗ 等待超时,验证码仍未解除`);
        return false;
      }
      
      // ---------------------------------------------------------------------------
      // 移动端 SSR 提取(默认路径)
      // ---------------------------------------------------------------------------
      
      function mobileUrl(pathname) {
        if (!pathname) return "";
        return pathname.startsWith("http") ? pathname : `${MOBILE_BASE_URL}${pathname}`;
      }
      
      function fetchText(url, redirects = 3) {
        return new Promise((resolve, reject) => {
          const req = https.get(url, { headers: MOBILE_HEADERS, timeout: 15000 }, (res) => {
            if (
              redirects > 0 &&
              res.statusCode >= 300 &&
              res.statusCode < 400 &&
              res.headers.location
            ) {
              res.resume();
              const nextUrl = new URL(res.headers.location, url).toString();
              fetchText(nextUrl, redirects - 1).then(resolve, reject);
              return;
            }
      
            let body = "";
            res.setEncoding("utf8");
            res.on("data", (chunk) => {
              body += chunk;
            });
            res.on("end", () => {
              if (res.statusCode < 200 || res.statusCode >= 300) {
                reject(new Error(`HTTP ${res.statusCode}`));
                return;
              }
              resolve(body);
            });
          });
      
          req.on("timeout", () => {
            req.destroy(new Error("request timeout"));
          });
          req.on("error", reject);
        });
      }
      
      function extractMobilePageContext(html) {
        const m = html.match(
          /<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i
        );
        if (!m) return null;
        try {
          return JSON.parse(m[1]);
        } catch (e) {
          console.log(`  ⚠ 移动端 pageContext JSON 解析失败: ${e.message}`);
          return null;
        }
      }
      
      function normalizeMobileBook(record, idx) {
        const title = record.bName || record.bookName || "";
        const bid = record.bid || record.bookId || "";
        const genre = [record.cat, record.subCat].filter(Boolean).join("·");
        const first = (...keys) => {
          for (const key of keys) {
            const value = record[key];
            if (value !== undefined && value !== null && value !== "") return String(value);
          }
          return "";
        };
      
        return {
          rank: record.rankNum || idx + 1,
          title,
          url: bid ? `${MOBILE_BASE_URL}/book/${bid}/` : "",
          author: record.bAuth || record.author || "",
          genre,
          status: first("status", "bookStatus", "serializationStatus"),
          words: first("cnt", "wordCount", "words", "wordCnt"),
          rankValue: first("rankCnt", "rankValue"),
          totalRecommendations: first(
            "totalRecommend",
            "totalRecommendations",
            "recommendCount",
            "totalRec"
          ),
          signing: first("signStatus", "signing", "contractStatus"),
          pricing: first("vipStatus", "pricing", "chargeStatus"),
          descText: record.desc || "",
          updateText: "",
        };
      }
      
      /** 清洗简介:折叠空白,超过 100 字时优先在句末截断。 */
      function cleanDesc(raw) {
        const desc = String(raw || "").replace(/\s+/g, " ").trim();
        if (desc.length <= 100) return desc;
        const cut = desc.slice(0, 100);
        const sentence = cut.match(/^[\s\S]*[。!?]/);
        return (sentence ? sentence[0] : cut) + "...";
      }
      
      function renderMarkdown(rt, books, url, sourceMode, extraLines = []) {
        const now = new Date().toISOString();
        const lines = [
          `# 起点 · ${rt.label}`,
          "",
          `- 来源:${url}`,
          `- 抓取方式:${sourceMode}`,
          `- 抓取时间:${now}`,
          `- 条目数:${books.length}`,
          ...extraLines,
          "",
          "---",
          "",
        ];
      
        for (let i = 0; i < books.length; i++) {
          const b = books[i];
          lines.push(`## #${b.rank || i + 1} ${b.title}`);
          const meta = [b.author, b.genre, b.status].filter(Boolean).join(" · ");
          if (meta) lines.push(`*${meta}*`);
          const required = (value) =>
            value === undefined || value === null || value === "" ? "[待补]" : String(value);
          lines.push(`**字数:${required(b.words)}**`);
          if (b.rankValue) lines.push(`**榜单值:${b.rankValue}**`);
          lines.push(`**总推荐:${required(b.totalRecommendations)}**`);
          lines.push(`**签约:${required(b.signing)}**`);
          lines.push(`**收费模式:${required(b.pricing)}**`);
          if (b.updateText) lines.push(`**最新更新:** ${b.updateText}`);
          if (b.tags?.length) lines.push(`**标签:** ${b.tags.join("、")}`);
          if (b.url) lines.push(`[作品页](${b.url})`);
          const desc = cleanDesc(b.descText);
          if (desc) {
            lines.push("");
            lines.push("**简介**");
            lines.push("");
            lines.push(desc);
          }
          lines.push("", "---", "");
        }
      
        return lines.join("\n");
      }
      
      async function scrapeRankMobile(rankTypeId) {
        const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
        if (!rt) {
          console.log(`  ⚠ 未知榜单类型: ${rankTypeId}`);
          return null;
        }
        if (!rt.mobilePath) {
          console.log(`  ⚠ 榜单 ${rankTypeId} 暂无移动端 SSR 路径`);
          return null;
        }
      
        const url = mobileUrl(rt.mobilePath);
        console.log(`\n→ 采集 起点${rt.label}(移动端 SSR)...`);
        console.log(`  URL: ${url}`);
      
        const html = await fetchText(url);
        const pageContext = extractMobilePageContext(html);
        const pageData = pageContext?.pageContext?.pageProps?.pageData;
        const records = pageData?.records || [];
        const books = records.map(normalizeMobileBook).filter((b) => b.title);
      
        if (!books.length) {
          console.log("  ⚠ 移动端 SSR 未提取到书籍");
          return null;
        }
      
        console.log(`  ✓ 提取 ${books.length} 本`);
      
        const extraLines = [];
        if (rt.mobileLabel && rt.mobileLabel !== rt.label) {
          extraLines.push(`- 移动端实际榜单:${rt.mobileLabel}`);
        }
        if (FETCH_DETAIL) {
          extraLines.push("- 说明:移动端 SSR 已包含简介;--detail 在 mobile/auto 模式下不会额外打开详情页。");
        }
      
        return renderMarkdown(rt, books, url, "mobile-ssr", extraLines);
      }
      
      // ---------------------------------------------------------------------------
      // 主流程
      // ---------------------------------------------------------------------------
      
      const args = process.argv.slice(2);
      const PORT = parseInt(getArg(args, "--port") || "9222", 10);
      const OUTDIR = getArg(args, "--outdir") || ".";
      const RANKTYPE = getArg(args, "--type") || "hotsales";
      const SCRAPE_MODE = getArg(args, "--mode") || "auto"; // auto | mobile | cdp
      const FETCH_DETAIL = (getArg(args, "--detail") || "no") === "yes";
      
      function scrapeRankCDP(port, rankTypeId) {
        const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
        if (!rt) {
          console.log(`  ⚠ 未知榜单类型: ${rankTypeId}`);
          return null;
        }
      
        const url = rt.baseUrl || `${PC_BASE_URL}/${rankTypeId}/`;
        console.log(`\n→ 采集 起点${rt.label}(CDP/PC)...`);
        console.log(`  URL: ${url}`);
      
        const pageReady = openWithCaptchaHandling(port, url);
        if (!pageReady) {
          console.log("  ✗ 起点采集失败:页面无法通过验证码拦截");
          return null;
        }
      
        scrollLoad(port, 3);
        sleep(1000);
      
        const books = extractBookList(port);
        if (!books.length) {
          console.log("  ⚠ 未提取到书籍");
          return null;
        }
        console.log(`  ✓ 提取 ${books.length} 本`);
      
        // 可选:逐条获取详情页补充数据
        if (FETCH_DETAIL) {
          console.log("  正在获取详情页补充数据...");
          for (let i = 0; i < Math.min(books.length, 20); i++) {
            const b = books[i];
            if (!b.url) continue;
            ab(port, "open", b.url);
            sleep(1500);
            const detail = extractDetail(port);
            if (detail) {
              if (detail.tags?.length) b.tags = detail.tags;
              if (detail.intro) b.descText = detail.intro;
              if (detail.update) b.updateText = detail.update;
              for (const field of ["words", "totalRecommendations", "signing", "pricing"]) {
                if (detail[field]) b[field] = detail[field];
              }
            }
            console.log(`    [${i + 1}/${books.length}] ${b.title}`);
          }
          // 返回榜单页
          ab(port, "open", url);
          sleep(2000);
        }
      
        return renderMarkdown(rt, books, url, "cdp-pc");
      }
      
      async function scrapeRank(rankTypeId) {
        if (!["auto", "mobile", "cdp"].includes(SCRAPE_MODE)) {
          throw new Error(`未知 --mode: ${SCRAPE_MODE}(可选 auto/mobile/cdp)`);
        }
      
        if (SCRAPE_MODE !== "cdp") {
          try {
            const content = await scrapeRankMobile(rankTypeId);
            if (content || SCRAPE_MODE === "mobile") return content;
          } catch (e) {
            console.log(`  ⚠ 移动端 SSR 采集失败: ${e.message}`);
            if (SCRAPE_MODE === "mobile") return null;
          }
        }
      
        if (SCRAPE_MODE !== "mobile") {
          console.log("  → 回退到 CDP/PC 页面采集");
          return scrapeRankCDP(PORT, rankTypeId);
        }
      
        return null;
      }
      
      async function main() {
        // 参数错误是配置问题,不是单个榜单的瞬时失败:先于 per-榜单隔离快速失败
        if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
          throw new Error(`未知 --type: ${RANKTYPE}`);
        }
        if (!["auto", "mobile", "cdp"].includes(SCRAPE_MODE)) {
          throw new Error(`未知 --mode: ${SCRAPE_MODE}(可选 auto/mobile/cdp)`);
        }
      
        const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
        let written = 0;
        let failed = 0;
        const partialReasons = [];
      
        for (const rt of rankTypes) {
          // per-榜单隔离:移动端 SSR 失败后的 CDP 回退会直接抛(ab() 不吞错),
          // 一个榜单的瞬时失败不该掐掉 --type all 后面的榜单(与番茄/刺猬猫一致)
          try {
            const content = await scrapeRank(rt);
            if (!content) {
              failed++;
              const rtInfo = RANK_TYPES.find((r) => r.id === rt);
              partialReasons.push(`${rtInfo ? rtInfo.label : rt}: no usable data`);
              continue;
            }
      
            const rtInfo = RANK_TYPES.find((r) => r.id === rt);
            const filename = `起点${rtInfo.label}_${localDateStamp()}.md`;
            fs.mkdirSync(OUTDIR, { recursive: true });
            const filepath = path.join(OUTDIR, filename);
            fs.writeFileSync(filepath, content, "utf-8");
            written++;
            console.log(`  ✓ 已保存: ${filepath}`);
          } catch (rankErr) {
            failed++;
            const rtInfo = RANK_TYPES.find((r) => r.id === rt);
            const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
            partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${message}`);
            console.error(
              `[qidian] ${rtInfo ? rtInfo.label : rt} 采集失败,跳过: ${message}`
            );
          }
        }
        return {
          planned: rankTypes.length,
          written,
          failed,
          partial: failed > 0,
          partialReasons,
        };
      }
      
      if (require.main === module) {
        runCli(main, "起点采集");
      }
      
      module.exports = {
        extractBookList,
        mobileUrl,
        extractMobilePageContext,
        normalizeMobileBook,
        cleanDesc,
        renderMarkdown,
      };
      
    • qimao-rank-scraper.js 16.3 KB
      #!/usr/bin/env node
      /**
       * 七猫小说排行榜采集脚本
       *
       * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
       * 采集策略:tab 切换男生榜/女生榜和榜单类型,滚动加载后从页面文本解析结构化数据。
       * 输出 Markdown 格式匹配 scan-output-format.md 规范。
       *
       * 用法:
       *   node qimao-rank-scraper.js --channel male --type hot --period day    # 男生大热榜日榜
       *   node qimao-rank-scraper.js --channel male --type hot --period month  # 男生大热榜月榜
       *   node qimao-rank-scraper.js --channel male --type hot --period all    # 日榜+月榜
       *   node qimao-rank-scraper.js --channel female --type new      # 女生新书榜
       *   node qimao-rank-scraper.js --channel all --type all         # 全部采集
       *
       * 前置:
       *   node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
       */
      
      const fs = require("fs");
      const path = require("path");
      const { ab, sleep, evalJSONBase64, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils");
      
      const RANK_URL = "https://www.qimao.com/paihang";
      
      /** 连通性 + 页面就绪自检 */
      function probePage(port) {
        return evalJSONBase64(
          port,
          "JSON.stringify({host:location.host,path:location.pathname,len:(document.body&&document.body.innerText||'').length})"
        );
      }
      
      const CHANNELS = [
        { id: "male", label: "男频", tab: "男生榜", path: "boy" },
        { id: "female", label: "女频", tab: "女生榜", path: "girl" },
      ];
      
      const RANK_TYPES = [
        { id: "hot", label: "大热榜", path: "hot" },
        { id: "new", label: "新书榜", path: "new" },
        { id: "finish", label: "完结榜", path: "over" },
        { id: "collect", label: "收藏榜", path: "collect" },
        { id: "update", label: "更新榜", path: "update" },
      ];
      
      const PERIODS = [
        { id: "day", label: "日榜", path: "date" },
        { id: "month", label: "月榜", path: "month" },
      ];
      
      // ---------------------------------------------------------------------------
      // 页面操作
      // ---------------------------------------------------------------------------
      
      function rankUrl(channelId, rankTypeId, periodId) {
        const channel = CHANNELS.find((item) => item.id === channelId);
        const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
        const period = PERIODS.find((item) => item.id === (periodId || "day"));
        if (!channel || !rankType || !period) return "";
        return `${RANK_URL}/${channel.path}/${rankType.path}/${period.path}/`;
      }
      
      /** 读取页面实际 active 状态;输出文件标签必须由该状态校验后才能使用。 */
      function extractObservedSelection(port) {
        const js = `JSON.stringify((function(){
          function text(selector){var e=document.querySelector(selector);return e?(e.textContent||'').replace(/\\s+/g,'').trim():'';}
          return {path:location.pathname,channel:text('.qm-switch-tab .item.active'),rankType:text('.child-tabs-item.menu-tab.active'),period:text('.date-type-tabs .tab.active')};
        })())`;
        return evalJSONBase64(port, js) || {};
      }
      
      function selectionMatches(observed, channelId, rankTypeId, periodId) {
        const channel = CHANNELS.find((item) => item.id === channelId);
        const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
        const period = periodId ? PERIODS.find((item) => item.id === periodId) : null;
        if (!channel || !rankType) return false;
        const expectedUrl = rankUrl(channelId, rankTypeId, periodId);
        if (!expectedUrl) return false;
        const expectedPath = new URL(expectedUrl).pathname;
        const actualPath = String(observed && observed.path || "").replace(/\/+$/, "/");
        return !!(
          actualPath === expectedPath &&
          String(observed.channel || "").includes(channel.tab) &&
          observed.rankType === rankType.label &&
          (!period || observed.period === period.label)
        );
      }
      
      /**
       * 从 DOM 获取书籍链接。每本书有多个 anchor(排名数字/书名/最近更新),
       * 按 bookId 聚合后取最像书名的文本(非纯数字、非"最近更新"前缀、最长),
       * 否则书名会被排名数字 anchor 覆盖,导致后续按书名回填链接全失败。
       */
      function extractBookUrls(port) {
        const js = `JSON.stringify((function(){
          var byId={};var order=[];
          Array.from(document.querySelectorAll('a')).forEach(function(a){
            var h=a.getAttribute('href')||a.href||'';
            var m=h.match(/\\/(?:shuku|book)\\/([0-9]+)/);
            if(!m)return; var id=m[1];
            var t=(a.innerText||a.textContent||'').replace(/\\s+/g,' ').trim();
            if(!byId[id]){byId[id]='';order.push(id);}
            if(t&&!/^[0-9]+$/.test(t)&&!/^(最近更新|最新章节|最新)/.test(t)){
              if(t.length>byId[id].length)byId[id]=t;
            }
          });
          return order.map(function(id){return {bookId:id,title:byId[id],url:'https://www.qimao.com/shuku/'+id+'/'};});
        })())`;
        return evalJSONBase64(port, js) || [];
      }
      
      /**
       * 从页面 innerText 解析结构化书籍数据。
       * 七猫页面文本结构固定:排名→书名→作者→题材→子分类→状态→字数→简介→更新→热度
       */
      function extractBooksFromText(port) {
        const js =
          "JSON.stringify((()=>{" +
          "var text=document.body.innerText||'';" +
          // 找到榜单数据起始位置
          "var start=-1;" +
          "['日榜','月榜'].forEach(function(m){if(start<0)start=text.indexOf(m)});" +
          "if(start<0)return[];" +
          "var lines=text.substring(start).split(/\\n/);" +
          "var books=[];var cur=null;var fieldIdx=0;" +
          "for(var i=0;i<lines.length;i++){" +
          "  var line=lines[i].trim();" +
          "  if(!line)continue;" +
          // 排行数据结束后的分页器/页脚必须立刻截断;否则“5 / 下一页 / 跳转 / 友情链接”
          // 会被串成一条字段齐全的假书目。
          "  if(/^(上一页|下一页|跳转|友情链接[::]?)$/.test(line)){if(cur&&cur.title)books.push(cur);cur=null;break}" +
          // 排名标记:独立数字 1-99
          "  if(/^\\d{1,2}$/.test(line)&&parseInt(line)<100){" +
          "    if(cur&&cur.title)books.push(cur);" +
          "    cur={rank:parseInt(line),title:'',author:'',genre:'',subGenre:'',status:'',words:'',heat:'',update:'',desc:''};" +
          "    fieldIdx=0;continue" +
          "  }" +
          "  if(!cur)continue;" +
          // 跳过 UI 文字
          "  if(/^(加入书架|立即阅读|蝉联|榜首)/.test(line))continue;" +
          // 热度
          "  var hm=line.match(/([\\d.]+)\\s*万\\s*热度/);" +
          "  if(hm){cur.heat=hm[1]+'万';continue}" +
          // 最新更新
          "  if(line.indexOf('最近更新')===0){cur.update=line.replace(/^最近更新\\s*/,'');continue}" +
          // 状态
          "  if(/^(连载中|已完结)$/.test(line)){cur.status=line;continue}" +
          // 字数
          "  if(/^[\\d.]+万字$/.test(line)){cur.words=line;continue}" +
          // 按序填充:书名→作者→题材→子分类
          "  if(fieldIdx===0){cur.title=line;fieldIdx=1;continue}" +
          "  if(fieldIdx===1){cur.author=line;fieldIdx=2;continue}" +
          "  if(fieldIdx===2){cur.genre=line;fieldIdx=3;continue}" +
          "  if(fieldIdx===3){cur.subGenre=line;fieldIdx=4;continue}" +
          // 其余为简介
          "  cur.desc+=(cur.desc?' ':'')+line" +
          "}" +
          "if(cur&&cur.title)books.push(cur);" +
          "return books" +
          "})())";
        return evalJSONBase64(port, js) || [];
      }
      
      /**
       * 排除分页器等被正文文本解析器误认成的伪书目。
       * 七猫榜单尾部会出现“5 / 下一页”这类纯 UI 文本;有效条目必须同时有正排名、书名和作者。
       */
      function isUsableBook(book) {
        return !!(
          book &&
          Number.isInteger(book.rank) &&
          book.rank > 0 &&
          book.title &&
          book.author &&
          !/^(上一页|下一页|跳转)$/.test(book.title) &&
          !/^(上一页|下一页|跳转|友情链接[::]?)$/.test(book.author)
        );
      }
      
      function cleanDesc(value) {
        const text = String(value || "")
          .replace(/\s*(?:飙升|上升|下降)\s*\d+\s*名\s*$/g, "")
          .replace(/\s*(?:上一页|下一页)\s*$/g, "")
          .replace(/\s+/g, " ")
          .trim();
        if (text.length <= 100) return text;
        const cut = text.slice(0, 100);
        const sentence = cut.match(/^[\s\S]*[。!?]/);
        return (sentence ? sentence[0] : cut) + "...";
      }
      
      function summarizeQuality(books, rawCount) {
        const linked = books.filter((book) => book.url).length;
        const heated = books.filter((book) => book.heat).length;
        const fieldCounts = [
          ["题材", "genre"],
          ["子分类", "subGenre"],
          ["状态", "status"],
          ["字数", "words"],
          ["热度", "heat"],
        ].map(([label, field]) => ({
          label,
          missing: books.filter((book) => !book[field]).length,
        }));
        const problems = [];
        if (rawCount > books.length) problems.push(`移除无效/UI条目 ${rawCount - books.length} 条`);
        if (linked < books.length) problems.push(`作品页链接缺失 ${books.length - linked} 条`);
        for (const field of fieldCounts) {
          if (field.missing) problems.push(`${field.label}缺失 ${field.missing} 条`);
        }
        if (books.length < 15) problems.push(`[数据稀疏] 实际采集 ${books.length} 条`);
        return {
          linked,
          heated,
          problems,
          quality: problems.length ? "[存在问题]" : "[OK]",
        };
      }
      
      function renderMarkdown(ch, rt, period, url, books, rawCount, now = new Date().toISOString()) {
        const periodLabel = period ? period.label : "";
        const summary = summarizeQuality(books, rawCount);
        const lines = [
          `# 七猫 · ${ch.label} · ${rt.label}${periodLabel}`,
          "",
          `- 数据质量:${summary.quality}`,
          `- 有效条目:${books.length} / ${rawCount}`,
          `- 问题摘要:${summary.problems.length ? summary.problems.join(";") : "无"}`,
          `- 作品页链接:${summary.linked} / ${books.length}`,
          `- 热度命中:${summary.heated} / ${books.length}`,
          `- 来源:${url}`,
          `- 抓取时间:${now}`,
          `- 条目数:${books.length}`,
          "",
          "---",
          "",
        ];
      
        for (const b of books) {
          try {
            lines.push(`### #${b.rank} ${b.title}`);
            const meta = [
              b.author || "[待补]",
              b.genre || "[待补]",
              b.subGenre || "[待补]",
              b.status || "[待补]",
              b.words || "[待补]",
              b.heat ? b.heat + "热度" : "[待补]",
            ].join(" · ");
            lines.push(`*${meta}*`);
            if (b.update) lines.push(`**最新更新:** ${b.update}`);
            if (b.url) lines.push(`[作品页](${b.url})`);
            const desc = cleanDesc(b.desc);
            if (desc) {
              lines.push("");
              lines.push("**简介**");
              lines.push("");
              lines.push(desc);
            }
            lines.push("", "---", "");
          } catch (bookErr) {
            console.error(`[qimao] ${ch.label}${rt.label} 第${b.rank}条处理出错: ${bookErr.message}`);
            lines.push("", "---", "");
          }
        }
      
        return lines.join("\n");
      }
      
      // ---------------------------------------------------------------------------
      // 主流程
      // ---------------------------------------------------------------------------
      
      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";
      const RANKTYPE = getArg(args, "--type") || "hot";
      const PERIOD = getArg(args, "--period") || "day";
      
      function scrapeRank(port, channelId, rankTypeId, periodId) {
        const ch = CHANNELS.find((c) => c.id === channelId);
        const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
        const period = periodId ? PERIODS.find((p) => p.id === periodId) : null;
        if (!ch || !rt) {
          console.log("  ⚠ 未知频道或榜单类型");
          return null;
        }
      
        const periodLabel = period ? period.label : "";
        const url = rankUrl(channelId, rankTypeId, periodId);
        console.log(`\n→ 采集 七猫${ch.label}${rt.label}${periodLabel}...`);
      
        let books, urls, rawCount;
        try {
          ab(port, "open", 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("qimao") === -1) {
            console.error(`  ✗ 当前页面非七猫(host=${probe.host}),可能被重定向,已跳过。`);
            return null;
          }
          const observed = extractObservedSelection(port);
          if (!selectionMatches(observed, channelId, rankTypeId, periodId)) {
            console.error(
              `  ✗ 页面实际榜单与请求不一致(请求 ${ch.tab}/${rt.label}/${periodLabel || "日榜"},` +
              `实际 ${observed.channel || "?"}/${observed.rankType || "?"}/${observed.period || "?"},path=${observed.path || probe.path || "?"}),已跳过。`
            );
            return null;
          }
          console.log(`  ✓ 已验证页面实际榜单:${observed.channel}/${observed.rankType}${observed.period ? "/" + observed.period : ""}`);
      
          // 滚动加载更多
          scrollLoad(port, 5);
          sleep(1000);
      
          // 文本解析获取书籍数据 + DOM 获取链接
          const rawBooks = extractBooksFromText(port);
          rawCount = rawBooks.length;
          books = rawBooks.filter(isUsableBook);
          urls = extractBookUrls(port);
        } catch (err) {
          console.error(`[qimao] ${ch.label}${rt.label}${periodLabel} 页面加载或提取出错: ${err.message}`);
          return null;
        }
      
        if (!books.length) {
          console.error(`[qimao] 采集失败:页面结构可能已变(选择器没匹配到数据),请检查榜单URL或更新选择器 (${RANK_URL} ${ch.label}${rt.label}${periodLabel})`);
          return null;
        }
      
        // 按标题匹配 URL(书名归一后比对,吸收空白差异)
        const norm = (s) => (s || "").replace(/\s+/g, "");
        for (const b of books) {
          try {
            const matched = urls.find((u) => norm(u.title) === norm(b.title));
            if (matched) b.url = matched.url;
          } catch (matchErr) {
            console.error(`[qimao] URL匹配出错(#${b.rank} ${b.title}): ${matchErr.message}`);
          }
        }
      
        const summary = summarizeQuality(books, rawCount);
        console.log(
          `  ✓ 提取 ${books.length} 本(链接 ${summary.linked}/${books.length},热度 ${summary.heated}/${books.length})`
        );
        return renderMarkdown(ch, rt, period, url, books, rawCount);
      }
      
      function buildTargets(channel, rankType, period) {
        const channels = channel === "all" ? CHANNELS.map((item) => item.id) : [channel];
        const rankTypes = rankType === "all" ? RANK_TYPES.map((item) => item.id) : [rankType];
        const targets = [];
        for (const channelId of channels) {
          for (const rankTypeId of rankTypes) {
            if (rankTypeId === "hot") {
              const periods = period === "all" ? PERIODS.map((item) => item.id) : [period];
              for (const periodId of periods) {
                targets.push({ channel: channelId, rankType: rankTypeId, period: periodId });
              }
            } else {
              targets.push({ channel: channelId, rankType: rankTypeId, period: null });
            }
          }
        }
        return targets;
      }
      
      function outputFilename(channelId, rankTypeId, periodId, date) {
        const channel = CHANNELS.find((item) => item.id === channelId);
        const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
        const period = periodId ? PERIODS.find((item) => item.id === periodId) : null;
        return `七猫${channel.label}${rankType.label}${period ? period.label : ""}_${date}.md`;
      }
      
      function main() {
        if (CHANNEL !== "all" && !CHANNELS.some((channel) => channel.id === CHANNEL)) {
          throw new Error(`未知 --channel: ${CHANNEL}`);
        }
        if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
          throw new Error(`未知 --type: ${RANKTYPE}`);
        }
        if (PERIOD !== "all" && !PERIODS.some((period) => period.id === PERIOD)) {
          throw new Error(`未知 --period: ${PERIOD}`);
        }
        const targets = buildTargets(CHANNEL, RANKTYPE, PERIOD);
        let written = 0;
        let failed = 0;
      
        for (const target of targets) {
          const content = scrapeRank(PORT, target.channel, target.rankType, target.period);
          if (!content) {
            failed++;
            continue;
          }
      
          const filename = outputFilename(target.channel, target.rankType, target.period, localDateStamp());
          fs.mkdirSync(OUTDIR, { recursive: true });
          const filepath = path.join(OUTDIR, filename);
          fs.writeFileSync(filepath, content, "utf-8");
          written++;
          console.log(`  ✓ 已保存: ${filepath}`);
        }
        return {
          planned: targets.length,
          written,
          failed,
          partial: failed > 0,
          partialReasons: [],
        };
      }
      
      if (require.main === module) {
        runCli(main, "七猫采集");
      }
      
      module.exports = {
        extractBooksFromText,
        isUsableBook,
        cleanDesc,
        renderMarkdown,
        rankUrl,
        selectionMatches,
        buildTargets,
        outputFilename,
      };
      
  • SKILL.md 16 KB
    ---
    name: story-long-scan
    version: 1.0.0
    description: "长篇网文扫榜。分析起点、番茄、晋江等平台排行榜数据,提炼市场趋势与热门题材。触发方式:/story-long-scan、/长篇扫榜、「长篇什么火」「起点排行」。"
    metadata: {"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
    ---
    # story-long-scan:长篇网文扫榜
    
    你是网络小说市场分析师。你的任务是基于榜单样本识别长篇网文市场格局,并输出可执行的题材候选、风险阈值和验证动作。
    
    **核心信念:单本排名只提供线索;跨样本重复模式才算信号。** 排行榜只能证明样本存在;必须通过多榜单、多作品和近期数据判断需求强度。
    
    ---
    
    ## 核心哲学
    
    ### 原则 1:扫榜看模式,别只看排名
    
    排名会波动,模式必须用重复样本验证。扫榜要提取:反复出现的题材、设定、套路、书名词和开篇卖点。单本上榜只能记为个例;同类样本达到可比数量后,才能标记为趋势候选。
    
    ### 原则 2:流量型平台和付费型平台看的东西不同
    
    番茄看的是流量和完读率,起点看的是订阅和追读,晋江看的是收藏和积分。不同平台的成功标准不同,扫榜方法也不同。
    
    ### 原则 3:扫榜的目的是找到你能写的爆款题材
    
    不按热度直接给结论。每个方向都要做项目可行性判断:素材储备、题材边界、篇幅承载、目标平台样本是否足够。
    
    ---
    
    ## 扫榜流程
    
    ### Phase 1:确认平台和方向
    
    问用户:**「你想看哪个平台?(起点/番茄/晋江/其他)有没有关注的题材方向?」**
    
    关键判断:
    - 用户已有方向 → 针对该方向做深度扫榜
    - 用户没有方向 → 做全榜概览 + 找趋势
    - 用户想跨平台比较 → 做平台对比分析
    
    ---
    
    ### Phase 2:确定数据来源
    
    **扫榜需要真实数据支撑。** 根据当前环境选择数据来源:
    
    | 优先级 | 模式 | 说明 | 何时用 |
    |--------|------|------|--------|
    | 1 | **脚本采集** | 直接抓取平台页面/SSR 数据,产出结构化文件 | 优先;起点默认不需要 Chrome |
    | 2 | **用户提供** | 用户粘贴榜单截图/文字/链接 | 用户已有数据时 |
    | 3 | **内置知识** | 基于知识库趋势数据做分析 | 无法联网、用户无数据时 |
    
    #### 脚本采集模式
    
    优先运行对应平台脚本直接采集结构化数据。起点使用移动端 SSR pageContext,默认不需要 Chrome/CDP;番茄等需要浏览器态的平台再使用 `/browser-cdp` 启动 Chrome。
    
    **采集流程**:
    1. 选择平台脚本;起点直接运行 `scripts/qidian-rank-scraper.js`,番茄/七猫/晋江等按需启动 browser-cdp
    2. 等待列表元素或 SSR 数据加载,逐条提取字段(排名、书名、作者、题材、字数、推荐/在读数等),判断翻页(起点通常单页50-100条,番茄按题材逐页cap≈20)
    3. 需要补充数据时(标签、简介、最新更新),进入详情页提取
    4. 按规范格式写入 Markdown 文件
    5. 多榜单/多题材时,逐组采集并保存
    
    **输出规范**:详见 [references/scan-output-format.md](references/scan-output-format.md),包含各平台字段定义、输出模板。
    
    **起点采集目标**(优先运行 `node scripts/qidian-rank-scraper.js --type {榜单} --outdir {输出目录}`;默认 `--mode auto` 会先用 `https://m.qidian.com` 移动端 SSR,PC/CDP 只作回退):
    
    | 榜单 | URL | 核心字段 |
    |------|-----|----------|
    | 新人签约新书榜 | qidian.com/rank/newsign/ | 作者·题材·签约·免费/VIP·字数·总推荐·标签·简介 |
    | 签约作者新书榜 | qidian.com/rank/signnewbook/ | 已签约作者新书,新风向信号 |
    | 公众作者新书榜 | qidian.com/rank/pubnewbook/ | 公众作者新书,发现潜力作者 |
    | 新人作者新书榜 | qidian.com/rank/newauthor/ | 新人作品,新人赛道风向 |
    | 三江推荐 | qidian.com/sanjiang/ | 编辑推荐,按周分组(注意:非 /rank/ 路径) |
    | 月票榜 | qidian.com/rank/yuepiao/ | 付费认可度最高指标 |
    | 畅销榜 | qidian.com/rank/hotsales/ | 真金白银投票 |
    | 阅读指数榜 | qidian.com/rank/readindex/ | 阅读量综合指标 |
    | 收藏榜 | qidian.com/rank/collect/ | 读者关注热度 |
    | 原创推荐榜 | qidian.com/rank/recom/ | |
    
    **番茄采集目标**:
    
    | 榜单 | URL格式 | 核心字段 |
    |------|---------|----------|
    | 男频阅读榜 | fanqienovel.com/rank/1_2_{cat_id} | 按题材逐页采集,在读数为核心指标 |
    | 女频阅读榜 | fanqienovel.com/rank/0_2_{cat_id} | 按题材逐页采集 |
    | 男频新书榜 | fanqienovel.com/rank/1_1_{cat_id} | 新风向信号 |
    | 女频新书榜 | fanqienovel.com/rank/0_1_{cat_id} | 新风向信号 |
    
    URL 参数:`/rank/{channel}_{type}_{cat_id}`,channel 0=女频/1=男频,type 1=新书榜/2=阅读榜。番茄列表页有字体反爬,须用 `scripts/fanqie-rank-scraper.js` 从详情页多策略解码书名/作者/题材/评分/标签/简介,配合 browser-cdp 使用:
    
    ```bash
    node scripts/fanqie-rank-scraper.js --channel 1 --type 2 --outdir {输出目录}   # 男频阅读榜
    node scripts/fanqie-rank-scraper.js --channel all --top 15 --outdir {输出目录}   # 男女频,每题材前 15 本
    ```
    
    > **番茄采集后必查文件头 `数据质量`**,异常排查步骤见 [references/scan-output-format.md](references/scan-output-format.md)。
    
    **七猫采集目标**:
    
    | 榜单 | URL | 核心字段 |
    |------|-----|----------|
    | 排行榜总入口 | qimao.com/paihang | 大热榜/新书榜/完结榜,热度为核心指标 |
    
    榜单类型:大热榜(日榜/月榜)、新书榜、完结榜、收藏榜、更新榜,支持男生榜/女生榜切换。
    
    大热榜用 `--period day|month|all` 显式选择日榜、月榜或两者(默认 `day`);周期会进入文件头与文件名。非大热榜不区分周期,`--period` 不会重复采集。
    
    **晋江采集目标**(`scripts/jjwxc-rank-scraper.js`,默认列表 + 详情两步走):
    
    | 榜单 | URL | 核心字段 |
    |------|-----|----------|
    | 收入金榜 | jjwxc.net/topten.php?orderstr=12&t=0 | 收藏数、营养液、积分、字数、状态(详情页 `onebook.php` 补采) |
    
    ```bash
    node scripts/jjwxc-rank-scraper.js --type 12 --outdir {输出目录}        # 列表+详情(默认每频道前10,详情上限100)
    node scripts/jjwxc-rank-scraper.js --type 12 --top 15 --detail-limit 60  # 调整每频道本数/详情总量
    node scripts/jjwxc-rank-scraper.js --type 12 --list-only                 # 只采列表(快,无核心指标)
    ```
    
    > **晋江硬性要求**:必须有详情页核心指标(收藏数/营养液/积分/字数),脚本默认已补采;采集要点见 [references/scan-output-format.md](references/scan-output-format.md)。
    
    **文件命名**:`{平台}{榜单名称}_{YYYYMMDD}.md`,例:`起点新人签约新书榜_20260425.md`
    
    #### 采集质量检查(「确定数据来源」完成后必须执行)
    
    每完成一个榜单的采集,立即执行以下检查。发现问题当场修复,不留给后续分析。详细规则见 [references/scan-output-format.md](references/scan-output-format.md)「数据清洗与字段约束」。
    
    **1. 数据完整性**
    
    | 检查项 | 标准 | 处理 |
    |--------|------|------|
    | 条目数量 | >= 15 条有效数据(小平台 >= 10) | 不足则在文件头注明 `[数据稀疏] 实际采集 N 条` |
    | 必填字段 | 排名、书名、作者(缺任一项视为无效) | 无效条目移除,条目数重新计算 |
    | 字段一致性 | 同一榜单内所有条目字段集必须一致 | 不一致条目标记 `[字段缺失: {字段名}]` |
    
    **2. 数据清洗**
    
    | 污染类型 | 处理 |
    |----------|------|
    | 平台模板文本(番茄「提供XXX完整版在线免费阅读」、七猫「上一页」等) | 删除模板文本,保留正文 |
    | 解析串行(同一条目出现两个不同作品的数据) | 标记 `[解析异常]`,删除并重新采集 |
    | 空字段(空白、`--`、`未知`) | 标记 `[待补]`,优先通过详情页补采 |
    
    **3. 简介截断**
    
    - 清洗后超过 100 字的简介,在最近的句号/问号/感叹号处截断,加 `...`
    - 平台模板文本不计入 100 字限制(先删除模板,再截断)
    
    **4. 文件头质量状态**
    
    每个采集文件头部必须包含:
    
    ```
    - 数据质量:[OK / 存在问题]
    - 有效条目:{N} / {总数}
    - 问题摘要:{无 / 具体问题描述}
    ```
    
    #### 其他数据来源
    
    **用户提供操作指引:**
    - 用户提供已有的扫描结果文件路径 → 直接加载进入「数据分析」
    - 用户提供链接 → 用 WebFetch 抓取
    - 用户粘贴/截图 → 手动解析进入分析
    
    **内置知识操作指引:**
    - 加载 `references/genre-trends.md`
    - 明确标注:「以下分析基于历史趋势数据;未完成实时榜单校验前只能作为候选假设。」并列出需要复扫的榜单。
    
    ---
    
    ### Phase 3:数据分析
    
    根据用户选择的平台,结合已获取的数据做以下分析:
    
    #### 起点中文网分析维度
    
    | 维度 | 看什么 |
    |---|---|
    | 月票榜/推荐票榜 | 付费用户认可度高、持续追读强 |
    | 畅销榜 | 真金白银投票,最硬核的指标 |
    | 签约作者新书榜 | 已签约作者的新作风向 |
    | 公众作者新书榜 | 公众作者的新作,发现潜力股 |
    | 新人作者新书榜 | 新作者作品与新题材信号 |
    | 三江推荐 | 编辑精选推荐,按周分组,发现平台力推作品 |
    | 分类榜单 | 各垂直题材的竞争格局 |
    | 追读率 | 核心指标,决定推荐位分配 |
    
    #### 番茄小说分析维度
    
    | 维度 | 看什么 |
    |---|---|
    | 阅读榜 | 流量与读者规模,在读数为核心指标 |
    | 新书榜 | 新题材、新风向的早期信号 |
    | 题材分布 | 各品类在读数集中度 |
    | 在读数趋势 | 同题材不同作品的流量差距 |
    | 标签热词 | 简介开头【】内的标签组合,揭示题材细分卖点(如「种田+慢热+西幻」) |
    
    #### 七猫小说分析维度
    
    | 维度 | 看什么 |
    |---|---|
    | 大热榜 | 热度排名,反映流量集中度 |
    | 新书榜 | 新流量风口 |
    | 完结榜 | 长尾价值作品 |
    | 热度指标 | 七猫核心指标,反映读者活跃度 |
    
    #### 晋江文学城分析维度
    
    > **采集硬性要求**:若用了 `--list-only` 或文件头标 `[仅列表-无核心指标]`,则该数据不足以支撑以下分析维度,视为不合格。
    
    | 维度 | 看什么 |
    |---|---|
    | 金榜 | 综合热度最高 |
    | 季度榜 | 中期趋势 |
    | 红字/黑字 | 积分与负面评价 |
    | 收藏/营养液 | 女频市场的核心指标 |
    
    #### 通用分析维度
    
    对每个平台的榜单数据,提取:
    
    1. **题材分布**:当前榜上哪些题材最多
    2. **新题材信号**:最近新出现的题材类型
    3. **经典题材变化**:老牌题材的走势(上升/稳定/下降)
    4. **字数与更新**:上榜作品的字数区间和更新频率
    5. **书名模式**:上榜作品的命名规律
    6. **开头卖点**:简介/标签中反复出现的关键词
    7. **新元素对比**:与上期/同类榜单对比,标注新出现的人物设定、开篇切入点、桥段套路
    
    ---
    
    ### Phase 4:输出扫榜报告
    
    ```
    # 长篇网文扫榜报告:{平台名称}
    
    ## 市场概况
    - 扫榜时间:{日期}
    - 核心发现:{一句话总结}
    
    ## 题材热度排行
    | 排名 | 题材 | 榜上数量 | 趋势 | 代表作 |
    |------|------|----------|------|--------|
    | 1 | {题材} | {N本} | ↑/→/↓ | {书名} |
    
    ## 新题材信号
    - {新出现或正在上升的题材,附依据}
    
    ## 经典题材动态
    - {老牌题材的现状,附依据}
    
    ## 新元素提取
    ### 新人物设定模式
    - {新模式描述 + 代表作}
    
    ### 新开篇切入点
    - {新切入点描述 + 代表作}
    
    ### 新桥段/套路
    - {新桥段描述 + 代表作}
    
    ## 关键数据洞察
    - 字数区间:上榜作品集中在 {X}-{Y} 万字
    - 更新频率:日均 {X} 字为主流
    - 书名特征:{命名模式总结}
    - 标签热词:{高频标签词}
    
    ## 值得关注的方向
    1. {方向 + 为什么值得关注 + 可行性评估}
    2. {方向 + 为什么值得关注 + 可行性评估}
    3. {方向 + 为什么值得关注 + 可行性评估}
    
    ## 一句话
    {犀利的总结}
    ```
    
    ---
    
    ### Phase 5:选题决策
    
    把扫榜结果变成能直接用的选题建议,产出 `选题决策.md`。完整方法(选题四步 + 可行性判断 + 输出模板)见 [references/topic-decision.md](references/topic-decision.md)。
    
    **如信息不足,向用户补齐项目条件:**「目标平台、已有素材、擅长题材/写作约束、计划篇幅是什么?」
    
    按 `topic-decision.md` 的选题四步产出 2-3 个推荐选题(能爆的原因 → 市场验证 → 差异化定位 → 可行性+失败风险+验证动作),写入**本次扫榜输出目录** `{outdir}/选题决策.md`
    
    **硬规则:**
    - 可行性上限:背靠榜单标了 `[数据稀疏]` 或同方向样本 <15(小平台<10)⇒ 不许给"高",强制降到"中" + 写明先验证;内置知识模式一律给"中"。
    - 不输出项目素材无法支撑的题材;不只看热度,必须给可行性和失败风险;不忽略平台调性差异(起点男频和晋江女频审美完全不同)。
    
    ---
    
    ## 平台特性速查
    
    | 平台 | 调性 | 核心指标 | 主力读者 | 适合类型 |
    |------|------|----------|----------|----------|
    | 起点中文网 | 男频为主,硬核爽文 | 追读率、月票 | 18-35 男性 | 玄幻、都市、科幻、游戏 |
    | 番茄小说 | 下沉市场,免费阅读 | 在读数、阅读榜排名 | 大众读者 | 脑洞、快节奏、强爽感 |
    | 晋江文学城 | 女频为主,精品路线 | 收藏、营养液、积分 | 16-30 女性 | 言情、纯爱、衍生 |
    | 七猫小说 | 下沉市场,免费阅读 | 热度、大热榜排名 | 大众读者 | 快节奏爽文 |
    | 刺猬猫 | 二次元、轻小说 | 追读 | 15-25 ACG | 同人、二次元、轻小说 |
    
    ---
    
    ## 流程衔接
    
    **流水线:** 长篇
    **位置:** 扫榜(第 1/3 步)
    
    | 时机 | 跳转到 | 命令 |
    |---|---|---|
    | 找到方向 | story-long-analyze | `/story-long-analyze` |
    | 直接开写 | story-long-write | `/story-long-write` |
    | 更适合短篇 | story-short-scan | `/story-short-scan` |
    
    ## 参考资料
    
    按需加载以下文件:
    
    | 文件 | 何时加载 |
    |------|----------|
    | [references/topic-decision.md](references/topic-decision.md) | 「选题决策」:选题四步 + 可行性判断 + 选题决策.md 模板 |
    | [references/reader-profiling.md](references/reader-profiling.md) | 需要分析目标读者画像时 |
    | [references/genre-trends.md](references/genre-trends.md) | 查看题材趋势候选、切入约束和样本校验规则时 |
    | [references/publishing-guide.md](references/publishing-guide.md) | 平台适配+推荐机制校验+数据指标+简介设计 |
    | [references/scan-output-format.md](references/scan-output-format.md) | 脚本/CDP 采集字段定义+输出模板 |
    | [scripts/cdp-utils.js](scripts/cdp-utils.js) | CDP 公共工具函数(ab/sleep/evalJSON/safeStr/scrollLoad/getArg),各采集脚本共用 |
    | [scripts/fanqie-rank-scraper.js](scripts/fanqie-rank-scraper.js) | 番茄榜单采集,分批请求防超时,带连通性自检+标题解析率质量标注,配合 browser-cdp 使用 |
    | [scripts/qidian-rank-scraper.js](scripts/qidian-rank-scraper.js) | 起点榜单采集(畅销/月票/新书等),默认移动端 SSR 提取,PC/CDP 回退 |
    | [scripts/qimao-rank-scraper.js](scripts/qimao-rank-scraper.js) | 七猫榜单采集(大热/新书/完结等),tab 切换(失败重试)+滚动加载,按 bookId 取书名回填作品页链接,带连通性自检+链接/热度命中率标注 |
    | [scripts/jjwxc-rank-scraper.js](scripts/jjwxc-rank-scraper.js) | 晋江榜单采集(收入金榜/月榜等),按频道分组 |
    | [scripts/ciweimao-rank-scraper.js](scripts/ciweimao-rank-scraper.js) | 刺猬猫榜单采集(点击/收藏/月票等),单页 9 榜提取,按 bookId 归一书名回填作品页链接,带连通性自检+空结果重试+链接命中率标注 |
    
    ---
    
    ## 语言
    
    - 跟随用户的语言回复,用户用什么语言就用什么语言回复
    - 中文回复遵循《中文文案排版指北》
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related