tt-help-cli-ycl 1.4.79 → 1.4.82

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.
@@ -5,14 +5,20 @@ import {
5
5
  } from "../webserver/server.mjs";
6
6
 
7
7
  export async function handleWebserver(options) {
8
- const { webserverPort, webserverPoolSize, webserverProxy, webserverNotify } =
9
- options;
8
+ const {
9
+ webserverPort,
10
+ webserverPoolSize,
11
+ webserverProxy,
12
+ webserverLog,
13
+ webserverNotify,
14
+ } = options;
10
15
 
11
16
  const { server, port } = await startWebserver(
12
17
  webserverPort,
13
18
  webserverPoolSize,
14
19
  webserverProxy,
15
20
  webserverNotify,
21
+ webserverLog,
16
22
  );
17
23
 
18
24
  let shuttingDown = false;
package/src/lib/args.js CHANGED
@@ -407,6 +407,7 @@ function parseWebserverArgs(args) {
407
407
  let webserverPort = 3000;
408
408
  let webserverPoolSize = 0;
409
409
  let webserverProxy = null;
410
+ let webserverLog = false;
410
411
  // 默认汇报到 ~/.tt-help.json 中配置的 server 地址
411
412
  let webserverNotify = defaultServer;
412
413
 
@@ -418,6 +419,8 @@ function parseWebserverArgs(args) {
418
419
  webserverPoolSize = parseInt(args[++i]) || 0;
419
420
  } else if (arg === "--proxy") {
420
421
  webserverProxy = args[++i] || null;
422
+ } else if (arg === "--log") {
423
+ webserverLog = true;
421
424
  } else if (arg === "--notify") {
422
425
  const next = args[i + 1];
423
426
  // 没写地址或下一个是别的参数 → 使用配置中的 server 地址
@@ -434,6 +437,7 @@ function parseWebserverArgs(args) {
434
437
  webserverPort,
435
438
  webserverPoolSize,
436
439
  webserverProxy,
440
+ webserverLog,
437
441
  webserverNotify,
438
442
  urls: [],
439
443
  outputFile: null,
@@ -625,6 +629,7 @@ function parseWatchdogArgs(args) {
625
629
  let watchdogServer = null;
626
630
  let watchdogInterval = 15;
627
631
  let watchdogClientId = null;
632
+ let watchdogNoDaemon = false;
628
633
 
629
634
  for (let i = 0; i < args.length; i++) {
630
635
  const arg = args[i];
@@ -634,6 +639,8 @@ function parseWatchdogArgs(args) {
634
639
  watchdogInterval = parseInt(args[++i]) || 15;
635
640
  } else if (arg === "--client-id") {
636
641
  watchdogClientId = args[++i];
642
+ } else if (arg === "--no-daemon") {
643
+ watchdogNoDaemon = true;
637
644
  }
638
645
  }
639
646
 
@@ -642,6 +649,7 @@ function parseWatchdogArgs(args) {
642
649
  watchdogServer,
643
650
  watchdogInterval,
644
651
  watchdogClientId,
652
+ watchdogNoDaemon,
645
653
  urls: [],
646
654
  outputFormat: "json",
647
655
  exploreCount: 0,
@@ -659,6 +667,7 @@ function parseClashDaemonArgs(args) {
659
667
  let interval = 30;
660
668
  let benchmark = 40;
661
669
  let dryRun = false;
670
+ let country = null;
662
671
 
663
672
  for (let i = 0; i < args.length; i++) {
664
673
  const arg = args[i];
@@ -666,6 +675,8 @@ function parseClashDaemonArgs(args) {
666
675
  interval = parseInt(args[++i]) || 30;
667
676
  } else if (arg === "--benchmark") {
668
677
  benchmark = parseInt(args[++i]) || 40;
678
+ } else if (arg === "--country") {
679
+ country = args[++i] || null;
669
680
  } else if (arg === "--dry-run") {
670
681
  dryRun = true;
671
682
  }
@@ -675,6 +686,7 @@ function parseClashDaemonArgs(args) {
675
686
  subcommand: "clash-daemon",
676
687
  interval,
677
688
  benchmark,
689
+ country,
678
690
  "dry-run": dryRun,
679
691
  urls: [],
680
692
  outputFormat: "json",
@@ -247,6 +247,7 @@ const HELP_TEXT = [
247
247
  " -p, --port <端口号> 监听端口,默认 3000",
248
248
  " --pool-size <数量> 浏览器页面池大小,默认 8",
249
249
  " --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
250
+ " --log 显示 API 成功日志(默认不显示,错误日志始终输出)",
250
251
  " --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
251
252
  " API:",
252
253
  " GET /health 健康检查",
@@ -255,6 +256,7 @@ const HELP_TEXT = [
255
256
  " POST /api/tiktok/video 获取视频信息 { videoUrl: string }",
256
257
  " POST /api/tiktok/lookup 同时获取视频和作者信息 { videoUrl: string }",
257
258
  " 示例: tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897 --notify",
259
+ " tt-help webserver -p 3000 --log",
258
260
  "",
259
261
  " comments [选项]",
260
262
  " 评论采集:从视频评论中发现新用户,来源标记为 comment",
@@ -381,10 +383,31 @@ const HELP_TEXT = [
381
383
  " --server <URL>, -s 服务端地址(默认使用 ~/.tt-help.json 中的 server)",
382
384
  " --interval <秒>, -i 心跳间隔,默认 15 秒(最小 5 秒)",
383
385
  " --client-id <ID> 客户端标识(默认本机 hostname)",
386
+ " --no-daemon 不启动 clash daemon(默认启动)",
384
387
  " 示例: tt-help watchdog",
385
388
  " tt-help watchdog --server http://117.71.53.99:17301",
386
389
  " tt-help watchdog -i 30 --client-id my-server-01",
387
390
  "",
391
+ " tt-help clash-daemon [选项]",
392
+ " Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
393
+ " 看门狗启动时会自动拉起此守护进程",
394
+ " 选项:",
395
+ " --interval <秒> 巡检间隔,默认 30 秒",
396
+ " --benchmark <分钟> 全量测速间隔,默认 40 分钟",
397
+ " --country <国家代码> 只保留指定国家节点,默认不过滤",
398
+ " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
399
+ " --dry-run 只检测不切换(调试用)",
400
+ " 看门狗运行时可通过 /tmp/clash-cmd.json 发命令动态调整:",
401
+ ' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
402
+ ' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
403
+ ' {"action":"setCountryFilter","country":null} 取消过滤',
404
+ ' {"action":"runBenchmark"} 手动触发全量测速',
405
+ ' {"action":"setInterval","seconds":60} 修改巡检间隔',
406
+ " 示例: tt-help clash-daemon",
407
+ " tt-help clash-daemon --country US",
408
+ " tt-help clash-daemon --interval 15 --benchmark 20 --country US",
409
+ " tt-help clash-daemon --dry-run",
410
+ "",
388
411
  " config [show|set|unset|reset]",
389
412
  " config 查看当前配置",
390
413
  " config set <key> <value> 设置配置(key: proxy, server, browser, userId, maxFollowing, maxFollowers, maxVideos, maxComments)",
@@ -400,6 +423,7 @@ const HELP_TEXT = [
400
423
  " tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
401
424
  " tt-help refresh --server http://127.0.0.1:3001 --port 9222",
402
425
  " tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
426
+ " tt-help webserver -p 3000 --log",
403
427
  " tt-help config set server http://127.0.0.1:3001",
404
428
  " tt-help attach -p 5 -i 10",
405
429
  " tt-help watch -o data/result.db",
@@ -26,14 +26,31 @@ const PDP_HEADERS = {
26
26
  "Accept-Language": "en-US,en;q=0.9",
27
27
  };
28
28
 
29
- // 浏览器解验证码后缓存的 Cookie,给 undici 复用
30
- let cachedCookies = null;
31
- let cookiesExpireAt = 0;
32
- const COOKIE_TTL_MS = 30 * 60 * 1000; // Cookie 有效期 30 分钟
33
-
34
- function getCookieHeader() {
35
- if (!cachedCookies || Date.now() > cookiesExpireAt) return null;
36
- return cachedCookies.map((c) => `${c.name}=${c.value}`).join("; ");
29
+ // 浏览器解验证码后缓存的 Cookie,按代理节点隔离(避免切节点后旧 Cookie 被冲掉)
30
+ const cookieCache = new Map(); // proxyUrl → { cookies, expireAt }
31
+ const COOKIE_TTL_MS = 60 * 60 * 1000; // Cookie 有效期 60 分钟
32
+
33
+ /** 按代理节点获取缓存的 Cookie,过期自动清除 */
34
+ function getCookieHeader(proxyUrl) {
35
+ const entry = cookieCache.get(proxyUrl);
36
+ if (!entry || Date.now() > entry.expireAt) {
37
+ if (entry) cookieCache.delete(proxyUrl);
38
+ return null;
39
+ }
40
+ return entry.cookies.map((c) => `${c.name}=${c.value}`).join("; ");
41
+ }
42
+
43
+ /** 按代理节点缓存 Cookie */
44
+ function setCachedCookies(proxyUrl, cookies) {
45
+ cookieCache.set(proxyUrl, {
46
+ cookies,
47
+ expireAt: Date.now() + COOKIE_TTL_MS,
48
+ });
49
+ }
50
+
51
+ /** 清除指定代理节点的 Cookie */
52
+ function clearCachedCookies(proxyUrl) {
53
+ cookieCache.delete(proxyUrl);
37
54
  }
38
55
 
39
56
  /**
@@ -140,14 +157,13 @@ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
140
157
  }
141
158
 
142
159
  if (solved) {
143
- // 提取 Cookie 给 undici 复用
160
+ // 提取 Cookie,按代理节点缓存(切回同一节点时可复用)
144
161
  try {
145
162
  const cookies = await page.context().cookies();
146
163
  if (cookies.length > 0) {
147
- cachedCookies = cookies;
148
- cookiesExpireAt = Date.now() + COOKIE_TTL_MS;
164
+ setCachedCookies(proxyUrl, cookies);
149
165
  console.error(
150
- `[shop-scraper] Captcha solved, cached ${cookies.length} cookies for undici reuse`,
166
+ `[shop-scraper] Captcha solved, cached ${cookies.length} cookies for proxy ${proxyUrl} (TTL ${COOKIE_TTL_MS / 60000}min)`,
151
167
  );
152
168
  }
153
169
  } catch (e) {
@@ -177,7 +193,7 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
177
193
  for (let attempt = 1; attempt <= 3; attempt++) {
178
194
  try {
179
195
  const headers = { ...PDP_HEADERS };
180
- const cookieStr = getCookieHeader();
196
+ const cookieStr = getCookieHeader(proxy);
181
197
  if (cookieStr) {
182
198
  headers["Cookie"] = cookieStr;
183
199
  }
@@ -195,14 +211,15 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
195
211
  } catch (err) {
196
212
  lastError = err;
197
213
 
198
- // Captcha:清除过期 Cookie,弹浏览器重新获取
214
+ // Captcha:清除当前代理节点的过期 Cookie,弹浏览器重新获取
199
215
  if (
200
216
  err.message?.includes("Blocked by TikTok") &&
201
217
  options.captchaFallback
202
218
  ) {
203
- console.error("[shop-scraper] Captcha hit, clearing cached cookies...");
204
- cachedCookies = null;
205
- cookiesExpireAt = 0;
219
+ console.error(
220
+ `[shop-scraper] Captcha on ${proxy}, clearing cached cookies...`,
221
+ );
222
+ clearCachedCookies(proxy);
206
223
  console.error("[shop-scraper] Opening browser for manual captcha...");
207
224
  await resolveCaptchaWithBrowser(url, proxy);
208
225
  // 重试(带着新 Cookie)
@@ -3811,10 +3811,160 @@ function renderClientCard(c, clientCommands) {
3811
3811
  header +
3812
3812
  styled("div", "color:#888;font-size:12px", osStr) +
3813
3813
  styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
3814
+ renderProxySection(c, userId) +
3814
3815
  renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis),
3815
3816
  );
3816
3817
  }
3817
3818
 
3819
+ // ── 代理节点区域(看门狗面板中每个客户端卡片) ──
3820
+
3821
+ function renderProxySection(c, userId) {
3822
+ var h = c.clashHealth;
3823
+ if (!h || !h.node) return "";
3824
+
3825
+ var nodes = (h.meta && h.meta.healthyNodes) || [];
3826
+ var hasNodes = nodes.length > 0;
3827
+
3828
+ return (
3829
+ renderProxyLine(h, nodes, userId, hasNodes) +
3830
+ renderNodeList(nodes, h.node, userId, hasNodes)
3831
+ );
3832
+ }
3833
+
3834
+ /** 代理状态行:当前节点 + 健康状态 + 展开按钮 */
3835
+ function renderProxyLine(h, nodes, userId, hasNodes) {
3836
+ var dot = h.healthy ? "#4ade80" : "#f87171";
3837
+ var status = h.healthy
3838
+ ? (h.timeTotal || 0).toFixed(1) +
3839
+ "s " +
3840
+ ((h.sizeDownload || 0) / 1024).toFixed(0) +
3841
+ "KB"
3842
+ : h.error || "异常";
3843
+
3844
+ return styled(
3845
+ "div",
3846
+ "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
3847
+ "display:flex;align-items:center;gap:8px;font-size:12px",
3848
+ styled("span", "color:" + dot + ";font-size:14px", "●") +
3849
+ styled("span", "color:#e0e0e0;font-weight:bold", h.node) +
3850
+ styled("span", "color:#666", "| " + status) +
3851
+ (hasNodes
3852
+ ? tag(
3853
+ "button",
3854
+ {
3855
+ onclick: "wdToggleNodes('" + esc(userId) + "')",
3856
+ style:
3857
+ "margin-left:auto;padding:2px 8px;font-size:11px;background:#333;" +
3858
+ "color:#60a5fa;border:1px solid #444;border-radius:3px;cursor:pointer",
3859
+ title: "查看可用节点列表",
3860
+ },
3861
+ nodes.length + " 个节点 ▸",
3862
+ )
3863
+ : ""),
3864
+ );
3865
+ }
3866
+
3867
+ /** 节点列表面板(默认隐藏) */
3868
+ function renderNodeList(nodes, currentNode, userId, hasNodes) {
3869
+ if (!hasNodes) return "";
3870
+
3871
+ var items = "";
3872
+ for (var i = 0; i < nodes.length; i++) {
3873
+ items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId);
3874
+ }
3875
+
3876
+ var id = "wdNodes-" + wdEscId(userId);
3877
+ return (
3878
+ '<div id="' +
3879
+ id +
3880
+ '" style="margin-top:4px;border:1px solid #2a2a3a;' +
3881
+ 'border-radius:6px;overflow:hidden;display:none">' +
3882
+ items +
3883
+ "</div>"
3884
+ );
3885
+ }
3886
+
3887
+ /** 单个节点行 */
3888
+ function renderNodeItem(name, isActive, userId) {
3889
+ var color = isActive ? "#4ade80" : "#888";
3890
+ var bg = isActive ? "background:#1a3a2a;" : "";
3891
+ var dot = isActive ? "●" : "○";
3892
+ var textColor = isActive ? "#4ade80" : "#aaa";
3893
+ var action = isActive
3894
+ ? '<span style="margin-left:auto;color:#555;font-size:10px">当前</span>'
3895
+ : "<button onclick=\"wdSwitchNode('" +
3896
+ esc(userId) +
3897
+ "','" +
3898
+ esc(name) +
3899
+ "')\" " +
3900
+ 'style="margin-left:auto;padding:1px 6px;font-size:10px;background:#2563eb;' +
3901
+ 'color:#fff;border:none;border-radius:2px;cursor:pointer">切换</button>';
3902
+
3903
+ return (
3904
+ '<div style="padding:3px 8px;display:flex;align-items:center;gap:6px;font-size:11px;' +
3905
+ bg +
3906
+ '">' +
3907
+ '<span style="color:' +
3908
+ color +
3909
+ '">' +
3910
+ dot +
3911
+ "</span>" +
3912
+ '<span style="color:' +
3913
+ textColor +
3914
+ '">' +
3915
+ name +
3916
+ "</span>" +
3917
+ action +
3918
+ "</div>"
3919
+ );
3920
+ }
3921
+
3922
+ // ── 交互函数 ──
3923
+
3924
+ function wdEscId(s) {
3925
+ return (s || "").replace(/[^a-zA-Z0-9_-]/g, "_");
3926
+ }
3927
+
3928
+ function wdToggleNodes(userId) {
3929
+ var el = document.getElementById("wdNodes-" + wdEscId(userId));
3930
+ if (!el) return;
3931
+ el.style.display = el.style.display === "none" ? "block" : "none";
3932
+ }
3933
+
3934
+ function wdSwitchNode(userId, node) {
3935
+ if (
3936
+ !confirm(
3937
+ "确定要将 " +
3938
+ userId +
3939
+ " 的代理切换到: " +
3940
+ node +
3941
+ " ?\n\n切换后自动巡检会继续运行。",
3942
+ )
3943
+ )
3944
+ return;
3945
+
3946
+ fetch("/api/watchdog/switch-node", {
3947
+ method: "POST",
3948
+ headers: { "Content-Type": "application/json" },
3949
+ body: JSON.stringify({ userId: userId, node: node }),
3950
+ })
3951
+ .then(function (r) {
3952
+ return r.json();
3953
+ })
3954
+ .then(function (d) {
3955
+ alert(
3956
+ d.ok
3957
+ ? "已发送切换指令: " +
3958
+ node +
3959
+ "\n\n客户端将在下次心跳时执行(约15秒内)。"
3960
+ : "失败: " + (d.error || "未知错误"),
3961
+ );
3962
+ })
3963
+ .catch(function (e) {
3964
+ alert("请求失败: " + e.message);
3965
+ });
3966
+ }
3967
+
3818
3968
  // 统一的命令行视图:每条命令一行,含运行状态
3819
3969
  function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
3820
3970
  var match = matchProcsToCmds(myCmds, procs);
@@ -612,7 +612,7 @@
612
612
  </div>
613
613
  </div>
614
614
 
615
- <script src="app.js"></script>
615
+ <script src="app.js?v=20260808c"></script>
616
616
  </body>
617
617
 
618
618
  </html>
@@ -26,6 +26,10 @@ const CLIENT_CLEANUP_MS = 24 * 60 * 60 * 1000; // 离线超过 24h 清理
26
26
  const llmCache = new Map();
27
27
  const LLM_CACHE_TTL = 15 * 60 * 1000; // 15 分钟过期
28
28
 
29
+ // Clash 命令队列:userId → [{ action, node, ... }]
30
+ // 看门狗客户端下次心跳时取走,取走后清空
31
+ const clashCmdQueue = new Map();
32
+
29
33
  // ── 配置文件读写 ──
30
34
 
31
35
  function loadConfig() {
@@ -92,6 +96,7 @@ function getOnlineClients() {
92
96
  config: info.config || {},
93
97
  processes,
94
98
  llmAnalysis,
99
+ clashHealth: info.clashHealth || null,
95
100
  lastHeartbeat: info.lastHeartbeat,
96
101
  registeredAt: info.registeredAt,
97
102
  online,
@@ -240,20 +245,28 @@ async function handleHeartbeat(params, body, res) {
240
245
  osInfo: body.osInfo || {},
241
246
  config: body.config || {},
242
247
  processes: body.processes || [],
248
+ clashHealth: body.clashHealth || null,
243
249
  });
244
250
 
245
251
  // 按 userId 返回该客户端的专属命令列表
246
252
  const userId = (body.config && body.config.userId) || "";
247
253
  const commands = getClientCommands(userId);
248
254
 
255
+ // 取出待下发的 Clash 命令(一次性,取走即清空)
256
+ const clashCommands = clashCmdQueue.get(userId) || [];
257
+ if (clashCommands.length > 0) {
258
+ clashCmdQueue.delete(userId);
259
+ }
260
+
249
261
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
250
262
  console.error(
251
- `[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}条命令`,
263
+ `[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}条命令${clashCommands.length ? ", " + clashCommands.length + "条Clash命令" : ""}`,
252
264
  );
253
265
 
254
266
  sendJSON(res, 200, {
255
267
  ok: true,
256
268
  commands,
269
+ clashCommands: clashCommands.length > 0 ? clashCommands : undefined,
257
270
  clientConfig: getClientConfigStored(userId),
258
271
  serverTime: Date.now(),
259
272
  });
@@ -377,6 +390,20 @@ async function handleClearLlmAnalysis(params, body, res) {
377
390
  sendJSON(res, 200, { ok: true, cleared });
378
391
  }
379
392
 
393
+ async function handleSwitchNode(params, body, res) {
394
+ const { userId, node } = body || {};
395
+ if (!userId || !node) {
396
+ sendJSON(res, 400, { error: "缺少 userId 或 node" });
397
+ return;
398
+ }
399
+ // 加入 Clash 命令队列,客户端下次心跳时取走
400
+ const queue = clashCmdQueue.get(userId) || [];
401
+ queue.push({ action: "switchNode", node });
402
+ clashCmdQueue.set(userId, queue);
403
+ console.error(`[watchdog] ${userId} 节点切换已入队: → ${node}`);
404
+ sendJSON(res, 200, { ok: true, message: `将在下次心跳时切换到 ${node}` });
405
+ }
406
+
380
407
  // ── 公开 ──
381
408
 
382
409
  export function registerWatchdogRoutes(httpServer) {
@@ -442,5 +469,9 @@ export async function tryHandleWatchdogRoute(
442
469
  await handleClearLlmAnalysis(params, body, res);
443
470
  return true;
444
471
  }
472
+ if (method === "POST" && routePath === "/api/watchdog/switch-node") {
473
+ await handleSwitchNode(params, body, res);
474
+ return true;
475
+ }
445
476
  return false;
446
477
  }
@@ -32,7 +32,12 @@ const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
32
32
 
33
33
  /** 自动发现 daemon.js 路径 */
34
34
  function findDaemonPath() {
35
- // 1. 从 ~/.tt-help.json 读取配置
35
+ // 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
36
+ // 避免 ~/.tt-help.json 的 clashDaemonPath 指向旧版外部 daemon
37
+ const internalPath = join(__dirname, "..", "clash", "daemon.js");
38
+ if (existsSync(internalPath)) return internalPath;
39
+
40
+ // 2. 显式配置覆盖(仅当包内缺失时)
36
41
  try {
37
42
  if (existsSync(TT_HELP_CONFIG)) {
38
43
  const cfg = JSON.parse(readFileSync(TT_HELP_CONFIG, "utf-8"));
@@ -42,10 +47,6 @@ function findDaemonPath() {
42
47
  }
43
48
  } catch {}
44
49
 
45
- // 2. 内部打包路径(npm publish 后 src/clash/daemon.js)
46
- const internalPath = join(__dirname, "..", "clash", "daemon.js");
47
- if (existsSync(internalPath)) return internalPath;
48
-
49
50
  // 3. 开发模式:clash-verge-explore 同级目录
50
51
  const candidates = [
51
52
  join(__dirname, "..", "..", "..", "clash-verge-explore", "daemon.js"),
@@ -117,11 +118,13 @@ export class WatchdogAgent {
117
118
  * @param {string} opts.serverUrl - 服务端地址
118
119
  * @param {string} opts.clientId - 客户端标识
119
120
  * @param {number} opts.intervalMs - 心跳间隔(毫秒)
121
+ * @param {boolean} [opts.spawnDaemon=true] - 是否自动启动 clash daemon
120
122
  */
121
- constructor({ serverUrl, clientId, intervalMs = 15000 }) {
123
+ constructor({ serverUrl, clientId, intervalMs = 15000, spawnDaemon = true }) {
122
124
  this.serverUrl = serverUrl.replace(/\/+$/, "");
123
125
  this.clientId = clientId;
124
126
  this.intervalMs = intervalMs;
127
+ this.spawnDaemon = !!spawnDaemon;
125
128
  this.running = false;
126
129
  this.heartbeatTimer = null;
127
130
  this.thresholdTimer = null;
@@ -160,8 +163,8 @@ export class WatchdogAgent {
160
163
  // 注册信号处理
161
164
  this._setupSignalHandlers();
162
165
 
163
- // 启动 clash daemon(保代理通道)
164
- this._spawnClashDaemon();
166
+ // 启动 clash daemon(保代理通道,--no-daemon 可关闭)
167
+ if (this.spawnDaemon) this._spawnClashDaemon();
165
168
 
166
169
  // 首次立即发送心跳
167
170
  await this._sendHeartbeat();
@@ -176,6 +179,11 @@ export class WatchdogAgent {
176
179
  this.processManager.checkThresholds();
177
180
  }, 5000);
178
181
 
182
+ // 假死检测:每 60s 检查各进程是否有日志输出,超 30 分钟无日志则重启
183
+ this.staleTimer = setInterval(() => {
184
+ this.processManager.checkStaleProcesses();
185
+ }, 60000);
186
+
179
187
  // LLM 日志分析(每 10 分钟调服务端接口)
180
188
  this._startLlmAnalyzer();
181
189
 
@@ -196,6 +204,10 @@ export class WatchdogAgent {
196
204
  clearInterval(this.thresholdTimer);
197
205
  this.thresholdTimer = null;
198
206
  }
207
+ if (this.staleTimer) {
208
+ clearInterval(this.staleTimer);
209
+ this.staleTimer = null;
210
+ }
199
211
  if (this.llmAnalyzerTimer) {
200
212
  clearInterval(this.llmAnalyzerTimer);
201
213
  this.llmAnalyzerTimer = null;
@@ -264,7 +276,7 @@ export class WatchdogAgent {
264
276
  const data = await resp.json();
265
277
  this.consecutiveFailures = 0;
266
278
 
267
- // 同步命令
279
+ // 同步进程命令
268
280
  if (Array.isArray(data.commands)) {
269
281
  const prev = this.processManager.runningCount;
270
282
  await this.processManager.syncCommands(data.commands);
@@ -276,6 +288,15 @@ export class WatchdogAgent {
276
288
  }
277
289
  }
278
290
 
291
+ // 处理 Clash daemon 命令(写 clash-cmd.json,daemon 轮询拾取)
292
+ if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
293
+ const ipc = await import("../clash/ipc.js");
294
+ for (const cmd of data.clashCommands) {
295
+ ipc.writeCommand(cmd);
296
+ console.error(`[watchdog] 下发 Clash 命令: ${JSON.stringify(cmd)}`);
297
+ }
298
+ }
299
+
279
300
  // 服务端下发的客户端配置(远程修改 ~/.tt-help.json)
280
301
  if (data.clientConfig) {
281
302
  applyClientConfig(data.clientConfig);
@@ -377,17 +398,21 @@ export class WatchdogAgent {
377
398
  this.upgradeChecker = startUpgradeChecker({
378
399
  onUpdateAvailable: async ({ current, latest }) => {
379
400
  console.error(`[upgrade] ${current} → ${latest}`);
401
+ // 停掉所有子进程与 clash daemon,避免新旧并存
380
402
  this.processManager.stopAll();
403
+ this._killClashDaemon();
381
404
 
382
405
  const result = await performUpgrade();
383
406
  if (result.success) {
384
407
  this.ttHelpVersion = latest;
385
- console.error(`[upgrade] 升级成功`);
386
- } else {
387
- console.error(`[upgrade] 失败: ${result.output.substring(0, 200)}`);
408
+ console.error(
409
+ `[upgrade] 升级成功 (${latest}),重启 watchdog 使新版 clash daemon 生效`,
410
+ );
411
+ // 重启自身进程加载新代码(内部 daemon 路径优先),新进程会拉起新版 daemon
412
+ this._restartSelf();
413
+ return;
388
414
  }
389
-
390
- // 立即心跳(同步命令,重启进程)
415
+ console.error(`[upgrade] 失败: ${result.output.substring(0, 200)}`);
391
416
  await this._sendHeartbeat();
392
417
  },
393
418
  });
@@ -409,7 +434,7 @@ export class WatchdogAgent {
409
434
 
410
435
  console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
411
436
  try {
412
- const child = spawn(process.execPath, [daemonPath], {
437
+ const child = spawn(process.execPath, [daemonPath, "--country", "US"], {
413
438
  stdio: ["ignore", "pipe", "pipe"],
414
439
  detached: false,
415
440
  });
@@ -463,6 +488,38 @@ export class WatchdogAgent {
463
488
  this.clashDaemon = null;
464
489
  }
465
490
 
491
+ /**
492
+ * 升级后重启自身进程(加载新代码)
493
+ * 用 cli.js watchdog 子进程替换当前进程,保证新版本内部 daemon 路径逻辑生效
494
+ */
495
+ _restartSelf() {
496
+ const cliPath = join(__dirname, "..", "..", "cli.js");
497
+ const args = ["watchdog"];
498
+ if (this.serverUrl) args.push("--server", this.serverUrl);
499
+ if (this.clientId) args.push("--client-id", this.clientId);
500
+ if (!this.spawnDaemon) args.push("--no-daemon");
501
+ args.push(
502
+ "--interval",
503
+ String(Math.max(5, Math.round(this.intervalMs / 1000))),
504
+ );
505
+ let child;
506
+ try {
507
+ child = spawn(process.execPath, [cliPath, ...args], {
508
+ detached: true,
509
+ stdio: "ignore",
510
+ });
511
+ child.unref();
512
+ } catch (e) {
513
+ console.error(`[upgrade] 重启自身失败: ${e.message}`);
514
+ return;
515
+ }
516
+ console.error(
517
+ `[upgrade] 新版 watchdog 已启动 (pid=${child.pid}),当前进程退出`,
518
+ );
519
+ this.stop();
520
+ setTimeout(() => process.exit(0), 500);
521
+ }
522
+
466
523
  /**
467
524
  * 信号处理
468
525
  */