@cloud411716/fancy-webnovel 2.2.0 → 3.0.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 (211) hide show
  1. package/client.js +201 -15
  2. package/cordis.patch.yml +5 -0
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +11 -7
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/confirmation.js +80 -0
  18. package/lib/db-init.js +123 -0
  19. package/lib/deployed-state.js +11 -11
  20. package/lib/direction-labels.js +265 -0
  21. package/lib/error-codes.js +31 -31
  22. package/lib/infra.js +29 -4
  23. package/lib/material-db.js +149 -0
  24. package/lib/path-guard.js +150 -0
  25. package/lib/projects.js +400 -0
  26. package/lib/scraper-utils.js +140 -0
  27. package/lib/surreal-server.js +338 -0
  28. package/lib/topic-decision.js +860 -0
  29. package/lib/topic-fields.js +251 -0
  30. package/lib/topic-schema.js +225 -0
  31. package/lib/workspace.js +501 -0
  32. package/package.json +29 -4
  33. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  34. package/plugins/fancy-analyze/index.js +1128 -77
  35. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  36. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  37. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  38. package/plugins/fancy-analyze/summary-progress.js +71 -7
  39. package/plugins/fancy-analyze/templates.js +437 -139
  40. package/plugins/fancy-bootstrap/index.js +276 -131
  41. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  42. package/plugins/fancy-bootstrap/templates.js +35 -18
  43. package/plugins/fancy-db/index.js +172 -0
  44. package/plugins/fancy-douyin/deploy.js +134 -0
  45. package/plugins/fancy-douyin/index.js +551 -0
  46. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  47. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  48. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  49. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  50. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  51. package/plugins/fancy-douyin/state.js +197 -0
  52. package/plugins/fancy-douyin/templates.js +127 -0
  53. package/plugins/fancy-export/exporter.js +805 -0
  54. package/plugins/fancy-export/index.js +63 -0
  55. package/plugins/fancy-import/import-driver.js +29 -6
  56. package/plugins/fancy-import/index.js +13 -5
  57. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  58. package/plugins/fancy-import/templates.js +17 -8
  59. package/plugins/fancy-material/exporter.js +153 -0
  60. package/plugins/fancy-material/genres.js +256 -0
  61. package/plugins/fancy-material/index.js +835 -0
  62. package/plugins/fancy-material/inventory.js +143 -0
  63. package/plugins/fancy-material/kinds.js +313 -0
  64. package/plugins/fancy-material/queue.js +55 -0
  65. package/plugins/fancy-material/scripts/query.js +254 -0
  66. package/plugins/fancy-material/templates.js +258 -0
  67. package/plugins/fancy-material/tool-deploy.js +138 -0
  68. package/plugins/fancy-material/validate.js +149 -0
  69. package/plugins/fancy-project/index.js +133 -0
  70. package/plugins/fancy-scan/existing-block.js +100 -0
  71. package/plugins/fancy-scan/index.js +410 -21
  72. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  73. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  74. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  75. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  76. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  77. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  78. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  79. package/plugins/fancy-scan/templates/blocks.js +155 -0
  80. package/plugins/fancy-scan/templates/platforms.js +372 -0
  81. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  82. package/plugins/fancy-scan/templates.js +545 -146
  83. package/plugins/fancy-scan/tool-deploy.js +335 -0
  84. package/plugins/fancy-scan/wait-turn.js +46 -0
  85. package/plugins/fancy-story-builder/db/README.md +106 -0
  86. package/plugins/fancy-story-builder/db/client.js +228 -0
  87. package/plugins/fancy-story-builder/db/commands.js +145 -0
  88. package/plugins/fancy-story-builder/db/ids.js +39 -0
  89. package/plugins/fancy-story-builder/db/import.js +798 -0
  90. package/plugins/fancy-story-builder/db/project.js +299 -0
  91. package/plugins/fancy-story-builder/db/query.js +361 -0
  92. package/plugins/fancy-story-builder/db/schema.js +417 -0
  93. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  94. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  95. package/plugins/fancy-story-builder/index.js +4105 -396
  96. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  97. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  98. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  99. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  100. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  101. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  102. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  103. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  104. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  105. package/plugins/fancy-story-builder/references/index.md +97 -6
  106. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  108. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  111. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  115. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  116. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  117. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  118. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  119. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  120. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  121. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  122. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  123. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  124. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  125. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  126. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  127. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  128. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  129. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  130. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  131. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  132. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  133. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  134. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  135. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  136. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  137. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  138. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  139. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  140. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  141. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  142. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  143. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  144. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  145. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  146. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  147. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  148. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  149. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  150. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  151. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  152. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  153. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  154. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  155. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  156. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  157. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  158. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  159. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  160. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  161. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  162. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  163. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  164. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  165. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  166. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  167. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  168. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  185. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  187. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  188. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  189. 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 +267 -44
  190. 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 +121 -19
  191. 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 +129 -21
  192. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  193. 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 +76 -17
  194. 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 +56 -8
  195. 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 +63 -13
  196. 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 +34 -6
  197. 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 +81 -18
  198. 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 +202 -42
  199. 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 +160 -27
  200. 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 +217 -41
  201. 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 +215 -41
  202. package/plugins/fancy-story-builder/templates.js +272 -74
  203. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  204. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  205. package/plugins/fancy-topic/index.js +414 -61
  206. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  207. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  208. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  209. package/plugins/fancy-topic/templates.js +225 -122
  210. package/plugins/fancy-topic/tool-deploy.js +200 -0
  211. package/lib/safe-rm.js +0 -94
