tt-help-cli-ycl 1.4.153 → 1.4.155

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.
@@ -47,6 +47,8 @@ export class ClashDaemon {
47
47
  this.disconnectSampleMs =
48
48
  opts.disconnectSampleMs > 0 ? opts.disconnectSampleMs : 1000;
49
49
  this.dryRun = !!opts.dryRun;
50
+ // 白名单守卫(默认开启):当前节点必须 ∈ 候选集(咱们节点列表),不在的不允许,强制切回
51
+ this.whitelistGuard = opts.whitelistGuard !== false;
50
52
  // 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
51
53
  this._discoverOpts = {
52
54
  apiPort: opts.apiPort,
@@ -168,6 +170,32 @@ export class ClashDaemon {
168
170
  });
169
171
  }
170
172
 
173
+ /**
174
+ * 逐个尝试候选节点并内容验证(巡检/failover/白名单守卫共用切换循环)
175
+ * @returns {{target: string|null, h2: object|null}} 第一个满足(健康或壳页)的候选;全部失败返回 {target:null}
176
+ */
177
+ async _tryRotateCandidates(c, ordered, currentNode, reason = "") {
178
+ const tag = reason ? ` [原因: ${reason}]` : "";
179
+ let target = null;
180
+ let h2 = null;
181
+ for (const cand of ordered) {
182
+ if (cand === currentNode) continue;
183
+ this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand}${tag}`);
184
+ if (!(await checker.switchToNode(cand))) {
185
+ this._log(` ↺ ${cand} 切换失败,下一个`);
186
+ continue;
187
+ }
188
+ this._markSwitch(c, cand);
189
+ h2 = checker.checkNodeHealth();
190
+ if (h2.healthy || this._isShellPage(h2)) {
191
+ target = cand;
192
+ break;
193
+ }
194
+ this._log(` ↺ ${cand} 内容验证未通过,下一个`);
195
+ }
196
+ return { target, h2 };
197
+ }
198
+
171
199
  /** 将全局 api/checker 上下文指向指定控制器 */
172
200
  _useController(c) {
173
201
  api.configure({ port: c.port, secret: c.secret });
@@ -402,6 +430,8 @@ export class ClashDaemon {
402
430
  this._log(
403
431
  `✓ [${c.proxyPort}] ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
404
432
  );
433
+ // 白名单守卫:节点健康但不在咱们节点列表内(如人工在 Clash Verge 界面手动切换)→ 强制切回
434
+ await this._enforceWhitelist(c, node);
405
435
  return;
406
436
  }
407
437
 
