tt-help-cli-ycl 1.3.6 → 1.3.8

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 (46) hide show
  1. package/README.md +17 -17
  2. package/cli.js +9 -9
  3. package/package.json +45 -45
  4. package/src/cli/auto.js +131 -121
  5. package/src/cli/explore.js +147 -138
  6. package/src/cli/progress.js +111 -111
  7. package/src/cli/scrape.js +47 -47
  8. package/src/cli/utils.js +18 -18
  9. package/src/cli/videos.js +41 -41
  10. package/src/cli/watch.js +31 -31
  11. package/src/lib/args.js +391 -391
  12. package/src/lib/browser/anti-detect.js +23 -23
  13. package/src/lib/browser/cdp.js +142 -142
  14. package/src/lib/browser/launch.js +43 -43
  15. package/src/lib/browser/page.js +87 -87
  16. package/src/lib/constants.js +109 -95
  17. package/src/lib/delay.js +54 -54
  18. package/src/lib/explore-fetch.js +118 -118
  19. package/src/lib/fetcher.js +45 -45
  20. package/src/lib/filter.js +66 -66
  21. package/src/lib/io.js +54 -54
  22. package/src/lib/mac-or-uuid.js +82 -0
  23. package/src/lib/output.js +80 -80
  24. package/src/lib/parser.js +47 -47
  25. package/src/lib/retry.js +44 -44
  26. package/src/lib/scrape.js +40 -40
  27. package/src/lib/url.js +52 -52
  28. package/src/main.mjs +221 -221
  29. package/src/scraper/auto-core.mjs +185 -185
  30. package/src/scraper/core.mjs +190 -190
  31. package/src/scraper/explore-core.mjs +162 -162
  32. package/src/scraper/modules/captcha-handler.mjs +114 -114
  33. package/src/scraper/modules/comment-extractor.mjs +69 -69
  34. package/src/scraper/modules/follow-extractor.mjs +121 -121
  35. package/src/scraper/modules/guess-extractor.mjs +51 -51
  36. package/src/scraper/modules/page-error-detector.mjs +70 -70
  37. package/src/scraper/modules/page-helpers.mjs +48 -48
  38. package/src/scraper/modules/scroll-collector.mjs +189 -189
  39. package/src/test-auto-follow.cjs +109 -0
  40. package/src/test-extractors.cjs +75 -0
  41. package/src/test-follow.cjs +41 -0
  42. package/src/videos/core.mjs +126 -126
  43. package/src/watch/data-store.mjs +258 -261
  44. package/src/watch/public/index.html +580 -464
  45. package/src/watch/server.mjs +308 -281
  46. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -1,48 +1,48 @@
