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/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,
@@ -659,6 +663,7 @@ function parseClashDaemonArgs(args) {
659
663
  let interval = 30;
660
664
  let benchmark = 40;
661
665
  let dryRun = false;
666
+ let country = null;
662
667
 
663
668
  for (let i = 0; i < args.length; i++) {
664
669
  const arg = args[i];
@@ -666,6 +671,8 @@ function parseClashDaemonArgs(args) {
666
671
  interval = parseInt(args[++i]) || 30;
667
672
  } else if (arg === "--benchmark") {
668
673
  benchmark = parseInt(args[++i]) || 40;
674
+ } else if (arg === "--country") {
675
+ country = args[++i] || null;
669
676
  } else if (arg === "--dry-run") {
670
677
  dryRun = true;
671
678
  }
@@ -675,6 +682,7 @@ function parseClashDaemonArgs(args) {
675
682
  subcommand: "clash-daemon",
676
683
  interval,
677
684
  benchmark,
685
+ country,
678
686
  "dry-run": dryRun,
679
687
  urls: [],
680
688
  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",
@@ -385,6 +387,26 @@ const HELP_TEXT = [
385
387
  " tt-help watchdog --server http://117.71.53.99:17301",
386
388
  " tt-help watchdog -i 30 --client-id my-server-01",
387
389
  "",
390
+ " tt-help clash-daemon [选项]",
391
+ " Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
392
+ " 看门狗启动时会自动拉起此守护进程",
393
+ " 选项:",
394
+ " --interval <秒> 巡检间隔,默认 30 秒",
395
+ " --benchmark <分钟> 全量测速间隔,默认 40 分钟",
396
+ " --country <国家代码> 只保留指定国家节点,默认不过滤",
397
+ " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
398
+ " --dry-run 只检测不切换(调试用)",
399
+ " 看门狗运行时可通过 /tmp/clash-cmd.json 发命令动态调整:",
400
+ ' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
401
+ ' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
402
+ ' {"action":"setCountryFilter","country":null} 取消过滤',
403
+ ' {"action":"runBenchmark"} 手动触发全量测速',
404
+ ' {"action":"setInterval","seconds":60} 修改巡检间隔',
405
+ " 示例: tt-help clash-daemon",
406
+ " tt-help clash-daemon --country US",
407
+ " tt-help clash-daemon --interval 15 --benchmark 20 --country US",
408
+ " tt-help clash-daemon --dry-run",
409
+ "",
388
410
  " config [show|set|unset|reset]",
389
411
  " config 查看当前配置",
390
412
  " config set <key> <value> 设置配置(key: proxy, server, browser, userId, maxFollowing, maxFollowers, maxVideos, maxComments)",
@@ -400,6 +422,7 @@ const HELP_TEXT = [
400
422
  " tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
401
423
  " tt-help refresh --server http://127.0.0.1:3001 --port 9222",
402
424
  " tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
425
+ " tt-help webserver -p 3000 --log",
403
426
  " tt-help config set server http://127.0.0.1:3001",
404
427
  " tt-help attach -p 5 -i 10",
405
428
  " 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
  }
@@ -176,6 +176,11 @@ export class WatchdogAgent {
176
176
  this.processManager.checkThresholds();
177
177
  }, 5000);
178
178
 
179
+ // 假死检测:每 60s 检查各进程是否有日志输出,超 30 分钟无日志则重启
180
+ this.staleTimer = setInterval(() => {
181
+ this.processManager.checkStaleProcesses();
182
+ }, 60000);
183
+
179
184
  // LLM 日志分析(每 10 分钟调服务端接口)
180
185
  this._startLlmAnalyzer();
181
186
 
@@ -196,6 +201,10 @@ export class WatchdogAgent {
196
201
  clearInterval(this.thresholdTimer);
197
202
  this.thresholdTimer = null;
198
203
  }
204
+ if (this.staleTimer) {
205
+ clearInterval(this.staleTimer);
206
+ this.staleTimer = null;
207
+ }
199
208
  if (this.llmAnalyzerTimer) {
200
209
  clearInterval(this.llmAnalyzerTimer);
201
210
  this.llmAnalyzerTimer = null;
@@ -264,7 +273,7 @@ export class WatchdogAgent {
264
273
  const data = await resp.json();
265
274
  this.consecutiveFailures = 0;
266
275
 
267
- // 同步命令
276
+ // 同步进程命令
268
277
  if (Array.isArray(data.commands)) {
269
278
  const prev = this.processManager.runningCount;
270
279
  await this.processManager.syncCommands(data.commands);
@@ -276,6 +285,15 @@ export class WatchdogAgent {
276
285
  }
277
286
  }
278
287
 
288
+ // 处理 Clash daemon 命令(写 clash-cmd.json,daemon 轮询拾取)
289
+ if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
290
+ const ipc = await import("../clash/ipc.js");
291
+ for (const cmd of data.clashCommands) {
292
+ ipc.writeCommand(cmd);
293
+ console.error(`[watchdog] 下发 Clash 命令: ${JSON.stringify(cmd)}`);
294
+ }
295
+ }
296
+
279
297
  // 服务端下发的客户端配置(远程修改 ~/.tt-help.json)
280
298
  if (data.clientConfig) {
281
299
  applyClientConfig(data.clientConfig);
@@ -409,7 +427,7 @@ export class WatchdogAgent {
409
427
 
410
428
  console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
411
429
  try {
412
- const child = spawn(process.execPath, [daemonPath], {
430
+ const child = spawn(process.execPath, [daemonPath, "--country", "US"], {
413
431
  stdio: ["ignore", "pipe", "pipe"],
414
432
  detached: false,
415
433
  });
@@ -22,6 +22,7 @@ const MAX_RESTART = 3;
22
22
  const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
23
23
  const IS_WIN = process.platform === "win32";
24
24
  const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
25
+ const STALE_LOG_TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟无日志输出视为假死
25
26
 
26
27
  // 从命令行中提取 CDP 端口列表(解析 --base-port 和 --port-count)
27
28
  function extractCdpPorts(cmdLine) {
@@ -215,6 +216,7 @@ export class ProcessManager extends EventEmitter {
215
216
  if (info) {
216
217
  info.logs.push(short);
217
218
  if (info.logs.length > MAX_LOG_LINES) info.logs.shift();
219
+ info.lastLogTime = Date.now(); // 记录最后日志时间,用于假死检测
218
220
  }
219
221
  }
220
222
  });
@@ -267,6 +269,7 @@ export class ProcessManager extends EventEmitter {
267
269
  restartCount: 0,
268
270
  stopped: false,
269
271
  logs: [],
272
+ lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
270
273
  });
271
274
  }
272
275
 
@@ -384,6 +387,60 @@ export class ProcessManager extends EventEmitter {
384
387
  return result;
385
388
  }
386
389
 
390
+ /**
391
+ * 检测假死进程:超过 STALE_LOG_TIMEOUT_MS 无日志输出则自动重启
392
+ * @returns {Array<{cmdId:string, silentMinutes:number}>} 被重启的进程列表
393
+ */
394
+ checkStaleProcesses() {
395
+ const now = Date.now();
396
+ const restarted = [];
397
+
398
+ for (const [cmdId, info] of this.processes) {
399
+ if (info.stopped || !info.child) continue;
400
+ // 启动不到 STALE_LOG_TIMEOUT_MS 的进程跳过(给启动留足时间)
401
+ const uptime = now - info.startTime;
402
+ if (uptime < STALE_LOG_TIMEOUT_MS) continue;
403
+
404
+ const lastLog = info.lastLogTime || info.startTime;
405
+ const silentMs = now - lastLog;
406
+ if (silentMs < STALE_LOG_TIMEOUT_MS) continue;
407
+
408
+ const silentMin = Math.round(silentMs / 60000);
409
+ console.error(
410
+ `[watchdog] 命令 ${cmdId} 已 ${silentMin} 分钟无日志输出(疑似假死),重启...`,
411
+ );
412
+
413
+ // 保存 cmdDef 和 restartCount,因为 stopCommand 会 delete
414
+ const cmdDef = info.cmdDef;
415
+ const restartCount = (info.restartCount || 0) + 1;
416
+
417
+ if (restartCount > MAX_RESTART) {
418
+ console.error(
419
+ `[watchdog] 命令 ${cmdId} 已达最大重启次数 (${MAX_RESTART}),不再自动重启`,
420
+ );
421
+ continue;
422
+ }
423
+
424
+ // 停止旧进程
425
+ this.stopCommand(cmdId);
426
+
427
+ // 重启(继承 restartCount)
428
+ const def = { ...cmdDef, _staleRestart: true };
429
+ this.startCommand(def);
430
+ const newInfo = this.processes.get(cmdId);
431
+ if (newInfo) {
432
+ newInfo.restartCount = restartCount;
433
+ }
434
+
435
+ console.error(
436
+ `[watchdog] 命令 ${cmdId} 假死重启完成 (${restartCount}/${MAX_RESTART})`,
437
+ );
438
+ restarted.push({ cmdId, silentMinutes: silentMin });
439
+ }
440
+
441
+ return restarted;
442
+ }
443
+
387
444
  /**
388
445
  * 与期望命令列表同步:
389
446
  * - 启动期望列表中已启用但未运行的命令