@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,821 @@
1
+ /**
2
+ * deslop-driver.js
3
+ * 去 AI 味阶段:插件驱动、逐章执行
4
+ *
5
+ * 设计原则(与写作阶段一致):
6
+ * **插件当司机,LLM 只按指令改文件;脚本当裁判。**
7
+ *
8
+ * 为什么不让脚本直接改正文:
9
+ * 旧实现的 9 条正则替换会把句子改坏 —— 把"此外,"直接删掉,句子就散了;
10
+ * 把"至关重要"换成"重要",语义就飘了。AI 腔是句式问题,不是词的问题,
11
+ * 机械替换救不了。
12
+ *
13
+ * 所以本驱动只做四件事:
14
+ * 1. 派提示词让模型按 24 类清单改写(清单在 scripts/ai-patterns.js,唯一真源)
15
+ * 2. 改写后**自己跑 deslop-validate.js 复扫**,blocking 没清零不算改干净
16
+ * 3. 算改动比例(changeRatio),超过 35% 自动回滚 —— 旧 SKILL.md 承诺了,
17
+ * 但旧代码里根本没有这个上限
18
+ * 4. 生成报告,把"改了什么、还剩什么"摆出来
19
+ *
20
+ * 本模块不直接依赖 dsh 运行时:sendPrompt / waitIdle / detect 都由调用方注入,
21
+ * 因此可以在没有 LLM 的情况下用测试夹具驱动。
22
+ */
23
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
24
+ import { join } from 'path';
25
+
26
+ import { changeRatio, deslopLevel, DESLOP_ROLLBACK_RATIO } from './scripts/ai-patterns.js';
27
+ import { countEffectiveChars } from './scripts/content-quality.js';
28
+ import { chapterFileName } from './writing-driver.js';
29
+ import { cnToNumber } from '#chinese-num';
30
+
31
+ export { cnToNumber };
32
+
33
+ /** 单章最大尝试次数(含首次) */
34
+ export const DESLOP_MAX_ATTEMPTS = 3;
35
+ /** 备份目录名(放在写作目录里,与正文同源) */
36
+ export const DESLOP_REVISIONS_DIRNAME = '.revisions';
37
+ /** 报告目录(项目根下) */
38
+ export const DESLOP_REPORTS_DIRNAME = '审查报告';
39
+ /**
40
+ * 字数容差:与写作阶段的 ±20% 一致。
41
+ * 改动比例(编辑距离)管不住"只加不减"—— 凭空多写 20% 的字,
42
+ * 编辑距离只有 17%,但已经超出写作阶段的字数容差了。
43
+ */
44
+ export const DESLOP_WORD_TOLERANCE = 0.2;
45
+
46
+ // ============================================================
47
+ // 路径与目录
48
+ // ============================================================
49
+
50
+ /**
51
+ * 去味阶段的目录布局。
52
+ * @param {string} projectRoot
53
+ */
54
+ export function deslopDirs(projectRoot) {
55
+ const src = join(projectRoot, '设定', 'raw', 'sources');
56
+ const writing = join(src, '写作');
57
+ return {
58
+ sources: src,
59
+ writing,
60
+ revisions: join(writing, DESLOP_REVISIONS_DIRNAME),
61
+ reports: join(projectRoot, DESLOP_REPORTS_DIRNAME)
62
+ };
63
+ }
64
+
65
+ /**
66
+ * 列出已写出的章文件(按章号升序)。
67
+ * 只认 `第NNN章.md` —— 与写作阶段的落盘约定一致。
68
+ *
69
+ * @param {string} projectRoot
70
+ * @returns {Array<{ch:number,name:string,path:string}>}
71
+ */
72
+ export function listChapterFiles(projectRoot) {
73
+ const dirs = deslopDirs(projectRoot);
74
+ let files = [];
75
+ try {
76
+ if (existsSync(dirs.writing)) files = readdirSync(dirs.writing);
77
+ } catch {
78
+ files = [];
79
+ }
80
+ const out = [];
81
+ for (const name of files) {
82
+ const m = /^第(\d+)章\.md$/.exec(name);
83
+ if (!m) continue;
84
+ out.push({ ch: Number(m[1]), name, path: join(dirs.writing, name) });
85
+ }
86
+ out.sort((a, b) => a.ch - b.ch);
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * 备份原文到 `.revisions/`。
92
+ * 备份失败必须**报错**(旧 skill 的 EC017)—— 没有备份就没有回滚能力,
93
+ * 后面那一步"改太多就回滚"就是空话。
94
+ *
95
+ * @returns {string} 备份文件路径
96
+ * @throws {Error} 备份失败时抛出
97
+ */
98
+ export function backupChapter(dirs, ch, text) {
99
+ mkdirSync(dirs.revisions, { recursive: true });
100
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
101
+ const path = join(dirs.revisions, '第' + String(ch).padStart(3, '0') + '章_' + stamp + '.md');
102
+ writeFileSync(path, text, 'utf-8');
103
+ return path;
104
+ }
105
+
106
+ /**
107
+ * 回滚到备份内容。
108
+ * @returns {boolean} 是否写回成功
109
+ */
110
+ export function restoreChapter(path, text) {
111
+ try {
112
+ writeFileSync(path, text, 'utf-8');
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ // ============================================================
120
+ // 章节完整性(改写不能把章改坏)
121
+ // ============================================================
122
+
123
+ /**
124
+ * 归一化标题行:把章号统一成阿拉伯数字、去掉前导零。
125
+ *
126
+ * 数字转换本身搬到 lib/chinese-num.js(导入阶段读旧稿的"第十二章"用同一份)。
127
+ *
128
+ * @param {string} line
129
+ * @returns {string}
130
+ */
131
+ export function normalizeTitleLine(line) {
132
+ const raw = String(line || '').trim();
133
+ if (!raw) return '';
134
+ return raw.replace(/第\s*([0-9]+|[〇零一二三四五六七八九十百千万两]+)\s*章/, (m, no) => {
135
+ const n = cnToNumber(no);
136
+ return n === null ? m : '第' + n + '章';
137
+ });
138
+ }
139
+
140
+ /**
141
+ * 检查改写有没有把章节改坏。
142
+ *
143
+ * 去味只该改 AI 味,不该顺手把标题改了、把字数改了、把正文删空了。
144
+ * 改动比例(编辑距离)管不住"只加不减"—— 凭空多写 20% 的字,
145
+ * 编辑距离只有 17%,但已经超出写作阶段的 ±20% 字数容差了。
146
+ *
147
+ * @param {string} before 改前正文
148
+ * @param {string} after 改后正文
149
+ * @returns {{ok:boolean, problems:string[]}}
150
+ */
151
+ export function checkChapterIntegrity(before, after) {
152
+ const problems = [];
153
+ const bTitle = String(before || '').split('\n')[0] || '';
154
+ const aTitle = String(after || '').split('\n')[0] || '';
155
+
156
+ if (bTitle.trim() && normalizeTitleLine(aTitle) !== normalizeTitleLine(bTitle)) {
157
+ problems.push('章节标题行被改了(原文「' + bTitle.trim() + '」,现在「' + aTitle.trim() + '」)');
158
+ }
159
+
160
+ const bWords = countEffectiveChars(before);
161
+ const aWords = countEffectiveChars(after);
162
+ if (bWords > 0) {
163
+ const delta = Math.abs(aWords - bWords) / bWords;
164
+ if (delta > DESLOP_WORD_TOLERANCE) {
165
+ problems.push('字数变化 ' + Math.round(delta * 100) + '%,超过 ±' +
166
+ Math.round(DESLOP_WORD_TOLERANCE * 100) + '%(' + bWords + ' → ' + aWords + ')');
167
+ }
168
+ }
169
+ if (bWords >= 50 && aWords < 50) {
170
+ problems.push('正文被清空了(有效字符 ' + aWords + ' < 50)');
171
+ }
172
+
173
+ return { ok: problems.length === 0, problems };
174
+ }
175
+
176
+ // ============================================================
177
+ // 提示词
178
+ // ============================================================
179
+
180
+ /**
181
+ * 取出命中行的原文(给模型看它到底要改哪几句)。
182
+ * @param {string} text
183
+ * @param {Array<{line:number}>} hits
184
+ * @param {number} [maxLines]
185
+ * @returns {Array<{line:number, text:string}>}
186
+ */
187
+ export function extractOffendingLines(text, hits, maxLines = 12) {
188
+ const lines = String(text || '').split('\n');
189
+ const seen = new Set();
190
+ const out = [];
191
+ for (const h of hits) {
192
+ if (!h || typeof h.line !== 'number') continue;
193
+ if (seen.has(h.line)) continue;
194
+ seen.add(h.line);
195
+ const content = (lines[h.line - 1] || '').trim();
196
+ out.push({ line: h.line, text: content });
197
+ if (out.length >= maxLines) break;
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * 把校验器的检出结果整理成"类目 → 命中行"的清单。
204
+ * @param {Object} detection deslop-validate.js 的 JSON 输出
205
+ * @param {string} text 原文
206
+ * @returns {Array<{id:string,no:number,name:string,group:string,severity:string,count:number,rewrite:string,lines:Array}>}
207
+ */
208
+ export function summarizeDetection(detection, text) {
209
+ const byCategory = (detection && detection.byCategory) || {};
210
+ const out = [];
211
+ for (const id of Object.keys(byCategory)) {
212
+ const c = byCategory[id];
213
+ if (!c) continue;
214
+ out.push({
215
+ id: c.id || id,
216
+ no: c.no,
217
+ name: c.name,
218
+ group: c.group,
219
+ severity: c.severity,
220
+ count: c.count,
221
+ rewrite: c.rewrite || '',
222
+ lines: extractOffendingLines(text, c.matches || [])
223
+ });
224
+ }
225
+ // blocking 在前,同级按编号排 —— 模型先处理最该改的
226
+ out.sort((a, b) => {
227
+ if (a.severity !== b.severity) return a.severity === 'blocking' ? -1 : 1;
228
+ return (a.no || 0) - (b.no || 0);
229
+ });
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * 构建一章的去味提示词。
235
+ *
236
+ * @param {Object} o
237
+ * @param {{ch:number,name:string,path:string}} o.chapter
238
+ * @param {string} o.contract 去味执行须知(产物契约)
239
+ * @param {Object} o.detection 首次检出结果(deslop-validate.js 的 JSON)
240
+ * @param {string} o.text 当前正文
241
+ * @param {number} [o.attempt] 第几次尝试
242
+ * @param {Object} [o.prev] 上一次尝试的结果 {ratio, exceeded, blockingCount, note}
243
+ * @returns {string}
244
+ */
245
+ export function buildDeslopPrompt(o) {
246
+ const chapter = o.chapter;
247
+ const attempt = o.attempt || 1;
248
+ const categories = summarizeDetection(o.detection, o.text);
249
+ const blocking = categories.filter((c) => c.severity === 'blocking');
250
+ const advisory = categories.filter((c) => c.severity !== 'blocking');
251
+ const parts = [];
252
+
253
+ if (o.contract) {
254
+ parts.push(o.contract.trim());
255
+ parts.push('');
256
+ parts.push('---');
257
+ parts.push('');
258
+ }
259
+
260
+ parts.push('# 本轮派活:第 ' + chapter.ch + ' 章去 AI 味' + (attempt > 1 ? '(第 ' + attempt + ' 次尝试)' : ''));
261
+ parts.push('');
262
+
263
+ parts.push('## 目标文件');
264
+ parts.push('');
265
+ parts.push('- 路径:`设定/raw/sources/写作/' + chapter.name + '`');
266
+ parts.push('- 改写方式:**直接覆盖原文件**,不要新建文件、不要改文件名。');
267
+ parts.push('');
268
+
269
+ parts.push('## 检出结果(校验器判的,不要自己另找标准)');
270
+ parts.push('');
271
+
272
+ if (blocking.length) {
273
+ parts.push('### 必须改干净(blocking)');
274
+ parts.push('');
275
+ for (const c of blocking) {
276
+ parts.push('- **第 ' + c.no + ' 类 ' + c.name + '**(' + c.group + ')—— 命中 ' + c.count + ' 处');
277
+ if (c.lines.length) {
278
+ for (const l of c.lines) {
279
+ parts.push(' - 第 ' + l.line + ' 行:' + (l.text || '(空行)'));
280
+ }
281
+ }
282
+ if (c.rewrite) parts.push(' - 改法:' + c.rewrite);
283
+ }
284
+ parts.push('');
285
+ }
286
+
287
+ if (advisory.length) {
288
+ parts.push('### 建议一并处理(advisory,不强制)');
289
+ parts.push('');
290
+ for (const c of advisory) {
291
+ parts.push('- **第 ' + c.no + ' 类 ' + c.name + '** —— 命中 ' + c.count + ' 处');
292
+ if (c.rewrite) parts.push(' - 改法:' + c.rewrite);
293
+ }
294
+ parts.push('');
295
+ }
296
+
297
+ if (!categories.length) {
298
+ parts.push('(校验器没有检出命中项 —— 如果你收到本提示词,说明上一轮判定有误,请先跑一遍校验器确认。)');
299
+ parts.push('');
300
+ }
301
+
302
+ if (o.prev && o.prev.exceeded) {
303
+ parts.push('## ⚠️ 上一轮被回滚了');
304
+ parts.push('');
305
+ parts.push('上一轮你的改动幅度是 ' + Math.round((o.prev.ratio || 0) * 100) + '%,超过了 ' +
306
+ Math.round(DESLOP_ROLLBACK_RATIO * 100) + '% 的上限,原文已被还原。');
307
+ parts.push('这说明你改得太狠了。**这一轮只改上面列出的那几处**,其余句子一个字都不要动。');
308
+ parts.push('');
309
+ } else if (o.prev && o.prev.note) {
310
+ parts.push('## ⚠️ 上一轮没通过');
311
+ parts.push('');
312
+ parts.push(o.prev.note);
313
+ parts.push('');
314
+ }
315
+
316
+ parts.push('## 硬约束');
317
+ parts.push('');
318
+ parts.push('- **只改 AI 味,不改剧情**:人物、事件、因果、伏笔、事实一律不动。');
319
+ parts.push('- **不要改标题行**(第一行 `# 第N章 <标题>` 保持原样)。');
320
+ parts.push('- **不要动台账**(`设定/raw/sources/写作/台账/` 下的文件本阶段不许改)。');
321
+ parts.push('- **改动幅度控制在 ' + Math.round(DESLOP_ROLLBACK_RATIO * 100) + '% 以内**,超了会被自动回滚。');
322
+ parts.push('- **字数变化不超过 ±' + Math.round(DESLOP_WORD_TOLERANCE * 100) + '%**(与写作阶段的字数容差一致),' +
323
+ '标题行也要原样保留 —— 这两条超了同样会被回滚。');
324
+ parts.push('');
325
+
326
+ parts.push('## 交活');
327
+ parts.push('');
328
+ parts.push('改完后用一段话汇报:改了几处、每处原来是什么句式、改成了什么。');
329
+ parts.push('');
330
+
331
+ return parts.join('\n');
332
+ }
333
+
334
+ // ============================================================
335
+ // 报告
336
+ // ============================================================
337
+
338
+ /**
339
+ * 渲染单章的去味报告。
340
+ * @param {Object} r 章节结果
341
+ * @returns {string} markdown
342
+ */
343
+ export function renderChapterReport(r) {
344
+ const lines = [];
345
+ const statusText = {
346
+ clean: '无需改写(未检出 blocking)',
347
+ passed: '已改写并通过复扫',
348
+ failed: '未改干净',
349
+ rolled_back: '改动超限已回滚',
350
+ error: '执行出错'
351
+ };
352
+ lines.push('# 第 ' + r.ch + ' 章 去 AI 味报告');
353
+ lines.push('');
354
+ lines.push('- 状态:**' + (statusText[r.status] || r.status) + '**');
355
+ lines.push('- 文件:`设定/raw/sources/写作/' + r.name + '`');
356
+ if (r.backup) lines.push('- 原文备份:`设定/raw/sources/写作/' + DESLOP_REVISIONS_DIRNAME + '/' + r.backupName + '`');
357
+ if (r.error) lines.push('- 错误:' + r.error);
358
+ lines.push('');
359
+
360
+ lines.push('## 初次检出');
361
+ lines.push('');
362
+ lines.push('- blocking 命中:' + r.initial.blockingCount + ' 处');
363
+ lines.push('- advisory 命中:' + r.initial.advisoryCount + ' 处');
364
+ lines.push('');
365
+ lines.push('| # | 分组 | 名称 | 级别 | 次数 |');
366
+ lines.push('|---|---|---|---|---|');
367
+ for (const c of r.initial.categories) {
368
+ lines.push('| ' + c.no + ' | ' + c.group + ' | ' + c.name + ' | ' + c.severity + ' | ' + c.count + ' |');
369
+ }
370
+ lines.push('');
371
+
372
+ if (r.attempts.length) {
373
+ lines.push('## 改写尝试');
374
+ lines.push('');
375
+ lines.push('| 尝试 | 改动比例 | 等级 | 复扫 blocking | 结论 |');
376
+ lines.push('|---|---|---|---|---|');
377
+ for (const a of r.attempts) {
378
+ const ratio = typeof a.ratio === 'number' ? Math.round(a.ratio * 100) + '%' : '-';
379
+ lines.push('| ' + a.attempt + ' | ' + ratio + ' | ' + (a.level || '-') + ' | ' +
380
+ (typeof a.blockingCount === 'number' ? a.blockingCount : '-') + ' | ' + (a.note || '-') + ' |');
381
+ }
382
+ lines.push('');
383
+ }
384
+
385
+ if (r.remaining && r.remaining.blockingCount > 0) {
386
+ lines.push('## 复扫后仍未清除(blocking)');
387
+ lines.push('');
388
+ lines.push('| # | 名称 | 次数 |');
389
+ lines.push('|---|---|---|');
390
+ for (const c of r.remaining.categories.filter((x) => x.severity === 'blocking')) {
391
+ lines.push('| ' + c.no + ' | ' + c.name + ' | ' + c.count + ' |');
392
+ }
393
+ lines.push('');
394
+ }
395
+
396
+ if (r.remaining && r.remaining.advisoryCount > 0) {
397
+ lines.push('## 残留 advisory(只提示,不拦)');
398
+ lines.push('');
399
+ lines.push('| # | 名称 | 次数 |');
400
+ lines.push('|---|---|---|');
401
+ for (const c of r.remaining.categories.filter((x) => x.severity !== 'blocking')) {
402
+ lines.push('| ' + c.no + ' | ' + c.name + ' | ' + c.count + ' |');
403
+ }
404
+ lines.push('');
405
+ }
406
+
407
+ return lines.join('\n');
408
+ }
409
+
410
+ /**
411
+ * 渲染总览报告。
412
+ * @param {Array} chapters 章节结果数组
413
+ * @returns {string} markdown
414
+ */
415
+ export function renderOverviewReport(chapters) {
416
+ const lines = [];
417
+ const passed = chapters.filter((c) => c.status === 'passed' || c.status === 'clean').length;
418
+ lines.push('# 去 AI 味总览');
419
+ lines.push('');
420
+ lines.push('- 章节总数:' + chapters.length);
421
+ lines.push('- 已处理:' + passed);
422
+ lines.push('- 未通过:' + chapters.filter((c) => c.status === 'failed' || c.status === 'rolled_back').length);
423
+ lines.push('- 执行出错:' + chapters.filter((c) => c.status === 'error').length);
424
+ lines.push('');
425
+ lines.push('| 章 | 状态 | 初次 blocking | 改动比例 | 复扫 blocking |');
426
+ lines.push('|---|---|---|---|---|');
427
+ for (const c of chapters) {
428
+ const last = c.attempts.length ? c.attempts[c.attempts.length - 1] : null;
429
+ const ratio = last && typeof last.ratio === 'number' ? Math.round(last.ratio * 100) + '%' : '-';
430
+ const rem = c.remaining ? c.remaining.blockingCount : '-';
431
+ lines.push('| ' + c.ch + ' | ' + c.status + ' | ' + c.initial.blockingCount + ' | ' + ratio + ' | ' + rem + ' |');
432
+ }
433
+ lines.push('');
434
+ return lines.join('\n');
435
+ }
436
+
437
+ /**
438
+ * 写报告到磁盘。
439
+ * @returns {string} 报告路径(失败返回空串)
440
+ */
441
+ export function writeReport(dirs, name, content) {
442
+ try {
443
+ mkdirSync(dirs.reports, { recursive: true });
444
+ const path = join(dirs.reports, name);
445
+ writeFileSync(path, content, 'utf-8');
446
+ return path;
447
+ } catch {
448
+ return '';
449
+ }
450
+ }
451
+
452
+ // ============================================================
453
+ // 主循环
454
+ // ============================================================
455
+
456
+ /**
457
+ * 逐章驱动去味阶段。
458
+ *
459
+ * @param {Object} opts
460
+ * @param {string} opts.projectRoot
461
+ * @param {Array<{ch:number,name:string,path:string}>} [opts.chapters]
462
+ * @param {number} [opts.maxAttempts]
463
+ * @param {string} [opts.contract] 去味执行须知
464
+ * @param {(prompt:string) => Promise<{ok:boolean, error?:string}>} opts.sendPrompt
465
+ * @param {(files:string[], before:string) => Promise<{ok:boolean, reason?:string, code?:string}>} opts.waitIdle
466
+ * @param {(file:string) => Promise<{ok:boolean, detection?:Object, error?:string}>} opts.detect
467
+ * @param {(msg:string) => void} [opts.onProgress]
468
+ * @param {(chapter:Object) => void} [opts.onChapter] 单章结束时的回调(用于落报告)
469
+ * @returns {Promise<Object>} 汇总报告
470
+ */
471
+ export async function runDeslopStage(opts) {
472
+ const dirs = opts.dirs || deslopDirs(opts.projectRoot);
473
+ const chapters = opts.chapters || listChapterFiles(opts.projectRoot);
474
+ const maxAttempts = opts.maxAttempts || DESLOP_MAX_ATTEMPTS;
475
+
476
+ const report = {
477
+ ok: true,
478
+ error: null,
479
+ chapters: [],
480
+ passed: 0,
481
+ clean: 0,
482
+ failed: [],
483
+ rolledBack: [],
484
+ errored: []
485
+ };
486
+
487
+ for (const chapter of chapters) {
488
+ const original = (() => {
489
+ try {
490
+ return readFileSync(chapter.path, 'utf-8');
491
+ } catch {
492
+ return null;
493
+ }
494
+ })();
495
+ if (original === null) {
496
+ const r = {
497
+ ch: chapter.ch,
498
+ name: chapter.name,
499
+ path: chapter.path,
500
+ status: 'error',
501
+ error: '读取章节文件失败:' + chapter.path,
502
+ backup: null,
503
+ backupName: null,
504
+ attempts: [],
505
+ initial: { blockingCount: 0, advisoryCount: 0, categories: [] },
506
+ remaining: null
507
+ };
508
+ report.chapters.push(r);
509
+ report.errored.push(chapter.ch);
510
+ if (opts.onProgress) opts.onProgress('第 ' + chapter.ch + ' 章读取失败,已跳过。');
511
+ continue;
512
+ }
513
+
514
+ // 1. 初次检测
515
+ const first = await opts.detect(chapter.path);
516
+ if (!first || !first.ok) {
517
+ const r = {
518
+ ch: chapter.ch,
519
+ name: chapter.name,
520
+ path: chapter.path,
521
+ status: 'error',
522
+ error: (first && first.error) || '检测脚本异常',
523
+ backup: null,
524
+ backupName: null,
525
+ attempts: [],
526
+ initial: { blockingCount: 0, advisoryCount: 0, categories: [] },
527
+ remaining: null
528
+ };
529
+ report.chapters.push(r);
530
+ report.errored.push(chapter.ch);
531
+ if (opts.onProgress) opts.onProgress('第 ' + chapter.ch + ' 章检测失败:' + r.error);
532
+ continue;
533
+ }
534
+
535
+ const detection = first.detection;
536
+ const initialCategories = summarizeDetection(detection, original);
537
+ const initial = {
538
+ blockingCount: detection.blockingCount || 0,
539
+ advisoryCount: detection.advisoryCount || 0,
540
+ categories: initialCategories
541
+ };
542
+
543
+ const base = {
544
+ ch: chapter.ch,
545
+ name: chapter.name,
546
+ path: chapter.path,
547
+ backup: null,
548
+ backupName: null,
549
+ attempts: [],
550
+ initial,
551
+ remaining: null,
552
+ error: null
553
+ };
554
+
555
+ // 2. 没有 blocking 就不用改(advisory 只进报告)
556
+ if (initial.blockingCount === 0) {
557
+ base.status = 'clean';
558
+ base.remaining = {
559
+ blockingCount: 0,
560
+ advisoryCount: initial.advisoryCount,
561
+ categories: initialCategories
562
+ };
563
+ report.chapters.push(base);
564
+ report.clean++;
565
+ if (opts.onProgress) {
566
+ opts.onProgress('第 ' + chapter.ch + ' 章没有 blocking 命中,跳过改写。');
567
+ }
568
+ if (opts.onChapter) opts.onChapter(base);
569
+ continue;
570
+ }
571
+
572
+ // 3. 备份(失败即中止该章:没有备份就没有回滚能力)
573
+ let backupPath;
574
+ try {
575
+ backupPath = backupChapter(dirs, chapter.ch, original);
576
+ } catch (e) {
577
+ base.status = 'error';
578
+ base.error = '备份失败:' + (e && e.message ? e.message : String(e));
579
+ report.chapters.push(base);
580
+ report.errored.push(chapter.ch);
581
+ if (opts.onProgress) opts.onProgress('第 ' + chapter.ch + ' 章备份失败,已中止该章。');
582
+ if (opts.onChapter) opts.onChapter(base);
583
+ continue;
584
+ }
585
+ base.backup = backupPath;
586
+ base.backupName = backupPath.slice(backupPath.lastIndexOf('/') + 1);
587
+
588
+ // 4. 改写循环
589
+ let attempt = 1;
590
+ let prev = null;
591
+ let status = 'failed';
592
+ let remaining = null;
593
+ let lastRatio = null;
594
+ // 本轮要喂给模型的是**当前**的检测结果与当前文本。
595
+ //
596
+ // 旧实现每轮都喂首次的 detection + 原始正文 —— 于是第二次以后,
597
+ // 模型看到的还是**原文**里的命中行(可能早就被改掉了),
598
+ // 它不知道剩下的问题在哪一行、哪一类,只能重新通读全文猜,
599
+ // 容易改错/改多,触发 35% 回滚。
600
+ // 现在每轮复扫后把 det2 与改后的正文接上,
601
+ // 模型看到的是"还剩哪几类、在哪几行、当前文本长什么样"。
602
+ let currentDetection = detection;
603
+ let currentText = original;
604
+
605
+ while (attempt <= maxAttempts) {
606
+ const prompt = buildDeslopPrompt({
607
+ chapter,
608
+ contract: opts.contract,
609
+ detection: currentDetection,
610
+ text: currentText,
611
+ attempt,
612
+ prev
613
+ });
614
+
615
+ const sent = await opts.sendPrompt(prompt);
616
+ if (!sent || sent.ok !== true) {
617
+ base.error = (sent && sent.error) || '提示词投递失败';
618
+ status = 'error';
619
+ report.errored.push(chapter.ch);
620
+ break;
621
+ }
622
+
623
+ const waited = await opts.waitIdle([chapter.path], original);
624
+ if (!waited || waited.ok !== true) {
625
+ const code = (waited && waited.code) || '';
626
+ const reason = (waited && waited.reason) || '等待 LLM 失败';
627
+ base.attempts.push({ attempt, kind: 'wait_failed', ratio: null, level: null, blockingCount: null, note: reason });
628
+ // 环境类失败(超时/中断)重试没有意义,直接中止本章
629
+ if (code === 'TIMEOUT' || code === 'ABORTED' || code === 'NO_AGENT') {
630
+ base.error = reason;
631
+ status = 'error';
632
+ report.errored.push(chapter.ch);
633
+ break;
634
+ }
635
+ prev = { note: '上一轮没有改动文件:' + reason };
636
+ attempt++;
637
+ continue;
638
+ }
639
+
640
+ const after = (() => {
641
+ try {
642
+ return readFileSync(chapter.path, 'utf-8');
643
+ } catch {
644
+ return null;
645
+ }
646
+ })();
647
+ if (after === null) {
648
+ base.attempts.push({ attempt, kind: 'unreadable', ratio: null, level: null, blockingCount: null, note: '改写后文件读不到' });
649
+ prev = { note: '上一轮之后文件读不到了,请重新写一遍这一章。' };
650
+ attempt++;
651
+ continue;
652
+ }
653
+
654
+ const ratio = changeRatio(original, after);
655
+ const level = deslopLevel(ratio);
656
+ lastRatio = ratio;
657
+
658
+ // 改动超限 → 回滚
659
+ if (level.exceeded) {
660
+ const restored = restoreChapter(chapter.path, original);
661
+ base.attempts.push({
662
+ attempt,
663
+ kind: 'ratio_exceeded',
664
+ ratio,
665
+ level: level.name,
666
+ blockingCount: null,
667
+ note: restored ? '超 ' + Math.round(DESLOP_ROLLBACK_RATIO * 100) + '%,已回滚' : '超限,回滚失败'
668
+ });
669
+ prev = { ratio, exceeded: true };
670
+ // 回滚了 → 磁盘上的内容又变回原文,检测基线也要跟着回退
671
+ currentDetection = detection;
672
+ currentText = original;
673
+ attempt++;
674
+ continue;
675
+ }
676
+
677
+ // 章节完整性:去味不能顺手把标题改了、字数改了、正文删空了
678
+ const integrity = checkChapterIntegrity(original, after);
679
+ if (!integrity.ok) {
680
+ restoreChapter(chapter.path, original);
681
+ base.attempts.push({
682
+ attempt,
683
+ kind: 'integrity',
684
+ ratio,
685
+ level: level.name,
686
+ blockingCount: null,
687
+ note: '改坏了(' + integrity.problems.join(';') + '),已回滚'
688
+ });
689
+ prev = { note: '上一轮改动把章节改坏了,已回滚:' + integrity.problems.join(';') +
690
+ '。这一轮只改 AI 味,标题、字数、篇幅都要保持原样。' };
691
+ currentDetection = detection;
692
+ currentText = original;
693
+ attempt++;
694
+ continue;
695
+ }
696
+
697
+ // 复扫
698
+ const again = await opts.detect(chapter.path);
699
+ if (!again || !again.ok) {
700
+ // 检测脚本崩了是**环境问题**,不是内容问题:重投不会改变结果。
701
+ // 按错误分类(见 scripts/error-classes.js):脚本问题不重投,单独上报。
702
+ restoreChapter(chapter.path, original);
703
+ base.attempts.push({
704
+ attempt,
705
+ kind: 'detect_crashed',
706
+ ratio,
707
+ level: level.name,
708
+ blockingCount: null,
709
+ note: '复扫失败:' + ((again && again.error) || '未知错误') + ',已回滚'
710
+ });
711
+ base.error = '复扫时检测脚本异常:' + ((again && again.error) || '未知错误');
712
+ status = 'error';
713
+ report.errored.push(chapter.ch);
714
+ break;
715
+ }
716
+
717
+ const det2 = again.detection;
718
+ // 下一轮喂 det2 + 改后的正文(不是首次的 detection + 原文)
719
+ currentDetection = det2;
720
+ currentText = after;
721
+ const remainingCategories = summarizeDetection(det2, after);
722
+ remaining = {
723
+ blockingCount: det2.blockingCount || 0,
724
+ advisoryCount: det2.advisoryCount || 0,
725
+ categories: remainingCategories
726
+ };
727
+
728
+ if (det2.blockingCount === 0) {
729
+ // 去味"干净"不等于"写作校验还能过"。
730
+ //
731
+ // 去味的完整性检查只有三项(标题没改、字数没变超过 ±20%、正文没被删空),
732
+ // 不检查占位符/字符集/禁用词/一级标题/台账 —— 所以去味完全可能
733
+ // 把一个本来合规的章改坏(引入 [X]、韩文、第二个 # 标题…),
734
+ // 而阶段照样报"✅ 去味完成",用户以为"写作+去味"双通过了。
735
+ //
736
+ // 这里在判通过之前补跑一次写作校验,不过就回滚(宁可这章没去味,
737
+ // 也不能留一个被改坏的章)。
738
+ if (opts.validateWriting) {
739
+ const wv = await opts.validateWriting(chapter, after);
740
+ if (!wv || wv.ok !== true) {
741
+ const reasons = ((wv && wv.errors) || []).map((e) => '[' + e.code + '] ' + e.message);
742
+ restoreChapter(chapter.path, original);
743
+ base.attempts.push({
744
+ attempt,
745
+ kind: 'writing_reject',
746
+ ratio,
747
+ level: level.name,
748
+ blockingCount: 0,
749
+ note: '去味后写作复检未通过(' + reasons.join(';') + '),已回滚'
750
+ });
751
+ prev = {
752
+ note: '上一轮改完后去味检测干净了,但**写作校验**没通过:'
753
+ + reasons.join(';')
754
+ + '。说明改写引入了新问题(占位符/字符集/禁用词/标题层级…)。'
755
+ + '这一轮只改 AI 味,别动这些地方。'
756
+ };
757
+ currentDetection = detection;
758
+ currentText = original;
759
+ attempt++;
760
+ continue;
761
+ }
762
+ base.writingRecheck = { ok: true };
763
+ }
764
+
765
+ base.attempts.push({
766
+ attempt,
767
+ kind: 'passed',
768
+ ratio,
769
+ level: level.name,
770
+ blockingCount: 0,
771
+ note: opts.validateWriting ? '通过(已复检写作校验)' : '通过'
772
+ });
773
+ status = 'passed';
774
+ break;
775
+ }
776
+
777
+ base.attempts.push({
778
+ attempt,
779
+ kind: 'rescan_failed',
780
+ ratio,
781
+ level: level.name,
782
+ blockingCount: det2.blockingCount,
783
+ note: '复扫仍有 ' + det2.blockingCount + ' 处 blocking'
784
+ });
785
+ prev = {
786
+ note: '上一轮改完后复扫,仍有 ' + det2.blockingCount + ' 处 blocking 没清掉'
787
+ + (remainingCategories.length
788
+ ? ',剩下的是第 ' + remainingCategories.map((c) => c.no + ' 类(' + c.name + ',' + c.count + ' 处)').join('、')
789
+ : '')
790
+ + '。**只改上面列出的这些命中行**,其余句子一个字都不要动。'
791
+ };
792
+ attempt++;
793
+ }
794
+
795
+ if (status !== 'passed' && status !== 'error') {
796
+ // 没改干净。只有"改动超限"才算 rolled_back ——
797
+ // "改坏了标题""改坏了字数"也是回滚,但那是另一类问题,报告里要分得开。
798
+ const last = base.attempts[base.attempts.length - 1];
799
+ if (last && last.kind === 'ratio_exceeded') {
800
+ status = 'rolled_back';
801
+ report.rolledBack.push(chapter.ch);
802
+ } else {
803
+ status = 'failed';
804
+ }
805
+ report.failed.push(chapter.ch);
806
+ } else if (status === 'passed') {
807
+ report.passed++;
808
+ }
809
+
810
+ base.status = status;
811
+ base.remaining = remaining;
812
+ if (typeof lastRatio === 'number') base.ratio = lastRatio;
813
+ report.chapters.push(base);
814
+ if (opts.onProgress) {
815
+ opts.onProgress('第 ' + chapter.ch + ' 章去味结果:' + status);
816
+ }
817
+ if (opts.onChapter) opts.onChapter(base);
818
+ }
819
+
820
+ return report;
821
+ }