tt-help-cli-ycl 1.4.128 → 1.4.130

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.128",
3
+ "version": "1.4.130",
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,197 @@
1
+ /**
2
+ * daemon _handleFailover 三级响应单测(2026-08-22,无需 Clash 运行)
3
+ *
4
+ * 验证:
5
+ * - 探针健康 → 窗口内 failover 仅检查不切换(前 N-1 次)
6
+ * - 探针不健康 → 立即切换(无视窗口)
7
+ * - 壳页 → 视为链路正常,仅检查不切换(与巡检口径一致)
8
+ * - 窗口过期 → 计数重置
9
+ * - 窗口内到达 failoverEscapeAfter 次 → 按业务级封禁轮换一次
10
+ * - 实际切换后 → 窗口与计数重置
11
+ * - escapeAfter 可配置
12
+ * - 候选集为空 → 触发 delay 初测且不切换
13
+ *
14
+ * 运行: node --test scripts/test-daemon-failover.mjs
15
+ * (若当前 Node 版本 module mock 仍需 flag: --experimental-test-module-mocks)
16
+ */
17
+ import { test, mock, before } from "node:test";
18
+ import assert from "node:assert/strict";
19
+
20
+ // ── 可配置假依赖状态 ──
21
+ const state = {
22
+ health: {
23
+ healthy: true,
24
+ httpCode: 200,
25
+ timeTotal: 1.2,
26
+ sizeDownload: 100000,
27
+ numRedirects: 0,
28
+ title: "TikTok - Make Your Day",
29
+ error: null,
30
+ },
31
+ nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
32
+ currentNode: "🇭🇰 香港01",
33
+ switches: [],
34
+ closes: 0,
35
+ };
36
+
37
+ function resetState() {
38
+ state.health = {
39
+ healthy: true,
40
+ httpCode: 200,
41
+ timeTotal: 1.2,
42
+ sizeDownload: 100000,
43
+ numRedirects: 0,
44
+ title: "TikTok - Make Your Day",
45
+ error: null,
46
+ };
47
+ state.nodes = ["🇭🇰 香港01", "🇲🇾 马来02"];
48
+ state.currentNode = "🇭🇰 香港01";
49
+ state.switches = [];
50
+ state.closes = 0;
51
+ }
52
+
53
+ // ── 注册模块 mock(必须在 import daemon.js 之前) ──
54
+ before(async () => {
55
+ mock.module("../src/clash/checker.js", {
56
+ namedExports: {
57
+ normalizeCountries: (v) => (Array.isArray(v) ? [...v] : v ? [v] : []),
58
+ CONFIG: {
59
+ groupName: "GLOBAL",
60
+ testUrl: "https://www.tiktok.com/explore",
61
+ proxyUrl: "http://127.0.0.1:7897",
62
+ nodeCountryFilter: null,
63
+ },
64
+ checkNodeHealth: () => ({ ...state.health }),
65
+ loadHealthyList: () => ({ nodes: [...state.nodes] }),
66
+ switchToNode: async (n) => {
67
+ state.switches.push(n);
68
+ state.currentNode = n;
69
+ // 模拟真实世界:故障属于原节点,切换后的新节点健康(候选内容验证通过)
70
+ state.health = { ...state.health, healthy: true, error: null };
71
+ return true;
72
+ },
73
+ applyDiscovered: () => {},
74
+ runDelayProbe: async () => ({
75
+ nodes: [...state.nodes],
76
+ total: state.nodes.length,
77
+ }),
78
+ saveHealthyList: () => {},
79
+ },
80
+ });
81
+ mock.module("../src/clash/api.js", {
82
+ namedExports: {
83
+ configure: () => {},
84
+ getCurrentNode: async () => state.currentNode,
85
+ closeAllConnections: async () => {
86
+ state.closes += 1;
87
+ },
88
+ },
89
+ });
90
+ mock.module("../src/clash/ipc.js", {
91
+ namedExports: {
92
+ writeHealth: () => {},
93
+ listCommandChannels: () => [],
94
+ readCommand: () => null,
95
+ clearCommand: () => {},
96
+ },
97
+ });
98
+ mock.module("../src/clash/discover.js", {
99
+ namedExports: {
100
+ discoverAll: async () => [],
101
+ invalidateDiscoverCache: () => {},
102
+ },
103
+ });
104
+ ({ ClashDaemon } = await import("../src/clash/daemon.js"));
105
+ });
106
+
107
+ let ClashDaemon = null;
108
+
109
+ function makeCtx(opts = {}) {
110
+ const d = new ClashDaemon({
111
+ failoverCooldownMs: 600000,
112
+ failoverEscapeAfter: 3,
113
+ ...opts,
114
+ });
115
+ const c = { proxyPort: 7897 };
116
+ return { d, c };
117
+ }
118
+
119
+ test("F1 探针健康:前 2 次 failover 仅检查不切换,第 3 次轮换", async () => {
120
+ resetState();
121
+ const { d, c } = makeCtx();
122
+ await d._handleFailover({ reason: "F1-1" }, c);
123
+ await d._handleFailover({ reason: "F1-2" }, c);
124
+ assert.equal(state.switches.length, 0, "前 2 次不应切换");
125
+ assert.equal(state.closes, 0, "前 2 次不应断开连接");
126
+ await d._handleFailover({ reason: "F1-3" }, c);
127
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "第 3 次按轮换切换到候选");
128
+ assert.equal(state.closes, 1, "切换后应断开旧连接");
129
+ });
130
+
131
+ test("F2 探针不健康:立即切换(无视窗口)", async () => {
132
+ resetState();
133
+ const { d, c } = makeCtx();
134
+ state.health = { ...state.health, healthy: false, error: "timeout" };
135
+ await d._handleFailover({ reason: "F2" }, c);
136
+ assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
137
+ });
138
+
139
+ test("F3 壳页视为链路正常:仅检查不切换", async () => {
140
+ resetState();
141
+ const { d, c } = makeCtx();
142
+ // 壳页签名:200 + 无跳转 + 无 error + 无 title
143
+ state.health = {
144
+ healthy: false,
145
+ httpCode: 200,
146
+ timeTotal: 1.0,
147
+ sizeDownload: 43000,
148
+ numRedirects: 0,
149
+ title: "",
150
+ error: null,
151
+ };
152
+ await d._handleFailover({ reason: "F3" }, c);
153
+ assert.equal(state.switches.length, 0, "壳页不应触发切换");
154
+ });
155
+
156
+ test("F4 窗口过期:计数重置,不累计到逃生阈值", async () => {
157
+ resetState();
158
+ const { d, c } = makeCtx({ failoverCooldownMs: 150 });
159
+ await d._handleFailover({ reason: "F4-1" }, c); // count=1
160
+ await new Promise((r) => setTimeout(r, 200)); // 窗口过期
161
+ await d._handleFailover({ reason: "F4-2" }, c); // 重置后 count=1
162
+ await d._handleFailover({ reason: "F4-3" }, c); // count=2
163
+ assert.equal(state.switches.length, 0, "窗口过期后不应累计旧计数");
164
+ await d._handleFailover({ reason: "F4-4" }, c); // count=3 → 轮换
165
+ assert.equal(state.switches.length, 1, "第 3 次才应轮换");
166
+ });
167
+
168
+ test("F5 切换后窗口重置:重新从仅检查开始", async () => {
169
+ resetState();
170
+ const { d, c } = makeCtx();
171
+ await d._handleFailover({ reason: "F5-1" }, c); // skip
172
+ await d._handleFailover({ reason: "F5-2" }, c); // skip
173
+ await d._handleFailover({ reason: "F5-3" }, c); // switch
174
+ assert.equal(state.switches.length, 1, "第 3 次应切换");
175
+ await d._handleFailover({ reason: "F5-4" }, c); // 重置后 count=1 → skip
176
+ assert.equal(state.switches.length, 1, "切换后应重置窗口,不连续切换");
177
+ await d._handleFailover({ reason: "F5-5" }, c); // count=2 → skip
178
+ await d._handleFailover({ reason: "F5-6" }, c); // count=3 → switch
179
+ assert.equal(state.switches.length, 2, "第 6 次才应再次轮换");
180
+ });
181
+
182
+ test("F6 escapeAfter 可配置:=1 时首次健康 failover 即轮换", async () => {
183
+ resetState();
184
+ const { d, c } = makeCtx({ failoverEscapeAfter: 1 });
185
+ await d._handleFailover({ reason: "F6" }, c);
186
+ assert.equal(state.switches.length, 1, "escapeAfter=1 应首次即切换");
187
+ });
188
+
189
+ test("F7 候选集为空:触发 delay 初测且不切换", async () => {
190
+ resetState();
191
+ const { d, c } = makeCtx();
192
+ state.health = { ...state.health, healthy: false, error: "timeout" };
193
+ state.nodes = [];
194
+ await d._handleFailover({ reason: "F7" }, c);
195
+ assert.equal(state.switches.length, 0, "无候选不应切换");
196
+ assert.ok(c._lastProbeAt, "应触发过 delay 初测");
197
+ });
@@ -0,0 +1,108 @@
1
+ // verify-kill-edge.mjs — 在 Windows 上端到端验证 killEdgeProcesses(netstat 方案)
2
+ // 场景模拟:业务开一个带 --remote-debugging-port 的 Edge 实例,
3
+ // 看门狗关闭时调用 killEdgeProcesses(null, port) 杀浏览器。
4
+ // 安全:只用独立端口 19222 + 独立 user-data-dir,不碰机器上其他 Edge。
5
+ import { exec, execSync } from "node:child_process";
6
+
7
+ const PKG =
8
+ process.env.APPDATA.replace(/\\/g, "/") +
9
+ "/npm/node_modules/tt-help-cli-ycl";
10
+ const { killEdgeProcesses, parseNetstatListeningPids } = await import(
11
+ "file:///" + PKG + "/src/lib/browser/cdp.js"
12
+ );
13
+
14
+ const PORT = 19222;
15
+ const USER_DATA_DIR =
16
+ process.env.TEMP.replace(/\\/g, "/") + "/edge-test-" + PORT;
17
+
18
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ function netstatPids(port) {
21
+ const out = execSync("netstat -ano", {
22
+ encoding: "utf-8",
23
+ timeout: 5000,
24
+ windowsHide: true,
25
+ });
26
+ return parseNetstatListeningPids(out, port);
27
+ }
28
+
29
+ async function cleanup() {
30
+ const pids = netstatPids(PORT);
31
+ for (const pid of pids) {
32
+ try {
33
+ execSync(`taskkill /F /T /PID ${pid}`, {
34
+ stdio: "ignore",
35
+ windowsHide: true,
36
+ });
37
+ } catch {}
38
+ }
39
+ try {
40
+ execSync(`rd /s /q "${USER_DATA_DIR}"`, {
41
+ stdio: "ignore",
42
+ windowsHide: true,
43
+ });
44
+ } catch {}
45
+ }
46
+
47
+ async function main() {
48
+ // [1] 解析器静态验证:用当前真实 netstat 输出
49
+ {
50
+ const out = execSync("netstat -ano", {
51
+ encoding: "utf-8",
52
+ timeout: 5000,
53
+ windowsHide: true,
54
+ });
55
+ const sample = out
56
+ .split(/\r?\n/)
57
+ .filter((l) => l.includes("LISTENING"))
58
+ .slice(0, 5);
59
+ console.log("[1] 当前真实 LISTENING 行:");
60
+ for (const l of sample) console.log(" " + l.trim());
61
+ const clash = parseNetstatListeningPids(out, 9097);
62
+ console.log("[1] 9097 解析 PID = " + JSON.stringify(clash));
63
+ }
64
+
65
+ // [2] 启动真实 Edge CDP 测试实例
66
+ console.log("[2] 启动 Edge CDP 测试实例 (port=" + PORT + ") ...");
67
+ exec(
68
+ `start "" msedge --remote-debugging-port=${PORT} --user-data-dir="${USER_DATA_DIR}" --no-first-run --no-default-browser-check --disable-background-mode --disable-component-update --disable-crash-reporter --disable-breakpad --disable-sync --disable-background-networking`,
69
+ { windowsHide: true },
70
+ );
71
+
72
+ let readyPids = [];
73
+ for (let i = 0; i < 30; i++) {
74
+ await delay(1000);
75
+ readyPids = netstatPids(PORT);
76
+ if (readyPids.length > 0) break;
77
+ }
78
+ console.log("[2] 端口 " + PORT + " 监听 PID = " + JSON.stringify(readyPids));
79
+ if (readyPids.length === 0) {
80
+ console.log("RESULT: SETUP_FAIL(Edge 未监听端口)");
81
+ await cleanup();
82
+ process.exit(2);
83
+ }
84
+
85
+ // [3] 调用包内真实 killEdgeProcesses(与看门狗关闭时同一函数)
86
+ console.log("[3] 调用 killEdgeProcesses(null, " + PORT + ") ...");
87
+ const t0 = Date.now();
88
+ await killEdgeProcesses(null, PORT);
89
+ await delay(2000);
90
+
91
+ const after = netstatPids(PORT);
92
+ console.log(
93
+ "[3] 调用耗时 " +
94
+ (Date.now() - t0) +
95
+ "ms,杀后端口监听 PID = " +
96
+ JSON.stringify(after),
97
+ );
98
+
99
+ console.log(after.length === 0 ? "RESULT: PASS" : "RESULT: FAIL");
100
+ await cleanup();
101
+ process.exit(after.length === 0 ? 0 : 1);
102
+ }
103
+
104
+ main().catch(async (e) => {
105
+ console.error("脚本异常: " + e.message);
106
+ await cleanup();
107
+ process.exit(3);
108
+ });
@@ -0,0 +1,159 @@
1
+ // verify-stop-command.mjs — 211 上验证修复后的"看门狗关闭"完整时序
2
+ // 模拟:看门狗托管业务命令 refresh-mini --port 19223,业务启动 Edge CDP 实例。
3
+ // 修复后 stopCommand 顺序:先 killEdgeProcesses(端口) 杀浏览器 → 再 taskkill 业务进程树。
4
+ // 验证:Edge 进程树全灭(端口释放)+ 业务进程被杀。
5
+ import { execSync, spawn } from "node:child_process";
6
+
7
+ const PKG =
8
+ process.env.APPDATA.replace(/\\/g, "/") +
9
+ "/npm/node_modules/tt-help-cli-ycl";
10
+ // 包内真实函数(当前 1.4.128 的 netstat 实现)
11
+ const { killEdgeProcesses } = await import(
12
+ "file:///" + PKG + "/src/lib/browser/cdp.js"
13
+ );
14
+
15
+ const PORT = 19223;
16
+ const USER_DATA_DIR =
17
+ process.env.TEMP.replace(/\\/g, "/") + "/edge-test-" + PORT;
18
+
19
+ // 修复后的 extractCdpPorts(与本地 process-manager.js 修改一致)
20
+ const BROWSER_CMD_RE =
21
+ /\b(?:explore|auto|attach|scrape|comments|refresh|recheck|precheck|search|videos|videostats|open|tag|login-check)\b/;
22
+ function extractCdpPorts(cmdLine) {
23
+ const baseMatch = cmdLine.match(/--base-port\s+(\d+)/);
24
+ if (baseMatch) {
25
+ const base = parseInt(baseMatch[1], 10);
26
+ const countMatch = cmdLine.match(/--port-count\s+(\d+)/);
27
+ const count = countMatch ? parseInt(countMatch[1], 10) : 1;
28
+ const ports = [];
29
+ for (let i = 0; i < count; i++) ports.push(base + i);
30
+ return ports;
31
+ }
32
+ if (BROWSER_CMD_RE.test(cmdLine)) {
33
+ const m = cmdLine.match(/--port\s+(\d+)/);
34
+ if (m) return [parseInt(m[1], 10)];
35
+ }
36
+ return [];
37
+ }
38
+
39
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ function listeningPids(port) {
42
+ const out = execSync("netstat -ano", {
43
+ encoding: "utf-8",
44
+ timeout: 5000,
45
+ windowsHide: true,
46
+ });
47
+ const pids = [];
48
+ for (const line of out.split(/\r?\n/)) {
49
+ if (!line.includes("LISTENING") || !line.includes(":" + port + " ")) continue;
50
+ const parts = line.trim().split(/\s+/);
51
+ const pid = parts[parts.length - 1];
52
+ if (/^\d+$/.test(pid) && !pids.includes(pid)) pids.push(pid);
53
+ }
54
+ return pids;
55
+ }
56
+
57
+ function pidAlive(pid) {
58
+ try {
59
+ const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
60
+ encoding: "utf-8",
61
+ timeout: 5000,
62
+ windowsHide: true,
63
+ });
64
+ // 解析输出文本判断(tasklist 无匹配时可能仍 exit 0,不能依赖退出码)
65
+ return new RegExp("\\b" + pid + "\\b").test(out);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ async function cleanup() {
72
+ for (const pid of listeningPids(PORT)) {
73
+ try {
74
+ execSync(`taskkill /F /T /PID ${pid}`, {
75
+ stdio: "ignore",
76
+ windowsHide: true,
77
+ });
78
+ } catch {}
79
+ }
80
+ try {
81
+ execSync(`rd /s /q "${USER_DATA_DIR}"`, {
82
+ stdio: "ignore",
83
+ windowsHide: true,
84
+ });
85
+ } catch {}
86
+ }
87
+
88
+ async function main() {
89
+ const CMD_LINE = `tt-help refresh-mini --preset slow --port ${PORT} --rotate-min 0`;
90
+
91
+ // [1] 端口提取(修复后逻辑)
92
+ const ports = extractCdpPorts(CMD_LINE);
93
+ console.log("[1] extractCdpPorts(" + CMD_LINE + ") = " + JSON.stringify(ports));
94
+ if (JSON.stringify(ports) !== JSON.stringify([PORT])) {
95
+ console.log("RESULT: FAIL(端口提取不对)");
96
+ process.exit(1);
97
+ }
98
+
99
+ // [2] 启动业务进程(node 长驻占位)与 Edge CDP 实例(脱离进程树,模拟 start 启动)
100
+ const biz = spawn(
101
+ process.execPath,
102
+ ["-e", "setInterval(()=>{},1000)"],
103
+ { stdio: ["ignore", "ignore", "ignore"], windowsHide: true },
104
+ );
105
+ console.log("[2] 业务进程 PID = " + biz.pid);
106
+
107
+ execSync(
108
+ `start "" msedge --remote-debugging-port=${PORT} --user-data-dir="${USER_DATA_DIR}" --no-first-run --no-default-browser-check --disable-background-mode --disable-component-update --disable-crash-reporter --disable-breakpad --disable-sync --disable-background-networking`,
109
+ { stdio: "ignore", windowsHide: true },
110
+ );
111
+
112
+ let edgePids = [];
113
+ for (let i = 0; i < 30; i++) {
114
+ await delay(1000);
115
+ edgePids = listeningPids(PORT);
116
+ if (edgePids.length > 0) break;
117
+ }
118
+ console.log("[2] Edge 监听 " + PORT + " PID = " + JSON.stringify(edgePids));
119
+ if (edgePids.length === 0) {
120
+ console.log("RESULT: SETUP_FAIL(Edge 未监听端口)");
121
+ await cleanup();
122
+ try { biz.kill(); } catch {}
123
+ process.exit(2);
124
+ }
125
+
126
+ // [3] 修复后的 stopCommand 顺序:先杀浏览器,再杀业务进程树
127
+ console.log("[3] 先 killEdgeProcesses(null, " + PORT + ") ...");
128
+ for (const port of ports) {
129
+ await killEdgeProcesses(null, port);
130
+ }
131
+ await delay(2000);
132
+ const edgeAfter = listeningPids(PORT);
133
+ console.log("[3] 杀浏览器后端口监听 = " + JSON.stringify(edgeAfter));
134
+
135
+ console.log("[3] 再 taskkill /F /T /PID " + biz.pid + " ...");
136
+ try {
137
+ execSync(`taskkill /F /T /PID ${biz.pid}`, {
138
+ stdio: "ignore",
139
+ timeout: 5000,
140
+ windowsHide: true,
141
+ });
142
+ } catch (e) {
143
+ console.log("[3] taskkill 业务返回: " + e.message);
144
+ }
145
+ await delay(3000);
146
+ const bizAlive = pidAlive(biz.pid);
147
+ console.log("[3] 业务进程存活: " + bizAlive);
148
+
149
+ const ok = edgeAfter.length === 0 && !bizAlive;
150
+ console.log(ok ? "RESULT: PASS" : "RESULT: FAIL");
151
+ await cleanup();
152
+ process.exit(ok ? 0 : 1);
153
+ }
154
+
155
+ main().catch(async (e) => {
156
+ console.error("脚本异常: " + e.message);
157
+ await cleanup();
158
+ process.exit(3);
159
+ });
@@ -20,8 +20,10 @@ function fmtCountries(v) {
20
20
  return list.length ? list.join(",") : "无";
21
21
  }
