tt-help-cli-ycl 1.4.154 → 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.
- package/package.json +1 -1
- package/scripts/test-daemon-whitelist.mjs +308 -0
- package/scripts/test-explore-captcha-wait.mjs +170 -0
- package/src/clash/daemon.js +125 -35
- package/src/cli/clash-daemon.js +1 -0
- package/src/cli/explore.js +67 -19
- package/src/lib/args.js +8 -0
- package/src/lib/constants.js +3 -0
- package/src/scraper/modules/captcha-handler.js +37 -21
- package/src/watch/public/app.js +97 -13
- package/src/watch/public/index.html +38 -1
- package/src/watch/watchdog-server.js +39 -1
package/src/clash/daemon.js
CHANGED
|
@@ -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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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,
|
package/src/cli/clash-daemon.js
CHANGED
|
@@ -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,
|
package/src/cli/explore.js
CHANGED
|
@@ -76,6 +76,7 @@ export async function handleExplore(options) {
|
|
|
76
76
|
exploreNoProduct,
|
|
77
77
|
exploreSwitchDelay,
|
|
78
78
|
exploreCommentDelay,
|
|
79
|
+
captchaWaitMin,
|
|
79
80
|
} = options;
|
|
80
81
|
|
|
81
82
|
let userId = exploreUserId || configuredUserId;
|
|
@@ -352,6 +353,44 @@ export async function handleExplore(options) {
|
|
|
352
353
|
switchAccount: (reason) => handleAccountSwitch(reason),
|
|
353
354
|
});
|
|
354
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
|
+
|
|
355
394
|
let processedCount = 0;
|
|
356
395
|
let errorCount = 0;
|
|
357
396
|
let consecutiveNetworkErrors = 0;
|
|
@@ -403,20 +442,13 @@ export async function handleExplore(options) {
|
|
|
403
442
|
// 切换任务前检测验证码
|
|
404
443
|
const switchCaptcha = await detectCaptcha(page);
|
|
405
444
|
if (switchCaptcha && switchCaptcha.visible) {
|
|
406
|
-
console.error(`\n[验证码]
|
|
445
|
+
console.error(`\n[验证码] 切换任务前检测到验证码`);
|
|
407
446
|
captchaCount++;
|
|
408
447
|
console.error(` [验证码] 累计 ${captchaCount} 次`);
|
|
409
448
|
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// 尝试关闭
|
|
414
|
-
const closeResult = await closeCaptcha(page);
|
|
415
|
-
if (closeResult.success) {
|
|
416
|
-
console.error(` [验证码] 已关闭`);
|
|
417
|
-
} else {
|
|
418
|
-
console.error(` [验证码] 关闭失败: ${closeResult.reason}`);
|
|
419
|
-
}
|
|
449
|
+
// 轮询等待人工解决(解决即继续,超时尝试关闭);
|
|
450
|
+
// 不再盲等 3 分钟——同一个验证码若未解决会在下一轮重复计数凑满切账户阈值
|
|
451
|
+
const resolved = await waitForCaptchaResolved();
|
|
420
452
|
|
|
421
453
|
// 上报
|
|
422
454
|
await withRetry("report captcha", () =>
|
|
@@ -424,16 +456,24 @@ export async function handleExplore(options) {
|
|
|
424
456
|
userId,
|
|
425
457
|
username: "unknown",
|
|
426
458
|
errorType: "captcha",
|
|
427
|
-
errorMessage:
|
|
459
|
+
errorMessage: resolved
|
|
460
|
+
? "切换任务前检测到验证码(已解决)"
|
|
461
|
+
: "切换任务前检测到验证码(超时未解决)",
|
|
428
462
|
stage: "between-tasks",
|
|
429
463
|
errorStack: "",
|
|
430
464
|
}),
|
|
431
465
|
).catch(() => {});
|
|
432
466
|
|
|
433
|
-
//
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
467
|
+
// 超时未解决才累计切账户;已解决的不计入(人工处理完了不应消耗账户)
|
|
468
|
+
if (!resolved) {
|
|
469
|
+
if (captchaCount >= 3) {
|
|
470
|
+
await handleAccountSwitch(
|
|
471
|
+
`验证码累计 ${captchaCount} 次(超时未解决)`,
|
|
472
|
+
);
|
|
473
|
+
captchaCount = 0;
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
captchaCount--; // 解决了就不算一次独立事件
|
|
437
477
|
}
|
|
438
478
|
}
|
|
439
479
|
|
|
@@ -657,9 +697,17 @@ export async function handleExplore(options) {
|
|
|
657
697
|
}),
|
|
658
698
|
).catch(() => {});
|
|
659
699
|
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
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
|
+
);
|
|
663
711
|
captchaCount = 0;
|
|
664
712
|
}
|
|
665
713
|
}
|
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,
|
package/src/lib/constants.js
CHANGED
|
@@ -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(
|
|
3
|
+
const container = document.querySelector(".captcha-verify-container");
|
|
4
4
|
if (!container) return null;
|
|
5
5
|
|
|
6
6
|
const r = container.getBoundingClientRect();
|
|
@@ -26,15 +26,22 @@ export async function detectCaptchaWithWait(page, timeoutMs = 5000) {
|
|
|
26
26
|
try {
|
|
27
27
|
const handle = await page.waitForFunction(
|
|
28
28
|
() => {
|
|
29
|
-
const c = document.querySelector(
|
|
29
|
+
const c = document.querySelector(".captcha-verify-container");
|
|
30
30
|
return !!(c && c.offsetParent !== null);
|
|
31
31
|
},
|
|
32
32
|
{ timeout: timeoutMs },
|
|
33
33
|
);
|
|
34
|
-
const appeared = handle
|
|
34
|
+
const appeared = handle
|
|
35
|
+
? await handle.jsonValue().catch(() => false)
|
|
36
|
+
: false;
|
|
35
37
|
if (!appeared) return null;
|
|
36
38
|
// 出现后用原函数拿完整信息(rect 等);异常按未出现处理
|
|
37
|
-
return (
|
|
39
|
+
return (
|
|
40
|
+
(await detectCaptcha(page).catch(() => null)) || {
|
|
41
|
+
exists: true,
|
|
42
|
+
visible: true,
|
|
43
|
+
}
|
|
44
|
+
);
|
|
38
45
|
} catch {
|
|
39
46
|
return null; // 超时或页面异常:未出现
|
|
40
47
|
}
|
|
@@ -42,12 +49,12 @@ export async function detectCaptchaWithWait(page, timeoutMs = 5000) {
|
|
|
42
49
|
|
|
43
50
|
export async function closeCaptcha(page) {
|
|
44
51
|
return page.evaluate(() => {
|
|
45
|
-
const closeBtn = document.getElementById(
|
|
46
|
-
if (!closeBtn) return { success: false, reason:
|
|
52
|
+
const closeBtn = document.getElementById("captcha_close_button");
|
|
53
|
+
if (!closeBtn) return { success: false, reason: "close button not found" };
|
|
47
54
|
|
|
48
|
-
closeBtn.dispatchEvent(new MouseEvent(
|
|
49
|
-
closeBtn.dispatchEvent(new MouseEvent(
|
|
50
|
-
closeBtn.dispatchEvent(new MouseEvent(
|
|
55
|
+
closeBtn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
56
|
+
closeBtn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
|
57
|
+
closeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
51
58
|
|
|
52
59
|
return { success: true };
|
|
53
60
|
});
|
|
@@ -59,12 +66,13 @@ export async function handleCaptcha(page, options = {}) {
|
|
|
59
66
|
const captcha = await detectCaptcha(page);
|
|
60
67
|
if (!captcha) return { detected: false, closed: false };
|
|
61
68
|
|
|
62
|
-
await new Promise(r => setTimeout(r, waitMs));
|
|
69
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
63
70
|
|
|
64
71
|
const result = await closeCaptcha(page);
|
|
65
|
-
if (!result.success)
|
|
72
|
+
if (!result.success)
|
|
73
|
+
return { detected: true, closed: false, reason: result.reason };
|
|
66
74
|
|
|
67
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
75
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
68
76
|
|
|
69
77
|
const stillThere = await detectCaptcha(page);
|
|
70
78
|
return { detected: true, closed: !stillThere };
|
|
@@ -74,8 +82,8 @@ export async function getIncognitoPage(browser, url, options = {}) {
|
|
|
74
82
|
const { waitMs = 3000 } = options;
|
|
75
83
|
const context = await browser.newContext();
|
|
76
84
|
const page = await context.newPage();
|
|
77
|
-
await page.goto(url, { waitUntil:
|
|
78
|
-
await new Promise(r => setTimeout(r, waitMs));
|
|
85
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
86
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
79
87
|
return { page, context };
|
|
80
88
|
}
|
|
81
89
|
|
|
@@ -85,13 +93,13 @@ export async function waitAndGetCaptcha(page, options = {}) {
|
|
|
85
93
|
const captcha = await detectCaptcha(page);
|
|
86
94
|
if (!captcha) return { detected: false, resolved: false, waited: 0 };
|
|
87
95
|
|
|
88
|
-
if (log) log(
|
|
96
|
+
if (log) log(" 检测到验证码,等待用户手动输入...");
|
|
89
97
|
|
|
90
98
|
const startTime = Date.now();
|
|
91
99
|
const deadline = startTime + waitMs;
|
|
92
100
|
|
|
93
101
|
while (Date.now() < deadline) {
|
|
94
|
-
await new Promise(r => setTimeout(r, pollInterval));
|
|
102
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
95
103
|
const remaining = await detectCaptcha(page);
|
|
96
104
|
if (!remaining) {
|
|
97
105
|
const waited = Math.round((Date.now() - startTime) / 1000);
|
|
@@ -105,7 +113,7 @@ export async function waitAndGetCaptcha(page, options = {}) {
|
|
|
105
113
|
|
|
106
114
|
// 超时后尝试关闭验证码弹窗
|
|
107
115
|
await closeCaptcha(page);
|
|
108
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
116
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
109
117
|
|
|
110
118
|
return { detected: true, resolved: false, waited };
|
|
111
119
|
}
|
|
@@ -115,22 +123,30 @@ export async function safeClickComment(page, options = {}) {
|
|
|
115
123
|
|
|
116
124
|
// 点击评论
|
|
117
125
|
await page.evaluate(() => {
|
|
118
|
-
const all = document.querySelectorAll(
|
|
126
|
+
const all = document.querySelectorAll("button");
|
|
119
127
|
for (const el of all) {
|
|
120
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
/^评论$/.test(el.textContent?.trim()) &&
|
|
130
|
+
el.offsetParent !== null &&
|
|
131
|
+
el.getBoundingClientRect().width > 0
|
|
132
|
+
) {
|
|
121
133
|
el.click();
|
|
122
134
|
break;
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
});
|
|
126
138
|
|
|
127
|
-
await new Promise(r => setTimeout(r, waitMs));
|
|
139
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
128
140
|
|
|
129
141
|
// 检测并关闭验证码
|
|
130
142
|
const captcha = await detectCaptcha(page);
|
|
131
143
|
if (captcha) {
|
|
132
144
|
const result = await handleCaptcha(page);
|
|
133
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
clicked: true,
|
|
147
|
+
captchaDetected: true,
|
|
148
|
+
captchaClosed: result.closed,
|
|
149
|
+
};
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
return { clicked: true, captchaDetected: false, captchaClosed: false };
|