@@ -12,12 +12,14 @@
12
12
  * node run-scan.js --project-root <abs> --platform fanqie --rank 1_2
13
13
  */
14
14
 
15
- import { writeFileSync, existsSync, mkdirSync, renameSync } from 'fs';
16
- import { join, dirname } from 'path';
15
+ import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from 'fs';
16
+ import { isAbsolute, join, relative, resolve } from 'path';
17
17
  import { fileURLToPath } from 'url';
18
18
  import { spawnScript, killProcessTree } from '#infra';
19
19
  import { EC, errorResponse } from '#error-codes';
20
- import { localDateStamp, normalizeBookIntro, normalizeScanMode, DEFAULT_SCAN_MODE } from '#scraper-utils';
20
+ import { atomicWriteFile, atomicWriteJSON } from '#atomic-write';
21
+ import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
22
+ import { localDateStamp, normalizeBookIntro, normalizeScanMode, DEFAULT_SCAN_MODE, renderIntroLines, sanitizeBookFields } from '#scraper-utils';
21
23
  import { readDeployedState, PHASE_ORDER, DEPLOYED_STATUS } from '#deployed-state';
22
24
 
23
25
  // 调用 scraper 的超时预算。
@@ -75,11 +77,12 @@ function ensureDir(dir) {
75
77
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
76
78
  }
77
79
 
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);
80
+ /** child 是否在 parent 目录内(防止 scraper 把文件写到项目外) */
81
+ function isPathInside(parent, child) {
82
+ const p = resolve(parent);
83
+ const c = resolve(child);
84
+ const rel = relative(p, c);
85
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
83
86
  }
84
87
 
85
88
  // phase 顺序(从早到晚),用于判定"只进不退"
