@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,708 @@
1
+ /**
2
+ * fancy-scan plugin entry
3
+ */
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join, resolve } from 'path';
6
+ import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
7
+ import { readDeployedState, deployedStateProblemText } from '#deployed-state';
8
+ import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
9
+ import { resolveWorkspace } from '#workspace';
10
+ import { localDateStamp, DEFAULT_SCAN_MODE } from '#scraper-utils';
11
+ import {
12
+ backupTopicDecision,
13
+ listMarkerBlocks,
14
+ repairTopicDecision,
15
+ inspectTopicDecision,
16
+ dedupeMarkerBlocks,
17
+ ensureTrailingNewline,
18
+ } from '#topic-decision';
19
+ import { readFileSync, existsSync, lstatSync, realpathSync, unlinkSync, mkdirSync } from 'fs';
20
+ import { scan, PLATFORM_TABLE, RANK_LISTS, parseScanMode } from './templates.js';
21
+ import { captureFileState, verifyBlockWritten } from './scripts/report-validate.js';
22
+ import { inspectExistingBlock, EXISTING_BLOCK_STATUS } from './existing-block.js';
23
+ import { waitForAnalysisTurn } from './wait-turn.js';
24
+ import { deployScanTools, writeScanManifest } from './tool-deploy.js';
25
+
26
+ export const inject = ['commands', 'userQuestions'];
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const SCAN_SCRIPTS_DIR = join(__dirname, 'scripts');
30
+
31
+ /**
32
+ * 解析用户选择的榜单
33
+ * @param {string[]} selected - 用户选择的 label 数组(如 '畅销榜' 或 '全选')
34
+ * @param {string} platform - 平台
35
+ * @returns {string[]} 榜单 ID 数组,如果选择"全选"则返回所有榜单
36
+ */
37
+ function parseRankLists(selected, platform) {
38
+ if (!Array.isArray(selected) || selected.length === 0) return [];
39
+
40
+ const allLists = (RANK_LISTS[platform] || []);
41
+
42
+ // 如果选择了"全选",返回所有榜单
43
+ if (selected.includes('全选')) {
44
+ return allLists.map(r => r.id);
45
+ }
46
+
47
+ // 否则通过 label 查找 rank id
48
+ const ids = [];
49
+ for (const label of selected) {
50
+ const rank = allLists.find(r => r.label === label);
51
+ if (rank) ids.push(rank.id);
52
+ }
53
+ return ids;
54
+ }
55
+
56
+ /**
57
+ * 获取榜单标签列表(用于通知显示)
58
+ */
59
+ function getRankLabels(rankIds, platform) {
60
+ if (!Array.isArray(rankIds) || rankIds.length === 0) return '';
61
+
62
+ const allLists = (RANK_LISTS[platform] || []);
63
+ if (rankIds.length === allLists.length) {
64
+ return '全选';
65
+ }
66
+ const labels = [];
67
+ for (const id of rankIds) {
68
+ const rank = allLists.find(r => r.id === id);
69
+ labels.push(rank ? rank.label : id);
70
+ }
71
+ return labels.join('、');
72
+ }
73
+
74
+ export async function apply(ctx) {
75
+ ctx.commands.register({
76
+ name: 'fancy-scan',
77
+ description: '📊 扫榜分析 ( usage: /fancy-scan [help] )',
78
+ input: {
79
+ hint: '.',
80
+ },
81
+ handler: async (invocation) => {
82
+ const session = invocation.agent.session;
83
+ const { signal } = invocation;
84
+
85
+ if ((invocation.rawInput || '').trim().toLowerCase() === 'help') {
86
+ return notify(session, 'success', scan.notify({ type: 'help' }));
87
+ }
88
+
89
+ // 获取工作区目录(忽略所有命令行参数,统一通过交互选择)
90
+ // 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户工作区目录。
91
+ // 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目执行部署与写盘。
92
+ const cwd = session.header?.cwd ?? null;
93
+ let projectRoot;
94
+ try {
95
+ projectRoot = resolveWorkspace({ cwd, required: true });
96
+ } catch {
97
+ projectRoot = cwd ? resolve(cwd) : null;
98
+ }
99
+ if (!projectRoot) {
100
+ return notify(session, 'error', scan.notify({
101
+ type: 'no_project_root',
102
+ err: '会话未提供当前工作目录'
103
+ }));
104
+ }
105
+ // 工作区目录来自会话 cwd;写盘前仍要过一遍路径守卫。
106
+ // 一个手工放置过 .fancy-deployed 的敏感目录(/etc 等)不能成为扫榜目标。
107
+ if (isSensitiveProjectPath(projectRoot) || !isValidProjectPath(projectRoot)) {
108
+ return notify(session, 'error', scan.notify({
109
+ type: 'no_project_root',
110
+ err: '工作区目录路径不合法或属于受保护目录'
111
+ }));
112
+ }
113
+ // 解开工作区目录的符号链接后再查一次:`/home/link -> /etc` 这种字符串
114
+ // 检查看不出来,但 run-scan 会跟着链接把 扫榜结果/ 写到 /etc。
115
+ try {
116
+ projectRoot = realpathSync(projectRoot);
117
+ } catch (e) {
118
+ return notify(session, 'error', scan.notify({
119
+ type: 'no_project_root',
120
+ err: `无法读取工作区目录:${e.message}`
121
+ }));
122
+ }
123
+ if (isSensitiveProjectPath(projectRoot) || !isValidProjectPath(projectRoot)) {
124
+ return notify(session, 'error', scan.notify({
125
+ type: 'no_project_root',
126
+ err: '工作区目录的真实路径属于受保护目录'
127
+ }));
128
+ }
129
+
130
+ // 扫榜结果/ 是插件自己的产物目录;如果它是指向工作区外的符号链接,
131
+ // 备份/修复/写入都会落到外面。run-scan 会拒绝,这里有旧项目时也要拦。
132
+ try {
133
+ const scanDirPath = join(projectRoot, '扫榜结果');
134
+ if (existsSync(scanDirPath)) {
135
+ const st = lstatSync(scanDirPath);
136
+ if (st.isSymbolicLink() || !st.isDirectory()) {
137
+ return notify(session, 'error', scan.notify({
138
+ type: 'no_project_root',
139
+ err: '扫榜结果/ 不是真实目录,拒绝写入;请换成真实目录'
140
+ }));
141
+ }
142
+ }
143
+ } catch { /* 目录不存在或检查失败交给后续流程 */ }
144
+
145
+ // 体检 .fancy-deployed —— 必须在**扫榜之前**查。
146
+ //
147
+ // 之前只检查文件存不存在,于是文件损坏时:
148
+ // 扫榜照跑、产物照出、命令照报成功,
149
+ // 但 phase 推不动(advancePhase 读不了那个文件,失败只写进 stderr),
150
+ // 下游 /fancy-topic 于是永远过不了 phase 检查。
151
+ // 用户只能反复重扫,死循环。
152
+ const deployedState = readDeployedState(projectRoot);
153
+ if (deployedState.status !== 'ok') {
154
+ const problem = deployedStateProblemText(deployedState, { projectRoot });
155
+ return notify(session, 'error', problem || scan.notify({ type: 'not_initialized' }));
156
+ }
157
+
158
+ // 忽略命令行参数,始终进入交互选择流程
159
+ let platform = '';
160
+
161
+ // 平台选择
162
+ if (!platform) {
163
+ if (signal?.aborted) {
164
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
165
+ }
166
+ const askedChoice = await askUserQuestion(ctx, invocation, [scan.askPlatform()]);
167
+ if (!askedChoice.ok) {
168
+ return notify(session, 'error', scan.notify({
169
+ type: askedChoice.failure.type,
170
+ err: askedChoice.failure.message
171
+ }));
172
+ }
173
+ const choice = askedChoice.answer;
174
+ if (!choice?.answers?.length || !choice.answers[0]) {
175
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
176
+ }
177
+ const selectedValue = choice.answers[0]?.selected?.[0];
178
+ if (!selectedValue) {
179
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
180
+ }
181
+ if (signal?.aborted) {
182
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
183
+ }
184
+ const row = PLATFORM_TABLE.find(r => r[2] === selectedValue);
185
+ if (!row) {
186
+ return notify(session, 'error', scan.notify({ type: 'invalid_choice' }));
187
+ }
188
+ platform = row[1];
189
+ }
190
+
191
+ // 榜单选择
192
+ let rankIds = [];
193
+ let rankListsQuestion = scan.askRankLists(platform);
194
+
195
+ if (signal?.aborted) {
196
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
197
+ }
198
+ const askedrankResult = await askUserQuestion(ctx, invocation, [rankListsQuestion]);
199
+ if (!askedrankResult.ok) {
200
+ return notify(session, 'error', scan.notify({
201
+ type: askedrankResult.failure.type,
202
+ err: askedrankResult.failure.message
203
+ }));
204
+ }
205
+ const rankResult = askedrankResult.answer;
206
+ if (!rankResult?.answers?.length || !rankResult.answers[0]) {
207
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
208
+ }
209
+ const selectedLabels = Array.isArray(rankResult.answers[0]?.selected) ? rankResult.answers[0].selected : [];
210
+ if (!selectedLabels.length) {
211
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
212
+ }
213
+ if (signal?.aborted) {
214
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
215
+ }
216
+
217
+ // 解析选择的榜单
218
+ rankIds = parseRankLists(selectedLabels, platform);
219
+ // 空榜单必须硬失败:此前会一路走到"采集完成"文案,但一个文件都没采到、
220
+ // 也不会投喂 LLM,用户看到的却是一切正常。
221
+ if (!rankIds.length) {
222
+ return notify(session, 'error', scan.notify({ type: 'no_rank_selected' }));
223
+ }
224
+ const rankLabels = getRankLabels(rankIds, platform);
225
+
226
+ // ── 采集前先查:今天这个平台 + 榜单是不是已经分析过 ──────────
227
+ // 平台、榜单、日期在采集前就确定了,块 ID 可以提前算出来。
228
+ // 如果目标文件里已经有本次块,再跑几十分钟采集没有任何意义;
229
+ // 直接发一条通知,不启动采集脚本、不改动任何文件。
230
+ const scanDateForCheck = localDateStamp();
231
+ const topicFileForCheck = join(
232
+ projectRoot,
233
+ '扫榜结果',
234
+ `topic_decision_${scanDateForCheck}.md`
235
+ );
236
+ const blockIdForCheck = scan.makeBlockId(platform, rankLabels, scanDateForCheck);
237
+ const existing = inspectExistingBlock(topicFileForCheck, blockIdForCheck);
238
+ if (existing.status === EXISTING_BLOCK_STATUS.FOUND) {
239
+ // 这不是失败:插件已经正确判断"今天这个块分析过了",
240
+ // 没有启动采集、也没有改文件。用 success,避免命令面板把它
241
+ // 渲染成红色错误(用户会以为扫榜坏了)。
242
+ return notify(session, 'success', scan.notify({
243
+ type: 'already_scanned',
244
+ platform: scan.platformLabel(platform),
245
+ rankLabels,
246
+ date: scanDateForCheck,
247
+ topicFile: topicFileForCheck,
248
+ projectRoot,
249
+ blockId: blockIdForCheck,
250
+ closed: existing.blocks.length > 0 ? existing.blocks[0].closed : true,
251
+ }));
252
+ }
253
+ if (existing.status === EXISTING_BLOCK_STATUS.UNREADABLE) {
254
+ return notify(session, 'error', scan.notify({
255
+ type: 'existing_block_unreadable',
256
+ topicFile: topicFileForCheck,
257
+ projectRoot,
258
+ err: existing.error || '目标文件无法读取',
259
+ }));
260
+ }
261
+
262
+ // 扫描方式选择(榜单选定之后)。
263
+ // 只有番茄有两条取数路径(明文接口 / 截图 OCR),其他平台 askScanMode
264
+ // 返回 null —— 此时不打扰用户,scanMode 留空表示"该平台无此选项"。
265
+ let scanMode = '';
266
+ const scanModeQuestion = scan.askScanMode(platform);
267
+ if (scanModeQuestion) {
268
+ if (signal?.aborted) {
269
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
270
+ }
271
+ const askedMode = await askUserQuestion(ctx, invocation, [scanModeQuestion]);
272
+ if (!askedMode.ok) {
273
+ return notify(session, 'error', scan.notify({
274
+ type: askedMode.failure.type,
275
+ err: askedMode.failure.message
276
+ }));
277
+ }
278
+ const modeAnswer = askedMode.answer;
279
+ if (!modeAnswer?.answers?.length || !modeAnswer.answers[0]) {
280
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
281
+ }
282
+ // DSH 的 selected 回传的是 label 字符串(options.value 会被静默忽略)
283
+ const selectedMode = modeAnswer.answers[0]?.selected?.[0];
284
+ if (!selectedMode) {
285
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
286
+ }
287
+ const parsedMode = parseScanMode(selectedMode);
288
+ if (!parsedMode) {
289
+ return notify(session, 'error', scan.notify({ type: 'invalid_scan_mode' }));
290
+ }
291
+ scanMode = parsedMode;
292
+ }
293
+
294
+ // ── 部署工作区内工具脚本 ──────────────────────────────────────
295
+ // 提示词里的预检/追加命令只能引用工作区内路径。脚本放在插件目录下时,
296
+ // 模型会去 node_modules 里执行:路径长、随安装位置变化、且那里是
297
+ // 包管理器管理的只读目录。部署失败就直接停 —— 没有脚本就没有
298
+ // 可信的校验与写入路径,继续采集只会产出一份没人能兜底的报告。
299
+ const deployed = deployScanTools(projectRoot);
300
+ if (!deployed.ok) {
301
+ return notify(session, 'error', scan.notify({
302
+ type: 'tool_deploy_failed',
303
+ err: deployed.problems.join(';'),
304
+ }));
305
+ }
306
+
307
+ // 依次采集每个榜单(单个榜单失败不中断,失败清单在末尾汇总)
308
+ const allFiles = [];
309
+ let phaseBlocked = null;
310
+ const failedRanks = [];
311
+ const succeededRankIds = [];
312
+ const receiptDates = new Set();
313
+ let lastDate = '';
314
+ for (const rankId of rankIds) {
315
+ // 用户按停止后不要再启动下一个榜单的采集(每个榜单可能跑几十分钟)。
316
+ if (signal?.aborted) {
317
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
318
+ }
319
+ const rankRow = RANK_LISTS[platform]?.find(r => r.id === rankId);
320
+ const rankLabel = rankRow ? rankRow.label : rankId;
321
+
322
+ const scriptResult = await spawnScript(
323
+ 'node',
324
+ [join(SCAN_SCRIPTS_DIR, 'run-scan.js'),
325
+ '--project-root', projectRoot, '--platform', platform, '--rank', rankId,
326
+ '--scan-mode', scanMode || DEFAULT_SCAN_MODE],
327
+ // 这一层是兜底预算,必须大于 run-scan.js 内部的 scraper 预算
328
+ // (25 分钟,见 run-scan.js 的 SCRAPER_TIMEOUT_MS)。
329
+ // 若两层等长,外层会先 δ(node 启动耗时)触发:外层杀的是
330
+ // run-scan.js 的进程组,而 scraper 是 detached 独立成组的,
331
+ // 外层够不到它 —— run-scan.js 一死,它内部的定时器也没了,
332
+ // scraper 就成了带 chromium 的孤儿,一路跑到自然结束。
333
+ { timeout: 1800000 }
334
+ );
335
+
336
+ if (!scriptResult.ok) {
337
+ const innerErr = (typeof scriptResult.error === 'object' && scriptResult.error !== null)
338
+ ? (scriptResult.error.message || JSON.stringify(scriptResult.error))
339
+ : String(scriptResult.error || '未知错误');
340
+ // 此前任一榜单失败就整体中断,已采集成功的榜单产物被丢弃,
341
+ // 用户既不知道采到了什么,也拿不到那些文件。
342
+ failedRanks.push({ label: rankLabel, error: innerErr });
343
+ continue;
344
+ }
345
+
346
+ // 解析 receipt
347
+ let receipt;
348
+ try { receipt = JSON.parse(scriptResult.output); } catch (_) {
349
+ failedRanks.push({ label: rankLabel, error: '采集结果解析失败(输出非 JSON)' });
350
+ continue;
351
+ }
352
+
353
+ lastDate = receipt.date || lastDate;
354
+ if (/^\d{8}$/.test(receipt.date || '')) receiptDates.add(receipt.date);
355
+ // receipt 来自子进程,不能假设字段一定是数组:字符串会被 spread 成字符。
356
+ const files = Array.isArray(receipt.scan_files) ? receipt.scan_files : [];
357
+ if (!files.length) {
358
+ failedRanks.push({ label: rankLabel, error: '采集回执里没有 scan_files 数组' });
359
+ continue;
360
+ }
361
+ allFiles.push(...files);
362
+ if (files.length > 0) succeededRankIds.push(rankId);
363
+ // scraper 内部的部分失败(番茄某个题材采失败)必须透出:
364
+ // run-scan.js 对这种情况仍报 ok:true(只要有文件),
365
+ // 若这里不读 partialReasons,失败信息会在插件层被整个吞掉,
366
+ // 用户只看到"采集完成",不知道哪个题材没采到。
367
+ if (Array.isArray(receipt.partialReasons) && receipt.partialReasons.length) {
368
+ failedRanks.push({
369
+ label: rankLabel,
370
+ error: '部分题材失败:' + receipt.partialReasons.join(';'),
371
+ });
372
+ }
373
+ // phase 推不动 = 整个流程卡住(/fancy-topic 要求 phase === 'scanned')。
374
+ // run-scan.js 以前只把失败写进 stderr,这里不读就等于没发生。
375
+ if (receipt.phase_blocked === true) {
376
+ const pa = receipt.phase_advance || {};
377
+ phaseBlocked = {
378
+ reason: pa.reason || '未知原因',
379
+ message: pa.message || '',
380
+ };
381
+ }
382
+ }
383
+
384
+ // 只把**真正采成功**的榜单写进提示词/块 ID。
385
+ // 之前把所有选中的 label 都传进去,某个榜失败时提示词会声称
386
+ // “它们都在本次数据文件里”,模型于是分析一个不存在的榜单。
387
+ const effectiveRankLabels = succeededRankIds.length
388
+ ? getRankLabels(succeededRankIds, platform)
389
+ : rankLabels;
390
+
391
+ // 一个榜单都没采成功 → 硬失败
392
+ if (allFiles.length === 0) {
393
+ const detail = failedRanks.length
394
+ ? failedRanks.map(f => f.label + ':' + f.error).join(';')
395
+ : '未产生任何采集文件';
396
+ return notify(session, 'error', scan.notify({ type: 'scraper_failed', err: detail }));
397
+ }
398
+
399
+ // 跨午夜采集:每个榜单的 run-scan 在各自启动时取日期,可能出现
400
+ // 畅销榜 20261029、其它榜 20261030 的混合。此时不能拿最后一个
401
+ // receipt 的日期当整份日报的日期 —— 会把两天数据写进 10/30。
402
+ if (receiptDates.size > 1) {
403
+ return notify(session, 'error', scan.notify({
404
+ type: 'scan_date_mixed',
405
+ dates: [...receiptDates].sort(),
406
+ files: allFiles,
407
+ projectRoot,
408
+ }));
409
+ }
410
+
411
+ if (signal?.aborted) {
412
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
413
+ }
414
+
415
+ // 日期口径必须与 run-scan.js 一致(本地时区)。此前 fallback 用
416
+ // toISOString()(UTC),凌晨 0-8 点会算出比采集时点早一天的文件名。
417
+ // receipt 里的日期必须再验一次格式;坏日期不能拼进路径/块 ID。
418
+ const scanDate = /^\d{8}$/.test(lastDate) ? lastDate : localDateStamp();
419
+ const topicFile = allFiles.length > 0
420
+ ? join(projectRoot, '扫榜结果', `topic_decision_${scanDate}.md`)
421
+ : '';
422
+
423
+ // 2026-10-27:投递提示词之前,先把 topic_decision 的状态收拾干净。
424
+ //
425
+ // 写这个文件的是 LLM,而 LLM 的 write 工具是**覆盖**语义 ——
426
+ // 实测(tt5 项目)第二个平台分析时,模型先 write("") 把文件清空,
427
+ // 再 cat >> 追加自己的内容,第一个平台的分析就此消失,全程无报错。
428
+ // 提示词已经改成"只许 cat >> 追加、不许用 write",但提示词只是建议,
429
+ // 真正的兜底是备份 + 校验恢复。
430
+ //
431
+ // 顺序不能乱:**先修复 → 再备份 → 最后才把已有内容告诉模型**。
432
+ // 修复 —— 上一轮可能被覆盖过,先把丢掉的段落补回来;
433
+ // 备份 —— 存的必须是补全后的内容,不然备份也是残缺的;
434
+ // 告知 —— 模型知道文件里已经有什么,才知道自己只该追加。
435
+ let repairedKeys = [];
436
+ try {
437
+ const repair = repairTopicDecision(topicFile);
438
+ if (repair.repaired.length) {
439
+ repairedKeys = repair.repaired;
440
+ console.error(`[fancy-scan] 上一次分析把 topic_decision 覆盖了,已从备份补回 ${repairedKeys.length} 个内容块:${repairedKeys.join('、')}`);
441
+ }
442
+ } catch (e) {
443
+ console.error('[fancy-scan] 校验 topic_decision 失败:' + (e && e.message ? e.message : String(e)));
444
+ }
445
+
446
+ const backup = backupTopicDecision(topicFile);
447
+ if (backup.backupPath) {
448
+ console.error(`[fancy-scan] 已备份 topic_decision → ${backup.backupPath}`);
449
+ }
450
+
451
+ // 文件末尾没有换行时,新追加的 start 标记会粘在旧行尾 ——
452
+ // grep 的 '^# …-start$' 就匹配不到了,模型会误判"没写进去"而重写,
453
+ // 于是同一个平台出现两个 start(写重了)。
454
+ // 这件事模型看不见,插件自己能看出来,所以在派发前补上。
455
+ try {
456
+ const nl = ensureTrailingNewline(topicFile);
457
+ if (nl.fixed) {
458
+ console.error('[fancy-scan] topic_decision 末尾缺换行,已补上(否则新块会粘在旧行尾)');
459
+ }
460
+ } catch (e) {
461
+ console.error('[fancy-scan] 补末尾换行失败:' + (e && e.message ? e.message : String(e)));
462
+ }
463
+
464
+ // 查文件里"上次写歪"的痕迹:同一个平台写了两遍、或把校验命令写进了正文。
465
+ //
466
+ // 模型自己看不见自己写进去的东西(写完就交差了),指望它"下次注意"没用。
467
+ // 插件查出来,写进提示词(让模型知道现状、别再重蹈覆辙),
468
+ // 同时通知用户。
469
+ //
470
+ // **重复的块直接收敛**(保留最后一个,与 topic 侧"后覆盖前"同向):
471
+ // 重复内容对 topic 是有害的(同一平台两份,用户看到的是混乱),
472
+ // 而收敛前一定先备份,删错了能从 .revisions/ 找回来。
473
+ // 命令痕迹只报告不删 —— 删正文行有误伤风险,交给用户自己看。
474
+ let dirt = null;
475
+ let fileWarnings = [];
476
+ let deduped = null;
477
+ try {
478
+ if (existsSync(topicFile)) {
479
+ let found = inspectTopicDecision(readFileSync(topicFile, 'utf-8'));
480
+ if (found.duplicateKeys.length) {
481
+ const dd = backupTopicDecision(topicFile);
482
+ // 备份失败就不删任何重复块:删了找不回来,宁可把重复留给用户处理。
483
+ if (!dd.backupPath) {
484
+ fileWarnings.push('检测到重复块,但备份失败;为避免丢数据,本次不自动收敛,请人工检查');
485
+ } else {
486
+ const res = dedupeMarkerBlocks(topicFile);
487
+ if (res.ok && res.removed.length) {
488
+ deduped = {
489
+ removed: res.removed,
490
+ backupPath: dd.backupPath,
491
+ kept: res.kept,
492
+ };
493
+ console.error(`[fancy-scan] topic_decision 收敛了 ${res.removed.length} 个重复块`);
494
+ // 收敛后重新查一遍,剩下的才是真正要提醒的
495
+ found = inspectTopicDecision(readFileSync(topicFile, 'utf-8'));
496
+ }
497
+ }
498
+ }
499
+ if (found.duplicateKeys.length || found.commandLines.length || found.unclosedBlocks.length) {
500
+ const lines = [];
501
+ for (const d of found.duplicateKeys) {
502
+ const msg = `「${d.key}」这个平台在文件里有 **${d.count} 个块**(上次追加写重了)`;
503
+ lines.push('- ' + msg);
504
+ fileWarnings.push(msg);
505
+ }
506
+ for (const c of found.commandLines) {
507
+ const msg = `「${c.key}」的块里混进了命令:\`${c.text}\``;
508
+ lines.push('- ' + msg);
509
+ fileWarnings.push(msg);
510
+ }
511
+ for (const u of found.unclosedBlocks) {
512
+ const msg = `「${u.key}」这个块**只有 start 标记、没有 end 标记**(上次没写完,内容被截断了)`;
513
+ lines.push('- ' + msg);
514
+ fileWarnings.push(msg);
515
+ }
516
+ dirt = { lines, deduped };
517
+ console.error(`[fancy-scan] topic_decision 有写歪痕迹:${lines.length} 条`);
518
+ }
519
+ }
520
+ } catch {
521
+ dirt = null;
522
+ }
523
+
524
+ // 把文件里已有的标记块告诉模型,让它知道"里面已经有什么",
525
+ // 自己只该追加、不该重写。
526
+ //
527
+ // **必须在收敛之后读**(2026-10-29 审计):之前这段在 dedupe 之前,
528
+ // 于是刚被删掉的重复块还留在列表里 ——
529
+ // 提示词告诉模型"文件里已经有这个块,一个字都不许动",
530
+ // 而那个块刚被插件删了。
531
+ let existingBlocks = [];
532
+ try {
533
+ if (existsSync(topicFile)) {
534
+ existingBlocks = listMarkerBlocks(readFileSync(topicFile, 'utf-8'));
535
+ }
536
+ } catch {
537
+ existingBlocks = [];
538
+ }
539
+ if (existingBlocks.length) {
540
+ console.error(`[fancy-scan] topic_decision 已有 ${existingBlocks.length} 个内容块,提示词已告知模型只追加`);
541
+ }
542
+
543
+ // phase 推不动就别再触发 LLM 分析了 —— 分析产物写进 topic_decision 后,
544
+ // /fancy-topic 依然会因为 phase 不对而跑不了,纯属浪费。
545
+ if (phaseBlocked) {
546
+ const detail = deployedStateProblemText(
547
+ readDeployedState(projectRoot),
548
+ { projectRoot }
549
+ ) || '';
550
+ return notify(session, 'error', scan.notify({
551
+ type: 'phase_advance_failed',
552
+ err: phaseBlocked.reason + (phaseBlocked.message ? `(${phaseBlocked.message})` : ''),
553
+ detail,
554
+ }));
555
+ }
556
+
557
+ // 触发 LLM 分析
558
+ if (allFiles.length > 0 && effectiveRankLabels) {
559
+ // 本次块的固定参数只算一次:块 ID、正文路径、指标与开关。
560
+ // 这些值同时进 manifest、提示词与回合结束后的复核 —— 三处必须完全一致。
561
+ const ds = scan.dataShape(platform);
562
+ const multiList = effectiveRankLabels === '全选'
563
+ || effectiveRankLabels.split('、').filter(Boolean).length > 1;
564
+ const metricLabel = scan.heatMetric(platform).label;
565
+ const blockId = scan.makeBlockId(platform, effectiveRankLabels, scanDate);
566
+ mkdirSync(join(projectRoot, '.tmp'), { recursive: true });
567
+ const bodyFile = scan.bodyFilePath(projectRoot, blockId, topicFile || projectRoot || '');
568
+ // 上一次运行可能留下同名的临时正文(同一块 ID)。先删掉:
569
+ // 模型如果跳过 5.1 直接跑 5.2,脚本会因为正文文件不存在而拒绝,
570
+ // 不会把上一轮的旧正文再追加一遍。
571
+ if (bodyFile.includes('fancy_scan_body_')) {
572
+ try { unlinkSync(bodyFile); } catch { /* 不存在 */ }
573
+ }
574
+ const manifest = writeScanManifest(projectRoot, {
575
+ topicFile,
576
+ blockId,
577
+ dataFiles: allFiles,
578
+ bodyFile,
579
+ metricLabel,
580
+ grouped: ds.grouped,
581
+ multiList,
582
+ rankLabels: (RANK_LISTS[platform] || []).map(r => r.label),
583
+ });
584
+ if (!manifest.ok) {
585
+ return notify(session, 'error', scan.notify({
586
+ type: 'manifest_failed',
587
+ err: manifest.problems.join(';'),
588
+ }));
589
+ }
590
+
591
+ // 写之前把文件状态记下来 —— 模型回合结束后要核对
592
+ // "旧内容有没有被动过"。模型看不见自己写进去的东西,
593
+ // 这一层只有插件能查。
594
+ let preWrite = null;
595
+ try {
596
+ preWrite = captureFileState(topicFile);
597
+ } catch {
598
+ preWrite = null;
599
+ }
600
+
601
+ const follow = await llmFollowup(invocation, scan.llmAnalysisPrompt({
602
+ platform,
603
+ rankLists: effectiveRankLabels ? effectiveRankLabels.split('、') : ['全选'],
604
+ date: scanDate,
605
+ files: allFiles,
606
+ topicFile,
607
+ projectRoot,
608
+ existingBlocks,
609
+ fileWarnings,
610
+ failedRanks,
611
+ appendScript: deployed.appendScript,
612
+ manifestPath: manifest.path,
613
+ }));
614
+ // llmFollowup 内部已捕获异常并返回 { ok, error },必须检查返回值,
615
+ // 否则投递失败也会报"采集完成,正在将数据交给 LLM 进行分析"
616
+ if (!follow || follow.ok !== true) {
617
+ return notify(session, 'error', scan.notify({
618
+ type: 'llm_delivery_failed',
619
+ err: (follow && follow.error) || '提示词投递失败'
620
+ }));
621
+ }
622
+
623
+ // llmFollowup 只代表“消息已入队”,模型是异步跑的。
624
+ // 必须等这一轮真正结束再复核;否则会在模型还没开始写的时候
625
+ // 立刻报“目标文件不存在”,而几秒后模型又正常写进去了。
626
+ let waited = await waitForAnalysisTurn(invocation, { signal });
627
+ if (waited && waited.code === 'ABORTED') {
628
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
629
+ }
630
+ // DSH 在 TRANSPORT 错误后会自动给 agent 发“继续”,模型往往在
631
+ // 下一轮才真正写完。第一轮 wait 已经返回错误时,不要立刻判定
632
+ // 失败;只对明确的传输错误再等一轮,给自动重试一个机会。
633
+ if (waited && waited.code === 'TURN_ERROR'
634
+ && (waited.providerCode === 'TRANSPORT'
635
+ || /TRANSPORT|Connection error/i.test(waited.reason || ''))) {
636
+ waited = await waitForAnalysisTurn(invocation, {
637
+ signal,
638
+ timeoutMs: 10 * 60 * 1000,
639
+ // 实测 DSH 的自动“继续”在本轮报错后约 2 分钟才启动;
640
+ // 默认 120 秒的 idle 宽限只差 3 秒就会误判超时。
641
+ idleGraceMs: 5 * 60 * 1000,
642
+ });
643
+ if (waited && waited.code === 'ABORTED') {
644
+ return notify(session, 'error', scan.notify({ type: 'cancelled' }));
645
+ }
646
+ }
647
+
648
+ // ── 复核:这一次到底写进去没有、写对没有 ──────────────────
649
+ // 提示词里唯一的写入路径是 append-block.js(它自己会校验),
650
+ // 但模型**可能绕过脚本**。所以插件自己再查一遍:
651
+ // 块在不在、闭合没闭合、正文过不过机械校验、旧内容有没有被动过。
652
+ // 查出来不对就告诉用户 —— 不能让"没写进去"被当成"写成功了"。
653
+ try {
654
+ const vres = verifyBlockWritten(
655
+ topicFile,
656
+ blockId,
657
+ {
658
+ metricLabel,
659
+ grouped: ds.grouped,
660
+ multiList,
661
+ dataFiles: allFiles,
662
+ knownLists: (RANK_LISTS[platform] || []).map(r => r.label),
663
+ },
664
+ preWrite
665
+ );
666
+ if (!vres.ok) {
667
+ console.error('[fancy-scan] 写入复核不通过:' + vres.problems.join(';'));
668
+ const waitNote = (waited && waited.ok !== true && waited.code !== 'TURN_ENDED')
669
+ ? `(等待 LLM 回合:${waited.reason || waited.code})`
670
+ : '';
671
+ return notify(session, 'error', scan.notify({
672
+ type: 'write_verify_failed',
673
+ err: `本次分析没有正确写入 ${topicFile}:${vres.problems.join(';')}${waitNote}`,
674
+ }));
675
+ }
676
+ } catch (e) {
677
+ // 复核本身出错(文件读不了 / 解析异常)不能静默放过:
678
+ // 否则用户会收到“成功”,但没人知道报告到底写没写对。
679
+ const msg = e && e.message ? e.message : String(e);
680
+ console.error('[fancy-scan] 写入复核出错:' + msg);
681
+ return notify(session, 'error', scan.notify({
682
+ type: 'write_verify_failed',
683
+ err: `写入复核本身失败:${msg}`,
684
+ }));
685
+ }
686
+ }
687
+
688
+ // 只有走到这里才是真的完成:采集成功 + LLM 回合结束 + 写入复核通过。
689
+ // 不能返回投递前的 handover 文案 —— 那会显示“正在将数据交给 LLM
690
+ // 进行分析”,而实际上分析已经做完了(真实日志 91f7ae4e 的第三条
691
+ // 命令就是这样:turn/end 之后仍显示“正在分析”)。
692
+ const doneText = scan.notify({
693
+ type: 'analysis_done',
694
+ platform: scan.platformLabel(platform),
695
+ rankLabels: effectiveRankLabels,
696
+ date: scanDate,
697
+ files: allFiles,
698
+ topicFile,
699
+ projectRoot,
700
+ failedRanks,
701
+ scanMode,
702
+ repaired: repairedKeys,
703
+ });
704
+ const dirtText = dirt ? '\n\n' + scan.notify({ type: 'dirty_file', dirt }) : '';
705
+ return notify(session, 'success', doneText + dirtText);
706
+ }
707
+ });
708
+ }