tt-help-cli-ycl 1.4.164 → 1.4.165

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.164",
3
+ "version": "1.4.165",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 候选集迟滞(mergeHealthyWithHysteresis)单测(离线,不碰真实健康文件)
3
+ *
4
+ * 背景:delay 初测为单点采样,瞬时抖动会把健康节点误踢出池(2026-09-28 生产实锤:
5
+ * 英国节点被白名单守卫切回)。迟滞规则:连续 2 轮初测失败才除名;失败第 1 轮保留
6
+ * 在池内作"降级观察",写盘顺序沉底(幸存者在前后降级在后,轮换优先实测存活节点)。
7
+ *
8
+ * 覆盖:
9
+ * - 首轮(无上一轮池)→ 仅幸存者,无降级
10
+ * - 本轮失败第 1 轮 → 保留 + 计数 1,排幸存者之后
11
+ * - 连续第 2 轮失败(prevDegraded=1)→ 除名
12
+ * - 降级后恢复 → 计数自动清零
13
+ * - maxRounds=1 → 关闭迟滞(失败即除名)
14
+ * - 容错:prev 非法值 / 空 prevDegraded
15
+ * - 文件往返:saveHealthyList(degraded) → loadHealthyList 读回
16
+ * - pruneHealthyListByCurrentProxies 剔除节点时携带留存节点的 degraded
17
+ *
18
+ * 运行: node scripts/test-clash-hysteresis.mjs
19
+ */
20
+ import assert from "node:assert/strict";
21
+ import { unlinkSync, existsSync } from "fs";
22
+ import { tmpdir } from "os";
23
+ import { join } from "path";
24
+ import {
25
+ mergeHealthyWithHysteresis,
26
+ saveHealthyList,
27
+ loadHealthyList,
28
+ pruneHealthyListByCurrentProxies,
29
+ applyDiscovered,
30
+ } from "../src/clash/checker.js";
31
+
32
+ let passed = 0;
33
+ const failures = [];
34
+ async function check(name, fn) {
35
+ try {
36
+ await fn();
37
+ passed++;
38
+ console.log(`PASS ${name}`);
39
+ } catch (e) {
40
+ failures.push({ name, e });
41
+ console.log(`FAIL ${name}: ${e.message}`);
42
+ }
43
+ }
44
+
45
+ // ── 纯函数:mergeHealthyWithHysteresis ──
46
+
47
+ await check("首轮(无上一轮池)→ 仅幸存者,无降级", () => {
48
+ const r = mergeHealthyWithHysteresis(["A", "B"], [], {}, 2);
49
+ assert.deepEqual(r.nodes, ["A", "B"]);
50
+ assert.deepEqual(r.degraded, {});
51
+ });
52
+
53
+ await check("本轮失败第 1 轮 → 保留 + 计数 1,排幸存者之后", () => {
54
+ // 上一轮池 [A,B,C],本轮只有 A 可达:B/C 首次失败,保留观察
55
+ const r = mergeHealthyWithHysteresis(["A"], ["A", "B", "C"], {}, 2);
56
+ assert.deepEqual(r.nodes, ["A", "B", "C"]); // 幸存者在前、降级在后(B/C 保持原相对序)
57
+ assert.deepEqual(r.degraded, { B: 1, C: 1 });
58
+ });
59
+
60
+ await check("连续第 2 轮失败(prevDegraded=1)→ 除名", () => {
61
+ // B 上一轮已失败 1 次,本轮再失败 → 达到 maxRounds=2,除名;C 首次失败保留
62
+ const r = mergeHealthyWithHysteresis(["A"], ["A", "B", "C"], { B: 1 }, 2);
63
+ assert.deepEqual(r.nodes, ["A", "C"]);
64
+ assert.deepEqual(r.degraded, { C: 1 });
65
+ assert.ok(!("B" in r.degraded));
66
+ });
67
+
68
+ await check("降级后恢复 → 计数自动清零", () => {
69
+ // C 上轮降级(计数 1),本轮恢复 → 回到幸存者区,degraded 中无 C;
70
+ // B 本轮首次失败,照常保留观察
71
+ const r = mergeHealthyWithHysteresis(
72
+ ["A", "C"],
73
+ ["A", "B", "C"],
74
+ { C: 1 },
75
+ 2,
76
+ );
77
+ assert.deepEqual(r.nodes, ["A", "C", "B"]);
78
+ assert.deepEqual(r.degraded, { B: 1 });
79
+ });
80
+
81
+ await check("maxRounds=1 → 关闭迟滞(失败即除名)", () => {
82
+ const r = mergeHealthyWithHysteresis(["A"], ["A", "B"], {}, 1);
83
+ assert.deepEqual(r.nodes, ["A"]);
84
+ assert.deepEqual(r.degraded, {});
85
+ });
86
+
87
+ await check("容错:prev 非法 / prevDegraded 非法", () => {
88
+ const r = mergeHealthyWithHysteresis(["A"], null, "bad", 2);
89
+ assert.deepEqual(r.nodes, ["A"]);
90
+ assert.deepEqual(r.degraded, {});
91
+ });
92
+
93
+ // ── 文件往返(隔离端口通道,不碰真实 clash-healthy-nodes.json) ──
94
+
95
+ const TEST_PORT = 64000 + Math.floor(Math.random() * 1000);
96
+ const TEST_FILE = join(tmpdir(), `clash-healthy-nodes-${TEST_PORT}.json`);
97
+ applyDiscovered({ proxyPort: TEST_PORT });
98
+
99
+ try {
100
+ await check("saveHealthyList(degraded) → loadHealthyList 读回", () => {
101
+ saveHealthyList(
102
+ ["🇺🇸美国HY1", "🇬🇧英国HY1"],
103
+ { "🇺🇸美国HY1": 800 },
104
+ { "🇬🇧英国HY1": 1 },
105
+ );
106
+ const d = loadHealthyList();
107
+ assert.deepEqual(d.nodes, ["🇺🇸美国HY1", "🇬🇧英国HY1"]);
108
+ assert.equal(d.delays["🇺🇸美国HY1"], 800);
109
+ assert.equal(d.degraded["🇬🇧英国HY1"], 1);
110
+ });
111
+
112
+ await check("pruneHealthyListByCurrentProxies 携带留存节点的 degraded", () => {
113
+ // 内核中只剩美国节点:英国被剔除,美国的降级计数(若有)应保留
114
+ saveHealthyList(
115
+ ["🇺🇸美国HY1", "🇺🇸美国HY2", "🇬🇧英国HY1"],
116
+ { "🇺🇸美国HY1": 800 },
117
+ { "🇺🇸美国HY2": 1, "🇬🇧英国HY1": 1 },
118
+ );
119
+ const r = pruneHealthyListByCurrentProxies(["🇺🇸美国HY1", "🇺🇸美国HY2"]);
120
+ assert.equal(r.removed, 1);
121
+ const d = loadHealthyList();
122
+ assert.deepEqual(d.nodes, ["🇺🇸美国HY1", "🇺🇸美国HY2"]);
123
+ assert.equal(d.degraded["🇺🇸美国HY2"], 1);
124
+ assert.ok(!("🇬🇧英国HY1" in d.degraded));
125
+ });
126
+ } finally {
127
+ if (existsSync(TEST_FILE)) unlinkSync(TEST_FILE);
128
+ }
129
+
130
+ console.log(`\n结果: ${passed} 通过, ${failures.length} 失败`);
131
+ if (failures.length) process.exit(1);
@@ -1,10 +1,12 @@
1
1
  /**
2
- * 客户端异常监控单测(2026-08-28)
2
+ * 客户端异常监控单测(2026-08-28,2026-09-28 增补验证码路径)
3
3
  *
4
4
  * 验证:
5
- * - detectClientError 检测函数:reportCount > 8 且最近一次上报在 2 分钟内
5
+ * - detectClientError 检测函数:
6
+ * A. reportCount > 8 且最近一次上报在 2 分钟内
7
+ * B. captchaCount ≥ 3(webserver 验证码上报,10 分钟过期窗口内累计)
6
8
  * - 按 hostname 过滤本机条目;hostname 为空的旧条目不参与关联
7
- * - 命中 → onAlert 收到本机全部条目(含未命中条目);未命中 → 不回调
9
+ * - 命中 → onAlert 收到本机全部条目与命中列表;未命中 → 不回调
8
10
  * - removeErrors 逐条 DELETE 并计数
9
11
  *
10
12
  * 运行: node --test scripts/test-client-error-monitor.mjs
@@ -60,6 +62,79 @@ test("D4 detectClientError:缺 reportCount → 不命中", () => {
60
62
  assert.equal(detectClientError({ timestamp: Date.now() }, Date.now()), false);
61
63
  });
62
64
 
65
+ // ── 验证码路径(captchaCount ≥ 3) ──
66
+
67
+ test("C1 detectClientError:captchaCount=3 → 命中", () => {
68
+ assert.equal(
69
+ detectClientError(
70
+ makeErr({ reportCount: 0, captchaCount: 3 }),
71
+ Date.now(),
72
+ ),
73
+ true,
74
+ );
75
+ });
76
+
77
+ test("C2 detectClientError:captchaCount=2 不命中(阈值是 ≥3)", () => {
78
+ assert.equal(
79
+ detectClientError(
80
+ makeErr({ reportCount: 0, captchaCount: 2 }),
81
+ Date.now(),
82
+ ),
83
+ false,
84
+ );
85
+ });
86
+
87
+ test("C3 detectClientError:captchaCount=3 但最近上报超 2 分钟 → 不命中", () => {
88
+ assert.equal(
89
+ detectClientError(
90
+ makeErr({
91
+ reportCount: 0,
92
+ captchaCount: 3,
93
+ timestamp: Date.now() - 2 * 60_000 - 1,
94
+ }),
95
+ Date.now(),
96
+ ),
97
+ false,
98
+ );
99
+ });
100
+
101
+ test("C4 detectClientError:reportCount 未达标 + captchaCount 达标 → 命中", () => {
102
+ assert.equal(
103
+ detectClientError(
104
+ makeErr({ reportCount: 1, captchaCount: 5 }),
105
+ Date.now(),
106
+ ),
107
+ true,
108
+ );
109
+ });
110
+
111
+ test("C5 监控器:验证码条目命中 → onAlert 收到命中列表", async () => {
112
+ const captchaHit = makeErr({
113
+ userId: "webserver-host",
114
+ reportCount: 0,
115
+ captchaCount: 3,
116
+ });
117
+ const businessMiss = makeErr({ userId: "u-task", reportCount: 2 });
118
+ mock.method(globalThis, "fetch", async () => ({
119
+ ok: true,
120
+ json: async () => ({ clients: [captchaHit, businessMiss] }),
121
+ }));
122
+ let received = null;
123
+ const m = new ClientErrorMonitor({
124
+ serverUrl: "http://127.0.0.1:3000",
125
+ hostname: HOST,
126
+ onAlert: async (mine, hit) => {
127
+ received = { mine, hit };
128
+ },
129
+ });
130
+ const hit = await m._poll();
131
+ assert.equal(hit.length, 1, "仅验证码条目命中");
132
+ assert.equal(hit[0].userId, "webserver-host");
133
+ assert.equal(received.hit.length, 1);
134
+ assert.equal(received.mine.length, 2, "回调同时携带本机未命中条目");
135
+ mock.restoreAll();
136
+ });
137
+
63
138
  // ── 监控器 ──
64
139
 
65
140
  test("M1 按 hostname 过滤:他机条目不触发本机回调", async () => {
@@ -244,6 +244,36 @@ check("switchClashNode:选第一个 delay 通过的候选并切换", async ()
244
244
  }
245
245
  });
246
246
 
247
+ check("switchClashNode:批量测速(/group/{G}/delay)优先且覆盖候选时不逐个探测", async () => {
248
+ let switchedTo = null;
249
+ let perNodeDelayCalls = 0;
250
+ const server = await startMockClash((req, url, body, reply) => {
251
+ if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
252
+ reply(200, { now: "A", all: ["A", "B", "C"] });
253
+ } else if (req.method === "GET" && url.pathname === "/group/GLOBAL/delay") {
254
+ reply(200, { B: 88, C: 999 });
255
+ } else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
256
+ perNodeDelayCalls++; // 批量可用时不应走到这里
257
+ reply(200, { delay: 120, timeout: false });
258
+ } else if (req.method === "PUT" && url.pathname === "/proxies/GLOBAL") {
259
+ switchedTo = body.name;
260
+ reply(204);
261
+ } else {
262
+ reply(404, {});
263
+ }
264
+ });
265
+ configure({ port: server.address().port });
266
+ try {
267
+ const picked = await switchClashNode("测试批量优先");
268
+ assert.equal(picked, "B");
269
+ assert.equal(switchedTo, "B");
270
+ assert.equal(perNodeDelayCalls, 0);
271
+ } finally {
272
+ resetConfig();
273
+ await new Promise((r) => server.close(r));
274
+ }
275
+ });
276
+
247
277
  check("switchClashNode:候选均探测不通 → 抛错", async () => {
248
278
  const server = await startMockClash((req, url, body, reply) => {
249
279
  if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
@@ -461,17 +461,23 @@ export function loadHealthyList(country = null) {
461
461
  isUsableProxyNode,
462
462
  );
463
463
  const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
464
+ const degradedRaw =
465
+ d.degraded && typeof d.degraded === "object" ? d.degraded : {};
464
466
  const filter = country || CONFIG.nodeCountryFilter;
465
467
  const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
466
468
  const delays = {};
469
+ const degraded = {};
467
470
  for (const n of filtered) {
468
471
  const v = allDelays[n];
469
472
  if (typeof v === "number" && v > 0) delays[n] = v;
473
+ const g = degradedRaw[n];
474
+ if (typeof g === "number" && g > 0) degraded[n] = g;
470
475
  }
471
476
  return {
472
477
  updated: d.updated || "",
473
478
  nodes: filtered,
474
479
  delays,
480
+ degraded,
475
481
  totalNodes: allNodes.length,
476
482
  excluded,
477
483
  countryFilter: filter || null,
@@ -482,12 +488,17 @@ export function loadHealthyList(country = null) {
482
488
  return _emptyList();
483
489
  }
484
490
 
485
- export function saveHealthyList(nodes, delays = {}) {
491
+ export function saveHealthyList(nodes, delays = {}, degraded = {}) {
486
492
  try {
487
493
  writeFileSync(
488
494
  _healthyFile,
489
495
  JSON.stringify(
490
- { updated: new Date().toISOString(), nodes, delays: delays || {} },
496
+ {
497
+ updated: new Date().toISOString(),
498
+ nodes,
499
+ delays: delays || {},
500
+ degraded: degraded || {},
501
+ },
491
502
  null,
492
503
  2,
493
504
  ),
@@ -498,6 +509,42 @@ export function saveHealthyList(nodes, delays = {}) {
498
509
  }
499
510
  }
500
511
 
512
+ /**
513
+ * 候选集迟滞合并(纯函数,便于单测)
514
+ *
515
+ * 背景:delay 初测为单点采样,瞬时抖动会把健康节点误踢出池(2026-09-28 生产实锤:
516
+ * 英国节点被白名单守卫切回)。规则:连续 maxRounds 轮初测失败才除名;失败第 1 轮
517
+ * 保留在池内作"降级观察",且排在幸存者之后(写盘顺序即沉底,轮换优先尝试实测存活节点)。
518
+ *
519
+ * @param {string[]} survivors 本轮初测幸存者(delay ≤ 阈值,升序)
520
+ * @param {string[]} prevNodes 上一轮池内节点(含上轮降级保留)
521
+ * @param {Record<string,number>} prevDegraded 上一轮降级计数 {node: 连续失败轮数}
522
+ * @param {number} maxRounds 连续失败轮数上限(达到即除名),默认 2;1 = 关闭迟滞
523
+ * @returns {{nodes: string[], degraded: Record<string, number>}} 合并后的池
524
+ * (幸存者在前、降级保留在后)与新降级计数;本轮恢复的节点计数自动清零
525
+ */
526
+ export function mergeHealthyWithHysteresis(
527
+ survivors,
528
+ prevNodes,
529
+ prevDegraded = {},
530
+ maxRounds = 2,
531
+ ) {
532
+ const list = Array.isArray(survivors) ? survivors : [];
533
+ const survivorSet = new Set(list);
534
+ const degraded = {};
535
+ const retained = [];
536
+ for (const n of Array.isArray(prevNodes) ? prevNodes : []) {
537
+ if (survivorSet.has(n)) continue; // 本轮存活:不进新 degraded,计数自动清零
538
+ const fails = (Number(prevDegraded[n]) || 0) + 1;
539
+ if (fails < maxRounds) {
540
+ degraded[n] = fails;
541
+ retained.push(n);
542
+ }
543
+ // fails >= maxRounds:连续多轮失败,确认除名
544
+ }
545
+ return { nodes: [...list, ...retained], degraded };
546
+ }
547
+
501
548
  /**
502
549
  * 剔除候选集中已不在当前内核配置中的节点(订阅切换 / 订阅更新场景)
503
550
  *
@@ -516,6 +563,8 @@ export function pruneHealthyListByCurrentProxies(validNames) {
516
563
  const d = JSON.parse(readFileSync(_healthyFile, "utf-8"));
517
564
  const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
518
565
  const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
566
+ const degradedRaw =
567
+ d.degraded && typeof d.degraded === "object" ? d.degraded : {};
519
568
  const valid = new Set(validNames);
520
569
  const keptNodes = allNodes.filter((n) => valid.has(n));
521
570
  const removed = allNodes.length - keptNodes.length;
@@ -523,11 +572,14 @@ export function pruneHealthyListByCurrentProxies(validNames) {
523
572
  return { kept: keptNodes.length, removed: 0, allGone: false };
524
573
  }
525
574
  const keptDelays = {};
575
+ const keptDegraded = {};
526
576
  for (const n of keptNodes) {
527
577
  const v = allDelays[n];
528
578
  if (typeof v === "number" && v > 0) keptDelays[n] = v;
579
+ const g = degradedRaw[n];
580
+ if (typeof g === "number" && g > 0) keptDegraded[n] = g;
529
581
  }
530
- saveHealthyList(keptNodes, keptDelays);
582
+ saveHealthyList(keptNodes, keptDelays, keptDegraded);
531
583
  return {
532
584
  kept: keptNodes.length,
533
585
  removed,
@@ -544,6 +596,7 @@ function _emptyList() {
544
596
  updated: "",
545
597
  nodes: [],
546
598
  delays: {},
599
+ degraded: {},
547
600
  totalNodes: 0,
548
601
  excluded: 0,
549
602
  countryFilter: null,
@@ -24,6 +24,10 @@ function fmtCountries(v) {
24
24
  // failover 冷却期(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
25
25
  // 锚点 = 最近一次实际切换节点的时间(_lastSwitchAt):冷却期内探针健康不切换
26
26
  const FAILOVER_COOLDOWN_MS = 600_000;
27
+ // 候选集迟滞(2026-09-28):delay 初测为单点采样,瞬时抖动会把健康节点误踢出池
28
+ // (生产实锤:英国节点被白名单守卫切回)。连续 PROBE_HYSTERESIS_ROUNDS 轮初测失败
29
+ // 才除名;失败第 1 轮保留在池内作"降级观察",写盘时排在幸存者之后(轮换优先实测存活)
30
+ const PROBE_HYSTERESIS_ROUNDS = 2;
27
31
  // 已弃用(2026-08-25 改为时间锚点方案):旧版"窗口内连报 N 次"计数,字段保留仅为兼容外部传参
28
32
  const FAILOVER_ESCAPE_AFTER = 3;
29
33
 
@@ -972,10 +976,20 @@ export class ClashDaemon {
972
976
  if (typeof it.delay === "number" && it.delay > 0)
973
977
  delays[it.node] = it.delay;
974
978
  }
975
- checker.saveHealthyList(r.nodes, delays);
979
+ // 迟滞合并:本轮失败的上一轮池内节点保留一轮观察(降级沉底),
980
+ // 连续 PROBE_HYSTERESIS_ROUNDS 轮失败才除名;恢复的节点计数自动清零
981
+ const prev = checker.loadHealthyList();
982
+ const merged = checker.mergeHealthyWithHysteresis(
983
+ r.nodes,
984
+ prev.nodes,
985
+ prev.degraded,
986
+ PROBE_HYSTERESIS_ROUNDS,
987
+ );
988
+ checker.saveHealthyList(merged.nodes, delays, merged.degraded);
989
+ const retainedCount = merged.nodes.length - r.nodes.length;
976
990
  // 清理切换历史中已不在候选集的节点,防止 Map 无限增长
977
991
  if (c._switchHistory && c._switchHistory.size) {
978
- const keep = new Set(r.nodes);
992
+ const keep = new Set(merged.nodes);
979
993
  for (const k of [...c._switchHistory.keys()]) {
980
994
  if (!keep.has(k)) c._switchHistory.delete(k);
981
995
  }
@@ -985,7 +999,7 @@ export class ClashDaemon {
985
999
  c._meta.healthyNodesCount = r.nodes.length;
986
1000
  if (r.nodes.length) {
987
1001
  this._log(
988
- `=== [${c.proxyPort}] delay 初测完成 (${elapsed}s): ${r.nodes.length} 个可达节点 ===`,
1002
+ `=== [${c.proxyPort}] delay 初测完成 (${elapsed}s): ${r.nodes.length} 个可达${retainedCount ? `(另降级保留 ${retainedCount} 个)` : ""} ===`,
989
1003
  );
990
1004
  } else {
991
1005
  this._log(
@@ -37,12 +37,16 @@ export function isDaemonAlive(port = 0) {
37
37
  * @param {string} reason 切换原因
38
38
  * @param {number} [proxyPort] 代理通道端口(0=默认通道)
39
39
  * @param {number} [waitMs] 等待毫秒数,默认 30000
40
+ * @param {object} [opts]
41
+ * @param {boolean} [opts.force] 强切(无视 daemon failover 冷却期直接轮换);
42
+ * 供调用方已自行节流的场景(如 webserver CDN 自愈:100s 冷却+5min 窗口+次数上限)
40
43
  * @returns {boolean} 是否发起了切换(有代理端口)
41
44
  */
