@cloud411716/fancy-webnovel 1.0.46 → 2.2.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 (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. 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 +204 -0
  105. 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
  106. 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
  107. 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
  108. 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 +66 -0
  109. 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 +76 -0
  110. 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 +72 -0
  111. 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 +37 -0
  112. 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 +53 -0
  113. 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 +40 -0
  114. 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 +79 -0
  115. 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 +132 -0
  116. 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 +133 -0
  117. 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 +160 -0
  118. 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
  119. 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 +134 -0
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -164
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -283
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
@@ -0,0 +1,1118 @@
1
+ /**
2
+ * import-core.js —— 导入阶段的确定性内核
3
+ *
4
+ * 导入的因果方向和 story-builder 正相反:
5
+ * 正向:先定 quota(框架.json)→ 照配额造实体 → 排章节 → 写正文
6
+ * 反向:旧稿已经在那里了 → 先量出旧稿的实际规模 → 再反推 quota
7
+ *
8
+ * 所以导入不能直接套用骨架校验器:
9
+ * 校验器问的是"实体条数符不符合 quota";
10
+ * 导入问的是"旧稿的实际规模能不能落进 quota 的合法区间,
11
+ * 落不进去该取哪个值"。
12
+ *
13
+ * 这个模块只做确定性的事(扫描、切章、统计、反推配额),
14
+ * 不涉及模型。模型要做的"从旧稿读出实体/关系"由插件派活。
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import { cnToNumber } from '#chinese-num';
20
+ import { countChapterChars } from '#story-builder/scripts/content-quality.js';
21
+ import { QUOTA_MAP } from '#story-builder/scripts/skeleton-quota.js';
22
+ import { formulaRange, defaultInRange } from '#story-builder/scripts/formula-engine.js';
23
+
24
+ // ============================================================
25
+ // 常量
26
+ // ============================================================
27
+
28
+ /** 正文目录名(在这些目录下的文件一律当正文) */
29
+ export const CHAPTER_DIR_HINTS = ['正文', '章节', '章节正文', 'chapter', 'chapters', 'manuscript'];
30
+
31
+ /** 设定目录名(在这些目录下的文件一律当设定) */
32
+ export const SETTING_DIR_HINTS = ['设定', '设定集', '背景设定', '背景', '资料', '素材', 'setting', 'settings'];
33
+
34
+ /** 导入时要跳过的目录(项目自己的产物 / 工具目录) */
35
+ export const SKIP_DIRS = [
36
+ '.git', 'node_modules', 'references', '模板', '审查报告', '对标', '.fancy',
37
+ '导入报告'
38
+ ];
39
+
40
+ /**
41
+ * 插件自己的产物路径(相对导入根)。
42
+ *
43
+ * 扫描项目根时不能把上一轮导入的产物当"旧稿"再导一遍 ——
44
+ * 那会让章节数翻倍、内容自相矛盾。
45
+ *
46
+ * 这里按**路径**排除而不是按目录名排除:目录名 "设定" 是用户的旧稿里
47
+ * 非常常见的文件夹(设定资料就放在那儿),按名字排除会连用户的设定一起跳过。
48
+ * 只排除插件真正写东西的那条路径。
49
+ */
50
+ export const OWN_OUTPUT_PATHS = ['设定/raw/sources'];
51
+
52
+ /** 正文字体量与扩展名 */
53
+ const TEXT_EXT = new Set(['.txt', '.md', '.markdown', '.text']);
54
+
55
+ /** 单文件上限(16MB):超了多半不是小说正文 */
56
+ const MAX_FILE_BYTES = 16 * 1024 * 1024;
57
+
58
+ // ============================================================
59
+ // 切章
60
+ // ============================================================
61
+
62
+ /** 卷/部/篇标题行 */
63
+ const VOLUME_RE = /^[ \t ]*第[ \t ]*([0-9]+|[〇零一二三四五六七八九十百千万两]+)[ \t ]*([卷部篇])[ \t ]*(.*)$/;
64
+
65
+ /** 章/回/节标题行 */
66
+ const CHAPTER_RE = /^[ \t ]*第[ \t ]*([0-9]+|[〇零一二三四五六七八九十百千万两]+)[ \t ]*([章回节])[ \t ]*(.*)$/;
67
+
68
+ /**
69
+ * 文件名里的章号(不锚定行首行尾)。
70
+ *
71
+ * 不能用 CHAPTER_RE:它带 `(.*)$`,匹配的是**整行**,
72
+ * 于是 m[0] = "第003章_夜巡.txt",截不出标题。
73
+ */
74
+ const CHAPTER_FILE_RE = /第[ \t ]*([0-9]+|[〇零一二三四五六七八九十百千万两]+)[ \t ]*([章回节])/;
75
+
76
+ /**
77
+ * 文件名/目录名里的卷号(同样不锚定,用于排序时的卷次分组)。
78
+ *
79
+ * 与 CHAPTER_FILE_RE 是两个正则而不是一个:章尾是 [章回节]、卷尾是 [卷部篇],
80
+ * 互不重叠,"第一卷/第1章.txt" 这种命名才能同时解析出卷号和章号。
81
+ */
82
+ const VOLUME_FILE_RE = /第[ \t ]*([0-9]+|[〇零一二三四五六七八九十百千万两]+)[ \t ]*([卷部篇])/;
83
+
84
+ /**
85
+ * 判断一行是不是章节标题。
86
+ *
87
+ * 光看"第N章"开头不够:正文里"第一章里陆子仲去了园区。"这种句子
88
+ * 也会被当标题(尤其出现在行首时),切出来的章标题就成了半句话。
89
+ *
90
+ * 追加判据:**标题部分不以句末标点结尾**。
91
+ * - "第一章 夜巡" → 标题"夜巡",无句末标点 → 是标题
92
+ * - "第一章 夜巡(上)" → 标题以右括号结尾,也不是句末标点 → 是标题
93
+ * - "第一章里陆子仲去了园区。" → 以句号结尾 → 是句子,不是标题
94
+ *
95
+ * @param {string} line
96
+ * @returns {boolean}
97
+ */
98
+ export function isChapterHeadingLine(line) {
99
+ const m = CHAPTER_RE.exec(String(line || ''));
100
+ if (!m) return false;
101
+ const rest = (m[3] || '').trim();
102
+ if (!rest) return true;
103
+ return !/[。!?,;、,.!?;::]$/.test(rest);
104
+ }
105
+
106
+ /**
107
+ * 从一段文本里切出章节,**保留原标题**。
108
+ *
109
+ * 旧实现(fancy-import/references/run-import.py)用
110
+ * `第\s*([0-9]+|[一二三四五六七八九十]+)\s*章` 全局匹配,
111
+ * 两个问题:
112
+ * 1. 不要求在行首 —— 正文里"他翻到第三章"也会被当成章节边界;
113
+ * 2. 只取 `m.group(0)`("第三章"三个字),**标题整个丢了**,
114
+ * 落盘成 `第001章_untitled.md`。
115
+ *
116
+ * @param {string} text
117
+ * @returns {{chapters:Array, volumes:Array}}
118
+ * chapters[i] = { no, unit, title, heading, body, startLine, volume }
119
+ */
120
+ export function splitChapters(text) {
121
+ const src = String(text || '');
122
+ const lines = src.split('\n');
123
+ const chapters = [];
124
+ const volumes = [];
125
+
126
+ let current = null;
127
+ let volume = null;
128
+
129
+ const flush = (endLine) => {
130
+ if (!current) return;
131
+ current.endLine = endLine;
132
+ current.body = current.bodyLines.join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
133
+ delete current.bodyLines;
134
+ chapters.push(current);
135
+ // 必须置空:卷标题会 flush 掉当前章,之后的正文行不该再往旧章里塞
136
+ current = null;
137
+ };
138
+
139
+ for (let i = 0; i < lines.length; i++) {
140
+ const line = lines[i];
141
+
142
+ const vm = VOLUME_RE.exec(line);
143
+ if (vm) {
144
+ flush(i);
145
+ volume = {
146
+ no: cnToNumber(vm[1]),
147
+ unit: vm[2],
148
+ title: (vm[3] || '').trim(),
149
+ heading: line.trim(),
150
+ line: i + 1
151
+ };
152
+ volumes.push(volume);
153
+ continue;
154
+ }
155
+
156
+ if (isChapterHeadingLine(line)) {
157
+ const cm = CHAPTER_RE.exec(line);
158
+ flush(i);
159
+ const rest = (cm[3] || '').trim();
160
+ current = {
161
+ no: cnToNumber(cm[1]),
162
+ unit: cm[2],
163
+ // 标题为空时保留原始标题行(绝不退化成 "untitled" —— 那是旧实现的老毛病)
164
+ title: rest || line.trim(),
165
+ titleFromHeading: rest.length > 0,
166
+ heading: line.trim(),
167
+ startLine: i + 1,
168
+ volume,
169
+ bodyLines: []
170
+ };
171
+ continue;
172
+ }
173
+
174
+ if (current) current.bodyLines.push(line);
175
+ }
176
+ flush(lines.length);
177
+
178
+ return { chapters, volumes };
179
+ }
180
+
181
+ /**
182
+ * 从一个文件里取章节。
183
+ *
184
+ * 两种布局:
185
+ * a) 一个大文件里含多章 → 按标题行切
186
+ * b) 一个文件就是一章(文件名带"第N章")→ 整篇算一章
187
+ *
188
+ * @param {string} fileName 文件名(含路径)
189
+ * @param {string} text 文件内容
190
+ * @returns {{chapters:Array, volumes:Array}}
191
+ */
192
+ export function chaptersFromFile(fileName, text) {
193
+ const base = path.basename(fileName);
194
+ const m = CHAPTER_FILE_RE.exec(base);
195
+ const split = splitChapters(text);
196
+
197
+ if (m) {
198
+ // 文件名就标明了章号:整篇算一章,正文里的其它"第N章"不算边界
199
+ const first = split.chapters[0];
200
+ const rest = split.chapters.length > 1
201
+ ? '\n\n' + split.chapters.slice(1).map((c) => c.heading + '\n' + c.body).join('\n\n')
202
+ : '';
203
+ const no = cnToNumber(m[1]);
204
+ // 先剥扩展名,再剥前导分隔符 —— 顺序反了会把 "第1章.txt" 切成 "txt"
205
+ // (先去分隔符:"." 被打掉 → ".txt" 变成 "txt" → 扩展名正则再也匹配不上)。
206
+ // 剥完扩展名还剩 "第1章" 时,章号后面没有标题 → 用正文里解析出的标题。
207
+ const stem = base.replace(/\.(txt|md|markdown|text)$/i, '');
208
+ const titleFromName = stem.slice(m.index + m[0].length)
209
+ .replace(/^[_\-.·、\s]+/, '');
210
+ return {
211
+ chapters: [{
212
+ no,
213
+ unit: m[2],
214
+ title: titleFromName || (first ? first.title : stem),
215
+ titleFromHeading: !!(first && first.titleFromHeading),
216
+ heading: first ? first.heading : base,
217
+ startLine: 1,
218
+ body: ((first ? first.body : text) + rest).trim(),
219
+ volume: null
220
+ }],
221
+ volumes: split.volumes
222
+ };
223
+ }
224
+
225
+ return split;
226
+ }
227
+
228
+ // ============================================================
229
+ // 扫描
230
+ // ============================================================
231
+
232
+ function listFiles(root) {
233
+ const out = [];
234
+ const walk = (dir, depth) => {
235
+ if (depth > 6) return;
236
+ let entries;
237
+ try {
238
+ entries = fs.readdirSync(dir, { withFileTypes: true });
239
+ } catch {
240
+ return;
241
+ }
242
+ for (const e of entries) {
243
+ if (e.name.startsWith('.')) continue;
244
+ const full = path.join(dir, e.name);
245
+ if (e.isDirectory()) {
246
+ if (SKIP_DIRS.includes(e.name)) continue;
247
+ // 插件自己的产物路径不扫(见 OWN_OUTPUT_PATHS 的说明)
248
+ const relDir = path.relative(root, full).split(path.sep).join('/');
249
+ if (OWN_OUTPUT_PATHS.some((p) => relDir === p || relDir.startsWith(p + '/'))) continue;
250
+ walk(full, depth + 1);
251
+ continue;
252
+ }
253
+ if (!e.isFile()) continue;
254
+ if (!TEXT_EXT.has(path.extname(e.name).toLowerCase())) continue;
255
+ out.push(full);
256
+ }
257
+ };
258
+ walk(root, 0);
259
+ sortFiles(out, root);
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * 从文件路径里解析卷号(取路径中**最后一个**带卷号的目录名)。
265
+ *
266
+ * 多卷旧稿常按"第一卷/第1章.txt"这样分目录;卷号取目录名而不是文件名,
267
+ * 因为文件名里通常只有章号。
268
+ *
269
+ * @param {string} root 导入根目录
270
+ * @param {string} full 文件绝对路径
271
+ * @returns {number|null} 卷号,解析不出返回 null
272
+ */
273
+ export function volumeNoFromPath(root, full) {
274
+ const rel = path.relative(root, full) || path.basename(full);
275
+ const segs = rel.split(/[\\/]+/);
276
+ const base = segs[segs.length - 1] || '';
277
+ let volume = null;
278
+ for (const s of segs.slice(0, -1)) {
279
+ const vm = VOLUME_FILE_RE.exec(s);
280
+ if (vm) volume = cnToNumber(vm[1]);
281
+ }
282
+ if (volume === null) {
283
+ const vm = VOLUME_FILE_RE.exec(base);
284
+ if (vm) volume = cnToNumber(vm[1]);
285
+ }
286
+ return volume;
287
+ }
288
+
289
+ /**
290
+ * 给文件列表排序 —— 导入的章序必须来自文件名,不能来自 readdir 的返回顺序。
291
+ *
292
+ * 为什么这是必须修的:readdirSync 的顺序是文件系统决定的(ext4 是 hash 序、
293
+ * NTFS 是名字序、APFS 又不一样),把 `第1章` 排到 `第10章` 前面是常事。
294
+ * 旧实现直接按遍历顺序 `seq += 1`,于是**正文被静默错位**:
295
+ * 标题是连续的(第1章…第5章),正文却是(第10章 → 第1章 → 第20章 → …)。
296
+ * 导入报告不报错,写作阶段按错序续写,前情/伏笔/时间线全部错位。
297
+ *
298
+ * 排序规则:
299
+ * 1. 能解析出章号的 → 按 (卷号, 章号) 升序
300
+ * 卷号取自路径里**最后一个**带卷号的目录名("第一卷/第1章.txt" → 1),
301
+ * 没有卷目录时取 0;
302
+ * 2. 解析不出章号的 → 按相对路径自然序(`localeCompare(..., {numeric:true})`,
303
+ * 这样 "第2章" 排在 "第10章" 前面);
304
+ * 3. 有章号的排在没章号的前面;
305
+ * 4. 最终以相对路径自然序兜底,保证排序是**稳定且确定**的。
306
+ *
307
+ * @param {string[]} files 绝对路径数组(原地排序)
308
+ * @param {string} root 导入根目录
309
+ * @returns {string[]} 同一个数组
310
+ */
311
+ export function sortFiles(files, root) {
312
+ const key = (full) => {
313
+ const rel = path.relative(root, full) || path.basename(full);
314
+ const segs = rel.split(/[\\/]+/);
315
+ const base = segs[segs.length - 1] || '';
316
+ const volume = volumeNoFromPath(root, full);
317
+ const cm = CHAPTER_FILE_RE.exec(base);
318
+ const chapter = cm ? cnToNumber(cm[1]) : null;
319
+ return { rel, volume, chapter };
320
+ };
321
+
322
+ files.sort((a, b) => {
323
+ const ka = key(a);
324
+ const kb = key(b);
325
+ const aHas = ka.chapter !== null;
326
+ const bHas = kb.chapter !== null;
327
+ if (aHas && bHas) {
328
+ const va = ka.volume === null ? 0 : ka.volume;
329
+ const vb = kb.volume === null ? 0 : kb.volume;
330
+ if (va !== vb) return va - vb;
331
+ if (ka.chapter !== kb.chapter) return ka.chapter - kb.chapter;
332
+ } else if (aHas !== bHas) {
333
+ return aHas ? -1 : 1;
334
+ }
335
+ return ka.rel.localeCompare(kb.rel, 'zh-Hans-CN', { numeric: true });
336
+ });
337
+ return files;
338
+ }
339
+
340
+ /**
341
+ * 把原始字节解码成文本:UTF-8 优先,失败按 GBK 解。
342
+ *
343
+ * 为什么必须做:Node 的 `readFileSync(p, 'utf-8')` 遇到非法字节**不抛错**,
344
+ * 而是把每个坏字节替换成 U+FFFD。GBK 旧稿于是变成一堆 "�",
345
+ * 章标题匹配不到 → 文件被归类成"设定" → **静默导入 0 章**,
346
+ * 而 scanSource 只在"章节和设定都为空"时才报错,所以还报 ok:true。
347
+ * 早期中文 txt 大量是 GBK,这个坑命中面很大。
348
+ *
349
+ * 顺带处理 UTF-8 BOM:带 BOM 的文件,第一行是 "\uFEFF第一章 …",
350
+ * 而 CHAPTER_RE 锚定行首(只允许空格/制表符/全角空格),
351
+ * BOM 会让**第一章的标题匹配不上**,切章从第二章开始。
352
+ *
353
+ * @param {Buffer|Uint8Array|string} raw 原始字节
354
+ * @returns {string}
355
+ */
356
+ export function decodeText(raw) {
357
+ let bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf-8');
358
+ if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
359
+ bytes = bytes.slice(3);
360
+ }
361
+ try {
362
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
363
+ } catch {
364
+ try {
365
+ return new TextDecoder('gbk').decode(bytes);
366
+ } catch {
367
+ // Node 没带 full-icu 时 gbk 解码器不可用 → 退回宽松的 UTF-8
368
+ // (会带 U+FFFD,但至少不会整体失败)
369
+ return bytes.toString('utf-8');
370
+ }
371
+ }
372
+ }
373
+
374
+ function readIfSmall(p) {
375
+ try {
376
+ const st = fs.statSync(p);
377
+ if (st.size > MAX_FILE_BYTES) return null;
378
+ return decodeText(fs.readFileSync(p));
379
+ } catch {
380
+ return null;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * 判断一个文件是正文还是设定。
386
+ *
387
+ * 判定顺序(先命中先定):
388
+ * 1. 路径里有正文目录名 → 正文
389
+ * 2. 路径里有设定目录名 → 设定
390
+ * 3. 文件名带"第N章" → 正文
391
+ * 4. 内容里有合法章标题行 → 正文(单章文件、一个大 txt 都算)
392
+ * 5. 其余 → 设定
393
+ *
394
+ * 第 4 条为什么单独判:设定资料里常出现"第一章"这种引用
395
+ * ("第一章里陆子仲去了园区"),只在文中出现不足以说明这是正文;
396
+ * 但**开头就是**"第一章 xxx"的文件,就是一章正文。
397
+ *
398
+ * @param {string} filePath
399
+ * @param {string} relPath 相对 root 的路径
400
+ * @param {string} text
401
+ * @returns {'chapter'|'setting'}
402
+ */
403
+ export function classifyFile(filePath, relPath, text) {
404
+ const segs = relPath.split(/[\\/]+/);
405
+ const dirs = segs.slice(0, -1);
406
+ const base = segs[segs.length - 1] || '';
407
+
408
+ for (const d of dirs) {
409
+ if (CHAPTER_DIR_HINTS.includes(d.toLowerCase())) return 'chapter';
410
+ }
411
+ for (const d of dirs) {
412
+ if (SETTING_DIR_HINTS.includes(d.toLowerCase())) return 'setting';
413
+ }
414
+ if (CHAPTER_RE.test(base)) return 'chapter';
415
+
416
+ const { chapters } = splitChapters(text);
417
+ return chapters.length >= 1 ? 'chapter' : 'setting';
418
+ }
419
+
420
+ /**
421
+ * 剔除"目录页"造成的空章,并合并完全重复的章节。
422
+ *
423
+ * ## 为什么必须有这一步
424
+ *
425
+ * 旧稿开头常有目录:
426
+ *
427
+ * 目录
428
+ * 第一章 开端 ← 被切成一个"章",但后面没有正文
429
+ * 第二章 转折
430
+ * 第三章 终局
431
+ * 第一章 开端 ← 真正的正文在这里开始
432
+ * 正文一。
433
+ *
434
+ * `splitChapters()` 只按"行首第N章 + 标题不以句末标点结尾"判断,
435
+ * 所以目录里的每一行都成了一个**空章**。旧实现只是记一条 warning,
436
+ * 空章照样写盘,章节数直接翻倍:
437
+ * - `scan.stats.chapterCount` 翻倍 → 反推出的 T、卷数、支线数、所有配额全被抬高;
438
+ * - 写作阶段 `readWritingState()` 把这些空文件当成"已存在"直接跳过;
439
+ * - 最终报"0 章"或一堆 EMPTY_BODY。
440
+ *
441
+ * ## 判定规则(刻意保守,宁可漏判也不误删正文)
442
+ *
443
+ * 只有同时满足三条的连续块才判为目录区:
444
+ * 1. 块长 ≥ 2(单个空章更可能是作者留白,不动它);
445
+ * 2. 块内每一章的正文都是空的;
446
+ * 3. 块内**每一个**章号都出现在后面的章节里
447
+ * (说明块里列的章确实都存在于正文中,是目录而不是正文)。
448
+ *
449
+ * 第 3 条用"全部出现"而不是"有交集":交集会在"块内列了 1、2,
450
+ * 而正文里只有 2"这种形态下误删 —— 那种其实是第 1 章没正文,不是目录。
451
+ *
452
+ * 多卷小说每卷都从"第一章"重新编号,这种情况各卷的号会重复 ——
453
+ * 但**章号相同不等于重复**:正文内容不同的同号章节一律保留(那多半是多卷),
454
+ * 只合并"章号相同**且正文逐字相同**"的(那才是真重复)。
455
+ *
456
+ * @param {Array} chapters 章节数组(原地过滤)
457
+ * @param {string[]} warnings 告警收集器(原地追加)
458
+ * @returns {{kept:Array, droppedToc:number, merged:number}}
459
+ */
460
+ export function stripTocChapters(chapters, warnings) {
461
+ const isEmpty = (c) => !String(c.body || '').trim();
462
+
463
+ // ---- 1. 找连续空章块 ----
464
+ const blocks = [];
465
+ let start = -1;
466
+ for (let i = 0; i <= chapters.length; i++) {
467
+ const empty = i < chapters.length && isEmpty(chapters[i]);
468
+ if (empty && start < 0) start = i;
469
+ if (!empty && start >= 0) {
470
+ blocks.push({ start, end: i - 1 });
471
+ start = -1;
472
+ }
473
+ }
474
+
475
+ const dropped = new Set();
476
+ let droppedToc = 0;
477
+ for (const b of blocks) {
478
+ if (b.end - b.start + 1 < 2) continue;
479
+ const blockNos = new Set();
480
+ for (let i = b.start; i <= b.end; i++) blockNos.add(chapters[i].no);
481
+ // 块内的章号是不是**全部**都在后面的章节里出现过?
482
+ const laterNos = new Set();
483
+ for (let i = b.end + 1; i < chapters.length; i++) laterNos.add(chapters[i].no);
484
+ let allLater = blockNos.size > 0;
485
+ for (const no of blockNos) {
486
+ if (!laterNos.has(no)) { allLater = false; break; }
487
+ }
488
+ if (!allLater) continue;
489
+ for (let i = b.start; i <= b.end; i++) dropped.add(i);
490
+ droppedToc += b.end - b.start + 1;
491
+ warnings.push(
492
+ '识别到目录页(' + (b.end - b.start + 1) + ' 行空章,章号与正文重复),已跳过'
493
+ );
494
+ }
495
+
496
+ // ---- 2. 合并"同号且正文逐字相同"的真重复 ----
497
+ const kept = [];
498
+ const seen = new Map(); // key = no + '\u0000' + body → 保留的那一条
499
+ let merged = 0;
500
+ for (let i = 0; i < chapters.length; i++) {
501
+ if (dropped.has(i)) continue;
502
+ const c = chapters[i];
503
+ const key = String(c.no) + '\u0000' + String(c.body || '');
504
+ if (seen.has(key)) {
505
+ merged += 1;
506
+ warnings.push('章号与正文都重复:' + c.no + '(' + (c.sourceRel || '') + '),已合并');
507
+ continue;
508
+ }
509
+ seen.set(key, c);
510
+ kept.push(c);
511
+ }
512
+
513
+ chapters.length = 0;
514
+ for (const c of kept) chapters.push(c);
515
+ return { kept, droppedToc, merged };
516
+ }
517
+
518
+ /**
519
+ * 扫描导入源(目录或单个文件)。
520
+ *
521
+ * @param {string} inputPath
522
+ * @returns {{
523
+ * ok: boolean, error: string|null,
524
+ * root: string,
525
+ * chapters: Array<{ch:number,no:number,title:string,body:string,sourcePath:string,words:number}>,
526
+ * settings: Array<{path:string,name:string,text:string,words:number}>,
527
+ * volumes: Array,
528
+ * stats: Object,
529
+ * warnings: string[]
530
+ * }}
531
+ */
532
+ export function scanSource(inputPath) {
533
+ const warnings = [];
534
+ if (!inputPath || typeof inputPath !== 'string') {
535
+ return { ok: false, error: '没有指定导入路径', root: '', chapters: [], settings: [], volumes: [], stats: {}, warnings };
536
+ }
537
+ let st;
538
+ try {
539
+ st = fs.statSync(inputPath);
540
+ } catch {
541
+ return { ok: false, error: '导入路径不存在:' + inputPath, root: inputPath, chapters: [], settings: [], volumes: [], stats: {}, warnings };
542
+ }
543
+
544
+ const chapters = [];
545
+ const settings = [];
546
+ const volumes = [];
547
+ let seq = 0;
548
+
549
+ const takeChapters = (filePath, text) => {
550
+ const rel = path.relative(inputPath, filePath) || path.basename(filePath);
551
+ const { chapters: cs, volumes: vs } = chaptersFromFile(filePath, text);
552
+ for (const v of vs) volumes.push(Object.assign({}, v, { sourcePath: filePath }));
553
+ for (const c of cs) {
554
+ seq += 1;
555
+ // 卷信息必须带出来:排序要按卷分组、重复检测要区分"同号不同卷"
556
+ // (多卷小说每卷都从"第一章"重新编号,同号是正常的)、
557
+ // chaptersPerVolume 要按卷分桶。
558
+ // 卷号优先取正文里解析出的卷标题,其次取路径里的卷目录名
559
+ // ("第一卷/第1章.txt" 这种分卷目录,正文里根本没有卷标题行)。
560
+ const volNo = c.volume ? c.volume.no : volumeNoFromPath(inputPath, filePath);
561
+ const volume = c.volume || (volNo !== null && volNo !== undefined
562
+ ? { no: volNo, unit: '卷', title: '', heading: '第' + volNo + '卷' }
563
+ : null);
564
+ chapters.push({
565
+ ch: seq,
566
+ no: c.no,
567
+ unit: c.unit,
568
+ title: c.title,
569
+ body: c.body,
570
+ volume,
571
+ volumeNo: volNo,
572
+ sourcePath: filePath,
573
+ sourceRel: rel,
574
+ words: countChapterChars(c.body)
575
+ });
576
+ }
577
+ };
578
+
579
+ const takeSetting = (filePath, text) => {
580
+ const rel = path.relative(inputPath, filePath) || path.basename(filePath);
581
+ settings.push({
582
+ path: filePath,
583
+ relPath: rel,
584
+ // 用**相对路径**做落盘名,不用 basename ——
585
+ // 旧稿里"设定/人物.md"和"补充资料/人物.md"同名,
586
+ // 平铺到导入资料目录后后者把前者覆盖了,
587
+ // 模型反推时只看到一份,另一份的设定凭空消失。
588
+ // 相对路径保留了目录结构,也天然不会撞名。
589
+ name: rel.split(path.sep).join('/'),
590
+ text,
591
+ words: countChapterChars(text)
592
+ });
593
+ };
594
+
595
+ if (st.isFile()) {
596
+ const text = readIfSmall(inputPath);
597
+ if (text === null) {
598
+ return { ok: false, error: '文件读不出来或太大(上限 16MB):' + inputPath, root: inputPath, chapters: [], settings: [], volumes: [], stats: {}, warnings };
599
+ }
600
+ const kind = classifyFile(inputPath, path.basename(inputPath), text);
601
+ if (kind === 'chapter') takeChapters(inputPath, text);
602
+ else takeSetting(inputPath, text);
603
+ } else if (st.isDirectory()) {
604
+ const files = listFiles(inputPath);
605
+ if (files.length === 0) {
606
+ return { ok: false, error: '目录里没有可导入的文本文件(支持 .txt / .md):' + inputPath, root: inputPath, chapters: [], settings: [], volumes: [], stats: {}, warnings };
607
+ }
608
+ for (const f of files) {
609
+ const text = readIfSmall(f);
610
+ if (text === null) {
611
+ warnings.push('跳过(读不出或超过 16MB):' + path.relative(inputPath, f));
612
+ continue;
613
+ }
614
+ const rel = path.relative(inputPath, f);
615
+ const kind = classifyFile(f, rel, text);
616
+ if (kind === 'chapter') takeChapters(f, text);
617
+ else takeSetting(f, text);
618
+ }
619
+ } else {
620
+ return { ok: false, error: '导入路径既不是文件也不是目录:' + inputPath, root: inputPath, chapters: [], settings: [], volumes: [], stats: {}, warnings };
621
+ }
622
+
623
+ // 目录页剔除 + 真重复合并(必须在统计章数之前 —— 否则配额全被抬高)
624
+ stripTocChapters(chapters, warnings);
625
+
626
+ if (chapters.length === 0 && settings.length === 0) {
627
+ return { ok: false, error: '没有找到任何可导入的内容:' + inputPath, root: inputPath, chapters: [], settings: [], volumes: [], stats: {}, warnings };
628
+ }
629
+
630
+ if (chapters.length === 0) {
631
+ // 扫到了设定资料但一章正文都没扫到 —— 旧实现这里照样 ok:true,
632
+ // 下游 target_chapters=0、avg_words_per_chapter=0,
633
+ // 骨架校验必失败,而导入可能仍报"✅ 导入完成:0 章"。
634
+ // 常见原因:编码不是 UTF-8/GBK、章标题格式认不出("第一章"之外的分隔符)。
635
+ //
636
+ // 设定资料照样返回去:用户要靠它判断"到底是编码问题还是内容问题"。
637
+ return {
638
+ ok: false,
639
+ error: '检测到 0 章:扫到了 ' + settings.length + ' 份设定资料,但没有一章正文。' +
640
+ '请确认旧稿是不是 UTF-8 或 GBK 编码、章节标题是不是"第N章/回/节"这种写法。',
641
+ root: inputPath,
642
+ chapters: [],
643
+ settings,
644
+ volumes,
645
+ stats: { chapterCount: 0, settingCount: settings.length },
646
+ warnings
647
+ };
648
+ }
649
+
650
+ // 剔除后重新连续编号:写盘文件名(`第NNN章.md`)和标题(`# 第N章`)都用它。
651
+ // 下游写作校验器要求章号与文件序号严格对应(CHAPTER_TITLE 会核对),
652
+ // 所以这里**重编号**是有意为之,导入报告里也会写明"已按顺序重编号"。
653
+ for (let i = 0; i < chapters.length; i++) chapters[i].ch = i + 1;
654
+
655
+ const words = chapters.map((c) => c.words);
656
+ const totalWords = words.reduce((a, b) => a + b, 0);
657
+ const stats = {
658
+ chapterCount: chapters.length,
659
+ settingCount: settings.length,
660
+ totalWords,
661
+ avgWords: chapters.length ? Math.round(totalWords / chapters.length) : 0,
662
+ minWords: words.length ? Math.min(...words) : 0,
663
+ maxWords: words.length ? Math.max(...words) : 0,
664
+ volumeCount: volumes.length
665
+ };
666
+
667
+ // 章号不连续 / 重复 / 倒退:记录下来,导入时按顺序重编号。
668
+ //
669
+ // 按**卷**分组判断:多卷小说每卷都从"第一章"重新编号,
670
+ // 跨卷同号是正常的,不该报"章号重复";同卷内同号才是真重复。
671
+ const seen = new Map(); // (卷号, 章号) → 已出现
672
+ const prevByVolume = new Map(); // 卷号 → 上一个章号
673
+ for (const c of chapters) {
674
+ if (c.no === null || c.no === undefined) continue;
675
+ const vk = String(c.volumeNo === null || c.volumeNo === undefined ? '' : c.volumeNo);
676
+ const sk = vk + '\u0000' + c.no;
677
+ if (seen.has(sk)) warnings.push('章号重复:' + c.no + '(' + c.sourceRel + ')');
678
+ seen.set(sk, true);
679
+ const prev = prevByVolume.get(vk);
680
+ if (prev !== undefined && c.no < prev) {
681
+ warnings.push('章号倒退:' + prev + ' → ' + c.no + '(' + c.sourceRel + ')');
682
+ }
683
+ prevByVolume.set(vk, c.no);
684
+ }
685
+
686
+ // 空章:目录页残留或切章失败。空章在写作阶段一律报 EMPTY_BODY,
687
+ // 与其让下游一地鸡毛,不如在这里明确说出来。
688
+ const empties = chapters.filter((c) => !String(c.body || '').trim());
689
+ if (empties.length) {
690
+ for (const c of empties) {
691
+ warnings.push('空章:第 ' + c.ch + ' 章(' + (c.sourceRel || '') + ')没有正文');
692
+ }
693
+ const ratio = chapters.length ? empties.length / chapters.length : 0;
694
+ // 阈值取"数量 > 3 且占比 > 1%":单个空章更可能是作者留白,只告警;
695
+ // 大量空章几乎一定是目录页残留或切章失败,这时**拒绝导入**,
696
+ // 而不是让用户在写作阶段才发现。
697
+ if (empties.length > 3 && ratio > 0.01) {
698
+ return {
699
+ ok: false,
700
+ error: '疑似目录页残留或切章失败:' + empties.length + '/' + chapters.length +
701
+ ' 章没有正文(占比 ' + Math.round(ratio * 100) + '%)。' +
702
+ '请确认旧稿的章节标题格式,或先把目录页删掉再导入。',
703
+ root: inputPath,
704
+ chapters: [],
705
+ settings: [],
706
+ volumes,
707
+ stats,
708
+ warnings
709
+ };
710
+ }
711
+ }
712
+
713
+ return { ok: true, error: null, root: inputPath, chapters, settings, volumes, stats, warnings };
714
+ }
715
+
716
+ // ============================================================
717
+ // quota 反推
718
+ // ============================================================
719
+
720
+ /**
721
+ * 按实体数组统计"实际规模"(与骨架校验器的配额口径一致)。
722
+ *
723
+ * 直接复用 skeleton-quota.js 的 QUOTA_MAP —— 那张表就是
724
+ * "quota 路径 ↔ 实体数组 + 过滤条件"的唯一真源,
725
+ * 这里再抄一份的话,实体类型枚举一改,两边就会对不上。
726
+ *
727
+ * 返回的键**不带 `quota.` 前缀**(与 QUOTA_MAP 的键一致)。
728
+ *
729
+ * @param {Object} entityData 实体.json 的内容
730
+ * @returns {Object} { 'character_counts.protagonist': 1, ... }
731
+ */
732
+ export function actualCountsByQuota(entityData) {
733
+ const out = {};
734
+ if (!entityData || typeof entityData !== 'object') return out;
735
+ for (const quotaPath of Object.keys(QUOTA_MAP)) {
736
+ const spec = QUOTA_MAP[quotaPath];
737
+ const arr = entityData[spec.arr];
738
+ if (!Array.isArray(arr)) continue;
739
+ let n = 0;
740
+ for (const item of arr) {
741
+ if (!item) continue;
742
+ if (spec.filter(item)) n += 1;
743
+ }
744
+ out[quotaPath] = n;
745
+ }
746
+ return out;
747
+ }
748
+
749
+ /**
750
+ * 反推 quota。
751
+ *
752
+ * 规则(按顺序):
753
+ * 1. **能反推就用实际值**(旧稿里真实有多少角色、多少势力…)——
754
+ * 导入的目标是复现旧稿,不是重造一本。
755
+ * 2. 实际值落在公式区间外 → **仍然用实际值**,但记一条"与公式冲突"。
756
+ * 不能改成区间内的值:quota 必须与实体条数一致
757
+ * (骨架校验器按 QUOTA_MISMATCH 核对实体数组条数),
758
+ * 把 quota 改成公式区间里的数,只会让"实体条数 ≠ quota"换个地方爆。
759
+ * 区间不符在骨架校验里是 warning(QUANTITY_MISMATCH),会出现在导入报告里。
760
+ * 3. 反推不出来(该类实体旧稿里根本没有)→ 按公式取下界,
761
+ * 记一条"按公式补齐"的决定。
762
+ *
763
+ * 依赖顺序有讲究:volume_count → 主线/支线 → 主角/核心配角/核心反派 →
764
+ * 核心冲突 → 其余。公式里 V/P/CS/CA/BC/CC 互相引用,
765
+ * 顺序错了区间会算错。
766
+ *
767
+ * @param {Object} o
768
+ * @param {number} o.targetChapters 旧稿实际章数
769
+ * @param {number} [o.volumeCount] 旧稿实际卷数
770
+ * @param {Object} [o.actualCounts] actualCountsByQuota() 的结果(键不带 quota. 前缀)
771
+ * @param {Object} formulas formulas-skeleton.json 的 formulas 段
772
+ * @returns {{quota:Object, decisions:Array, derivations:Object}}
773
+ */
774
+ export function reverseQuota(o) {
775
+ const formulas = o.formulas || {};
776
+ const actual = o.actualCounts || {};
777
+ const decisions = [];
778
+ const quota = {};
779
+
780
+ const setPath = (obj, path, value) => {
781
+ const parts = path.split('.');
782
+ let cur = obj;
783
+ for (let i = 0; i < parts.length - 1; i++) {
784
+ if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
785
+ cur = cur[parts[i]];
786
+ }
787
+ cur[parts[parts.length - 1]] = value;
788
+ };
789
+
790
+ // 求值用的框架上下文(公式引用 quota 里的其它字段)
791
+ const framework = {
792
+ capacity: { target_chapters: o.targetChapters },
793
+ quota
794
+ };
795
+
796
+ /**
797
+ * 处理一个 quota 字段。
798
+ * @param {string} quotaPath 如 'quota.branch_count'
799
+ * @param {number|null} actualValue 旧稿实际值(null = 反推不出来)
800
+ */
801
+ const resolve = (quotaPath, actualValue) => {
802
+ const ctx = buildCtx(framework);
803
+ const range = formulaRange(formulas, quotaPath, ctx);
804
+ const low = range ? Math.round(range.low) : null;
805
+ const high = range ? Math.round(range.high) : null;
806
+
807
+ let value = null;
808
+ let source = null;
809
+ let outOfRange = false;
810
+ let note = '';
811
+
812
+ if (actualValue !== null && actualValue !== undefined && isFinite(actualValue) && actualValue >= 0) {
813
+ value = Math.round(actualValue);
814
+ source = 'actual';
815
+ if (range && (value < low || value > high)) {
816
+ outOfRange = true;
817
+ // 保留实际值(不夹到区间里):quota 必须与实体条数一致,
818
+ // 夹取值只会把"区间 warning"换成"实体条数 ≠ quota"的 error。
819
+ // 越界只报 QUANTITY_MISMATCH(warning),骨架阶段能过。
820
+ note = '旧稿实际值 ' + value + ' 不在公式区间 [' + low + ', ' + high + '] 内;'
821
+ + '按旧稿实际规模保留(quota 必须与实体条数一致),骨架校验会报 QUANTITY_MISMATCH 警告';
822
+ } else {
823
+ note = '旧稿实际值落在公式区间内';
824
+ }
825
+ } else if (range) {
826
+ value = defaultInRange(range);
827
+ source = 'formula';
828
+ if (value === null) {
829
+ // 公式区间为空(下界 > 上界)—— 取任何值都不合法。
830
+ // 如实说明,别写一个"看起来像合法值"的数进去。
831
+ value = 1;
832
+ source = 'fallback';
833
+ note = '公式区间为空([' + low + ', ' + high + '],下界 > 上界),暂取 1;'
834
+ + '请调 capacity.target_chapters 或相关的 quota 字段让区间恢复非空';
835
+ } else {
836
+ note = '旧稿推不出来,按公式取下界(区间 [' + low + ', ' + high + '])';
837
+ }
838
+ } else {
839
+ value = 1;
840
+ source = 'fallback';
841
+ note = '既无实际值也无公式,取 1';
842
+ }
843
+
844
+ setPath(quota, quotaPath.replace(/^quota\./, ''), value);
845
+ decisions.push({ field: quotaPath, value, source, low, high, outOfRange, note });
846
+ return value;
847
+ };
848
+
849
+ // ---- 依赖顺序 ----
850
+ // 卷数(T → V)
851
+ resolve('quota.volume_count', o.volumeCount && o.volumeCount > 0 ? o.volumeCount : null);
852
+
853
+ // 主角 / 核心配角 / 核心反派(P / CS / CA)—— 角色类的分项配额
854
+ resolve('quota.character_counts.protagonist', pick(actual, 'quota.character_counts.protagonist'));
855
+ resolve('quota.character_counts.core_supporting', pick(actual, 'quota.character_counts.core_supporting'));
856
+ resolve('quota.character_counts.core_antagonist', pick(actual, 'quota.character_counts.core_antagonist'));
857
+
858
+ // 支线(BC)
859
+ resolve('quota.branch_count', pick(actual, 'quota.branch_count'));
860
+ // 核心冲突(CC)
861
+ resolve('quota.conflict_counts.core', pick(actual, 'quota.conflict_counts.core'));
862
+ // 主线(固定公式)
863
+ resolve('quota.main_line_count', pick(actual, 'quota.main_line_count'));
864
+
865
+ // 其余全部字段
866
+ const rest = [
867
+ 'quota.character_counts.volume_supporting',
868
+ 'quota.character_counts.volume_antagonist',
869
+ 'quota.conflict_counts.phase',
870
+ 'quota.conflict_counts.branch',
871
+ 'quota.faction_count',
872
+ 'quota.faction_relation_count',
873
+ 'quota.location_count',
874
+ 'quota.ending_count',
875
+ 'quota.foreshadow_counts.small',
876
+ 'quota.foreshadow_counts.medium',
877
+ 'quota.foreshadow_counts.large',
878
+ 'quota.climax_counts.small',
879
+ 'quota.climax_counts.medium',
880
+ 'quota.climax_counts.large',
881
+ 'quota.pleasure_counts.small',
882
+ 'quota.pleasure_counts.medium',
883
+ 'quota.pleasure_counts.large',
884
+ 'quota.item_counts.core',
885
+ 'quota.item_counts.branch',
886
+ 'quota.item_counts.symbolic',
887
+ 'quota.world_rule_counts.core',
888
+ 'quota.world_rule_counts.information',
889
+ 'quota.world_rule_counts.social'
890
+ ];
891
+ for (const p of rest) resolve(p, pick(actual, p));
892
+
893
+ // ---- 公式推导记录(骨架校验器要求 30 条齐全,key 与 formulas-skeleton.json 一致) ----
894
+ // 每条:{ formula(逐字照抄), range_low, range_high, note }
895
+ // 终值超出区间时 note 必须写明理由(否则 QUANTITY_MISMATCH 只是个没头没尾的警告)。
896
+ const byField = {};
897
+ for (const d of decisions) byField[d.field] = d;
898
+ const derivations = {};
899
+ for (const key of Object.keys(formulas)) {
900
+ const spec = formulas[key];
901
+ if (!spec) continue;
902
+ const field = spec.applies_to_field;
903
+ const d = byField[field];
904
+ const range = formulaRange(formulas, field, buildCtx(framework));
905
+ const low = range ? Math.round(range.low) : 0;
906
+ const high = range ? Math.round(range.high) : 0;
907
+ let note = '';
908
+ if (d && d.outOfRange) {
909
+ note = '终值 ' + d.value + ' 按旧稿实际规模保留,落在公式区间 [' + low + ', ' + high + '] 之外';
910
+ } else if (d && d.source === 'formula') {
911
+ note = '旧稿推不出来,按公式取下界';
912
+ }
913
+ derivations[key] = {
914
+ formula: spec.formula,
915
+ range_low: low,
916
+ range_high: high,
917
+ note: note
918
+ };
919
+ }
920
+
921
+ return { quota, decisions, derivations };
922
+ }
923
+
924
+ /**
925
+ * 最大余数法分配整数。
926
+ *
927
+ * 给定每个槽位的权重和要分配的总份数,按权重比例分整数份,
928
+ * 余数大的先拿 —— 保证:
929
+ * - 每份都是**整数**(不会分出 0.5 章)
930
+ * - 所有份数加起来**恰好等于**总数(不丢不重)
931
+ * - 每份都 ≥ 0
932
+ *
933
+ * @param {number[]} weights 各槽位的权重(非负)
934
+ * @param {number} total 要分配的总份数
935
+ * @param {number} slots 槽位数
936
+ * @returns {number[]} 长度 = slots
937
+ */
938
+ export function largestRemainder(weights, total, slots) {
939
+ const n = Math.max(0, Math.round(slots));
940
+ const out = new Array(n).fill(0);
941
+ if (!n) return out;
942
+ const t = Math.max(0, Math.round(total));
943
+ if (!t) return out;
944
+
945
+ let w = Array.isArray(weights) ? weights.slice(0, n) : [];
946
+ while (w.length < n) w.push(0);
947
+ w = w.map((x) => {
948
+ const v = Number(x);
949
+ return isFinite(v) && v > 0 ? v : 0;
950
+ });
951
+ const wsum = w.reduce((a, b) => a + b, 0);
952
+ // 全是 0 权重 → 均分
953
+ const eff = wsum > 0 ? w : new Array(n).fill(1);
954
+ const esum = eff.reduce((a, b) => a + b, 0);
955
+
956
+ const quota = eff.map((x) => (t * x) / esum);
957
+ const floors = quota.map((x) => Math.floor(x));
958
+ let rest = t - floors.reduce((a, b) => a + b, 0);
959
+ // 余数大的先拿;余数相同按下标小的先拿(结果确定,不随运行变化)
960
+ const order = quota
961
+ .map((x, i) => ({ i, frac: x - Math.floor(x) }))
962
+ .sort((a, b) => (b.frac - a.frac) || (a.i - b.i));
963
+ for (const o of order) {
964
+ if (rest <= 0) break;
965
+ floors[o.i] += 1;
966
+ rest -= 1;
967
+ }
968
+ return floors;
969
+ }
970
+
971
+ /**
972
+ * 生成 volume_ratio(每卷占多少章)。
973
+ *
974
+ * 骨架校验器要求:长度 = quota.volume_count,各项 chapter_ratio 之和 = 1.0。
975
+ *
976
+ * 优先用**旧稿的实际分卷**:每卷实际写了多少章,就占多少比例。
977
+ * 旧稿没分卷信息时才均分(opts.hasVolumeInfo === false)。
978
+ *
979
+ * ## 为什么用最大余数法而不是"各算各的再让最后一项吃差额"
980
+ *
981
+ * 旧实现最后一项吃差额,于是:
982
+ * buildVolumeRatio(30, [29 个 1000, 1]) → 前 29 项 0.0345,最后一项 **-0.0005**
983
+ * 负比例会让节奏阶段算出"期望约 -2 章",负数还会一路传下去。
984
+ *
985
+ * 现在先按权重分配**整数章数**(每份天然 ≥ 0、加起来恰好等于总章数),
986
+ * 再把整数转成比例(放大到万分之一单位后用最大余数法补齐),
987
+ * 于是每一项都在 [0,1]、没有负数、和**精确等于** 1。
988
+ *
989
+ * @param {number} volumeCount
990
+ * @param {number[]} [chaptersPerVolume] 每卷实际章数(按卷序)
991
+ * @param {Object} [opts]
992
+ * @param {number} [opts.total] 总章数(默认 = chaptersPerVolume 之和)
993
+ * @param {boolean} [opts.hasVolumeInfo] 旧稿是否有分卷信息(默认 true)
994
+ * @returns {Array<{volume_id:string, chapter_ratio:number}>}
995
+ */
996
+ export function buildVolumeRatio(volumeCount, chaptersPerVolume, opts = {}) {
997
+ const v = Math.max(0, Math.round(volumeCount || 0));
998
+ if (!v) return [];
999
+
1000
+ let counts = Array.isArray(chaptersPerVolume)
1001
+ ? chaptersPerVolume.slice(0, v).map((c) => {
1002
+ const n = Number(c);
1003
+ return isFinite(n) && n > 0 ? n : 0;
1004
+ })
1005
+ : [];
1006
+ while (counts.length < v) counts.push(0);
1007
+
1008
+ const counted = counts.reduce((a, b) => a + b, 0);
1009
+ const hasVolumeInfo = opts.hasVolumeInfo !== false;
1010
+ const total = opts.total !== undefined && opts.total !== null && isFinite(opts.total)
1011
+ ? Math.max(0, Math.round(opts.total))
1012
+ : counted;
1013
+
1014
+ // 权重:旧稿有分卷信息就按各卷实际章数;没有就均分(等权)
1015
+ const weights = hasVolumeInfo ? counts : new Array(v).fill(1);
1016
+ // 比例的分母:真实总章数。旧稿一章都没有时(total=0)没有分母可言,
1017
+ // 这时按"每卷 1 份"均分 —— 骨架只要求各项加起来等于 1。
1018
+ const base = total > 0 ? total : v;
1019
+ const alloc = largestRemainder(weights, base, v);
1020
+
1021
+ // 整数 → 比例(放大到万分之一单位,再用最大余数法补齐,和恰好是 1)
1022
+ const UNIT = 10000;
1023
+ const scaled = alloc.map((a) => (a * UNIT) / base);
1024
+ const floors = scaled.map((x) => Math.floor(x));
1025
+ let rest = UNIT - floors.reduce((a, b) => a + b, 0);
1026
+ const order = scaled
1027
+ .map((x, i) => ({ i, frac: x - Math.floor(x) }))
1028
+ .sort((a, b) => (b.frac - a.frac) || (a.i - b.i));
1029
+ for (const o of order) {
1030
+ if (rest <= 0) break;
1031
+ floors[o.i] += 1;
1032
+ rest -= 1;
1033
+ }
1034
+
1035
+ const out = [];
1036
+ for (let i = 0; i < v; i++) {
1037
+ out.push({
1038
+ volume_id: 'V' + String(i + 1).padStart(3, '0'),
1039
+ chapter_ratio: floors[i] / UNIT
1040
+ });
1041
+ }
1042
+ return out;
1043
+ }
1044
+
1045
+ /**
1046
+ * 取实际值。
1047
+ *
1048
+ * 键的两种形态都认:`quota.faction_count`(公式里的路径写法)
1049
+ * 与 `faction_count`(QUOTA_MAP 的键写法)。
1050
+ * 两个来源的键形态不一致是历史遗留,硬要调用方记住一种太容易出错。
1051
+ *
1052
+ * **0 是合法统计值,不是"推不出来"**(2026-10-27 修):
1053
+ * 旧实现只接受 v > 0,于是"旧稿里一个势力都没有"(actual=0)
1054
+ * 被当成"反推不出来",改用公式下界 → quota 变成 2,
1055
+ * 而实体数组里 factions 是 0 条 → 骨架阶段 QUOTA_MISMATCH(error),
1056
+ * 导入却报成功。旧稿本来就没有的东西,不该凭空要求模型造出来。
1057
+ *
1058
+ * @returns {number|null} null = 缺失(没有这个统计项)
1059
+ */
1060
+ function pick(actual, key) {
1061
+ const bare = key.replace(/^quota\./, '');
1062
+ const v = actual[key] !== undefined ? actual[key] : actual[bare];
1063
+ return typeof v === 'number' && isFinite(v) && v >= 0 ? v : null;
1064
+ }
1065
+
1066
+ /**
1067
+ * 造公式求值用的变量表。
1068
+ * 每次 resolve 之后要重建 —— quota 变了,依赖它的区间也会变。
1069
+ */
1070
+ function buildCtx(framework) {
1071
+ const quota = framework.quota || {};
1072
+ const get = (path) => {
1073
+ const parts = path.split('.');
1074
+ let cur = quota;
1075
+ for (const p of parts) {
1076
+ if (!cur || typeof cur !== 'object') return undefined;
1077
+ cur = cur[p];
1078
+ }
1079
+ return cur;
1080
+ };
1081
+ return {
1082
+ T: framework.capacity ? framework.capacity.target_chapters : undefined,
1083
+ V: get('volume_count'),
1084
+ P: get('character_counts.protagonist'),
1085
+ CS: get('character_counts.core_supporting'),
1086
+ CA: get('character_counts.core_antagonist'),
1087
+ BC: get('branch_count'),
1088
+ CC: get('conflict_counts.core'),
1089
+ quota,
1090
+ Math
1091
+ };
1092
+ }
1093
+
1094
+ // ============================================================
1095
+ // 落盘
1096
+ // ============================================================
1097
+
1098
+ /**
1099
+ * 章节正文 → story-builder 格式(第一行必须是 `# 第N章 <标题>`)。
1100
+ *
1101
+ * @param {Object} chapter scanSource 里的章节
1102
+ * @param {number} seq 顺序章号(1..N)
1103
+ * @returns {string}
1104
+ */
1105
+ export function buildChapterFile(chapter, seq) {
1106
+ const head = '# 第' + seq + '章 ' + chapter.title;
1107
+ const body = String(chapter.body || '').trim();
1108
+ return body ? head + '\n\n' + body + '\n' : head + '\n';
1109
+ }
1110
+
1111
+ /**
1112
+ * 章节文件名:`第NNN章.md`(三位补零,与写作阶段一致)。
1113
+ * @param {number} seq
1114
+ * @returns {string}
1115
+ */
1116
+ export function chapterFileName(seq) {
1117
+ return '第' + String(seq).padStart(3, '0') + '章.md';
1118
+ }