22
22
 
23
- // failover 切换冷却期(默认 45s,可经 opts.failoverCooldownMs 覆盖)
24
- const FAILOVER_COOLDOWN_MS = 45_000;
23
+ // failover 检查窗口(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
24
+ const FAILOVER_COOLDOWN_MS = 600_000;
25
+ // 窗口内探针健康时的切换阈值(默认 3:前 2 次仅检查,第 3 次按业务级封禁轮换)
26
+ const FAILOVER_ESCAPE_AFTER = 3;
25
27
 
26
28
  // ══════════════════════════════════════════════
27
29
  // ClashDaemon
@@ -35,6 +37,10 @@ export class ClashDaemon {
35
37
  opts.failoverCooldownMs > 0
36
38
  ? opts.failoverCooldownMs
37
39
  : FAILOVER_COOLDOWN_MS;
40
+ this.failoverEscapeAfter =
41
+ opts.failoverEscapeAfter > 0
42
+ ? opts.failoverEscapeAfter
43
+ : FAILOVER_ESCAPE_AFTER;
38
44
  this.dryRun = !!opts.dryRun;
39
45
  // 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
40
46
  this._discoverOpts = {
@@ -602,32 +608,48 @@ export class ClashDaemon {
602
608
  /**
603
609
  * 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
604
610
  * 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
611
+ *
612
+ * failover 三级响应(2026-08-22,替代原 45s 冷却):
613
+ * 1. 每次 failover 先检查当前节点(消除冷却期外盲切);
614
+ * 2. 探针不健康 → 立即切换(有依据才切,无视窗口);
615
+ * 3. 探针健康 → 窗口内默认只检查不切换;同一窗口内 failover 到达
616
+ * failoverEscapeAfter 次(默认 3)→ 视为业务级封禁(curl 探针感知不到
617
+ * TikTok 业务风控)→ 按轮换逻辑切一次。
618
+ * 实际切换(成功或恢复原节点)或触发候选初测后重置窗口与计数,给新节点干净
619
+ * 观察窗口;窗口 = failoverCooldownMs(默认 10 分钟,与候选集初测周期对齐)。
620
+ * 按控制端独立记录(多控制端互不影响);对外接口(命令动作/IPC/health)不变。
605
621
  */
606
622
  async _handleFailover(cmd, c) {
607
623
  try {
608
- // ── failover 冷却 + 健康验证(2026-08-18)──
609
- // 冷却期内再次收到 failover:先验证当前节点健康,健康则跳过并续冷却
610
- // (持续健康 → 持续不切,避免冷却期结束后又白切健康节点;同时过滤滞后/重连误报),
611
- // 不健康才继续切换。按控制端独立记录冷却(多控制端互不影响);
612
- // 对外接口(命令动作 / IPC / health 格式)保持不变。
624
+ const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
613
625
  const now = Date.now();
614
- const lastAt = c._lastFailoverAt || 0;
615
- if (lastAt > 0 && now - lastAt < this.failoverCooldownMs) {
616
- const h = checker.checkNodeHealth();
617
- if (h && h.healthy) {
618
- c._lastFailoverAt = now; // 续冷却:持续健康则持续不切
619
- this._log(
620
- `🔄 冷却期内节点健康 [${c.proxyPort}],跳过重复 failover(上次判定 ${Math.round((now - lastAt) / 1000)}s 前)`,
621
- );
622
- return;
623
- }
626
+ const w =
627
+ c._failoverWindow || (c._failoverWindow = { startAt: 0, count: 0 });
628
+ if (now - w.startAt >= this.failoverCooldownMs) {
629
+ w.startAt = now;
630
+ w.count = 0;
631
+ }
632
+ w.count += 1;
633
+
634
+ // 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
635
+ const h = checker.checkNodeHealth();
636
+ const probeOk = !!h && (h.healthy || this._isShellPage(h));
637
+ if (probeOk && w.count < this.failoverEscapeAfter) {
638
+ this._log(
639
+ `🔄 [${c.proxyPort}] 探针健康,窗口内第 ${w.count}/${this.failoverEscapeAfter} 次 failover 仅检查不切换 [${reason}]`,
640
+ );
641
+ return;
642
+ }
643
+ if (probeOk) {
644
+ this._log(
645
+ `🔄 [${c.proxyPort}] 窗口内第 ${w.count} 次 failover:探针健康但业务持续报错,按业务级封禁轮换 [${reason}]`,
646
+ );
647
+ } else {
624
648
  this._log(
625
- `🔄 冷却期内节点不健康 [${c.proxyPort}],继续故障切换(有依据才切)`,
649
+ `🔄 [${c.proxyPort}] 探针不健康(${(h && (h.error || `HTTP ${h.httpCode}`)) || "无响应"}),立即故障切换 [${reason}]`,
626
650
  );
627
651
  }
628
- c._lastFailoverAt = now;
629
652
 
630
- const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
631
653
  const node = await api
632
654
  .getCurrentNode(checker.CONFIG.groupName)
633
655
  .catch(() => "?");
@@ -637,6 +659,9 @@ export class ClashDaemon {
637
659
  const { nodes } = checker.loadHealthyList();
638
660
  if (!nodes.length) {
639
661
  this._log("🔄 候选集为空,触发 delay 初测");
662
+ // 初测即处置:重置窗口,避免健康探测每轮重复触发初测
663
+ w.startAt = now;
664
+ w.count = 0;
640
665
  await this._runDelayProbeController(c);
641
666
  return;
642
667
  }
@@ -660,6 +685,10 @@ export class ClashDaemon {
660
685
  }
661
686
  this._log(` ↺ ${cand} 内容验证未通过,下一个`);
662
687
  }
688
+ // 本次 failover 已尽力处置(成功切换或恢复原节点):重置窗口,给新节点干净观察窗口
689
+ w.startAt = now;
690
+ w.count = 0;
691
+
663
692
  if (!target) {
664
693
  await checker.switchToNode(node).catch(() => {});
665
694
  this._markSwitch(c, node);
@@ -801,5 +830,8 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
801
830
  secret: getArg("--secret") || undefined,
802
831
  selection: getArg("--selection") || undefined,
803
832
  configPaths: configPaths.length ? configPaths : undefined,
833
+ failoverCooldownMs: parseInt(getArg("--failover-cooldown-ms")) || undefined,
834
+ failoverEscapeAfter:
835
+ parseInt(getArg("--failover-escape-after")) || undefined,
804
836
  }).start();
805
837
  }
@@ -78,16 +78,29 @@ function printProcessLine(id, msg) {
78
78
  }
79
79
  }
80
80
 
81
- // 从命令行中提取 CDP 端口列表(解析 --base-port 和 --port-count)
81
+ // 会启动 CDP 浏览器的采集类命令(用于识别单端口形式 --port 的归属)
82
+ // 排除 webserver/watch 等 --port 是服务端口的命令,避免误杀
83
+ const BROWSER_CMD_RE =
84
+ /\b(?:explore|auto|attach|scrape|comments|refresh|recheck|precheck|search|videos|videostats|open|tag|login-check)\b/;
85
+
86
+ // 从命令行中提取 CDP 端口列表(解析 --base-port/--port-count 和单端口 --port)
82
87
  function extractCdpPorts(cmdLine) {
83
- const match = cmdLine.match(/--base-port\s+(\d+)/);
84
- if (!match) return [];
85
- const base = parseInt(match[1], 10);
86
- const countMatch = cmdLine.match(/--port-count\s+(\d+)/);
87
- const count = countMatch ? parseInt(countMatch[1], 10) : 1;
88
- const ports = [];
89
- for (let i = 0; i < count; i++) ports.push(base + i);
90
- return ports;
88
+ // 多端口形式:--base-port N [--port-count M] → N..N+M-1
89
+ const baseMatch = cmdLine.match(/--base-port\s+(\d+)/);
90
+ if (baseMatch) {
91
+ const base = parseInt(baseMatch[1], 10);
92
+ const countMatch = cmdLine.match(/--port-count\s+(\d+)/);
93
+ const count = countMatch ? parseInt(countMatch[1], 10) : 1;
94
+ const ports = [];
95
+ for (let i = 0; i < count; i++) ports.push(base + i);
96
+ return ports;
97
+ }
98
+ // 单端口形式(如 refresh-mini --port 5222):仅浏览器类命令提取
99
+ if (BROWSER_CMD_RE.test(cmdLine)) {
100
+ const m = cmdLine.match(/--port\s+(\d+)/);
101
+ if (m) return [parseInt(m[1], 10)];
102
+ }
103
+ return [];
91
104
  }
92
105
 
93
106
  // 引号感知的命令拆分:"a b" "c d" → ["a b", "c d"]
@@ -382,6 +395,13 @@ export class ProcessManager extends EventEmitter {
382
395
  const cmdLine = info.cmdDef?.command || "";
383
396
  const ports = extractCdpPorts(cmdLine);
384
397
 
398
+ // 必须先杀浏览器再杀业务进程树:业务进程被强杀后 Edge 主进程可能随之
399
+ // 退出、CDP 端口释放,此时按端口找监听 PID 会落空,导致 Edge 子进程残留。
400
+ // 先杀时端口必在监听,netstat 找 PID → taskkill /T 可杀干净整个 Edge 进程树。
401
+ for (const port of ports) {
402
+ killEdgeProcesses(null, port);
403
+ }
404
+
385
405
  if (IS_WIN) {
386
406
  // Windows: shell:true 产生 cmd.exe 中间层,必须杀整个进程树
387
407
  execSync(`taskkill /PID ${info.child.pid} /T /F`, {
@@ -401,10 +421,6 @@ export class ProcessManager extends EventEmitter {
401
421
  }, 2000);
402
422
  }
403
423
 
404
- // 全平台:关闭脱离进程树的浏览器(open -a / start msedge 启动的)
405
- for (const port of ports) {
406
- killEdgeProcesses(null, port);
407
- }
408
424
  } catch (e) {
409
425
  console.error(`[watchdog] 停止命令 ${cmdId} 失败: ${e.message}`);
410
426
  }