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.
- package/README.md +33 -33
- package/cli.js +9 -9
- package/package.json +52 -52
- package/scripts/run-explore copy.bat +101 -101
- package/scripts/run-explore.bat +134 -134
- package/scripts/run-explore.ps1 +159 -159
- package/scripts/run-explore.sh +121 -121
- package/scripts/test-captcha-lib.mjs +68 -0
- package/scripts/test-captcha.mjs +81 -0
- package/scripts/test-incognito-lib.mjs +36 -0
- package/scripts/test-login-state.mjs +128 -0
- package/scripts/test-safe-click.mjs +45 -0
- package/scripts/test-watch-db-smoke.mjs +246 -0
- package/src/cli/attach.js +331 -331
- package/src/cli/auto.js +265 -265
- package/src/cli/comments.js +620 -620
- package/src/cli/config.js +170 -170
- package/src/cli/db-import.js +51 -51
- package/src/cli/explore.js +555 -555
- package/src/cli/open.js +109 -111
- package/src/cli/progress.js +111 -111
- package/src/cli/refresh.js +288 -288
- package/src/cli/scrape.js +47 -47
- package/src/cli/utils.js +18 -18
- package/src/cli/videos.js +41 -41
- package/src/cli/videostats.js +196 -196
- package/src/cli/watch.js +30 -30
- package/src/lib/api-interceptor.js +161 -161
- package/src/lib/args.js +809 -809
- package/src/lib/browser/anti-detect.js +23 -23
- package/src/lib/browser/cdp.js +261 -261
- package/src/lib/browser/health-checker.js +114 -114
- package/src/lib/browser/launch.js +43 -43
- package/src/lib/browser/page.js +184 -184
- package/src/lib/constants.js +297 -297
- package/src/lib/delay.js +54 -54
- package/src/lib/explore-fetch.js +118 -118
- package/src/lib/fetcher.js +45 -45
- package/src/lib/filter.js +66 -66
- package/src/lib/io.js +54 -54
- package/src/lib/output.js +80 -80
- package/src/lib/page-error-detector.js +109 -109
- package/src/lib/parse-ssr.mjs +69 -69
- package/src/lib/parser.js +47 -47
- package/src/lib/retry.js +45 -45
- package/src/lib/scrape.js +90 -90
- package/src/lib/target-locations.js +61 -61
- package/src/lib/tiktok-scraper.mjs +98 -61
- package/src/lib/url.js +52 -52
- package/src/main.js +73 -73
- package/src/npm-main.js +70 -70
- package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
- package/src/scraper/auto-core.js +203 -203
- package/src/scraper/core.js +255 -255
- package/src/scraper/explore-core.js +208 -208
- package/src/scraper/modules/captcha-handler.js +114 -114
- package/src/scraper/modules/follow-extractor.js +250 -250
- package/src/scraper/modules/guess-extractor.js +51 -51
- package/src/scraper/modules/page-helpers.js +48 -48
- package/src/scraper/refresh-core.js +213 -213
- package/src/videos/core.js +143 -143
- package/src/watch/data-store.js +2980 -2980
- package/src/watch/public/index.html +2355 -2355
- package/src/watch/server.js +727 -727
package/src/cli/comments.js
CHANGED
|
@@ -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
|
+
}
|