tt-help-cli-ycl 1.4.82 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.82",
3
+ "version": "1.4.83",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clash/api.js CHANGED
@@ -183,3 +183,34 @@ export async function switchToFastest(groupName) {
183
183
  }
184
184
  throw new Error("存活节点都不在目标组中");
185
185
  }
186
+
187
+ /**
188
+ * 对单个节点测延迟(不切换当前选中节点)
189
+ * GET /proxies/{name}/delay?url=&timeout=
190
+ * @param {string} name - 节点名
191
+ * @param {string} url - 测速目标 URL
192
+ * @param {number} timeoutMs - 超时(毫秒)
193
+ * @returns {Promise<{delay:number, timeout:boolean}>} delay 毫秒,-1 表示失败;timeout=true 表示超时/不通
194
+ */
195
+ export async function getProxyDelay(name, url, timeoutMs = 5000) {
196
+ try {
197
+ const qs = `url=${encodeURIComponent(url)}&timeout=${timeoutMs}`;
198
+ const res = await fetch(
199
+ `${BASE_URL}/proxies/${encodeURIComponent(name)}/delay?${qs}`,
200
+ {
201
+ headers: headers(),
202
+ signal: AbortSignal.timeout(timeoutMs + 5000),
203
+ },
204
+ );
205
+ if (res.status === 404) return { delay: -1, timeout: true };
206
+ if (!res.ok) {
207
+ const body = await res.text().catch(() => "");
208
+ throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`);
209
+ }
210
+ const text = await res.text();
211
+ const data = text ? JSON.parse(text) : { delay: -1, timeout: true };
212
+ return { delay: data.delay ?? -1, timeout: data.timeout === true };
213
+ } catch {
214
+ return { delay: -1, timeout: true };
215
+ }
216
+ }
@@ -62,12 +62,16 @@ let _proxyResolved = false;
62
62
 
63
63
  export async function resolveAndSetProxyUrl() {
64
64
  if (_proxyResolved) return CONFIG.proxyUrl;
65
- try { CONFIG.proxyUrl = await api.resolveProxyUrl(); } catch {}
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() { _proxyResolved = false; }
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()) return { alive: true, reason: "verge-mihomo 进程已运行" };
82
- if (_detectApi()) return { alive: true, reason: `API 端口 ${CONFIG.apiPort} 可达` };
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("tasklist", ["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
93
- { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
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
- { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
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 { return false; }
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(exe, ["-s", "--max-time", "3",
106
- `http://127.0.0.1:${CONFIG.apiPort}/version`],
107
- { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
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 { return false; }
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(` 2. API 端口 ${CONFIG.apiPort} 已开启(设置 → 允许局域网连接 / RESTful API)`);
120
- console.error(` 3. 代理端口 ${CONFIG.proxyUrl.replace("http://", "")} 正在监听\n`);
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, timeTotal, sizeDownload, title, error,
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", "-w", "\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
143
- "--proxy", CONFIG.proxyUrl,
144
- "--max-time", String(CONFIG.curlTimeout),
145
- "-L", "-A", CURL_UA, url,
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: idx >= 0 ? out.substring(0, idx).trimEnd() : "",
189
+ body: idx >= 0 ? out.substring(0, idx).trimEnd() : "",
157
190
  metaLine: idx >= 0 ? out.substring(idx + 13).trim() : "",
158
- error: r.error ? r.error.message : r.status !== 0 ? `curl exit ${r.status}` : null,
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 { httpCode: parseInt(p[0]) || 0, timeTotal: parseFloat(p[1]) || 0, sizeDownload: parseInt(p[2]) || 0 };
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 !error && httpCode === 200 && size >= CONFIG.minPageSize &&
174
- title.includes("TikTok") && !title.startsWith("About |") &&
175
- !body.includes("SlardarWAF") && !body.includes("_wafchallengeid");
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]) return { filtered: nodes, excluded: 0 };
184
- const filtered = nodes.filter(n => COUNTRY_PATTERNS[country].test(n));
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 { updated: d.updated || "", nodes: filtered, totalNodes: allNodes.length, excluded, countryFilter: filter || null };
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(HEALTHY_FILE,
213
- JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2), "utf-8");
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 { updated: "", nodes: [], totalNodes: 0, excluded: 0, countryFilter: null };
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(`[checker] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
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()) return { action: "skipped", detail: "Clash Verge 未运行" };
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 { action: "ok", detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB` };
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 { currentNode = await api.getCurrentNode(CONFIG.groupName); }
272
- catch (e) { return { action: "failed", detail: `无法获取当前节点: ${e.message}` }; }
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) return { action: "failed", detail: `${reason} — 健康列表为空` };
410
+ if (!nodes.length)
411
+ return { action: "failed", detail: `${reason} — 健康列表为空` };
276
412
 
277
413
  const target = pickFailoverNode(currentNode, nodes);
278
- if (!target) return { action: "failed", detail: `${reason} — 无可用候选节点` };
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} 失败` };
@@ -1,30 +1,24 @@
1
1
  /**
2
2
  * ClashDaemon — 代理节点健康守护
3
3
  *
4
- * 职责: 定时巡检 → 异常切换 → 基准测速 → IPC 上报
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 || 30) * 1000;
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} 个健康节点${isStale ? ` (已过期 ${ageMin}min,立即刷新)` : ""}` : "健康列表为空,将立即执行基准测速"}`,
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._runBenchmark();
73
+ this._runDelayProbe();
80
74
  this._timers.push(
81
- setInterval(() => this._runBenchmark(), this.benchmarkMs),
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
- const target = checker.pickFailoverNode(node, nodes);
136
- if (!target) {
137
- this._log(`✗ ${reason} — 无法切换`);
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] 应切换到 ${target}`);
135
+ this._log(`[DRY-RUN] 当前节点异常,候选: ${nodes.length} 个`);
142
136
  return;
