tt-help-cli-ycl 1.4.183 → 1.4.184

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.
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getOrCreatePage,
3
3
  isBrowserClosedError,
4
+ isRetryableRestoreFailure,
4
5
  relaunchBrowser,
5
6
  } from "../lib/browser/page.js";
6
7
  import {
@@ -28,7 +29,6 @@ import {
28
29
  import { showResourceUsage } from "../utils/index.js";
29
30
  import {
30
31
  HealthChecker,
31
- MEMORY_THRESHOLD,
32
32
  ROTATE_INTERVAL_MS,
33
33
  } from "../lib/browser/health-checker.js";
34
34
  import {
@@ -37,8 +37,12 @@ import {
37
37
  gotoTiktok,
38
38
  injectPoolAccount,
39
39
  ensurePoolLogin,
40
+ crashBackoffMs,
41
+ retryRestoreOnNavFailure,
40
42
  } from "../lib/login-pool-client.js";
43
+ import { getClientConfig } from "../watchdog/reporter.js";
41
44
  import { createApiClient } from "../lib/api-client.js";
45
+ import { createErrorReporter } from "../lib/error-report.js";
42
46
  import { createCDNHandler } from "../lib/cdn-handler.js";
43
47
  import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
44
48
  import path from "path";
@@ -48,6 +52,7 @@ const MAX_RETRY_WAIT = 5 * 60 * 1000;
48
52
  const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
49
53
  const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
50
54
  const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
55
+ const CRASH_BACKOFF_SLICE_MS = 1000; // 崩溃熔断/恢复重试等待的分片粒度(分片醒检 shuttingDown)
51
56
 
52
57
  // 换号原因受控 code(2026-10-30 定稿:服务端 switchStats 按账号聚合展示;
53
58
  // 只用这组固定值上报,不传自由文本;startup 首次取号不带 reason)
@@ -110,6 +115,21 @@ export async function handleExplore(options) {
110
115
  let poolClient = null; // 账号中心客户端
111
116
  let poolAccount = null; // 当前在手账号(含 cookies 与租约 id)
112
117
  let poolAccountSince = Date.now(); // 当前账号开始使用时刻(48min 换号计时)
118
+ // 崩溃熔断状态(F16 实录):浏览器级注入失败轮数 + 退避窗口截止时刻(窗口内不取号)
119
+ let crashStreak = 0;
120
+ let crashBackoffUntil = 0;
121
+
122
+ /** 分片 sleep:每 CRASH_BACKOFF_SLICE_MS 醒检 shuttingDown,被中断返回 false */
123
+ const sleepSliced = async (totalMs) => {
124
+ let remaining = totalMs;
125
+ while (remaining > 0) {
126
+ if (shuttingDown) return false;
127
+ const slice = Math.min(CRASH_BACKOFF_SLICE_MS, remaining);
128
+ await new Promise((r) => setTimeout(r, slice));
129
+ remaining -= slice;
130
+ }
131
+ return !shuttingDown;
132
+ };
113
133
 
114
134
  const shutdown = async (signal) => {
115
135
  if (shuttingDown) return;
@@ -177,11 +197,14 @@ export async function handleExplore(options) {
177
197
 
178
198
  const poolMode = !exploreNoPool;
179
199
  const poolPort = explorePort || exploreBasePort || 9222;
200
+ // 内存阈值可配置覆盖(~/.tt-help.json memoryThreshold,默认 0.95):F16 类 7G 双浏览器机可调低提前重启
201
+ const memoryThresholdConfig = getClientConfig().memoryThreshold;
180
202
  const healthChecker = poolMode
181
- ? new HealthChecker({ basePort: poolPort, totalAccounts: 1 })
203
+ ? new HealthChecker({ basePort: poolPort, totalAccounts: 1, memoryThreshold: memoryThresholdConfig })
182
204
  : new HealthChecker({
183
205
  basePort: exploreBasePort,
184
206
  totalAccounts: explorePortCount,
207
+ memoryThreshold: memoryThresholdConfig,
185
208
  });
186
209
  let currentAccount = healthChecker.getCurrentAccount();
187
210
 
@@ -246,6 +269,15 @@ export async function handleExplore(options) {
246
269
  meta: { port: cdpOptions.port },
247
270
  });
248
271
 
272
+ // 客户端错误上报封装:自动附带 poolUsername(报错时在手池号)与 proxyNode(当前 clash 节点)
273
+ const errorReporter = createErrorReporter({
274
+ apiPost,
275
+ serverUrl,
276
+ userId,
277
+ proxyUrl: resolvedProxy,
278
+ getState: () => ({ poolUsername: poolAccount?.username || "" }),
279
+ });
280
+
249
281
  await apiGet(`${serverUrl}/api/stats`);
250
282
 
251
283
  if (exploreUsernames && exploreUsernames.length > 0) {
@@ -363,8 +395,16 @@ export async function handleExplore(options) {
363
395
  * @param code 受控换号原因(SWITCH_REASON,上报服务端 switchStats;可选);
364
396
  * 首选按外层 call site 上报,候选注入失败后的再取号 = inject_fail
365
397
  */
366
- async function switchPoolAccount(reason, code) {
398
+ async function switchPoolAccount(reason, code, opts = {}) {
367
399
  console.error(`\n[换号] ${reason},从账号中心取新账号...`);
400
+ // 崩溃熔断:退避窗口内不取号(保护账号池),分片 sleep 响应 shuttingDown
401
+ const backoffWaitMs = Math.max(0, crashBackoffUntil - Date.now());
402
+ if (backoffWaitMs > 0) {
403
+ console.error(
404
+ `[浏览器] 崩溃熔断中,${Math.ceil(backoffWaitMs / 1000)}s 后再试(不取号,保护账号池)`,
405
+ );
406
+ if (!(await sleepSliced(backoffWaitMs))) return false;
407
+ }
368
408
  for (let attempt = 0; attempt < 5; attempt++) {
369
409
  let next = null;
370
410
  try {
@@ -379,6 +419,31 @@ export async function handleExplore(options) {
379
419
  verifyUsername: next.username,
380
420
  }).catch((e) => ({ ok: false, reason: e.message }));
381
421
  if (!r.ok) {
422
+ if (isBrowserClosedError(new Error(r.reason))) {
423
+ // 浏览器级崩溃:不怪账号,立即中止候选循环(不消耗 inject_fail/recover_fail 口径),
424
+ // 已取候选照常 release,转同号重启(与内存超限路径同语义);按轮熔断退避
425
+ crashStreak++;
426
+ const backoffMs = crashBackoffMs(crashStreak);
427
+ crashBackoffUntil = Date.now() + backoffMs;
428
+ console.error(
429
+ `[换号] @${next.username} 注入失败为浏览器级崩溃(${r.reason}),中止候选循环(第 ${crashStreak} 轮,熔断 ${Math.round(backoffMs / 1000)}s),释放候选...`,
430
+ );
431
+ await poolClient.release(next).catch(() => {});
432
+ await errorReporter.report({
433
+ username: poolAccount ? poolAccount.username : "unknown",
434
+ errorType: "account_switch_failed",
435
+ errorMessage: `注入失败为浏览器级崩溃,中止候选循环转同号重启(第${crashStreak}轮,熔断${Math.round(backoffMs / 1000)}s): ${r.reason}`,
436
+ stage: "account-switch",
437
+ }).catch(() => {});
438
+ if (opts.fromRecovery) {
439
+ // 恢复链中(重启后恢复失败触发的换号)不再递归重启,保留现场等熔断窗口
440
+ console.error(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
441
+ return false;
442
+ }
443
+ return await restartPoolBrowser(
444
+ `注入验证遭遇浏览器级崩溃(${r.reason})`,
445
+ );
446
+ }
382
447
  console.error(
383
448
  `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
384
449
  );
@@ -391,6 +456,7 @@ export async function handleExplore(options) {
391
456
  poolAccountSince = Date.now();
392
457
  loggedIn = true;
393
458
  lastFollowSuccessTime = Date.now();
459
+ crashStreak = 0; // 注入验证成功即清零熔断轮数
394
460
  captchaCount = 0;
395
461
  blockedCount = 0;
396
462
  cdn.reset();
@@ -407,8 +473,7 @@ export async function handleExplore(options) {
407
473
  return true;
408
474
  }
409
475
  console.error(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
410
- await apiPost(`${serverUrl}/api/error-report`, {
411
- userId,
476
+ await errorReporter.report({
412
477
  username: poolAccount ? poolAccount.username : "unknown",
413
478
  errorType: "account_switch_failed",
414
479
  errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
@@ -420,21 +485,41 @@ export async function handleExplore(options) {
420
485
  /** 浏览器重建/重启后恢复登录态:磁盘态一致则免注入,否则用内存 cookies 补注入 */
421
486
  async function restorePoolLogin() {
422
487
  if (!poolAccount) return false;
423
- const r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
488
+ let r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
424
489
  ok: false,
425
490
  reason: e.message,
426
491
  }));
492
+ if (!r.ok) {
493
+ // 导航类瞬态失败(net::ERR_* / 超时 / 崩溃)重试后再弃号,重试期间不弃号;
494
+ // 账号级失败(身份不符/会话过期)不重试直接走换号
495
+ r = await retryRestoreOnNavFailure(
496
+ () => ensurePoolLogin(page, poolAccount),
497
+ r.reason,
498
+ {
499
+ isRetryable: isRetryableRestoreFailure,
500
+ sleep: (ms) => sleepSliced(ms),
501
+ shouldAbort: () => shuttingDown,
502
+ },
503
+ );
504
+ if (r.aborted) return false;
505
+ if (!r.ok && r.attempts > 1) {
506
+ console.error(`[登录池] 登录态恢复重试 ${r.attempts} 次后仍失败`);
507
+ }
508
+ }
427
509
  if (r.ok) {
428
510
  loggedIn = true;
511
+ crashStreak = 0; // 恢复成功(含注入验证成功)即清零熔断轮数
429
512
  console.error(
430
513
  `[登录池] 登录态已恢复 @${poolAccount.username}${r.injected ? "(重新注入)" : "(磁盘态一致)"}`,
431
514
  );
432
515
  return true;
433
516
  }
434
517
  console.error(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
518
+ // 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
435
519
  return await switchPoolAccount(
436
520
  `浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
437
521
  SWITCH_REASON.RECOVER_FAIL,
522
+ { fromRecovery: true },
438
523
  );
439
524
  }
440
525
 
@@ -450,9 +535,11 @@ export async function handleExplore(options) {
450
535
  Object.assign(page, rebuiltPage);
451
536
  } catch (e) {
452
537
  console.error(`[浏览器] 重启失败: ${e.message},转入换号流程`);
538
+ // 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
453
539
  return await switchPoolAccount(
454
540
  `浏览器重启失败(${e.message})`,
455
541
  SWITCH_REASON.RECOVER_FAIL,
542
+ { fromRecovery: true },
456
543
  );
457
544
  }
458
545
  return await restorePoolLogin();
@@ -508,8 +595,7 @@ export async function handleExplore(options) {
508
595
  console.error(
509
596
  `[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
510
597
  );
511
- await apiPost(`${serverUrl}/api/error-report`, {
512
- userId,
598
+ await errorReporter.report({
513
599
  username: "account-switch",
514
600
  errorType: "account_switch_failed",
515
601
  errorMessage: `账户切换后导航持续失败: ${err.message}`,
@@ -570,8 +656,7 @@ export async function handleExplore(options) {
570
656
  }),
571
657
  reportError: (result, username) =>
572
658
  withRetry("report cdn-blocked", () =>
573
- apiPost(`${serverUrl}/api/error-report`, {
574
- userId,
659
+ errorReporter.report({
575
660
  username,
576
661
  errorType: "cdn_blocked",
577
662
  errorMessage: result.error || "CDN限流 (Access Denied)",
@@ -666,7 +751,7 @@ export async function handleExplore(options) {
666
751
  // 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号,不换端口)
667
752
  const memRatio = healthChecker.memoryUsage();
668
753
  const elapsedMs = Date.now() - poolAccountSince;
669
- if (memRatio >= MEMORY_THRESHOLD) {
754
+ if (memRatio >= healthChecker.memoryThreshold) {
670
755
  await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
671
756
  } else if (elapsedMs >= ROTATE_INTERVAL_MS) {
672
757
  await handleAccountSwitch(
@@ -716,8 +801,7 @@ export async function handleExplore(options) {
716
801
 
717
802
  // 上报
718
803
  await withRetry("report captcha", () =>
719
- apiPost(`${serverUrl}/api/error-report`, {
720
- userId,
804
+ errorReporter.report({
721
805
  username: "unknown",
722
806
  errorType: "captcha",
723
807
  errorMessage: resolved
@@ -920,8 +1004,7 @@ export async function handleExplore(options) {
920
1004
  ? "network"
921
1005
  : "other";
922
1006
  await withRetry("report error", () =>
923
- apiPost(`${serverUrl}/api/error-report`, {
924
- userId,
1007
+ errorReporter.report({
925
1008
  username,
926
1009
  errorType,
927
1010
  errorMessage: result.error,
@@ -956,8 +1039,7 @@ export async function handleExplore(options) {
956
1039
  console.error(` [验证码] 累计 ${captchaCount} 次`);
957
1040
 
958
1041
  await withRetry("report captcha", () =>
959
- apiPost(`${serverUrl}/api/error-report`, {
960
- userId,
1042
+ errorReporter.report({
961
1043
  username,
962
1044
  errorType: "captcha",
963
1045
  errorMessage: result.captchaMessage || "页面出现验证码",
package/src/cli/search.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  getOrCreatePage,
27
27
  safeCheckLogin,
28
28
  isBrowserClosedError,
29
+ isRetryableRestoreFailure,
29
30
  relaunchBrowser,
30
31
  } from "../lib/browser/page.js";
31
32
  import { collectSearchAuthors } from "../lib/search-collector.js";
@@ -39,16 +40,19 @@ import {
39
40
  closeCaptcha,
40
41
  } from "../scraper/modules/captcha-handler.js";
41
42
  import { createApiClient } from "../lib/api-client.js";
43
+ import { createErrorReporter } from "../lib/error-report.js";
42
44
  import {
43
45
  HealthChecker,
44
- MEMORY_THRESHOLD,
45
46
  ROTATE_INTERVAL_MS,
46
47
  } from "../lib/browser/health-checker.js";
47
48
  import {
48
49
  createLoginPoolClient,
49
50
  injectPoolAccount,
50
51
  ensurePoolLogin,
52
+ crashBackoffMs,
53
+ retryRestoreOnNavFailure,
51
54
  } from "../lib/login-pool-client.js";
55
+ import { getClientConfig } from "../watchdog/reporter.js";
52
56
  import {
53
57
  server as cfgServer,
54
58
  proxy as configuredProxy,
@@ -62,6 +66,7 @@ const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
62
66
  const PAGE_GOTO_TIMEOUT = 60000;
63
67
  const MAX_RETRY_WAIT = 5 * 60 * 1000;
64
68
  const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
69
+ const CRASH_BACKOFF_SLICE_MS = 1000; // 崩溃熔断/恢复重试等待的分片粒度(分片醒检 shuttingDown)
65
70
 
66
71
  async function withRetry(label, fn) {
67
72
  let backoff = 1000;
@@ -115,11 +120,14 @@ export async function handleSearch(parsed) {
115
120
  // ── 初始化 ──
116
121
  const baseUrl = serverUrl || DEFAULT_SERVER;
117
122
  const poolMode = !searchNoPool;
123
+ // 内存阈值可配置覆盖(~/.tt-help.json memoryThreshold,默认 0.95):F16 类 7G 双浏览器机可调低提前重启
124
+ const memoryThresholdConfig = getClientConfig().memoryThreshold;
118
125
  const healthChecker = poolMode
119
- ? new HealthChecker({ basePort, totalAccounts: 1 })
126
+ ? new HealthChecker({ basePort, totalAccounts: 1, memoryThreshold: memoryThresholdConfig })
120
127
  : new HealthChecker({
121
128
  basePort,
122
129
  totalAccounts: portCount,
130
+ memoryThreshold: memoryThresholdConfig,
123
131
  });
124
132
  let currentAccount = healthChecker.getCurrentAccount();
125
133
 
@@ -136,6 +144,21 @@ export async function handleSearch(parsed) {
136
144
  let poolClient = null;
137
145
  let poolAccount = null;
138
146
  let poolAccountSince = Date.now();
147
+ // 崩溃熔断状态(F16 实录):浏览器级注入失败轮数 + 退避窗口截止时刻(窗口内不取号)
148
+ let crashStreak = 0;
149
+ let crashBackoffUntil = 0;
150
+
151
+ /** 分片 sleep:每 CRASH_BACKOFF_SLICE_MS 醒检 shuttingDown,被中断返回 false */
152
+ const sleepSliced = async (totalMs) => {
153
+ let remaining = totalMs;
154
+ while (remaining > 0) {
155
+ if (shuttingDown) return false;
156
+ const slice = Math.min(CRASH_BACKOFF_SLICE_MS, remaining);
157
+ await new Promise((r) => setTimeout(r, slice));
158
+ remaining -= slice;
159
+ }
160
+ return !shuttingDown;
161
+ };
139
162
 
140
163
  const cleanup = () => {
141
164
  if (shuttingDown) return;
@@ -167,6 +190,15 @@ export async function handleSearch(parsed) {
167
190
  }
168
191
  log(`[Search] 用户编号: ${userId}`);
169
192
 
193
+ // 客户端错误上报封装:自动附带 poolUsername(报错时在手池号)与 proxyNode(当前 clash 节点)
194
+ const errorReporter = createErrorReporter({
195
+ apiPost,
196
+ serverUrl: baseUrl,
197
+ userId,
198
+ proxyUrl: resolvedProxy,
199
+ getState: () => ({ poolUsername: poolAccount?.username || "" }),
200
+ });
201
+
170
202
  setDelayConfig("normal");
171
203
 
172
204
  try {
@@ -378,8 +410,7 @@ export async function handleSearch(parsed) {
378
410
  log(
379
411
  `[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
380
412
  );
381
- await apiPost(`${baseUrl}/api/error-report`, {
382
- userId,
413
+ await errorReporter.report({
383
414
  username: "account-switch",
384
415
  errorType: "account_switch_failed",
385
416
  errorMessage: `账户切换后导航持续失败: ${err.message}`,
@@ -435,8 +466,16 @@ export async function handleSearch(parsed) {
435
466
 
436
467
  // ── 登录池模式辅助 ──
437
468
 
438
- async function switchPoolAccount(reason) {
469
+ async function switchPoolAccount(reason, opts = {}) {
439
470
  log(`\n[换号] ${reason},从账号中心取新账号...`);
471
+ // 崩溃熔断:退避窗口内不取号(保护账号池),分片 sleep 响应 shuttingDown
472
+ const backoffWaitMs = Math.max(0, crashBackoffUntil - Date.now());
473
+ if (backoffWaitMs > 0) {
474
+ log(
475
+ `[浏览器] 崩溃熔断中,${Math.ceil(backoffWaitMs / 1000)}s 后再试(不取号,保护账号池)`,
476
+ );
477
+ if (!(await sleepSliced(backoffWaitMs))) return false;
478
+ }
440
479
  for (let attempt = 0; attempt < 5; attempt++) {
441
480
  let next = null;
442
481
  try {
@@ -451,7 +490,34 @@ export async function handleSearch(parsed) {
451
490
  verifyUsername: next.username,
452
491
  }).catch((e) => ({ ok: false, reason: e.message }));
453
492
  if (!r.ok) {
454
- log(`[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`);
493
+ if (isBrowserClosedError(new Error(r.reason))) {
494
+ // 浏览器级崩溃:不怪账号,立即中止候选循环(不消耗 inject_fail 口径),
495
+ // 已取候选照常 release,转同号重启(与内存超限路径同语义);按轮熔断退避
496
+ crashStreak++;
497
+ const backoffMs = crashBackoffMs(crashStreak);
498
+ crashBackoffUntil = Date.now() + backoffMs;
499
+ log(
500
+ `[换号] @${next.username} 注入失败为浏览器级崩溃(${r.reason}),中止候选循环(第 ${crashStreak} 轮,熔断 ${Math.round(backoffMs / 1000)}s),释放候选...`,
501
+ );
502
+ await poolClient.release(next).catch(() => {});
503
+ await errorReporter.report({
504
+ username: poolAccount ? poolAccount.username : "unknown",
505
+ errorType: "account_switch_failed",
506
+ errorMessage: `注入失败为浏览器级崩溃,中止候选循环转同号重启(第${crashStreak}轮,熔断${Math.round(backoffMs / 1000)}s): ${r.reason}`,
507
+ stage: "account-switch",
508
+ }).catch(() => {});
509
+ if (opts.fromRecovery) {
510
+ // 恢复链中(重启后恢复失败触发的换号)不再递归重启,保留现场等熔断窗口
511
+ log(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
512
+ return false;
513
+ }
514
+ return await restartPoolBrowser(
515
+ `注入验证遭遇浏览器级崩溃(${r.reason})`,
516
+ );
517
+ }
518
+ log(
519
+ `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
520
+ );
455
521
  await poolClient.release(next).catch(() => {});
456
522
  continue;
457
523
  }
@@ -459,6 +525,7 @@ export async function handleSearch(parsed) {
459
525
  poolAccount = next;
460
526
  poolAccountSince = Date.now();
461
527
  loggedIn = true;
528
+ crashStreak = 0; // 注入验证成功即清零熔断轮数
462
529
  captchaCount = 0;
463
530
  staleRounds = 0;
464
531
  consecutiveNetworkErrors = 0;
@@ -472,8 +539,7 @@ export async function handleSearch(parsed) {
472
539
  return true;
473
540
  }
474
541
  log(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
475
- await apiPost(`${baseUrl}/api/error-report`, {
476
- userId,
542
+ await errorReporter.report({
477
543
  username: poolAccount ? poolAccount.username : "unknown",
478
544
  errorType: "account_switch_failed",
479
545
  errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
@@ -485,17 +551,39 @@ export async function handleSearch(parsed) {
485
551
  /** 浏览器重建后恢复在手账号:磁盘态一致则免注入,否则用内存 cookies 补注入 */
486
552
  async function restorePoolLogin() {
487
553
  if (!poolAccount) return false;
488
- const r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
554
+ let r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
489
555
  ok: false,
490
556
  reason: e.message,
491
557
  }));
558
+ if (!r.ok) {
559
+ // 导航类瞬态失败(net::ERR_* / 超时 / 崩溃)重试后再弃号,重试期间不弃号;
560
+ // 账号级失败(身份不符/会话过期)不重试直接走换号
561
+ r = await retryRestoreOnNavFailure(
562
+ () => ensurePoolLogin(page, poolAccount),
563
+ r.reason,
564
+ {
565
+ isRetryable: isRetryableRestoreFailure,
566
+ sleep: (ms) => sleepSliced(ms),
567
+ shouldAbort: () => shuttingDown,
568
+ },
569
+ );
570
+ if (r.aborted) return false;
571
+ if (!r.ok && r.attempts > 1) {
572
+ log(`[登录池] 登录态恢复重试 ${r.attempts} 次后仍失败`);
573
+ }
574
+ }
492
575
  if (r.ok) {
493
576
  loggedIn = true;
577
+ crashStreak = 0; // 恢复成功(含注入验证成功)即清零熔断轮数
494
578
  log(`[登录池] 登录态已恢复 @${poolAccount.username}`);
495
579
  return true;
496
580
  }
497
581
  log(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
498
- return await switchPoolAccount(`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`);
582
+ // 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
583
+ return await switchPoolAccount(
584
+ `浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
585
+ { fromRecovery: true },
586
+ );
499
587
  }
500
588
 
501
589
  /** 内存超限:同号重启浏览器(不换号——内存是浏览器进程问题,不是账号问题) */
@@ -509,7 +597,10 @@ export async function handleSearch(parsed) {
509
597
  page = await setupNewPage(browser);
510
598
  } catch (e) {
511
599
  log(`[浏览器] 重启失败: ${e.message},转入换号流程`);
512
- return await switchPoolAccount(`浏览器重启失败(${e.message})`);
600
+ // 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
601
+ return await switchPoolAccount(`浏览器重启失败(${e.message})`, {
602
+ fromRecovery: true,
603
+ });
513
604
  }
514
605
  return await restorePoolLogin();
515
606
  }
@@ -540,7 +631,7 @@ export async function handleSearch(parsed) {
540
631
  // 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号)
541
632
  const memRatio = healthChecker.memoryUsage();
542
633
  const elapsedMs = Date.now() - poolAccountSince;
543
- if (memRatio >= MEMORY_THRESHOLD) {
634
+ if (memRatio >= healthChecker.memoryThreshold) {
544
635
  await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
545
636
  } else if (elapsedMs >= ROTATE_INTERVAL_MS) {
546
637
  await handleAccountSwitch(
@@ -582,7 +673,7 @@ export async function handleSearch(parsed) {
582
673
 
583
674
  // 上报
584
675
  await withRetry("report captcha", () =>
585
- apiPost(`${baseUrl}/api/error-report`, {
676
+ errorReporter.report({
586
677
  userId: clientId,
587
678
  username: "unknown",
588
679
  errorType: "captcha",
@@ -739,7 +830,7 @@ export async function handleSearch(parsed) {
739
830
 
740
831
  const errorType = consecutiveNetworkErrors > 1 ? "network" : "other";
741
832
  await withRetry("report error", () =>
742
- apiPost(`${baseUrl}/api/error-report`, {
833
+ errorReporter.report({
743
834
  userId: clientId,
744
835
  username: tag,
745
836
  errorType,
@@ -772,7 +863,7 @@ export async function handleSearch(parsed) {
772
863
  log(` [验证码] 搜索后检测到验证码,累计 ${captchaCount} 次`);
773
864
 
774
865
  await withRetry("report captcha", () =>
775
- apiPost(`${baseUrl}/api/error-report`, {
866
+ errorReporter.report({
776
867
  userId: clientId,
777
868
  username: tag,
778
869
  errorType: "captcha",
@@ -5,12 +5,28 @@ import { execSync } from "child_process";
5
5
  const DEFAULT_BASE_PORT = 9222;
6
6
  const DEFAULT_TOTAL_ACCOUNTS = 10;
7
7
  const MEMORY_THRESHOLD = 0.95;
8
+ // 配置覆盖(~/.tt-help.json memoryThreshold)钳制边界:过低误触发频繁重启,过高失去保护意义
9
+ const MEMORY_THRESHOLD_MIN = 0.5;
10
+ const MEMORY_THRESHOLD_MAX = 0.98;
8
11
  const ROTATE_INTERVAL_MS = 0.8 * 60 * 60 * 1000;
9
12
 
13
+ /**
14
+ * 内存阈值解析:缺省/非法(null/undefined/空串/非数值)回退默认 0.95,
15
+ * 合法值钳制 [0.5, 0.98]。纯函数,供 HealthChecker 构造与单测使用。
16
+ */
17
+ function resolveMemoryThreshold(raw) {
18
+ if (raw === null || raw === undefined || raw === "") return MEMORY_THRESHOLD;
19
+ const n = Number(raw);
20
+ if (!Number.isFinite(n)) return MEMORY_THRESHOLD;
21
+ return Math.min(MEMORY_THRESHOLD_MAX, Math.max(MEMORY_THRESHOLD_MIN, n));
22
+ }
23
+
10
24
  class HealthChecker {
11
25
  constructor(options = {}) {
12
26
  const basePort = options.basePort ?? DEFAULT_BASE_PORT;
13
27
  const totalAccounts = options.totalAccounts ?? DEFAULT_TOTAL_ACCOUNTS;
28
+ // 内存阈值可配置覆盖(默认 0.95):F16 类 7G 双浏览器机可调低提前重启(explore/search 构造时传入)
29
+ this.memoryThreshold = resolveMemoryThreshold(options.memoryThreshold);
14
30
 
15
31
  this.accounts = [];
16
32
  for (let i = 0; i < totalAccounts; i++) {
@@ -83,7 +99,7 @@ class HealthChecker {
83
99
  const memUsage = this.memoryUsage();
84
100
  const memPercent = (memUsage * 100).toFixed(1);
85
101
 
86
- if (memUsage >= MEMORY_THRESHOLD) {
102
+ if (memUsage >= this.memoryThreshold) {
87
103
  const currentPort = this.accounts[this.currentIndex].port;
88
104
  return {
89
105
  shouldSwitch: true,
@@ -104,7 +120,7 @@ class HealthChecker {
104
120
  // 计算预计切换时间
105
121
  const remainingMs = ROTATE_INTERVAL_MS - elapsed;
106
122
  const remainingMins = Math.round(remainingMs / 60000);
107
- const memHeadroom = ((MEMORY_THRESHOLD - memUsage) * 100).toFixed(1);
123
+ const memHeadroom = ((this.memoryThreshold - memUsage) * 100).toFixed(1);
108
124
  const currentPort = this.accounts[this.currentIndex].port;
109
125
 
110
126
  return {
@@ -114,4 +130,4 @@ class HealthChecker {
114
130
  }
115
131
  }
116
132
 
117
- export { HealthChecker, MEMORY_THRESHOLD, ROTATE_INTERVAL_MS };
133
+ export { HealthChecker, MEMORY_THRESHOLD, MEMORY_THRESHOLD_MIN, MEMORY_THRESHOLD_MAX, resolveMemoryThreshold, ROTATE_INTERVAL_MS };
@@ -15,12 +15,20 @@ const DEFAULT_USER_DATA_DIR = path.join(
15
15
  const BROWSER_CLOSED_PATTERNS = [
16
16
  "Target page, context or browser has been closed",
17
17
  "Target closed",
18
+ "Target crashed",
19
+ "Page crashed",
18
20
  "Browser has been closed",
19
21
  "Protocol error",
20
22
  "Connection closed",
21
23
  "net::ERR_CONNECTION",
22
24
  ];
23
25
 
26
+ /**
27
+ * 判定浏览器级故障:浏览器关闭**或渲染进程崩溃**。
28
+ * 渲染崩溃(Target/Target crashed / Page crashed)发生时 CDP 连接往往仍存活,
29
+ * 但页面已死,须与连接断开同语义处理:重建浏览器 + 重试。
30
+ * (F16 实录:崩溃曾被误判为「账号注入失败」驱动换号循环,单日烧 28 个账号)
31
+ */
24
32
  export function isBrowserClosedError(error) {
25
33
  if (!error) return false;
26
34
  const msg = error.message || error.toString() || "";
@@ -71,6 +79,21 @@ const DOM_CHECK_RETRIES = 3; // DOM 检测最大重试次数
71
79
  const SAFE_CHECK_ROUNDS = 2; // 安全登录检测轮数(首次检测 + 额外1轮确认)
72
80
  const SAFE_CHECK_INTERVAL = 5000; // 安全登录检测每轮间隔 5 秒
73
81
 
82
+ /**
83
+ * 登录态恢复失败是否属导航类瞬态失败(可重试,重试期间不弃号):
84
+ * - net::ERR_*(含 ERR_HTTP_RESPONSE_CODE_FAILURE / ERR_CONNECTION_* 等导航层错误)
85
+ * - 超时(Timeout ... exceeded / timed out)
86
+ * - 浏览器级关闭或渲染崩溃(isBrowserClosedError)
87
+ * 账号级失败(身份不符/会话过期/无账号数据等)不可重试,直接走换号流程。
88
+ */
89
+ export function isRetryableRestoreFailure(reason) {
90
+ if (!reason) return false;
91
+ const msg = String(reason);
92
+ if (msg.includes("net::ERR_")) return true;
93
+ if (/timed?\s*out|timeout/i.test(msg)) return true;
94
+ return isBrowserClosedError(new Error(msg));
95
+ }
96
+
74
97
  /**
75
98
  * 判断登录状态:Cookie 为主,DOM 验真为辅。
76
99
  * - 无 sessionid Cookie → 未登录