tt-help-cli-ycl 1.4.165 → 1.4.166

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.165",
3
+ "version": "1.4.166",
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,25 @@
1
+ // 一次性运维脚本:为 task_queue 建 claim 兜底分支加速索引(幂等)
2
+ import { createRequire } from "module";
3
+ const require = createRequire("D:/tiktok/tiktok-cli/package.json");
4
+ const Database = require("better-sqlite3");
5
+
6
+ const db = new Database("D:/tiktok/data/result.db");
7
+ db.pragma("busy_timeout = 30000");
8
+ const t0 = Date.now();
9
+ db.exec(
10
+ "CREATE INDEX IF NOT EXISTS idx_tq_claim_fallback ON task_queue(status, follower_count DESC, created_at ASC, unique_id ASC)",
11
+ );
12
+ console.log(`index created in ${Date.now() - t0}ms`);
13
+
14
+ // 验证兜底分支走新索引
15
+ const SQL = `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) NOT IN ('PL','NL','BE','DE','FR','IT','IE','ES','CZ','GR','HU','PT','AT','TH','US') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`;
16
+ const plan = db
17
+ .prepare(`EXPLAIN QUERY PLAN ${SQL}`)
18
+ .all()
19
+ .map((r) => r.detail)
20
+ .join(" | ");
21
+ console.log("plan:", plan);
22
+ const t1 = Date.now();
23
+ db.prepare(SQL).get();
24
+ console.log(`fallback branch query: ${Date.now() - t1}ms`);
25
+ db.close();
@@ -19,10 +19,8 @@ const plan = db
19
19
  .join(" | ");
20
20
  console.log("plan:", plan);
21
21
  const t1 = Date.now();
22
- db
23
- .prepare(
24
- `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) IN ('PL') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`,
25
- )
26
- .get();
22
+ db.prepare(
23
+ `SELECT * FROM task_queue WHERE status='pending' AND pinned=0 AND tt_seller=1 AND verified=0 AND UPPER(COALESCE(guessed_location,'')) IN ('PL') AND (status_code IS NULL OR status_code=0 OR status_code=209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`,
24
+ ).get();
27
25
  console.log(`branch query: ${Date.now() - t1}ms`);
28
26
  db.close();
@@ -1,11 +1,13 @@
1
1
  /**
2
- * 客户端异常监控单测(2026-08-28,2026-09-28 增补验证码路径)
2
+ * 客户端异常监控单测(2026-08-28,2026-09-28 增补验证码路径,
3
+ * 2026-09-29 改为 clientId 本机关联 —— 克隆机 hostname 撞名修复)
3
4
  *
4
5
  * 验证:
5
6
  * - detectClientError 检测函数:
6
7
  * A. reportCount > 8 且最近一次上报在 2 分钟内
7
8
  * B. captchaCount ≥ 3(webserver 验证码上报,10 分钟过期窗口内累计)
8
- * - 按 hostname 过滤本机条目;hostname 为空的旧条目不参与关联
9
+ * - 按 clientId 过滤本机条目(每台唯一);hostname 撞名的兄弟机条目、
10
+ * 无 clientId 的旧条目均不参与关联
9
11
  * - 命中 → onAlert 收到本机全部条目与命中列表;未命中 → 不回调
10
12
  * - removeErrors 逐条 DELETE 并计数
11
13
  *
@@ -13,18 +15,21 @@
13
15
  */
14
16
  import { test, mock } from "node:test";
15
17
  import assert from "node:assert/strict";
16
- import os from "os";
17
18
  import {
18
19
  detectClientError,
19
20
  ClientErrorMonitor,
20
21
  } from "../src/watchdog/client-error-monitor.js";
21
22
 
22
- const HOST = os.hostname();
23
+ // 模拟克隆机群:hostname 全部相同(事故现场),clientId 每台唯一
24
+ const CLONED_HOST = "WIN-AADJ1HTCCAQ";
25
+ const CLIENT_ID = `F-9@${CLONED_HOST}`;
26
+ const BROTHER_CLIENT_ID = `F-10@${CLONED_HOST}`;
23
27
 
