@cloud411716/fancy-webnovel 1.0.46 → 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 (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  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 +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. 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 +427 -0
  238. 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
  239. 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
  240. 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
  241. 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 +168 -0
  242. 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 +184 -0
  243. 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
  244. 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 +131 -0
  245. 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 +85 -0
  246. 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 +103 -0
  247. 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 +68 -0
  248. 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 +142 -0
  249. 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 +292 -0
  250. 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 +266 -0
  251. 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 +336 -0
  252. 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
  253. 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 +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,923 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 番茄小说排行榜采集脚本
4
+ *
5
+ * 流程:
6
+ * 1. 使用 playwright-core 打开榜单首页
7
+ * 2. 从页面 DOM 动态提取子题材分类链接
8
+ * 3. 把"全部"(首页本身)也加入品类列表
9
+ * 4. 对每个分类:新建独立 browser context(避免 OOM/状态污染)
10
+ * → 等待前 TOP 名渲染完毕
11
+ * → DOM 提取 rank/readerCount/url(明文)
12
+ * → 截图 title/author/status/desc 区域
13
+ * → tesseract.js OCR 识别截图
14
+ * 5. 失败时写结构化诊断到 stderr(即便进程被 OOM 杀,前面阶段的进度也能保留)
15
+ * 6. 完成后输出 JSON receipt 到 stdout(被 run-scan.js 解析)
16
+ *
17
+ * 关键决策:
18
+ * - 每 category 用全新 browser context:避免共享 chromium 时的内存累积
19
+ * - 截图用 jpeg quality 95 + deviceScaleFactor 2 提高 OCR 准确率
20
+ * - OCR 后清理 Tesseract 在中文间插入的空格
21
+ */
22
+
23
+ import fs from 'fs';
24
+ import { atomicWriteFile } from '#atomic-write';
25
+ import path from 'path';
26
+ import { pathToFileURL } from 'url';
27
+ import { chromium } from 'playwright-core';
28
+ import Tesseract from 'tesseract.js';
29
+ import { getArg, localDateStamp, runCli, normalizeScanMode, SCAN_MODE_API, renderIntroLines, sanitizeBookFields, sanitizeMarkdownInline } from '#scraper-utils';
30
+
31
+ // 捕获未处理的 promise 错误——只写 stderr,让上层重试逻辑处理,不要直接退出
32
+ process.on('unhandledRejection', (reason) => {
33
+ const msg = reason instanceof Error ? reason.message : String(reason);
34
+ process.stderr.write(`[unhandledRejection] ${msg}\n`);
35
+ });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // CLI 参数
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const args = process.argv.slice(2);
42
+ const OUTDIR = getArg(args, "--outdir") || ".";
43
+ const CHANNEL = getArg(args, "--channel") || "1";
44
+ const TYPE = getArg(args, "--type") || "2";
45
+ const TOP = parseInt(getArg(args, "--top") || "10", 10);
46
+
47
+ // 扫描方式(取数路径):
48
+ // api —— 书名/作者/简介走番茄明文接口 /api/book/info(零 OCR 误差)
49
+ // ocr —— 渲染后截图 + tesseract 识别(原流程)
50
+ // 非法值/缺失一律按默认(api)处理:绝不能出现"既不截图也不取接口"的空采集。
51
+ const SCAN_MODE = normalizeScanMode(getArg(args, "--scan-mode"));
52
+
53
+ // 兜底退出:任何情况都尝试释放 OCR worker
54
+ process.on('exit', () => {
55
+ if (_ocrWorker) {
56
+ try { _ocrWorker.terminate(); } catch (_) {}
57
+ _ocrWorker = null;
58
+ }
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // 工具函数
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function channelLabel(ch) {
66
+ return ch === "1" ? "男频" : "女频";
67
+ }
68
+
69
+ function typeLabel(t) {
70
+ return t === "2" ? "阅读榜" : "新书榜";
71
+ }
72
+
73
+ /**
74
+ * 等待排名前 N 名渲染完毕(最多等 60 秒)
75
+ */
76
+ async function waitForRankN(page, n) {
77
+ let lastCount = -1;
78
+ let stableRounds = 0;
79
+ for (let attempt = 0; attempt < 30; attempt++) {
80
+ const count = await page.locator('.rank-book-item').count();
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;
92
+ await page.waitForTimeout(2000);
93
+ }
94
+ return false;
95
+ }
96
+
97
+ async function screenshotToFile(locator, filepath) {
98
+ const buf = await locator.screenshot({ type: 'jpeg', quality: 95 });
99
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
100
+ atomicWriteFile(filepath, buf);
101
+ return filepath;
102
+ }
103
+
104
+ /**
105
+ * 把页面文本规整成安全的单层目录名。
106
+ *
107
+ * 题材名与排名都直接取自 DOM 文本,可能含 '/'、'..' 或控制字符;
108
+ * 直接拼进路径会导致 mkdir 失败,或写到目标目录之外。
109
+ */
110
+ export function safePathSegment(raw, fallback) {
111
+ const s = String(raw == null ? '' : raw)
112
+ .replace(/[\x00-\x1f\x7f]/g, '') // 控制字符
113
+ .replace(/[/\\]/g, '_') // 路径分隔符
114
+ .replace(/^\.+/, '_') // 开头的点串("."、".."、"...")
115
+ .trim();
116
+ return s || fallback;
117
+ }
118
+
119
+ /**
120
+ * 单例 OCR worker:tesseract.js v7 用 createWorker 初始化,避免每张图都重建 worker
121
+ * 进程退出时 dispose
122
+ *
123
+ * 语言模型只装 chi_sim,不装 eng+chi_sim。
124
+ *
125
+ * tesseract 是**按行/按块**判定语言的,不是逐字混合。番茄的简介开头常是
126
+ * "【种田+慢热+西幻+多种族+无系统+争霸】" 这种符号密集的标签块,
127
+ * 混合模型会把**整行**判成英文,于是该行的中文被读成拉丁字母:
128
+ * "穿越中世纪,成为一名叫"菲尔德"的贵族" → "FEPHE,AAS FRENRE SAXMR,ABHNERE"
129
+ * 同一行的后半段若以中文开头又是对的 —— 这正是"按行判定"的症状。
130
+ *
131
+ * 实测(20 本,详情页明文作真值,编辑距离相似度):
132
+ * 字段 eng+chi_sim chi_sim
133
+ * title 93.0% 94.2%
134
+ * author 83.3% 93.6%
135
+ * desc 84.0% 92.9%
136
+ * desc 里 20 本有 18 本被 eng 判成拉丁行,chi_sim 只有 4 本。
137
+ */
138
+ let _ocrWorker = null;
139
+ let _ocrInitPromise = null;
140
+
141
+ // OCR 各环节的超时上限。
142
+ // tesseract.js 的 recognize() 没有取消接口,也没有超时参数(实测其源码中
143
+ // 搜不到任何 timeout 机制),worker 又是单线程串行处理任务的 ——
144
+ // 一次 recognize 卡死,会把后续所有 OCR 一起拖死,只能靠外层 25 分钟
145
+ // 的进程超时兜底,代价是整榜数据作废。
146
+ const OCR_INIT_TIMEOUT_MS = 120000; // worker 初始化(含语言包加载/下载)
147
+ const OCR_TIMEOUT_MS = 30000; // 单张图识别
148
+ const OCR_TERMINATE_TIMEOUT_MS = 5000; // 换掉卡死 worker 时终止自身的上限
149
+
150
+ /**
151
+ * 给 promise 加超时兜底。
152
+ *
153
+ * 超时后原始 promise 仍在跑(无法取消),但 race 已经结算,
154
+ * 调用方不再等待;原始 promise 后续的 reject 由 race 吸收,不会变成
155
+ * unhandledRejection。
156
+ */
157
+ function withTimeout(promise, ms, label) {
158
+ let timer;
159
+ return Promise.race([
160
+ promise,
161
+ new Promise((_, reject) => {
162
+ timer = setTimeout(() => reject(new Error(`${label}超时(${ms}ms)`)), ms);
163
+ }),
164
+ ]).finally(() => clearTimeout(timer));
165
+ }
166
+
167
+ /** 终止并丢弃当前 worker(卡死时用;worker 为空则什么都不做) */
168
+ async function resetOcrWorker() {
169
+ const w = _ocrWorker;
170
+ _ocrWorker = null;
171
+ _ocrInitPromise = null;
172
+ if (!w) return;
173
+ try {
174
+ await Promise.race([
175
+ w.terminate(),
176
+ new Promise((_, reject) => setTimeout(() => reject(new Error('terminate timeout')), OCR_TERMINATE_TIMEOUT_MS)),
177
+ ]);
178
+ } catch (_) {}
179
+ }
180
+
181
+ async function getOcrWorker() {
182
+ if (_ocrWorker) return _ocrWorker;
183
+ if (_ocrInitPromise) return _ocrInitPromise;
184
+ const init = Tesseract.createWorker('chi_sim');
185
+ _ocrInitPromise = (async () => {
186
+ try {
187
+ const w = await withTimeout(init, OCR_INIT_TIMEOUT_MS, 'OCR worker 初始化');
188
+ _ocrWorker = w;
189
+ return w;
190
+ } catch (e) {
191
+ // 失败/超时后必须清掉缓存的 promise:否则后续每次调用都拿到同一个
192
+ // rejected promise,OCR 永远不可用(实测此前 disposeOcrWorker 在
193
+ // _ocrWorker 为空时也不会清 _ocrInitPromise)。
194
+ _ocrInitPromise = null;
195
+ // init 可能日后仍会成功,兜底终止它,避免泄漏一个 worker
196
+ init.then(w => w.terminate().catch(() => {})).catch(() => {});
197
+ throw e;
198
+ }
199
+ })();
200
+ return _ocrInitPromise;
201
+ }
202
+
203
+ async function disposeOcrWorker() {
204
+ await resetOcrWorker();
205
+ }
206
+
207
+ /**
208
+ * OCR 识别截图(清理多余空格,保留中文可读性)
209
+ */
210
+ /**
211
+ * 规整 OCR 文本中的多余空白。
212
+ *
213
+ * tesseract 识别中文时是"逐字插空格"("百 岁 修仙"),这些空格必须去掉;
214
+ * 但拉丁字母/数字构成的多词短语("Hello World")的词间空格要保留。
215
+ *
216
+ * 此前实现先 replace(/\s+/g,'') 把所有空白删光,导致:
217
+ * 1) 英文短语被粘连成 "HelloWorld";
218
+ * 2) 后面"只删 CJK 之间空格"的分支永远匹配不到东西(死代码),
219
+ * 且该分支的注释与实际行为不符。
220
+ *
221
+ * 规则:只保留"拉丁字母/数字 ↔ 拉丁字母/数字"之间的空白,其余一律删除。
222
+ * 用占位符保住需要保留的空格,避免与后续的全量删除互相抵消。
223
+ */
224
+ export function normalizeOcrText(raw) {
225
+ const MARK = '\u0000';
226
+ return String(raw == null ? '' : raw)
227
+ .trim()
228
+ .replace(/([A-Za-z0-9])[\s]+(?=[A-Za-z0-9])/g, `$1${MARK}`)
229
+ .replace(/\s+/g, '')
230
+ .split(MARK).join(' ');
231
+ }
232
+
233
+ async function ocrFile(imagePath) {
234
+ const worker = await getOcrWorker();
235
+ try {
236
+ const { data: { text } } = await withTimeout(
237
+ worker.recognize(imagePath),
238
+ OCR_TIMEOUT_MS,
239
+ 'OCR 识别'
240
+ );
241
+ return normalizeOcrText(text);
242
+ } catch (e) {
243
+ // recognize 卡死时无法取消,而 worker 单线程串行处理任务 ——
244
+ // 一个卡死会把后续所有 OCR 一起拖死。只能把 worker 换掉,
245
+ // 让 ocrWithRetry 的下一次重试拿到全新的 worker。
246
+ await resetOcrWorker();
247
+ throw e;
248
+ }
249
+ }
250
+
251
+ async function screenshotWithRetry(locator, filepath, retries = 3) {
252
+ for (let i = 0; i < retries; i++) {
253
+ try {
254
+ return await screenshotToFile(locator, filepath);
255
+ } catch (e) {
256
+ if (i === retries - 1) throw e;
257
+ await new Promise(r => setTimeout(r, 1000 * (i + 1)));
258
+ }
259
+ }
260
+ }
261
+
262
+ async function ocrWithRetry(imagePath, retries = 3) {
263
+ for (let i = 0; i < retries; i++) {
264
+ try {
265
+ return await ocrFile(imagePath);
266
+ } catch (e) {
267
+ if (i === retries - 1) throw e;
268
+ await new Promise(r => setTimeout(r, 1000 * (i + 1)));
269
+ }
270
+ }
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // API 模式:番茄明文接口
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * 番茄的榜单页把书名/作者/简介用 awesome-font 做了 PUA 字体混淆
279
+ * (DOM 文本是 U+E000–U+F8FF 私用区码位),所以"渲染后识别"只是取回真文本
280
+ * 的一种手段,精度天然受限于 OCR。
281
+ *
282
+ * 番茄自家在用的 /api/book/info 返回的是同名同内容的**明文**字段
283
+ * (bookName / author / abstract),无需浏览器、无需 cookie、无需 a_bogus 签名。
284
+ *
285
+ * 三条独立证据链确认它与榜单页是同一份文本:
286
+ * 1) 榜页 DOM(PUA) --码表--> 明文 vs 接口明文:title 10/10 一致、
287
+ * author 10/10 一致、desc 长度 10/10 严格相等;
288
+ * 2) 生产 OCR 产物 200 本 vs 接口明文:desc 字符相似度 92.26%,
289
+ * 差异全部是 OCR 认字错误(登项→登顶、社瘟→社畜),长度逐本对齐;
290
+ * 3) DOM「在读:70.3万」vs 接口 readCount:200/200 误差 <2%。
291
+ */
292
+ const BOOK_INFO_URL = 'https://fanqienovel.com/api/book/info?bookId=';
293
+
294
+ /**
295
+ * 明文接口的 UA。
296
+ *
297
+ * 实测桌面 Chrome UA 直连即可(HTTP 200),不需要 cookie。
298
+ * 同窗口 A/B 对照与高并发交错对照都证明带不带 cookie 对成功率没有影响
299
+ * (两组各 200/200 全绿)—— 此前观察到的 444 是突发请求后的限流时间窗,
300
+ * 与 cookie 无关。
301
+ * 这里仍然不带 cookie:浏览器会话 cookie 对本进程本就无意义,
302
+ * 真被限流时少一个变量也好排查。
303
+ */
304
+ const API_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
305
+
306
+ const API_TIMEOUT_MS = 20000; // 实测 p95 延迟 463ms,留足余量
307
+ const API_CONCURRENCY = 8; // 实测 8 并发 24 QPS 零失败;40 并发 70 QPS 也零失败,
308
+ // 但曾观测到突发后的一次 444 限流窗口,保守取 8
309
+ const API_RETRY = 3; // 失败重试次数(总尝试 = API_RETRY + 1)
310
+ const API_RETRY_DELAY_MS = 1500;
311
+
312
+ /**
313
+ * 取一本书的明文信息。
314
+ *
315
+ * 失败判定不能只看 HTTP 状态码:查无此书时接口仍返回 HTTP 200,
316
+ * 响应体是 {"data":{},"code":-1,"message":"查无此书"} ——
317
+ * 只看状态码会把"这本书取不到"误判成"取到了空简介"。
318
+ */
319
+ async function fetchBookInfo(bookId) {
320
+ const res = await fetch(BOOK_INFO_URL + encodeURIComponent(bookId), {
321
+ headers: { 'User-Agent': API_UA, 'Accept': 'application/json, text/plain, */*' },
322
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
323
+ });
324
+ if (res.status !== 200) throw new Error(`HTTP ${res.status}`);
325
+ const body = await res.text();
326
+ let json;
327
+ try { json = JSON.parse(body); } catch (_) { throw new Error('响应非 JSON'); }
328
+ if (!json || json.code !== 0) {
329
+ throw new Error(json && json.message ? String(json.message) : `接口返回 code=${json && json.code}`);
330
+ }
331
+ const data = json.data;
332
+ // 缺**书名**才是整本失败;缺简介不算(2026-10-29 审计)。
333
+ //
334
+ // 之前是 `!data.abstract` → throw。API 模式下书名/作者/精确在读/字数/
335
+ // 官方标签**全都来自这个接口**(DOM 里的书名是 PUA 混淆字形),
336
+ // 所以"只因没有 abstract"就把整本书判失败,丢的远不止简介。
337
+ // 真的没简介的书,书名作者还在,照样能收(简介留空)。
338
+ if (!data || !data.bookName) throw new Error('接口未返回书名');
339
+ return data;
340
+ }
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
+
367
+ /**
368
+ * bookId → { ok, data } 的缓存。
369
+ *
370
+ * 同一个 bookId 会出现在多个题材榜单里(实测 200 条记录只有 190 个唯一 id),
371
+ * 缓存后同一本书只请求一次。
372
+ */
373
+ const _bookInfoCache = new Map();
374
+
375
+ /**
376
+ * 带重试的单本取数。缓存的是**结果**而不是 promise 之外的中间态,
377
+ * 失败也缓存 —— 同一本书在别的题材再次出现时不必再撞一次接口。
378
+ */
379
+ async function loadBookInfo(bookId) {
380
+ if (_bookInfoCache.has(bookId)) return _bookInfoCache.get(bookId);
381
+ let lastError = null;
382
+ for (let attempt = 0; attempt <= API_RETRY; attempt++) {
383
+ try {
384
+ const data = await fetchBookInfo(bookId);
385
+ const result = { ok: true, data };
386
+ _bookInfoCache.set(bookId, result);
387
+ return result;
388
+ } catch (e) {
389
+ lastError = e;
390
+ if (isNonRetryableBookInfoError(e)) break;
391
+ if (attempt < API_RETRY) {
392
+ await new Promise(r => setTimeout(r, API_RETRY_DELAY_MS * (attempt + 1)));
393
+ }
394
+ }
395
+ }
396
+ const reason = describeBookInfoError(lastError);
397
+ process.stderr.write(`[api-fail] 番茄/${bookId}: ${reason}\n`);
398
+ const result = { ok: false, error: reason };
399
+ _bookInfoCache.set(bookId, result);
400
+ return result;
401
+ }
402
+
403
+ /**
404
+ * 限并发批量取明文。
405
+ *
406
+ * 并发上限是硬约束:番茄接口在高并发突发后会进入限流窗口(实测出现过
407
+ * 一次 HTTP 444 / Access Denied),所以这里固定 8 并发,不做自适应放大。
408
+ */
409
+ async function loadAllBookInfo(bookIds) {
410
+ const out = new Map();
411
+ if (!bookIds.length) return out;
412
+ let cursor = 0;
413
+ const workers = Array.from({ length: Math.min(API_CONCURRENCY, bookIds.length) }, async () => {
414
+ while (true) {
415
+ const i = cursor++;
416
+ if (i >= bookIds.length) break;
417
+ const id = bookIds[i];
418
+ out.set(id, await loadBookInfo(id));
419
+ }
420
+ });
421
+ await Promise.all(workers);
422
+ return out;
423
+ }
424
+
425
+ /**
426
+ * 从页面提取品类列表(DOM 中所有匹配 prefix 的 anchor)
427
+ */
428
+ async function extractCategories(page, channel, type) {
429
+ const prefix = `/rank/${channel}_${type}_`;
430
+ return await page.evaluate((pref) => {
431
+ const out = [], seen = new Set();
432
+ for (const a of document.querySelectorAll('a')) {
433
+ const href = a.getAttribute('href') || '';
434
+ if (!href.includes(pref)) continue;
435
+ const name = (a.innerText || a.textContent || '').trim();
436
+ if (!name || seen.has(href)) continue;
437
+ seen.add(href);
438
+ out.push({ name, href });
439
+ }
440
+ return out;
441
+ }, prefix);
442
+ }
443
+
444
+ /**
445
+ * 把榜单卡片里的文本调整成适合 OCR 的样子。
446
+ *
447
+ * 番茄的 title/author/desc 用 awesome-font 做了 PUA 字体混淆(DOM 里的
448
+ * 文本是 U+E000–U+F8FF 私用区码位),只能靠"渲染后识别"(截图 + OCR)
449
+ * 拿回真文本。页面的默认样式对 OCR 有两处不利:
450
+ *
451
+ * 1) desc 被 `-webkit-line-clamp:2` 截成两行(榜单页规则
452
+ * `.muye-rank-book-list .book-item-text .abstract`:
453
+ * line-height:23px + overflow:hidden + -webkit-box,
454
+ * 实测 clientHeight=46px / scrollHeight=92~184px)。
455
+ * DOM 里的文本其实是完整的(实测榜单页 desc 与详情页 abstract 等长),
456
+ * 截断只是视觉的 —— 白丢三分之二内容,而且最后一行是半截字形,
457
+ * 识别出来是垃圾(实测尾部出现 "…满是贫穷和怪.,1")。
458
+ * 2) desc 颜色是 rgba(0,0,0,.4)、author 同样是 rgba(0,0,0,.4) ——
459
+ * 浅灰低对比度,tesseract 的识别率明显下降(改纯黑后命中率 +0.2pp)。
460
+ * (title 本来就是 rgb(0,0,0),一起设成 #000 只是兜底,无副作用。)
461
+ *
462
+ * 这里解除行数限制并把颜色改成纯黑。元素是逐个渲染出来的,
463
+ * 所以要在榜单渲染完成之后(waitForRankN 之后)再统一处理。
464
+ */
465
+ async function prepareOcrFriendlyStyles(page) {
466
+ await page.evaluate(() => {
467
+ for (const el of document.querySelectorAll('.book-item-text .desc')) {
468
+ el.style.setProperty('-webkit-line-clamp', 'unset', 'important');
469
+ el.style.setProperty('height', 'auto', 'important');
470
+ el.style.setProperty('max-height', 'none', 'important');
471
+ el.style.setProperty('overflow', 'visible', 'important');
472
+ el.style.setProperty('display', 'block', 'important');
473
+ }
474
+ for (const el of document.querySelectorAll(
475
+ '.book-item-text .desc, .book-item-text .author, .book-item-text .title'
476
+ )) {
477
+ el.style.setProperty('color', '#000', 'important');
478
+ }
479
+ });
480
+ }
481
+
482
+ /**
483
+ * 采集一个品类(10 本书的 rank/title/author/status/desc/readerCount)
484
+ *
485
+ * 两种取数路径:
486
+ * api —— 排名/状态/在读/bookId 走 DOM 直读(本来就是明文),
487
+ * 书名/作者/简介走番茄明文接口;不截图、不 OCR。
488
+ * ocr —— 原流程:四个字段全部截图 + tesseract 识别。
489
+ *
490
+ * 返回 { books, failures }:failures 是取不到明文的书,向上汇总到
491
+ * partialReasons,让用户看得见"哪本没取到",而不是只看到空简介。
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
+
543
+ async function scrapeCategory(page, catHref, catName, ch, type, screenshotBase) {
544
+ await page.goto('https://fanqienovel.com' + catHref, { waitUntil: 'commit', timeout: 60000 });
545
+ await page.waitForTimeout(3000);
546
+
547
+ const ready = await waitForRankN(page, TOP);
548
+ if (!ready) throw new Error('等待渲染超时');
549
+
550
+ // 解除 desc 的两行截断、把文字改成纯黑,再截图 OCR
551
+ // (只有 OCR 路径需要:API 路径不截图,改样式没有意义)
552
+ if (SCAN_MODE !== SCAN_MODE_API) {
553
+ await prepareOcrFriendlyStyles(page);
554
+ }
555
+
556
+ // 一次性拿所有书籍 DOM 引用,避免后续循环索引错位
557
+ const items = await page.locator('.rank-book-item').all();
558
+ const actualTop = Math.min(items.length, TOP);
559
+ const books = [];
560
+
561
+ for (let i = 0; i < actualTop; i++) {
562
+ const item = items[i];
563
+
564
+ const rankRaw = await item.locator('.book-item-index h1').first().textContent().catch(() => '');
565
+ const rank = (rankRaw || '').trim() || String(i + 1);
566
+
567
+ const readerText = await item.locator('.book-item-count').first().textContent().catch(() => '');
568
+ const readerCount = (readerText || '').replace('在读:', '').trim();
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
+
575
+ const url = await item.locator('.book-item-text .title a').first().getAttribute('href').catch(() => '') || '';
576
+ const bookIdMatch = url.match(/\/page\/(\d+)/);
577
+ const bookId = (bookIdMatch && bookIdMatch[1]) || `unknown_${i}`;
578
+
579
+ if (SCAN_MODE === SCAN_MODE_API) {
580
+ // 状态在 DOM 里本来就是明文(实测 PUA=0,"连载中"/"已完结"),
581
+ // 原流程却把它也送去 OCR —— 这里直接读,省掉每本一次识别。
582
+ const statusRaw = await item.locator('.book-item-footer .book-item-footer-status')
583
+ .first().textContent().catch(() => '');
584
+ books.push({
585
+ rank, bookId, url,
586
+ title: '', author: '',
587
+ status: (statusRaw || '').trim() || '未知',
588
+ desc: '',
589
+ readerCount: readerCount || '未知',
590
+ rankChange,
591
+ });
592
+ continue;
593
+ }
594
+
595
+ const bd = path.join(screenshotBase, safePathSegment(rank, String(i + 1)));
596
+ fs.mkdirSync(bd, { recursive: true });
597
+
598
+ const ocr = {};
599
+ for (const [field, sel] of [
600
+ ['title', '.book-item-text .title'],
601
+ ['author', '.book-item-text .author'],
602
+ ['status', '.book-item-footer .book-item-footer-status'],
603
+ ['desc', '.book-item-text .desc'],
604
+ ]) {
605
+ const fp = path.join(bd, `${bookId}_${field}.jpg`);
606
+ try {
607
+ await screenshotWithRetry(item.locator(sel), fp, 3);
608
+ ocr[field] = await ocrWithRetry(fp, 3);
609
+ } catch (e) {
610
+ // 单字段 OCR 失败不影响其他字段——降级为空字符串
611
+ process.stderr.write(`[ocr-fail] ${catName}/${bookId}/${field}: ${e.message}\n`);
612
+ ocr[field] = '';
613
+ }
614
+ }
615
+
616
+ books.push({
617
+ rank, bookId, url,
618
+ title: ocr.title || `(bookId: ${bookId})`,
619
+ author: ocr.author || '未知',
620
+ status: ocr.status || '未知',
621
+ desc: ocr.desc || '',
622
+ readerCount: readerCount || '未知',
623
+ rankChange,
624
+ });
625
+ }
626
+
627
+ // API 路径:批量补书名/作者/简介
628
+ const failures = [];
629
+ if (SCAN_MODE === SCAN_MODE_API) {
630
+ const ids = [...new Set(books.map(b => b.bookId).filter(id => /^\d+$/.test(id)))];
631
+ const infoMap = await loadAllBookInfo(ids);
632
+ for (const b of books) {
633
+ const r = infoMap.get(b.bookId);
634
+ if (r && r.ok) {
635
+ b.title = r.data.bookName || b.title;
636
+ b.author = r.data.author || b.author;
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);
645
+ continue;
646
+ }
647
+ // 两种失败要分开说:一种是没从 DOM 拿到 bookId(压根没法查接口),
648
+ // 另一种是接口本身没返回。都归成"取不到"会让排查无从下手。
649
+ const reason = /^\d+$/.test(b.bookId)
650
+ ? ((r && r.error) || '未知原因')
651
+ : '未取到 bookId';
652
+ process.stderr.write(`[api-fail] ${catName}/${b.bookId}: ${reason}\n`);
653
+ b.title = b.title || `(bookId: ${b.bookId})`;
654
+ b.author = b.author || '未知';
655
+ failures.push(`${catName}/${b.bookId} 明文获取失败: ${reason}`);
656
+ }
657
+ }
658
+
659
+ return { books, failures };
660
+ }
661
+
662
+ function buildMarkdown(catName, books) {
663
+ const lines = [];
664
+ lines.push(`## ${sanitizeMarkdownInline(catName)} — ${books.length} 本`, '');
665
+ for (const raw of books) {
666
+ const b = sanitizeBookFields(raw);
667
+ lines.push(`### #${b.rank} ${b.title}`);
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})`);
681
+ lines.push('');
682
+ }
683
+ lines.push('---', '');
684
+ return lines.join('\n');
685
+ }
686
+
687
+ /**
688
+ * 确保 browser 有效;旧实例已死则关闭并启动新的(避免孤儿进程)
689
+ */
690
+ async function ensureBrowser(browserRef) {
691
+ if (browserRef.current) {
692
+ // isConnected() 返回 boolean,不是 Promise
693
+ let alive = false;
694
+ try { alive = browserRef.current.isConnected(); } catch (_) { alive = false; }
695
+ if (alive) return browserRef.current;
696
+ try { await browserRef.current.close(); } catch (_) {}
697
+ browserRef.current = null;
698
+ }
699
+ browserRef.current = await chromium.launch({ headless: true, timeout: 60000 });
700
+ return browserRef.current;
701
+ }
702
+
703
+ /**
704
+ * 采集一个 channel+type,browserRef 是 { current: Browser } 包装对象
705
+ */
706
+ async function scrapeChannel(browserRef, ch, type) {
707
+ const chLabel = channelLabel(ch), tyLabel = typeLabel(type);
708
+ const listUrl = `https://fanqienovel.com/rank/${ch}_${type}/`;
709
+
710
+ // 1. 获取品类列表
711
+ let categories = [];
712
+ const listPage = await browserRef.current.newPage();
713
+ try {
714
+ await listPage.goto(listUrl, { waitUntil: 'commit', timeout: 60000 });
715
+ // 轮询直到拿到品类列表(动态渲染,最多等 20 秒)
716
+ for (let attempt = 0; attempt < 20; attempt++) {
717
+ categories = await extractCategories(listPage, ch, type);
718
+ if (categories.length > 0) break;
719
+ await listPage.waitForTimeout(1000);
720
+ }
721
+ } catch (e) {
722
+ process.stderr.write(`[stage] extract-categories failed: ${e.message}\n`);
723
+ } finally {
724
+ await listPage.close();
725
+ }
726
+
727
+ // 2. fallback:拿不到任何分类时使用全部 init catId
728
+ if (!categories.length) {
729
+ const initCatId = ch === "1" ? "1141" : "1139";
730
+ categories.push({ name: "全部", href: `/rank/${ch}_${type}_${initCatId}` });
731
+ } else {
732
+ // 把首页(全部)放到第一个,确保也被采集
733
+ categories.unshift({ name: '全部', href: `/rank/${ch}_${type}/` });
734
+ }
735
+
736
+ // 3. 准备 markdown 容器
737
+ const now = new Date().toISOString();
738
+ const allLines = [
739
+ `# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
740
+ '',
741
+ `- 频道参数:channel=${ch},type=${type}`,
742
+ `- 抓取时间:${now}`,
743
+ `- 每题材前${TOP}本`,
744
+ '',
745
+ '---',
746
+ ''
747
+ ];
748
+
749
+ const screenshotBasePrefix = path.join(OUTDIR, 'screenshots', `${chLabel}${tyLabel}_${Date.now()}`);
750
+ const writtenFiles = [];
751
+ const partialReasons = [];
752
+ let failedCategories = 0;
753
+ // 单本取数失败与"品类采集失败"是两种不同粒度的失败,单位不能混:
754
+ // 前者计本数,后者计题材数。两者都要进 partialReasons 让用户看见。
755
+ let failedBooks = 0;
756
+ // 成功采集到的品类数(2026-10-29 审计)。
757
+ // 之前只看"写了几个文件" —— 而**所有品类都失败时文件照样写出来**
758
+ // (里面全是 `## <品类> — 采集失败` 段落),上层 `ok: scanFiles.length > 0`
759
+ // 于是判成功,项目被推进到 phase=scanned,用户拿到一份空报告。
760
+ let okCategories = 0;
761
+
762
+ // 4. 逐个分类采集(每个 category 用独立 browser context)
763
+ for (let ci = 0; ci < categories.length; ci++) {
764
+ const cat = categories[ci];
765
+ const screenshotBase = path.join(screenshotBasePrefix, safePathSegment(cat.name, '未分类'));
766
+
767
+ let ok = false;
768
+ for (let retry = 0; retry < 2 && !ok; retry++) {
769
+ let ctx = null;
770
+ try {
771
+ const browser = await ensureBrowser(browserRef);
772
+ ctx = await browser.newContext({
773
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 2 },
774
+ });
775
+ const page = await ctx.newPage();
776
+
777
+ const { books, failures } = await scrapeCategory(page, cat.href, cat.name, ch, type, screenshotBase);
778
+ allLines.push(buildMarkdown(cat.name, books));
779
+ // 单本明文取不到不算"品类失败",但必须让用户看见:
780
+ // 否则产物里就是一条只有排名的空简介,看不出是接口挂了还是本来就没简介。
781
+ if (failures.length) {
782
+ partialReasons.push(...failures);
783
+ failedBooks += failures.length;
784
+ }
785
+ ok = true;
786
+ okCategories++;
787
+ } catch (e) {
788
+ const diag = { stage: 'category', cat: cat.name, href: cat.href, msg: e.message };
789
+ process.stderr.write(`[diag] ${JSON.stringify(diag)}\n`);
790
+ // browser 可能已死,主动 invalidate,下次 ensureBrowser 会重建
791
+ if (browserRef.current) {
792
+ const stillAlive = (() => { try { return browserRef.current.isConnected(); } catch (_) { return false; } })();
793
+ if (!stillAlive) {
794
+ process.stderr.write(`[diag] browser dead, invalidating\n`);
795
+ browserRef.current = null;
796
+ }
797
+ }
798
+ if (retry === 0) {
799
+ await new Promise(r => setTimeout(r, 2000));
800
+ } else {
801
+ failedCategories++;
802
+ partialReasons.push(`${cat.name}: ${e.message}`);
803
+ allLines.push(`## ${cat.name} — 采集失败`, '', '---', '');
804
+ }
805
+ } finally {
806
+ if (ctx) {
807
+ try { await ctx.close(); } catch (_) {}
808
+ }
809
+ }
810
+ }
811
+
812
+ await new Promise(r => setTimeout(r, 500));
813
+ }
814
+
815
+ // 5. 写 markdown 文件
816
+ const date = localDateStamp();
817
+ const filename = `番茄${channelLabel(ch)}${typeLabel(type)}_${date}.md`;
818
+ fs.mkdirSync(OUTDIR, { recursive: true });
819
+ const filepath = path.join(OUTDIR, filename);
820
+ atomicWriteFile(filepath, allLines.join('\n'));
821
+ writtenFiles.push(filepath);
822
+
823
+ return {
824
+ writtenFiles,
825
+ failed: failedCategories + failedBooks,
826
+ okCategories,
827
+ partialReasons,
828
+ };
829
+ }
830
+
831
+ async function main() {
832
+ if (!["0", "1", "all"].includes(CHANNEL)) throw new Error(`未知 --channel: ${CHANNEL}`);
833
+ if (!["1", "2", "all"].includes(TYPE)) throw new Error(`未知 --type: ${TYPE}`);
834
+ const channels = CHANNEL === "all" ? ["1", "0"] : [CHANNEL];
835
+ const types = TYPE === "all" ? ["2", "1"] : [TYPE];
836
+
837
+ const browserRef = { current: null };
838
+ // 被 SIGTERM 杀时(上游超时或中断)主动关掉浏览器,
839
+ // 否则 chromium 会留在系统里继续占 CPU/内存。
840
+ // close 必须带超时兜底:浏览器正在导航时 close() 可能挂住,
841
+ // 无超时的话进程会卡住不退出(实测过)。
842
+ process.on('SIGTERM', async () => {
843
+ try {
844
+ if (browserRef.current) {
845
+ await Promise.race([
846
+ browserRef.current.close(),
847
+ new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), 5000)),
848
+ ]);
849
+ }
850
+ } catch (_) {}
851
+ process.exit(143);
852
+ });
853
+ const planned = channels.length * types.length;
854
+ const writtenFiles = [];
855
+ const partialReasons = [];
856
+ let failedCount = 0;
857
+ let okCategories = 0;
858
+
859
+ // 首次启动 browser(失败时尝试一次 fallback 重试)
860
+ try {
861
+ await ensureBrowser(browserRef);
862
+ } catch (launchErr) {
863
+ process.stderr.write(`[fatal] browser launch failed: ${launchErr.message}\n`);
864
+ // 重新抛出让 runCli 捕获
865
+ throw new Error(`浏览器启动失败:${launchErr.message}`);
866
+ }
867
+
868
+ try {
869
+ for (const ch of channels) {
870
+ for (const ty of types) {
871
+ try {
872
+ const r = await scrapeChannel(browserRef, ch, ty);
873
+ writtenFiles.push(...r.writtenFiles);
874
+ failedCount += r.failed;
875
+ okCategories += r.okCategories || 0;
876
+ partialReasons.push(...r.partialReasons);
877
+ } catch (chErr) {
878
+ failedCount++;
879
+ partialReasons.push(`${channelLabel(ch)}${typeLabel(ty)}: ${chErr.message}`);
880
+ }
881
+ }
882
+ }
883
+ } finally {
884
+ // 关闭 browser(兜底超时 5s)
885
+ if (browserRef.current) {
886
+ try {
887
+ await Promise.race([
888
+ browserRef.current.close(),
889
+ new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), 5000)),
890
+ ]);
891
+ } catch (e) {
892
+ process.stderr.write(`[cleanup] browser close warning: ${e.message}\n`);
893
+ // 强制 SIGTERM
894
+ try { browserRef.current.process()?.kill('SIGTERM'); } catch (_) {}
895
+ }
896
+ }
897
+ }
898
+
899
+ const written = writtenFiles.length;
900
+ return {
901
+ planned,
902
+ written,
903
+ failed: failedCount,
904
+ // 成功采集到的品类数 —— 上层用它判断"是不是一个都没成功"
905
+ okCategories,
906
+ partial: failedCount > 0 || written < planned,
907
+ partialReasons,
908
+ files: writtenFiles,
909
+ };
910
+ }
911
+
912
+ // import.meta.main 需要 Node >= 22.19(package.json 声明支持 node >=18.0.0)。
913
+ // 旧版本上它是 undefined,必须回落到 argv[1] 比对 —— 否则整个脚本静默空跑:
914
+ // exit 0、无 stdout、没有任何采集文件,调用方只看到"采集失败"。
915
+ const isMain = import.meta.main ??
916
+ (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
917
+
918
+ if (isMain) {
919
+ // 把 dispose 包装成 hook,确保 OCR worker 在退出前 dispose(避免热加载残留)
920
+ runCli(main(), "番茄采集", {
921
+ beforeExit: disposeOcrWorker,
922
+ });
923
+ }