tt-help-cli-ycl 1.4.79 → 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 +37 -14
- package/src/clash/health-checker.js +0 -248
package/src/webserver/server.mjs
CHANGED
|
@@ -72,11 +72,18 @@ const SCRAPER_CONFIG = {
|
|
|
72
72
|
maxRequestsPerPage: 50,
|
|
73
73
|
proxyServer: null,
|
|
74
74
|
captchaNotifyUrl: null,
|
|
75
|
+
enableLog: false,
|
|
75
76
|
};
|
|
76
77
|
|
|
78
|
+
// 仅 --log 模式下输出成功日志;错误日志始终输出
|
|
79
|
+
function apiLog(msg) {
|
|
80
|
+
if (SCRAPER_CONFIG.enableLog) console.error(msg);
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
let scraperInstance = null;
|
|
78
84
|
let scraperClosing = false;
|
|
79
85
|
let scraperInitPromise = null;
|
|
86
|
+
let restartPromise = null; // 防重入:同一时间只允许一个 restartScraper
|
|
80
87
|
let consecutiveErrors = 0;
|
|
81
88
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
82
89
|
let warmTimer = null;
|
|
@@ -127,20 +134,34 @@ function recordRequest(success) {
|
|
|
127
134
|
|
|
128
135
|
function isDegraded() {
|
|
129
136
|
// 仅当 scraper 正在恢复/关闭中才算降级,初始冷状态不算
|
|
130
|
-
return
|
|
137
|
+
return (
|
|
138
|
+
scraperClosing || scraperInitPromise !== null || restartPromise !== null
|
|
139
|
+
);
|
|
131
140
|
}
|
|
132
141
|
|
|
133
142
|
async function restartScraper(reason) {
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
// 防重入:已有重启在进行中,等它完成即可
|
|
144
|
+
if (restartPromise) {
|
|
145
|
+
console.error(`[webserver] 重启已在执行中,跳过本次: ${reason}`);
|
|
146
|
+
return restartPromise;
|
|
147
|
+
}
|
|
148
|
+
restartPromise = (async () => {
|
|
149
|
+
console.error(`[webserver] 重启 scraper: ${reason}`);
|
|
150
|
+
if (scraperInstance) {
|
|
151
|
+
try {
|
|
152
|
+
await scraperInstance.close();
|
|
153
|
+
} catch {}
|
|
154
|
+
scraperInstance = null;
|
|
155
|
+
}
|
|
156
|
+
scraperInitPromise = null;
|
|
157
|
+
consecutiveErrors = 0;
|
|
158
|
+
console.error(`[webserver] 重启 scraper 完成`);
|
|
159
|
+
})();
|
|
160
|
+
try {
|
|
161
|
+
await restartPromise;
|
|
162
|
+
} finally {
|
|
163
|
+
restartPromise = null;
|
|
140
164
|
}
|
|
141
|
-
scraperInitPromise = null;
|
|
142
|
-
consecutiveErrors = 0;
|
|
143
|
-
// 下次请求时懒初始化
|
|
144
165
|
}
|
|
145
166
|
|
|
146
167
|
async function warmScraper() {
|
|
@@ -215,7 +236,7 @@ async function handleTiktokUser(req, res) {
|
|
|
215
236
|
});
|
|
216
237
|
}
|
|
217
238
|
|
|
218
|
-
|
|
239
|
+
apiLog(
|
|
219
240
|
`[webserver] user OK | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | verified=${result.verified} followers=${result.followerCount}`,
|
|
220
241
|
);
|
|
221
242
|
recordRequest(true);
|
|
@@ -244,7 +265,7 @@ async function handleTiktokVideo(req, res) {
|
|
|
244
265
|
});
|
|
245
266
|
}
|
|
246
267
|
|
|
247
|
-
|
|
268
|
+
apiLog(
|
|
248
269
|
`[webserver] video OK | ${clientIP} | @${result.author?.uniqueId || "?"} | ${elapsed}ms | plays=${result.stats?.playCount}`,
|
|
249
270
|
);
|
|
250
271
|
recordRequest(true);
|
|
@@ -273,7 +294,7 @@ async function handleTiktokLookup(req, res) {
|
|
|
273
294
|
});
|
|
274
295
|
}
|
|
275
296
|
|
|
276
|
-
|
|
297
|
+
apiLog(
|
|
277
298
|
`[webserver] lookup OK | ${clientIP} | @${result.user?.uniqueId || "?"} | ${elapsed}ms | videoId=${result.video?.id}`,
|
|
278
299
|
);
|
|
279
300
|
recordRequest(true);
|
|
@@ -299,7 +320,7 @@ async function handleTiktokProduct(req, res) {
|
|
|
299
320
|
captchaFallback: false,
|
|
300
321
|
},
|
|
301
322
|
);
|
|
302
|
-
|
|
323
|
+
apiLog(
|
|
303
324
|
`[webserver] product OK | ${clientIP} | rating=${result.rating ?? "-"} sold=${result.soldCount ?? "-"} name=${(result.productName || "").slice(0, 40)}`,
|
|
304
325
|
);
|
|
305
326
|
recordRequest(true);
|
|
@@ -457,6 +478,7 @@ export async function startWebserver(
|
|
|
457
478
|
poolSize = 0,
|
|
458
479
|
proxyServer = null,
|
|
459
480
|
captchaNotifyUrl = null,
|
|
481
|
+
enableLog = false,
|
|
460
482
|
) {
|
|
461
483
|
if (poolSize > 0) {
|
|
462
484
|
SCRAPER_CONFIG.poolSize = poolSize;
|
|
@@ -467,6 +489,7 @@ export async function startWebserver(
|
|
|
467
489
|
if (captchaNotifyUrl) {
|
|
468
490
|
SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
|
|
469
491
|
}
|
|
492
|
+
SCRAPER_CONFIG.enableLog = !!enableLog;
|
|
470
493
|
const server = http.createServer(async (req, res) => {
|
|
471
494
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
472
495
|
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 节点健康检测模块
|
|
3
|
-
*
|
|
4
|
-
* 职责:
|
|
5
|
-
* 1. 检测 Clash Verge 进程是否存活
|
|
6
|
-
* 2. 检测当前节点对目标 URL 是否健康
|
|
7
|
-
* 3. 故障切换:从健康节点列表中随机选取并切换
|
|
8
|
-
* 4. 健康节点列表持久化
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { spawnSync } from "child_process";
|
|
12
|
-
import * as api from "./api.js";
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
import { tmpdir } from "os";
|
|
16
|
-
|
|
17
|
-
const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
18
|
-
|
|
19
|
-
// ====== 可配置参数 ======
|
|
20
|
-
export const CONFIG = {
|
|
21
|
-
testUrl: "https://www.tiktok.com/explore",
|
|
22
|
-
proxyUrl: "http://127.0.0.1:7897",
|
|
23
|
-
apiPort: 9097,
|
|
24
|
-
groupName: "GLOBAL",
|
|
25
|
-
curlTimeout: 12,
|
|
26
|
-
minPageSize: 50 * 1024,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// ====== 进程检测(跨平台 mac/win) ======
|
|
30
|
-
|
|
31
|
-
const IS_WIN = process.platform === "win32";
|
|
32
|
-
|
|
33
|
-
export function checkClashAlive() {
|
|
34
|
-
try {
|
|
35
|
-
if (IS_WIN) {
|
|
36
|
-
const result = spawnSync(
|
|
37
|
-
"tasklist",
|
|
38
|
-
["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
39
|
-
{
|
|
40
|
-
encoding: "utf-8",
|
|
41
|
-
timeout: 5000,
|
|
42
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
-
},
|
|
44
|
-
);
|
|
45
|
-
if (result.stdout && result.stdout.includes("verge-mihomo.exe"))
|
|
46
|
-
return true;
|
|
47
|
-
} else {
|
|
48
|
-
const result = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
49
|
-
encoding: "utf-8",
|
|
50
|
-
timeout: 5000,
|
|
51
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
-
});
|
|
53
|
-
if (result.status === 0 && result.stdout.trim()) return true;
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
/* fallthrough */
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const curlExe = IS_WIN ? "curl.exe" : "curl";
|
|
61
|
-
const result = spawnSync(
|
|
62
|
-
curlExe,
|
|
63
|
-
["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
64
|
-
{
|
|
65
|
-
encoding: "utf-8",
|
|
66
|
-
timeout: 5000,
|
|
67
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
-
},
|
|
69
|
-
);
|
|
70
|
-
return result.stdout.includes('"version"');
|
|
71
|
-
} catch {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ====== 节点健康检测 ======
|
|
77
|
-
|
|
78
|
-
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
79
|
-
const args = [
|
|
80
|
-
"-s",
|
|
81
|
-
"-w",
|
|
82
|
-
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
83
|
-
"--proxy",
|
|
84
|
-
CONFIG.proxyUrl,
|
|
85
|
-
"--max-time",
|
|
86
|
-
String(CONFIG.curlTimeout),
|
|
87
|
-
"-L",
|
|
88
|
-
"-A",
|
|
89
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
90
|
-
url,
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
const result = spawnSync("curl", args, {
|
|
94
|
-
encoding: "utf-8",
|
|
95
|
-
timeout: (CONFIG.curlTimeout + 5) * 1000,
|
|
96
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const stdout = result.stdout || "";
|
|
101
|
-
const sentinel = "__CURL_META__";
|
|
102
|
-
const idx = stdout.lastIndexOf(sentinel);
|
|
103
|
-
|
|
104
|
-
let body = "",
|
|
105
|
-
metaLine = "";
|
|
106
|
-
if (idx >= 0) {
|
|
107
|
-
body = stdout.substring(0, idx).trimEnd();
|
|
108
|
-
metaLine = stdout.substring(idx + sentinel.length).trim();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const parts = metaLine.split("|");
|
|
112
|
-
const httpCode = parseInt(parts[0]) || 0;
|
|
113
|
-
const timeTotal = parseFloat(parts[1]) || 0;
|
|
114
|
-
const sizeDownload = parseInt(parts[2]) || 0;
|
|
115
|
-
|
|
116
|
-
let title = "";
|
|
117
|
-
const titleMatch = body.match(/<title>([^<]*)<\/title>/i);
|
|
118
|
-
if (titleMatch) title = titleMatch[1].trim();
|
|
119
|
-
|
|
120
|
-
let error = null;
|
|
121
|
-
if (result.error) {
|
|
122
|
-
error = result.error.message || "spawn error";
|
|
123
|
-
} else if (result.status !== 0) {
|
|
124
|
-
error = `curl exit ${result.status}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const healthy =
|
|
128
|
-
error === null &&
|
|
129
|
-
httpCode === 200 &&
|
|
130
|
-
sizeDownload >= CONFIG.minPageSize &&
|
|
131
|
-
title.includes("TikTok") &&
|
|
132
|
-
!title.startsWith("About |") &&
|
|
133
|
-
!body.includes("SlardarWAF") &&
|
|
134
|
-
!body.includes("_wafchallengeid");
|
|
135
|
-
|
|
136
|
-
return { healthy, httpCode, timeTotal, sizeDownload, title, error };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ====== 健康节点列表持久化 ======
|
|
140
|
-
|
|
141
|
-
export function loadHealthyList() {
|
|
142
|
-
try {
|
|
143
|
-
if (existsSync(HEALTHY_FILE)) {
|
|
144
|
-
const data = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
|
|
145
|
-
return {
|
|
146
|
-
updated: data.updated || "",
|
|
147
|
-
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
} catch (e) {
|
|
151
|
-
console.error(`[health] 读取健康列表失败: ${e.message}`);
|
|
152
|
-
}
|
|
153
|
-
return { updated: "", nodes: [] };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function saveHealthyList(nodes) {
|
|
157
|
-
try {
|
|
158
|
-
const data = { updated: new Date().toISOString(), nodes };
|
|
159
|
-
writeFileSync(HEALTHY_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
160
|
-
} catch (e) {
|
|
161
|
-
console.error(`[health] 保存健康列表失败: ${e.message}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ====== 故障切换 ======
|
|
166
|
-
|
|
167
|
-
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
168
|
-
if (!healthyNodes || healthyNodes.length === 0) return null;
|
|
169
|
-
const candidates = healthyNodes.filter((n) => n !== currentNode);
|
|
170
|
-
if (candidates.length === 0) {
|
|
171
|
-
console.error(
|
|
172
|
-
`[health] 健康列表中无可用候选节点(共${healthyNodes.length}个),保持在当前节点`,
|
|
173
|
-
);
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
const idx = Math.floor(Math.random() * candidates.length);
|
|
177
|
-
return candidates[idx];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function switchToNode(nodeName) {
|
|
181
|
-
try {
|
|
182
|
-
await api.switchProxy(CONFIG.groupName, nodeName);
|
|
183
|
-
await new Promise((r) => setTimeout(r, 600));
|
|
184
|
-
const current = await api.getCurrentNode(CONFIG.groupName);
|
|
185
|
-
if (current !== nodeName) {
|
|
186
|
-
console.error(`[health] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
return true;
|
|
190
|
-
} catch (e) {
|
|
191
|
-
console.error(`[health] 切换节点失败: ${e.message}`);
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ====== 便捷入口 ======
|
|
197
|
-
|
|
198
|
-
export async function runHealthCheck() {
|
|
199
|
-
if (!checkClashAlive()) {
|
|
200
|
-
return { action: "skipped", detail: "Clash Verge 未运行" };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const health = checkNodeHealth();
|
|
204
|
-
if (health.healthy) {
|
|
205
|
-
return {
|
|
206
|
-
action: "ok",
|
|
207
|
-
detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB`,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const reason = health.error
|
|
212
|
-
? `错误: ${health.error}`
|
|
213
|
-
: `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
|
|
214
|
-
|
|
215
|
-
let currentNode = "";
|
|
216
|
-
try {
|
|
217
|
-
currentNode = await api.getCurrentNode(CONFIG.groupName);
|
|
218
|
-
} catch (e) {
|
|
219
|
-
return { action: "failed", detail: `无法获取当前节点: ${e.message}` };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const { nodes: healthyNodes } = loadHealthyList();
|
|
223
|
-
if (healthyNodes.length === 0) {
|
|
224
|
-
return { action: "failed", detail: `${reason} — 健康列表为空,无法切换` };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const target = pickFailoverNode(currentNode, healthyNodes);
|
|
228
|
-
if (!target) {
|
|
229
|
-
return { action: "failed", detail: `${reason} — 无可用候选节点` };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const switched = await switchToNode(target);
|
|
233
|
-
if (!switched) {
|
|
234
|
-
return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const newHealth = checkNodeHealth();
|
|
238
|
-
if (newHealth.healthy) {
|
|
239
|
-
return {
|
|
240
|
-
action: "switched",
|
|
241
|
-
detail: `${currentNode} → ${target} (${newHealth.timeTotal.toFixed(1)}s ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
return {
|
|
245
|
-
action: "switched",
|
|
246
|
-
detail: `${currentNode} → ${target} (切换后仍异常: ${newHealth.httpCode} ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
247
|
-
};
|
|
248
|
-
}
|