@cloud411716/fancy-webnovel 1.0.46 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
  105. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  106. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  107. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  108. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
  109. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
  110. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
  111. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
  112. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
  113. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
  114. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
  115. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
  116. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
  117. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
  118. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  119. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -164
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -283
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fancy-scan run-scan.js — 采集编排层
4
+ *
5
+ * 职责:
6
+ * 1. 检查 .fancy-deployed
7
+ * 2. 调用平台采集脚本(qidian HTTP;其他平台通过 playwright-core)
8
+ * 3. 解析 scraper 的结构化 JSON receipt
9
+ * 4. 生成汇总 receipt 输出到 stdout
10
+ *
11
+ * 调用方式:
12
+ * node run-scan.js --project-root <abs> --platform fanqie --rank 1_2
13
+ */
14
+
15
+ import { writeFileSync, existsSync, mkdirSync, renameSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { spawnScript, killProcessTree } from '#infra';
19
+ import { EC, errorResponse } from '#error-codes';
20
+ import { localDateStamp, normalizeBookIntro, normalizeScanMode, DEFAULT_SCAN_MODE } from '#scraper-utils';
21
+ import { readDeployedState, PHASE_ORDER, DEPLOYED_STATUS } from '#deployed-state';
22
+
23
+ // 调用 scraper 的超时预算。
24
+ //
25
+ // 插件层给 run-scan.js 的预算是 30 分钟(fancy-scan/index.js 的 spawnScript),
26
+ // 这里必须留出余量:**内层要早于外层触发**,否则外层会先掐死本进程,
27
+ // 而 scraper 是被 detached 出去、独立成组的 —— 外层的 process.kill(-pid)
28
+ // 够不到它;本进程一死,本层的定时器随之消失,scraper 就永远收不到
29
+ // SIGTERM,会带着 chromium 一路跑到自然结束。
30
+ //
31
+ // 实测(外层 8s / 内层 60s 的真实番茄链路):外层超时后 11.5 秒,
32
+ // scraper 进程仍在,headless chrome 从 7 涨到 13 —— 确认是孤儿。
33
+ // 本层 25 分钟 < 外层 30 分钟后,超时先由本层处理:
34
+ // 杀进程组 → scraper 收到 SIGTERM → 它自己关掉浏览器 → 干净退出。
35
+ const SCRAPER_TIMEOUT_MS = 25 * 60 * 1000;
36
+
37
+ // 字符串消息 → EC 码映射,用于把 main() 里 throw new Error(msg) 转成结构化错误
38
+ const MSG_TO_EC = {
39
+ '缺少必要参数: --project-root --platform --rank': EC.EC042_MISSING_REQUIRED,
40
+ '项目未初始化:.fancy-deployed 不存在': EC.EC124_PROJECT_NOT_INITIALIZED,
41
+ '不支持的平台': EC.EC029_INVALID_PLATFORM_SCAN,
42
+ };
43
+
44
+ // EC027_NO_OUTPUT_FILES 用前缀匹配(runScraper 返回的错误带平台名前缀)
45
+ function lookupEC(msg) {
46
+ if (MSG_TO_EC[msg]) return MSG_TO_EC[msg];
47
+ if (msg.includes('未找到输出文件')) return EC.EC027_NO_OUTPUT_FILES;
48
+ if (msg.startsWith('不支持的平台')) return EC.EC029_INVALID_PLATFORM_SCAN;
49
+ if (msg.includes('JSON')) return EC.EC123_JSON_PARSE_FAIL;
50
+ return null;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // 平台配置
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const VALID_PLATFORMS = ['qidian', 'fanqie', 'jinjiang', 'qimao'];
58
+
59
+ const PLATFORM_CN = {
60
+ qidian: '起点',
61
+ fanqie: '番茄',
62
+ jinjiang: '晋江',
63
+ qimao: '七猫',
64
+ };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // 工具函数
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function nowIso() {
71
+ return new Date().toISOString();
72
+ }
73
+
74
+ function ensureDir(dir) {
75
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
76
+ }
77
+
78
+ // 原子写 JSON:写到 .tmp 后 rename,避免半截文件污染状态
79
+ function atomicWriteJSON(filepath, data) {
80
+ const tmpPath = join(dirname(filepath), `.${Date.now()}-${process.pid}.tmp`);
81
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
82
+ renameSync(tmpPath, filepath);
83
+ }
84
+
85
+ // phase 顺序(从早到晚),用于判定"只进不退"
86
+
87
+ // 把 .fancy-deployed 的 phase 推进到指定值;只能向前推进或保持
88
+ // 不会从 analyzed 退回到 scanned
89
+ function advancePhase(projectRoot, nextPhase) {
90
+ const deployedPath = join(projectRoot, '.fancy-deployed');
91
+ // 用共享体检读状态:missing / corrupt / no_phase / unknown_phase 四种情况
92
+ // 在这里被统一区分开,不再靠 JSON.parse 抛异常来表达"文件坏了"。
93
+ const state = readDeployedState(projectRoot);
94
+ if (state.status === DEPLOYED_STATUS.MISSING) {
95
+ return { updated: false, reason: 'project_not_initialized' };
96
+ }
97
+ if (state.status === DEPLOYED_STATUS.CORRUPT) {
98
+ return { updated: false, reason: 'corrupt', message: state.error };
99
+ }
100
+ if (state.status === DEPLOYED_STATUS.NO_PHASE) {
101
+ return { updated: false, reason: 'unknown_phase', currentPhase: null, requestedPhase: nextPhase };
102
+ }
103
+ if (state.status === DEPLOYED_STATUS.UNKNOWN_PHASE) {
104
+ return { updated: false, reason: 'unknown_phase', currentPhase: state.phase, requestedPhase: nextPhase };
105
+ }
106
+ const data = state.config;
107
+ const currentRank = PHASE_ORDER.indexOf(data.phase);
108
+ const nextRank = PHASE_ORDER.indexOf(nextPhase);
109
+ if (currentRank === -1 || nextRank === -1) {
110
+ return { updated: false, reason: 'unknown_phase', currentPhase: data.phase, requestedPhase: nextPhase };
111
+ }
112
+ if (currentRank >= nextRank) {
113
+ return { updated: false, reason: currentRank === nextRank ? 'already_at_phase' : 'already_advanced', currentPhase: data.phase, requestedPhase: nextPhase };
114
+ }
115
+ const previousPhase = data.phase;
116
+ data.phase = nextPhase;
117
+ try {
118
+ atomicWriteJSON(deployedPath, data);
119
+ } catch (e) {
120
+ return { updated: false, reason: 'write_failed', message: e.message };
121
+ }
122
+ return { updated: true, previousPhase, currentPhase: nextPhase };
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // HTTP 采集 — 起点 mobile SSR
127
+ // ---------------------------------------------------------------------------
128
+
129
+ // 起点榜单(移动端 m.qidian.com)。
130
+ // 路径与页面中文名均取自 m.qidian.com 各榜单页的实测结果:
131
+ // readindex → 阅读榜 / newfans → 书友榜(不是"新粉榜")
132
+ // PC 端 www.qidian.com 同一路径有瑞数 WAF + 腾讯滑块验证码,纯 HTTP 拿不到数据,
133
+ // 故固定走移动端;移动端没有 /rank/recom/(404),推荐榜的移动端路径是 /rank/rec/。
134
+ const QIDIAN_RANK_TYPES = [
135
+ { id: 'hotsales', label: '畅销榜', path: '/rank/hotsales/' },
136
+ { id: 'yuepiao', label: '月票榜', path: '/rank/yuepiao/' },
137
+ { id: 'readindex', label: '阅读榜', path: '/rank/readindex/' },
138
+ { id: 'newfans', label: '书友榜', path: '/rank/newfans/' },
139
+ { id: 'rec', label: '推荐榜', path: '/rank/rec/' },
140
+ { id: 'update', label: '更新榜', path: '/rank/update/' },
141
+ { id: 'sign', label: '签约榜', path: '/rank/sign/' },
142
+ { id: 'newbook', label: '新书榜', path: '/rank/newbook/' },
143
+ { id: 'newauthor', label: '新人作者榜', path: '/rank/newauthor/' },
144
+ ];
145
+
146
+ /**
147
+ * 抓取单本作品的详情(移动端详情页 SSR)。
148
+ *
149
+ * 榜单页 records 里也有 desc,但它比详情页短:实测新书榜 20 本,
150
+ * 详情 desc 100% 更长(中位 +8 字符,最大 +56),其中 2 本榜单 desc
151
+ * 尾部带省略号。详情 desc 才是完整版,代价是每本多一次请求。
152
+ *
153
+ * 详情页还补上了 bookStatus:榜单 records 的键实测为
154
+ * _index/bAuth/bName/bid/cat/catId/cnt/desc/rankNum/subCat/subCatId,
155
+ * 没有任何 status 类字段,20/20 为空 —— 此前产出的 `**状态:**` 行恒为空。
156
+ */
157
+ async function fetchBookDetail(bookUrl, headers) {
158
+ const html = await fetch(bookUrl, { headers, signal: AbortSignal.timeout(15000) })
159
+ .then(r => r.text());
160
+ const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
161
+ if (!m) throw new Error('未找到 pageContext');
162
+ const bookInfo = JSON.parse(m[1])?.pageContext?.pageProps?.pageData?.bookInfo;
163
+ if (!bookInfo) throw new Error('未找到 bookInfo');
164
+ return {
165
+ intro: normalizeBookIntro(bookInfo.desc || ''),
166
+ status: bookInfo.bookStatus || '',
167
+ signStatus: bookInfo.signStatus || '',
168
+ };
169
+ }
170
+
171
+ async function scrapeQidianRank(rankId, outDir) {
172
+ const MOBILE_BASE = 'https://m.qidian.com';
173
+ const RANK_TYPE = QIDIAN_RANK_TYPES.find(r => r.id === rankId);
174
+ if (!RANK_TYPE) {
175
+ return { ok: false, error: `未知榜单 ID: ${rankId}` };
176
+ }
177
+
178
+ const MOBILE_HEADERS = {
179
+ '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',
180
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
181
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
182
+ 'Accept-Encoding': 'identity',
183
+ };
184
+
185
+ const url = `${MOBILE_BASE}${RANK_TYPE.path}`;
186
+ let html = '';
187
+ try {
188
+ html = await fetch(url, { headers: MOBILE_HEADERS, signal: AbortSignal.timeout(15000) }).then(r => r.text());
189
+ } catch (e) {
190
+ return { ok: false, error: `${RANK_TYPE.label} 请求失败: ${e.message}` };
191
+ }
192
+
193
+ const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
194
+ if (!m) {
195
+ return { ok: false, error: `${RANK_TYPE.label} 未找到 pageContext` };
196
+ }
197
+
198
+ let pageContext;
199
+ try {
200
+ pageContext = JSON.parse(m[1]);
201
+ } catch (e) {
202
+ return { ok: false, error: `${RANK_TYPE.label} JSON 解析失败` };
203
+ }
204
+
205
+ const records = pageContext?.pageContext?.pageProps?.pageData?.records || [];
206
+ if (!records.length) {
207
+ return { ok: false, error: `${RANK_TYPE.label} 无书籍数据` };
208
+ }
209
+
210
+ const books = records.map((r, i) => ({
211
+ rank: r.rankNum || i + 1,
212
+ title: r.bName || r.bookName || '',
213
+ url: r.bid ? `${MOBILE_BASE}/book/${r.bid}/` : '',
214
+ author: r.bAuth || r.author || '',
215
+ genre: [r.cat, r.subCat].filter(Boolean).join('·'),
216
+ status: r.status || '',
217
+ words: r.cnt || r.wordCount || '',
218
+ rankValue: r.rankCnt || '',
219
+ totalRecommend: r.totalRecommend || '',
220
+ signStatus: r.signStatus || '',
221
+ vipStatus: r.vipStatus || '',
222
+ }));
223
+
224
+ // 逐本访问详情页补简介。
225
+ // 串行而非并发:起点有瑞数 WAF(当前靠移动端规避),并发易触发风控。
226
+ // 单本失败不中断整个榜单 —— 失败原因记入 partialReasons,
227
+ // 由 run-scan.js 的 receipt 一路带到插件层显示在失败清单里。
228
+ const partialReasons = [];
229
+ for (const b of books) {
230
+ if (!b.url) continue;
231
+ try {
232
+ const detail = await fetchBookDetail(b.url, MOBILE_HEADERS);
233
+ b.intro = detail.intro;
234
+ if (!b.status && detail.status) b.status = detail.status;
235
+ } catch (e) {
236
+ partialReasons.push(`${b.title} 简介获取失败: ${e.message}`);
237
+ }
238
+ }
239
+
240
+ const today = localDateStamp();
241
+ const outFile = join(outDir, `${PLATFORM_CN.qidian}${RANK_TYPE.label}_${today}.md`);
242
+ const md = renderQidianMarkdown(RANK_TYPE.label, url, books);
243
+ writeFileSync(outFile, md, 'utf-8');
244
+
245
+ return {
246
+ ok: true,
247
+ writtenFiles: [outFile],
248
+ failed: partialReasons.length,
249
+ partialReasons,
250
+ };
251
+ }
252
+
253
+ function renderQidianMarkdown(rankLabel, url, books) {
254
+ const now = nowIso();
255
+ const lines = [
256
+ `# 起点 · ${rankLabel}`,
257
+ '',
258
+ `- 来源:${url}`,
259
+ `- 抓取方式:mobile-ssr`,
260
+ `- 抓取时间:${now}`,
261
+ `- 条目数:${books.length}`,
262
+ '',
263
+ '---',
264
+ '',
265
+ ];
266
+
267
+ for (const b of books) {
268
+ lines.push(`## #${b.rank} ${b.title}`);
269
+ if (b.author) lines.push(`**作者:** ${b.author}`);
270
+ if (b.genre) lines.push(`**题材:** ${b.genre}`);
271
+ if (b.status) lines.push(`**状态:** ${b.status}`);
272
+ lines.push(`**字数:** ${b.words || '[待补]'}`);
273
+ if (b.intro) {
274
+ // 格式与其它字段保持一致:首段紧跟标签,其余段落各自成段
275
+ const paras = b.intro.split('\n\n');
276
+ lines.push(`**简介:** ${paras[0]}`);
277
+ for (const p of paras.slice(1)) lines.push('', p);
278
+ }
279
+ if (b.url) lines.push(`[作品页](${b.url})`);
280
+ lines.push('', '---', '');
281
+ }
282
+
283
+ return lines.join('\n');
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // scraper 参数构造(platform → 调哪个 scraper)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ function scraperArgs(platform, rank, scanMode) {
291
+ switch (platform) {
292
+ case 'fanqie': {
293
+ const [channel, type] = rank.split('_');
294
+ return {
295
+ script: 'fanqie-rank-scraper.js',
296
+ // --scan-mode 决定番茄用明文接口还是截图 OCR 取书名/作者/简介。
297
+ // 非法值在 normalizeScanMode 里已回落到默认,不会透传给 scraper。
298
+ extra: ['--channel', channel, '--type', type, '--top', '10',
299
+ '--scan-mode', normalizeScanMode(scanMode)],
300
+ };
301
+ }
302
+ case 'jinjiang':
303
+ return {
304
+ script: 'jjwxc-rank-scraper.js',
305
+ extra: ['--type', rank],
306
+ };
307
+ case 'qimao': {
308
+ const parts = rank.split('/');
309
+ const channel = parts[0];
310
+ const type = parts[1];
311
+ const period = parts[2] || 'day';
312
+ return {
313
+ script: 'qimao-rank-scraper.js',
314
+ extra: ['--channel', channel, '--type', type, '--period', period],
315
+ };
316
+ }
317
+ default:
318
+ return { script: null, extra: [] };
319
+ }
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // 调用 scraper(统一用 infra.js spawnScript)
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * 把 spawnScript 的原始错误转成人话。
328
+ *
329
+ * "timeout after 1500000ms" 是内部实现细节,会原样出现在给用户看的
330
+ * 失败清单里;这里转成"采集超时(超过 25 分钟未完成)"。
331
+ */
332
+ function humanizeSpawnError(raw) {
333
+ const text = String(raw ?? '');
334
+ const t = /^timeout after (\d+)ms$/.exec(text);
335
+ if (!t) return text;
336
+ return `采集超时(超过 ${Math.round(Number(t[1]) / 60000)} 分钟未完成)`;
337
+ }
338
+
339
+ async function runScraper(platform, rank, outDir, scanMode) {
340
+ const args = scraperArgs(platform, rank, scanMode);
341
+ if (!args.script) {
342
+ return { ok: false, error: `平台 ${platform} 暂不支持采集` };
343
+ }
344
+
345
+ // scraper 脚本路径(相对 run-scan.js)
346
+ // 必须用 fileURLToPath:URL.pathname 会把空格/中文百分号编码
347
+ // (/tmp/普通 目录 → /tmp/%E6%99%AE%E9%80%9A%20%E7%9B%AE%E5%BD%95),
348
+ // 拼出来的路径不存在,采集必然失败。
349
+ const scraperPath = join(fileURLToPath(new URL('.', import.meta.url)), 'scrapers', args.script);
350
+ const fullArgs = ['--outdir', outDir, ...args.extra];
351
+
352
+ // 不传 NODE_PATH:tesseract.js 由 scraper 脚本按 node_modules 向上查找解析
353
+ // (scraper 位于 <repo>/plugins/fancy-scan/scripts/scrapers/ 下,
354
+ // 能找到 <repo>/node_modules/tesseract.js),实测未设 NODE_PATH 时 OCR 正常。
355
+ // 此前硬编码的 /home/cloud/.npm-global/lib/node_modules 下并没有 tesseract.js,
356
+ // 覆盖用户自带的 NODE_PATH 只会带来副作用。
357
+ const result = await spawnScript('node', [scraperPath, ...fullArgs], {
358
+ timeout: SCRAPER_TIMEOUT_MS,
359
+ onSpawn: (proc) => {
360
+ // 本进程被 SIGTERM(上游超时或用户中断)时,scraper 因为 detached
361
+ // 独立成组、不在本进程组里,必须显式转发 —— 否则它会带着 chromium
362
+ // 继续跑完整流程。转发后不立刻退出:让 scraper 自己收尾(关浏览器),
363
+ // 本进程随后会拿到失败 receipt 正常退出。
364
+ process.on('SIGTERM', () => {
365
+ killProcessTree(proc, 'SIGTERM');
366
+ // 兜底:scraper 若迟迟不退,10 秒后强制结束本进程。
367
+ setTimeout(() => process.exit(143), 10000).unref();
368
+ });
369
+ },
370
+ });
371
+
372
+ if (!result.ok) {
373
+ return {
374
+ ok: false,
375
+ error: humanizeSpawnError(result.error || `exit ${result.code}`),
376
+ stderr: result.stderr,
377
+ };
378
+ }
379
+
380
+ // 解析结构化 receipt(fanqie/jinjiang/qimao 都用此协议)
381
+ let receipt;
382
+ try {
383
+ receipt = JSON.parse(result.output);
384
+ } catch (_) {
385
+ // 优先用 stderr 作为错误信息(stdout 经常被进度日志污染)
386
+ const stderrTail = (result.stderr || '').trim().slice(-300);
387
+ const stdoutTail = (result.output || '').trim().slice(-200);
388
+ return {
389
+ ok: false,
390
+ error: stderrTail
391
+ ? `scraper 输出非 JSON;stderr: ${stderrTail}`
392
+ : `scraper 输出非 JSON;stdout: ${stdoutTail}`,
393
+ stderr: result.stderr,
394
+ };
395
+ }
396
+
397
+ if (receipt.ok === false) {
398
+ // receipt.error 可能为 undefined(scraper 没设置),fallback 到 stderr
399
+ const stderrTail = (result.stderr || '').trim().slice(-300);
400
+ return {
401
+ ok: false,
402
+ error: receipt.error || stderrTail || 'scraper 报告失败(无错误详情)',
403
+ stderr: result.stderr,
404
+ };
405
+ }
406
+
407
+ const writtenFiles = receipt.files || receipt.written || receipt.scan_files || [];
408
+ if (!Array.isArray(writtenFiles) || writtenFiles.length === 0) {
409
+ return {
410
+ ok: false,
411
+ error: `${PLATFORM_CN[platform]}(${rank})采集失败:未找到输出文件`,
412
+ stderr: result.stderr,
413
+ };
414
+ }
415
+
416
+ return {
417
+ ok: true,
418
+ writtenFiles,
419
+ failed: receipt.failed || 0,
420
+ partial: receipt.partial || false,
421
+ partialReasons: receipt.partialReasons || [],
422
+ };
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // 主流程
427
+ // ---------------------------------------------------------------------------
428
+
429
+ async function main() {
430
+ const args = process.argv.slice(2);
431
+ let projectRoot = '', platform = '', rank = '';
432
+ // 扫描方式(取数路径)。仅番茄使用;其他平台忽略。
433
+ // 缺失或非法一律按默认(api)处理,绝不把未知值透传给 scraper。
434
+ let scanMode = DEFAULT_SCAN_MODE;
435
+
436
+ for (let i = 0; i < args.length; i++) {
437
+ if (args[i] === '--project-root') projectRoot = args[i + 1] || '';
438
+ if (args[i] === '--platform') platform = args[i + 1] || '';
439
+ if (args[i] === '--rank') rank = args[i + 1] || '';
440
+ if (args[i] === '--scan-mode') scanMode = args[i + 1] || '';
441
+ }
442
+
443
+ if (!projectRoot || !platform || !rank) {
444
+ throw new Error('缺少必要参数: --project-root --platform --rank');
445
+ }
446
+
447
+ if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
448
+ throw new Error('项目未初始化:.fancy-deployed 不存在');
449
+ }
450
+
451
+ if (!VALID_PLATFORMS.includes(platform)) {
452
+ throw new Error(`不支持的平台: ${platform}`);
453
+ }
454
+
455
+ const today = localDateStamp();
456
+ const scanDir = join(projectRoot, '扫榜结果');
457
+ ensureDir(scanDir);
458
+
459
+ let scanFiles = [];
460
+ let failed = 0;
461
+ const partialReasons = [];
462
+
463
+ // 调用 scraper
464
+ const r = platform === 'qidian'
465
+ ? await scrapeQidianRank(rank, scanDir)
466
+ : await runScraper(platform, rank, scanDir, scanMode);
467
+
468
+ if (!r.ok) {
469
+ throw new Error(r.error);
470
+ }
471
+
472
+ scanFiles = r.writtenFiles;
473
+ failed = r.failed || 0;
474
+ if (Array.isArray(r.partialReasons)) partialReasons.push(...r.partialReasons);
475
+
476
+ // 扫榜成功 → 把 .fancy-deployed 的 phase 推进到 'scanned'
477
+ // 部分失败(failed>0)也算成功推进,因为已有产出文件
478
+ // 注:fancy-topic 启动时会再检查 topic_decision_<date>.md 是否存在,
479
+ // 并要求 phase === 'scanned' 才允许运行
480
+ const phaseResult = advancePhase(projectRoot, 'scanned');
481
+ console.error(` 📌 phase: ${phaseResult.updated ? `${phaseResult.previousPhase} → ${phaseResult.currentPhase}` : phaseResult.reason}`);
482
+ // "没推进"有两种含义,必须分开:
483
+ // already_at_phase / already_advanced —— 正常(重扫),不是问题
484
+ // corrupt / unknown_phase / write_failed —— phase 推不动,流程走不下去
485
+ // 后者以前只写进 stderr,receipt 里看不出来,插件层于是照报"采集完成",
486
+ // 用户接着跑 /fancy-topic 报"状态文件读不了",完全不知道问题出在这儿。
487
+ const phaseBlocked = !phaseResult.updated
488
+ && !['already_at_phase', 'already_advanced'].includes(phaseResult.reason);
489
+
490
+ // 输出结构化 receipt
491
+ const receipt = {
492
+ ok: scanFiles.length > 0,
493
+ operation: 'scan',
494
+ platform,
495
+ rank,
496
+ date: today,
497
+ scan_files: scanFiles,
498
+ topic_decision: join(scanDir, `topic_decision_${today}.md`),
499
+ written: scanFiles.length,
500
+ failed,
501
+ partial: failed > 0,
502
+ partialReasons,
503
+ phase_advance: phaseResult,
504
+ phase_blocked: phaseBlocked,
505
+ summary: `${PLATFORM_CN[platform]}(${rank})采集完成,共 ${scanFiles.length} 个文件`,
506
+ };
507
+
508
+ console.log(JSON.stringify(receipt));
509
+ }
510
+
511
+ main().catch(e => {
512
+ const msg = e.message || String(e);
513
+ // 尝试把已知消息映射到 EC 码
514
+ const ec = lookupEC(msg);
515
+ const errObj = ec
516
+ ? errorResponse(ec, { message: msg })
517
+ : { ok: false, error: { code: 1, code_name: 'EC001', message: msg } };
518
+ process.stdout.write(JSON.stringify(errObj) + '\n');
519
+ process.exit(ec ? ec.code : 1);
520
+ });