24
28
  function makeErr(overrides = {}) {
25
29
  return {
26
30
  userId: "u1",
27
- hostname: HOST,
31
+ hostname: CLONED_HOST,
32
+ clientId: CLIENT_ID,
28
33
  errorType: "consecutive_blocked",
29
34
  reportCount: 10,
30
35
  timestamp: Date.now() - 30_000,
@@ -32,6 +37,22 @@ function makeErr(overrides = {}) {
32
37
  };
33
38
  }
34
39
 
40
+ function makeMonitor(opts = {}) {
41
+ let received = null;
42
+ let alerted = false;
43
+ const m = new ClientErrorMonitor({
44
+ serverUrl: "http://127.0.0.1:3000",
45
+ hostname: CLONED_HOST, // 仅展示用;关联只认 clientId
46
+ clientId: CLIENT_ID,
47
+ onAlert: async (mine, hit) => {
48
+ alerted = true;
49
+ received = { mine, hit };
50
+ },
51
+ ...opts,
52
+ });
53
+ return { m, isAlerted: () => alerted, getReceived: () => received };
54
+ }
55
+
35
56
  // ── 检测函数 ──
36
57
 
37
58
  test("D1 detectClientError:累计>8 且 2 分钟内 → 命中", () => {
@@ -66,20 +87,14 @@ test("D4 detectClientError:缺 reportCount → 不命中", () => {
66
87
 
67
88
  test("C1 detectClientError:captchaCount=3 → 命中", () => {
68
89
  assert.equal(
69
- detectClientError(
70
- makeErr({ reportCount: 0, captchaCount: 3 }),
71
- Date.now(),
72
- ),
90
+ detectClientError(makeErr({ reportCount: 0, captchaCount: 3 }), Date.now()),
73
91
  true,
74
92
  );
75
93
  });
76
94
 
77
95
  test("C2 detectClientError:captchaCount=2 不命中(阈值是 ≥3)", () => {
78
96
  assert.equal(
79
- detectClientError(
80
- makeErr({ reportCount: 0, captchaCount: 2 }),
81
- Date.now(),
82
- ),
97
+ detectClientError(makeErr({ reportCount: 0, captchaCount: 2 }), Date.now()),
83
98
  false,
84
99
  );
85
100
  });
@@ -100,10 +115,7 @@ test("C3 detectClientError:captchaCount=3 但最近上报超 2 分钟 → 不
100
115
 
101
116
  test("C4 detectClientError:reportCount 未达标 + captchaCount 达标 → 命中", () => {
102
117
  assert.equal(
103
- detectClientError(
104
- makeErr({ reportCount: 1, captchaCount: 5 }),
105
- Date.now(),
106
- ),
118
+ detectClientError(makeErr({ reportCount: 1, captchaCount: 5 }), Date.now()),
107
119
  true,
108
120
  );
109
121
  });
@@ -119,41 +131,35 @@ test("C5 监控器:验证码条目命中 → onAlert 收到命中列表", asyn
119
131
  ok: true,
120
132
  json: async () => ({ clients: [captchaHit, businessMiss] }),
121
133
  }));
122
- let received = null;
123
- const m = new ClientErrorMonitor({
124
- serverUrl: "http://127.0.0.1:3000",
125
- hostname: HOST,
126
- onAlert: async (mine, hit) => {
127
- received = { mine, hit };
128
- },
129
- });
134
+ const { m, getReceived } = makeMonitor();
130
135
  const hit = await m._poll();
131
136
  assert.equal(hit.length, 1, "仅验证码条目命中");
132
137
  assert.equal(hit[0].userId, "webserver-host");
133
- assert.equal(received.hit.length, 1);
134
- assert.equal(received.mine.length, 2, "回调同时携带本机未命中条目");
138
+ assert.equal(getReceived().hit.length, 1);
139
+ assert.equal(getReceived().mine.length, 2, "回调同时携带本机未命中条目");
135
140
  mock.restoreAll();
136
141
  });
137
142
 
138
- // ── 监控器 ──
143
+ // ── 监控器:本机关联(clientId) ──
139
144
 
140
- test("M1 按 hostname 过滤:他机条目不触发本机回调", async () => {
145
+ test("M1 按 clientId 过滤:撞名兄弟机条目不触发本机回调", async () => {
146
+ // 克隆机事故回归:F-10 与 F-9 hostname 完全相同,仅 clientId 不同
141
147
  mock.method(globalThis, "fetch", async () => ({
142
148
  ok: true,
143
149
  json: async () => ({
144
- clients: [makeErr({ hostname: "OTHER-MACHINE" })],
150
+ clients: [
151
+ makeErr({
152
+ userId: "F-10-8222-1",
153
+ clientId: BROTHER_CLIENT_ID,
154
+ captchaCount: 3,
155
+ reportCount: 0,
156
+ }),
157
+ ],
145
158
  }),
146
159
  }));
147
- let alerted = false;
148
- const m = new ClientErrorMonitor({
149
- serverUrl: "http://127.0.0.1:3000",
150
- hostname: HOST,
151
- onAlert: async () => {
152
- alerted = true;
153
- },
154
- });
160
+ const { m, isAlerted } = makeMonitor();
155
161
  const hit = await m._poll();
156
- assert.equal(alerted, false, "他机异常不应回调");
162
+ assert.equal(isAlerted(), false, "兄弟机异常不应触发本机回调/强切");
157
163
  assert.equal(hit, null, "无本机条目应返回 null");
158
164
  mock.restoreAll();
159
165
  });
@@ -161,51 +167,62 @@ test("M1 按 hostname 过滤:他机条目不触发本机回调", async () => {
161
167
  test("M2 命中:onAlert 收到本机全部条目(含未命中条目)", async () => {
162
168
  const mineHit = makeErr({ userId: "u-hit", reportCount: 10 });
163
169
  const mineMiss = makeErr({ userId: "u-miss", reportCount: 2 });
164
- const other = makeErr({ userId: "u-other", hostname: "OTHER-MACHINE" });
170
+ const other = makeErr({
171
+ userId: "u-other",
172
+ clientId: BROTHER_CLIENT_ID, // hostname 相同,clientId 不同
173
+ });
165
174
  mock.method(globalThis, "fetch", async () => ({
166
175
  ok: true,
167
176
  json: async () => ({ clients: [mineHit, mineMiss, other] }),
168
177
  }));
169
- let received = null;
170
- const m = new ClientErrorMonitor({
171
- serverUrl: "http://127.0.0.1:3000",
172
- hostname: HOST,
173
- onAlert: async (mine, hit) => {
174
- received = { mine, hit };
175
- },
176
- });
178
+ const { m, getReceived } = makeMonitor();
177
179
  const hit = await m._poll();
178
180
  assert.equal(hit.length, 1, "命中 1 条");
179
181
  assert.equal(hit[0].userId, "u-hit");
180
- assert.equal(received.mine.length, 2, "回调收到本机全部 2 条");
182
+ assert.equal(getReceived().mine.length, 2, "回调收到本机全部 2 条");
181
183
  assert.deepEqual(
182
- received.mine.map((e) => e.userId).sort(),
184
+ getReceived()
185
+ .mine.map((e) => e.userId)
186
+ .sort(),
183
187
  ["u-hit", "u-miss"],
184
188
  "回调含命中与未命中的本机条目",
185
189
  );
186
190
  mock.restoreAll();
187
191
  });
188
192
 
189
- test("M3 hostname 为空的旧条目不参与本机关联", async () => {
193
+ test("M3 无 clientId 的旧条目不参与本机关联(宁漏不误)", async () => {
190
194
  mock.method(globalThis, "fetch", async () => ({
191
195
  ok: true,
192
- json: async () => ({ clients: [makeErr({ hostname: "" })] }),
196
+ json: async () => ({
197
+ clients: [makeErr({ clientId: "", reportCount: 99 })],
198
+ }),
199
+ }));
200
+ const { m, isAlerted } = makeMonitor();
201
+ const hit = await m._poll();
202
+ assert.equal(isAlerted(), false, "旧版上报条目不应触发强切");
203
+ assert.equal(hit, null);
204
+ mock.restoreAll();
205
+ });
206
+
207
+ test("M4 未配置 clientId 的监控器:不匹配任何条目", async () => {
208
+ mock.method(globalThis, "fetch", async () => ({
209
+ ok: true,
210
+ json: async () => ({ clients: [makeErr({ reportCount: 99 })] }),
193
211
  }));
194
212
  let alerted = false;
195
213
  const m = new ClientErrorMonitor({
196
214
  serverUrl: "http://127.0.0.1:3000",
197
- hostname: HOST,
198
215
  onAlert: async () => {
199
216
  alerted = true;
200
217
  },
201
218
  });
202
219
  const hit = await m._poll();
203
- assert.equal(alerted, false, "空 hostname 旧条目不应回调");
220
+ assert.equal(alerted, false, "clientId 未配置时监控不生效");
204
221
  assert.equal(hit, null);
205
222
  mock.restoreAll();
206
223
  });
207
224
 
208
- test("M4 removeErrors:逐条 DELETE 并计数", async () => {
225
+ test("M5 removeErrors:逐条 DELETE 并计数", async () => {
209
226
  const deleted = [];
210
227
  mock.method(globalThis, "fetch", async (url, opts) => {
211
228
  if (opts && opts.method === "DELETE") {
@@ -232,20 +249,13 @@ test("M4 removeErrors:逐条 DELETE 并计数", async () => {
232
249
  mock.restoreAll();
233
250
  });
234
251
 
235
- test("M5 拉取失败静默:fetch 抛错不回调不崩溃", async () => {
252
+ test("M6 拉取失败静默:fetch 抛错不回调不崩溃", async () => {
236
253
  mock.method(globalThis, "fetch", async () => {
237
254
  throw new Error("ECONNREFUSED");
238
255
  });
239
- let alerted = false;
240
- const m = new ClientErrorMonitor({
241
- serverUrl: "http://127.0.0.1:3000",
242
- hostname: HOST,
243
- onAlert: async () => {
244
- alerted = true;
245
- },
246
- });
256
+ const { m, isAlerted } = makeMonitor();
247
257
  const hit = await m._poll();
248
- assert.equal(alerted, false);
258
+ assert.equal(isAlerted(), false);
249
259
  assert.equal(hit, null);
250
260
  mock.restoreAll();
251
261
  });
package/src/cli/tag.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  formatMemoryWithBrowser,
8
8
  } from "../lib/browser-memory.js";
9
9
  import { reportClientErrorAndFailover } from "../lib/client-error-report.js";
10
+ import { getWatchdogClientId } from "../lib/api-client.js";
10
11
  import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
11
12
  import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
12
13
  import { getOrCreatePage } from "../lib/browser/page.js";
@@ -1324,6 +1325,7 @@ export async function handleScoreAll(parsed) {
1324
1325
  stage: "tag-scoring",
1325
1326
  errorStack: "",
1326
1327
  hostname: os.hostname(),
1328
+ clientId: getWatchdogClientId(), // 看门狗按 clientId 关联本机异常
1327
1329
  }),
1328
1330
  });
1329
1331
  } catch {}
@@ -1,5 +1,9 @@
1
1
  import crypto from "node:crypto";
2
2
  import os from "os";
3
+ import { getWatchdogClientId } from "../watchdog/reporter.js";
4
+
5
+ // 供业务侧(client-error-report / tag / webserver 等)统一从本模块取机器标识
6
+ export { getWatchdogClientId };
3
7
 
4
8
  const MAX_RETRY_WAIT = 5 * 60 * 1000;
5
9
 
@@ -82,11 +86,23 @@ export function createApiClient(opts = {}) {
82
86
  // - header 放净化后的 ASCII 值(仅保证请求能发出)
83
87
  // - 原始 hostname 塞进 body(JSON 为 UTF-8,无 ByteString 限制),
84
88
  // 服务端 body.hostname 优先,看门狗仍能按原始主机名关联本机异常
89
+ // 另:克隆机群 hostname 全部相同(如 F-2~F-10 均为 WIN-AADJ1HTCCAQ),
90
+ // hostname 已不能唯一标识机器 → 同时携带 watchdog clientId(每台唯一,
91
+ // watchdog 启动时写入 ~/.tt-help.json 的 watchdogClientId 字段),
92
+ // 看门狗按 clientId 关联本机异常,hostname 仅作展示。
85
93
  if (/\/api\/error-report(\?|$)/.test(String(url))) {
86
94
  headers["X-Client-Hostname"] = os
87
95
  .hostname()
88
96
  .replace(/[^\x20-\x7e]/g, "_");
89
97
  body = { ...body, hostname: os.hostname() };
98
+ const wdClientId = getWatchdogClientId();
99
+ if (wdClientId) {
100
+ headers["X-Watchdog-Client-Id"] = wdClientId.replace(
101
+ /[^\x20-\x7e]/g,
102
+ "_",
103
+ );
104
+ body = { ...body, clientId: wdClientId };
105
+ }
90
106
  }
91
107
  const res = await fetch(url, {
92
108
  method: "POST",
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import os from "os";
7
+ import { getWatchdogClientId } from "./api-client.js";
7
8
 
8
9
  /**
9
10
  * 触发 Clash daemon 的故障切换(换出口节点),与 watchdog 的 failover 一致。
@@ -49,7 +50,9 @@ export async function reportClientErrorAndFailover({
49
50
  errorMessage: message,
50
51
  stage,
51
52
  errorStack: "",
52
- hostname: os.hostname(), // 机器标识:看门狗按 hostname 关联本机异常
53
+ hostname: os.hostname(), // 机器标识(展示用;克隆机群 hostname 重复)
54
+ // 看门狗按 clientId 关联本机异常(每台唯一,克隆机 hostname 重复不可用)
55
+ clientId: getWatchdogClientId(),
53
56
  });
54
57
  } catch {}
55
58
  await triggerClashFailover(message, log);
@@ -2563,11 +2563,15 @@ export function createStore(filePath, options = {}) {
2563
2563
  stage,
2564
2564
  errorStack,
2565
2565
  hostname,
2566
+ clientId,
2566
2567
  ) {
2567
2568
  const existing = clientErrors.get(userId);
2568
2569
  if (existing) {
2569
2570
  existing.timestamp = Date.now();
2570
2571
  if (hostname) existing.hostname = hostname;
2572
+ // clientId(机器唯一标识,如 F-9@HOST)随上报刷新;克隆机群 hostname 重复,
2573
+ // 看门狗只认 clientId 关联本机条目
2574
+ if (clientId) existing.clientId = clientId;
2571
2575
  if (errorType === "captcha") {
2572
2576
  existing.captchaCount = (existing.captchaCount || 0) + 1;
2573
2577
  if (!existing.captchaStage) existing.captchaStage = stage || "";
@@ -2586,6 +2590,7 @@ export function createStore(filePath, options = {}) {
2586
2590
  clientErrors.set(userId, {
2587
2591
  userId,
2588
2592
  hostname: hostname || "",
2593
+ clientId: clientId || "", // 机器唯一标识;空 = 旧版客户端上报,不参与看门狗触发
2589
2594
  errorType,
2590
2595
  errorMessage: errorMessage || "",
2591
2596
  errorStack: errorStack || "",
@@ -173,7 +173,6 @@ export function initDb(filePath) {
173
173
  product_review_count: "INTEGER",
174
174
  product_sold_count: "INTEGER",
175
175
  product_name: "TEXT",
176
- product_skus: "TEXT",
177
176
  user_create_time: "INTEGER",
178
177
  recent_ec_videos: "TEXT",
179
178
  });
@@ -210,7 +209,6 @@ export function initDb(filePath) {
210
209
  product_review_count: "INTEGER",
211
210
  product_sold_count: "INTEGER",
212
211
  product_name: "TEXT",
213
- product_skus: "TEXT",
214
212
  user_create_time: "INTEGER",
215
213
  recent_ec_videos: "TEXT",
216
214
  });
@@ -248,7 +246,6 @@ export function initDb(filePath) {
248
246
  product_review_count: "INTEGER",
249
247
  product_sold_count: "INTEGER",
250
248
  product_name: "TEXT",
251
- product_skus: "TEXT",
252
249
  user_create_time: "INTEGER",
253
250
  recent_ec_videos: "TEXT",
254
251
  });
@@ -451,13 +448,17 @@ export function initDb(filePath) {
451
448
 
452
449
  // ===== task_queue 索引(claimNextJob 逐级短路查询 + 监控列表) =====
453
450
  // idx_tq_claim_fast:claim 分支查询主索引(2026-09-17 实战验证:分支 7s→1ms)。
454
- // 普通复合索引,不依赖部分索引的 WHERE 表达式文本匹配(旧 4 个部分索引因
451
+ // idx_tq_claim_fallback:兜底分支索引(2026-09-28 实战验证:NOT IN 多国分支 4.2s→16ms)。
452
+ // 配置外国家(OTHER)的池子可达十几万行,NOT IN 多国无法用 loc 前导列 seek,
453
+ // 此索引跳过 loc、直接按 (status, follower_count) 排序 seek 第一行。
454
+ // 均为普通复合索引,不依赖部分索引的 WHERE 表达式文本匹配(旧 4 个部分索引因
455
455
  // pinned=0 与 COALESCE(pinned,0)=0 文本不一致、首国分支 OR 空串,从未被命中,已废弃清理)。
456
456
  const tqIndexes = [
457
457
  `CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)`,
458
458
  `CREATE INDEX IF NOT EXISTS idx_tq_claimed_by_status ON task_queue(claimed_by, status, claimed_at)`,
459
459
  `CREATE INDEX IF NOT EXISTS idx_tq_status_claimed_at ON task_queue(status, claimed_at)`,
460
460
  `CREATE INDEX IF NOT EXISTS idx_tq_claim_fast ON task_queue(status, UPPER(COALESCE(guessed_location,'')), follower_count DESC, created_at ASC, unique_id ASC)`,
461
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_fallback ON task_queue(status, follower_count DESC, created_at ASC, unique_id ASC)`,
461
462
  // 监控页面用户列表查询加速
462
463
  `CREATE INDEX IF NOT EXISTS idx_tq_list_pending ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'pending'`,
463
464
  `CREATE INDEX IF NOT EXISTS idx_tq_list_processing ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'processing'`,
@@ -2047,6 +2047,7 @@ export function startWatchServer(
2047
2047
  body.stage || "",
2048
2048
  body.errorStack || "",
2049
2049
  body.hostname || req.headers["x-client-hostname"] || "",
2050
+ body.clientId || req.headers["x-watchdog-client-id"] || "",
2050
2051
  );
2051
2052
  sendJSON(res, 200, { ok: true });
2052
2053
  } else {
@@ -13,6 +13,7 @@ import {
13
13
  getLocalIP,
14
14
  getClashHealth,
15
15
  getClashHealthChannels,
16
+ setWatchdogClientId,
16
17
  } from "./reporter.js";
17
18
  import { ProcessManager } from "./process-manager.js";
18
19
  import {
@@ -343,6 +344,14 @@ export class WatchdogAgent {
343
344
  `[watchdog] 升级: ${isGlobalInstall() ? "已启用" : "跳过(源码模式)"}`,
344
345
  );
345
346
 
347
+ // 把 clientId 写入 ~/.tt-help.json:业务进程上报异常时携带,看门狗按
348
+ // clientId(每台唯一)关联本机条目;克隆机群 hostname 重复不可用
349
+ if (!setWatchdogClientId(this.clientId)) {
350
+ console.error(
351
+ `[watchdog] ⚠️ clientId 写入 ~/.tt-help.json 失败,客户端异常监控将无法关联本机条目`,
352
+ );
353
+ }
354
+
346
355
  // 注册信号处理
347
356
  this._setupSignalHandlers();
348
357
 
@@ -353,7 +362,11 @@ export class WatchdogAgent {
353
362
  spawnDaemon: this.spawnDaemon,
354
363
  selfPid: process.pid,
355
364
  });
356
- if (takeover.agents.length || takeover.daemons.length || takeover.orphans.length) {
365
+ if (
366
+ takeover.agents.length ||
367
+ takeover.daemons.length ||
368
+ takeover.orphans.length
369
+ ) {
357
370
  console.error(
358
371
  `[watchdog] 环境初始化: 接管旧 agent ${takeover.agents.length} 个、旧 daemon ${takeover.daemons.length} 个、孤儿命令 ${takeover.orphans.length} 个`,
359
372
  );
@@ -736,7 +749,8 @@ export class WatchdogAgent {
736
749
  }
737
750
  this.clientErrorMonitor = new ClientErrorMonitor({
738
751
  serverUrl: server,
739
- hostname: osHostname(),
752
+ hostname: osHostname(), // 仅展示;关联用 clientId
753
+ clientId: this.clientId, // 唯一机器标识,克隆机 hostname 重复不可用
740
754
  intervalMs: 60_000,
741
755
  onAlert: async (mine, hit) => {
742
756
  const ids = hit.map((e) => e.userId).join(",");
@@ -856,7 +870,10 @@ export class WatchdogAgent {
856
870
  applyClientConfig(data.clientConfig);
857
871
  }
858
872
  // 服务端下发的代理订阅配置(变化才转发给 daemon 热应用;daemon 侧幂等)
859
- if (data.proxySettings && Array.isArray(data.proxySettings.subscriptions)) {
873
+ if (
874
+ data.proxySettings &&
875
+ Array.isArray(data.proxySettings.subscriptions)
876
+ ) {
860
877
  const sig = JSON.stringify(data.proxySettings.subscriptions);
861
878
  if (sig !== this._proxySubsSig) {
862
879
  this._proxySubsSig = sig;
@@ -3,13 +3,18 @@
3
3
  *
4
4
  * 职责:
5
5
  * 1. 定时拉取 watch 服务端 /api/client-errors
6
- * 2. 按 hostname 过滤出本机条目(异常条目由业务进程上报时携带 hostname)
6
+ * 2. 按 clientId 过滤出本机条目(异常条目由业务进程上报时携带 watchdog clientId)
7
7
  * 3. 逐条过独立检测函数,命中 → onAlert(本机全部条目, 命中条目)
8
8
  * 4. 提供 removeErrors 删除接口(处理完成后的清理)
9
9
  *
10
10
  * 检测函数(两条命中路径,满足任一即命中):
11
11
  * A. reportCount > 8(非验证码业务错误累计)且最近一次上报在 2 分钟内
12
12
  * B. captchaCount ≥ 3(webserver 验证码上报,服务端 10 分钟过期窗口内累计)
13
+ *
14
+ * ⚠️ 本机关联必须用 clientId(如 F-9@WIN-AADJ1HTCCAQ,每台唯一):克隆机群
15
+ * hostname 全部相同,按 hostname 关联会把兄弟机器的条目当自己的 → 误删条目 +
16
+ * 误强切(2026-09-28 F-9 条目被 F-10/F-6 抢删事故)。旧条目无 clientId 不参与
17
+ * 触发(宁漏不误:漏切由业务自带 failover + daemon 冷却兑底)。
13
18
  */
14
19
 
15
20
  import os from "os";
@@ -56,14 +61,24 @@ export class ClientErrorMonitor {
56
61
  /**
57
62
  * @param {object} opts
58
63
  * @param {string} opts.serverUrl - watch 服务端地址(~/.tt-help.json 的 server)
59
- * @param {string} [opts.hostname] - 本机 hostname(默认 os.hostname())
64
+ * @param {string} opts.clientId - 本机 watchdog clientId(唯一机器标识,必传;
65
+ * 未传则不匹配任何条目,客户端异常监控不生效)
66
+ * @param {string} [opts.hostname] - 本机 hostname(仅日志展示用,不再参与关联)
60
67
  * @param {number} [opts.intervalMs] - 拉取周期,默认 60s
61
68
  * @param {(err: object, now: number) => boolean} [opts.detect] - 检测函数,默认 detectClientError
62
- * @param {(mine: Array, hit: Array) => Promise|void} [opts.onAlert] - 命中回调
69
+ * @param {(err: object, now: number) => boolean} [opts.onAlert] - 命中回调
63
70
  */
64
- constructor({ serverUrl, hostname, intervalMs = 60_000, detect, onAlert }) {
71
+ constructor({
72
+ serverUrl,
73
+ hostname,
74
+ clientId,
75
+ intervalMs = 60_000,
76
+ detect,
77
+ onAlert,
78
+ }) {
65
79
  this.serverUrl = serverUrl ? String(serverUrl).replace(/\/+$/, "") : "";
66
- this.hostname = hostname || os.hostname();
80
+ this.hostname = hostname || os.hostname(); // 仅展示用
81
+ this.clientId = clientId || ""; // 本机关联唯一依据
67
82
  this.intervalMs = intervalMs;
68
83
  this.detect = detect || detectClientError;
69
84
  this.onAlert = onAlert || null;
@@ -105,10 +120,11 @@ export class ClientErrorMonitor {
105
120
  if (!resp.ok) return null;
106
121
  const data = await resp.json();
107
122
  const clients = Array.isArray(data.clients) ? data.clients : [];
108
- // hostname 为空的旧条目(升级前上报)不参与本机关联
109
- const mine = clients.filter(
110
- (c) => c.hostname && c.hostname === this.hostname,
111
- );
123
+ // 严格 clientId 匹配:克隆机群 hostname 重复,绝不用 hostname 关联;
124
+ // 未配置 clientId(异常)或条目无 clientId(旧版客户端上报)均不匹配
125
+ const mine = this.clientId
126
+ ? clients.filter((c) => c.clientId && c.clientId === this.clientId)
127
+ : [];
112
128
  if (mine.length === 0) return null;
113
129
  const now = Date.now();
114
130
  const hit = mine.filter((c) => this.detect(c, now));
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import os from "os";
7
- import { existsSync, readFileSync, readdirSync } from "fs";
7
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { homedir, tmpdir } from "os";
10
10
 
@@ -12,6 +12,47 @@ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
12
12
  const CONFIG_PATH = join(homeDir, ".tt-help.json");
13
13
  const CLASH_HEALTH_FILE = join(tmpdir(), "clash-health.json");
14
14
 
15
+ /**
16
+ * 写入本机 watchdog clientId 到 ~/.tt-help.json(业务进程上报异常时读取携带)
17
+ *
18
+ * 背景:克隆机群 hostname 全部相同(如 F-2~F-10 均为 WIN-AADJ1HTCCAQ),
19
+ * 看门狗按 hostname 关联「本机异常」会把兄弟机器的条目当自己的 → 互删条目、
20
+ * 误强切。clientId(如 F-9@WIN-AADJ1HTCCAQ)每台唯一,取代 hostname 做关联。
21
+ * @param {string} id - watchdog clientId(--client-id 参数值)
22
+ * @returns {boolean} 是否写入成功(失败不阻断启动,仅影响异常关联精度)
23
+ */
24
+ export function setWatchdogClientId(id) {
25
+ if (!id) return false;
26
+ try {
27
+ let cfg = {};
28
+ if (existsSync(CONFIG_PATH)) {
29
+ cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
30
+ }
31
+ if (cfg.watchdogClientId === id) return true; // 已一致,免写
32
+ cfg.watchdogClientId = id;
33
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf-8");
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 读取本机 watchdog clientId(业务进程上报异常时携带;watchdog 未启动过/未写入时返回 "")
42
+ * @returns {string}
43
+ */
44
+ export function getWatchdogClientId() {
45
+ try {
46
+ if (existsSync(CONFIG_PATH)) {
47
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
48
+ return cfg.watchdogClientId || "";
49
+ }
50
+ } catch {
51
+ // 配置不可读
52
+ }
53
+ return "";
54
+ }
55
+
15
56
  /**
16
57
  * 获取系统信息
17
58
  */
@@ -3,6 +3,7 @@ import os from "os";
3
3
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
4
4
  import { getProductInfo } from "../lib/shop-scraper.mjs";
5
5
  import { userId as configUserId } from "../lib/constants.js";
6
+ import { getWatchdogClientId } from "../lib/api-client.js";
6
7
  import { killListeningOnPort } from "../lib/port.js";
7
8
  import {
8
9
  getCurrentNode,
@@ -378,9 +379,7 @@ export async function switchClashNode(reason) {
378
379
  const current = await getCurrentNode(CLASH_GROUP_NAME).catch(() => "?");
379
380
  const nodes = await listGroupNodes(CLASH_GROUP_NAME);
380
381
  // 过滤机场信息节点(剩余流量/套餐到期等,非真实出口)
381
- const candidates = nodes.filter(
382
- (n) => n !== current && isUsableProxyNode(n),
383
- );
382
+ const candidates = nodes.filter((n) => n !== current && isUsableProxyNode(n));
384
383
  if (!candidates.length) {
385
384
  throw new Error(
386
385
  `切节点失败:组 ${CLASH_GROUP_NAME} 无候选节点 (当前 ${current})`,
@@ -808,6 +807,7 @@ async function notifyCaptchaToWatch(url, maxRetries = 3) {
808
807
  stage: "product_info",
809
808
  errorStack: url,
810
809
  hostname: os.hostname(),
810
+ clientId: getWatchdogClientId(), // 看门狗按 clientId 关联本机异常
811
811
  }),
812
812
  });
813
813
  console.error("[webserver] Captcha alert sent to watch server");