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.
@@ -5,17 +5,11 @@
5
5
  // 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
6
6
  // ────────────────────────────────────────
7
7
 
8
- import {
9
- getBrowserMemory,
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
- portPoolStart: 6222,
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
- const portPoolStart = basePort || CFG.portPoolStart;
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
- browserRestartCount = 0;
526
- let consecutiveErrors = 0,
527
- consecutive403 = 0,
528
- consecutiveCaptchaCount = 0,
529
- consecutiveTaskErrors = 0;
530
-
531
- // ── CDP 会话 ──
532
- const session = new CdpSessionManager({
533
- portPoolStart,
534
- portPoolSize: CFG.portPoolSize,
535
- proxy: effectiveProxy,
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} 重启=${browserRestartCount}`,
397
+ `\n── ${signal} ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
544
398
  );
545
- await session.shutdown();
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 端口池: ${portPoolStart}-${portPoolStart + CFG.portPoolSize - 1}(当前: ${session.port})`,
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(session.port)}`);
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
- console.error(` × 获取任务失败: ${err.message}`);
611
- consecutiveErrors++;
612
- if (consecutiveErrors > 0 && consecutiveErrors % 30 === 0) {
613
- console.error(
614
- ` ⚠ 已连续获取失败 ${consecutiveErrors} 次,继续等待服务恢复...`,
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
- const waitMs = Math.min(consecutiveErrors * 1000, 60000);
618
- await delay(waitMs, waitMs + 1000);
462
+ await new Promise((r) => setTimeout(r, 15000));
619
463
  continue;
620
464
  }
621
- consecutiveErrors = 0;
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
- await session.ensurePage();
652
- if (!session.page) throw new Error("未找到可用页面");
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(session.page);
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(session.page, {
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(session.page, href);
521
+ await prepareVideoPage(page, href);
676
522
 
677
523
  // ── 提取视频 tag(仅 ttSeller 视频)──
678
524
  if (ttSeller && loc) {
679
- const { tagNames } = await extractVideoTags(session.page);
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(session.page, {
542
+ const result = await fetchUserCommentsAPI(page, {
697
543
  maxComments: actualMaxComments,
698
544
  log: () => {},
699
545
  });
700
546
 
701
547
  // 关闭评论面板
702
- await closeCommentPanel(session.page);
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(session.page);
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
- session.page,
725
- { waitMs: 300 },
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(session.page, {
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
- consecutiveCaptchaCount = 0;
604
+ errorCounts.captcha = 0; // 人工点掉 → 验证码计数清零
760
605
  } else {
761
- consecutiveCaptchaCount++;
762
- resultNote = `验证码未解决(${consecutiveCaptchaCount}/${CFG.maxConsecutiveCaptcha})`;
763
- await closeCaptcha(session.page).catch(() => {});
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
- if (consecutiveCaptchaCount >= CFG.maxConsecutiveCaptcha) {
768
- console.error(
769
- ` ♻️ 连续${CFG.maxConsecutiveCaptcha}次验证码 → 重启浏览器`,
770
- );
771
- await session.recycleSession("连续验证码", {
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
- consecutiveCaptchaCount = 0;
619
+ errorCounts.captcha = 0;
778
620
  }
779
621
  } else {
780
- consecutiveCaptchaCount = 0;
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
- consecutive403 = 0;
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(session.port)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount} 距轮换≈${remainingMins}min`,
656
+ `── #${totalTasks()} ── ${formatMemoryWithBrowser(cdpPort)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
820
657
  );
821
658
  }
822
659
  } catch (err) {
823
660
  errorCount++;
824
- consecutiveTaskErrors++;
825
- const isBrowserClosed = isBrowserClosedError(err);
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
- consecutive403++;
688
+ const over = bumpErrorCount("http403");
834
689
  console.error(
835
- ` 403 (${consecutive403}/${CFG.consecutive403Blocked})`,
690
+ ` 🚫 HTTP 403(连续第 ${errorCounts.http403} 次)`,
836
691
  );
837
- if (consecutive403 >= CFG.consecutive403Blocked) {
838
- console.error(" 🚫 连续403 → 等2min重启浏览器");
839
- await new Promise((r) => setTimeout(r, 120000));
840
- await session.recycleSession("连续 403 封禁");
841
- consecutive403 = 0;
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
- } else if (isBrowserClosed) {
844
- console.error(" 浏览器断开 → 重启");
845
- browserRestartCount++;
846
- await session.restartBrowser();
847
- } else {
848
- console.error(` ${err.message}`);
706
+ await recyclePage();
707
+ continue;
849
708
  }
850
-
851
- // 连续任务错误达到阈值,汇报服务端 + 触发 daemon fallback 换节点
852
- if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
709
+ console.error(` ${err.message}`);
710
+ const over = bumpErrorCount("other");
711
+ if (over) {
853
712
  console.error(
854
- ` ⚠️ 连续任务错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端 + 触发 fallback`,
713
+ ` ❌ 连续其他错误超过 ${ERROR_LIMITS.other} 次 → 打印并退出`,
855
714
  );
856
- await reportConsecutiveTaskErrors({
857
- post: apiPost,
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
- consecutiveTaskErrors++;
873
- if (isBrowserClosedError(err)) {
874
- browserRestartCount++;
875
- console.error(` ⚠ 浏览器断开 (#${browserRestartCount}) → 重启`);
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
- // 连续轮间错误达到阈值,汇报服务端 + 触发 daemon fallback 换节点
882
- if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
727
+ if (isNetworkError(err)) {
728
+ const over = bumpErrorCount("network");
883
729
  console.error(
884
- ` ⚠️ 连续轮间错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端 + 触发 fallback`,
730
+ ` ⚠️ 网络错误 (${err.message}),15 秒后重试(连续网络错误 ${errorCounts.network} 次)...`,
885
731
  );
886
- await reportConsecutiveTaskErrors({
887
- post: apiPost,
888
- baseUrl: serverUrl,
889
- userId,
890
- message: `连续 ${consecutiveTaskErrors} 个轮次处理失败 (port=${session.port})`,
891
- stage: "comments",
892
- });
893
- consecutiveTaskErrors = 0;
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} 重启=${browserRestartCount}`,
782
+ `\n── 统计 ── 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
906
783
  );
907
- await session.shutdown();
784
+ await closeResources();
908
785
  }
909
786
  }
910
787