@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.
- package/README.md +65 -0
- package/client.js +201 -15
- package/cordis.patch.yml +5 -1
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +12 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +31 -7
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +145 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Start, reuse, switch or stop the workspace-local Chrome.
|
|
5
|
+
*
|
|
6
|
+
* - headed: needed for the first manual login / captcha.
|
|
7
|
+
* - headless: used on later runs when login-state.json has auth cookies.
|
|
8
|
+
* - --stop: close the Chrome belonging to this workspace.
|
|
9
|
+
*
|
|
10
|
+
* The profile is the primary login state. Cookies are additionally mirrored to
|
|
11
|
+
* login-state.json by the page scripts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const net = require('net');
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const { spawn } = require('child_process');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const { parseArgs, ensureDir, readJson, writeJsonAtomic, printJson, fail, log } = require('./lib.cjs');
|
|
21
|
+
|
|
22
|
+
function getVersion(port, timeout = 1500) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/json/version', timeout }, (res) => {
|
|
25
|
+
let body = '';
|
|
26
|
+
res.setEncoding('utf8');
|
|
27
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
28
|
+
res.on('end', () => {
|
|
29
|
+
try {
|
|
30
|
+
resolve({ ok: true, data: JSON.parse(body) });
|
|
31
|
+
} catch {
|
|
32
|
+
resolve({ ok: false });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false }); });
|
|
37
|
+
req.on('error', () => resolve({ ok: false }));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function portFree(port) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const server = net.createServer();
|
|
44
|
+
server.once('error', () => resolve(false));
|
|
45
|
+
server.listen(port, '127.0.0.1', () => {
|
|
46
|
+
server.close(() => resolve(true));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pickPort(preferred) {
|
|
52
|
+
if (await portFree(preferred)) return preferred;
|
|
53
|
+
for (let port = 9333; port <= 9399; port += 1) {
|
|
54
|
+
if (await portFree(port)) return port;
|
|
55
|
+
}
|
|
56
|
+
throw new Error('没有可用的本地端口');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForCdp(port, timeoutMs = 40000) {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
const result = await getVersion(port);
|
|
63
|
+
if (result.ok) return result.data;
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function closeChrome(port) {
|
|
70
|
+
const version = await getVersion(port);
|
|
71
|
+
if (!version.ok) return false;
|
|
72
|
+
const wsUrl = version.data && version.data.webSocketDebuggerUrl;
|
|
73
|
+
if (!wsUrl || typeof WebSocket !== 'function') return false;
|
|
74
|
+
await new Promise((resolve) => {
|
|
75
|
+
let finished = false;
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (finished) return;
|
|
78
|
+
finished = true;
|
|
79
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
80
|
+
resolve();
|
|
81
|
+
};
|
|
82
|
+
let ws;
|
|
83
|
+
try {
|
|
84
|
+
ws = new WebSocket(wsUrl);
|
|
85
|
+
} catch {
|
|
86
|
+
resolve();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
ws.onopen = () => {
|
|
90
|
+
try { ws.send(JSON.stringify({ id: 1, method: 'Browser.close' })); } catch { /* ignore */ }
|
|
91
|
+
setTimeout(finish, 2500);
|
|
92
|
+
};
|
|
93
|
+
ws.onmessage = finish;
|
|
94
|
+
ws.onerror = finish;
|
|
95
|
+
setTimeout(finish, 5000);
|
|
96
|
+
});
|
|
97
|
+
for (let i = 0; i < 20; i += 1) {
|
|
98
|
+
if (!(await getVersion(port)).ok) return true;
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main() {
|
|
105
|
+
const args = parseArgs(process.argv.slice(2));
|
|
106
|
+
const workspace = path.resolve(String(args.workspace || process.cwd()));
|
|
107
|
+
const configFile = path.resolve(String(args.config || path.join(workspace, '.fancy', 'douyin', 'chrome.json')));
|
|
108
|
+
const profile = path.resolve(String(args.profile || path.join(workspace, '.fancy', 'douyin', 'chrome-profile')));
|
|
109
|
+
const chrome = String(args.chrome || process.env.FANCY_DOUYIN_CHROME || process.env.CHROME_PATH || '/usr/bin/google-chrome');
|
|
110
|
+
const mode = String(args.mode || 'headed').toLowerCase() === 'headless' ? 'headless' : 'headed';
|
|
111
|
+
const existing = readJson(configFile);
|
|
112
|
+
|
|
113
|
+
if (args.stop) {
|
|
114
|
+
const port = Number(existing && existing.port ? existing.port : 0);
|
|
115
|
+
if (!port) {
|
|
116
|
+
printJson({ ok: true, stopped: false, reason: '没有 chrome.json 端口记录' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const stopped = await closeChrome(port);
|
|
120
|
+
printJson({ ok: true, stopped, port });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!fs.existsSync(chrome)) {
|
|
125
|
+
throw new Error('找不到 Chrome:' + chrome + '。请安装 google-chrome,或设置 FANCY_DOUYIN_CHROME。');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hash = crypto.createHash('sha256').update(workspace).digest();
|
|
129
|
+
const workspacePort = 9400 + (hash[0] % 200);
|
|
130
|
+
const preferred = Number(args.port || process.env.FANCY_DOUYIN_CDP_PORT || (existing && existing.port) || workspacePort);
|
|
131
|
+
|
|
132
|
+
if (existing && Number(existing.port) > 0) {
|
|
133
|
+
const version = await getVersion(Number(existing.port));
|
|
134
|
+
const existingMode = existing.mode || 'headed';
|
|
135
|
+
if (version.ok && existingMode === mode) {
|
|
136
|
+
const port = Number(existing.port);
|
|
137
|
+
printJson({
|
|
138
|
+
ok: true,
|
|
139
|
+
reused: true,
|
|
140
|
+
mode,
|
|
141
|
+
port,
|
|
142
|
+
cdp: 'http://127.0.0.1:' + port,
|
|
143
|
+
profile,
|
|
144
|
+
browser: version.data?.Browser || '',
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (version.ok) {
|
|
149
|
+
log('[chrome] mode switch ' + existingMode + ' -> ' + mode + ', closing old browser');
|
|
150
|
+
await closeChrome(Number(existing.port));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const port = await pickPort(Number.isFinite(preferred) && preferred > 0 ? preferred : workspacePort);
|
|
155
|
+
ensureDir(profile);
|
|
156
|
+
ensureDir(path.dirname(configFile));
|
|
157
|
+
|
|
158
|
+
const chromeArgs = [
|
|
159
|
+
'--remote-debugging-port=' + port,
|
|
160
|
+
'--remote-allow-origins=*',
|
|
161
|
+
'--user-data-dir=' + profile,
|
|
162
|
+
'--no-first-run',
|
|
163
|
+
'--no-default-browser-check',
|
|
164
|
+
'--disable-blink-features=AutomationControlled',
|
|
165
|
+
'--window-size=1400,1000',
|
|
166
|
+
'--lang=zh-CN',
|
|
167
|
+
];
|
|
168
|
+
if (mode === 'headless') {
|
|
169
|
+
chromeArgs.push('--headless=new');
|
|
170
|
+
}
|
|
171
|
+
chromeArgs.push('about:blank');
|
|
172
|
+
|
|
173
|
+
log('[chrome] starting', chrome, 'mode=' + mode, 'port=' + port, 'profile=' + profile);
|
|
174
|
+
const child = spawn(chrome, chromeArgs, {
|
|
175
|
+
detached: true,
|
|
176
|
+
stdio: 'ignore',
|
|
177
|
+
});
|
|
178
|
+
child.unref();
|
|
179
|
+
|
|
180
|
+
const version = await waitForCdp(port);
|
|
181
|
+
if (!version) {
|
|
182
|
+
throw new Error('Chrome 已启动,但 CDP 在 40 秒内没有就绪。请检查 DISPLAY 是否能打开图形窗口;如果该 profile 已被另一个 Chrome 窗口占用,请先关闭那个窗口再重试。');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
writeJsonAtomic(configFile, {
|
|
186
|
+
port,
|
|
187
|
+
profile,
|
|
188
|
+
mode,
|
|
189
|
+
started_at: new Date().toISOString(),
|
|
190
|
+
}, 0o600);
|
|
191
|
+
|
|
192
|
+
printJson({
|
|
193
|
+
ok: true,
|
|
194
|
+
reused: false,
|
|
195
|
+
mode,
|
|
196
|
+
port,
|
|
197
|
+
cdp: 'http://127.0.0.1:' + port,
|
|
198
|
+
profile,
|
|
199
|
+
browser: version.Browser || '',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch(fail);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace state for fancy-douyin.
|
|
3
|
+
*
|
|
4
|
+
* Accounts, crawl history, the Chrome profile and downloaded screenshots all
|
|
5
|
+
* live under <workspace>/.fancy/douyin. The deployed crawler scripts live under
|
|
6
|
+
* <workspace>/.fancy-tools/fancy-douyin.
|
|
7
|
+
*
|
|
8
|
+
* The crawl history is keyed by the note id parsed from /note/<id>. A note is
|
|
9
|
+
* written only after its screenshot analysis has been imported into the
|
|
10
|
+
* material library, so failed notes are retried on the next run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { atomicWriteJson, readJsonFile } from '#workspace';
|
|
16
|
+
|
|
17
|
+
export const DOUYIN_DIR = path.join('.fancy', 'douyin');
|
|
18
|
+
|
|
19
|
+
export function douyinPaths(root) {
|
|
20
|
+
const base = path.join(path.resolve(root), DOUYIN_DIR);
|
|
21
|
+
return {
|
|
22
|
+
base,
|
|
23
|
+
accounts: path.join(base, 'accounts.json'),
|
|
24
|
+
crawled: path.join(base, 'crawled.json'),
|
|
25
|
+
loginState: path.join(base, 'login-state.json'),
|
|
26
|
+
chrome: path.join(base, 'chrome.json'),
|
|
27
|
+
profile: path.join(base, 'chrome-profile'),
|
|
28
|
+
captchaLog: path.join(base, 'captcha.log'),
|
|
29
|
+
output: path.join(base, 'output'),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ensureDouyinDir(root) {
|
|
34
|
+
const paths = douyinPaths(root);
|
|
35
|
+
fs.mkdirSync(paths.base, { recursive: true, mode: 0o700 });
|
|
36
|
+
return paths;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a Douyin account homepage URL.
|
|
41
|
+
*
|
|
42
|
+
* We only accept /user/<id> pages. Share links and individual note links are
|
|
43
|
+
* not account homepages and cannot be scanned.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} raw
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeAccountUrl(raw) {
|
|
49
|
+
let text = String(raw || '').trim();
|
|
50
|
+
if (!text) throw new Error('缺少抖音账号主页地址');
|
|
51
|
+
if (!/^https?:\/\//i.test(text)) text = 'https://' + text;
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(text);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error('不是合法的 URL:' + raw);
|
|
57
|
+
}
|
|
58
|
+
const host = url.hostname.toLowerCase();
|
|
59
|
+
if (host !== 'douyin.com' && !host.endsWith('.douyin.com')
|
|
60
|
+
&& host !== 'iesdouyin.com' && !host.endsWith('.iesdouyin.com')) {
|
|
61
|
+
throw new Error('不是抖音地址:' + raw);
|
|
62
|
+
}
|
|
63
|
+
if (!url.pathname.startsWith('/user/') || url.pathname.length <= '/user/'.length) {
|
|
64
|
+
throw new Error('请填写抖音账号主页地址(包含 /user/),不要填写单篇作品或短链:' + raw);
|
|
65
|
+
}
|
|
66
|
+
url.hash = '';
|
|
67
|
+
url.search = '';
|
|
68
|
+
return url.toString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function accountSlug(url) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = new URL(url);
|
|
74
|
+
const id = parsed.pathname.split('/').filter(Boolean).pop() || 'account';
|
|
75
|
+
return id.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'account';
|
|
76
|
+
} catch {
|
|
77
|
+
return 'account';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function noteIdFromUrl(url) {
|
|
82
|
+
const match = String(url || '').match(/\/note\/(\d+)/);
|
|
83
|
+
if (match) return match[1];
|
|
84
|
+
return String(url || '').replace(/[?#].*$/, '').split('/').filter(Boolean).pop() || '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isNoteUrl(url) {
|
|
88
|
+
return /\/note\/\d+/.test(String(url || ''));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readAccounts(root) {
|
|
92
|
+
const paths = douyinPaths(root);
|
|
93
|
+
const value = readJsonFile(paths.accounts);
|
|
94
|
+
const accounts = Array.isArray(value?.accounts) ? value.accounts : [];
|
|
95
|
+
return accounts
|
|
96
|
+
.filter((item) => item && typeof item.url === 'string')
|
|
97
|
+
.map((item) => ({
|
|
98
|
+
url: item.url,
|
|
99
|
+
added_at: item.added_at || '',
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeAccounts(root, accounts) {
|
|
104
|
+
const paths = ensureDouyinDir(root);
|
|
105
|
+
atomicWriteJson(paths.accounts, {
|
|
106
|
+
version: 1,
|
|
107
|
+
accounts: accounts.map((item) => ({
|
|
108
|
+
url: item.url,
|
|
109
|
+
added_at: item.added_at || new Date().toISOString(),
|
|
110
|
+
})),
|
|
111
|
+
}, { mode: 0o600 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} root
|
|
116
|
+
* @param {string} rawUrl
|
|
117
|
+
* @returns {{added: boolean, url: string, accounts: Array}}
|
|
118
|
+
*/
|
|
119
|
+
export function addAccount(root, rawUrl) {
|
|
120
|
+
const url = normalizeAccountUrl(rawUrl);
|
|
121
|
+
const accounts = readAccounts(root);
|
|
122
|
+
if (accounts.some((item) => item.url === url)) {
|
|
123
|
+
return { added: false, url, accounts };
|
|
124
|
+
}
|
|
125
|
+
accounts.push({ url, added_at: new Date().toISOString() });
|
|
126
|
+
writeAccounts(root, accounts);
|
|
127
|
+
return { added: true, url, accounts };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove an account by 1-based list index or by homepage URL.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} root
|
|
134
|
+
* @param {string} selector
|
|
135
|
+
* @returns {{removed: boolean, url: string, accounts: Array}}
|
|
136
|
+
*/
|
|
137
|
+
export function removeAccount(root, selector) {
|
|
138
|
+
const accounts = readAccounts(root);
|
|
139
|
+
const text = String(selector || '').trim();
|
|
140
|
+
if (!text) throw new Error('缺少要删除的账号序号或主页地址');
|
|
141
|
+
let index = -1;
|
|
142
|
+
if (/^\d+$/.test(text)) {
|
|
143
|
+
index = Number(text) - 1;
|
|
144
|
+
} else {
|
|
145
|
+
const url = normalizeAccountUrl(text);
|
|
146
|
+
index = accounts.findIndex((item) => item.url === url);
|
|
147
|
+
}
|
|
148
|
+
if (index < 0 || index >= accounts.length) {
|
|
149
|
+
throw new Error('没有找到要删除的账号:' + selector);
|
|
150
|
+
}
|
|
151
|
+
const [removed] = accounts.splice(index, 1);
|
|
152
|
+
writeAccounts(root, accounts);
|
|
153
|
+
return { removed: true, url: removed.url, accounts };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function readCrawled(root) {
|
|
157
|
+
const value = readJsonFile(douyinPaths(root).crawled);
|
|
158
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function writeCrawled(root, state) {
|
|
162
|
+
const paths = ensureDouyinDir(root);
|
|
163
|
+
atomicWriteJson(paths.crawled, state, { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function markCrawled(root, noteId, data) {
|
|
167
|
+
const state = readCrawled(root);
|
|
168
|
+
state[noteId] = {
|
|
169
|
+
...data,
|
|
170
|
+
crawled_at: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
writeCrawled(root, state);
|
|
173
|
+
return state[noteId];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function accountListText(root) {
|
|
177
|
+
const accounts = readAccounts(root);
|
|
178
|
+
if (!accounts.length) {
|
|
179
|
+
return [
|
|
180
|
+
'还没有记录抖音账号。',
|
|
181
|
+
'先添加账号主页:/fancy-douyin add https://www.douyin.com/user/...',
|
|
182
|
+
].join('\n');
|
|
183
|
+
}
|
|
184
|
+
const crawled = readCrawled(root);
|
|
185
|
+
const counts = new Map();
|
|
186
|
+
for (const item of Object.values(crawled)) {
|
|
187
|
+
if (!item || typeof item !== 'object' || !item.account_url) continue;
|
|
188
|
+
counts.set(item.account_url, (counts.get(item.account_url) || 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
return [
|
|
191
|
+
'已记录的抖音账号:',
|
|
192
|
+
...accounts.map((item, index) => {
|
|
193
|
+
const count = counts.get(item.url) || 0;
|
|
194
|
+
return `${index + 1}. ${item.url}(已采集 ${count} 篇)`;
|
|
195
|
+
}),
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-douyin command text and LLM prompt rendering.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PROMPT_FILE = path.join(__dirname, 'prompts', '01-analysis-prompt.md');
|
|
11
|
+
|
|
12
|
+
export function helpText() {
|
|
13
|
+
return `🎬 抖音图文采集 用法
|
|
14
|
+
|
|
15
|
+
/fancy-douyin add <主页地址> 记录一个抖音账号主页
|
|
16
|
+
/fancy-douyin list 查看已记录的账号
|
|
17
|
+
/fancy-douyin remove <序号|地址> 删除已记录的账号
|
|
18
|
+
/fancy-douyin 依次采集所有账号下未处理的图文作品
|
|
19
|
+
/fancy-douyin help 显示本帮助
|
|
20
|
+
|
|
21
|
+
说明:
|
|
22
|
+
- 只采集 /note/ 图文作品,跳过视频。
|
|
23
|
+
- 每篇作品会下载轮播原图并转成 JPG(不是滚动中的页面截图),然后交给 LLM 提取素材。
|
|
24
|
+
- 素材按 material JSONL 格式写入数据库,source 固定为 other。
|
|
25
|
+
- 已成功入库的作品记录在 <工作区>/.fancy/douyin/crawled.json,后续跳过。
|
|
26
|
+
- 登录态保存在 <工作区>/.fancy/douyin/chrome-profile/,并镜像到 login-state.json。
|
|
27
|
+
- 首次需要登录时用有头 Chrome;登录态有效后后续运行自动使用无头 Chrome。
|
|
28
|
+
- 每次运行结束会自动关闭 Chrome,释放资源。
|
|
29
|
+
- 需要登录或安全验证时会弹窗,请在 Chrome 窗口手动完成,然后选择「已登陆」。
|
|
30
|
+
- 浏览器脚本部署在 <工作区>/.fancy-tools/fancy-douyin/。`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loginQuestion(paths) {
|
|
34
|
+
return {
|
|
35
|
+
id: 'douyin-login',
|
|
36
|
+
header: '抖音需要登录',
|
|
37
|
+
question: '请在弹出的 Chrome 窗口完成抖音登录或安全验证。完成后点「已登陆」继续。',
|
|
38
|
+
detail: [
|
|
39
|
+
'登录态会保存在工作区的 Chrome profile 和 login-state.json 中,下次运行可复用。',
|
|
40
|
+
'如果当前没有看到 Chrome 窗口,请检查桌面环境/DISPLAY 是否可用。',
|
|
41
|
+
'点「取消」会停止本次采集,已导入的素材和已保存的登录态不会撤销。',
|
|
42
|
+
'',
|
|
43
|
+
'profile:' + paths.profile,
|
|
44
|
+
'cookie 镜像:' + paths.loginState,
|
|
45
|
+
].join('\n'),
|
|
46
|
+
options: [
|
|
47
|
+
{ label: '已登陆', description: '已经完成登录/验证,继续采集' },
|
|
48
|
+
{ label: '取消', description: '停止本次采集' },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function read(value, fallback = '') {
|
|
54
|
+
const text = String(value == null ? '' : value).trim();
|
|
55
|
+
return text || fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function analysisPrompt({
|
|
59
|
+
accountUrl,
|
|
60
|
+
note,
|
|
61
|
+
screenshots,
|
|
62
|
+
targetFile,
|
|
63
|
+
workspace,
|
|
64
|
+
materialKinds,
|
|
65
|
+
queryScript,
|
|
66
|
+
validateScript,
|
|
67
|
+
}) {
|
|
68
|
+
const template = fs.readFileSync(PROMPT_FILE, 'utf8');
|
|
69
|
+
const list = (screenshots || [])
|
|
70
|
+
.map((shot, index) => `${index + 1}. \`${shot.file}\``)
|
|
71
|
+
.join('\n');
|
|
72
|
+
const vars = {
|
|
73
|
+
ACCOUNT_URL: read(accountUrl),
|
|
74
|
+
NOTE_URL: read(note.url),
|
|
75
|
+
NOTE_TITLE: read(note.title, '(无标题)'),
|
|
76
|
+
NOTE_DESCRIPTION: read(note.description, '(无描述)'),
|
|
77
|
+
NOTE_BODY: read(note.body, '(无正文摘录)').slice(0, 3000),
|
|
78
|
+
NOTE_DIR: read(note.dir),
|
|
79
|
+
SCREENSHOT_LIST: list || '(没有截图)',
|
|
80
|
+
TARGET_FILE: targetFile,
|
|
81
|
+
MATERIAL_KINDS: (materialKinds || []).join(', '),
|
|
82
|
+
QUERY_SCRIPT: queryScript,
|
|
83
|
+
VALIDATE_SCRIPT: validateScript,
|
|
84
|
+
WORKSPACE: workspace,
|
|
85
|
+
};
|
|
86
|
+
let text = template;
|
|
87
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
88
|
+
text = text.split('{{' + key + '}}').join(value);
|
|
89
|
+
}
|
|
90
|
+
return text.trim() + '\n';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function summaryText({
|
|
94
|
+
accounts,
|
|
95
|
+
scannedAccounts,
|
|
96
|
+
foundNotes,
|
|
97
|
+
skippedNotes,
|
|
98
|
+
processedNotes,
|
|
99
|
+
inserted,
|
|
100
|
+
skippedMaterials,
|
|
101
|
+
failures,
|
|
102
|
+
outputDir,
|
|
103
|
+
stateFile,
|
|
104
|
+
aborted,
|
|
105
|
+
mode,
|
|
106
|
+
}) {
|
|
107
|
+
const lines = [
|
|
108
|
+
aborted ? '⚠️ 抖音采集已中断' : '✅ 抖音采集完成',
|
|
109
|
+
'账号:' + accounts + ' 个(本次扫描 ' + scannedAccounts + ' 个)',
|
|
110
|
+
'浏览器:' + (mode === 'headless' ? '无头' : '有头'),
|
|
111
|
+
'发现图文:' + foundNotes + ' 篇',
|
|
112
|
+
'已处理过并跳过:' + skippedNotes + ' 篇',
|
|
113
|
+
'本次处理:' + processedNotes + ' 篇',
|
|
114
|
+
'新增素材:' + inserted + ' 条(重复跳过 ' + skippedMaterials + ' 条)',
|
|
115
|
+
];
|
|
116
|
+
if (failures.length) {
|
|
117
|
+
lines.push('失败:' + failures.length + ' 篇');
|
|
118
|
+
for (const item of failures.slice(0, 10)) {
|
|
119
|
+
lines.push(' - ' + (item.id || item.url || '未知') + ':' + item.reason);
|
|
120
|
+
}
|
|
121
|
+
if (failures.length > 10) lines.push(' - 其余 ' + (failures.length - 10) + ' 篇省略');
|
|
122
|
+
lines.push('失败作品不会写入采集记录,下次运行会重试。');
|
|
123
|
+
}
|
|
124
|
+
lines.push('输出目录:' + outputDir);
|
|
125
|
+
lines.push('采集记录:' + stateFile);
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|