tt-help-cli-ycl 1.4.165 → 1.4.166
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/add-claim-fallback-index.mjs +25 -0
- package/scripts/add-claim-index.mjs +3 -5
- package/scripts/test-client-error-monitor.mjs +75 -65
- package/src/cli/tag.js +2 -0
- package/src/lib/api-client.js +16 -0
- package/src/lib/client-error-report.js +4 -1
- package/src/watch/data-store.js +5 -0
- 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
|
@@ -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();
|
|
@@ -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/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",
|
|
@@ -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
|
@@ -2563,11 +2563,15 @@ export function createStore(filePath, options = {}) {
|
|
|
2563
2563
|
stage,
|
|
2564
2564
|
errorStack,
|
|
2565
2565
|
hostname,
|
|
2566
|
+
clientId,
|
|
2566
2567
|
) {
|
|
2567
2568
|
const existing = clientErrors.get(userId);
|
|
2568
2569
|
if (existing) {
|
|
2569
2570
|
existing.timestamp = Date.now();
|
|
2570
2571
|
if (hostname) existing.hostname = hostname;
|
|
2572
|
+
// clientId(机器唯一标识,如 F-9@HOST)随上报刷新;克隆机群 hostname 重复,
|
|
2573
|
+
// 看门狗只认 clientId 关联本机条目
|
|
2574
|
+
if (clientId) existing.clientId = clientId;
|
|
2571
2575
|
if (errorType === "captcha") {
|
|
2572
2576
|
existing.captchaCount = (existing.captchaCount || 0) + 1;
|
|
2573
2577
|
if (!existing.captchaStage) existing.captchaStage = stage || "";
|
|
@@ -2586,6 +2590,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2586
2590
|
clientErrors.set(userId, {
|
|
2587
2591
|
userId,
|
|
2588
2592
|
hostname: hostname || "",
|
|
2593
|
+
clientId: clientId || "", // 机器唯一标识;空 = 旧版客户端上报,不参与看门狗触发
|
|
2589
2594
|
errorType,
|
|
2590
2595
|
errorMessage: errorMessage || "",
|
|
2591
2596
|
errorStack: errorStack || "",
|
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");
|