tt-help-cli-ycl 1.4.144 → 1.4.146
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/scripts/test-daemon-failover.mjs +61 -4
- package/scripts/test-product-fill-cdp.mjs +168 -0
- package/src/clash/checker.js +41 -0
- package/src/clash/daemon.js +26 -0
- package/src/cli/comments.js +265 -388
- package/src/cli/product-fill.js +255 -128
- package/src/cli/tag.js +143 -334
- package/src/lib/args.js +15 -5
- package/src/lib/constants.js +14 -10
- package/src/lib/shop-scraper.mjs +69 -6
- package/src/watch/data-store.js +129 -6
- package/src/watch/public/app.js +290 -0
- package/src/watch/public/index.html +99 -1
- package/src/watch/public/style.css +202 -0
- package/src/watch/server.js +64 -1
- package/src/watchdog/process-manager.js +11 -6
- package/scripts/test-product-fill.mjs +0 -306
package/src/cli/comments.js
CHANGED
|
@@ -5,17 +5,11 @@
|
|
|
5
5
|
// 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
|
|
6
6
|
// ────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
formatMemoryWithBrowser,
|
|
11
|
-
} from "../lib/browser-memory.js";
|
|
12
|
-
import { reportConsecutiveTaskErrors } from "../lib/client-error-report.js";
|
|
8
|
+
import { formatMemoryWithBrowser } from "../lib/browser-memory.js";
|
|
9
|
+
import { reportClientErrorAndFailover } from "../lib/client-error-report.js";
|
|
13
10
|
|
|
14
11
|
import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
|
|
15
|
-
import {
|
|
16
|
-
closeCommentPanel,
|
|
17
|
-
isBrowserClosedError,
|
|
18
|
-
} from "../lib/browser/page.js";
|
|
12
|
+
import { closeCommentPanel, getOrCreatePage } from "../lib/browser/page.js";
|
|
19
13
|
import {
|
|
20
14
|
server as defaultServer,
|
|
21
15
|
proxy as configuredProxy,
|
|
@@ -43,78 +37,16 @@ import { parseVideoInfo } from "../lib/parse-ssr.mjs";
|
|
|
43
37
|
// ────────────────────────────────────────
|
|
44
38
|
|
|
45
39
|
const CFG = {
|
|
46
|
-
/** 默认 CDP
|
|
47
|
-
|
|
48
|
-
/** 端口池大小 */
|
|
49
|
-
portPoolSize: 10,
|
|
40
|
+
/** 默认 CDP 端口(固定:一次会话一个浏览器,不换端口、不重启) */
|
|
41
|
+
defaultPort: 6222,
|
|
50
42
|
/** 每视频最大评论数 */
|
|
51
43
|
maxComments: 200,
|
|
52
44
|
/** 空闲间隔上限(秒) */
|
|
53
45
|
interval: 10,
|
|
54
46
|
/** 并行数 */
|
|
55
47
|
parallel: 1,
|
|
56
|
-
/** 连续 403 后重启浏览器 */
|
|
57
|
-
consecutive403Blocked: 3,
|
|
58
|
-
/** 连续验证码后重启浏览器 */
|
|
59
|
-
maxConsecutiveCaptcha: 3,
|
|
60
|
-
/** 内存回收 — 触发回收的 RSS(MB) 阈值 */
|
|
61
|
-
recycleRssMb: 2000,
|
|
62
|
-
/** 内存回收 — 触发回收的 Heap(MB) 阈值 */
|
|
63
|
-
recycleHeapMb: 2000,
|
|
64
|
-
/** 内存回收 — 触发回收的 Edge 进程(MB) 阈值 */
|
|
65
|
-
recycleEdgeMb: 2000,
|
|
66
|
-
/** 内存回收 — 两次回收最小间隔(ms) */
|
|
67
|
-
recycleCooldownMs: 180000,
|
|
68
|
-
/** 内存回收 — 每 N 个任务强制回收(0=禁用) */
|
|
69
|
-
periodicRecycleEvery: 0,
|
|
70
|
-
/** 内存回收 — 回收时是否切换端口(默认关闭:只重启浏览器不换端口) */
|
|
71
|
-
switchPortOnRecycle: false,
|
|
72
|
-
/** 连续任务处理错误阈值(达到后上报服务端) */
|
|
73
|
-
consecutiveTaskErrorsMax: 20,
|
|
74
|
-
/** 单端口最大运行时间(ms),超时自动重启浏览器(默认 48 分钟) */
|
|
75
|
-
maxSessionRunTimeMs: 48 * 60 * 1000,
|
|
76
48
|
};
|
|
77
49
|
|
|
78
|
-
/** 从环境变量覆写配置 */
|
|
79
|
-
function loadEnvConfig() {
|
|
80
|
-
const int = (key, def) => {
|
|
81
|
-
const v = parseInt(process.env[key], 10);
|
|
82
|
-
return Number.isFinite(v) ? v : def;
|
|
83
|
-
};
|
|
84
|
-
CFG.portPoolStart = int("COMMENTS_PORT_POOL_START", CFG.portPoolStart);
|
|
85
|
-
CFG.portPoolSize = int("COMMENTS_PORT_POOL_SIZE", CFG.portPoolSize);
|
|
86
|
-
CFG.recycleRssMb = Math.max(
|
|
87
|
-
256,
|
|
88
|
-
int("COMMENTS_RECYCLE_RSS_MB", CFG.recycleRssMb),
|
|
89
|
-
);
|
|
90
|
-
CFG.recycleHeapMb = Math.max(
|
|
91
|
-
128,
|
|
92
|
-
int("COMMENTS_RECYCLE_HEAP_MB", CFG.recycleHeapMb),
|
|
93
|
-
);
|
|
94
|
-
CFG.recycleEdgeMb = Math.max(
|
|
95
|
-
500,
|
|
96
|
-
int("COMMENTS_RECYCLE_EDGE_MB", CFG.recycleEdgeMb),
|
|
97
|
-
);
|
|
98
|
-
CFG.recycleCooldownMs = Math.max(
|
|
99
|
-
0,
|
|
100
|
-
int("COMMENTS_RECYCLE_COOLDOWN_MS", CFG.recycleCooldownMs),
|
|
101
|
-
);
|
|
102
|
-
CFG.periodicRecycleEvery = Math.max(
|
|
103
|
-
0,
|
|
104
|
-
int("COMMENTS_PERIODIC_RECYCLE_EVERY", CFG.periodicRecycleEvery),
|
|
105
|
-
);
|
|
106
|
-
CFG.switchPortOnRecycle =
|
|
107
|
-
String(process.env.COMMENTS_SWITCH_PORT_ON_RECYCLE || "0") !== "0";
|
|
108
|
-
CFG.consecutiveTaskErrorsMax = Math.max(
|
|
109
|
-
1,
|
|
110
|
-
int("COMMENTS_CONSECUTIVE_TASK_ERRORS_MAX", CFG.consecutiveTaskErrorsMax),
|
|
111
|
-
);
|
|
112
|
-
CFG.maxSessionRunTimeMs = Math.max(
|
|
113
|
-
60000,
|
|
114
|
-
int("COMMENTS_MAX_SESSION_RUN_TIME_MS", CFG.maxSessionRunTimeMs),
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
50
|
// ────────────────────────────────────────
|
|
119
51
|
// Section 3: 页面辅助
|
|
120
52
|
// ────────────────────────────────────────
|
|
@@ -191,184 +123,6 @@ async function setupPageBlocking(page) {
|
|
|
191
123
|
_blockedPages.add(page);
|
|
192
124
|
}
|
|
193
125
|
|
|
194
|
-
// ────────────────────────────────────────
|
|
195
|
-
// Section 5: CDP 会话管理(端口池/浏览器/页面/内存回收)
|
|
196
|
-
// ────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
class CdpSessionManager {
|
|
199
|
-
constructor({ portPoolStart, portPoolSize, proxy }) {
|
|
200
|
-
this.port = portPoolStart;
|
|
201
|
-
this.portPoolStart = portPoolStart;
|
|
202
|
-
this.portPoolSize = portPoolSize;
|
|
203
|
-
this.proxy = proxy;
|
|
204
|
-
this.browser = null;
|
|
205
|
-
this.page = null;
|
|
206
|
-
this.lastRecycleAt = 0;
|
|
207
|
-
this.sessionStartTime = Date.now();
|
|
208
|
-
this._lastMemCheck = 0;
|
|
209
|
-
this._shuttingDown = false;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
pickNextPort() {
|
|
213
|
-
const candidates = [];
|
|
214
|
-
for (let i = 0; i < this.portPoolSize; i++) {
|
|
215
|
-
const p = this.portPoolStart + i;
|
|
216
|
-
if (p !== this.port) candidates.push(p);
|
|
217
|
-
}
|
|
218
|
-
if (candidates.length === 0) return this.port;
|
|
219
|
-
return candidates[Math.floor(Math.random() * candidates.length)];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** 启动:连接浏览器 + 获取页面 */
|
|
223
|
-
async init() {
|
|
224
|
-
const opts = { port: this.port };
|
|
225
|
-
if (this.proxy) opts.proxyServer = this.proxy;
|
|
226
|
-
this.browser = await ensureBrowserReady(opts);
|
|
227
|
-
this.page = await this._ensurePage();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** 获取或恢复 page */
|
|
231
|
-
async _ensurePage() {
|
|
232
|
-
let p = getPage(this.browser);
|
|
233
|
-
if (p) await setupPageBlocking(p);
|
|
234
|
-
return p;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async ensurePage() {
|
|
238
|
-
if (!this.page || this.page.isClosed()) {
|
|
239
|
-
this.page = await this._ensurePage();
|
|
240
|
-
}
|
|
241
|
-
return this.page;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** 轻量回收:清空当前 tab */
|
|
245
|
-
async recyclePage() {
|
|
246
|
-
if (!this.page || this.page.isClosed()) {
|
|
247
|
-
this.page = await this._ensurePage();
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
await this.page.goto("about:blank", {
|
|
252
|
-
waitUntil: "domcontentloaded",
|
|
253
|
-
timeout: 5000,
|
|
254
|
-
});
|
|
255
|
-
} catch {
|
|
256
|
-
await this.page.close().catch(() => {});
|
|
257
|
-
this.page = await this._ensurePage();
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** 深度回收:关闭浏览器,可选切换端口 */
|
|
262
|
-
async recycleSession(reason, { switchPort = true } = {}) {
|
|
263
|
-
const oldPort = this.port;
|
|
264
|
-
const doSwitch = switchPort && CFG.switchPortOnRecycle;
|
|
265
|
-
if (doSwitch) this.port = this.pickNextPort();
|
|
266
|
-
const hint =
|
|
267
|
-
oldPort === this.port
|
|
268
|
-
? `port=${this.port}`
|
|
269
|
-
: `port ${oldPort}→${this.port}`;
|
|
270
|
-
console.error(` ♻️ 重建 CDP 会话 (${reason}; ${hint})...`);
|
|
271
|
-
|
|
272
|
-
if (this.page) {
|
|
273
|
-
await this.page.close().catch(() => {});
|
|
274
|
-
this.page = null;
|
|
275
|
-
}
|
|
276
|
-
await this.browser?.close().catch(() => {});
|
|
277
|
-
|
|
278
|
-
if (oldPort !== this.port) {
|
|
279
|
-
await killEdgeProcesses(null, oldPort).catch(() => {});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const opts = { port: this.port };
|
|
283
|
-
if (this.proxy) opts.proxyServer = this.proxy;
|
|
284
|
-
this.browser = await ensureBrowserReady(opts);
|
|
285
|
-
this.page = await this._ensurePage();
|
|
286
|
-
this.lastRecycleAt = Date.now();
|
|
287
|
-
// 重建会话后重置运行计时(无论是否换端口)。
|
|
288
|
-
// 旧逻辑只在换端口时重置,而默认 switchPortOnRecycle=false,
|
|
289
|
-
// 导致 48min 定时轮换触发后计时不归零,之后每次内存采样(30s)
|
|
290
|
-
// 都再次满足超时条件 → 每 30s 反复重建会话(“一直在切换”)
|
|
291
|
-
this.sessionStartTime = Date.now();
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** 内存检查 + 定时轮换 + 按需回收(每 30s 最多采样一次) */
|
|
295
|
-
async maybeRecycleForMemory(processedCount) {
|
|
296
|
-
const now = Date.now();
|
|
297
|
-
if (now - this._lastMemCheck < 30000) return;
|
|
298
|
-
this._lastMemCheck = now;
|
|
299
|
-
|
|
300
|
-
// 定时轮换:单端口运行超时自动重启浏览器
|
|
301
|
-
const sessionElapsed = now - this.sessionStartTime;
|
|
302
|
-
if (sessionElapsed >= CFG.maxSessionRunTimeMs) {
|
|
303
|
-
const mins = Math.round(sessionElapsed / 60000);
|
|
304
|
-
await this.recycleSession(`定时轮换 (运行 ${mins} 分钟)`, {
|
|
305
|
-
switchPort: true,
|
|
306
|
-
});
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const mem = process.memoryUsage();
|
|
311
|
-
const rssMB = mem.rss / 1024 / 1024;
|
|
312
|
-
const heapMB = mem.heapUsed / 1024 / 1024;
|
|
313
|
-
const edgeMB = getBrowserMemory(this.port);
|
|
314
|
-
|
|
315
|
-
const nodeOver = rssMB >= CFG.recycleRssMb || heapMB >= CFG.recycleHeapMb;
|
|
316
|
-
const edgeOver = edgeMB !== null && edgeMB >= CFG.recycleEdgeMb;
|
|
317
|
-
|
|
318
|
-
if (nodeOver || edgeOver) {
|
|
319
|
-
if (
|
|
320
|
-
CFG.recycleCooldownMs > 0 &&
|
|
321
|
-
Date.now() - this.lastRecycleAt < CFG.recycleCooldownMs
|
|
322
|
-
)
|
|
323
|
-
return;
|
|
324
|
-
|
|
325
|
-
const parts = [];
|
|
326
|
-
if (rssMB >= CFG.recycleRssMb)
|
|
327
|
-
parts.push(`rss=${rssMB.toFixed(0)}≥${CFG.recycleRssMb}`);
|
|
328
|
-
if (heapMB >= CFG.recycleHeapMb)
|
|
329
|
-
parts.push(`heap=${heapMB.toFixed(0)}≥${CFG.recycleHeapMb}`);
|
|
330
|
-
if (edgeOver)
|
|
331
|
-
parts.push(`edge:${this.port}=${edgeMB}≥${CFG.recycleEdgeMb}`);
|
|
332
|
-
|
|
333
|
-
await this.recycleSession(`mem ${parts.join("+")}`, {
|
|
334
|
-
switchPort: edgeOver,
|
|
335
|
-
});
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
CFG.periodicRecycleEvery > 0 &&
|
|
341
|
-
processedCount > 0 &&
|
|
342
|
-
processedCount % CFG.periodicRecycleEvery === 0
|
|
343
|
-
) {
|
|
344
|
-
await this.recycleSession(`periodic #${processedCount}`, {
|
|
345
|
-
switchPort: false,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** 重启浏览器(不切换端口,用于浏览器异常断开) */
|
|
351
|
-
async restartBrowser() {
|
|
352
|
-
try {
|
|
353
|
-
await this.browser?.close();
|
|
354
|
-
} catch {}
|
|
355
|
-
this.browser = null;
|
|
356
|
-
this.page = null;
|
|
357
|
-
const opts = { port: this.port };
|
|
358
|
-
if (this.proxy) opts.proxyServer = this.proxy;
|
|
359
|
-
this.browser = await ensureBrowserReady(opts);
|
|
360
|
-
this.page = await this._ensurePage();
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async shutdown() {
|
|
364
|
-
if (this._shuttingDown) return;
|
|
365
|
-
this._shuttingDown = true;
|
|
366
|
-
if (this.page) await this.page.close().catch(() => {});
|
|
367
|
-
await this.browser?.close().catch(() => {});
|
|
368
|
-
killEdgeProcesses(null, this.port);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
126
|
// ────────────────────────────────────────
|
|
373
127
|
// Section 6: 共享操作(视频页准备 & 评论提取 & 保存)
|
|
374
128
|
// ────────────────────────────────────────
|
|
@@ -498,11 +252,10 @@ async function runAutoMode(options) {
|
|
|
498
252
|
const actualInterval = interval || CFG.interval;
|
|
499
253
|
const actualMaxComments = maxComments || CFG.maxComments;
|
|
500
254
|
const effectiveProxy = proxyOption || configuredProxy;
|
|
501
|
-
|
|
255
|
+
// 固定端口:一次会话一个浏览器,全程不换端口、不重启浏览器(错误只打印,不自愈)
|
|
256
|
+
const cdpPort = basePort || CFG.defaultPort;
|
|
502
257
|
const jobLocations = locations || null;
|
|
503
258
|
|
|
504
|
-
loadEnvConfig();
|
|
505
|
-
|
|
506
259
|
// ── 设备标识 ──
|
|
507
260
|
let userId = configuredUserId;
|
|
508
261
|
if (!userId) {
|
|
@@ -521,28 +274,129 @@ async function runAutoMode(options) {
|
|
|
521
274
|
let shuttingDown = false;
|
|
522
275
|
let processedCount = 0,
|
|
523
276
|
skippedCount = 0,
|
|
524
|
-
errorCount = 0
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
277
|
+
errorCount = 0;
|
|
278
|
+
|
|
279
|
+
// ── 错误计数(纯观察,不做任何自愈) ──
|
|
280
|
+
// 阈值语义:连续超过 N 次触发动作(即第 N+1 次)
|
|
281
|
+
// 403 连续 >2 次 → 上报服务端 + 强制 fallback(daemon 换节点),清零后继续跑
|
|
282
|
+
// 网络错误 连续 >10 次 → 触发 daemon fallback 换节点(本地 IPC,不依赖服务端可达)+ 退出
|
|
283
|
+
// 验证码 连续 >3 次(人工一直未解决)→ 打印退出
|
|
284
|
+
// 其他错误 连续 >10 次 → 打印退出
|
|
285
|
+
// 清零规则:成功提交一个任务 → 全部清零;fallback 触发 → 对应计数清零;
|
|
286
|
+
// 人工点掉验证码 → 验证码计数清零;领取任务成功 → 网络计数清零
|
|
287
|
+
const errorCounts = { http403: 0, network: 0, captcha: 0, other: 0 };
|
|
288
|
+
const ERROR_LIMITS = { http403: 2, network: 10, captcha: 3, other: 10 };
|
|
289
|
+
const resetAllErrorCounts = () => {
|
|
290
|
+
errorCounts.http403 = 0;
|
|
291
|
+
errorCounts.network = 0;
|
|
292
|
+
errorCounts.captcha = 0;
|
|
293
|
+
errorCounts.other = 0;
|
|
294
|
+
};
|
|
295
|
+
const bumpErrorCount = (type) => {
|
|
296
|
+
errorCounts[type]++;
|
|
297
|
+
return errorCounts[type] > ERROR_LIMITS[type];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// 浏览器是否真的已断开(用户手动关窗口 / CDP 断开)。
|
|
301
|
+
// 网络层 net::ERR_* 是代理抖动/连接重置,浏览器进程还活着,不算关闭。
|
|
302
|
+
function isBrowserDisconnected(browser, e) {
|
|
303
|
+
try {
|
|
304
|
+
if (
|
|
305
|
+
browser &&
|
|
306
|
+
typeof browser.isConnected === "function" &&
|
|
307
|
+
!browser.isConnected()
|
|
308
|
+
) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
} catch {}
|
|
312
|
+
if (!e) return false;
|
|
313
|
+
const msg = e.message || "";
|
|
314
|
+
if (msg.includes("net::ERR_")) return false;
|
|
315
|
+
return (
|
|
316
|
+
msg.includes("Target closed") ||
|
|
317
|
+
msg.includes("Browser has been closed") ||
|
|
318
|
+
msg.includes("Target page, context or browser has been closed")
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 网络错误判定(代理抖动 net::ERR_* 归为网络错误)
|
|
323
|
+
function isNetworkError(e) {
|
|
324
|
+
return (
|
|
325
|
+
e.code === "ECONNREFUSED" ||
|
|
326
|
+
e.code === "ENOTFOUND" ||
|
|
327
|
+
e.code === "ECONNRESET" ||
|
|
328
|
+
(e.message &&
|
|
329
|
+
(e.message.includes("ECONNREFUSED") ||
|
|
330
|
+
e.message.includes("fetch failed") ||
|
|
331
|
+
e.message.includes("network") ||
|
|
332
|
+
e.message.includes("net::ERR_")))
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 网络错误超限 → 触发 daemon fallback 换节点(本地 IPC,不依赖服务端可达)+ 退出
|
|
337
|
+
const networkErrorExit = async () => {
|
|
338
|
+
console.error(
|
|
339
|
+
` ❌ 连续网络错误超过 ${ERROR_LIMITS.network} 次 → 触发 daemon fallback 换节点后退出`,
|
|
340
|
+
);
|
|
341
|
+
await reportClientErrorAndFailover({
|
|
342
|
+
post: apiPost,
|
|
343
|
+
baseUrl: serverUrl,
|
|
344
|
+
userId,
|
|
345
|
+
errorType: "network_failure",
|
|
346
|
+
message: `连续 ${ERROR_LIMITS.network + 1} 次网络错误 (port=${cdpPort})`,
|
|
347
|
+
stage: "comments",
|
|
348
|
+
});
|
|
349
|
+
await shutdown("连续网络错误");
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// ── CDP 浏览器(固定端口) ──
|
|
353
|
+
const cdpOpts = { port: cdpPort };
|
|
354
|
+
if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
|
|
355
|
+
let browser;
|
|
356
|
+
try {
|
|
357
|
+
browser = await ensureBrowserReady(cdpOpts);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error(`× 浏览器启动失败: ${err.message}`);
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
let page = await getOrCreatePage(browser);
|
|
363
|
+
await setupPageBlocking(page);
|
|
364
|
+
|
|
365
|
+
// 轻量回收:只清空当前 tab(不重启浏览器)
|
|
366
|
+
async function recyclePage() {
|
|
367
|
+
if (!page || page.isClosed()) {
|
|
368
|
+
page = await getOrCreatePage(browser);
|
|
369
|
+
await setupPageBlocking(page);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
await page.goto("about:blank", {
|
|
374
|
+
waitUntil: "domcontentloaded",
|
|
375
|
+
timeout: 5000,
|
|
376
|
+
});
|
|
377
|
+
} catch {
|
|
378
|
+
await page.close().catch(() => {});
|
|
379
|
+
page = await getOrCreatePage(browser);
|
|
380
|
+
await setupPageBlocking(page);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
537
383
|
|
|
538
384
|
// ── 生命周期 ──
|
|
385
|
+
let closing = false;
|
|
386
|
+
const closeResources = async () => {
|
|
387
|
+
if (closing) return;
|
|
388
|
+
closing = true;
|
|
389
|
+
if (page) await page.close().catch(() => {});
|
|
390
|
+
await browser?.close().catch(() => {});
|
|
391
|
+
killEdgeProcesses(null, cdpPort);
|
|
392
|
+
};
|
|
539
393
|
const shutdown = async (signal) => {
|
|
540
394
|
if (shuttingDown) return;
|
|
541
395
|
shuttingDown = true;
|
|
542
396
|
console.error(
|
|
543
|
-
`\n── ${signal} ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}
|
|
397
|
+
`\n── ${signal} ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
|
|
544
398
|
);
|
|
545
|
-
await
|
|
399
|
+
await closeResources();
|
|
546
400
|
console.error("已退出");
|
|
547
401
|
process.exit(0);
|
|
548
402
|
};
|
|
@@ -567,37 +421,26 @@ async function runAutoMode(options) {
|
|
|
567
421
|
if (jobLocations) console.error(`║ 任务过滤: ${jobLocations}`);
|
|
568
422
|
console.error(`║ 服务端: ${serverUrl}`);
|
|
569
423
|
console.error(
|
|
570
|
-
`║ CDP
|
|
424
|
+
`║ CDP 端口: ${cdpPort}${effectiveProxy ? " 代理: " + effectiveProxy : ""}`,
|
|
571
425
|
);
|
|
572
426
|
console.error(`║ 并行: ${actualParallel}, 间隔: ${actualInterval}s`);
|
|
573
427
|
console.error(`║ 每视频最大评论: ${actualMaxComments}`);
|
|
574
|
-
if (effectiveProxy) console.error(`║ 代理: ${effectiveProxy}`);
|
|
575
428
|
console.error(`╚══════════════════════════════════════════╝`);
|
|
576
|
-
console.error(`📊 启动内存: ${formatMemoryWithBrowser(
|
|
429
|
+
console.error(`📊 启动内存: ${formatMemoryWithBrowser(cdpPort)}`);
|
|
577
430
|
console.error("开始循环接收任务...\n");
|
|
578
431
|
|
|
579
|
-
// ── 启动浏览器 ──
|
|
580
|
-
try {
|
|
581
|
-
await session.init();
|
|
582
|
-
} catch (err) {
|
|
583
|
-
console.error(`× 浏览器启动失败: ${err.message}`);
|
|
584
|
-
process.exit(1);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
432
|
// ════════════════════════════════════════
|
|
588
433
|
// 主循环
|
|
589
434
|
// ════════════════════════════════════════
|
|
590
435
|
try {
|
|
591
436
|
while (!shuttingDown) {
|
|
437
|
+
// 浏览器窗口被手动关闭(CDP 断开)→ 自动退出
|
|
438
|
+
if (isBrowserDisconnected(browser)) {
|
|
439
|
+
console.error("🚪 检测到浏览器窗口已关闭,自动退出");
|
|
440
|
+
await shutdown("浏览器已关闭");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
592
443
|
try {
|
|
593
|
-
// 确保页面可用
|
|
594
|
-
await session.ensurePage();
|
|
595
|
-
if (!session.page) {
|
|
596
|
-
console.error(" ⚠ 无可用页面 → 重建会话");
|
|
597
|
-
await session.recycleSession("page missing", { switchPort: false });
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
444
|
// ── 领取任务 ──
|
|
602
445
|
let tasks;
|
|
603
446
|
try {
|
|
@@ -607,18 +450,19 @@ async function runAutoMode(options) {
|
|
|
607
450
|
const resp = await apiGet(apiUrl);
|
|
608
451
|
tasks = resp.tasks || [];
|
|
609
452
|
} catch (err) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
453
|
+
// 领取失败计入网络错误(服务端不可达 / 代理不通都算)
|
|
454
|
+
const over = bumpErrorCount("network");
|
|
455
|
+
console.error(
|
|
456
|
+
` × 获取任务失败: ${err.message}(连续网络错误 ${errorCounts.network} 次),15 秒后重试...`,
|
|
457
|
+
);
|
|
458
|
+
if (over) {
|
|
459
|
+
await networkErrorExit();
|
|
460
|
+
return;
|
|
616
461
|
}
|
|
617
|
-
|
|
618
|
-
await delay(waitMs, waitMs + 1000);
|
|
462
|
+
await new Promise((r) => setTimeout(r, 15000));
|
|
619
463
|
continue;
|
|
620
464
|
}
|
|
621
|
-
|
|
465
|
+
errorCounts.network = 0; // 领取成功 → 网络计数清零
|
|
622
466
|
|
|
623
467
|
if (tasks.length === 0) {
|
|
624
468
|
console.error(` … 无任务,${actualInterval}s 内重试`);
|
|
@@ -648,18 +492,20 @@ async function runAutoMode(options) {
|
|
|
648
492
|
console.error(`【${taskNum}】${href.substring(0, 60)}…`);
|
|
649
493
|
|
|
650
494
|
// 确保页面可用
|
|
651
|
-
|
|
652
|
-
|
|
495
|
+
if (!page || page.isClosed()) {
|
|
496
|
+
page = await getOrCreatePage(browser);
|
|
497
|
+
await setupPageBlocking(page);
|
|
498
|
+
}
|
|
653
499
|
|
|
654
500
|
// ── 登录弹窗检测(任务前)──
|
|
655
501
|
try {
|
|
656
|
-
const modals = await detectModals(
|
|
502
|
+
const modals = await detectModals(page);
|
|
657
503
|
const loginModal = modals.find(
|
|
658
504
|
(m) => m.type === "login-modal" || m.type === "modal-container",
|
|
659
505
|
);
|
|
660
506
|
if (loginModal) {
|
|
661
507
|
console.error(` 🔔 检测到登录弹窗,正在关闭...`);
|
|
662
|
-
const { closed, failed } = await closeAllModals(
|
|
508
|
+
const { closed, failed } = await closeAllModals(page, {
|
|
663
509
|
waitMs: 300,
|
|
664
510
|
});
|
|
665
511
|
console.error(
|
|
@@ -672,11 +518,11 @@ async function runAutoMode(options) {
|
|
|
672
518
|
}
|
|
673
519
|
|
|
674
520
|
// 导航到视频页
|
|
675
|
-
await prepareVideoPage(
|
|
521
|
+
await prepareVideoPage(page, href);
|
|
676
522
|
|
|
677
523
|
// ── 提取视频 tag(仅 ttSeller 视频)──
|
|
678
524
|
if (ttSeller && loc) {
|
|
679
|
-
const { tagNames } = await extractVideoTags(
|
|
525
|
+
const { tagNames } = await extractVideoTags(page);
|
|
680
526
|
if (tagNames.length > 0) {
|
|
681
527
|
const { inserted } = await saveVideoTags(
|
|
682
528
|
apiPost,
|
|
@@ -693,13 +539,13 @@ async function runAutoMode(options) {
|
|
|
693
539
|
}
|
|
694
540
|
|
|
695
541
|
// 获取评论
|
|
696
|
-
const result = await fetchUserCommentsAPI(
|
|
542
|
+
const result = await fetchUserCommentsAPI(page, {
|
|
697
543
|
maxComments: actualMaxComments,
|
|
698
544
|
log: () => {},
|
|
699
545
|
});
|
|
700
546
|
|
|
701
547
|
// 关闭评论面板
|
|
702
|
-
await closeCommentPanel(
|
|
548
|
+
await closeCommentPanel(page);
|
|
703
549
|
|
|
704
550
|
// ── 处理结果 ──
|
|
705
551
|
let resultNote = "";
|
|
@@ -713,17 +559,16 @@ async function runAutoMode(options) {
|
|
|
713
559
|
|
|
714
560
|
// ── 先检测并关闭登录弹窗(避免被误判为验证码导致长时间等待)──
|
|
715
561
|
try {
|
|
716
|
-
const modalsBefore = await detectModals(
|
|
562
|
+
const modalsBefore = await detectModals(page);
|
|
717
563
|
const loginModal = modalsBefore.find(
|
|
718
564
|
(m) =>
|
|
719
565
|
m.type === "login-modal" || m.type === "modal-container",
|
|
720
566
|
);
|
|
721
567
|
if (loginModal) {
|
|
722
568
|
console.error(" 🔔 检测到登录弹窗,正在关闭...");
|
|
723
|
-
const { closed, failed } = await closeAllModals(
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
);
|
|
569
|
+
const { closed, failed } = await closeAllModals(page, {
|
|
570
|
+
waitMs: 300,
|
|
571
|
+
});
|
|
727
572
|
console.error(
|
|
728
573
|
` 🔔 弹窗关闭: closed=${JSON.stringify(closed)} failed=${JSON.stringify(failed)}`,
|
|
729
574
|
);
|
|
@@ -737,7 +582,7 @@ async function runAutoMode(options) {
|
|
|
737
582
|
await new Promise((r) =>
|
|
738
583
|
setTimeout(r, 3000 + Math.random() * 2000),
|
|
739
584
|
);
|
|
740
|
-
const captchaResult = await waitAndGetCaptcha(
|
|
585
|
+
const captchaResult = await waitAndGetCaptcha(page, {
|
|
741
586
|
waitMs: 180000,
|
|
742
587
|
pollInterval: 5000,
|
|
743
588
|
log: () => {},
|
|
@@ -756,28 +601,25 @@ async function runAutoMode(options) {
|
|
|
756
601
|
|
|
757
602
|
if (captchaResult.resolved) {
|
|
758
603
|
resultNote = "验证码已解决";
|
|
759
|
-
|
|
604
|
+
errorCounts.captcha = 0; // 人工点掉 → 验证码计数清零
|
|
760
605
|
} else {
|
|
761
|
-
|
|
762
|
-
resultNote = `验证码未解决(${
|
|
763
|
-
await closeCaptcha(
|
|
606
|
+
const over = bumpErrorCount("captcha");
|
|
607
|
+
resultNote = `验证码未解决(连续第 ${errorCounts.captcha} 次)`;
|
|
608
|
+
await closeCaptcha(page).catch(() => {});
|
|
764
609
|
await new Promise((r) => setTimeout(r, 1000));
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
switchPort: true,
|
|
773
|
-
});
|
|
774
|
-
consecutiveCaptchaCount = 0;
|
|
610
|
+
if (over) {
|
|
611
|
+
console.error(
|
|
612
|
+
` ❌ 连续验证码超过 ${ERROR_LIMITS.captcha} 次且未解决 → 打印并退出`,
|
|
613
|
+
);
|
|
614
|
+
await shutdown("连续验证码未解决");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
775
617
|
}
|
|
776
618
|
} else {
|
|
777
|
-
|
|
619
|
+
errorCounts.captcha = 0;
|
|
778
620
|
}
|
|
779
621
|
} else {
|
|
780
|
-
|
|
622
|
+
errorCounts.captcha = 0;
|
|
781
623
|
const authors = extractUniqueAuthors(result.comments);
|
|
782
624
|
const discovered = authors.length;
|
|
783
625
|
let added = 0,
|
|
@@ -806,105 +648,140 @@ async function runAutoMode(options) {
|
|
|
806
648
|
// commit 任务
|
|
807
649
|
await apiPut(`${serverUrl}/api/comment-task/${id}`);
|
|
808
650
|
processedCount++;
|
|
809
|
-
|
|
810
|
-
consecutiveTaskErrors = 0;
|
|
651
|
+
resetAllErrorCounts(); // 成功一个任务 → 所有错误计数清零
|
|
811
652
|
|
|
812
653
|
// 每 10 个任务输出内存汇总
|
|
813
654
|
if (totalTasks() % 10 === 0) {
|
|
814
|
-
const remainingMs =
|
|
815
|
-
session.sessionStartTime + CFG.maxSessionRunTimeMs - Date.now();
|
|
816
|
-
const remainingMins =
|
|
817
|
-
remainingMs > 0 ? Math.round(remainingMs / 60000) : 0;
|
|
818
655
|
console.error(
|
|
819
|
-
`── #${totalTasks()} ── ${formatMemoryWithBrowser(
|
|
656
|
+
`── #${totalTasks()} ── ${formatMemoryWithBrowser(cdpPort)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
|
|
820
657
|
);
|
|
821
658
|
}
|
|
822
659
|
} catch (err) {
|
|
823
660
|
errorCount++;
|
|
824
|
-
|
|
825
|
-
|
|
661
|
+
// 浏览器窗口被手动关闭 → 退出
|
|
662
|
+
if (isBrowserDisconnected(browser, err)) {
|
|
663
|
+
console.error(
|
|
664
|
+
`🚪 浏览器窗口已关闭 (${err.message || ""}),自动退出`,
|
|
665
|
+
);
|
|
666
|
+
await shutdown("浏览器已关闭");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// 网络错误(含代理抖动 net::ERR_*)
|
|
670
|
+
if (isNetworkError(err)) {
|
|
671
|
+
const over = bumpErrorCount("network");
|
|
672
|
+
console.error(
|
|
673
|
+
` ⚠️ 网络错误 (${err.message}),15 秒后重试(连续网络错误 ${errorCounts.network} 次)...`,
|
|
674
|
+
);
|
|
675
|
+
if (over) {
|
|
676
|
+
await networkErrorExit();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
await new Promise((r) => setTimeout(r, 15000));
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
826
682
|
const is403 =
|
|
827
683
|
err.message &&
|
|
828
684
|
(err.message.includes("403") ||
|
|
829
685
|
err.message.includes("status 403") ||
|
|
830
686
|
err.message.includes("status code 403"));
|
|
831
|
-
|
|
832
687
|
if (is403) {
|
|
833
|
-
|
|
688
|
+
const over = bumpErrorCount("http403");
|
|
834
689
|
console.error(
|
|
835
|
-
` 403
|
|
690
|
+
` 🚫 HTTP 403(连续第 ${errorCounts.http403} 次)`,
|
|
836
691
|
);
|
|
837
|
-
if (
|
|
838
|
-
console.error(
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
692
|
+
if (over) {
|
|
693
|
+
console.error(
|
|
694
|
+
` ⚠️ 连续 403 超过 ${ERROR_LIMITS.http403} 次 → 上报服务端 + 触发 daemon fallback`,
|
|
695
|
+
);
|
|
696
|
+
await reportClientErrorAndFailover({
|
|
697
|
+
post: apiPost,
|
|
698
|
+
baseUrl: serverUrl,
|
|
699
|
+
userId,
|
|
700
|
+
errorType: "consecutive_blocked",
|
|
701
|
+
message: `连续 ${ERROR_LIMITS.http403 + 1} 次 403 (port=${cdpPort})`,
|
|
702
|
+
stage: "comments",
|
|
703
|
+
});
|
|
704
|
+
errorCounts.http403 = 0; // fallback 触发后清零
|
|
842
705
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
browserRestartCount++;
|
|
846
|
-
await session.restartBrowser();
|
|
847
|
-
} else {
|
|
848
|
-
console.error(` ${err.message}`);
|
|
706
|
+
await recyclePage();
|
|
707
|
+
continue;
|
|
849
708
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if (
|
|
709
|
+
console.error(` ${err.message}`);
|
|
710
|
+
const over = bumpErrorCount("other");
|
|
711
|
+
if (over) {
|
|
853
712
|
console.error(
|
|
854
|
-
`
|
|
713
|
+
` ❌ 连续其他错误超过 ${ERROR_LIMITS.other} 次 → 打印并退出`,
|
|
855
714
|
);
|
|
856
|
-
await
|
|
857
|
-
|
|
858
|
-
baseUrl: serverUrl,
|
|
859
|
-
userId,
|
|
860
|
-
message: `连续 ${consecutiveTaskErrors} 个任务处理失败 (port=${session.port})`,
|
|
861
|
-
stage: "comments",
|
|
862
|
-
});
|
|
863
|
-
consecutiveTaskErrors = 0;
|
|
715
|
+
await shutdown("连续其他错误");
|
|
716
|
+
return;
|
|
864
717
|
}
|
|
865
718
|
}
|
|
866
|
-
|
|
867
|
-
// 任务间内存检查
|
|
868
|
-
await session.maybeRecycleForMemory(processedCount);
|
|
869
719
|
}
|
|
870
720
|
} catch (err) {
|
|
871
721
|
errorCount++;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
await session.restartBrowser();
|
|
877
|
-
} else {
|
|
878
|
-
console.error(` × ${err.message}`);
|
|
722
|
+
if (isBrowserDisconnected(browser, err)) {
|
|
723
|
+
console.error(`🚪 浏览器窗口已关闭 (${err.message || ""}),自动退出`);
|
|
724
|
+
await shutdown("浏览器已关闭");
|
|
725
|
+
return;
|
|
879
726
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
|
|
727
|
+
if (isNetworkError(err)) {
|
|
728
|
+
const over = bumpErrorCount("network");
|
|
883
729
|
console.error(
|
|
884
|
-
` ⚠️
|
|
730
|
+
` ⚠️ 网络错误 (${err.message}),15 秒后重试(连续网络错误 ${errorCounts.network} 次)...`,
|
|
885
731
|
);
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
732
|
+
if (over) {
|
|
733
|
+
await networkErrorExit();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
await new Promise((r) => setTimeout(r, 15000));
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const is403 =
|
|
740
|
+
err.message &&
|
|
741
|
+
(err.message.includes("403") ||
|
|
742
|
+
err.message.includes("status 403") ||
|
|
743
|
+
err.message.includes("status code 403"));
|
|
744
|
+
if (is403) {
|
|
745
|
+
const over = bumpErrorCount("http403");
|
|
746
|
+
console.error(` 🚫 HTTP 403(连续第 ${errorCounts.http403} 次)`);
|
|
747
|
+
if (over) {
|
|
748
|
+
console.error(
|
|
749
|
+
` ⚠️ 连续 403 超过 ${ERROR_LIMITS.http403} 次 → 上报服务端 + 触发 daemon fallback`,
|
|
750
|
+
);
|
|
751
|
+
await reportClientErrorAndFailover({
|
|
752
|
+
post: apiPost,
|
|
753
|
+
baseUrl: serverUrl,
|
|
754
|
+
userId,
|
|
755
|
+
errorType: "consecutive_blocked",
|
|
756
|
+
message: `连续 ${ERROR_LIMITS.http403 + 1} 次 403 (port=${cdpPort})`,
|
|
757
|
+
stage: "comments",
|
|
758
|
+
});
|
|
759
|
+
errorCounts.http403 = 0;
|
|
760
|
+
}
|
|
761
|
+
await new Promise((r) => setTimeout(r, 15000));
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
console.error(` × ${err.message}`);
|
|
765
|
+
const over = bumpErrorCount("other");
|
|
766
|
+
if (over) {
|
|
767
|
+
console.error(
|
|
768
|
+
` ❌ 连续其他错误超过 ${ERROR_LIMITS.other} 次 → 打印并退出`,
|
|
769
|
+
);
|
|
770
|
+
await shutdown("连续其他错误");
|
|
771
|
+
return;
|
|
894
772
|
}
|
|
895
773
|
}
|
|
896
774
|
|
|
897
|
-
//
|
|
898
|
-
await session.maybeRecycleForMemory(processedCount);
|
|
775
|
+
// 轮间等待
|
|
899
776
|
await delay(1000, actualInterval * 1000);
|
|
900
777
|
}
|
|
901
778
|
} finally {
|
|
902
779
|
process.removeListener("SIGINT", onSigint);
|
|
903
780
|
process.removeListener("SIGTERM", onSigterm);
|
|
904
781
|
console.error(
|
|
905
|
-
`\n── 统计 ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}
|
|
782
|
+
`\n── 统计 ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
|
|
906
783
|
);
|
|
907
|
-
await
|
|
784
|
+
await closeResources();
|
|
908
785
|
}
|
|
909
786
|
}
|
|
910
787
|
|