@@ -115,7 +118,7 @@ function advancePhase(projectRoot, nextPhase) {
115
118
  const previousPhase = data.phase;
116
119
  data.phase = nextPhase;
117
120
  try {
118
- atomicWriteJSON(deployedPath, data);
121
+ atomicWriteJSON(deployedPath, data, { mode: 0o600 });
119
122
  } catch (e) {
120
123
  return { updated: false, reason: 'write_failed', message: e.message };
121
124
  }
@@ -153,18 +156,69 @@ const QIDIAN_RANK_TYPES = [
153
156
  * 详情页还补上了 bookStatus:榜单 records 的键实测为
154
157
  * _index/bAuth/bName/bid/cat/catId/cnt/desc/rankNum/subCat/subCatId,
155
158
  * 没有任何 status 类字段,20/20 为空 —— 此前产出的 `**状态:**` 行恒为空。
159
+ *
160
+ * ## 月票(2026-10-28)
161
+ *
162
+ * 起点此前**没有任何热度指标** —— 产出的 md 里只有作者/题材/状态/字数/简介,
163
+ * 没有能判断一本书热不热的数字。而榜单页的 records 也**不带月票**:
164
+ * 实测 keys 为 catId/rankCnt/bName/rankNum/cat/cnt/subCatId/bid/subCat/bAuth/desc/_index,
165
+ * 其中 `rankCnt` **随榜单类型变化**:
166
+ * - 月票榜 → "12.81万月票"(有,但精度只到万位)
167
+ * - 书友榜 → "3541"(收藏数)
168
+ * - 推荐榜 → "4.15万推荐"
169
+ * - 畅销榜 / 阅读榜 / 新书榜 → **没有** rankCnt
170
+ *
171
+ * 也就是说光靠榜单页拿不到稳定可比的月票数。
172
+ * 详情页的 `bookInfo.monthTicket` 才是**精确整数**(实测 123271),
173
+ * 所以月票一律取详情页。
174
+ *
175
+ * ## 起点其它热度指标(2026-10-28)
176
+ *
177
+ * 起点不止月票,详情页还给了三个同样能判断热度的字段,一并采集:
178
+ *
179
+ * · `bookInfo.recomAll` —— **总推荐票**(开书至今累计,实测 99261 ~ 3739 万)
180
+ * · `bookInfo.recomWeek` —— **周推荐票**(最近一周,实测 4031 ~ 41826)
181
+ * · `pageData.rewardInfo.todayCnt / weekCnt` —— **打赏**(今日/本周,实测 2 ~ 352)
182
+ *
183
+ * 三个都实测过 12/12 本都有值,且区分度够大。但口径完全不同:
184
+ * `recomAll` 是**历史累计**(老书天然占优),`recomWeek` 是**周增量**,
185
+ * 打赏是**次数不是金额**(页面不公开打赏金额,`totalprice` 恒为 0)。
186
+ *
187
+ * 顺带查了榜单页的 records:`totalRecommend` 这个键**所有榜都是 undefined**
188
+ * (月票榜/畅销榜/推荐榜/阅读榜各 0/20),所以总推荐只能取详情页。
156
189
  */
157
190
  async function fetchBookDetail(bookUrl, headers) {
158
191
  const html = await fetch(bookUrl, { headers, signal: AbortSignal.timeout(15000) })
159
192
  .then(r => r.text());
160
193
  const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
161
194
  if (!m) throw new Error('未找到 pageContext');
162
- const bookInfo = JSON.parse(m[1])?.pageContext?.pageProps?.pageData?.bookInfo;
195
+ const pageData = JSON.parse(m[1])?.pageContext?.pageProps?.pageData;
196
+ const bookInfo = pageData?.bookInfo;
163
197
  if (!bookInfo) throw new Error('未找到 bookInfo');
198
+ // 取不到就返回 null(区别于"真的是 0")—— 渲染时据此决定要不要写这一行。
199
+ // 注:这个函数被测试用源码切片单独调用,所以辅助函数只能定义在函数体内。
200
+ const numOrNull = (v) => {
201
+ if (v === null || v === undefined || v === '') return null;
202
+ const n = Number(v);
203
+ return Number.isFinite(n) ? n : null;
204
+ };
205
+ const reward = pageData.rewardInfo || {};
206
+ const monthTicket = numOrNull(bookInfo.monthTicket);
164
207
  return {
165
208
  intro: normalizeBookIntro(bookInfo.desc || ''),
166
209
  status: bookInfo.bookStatus || '',
167
210
  signStatus: bookInfo.signStatus || '',
211
+ // 月票:精确整数。别用 noRewardMonthTic(那是"未奖励月票",恒为 0)。
212
+ // 额外保留 present:0 张是合法数据,不能和"字段缺失"混为一谈。
213
+ monthTicket: monthTicket === null ? 0 : monthTicket,
214
+ monthTicketPresent: monthTicket !== null,
215
+ // 总推荐票:开书至今累计(bookInfo.recomAll)
216
+ totalRecommend: numOrNull(bookInfo.recomAll),
217
+ // 周推荐票:最近一周(bookInfo.recomWeek)
218
+ weekRecommend: numOrNull(bookInfo.recomWeek),
219
+ // 打赏:**次数**,不是金额 —— 页面不公开打赏金额(totalprice 恒为 0)
220
+ rewardToday: numOrNull(reward.todayCnt),
221
+ rewardWeek: numOrNull(reward.weekCnt),
168
222
  };
169
223
  }
170
224
 
@@ -232,15 +286,34 @@ async function scrapeQidianRank(rankId, outDir) {
232
286
  const detail = await fetchBookDetail(b.url, MOBILE_HEADERS);
233
287
  b.intro = detail.intro;
234
288
  if (!b.status && detail.status) b.status = detail.status;
289
+ b.monthTicket = detail.monthTicket;
290
+ b.monthTicketPresent = detail.monthTicketPresent;
291
+ if (detail.totalRecommend !== null) b.totalRecommend = detail.totalRecommend;
292
+ if (detail.weekRecommend !== null) b.weekRecommend = detail.weekRecommend;
293
+ if (detail.rewardToday !== null || detail.rewardWeek !== null) {
294
+ b.rewardToday = detail.rewardToday;
295
+ b.rewardWeek = detail.rewardWeek;
296
+ }
235
297
  } catch (e) {
236
298
  partialReasons.push(`${b.title} 简介获取失败: ${e.message}`);
237
299
  }
238
300
  }
239
301
 
302
+ // 全部详情页都失败时,文件里既没有简介也没有热度指标 —— 这不是
303
+ // “部分失败”,是整榜数据不可用。直接判失败,别让下游在空数据上做分析。
304
+ const usable = books.filter(b => b.intro || b.monthTicketPresent).length;
305
+ if (usable === 0) {
306
+ return {
307
+ ok: false,
308
+ error: `${RANK_TYPE.label} 所有书籍详情页获取失败:${partialReasons.slice(0, 3).join(';')}`,
309
+ partialReasons,
310
+ };
311
+ }
312
+
240
313
  const today = localDateStamp();
241
314
  const outFile = join(outDir, `${PLATFORM_CN.qidian}${RANK_TYPE.label}_${today}.md`);
242
315
  const md = renderQidianMarkdown(RANK_TYPE.label, url, books);
243
- writeFileSync(outFile, md, 'utf-8');
316
+ atomicWriteFile(outFile, md);
244
317
 
245
318
  return {
246
319
  ok: true,
@@ -264,19 +337,38 @@ function renderQidianMarkdown(rankLabel, url, books) {
264
337
  '',
265
338
  ];
266
339
 
267
- for (const b of books) {
340
+ for (const raw of books) {
341
+ const b = sanitizeBookFields(raw);
268
342
  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 || '[待补]'}`);
343
+ if (b.author) lines.push(`- **作者:** ${b.author}`);
344
+ if (b.genre) lines.push(`- **题材:** ${b.genre}`);
345
+ if (b.status) lines.push(`- **状态:** ${b.status}`);
346
+ // 缺失就不写这一行。写 [待补] 会让下游把它当成真实字数/占位符。
347
+ if (b.words) lines.push(`- **字数:** ${b.words}`);
348
+ // 热度指标 —— 起点的四个热度数字,口径各不相同,别混用:
349
+ // 月票(当月,主指标)/ 总推荐(开书至今累计)/ 周推荐(最近一周)/ 打赏(今日·本周人次)
350
+ // 0 张是合法数据;只有明确“缺失”(monthTicketPresent === false)才不写。
351
+ const hasMonthTicket = b.monthTicketPresent !== false
352
+ && typeof b.monthTicket === 'number' && Number.isFinite(b.monthTicket);
353
+ if (hasMonthTicket) lines.push(`- **月票:** ${b.monthTicket}`);
354
+ // 注意:字段可能是 null(取不到)、undefined(压根没取)、
355
+ // 也可能真的是 0。三者必须分开 —— 把"没取到"当成 0 写进文件,
356
+ // 等于告诉模型"这本书本周没人打赏",那是编造。
357
+ const hasNum = (v) => v !== null && v !== undefined;
358
+ if (hasNum(b.totalRecommend)) lines.push(`- **总推荐:** ${b.totalRecommend}`);
359
+ if (hasNum(b.weekRecommend)) lines.push(`- **周推荐:** ${b.weekRecommend}`);
360
+ if (hasNum(b.rewardToday) || hasNum(b.rewardWeek)) {
361
+ const t = hasNum(b.rewardToday) ? b.rewardToday : 0;
362
+ const w = hasNum(b.rewardWeek) ? b.rewardWeek : 0;
363
+ lines.push(`- **打赏:** 今日 ${t} / 本周 ${w}`);
364
+ }
273
365
  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);
366
+ // 格式与其它字段保持一致:首段紧跟标签,其余段落各自成段。
367
+ // 起点简介里的换行有两种语义(<br> → 行分隔、段落标签 → 段分隔),
368
+ // 所以按空行分段,不按单换行。
369
+ lines.push(...renderIntroLines(b.intro));
278
370
  }
279
- if (b.url) lines.push(`[作品页](${b.url})`);
371
+ if (b.url) lines.push(`- [作品页](${b.url})`);
280
372
  lines.push('', '---', '');
281
373
  }
282
374
 
@@ -417,6 +509,10 @@ async function runScraper(platform, rank, outDir, scanMode) {
417
509
  ok: true,
418
510
  writtenFiles,
419
511
  failed: receipt.failed || 0,
512
+ // 成功采集到的品类数(番茄 receipt 提供)。没有这个字段的平台
513
+ // (起点/晋江/七猫)返回 undefined —— 调用方按"未知"处理,不拦。
514
+ okCategories: Number.isInteger(receipt.okCategories) ? receipt.okCategories : undefined,
515
+ planned: Number.isInteger(receipt.planned) ? receipt.planned : undefined,
420
516
  partial: receipt.partial || false,
421
517
  partialReasons: receipt.partialReasons || [],
422
518
  };
@@ -444,16 +540,46 @@ async function main() {
444
540
  throw new Error('缺少必要参数: --project-root --platform --rank');
445
541
  }
446
542
 
543
+ if (!isAbsolute(projectRoot)) {
544
+ throw new Error(`--project-root 必须是绝对路径: ${projectRoot}`);
545
+ }
546
+ projectRoot = resolve(projectRoot);
547
+ if (isSensitiveProjectPath(projectRoot) || !isValidProjectPath(projectRoot)) {
548
+ throw new Error(`--project-root 路径不合法或属于受保护目录: ${projectRoot}`);
549
+ }
447
550
  if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
448
551
  throw new Error('项目未初始化:.fancy-deployed 不存在');
449
552
  }
450
553
 
554
+ // 先判“有没有初始化”,再解开符号链接查真实路径:
555
+ // 目录不存在时应报 EC124(未初始化),而不是把“路径读不了”误报成 EC001。
556
+ try {
557
+ projectRoot = realpathSync(projectRoot);
558
+ } catch (e) {
559
+ throw new Error(`无法读取 --project-root:${e.message}`);
560
+ }
561
+ if (isSensitiveProjectPath(projectRoot) || !isValidProjectPath(projectRoot)) {
562
+ throw new Error(`--project-root 的真实路径属于受保护目录: ${projectRoot}`);
563
+ }
564
+
451
565
  if (!VALID_PLATFORMS.includes(platform)) {
452
566
  throw new Error(`不支持的平台: ${platform}`);
453
567
  }
454
568
 
455
569
  const today = localDateStamp();
456
570
  const scanDir = join(projectRoot, '扫榜结果');
571
+ // 扫榜结果/ 如果是符号链接,写盘会落到链接指向的项目外。
572
+ // 这是插件自己管理的产物目录,不允许用链接代替。
573
+ try {
574
+ if (existsSync(scanDir)) {
575
+ const st = lstatSync(scanDir);
576
+ if (st.isSymbolicLink() || !st.isDirectory()) {
577
+ throw new Error('扫榜结果/ 不是真实目录,拒绝写入;请换成真实目录');
578
+ }
579
+ }
580
+ } catch (e) {
581
+ if (e && e.message && e.message.includes('不是真实目录')) throw e;
582
+ }
457
583
  ensureDir(scanDir);
458
584
 
459
585
  let scanFiles = [];
@@ -470,6 +596,18 @@ async function main() {
470
596
  }
471
597
 
472
598
  scanFiles = r.writtenFiles;
599
+ // scraper 报告的文件必须在 扫榜结果/ 内且真实存在。
600
+ // 不能让一个被篡改/写错的 scraper 把任意路径塞进扫描回执,
601
+ // 后面 LLM 会把它当作数据文件去读。
602
+ const scanDirReal = realpathSync(scanDir);
603
+ for (const f of scanFiles) {
604
+ let realFile = '';
605
+ try { realFile = realpathSync(f); } catch { /* 不存在 */ }
606
+ if (!realFile || !isPathInside(scanDirReal, realFile)
607
+ || !existsSync(realFile) || !statSync(realFile).isFile()) {
608
+ throw new Error(`scraper 返回了非法输出文件: ${f}`);
609
+ }
610
+ }
473
611
  failed = r.failed || 0;
474
612
  if (Array.isArray(r.partialReasons)) partialReasons.push(...r.partialReasons);
475
613
 
@@ -477,7 +615,16 @@ async function main() {
477
615
  // 部分失败(failed>0)也算成功推进,因为已有产出文件
478
616
  // 注:fancy-topic 启动时会再检查 topic_decision_<date>.md 是否存在,
479
617
  // 并要求 phase === 'scanned' 才允许运行
480
- const phaseResult = advancePhase(projectRoot, 'scanned');
618
+ //
619
+ // **但一个品类都没成功时不推进**(2026-10-29 审计):
620
+ // 番茄在所有品类都失败时**照样写文件**(里面全是 `## <品类> — 采集失败`),
621
+ // 于是 scanFiles.length > 0 → 判成功 → phase 推进到 scanned
622
+ // → 用户以为扫好了,跑 /fancy-topic 却在空报告上做选题。
623
+ const nothingSucceeded = r.okCategories === 0
624
+ && Number.isInteger(r.planned) && r.planned > 0;
625
+ const phaseResult = nothingSucceeded
626
+ ? { updated: false, reason: 'no_category_succeeded' }
627
+ : advancePhase(projectRoot, 'scanned');
481
628
  console.error(` 📌 phase: ${phaseResult.updated ? `${phaseResult.previousPhase} → ${phaseResult.currentPhase}` : phaseResult.reason}`);
482
629
  // "没推进"有两种含义,必须分开:
483
630
  // already_at_phase / already_advanced —— 正常(重扫),不是问题
@@ -21,11 +21,12 @@
21
21
  */
22
22
 
23
23
  import fs from 'fs';
24
+ import { atomicWriteFile } from '#atomic-write';
24
25
  import path from 'path';
25
26
  import { pathToFileURL } from 'url';
26
27
  import { chromium } from 'playwright-core';
27
28
  import Tesseract from 'tesseract.js';
28
- import { getArg, localDateStamp, runCli, normalizeScanMode, SCAN_MODE_API } from '#scraper-utils';
29
+ import { getArg, localDateStamp, runCli, normalizeScanMode, SCAN_MODE_API, renderIntroLines, sanitizeBookFields, sanitizeMarkdownInline } from '#scraper-utils';
29
30
 
30
31
  // 捕获未处理的 promise 错误——只写 stderr,让上层重试逻辑处理,不要直接退出
31
32
  process.on('unhandledRejection', (reason) => {
@@ -73,11 +74,21 @@ function typeLabel(t) {
73
74
  * 等待排名前 N 名渲染完毕(最多等 60 秒)
74
75
  */
75
76
  async function waitForRankN(page, n) {
77
+ let lastCount = -1;
78
+ let stableRounds = 0;
76
79
  for (let attempt = 0; attempt < 30; attempt++) {
77
80
  const count = await page.locator('.rank-book-item').count();
78
- if (count < n) { await page.waitForTimeout(2000); continue; }
79
- const h1Text = await page.locator('.rank-book-item').nth(n - 1).locator('.book-item-index h1').textContent().catch(() => '');
80
- if (h1Text && h1Text.trim() !== '') return true;
81
+ if (count >= n) {
82
+ const h1Text = await page.locator('.rank-book-item').nth(n - 1).locator('.book-item-index h1').textContent().catch(() => '');
83
+ if (h1Text && h1Text.trim() !== '') return true;
84
+ } else if (count > 0 && count === lastCount) {
85
+ // 数量连续两轮不变 → 渲染已经稳定,就是这个品类本来本数少
86
+ stableRounds++;
87
+ if (stableRounds >= 2) return true;
88
+ } else {
89
+ stableRounds = 0;
90
+ }
91
+ lastCount = count;
81
92
  await page.waitForTimeout(2000);
82
93
  }
83
94
  return false;
@@ -86,7 +97,7 @@ async function waitForRankN(page, n) {
86
97
  async function screenshotToFile(locator, filepath) {
87
98
  const buf = await locator.screenshot({ type: 'jpeg', quality: 95 });
88
99
  fs.mkdirSync(path.dirname(filepath), { recursive: true });
89
- fs.writeFileSync(filepath, buf);
100
+ atomicWriteFile(filepath, buf);
90
101
  return filepath;
91
102
  }
92
103
 
@@ -295,7 +306,7 @@ const API_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like
295
306
  const API_TIMEOUT_MS = 20000; // 实测 p95 延迟 463ms,留足余量
296
307
  const API_CONCURRENCY = 8; // 实测 8 并发 24 QPS 零失败;40 并发 70 QPS 也零失败,
297
308
  // 但曾观测到突发后的一次 444 限流窗口,保守取 8
298
- const API_RETRY = 1; // 失败重试次数(总尝试 = API_RETRY + 1)
309
+ const API_RETRY = 3; // 失败重试次数(总尝试 = API_RETRY + 1)
299
310
  const API_RETRY_DELAY_MS = 1500;
300
311
 
301
312
  /**
@@ -318,10 +329,41 @@ async function fetchBookInfo(bookId) {
318
329
  throw new Error(json && json.message ? String(json.message) : `接口返回 code=${json && json.code}`);
319
330
  }
320
331
  const data = json.data;
321
- if (!data || !data.abstract) throw new Error('接口未返回简介');
332
+ // 缺**书名**才是整本失败;缺简介不算(2026-10-29 审计)。
333
+ //
334
+ // 之前是 `!data.abstract` → throw。API 模式下书名/作者/精确在读/字数/
335
+ // 官方标签**全都来自这个接口**(DOM 里的书名是 PUA 混淆字形),
336
+ // 所以"只因没有 abstract"就把整本书判失败,丢的远不止简介。
337
+ // 真的没简介的书,书名作者还在,照样能收(简介留空)。
338
+ if (!data || !data.bookName) throw new Error('接口未返回书名');
322
339
  return data;
323
340
  }
324
341
 
342
+ /**
343
+ * 把 fetch 的 `TypeError: fetch failed` 展开成可排查的原因。
344
+ *
345
+ * Node/undici 把真正的网络错误藏在 `err.cause` 里(code / hostname /
346
+ * address / AggregateError),只记 message 时日志里永远只有 "fetch failed",
347
+ * 无法判断是 DNS、连接重置、超时还是限流。
348
+ */
349
+ function describeBookInfoError(err) {
350
+ if (!err) return '未知错误';
351
+ const cause = err.cause || {};
352
+ const first = (cause.errors && cause.errors[0]) || cause;
353
+ const code = first.code || cause.code || '';
354
+ const host = first.hostname || cause.hostname || first.address || cause.address || '';
355
+ const detail = [err.message || String(err)];
356
+ if (code) detail.push(code);
357
+ if (host) detail.push(host);
358
+ return detail.join(' / ');
359
+ }
360
+
361
+ /** 这些错误重试没有意义(书不存在/接口契约错误),直接缓存失败。 */
362
+ function isNonRetryableBookInfoError(err) {
363
+ const message = String((err && err.message) || '');
364
+ return /查无此书|接口未返回书名|响应非 JSON|code=-?\d+/.test(message);
365
+ }
366
+
325
367
  /**
326
368
  * bookId → { ok, data } 的缓存。
327
369
  *
@@ -345,12 +387,15 @@ async function loadBookInfo(bookId) {
345
387
  return result;
346
388
  } catch (e) {
347
389
  lastError = e;
390
+ if (isNonRetryableBookInfoError(e)) break;
348
391
  if (attempt < API_RETRY) {
349
392
  await new Promise(r => setTimeout(r, API_RETRY_DELAY_MS * (attempt + 1)));
350
393
  }
351
394
  }
352
395
  }
353
- const result = { ok: false, error: lastError ? lastError.message : '未知错误' };
396
+ const reason = describeBookInfoError(lastError);
397
+ process.stderr.write(`[api-fail] 番茄/${bookId}: ${reason}\n`);
398
+ const result = { ok: false, error: reason };
354
399
  _bookInfoCache.set(bookId, result);
355
400
  return result;
356
401
  }
@@ -445,6 +490,56 @@ async function prepareOcrFriendlyStyles(page) {
445
490
  * 返回 { books, failures }:failures 是取不到明文的书,向上汇总到
446
491
  * partialReasons,让用户看得见"哪本没取到",而不是只看到空简介。
447
492
  */
493
+ /**
494
+ * 读榜页给出的**名次变动**。
495
+ *
496
+ * DOM:`<p><span class="up">1</span></p>` / `<span class="down">2</span>` / `<p>-</p>`。
497
+ * 方向只在 span 的 class 上,textContent 分不出升降(实测 up/down 都出现过,
498
+ * 只取数字会把"下降 1 名"判成"上升 1 名")。
499
+ *
500
+ * 返回:
501
+ * '上升 N' / '下降 N' / '持平' —— 有值
502
+ * null —— 这个元素压根不存在(不是"没变化")
503
+ */
504
+ export async function readRankChange(item) {
505
+ const p = item.locator('.book-item-index p').first();
506
+ if ((await p.count().catch(() => 0)) === 0) return null;
507
+ const text = (await p.innerText().catch(() => '')).trim();
508
+ if (!text || text === '-') return '持平';
509
+ const cls = (await p.locator('span').first().getAttribute('class').catch(() => '')) || '';
510
+ if (/up/i.test(cls)) return `上升 ${text}`;
511
+ if (/down/i.test(cls)) return `下降 ${text}`;
512
+ return text;
513
+ }
514
+
515
+ /**
516
+ * 解析接口里的 `categoryV2` —— 平台**官方标签**。
517
+ *
518
+ * 形如 `[{"Name":"西方奇幻","MainCategory":true,...},{"Name":"系统",...}]`,
519
+ * 每个标签还带官方描述(ExternalDesc)和维度号(Dim)。
520
+ *
521
+ * 实测 25/25 本都有,平均 5.8 个/本,范围 3~11 个。第一个是主分类
522
+ * (MainCategory=true,与采集端的题材组一致),其余是次级标签 ——
523
+ * 次级标签里**题材细分和元素套路混在一起**(如"都市/东方玄幻"与
524
+ * "无限流/单女主/无脑爽"),不能按 Dim 硬分。
525
+ *
526
+ * 主分类排到最前(不依赖数组顺序),其余保持原序。
527
+ */
528
+ export function parseTags(categoryV2) {
529
+ if (!categoryV2) return [];
530
+ let arr;
531
+ try {
532
+ arr = JSON.parse(categoryV2);
533
+ } catch (_) {
534
+ return [];
535
+ }
536
+ if (!Array.isArray(arr)) return [];
537
+ const nameOf = (t) => (t && t.Name ? String(t.Name).trim() : '');
538
+ const main = arr.filter((t) => t && t.MainCategory);
539
+ const rest = arr.filter((t) => t && !t.MainCategory);
540
+ return [...main, ...rest].map(nameOf).filter(Boolean);
541
+ }
542
+
448
543
  async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase) {
449
544
  await page.goto('https://fanqienovel.com' + catHref, { waitUntil: 'commit', timeout: 60000 });
450
545
  await page.waitForTimeout(3000);
@@ -472,6 +567,11 @@ async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase)
472
567
  const readerText = await item.locator('.book-item-count').first().textContent().catch(() => '');
473
568
  const readerCount = (readerText || '').replace('在读:', '').trim();
474
569
 
570
+ // 排名变动:`<p><span class="up">1</span></p>` / `down` / `-`(无变化)。
571
+ // 方向在 span 的 class 里,光取 textContent 分不出上升还是下降 ——
572
+ // 实测 19 个分类 380 本里 up/down 都有,只看数字会全判错。
573
+ const rankChange = await readRankChange(item);
574
+
475
575
  const url = await item.locator('.book-item-text .title a').first().getAttribute('href').catch(() => '') || '';
476
576
  const bookIdMatch = url.match(/\/page\/(\d+)/);
477
577
  const bookId = (bookIdMatch && bookIdMatch[1]) || `unknown_${i}`;
@@ -487,6 +587,7 @@ async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase)
487
587
  status: (statusRaw || '').trim() || '未知',
488
588
  desc: '',
489
589
  readerCount: readerCount || '未知',
590
+ rankChange,
490
591
  });
491
592
  continue;
492
593
  }
@@ -519,6 +620,7 @@ async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase)
519
620
  status: ocr.status || '未知',
520
621
  desc: ocr.desc || '',
521
622
  readerCount: readerCount || '未知',
623
+ rankChange,
522
624
  });
