tt-help-cli-ycl 1.4.78 → 1.4.80
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/src/clash/api.js +17 -0
- package/src/clash/checker.js +218 -94
- package/src/clash/daemon.js +227 -68
- package/src/clash/ipc.js +23 -4
- package/src/cli/clash-daemon.js +2 -1
- package/src/cli/explore.js +15 -4
- package/src/cli/refresh.js +15 -6
- package/src/cli/search.js +16 -7
- package/src/cli/webserver.js +8 -2
- package/src/lib/args.js +8 -0
- package/src/lib/constants.js +23 -0
- package/src/lib/shop-scraper.mjs +34 -17
- package/src/watch/public/app.js +150 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/watchdog-server.js +32 -1
- package/src/watchdog/agent.js +20 -2
- package/src/watchdog/process-manager.js +57 -0
- package/src/webserver/server.mjs +197 -27
- package/src/clash/health-checker.js +0 -248
package/src/lib/args.js
CHANGED
|
@@ -407,6 +407,7 @@ function parseWebserverArgs(args) {
|
|
|
407
407
|
let webserverPort = 3000;
|
|
408
408
|
let webserverPoolSize = 0;
|
|
409
409
|
let webserverProxy = null;
|
|
410
|
+
let webserverLog = false;
|
|
410
411
|
// 默认汇报到 ~/.tt-help.json 中配置的 server 地址
|
|
411
412
|
let webserverNotify = defaultServer;
|
|
412
413
|
|
|
@@ -418,6 +419,8 @@ function parseWebserverArgs(args) {
|
|
|
418
419
|
webserverPoolSize = parseInt(args[++i]) || 0;
|
|
419
420
|
} else if (arg === "--proxy") {
|
|
420
421
|
webserverProxy = args[++i] || null;
|
|
422
|
+
} else if (arg === "--log") {
|
|
423
|
+
webserverLog = true;
|
|
421
424
|
} else if (arg === "--notify") {
|
|
422
425
|
const next = args[i + 1];
|
|
423
426
|
// 没写地址或下一个是别的参数 → 使用配置中的 server 地址
|
|
@@ -434,6 +437,7 @@ function parseWebserverArgs(args) {
|
|
|
434
437
|
webserverPort,
|
|
435
438
|
webserverPoolSize,
|
|
436
439
|
webserverProxy,
|
|
440
|
+
webserverLog,
|
|
437
441
|
webserverNotify,
|
|
438
442
|
urls: [],
|
|
439
443
|
outputFile: null,
|
|
@@ -659,6 +663,7 @@ function parseClashDaemonArgs(args) {
|
|
|
659
663
|
let interval = 30;
|
|
660
664
|
let benchmark = 40;
|
|
661
665
|
let dryRun = false;
|
|
666
|
+
let country = null;
|
|
662
667
|
|
|
663
668
|
for (let i = 0; i < args.length; i++) {
|
|
664
669
|
const arg = args[i];
|
|
@@ -666,6 +671,8 @@ function parseClashDaemonArgs(args) {
|
|
|
666
671
|
interval = parseInt(args[++i]) || 30;
|
|
667
672
|
} else if (arg === "--benchmark") {
|
|
668
673
|
benchmark = parseInt(args[++i]) || 40;
|
|
674
|
+
} else if (arg === "--country") {
|
|
675
|
+
country = args[++i] || null;
|
|
669
676
|
} else if (arg === "--dry-run") {
|
|
670
677
|
dryRun = true;
|
|
671
678
|
}
|
|
@@ -675,6 +682,7 @@ function parseClashDaemonArgs(args) {
|
|
|
675
682
|
subcommand: "clash-daemon",
|
|
676
683
|
interval,
|
|
677
684
|
benchmark,
|
|
685
|
+
country,
|
|
678
686
|
"dry-run": dryRun,
|
|
679
687
|
urls: [],
|
|
680
688
|
outputFormat: "json",
|
package/src/lib/constants.js
CHANGED
|
@@ -247,6 +247,7 @@ const HELP_TEXT = [
|
|
|
247
247
|
" -p, --port <端口号> 监听端口,默认 3000",
|
|
248
248
|
" --pool-size <数量> 浏览器页面池大小,默认 8",
|
|
249
249
|
" --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
|
|
250
|
+
" --log 显示 API 成功日志(默认不显示,错误日志始终输出)",
|
|
250
251
|
" --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
|
|
251
252
|
" API:",
|
|
252
253
|
" GET /health 健康检查",
|
|
@@ -255,6 +256,7 @@ const HELP_TEXT = [
|
|
|
255
256
|
" POST /api/tiktok/video 获取视频信息 { videoUrl: string }",
|
|
256
257
|
" POST /api/tiktok/lookup 同时获取视频和作者信息 { videoUrl: string }",
|
|
257
258
|
" 示例: tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897 --notify",
|
|
259
|
+
" tt-help webserver -p 3000 --log",
|
|
258
260
|
"",
|
|
259
261
|
" comments [选项]",
|
|
260
262
|
" 评论采集:从视频评论中发现新用户,来源标记为 comment",
|
|
@@ -385,6 +387,26 @@ const HELP_TEXT = [
|
|
|
385
387
|
" tt-help watchdog --server http://117.71.53.99:17301",
|
|
386
388
|
" tt-help watchdog -i 30 --client-id my-server-01",
|
|
387
389
|
"",
|
|
390
|
+
" tt-help clash-daemon [选项]",
|
|
391
|
+
" Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
|
|
392
|
+
" 看门狗启动时会自动拉起此守护进程",
|
|
393
|
+
" 选项:",
|
|
394
|
+
" --interval <秒> 巡检间隔,默认 30 秒",
|
|
395
|
+
" --benchmark <分钟> 全量测速间隔,默认 40 分钟",
|
|
396
|
+
" --country <国家代码> 只保留指定国家节点,默认不过滤",
|
|
397
|
+
" 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
|
|
398
|
+
" --dry-run 只检测不切换(调试用)",
|
|
399
|
+
" 看门狗运行时可通过 /tmp/clash-cmd.json 发命令动态调整:",
|
|
400
|
+
' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
|
|
401
|
+
' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
|
|
402
|
+
' {"action":"setCountryFilter","country":null} 取消过滤',
|
|
403
|
+
' {"action":"runBenchmark"} 手动触发全量测速',
|
|
404
|
+
' {"action":"setInterval","seconds":60} 修改巡检间隔',
|
|
405
|
+
" 示例: tt-help clash-daemon",
|
|
406
|
+
" tt-help clash-daemon --country US",
|
|
407
|
+
" tt-help clash-daemon --interval 15 --benchmark 20 --country US",
|
|
408
|
+
" tt-help clash-daemon --dry-run",
|
|
409
|
+
"",
|
|
388
410
|
" config [show|set|unset|reset]",
|
|
389
411
|
" config 查看当前配置",
|
|
390
412
|
" config set <key> <value> 设置配置(key: proxy, server, browser, userId, maxFollowing, maxFollowers, maxVideos, maxComments)",
|
|
@@ -400,6 +422,7 @@ const HELP_TEXT = [
|
|
|
400
422
|
" tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
|
|
401
423
|
" tt-help refresh --server http://127.0.0.1:3001 --port 9222",
|
|
402
424
|
" tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
|
|
425
|
+
" tt-help webserver -p 3000 --log",
|
|
403
426
|
" tt-help config set server http://127.0.0.1:3001",
|
|
404
427
|
" tt-help attach -p 5 -i 10",
|
|
405
428
|
" tt-help watch -o data/result.db",
|
package/src/lib/shop-scraper.mjs
CHANGED
|
@@ -26,14 +26,31 @@ const PDP_HEADERS = {
|
|
|
26
26
|
"Accept-Language": "en-US,en;q=0.9",
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
// 浏览器解验证码后缓存的 Cookie
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
function getCookieHeader() {
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
// 浏览器解验证码后缓存的 Cookie,按代理节点隔离(避免切节点后旧 Cookie 被冲掉)
|
|
30
|
+
const cookieCache = new Map(); // proxyUrl → { cookies, expireAt }
|
|
31
|
+
const COOKIE_TTL_MS = 60 * 60 * 1000; // Cookie 有效期 60 分钟
|
|
32
|
+
|
|
33
|
+
/** 按代理节点获取缓存的 Cookie,过期自动清除 */
|
|
34
|
+
function getCookieHeader(proxyUrl) {
|
|
35
|
+
const entry = cookieCache.get(proxyUrl);
|
|
36
|
+
if (!entry || Date.now() > entry.expireAt) {
|
|
37
|
+
if (entry) cookieCache.delete(proxyUrl);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return entry.cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 按代理节点缓存 Cookie */
|
|
44
|
+
function setCachedCookies(proxyUrl, cookies) {
|
|
45
|
+
cookieCache.set(proxyUrl, {
|
|
46
|
+
cookies,
|
|
47
|
+
expireAt: Date.now() + COOKIE_TTL_MS,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 清除指定代理节点的 Cookie */
|
|
52
|
+
function clearCachedCookies(proxyUrl) {
|
|
53
|
+
cookieCache.delete(proxyUrl);
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
/**
|
|
@@ -140,14 +157,13 @@ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
|
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
if (solved) {
|
|
143
|
-
// 提取 Cookie
|
|
160
|
+
// 提取 Cookie,按代理节点缓存(切回同一节点时可复用)
|
|
144
161
|
try {
|
|
145
162
|
const cookies = await page.context().cookies();
|
|
146
163
|
if (cookies.length > 0) {
|
|
147
|
-
|
|
148
|
-
cookiesExpireAt = Date.now() + COOKIE_TTL_MS;
|
|
164
|
+
setCachedCookies(proxyUrl, cookies);
|
|
149
165
|
console.error(
|
|
150
|
-
`[shop-scraper] Captcha solved, cached ${cookies.length} cookies for
|
|
166
|
+
`[shop-scraper] Captcha solved, cached ${cookies.length} cookies for proxy ${proxyUrl} (TTL ${COOKIE_TTL_MS / 60000}min)`,
|
|
151
167
|
);
|
|
152
168
|
}
|
|
153
169
|
} catch (e) {
|
|
@@ -177,7 +193,7 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
177
193
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
178
194
|
try {
|
|
179
195
|
const headers = { ...PDP_HEADERS };
|
|
180
|
-
const cookieStr = getCookieHeader();
|
|
196
|
+
const cookieStr = getCookieHeader(proxy);
|
|
181
197
|
if (cookieStr) {
|
|
182
198
|
headers["Cookie"] = cookieStr;
|
|
183
199
|
}
|
|
@@ -195,14 +211,15 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
195
211
|
} catch (err) {
|
|
196
212
|
lastError = err;
|
|
197
213
|
|
|
198
|
-
// Captcha
|
|
214
|
+
// Captcha:清除当前代理节点的过期 Cookie,弹浏览器重新获取
|
|
199
215
|
if (
|
|
200
216
|
err.message?.includes("Blocked by TikTok") &&
|
|
201
217
|
options.captchaFallback
|
|
202
218
|
) {
|
|
203
|
-
console.error(
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
console.error(
|
|
220
|
+
`[shop-scraper] Captcha on ${proxy}, clearing cached cookies...`,
|
|
221
|
+
);
|
|
222
|
+
clearCachedCookies(proxy);
|
|
206
223
|
console.error("[shop-scraper] Opening browser for manual captcha...");
|
|
207
224
|
await resolveCaptchaWithBrowser(url, proxy);
|
|
208
225
|
// 重试(带着新 Cookie)
|
package/src/watch/public/app.js
CHANGED
|
@@ -3811,10 +3811,160 @@ function renderClientCard(c, clientCommands) {
|
|
|
3811
3811
|
header +
|
|
3812
3812
|
styled("div", "color:#888;font-size:12px", osStr) +
|
|
3813
3813
|
styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
|
|
3814
|
+
renderProxySection(c, userId) +
|
|
3814
3815
|
renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis),
|
|
3815
3816
|
);
|
|
3816
3817
|
}
|
|
3817
3818
|
|
|
3819
|
+
// ── 代理节点区域(看门狗面板中每个客户端卡片) ──
|
|
3820
|
+
|
|
3821
|
+
function renderProxySection(c, userId) {
|
|
3822
|
+
var h = c.clashHealth;
|
|
3823
|
+
if (!h || !h.node) return "";
|
|
3824
|
+
|
|
3825
|
+
var nodes = (h.meta && h.meta.healthyNodes) || [];
|
|
3826
|
+
var hasNodes = nodes.length > 0;
|
|
3827
|
+
|
|
3828
|
+
return (
|
|
3829
|
+
renderProxyLine(h, nodes, userId, hasNodes) +
|
|
3830
|
+
renderNodeList(nodes, h.node, userId, hasNodes)
|
|
3831
|
+
);
|
|
3832
|
+
}
|
|
3833
|
+
|
|
3834
|
+
/** 代理状态行:当前节点 + 健康状态 + 展开按钮 */
|
|
3835
|
+
function renderProxyLine(h, nodes, userId, hasNodes) {
|
|
3836
|
+
var dot = h.healthy ? "#4ade80" : "#f87171";
|
|
3837
|
+
var status = h.healthy
|
|
3838
|
+
? (h.timeTotal || 0).toFixed(1) +
|
|
3839
|
+
"s " +
|
|
3840
|
+
((h.sizeDownload || 0) / 1024).toFixed(0) +
|
|
3841
|
+
"KB"
|
|
3842
|
+
: h.error || "异常";
|
|
3843
|
+
|
|
3844
|
+
return styled(
|
|
3845
|
+
"div",
|
|
3846
|
+
"margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
|
|
3847
|
+
"display:flex;align-items:center;gap:8px;font-size:12px",
|
|
3848
|
+
styled("span", "color:" + dot + ";font-size:14px", "●") +
|
|
3849
|
+
styled("span", "color:#e0e0e0;font-weight:bold", h.node) +
|
|
3850
|
+
styled("span", "color:#666", "| " + status) +
|
|
3851
|
+
(hasNodes
|
|
3852
|
+
? tag(
|
|
3853
|
+
"button",
|
|
3854
|
+
{
|
|
3855
|
+
onclick: "wdToggleNodes('" + esc(userId) + "')",
|
|
3856
|
+
style:
|
|
3857
|
+
"margin-left:auto;padding:2px 8px;font-size:11px;background:#333;" +
|
|
3858
|
+
"color:#60a5fa;border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
3859
|
+
title: "查看可用节点列表",
|
|
3860
|
+
},
|
|
3861
|
+
nodes.length + " 个节点 ▸",
|
|
3862
|
+
)
|
|
3863
|
+
: ""),
|
|
3864
|
+
);
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
/** 节点列表面板(默认隐藏) */
|
|
3868
|
+
function renderNodeList(nodes, currentNode, userId, hasNodes) {
|
|
3869
|
+
if (!hasNodes) return "";
|
|
3870
|
+
|
|
3871
|
+
var items = "";
|
|
3872
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
3873
|
+
items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId);
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
var id = "wdNodes-" + wdEscId(userId);
|
|
3877
|
+
return (
|
|
3878
|
+
'<div id="' +
|
|
3879
|
+
id +
|
|
3880
|
+
'" style="margin-top:4px;border:1px solid #2a2a3a;' +
|
|
3881
|
+
'border-radius:6px;overflow:hidden;display:none">' +
|
|
3882
|
+
items +
|
|
3883
|
+
"</div>"
|
|
3884
|
+
);
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
/** 单个节点行 */
|
|
3888
|
+
function renderNodeItem(name, isActive, userId) {
|
|
3889
|
+
var color = isActive ? "#4ade80" : "#888";
|
|
3890
|
+
var bg = isActive ? "background:#1a3a2a;" : "";
|
|
3891
|
+
var dot = isActive ? "●" : "○";
|
|
3892
|
+
var textColor = isActive ? "#4ade80" : "#aaa";
|
|
3893
|
+
var action = isActive
|
|
3894
|
+
? '<span style="margin-left:auto;color:#555;font-size:10px">当前</span>'
|
|
3895
|
+
: "<button onclick=\"wdSwitchNode('" +
|
|
3896
|
+
esc(userId) +
|
|
3897
|
+
"','" +
|
|
3898
|
+
esc(name) +
|
|
3899
|
+
"')\" " +
|
|
3900
|
+
'style="margin-left:auto;padding:1px 6px;font-size:10px;background:#2563eb;' +
|
|
3901
|
+
'color:#fff;border:none;border-radius:2px;cursor:pointer">切换</button>';
|
|
3902
|
+
|
|
3903
|
+
return (
|
|
3904
|
+
'<div style="padding:3px 8px;display:flex;align-items:center;gap:6px;font-size:11px;' +
|
|
3905
|
+
bg +
|
|
3906
|
+
'">' +
|
|
3907
|
+
'<span style="color:' +
|
|
3908
|
+
color +
|
|
3909
|
+
'">' +
|
|
3910
|
+
dot +
|
|
3911
|
+
"</span>" +
|
|
3912
|
+
'<span style="color:' +
|
|
3913
|
+
textColor +
|
|
3914
|
+
'">' +
|
|
3915
|
+
name +
|
|
3916
|
+
"</span>" +
|
|
3917
|
+
action +
|
|
3918
|
+
"</div>"
|
|
3919
|
+
);
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
// ── 交互函数 ──
|
|
3923
|
+
|
|
3924
|
+
function wdEscId(s) {
|
|
3925
|
+
return (s || "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
function wdToggleNodes(userId) {
|
|
3929
|
+
var el = document.getElementById("wdNodes-" + wdEscId(userId));
|
|
3930
|
+
if (!el) return;
|
|
3931
|
+
el.style.display = el.style.display === "none" ? "block" : "none";
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
function wdSwitchNode(userId, node) {
|
|
3935
|
+
if (
|
|
3936
|
+
!confirm(
|
|
3937
|
+
"确定要将 " +
|
|
3938
|
+
userId +
|
|
3939
|
+
" 的代理切换到: " +
|
|
3940
|
+
node +
|
|
3941
|
+
" ?\n\n切换后自动巡检会继续运行。",
|
|
3942
|
+
)
|
|
3943
|
+
)
|
|
3944
|
+
return;
|
|
3945
|
+
|
|
3946
|
+
fetch("/api/watchdog/switch-node", {
|
|
3947
|
+
method: "POST",
|
|
3948
|
+
headers: { "Content-Type": "application/json" },
|
|
3949
|
+
body: JSON.stringify({ userId: userId, node: node }),
|
|
3950
|
+
})
|
|
3951
|
+
.then(function (r) {
|
|
3952
|
+
return r.json();
|
|
3953
|
+
})
|
|
3954
|
+
.then(function (d) {
|
|
3955
|
+
alert(
|
|
3956
|
+
d.ok
|
|
3957
|
+
? "已发送切换指令: " +
|
|
3958
|
+
node +
|
|
3959
|
+
"\n\n客户端将在下次心跳时执行(约15秒内)。"
|
|
3960
|
+
: "失败: " + (d.error || "未知错误"),
|
|
3961
|
+
);
|
|
3962
|
+
})
|
|
3963
|
+
.catch(function (e) {
|
|
3964
|
+
alert("请求失败: " + e.message);
|
|
3965
|
+
});
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3818
3968
|
// 统一的命令行视图:每条命令一行,含运行状态
|
|
3819
3969
|
function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis) {
|
|
3820
3970
|
var match = matchProcsToCmds(myCmds, procs);
|
|
@@ -26,6 +26,10 @@ const CLIENT_CLEANUP_MS = 24 * 60 * 60 * 1000; // 离线超过 24h 清理
|
|
|
26
26
|
const llmCache = new Map();
|
|
27
27
|
const LLM_CACHE_TTL = 15 * 60 * 1000; // 15 分钟过期
|
|
28
28
|
|
|
29
|
+
// Clash 命令队列:userId → [{ action, node, ... }]
|
|
30
|
+
// 看门狗客户端下次心跳时取走,取走后清空
|
|
31
|
+
const clashCmdQueue = new Map();
|
|
32
|
+
|
|
29
33
|
// ── 配置文件读写 ──
|
|
30
34
|
|
|
31
35
|
function loadConfig() {
|
|
@@ -92,6 +96,7 @@ function getOnlineClients() {
|
|
|
92
96
|
config: info.config || {},
|
|
93
97
|
processes,
|
|
94
98
|
llmAnalysis,
|
|
99
|
+
clashHealth: info.clashHealth || null,
|
|
95
100
|
lastHeartbeat: info.lastHeartbeat,
|
|
96
101
|
registeredAt: info.registeredAt,
|
|
97
102
|
online,
|
|
@@ -240,20 +245,28 @@ async function handleHeartbeat(params, body, res) {
|
|
|
240
245
|
osInfo: body.osInfo || {},
|
|
241
246
|
config: body.config || {},
|
|
242
247
|
processes: body.processes || [],
|
|
248
|
+
clashHealth: body.clashHealth || null,
|
|
243
249
|
});
|
|
244
250
|
|
|
245
251
|
// 按 userId 返回该客户端的专属命令列表
|
|
246
252
|
const userId = (body.config && body.config.userId) || "";
|
|
247
253
|
const commands = getClientCommands(userId);
|
|
248
254
|
|
|
255
|
+
// 取出待下发的 Clash 命令(一次性,取走即清空)
|
|
256
|
+
const clashCommands = clashCmdQueue.get(userId) || [];
|
|
257
|
+
if (clashCommands.length > 0) {
|
|
258
|
+
clashCmdQueue.delete(userId);
|
|
259
|
+
}
|
|
260
|
+
|
|
249
261
|
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
250
262
|
console.error(
|
|
251
|
-
`[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}
|
|
263
|
+
`[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}条命令${clashCommands.length ? ", " + clashCommands.length + "条Clash命令" : ""}`,
|
|
252
264
|
);
|
|
253
265
|
|
|
254
266
|
sendJSON(res, 200, {
|
|
255
267
|
ok: true,
|
|
256
268
|
commands,
|
|
269
|
+
clashCommands: clashCommands.length > 0 ? clashCommands : undefined,
|
|
257
270
|
clientConfig: getClientConfigStored(userId),
|
|
258
271
|
serverTime: Date.now(),
|
|
259
272
|
});
|
|
@@ -377,6 +390,20 @@ async function handleClearLlmAnalysis(params, body, res) {
|
|
|
377
390
|
sendJSON(res, 200, { ok: true, cleared });
|
|
378
391
|
}
|
|
379
392
|
|
|
393
|
+
async function handleSwitchNode(params, body, res) {
|
|
394
|
+
const { userId, node } = body || {};
|
|
395
|
+
if (!userId || !node) {
|
|
396
|
+
sendJSON(res, 400, { error: "缺少 userId 或 node" });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// 加入 Clash 命令队列,客户端下次心跳时取走
|
|
400
|
+
const queue = clashCmdQueue.get(userId) || [];
|
|
401
|
+
queue.push({ action: "switchNode", node });
|
|
402
|
+
clashCmdQueue.set(userId, queue);
|
|
403
|
+
console.error(`[watchdog] ${userId} 节点切换已入队: → ${node}`);
|
|
404
|
+
sendJSON(res, 200, { ok: true, message: `将在下次心跳时切换到 ${node}` });
|
|
405
|
+
}
|
|
406
|
+
|
|
380
407
|
// ── 公开 ──
|
|
381
408
|
|
|
382
409
|
export function registerWatchdogRoutes(httpServer) {
|
|
@@ -442,5 +469,9 @@ export async function tryHandleWatchdogRoute(
|
|
|
442
469
|
await handleClearLlmAnalysis(params, body, res);
|
|
443
470
|
return true;
|
|
444
471
|
}
|
|
472
|
+
if (method === "POST" && routePath === "/api/watchdog/switch-node") {
|
|
473
|
+
await handleSwitchNode(params, body, res);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
445
476
|
return false;
|
|
446
477
|
}
|
package/src/watchdog/agent.js
CHANGED
|
@@ -176,6 +176,11 @@ export class WatchdogAgent {
|
|
|
176
176
|
this.processManager.checkThresholds();
|
|
177
177
|
}, 5000);
|
|
178
178
|
|
|
179
|
+
// 假死检测:每 60s 检查各进程是否有日志输出,超 30 分钟无日志则重启
|
|
180
|
+
this.staleTimer = setInterval(() => {
|
|
181
|
+
this.processManager.checkStaleProcesses();
|
|
182
|
+
}, 60000);
|
|
183
|
+
|
|
179
184
|
// LLM 日志分析(每 10 分钟调服务端接口)
|
|
180
185
|
this._startLlmAnalyzer();
|
|
181
186
|
|
|
@@ -196,6 +201,10 @@ export class WatchdogAgent {
|
|
|
196
201
|
clearInterval(this.thresholdTimer);
|
|
197
202
|
this.thresholdTimer = null;
|
|
198
203
|
}
|
|
204
|
+
if (this.staleTimer) {
|
|
205
|
+
clearInterval(this.staleTimer);
|
|
206
|
+
this.staleTimer = null;
|
|
207
|
+
}
|
|
199
208
|
if (this.llmAnalyzerTimer) {
|
|
200
209
|
clearInterval(this.llmAnalyzerTimer);
|
|
201
210
|
this.llmAnalyzerTimer = null;
|
|
@@ -264,7 +273,7 @@ export class WatchdogAgent {
|
|
|
264
273
|
const data = await resp.json();
|
|
265
274
|
this.consecutiveFailures = 0;
|
|
266
275
|
|
|
267
|
-
//
|
|
276
|
+
// 同步进程命令
|
|
268
277
|
if (Array.isArray(data.commands)) {
|
|
269
278
|
const prev = this.processManager.runningCount;
|
|
270
279
|
await this.processManager.syncCommands(data.commands);
|
|
@@ -276,6 +285,15 @@ export class WatchdogAgent {
|
|
|
276
285
|
}
|
|
277
286
|
}
|
|
278
287
|
|
|
288
|
+
// 处理 Clash daemon 命令(写 clash-cmd.json,daemon 轮询拾取)
|
|
289
|
+
if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
|
|
290
|
+
const ipc = await import("../clash/ipc.js");
|
|
291
|
+
for (const cmd of data.clashCommands) {
|
|
292
|
+
ipc.writeCommand(cmd);
|
|
293
|
+
console.error(`[watchdog] 下发 Clash 命令: ${JSON.stringify(cmd)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
279
297
|
// 服务端下发的客户端配置(远程修改 ~/.tt-help.json)
|
|
280
298
|
if (data.clientConfig) {
|
|
281
299
|
applyClientConfig(data.clientConfig);
|
|
@@ -409,7 +427,7 @@ export class WatchdogAgent {
|
|
|
409
427
|
|
|
410
428
|
console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
|
|
411
429
|
try {
|
|
412
|
-
const child = spawn(process.execPath, [daemonPath], {
|
|
430
|
+
const child = spawn(process.execPath, [daemonPath, "--country", "US"], {
|
|
413
431
|
stdio: ["ignore", "pipe", "pipe"],
|
|
414
432
|
detached: false,
|
|
415
433
|
});
|
|
@@ -22,6 +22,7 @@ const MAX_RESTART = 3;
|
|
|
22
22
|
const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
|
|
23
23
|
const IS_WIN = process.platform === "win32";
|
|
24
24
|
const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
|
|
25
|
+
const STALE_LOG_TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟无日志输出视为假死
|
|
25
26
|
|
|
26
27
|
// 从命令行中提取 CDP 端口列表(解析 --base-port 和 --port-count)
|
|
27
28
|
function extractCdpPorts(cmdLine) {
|
|
@@ -215,6 +216,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
215
216
|
if (info) {
|
|
216
217
|
info.logs.push(short);
|
|
217
218
|
if (info.logs.length > MAX_LOG_LINES) info.logs.shift();
|
|
219
|
+
info.lastLogTime = Date.now(); // 记录最后日志时间,用于假死检测
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
});
|
|
@@ -267,6 +269,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
267
269
|
restartCount: 0,
|
|
268
270
|
stopped: false,
|
|
269
271
|
logs: [],
|
|
272
|
+
lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
|
|
270
273
|
});
|
|
271
274
|
}
|
|
272
275
|
|
|
@@ -384,6 +387,60 @@ export class ProcessManager extends EventEmitter {
|
|
|
384
387
|
return result;
|
|
385
388
|
}
|
|
386
389
|
|
|
390
|
+
/**
|
|
391
|
+
* 检测假死进程:超过 STALE_LOG_TIMEOUT_MS 无日志输出则自动重启
|
|
392
|
+
* @returns {Array<{cmdId:string, silentMinutes:number}>} 被重启的进程列表
|
|
393
|
+
*/
|
|
394
|
+
checkStaleProcesses() {
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
const restarted = [];
|
|
397
|
+
|
|
398
|
+
for (const [cmdId, info] of this.processes) {
|
|
399
|
+
if (info.stopped || !info.child) continue;
|
|
400
|
+
// 启动不到 STALE_LOG_TIMEOUT_MS 的进程跳过(给启动留足时间)
|
|
401
|
+
const uptime = now - info.startTime;
|
|
402
|
+
if (uptime < STALE_LOG_TIMEOUT_MS) continue;
|
|
403
|
+
|
|
404
|
+
const lastLog = info.lastLogTime || info.startTime;
|
|
405
|
+
const silentMs = now - lastLog;
|
|
406
|
+
if (silentMs < STALE_LOG_TIMEOUT_MS) continue;
|
|
407
|
+
|
|
408
|
+
const silentMin = Math.round(silentMs / 60000);
|
|
409
|
+
console.error(
|
|
410
|
+
`[watchdog] 命令 ${cmdId} 已 ${silentMin} 分钟无日志输出(疑似假死),重启...`,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// 保存 cmdDef 和 restartCount,因为 stopCommand 会 delete
|
|
414
|
+
const cmdDef = info.cmdDef;
|
|
415
|
+
const restartCount = (info.restartCount || 0) + 1;
|
|
416
|
+
|
|
417
|
+
if (restartCount > MAX_RESTART) {
|
|
418
|
+
console.error(
|
|
419
|
+
`[watchdog] 命令 ${cmdId} 已达最大重启次数 (${MAX_RESTART}),不再自动重启`,
|
|
420
|
+
);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 停止旧进程
|
|
425
|
+
this.stopCommand(cmdId);
|
|
426
|
+
|
|
427
|
+
// 重启(继承 restartCount)
|
|
428
|
+
const def = { ...cmdDef, _staleRestart: true };
|
|
429
|
+
this.startCommand(def);
|
|
430
|
+
const newInfo = this.processes.get(cmdId);
|
|
431
|
+
if (newInfo) {
|
|
432
|
+
newInfo.restartCount = restartCount;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.error(
|
|
436
|
+
`[watchdog] 命令 ${cmdId} 假死重启完成 (${restartCount}/${MAX_RESTART})`,
|
|
437
|
+
);
|
|
438
|
+
restarted.push({ cmdId, silentMinutes: silentMin });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return restarted;
|
|
442
|
+
}
|
|
443
|
+
|
|
387
444
|
/**
|
|
388
445
|
* 与期望命令列表同步:
|
|
389
446
|
* - 启动期望列表中已启用但未运行的命令
|