tt-help-cli-ycl 1.4.122 → 1.4.124

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.122",
3
+ "version": "1.4.124",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,9 @@ function fmtCountries(v) {
20
20
  return list.length ? list.join(",") : "无";
21
21
  }
22
22
 
23
+ // failover 切换冷却期(默认 45s,可经 opts.failoverCooldownMs 覆盖)
24
+ const FAILOVER_COOLDOWN_MS = 45_000;
25
+
23
26
  // ══════════════════════════════════════════════
24
27
  // ClashDaemon
25
28
  // ══════════════════════════════════════════════
@@ -28,6 +31,10 @@ export class ClashDaemon {
28
31
  constructor(opts = {}) {
29
32
  this.intervalMs = (opts.intervalSec || 120) * 1000;
30
33
  this.benchmarkMs = (opts.benchmarkMin || 10) * 60 * 1000; // 候选集刷新默认 10 分钟
34
+ this.failoverCooldownMs =
35
+ opts.failoverCooldownMs > 0
36
+ ? opts.failoverCooldownMs
37
+ : FAILOVER_COOLDOWN_MS;
31
38
  this.dryRun = !!opts.dryRun;
32
39
  // 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
33
40
  this._discoverOpts = {
@@ -508,6 +515,28 @@ export class ClashDaemon {
508
515
  */
509
516
  async _handleFailover(cmd, c) {
510
517
  try {
518
+ // ── failover 冷却 + 健康验证(2026-08-18)──
519
+ // 冷却期内再次收到 failover:先验证当前节点健康,健康则跳过并续冷却
520
+ // (持续健康 → 持续不切,避免冷却期结束后又白切健康节点;同时过滤滞后/重连误报),
521
+ // 不健康才继续切换。按控制端独立记录冷却(多控制端互不影响);
522
+ // 对外接口(命令动作 / IPC / health 格式)保持不变。
523
+ const now = Date.now();
524
+ const lastAt = c._lastFailoverAt || 0;
525
+ if (lastAt > 0 && now - lastAt < this.failoverCooldownMs) {
526
+ const h = checker.checkNodeHealth();
527
+ if (h && h.healthy) {
528
+ c._lastFailoverAt = now; // 续冷却:持续健康则持续不切
529
+ this._log(
530
+ `🔄 冷却期内节点健康 [${c.proxyPort}],跳过重复 failover(上次判定 ${Math.round((now - lastAt) / 1000)}s 前)`,
531
+ );
532
+ return;
533
+ }
534
+ this._log(
535
+ `🔄 冷却期内节点不健康 [${c.proxyPort}],继续故障切换(有依据才切)`,
536
+ );
537
+ }
538
+ c._lastFailoverAt = now;
539
+
511
540
  const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
512
541
  const node = await api
513
542
  .getCurrentNode(checker.CONFIG.groupName)
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 代理切换(failover)共享工具 — explore / refresh 使用
3
+ *
4
+ * 通过 clash/ipc.js 的文件 IPC 黑盒接口与 clash-daemon 通信:
5
+ * writeCommand({action:"failover", ...}, proxyPort) → daemon 切换 Clash 出口节点
6
+ * 浏览器仍走同一个 Clash 端口(--proxy),节点切换对浏览器透明,无需重启浏览器。
7
+ *
8
+ * 简单策略:遇到 CDN 限流 / 被封 → 发切换命令 + 固定等待生效,不轮询回执。
9
+ */
10
+ import { readHealth, writeCommand } from "./ipc.js";
11
+
12
+ const DEFAULT_SWITCH_WAIT_MS = 30_000; // 触发后等待节点切换生效
13
+
14
+ /** 从代理 URL 解析端口(http://127.0.0.1:7890 → 7890),无法解析返回 0(默认通道) */
15
+ export function proxyPortOf(url) {
16
+ if (!url) return 0;
17
+ try {
18
+ const u = new URL(url);
19
+ const p = parseInt(u.port, 10);
20
+ return p > 0 ? p : 0;
21
+ } catch {
22
+ const m = String(url).match(/:(\d+)/);
23
+ return m ? parseInt(m[1], 10) : 0;
24
+ }
25
+ }
26
+
27
+ /** 用 daemon 的 IPC 健康状态判断其是否存活(黑盒接口:clash-health.json,按通道端口) */
28
+ export function isDaemonAlive(port = 0) {
29
+ const h = readHealth(port);
30
+ return !!(h && !h.stale);
31
+ }
32
+
33
+ /**
34
+ * 触发代理切换:向 daemon 发 failover 命令,等待节点切换生效后返回。
35
+ * - 未配置代理(port=0)→ 跳过并提示,返回 false
36
+ * - daemon 未运行 → 打印提示(writeCommand 无害,无人读取),仍按 waitMs 等待返回
37
+ * @param {string} reason 切换原因
38
+ * @param {number} [proxyPort] 代理通道端口(0=默认通道)
39
+ * @param {number} [waitMs] 等待毫秒数,默认 30000
40
+ * @returns {boolean} 是否发起了切换(有代理端口)
41
+ */
42
+ export async function triggerProxySwitch(
43
+ reason,
44
+ proxyPort = 0,
45
+ waitMs = DEFAULT_SWITCH_WAIT_MS,
46
+ ) {
47
+ if (!proxyPort) {
48
+ console.error(` [代理] 未配置代理,跳过切换(${reason})`);
49
+ return false;
50
+ }
51
+ if (!isDaemonAlive(proxyPort)) {
52
+ console.error(
53
+ ` [代理] clash-daemon 未运行(通道 ${proxyPort}),切换命令可能无人处理(${reason})`,
54
+ );
55
+ }
56
+ console.error(
57
+ ` [代理] 触发切换(${reason},通道 ${proxyPort}),等待 ${Math.round(waitMs / 1000)}s 生效...`,
58
+ );
59
+ writeCommand(
60
+ { action: "failover", reason: `proxy-switch: ${reason}`, proxyPort },
61
+ proxyPort,
62
+ );
63
+ await new Promise((r) => setTimeout(r, waitMs));
64
+ return true;
65
+ }
@@ -165,10 +165,12 @@ function getBrowserMemory(port = null) {
165
165
  `}; ` +
166
166
  `Write-Output $sum`;
167
167
  } else {
168
+ // 无端口场景:Get-Process(.NET API,不依赖 WMI)统计全部 msedge 工作集,
169
+ // 与原 Get-CimInstance 全量求和行为等价;WMI 损坏/禁用的机器也能返回
168
170
  ps1Content =
169
- `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
171
+ `$procs = Get-Process msedge -ErrorAction SilentlyContinue; ` +
170
172
  `$sum = 0; ` +
171
- `foreach ($p in $procs) { $sum += $p.WorkingSetSize }; ` +
173
+ `foreach ($p in $procs) { $sum += $p.WorkingSet64 }; ` +
172
174
  `Write-Output $sum`;
173
175
  }
174
176
  writeFileSync(ps1Path, ps1Content);
@@ -27,6 +27,7 @@ import { showResourceUsage } from "../utils/index.js";
27
27
  import { HealthChecker } from "../lib/browser/health-checker.js";
28
28
  import { createApiClient } from "../lib/api-client.js";
29
29
  import { createCDNHandler } from "../lib/cdn-handler.js";
30
+ import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
30
31
  import path from "path";
31
32
  import os from "os";
32
33
 
@@ -144,6 +145,7 @@ export async function handleExplore(options) {
144
145
  // 未配置则不注入 proxyServer,浏览器走系统默认代理。
145
146
  // 本地地址不再自动解析/强制 requireClashOrExit(与 refresh-mini 一致)。
146
147
  const resolvedProxy = exploreProxy || configuredProxy || null;
148
+ const proxyPort = proxyPortOf(resolvedProxy);
147
149
  if (resolvedProxy) {
148
150
  cdpOptions.proxyServer = resolvedProxy;
149
151
  console.error(
@@ -517,6 +519,8 @@ export async function handleExplore(options) {
517
519
  if (result.cdnBlocked) {
518
520
  consecutiveNetworkErrors++;
519
521
  errorCount++;
522
+ // 遇到 CDN 即触发代理切换(等待 30s 生效),原退避/切账户逻辑保留
523
+ await triggerProxySwitch(`CDN限流 @${username}`, proxyPort);
520
524
  await cdn.handle(result, username);
521
525
  continue;
522
526
  }
@@ -576,6 +580,8 @@ export async function handleExplore(options) {
576
580
  if (errorType === "被封") {
577
581
  blockedCount++;
578
582
  console.error(` [被封] 累计 ${blockedCount} 次`);
583
+ // 触发代理切换(api-interceptor 已内置 30/40/50s 重试,到这里仍封直接切代理)
584
+ await triggerProxySwitch(`账号被封 @${username}`, proxyPort);
579
585
  if (blockedCount >= 3) {
580
586
  await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
581
587
  blockedCount = 0;
@@ -26,6 +26,7 @@ import {
26
26
  import { HealthChecker } from "../lib/browser/health-checker.js";
27
27
  import { createApiClient } from "../lib/api-client.js";
28
28
  import { createCDNHandler } from "../lib/cdn-handler.js";
29
+ import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
29
30
  import path from "path";
30
31
  import os from "os";
31
32
 
@@ -80,6 +81,7 @@ export async function handleRefresh(options) {
80
81
  // 未配置则不注入 proxyServer,浏览器走系统默认代理。
81
82
  const effectiveProxy = exploreProxy || configuredProxy;
82
83
  const resolvedProxy = effectiveProxy || null;
84
+ const proxyPort = proxyPortOf(resolvedProxy);
83
85
 
84
86
  let userId = exploreUserId || configuredUserId;
85
87
  let browser = null;
@@ -546,6 +548,8 @@ export async function handleRefresh(options) {
546
548
  if (result.cdnBlocked) {
547
549
  consecutiveNetworkErrors++;
548
550
  errorCount++;
551
+ // 遇到 CDN 即触发代理切换(等待 30s 生效),原退避/切账户逻辑保留
552
+ await triggerProxySwitch(`CDN限流 @${username}`, proxyPort);
549
553
  await cdn.handle(result, username);
550
554
  continue;
551
555
  }
@@ -605,6 +609,8 @@ export async function handleRefresh(options) {
605
609
  if (errorType === "被封") {
606
610
  blockedCount++;
607
611
  console.error(` [被封] 累计 ${blockedCount} 次`);
612
+ // 触发代理切换(api-interceptor 已内置 30/40/50s 重试,到这里仍封直接切代理)
613
+ await triggerProxySwitch(`账号被封 @${username}`, proxyPort);
608
614
  if (blockedCount >= 3) {
609
615
  await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
610
616
  blockedCount = 0;
package/src/cli/tag.js CHANGED
@@ -109,10 +109,12 @@ function getBrowserMemory(port = null) {
109
109
  `}; ` +
110
110
  `Write-Output $sum`;
111
111
  } else {
112
+ // 无端口场景:Get-Process(.NET API,不依赖 WMI)统计全部 msedge 工作集,
113
+ // 与原 Get-CimInstance 全量求和行为等价;WMI 损坏/禁用的机器也能返回
112
114
  ps1Content =
113
- `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
115
+ `$procs = Get-Process msedge -ErrorAction SilentlyContinue; ` +
114
116
  `$sum = 0; ` +
115
- `foreach ($p in $procs) { $sum += $p.WorkingSetSize }; ` +
117
+ `foreach ($p in $procs) { $sum += $p.WorkingSet64 }; ` +
116
118
  `Write-Output $sum`;
117
119
  }
118
120
  writeFileSync(ps1Path, ps1Content);
@@ -1089,21 +1091,47 @@ export async function handleScoreAll(parsed) {
1089
1091
  log(` 客户端 ID: ${clientId.substring(0, 8)}...`);
1090
1092
  log("");
1091
1093
 
1092
- // Ctrl+C 时关闭浏览器和 scraper
1093
- const cleanup = () => {
1094
+ // Ctrl+C / SIGTERM 时关闭浏览器和 scraper
1095
+ // 注意:process.exit 必须放在异步清理(killEdgeProcesses)完成之后,
1096
+ // 否则 exec 杀浏览器的命令来不及执行,Edge 窗口会残留
1097
+ let cleaningUp = false;
1098
+ let signalCount = 0;
1099
+ const cleanup = async ({ noExit = false } = {}) => {
1100
+ if (cleaningUp) return;
1101
+ cleaningUp = true;
1094
1102
  log("\n正在清理资源...");
1095
- if (enrichScraper) enrichScraper.close().catch(() => {});
1096
- killEdgeProcesses(null, cdpPort);
1097
- process.exit(0);
1103
+ if (enrichScraper) await enrichScraper.close().catch(() => {});
1104
+ log("正在关闭 CDP 浏览器...");
1105
+ try {
1106
+ await Promise.race([
1107
+ killEdgeProcesses(null, cdpPort),
1108
+ new Promise((r) => setTimeout(r, 5000)),
1109
+ ]);
1110
+ log("浏览器已关闭");
1111
+ } catch (e) {
1112
+ log(`关闭浏览器失败: ${e.message}`);
1113
+ }
1114
+ if (!noExit) process.exit(0);
1115
+ };
1116
+ const onSignal = (signal) => {
1117
+ signalCount++;
1118
+ if (signalCount > 1) {
1119
+ // 第二次信号:清理可能卡住,强制退出
1120
+ log(`\n再次收到 ${signal},强制退出`);
1121
+ process.exit(signal === "SIGTERM" ? 143 : 130);
1122
+ }
1123
+ log(`\n收到 ${signal},正在清理资源...`);
1124
+ cleanup().catch(() => process.exit(1));
1098
1125
  };
1099
- process.on("SIGINT", cleanup);
1126
+ process.on("SIGINT", () => onSignal("SIGINT"));
1127
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
1100
1128
 
1101
1129
  try {
1102
1130
  while (true) {
1103
1131
  // 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
1104
1132
  if (isBrowserDisconnected(browser)) {
1105
1133
  log("🚪 检测到浏览器窗口已关闭,score-all 自动退出");
1106
- cleanup();
1134
+ await cleanup();
1107
1135
  return;
1108
1136
  }
1109
1137
  try {
@@ -1327,7 +1355,7 @@ export async function handleScoreAll(parsed) {
1327
1355
  // 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
1328
1356
  if (isBrowserDisconnected(browser, e)) {
1329
1357
  log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),score-all 自动退出`);
1330
- cleanup();
1358
+ await cleanup();
1331
1359
  return;
1332
1360
  }
1333
1361
  // 区分网络错误和业务错误
@@ -1445,15 +1473,9 @@ export async function handleScoreAll(parsed) {
1445
1473
  }
1446
1474
  }
1447
1475
  } finally {
1448
- if (enrichScraper) await enrichScraper.close();
1449
- log("✅ TikTokScraper 已关闭");
1450
- log("正在关闭 CDP 浏览器...");
1451
- try {
1452
- killEdgeProcesses(null, cdpPort);
1453
- log("浏览器已关闭");
1454
- } catch (e) {
1455
- log(`关闭浏览器失败: ${e.message}`);
1456
- }
1476
+ // 正常路径退出(无信号):清理资源但不强制 process.exit,
1477
+ // 让事件循环自然结束;信号路径已在 cleanup 内 exit
1478
+ await cleanup({ noExit: true });
1457
1479
  }
1458
1480
  }
1459
1481
 
@@ -1787,20 +1809,44 @@ export async function handleTagRefresh(parsed) {
1787
1809
  log(` 客户端 ID: ${clientId.substring(0, 8)}...`);
1788
1810
  log("");
1789
1811
 
1790
- const cleanup = () => {
1812
+ let cleaningUp = false;
1813
+ let signalCount = 0;
1814
+ const cleanup = async ({ noExit = false } = {}) => {
1815
+ if (cleaningUp) return;
1816
+ cleaningUp = true;
1791
1817
  log("\n正在清理资源...");
1792
- if (enrichScraper) enrichScraper.close().catch(() => {});
1793
- killEdgeProcesses(null, cdpPort);
1794
- process.exit(0);
1818
+ if (enrichScraper) await enrichScraper.close().catch(() => {});
1819
+ log("正在关闭 CDP 浏览器...");
1820
+ try {
1821
+ await Promise.race([
1822
+ killEdgeProcesses(null, cdpPort),
1823
+ new Promise((r) => setTimeout(r, 5000)),
1824
+ ]);
1825
+ log("浏览器已关闭");
1826
+ } catch (e) {
1827
+ log(`关闭浏览器失败: ${e.message}`);
1828
+ }
1829
+ if (!noExit) process.exit(0);
1830
+ };
1831
+ const onSignal = (signal) => {
1832
+ signalCount++;
1833
+ if (signalCount > 1) {
1834
+ // 第二次信号:清理可能卡住,强制退出
1835
+ log(`\n再次收到 ${signal},强制退出`);
1836
+ process.exit(signal === "SIGTERM" ? 143 : 130);
1837
+ }
1838
+ log(`\n收到 ${signal},正在清理资源...`);
1839
+ cleanup().catch(() => process.exit(1));
1795
1840
  };
1796
- process.on("SIGINT", cleanup);
1841
+ process.on("SIGINT", () => onSignal("SIGINT"));
1842
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
1797
1843
 
1798
1844
  try {
1799
1845
  while (true) {
1800
1846
  // 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
1801
1847
  if (isBrowserDisconnected(browser)) {
1802
1848
  log("🚪 检测到浏览器窗口已关闭,refresh 自动退出");
1803
- cleanup();
1849
+ await cleanup();
1804
1850
  return;
1805
1851
  }
1806
1852
  try {
@@ -2026,7 +2072,7 @@ export async function handleTagRefresh(parsed) {
2026
2072
  // 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
2027
2073
  if (isBrowserDisconnected(browser, e)) {
2028
2074
  log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),refresh 自动退出`);
2029
- cleanup();
2075
+ await cleanup();
2030
2076
  return;
2031
2077
  }
2032
2078
  const isNetworkError =
@@ -2048,6 +2094,7 @@ export async function handleTagRefresh(parsed) {
2048
2094
  }
2049
2095
  }
2050
2096
  } finally {
2051
- cleanup();
2097
+ // 正常路径退出(无信号):清理资源但不强制 process.exit
2098
+ await cleanup({ noExit: true });
2052
2099
  }
2053
2100
  }
@@ -260,11 +260,23 @@ async function fetchUserVideosAPI(
260
260
  ) {
261
261
  log(` [API拦截] 获取 @${username} 视频 ...`);
262
262
 
263
- // CDP 健康检查:带超时保护。页面主线程死锁时 evaluate 可能长时间无响应,
264
- // 超时后自动刷新页面恢复(实测刷新页面即可解除死锁)
263
+ const {
264
+ blockedRetryWaitsMs = DEFAULT_BLOCKED_RETRY_WAITS,
265
+ cdpProbeTimeoutMs = 10000, // 单次探活超时
266
+ cdpObserveWaitMs = 20000, // 观察等待间隔(页面转圈加载期)
267
+ cdpObserveRounds = 2, // 观察轮数
268
+ cdpReloadSettleMs = 5000, // 刷新后缓冲等待,让加载高峰过去
269
+ cdpReloadProbeTimeoutMs = 20000, // 刷新后重检窗口
270
+ } = options;
271
+
272
+ // CDP 健康检查:带超时保护,区分"页面忙"与"页面死"。
273
+ // evaluate 排队在页面主线程上,加载中的慢页面(肉眼可见转圈)10s 轮不到执行很正常,
274
+ // 此时是忙不是死:先观察等待(默认 2 轮 x 20s),慢页面通常在此期间自然加载完自愈。
275
+ // 观察后仍无响应才刷新页面(实测刷新可解除主线程死锁);刷新后页面处于最忙的加载期,
276
+ // 先缓冲等待再以更宽松的窗口重检,避免误杀正在恢复的页面。
265
277
  const evalWithTimeout = (label, ms) =>
266
278
  Promise.race([
267
- page.evaluate(() => 1),
279
+ page.evaluate(() => document.readyState),
268
280
  new Promise((_, reject) =>
269
281
  setTimeout(
270
282
  () => reject(new Error(`${label} 超时(${ms / 1000}s)`)),
@@ -273,23 +285,50 @@ async function fetchUserVideosAPI(
273
285
  ),
274
286
  ]);
275
287
 
288
+ let healthy = false;
276
289
  try {
277
- await evalWithTimeout("CDP 健康检查", 10000);
290
+ const state = await evalWithTimeout("CDP 健康检查", cdpProbeTimeoutMs);
291
+ healthy = true;
292
+ if (state !== "complete") {
293
+ log(` [API拦截] 健康检查通过,页面加载中 (readyState=${state})`);
294
+ }
278
295
  } catch (e) {
279
- log(` [API拦截] CDP 连接异常: ${e.message},刷新页面恢复...`);
296
+ // 观察阶段:慢页面几十秒内通常自愈,期间不做任何操作
297
+ log(
298
+ ` [API拦截] CDP 探活超时: ${e.message},页面可能仍在加载,观察等待...`,
299
+ );
300
+ for (let round = 1; round <= cdpObserveRounds && !healthy; round++) {
301
+ await new Promise((r) => setTimeout(r, cdpObserveWaitMs));
302
+ try {
303
+ await evalWithTimeout(
304
+ `观察重检 ${round}/${cdpObserveRounds}`,
305
+ cdpProbeTimeoutMs,
306
+ );
307
+ healthy = true;
308
+ log(` [API拦截] 页面已恢复(观察 ${round} 轮后)`);
309
+ } catch (e2) {
310
+ log(
311
+ ` [API拦截] 观察 ${round}/${cdpObserveRounds} 后仍无响应: ${e2.message}`,
312
+ );
313
+ }
314
+ }
315
+ }
316
+
317
+ if (!healthy) {
318
+ log(` [API拦截] 观察后仍无响应,刷新页面恢复...`);
280
319
  await page
281
320
  .reload({ waitUntil: "domcontentloaded", timeout: 30000 })
282
321
  .catch(() => {});
322
+ await new Promise((r) => setTimeout(r, cdpReloadSettleMs));
283
323
  try {
284
- await evalWithTimeout("刷新后重检", 10000);
285
- log(` [API拦截] 页面已恢复`);
324
+ await evalWithTimeout("刷新后重检", cdpReloadProbeTimeoutMs);
325
+ log(` [API拦截] 页面已恢复(刷新后)`);
286
326
  } catch (e2) {
287
327
  throw new Error(`CDP 连接异常(刷新后仍失败): ${e2.message}`);
288
328
  }
289
329
  }
290
330
 
291
331
  let apiRequestUrlRef = { value: null };
292
- const { blockedRetryWaitsMs = DEFAULT_BLOCKED_RETRY_WAITS } = options;
293
332
  const retryWaits = Array.isArray(blockedRetryWaitsMs)
294
333
  ? blockedRetryWaitsMs
295
334
  : DEFAULT_BLOCKED_RETRY_WAITS;
@@ -70,6 +70,25 @@ function checkEdgeArgs() {
70
70
  });
71
71
  }
72
72
 
73
+ /**
74
+ * 解析 netstat -ano 输出,返回监听指定端口的 PID 列表(去重)。
75
+ * 不依赖 WMI,适配 WMI 损坏/禁用的 Windows 机器。
76
+ * @param {string} netstatOutput
77
+ * @param {number} port
78
+ * @returns {string[]}
79
+ */
80
+ export function parseNetstatListeningPids(netstatOutput, port) {
81
+ const pids = [];
82
+ for (const line of netstatOutput.split(/\r?\n/)) {
83
+ // LISTENING 状态 + 精确端口匹配(端口后必须跟空白,避免 8222 误匹配 18222)
84
+ if (!line.includes("LISTENING") || !line.includes(`:${port} `)) continue;
85
+ const parts = line.trim().split(/\s+/);
86
+ const pid = parts[parts.length - 1];
87
+ if (/^\d+$/.test(pid) && !pids.includes(pid)) pids.push(pid);
88
+ }
89
+ return pids;
90
+ }
91
+
73
92
  function killEdgeProcesses(targetDir, port) {
74
93
  return new Promise((resolve) => {
75
94
  const platform = os.platform();
@@ -120,39 +139,31 @@ function killEdgeProcesses(targetDir, port) {
120
139
  }
121
140
  } else if (platform === "win32") {
122
141
  if (port) {
123
- // 用端口匹配(纯数字,无编码问题),而不是 userDataDir 路径(中文路径会乱码)
142
+ // 不依赖 WMI:Get-CimInstance 在 WMI 损坏/禁用的机器上查不到进程(KILLED_PIDS 为空)。
143
+ // 改为 netstat 按监听端口找 PID(CDP 端口由浏览器主进程监听),taskkill /F /T 杀进程树
144
+ let killCmds = "";
124
145
  try {
125
- const ps1Path = path.join(os.tmpdir(), `kill-edge-${Date.now()}.ps1`);
126
- const ps1Content =
127
- `$procs = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" 2>$null; ` +
128
- `$count = 0; ` +
129
- `$pids = @(); ` +
130
- `foreach ($p in $procs) { ` +
131
- ` if ($p.CommandLine -and $p.CommandLine -like "*--remote-debugging-port=${port}*") { ` +
132
- ` $pids += $p.ProcessId; ` +
133
- ` Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue; ` +
134
- ` $count++ ` +
135
- ` } ` +
136
- `}; ` +
137
- `Write-Output "KILLED_PIDS: $($pids -join ',')"; ` +
138
- `Start-Sleep -Milliseconds 500; ` +
139
- `Get-ChildItem "$env:LOCALAPPDATA\\Microsoft\\Edge\\Singleton*" -ErrorAction SilentlyContinue | Remove-Item -Force; ` +
140
- `Write-Output $count`;
141
- fs.writeFileSync(ps1Path, ps1Content);
142
- const result = execSync(
143
- `powershell.exe -ExecutionPolicy Bypass -File "${ps1Path}"`,
144
- )
145
- .toString()
146
- .trim();
147
- console.error(`[killEdgeProcesses] Windows PS result: ${result}`);
148
- try {
149
- fs.unlinkSync(ps1Path);
150
- } catch {}
151
- command = "exit 0";
146
+ const netstatOut = execSync("netstat -ano", {
147
+ encoding: "utf-8",
148
+ timeout: 5000,
149
+ windowsHide: true,
150
+ });
151
+ const pids = parseNetstatListeningPids(netstatOut, port);
152
+ if (pids.length > 0) {
153
+ killCmds = pids
154
+ .map((pid) => `taskkill /F /T /PID ${pid} 2>nul`)
155
+ .join(" & ");
156
+ console.error(
157
+ `[killEdgeProcesses] 端口 ${port} 监听进程 PID: ${pids.join(", ")}`,
158
+ );
159
+ }
152
160
  } catch (e) {
153
- console.error(`[killEdgeProcesses] Windows PS failed: ${e.message}`);
154
- command = "taskkill /F /IM msedge.exe 2>nul || exit 0";
161
+ console.error(`[killEdgeProcesses] netstat 查询失败: ${e.message}`);
155
162
  }
163
+ // 找不到监听进程 = 浏览器可能已退出,不做按进程名的兜底(避免误杀其他端口实例)
164
+ command = killCmds
165
+ ? `(${killCmds}); del /f /q "%LOCALAPPDATA%\\Microsoft\\Edge\\Singleton*" 2>nul; exit 0`
166
+ : "exit 0";
156
167
  } else {
157
168
  command = "taskkill /F /IM msedge.exe 2>nul || exit 0";
158
169
  }
@@ -4042,16 +4042,21 @@ function renderLoginSection(c, userId, loginPorts) {
4042
4042
  "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
4043
4043
  "display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap";
4044
4044
 
4045
+ // 未配置端口 → 只显示配置按钮,不展示任何历史/上报的检测状态。
4046
+ // 否则会与"未配置"并存显示删除配置前的旧结果,误导用户。
4047
+ if (!configured.length) {
4048
+ return styled(
4049
+ "div",
4050
+ wrapStyle,
4051
+ head + styled("span", "color:#555;margin-left:auto", ""),
4052
+ );
4053
+ }
4054
+
4045
4055
  if (!report) {
4046
4056
  return styled(
4047
4057
  "div",
4048
4058
  wrapStyle,
4049
- head +
4050
- styled(
4051
- "span",
4052
- "color:#555;margin-left:auto",
4053
- configured.length ? "等待客户端上报..." : "",
4054
- ),
4059
+ head + styled("span", "color:#555;margin-left:auto", "等待客户端上报..."),
4055
4060
  );
4056
4061
  }
4057
4062
 
@@ -686,7 +686,7 @@
686
686
  </div>
687
687
  </div>
688
688
 
689
- <script src="app.js?v=20260817"></script>
689
+ <script src="app.js?v=20260818"></script>
690
690
  </body>
691
691
 
692
692
  </html>
@@ -352,6 +352,7 @@ export class WatchdogAgent {
352
352
  if (!this.running || !this._errorTriggerEnabled) return;
353
353
  const now = Date.now();
354
354
  const scanned = this.processManager.scanErrorLogs(NETWORK_ERROR_RE);
355
+ let failoverTriggered = false; // 本轮是否已触发全局切换
355
356
  for (const { cmdId, cmdDef, newErrors } of scanned) {
356
357
  if (!isCollectorCommand(cmdDef)) continue;
357
358
  let st = this._errorStates.get(cmdId) || {
@@ -359,6 +360,10 @@ export class WatchdogAgent {
359
360
  firstErrorAt: 0,
360
361
  lastErrorAt: 0,
361
362
  };
363
+ if (failoverTriggered) {
364
+ // 本轮已有进程触发全局切换 → 该进程也从新计数开始(防同轮接力触发)
365
+ st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
366
+ }
362
367
  const hadNew = newErrors.length > 0;
363
368
  for (const line of newErrors) {
364
369
  // 错误间隔超阈值 → 连续中断,重新计数
@@ -381,7 +386,11 @@ export class WatchdogAgent {
381
386
  `[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换`,
382
387
  );
383
388
  this._triggerFailover(cmdId);
384
- // 切换后重新计数
389
+ // 切换是全局的(所有子进程共用节点):清空全部错误计数,
390
+ // 让新节点有干净的观察窗口,旧节点错误不再驱动后续切换;
391
+ // 触发者自身也重新计数(含本轮后续错误行,从 0 累计)。
392
+ this._errorStates.clear();
393
+ failoverTriggered = true;
385
394
  st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
386
395
  }
387
396
  }
@@ -122,12 +122,13 @@ function splitCommand(cmdLine) {
122
122
  function getProcessStats(pid) {
123
123
  try {
124
124
  if (IS_WIN) {
125
- // 与 tag.js handleScoreAll 一致:用临时 .ps1 + Get-CimInstance Win32_Process + WorkingSetSize
125
+ // 用 Get-Process(.NET System.Diagnostics.Process API),不依赖 WMI。
126
+ // 部分机器 WMI 损坏/禁用时 Get-CimInstance 返回空,导致内存读取为 0
126
127
  const ps1Path = join(tmpdir(), `wd-proc-${pid}-${Date.now()}.ps1`);
127
128
  try {
128
129
  const ps1Content =
129
- `$p = Get-CimInstance Win32_Process -Filter "ProcessId='${pid}'" -ErrorAction SilentlyContinue; ` +
130
- `if ($p) { Write-Output $p.WorkingSetSize } else { Write-Output 0 }`;
130
+ `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; ` +
131
+ `if ($p) { Write-Output $p.WorkingSet64 } else { Write-Output 0 }`;
131
132
  writeFileSync(ps1Path, ps1Content);
132
133
  const stdout = execSync(
133
134
  `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`,
@@ -176,6 +177,7 @@ function getProcessStats(pid) {
176
177
 
177
178
  // Windows: 从 cmd.exe 往下找真正的 node.exe PID(shell:true 时 cmd.exe 是中间层)
178
179
  function getChildPid(parentPid) {
180
+ // 主路径:WMI 查询(原逻辑不变,WMI 正常机器零变化)
179
181
  try {
180
182
  const out = execSync(
181
183
  `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${parentPid}' | Where-Object { $_.Name -notlike 'conhost*' } | Select-Object -First 1 -ExpandProperty ProcessId"`,
@@ -187,6 +189,22 @@ function getChildPid(parentPid) {
187
189
  },
188
190
  ).trim();
189
191
  return parseInt(out, 10) || 0;
192
+ } catch {
193
+ // WMI 损坏/禁用时 Get-CimInstance 直接报错,落入 fallback
194
+ }
195
+ // fallback:不依赖 WMI,用 Get-Process(.NET API)按启动时间找
196
+ // cmd.exe 之后最早启动的 node.exe(watchdog spawn 后 3s 才调用,node 应已启动)
197
+ try {
198
+ const out = execSync(
199
+ `powershell -NoProfile -Command "$cmd = Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue; if ($cmd) { Get-Process node -ErrorAction SilentlyContinue | Where-Object { $_.StartTime -ge $cmd.StartTime } | Sort-Object StartTime | Select-Object -First 1 -ExpandProperty Id }"`,
200
+ {
201
+ encoding: "utf-8",
202
+ timeout: 3000,
203
+ stdio: ["ignore", "pipe", "ignore"],
204
+ windowsHide: true,
205
+ },
206
+ ).trim();
207
+ return parseInt(out, 10) || 0;
190
208
  } catch {
191
209
  return 0;
192
210
  }
@@ -350,8 +350,9 @@ async function snapshotAllProcesses() {
350
350
  });
351
351
  }
352
352
  }
353
- } catch {
353
+ } catch (e) {
354
354
  /* 快照失败时返回空,清理环节自然跳过 */
355
+ console.error(`[upgrader] 进程表快照失败(WMI 不可用?): ${e.message}`);
355
356
  }
356
357
  return rows;
357
358
  }
@@ -465,7 +466,8 @@ async function listWatchdogPids() {
465
466
  .split(/\r?\n/)
466
467
  .map((s) => parseInt(s.trim(), 10))
467
468
  .filter((n) => n > 0);
468
- } catch {
469
+ } catch (e) {
470
+ console.error(`[upgrader] 枚举 watchdog 进程失败(WMI 不可用?): ${e.message}`);
469
471
  return [];
470
472
  }
471
473
  }