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.
@@ -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
- }