@cloud411716/fancy-webnovel 2.2.0 → 3.0.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 (211) hide show
  1. package/client.js +201 -15
  2. package/cordis.patch.yml +5 -0
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +11 -7
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/confirmation.js +80 -0
  18. package/lib/db-init.js +123 -0
  19. package/lib/deployed-state.js +11 -11
  20. package/lib/direction-labels.js +265 -0
  21. package/lib/error-codes.js +31 -31
  22. package/lib/infra.js +29 -4
  23. package/lib/material-db.js +149 -0
  24. package/lib/path-guard.js +150 -0
  25. package/lib/projects.js +400 -0
  26. package/lib/scraper-utils.js +140 -0
  27. package/lib/surreal-server.js +338 -0
  28. package/lib/topic-decision.js +860 -0
  29. package/lib/topic-fields.js +251 -0
  30. package/lib/topic-schema.js +225 -0
  31. package/lib/workspace.js +501 -0
  32. package/package.json +29 -4
  33. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  34. package/plugins/fancy-analyze/index.js +1128 -77
  35. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  36. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  37. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  38. package/plugins/fancy-analyze/summary-progress.js +71 -7
  39. package/plugins/fancy-analyze/templates.js +437 -139
  40. package/plugins/fancy-bootstrap/index.js +276 -131
  41. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  42. package/plugins/fancy-bootstrap/templates.js +35 -18
  43. package/plugins/fancy-db/index.js +172 -0
  44. package/plugins/fancy-douyin/deploy.js +134 -0
  45. package/plugins/fancy-douyin/index.js +551 -0
  46. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  47. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  48. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  49. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  50. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  51. package/plugins/fancy-douyin/state.js +197 -0
  52. package/plugins/fancy-douyin/templates.js +127 -0
  53. package/plugins/fancy-export/exporter.js +805 -0
  54. package/plugins/fancy-export/index.js +63 -0
  55. package/plugins/fancy-import/import-driver.js +29 -6
  56. package/plugins/fancy-import/index.js +13 -5
  57. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  58. package/plugins/fancy-import/templates.js +17 -8
  59. package/plugins/fancy-material/exporter.js +153 -0
  60. package/plugins/fancy-material/genres.js +256 -0
  61. package/plugins/fancy-material/index.js +835 -0
  62. package/plugins/fancy-material/inventory.js +143 -0
  63. package/plugins/fancy-material/kinds.js +313 -0
  64. package/plugins/fancy-material/queue.js +55 -0
  65. package/plugins/fancy-material/scripts/query.js +254 -0
  66. package/plugins/fancy-material/templates.js +258 -0
  67. package/plugins/fancy-material/tool-deploy.js +138 -0
  68. package/plugins/fancy-material/validate.js +149 -0
  69. package/plugins/fancy-project/index.js +133 -0
  70. package/plugins/fancy-scan/existing-block.js +100 -0
  71. package/plugins/fancy-scan/index.js +410 -21
  72. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  73. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  74. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  75. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  76. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  77. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  78. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  79. package/plugins/fancy-scan/templates/blocks.js +155 -0
  80. package/plugins/fancy-scan/templates/platforms.js +372 -0
  81. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  82. package/plugins/fancy-scan/templates.js +545 -146
  83. package/plugins/fancy-scan/tool-deploy.js +335 -0
  84. package/plugins/fancy-scan/wait-turn.js +46 -0
  85. package/plugins/fancy-story-builder/db/README.md +106 -0
  86. package/plugins/fancy-story-builder/db/client.js +228 -0
  87. package/plugins/fancy-story-builder/db/commands.js +145 -0
  88. package/plugins/fancy-story-builder/db/ids.js +39 -0
  89. package/plugins/fancy-story-builder/db/import.js +798 -0
  90. package/plugins/fancy-story-builder/db/project.js +299 -0
  91. package/plugins/fancy-story-builder/db/query.js +361 -0
  92. package/plugins/fancy-story-builder/db/schema.js +417 -0
  93. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  94. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  95. package/plugins/fancy-story-builder/index.js +4105 -396
  96. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  97. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  98. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  99. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  100. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  101. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  102. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  103. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  104. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  105. package/plugins/fancy-story-builder/references/index.md +97 -6
  106. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  108. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  111. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  115. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  116. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  117. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  118. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  119. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  120. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  121. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  122. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  123. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  124. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  125. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  126. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  127. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  128. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  129. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  130. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  131. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  132. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  133. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  134. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  135. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  136. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  137. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  138. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  139. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  140. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  141. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  142. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  143. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  144. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  145. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  146. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  147. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  148. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  149. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  150. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  151. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  152. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  153. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  154. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  155. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  156. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  157. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  158. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  159. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  160. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  161. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  162. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  163. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  164. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  165. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  166. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  167. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  168. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  185. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  187. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  188. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  189. 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
  190. 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
  191. 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
  192. 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
  193. 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
  194. 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
  195. 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
  196. 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
  197. 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
  198. 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
  199. 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
  200. 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
  201. 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
  202. package/plugins/fancy-story-builder/templates.js +272 -74
  203. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  204. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  205. package/plugins/fancy-topic/index.js +414 -61
  206. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  207. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  208. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  209. package/plugins/fancy-topic/templates.js +225 -122
  210. package/plugins/fancy-topic/tool-deploy.js +200 -0
  211. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,551 @@