42
45
  export async function triggerProxySwitch(
43
46
  reason,
44
47
  proxyPort = 0,
45
48
  waitMs = DEFAULT_SWITCH_WAIT_MS,
49
+ { force = false } = {},
46
50
  ) {
47
51
  if (!proxyPort) {
48
52
  console.error(` [代理] 未配置代理,跳过切换(${reason})`);
@@ -57,7 +61,7 @@ export async function triggerProxySwitch(
57
61
  ` [代理] 触发切换(${reason},通道 ${proxyPort}),等待 ${Math.round(waitMs / 1000)}s 生效...`,
58
62
  );
59
63
  writeCommand(
60
- { action: "failover", reason: `proxy-switch: ${reason}`, proxyPort },
64
+ { action: "failover", reason: `proxy-switch: ${reason}`, proxyPort, force },
61
65
  proxyPort,
62
66
  );
63
67
  await new Promise((r) => setTimeout(r, waitMs));
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync, readdirSync, copyFileSync, openSync, renameSync, lstatSync, symlinkSync } from "fs";
17
17
  import net from "net";
18
- import { homedir, platform, arch } from "os";
18
+ import { homedir, platform, arch, tmpdir } from "os";
19
19
  import { join, dirname } from "path";
20
20
  import { spawn, spawnSync, execSync } from "child_process";
21
21
  import { gunzipSync } from "zlib";
@@ -681,6 +681,13 @@ export async function stopRuntimeInstance(index = 1, { log = () => {} } = {}) {
681
681
  } catch {}
682
682
  }
