tt-help-cli-ycl 1.4.80 → 1.4.83
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 +31 -0
- package/src/clash/benchmark/lib/clash-api.js +234 -0
- package/src/clash/benchmark/speedtest-result.json +431 -0
- package/src/clash/benchmark/speedtest-tiktok.mjs +384 -0
- package/src/clash/checker.js +178 -41
- package/src/clash/daemon.js +62 -94
- package/src/cli/clash-daemon.js +1 -1
- package/src/cli/watchdog.js +8 -1
- package/src/lib/args.js +6 -2
- package/src/lib/constants.js +4 -3
- package/src/watchdog/agent.js +52 -13
package/src/clash/checker.js
CHANGED
|
@@ -62,12 +62,16 @@ let _proxyResolved = false;
|
|
|
62
62
|
|
|
63
63
|
export async function resolveAndSetProxyUrl() {
|
|
64
64
|
if (_proxyResolved) return CONFIG.proxyUrl;
|
|
65
|
-
try {
|
|
65
|
+
try {
|
|
66
|
+
CONFIG.proxyUrl = await api.resolveProxyUrl();
|
|
67
|
+
} catch {}
|
|
66
68
|
_proxyResolved = true;
|
|
67
69
|
return CONFIG.proxyUrl;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
export function resetProxyUrlCache() {
|
|
72
|
+
export function resetProxyUrlCache() {
|
|
73
|
+
_proxyResolved = false;
|
|
74
|
+
}
|
|
71
75
|
|
|
72
76
|
// ══════════════════════════════════════════════
|
|
73
77
|
// 2. 进程 / API 可达性检测
|
|
@@ -78,8 +82,10 @@ export function checkClashAlive() {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
export function checkClashAliveWithReason() {
|
|
81
|
-
if (_detectProcess())
|
|
82
|
-
|
|
85
|
+
if (_detectProcess())
|
|
86
|
+
return { alive: true, reason: "verge-mihomo 进程已运行" };
|
|
87
|
+
if (_detectApi())
|
|
88
|
+
return { alive: true, reason: `API 端口 ${CONFIG.apiPort} 可达` };
|
|
83
89
|
return {
|
|
84
90
|
alive: false,
|
|
85
91
|
reason: `Clash Verge 未运行,或 API 端口 ${CONFIG.apiPort} 未开启(请在 Clash Verge 设置中开启"允许局域网连接"或"RESTful API")`,
|
|
@@ -89,24 +95,36 @@ export function checkClashAliveWithReason() {
|
|
|
89
95
|
function _detectProcess() {
|
|
90
96
|
try {
|
|
91
97
|
if (IS_WIN) {
|
|
92
|
-
const r = spawnSync(
|
|
93
|
-
|
|
98
|
+
const r = spawnSync(
|
|
99
|
+
"tasklist",
|
|
100
|
+
["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
101
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
102
|
+
);
|
|
94
103
|
return r.stdout?.includes("verge-mihomo.exe");
|
|
95
104
|
}
|
|
96
|
-
const r = spawnSync("pgrep", ["-x", "verge-mihomo"],
|
|
97
|
-
|
|
105
|
+
const r = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
106
|
+
encoding: "utf-8",
|
|
107
|
+
timeout: 5000,
|
|
108
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
109
|
+
});
|
|
98
110
|
return r.status === 0 && !!r.stdout.trim();
|
|
99
|
-
} catch {
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
function _detectApi() {
|
|
103
117
|
try {
|
|
104
118
|
const exe = IS_WIN ? "curl.exe" : "curl";
|
|
105
|
-
const r = spawnSync(
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
const r = spawnSync(
|
|
120
|
+
exe,
|
|
121
|
+
["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
122
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
123
|
+
);
|
|
108
124
|
return r.stdout.includes('"version"');
|
|
109
|
-
} catch {
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
110
128
|
}
|
|
111
129
|
|
|
112
130
|
export async function requireClashOrExit(label = "") {
|
|
@@ -116,8 +134,12 @@ export async function requireClashOrExit(label = "") {
|
|
|
116
134
|
console.error(`\n${pf}⚠ 代理不可用:${reason}`);
|
|
117
135
|
console.error(`${pf}请先启动 Clash Verge 并确保:`);
|
|
118
136
|
console.error(` 1. Clash Verge 正在运行(verge-mihomo 进程存活)`);
|
|
119
|
-
console.error(
|
|
120
|
-
|
|
137
|
+
console.error(
|
|
138
|
+
` 2. API 端口 ${CONFIG.apiPort} 已开启(设置 → 允许局域网连接 / RESTful API)`,
|
|
139
|
+
);
|
|
140
|
+
console.error(
|
|
141
|
+
` 3. 代理端口 ${CONFIG.proxyUrl.replace("http://", "")} 正在监听\n`,
|
|
142
|
+
);
|
|
121
143
|
process.exit(1);
|
|
122
144
|
}
|
|
123
145
|
return resolveAndSetProxyUrl();
|
|
@@ -133,16 +155,27 @@ export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
|
133
155
|
const title = _extractTitle(body);
|
|
134
156
|
return {
|
|
135
157
|
healthy: _isHealthyPage(error, httpCode, sizeDownload, title, body),
|
|
136
|
-
httpCode,
|
|
158
|
+
httpCode,
|
|
159
|
+
timeTotal,
|
|
160
|
+
sizeDownload,
|
|
161
|
+
title,
|
|
162
|
+
error,
|
|
137
163
|
};
|
|
138
164
|
}
|
|
139
165
|
|
|
140
166
|
function _curlViaProxy(url) {
|
|
141
167
|
const args = [
|
|
142
|
-
"-s",
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
168
|
+
"-s",
|
|
169
|
+
"-w",
|
|
170
|
+
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
171
|
+
"--proxy",
|
|
172
|
+
CONFIG.proxyUrl,
|
|
173
|
+
"--max-time",
|
|
174
|
+
String(CONFIG.curlTimeout),
|
|
175
|
+
"-L",
|
|
176
|
+
"-A",
|
|
177
|
+
CURL_UA,
|
|
178
|
+
url,
|
|
146
179
|
];
|
|
147
180
|
const r = spawnSync("curl", args, {
|
|
148
181
|
encoding: "utf-8",
|
|
@@ -153,15 +186,23 @@ function _curlViaProxy(url) {
|
|
|
153
186
|
const out = r.stdout || "";
|
|
154
187
|
const idx = out.lastIndexOf("__CURL_META__");
|
|
155
188
|
return {
|
|
156
|
-
body:
|
|
189
|
+
body: idx >= 0 ? out.substring(0, idx).trimEnd() : "",
|
|
157
190
|
metaLine: idx >= 0 ? out.substring(idx + 13).trim() : "",
|
|
158
|
-
error:
|
|
191
|
+
error: r.error
|
|
192
|
+
? r.error.message
|
|
193
|
+
: r.status !== 0
|
|
194
|
+
? `curl exit ${r.status}`
|
|
195
|
+
: null,
|
|
159
196
|
};
|
|
160
197
|
}
|
|
161
198
|
|
|
162
199
|
function _parseCurlMeta(line) {
|
|
163
200
|
const p = line.split("|");
|
|
164
|
-
return {
|
|
201
|
+
return {
|
|
202
|
+
httpCode: parseInt(p[0]) || 0,
|
|
203
|
+
timeTotal: parseFloat(p[1]) || 0,
|
|
204
|
+
sizeDownload: parseInt(p[2]) || 0,
|
|
205
|
+
};
|
|
165
206
|
}
|
|
166
207
|
|
|
167
208
|
function _extractTitle(body) {
|
|
@@ -170,9 +211,15 @@ function _extractTitle(body) {
|
|
|
170
211
|
}
|
|
171
212
|
|
|
172
213
|
function _isHealthyPage(error, httpCode, size, title, body) {
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
214
|
+
return (
|
|
215
|
+
!error &&
|
|
216
|
+
httpCode === 200 &&
|
|
217
|
+
size >= CONFIG.minPageSize &&
|
|
218
|
+
title.includes("TikTok") &&
|
|
219
|
+
!title.startsWith("About |") &&
|
|
220
|
+
!body.includes("SlardarWAF") &&
|
|
221
|
+
!body.includes("_wafchallengeid")
|
|
222
|
+
);
|
|
176
223
|
}
|
|
177
224
|
|
|
178
225
|
// ══════════════════════════════════════════════
|
|
@@ -180,8 +227,9 @@ function _isHealthyPage(error, httpCode, size, title, body) {
|
|
|
180
227
|
// ══════════════════════════════════════════════
|
|
181
228
|
|
|
182
229
|
export function filterNodesByCountry(nodes, country) {
|
|
183
|
-
if (!country || !COUNTRY_PATTERNS[country])
|
|
184
|
-
|
|
230
|
+
if (!country || !COUNTRY_PATTERNS[country])
|
|
231
|
+
return { filtered: nodes, excluded: 0 };
|
|
232
|
+
const filtered = nodes.filter((n) => COUNTRY_PATTERNS[country].test(n));
|
|
185
233
|
return { filtered, excluded: nodes.length - filtered.length };
|
|
186
234
|
}
|
|
187
235
|
|
|
@@ -189,6 +237,69 @@ export function getSupportedCountries() {
|
|
|
189
237
|
return Object.keys(COUNTRY_PATTERNS);
|
|
190
238
|
}
|
|
191
239
|
|
|
240
|
+
// ══════════════════════════════════════════════
|
|
241
|
+
// 4.5 delay 初测(零切换,生成候选集)
|
|
242
|
+
// ══════════════════════════════════════════════
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 对 GLOBAL 组节点做 delay 初测(不切换当前节点)
|
|
246
|
+
* 并发测延迟,筛出可连通且延迟达标的节点,按延迟升序返回,作为故障切换的候选集
|
|
247
|
+
* @param {object} opts
|
|
248
|
+
* @param {string} [opts.country=null] - 国家过滤(默认 CONFIG.nodeCountryFilter)
|
|
249
|
+
* @param {number} [opts.timeoutMs=5000] - 单节点超时(毫秒)
|
|
250
|
+
* @param {number} [opts.thresholdMs=4000] - 延迟阈值(超过视为慢,不入选,毫秒)
|
|
251
|
+
* @param {number} [opts.concurrency=10] - 并发数
|
|
252
|
+
* @returns {Promise<{nodes:string[], total:number, results:Array}>}
|
|
253
|
+
*/
|
|
254
|
+
export async function runDelayProbe({
|
|
255
|
+
country = null,
|
|
256
|
+
timeoutMs = 5000,
|
|
257
|
+
thresholdMs = 4000,
|
|
258
|
+
concurrency = 10,
|
|
259
|
+
} = {}) {
|
|
260
|
+
const filter = country || CONFIG.nodeCountryFilter;
|
|
261
|
+
let all = [];
|
|
262
|
+
try {
|
|
263
|
+
all = await api.listGroupNodes(CONFIG.groupName);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.error(`[checker] 获取 GLOBAL 节点列表失败: ${e.message}`);
|
|
266
|
+
return { nodes: [], total: 0, results: [] };
|
|
267
|
+
}
|
|
268
|
+
const { filtered, excluded } = filterNodesByCountry(all, filter);
|
|
269
|
+
const total = all.length;
|
|
270
|
+
|
|
271
|
+
const results = [];
|
|
272
|
+
const queue = [...filtered];
|
|
273
|
+
async function worker() {
|
|
274
|
+
while (queue.length) {
|
|
275
|
+
const name = queue.shift();
|
|
276
|
+
const r = await api.getProxyDelay(name, CONFIG.testUrl, timeoutMs);
|
|
277
|
+
results.push({ node: name, delay: r.delay, timeout: r.timeout });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const workers = Array.from(
|
|
281
|
+
{ length: Math.min(concurrency, Math.max(1, filtered.length)) },
|
|
282
|
+
() => worker(),
|
|
283
|
+
);
|
|
284
|
+
await Promise.all(workers);
|
|
285
|
+
|
|
286
|
+
const good = results
|
|
287
|
+
.filter((r) => !r.timeout && r.delay > 0 && r.delay <= thresholdMs)
|
|
288
|
+
.sort((a, b) => a.delay - b.delay);
|
|
289
|
+
|
|
290
|
+
const tag = filter
|
|
291
|
+
? ` (${filter}节点 ${filtered.length}/${total}, 排除 ${excluded})`
|
|
292
|
+
: ` (${total} 个节点)`;
|
|
293
|
+
console.error(
|
|
294
|
+
`[checker] delay 初测完成: ${good.length}/${filtered.length} 个可达${tag}`,
|
|
295
|
+
);
|
|
296
|
+
return {
|
|
297
|
+
nodes: good.map((r) => r.node),
|
|
298
|
+
total: filtered.length,
|
|
299
|
+
results: good,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
192
303
|
// ══════════════════════════════════════════════
|
|
193
304
|
// 5. 健康节点列表持久化
|
|
194
305
|
// ══════════════════════════════════════════════
|
|
@@ -200,7 +311,13 @@ export function loadHealthyList(country = null) {
|
|
|
200
311
|
const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
|
|
201
312
|
const filter = country || CONFIG.nodeCountryFilter;
|
|
202
313
|
const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
|
|
203
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
updated: d.updated || "",
|
|
316
|
+
nodes: filtered,
|
|
317
|
+
totalNodes: allNodes.length,
|
|
318
|
+
excluded,
|
|
319
|
+
countryFilter: filter || null,
|
|
320
|
+
};
|
|
204
321
|
} catch (e) {
|
|
205
322
|
console.error(`[checker] 读取健康列表失败: ${e.message}`);
|
|
206
323
|
}
|
|
@@ -209,15 +326,24 @@ export function loadHealthyList(country = null) {
|
|
|
209
326
|
|
|
210
327
|
export function saveHealthyList(nodes) {
|
|
211
328
|
try {
|
|
212
|
-
writeFileSync(
|
|
213
|
-
|
|
329
|
+
writeFileSync(
|
|
330
|
+
HEALTHY_FILE,
|
|
331
|
+
JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2),
|
|
332
|
+
"utf-8",
|
|
333
|
+
);
|
|
214
334
|
} catch (e) {
|
|
215
335
|
console.error(`[checker] 保存健康列表失败: ${e.message}`);
|
|
216
336
|
}
|
|
217
337
|
}
|
|
218
338
|
|
|
219
339
|
function _emptyList() {
|
|
220
|
-
return {
|
|
340
|
+
return {
|
|
341
|
+
updated: "",
|
|
342
|
+
nodes: [],
|
|
343
|
+
totalNodes: 0,
|
|
344
|
+
excluded: 0,
|
|
345
|
+
countryFilter: null,
|
|
346
|
+
};
|
|
221
347
|
}
|
|
222
348
|
|
|
223
349
|
// ══════════════════════════════════════════════
|
|
@@ -226,7 +352,7 @@ function _emptyList() {
|
|
|
226
352
|
|
|
227
353
|
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
228
354
|
if (!healthyNodes?.length) return null;
|
|
229
|
-
const candidates = healthyNodes.filter(n => n !== currentNode);
|
|
355
|
+
const candidates = healthyNodes.filter((n) => n !== currentNode);
|
|
230
356
|
if (!candidates.length) {
|
|
231
357
|
console.error(`[checker] 无可用候选节点(共${healthyNodes.length}个)`);
|
|
232
358
|
return null;
|
|
@@ -237,10 +363,12 @@ export function pickFailoverNode(currentNode, healthyNodes) {
|
|
|
237
363
|
export async function switchToNode(nodeName) {
|
|
238
364
|
try {
|
|
239
365
|
await api.switchProxy(CONFIG.groupName, nodeName);
|
|
240
|
-
await new Promise(r => setTimeout(r, 600));
|
|
366
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
241
367
|
const current = await api.getCurrentNode(CONFIG.groupName);
|
|
242
368
|
if (current !== nodeName) {
|
|
243
|
-
console.error(
|
|
369
|
+
console.error(
|
|
370
|
+
`[checker] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`,
|
|
371
|
+
);
|
|
244
372
|
return false;
|
|
245
373
|
}
|
|
246
374
|
return true;
|
|
@@ -255,12 +383,16 @@ export async function switchToNode(nodeName) {
|
|
|
255
383
|
// ══════════════════════════════════════════════
|
|
256
384
|
|
|
257
385
|
export async function runHealthCheck() {
|
|
258
|
-
if (!checkClashAlive())
|
|
386
|
+
if (!checkClashAlive())
|
|
387
|
+
return { action: "skipped", detail: "Clash Verge 未运行" };
|
|
259
388
|
await resolveAndSetProxyUrl();
|
|
260
389
|
|
|
261
390
|
const health = checkNodeHealth();
|
|
262
391
|
if (health.healthy) {
|
|
263
|
-
return {
|
|
392
|
+
return {
|
|
393
|
+
action: "ok",
|
|
394
|
+
detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB`,
|
|
395
|
+
};
|
|
264
396
|
}
|
|
265
397
|
|
|
266
398
|
const reason = health.error
|
|
@@ -268,14 +400,19 @@ export async function runHealthCheck() {
|
|
|
268
400
|
: `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
|
|
269
401
|
|
|
270
402
|
let currentNode;
|
|
271
|
-
try {
|
|
272
|
-
|
|
403
|
+
try {
|
|
404
|
+
currentNode = await api.getCurrentNode(CONFIG.groupName);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
return { action: "failed", detail: `无法获取当前节点: ${e.message}` };
|
|
407
|
+
}
|
|
273
408
|
|
|
274
409
|
const { nodes } = loadHealthyList();
|
|
275
|
-
if (!nodes.length)
|
|
410
|
+
if (!nodes.length)
|
|
411
|
+
return { action: "failed", detail: `${reason} — 健康列表为空` };
|
|
276
412
|
|
|
277
413
|
const target = pickFailoverNode(currentNode, nodes);
|
|
278
|
-
if (!target)
|
|
414
|
+
if (!target)
|
|
415
|
+
return { action: "failed", detail: `${reason} — 无可用候选节点` };
|
|
279
416
|
|
|
280
417
|
if (!(await switchToNode(target))) {
|
|
281
418
|
return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
|
package/src/clash/daemon.js
CHANGED
|
@@ -1,30 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ClashDaemon — 代理节点健康守护
|
|
3
3
|
*
|
|
4
|
-
* 职责: 定时巡检 → 异常切换 →
|
|
4
|
+
* 职责: 定时巡检 → 异常切换 → delay 初测生成候选集 → IPC 上报
|
|
5
5
|
* 看门狗通过 /tmp/clash-cmd.json 下发命令,每 10s 轮询
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { spawn } from "child_process";
|
|
9
|
-
import { join, dirname } from "path";
|
|
10
8
|
import { fileURLToPath } from "url";
|
|
11
|
-
import { readFileSync, existsSync } from "fs";
|
|
12
9
|
import * as api from "./api.js";
|
|
13
10
|
import * as checker from "./checker.js";
|
|
14
11
|
import * as ipc from "./ipc.js";
|
|
15
12
|
|
|
16
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
-
|
|
18
13
|
// ══════════════════════════════════════════════
|
|
19
14
|
// ClashDaemon
|
|
20
15
|
// ══════════════════════════════════════════════
|
|
21
16
|
|
|
22
17
|
export class ClashDaemon {
|
|
23
18
|
constructor(opts = {}) {
|
|
24
|
-
this.intervalMs = (opts.intervalSec ||
|
|
19
|
+
this.intervalMs = (opts.intervalSec || 120) * 1000;
|
|
25
20
|
this.benchmarkMs = (opts.benchmarkMin || 40) * 60 * 1000;
|
|
26
21
|
this.dryRun = !!opts.dryRun;
|
|
27
|
-
this.benchmarkScript = _findBenchmark();
|
|
28
22
|
this._running = false;
|
|
29
23
|
this._timers = [];
|
|
30
24
|
this._benchmarkRunning = false;
|
|
@@ -68,7 +62,7 @@ export class ClashDaemon {
|
|
|
68
62
|
: Infinity;
|
|
69
63
|
const isStale = !list.nodes.length || ageMin >= this.benchmarkMs / 60000;
|
|
70
64
|
this._log(
|
|
71
|
-
`${list.nodes.length ? `已加载 ${list.nodes.length}
|
|
65
|
+
`${list.nodes.length ? `已加载 ${list.nodes.length} 个候选节点${isStale ? ` (已过期 ${ageMin}min,立即刷新)` : ""}` : "候选集为空,将立即执行 delay 初测"}`,
|
|
72
66
|
);
|
|
73
67
|
|
|
74
68
|
await this._healthCheck();
|
|
@@ -76,9 +70,9 @@ export class ClashDaemon {
|
|
|
76
70
|
|
|
77
71
|
const initial = isStale ? 5000 : this.benchmarkMs;
|
|
78
72
|
setTimeout(() => {
|
|
79
|
-
this.
|
|
73
|
+
this._runDelayProbe();
|
|
80
74
|
this._timers.push(
|
|
81
|
-
setInterval(() => this.
|
|
75
|
+
setInterval(() => this._runDelayProbe(), this.benchmarkMs),
|
|
82
76
|
);
|
|
83
77
|
}, initial);
|
|
84
78
|
|
|
@@ -132,23 +126,48 @@ export class ClashDaemon {
|
|
|
132
126
|
const reason =
|
|
133
127
|
h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
|
|
134
128
|
const { nodes } = checker.loadHealthyList();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.
|
|
129
|
+
if (!nodes.length) {
|
|
130
|
+
this._log(`✗ ${reason} — 候选集为空,触发 delay 初测`);
|
|
131
|
+
await this._runDelayProbe();
|
|
138
132
|
return;
|
|
139
133
|
}
|
|
140
134
|
if (this.dryRun) {
|
|
141
|
-
this._log(`[DRY-RUN]
|
|
135
|
+
this._log(`[DRY-RUN] 当前节点异常,候选: ${nodes.length} 个`);
|
|
142
136
|
return;
|
|
143
137
|
}
|
|
144
138
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
// 逐个切换候选 + 内容验证,第一个满足即采用
|
|
140
|
+
let target = null;
|
|
141
|
+
let h2 = null;
|
|
142
|
+
for (const cand of nodes) {
|
|
143
|
+
if (cand === node) continue;
|
|
144
|
+
this._log(`↺ 尝试候选: ${cand}`);
|
|
145
|
+
if (!(await checker.switchToNode(cand))) {
|
|
146
|
+
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
h2 = checker.checkNodeHealth();
|
|
150
|
+
if (h2.healthy) {
|
|
151
|
+
target = cand;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
this._log(` ↺ ${cand} 内容验证未通过,下一个`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!target) {
|
|
158
|
+
// 全部候选不可用,恢复原节点
|
|
159
|
+
await checker.switchToNode(node).catch(() => {});
|
|
160
|
+
this._log(`✗ ${reason} — 候选全部不可用,恢复原节点`);
|
|
161
|
+
ipc.writeHealth({
|
|
162
|
+
node,
|
|
163
|
+
healthy: false,
|
|
164
|
+
error: `${reason} — 候选全部不可用`,
|
|
165
|
+
lastSwitch: "failover: 候选全部不可用",
|
|
166
|
+
meta: this._buildMeta(),
|
|
167
|
+
});
|
|
148
168
|
return;
|
|
149
169
|
}
|
|
150
170
|
|
|
151
|
-
const h2 = checker.checkNodeHealth();
|
|
152
171
|
this._log(
|
|
153
172
|
`⚠ ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
154
173
|
);
|
|
@@ -173,65 +192,34 @@ export class ClashDaemon {
|
|
|
173
192
|
}
|
|
174
193
|
}
|
|
175
194
|
|
|
176
|
-
// ──
|
|
195
|
+
// ── 候选集 delay 初测(零切换) ──
|
|
177
196
|
|
|
178
|
-
|
|
179
|
-
if (
|
|
197
|
+
async _runDelayProbe() {
|
|
198
|
+
if (this._benchmarkRunning) return;
|
|
180
199
|
this._benchmarkRunning = true;
|
|
181
|
-
this._log("===
|
|
200
|
+
this._log("=== 节点 delay 初测开始(零切换) ===");
|
|
182
201
|
const start = Date.now();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
let stderr = "";
|
|
190
|
-
child.stderr.on("data", (d) => {
|
|
191
|
-
stderr += d.toString();
|
|
192
|
-
});
|
|
193
|
-
child.on("close", (code) => {
|
|
194
|
-
this._benchmarkRunning = false;
|
|
202
|
+
try {
|
|
203
|
+
const r = await checker.runDelayProbe({
|
|
204
|
+
country: checker.CONFIG.nodeCountryFilter,
|
|
205
|
+
});
|
|
195
206
|
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const rp = join(dirname(this.benchmarkScript), "speedtest-result.json");
|
|
202
|
-
if (!existsSync(rp)) {
|
|
203
|
-
this._log("=== 基准测速完成但未找到结果文件 ===");
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
const allNodes = (
|
|
207
|
-
JSON.parse(readFileSync(rp, "utf-8")).realNodes || []
|
|
208
|
-
).map((r) => r.node);
|
|
209
|
-
if (!allNodes.length) {
|
|
210
|
-
this._log("=== 基准测速完成: 0 个健康节点 ===");
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
checker.saveHealthyList(allNodes);
|
|
214
|
-
const { filtered, excluded } = checker.filterNodesByCountry(
|
|
215
|
-
allNodes,
|
|
216
|
-
checker.CONFIG.nodeCountryFilter,
|
|
217
|
-
);
|
|
218
|
-
this._meta.lastBenchmark = new Date().toISOString();
|
|
219
|
-
this._meta.healthyNodesTotal = allNodes.length;
|
|
220
|
-
this._meta.healthyNodesCount = filtered.length;
|
|
221
|
-
const tag = checker.CONFIG.nodeCountryFilter
|
|
222
|
-
? ` (${checker.CONFIG.nodeCountryFilter}节点 ${filtered.length}个, 排除 ${excluded}个)`
|
|
223
|
-
: "";
|
|
207
|
+
checker.saveHealthyList(r.nodes);
|
|
208
|
+
this._meta.lastBenchmark = new Date().toISOString();
|
|
209
|
+
this._meta.healthyNodesTotal = r.total;
|
|
210
|
+
this._meta.healthyNodesCount = r.nodes.length;
|
|
211
|
+
if (r.nodes.length) {
|
|
224
212
|
this._log(
|
|
225
|
-
`===
|
|
213
|
+
`=== delay 初测完成 (${elapsed}s): ${r.nodes.length} 个可达节点 ===`,
|
|
226
214
|
);
|
|
227
|
-
}
|
|
228
|
-
this._log(
|
|
215
|
+
} else {
|
|
216
|
+
this._log(`=== delay 初测完成 (${elapsed}s): 0 个可达节点 ===`);
|
|
229
217
|
}
|
|
230
|
-
})
|
|
231
|
-
|
|
218
|
+
} catch (e) {
|
|
219
|
+
this._log(`delay 初测异常: ${e.message}`);
|
|
220
|
+
} finally {
|
|
232
221
|
this._benchmarkRunning = false;
|
|
233
|
-
|
|
234
|
-
});
|
|
222
|
+
}
|
|
235
223
|
}
|
|
236
224
|
|
|
237
225
|
// ── 命令轮询 ──
|
|
@@ -313,10 +301,10 @@ export class ClashDaemon {
|
|
|
313
301
|
console.error("═".repeat(50));
|
|
314
302
|
console.error(" Clash Verge 节点健康守护");
|
|
315
303
|
console.error(
|
|
316
|
-
` 检测: ${checker.CONFIG.testUrl} | 巡检: ${this.intervalMs / 1000}s |
|
|
304
|
+
` 检测: ${checker.CONFIG.testUrl} | 巡检: ${this.intervalMs / 1000}s | 候选刷新: ${this.benchmarkMs / 60000}min${filterTag}`,
|
|
317
305
|
);
|
|
318
306
|
console.error(
|
|
319
|
-
`
|
|
307
|
+
` 模式: ${this.dryRun ? "DRY-RUN(只检测不切换)" : "正常运行"}`,
|
|
320
308
|
);
|
|
321
309
|
console.error("═".repeat(50));
|
|
322
310
|
}
|
|
@@ -354,8 +342,8 @@ function _buildCmdHandlers(daemon) {
|
|
|
354
342
|
}
|
|
355
343
|
},
|
|
356
344
|
runBenchmark() {
|
|
357
|
-
daemon._log("📋
|
|
358
|
-
daemon.
|
|
345
|
+
daemon._log("📋 收到手动刷新候选集命令");
|
|
346
|
+
daemon._runDelayProbe();
|
|
359
347
|
},
|
|
360
348
|
setInterval(cmd) {
|
|
361
349
|
if (cmd.seconds > 0) {
|
|
@@ -367,26 +355,6 @@ function _buildCmdHandlers(daemon) {
|
|
|
367
355
|
};
|
|
368
356
|
}
|
|
369
357
|
|
|
370
|
-
// ══════════════════════════════════════════════
|
|
371
|
-
// 工具
|
|
372
|
-
// ══════════════════════════════════════════════
|
|
373
|
-
|
|
374
|
-
function _findBenchmark() {
|
|
375
|
-
for (const p of [
|
|
376
|
-
join(
|
|
377
|
-
__dirname,
|
|
378
|
-
"..",
|
|
379
|
-
"..",
|
|
380
|
-
"..",
|
|
381
|
-
"clash-verge-explore",
|
|
382
|
-
"speedtest-tiktok.mjs",
|
|
383
|
-
),
|
|
384
|
-
join(__dirname, "speedtest-tiktok.mjs"),
|
|
385
|
-
])
|
|
386
|
-
if (existsSync(p)) return p;
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
358
|
// 直接运行时自动启动
|
|
391
359
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
392
360
|
const args = process.argv.slice(2);
|
package/src/cli/clash-daemon.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* clash-daemon CLI 命令入口
|
|
3
3
|
*
|
|
4
|
-
* 用法: tt-help clash-daemon [--interval
|
|
4
|
+
* 用法: tt-help clash-daemon [--interval 120] [--benchmark 40] [--country US] [--dry-run]
|
|
5
5
|
*/
|
|
6
6
|
import { ClashDaemon } from "../clash/daemon.js";
|
|
7
7
|
|
package/src/cli/watchdog.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* --server <URL> 服务端地址(默认使用 ~/.tt-help.json 中的 server)
|
|
8
8
|
* --interval <秒> 心跳间隔,默认 15 秒
|
|
9
9
|
* --client-id <ID> 客户端标识(默认本机 hostname)
|
|
10
|
+
* --no-daemon 不启动 clash daemon(默认启动)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import os from "os";
|
|
@@ -14,7 +15,12 @@ import { WatchdogAgent } from "../watchdog/agent.js";
|
|
|
14
15
|
import { getClientConfig } from "../watchdog/reporter.js";
|
|
15
16
|
|
|
16
17
|
export async function handleWatchdog(options) {
|
|
17
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
watchdogServer,
|
|
20
|
+
watchdogInterval,
|
|
21
|
+
watchdogClientId,
|
|
22
|
+
watchdogNoDaemon,
|
|
23
|
+
} = options;
|
|
18
24
|
|
|
19
25
|
// 服务端地址:--server 参数 > ~/.tt-help.json server > 默认值
|
|
20
26
|
const clientConfig = getClientConfig();
|
|
@@ -48,6 +54,7 @@ export async function handleWatchdog(options) {
|
|
|
48
54
|
serverUrl,
|
|
49
55
|
clientId,
|
|
50
56
|
intervalMs: intervalSec * 1000,
|
|
57
|
+
spawnDaemon: !watchdogNoDaemon,
|
|
51
58
|
});
|
|
52
59
|
|
|
53
60
|
await agent.start();
|