tt-help-cli-ycl 1.4.164 → 1.4.166

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.166",
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,25 @@
1
+ // 一次性运维脚本:为 task_queue 建 claim 兜底分支加速索引(幂等)
2
+ import { createRequire } from "module";
3
+ const require = createRequire("D:/tiktok/tiktok-cli/package.json");
4
+ const Database = require("better-sqlite3");
5
+
6
+ const db = new Database("D:/tiktok/data/result.db");
7
+ db.pragma("busy_timeout = 30000");
8
+ const t0 = Date.now();
9
+ db.exec(
10
+ "CREATE INDEX IF NOT EXISTS idx_tq_claim_fallback ON task_queue(status, follower_count DESC, created_at ASC, unique_id ASC)",
11
+ );
12
+ console.log(`index created in ${Date.now() - t0}ms`);
13
+
14
+ // 验证兜底分支走新索引
15
+ const SQL = `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) NOT IN ('PL','NL','BE','DE','FR','IT','IE','ES','CZ','GR','HU','PT','AT','TH','US') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`;
16
+ const plan = db
17
+ .prepare(`EXPLAIN QUERY PLAN ${SQL}`)
18
+ .all()
19
+ .map((r) => r.detail)
20
+ .join(" | ");
21
+ console.log("plan:", plan);
22
+ const t1 = Date.now();
23
+ db.prepare(SQL).get();
24
+ console.log(`fallback branch query: ${Date.now() - t1}ms`);
25
+ db.close();
@@ -19,10 +19,8 @@ const plan = db
19
19
  .join(" | ");
20
20
  console.log("plan:", plan);
21
21
  const t1 = Date.now();
22
- db
23
- .prepare(
24
- `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) IN ('PL') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`,
25
- )
26
- .get();
22
+ db.prepare(
23
+ `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) IN ('PL') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`,
24
+ ).get();
27
25
  console.log(`branch query: ${Date.now() - t1}ms`);
28
26
  db.close();
@@ -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,28 +1,35 @@
1
1
  /**
2
- * 客户端异常监控单测(2026-08-28)
2
+ * 客户端异常监控单测(2026-08-28,2026-09-28 增补验证码路径,
3
+ * 2026-09-29 改为 clientId 本机关联 —— 克隆机 hostname 撞名修复)
3
4
  *
4
5
  * 验证:
5
- * - detectClientError 检测函数:reportCount > 8 且最近一次上报在 2 分钟内
6
- * - 按 hostname 过滤本机条目;hostname 为空的旧条目不参与关联
7
- * - 命中 → onAlert 收到本机全部条目(含未命中条目);未命中 → 不回调
6
+ * - detectClientError 检测函数:
7
+ * A. reportCount > 8 且最近一次上报在 2 分钟内
8
+ * B. captchaCount ≥ 3(webserver 验证码上报,10 分钟过期窗口内累计)
9
+ * - 按 clientId 过滤本机条目(每台唯一);hostname 撞名的兄弟机条目、
10
+ * 无 clientId 的旧条目均不参与关联
11
+ * - 命中 → onAlert 收到本机全部条目与命中列表;未命中 → 不回调
8
12
  * - removeErrors 逐条 DELETE 并计数
9
13
  *
10
14
  * 运行: node --test scripts/test-client-error-monitor.mjs
11
15
  */
12
16
  import { test, mock } from "node:test";
13
17
  import assert from "node:assert/strict";
14
- import os from "os";
15
18
  import {
16
19
  detectClientError,
17
20
  ClientErrorMonitor,
18
21
  } from "../src/watchdog/client-error-monitor.js";
19
22
 
20
- const HOST = os.hostname();
23
+ // 模拟克隆机群:hostname 全部相同(事故现场),clientId 每台唯一
24
+ const CLONED_HOST = "WIN-AADJ1HTCCAQ";
25
+ const CLIENT_ID = `F-9@${CLONED_HOST}`;
26
+ const BROTHER_CLIENT_ID = `F-10@${CLONED_HOST}`;
21
27
 
