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 +2 -2
- package/scripts/add-claim-fallback-index.mjs +25 -0
- package/scripts/add-claim-index.mjs +3 -5
- package/scripts/deploy-node.sh +83 -0
- package/scripts/test-client-error-monitor.mjs +75 -65
- package/src/cli/attach.js +67 -19
- package/src/cli/tag.js +2 -0
- package/src/lib/api-client.js +16 -0
- package/src/lib/browser/cdp.js +182 -27
- package/src/lib/client-error-report.js +4 -1
- package/src/watch/data-store.js +47 -3
- package/src/watch/db-schema.js +5 -4
- package/src/watch/server.js +1 -0
- package/src/watchdog/agent.js +20 -3
- package/src/watchdog/client-error-monitor.js +25 -9
- package/src/watchdog/reporter.js +42 -1
- package/src/webserver/server.mjs +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tt-help-cli-ycl",
|
|
3
|
-
"version": "1.4.
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
* - 按
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
134
|
-
assert.equal(
|
|
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 按
|
|
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: [
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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(
|
|
182
|
+
assert.equal(getReceived().mine.length, 2, "回调收到本机全部 2 条");
|
|
181
183
|
assert.deepEqual(
|
|
182
|
-
|
|
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
|
|
193
|
+
test("M3 无 clientId 的旧条目不参与本机关联(宁漏不误)", async () => {
|
|
190
194
|
mock.method(globalThis, "fetch", async () => ({
|
|
191
195
|
ok: true,
|
|
192
|
-
json: async () => ({
|
|
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, "
|
|
220
|
+
assert.equal(alerted, false, "clientId 未配置时监控不生效");
|
|
204
221
|
assert.equal(hit, null);
|
|
205
222
|
mock.restoreAll();
|
|
206
223
|
});
|
|
207
224
|
|
|
208
|
-
test("
|
|
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("
|
|
252
|
+
test("M6 拉取失败静默:fetch 抛错不回调不崩溃", async () => {
|
|
236
253
|
mock.method(globalThis, "fetch", async () => {
|
|
237
254
|
throw new Error("ECONNREFUSED");
|
|
238
255
|
});
|
|
239
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
410
|
+
// 失败率冷却策略:
|
|
411
|
+
// - 本地浏览器模式(scraper):CDN 限流 >30% 触发切节点 + 冷却(主动绕过)
|
|
412
|
+
// - webserver 模式:统一按总失败率阶梯冷却(出口在远端,分类对客户端无意义,
|
|
413
|
+
// 唯一手段是等;冷却期服务端 503 会带 cdnCooldownSec,优先精确对齐窗口)
|
|
388
414
|
const cdnRatio = tasks.length > 0 ? cdnBlockedCount / tasks.length : 0;
|
|
389
|
-
|
|
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 (
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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 {}
|
package/src/lib/api-client.js
CHANGED
|
@@ -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",
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -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
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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:
|
|
272
|
-
const m = line.match(/^\s*(\d+)
|
|
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
|
-
//
|
|
409
|
-
|
|
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(
|
|
562
|
+
console.error(
|
|
563
|
+
"[浏览器] 无法判断是否存在残留 Edge 实例(WMI/lockfile 均不可用),跳过清理",
|
|
564
|
+
);
|
|
413
565
|
} else if (stalePids.length > 0) {
|
|
414
566
|
console.error(
|
|
415
|
-
`[浏览器] 检测到占用 ${path.basename(userDataDir)}
|
|
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}
|
|
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(), //
|
|
53
|
+
hostname: os.hostname(), // 机器标识(展示用;克隆机群 hostname 重复)
|
|
54
|
+
// 看门狗按 clientId 关联本机异常(每台唯一,克隆机 hostname 重复不可用)
|
|
55
|
+
clientId: getWatchdogClientId(),
|
|
53
56
|
});
|
|
54
57
|
} catch {}
|
|
55
58
|
await triggerClashFailover(message, log);
|
package/src/watch/data-store.js
CHANGED
|
@@ -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
|
-
|
|
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 || "",
|
package/src/watch/db-schema.js
CHANGED
|
@@ -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
|
-
//
|
|
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'`,
|
package/src/watch/server.js
CHANGED
|
@@ -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 {
|
package/src/watchdog/agent.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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. 按
|
|
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}
|
|
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 {(
|
|
69
|
+
* @param {(err: object, now: number) => boolean} [opts.onAlert] - 命中回调
|
|
63
70
|
*/
|
|
64
|
-
constructor({
|
|
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
|
-
|
|
110
|
-
|
|
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));
|
package/src/watchdog/reporter.js
CHANGED
|
@@ -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
|
*/
|
package/src/webserver/server.mjs
CHANGED
|
@@ -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");
|