tt-help-cli-ycl 1.4.89 → 1.4.90

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.90",
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,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
  }
@@ -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}`);
@@ -597,12 +615,23 @@ export class WatchdogAgent {
597
615
  }
598
616
 
599
617
  console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
618
+ // 上次生效的国家优先(从 daemon 写入的 clash-health.json 读取),避免重启回退到硬编码 US
619
+ // 服务端心跳会按"实际生效 vs 配置"收敛,这里作为下发前窗口/服务端不可达时的兜底
620
+ let countryArg = "US";
600
621
  try {
601
- const child = spawn(process.execPath, [daemonPath, "--country", "US"], {
602
- stdio: ["ignore", "pipe", "pipe"],
603
- detached: false,
604
- windowsHide: true, // Windows 隐藏控制台窗口
605
- });
622
+ const prev = getClashHealth()?.meta?.countryFilter;
623
+ if (Array.isArray(prev) && prev.length) countryArg = prev.join(",");
624
+ } catch {}
625
+ try {
626
+ const child = spawn(
627
+ process.execPath,
628
+ [daemonPath, "--country", countryArg],
629
+ {
630
+ stdio: ["ignore", "pipe", "pipe"],
631
+ detached: false,
632
+ windowsHide: true, // Windows 隐藏控制台窗口
633
+ },
634
+ );
606
635
 
607
636
  child.stdout?.on("data", (d) => {
608
637
  // daemon 日志通过 stderr 输出,stdout 静默
@@ -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
  }
@@ -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