tt-help-cli-ycl 1.4.184 → 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.
@@ -39,6 +39,10 @@ import {
39
39
  ensurePoolLogin,
40
40
  crashBackoffMs,
41
41
  retryRestoreOnNavFailure,
42
+ CANDIDATE_INJECT_MAX_RETRIES,
43
+ CANDIDATE_INJECT_DELAY_MS,
44
+ decideCandidateInjectAction,
45
+ isRetryableInjectFailure,
42
46
  } from "../lib/login-pool-client.js";
43
47
  import { getClientConfig } from "../watchdog/reporter.js";
44
48
  import { createApiClient } from "../lib/api-client.js";
@@ -390,8 +394,10 @@ export async function handleExplore(options) {
390
394
 
391
395
  /**
392
396
  * 换号(以号为主键):先取新号(旧号仍在手,无空窗)→ 注入验证 → 还旧号。
393
- * 候选最多试 5 个(注入失败的释放回池);池空/服务器不可达 → 保旧号返回 false,
394
- * 由主循环「无在手账号」补取逻辑兑底。
397
+ * 候选最多试 5 个;注入失败按类型分流(decideCandidateInjectAction):
398
+ * 浏览器级崩溃 → 熔断重启;确定性号死(会话过期/身份不符)→ 立即释放回池换下一个;
399
+ * 瞬态/环境失败 → 同一候选原号重试(共 3 次尝试,间隔 15s)全败才释放;
400
+ * 池空/服务器不可达 → 保旧号返回 false,由主循环「无在手账号」补取逻辑兑底。
395
401
  * @param code 受控换号原因(SWITCH_REASON,上报服务端 switchStats;可选);
396
402
  * 首选按外层 call site 上报,候选注入失败后的再取号 = inject_fail
397
403
  */
@@ -405,6 +411,33 @@ export async function handleExplore(options) {
405
411
  );
406
412
  if (!(await sleepSliced(backoffWaitMs))) return false;
407
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
+ }
408
441
  for (let attempt = 0; attempt < 5; attempt++) {
409
442
  let next = null;
410
443
  try {
@@ -415,40 +448,71 @@ export async function handleExplore(options) {
415
448
  );
416
449
  return false;
417
450
  }
418
- const r = await injectPoolAccount(page, next, {
451
+ let r = await injectPoolAccount(page, next, {
419
452
  verifyUsername: next.username,
420
453
  }).catch((e) => ({ ok: false, reason: e.message }));
421
454
  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),释放候选...`,
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
+ },
430
481
  );
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(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
482
+ if (r.aborted) {
483
+ // shuttingDown 中断:释放候选退出(租约主动还干净,服务端 TTL 兑底)
484
+ console.error(`[换号] @${next.username} 原号重试等待中被关闭信号中断,释放候选`);
485
+ await poolClient.release(next).catch(() => {});
441
486
  return false;
442
487
  }
443
- return await restartPoolBrowser(
444
- `注入验证遭遇浏览器级崩溃(${r.reason})`,
445
- );
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;
446
515
  }
447
- console.error(
448
- `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
449
- );
450
- await poolClient.release(next).catch(() => {});
451
- continue;
452
516
  }
453
517
  // 新号验证通过:切换在手状态,再还旧号(谁持账号谁还)
454
518
  const prev = poolAccount;
package/src/cli/search.js CHANGED
@@ -51,6 +51,10 @@ import {
51
51
  ensurePoolLogin,
52
52
  crashBackoffMs,
53
53
  retryRestoreOnNavFailure,
54
+ CANDIDATE_INJECT_MAX_RETRIES,
55
+ CANDIDATE_INJECT_DELAY_MS,
56
+ decideCandidateInjectAction,
57
+ isRetryableInjectFailure,
54
58
  } from "../lib/login-pool-client.js";
55
59
  import { getClientConfig } from "../watchdog/reporter.js";