22
28
  function makeErr(overrides = {}) {
23
29
  return {
24
30
  userId: "u1",
25
- hostname: HOST,
31
+ hostname: CLONED_HOST,
32
+ clientId: CLIENT_ID,
26
33
  errorType: "consecutive_blocked",
27
34
  reportCount: 10,
28
35
  timestamp: Date.now() - 30_000,
@@ -30,6 +37,22 @@ function makeErr(overrides = {}) {
30
37
  };
31
38
  }
32
39
 
40
+ function makeMonitor(opts = {}) {
41
+ let received = null;
42
+ let alerted = false;
43
+ const m = new ClientErrorMonitor({
44
+ serverUrl: "http://127.0.0.1:3000",
45
+ hostname: CLONED_HOST, // 仅展示用;关联只认 clientId
46
+ clientId: CLIENT_ID,
47
+ onAlert: async (mine, hit) => {
48
+ alerted = true;
49
+ received = { mine, hit };
50
+ },
51
+ ...opts,
52
+ });
53
+ return { m, isAlerted: () => alerted, getReceived: () => received };
54
+ }
55
+
33
56
  // ── 检测函数 ──
34
57
 
35
58
  test("D1 detectClientError:累计>8 且 2 分钟内 → 命中", () => {
@@ -60,25 +83,83 @@ test("D4 detectClientError:缺 reportCount → 不命中", () => {
60
83
  assert.equal(detectClientError({ timestamp: Date.now() }, Date.now()), false);
61
84
  });
62
85
 
63
- // ── 监控器 ──
86
+ // ── 验证码路径(captchaCount ≥ 3) ──
87
+
88
+ test("C1 detectClientError:captchaCount=3 → 命中", () => {
89
+ assert.equal(
90
+ detectClientError(makeErr({ reportCount: 0, captchaCount: 3 }), Date.now()),
91
+ true,
92
+ );
93
+ });
94
+
95
+ test("C2 detectClientError:captchaCount=2 不命中(阈值是 ≥3)", () => {
96
+ assert.equal(
97
+ detectClientError(makeErr({ reportCount: 0, captchaCount: 2 }), Date.now()),
98
+ false,
99
+ );
100
+ });
101
+
102
+ test("C3 detectClientError:captchaCount=3 但最近上报超 2 分钟 → 不命中", () => {
103
+ assert.equal(
104
+ detectClientError(
105
+ makeErr({
106
+ reportCount: 0,
107
+ captchaCount: 3,
108
+ timestamp: Date.now() - 2 * 60_000 - 1,
109
+ }),
110
+ Date.now(),
111
+ ),
112
+ false,
113
+ );
114
+ });
64
115
 
65
- test("M1 按 hostname 过滤:他机条目不触发本机回调", async () => {
116
+ test("C4 detectClientError:reportCount 未达标 + captchaCount 达标 → 命中", () => {
117
+ assert.equal(
118
+ detectClientError(makeErr({ reportCount: 1, captchaCount: 5 }), Date.now()),
119
+ true,
120
+ );
121
+ });
122
+
123
+ test("C5 监控器:验证码条目命中 → onAlert 收到命中列表", async () => {
124
+ const captchaHit = makeErr({
125
+ userId: "webserver-host",
126
+ reportCount: 0,
127
+ captchaCount: 3,
128
+ });
129
+ const businessMiss = makeErr({ userId: "u-task", reportCount: 2 });
130
+ mock.method(globalThis, "fetch", async () => ({
131
+ ok: true,
132
+ json: async () => ({ clients: [captchaHit, businessMiss] }),
133
+ }));
134
+ const { m, getReceived } = makeMonitor();
135
+ const hit = await m._poll();
136
+ assert.equal(hit.length, 1, "仅验证码条目命中");
137
+ assert.equal(hit[0].userId, "webserver-host");
138
+ assert.equal(getReceived().hit.length, 1);
139
+ assert.equal(getReceived().mine.length, 2, "回调同时携带本机未命中条目");
140
+ mock.restoreAll();
141
+ });
142
+
143
+ // ── 监控器:本机关联(clientId) ──
144
+
145
+ test("M1 按 clientId 过滤:撞名兄弟机条目不触发本机回调", async () => {
146
+ // 克隆机事故回归:F-10 与 F-9 hostname 完全相同,仅 clientId 不同
66
147
  mock.method(globalThis, "fetch", async () => ({
67
148
  ok: true,
68
149
  json: async () => ({
69
- clients: [makeErr({ hostname: "OTHER-MACHINE" })],
150
+ clients: [
151
+ makeErr({
152
+ userId: "F-10-8222-1",
153
+ clientId: BROTHER_CLIENT_ID,
154
+ captchaCount: 3,
155
+ reportCount: 0,
156
+ }),
157
+ ],
70
158
  }),
71
159
  }));
72
- let alerted = false;
73
- const m = new ClientErrorMonitor({
74
- serverUrl: "http://127.0.0.1:3000",
75
- hostname: HOST,
76
- onAlert: async () => {
77
- alerted = true;
78
- },
79
- });
160
+ const { m, isAlerted } = makeMonitor();
80
161
  const hit = await m._poll();
81
- assert.equal(alerted, false, "他机异常不应回调");
162
+ assert.equal(isAlerted(), false, "兄弟机异常不应触发本机回调/强切");
82
163
  assert.equal(hit, null, "无本机条目应返回 null");
83
164
  mock.restoreAll();
84
165
  });
@@ -86,51 +167,62 @@ test("M1 按 hostname 过滤:他机条目不触发本机回调", async () => {
86
167
  test("M2 命中:onAlert 收到本机全部条目(含未命中条目)", async () => {
87
168
  const mineHit = makeErr({ userId: "u-hit", reportCount: 10 });
88
169
  const mineMiss = makeErr({ userId: "u-miss", reportCount: 2 });
89
- const other = makeErr({ userId: "u-other", hostname: "OTHER-MACHINE" });
170
+ const other = makeErr({
171
+ userId: "u-other",
172
+ clientId: BROTHER_CLIENT_ID, // hostname 相同,clientId 不同
173
+ });
90
174
  mock.method(globalThis, "fetch", async () => ({
91
175
  ok: true,
92
176
  json: async () => ({ clients: [mineHit, mineMiss, other] }),
93
177
  }));
94
- let received = null;
95
- const m = new ClientErrorMonitor({
96
- serverUrl: "http://127.0.0.1:3000",
97
- hostname: HOST,
98
- onAlert: async (mine, hit) => {
99
- received = { mine, hit };
100
- },
101
- });
178
+ const { m, getReceived } = makeMonitor();
102
179
  const hit = await m._poll();
103
180
  assert.equal(hit.length, 1, "命中 1 条");
104
181
  assert.equal(hit[0].userId, "u-hit");
105
- assert.equal(received.mine.length, 2, "回调收到本机全部 2 条");
182
+ assert.equal(getReceived().mine.length, 2, "回调收到本机全部 2 条");
106
183
  assert.deepEqual(
107
- received.mine.map((e) => e.userId).sort(),
184
+ getReceived()
185
+ .mine.map((e) => e.userId)
186
+ .sort(),
108
187
  ["u-hit", "u-miss"],
109
188
  "回调含命中与未命中的本机条目",
110
189
  );
111
190
  mock.restoreAll();
112
191
  });
113
192
 
114
- test("M3 hostname 为空的旧条目不参与本机关联", async () => {
193
+ test("M3 无 clientId 的旧条目不参与本机关联(宁漏不误)", async () => {
194
+ mock.method(globalThis, "fetch", async () => ({
195
+ ok: true,
196
+ json: async () => ({
197
+ clients: [makeErr({ clientId: "", reportCount: 99 })],
198
+ }),
199
+ }));
200
+ const { m, isAlerted } = makeMonitor();
201
+ const hit = await m._poll();
202
+ assert.equal(isAlerted(), false, "旧版上报条目不应触发强切");
203
+ assert.equal(hit, null);
204
+ mock.restoreAll();
205
+ });
206
+
207
+ test("M4 未配置 clientId 的监控器:不匹配任何条目", async () => {
115
208
  mock.method(globalThis, "fetch", async () => ({
116
209
  ok: true,
117
- json: async () => ({ clients: [makeErr({ hostname: "" })] }),
210
+ json: async () => ({ clients: [makeErr({ reportCount: 99 })] }),
118
211
  }));
119
212
  let alerted = false;
120
213
  const m = new ClientErrorMonitor({
121
214
  serverUrl: "http://127.0.0.1:3000",
122
- hostname: HOST,
123
215
  onAlert: async () => {
124
216
  alerted = true;
125
217
  },
126
218
  });
127
219
  const hit = await m._poll();
128
- assert.equal(alerted, false, "空 hostname 旧条目不应回调");
220
+ assert.equal(alerted, false, "clientId 未配置时监控不生效");
129
221
  assert.equal(hit, null);
130
222
  mock.restoreAll();
131
223
  });
132
224
 
133
- test("M4 removeErrors:逐条 DELETE 并计数", async () => {
225
+ test("M5 removeErrors:逐条 DELETE 并计数", async () => {
134
226
  const deleted = [];
135
227
  mock.method(globalThis, "fetch", async (url, opts) => {
136
228
  if (opts && opts.method === "DELETE") {
@@ -157,20 +249,13 @@ test("M4 removeErrors:逐条 DELETE 并计数", async () => {
157
249
  mock.restoreAll();
158
250
  });
159
251
 
160
- test("M5 拉取失败静默:fetch 抛错不回调不崩溃", async () => {
252
+ test("M6 拉取失败静默:fetch 抛错不回调不崩溃", async () => {
161
253
  mock.method(globalThis, "fetch", async () => {
162
254
  throw new Error("ECONNREFUSED");
163
255
  });
164
- let alerted = false;
165
- const m = new ClientErrorMonitor({
166
- serverUrl: "http://127.0.0.1:3000",
167
- hostname: HOST,
168
- onAlert: async () => {
169
- alerted = true;
170
- },
171
- });
256
+ const { m, isAlerted } = makeMonitor();
172
257
  const hit = await m._poll();
173
- assert.equal(alerted, false);
258
+ assert.equal(isAlerted(), false);
174
259
  assert.equal(hit, null);
175
260
  mock.restoreAll();
176
261
  });
@@ -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));