tt-help-cli-ycl 1.4.89 → 1.4.91

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.89",
3
+ "version": "1.4.91",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,4 +48,4 @@
48
48
  "devDependencies": {
49
49
  "better-sqlite3": "^12.10.0"
50
50
  }
51
- }
51
+ }
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 回归测试:节点国家配置收敛修复(2026-08-12)
4
+ *
5
+ * 背景:daemon 启动硬编码 --country US,且服务端幂等基准是"是否下发过",
6
+ * 导致 daemon 重启(崩溃自愈/升级/机器重启)回退 US 后服务端不补发 → 一直用美国节点。
7
+ *
8
+ * 运行方式(务必用隔离 TMPDIR,避免污染真实 clash-*.json / 服务端配置):
9
+ * TMPDIR=$(mktemp -d) node scripts/test-country-fix.mjs
10
+ *
11
+ * 覆盖:
12
+ * A. 服务端幂等基准 = 客户端实际生效国家(核心修复)
13
+ * - daemon 重启回退 US 后,服务端补发 JP
14
+ * - daemon 已生效 JP 时,幂等不重复下发
15
+ * - daemon 未上报时,下发
16
+ * - 多国配置且已生效时,幂等不重复下发
17
+ * B. daemon 逗号分隔多国 --country 参数解析
18
+ * C. checker normalizeCountries / filterNodesByCountry 回归
19
+ * D. 客户端 daemon 启动兜底:读 clash-health.json 上次生效国家 → join(",")
20
+ */
21
+
22
+ import { mkdtempSync, writeFileSync } from "fs";
23
+ import { tmpdir } from "os";
24
+ import { join } from "path";
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ function ok(cond, name, extra = "") {
29
+ if (cond) {
30
+ passed++;
31
+ console.log(` ✅ ${name}`);
32
+ } else {
33
+ failed++;
34
+ console.log(` ❌ ${name} ${extra}`);
35
+ }
36
+ }
37
+
38
+ // ── 隔离 HOME:避免读写真实服务端配置 ~/.tt-help-watchdog.json ──
39
+ const fakeHome = mkdtempSync(join(tmpdir(), "tt-wd-home-"));
40
+ process.env.HOME = fakeHome;
41
+ process.env.USERPROFILE = fakeHome;
42
+
43
+ function fakeRes() {
44
+ return {
45
+ status: 0,
46
+ body: null,
47
+ writeHead(code) {
48
+ this.status = code;
49
+ },
50
+ end(b) {
51
+ this.body = b;
52
+ },
53
+ };
54
+ }
55
+ async function wdCall(mod, path, method, params, body) {
56
+ const res = fakeRes();
57
+ await mod.tryHandleWatchdogRoute(path, method, params, body, res);
58
+ return { status: res.status, data: res.body ? JSON.parse(res.body) : null };
59
+ }
60
+
61
+ console.log("\n═══ A. 服务端幂等基准 = 客户端实际生效国家 ═══");
62
+ const wd = await import("../src/watch/watchdog-server.js");
63
+
64
+ // A1 配置 T1 → JP
65
+ let r = await wdCall(
66
+ wd,
67
+ "/api/watchdog/set-country-filter",
68
+ "POST",
69
+ {},
70
+ {
71
+ userId: "T1",
72
+ countries: ["JP"],
73
+ },
74
+ );
75
+ ok(
76
+ r.status === 200 && JSON.stringify(r.data.countries) === '["JP"]',
77
+ "A1 配置 T1 → JP",
78
+ JSON.stringify(r.data),
79
+ );
80
+
81
+ // A2 心跳:daemon 回退 US(重启后)→ 服务端应补发 JP(核心修复点,旧逻辑因 lastSent 幂等不会下发)
82
+ r = await wdCall(
83
+ wd,
84
+ "/api/watchdog/heartbeat",
85
+ "POST",
86
+ { clientId: "c1" },
87
+ {
88
+ config: { userId: "T1" },
89
+ clashHealth: { meta: { countryFilter: ["US"] } },
90
+ },
91
+ );
92
+ let cmds = (r.data && r.data.clashCommands) || [];
93
+ ok(
94
+ cmds.some(
95
+ (c) =>
96
+ c.action === "setCountryFilter" &&
97
+ JSON.stringify(c.countries) === '["JP"]',
98
+ ),
99
+ "A2 daemon 生效 US(重启回退) → 补发 setCountryFilter JP",
100
+ JSON.stringify(cmds),
101
+ );
102
+
103
+ // A3 心跳:daemon 已生效 JP → 幂等不重复下发
104
+ r = await wdCall(
105
+ wd,
106
+ "/api/watchdog/heartbeat",
107
+ "POST",
108
+ { clientId: "c1" },
109
+ {
110
+ config: { userId: "T1" },
111
+ clashHealth: { meta: { countryFilter: ["JP"] } },
112
+ },
113
+ );
114
+ cmds = (r.data && r.data.clashCommands) || [];
115
+ ok(
116
+ !cmds.some((c) => c.action === "setCountryFilter"),
117
+ "A3 daemon 生效 JP → 幂等不下发",
118
+ JSON.stringify(cmds),
119
+ );
120
+
121
+ // A4 心跳:daemon 未上报 clashHealth → 下发
122
+ r = await wdCall(
123
+ wd,
124
+ "/api/watchdog/heartbeat",
125
+ "POST",
126
+ { clientId: "c1" },
127
+ {
128
+ config: { userId: "T1" },
129
+ clashHealth: null,
130
+ },
131
+ );
132
+ cmds = (r.data && r.data.clashCommands) || [];
133
+ ok(
134
+ cmds.some(
135
+ (c) =>
136
+ c.action === "setCountryFilter" &&
137
+ JSON.stringify(c.countries) === '["JP"]',
138
+ ),
139
+ "A4 daemon 未上报 → 下发 setCountryFilter JP",
140
+ JSON.stringify(cmds),
141
+ );
142
+
143
+ // A5 多国配置 US+JP 且 daemon 已生效 US+JP → 幂等不下发
144
+ // 注:配置动作本身会立即入队一次下发(配置后本该生效一次),首轮心跳取走后再验证幂等
145
+ await wdCall(
146
+ wd,
147
+ "/api/watchdog/set-country-filter",
148
+ "POST",
149
+ {},
150
+ {
151
+ userId: "T2",
152
+ countries: ["US", "JP"],
153
+ },
154
+ );
155
+ r = await wdCall(
156
+ wd,
157
+ "/api/watchdog/heartbeat",
158
+ "POST",
159
+ { clientId: "c2" },
160
+ {
161
+ config: { userId: "T2" },
162
+ clashHealth: { meta: { countryFilter: ["US", "JP"] } },
163
+ },
164
+ );
165
+ cmds = (r.data && r.data.clashCommands) || [];
166
+ ok(
167
+ cmds.some(
168
+ (c) =>
169
+ c.action === "setCountryFilter" &&
170
+ JSON.stringify(c.countries) === '["US","JP"]',
171
+ ),
172
+ "A5a 配置后首次心跳取走命令",
173
+ JSON.stringify(cmds),
174
+ );
175
+ r = await wdCall(
176
+ wd,
177
+ "/api/watchdog/heartbeat",
178
+ "POST",
179
+ { clientId: "c2" },
180
+ {
181
+ config: { userId: "T2" },
182
+ clashHealth: { meta: { countryFilter: ["US", "JP"] } },
183
+ },
184
+ );
185
+ cmds = (r.data && r.data.clashCommands) || [];
186
+ ok(
187
+ !cmds.some((c) => c.action === "setCountryFilter"),
188
+ "A5 多国且已生效 → 幂等不下发",
189
+ JSON.stringify(cmds),
190
+ );
191
+
192
+ console.log("\n═══ B. daemon 逗号分隔多国 --country 解析 ═══");
193
+ const { ClashDaemon } = await import("../src/clash/daemon.js");
194
+ const checker = await import("../src/clash/checker.js");
195
+
196
+ const cases = [
197
+ ["US,JP", ["US", "JP"], "US,JP 逗号分隔 → 多国并集"],
198
+ ["JP", ["JP"], "JP 单国"],
199
+ ["us, jp", ["US", "JP"], "小写+空格归一"],
200
+ [undefined, null, "未传 country → 不过滤"],
201
+ ];
202
+ for (const [input, expect, name] of cases) {
203
+ checker.CONFIG.nodeCountryFilter = null;
204
+ new ClashDaemon({ country: input });
205
+ const got = checker.CONFIG.nodeCountryFilter;
206
+ ok(JSON.stringify(got) === JSON.stringify(expect), name, JSON.stringify(got));
207
+ }
208
+ checker.CONFIG.nodeCountryFilter = null;
209
+
210
+ console.log("\n═══ C. checker 国家过滤回归 ═══");
211
+ ok(
212
+ JSON.stringify(checker.normalizeCountries(["us", " JP", "US"])) ===
213
+ '["US","JP"]',
214
+ "C1 数组去重大写",
215
+ );
216
+ ok(
217
+ JSON.stringify(checker.normalizeCountries("jp")) === '["JP"]',
218
+ "C2 字符串大写",
219
+ );
220
+ ok(checker.normalizeCountries(null).length === 0, "C3 null → 空");
221
+ ok(checker.normalizeCountries("").length === 0, "C4 空串 → 空");
222
+ // 注意:checker.normalizeCountries 不拆分逗号(拆分在 daemon constructor),单字符串"US,JP"保持为一个元素
223
+ ok(
224
+ JSON.stringify(checker.normalizeCountries("US,JP")) === '["US,JP"]',
225
+ "C5 单字符串逗号不拆分(保持向后兼容)",
226
+ );
227
+ const nodes = ["🇺🇸美国A", "🇯🇵日本A", "🇩🇪德国A", "无旗帜节点"];
228
+ let fr = checker.filterNodesByCountry(nodes, ["US", "JP"]);
229
+ ok(
230
+ fr.filtered.length === 2 && fr.excluded === 2,
231
+ "C6 多国并集过滤(US+JP 命中 2)",
232
+ JSON.stringify(fr),
233
+ );
234
+ fr = checker.filterNodesByCountry(nodes, ["ZZ"]);
235
+ ok(
236
+ fr.filtered.length === 4 && fr.excluded === 0,
237
+ "C7 未知国家向后兼容返回全部",
238
+ );
239
+ fr = checker.filterNodesByCountry(nodes, null);
240
+ ok(fr.filtered.length === 4 && fr.excluded === 0, "C8 null 不过滤");
241
+
242
+ console.log("\n═══ D. 客户端 daemon 启动兜底(读上次生效国家)═══");
243
+ const { getClashHealth } = await import("../src/watchdog/reporter.js");
244
+ const healthPath = join(tmpdir(), "clash-health.json");
245
+
246
+ writeFileSync(
247
+ healthPath,
248
+ JSON.stringify({
249
+ meta: { countryFilter: ["JP"] },
250
+ lastCheck: new Date().toISOString(),
251
+ }),
252
+ );
253
+ let prev = getClashHealth()?.meta?.countryFilter;
254
+ ok(
255
+ Array.isArray(prev) && JSON.stringify(prev) === '["JP"]',
256
+ "D1 读到上次生效国家 JP",
257
+ JSON.stringify(prev),
258
+ );
259
+ let countryArg = Array.isArray(prev) && prev.length ? prev.join(",") : "US";
260
+ ok(countryArg === "JP", "D2 单国 join → 'JP'", countryArg);
261
+
262
+ writeFileSync(
263
+ healthPath,
264
+ JSON.stringify({
265
+ meta: { countryFilter: ["US", "JP"] },
266
+ lastCheck: new Date().toISOString(),
267
+ }),
268
+ );
269
+ prev = getClashHealth()?.meta?.countryFilter;
270
+ countryArg = Array.isArray(prev) && prev.length ? prev.join(",") : "US";
271
+ ok(countryArg === "US,JP", "D3 多国 join → 'US,JP'", countryArg);
272
+
273
+ // 无上次生效记录(clashHealth 缺失)→ 兜底 US
274
+ const nonePrev = null?.meta?.countryFilter;
275
+ const noneArg =
276
+ Array.isArray(nonePrev) && nonePrev.length ? nonePrev.join(",") : "US";
277
+ ok(noneArg === "US", "D4 无记录 → 兜底 US");
278
+
279
+ console.log(`\n结果: ${passed} 通过, ${failed} 失败`);
280
+ process.exit(failed ? 1 : 0);
@@ -25,14 +25,22 @@ function fmtCountries(v) {
25
25
  export class ClashDaemon {
26
26
  constructor(opts = {}) {
27
27
  this.intervalMs = (opts.intervalSec || 120) * 1000;
28
- this.benchmarkMs = (opts.benchmarkMin || 40) * 60 * 1000;
28
+ this.benchmarkMs = (opts.benchmarkMin || 10) * 60 * 1000; // 候选集刷新默认 10 分钟
29
29
  this.dryRun = !!opts.dryRun;
30
30
  this._running = false;
31
31
  this._timers = [];
32
32
  this._waitTimer = null; // 等待 Clash 就绪的重试定时器
33
33
  this._startedOnce = false; // 首次启动标记(banner/信号只打印注册一次)
34
34
  this._benchmarkRunning = false;
35
- const initCountries = normalizeCountries(opts.country);
35
+ // 支持逗号分隔字符串(如 "US,JP"),便于 watchdog 以 --country 透传上次生效的多国配置
36
+ let initCountriesRaw = opts.country;
37
+ if (
38
+ typeof initCountriesRaw === "string" &&
39
+ initCountriesRaw.includes(",")
40
+ ) {
41
+ initCountriesRaw = initCountriesRaw.split(",");
42
+ }
43
+ const initCountries = normalizeCountries(initCountriesRaw);
36
44
  if (initCountries.length) checker.CONFIG.nodeCountryFilter = initCountries;
37
45
  this._meta = {
38
46
  groupName: checker.CONFIG.groupName,
@@ -171,21 +179,24 @@ export class ClashDaemon {
171
179
  h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
172
180
  const { nodes } = checker.loadHealthyList();
173
181
  if (!nodes.length) {
174
- this._log(`✗ ${reason} — 候选集为空,触发 delay 初测`);
182
+ this._log(`✗ 巡检异常 [${reason}] — 候选集为空,触发 delay 初测`);
175
183
  await this._runDelayProbe();
176
184
  return;
177
185
  }
178
186
  if (this.dryRun) {
179
- this._log(`[DRY-RUN] 当前节点异常,候选: ${nodes.length} 个`);
187
+ this._log(
188
+ `[DRY-RUN] 当前节点 ${node} 异常 [${reason}],候选: ${nodes.length} 个`,
189
+ );
180
190
  return;
181
191
  }
182
192
 
193
+ this._log(`✗ 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`);
183
194
  // 逐个切换候选 + 内容验证,第一个满足即采用
184
195
  let target = null;
185
196
  let h2 = null;
186
197
  for (const cand of nodes) {
187
198
  if (cand === node) continue;
188
- this._log(`↺ 尝试候选: ${cand}`);
199
+ this._log(`↺ 尝试候选: ${cand} [原因: ${reason}]`);
189
200
  if (!(await checker.switchToNode(cand))) {
190
201
  this._log(` ↺ ${cand} 切换失败,下一个`);
191
202
  continue;
@@ -201,19 +212,19 @@ export class ClashDaemon {
201
212
  if (!target) {
202
213
  // 全部候选不可用,恢复原节点
203
214
  await checker.switchToNode(node).catch(() => {});
204
- this._log(`✗ ${reason} — 候选全部不可用,恢复原节点`);
215
+ this._log(`✗ 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`);
205
216
  ipc.writeHealth({
206
217
  node,
207
218
  healthy: false,
208
219
  error: `${reason} — 候选全部不可用`,
209
- lastSwitch: "failover: 候选全部不可用",
220
+ lastSwitch: `巡检恢复(${reason}): → ${node}`,
210
221
  meta: this._buildMeta(),
211
222
  });
212
223
  return;
213
224
  }
214
225
 
215
226
  this._log(
216
- `⚠ ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
227
+ `⚠ 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
217
228
  );
218
229
  ipc.writeHealth({
219
230
  node: target,
@@ -222,7 +233,7 @@ export class ClashDaemon {
222
233
  timeTotal: h2.timeTotal,
223
234
  sizeDownload: h2.sizeDownload,
224
235
  title: h2.title,
225
- lastSwitch: `${node} → ${target}`,
236
+ lastSwitch: `巡检异常(${reason}): ${node} → ${target}`,
226
237
  meta: this._buildMeta(),
227
238
  });
228
239
  } catch (e) {
@@ -323,12 +334,13 @@ export class ClashDaemon {
323
334
  * 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
324
335
  * 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
325
336
  */
326
- async _handleFailover() {
337
+ async _handleFailover(cmd) {
327
338
  try {
339
+ const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
328
340
  const node = await api
329
341
  .getCurrentNode(checker.CONFIG.groupName)
330
342
  .catch(() => "?");
331
- this._log(`🔄 看门狗指令: 触发故障切换 (当前: ${node})`);
343
+ this._log(`🔄 看门狗指令: 触发故障切换 [${reason}] (当前: ${node})`);
332
344
  const { nodes } = checker.loadHealthyList();
333
345
  if (!nodes.length) {
334
346
  this._log("🔄 候选集为空,触发 delay 初测");
@@ -354,16 +366,18 @@ export class ClashDaemon {
354
366
  }
355
367
  if (!target) {
356
368
  await checker.switchToNode(node).catch(() => {});
357
- this._log(`✗ failover: 候选全部不可用,恢复原节点`);
369
+ this._log(
370
+ `✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
371
+ );
358
372
  return ipc.writeHealth({
359
373
  node,
360
374
  healthy: false,
361
- error: "failover: 候选全部不可用",
362
- lastSwitch: "failover: 候选全部不可用",
375
+ error: `failover(${reason}): 候选全部不可用`,
376
+ lastSwitch: `failover恢复(${reason}): → ${node}`,
363
377
  meta: this._buildMeta(),
364
378
  });
365
379
  }
366
- this._log(`⚠ failover: ${node} → ${target}`);
380
+ this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
367
381
  ipc.writeHealth({
368
382
  node: target,
369
383
  healthy: h2.healthy,
@@ -371,7 +385,7 @@ export class ClashDaemon {
371
385
  timeTotal: h2.timeTotal,
372
386
  sizeDownload: h2.sizeDownload,
373
387
  title: h2.title,
374
- lastSwitch: `failover: ${node} → ${target}`,
388
+ lastSwitch: `${reason}: ${node} → ${target}`,
375
389
  meta: this._buildMeta(),
376
390
  });
377
391
  } catch (e) {
@@ -464,7 +478,7 @@ function _buildCmdHandlers(daemon) {
464
478
  }
465
479
  },
466
480
  switchNode: (cmd) => daemon._handleSwitchNode(cmd),
467
- failover: () => daemon._handleFailover(),
481
+ failover: (cmd) => daemon._handleFailover(cmd),
468
482
  };
469
483
  }
470
484
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * clash-daemon CLI 命令入口
3
3
  *
4
- * 用法: tt-help clash-daemon [--interval 120] [--benchmark 40] [--country US] [--dry-run]
4
+ * 用法: tt-help clash-daemon [--interval 120] [--benchmark 10] [--country US] [--dry-run]
5
5
  */
6
6
  import { ClashDaemon } from "../clash/daemon.js";
7
7
 
package/src/cli/tag.js CHANGED
@@ -6,7 +6,7 @@ import path from "path";
6
6
  import readline from "readline";
7
7
  import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
8
8
  import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
9
- import { getOrCreatePage } from "../lib/browser/page.js";
9
+ import { getOrCreatePage, isBrowserClosedError } from "../lib/browser/page.js";
10
10
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
11
11
  import {
12
12
  DEFAULT_TARGET_LOCATIONS,
@@ -48,6 +48,24 @@ function resolveTargetCountries(countries) {
48
48
  return countries || DEFAULT_SCORE_COUNTRIES;
49
49
  }
50
50
 
51
+ /**
52
+ * 判断浏览器是否已断开(用户手动关窗口 / CDP 断开)。
53
+ * 用于让采集循环(score-all / refresh)在浏览器被关闭后自动退出,避免无限重试。
54
+ */
55
+ function isBrowserDisconnected(browser, e) {
56
+ if (e && isBrowserClosedError(e)) return true;
57
+ try {
58
+ if (
59
+ browser &&
60
+ typeof browser.isConnected === "function" &&
61
+ !browser.isConnected()
62
+ ) {
63
+ return true;
64
+ }
65
+ } catch {}
66
+ return false;
67
+ }
68
+
51
69
  function formatMemoryUsage(mem = process.memoryUsage()) {
52
70
  return `rss:${(mem.rss / 1024 / 1024).toFixed(0)}MB heap:${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB ext:${(mem.external / 1024 / 1024).toFixed(0)}MB ab:${(mem.arrayBuffers / 1024 / 1024).toFixed(0)}MB`;
53
71
  }
@@ -1082,6 +1100,12 @@ export async function handleScoreAll(parsed) {
1082
1100
 
1083
1101
  try {
1084
1102
  while (true) {
1103
+ // 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
1104
+ if (isBrowserDisconnected(browser)) {
1105
+ log("🚪 检测到浏览器窗口已关闭,score-all 自动退出");
1106
+ cleanup();
1107
+ return;
1108
+ }
1085
1109
  try {
1086
1110
  // 从服务端取下一个 new 标签(同时查剩余数量)
1087
1111
  const tagsRes = await fetch(
@@ -1300,6 +1324,12 @@ export async function handleScoreAll(parsed) {
1300
1324
  await maybeRecycleForMemory();
1301
1325
  await randomDelay(3000, 7000);
1302
1326
  } catch (e) {
1327
+ // 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
1328
+ if (isBrowserDisconnected(browser, e)) {
1329
+ log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),score-all 自动退出`);
1330
+ cleanup();
1331
+ return;
1332
+ }
1303
1333
  // 区分网络错误和业务错误
1304
1334
  const isNetworkError =
1305
1335
  e.code === "ECONNREFUSED" ||
@@ -1767,6 +1797,12 @@ export async function handleTagRefresh(parsed) {
1767
1797
 
1768
1798
  try {
1769
1799
  while (true) {
1800
+ // 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
1801
+ if (isBrowserDisconnected(browser)) {
1802
+ log("🚪 检测到浏览器窗口已关闭,refresh 自动退出");
1803
+ cleanup();
1804
+ return;
1805
+ }
1770
1806
  try {
1771
1807
  // 从服务端获取下一个 productive 标签
1772
1808
  const taskRes = await fetch(
@@ -1987,6 +2023,12 @@ export async function handleTagRefresh(parsed) {
1987
2023
  await maybeRecycleForMemory();
1988
2024
  await randomDelay(3000, 7000);
1989
2025
  } catch (e) {
2026
+ // 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
2027
+ if (isBrowserDisconnected(browser, e)) {
2028
+ log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),refresh 自动退出`);
2029
+ cleanup();
2030
+ return;
2031
+ }
1990
2032
  const isNetworkError =
1991
2033
  e.code === "ECONNREFUSED" ||
1992
2034
  e.code === "ENOTFOUND" ||
package/src/lib/args.js CHANGED
@@ -665,7 +665,7 @@ function parseWatchdogArgs(args) {
665
665
 
666
666
  function parseClashDaemonArgs(args) {
667
667
  let interval = 120;
668
- let benchmark = 40;
668
+ let benchmark = 10;
669
669
  let dryRun = false;
670
670
  let country = null;
671
671
 
@@ -674,7 +674,7 @@ function parseClashDaemonArgs(args) {
674
674
  if (arg === "--interval") {
675
675
  interval = parseInt(args[++i]) || 120;
676
676
  } else if (arg === "--benchmark") {
677
- benchmark = parseInt(args[++i]) || 40;
677
+ benchmark = parseInt(args[++i]) || 10;
678
678
  } else if (arg === "--country") {
679
679
  country = args[++i] || null;
680
680
  } else if (arg === "--dry-run") {
@@ -393,7 +393,7 @@ const HELP_TEXT = [
393
393
  " 看门狗启动时会自动拉起此守护进程",
394
394
  " 选项:",
395
395
  " --interval <秒> 巡检间隔,默认 120 秒(2分钟)",
396
- " --benchmark <分钟> 候选集 delay 初测间隔,默认 40 分钟",
396
+ " --benchmark <分钟> 候选集 delay 初测间隔,默认 10 分钟",
397
397
  " --country <国家代码> 只保留指定国家节点,默认不过滤",
398
398
  " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
399
399
  " --dry-run 只检测不切换(调试用)",
@@ -330,11 +330,17 @@ async function handleHeartbeat(params, body, res) {
330
330
  const userId = (body.config && body.config.userId) || "";
331
331
  const commands = getClientCommands(userId);
332
332
 
333
- // 幂等下发节点国家配置(默认 US;配置变化时才入队,daemon 端相同则忽略)
333
+ // 幂等下发节点国家配置(默认 US)
334
+ // 幂等基准 = 客户端 daemon 实际生效国家(body.clashHealth.meta.countryFilter),而非"是否下发过"。
335
+ // 否则 daemon 重启(崩溃自愈/升级/机器重启)回退到 --country US 后,服务端会因"配置没变"而不补发,
336
+ // 导致实际一直用美国节点(生效与配置不一致)。
334
337
  const countries = getClientCountries(userId);
335
338
  const countryKey = JSON.stringify(countries);
339
+ const activeKey = JSON.stringify(
340
+ body.clashHealth?.meta?.countryFilter || null,
341
+ );
336
342
  const pending = clashCmdQueue.get(userId) || [];
337
- if (countryKey !== lastSentCountries.get(userId)) {
343
+ if (countryKey !== activeKey) {
338
344
  pending.push({ action: "setCountryFilter", countries });
339
345
  lastSentCountries.set(userId, countryKey);
340
346
  }
@@ -16,14 +16,14 @@ import {
16
16
  import { ProcessManager } from "./process-manager.js";
17
17
  import {
18
18
  getVersion,
19
- performUpgrade,
19
+ spawnUpgraderAndWait,
20
20
  isGlobalInstall,
21
21
  startUpgradeChecker,
22
22
  } from "./upgrader.js";
23
- import { writeFileSync, existsSync, readFileSync, openSync } from "fs";
23
+ import { writeFileSync, existsSync, readFileSync } from "fs";
24
24
  import { spawn } from "child_process";
25
25
  import { join, dirname } from "path";
26
- import { homedir, tmpdir } from "os";
26
+ import { homedir } from "os";
27
27
  import { fileURLToPath } from "url";
28
28
 
29
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -195,6 +195,7 @@ export class WatchdogAgent {
195
195
  this._errorTriggerCount = ERROR_TRIGGER_COUNT;
196
196
  this._errorTriggerMinMs = ERROR_MIN_MS;
197
197
  this._errorTriggerBreakMs = ERROR_BREAK_MS;
198
+ this._lastErrorTriggerKey = null; // 上次配置指纹(幂等打印用)
198
199
  }
199
200
 
200
201
  /**
@@ -303,6 +304,10 @@ export class WatchdogAgent {
303
304
  this._errorTriggerMinMs = cfg.minMinutes * 60 * 1000;
304
305
  if (cfg?.breakSeconds > 0)
305
306
  this._errorTriggerBreakMs = cfg.breakSeconds * 1000;
307
+ // 幂等打印:参数没变化就不刷屏(心跳每次都会带 errorTrigger,此前会每次心跳都打一条)
308
+ const key = `${this._errorTriggerEnabled}|${this._errorTriggerCount}|${this._errorTriggerMinMs}|${this._errorTriggerBreakMs}`;
309
+ if (key === this._lastErrorTriggerKey) return;
310
+ this._lastErrorTriggerKey = key;
306
311
  console.error(
307
312
  `[watchdog] 错误触发切换: ${this._errorTriggerEnabled ? "已启用" : "已禁用"} (连续${this._errorTriggerCount}次 / 持续${this._errorTriggerMinMs / 60000}min / 中断${this._errorTriggerBreakMs / 1000}s)`,
308
313
  );
@@ -323,6 +328,7 @@ export class WatchdogAgent {
323
328
  firstErrorAt: 0,
324
329
  lastErrorAt: 0,
325
330
  };
331
+ const hadNew = newErrors.length > 0;
326
332
  for (const line of newErrors) {
327
333
  // 错误间隔超阈值 → 连续中断,重新计数
328
334
  if (
@@ -349,6 +355,15 @@ export class WatchdogAgent {
349
355
  }
350
356
  }
351
357
  this._errorStates.set(cmdId, st);
358
+ // 动态进度:仅本轮扫描到新增错误时打印(扫描间隔 30s,天然低频不刷屏)
359
+ if (hadNew && st.consecutive > 0 && st.firstErrorAt) {
360
+ const durMs = now - st.firstErrorAt;
361
+ const durMin = Math.floor(durMs / 60000);
362
+ const durSec = Math.floor((durMs % 60000) / 1000);
363
+ console.error(
364
+ `[watchdog] 错误累计: ${cmdId} 连续 ${st.consecutive} 次 / 已持续 ${durMin}min${durSec}s (阈值: 连续${this._errorTriggerCount}次/持续${this._errorTriggerMinMs / 60000}min)`,
365
+ );
366
+ }
352
367
  }
353
368
  // 无新错误的进程:长时间无错误则重置连续计数
354
369
  for (const [cmdId, st] of this._errorStates) {
@@ -367,7 +382,10 @@ export class WatchdogAgent {
367
382
  async _triggerFailover(cmdId) {
368
383
  try {
369
384
  const ipc = await import("../clash/ipc.js");
370
- ipc.writeCommand({ action: "failover" });
385
+ ipc.writeCommand({
386
+ action: "failover",
387
+ reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
388
+ });
371
389
  console.error(`[watchdog] 已下发 failover 命令 (触发源: ${cmdId})`);
372
390
  } catch (e) {
373
391
  console.error(`[watchdog] 下发 failover 失败: ${e.message}`);
@@ -556,32 +574,39 @@ export class WatchdogAgent {
556
574
 
557
575
  this.upgradeChecker = startUpgradeChecker({
558
576
  onUpdateAvailable: async ({ current, latest }) => {
559
- console.error(`[upgrade] ${current} → ${latest}`);
560
- // 升级流程:先标记 daemon 主动关闭(避免 kill 后被崩溃自愈重新拉起旧版),再停业务与 daemon
561
- this._daemonStopping = true;
562
- this.processManager.stopAll();
563
- this._killClashDaemon();
564
-
565
- const result = await performUpgrade();
566
- if (result.success) {
567
- this.ttHelpVersion = latest;
577
+ // 简化升级:spawn 独立升级器下载,下载期间业务/心跳完全不受影响;
578
+ // 下载成功后才停业务并退出,由新版接管(避免"边停边起"与二次 taskkill 竞态)
579
+ console.error(
580
+ `[upgrade] ${current} → ${latest},启动升级程序(下载期间业务不受影响)`,
581
+ );
582
+ const outcome = await spawnUpgraderAndWait(this._buildRestartParams());
583
+ if (outcome !== "ok") {
568
584
  console.error(
569
- `[upgrade] 升级成功 (${latest}),重启 watchdog 使新版 clash daemon 生效`,
585
+ `[upgrade] 升级未完成(${outcome === "fail" ? "下载失败" : "升级程序无响应"}),保持当前版本运行`,
570
586
  );
571
- // 重启自身进程加载新代码(内部 daemon 路径优先),新进程会拉起新版 daemon
572
- this._restartSelf();
573
587
  return;
574
588
  }
575
- // 升级失败:恢复 daemon 与业务,保持当前进程运行
576
589
  console.error(
577
- `[upgrade] 失败: ${result.output.substring(0, 200)},恢复运行`,
590
+ `[upgrade] 新版本已下载,停止业务与 daemon 后退出,新版将接管`,
578
591
  );
579
- this._recoverAfterFailedUpgrade();
580
- await this._sendHeartbeat();
592
+ await this.stop();
593
+ process.exit(0);
581
594
  },
582
595
  });
583
596
  }
584
597
 
598
+ /** 构建新版 watchdog 重启所需参数(透传给独立升级器) */
599
+ _buildRestartParams() {
600
+ return {
601
+ serverUrl: this.serverUrl,
602
+ clientId: this.clientId,
603
+ intervalSec: Math.max(5, Math.round(this.intervalMs / 1000)),
604
+ spawnDaemon: this.spawnDaemon,
605
+ interactive: !!process.stdout.isTTY,
606
+ oldPid: process.pid,
607
+ };
608
+ }
609
+
585
610
  /**
586
611
  * 启动 clash daemon 子进程
587
612
  * daemon 负责代理节点健康巡检和故障切换,watchdog 不阻塞等待
@@ -597,12 +622,23 @@ export class WatchdogAgent {
597
622
  }
598
623
 
599
624
  console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
625
+ // 上次生效的国家优先(从 daemon 写入的 clash-health.json 读取),避免重启回退到硬编码 US
626
+ // 服务端心跳会按"实际生效 vs 配置"收敛,这里作为下发前窗口/服务端不可达时的兜底
627
+ let countryArg = "US";
600
628
  try {
601
- const child = spawn(process.execPath, [daemonPath, "--country", "US"], {
602
- stdio: ["ignore", "pipe", "pipe"],
603
- detached: false,
604
- windowsHide: true, // Windows 隐藏控制台窗口
605
- });
629
+ const prev = getClashHealth()?.meta?.countryFilter;
630
+ if (Array.isArray(prev) && prev.length) countryArg = prev.join(",");
631
+ } catch {}
632
+ try {
633
+ const child = spawn(
634
+ process.execPath,
635
+ [daemonPath, "--country", countryArg],
636
+ {
637
+ stdio: ["ignore", "pipe", "pipe"],
638
+ detached: false,
639
+ windowsHide: true, // Windows 隐藏控制台窗口
640
+ },
641
+ );
606
642
 
607
643
  child.stdout?.on("data", (d) => {
608
644
  // daemon 日志通过 stderr 输出,stdout 静默
@@ -695,110 +731,6 @@ export class WatchdogAgent {
695
731
  this.clashDaemon = null;
696
732
  }
697
733
 
698
- /**
699
- * 升级后重启自身进程(加载新代码)
700
- * 用 cli.js watchdog 子进程替换当前进程,保证新版本内部 daemon 路径逻辑生效。
701
- * 带存活确认与兜底:新进程确认存活后才退出旧进程;启动失败则回退保持旧进程运行。
702
- */
703
- _restartSelf() {
704
- const cliPath = join(__dirname, "..", "..", "cli.js");
705
- const args = ["watchdog"];
706
- if (this.serverUrl) args.push("--server", this.serverUrl);
707
- if (this.clientId) args.push("--client-id", this.clientId);
708
- if (!this.spawnDaemon) args.push("--no-daemon");
709
- args.push(
710
- "--interval",
711
- String(Math.max(5, Math.round(this.intervalMs / 1000))),
712
- );
713
-
714
- // 交互式终端(用户在 cmd 窗口里手动跑 tt-help watchdog):
715
- // 升级重启后接管原控制台窗口继续显示(stdio inherit + 不 detached),窗口不消失
716
- const interactive = !!process.stdout.isTTY;
717
-
718
- // 非交互(计划任务/脚本/无人值守)时,新进程日志重定向到文件,崩溃有据可查
719
- let logFd = undefined;
720
- try {
721
- const logPath = join(
722
- tmpdir(),
723
- `tt-help-watchdog-restart-${Date.now()}.log`,
724
- );
725
- logFd = openSync(logPath, "a");
726
- console.error(`[upgrade] 新进程日志: ${logPath}`);
727
- } catch {}
728
- const out = logFd ?? "ignore";
729
-
730
- let child;
731
- try {
732
- child = spawn(process.execPath, [cliPath, ...args], {
733
- // 交互式:继承原 cmd 窗口一直显示;非交互:后台运行 + 日志文件
734
- detached: !interactive,
735
- stdio: interactive ? "inherit" : ["ignore", out, out],
736
- windowsHide: !interactive, // 交互时不隐藏,保留可见窗口
737
- });
738
- } catch (e) {
739
- console.error(`[upgrade] 重启自身失败: ${e.message},保持旧进程运行`);
740
- this._recoverAfterFailedUpgrade();
741
- return;
742
- }
743
- // 非交互后台进程独立存活(父进程退出不影响);交互式继承窗口无需 unref
744
- if (!interactive) child.unref();
745
-
746
- // 存活确认:轮询新进程是否正常存活,确认后才退出旧进程
747
- let exited = false;
748
- let exitCode = null;
749
- let exitSignal = null;
750
- child.once("exit", (c, s) => {
751
- exited = true;
752
- exitCode = c;
753
- exitSignal = s;
754
- });
755
-
756
- console.error(
757
- `[upgrade] 新版 watchdog 已启动 (pid=${child.pid}),等待存活确认...`,
758
- );
759
- let attempts = 0;
760
- const confirm = () => {
761
- attempts++;
762
- if (exited) {
763
- console.error(
764
- `[upgrade] 新版 watchdog 启动后即退出 (code=${exitCode}, signal=${exitSignal}),回退保持旧进程`,
765
- );
766
- this._recoverAfterFailedUpgrade();
767
- return;
768
- }
769
- try {
770
- process.kill(child.pid, 0); // 抛错说明进程已不存在
771
- } catch {
772
- console.error(
773
- `[upgrade] 新版 watchdog 进程不存在 (pid=${child.pid}),回退保持旧进程`,
774
- );
775
- this._recoverAfterFailedUpgrade();
776
- return;
777
- }
778
- if (attempts >= 6) {
779
- console.error(`[upgrade] 新版 watchdog 存活确认,旧进程退出`);
780
- this.stop();
781
- setTimeout(() => process.exit(0), 500);
782
- return;
783
- }
784
- setTimeout(confirm, 1000);
785
- };
786
- setTimeout(confirm, 1000);
787
- }
788
-
789
- /**
790
- * 升级/重启失败后的回退恢复:重新拉起 daemon 并同步业务进程
791
- */
792
- _recoverAfterFailedUpgrade() {
793
- this._daemonStopping = false;
794
- if (this.spawnDaemon && !this.clashDaemon) {
795
- console.error(`[upgrade] 恢复 clash daemon...`);
796
- this._spawnClashDaemon();
797
- }
798
- console.error(`[upgrade] 恢复业务进程(等待心跳同步命令)...`);
799
- this._sendHeartbeat().catch(() => {});
800
- }
801
-
802
734
  /**
803
735
  * 信号处理
804
736
  */
@@ -15,18 +15,34 @@ export async function analyzeProcessLogs(processLogs, llmFetch) {
15
15
  const results = [];
16
16
  for (const proc of processLogs) {
17
17
  try {
18
- console.error(`[llm-analyzer] 分析 ${proc.cmdId.substring(0, 40)}... (${proc.logs.length} 行日志)`);
18
+ console.error(
19
+ `[llm-analyzer] 分析 ${proc.cmdId.substring(0, 40)}... (${proc.logs.length} 行日志)`,
20
+ );
19
21
  const content = await llmFetch({
20
- messages: [{ role: "user", content: buildPrompt(proc.cmdId, proc.logs.slice(-200).join("\n"), proc.uptimeSeconds, proc.restartCount) }],
22
+ messages: [
23
+ {
24
+ role: "user",
25
+ content: buildPrompt(
26
+ proc.cmdId,
27
+ proc.logs.slice(-200).join("\n"),
28
+ proc.uptimeSeconds,
29
+ proc.restartCount,
30
+ ),
31
+ },
32
+ ],
21
33
  max_tokens: 256,
22
34
  temperature: 0.1,
23
35
  enable_thinking: false,
24
36
  });
25
37
  const parsed = parseResponse(content);
26
- console.error(`[llm-analyzer] ${proc.cmdId.substring(0, 30)} → ${parsed.severity}: ${parsed.summary}`);
38
+ console.error(
39
+ `[llm-analyzer] ${proc.cmdId.substring(0, 30)} → ${parsed.severity}: ${parsed.summary}`,
40
+ );
27
41
  results.push({ cmdId: proc.cmdId, ...parsed });
28
42
  } catch (e) {
29
- console.error(`[llm-analyzer] ${proc.cmdId.substring(0, 30)} 失败: ${e.message}`);
43
+ console.error(
44
+ `[llm-analyzer] ${proc.cmdId.substring(0, 30)} 失败: ${e.message}`,
45
+ );
30
46
  }
31
47
  }
32
48
  return results;
@@ -39,21 +55,32 @@ async function createStandaloneFetch() {
39
55
  const { existsSync, readFileSync } = await import("fs");
40
56
  const { join } = await import("path");
41
57
  const { homedir } = await import("os");
42
- const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
58
+ const homeDir =
59
+ homedir() || process.env.HOME || process.env.USERPROFILE || "";
43
60
  let fileCfg = {};
44
61
  try {
45
62
  const cfgPath = join(homeDir, ".tt-help.json");
46
- if (existsSync(cfgPath)) fileCfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
63
+ if (existsSync(cfgPath))
64
+ fileCfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
47
65
  } catch {}
48
- const url = process.env.LLM_URL || fileCfg.llmUrl || "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
66
+ const url =
67
+ process.env.LLM_URL ||
68
+ fileCfg.llmUrl ||
69
+ "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
49
70
  const apiKey = process.env.LLM_API_KEY || fileCfg.llmApiKey || "";
50
71
  const model = process.env.LLM_MODEL || fileCfg.llmModel || "auto";
51
- if (!apiKey) { console.error("[llm-analyzer] 未配置 LLM_API_KEY"); return null; }
72
+ if (!apiKey) {
73
+ console.error("[llm-analyzer] 未配置 LLM_API_KEY");
74
+ return null;
75
+ }
52
76
  console.error(`[llm-analyzer] 客户端模式: ${model} @ ${url}`);
53
77
  return async ({ messages, max_tokens = 256, temperature = 0.1 }) => {
54
78
  const resp = await fetch(url, {
55
79
  method: "POST",
56
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
80
+ headers: {
81
+ "Content-Type": "application/json",
82
+ Authorization: `Bearer ${apiKey}`,
83
+ },
57
84
  body: JSON.stringify({ model, messages, max_tokens, temperature }),
58
85
  signal: AbortSignal.timeout(30000),
59
86
  });
@@ -72,9 +99,17 @@ async function createStandaloneFetch() {
72
99
  // ── Prompt ──
73
100
 
74
101
  function buildPrompt(cmdId, logs, uptimeSeconds, restartCount) {
75
- const uptime = uptimeSeconds >= 3600
76
- ? Math.round(uptimeSeconds / 3600) + "h"
77
- : uptimeSeconds + "s";
102
+ const uptime =
103
+ uptimeSeconds >= 3600
104
+ ? Math.round(uptimeSeconds / 3600) + "h"
105
+ : uptimeSeconds + "s";
106
+ const logText = logs || "(无日志)";
107
+ // 统计错误码 209002(TikTok 验证码/风控)出现次数,写入 prompt 避免 LLM 误判为故障
108
+ const captchaCount = logText.split("209002").length - 1;
109
+ const captchaNote =
110
+ captchaCount > 0
111
+ ? `\n背景: 日志中出现 ${captchaCount} 处错误码 209002(TikTok 验证码/风控,正常现象,程序会自动重试),判断时请忽略它们,不要当作 error,也不要在建议里写"账号被封禁/更换账号/更换IP"。`
112
+ : "";
78
113
  return `你是 TikTok 数据采集工具的运维助手。以下是进程 "${cmdId}" 的最近 10 分钟日志,请分析是否有异常。
79
114
 
80
115
  进程信息:
@@ -83,15 +118,16 @@ function buildPrompt(cmdId, logs, uptimeSeconds, restartCount) {
83
118
  - 历史重启: ${restartCount} 次
84
119
 
85
120
  最近日志:
86
- ${logs || "(无日志)"}
121
+ ${logText}${captchaNote}
87
122
 
88
123
  请返回 JSON(仅 JSON,无其他内容):
89
124
  {"severity":"ok"|"warn"|"error","summary":"一句话中文总结,不超过 50 字","suggestions":["建议1","建议2"]}
90
125
 
91
126
  判断标准:
92
127
  - ok: 无异常,正常运行
93
- - warn: 有偶发错误(验证码、403、超时 < 3次)或内存偏高
94
- - error: 严重错误(连续崩溃、登录失效、端口占用、大量 403)
128
+ - warn: 有偶发错误(403、超时 < 3次)或内存偏高
129
+ - error: 严重错误(连续崩溃、登录失效、端口占用、大量 403/超时)
130
+ - 重要: 错误码 209002 = 验证码/风控,是正常限流,不是故障,不算 error;只有真正的登录失效/崩溃/端口占用才算 error
95
131
  - 每个 suggestions 是简洁可操作的建议,不超过 3 条`;
96
132
  }
97
133
 
@@ -100,15 +136,31 @@ ${logs || "(无日志)"}
100
136
  function parseResponse(content) {
101
137
  try {
102
138
  const parsed = JSON.parse(content);
103
- return { severity: parsed.severity || "ok", summary: parsed.summary || "", suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.slice(0, 3) : [] };
139
+ return {
140
+ severity: parsed.severity || "ok",
141
+ summary: parsed.summary || "",
142
+ suggestions: Array.isArray(parsed.suggestions)
143
+ ? parsed.suggestions.slice(0, 3)
144
+ : [],
145
+ };
104
146
  } catch {
105
147
  const match = content.match(/\{[\s\S]*\}/);
106
148
  if (match) {
107
149
  try {
108
150
  const parsed = JSON.parse(match[0]);
109
- return { severity: parsed.severity || "ok", summary: parsed.summary || "", suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.slice(0, 3) : [] };
151
+ return {
152
+ severity: parsed.severity || "ok",
153
+ summary: parsed.summary || "",
154
+ suggestions: Array.isArray(parsed.suggestions)
155
+ ? parsed.suggestions.slice(0, 3)
156
+ : [],
157
+ };
110
158
  } catch {}
111
159
  }
112
- return { severity: "warn", summary: content.substring(0, 100), suggestions: [] };
160
+ return {
161
+ severity: "warn",
162
+ summary: content.substring(0, 100),
163
+ suggestions: [],
164
+ };
113
165
  }
114
166
  }
@@ -5,11 +5,11 @@
5
5
  * 发现新版本 → 回调通知 agent → agent 负责停进程 → 升级 → 重启。
6
6
  */
7
7
 
8
- import { exec } from "child_process";
9
- import { readFileSync } from "fs";
8
+ import { exec, spawn } from "child_process";
9
+ import { readFileSync, writeFileSync, unlinkSync, openSync } from "fs";
10
10
  import { fileURLToPath } from "url";
11
11
  import { dirname, join } from "path";
12
- import { homedir } from "os";
12
+ import { homedir, tmpdir } from "os";
13
13
 
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
15
  const PKG_PATH = join(__dirname, "..", "..", "package.json");
@@ -156,3 +156,199 @@ export function startUpgradeChecker({
156
156
  },
157
157
  };
158
158
  }
159
+
160
+ // ══════════════════════════════════════════════
161
+ // 独立升级器(直接运行模式)
162
+ // ══════════════════════════════════════════════
163
+ //
164
+ // 设计目标:让升级变得简单、无竞态。
165
+ // 旧 watchdog 检测到新版 → spawn 独立升级器(detached)→ 旧 watchdog 继续正常运行(业务不断)
166
+ // 升级器:npm install → 写结果文件 → 等旧 watchdog 退出 → spawn 新版 watchdog → 自身退出
167
+ // 旧 watchdog 看到结果 ok → 停心跳/停业务/停 daemon → 退出,由新版接管。
168
+ //
169
+ // 结果文件:tmpdir/tt-help-upgrade-result.json
170
+ // - 由升级器写入 { status: "ok" | "fail", error?, at },旧 watchdog 轮询读取
171
+ //
172
+ // 用法(旧 watchdog 调用):
173
+ // node upgrader.js --upgrade --old-pid <pid> [--interactive] [--server ...] [--client-id ...] [--interval ...] [--no-daemon]
174
+
175
+ const UPGRADE_RESULT_FILE = join(tmpdir(), "tt-help-upgrade-result.json");
176
+
177
+ function writeUpgradeResult(state) {
178
+ try {
179
+ writeFileSync(
180
+ UPGRADE_RESULT_FILE,
181
+ JSON.stringify({ ...state, at: Date.now() }, null, 2),
182
+ "utf-8",
183
+ );
184
+ } catch {}
185
+ }
186
+
187
+ /** 轮询等待指定 pid 退出,返回是否已退出(超时返回 false) */
188
+ async function waitProcessExit(
189
+ pid,
190
+ { timeoutMs = 60_000, pollMs = 1000 } = {},
191
+ ) {
192
+ const start = Date.now();
193
+ while (Date.now() - start < timeoutMs) {
194
+ try {
195
+ process.kill(pid, 0); // 存在则继续等
196
+ } catch {
197
+ return true; // 已退出
198
+ }
199
+ await new Promise((r) => setTimeout(r, pollMs));
200
+ }
201
+ return false;
202
+ }
203
+
204
+ /**
205
+ * 独立升级器主流程(由旧 watchdog 以子进程方式启动)
206
+ * @param {string[]} argv - process.argv.slice(2)
207
+ */
208
+ export async function runUpgradeMode(argv) {
209
+ const get = (f) => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : undefined);
210
+ const oldPid = parseInt(get("--old-pid"), 10) || 0;
211
+ const interactive = argv.includes("--interactive");
212
+ const serverUrl = get("--server");
213
+ const clientId = get("--client-id");
214
+ const intervalSec = parseInt(get("--interval"), 10) || 15;
215
+ const noDaemon = argv.includes("--no-daemon");
216
+
217
+ console.error(
218
+ `[upgrade-proc] 开始下载新版: npm install -g ${PACKAGE}@latest ...`,
219
+ );
220
+ const result = await performUpgrade();
221
+ if (!result.success) {
222
+ const err = result.output.substring(0, 300);
223
+ console.error(`[upgrade-proc] 下载失败: ${err}`);
224
+ writeUpgradeResult({ status: "fail", error: err });
225
+ process.exit(1);
226
+ }
227
+ console.error(`[upgrade-proc] 下载完成,通知旧 watchdog 退出`);
228
+ writeUpgradeResult({ status: "ok" });
229
+
230
+ // 等旧 watchdog 退出,避免与其 stopAll 竞态(它退出后才会让新版接管业务)
231
+ if (oldPid) {
232
+ const exited = await waitProcessExit(oldPid);
233
+ if (!exited) {
234
+ console.error(
235
+ `[upgrade-proc] 等待旧 watchdog(pid=${oldPid}) 退出超时,继续启动新版`,
236
+ );
237
+ }
238
+ }
239
+
240
+ // 启动新版 watchdog(interactive 时继承控制台接管窗口;否则后台 + 日志文件)
241
+ const cliPath = join(
242
+ dirname(fileURLToPath(import.meta.url)),
243
+ "..",
244
+ "..",
245
+ "cli.js",
246
+ );
247
+ const args = ["watchdog"];
248
+ if (serverUrl) args.push("--server", serverUrl);
249
+ if (clientId) args.push("--client-id", clientId);
250
+ args.push("--interval", String(intervalSec));
251
+ if (noDaemon) args.push("--no-daemon");
252
+
253
+ let logFd = undefined;
254
+ try {
255
+ const logPath = join(tmpdir(), `tt-help-watchdog-${Date.now()}.log`);
256
+ logFd = openSync(logPath, "a");
257
+ console.error(`[upgrade-proc] 新版 watchdog 日志: ${logPath}`);
258
+ } catch {}
259
+
260
+ let child;
261
+ try {
262
+ child = spawn(process.execPath, [cliPath, ...args], {
263
+ detached: !interactive,
264
+ stdio: interactive
265
+ ? "inherit"
266
+ : ["ignore", logFd || "ignore", logFd || "ignore"],
267
+ windowsHide: !interactive,
268
+ });
269
+ if (!interactive) child.unref();
270
+ } catch (e) {
271
+ console.error(`[upgrade-proc] 新版 watchdog 启动失败: ${e.message}`);
272
+ process.exit(1);
273
+ }
274
+ console.error(`[upgrade-proc] 新版 watchdog 已启动 (pid=${child.pid})`);
275
+ process.exit(0);
276
+ }
277
+
278
+ /**
279
+ * 旧 watchdog 调用:spawn 独立升级器并轮询结果文件
280
+ * @returns {Promise<"ok"|"fail"|"timeout">}
281
+ */
282
+ export function spawnUpgraderAndWait({
283
+ serverUrl,
284
+ clientId,
285
+ intervalSec = 15,
286
+ spawnDaemon = true,
287
+ interactive = false,
288
+ oldPid = 0,
289
+ timeoutMs = 180_000,
290
+ } = {}) {
291
+ return new Promise((resolve) => {
292
+ // 清理上次结果
293
+ try {
294
+ unlinkSync(UPGRADE_RESULT_FILE);
295
+ } catch {}
296
+
297
+ const upgraderPath = fileURLToPath(import.meta.url);
298
+ const args = ["--upgrade", "--old-pid", String(oldPid || process.pid)];
299
+ if (serverUrl) args.push("--server", serverUrl);
300
+ if (clientId) args.push("--client-id", clientId);
301
+ args.push("--interval", String(intervalSec));
302
+ if (!spawnDaemon) args.push("--no-daemon");
303
+ if (interactive) args.push("--interactive");
304
+
305
+ let child;
306
+ try {
307
+ child = spawn(process.execPath, [upgraderPath, ...args], {
308
+ // 交互:升级器接管控制台(后续新版 watchdog 也继承窗口);非交互:后台 + 日志
309
+ detached: !interactive,
310
+ stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
311
+ windowsHide: !interactive,
312
+ });
313
+ if (!interactive) child.unref();
314
+ } catch (e) {
315
+ console.error(`[watchdog] 升级程序启动失败: ${e.message}`);
316
+ resolve("fail");
317
+ return;
318
+ }
319
+ child.stderr?.on("data", (d) => {
320
+ const m = d.toString().trim();
321
+ if (m) console.error(`[upgrade-proc] ${m.substring(0, 200)}`);
322
+ });
323
+
324
+ const started = Date.now();
325
+ const timer = setInterval(() => {
326
+ try {
327
+ const data = JSON.parse(readFileSync(UPGRADE_RESULT_FILE, "utf-8"));
328
+ if (data && data.status) {
329
+ clearInterval(timer);
330
+ resolve(data.status === "ok" ? "ok" : "fail");
331
+ }
332
+ } catch {}
333
+ if (Date.now() - started > timeoutMs) {
334
+ clearInterval(timer);
335
+ try {
336
+ unlinkSync(UPGRADE_RESULT_FILE);
337
+ } catch {}
338
+ console.error(
339
+ `[watchdog] 升级程序 ${timeoutMs / 1000}s 无结果,放弃等待`,
340
+ );
341
+ resolve("timeout");
342
+ }
343
+ }, 2000);
344
+ });
345
+ }
346
+
347
+ // 直接运行时自动进入独立升级器模式
348
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
349
+ runUpgradeMode(process.argv.slice(2)).catch((e) => {
350
+ console.error(`[upgrade-proc] 升级器异常: ${e.message}`);
351
+ writeUpgradeResult({ status: "fail", error: e.message });
352
+ process.exit(1);
353
+ });
354
+ }
@@ -86,6 +86,9 @@ let scraperInitPromise = null;
86
86
  let restartPromise = null; // 防重入:同一时间只允许一个 restartScraper
87
87
  let consecutiveErrors = 0;
88
88
  const MAX_CONSECUTIVE_ERRORS = 10;
89
+ // TikTok 明确返回的状态码中可重试的(验证码/风控 209002);
90
+ // 其余明确状态码(10202 被封、10221 不存在等)虽不可重试,同样属于业务结果而非 webserver 故障
91
+ const TIKTOK_RETRYABLE_NORMAL_CODES = new Set([209002]);
89
92
  let warmTimer = null;
90
93
  let lastWarmTime = 0;
91
94
  let healthTimer = null; // 每 10 分钟健康心跳日志(始终输出,防假死检测误判重启)
@@ -252,15 +255,29 @@ async function handleTiktokUser(req, res) {
252
255
  });
253
256
  }
254
257
 
258
+ if (result.error && result.statusCode) {
259
+ // TikTok 明确返回的状态码(209002 验证码、10202 被封、10221 不存在等):
260
+ // 属业务结果而非 webserver 故障,不计入连续错误、不触发 scraper 重启
261
+ const retryable = TIKTOK_RETRYABLE_NORMAL_CODES.has(result.statusCode);
262
+ apiLog(
263
+ `[webserver] user business | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | statusCode=${result.statusCode}${retryable ? " (可重试)" : ""}`,
264
+ );
265
+ recordRequest(true);
266
+ return sendJSON(res, 503, {
267
+ error: `TikTok statusCode=${result.statusCode}`,
268
+ retryable,
269
+ tiktokStatusCode: result.statusCode,
270
+ });
271
+ }
255
272
  if (result.error) {
273
+ // 无明确状态码但 error(解析/结构异常)→ 才算 webserver 故障
256
274
  console.error(
257
- `[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | TikTok statusCode=${result.statusCode}`,
275
+ `[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | parse error (无明确 statusCode)`,
258
276
  );
259
277
  recordRequest(false);
260
278
  return sendJSON(res, 502, {
261
- error: `TikTok error (statusCode=${result.statusCode})`,
279
+ error: "TikTok error (无明确 statusCode)",
262
280
  retryable: false,
263
- tiktokStatusCode: result.statusCode,
264
281
  });
265
282
  }
266
283