@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

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 (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. 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 +267 -44
  191. 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 +121 -19
  192. 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 +129 -21
  193. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  194. 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 +76 -17
  195. 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 +56 -8
  196. 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 +63 -13
  197. 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 +34 -6
  198. 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 +81 -18
  199. 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 +202 -42
  200. 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 +160 -27
  201. 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 +217 -41
  202. 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 +215 -41
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,251 @@
1
+ /**
2
+ * topic-fields.js —— 选题 JSON 的字段清单(**单一事实来源**)
3
+ *
4
+ * `_novel_topic_suggestion.json` 和 `_novel_topic.json` 共用这套字段:
5
+ *
6
+ * 1. fancy-topic 的探索提示词(字段清单 + JSON 模板);
7
+ * 2. fancy-topic setup 的弹框(一个字段一个问题);
8
+ * 3. 保存脚本 / 写盘脚本的必填校验;
9
+ * 4. fancy-story-builder 的任务输入快照与提示词渲染。
10
+ *
11
+ * 以前字段清单在提示词、templates.js、topic-schema.js、select-topic.js
12
+ * 各写一份,改一处就漂一处。现在只在这里定义,其它地方全部派生。
13
+ *
14
+ * 2026-10-30 重新设计:
15
+ * - 原来的 `core_summary` 与方向设计里的「定位」合并为 `positioning`;
16
+ * - 原来的 `direction_design` 大字符串拆成 11 个平级字段;
17
+ * - 11 个字段的 id / label 来自 `lib/direction-labels.js`,与扫榜报告
18
+ * 的 11 项标签一一对应。
19
+ */
20
+ import { DIRECTION_FIELDS } from '#direction-labels';
21
+
22
+ /** 题材选项(弹框用;value 只用于旧版映射,DSH 回传 label) */
23
+ export const GENRE_OPTIONS = [
24
+ { label: '玄幻', value: 'xianxia' },
25
+ { label: '都市', value: 'urban' },
26
+ { label: '言情', value: 'romance' },
27
+ { label: '悬疑', value: 'mystery' },
28
+ { label: '历史', value: 'history' },
29
+ { label: '科幻', value: 'scifi' },
30
+ { label: '仙侠', value: 'xianxia-cultivation' },
31
+ { label: '其他', value: 'other' },
32
+ ];
33
+
34
+ /**
35
+ * 基础字段(非方向设计)。
36
+ *
37
+ * `prompt` 进探索提示词的字段清单,`sample` 进 JSON 模板;
38
+ * `placeholder` / `description` 只用于 setup 弹框标题。
39
+ */
40
+ const BASE_FIELDS = [
41
+ {
42
+ id: 'book_name',
43
+ label: '书名',
44
+ kind: 'text',
45
+ required: true,
46
+ prompt: '根据榜单上同题材书籍的命名方式,给出契合本平台风格的书名',
47
+ placeholder: '例如:诡秘之主',
48
+ sample: '诡秘之主',
49
+ },
50
+ {
51
+ id: 'genre',
52
+ label: '题材',
53
+ kind: 'select',
54
+ required: true,
55
+ prompt: '根据榜单中的热门题材给出建议',
56
+ options: GENRE_OPTIONS,
57
+ sample: '玄幻',
58
+ },
59
+ {
60
+ id: 'target_words',
61
+ label: '目标字数',
62
+ kind: 'number',
63
+ required: true,
64
+ prompt: '正整数,例如 2000000',
65
+ placeholder: '例如:2000000',
66
+ sample: 2000000,
67
+ },
68
+ {
69
+ id: 'target_chapters',
70
+ label: '目标章数',
71
+ kind: 'number',
72
+ required: true,
73
+ prompt: '正整数,例如 600',
74
+ placeholder: '例如:600',
75
+ sample: 600,
76
+ },
77
+ {
78
+ id: 'direction_title',
79
+ label: '选题方向',
80
+ kind: 'text',
81
+ required: true,
82
+ prompt: '从扫榜报告的「六、选题方向建议」里挑一个方向,写方向标题本身,不要带「方向 N:」前缀',
83
+ placeholder: '例如:都市高武·国术证道',
84
+ description: '扫榜报告里选定的那个方向标题',
85
+ sample: '都市高武·国术证道',
86
+ },
87
+ ];
88
+
89
+ /** 方向设计字段(11 项,顺序与扫榜报告一致) */
90
+ const DESIGN_FIELDS = DIRECTION_FIELDS.map((f) => ({
91
+ id: f.id,
92
+ label: f.topicLabel || f.label,
93
+ kind: 'text',
94
+ required: true,
95
+ prompt: f.description,
96
+ description: f.description,
97
+ sample: f.sample,
98
+ minChars: 20,
99
+ scanLabel: f.label,
100
+ }));
101
+
102
+ const DESIGN_BY_ID = new Map(DESIGN_FIELDS.map(f => [f.id, f]));
103
+
104
+ /**
105
+ * 完整字段顺序。
106
+ *
107
+ * 基础信息 → 选题方向 →「定位 / 核心冲突」→ 硬约束 → 其余方向设计。
108
+ * 顺序同时决定探索提示词的编号、setup 弹框的顺序和写盘参数顺序。
109
+ */
110
+ export const TOPIC_FIELDS = [
111
+ ...BASE_FIELDS.slice(0, 4), // 书名 / 题材 / 目标字数 / 目标章数
112
+ BASE_FIELDS.find(f => f.id === 'direction_title'),
113
+ DESIGN_BY_ID.get('positioning'), // 一句话定位
114
+ {
115
+ id: 'core_conflict',
116
+ label: '核心冲突',
117
+ kind: 'text',
118
+ required: true,
119
+ prompt: '故事的主要矛盾,一句话写清',
120
+ placeholder: '故事的主要矛盾是什么',
121
+ sample: '传统武学与现代秩序的冲突',
122
+ },
123
+ {
124
+ id: 'hard_constraints',
125
+ label: '硬门禁',
126
+ kind: 'multiselect',
127
+ required: true,
128
+ prompt: '绝对不能违反的约束(可多值)',
129
+ description: '绝对不能违反的约束;可多选;如无约束,输入 "无"',
130
+ sample: ['不许烂尾'],
131
+ },
132
+ {
133
+ id: 'anti_tropes',
134
+ label: '反套路',
135
+ kind: 'multiselect',
136
+ required: true,
137
+ prompt: '必须避免的俗套(可多值)',
138
+ description: '必须避免的俗套;可多选;如无,输入 "无"',
139
+ sample: ['不许系统流'],
140
+ },
141
+ ...DESIGN_FIELDS.filter(f => f.id !== 'positioning'),
142
+ ];
143
+
144
+ /** 必填字段 ID(顺序即写盘参数顺序) */
145
+ export const TOPIC_REQUIRED_FIELDS = TOPIC_FIELDS.map(f => f.id);
146
+
147
+ /** 方向设计字段 ID(11 项) */
148
+ export const TOPIC_DESIGN_FIELD_IDS = DIRECTION_FIELDS.map(f => f.id);
149
+
150
+ export const TOPIC_FIELD_BY_ID = new Map(TOPIC_FIELDS.map(f => [f.id, f]));
151
+
152
+ export const TOPIC_TEXT_FIELDS = TOPIC_FIELDS
153
+ .filter(f => f.kind === 'text' || f.kind === 'select')
154
+ .map(f => f.id);
155
+
156
+ export const TOPIC_ARRAY_FIELDS = TOPIC_FIELDS
157
+ .filter(f => f.kind === 'multiselect')
158
+ .map(f => f.id);
159
+
160
+ export const TOPIC_NUMBER_FIELDS = TOPIC_FIELDS
161
+ .filter(f => f.kind === 'number')
162
+ .map(f => f.id);
163
+
164
+ /** 字段中文名(错误消息 / 弹框 / 快照渲染共用) */
165
+ export const TOPIC_FIELD_CN = Object.fromEntries(
166
+ TOPIC_FIELDS.map(f => [f.id, f.label])
167
+ );
168
+
169
+ /** 方向字段中文标签(含扫榜报告里的本名) */
170
+ export const TOPIC_DESIGN_FIELD_CN = Object.fromEntries(
171
+ DIRECTION_FIELDS.map(f => [f.id, f.topicLabel || f.label])
172
+ );
173
+
174
+ /**
175
+ * 生成探索提示词里的字段清单。
176
+ *
177
+ * @returns {string}
178
+ */
179
+ export function topicFieldListBlock() {
180
+ return TOPIC_FIELDS.map((f, i) => {
181
+ const tail = f.prompt ? ` — ${f.prompt}` : '';
182
+ return `${i + 1}. **${f.label}**(${f.id})${tail}`;
183
+ }).join('\n');
184
+ }
185
+
186
+ /** JSON 字符串转义(模板里不能出现未转义的引号/换行) */
187
+ function jsonString(v) {
188
+ return JSON.stringify(String(v));
189
+ }
190
+
191
+ /**
192
+ * 生成探索提示词里的输入 JSON 模板。
193
+ *
194
+ * 数值字段写 0,数组字段给一项示例,文本字段给 sample;
195
+ * 模型只需要把值替换成真实内容。
196
+ *
197
+ * @returns {string}
198
+ */
199
+ export function topicJsonTemplateBlock() {
200
+ const lines = TOPIC_FIELDS.map((f) => {
201
+ let value;
202
+ if (f.kind === 'number') value = '0';
203
+ else if (f.kind === 'multiselect') value = `[${jsonString((f.sample || ['...'])[0])}]`;
204
+ else if (typeof f.sample === 'number') value = String(f.sample);
205
+ else value = jsonString(f.sample || `${f.label}示例`);
206
+ return ` ${jsonString(f.id)}: ${value}`;
207
+ });
208
+ return `{\n${lines.join(',\n')}\n}`;
209
+ }
210
+
211
+ /**
212
+ * 把旧版选题数据迁移成新字段。
213
+ *
214
+ * 兼容两类旧文件:
215
+ * - 旧版 `core_summary` → `positioning`;
216
+ * - 旧版 `direction_design`(11 行文本)→ 11 个平级字段。
217
+ *
218
+ * 新字段已经存在时以新字段为准,不覆盖。
219
+ *
220
+ * @param {object} data
221
+ * @returns {object}
222
+ */
223
+ export function normalizeLegacyTopicData(data) {
224
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
225
+ const out = { ...data };
226
+
227
+ if (typeof out.direction_design === 'string' && out.direction_design.trim()) {
228
+ for (const field of DIRECTION_FIELDS) {
229
+ if (typeof out[field.id] === 'string' && out[field.id].trim()) continue;
230
+ const re = new RegExp(
231
+ '^\\s*(?:[-*+]\\s+)?\\*{0,2}'
232
+ + String(field.label).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
233
+ + '\\*{0,2}\\s*[::]\\s*(.*)$',
234
+ 'm'
235
+ );
236
+ const hit = re.exec(out.direction_design);
237
+ if (hit && hit[1].trim()) out[field.id] = hit[1].trim();
238
+ }
239
+ }
240
+
241
+ // 旧文件里 direction_design 的「定位」信息更完整,优先保留;
242
+ // 没有「定位」时才退回旧版 core_summary(一句话故事)。
243
+ if ((!out.positioning || !String(out.positioning).trim())
244
+ && typeof out.core_summary === 'string' && out.core_summary.trim()) {
245
+ out.positioning = out.core_summary;
246
+ }
247
+
248
+ delete out.direction_design;
249
+ delete out.core_summary;
250
+ return out;
251
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * topic-schema.js —— `_novel_topic` / `_novel_topic_suggestion` 的字段契约
3
+ *
4
+ * 扫榜建议(`_novel_topic_suggestion.json`)和最终敲定(`_novel_topic.json`)
5
+ * 都必须过同一套校验:字段齐全、类型正确、目标字数/章数是正整数、
6
+ * 硬门禁/反套路是字符串数组、11 项方向设计齐全且每项有实质内容。
7
+ *
8
+ * 2026-10-30 起,原来的 `direction_design` 大字符串拆成 11 个平级字段;
9
+ * `core_summary` 与「定位」合并为 `positioning`。字段清单只在
10
+ * `lib/topic-fields.js` 定义一次,提示词、弹框、写盘脚本、story-builder
11
+ * 全部从那里派生。
12
+ */
13
+ import { existsSync, statSync } from 'fs';
14
+ import { isAbsolute, resolve } from 'path';
15
+ import {
16
+ DIRECTION_FIELDS,
17
+ } from '#direction-labels';
18
+ import {
19
+ TOPIC_REQUIRED_FIELDS,
20
+ TOPIC_TEXT_FIELDS,
21
+ TOPIC_ARRAY_FIELDS,
22
+ TOPIC_NUMBER_FIELDS,
23
+ TOPIC_FIELD_BY_ID,
24
+ normalizeLegacyTopicData,
25
+ } from '#topic-fields';
26
+
27
+ /** 项目允许的平台(中文名;与 fancy-topic 的 PLATFORM_OPTIONS 一致) */
28
+ export const TOPIC_PLATFORMS = ['番茄', '起点', '晋江', '七猫'];
29
+
30
+ /** 目标章数上限(与 select-topic.js 的历史上限一致) */
31
+ export const MAX_TARGET_CHAPTERS = 5000;
32
+
33
+ /** 目标字数上限:避免把“亿”当成字数写进下游公式 */
34
+ export const MAX_TARGET_WORDS = 100_000_000;
35
+
36
+ /** 方向设计字段 ID(11 项) */
37
+ export const DESIGN_FIELD_IDS = DIRECTION_FIELDS.map(f => f.id);
38
+
39
+ const DESIGN_FIELD_ID_SET = new Set(DESIGN_FIELD_IDS);
40
+
41
+ /** 常见占位值 —— 模型原样抄模板时必须被抓出来 */
42
+ const PLACEHOLDER_VALUES = new Set([
43
+ '书名', '题材', '一句话故事', '核心冲突', '一句话定位',
44
+ '约束', '反套路', '方向名', '定位:…', '定位:...',
45
+ '…', '...',
46
+ ]);
47
+
48
+ function isPlainObject(v) {
49
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
50
+ }
51
+
52
+ function hasControlChars(s, allowNewline = false) {
53
+ const re = allowNewline ? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ : /[\x00-\x1f\x7f]/;
54
+ return re.test(s);
55
+ }
56
+
57
+ function nonEmptyString(v) {
58
+ return typeof v === 'string' && v.trim().length > 0;
59
+ }
60
+
61
+ /**
62
+ * 把旧版选题数据迁移成新字段(供 fancy-topic / story-builder 读取旧文件)。
63
+ *
64
+ * @param {object} data
65
+ * @returns {object}
66
+ */
67
+ export function normalizeTopicData(data) {
68
+ return normalizeLegacyTopicData(data);
69
+ }
70
+
71
+ /**
72
+ * 校验一份选题数据。
73
+ *
74
+ * 旧版 `_novel_topic.json`(含 `direction_design` / `core_summary`)会先
75
+ * 迁移成新字段再校验;写盘输出始终是新结构。
76
+ *
77
+ * @param {object} data
78
+ * @param {object} [opts]
79
+ * @param {string} [opts.expectedPlatform] 期望的平台中文名
80
+ * @param {string} [opts.projectRoot] 项目根(用于校验 source_decision_file 不越界)
81
+ * @param {boolean} [opts.requireSource] 是否要求 source_decision_file
82
+ * @param {number} [opts.minDesignChars] 方向设计每项最少汉字数(默认 20)
83
+ * @returns {{ok: boolean, errors: string[], warnings: string[], normalized: object|null}}
84
+ */
85
+ export function validateTopicData(data, opts = {}) {
86
+ const errors = [];
87
+ const warnings = [];
88
+
89
+ if (!isPlainObject(data)) {
90
+ return { ok: false, errors: ['顶层必须是 JSON 对象'], warnings, normalized: null };
91
+ }
92
+
93
+ const d = normalizeLegacyTopicData(data);
94
+ const expectedPlatform = opts.expectedPlatform ? String(opts.expectedPlatform) : '';
95
+ const minDesignChars = Number.isInteger(opts.minDesignChars) ? opts.minDesignChars : 20;
96
+
97
+ // ── 必填 / 类型 ────────────────────────────────────────────────
98
+ for (const f of TOPIC_REQUIRED_FIELDS) {
99
+ if (!(f in d)) {
100
+ errors.push(`缺字段: ${f}`);
101
+ continue;
102
+ }
103
+ const v = d[f];
104
+ if (v === undefined || v === null || v === '') {
105
+ errors.push(`${f} 不能为空`);
106
+ continue;
107
+ }
108
+
109
+ if (TOPIC_NUMBER_FIELDS.includes(f)) {
110
+ if (!Number.isInteger(v) || v <= 0) {
111
+ errors.push(`${f} 必须是正整数(当前 ${JSON.stringify(v)})`);
112
+ } else if (f === 'target_chapters' && v > MAX_TARGET_CHAPTERS) {
113
+ errors.push(`${f} 超过上限 ${MAX_TARGET_CHAPTERS}(当前 ${v}),请拆成多部书`);
114
+ } else if (f === 'target_words' && v > MAX_TARGET_WORDS) {
115
+ errors.push(`${f} 超过上限 ${MAX_TARGET_WORDS}(当前 ${v})`);
116
+ }
117
+ continue;
118
+ }
119
+
120
+ if (TOPIC_ARRAY_FIELDS.includes(f)) {
121
+ if (!Array.isArray(v)) {
122
+ errors.push(`${f} 必须是字符串数组`);
123
+ } else if (v.length === 0) {
124
+ errors.push(`${f} 不能是空数组(没有就写 ["无"])`);
125
+ } else {
126
+ v.forEach((item, i) => {
127
+ if (!nonEmptyString(item)) errors.push(`${f}[${i}] 必须是非空字符串`);
128
+ else if (hasControlChars(item)) errors.push(`${f}[${i}] 不能含换行/控制字符`);
129
+ else if (PLACEHOLDER_VALUES.has(item.trim())) {
130
+ errors.push(`${f}[${i}] 还是占位值「${item.trim()}」,必须替换成真实内容`);
131
+ }
132
+ });
133
+ }
134
+ continue;
135
+ }
136
+
137
+ if (TOPIC_TEXT_FIELDS.includes(f)) {
138
+ const field = TOPIC_FIELD_BY_ID.get(f);
139
+ const label = field ? field.label : f;
140
+ if (typeof v !== 'string') {
141
+ errors.push(`${f} 必须是字符串`);
142
+ } else if (!v.trim()) {
143
+ errors.push(`${f} 不能为空`);
144
+ } else if (hasControlChars(v, DESIGN_FIELD_ID_SET.has(f))) {
145
+ errors.push(`${f} 不能含换行/控制字符`);
146
+ } else if (PLACEHOLDER_VALUES.has(v.trim())) {
147
+ errors.push(`${f} 还是占位值「${v.trim()}」,必须替换成真实内容`);
148
+ } else if (f === 'direction_title' && /^方向\s*\d+\s*[::]/.test(v.trim())) {
149
+ errors.push('direction_title 不要带「方向 N:」前缀,只写方向标题本身');
150
+ } else if (DESIGN_FIELD_ID_SET.has(f) && cjkCount(v) < minDesignChars) {
151
+ errors.push(`${label}(${f})内容太短:当前 ${cjkCount(v)} 个汉字,要求 ≥ ${minDesignChars}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ // ── 平台 ──────────────────────────────────────────────────────
157
+ const platform = d.platform;
158
+ if (platform === undefined || platform === null || platform === '') {
159
+ errors.push('缺字段: platform');
160
+ } else if (typeof platform !== 'string' || !TOPIC_PLATFORMS.includes(platform)) {
161
+ errors.push(`platform 必须是 ${TOPIC_PLATFORMS.join(' / ')} 之一(当前 ${JSON.stringify(platform)})`);
162
+ } else if (expectedPlatform && platform !== expectedPlatform) {
163
+ errors.push(`platform 应为 ${expectedPlatform},实际 ${platform}`);
164
+ }
165
+
166
+ // ── 方向设计 11 项:每项太短已在上面逐条点名 ────────────────────
167
+ for (const field of DIRECTION_FIELDS) {
168
+ if (typeof d[field.id] === 'string' && /…|\.\.\./.test(d[field.id])) {
169
+ warnings.push(`${field.topicLabel || field.label}里还出现省略号,确认不是模板占位符`);
170
+ }
171
+ }
172
+
173
+ // ── source_decision_file(可选,但给了就必须是真的文件) ────────
174
+ const source = d.source_decision_file;
175
+ if (source !== undefined && source !== null && source !== '') {
176
+ if (typeof source !== 'string' || !source.trim()) {
177
+ errors.push('source_decision_file 必须是非空字符串');
178
+ } else if (!isAbsolute(source)) {
179
+ errors.push('source_decision_file 必须是绝对路径');
180
+ } else {
181
+ let sourceOk = false;
182
+ try { sourceOk = existsSync(source) && statSync(source).isFile(); } catch { sourceOk = false; }
183
+ if (!sourceOk) {
184
+ errors.push(`source_decision_file 不存在或不是文件: ${source}`);
185
+ } else if (opts.projectRoot) {
186
+ const root = resolve(opts.projectRoot);
187
+ const target = resolve(source);
188
+ const rel = target.startsWith(root + '/') ? target.slice(root.length + 1) : '';
189
+ if (!rel || rel.startsWith('..')) {
190
+ errors.push('source_decision_file 必须位于项目根内');
191
+ }
192
+ }
193
+ }
194
+ } else if (opts.requireSource) {
195
+ errors.push('缺字段: source_decision_file');
196
+ }
197
+
198
+ // ── 归一化(只在通过时返回) ──────────────────────────────────
199
+ let normalized = null;
200
+ if (!errors.length) {
201
+ normalized = {};
202
+ for (const f of TOPIC_REQUIRED_FIELDS) {
203
+ const v = d[f];
204
+ normalized[f] = TOPIC_ARRAY_FIELDS.includes(f)
205
+ ? v.map(x => x.trim())
206
+ : (typeof v === 'string' ? v.trim() : v);
207
+ }
208
+ normalized.platform = platform;
209
+ if (source) normalized.source_decision_file = source;
210
+ }
211
+
212
+ return { ok: errors.length === 0, errors, warnings, normalized };
213
+ }
214
+
215
+ /** 数汉字(与 direction-labels 的口径一致) */
216
+ function cjkCount(text) {
217
+ let n = 0;
218
+ for (const ch of String(text || '')) {
219
+ const c = ch.codePointAt(0);
220
+ if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf)) n++;
221
+ }
222
+ return n;
223
+ }
224
+
225
+ export { TOPIC_REQUIRED_FIELDS };