@@ -412,6 +442,8 @@ export class ClashDaemon {
412
442
  this._log(
413
443
  `~ [${c.proxyPort}] 空壳页(降级响应,链路正常),不切换 (${(h.sizeDownload / 1024).toFixed(0)}KB ${h.timeTotal.toFixed(1)}s)`,
414
444
  );
445
+ // 白名单守卫与主分支同口径:壳页=链路正常,但列表外节点仍不允许
446
+ if (await this._enforceWhitelist(c, node)) return;
415
447
  return this._writeHealth(c, {
416
448
  node,
417
449
  healthy: true,
@@ -432,6 +464,8 @@ export class ClashDaemon {
432
464
  this._log(
433
465
  `~ [${c.proxyPort}] 复测恢复(首次异常: ${firstReason}),不切换`,
434
466
  );
467
+ // 白名单守卫:复测恢复同样守口径(列表外节点不允许)
468
+ if (await this._enforceWhitelist(c, node)) return;
435
469
  return this._writeHealth(c, {
436
470
  node,
437
471
  healthy: true,
@@ -476,23 +510,12 @@ export class ClashDaemon {
476
510
  // 逐个切换候选 + 内容验证,第一个满足即采用
477
511
  // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
478
512
  const ordered = this._sortCandidates(nodes, c);
479
- let target = null;
480
- let h2 = null;
481
- for (const cand of ordered) {
482
- if (cand === node) continue;
483
- this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand} [原因: ${reason}]`);
484
- if (!(await checker.switchToNode(cand))) {
485
- this._log(` ↺ ${cand} 切换失败,下一个`);
486
- continue;
487
- }
488
- this._markSwitch(c, cand);
489
- h2 = checker.checkNodeHealth();
490
- if (h2.healthy || this._isShellPage(h2)) {
491
- target = cand;
492
- break;
493
- }
494
- this._log(` ↺ ${cand} 内容验证未通过,下一个`);
495
- }
513
+ const { target, h2 } = await this._tryRotateCandidates(
514
+ c,
515
+ ordered,
516
+ node,
517
+ reason,
518
+ );
496
519
 
497
520
  if (!target) {
498
521
  // 全部候选不可用,恢复原节点
@@ -564,6 +587,75 @@ export class ClashDaemon {
564
587
  }
565
588
  }
566
589
 
590
+ /**
591
+ * 白名单守卫:当前 Clash Verge 正在使用的节点必须 ∈ 候选集(咱们节点列表)
592
+ *
593
+ * 场景:人工在 Clash Verge 界面手动切到列表外节点、其它程序乱切等。
594
+ * 每轮巡检执行(健康分支):发现越界 → 立即切回候选集
595
+ *(按轮换排序逐个切换+内容验证,第一个满足即采用,同巡检切换口径)。
596
+ *
597
+ * 不受 failover 冷却期约束(越界纠正而非故障切换);候选集为空时无法判定,
598
+ * 放行(由 delay 初测逻辑自动补齐,下一轮巡检再守卫)。
599
+ *
600
+ * @returns {Promise<boolean>} true=已强制切回候选集(调用方可据此跳过自身的 health 写入)
601
+ */
602
+ async _enforceWhitelist(c, node) {
603
+ if (!this.whitelistGuard) return false;
604
+ if (!node || node === "?") return false;
605
+ const { nodes } = checker.loadHealthyList();
606
+ if (!nodes.length || nodes.includes(node)) return false;
607
+ if (this.dryRun) {
608
+ this._log(
609
+ `[DRY-RUN] [${c.proxyPort}] 白名单守卫: 当前节点 ${node} 不在候选集(${nodes.length}个)内,应切回`,
610
+ );
611
+ return false;
612
+ }
613
+ this._log(
614
+ `⛔ [${c.proxyPort}] 白名单守卫: 当前节点 ${node} 不在咱们节点列表(${nodes.length}个)内,切回`,
615
+ );
616
+ const ordered = this._sortCandidates(nodes, c);
617
+ const { target, h2 } = await this._tryRotateCandidates(
618
+ c,
619
+ ordered,
620
+ node,
621
+ "白名单守卫",
622
+ );
623
+ if (!target) {
624
+ // 与 failover/巡检切换口径一致:候选全部不可用时恢复原节点
625
+ //(_tryRotateCandidates 逐个试切后,当前实际已停在最后尝试的候选上)
626
+ await checker.switchToNode(node).catch(() => {});
627
+ this._markSwitch(c, node);
628
+ this._markSwitchTime(c);
629
+ await this._disconnect(c);
630
+ this._log(
631
+ `✗ [${c.proxyPort}] 白名单守卫: 候选全部不可用,恢复原节点 ${node}`,
632
+ );
633
+ this._writeHealth(c, {
634
+ node,
635
+ healthy: false,
636
+ error: "白名单守卫 — 候选全部不可用",
637
+ lastSwitch: `白名单守卫: 候选全部不可用,恢复原节点 ${node}`,
638
+ });
639
+ return false;
640
+ }
641
+ this._log(
642
+ `⚠ [${c.proxyPort}] 节点切换 [白名单守卫]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
643
+ );
644
+ // 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
645
+ await this._disconnect(c);
646
+ this._markSwitchTime(c);
647
+ this._writeHealth(c, {
648
+ node: target,
649
+ healthy: h2.healthy,
650
+ httpCode: h2.httpCode,
651
+ timeTotal: h2.timeTotal,
652
+ sizeDownload: h2.sizeDownload,
653
+ title: h2.title,
654
+ lastSwitch: `白名单守卫: ${node} → ${target}`,
655
+ });
656
+ return true;
657
+ }
658
+
567
659
  // ── 候选集 delay 初测(每个控制端独立,零切换) ──
568
660
 
569
661
  _runDelayProbeAll() {
@@ -753,23 +845,12 @@ export class ClashDaemon {
753
845
  // 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
754
846
  // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
755
847
  const ordered = this._sortCandidates(nodes, c);
756
- let target = null;
757
- let h2 = null;
758
- for (const cand of ordered) {
759
- if (cand === node) continue;
760
- this._log(`↺ 尝试候选: ${cand}`);
761
- if (!(await checker.switchToNode(cand))) {
762
- this._log(` ↺ ${cand} 切换失败,下一个`);
763
- continue;
764
- }
765
- this._markSwitch(c, cand);
766
- h2 = checker.checkNodeHealth();
767
- if (h2.healthy || this._isShellPage(h2)) {
768
- target = cand;
769
- break;
770
- }
771
- this._log(` ↺ ${cand} 内容验证未通过,下一个`);
772
- }
848
+ const { target, h2 } = await this._tryRotateCandidates(
849
+ c,
850
+ ordered,
851
+ node,
852
+ reason,
853
+ );
773
854
  // 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
774
855
  this._markSwitchTime(c);
775
856
 
@@ -839,7 +920,7 @@ export class ClashDaemon {
839
920
  ` 检测: ${checker.CONFIG.testUrl} | 巡检: ${this.intervalMs / 1000}s | 候选刷新: ${this.benchmarkMs / 60000}min${filterTag}`,
840
921
  );
841
922
  console.error(
842
- ` 模式: ${this.dryRun ? "DRY-RUN(只检测不切换)" : "正常运行"}`,
923
+ ` 模式: ${this.dryRun ? "DRY-RUN(只检测不切换)" : "正常运行"} | 白名单守卫: ${this.whitelistGuard ? "开启(列表外节点强制切回)" : "关闭"}`,
843
924
  );
844
925
  console.error("═".repeat(50));
845
926
  }
@@ -882,6 +963,14 @@ function _buildCmdHandlers(daemon) {
882
963
  daemon._log(`📋 检测URL: → ${cmd.url}`);
883
964
  }
884
965
  },
966
+ setWhitelistGuard(cmd) {
967
+ const prev = daemon.whitelistGuard;
968
+ const next = cmd.enabled !== false && cmd.enabled !== "false";
969
+ daemon.whitelistGuard = next;
970
+ daemon._log(
971
+ `📋 白名单守卫: ${prev ? "开启" : "关闭"} → ${next ? "开启" : "关闭"}`,
972
+ );
973
+ },
885
974
  runBenchmark() {
886
975
  daemon._log("📋 收到手动刷新候选集命令");
887
976
  daemon._runDelayProbeAll();
@@ -911,6 +1000,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
911
1000
  benchmarkMin: parseInt(getArg("--benchmark")) || undefined,
912
1001
  country: getArg("--country") || undefined,
913
1002
  dryRun: args.includes("--dry-run"),
1003
+ whitelistGuard: !args.includes("--no-whitelist-guard"),
914
1004
  apiPort: parseInt(getArg("--api-port")) || undefined,
915
1005
  secret: getArg("--secret") || undefined,
916
1006
  selection: getArg("--selection") || undefined,
@@ -16,6 +16,7 @@ export async function handleClashDaemon(options) {
16
16
  options["dry-run"] === "" ||
17
17
  options["dry-run"] === true ||
18
18
  process.argv.includes("--dry-run"),
19
+ whitelistGuard: !options["no-whitelist-guard"],
19
20
  apiPort: parseInt(options["api-port"]) || undefined,
20
21
  secret: options.secret || undefined,
21
22
  selection: options.selection || undefined,
@@ -10,6 +10,7 @@ import {
10
10
  } from "../scraper/modules/page-helpers.js";
11
11
  import {
12
12
  detectCaptcha,
13
+ detectCaptchaWithWait,
13
14
  closeCaptcha,
14
15
  } from "../scraper/modules/captcha-handler.js";
15
16
  import {
@@ -75,6 +76,7 @@ export async function handleExplore(options) {
75
76
  exploreNoProduct,
76
77
  exploreSwitchDelay,
77
78
  exploreCommentDelay,
79
+ captchaWaitMin,
78
80
  } = options;
79
81
 
80
82
  let userId = exploreUserId || configuredUserId;
@@ -351,6 +353,44 @@ export async function handleExplore(options) {
351
353
  switchAccount: (reason) => handleAccountSwitch(reason),
352
354
  });
353
355
 
356
+ /** 等待人工在浏览器中解决验证码:轮询检测,解决即继续;超时返回 false */
357
+ async function waitForCaptchaResolved() {
358
+ const waitMin = captchaWaitMin || 3;
359
+ const deadline = Date.now() + waitMin * 60000;
360
+ console.error(
361
+ ` [验证码] 等待人工解决(上限 ${waitMin} 分钟,每15秒检测一次,解决即继续)...`,
362
+ );
363
+ while (Date.now() < deadline) {
364
+ await new Promise((r) => setTimeout(r, 15000));
365
+ let visible = true;
366
+ try {
367
+ const r = await detectCaptcha(page);
368
+ visible = !!(r && r.visible);
369
+ } catch {
370
+ visible = false; // 检测异常(页面导航中/重建中)按已解决处理,避免卡死
371
+ }
372
+ if (!visible) {
373
+ console.error(" [验证码] 已解决,继续处理");
374
+ return true;
375
+ }
376
+ }
377
+ console.error(` [验证码] 等待 ${waitMin} 分钟未解决,尝试关闭...`);
378
+ // 超时后尝试关闭验证码弹窗,关不掉由调用方计数切账户
379
+ await closeCaptcha(page).catch(() => {});
380
+ await new Promise((r) => setTimeout(r, 2000));
381
+ try {
382
+ const r = await detectCaptcha(page);
383
+ if (!(r && r.visible)) {
384
+ console.error(" [验证码] 超时后关闭成功,继续处理");
385
+ return true;
386
+ }
387
+ } catch {
388
+ /* 忽略 */
389
+ }
390
+ console.error(" [验证码] 超时且关闭失败");
391
+ return false;
392
+ }
393
+
354
394
  let processedCount = 0;
355
395
  let errorCount = 0;
356
396
  let consecutiveNetworkErrors = 0;
@@ -402,20 +442,13 @@ export async function handleExplore(options) {
402
442
  // 切换任务前检测验证码
403
443
  const switchCaptcha = await detectCaptcha(page);
404
444
  if (switchCaptcha && switchCaptcha.visible) {
405
- console.error(`\n[验证码] 切换任务前检测到验证码,等待3分钟...`);
445
+ console.error(`\n[验证码] 切换任务前检测到验证码`);
406
446
  captchaCount++;
407
447
  console.error(` [验证码] 累计 ${captchaCount} 次`);
408
448
 
409
- // 等待3分钟
410
- await new Promise((r) => setTimeout(r, 3 * 60 * 1000));
411
-
412
- // 尝试关闭
413
- const closeResult = await closeCaptcha(page);
414
- if (closeResult.success) {
415
- console.error(` [验证码] 已关闭`);
416
- } else {
417
- console.error(` [验证码] 关闭失败: ${closeResult.reason}`);
418
- }
449
+ // 轮询等待人工解决(解决即继续,超时尝试关闭);
450
+ // 不再盲等 3 分钟——同一个验证码若未解决会在下一轮重复计数凑满切账户阈值
451
+ const resolved = await waitForCaptchaResolved();
419
452
 
420
453
  // 上报
421
454
  await withRetry("report captcha", () =>
@@ -423,16 +456,24 @@ export async function handleExplore(options) {
423
456
  userId,
424
457
  username: "unknown",
425
458
  errorType: "captcha",
426
- errorMessage: "切换任务前检测到验证码",
459
+ errorMessage: resolved
460
+ ? "切换任务前检测到验证码(已解决)"
461
+ : "切换任务前检测到验证码(超时未解决)",
427
462
  stage: "between-tasks",
428
463
  errorStack: "",
429
464
  }),
430
465
  ).catch(() => {});
431
466
 
432
- // 累计2次切换账户
433
- if (captchaCount >= 2) {
434
- await handleAccountSwitch(`验证码累计 ${captchaCount} 次`);
435
- captchaCount = 0;
467
+ // 超时未解决才累计切账户;已解决的不计入(人工处理完了不应消耗账户)
468
+ if (!resolved) {
469
+ if (captchaCount >= 3) {
470
+ await handleAccountSwitch(
471
+ `验证码累计 ${captchaCount} 次(超时未解决)`,
472
+ );
473
+ captchaCount = 0;
474
+ }
475
+ } else {
476
+ captchaCount--; // 解决了就不算一次独立事件
436
477
  }
437
478
  }
438
479
 
@@ -578,7 +619,8 @@ export async function handleExplore(options) {
578
619
  // 先记住原错误类型:兜底转验证码时带上,服务端可同时看到"被判+验证码"
579
620
  const wasBlocked = result.error.startsWith("被封:");
580
621
  try {
581
- const lateCaptcha = await detectCaptcha(page);
622
+ // 带等待检测:验证码容器晚于页面加载渲染,瞬时检测常漏(尤其被判场景)
623
+ const lateCaptcha = await detectCaptchaWithWait(page, 5000);
582
624
  if (lateCaptcha && lateCaptcha.visible) {
583
625
  console.error(
584
626
  ` [验证码] 兜底识别到验证码 @${username}${wasBlocked ? "(被判后弹出)" : ""}`,
@@ -655,9 +697,17 @@ export async function handleExplore(options) {
655
697
  }),
656
698
  ).catch(() => {});
657
699
 
658
- // 累计2次验证码,切换账户
659
- if (captchaCount >= 2) {
660
- await handleAccountSwitch(`验证码累计 ${captchaCount} 次`);
700
+ // 轮询等待人工解决(解决即继续):
701
+ // 不等待会导致同一个验证码在下一轮"切换任务前检测"重复计数,白白凑满阈值切账户
702
+ const resolved = await waitForCaptchaResolved();
703
+
704
+ if (resolved) {
705
+ captchaCount--; // 人工解决了,不算独立事件
706
+ } else if (captchaCount >= 3) {
707
+ // 累计3次验证码(均超时未解决),切换账户
708
+ await handleAccountSwitch(
709
+ `验证码累计 ${captchaCount} 次(超时未解决)`,
710
+ );
661
711
  captchaCount = 0;
662
712
  }
663
713
  }
@@ -1,6 +1,9 @@
1
1
  import { delay } from "./delay.js";
2
2
  import { assertPageUrl } from "./browser/page.js";
3
- import { detectCaptcha } from "../scraper/modules/captcha-handler.js";
3
+ import {
4
+ detectCaptcha,
5
+ detectCaptchaWithWait,
6
+ } from "../scraper/modules/captcha-handler.js";
4
7
  import {
5
8
  detectPageError,
6
9
  detectPageErrorWithWait,
@@ -392,19 +395,25 @@ async function fetchUserVideosAPI(
392
395
  ? "空响应(0 字节)"
393
396
  : `限流(HTTP ${httpStatus}, ${bodyText.slice(0, 60)})`;
394
397
  // 被封形态(尤其 429/ratelimit)常伴随页面弹出验证码:此时继续重试无意义,
395
- // 人工解决验证码才是正解。检测到立即抛 "验证码:",跳过剩余 30/40/50s 空转重试
396
- try {
397
- const captcha = await detectCaptcha(page);
398
- if (captcha && captcha.visible) {
399
- log(
400
- ` [API拦截] item_list ${reason},页面已弹出验证码,转验证码处理(不再重试)`,
401
- );
402
- throw new Error(`验证码: ${username}`);
398
+ // 人工解决验证码才是正解。检测到立即抛 "验证码:",跳过剩余 30/40/50s 空转重试。
399
+ // 验证码容器晚于 domcontentloaded 渲染,瞬时检测常因"太早"漏检 → 用带等待检测;
400
+ // 等待期(30/40/50s)内弹出的验证码会被下一轮 goto 冲掉,
401
+ // 所以每轮 sleep 醒来后、下次 goto 前还要再检一次
402
+ const captchaHit = async (waitMs, when) => {
403
+ try {
404
+ const captcha = await detectCaptchaWithWait(page, waitMs);
405
+ if (captcha && captcha.visible) {
406
+ log(
407
+ ` [API拦截] item_list ${reason},${when}弹出验证码,转验证码处理(不再重试)`,
408
+ );
409
+ throw new Error(`验证码: ${username}`);
410
+ }
411
+ } catch (e) {
412
+ if (e.message && e.message.startsWith("验证码:")) throw e;
413
+ // 检测本身失败(页面导航中/上下文销毁等):忽略,继续按被封重试
403
414
  }
404
- } catch (e) {
405
- if (e.message && e.message.startsWith("验证码:")) throw e;
406
- // 检测本身失败(页面导航中/上下文销毁等):忽略,继续按被封重试
407
- }
415
+ };
416
+ await captchaHit(5000, "页面已");
408
417
  const retryIndex = attempt - 1;
409
418
  if (retryIndex < retryWaits.length) {
410
419
  const waitMs = retryWaits[retryIndex];
@@ -412,8 +421,12 @@ async function fetchUserVideosAPI(
412
421
  ` [API拦截] item_list ${reason},判定为被封,${waitMs / 1000}s 后第 ${attempt}/${retryWaits.length} 次重试...`,
413
422
  );
414
423
  await new Promise((r) => setTimeout(r, waitMs));
424
+ // 关键时机:等待期间验证码才会真正渲染出来(被 ban 后前端延迟弹窗),
425
+ // 下次 goto 会把它冲掉 —— sleep 醒来后立即检测,抓住它
426
+ await captchaHit(3000, "等待期间");
415
427
  continue;
416
428
  }
429
+ await captchaHit(5000, "重试耗尽后");
417
430
  log(
418
431
  ` [API拦截] item_list ${reason},重试 ${retryWaits.length} 次仍被封`,
419
432
  );
package/src/lib/args.js CHANGED
@@ -187,6 +187,7 @@ function parseExploreArgs(args) {
187
187
  let exploreNoProduct = false;
188
188
  let exploreSwitchDelay = null;
189
189
  let exploreCommentDelay = null;
190
+ let captchaWaitMin = 3;
190
191
 
191
192
  const positional = [];
192
193
  const PRESETS = ["fast", "normal", "slow", "stealth"];
@@ -237,6 +238,8 @@ function parseExploreArgs(args) {
237
238
  exploreSwitchDelay = parseInt(args[++i], 10) || null;
238
239
  } else if (arg === "--comment-delay") {
239
240
  exploreCommentDelay = parseInt(args[++i], 10) || null;
241
+ } else if (arg === "--captcha-wait-min") {
242
+ captchaWaitMin = parseInt(args[++i], 10) || 3;
240
243
  } else {
241
244
  positional.push(arg);
242
245
  }
@@ -264,6 +267,7 @@ function parseExploreArgs(args) {
264
267
  explorePreset,
265
268
  exploreSwitchDelay,
266
269
  exploreCommentDelay,
270
+ captchaWaitMin,
267
271
  exploreInterval,
268
272
  exploreMaxComments,
269
273
  exploreMaxGuess,
@@ -813,6 +817,7 @@ function parseClashDaemonArgs(args) {
813
817
  let interval = 120;
814
818
  let benchmark = 10;
815
819
  let dryRun = false;
820
+ let noWhitelistGuard = false;
816
821
  let country = null;
817
822
  let apiPort = null;
818
823
  let secret = null;
@@ -829,6 +834,8 @@ function parseClashDaemonArgs(args) {
829
834
  country = args[++i] || null;
830
835
  } else if (arg === "--dry-run") {
831
836
  dryRun = true;
837
+ } else if (arg === "--no-whitelist-guard") {
838
+ noWhitelistGuard = true;
832
839
  } else if (arg === "--api-port") {
833
840
  apiPort = parseInt(args[++i]) || 0;
834
841
  } else if (arg === "--secret") {
@@ -847,6 +854,7 @@ function parseClashDaemonArgs(args) {
847
854
  benchmark,
848
855
  country,
849
856
  "dry-run": dryRun,
857
+ "no-whitelist-guard": noWhitelistGuard,
850
858
  "api-port": apiPort,
851
859
  secret,
852
860
  selection,
@@ -127,6 +127,7 @@ const HELP_TEXT = [
127
127
  " --preset <预设> 延迟预设 fast/normal/slow/stealth,默认 normal",
128
128
  " --switch-delay <毫秒> 任务切换延迟上限,覆盖预设值(实际等待随机 delay(switchMax, switchMax*3))",
129
129
  " --comment-delay <毫秒> 评论滚动延迟上限,覆盖预设值",
130
+ " --captcha-wait-min <分钟> 验证码等待人工解决上限(轮询检测,解决即继续),默认 3",
130
131
  ` --location <国家代码> 国家筛选,逗号分隔,默认 ${DEFAULT_TARGET_LOCATIONS_CSV}`,
131
132
  " --job-locations <国家> 任务国家筛选,逗号分隔(仅筛选服务端任务)",
132
133
  " --max-comments <数量> 每视频最大评论数,默认 10",
@@ -460,6 +461,7 @@ const HELP_TEXT = [
460
461
  " --country <国家代码> 只保留指定国家节点,默认不过滤",
461
462
  " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
462
463
  " --dry-run 只检测不切换(调试用)",
464
+ " --no-whitelist-guard 关闭白名单守卫(默认开启:当前节点不在候选集内时强制切回)",
463
465
  " --api-port <端口> 控制 API 端口(默认自动发现;显式指定验证失败时报错)",
464
466
  " --secret <secret> 控制 API 密钥(默认自动获取)",
465
467
  " --selection <策略> 多控制端选择策略: system-proxy / preference / first-alive",
@@ -469,6 +471,7 @@ const HELP_TEXT = [
469
471
  ' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
470
472
  ' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
471
473
  ' {"action":"setCountryFilter","country":null} 取消过滤',
474
+ ' {"action":"setWhitelistGuard","enabled":false} 关闭白名单守卫(enabled:true 开启)',
472
475
  ' {"action":"runBenchmark"} 手动刷新候选集(delay 初测)',
473
476
  ' {"action":"setInterval","seconds":60} 修改巡检间隔',
474
477
  " 示例: tt-help clash-daemon",
@@ -1,6 +1,6 @@
1
1
  export async function detectCaptcha(page) {
2
2
  return page.evaluate(() => {
3
- const container = document.querySelector('.captcha-verify-container');
3
+ const container = document.querySelector(".captcha-verify-container");
4
4
  if (!container) return null;
5
5
 
6
6
  const r = container.getBoundingClientRect();
@@ -17,14 +17,44 @@ export async function detectCaptcha(page) {
17
17
  });
18
18
  }
19
19
 
20
+ /**
21
+ * 带等待的验证码检测:轮询等待 .captcha-verify-container 出现且可见。
22
+ * 验证码容器由前端 JS 延迟渲染(晚于 domcontentloaded / item_list 响应),
23
+ * 瞬时 detectCaptcha 常因"检测太早"漏检;超时未出现返回 null。
24
+ */
25
+ export async function detectCaptchaWithWait(page, timeoutMs = 5000) {
26
+ try {
27
+ const handle = await page.waitForFunction(
28
+ () => {
29
+ const c = document.querySelector(".captcha-verify-container");
30
+ return !!(c && c.offsetParent !== null);
31
+ },
32
+ { timeout: timeoutMs },
33
+ );
34
+ const appeared = handle
35
+ ? await handle.jsonValue().catch(() => false)
36
+ : false;
37
+ if (!appeared) return null;
38
+ // 出现后用原函数拿完整信息(rect 等);异常按未出现处理
39
+ return (
40
+ (await detectCaptcha(page).catch(() => null)) || {
41
+ exists: true,
42
+ visible: true,
43
+ }
44
+ );
45
+ } catch {
46
+ return null; // 超时或页面异常:未出现
47
+ }
48
+ }
49
+
20
50
  export async function closeCaptcha(page) {
21
51
  return page.evaluate(() => {
22
- const closeBtn = document.getElementById('captcha_close_button');
23
- if (!closeBtn) return { success: false, reason: 'close button not found' };
52
+ const closeBtn = document.getElementById("captcha_close_button");
53
+ if (!closeBtn) return { success: false, reason: "close button not found" };
24
54
 
25
- closeBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
26
- closeBtn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
27
- closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
55
+ closeBtn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
56
+ closeBtn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
57
+ closeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
28
58
 
29
59
  return { success: true };
30
60
  });
@@ -36,12 +66,13 @@ export async function handleCaptcha(page, options = {}) {
36
66
  const captcha = await detectCaptcha(page);
37
67
  if (!captcha) return { detected: false, closed: false };
38
68
 
39
- await new Promise(r => setTimeout(r, waitMs));
69
+ await new Promise((r) => setTimeout(r, waitMs));
40
70
 
41
71
  const result = await closeCaptcha(page);
42
- if (!result.success) return { detected: true, closed: false, reason: result.reason };
72
+ if (!result.success)
73
+ return { detected: true, closed: false, reason: result.reason };
43
74
 
44
- await new Promise(r => setTimeout(r, 1000));
75
+ await new Promise((r) => setTimeout(r, 1000));
45
76
 
46
77
  const stillThere = await detectCaptcha(page);
47
78
  return { detected: true, closed: !stillThere };
@@ -51,8 +82,8 @@ export async function getIncognitoPage(browser, url, options = {}) {
51
82
  const { waitMs = 3000 } = options;
52
83
  const context = await browser.newContext();
53
84
  const page = await context.newPage();
54
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
55
- await new Promise(r => setTimeout(r, waitMs));
85
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
86
+ await new Promise((r) => setTimeout(r, waitMs));
56
87
  return { page, context };
57
88
  }
58
89
 
@@ -62,13 +93,13 @@ export async function waitAndGetCaptcha(page, options = {}) {
62
93
  const captcha = await detectCaptcha(page);
63
94
  if (!captcha) return { detected: false, resolved: false, waited: 0 };
64
95
 
65
- if (log) log(' 检测到验证码,等待用户手动输入...');
96
+ if (log) log(" 检测到验证码,等待用户手动输入...");
66
97
 
67
98
  const startTime = Date.now();
68
99
  const deadline = startTime + waitMs;
69
100
 
70
101
  while (Date.now() < deadline) {
71
- await new Promise(r => setTimeout(r, pollInterval));
102
+ await new Promise((r) => setTimeout(r, pollInterval));
72
103
  const remaining = await detectCaptcha(page);
73
104
  if (!remaining) {
74
105
  const waited = Math.round((Date.now() - startTime) / 1000);
@@ -82,7 +113,7 @@ export async function waitAndGetCaptcha(page, options = {}) {
82
113
 
83
114
  // 超时后尝试关闭验证码弹窗
84
115
  await closeCaptcha(page);
85
- await new Promise(r => setTimeout(r, 1000));
116
+ await new Promise((r) => setTimeout(r, 1000));
86
117
 
87
118
  return { detected: true, resolved: false, waited };
88
119
  }
@@ -92,22 +123,30 @@ export async function safeClickComment(page, options = {}) {
92
123
 
93
124
  // 点击评论
94
125
  await page.evaluate(() => {
95
- const all = document.querySelectorAll('button');
126
+ const all = document.querySelectorAll("button");
96
127
  for (const el of all) {
97
- if (/^评论$/.test(el.textContent?.trim()) && el.offsetParent !== null && el.getBoundingClientRect().width > 0) {
128
+ if (
129
+ /^评论$/.test(el.textContent?.trim()) &&
130
+ el.offsetParent !== null &&
131
+ el.getBoundingClientRect().width > 0
132
+ ) {
98
133
  el.click();
99
134
  break;
100
135
  }
101
136
  }
102
137
  });
103
138
 
104
- await new Promise(r => setTimeout(r, waitMs));
139
+ await new Promise((r) => setTimeout(r, waitMs));
105
140
 
106
141
  // 检测并关闭验证码
107
142
  const captcha = await detectCaptcha(page);
108
143
  if (captcha) {
109
144
  const result = await handleCaptcha(page);
110
- return { clicked: true, captchaDetected: true, captchaClosed: result.closed };
145
+ return {
146
+ clicked: true,
147
+ captchaDetected: true,
148
+ captchaClosed: result.closed,
149
+ };
111
150
  }
112
151
 
113
152
  return { clicked: true, captchaDetected: false, captchaClosed: false };