tt-help-cli-ycl 1.4.183 → 1.4.185

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,16 @@ import {
37
37
  gotoTiktok,
38
38
  injectPoolAccount,
39
39
  ensurePoolLogin,
40
+ crashBackoffMs,
41
+ retryRestoreOnNavFailure,
42
+ CANDIDATE_INJECT_MAX_RETRIES,
43
+ CANDIDATE_INJECT_DELAY_MS,
44
+ decideCandidateInjectAction,
45
+ isRetryableInjectFailure,
40
46
  } from "../lib/login-pool-client.js";
47
+ import { getClientConfig } from "../watchdog/reporter.js";
41
48
  import { createApiClient } from "../lib/api-client.js";
49
+ import { createErrorReporter } from "../lib/error-report.js";
42
50
  import { createCDNHandler } from "../lib/cdn-handler.js";
43
51
  import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
44
52
  import path from "path";
@@ -48,6 +56,7 @@ const MAX_RETRY_WAIT = 5 * 60 * 1000;
48
56
  const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
49
57
  const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
50
58
  const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
59
+ const CRASH_BACKOFF_SLICE_MS = 1000; // 崩溃熔断/恢复重试等待的分片粒度(分片醒检 shuttingDown)
51
60
 
52
61
  // 换号原因受控 code(2026-10-30 定稿:服务端 switchStats 按账号聚合展示;
53
62
  // 只用这组固定值上报,不传自由文本;startup 首次取号不带 reason)
