@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

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 (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
  191. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
  192. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
  193. 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
  194. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
  195. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
  196. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
  197. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
  198. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
  199. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
  200. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
  201. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
  202. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,860 @@
1
+ /**
2
+ * topic-decision.js —— topic_decision_<date>.md 的标记解析、备份与恢复
3
+ *
4
+ * ## 为什么需要这个模块
5
+ *
6
+ * topic_decision_<date>.md 是**多个平台共用的一个文件**:番茄扫一次追加一段,
7
+ * 起点扫一次再追加一段。写这个文件的是 LLM(插件把提示词投递给 LLM,
8
+ * LLM 自己落盘),插件侧并没有代码在写它。
9
+ *
10
+ * 这么设计有个硬伤:**"追加"这个语义被委托给了 LLM,而 LLM 的写文件工具
11
+ * (write)是覆盖语义**。实测(2026-10-27,tt5 项目):
12
+ *
13
+ * turn 4(起点):write(topic_decision, "") → cat >> 追加起点分析 ✅
14
+ * turn 5(番茄):write(topic_decision, "") → cat >> 追加番茄分析 ❌
15
+ *
16
+ * 第二轮的 write("") 把第一轮的起点分析整个清空了 —— 最终文件里只剩番茄,
17
+ * 起点的分析**无声无息地丢了**,全程没有任何报错。成因是提示词里那句
18
+ * "新建一个空文件":模型为了"新建"就去调 write 工具,而 write 空内容
19
+ * 等于清空文件。
20
+ *
21
+ * 光靠提示词约束模型守规矩是不够的 —— 同一个文件被覆盖两次,内容就没了。
22
+ * 所以这里做三件事:
23
+ * 1. parseMarkerBlocks —— 解析文件里的 start/end 标记块;
24
+ * 2. backupTopicDecision —— 写入前备份(覆盖后还能救回来);
25
+ * 3. repairTopicDecision —— 消费前校验,把被覆盖掉的块自动补回。
26
+ *
27
+ * ## 为什么标记解析单独放这里
28
+ *
29
+ * fancy-topic 自己有一套切块逻辑(splitDecisionSections),那套**刻意容忍**
30
+ * LLM 漏写 -end 标记(配对切块会一路吞到下一个 -end,把别的平台的分析
31
+ * 一起带走)。但校验"某个平台的段落还在不在"需要**严格**认标记对,
32
+ * 两套语义不一样,所以这里另起一套,不去动 fancy-topic 那套。
33
+ */
34
+
35
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync } from 'fs';
36
+ import { join, dirname, basename } from 'path';
37
+ import { randomBytes } from 'crypto';
38
+ import { atomicWriteFile as atomicWriteFileShared } from '#atomic-write';
39
+ import { missingDirectionLabels } from '#direction-labels';
40
+
41
+ const REVISIONS_DIR = '.revisions';
42
+
43
+ /**
44
+ * 原子写文件:先写同目录的临时文件,再 rename 覆盖。
45
+ *
46
+ * 直接 writeFileSync 覆盖写,进程写到一半被杀(OOM / 磁盘满 / 用户 Ctrl-C)
47
+ * 会把文件截断 —— 对 topic_decision 来说就是**整份分析报告没了**,
48
+ * 而且这种损坏往往发生在用户最不想丢数据的时候。
49
+ *
50
+ * rename 在同一文件系统内是原子的:要么还是旧内容,要么已经是新内容,
51
+ * 不会出现"半个文件"。
52
+ *
53
+ * @param {string} file
54
+ * @param {string} content
55
+ */
56
+ function atomicWriteFile(file, content) {
57
+ // 具体实现(随机临时名 + wx + rename + fsync)在 lib/atomic-write.js;
58
+ // 这里保留同名包装,避免外部调用点和测试切片漂移。
59
+ atomicWriteFileShared(file, content);
60
+ }
61
+
62
+ /** 标题行(markdown 标题,1~6 级) */
63
+ const TITLE_RE = /^[ \t]*#{1,6}[ \t]*(.+?)[ \t]*$/;
64
+ /**
65
+ * -start / -end 后缀(允许全角/半角连字符与空格)。
66
+ *
67
+ * **分隔符是必需的**(2026-10-29 审计):
68
+ * 原来的 `[-—–\s]*start\s*$` 里 `[-—–\s]*` 能匹配**零个字符**,
69
+ * 于是任何以 "start" 结尾的标题都会被当成标记行 ——
70
+ * `### Restart`、`## Trend` 这类正常标题会凭空变成一个 key 为 "Re" / "T"
71
+ * 的块,把文件结构搅乱。
72
+ *
73
+ * 现在要求 start/end 前面是行首或一个分隔符(连字符 / 破折号 / 空白)。
74
+ */
75
+ const START_SUFFIX_RE = /(?:^|[-—–\s])start\s*$/i;
76
+ const END_SUFFIX_RE = /(?:^|[-—–\s])end\s*$/i;
77
+
78
+ /**
79
+ * 解析 topic_decision 文件里的标记块。
80
+ *
81
+ * 一个"块"从 `-start` 标题行开始,到 `-end` 标题行结束。
82
+ * 缺 -end 的块也会返回(closed: false)—— 那是 LLM 漏标记,
83
+ * 但内容还在,不能当成丢了。
84
+ *
85
+ * @param {string} content 文件内容
86
+ * @returns {Array<{key: string, startTitle: string, endTitle: string|null,
87
+ * closed: boolean, text: string}>}
88
+ */
89
+ export function parseMarkerBlocks(content) {
90
+ const lines = String(content || '').split('\n');
91
+ const blocks = [];
92
+ let cur = null;
93
+
94
+ for (const line of lines) {
95
+ const m = line.match(TITLE_RE);
96
+ if (!m) {
97
+ if (cur) cur.lines.push(line);
98
+ continue;
99
+ }
100
+ const title = m[1].trim();
101
+
102
+ if (START_SUFFIX_RE.test(title)) {
103
+ // 上一个块没闭合就又来一个 start —— 先收尾,别把两段混成一块
104
+ if (cur) blocks.push(cur);
105
+ cur = {
106
+ key: title.replace(START_SUFFIX_RE, '').trim(),
107
+ startTitle: title,
108
+ endTitle: null,
109
+ closed: false,
110
+ lines: [line],
111
+ };
112
+ continue;
113
+ }
114
+
115
+ if (END_SUFFIX_RE.test(title)) {
116
+ const endKey = title.replace(END_SUFFIX_RE, '').trim();
117
+ // **end 的 key 必须和 start 的 key 一样**(2026-10-29 审计)。
118
+ // 之前不校验,于是 `# 番茄-…-end` 能把 `# 起点-…-start` 开的块闭合掉 ——
119
+ // 起点那块变成"已闭合",番茄那块凭空出现,文件结构全错。
120
+ // key 对不上时,这一行按**正文**处理(当前块不闭合)。
121
+ if (cur && endKey === cur.key) {
122
+ cur.endTitle = title;
123
+ cur.closed = true;
124
+ cur.lines.push(line);
125
+ blocks.push(cur);
126
+ cur = null;
127
+ } else if (cur) {
128
+ cur.lines.push(line);
129
+ }
130
+ // 没有对应 start 的 end 标记:忽略(不构成一个块)
131
+ continue;
132
+ }
133
+
134
+ if (cur) cur.lines.push(line);
135
+ }
136
+ if (cur) blocks.push(cur);
137
+
138
+ return blocks.map(b => ({
139
+ key: b.key,
140
+ startTitle: b.startTitle,
141
+ endTitle: b.endTitle,
142
+ closed: b.closed,
143
+ text: b.lines.join('\n'),
144
+ }));
145
+ }
146
+
147
+ /**
148
+ * 文件里已有的标记块清单(去重,按出现顺序)。
149
+ *
150
+ * @param {string} content
151
+ * @returns {Array<{key: string, startTitle: string, endTitle: string|null, closed: boolean}>}
152
+ */
153
+ export function listMarkerBlocks(content) {
154
+ const seen = new Set();
155
+ const out = [];
156
+ for (const b of parseMarkerBlocks(content)) {
157
+ if (seen.has(b.key)) continue;
158
+ seen.add(b.key);
159
+ out.push({
160
+ key: b.key,
161
+ startTitle: b.startTitle,
162
+ endTitle: b.endTitle,
163
+ closed: b.closed,
164
+ });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * 把 topic_decision 里**同一个 key 的重复块**收敛成一个:保留最后一个,删掉前面的。
171
+ *
172
+ * ## 为什么留最后一个
173
+ *
174
+ * 与 topic 侧的去冗余规则同向(`selectNonRedundantBlocks`:**后出现的覆盖先出现的**)——
175
+ * 插件在别处已经按这个规则取内容了,文件这边收敛成同一个结果,
176
+ * 不会出现"文件里留的是 A、插件读的是 B"的分裂。
177
+ *
178
+ * ## 什么时候会重复
179
+ *
180
+ * 实测(2026-10-28 tt5):heredoc 缺结束符 `FANCY_SCAN_EOF`,
181
+ * shell 把后面的校验命令也当成正文;模型发现后用 edit 清理,**又重写了一遍** ——
182
+ * 文件里同一个平台于是有了两份报告。
183
+ *
184
+ * 模型看不见自己写进去的东西,所以只能插件来收敛。
185
+ *
186
+ * ## 只动闭合的块
187
+ *
188
+ * 没闭合的块(只有 start 没有 end)本身就是坏的,自动删它有可能把
189
+ * 别的内容一起带走 —— 那部分只报告,不动。
190
+ *
191
+ * @param {string} topicFile
192
+ * @returns {{ok: boolean, removed: Array<{key: string, lines: string}>,
193
+ * kept: number, reason: string|null}}
194
+ */
195
+ export function dedupeMarkerBlocks(topicFile) {
196
+ const empty = { ok: true, removed: [], kept: 0, reason: null };
197
+ if (!topicFile || !existsSync(topicFile)) return { ...empty, reason: '文件不存在' };
198
+
199
+ let content;
200
+ try {
201
+ content = readFileSync(topicFile, 'utf-8');
202
+ } catch (e) {
203
+ return { ...empty, reason: '读文件失败:' + (e && e.message ? e.message : String(e)) };
204
+ }
205
+
206
+ const lines = content.split('\n');
207
+ const blocks = [];
208
+ let cur = null;
209
+ lines.forEach((line, i) => {
210
+ const m = line.match(TITLE_RE);
211
+ if (!m) return;
212
+ const title = m[1].trim();
213
+ if (START_SUFFIX_RE.test(title)) {
214
+ if (cur) blocks.push(cur);
215
+ cur = {
216
+ key: title.replace(START_SUFFIX_RE, '').trim(),
217
+ start: i, end: null, closed: false,
218
+ };
219
+ return;
220
+ }
221
+ if (END_SUFFIX_RE.test(title)) {
222
+ // end 的 key 必须和 start 一样,否则不闭合(见 parseMarkerBlocks)
223
+ const endKey = title.replace(END_SUFFIX_RE, '').trim();
224
+ if (cur && endKey === cur.key) {
225
+ cur.end = i;
226
+ cur.closed = true;
227
+ blocks.push(cur);
228
+ cur = null;
229
+ }
230
+ }
231
+ });
232
+ if (cur) blocks.push(cur);
233
+ // 未闭合的块:算出它实际到哪一行(到下一个块之前 / 文件末尾)
234
+ for (let i = 0; i < blocks.length; i++) {
235
+ if (blocks[i].closed) continue;
236
+ blocks[i].end = blocks[i + 1] ? blocks[i + 1].start - 1 : lines.length - 1;
237
+ }
238
+
239
+ const byKey = new Map();
240
+ for (const b of blocks) {
241
+ if (!byKey.has(b.key)) byKey.set(b.key, []);
242
+ byKey.get(b.key).push(b);
243
+ }
244
+
245
+ const drop = new Set();
246
+ const removed = [];
247
+ for (const [key, list] of byKey) {
248
+ if (list.length < 2) continue;
249
+ // 保留策略(2026-10-29 审计):**优先保留闭合的块**。
250
+ // 之前固定取最后一个 —— 如果最后一个只有 start 没有 end,
251
+ // 前面的**完整闭合块反而被删掉**,文件里留下的是截断内容。
252
+ const closed = list.filter(b => b.closed);
253
+ // 一个都没闭合 → 不动(可能是有意留的半成品,宁可报告也不删)
254
+ if (!closed.length) continue;
255
+ const keep = closed[closed.length - 1];
256
+ for (const b of list) {
257
+ if (b === keep) continue;
258
+ for (let i = b.start; i <= b.end; i++) drop.add(i);
259
+ removed.push({ key, lines: `${b.start + 1}-${b.end + 1}` });
260
+ }
261
+ }
262
+ if (!removed.length) return { ...empty, kept: byKey.size };
263
+
264
+ const kept = lines.filter((_, i) => !drop.has(i)).join('\n');
265
+ try {
266
+ atomicWriteFile(topicFile, kept);
267
+ } catch (e) {
268
+ return { ...empty, reason: '写回失败:' + (e && e.message ? e.message : String(e)) };
269
+ }
270
+ return { ok: true, removed, kept: byKey.size, reason: null };
271
+ }
272
+
273
+ /**
274
+ * 查 topic_decision 里的"脏"痕迹。
275
+ *
276
+ * 这三种都是**模型自己写文件写歪了**的症状,插件必须能自己发现 ——
277
+ * 模型看不见自己写进去的东西,它只会"照着提示词再来一次"。
278
+ *
279
+ * 1. **同一个 key 出现多个块** —— 追加写重了。
280
+ * 实测(2026-10-28 tt5):heredoc 少了结束符 `FANCY_SCAN_EOF`,
281
+ * shell 把后面的校验命令也当成正文;模型发现后用 edit 清理,
282
+ * 又重写了一遍 —— 文件里于是有了两份同一个平台的报告。
283
+ * 2. **块里混进了命令** —— 模型把校验命令(`grep -c` / `cat >>`)
284
+ * 当成正文写了进去。
285
+ * 3. **块没闭合** —— 只有 `-start` 没有 `-end`。
286
+ * 实测(2026-10-28 tt5):正文里出现了单独一行 `FANCY_SCAN_EOF`,
287
+ * heredoc 被提前终止,shell 只把 start 行和第一段正文写了进去,
288
+ * `-end` 标记**根本没写进文件**(`start=1 / end=0`)。
289
+ * 这种块的内容是**被截断的**,而且它会让下一次同平台的计数 N ≥ 1,
290
+ * 把重扫挡在门外 —— 用户不处理就再也扫不了这个平台了。
291
+ *
292
+ * ## 为什么第 3 种只报告、不自动补 end 标记
293
+ *
294
+ * 自动补一行 `-end` 看起来无害,实际上会把**被截断的内容伪装成完整的** ——
295
+ * 用户以为那段分析写完了,其实正文只写了一半。
296
+ * 截断这件事必须让用户看见,所以只报告。
297
+ *
298
+ * @param {string} content 文件内容
299
+ * @returns {{duplicateKeys: Array<{key: string, count: number}>,
300
+ * commandLines: Array<{key: string, line: number, text: string}>,
301
+ * unclosedBlocks: Array<{key: string, startTitle: string}>}}
302
+ */
303
+ export function inspectTopicDecision(content) {
304
+ const blocks = parseMarkerBlocks(content);
305
+ const countByKey = new Map();
306
+ for (const b of blocks) {
307
+ countByKey.set(b.key, (countByKey.get(b.key) || 0) + 1);
308
+ }
309
+ const duplicateKeys = [...countByKey.entries()]
310
+ .filter(([, n]) => n > 1)
311
+ .map(([key, count]) => ({ key, count }));
312
+
313
+ // 没闭合的块:只有 start 标记、没有 end 标记。
314
+ // 提示词里的 5.3 校验(start=1 但 end≠1)管的是"本次写入",
315
+ // 这里管的是**文件里已经存在的**坏块 —— 模型自己看不见,只能插件查。
316
+ const unclosedBlocks = blocks
317
+ .filter(b => !b.closed)
318
+ .map(b => ({ key: b.key, startTitle: b.startTitle }));
319
+
320
+ // 命令痕迹:只认足够特异的命令形式,别把正文里的普通词误伤
321
+ const CMD_RE = [
322
+ /^\s*grep\s+-c\s+\S/, // grep -c '...' "..."
323
+ /^\s*cat\s+>>?\s+\S/, // cat >> "..." / cat > "..."
324
+ /^\s*FANCY_SCAN_(?:BODY_)?EOF\s*$/, // heredoc 结束符漏到正文里
325
+ /^\s*node\s+references\/\S/, // node references/xxx.js
326
+ /^\s*set\s+-e\s*$/, // set -e
327
+ /^\s*FILE\s*=\s*\S/, // FILE="..."
328
+ /^\s*```(?:bash|sh)?\s*$/, // 代码块围栏漏进正文
329
+ ];
330
+ // 扫**全文**,不只扫块内 ——
331
+ // 实测(2026-10-28 tt5):heredoc 缺结束符时,shell 把后面的校验命令
332
+ // 当成正文,而那些命令落在 end 标记**之后**,根本不在任何块里。
333
+ const commandLines = [];
334
+ const lines = String(content || '').split('\n');
335
+ const blockOf = new Map();
336
+ blocks.forEach((b) => {
337
+ for (const l of String(b.text || '').split('\n')) blockOf.set(l, b.key);
338
+ });
339
+ let currentKey = null;
340
+ lines.forEach((line, li) => {
341
+ const m = line.match(TITLE_RE);
342
+ if (m) {
343
+ const title = m[1].trim();
344
+ if (START_SUFFIX_RE.test(title)) currentKey = title.replace(START_SUFFIX_RE, '').trim();
345
+ else if (END_SUFFIX_RE.test(title)) currentKey = null;
346
+ return; // 标记行本身不算命令痕迹
347
+ }
348
+ if (CMD_RE.some((re) => re.test(line))) {
349
+ commandLines.push({ key: currentKey, line: li + 1, text: line.trim() });
350
+ }
351
+ });
352
+
353
+ return { duplicateKeys, commandLines, unclosedBlocks };
354
+ }
355
+
356
+ /**
357
+ * 检查一个内容块的正文**是不是真的写进去了**。
358
+ *
359
+ * ## 为什么需要这个
360
+ *
361
+ * start/end 标记只能证明"块在文件里",不能证明"块里有分析"。
362
+ * 实测(2026-10-28):模型把 5.2 的 heredoc 命令原样跑了一遍 ——
363
+ * `<本次正文>` 这个占位符**原封不动地进了文件**,而 start/end 两个标记
364
+ * 都写对了。于是:
365
+ *
366
+ * - scan 侧的校验读到 start = 1 且 end = 1 → 判定"写入成功";
367
+ * - topic 侧也不检查正文 → 这段占位文字被原样发给 LLM 做选题;
368
+ * - 选题结论建立在"字都没写"的块上,最后一路流进整个小说项目。
369
+ *
370
+ * 模型**看不见自己写进去的东西**,它只会说"我写好了"。
371
+ * 所以这个判定必须由插件来做 —— 而且插件完全算得出来。
372
+ *
373
+ * ## 判据分两档
374
+ *
375
+ * **确凿的失败(blocks 判定,会拦住派发)**:
376
+ * 1. 正文里还留着 `<本次正文>` 占位符 —— 内容没替换进去;
377
+ * 2. 正文去掉标记行后是空的 —— 只写了标记没写内容;
378
+ * 3. 正文只剩命令痕迹(grep / cat >> / 围栏)—— 写进去的是命令不是分析。
379
+ *
380
+ * **可疑但不确凿(warnings 判定,只提示不拦)**:
381
+ * · 正文偏短(< 200 字);
382
+ * · 没有"一、二、三…"这样的结构标题。
383
+ *
384
+ * 后两条**不拦**:报告本来就可能短、结构标题写法也可能因平台而异。
385
+ * 把"我觉得这不合格"当成"这是坏的",会误伤正常内容 —— 所以只提示。
386
+ *
387
+ * @param {string} body 块的正文(不含 start/end 标记行)
388
+ * @returns {{ok: boolean, reasons: string[], warnings: string[]}}
389
+ */
390
+ export function inspectBlockBody(body) {
391
+ const text = String(body || '');
392
+ const reasons = [];
393
+ const warnings = [];
394
+
395
+ if (/<本次正文>/.test(text)) {
396
+ reasons.push('正文里还留着 `<本次正文>` 占位符 —— 内容没替换进去');
397
+ }
398
+
399
+ // 去掉标记行、代码围栏与空白后,看还剩多少实质内容
400
+ const stripped = text
401
+ .split('\n')
402
+ .filter(l => !/^\s*#\s*[^\n]*-(start|end)\s*$/i.test(l))
403
+ .filter(l => !/^\s*(```|~~~)/.test(l))
404
+ .join('')
405
+ .replace(/\s+/g, '');
406
+
407
+ if (!stripped) {
408
+ reasons.push('正文是空的 —— 只写了标记行,没有内容');
409
+ } else {
410
+ // 只剩命令痕迹 = 写进去的是命令,不是分析
411
+ const cmdOnly = text
412
+ .split('\n')
413
+ .filter(l => l.trim())
414
+ .filter(l => !/^\s*#\s*[^\n]*-(start|end)\s*$/i.test(l))
415
+ .every(l => /^\s*(grep\s+-c|cat\s+>>?|FANCY_SCAN_(?:BODY_)?EOF|```|node\s+references\/)/.test(l));
416
+ if (cmdOnly) {
417
+ reasons.push('正文里只剩命令痕迹(grep / cat >> 之类)—— 写进去的是命令,不是分析');
418
+ }
419
+ }
420
+
421
+ // 方向标题格式是**固定**的(`### 方向 N:名称`),所以数不出来就是确凿的问题,
422
+ // 不像"结构标题"那样还能只是格式差异。
423
+ const dirCount = (text.match(/^###\s*方向\s*[0-9]+\s*[::]/gm) || []).length;
424
+ if (stripped && dirCount > 0 && dirCount < 3) {
425
+ reasons.push(`正文里只有 ${dirCount} 个方向标题(要求至少 3 个)—— 报告没写全`);
426
+ }
427
+
428
+ if (stripped.length < 200) {
429
+ warnings.push(`正文只有 ${stripped.length} 个字(不到 200 字),内容可能偏薄`);
430
+ }
431
+
432
+ // 11 项设计标签是**固定**的(见 #direction-labels)——
433
+ // 缺标签的方向是"空壳方向",下游 story-builder 拿到的是残缺输入。
434
+ // 提示词里已经要求写全,插件这边再查一遍是**兜底**(提示词是请求,检查才是保证)。
435
+ // 只告警、不判块失败:格式差异不该把一整块报告判死。
436
+ if (dirCount > 0) {
437
+ const missing = missingDirectionLabels(text);
438
+ if (missing.length) {
439
+ warnings.push(
440
+ `有 ${missing.length} 个方向缺 11 项设计标签(${missing.join(';')})`
441
+ + ' —— 标签要写成 `- **定位**:…` 这种形式,11 项都要齐'
442
+ );
443
+ }
444
+ }
445
+
446
+ if (stripped && !/^#{1,6}\s*[一二三四五六七]、/m.test(text)) {
447
+ warnings.push('正文里没有"一、二、三…"这样的结构标题,报告可能不完整');
448
+ }
449
+
450
+ return { ok: reasons.length === 0, reasons, warnings };
451
+ }
452
+
453
+ /**
454
+ * 检查文件里**每个块**的正文是否有效。
455
+ *
456
+ * @param {string} content 文件内容
457
+ * @returns {{key: string, startTitle: string, reasons: string[], warnings: string[]}[]}
458
+ * 只返回有问题的块
459
+ */
460
+ export function inspectBlockBodies(content) {
461
+ const out = [];
462
+ for (const b of parseMarkerBlocks(content)) {
463
+ // 去掉 start/end 标记行,只留正文
464
+ const body = b.text
465
+ .split('\n')
466
+ .filter(l => !/^\s*#\s*[^\n]*-(start|end)\s*$/i.test(l))
467
+ .join('\n');
468
+ const res = inspectBlockBody(body);
469
+ if (!res.ok || res.warnings.length) {
470
+ out.push({
471
+ key: b.key,
472
+ startTitle: b.startTitle,
473
+ reasons: res.reasons,
474
+ warnings: res.warnings,
475
+ });
476
+ }
477
+ }
478
+ return out;
479
+ }
480
+
481
+ /**
482
+ * 确保 topic_decision 文件以换行结尾。
483
+ *
484
+ * ## 为什么需要这个
485
+ *
486
+ * `cat >> file` 是**裸追加** —— 它不会在前面补换行。
487
+ * 所以文件末尾如果没有换行符,新追加的内容会**粘在旧行尾**:
488
+ *
489
+ * 文件末尾: …正文最后一行 (没有换行)
490
+ * 追加内容: # 番茄-男频阅读榜-20261028-start\n…
491
+ * 结果: …正文最后一行# 番茄-男频阅读榜-20261028-start\n…
492
+ *
493
+ * 后果不是"看着别扭",而是**功能性的坏**:
494
+ * `grep -c '^# 番茄-男频阅读榜-20261028-start$'` 匹配不到(标记行不在行首),
495
+ * 于是 5.3 的校验读到 start = 0 → 模型以为"标记没写进去" → **再执行一次写入**
496
+ * → 同一个平台两个 start 标记 → 写重了。
497
+ *
498
+ * 也就是说,"文件末尾缺一个换行"会**引爆「写重了」事故链**。
499
+ * 这件事模型没法自己知道(它看不见文件末尾有没有换行),
500
+ * 而且插件能自己算出来 —— 所以由插件在派发前补上。
501
+ *
502
+ * 只在**确实缺换行**时才写文件;文件不存在或已经以换行结尾时什么都不做。
503
+ *
504
+ * @param {string} topicFile topic_decision_<date>.md 的绝对路径
505
+ * @returns {{ok: boolean, fixed: boolean, reason: string|null}}
506
+ */
507
+ export function ensureTrailingNewline(topicFile) {
508
+ const empty = { ok: true, fixed: false, reason: null };
509
+ if (!topicFile || !existsSync(topicFile)) return { ...empty, reason: '文件不存在' };
510
+
511
+ let content;
512
+ try {
513
+ content = readFileSync(topicFile, 'utf-8');
514
+ } catch (e) {
515
+ return { ...empty, reason: '读文件失败:' + (e && e.message ? e.message : String(e)) };
516
+ }
517
+
518
+ if (!content) return { ...empty, reason: '文件是空的' };
519
+ if (content.endsWith('\n')) return { ...empty, reason: null };
520
+
521
+ try {
522
+ atomicWriteFile(topicFile, content + '\n');
523
+ } catch (e) {
524
+ return { ...empty, reason: '写回失败:' + (e && e.message ? e.message : String(e)) };
525
+ }
526
+ return { ok: true, fixed: true, reason: null };
527
+ }
528
+
529
+ /**
530
+ * 解析块标题里的"榜单范围"与"日期"。
531
+ *
532
+ * 标记行的格式是 `# 平台-榜单列表-日期-start`:
533
+ *
534
+ * # 起点-全选-20261027-start
535
+ * # 起点-新书榜、新人作者榜-20261027-start
536
+ *
537
+ * 平台名和榜单列表都不含 '-',日期是 8 位数字,所以按 '-' 分三段即可。
538
+ *
539
+ * 解析不出来(LLM 没写标准标记、标题是散文、日期不是 8 位数字……)时
540
+ * 返回 null —— 调用方据此**保守地保留**该块,不做冗余判断。
541
+ * 宁可多发一块给 LLM,也不要凭空吞掉用户的数据。
542
+ *
543
+ * @param {string} title - 块的首个标题(可带 '# ' 前缀与 -start/-end 后缀)
544
+ * @returns {{lists: string[], date: string, isAll: boolean}|null}
545
+ */
546
+ export function parseBlockMeta(title) {
547
+ const raw = String(title || '')
548
+ .trim()
549
+ .replace(/^#+\s*/, '')
550
+ .replace(/[-—–\s]*start\s*$/i, '')
551
+ .replace(/[-—–\s]*end\s*$/i, '')
552
+ .trim();
553
+ if (!raw) return null;
554
+
555
+ const parts = raw.split('-');
556
+ if (parts.length < 3) return null;
557
+
558
+ const date = parts[parts.length - 1];
559
+ if (!/^\d{8}$/.test(date)) return null;
560
+
561
+ const lists = parts[parts.length - 2]
562
+ .split('、')
563
+ .map(s => s.trim())
564
+ .filter(Boolean);
565
+ if (!lists.length) return null;
566
+
567
+ return { lists, date, isAll: lists.length === 1 && lists[0] === '全选' };
568
+ }
569
+
570
+ /**
571
+ * 榜单集合的包含关系:outer 的榜单范围是否**覆盖** inner。
572
+ *
573
+ * "全选"是该平台的**全部**榜单,所以覆盖任何子集;
574
+ * 反过来,窄的范围覆盖不了"全选"。
575
+ *
576
+ * @param {{lists: string[], isAll: boolean}} outer
577
+ * @param {{lists: string[], isAll: boolean}} inner
578
+ * @returns {boolean}
579
+ */
580
+ function scopeCovers(outer, inner) {
581
+ if (!outer || !inner) return false;
582
+ if (outer.isAll) return true;
583
+ if (inner.isAll) return false;
584
+ return inner.lists.every(l => outer.lists.includes(l));
585
+ }
586
+
587
+ /**
588
+ * 从同平台的若干块里,挑出**该发给 LLM 的那些**(去掉冗余块)。
589
+ *
590
+ * ## 问题
591
+ *
592
+ * 同一个平台一天里可能被扫多次,每次追加一个标记块:
593
+ *
594
+ * # 起点-新书榜、新人作者榜-20261027-start ← 先扫了 2 个榜单
595
+ * # 起点-全选-20261027-start ← 后来扫了全部榜单
596
+ *
597
+ * 两块都留在文件里(数据不丢,这是对的),但 /fancy-topic 选"起点"时
598
+ * 会把两块**一起**发给 LLM —— 后一次扫描重新采集了同样的榜单文件
599
+ * (起点新书榜_20261027.md 被覆盖成新的一份),前一块的分析既陈旧
600
+ * 又被完全覆盖,同一批数据会被统计两遍。
601
+ *
602
+ * ## 去冗余规则(**按文件里的先后顺序**)
603
+ *
604
+ * 后出现的块覆盖先出现的块时,先出现的块冗余:
605
+ *
606
+ * 先 起点-新书榜、新人作者榜 后 起点-全选 → 丢前块(全选重采过新书榜)
607
+ * 先 起点-全选 后 起点-签约榜 → 两块都留
608
+ *
609
+ * 第二种情况**不能**丢:后扫的"签约榜"是另一次独立采集(榜单快照、
610
+ * 采集时间都不同),不是"全选"的子集 —— 早先那次全选里对签约榜的
611
+ * 分析用的还是旧数据,而且全选还包含签约榜之外的其它榜单,两块都有价值。
612
+ * 不看顺序、只看"范围包含"的规则会把它误删,打破了"同平台多块都要
613
+ * 发给 LLM"这条用户报过的需求。
614
+ *
615
+ * 同 key 重复(同平台 + 同榜单 + 同日期)也由这条规则覆盖:
616
+ * 后一次扫描重采了同样的文件,前一块同样陈旧。
617
+ *
618
+ * ## 保守边界
619
+ *
620
+ * · 日期不同 → 不是冗余(数据来自不同天的榜单文件),两块都留;
621
+ * · 榜单集合互不包含 → 不是冗余,两块都留;
622
+ * · 解析不出标题元信息 → 保留(宁可多发,不可凭猜删数据)。
623
+ *
624
+ * @param {Array} items - 待筛选的块
625
+ * @param {(item:any)=>string} [getTitle] - 取块标题的函数。
626
+ * 不传时按块自身的两种常见形态取:
627
+ * `startTitle`(lib 里的标记块)或 `headers[0]`(fancy-topic 的切段结果)。
628
+ * @returns {{kept: Array, dropped: Array<{item:any, title:string, reason:string}>}}
629
+ */
630
+ export function selectNonRedundantBlocks(items, getTitle) {
631
+ const list = Array.isArray(items) ? items : [];
632
+ const titleOf = typeof getTitle === 'function'
633
+ ? getTitle
634
+ : (it) => {
635
+ if (!it) return '';
636
+ if (it.startTitle) return it.startTitle;
637
+ if (Array.isArray(it.headers) && it.headers.length) return it.headers[0];
638
+ return '';
639
+ };
640
+
641
+ const metas = list.map((it, i) => {
642
+ const title = String(titleOf(it) || '');
643
+ return { i, it, title, meta: parseBlockMeta(title) };
644
+ });
645
+
646
+ /** index → 去冗余理由 */
647
+ const dropReason = new Map();
648
+
649
+ // 后出现的块覆盖先出现的块时,先出现的那块冗余 ——
650
+ //
651
+ // "覆盖"指后一次扫描**重新采集了同样的榜单文件**
652
+ // (起点新书榜_20261027.md 被覆盖成新的一份),所以先扫的那块分析
653
+ // 用的数据已经陈旧,而且结论被完全覆盖 —— 同一批数据会被统计两遍。
654
+ //
655
+ // 顺序是关键:不看顺序、只看"范围包含"会误删。
656
+ // 先 起点-新书榜、新人作者榜 后 起点-全选 → 丢前块(全选重采过新书榜)
657
+ // 先 起点-全选 后 起点-签约榜 → 两块都留
658
+ // 第二种不能丢:后扫的"签约榜"是另一次独立采集(榜单快照、采集时间
659
+ // 都不同),不是"全选"的子集;而且全选里对签约榜的分析用的是旧数据,
660
+ // 还包含签约榜之外的其它榜单 —— 两块都有价值。
661
+ //
662
+ // 同 key 重复(同平台+同榜单+同日期)也由这条规则覆盖。
663
+ for (const early of metas) {
664
+ if (!early.meta || dropReason.has(early.i)) continue;
665
+ for (const late of metas) {
666
+ if (late.i <= early.i || !late.meta) continue;
667
+ if (early.meta.date !== late.meta.date) continue;
668
+ if (!scopeCovers(late.meta, early.meta)) continue;
669
+ dropReason.set(
670
+ early.i,
671
+ `后扫的「${late.title}」重新采集了同样的榜单,覆盖了前面这块`
672
+ );
673
+ break;
674
+ }
675
+ }
676
+
677
+ const kept = [];
678
+ const dropped = [];
679
+ for (const m of metas) {
680
+ const reason = dropReason.get(m.i);
681
+ if (reason) {
682
+ dropped.push({ item: m.it, title: m.title, reason });
683
+ } else {
684
+ kept.push(m.it);
685
+ }
686
+ }
687
+ return { kept, dropped };
688
+ }
689
+
690
+ /**
691
+ * 备份 topic_decision 文件。
692
+ *
693
+ * 备份放在 `<扫榜结果>/.revisions/topic_decision_<日期>-<时间戳>.md`。
694
+ * 目录名以 `.` 开头,且备份文件名不匹配 `topic_decision_\d{8}.md`,
695
+ * 所以既不会被 fancy-topic 当成可选的决策文件,也不会被扫榜产物识别
696
+ * 当成平台文件。
697
+ *
698
+ * 文件不存在时不备份(第一次扫,没有可丢的东西)。
699
+ *
700
+ * @param {string} topicFile topic_decision_<date>.md 的绝对路径
701
+ * @returns {{backupPath: string|null, bytes: number}}
702
+ */
703
+ export function backupTopicDecision(topicFile) {
704
+ if (!topicFile || !existsSync(topicFile)) return { backupPath: null, bytes: 0 };
705
+ let content;
706
+ try {
707
+ content = readFileSync(topicFile, 'utf-8');
708
+ } catch {
709
+ return { backupPath: null, bytes: 0 };
710
+ }
711
+ if (!content.trim()) return { backupPath: null, bytes: 0 };
712
+
713
+ const dir = join(dirname(topicFile), REVISIONS_DIR);
714
+ try {
715
+ // `.revisions` 是项目根下的可写目录:如果它是指向项目外的符号链接,
716
+ // 备份会把整份报告写到外面去。拒绝符号链接,宁可不备份(调用方会
717
+ // 因为 backupPath=null 而跳过自动去重)。
718
+ try {
719
+ const st = lstatSync(dir);
720
+ if (st.isSymbolicLink()) return { backupPath: null, bytes: 0 };
721
+ if (!st.isDirectory()) return { backupPath: null, bytes: 0 };
722
+ } catch { /* 目录不存在,下面 mkdir */ }
723
+ mkdirSync(dir, { recursive: true });
724
+ } catch {
725
+ return { backupPath: null, bytes: 0 };
726
+ }
727
+
728
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
729
+ const name = basename(topicFile).replace(/\.md$/, '');
730
+ // 同一秒内可能连续备份两次(修复前 / 去重前);加随机后缀避免互相覆盖。
731
+ const backupPath = join(dir, `${name}-${stamp}-${randomBytes(3).toString('hex')}.md`);
732
+ try {
733
+ atomicWriteFile(backupPath, content);
734
+ } catch {
735
+ return { backupPath: null, bytes: 0 };
736
+ }
737
+ return { backupPath, bytes: Buffer.byteLength(content, 'utf-8') };
738
+ }
739
+
740
+ /**
741
+ * 列出 topic_decision 的备份,按时间从新到旧。
742
+ *
743
+ * @param {string} topicFile
744
+ * @returns {string[]}
745
+ */
746
+ export function listBackups(topicFile) {
747
+ const dir = join(dirname(topicFile || ''), REVISIONS_DIR);
748
+ if (!topicFile || !existsSync(dir)) return [];
749
+ let names;
750
+ try {
751
+ names = readdirSync(dir);
752
+ } catch {
753
+ return [];
754
+ }
755
+ const prefix = basename(topicFile).replace(/\.md$/, '') + '-';
756
+ return names
757
+ .filter(n => n.startsWith(prefix) && n.endsWith('.md'))
758
+ .map(n => join(dir, n))
759
+ .filter(p => {
760
+ try {
761
+ return statSync(p).isFile();
762
+ } catch {
763
+ return false;
764
+ }
765
+ })
766
+ .sort()
767
+ .reverse();
768
+ }
769
+
770
+ /**
771
+ * 校验并修复 topic_decision 文件。
772
+ *
773
+ * 逻辑:拿最新的备份和当前文件比 —— 备份里有、当前文件里没有的标记块,
774
+ * 说明那段内容被后来的写入**覆盖**掉了(模型用 write 清空重写),
775
+ * 自动把丢失的块补回文件末尾。
776
+ *
777
+ * @param {string} topicFile topic_decision_<date>.md 的绝对路径
778
+ * @returns {{ok: boolean, repaired: string[], missingNow: string[],
779
+ * fromBackup: string|null, reason: string|null}}
780
+ */
781
+ export function repairTopicDecision(topicFile) {
782
+ const empty = { ok: true, repaired: [], missingNow: [], fromBackup: null, reason: null };
783
+ if (!topicFile || !existsSync(topicFile)) return { ...empty, reason: '文件不存在' };
784
+
785
+ let current;
786
+ try {
787
+ current = readFileSync(topicFile, 'utf-8');
788
+ } catch (e) {
789
+ return { ...empty, reason: '读文件失败:' + (e && e.message ? e.message : String(e)) };
790
+ }
791
+
792
+ const backups = listBackups(topicFile);
793
+ if (backups.length === 0) {
794
+ return { ...empty, reason: '没有备份可比对(第一次扫,或备份已清理)' };
795
+ }
796
+
797
+ let backupContent = null;
798
+ let usedBackup = null;
799
+ for (const b of backups) {
800
+ try {
801
+ const c = readFileSync(b, 'utf-8');
802
+ if (!c.trim()) continue;
803
+ backupContent = c;
804
+ usedBackup = b;
805
+ break;
806
+ } catch {
807
+ continue;
808
+ }
809
+ }
810
+ if (!backupContent) return { ...empty, reason: '备份都读不出来' };
811
+
812
+ // 按**块级**判断丢了什么(2026-10-29 审计)。
813
+ //
814
+ // 之前只看 key 在不在:同一个 key 的块被**截断**(只剩标题和两行)时,
815
+ // key 仍然存在 → 不算 lost → 不会从备份恢复。
816
+ // 现在同 key 也要比大小:当前块明显比备份块小 → 判定为被截断,一并补回。
817
+ //
818
+ // 补回后会出现两个同 key 的块 —— 那没关系,
819
+ // 下游的 dedupeMarkerBlocks 会收敛(保留闭合的那个)。
820
+ const currentBlocks = parseMarkerBlocks(current);
821
+ const currentByKey = new Map();
822
+ for (const b of currentBlocks) {
823
+ if (!currentByKey.has(b.key)) currentByKey.set(b.key, []);
824
+ currentByKey.get(b.key).push(b);
825
+ }
826
+ const backupBlocks = parseMarkerBlocks(backupContent);
827
+ const lost = backupBlocks.filter(b => {
828
+ const cur = (currentByKey.get(b.key) || [])[0];
829
+ if (!cur) return true; // 整个块没了
830
+ if (!b.text) return false;
831
+ // 当前块不到备份块的一半 → 被截断
832
+ return cur.text.length < b.text.length * 0.5;
833
+ });
834
+
835
+ if (lost.length === 0) {
836
+ return { ok: true, repaired: [], missingNow: [], fromBackup: usedBackup, reason: null };
837
+ }
838
+
839
+ // 把丢失的块补回文件末尾
840
+ const addition = '\n\n' + lost.map(b => b.text).join('\n\n') + '\n';
841
+ try {
842
+ atomicWriteFile(topicFile, current.replace(/\s*$/, '') + addition);
843
+ } catch (e) {
844
+ return {
845
+ ok: false,
846
+ repaired: [],
847
+ missingNow: lost.map(b => b.key),
848
+ fromBackup: usedBackup,
849
+ reason: '补写失败:' + (e && e.message ? e.message : String(e)),
850
+ };
851
+ }
852
+
853
+ return {
854
+ ok: true,
855
+ repaired: lost.map(b => b.key),
856
+ missingNow: lost.map(b => b.key),
857
+ fromBackup: usedBackup,
858
+ reason: null,
859
+ };
860
+ }