143
137
  }
144
138
 
145
- const switched = await checker.switchToNode(target);
146
- if (!switched) {
147
- this._log(`✗ ${reason} → 切换到 ${target} 失败`);
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
- _runBenchmark() {
179
- if (!this.benchmarkScript || this._benchmarkRunning) return;
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
- const child = spawn(
184
- process.execPath,
185
- [this.benchmarkScript, "--timeout", "12"],
186
- { cwd: dirname(this.benchmarkScript), stdio: ["ignore", "pipe", "pipe"] },
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
- if (code !== 0) {
197
- this._log(`=== 基准测速失败 (exit ${code}, ${elapsed}s) ===`);
198
- return;
199
- }
200
- try {
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
- `=== 基准测速完成 (${elapsed}s): ${allNodes.length} 个健康节点${tag} ===`,
213
+ `=== delay 初测完成 (${elapsed}s): ${r.nodes.length} 个可达节点 ===`,
226
214
  );
227
- } catch (e) {
228
- this._log(`解析结果失败: ${e.message}`);
215
+ } else {
216
+ this._log(`=== delay 初测完成 (${elapsed}s): 0 个可达节点 ===`);
229
217
  }
230
- });
231
- child.on("error", (err) => {
218
+ } catch (e) {
219
+ this._log(`delay 初测异常: ${e.message}`);
220
+ } finally {
232
221
  this._benchmarkRunning = false;
233
- this._log(`基准测速异常: ${err.message}`);
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 | 基准: ${this.benchmarkMs / 60000}min${filterTag}`,
304
+ ` 检测: ${checker.CONFIG.testUrl} | 巡检: ${this.intervalMs / 1000}s | 候选刷新: ${this.benchmarkMs / 60000}min${filterTag}`,
317
305
  );
318
306
  console.error(
319
- ` 基准脚本: ${this.benchmarkScript || "(不可用)"} | 模式: ${this.dryRun ? "DRY-RUN" : "正常"}`,
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._runBenchmark();
345
+ daemon._log("📋 收到手动刷新候选集命令");
346
+ daemon._runDelayProbe();
359
347
  },
360
348
  setInterval(cmd) {
361
349
  if (cmd.seconds > 0) {
@@ -367,29 +355,6 @@ function _buildCmdHandlers(daemon) {
367
355
  };
368
356
  }
369
357
 
370
- // ══════════════════════════════════════════════
371
- // 工具
372
- // ══════════════════════════════════════════════
373
-
374
- function _findBenchmark() {
375
- for (const p of [
376
- // 1. 包内 benchmark(npm publish 后 src/clash/benchmark/speedtest-tiktok.mjs)
377
- join(__dirname, "benchmark", "speedtest-tiktok.mjs"),
378
- // 2. 开发模式:clash-verge-explore 同级目录
379
- join(
380
- __dirname,
381
- "..",
382
- "..",
383
- "..",
384
- "clash-verge-explore",
385
- "speedtest-tiktok.mjs",
386
- ),
387
- join(__dirname, "speedtest-tiktok.mjs"),
388
- ])
389
- if (existsSync(p)) return p;
390
- return null;
391
- }
392
-
393
358
  // 直接运行时自动启动
394
359
  if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
395
360
  const args = process.argv.slice(2);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * clash-daemon CLI 命令入口
3
3
  *
4
- * 用法: tt-help clash-daemon [--interval 30] [--benchmark 40] [--country US] [--dry-run]
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/lib/args.js CHANGED
@@ -664,7 +664,7 @@ function parseWatchdogArgs(args) {
664
664
  }
665
665
 
666
666
  function parseClashDaemonArgs(args) {
667
- let interval = 30;
667
+ let interval = 120;
668
668
  let benchmark = 40;
669
669
  let dryRun = false;
670
670
  let country = null;
@@ -672,7 +672,7 @@ function parseClashDaemonArgs(args) {
672
672
  for (let i = 0; i < args.length; i++) {
673
673
  const arg = args[i];
674
674
  if (arg === "--interval") {
675
- interval = parseInt(args[++i]) || 30;
675
+ interval = parseInt(args[++i]) || 120;
676
676
  } else if (arg === "--benchmark") {
677
677
  benchmark = parseInt(args[++i]) || 40;
678
678
  } else if (arg === "--country") {
@@ -392,8 +392,8 @@ const HELP_TEXT = [
392
392
  " Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
393
393
  " 看门狗启动时会自动拉起此守护进程",
394
394
  " 选项:",
395
- " --interval <秒> 巡检间隔,默认 30 秒",
396
- " --benchmark <分钟> 全量测速间隔,默认 40 分钟",
395
+ " --interval <秒> 巡检间隔,默认 120 秒(2分钟)",
396
+ " --benchmark <分钟> 候选集 delay 初测间隔,默认 40 分钟",
397
397
  " --country <国家代码> 只保留指定国家节点,默认不过滤",
398
398
  " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
399
399
  " --dry-run 只检测不切换(调试用)",
@@ -401,7 +401,7 @@ const HELP_TEXT = [
401
401
  ' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
402
402
  ' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
403
403
  ' {"action":"setCountryFilter","country":null} 取消过滤',
404
- ' {"action":"runBenchmark"} 手动触发全量测速',
404
+ ' {"action":"runBenchmark"} 手动刷新候选集(delay 初测)',
405
405
  ' {"action":"setInterval","seconds":60} 修改巡检间隔',
406
406
  " 示例: tt-help clash-daemon",
407
407
  " tt-help clash-daemon --country US",