tt-help-cli-ycl 1.4.163 → 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 +1 -1
- package/scripts/test-clash-hysteresis.mjs +131 -0
- package/scripts/test-client-error-monitor.mjs +78 -3
- package/scripts/test-webserver-error-classify.mjs +30 -0
- package/src/clash/checker.js +56 -3
- package/src/clash/daemon.js +17 -3
- package/src/clash/proxy-switch.js +5 -1
- package/src/clash/runtime.js +34 -1
- package/src/lib/browser/cdp.js +104 -1
- package/src/watch/public/app.js +4 -0
- package/src/watchdog/agent.js +21 -9
- package/src/watchdog/client-error-monitor.js +25 -5
- package/src/webserver/server.mjs +100 -11
package/package.json
CHANGED
|
@@ -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 检测函数:
|
|
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") {
|
package/src/clash/checker.js
CHANGED
|
@@ -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
|
-
{
|
|
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,
|
package/src/clash/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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));
|
package/src/clash/runtime.js
CHANGED
|
@@ -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";
|
|
@@ -214,7 +214,33 @@ async function installCoreFromArt(artPath, entry, { log = () => {} } = {}) {
|
|
|
214
214
|
* 确保内核二进制就位(多级获取: 本地缓存 → 包内 vendor → 服务器下载 → GitHub 兼底)
|
|
215
215
|
* @returns {Promise<{ok:boolean, bin?:string, source?:string, error?:string}>}
|
|
216
216
|
*/
|
|
217
|
+
// ── 自定义内核(老 CPU 机器用本机可用内核,如 Verge 的 verge-mihomo.exe) ──
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 自定义内核路径(~/.tt-help.json → clash.corePath):
|
|
221
|
+
* 官方 mihomo 二进制与 CPU 不兼容的机器(如 AMD A8-6410,启动即 0xC0000005),
|
|
222
|
+
* 指定本机已验证可用的内核二进制(例:C:\Program Files\Clash Verge\verge-mihomo.exe)。
|
|
223
|
+
* 指定后跳过下载/sha256 校验,直接用该路径 spawn(-d/-f 仍指向我们的运行目录,配置/日志隔离不变)。
|
|
224
|
+
*/
|
|
225
|
+
export function customCorePath() {
|
|
226
|
+
try {
|
|
227
|
+
const cfgPath = join(homedir(), ".tt-help.json");
|
|
228
|
+
if (!existsSync(cfgPath)) return null;
|
|
229
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
230
|
+
const p = cfg?.clash?.corePath;
|
|
231
|
+
return p && existsSync(p) ? p : null;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
217
237
|
export async function ensureBinary({ log = () => {} } = {}) {
|
|
238
|
+
// 0) 自定义内核(clash.corePath,存在即用,跳过下载/校验)
|
|
239
|
+
const custom = customCorePath();
|
|
240
|
+
if (custom) {
|
|
241
|
+
log(`⚙ 使用自定义内核: ${custom}`);
|
|
242
|
+
return { ok: true, bin: custom, source: "custom" };
|
|
243
|
+
}
|
|
218
244
|
const { bin } = runtimePaths();
|
|
219
245
|
const entry = VENDOR_MANIFEST[platformKey()];
|
|
220
246
|
if (!entry) return { ok: false, error: `不支持的平台: ${platformKey()}` };
|
|
@@ -655,6 +681,13 @@ export async function stopRuntimeInstance(index = 1, { log = () => {} } = {}) {
|
|
|
655
681
|
} catch {}
|
|
656
682
|
}
|
|
657
683
|
}
|
|
684
|
+
if (killed) {
|
|
685
|
+
// 回收成功 → 一并清理通道健康文件,避免幽灵通道残留
|
|
686
|
+
//(daemon _rediscover 后不再巡检该实例,文件永远不会被覆盖,看门狗页面会多出幽灵订阅)
|
|
687
|
+
try {
|
|
688
|
+
rmSync(join(tmpdir(), `clash-health-${spec.mixedPort}.json`), { force: true });
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
658
691
|
return killed;
|
|
659
692
|
}
|
|
660
693
|
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -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
|
-
|
|
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` +
|
package/src/watch/public/app.js
CHANGED
|
@@ -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
|
}
|
package/src/watchdog/agent.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
739
|
-
|
|
740
|
-
|
|
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]
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
31
|
+
if (!err || typeof err.timestamp !== "number") return false;
|
|
28
32
|
const fresh = now - err.timestamp < DETECT_FRESH_MS;
|
|
29
|
-
|
|
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
|
/**
|
package/src/webserver/server.mjs
CHANGED
|
@@ -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 个限流窗口 →
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
-
|
|
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
|
|
377
|
-
if (
|
|
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=${
|
|
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=${
|
|
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
|
-
|
|
407
|
-
|
|
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
|
}
|