@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,682 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 写作阶段校验脚本 v1.2
4
+ * 源码中零反引号,避免 markdown 渲染问题。
5
+ *
6
+ * v1.2 变更(修复批次 P2-1 / P2-9):
7
+ * - P2-1:chineseToInt 的 unitMap 补 '万': 10000
8
+ * (原版 titlePattern 允许"万"但 unitMap 没有,导致"一万"被解析为 1)
9
+ * - P2-9:UPSTREAM_PARTIAL 触发条件从 !outline && !avgWords 改为 !outline || !avgWords
10
+ * (原版只提供骨架时 avgWords 有值、outline 为空,不会触发提示,
11
+ * 但实际上缺细纲对照就是上游不完整)
12
+ *
13
+ * 保留 v1.1 的 P1-1 修复:
14
+ * - 章节标题三种格式兼容
15
+ * - 禁用词与提示词对齐
16
+ * - countChineseChars 只删行首标题
17
+ * - EMPTY_BODY
18
+ * - 删除空函数 compareWithTimeline
19
+ *
20
+ * 用法:
21
+ * node writing-validate.js \
22
+ * --input=设定/raw/sources/写作/第NNN章.md \
23
+ * --chapter=N \
24
+ * --upstream=设定/raw/sources/骨架,设定/raw/sources/血肉,设定/raw/sources/节奏
25
+ * 退出码:0=通过 1=内容错误 2=脚本异常
26
+ */
27
+
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { readStageGate } from './graph-utils.js';
32
+ import { countChapterChars as countChineseChars } from './content-quality.js';
33
+ import {
34
+ resolveTailHook,
35
+ resolveHeadHook,
36
+ resolveMicroPayoff,
37
+ findHookRepeats,
38
+ TAIL_HOOK_TYPES,
39
+ HEAD_HOOK_TYPES,
40
+ MICRO_PAYOFFS,
41
+ HOOK_CATEGORIES
42
+ } from './reading-power.js';
43
+
44
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
+
46
+ // ============================================================
47
+ // 参数解析
48
+ // ============================================================
49
+ function parseArgs() {
50
+ const args = process.argv.slice(2);
51
+ const result = { upstream: [] };
52
+ for (const arg of args) {
53
+ // 参数名允许连字符(与 skeleton/flesh/rhythm 三个脚本保持一致),
54
+ // 否则 --stage-dir 这类参数会被静默丢弃
55
+ const m = arg.match(/^--([\w-]+)=(.+)$/);
56
+ if (m) {
57
+ if (m[1] === 'upstream') {
58
+ result.upstream.push(...m[2].split(',').map(s => s.trim()).filter(Boolean));
59
+ } else {
60
+ result[m[1]] = m[2];
61
+ }
62
+ }
63
+ }
64
+ return result;
65
+ }
66
+
67
+ // ============================================================
68
+ // IO
69
+ // ============================================================
70
+ function readJson(p) {
71
+ if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
72
+ try {
73
+ return { data: JSON.parse(fs.readFileSync(p, 'utf-8')) };
74
+ } catch (err) {
75
+ return { error: 'JSON 解析失败: ' + err.message };
76
+ }
77
+ }
78
+
79
+ function readText(p) {
80
+ if (!fs.existsSync(p)) return { error: '文件不存在: ' + p };
81
+ try {
82
+ return { data: fs.readFileSync(p, 'utf-8') };
83
+ } catch (err) {
84
+ return { error: '读取失败: ' + err.message };
85
+ }
86
+ }
87
+
88
+ // ============================================================
89
+ // 中文数字 → 整数
90
+ //
91
+ // 正确实现中文小节记法:
92
+ // 一百零五 = 105 零是"占位"(不占位值)
93
+ // 一千零五 = 1005
94
+ // 十二 = 12 十前面无数字时按 1 处理
95
+ // 二百五十 = 250
96
+ // 十二万 = 120000 万级要把已收拢的小节整体乘 10000
97
+ //
98
+ // 旧实现把"万"当成普通单位直接累加(十二万 → 20010),
99
+ // 且"零"的处理与注释描述相反(一百零五 虽对,零后数字会被清零)。
100
+ // ============================================================
101
+ function chineseToInt(s) {
102
+ const digitMap = { '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
103
+ const unitMap = { '十': 10, '百': 100, '千': 1000, '万': 10000, '亿': 100000000 };
104
+ let total = 0; // 已完成的万/亿级部分
105
+ let section = 0; // 当前小节(万以下)
106
+ let current = 0; // 当前数字
107
+ let pendingZero = false;
108
+ for (const ch of s) {
109
+ if (digitMap[ch] !== undefined) {
110
+ if (ch === '零') {
111
+ pendingZero = true;
112
+ current = 0;
113
+ } else {
114
+ current = digitMap[ch];
115
+ pendingZero = false;
116
+ }
117
+ } else if (unitMap[ch] !== undefined) {
118
+ const u = unitMap[ch];
119
+ if (u >= 10000) {
120
+ // 万/亿:先收拢当前小节,再整体乘以单位
121
+ section += current;
122
+ total = (total + section) * u;
123
+ section = 0;
124
+ } else {
125
+ // 十/百/千:前面没有数字时按 1 处理(十二 = 12)
126
+ section += (current === 0 && !pendingZero ? 1 : current) * u;
127
+ }
128
+ current = 0;
129
+ pendingZero = false;
130
+ }
131
+ }
132
+ return total + section + current;
133
+ }
134
+
135
+ // ============================================================
136
+ // 字数统计
137
+ // ============================================================
138
+ // 口径统一走 content-quality.js 的 countChapterChars(计入中文标点,
139
+ // 与网文平台的"字数"口径一致)。校验用 ±20% 容差比较 word_target。
140
+ // 导入阶段反推 avg_words_per_chapter 时也用同一个口径 ——
141
+ // 两边口径不一致,导入出来的章会被误报 WORD_COUNT。
142
+ // 反引号字符用 fromCharCode 生成,源码零反引号
143
+ const BT = String.fromCharCode(96);
144
+ const BT3 = BT + BT + BT;
145
+ const BT3_RE = new RegExp(BT3 + '[\\s\\S]*?' + BT3, 'g');
146
+
147
+ // ============================================================
148
+ // 字符集 / 禁用词
149
+ // ============================================================
150
+ const KOREAN_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/;
151
+ const KANA_RE = /[\u3040-\u309F\u30A0-\u30FF]/;
152
+ // 占位符检测(避免误伤正常散文):
153
+ // - 方括号占位符 [X]/[Y]/[Z]/[W] 一律算占位符(无歧义)
154
+ // - "待定"/"待确认"/"TBD"/"N/A"/"xxx" 只在"独占一行"时才算占位符。
155
+ // 之前用 /待定/ 全局匹配,正常正文里的"合同上的价格待定"会被误判成占位符并卡住章节。
156
+ // 词表与 content-quality.js(骨架/血肉阶段)的 PLACEHOLDER_TEXTS 对齐:
157
+ // 那边按"整段 token"匹配,所以 xxx 独占一行会被拦;这边原来漏了 xxx,
158
+ // 提示词却写着"xxx 会被 PLACEHOLDER 拦"—— 提示词说一套、代码做一套(2026-10-27 修)。
159
+ const PLACEHOLDER_RE = /\[[XYZW]\]/;
160
+ const PLACEHOLDER_LINE_RE = /^\s*(待确认|待定|待补充|TBD|N\/A|未定|xxx|XXX|xx|XX)\s*$/m;
161
+
162
+ // 与 04-writing-prompt.md §7.3 和 §11.4 严格一致
163
+ // 注:"建议"/"尽量"/"约莫"/"大致" 是小说常用词(对白/叙述),不在禁用清单
164
+ const FORBIDDEN_WORDS = ['基本一致', '无重大问题'];
165
+
166
+ function textContentCheck(text, errors) {
167
+ if (KOREAN_RE.test(text)) {
168
+ const m = text.match(KOREAN_RE);
169
+ errors.push({ code: 'CHARSET_KO', path: '正文', message: '含韩文: "' + m[0] + '"', hint: null });
170
+ }
171
+ if (KANA_RE.test(text)) {
172
+ const m = text.match(KANA_RE);
173
+ errors.push({ code: 'CHARSET_JP', path: '正文', message: '含日文假名: "' + m[0] + '"', hint: null });
174
+ }
175
+ const placeholderMatch = text.match(PLACEHOLDER_RE) || text.match(PLACEHOLDER_LINE_RE);
176
+ if (placeholderMatch) {
177
+ errors.push({ code: 'PLACEHOLDER', path: '正文', message: '含占位符: "' + placeholderMatch[0].trim() + '"', hint: null });
178
+ }
179
+ for (const w of FORBIDDEN_WORDS) {
180
+ if (text.includes(w)) {
181
+ errors.push({
182
+ code: 'FORBIDDEN_WORD',
183
+ path: '正文',
184
+ message: '含禁用词 "' + w + '"',
185
+ hint: '这是 AI 分析口吻,不应出现在小说正文'
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ // ============================================================
192
+ // 格式检查(含章节标题正则 + 正文非空)
193
+ // ============================================================
194
+ function formatCheck(text, chapterN, errors) {
195
+ const lines = text.split('\n');
196
+
197
+ const firstNonEmptyIdx = lines.findIndex(l => l.trim().length > 0);
198
+ if (firstNonEmptyIdx < 0) {
199
+ errors.push({ code: 'EMPTY_CHAPTER', path: '全文', message: '章节正文为空', hint: null });
200
+ return;
201
+ }
202
+ const firstNonEmpty = lines[firstNonEmptyIdx];
203
+
204
+ // 章节标题:兼容
205
+ // # 第1章 <标题> (阿拉伯数字,不补零)
206
+ // # 第001章 <标题> (阿拉伯数字,三位补零)
207
+ // # 第一章 <标题> (中文数字)
208
+ // 允许标题前后有空格
209
+ const titlePattern = /^#\s*第\s*([0-9]+|[零一二三四五六七八九十百千万]+)\s*章/;
210
+ const titleMatch = firstNonEmpty.match(titlePattern);
211
+
212
+ if (!titleMatch) {
213
+ errors.push({
214
+ code: 'CHAPTER_TITLE',
215
+ path: '第一行',
216
+ message: '第一行应为 "# 第N章 <标题>" 或 "# 第NNN章 <标题>" 或 "# 第X章 <标题>"(X 为中文数字),实际 "' + firstNonEmpty.slice(0, 40) + '"',
217
+ hint: null
218
+ });
219
+ } else {
220
+ const raw = titleMatch[1];
221
+ let parsed;
222
+ if (/^[0-9]+$/.test(raw)) {
223
+ parsed = parseInt(raw, 10);
224
+ } else {
225
+ parsed = chineseToInt(raw);
226
+ }
227
+ if (parsed !== chapterN) {
228
+ errors.push({
229
+ code: 'CHAPTER_TITLE',
230
+ path: '第一行',
231
+ message: '标题中的章号 "' + raw + '"(解析为 ' + parsed + ')与 --chapter=' + chapterN + ' 不一致',
232
+ hint: null
233
+ });
234
+ }
235
+ }
236
+
237
+ // 全文不得再出现其它一级标题(豁免代码块内的 #)
238
+ const h1Matches = text.replace(BT3_RE, '').match(/^#\s+/gm) || [];
239
+ if (h1Matches.length > 1) {
240
+ errors.push({
241
+ code: 'CHAPTER_TITLE_DUP',
242
+ path: '全文',
243
+ message: '出现 ' + h1Matches.length + ' 个一级标题,本章应只有 1 个',
244
+ hint: '小标题用 ## 或 ###'
245
+ });
246
+ }
247
+
248
+ // 标题存在但正文为空(去掉标题行后,非空白字符 < 50)
249
+ const bodyLines = lines.slice(firstNonEmptyIdx + 1);
250
+ const bodyText = bodyLines.join('\n');
251
+ const bodyNonSpace = bodyText.replace(/\s+/g, '').length;
252
+ if (bodyNonSpace < 50) {
253
+ errors.push({
254
+ code: 'EMPTY_BODY',
255
+ path: '正文',
256
+ message: '标题存在但正文内容过短(非空白字符仅 ' + bodyNonSpace + ' 个,应 >= 50)',
257
+ hint: null
258
+ });
259
+ }
260
+
261
+ // 无多余空行(连续 4 个以上换行)
262
+ const emptyParagraphRuns = text.match(/\n{4,}/g);
263
+ if (emptyParagraphRuns) {
264
+ errors.push({
265
+ code: 'EMPTY_PARAGRAPH',
266
+ path: '全文',
267
+ message: '出现连续 ' + emptyParagraphRuns.length + ' 处多余空行(超过 3 个)',
268
+ hint: null
269
+ });
270
+ }
271
+ }
272
+
273
+ // ============================================================
274
+ // 与细纲对照
275
+ // ============================================================
276
+ function compareWithOutline(text, chapterN, outline, errors, warnings) {
277
+ if (!outline) {
278
+ warnings.push({
279
+ code: 'OUTLINE_MISSING',
280
+ path: '章节细纲',
281
+ message: '本章(第 ' + chapterN + ' 章)无细纲,无法做钩子/爽点对照',
282
+ hint: '重点章应有细纲'
283
+ });
284
+ return;
285
+ }
286
+
287
+ if (typeof outline.word_target === 'number') {
288
+ const actual = countChineseChars(text);
289
+ const low = Math.round(outline.word_target * 0.8);
290
+ const high = Math.round(outline.word_target * 1.2);
291
+ if (actual < low || actual > high) {
292
+ errors.push({
293
+ code: 'WORD_TARGET_MISMATCH',
294
+ path: '正文字数',
295
+ message: '实际字数 ' + actual + ' 偏离细纲 word_target=' + outline.word_target + ' 超过 ±20% [' + low + ', ' + high + ']',
296
+ hint: null
297
+ });
298
+ }
299
+ }
300
+
301
+ if (outline.hook_strength === '强') {
302
+ // 钩子须在首/尾 100 字内兑现(见 04-writing-prompt.md §7.3)。
303
+ // 旧实现取前/后 400 字再判断 length < 50,而正文早已被 EMPTY_BODY 兜底为 >= 50 字符,
304
+ // 两条检查实际永远不触发(死代码)。这里改成检查首/尾 100 字内的有效内容密度。
305
+ const HOOK_WINDOW = 100;
306
+ const HOOK_MIN = 30;
307
+ const head = text.slice(0, HOOK_WINDOW).replace(/\s+/g, '');
308
+ const tail = text.slice(-HOOK_WINDOW).replace(/\s+/g, '');
309
+ if (head.length < HOOK_MIN) {
310
+ errors.push({
311
+ code: 'HOOK_HEAD_MISSING', path: '章首',
312
+ message: '钩子强度为"强"但前 ' + HOOK_WINDOW + ' 字内有效内容不足 ' + HOOK_MIN + ' 字(实际 ' + head.length + ')',
313
+ hint: '按细纲 hook_head 在章首兑现钩子'
314
+ });
315
+ }
316
+ if (tail.length < HOOK_MIN) {
317
+ errors.push({
318
+ code: 'HOOK_TAIL_MISSING', path: '章尾',
319
+ message: '钩子强度为"强"但末 ' + HOOK_WINDOW + ' 字内有效内容不足 ' + HOOK_MIN + ' 字(实际 ' + tail.length + ')',
320
+ hint: '按细纲 hook_tail 在章尾兑现钩子'
321
+ });
322
+ }
323
+ }
324
+ }
325
+
326
+ // ============================================================
327
+ // 与人物档案对照
328
+ // ============================================================
329
+ function compareWithCharacter(text, chapterN, characters, errors, warnings) {
330
+ if (!characters || !characters.length) return;
331
+
332
+ const mentionCount = new Map();
333
+ for (const c of characters) {
334
+ if (!c.name) continue;
335
+ const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
+ const re = new RegExp(escaped, 'g');
337
+ const count = (text.match(re) || []).length;
338
+ if (count > 0) mentionCount.set(c.name, count);
339
+ }
340
+ if (mentionCount.size === 0) {
341
+ warnings.push({
342
+ code: 'NO_CHARACTER_MENTION',
343
+ path: '正文',
344
+ message: '本章未提及任何已建档角色',
345
+ hint: '除非是纯环境/过渡章,否则应有至少一个角色出现'
346
+ });
347
+ } else {
348
+ // 旧实现算出了每个角色的出现次数却直接丢弃,这里把它用起来:
349
+ // 列出本章实际出场的角色及出现次数,便于人工核对"该出场的角色是否都出场"。
350
+ const detail = [...mentionCount.entries()]
351
+ .sort((a, b) => b[1] - a[1])
352
+ .map(([name, n]) => name + '×' + n)
353
+ .join('、');
354
+ warnings.push({
355
+ code: 'CHARACTER_MENTION',
356
+ path: '正文',
357
+ message: '本章出场角色(按出现次数):' + detail,
358
+ hint: null
359
+ });
360
+ }
361
+ }
362
+
363
+ // ============================================================
364
+ // 阅读动力与台账对照(钩子类型 / 钩子重复 / 微兑现 / 死人出场)
365
+ // ============================================================
366
+
367
+ /**
368
+ * 人物台账里表示"已死亡"的 status 写法。
369
+ * 旧 skill 的"死人不再出场"闸门靠这个判定,没有统一枚举,
370
+ * 所以按关键字兜(模型写"死亡/已死/身亡/阵亡/去世"都算数)。
371
+ */
372
+ const DEAD_STATUS_RE = /(死亡|已死|身亡|阵亡|去世|殁)/;
373
+
374
+ /**
375
+ * 章台账归一化:支持数组与"以 ch 为 key 的对象"两种存法,
376
+ * 并按 ch 去重、**保留最后一条**。
377
+ *
378
+ * 为什么取最后一条:重写某章时台账是"追加"而不是"原地改",
379
+ * 同一 ch 会有多条记录。后写的那条才是当前生效的版本。
380
+ */
381
+ export function normalizeChapterLedger(raw) {
382
+ let list = [];
383
+ if (Array.isArray(raw)) list = raw.slice();
384
+ else if (raw && typeof raw === 'object') {
385
+ for (const k of Object.keys(raw)) {
386
+ const v = raw[k];
387
+ if (v && typeof v === 'object') list.push(Object.assign({ ch: Number(k) }, v));
388
+ }
389
+ }
390
+ const byCh = new Map();
391
+ for (const e of list) {
392
+ if (!e || typeof e !== 'object') continue;
393
+ const ch = Number(e.ch);
394
+ if (!Number.isInteger(ch)) continue;
395
+ byCh.set(ch, e);
396
+ }
397
+ return Array.from(byCh.values()).sort((a, b) => a.ch - b.ch);
398
+ }
399
+
400
+ /**
401
+ * 章台账与阅读动力规则的对账。
402
+ *
403
+ * 覆盖旧 skill 的 3 项充分性闸门(见 writing-rules.js COMMIT_GATES):
404
+ * 闸门 5 钩子符合 7/13 种 → HOOK_TYPE_INVALID / HOOK_REPEAT
405
+ * 闸门 6 死人不再出场 → DEAD_CHARACTER_APPEARS
406
+ * 以及 7 种微兑现(每章至少 1 个)→ MICRO_PAYOFF_MISSING
407
+ *
408
+ * 规则表在 reading-power.js(唯一真源),这里只负责对账。
409
+ */
410
+ function ledgerChecks(stageDir, chapterN, text, errors, warnings) {
411
+ const ledgerDir = path.join(stageDir, '台账');
412
+ const chapterRaw = readJson(path.join(ledgerDir, '章台账.json'));
413
+ const characterRaw = readJson(path.join(ledgerDir, '人物台账.json'));
414
+
415
+ const entries = normalizeChapterLedger(chapterRaw.data);
416
+ const entry = entries.find((e) => e.ch === chapterN) || null;
417
+
418
+ if (!entry) {
419
+ errors.push({
420
+ code: 'LEDGER_ENTRY_MISSING',
421
+ path: '台账/章台账.json',
422
+ message: '第 ' + chapterN + ' 章没有对应的台账条目(写完一章必须更新章台账)',
423
+ hint: '章台账每章追加一条,含 ch / actual_word_count / hook_head_type / hook_tail_type / micro_payoffs'
424
+ });
425
+ return;
426
+ }
427
+
428
+ // ---- 闸门 5a:钩子类型必须在 7 式 / 13 式之内 ----
429
+ const head = resolveHeadHook(entry.hook_head_type);
430
+ if (!head) {
431
+ errors.push({
432
+ code: 'HOOK_TYPE_INVALID',
433
+ path: '台账/章台账.json#hook_head_type',
434
+ message: '章首引子类型 "' + (entry.hook_head_type || '(空)') + '" 不在 7 式章首引子之内',
435
+ hint: '合法值(id 或中文名):' + HEAD_HOOK_TYPES.map((h) => h.name).join('、')
436
+ });
437
+ }
438
+ const tail = resolveTailHook(entry.hook_tail_type);
439
+ if (!tail) {
440
+ errors.push({
441
+ code: 'HOOK_TYPE_INVALID',
442
+ path: '台账/章台账.json#hook_tail_type',
443
+ message: '章末钩子类型 "' + (entry.hook_tail_type || '(空)') + '" 不在 13 式章末钩子之内',
444
+ hint: '合法值(id 或中文名):' + TAIL_HOOK_TYPES.map((h) => h.name).join('、')
445
+ });
446
+ }
447
+
448
+ // ---- 闸门 5b:不得连续 3 章用同一种钩子 / 同一大分类 ----
449
+ const repeats = findHookRepeats(entries);
450
+ for (const rep of repeats.sameType) {
451
+ if (rep.chapters.indexOf(chapterN) < 0) continue;
452
+ errors.push({
453
+ code: 'HOOK_REPEAT',
454
+ path: '台账/章台账.json#hook_tail_type',
455
+ message: '第 ' + rep.chapters.join('、') + ' 章连续用了同一种章末钩子(' + rep.value + '),严禁连续 3 章用同一种',
456
+ hint: '换一种章末钩子;或换一个大分类(' + HOOK_CATEGORIES.join('、') + ')也行'
457
+ });
458
+ }
459
+ for (const rep of repeats.sameCategory) {
460
+ if (rep.chapters.indexOf(chapterN) < 0) continue;
461
+ errors.push({
462
+ code: 'HOOK_REPEAT',
463
+ path: '台账/章台账.json#hook_tail_type',
464
+ message: '第 ' + rep.chapters.join('、') + ' 章连续用了同一大分类的章末钩子(' + rep.value + '),读者的感受会重复',
465
+ hint: '换一个大分类(' + HOOK_CATEGORIES.join('、') + ')'
466
+ });
467
+ }
468
+
469
+ // ---- 7 种微兑现:每章至少 1 个 ----
470
+ const payoffs = Array.isArray(entry.micro_payoffs) ? entry.micro_payoffs : [];
471
+ if (!payoffs.length) {
472
+ errors.push({
473
+ code: 'MICRO_PAYOFF_MISSING',
474
+ path: '台账/章台账.json#micro_payoffs',
475
+ message: '第 ' + chapterN + ' 章没有登记微兑现,每章至少要有 1 个,否则读者会觉得这章白读',
476
+ hint: '合法值(id 或中文名):' + MICRO_PAYOFFS.map((m) => m.name).join('、')
477
+ });
478
+ } else {
479
+ const bad = payoffs.filter((p) => !resolveMicroPayoff(p));
480
+ if (bad.length) {
481
+ errors.push({
482
+ code: 'MICRO_PAYOFF_MISSING',
483
+ path: '台账/章台账.json#micro_payoffs',
484
+ message: '微兑现类型不在 7 种之内:' + bad.join('、'),
485
+ hint: '合法值(id 或中文名):' + MICRO_PAYOFFS.map((m) => m.name).join('、')
486
+ });
487
+ }
488
+ }
489
+
490
+ // ---- 闸门 6:死人不再出场(除非本章登记了复生) ----
491
+ const characters = (characterRaw.data && typeof characterRaw.data === 'object') ? characterRaw.data : {};
492
+ const revivals = Array.isArray(entry.revival_events) ? entry.revival_events.join('、') : '';
493
+ for (const name of Object.keys(characters)) {
494
+ const c = characters[name];
495
+ if (!c || typeof c !== 'object') continue;
496
+ const status = typeof c.status === 'string' ? c.status : '';
497
+ if (!status || !DEAD_STATUS_RE.test(status)) continue;
498
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
499
+ if (!new RegExp(escaped).test(text)) continue;
500
+ if (revivals.indexOf(name) >= 0) continue;
501
+ errors.push({
502
+ code: 'DEAD_CHARACTER_APPEARS',
503
+ path: '台账/人物台账.json#' + name,
504
+ message: '角色「' + name + '」已死亡(status="' + status + '"),却在本章正文里出现',
505
+ hint: '要么改剧情,要么在本章台账的 revival_events 里登记复生(写清角色名与原因)'
506
+ });
507
+ }
508
+ }
509
+
510
+ // ============================================================
511
+ // 主流程
512
+ // ============================================================
513
+ function emit(status, file, errors, warnings) {
514
+ const payload = { status: status, file: file, errors: errors, warnings: warnings };
515
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
516
+ process.exitCode = status;
517
+ // 返回载荷对象:调用方可能用返回值做守卫(如 `if (err) return err;`),
518
+ // 返回 undefined 会让守卫失效导致继续执行并二次输出。
519
+ return payload;
520
+ }
521
+
522
+ function validate(args) {
523
+ const errors = [];
524
+ const warnings = [];
525
+
526
+ const chapterN = parseInt(args.chapter, 10);
527
+ if (!Number.isInteger(chapterN) || chapterN < 1) {
528
+ return emit(2, 'UNKNOWN', [{ code: 'ARG', path: '--chapter', message: '--chapter 必须是正整数', hint: null }], []);
529
+ }
530
+
531
+ if (!args.input) {
532
+ return emit(2, 'UNKNOWN', [{ code: 'ARG', path: '--input', message: '缺少 --input', hint: null }], []);
533
+ }
534
+
535
+ if (!args.upstream || args.upstream.length === 0) {
536
+ return emit(2, 'UNKNOWN', [{ code: 'UPSTREAM_MISSING', path: '', message: '写作阶段必须提供 --upstream=<骨架>,<血肉>,<节奏>', hint: null }], []);
537
+ }
538
+
539
+ // 阶段门禁:骨架/血肉/节奏三个上游都必须已通过全量校验
540
+ // (此前这个判定只写在提示词里,脚本从不读上游状态)
541
+ for (const dir of args.upstream) {
542
+ const g = readStageGate(dir);
543
+ if (!g.ok) {
544
+ return emit(2, '第' + chapterN + '章', [{ code: 'UPSTREAM_NOT_PASSED', path: dir, message: g.reason, hint: '请先对上游阶段执行全量校验并通过' }], []);
545
+ }
546
+ }
547
+
548
+ const textRes = readText(args.input);
549
+ if (textRes.error) {
550
+ return emit(2, '第' + chapterN + '章', [{ code: 'READ', path: args.input, message: textRes.error, hint: null }], []);
551
+ }
552
+ const text = textRes.data;
553
+
554
+ let outline = null;
555
+ let characters = [];
556
+ let avgWords = null;
557
+
558
+ for (const dir of args.upstream) {
559
+ const outlineRes = readJson(path.join(dir, '章节细纲.json'));
560
+ if (outlineRes.data && Array.isArray(outlineRes.data.outlines)) {
561
+ outline = outlineRes.data.outlines.find(o => o.ch === chapterN) || null;
562
+ }
563
+
564
+ const charRes = readJson(path.join(dir, '角色.json'));
565
+ if (charRes.data && Array.isArray(charRes.data.characters)) {
566
+ characters = charRes.data.characters.map(c => ({ name: c.name })).filter(c => c.name);
567
+ }
568
+
569
+ const fwRes = readJson(path.join(dir, '框架.json'));
570
+ if (fwRes.data && fwRes.data.capacity && fwRes.data.capacity.avg_words_per_chapter) {
571
+ avgWords = fwRes.data.capacity.avg_words_per_chapter;
572
+ }
573
+ }
574
+
575
+ // P2-9:outline 或 avgWords 任一缺失就提示上游不完整
576
+ if (!outline || !avgWords) {
577
+ warnings.push({
578
+ code: 'UPSTREAM_PARTIAL',
579
+ path: '',
580
+ message: '上游不完整:' + (!outline ? '缺章节细纲' : '') + (!outline && !avgWords ? '、' : '') + (!avgWords ? '缺框架字数基准' : '') + ',无法做完整对照',
581
+ hint: '确认 --upstream 是否同时包含节奏目录(章节细纲.json)和骨架目录(框架.json)'
582
+ });
583
+ }
584
+
585
+ // 1. 文本内容检查
586
+ textContentCheck(text, errors);
587
+
588
+ // 2. 格式检查(含章节标题 + 正文非空)
589
+ formatCheck(text, chapterN, errors);
590
+
591
+ // 3. 字数检查
592
+ const targetAvg = (outline && outline.word_target) || avgWords;
593
+ if (typeof targetAvg === 'number' && targetAvg > 0) {
594
+ const actual = countChineseChars(text);
595
+ const low = Math.round(targetAvg * 0.8);
596
+ const high = Math.round(targetAvg * 1.2);
597
+ if (actual < low || actual > high) {
598
+ const alreadyReported = errors.some(e => e.code === 'WORD_TARGET_MISMATCH');
599
+ if (!alreadyReported) {
600
+ errors.push({
601
+ code: 'WORD_COUNT',
602
+ path: '正文字数',
603
+ message: '实际字数 ' + actual + ' 偏离基准 ' + targetAvg + ' 超过 ±20% [' + low + ', ' + high + ']',
604
+ hint: null
605
+ });
606
+ }
607
+ }
608
+ }
609
+
610
+ // 4. 与细纲对照
611
+ compareWithOutline(text, chapterN, outline, errors, warnings);
612
+
613
+ // 5. 与人物档案对照
614
+ compareWithCharacter(text, chapterN, characters, errors, warnings);
615
+
616
+ // 6. 阅读动力与台账对照(钩子类型 / 钩子重复 / 微兑现 / 死人出场)
617
+ ledgerChecks(path.dirname(args.input), chapterN, text, errors, warnings);
618
+
619
+ // 7. 更新 stage-status
620
+ const stageDir = path.dirname(args.input);
621
+ updateChapterStatus(stageDir, chapterN, errors.length === 0 ? 0 : 1);
622
+
623
+ emit(errors.length === 0 ? 0 : 1, '第' + chapterN + '章', errors, warnings);
624
+ }
625
+
626
+ // ============================================================
627
+ // stage-status 更新
628
+ // ============================================================
629
+ function updateChapterStatus(stageDir, chapterN, status) {
630
+ const statusPath = path.join(stageDir, '.stage-status.json');
631
+ let s = {
632
+ stage: '写作',
633
+ last_check: null,
634
+ chapters: {},
635
+ last_passed_chapter: 0,
636
+ total_chapters: 0
637
+ };
638
+ if (fs.existsSync(statusPath)) {
639
+ try { s = JSON.parse(fs.readFileSync(statusPath, 'utf-8')); } catch (e) {}
640
+ }
641
+ // 兜底:旧 status 文件可能缺 chapters / last_passed_chapter / total_chapters 字段
642
+ s.stage = s.stage || '写作';
643
+ s.chapters = s.chapters || {};
644
+ s.last_passed_chapter = typeof s.last_passed_chapter === 'number' ? s.last_passed_chapter : 0;
645
+ s.total_chapters = typeof s.total_chapters === 'number' ? s.total_chapters : Object.keys(s.chapters).length;
646
+ s.last_check = new Date().toISOString();
647
+ s.chapters[chapterN] = {
648
+ status: status === 0 ? 'PASSED' : 'FAILED',
649
+ last_check: new Date().toISOString()
650
+ };
651
+ // 连续通过的最大章号:写作阶段的完本判定是 last_passed_chapter === T,
652
+ // 因此必须要求 1..T 全部连续通过。
653
+ // 旧实现取"最大通过章号",导致第 3 章失败、第 5 章通过时 last_passed_chapter=5,
654
+ // 缺口被掩盖,下游会误判为"已完本且全部通过"。
655
+ let contiguous = 0;
656
+ while (s.chapters[String(contiguous + 1)] && s.chapters[String(contiguous + 1)].status === 'PASSED') {
657
+ contiguous++;
658
+ }
659
+ s.last_passed_chapter = contiguous;
660
+ s.max_passed_chapter = Math.max(
661
+ contiguous,
662
+ ...Object.entries(s.chapters)
663
+ .filter(([, v]) => v && v.status === 'PASSED')
664
+ .map(([k]) => Number(k) || 0)
665
+ );
666
+ s.failed_chapters = Object.entries(s.chapters)
667
+ .filter(([, v]) => v && v.status !== 'PASSED')
668
+ .map(([k]) => Number(k) || 0)
669
+ .sort((a, b) => a - b);
670
+ s.total_chapters = Object.keys(s.chapters).length;
671
+ try { fs.writeFileSync(statusPath, JSON.stringify(s, null, 2), 'utf-8'); } catch (e) {}
672
+ }
673
+
674
+ // ============================================================
675
+ // 入口
676
+ // ============================================================
677
+ function main() {
678
+ const args = parseArgs();
679
+ return validate(args);
680
+ }
681
+
682
+ main();