tt-help-cli-ycl 1.4.165 → 1.4.167

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.167",
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,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();
@@ -0,0 +1,83 @@
1
+ #!/bin/bash
2
+ # 将本地 tiktok-cli 打包部署到 Windows 采集节点(试运行,不经 npm registry)
3
+ # 用法:
4
+ # ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验
5
+ # ./scripts/deploy-node.sh tiktok-210 --bump # 先把 package.json 版本号 +1 再打包
6
+ # ./scripts/deploy-node.sh tiktok-210 --bump --restart # 安装后重启走 CDP 的命令进程
7
+ # #(watchdog 检测到退出会用新代码自动拉起)
8
+ # 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启命令
9
+ # 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210,经跳板机),BatchMode 免密可用
10
+ # 说明:
11
+ # - 无需停进程再安装:采集客户端不加载 better-sqlite3(只有 videostats/watch 服务端用),无文件锁
12
+ # - watchdog 自升级:npm 覆盖安装后守护进程经 upgrader.js 自动重启,受管命令随后重建
13
+ # - 节点上保留 tgz 安装包,回滚时 npm install -g 旧版 tgz 即可
14
+ set -euo pipefail
15
+
16
+ ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--bump] [--restart]}"
17
+ shift || true
18
+ DO_BUMP=0
19
+ DO_RESTART=0
20
+ for a in "$@"; do
21
+ case "$a" in
22
+ --bump) DO_BUMP=1 ;;
23
+ --restart) DO_RESTART=1 ;;
24
+ *) echo "未知参数: $a"; exit 1 ;;
25
+ esac
26
+ done
27
+
28
+ cd "$(dirname "$0")/.."
29
+
30
+ if [ "$DO_BUMP" = 1 ]; then
31
+ node -e "
32
+ const fs = require('fs');
33
+ const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
34
+ const m = p.version.split('.');
35
+ m[2] = Number(m[2]) + 1;
36
+ p.version = m.join('.');
37
+ fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');
38
+ console.log('版本号 ->', p.version);
39
+ "
40
+ fi
41
+
42
+ NAME=$(node -p "require('./package.json').name")
43
+ VER=$(node -p "require('./package.json').version")
44
+ TGZ="${NAME}-${VER}.tgz"
45
+
46
+ echo "==> npm pack"
47
+ rm -f "$TGZ"
48
+ TGZ=$(npm pack 2>/dev/null | tail -1)
49
+ echo "包: $TGZ ($(du -h "$TGZ" | cut -f1))"
50
+
51
+ echo "==> scp → $ALIAS"
52
+ scp -o ConnectTimeout=25 -o BatchMode=yes "$TGZ" "$ALIAS:$TGZ" 2>&1 |
53
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null
54
+ echo "已传输到 %USERPROFILE%\\$TGZ"
55
+
56
+ echo "==> 远程 npm install -g"
57
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
58
+ "npm install -g %USERPROFILE%\\$TGZ 2>&1" |
59
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
60
+ tail -3
61
+
62
+ echo "==> 校验远程版本"
63
+ REMOTE_VER=$(
64
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
65
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
66
+ tail -1 | tr -d '\r'
67
+ )
68
+ if [ "$REMOTE_VER" = "$VER" ]; then
69
+ echo "✓ 远程版本 $REMOTE_VER"
70
+ else
71
+ echo "✗ 版本不一致: 期望 $VER, 实际 '${REMOTE_VER:-空}'"
72
+ exit 1
73
+ fi
74
+
75
+ if [ "$DO_RESTART" = 1 ]; then
76
+ echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
77
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
78
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
79
+ head -10 || true
80
+ echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
81
+ fi
82
+
83
+ echo "==> 完成"
@@ -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/attach.js CHANGED
@@ -160,13 +160,26 @@ export async function handleAttach(options) {
160
160
  data = await res.json();
161
161
  } catch {}
