tt-help-cli-ycl 1.4.89 → 1.4.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/test-country-fix.mjs +280 -0
- package/src/clash/daemon.js +31 -17
- package/src/cli/clash-daemon.js +1 -1
- package/src/cli/tag.js +43 -1
- package/src/lib/args.js +2 -2
- package/src/lib/constants.js +1 -1
- package/src/watch/watchdog-server.js +8 -2
- package/src/watchdog/agent.js +35 -6
- package/src/watchdog/llm-analyzer.js +70 -18
- package/src/webserver/server.mjs +20 -3
package/package.json
CHANGED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 回归测试:节点国家配置收敛修复(2026-08-12)
|
|
4
|
+
*
|
|
5
|
+
* 背景:daemon 启动硬编码 --country US,且服务端幂等基准是"是否下发过",
|
|
6
|
+
* 导致 daemon 重启(崩溃自愈/升级/机器重启)回退 US 后服务端不补发 → 一直用美国节点。
|
|
7
|
+
*
|
|
8
|
+
* 运行方式(务必用隔离 TMPDIR,避免污染真实 clash-*.json / 服务端配置):
|
|
9
|
+
* TMPDIR=$(mktemp -d) node scripts/test-country-fix.mjs
|
|
10
|
+
*
|
|
11
|
+
* 覆盖:
|
|
12
|
+
* A. 服务端幂等基准 = 客户端实际生效国家(核心修复)
|
|
13
|
+
* - daemon 重启回退 US 后,服务端补发 JP
|
|
14
|
+
* - daemon 已生效 JP 时,幂等不重复下发
|
|
15
|
+
* - daemon 未上报时,下发
|
|
16
|
+
* - 多国配置且已生效时,幂等不重复下发
|
|
17
|
+
* B. daemon 逗号分隔多国 --country 参数解析
|
|
18
|
+
* C. checker normalizeCountries / filterNodesByCountry 回归
|
|
19
|
+
* D. 客户端 daemon 启动兜底:读 clash-health.json 上次生效国家 → join(",")
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdtempSync, writeFileSync } from "fs";
|
|
23
|
+
import { tmpdir } from "os";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
function ok(cond, name, extra = "") {
|
|
29
|
+
if (cond) {
|
|
30
|
+
passed++;
|
|
31
|
+
console.log(` ✅ ${name}`);
|
|
32
|
+
} else {
|
|
33
|
+
failed++;
|
|
34
|
+
console.log(` ❌ ${name} ${extra}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── 隔离 HOME:避免读写真实服务端配置 ~/.tt-help-watchdog.json ──
|
|
39
|
+
const fakeHome = mkdtempSync(join(tmpdir(), "tt-wd-home-"));
|
|
40
|
+
process.env.HOME = fakeHome;
|
|
41
|
+
process.env.USERPROFILE = fakeHome;
|
|
42
|
+
|
|
43
|
+
function fakeRes() {
|
|
44
|
+
return {
|
|
45
|
+
status: 0,
|
|
46
|
+
body: null,
|
|
47
|
+
writeHead(code) {
|
|
48
|
+
this.status = code;
|
|
49
|
+
},
|
|
50
|
+
end(b) {
|
|
51
|
+
this.body = b;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function wdCall(mod, path, method, params, body) {
|
|
56
|
+
const res = fakeRes();
|
|
57
|
+
await mod.tryHandleWatchdogRoute(path, method, params, body, res);
|
|
58
|
+
return { status: res.status, data: res.body ? JSON.parse(res.body) : null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log("\n═══ A. 服务端幂等基准 = 客户端实际生效国家 ═══");
|
|
62
|
+
const wd = await import("../src/watch/watchdog-server.js");
|
|
63
|
+
|
|
64
|
+
// A1 配置 T1 → JP
|
|
65
|
+
let r = await wdCall(
|
|
66
|
+
wd,
|
|
67
|
+
"/api/watchdog/set-country-filter",
|
|
68
|
+
"POST",
|
|
69
|
+
{},
|
|
70
|
+
{
|
|
71
|
+
userId: "T1",
|
|
72
|
+
countries: ["JP"],
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
ok(
|
|
76
|
+
r.status === 200 && JSON.stringify(r.data.countries) === '["JP"]',
|
|
77
|
+
"A1 配置 T1 → JP",
|
|
78
|
+
JSON.stringify(r.data),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// A2 心跳:daemon 回退 US(重启后)→ 服务端应补发 JP(核心修复点,旧逻辑因 lastSent 幂等不会下发)
|
|
82
|
+
r = await wdCall(
|
|
83
|
+
wd,
|
|
84
|
+
"/api/watchdog/heartbeat",
|
|
85
|
+
"POST",
|
|
86
|
+
{ clientId: "c1" },
|
|
87
|
+
{
|
|
88
|
+
config: { userId: "T1" },
|
|
89
|
+
clashHealth: { meta: { countryFilter: ["US"] } },
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
let cmds = (r.data && r.data.clashCommands) || [];
|
|
93
|
+
ok(
|
|
94
|
+
cmds.some(
|
|
95
|
+
(c) =>
|
|
96
|
+
c.action === "setCountryFilter" &&
|
|
97
|
+
JSON.stringify(c.countries) === '["JP"]',
|
|
98
|
+
),
|
|
99
|
+
"A2 daemon 生效 US(重启回退) → 补发 setCountryFilter JP",
|
|
100
|
+
JSON.stringify(cmds),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// A3 心跳:daemon 已生效 JP → 幂等不重复下发
|
|
104
|
+
r = await wdCall(
|
|
105
|
+
wd,
|
|
106
|
+
"/api/watchdog/heartbeat",
|
|
107
|
+
"POST",
|
|
108
|
+
{ clientId: "c1" },
|
|
109
|
+
{
|
|
110
|
+
config: { userId: "T1" },
|
|
111
|
+
clashHealth: { meta: { countryFilter: ["JP"] } },
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
cmds = (r.data && r.data.clashCommands) || [];
|
|
115
|
+
ok(
|
|
116
|
+
!cmds.some((c) => c.action === "setCountryFilter"),
|
|
117
|
+
"A3 daemon 生效 JP → 幂等不下发",
|
|
118
|
+
JSON.stringify(cmds),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// A4 心跳:daemon 未上报 clashHealth → 下发
|
|
122
|
+
r = await wdCall(
|
|
123
|
+
wd,
|
|
124
|
+
"/api/watchdog/heartbeat",
|
|
125
|
+
"POST",
|
|
126
|
+
{ clientId: "c1" },
|
|
127
|
+
{
|
|
128
|
+
config: { userId: "T1" },
|
|
129
|
+
clashHealth: null,
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
cmds = (r.data && r.data.clashCommands) || [];
|
|
133
|
+
ok(
|
|
134
|
+
cmds.some(
|
|
135
|
+
(c) =>
|
|
136
|
+
c.action === "setCountryFilter" &&
|
|
137
|
+
JSON.stringify(c.countries) === '["JP"]',
|
|
138
|
+
),
|
|
139
|
+
"A4 daemon 未上报 → 下发 setCountryFilter JP",
|
|
140
|
+
JSON.stringify(cmds),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// A5 多国配置 US+JP 且 daemon 已生效 US+JP → 幂等不下发
|
|
144
|
+
// 注:配置动作本身会立即入队一次下发(配置后本该生效一次),首轮心跳取走后再验证幂等
|
|
145
|
+
await wdCall(
|
|
146
|
+
wd,
|
|
147
|
+
"/api/watchdog/set-country-filter",
|
|
148
|
+
"POST",
|
|
149
|
+
{},
|
|
150
|
+
{
|
|
151
|
+
userId: "T2",
|
|
152
|
+
countries: ["US", "JP"],
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
r = await wdCall(
|
|
156
|
+
wd,
|
|
157
|
+
"/api/watchdog/heartbeat",
|
|
158
|
+
"POST",
|
|
159
|
+
{ clientId: "c2" },
|
|
160
|
+
{
|
|
161
|
+
config: { userId: "T2" },
|
|
162
|
+
clashHealth: { meta: { countryFilter: ["US", "JP"] } },
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
cmds = (r.data && r.data.clashCommands) || [];
|
|
166
|
+
ok(
|
|
167
|
+
cmds.some(
|
|
168
|
+
(c) =>
|
|
169
|
+
c.action === "setCountryFilter" &&
|
|
170
|
+
JSON.stringify(c.countries) === '["US","JP"]',
|
|
171
|
+
),
|
|
172
|
+
"A5a 配置后首次心跳取走命令",
|
|
173
|
+
JSON.stringify(cmds),
|
|
174
|
+
);
|
|
175
|
+
r = await wdCall(
|
|
176
|
+
wd,
|
|
177
|
+
"/api/watchdog/heartbeat",
|
|
178
|
+
"POST",
|
|
179
|
+
{ clientId: "c2" },
|
|
180
|
+
{
|
|
181
|
+
config: { userId: "T2" },
|
|
182
|
+
clashHealth: { meta: { countryFilter: ["US", "JP"] } },
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
cmds = (r.data && r.data.clashCommands) || [];
|
|
186
|
+
ok(
|
|
187
|
+
!cmds.some((c) => c.action === "setCountryFilter"),
|
|
188
|
+
"A5 多国且已生效 → 幂等不下发",
|
|
189
|
+
JSON.stringify(cmds),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
console.log("\n═══ B. daemon 逗号分隔多国 --country 解析 ═══");
|
|
193
|
+
const { ClashDaemon } = await import("../src/clash/daemon.js");
|
|
194
|
+
const checker = await import("../src/clash/checker.js");
|
|
195
|
+
|
|
196
|
+
const cases = [
|
|
197
|
+
["US,JP", ["US", "JP"], "US,JP 逗号分隔 → 多国并集"],
|
|
198
|
+
["JP", ["JP"], "JP 单国"],
|
|
199
|
+
["us, jp", ["US", "JP"], "小写+空格归一"],
|
|
200
|
+
[undefined, null, "未传 country → 不过滤"],
|
|
201
|
+
];
|
|
202
|
+
for (const [input, expect, name] of cases) {
|
|
203
|
+
checker.CONFIG.nodeCountryFilter = null;
|
|
204
|
+
new ClashDaemon({ country: input });
|
|
205
|
+
const got = checker.CONFIG.nodeCountryFilter;
|
|
206
|
+
ok(JSON.stringify(got) === JSON.stringify(expect), name, JSON.stringify(got));
|
|
207
|
+
}
|
|
208
|
+
checker.CONFIG.nodeCountryFilter = null;
|
|
209
|
+
|
|
210
|
+
console.log("\n═══ C. checker 国家过滤回归 ═══");
|
|
211
|
+
ok(
|
|
212
|
+
JSON.stringify(checker.normalizeCountries(["us", " JP", "US"])) ===
|
|
213
|
+
'["US","JP"]',
|
|
214
|
+
"C1 数组去重大写",
|
|
215
|
+
);
|
|
216
|
+
ok(
|
|
217
|
+
JSON.stringify(checker.normalizeCountries("jp")) === '["JP"]',
|
|
218
|
+
"C2 字符串大写",
|
|
219
|
+
);
|
|
220
|
+
ok(checker.normalizeCountries(null).length === 0, "C3 null → 空");
|
|
221
|
+
ok(checker.normalizeCountries("").length === 0, "C4 空串 → 空");
|
|
222
|
+
// 注意:checker.normalizeCountries 不拆分逗号(拆分在 daemon constructor),单字符串"US,JP"保持为一个元素
|
|
223
|
+
ok(
|
|
224
|
+
JSON.stringify(checker.normalizeCountries("US,JP")) === '["US,JP"]',
|
|
225
|
+
"C5 单字符串逗号不拆分(保持向后兼容)",
|
|
226
|
+
);
|
|
227
|
+
const nodes = ["🇺🇸美国A", "🇯🇵日本A", "🇩🇪德国A", "无旗帜节点"];
|
|
228
|
+
let fr = checker.filterNodesByCountry(nodes, ["US", "JP"]);
|
|
229
|
+
ok(
|
|
230
|
+
fr.filtered.length === 2 && fr.excluded === 2,
|
|
231
|
+
"C6 多国并集过滤(US+JP 命中 2)",
|
|
232
|
+
JSON.stringify(fr),
|
|
233
|
+
);
|
|
234
|
+
fr = checker.filterNodesByCountry(nodes, ["ZZ"]);
|
|
235
|
+
ok(
|
|
236
|
+
fr.filtered.length === 4 && fr.excluded === 0,
|
|
237
|
+
"C7 未知国家向后兼容返回全部",
|
|
238
|
+
);
|
|
239
|
+
fr = checker.filterNodesByCountry(nodes, null);
|
|
240
|
+
ok(fr.filtered.length === 4 && fr.excluded === 0, "C8 null 不过滤");
|
|
241
|
+
|
|
242
|
+
console.log("\n═══ D. 客户端 daemon 启动兜底(读上次生效国家)═══");
|
|
243
|
+
const { getClashHealth } = await import("../src/watchdog/reporter.js");
|
|
244
|
+
const healthPath = join(tmpdir(), "clash-health.json");
|
|
245
|
+
|
|
246
|
+
writeFileSync(
|
|
247
|
+
healthPath,
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
meta: { countryFilter: ["JP"] },
|
|
250
|
+
lastCheck: new Date().toISOString(),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
let prev = getClashHealth()?.meta?.countryFilter;
|
|
254
|
+
ok(
|
|
255
|
+
Array.isArray(prev) && JSON.stringify(prev) === '["JP"]',
|
|
256
|
+
"D1 读到上次生效国家 JP",
|
|
257
|
+
JSON.stringify(prev),
|
|
258
|
+
);
|
|
259
|
+
let countryArg = Array.isArray(prev) && prev.length ? prev.join(",") : "US";
|
|
260
|
+
ok(countryArg === "JP", "D2 单国 join → 'JP'", countryArg);
|
|
261
|
+
|
|
262
|
+
writeFileSync(
|
|
263
|
+
healthPath,
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
meta: { countryFilter: ["US", "JP"] },
|
|
266
|
+
lastCheck: new Date().toISOString(),
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
prev = getClashHealth()?.meta?.countryFilter;
|
|
270
|
+
countryArg = Array.isArray(prev) && prev.length ? prev.join(",") : "US";
|
|
271
|
+
ok(countryArg === "US,JP", "D3 多国 join → 'US,JP'", countryArg);
|
|
272
|
+
|
|
273
|
+
// 无上次生效记录(clashHealth 缺失)→ 兜底 US
|
|
274
|
+
const nonePrev = null?.meta?.countryFilter;
|
|
275
|
+
const noneArg =
|
|
276
|
+
Array.isArray(nonePrev) && nonePrev.length ? nonePrev.join(",") : "US";
|
|
277
|
+
ok(noneArg === "US", "D4 无记录 → 兜底 US");
|
|
278
|
+
|
|
279
|
+
console.log(`\n结果: ${passed} 通过, ${failed} 失败`);
|
|
280
|
+
process.exit(failed ? 1 : 0);
|
package/src/clash/daemon.js
CHANGED
|
@@ -25,14 +25,22 @@ function fmtCountries(v) {
|
|
|
25
25
|
export class ClashDaemon {
|
|
26
26
|
constructor(opts = {}) {
|
|
27
27
|
this.intervalMs = (opts.intervalSec || 120) * 1000;
|
|
28
|
-
this.benchmarkMs = (opts.benchmarkMin ||
|
|
28
|
+
this.benchmarkMs = (opts.benchmarkMin || 10) * 60 * 1000; // 候选集刷新默认 10 分钟
|
|
29
29
|
this.dryRun = !!opts.dryRun;
|
|
30
30
|
this._running = false;
|
|
31
31
|
this._timers = [];
|
|
32
32
|
this._waitTimer = null; // 等待 Clash 就绪的重试定时器
|
|
33
33
|
this._startedOnce = false; // 首次启动标记(banner/信号只打印注册一次)
|
|
34
34
|
this._benchmarkRunning = false;
|
|
35
|
-
|
|
35
|
+
// 支持逗号分隔字符串(如 "US,JP"),便于 watchdog 以 --country 透传上次生效的多国配置
|
|
36
|
+
let initCountriesRaw = opts.country;
|
|
37
|
+
if (
|
|
38
|
+
typeof initCountriesRaw === "string" &&
|
|
39
|
+
initCountriesRaw.includes(",")
|
|
40
|
+
) {
|
|
41
|
+
initCountriesRaw = initCountriesRaw.split(",");
|
|
42
|
+
}
|
|
43
|
+
const initCountries = normalizeCountries(initCountriesRaw);
|
|
36
44
|
if (initCountries.length) checker.CONFIG.nodeCountryFilter = initCountries;
|
|
37
45
|
this._meta = {
|
|
38
46
|
groupName: checker.CONFIG.groupName,
|
|
@@ -171,21 +179,24 @@ export class ClashDaemon {
|
|
|
171
179
|
h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
|
|
172
180
|
const { nodes } = checker.loadHealthyList();
|
|
173
181
|
if (!nodes.length) {
|
|
174
|
-
this._log(`✗ ${reason} — 候选集为空,触发 delay 初测`);
|
|
182
|
+
this._log(`✗ 巡检异常 [${reason}] — 候选集为空,触发 delay 初测`);
|
|
175
183
|
await this._runDelayProbe();
|
|
176
184
|
return;
|
|
177
185
|
}
|
|
178
186
|
if (this.dryRun) {
|
|
179
|
-
this._log(
|
|
187
|
+
this._log(
|
|
188
|
+
`[DRY-RUN] 当前节点 ${node} 异常 [${reason}],候选: ${nodes.length} 个`,
|
|
189
|
+
);
|
|
180
190
|
return;
|
|
181
191
|
}
|
|
182
192
|
|
|
193
|
+
this._log(`✗ 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`);
|
|
183
194
|
// 逐个切换候选 + 内容验证,第一个满足即采用
|
|
184
195
|
let target = null;
|
|
185
196
|
let h2 = null;
|
|
186
197
|
for (const cand of nodes) {
|
|
187
198
|
if (cand === node) continue;
|
|
188
|
-
this._log(`↺ 尝试候选: ${cand}`);
|
|
199
|
+
this._log(`↺ 尝试候选: ${cand} [原因: ${reason}]`);
|
|
189
200
|
if (!(await checker.switchToNode(cand))) {
|
|
190
201
|
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
191
202
|
continue;
|
|
@@ -201,19 +212,19 @@ export class ClashDaemon {
|
|
|
201
212
|
if (!target) {
|
|
202
213
|
// 全部候选不可用,恢复原节点
|
|
203
214
|
await checker.switchToNode(node).catch(() => {});
|
|
204
|
-
this._log(`✗ ${reason} —
|
|
215
|
+
this._log(`✗ 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`);
|
|
205
216
|
ipc.writeHealth({
|
|
206
217
|
node,
|
|
207
218
|
healthy: false,
|
|
208
219
|
error: `${reason} — 候选全部不可用`,
|
|
209
|
-
lastSwitch:
|
|
220
|
+
lastSwitch: `巡检恢复(${reason}): → ${node}`,
|
|
210
221
|
meta: this._buildMeta(),
|
|
211
222
|
});
|
|
212
223
|
return;
|
|
213
224
|
}
|
|
214
225
|
|
|
215
226
|
this._log(
|
|
216
|
-
`⚠ ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
227
|
+
`⚠ 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
217
228
|
);
|
|
218
229
|
ipc.writeHealth({
|
|
219
230
|
node: target,
|
|
@@ -222,7 +233,7 @@ export class ClashDaemon {
|
|
|
222
233
|
timeTotal: h2.timeTotal,
|
|
223
234
|
sizeDownload: h2.sizeDownload,
|
|
224
235
|
title: h2.title,
|
|
225
|
-
lastSwitch:
|
|
236
|
+
lastSwitch: `巡检异常(${reason}): ${node} → ${target}`,
|
|
226
237
|
meta: this._buildMeta(),
|
|
227
238
|
});
|
|
228
239
|
} catch (e) {
|
|
@@ -323,12 +334,13 @@ export class ClashDaemon {
|
|
|
323
334
|
* 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
|
|
324
335
|
* 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
|
|
325
336
|
*/
|
|
326
|
-
async _handleFailover() {
|
|
337
|
+
async _handleFailover(cmd) {
|
|
327
338
|
try {
|
|
339
|
+
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
328
340
|
const node = await api
|
|
329
341
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
330
342
|
.catch(() => "?");
|
|
331
|
-
this._log(`🔄 看门狗指令: 触发故障切换 (当前: ${node})`);
|
|
343
|
+
this._log(`🔄 看门狗指令: 触发故障切换 [${reason}] (当前: ${node})`);
|
|
332
344
|
const { nodes } = checker.loadHealthyList();
|
|
333
345
|
if (!nodes.length) {
|
|
334
346
|
this._log("🔄 候选集为空,触发 delay 初测");
|
|
@@ -354,16 +366,18 @@ export class ClashDaemon {
|
|
|
354
366
|
}
|
|
355
367
|
if (!target) {
|
|
356
368
|
await checker.switchToNode(node).catch(() => {});
|
|
357
|
-
this._log(
|
|
369
|
+
this._log(
|
|
370
|
+
`✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
|
|
371
|
+
);
|
|
358
372
|
return ipc.writeHealth({
|
|
359
373
|
node,
|
|
360
374
|
healthy: false,
|
|
361
|
-
error:
|
|
362
|
-
lastSwitch:
|
|
375
|
+
error: `failover(${reason}): 候选全部不可用`,
|
|
376
|
+
lastSwitch: `failover恢复(${reason}): → ${node}`,
|
|
363
377
|
meta: this._buildMeta(),
|
|
364
378
|
});
|
|
365
379
|
}
|
|
366
|
-
this._log(`⚠
|
|
380
|
+
this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
|
|
367
381
|
ipc.writeHealth({
|
|
368
382
|
node: target,
|
|
369
383
|
healthy: h2.healthy,
|
|
@@ -371,7 +385,7 @@ export class ClashDaemon {
|
|
|
371
385
|
timeTotal: h2.timeTotal,
|
|
372
386
|
sizeDownload: h2.sizeDownload,
|
|
373
387
|
title: h2.title,
|
|
374
|
-
lastSwitch:
|
|
388
|
+
lastSwitch: `${reason}: ${node} → ${target}`,
|
|
375
389
|
meta: this._buildMeta(),
|
|
376
390
|
});
|
|
377
391
|
} catch (e) {
|
|
@@ -464,7 +478,7 @@ function _buildCmdHandlers(daemon) {
|
|
|
464
478
|
}
|
|
465
479
|
},
|
|
466
480
|
switchNode: (cmd) => daemon._handleSwitchNode(cmd),
|
|
467
|
-
failover: () => daemon._handleFailover(),
|
|
481
|
+
failover: (cmd) => daemon._handleFailover(cmd),
|
|
468
482
|
};
|
|
469
483
|
}
|
|
470
484
|
|
package/src/cli/clash-daemon.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* clash-daemon CLI 命令入口
|
|
3
3
|
*
|
|
4
|
-
* 用法: tt-help clash-daemon [--interval 120] [--benchmark
|
|
4
|
+
* 用法: tt-help clash-daemon [--interval 120] [--benchmark 10] [--country US] [--dry-run]
|
|
5
5
|
*/
|
|
6
6
|
import { ClashDaemon } from "../clash/daemon.js";
|
|
7
7
|
|
package/src/cli/tag.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from "path";
|
|
|
6
6
|
import readline from "readline";
|
|
7
7
|
import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
|
|
8
8
|
import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
|
|
9
|
-
import { getOrCreatePage } from "../lib/browser/page.js";
|
|
9
|
+
import { getOrCreatePage, isBrowserClosedError } from "../lib/browser/page.js";
|
|
10
10
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
11
11
|
import {
|
|
12
12
|
DEFAULT_TARGET_LOCATIONS,
|
|
@@ -48,6 +48,24 @@ function resolveTargetCountries(countries) {
|
|
|
48
48
|
return countries || DEFAULT_SCORE_COUNTRIES;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 判断浏览器是否已断开(用户手动关窗口 / CDP 断开)。
|
|
53
|
+
* 用于让采集循环(score-all / refresh)在浏览器被关闭后自动退出,避免无限重试。
|
|
54
|
+
*/
|
|
55
|
+
function isBrowserDisconnected(browser, e) {
|
|
56
|
+
if (e && isBrowserClosedError(e)) return true;
|
|
57
|
+
try {
|
|
58
|
+
if (
|
|
59
|
+
browser &&
|
|
60
|
+
typeof browser.isConnected === "function" &&
|
|
61
|
+
!browser.isConnected()
|
|
62
|
+
) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
function formatMemoryUsage(mem = process.memoryUsage()) {
|
|
52
70
|
return `rss:${(mem.rss / 1024 / 1024).toFixed(0)}MB heap:${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB ext:${(mem.external / 1024 / 1024).toFixed(0)}MB ab:${(mem.arrayBuffers / 1024 / 1024).toFixed(0)}MB`;
|
|
53
71
|
}
|
|
@@ -1082,6 +1100,12 @@ export async function handleScoreAll(parsed) {
|
|
|
1082
1100
|
|
|
1083
1101
|
try {
|
|
1084
1102
|
while (true) {
|
|
1103
|
+
// 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
|
|
1104
|
+
if (isBrowserDisconnected(browser)) {
|
|
1105
|
+
log("🚪 检测到浏览器窗口已关闭,score-all 自动退出");
|
|
1106
|
+
cleanup();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1085
1109
|
try {
|
|
1086
1110
|
// 从服务端取下一个 new 标签(同时查剩余数量)
|
|
1087
1111
|
const tagsRes = await fetch(
|
|
@@ -1300,6 +1324,12 @@ export async function handleScoreAll(parsed) {
|
|
|
1300
1324
|
await maybeRecycleForMemory();
|
|
1301
1325
|
await randomDelay(3000, 7000);
|
|
1302
1326
|
} catch (e) {
|
|
1327
|
+
// 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
|
|
1328
|
+
if (isBrowserDisconnected(browser, e)) {
|
|
1329
|
+
log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),score-all 自动退出`);
|
|
1330
|
+
cleanup();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1303
1333
|
// 区分网络错误和业务错误
|
|
1304
1334
|
const isNetworkError =
|
|
1305
1335
|
e.code === "ECONNREFUSED" ||
|
|
@@ -1767,6 +1797,12 @@ export async function handleTagRefresh(parsed) {
|
|
|
1767
1797
|
|
|
1768
1798
|
try {
|
|
1769
1799
|
while (true) {
|
|
1800
|
+
// 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
|
|
1801
|
+
if (isBrowserDisconnected(browser)) {
|
|
1802
|
+
log("🚪 检测到浏览器窗口已关闭,refresh 自动退出");
|
|
1803
|
+
cleanup();
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1770
1806
|
try {
|
|
1771
1807
|
// 从服务端获取下一个 productive 标签
|
|
1772
1808
|
const taskRes = await fetch(
|
|
@@ -1987,6 +2023,12 @@ export async function handleTagRefresh(parsed) {
|
|
|
1987
2023
|
await maybeRecycleForMemory();
|
|
1988
2024
|
await randomDelay(3000, 7000);
|
|
1989
2025
|
} catch (e) {
|
|
2026
|
+
// 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
|
|
2027
|
+
if (isBrowserDisconnected(browser, e)) {
|
|
2028
|
+
log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),refresh 自动退出`);
|
|
2029
|
+
cleanup();
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
1990
2032
|
const isNetworkError =
|
|
1991
2033
|
e.code === "ECONNREFUSED" ||
|
|
1992
2034
|
e.code === "ENOTFOUND" ||
|
package/src/lib/args.js
CHANGED
|
@@ -665,7 +665,7 @@ function parseWatchdogArgs(args) {
|
|
|
665
665
|
|
|
666
666
|
function parseClashDaemonArgs(args) {
|
|
667
667
|
let interval = 120;
|
|
668
|
-
let benchmark =
|
|
668
|
+
let benchmark = 10;
|
|
669
669
|
let dryRun = false;
|
|
670
670
|
let country = null;
|
|
671
671
|
|
|
@@ -674,7 +674,7 @@ function parseClashDaemonArgs(args) {
|
|
|
674
674
|
if (arg === "--interval") {
|
|
675
675
|
interval = parseInt(args[++i]) || 120;
|
|
676
676
|
} else if (arg === "--benchmark") {
|
|
677
|
-
benchmark = parseInt(args[++i]) ||
|
|
677
|
+
benchmark = parseInt(args[++i]) || 10;
|
|
678
678
|
} else if (arg === "--country") {
|
|
679
679
|
country = args[++i] || null;
|
|
680
680
|
} else if (arg === "--dry-run") {
|
package/src/lib/constants.js
CHANGED
|
@@ -393,7 +393,7 @@ const HELP_TEXT = [
|
|
|
393
393
|
" 看门狗启动时会自动拉起此守护进程",
|
|
394
394
|
" 选项:",
|
|
395
395
|
" --interval <秒> 巡检间隔,默认 120 秒(2分钟)",
|
|
396
|
-
" --benchmark <分钟> 候选集 delay 初测间隔,默认
|
|
396
|
+
" --benchmark <分钟> 候选集 delay 初测间隔,默认 10 分钟",
|
|
397
397
|
" --country <国家代码> 只保留指定国家节点,默认不过滤",
|
|
398
398
|
" 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
|
|
399
399
|
" --dry-run 只检测不切换(调试用)",
|
|
@@ -330,11 +330,17 @@ async function handleHeartbeat(params, body, res) {
|
|
|
330
330
|
const userId = (body.config && body.config.userId) || "";
|
|
331
331
|
const commands = getClientCommands(userId);
|
|
332
332
|
|
|
333
|
-
// 幂等下发节点国家配置(默认 US
|
|
333
|
+
// 幂等下发节点国家配置(默认 US)
|
|
334
|
+
// 幂等基准 = 客户端 daemon 实际生效国家(body.clashHealth.meta.countryFilter),而非"是否下发过"。
|
|
335
|
+
// 否则 daemon 重启(崩溃自愈/升级/机器重启)回退到 --country US 后,服务端会因"配置没变"而不补发,
|
|
336
|
+
// 导致实际一直用美国节点(生效与配置不一致)。
|
|
334
337
|
const countries = getClientCountries(userId);
|
|
335
338
|
const countryKey = JSON.stringify(countries);
|
|
339
|
+
const activeKey = JSON.stringify(
|
|
340
|
+
body.clashHealth?.meta?.countryFilter || null,
|
|
341
|
+
);
|
|
336
342
|
const pending = clashCmdQueue.get(userId) || [];
|
|
337
|
-
if (countryKey !==
|
|
343
|
+
if (countryKey !== activeKey) {
|
|
338
344
|
pending.push({ action: "setCountryFilter", countries });
|
|
339
345
|
lastSentCountries.set(userId, countryKey);
|
|
340
346
|
}
|
package/src/watchdog/agent.js
CHANGED
|
@@ -195,6 +195,7 @@ export class WatchdogAgent {
|
|
|
195
195
|
this._errorTriggerCount = ERROR_TRIGGER_COUNT;
|
|
196
196
|
this._errorTriggerMinMs = ERROR_MIN_MS;
|
|
197
197
|
this._errorTriggerBreakMs = ERROR_BREAK_MS;
|
|
198
|
+
this._lastErrorTriggerKey = null; // 上次配置指纹(幂等打印用)
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
/**
|
|
@@ -303,6 +304,10 @@ export class WatchdogAgent {
|
|
|
303
304
|
this._errorTriggerMinMs = cfg.minMinutes * 60 * 1000;
|
|
304
305
|
if (cfg?.breakSeconds > 0)
|
|
305
306
|
this._errorTriggerBreakMs = cfg.breakSeconds * 1000;
|
|
307
|
+
// 幂等打印:参数没变化就不刷屏(心跳每次都会带 errorTrigger,此前会每次心跳都打一条)
|
|
308
|
+
const key = `${this._errorTriggerEnabled}|${this._errorTriggerCount}|${this._errorTriggerMinMs}|${this._errorTriggerBreakMs}`;
|
|
309
|
+
if (key === this._lastErrorTriggerKey) return;
|
|
310
|
+
this._lastErrorTriggerKey = key;
|
|
306
311
|
console.error(
|
|
307
312
|
`[watchdog] 错误触发切换: ${this._errorTriggerEnabled ? "已启用" : "已禁用"} (连续${this._errorTriggerCount}次 / 持续${this._errorTriggerMinMs / 60000}min / 中断${this._errorTriggerBreakMs / 1000}s)`,
|
|
308
313
|
);
|
|
@@ -323,6 +328,7 @@ export class WatchdogAgent {
|
|
|
323
328
|
firstErrorAt: 0,
|
|
324
329
|
lastErrorAt: 0,
|
|
325
330
|
};
|
|
331
|
+
const hadNew = newErrors.length > 0;
|
|
326
332
|
for (const line of newErrors) {
|
|
327
333
|
// 错误间隔超阈值 → 连续中断,重新计数
|
|
328
334
|
if (
|
|
@@ -349,6 +355,15 @@ export class WatchdogAgent {
|
|
|
349
355
|
}
|
|
350
356
|
}
|
|
351
357
|
this._errorStates.set(cmdId, st);
|
|
358
|
+
// 动态进度:仅本轮扫描到新增错误时打印(扫描间隔 30s,天然低频不刷屏)
|
|
359
|
+
if (hadNew && st.consecutive > 0 && st.firstErrorAt) {
|
|
360
|
+
const durMs = now - st.firstErrorAt;
|
|
361
|
+
const durMin = Math.floor(durMs / 60000);
|
|
362
|
+
const durSec = Math.floor((durMs % 60000) / 1000);
|
|
363
|
+
console.error(
|
|
364
|
+
`[watchdog] 错误累计: ${cmdId} 连续 ${st.consecutive} 次 / 已持续 ${durMin}min${durSec}s (阈值: 连续${this._errorTriggerCount}次/持续${this._errorTriggerMinMs / 60000}min)`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
352
367
|
}
|
|
353
368
|
// 无新错误的进程:长时间无错误则重置连续计数
|
|
354
369
|
for (const [cmdId, st] of this._errorStates) {
|
|
@@ -367,7 +382,10 @@ export class WatchdogAgent {
|
|
|
367
382
|
async _triggerFailover(cmdId) {
|
|
368
383
|
try {
|
|
369
384
|
const ipc = await import("../clash/ipc.js");
|
|
370
|
-
ipc.writeCommand({
|
|
385
|
+
ipc.writeCommand({
|
|
386
|
+
action: "failover",
|
|
387
|
+
reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
|
|
388
|
+
});
|
|
371
389
|
console.error(`[watchdog] 已下发 failover 命令 (触发源: ${cmdId})`);
|
|
372
390
|
} catch (e) {
|
|
373
391
|
console.error(`[watchdog] 下发 failover 失败: ${e.message}`);
|
|
@@ -597,12 +615,23 @@ export class WatchdogAgent {
|
|
|
597
615
|
}
|
|
598
616
|
|
|
599
617
|
console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
|
|
618
|
+
// 上次生效的国家优先(从 daemon 写入的 clash-health.json 读取),避免重启回退到硬编码 US
|
|
619
|
+
// 服务端心跳会按"实际生效 vs 配置"收敛,这里作为下发前窗口/服务端不可达时的兜底
|
|
620
|
+
let countryArg = "US";
|
|
600
621
|
try {
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
622
|
+
const prev = getClashHealth()?.meta?.countryFilter;
|
|
623
|
+
if (Array.isArray(prev) && prev.length) countryArg = prev.join(",");
|
|
624
|
+
} catch {}
|
|
625
|
+
try {
|
|
626
|
+
const child = spawn(
|
|
627
|
+
process.execPath,
|
|
628
|
+
[daemonPath, "--country", countryArg],
|
|
629
|
+
{
|
|
630
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
631
|
+
detached: false,
|
|
632
|
+
windowsHide: true, // Windows 隐藏控制台窗口
|
|
633
|
+
},
|
|
634
|
+
);
|
|
606
635
|
|
|
607
636
|
child.stdout?.on("data", (d) => {
|
|
608
637
|
// daemon 日志通过 stderr 输出,stdout 静默
|
|
@@ -15,18 +15,34 @@ export async function analyzeProcessLogs(processLogs, llmFetch) {
|
|
|
15
15
|
const results = [];
|
|
16
16
|
for (const proc of processLogs) {
|
|
17
17
|
try {
|
|
18
|
-
console.error(
|
|
18
|
+
console.error(
|
|
19
|
+
`[llm-analyzer] 分析 ${proc.cmdId.substring(0, 40)}... (${proc.logs.length} 行日志)`,
|
|
20
|
+
);
|
|
19
21
|
const content = await llmFetch({
|
|
20
|
-
messages: [
|
|
22
|
+
messages: [
|
|
23
|
+
{
|
|
24
|
+
role: "user",
|
|
25
|
+
content: buildPrompt(
|
|
26
|
+
proc.cmdId,
|
|
27
|
+
proc.logs.slice(-200).join("\n"),
|
|
28
|
+
proc.uptimeSeconds,
|
|
29
|
+
proc.restartCount,
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
21
33
|
max_tokens: 256,
|
|
22
34
|
temperature: 0.1,
|
|
23
35
|
enable_thinking: false,
|
|
24
36
|
});
|
|
25
37
|
const parsed = parseResponse(content);
|
|
26
|
-
console.error(
|
|
38
|
+
console.error(
|
|
39
|
+
`[llm-analyzer] ${proc.cmdId.substring(0, 30)} → ${parsed.severity}: ${parsed.summary}`,
|
|
40
|
+
);
|
|
27
41
|
results.push({ cmdId: proc.cmdId, ...parsed });
|
|
28
42
|
} catch (e) {
|
|
29
|
-
console.error(
|
|
43
|
+
console.error(
|
|
44
|
+
`[llm-analyzer] ${proc.cmdId.substring(0, 30)} 失败: ${e.message}`,
|
|
45
|
+
);
|
|
30
46
|
}
|
|
31
47
|
}
|
|
32
48
|
return results;
|
|
@@ -39,21 +55,32 @@ async function createStandaloneFetch() {
|
|
|
39
55
|
const { existsSync, readFileSync } = await import("fs");
|
|
40
56
|
const { join } = await import("path");
|
|
41
57
|
const { homedir } = await import("os");
|
|
42
|
-
const homeDir =
|
|
58
|
+
const homeDir =
|
|
59
|
+
homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
43
60
|
let fileCfg = {};
|
|
44
61
|
try {
|
|
45
62
|
const cfgPath = join(homeDir, ".tt-help.json");
|
|
46
|
-
if (existsSync(cfgPath))
|
|
63
|
+
if (existsSync(cfgPath))
|
|
64
|
+
fileCfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
47
65
|
} catch {}
|
|
48
|
-
const url =
|
|
66
|
+
const url =
|
|
67
|
+
process.env.LLM_URL ||
|
|
68
|
+
fileCfg.llmUrl ||
|
|
69
|
+
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
|
|
49
70
|
const apiKey = process.env.LLM_API_KEY || fileCfg.llmApiKey || "";
|
|
50
71
|
const model = process.env.LLM_MODEL || fileCfg.llmModel || "auto";
|
|
51
|
-
if (!apiKey) {
|
|
72
|
+
if (!apiKey) {
|
|
73
|
+
console.error("[llm-analyzer] 未配置 LLM_API_KEY");
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
52
76
|
console.error(`[llm-analyzer] 客户端模式: ${model} @ ${url}`);
|
|
53
77
|
return async ({ messages, max_tokens = 256, temperature = 0.1 }) => {
|
|
54
78
|
const resp = await fetch(url, {
|
|
55
79
|
method: "POST",
|
|
56
|
-
headers: {
|
|
80
|
+
headers: {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
Authorization: `Bearer ${apiKey}`,
|
|
83
|
+
},
|
|
57
84
|
body: JSON.stringify({ model, messages, max_tokens, temperature }),
|
|
58
85
|
signal: AbortSignal.timeout(30000),
|
|
59
86
|
});
|
|
@@ -72,9 +99,17 @@ async function createStandaloneFetch() {
|
|
|
72
99
|
// ── Prompt ──
|
|
73
100
|
|
|
74
101
|
function buildPrompt(cmdId, logs, uptimeSeconds, restartCount) {
|
|
75
|
-
const uptime =
|
|
76
|
-
|
|
77
|
-
|
|
102
|
+
const uptime =
|
|
103
|
+
uptimeSeconds >= 3600
|
|
104
|
+
? Math.round(uptimeSeconds / 3600) + "h"
|
|
105
|
+
: uptimeSeconds + "s";
|
|
106
|
+
const logText = logs || "(无日志)";
|
|
107
|
+
// 统计错误码 209002(TikTok 验证码/风控)出现次数,写入 prompt 避免 LLM 误判为故障
|
|
108
|
+
const captchaCount = logText.split("209002").length - 1;
|
|
109
|
+
const captchaNote =
|
|
110
|
+
captchaCount > 0
|
|
111
|
+
? `\n背景: 日志中出现 ${captchaCount} 处错误码 209002(TikTok 验证码/风控,正常现象,程序会自动重试),判断时请忽略它们,不要当作 error,也不要在建议里写"账号被封禁/更换账号/更换IP"。`
|
|
112
|
+
: "";
|
|
78
113
|
return `你是 TikTok 数据采集工具的运维助手。以下是进程 "${cmdId}" 的最近 10 分钟日志,请分析是否有异常。
|
|
79
114
|
|
|
80
115
|
进程信息:
|
|
@@ -83,15 +118,16 @@ function buildPrompt(cmdId, logs, uptimeSeconds, restartCount) {
|
|
|
83
118
|
- 历史重启: ${restartCount} 次
|
|
84
119
|
|
|
85
120
|
最近日志:
|
|
86
|
-
${
|
|
121
|
+
${logText}${captchaNote}
|
|
87
122
|
|
|
88
123
|
请返回 JSON(仅 JSON,无其他内容):
|
|
89
124
|
{"severity":"ok"|"warn"|"error","summary":"一句话中文总结,不超过 50 字","suggestions":["建议1","建议2"]}
|
|
90
125
|
|
|
91
126
|
判断标准:
|
|
92
127
|
- ok: 无异常,正常运行
|
|
93
|
-
- warn:
|
|
94
|
-
- error: 严重错误(连续崩溃、登录失效、端口占用、大量 403
|
|
128
|
+
- warn: 有偶发错误(403、超时 < 3次)或内存偏高
|
|
129
|
+
- error: 严重错误(连续崩溃、登录失效、端口占用、大量 403/超时)
|
|
130
|
+
- 重要: 错误码 209002 = 验证码/风控,是正常限流,不是故障,不算 error;只有真正的登录失效/崩溃/端口占用才算 error
|
|
95
131
|
- 每个 suggestions 是简洁可操作的建议,不超过 3 条`;
|
|
96
132
|
}
|
|
97
133
|
|
|
@@ -100,15 +136,31 @@ ${logs || "(无日志)"}
|
|
|
100
136
|
function parseResponse(content) {
|
|
101
137
|
try {
|
|
102
138
|
const parsed = JSON.parse(content);
|
|
103
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
severity: parsed.severity || "ok",
|
|
141
|
+
summary: parsed.summary || "",
|
|
142
|
+
suggestions: Array.isArray(parsed.suggestions)
|
|
143
|
+
? parsed.suggestions.slice(0, 3)
|
|
144
|
+
: [],
|
|
145
|
+
};
|
|
104
146
|
} catch {
|
|
105
147
|
const match = content.match(/\{[\s\S]*\}/);
|
|
106
148
|
if (match) {
|
|
107
149
|
try {
|
|
108
150
|
const parsed = JSON.parse(match[0]);
|
|
109
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
severity: parsed.severity || "ok",
|
|
153
|
+
summary: parsed.summary || "",
|
|
154
|
+
suggestions: Array.isArray(parsed.suggestions)
|
|
155
|
+
? parsed.suggestions.slice(0, 3)
|
|
156
|
+
: [],
|
|
157
|
+
};
|
|
110
158
|
} catch {}
|
|
111
159
|
}
|
|
112
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
severity: "warn",
|
|
162
|
+
summary: content.substring(0, 100),
|
|
163
|
+
suggestions: [],
|
|
164
|
+
};
|
|
113
165
|
}
|
|
114
166
|
}
|
package/src/webserver/server.mjs
CHANGED
|
@@ -86,6 +86,9 @@ let scraperInitPromise = null;
|
|
|
86
86
|
let restartPromise = null; // 防重入:同一时间只允许一个 restartScraper
|
|
87
87
|
let consecutiveErrors = 0;
|
|
88
88
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
89
|
+
// TikTok 明确返回的状态码中可重试的(验证码/风控 209002);
|
|
90
|
+
// 其余明确状态码(10202 被封、10221 不存在等)虽不可重试,同样属于业务结果而非 webserver 故障
|
|
91
|
+
const TIKTOK_RETRYABLE_NORMAL_CODES = new Set([209002]);
|
|
89
92
|
let warmTimer = null;
|
|
90
93
|
let lastWarmTime = 0;
|
|
91
94
|
let healthTimer = null; // 每 10 分钟健康心跳日志(始终输出,防假死检测误判重启)
|
|
@@ -252,15 +255,29 @@ async function handleTiktokUser(req, res) {
|
|
|
252
255
|
});
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
if (result.error && result.statusCode) {
|
|
259
|
+
// TikTok 明确返回的状态码(209002 验证码、10202 被封、10221 不存在等):
|
|
260
|
+
// 属业务结果而非 webserver 故障,不计入连续错误、不触发 scraper 重启
|
|
261
|
+
const retryable = TIKTOK_RETRYABLE_NORMAL_CODES.has(result.statusCode);
|
|
262
|
+
apiLog(
|
|
263
|
+
`[webserver] user business | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | statusCode=${result.statusCode}${retryable ? " (可重试)" : ""}`,
|
|
264
|
+
);
|
|
265
|
+
recordRequest(true);
|
|
266
|
+
return sendJSON(res, 503, {
|
|
267
|
+
error: `TikTok statusCode=${result.statusCode}`,
|
|
268
|
+
retryable,
|
|
269
|
+
tiktokStatusCode: result.statusCode,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
255
272
|
if (result.error) {
|
|
273
|
+
// 无明确状态码但 error(解析/结构异常)→ 才算 webserver 故障
|
|
256
274
|
console.error(
|
|
257
|
-
`[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms |
|
|
275
|
+
`[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | parse error (无明确 statusCode)`,
|
|
258
276
|
);
|
|
259
277
|
recordRequest(false);
|
|
260
278
|
return sendJSON(res, 502, {
|
|
261
|
-
error:
|
|
279
|
+
error: "TikTok error (无明确 statusCode)",
|
|
262
280
|
retryable: false,
|
|
263
|
-
tiktokStatusCode: result.statusCode,
|
|
264
281
|
});
|
|
265
282
|
}
|
|
266
283
|
|