@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,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Open one Douyin /note/ work, capture its image slides as JPG screenshots and
5
+ * write note.json. stdout is one JSON object; Chrome stays open.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const {
11
+ log, parseArgs, connect, restoreCookies, saveCookies,
12
+ gateResult, printJson, fail, ensureDir,
13
+ } = require('./lib.cjs');
14
+
15
+ function noteId(url) {
16
+ const match = String(url).match(/\/note\/(\d+)/);
17
+ if (match) return match[1];
18
+ return String(url).replace(/[?#].*$/, '').split('/').filter(Boolean).pop() || '';
19
+ }
20
+
21
+ async function readMetadata(page) {
22
+ return await page.evaluate(() => {
23
+ const meta = (name) => {
24
+ const el = document.querySelector('meta[name="' + name + '"]');
25
+ return el && el.content ? el.content : '';
26
+ };
27
+ return {
28
+ url: location.href,
29
+ title: document.title || '',
30
+ description: meta('description'),
31
+ body: (document.body && document.body.innerText ? document.body.innerText : '').slice(0, 6000),
32
+ };
33
+ });
34
+ }
35
+
36
+ const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36';
37
+
38
+ async function collectSlideUrls(page) {
39
+ const seen = new Set();
40
+ const urls = [];
41
+ for (let round = 0; round < 14; round += 1) {
42
+ const batch = await page.evaluate(() => {
43
+ const imgs = [...document.querySelectorAll('img')]
44
+ .filter((img) => img.src && img.src.includes('tplv-dy-aweme-images'))
45
+ .map((img) => ({ src: img.src, x: Math.round(img.getBoundingClientRect().x) }));
46
+ const localSeen = new Set();
47
+ const unique = [];
48
+ for (const img of imgs.sort((a, b) => a.x - b.x)) {
49
+ if (localSeen.has(img.src)) continue;
50
+ localSeen.add(img.src);
51
+ unique.push(img.src);
52
+ }
53
+ return unique;
54
+ });
55
+ let added = 0;
56
+ for (const src of batch) {
57
+ if (seen.has(src)) continue;
58
+ seen.add(src);
59
+ urls.push(src);
60
+ added += 1;
61
+ }
62
+ if (added === 0 && round > 0) break;
63
+ await page.keyboard.press('ArrowRight');
64
+ await page.waitForTimeout(900);
65
+ }
66
+ return urls;
67
+ }
68
+
69
+ async function downloadAsJpeg(context, converterPage, src, file) {
70
+ const response = await context.request.get(src, {
71
+ headers: {
72
+ Referer: 'https://www.douyin.com/',
73
+ 'User-Agent': UA,
74
+ },
75
+ timeout: 45000,
76
+ failOnStatusCode: false,
77
+ });
78
+ if (!response.ok()) throw new Error('下载图片失败:HTTP ' + response.status());
79
+ const body = await response.body();
80
+ const type = String(response.headers()['content-type'] || 'image/jpeg').split(';')[0].trim() || 'image/jpeg';
81
+ const dataUrl = 'data:' + type + ';base64,' + body.toString('base64');
82
+ const jpegDataUrl = await converterPage.evaluate(async (url) => {
83
+ const image = new Image();
84
+ image.src = url;
85
+ if (typeof image.decode === 'function') await image.decode();
86
+ else await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; });
87
+ const canvas = document.createElement('canvas');
88
+ canvas.width = image.naturalWidth;
89
+ canvas.height = image.naturalHeight;
90
+ const context2d = canvas.getContext('2d');
91
+ context2d.drawImage(image, 0, 0);
92
+ return canvas.toDataURL('image/jpeg', 0.9);
93
+ }, dataUrl);
94
+ const base64 = String(jpegDataUrl).replace(/^data:image\/jpeg;base64,/, '');
95
+ fs.writeFileSync(file, Buffer.from(base64, 'base64'));
96
+ }
97
+
98
+ async function captureSlides(page, context, outDir) {
99
+ const urls = await collectSlideUrls(page);
100
+ const screenshots = [];
101
+ if (urls.length) {
102
+ // Convert the original carousel images, not viewport screenshots. This
103
+ // avoids partial captures while Douyin is scrolling or lazy-loading.
104
+ const converter = await context.newPage();
105
+ try {
106
+ for (let index = 0; index < urls.length; index += 1) {
107
+ const file = path.join(outDir, 'slide_' + String(index + 1).padStart(2, '0') + '.jpg');
108
+ try {
109
+ await downloadAsJpeg(context, converter, urls[index], file);
110
+ screenshots.push({ index: index + 1, file, src: urls[index] });
111
+ } catch (err) {
112
+ log('[note] slide download failed: ' + (err && err.message ? err.message : String(err)));
113
+ }
114
+ }
115
+ } finally {
116
+ try { await converter.close(); } catch { /* ignore */ }
117
+ }
118
+ }
119
+
120
+ if (!screenshots.length) {
121
+ const file = path.join(outDir, 'note.jpg');
122
+ try {
123
+ await page.screenshot({ path: file, type: 'jpeg', quality: 90, fullPage: false });
124
+ screenshots.push({ index: 1, file, src: '' });
125
+ } catch (err) {
126
+ throw new Error('没有找到图文图片,且页面截图失败:' + (err && err.message ? err.message : String(err)));
127
+ }
128
+ }
129
+ return screenshots;
130
+ }
131
+
132
+ async function main() {
133
+ const args = parseArgs(process.argv.slice(2));
134
+ const workspace = path.resolve(String(args.workspace || process.cwd()));
135
+ const noteUrl = String(args.note || '');
136
+ const cdp = String(args.cdp || 'http://127.0.0.1:9222');
137
+ const outDir = path.resolve(String(args.out || path.join(workspace, '.fancy', 'douyin', 'output', 'note')));
138
+ const loginState = path.resolve(String(args['login-state'] || path.join(workspace, '.fancy', 'douyin', 'login-state.json')));
139
+ if (!/\/note\//.test(noteUrl)) throw new Error('不是抖音图文作品地址:' + noteUrl);
140
+ ensureDir(outDir);
141
+
142
+ const { context } = await connect(cdp);
143
+ let page = null;
144
+ let keepPage = false;
145
+ try {
146
+ await restoreCookies(context, loginState);
147
+ page = await context.newPage();
148
+ await page.goto(noteUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
149
+ await page.waitForTimeout(4500);
150
+
151
+ const firstGate = await gateResult(page, outDir);
152
+ if (firstGate) {
153
+ keepPage = true;
154
+ await saveCookies(context, loginState, page.url());
155
+ printJson(firstGate);
156
+ return;
157
+ }
158
+
159
+ const meta = await readMetadata(page);
160
+ const screenshots = await captureSlides(page, context, outDir);
161
+
162
+ const afterGate = await gateResult(page, outDir);
163
+ if (afterGate) {
164
+ keepPage = true;
165
+ await saveCookies(context, loginState, page.url());
166
+ printJson(afterGate);
167
+ return;
168
+ }
169
+
170
+ await saveCookies(context, loginState, page.url());
171
+ const note = {
172
+ id: noteId(noteUrl),
173
+ url: noteUrl,
174
+ final_url: meta.url,
175
+ title: meta.title,
176
+ description: meta.description,
177
+ body: meta.body,
178
+ screenshots,
179
+ crawled_at: new Date().toISOString(),
180
+ dir: outDir,
181
+ };
182
+ const noteFile = path.join(outDir, 'note.json');
183
+ fs.writeFileSync(noteFile, JSON.stringify(note, null, 2) + '\n', 'utf8');
184
+ printJson({ ok: true, login_required: false, note, note_file: noteFile });
185
+ } finally {
186
+ if (page && !keepPage) {
187
+ try { await page.close(); } catch { /* ignore */ }
188
+ }
189
+ }
190
+ }
191
+
192
+ main()
193
+ .then(() => process.exit(0))
194
+ .catch((err) => { fail(err); process.exit(0); });
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared helpers for the deployed Douyin browser scripts.
5
+ *
6
+ * These scripts run outside the plugin package, so they only require
7
+ * playwright-core through NODE_PATH (set by the plugin) plus Node built-ins.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ function log(...args) {
14
+ process.stderr.write(args.map((value) => String(value)).join(' ') + '\n');
15
+ }
16
+
17
+ function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ function parseArgs(argv) {
22
+ const out = {};
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ const arg = argv[i];
25
+ if (!arg.startsWith('--')) continue;
26
+ const key = arg.slice(2);
27
+ const next = argv[i + 1];
28
+ if (next === undefined || next.startsWith('--')) {
29
+ out[key] = true;
30
+ } else {
31
+ out[key] = next;
32
+ i += 1;
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function requirePlaywright() {
39
+ try {
40
+ return require('playwright-core');
41
+ } catch (err) {
42
+ throw new Error('无法加载 playwright-core。请由 /fancy-douyin 启动脚本(NODE_PATH 未设置)。' + (err && err.message ? ' ' + err.message : ''));
43
+ }
44
+ }
45
+
46
+ function ensureDir(dir) {
47
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ }
49
+
50
+ function readJson(file) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function writeJsonAtomic(file, value, mode = 0o600) {
59
+ ensureDir(path.dirname(file));
60
+ const tmp = file + '.' + process.pid + '.' + Date.now() + '.tmp';
61
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf8', mode });
62
+ fs.renameSync(tmp, file);
63
+ try { fs.chmodSync(file, mode); } catch { /* best effort */ }
64
+ }
65
+
66
+ async function connect(cdp) {
67
+ const { chromium } = requirePlaywright();
68
+ const browser = await chromium.connectOverCDP(cdp);
69
+ const context = browser.contexts()[0];
70
+ if (!context) throw new Error('Chrome 没有可用的浏览器上下文');
71
+ return { browser, context };
72
+ }
73
+
74
+ async function restoreCookies(context, loginStateFile) {
75
+ const data = readJson(loginStateFile);
76
+ const cookies = Array.isArray(data?.cookies) ? data.cookies : [];
77
+ if (!cookies.length) return 0;
78
+ // The persistent profile is authoritative. Do not overwrite freshly logged-in
79
+ // cookies with an older mirror; only restore when the profile has no Douyin
80
+ // auth cookie at all.
81
+ try {
82
+ const current = await context.cookies('https://www.douyin.com');
83
+ const authNames = new Set(['sessionid', 'sessionid_ss', 'passport_auth_status', 'sid_tt', 'sid_guard']);
84
+ if (current.some((cookie) => authNames.has(cookie.name))) return 0;
85
+ } catch { /* fall through to restore */ }
86
+ try {
87
+ await context.addCookies(cookies);
88
+ return cookies.length;
89
+ } catch (err) {
90
+ log('[cookies] 恢复失败:' + (err && err.message ? err.message : String(err)));
91
+ return 0;
92
+ }
93
+ }
94
+
95
+ async function saveCookies(context, loginStateFile, url) {
96
+ try {
97
+ const cookies = await context.cookies();
98
+ writeJsonAtomic(loginStateFile, {
99
+ saved_at: new Date().toISOString(),
100
+ url: url || '',
101
+ cookies,
102
+ }, 0o600);
103
+ return cookies.length;
104
+ } catch (err) {
105
+ log('[cookies] 保存失败:' + (err && err.message ? err.message : String(err)));
106
+ return 0;
107
+ }
108
+ }
109
+
110
+ async function detectGate(page) {
111
+ const info = await page.evaluate(() => {
112
+ const body = (document.body && document.body.innerText ? document.body.innerText : '').slice(0, 8000);
113
+ const modal = document.querySelector('.douyin_login_new_class');
114
+ let modalVisible = false;
115
+ try {
116
+ modalVisible = Boolean(modal && modal.getBoundingClientRect().width > 0);
117
+ } catch { /* ignore */ }
118
+ const loginText = body.includes('登录后免费畅享高清视频')
119
+ || body.includes('登录后查看更多')
120
+ || body.includes('登录后查看')
121
+ || body.includes('请先登录')
122
+ || body.includes('扫码登录')
123
+ || body.includes('密码登录');
124
+ const captchaKeys = ['请完成下列验证后继续', '滑块', '拖动', '安全验证', '图形验证', '请输入验证码'];
125
+ const captcha = captchaKeys.some((key) => body.includes(key));
126
+ return {
127
+ modalVisible,
128
+ loginText,
129
+ captcha,
130
+ url: location.href,
131
+ title: document.title,
132
+ bodyHead: body.slice(0, 400),
133
+ };
134
+ });
135
+ const required = Boolean(info.modalVisible || info.loginText || info.captcha);
136
+ return {
137
+ ...info,
138
+ required,
139
+ reason: info.captcha ? 'captcha' : (required ? 'login' : ''),
140
+ };
141
+ }
142
+
143
+ async function saveGateScreenshot(page, outDir) {
144
+ try {
145
+ ensureDir(outDir);
146
+ const file = path.join(outDir, 'gate.jpg');
147
+ await page.screenshot({ path: file, type: 'jpeg', quality: 85, fullPage: false });
148
+ return file;
149
+ } catch {
150
+ return '';
151
+ }
152
+ }
153
+
154
+ async function gateResult(page, outDir, extra = {}) {
155
+ const gate = await detectGate(page);
156
+ if (!gate.required) return null;
157
+ const screenshot = await saveGateScreenshot(page, outDir);
158
+ return {
159
+ ok: true,
160
+ login_required: true,
161
+ reason: gate.reason,
162
+ screenshot,
163
+ url: gate.url,
164
+ title: gate.title,
165
+ body_head: gate.bodyHead,
166
+ ...extra,
167
+ };
168
+ }
169
+
170
+ function printJson(value) {
171
+ process.stdout.write(JSON.stringify(value) + '\n');
172
+ }
173
+
174
+ function fail(err) {
175
+ printJson({
176
+ ok: false,
177
+ error: err && err.message ? err.message : String(err),
178
+ });
179
+ }
180
+
181
+ module.exports = {
182
+ log,
183
+ sleep,
184
+ parseArgs,
185
+ requirePlaywright,
186
+ ensureDir,
187
+ readJson,
188
+ writeJsonAtomic,
189
+ connect,
190
+ restoreCookies,
191
+ saveCookies,
192
+ detectGate,
193
+ saveGateScreenshot,
194
+ gateResult,
195
+ printJson,
196
+ fail,
197
+ };
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Scan one Douyin account homepage and return its /note/ image-text works.
5
+ *
6
+ * stdout is one JSON object. Progress goes to stderr. The script never closes
7
+ * Chrome; it closes only its own page, so login state stays in the profile.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const {
13
+ log, parseArgs, connect, restoreCookies, saveCookies,
14
+ gateResult, printJson, fail, ensureDir,
15
+ } = require('./lib.cjs');
16
+
17
+ function normalize(url) {
18
+ if (String(url).startsWith('//')) return 'https:' + String(url);
19
+ return String(url);
20
+ }
21
+
22
+ async function collectCards(page) {
23
+ const cards = new Map();
24
+ let stable = 0;
25
+ for (let round = 1; round <= 30; round += 1) {
26
+ const batch = await page.evaluate(() => {
27
+ const rows = [];
28
+ for (const a of document.querySelectorAll('a[href*="/note/"], a[href*="/video/"]')) {
29
+ const li = a.closest('li') || a.parentElement;
30
+ const img = li ? li.querySelector('img') : null;
31
+ rows.push({
32
+ href: a.href,
33
+ kind: a.href.includes('/note/') ? 'note' : 'video',
34
+ text: li && li.innerText ? li.innerText.trim().slice(0, 300) : '',
35
+ alt: img && img.alt ? img.alt : '',
36
+ cover: img && img.src ? img.src : '',
37
+ });
38
+ }
39
+ return rows;
40
+ });
41
+ const before = cards.size;
42
+ for (const card of batch) {
43
+ const href = normalize(card.href);
44
+ if (!cards.has(href)) cards.set(href, { ...card, href });
45
+ }
46
+ log('[scan] round ' + round + ': ' + cards.size + ' cards');
47
+ if (cards.size === before) stable += 1;
48
+ else stable = 0;
49
+ if (stable >= 4 && cards.size > 0) break;
50
+ await page.evaluate(() => {
51
+ const candidates = [...document.querySelectorAll('div')].filter((el) => {
52
+ const style = getComputedStyle(el);
53
+ return style.overflowY === 'auto'
54
+ && el.scrollHeight > el.clientHeight + 100
55
+ && el.clientHeight > 500;
56
+ });
57
+ const target = candidates.sort((a, b) => b.clientHeight - a.clientHeight)[0];
58
+ if (target) target.scrollTop = target.scrollHeight;
59
+ else window.scrollTo(0, document.body.scrollHeight);
60
+ });
61
+ await page.waitForTimeout(2200 + Math.floor(Math.random() * 1600));
62
+ }
63
+ return [...cards.values()];
64
+ }
65
+
66
+ async function main() {
67
+ const args = parseArgs(process.argv.slice(2));
68
+ const workspace = path.resolve(String(args.workspace || process.cwd()));
69
+ const account = String(args.account || '');
70
+ const cdp = String(args.cdp || 'http://127.0.0.1:9222');
71
+ const outDir = path.resolve(String(args.out || path.join(workspace, '.fancy', 'douyin', 'output', 'scan')));
72
+ const loginState = path.resolve(String(args['login-state'] || path.join(workspace, '.fancy', 'douyin', 'login-state.json')));
73
+ if (!account) throw new Error('缺少 --account 主页地址');
74
+ ensureDir(outDir);
75
+
76
+ const { context } = await connect(cdp);
77
+ let page = null;
78
+ let keepPage = false;
79
+ try {
80
+ await restoreCookies(context, loginState);
81
+ page = await context.newPage();
82
+ await page.goto(account, { waitUntil: 'domcontentloaded', timeout: 60000 });
83
+ await page.waitForTimeout(3500);
84
+
85
+ const firstGate = await gateResult(page, outDir);
86
+ if (firstGate) {
87
+ keepPage = true;
88
+ await saveCookies(context, loginState, page.url());
89
+ printJson(firstGate);
90
+ return;
91
+ }
92
+
93
+ const cards = await collectCards(page);
94
+ const afterGate = await gateResult(page, outDir);
95
+ if (afterGate) {
96
+ keepPage = true;
97
+ await saveCookies(context, loginState, page.url());
98
+ printJson(afterGate);
99
+ return;
100
+ }
101
+
102
+ await saveCookies(context, loginState, page.url());
103
+ const cardsFile = path.join(outDir, 'profile_cards.json');
104
+ fs.writeFileSync(cardsFile, JSON.stringify(cards, null, 2) + '\n', 'utf8');
105
+
106
+ const notes = cards.filter((card) => card.kind === 'note');
107
+ const videos = cards.filter((card) => card.kind === 'video');
108
+ printJson({
109
+ ok: true,
110
+ login_required: false,
111
+ account,
112
+ title: await page.title(),
113
+ notes,
114
+ videos: videos.length,
115
+ cards_file: cardsFile,
116
+ });
117
+ } finally {
118
+ if (page && !keepPage) {
119
+ try { await page.close(); } catch { /* ignore */ }
120
+ }
121
+ // Deliberately do not call browser.close(): Chrome stays open for the
122
+ // next note and preserves the workspace-local login profile.
123
+ }
124
+ }
125
+
126
+ main()
127
+ .then(() => process.exit(0))
128
+ .catch((err) => { fail(err); process.exit(0); });