
Story Long Scan
- 9.6k installs
- 4.7k repo stars
- Updated July 27, 2026
- worldwonderer/oh-story-claudecode
story-long-scan is an agent skill that scans long-form web fiction platform rankings to identify repeatable genre trends and actionable topic candidates.
About
The story-long-scan skill analyzes long-form web fiction market rankings on platforms like Qidian, Fanqie, and Jinjiang to surface repeatable genre trends rather than single-book spikes. Core philosophy treats one ranking as a clue while cross-sample repeated patterns become signals. Traffic platforms like Fanqie emphasize completion rates while subscription platforms like Qidian focus on paid read metrics and Jinjiang on favorites and points. Phase 1 confirms target platform and genre direction for focused or overview scans. Data sourcing prioritizes script scraping with platform-specific scrapers, falling back to user-provided screenshots or built-in knowledge trends. Qidian collection uses qidian-rank-scraper.js with mobile SSR pageContext by default. Output follows scan-output-format references with structured Markdown files per rank list. Each candidate direction receives feasibility checks on material reserves, genre boundaries, length capacity, and platform sample sufficiency. Use when users invoke long-form rank scanning, ask what genres are hot, or request Qidian ranking analysis.
- Cross-sample pattern detection instead of single-rank conclusions.
- Platform-specific metrics for traffic vs subscription vs favorites platforms.
- Script scraping priority with qidian-rank-scraper.js mobile SSR mode.
- Structured Markdown output per scan-output-format references.
- Feasibility checks on material, genre boundaries, and platform fit.
Story Long Scan by the numbers
- 9,575 all-time installs (skills.sh)
- +777 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #87 of 1,340 Generative Media skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
story-long-scan capabilities & compatibility
- Capabilities
- multi platform rank list scraping · cross sample trend pattern extraction · platform specific success metric interpretation · structured scan markdown output · genre feasibility and validation guidance
- Use cases
- research · copywriting
What story-long-scan says it does
单本排名只提供线索;跨样本重复模式才算信号。
分析起点、番茄、晋江等平台排行榜数据,提炼市场趋势与热门题材。
npx skills add https://github.com/worldwonderer/oh-story-claudecode --skill story-long-scanAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9.6k |
|---|---|
| repo stars | ★ 4.7k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | worldwonderer/oh-story-claudecode ↗ |
What long-form web fiction genres and patterns are trending on Qidian, Fanqie, or Jinjiang rank lists?
Scan Qidian, Fanqie, Jinjiang and other platform rank lists to identify repeatable genre trends and actionable topic candidates.
Who is it for?
Developers and writers researching long-form web fiction market trends before choosing a genre direction.
Skip if: Skip when you need single short-fiction breakdown; use story-short-analyze instead.
When should I use this skill?
User invokes /story-long-scan, asks what genres are hot, or requests Qidian or Fanqie rank analysis.
What you get
Structured rank scan reports with trend candidates, risk thresholds, and validation actions per platform.
Files
story-long-scan:长篇网文扫榜
你是网络小说市场分析师。你的任务是基于榜单样本识别长篇网文市场格局,并输出可执行的题材候选、风险阈值和验证动作。
核心信念:单本排名只提供线索;跨样本重复模式才算信号。 排行榜只能证明样本存在;必须通过多榜单、多作品和近期数据判断需求强度。
---
核心哲学
原则 1:扫榜看模式,别只看排名
排名会波动,模式必须用重复样本验证。扫榜要提取:反复出现的题材、设定、套路、书名词和开篇卖点。单本上榜只能记为个例;同类样本达到可比数量后,才能标记为趋势候选。
原则 2:流量型平台和付费型平台看的东西不同
番茄看的是流量和完读率,起点看的是订阅和追读,晋江看的是收藏和积分。不同平台的成功标准不同,扫榜方法也不同。
原则 3:扫榜的目的是找到你能写的爆款题材
不按热度直接给结论。每个方向都要做项目可行性判断:素材储备、题材边界、篇幅承载、目标平台样本是否足够。
---
扫榜流程
Phase 1:确认平台和方向
问用户:「你想看哪个平台?(起点/番茄/晋江/其他)有没有关注的题材方向?」
关键判断:
- 用户已有方向 → 针对该方向做深度扫榜
- 用户没有方向 → 做全榜概览 + 找趋势
- 用户想跨平台比较 → 做平台对比分析
---
Phase 1.5:确定数据来源
扫榜需要真实数据支撑。 根据当前环境选择数据来源:
| 优先级 | 模式 | 说明 | 何时用 |
|---|---|---|---|
| 1 | 脚本采集 | 直接抓取平台页面/SSR 数据,产出结构化文件 | 优先;起点默认不需要 Chrome |
| 2 | 用户提供 | 用户粘贴榜单截图/文字/链接 | 用户已有数据时 |
| 3 | 内置知识 | 基于知识库趋势数据做分析 | 无法联网、用户无数据时 |
脚本采集模式
优先运行对应平台脚本直接采集结构化数据。起点使用移动端 SSR pageContext,默认不需要 Chrome/CDP;番茄等需要浏览器态的平台再使用 /browser-cdp 启动 Chrome。
采集流程: 1. 选择平台脚本;起点直接运行 scripts/qidian-rank-scraper.js,番茄/七猫/晋江等按需启动 browser-cdp 2. 等待列表元素或 SSR 数据加载,逐条提取字段(排名、书名、作者、题材、字数、推荐/在读数等) 3. 需要补充数据时(标签、简介、最新更新),进入详情页提取 4. 按规范格式写入 Markdown 文件 5. 多榜单/多题材时,逐组采集并保存
输出规范:详见 references/scan-output-format.md,包含各平台字段定义、输出模板、文件命名规范。
起点采集目标(优先运行 node scripts/qidian-rank-scraper.js --type {榜单} --outdir {输出目录};默认 --mode auto 会先用 https://m.qidian.com 移动端 SSR,PC/CDP 只作回退):
| 榜单 | URL | 核心字段 |
|---|---|---|
| 新人签约新书榜 | qidian.com/rank/newsign/ | 作者·题材·签约·免费/VIP·字数·总推荐·标签·简介 |
| 签约作者新书榜 | qidian.com/rank/signnewbook/ | 已签约作者新书,新风向信号 |
| 公众作者新书榜 | qidian.com/rank/pubnewbook/ | 公众作者新书,发现潜力作者 |
| 新人作者新书榜 | qidian.com/rank/newauthor/ | 新人作品,新人赛道风向 |
| 三江推荐 | qidian.com/sanjiang/ | 编辑推荐,按周分组(注意:非 /rank/ 路径) |
| 月票榜 | qidian.com/rank/yuepiao/ | 付费认可度最高指标 |
| 畅销榜 | qidian.com/rank/hotsales/ | 真金白银投票 |
| 阅读指数榜 | qidian.com/rank/readindex/ | 阅读量综合指标 |
| 收藏榜 | qidian.com/rank/collect/ | 读者关注热度 |
番茄采集目标:
| 榜单 | 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 使用:
node scripts/fanqie-rank-scraper.js --channel 1 --type 2 --outdir {输出目录} # 男频阅读榜
node scripts/fanqie-rank-scraper.js --channel all --top 15 --outdir {输出目录} # 男女频,每题材前 15 本番茄采集后必查文件头 `数据质量`:标[标题解析异常]或书名大量显示(标题待解析),说明详情页解码失败(多为页面结构变动或被登录/验证页拦截)。先在已登录 Chrome 里手动打开任一https://fanqienovel.com/page/{bookId}确认页面正常,再重采;控制台报CDP 无响应则先按 browser-cdp 重启 Chrome。排查细节见 references/scan-output-format.md。
七猫采集目标:
| 榜单 | URL | 核心字段 |
|---|---|---|
| 排行榜总入口 | qimao.com/paihang | 大热榜/新书榜/完结榜,热度为核心指标 |
榜单类型:大热榜(日榜/月榜)、新书榜、完结榜、收藏榜、更新榜,支持男生榜/女生榜切换。
晋江采集目标(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 # 只采列表(快,无核心指标)晋江硬性要求:列表页只有书名+作者,无法支撑分析,必须进详情页。脚本已自动从书名 anchor 取novelid→onebook.php?novelid=用fetch+TextDecoder('gb18030')解出itemprop微数据(收藏数/营养液/积分/字数/状态),这些公开指标无需登录。受--top(每频道)+--detail-limit(总量)约束以控制时长;列表全量保留,仅 top 本补详情。文件头标注详情命中率,全部无收藏数时标[详情解析异常/登录态缺失]。
文件命名:{平台}{榜单名称}_{YYYYMMDD}.md,例:起点新人签约新书榜_20260425.md
采集质量检查(Phase 1.5 完成后必须执行)
每完成一个榜单的采集,立即执行以下检查。发现问题当场修复,不留给后续分析。详细规则见 references/scan-output-format.md「数据清洗与字段约束」。
1. 数据完整性
| 检查项 | 标准 | 处理 |
|---|---|---|
| 条目数量 | >= 15 条有效数据(小平台 >= 10) | 不足则在文件头注明 [数据稀疏] 实际采集 N 条 |
| 必填字段 | 排名、书名、作者(缺任一项视为无效) | 无效条目移除,条目数重新计算 |
| 字段一致性 | 同一榜单内所有条目字段集必须一致 | 不一致条目标记 [字段缺失: {字段名}] |
2. 数据清洗
| 污染类型 | 处理 |
|---|---|
| 平台模板文本(番茄「提供XXX完整版在线免费阅读」、七猫「上一页」等) | 删除模板文本,保留正文 |
| 解析串行(同一条目出现两个不同作品的数据) | 标记 [解析异常],删除并重新采集 |
空字段(空白、--、未知) | 标记 [待补],优先通过详情页补采 |
3. 简介截断
- 清洗后超过 100 字的简介,在最近的句号/问号/感叹号处截断,加
... - 平台模板文本不计入 100 字限制(先删除模板,再截断)
4. 文件头质量状态
每个采集文件头部必须包含:
- 数据质量:[OK / 存在问题]
- 有效条目:{N} / {总数}
- 问题摘要:{无 / 具体问题描述}其他数据来源
用户提供操作指引:
- 用户提供已有的扫描结果文件路径 → 直接加载进入 Phase 2 分析
- 用户提供链接 → 用 WebFetch 抓取
- 用户粘贴/截图 → 手动解析进入分析
内置知识操作指引:
- 加载
references/genre-trends.md - 明确标注:「以下分析基于历史趋势数据;未完成实时榜单校验前只能作为候选假设。」并列出需要复扫的榜单。
---
Phase 2:数据分析
根据用户选择的平台,结合已获取的数据做以下分析:
起点中文网分析维度
| 维度 | 看什么 |
|---|---|
| 月票榜/推荐票榜 | 付费用户认可度高、持续追读强 |
| 畅销榜 | 真金白银投票,最硬核的指标 |
| 签约作者新书榜 | 已签约作者的新作风向 |
| 公众作者新书榜 | 公众作者的新作,发现潜力股 |
| 新人作者新书榜 | 新作者作品与新题材信号 |
| 三江推荐 | 编辑精选推荐,按周分组,发现平台力推作品 |
| 分类榜单 | 各垂直题材的竞争格局 |
| 追读率 | 核心指标,决定推荐位分配 |
番茄小说分析维度
| 维度 | 看什么 |
|---|---|
| 阅读榜 | 流量与读者规模,在读数为核心指标 |
| 新书榜 | 新题材、新风向的早期信号 |
| 题材分布 | 各品类在读数集中度 |
| 在读数趋势 | 同题材不同作品的流量差距 |
| 标签热词 | 简介开头【】内的标签组合,揭示题材细分卖点(如「种田+慢热+西幻」) |
七猫小说分析维度
| 维度 | 看什么 |
|---|---|
| 大热榜 | 热度排名,反映流量集中度 |
| 新书榜 | 新流量风口 |
| 完结榜 | 长尾价值作品 |
| 热度指标 | 七猫核心指标,反映读者活跃度 |
晋江文学城分析维度
采集硬性要求:必须有详情页核心指标(收藏数/营养液/积分/字数)。jjwxc-rank-scraper.js默认已补采(top 本);若用了--list-only或文件头标[仅列表-无核心指标],则该数据不足以支撑以下分析维度,视为不合格。
| 维度 | 看什么 |
|---|---|
| 金榜 | 综合热度最高 |
| 季度榜 | 中期趋势 |
| 红字/黑字 | 积分与负面评价 |
| 收藏/营养液 | 女频市场的核心指标 |
通用分析维度
对每个平台的榜单数据,提取:
1. 题材分布:当前榜上哪些题材最多 2. 新题材信号:最近新出现的题材类型 3. 经典题材变化:老牌题材的走势(上升/稳定/下降) 4. 字数与更新:上榜作品的字数区间和更新频率 5. 书名模式:上榜作品的命名规律 6. 开头卖点:简介/标签中反复出现的关键词 7. 新元素对比:与上期/同类榜单对比,标注新出现的人物设定、开篇切入点、桥段套路
---
Phase 3:输出扫榜报告
# 长篇网文扫榜报告:{平台名称}
## 市场概况
- 扫榜时间:{日期}
- 核心发现:{一句话总结}
## 题材热度排行
| 排名 | 题材 | 榜上数量 | 趋势 | 代表作 |
|------|------|----------|------|--------|
| 1 | {题材} | {N本} | ↑/→/↓ | {书名} |
## 新题材信号
- {新出现或正在上升的题材,附依据}
## 经典题材动态
- {老牌题材的现状,附依据}
## 新元素提取
### 新人物设定模式
- {新模式描述 + 代表作}
### 新开篇切入点
- {新切入点描述 + 代表作}
### 新桥段/套路
- {新桥段描述 + 代表作}
## 关键数据洞察
- 字数区间:上榜作品集中在 {X}-{Y} 万字
- 更新频率:日均 {X} 字为主流
- 书名特征:{命名模式总结}
- 标签热词:{高频标签词}
## 值得关注的方向
1. {方向 + 为什么值得关注 + 可行性评估}
2. {方向 + 为什么值得关注 + 可行性评估}
3. {方向 + 为什么值得关注 + 可行性评估}
## 一句话
{犀利的总结}---
Phase 4:选题决策
把扫榜结果变成能直接用的选题建议,产出 选题决策.md。完整方法(选题四步 + 可行性判断 + 输出模板)见 references/topic-decision.md。
如信息不足,向用户补齐项目条件:「目标平台、已有素材、擅长题材/写作约束、计划篇幅是什么?」
按 topic-decision.md 的选题四步产出 2-3 个推荐选题(能爆的原因 → 市场验证 → 差异化定位 → 可行性+失败风险+验证动作),写入本次扫榜输出目录 {outdir}/选题决策.md,并告知用户路径与下一步:「开书时把 选题决策.md 放到小说项目根目录,写作会自动读取;想确认"能爆的原因"先 /story-long-analyze 拆对标书。」
硬规则:
- 可行性上限:背靠榜单标了
[数据稀疏]或同方向样本 <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 |
选题决策.md 交接:Phase 4 产出的 选题决策.md 写在扫榜输出目录(扫榜常在没有小说项目时进行)。开书时把它搬到小说项目根目录,story-long-write Phase 1 会自动读取;拆文(story-long-analyze)会在汇总报告产出后回填对应选题的"能爆的原因"。参考资料
按需加载以下文件:
| 文件 | 何时加载 |
|---|---|
| references/topic-decision.md | Phase 4 选题决策:选题四步 + 可行性判断 + 选题决策.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 | 晋江榜单采集(收入金榜/月榜等),按频道分组;列表取书名/作者/novelid,再进 onebook.php 详情页(gb18030 解码 itemprop)补采收藏/营养液/积分/字数/状态,受 --top/--detail-limit 约束,--list-only 可跳过 |
| scripts/ciweimao-rank-scraper.js | 刺猬猫榜单采集(点击/收藏/月票等),单页 9 榜提取,按 bookId 归一书名回填作品页链接,带连通性自检+空结果重试+链接命中率标注 |
---
语言
- 跟随用户的语言回复,用户用什么语言就用什么语言回复
- 中文回复遵循《中文文案排版指北》
网文题材趋势与流派参考
题材选择、流派写作要点、扫榜技巧的速查手册。
---
脑洞文创作
从灵感到成书
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字。类型:事件型 / 反差型 / 互动型
网文平台运营与书名简介指南
目录
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属中游水平
通用数据逻辑
- 读完率高 = 内容质量好,追更高 = 期待感足
- 书架比好但完读差 = 中间内容出了问题
- 收益与追更/在读等指标相关,但具体收益受平台规则、合同和推荐阶段影响,必须另行核实
- 数据不好时优先检查:开头吸量、章节留存、期待感断档
---
书名命名技法详见 genre-readers.md「书名命名技法」章节。章节名策略详见 outline-methods.md「章节名策略」章节。
---
简介写法
核心结构:主角处境 -> 金手指能干嘛 -> 第一个爽点预览。不要写成设定说明书,要写成广告。好处要说具体。
常见错误:东扯一句西塞一句没有核心主题 | 创意名字但实际是普通积分类系统 | "且看主角如何如何"空话套话 | 自称用"俺"等过于出戏
简介结构提取:参考三江推荐(qidian.com/sanjiang/)或畅销榜同类型爆款简介结构,只提取功能位:问题、利益点、爽点预告、安全感、悬念。禁止复刻具体剧情和表达。
读者画像系统
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章定生死
- 爽点偏好:逆袭 > 打脸 > 获得认可
- 雷区:文艺腔、节奏慢、不爽
- 黄金标准:开篇即冲突,绝不平淡
刺猬猫读者
- 核心需求:二次元、轻小说、有趣
- 容忍度:开头必须有趣
- 爽点偏好:有趣 > 有梗 > 有梗但意想不到
- 雷区:太严肃、太现实、没有二次元元素
- 黄金标准:有梗有笑点,偶尔有深度
扫榜数据采集格式规范
定义起点/番茄/七猫/晋江的采集字段、输出模板和清洗规则。
---
起点
起点采集说明
优先使用 scripts/qidian-rank-scraper.js 的默认 --mode auto。脚本先读取 https://m.qidian.com 移动端 SSR pageContext JSON,规避 PC 站风控页;移动端不可用时才回退到 CDP/PC 页面。输出头部会标注 抓取方式:mobile-ssr 或 cdp-pc。
榜单URL
| 榜单 | URL |
|---|---|
| 新人签约新书榜 | qidian.com/rank/newsign/ |
| 签约作者新书榜 | 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/ |
字段
排名 | 书名 | 作者 | 题材 | 状态 | 签约 | 收费模式 | 字数(万字) | 总推荐 | 标签(详情页) | 最新更新(详情页) | 作品页链接 | 简介(详情页,截断100字)
输出模板
# qidian · {榜单名称}
- 来源:{榜单URL}
- 抓取时间:{ISO 8601}
- 条目数:{N}
---
## #{排名} {书名}
*{作者} · {题材} · {状态} · {签约} · {免费/VIP} · {字数}万字 · {推荐数}总推荐*
**标签:** {标签}
**最新更新:** {YYYY-MM-DD HH:MM:SS} · {章节标题}
[作品页]({URL})
**简介**
{简介原文}采集要点
榜单页含:排名/书名/作者/题材/字数/推荐/签约/免费VIP。详情页需:标签/最新更新/简介。三江按周分组。
---
番茄小说
榜单URL格式
/rank/{channel}_{type}_{cat_id} — channel 0=女频/1=男频,type 1=新书榜/2=阅读榜
题材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+...】(如「种田、慢热、西幻」),是题材细分的真实信号。
输出模板
# 番茄 · {频道}{榜单名} · 全 {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字)
输出模板
# 七猫 · {男/女}频 · {榜单名称}
- 来源: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 | 收藏数(核心) | 营养液 | 积分 | 字数 | 状态 | 作品页链接
输出模板
# 晋江 · {榜单名}
- 来源:{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条。低于底线标[数据稀疏]。
---
browser-cdp流程
1. 启动browser-cdp打开目标榜单 2. 等待列表元素出现 3. 提取列表页数据 4. 判断翻页(起点通常单页50-100条,番茄按题材逐页cap≈20) 5. 逐条进详情页获取补充数据(标签/简介/最新更新) 6. 汇总写入Markdown
---
文件命名
{平台}{榜单名称}_{日期}.md
批量采集
| 平台 | 默认组合 |
|---|---|
| 起点 | 新人签约新书榜+签约作者新书榜前20+月票榜前20+畅销榜前20 |
| 番茄 | 男频阅读榜全题材+女频阅读榜全题材 |
| 七猫 | 男频大热榜日榜+女频大热榜日榜 |
| 晋江 | 收入金榜+月榜 |
| 全平台 | 起点+番茄+七猫默认组合 |
选题决策:从扫榜数据到"写什么能爆"
把扫榜结果变成能直接用的选题建议:推荐写什么、为什么能爆、行不行、怎么验证。 Phase 4 用本文方法产出 选题决策.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. 告知用户路径,并说明下一步:「开书时把 选题决策.md 放到小说项目根目录,写作会自动读取;想确认"能爆的原因"就先 /story-long-analyze 拆对标书,拆完会回填。」 3. 后续衔接(本文件不执行,仅说明):拆文在汇总报告产出后回填对应选题的"能爆的原因";写作 Phase 1 读取 选题决策.md 作为开书起点。
/**
* CDP 工具函数 — 各平台采集脚本的公共依赖
*
* 使用方式:
* const { ab, sleep, evalJSON, scrollLoad, getArg, safeStr } = require("./cdp-utils");
*
* 前置:
* node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
*/
const { execSync } = require("child_process");
// ---------------------------------------------------------------------------
// agent-browser 工具函数
// ---------------------------------------------------------------------------
/**
* 调用 agent-browser CLI
* @param {number} port - CDP 端口
* @param {...string} args - agent-browser 参数
* @returns {string} stdout(trim 后)
*/
function ab(port, ...args) {
const cmd = args.map((a) => `"${a.replace(/"/g, '\\"')}"`).join(" ");
try {
return execSync(`agent-browser --cdp ${port} ${cmd}`, {
encoding: "utf-8",
timeout: 20000,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e) {
return e.stdout?.trim() || "";
}
}
/** 等待 ms 毫秒(跨平台,不依赖系统 sleep 命令) */
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/** 在浏览器内执行 JS 并解析 JSON 返回值 */
function evalJSON(port, js) {
const raw = ab(port, "eval", js);
if (!raw || raw === "ERR") return null;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch {}
}
return parsed;
} catch {
return null;
}
}
/**
* 安全地将值插入浏览器 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);
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
}
module.exports = { ab, sleep, evalJSON, safeStr, scrollLoad, getArg };
#!/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, scrollLoad, getArg } = require("./cdp-utils");
const RANK_URL = "https://www.ciweimao.com/rank-index";
// eval 统一走 base64,规避复杂 JS 的 shell 转义问题(与 fanqie 一致)
function evalJSON(port, js) {
const b64 = Buffer.from(String(js), "utf-8").toString("base64");
const raw = ab(port, "eval", "-b", b64);
if (!raw || raw === "ERR") return null;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch {}
}
return parsed;
} catch {
return null;
}
}
/** 连通性 + 页面就绪自检 */
function probePage(port) {
return evalJSON(
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 evalJSON(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 evalJSON(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;
}
if (probe.host && probe.host.indexOf("ciweimao") === -1) {
console.error(` ✗ 当前页面非刺猬猫(host=${probe.host}),可能被重定向,已跳过。`);
return;
}
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;
}
urls = extractBookUrls(PORT);
} catch (err) {
console.error(`[ciweimao] 采集失败(页面加载或提取阶段): ${err.message}`);
return;
}
console.log(` ✓ 提取 ${sections.length} 个榜单,${urls.length} 个书籍链接`);
// 筛选需要的榜单类型
const targetTypes =
RANKTYPE === "all"
? RANK_TYPES
: RANK_TYPES.filter((r) => r.id === RANKTYPE);
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 date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const filename = `刺猬猫${rt.label}_${date}.md`;
fs.mkdirSync(OUTDIR, { recursive: true });
const filepath = path.join(OUTDIR, filename);
fs.writeFileSync(filepath, lines.join("\n"), "utf-8");
console.log(` ✓ ${rt.label}:${section.entries.length} 条 → ${filepath}`);
} catch (rankErr) {
console.error(`[ciweimao] ${rt.label} 处理出错,跳过: ${rankErr.message}`);
}
}
}
try {
main();
} catch (e) {
console.error(`刺猬猫采集失败: ${e && e.message ? e.message : e}`);
process.exit(1);
}
#!/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, scrollLoad, getArg } = require("./cdp-utils");
// 一次详情请求的并发批大小。番茄详情页用同步 XHR 拉取,批太大会撞上
// cdp-utils 里 ab() 的 20s 超时,导致整批返回空 → 书名全部回退成 bookId。
const DETAIL_CHUNK = 5;
// ---------------------------------------------------------------------------
// eval 封装:统一走 base64,规避复杂 JS(正则/引号/反斜杠)的 shell 转义问题
// ---------------------------------------------------------------------------
/** 在浏览器内执行 JS(base64 传参)并解析 JSON 返回值 */
function evalJSON(port, js) {
const b64 = Buffer.from(String(js), "utf-8").toString("base64");
const raw = ab(port, "eval", "-b", b64);
if (!raw || raw === "ERR") return null;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch {}
}
return parsed;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// 页面提取
// ---------------------------------------------------------------------------
/** 连通性 + 页面就绪自检 */
function probePage(port) {
return evalJSON(
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 evalJSON(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 = evalJSON(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 evalJSON(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() {
const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
const types = TYPE === "all" ? ["2", "1"] : [TYPE];
for (const ch of channels) {
for (const ty of types) {
try {
const content = scrapeChannel(ch, ty);
if (!content) continue;
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const filename = `番茄${channelLabel(ch)}${typeLabel(ty)}_全题材_${date}.md`;
fs.mkdirSync(OUTDIR, { recursive: true });
const filepath = path.join(OUTDIR, filename);
fs.writeFileSync(filepath, content, "utf-8");
console.log(` ✓ 已保存: ${filepath}`);
} catch (chErr) {
console.error(
`[fanqie] ${channelLabel(ch)}${typeLabel(ty)} 采集失败,跳过: ${chErr && chErr.message ? chErr.message : chErr}`
);
}
}
}
}
if (require.main === module) {
try {
main();
} catch (e) {
console.error(`番茄采集失败: ${e && e.message ? e.message : e}`);
process.exit(1);
}
}
// 导出纯函数/JS 构建器,供测试在 sandbox 内验证解析逻辑
module.exports = {
buildCategoriesJS,
buildBookListJS,
buildDetailJS,
fmtReads,
fmtWords,
fmtStatus,
cleanDesc,
};
#!/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, getArg } = 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;
// ---------------------------------------------------------------------------
// eval 封装:统一走 base64,规避复杂 JS 的 shell 转义问题(与 fanqie 一致)
// ---------------------------------------------------------------------------
function evalJSON(port, js) {
const b64 = Buffer.from(String(js), "utf-8").toString("base64");
const raw = ab(port, "eval", "-b", b64);
if (!raw || raw === "ERR") return null;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch {}
}
return parsed;
} catch {
return null;
}
}
/** 连通性 + 页面就绪自检 */
function probePage(port) {
return evalJSON(
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 evalJSON(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);})`;
}
/** 分批解码详情,合并结果 */
function fetchDetails(port, ids) {
const map = {};
for (let i = 0; i < ids.length; i += DETAIL_CHUNK) {
const chunk = ids.slice(i, i + DETAIL_CHUNK);
const part = evalJSON(port, buildDetailJS(chunk)) || {};
Object.assign(map, part);
sleep(400);
}
return map;
}
// ---------------------------------------------------------------------------
// 格式化
// ---------------------------------------------------------------------------
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;
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})...`);
detailMap = fetchDetails(port, picked);
detailOk = Object.values(detailMap).filter((d) => d && d.collect).length;
console.log(` ✓ 详情命中收藏数 ${detailOk}/${picked.length}`);
}
}
// 质量状态:详情开启时,收藏数命中率是核心信号
let quality = "[OK]";
if (!LIST_ONLY && detailPlanned > 0 && detailOk === 0) {
quality = "[详情解析异常/登录态缺失]";
console.error(` ⚠ 详情全部无收藏数:可能页面结构变动或需登录,已在文件头标注。`);
} 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 lines.join("\n");
}
function main() {
const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
const channels = [CHANNEL]; // 晋江频道 ID 需从页面获取,默认全站
for (const rt of rankTypes) {
for (const ch of channels) {
const content = scrapeRank(PORT, rt, ch);
if (!content) continue;
const rtInfo = RANK_TYPES.find((r) => r.id === rt);
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const chLabel = ch === "0" ? "全站" : `频道${ch}`;
const filename = `晋江${rtInfo.label}_${chLabel}_${date}.md`;
fs.mkdirSync(OUTDIR, { recursive: true });
const filepath = path.join(OUTDIR, filename);
fs.writeFileSync(filepath, content, "utf-8");
console.log(` ✓ 已保存: ${filepath}`);
}
}
}
if (require.main === module) {
try {
main();
} catch (e) {
console.error(`晋江采集失败: ${e && e.message ? e.message : e}`);
process.exit(1);
}
}
module.exports = { buildDetailJS, fmtWan };
#!/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 } = 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=[];" +
"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 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:'',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():'';" +
" if(title){" +
" items.push({rank:idx+1,title:title,url:url,author:author,genre:genre+(subGenre?'·'+subGenre:''),status:status,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():'';" +
"return {tags:tags,intro:introText,update:updateText}" +
"})())";
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 stats = [];
if (record.cnt) stats.push(record.cnt);
if (record.rankCnt) stats.push(`榜单值 ${record.rankCnt}`);
return {
rank: record.rankNum || idx + 1,
title,
url: bid ? `${MOBILE_BASE_URL}/book/${bid}/` : "",
author: record.bAuth || record.author || "",
genre,
status: stats.join(" · "),
descText: record.desc || "",
updateText: "",
};
}
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}*`);
if (b.updateText) lines.push(`**最新更新:** ${b.updateText}`);
if (b.tags?.length) lines.push(`**标签:** ${b.tags.join("、")}`);
if (b.url) lines.push(`[作品页](${b.url})`);
if (b.descText) {
lines.push("");
lines.push("**简介**");
lines.push("");
lines.push(b.descText);
}
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;
}
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() {
const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
for (const rt of rankTypes) {
const content = await scrapeRank(rt);
if (!content) continue;
const rtInfo = RANK_TYPES.find((r) => r.id === rt);
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const filename = `起点${rtInfo.label}_${date}.md`;
fs.mkdirSync(OUTDIR, { recursive: true });
const filepath = path.join(OUTDIR, filename);
fs.writeFileSync(filepath, content, "utf-8");
console.log(` ✓ 已保存: ${filepath}`);
}
}
main().catch((e) => {
console.error(`起点采集失败: ${e.message}`);
process.exit(1);
});
#!/usr/bin/env node
/**
* 七猫小说排行榜采集脚本
*
* 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
* 采集策略:tab 切换男生榜/女生榜和榜单类型,滚动加载后从页面文本解析结构化数据。
* 输出 Markdown 格式匹配 scan-output-format.md 规范。
*
* 用法:
* node qimao-rank-scraper.js --channel male --type hot # 男生大热榜
* 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, safeStr, scrollLoad, getArg } = require("./cdp-utils");
const RANK_URL = "https://www.qimao.com/paihang";
// eval 统一走 base64,规避复杂 JS 的 shell 转义问题(与 fanqie 一致)
function evalJSON(port, js) {
const b64 = Buffer.from(String(js), "utf-8").toString("base64");
const raw = ab(port, "eval", "-b", b64);
if (!raw || raw === "ERR") return null;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch {}
}
return parsed;
} catch {
return null;
}
}
/** 连通性 + 页面就绪自检 */
function probePage(port) {
return evalJSON(
port,
"JSON.stringify({host:location.host,len:(document.body&&document.body.innerText||'').length})"
);
}
const CHANNELS = [
{ id: "male", label: "男频", tab: "男生" },
{ id: "female", label: "女频", tab: "女生" },
];
const RANK_TYPES = [
{ id: "hot", label: "大热榜" },
{ id: "new", label: "新书榜" },
{ id: "finish", label: "完结榜" },
{ id: "collect", label: "收藏榜" },
{ id: "update", label: "更新榜" },
];
// ---------------------------------------------------------------------------
// 页面操作
// ---------------------------------------------------------------------------
/** 点击包含指定文本的 tab 元素 */
function clickTab(port, text) {
const js =
"JSON.stringify((()=>{" +
"var all=document.querySelectorAll('div,span,a,button,li');" +
"var el=Array.from(all).find(function(e){" +
"var t=e.textContent.trim();" +
"return t===" + safeStr(text) + "||t===" + safeStr(text + "榜") +
"});" +
"if(el){el.click();return true}return false" +
"})())";
return evalJSON(port, js);
}
/** 点 tab,失败后等一拍重试一次(tab 异步渲染可能滞后) */
function clickTabRetry(port, text) {
if (clickTab(port, text)) return true;
sleep(1500);
return !!clickTab(port, text);
}
/**
* 从 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 evalJSON(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;" +
// 排名标记:独立数字 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 evalJSON(port, js) || [];
}
// ---------------------------------------------------------------------------
// 主流程
// ---------------------------------------------------------------------------
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";
function scrapeRank(port, channelId, rankTypeId) {
const ch = CHANNELS.find((c) => c.id === channelId);
const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
if (!ch || !rt) {
console.log(" ⚠ 未知频道或榜单类型");
return null;
}
console.log(`\n→ 采集 七猫${ch.label}${rt.label}...`);
let books, urls;
try {
ab(port, "open", RANK_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;
}
// 切换频道 tab(tab 渲染可能滞后,失败重试一次)
if (!clickTabRetry(port, ch.tab)) {
console.log(` ⚠ 未找到「${ch.tab}」tab`);
return null;
}
console.log(` ✓ 切换到${ch.tab}频`);
sleep(2000);
// 切换榜单类型 tab
if (!clickTabRetry(port, rt.label)) {
console.log(` ⚠ 未找到「${rt.label}」tab`);
return null;
}
console.log(` ✓ 切换到${rt.label}`);
sleep(2000);
// 滚动加载更多
scrollLoad(port, 5);
sleep(1000);
// 文本解析获取书籍数据 + DOM 获取链接
books = extractBooksFromText(port);
urls = extractBookUrls(port);
} catch (err) {
console.error(`[qimao] ${ch.label}${rt.label} 页面加载或提取出错: ${err.message}`);
return null;
}
if (!books.length) {
console.error(`[qimao] 采集失败:页面结构可能已变(选择器没匹配到数据),请检查榜单URL或更新选择器 (${RANK_URL} ${ch.label}${rt.label})`);
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 linked = books.filter((b) => b.url).length;
const heated = books.filter((b) => b.heat).length;
console.log(` ✓ 提取 ${books.length} 本(链接 ${linked}/${books.length},热度 ${heated}/${books.length})`);
const now = new Date().toISOString();
const lines = [
`# 七猫 · ${ch.label} · ${rt.label}`,
"",
`- 作品页链接:${linked} / ${books.length}`,
`- 热度命中:${heated} / ${books.length}`,
`- 来源:${RANK_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 + "热度" : "",
]
.filter(Boolean)
.join(" · ");
lines.push(`*${meta}*`);
if (b.update) lines.push(`**最新更新:** ${b.update}`);
if (b.url) lines.push(`[作品页](${b.url})`);
if (b.desc) {
lines.push("");
lines.push("**简介**");
lines.push("");
lines.push(b.desc);
}
lines.push("", "---", "");
} catch (bookErr) {
console.error(`[qimao] ${ch.label}${rt.label} 第${b.rank}条处理出错: ${bookErr.message}`);
lines.push("", "---", "");
}
}
return lines.join("\n");
}
function main() {
const channels = CHANNEL === "all" ? CHANNELS.map((c) => c.id) : [CHANNEL];
const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
for (const ch of channels) {
for (const rt of rankTypes) {
const content = scrapeRank(PORT, ch, rt);
if (!content) continue;
const chInfo = CHANNELS.find((c) => c.id === ch);
const rtInfo = RANK_TYPES.find((r) => r.id === rt);
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const filename = `七猫${chInfo.label}${rtInfo.label}_${date}.md`;
fs.mkdirSync(OUTDIR, { recursive: true });
const filepath = path.join(OUTDIR, filename);
fs.writeFileSync(filepath, content, "utf-8");
console.log(` ✓ 已保存: ${filepath}`);
}
}
}
try {
main();
} catch (e) {
console.error(`七猫采集失败: ${e && e.message ? e.message : e}`);
process.exit(1);
}
Related skills
FAQ
Is Story Long Scan safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.