1
- import {
2
- delay,
3
- getDelayConfig,
4
- setDelayConfig,
5
- listDelayPresets,
6
- DELAY_PRESETS,
7
- } from '../../lib/delay.js';
8
- import { ensureBrowserReady } from '../../lib/browser/cdp.js';
9
- import {
10
- ensureTikTokPage,
11
- closeCommentPanel,
12
- findTikTokPage,
13
- getOrCreatePage,
14
- isLoggedIn,
15
- assertPageUrl,
16
- } from '../../lib/browser/page.js';
17
- import { retryWithBackoff, isRetryableError } from '../../lib/retry.js';
18
- import {
19
- extractUserSection,
20
- parseUserSection,
21
- extractLocationCreated,
22
- USER_SECTION_SIZE,
23
- } from '../../lib/parser.js';
24
- import { detectPageError } from './page-error-detector.mjs';
25
-
26
- export {
27
- delay,
28
- setDelayConfig,
29
- getDelayConfig,
30
- listDelayPresets,
31
- DELAY_PRESETS,
32
- ensureBrowserReady,
33
- ensureTikTokPage,
34
- closeCommentPanel,
35
- findTikTokPage,
36
- getOrCreatePage,
37
- isLoggedIn,
38
- assertPageUrl,
39
- retryWithBackoff,
40
- isRetryableError,
41
- extractUserSection,
42
- parseUserSection,
43
- extractLocationCreated,
44
- USER_SECTION_SIZE,
45
- detectPageError,
46
- };
47
-
48
- export const CDP_PORT = 9222;
1
+ import {
2
+ delay,
3
+ getDelayConfig,
4
+ setDelayConfig,
5
+ listDelayPresets,
6
+ DELAY_PRESETS,
7
+ } from '../../lib/delay.js';
8
+ import { ensureBrowserReady } from '../../lib/browser/cdp.js';
9
+ import {
10
+ ensureTikTokPage,
11
+ closeCommentPanel,
12
+ findTikTokPage,
13
+ getOrCreatePage,
14
+ isLoggedIn,
15
+ assertPageUrl,
16
+ } from '../../lib/browser/page.js';
17
+ import { retryWithBackoff, isRetryableError } from '../../lib/retry.js';
18
+ import {
19
+ extractUserSection,
20
+ parseUserSection,
21
+ extractLocationCreated,
22
+ USER_SECTION_SIZE,
23
+ } from '../../lib/parser.js';
24
+ import { detectPageError } from './page-error-detector.mjs';
25
+
26
+ export {
27
+ delay,
28
+ setDelayConfig,
29
+ getDelayConfig,
30
+ listDelayPresets,
31
+ DELAY_PRESETS,
32
+ ensureBrowserReady,
33
+ ensureTikTokPage,
34
+ closeCommentPanel,
35
+ findTikTokPage,
36
+ getOrCreatePage,
37
+ isLoggedIn,
38
+ assertPageUrl,
39
+ retryWithBackoff,
40
+ isRetryableError,
41
+ extractUserSection,
42
+ parseUserSection,
43
+ extractLocationCreated,
44
+ USER_SECTION_SIZE,
45
+ detectPageError,
46
+ };
47
+
48
+ export const CDP_PORT = 9222;
@@ -1,189 +1,189 @@
1
- import { delay } from "../../lib/delay.js";
2
- import { detectPageError } from "./page-error-detector.mjs";
3
-
4
- async function doCollect(
5
- page,
6
- { container, findScrollable, fnStr, extraArgs },
7
- ) {
8
- return page.evaluate(
9
- ({ fn: fnStr, containerSelector, findScrollableFlag, args }) => {
10
- let el;
11
- if (!containerSelector) {
12
- el = window;
13
- } else {
14
- el = document.querySelector(containerSelector);
15
- if (!el) {
16
- el = window;
17
- } else if (findScrollableFlag) {
18
- let current = el;
19
- let found = false;
20
- while (current && current !== document.body) {
21
- if (current.scrollHeight > current.clientHeight + 10) {
22
- el = current;
23
- found = true;
24
- break;
25
- }
26
- current = current.parentElement;
27
- }
28
- if (!found) el = document.body;
29
- }
30
- }
31
- const fn = eval("(" + fnStr + ")");
32
- return fn(el, args);
33
- },
34
- {
35
- fn: fnStr,
36
- containerSelector: container,
37
- findScrollableFlag: findScrollable,
38
- args: extraArgs,
39
- },
40
- );
41
- }
42
-
43
- const LOADING_SELECTORS = [
44
- '[class*="loading"]',
45
- '[class*="Loading"]',
46
- '[class*="spinner"]',
47
- '[class*="Spinner"]',
48
- '[class*="skeleton"]',
49
- '[class*="Skeleton"]',
50
- '[aria-busy="true"]',
51
- ];
52
-
53
- async function waitForLoading(page) {
54
- const maxWait = 5000;
55
- const startTime = Date.now();
56
- while (Date.now() - startTime < maxWait) {
57
- const isLoading = await page.evaluate((sels) => {
58
- if (document.readyState !== "complete") return true;
59
- for (const sel of sels) {
60
- const el = document.querySelector(sel);
61
- if (el && el.offsetParent !== null) return true;
62
- }
63
- return false;
64
- }, LOADING_SELECTORS);
65
- if (!isLoading) return;
66
- await delay(300, 600);
67
- }
68
- }
69
-
70
- export async function scrollAndCollect(page, options) {
71
- const {
72
- container,
73
- findScrollable = false,
74
- collectFn,
75
- extraArgs,
76
- delayRange = [800, 1500],
77
- maxItems,
78
- maxRounds = 200,
79
- staleThreshold = 3,
80
- uniqueKey,
81
- onRound,
82
- } = options;
83
-
84
- if (!collectFn) throw new Error("collectFn is required");
85
-
86
- const fnStr =
87
- typeof collectFn === "function" ? collectFn.toString() : collectFn;
88
- const allItems = [];
89
- const seenKeys = uniqueKey ? new Set() : null;
90
- let staleCount = 0;
91
-
92
- const processItems = (result) => {
93
- const raw = result.items || [];
94
- const newItems = uniqueKey
95
- ? raw.filter((item) => {
96
- const key = uniqueKey(item);
97
- if (seenKeys.has(key)) return false;
98
- seenKeys.add(key);
99
- return true;
100
- })
101
- : raw;
102
- allItems.push(...newItems);
103
- return newItems;
104
- };
105
-
106
- const isDone = (newItems) => {
107
- if (maxItems !== undefined && allItems.length >= maxItems) return true;
108
- if (newItems.length === 0) {
109
- staleCount++;
110
- if (staleCount >= staleThreshold) return true;
111
- } else {
112
- staleCount = 0;
113
- }
114
- return false;
115
- };
116
-
117
- const collectCtx = { container, findScrollable, fnStr, extraArgs };
118
-
119
- const pageError = await detectPageError(page);
120
-
121
- if (pageError) return [];
122
-
123
- await waitForLoading(page);
124
- let result = await doCollect(page, collectCtx);
125
- let newItems = processItems(result);
126
- if (onRound) onRound(0, newItems, allItems);
127
- if (isDone(newItems)) return allItems;
128
-
129
- for (let round = 1; round < maxRounds; round++) {
130
- await threePhaseScroll(page, { container, findScrollable });
131
- await delay(delayRange[0], delayRange[1]);
132
- await waitForLoading(page);
133
-
134
- result = await doCollect(page, collectCtx);
135
- newItems = processItems(result);
136
-
137
- if (onRound) onRound(round, newItems, allItems);
138
-
139
- if (isDone(newItems)) break;
140
- }
141
-
142
- return allItems;
143
- }
144
-
145
- async function threePhaseScroll(page, { container, findScrollable }) {
146
- await page.evaluate(
147
- async (opts) => {
148
- let el;
149
- if (!opts.container) {
150
- el = window;
151
- } else {
152
- el = document.querySelector(opts.container);
153
- if (!el) {
154
- el = window;
155
- } else if (opts.findScrollable) {
156
- let current = el;
157
- let found = false;
158
- while (current && current !== document.body) {
159
- if (current.scrollHeight > current.clientHeight + 10) {
160
- el = current;
161
- found = true;
162
- break;
163
- }
164
- current = current.parentElement;
165
- }
166
- if (!found) el = document.body;
167
- }
168
- }
169
-
170
- const randDelay = (min, max) =>
171
- new Promise((r) => setTimeout(r, min + Math.random() * (max - min)));
172
-
173
- if (el === window) {
174
- window.scrollBy(0, window.innerHeight);
175
- await randDelay(400, 800);
176
- window.scrollBy(0, -200);
177
- await randDelay(200, 400);
178
- window.scrollBy(0, window.innerHeight);
179
- } else {
180
- el.scrollTop = el.scrollHeight;
181
- await randDelay(400, 800);
182
- el.scrollTop -= 100 + Math.random() * 100;
183
- await randDelay(200, 400);
184
- el.scrollTop = el.scrollHeight;
185
- }
186
- },
187
- { container, findScrollable },
188
- );
189
- }
1
+ import { delay } from "../../lib/delay.js";
2
+ import { detectPageError } from "./page-error-detector.mjs";
3
+
4
+ async function doCollect(
5
+ page,
6
+ { container, findScrollable, fnStr, extraArgs },
7
+ ) {
8
+ return page.evaluate(
9
+ ({ fn: fnStr, containerSelector, findScrollableFlag, args }) => {
10
+ let el;
11
+ if (!containerSelector) {
12
+ el = window;
13
+ } else {
14
+ el = document.querySelector(containerSelector);
15
+ if (!el) {
16
+ el = window;
17
+ } else if (findScrollableFlag) {
18
+ let current = el;
19
+ let found = false;
20
+ while (current && current !== document.body) {
21
+ if (current.scrollHeight > current.clientHeight + 10) {
22
+ el = current;
23
+ found = true;
24
+ break;
25
+ }
26
+ current = current.parentElement;
27
+ }
28
+ if (!found) el = document.body;
29
+ }
30
+ }
31
+ const fn = eval("(" + fnStr + ")");
32
+ return fn(el, args);
33
+ },
34
+ {
35
+ fn: fnStr,
36
+ containerSelector: container,
37
+ findScrollableFlag: findScrollable,
38
+ args: extraArgs,
39
+ },
40
+ );
41
+ }
42
+
43
+ const LOADING_SELECTORS = [
44
+ '[class*="loading"]',
45
+ '[class*="Loading"]',
46
+ '[class*="spinner"]',
47
+ '[class*="Spinner"]',
48
+ '[class*="skeleton"]',
49
+ '[class*="Skeleton"]',
50
+ '[aria-busy="true"]',
51
+ ];
52
+
53
+ async function waitForLoading(page) {
54
+ const maxWait = 5000;
55
+ const startTime = Date.now();
56
+ while (Date.now() - startTime < maxWait) {
57
+ const isLoading = await page.evaluate((sels) => {
58
+ if (document.readyState !== "complete") return true;
59
+ for (const sel of sels) {
60
+ const el = document.querySelector(sel);
61
+ if (el && el.offsetParent !== null) return true;
62
+ }
63
+ return false;
64
+ }, LOADING_SELECTORS);
65
+ if (!isLoading) return;
66
+ await delay(300, 600);
67
+ }
68
+ }
69
+
70
+ export async function scrollAndCollect(page, options) {
71
+ const {
72
+ container,
73
+ findScrollable = false,
74
+ collectFn,
75
+ extraArgs,
76
+ delayRange = [800, 1500],
77
+ maxItems,
78
+ maxRounds = 200,
79
+ staleThreshold = 3,
80
+ uniqueKey,
81
+ onRound,
82
+ } = options;
83
+
84
+ if (!collectFn) throw new Error("collectFn is required");
85
+
86
+ const fnStr =
87
+ typeof collectFn === "function" ? collectFn.toString() : collectFn;
88
+ const allItems = [];
89
+ const seenKeys = uniqueKey ? new Set() : null;
90
+ let staleCount = 0;
91
+
92
+ const processItems = (result) => {
93
+ const raw = result.items || [];
94
+ const newItems = uniqueKey
95
+ ? raw.filter((item) => {
96
+ const key = uniqueKey(item);
97
+ if (seenKeys.has(key)) return false;
98
+ seenKeys.add(key);
99
+ return true;
100
+ })
101
+ : raw;
102
+ allItems.push(...newItems);
103
+ return newItems;
104
+ };
105
+
106
+ const isDone = (newItems) => {
107
+ if (maxItems !== undefined && allItems.length >= maxItems) return true;
108
+ if (newItems.length === 0) {
109
+ staleCount++;
110
+ if (staleCount >= staleThreshold) return true;
111
+ } else {
112
+ staleCount = 0;
113
+ }
114
+ return false;
115
+ };
116
+
117
+ const collectCtx = { container, findScrollable, fnStr, extraArgs };
118
+
119
+ const pageError = await detectPageError(page);
120
+
121
+ if (pageError) return [];
122
+
123
+ await waitForLoading(page);
124
+ let result = await doCollect(page, collectCtx);
125
+ let newItems = processItems(result);
126
+ if (onRound) onRound(0, newItems, allItems);
127
+ if (isDone(newItems)) return allItems;
128
+
129
+ for (let round = 1; round < maxRounds; round++) {
130
+ await threePhaseScroll(page, { container, findScrollable });
131
+ await delay(delayRange[0], delayRange[1]);
132
+ await waitForLoading(page);
133
+
134
+ result = await doCollect(page, collectCtx);
135
+ newItems = processItems(result);
136
+
137
+ if (onRound) onRound(round, newItems, allItems);
138
+
139
+ if (isDone(newItems)) break;
140
+ }
141
+
142
+ return allItems;
143
+ }
144
+
145
+ async function threePhaseScroll(page, { container, findScrollable }) {
146
+ await page.evaluate(
147
+ async (opts) => {
148
+ let el;
149
+ if (!opts.container) {
150
+ el = window;
151
+ } else {
152
+ el = document.querySelector(opts.container);
153
+ if (!el) {
154
+ el = window;
155
+ } else if (opts.findScrollable) {
156
+ let current = el;
157
+ let found = false;
158
+ while (current && current !== document.body) {
159
+ if (current.scrollHeight > current.clientHeight + 10) {
160
+ el = current;
161
+ found = true;
162
+ break;
163
+ }
164
+ current = current.parentElement;
165
+ }
166
+ if (!found) el = document.body;
167
+ }
168
+ }
169
+
170
+ const randDelay = (min, max) =>
171
+ new Promise((r) => setTimeout(r, min + Math.random() * (max - min)));
172
+
173
+ if (el === window) {
174
+ window.scrollBy(0, window.innerHeight);
175
+ await randDelay(400, 800);
176
+ window.scrollBy(0, -200);
177
+ await randDelay(200, 400);
178
+ window.scrollBy(0, window.innerHeight);
179
+ } else {
180
+ el.scrollTop = el.scrollHeight;
181
+ await randDelay(400, 800);
182
+ el.scrollTop -= 100 + Math.random() * 100;
183
+ await randDelay(200, 400);
184
+ el.scrollTop = el.scrollHeight;
185
+ }
186
+ },
187
+ { container, findScrollable },
188
+ );
189
+ }
@@ -0,0 +1,109 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const { ensureBrowserReady, setDelayConfig } = require('./scraper/modules/page-helpers.cjs');
4
+ const { processUser } = require('./auto-core.cjs');
5
+ const { createStore } = require('./data-store.cjs');
6
+
7
+ async function main() {
8
+ const outFile = path.join(__dirname, '..', 'results', 'auto-test.json');
9
+ const store = createStore(outFile);
10
+
11
+ setDelayConfig('fast');
12
+
13
+ const browser = await ensureBrowserReady();
14
+ let page;
15
+ try {
16
+ const contexts = browser.contexts();
17
+ page = null;
18
+ for (const ctx of contexts) {
19
+ for (const p of ctx.pages()) {
20
+ if (p.url().includes('tiktok.com')) { page = p; break; }
21
+ }
22
+ if (page) break;
23
+ }
24
+ if (!page) page = await contexts[0].newPage();
25
+
26
+ console.error('========== 测试 processUser + enableFollow ==========');
27
+ console.error('用户: @qiqi23280\n');
28
+
29
+ const result = await processUser(page, 'qiqi23280', {
30
+ collectMax: 1,
31
+ scrapeDepth: 1,
32
+ maxComments: 10,
33
+ maxGuess: 5,
34
+ preset: 'fast',
35
+ enableFollow: true,
36
+ maxFollowing: 50,
37
+ maxFollowers: 50,
38
+ browser,
39
+ }, console.error);
40
+
41
+ console.error('\n========== 结果验证 ==========');
42
+ let allPassed = true;
43
+
44
+ const checks = [
45
+ { label: '用户信息', ok: result.userInfo && result.userInfo.uniqueId, detail: result.userInfo?.uniqueId },
46
+ { label: '关注列表', ok: Array.isArray(result.discoveredFollowing) && result.discoveredFollowing.length > 0, detail: `${result.discoveredFollowing?.length || 0} 人` },
47
+ { label: '粉丝列表', ok: Array.isArray(result.discoveredFollowers) && result.discoveredFollowers.length > 0, detail: `${result.discoveredFollowers?.length || 0} 人` },
48
+ { label: '关注格式', ok: result.discoveredFollowing?.every(p => Array.isArray(p) && p.length === 2 && p[0].startsWith('@')), detail: null },
49
+ { label: '粉丝格式', ok: result.discoveredFollowers?.every(p => Array.isArray(p) && p.length === 2 && p[0].startsWith('@')), detail: null },
50
+ { label: '无错误', ok: !result.error, detail: result.error },
51
+ ];
52
+
53
+ for (const c of checks) {
54
+ const status = c.ok ? 'PASS' : 'FAIL';
55
+ const detailStr = c.detail !== null ? ` (${c.detail})` : '';
56
+ console.error(` ${status}: ${c.label}${detailStr}`);
57
+ if (!c.ok) allPassed = false;
58
+ }
59
+
60
+ // 模拟入队逻辑
61
+ const queue = ['qiqi23280'];
62
+ const followingIds = (result.discoveredFollowing || []).map(([h]) => h.replace(/^@/, ''));
63
+ const followerIds = (result.discoveredFollowers || []).map(([h]) => h.replace(/^@/, ''));
64
+
65
+ for (const uid of followingIds) queue.push(uid);
66
+ for (const uid of followerIds) queue.push(uid);
67
+ const uniqueQueue = [...new Set(queue)];
68
+
69
+ console.error(`\n 队列长度: ${uniqueQueue.length}(关注 ${followingIds.length} + 粉丝 ${followerIds.length} + 种子 1)`);
70
+
71
+ // 写入 store 验证
72
+ store.addUser({
73
+ uniqueId: 'qiqi23280',
74
+ ...result.userInfo,
75
+ sources: ['seed'],
76
+ });
77
+ for (const [handle, name] of (result.discoveredFollowing || [])) {
78
+ store.addUser({ uniqueId: handle.replace(/^@/, ''), nickname: name, sources: ['following'] });
79
+ }
80
+ for (const [handle, name] of (result.discoveredFollowers || [])) {
81
+ store.addUser({ uniqueId: handle.replace(/^@/, ''), nickname: name, sources: ['follower'] });
82
+ }
83
+ store.save();
84
+
85
+ const allUsers = store.getAllUsers();
86
+ console.error(` Store 用户数: ${allUsers.length}`);
87
+
88
+ // 验证 source 标记
89
+ const followingUsers = allUsers.filter(u => u.sources?.includes('following'));
90
+ const followerUsers = allUsers.filter(u => u.sources?.includes('follower'));
91
+ console.error(` 关注来源: ${followingUsers.length} | 粉丝来源: ${followerUsers.length}`);
92
+
93
+ if (followingUsers.length === 0 || followerUsers.length === 0) {
94
+ console.error(' FAIL: 缺少 following 或 follower 来源标记');
95
+ allPassed = false;
96
+ } else {
97
+ console.error(' PASS: 来源标记正确');
98
+ }
99
+
100
+ console.error(`\n${allPassed ? 'ALL PASSED' : 'SOME FAILED'}`);
101
+ console.error(`数据保存到: ${outFile}`);
102
+ process.exit(allPassed ? 0 : 1);
103
+
104
+ } finally {
105
+ await browser.close().catch(() => {});
106
+ }
107
+ }
108
+
109
+ main().catch(err => { console.error('FATAL:', err.message); process.exit(1); });
@@ -0,0 +1,75 @@
1
+ const { ensureBrowserReady, delay, setDelayConfig } = require('./scraper/modules/page-helpers.cjs');
2
+ const { extractCommentAuthors } = require('./scraper/modules/comment-extractor.cjs');
3
+ const { extractGuessVideos } = require('./scraper/modules/guess-extractor.cjs');
4
+
5
+ async function main() {
6
+ setDelayConfig('fast');
7
+
8
+ const videoUrl = process.argv[2] || 'https://www.tiktok.com/@porfirio.fructuoso/video/7615853535955111198';
9
+ console.error(`目标: ${videoUrl}`);
10
+
11
+ const browser = await ensureBrowserReady();
12
+ let page;
13
+ try {
14
+ const contexts = browser.contexts();
15
+ page = null;
16
+ for (const ctx of contexts) {
17
+ for (const p of ctx.pages()) {
18
+ if (p.url().includes('tiktok.com')) { page = p; break; }
19
+ }
20
+ if (page) break;
21
+ }
22
+ if (!page) {
23
+ page = await contexts[0].newPage();
24
+ }
25
+
26
+ await page.goto(videoUrl, { waitUntil: 'networkidle', timeout: 60000 });
27
+ await delay(5000, 8000);
28
+
29
+ console.error(`当前URL: ${page.url()}`);
30
+
31
+ let allPassed = true;
32
+
33
+ // ========== 评论提取 ==========
34
+ console.error('\n--- 评论提取 (max=30) ---');
35
+ const t1 = Date.now();
36
+ let commentUsers = [];
37
+ try { commentUsers = await extractCommentAuthors(page, 30); }
38
+ catch (e) { console.error(` 异常: ${e.message}`); }
39
+ console.error(` 耗时: ${((Date.now()-t1)/1000).toFixed(1)}s, 结果: ${commentUsers.length} 个`);
40
+
41
+ if (commentUsers.length > 0) {
42
+ const s = new Set(commentUsers);
43
+ const ok = s.size === commentUsers.length;
44
+ console.error(` ${ok ? 'PASS' : 'FAIL'}: 唯一${s.size}/总数${commentUsers.length}`);
45
+ if (!ok) allPassed = false;
46
+ }
47
+
48
+ // ========== 猜你喜欢提取 ==========
49
+ console.error('\n--- 猜你喜欢提取 (max=20) ---');
50
+ const t2 = Date.now();
51
+ let guessVideos = [];
52
+ try { guessVideos = await extractGuessVideos(page, 20); }
53
+ catch (e) { console.error(` 异常: ${e.message}`); }
54
+ console.error(` 耗时: ${((Date.now()-t2)/1000).toFixed(1)}s, 结果: ${guessVideos.length} 个`);
55
+
56
+ if (guessVideos.length > 0) {
57
+ const ids = guessVideos.map(v => v.videoId);
58
+ const s = new Set(ids);
59
+ const ok = s.size === ids.length;
60
+ console.error(` ${ok ? 'PASS' : 'FAIL'}: 唯一${s.size}/总数${ids.length}`);
61
+ if (!ok) allPassed = false;
62
+ const ok2 = guessVideos.every(v => v.author && v.videoId && v.url);
63
+ console.error(` ${ok2 ? 'PASS' : 'FAIL'}: 结构完整`);
64
+ if (!ok2) allPassed = false;
65
+ }
66
+
67
+ console.error(`\n${allPassed ? 'ALL PASSED' : 'SOME FAILED'}`);
68
+ process.exit(allPassed ? 0 : 1);
69
+
70
+ } finally {
71
+ await browser.close().catch(() => {});
72
+ }
73
+ }
74
+
75
+ main().catch(err => { console.error('FATAL:', err.message); process.exit(1); });