@@ -110,6 +119,21 @@ export async function handleExplore(options) {
110
119
  let poolClient = null; // 账号中心客户端
111
120
  let poolAccount = null; // 当前在手账号(含 cookies 与租约 id)
112
121
  let poolAccountSince = Date.now(); // 当前账号开始使用时刻(48min 换号计时)
122
+ // 崩溃熔断状态(F16 实录):浏览器级注入失败轮数 + 退避窗口截止时刻(窗口内不取号)
123
+ let crashStreak = 0;
124
+ let crashBackoffUntil = 0;
125
+
126
+ /** 分片 sleep:每 CRASH_BACKOFF_SLICE_MS 醒检 shuttingDown,被中断返回 false */
127
+ const sleepSliced = async (totalMs) => {
128
+ let remaining = totalMs;
129
+ while (remaining > 0) {
130
+ if (shuttingDown) return false;
131
+ const slice = Math.min(CRASH_BACKOFF_SLICE_MS, remaining);
132
+ await new Promise((r) => setTimeout(r, slice));
133
+ remaining -= slice;
134
+ }
135
+ return !shuttingDown;
136
+ };
113
137
 
114
138
  const shutdown = async (signal) => {
115
139
  if (shuttingDown) return;
@@ -177,11 +201,14 @@ export async function handleExplore(options) {
177
201
 
178
202
  const poolMode = !exploreNoPool;
179
203
  const poolPort = explorePort || exploreBasePort || 9222;
204
+ // 内存阈值可配置覆盖(~/.tt-help.json memoryThreshold,默认 0.95):F16 类 7G 双浏览器机可调低提前重启
205
+ const memoryThresholdConfig = getClientConfig().memoryThreshold;
180
206
  const healthChecker = poolMode
181
- ? new HealthChecker({ basePort: poolPort, totalAccounts: 1 })
207
+ ? new HealthChecker({ basePort: poolPort, totalAccounts: 1, memoryThreshold: memoryThresholdConfig })
182
208
  : new HealthChecker({
183
209
  basePort: exploreBasePort,
184
210
  totalAccounts: explorePortCount,
211
+ memoryThreshold: memoryThresholdConfig,
185
212
  });
186
213
  let currentAccount = healthChecker.getCurrentAccount();
187
214
 
@@ -246,6 +273,15 @@ export async function handleExplore(options) {
246
273
  meta: { port: cdpOptions.port },
247
274
  });
248
275
 
276
+ // 客户端错误上报封装:自动附带 poolUsername(报错时在手池号)与 proxyNode(当前 clash 节点)
277
+ const errorReporter = createErrorReporter({
278
+ apiPost,
279
+ serverUrl,
280
+ userId,
281
+ proxyUrl: resolvedProxy,
282
+ getState: () => ({ poolUsername: poolAccount?.username || "" }),
283
+ });
284
+
249
285
  await apiGet(`${serverUrl}/api/stats`);
250
286
 
251
287
  if (exploreUsernames && exploreUsernames.length > 0) {
@@ -358,13 +394,50 @@ export async function handleExplore(options) {
358
394
 
359
395
  /**
360
396
  * 换号(以号为主键):先取新号(旧号仍在手,无空窗)→ 注入验证 → 还旧号。
361
- * 候选最多试 5 个(注入失败的释放回池);池空/服务器不可达 → 保旧号返回 false,
362
- * 由主循环「无在手账号」补取逻辑兑底。
397
+ * 候选最多试 5 个;注入失败按类型分流(decideCandidateInjectAction):
398
+ * 浏览器级崩溃 → 熔断重启;确定性号死(会话过期/身份不符)→ 立即释放回池换下一个;
399
+ * 瞬态/环境失败 → 同一候选原号重试(共 3 次尝试,间隔 15s)全败才释放;
400
+ * 池空/服务器不可达 → 保旧号返回 false,由主循环「无在手账号」补取逻辑兑底。
363
401
  * @param code 受控换号原因(SWITCH_REASON,上报服务端 switchStats;可选);
364
402
  * 首选按外层 call site 上报,候选注入失败后的再取号 = inject_fail
365
403
  */
366
- async function switchPoolAccount(reason, code) {
404
+ async function switchPoolAccount(reason, code, opts = {}) {
367
405
  console.error(`\n[换号] ${reason},从账号中心取新账号...`);
406
+ // 崩溃熔断:退避窗口内不取号(保护账号池),分片 sleep 响应 shuttingDown
407
+ const backoffWaitMs = Math.max(0, crashBackoffUntil - Date.now());
408
+ if (backoffWaitMs > 0) {
409
+ console.error(
410
+ `[浏览器] 崩溃熔断中,${Math.ceil(backoffWaitMs / 1000)}s 后再试(不取号,保护账号池)`,
411
+ );
412
+ if (!(await sleepSliced(backoffWaitMs))) return false;
413
+ }
414
+ // 候选注入失败命中浏览器级崩溃签名时的处置(首判与重试中途重判共用):
415
+ // crashStreak++ 按轮退避 + 释放候选 + 上报 + 转同号重启;返回值由调用方直接 return
416
+ async function abortCandidateLoopOnCrash(crashReason, candidate, fromRecovery) {
417
+ // 浏览器级崩溃:不怪账号,立即中止候选循环(不消耗 inject_fail/recover_fail 口径),
418
+ // 已取候选照常 release,转同号重启(与内存超限路径同语义);按轮熔断退避
419
+ crashStreak++;
420
+ const backoffMs = crashBackoffMs(crashStreak);
421
+ crashBackoffUntil = Date.now() + backoffMs;
422
+ console.error(
423
+ `[换号] @${candidate.username} 注入失败为浏览器级崩溃(${crashReason}),中止候选循环(第 ${crashStreak} 轮,熔断 ${Math.round(backoffMs / 1000)}s),释放候选...`,
424
+ );
425
+ await poolClient.release(candidate).catch(() => {});
426
+ await errorReporter.report({
427
+ username: poolAccount ? poolAccount.username : "unknown",
428
+ errorType: "account_switch_failed",
429
+ errorMessage: `注入失败为浏览器级崩溃,中止候选循环转同号重启(第${crashStreak}轮,熔断${Math.round(backoffMs / 1000)}s): ${crashReason}`,
430
+ stage: "account-switch",
431
+ }).catch(() => {});
432
+ if (fromRecovery) {
433
+ // 恢复链中(重启后恢复失败触发的换号)不再递归重启,保留现场等熔断窗口
434
+ console.error(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
435
+ return false;
436
+ }
437
+ return await restartPoolBrowser(
438
+ `注入验证遭遇浏览器级崩溃(${crashReason})`,
439
+ );
440
+ }
368
441
  for (let attempt = 0; attempt < 5; attempt++) {
369
442
  let next = null;
370
443
  try {
@@ -375,15 +448,71 @@ export async function handleExplore(options) {
375
448
  );
376
449
  return false;
377
450
  }
378
- const r = await injectPoolAccount(page, next, {
451
+ let r = await injectPoolAccount(page, next, {
379
452
  verifyUsername: next.username,
380
453
  }).catch((e) => ({ ok: false, reason: e.message }));
381
454
  if (!r.ok) {
382
- console.error(
383
- `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
384
- );
385
- await poolClient.release(next).catch(() => {});
386
- continue;
455
+ // 注入失败三路分流(f11 48h 实测:~17% 瞬态环境失败曾连烧 5 候选全死于同一环境):
456
+ // 崩溃 → 既有熔断(中止候选循环);确定性号死 → 立即弃号换下一个候选(现状行为);
457
+ // 瞬态/环境失败 → 同一候选原号重试(共 3 次尝试,间隔 15s),重试期间租约保持在手
458
+ let action = decideCandidateInjectAction(r.reason, isBrowserClosedError);
459
+ if (action === "circuit-break") {
460
+ return await abortCandidateLoopOnCrash(r.reason, next, opts.fromRecovery);
461
+ }
462
+ if (action === "retry") {
463
+ // 瞬态/环境失败(超时/AbortError/net::ERR_*/Execution context destroyed/无账号数据/导航类):
464
+ // 原号重试最多 2 次(共 3 次尝试),间隔 15s,重试期间不 release(租约保持在手);
465
+ // 确定性号死(会话过期/身份不符)不进此分支,直接弃号换下一个候选
466
+ r = await retryRestoreOnNavFailure(
467
+ () =>
468
+ injectPoolAccount(page, next, { verifyUsername: next.username }),
469
+ r.reason,
470
+ {
471
+ isRetryable: isRetryableInjectFailure,
472
+ maxRetries: CANDIDATE_INJECT_MAX_RETRIES,
473
+ delayMs: CANDIDATE_INJECT_DELAY_MS,
474
+ sleep: (ms) => sleepSliced(ms),
475
+ shouldAbort: () => shuttingDown,
476
+ onRetry: (n, delayMs, why) =>
477
+ console.error(
478
+ `[换号] @${next.username} 第${n}次注入失败(瞬态,${why}),${Math.round(delayMs / 1000)}s 后原号重试`,
479
+ ),
480
+ },
481
+ );
482
+ if (r.aborted) {
483
+ // shuttingDown 中断:释放候选退出(租约主动还干净,服务端 TTL 兑底)
484
+ console.error(`[换号] @${next.username} 原号重试等待中被关闭信号中断,释放候选`);
485
+ await poolClient.release(next).catch(() => {});
486
+ return false;
487
+ }
488
+ if (r.ok && r.attempts > 1) {
489
+ console.error(
490
+ `[换号] @${next.username} 原号重试成功(第 ${r.attempts} 次尝试)`,
491
+ );
492
+ }
493
+ if (!r.ok) {
494
+ // 重试中途失败签名可能升级为浏览器级崩溃(如首败 timeout、再败 Page crashed):
495
+ // 对最终原因重判,命中熔断则中止候选循环(不弃号给下一候选——坏浏览器再吃一个号),
496
+ // 升级为确定性号死则 fall through 到下方弃号分支换候选
497
+ action = decideCandidateInjectAction(r.reason, isBrowserClosedError);
498
+ if (action === "circuit-break") {
499
+ return await abortCandidateLoopOnCrash(r.reason, next, opts.fromRecovery);
500
+ }
501
+ }
502
+ }
503
+ if (!r.ok) {
504
+ if (r.attempts > 1) {
505
+ console.error(
506
+ `[换号] @${next.username} 原号重试 ${r.attempts - 1} 次后仍失败(共 ${r.attempts} 次尝试,${r.reason}),释放候选换下一个...`,
507
+ );
508
+ } else {
509
+ console.error(
510
+ `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
511
+ );
512
+ }
513
+ await poolClient.release(next).catch(() => {});
514
+ continue;
515
+ }
387
516
  }
388
517
  // 新号验证通过:切换在手状态,再还旧号(谁持账号谁还)
389
518
  const prev = poolAccount;
@@ -391,6 +520,7 @@ export async function handleExplore(options) {
391
520
  poolAccountSince = Date.now();
392
521
  loggedIn = true;
393
522
  lastFollowSuccessTime = Date.now();
523
+ crashStreak = 0; // 注入验证成功即清零熔断轮数
394
524
  captchaCount = 0;
395
525
  blockedCount = 0;
396
526
  cdn.reset();
@@ -407,8 +537,7 @@ export async function handleExplore(options) {
407
537
  return true;
408
538
  }
409
539
  console.error(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
410
- await apiPost(`${serverUrl}/api/error-report`, {
411
- userId,
540
+ await errorReporter.report({
412
541
  username: poolAccount ? poolAccount.username : "unknown",
413
542
  errorType: "account_switch_failed",
414
543
  errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
@@ -420,21 +549,41 @@ export async function handleExplore(options) {
420
549
  /** 浏览器重建/重启后恢复登录态:磁盘态一致则免注入,否则用内存 cookies 补注入 */
421
550
  async function restorePoolLogin() {
422
551
  if (!poolAccount) return false;
423
- const r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
552
+ let r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
424
553
  ok: false,
425
554
  reason: e.message,
426
555
  }));
556
+ if (!r.ok) {
557
+ // 导航类瞬态失败(net::ERR_* / 超时 / 崩溃)重试后再弃号,重试期间不弃号;
558
+ // 账号级失败(身份不符/会话过期)不重试直接走换号
559
+ r = await retryRestoreOnNavFailure(
560
+ () => ensurePoolLogin(page, poolAccount),
561
+ r.reason,
562
+ {
563
+ isRetryable: isRetryableRestoreFailure,
564
+ sleep: (ms) => sleepSliced(ms),
565
+ shouldAbort: () => shuttingDown,
566
+ },
567
+ );
568
+ if (r.aborted) return false;
569
+ if (!r.ok && r.attempts > 1) {
570
+ console.error(`[登录池] 登录态恢复重试 ${r.attempts} 次后仍失败`);
571
+ }
572
+ }
427
573
  if (r.ok) {
428
574
  loggedIn = true;
575
+ crashStreak = 0; // 恢复成功(含注入验证成功)即清零熔断轮数
429
576
  console.error(
430
577
  `[登录池] 登录态已恢复 @${poolAccount.username}${r.injected ? "(重新注入)" : "(磁盘态一致)"}`,
431
578
  );
432
579
  return true;
433
580
  }
434
581
  console.error(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
582
+ // 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
435
583
  return await switchPoolAccount(
436
584
  `浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
437
585
  SWITCH_REASON.RECOVER_FAIL,
586
+ { fromRecovery: true },
438
587
  );
439
588
  }
440
589
 
@@ -450,9 +599,11 @@ export async function handleExplore(options) {
450
599
  Object.assign(page, rebuiltPage);
451
600
  } catch (e) {
452
601
  console.error(`[浏览器] 重启失败: ${e.message},转入换号流程`);
602
+ // 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
453
603
  return await switchPoolAccount(
454
604
  `浏览器重启失败(${e.message})`,
455
605
  SWITCH_REASON.RECOVER_FAIL,
606
+ { fromRecovery: true },
456
607
  );
457
608
  }
458
609
  return await restorePoolLogin();
@@ -508,8 +659,7 @@ export async function handleExplore(options) {
508
659
  console.error(
509
660
  `[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
510
661
  );
511
- await apiPost(`${serverUrl}/api/error-report`, {
512
- userId,
662
+ await errorReporter.report({
513
663
  username: "account-switch",
514
664
  errorType: "account_switch_failed",
515
665
  errorMessage: `账户切换后导航持续失败: ${err.message}`,
@@ -570,8 +720,7 @@ export async function handleExplore(options) {
570
720
  }),
571
721
  reportError: (result, username) =>
572
722
  withRetry("report cdn-blocked", () =>
573
- apiPost(`${serverUrl}/api/error-report`, {
574
- userId,
723
+ errorReporter.report({
575
724
  username,
576
725
  errorType: "cdn_blocked",
577
726
  errorMessage: result.error || "CDN限流 (Access Denied)",
@@ -666,7 +815,7 @@ export async function handleExplore(options) {
666
815
  // 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号,不换端口)
667
816
  const memRatio = healthChecker.memoryUsage();
668
817
  const elapsedMs = Date.now() - poolAccountSince;
669
- if (memRatio >= MEMORY_THRESHOLD) {
818
+ if (memRatio >= healthChecker.memoryThreshold) {
670
819
  await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
671
820
  } else if (elapsedMs >= ROTATE_INTERVAL_MS) {
672
821
  await handleAccountSwitch(
@@ -716,8 +865,7 @@ export async function handleExplore(options) {
716
865
 
717
866
  // 上报
718
867
  await withRetry("report captcha", () =>
719
- apiPost(`${serverUrl}/api/error-report`, {
720
- userId,
868
+ errorReporter.report({
721
869
  username: "unknown",
722
870
  errorType: "captcha",
723
871
  errorMessage: resolved
@@ -920,8 +1068,7 @@ export async function handleExplore(options) {
920
1068
  ? "network"
921
1069
  : "other";
922
1070
  await withRetry("report error", () =>
923
- apiPost(`${serverUrl}/api/error-report`, {
924
- userId,
1071
+ errorReporter.report({
925
1072
  username,
926
1073
  errorType,
927
1074
  errorMessage: result.error,
@@ -956,8 +1103,7 @@ export async function handleExplore(options) {
956
1103
  console.error(` [验证码] 累计 ${captchaCount} 次`);
957
1104
 
958
1105
  await withRetry("report captcha", () =>
959
- apiPost(`${serverUrl}/api/error-report`, {
960
- userId,
1106
+ errorReporter.report({
961
1107
  username,
962
1108
  errorType: "captcha",
963
1109
  errorMessage: result.captchaMessage || "页面出现验证码",