523
625
  }
524
626
 
@@ -533,6 +635,13 @@ async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase)
533
635
  b.title = r.data.bookName || b.title;
534
636
  b.author = r.data.author || b.author;
535
637
  b.desc = r.data.abstract || '';
638
+ // 精确在读:榜页只给"59.8万"这种舍入值,接口给的是准确人数
639
+ // (实测同书 597999 ↔ 榜页 59.8万)。份额/中位数用它更准。
640
+ if (r.data.readCount) b.readCount = String(r.data.readCount);
641
+ // 字数:番茄此前完全没有字数(其它三个平台都有)
642
+ if (r.data.wordNumber) b.wordCount = String(r.data.wordNumber);
643
+ // 平台官方标签
644
+ b.tags = parseTags(r.data.categoryV2);
536
645
  continue;
537
646
  }
538
647
  // 两种失败要分开说:一种是没从 DOM 拿到 bookId(压根没法查接口),
@@ -552,14 +661,23 @@ async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase)
552
661
 
553
662
  function buildMarkdown(catName, books) {
554
663
  const lines = [];
555
- lines.push(`## ${catName} — ${books.length} 本`, '');
556
- for (const b of books) {
664
+ lines.push(`## ${sanitizeMarkdownInline(catName)} — ${books.length} 本`, '');
665
+ for (const raw of books) {
666
+ const b = sanitizeBookFields(raw);
557
667
  lines.push(`### #${b.rank} ${b.title}`);
558
- lines.push(`**作者:** ${b.author}`);
559
- if (b.status) lines.push(`**状态:** ${b.status}`);
560
- if (b.readerCount) lines.push(`**在读:** ${b.readerCount}`);
561
- if (b.desc) { lines.push(''); lines.push('**简介**'); lines.push(b.desc); }
562
- if (b.url) lines.push(`[作品页](https://fanqienovel.com${b.url})`);
668
+ lines.push(`- **作者:** ${b.author}`);
669
+ if (b.status) lines.push(`- **状态:** ${b.status}`);
670
+ // 精确在读优先(接口给的准确人数),没有才用榜页的舍入值
671
+ const readers = b.readCount || b.readerCount;
672
+ if (readers) lines.push(`- **在读:** ${readers}`);
673
+ if (b.wordCount) lines.push(`- **字数:** ${b.wordCount}`);
674
+ if (b.tags && b.tags.length) lines.push(`- **标签:** ${b.tags.join('、')}`);
675
+ if (b.rankChange) lines.push(`- **排名变动:** ${b.rankChange}`);
676
+ // 简介和其他字段一样是 `- ` 列表项;单换行用硬换行,空行才是段落分隔。
677
+ // 之前是「空行 + `**简介**` + 简介整块」—— 没有冒号,且和上下文粘成一段,
678
+ // 而且简介首尾的空白字符原样留在文件里。
679
+ lines.push(...renderIntroLines(b.desc || ''));
680
+ if (b.url) lines.push(`- [作品页](https://fanqienovel.com${b.url})`);
563
681
  lines.push('');
564
682
  }
565
683
  lines.push('---', '');
@@ -635,6 +753,11 @@ async function scrapeChannel(browserRef, ch, type) {
635
753
  // 单本取数失败与"品类采集失败"是两种不同粒度的失败,单位不能混:
636
754
  // 前者计本数,后者计题材数。两者都要进 partialReasons 让用户看见。
637
755
  let failedBooks = 0;
756
+ // 成功采集到的品类数(2026-10-29 审计)。
757
+ // 之前只看"写了几个文件" —— 而**所有品类都失败时文件照样写出来**
758
+ // (里面全是 `## <品类> — 采集失败` 段落),上层 `ok: scanFiles.length > 0`
759
+ // 于是判成功,项目被推进到 phase=scanned,用户拿到一份空报告。
760
+ let okCategories = 0;
638
761
 
639
762
  // 4. 逐个分类采集(每个 category 用独立 browser context)
640
763
  for (let ci = 0; ci < categories.length; ci++) {
@@ -660,6 +783,7 @@ async function scrapeChannel(browserRef, ch, type) {
660
783
  failedBooks += failures.length;
661
784
  }
662
785
  ok = true;
786
+ okCategories++;
663
787
  } catch (e) {
664
788
  const diag = { stage: 'category', cat: cat.name, href: cat.href, msg: e.message };
665
789
  process.stderr.write(`[diag] ${JSON.stringify(diag)}\n`);
@@ -693,12 +817,13 @@ async function scrapeChannel(browserRef, ch, type) {
693
817
  const filename = `番茄${channelLabel(ch)}${typeLabel(type)}_${date}.md`;
694
818
  fs.mkdirSync(OUTDIR, { recursive: true });
695
819
  const filepath = path.join(OUTDIR, filename);
696
- fs.writeFileSync(filepath, allLines.join('\n'), 'utf-8');
820
+ atomicWriteFile(filepath, allLines.join('\n'));
697
821
  writtenFiles.push(filepath);
698
822
 
699
823
  return {
700
824
  writtenFiles,
701
825
  failed: failedCategories + failedBooks,
826
+ okCategories,
702
827
  partialReasons,
703
828
  };
704
829
  }
@@ -729,6 +854,7 @@ async function main() {
729
854
  const writtenFiles = [];
730
855
  const partialReasons = [];
731
856
  let failedCount = 0;
857
+ let okCategories = 0;
732
858
 
733
859
  // 首次启动 browser(失败时尝试一次 fallback 重试)
734
860
  try {
@@ -746,6 +872,7 @@ async function main() {
746
872
  const r = await scrapeChannel(browserRef, ch, ty);
747
873
  writtenFiles.push(...r.writtenFiles);
748
874
  failedCount += r.failed;
875
+ okCategories += r.okCategories || 0;
749
876
  partialReasons.push(...r.partialReasons);
750
877
  } catch (chErr) {
751
878
  failedCount++;
@@ -774,6 +901,8 @@ async function main() {
774
901
  planned,
775
902
  written,
776
903
  failed: failedCount,
904
+ // 成功采集到的品类数 —— 上层用它判断"是不是一个都没成功"
905
+ okCategories,
777
906
  partial: failedCount > 0 || written < planned,
778
907
  partialReasons,
779
908
  files: writtenFiles,