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.
@@ -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
  * - 启动期望列表中已启用但未运行的命令
@@ -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 scraperClosing || scraperInitPromise !== null;
137
+ return (
138
+ scraperClosing || scraperInitPromise !== null || restartPromise !== null
139
+ );
131
140
  }
132
141
 
133
142
  async function restartScraper(reason) {
134
- console.error(`[webserver] 重启 scraper: ${reason}`);
135
- if (scraperInstance) {
136
- try {
137
- await scraperInstance.close();
138
- } catch {}
139
- scraperInstance = null;
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
- console.error(
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
- console.error(
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
- console.error(
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
- console.error(
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
- }