56
60
  import {
@@ -476,6 +480,33 @@ export async function handleSearch(parsed) {
476
480
  );
477
481
  if (!(await sleepSliced(backoffWaitMs))) return false;
478
482
  }
483
+ // 候选注入失败命中浏览器级崩溃签名时的处置(首判与重试中途重判共用):
484
+ // crashStreak++ 按轮退避 + 释放候选 + 上报 + 转同号重启;返回值由调用方直接 return
485
+ async function abortCandidateLoopOnCrash(crashReason, candidate, fromRecovery) {
486
+ // 浏览器级崩溃:不怪账号,立即中止候选循环(不消耗 inject_fail 口径),
487
+ // 已取候选照常 release,转同号重启(与内存超限路径同语义);按轮熔断退避
488
+ crashStreak++;
489
+ const backoffMs = crashBackoffMs(crashStreak);
490
+ crashBackoffUntil = Date.now() + backoffMs;
491
+ log(
492
+ `[换号] @${candidate.username} 注入失败为浏览器级崩溃(${crashReason}),中止候选循环(第 ${crashStreak} 轮,熔断 ${Math.round(backoffMs / 1000)}s),释放候选...`,
493
+ );
494
+ await poolClient.release(candidate).catch(() => {});
495
+ await errorReporter.report({
496
+ username: poolAccount ? poolAccount.username : "unknown",
497
+ errorType: "account_switch_failed",
498
+ errorMessage: `注入失败为浏览器级崩溃,中止候选循环转同号重启(第${crashStreak}轮,熔断${Math.round(backoffMs / 1000)}s): ${crashReason}`,
499
+ stage: "account-switch",
500
+ }).catch(() => {});
501
+ if (fromRecovery) {
502
+ // 恢复链中(重启后恢复失败触发的换号)不再递归重启,保留现场等熔断窗口
503
+ log(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
504
+ return false;
505
+ }
506
+ return await restartPoolBrowser(
507
+ `注入验证遭遇浏览器级崩溃(${crashReason})`,
508
+ );
509
+ }
479
510
  for (let attempt = 0; attempt < 5; attempt++) {
480
511
  let next = null;
481
512
  try {
@@ -486,40 +517,71 @@ export async function handleSearch(parsed) {
486
517
  );
487
518
  return false;
488
519
  }
489
- const r = await injectPoolAccount(page, next, {
520
+ let r = await injectPoolAccount(page, next, {
490
521
  verifyUsername: next.username,
491
522
  }).catch((e) => ({ ok: false, reason: e.message }));
492
523
  if (!r.ok) {
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),释放候选...`,
524
+ // 注入失败三路分流(与 explore.js 镜像):崩溃 → 既有熔断(中止候选循环);
525
+ // 确定性号死 → 立即弃号换下一个候选(现状行为);
526
+ // 瞬态/环境失败 → 同一候选原号重试(共 3 次尝试,间隔 15s),重试期间租约保持在手
527
+ let action = decideCandidateInjectAction(r.reason, isBrowserClosedError);
528
+ if (action === "circuit-break") {
529
+ return await abortCandidateLoopOnCrash(r.reason, next, opts.fromRecovery);
530
+ }
531
+ if (action === "retry") {
532
+ // 瞬态/环境失败(超时/AbortError/net::ERR_*/Execution context destroyed/无账号数据/导航类):
533
+ // 原号重试最多 2 次(共 3 次尝试),间隔 15s,重试期间不 release(租约保持在手);
534
+ // 确定性号死(会话过期/身份不符)不进此分支,直接弃号换下一个候选
535
+ r = await retryRestoreOnNavFailure(
536
+ () =>
537
+ injectPoolAccount(page, next, { verifyUsername: next.username }),
538
+ r.reason,
539
+ {
540
+ isRetryable: isRetryableInjectFailure,
541
+ maxRetries: CANDIDATE_INJECT_MAX_RETRIES,
542
+ delayMs: CANDIDATE_INJECT_DELAY_MS,
543
+ sleep: (ms) => sleepSliced(ms),
544
+ shouldAbort: () => shuttingDown,
545
+ onRetry: (n, delayMs, why) =>
546
+ log(
547
+ `[换号] @${next.username} 第${n}次注入失败(瞬态,${why}),${Math.round(delayMs / 1000)}s 后原号重试`,
548
+ ),
549
+ },
501
550
  );
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(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
551
+ if (r.aborted) {
552
+ // shuttingDown 中断:释放候选退出(租约主动还干净,服务端 TTL 兑底)
553
+ log(`[换号] @${next.username} 原号重试等待中被关闭信号中断,释放候选`);
554
+ await poolClient.release(next).catch(() => {});
512
555
  return false;
513
556
  }
514
- return await restartPoolBrowser(
515
- `注入验证遭遇浏览器级崩溃(${r.reason})`,
516
- );
557
+ if (r.ok && r.attempts > 1) {
558
+ log(
559
+ `[换号] @${next.username} 原号重试成功(第 ${r.attempts} 次尝试)`,
560
+ );
561
+ }
562
+ if (!r.ok) {
563
+ // 重试中途失败签名可能升级为浏览器级崩溃(如首败 timeout、再败 Page crashed):
564
+ // 对最终原因重判,命中熔断则中止候选循环(不弃号给下一候选——坏浏览器再吃一个号),
565
+ // 升级为确定性号死则 fall through 到下方弃号分支换候选
566
+ action = decideCandidateInjectAction(r.reason, isBrowserClosedError);
567
+ if (action === "circuit-break") {
568
+ return await abortCandidateLoopOnCrash(r.reason, next, opts.fromRecovery);
569
+ }
570
+ }
571
+ }
572
+ if (!r.ok) {
573
+ if (r.attempts > 1) {
574
+ log(
575
+ `[换号] @${next.username} 原号重试 ${r.attempts - 1} 次后仍失败(共 ${r.attempts} 次尝试,${r.reason}),释放候选换下一个...`,
576
+ );
577
+ } else {
578
+ log(
579
+ `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
580
+ );
581
+ }
582
+ await poolClient.release(next).catch(() => {});
583
+ continue;
517
584
  }
518
- log(
519
- `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
520
- );
521
- await poolClient.release(next).catch(() => {});
522
- continue;
523
585
  }
524
586
  const prev = poolAccount;
525
587
  poolAccount = next;
@@ -238,6 +238,8 @@ export async function retryRestoreOnNavFailure(attemptFn, firstFailure, opts = {
238
238
  let reason = firstFailure;
239
239
  let attempts = 1; // 首次尝试已发生
240
240
  while (typeof isRetryable === "function" && attempts <= maxRetries && isRetryable(reason)) {
241
+ // 每轮重试前回调(可选):attempts=当前已失败次数,供调用方打「第N次失败→间隔后重试」日志
242
+ if (typeof opts.onRetry === "function") opts.onRetry(attempts, delayMs, reason);
241
243
  await sleep(delayMs);
242
244
  if (shouldAbort()) return { ok: false, reason, attempts, aborted: true };
243
245
  attempts++;
@@ -249,3 +251,68 @@ export async function retryRestoreOnNavFailure(attemptFn, firstFailure, opts = {
249
251
  }
250
252
  return { ok: false, reason, attempts };
251
253
  }
254
+
255
+ // ── 候选注入失败原号重试(纯函数/纯常量,2026-11-05 f11 48h 实测驱动)──
256
+ // 背景:注入验证失败 82.8% 为浏览器级崩溃(走既有崩溃熔断),~17% 为瞬态环境失败
257
+ // (page.goto 超时 / AbortError / net::ERR_* / Execution context destroyed / 无账号数据),
258
+ // 0 条会话过期。现状对非崩溃失败一律立即弃号换下一个候选,网络劣化窗口会连续烧掉
259
+ // 5 个候选且全死于同一环境。改造:瞬态失败同一候选原号重试(共 3 次尝试,间隔 15s),
260
+ // 重试期间租约保持在手;确定性号死(会话过期/身份不符)维持立即弃号;
261
+ // 浏览器级崩溃维持熔断路径(决策上崩溃优先,见 decideCandidateInjectAction)。
262
+ // 本组函数保持零依赖(崩溃签名判定由调用方注入 isBrowserClosedError,勿拖 playwright 链)。
263
+
264
+ /** 候选注入瞬态失败原号重试:追加次数(共 3 次尝试,全败才弃号换下一个候选) */
265
+ export const CANDIDATE_INJECT_MAX_RETRIES = 2;
266
+ /** 候选注入重试间隔 */
267
+ export const CANDIDATE_INJECT_DELAY_MS = 15 * 1000;
268
+
269
+ /**
270
+ * 确定性号死:会话过期 / 身份不符(injectPoolAccount 的账号级失败文案)。
271
+ * 重试无意义(号本身死了,换环境也救不活),立即弃号换下一个候选。
272
+ */
273
+ export function isDeterministicAccountFailure(reason) {
274
+ if (!reason) return false;
275
+ const msg = String(reason);
276
+ return msg.includes("会话过期") || msg.includes("身份不符");
277
+ }
278
+
279
+ /**
280
+ * 候选注入失败是否可原号重试(瞬态/环境失败白名单,f11 实测清单):
281
+ * - 可重试:超时 / net::ERR_*(导航层) / AbortError / Execution context destroyed /
282
+ * 无账号数据 / 导航失败前缀
283
+ * - 不可重试:确定性号死(会话过期/身份不符);浏览器级崩溃签名(Page/Target crashed 等
284
+ * 由调用方决策先行分流走熔断,此处对不含 net::ERR_CONNECTION 前缀的纯崩溃签名兜底排除);
285
+ * 未知失败(既非号死也非已知瞬态签名)→ 不重试,维持现状立即弃号(保守兜底)
286
+ */
287
+ export function isRetryableInjectFailure(reason) {
288
+ if (!reason) return false;
289
+ const msg = String(reason);
290
+ if (isDeterministicAccountFailure(msg)) return false;
291
+ if (msg.includes("net::ERR_")) return true;
292
+ if (/timed?\s*out|timeout/i.test(msg)) return true;
293
+ if (/AbortError/i.test(msg)) return true;
294
+ if (/Execution context (was )?destroyed/i.test(msg)) return true;
295
+ if (msg.includes("无账号数据")) return true;
296
+ if (msg.includes("导航失败")) return true;
297
+ return false;
298
+ }
299
+
300
+ /**
301
+ * 候选注入失败处置决策(纯函数,候选循环内按返回值三路分流):
302
+ * 返回 "circuit-break" | "retry" | "discard"
303
+ * - circuit-break:浏览器级崩溃(isBrowserClosedError 命中,含 Page/Target crashed、
304
+ * net::ERR_CONNECTION_* 等)→ 既有崩溃熔断路径(中止候选循环,已取候选照常 release)
305
+ * - discard:确定性号死(会话过期/身份不符)或未知失败 → 立即弃号换下一个候选(现状行为)
306
+ * - retry:瞬态/环境失败 → 同一候选原号重试(共 3 次尝试,间隔 15s,租约保持在手)
307
+ * @param {string} reason 注入失败原因
308
+ * @param {(error: Error) => boolean} [isBrowserClosedFn] 崩溃签名判定(客户端传
309
+ * page.js 的 isBrowserClosedError,SSOT 留 page.js;测试可注入假谓词)
310
+ */
311
+ export function decideCandidateInjectAction(reason, isBrowserClosedFn) {
312
+ const msg = String(reason ?? "");
313
+ if (typeof isBrowserClosedFn === "function" && isBrowserClosedFn(new Error(msg))) {
314
+ return "circuit-break";
315
+ }
316
+ if (isRetryableInjectFailure(msg)) return "retry";
317
+ return "discard";
318
+ }