tt-help-cli-ycl 1.3.48 → 1.3.50

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 (64) hide show
  1. package/README.md +33 -33
  2. package/cli.js +9 -9
  3. package/package.json +52 -52
  4. package/scripts/run-explore copy.bat +101 -101
  5. package/scripts/run-explore.bat +134 -134
  6. package/scripts/run-explore.ps1 +159 -159
  7. package/scripts/run-explore.sh +121 -121
  8. package/scripts/test-captcha-lib.mjs +68 -0
  9. package/scripts/test-captcha.mjs +81 -0
  10. package/scripts/test-incognito-lib.mjs +36 -0
  11. package/scripts/test-login-state.mjs +128 -0
  12. package/scripts/test-safe-click.mjs +45 -0
  13. package/scripts/test-watch-db-smoke.mjs +246 -0
  14. package/src/cli/attach.js +331 -331
  15. package/src/cli/auto.js +265 -265
  16. package/src/cli/comments.js +620 -620
  17. package/src/cli/config.js +170 -170
  18. package/src/cli/db-import.js +51 -51
  19. package/src/cli/explore.js +555 -555
  20. package/src/cli/open.js +109 -111
  21. package/src/cli/progress.js +111 -111
  22. package/src/cli/refresh.js +288 -288
  23. package/src/cli/scrape.js +47 -47
  24. package/src/cli/utils.js +18 -18
  25. package/src/cli/videos.js +41 -41
  26. package/src/cli/videostats.js +196 -196
  27. package/src/cli/watch.js +30 -30
  28. package/src/lib/api-interceptor.js +161 -161
  29. package/src/lib/args.js +809 -809
  30. package/src/lib/browser/anti-detect.js +23 -23
  31. package/src/lib/browser/cdp.js +261 -261
  32. package/src/lib/browser/health-checker.js +114 -114
  33. package/src/lib/browser/launch.js +43 -43
  34. package/src/lib/browser/page.js +184 -184
  35. package/src/lib/constants.js +297 -297
  36. package/src/lib/delay.js +54 -54
  37. package/src/lib/explore-fetch.js +118 -118
  38. package/src/lib/fetcher.js +45 -45
  39. package/src/lib/filter.js +66 -66
  40. package/src/lib/io.js +54 -54
  41. package/src/lib/output.js +80 -80
  42. package/src/lib/page-error-detector.js +109 -109
  43. package/src/lib/parse-ssr.mjs +69 -69
  44. package/src/lib/parser.js +47 -47
  45. package/src/lib/retry.js +45 -45
  46. package/src/lib/scrape.js +90 -90
  47. package/src/lib/target-locations.js +61 -61
  48. package/src/lib/tiktok-scraper.mjs +98 -61
  49. package/src/lib/url.js +52 -52
  50. package/src/main.js +73 -73
  51. package/src/npm-main.js +70 -70
  52. package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
  53. package/src/scraper/auto-core.js +203 -203
  54. package/src/scraper/core.js +255 -255
  55. package/src/scraper/explore-core.js +208 -208
  56. package/src/scraper/modules/captcha-handler.js +114 -114
  57. package/src/scraper/modules/follow-extractor.js +250 -250
  58. package/src/scraper/modules/guess-extractor.js +51 -51
  59. package/src/scraper/modules/page-helpers.js +48 -48
  60. package/src/scraper/refresh-core.js +213 -213
  61. package/src/videos/core.js +143 -143
  62. package/src/watch/data-store.js +2980 -2980
  63. package/src/watch/public/index.html +2355 -2355
  64. package/src/watch/server.js +727 -727
