@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,437 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 网文创作素材库 · 原子素材 JSONL 校验 / 转 SurrealQL / 追加导入
4
+ *
5
+ * 用法:
6
+ * node import.js --check sample.jsonl
7
+ * node import.js --to-surql sample.surql sample.jsonl
8
+ * node import.js --endpoint http://127.0.0.1:8000 --namespace fancy \
9
+ * --database material_library --user root --pass root sample.jsonl
10
+ *
11
+ * 一条素材有 7 个字段:
12
+ * id, kind, name, tags, genres, note, source
13
+ *
14
+ * 默认只追加:已存在的 id 或同 kind+name 会跳过;--update 才覆盖。
15
+ */
16
+
17
+ import { readFileSync, writeFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { MATERIAL_KINDS } from '../../plugins/fancy-material/kinds.js';
21
+
22
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
23
+ const ALLOWED_FIELDS = new Set(['id', 'kind', 'name', 'tags', 'genres', 'note', 'source']);
24
+ const ID_RE = /^[a-z][a-z0-9_]*$/;
25
+
26
+ class ImportError extends Error {
27
+ constructor(message, line) {
28
+ super(line ? `第 ${line} 行:${message}` : message);
29
+ this.line = line || null;
30
+ }
31
+ }
32
+
33
+ function isPlainObject(value) {
34
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+
37
+ function ensureString(value, field, line) {
38
+ if (typeof value !== 'string' || !value.trim()) {
39
+ throw new ImportError(`字段 ${field} 必须是非空字符串`, line);
40
+ }
41
+ }
42
+
43
+ function ensureStringArray(value, field, line, min, max) {
44
+ if (!Array.isArray(value)) throw new ImportError(`字段 ${field} 必须是数组`, line);
45
+ if (value.length < min || value.length > max) {
46
+ throw new ImportError(`字段 ${field} 需要 ${min}-${max} 项,实际 ${value.length} 项`, line);
47
+ }
48
+ for (const item of value) {
49
+ if (typeof item !== 'string' || !item.trim()) {
50
+ throw new ImportError(`字段 ${field} 的数组元素必须是非空字符串`, line);
51
+ }
52
+ }
53
+ }
54
+
55
+ function validateRecord(raw, line, seenIds) {
56
+ if (!isPlainObject(raw)) throw new ImportError('每行必须是一个 JSON 对象', line);
57
+
58
+ for (const key of Object.keys(raw)) {
59
+ if (!ALLOWED_FIELDS.has(key)) {
60
+ throw new ImportError(`不允许的字段:${key}。原子素材只允许 id/kind/name/tags/genres/note/source`, line);
61
+ }
62
+ }
63
+
64
+ ensureString(raw.id, 'id', line);
65
+ ensureString(raw.kind, 'kind', line);
66
+ ensureString(raw.name, 'name', line);
67
+ ensureStringArray(raw.tags, 'tags', line, 3, 8);
68
+ ensureStringArray(raw.genres, 'genres', line, 1, 3);
69
+ if (raw.note !== undefined) {
70
+ if (typeof raw.note !== 'string') throw new ImportError('note 必须是字符串', line);
71
+ }
72
+ const source = raw.source === undefined ? 'builtin' : raw.source;
73
+ if (source !== 'builtin' && source !== 'other') {
74
+ throw new ImportError("source 只能是 'builtin' 或 'other'", line);
75
+ }
76
+
77
+ const id = raw.id;
78
+ if (!ID_RE.test(id) || id.length > 120) {
79
+ throw new ImportError(`id 只能用小写字母、数字、下划线,且不超过 120 字符:${id}`, line);
80
+ }
81
+ const kind = raw.kind;
82
+ if (source === 'builtin') {
83
+ if (!MATERIAL_KINDS.includes(kind)) {
84
+ throw new ImportError(`builtin 素材的 kind 不在允许列表:${kind}`, line);
85
+ }
86
+ if (!id.startsWith(kind + '_')) {
87
+ throw new ImportError(`builtin 素材的 id 必须以 kind 开头:${kind}_...,实际 ${id}`, line);
88
+ }
89
+ }
90
+ if (seenIds.has(id)) throw new ImportError(`id 重复:${id}`, line);
91
+ seenIds.add(id);
92
+
93
+ return {
94
+ id,
95
+ kind,
96
+ record: {
97
+ source,
98
+ kind,
99
+ name: raw.name.trim(),
100
+ tags: raw.tags.map((item) => item.trim()),
101
+ genres: raw.genres.map((item) => item.trim()),
102
+ note: typeof raw.note === 'string' ? raw.note : '',
103
+ },
104
+ };
105
+ }
106
+
107
+ function parseArgs(argv) {
108
+ const env = process.env;
109
+ const opts = {
110
+ endpoint: env.SURREAL_ENDPOINT || env.SURREAL_URL || 'http://127.0.0.1:8000',
111
+ namespace: env.SURREAL_NAMESPACE || 'fancy',
112
+ database: env.SURREAL_DATABASE || 'material_library',
113
+ user: env.SURREAL_USER || 'root',
114
+ pass: env.SURREAL_PASS || '',
115
+ authLevel: env.SURREAL_AUTH_LEVEL || 'root',
116
+ check: false,
117
+ dryRun: false,
118
+ toSurql: null,
119
+ withSchema: false,
120
+ update: false,
121
+ prefetch: true,
122
+ quiet: false,
123
+ help: false,
124
+ };
125
+ const files = [];
126
+ for (let i = 0; i < argv.length; i += 1) {
127
+ const arg = argv[i];
128
+ const take = (name) => {
129
+ if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
130
+ const next = argv[i + 1];
131
+ if (next === undefined || (next.startsWith('-') && next !== '-')) throw new ImportError(`${name} 需要一个值`);
132
+ i += 1;
133
+ return next;
134
+ };
135
+ if (arg === '--help' || arg === '-h') opts.help = true;
136
+ else if (arg === '--check') opts.check = true;
137
+ else if (arg === '--dry-run') { opts.check = true; opts.dryRun = true; }
138
+ else if (arg.startsWith('--to-surql')) opts.toSurql = take('--to-surql');
139
+ else if (arg === '--with-schema') opts.withSchema = true;
140
+ else if (arg === '--update') opts.update = true;
141
+ else if (arg === '--no-prefetch') opts.prefetch = false;
142
+ else if (arg === '--quiet') opts.quiet = true;
143
+ else if (arg.startsWith('--endpoint')) opts.endpoint = take('--endpoint');
144
+ else if (arg.startsWith('--namespace') || arg.startsWith('--ns')) opts.namespace = take('--namespace');
145
+ else if (arg.startsWith('--database') || arg.startsWith('--db')) opts.database = take('--database');
146
+ else if (arg.startsWith('--user') || arg.startsWith('--username')) opts.user = take('--user');
147
+ else if (arg.startsWith('--pass') || arg.startsWith('--password')) opts.pass = take('--pass');
148
+ else if (arg.startsWith('--auth-level')) opts.authLevel = take('--auth-level');
149
+ else if (arg === '--') {
150
+ files.push(...argv.slice(i + 1));
151
+ break;
152
+ } else if (arg.startsWith('-')) {
153
+ throw new ImportError(`未知参数:${arg}`);
154
+ } else {
155
+ files.push(arg);
156
+ }
157
+ }
158
+ if (!files.length) files.push('sample.jsonl');
159
+ return { opts, files };
160
+ }
161
+
162
+ function loadJsonl(file, seenIds = new Set()) {
163
+ const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
164
+ const lines = text.split(/\r?\n/);
165
+ const records = [];
166
+ for (let i = 0; i < lines.length; i += 1) {
167
+ const line = lines[i].trim();
168
+ if (!line) continue;
169
+ let raw;
170
+ try {
171
+ raw = JSON.parse(line);
172
+ } catch (err) {
173
+ throw new ImportError(`JSON 解析失败:${err.message}`, i + 1);
174
+ }
175
+ records.push(validateRecord(raw, i + 1, seenIds));
176
+ }
177
+ if (!records.length) throw new ImportError(`${file} 里没有素材记录`);
178
+ return records;
179
+ }
180
+
181
+ function surrealLiteral(value) {
182
+ if (value === null) return 'NULL';
183
+ if (typeof value === 'string') return JSON.stringify(value);
184
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
185
+ if (typeof value === 'number') {
186
+ if (!Number.isFinite(value)) throw new ImportError('JSON 里不能有 NaN 或 Infinity');
187
+ return JSON.stringify(value);
188
+ }
189
+ if (Array.isArray(value)) return '[' + value.map(surrealLiteral).join(', ') + ']';
190
+ if (isPlainObject(value)) {
191
+ return '{ ' + Object.entries(value)
192
+ .map(([key, item]) => `${JSON.stringify(key)}: ${surrealLiteral(item)}`)
193
+ .join(', ') + ' }';
194
+ }
195
+ throw new ImportError(`不支持的 JSON 值类型:${typeof value}`);
196
+ }
197
+
198
+ function toSurql(records, opts) {
199
+ const out = [];
200
+ if (opts.withSchema) {
201
+ out.push(readFileSync(resolve(HERE, 'schema.surql'), 'utf8').trimEnd());
202
+ out.push('');
203
+ }
204
+ for (const item of records) {
205
+ const literal = surrealLiteral(item.record);
206
+ out.push(`-- ${item.kind}: ${item.record.name}`);
207
+ if (opts.update) {
208
+ out.push(`UPSERT material:${item.id} MERGE ${literal};`);
209
+ } else {
210
+ const kind = surrealLiteral(item.kind);
211
+ const name = surrealLiteral(item.record.name);
212
+ out.push(`IF (SELECT VALUE id FROM material WHERE id = material:${item.id} OR (kind = ${kind} AND name = ${name})) = [] { CREATE material:${item.id} CONTENT ${literal}; };`);
213
+ }
214
+ }
215
+ return out.join('\n') + '\n';
216
+ }
217
+
218
+ function rpcUrl(endpoint) {
219
+ const base = String(endpoint || '').replace(/\/+$/, '');
220
+ return /\/rpc$/.test(base) ? base : base + '/rpc';
221
+ }
222
+
223
+ async function connectDb(opts) {
224
+ let Surreal;
225
+ let createRemoteEngines;
226
+ let RecordId;
227
+ try {
228
+ ({ Surreal, createRemoteEngines, RecordId } = await import('surrealdb'));
229
+ } catch {
230
+ throw new ImportError('缺少 surrealdb 依赖。请在 fancy-webnovel 仓库内运行,或先 npm install surrealdb。');
231
+ }
232
+ const db = new Surreal({ engines: { ...createRemoteEngines() } });
233
+ await db.connect(rpcUrl(opts.endpoint));
234
+ const signin = { username: opts.user, password: opts.pass };
235
+ if (opts.authLevel === 'namespace' || opts.authLevel === 'ns') signin.namespace = opts.namespace;
236
+ if (opts.authLevel === 'database' || opts.authLevel === 'db') {
237
+ signin.namespace = opts.namespace;
238
+ signin.database = opts.database;
239
+ }
240
+ await db.signin(signin);
241
+ await db.use({ namespace: opts.namespace, database: opts.database });
242
+ return { db, RecordId };
243
+ }
244
+
245
+ async function ensureSchema(db) {
246
+ const info = await db.query('INFO FOR TABLE material;');
247
+ const table = Array.isArray(info) && info[0] ? info[0] : {};
248
+ if (!table.fields || !table.fields.kind || !table.fields.tags) {
249
+ throw new ImportError('目标库还没有原子素材表结构,请先导入 schema.surql。');
250
+ }
251
+ }
252
+
253
+ async function existingMaterialState(db) {
254
+ const rows = await db.query('SELECT id, kind, name FROM material;');
255
+ const list = Array.isArray(rows) && Array.isArray(rows[0]) ? rows[0] : [];
256
+ const ids = new Set();
257
+ const names = new Set();
258
+ for (const row of list) {
259
+ const id = String(row.id || '').replace(/^material:/, '');
260
+ if (id) ids.add(id);
261
+ if (row.kind && row.name) names.add(String(row.kind) + '\u0000' + String(row.name));
262
+ }
263
+ return { ids, names };
264
+ }
265
+
266
+ function duplicateError(err) {
267
+ const message = err && err.message ? err.message : String(err);
268
+ return /already exists|already contains|duplicate|unique|index .*contains/i.test(message);
269
+ }
270
+
271
+ async function importToDb(records, opts) {
272
+ const { db, RecordId } = await connectDb(opts);
273
+ try {
274
+ await ensureSchema(db);
275
+ // Full-table prefetch is convenient for one-off imports, but a queue run
276
+ // imports thousands of small batches against a growing table. In that mode
277
+ // the unique indexes are the source of truth; duplicate CREATEs are caught
278
+ // below and counted as skipped.
279
+ const state = opts.prefetch
280
+ ? await existingMaterialState(db)
281
+ : { ids: new Set(), names: new Set() };
282
+ let imported = 0;
283
+ let skipped = 0;
284
+
285
+ for (const item of records) {
286
+ const pair = String(item.kind) + '\u0000' + String(item.record.name);
287
+ const exists = opts.prefetch && (state.ids.has(item.id) || state.names.has(pair));
288
+ if (exists && !opts.update) {
289
+ skipped += 1;
290
+ continue;
291
+ }
292
+ if (opts.dryRun) {
293
+ imported += 1;
294
+ state.ids.add(item.id);
295
+ state.names.add(pair);
296
+ continue;
297
+ }
298
+ if (opts.update) {
299
+ await db.upsert(new RecordId('material', item.id)).merge(item.record);
300
+ } else {
301
+ try {
302
+ await db.create(new RecordId('material', item.id)).content(item.record);
303
+ } catch (err) {
304
+ if (duplicateError(err)) {
305
+ skipped += 1;
306
+ continue;
307
+ }
308
+ throw err;
309
+ }
310
+ }
311
+ imported += 1;
312
+ state.ids.add(item.id);
313
+ state.names.add(pair);
314
+ if (!opts.quiet && imported % 100 === 0) {
315
+ process.stderr.write(`已导入 ${imported}/${records.length}\n`);
316
+ }
317
+ }
318
+ return { imported, skipped };
319
+ } finally {
320
+ try { await db.close(); } catch { /* ignore */ }
321
+ }
322
+ }
323
+
324
+ function usage() {
325
+ return `网文创作素材库 · 原子素材导入工具
326
+
327
+ 用法:
328
+ node import.js --check <file.jsonl>
329
+ node import.js --to-surql <out.surql> <file.jsonl>
330
+ node import.js [连接参数] <file.jsonl>
331
+
332
+ 一条素材字段:
333
+ id, kind, name, tags, genres, note, source
334
+
335
+ source:
336
+ builtin 内置素材,kind 必须是允许列表之一
337
+ other 自定义素材,kind 不受限制
338
+
339
+ 连接参数:
340
+ --endpoint <url> 默认 $SURREAL_ENDPOINT 或 http://127.0.0.1:8000
341
+ --namespace <ns> 默认 $SURREAL_NAMESPACE 或 fancy
342
+ --database <db> 默认 $SURREAL_DATABASE 或 material_library
343
+ --user <user> 默认 $SURREAL_USER 或 root
344
+ --pass <pass> 默认 $SURREAL_PASS
345
+ --auth-level <level> root | namespace | database(默认 root)
346
+
347
+ 其他:
348
+ --check 只校验 JSONL,不连接数据库
349
+ --dry-run 连接数据库、检查 schema,但不写入
350
+ --to-surql <file> 转成 SurrealQL;file 为 - 时输出到 stdout
351
+ --with-schema 与 --to-surql 连用时,把 schema.surql 一起写进去
352
+ --update 显式覆盖已存在的 id(默认只追加,不覆盖)
353
+ --no-prefetch 不预读全表,依赖唯一索引去重(批量队列用)
354
+ --quiet 不打印进度
355
+ -h, --help 显示本帮助
356
+
357
+ 示例:
358
+ node docs/material-library/import.js --check docs/material-library/sample.jsonl
359
+ node docs/material-library/import.js --to-surql /tmp/materials.surql docs/material-library/sample.jsonl
360
+ node docs/material-library/import.js --endpoint http://127.0.0.1:8000 --user root --pass root docs/material-library/sample.jsonl
361
+ `;
362
+ }
363
+
364
+ async function main() {
365
+ let parsed;
366
+ try {
367
+ parsed = parseArgs(process.argv.slice(2));
368
+ } catch (err) {
369
+ process.stderr.write(String(err.message) + '\n\n' + usage());
370
+ process.exitCode = 2;
371
+ return;
372
+ }
373
+ const { opts, files } = parsed;
374
+ if (opts.help) {
375
+ process.stdout.write(usage());
376
+ return;
377
+ }
378
+
379
+ let records;
380
+ try {
381
+ const seenIds = new Set();
382
+ records = files.flatMap((file) => loadJsonl(file, seenIds));
383
+ } catch (err) {
384
+ process.stderr.write('❌ ' + err.message + '\n');
385
+ process.exitCode = 1;
386
+ return;
387
+ }
388
+
389
+ if (opts.toSurql !== null) {
390
+ let output;
391
+ try {
392
+ output = toSurql(records, opts);
393
+ } catch (err) {
394
+ process.stderr.write('❌ ' + err.message + '\n');
395
+ process.exitCode = 1;
396
+ return;
397
+ }
398
+ if (opts.toSurql === '-' || opts.toSurql === '') {
399
+ process.stdout.write(output);
400
+ } else {
401
+ writeFileSync(opts.toSurql, output, 'utf8');
402
+ if (!opts.quiet) process.stderr.write(`✅ 已写入 ${opts.toSurql}(${records.length} 条素材)\n`);
403
+ }
404
+ return;
405
+ }
406
+
407
+ if (opts.check && !opts.dryRun) {
408
+ if (!opts.quiet) {
409
+ const kinds = new Map();
410
+ for (const item of records) kinds.set(item.kind, (kinds.get(item.kind) || 0) + 1);
411
+ process.stdout.write(`✅ 校验通过:${records.length} 条素材,${kinds.size} 个 kind\n`);
412
+ for (const [kind, count] of [...kinds.entries()].sort()) {
413
+ process.stdout.write(` ${kind}: ${count}\n`);
414
+ }
415
+ }
416
+ return;
417
+ }
418
+
419
+ let result;
420
+ try {
421
+ result = await importToDb(records, opts);
422
+ } catch (err) {
423
+ process.stderr.write('❌ 导入失败:' + err.message + '\n');
424
+ process.exitCode = 1;
425
+ return;
426
+ }
427
+ if (opts.dryRun) {
428
+ process.stdout.write(`✅ 预检通过:预计新增 ${result.imported} 条,跳过 ${result.skipped} 条已存在素材;schema 可用,未写入。\n`);
429
+ } else {
430
+ process.stdout.write(`✅ 导入完成:新增 ${result.imported} 条素材,跳过 ${result.skipped} 条已存在素材。\n`);
431
+ }
432
+ }
433
+
434
+ main().catch((err) => {
435
+ process.stderr.write('❌ ' + (err && err.stack ? err.stack : String(err)) + '\n');
436
+ process.exitCode = 1;
437
+ });