683
683
  }
684
+ if (killed) {
685
+ // 回收成功 → 一并清理通道健康文件,避免幽灵通道残留
686
+ //(daemon _rediscover 后不再巡检该实例,文件永远不会被覆盖,看门狗页面会多出幽灵订阅)
687
+ try {
688
+ rmSync(join(tmpdir(), `clash-health-${spec.mixedPort}.json`), { force: true });
689
+ } catch {}
690
+ }
684
691
  return killed;
685
692
  }
686
693
 
@@ -222,6 +222,92 @@ function killEdgeProcesses(targetDir, port) {
222
222
  });
223
223
  }
224
224
 
225
+ /**
226
+ * 查找"残留" Edge 主进程:命令行包含指定 user-data-dir、但不带
227
+ * --remote-debugging-port(Edge 自动更新/崩溃自恢复、上次强杀残留等,
228
+ * 占着 profile 却没监听 CDP 端口)。
229
+ * 排除带 --type= 的子进程:渲染进程等子进程同样带 user-data-dir 却没有
230
+ * CDP 参数,误杀会打断同 profile 的健康实例。
231
+ *
232
+ * 这类实例必须先杀掉再启动:否则新 msedge 会因 Chromium ProcessSingleton
233
+ * 把命令行转发给旧实例,变成"旧实例开一个 about:blank 新 tab",CDP 端口
234
+ * 永远起不来 → 等待超时 → watchdog 自动重启 → 每轮再开一个 tab。
235
+ *
236
+ * killEdgeProcesses 在 Windows 上按监听端口找 PID,对这类实例无效,
237
+ * 故单独按命令行匹配精确清理。
238
+ *
239
+ * @param {string} targetDir - user-data-dir 路径
240
+ * @returns {string[]|null} PID 列表;无法枚举进程命令行(PowerShell/WMI 不可用)时返回 null
241
+ */
242
+ function findStaleEdgePids(targetDir) {
243
+ const platform = os.platform();
244
+ try {
245
+ let out;
246
+ if (platform === "win32") {
247
+ // PowerShell 为主(wmic 已从新版 Win11 移除;项目其他模块同样依赖 PowerShell)
248
+ out = decodeWinOutput(
249
+ execSync(
250
+ 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'msedge.exe\' } | ForEach-Object { Write-Output ($_.ProcessId.ToString() + \'|\' + $_.CommandLine) }"',
251
+ { encoding: "buffer", timeout: 10000, windowsHide: true },
252
+ ),
253
+ );
254
+ } else {
255
+ out = execSync("ps -eo pid,args", {
256
+ encoding: "utf-8",
257
+ timeout: 10000,
258
+ });
259
+ }
260
+ const dirLower = targetDir.toLowerCase();
261
+ const pids = [];
262
+ for (const line of out.split(/\r?\n/)) {
263
+ const lower = line.toLowerCase();
264
+ if (!lower.includes(dirLower)) continue; // 只匹配本 profile
265
+ // 边界保护:避免 _p9222 误匹配 _p92221 这类前缀目录
266
+ const idx = lower.indexOf(dirLower);
267
+ const next = lower[idx + dirLower.length];
268
+ if (next && /[a-z0-9_]/.test(next)) continue;
269
+ if (lower.includes("remote-debugging-port")) continue; // 健康 CDP 实例,不动
270
+ if (lower.includes("--type=")) continue; // 子进程,误杀会打断健康实例
271
+ // 提取 PID:win 格式 "PID|命令行",unix 格式 " PID 命令行"
272
+ const m = line.match(/^\s*(\d+)(?:\||\s)/);
273
+ if (m && !pids.includes(m[1])) pids.push(m[1]);
274
+ }
275
+ return pids;
276
+ } catch (e) {
277
+ console.error(`[findStaleEdgePids] 枚举 Edge 进程失败: ${e.message}`);
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 强杀指定 PID 的 Edge 进程树(Windows 用 taskkill /T 保级联,其余平台 kill -9)。
284
+ * @param {string[]} pids
285
+ * @returns {Promise<void>}
286
+ */
287
+ function killEdgeStale(pids) {
288
+ const platform = os.platform();
289
+ if (platform === "win32") {
290
+ const killCmds = pids.map((pid) => `taskkill /F /T /PID ${pid}`).join(" & ");
291
+ try {
292
+ const out = execSync(`(${killCmds})`, {
293
+ encoding: "buffer",
294
+ timeout: 15000,
295
+ windowsHide: true,
296
+ });
297
+ const lines = decodeWinOutput(out).split(/\r?\n/).filter(Boolean);
298
+ console.error(
299
+ `[killEdgeStale] taskkill 结果: ${lines.join(" | ") || "(无输出)"}`,
300
+ );
301
+ } catch (e) {
302
+ console.error(`[killEdgeStale] taskkill 失败: ${e.message}`);
303
+ }
304
+ return Promise.resolve();
305
+ }
306
+ return new Promise((resolve) => {
307
+ exec(`kill -9 ${pids.join(" ")} 2>/dev/null; true`, () => resolve());
308
+ });
309
+ }
310
+
225
311
  function launchEdgeWithCDP(port, userDataDir, proxyServer) {
226
312
  return new Promise((resolve, reject) => {
227
313
  const platform = os.platform();
@@ -319,10 +405,27 @@ export async function ensureBrowserReady(options = {}) {
319
405
  console.error(`CDP 端口 ${port} 未就绪,正在启动 Edge 浏览器...`);
320
406
  }
321
407
  }
408
+ // 残留实例清理:占着该 user-data-dir 却没开 CDP 的旧 Edge 必须先杀掉,
409
+ // 否则 launch 会被 ProcessSingleton 转发成"旧实例开新 tab",CDP 永远起不来。
410
+ const stalePids = findStaleEdgePids(userDataDir);
411
+ if (stalePids === null) {
412
+ console.error("[浏览器] 无法枚举 Edge 进程命令行,跳过残留实例检测");
413
+ } else if (stalePids.length > 0) {
414
+ console.error(
415
+ `[浏览器] 检测到占用 ${path.basename(userDataDir)} 但未开 CDP 的残留 Edge 实例 (PID: ${stalePids.join(", ")}),正在清理...`,
416
+ );
417
+ await killEdgeStale(stalePids);
418
+ await new Promise((r) => setTimeout(r, 3000));
419
+ }
420
+
322
421
  await launchEdgeWithCDP(port, userDataDir, proxyServer);
323
422
 
324
423
  console.error("等待浏览器启动...");
325
- const launched = await waitForCDP(port);
424
+ // Windows 冷启动(杀软扫描/更新后首次启动)可能超过 30s,放宽到 60s
425
+ const launched = await waitForCDP(
426
+ port,
427
+ os.platform() === "win32" ? 60000 : 30000,
428
+ );
326
429
  if (!launched) {
327
430
  throw new Error(
328
431
  `等待 CDP 端口 ${port} 超时。请确认 Edge 浏览器已安装,\n` +
@@ -5316,6 +5316,8 @@ function renderProxySection(c, userId) {
5316
5316
  }
5317
5317
  var html = renderSubsOverview(c, userId, builtin, channels);
5318
5318
  for (var j = 0; j < external.length; j++) {
5319
+ // 幽灵外部通道(>2min 未更新的残留健康文件)不渲染
5320
+ if (external[j].stale) continue;
5319
5321
  // 外部通道的订阅配置入口:仅当没有总览区时显示(配置入口全局收敛为一个)
5320
5322
  html += renderExternalChannel(external[j], userId, !html);
5321
5323
  }
@@ -5391,6 +5393,8 @@ function renderSubsOverview(c, userId, builtinChannels, allChannels) {
5391
5393
  }
5392
5394
  var count = configured.length;
5393
5395
  for (var p in byPort) {
5396
+ // 幽灵通道(实例已回收但健康文件残留,>2min 未更新)不得撑大订阅数
5397
+ if (byPort[p] && byPort[p].stale) continue;
5394
5398
  var n = parseInt(p, 10) - _SUBS_BASE_PROXY_PORT + 1;
5395
5399
  if (n > count) count = n;
5396
5400
  }
@@ -27,7 +27,10 @@ import { join, dirname } from "path";
27
27
  import { homedir, tmpdir, hostname as osHostname } from "os";
28
28
  import { fileURLToPath } from "url";
29
29
  import { pushSelfLog, getSelfLogs } from "./self-log-buffer.js";
30
- import { ClientErrorMonitor } from "./client-error-monitor.js";
30
+ import {
31
+ ClientErrorMonitor,
32
+ DETECT_CAPTCHA_COUNT_THRESHOLD,
33
+ } from "./client-error-monitor.js";
31
34
  import { takeoverEnvironment } from "./env-init.js";
32
35
 
33
36
  // ── 看门狗自身日志入环(供后台页面按需拉取,经 report-logs 回传落盘) ──
@@ -719,7 +722,8 @@ export class WatchdogAgent {
719
722
 
720
723
  /**
721
724
  * 启动客户端异常监控:定时拉取 watch 服务端异常列表,
722
- * 命中本机条目(reportCount > 8 且最近 2 分钟内)→ 强切代理 + 删除全部本机条目。
725
+ * 命中本机条目(reportCount > 8 或 captchaCount ≥ 3,且最近 2 分钟内)
726
+ * → 强切代理 + 删除命中条目(防下一轮轮询重复强切)。
723
727
  */
724
728
  _startClientErrorMonitor() {
725
729
  const cfg = getClientConfig();
@@ -734,16 +738,24 @@ export class WatchdogAgent {
734
738
  serverUrl: server,
735
739
  hostname: osHostname(),
736
740
  intervalMs: 60_000,
737
- onAlert: async (mine) => {
738
- const ids = mine.map((e) => e.userId).join(",");
739
- console.error(
740
- `[watchdog] 客户端异常监控命中 ${mine.length} 条本机异常 (${ids}),强制切换代理并清理`,
741
+ onAlert: async (mine, hit) => {
742
+ const ids = hit.map((e) => e.userId).join(",");
743
+ // 验证码计数与业务错误累计共用监控通道,强切原因区分便于排障
744
+ const hasCaptcha = hit.some(
745
+ (e) =>
746
+ typeof e.captchaCount === "number" &&
747
+ e.captchaCount >= DETECT_CAPTCHA_COUNT_THRESHOLD,
741
748
  );
742
- await this._forceFailover("client-errors");
743
- const removed = await this.clientErrorMonitor.removeErrors(mine);
744
749
  console.error(
745
- `[watchdog] 已清理本机异常条目 ${removed}/${mine.length}`,
750
+ `[watchdog] 客户端异常监控命中 ${hit.length} 条本机异常 (${ids})${hasCaptcha ? " [含验证码]" : ""},强制切换代理并清理`,
751
+ );
752
+ await this._forceFailover(
753
+ hasCaptcha ? "client-captcha" : "client-errors",
746
754
  );
755
+ // 仅清理命中条目:命中条目留存会持续满足阈值 → 下轮轮询重复强切;
756
+ // 未命中的本机条目(计数未达标)保留继续累计
757
+ const removed = await this.clientErrorMonitor.removeErrors(hit);
758
+ console.error(`[watchdog] 已清理命中异常条目 ${removed}/${hit.length}`);
747
759
  },
748
760
  });
749
761
  this.clientErrorMonitor.start();
@@ -7,26 +7,46 @@
7
7
  * 3. 逐条过独立检测函数,命中 → onAlert(本机全部条目, 命中条目)
8
8
  * 4. 提供 removeErrors 删除接口(处理完成后的清理)
9
9
  *
10
- * 检测函数第一版:reportCount > 8 且最近一次上报在 2 分钟内。
10
+ * 检测函数(两条命中路径,满足任一即命中):
11
+ * A. reportCount > 8(非验证码业务错误累计)且最近一次上报在 2 分钟内
12
+ * B. captchaCount ≥ 3(webserver 验证码上报,服务端 10 分钟过期窗口内累计)
11
13
  */
12
14
 
13
15
  import os from "os";
14
16
 
15
- /** 检测阈值:累计上报次数 > 8 */
17
+ /** 检测阈值:非验证码错误累计上报次数 > 8 */
16
18
  export const DETECT_REPORT_COUNT_THRESHOLD = 8;
19
+ /** 检测阈值:验证码次数 ≥ 3(10 分钟过期窗口内 3 次独立验证码事件) */
20
+ export const DETECT_CAPTCHA_COUNT_THRESHOLD = 3;
17
21
  /** 检测窗口:最近一次上报距今 < 2 分钟 */
18
22
  export const DETECT_FRESH_MS = 2 * 60 * 1000;
19
23
 
20
24
  /**
21
- * 独立检测函数(第一版)
25
+ * 独立检测函数
22
26
  * @param {object} err - /api/client-errors 返回的单条异常
23
27
  * @param {number} [now] - 当前时间戳(测试注入用)
24
28
  * @returns {boolean}
25
29
  */
26
30
  export function detectClientError(err, now = Date.now()) {
27
- if (!err || typeof err.reportCount !== "number") return false;
31
+ if (!err || typeof err.timestamp !== "number") return false;
28
32
  const fresh = now - err.timestamp < DETECT_FRESH_MS;
29
- return err.reportCount > DETECT_REPORT_COUNT_THRESHOLD && fresh;
33
+ if (!fresh) return false;
34
+ // 路径 A:非验证码业务错误累计
35
+ if (
36
+ typeof err.reportCount === "number" &&
37
+ err.reportCount > DETECT_REPORT_COUNT_THRESHOLD
38
+ ) {
39
+ return true;
40
+ }
41
+ // 路径 B:验证码计数达标(每次验证码上报都刷新 timestamp,达标时必然新鲜,
42
+ // freshness 主要防陈旧累计延迟触发)
43
+ if (
44
+ typeof err.captchaCount === "number" &&
45
+ err.captchaCount >= DETECT_CAPTCHA_COUNT_THRESHOLD
46
+ ) {
47
+ return true;
48
+ }
49
+ return false;
30
50
  }
31
51
 
32
52
  /**
@@ -8,8 +8,17 @@ import {
8
8
  getCurrentNode,
9
9
  listGroupNodes,
10
10
  getProxyDelay,
11
+ getGroupDelay,
11
12
  switchProxy,
13
+ configure as configureClashApi,
12
14
  } from "../clash/api.js";
15
+ import { isUsableProxyNode } from "../clash/checker.js";
16
+ import {
17
+ proxyPortOf,
18
+ isDaemonAlive,
19
+ triggerProxySwitch,
20
+ } from "../clash/proxy-switch.js";
21
+ import { discoverAll } from "../clash/discover.js";
13
22
 
14
23
  // ---- 工具函数 ----
15
24
 
@@ -298,7 +307,8 @@ async function restartScraper(reason) {
298
307
  // ---- CDN 限流自愈(限流窗口计数 + 5 分钟结算 + 切节点 + 躺平)----
299
308
  // 模型(用户定稿):只数"限流窗口",成功请求完全不参与计数。
300
309
  // 每次限流(403/429)→ 计一个限流窗口 + 进入 100s 冷却(静默,请求直接 503);
301
- // 5 分钟窗口内累计 2 个限流窗口 → 立即切换 Clash 节点;
310
+ // 5 分钟窗口内累计 2 个限流窗口 → 请求切节点(汇聚到 clash-daemon:文件 IPC 强切
311
+ // 指令,候选集/白名单/同订阅互斥由 daemon 统一裁决;daemon 未运行时回退直连切换);
302
312
  // 窗口到期(距第一个限流窗口超过 5 分钟)时结算:限流窗口 ≥2 → 切换并重新计数;<2 → 重新计数。
303
313
  // 切节点成功 → 重新计数;连切 CDN_MAX_SWITCHES 个节点仍限流 → 躺平(持续 503 靠 nginx failover)。
304
314
  let cdnCooling = false; // 当前是否处于冷却静默期
@@ -313,6 +323,7 @@ const CDN_SWITCH_COOLDOWN_COUNT = 2; // 5 分钟窗口内限流窗口数阈值
313
323
  const CDN_SWITCH_WINDOW_MS = 5 * 60 * 1000; // 统计窗口:5 分钟
314
324
  const CDN_MAX_SWITCHES = 3; // 连续切换节点次数上限,超过躺平
315
325
  const CLASH_GROUP_NAME = "GLOBAL"; // Clash 选择器组名(与 clash/checker.js CONFIG 一致)
326
+ let cdnSwitchChannelPort = 0; // 切节点 IPC 通道 = webserver 代理端口(0=默认通道);startWebserver 注入
316
327
 
317
328
  /**
318
329
  * 5 分钟窗口结算(纯函数,便于单测):
@@ -360,34 +371,78 @@ function shouldFailFast() {
360
371
  }
361
372
 
362
373
  /**
363
- * webserver 自己发起的节点 fallback:切换 Clash 组到第一个 delay 探测通过的候选节点。
364
- * 由 CDN 自愈状态机在"5 分钟内累计 2 个完整限流窗口"后调用,不依赖看门狗/daemon。
374
+ * 直连切换 Clash 组到第一个 delay 探测通过的候选节点。
375
+ * 回退路径:仅 daemon 未运行时使用(正常路径 switchNodeViaDaemon 汇聚到 daemon)。
365
376
  */
366
377
  export async function switchClashNode(reason) {
367
378
  const current = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
368
379
  const nodes = await listGroupNodes(CLASH_GROUP_NAME);
369
- const candidates = nodes.filter((n) => n !== current);
380
+ // 过滤机场信息节点(剩余流量/套餐到期等,非真实出口)
381
+ const candidates = nodes.filter(
382
+ (n) => n !== current && isUsableProxyNode(n),
383
+ );
370
384
  if (!candidates.length) {
371
385
  throw new Error(
372
386
  `切节点失败:组 ${CLASH_GROUP_NAME} 无候选节点 (当前 ${current})`,
373
387
  );
374
388
  }
389
+ const probeUrl = "https://www.tiktok.com";
390
+ // 批量测速优先(/group/{G}/delay 含 provider 节点):provider 模式下订阅节点
391
+ // 不在 /proxies 表内,逐个 /proxies/{name}/delay 必 404 → 全部误判 -1 秒失败
392
+ // (2026-09-28 生产实锤:44 个候选全 -1 → 误躺平);批量不可用再回退逐个探测
393
+ let delayMap = await getGroupDelay(CLASH_GROUP_NAME, probeUrl, 5000);
394
+ if (
395
+ !delayMap ||
396
+ (Object.keys(delayMap).length > 0 &&
397
+ !candidates.some((c) => typeof delayMap[c] === "number"))
398
+ ) {
399
+ // 批量端点不可用或响应形状不符(不覆盖任何候选,可能是旧内核/中间层)→ 回退
400
+ console.error(
401
+ "[webserver] 批量测速不可用,回退逐个探测(provider 节点将不可见)",
402
+ );
403
+ delayMap = {};
404
+ for (const cand of candidates) {
405
+ delayMap[cand] = (await getProxyDelay(cand, probeUrl, 5000)).delay;
406
+ }
407
+ }
375
408
  for (const cand of candidates) {
376
- const d = await getProxyDelay(cand, "https://www.tiktok.com", 5000);
377
- if (d.delay > 0 && !d.timeout) {
409
+ const delay = delayMap[cand];
410
+ if (typeof delay === "number" && delay > 0) {
378
411
  await switchProxy(CLASH_GROUP_NAME, cand);
379
412
  console.error(
380
- `[webserver] 已切换 Clash 节点: ${current} → ${cand} (delay=${d.delay}ms) [${reason}]`,
413
+ `[webserver] 已切换 Clash 节点: ${current} → ${cand} (delay=${delay}ms) [${reason}]`,
381
414
  );
382
415
  return cand;
383
416
  }
384
417
  console.error(
385
- `[webserver] 候选节点探测不通,跳过: ${cand} (delay=${d.delay}ms)`,
418
+ `[webserver] 候选节点探测不通,跳过: ${cand} (delay=${delay ?? -1}ms)`,
386
419
  );
387
420
  }
388
421
  throw new Error(`切节点失败:${candidates.length} 个候选均探测不通`);
389
422
  }
390
423
 
424
+ /**
425
+ * 通过 daemon 切换节点(首选路径):文件 IPC 发 failover 强切指令(force=true——
426
+ * webserver 自身已完成节流:100s 冷却 + 5min 2 窗口 + 3 次上限,触发时必须真切),
427
+ * 等待 daemon(10s 轮询 + 探测/候选验证)完成后核对 GLOBAL 当前节点。
428
+ * 节点选择/白名单守卫/同订阅互斥全部由 daemon 统一裁决,webserver 不直接操作控制器。
429
+ * @returns {Promise<string|undefined|null>}
430
+ * 新节点名;undefined = daemon 未运行(调用方回退直连);null = 命令已发但节点未变化
431
+ */
432
+ async function switchNodeViaDaemon(reason) {
433
+ if (!isDaemonAlive(cdnSwitchChannelPort)) return undefined;
434
+ const before = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
435
+ console.error(
436
+ `[webserver] 节点切换汇聚到 daemon (通道 ${cdnSwitchChannelPort || "默认"},当前 ${before}) [${reason}]`,
437
+ );
438
+ // daemon 10s 轮询命令 + 逐候选内容验证,给足等待
439
+ await triggerProxySwitch(reason, cdnSwitchChannelPort, 45_000, {
440
+ force: true,
441
+ });
442
+ const after = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
443
+ return after && after !== before ? after : null;
444
+ }
445
+
391
446
  // 触发切换 Clash 节点(防重入;连切上限;成功重置计数)
392
447
  function startCdnSwitch() {
393
448
  if (cdnSwitching || cdnGiveUp) return;
@@ -403,9 +458,20 @@ function startCdnSwitch() {
403
458
  );
404
459
  cdnSwitching = true;
405
460
  cdnSwitchCount++;
406
- switchClashNode(
407
- `5 分钟内累计 ${cdnCooldownCount} 个完整限流窗口,触发第 ${cdnSwitchCount} 次切节点`,
408
- )
461
+ const switchReason = `5 分钟内累计 ${cdnCooldownCount} 个完整限流窗口,触发第 ${cdnSwitchCount} 次切节点`;
462
+ switchNodeViaDaemon(switchReason)
463
+ .then(async (node) => {
464
+ if (node === undefined) {
465
+ console.error("[webserver] daemon 未运行,回退直连切换 Clash 节点");
466
+ return switchClashNode(switchReason);
467
+ }
468
+ if (node === null) {
469
+ throw new Error(
470
+ "daemon 已收到切换命令但节点未变化(候选集为空或候选均验证失败)",
471
+ );
472
+ }
473
+ return node;
474
+ })
409
475
  .then((node) => {
410
476
  // 切换成功:重新计数(窗口起点与限流窗口数都清零),新节点重新累计
411
477
  cdnCooldownCount = 0;
@@ -848,6 +914,29 @@ export async function startWebserver(
848
914
  if (proxyServer) {
849
915
  SCRAPER_CONFIG.proxyServer = proxyServer;
850
916
  }
917
+ // 节点切换通道与控制器对齐(多实例各归各:代理 7897→控制器 9097,7898→9098)。
918
+ // 不对齐的话,第二个 webserver 实例的直连切换会打到默认 9097,切错控制器。
919
+ cdnSwitchChannelPort = proxyPortOf(SCRAPER_CONFIG.proxyServer);
920
+ try {
921
+ const controllers = await discoverAll();
922
+ const mine =
923
+ controllers.find((c) => c.proxyPort === cdnSwitchChannelPort) ||
924
+ (cdnSwitchChannelPort === 0 ? controllers[0] : null);
925
+ if (mine) {
926
+ configureClashApi({ port: mine.port, secret: mine.secret });
927
+ console.error(
928
+ `[webserver] Clash 控制器对齐: 代理 ${cdnSwitchChannelPort || "默认"} → api ${mine.port} (${mine.source})`,
929
+ );
930
+ } else {
931
+ console.error(
932
+ `[webserver] 未发现代理 ${cdnSwitchChannelPort} 对应的控制器,节点切换沿用默认端口`,
933
+ );
934
+ }
935
+ } catch (e) {
936
+ console.error(
937
+ `[webserver] Clash 控制器发现失败,节点切换沿用默认端口: ${e.message}`,
938
+ );
939
+ }
851
940
  if (captchaNotifyUrl) {
852
941
  SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
853
942
  }