story-long-scan
长篇网文扫榜。分析起点、番茄、晋江等平台排行榜数据,提炼市场趋势与热门题材。触发方式:/story-long-scan、/长篇扫榜、「长篇什么火」「起点排行」。
Install
npx skills add https://github.com/zenstory-ai/oh-story-claudecode/tree/main/skills/story-long-scan
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-oh-story-claudecode@llmmart
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。
采集流程:
- 选择平台脚本;起点直接运行
scripts/qidian-rank-scraper.js,番茄/七猫/晋江等按需启动 browser-cdp - 等待列表元素或 SSR 数据加载,逐条提取字段(排名、书名、作者、题材、字数、推荐/在读数等),判断翻页(起点通常单页50-100条,番茄按题材逐页cap≈20)
- 需要补充数据时(标签、简介、最新更新),进入详情页提取
- 按规范格式写入 Markdown 文件
- 多榜单/多题材时,逐组采集并保存
输出规范:详见 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或文件头标[仅列表-无核心指标],则该数据不足以支撑以下分析维度,视为不合格。
| 维度 | 看什么 |
|---|---|
| 金榜 | 综合热度最高 |
| 季度榜 | 中期趋势 |
| 红字/黑字 | 积分与负面评价 |
| 收藏/营养液 | 女频市场的核心指标 |
通用分析维度
对每个平台的榜单数据,提取:
- 题材分布:当前榜上哪些题材最多
- 新题材信号:最近新出现的题材类型
- 经典题材变化:老牌题材的走势(上升/稳定/下降)
- 字数与更新:上榜作品的字数区间和更新频率
- 书名模式:上榜作品的命名规律
- 开头卖点:简介/标签中反复出现的关键词
- 新元素对比:与上期/同类榜单对比,标注新出现的人物设定、开篇切入点、桥段套路
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.
Reviews (0)
No reviews yet.
No comments yet.