@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,413 @@
1
+ /**
2
+ * content-quality.js
3
+ * 内容质量检查(骨架 / 血肉阶段共用)
4
+ *
5
+ * 补的是一个此前完全空白的校验面:所有既有检查都只管"结构对不对",
6
+ * 不管"内容是不是空的"。实测能拿到 status 0 的数据集里,
7
+ * 实体名全是「主角1 / 核心配角1」这类占位名,自由文本字段全是
8
+ * 「很好」「很厉害」这类空话,而没有任何一条检查会报错。
9
+ *
10
+ * 四类检查:
11
+ * NAME_PLACEHOLDER 泛化名:实体名是「主角1」「核心配角2」这类角色标签
12
+ * TEXT_PLACEHOLDER 占位文本:待补充 / TBD / xxx / 纯标点
13
+ * TEXT_TOO_SHORT 最短长度:叙述字段短到承载不了信息
14
+ * TEXT_DUPLICATE 同质化:两个实体的同一字段几乎一字不差(复制粘贴)
15
+ *
16
+ * 阈值全部集中在下面的常量里,改阈值不用动检查逻辑。
17
+ * 源码中零反引号,避免 markdown 渲染问题。
18
+ */
19
+
20
+ // ============================================================
21
+ // 阈值配置
22
+ // ============================================================
23
+
24
+ // 泛化名的角色标签。命中即认为该实体名是占位名而非真正的名字。
25
+ // 规则:标签本身,或标签 + 数字后缀(主角 / 主角1 / 主角12)。
26
+ const GENERIC_NAME_LABELS = [
27
+ // 角色类型标签
28
+ '主角', '核心配角', '卷级配角', '贯穿级核心配角', '核心反派', '卷级反派',
29
+ '反派', '配角', '角色', '人物', '对手', '敌人', '朋友', '队友',
30
+ // 关系位置标签
31
+ '老师', '导师', '师父', '师兄', '师姐', '师妹', '师弟',
32
+ '哥哥', '姐姐', '弟弟', '妹妹', '丈夫', '妻子',
33
+ // 无信息量标签
34
+ '男人', '女人', '男子', '女子', '少年', '青年', '中年', '老年',
35
+ '无名氏', '路人', '群众', '龙套', '路人甲', '路人乙',
36
+ // 其它实体的类型标签
37
+ '冲突', '势力', '地点', '道具', '结局', '伏笔', '高潮', '爽点',
38
+ '规则', '主线', '支线', '卷', '幕', '幕结构',
39
+ '核心道具', '支线道具', '象征道具',
40
+ // 测试/占位标记
41
+ '测试', '测试角色', '测试人物', '测试数据', '样例', '示例', '例子', '占位'
42
+ ];
43
+
44
+ // 占位文本。命中即认为该字段没有真正内容。
45
+ const PLACEHOLDER_TEXTS = new Set([
46
+ '待补充', '待完善', '待细化', '待定', '待写', '待补', '待确认', '后续补充',
47
+ 'TBD', 'TO DO', 'TODO', 'todo', 'tbd',
48
+ 'N/A', 'n/a', 'null', 'undefined', 'none', 'None',
49
+ 'xxx', 'XXX', 'xx', 'XX', '???', '???',
50
+ '略', '省略', '以下略', '同上', '暂缺', '暂无', '空', '空白', '空缺',
51
+ '占位', 'placeholder', 'PLACEHOLDER', 'Placeholder',
52
+ '示例', '样例', '例子', '测试', 'test', 'TEST', 'Test',
53
+ '-', '--', '—', '——', '_', '/', '/', '?', '?', '!', '!',
54
+ '。', '…', '……', '......', '………。',
55
+ '不知道', '没写', '没想好', '再说', '随机', '随便'
56
+ ]);
57
+
58
+ // 这些字段允许出现"无 / 略"这类极短值,语义上就是"没有例外 / 没有限制"。
59
+ // 不排除的话,正常的世界观规则会被误判成占位。
60
+ const SHORT_VALUE_EXEMPT_FIELDS = new Set(['exception', 'limit']);
61
+
62
+ // 不做长度检查的字段(本来就可以很短,且有专门的检查覆盖)
63
+ // 这些字段要么是纯标签(type / stage / position_level),要么语义上就是短标签
64
+ // (catchphrase 口头禅、theme_short 卷主题),要么是数组里的短条目。
65
+ //
66
+ // genre / book_name 是**输入快照里原样照抄的字段**(提示词要求照抄任务输入):
67
+ // 题材词本来就可能是两个字(历史 / 言情 / 武侠 / 科幻),书名也可能很短。
68
+ // 不豁免的话会报 TEXT_TOO_SHORT,而模型改不了(照抄才是对的),
69
+ // 这条错就永远修不好、还挂在报告里(tt5 实测 2026-10-25:
70
+ // "genre 字段内容过短(有效字符 2,要求至少 3):'历史'")。
71
+ const LENGTH_CHECK_EXEMPT_FIELDS = new Set([
72
+ 'name', 'target_name', 'id', 'type', 'stage', 'gender', 'age_group',
73
+ 'catchphrase', 'behavior_repeat_points',
74
+ 'position_level', 'theme_short', 'stance', 'core_emotion', 'info_name',
75
+ 'focus_points', 'key_event_descriptions', 'memorable_scenes', 'epilogue_hints',
76
+ 'suspense_blanks', 'anti_trope_design', 'hard_constraints', 'anti_tropes',
77
+ 'resources', 'key_elements', 'related_characters', 'related_conflicts',
78
+ 'golden_finger', 'function', 'relation',
79
+ 'genre', 'book_name'
80
+ ]);
81
+
82
+ // "必须是一段话"的字段:短于 6 个有效字符即判为空话。
83
+ // 这些字段承载的是叙述性内容,几个字表达不了一个完整信息。
84
+ const LONG_PROSE_FIELDS = new Set([
85
+ 'description', 'content', 'core_summary', 'core_conflict', 'conflict_summary',
86
+ 'conflict_solution', 'core_goal', 'core_drive', 'influence_scope',
87
+ 'surface_contradiction', 'deep_contradiction', 'ultimate_contradiction',
88
+ 'internal_structure', 'suppression_method', 'resource_seizure_path',
89
+ 'main_line_connection', 'main_relation', 'expected_outcome',
90
+ 'narrative_role_description', 'layout', 'appearance', 'origin',
91
+ 'function_detail', 'symbolic_meaning', 'usage_scenario', 'applicable_scope',
92
+ 'impact_on_protagonist', 'impact_on_plot', 'violation_consequence',
93
+ 'contradiction', 'solution', 'residual_impact', 'resolution_method',
94
+ 'emotion_trajectory', 'final_point', 'special_remaining',
95
+ 'climax_design', 'turning_design', 'cost_design', 'resolution_design',
96
+ 'core_flaw', 'known_secrets', 'unknown_secrets', 'false_beliefs',
97
+ 'long_term', 'medium_term', 'short_term',
98
+ 'speech_rhythm', 'info_preference', 'identity_wording',
99
+ 'personality_tone', 'relationship_stage_tone',
100
+ 'position_description', 'relation_description'
101
+ ]);
102
+
103
+ // 短叙述字段的最低有效字符数
104
+ const MIN_LEN_LONG_PROSE = 6;
105
+ // 普通自由文本字段的最低有效字符数
106
+ const MIN_LEN_DEFAULT = 3;
107
+
108
+ // 同质化判定:二元组 Jaccard 相似度阈值
109
+ const DUP_JACCARD_THRESHOLD = 0.8;
110
+ // 参与同质化比较的文本最少二元组数(太短的文本 bigram 噪声大,不参与)
111
+ const DUP_MIN_BIGRAMS = 8;
112
+
113
+ // ============================================================
114
+ // 字数统计(写作校验器与导入阶段共用同一口径)
115
+ // ============================================================
116
+
117
+ // 反引号字符用 fromCharCode 生成,源码零反引号
118
+ const BT = String.fromCharCode(96);
119
+ const BT3 = BT + BT + BT;
120
+ const BT3_RE = new RegExp(BT3 + '[\\s\\S]*?' + BT3, 'g');
121
+
122
+ /**
123
+ * 正文字数:**计入中文标点**(与网文平台的"字数"口径一致,如起点/番茄
124
+ * 计的是含标点的字符数,不含空白)。不含空白、代码块、行首标题行。
125
+ *
126
+ * 为什么放在共享模块里而不是各写一份:
127
+ * 导入阶段从旧稿反推出的 `avg_words_per_chapter`,
128
+ * 要和写作校验器的 ±20% 字数容差对得上。口径不一致的话,
129
+ * 导入出来的章会被写作阶段误报 WORD_COUNT —— 而这跟正文质量无关。
130
+ */
131
+ export function countChapterChars(text) {
132
+ if (typeof text !== 'string') return 0;
133
+ const stripped = text
134
+ .replace(BT3_RE, '')
135
+ .replace(/^#+\s+.*$/gm, '')
136
+ .replace(/[*_\x60>-]+/g, '')
137
+ .replace(/\s+/g, '');
138
+ return stripped.length;
139
+ }
140
+
141
+ // ============================================================
142
+ // 基础工具
143
+ // ============================================================
144
+
145
+ /**
146
+ * 统计有效字符数:去掉空白与常见标点后的字符数。
147
+ * 只算"承载信息"的字符,避免用标点撑长度。
148
+ */
149
+ export function countEffectiveChars(text) {
150
+ if (typeof text !== 'string') return 0;
151
+ let n = 0;
152
+ for (const ch of text) {
153
+ if (/\s/.test(ch)) continue;
154
+ if (/[,。、;:?!“”‘’()《》【】—…·,.;:?!"'()\[\]{}<>\-_=+*/\\|~`@#$%^&]/.test(ch)) continue;
155
+ n++;
156
+ }
157
+ return n;
158
+ }
159
+
160
+ /**
161
+ * 判断字符串是否"由角色标签构成"(可带数字后缀)。
162
+ */
163
+ export function isGenericName(name) {
164
+ if (typeof name !== 'string') return false;
165
+ const t = name.trim();
166
+ if (!t) return false;
167
+ for (const label of GENERIC_NAME_LABELS) {
168
+ if (t === label) return true;
169
+ if (t.length > label.length && t.startsWith(label) && /^\d+$/.test(t.slice(label.length))) return true;
170
+ }
171
+ return false;
172
+ }
173
+
174
+ /**
175
+ * 判断字符串是否是占位文本。
176
+ */
177
+ export function isPlaceholderText(text) {
178
+ if (typeof text !== 'string') return false;
179
+ const t = text.trim();
180
+ if (!t) return true;
181
+ if (PLACEHOLDER_TEXTS.has(t)) return true;
182
+ // 纯标点
183
+ if (!/[0-9A-Za-z\u4e00-\u9fa5]/.test(t)) return true;
184
+ // 同一个字符重复 3 次以上("啊啊啊"、"。。。"、"———")
185
+ if (new Set(t.split('')).size === 1 && t.length >= 3) return true;
186
+ // x / ? 占位符
187
+ if (/^[xX??**##]{2,}$/.test(t)) return true;
188
+ return false;
189
+ }
190
+
191
+ /**
192
+ * 取中文二元组集合(去重)。
193
+ */
194
+ export function bigrams(text) {
195
+ const t = (text || '').replace(/\s+/g, '');
196
+ const out = new Set();
197
+ for (let i = 0; i + 1 < t.length; i++) out.add(t.slice(i, i + 2));
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * 两个文本的二元组 Jaccard 相似度。
203
+ */
204
+ export function jaccard(a, b) {
205
+ if (!a || !b) return 0;
206
+ let inter = 0;
207
+ for (const x of a) if (b.has(x)) inter++;
208
+ const union = a.size + b.size - inter;
209
+ return union === 0 ? 0 : inter / union;
210
+ }
211
+
212
+ // ============================================================
213
+ // 遍历工具
214
+ // ============================================================
215
+
216
+ /**
217
+ * 递归遍历 JSON,找出所有"自由文本字段"。
218
+ *
219
+ * 返回 [{ path, leaf, text, owner }]:
220
+ * path 点号路径,数组用 [] 表示(characters[].description)
221
+ * leaf 字段名(最后一段)
222
+ * text 字段值(字符串)
223
+ * owner 所属实体的标识(数组下标或对象里的 id/name),用于同质化分组
224
+ */
225
+ export function collectTextFields(data) {
226
+ const out = [];
227
+ if (!data || typeof data !== 'object') return out;
228
+
229
+ const walk = (node, path, leaf, owner) => {
230
+ if (Array.isArray(node)) {
231
+ node.forEach((v, i) => walk(v, path + '[]', leaf, owner + (owner ? '.' : '') + i));
232
+ return;
233
+ }
234
+ if (!node || typeof node !== 'object') return;
235
+ for (const [k, v] of Object.entries(node)) {
236
+ if (typeof v === 'string') {
237
+ out.push({ path: path ? path + '.' + k : k, leaf: k, text: v, owner: owner });
238
+ } else if (v && typeof v === 'object') {
239
+ walk(v, path ? path + '.' + k : k, k, owner);
240
+ }
241
+ }
242
+ };
243
+
244
+ walk(data, '', '', '');
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * 找出"实体名"字段:路径最后一段是 name,且不是引用别人的 name。
250
+ * 引用型 name(members[].name 指向骨架角色)也一并检查——
251
+ * 骨架那边已经是占位名的话,这里同样是占位名。
252
+ */
253
+ function isNameField(path, leaf) {
254
+ return leaf === 'name';
255
+ }
256
+
257
+ /**
258
+ * 从模板 structure 里收集所有 enumRef 字段的叶子名。
259
+ *
260
+ * 枚举值天然很短("个人""伏笔""血缘""对话"),拿叙述字段的长度标准去卡它们
261
+ * 会全线误报。模板是唯一知道"这个字段是枚举"的地方,所以由调用方把模板传进来。
262
+ */
263
+ export function collectEnumLeaves(structure) {
264
+ const leaves = new Set();
265
+ if (!structure || typeof structure !== 'object') return leaves;
266
+ const walk = (node) => {
267
+ if (!node || typeof node !== 'object') return;
268
+ if (Array.isArray(node)) {
269
+ for (const v of node) walk(v);
270
+ return;
271
+ }
272
+ // 命中一个带 enumRef 的字段:它自己的名字就是要排除的叶子名
273
+ if (node.enumRef) leaves.add(node.__leafName || '');
274
+ for (const [k, v] of Object.entries(node.properties || {})) {
275
+ if (v && typeof v === 'object') {
276
+ if (v.enumRef) leaves.add(k);
277
+ walk(v);
278
+ }
279
+ }
280
+ // 数组元素:继续往下走
281
+ if (node.items && typeof node.items === 'object') walk(node.items);
282
+ };
283
+ walk(structure);
284
+ leaves.delete('');
285
+ return leaves;
286
+ }
287
+
288
+ // ============================================================
289
+ // 检查主体
290
+ // ============================================================
291
+
292
+ /**
293
+ * 泛化名检查。
294
+ *
295
+ * 对所有实体的 name 字段判定是否只是角色标签。
296
+ */
297
+ export function checkGenericNames(data, errors) {
298
+ for (const f of collectTextFields(data)) {
299
+ if (!isNameField(f.path, f.leaf)) continue;
300
+ if (!isGenericName(f.text)) continue;
301
+ errors.push({
302
+ code: 'NAME_PLACEHOLDER',
303
+ path: f.path,
304
+ // owner 指出是哪个实体(path 不带下标,见上面 TEXT_PLACEHOLDER 的说明)
305
+ owner: f.owner,
306
+ message: '实体名 "' + f.text.trim() + '" 是泛化占位名,不是真实的名字',
307
+ hint: '换成有辨识度的专名(如"陆子仲");泛化名会让后面所有血肉描写失去落点'
308
+ });
309
+ }
310
+ }
311
+
312
+ /**
313
+ * 占位文本 + 最短长度检查。
314
+ */
315
+ export function checkTextQuality(data, errors, enumLeaves) {
316
+ const enums = enumLeaves || new Set();
317
+ for (const f of collectTextFields(data)) {
318
+ const leaf = f.leaf;
319
+ if (leaf === 'name') continue; // 名字由 NAME_PLACEHOLDER 负责
320
+ if (SHORT_VALUE_EXEMPT_FIELDS.has(leaf)) continue;
321
+ if (enums.has(leaf)) continue; // 枚举值天然短,不按叙述标准卡
322
+
323
+ if (isPlaceholderText(f.text)) {
324
+ errors.push({
325
+ code: 'TEXT_PLACEHOLDER',
326
+ path: f.path,
327
+ // 2026-10-27:错误对象带上 owner(具体是哪个实体)。
328
+ // path 故意**不带下标**(characters[].description)——
329
+ // 同质化检查(checkHomogenizedContent)靠"同路径分组"把不同实体的
330
+ // 同名字段放在一起比较,路径一旦带上下标,分组就散了,
331
+ // 同质化检查会整个失效。所以定位靠 owner,不靠 path。
332
+ owner: f.owner,
333
+ message: '字段内容是占位文本("' + f.text.trim().slice(0, 20) + '"),没有实际信息',
334
+ hint: '这个字段要写实内容,不能留占位符或待办标记'
335
+ });
336
+ continue;
337
+ }
338
+
339
+ if (LENGTH_CHECK_EXEMPT_FIELDS.has(leaf)) continue;
340
+
341
+ const min = LONG_PROSE_FIELDS.has(leaf) ? MIN_LEN_LONG_PROSE : MIN_LEN_DEFAULT;
342
+ const len = countEffectiveChars(f.text);
343
+ if (len >= min) continue;
344
+
345
+ errors.push({
346
+ code: 'TEXT_TOO_SHORT',
347
+ path: f.path,
348
+ message: '字段内容过短(有效字符 ' + len + ',要求至少 ' + min + '):"' + f.text.trim().slice(0, 20) + '"',
349
+ hint: LONG_PROSE_FIELDS.has(leaf)
350
+ ? '这是一个需要成句叙述的字段,至少写清一个具体事实(谁 / 在什么处境下 / 做了什么)'
351
+ : '把这个字段写成具体信息,而不是一个词'
352
+ });
353
+ }
354
+ }
355
+
356
+ /**
357
+ * 同质化检查:同一文件里,不同实体的同一个叙述字段几乎一字不差。
358
+ *
359
+ * 典型场景是 LLM 把一段模板文字复制给了所有角色,字段数都对、内容全一样。
360
+ */
361
+ export function checkHomogenizedContent(data, errors) {
362
+ const fields = collectTextFields(data).filter(
363
+ (f) => LONG_PROSE_FIELDS.has(f.leaf) && !SHORT_VALUE_EXEMPT_FIELDS.has(f.leaf)
364
+ );
365
+
366
+ // 按"字段路径"分组,同一组内互相比较
367
+ const groups = new Map();
368
+ for (const f of fields) {
369
+ if (!groups.has(f.path)) groups.set(f.path, []);
370
+ groups.get(f.path).push(f);
371
+ }
372
+
373
+ const reported = new Set();
374
+ for (const [path, list] of groups) {
375
+ if (list.length < 2) continue;
376
+ const withBigrams = [];
377
+ for (const f of list) {
378
+ const bg = bigrams(f.text);
379
+ if (bg.size < DUP_MIN_BIGRAMS) continue;
380
+ withBigrams.push({ f: f, bg: bg });
381
+ }
382
+ for (let i = 0; i < withBigrams.length; i++) {
383
+ for (let j = i + 1; j < withBigrams.length; j++) {
384
+ const a = withBigrams[i];
385
+ const b = withBigrams[j];
386
+ if (a.f.owner === b.f.owner) continue;
387
+ const sim = jaccard(a.bg, b.bg);
388
+ if (sim < DUP_JACCARD_THRESHOLD) continue;
389
+ const key = path + '|' + [a.f.owner, b.f.owner].sort().join('|');
390
+ if (reported.has(key)) continue;
391
+ reported.add(key);
392
+ errors.push({
393
+ code: 'TEXT_DUPLICATE',
394
+ path: path,
395
+ message: '两个不同实体的「' + path + '」内容几乎完全相同(相似度 ' + sim.toFixed(2) + ')',
396
+ hint: '每个实体写各自的具体内容;逐字复制会让角色失去区分度'
397
+ });
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ /**
404
+ * 一次跑完全部内容质量检查。
405
+ *
406
+ * opts.enumLeaves:模板里 enumRef 字段的叶子名集合,枚举值不参与长度检查。
407
+ */
408
+ export function runContentQualityChecks(data, errors, opts) {
409
+ const enumLeaves = (opts && opts.enumLeaves) || new Set();
410
+ checkGenericNames(data, errors);
411
+ checkTextQuality(data, errors, enumLeaves);
412
+ checkHomogenizedContent(data, errors);
413
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deslop-validate.js —— 去 AI 味检测器(只读)
4
+ *
5
+ * 只检测、不改写。改写由模型按提示词做(插件派活),
6
+ * 脚本负责判定与仲裁:
7
+ * 1. 检测 24 类 AI 模式(词表在 ai-patterns.js,唯一真源)
8
+ * 2. 分级:blocking 命中就判不通过,advisory 只提示
9
+ * 3. 改写后复扫:blocking 清零才算改干净了
10
+ *
11
+ * 为什么不让脚本直接改正文:
12
+ * 旧实现的 9 条正则替换会把句子改坏("此外," 直接删掉,句子就散了)。
13
+ * 机械替换救不了"AI 腔"—— AI 腔是句式问题,不是词的问题。
14
+ *
15
+ * 用法:
16
+ * node deslop-validate.js --input=设定/raw/sources/写作/第001章.md
17
+ * node deslop-validate.js --input=... --fail-on-blocking
18
+ *
19
+ * 退出码:0=通过 1=内容错误(blocking 命中) 2=脚本异常
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ import { detectAiPatterns, AI_PATTERNS } from './ai-patterns.js';
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ // ============================================================
30
+ // 参数与 IO
31
+ // ============================================================
32
+ function parseArgs() {
33
+ const args = process.argv.slice(2);
34
+ const result = {};
35
+ for (const arg of args) {
36
+ const m = arg.match(/^--([\w-]+)=(.+)$/);
37
+ if (m) {
38
+ result[m[1]] = m[2];
39
+ } else if (arg === '--fail-on-blocking') {
40
+ result.failOnBlocking = true;
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ function readText(p) {
47
+ try {
48
+ return { data: fs.readFileSync(p, 'utf-8') };
49
+ } catch (e) {
50
+ return { error: '读取失败: ' + e.message };
51
+ }
52
+ }
53
+
54
+ function emit(status, file, payload) {
55
+ const out = Object.assign({ status: status, file: file }, payload);
56
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
57
+ process.exitCode = status;
58
+ return out;
59
+ }
60
+
61
+ // ============================================================
62
+ // 主流程
63
+ // ============================================================
64
+ function validate(args) {
65
+ if (!args.input) {
66
+ return emit(2, 'UNKNOWN', {
67
+ errors: [{ code: 'ARG', path: '--input', message: '缺少 --input', hint: null }],
68
+ findings: [], byCategory: {}, blockingCount: 0, advisoryCount: 0
69
+ });
70
+ }
71
+
72
+ const textRes = readText(args.input);
73
+ if (textRes.error) {
74
+ return emit(2, path.basename(args.input), {
75
+ errors: [{ code: 'READ', path: args.input, message: textRes.error, hint: null }],
76
+ findings: [], byCategory: {}, blockingCount: 0, advisoryCount: 0
77
+ });
78
+ }
79
+
80
+ const text = textRes.data;
81
+ let detected;
82
+ try {
83
+ detected = detectAiPatterns(text);
84
+ } catch (e) {
85
+ return emit(2, path.basename(args.input), {
86
+ errors: [{ code: 'CHECK_CRASHED', path: args.input, message: '检测器异常: ' + e.message, hint: null }],
87
+ findings: [], byCategory: {}, blockingCount: 0, advisoryCount: 0
88
+ });
89
+ }
90
+
91
+ // 每个类目带上改写指引(给模型看)
92
+ const categories = {};
93
+ for (const id of Object.keys(detected.byCategory)) {
94
+ const c = detected.byCategory[id];
95
+ const def = AI_PATTERNS.find((p) => p.id === id);
96
+ categories[id] = Object.assign({}, c, {
97
+ rewrite: def ? def.rewrite : null,
98
+ no: def ? def.no : null,
99
+ group: def ? def.group : null
100
+ });
101
+ }
102
+
103
+ const blocking = detected.blockingCount;
104
+ const advisory = detected.advisoryCount;
105
+
106
+ // 报告按类目聚合:同一类命中多次只列一次,模型改一处就够
107
+ const errors = [];
108
+ for (const id of Object.keys(categories)) {
109
+ const c = categories[id];
110
+ if (c.severity !== 'blocking') continue;
111
+ errors.push({
112
+ code: 'AI_PATTERN_BLOCKING',
113
+ path: '第 ' + c.matches[0].line + ' 行等 ' + c.count + ' 处',
114
+ message: '命中 AI 模式「' + c.name + '」(第 ' + c.no + ' 类)' + c.count + ' 次',
115
+ hint: c.rewrite
116
+ });
117
+ }
118
+ for (const id of Object.keys(categories)) {
119
+ const c = categories[id];
120
+ if (c.severity !== 'advisory') continue;
121
+ errors.push({
122
+ code: 'AI_PATTERN_ADVISORY',
123
+ path: '第 ' + c.matches[0].line + ' 行等 ' + c.count + ' 处',
124
+ message: '命中 AI 模式「' + c.name + '」(第 ' + c.no + ' 类)' + c.count + ' 次',
125
+ hint: c.rewrite
126
+ });
127
+ }
128
+
129
+ const failOnBlocking = args.failOnBlocking !== undefined;
130
+ const status = failOnBlocking && blocking > 0 ? 1 : 0;
131
+
132
+ return emit(status, path.basename(args.input), {
133
+ errors: errors,
134
+ findings: detected.findings,
135
+ byCategory: categories,
136
+ blockingCount: blocking,
137
+ advisoryCount: advisory,
138
+ totalCategories: Object.keys(categories).length
139
+ });
140
+ }
141
+
142
+ const args = parseArgs();
143
+ validate(args);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * error-classes.js —— 校验错误的分类:内容问题 vs 脚本/环境问题
3
+ *
4
+ * 为什么必须分开:
5
+ * 插件的驱动逻辑是"校验 → 归因 → 重投给模型改"。
6
+ * 但有些错误**不是模型能改的**:
7
+ * GLOSSARY_LOAD —— 词表文件读不到(部署缺文件)
8
+ * FORMULAS_LOAD —— 公式文件读不到
9
+ * TEMPLATE —— 模板读不到
10
+ * READ —— 产物文件读不到
11
+ * ARG —— 调用参数错
12
+ * CHECK_CRASHED —— 检查器自己抛异常
13
+ * 这类错甩给模型毫无意义:提示词明确禁止模型改 `模板/` 和 `references/`,
14
+ * 而且重投多少次结果都一样(模型改不了部署清单)。
15
+ *
16
+ * 真实事故:抽出 scripts/skeleton-quota.js 后部署清单没跟着改,
17
+ * 骨架阶段报 "references/skeleton-quota.js is missing" ——
18
+ * 这是部署缺陷,不是模型写错了产物,却走进了"重投给模型"的队列。
19
+ *
20
+ * 判定标准与 prompt-drift-check.js 共用同一份 INFRA_CODES
21
+ * (那里用它判断"哪些码不必进提示词的速查表"):
22
+ * INFRA_CODES → 脚本/环境问题 → **不发给模型**,单独上报
23
+ * 其余 → 内容问题 → 归因后重投给模型
24
+ */
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ /**
29
+ * 基础设施类错误码:全部对应"脚本自己出问题",不是产物内容的问题。
30
+ *
31
+ * 判据:这个码出现时,重投给模型不可能改变结果。
32
+ */
33
+ export const INFRA_CODES = new Set([
34
+ 'ARG', 'READ', 'TEMPLATE', 'GLOSSARY_LOAD', 'FORMULAS_LOAD', 'CHECK_CRASHED',
35
+ // 插件侧包装出来的两类:校验脚本崩溃 / 输出不是合法 JSON
36
+ 'VALIDATOR_CRASH', 'VALIDATOR_OUTPUT_INVALID',
37
+ // 驱动层(stage-driver.js)的插件级错误:模型改不了,重投也没用
38
+ 'PROMPT_DELIVERY_FAILED', // 提示词投递失败
39
+ 'BATCH_SHARD_MISSING', // 分片文件没写出来
40
+ 'BATCH_SHARD_INVALID', // 分片文件不是合法 JSON
41
+ 'BATCH_GENERATION_FAILED', // 整批生成失败
42
+ 'CHECKPOINT_RESTART_LIMIT', // 中途校验重投到上限仍未通过
43
+ 'SETTLED_PRODUCT_TAMPERED', // 模型越权改了已定稿产物(脚本已用分片覆盖回去)
44
+ 'RESTORE_SETTLED_FAILED' // 恢复已定稿产物失败
45
+ ]);
46
+
47
+ /**
48
+ * 这个错误码是不是"脚本/环境问题"(不该发给模型)。
49
+ * @param {string} code
50
+ * @returns {boolean}
51
+ */
52
+ export function isInfraCode(code) {
53
+ if (!code) return false;
54
+ return INFRA_CODES.has(String(code).trim());
55
+ }
56
+
57
+ /**
58
+ * 把一批校验错误分成两类。
59
+ *
60
+ * @param {Array} errors 校验器返回的 errors[]
61
+ * @returns {{infra: Array, content: Array}}
62
+ * infra —— 脚本/环境问题:不重投,单独上报
63
+ * content —— 产物内容问题:归因后重投给模型
64
+ */
65
+ export function classifyIssues(errors) {
66
+ const infra = [];
67
+ const content = [];
68
+ for (const e of Array.isArray(errors) ? errors : []) {
69
+ if (isInfraCode(e && e.code)) infra.push(e);
70
+ else content.push(e);
71
+ }
72
+ return { infra, content };
73
+ }
74
+
75
+ /**
76
+ * 基础设施错误的一句话说明(给用户看的)。
77
+ * @param {Array} infra classifyIssues 里的 infra 部分
78
+ * @returns {string}
79
+ */
80
+ export function describeInfraIssues(infra) {
81
+ const list = Array.isArray(infra) ? infra : [];
82
+ if (!list.length) return '';
83
+ const codes = [];
84
+ for (const e of list) {
85
+ const c = (e && e.code) || 'ERROR';
86
+ if (codes.indexOf(c) < 0) codes.push(c);
87
+ }
88
+ return '校验脚本自身出问题(不是生成的文件的问题,模型改不了):' + codes.join('、');
89
+ }
90
+
91
+ /**
92
+ * 把基础设施问题渲染成报告里的一段(三个阶段共用)。
93
+ *
94
+ * 单独成段的理由:这类错**没有**重投给模型,
95
+ * 如果混在"校验错误"里,用户会以为模型下一轮能改好。
96
+ *
97
+ * @param {Array} infra classifyIssues 里的 infra 部分
98
+ * @returns {string[]} markdown 行(空数组 = 没有基础设施问题)
99
+ */
100
+ export function renderInfraBlock(infra) {
101
+ const list = Array.isArray(infra) ? infra : [];
102
+ if (!list.length) return [];
103
+ const lines = [];
104
+ lines.push('');
105
+ lines.push('**校验脚本自身的问题(' + list.length + ' 条)**');
106
+ lines.push('这些不是生成的文件的问题:模型改不了 `模板/` 和 `references/`,所以没有重投给模型。');
107
+ for (const e of list.slice(0, 8)) {
108
+ lines.push('- [' + (e.code || 'ERROR') + '] ' + (e.path || '(无路径)') + ':' + (e.message || ''));
109
+ }
110
+ if (list.length > 8) lines.push('- ……还有 ' + (list.length - 8) + ' 条');
111
+ return lines;
112
+ }