tt-help-cli-ycl 1.4.79 → 1.4.82
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/benchmark/lib/clash-api.js +234 -0
- package/src/clash/benchmark/speedtest-result.json +431 -0
- package/src/clash/benchmark/speedtest-tiktok.mjs +384 -0
- package/src/clash/checker.js +218 -94
- package/src/clash/daemon.js +230 -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/watchdog.js +8 -1
- package/src/cli/webserver.js +8 -2
- package/src/lib/args.js +12 -0
- package/src/lib/constants.js +24 -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 +72 -15
- package/src/watchdog/process-manager.js +57 -0
- package/src/webserver/server.mjs +37 -14
- package/src/clash/health-checker.js +0 -248
package/src/cli/webserver.js
CHANGED
|
@@ -5,14 +5,20 @@ import {
|
|
|
5
5
|
} from "../webserver/server.mjs";
|
|
6
6
|
|
|
7
7
|
export async function handleWebserver(options) {
|
|
8
|
-
const {
|
|
9
|
-
|
|
8
|
+
const {
|
|
9
|
+
webserverPort,
|
|
10
|
+
webserverPoolSize,
|
|
11
|
+
webserverProxy,
|
|
12
|
+
webserverLog,
|
|
13
|
+
webserverNotify,
|
|
14
|
+
} = options;
|
|
10
15
|
|
|
11
16
|
const { server, port } = await startWebserver(
|
|
12
17
|
webserverPort,
|
|
13
18
|
webserverPoolSize,
|
|
14
19
|
webserverProxy,
|
|
15
20
|
webserverNotify,
|
|
21
|
+
webserverLog,
|
|
16
22
|
);
|
|
17
23
|
|
|
18
24
|
let shuttingDown = false;
|
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,
|
|
@@ -625,6 +629,7 @@ function parseWatchdogArgs(args) {
|
|
|
625
629
|
let watchdogServer = null;
|
|
626
630
|
let watchdogInterval = 15;
|
|
627
631
|
let watchdogClientId = null;
|
|
632
|
+
let watchdogNoDaemon = false;
|
|
628
633
|
|
|
629
634
|
for (let i = 0; i < args.length; i++) {
|
|
630
635
|
const arg = args[i];
|
|
@@ -634,6 +639,8 @@ function parseWatchdogArgs(args) {
|
|
|
634
639
|
watchdogInterval = parseInt(args[++i]) || 15;
|
|
635
640
|
} else if (arg === "--client-id") {
|
|
636
641
|
watchdogClientId = args[++i];
|
|
642
|
+
} else if (arg === "--no-daemon") {
|
|
643
|
+
watchdogNoDaemon = true;
|
|
637
644
|
}
|
|
638
645
|
}
|
|
639
646
|
|
|
@@ -642,6 +649,7 @@ function parseWatchdogArgs(args) {
|
|
|
642
649
|
watchdogServer,
|
|
643
650
|
watchdogInterval,
|
|
644
651
|
watchdogClientId,
|
|
652
|
+
watchdogNoDaemon,
|
|
645
653
|
urls: [],
|
|
646
654
|
outputFormat: "json",
|
|
647
655
|
exploreCount: 0,
|
|
@@ -659,6 +667,7 @@ function parseClashDaemonArgs(args) {
|
|
|
659
667
|
let interval = 30;
|
|
660
668
|
let benchmark = 40;
|
|
661
669
|
let dryRun = false;
|
|
670
|
+
let country = null;
|
|
662
671
|
|
|
663
672
|
for (let i = 0; i < args.length; i++) {
|
|
664
673
|
const arg = args[i];
|
|
@@ -666,6 +675,8 @@ function parseClashDaemonArgs(args) {
|
|
|
666
675
|
interval = parseInt(args[++i]) || 30;
|
|
667
676
|
} else if (arg === "--benchmark") {
|
|
668
677
|
benchmark = parseInt(args[++i]) || 40;
|
|
678
|
+
} else if (arg === "--country") {
|
|
679
|
+
country = args[++i] || null;
|
|
669
680
|
} else if (arg === "--dry-run") {
|
|
670
681
|
dryRun = true;
|
|
671
682
|
}
|
|
@@ -675,6 +686,7 @@ function parseClashDaemonArgs(args) {
|
|
|
675
686
|
subcommand: "clash-daemon",
|
|
676
687
|
interval,
|
|
677
688
|
benchmark,
|
|
689
|
+
country,
|
|
678
690
|
"dry-run": dryRun,
|
|
679
691
|
urls: [],
|
|
680
692
|
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",
|
|
@@ -381,10 +383,31 @@ const HELP_TEXT = [
|
|
|
381
383
|
" --server <URL>, -s 服务端地址(默认使用 ~/.tt-help.json 中的 server)",
|
|
382
384
|
" --interval <秒>, -i 心跳间隔,默认 15 秒(最小 5 秒)",
|
|
383
385
|
" --client-id <ID> 客户端标识(默认本机 hostname)",
|
|
386
|
+
" --no-daemon 不启动 clash daemon(默认启动)",
|
|
384
387
|
" 示例: tt-help watchdog",
|
|
385
388
|
" tt-help watchdog --server http://117.71.53.99:17301",
|
|
386
389
|
" tt-help watchdog -i 30 --client-id my-server-01",
|
|
387
390
|
"",
|
|
391
|
+
" tt-help clash-daemon [选项]",
|
|
392
|
+
" Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
|
|
393
|
+
" 看门狗启动时会自动拉起此守护进程",
|
|
394
|
+
" 选项:",
|
|
395
|
+
" --interval <秒> 巡检间隔,默认 30 秒",
|
|
396
|
+
" --benchmark <分钟> 全量测速间隔,默认 40 分钟",
|
|
397
|
+
" --country <国家代码> 只保留指定国家节点,默认不过滤",
|
|
398
|
+
" 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
|
|
399
|
+
" --dry-run 只检测不切换(调试用)",
|
|
400
|
+
" 看门狗运行时可通过 /tmp/clash-cmd.json 发命令动态调整:",
|
|
401
|
+
' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
|
|
402
|
+
' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
|
|
403
|
+
' {"action":"setCountryFilter","country":null} 取消过滤',
|
|
404
|
+
' {"action":"runBenchmark"} 手动触发全量测速',
|
|
405
|
+
' {"action":"setInterval","seconds":60} 修改巡检间隔',
|
|
406
|
+
" 示例: tt-help clash-daemon",
|
|
407
|
+
" tt-help clash-daemon --country US",
|
|
408
|
+
" tt-help clash-daemon --interval 15 --benchmark 20 --country US",
|
|
409
|
+
" tt-help clash-daemon --dry-run",
|
|
410
|
+
"",
|
|
388
411
|
" config [show|set|unset|reset]",
|
|
389
412
|
" config 查看当前配置",
|
|
390
413
|
" config set <key> <value> 设置配置(key: proxy, server, browser, userId, maxFollowing, maxFollowers, maxVideos, maxComments)",
|
|
@@ -400,6 +423,7 @@ const HELP_TEXT = [
|
|
|
400
423
|
" tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
|
|
401
424
|
" tt-help refresh --server http://127.0.0.1:3001 --port 9222",
|
|
402
425
|
" tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
|
|
426
|
+
" tt-help webserver -p 3000 --log",
|
|
403
427
|
" tt-help config set server http://127.0.0.1:3001",
|
|
404
428
|
" tt-help attach -p 5 -i 10",
|
|
405
429
|
" 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
|
@@ -32,7 +32,12 @@ const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
|
|
|
32
32
|
|
|
33
33
|
/** 自动发现 daemon.js 路径 */
|
|
34
34
|
function findDaemonPath() {
|
|
35
|
-
// 1.
|
|
35
|
+
// 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
|
|
36
|
+
// 避免 ~/.tt-help.json 的 clashDaemonPath 指向旧版外部 daemon
|
|
37
|
+
const internalPath = join(__dirname, "..", "clash", "daemon.js");
|
|
38
|
+
if (existsSync(internalPath)) return internalPath;
|
|
39
|
+
|
|
40
|
+
// 2. 显式配置覆盖(仅当包内缺失时)
|
|
36
41
|
try {
|
|
37
42
|
if (existsSync(TT_HELP_CONFIG)) {
|
|
38
43
|
const cfg = JSON.parse(readFileSync(TT_HELP_CONFIG, "utf-8"));
|
|
@@ -42,10 +47,6 @@ function findDaemonPath() {
|
|
|
42
47
|
}
|
|
43
48
|
} catch {}
|
|
44
49
|
|
|
45
|
-
// 2. 内部打包路径(npm publish 后 src/clash/daemon.js)
|
|
46
|
-
const internalPath = join(__dirname, "..", "clash", "daemon.js");
|
|
47
|
-
if (existsSync(internalPath)) return internalPath;
|
|
48
|
-
|
|
49
50
|
// 3. 开发模式:clash-verge-explore 同级目录
|
|
50
51
|
const candidates = [
|
|
51
52
|
join(__dirname, "..", "..", "..", "clash-verge-explore", "daemon.js"),
|
|
@@ -117,11 +118,13 @@ export class WatchdogAgent {
|
|
|
117
118
|
* @param {string} opts.serverUrl - 服务端地址
|
|
118
119
|
* @param {string} opts.clientId - 客户端标识
|
|
119
120
|
* @param {number} opts.intervalMs - 心跳间隔(毫秒)
|
|
121
|
+
* @param {boolean} [opts.spawnDaemon=true] - 是否自动启动 clash daemon
|
|
120
122
|
*/
|
|
121
|
-
constructor({ serverUrl, clientId, intervalMs = 15000 }) {
|
|
123
|
+
constructor({ serverUrl, clientId, intervalMs = 15000, spawnDaemon = true }) {
|
|
122
124
|
this.serverUrl = serverUrl.replace(/\/+$/, "");
|
|
123
125
|
this.clientId = clientId;
|
|
124
126
|
this.intervalMs = intervalMs;
|
|
127
|
+
this.spawnDaemon = !!spawnDaemon;
|
|
125
128
|
this.running = false;
|
|
126
129
|
this.heartbeatTimer = null;
|
|
127
130
|
this.thresholdTimer = null;
|
|
@@ -160,8 +163,8 @@ export class WatchdogAgent {
|
|
|
160
163
|
// 注册信号处理
|
|
161
164
|
this._setupSignalHandlers();
|
|
162
165
|
|
|
163
|
-
// 启动 clash daemon
|
|
164
|
-
this._spawnClashDaemon();
|
|
166
|
+
// 启动 clash daemon(保代理通道,--no-daemon 可关闭)
|
|
167
|
+
if (this.spawnDaemon) this._spawnClashDaemon();
|
|
165
168
|
|
|
166
169
|
// 首次立即发送心跳
|
|
167
170
|
await this._sendHeartbeat();
|
|
@@ -176,6 +179,11 @@ export class WatchdogAgent {
|
|
|
176
179
|
this.processManager.checkThresholds();
|
|
177
180
|
}, 5000);
|
|
178
181
|
|
|
182
|
+
// 假死检测:每 60s 检查各进程是否有日志输出,超 30 分钟无日志则重启
|
|
183
|
+
this.staleTimer = setInterval(() => {
|
|
184
|
+
this.processManager.checkStaleProcesses();
|
|
185
|
+
}, 60000);
|
|
186
|
+
|
|
179
187
|
// LLM 日志分析(每 10 分钟调服务端接口)
|
|
180
188
|
this._startLlmAnalyzer();
|
|
181
189
|
|
|
@@ -196,6 +204,10 @@ export class WatchdogAgent {
|
|
|
196
204
|
clearInterval(this.thresholdTimer);
|
|
197
205
|
this.thresholdTimer = null;
|
|
198
206
|
}
|
|
207
|
+
if (this.staleTimer) {
|
|
208
|
+
clearInterval(this.staleTimer);
|
|
209
|
+
this.staleTimer = null;
|
|
210
|
+
}
|
|
199
211
|
if (this.llmAnalyzerTimer) {
|
|
200
212
|
clearInterval(this.llmAnalyzerTimer);
|
|
201
213
|
this.llmAnalyzerTimer = null;
|
|
@@ -264,7 +276,7 @@ export class WatchdogAgent {
|
|
|
264
276
|
const data = await resp.json();
|
|
265
277
|
this.consecutiveFailures = 0;
|
|
266
278
|
|
|
267
|
-
//
|
|
279
|
+
// 同步进程命令
|
|
268
280
|
if (Array.isArray(data.commands)) {
|
|
269
281
|
const prev = this.processManager.runningCount;
|
|
270
282
|
await this.processManager.syncCommands(data.commands);
|
|
@@ -276,6 +288,15 @@ export class WatchdogAgent {
|
|
|
276
288
|
}
|
|
277
289
|
}
|
|
278
290
|
|
|
291
|
+
// 处理 Clash daemon 命令(写 clash-cmd.json,daemon 轮询拾取)
|
|
292
|
+
if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
|
|
293
|
+
const ipc = await import("../clash/ipc.js");
|
|
294
|
+
for (const cmd of data.clashCommands) {
|
|
295
|
+
ipc.writeCommand(cmd);
|
|
296
|
+
console.error(`[watchdog] 下发 Clash 命令: ${JSON.stringify(cmd)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
279
300
|
// 服务端下发的客户端配置(远程修改 ~/.tt-help.json)
|
|
280
301
|
if (data.clientConfig) {
|
|
281
302
|
applyClientConfig(data.clientConfig);
|
|
@@ -377,17 +398,21 @@ export class WatchdogAgent {
|
|
|
377
398
|
this.upgradeChecker = startUpgradeChecker({
|
|
378
399
|
onUpdateAvailable: async ({ current, latest }) => {
|
|
379
400
|
console.error(`[upgrade] ${current} → ${latest}`);
|
|
401
|
+
// 停掉所有子进程与 clash daemon,避免新旧并存
|
|
380
402
|
this.processManager.stopAll();
|
|
403
|
+
this._killClashDaemon();
|
|
381
404
|
|
|
382
405
|
const result = await performUpgrade();
|
|
383
406
|
if (result.success) {
|
|
384
407
|
this.ttHelpVersion = latest;
|
|
385
|
-
console.error(
|
|
386
|
-
|
|
387
|
-
|
|
408
|
+
console.error(
|
|
409
|
+
`[upgrade] 升级成功 (${latest}),重启 watchdog 使新版 clash daemon 生效`,
|
|
410
|
+
);
|
|
411
|
+
// 重启自身进程加载新代码(内部 daemon 路径优先),新进程会拉起新版 daemon
|
|
412
|
+
this._restartSelf();
|
|
413
|
+
return;
|
|
388
414
|
}
|
|
389
|
-
|
|
390
|
-
// 立即心跳(同步命令,重启进程)
|
|
415
|
+
console.error(`[upgrade] 失败: ${result.output.substring(0, 200)}`);
|
|
391
416
|
await this._sendHeartbeat();
|
|
392
417
|
},
|
|
393
418
|
});
|
|
@@ -409,7 +434,7 @@ export class WatchdogAgent {
|
|
|
409
434
|
|
|
410
435
|
console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
|
|
411
436
|
try {
|
|
412
|
-
const child = spawn(process.execPath, [daemonPath], {
|
|
437
|
+
const child = spawn(process.execPath, [daemonPath, "--country", "US"], {
|
|
413
438
|
stdio: ["ignore", "pipe", "pipe"],
|
|
414
439
|
detached: false,
|
|
415
440
|
});
|
|
@@ -463,6 +488,38 @@ export class WatchdogAgent {
|
|
|
463
488
|
this.clashDaemon = null;
|
|
464
489
|
}
|
|
465
490
|
|
|
491
|
+
/**
|
|
492
|
+
* 升级后重启自身进程(加载新代码)
|
|
493
|
+
* 用 cli.js watchdog 子进程替换当前进程,保证新版本内部 daemon 路径逻辑生效
|
|
494
|
+
*/
|
|
495
|
+
_restartSelf() {
|
|
496
|
+
const cliPath = join(__dirname, "..", "..", "cli.js");
|
|
497
|
+
const args = ["watchdog"];
|
|
498
|
+
if (this.serverUrl) args.push("--server", this.serverUrl);
|
|
499
|
+
if (this.clientId) args.push("--client-id", this.clientId);
|
|
500
|
+
if (!this.spawnDaemon) args.push("--no-daemon");
|
|
501
|
+
args.push(
|
|
502
|
+
"--interval",
|
|
503
|
+
String(Math.max(5, Math.round(this.intervalMs / 1000))),
|
|
504
|
+
);
|
|
505
|
+
let child;
|
|
506
|
+
try {
|
|
507
|
+
child = spawn(process.execPath, [cliPath, ...args], {
|
|
508
|
+
detached: true,
|
|
509
|
+
stdio: "ignore",
|
|
510
|
+
});
|
|
511
|
+
child.unref();
|
|
512
|
+
} catch (e) {
|
|
513
|
+
console.error(`[upgrade] 重启自身失败: ${e.message}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
console.error(
|
|
517
|
+
`[upgrade] 新版 watchdog 已启动 (pid=${child.pid}),当前进程退出`,
|
|
518
|
+
);
|
|
519
|
+
this.stop();
|
|
520
|
+
setTimeout(() => process.exit(0), 500);
|
|
521
|
+
}
|
|
522
|
+
|
|
466
523
|
/**
|
|
467
524
|
* 信号处理
|
|
468
525
|
*/
|