tt-help-cli-ycl 1.4.139 → 1.4.140
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-webserver-error-classify.mjs +379 -0
- package/src/clash/checker.js +14 -2
- package/src/clash/daemon.js +7 -1
- package/src/cli/comments.js +5 -4
- package/src/lib/args.js +4 -4
- 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/public/app.js +67 -23
- package/src/watch/public/style.css +18 -0
- package/src/webserver/server.mjs +262 -79
package/package.json
CHANGED
|
@@ -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
|
@@ -552,7 +552,12 @@ export class ClashDaemon {
|
|
|
552
552
|
country: checker.CONFIG.nodeCountryFilter,
|
|
553
553
|
});
|
|
554
554
|
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
555
|
-
|
|
555
|
+
const delays = {};
|
|
556
|
+
for (const it of r.results || []) {
|
|
557
|
+
if (typeof it.delay === "number" && it.delay > 0)
|
|
558
|
+
delays[it.node] = it.delay;
|
|
559
|
+
}
|
|
560
|
+
checker.saveHealthyList(r.nodes, delays);
|
|
556
561
|
// 清理切换历史中已不在候选集的节点,防止 Map 无限增长
|
|
557
562
|
if (c._switchHistory && c._switchHistory.size) {
|
|
558
563
|
const keep = new Set(r.nodes);
|
|
@@ -771,6 +776,7 @@ export class ClashDaemon {
|
|
|
771
776
|
healthyNodesCount: list.nodes.length,
|
|
772
777
|
healthyNodesTotal: list.totalNodes,
|
|
773
778
|
healthyNodes: list.nodes.slice(0, 50),
|
|
779
|
+
healthyNodeDelays: list.delays || {},
|
|
774
780
|
});
|
|
775
781
|
return base;
|
|
776
782
|
}
|
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/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;
|
package/src/lib/constants.js
CHANGED
|
@@ -291,7 +291,7 @@ const HELP_TEXT = [
|
|
|
291
291
|
" --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
|
|
292
292
|
" --rate <请求/秒> 对 TikTok 的全局请求速率限制,默认 3(0=不限制)",
|
|
293
293
|
" (限速突发容量自动跟随 --pool-size,无需单独设置)",
|
|
294
|
-
" --cooldown <秒> CDN
|
|
294
|
+
" --cooldown <秒> CDN 限流自动冷却时长,默认 100(冷却→探活→最多2次重启→自动切 Clash 节点)",
|
|
295
295
|
" --log 显示 API 成功日志(默认不显示,错误日志始终输出)",
|
|
296
296
|
" --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
|
|
297
297
|
" API:",
|
package/src/lib/parse-ssr.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CDN 限流错误(Akamai Access Denied
|
|
2
|
+
* CDN 限流错误(Akamai Access Denied / HTTP 非 2xx 风控)
|
|
3
3
|
*/
|
|
4
4
|
export class CDNBlockedError extends Error {
|
|
5
5
|
constructor(message = "CDN限流 (Access Denied)", reference) {
|
|
@@ -9,6 +9,18 @@ export class CDNBlockedError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* TikTok 明确 HTTP 状态码错误(goto 阶段被非 2xx 拒绝,如 404 视频/用户不存在)。
|
|
14
|
+
* 与 CDNBlockedError 区分:业务结果而非风控,webserver 返回 200 + statusCode。
|
|
15
|
+
*/
|
|
16
|
+
export class TikTokStatusError extends Error {
|
|
17
|
+
constructor(statusCode, message) {
|
|
18
|
+
super(message || `TikTok HTTP ${statusCode}`);
|
|
19
|
+
this.name = "TikTokStatusError";
|
|
20
|
+
this.statusCode = statusCode;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
/**
|
|
13
25
|
* 检测 HTML 是否为 CDN Access Denied
|
|
14
26
|
* 返回 { isBlocked: true, reference: "xxx" } 或 null
|
package/src/lib/port.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 端口占用清理:webserver 启动前检查目标端口,若被占用直接杀掉占用进程。
|
|
3
|
+
* Windows 走 netstat -ano + taskkill;macOS/Linux 走 lsof + SIGKILL。
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { parseNetstatListeningPids } from "./browser/cdp.js";
|
|
7
|
+
|
|
8
|
+
const KILL_WAIT_MS = 500; // 杀完等端口释放(Windows 进程退出有延迟,立即 listen 仍可能 EADDRINUSE)
|
|
9
|
+
|
|
10
|
+
function delay(ms) {
|
|
11
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 返回监听指定端口的 PID 列表(字符串数组) */
|
|
15
|
+
export function findListeningPids(port) {
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
try {
|
|
18
|
+
const out = execSync("netstat -ano -p TCP", {
|
|
19
|
+
encoding: "utf-8",
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
windowsHide: true,
|
|
22
|
+
});
|
|
23
|
+
return parseNetstatListeningPids(out, port);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(`[port] netstat 查询失败: ${e.message}`);
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// POSIX:lsof 找不到监听时退出码非 0 且无输出,属正常情况
|
|
30
|
+
try {
|
|
31
|
+
const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, {
|
|
32
|
+
encoding: "utf-8",
|
|
33
|
+
timeout: 5000,
|
|
34
|
+
});
|
|
35
|
+
return out
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter((s) => /^\d+$/.test(s));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 杀掉监听指定端口的全部进程(排除自身 PID),供启动前清理端口占用。
|
|
46
|
+
* 不区分进程类型:谁占谁死(用户拍板)。
|
|
47
|
+
*/
|
|
48
|
+
export async function killListeningOnPort(port) {
|
|
49
|
+
const pids = findListeningPids(port).filter((p) => p !== String(process.pid));
|
|
50
|
+
if (!pids.length) return;
|
|
51
|
+
console.error(
|
|
52
|
+
`[webserver] 端口 ${port} 被占用,直接清理进程 PID: ${pids.join(", ")}`,
|
|
53
|
+
);
|
|
54
|
+
for (const pid of pids) {
|
|
55
|
+
try {
|
|
56
|
+
if (process.platform === "win32") {
|
|
57
|
+
execSync(`taskkill /F /T /PID ${pid}`, {
|
|
58
|
+
encoding: "buffer",
|
|
59
|
+
timeout: 15000,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
});
|
|
62
|
+
} else {
|
|
63
|
+
process.kill(parseInt(pid, 10), "SIGKILL");
|
|
64
|
+
}
|
|
65
|
+
console.error(`[webserver] 已杀 PID ${pid}`);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(`[webserver] 杀 PID ${pid} 失败: ${e.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// 等待端口释放,避免立即 listen 仍 EADDRINUSE
|
|
71
|
+
await delay(KILL_WAIT_MS);
|
|
72
|
+
}
|