1
+ /**
2
+ * fancy-douyin — crawl Douyin image-text works into the material library.
3
+ *
4
+ * Commands:
5
+ * /fancy-douyin crawl every recorded account
6
+ * /fancy-douyin add <主页地址> record an account
7
+ * /fancy-douyin list list recorded accounts
8
+ * /fancy-douyin remove <序号|地址> remove an account
9
+ * /fancy-douyin help
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { RecordId } from 'surrealdb';
15
+
16
+ import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
17
+ import { readJsonFile, resolveWorkspace } from '#workspace';
18
+ import { connectMaterial, ensureMaterialDatabase } from '#material-db';
19
+ import { deployMaterialTools } from '../fancy-material/tool-deploy.js';
20
+ import { MATERIAL_KINDS } from '../fancy-material/kinds.js';
21
+ import { makeWaitForTurn } from '../fancy-story-builder/writing-driver.js';
22
+ import { deployDouyinTools } from './deploy.js';
23
+ import {
24
+ accountListText,
25
+ accountSlug,
26
+ addAccount,
27
+ douyinPaths,
28
+ ensureDouyinDir,
29
+ markCrawled,
30
+ noteIdFromUrl,
31
+ readAccounts,
32
+ readCrawled,
33
+ removeAccount,
34
+ } from './state.js';
35
+ import { analysisPrompt, helpText, loginQuestion, summaryText } from './templates.js';
36
+
37
+ export const inject = ['commands', 'userQuestions'];
38
+
39
+ const ANALYSIS_TIMEOUT_MS = 20 * 60 * 1000;
40
+ const ANALYSIS_IDLE_GRACE_MS = 60 * 1000;
41
+ const SCRIPT_TIMEOUT_MS = 4 * 60 * 1000;
42
+ const LOGIN_RETRIES = 5;
43
+ const NOTE_DELAY_MIN_MS = 6000;
44
+ const NOTE_DELAY_MAX_MS = 12000;
45
+ const DUPLICATE_RE = /already exists|already contains|duplicate|unique|index .*contains/i;
46
+
47
+ function cwdOf(session) {
48
+ return session && session.header && session.header.cwd ? session.header.cwd : null;
49
+ }
50
+
51
+ function workspaceOf(session) {
52
+ return resolveWorkspace({ cwd: cwdOf(session), required: true });
53
+ }
54
+
55
+ function sleep(ms) {
56
+ return new Promise((resolve) => setTimeout(resolve, ms));
57
+ }
58
+
59
+ function abortRequested(invocation) {
60
+ return Boolean(invocation && invocation.signal && invocation.signal.aborted);
61
+ }
62
+
63
+ function stampOfNow() {
64
+ return new Date().toISOString().replace(/[-:T+.]/g, '').slice(0, 14);
65
+ }
66
+
67
+ function parseToolOutput(result) {
68
+ try {
69
+ return JSON.parse(String(result && result.output || ''));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ async function runTool(toolPath, args, { root, nodePath, timeout = SCRIPT_TIMEOUT_MS }) {
76
+ const env = { ...process.env };
77
+ if (nodePath) {
78
+ env.NODE_PATH = [nodePath, env.NODE_PATH || ''].filter(Boolean).join(path.delimiter);
79
+ }
80
+ const result = await spawnScript(process.execPath, [toolPath, ...args], {
81
+ timeout,
82
+ cwd: root,
83
+ env,
84
+ });
85
+ return { result, json: parseToolOutput(result) };
86
+ }
87
+
88
+ function hasDouyinLogin(paths) {
89
+ const data = readJsonFile(paths.loginState);
90
+ const cookies = Array.isArray(data && data.cookies) ? data.cookies : [];
91
+ const authNames = new Set(['sessionid', 'sessionid_ss', 'passport_auth_status', 'sid_tt', 'sid_guard']);
92
+ return cookies.some((cookie) => cookie && authNames.has(cookie.name));
93
+ }
94
+
95
+ async function startChrome(root, tools, paths, mode) {
96
+ const started = await runTool(tools.scripts.start, [
97
+ '--workspace', root,
98
+ '--config', paths.chrome,
99
+ '--profile', paths.profile,
100
+ '--mode', mode,
101
+ ], {
102
+ root,
103
+ nodePath: tools.nodePath,
104
+ timeout: 90000,
105
+ });
106
+ if (!started.json || started.json.ok !== true) {
107
+ return {
108
+ ok: false,
109
+ error: (started.json && started.json.error)
110
+ || (started.result && (started.result.stderr || started.result.error))
111
+ || '未知错误',
112
+ };
113
+ }
114
+ return {
115
+ ok: true,
116
+ cdp: started.json.cdp,
117
+ mode: started.json.mode || mode,
118
+ };
119
+ }
120
+
121
+ async function stopChrome(root, tools, paths) {
122
+ try {
123
+ await runTool(tools.scripts.start, [
124
+ '--workspace', root,
125
+ '--config', paths.chrome,
126
+ '--stop',
127
+ ], {
128
+ root,
129
+ nodePath: tools.nodePath,
130
+ timeout: 30000,
131
+ });
132
+ } catch { /* best effort */ }
133
+ }
134
+
135
+ async function runWithLogin(ctx, invocation, session, root, toolPath, args, outDir, paths, label, nodePath, browser, tools) {
136
+ for (let attempt = 1; attempt <= LOGIN_RETRIES; attempt += 1) {
137
+ const { result, json } = await runTool(toolPath, args, { root, nodePath });
138
+ if (json && json.login_required) {
139
+ if (browser.mode === 'headless') {
140
+ // Headless cannot show a login UI. Close it, start headed Chrome and
141
+ // retry the same step so the user can complete the login.
142
+ const switched = await startChrome(root, tools, paths, 'headed');
143
+ if (!switched.ok) return { ok: false, error: '切换到有头 Chrome 失败:' + switched.error };
144
+ browser.mode = 'headed';
145
+ browser.cdp = switched.cdp;
146
+ const cdpIndex = args.indexOf('--cdp');
147
+ if (cdpIndex >= 0) args[cdpIndex + 1] = browser.cdp;
148
+ continue;
149
+ }
150
+ const asked = await askUserQuestion(ctx, invocation, [loginQuestion(paths)]);
151
+ if (!asked.ok) {
152
+ return { ok: false, error: asked.failure?.message || '提问失败' };
153
+ }
154
+ const selected = asked.answer?.answers?.[0]?.selected?.[0];
155
+ if (selected !== '已登陆') {
156
+ return { ok: false, cancelled: true, error: '已取消' };
157
+ }
158
+ await sleep(1200);
159
+ continue;
160
+ }
161
+ if (!json) {
162
+ return {
163
+ ok: false,
164
+ error: (result.stderr || result.error || '脚本没有返回 JSON').trim(),
165
+ };
166
+ }
167
+ if (!result.ok || json.ok !== true) {
168
+ return { ok: false, error: json.error || result.error || (label + '失败') };
169
+ }
170
+ return { ok: true, json };
171
+ }
172
+ return { ok: false, error: '连续 ' + LOGIN_RETRIES + ' 次检测到登录/验证,已停止' };
173
+ }
174
+
175
+ function normalizeOtherFile(file) {
176
+ const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
177
+ const out = [];
178
+ for (const line of text.split(/\r?\n/)) {
179
+ if (!line.trim()) continue;
180
+ let record;
181
+ try {
182
+ record = JSON.parse(line);
183
+ } catch (err) {
184
+ throw new Error('JSON 解析失败:' + (err && err.message ? err.message : String(err)));
185
+ }
186
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
187
+ throw new Error('JSONL 行不是对象');
188
+ }
189
+ record.source = 'other';
190
+ out.push(JSON.stringify(record));
191
+ }
192
+ if (!out.length) throw new Error('LLM 没有写入任何素材');
193
+ fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
194
+ return out.length;
195
+ }
196
+
197
+ async function validateOtherFile(file, validateScript) {
198
+ const result = await spawnScript(process.execPath, [validateScript, file], { timeout: 60000 });
199
+ if (result.code !== 0) {
200
+ return {
201
+ ok: false,
202
+ detail: (result.stderr || result.output || result.error || '格式校验失败').trim(),
203
+ };
204
+ }
205
+ return { ok: true, detail: '' };
206
+ }
207
+
208
+ function readJsonl(file) {
209
+ return fs.readFileSync(file, 'utf8')
210
+ .replace(/^\uFEFF/, '')
211
+ .split(/\r?\n/)
212
+ .filter((line) => line.trim())
213
+ .map((line) => JSON.parse(line));
214
+ }
215
+
216
+ export async function importOtherMaterials(root, file) {
217
+ const records = readJsonl(file);
218
+ const { db } = await connectMaterial(root);
219
+ let inserted = 0;
220
+ let skipped = 0;
221
+ try {
222
+ for (const record of records) {
223
+ try {
224
+ await db.create(new RecordId('material', record.id)).content({
225
+ source: 'other',
226
+ kind: record.kind,
227
+ name: record.name,
228
+ tags: record.tags,
229
+ genres: record.genres,
230
+ note: typeof record.note === 'string' ? record.note : '',
231
+ });
232
+ inserted += 1;
233
+ } catch (err) {
234
+ if (DUPLICATE_RE.test(err && err.message ? err.message : String(err))) {
235
+ skipped += 1;
236
+ continue;
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+ } finally {
242
+ try { await db.close(); } catch { /* ignore */ }
243
+ }
244
+ return { inserted, skipped };
245
+ }
246
+
247
+ function retryPrompt(target, detail) {
248
+ return [
249
+ '上一批素材文件没有通过校验。',
250
+ '',
251
+ '目标文件:`' + target + '`',
252
+ '',
253
+ '校验错误:',
254
+ '```text',
255
+ String(detail || '').trim(),
256
+ '```',
257
+ '',
258
+ '请只修正这些问题,重新写入同一个目标文件。',
259
+ '保持 source 为 other;不要删除已经正确的素材,也不要输出 Markdown。',
260
+ '写完后运行 validate.js,只回复:已写入 ' + target,
261
+ ].join('\n');
262
+ }
263
+
264
+ async function analyzeNote(ctx, invocation, session, {
265
+ root, note, screenshots, target, materialTools, paths,
266
+ }) {
267
+ const prompt = analysisPrompt({
268
+ accountUrl: note.account_url,
269
+ note,
270
+ screenshots,
271
+ targetFile: target,
272
+ workspace: root,
273
+ materialKinds: MATERIAL_KINDS,
274
+ queryScript: materialTools.queryScript,
275
+ validateScript: materialTools.validateScript,
276
+ });
277
+ const waitForTurn = makeWaitForTurn(invocation, {
278
+ timeoutMs: ANALYSIS_TIMEOUT_MS,
279
+ idleGraceMs: ANALYSIS_IDLE_GRACE_MS,
280
+ signal: invocation.signal,
281
+ changed: (files, before) => {
282
+ try {
283
+ return fs.readFileSync(files[0], 'utf8') !== before;
284
+ } catch {
285
+ return true;
286
+ }
287
+ },
288
+ });
289
+
290
+ let detail = '';
291
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
292
+ let before = null;
293
+ try { before = fs.readFileSync(target, 'utf8'); } catch { /* new file */ }
294
+ const sent = await llmFollowup(invocation, attempt === 1 ? prompt : retryPrompt(target, detail), {
295
+ appendTempRule: false,
296
+ });
297
+ if (!sent || sent.ok !== true) {
298
+ return { ok: false, error: '提示词发送失败:' + ((sent && sent.error) || '未知错误') };
299
+ }
300
+ const waited = await waitForTurn([target], before);
301
+ if (!waited.ok && waited.code === 'ABORTED') {
302
+ return { ok: false, aborted: true, error: '用户中断' };
303
+ }
304
+ if (!waited.ok && waited.code === 'TIMEOUT') {
305
+ return { ok: false, fatal: true, error: waited.reason || '等待 LLM 超时' };
306
+ }
307
+ if (!waited.ok && waited.code !== 'NO_CHANGE') {
308
+ detail = waited.reason || waited.code || '等待 LLM 写入失败';
309
+ continue;
310
+ }
311
+ if (!waited.ok && waited.code === 'NO_CHANGE') {
312
+ detail = 'LLM 本轮结束但没有写入目标文件';
313
+ continue;
314
+ }
315
+
316
+ try {
317
+ normalizeOtherFile(target);
318
+ } catch (err) {
319
+ detail = err && err.message ? err.message : String(err);
320
+ continue;
321
+ }
322
+ const checked = await validateOtherFile(target, materialTools.validateScript);
323
+ if (!checked.ok) {
324
+ detail = checked.detail;
325
+ continue;
326
+ }
327
+ try {
328
+ const result = await importOtherMaterials(root, target);
329
+ return { ok: true, ...result };
330
+ } catch (err) {
331
+ return { ok: false, error: '导入数据库失败:' + (err && err.message ? err.message : String(err)) };
332
+ }
333
+ }
334
+ return { ok: false, error: '素材没有通过校验:' + detail };
335
+ }
336
+
337
+ async function runCrawl(ctx, invocation, session) {
338
+ const root = workspaceOf(session);
339
+ const paths = ensureDouyinDir(root);
340
+ const accounts = readAccounts(root);
341
+ if (!accounts.length) {
342
+ return notify(session, 'error', '还没有记录抖音账号。先运行:/fancy-douyin add <主页地址>');
343
+ }
344
+
345
+ const tools = deployDouyinTools(root);
346
+ if (!tools.ok) {
347
+ return notify(session, 'error', '❌ 部署抖音脚本失败:\n' + (tools.problems.join('\n') || '未知错误'));
348
+ }
349
+ await ensureMaterialDatabase(root);
350
+ const materialTools = deployMaterialTools(root);
351
+ if (!materialTools.ok) {
352
+ return notify(session, 'error', '❌ 部署素材校验脚本失败:\n' + (materialTools.problems.join('\n') || '未知错误'));
353
+ }
354
+
355
+ const mode = hasDouyinLogin(paths) ? 'headless' : 'headed';
356
+ const started = await startChrome(root, tools, paths, mode);
357
+ if (!started.ok) {
358
+ return notify(session, 'error', '❌ 启动 Chrome 失败:' + started.error);
359
+ }
360
+ const browser = { cdp: started.cdp, mode: started.mode };
361
+
362
+ try {
363
+ const runDir = path.join(paths.output, stampOfNow());
364
+ fs.mkdirSync(runDir, { recursive: true });
365
+ const crawled = readCrawled(root);
366
+ const failures = [];
367
+ let scannedAccounts = 0;
368
+ let foundNotes = 0;
369
+ let skippedNotes = 0;
370
+ let processedNotes = 0;
371
+ let inserted = 0;
372
+ let skippedMaterials = 0;
373
+ let aborted = false;
374
+
375
+ outer:
376
+ for (const account of accounts) {
377
+ if (abortRequested(invocation)) { aborted = true; break; }
378
+ const accountDir = path.join(runDir, accountSlug(account.url));
379
+ const scan = await runWithLogin(
380
+ ctx, invocation, session, root, tools.scripts.scan,
381
+ ['--workspace', root, '--account', account.url, '--cdp', browser.cdp,
382
+ '--out', accountDir, '--login-state', paths.loginState],
383
+ accountDir,
384
+ paths,
385
+ '扫描账号',
386
+ tools.nodePath,
387
+ browser,
388
+ tools,
389
+ );
390
+ if (!scan.ok) {
391
+ failures.push({ url: account.url, reason: scan.error });
392
+ if (scan.cancelled) { aborted = true; break; }
393
+ continue;
394
+ }
395
+
396
+ scannedAccounts += 1;
397
+ const notes = Array.isArray(scan.json.notes) ? scan.json.notes : [];
398
+ foundNotes += notes.length;
399
+ for (const card of notes) {
400
+ if (abortRequested(invocation)) { aborted = true; break outer; }
401
+ const noteId = noteIdFromUrl(card.href);
402
+ if (!noteId) continue;
403
+ if (crawled[noteId]) {
404
+ skippedNotes += 1;
405
+ continue;
406
+ }
407
+ const noteDir = path.join(accountDir, noteId);
408
+ const noteResult = await runWithLogin(
409
+ ctx, invocation, session, root, tools.scripts.note,
410
+ ['--workspace', root, '--note', card.href, '--cdp', browser.cdp,
411
+ '--out', noteDir, '--login-state', paths.loginState],
412
+ noteDir,
413
+ paths,
414
+ '采集作品',
415
+ tools.nodePath,
416
+ browser,
417
+ tools,
418
+ );
419
+ if (!noteResult.ok) {
420
+ failures.push({ id: noteId, url: card.href, reason: noteResult.error });
421
+ if (noteResult.cancelled) { aborted = true; break outer; }
422
+ continue;
423
+ }
424
+ const note = noteResult.json.note;
425
+ if (!note || !Array.isArray(note.screenshots) || !note.screenshots.length) {
426
+ failures.push({ id: noteId, url: card.href, reason: '没有截图' });
427
+ continue;
428
+ }
429
+ processedNotes += 1;
430
+ note.account_url = account.url;
431
+ const target = path.join(noteDir, 'materials.jsonl');
432
+ const analyzed = await analyzeNote(ctx, invocation, session, {
433
+ root,
434
+ note,
435
+ screenshots: note.screenshots,
436
+ target,
437
+ materialTools,
438
+ paths,
439
+ });
440
+ if (!analyzed.ok) {
441
+ failures.push({ id: noteId, url: card.href, reason: analyzed.error });
442
+ if (analyzed.aborted) { aborted = true; break outer; }
443
+ if (analyzed.fatal) break outer;
444
+ continue;
445
+ }
446
+ inserted += analyzed.inserted;
447
+ skippedMaterials += analyzed.skipped;
448
+ markCrawled(root, noteId, {
449
+ account_url: account.url,
450
+ url: note.url || card.href,
451
+ title: note.title || card.text || '',
452
+ screenshots: note.screenshots.map((shot) => shot.file),
453
+ dir: noteDir,
454
+ inserted: analyzed.inserted,
455
+ skipped: analyzed.skipped,
456
+ });
457
+ crawled[noteId] = true;
458
+ if (NOTE_DELAY_MIN_MS < NOTE_DELAY_MAX_MS) {
459
+ const delay = NOTE_DELAY_MIN_MS + Math.floor(Math.random() * (NOTE_DELAY_MAX_MS - NOTE_DELAY_MIN_MS));
460
+ await sleep(delay);
461
+ }
462
+ }
463
+ }
464
+
465
+ return notify(session, (aborted || failures.length) ? 'error' : 'success', summaryText({
466
+ accounts: accounts.length,
467
+ scannedAccounts,
468
+ foundNotes,
469
+ skippedNotes,
470
+ processedNotes,
471
+ inserted,
472
+ skippedMaterials,
473
+ failures,
474
+ outputDir: runDir,
475
+ stateFile: paths.crawled,
476
+ aborted,
477
+ mode: browser.mode,
478
+ }));
479
+ } finally {
480
+ await stopChrome(root, tools, paths);
481
+ }
482
+ }
483
+
484
+ async function runAdd(session, rawUrl) {
485
+ const root = workspaceOf(session);
486
+ try {
487
+ const result = addAccount(root, rawUrl);
488
+ if (!result.added) {
489
+ return notify(session, 'success', '账号已存在:' + result.url);
490
+ }
491
+ return notify(session, 'success', [
492
+ '✅ 已添加抖音账号:' + result.url,
493
+ '运行 /fancy-douyin 开始采集。',
494
+ ].join('\n'));
495
+ } catch (err) {
496
+ return notify(session, 'error', '❌ ' + (err && err.message ? err.message : String(err)));
497
+ }
498
+ }
499
+
500
+ async function runList(session) {
501
+ const root = workspaceOf(session);
502
+ return notify(session, 'success', accountListText(root));
503
+ }
504
+
505
+ async function runRemove(session, selector) {
506
+ const root = workspaceOf(session);
507
+ try {
508
+ const result = removeAccount(root, selector);
509
+ return notify(session, 'success', '✅ 已删除账号:' + result.url);
510
+ } catch (err) {
511
+ return notify(session, 'error', '❌ ' + (err && err.message ? err.message : String(err)));
512
+ }
513
+ }
514
+
515
+ export async function apply(ctx) {
516
+ ctx.commands.register({
517
+ name: 'fancy-douyin',
518
+ description: '🎬 抖音图文采集 ( usage: /fancy-douyin [add <主页> | list | remove <序号|地址> | help] )',
519
+ input: { hint: '[add <主页> | list | remove <序号|地址> | help]' },
520
+ handler: async (invocation) => {
521
+ const session = invocation.agent.session;
522
+ const raw = String(invocation.rawInput || '').trim();
523
+ const tokens = raw.split(/\s+/).filter(Boolean);
524
+ const command = (tokens[0] || '').toLowerCase();
525
+
526
+ if (command === 'help' || command === '?') {
527
+ return notify(session, 'success', helpText());
528
+ }
529
+ try {
530
+ if (command === 'add') {
531
+ if (tokens.length < 2) return notify(session, 'error', '用法:/fancy-douyin add <抖音账号主页地址>');
532
+ return await runAdd(session, tokens.slice(1).join(' '));
533
+ }
534
+ if (command === 'list') {
535
+ if (tokens.length > 1) return notify(session, 'error', '用法:/fancy-douyin list(不接受额外参数)');
536
+ return await runList(session);
537
+ }
538
+ if (command === 'remove') {
539
+ if (tokens.length < 2) return notify(session, 'error', '用法:/fancy-douyin remove <序号|主页地址>');
540
+ return await runRemove(session, tokens.slice(1).join(' '));
541
+ }
542
+ if (command) {
543
+ return notify(session, 'error', '未知子命令:' + command + '\n运行 /fancy-douyin help 查看用法。');
544
+ }
545
+ return await runCrawl(ctx, invocation, session);
546
+ } catch (err) {
547
+ return notify(session, 'error', '❌ 抖音命令失败:' + (err && err.message ? err.message : String(err)));
548
+ }
549
+ },
550
+ });
551
+ }
@@ -0,0 +1,84 @@
1
+ 你正在分析一篇抖音图文作品,把图片里的内容拆成可以直接进入网文素材库的小积木。
2
+
3
+ ## 作品信息
4
+
5
+ - 账号主页:{{ACCOUNT_URL}}
6
+ - 作品地址:{{NOTE_URL}}
7
+ - 作品标题:{{NOTE_TITLE}}
8
+ - 作品描述:{{NOTE_DESCRIPTION}}
9
+ - 页面正文摘录:{{NOTE_BODY}}
10
+ - 截图目录:`{{NOTE_DIR}}`
11
+
12
+ ## 截图
13
+
14
+ {{SCREENSHOT_LIST}}
15
+
16
+ ## 任务
17
+
18
+ 读取上面的截图,提取其中可复用的网文创作素材:
19
+
20
+ - 设定、等级体系、世界观规则;
21
+ - 能力、代价、限制、反制;
22
+ - 物品、材料、功能、视觉特征;
23
+ - 地名、势力、组织、身份;
24
+ - 普通怪物、神话生物、神祇、上古凶兽;
25
+ - 人物外形、气质、性格、习惯、说话风格、出身;
26
+ - 场景、天气、感官、描写词;
27
+ - 脑洞、冲突种子、反转、套路。
28
+
29
+ 不要复述整篇文案,不要把原图里的整段文字原样抄进来。把内容抽象成一条条可以独立取用的素材。
30
+
31
+ ## 输出文件
32
+
33
+ 把 JSONL 写到:
34
+
35
+ ```text
36
+ {{TARGET_FILE}}
37
+ ```
38
+
39
+ 一行一个 JSON 对象,不要 Markdown 代码围栏、标题、解释、前后缀。
40
+
41
+ 字段只能是:
42
+
43
+ ```text
44
+ id / kind / name / tags / genres / note / source
45
+ ```
46
+
47
+ - `source` 固定写 `other`。
48
+ - `kind` 不受内置列表限制;如果内容能归入下面的内置 kind,优先使用内置 kind。
49
+ - `tags` 3–8 个短标签。
50
+ - `genres` 1–3 个题材;拿不准就写 `["通用"]`。
51
+ - `note` 可写一句补充,没有就写空字符串。
52
+ - `id` 用小写字母、数字、下划线,格式建议:`<kind>_dy_<作品ID>_<序号>`,全局唯一。
53
+
54
+ 优先使用的内置 kind:
55
+
56
+ ```text
57
+ {{MATERIAL_KINDS}}
58
+ ```
59
+
60
+ ## 质量要求
61
+
62
+ - 具体优先:一条素材必须能在 30 秒内变成一个具体画面、动作、规则或人物关系。
63
+ - 拒绝空泛词:不要“火红、耀眼、神秘、强大、古老、传说、绝世”。
64
+ - 命名必须有具体锚点:数字、地名、材质、动作、职业、年代、禁忌、功能或反差。
65
+ - 拒绝“复原眼、章眼、读心手、旧仓单、神秘剑、黑色长刀”这类废名。
66
+ - 一条只写一个细节;机制、代价、限制、反制分开成条。
67
+ - 不要为了凑数复制、换皮或降低质量;宁可不写,也不要写废名。
68
+ - 通常一篇作品提取 5–40 条,按截图实际信息量决定。
69
+
70
+ 生成前可以查询已有素材,避免同名:
71
+
72
+ ```bash
73
+ node '{{QUERY_SCRIPT}}' --workspace '{{WORKSPACE}}' --kind <kind> --name '<候选名>' --json
74
+ ```
75
+
76
+ 写完运行格式校验:
77
+
78
+ ```bash
79
+ node '{{VALIDATE_SCRIPT}}' '{{TARGET_FILE}}'
80
+ ```
81
+
82
+ 校验失败就按错误修正,直到通过。
83
+
84
+ 写完后只回复:`已写入 {{TARGET_FILE}}`。