@@ -1,620 +1,620 @@
1
- import { chromium } from "playwright";
2
- import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
3
- import { closeCommentPanel } from "../lib/browser/page.js";
4
- import { server as defaultServer } from "../lib/constants.js";
5
- import {
6
- DEFAULT_TARGET_LOCATIONS,
7
- isLocationInList,
8
- normalizeLocation,
9
- } from "../lib/target-locations.js";
10
-
11
- async function waitForPageReady(page, timeout = 30000) {
12
- const startTime = Date.now();
13
- while (Date.now() - startTime < timeout) {
14
- try {
15
- const ready = await page.evaluate(() => {
16
- return document.querySelectorAll('[class*="tabbar-item"]').length > 0;
17
- });
18
- if (ready) return true;
19
- } catch {
20
- // Page may have navigated, retry
21
- }
22
- await new Promise((r) => setTimeout(r, 1000));
23
- }
24
- return false;
25
- }
26
-
27
- async function safeEvaluate(page, fn) {
28
- try {
29
- return await page.evaluate(fn);
30
- } catch {
31
- return null;
32
- }
33
- }
34
-
35
- async function withRetry(label, fn, maxRetries = 3) {
36
- let backoff = 2000;
37
- for (let i = 0; i < maxRetries; i++) {
38
- try {
39
- return await fn();
40
- } catch (err) {
41
- if (i < maxRetries - 1) {
42
- console.error(
43
- ` [${label}] 失败: ${err.message},${backoff / 1000}s 后重试...`,
44
- );
45
- await new Promise((r) => setTimeout(r, backoff));
46
- backoff *= 2;
47
- } else {
48
- throw err;
49
- }
50
- }
51
- }
52
- }
53
-
54
- async function apiPost(url, body) {
55
- return withRetry(`POST ${url}`, async () => {
56
- const res = await fetch(url, {
57
- method: "POST",
58
- headers: { "Content-Type": "application/json" },
59
- body: JSON.stringify(body),
60
- });
61
- if (!res.ok) {
62
- const errText = await res.text();
63
- throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
64
- }
65
- return res.json();
66
- });
67
- }
68
-
69
- async function apiPut(url) {
70
- return withRetry(`PUT ${url}`, async () => {
71
- const res = await fetch(url, {
72
- method: "PUT",
73
- headers: { "Content-Type": "application/json" },
74
- });
75
- if (!res.ok) {
76
- const errText = await res.text();
77
- throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
78
- }
79
- return res.json();
80
- });
81
- }
82
-
83
- async function apiGet(url) {
84
- return withRetry(`GET ${url}`, async () => {
85
- const res = await fetch(url);
86
- if (!res.ok) {
87
- const errText = await res.text();
88
- throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
89
- }
90
- return res.json();
91
- });
92
- }
93
-
94
- function isBrowserClosedError(err) {
95
- if (!err) return false;
96
- const msg = err.message || err.toString() || "";
97
- return (
98
- (msg.includes("Target") && msg.includes("closed")) ||
99
- (msg.includes("Session") && msg.includes("deleted")) ||
100
- (msg.includes("Session") && msg.includes("not found")) ||
101
- (msg.includes("Browser") && msg.includes("closed")) ||
102
- msg.includes("Execution context was destroyed") ||
103
- msg.includes("Protocol error") ||
104
- msg.includes("Cannot find context")
105
- );
106
- }
107
-
108
- /**
109
- * 自动模式:循环从服务端取视频任务,抓评论,提交
110
- */
111
- async function runAutoMode(options) {
112
- const { serverUrl, parallel, interval, maxComments } = options;
113
- const actualParallel = Math.max(1, parallel || 1);
114
- const actualInterval = interval || 10;
115
- const actualMaxComments = maxComments || 200;
116
- let shuttingDown = false;
117
-
118
- console.error(
119
- `\n[Comments Auto] 并行: ${actualParallel}, 间隔: ${actualInterval}s, 评论数: ${actualMaxComments}`,
120
- );
121
- console.error(`服务器: ${serverUrl}`);
122
- console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
123
- console.error(`开始循环接收任务...\n`);
124
-
125
- let browser;
126
- let browserRestartCount = 0;
127
- let processedCount = 0;
128
- let skippedCount = 0;
129
- let errorCount = 0;
130
- let consecutiveErrors = 0;
131
-
132
- const shutdown = async (signal) => {
133
- if (shuttingDown) return;
134
- shuttingDown = true;
135
- console.error(`\n[Comments Auto] 收到 ${signal},正在关闭浏览器...`);
136
- await browser?.close().catch(() => {});
137
- console.error("[Comments Auto] 已退出");
138
- process.exit(0);
139
- };
140
-
141
- const onSigint = () => {
142
- void shutdown("SIGINT");
143
- };
144
- const onSigterm = () => {
145
- void shutdown("SIGTERM");
146
- };
147
-
148
- process.once("SIGINT", onSigint);
149
- process.once("SIGTERM", onSigterm);
150
-
151
- async function ensureBrowser() {
152
- if (browser) {
153
- try {
154
- await browser.contexts()[0]?.pages()[0]?.url();
155
- return browser;
156
- } catch {
157
- try {
158
- await browser.close();
159
- } catch {}
160
- browser = null;
161
- }
162
- }
163
- browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
164
- return browser;
165
- }
166
-
167
- async function getPage(browser) {
168
- const contexts = browser.contexts();
169
- if (contexts.length > 0) {
170
- const pages = contexts[0].pages();
171
- if (pages.length > 0) return pages[0];
172
- }
173
- return null;
174
- }
175
-
176
- try {
177
- while (!shuttingDown) {
178
- let page;
179
- try {
180
- browser = await ensureBrowser();
181
- page = await getPage(browser);
182
- if (!page) {
183
- console.error("[Comments Auto] 未找到可用页面,等待中...");
184
- await new Promise((r) => setTimeout(r, actualInterval * 1000));
185
- continue;
186
- }
187
-
188
- // 获取任务
189
- let tasks;
190
- try {
191
- const resp = await apiGet(
192
- `${serverUrl}/api/comment-tasks?limit=${actualParallel}`,
193
- );
194
- tasks = resp.tasks || [];
195
- } catch (err) {
196
- console.error(`[Comments Auto] 获取任务失败: ${err.message}`);
197
- consecutiveErrors++;
198
- if (consecutiveErrors > 10) {
199
- console.error("[Comments Auto] 连续获取失败超过10次,请检查服务端");
200
- process.exit(1);
201
- }
202
- await new Promise((r) => setTimeout(r, actualInterval * 1000));
203
- continue;
204
- }
205
- consecutiveErrors = 0;
206
-
207
- if (tasks.length === 0) {
208
- console.error(
209
- `[Comments Auto] 暂无任务,${actualInterval}s 后重试...`,
210
- );
211
- await new Promise((r) => setTimeout(r, actualInterval * 1000));
212
- continue;
213
- }
214
-
215
- console.error(`[Comments Auto] 获取 ${tasks.length} 个任务`);
216
-
217
- for (const task of tasks) {
218
- try {
219
- const { id, href, locationCreated, ttSeller } = task;
220
- const loc = normalizeLocation(locationCreated);
221
-
222
- // 检查目标国家
223
- if (loc && !isLocationInList(loc, DEFAULT_TARGET_LOCATIONS)) {
224
- // 非目标国家,直接 commit 跳过
225
- await apiPut(`${serverUrl}/api/comment-task/${id}`);
226
- skippedCount++;
227
- console.error(
228
- ` [跳过] ${id.substring(0, 15)}... 国家: ${loc} (非目标)`,
229
- );
230
- continue;
231
- }
232
-
233
- console.error(
234
- ` [处理] ${id.substring(0, 15)}... 国家: ${loc || "?"} ttSeller: ${ttSeller} -> ${href}`,
235
- );
236
-
237
- // 确保浏览器可用
238
- browser = await ensureBrowser();
239
- page = await getPage(browser);
240
- if (!page) {
241
- throw new Error("未找到可用页面");
242
- }
243
-
244
- // 导航到视频页
245
- await page.goto(href, {
246
- waitUntil: "domcontentloaded",
247
- timeout: 30000,
248
- });
249
- const ready = await waitForPageReady(page, 30000);
250
- if (!ready) {
251
- throw new Error("页面加载超时");
252
- }
253
-
254
- // 关闭弹窗
255
- await safeEvaluate(page, () => {
256
- document
257
- .querySelectorAll('[id*="modal-overlay"]')
258
- .forEach((o) => o.click());
259
- });
260
- await page.keyboard.press("Escape");
261
- await new Promise((r) => setTimeout(r, 1000));
262
-
263
- // 如果评论面板已打开,先关闭
264
- const panelOpen = await safeEvaluate(page, () => {
265
- return !!document.querySelector('[class*="RightPanelContainer"]');
266
- });
267
- if (panelOpen) {
268
- await closeCommentPanel(page);
269
- await new Promise((r) => setTimeout(r, 800));
270
- }
271
-
272
- // 获取评论
273
- const result = await fetchUserCommentsAPI(page, {
274
- maxComments: actualMaxComments,
275
- log: () => {},
276
- });
277
-
278
- // 关闭评论面板
279
- await closeCommentPanel(page);
280
-
281
- if (
282
- result.error ||
283
- !result.comments ||
284
- result.comments.length === 0
285
- ) {
286
- console.error(` [无评论] ${result.error || "未获取到评论"}`);
287
- } else {
288
- // 提取去重作者
289
- const authors = [
290
- ...new Set(
291
- result.comments
292
- .map((c) => c.user?.unique_id)
293
- .filter(Boolean)
294
- .map((u) => u.replace(/^@/, "")),
295
- ),
296
- ];
297
-
298
- if (authors.length > 0) {
299
- // 提交作者
300
- const saveResult = await apiPost(`${serverUrl}/api/users`, {
301
- usernames: authors.map((a) => "@" + a),
302
- sources: ["comment"],
303
- guessedLocation: loc || null,
304
- });
305
- console.error(
306
- ` [提交] ${saveResult.added} 新增, ${saveResult.skipped} 跳过`,
307
- );
308
- } else {
309
- console.error(
310
- ` [无作者] 获取了 ${result.comments.length} 条评论但无作者信息`,
311
- );
312
- }
313
- }
314
-
315
- // commit 任务
316
- await apiPut(`${serverUrl}/api/comment-task/${id}`);
317
- processedCount++;
318
- console.error(` [完成] 累计 ${processedCount} 个视频`);
319
- } catch (err) {
320
- errorCount++;
321
- const isBrowserClosed = isBrowserClosedError(err);
322
- if (isBrowserClosed) {
323
- console.error(` [浏览器异常] ${err.message},将重启浏览器`);
324
- try {
325
- await browser.close();
326
- } catch {}
327
- browser = null;
328
- browserRestartCount++;
329
- } else {
330
- console.error(` [错误] ${err.message}`);
331
- }
332
- continue;
333
- }
334
- }
335
- } catch (err) {
336
- errorCount++;
337
- const isBrowserClosed = isBrowserClosedError(err);
338
- if (isBrowserClosed) {
339
- console.error(
340
- `[Comments Auto] 浏览器异常 (${++browserRestartCount}),正在重启...`,
341
- );
342
- try {
343
- await browser.close();
344
- } catch {}
345
- browser = null;
346
- } else {
347
- console.error(`[Comments Auto] 异常: ${err.message}`);
348
- }
349
- }
350
-
351
- // 等待间隔
352
- await new Promise((r) => setTimeout(r, actualInterval * 1000));
353
- }
354
- } finally {
355
- process.removeListener("SIGINT", onSigint);
356
- process.removeListener("SIGTERM", onSigterm);
357
- await browser?.close().catch(() => {});
358
- }
359
- }
360
-
361
- export async function handleComments(options) {
362
- const {
363
- commentsUrl,
364
- commentsMax,
365
- commentsSave,
366
- commentsParallel,
367
- commentsInterval,
368
- commentsServer,
369
- } = options;
370
-
371
- // 自动模式:无URL且传了 -s/--server 参数(或任何参数)
372
- if (!commentsUrl && process.argv.length > 3) {
373
- return runAutoMode({
374
- serverUrl: commentsServer,
375
- parallel: commentsParallel,
376
- interval: commentsInterval,
377
- maxComments: commentsMax || 200,
378
- });
379
- }
380
-
381
- // 手动模式(需要 URL)
382
- if (!commentsUrl) {
383
- console.error("用法: tt-help comments <视频URL> [最大评论数] [--save]");
384
- console.error(
385
- " tt-help comments [-p N] [-i N] [-s server] [-m maxComments]",
386
- );
387
- console.error("");
388
- console.error("手动模式: tt-help comments <URL> [N] [--save]");
389
- console.error("自动模式: tt-help comments -p 1 -i 10 -m 200 -s <server>");
390
- console.error("");
391
- console.error(
392
- "选项: --save 去重后保存到服务端,来源标记为 comment",
393
- );
394
- console.error(" -p, --parallel 并行数 (默认 1)");
395
- console.error(" -i, --interval 空闲间隔秒 (默认 10)");
396
- console.error(" -s, --server 服务端地址");
397
- console.error(" -m, --max-comments 每视频最大评论数 (默认 200)");
398
- process.exit(1);
399
- }
400
-
401
- if (commentsSave) {
402
- // 手动模式 --save
403
- let browser;
404
- try {
405
- browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
406
- const contexts = browser.contexts();
407
- let page;
408
-
409
- if (contexts.length > 0) {
410
- const pages = contexts[0].pages();
411
- if (pages.length > 0) {
412
- page = pages[0];
413
- }
414
- }
415
-
416
- if (!page) {
417
- console.error("未找到可用页面");
418
- process.exit(1);
419
- }
420
-
421
- console.error(`正在打开: ${commentsUrl}`);
422
- await page.goto(commentsUrl, {
423
- waitUntil: "domcontentloaded",
424
- timeout: 30000,
425
- });
426
- const ready = await waitForPageReady(page, 30000);
427
- if (!ready) {
428
- console.error("页面加载超时,tab 未出现");
429
- process.exit(1);
430
- }
431
-
432
- await safeEvaluate(page, () => {
433
- document
434
- .querySelectorAll('[id*="modal-overlay"]')
435
- .forEach((o) => o.click());
436
- });
437
- await page.keyboard.press("Escape");
438
- await new Promise((r) => setTimeout(r, 1000));
439
-
440
- const videoInfo = await safeEvaluate(page, () => {
441
- const result = {};
442
- const m = window.location.href.match(/@([^/]+)\/video/);
443
- result.author = m ? "@" + m[1] : "未知";
444
- const html = document.documentElement.outerHTML;
445
- const locMatch = html.match(/"locationCreated":"([^"]*)/);
446
- result.locationCreated = locMatch ? locMatch[1] : null;
447
- return result;
448
- });
449
-
450
- const panelOpen = await safeEvaluate(page, () => {
451
- return !!document.querySelector('[class*="RightPanelContainer"]');
452
- });
453
- if (panelOpen) {
454
- await closeCommentPanel(page);
455
- await new Promise((r) => setTimeout(r, 800));
456
- }
457
-
458
- const result = await fetchUserCommentsAPI(page, {
459
- maxComments: commentsMax,
460
- log: () => {},
461
- });
462
-
463
- await closeCommentPanel(page);
464
-
465
- if (result.error || !result.comments || result.comments.length === 0) {
466
- console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
467
- process.exit(1);
468
- }
469
-
470
- const authors = [
471
- ...new Set(
472
- result.comments
473
- .map((c) => c.user?.unique_id)
474
- .filter(Boolean)
475
- .map((u) => u.replace(/^@/, "")),
476
- ),
477
- ];
478
-
479
- const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
480
- const serverUrl = commentsServer || defaultServer;
481
-
482
- if (
483
- guessedLocation &&
484
- !isLocationInList(guessedLocation, DEFAULT_TARGET_LOCATIONS)
485
- ) {
486
- console.error(`\n猜测国家: ${guessedLocation},非目标国家,跳过保存`);
487
- console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
488
- } else {
489
- console.error(`\n正在提交 ${authors.length} 个评论作者到服务端...`);
490
- console.error(`服务端: ${serverUrl}`);
491
- if (guessedLocation) console.error(`猜测国家: ${guessedLocation}`);
492
-
493
- try {
494
- const saveResult = await apiPost(`${serverUrl}/api/users`, {
495
- usernames: authors.map((a) => "@" + a),
496
- sources: ["comment"],
497
- guessedLocation: guessedLocation || null,
498
- });
499
- console.error(
500
- `\n提交结果: ${saveResult.added} 个新增, ${saveResult.skipped} 个跳过`,
501
- );
502
- } catch (err) {
503
- console.error(`\n提交失败: ${err.message}`);
504
- process.exit(1);
505
- }
506
- }
507
- } catch (err) {
508
- console.error(`获取评论失败: ${err.message}`);
509
- process.exit(1);
510
- } finally {
511
- if (browser) await browser.close().catch(() => {});
512
- }
513
- return;
514
- }
515
-
516
- // 手动模式:打印到控制台
517
- let browser;
518
- try {
519
- browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
520
- const contexts = browser.contexts();
521
- let page;
522
-
523
- if (contexts.length > 0) {
524
- const pages = contexts[0].pages();
525
- if (pages.length > 0) {
526
- page = pages[0];
527
- }
528
- }
529
-
530
- if (!page) {
531
- console.error("未找到可用页面");
532
- process.exit(1);
533
- }
534
-
535
- console.error(`正在打开: ${commentsUrl}`);
536
- await page.goto(commentsUrl, {
537
- waitUntil: "domcontentloaded",
538
- timeout: 30000,
539
- });
540
- const ready = await waitForPageReady(page, 30000);
541
- if (!ready) {
542
- console.error("页面加载超时,tab 未出现");
543
- process.exit(1);
544
- }
545
-
546
- await safeEvaluate(page, () => {
547
- document
548
- .querySelectorAll('[id*="modal-overlay"]')
549
- .forEach((o) => o.click());
550
- });
551
- await page.keyboard.press("Escape");
552
- await new Promise((r) => setTimeout(r, 1000));
553
-
554
- const videoInfo = await safeEvaluate(page, () => {
555
- const result = {};
556
- const m = window.location.href.match(/@([^/]+)\/video/);
557
- result.author = m ? "@" + m[1] : "未知";
558
- const html = document.documentElement.outerHTML;
559
- const locMatch = html.match(/"locationCreated":"([^"]*)/);
560
- result.locationCreated = locMatch ? locMatch[1] : null;
561
- return result;
562
- });
563
-
564
- const panelOpen = await safeEvaluate(page, () => {
565
- return !!document.querySelector('[class*="RightPanelContainer"]');
566
- });
567
- if (panelOpen) {
568
- await closeCommentPanel(page);
569
- await new Promise((r) => setTimeout(r, 800));
570
- }
571
-
572
- const result = await fetchUserCommentsAPI(page, {
573
- maxComments: commentsMax,
574
- log: () => {},
575
- });
576
-
577
- await closeCommentPanel(page);
578
-
579
- if (result.error || !result.comments || result.comments.length === 0) {
580
- console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
581
- process.exit(1);
582
- }
583
-
584
- const authors = [
585
- ...new Set(
586
- result.comments
587
- .map((c) => c.user?.unique_id)
588
- .filter(Boolean)
589
- .map((u) => u.replace(/^@/, "")),
590
- ),
591
- ];
592
-
593
- console.log(`视频: ${commentsUrl}`);
594
- console.log(`作者: ${videoInfo?.author || "未知"}`);
595
- if (videoInfo?.locationCreated)
596
- console.log(`猜测国家: ${videoInfo.locationCreated}`);
597
- console.log(`评论数: ${result.comments.length}`);
598
- console.log(`评论作者: ${authors.length}`);
599
- console.log("");
600
-
601
- result.comments.forEach((c, i) => {
602
- const author = c.user?.unique_id
603
- ? "@" + c.user.unique_id
604
- : c.user?.nickname || "未知";
605
- const text = c.text || "";
606
- const likes = c.digg_count || 0;
607
- console.log(`${i + 1}. [${author}] ${text} (\u2764 ${likes})`);
608
- });
609
-
610
- console.log("");
611
- console.log(
612
- `共 ${result.comments.length} 条评论, ${authors.length} 个唯一作者`,
613
- );
614
- } catch (err) {
615
- console.error(`获取评论失败: ${err.message}`);
616
- process.exit(1);
617
- } finally {
618
- if (browser) await browser.close().catch(() => {});
619
- }
620
- }
1
+ import { chromium } from "playwright";
2
+ import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
3
+ import { closeCommentPanel } from "../lib/browser/page.js";
4
+ import { server as defaultServer } from "../lib/constants.js";
5
+ import {
6
+ DEFAULT_TARGET_LOCATIONS,
7
+ isLocationInList,
8
+ normalizeLocation,
9
+ } from "../lib/target-locations.js";
10
+
11
+ async function waitForPageReady(page, timeout = 30000) {
12
+ const startTime = Date.now();
13
+ while (Date.now() - startTime < timeout) {
14
+ try {
15
+ const ready = await page.evaluate(() => {
16
+ return document.querySelectorAll('[class*="tabbar-item"]').length > 0;
17
+ });
18
+ if (ready) return true;
19
+ } catch {
20
+ // Page may have navigated, retry
21
+ }
22
+ await new Promise((r) => setTimeout(r, 1000));
23
+ }
24
+ return false;
25
+ }
26
+
27
+ async function safeEvaluate(page, fn) {
28
+ try {
29
+ return await page.evaluate(fn);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function withRetry(label, fn, maxRetries = 3) {
36
+ let backoff = 2000;
37
+ for (let i = 0; i < maxRetries; i++) {
38
+ try {
39
+ return await fn();
40
+ } catch (err) {
41
+ if (i < maxRetries - 1) {
42
+ console.error(
43
+ ` [${label}] 失败: ${err.message},${backoff / 1000}s 后重试...`,
44
+ );
45
+ await new Promise((r) => setTimeout(r, backoff));
46
+ backoff *= 2;
47
+ } else {
48
+ throw err;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ async function apiPost(url, body) {
55
+ return withRetry(`POST ${url}`, async () => {
56
+ const res = await fetch(url, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify(body),
60
+ });
61
+ if (!res.ok) {
62
+ const errText = await res.text();
63
+ throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
64
+ }
65
+ return res.json();
66
+ });
67
+ }
68
+
69
+ async function apiPut(url) {
70
+ return withRetry(`PUT ${url}`, async () => {
71
+ const res = await fetch(url, {
72
+ method: "PUT",
73
+ headers: { "Content-Type": "application/json" },
74
+ });
75
+ if (!res.ok) {
76
+ const errText = await res.text();
77
+ throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
78
+ }
79
+ return res.json();
80
+ });
81
+ }
82
+
83
+ async function apiGet(url) {
84
+ return withRetry(`GET ${url}`, async () => {
85
+ const res = await fetch(url);
86
+ if (!res.ok) {
87
+ const errText = await res.text();
88
+ throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
89
+ }
90
+ return res.json();
91
+ });
92
+ }
93
+
94
+ function isBrowserClosedError(err) {
95
+ if (!err) return false;
96
+ const msg = err.message || err.toString() || "";
97
+ return (
98
+ (msg.includes("Target") && msg.includes("closed")) ||
99
+ (msg.includes("Session") && msg.includes("deleted")) ||
100
+ (msg.includes("Session") && msg.includes("not found")) ||
101
+ (msg.includes("Browser") && msg.includes("closed")) ||
102
+ msg.includes("Execution context was destroyed") ||
103
+ msg.includes("Protocol error") ||
104
+ msg.includes("Cannot find context")
105
+ );
106
+ }
107
+
108
+ /**
109
+ * 自动模式:循环从服务端取视频任务,抓评论,提交
110
+ */
111
+ async function runAutoMode(options) {
112
+ const { serverUrl, parallel, interval, maxComments } = options;
113
+ const actualParallel = Math.max(1, parallel || 1);
114
+ const actualInterval = interval || 10;
115
+ const actualMaxComments = maxComments || 200;
116
+ let shuttingDown = false;
117
+
118
+ console.error(
119
+ `\n[Comments Auto] 并行: ${actualParallel}, 间隔: ${actualInterval}s, 评论数: ${actualMaxComments}`,
120
+ );
121
+ console.error(`服务器: ${serverUrl}`);
122
+ console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
123
+ console.error(`开始循环接收任务...\n`);
124
+
125
+ let browser;
126
+ let browserRestartCount = 0;
127
+ let processedCount = 0;
128
+ let skippedCount = 0;
129
+ let errorCount = 0;
130
+ let consecutiveErrors = 0;
131
+
132
+ const shutdown = async (signal) => {
133
+ if (shuttingDown) return;
134
+ shuttingDown = true;
135
+ console.error(`\n[Comments Auto] 收到 ${signal},正在关闭浏览器...`);
136
+ await browser?.close().catch(() => {});
137
+ console.error("[Comments Auto] 已退出");
138
+ process.exit(0);
139
+ };
140
+
141
+ const onSigint = () => {
142
+ void shutdown("SIGINT");
143
+ };
144
+ const onSigterm = () => {
145
+ void shutdown("SIGTERM");
146
+ };
147
+
148
+ process.once("SIGINT", onSigint);
149
+ process.once("SIGTERM", onSigterm);
150
+
151
+ async function ensureBrowser() {
152
+ if (browser) {
153
+ try {
154
+ await browser.contexts()[0]?.pages()[0]?.url();
155
+ return browser;
156
+ } catch {
157
+ try {
158
+ await browser.close();
159
+ } catch {}
160
+ browser = null;
161
+ }
162
+ }
163
+ browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
164
+ return browser;
165
+ }
166
+
167
+ async function getPage(browser) {
168
+ const contexts = browser.contexts();
169
+ if (contexts.length > 0) {
170
+ const pages = contexts[0].pages();
171
+ if (pages.length > 0) return pages[0];
172
+ }
173
+ return null;
174
+ }
175
+
176
+ try {
177
+ while (!shuttingDown) {
178
+ let page;
179
+ try {
180
+ browser = await ensureBrowser();
181
+ page = await getPage(browser);
182
+ if (!page) {
183
+ console.error("[Comments Auto] 未找到可用页面,等待中...");
184
+ await new Promise((r) => setTimeout(r, actualInterval * 1000));
185
+ continue;
186
+ }
187
+
188
+ // 获取任务
189
+ let tasks;
190
+ try {
191
+ const resp = await apiGet(
192
+ `${serverUrl}/api/comment-tasks?limit=${actualParallel}`,
193
+ );
194
+ tasks = resp.tasks || [];
195
+ } catch (err) {
196
+ console.error(`[Comments Auto] 获取任务失败: ${err.message}`);
197
+ consecutiveErrors++;
198
+ if (consecutiveErrors > 10) {
199
+ console.error("[Comments Auto] 连续获取失败超过10次,请检查服务端");
200
+ process.exit(1);
201
+ }
202
+ await new Promise((r) => setTimeout(r, actualInterval * 1000));
203
+ continue;
204
+ }
205
+ consecutiveErrors = 0;
206
+
207
+ if (tasks.length === 0) {
208
+ console.error(
209
+ `[Comments Auto] 暂无任务,${actualInterval}s 后重试...`,
210
+ );
211
+ await new Promise((r) => setTimeout(r, actualInterval * 1000));
212
+ continue;
213
+ }
214
+
215
+ console.error(`[Comments Auto] 获取 ${tasks.length} 个任务`);
216
+
217
+ for (const task of tasks) {
218
+ try {
219
+ const { id, href, locationCreated, ttSeller } = task;
220
+ const loc = normalizeLocation(locationCreated);
221
+
222
+ // 检查目标国家
223
+ if (loc && !isLocationInList(loc, DEFAULT_TARGET_LOCATIONS)) {
224
+ // 非目标国家,直接 commit 跳过
225
+ await apiPut(`${serverUrl}/api/comment-task/${id}`);
226
+ skippedCount++;
227
+ console.error(
228
+ ` [跳过] ${id.substring(0, 15)}... 国家: ${loc} (非目标)`,
229
+ );
230
+ continue;
231
+ }
232
+
233
+ console.error(
234
+ ` [处理] ${id.substring(0, 15)}... 国家: ${loc || "?"} ttSeller: ${ttSeller} -> ${href}`,
235
+ );
236
+
237
+ // 确保浏览器可用
238
+ browser = await ensureBrowser();
239
+ page = await getPage(browser);
240
+ if (!page) {
241
+ throw new Error("未找到可用页面");
242
+ }
243
+
244
+ // 导航到视频页
245
+ await page.goto(href, {
246
+ waitUntil: "domcontentloaded",
247
+ timeout: 30000,
248
+ });
249
+ const ready = await waitForPageReady(page, 30000);
250
+ if (!ready) {
251
+ throw new Error("页面加载超时");
252
+ }
253
+
254
+ // 关闭弹窗
255
+ await safeEvaluate(page, () => {
256
+ document
257
+ .querySelectorAll('[id*="modal-overlay"]')
258
+ .forEach((o) => o.click());
259
+ });
260
+ await page.keyboard.press("Escape");
261
+ await new Promise((r) => setTimeout(r, 1000));
262
+
263
+ // 如果评论面板已打开,先关闭
264
+ const panelOpen = await safeEvaluate(page, () => {
265
+ return !!document.querySelector('[class*="RightPanelContainer"]');
266
+ });
267
+ if (panelOpen) {
268
+ await closeCommentPanel(page);
269
+ await new Promise((r) => setTimeout(r, 800));
270
+ }
271
+
272
+ // 获取评论
273
+ const result = await fetchUserCommentsAPI(page, {
274
+ maxComments: actualMaxComments,
275
+ log: () => {},
276
+ });
277
+
278
+ // 关闭评论面板
279
+ await closeCommentPanel(page);
280
+
281
+ if (
282
+ result.error ||
283
+ !result.comments ||
284
+ result.comments.length === 0
285
+ ) {
286
+ console.error(` [无评论] ${result.error || "未获取到评论"}`);
287
+ } else {
288
+ // 提取去重作者
289
+ const authors = [
290
+ ...new Set(
291
+ result.comments
292
+ .map((c) => c.user?.unique_id)
293
+ .filter(Boolean)
294
+ .map((u) => u.replace(/^@/, "")),
295
+ ),
296
+ ];
297
+
298
+ if (authors.length > 0) {
299
+ // 提交作者
300
+ const saveResult = await apiPost(`${serverUrl}/api/users`, {
301
+ usernames: authors.map((a) => "@" + a),
302
+ sources: ["comment"],
303
+ guessedLocation: loc || null,
304
+ });
305
+ console.error(
306
+ ` [提交] ${saveResult.added} 新增, ${saveResult.skipped} 跳过`,
307
+ );
308
+ } else {
309
+ console.error(
310
+ ` [无作者] 获取了 ${result.comments.length} 条评论但无作者信息`,
311
+ );
312
+ }
313
+ }
314
+
315
+ // commit 任务
316
+ await apiPut(`${serverUrl}/api/comment-task/${id}`);
317
+ processedCount++;
318
+ console.error(` [完成] 累计 ${processedCount} 个视频`);
319
+ } catch (err) {
320
+ errorCount++;
321
+ const isBrowserClosed = isBrowserClosedError(err);
322
+ if (isBrowserClosed) {
323
+ console.error(` [浏览器异常] ${err.message},将重启浏览器`);
324
+ try {
325
+ await browser.close();
326
+ } catch {}
327
+ browser = null;
328
+ browserRestartCount++;
329
+ } else {
330
+ console.error(` [错误] ${err.message}`);
331
+ }
332
+ continue;
333
+ }
334
+ }
335
+ } catch (err) {
336
+ errorCount++;
337
+ const isBrowserClosed = isBrowserClosedError(err);
338
+ if (isBrowserClosed) {
339
+ console.error(
340
+ `[Comments Auto] 浏览器异常 (${++browserRestartCount}),正在重启...`,
341
+ );
342
+ try {
343
+ await browser.close();
344
+ } catch {}
345
+ browser = null;
346
+ } else {
347
+ console.error(`[Comments Auto] 异常: ${err.message}`);
348
+ }
349
+ }
350
+
351
+ // 等待间隔
352
+ await new Promise((r) => setTimeout(r, actualInterval * 1000));
353
+ }
354
+ } finally {
355
+ process.removeListener("SIGINT", onSigint);
356
+ process.removeListener("SIGTERM", onSigterm);
357
+ await browser?.close().catch(() => {});
358
+ }
359
+ }
360
+
361
+ export async function handleComments(options) {
362
+ const {
363
+ commentsUrl,
364
+ commentsMax,
365
+ commentsSave,
366
+ commentsParallel,
367
+ commentsInterval,
368
+ commentsServer,
369
+ } = options;
370
+
371
+ // 自动模式:无URL且传了 -s/--server 参数(或任何参数)
372
+ if (!commentsUrl && process.argv.length > 3) {
373
+ return runAutoMode({
374
+ serverUrl: commentsServer,
375
+ parallel: commentsParallel,
376
+ interval: commentsInterval,
377
+ maxComments: commentsMax || 200,
378
+ });
379
+ }
380
+
381
+ // 手动模式(需要 URL)
382
+ if (!commentsUrl) {
383
+ console.error("用法: tt-help comments <视频URL> [最大评论数] [--save]");
384
+ console.error(
385
+ " tt-help comments [-p N] [-i N] [-s server] [-m maxComments]",
386
+ );
387
+ console.error("");
388
+ console.error("手动模式: tt-help comments <URL> [N] [--save]");
389
+ console.error("自动模式: tt-help comments -p 1 -i 10 -m 200 -s <server>");
390
+ console.error("");
391
+ console.error(
392
+ "选项: --save 去重后保存到服务端,来源标记为 comment",
393
+ );
394
+ console.error(" -p, --parallel 并行数 (默认 1)");
395
+ console.error(" -i, --interval 空闲间隔秒 (默认 10)");
396
+ console.error(" -s, --server 服务端地址");
397
+ console.error(" -m, --max-comments 每视频最大评论数 (默认 200)");
398
+ process.exit(1);
399
+ }
400
+
401
+ if (commentsSave) {
402
+ // 手动模式 --save
403
+ let browser;
404
+ try {
405
+ browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
406
+ const contexts = browser.contexts();
407
+ let page;
408
+
409
+ if (contexts.length > 0) {
410
+ const pages = contexts[0].pages();
411
+ if (pages.length > 0) {
412
+ page = pages[0];
413
+ }
414
+ }
415
+
416
+ if (!page) {
417
+ console.error("未找到可用页面");
418
+ process.exit(1);
419
+ }
420
+
421
+ console.error(`正在打开: ${commentsUrl}`);
422
+ await page.goto(commentsUrl, {
423
+ waitUntil: "domcontentloaded",
424
+ timeout: 30000,
425
+ });
426
+ const ready = await waitForPageReady(page, 30000);
427
+ if (!ready) {
428
+ console.error("页面加载超时,tab 未出现");
429
+ process.exit(1);
430
+ }
431
+
432
+ await safeEvaluate(page, () => {
433
+ document
434
+ .querySelectorAll('[id*="modal-overlay"]')
435
+ .forEach((o) => o.click());
436
+ });
437
+ await page.keyboard.press("Escape");
438
+ await new Promise((r) => setTimeout(r, 1000));
439
+
440
+ const videoInfo = await safeEvaluate(page, () => {
441
+ const result = {};
442
+ const m = window.location.href.match(/@([^/]+)\/video/);
443
+ result.author = m ? "@" + m[1] : "未知";
444
+ const html = document.documentElement.outerHTML;
445
+ const locMatch = html.match(/"locationCreated":"([^"]*)/);
446
+ result.locationCreated = locMatch ? locMatch[1] : null;
447
+ return result;
448
+ });
449
+
450
+ const panelOpen = await safeEvaluate(page, () => {
451
+ return !!document.querySelector('[class*="RightPanelContainer"]');
452
+ });
453
+ if (panelOpen) {
454
+ await closeCommentPanel(page);
455
+ await new Promise((r) => setTimeout(r, 800));
456
+ }
457
+
458
+ const result = await fetchUserCommentsAPI(page, {
459
+ maxComments: commentsMax,
460
+ log: () => {},
461
+ });
462
+
463
+ await closeCommentPanel(page);
464
+
465
+ if (result.error || !result.comments || result.comments.length === 0) {
466
+ console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
467
+ process.exit(1);
468
+ }
469
+
470
+ const authors = [
471
+ ...new Set(
472
+ result.comments
473
+ .map((c) => c.user?.unique_id)
474
+ .filter(Boolean)
475
+ .map((u) => u.replace(/^@/, "")),
476
+ ),
477
+ ];
478
+
479
+ const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
480
+ const serverUrl = commentsServer || defaultServer;
481
+
482
+ if (
483
+ guessedLocation &&
484
+ !isLocationInList(guessedLocation, DEFAULT_TARGET_LOCATIONS)
485
+ ) {
486
+ console.error(`\n猜测国家: ${guessedLocation},非目标国家,跳过保存`);
487
+ console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
488
+ } else {
489
+ console.error(`\n正在提交 ${authors.length} 个评论作者到服务端...`);
490
+ console.error(`服务端: ${serverUrl}`);
491
+ if (guessedLocation) console.error(`猜测国家: ${guessedLocation}`);
492
+
493
+ try {
494
+ const saveResult = await apiPost(`${serverUrl}/api/users`, {
495
+ usernames: authors.map((a) => "@" + a),
496
+ sources: ["comment"],
497
+ guessedLocation: guessedLocation || null,
498
+ });
499
+ console.error(
500
+ `\n提交结果: ${saveResult.added} 个新增, ${saveResult.skipped} 个跳过`,
501
+ );
502
+ } catch (err) {
503
+ console.error(`\n提交失败: ${err.message}`);
504
+ process.exit(1);
505
+ }
506
+ }
507
+ } catch (err) {
508
+ console.error(`获取评论失败: ${err.message}`);
509
+ process.exit(1);
510
+ } finally {
511
+ if (browser) await browser.close().catch(() => {});
512
+ }
513
+ return;
514
+ }
515
+
516
+ // 手动模式:打印到控制台
517
+ let browser;
518
+ try {
519
+ browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
520
+ const contexts = browser.contexts();
521
+ let page;
522
+
523
+ if (contexts.length > 0) {
524
+ const pages = contexts[0].pages();
525
+ if (pages.length > 0) {
526
+ page = pages[0];
527
+ }
528
+ }
529
+
530
+ if (!page) {
531
+ console.error("未找到可用页面");
532
+ process.exit(1);
533
+ }
534
+
535
+ console.error(`正在打开: ${commentsUrl}`);
536
+ await page.goto(commentsUrl, {
537
+ waitUntil: "domcontentloaded",
538
+ timeout: 30000,
539
+ });
540
+ const ready = await waitForPageReady(page, 30000);
541
+ if (!ready) {
542
+ console.error("页面加载超时,tab 未出现");
543
+ process.exit(1);
544
+ }
545
+
546
+ await safeEvaluate(page, () => {
547
+ document
548
+ .querySelectorAll('[id*="modal-overlay"]')
549
+ .forEach((o) => o.click());
550
+ });
551
+ await page.keyboard.press("Escape");
552
+ await new Promise((r) => setTimeout(r, 1000));
553
+
554
+ const videoInfo = await safeEvaluate(page, () => {
555
+ const result = {};
556
+ const m = window.location.href.match(/@([^/]+)\/video/);
557
+ result.author = m ? "@" + m[1] : "未知";
558
+ const html = document.documentElement.outerHTML;
559
+ const locMatch = html.match(/"locationCreated":"([^"]*)/);
560
+ result.locationCreated = locMatch ? locMatch[1] : null;
561
+ return result;
562
+ });
563
+
564
+ const panelOpen = await safeEvaluate(page, () => {
565
+ return !!document.querySelector('[class*="RightPanelContainer"]');
566
+ });
567
+ if (panelOpen) {
568
+ await closeCommentPanel(page);
569
+ await new Promise((r) => setTimeout(r, 800));
570
+ }
571
+
572
+ const result = await fetchUserCommentsAPI(page, {
573
+ maxComments: commentsMax,
574
+ log: () => {},
575
+ });
576
+
577
+ await closeCommentPanel(page);
578
+
579
+ if (result.error || !result.comments || result.comments.length === 0) {
580
+ console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
581
+ process.exit(1);
582
+ }
583
+
584
+ const authors = [
585
+ ...new Set(
586
+ result.comments
587
+ .map((c) => c.user?.unique_id)
588
+ .filter(Boolean)
589
+ .map((u) => u.replace(/^@/, "")),
590
+ ),
591
+ ];
592
+
593
+ console.log(`视频: ${commentsUrl}`);
594
+ console.log(`作者: ${videoInfo?.author || "未知"}`);
595
+ if (videoInfo?.locationCreated)
596
+ console.log(`猜测国家: ${videoInfo.locationCreated}`);
597
+ console.log(`评论数: ${result.comments.length}`);
598
+ console.log(`评论作者: ${authors.length}`);
599
+ console.log("");
600
+
601
+ result.comments.forEach((c, i) => {
602
+ const author = c.user?.unique_id
603
+ ? "@" + c.user.unique_id
604
+ : c.user?.nickname || "未知";
605
+ const text = c.text || "";
606
+ const likes = c.digg_count || 0;
607
+ console.log(`${i + 1}. [${author}] ${text} (\u2764 ${likes})`);
608
+ });
609
+
610
+ console.log("");
611
+ console.log(
612
+ `共 ${result.comments.length} 条评论, ${authors.length} 个唯一作者`,
613
+ );
614
+ } catch (err) {
615
+ console.error(`获取评论失败: ${err.message}`);
616
+ process.exit(1);
617
+ } finally {
618
+ if (browser) await browser.close().catch(() => {});
619
+ }
620
+ }