@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,667 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fancy-scan run-scan.js — 采集编排层
4
+ *
5
+ * 职责:
6
+ * 1. 检查 .fancy-deployed
7
+ * 2. 调用平台采集脚本(qidian HTTP;其他平台通过 playwright-core)
8
+ * 3. 解析 scraper 的结构化 JSON receipt
9
+ * 4. 生成汇总 receipt 输出到 stdout
10
+ *
11
+ * 调用方式:
12
+ * node run-scan.js --project-root <abs> --platform fanqie --rank 1_2
13
+ */
14
+
15
+ import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from 'fs';
16
+ import { isAbsolute, join, relative, resolve } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { spawnScript, killProcessTree } from '#infra';
19
+ import { EC, errorResponse } from '#error-codes';
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';
23
+ import { readDeployedState, PHASE_ORDER, DEPLOYED_STATUS } from '#deployed-state';
24
+
25
+ // 调用 scraper 的超时预算。
26
+ //
27
+ // 插件层给 run-scan.js 的预算是 30 分钟(fancy-scan/index.js 的 spawnScript),
28
+ // 这里必须留出余量:**内层要早于外层触发**,否则外层会先掐死本进程,
29
+ // 而 scraper 是被 detached 出去、独立成组的 —— 外层的 process.kill(-pid)
30
+ // 够不到它;本进程一死,本层的定时器随之消失,scraper 就永远收不到
31
+ // SIGTERM,会带着 chromium 一路跑到自然结束。
32
+ //
33
+ // 实测(外层 8s / 内层 60s 的真实番茄链路):外层超时后 11.5 秒,
34
+ // scraper 进程仍在,headless chrome 从 7 涨到 13 —— 确认是孤儿。
35
+ // 本层 25 分钟 < 外层 30 分钟后,超时先由本层处理:
36
+ // 杀进程组 → scraper 收到 SIGTERM → 它自己关掉浏览器 → 干净退出。
37
+ const SCRAPER_TIMEOUT_MS = 25 * 60 * 1000;
38
+
39
+ // 字符串消息 → EC 码映射,用于把 main() 里 throw new Error(msg) 转成结构化错误
40
+ const MSG_TO_EC = {
41
+ '缺少必要参数: --project-root --platform --rank': EC.EC042_MISSING_REQUIRED,
42
+ '项目未初始化:.fancy-deployed 不存在': EC.EC124_PROJECT_NOT_INITIALIZED,
43
+ '不支持的平台': EC.EC029_INVALID_PLATFORM_SCAN,
44
+ };
45
+
46
+ // EC027_NO_OUTPUT_FILES 用前缀匹配(runScraper 返回的错误带平台名前缀)
47
+ function lookupEC(msg) {
48
+ if (MSG_TO_EC[msg]) return MSG_TO_EC[msg];
49
+ if (msg.includes('未找到输出文件')) return EC.EC027_NO_OUTPUT_FILES;
50
+ if (msg.startsWith('不支持的平台')) return EC.EC029_INVALID_PLATFORM_SCAN;
51
+ if (msg.includes('JSON')) return EC.EC123_JSON_PARSE_FAIL;
52
+ return null;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // 平台配置
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const VALID_PLATFORMS = ['qidian', 'fanqie', 'jinjiang', 'qimao'];
60
+
61
+ const PLATFORM_CN = {
62
+ qidian: '起点',
63
+ fanqie: '番茄',
64
+ jinjiang: '晋江',
65
+ qimao: '七猫',
66
+ };
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // 工具函数
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function nowIso() {
73
+ return new Date().toISOString();
74
+ }
75
+
76
+ function ensureDir(dir) {
77
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
78
+ }
79
+
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);
86
+ }
87
+
88
+ // phase 顺序(从早到晚),用于判定"只进不退"
89
+
90
+ // 把 .fancy-deployed 的 phase 推进到指定值;只能向前推进或保持
91
+ // 不会从 analyzed 退回到 scanned
92
+ function advancePhase(projectRoot, nextPhase) {
93
+ const deployedPath = join(projectRoot, '.fancy-deployed');
94
+ // 用共享体检读状态:missing / corrupt / no_phase / unknown_phase 四种情况
95
+ // 在这里被统一区分开,不再靠 JSON.parse 抛异常来表达"文件坏了"。
96
+ const state = readDeployedState(projectRoot);
97
+ if (state.status === DEPLOYED_STATUS.MISSING) {
98
+ return { updated: false, reason: 'project_not_initialized' };
99
+ }
100
+ if (state.status === DEPLOYED_STATUS.CORRUPT) {
101
+ return { updated: false, reason: 'corrupt', message: state.error };
102
+ }
103
+ if (state.status === DEPLOYED_STATUS.NO_PHASE) {
104
+ return { updated: false, reason: 'unknown_phase', currentPhase: null, requestedPhase: nextPhase };
105
+ }
106
+ if (state.status === DEPLOYED_STATUS.UNKNOWN_PHASE) {
107
+ return { updated: false, reason: 'unknown_phase', currentPhase: state.phase, requestedPhase: nextPhase };
108
+ }
109
+ const data = state.config;
110
+ const currentRank = PHASE_ORDER.indexOf(data.phase);
111
+ const nextRank = PHASE_ORDER.indexOf(nextPhase);
112
+ if (currentRank === -1 || nextRank === -1) {
113
+ return { updated: false, reason: 'unknown_phase', currentPhase: data.phase, requestedPhase: nextPhase };
114
+ }
115
+ if (currentRank >= nextRank) {
116
+ return { updated: false, reason: currentRank === nextRank ? 'already_at_phase' : 'already_advanced', currentPhase: data.phase, requestedPhase: nextPhase };
117
+ }
118
+ const previousPhase = data.phase;
119
+ data.phase = nextPhase;
120
+ try {
121
+ atomicWriteJSON(deployedPath, data, { mode: 0o600 });
122
+ } catch (e) {
123
+ return { updated: false, reason: 'write_failed', message: e.message };
124
+ }
125
+ return { updated: true, previousPhase, currentPhase: nextPhase };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // HTTP 采集 — 起点 mobile SSR
130
+ // ---------------------------------------------------------------------------
131
+
132
+ // 起点榜单(移动端 m.qidian.com)。
133
+ // 路径与页面中文名均取自 m.qidian.com 各榜单页的实测结果:
134
+ // readindex → 阅读榜 / newfans → 书友榜(不是"新粉榜")
135
+ // PC 端 www.qidian.com 同一路径有瑞数 WAF + 腾讯滑块验证码,纯 HTTP 拿不到数据,
136
+ // 故固定走移动端;移动端没有 /rank/recom/(404),推荐榜的移动端路径是 /rank/rec/。
137
+ const QIDIAN_RANK_TYPES = [
138
+ { id: 'hotsales', label: '畅销榜', path: '/rank/hotsales/' },
139
+ { id: 'yuepiao', label: '月票榜', path: '/rank/yuepiao/' },
140
+ { id: 'readindex', label: '阅读榜', path: '/rank/readindex/' },
141
+ { id: 'newfans', label: '书友榜', path: '/rank/newfans/' },
142
+ { id: 'rec', label: '推荐榜', path: '/rank/rec/' },
143
+ { id: 'update', label: '更新榜', path: '/rank/update/' },
144
+ { id: 'sign', label: '签约榜', path: '/rank/sign/' },
145
+ { id: 'newbook', label: '新书榜', path: '/rank/newbook/' },
146
+ { id: 'newauthor', label: '新人作者榜', path: '/rank/newauthor/' },
147
+ ];
148
+
149
+ /**
150
+ * 抓取单本作品的详情(移动端详情页 SSR)。
151
+ *
152
+ * 榜单页 records 里也有 desc,但它比详情页短:实测新书榜 20 本,
153
+ * 详情 desc 100% 更长(中位 +8 字符,最大 +56),其中 2 本榜单 desc
154
+ * 尾部带省略号。详情 desc 才是完整版,代价是每本多一次请求。
155
+ *
156
+ * 详情页还补上了 bookStatus:榜单 records 的键实测为
157
+ * _index/bAuth/bName/bid/cat/catId/cnt/desc/rankNum/subCat/subCatId,
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),所以总推荐只能取详情页。
189
+ */
190
+ async function fetchBookDetail(bookUrl, headers) {
191
+ const html = await fetch(bookUrl, { headers, signal: AbortSignal.timeout(15000) })
192
+ .then(r => r.text());
193
+ const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
194
+ if (!m) throw new Error('未找到 pageContext');
195
+ const pageData = JSON.parse(m[1])?.pageContext?.pageProps?.pageData;
196
+ const bookInfo = pageData?.bookInfo;
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);
207
+ return {
208
+ intro: normalizeBookIntro(bookInfo.desc || ''),
209
+ status: bookInfo.bookStatus || '',
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),
222
+ };
223
+ }
224
+
225
+ async function scrapeQidianRank(rankId, outDir) {
226
+ const MOBILE_BASE = 'https://m.qidian.com';
227
+ const RANK_TYPE = QIDIAN_RANK_TYPES.find(r => r.id === rankId);
228
+ if (!RANK_TYPE) {
229
+ return { ok: false, error: `未知榜单 ID: ${rankId}` };
230
+ }
231
+
232
+ const MOBILE_HEADERS = {
233
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
234
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
235
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
236
+ 'Accept-Encoding': 'identity',
237
+ };
238
+
239
+ const url = `${MOBILE_BASE}${RANK_TYPE.path}`;
240
+ let html = '';
241
+ try {
242
+ html = await fetch(url, { headers: MOBILE_HEADERS, signal: AbortSignal.timeout(15000) }).then(r => r.text());
243
+ } catch (e) {
244
+ return { ok: false, error: `${RANK_TYPE.label} 请求失败: ${e.message}` };
245
+ }
246
+
247
+ const m = html.match(/<script[^>]+id=["']vite-plugin-ssr_pageContext["'][^>]*>([\s\S]*?)<\/script>/i);
248
+ if (!m) {
249
+ return { ok: false, error: `${RANK_TYPE.label} 未找到 pageContext` };
250
+ }
251
+
252
+ let pageContext;
253
+ try {
254
+ pageContext = JSON.parse(m[1]);
255
+ } catch (e) {
256
+ return { ok: false, error: `${RANK_TYPE.label} JSON 解析失败` };
257
+ }
258
+
259
+ const records = pageContext?.pageContext?.pageProps?.pageData?.records || [];
260
+ if (!records.length) {
261
+ return { ok: false, error: `${RANK_TYPE.label} 无书籍数据` };
262
+ }
263
+
264
+ const books = records.map((r, i) => ({
265
+ rank: r.rankNum || i + 1,
266
+ title: r.bName || r.bookName || '',
267
+ url: r.bid ? `${MOBILE_BASE}/book/${r.bid}/` : '',
268
+ author: r.bAuth || r.author || '',
269
+ genre: [r.cat, r.subCat].filter(Boolean).join('·'),
270
+ status: r.status || '',
271
+ words: r.cnt || r.wordCount || '',
272
+ rankValue: r.rankCnt || '',
273
+ totalRecommend: r.totalRecommend || '',
274
+ signStatus: r.signStatus || '',
275
+ vipStatus: r.vipStatus || '',
276
+ }));
277
+
278
+ // 逐本访问详情页补简介。
279
+ // 串行而非并发:起点有瑞数 WAF(当前靠移动端规避),并发易触发风控。
280
+ // 单本失败不中断整个榜单 —— 失败原因记入 partialReasons,
281
+ // 由 run-scan.js 的 receipt 一路带到插件层显示在失败清单里。
282
+ const partialReasons = [];
283
+ for (const b of books) {
284
+ if (!b.url) continue;
285
+ try {
286
+ const detail = await fetchBookDetail(b.url, MOBILE_HEADERS);
287
+ b.intro = detail.intro;
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
+ }
297
+ } catch (e) {
298
+ partialReasons.push(`${b.title} 简介获取失败: ${e.message}`);
299
+ }
300
+ }
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
+
313
+ const today = localDateStamp();
314
+ const outFile = join(outDir, `${PLATFORM_CN.qidian}${RANK_TYPE.label}_${today}.md`);
315
+ const md = renderQidianMarkdown(RANK_TYPE.label, url, books);
316
+ atomicWriteFile(outFile, md);
317
+
318
+ return {
319
+ ok: true,
320
+ writtenFiles: [outFile],
321
+ failed: partialReasons.length,
322
+ partialReasons,
323
+ };
324
+ }
325
+
326
+ function renderQidianMarkdown(rankLabel, url, books) {
327
+ const now = nowIso();
328
+ const lines = [
329
+ `# 起点 · ${rankLabel}`,
330
+ '',
331
+ `- 来源:${url}`,
332
+ `- 抓取方式:mobile-ssr`,
333
+ `- 抓取时间:${now}`,
334
+ `- 条目数:${books.length}`,
335
+ '',
336
+ '---',
337
+ '',
338
+ ];
339
+
340
+ for (const raw of books) {
341
+ const b = sanitizeBookFields(raw);
342
+ lines.push(`## #${b.rank} ${b.title}`);
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
+ }
365
+ if (b.intro) {
366
+ // 格式与其它字段保持一致:首段紧跟标签,其余段落各自成段。
367
+ // 起点简介里的换行有两种语义(<br> → 行分隔、段落标签 → 段分隔),
368
+ // 所以按空行分段,不按单换行。
369
+ lines.push(...renderIntroLines(b.intro));
370
+ }
371
+ if (b.url) lines.push(`- [作品页](${b.url})`);
372
+ lines.push('', '---', '');
373
+ }
374
+
375
+ return lines.join('\n');
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // scraper 参数构造(platform → 调哪个 scraper)
380
+ // ---------------------------------------------------------------------------
381
+
382
+ function scraperArgs(platform, rank, scanMode) {
383
+ switch (platform) {
384
+ case 'fanqie': {
385
+ const [channel, type] = rank.split('_');
386
+ return {
387
+ script: 'fanqie-rank-scraper.js',
388
+ // --scan-mode 决定番茄用明文接口还是截图 OCR 取书名/作者/简介。
389
+ // 非法值在 normalizeScanMode 里已回落到默认,不会透传给 scraper。
390
+ extra: ['--channel', channel, '--type', type, '--top', '10',
391
+ '--scan-mode', normalizeScanMode(scanMode)],
392
+ };
393
+ }
394
+ case 'jinjiang':
395
+ return {
396
+ script: 'jjwxc-rank-scraper.js',
397
+ extra: ['--type', rank],
398
+ };
399
+ case 'qimao': {
400
+ const parts = rank.split('/');
401
+ const channel = parts[0];
402
+ const type = parts[1];
403
+ const period = parts[2] || 'day';
404
+ return {
405
+ script: 'qimao-rank-scraper.js',
406
+ extra: ['--channel', channel, '--type', type, '--period', period],
407
+ };
408
+ }
409
+ default:
410
+ return { script: null, extra: [] };
411
+ }
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // 调用 scraper(统一用 infra.js spawnScript)
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /**
419
+ * 把 spawnScript 的原始错误转成人话。
420
+ *
421
+ * "timeout after 1500000ms" 是内部实现细节,会原样出现在给用户看的
422
+ * 失败清单里;这里转成"采集超时(超过 25 分钟未完成)"。
423
+ */
424
+ function humanizeSpawnError(raw) {
425
+ const text = String(raw ?? '');
426
+ const t = /^timeout after (\d+)ms$/.exec(text);
427
+ if (!t) return text;
428
+ return `采集超时(超过 ${Math.round(Number(t[1]) / 60000)} 分钟未完成)`;
429
+ }
430
+
431
+ async function runScraper(platform, rank, outDir, scanMode) {
432
+ const args = scraperArgs(platform, rank, scanMode);
433
+ if (!args.script) {
434
+ return { ok: false, error: `平台 ${platform} 暂不支持采集` };
435
+ }
436
+
437
+ // scraper 脚本路径(相对 run-scan.js)
438
+ // 必须用 fileURLToPath:URL.pathname 会把空格/中文百分号编码
439
+ // (/tmp/普通 目录 → /tmp/%E6%99%AE%E9%80%9A%20%E7%9B%AE%E5%BD%95),
440
+ // 拼出来的路径不存在,采集必然失败。
441
+ const scraperPath = join(fileURLToPath(new URL('.', import.meta.url)), 'scrapers', args.script);
442
+ const fullArgs = ['--outdir', outDir, ...args.extra];
443
+
444
+ // 不传 NODE_PATH:tesseract.js 由 scraper 脚本按 node_modules 向上查找解析
445
+ // (scraper 位于 <repo>/plugins/fancy-scan/scripts/scrapers/ 下,
446
+ // 能找到 <repo>/node_modules/tesseract.js),实测未设 NODE_PATH 时 OCR 正常。
447
+ // 此前硬编码的 /home/cloud/.npm-global/lib/node_modules 下并没有 tesseract.js,
448
+ // 覆盖用户自带的 NODE_PATH 只会带来副作用。
449
+ const result = await spawnScript('node', [scraperPath, ...fullArgs], {
450
+ timeout: SCRAPER_TIMEOUT_MS,
451
+ onSpawn: (proc) => {
452
+ // 本进程被 SIGTERM(上游超时或用户中断)时,scraper 因为 detached
453
+ // 独立成组、不在本进程组里,必须显式转发 —— 否则它会带着 chromium
454
+ // 继续跑完整流程。转发后不立刻退出:让 scraper 自己收尾(关浏览器),
455
+ // 本进程随后会拿到失败 receipt 正常退出。
456
+ process.on('SIGTERM', () => {
457
+ killProcessTree(proc, 'SIGTERM');
458
+ // 兜底:scraper 若迟迟不退,10 秒后强制结束本进程。
459
+ setTimeout(() => process.exit(143), 10000).unref();
460
+ });
461
+ },
462
+ });
463
+
464
+ if (!result.ok) {
465
+ return {
466
+ ok: false,
467
+ error: humanizeSpawnError(result.error || `exit ${result.code}`),
468
+ stderr: result.stderr,
469
+ };
470
+ }
471
+
472
+ // 解析结构化 receipt(fanqie/jinjiang/qimao 都用此协议)
473
+ let receipt;
474
+ try {
475
+ receipt = JSON.parse(result.output);
476
+ } catch (_) {
477
+ // 优先用 stderr 作为错误信息(stdout 经常被进度日志污染)
478
+ const stderrTail = (result.stderr || '').trim().slice(-300);
479
+ const stdoutTail = (result.output || '').trim().slice(-200);
480
+ return {
481
+ ok: false,
482
+ error: stderrTail
483
+ ? `scraper 输出非 JSON;stderr: ${stderrTail}`
484
+ : `scraper 输出非 JSON;stdout: ${stdoutTail}`,
485
+ stderr: result.stderr,
486
+ };
487
+ }
488
+
489
+ if (receipt.ok === false) {
490
+ // receipt.error 可能为 undefined(scraper 没设置),fallback 到 stderr
491
+ const stderrTail = (result.stderr || '').trim().slice(-300);
492
+ return {
493
+ ok: false,
494
+ error: receipt.error || stderrTail || 'scraper 报告失败(无错误详情)',
495
+ stderr: result.stderr,
496
+ };
497
+ }
498
+
499
+ const writtenFiles = receipt.files || receipt.written || receipt.scan_files || [];
500
+ if (!Array.isArray(writtenFiles) || writtenFiles.length === 0) {
501
+ return {
502
+ ok: false,
503
+ error: `${PLATFORM_CN[platform]}(${rank})采集失败:未找到输出文件`,
504
+ stderr: result.stderr,
505
+ };
506
+ }
507
+
508
+ return {
509
+ ok: true,
510
+ writtenFiles,
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,
516
+ partial: receipt.partial || false,
517
+ partialReasons: receipt.partialReasons || [],
518
+ };
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // 主流程
523
+ // ---------------------------------------------------------------------------
524
+
525
+ async function main() {
526
+ const args = process.argv.slice(2);
527
+ let projectRoot = '', platform = '', rank = '';
528
+ // 扫描方式(取数路径)。仅番茄使用;其他平台忽略。
529
+ // 缺失或非法一律按默认(api)处理,绝不把未知值透传给 scraper。
530
+ let scanMode = DEFAULT_SCAN_MODE;
531
+
532
+ for (let i = 0; i < args.length; i++) {
533
+ if (args[i] === '--project-root') projectRoot = args[i + 1] || '';
534
+ if (args[i] === '--platform') platform = args[i + 1] || '';
535
+ if (args[i] === '--rank') rank = args[i + 1] || '';
536
+ if (args[i] === '--scan-mode') scanMode = args[i + 1] || '';
537
+ }
538
+
539
+ if (!projectRoot || !platform || !rank) {
540
+ throw new Error('缺少必要参数: --project-root --platform --rank');
541
+ }
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
+ }
550
+ if (!existsSync(join(projectRoot, '.fancy-deployed'))) {
551
+ throw new Error('项目未初始化:.fancy-deployed 不存在');
552
+ }
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
+
565
+ if (!VALID_PLATFORMS.includes(platform)) {
566
+ throw new Error(`不支持的平台: ${platform}`);
567
+ }
568
+
569
+ const today = localDateStamp();
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
+ }
583
+ ensureDir(scanDir);
584
+
585
+ let scanFiles = [];
586
+ let failed = 0;
587
+ const partialReasons = [];
588
+
589
+ // 调用 scraper
590
+ const r = platform === 'qidian'
591
+ ? await scrapeQidianRank(rank, scanDir)
592
+ : await runScraper(platform, rank, scanDir, scanMode);
593
+
594
+ if (!r.ok) {
595
+ throw new Error(r.error);
596
+ }
597
+
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
+ }
611
+ failed = r.failed || 0;
612
+ if (Array.isArray(r.partialReasons)) partialReasons.push(...r.partialReasons);
613
+
614
+ // 扫榜成功 → 把 .fancy-deployed 的 phase 推进到 'scanned'
615
+ // 部分失败(failed>0)也算成功推进,因为已有产出文件
616
+ // 注:fancy-topic 启动时会再检查 topic_decision_<date>.md 是否存在,
617
+ // 并要求 phase === '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');
628
+ console.error(` 📌 phase: ${phaseResult.updated ? `${phaseResult.previousPhase} → ${phaseResult.currentPhase}` : phaseResult.reason}`);
629
+ // "没推进"有两种含义,必须分开:
630
+ // already_at_phase / already_advanced —— 正常(重扫),不是问题
631
+ // corrupt / unknown_phase / write_failed —— phase 推不动,流程走不下去
632
+ // 后者以前只写进 stderr,receipt 里看不出来,插件层于是照报"采集完成",
633
+ // 用户接着跑 /fancy-topic 报"状态文件读不了",完全不知道问题出在这儿。
634
+ const phaseBlocked = !phaseResult.updated
635
+ && !['already_at_phase', 'already_advanced'].includes(phaseResult.reason);
636
+
637
+ // 输出结构化 receipt
638
+ const receipt = {
639
+ ok: scanFiles.length > 0,
640
+ operation: 'scan',
641
+ platform,
642
+ rank,
643
+ date: today,
644
+ scan_files: scanFiles,
645
+ topic_decision: join(scanDir, `topic_decision_${today}.md`),
646
+ written: scanFiles.length,
647
+ failed,
648
+ partial: failed > 0,
649
+ partialReasons,
650
+ phase_advance: phaseResult,
651
+ phase_blocked: phaseBlocked,
652
+ summary: `${PLATFORM_CN[platform]}(${rank})采集完成,共 ${scanFiles.length} 个文件`,
653
+ };
654
+
655
+ console.log(JSON.stringify(receipt));
656
+ }
657
+
658
+ main().catch(e => {
659
+ const msg = e.message || String(e);
660
+ // 尝试把已知消息映射到 EC 码
661
+ const ec = lookupEC(msg);
662
+ const errObj = ec
663
+ ? errorResponse(ec, { message: msg })
664
+ : { ok: false, error: { code: 1, code_name: 'EC001', message: msg } };
665
+ process.stdout.write(JSON.stringify(errObj) + '\n');
666
+ process.exit(ec ? ec.code : 1);
667
+ });