tt-help-cli-ycl 1.4.78 → 1.4.80
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/src/clash/api.js +17 -0
- package/src/clash/checker.js +218 -94
- package/src/clash/daemon.js +227 -68
- package/src/clash/ipc.js +23 -4
- package/src/cli/clash-daemon.js +2 -1
- package/src/cli/explore.js +15 -4
- package/src/cli/refresh.js +15 -6
- package/src/cli/search.js +16 -7
- package/src/cli/webserver.js +8 -2
- package/src/lib/args.js +8 -0
- package/src/lib/constants.js +23 -0
- package/src/lib/shop-scraper.mjs +34 -17
- package/src/watch/public/app.js +150 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/watchdog-server.js +32 -1
- package/src/watchdog/agent.js +20 -2
- package/src/watchdog/process-manager.js +57 -0
- package/src/webserver/server.mjs +197 -27
- package/src/clash/health-checker.js +0 -248
package/src/webserver/server.mjs
CHANGED
|
@@ -35,6 +35,14 @@ function sendJSON(res, status, data) {
|
|
|
35
35
|
|
|
36
36
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
37
37
|
|
|
38
|
+
function getClientIP(req) {
|
|
39
|
+
return (
|
|
40
|
+
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
|
41
|
+
req.socket.remoteAddress ||
|
|
42
|
+
""
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
function readBody(req) {
|
|
39
47
|
return new Promise((resolve, reject) => {
|
|
40
48
|
let body = "";
|
|
@@ -64,15 +72,27 @@ const SCRAPER_CONFIG = {
|
|
|
64
72
|
maxRequestsPerPage: 50,
|
|
65
73
|
proxyServer: null,
|
|
66
74
|
captchaNotifyUrl: null,
|
|
75
|
+
enableLog: false,
|
|
67
76
|
};
|
|
68
77
|
|
|
78
|
+
// 仅 --log 模式下输出成功日志;错误日志始终输出
|
|
79
|
+
function apiLog(msg) {
|
|
80
|
+
if (SCRAPER_CONFIG.enableLog) console.error(msg);
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
let scraperInstance = null;
|
|
70
84
|
let scraperClosing = false;
|
|
71
85
|
let scraperInitPromise = null;
|
|
86
|
+
let restartPromise = null; // 防重入:同一时间只允许一个 restartScraper
|
|
72
87
|
let consecutiveErrors = 0;
|
|
73
88
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
74
89
|
let warmTimer = null;
|
|
75
90
|
let lastWarmTime = 0;
|
|
91
|
+
// 滑动窗口错误率追踪(用于 /health 上报,不触发重启)
|
|
92
|
+
let recentTotal = 0;
|
|
93
|
+
let recentErrors = 0;
|
|
94
|
+
let recentWindowStart = Date.now();
|
|
95
|
+
const RECENT_WINDOW_MS = 60000; // 60 秒滑动窗口
|
|
76
96
|
|
|
77
97
|
async function getScraper() {
|
|
78
98
|
if (scraperInstance && !scraperClosing) {
|
|
@@ -100,17 +120,48 @@ async function getScraper() {
|
|
|
100
120
|
}
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
function recordRequest(success) {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
if (now - recentWindowStart > RECENT_WINDOW_MS) {
|
|
126
|
+
recentTotal = 0;
|
|
127
|
+
recentErrors = 0;
|
|
128
|
+
recentWindowStart = now;
|
|
129
|
+
}
|
|
130
|
+
recentTotal++;
|
|
131
|
+
if (!success) recentErrors++;
|
|
132
|
+
if (success) consecutiveErrors = 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isDegraded() {
|
|
136
|
+
// 仅当 scraper 正在恢复/关闭中才算降级,初始冷状态不算
|
|
137
|
+
return (
|
|
138
|
+
scraperClosing || scraperInitPromise !== null || restartPromise !== null
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
103
142
|
async function restartScraper(reason) {
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
143
|
+
// 防重入:已有重启在进行中,等它完成即可
|
|
144
|
+
if (restartPromise) {
|
|
145
|
+
console.error(`[webserver] 重启已在执行中,跳过本次: ${reason}`);
|
|
146
|
+
return restartPromise;
|
|
147
|
+
}
|
|
148
|
+
restartPromise = (async () => {
|
|
149
|
+
console.error(`[webserver] 重启 scraper: ${reason}`);
|
|
150
|
+
if (scraperInstance) {
|
|
151
|
+
try {
|
|
152
|
+
await scraperInstance.close();
|
|
153
|
+
} catch {}
|
|
154
|
+
scraperInstance = null;
|
|
155
|
+
}
|
|
156
|
+
scraperInitPromise = null;
|
|
157
|
+
consecutiveErrors = 0;
|
|
158
|
+
console.error(`[webserver] 重启 scraper 完成`);
|
|
159
|
+
})();
|
|
160
|
+
try {
|
|
161
|
+
await restartPromise;
|
|
162
|
+
} finally {
|
|
163
|
+
restartPromise = null;
|
|
110
164
|
}
|
|
111
|
-
scraperInitPromise = null;
|
|
112
|
-
consecutiveErrors = 0;
|
|
113
|
-
// 下次请求时懒初始化
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
async function warmScraper() {
|
|
@@ -156,13 +207,39 @@ async function handleTiktokUser(req, res) {
|
|
|
156
207
|
if (!body.uniqueId) {
|
|
157
208
|
return sendJSON(res, 400, { error: "missing uniqueId" });
|
|
158
209
|
}
|
|
210
|
+
const clientIP = getClientIP(req);
|
|
211
|
+
const startTime = Date.now();
|
|
159
212
|
const scraper = await getScraper();
|
|
160
213
|
const result = await scraper.getUserInfo(body.uniqueId);
|
|
214
|
+
const elapsed = Date.now() - startTime;
|
|
215
|
+
|
|
161
216
|
if (!result) {
|
|
162
|
-
|
|
163
|
-
|
|
217
|
+
console.error(
|
|
218
|
+
`[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | empty SSR / parse failed`,
|
|
219
|
+
);
|
|
220
|
+
recordRequest(false);
|
|
221
|
+
return sendJSON(res, 503, {
|
|
222
|
+
error: "Temporary failure: unable to fetch user data",
|
|
223
|
+
retryable: true,
|
|
164
224
|
});
|
|
165
225
|
}
|
|
226
|
+
|
|
227
|
+
if (result.error) {
|
|
228
|
+
console.error(
|
|
229
|
+
`[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | TikTok statusCode=${result.statusCode}`,
|
|
230
|
+
);
|
|
231
|
+
recordRequest(false);
|
|
232
|
+
return sendJSON(res, 502, {
|
|
233
|
+
error: `TikTok error (statusCode=${result.statusCode})`,
|
|
234
|
+
retryable: false,
|
|
235
|
+
tiktokStatusCode: result.statusCode,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
apiLog(
|
|
240
|
+
`[webserver] user OK | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | verified=${result.verified} followers=${result.followerCount}`,
|
|
241
|
+
);
|
|
242
|
+
recordRequest(true);
|
|
166
243
|
return sendJSON(res, 200, result);
|
|
167
244
|
}
|
|
168
245
|
|
|
@@ -171,13 +248,27 @@ async function handleTiktokVideo(req, res) {
|
|
|
171
248
|
if (!body.videoUrl) {
|
|
172
249
|
return sendJSON(res, 400, { error: "missing videoUrl" });
|
|
173
250
|
}
|
|
251
|
+
const clientIP = getClientIP(req);
|
|
252
|
+
const startTime = Date.now();
|
|
174
253
|
const scraper = await getScraper();
|
|
175
254
|
const result = await scraper.getVideoInfo(body.videoUrl);
|
|
255
|
+
const elapsed = Date.now() - startTime;
|
|
256
|
+
|
|
176
257
|
if (!result) {
|
|
177
|
-
|
|
178
|
-
|
|
258
|
+
console.error(
|
|
259
|
+
`[webserver] video FAIL | ${clientIP} | ${body.videoUrl.slice(0, 60)} | ${elapsed}ms | empty SSR / parse failed`,
|
|
260
|
+
);
|
|
261
|
+
recordRequest(false);
|
|
262
|
+
return sendJSON(res, 503, {
|
|
263
|
+
error: "Temporary failure: unable to fetch video data",
|
|
264
|
+
retryable: true,
|
|
179
265
|
});
|
|
180
266
|
}
|
|
267
|
+
|
|
268
|
+
apiLog(
|
|
269
|
+
`[webserver] video OK | ${clientIP} | @${result.author?.uniqueId || "?"} | ${elapsed}ms | plays=${result.stats?.playCount}`,
|
|
270
|
+
);
|
|
271
|
+
recordRequest(true);
|
|
181
272
|
return sendJSON(res, 200, result);
|
|
182
273
|
}
|
|
183
274
|
|
|
@@ -186,13 +277,27 @@ async function handleTiktokLookup(req, res) {
|
|
|
186
277
|
if (!body.videoUrl) {
|
|
187
278
|
return sendJSON(res, 400, { error: "missing videoUrl" });
|
|
188
279
|
}
|
|
280
|
+
const clientIP = getClientIP(req);
|
|
281
|
+
const startTime = Date.now();
|
|
189
282
|
const scraper = await getScraper();
|
|
190
283
|
const result = await scraper.getUserAndVideo(body.videoUrl);
|
|
284
|
+
const elapsed = Date.now() - startTime;
|
|
285
|
+
|
|
191
286
|
if (!result) {
|
|
192
|
-
|
|
193
|
-
|
|
287
|
+
console.error(
|
|
288
|
+
`[webserver] lookup FAIL | ${clientIP} | ${body.videoUrl.slice(0, 60)} | ${elapsed}ms | empty SSR / parse failed`,
|
|
289
|
+
);
|
|
290
|
+
recordRequest(false);
|
|
291
|
+
return sendJSON(res, 503, {
|
|
292
|
+
error: "Temporary failure: unable to fetch video/user data",
|
|
293
|
+
retryable: true,
|
|
194
294
|
});
|
|
195
295
|
}
|
|
296
|
+
|
|
297
|
+
apiLog(
|
|
298
|
+
`[webserver] lookup OK | ${clientIP} | @${result.user?.uniqueId || "?"} | ${elapsed}ms | videoId=${result.video?.id}`,
|
|
299
|
+
);
|
|
300
|
+
recordRequest(true);
|
|
196
301
|
return sendJSON(res, 200, result);
|
|
197
302
|
}
|
|
198
303
|
|
|
@@ -201,10 +306,7 @@ async function handleTiktokProduct(req, res) {
|
|
|
201
306
|
if (!body.url) {
|
|
202
307
|
return sendJSON(res, 400, { error: "missing url" });
|
|
203
308
|
}
|
|
204
|
-
const clientIP =
|
|
205
|
-
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
|
206
|
-
req.socket.remoteAddress ||
|
|
207
|
-
"";
|
|
309
|
+
const clientIP = getClientIP(req);
|
|
208
310
|
// URL 标准化:www.tiktok.com/shop/pdp → shop.tiktok.com/us/pdp
|
|
209
311
|
const normalizedUrl = body.url.replace(
|
|
210
312
|
"www.tiktok.com/shop/pdp",
|
|
@@ -218,21 +320,44 @@ async function handleTiktokProduct(req, res) {
|
|
|
218
320
|
captchaFallback: false,
|
|
219
321
|
},
|
|
220
322
|
);
|
|
221
|
-
|
|
323
|
+
apiLog(
|
|
222
324
|
`[webserver] product OK | ${clientIP} | rating=${result.rating ?? "-"} sold=${result.soldCount ?? "-"} name=${(result.productName || "").slice(0, 40)}`,
|
|
223
325
|
);
|
|
326
|
+
recordRequest(true);
|
|
224
327
|
return sendJSON(res, 200, result);
|
|
225
328
|
} catch (e) {
|
|
226
329
|
console.error(`[webserver] product FAIL | ${clientIP} | ${e.message}`);
|
|
330
|
+
recordRequest(false);
|
|
227
331
|
|
|
228
|
-
//
|
|
229
|
-
// 必须在 sendJSON 之前调用,否则 sendJSON 抛异常会导致验证码通知丢失
|
|
332
|
+
// captcha:触发上报+恢复(不阻塞其他请求),返回 503 让 nginx 可 failover
|
|
230
333
|
if (e.message?.includes("Blocked by TikTok")) {
|
|
231
334
|
scheduleCaptchaRecovery(normalizedUrl, SCRAPER_CONFIG.proxyServer);
|
|
335
|
+
return sendJSON(res, 503, {
|
|
336
|
+
error: "Blocked by TikTok security check (captcha)",
|
|
337
|
+
retryable: true,
|
|
338
|
+
});
|
|
232
339
|
}
|
|
233
340
|
|
|
234
|
-
|
|
235
|
-
|
|
341
|
+
// 网络超时类错误 → 504
|
|
342
|
+
if (
|
|
343
|
+
e.name === "HeadersTimeoutError" ||
|
|
344
|
+
e.name === "BodyTimeoutError" ||
|
|
345
|
+
e.message?.includes("timeout") ||
|
|
346
|
+
e.message?.includes("ETIMEDOUT") ||
|
|
347
|
+
e.message?.includes("ECONNREFUSED") ||
|
|
348
|
+
e.message?.includes("ECONNRESET")
|
|
349
|
+
) {
|
|
350
|
+
return sendJSON(res, 504, {
|
|
351
|
+
error: `Upstream timeout or network error: ${e.message}`,
|
|
352
|
+
retryable: true,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 其他上游错误 → 502
|
|
357
|
+
return sendJSON(res, 502, {
|
|
358
|
+
error: e.message,
|
|
359
|
+
retryable: true,
|
|
360
|
+
});
|
|
236
361
|
}
|
|
237
362
|
}
|
|
238
363
|
|
|
@@ -311,10 +436,16 @@ async function scheduleCaptchaRecovery(url, proxy) {
|
|
|
311
436
|
}
|
|
312
437
|
|
|
313
438
|
function handleHealth(req, res) {
|
|
314
|
-
|
|
315
|
-
|
|
439
|
+
const degraded = isDegraded();
|
|
440
|
+
const recentErrorRate = recentTotal > 0 ? recentErrors / recentTotal : 0;
|
|
441
|
+
return sendJSON(res, degraded ? 503 : 200, {
|
|
442
|
+
status: degraded ? "degraded" : "ok",
|
|
316
443
|
scraperReady: scraperInstance !== null && !scraperClosing,
|
|
444
|
+
scraperInitializing: scraperInitPromise !== null,
|
|
317
445
|
consecutiveErrors,
|
|
446
|
+
recentErrorRate: Math.round(recentErrorRate * 100) / 100,
|
|
447
|
+
recentTotal,
|
|
448
|
+
recentErrors,
|
|
318
449
|
lastWarmSec: lastWarmTime
|
|
319
450
|
? Math.round((Date.now() - lastWarmTime) / 1000)
|
|
320
451
|
: null,
|
|
@@ -347,6 +478,7 @@ export async function startWebserver(
|
|
|
347
478
|
poolSize = 0,
|
|
348
479
|
proxyServer = null,
|
|
349
480
|
captchaNotifyUrl = null,
|
|
481
|
+
enableLog = false,
|
|
350
482
|
) {
|
|
351
483
|
if (poolSize > 0) {
|
|
352
484
|
SCRAPER_CONFIG.poolSize = poolSize;
|
|
@@ -357,6 +489,7 @@ export async function startWebserver(
|
|
|
357
489
|
if (captchaNotifyUrl) {
|
|
358
490
|
SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
|
|
359
491
|
}
|
|
492
|
+
SCRAPER_CONFIG.enableLog = !!enableLog;
|
|
360
493
|
const server = http.createServer(async (req, res) => {
|
|
361
494
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
362
495
|
|
|
@@ -384,11 +517,48 @@ export async function startWebserver(
|
|
|
384
517
|
} catch (e) {
|
|
385
518
|
// 自动恢复:连续错误超过阈值时重启 scraper
|
|
386
519
|
consecutiveErrors++;
|
|
520
|
+
recordRequest(false);
|
|
521
|
+
|
|
522
|
+
// 区分错误类型,返回正确的 HTTP 状态码供 nginx 判断
|
|
523
|
+
let status = 500;
|
|
524
|
+
let retryable = false;
|
|
525
|
+
|
|
526
|
+
if (e.name === "CDNBlockedError") {
|
|
527
|
+
// CDN 限流(Akamai Access Denied)→ 503 临时不可用
|
|
528
|
+
status = 503;
|
|
529
|
+
retryable = true;
|
|
530
|
+
} else if (e.message?.includes("Server is shutting down")) {
|
|
531
|
+
// 服务正在关闭 → 503
|
|
532
|
+
status = 503;
|
|
533
|
+
retryable = true;
|
|
534
|
+
} else if (
|
|
535
|
+
e.message?.includes("Timeout") ||
|
|
536
|
+
e.message?.includes("timeout") ||
|
|
537
|
+
e.name === "TimeoutError"
|
|
538
|
+
) {
|
|
539
|
+
// 超时 → 504
|
|
540
|
+
status = 504;
|
|
541
|
+
retryable = true;
|
|
542
|
+
} else if (
|
|
543
|
+
e.message?.includes("Protocol error") ||
|
|
544
|
+
e.message?.includes("Target closed") ||
|
|
545
|
+
e.message?.includes("Browser closed") ||
|
|
546
|
+
e.message?.includes("Session closed")
|
|
547
|
+
) {
|
|
548
|
+
// 浏览器/页面崩溃 → 503
|
|
549
|
+
status = 503;
|
|
550
|
+
retryable = true;
|
|
551
|
+
}
|
|
552
|
+
|
|
387
553
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
388
554
|
await restartScraper(`连续 ${consecutiveErrors} 次错误`);
|
|
389
555
|
}
|
|
556
|
+
|
|
390
557
|
if (!res.headersSent) {
|
|
391
|
-
return sendJSON(res,
|
|
558
|
+
return sendJSON(res, status, {
|
|
559
|
+
error: e.message,
|
|
560
|
+
retryable,
|
|
561
|
+
});
|
|
392
562
|
}
|
|
393
563
|
// 响应已发送(流式等场景),记录日志并销毁 socket
|
|
394
564
|
console.error(
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 节点健康检测模块
|
|
3
|
-
*
|
|
4
|
-
* 职责:
|
|
5
|
-
* 1. 检测 Clash Verge 进程是否存活
|
|
6
|
-
* 2. 检测当前节点对目标 URL 是否健康
|
|
7
|
-
* 3. 故障切换:从健康节点列表中随机选取并切换
|
|
8
|
-
* 4. 健康节点列表持久化
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { spawnSync } from "child_process";
|
|
12
|
-
import * as api from "./api.js";
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
import { tmpdir } from "os";
|
|
16
|
-
|
|
17
|
-
const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
18
|
-
|
|
19
|
-
// ====== 可配置参数 ======
|
|
20
|
-
export const CONFIG = {
|
|
21
|
-
testUrl: "https://www.tiktok.com/explore",
|
|
22
|
-
proxyUrl: "http://127.0.0.1:7897",
|
|
23
|
-
apiPort: 9097,
|
|
24
|
-
groupName: "GLOBAL",
|
|
25
|
-
curlTimeout: 12,
|
|
26
|
-
minPageSize: 50 * 1024,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// ====== 进程检测(跨平台 mac/win) ======
|
|
30
|
-
|
|
31
|
-
const IS_WIN = process.platform === "win32";
|
|
32
|
-
|
|
33
|
-
export function checkClashAlive() {
|
|
34
|
-
try {
|
|
35
|
-
if (IS_WIN) {
|
|
36
|
-
const result = spawnSync(
|
|
37
|
-
"tasklist",
|
|
38
|
-
["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
39
|
-
{
|
|
40
|
-
encoding: "utf-8",
|
|
41
|
-
timeout: 5000,
|
|
42
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
-
},
|
|
44
|
-
);
|
|
45
|
-
if (result.stdout && result.stdout.includes("verge-mihomo.exe"))
|
|
46
|
-
return true;
|
|
47
|
-
} else {
|
|
48
|
-
const result = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
49
|
-
encoding: "utf-8",
|
|
50
|
-
timeout: 5000,
|
|
51
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
-
});
|
|
53
|
-
if (result.status === 0 && result.stdout.trim()) return true;
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
/* fallthrough */
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const curlExe = IS_WIN ? "curl.exe" : "curl";
|
|
61
|
-
const result = spawnSync(
|
|
62
|
-
curlExe,
|
|
63
|
-
["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
64
|
-
{
|
|
65
|
-
encoding: "utf-8",
|
|
66
|
-
timeout: 5000,
|
|
67
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
-
},
|
|
69
|
-
);
|
|
70
|
-
return result.stdout.includes('"version"');
|
|
71
|
-
} catch {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ====== 节点健康检测 ======
|
|
77
|
-
|
|
78
|
-
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
79
|
-
const args = [
|
|
80
|
-
"-s",
|
|
81
|
-
"-w",
|
|
82
|
-
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
83
|
-
"--proxy",
|
|
84
|
-
CONFIG.proxyUrl,
|
|
85
|
-
"--max-time",
|
|
86
|
-
String(CONFIG.curlTimeout),
|
|
87
|
-
"-L",
|
|
88
|
-
"-A",
|
|
89
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
90
|
-
url,
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
const result = spawnSync("curl", args, {
|
|
94
|
-
encoding: "utf-8",
|
|
95
|
-
timeout: (CONFIG.curlTimeout + 5) * 1000,
|
|
96
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const stdout = result.stdout || "";
|
|
101
|
-
const sentinel = "__CURL_META__";
|
|
102
|
-
const idx = stdout.lastIndexOf(sentinel);
|
|
103
|
-
|
|
104
|
-
let body = "",
|
|
105
|
-
metaLine = "";
|
|
106
|
-
if (idx >= 0) {
|
|
107
|
-
body = stdout.substring(0, idx).trimEnd();
|
|
108
|
-
metaLine = stdout.substring(idx + sentinel.length).trim();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const parts = metaLine.split("|");
|
|
112
|
-
const httpCode = parseInt(parts[0]) || 0;
|
|
113
|
-
const timeTotal = parseFloat(parts[1]) || 0;
|
|
114
|
-
const sizeDownload = parseInt(parts[2]) || 0;
|
|
115
|
-
|
|
116
|
-
let title = "";
|
|
117
|
-
const titleMatch = body.match(/<title>([^<]*)<\/title>/i);
|
|
118
|
-
if (titleMatch) title = titleMatch[1].trim();
|
|
119
|
-
|
|
120
|
-
let error = null;
|
|
121
|
-
if (result.error) {
|
|
122
|
-
error = result.error.message || "spawn error";
|
|
123
|
-
} else if (result.status !== 0) {
|
|
124
|
-
error = `curl exit ${result.status}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const healthy =
|
|
128
|
-
error === null &&
|
|
129
|
-
httpCode === 200 &&
|
|
130
|
-
sizeDownload >= CONFIG.minPageSize &&
|
|
131
|
-
title.includes("TikTok") &&
|
|
132
|
-
!title.startsWith("About |") &&
|
|
133
|
-
!body.includes("SlardarWAF") &&
|
|
134
|
-
!body.includes("_wafchallengeid");
|
|
135
|
-
|
|
136
|
-
return { healthy, httpCode, timeTotal, sizeDownload, title, error };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ====== 健康节点列表持久化 ======
|
|
140
|
-
|
|
141
|
-
export function loadHealthyList() {
|
|
142
|
-
try {
|
|
143
|
-
if (existsSync(HEALTHY_FILE)) {
|
|
144
|
-
const data = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
|
|
145
|
-
return {
|
|
146
|
-
updated: data.updated || "",
|
|
147
|
-
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
} catch (e) {
|
|
151
|
-
console.error(`[health] 读取健康列表失败: ${e.message}`);
|
|
152
|
-
}
|
|
153
|
-
return { updated: "", nodes: [] };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function saveHealthyList(nodes) {
|
|
157
|
-
try {
|
|
158
|
-
const data = { updated: new Date().toISOString(), nodes };
|
|
159
|
-
writeFileSync(HEALTHY_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
160
|
-
} catch (e) {
|
|
161
|
-
console.error(`[health] 保存健康列表失败: ${e.message}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ====== 故障切换 ======
|
|
166
|
-
|
|
167
|
-
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
168
|
-
if (!healthyNodes || healthyNodes.length === 0) return null;
|
|
169
|
-
const candidates = healthyNodes.filter((n) => n !== currentNode);
|
|
170
|
-
if (candidates.length === 0) {
|
|
171
|
-
console.error(
|
|
172
|
-
`[health] 健康列表中无可用候选节点(共${healthyNodes.length}个),保持在当前节点`,
|
|
173
|
-
);
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
const idx = Math.floor(Math.random() * candidates.length);
|
|
177
|
-
return candidates[idx];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function switchToNode(nodeName) {
|
|
181
|
-
try {
|
|
182
|
-
await api.switchProxy(CONFIG.groupName, nodeName);
|
|
183
|
-
await new Promise((r) => setTimeout(r, 600));
|
|
184
|
-
const current = await api.getCurrentNode(CONFIG.groupName);
|
|
185
|
-
if (current !== nodeName) {
|
|
186
|
-
console.error(`[health] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
return true;
|
|
190
|
-
} catch (e) {
|
|
191
|
-
console.error(`[health] 切换节点失败: ${e.message}`);
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ====== 便捷入口 ======
|
|
197
|
-
|
|
198
|
-
export async function runHealthCheck() {
|
|
199
|
-
if (!checkClashAlive()) {
|
|
200
|
-
return { action: "skipped", detail: "Clash Verge 未运行" };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const health = checkNodeHealth();
|
|
204
|
-
if (health.healthy) {
|
|
205
|
-
return {
|
|
206
|
-
action: "ok",
|
|
207
|
-
detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB`,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const reason = health.error
|
|
212
|
-
? `错误: ${health.error}`
|
|
213
|
-
: `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
|
|
214
|
-
|
|
215
|
-
let currentNode = "";
|
|
216
|
-
try {
|
|
217
|
-
currentNode = await api.getCurrentNode(CONFIG.groupName);
|
|
218
|
-
} catch (e) {
|
|
219
|
-
return { action: "failed", detail: `无法获取当前节点: ${e.message}` };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const { nodes: healthyNodes } = loadHealthyList();
|
|
223
|
-
if (healthyNodes.length === 0) {
|
|
224
|
-
return { action: "failed", detail: `${reason} — 健康列表为空,无法切换` };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const target = pickFailoverNode(currentNode, healthyNodes);
|
|
228
|
-
if (!target) {
|
|
229
|
-
return { action: "failed", detail: `${reason} — 无可用候选节点` };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const switched = await switchToNode(target);
|
|
233
|
-
if (!switched) {
|
|
234
|
-
return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const newHealth = checkNodeHealth();
|
|
238
|
-
if (newHealth.healthy) {
|
|
239
|
-
return {
|
|
240
|
-
action: "switched",
|
|
241
|
-
detail: `${currentNode} → ${target} (${newHealth.timeTotal.toFixed(1)}s ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
return {
|
|
245
|
-
action: "switched",
|
|
246
|
-
detail: `${currentNode} → ${target} (切换后仍异常: ${newHealth.httpCode} ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
247
|
-
};
|
|
248
|
-
}
|