tt-help-cli-ycl 1.4.7 → 1.4.9
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/package.json +1 -1
- package/src/cli/comments.js +676 -407
- package/src/cli/tag.js +2 -2
- package/src/lib/args.js +2 -2
- package/src/lib/constants.js +2 -2
- package/src/lib/modal-handler.js +592 -0
package/src/cli/comments.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
// ────────────────────────────────────────
|
|
2
|
+
// comments.js — TikTok 评论采集 CLI
|
|
3
|
+
// 模式:
|
|
4
|
+
// 自动模式: tt-help comments [-p N] [-i N] [-s server] 从服务端轮询任务
|
|
5
|
+
// 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
|
|
6
|
+
// ────────────────────────────────────────
|
|
7
|
+
|
|
1
8
|
import { chromium } from "playwright";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
|
|
2
12
|
import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
|
|
3
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
closeCommentPanel,
|
|
15
|
+
isBrowserClosedError,
|
|
16
|
+
} from "../lib/browser/page.js";
|
|
4
17
|
import {
|
|
5
18
|
server as defaultServer,
|
|
6
19
|
proxy as configuredProxy,
|
|
@@ -11,19 +24,130 @@ import {
|
|
|
11
24
|
normalizeLocation,
|
|
12
25
|
} from "../lib/target-locations.js";
|
|
13
26
|
import { createApiClient } from "../lib/api-client.js";
|
|
14
|
-
import { ensureBrowserReady
|
|
27
|
+
import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
|
|
15
28
|
import { delay } from "../lib/delay.js";
|
|
29
|
+
import {
|
|
30
|
+
waitAndGetCaptcha,
|
|
31
|
+
closeCaptcha,
|
|
32
|
+
} from "../scraper/modules/captcha-handler.js";
|
|
33
|
+
import { detectModals, closeAllModals } from "../lib/modal-handler.js";
|
|
34
|
+
|
|
35
|
+
// ────────────────────────────────────────
|
|
36
|
+
// Section 1: 常量 & 配置默认值
|
|
37
|
+
// ────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const CFG = {
|
|
40
|
+
/** 默认 CDP 端口池起点 */
|
|
41
|
+
portPoolStart: 6222,
|
|
42
|
+
/** 端口池大小 */
|
|
43
|
+
portPoolSize: 10,
|
|
44
|
+
/** 每视频最大评论数 */
|
|
45
|
+
maxComments: 200,
|
|
46
|
+
/** 空闲间隔上限(秒) */
|
|
47
|
+
interval: 10,
|
|
48
|
+
/** 并行数 */
|
|
49
|
+
parallel: 1,
|
|
50
|
+
/** 连续 403 后切换端口 */
|
|
51
|
+
consecutive403Blocked: 3,
|
|
52
|
+
/** 连续验证码后切换端口 */
|
|
53
|
+
maxConsecutiveCaptcha: 3,
|
|
54
|
+
/** 内存回收 — 触发回收的 RSS(MB) 阈值 */
|
|
55
|
+
recycleRssMb: 900,
|
|
56
|
+
/** 内存回收 — 触发回收的 Heap(MB) 阈值 */
|
|
57
|
+
recycleHeapMb: 512,
|
|
58
|
+
/** 内存回收 — 触发回收的 Edge 进程(MB) 阈值 */
|
|
59
|
+
recycleEdgeMb: 2000,
|
|
60
|
+
/** 内存回收 — 两次回收最小间隔(ms) */
|
|
61
|
+
recycleCooldownMs: 180000,
|
|
62
|
+
/** 内存回收 — 每 N 个任务强制回收(0=禁用) */
|
|
63
|
+
periodicRecycleEvery: 0,
|
|
64
|
+
/** 内存回收 — 回收时是否切换端口 */
|
|
65
|
+
switchPortOnRecycle: true,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 从环境变量覆写配置 */
|
|
69
|
+
function loadEnvConfig() {
|
|
70
|
+
const int = (key, def) => {
|
|
71
|
+
const v = parseInt(process.env[key], 10);
|
|
72
|
+
return Number.isFinite(v) ? v : def;
|
|
73
|
+
};
|
|
74
|
+
CFG.portPoolStart = int("COMMENTS_PORT_POOL_START", CFG.portPoolStart);
|
|
75
|
+
CFG.portPoolSize = int("COMMENTS_PORT_POOL_SIZE", CFG.portPoolSize);
|
|
76
|
+
CFG.recycleRssMb = Math.max(
|
|
77
|
+
256,
|
|
78
|
+
int("COMMENTS_RECYCLE_RSS_MB", CFG.recycleRssMb),
|
|
79
|
+
);
|
|
80
|
+
CFG.recycleHeapMb = Math.max(
|
|
81
|
+
128,
|
|
82
|
+
int("COMMENTS_RECYCLE_HEAP_MB", CFG.recycleHeapMb),
|
|
83
|
+
);
|
|
84
|
+
CFG.recycleEdgeMb = Math.max(
|
|
85
|
+
500,
|
|
86
|
+
int("COMMENTS_RECYCLE_EDGE_MB", CFG.recycleEdgeMb),
|
|
87
|
+
);
|
|
88
|
+
CFG.recycleCooldownMs = Math.max(
|
|
89
|
+
0,
|
|
90
|
+
int("COMMENTS_RECYCLE_COOLDOWN_MS", CFG.recycleCooldownMs),
|
|
91
|
+
);
|
|
92
|
+
CFG.periodicRecycleEvery = Math.max(
|
|
93
|
+
0,
|
|
94
|
+
int("COMMENTS_PERIODIC_RECYCLE_EVERY", CFG.periodicRecycleEvery),
|
|
95
|
+
);
|
|
96
|
+
CFG.switchPortOnRecycle =
|
|
97
|
+
String(process.env.COMMENTS_SWITCH_PORT_ON_RECYCLE || "1") !== "0";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ────────────────────────────────────────
|
|
101
|
+
// Section 2: 内存监控工具
|
|
102
|
+
// ────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function formatMemoryUsage(mem = process.memoryUsage()) {
|
|
105
|
+
const mb = (n) => (n / 1024 / 1024).toFixed(0);
|
|
106
|
+
return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getBrowserMemory(port) {
|
|
110
|
+
try {
|
|
111
|
+
const platform = os.platform();
|
|
112
|
+
if (platform !== "darwin" && platform !== "linux") return null;
|
|
113
|
+
const procName = platform === "darwin" ? "Microsoft Edge" : "msedge";
|
|
114
|
+
const c = procName[0];
|
|
115
|
+
const rest = procName.slice(1);
|
|
116
|
+
let cmd;
|
|
117
|
+
if (port) {
|
|
118
|
+
cmd = `ps aux | grep "[${c}]${rest}" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
|
|
119
|
+
} else {
|
|
120
|
+
cmd = `ps aux | grep "[${c}]${rest}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
|
|
121
|
+
}
|
|
122
|
+
const mb = parseInt(execSync(cmd, { timeout: 5000 }).toString().trim(), 10);
|
|
123
|
+
return mb > 0 ? mb : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatMemoryWithBrowser(port) {
|
|
130
|
+
const nodeMem = formatMemoryUsage();
|
|
131
|
+
const edgeMB = getBrowserMemory(port);
|
|
132
|
+
if (!edgeMB) return nodeMem;
|
|
133
|
+
const label = port ? ` edge:${port}:${edgeMB}MB` : ` edge:${edgeMB}MB`;
|
|
134
|
+
return nodeMem + label;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ────────────────────────────────────────
|
|
138
|
+
// Section 3: 页面辅助
|
|
139
|
+
// ────────────────────────────────────────
|
|
16
140
|
|
|
17
141
|
async function waitForPageReady(page, timeout = 30000) {
|
|
18
|
-
const
|
|
19
|
-
while (Date.now()
|
|
142
|
+
const deadline = Date.now() + timeout;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
20
144
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
145
|
+
const ok = await page.evaluate(
|
|
146
|
+
() => document.querySelectorAll('[class*="tabbar-item"]').length > 0,
|
|
147
|
+
);
|
|
148
|
+
if (ok) return true;
|
|
25
149
|
} catch {
|
|
26
|
-
|
|
150
|
+
/* 页面可能正在导航 */
|
|
27
151
|
}
|
|
28
152
|
await new Promise((r) => setTimeout(r, 1000));
|
|
29
153
|
}
|
|
@@ -38,23 +162,252 @@ async function safeEvaluate(page, fn) {
|
|
|
38
162
|
}
|
|
39
163
|
}
|
|
40
164
|
|
|
41
|
-
function
|
|
42
|
-
if (!
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
165
|
+
function getPage(browser) {
|
|
166
|
+
if (!browser) return null;
|
|
167
|
+
try {
|
|
168
|
+
const ctx = browser.contexts()[0];
|
|
169
|
+
if (!ctx) return null;
|
|
170
|
+
const pages = ctx.pages();
|
|
171
|
+
return pages.length > 0 ? pages[0] : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ────────────────────────────────────────
|
|
178
|
+
// Section 4: CDP 会话管理(端口池/浏览器/页面/内存回收)
|
|
179
|
+
// ────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
class CdpSessionManager {
|
|
182
|
+
constructor({ portPoolStart, portPoolSize, proxy }) {
|
|
183
|
+
this.port = portPoolStart;
|
|
184
|
+
this.portPoolStart = portPoolStart;
|
|
185
|
+
this.portPoolSize = portPoolSize;
|
|
186
|
+
this.proxy = proxy;
|
|
187
|
+
this.browser = null;
|
|
188
|
+
this.page = null;
|
|
189
|
+
this.lastRecycleAt = 0;
|
|
190
|
+
this._blockedPages = new WeakSet();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pickNextPort() {
|
|
194
|
+
const candidates = [];
|
|
195
|
+
for (let i = 0; i < this.portPoolSize; i++) {
|
|
196
|
+
const p = this.portPoolStart + i;
|
|
197
|
+
if (p !== this.port) candidates.push(p);
|
|
198
|
+
}
|
|
199
|
+
if (candidates.length === 0) return this.port;
|
|
200
|
+
return candidates[Math.floor(Math.random() * candidates.length)];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 启动:连接浏览器 + 获取页面 */
|
|
204
|
+
async init() {
|
|
205
|
+
const opts = { port: this.port };
|
|
206
|
+
if (this.proxy) opts.proxyServer = this.proxy;
|
|
207
|
+
this.browser = await ensureBrowserReady(opts);
|
|
208
|
+
this.page = await this._ensurePage();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 获取或恢复 page */
|
|
212
|
+
async _ensurePage() {
|
|
213
|
+
let p = getPage(this.browser);
|
|
214
|
+
if (p) await this._setupBlocking(p);
|
|
215
|
+
return p;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async ensurePage() {
|
|
219
|
+
if (!this.page || this.page.isClosed()) {
|
|
220
|
+
this.page = await this._ensurePage();
|
|
221
|
+
}
|
|
222
|
+
return this.page;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 页面请求拦截:屏蔽图片/样式减少内存 */
|
|
226
|
+
async _setupBlocking(page) {
|
|
227
|
+
if (!page || this._blockedPages.has(page)) return;
|
|
228
|
+
await page.route("**/*", (route) => {
|
|
229
|
+
const t = route.request().resourceType();
|
|
230
|
+
if (t === "image" || t === "stylesheet") route.abort();
|
|
231
|
+
else route.continue();
|
|
232
|
+
});
|
|
233
|
+
this._blockedPages.add(page);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 轻量回收:清空当前 tab */
|
|
237
|
+
async recyclePage() {
|
|
238
|
+
if (!this.page || this.page.isClosed()) {
|
|
239
|
+
this.page = await this._ensurePage();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await this.page.goto("about:blank", {
|
|
244
|
+
waitUntil: "domcontentloaded",
|
|
245
|
+
timeout: 5000,
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
await this.page.close().catch(() => {});
|
|
249
|
+
this.page = await this._ensurePage();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 深度回收:关闭浏览器,可选切换端口 */
|
|
254
|
+
async recycleSession(reason, { switchPort = true } = {}) {
|
|
255
|
+
const oldPort = this.port;
|
|
256
|
+
const doSwitch = switchPort && CFG.switchPortOnRecycle;
|
|
257
|
+
if (doSwitch) this.port = this.pickNextPort();
|
|
258
|
+
const hint =
|
|
259
|
+
oldPort === this.port
|
|
260
|
+
? `port=${this.port}`
|
|
261
|
+
: `port ${oldPort}→${this.port}`;
|
|
262
|
+
console.error(` ♻️ 重建 CDP 会话 (${reason}; ${hint})...`);
|
|
263
|
+
|
|
264
|
+
if (this.page) {
|
|
265
|
+
await this.page.close().catch(() => {});
|
|
266
|
+
this.page = null;
|
|
267
|
+
}
|
|
268
|
+
await this.browser?.close().catch(() => {});
|
|
269
|
+
|
|
270
|
+
if (oldPort !== this.port) {
|
|
271
|
+
await killEdgeProcesses(null, oldPort).catch(() => {});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const opts = { port: this.port };
|
|
275
|
+
if (this.proxy) opts.proxyServer = this.proxy;
|
|
276
|
+
this.browser = await ensureBrowserReady(opts);
|
|
277
|
+
this.page = await this._ensurePage();
|
|
278
|
+
this.lastRecycleAt = Date.now();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 内存检查 + 按需回收 */
|
|
282
|
+
async maybeRecycleForMemory(processedCount) {
|
|
283
|
+
const mem = process.memoryUsage();
|
|
284
|
+
const rssMB = mem.rss / 1024 / 1024;
|
|
285
|
+
const heapMB = mem.heapUsed / 1024 / 1024;
|
|
286
|
+
const edgeMB = getBrowserMemory(this.port);
|
|
287
|
+
|
|
288
|
+
const nodeOver = rssMB >= CFG.recycleRssMb || heapMB >= CFG.recycleHeapMb;
|
|
289
|
+
const edgeOver = edgeMB !== null && edgeMB >= CFG.recycleEdgeMb;
|
|
290
|
+
|
|
291
|
+
if (nodeOver || edgeOver) {
|
|
292
|
+
if (
|
|
293
|
+
CFG.recycleCooldownMs > 0 &&
|
|
294
|
+
Date.now() - this.lastRecycleAt < CFG.recycleCooldownMs
|
|
295
|
+
)
|
|
296
|
+
return;
|
|
297
|
+
|
|
298
|
+
const parts = [];
|
|
299
|
+
if (rssMB >= CFG.recycleRssMb)
|
|
300
|
+
parts.push(`rss=${rssMB.toFixed(0)}≥${CFG.recycleRssMb}`);
|
|
301
|
+
if (heapMB >= CFG.recycleHeapMb)
|
|
302
|
+
parts.push(`heap=${heapMB.toFixed(0)}≥${CFG.recycleHeapMb}`);
|
|
303
|
+
if (edgeOver)
|
|
304
|
+
parts.push(`edge:${this.port}=${edgeMB}≥${CFG.recycleEdgeMb}`);
|
|
305
|
+
|
|
306
|
+
await this.recycleSession(`mem ${parts.join("+")}`, {
|
|
307
|
+
switchPort: edgeOver,
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (
|
|
313
|
+
CFG.periodicRecycleEvery > 0 &&
|
|
314
|
+
processedCount > 0 &&
|
|
315
|
+
processedCount % CFG.periodicRecycleEvery === 0
|
|
316
|
+
) {
|
|
317
|
+
await this.recycleSession(`periodic #${processedCount}`, {
|
|
318
|
+
switchPort: false,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 重启浏览器(不切换端口,用于浏览器异常断开) */
|
|
324
|
+
async restartBrowser() {
|
|
325
|
+
try {
|
|
326
|
+
await this.browser?.close();
|
|
327
|
+
} catch {}
|
|
328
|
+
this.browser = null;
|
|
329
|
+
this.page = null;
|
|
330
|
+
const opts = { port: this.port };
|
|
331
|
+
if (this.proxy) opts.proxyServer = this.proxy;
|
|
332
|
+
this.browser = await ensureBrowserReady(opts);
|
|
333
|
+
this.page = await this._ensurePage();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async shutdown() {
|
|
337
|
+
if (this.page) await this.page.close().catch(() => {});
|
|
338
|
+
await this.browser?.close().catch(() => {});
|
|
339
|
+
killEdgeProcesses(null, this.port);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ────────────────────────────────────────
|
|
344
|
+
// Section 5: 共享操作(视频页准备 & 评论提取 & 保存)
|
|
345
|
+
// ────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async function prepareVideoPage(page, href) {
|
|
348
|
+
await page.goto(href, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
349
|
+
const ready = await waitForPageReady(page, 30000);
|
|
350
|
+
if (!ready) throw new Error("页面加载超时");
|
|
351
|
+
|
|
352
|
+
// 关闭弹窗
|
|
353
|
+
await safeEvaluate(page, () => {
|
|
354
|
+
document
|
|
355
|
+
.querySelectorAll('[id*="modal-overlay"]')
|
|
356
|
+
.forEach((o) => o.click());
|
|
357
|
+
});
|
|
358
|
+
await page.keyboard.press("Escape");
|
|
359
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
360
|
+
|
|
361
|
+
// 如果评论面板已打开,先关闭
|
|
362
|
+
const panelOpen = await safeEvaluate(
|
|
363
|
+
page,
|
|
364
|
+
() => !!document.querySelector('[class*="RightPanelContainer"]'),
|
|
52
365
|
);
|
|
366
|
+
if (panelOpen) {
|
|
367
|
+
await closeCommentPanel(page);
|
|
368
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
369
|
+
}
|
|
53
370
|
}
|
|
54
371
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
372
|
+
function extractVideoInfo() {
|
|
373
|
+
const result = {};
|
|
374
|
+
const m = window.location.href.match(/@([^/]+)\/video/);
|
|
375
|
+
result.author = m ? "@" + m[1] : "未知";
|
|
376
|
+
const html = document.documentElement.outerHTML;
|
|
377
|
+
const locMatch = html.match(/"locationCreated":"([^"]*)/);
|
|
378
|
+
result.locationCreated = locMatch ? locMatch[1] : null;
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function extractUniqueAuthors(comments) {
|
|
383
|
+
return [
|
|
384
|
+
...new Set(
|
|
385
|
+
comments
|
|
386
|
+
.map((c) => c.user?.unique_id)
|
|
387
|
+
.filter(Boolean)
|
|
388
|
+
.map((u) => u.replace(/^@/, "")),
|
|
389
|
+
),
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function saveAuthorsToServer(serverUrl, authors, guessedLocation) {
|
|
394
|
+
const { apiPost } = createApiClient({
|
|
395
|
+
checkStatus: true,
|
|
396
|
+
maxRetries: 2,
|
|
397
|
+
backoff: 2000,
|
|
398
|
+
log: true,
|
|
399
|
+
});
|
|
400
|
+
return apiPost(`${serverUrl}/api/users`, {
|
|
401
|
+
usernames: authors.map((a) => "@" + a),
|
|
402
|
+
sources: ["comment"],
|
|
403
|
+
guessedLocation: guessedLocation || null,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ────────────────────────────────────────
|
|
408
|
+
// Section 6: 自动模式 — 循环从服务端取任务、抓评论、提交
|
|
409
|
+
// ────────────────────────────────────────
|
|
410
|
+
|
|
58
411
|
async function runAutoMode(options) {
|
|
59
412
|
const {
|
|
60
413
|
serverUrl,
|
|
@@ -64,41 +417,48 @@ async function runAutoMode(options) {
|
|
|
64
417
|
basePort,
|
|
65
418
|
proxy: proxyOption,
|
|
66
419
|
} = options;
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
420
|
+
|
|
421
|
+
const actualParallel = Math.max(1, parallel || CFG.parallel);
|
|
422
|
+
const actualInterval = interval || CFG.interval;
|
|
423
|
+
const actualMaxComments = maxComments || CFG.maxComments;
|
|
71
424
|
const effectiveProxy = proxyOption || configuredProxy;
|
|
72
|
-
|
|
425
|
+
const portPoolStart = basePort || CFG.portPoolStart;
|
|
73
426
|
|
|
74
|
-
|
|
427
|
+
loadEnvConfig();
|
|
428
|
+
|
|
429
|
+
const { apiGet, apiPut } = createApiClient({
|
|
75
430
|
checkStatus: true,
|
|
76
431
|
maxRetries: 2,
|
|
77
432
|
backoff: 2000,
|
|
78
433
|
log: true,
|
|
79
|
-
meta: { port: actualBasePort },
|
|
80
434
|
});
|
|
81
435
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
436
|
+
// ── 状态 ──
|
|
437
|
+
let shuttingDown = false;
|
|
438
|
+
let processedCount = 0,
|
|
439
|
+
skippedCount = 0,
|
|
440
|
+
errorCount = 0,
|
|
441
|
+
browserRestartCount = 0;
|
|
442
|
+
let consecutiveErrors = 0,
|
|
443
|
+
consecutive403 = 0,
|
|
444
|
+
consecutiveCaptchaCount = 0;
|
|
445
|
+
|
|
446
|
+
// ── CDP 会话 ──
|
|
447
|
+
const session = new CdpSessionManager({
|
|
448
|
+
portPoolStart,
|
|
449
|
+
portPoolSize: CFG.portPoolSize,
|
|
450
|
+
proxy: effectiveProxy,
|
|
451
|
+
});
|
|
95
452
|
|
|
453
|
+
// ── 生命周期 ──
|
|
96
454
|
const shutdown = async (signal) => {
|
|
97
455
|
if (shuttingDown) return;
|
|
98
456
|
shuttingDown = true;
|
|
99
|
-
console.error(
|
|
100
|
-
|
|
101
|
-
|
|
457
|
+
console.error(
|
|
458
|
+
`\n── ${signal} ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount} 重启=${browserRestartCount}`,
|
|
459
|
+
);
|
|
460
|
+
await session.shutdown();
|
|
461
|
+
console.error("已退出");
|
|
102
462
|
process.exit(0);
|
|
103
463
|
};
|
|
104
464
|
|
|
@@ -108,50 +468,51 @@ async function runAutoMode(options) {
|
|
|
108
468
|
const onSigterm = () => {
|
|
109
469
|
void shutdown("SIGTERM");
|
|
110
470
|
};
|
|
111
|
-
|
|
112
471
|
process.once("SIGINT", onSigint);
|
|
113
472
|
process.once("SIGTERM", onSigterm);
|
|
114
473
|
|
|
115
|
-
|
|
116
|
-
if (browser) {
|
|
117
|
-
try {
|
|
118
|
-
await browser.contexts()[0]?.pages()[0]?.url();
|
|
119
|
-
return browser;
|
|
120
|
-
} catch {
|
|
121
|
-
try {
|
|
122
|
-
await browser.close();
|
|
123
|
-
} catch {}
|
|
124
|
-
browser = null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
const cdpOpts = { port: actualBasePort };
|
|
128
|
-
if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
|
|
129
|
-
browser = await ensureBrowserReadyCDP(cdpOpts);
|
|
130
|
-
return browser;
|
|
131
|
-
}
|
|
474
|
+
const totalTasks = () => processedCount + skippedCount + errorCount;
|
|
132
475
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
476
|
+
// ── 启动日志 ──
|
|
477
|
+
console.error(`\n╔══════════════════════════════════════════╗`);
|
|
478
|
+
console.error(`║ 自动评论采集模式(CDP 连接已登录浏览器)`);
|
|
479
|
+
console.error(
|
|
480
|
+
`║ 目标国家: ${DEFAULT_TARGET_LOCATIONS.slice(0, 6).join(",")}...`,
|
|
481
|
+
);
|
|
482
|
+
console.error(`║ 服务端: ${serverUrl}`);
|
|
483
|
+
console.error(
|
|
484
|
+
`║ CDP 端口池: ${portPoolStart}-${portPoolStart + CFG.portPoolSize - 1}(当前: ${session.port})`,
|
|
485
|
+
);
|
|
486
|
+
console.error(`║ 并行: ${actualParallel}, 间隔: ${actualInterval}s`);
|
|
487
|
+
console.error(`║ 每视频最大评论: ${actualMaxComments}`);
|
|
488
|
+
if (effectiveProxy) console.error(`║ 代理: ${effectiveProxy}`);
|
|
489
|
+
console.error(`╚══════════════════════════════════════════╝`);
|
|
490
|
+
console.error(`📊 启动内存: ${formatMemoryWithBrowser(session.port)}`);
|
|
491
|
+
console.error("开始循环接收任务...\n");
|
|
492
|
+
|
|
493
|
+
// ── 启动浏览器 ──
|
|
494
|
+
try {
|
|
495
|
+
await session.init();
|
|
496
|
+
} catch (err) {
|
|
497
|
+
console.error(`× 浏览器启动失败: ${err.message}`);
|
|
498
|
+
process.exit(1);
|
|
140
499
|
}
|
|
141
500
|
|
|
501
|
+
// ════════════════════════════════════════
|
|
502
|
+
// 主循环
|
|
503
|
+
// ════════════════════════════════════════
|
|
142
504
|
try {
|
|
143
505
|
while (!shuttingDown) {
|
|
144
|
-
let page;
|
|
145
506
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!page) {
|
|
149
|
-
console.error("
|
|
150
|
-
await
|
|
507
|
+
// 确保页面可用
|
|
508
|
+
await session.ensurePage();
|
|
509
|
+
if (!session.page) {
|
|
510
|
+
console.error(" ⚠ 无可用页面 → 重建会话");
|
|
511
|
+
await session.recycleSession("page missing", { switchPort: false });
|
|
151
512
|
continue;
|
|
152
513
|
}
|
|
153
514
|
|
|
154
|
-
//
|
|
515
|
+
// ── 领取任务 ──
|
|
155
516
|
let tasks;
|
|
156
517
|
try {
|
|
157
518
|
const resp = await apiGet(
|
|
@@ -159,11 +520,11 @@ async function runAutoMode(options) {
|
|
|
159
520
|
);
|
|
160
521
|
tasks = resp.tasks || [];
|
|
161
522
|
} catch (err) {
|
|
162
|
-
console.error(`
|
|
523
|
+
console.error(` × 获取任务失败: ${err.message}`);
|
|
163
524
|
consecutiveErrors++;
|
|
164
525
|
if (consecutiveErrors > 10) {
|
|
165
|
-
console.error("
|
|
166
|
-
|
|
526
|
+
console.error(" × 连续获取失败 >10 → 退出");
|
|
527
|
+
await shutdown("too many errors");
|
|
167
528
|
}
|
|
168
529
|
await delay(1000, actualInterval * 1000);
|
|
169
530
|
continue;
|
|
@@ -171,295 +532,250 @@ async function runAutoMode(options) {
|
|
|
171
532
|
consecutiveErrors = 0;
|
|
172
533
|
|
|
173
534
|
if (tasks.length === 0) {
|
|
174
|
-
console.error(
|
|
175
|
-
`[Comments Auto] 暂无任务,${actualInterval}s 内随机等待后重试...`,
|
|
176
|
-
);
|
|
535
|
+
console.error(` … 无任务,${actualInterval}s 内重试`);
|
|
177
536
|
await delay(1000, actualInterval * 1000);
|
|
178
537
|
continue;
|
|
179
538
|
}
|
|
180
539
|
|
|
181
|
-
|
|
182
|
-
|
|
540
|
+
// ── 逐个处理任务 ──
|
|
183
541
|
for (const task of tasks) {
|
|
542
|
+
if (shuttingDown) break;
|
|
543
|
+
|
|
184
544
|
try {
|
|
185
|
-
const { id, href, locationCreated
|
|
545
|
+
const { id, href, locationCreated } = task;
|
|
186
546
|
const loc = normalizeLocation(locationCreated);
|
|
547
|
+
const taskNum = totalTasks() + 1;
|
|
187
548
|
|
|
188
|
-
//
|
|
549
|
+
// 非目标国家 → 跳过
|
|
189
550
|
if (loc && !isLocationInList(loc, DEFAULT_TARGET_LOCATIONS)) {
|
|
190
|
-
// 非目标国家,直接 commit 跳过
|
|
191
551
|
await apiPut(`${serverUrl}/api/comment-task/${id}`);
|
|
192
552
|
skippedCount++;
|
|
193
553
|
console.error(
|
|
194
|
-
|
|
554
|
+
`【${taskNum}】${href.substring(0, 60)}… ${loc} (非目标)`,
|
|
195
555
|
);
|
|
196
556
|
continue;
|
|
197
557
|
}
|
|
198
558
|
|
|
199
|
-
console.error(
|
|
200
|
-
` [处理] ${id.substring(0, 15)}... 国家: ${loc || "?"} ttSeller: ${ttSeller} -> ${href}`,
|
|
201
|
-
);
|
|
559
|
+
console.error(`【${taskNum}】${href.substring(0, 60)}…`);
|
|
202
560
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
page
|
|
206
|
-
if (!page) {
|
|
207
|
-
throw new Error("未找到可用页面");
|
|
208
|
-
}
|
|
561
|
+
// 确保页面可用
|
|
562
|
+
await session.ensurePage();
|
|
563
|
+
if (!session.page) throw new Error("未找到可用页面");
|
|
209
564
|
|
|
210
565
|
// 导航到视频页
|
|
211
|
-
await page
|
|
212
|
-
waitUntil: "domcontentloaded",
|
|
213
|
-
timeout: 30000,
|
|
214
|
-
});
|
|
215
|
-
const ready = await waitForPageReady(page, 30000);
|
|
216
|
-
if (!ready) {
|
|
217
|
-
throw new Error("页面加载超时");
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 关闭弹窗
|
|
221
|
-
await safeEvaluate(page, () => {
|
|
222
|
-
document
|
|
223
|
-
.querySelectorAll('[id*="modal-overlay"]')
|
|
224
|
-
.forEach((o) => o.click());
|
|
225
|
-
});
|
|
226
|
-
await page.keyboard.press("Escape");
|
|
227
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
228
|
-
|
|
229
|
-
// 如果评论面板已打开,先关闭
|
|
230
|
-
const panelOpen = await safeEvaluate(page, () => {
|
|
231
|
-
return !!document.querySelector('[class*="RightPanelContainer"]');
|
|
232
|
-
});
|
|
233
|
-
if (panelOpen) {
|
|
234
|
-
await closeCommentPanel(page);
|
|
235
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
236
|
-
}
|
|
566
|
+
await prepareVideoPage(session.page, href);
|
|
237
567
|
|
|
238
568
|
// 获取评论
|
|
239
|
-
const result = await fetchUserCommentsAPI(page, {
|
|
569
|
+
const result = await fetchUserCommentsAPI(session.page, {
|
|
240
570
|
maxComments: actualMaxComments,
|
|
241
571
|
log: () => {},
|
|
242
572
|
});
|
|
243
573
|
|
|
244
574
|
// 关闭评论面板
|
|
245
|
-
await closeCommentPanel(page);
|
|
575
|
+
await closeCommentPanel(session.page);
|
|
576
|
+
|
|
577
|
+
// ── 处理结果 ──
|
|
578
|
+
let resultNote = "";
|
|
246
579
|
|
|
247
580
|
if (
|
|
248
581
|
result.error ||
|
|
249
582
|
!result.comments ||
|
|
250
583
|
result.comments.length === 0
|
|
251
584
|
) {
|
|
252
|
-
|
|
253
|
-
} else {
|
|
254
|
-
// 提取去重作者
|
|
255
|
-
const authors = [
|
|
256
|
-
...new Set(
|
|
257
|
-
result.comments
|
|
258
|
-
.map((c) => c.user?.unique_id)
|
|
259
|
-
.filter(Boolean)
|
|
260
|
-
.map((u) => u.replace(/^@/, "")),
|
|
261
|
-
),
|
|
262
|
-
];
|
|
585
|
+
resultNote = result.error || "无评论";
|
|
263
586
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
});
|
|
271
|
-
console.error(
|
|
272
|
-
` [提交] ${saveResult.added} 新增, ${saveResult.skipped} 跳过`,
|
|
587
|
+
// ── 先检测并关闭登录弹窗(避免被误判为验证码导致长时间等待)──
|
|
588
|
+
try {
|
|
589
|
+
const modalsBefore = await detectModals(session.page);
|
|
590
|
+
const loginModal = modalsBefore.find(
|
|
591
|
+
(m) =>
|
|
592
|
+
m.type === "login-modal" || m.type === "modal-container",
|
|
273
593
|
);
|
|
594
|
+
if (loginModal) {
|
|
595
|
+
console.error(" 🔔 检测到登录弹窗,正在关闭...");
|
|
596
|
+
const { closed, failed } = await closeAllModals(
|
|
597
|
+
session.page,
|
|
598
|
+
{ waitMs: 300 },
|
|
599
|
+
);
|
|
600
|
+
console.error(
|
|
601
|
+
` 🔔 弹窗关闭: closed=${JSON.stringify(closed)} failed=${JSON.stringify(failed)}`,
|
|
602
|
+
);
|
|
603
|
+
resultNote = resultNote + " (已关闭登录弹窗)";
|
|
604
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
605
|
+
}
|
|
606
|
+
} catch (modalErr) {
|
|
607
|
+
console.error(` ⚠️ 弹窗检测异常: ${modalErr.message}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
await new Promise((r) =>
|
|
611
|
+
setTimeout(r, 3000 + Math.random() * 2000),
|
|
612
|
+
);
|
|
613
|
+
const captchaResult = await waitAndGetCaptcha(session.page, {
|
|
614
|
+
waitMs: 180000,
|
|
615
|
+
pollInterval: 5000,
|
|
616
|
+
log: () => {},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
if (captchaResult.detected) {
|
|
620
|
+
if (captchaResult.resolved) {
|
|
621
|
+
resultNote = "验证码已解决";
|
|
622
|
+
consecutiveCaptchaCount = 0;
|
|
623
|
+
} else {
|
|
624
|
+
consecutiveCaptchaCount++;
|
|
625
|
+
resultNote = `验证码未解决(${consecutiveCaptchaCount}/${CFG.maxConsecutiveCaptcha})`;
|
|
626
|
+
await closeCaptcha(session.page).catch(() => {});
|
|
627
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (consecutiveCaptchaCount >= CFG.maxConsecutiveCaptcha) {
|
|
631
|
+
console.error(
|
|
632
|
+
` ♻️ 连续${CFG.maxConsecutiveCaptcha}次验证码 → 切换端口`,
|
|
633
|
+
);
|
|
634
|
+
await session.recycleSession("连续验证码", {
|
|
635
|
+
switchPort: true,
|
|
636
|
+
});
|
|
637
|
+
consecutiveCaptchaCount = 0;
|
|
638
|
+
}
|
|
274
639
|
} else {
|
|
275
|
-
|
|
276
|
-
|
|
640
|
+
consecutiveCaptchaCount = 0;
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
consecutiveCaptchaCount = 0;
|
|
644
|
+
const authors = extractUniqueAuthors(result.comments);
|
|
645
|
+
const discovered = authors.length;
|
|
646
|
+
let added = 0,
|
|
647
|
+
skipped = 0;
|
|
648
|
+
|
|
649
|
+
if (authors.length > 0) {
|
|
650
|
+
const saveResult = await saveAuthorsToServer(
|
|
651
|
+
serverUrl,
|
|
652
|
+
authors,
|
|
653
|
+
loc,
|
|
277
654
|
);
|
|
655
|
+
added = saveResult.added;
|
|
656
|
+
skipped = saveResult.skipped;
|
|
278
657
|
}
|
|
658
|
+
|
|
659
|
+
console.error(
|
|
660
|
+
` 发现:${discovered}个,新增:${added}个,跳过:${skipped}个`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (resultNote) {
|
|
665
|
+
console.error(` ${resultNote}`);
|
|
279
666
|
}
|
|
280
667
|
|
|
281
668
|
// commit 任务
|
|
282
669
|
await apiPut(`${serverUrl}/api/comment-task/${id}`);
|
|
283
670
|
processedCount++;
|
|
284
|
-
|
|
671
|
+
consecutive403 = 0;
|
|
672
|
+
|
|
673
|
+
// 每 10 个任务输出内存汇总
|
|
674
|
+
if (totalTasks() % 10 === 0) {
|
|
675
|
+
console.error(
|
|
676
|
+
`── #${totalTasks()} ── ${formatMemoryWithBrowser(session.port)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
285
679
|
} catch (err) {
|
|
286
680
|
errorCount++;
|
|
287
681
|
const isBrowserClosed = isBrowserClosedError(err);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
682
|
+
const is403 =
|
|
683
|
+
err.message &&
|
|
684
|
+
(err.message.includes("403") ||
|
|
685
|
+
err.message.includes("status 403") ||
|
|
686
|
+
err.message.includes("status code 403"));
|
|
687
|
+
|
|
688
|
+
if (is403) {
|
|
689
|
+
consecutive403++;
|
|
690
|
+
console.error(
|
|
691
|
+
` 403 (${consecutive403}/${CFG.consecutive403Blocked})`,
|
|
692
|
+
);
|
|
693
|
+
if (consecutive403 >= CFG.consecutive403Blocked) {
|
|
694
|
+
console.error(" 🚫 连续403 → 等2min切换端口");
|
|
695
|
+
await new Promise((r) => setTimeout(r, 120000));
|
|
696
|
+
await session.recycleSession("连续 403 封禁");
|
|
697
|
+
consecutive403 = 0;
|
|
698
|
+
}
|
|
699
|
+
} else if (isBrowserClosed) {
|
|
700
|
+
console.error(" 浏览器断开 → 重启");
|
|
294
701
|
browserRestartCount++;
|
|
702
|
+
await session.restartBrowser();
|
|
295
703
|
} else {
|
|
296
|
-
console.error(`
|
|
704
|
+
console.error(` ${err.message}`);
|
|
297
705
|
}
|
|
298
|
-
continue;
|
|
299
706
|
}
|
|
707
|
+
|
|
708
|
+
// 任务间内存检查
|
|
709
|
+
await session.maybeRecycleForMemory(processedCount);
|
|
300
710
|
}
|
|
301
711
|
} catch (err) {
|
|
302
712
|
errorCount++;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
console.error(
|
|
306
|
-
|
|
307
|
-
);
|
|
308
|
-
try {
|
|
309
|
-
await browser.close();
|
|
310
|
-
} catch {}
|
|
311
|
-
browser = null;
|
|
713
|
+
if (isBrowserClosedError(err)) {
|
|
714
|
+
browserRestartCount++;
|
|
715
|
+
console.error(` ⚠ 浏览器断开 (#${browserRestartCount}) → 重启`);
|
|
716
|
+
await session.restartBrowser();
|
|
312
717
|
} else {
|
|
313
|
-
console.error(`
|
|
718
|
+
console.error(` × ${err.message}`);
|
|
314
719
|
}
|
|
315
720
|
}
|
|
316
721
|
|
|
317
|
-
//
|
|
722
|
+
// 轮间内存检查 + 等待
|
|
723
|
+
await session.maybeRecycleForMemory(processedCount);
|
|
318
724
|
await delay(1000, actualInterval * 1000);
|
|
319
725
|
}
|
|
320
726
|
} finally {
|
|
321
727
|
process.removeListener("SIGINT", onSigint);
|
|
322
728
|
process.removeListener("SIGTERM", onSigterm);
|
|
323
|
-
await browser?.close().catch(() => {});
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export async function handleComments(options) {
|
|
328
|
-
const {
|
|
329
|
-
commentsUrl,
|
|
330
|
-
commentsMax,
|
|
331
|
-
commentsSave,
|
|
332
|
-
commentsParallel,
|
|
333
|
-
commentsInterval,
|
|
334
|
-
commentsServer,
|
|
335
|
-
commentsBasePort,
|
|
336
|
-
commentsProxy,
|
|
337
|
-
} = options;
|
|
338
|
-
|
|
339
|
-
const effectiveProxy = commentsProxy || configuredProxy;
|
|
340
|
-
|
|
341
|
-
// 自动模式:无URL就进入自动模式
|
|
342
|
-
if (!commentsUrl) {
|
|
343
|
-
return runAutoMode({
|
|
344
|
-
serverUrl: commentsServer || defaultServer,
|
|
345
|
-
parallel: commentsParallel,
|
|
346
|
-
interval: commentsInterval,
|
|
347
|
-
maxComments: commentsMax || 200,
|
|
348
|
-
basePort: commentsBasePort || 9222,
|
|
349
|
-
proxy: effectiveProxy,
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// 手动模式(有 URL)
|
|
354
|
-
if (commentsUrl) {
|
|
355
|
-
console.error("用法: tt-help comments <视频URL> [最大评论数] [--save]");
|
|
356
|
-
console.error(
|
|
357
|
-
" tt-help comments [-p N] [-i N] [-s server] [-m maxComments]",
|
|
358
|
-
);
|
|
359
|
-
console.error("");
|
|
360
|
-
console.error("手动模式: tt-help comments <URL> [N] [--save]");
|
|
361
|
-
console.error("自动模式: tt-help comments (全部默认值)");
|
|
362
|
-
console.error("");
|
|
363
729
|
console.error(
|
|
364
|
-
|
|
730
|
+
`\n── 统计 ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount} 重启=${browserRestartCount}`,
|
|
365
731
|
);
|
|
366
|
-
|
|
367
|
-
console.error(
|
|
368
|
-
" -i, --interval 空闲间隔上限秒 (默认 10,实际 1~N 随机)",
|
|
369
|
-
);
|
|
370
|
-
console.error(` -s, --server 服务端地址 (默认 ${defaultServer})`);
|
|
371
|
-
console.error(" -m, --max-comments 每视频最大评论数 (默认 200)");
|
|
372
|
-
console.error(" --base-port 浏览器CDP端口 (默认 9222)");
|
|
373
|
-
process.exit(1);
|
|
732
|
+
await session.shutdown();
|
|
374
733
|
}
|
|
734
|
+
}
|
|
375
735
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
try {
|
|
380
|
-
browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
|
|
381
|
-
const contexts = browser.contexts();
|
|
382
|
-
let page;
|
|
383
|
-
|
|
384
|
-
if (contexts.length > 0) {
|
|
385
|
-
const pages = contexts[0].pages();
|
|
386
|
-
if (pages.length > 0) {
|
|
387
|
-
page = pages[0];
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (!page) {
|
|
392
|
-
console.error("未找到可用页面");
|
|
393
|
-
process.exit(1);
|
|
394
|
-
}
|
|
736
|
+
// ────────────────────────────────────────
|
|
737
|
+
// Section 7: 手动模式 — 单视频评论提取 + 输出/保存
|
|
738
|
+
// ────────────────────────────────────────
|
|
395
739
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!ready) {
|
|
403
|
-
console.error("页面加载超时,tab 未出现");
|
|
404
|
-
process.exit(1);
|
|
405
|
-
}
|
|
740
|
+
async function runManualMode(url, options) {
|
|
741
|
+
const {
|
|
742
|
+
maxComments = CFG.maxComments,
|
|
743
|
+
save: shouldSave,
|
|
744
|
+
serverUrl = defaultServer,
|
|
745
|
+
} = options;
|
|
406
746
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const videoInfo = await safeEvaluate(page, () => {
|
|
416
|
-
const result = {};
|
|
417
|
-
const m = window.location.href.match(/@([^/]+)\/video/);
|
|
418
|
-
result.author = m ? "@" + m[1] : "未知";
|
|
419
|
-
const html = document.documentElement.outerHTML;
|
|
420
|
-
const locMatch = html.match(/"locationCreated":"([^"]*)/);
|
|
421
|
-
result.locationCreated = locMatch ? locMatch[1] : null;
|
|
422
|
-
return result;
|
|
423
|
-
});
|
|
747
|
+
let browser;
|
|
748
|
+
try {
|
|
749
|
+
browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
|
|
750
|
+
const page = getPage(browser);
|
|
751
|
+
if (!page) {
|
|
752
|
+
console.error("未找到可用页面");
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
424
755
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
});
|
|
428
|
-
if (panelOpen) {
|
|
429
|
-
await closeCommentPanel(page);
|
|
430
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
431
|
-
}
|
|
756
|
+
console.error(`正在打开: ${url}`);
|
|
757
|
+
await prepareVideoPage(page, url);
|
|
432
758
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
log: () => {},
|
|
436
|
-
});
|
|
759
|
+
// 提取视频元信息(仅手动模式需要)
|
|
760
|
+
const videoInfo = await safeEvaluate(page, extractVideoInfo);
|
|
437
761
|
|
|
438
|
-
|
|
762
|
+
// 获取评论
|
|
763
|
+
const result = await fetchUserCommentsAPI(page, {
|
|
764
|
+
maxComments,
|
|
765
|
+
log: () => {},
|
|
766
|
+
});
|
|
767
|
+
await closeCommentPanel(page);
|
|
439
768
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
769
|
+
if (result.error || !result.comments || result.comments.length === 0) {
|
|
770
|
+
console.error(`获取评论失败: ${result.error || "未获取到评论"}`);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
444
773
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
result.comments
|
|
448
|
-
.map((c) => c.user?.unique_id)
|
|
449
|
-
.filter(Boolean)
|
|
450
|
-
.map((u) => u.replace(/^@/, "")),
|
|
451
|
-
),
|
|
452
|
-
];
|
|
453
|
-
|
|
454
|
-
const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
|
|
455
|
-
const serverUrl = commentsServer || defaultServer;
|
|
456
|
-
const { apiPost } = createApiClient({
|
|
457
|
-
checkStatus: true,
|
|
458
|
-
maxRetries: 2,
|
|
459
|
-
backoff: 2000,
|
|
460
|
-
log: true,
|
|
461
|
-
});
|
|
774
|
+
const authors = extractUniqueAuthors(result.comments);
|
|
775
|
+
const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
|
|
462
776
|
|
|
777
|
+
if (shouldSave) {
|
|
778
|
+
// ── --save: 去重保存到服务端 ──
|
|
463
779
|
if (
|
|
464
780
|
guessedLocation &&
|
|
465
781
|
!isLocationInList(guessedLocation, DEFAULT_TARGET_LOCATIONS)
|
|
@@ -472,11 +788,11 @@ export async function handleComments(options) {
|
|
|
472
788
|
if (guessedLocation) console.error(`猜测国家: ${guessedLocation}`);
|
|
473
789
|
|
|
474
790
|
try {
|
|
475
|
-
const saveResult = await
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
guessedLocation
|
|
479
|
-
|
|
791
|
+
const saveResult = await saveAuthorsToServer(
|
|
792
|
+
serverUrl,
|
|
793
|
+
authors,
|
|
794
|
+
guessedLocation,
|
|
795
|
+
);
|
|
480
796
|
console.error(
|
|
481
797
|
`\n提交结果: ${saveResult.added} 个新增, ${saveResult.skipped} 个跳过`,
|
|
482
798
|
);
|
|
@@ -485,113 +801,30 @@ export async function handleComments(options) {
|
|
|
485
801
|
process.exit(1);
|
|
486
802
|
}
|
|
487
803
|
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (pages.length > 0) {
|
|
507
|
-
page = pages[0];
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (!page) {
|
|
512
|
-
console.error("未找到可用页面");
|
|
513
|
-
process.exit(1);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
console.error(`正在打开: ${commentsUrl}`);
|
|
517
|
-
await page.goto(commentsUrl, {
|
|
518
|
-
waitUntil: "domcontentloaded",
|
|
519
|
-
timeout: 30000,
|
|
520
|
-
});
|
|
521
|
-
const ready = await waitForPageReady(page, 30000);
|
|
522
|
-
if (!ready) {
|
|
523
|
-
console.error("页面加载超时,tab 未出现");
|
|
524
|
-
process.exit(1);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
await safeEvaluate(page, () => {
|
|
528
|
-
document
|
|
529
|
-
.querySelectorAll('[id*="modal-overlay"]')
|
|
530
|
-
.forEach((o) => o.click());
|
|
531
|
-
});
|
|
532
|
-
await page.keyboard.press("Escape");
|
|
533
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
534
|
-
|
|
535
|
-
const videoInfo = await safeEvaluate(page, () => {
|
|
536
|
-
const result = {};
|
|
537
|
-
const m = window.location.href.match(/@([^/]+)\/video/);
|
|
538
|
-
result.author = m ? "@" + m[1] : "未知";
|
|
539
|
-
const html = document.documentElement.outerHTML;
|
|
540
|
-
const locMatch = html.match(/"locationCreated":"([^"]*)/);
|
|
541
|
-
result.locationCreated = locMatch ? locMatch[1] : null;
|
|
542
|
-
return result;
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
const panelOpen = await safeEvaluate(page, () => {
|
|
546
|
-
return !!document.querySelector('[class*="RightPanelContainer"]');
|
|
547
|
-
});
|
|
548
|
-
if (panelOpen) {
|
|
549
|
-
await closeCommentPanel(page);
|
|
550
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const result = await fetchUserCommentsAPI(page, {
|
|
554
|
-
maxComments: commentsMax,
|
|
555
|
-
log: () => {},
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
await closeCommentPanel(page);
|
|
804
|
+
} else {
|
|
805
|
+
// ── 打印到控制台 ──
|
|
806
|
+
console.log(`视频: ${url}`);
|
|
807
|
+
console.log(`作者: ${videoInfo?.author || "未知"}`);
|
|
808
|
+
if (videoInfo?.locationCreated)
|
|
809
|
+
console.log(`猜测国家: ${videoInfo.locationCreated}`);
|
|
810
|
+
console.log(`评论数: ${result.comments.length}`);
|
|
811
|
+
console.log(`评论作者: ${authors.length}`);
|
|
812
|
+
console.log("");
|
|
813
|
+
|
|
814
|
+
result.comments.forEach((c, i) => {
|
|
815
|
+
const author = c.user?.unique_id
|
|
816
|
+
? "@" + c.user.unique_id
|
|
817
|
+
: c.user?.nickname || "未知";
|
|
818
|
+
console.log(
|
|
819
|
+
`${i + 1}. [${author}] ${c.text || ""} (\u2764 ${c.digg_count || 0})`,
|
|
820
|
+
);
|
|
821
|
+
});
|
|
559
822
|
|
|
560
|
-
|
|
561
|
-
console.
|
|
562
|
-
|
|
823
|
+
console.log("");
|
|
824
|
+
console.log(
|
|
825
|
+
`共 ${result.comments.length} 条评论, ${authors.length} 个唯一作者`,
|
|
826
|
+
);
|
|
563
827
|
}
|
|
564
|
-
|
|
565
|
-
const authors = [
|
|
566
|
-
...new Set(
|
|
567
|
-
result.comments
|
|
568
|
-
.map((c) => c.user?.unique_id)
|
|
569
|
-
.filter(Boolean)
|
|
570
|
-
.map((u) => u.replace(/^@/, "")),
|
|
571
|
-
),
|
|
572
|
-
];
|
|
573
|
-
|
|
574
|
-
console.log(`视频: ${commentsUrl}`);
|
|
575
|
-
console.log(`作者: ${videoInfo?.author || "未知"}`);
|
|
576
|
-
if (videoInfo?.locationCreated)
|
|
577
|
-
console.log(`猜测国家: ${videoInfo.locationCreated}`);
|
|
578
|
-
console.log(`评论数: ${result.comments.length}`);
|
|
579
|
-
console.log(`评论作者: ${authors.length}`);
|
|
580
|
-
console.log("");
|
|
581
|
-
|
|
582
|
-
result.comments.forEach((c, i) => {
|
|
583
|
-
const author = c.user?.unique_id
|
|
584
|
-
? "@" + c.user.unique_id
|
|
585
|
-
: c.user?.nickname || "未知";
|
|
586
|
-
const text = c.text || "";
|
|
587
|
-
const likes = c.digg_count || 0;
|
|
588
|
-
console.log(`${i + 1}. [${author}] ${text} (\u2764 ${likes})`);
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
console.log("");
|
|
592
|
-
console.log(
|
|
593
|
-
`共 ${result.comments.length} 条评论, ${authors.length} 个唯一作者`,
|
|
594
|
-
);
|
|
595
828
|
} catch (err) {
|
|
596
829
|
console.error(`获取评论失败: ${err.message}`);
|
|
597
830
|
process.exit(1);
|
|
@@ -599,3 +832,39 @@ export async function handleComments(options) {
|
|
|
599
832
|
if (browser) await browser.close().catch(() => {});
|
|
600
833
|
}
|
|
601
834
|
}
|
|
835
|
+
|
|
836
|
+
// ────────────────────────────────────────
|
|
837
|
+
// Section 8: 入口 — handleComments
|
|
838
|
+
// ────────────────────────────────────────
|
|
839
|
+
|
|
840
|
+
export async function handleComments(options) {
|
|
841
|
+
const {
|
|
842
|
+
commentsUrl,
|
|
843
|
+
commentsMax,
|
|
844
|
+
commentsSave,
|
|
845
|
+
commentsParallel,
|
|
846
|
+
commentsInterval,
|
|
847
|
+
commentsServer,
|
|
848
|
+
commentsBasePort,
|
|
849
|
+
commentsProxy,
|
|
850
|
+
} = options;
|
|
851
|
+
|
|
852
|
+
// 自动模式:无 URL
|
|
853
|
+
if (!commentsUrl) {
|
|
854
|
+
return runAutoMode({
|
|
855
|
+
serverUrl: commentsServer || defaultServer,
|
|
856
|
+
parallel: commentsParallel,
|
|
857
|
+
interval: commentsInterval,
|
|
858
|
+
maxComments: commentsMax,
|
|
859
|
+
basePort: commentsBasePort,
|
|
860
|
+
proxy: commentsProxy,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// 手动模式:有 URL
|
|
865
|
+
return runManualMode(commentsUrl, {
|
|
866
|
+
maxComments: commentsMax,
|
|
867
|
+
save: commentsSave,
|
|
868
|
+
serverUrl: commentsServer || defaultServer,
|
|
869
|
+
});
|
|
870
|
+
}
|