@cloud411716/fancy-webnovel 1.0.45 → 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 -160
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -275
  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,292 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fancy-analyze split_source.js — 拆分长文本源文件
4
+ *
5
+ * 按"章"切分源文件,生成多份 .txt 分片,方便 LLM 分批读取。
6
+ *
7
+ * 切分逻辑:
8
+ * - 按 "第N章" / "第 N 章" / "Chapter N" / "chapter N" 等正则匹配
9
+ * - 每章单独输出到一个分片文件
10
+ * - 文件名格式: split_<3位零填充>_<章号>_<章标题>.txt
11
+ *
12
+ * CLI 模式 A(按书名查找):
13
+ * node split_source.js \
14
+ * --project-root <abs> \
15
+ * --book-name <书名>
16
+ *
17
+ * CLI 模式 B(直接指定源文件):
18
+ * node split_source.js \
19
+ * --source <abs-path-to-file> \
20
+ * --output-dir <abs-path-to-output-dir>
21
+ *
22
+ * 输出:
23
+ * <output-dir>/split_<NNN>.txt
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, statSync, unlinkSync } from 'fs';
27
+ import { join, dirname, resolve } from 'path';
28
+ import { fileURLToPath } from 'url';
29
+
30
+ import { cnToNumber } from '../../../lib/chinese-num.js';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+
34
+ // 多种章标记正则(按优先级)
35
+ const CHAPTER_PATTERNS = [
36
+ /^\s*第\s*([零〇一二两三四五六七八九十百千万亿\d]+)\s*章\s*[、\.\s ]*(.*)$/mgu,
37
+ /^\s*Chapter\s*(\d+)\s*[、\.\s:]*(.*)$/mgu,
38
+ /^\s*chapter\s*(\d+)\s*[、\.\s:]*(.*)$/mgu,
39
+ /^\s*CHAPTER\s*(\d+)\s*[、\.\s:]*(.*)$/mgu,
40
+ ];
41
+
42
+ /**
43
+ * 中文数字 → 阿拉伯数字。
44
+ *
45
+ * 2026-10-27:这份转换原本是本文件私有的,而且比仓库里其它地方弱 ——
46
+ * 只认到"千"、没有"两"、算法也不对("十二万"会算成 20010)。
47
+ * 现在统一走 lib/chinese-num.js 的 cnToNumber,全仓库一个口径。
48
+ */
49
+ const CN_DIGIT_CHARS = '零〇一二两三四五六七八九十百千万亿';
50
+
51
+ function findChapterStarts(text) {
52
+ const starts = [];
53
+ for (const pat of CHAPTER_PATTERNS) {
54
+ for (const m of text.matchAll(pat)) {
55
+ const numStr = m[1];
56
+ const num = cnToNumber(numStr);
57
+ if (num !== null && num > 0) {
58
+ const title = m[2] ? m[2].trim() : '';
59
+ starts.push({ pos: m.index, num, title });
60
+ }
61
+ }
62
+ }
63
+ // 去重 + 排序
64
+ const seen = new Set();
65
+ const unique = [];
66
+ for (const item of starts.sort((a, b) => a.pos - b.pos)) {
67
+ const key = `${item.num}_${Math.floor(item.pos / 100)}`;
68
+ if (!seen.has(key)) {
69
+ seen.add(key);
70
+ unique.push(item);
71
+ }
72
+ }
73
+ return unique;
74
+ }
75
+
76
+ function safeFilename(name) {
77
+ // 去掉所有标点符号和空白字符
78
+ return name.replace(/[\\/:*?"<>|\s.,,、。;;‘’""【】《》()()!!??_]/g, '').substring(0, 30);
79
+ }
80
+
81
+ function outputJson(data) {
82
+ console.log(JSON.stringify(data, null, 2));
83
+ }
84
+
85
+ /** 单个分片的最小字符数。小于这个值的分片对 LLM 没有意义,
86
+ * 而且 <1000 会让"第 0 章""abc"这类脏参数变成 0 → 无限循环。 */
87
+ const MIN_CHARS_PER_SPLIT = 1000;
88
+
89
+ /**
90
+ * 本脚本产出的分片文件名。
91
+ *
92
+ * 清理旧分片时**只删这些**,不能删目录下所有 .txt ——
93
+ * 输出目录有可能就是用户的资料目录/旧稿目录,
94
+ * 删光 .txt 等于替用户把资料删了。
95
+ */
96
+ const OWNED_SPLIT_FILE_RE = /^(\d{3}\.txt|第\d+章.*\.txt)$/;
97
+
98
+ function main() {
99
+ const args = process.argv.slice(2);
100
+ let sourcePath = null;
101
+ let projectRoot = null;
102
+ let outputDir = null;
103
+ let maxCharsPerSplit = 80000;
104
+
105
+ // 简单参数解析
106
+ for (let i = 0; i < args.length; i++) {
107
+ if (args[i] === '--source' && i + 1 < args.length) {
108
+ sourcePath = resolve(args[++i]);
109
+ } else if (args[i] === '--project-root' && i + 1 < args.length) {
110
+ projectRoot = resolve(args[++i]);
111
+ } else if (args[i] === '--book-name' && i + 1 < args.length) {
112
+ // 从项目根找书名的逻辑在调用方处理,这里不重复
113
+ i++;
114
+ } else if (args[i] === '--output-dir' && i + 1 < args.length) {
115
+ outputDir = resolve(args[++i]);
116
+ } else if (args[i] == '--max-chars-per-split' && i + 1 < args.length) {
117
+ const raw = args[++i];
118
+ const n = Number(raw);
119
+ // 必须严格校验:旧实现用 parseInt,'0abc' 会变成 0,
120
+ // 而分片循环是 `for (let i = 0; i < total; i += chunkSize)` ——
121
+ // chunkSize=0 就是**无限循环**,一直写文件直到磁盘满。
122
+ // 负数同理(i 递减)。NaN 则写出空文件。
123
+ if (!Number.isInteger(n) || n < MIN_CHARS_PER_SPLIT) {
124
+ outputJson({
125
+ ok: false,
126
+ error: {
127
+ code: 6,
128
+ code_name: 'EC_BAD_USAGE',
129
+ message: '--max-chars-per-split 必须是 >= ' + MIN_CHARS_PER_SPLIT + ' 的整数(当前:' + raw + ')'
130
+ }
131
+ });
132
+ process.exit(6);
133
+ }
134
+ maxCharsPerSplit = n;
135
+ }
136
+ }
137
+
138
+ // 至少需要 source + output-dir
139
+ if (!sourcePath || !outputDir) {
140
+ outputJson({
141
+ ok: false,
142
+ error: { code: 6, code_name: 'EC_BAD_USAGE', message: '必须传 --source 和 --output-dir' }
143
+ });
144
+ process.exit(6);
145
+ }
146
+
147
+ // 检查源文件
148
+ let stat;
149
+ try {
150
+ stat = statSync(sourcePath);
151
+ } catch {
152
+ outputJson({ ok: false, error: { code: 1, code_name: 'EC_SOURCE_MISSING', message: `源文件不存在: ${sourcePath}` } });
153
+ process.exit(1);
154
+ }
155
+ if (!stat.isFile()) {
156
+ outputJson({ ok: false, error: { code: 1, code_name: 'EC_SOURCE_MISSING', message: `源文件不是文件: ${sourcePath}` } });
157
+ process.exit(1);
158
+ }
159
+
160
+ // 输出目录不能就是源文件所在的目录 ——
161
+ // 清理旧分片会把源文件一起删掉(源文件通常也是 .txt),
162
+ // 然后 readFileSync 失败,用户的旧稿就这么没了。
163
+ const sourceDir = dirname(sourcePath);
164
+ if (sourceDir === outputDir) {
165
+ outputJson({
166
+ ok: false,
167
+ error: {
168
+ code: 6,
169
+ code_name: 'EC_BAD_USAGE',
170
+ message: '--output-dir 不能和源文件在同一目录(会把源文件当旧分片删掉):' + outputDir
171
+ }
172
+ });
173
+ process.exit(6);
174
+ }
175
+
176
+ // 创建输出目录
177
+ mkdirSync(outputDir, { recursive: true });
178
+
179
+ // 清掉上一次拆分留下的分片。
180
+ //
181
+ // 这个目录是 llm_wiki 的 ingest 输入,"重新分析同一本书"时如果不清,
182
+ // 上一版的旧分片会一直躺在里面 —— 目录内容和源文件对不上,
183
+ // 用户以为在分析新版,拿到的却是新旧混合的产物(实测:源文件从
184
+ // 3 章改成 1 章后重跑,目录里仍然是 3 个文件)。
185
+ //
186
+ // **只删本脚本产出的文件**(OWNED_SPLIT_FILE_RE):
187
+ // 旧实现删的是目录下**所有** .txt —— 输出目录如果是用户的资料目录,
188
+ // 这一删就是替用户把资料全删了。
189
+ try {
190
+ for (const f of readdirSync(outputDir)) {
191
+ if (OWNED_SPLIT_FILE_RE.test(f)) {
192
+ try { unlinkSync(join(outputDir, f)); } catch { /* 单个删不掉就跳过 */ }
193
+ }
194
+ }
195
+ } catch {
196
+ // 目录读不到就当没有旧分片
197
+ }
198
+
199
+ // 读取文件并统一转为 UTF-8
200
+ let text;
201
+ try {
202
+ const raw = readFileSync(sourcePath); // 读取原始字节
203
+ // 检测是否为有效 UTF-8:尝试用 UTF-8 解码
204
+ try {
205
+ const decoder = new TextDecoder('utf-8', { fatal: true });
206
+ text = decoder.decode(raw);
207
+ } catch {
208
+ // UTF-8 解码失败(fatal),说明是 GBK 编码
209
+ text = new TextDecoder('gbk').decode(raw);
210
+ }
211
+ } catch (err) {
212
+ outputJson({ ok: false, error: { code: 7, code_name: 'EC_READ_ERROR', message: `读取源文件失败: ${err.message}` } });
213
+ process.exit(7);
214
+ }
215
+
216
+ if (!text.trim()) {
217
+ outputJson({ ok: false, error: { code: 2, code_name: 'EC_SOURCE_EMPTY', message: '来源文件为空' } });
218
+ process.exit(2);
219
+ }
220
+
221
+ // 找章节起始
222
+ const chapterStarts = findChapterStarts(text);
223
+ const written = [];
224
+
225
+ if (chapterStarts.length === 0) {
226
+ // 没找到章标记 → 按 maxCharsPerSplit 切分
227
+ const chunkSize = maxCharsPerSplit;
228
+ const total = text.length;
229
+ for (let i = 0; i < total; i += chunkSize) {
230
+ const partNum = written.length + 1;
231
+ const partPath = join(outputDir, `${String(partNum).padStart(3, '0')}.txt`);
232
+ writeFileSync(partPath, text.slice(i, i + chunkSize), 'utf-8');
233
+ written.push(`${String(partNum).padStart(3, '0')}.txt`);
234
+ }
235
+ } else {
236
+ // 按章节切分
237
+ for (let i = 0; i < chapterStarts.length; i++) {
238
+ const { pos, num, title } = chapterStarts[i];
239
+ const end = i + 1 < chapterStarts.length ? chapterStarts[i + 1].pos : text.length;
240
+ // 去掉开头的空白行和章节标题行(如"第一章 xxx")
241
+ const rawText = text.slice(pos, end);
242
+ const lines = rawText.split('\n');
243
+ let startIndex = 0;
244
+ for (let j = 0; j < lines.length; j++) {
245
+ if (lines[j].trim() === '') {
246
+ startIndex = j + 1;
247
+ } else {
248
+ break;
249
+ }
250
+ }
251
+ // 跳过章节标题行(第一行非空白内容)
252
+ const chapterText = lines.slice(startIndex + 1).join('\n');
253
+ const safeTitle = safeFilename(title);
254
+ let base = '';
255
+ if (safeTitle) {
256
+ base = `第${num}章-${safeTitle}`;
257
+ } else {
258
+ base = `第${num}章`;
259
+ }
260
+ // 超长的章要按 maxCharsPerSplit **二次切分** ——
261
+ // 旧实现只看 chapterStarts 的章边界,一个 20 万字的章会原样写成一个
262
+ // 分片,仍然远超 LLM 上下文,ingest 时照样被截断。
263
+ const pieces = [];
264
+ if (chapterText.length <= maxCharsPerSplit) {
265
+ pieces.push(chapterText);
266
+ } else {
267
+ for (let j = 0; j < chapterText.length; j += maxCharsPerSplit) {
268
+ pieces.push(chapterText.slice(j, j + maxCharsPerSplit));
269
+ }
270
+ }
271
+ for (let k = 0; k < pieces.length; k++) {
272
+ const suffix = pieces.length > 1 ? '-' + String(k + 1) : '';
273
+ const filename = base + suffix + '.txt';
274
+ const partPath = join(outputDir, filename);
275
+ writeFileSync(partPath, pieces[k], 'utf-8');
276
+ written.push(filename);
277
+ }
278
+ }
279
+ }
280
+
281
+ outputJson({
282
+ ok: true,
283
+ operation: 'split-source',
284
+ source_path: sourcePath,
285
+ source_chars: text.length,
286
+ chapter_count: chapterStarts.length,
287
+ split_files: written,
288
+ split_count: written.length,
289
+ });
290
+ }
291
+
292
+ main();