162
162
  if (!res.ok) {
163
- // CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)
163
+ // CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)。
164
+ // 服务端冷却期返回 503 + error="CDN cooldown active, cooling IP"(server.mjs),
165
+ // 并携带 cdnCooldownSec=剩余秒数,一并识别;秒数挂到 err.serverCooldownSec
166
+ // 供外层精确对齐冷却窗口,避免冷却期整批空转猛冲。
164
167
  if (
165
168
  data &&
166
169
  typeof data.error === "string" &&
167
- data.error.includes("CDN限流")
170
+ (data.error.includes("CDN限流") ||
171
+ data.error.includes("CDN cooldown"))
168
172
  ) {
169
- throw new CDNBlockedError(data.error);
173
+ const cdnErr = new CDNBlockedError(data.error);
174
+ if (data.cdnCooldownSec > 0)
175
+ cdnErr.serverCooldownSec = data.cdnCooldownSec;
176
+ throw cdnErr;
177
+ }
178
+ if (data && data.cdnCooldownSec > 0) {
179
+ // 文案兜底:只要响应带冷却秒数字段就按 CDN 限流处理
180
+ const cdnErr = new CDNBlockedError("CDN限流 (cooldown)");
181
+ cdnErr.serverCooldownSec = data.cdnCooldownSec;
182
+ throw cdnErr;
170
183
  }
171
184
  // 其余失败(504 超时 / 503 / 502 / 500 等):抛出带状态码与错误详情的异常,
172
185
  // 让外层日志显示真实原因(如 page.goto 超时)而非笼统"未获取到用户信息"。
@@ -221,6 +234,7 @@ export async function handleAttach(options) {
221
234
  let browserRestartCount = 0;
222
235
  let taskBatchCount = 0;
223
236
  let consecutiveCdnBlocks = 0; // 连续 CDN 限流轮数(自适应:前几次切换节点,之后长等待)
237
+ let consecutiveHighFail = 0; // 连续高失败率轮数(webserver 模式通用阶梯冷却)
224
238
 
225
239
  while (!shuttingDown) {
226
240
  loopCount++;
@@ -299,6 +313,7 @@ export async function handleAttach(options) {
299
313
  let successCount = 0;
300
314
  let failCount = 0;
301
315
  let cdnBlockedCount = 0;
316
+ let serverCdnCooldownSec = 0; // 服务端告知的剩余冷却秒数(webserver 503 响应携带)
302
317
  let needRestart = false;
303
318
 
304
319
  // 收集抓取成功的任务,记录抓取失败的
@@ -308,8 +323,16 @@ export async function handleAttach(options) {
308
323
  const { uniqueId, info, error } = result.value;
309
324
  if (error) {
310
325
  if (error instanceof CDNBlockedError) {
311
- attachLog(` ⚠ @${uniqueId} CDN限流 (Access Denied)`);
326
+ attachLog(
327
+ error.serverCooldownSec
328
+ ? ` ⚠ @${uniqueId} CDN限流 (服务端冷却中,剩余 ${error.serverCooldownSec}s)`
329
+ : ` ⚠ @${uniqueId} CDN限流 (Access Denied)`,
330
+ );
312
331
  cdnBlockedCount++;
332
+ serverCdnCooldownSec = Math.max(
333
+ serverCdnCooldownSec,
334
+ error.serverCooldownSec || 0,
335
+ );
313
336
  failCount++;
314
337
  } else if (isBrowserClosedError(error)) {
315
338
  needRestart = true;
@@ -384,9 +407,13 @@ export async function handleAttach(options) {
384
407
 
385
408
  attachLog(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
386
409
 
387
- // CDN 限流比例超过 30% 时:自适应策略(前几次切节点,连续限流则长等待)
410
+ // 失败率冷却策略:
411
+ // - 本地浏览器模式(scraper):CDN 限流 >30% 触发切节点 + 冷却(主动绕过)
412
+ // - webserver 模式:统一按总失败率阶梯冷却(出口在远端,分类对客户端无意义,
413
+ // 唯一手段是等;冷却期服务端 503 会带 cdnCooldownSec,优先精确对齐窗口)
388
414
  const cdnRatio = tasks.length > 0 ? cdnBlockedCount / tasks.length : 0;
389
- if (cdnRatio > 0.3) {
415
+ const failRatio = tasks.length > 0 ? failCount / tasks.length : 0;
416
+ if (cdnRatio > 0.3 && scraper) {
390
417
  consecutiveCdnBlocks++;
391
418
  // 冷却指数退避:60s→120s→240s→…上限 300s(切换后短等,持续限流则长等窗口过去)
392
419
  const coolDownSeconds = Math.min(
@@ -394,12 +421,7 @@ export async function handleAttach(options) {
394
421
  MAX_CDN_COOLDOWN_SEC,
395
422
  );
396
423
  const shouldSwitch = consecutiveCdnBlocks <= MAX_CDN_SWITCH_ATTEMPTS;
397
- if (!scraper) {
398
- // webserver 模式:限流发生在远端 webserver 出口,本地切节点无效,仅冷却等待
399
- attachLog(
400
- ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),webserver 模式不切节点,冷却 ${coolDownSeconds} 秒...`,
401
- );
402
- } else if (shouldSwitch) {
424
+ if (shouldSwitch) {
403
425
  // 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
404
426
  try {
405
427
  writeCommand({
@@ -417,16 +439,42 @@ export async function handleAttach(options) {
417
439
  );
418
440
  }
419
441
  await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
420
- if (scraper) {
421
- await recycleScraper(
422
- scraper,
423
- `CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
424
- );
425
- browserRestartCount++;
426
- }
442
+ await recycleScraper(
443
+ scraper,
444
+ `CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
445
+ );
446
+ browserRestartCount++;
447
+ taskBatchCount = 0;
448
+ } else if (
449
+ webserverUrl &&
450
+ tasks.length >= 5 &&
451
+ failRatio > 0.5
452
+ ) {
453
+ // webserver 模式统一阶梯:总失败率 >50% 即冷却(含 CDN 限流/goto 超时/
454
+ // 验证码/网络错误等所有类型)。首次 30s 低代价试探,连续则指数升级
455
+ // 30→60→120→240→300s 封顶;服务端告知冷却剩余秒数时优先精确对齐;
456
+ // 失败率回落 ≤30% 清零
457
+ consecutiveHighFail++;
458
+ const coolDownSeconds = serverCdnCooldownSec
459
+ ? Math.min(
460
+ Math.max(serverCdnCooldownSec + 5, 30),
461
+ MAX_CDN_COOLDOWN_SEC,
462
+ )
463
+ : Math.min(
464
+ 30 * Math.pow(2, consecutiveHighFail - 1),
465
+ MAX_CDN_COOLDOWN_SEC,
466
+ );
467
+ const cdnTag = cdnBlockedCount
468
+ ? `(其中 CDN限流 ${cdnBlockedCount})`
469
+ : "";
470
+ attachLog(
471
+ ` [Attach] 失败率 ${(failRatio * 100).toFixed(0)}% (${failCount}/${tasks.length})${cdnTag},冷却 ${coolDownSeconds} 秒...`,
472
+ );
473
+ await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
427
474
  taskBatchCount = 0;
428
475
  } else {
429
476
  consecutiveCdnBlocks = 0; // 正常批次重置连续限流计数
477
+ if (failRatio <= 0.3) consecutiveHighFail = 0; // 失败率回落清零阶梯计数
430
478
  }
431
479
 
432
480
  if (scraper) {
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",
@@ -222,22 +222,66 @@ function killEdgeProcesses(targetDir, port) {
222
222
  });
223
223
  }
224
224
 
225
+ /** 查询监听指定端口的进程 PID 列表(netstat,不依赖 WMI);查询失败返回 [] */
226
+ function getListeningPidsOnPort(port) {
227
+ try {
228
+ const out = execSync("netstat -ano", {
229
+ encoding: "utf-8",
230
+ timeout: 10000,
231
+ windowsHide: true,
232
+ });
233
+ return parseNetstatListeningPids(out, port);
234
+ } catch {
235
+ return [];
236
+ }
237
+ }
238
+
239
+ /** tasklist 验证 PID 存活且属于 msedge(不依赖 WMI):true/false;查询失败返回 null */
240
+ function isEdgePidAliveWin(pid) {
241
+ try {
242
+ const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, {
243
+ encoding: "utf-8",
244
+ timeout: 5000,
245
+ windowsHide: true,
246
+ });
247
+ return out.toLowerCase().includes("msedge");
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
225
253
  /**
226
- * 查找"残留" Edge 主进程:命令行包含指定 user-data-dir、但不带
227
- * --remote-debugging-port(Edge 自动更新/崩溃自恢复、上次强杀残留等,
228
- * 占着 profile 却没监听 CDP 端口)。
229
- * 排除带 --type= 的子进程:渲染进程等子进程同样带 user-data-dir 却没有
230
- * CDP 参数,误杀会打断同 profile 的健康实例。
254
+ * 读取 profile 目录下 lockfile 里的数字(ProcessSingleton 持有者 PID 候选)。
255
+ * Chromium 写入格式随版本有差异,返回前 5 个数字串,由调用方逐一验证。
256
+ */
257
+ function readLockPidCandidates(userDataDir) {
258
+ try {
259
+ const content = fs.readFileSync(path.join(userDataDir, "lockfile"), "utf-8");
260
+ return (content.match(/\d+/g) || []).slice(0, 5);
261
+ } catch {
262
+ return [];
263
+ }
264
+ }
265
+
266
+ /**
267
+ * 查找"残留" Edge 主进程:占着指定 user-data-dir 的实例(调用前已确认
268
+ * CDP 端口未监听,故带 --remote-debugging-port 参数的僵死实例同样算残留)。
269
+ * 排除带 --type= 的子进程:渲染进程等子进程同样带 user-data-dir,
270
+ * 误杀会打断同 profile 的健康实例。
231
271
  *
232
272
  * 这类实例必须先杀掉再启动:否则新 msedge 会因 Chromium ProcessSingleton
233
273
  * 把命令行转发给旧实例,变成"旧实例开一个 about:blank 新 tab",CDP 端口
234
274
  * 永远起不来 → 等待超时 → watchdog 自动重启 → 每轮再开一个 tab。
235
275
  *
236
276
  * killEdgeProcesses 在 Windows 上按监听端口找 PID,对这类实例无效,
237
- * 故单独按命令行匹配精确清理。
277
+ * 故单独按命令行/lockfile 匹配精确清理,且不依赖 WMI:
278
+ * 1. PowerShell 枚举 msedge 命令行按 user-data-dir 匹配(需 WMI,
279
+ * WMI 损坏的机器上可能返回空)
280
+ * 2. lockfile 兜底:读 profile 目录 lockfile 中的 PID,用 tasklist 验证
281
+ * 存活且是 msedge(不依赖 WMI,覆盖 WMI 损坏 + 带 CDP 参数的僵死实例)
238
282
  *
239
283
  * @param {string} targetDir - user-data-dir 路径
240
- * @returns {string[]|null} PID 列表;无法枚举进程命令行(PowerShell/WMI 不可用)时返回 null
284
+ * @returns {string[]|null} PID 列表;命令行枚举失败且 lockfile 无可信结论时返回 null
241
285
  */
242
286
  function findStaleEdgePids(targetDir) {
243
287
  const platform = os.platform();
@@ -245,31 +289,61 @@ function findStaleEdgePids(targetDir) {
245
289
  let out;
246
290
  if (platform === "win32") {
247
291
  // PowerShell 为主(wmic 已从新版 Win11 移除;项目其他模块同样依赖 PowerShell)
248
- out = decodeWinOutput(
249
- execSync(
250
- 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'msedge.exe\' } | ForEach-Object { Write-Output ($_.ProcessId.ToString() + \'|\' + $_.CommandLine) }"',
251
- { encoding: "buffer", timeout: 10000, windowsHide: true },
252
- ),
253
- );
254
- } else {
255
- out = execSync("ps -eo pid,args", {
256
- encoding: "utf-8",
257
- timeout: 10000,
258
- });
292
+ let byCmdline = null;
293
+ try {
294
+ out = decodeWinOutput(
295
+ execSync(
296
+ 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'msedge.exe\' } | ForEach-Object { Write-Output ($_.ProcessId.ToString() + \'|\' + $_.CommandLine) }"',
297
+ { encoding: "buffer", timeout: 10000, windowsHide: true },
298
+ ),
299
+ );
300
+ const dirLower = targetDir.toLowerCase();
301
+ const pids = [];
302
+ for (const line of out.split(/\r?\n/)) {
303
+ const lower = line.toLowerCase();
304
+ if (!lower.includes(dirLower)) continue; // 只匹配本 profile
305
+ // 边界保护:避免 _p9222 误匹配 _p92221 这类前缀目录
306
+ const idx = lower.indexOf(dirLower);
307
+ const next = lower[idx + dirLower.length];
308
+ if (next && /[a-z0-9_]/.test(next)) continue;
309
+ if (lower.includes("--type=")) continue; // 子进程,误杀会打断健康实例
310
+ // 提取 PID:win 格式 "PID|命令行"
311
+ const m = line.match(/^\s*(\d+)\|/);
312
+ if (m && !pids.includes(m[1])) pids.push(m[1]);
313
+ }
314
+ byCmdline = pids;
315
+ } catch (e) {
316
+ console.error(`[findStaleEdgePids] PowerShell 枚举失败: ${e.message}`);
317
+ byCmdline = null;
318
+ }
319
+ if (byCmdline && byCmdline.length > 0) return byCmdline;
320
+
321
+ // lockfile 兑底:WMI 损坏时命令行枚举为空/失败;或残留实例带 CDP
322
+ // 参数时命令行匹配可能误报为空,lockfile 直接给出持有者 PID
323
+ for (const cand of readLockPidCandidates(targetDir)) {
324
+ if (isEdgePidAliveWin(cand) === true) return [cand];
325
+ }
326
+
327
+ // 命令行枚举失败且 lockfile 没找到活持有者 → 无法判断
328
+ if (byCmdline === null) return null;
329
+ return [];
259
330
  }
331
+
332
+ out = execSync("ps -eo pid,args", {
333
+ encoding: "utf-8",
334
+ timeout: 10000,
335
+ });
260
336
  const dirLower = targetDir.toLowerCase();
261
337
  const pids = [];
262
338
  for (const line of out.split(/\r?\n/)) {
263
339
  const lower = line.toLowerCase();
264
340
  if (!lower.includes(dirLower)) continue; // 只匹配本 profile
265
- // 边界保护:避免 _p9222 误匹配 _p92221 这类前缀目录
266
341
  const idx = lower.indexOf(dirLower);
267
342
  const next = lower[idx + dirLower.length];
268
343
  if (next && /[a-z0-9_]/.test(next)) continue;
269
- if (lower.includes("remote-debugging-port")) continue; // 健康 CDP 实例,不动
270
344
  if (lower.includes("--type=")) continue; // 子进程,误杀会打断健康实例
271
- // 提取 PID:win 格式 "PID|命令行",unix 格式 " PID 命令行"
272
- const m = line.match(/^\s*(\d+)(?:\||\s)/);
345
+ // 提取 PID:unix 格式 " PID 命令行"
346
+ const m = line.match(/^\s*(\d+)\s/);
273
347
  if (m && !pids.includes(m[1])) pids.push(m[1]);
274
348
  }
275
349
  return pids;
@@ -308,6 +382,66 @@ function killEdgeStale(pids) {
308
382
  });
309
383
  }
310
384
 
385
+ /**
386
+ * CDP 等待超时后的现场诊断:端口被谁占用、msedge 进程总数、profile 锁持有者。
387
+ * 输出随超时错误一起打印/上报,远程节点无需人工登录即可定位问题。
388
+ * @param {number} port
389
+ * @param {string} userDataDir
390
+ * @returns {string} 形如 "[诊断] ...; ..." 的摘要;收集失败返回 ""
391
+ */
392
+ function collectTimeoutDiagnostics(port, userDataDir) {
393
+ const parts = [];
394
+ try {
395
+ if (os.platform() === "win32") {
396
+ const listenPids = getListeningPidsOnPort(port);
397
+ if (listenPids.length > 0) {
398
+ const names = listenPids.map((pid) => {
399
+ try {
400
+ const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, {
401
+ encoding: "utf-8",
402
+ timeout: 5000,
403
+ windowsHide: true,
404
+ });
405
+ return out.split(",")[0]?.replace(/"/g, "").trim() || pid;
406
+ } catch {
407
+ return pid;
408
+ }
409
+ });
410
+ parts.push(
411
+ `端口 ${port} 正被监听但 CDP 未响应 (PID: ${listenPids.join(", ")}, 进程: ${names.join(", ")})`,
412
+ );
413
+ } else {
414
+ parts.push(`端口 ${port} 无任何进程监听`);
415
+ }
416
+ try {
417
+ const out = execSync(
418
+ 'tasklist /FI "IMAGENAME eq msedge.exe" /FO CSV /NH',
419
+ {
420
+ encoding: "utf-8",
421
+ timeout: 5000,
422
+ windowsHide: true,
423
+ },
424
+ );
425
+ const count = out
426
+ .split(/\r?\n/)
427
+ .filter((l) => l.toLowerCase().includes("msedge")).length;
428
+ parts.push(`msedge 进程总数: ${count}`);
429
+ } catch {}
430
+ const lockPids = readLockPidCandidates(userDataDir).filter(
431
+ (pid) => isEdgePidAliveWin(pid) !== false,
432
+ );
433
+ parts.push(
434
+ lockPids.length
435
+ ? `profile lockfile 持有者: ${lockPids.join(", ")}`
436
+ : "profile lockfile 无存活持有者",
437
+ );
438
+ }
439
+ } catch (e) {
440
+ parts.push(`诊断收集失败: ${e.message}`);
441
+ }
442
+ return parts.length ? `[诊断] ${parts.join("; ")}` : "";
443
+ }
444
+
311
445
  function launchEdgeWithCDP(port, userDataDir, proxyServer) {
312
446
  return new Promise((resolve, reject) => {
313
447
  const platform = os.platform();
@@ -405,14 +539,32 @@ export async function ensureBrowserReady(options = {}) {
405
539
  console.error(`CDP 端口 ${port} 未就绪,正在启动 Edge 浏览器...`);
406
540
  }
407
541
  }
408
- // 残留实例清理:占着该 user-data-dir 却没开 CDP 的旧 Edge 必须先杀掉,
409
- // 否则 launch 会被 ProcessSingleton 转发成"旧实例开新 tab",CDP 永远起不来。
542
+ // (Windows)CDP 端口被非 Edge 进程占用 → 立即报错,避免干等 60 秒超时
543
+ if (os.platform() === "win32") {
544
+ const listenPids = getListeningPidsOnPort(port);
545
+ if (listenPids.length > 0) {
546
+ const isEdge = listenPids.map((pid) => isEdgePidAliveWin(pid));
547
+ if (isEdge.every((v) => v === false)) {
548
+ throw new Error(
549
+ `CDP 端口 ${port} 被非 Edge 进程占用 (PID: ${listenPids.join(", ")}),` +
550
+ `无法启动浏览器。请释放该端口或更换 --cdp-port`,
551
+ );
552
+ }
553
+ // Edge 在监听但 HTTP 未响应 → 视为僵死,按残留处理(下方清理)
554
+ }
555
+ }
556
+
557
+ // 残留实例清理:占着该 user-data-dir 但 CDP 端口未监听的旧 Edge 必须先杀掉
558
+ //(含带 --remote-debugging-port 参数的僵死实例),否则 launch 会被
559
+ // ProcessSingleton 转发成"旧实例开新 tab",CDP 永远起不来。
410
560
  const stalePids = findStaleEdgePids(userDataDir);
411
561
  if (stalePids === null) {
412
- console.error("[浏览器] 无法枚举 Edge 进程命令行,跳过残留实例检测");
562
+ console.error(
563
+ "[浏览器] 无法判断是否存在残留 Edge 实例(WMI/lockfile 均不可用),跳过清理",
564
+ );
413
565
  } else if (stalePids.length > 0) {
414
566
  console.error(
415
- `[浏览器] 检测到占用 ${path.basename(userDataDir)} 但未开 CDP 的残留 Edge 实例 (PID: ${stalePids.join(", ")}),正在清理...`,
567
+ `[浏览器] 检测到占用 ${path.basename(userDataDir)} 但 CDP 端口未监听的残留 Edge 实例 (PID: ${stalePids.join(", ")}),正在清理...`,
416
568
  );
417
569
  await killEdgeStale(stalePids);
418
570
  await new Promise((r) => setTimeout(r, 3000));
@@ -427,8 +579,11 @@ export async function ensureBrowserReady(options = {}) {
427
579
  os.platform() === "win32" ? 60000 : 30000,
428
580
  );
429
581
  if (!launched) {
582
+ const diag = collectTimeoutDiagnostics(port, userDataDir);
583
+ if (diag) console.error(`[浏览器] ${diag}`);
430
584
  throw new Error(
431
- `等待 CDP 端口 ${port} 超时。请确认 Edge 浏览器已安装,\n` +
585
+ `等待 CDP 端口 ${port} 超时。${diag ? diag + "\n" : ""}` +
586
+ `请确认 Edge 浏览器已安装,\n` +
432
587
  `或手动启动: Microsoft Edge --remote-debugging-port=${port} [参见 cdp.js extraArgs]`,
433
588
  );
434
589
  }
@@ -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);
@@ -1260,6 +1260,10 @@ export function createStore(filePath, options = {}) {
1260
1260
  return data;
1261
1261
  }
1262
1262
 
1263
+ // 空级跳过记忆(claimNextJob 用):key = "level|国家列表",value = 首次全 miss 时间。
1264
+ // 某来源级(如 seed)全部国家分支 miss 后,TTL 内跳过该级,避免每次领任务空跑
1265
+ const levelEmptyCache = new Map();
1266
+
1263
1267
  async function claimNextJob(
1264
1268
  userId,
1265
1269
  expireMs = 5 * 60 * 1000,
@@ -1391,24 +1395,42 @@ export function createStore(filePath, options = {}) {
1391
1395
  // 非 pinned 优先级:seed → seller → follow → other,每级 × 3 个 location tier × (hasVideo / noVideoReq)
1392
1396
  const priorities = [
1393
1397
  {
1398
+ name: "seed",
1394
1399
  filter: `instr(COALESCE(sources, ''), '"seed"') > 0`,
1395
1400
  loggedInOnly: false,
1396
1401
  },
1397
1402
  {
1403
+ name: "seller",
1398
1404
  filter: `tt_seller = 1 AND verified = 0`,
1399
1405
  loggedInOnly: true,
1400
1406
  sellerAnyCountry: true,
1401
1407
  },
1402
1408
  {
1409
+ name: "follow",
1403
1410
  filter: `(instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0 OR instr(COALESCE(sources, ''), '"comment"') > 0)`,
1404
1411
  loggedInOnly: false,
1405
1412
  },
1406
- { filter: `1 = 1`, loggedInOnly: false },
1413
+ { name: "other", filter: `1 = 1`, loggedInOnly: false },
1407
1414
  ];
1408
1415
 
1416
+ // 空级跳过记忆:某来源级(如 seed)全部国家分支 miss 后,记录时间;
1417
+ // TTL 内该级整体跳过(池子空了没必要每次领任务都空跑 24国×2 条查询,
1418
+ // 2026-09-29 实测 seed 空级每次烧 1.4s CPU 是 watch 饱和主因)。
1419
+ // TTL 过期后恢复探测一次——refill 补进新任务即可自动恢复。
1420
+ const LEVEL_EMPTY_TTL = 60 * 1000;
1421
+ for (const p of priorities) {
1422
+ if (p.loggedInOnly && !loggedIn) continue;
1423
+ const levelKey = `${p.name}|${orderedCountries.join(",")}`;
1424
+ const emptySince = levelEmptyCache.get(levelKey);
1425
+ if (emptySince && now - emptySince < LEVEL_EMPTY_TTL) {
1426
+ p.skip = true; // 标记跳过,下面构建分支时跳过
1427
+ }
1428
+ }
1429
+
1409
1430
  let basePrio = 4;
1410
1431
  for (const p of priorities) {
1411
1432
  if (p.loggedInOnly && !loggedIn) continue;
1433
+ if (p.skip) continue;
1412
1434
  // 逐国顺序分支:配置内国家按优先级顺序;未传子集时末尾补“配置外国家”兑底。
1413
1435
  // 空位置(loc='')只在首个国家的分支参与:首国的 noVideoReq 分支 miss 即意味着
1414
1436
  // 空位置池已耗尽,后续分支无需再 OR ''(避免反复扫描空位置大池)
@@ -1421,6 +1443,7 @@ export function createStore(filePath, options = {}) {
1421
1443
  // 传了国家子集时对应国家的商家仍优先,领完再兜任意国家(含空位置商家);
1422
1444
  // 未传国家时同理(配置内各国 → 配置外 → 任意)。其他来源级分支不受影响。
1423
1445
  if (p.sellerAnyCountry) locationBranches.push(null); // null → 任意国家分支
1446
+ p._branchStart = branches.length; // 记录该级分支区间(用于判空)
1424
1447
  let subPrio = 0;
1425
1448
  for (const loc of locationBranches) {
1426
1449
  // 商家任意国家分支:不限 guessed_location(含空位置的商家)
@@ -1453,12 +1476,16 @@ export function createStore(filePath, options = {}) {
1453
1476
  );
1454
1477
  subPrio++;
1455
1478
  }
1479
+ p._branchEnd = branches.length;
1456
1480
  basePrio += 2;
1457
1481
  }
1458
1482
 
1459
- // 逐级执行:首个命中分支的最优行即领取结果(未命中继续下一优先级分支)
1483
+ // 逐级执行:首个命中分支的最优行即领取结果(未命中继续下一优先级分支)。
1484
+ // 同时记录哪些来源级全部 miss → 写入 levelEmptyCache(下次领取 TTL 内直接跳过该级)
1460
1485
  let selectedRow = null;
1461
- for (const b of branches) {
1486
+ let bi = 0;
1487
+ for (; bi < branches.length; bi++) {
1488
+ const b = branches[bi];
1462
1489
  selectedRow =
1463
1490
  getDb()
1464
1491
  .prepare(
@@ -1468,6 +1495,18 @@ export function createStore(filePath, options = {}) {
1468
1495
  if (selectedRow) break;
1469
1496
  }
1470
1497
 
1498
+ // 记录空级:分支按优先级顺序执行,命中所处级之前的所有来源级必然全 miss;
1499
+ // 空级写入 levelEmptyCache(TTL 内下次领取直接跳过),命中级清除记忆
1500
+ // bi = 命中分支下标(未命中则停在 branches.length)
1501
+ const hitIdx = bi;
1502
+ for (const p of priorities) {
1503
+ if (p.loggedInOnly && !loggedIn) continue;
1504
+ if (p._branchStart == null) continue; // 被 skip 跳过的级无分支,保留原记忆
1505
+ const levelKey = `${p.name}|${orderedCountries.join(",")}`;
1506
+ if (hitIdx >= p._branchEnd) levelEmptyCache.set(levelKey, now);
1507
+ else levelEmptyCache.delete(levelKey);
1508
+ }
1509
+
1471
1510
  if (selectedRow) {
1472
1511
  getDb()
1473
1512
  .prepare(
@@ -2563,11 +2602,15 @@ export function createStore(filePath, options = {}) {
2563
2602
  stage,
2564
2603
  errorStack,
2565
2604
  hostname,
2605
+ clientId,
2566
2606
  ) {
2567
2607
  const existing = clientErrors.get(userId);
2568
2608
  if (existing) {
2569
2609
  existing.timestamp = Date.now();
2570
2610
  if (hostname) existing.hostname = hostname;
2611
+ // clientId(机器唯一标识,如 F-9@HOST)随上报刷新;克隆机群 hostname 重复,
2612
+ // 看门狗只认 clientId 关联本机条目
2613
+ if (clientId) existing.clientId = clientId;
2571
2614
  if (errorType === "captcha") {
2572
2615
  existing.captchaCount = (existing.captchaCount || 0) + 1;
2573
2616
  if (!existing.captchaStage) existing.captchaStage = stage || "";
@@ -2586,6 +2629,7 @@ export function createStore(filePath, options = {}) {
2586
2629
  clientErrors.set(userId, {
2587
2630
  userId,
2588
2631
  hostname: hostname || "",
2632
+ clientId: clientId || "", // 机器唯一标识;空 = 旧版客户端上报,不参与看门狗触发
2589
2633
  errorType,
2590
2634
  errorMessage: errorMessage || "",
2591
2635
  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");