tt-help-cli-ycl 1.4.139 → 1.4.142
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-client-error-monitor.mjs +176 -0
- package/scripts/test-daemon-failover.mjs +13 -0
- package/scripts/test-proxy-switch-restart.mjs +198 -0
- package/scripts/test-webserver-error-classify.mjs +379 -0
- package/src/clash/checker.js +14 -2
- package/src/clash/daemon.js +33 -11
- package/src/cli/comments.js +5 -4
- package/src/cli/tag.js +26 -17
- package/src/lib/api-client.js +11 -5
- package/src/lib/args.js +4 -4
- package/src/lib/client-error-report.js +3 -0
- package/src/lib/constants.js +1 -1
- package/src/lib/parse-ssr.mjs +13 -1
- package/src/lib/port.js +72 -0
- package/src/lib/tiktok-scraper.mjs +62 -4
- package/src/watch/data-store.js +3 -0
- package/src/watch/public/app.js +249 -31
- package/src/watch/public/index.html +41 -1
- package/src/watch/public/style.css +18 -0
- package/src/watch/server.js +1 -0
- package/src/watch/watchdog-server.js +100 -0
- package/src/watchdog/agent.js +157 -1
- package/src/watchdog/client-error-monitor.js +131 -0
- package/src/watchdog/process-manager.js +49 -0
- package/src/webserver/server.mjs +263 -79
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// 回归测试:webserver 错误分类(IP 风控 → CDN 自愈,重启竞态 → 不计连续错误)
|
|
2
|
+
// 运行:node scripts/test-webserver-error-classify.mjs
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
TikTokScraper,
|
|
8
|
+
classifyGotoError,
|
|
9
|
+
} from "../src/lib/tiktok-scraper.mjs";
|
|
10
|
+
import {
|
|
11
|
+
classifyError,
|
|
12
|
+
switchClashNode,
|
|
13
|
+
settleCooldownWindow,
|
|
14
|
+
} from "../src/webserver/server.mjs";
|
|
15
|
+
import { CDNBlockedError, TikTokStatusError } from "../src/lib/parse-ssr.mjs";
|
|
16
|
+
import { configure, resetConfig } from "../src/clash/api.js";
|
|
17
|
+
import { parseNetstatListeningPids } from "../src/lib/browser/cdp.js";
|
|
18
|
+
import { findListeningPids, killListeningOnPort } from "../src/lib/port.js";
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
const checks = [];
|
|
23
|
+
function check(name, fn) {
|
|
24
|
+
checks.push({ name, fn });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runAll() {
|
|
28
|
+
for (const { name, fn } of checks) {
|
|
29
|
+
try {
|
|
30
|
+
await fn();
|
|
31
|
+
passed++;
|
|
32
|
+
console.error(`PASS ${name}`);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
failed++;
|
|
35
|
+
console.error(`FAIL ${name}: ${e.message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
console.error(`\n结果: ${passed} 通过, ${failed} 失败`);
|
|
39
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- classifyGotoError ----
|
|
43
|
+
check("goto 非2xx + status=403 → CDNBlockedError 带状态码", () => {
|
|
44
|
+
const err = classifyGotoError(
|
|
45
|
+
"page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/1",
|
|
46
|
+
403,
|
|
47
|
+
);
|
|
48
|
+
assert.ok(err instanceof CDNBlockedError);
|
|
49
|
+
assert.equal(err.name, "CDNBlockedError");
|
|
50
|
+
assert.match(err.message, /status=403/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
check("goto 非2xx + status=429 → CDNBlockedError", () => {
|
|
54
|
+
const err = classifyGotoError(
|
|
55
|
+
"page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://x",
|
|
56
|
+
429,
|
|
57
|
+
);
|
|
58
|
+
assert.ok(err instanceof CDNBlockedError);
|
|
59
|
+
assert.match(err.message, /status=429/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
check("goto 非2xx + status=null → CDNBlockedError 标 unknown", () => {
|
|
63
|
+
const err = classifyGotoError(
|
|
64
|
+
"page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://x",
|
|
65
|
+
null,
|
|
66
|
+
);
|
|
67
|
+
assert.ok(err instanceof CDNBlockedError);
|
|
68
|
+
assert.match(err.message, /status=unknown/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
check("goto 非2xx + status=404 → TikTokStatusError(业务结果)", () => {
|
|
72
|
+
const err = classifyGotoError(
|
|
73
|
+
"page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/9",
|
|
74
|
+
404,
|
|
75
|
+
);
|
|
76
|
+
assert.ok(err instanceof TikTokStatusError);
|
|
77
|
+
assert.equal(err.statusCode, 404);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
check("goto ERR_ABORTED → null(原样冒泡)", () => {
|
|
81
|
+
assert.equal(
|
|
82
|
+
classifyGotoError(
|
|
83
|
+
"page.goto: net::ERR_ABORTED at view-source:https://x",
|
|
84
|
+
null,
|
|
85
|
+
),
|
|
86
|
+
null,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
check("goto frame detached → null(原样冒泡)", () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
classifyGotoError("page.goto: net::ERR_ABORTED; maybe frame was detached?"),
|
|
93
|
+
null,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
check("goto 非字符串 → null", () => {
|
|
98
|
+
assert.equal(classifyGotoError(undefined), null);
|
|
99
|
+
assert.equal(classifyGotoError(null), null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---- classifyError ----
|
|
103
|
+
check("CDNBlockedError → cdn/503/retryable/不计连续错误", () => {
|
|
104
|
+
const c = classifyError(new CDNBlockedError("CDN限流"));
|
|
105
|
+
assert.deepEqual(c, {
|
|
106
|
+
kind: "cdn",
|
|
107
|
+
status: 503,
|
|
108
|
+
retryable: true,
|
|
109
|
+
countConsecutive: false,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
check("goto 包装的 CDNBlockedError → cdn", () => {
|
|
114
|
+
const err = classifyGotoError(
|
|
115
|
+
"page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/2",
|
|
116
|
+
403,
|
|
117
|
+
);
|
|
118
|
+
const c = classifyError(err);
|
|
119
|
+
assert.equal(c.kind, "cdn");
|
|
120
|
+
assert.equal(c.status, 503);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
check("TikTokStatusError(404) → business/200/不计连续错误", () => {
|
|
124
|
+
const c = classifyError(new TikTokStatusError(404, "TikTok HTTP 404"));
|
|
125
|
+
assert.deepEqual(c, {
|
|
126
|
+
kind: "business",
|
|
127
|
+
status: 200,
|
|
128
|
+
retryable: false,
|
|
129
|
+
countConsecutive: false,
|
|
130
|
+
statusCode: 404,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
check("Server is shutting down → shutdown/503/不计", () => {
|
|
135
|
+
const c = classifyError(new Error("Server is shutting down"));
|
|
136
|
+
assert.deepEqual(c, {
|
|
137
|
+
kind: "shutdown",
|
|
138
|
+
status: 503,
|
|
139
|
+
retryable: true,
|
|
140
|
+
countConsecutive: false,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
check("Timeout → timeout/504/计", () => {
|
|
145
|
+
const c = classifyError(new Error("page.goto: Timeout 15000ms exceeded"));
|
|
146
|
+
assert.deepEqual(c, {
|
|
147
|
+
kind: "timeout",
|
|
148
|
+
status: 504,
|
|
149
|
+
retryable: true,
|
|
150
|
+
countConsecutive: true,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
check("重启竞态产物 → browser/503/不计", () => {
|
|
155
|
+
const cases = [
|
|
156
|
+
"browserContext.newPage: Target page, context or browser has been closed",
|
|
157
|
+
"page.goto: net::ERR_ABORTED at view-source:https://www.tiktok.com/@x/video/3",
|
|
158
|
+
"page.goto: net::ERR_ABORTED; maybe frame was detached?",
|
|
159
|
+
"Cannot read properties of null (reading 'newPage')",
|
|
160
|
+
"Execution context was destroyed, most likely because of a navigation.",
|
|
161
|
+
"Protocol error (Page.navigate): Target closed.",
|
|
162
|
+
];
|
|
163
|
+
for (const msg of cases) {
|
|
164
|
+
const c = classifyError(new Error(msg));
|
|
165
|
+
assert.equal(c.kind, "browser", msg);
|
|
166
|
+
assert.equal(c.status, 503, msg);
|
|
167
|
+
assert.equal(c.retryable, true, msg);
|
|
168
|
+
assert.equal(c.countConsecutive, false, msg);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
check("未知错误 → unknown/500/计", () => {
|
|
173
|
+
const c = classifyError(new Error("some random parse failure"));
|
|
174
|
+
assert.deepEqual(c, {
|
|
175
|
+
kind: "unknown",
|
|
176
|
+
status: 500,
|
|
177
|
+
retryable: false,
|
|
178
|
+
countConsecutive: true,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ---- TikTokScraper 关闭标记(重启竞态防护)----
|
|
183
|
+
check("closing=true 时 _ensurePage 抛 Server is shutting down", async () => {
|
|
184
|
+
const s = new TikTokScraper({ poolSize: 1 });
|
|
185
|
+
s.closing = true;
|
|
186
|
+
await assert.rejects(() => s._ensurePage({}), /Server is shutting down/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
check("context=null 时 _ensurePage 抛 Server is shutting down", async () => {
|
|
190
|
+
const s = new TikTokScraper({ poolSize: 1 });
|
|
191
|
+
s.closing = false;
|
|
192
|
+
s.context = null;
|
|
193
|
+
await assert.rejects(() => s._ensurePage({}), /Server is shutting down/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---- switchClashNode(mock Clash API,不碰真实 9097)----
|
|
197
|
+
/** 起一个 mock Clash 控制端,返回处理函数闭包里的状态 */
|
|
198
|
+
function startMockClash(handler) {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const server = createServer((req, res) => {
|
|
201
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
202
|
+
let body = "";
|
|
203
|
+
req.on("data", (c) => (body += c));
|
|
204
|
+
req.on("end", () => {
|
|
205
|
+
const reply = (status, data) => {
|
|
206
|
+
res.statusCode = status;
|
|
207
|
+
if (data === undefined) {
|
|
208
|
+
res.end();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
res.setHeader("Content-Type", "application/json");
|
|
212
|
+
res.end(JSON.stringify(data));
|
|
213
|
+
};
|
|
214
|
+
handler(req, url, body ? JSON.parse(body) : null, reply);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
server.listen(0, "127.0.0.1", () => resolve(server));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
check("switchClashNode:选第一个 delay 通过的候选并切换", async () => {
|
|
222
|
+
let switchedTo = null;
|
|
223
|
+
const server = await startMockClash((req, url, body, reply) => {
|
|
224
|
+
if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
|
|
225
|
+
reply(200, { now: "A", all: ["A", "B", "C"] });
|
|
226
|
+
} else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
|
|
227
|
+
reply(200, { delay: 120, timeout: false });
|
|
228
|
+
} else if (req.method === "PUT" && url.pathname === "/proxies/GLOBAL") {
|
|
229
|
+
switchedTo = body.name;
|
|
230
|
+
reply(204);
|
|
231
|
+
} else {
|
|
232
|
+
reply(404, {});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
const port = server.address().port;
|
|
236
|
+
configure({ port });
|
|
237
|
+
try {
|
|
238
|
+
const picked = await switchClashNode("测试切节点");
|
|
239
|
+
assert.equal(picked, "B"); // 跳过当前节点 A,选第一个探测通过的 B
|
|
240
|
+
assert.equal(switchedTo, "B");
|
|
241
|
+
} finally {
|
|
242
|
+
resetConfig();
|
|
243
|
+
await new Promise((r) => server.close(r));
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
check("switchClashNode:候选均探测不通 → 抛错", async () => {
|
|
248
|
+
const server = await startMockClash((req, url, body, reply) => {
|
|
249
|
+
if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
|
|
250
|
+
reply(200, { now: "A", all: ["A", "B"] });
|
|
251
|
+
} else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
|
|
252
|
+
reply(200, { delay: -1, timeout: true });
|
|
253
|
+
} else {
|
|
254
|
+
reply(404, {});
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
configure({ port: server.address().port });
|
|
258
|
+
try {
|
|
259
|
+
await assert.rejects(() => switchClashNode("测试"), /候选均探测不通/);
|
|
260
|
+
} finally {
|
|
261
|
+
resetConfig();
|
|
262
|
+
await new Promise((r) => server.close(r));
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
check("switchClashNode:无候选节点(组内只有当前节点)→ 抛错", async () => {
|
|
267
|
+
const server = await startMockClash((req, url, body, reply) => {
|
|
268
|
+
if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
|
|
269
|
+
reply(200, { now: "A", all: ["A"] });
|
|
270
|
+
} else {
|
|
271
|
+
reply(404, {});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
configure({ port: server.address().port });
|
|
275
|
+
try {
|
|
276
|
+
await assert.rejects(() => switchClashNode("测试"), /无候选节点/);
|
|
277
|
+
} finally {
|
|
278
|
+
resetConfig();
|
|
279
|
+
await new Promise((r) => server.close(r));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---- 端口占用清理 ----
|
|
284
|
+
check("parseNetstatListeningPids:精确端口匹配(3000 不误匹配 30001)", () => {
|
|
285
|
+
const out = [
|
|
286
|
+
" TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234",
|
|
287
|
+
" TCP 0.0.0.0:30001 0.0.0.0:0 LISTENING 5678",
|
|
288
|
+
" TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 1234",
|
|
289
|
+
" TCP [::]:3000 [::]:0 LISTENING 4321",
|
|
290
|
+
].join("\r\n");
|
|
291
|
+
assert.deepEqual(parseNetstatListeningPids(out, 3000), ["1234", "4321"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
check("killListeningOnPort:杀掉占用端口的子进程并可重新监听", async () => {
|
|
295
|
+
// 先占一个随机端口拿到端口号,再释放
|
|
296
|
+
const holder = createServer();
|
|
297
|
+
await new Promise((r) => holder.listen(0, "127.0.0.1", r));
|
|
298
|
+
const port = holder.address().port;
|
|
299
|
+
await new Promise((r) => holder.close(r));
|
|
300
|
+
|
|
301
|
+
// spawn 独立子进程占住该端口
|
|
302
|
+
const child = spawn(
|
|
303
|
+
process.execPath,
|
|
304
|
+
[
|
|
305
|
+
"-e",
|
|
306
|
+
`require("http").createServer((q,s)=>s.end("ok")).listen(${port},"127.0.0.1");setInterval(()=>{},1000);`,
|
|
307
|
+
],
|
|
308
|
+
{ stdio: "ignore" },
|
|
309
|
+
);
|
|
310
|
+
await new Promise((r) => setTimeout(r, 800)); // 等子进程监听
|
|
311
|
+
|
|
312
|
+
// 确认端口确实被占用
|
|
313
|
+
const before = findListeningPids(port);
|
|
314
|
+
assert.ok(
|
|
315
|
+
before.includes(String(child.pid)),
|
|
316
|
+
`预期子进程 ${child.pid} 在监听 ${port},实际: ${before.join(",")}`,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// 先注册 exit 监听(kill 后进程瞬间退出,后注册会丢失事件)
|
|
320
|
+
const exitedPromise = new Promise((resolve) => {
|
|
321
|
+
const t = setTimeout(() => resolve(false), 3000);
|
|
322
|
+
child.once("exit", () => {
|
|
323
|
+
clearTimeout(t);
|
|
324
|
+
resolve(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await killListeningOnPort(port);
|
|
329
|
+
|
|
330
|
+
const exited = await exitedPromise;
|
|
331
|
+
assert.equal(exited, true, "子进程应被杀死");
|
|
332
|
+
|
|
333
|
+
// 端口应可重新监听
|
|
334
|
+
const s2 = createServer();
|
|
335
|
+
await new Promise((r) => s2.listen(port, "127.0.0.1", r));
|
|
336
|
+
await new Promise((r) => s2.close(r));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---- 5 分钟窗口结算(只数限流窗口,成功请求不参与)----
|
|
340
|
+
const WINDOW = 5 * 60 * 1000;
|
|
341
|
+
check("settleCooldownWindow:无窗口 → 不结算原样返回", () => {
|
|
342
|
+
assert.deepEqual(settleCooldownWindow(0, 0, 1000, WINDOW, 2), {
|
|
343
|
+
shouldSwitch: false,
|
|
344
|
+
count: 0,
|
|
345
|
+
windowStartAt: 0,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
check("settleCooldownWindow:窗口内 → 不结算原样返回", () => {
|
|
349
|
+
assert.deepEqual(
|
|
350
|
+
settleCooldownWindow(1, 1000, 1000 + WINDOW - 1, WINDOW, 2),
|
|
351
|
+
{
|
|
352
|
+
shouldSwitch: false,
|
|
353
|
+
count: 1,
|
|
354
|
+
windowStartAt: 1000,
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
check("settleCooldownWindow:超 5 分钟且限流窗口 ≥2 → 切换并重新计数", () => {
|
|
359
|
+
assert.deepEqual(
|
|
360
|
+
settleCooldownWindow(2, 1000, 1000 + WINDOW + 1, WINDOW, 2),
|
|
361
|
+
{
|
|
362
|
+
shouldSwitch: true,
|
|
363
|
+
count: 0,
|
|
364
|
+
windowStartAt: 0,
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
check("settleCooldownWindow:超 5 分钟但限流窗口 <2 → 重新计算不切换", () => {
|
|
369
|
+
assert.deepEqual(
|
|
370
|
+
settleCooldownWindow(1, 1000, 1000 + WINDOW + 1, WINDOW, 2),
|
|
371
|
+
{
|
|
372
|
+
shouldSwitch: false,
|
|
373
|
+
count: 0,
|
|
374
|
+
windowStartAt: 0,
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await runAll();
|
package/src/clash/checker.js
CHANGED
|
@@ -403,11 +403,18 @@ export function loadHealthyList(country = null) {
|
|
|
403
403
|
if (!existsSync(_healthyFile)) return _emptyList();
|
|
404
404
|
const d = JSON.parse(readFileSync(_healthyFile, "utf-8"));
|
|
405
405
|
const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
|
|
406
|
+
const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
|
|
406
407
|
const filter = country || CONFIG.nodeCountryFilter;
|
|
407
408
|
const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
|
|
409
|
+
const delays = {};
|
|
410
|
+
for (const n of filtered) {
|
|
411
|
+
const v = allDelays[n];
|
|
412
|
+
if (typeof v === "number" && v > 0) delays[n] = v;
|
|
413
|
+
}
|
|
408
414
|
return {
|
|
409
415
|
updated: d.updated || "",
|
|
410
416
|
nodes: filtered,
|
|
417
|
+
delays,
|
|
411
418
|
totalNodes: allNodes.length,
|
|
412
419
|
excluded,
|
|
413
420
|
countryFilter: filter || null,
|
|
@@ -418,11 +425,15 @@ export function loadHealthyList(country = null) {
|
|
|
418
425
|
return _emptyList();
|
|
419
426
|
}
|
|
420
427
|
|
|
421
|
-
export function saveHealthyList(nodes) {
|
|
428
|
+
export function saveHealthyList(nodes, delays = {}) {
|
|
422
429
|
try {
|
|
423
430
|
writeFileSync(
|
|
424
431
|
_healthyFile,
|
|
425
|
-
JSON.stringify(
|
|
432
|
+
JSON.stringify(
|
|
433
|
+
{ updated: new Date().toISOString(), nodes, delays: delays || {} },
|
|
434
|
+
null,
|
|
435
|
+
2,
|
|
436
|
+
),
|
|
426
437
|
"utf-8",
|
|
427
438
|
);
|
|
428
439
|
} catch (e) {
|
|
@@ -434,6 +445,7 @@ function _emptyList() {
|
|
|
434
445
|
return {
|
|
435
446
|
updated: "",
|
|
436
447
|
nodes: [],
|
|
448
|
+
delays: {},
|
|
437
449
|
totalNodes: 0,
|
|
438
450
|
excluded: 0,
|
|
439
451
|
countryFilter: null,
|
package/src/clash/daemon.js
CHANGED
|
@@ -104,7 +104,11 @@ export class ClashDaemon {
|
|
|
104
104
|
|
|
105
105
|
/** 记录最近一次实际切换节点的时间(failover/巡检/手动切换共用锚点,冷却期判断依据) */
|
|
106
106
|
_markSwitchTime(c) {
|
|
107
|
-
if (c)
|
|
107
|
+
if (!c) return;
|
|
108
|
+
c._lastSwitchAt = Date.now();
|
|
109
|
+
// 切换通知序号:每次实际切换 +1,watchdog 感知变化后强制重启全部命令
|
|
110
|
+
// (先打通路:发现切了就重启;冷却/开关等控制后续再加)
|
|
111
|
+
c._restartSeq = (c._restartSeq || 0) + 1;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
/**
|
|
@@ -191,7 +195,12 @@ export class ClashDaemon {
|
|
|
191
195
|
|
|
192
196
|
/** 写通道健康状态(每端口一份;主控制端额外写默认文件兼容 watchdog) */
|
|
193
197
|
_writeHealth(c, state) {
|
|
194
|
-
const payload = {
|
|
198
|
+
const payload = {
|
|
199
|
+
...state,
|
|
200
|
+
// 切换通知序号(每次实际切换 +1,watchdog 比较变化感知切换)
|
|
201
|
+
restartSeq: (c && c._restartSeq) || 0,
|
|
202
|
+
meta: this._buildMeta(c),
|
|
203
|
+
};
|
|
195
204
|
ipc.writeHealth(payload, c.proxyPort || 0);
|
|
196
205
|
if (c === this._primary && c.proxyPort) ipc.writeHealth(payload, 0);
|
|
197
206
|
}
|
|
@@ -552,7 +561,12 @@ export class ClashDaemon {
|
|
|
552
561
|
country: checker.CONFIG.nodeCountryFilter,
|
|
553
562
|
});
|
|
554
563
|
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
555
|
-
|
|
564
|
+
const delays = {};
|
|
565
|
+
for (const it of r.results || []) {
|
|
566
|
+
if (typeof it.delay === "number" && it.delay > 0)
|
|
567
|
+
delays[it.node] = it.delay;
|
|
568
|
+
}
|
|
569
|
+
checker.saveHealthyList(r.nodes, delays);
|
|
556
570
|
// 清理切换历史中已不在候选集的节点,防止 Map 无限增长
|
|
557
571
|
if (c._switchHistory && c._switchHistory.size) {
|
|
558
572
|
const keep = new Set(r.nodes);
|
|
@@ -662,21 +676,28 @@ export class ClashDaemon {
|
|
|
662
676
|
try {
|
|
663
677
|
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
664
678
|
const now = Date.now();
|
|
679
|
+
// 强切(force=true,看门狗客户端异常监控触发):无视冷却期直接轮换;
|
|
680
|
+
// 切换成功后仍更新 _lastSwitchAt 锚点,给新节点干净观察期
|
|
681
|
+
const force = !!(cmd && cmd.force);
|
|
665
682
|
|
|
666
683
|
// 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
|
|
667
684
|
const h = checker.checkNodeHealth();
|
|
668
685
|
const probeOk = !!h && (h.healthy || this._isShellPage(h));
|
|
669
686
|
if (probeOk) {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
this.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
687
|
+
if (!force) {
|
|
688
|
+
// 探针健康:受冷却期约束(锚点 = 最近一次实际切换节点的时间 _lastSwitchAt)
|
|
689
|
+
const gap = now - (c._lastSwitchAt || 0);
|
|
690
|
+
if (gap < this.failoverCooldownMs) {
|
|
691
|
+
this._log(
|
|
692
|
+
`🔄 [${c.proxyPort}] 探针健康,距上次切换 ${Math.round(gap / 1000)}s(<${this.failoverCooldownMs / 60000}min 冷却),仅检查不切换 [${reason}]`,
|
|
693
|
+
);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
677
696
|
}
|
|
678
697
|
this._log(
|
|
679
|
-
|
|
698
|
+
force
|
|
699
|
+
? `🔄 [${c.proxyPort}] 强切指令(无视冷却),按业务级封禁轮换 [${reason}]`
|
|
700
|
+
: `🔄 [${c.proxyPort}] 探针健康但业务持续报错,距上次切换已过冷却期,按业务级封禁轮换 [${reason}]`,
|
|
680
701
|
);
|
|
681
702
|
} else {
|
|
682
703
|
this._log(
|
|
@@ -771,6 +792,7 @@ export class ClashDaemon {
|
|
|
771
792
|
healthyNodesCount: list.nodes.length,
|
|
772
793
|
healthyNodesTotal: list.totalNodes,
|
|
773
794
|
healthyNodes: list.nodes.slice(0, 50),
|
|
795
|
+
healthyNodeDelays: list.delays || {},
|
|
774
796
|
});
|
|
775
797
|
return base;
|
|
776
798
|
}
|
package/src/cli/comments.js
CHANGED
|
@@ -284,10 +284,11 @@ class CdpSessionManager {
|
|
|
284
284
|
this.browser = await ensureBrowserReady(opts);
|
|
285
285
|
this.page = await this._ensurePage();
|
|
286
286
|
this.lastRecycleAt = Date.now();
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
287
|
+
// 重建会话后重置运行计时(无论是否换端口)。
|
|
288
|
+
// 旧逻辑只在换端口时重置,而默认 switchPortOnRecycle=false,
|
|
289
|
+
// 导致 48min 定时轮换触发后计时不归零,之后每次内存采样(30s)
|
|
290
|
+
// 都再次满足超时条件 → 每 30s 反复重建会话(“一直在切换”)
|
|
291
|
+
this.sessionStartTime = Date.now();
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
/** 内存检查 + 定时轮换 + 按需回收(每 30s 最多采样一次) */
|
package/src/cli/tag.js
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync } from "fs";
|
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import http from "http";
|
|
4
4
|
import readline from "readline";
|
|
5
|
+
import os from "os";
|
|
5
6
|
import {
|
|
6
7
|
getBrowserMemory,
|
|
7
8
|
formatMemoryWithBrowser,
|
|
@@ -1058,11 +1059,12 @@ export async function handleScoreAll(parsed) {
|
|
|
1058
1059
|
let retrySameTag = false; // 是否重试同一个 tag
|
|
1059
1060
|
let tag403Attempts = 0; // 当前 tag 已发生 403 的次数
|
|
1060
1061
|
let bannedCount = 0; // 连续判定“被封”的次数(成功 tag 清零)
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
parseInt(process.env.
|
|
1065
|
-
parseInt(process.env.
|
|
1062
|
+
// 403 渐退重试间隔(秒):30/40/50s 各重试一次,仍 403 才判定“被封”一次。
|
|
1063
|
+
// 与 api-interceptor 被封重试(30/40/50s 共 3 次)保持一致
|
|
1064
|
+
const BAN_RETRY_WAITS = [
|
|
1065
|
+
(parseInt(process.env.TAG_SCOREALL_403_RETRY_1 || "30", 10) || 30) * 1000,
|
|
1066
|
+
(parseInt(process.env.TAG_SCOREALL_403_RETRY_2 || "40", 10) || 40) * 1000,
|
|
1067
|
+
(parseInt(process.env.TAG_SCOREALL_403_RETRY_3 || "50", 10) || 50) * 1000,
|
|
1066
1068
|
];
|
|
1067
1069
|
|
|
1068
1070
|
// 生成客户端 ID,用于服务端追踪
|
|
@@ -1439,26 +1441,32 @@ export async function handleScoreAll(parsed) {
|
|
|
1439
1441
|
e.message &&
|
|
1440
1442
|
(e.message.includes("验证码") || e.message.includes("captcha"));
|
|
1441
1443
|
|
|
1442
|
-
// ── 403
|
|
1444
|
+
// ── 403:渐退等待 30/40/50s 各重试一次,仍 403 判定“被封” ──
|
|
1445
|
+
// 首次被封重启浏览器(同端口换新会话),后续仍 403 只清 tab
|
|
1443
1446
|
if (is403) {
|
|
1447
|
+
const retryIndex = tag403Attempts;
|
|
1444
1448
|
tag403Attempts++;
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
Math.min(bannedCount, BAN_RETRY_BUDGETS.length - 1)
|
|
1448
|
-
];
|
|
1449
|
-
if (tag403Attempts <= budget) {
|
|
1449
|
+
if (retryIndex < BAN_RETRY_WAITS.length) {
|
|
1450
|
+
const waitMs = BAN_RETRY_WAITS[retryIndex];
|
|
1450
1451
|
retrySameTag = true;
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1452
|
+
if (retryIndex === 0) {
|
|
1453
|
+
log(
|
|
1454
|
+
` 🚫 HTTP 403,判定被封,重启浏览器后 ${waitMs / 1000}s 第 ${tag403Attempts}/${BAN_RETRY_WAITS.length} 次重试 #${currentTag} ...`,
|
|
1455
|
+
);
|
|
1456
|
+
await recycleCdpSession(`首次被封`, { switchPort: false });
|
|
1457
|
+
} else {
|
|
1458
|
+
log(
|
|
1459
|
+
` 🚫 HTTP 403,判定被封,${waitMs / 1000}s 后第 ${tag403Attempts}/${BAN_RETRY_WAITS.length} 次重试 #${currentTag} ...`,
|
|
1460
|
+
);
|
|
1461
|
+
await recyclePage();
|
|
1462
|
+
}
|
|
1463
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1456
1464
|
continue;
|
|
1457
1465
|
}
|
|
1458
1466
|
// 重试耗尽:判定“被封”
|
|
1459
1467
|
bannedCount++;
|
|
1460
1468
|
log(
|
|
1461
|
-
` 🚫 重试 ${
|
|
1469
|
+
` 🚫 30/40/50s 重试 ${BAN_RETRY_WAITS.length} 次仍 403,判定账号被封(连续判封 ${bannedCount} 次)`,
|
|
1462
1470
|
);
|
|
1463
1471
|
if (bannedCount >= 2) {
|
|
1464
1472
|
log(` ⚠️ 连续两次判封 → 汇报服务端 + 触发 daemon fallback`);
|
|
@@ -1511,6 +1519,7 @@ export async function handleScoreAll(parsed) {
|
|
|
1511
1519
|
errorMessage: e.message,
|
|
1512
1520
|
stage: "tag-scoring",
|
|
1513
1521
|
errorStack: "",
|
|
1522
|
+
hostname: os.hostname(),
|
|
1514
1523
|
}),
|
|
1515
1524
|
});
|
|
1516
1525
|
} catch {}
|
package/src/lib/api-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import os from "os";
|
|
2
3
|
|
|
3
4
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
4
5
|
|
|
@@ -68,13 +69,18 @@ export function createApiClient(opts = {}) {
|
|
|
68
69
|
return withRetry(
|
|
69
70
|
`POST ${url}`,
|
|
70
71
|
async () => {
|
|
72
|
+
const headers = buildHeaders(
|
|
73
|
+
{ "Content-Type": "application/json" },
|
|
74
|
+
clientId,
|
|
75
|
+
meta,
|
|
76
|
+
);
|
|
77
|
+
// error-report 自动带上本机 hostname,供看门狗按机器关联异常
|
|
78
|
+
if (/\/api\/error-report(\?|$)/.test(String(url))) {
|
|
79
|
+
headers["X-Client-Hostname"] = os.hostname();
|
|
80
|
+
}
|
|
71
81
|
const res = await fetch(url, {
|
|
72
82
|
method: "POST",
|
|
73
|
-
headers
|
|
74
|
-
{ "Content-Type": "application/json" },
|
|
75
|
-
clientId,
|
|
76
|
-
meta,
|
|
77
|
-
),
|
|
83
|
+
headers,
|
|
78
84
|
body: JSON.stringify(body),
|
|
79
85
|
signal: AbortSignal.timeout(30000),
|
|
80
86
|
});
|
package/src/lib/args.js
CHANGED
|
@@ -414,8 +414,8 @@ function parseWebserverArgs(args) {
|
|
|
414
414
|
let webserverLog = false;
|
|
415
415
|
// 默认安全限速:3 请求/秒。限速突发容量自动跟随 pool-size(见下方返回)。
|
|
416
416
|
let webserverRate = 3;
|
|
417
|
-
// CDN 限流冷却时长(秒),默认
|
|
418
|
-
let webserverCooldown =
|
|
417
|
+
// CDN 限流冷却时长(秒),默认 100(2 次重启 + 切节点流程节奏)
|
|
418
|
+
let webserverCooldown = 100;
|
|
419
419
|
// 默认汇报到 ~/.tt-help.json 中配置的 server 地址
|
|
420
420
|
let webserverNotify = defaultServer;
|
|
421
421
|
|
|
@@ -438,10 +438,10 @@ function parseWebserverArgs(args) {
|
|
|
438
438
|
} else if (arg === "--cooldown") {
|
|
439
439
|
const next = args[i + 1];
|
|
440
440
|
if (next && !next.startsWith("--")) {
|
|
441
|
-
webserverCooldown = parseInt(next) ||
|
|
441
|
+
webserverCooldown = parseInt(next) || 100;
|
|
442
442
|
i++;
|
|
443
443
|
} else {
|
|
444
|
-
webserverCooldown =
|
|
444
|
+
webserverCooldown = 100; // 缺省回退到默认值
|
|
445
445
|
}
|
|
446
446
|
} else if (arg === "--log") {
|
|
447
447
|
webserverLog = true;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* 供 tag / comments 等业务命令复用,避免重复实现。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import os from "os";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* 触发 Clash daemon 的故障切换(换出口节点),与 watchdog 的 failover 一致。
|
|
8
10
|
* 复用 ../clash/ipc.js 现成的 writeCommand 接口,不改动 daemon/ipc 模块。
|
|
@@ -47,6 +49,7 @@ export async function reportClientErrorAndFailover({
|
|
|
47
49
|
errorMessage: message,
|
|
48
50
|
stage,
|
|
49
51
|
errorStack: "",
|
|
52
|
+
hostname: os.hostname(), // 机器标识:看门狗按 hostname 关联本机异常
|
|
50
53
|
});
|
|
51
54
|
} catch {}
|
|
52
55
|
await triggerClashFailover(message, log);
|