tt-help-cli-ycl 1.4.126 → 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.126",
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}`,
@@ -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
 
@@ -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(
@@ -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
  }