tt-help-cli-ycl 1.4.125 → 1.4.128

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.125",
3
+ "version": "1.4.128",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * daemon 切换历史排序单测(无需 Clash 运行)
3
+ * 验证 _sortCandidates / _markSwitch 的纯时间轮换(后进后出)行为
4
+ */
5
+ import { ClashDaemon } from "../src/clash/daemon.js";
6
+
7
+ let pass = 0;
8
+ let fail = 0;
9
+ function ok(cond, name, detail = "") {
10
+ if (cond) {
11
+ pass++;
12
+ console.log(` ✓ ${name}`);
13
+ } else {
14
+ fail++;
15
+ console.error(` ✗ ${name} ${detail}`);
16
+ }
17
+ }
18
+
19
+ const d = new ClashDaemon({});
20
+ const nodes = ["A", "B", "C", "D"];
21
+ const T0 = Date.now();
22
+
23
+ // T1 无历史 → 原序
24
+ {
25
+ const c = { proxyPort: 1 };
26
+ ok(
27
+ JSON.stringify(d._sortCandidates(nodes, c)) === JSON.stringify(nodes),
28
+ "T1 无历史保持原序",
29
+ );
30
+ }
31
+
32
+ // T2 切过沉底,未切过保持原序
33
+ {
34
+ const c = { proxyPort: 2 };
35
+ c._switchHistory = new Map([["B", T0 - 10_000]]);
36
+ const r = d._sortCandidates(nodes, c);
37
+ ok(r.join() === "A,C,D,B", "T2 切过沉底,未切过保持原序", r.join());
38
+ }
39
+
40
+ // T3 切过按切换时间升序,未切过最前
41
+ {
42
+ const c = { proxyPort: 3 };
43
+ c._switchHistory = new Map([
44
+ ["C", T0 - 2_000],
45
+ ["A", T0 - 1_000],
46
+ ]);
47
+ const r = d._sortCandidates(nodes, c);
48
+ ok(r.join() === "B,D,C,A", "T3 切过按切换时间升序,未切过最前", r.join());
49
+ }
50
+
51
+ // T4 全部切过 → 完全按切换时间轮换
52
+ {
53
+ const c = { proxyPort: 4 };
54
+ c._switchHistory = new Map([
55
+ ["A", T0 - 4_000],
56
+ ["B", T0 - 1_000],
57
+ ["C", T0 - 3_000],
58
+ ["D", T0 - 2_000],
59
+ ]);
60
+ const r = d._sortCandidates(nodes, c);
61
+ ok(r.join() === "A,C,D,B", "T4 全切过按切换时间轮换", r.join());
62
+ }
63
+
64
+ // T5 无时间窗口:无论多久前切过都按时间排队(不恢复 delay 原序)
65
+ {
66
+ const c = { proxyPort: 5 };
67
+ c._switchHistory = new Map([["C", T0 - 3_600_000]]); // 1 小时前切过
68
+ const r = d._sortCandidates(nodes, c);
69
+ ok(r.join() === "A,B,D,C", "T5 无窗口:1 小时前切过仍沉底", r.join());
70
+ }
71
+
72
+ // T6 排序不改原数组
73
+ {
74
+ ok(nodes.join() === "A,B,C,D", "T6 原数组未被修改");
75
+ }
76
+
77
+ // T7 _markSwitch 惰性初始化 + 更新
78
+ {
79
+ const c = { proxyPort: 6 };
80
+ d._markSwitch(c, "A");
81
+ ok(
82
+ c._switchHistory instanceof Map && c._switchHistory.has("A"),
83
+ "T7 _markSwitch 惰性初始化并记录",
84
+ );
85
+ const t1 = c._switchHistory.get("A");
86
+ d._markSwitch(c, "A");
87
+ ok(c._switchHistory.get("A") >= t1, "T8 _markSwitch 重复记录更新时间戳");
88
+ }
89
+
90
+ // T9 时间戳相同时稳定排序(V8 sort 稳定,保持原相对序)
91
+ {
92
+ const c = { proxyPort: 7 };
93
+ c._switchHistory = new Map([
94
+ ["A", T0 - 100],
95
+ ["B", T0 - 100],
96
+ ]);
97
+ const r = d._sortCandidates(nodes, c);
98
+ ok(r.join() === "C,D,A,B", "T9 相同时间戳保持原相对序(稳定排序)", r.join());
99
+ }
100
+
101
+ // T10 壳页判定:200 + 无跳转 + 无 error + 无 title → 壳页
102
+ ok(
103
+ d._isShellPage({ error: null, httpCode: 200, numRedirects: 0, title: "" }) ===
104
+ true,
105
+ "T10 壳页判定:200+无title → true",
106
+ );
107
+ // T11 健康页(有 title)→ 非壳页
108
+ ok(
109
+ d._isShellPage({
110
+ error: null,
111
+ httpCode: 200,
112
+ numRedirects: 0,
113
+ title: "TikTok - Make Your Day",
114
+ }) === false,
115
+ "T11 健康页有 title → false",
116
+ );
117
+ // T12 连接错误 → 非壳页
118
+ ok(
119
+ d._isShellPage({
120
+ error: "timeout",
121
+ httpCode: 0,
122
+ numRedirects: 0,
123
+ title: "",
124
+ }) === false,
125
+ "T12 连接错误 → false",
126
+ );
127
+ // T13 跳转 → 非壳页
128
+ ok(
129
+ d._isShellPage({ error: null, httpCode: 200, numRedirects: 1, title: "" }) ===
130
+ false,
131
+ "T13 跳转 → false",
132
+ );
133
+ // T14 非 200 → 非壳页
134
+ ok(
135
+ d._isShellPage({ error: null, httpCode: 403, numRedirects: 0, title: "" }) ===
136
+ false,
137
+ "T14 非 200 → false",
138
+ );
139
+
140
+ console.log(`\n结果: ${pass} 通过 / ${fail} 失败`);
141
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 测试:升级链绕开 WMI 的新逻辑(2026-08-21)
3
+ * 范围(macOS 可测部分):
4
+ * 1. parseCsvLine:tasklist /FO CSV 输出解析(引号/逗号边界)
5
+ * 2. 模拟 tasklist /V 输出的窗口标题匹配过滤逻辑(复算与实现一致的筛选规则)
6
+ * 3. agent 构造即写存活标记文件(STARTED_FILE),内容含 pid/version
7
+ */
8
+ import assert from "assert";
9
+ import { readFileSync, rmSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { tmpdir } from "os";
12
+ import { fileURLToPath } from "url";
13
+
14
+ const { parseCsvLine } = await import("../src/watchdog/upgrader.js");
15
+
16
+ let pass = 0;
17
+ const ok = (msg) => {
18
+ pass++;
19
+ console.log(` ✓ ${msg}`);
20
+ };
21
+
22
+ // ── 1. parseCsvLine ──
23
+ const L1 = '"conhost.exe","5234","Console","2","8,312 K","Running","x\\user","0:00:01","tt-help-watchdog (F-7@WIN)"';
24
+ const f1 = parseCsvLine(L1);
25
+ assert.strictEqual(f1[0], "conhost.exe", "映像名");
26
+ assert.strictEqual(f1[1], "5234", "PID");
27
+ assert.strictEqual(f1[3], "2", "会话#");
28
+ assert.strictEqual(f1[4], "8,312 K", "带逗号字段 8,312 K");
29
+ assert.strictEqual(f1[8], "tt-help-watchdog (F-7@WIN)", "窗口标题");
30
+ ok("parseCsvLine: 常规行(含带逗号字段)");
31
+
32
+ // 标题内含转义引号(CSV 标准:双引号 "" 转义)
33
+ const L2 = '"cmd.exe","999","Console","2","1,024 K","Running","x\\user","0:00:00","say ""hi"", tt-help-watchdog"';
34
+ const f2 = parseCsvLine(L2);
35
+ assert.strictEqual(f2[8], 'say "hi", tt-help-watchdog', "标题含转义引号与逗号");
36
+ ok("parseCsvLine: 标题含转义引号+逗号");
37
+
38
+ // 无引号行(表头)
39
+ const f3 = parseCsvLine("映像名称,PID,会话名,会话#,内存使用,状态,用户名,CPU 时间,窗口标题");
40
+ assert.strictEqual(f3.length, 9, "表头 9 列");
41
+ ok("parseCsvLine: 无引号表头");
42
+
43
+ // ── 2. 模拟 tasklist /V 输出做标题匹配过滤(复算实现逻辑) ──
44
+ const mockTasklist = [
45
+ '"映像名称","PID","会话名","会话#","内存使用","状态","用户名","CPU 时间","窗口标题"',
46
+ '"System Idle Process","0","Services","0","8 K","Unknown","NT AUTHORITY\\SYSTEM","1:23:45:55","N/A"',
47
+ '"conhost.exe","5234","Console","2","8,312 K","Running","x\\user","0:00:01","tt-help-watchdog (F-7@WIN-AADJ1HTCCAQ)"',
48
+ '"cmd.exe","4000","Console","2","4,444 K","Running","x\\user","0:00:00","N/A"',
49
+ '"conhost.exe","7777","Console","2","2,048 K","Running","x\\user","0:00:00","C:\\Windows\\system32\\cmd.exe"',
50
+ '"node.exe","6400","Console","2","120,000 K","Running","x\\user","0:00:10","N/A"',
51
+ '"cmd.exe","6401","Console","2","3,000 K","Running","x\\user","0:00:00","tt-help-watchdog (old)"',
52
+ ];
53
+ const TAG = "tt-help-watchdog";
54
+ const matched = [];
55
+ for (const line of mockTasklist) {
56
+ if (!line.toLowerCase().includes(TAG.toLowerCase())) continue;
57
+ const fields = parseCsvLine(line);
58
+ if (fields.length < 2) continue;
59
+ const name = (fields[0] || "").toLowerCase();
60
+ if (!name.includes("conhost") && !name.includes("cmd")) continue;
61
+ const pid = parseInt(fields[1], 10);
62
+ if (pid > 0) matched.push(pid);
63
+ }
64
+ assert.deepStrictEqual(
65
+ [...new Set(matched)].sort((a, b) => a - b),
66
+ [5234, 6401],
67
+ "只匹配到标题含 tag 的 conhost/cmd,普通窗口/其他进程不误匹配",
68
+ );
69
+ ok("标题匹配过滤: 命中旧窗口 conhost+cmd,排除无关进程");
70
+
71
+ // ── 3. agent 构造即写存活标记文件 ──
72
+ const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
73
+ rmSync(STARTED_FILE, { force: true });
74
+ const { WatchdogAgent } = await import("../src/watchdog/agent.js");
75
+ const agent = new WatchdogAgent({
76
+ serverUrl: "http://127.0.0.1:3001",
77
+ clientId: "test@unit",
78
+ intervalMs: 15000,
79
+ });
80
+ assert.ok(agent, "agent 构造成功");
81
+ const st = JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
82
+ assert.strictEqual(st.pid, process.pid, "pid=当前进程");
83
+ assert.ok(typeof st.version === "string" && st.version.length > 0, "version 非空");
84
+ assert.ok(st.ts > 0, "ts 存在");
85
+ ok(`存活文件写入: pid=${st.pid}, version=${st.version}`);
86
+
87
+ // 重复构造覆盖写(模拟新版 watchdog 启动覆盖旧文件)
88
+ const agent2 = new WatchdogAgent({
89
+ serverUrl: "http://127.0.0.1:3001",
90
+ clientId: "test2@unit",
91
+ });
92
+ const st2 = JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
93
+ assert.strictEqual(st2.pid, process.pid, "覆盖写仍有效");
94
+ ok("存活文件: 重复构造覆盖写正常");
95
+
96
+ // 清理测试文件
97
+ rmSync(STARTED_FILE, { force: true });
98
+ console.log(`\n全部通过: ${pass} 项`);
@@ -84,6 +84,45 @@ export class ClashDaemon {
84
84
  return run;
85
85
  }
86
86
 
87
+ /** 记录一次成功切换(failover/巡检/手动切换共用),供候选排序使用 */
88
+ _markSwitch(c, nodeName) {
89
+ if (!c || !nodeName) return;
90
+ c._switchHistory = c._switchHistory || new Map();
91
+ c._switchHistory.set(nodeName, Date.now());
92
+ }
93
+
94
+ /**
95
+ * 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
96
+ * 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
97
+ */
98
+ _isShellPage(h) {
99
+ return (
100
+ !!h && !h.error && h.httpCode === 200 && h.numRedirects === 0 && !h.title
101
+ );
102
+ }
103
+
104
+ /**
105
+ * 候选排序(切换历史感知,纯时间轮换):
106
+ * 业务级封禁(TikTok 风控)探针无法感知,用"最近切换时间"作为唯一可靠信号,
107
+ * 让 failover/巡检在候选池内公平轮换,避免少数节点间反复横跳。
108
+ * 排序规则:
109
+ * - 从未切过的节点排最前(保持 delay 原序,新入池节点优先尝试)
110
+ * - 切过的节点按切换时间升序沉底(最早切换的先试,最近切换的最后试 = 后进后出)
111
+ * 轮完一圈自然回访最早切过的节点(等于重新评估),无需时间窗口过期。
112
+ */
113
+ _sortCandidates(nodes, c) {
114
+ const hist = c && c._switchHistory;
115
+ if (!hist || !hist.size || !Array.isArray(nodes)) return nodes;
116
+ return [...nodes].sort((a, b) => {
117
+ const aLast = hist.get(a) || 0;
118
+ const bLast = hist.get(b) || 0;
119
+ if (aLast && bLast) return aLast - bLast; // 都切过:最早切换的先试
120
+ if (aLast) return 1; // 仅 a 切过:沉底
121
+ if (bLast) return -1;
122
+ return 0; // 都未切过:保持 delay 原序(Array.sort 稳定排序)
123
+ });
124
+ }
125
+
87
126
  /** 将全局 api/checker 上下文指向指定控制器 */
88
127
  _useController(c) {
89
128
  api.configure({ port: c.port, secret: c.secret });
@@ -271,7 +310,7 @@ export class ClashDaemon {
271
310
  const node = await api
272
311
  .getCurrentNode(checker.CONFIG.groupName)
273
312
  .catch(() => "?");
274
- const h = checker.checkNodeHealth();
313
+ let h = checker.checkNodeHealth();
275
314
  this._writeHealth(c, {
276
315
  node,
277
316
  healthy: h.healthy,
@@ -290,6 +329,45 @@ export class ClashDaemon {
290
329
  return;
291
330
  }
292
331
 
332
+ // 空壳页(TikTok SSR 降级响应):链路可用、浏览器会自行渲染,
333
+ // 切换无收益且会打断业务连接,不触发切换
334
+ if (this._isShellPage(h)) {
335
+ c._apiFailCount = 0;
336
+ this._log(
337
+ `~ [${c.proxyPort}] 空壳页(降级响应,链路正常),不切换 (${(h.sizeDownload / 1024).toFixed(0)}KB ${h.timeTotal.toFixed(1)}s)`,
338
+ );
339
+ return this._writeHealth(c, {
340
+ node,
341
+ healthy: true,
342
+ httpCode: h.httpCode,
343
+ timeTotal: h.timeTotal,
344
+ sizeDownload: h.sizeDownload,
345
+ title: h.title,
346
+ shellPage: true,
347
+ });
348
+ }
349
+
350
+ // 真异常先复测一次:过滤瞬时抖动(截断/丢包),连续异常才进入切换
351
+ const firstReason =
352
+ h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
353
+ await new Promise((r) => setTimeout(r, 3000));
354
+ const hr = checker.checkNodeHealth();
355
+ if (hr.healthy || this._isShellPage(hr)) {
356
+ this._log(
357
+ `~ [${c.proxyPort}] 复测恢复(首次异常: ${firstReason}),不切换`,
358
+ );
359
+ return this._writeHealth(c, {
360
+ node,
361
+ healthy: true,
362
+ httpCode: hr.httpCode,
363
+ timeTotal: hr.timeTotal,
364
+ sizeDownload: hr.sizeDownload,
365
+ title: hr.title,
366
+ shellPage: this._isShellPage(hr) ? true : undefined,
367
+ });
368
+ }
369
+ h = hr; // 复测仍异常,以最新数据为准
370
+
293
371
  const reason =
294
372
  h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
295
373
  const { nodes } = checker.loadHealthyList();
@@ -320,17 +398,20 @@ export class ClashDaemon {
320
398
  `✗ [${c.proxyPort}] 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`,
321
399
  );
322
400
  // 逐个切换候选 + 内容验证,第一个满足即采用
401
+ // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
402
+ const ordered = this._sortCandidates(nodes, c);
323
403
  let target = null;
324
404
  let h2 = null;
325
- for (const cand of nodes) {
405
+ for (const cand of ordered) {
326
406
  if (cand === node) continue;
327
407
  this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand} [原因: ${reason}]`);
328
408
  if (!(await checker.switchToNode(cand))) {
329
409
  this._log(` ↺ ${cand} 切换失败,下一个`);
330
410
  continue;
331
411
  }
412
+ this._markSwitch(c, cand);
332
413
  h2 = checker.checkNodeHealth();
333
- if (h2.healthy) {
414
+ if (h2.healthy || this._isShellPage(h2)) {
334
415
  target = cand;
335
416
  break;
336
417
  }
@@ -340,6 +421,7 @@ export class ClashDaemon {
340
421
  if (!target) {
341
422
  // 全部候选不可用,恢复原节点
342
423
  await checker.switchToNode(node).catch(() => {});
424
+ this._markSwitch(c, node);
343
425
  await api.closeAllConnections().catch(() => {});
344
426
  this._log(
345
427
  `✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
@@ -428,6 +510,13 @@ export class ClashDaemon {
428
510
  });
429
511
  const elapsed = ((Date.now() - start) / 1000).toFixed(0);
430
512
  checker.saveHealthyList(r.nodes);
513
+ // 清理切换历史中已不在候选集的节点,防止 Map 无限增长
514
+ if (c._switchHistory && c._switchHistory.size) {
515
+ const keep = new Set(r.nodes);
516
+ for (const k of [...c._switchHistory.keys()]) {
517
+ if (!keep.has(k)) c._switchHistory.delete(k);
518
+ }
519
+ }
431
520
  c._meta.lastBenchmark = new Date().toISOString();
432
521
  c._meta.healthyNodesTotal = r.total;
433
522
  c._meta.healthyNodesCount = r.nodes.length;
@@ -489,6 +578,7 @@ export class ClashDaemon {
489
578
  lastSwitch: `manual → ${cmd.node} (失败)`,
490
579
  });
491
580
  }
581
+ this._markSwitch(c, cmd.node);
492
582
  const h = checker.checkNodeHealth();
493
583
  // 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
494
584
  await api.closeAllConnections().catch(() => {});
@@ -551,17 +641,20 @@ export class ClashDaemon {
551
641
  return;
552
642
  }
553
643
  // 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
644
+ // 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
645
+ const ordered = this._sortCandidates(nodes, c);
554
646
  let target = null;
555
647
  let h2 = null;
556
- for (const cand of nodes) {
648
+ for (const cand of ordered) {
557
649
  if (cand === node) continue;
558
650
  this._log(`↺ 尝试候选: ${cand}`);
559
651
  if (!(await checker.switchToNode(cand))) {
560
652
  this._log(` ↺ ${cand} 切换失败,下一个`);
561
653
  continue;
562
654
  }
655
+ this._markSwitch(c, cand);
563
656
  h2 = checker.checkNodeHealth();
564
- if (h2.healthy) {
657
+ if (h2.healthy || this._isShellPage(h2)) {
565
658
  target = cand;
566
659
  break;
567
660
  }
@@ -569,6 +662,7 @@ export class ClashDaemon {
569
662
  }
570
663
  if (!target) {
571
664
  await checker.switchToNode(node).catch(() => {});
665
+ this._markSwitch(c, node);
572
666
  await api.closeAllConnections().catch(() => {});
573
667
  this._log(
574
668
  `✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
package/src/cli/open.js CHANGED
@@ -90,8 +90,8 @@ export async function handleOpen(parsed) {
90
90
 
91
91
  browser = await ensureBrowserReady(cdpOptions);
92
92
 
93
- // Ctrl+C 关闭浏览器
94
- process.on("SIGINT", async () => {
93
+ // Ctrl+C 关闭浏览器:优雅断开优先(超时保护),强杀进程兜底,最后强制退出
94
+ const shutdown = async () => {
95
95
  console.error("\n正在关闭浏览器...");
96
96
  // 先优雅断开 CDP 连接(带 3s 超时保护):进程已被强杀时 close() 会挂起等 close 事件
97
97
  try {
@@ -109,7 +109,8 @@ export async function handleOpen(parsed) {
109
109
  });
110
110
  console.error("浏览器已关闭");
111
111
  process.exit(0);
112
- });
112
+ };
113
+ process.on("SIGINT", shutdown);
113
114
 
114
115
  console.error("");
115
116
  console.error("浏览器已启动并连接成功");
@@ -576,22 +576,49 @@ export async function handleRefreshMini(options) {
576
576
 
577
577
  if (result.error) {
578
578
  const isBlocked = result.error.startsWith("被封:");
579
- consecutiveTaskErrors++;
579
+ // 导航打断(多为切换节点后的网络重建期)不计入连续任务错误,
580
+ // 避免"切换 → 掐断连接 → 导航打断 → 计错 → 再切换"的循环
581
+ const isNavBreak = result.error.includes(
582
+ "Execution context was destroyed",
583
+ );
584
+ if (!isNavBreak) consecutiveTaskErrors++;
580
585
  errorCount++;
581
586
 
582
- // 临时性错误:自动重试一次
587
+ // 临时性错误:自动重试
583
588
  if (result.retryable) {
584
- console.error(` [临时错误] 等待 5 秒后重试 @${username}...`);
585
- await sleep(5000);
586
- result = await processExplore(
587
- page,
588
- username,
589
- processOptions(),
590
- console.error,
591
- );
592
- if (!result.error) {
593
- consecutiveTaskErrors = 0;
594
- errorCount--;
589
+ if (isNavBreak) {
590
+ // 与被封同款渐退等待:30/40/50s,给切换后的网络重建时间
591
+ const navWaits = [30_000, 40_000, 50_000];
592
+ for (let retry = 0; retry < navWaits.length; retry++) {
593
+ console.error(
594
+ ` [网络重建] 导航打断(切换节点后),等待 ${navWaits[retry] / 1000}s 稳定后第 ${retry + 1}/${navWaits.length} 次重试 @${username}...`,
595
+ );
596
+ await sleep(navWaits[retry]);
597
+ result = await processExplore(
598
+ page,
599
+ username,
600
+ processOptions(),
601
+ console.error,
602
+ );
603
+ if (!result.error) {
604
+ consecutiveTaskErrors = 0;
605
+ errorCount--;
606
+ break;
607
+ }
608
+ }
609
+ } else {
610
+ console.error(` [临时错误] 等待 5 秒后重试 @${username}...`);
611
+ await sleep(5000);
612
+ result = await processExplore(
613
+ page,
614
+ username,
615
+ processOptions(),
616
+ console.error,
617
+ );
618
+ if (!result.error) {
619
+ consecutiveTaskErrors = 0;
620
+ errorCount--;
621
+ }
595
622
  }
596
623
  }
597
624
 
@@ -251,17 +251,16 @@ async function attemptItemListFetch(page, username, log, apiRequestUrlRef) {
251
251
  };
252
252
  }
253
253
 
254
- async function fetchUserVideosAPI(
255
- page,
256
- username,
257
- maxVideos,
258
- log,
259
- options = {},
260
- ) {
261
- log(` [API拦截] 获取 @${username} 视频 ...`);
262
-
254
+ /**
255
+ * CDP 健康检查:带超时保护,区分"页面忙"与"页面死"。
256
+ * evaluate 排队在页面主线程上,加载中的慢页面(肉眼可见转圈)10s 轮不到执行很正常,
257
+ * 此时是忙不是死:先观察等待(默认 2 轮 x 20s),慢页面通常在此期间自然加载完自愈。
258
+ * 观察后仍无响应才刷新页面(实测刷新可解除主线程死锁);刷新后页面处于最忙的加载期,
259
+ * 先缓冲等待再以更宽松的窗口重检,避免误杀正在恢复的页面。
260
+ * 全部失败抛 "CDP 连接异常(刷新后仍失败)"。
261
+ */
262
+ async function checkCdpHealth(page, options, log) {
263
263
  const {
264
- blockedRetryWaitsMs = DEFAULT_BLOCKED_RETRY_WAITS,
265
264
  cdpProbeTimeoutMs = 10000, // 单次探活超时
266
265
  cdpObserveWaitMs = 20000, // 观察等待间隔(页面转圈加载期)
267
266
  cdpObserveRounds = 2, // 观察轮数
@@ -269,11 +268,6 @@ async function fetchUserVideosAPI(
269
268
  cdpReloadProbeTimeoutMs = 20000, // 刷新后重检窗口
270
269
  } = options;
271
270
 
272
- // CDP 健康检查:带超时保护,区分"页面忙"与"页面死"。
273
- // evaluate 排队在页面主线程上,加载中的慢页面(肉眼可见转圈)10s 轮不到执行很正常,
274
- // 此时是忙不是死:先观察等待(默认 2 轮 x 20s),慢页面通常在此期间自然加载完自愈。
275
- // 观察后仍无响应才刷新页面(实测刷新可解除主线程死锁);刷新后页面处于最忙的加载期,
276
- // 先缓冲等待再以更宽松的窗口重检,避免误杀正在恢复的页面。
277
271
  const evalWithTimeout = (label, ms) =>
278
272
  Promise.race([
279
273
  page.evaluate(() => document.readyState),
@@ -285,49 +279,62 @@ async function fetchUserVideosAPI(
285
279
  ),
286
280
  ]);
287
281
 
288
- let healthy = false;
282
+ // 首探:成功(即使页面仍在加载)即认为健康
289
283
  try {
290
284
  const state = await evalWithTimeout("CDP 健康检查", cdpProbeTimeoutMs);
291
- healthy = true;
292
285
  if (state !== "complete") {
293
286
  log(` [API拦截] 健康检查通过,页面加载中 (readyState=${state})`);
294
287
  }
288
+ return;
295
289
  } catch (e) {
296
- // 观察阶段:慢页面几十秒内通常自愈,期间不做任何操作
297
290
  log(
298
291
  ` [API拦截] CDP 探活超时: ${e.message},页面可能仍在加载,观察等待...`,
299
292
  );
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
293
  }
316
294
 
317
- if (!healthy) {
318
- log(` [API拦截] 观察后仍无响应,刷新页面恢复...`);
319
- await page
320
- .reload({ waitUntil: "domcontentloaded", timeout: 30000 })
321
- .catch(() => {});
322
- await new Promise((r) => setTimeout(r, cdpReloadSettleMs));
295
+ // 观察阶段:慢页面几十秒内通常自愈,期间不做任何操作
296
+ for (let round = 1; round <= cdpObserveRounds; round++) {
297
+ await new Promise((r) => setTimeout(r, cdpObserveWaitMs));
323
298
  try {
324
- await evalWithTimeout("刷新后重检", cdpReloadProbeTimeoutMs);
325
- log(` [API拦截] 页面已恢复(刷新后)`);
299
+ await evalWithTimeout(
300
+ `观察重检 ${round}/${cdpObserveRounds}`,
301
+ cdpProbeTimeoutMs,
302
+ );
303
+ log(` [API拦截] 页面已恢复(观察 ${round} 轮后)`);
304
+ return;
326
305
  } catch (e2) {
327
- throw new Error(`CDP 连接异常(刷新后仍失败): ${e2.message}`);
306
+ log(
307
+ ` [API拦截] 观察 ${round}/${cdpObserveRounds} 后仍无响应: ${e2.message}`,
308
+ );
328
309
  }
329
310
  }
330
311
 
312
+ // 观察后仍无响应才刷新页面
313
+ log(` [API拦截] 观察后仍无响应,刷新页面恢复...`);
314
+ await page
315
+ .reload({ waitUntil: "domcontentloaded", timeout: 30000 })
316
+ .catch(() => {});
317
+ await new Promise((r) => setTimeout(r, cdpReloadSettleMs));
318
+ try {
319
+ await evalWithTimeout("刷新后重检", cdpReloadProbeTimeoutMs);
320
+ log(` [API拦截] 页面已恢复(刷新后)`);
321
+ } catch (e2) {
322
+ throw new Error(`CDP 连接异常(刷新后仍失败): ${e2.message}`);
323
+ }
324
+ }
325
+
326
+ async function fetchUserVideosAPI(
327
+ page,
328
+ username,
329
+ maxVideos,
330
+ log,
331
+ options = {},
332
+ ) {
333
+ log(` [API拦截] 获取 @${username} 视频 ...`);
334
+
335
+ await checkCdpHealth(page, options, log);
336
+
337
+ const { blockedRetryWaitsMs = DEFAULT_BLOCKED_RETRY_WAITS } = options;
331
338
  let apiRequestUrlRef = { value: null };
332
339
  const retryWaits = Array.isArray(blockedRetryWaitsMs)
333
340
  ? blockedRetryWaitsMs
@@ -70,6 +70,19 @@ function checkEdgeArgs() {
70
70
  });
71
71
  }
72
72
 
73
+ /**
74
+ * 解码 Windows 命令输出:中文系统 taskkill 输出为 GBK,
75
+ * 先按 UTF-8 解码,出现乱码替换符(U+FFFD)时改用 GBK 重解。
76
+ */
77
+ function decodeWinOutput(data) {
78
+ if (!Buffer.isBuffer(data)) return String(data ?? "").trim();
79
+ let text = new TextDecoder("utf-8").decode(data);
80
+ if (text.includes("\uFFFD")) {
81
+ text = new TextDecoder("gbk").decode(data);
82
+ }
83
+ return text.trim();
84
+ }
85
+
73
86
  /**
74
87
  * 解析 netstat -ano 输出,返回监听指定端口的 PID 列表(去重)。
75
88
  * 不依赖 WMI,适配 WMI 损坏/禁用的 Windows 机器。
@@ -89,6 +102,58 @@ export function parseNetstatListeningPids(netstatOutput, port) {
89
102
  return pids;
90
103
  }
91
104
 
105
+ /**
106
+ * Windows:按监听端口杀掉 Edge 主进程及其进程树(不依赖 WMI,
107
+ * Get-CimInstance 在 WMI 损坏/禁用的机器上查不到进程)。
108
+ * 返回后续清理 Singleton 锁文件的命令。
109
+ */
110
+ function killEdgeOnWindowsByPort(port) {
111
+ let killCmds = "";
112
+ try {
113
+ const netstatOut = execSync("netstat -ano", {
114
+ encoding: "utf-8",
115
+ timeout: 5000,
116
+ windowsHide: true,
117
+ });
118
+ const pids = parseNetstatListeningPids(netstatOut, port);
119
+ if (pids.length > 0) {
120
+ // 不重定向 stderr:taskkill 的错误信息(拒绝访问/找不到进程)需要可见
121
+ killCmds = pids.map((pid) => `taskkill /F /T /PID ${pid}`).join(" & ");
122
+ console.error(
123
+ `[killEdgeProcesses] 端口 ${port} 监听进程 PID: ${pids.join(", ")}`,
124
+ );
125
+ }
126
+ } catch (e) {
127
+ console.error(`[killEdgeProcesses] netstat 查询失败: ${e.message}`);
128
+ }
129
+
130
+ if (killCmds) {
131
+ // 同步执行并打印结果:taskkill 失败(权限/会话不匹配等)必须可见,不能静默吞掉
132
+ try {
133
+ const out = execSync(`(${killCmds})`, {
134
+ encoding: "buffer",
135
+ timeout: 15000,
136
+ windowsHide: true,
137
+ });
138
+ const lines = decodeWinOutput(out).split(/\r?\n/).filter(Boolean);
139
+ console.error(
140
+ `[killEdgeProcesses] taskkill 结果: ${lines.join(" | ") || "(无输出)"}`,
141
+ );
142
+ } catch (e) {
143
+ console.error(`[killEdgeProcesses] taskkill 失败: ${e.message}`);
144
+ const detail = decodeWinOutput(
145
+ Buffer.concat([
146
+ Buffer.isBuffer(e.stdout) ? e.stdout : Buffer.alloc(0),
147
+ Buffer.isBuffer(e.stderr) ? e.stderr : Buffer.alloc(0),
148
+ ]),
149
+ );
150
+ if (detail) console.error(`[killEdgeProcesses] 详情: ${detail}`);
151
+ }
152
+ }
153
+
154
+ return `del /f /q "%LOCALAPPDATA%\\Microsoft\\Edge\\Singleton*" 2>nul; exit 0`;
155
+ }
156
+
92
157
  function killEdgeProcesses(targetDir, port) {
93
158
  return new Promise((resolve) => {
94
159
  const platform = os.platform();
@@ -138,53 +203,11 @@ function killEdgeProcesses(targetDir, port) {
138
203
  'killall -9 "Microsoft Edge" 2>/dev/null; rm -f ~/Library/Caches/Microsoft\\ Edge/Singleton*; true';
139
204
  }
140
205
  } else if (platform === "win32") {
141
- if (port) {
142
- // 不依赖 WMI:Get-CimInstance 在 WMI 损坏/禁用的机器上查不到进程(KILLED_PIDS 为空)。
143
- // 改为 netstat 按监听端口找 PID(CDP 端口由浏览器主进程监听),taskkill /F /T 杀进程树
144
- let killCmds = "";
145
- try {
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
- // 不重定向 stderr:taskkill 的错误信息(拒绝访问/找不到进程)需要可见
154
- killCmds = pids
155
- .map((pid) => `taskkill /F /T /PID ${pid}`)
156
- .join(" & ");
157
- console.error(
158
- `[killEdgeProcesses] 端口 ${port} 监听进程 PID: ${pids.join(", ")}`,
159
- );
160
- }
161
- } catch (e) {
162
- console.error(`[killEdgeProcesses] netstat 查询失败: ${e.message}`);
163
- }
164
- if (killCmds) {
165
- // 同步执行并打印结果:taskkill 失败(权限/会话不匹配等)必须可见,不能静默吞掉
166
- const killCmd = `(${killCmds})`;
167
- try {
168
- const out = execSync(killCmd, {
169
- encoding: "utf-8",
170
- timeout: 15000,
171
- windowsHide: true,
172
- });
173
- const lines = out.trim().split(/\r?\n/).filter(Boolean);
174
- console.error(
175
- `[killEdgeProcesses] taskkill 结果: ${lines.join(" | ") || "(无输出)"}`,
176
- );
177
- } catch (e) {
178
- console.error(`[killEdgeProcesses] taskkill 失败: ${e.message}`);
179
- const detail = `${e.stdout || ""}${e.stderr || ""}`.trim();
180
- if (detail) console.error(`[killEdgeProcesses] 详情: ${detail}`);
181
- }
182
- }
183
- // 找不到监听进程 = 浏览器可能已退出,不做按进程名的兜底(避免误杀其他端口实例)
184
- command = `del /f /q "%LOCALAPPDATA%\\Microsoft\\Edge\\Singleton*" 2>nul; exit 0`;
185
- } else {
186
- command = "taskkill /F /IM msedge.exe 2>nul || exit 0";
187
- }
206
+ // 有端口:按监听端口杀进程树(找不到监听进程 = 浏览器可能已退出,
207
+ // 不做按进程名的兜底,避免误杀其他端口实例)
208
+ command = port
209
+ ? killEdgeOnWindowsByPort(port)
210
+ : "taskkill /F /IM msedge.exe 2>nul || exit 0";
188
211
  } else {
189
212
  if (targetDir) {
190
213
  const escapedDir = targetDir.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
@@ -24,7 +24,7 @@ import {
24
24
  import { writeFileSync, existsSync, readFileSync } from "fs";
25
25
  import { spawn } from "child_process";
26
26
  import { join, dirname } from "path";
27
- import { homedir } from "os";
27
+ import { homedir, tmpdir } from "os";
28
28
  import { fileURLToPath } from "url";
29
29
 
30
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -32,6 +32,36 @@ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
32
32
  const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
33
33
  const IS_WIN = process.platform === "win32";
34
34
 
35
+ // ── 升级配套(绕开 WMI)──
36
+ // 部分 Windows 机器 WMI(WinMgmt)损坏,升级器用 Get-CimInstance 查进程表会失效,
37
+ // 导致旧 cmd 窗口不关、新版存活误判。故升级链改为:
38
+ // 1. 本进程启动时设置控制台窗口标题 → 升级器按标题匹配(tasklist /V)关旧窗
39
+ // 2. 本进程启动时写存活标记文件 → 升级器读文件确认新版存活(不再枚举进程表)
40
+ const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
41
+ const WINDOW_TITLE_TAG = "tt-help-watchdog";
42
+
43
+ /** 设置控制台窗口标题(仅 Windows 交互窗口生效,ESC ]0 转义;失败静默) */
44
+ function setConsoleTitle(clientId) {
45
+ if (process.platform !== "win32") return;
46
+ try {
47
+ process.stdout.write(`\x1b]0;${WINDOW_TITLE_TAG} (${clientId})\x07`);
48
+ } catch {}
49
+ }
50
+
51
+ /** 写存活标记文件(升级器据此确认新版 watchdog 已启动,绕开 WMI) */
52
+ function writeStartedFile() {
53
+ try {
54
+ writeFileSync(
55
+ STARTED_FILE,
56
+ JSON.stringify({
57
+ pid: process.pid,
58
+ version: getVersion(),
59
+ ts: Date.now(),
60
+ }),
61
+ );
62
+ } catch {}
63
+ }
64
+
35
65
  // ── 业务日志错误统计 → 触发节点切换 ──
36
66
  // 识别采集程序日志里已有的错误分类标签(explore 等已打标),连续错误达标则切换出口节点
37
67
  const NETWORK_ERROR_PATTERNS = [
@@ -173,6 +203,9 @@ export class WatchdogAgent {
173
203
  }) {
174
204
  this.serverUrl = serverUrl.replace(/\/+$/, "");
175
205
  this.clientId = clientId;
206
+ // 升级配套:设窗口标题(升级器按标题关旧窗)+ 写存活文件(升级器做存活校验)
207
+ setConsoleTitle(clientId);
208
+ writeStartedFile();
176
209
  this.intervalMs = intervalMs;
177
210
  this.spawnDaemon = !!spawnDaemon;
178
211
  this.loginCheckIntervalMs = Math.max(
@@ -175,36 +175,45 @@ function getProcessStats(pid) {
175
175
  }
176
176
  }
177
177
 
178
+ /**
179
+ * Windows: 执行 PowerShell -Command 并返回 stdout(trim 后)。
180
+ * 统一 execSync 选项,避免各处重复配置。
181
+ */
182
+ function runPowerShell(psCommand, timeoutMs = 3000) {
183
+ return execSync(`powershell -NoProfile -Command "${psCommand}"`, {
184
+ encoding: "utf-8",
185
+ timeout: timeoutMs,
186
+ stdio: ["ignore", "pipe", "ignore"],
187
+ windowsHide: true, // Windows 隐藏控制台窗口
188
+ }).trim();
189
+ }
190
+
178
191
  // Windows: 从 cmd.exe 往下找真正的 node.exe PID(shell:true 时 cmd.exe 是中间层)
179
192
  function getChildPid(parentPid) {
180
- // 主路径:WMI 查询(原逻辑不变,WMI 正常机器零变化)
193
+ // 主路径:WMI 查询(WMI 正常机器零变化)
181
194
  try {
182
- const out = execSync(
183
- `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ParentProcessId=${parentPid}' | Where-Object { $_.Name -notlike 'conhost*' } | Select-Object -First 1 -ExpandProperty ProcessId"`,
184
- {
185
- encoding: "utf-8",
186
- timeout: 3000,
187
- stdio: ["ignore", "pipe", "ignore"],
188
- windowsHide: true, // Windows 隐藏控制台窗口
189
- },
190
- ).trim();
191
- return parseInt(out, 10) || 0;
195
+ return (
196
+ parseInt(
197
+ runPowerShell(
198
+ `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${parentPid}' | Where-Object { $_.Name -notlike 'conhost*' } | Select-Object -First 1 -ExpandProperty ProcessId`,
199
+ ),
200
+ 10,
201
+ ) || 0
202
+ );
192
203
  } catch {
193
204
  // WMI 损坏/禁用时 Get-CimInstance 直接报错,落入 fallback
194
205
  }
195
206
  // fallback:不依赖 WMI,用 Get-Process(.NET API)按启动时间找
196
207
  // cmd.exe 之后最早启动的 node.exe(watchdog spawn 后 3s 才调用,node 应已启动)
197
208
  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;
209
+ return (
210
+ parseInt(
211
+ runPowerShell(
212
+ `$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 }`,
213
+ ),
214
+ 10,
215
+ ) || 0
216
+ );
208
217
  } catch {
209
218
  return 0;
210
219
  }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { exec, spawn, execFile } from "child_process";
10
10
  import { promisify } from "util";
11
- import { readFileSync, openSync } from "fs";
11
+ import { readFileSync, openSync, unlinkSync } from "fs";
12
12
  import { fileURLToPath } from "url";
13
13
  import { dirname, join } from "path";
14
14
  import { homedir, tmpdir } from "os";
@@ -21,6 +21,12 @@ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
21
21
  const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
22
22
  const execFileP = promisify(execFile);
23
23
 
24
+ // 升级链绕开 WMI 的配套常量(与 agent.js 保持一致):
25
+ // 部分 Windows 机器 WMI(WinMgmt)损坏,Get-CimInstance 查不到进程表 →
26
+ // 关旧窗改用窗口标题匹配(tasklist /V),存活校验改用新版 watchdog 写出的标记文件
27
+ const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
28
+ const WINDOW_TITLE_TAG = "tt-help-watchdog";
29
+
24
30
  /**
25
31
  * 升级检测源:TT_HELP_REGISTRY 环境变量 > ~/.tt-help.json 的 npmRegistry > 官方 npmjs.org
26
32
  * 注意:检测源应与本机 npm install 使用的 registry 保持一致(默认 npm 自身配置)
@@ -298,22 +304,101 @@ async function waitProcessExit(pid, waitMs) {
298
304
  return false;
299
305
  }
300
306
 
301
- /** 向上遍历父链,返回 cmd.exe 祖先进程 PID(旧 watchdog 所在窗口,升级后关窗用) */
302
- function listCmdAncestorPids(rows, pid) {
303
- const byId = new Map(rows.map((r) => [r.pid, r]));
307
+ /** 解析一行 CSV(处理引号包裹的逗号/转义引号),用于解析 tasklist /FO CSV 输出 */
308
+ export function parseCsvLine(line) {
304
309
  const out = [];
305
- const seen = new Set();
306
- let cur = byId.get(pid)?.ppid;
307
- while (cur && !seen.has(cur)) {
308
- seen.add(cur);
309
- const r = byId.get(cur);
310
- if (!r) break;
311
- if (/cmd(\.exe)?\b/i.test(r.cmd || "")) out.push(cur);
312
- cur = r.ppid;
310
+ let cur = "";
311
+ let inQ = false;
312
+ for (let i = 0; i < line.length; i++) {
313
+ const c = line[i];
314
+ if (inQ) {
315
+ if (c === '"') {
316
+ if (line[i + 1] === '"') {
317
+ cur += '"';
318
+ i++;
319
+ } else {
320
+ inQ = false;
321
+ }
322
+ } else {
323
+ cur += c;
324
+ }
325
+ } else if (c === '"') {
326
+ inQ = true;
327
+ } else if (c === ",") {
328
+ out.push(cur);
329
+ cur = "";
330
+ } else {
331
+ cur += c;
332
+ }
313
333
  }
334
+ out.push(cur);
314
335
  return out;
315
336
  }
316
337
 
338
+ /**
339
+ * Windows:按窗口标题匹配 tasklist /V 输出中的控制台宿主进程 PID(绕开 WMI)。
340
+ * 窗口标题在 conhost.exe 行(Win10/11)或 cmd.exe 行(旧系统),两者都返回;
341
+ * 其余进程(含升级器 U 自身的隐藏 conhost,标题为空)不会误匹配。
342
+ */
343
+ async function listWindowPidsByTitle(titleSubstr) {
344
+ if (process.platform !== "win32") return [];
345
+ try {
346
+ const { stdout } = await execFileP(
347
+ "tasklist.exe",
348
+ ["/V", "/FO", "CSV"],
349
+ { timeout: 20000, windowsHide: true },
350
+ );
351
+ const pids = [];
352
+ for (const line of String(stdout).split(/\r?\n/)) {
353
+ if (!line.toLowerCase().includes(titleSubstr.toLowerCase())) continue;
354
+ const fields = parseCsvLine(line);
355
+ if (fields.length < 2) continue;
356
+ const name = (fields[0] || "").toLowerCase();
357
+ if (!name.includes("conhost") && !name.includes("cmd")) continue;
358
+ const pid = parseInt(fields[1], 10);
359
+ if (pid > 0 && pid !== process.pid) pids.push(pid);
360
+ }
361
+ return [...new Set(pids)];
362
+ } catch (e) {
363
+ console.error(`[upgrade-proc] tasklist 查窗口失败: ${e.message}`);
364
+ return [];
365
+ }
366
+ }
367
+
368
+ /**
369
+ * 关闭旧 watchdog 所在的 cmd 窗口(绕开 WMI):
370
+ * 旧 watchdog 启动时已把窗口标题设为含 WINDOW_TITLE_TAG 的串(agent.js),
371
+ * 此时新版窗口尚未弹出,匹配到的窗口即旧窗口 → taskkill 杀 conhost/cmd 关窗。
372
+ * 旧 watchdog 版本过旧(未设标题)或非窗口运行时匹配不到,跳过。
373
+ */
374
+ async function killOldWatchdogWindow() {
375
+ if (process.platform !== "win32") return;
376
+ const pids = await listWindowPidsByTitle(WINDOW_TITLE_TAG);
377
+ if (pids.length === 0) {
378
+ console.error(
379
+ `[upgrade-proc] 未找到标题含 "${WINDOW_TITLE_TAG}" 的窗口(旧 watchdog 非窗口运行或版本过旧未设标题),跳过关旧窗`,
380
+ );
381
+ return;
382
+ }
383
+ await killPidList(pids, "已关闭旧 cmd 窗口");
384
+ }
385
+
386
+ /** 读存活标记文件(新版 watchdog 启动后写入 {pid, version, ts}) */
387
+ function readStartedFile() {
388
+ try {
389
+ return JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
395
+ /** 删存活标记文件(spawn 新版前清掉旧 watchdog 的残留,确保读到的是新进程的) */
396
+ function deleteStartedFile() {
397
+ try {
398
+ unlinkSync(STARTED_FILE);
399
+ } catch {}
400
+ }
401
+
317
402
  /** 一次拉取全进程表 [{pid, ppid, cmd}](Windows: CIM;POSIX: ps) */
318
403
  async function snapshotAllProcesses() {
319
404
  const rows = [];
@@ -441,24 +526,13 @@ async function killPidList(pids, label) {
441
526
  return killed;
442
527
  }
443
528
 
444
- /** 枚举当前所有 watchdog 进程 PID(存活校验前后对比用;不含升级器 U 自身) */
529
+ /**
530
+ * 枚举当前所有 watchdog 进程 PID(仅 POSIX 存活校验前后对比用)。
531
+ * Windows 不再走这里——部分机器 WMI 损坏时枚举会静默返回空,已改为读
532
+ * 新版 watchdog 写出的存活标记文件(见 runUpgradeMode 第 5 步)。
533
+ */
445
534
  async function listWatchdogPids() {
446
535
  try {
447
- if (process.platform === "win32") {
448
- const ps =
449
- `Get-CimInstance Win32_Process -Filter "Name='node.exe'" ` +
450
- `| Where-Object { $_.CommandLine -match 'cli\\.js watchdog' } ` +
451
- `| Select-Object -ExpandProperty ProcessId`;
452
- const { stdout } = await execFileP(
453
- "powershell.exe",
454
- ["-NoProfile", "-Command", ps],
455
- { timeout: 15000, windowsHide: true },
456
- );
457
- return String(stdout)
458
- .split(/\r?\n/)
459
- .map((s) => parseInt(s.trim(), 10))
460
- .filter((n) => n > 0);
461
- }
462
536
  const { stdout } = await execFileP("pgrep", ["-f", "cli.js watchdog"], {
463
537
  timeout: 15000,
464
538
  });
@@ -467,9 +541,7 @@ async function listWatchdogPids() {
467
541
  .map((s) => parseInt(s.trim(), 10))
468
542
  .filter((n) => n > 0);
469
543
  } catch (e) {
470
- console.error(
471
- `[upgrader] 枚举 watchdog 进程失败(WMI 不可用?): ${e.message}`,
472
- );
544
+ console.error(`[upgrader] 枚举 watchdog 进程失败: ${e.message}`);
473
545
  return [];
474
546
  }
475
547
  }
@@ -551,11 +623,11 @@ export async function runUpgradeMode(argv) {
551
623
  // 会触发旧 watchdog 的 SIGINT → gracefulShutdown 优雅停业务。因此先向旧 watchdog
552
624
  // 的控制台发送 CTRL_C_EVENT(等效手动按键),8s 未退再强杀兜底;随后按【杀旧前】
553
625
  // 快照强杀整棵后代树(排除 U 自己;杀旧后中间层死亡会断父链,必须提前快照),
554
- // 再兜底清理「父已死」的孤儿业务进程,最后关闭旧 watchdog 所在的 cmd 窗口。
626
+ // 再兜底清理「父已死」的孤儿业务进程,最后关闭旧 watchdog 所在的 cmd 窗口
627
+ // (按窗口标题匹配 tasklist /V,绕开 WMI——部分机器 WMI 损坏时快照为空)。
555
628
  console.error(`[upgrade-proc] 通知旧 watchdog (pid=${oldPid}) 退出 ...`);
556
629
  const procSnapshot = await snapshotAllProcesses();
557
630
  const descPids = listDescendantPids(procSnapshot, oldPid, process.pid);
558
- const cmdAncestors = listCmdAncestorPids(procSnapshot, oldPid);
559
631
 
560
632
  let exited = false;
561
633
  const gracefulSent =
@@ -584,8 +656,9 @@ export async function runUpgradeMode(argv) {
584
656
  // 兜底:快照时父链已断(中间层先死)的孤儿业务进程,杀旧后按特征清理
585
657
  const orphanPids = listOrphanBusinessPids(await snapshotAllProcesses());
586
658
  await killPidList(orphanPids, "已清理孤儿业务进程");
587
- // 关闭旧 watchdog 所在的 cmd 窗口(业务已停,窗口只剩空壳提示符)
588
- await killPidList(cmdAncestors, "已关闭旧 cmd 窗口");
659
+ // 关闭旧 watchdog 所在的 cmd 窗口(业务已停,窗口只剩空壳提示符);
660
+ // 按窗口标题匹配(不依赖 WMI),旧 watchdog 已设标题含 WINDOW_TITLE_TAG
661
+ await killOldWatchdogWindow();
589
662
 
590
663
  // 4) 旧进程已退,启新版 watchdog
591
664
  // Windows:显式弹独立交互 cmd 窗口(start → cmd /k),日志直接显示在窗口,
@@ -602,9 +675,12 @@ export async function runUpgradeMode(argv) {
602
675
  process.platform === "win32"
603
676
  ? undefined
604
677
  : openLogFile("tt-help-watchdog", "upgrade-proc", "新版 watchdog 日志");
605
- // 记录启动前的 watchdog 进程集合,用于存活校验前后对比(Windows 经 start 启动时
606
- // 中间 cmd 秒退,不能检查其 PID)
607
- const beforePids = await listWatchdogPids();
678
+ // Windows 存活校验改读新版 watchdog 写出的标记文件(WMI 损坏时枚举进程表失效),
679
+ // spawn 前先删掉旧 watchdog 的残留,确保读到的是新进程写的
680
+ if (process.platform === "win32") deleteStartedFile();
681
+ // POSIX:记录启动前的 watchdog 进程集合,用于存活校验前后对比
682
+ const beforePids =
683
+ process.platform === "win32" ? [] : await listWatchdogPids();
608
684
  let child;
609
685
  try {
610
686
  child = spawnNewWatchdog(cliPath, args, logFd);
@@ -620,20 +696,49 @@ export async function runUpgradeMode(argv) {
620
696
  process.exit(1);
621
697
  }
622
698
 
623
- // 5) 存活校验:3s 后确认出现新增的 watchdog 进程;未存活则明确报错,不静默
699
+ // 5) 存活校验:3s 后确认新版 watchdog 存活;未存活则明确报错,不静默
624
700
  await new Promise((r) => setTimeout(r, 3000));
625
- const newPids = (await listWatchdogPids()).filter(
626
- (p) => !beforePids.includes(p),
627
- );
628
- if (newPids.length > 0) {
629
- console.error(
630
- `[upgrade-proc] 新版 watchdog 存活确认 (pid=${newPids.join(",")}),升级完成`,
631
- );
701
+ if (process.platform === "win32") {
702
+ // Windows:轮询存活标记文件(新版 watchdog 启动即写,含 pid/version),
703
+ // 版本匹配且 pid 可探测存活才算确认。15s 超时未出现则报错。
704
+ const deadline = Date.now() + 15_000;
705
+ let st = null;
706
+ while (Date.now() < deadline && !st) {
707
+ const cur = readStartedFile();
708
+ if (
709
+ cur &&
710
+ cur.pid > 0 &&
711
+ String(cur.version) === String(verify.version) &&
712
+ (await isProcessAlive(cur.pid))
713
+ ) {
714
+ st = cur;
715
+ }
716
+ if (!st) await new Promise((r) => setTimeout(r, 1000));
717
+ }
718
+ if (st) {
719
+ console.error(
720
+ `[upgrade-proc] 新版 watchdog 存活确认 (pid=${st.pid}, v${st.version}),升级完成`,
721
+ );
722
+ } else {
723
+ console.error(
724
+ `[upgrade-proc] 新版 watchdog 启动后未存活,请查看窗口/日志排障`,
725
+ );
726
+ process.exit(1);
727
+ }
632
728
  } else {
633
- console.error(
634
- `[upgrade-proc] 新版 watchdog 启动后未存活,请查看窗口/日志排障`,
729
+ const newPids = (await listWatchdogPids()).filter(
730
+ (p) => !beforePids.includes(p),
635
731
  );
636
- process.exit(1);
732
+ if (newPids.length > 0) {
733
+ console.error(
734
+ `[upgrade-proc] 新版 watchdog 存活确认 (pid=${newPids.join(",")}),升级完成`,
735
+ );
736
+ } else {
737
+ console.error(
738
+ `[upgrade-proc] 新版 watchdog 启动后未存活,请查看窗口/日志排障`,
739
+ );
740
+ process.exit(1);
741
+ }
637
742
  }
638
743
  process.exit(0);
639
744
  }