tt-help-cli-ycl 1.4.76 → 1.4.79

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.76",
3
+ "version": "1.4.79",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Clash Verge (mihomo) RESTful API 客户端
3
+ *
4
+ * API 参考: https://wiki.metacubex.one/en/api
5
+ * 认证方式: Authorization: Bearer ${secret}
6
+ * 依赖 Clash Verge GUI 运行时绑定的 TCP 127.0.0.1:9097
7
+ */
8
+
9
+ const BASE_URL = "http://127.0.0.1:9097";
10
+ const SECRET = "abcdefg";
11
+
12
+ function headers() {
13
+ return {
14
+ Authorization: `Bearer ${SECRET}`,
15
+ "Content-Type": "application/json",
16
+ };
17
+ }
18
+
19
+ async function request(path, options = {}) {
20
+ const url = `${BASE_URL}${path}`;
21
+ const res = await fetch(url, {
22
+ ...options,
23
+ headers: { ...headers(), ...options.headers },
24
+ });
25
+ if (!res.ok) {
26
+ const body = await res.text().catch(() => "");
27
+ throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`);
28
+ }
29
+ if (res.status === 204) return null;
30
+ const text = await res.text();
31
+ return text ? JSON.parse(text) : null;
32
+ }
33
+
34
+ // ===== 代理相关 =====
35
+
36
+ export async function getProxies() {
37
+ return request("/proxies");
38
+ }
39
+
40
+ export async function getProxy(name) {
41
+ return request(`/proxies/${encodeURIComponent(name)}`);
42
+ }
43
+
44
+ export async function switchProxy(groupName, nodeName) {
45
+ return request(`/proxies/${encodeURIComponent(groupName)}`, {
46
+ method: "PUT",
47
+ body: JSON.stringify({ name: nodeName }),
48
+ });
49
+ }
50
+
51
+ export async function getGroups() {
52
+ const data = await getProxies();
53
+ const proxies = data.proxies || {};
54
+ const groups = [];
55
+ const groupTypes = new Set([
56
+ "Selector",
57
+ "URLTest",
58
+ "Fallback",
59
+ "LoadBalance",
60
+ ]);
61
+ for (const [name, info] of Object.entries(proxies)) {
62
+ if (groupTypes.has(info.type)) {
63
+ groups.push({
64
+ name,
65
+ type: info.type,
66
+ now: info.now || "",
67
+ all: info.all || [],
68
+ });
69
+ }
70
+ }
71
+ return groups;
72
+ }
73
+
74
+ export async function getNodes() {
75
+ const data = await getProxies();
76
+ const proxies = data.proxies || {};
77
+ const nodes = [];
78
+ const groupTypes = new Set([
79
+ "Selector",
80
+ "URLTest",
81
+ "Fallback",
82
+ "LoadBalance",
83
+ ]);
84
+ for (const [name, info] of Object.entries(proxies)) {
85
+ if (!groupTypes.has(info.type)) {
86
+ nodes.push({
87
+ name,
88
+ type: info.type,
89
+ alive: info.alive ?? false,
90
+ delay:
91
+ info.history?.length > 0
92
+ ? info.history[info.history.length - 1].delay
93
+ : null,
94
+ udp: info.udp ?? false,
95
+ });
96
+ }
97
+ }
98
+ return nodes;
99
+ }
100
+
101
+ export async function getConfigs() {
102
+ return request("/configs");
103
+ }
104
+ export async function patchConfigs(patch) {
105
+ return request("/configs", { method: "PATCH", body: JSON.stringify(patch) });
106
+ }
107
+ export async function reloadConfigs() {
108
+ return request("/configs?force=true", { method: "PUT" });
109
+ }
110
+ export async function getVersion() {
111
+ return request("/version");
112
+ }
113
+ export async function getMemory() {
114
+ return request("/memory");
115
+ }
116
+ export async function getConnections() {
117
+ return request("/connections");
118
+ }
119
+ export async function closeConnection(id) {
120
+ return request(`/connections/${id}`, { method: "DELETE" });
121
+ }
122
+ export async function closeAllConnections() {
123
+ return request("/connections", { method: "DELETE" });
124
+ }
125
+ export async function getRules() {
126
+ return request("/rules");
127
+ }
128
+ export async function getProxyProviders() {
129
+ return request("/providers/proxies");
130
+ }
131
+ export async function updateProxyProvider(name) {
132
+ return request(`/providers/proxies/${encodeURIComponent(name)}`, {
133
+ method: "PUT",
134
+ });
135
+ }
136
+ export async function healthCheckProvider(name) {
137
+ return request(`/providers/proxies/${encodeURIComponent(name)}/healthcheck`);
138
+ }
139
+
140
+ export async function getCurrentNode(groupName) {
141
+ const proxy = await getProxy(groupName);
142
+ return proxy.now || null;
143
+ }
144
+
145
+ export async function listGroupNodes(groupName) {
146
+ const proxy = await getProxy(groupName);
147
+ return proxy.all || [];
148
+ }
149
+
150
+ export async function getAliveNodes() {
151
+ const nodes = await getNodes();
152
+ return nodes
153
+ .filter((n) => n.alive && n.delay !== null && n.delay > 0)
154
+ .sort((a, b) => (a.delay ?? Infinity) - (b.delay ?? Infinity));
155
+ }
156
+
157
+ export async function switchToFastest(groupName) {
158
+ const alive = await getAliveNodes();
159
+ if (alive.length === 0) throw new Error("没有存活的节点");
160
+ const groupNodes = await listGroupNodes(groupName);
161
+ for (const node of alive) {
162
+ if (groupNodes.includes(node.name)) {
163
+ await switchProxy(groupName, node.name);
164
+ return node;
165
+ }
166
+ }
167
+ throw new Error("存活节点都不在目标组中");
168
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 节点健康检测 — 纯函数模块
3
+ *
4
+ * 职责:
5
+ * 1. 检测 Clash Verge 进程存活(跨平台 mac/win)
6
+ * 2. 检测当前代理节点对目标 URL 的健康状态
7
+ * 3. 健康节点列表持久化
8
+ * 4. 故障切换候选节点选取
9
+ */
10
+ import { spawnSync } from "child_process";
11
+ import { readFileSync, writeFileSync, existsSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+
15
+ const IS_WIN = process.platform === "win32";
16
+ const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
17
+
18
+ export const CONFIG = {
19
+ testUrl: "https://www.tiktok.com/explore",
20
+ proxyUrl: "http://127.0.0.1:7897",
21
+ apiPort: 9097,
22
+ groupName: "GLOBAL",
23
+ curlTimeout: 12,
24
+ minPageSize: 50 * 1024,
25
+ };
26
+
27
+ // ---- 进程检测 ----
28
+
29
+ export function checkClashAlive() {
30
+ try {
31
+ if (IS_WIN) {
32
+ const r = spawnSync(
33
+ "tasklist",
34
+ ["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
35
+ {
36
+ encoding: "utf-8",
37
+ timeout: 5000,
38
+ stdio: ["pipe", "pipe", "pipe"],
39
+ },
40
+ );
41
+ if (r.stdout?.includes("verge-mihomo.exe")) return true;
42
+ } else {
43
+ const r = spawnSync("pgrep", ["-x", "verge-mihomo"], {
44
+ encoding: "utf-8",
45
+ timeout: 5000,
46
+ stdio: ["pipe", "pipe", "pipe"],
47
+ });
48
+ if (r.status === 0 && r.stdout.trim()) return true;
49
+ }
50
+ } catch {
51
+ /* fallthrough */
52
+ }
53
+
54
+ try {
55
+ const exe = IS_WIN ? "curl.exe" : "curl";
56
+ const r = spawnSync(
57
+ exe,
58
+ ["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
59
+ {
60
+ encoding: "utf-8",
61
+ timeout: 5000,
62
+ stdio: ["pipe", "pipe", "pipe"],
63
+ },
64
+ );
65
+ return r.stdout.includes('"version"');
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ // ---- 节点健康检测 ----
72
+
73
+ export function checkNodeHealth(url = CONFIG.testUrl) {
74
+ const args = [
75
+ "-s",
76
+ "-w",
77
+ "\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
78
+ "--proxy",
79
+ CONFIG.proxyUrl,
80
+ "--max-time",
81
+ String(CONFIG.curlTimeout),
82
+ "-L",
83
+ "-A",
84
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
85
+ url,
86
+ ];
87
+ const result = spawnSync("curl", args, {
88
+ encoding: "utf-8",
89
+ timeout: (CONFIG.curlTimeout + 5) * 1000,
90
+ stdio: ["pipe", "pipe", "pipe"],
91
+ maxBuffer: 10 * 1024 * 1024,
92
+ });
93
+
94
+ const stdout = result.stdout || "";
95
+ const idx = stdout.lastIndexOf("__CURL_META__");
96
+ let body = "",
97
+ metaLine = "";
98
+ if (idx >= 0) {
99
+ body = stdout.substring(0, idx).trimEnd();
100
+ metaLine = stdout.substring(idx + 13).trim();
101
+ }
102
+ const parts = metaLine.split("|");
103
+ const httpCode = parseInt(parts[0]) || 0;
104
+ const timeTotal = parseFloat(parts[1]) || 0;
105
+ const sizeDownload = parseInt(parts[2]) || 0;
106
+
107
+ let title = "";
108
+ const m = body.match(/<title>([^<]*)<\/title>/i);
109
+ if (m) title = m[1].trim();
110
+
111
+ let error = null;
112
+ if (result.error) error = result.error.message;
113
+ else if (result.status !== 0) error = `curl exit ${result.status}`;
114
+
115
+ const healthy =
116
+ !error &&
117
+ httpCode === 200 &&
118
+ sizeDownload >= CONFIG.minPageSize &&
119
+ title.includes("TikTok") &&
120
+ !title.startsWith("About |") &&
121
+ !body.includes("SlardarWAF") &&
122
+ !body.includes("_wafchallengeid");
123
+
124
+ return { healthy, httpCode, timeTotal, sizeDownload, title, error };
125
+ }
126
+
127
+ // ---- 健康节点列表持久化 ----
128
+
129
+ export function loadHealthyList() {
130
+ try {
131
+ if (existsSync(HEALTHY_FILE)) {
132
+ const d = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
133
+ return {
134
+ updated: d.updated || "",
135
+ nodes: Array.isArray(d.nodes) ? d.nodes : [],
136
+ };
137
+ }
138
+ } catch (e) {
139
+ console.error(`[checker] 读取健康列表失败: ${e.message}`);
140
+ }
141
+ return { updated: "", nodes: [] };
142
+ }
143
+
144
+ export function saveHealthyList(nodes) {
145
+ try {
146
+ writeFileSync(
147
+ HEALTHY_FILE,
148
+ JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2),
149
+ "utf-8",
150
+ );
151
+ } catch (e) {
152
+ console.error(`[checker] 保存健康列表失败: ${e.message}`);
153
+ }
154
+ }
155
+
156
+ // ---- 故障切换候选选取 ----
157
+
158
+ export function pickFailoverNode(currentNode, healthyNodes) {
159
+ if (!healthyNodes?.length) return null;
160
+ const candidates = healthyNodes.filter((n) => n !== currentNode);
161
+ if (!candidates.length) {
162
+ console.error(`[checker] 无可用候选节点(共${healthyNodes.length}个)`);
163
+ return null;
164
+ }
165
+ return candidates[Math.floor(Math.random() * candidates.length)];
166
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * ClashDaemon — 代理节点健康守护
3
+ *
4
+ * 定时巡检当前节点 TikTok 可达性,异常时自动故障切换。
5
+ * 定期执行全量基准测速刷新健康节点列表。
6
+ * 通过 IPC 文件向 watchdog 上报状态。
7
+ */
8
+ import { spawn } from "child_process";
9
+ import { join, dirname } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { readFileSync, existsSync } from "fs";
12
+ import * as api from "./api.js";
13
+ import * as checker from "./checker.js";
14
+ import * as ipc from "./ipc.js";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ function findBenchmarkScript() {
19
+ const candidates = [
20
+ join(
21
+ __dirname,
22
+ "..",
23
+ "..",
24
+ "..",
25
+ "clash-verge-explore",
26
+ "speedtest-tiktok.mjs",
27
+ ),
28
+ join(__dirname, "speedtest-tiktok.mjs"),
29
+ ];
30
+ for (const p of candidates) if (existsSync(p)) return p;
31
+ return null;
32
+ }
33
+
34
+ export class ClashDaemon {
35
+ constructor(opts = {}) {
36
+ this.intervalMs = (opts.intervalSec || 30) * 1000;
37
+ this.benchmarkMs = (opts.benchmarkMin || 40) * 60 * 1000;
38
+ this.dryRun = !!opts.dryRun;
39
+ this.benchmarkScript = findBenchmarkScript();
40
+ this._running = false;
41
+ this._timers = [];
42
+ this._benchmarkRunning = false;
43
+ }
44
+
45
+ async start() {
46
+ if (this._running) return;
47
+ this._running = true;
48
+ this._logBanner();
49
+ this._setupSignals();
50
+
51
+ const list = checker.loadHealthyList();
52
+ const ageMin = list.updated
53
+ ? Math.round((Date.now() - new Date(list.updated).getTime()) / 60000)
54
+ : Infinity;
55
+ const isStale = !list.nodes.length || ageMin >= this.benchmarkMs / 60000;
56
+
57
+ if (list.nodes.length) {
58
+ this._log(
59
+ `已加载 ${list.nodes.length} 个健康节点${isStale ? ` (已过期 ${ageMin}min,立即刷新)` : ""}`,
60
+ );
61
+ } else {
62
+ this._log("健康列表为空,将立即执行基准测速");
63
+ }
64
+
65
+ await this._healthCheck();
66
+ this._timers.push(setInterval(() => this._healthCheck(), this.intervalMs));
67
+
68
+ const initial = isStale ? 5000 : this.benchmarkMs;
69
+ setTimeout(() => {
70
+ this._runBenchmark();
71
+ this._timers.push(
72
+ setInterval(() => this._runBenchmark(), this.benchmarkMs),
73
+ );
74
+ }, initial);
75
+
76
+ this._log("守护进程已启动");
77
+ }
78
+
79
+ async stop() {
80
+ this._running = false;
81
+ this._timers.forEach(clearInterval);
82
+ this._timers = [];
83
+ this._log("守护进程已停止");
84
+ }
85
+
86
+ // ---- 巡检 ----
87
+
88
+ async _healthCheck() {
89
+ if (!this._running || this._benchmarkRunning) return;
90
+ try {
91
+ if (!checker.checkClashAlive()) {
92
+ ipc.writeHealth({ node: "?", healthy: false, error: "Clash 未运行" });
93
+ return;
94
+ }
95
+ const node = await api
96
+ .getCurrentNode(checker.CONFIG.groupName)
97
+ .catch(() => "?");
98
+ const h = checker.checkNodeHealth();
99
+ ipc.writeHealth({
100
+ node,
101
+ healthy: h.healthy,
102
+ httpCode: h.httpCode,
103
+ timeTotal: h.timeTotal,
104
+ sizeDownload: h.sizeDownload,
105
+ title: h.title,
106
+ });
107
+
108
+ if (h.healthy) {
109
+ if (Math.random() < 0.17)
110
+ this._log(
111
+ `✓ ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
112
+ );
113
+ return;
114
+ }
115
+
116
+ const reason =
117
+ h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
118
+ const { nodes } = checker.loadHealthyList();
119
+ const target = checker.pickFailoverNode(node, nodes);
120
+ if (!target) {
121
+ this._log(`✗ ${reason} — 无法切换`);
122
+ return;
123
+ }
124
+ if (this.dryRun) {
125
+ this._log(`[DRY-RUN] 应切换到 ${target}`);
126
+ return;
127
+ }
128
+
129
+ await api.switchProxy(checker.CONFIG.groupName, target);
130
+ await new Promise((r) => setTimeout(r, 600));
131
+ const h2 = checker.checkNodeHealth();
132
+ this._log(
133
+ `⚠ ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
134
+ );
135
+ ipc.writeHealth({
136
+ node: target,
137
+ healthy: h2.healthy,
138
+ httpCode: h2.httpCode,
139
+ timeTotal: h2.timeTotal,
140
+ sizeDownload: h2.sizeDownload,
141
+ title: h2.title,
142
+ lastSwitch: `${node} → ${target}`,
143
+ });
144
+ } catch (e) {
145
+ this._log(`巡检异常: ${e.message}`);
146
+ ipc.writeHealth({ node: "?", healthy: false, error: e.message });
147
+ }
148
+ }
149
+
150
+ // ---- 基准 ----
151
+
152
+ _runBenchmark() {
153
+ if (!this.benchmarkScript || this._benchmarkRunning) return;
154
+ this._benchmarkRunning = true;
155
+ this._log("=== 基准测速开始 ===");
156
+ const start = Date.now();
157
+ const child = spawn(
158
+ process.execPath,
159
+ [this.benchmarkScript, "--timeout", "12"],
160
+ {
161
+ cwd: dirname(this.benchmarkScript),
162
+ stdio: ["ignore", "pipe", "pipe"],
163
+ },
164
+ );
165
+ let stderr = "";
166
+ child.stderr.on("data", (d) => {
167
+ stderr += d.toString();
168
+ });
169
+ child.on("close", (code) => {
170
+ this._benchmarkRunning = false;
171
+ const elapsed = ((Date.now() - start) / 1000).toFixed(0);
172
+ if (code === 0) {
173
+ try {
174
+ const rp = join(
175
+ dirname(this.benchmarkScript),
176
+ "speedtest-result.json",
177
+ );
178
+ if (existsSync(rp)) {
179
+ const nodes =
180
+ JSON.parse(readFileSync(rp, "utf-8")).realNodes?.map(
181
+ (r) => r.node,
182
+ ) || [];
183
+ if (nodes.length) {
184
+ checker.saveHealthyList(nodes);
185
+ this._log(
186
+ `=== 基准测速完成 (${elapsed}s): ${nodes.length} 个健康节点 ===`,
187
+ );
188
+ } else this._log(`=== 基准测速完成: 0 个健康节点 ===`);
189
+ }
190
+ } catch (e) {
191
+ this._log(`解析结果失败: ${e.message}`);
192
+ }
193
+ } else {
194
+ this._log(`=== 基准测速失败 (exit ${code}, ${elapsed}s) ===`);
195
+ }
196
+ });
197
+ child.on("error", (err) => {
198
+ this._benchmarkRunning = false;
199
+ this._log(`基准测速异常: ${err.message}`);
200
+ });
201
+ }
202
+
203
+ // ---- helpers ----
204
+
205
+ _log(msg) {
206
+ console.error(
207
+ `[daemon ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] ${msg}`,
208
+ );
209
+ }
210
+
211
+ _logBanner() {
212
+ console.error("═".repeat(50));
213
+ console.error(" Clash Verge 节点健康守护");
214
+ console.error(
215
+ ` 检测: ${checker.CONFIG.testUrl} | 巡检: ${this.intervalMs / 1000}s | 基准: ${this.benchmarkMs / 60000}min`,
216
+ );
217
+ console.error(
218
+ ` 基准脚本: ${this.benchmarkScript || "(不可用)"} | 模式: ${this.dryRun ? "DRY-RUN" : "正常"}`,
219
+ );
220
+ console.error("═".repeat(50));
221
+ }
222
+
223
+ _setupSignals() {
224
+ const s = (sig) => {
225
+ this._log(`收到 ${sig},退出`);
226
+ this.stop();
227
+ process.exit(0);
228
+ };
229
+ process.on("SIGINT", () => s("SIGINT"));
230
+ process.on("SIGTERM", () => s("SIGTERM"));
231
+ }
232
+ }
233
+
234
+ // 直接运行时(watchdog spawn 或 node daemon.js),自动启动
235
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
236
+ const args = process.argv.slice(2);
237
+ new ClashDaemon({
238
+ intervalSec: parseInt(args[args.indexOf("--interval") + 1]) || undefined,
239
+ benchmarkMin: parseInt(args[args.indexOf("--benchmark") + 1]) || undefined,
240
+ dryRun: args.includes("--dry-run"),
241
+ }).start();
242
+ }
@@ -0,0 +1,248 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Clash 模块统一入口
3
+ */
4
+ export { ClashDaemon } from "./daemon.js";
5
+ export * as api from "./api.js";
6
+ export * as checker from "./checker.js";
7
+ export * as ipc from "./ipc.js";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 文件 IPC 模块
3
+ *
4
+ * 与 watchdog 进程间通信,通过 tmpdir 下的 JSON 文件。
5
+ *
6
+ * clash-health.json — daemon → watchdog(代理健康状态)
7
+ * clash-cmd.json — watchdog → daemon(远程命令,预留)
8
+ */
9
+ import { readFileSync, writeFileSync, existsSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+
13
+ const HEALTH_FILE = join(tmpdir(), "clash-health.json");
14
+ const CMD_FILE = join(tmpdir(), "clash-cmd.json");
15
+
16
+ // ---- health(daemon → watchdog) ----
17
+
18
+ export function writeHealth(state) {
19
+ try {
20
+ writeFileSync(
21
+ HEALTH_FILE,
22
+ JSON.stringify(
23
+ {
24
+ ...state,
25
+ lastCheck: new Date().toISOString(),
26
+ },
27
+ null,
28
+ 2,
29
+ ),
30
+ "utf-8",
31
+ );
32
+ } catch {
33
+ /* 非关键路径 */
34
+ }
35
+ }
36
+
37
+ export function readHealth() {
38
+ try {
39
+ if (!existsSync(HEALTH_FILE)) return null;
40
+ const data = JSON.parse(readFileSync(HEALTH_FILE, "utf-8"));
41
+ const age = Date.now() - new Date(data.lastCheck || 0).getTime();
42
+ return {
43
+ ...data,
44
+ stale: age > 120_000,
45
+ ageSeconds: Math.round(age / 1000),
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // ---- command(watchdog → daemon,预留) ----
53
+
54
+ export function writeCommand(cmd) {
55
+ try {
56
+ writeFileSync(CMD_FILE, JSON.stringify(cmd, null, 2), "utf-8");
57
+ } catch {
58
+ /* 非关键路径 */
59
+ }
60
+ }
61
+
62
+ export function readCommand() {
63
+ try {
64
+ if (!existsSync(CMD_FILE)) return null;
65
+ return JSON.parse(readFileSync(CMD_FILE, "utf-8"));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function clearCommand() {
72
+ try {
73
+ if (existsSync(CMD_FILE)) writeFileSync(CMD_FILE, "{}", "utf-8");
74
+ } catch {}
75
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * clash-daemon CLI 命令入口
3
+ *
4
+ * 用法: tt-help clash-daemon [--interval 30] [--benchmark 40] [--dry-run]
5
+ */
6
+ import { ClashDaemon } from "../clash/daemon.js";
7
+
8
+ export async function handleClashDaemon(options) {
9
+ const d = new ClashDaemon({
10
+ intervalSec: parseInt(options.interval) || undefined,
11
+ benchmarkMin: parseInt(options.benchmark) || undefined,
12
+ dryRun:
13
+ options["dry-run"] === "" ||
14
+ options["dry-run"] === true ||
15
+ process.argv.includes("--dry-run"),
16
+ });
17
+ await d.start();
18
+ }
package/src/lib/args.js CHANGED
@@ -655,6 +655,40 @@ function parseWatchdogArgs(args) {
655
655
  };
656
656
  }
657
657
 
658
+ function parseClashDaemonArgs(args) {
659
+ let interval = 30;
660
+ let benchmark = 40;
661
+ let dryRun = false;
662
+
663
+ for (let i = 0; i < args.length; i++) {
664
+ const arg = args[i];
665
+ if (arg === "--interval") {
666
+ interval = parseInt(args[++i]) || 30;
667
+ } else if (arg === "--benchmark") {
668
+ benchmark = parseInt(args[++i]) || 40;
669
+ } else if (arg === "--dry-run") {
670
+ dryRun = true;
671
+ }
672
+ }
673
+
674
+ return {
675
+ subcommand: "clash-daemon",
676
+ interval,
677
+ benchmark,
678
+ "dry-run": dryRun,
679
+ urls: [],
680
+ outputFormat: "json",
681
+ exploreCount: 0,
682
+ showConfig: false,
683
+ showHelp: false,
684
+ customProxy: null,
685
+ configAction: null,
686
+ configValue: null,
687
+ pipeMode: false,
688
+ filterStr: null,
689
+ };
690
+ }
691
+
658
692
  function parseAttachArgs(args) {
659
693
  let parallel = 1;
660
694
  let interval = 10;
@@ -1362,6 +1396,10 @@ export function parseArgs() {
1362
1396
  return parseWatchdogArgs(args.slice(1));
1363
1397
  }
1364
1398
 
1399
+ if (args.length > 0 && args[0] === "clash-daemon") {
1400
+ return parseClashDaemonArgs(args.slice(1));
1401
+ }
1402
+
1365
1403
  if (args.length > 0 && args[0] === "precheck") {
1366
1404
  return parsePrecheckArgs(args.slice(1));
1367
1405
  }
package/src/main.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  import { handleRawSync } from "./cli/raw-sync.js";
24
24
  import { handleSearch } from "./cli/search.js";
25
25
  import { handleWatchdog } from "./cli/watchdog.js";
26
+ import { handleClashDaemon } from "./cli/clash-daemon.js";
26
27
 
27
28
  async function main() {
28
29
  const parsed = parseArgs();
@@ -68,6 +69,8 @@ async function main() {
68
69
  return handleSearch(parsed);
69
70
  case "watchdog":
70
71
  return handleWatchdog(parsed);
72
+ case "clash-daemon":
73
+ return handleClashDaemon(parsed);
71
74
  }
72
75
 
73
76
  const {
@@ -42,7 +42,11 @@ function findDaemonPath() {
42
42
  }
43
43
  } catch {}
44
44
 
45
- // 2. 相对于 tiktok-cli 同级目录
45
+ // 2. 内部打包路径(npm publish 后 src/clash/daemon.js)
46
+ const internalPath = join(__dirname, "..", "clash", "daemon.js");
47
+ if (existsSync(internalPath)) return internalPath;
48
+
49
+ // 3. 开发模式:clash-verge-explore 同级目录
46
50
  const candidates = [
47
51
  join(__dirname, "..", "..", "..", "clash-verge-explore", "daemon.js"),
48
52
  join(__dirname, "..", "..", "..", "..", "clash-verge-explore", "daemon.js"),
@@ -35,6 +35,14 @@ function sendJSON(res, status, data) {
35
35
 
36
36
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
37
37
 
38
+ function getClientIP(req) {
39
+ return (
40
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
41
+ req.socket.remoteAddress ||
42
+ ""
43
+ );
44
+ }
45
+
38
46
  function readBody(req) {
39
47
  return new Promise((resolve, reject) => {
40
48
  let body = "";
@@ -73,6 +81,11 @@ let consecutiveErrors = 0;
73
81
  const MAX_CONSECUTIVE_ERRORS = 10;
74
82
  let warmTimer = null;
75
83
  let lastWarmTime = 0;
84
+ // 滑动窗口错误率追踪(用于 /health 上报,不触发重启)
85
+ let recentTotal = 0;
86
+ let recentErrors = 0;
87
+ let recentWindowStart = Date.now();
88
+ const RECENT_WINDOW_MS = 60000; // 60 秒滑动窗口
76
89
 
77
90
  async function getScraper() {
78
91
  if (scraperInstance && !scraperClosing) {
@@ -100,6 +113,23 @@ async function getScraper() {
100
113
  }
101
114
  }
102
115
 
116
+ function recordRequest(success) {
117
+ const now = Date.now();
118
+ if (now - recentWindowStart > RECENT_WINDOW_MS) {
119
+ recentTotal = 0;
120
+ recentErrors = 0;
121
+ recentWindowStart = now;
122
+ }
123
+ recentTotal++;
124
+ if (!success) recentErrors++;
125
+ if (success) consecutiveErrors = 0;
126
+ }
127
+
128
+ function isDegraded() {
129
+ // 仅当 scraper 正在恢复/关闭中才算降级,初始冷状态不算
130
+ return scraperClosing || scraperInitPromise !== null;
131
+ }
132
+
103
133
  async function restartScraper(reason) {
104
134
  console.error(`[webserver] 重启 scraper: ${reason}`);
105
135
  if (scraperInstance) {
@@ -156,13 +186,39 @@ async function handleTiktokUser(req, res) {
156
186
  if (!body.uniqueId) {
157
187
  return sendJSON(res, 400, { error: "missing uniqueId" });
158
188
  }
189
+ const clientIP = getClientIP(req);
190
+ const startTime = Date.now();
159
191
  const scraper = await getScraper();
160
192
  const result = await scraper.getUserInfo(body.uniqueId);
193
+ const elapsed = Date.now() - startTime;
194
+
161
195
  if (!result) {
162
- return sendJSON(res, 404, {
163
- error: "User not found or failed to parse data",
196
+ console.error(
197
+ `[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | empty SSR / parse failed`,
198
+ );
199
+ recordRequest(false);
200
+ return sendJSON(res, 503, {
201
+ error: "Temporary failure: unable to fetch user data",
202
+ retryable: true,
164
203
  });
165
204
  }
205
+
206
+ if (result.error) {
207
+ console.error(
208
+ `[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | TikTok statusCode=${result.statusCode}`,
209
+ );
210
+ recordRequest(false);
211
+ return sendJSON(res, 502, {
212
+ error: `TikTok error (statusCode=${result.statusCode})`,
213
+ retryable: false,
214
+ tiktokStatusCode: result.statusCode,
215
+ });
216
+ }
217
+
218
+ console.error(
219
+ `[webserver] user OK | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | verified=${result.verified} followers=${result.followerCount}`,
220
+ );
221
+ recordRequest(true);
166
222
  return sendJSON(res, 200, result);
167
223
  }
168
224
 
@@ -171,13 +227,27 @@ async function handleTiktokVideo(req, res) {
171
227
  if (!body.videoUrl) {
172
228
  return sendJSON(res, 400, { error: "missing videoUrl" });
173
229
  }
230
+ const clientIP = getClientIP(req);
231
+ const startTime = Date.now();
174
232
  const scraper = await getScraper();
175
233
  const result = await scraper.getVideoInfo(body.videoUrl);
234
+ const elapsed = Date.now() - startTime;
235
+
176
236
  if (!result) {
177
- return sendJSON(res, 404, {
178
- error: "Video not found or failed to parse data",
237
+ console.error(
238
+ `[webserver] video FAIL | ${clientIP} | ${body.videoUrl.slice(0, 60)} | ${elapsed}ms | empty SSR / parse failed`,
239
+ );
240
+ recordRequest(false);
241
+ return sendJSON(res, 503, {
242
+ error: "Temporary failure: unable to fetch video data",
243
+ retryable: true,
179
244
  });
180
245
  }
246
+
247
+ console.error(
248
+ `[webserver] video OK | ${clientIP} | @${result.author?.uniqueId || "?"} | ${elapsed}ms | plays=${result.stats?.playCount}`,
249
+ );
250
+ recordRequest(true);
181
251
  return sendJSON(res, 200, result);
182
252
  }
183
253
 
@@ -186,13 +256,27 @@ async function handleTiktokLookup(req, res) {
186
256
  if (!body.videoUrl) {
187
257
  return sendJSON(res, 400, { error: "missing videoUrl" });
188
258
  }
259
+ const clientIP = getClientIP(req);
260
+ const startTime = Date.now();
189
261
  const scraper = await getScraper();
190
262
  const result = await scraper.getUserAndVideo(body.videoUrl);
263
+ const elapsed = Date.now() - startTime;
264
+
191
265
  if (!result) {
192
- return sendJSON(res, 404, {
193
- error: "Video not found or failed to parse data",
266
+ console.error(
267
+ `[webserver] lookup FAIL | ${clientIP} | ${body.videoUrl.slice(0, 60)} | ${elapsed}ms | empty SSR / parse failed`,
268
+ );
269
+ recordRequest(false);
270
+ return sendJSON(res, 503, {
271
+ error: "Temporary failure: unable to fetch video/user data",
272
+ retryable: true,
194
273
  });
195
274
  }
275
+
276
+ console.error(
277
+ `[webserver] lookup OK | ${clientIP} | @${result.user?.uniqueId || "?"} | ${elapsed}ms | videoId=${result.video?.id}`,
278
+ );
279
+ recordRequest(true);
196
280
  return sendJSON(res, 200, result);
197
281
  }
198
282
 
@@ -201,10 +285,7 @@ async function handleTiktokProduct(req, res) {
201
285
  if (!body.url) {
202
286
  return sendJSON(res, 400, { error: "missing url" });
203
287
  }
204
- const clientIP =
205
- req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
206
- req.socket.remoteAddress ||
207
- "";
288
+ const clientIP = getClientIP(req);
208
289
  // URL 标准化:www.tiktok.com/shop/pdp → shop.tiktok.com/us/pdp
209
290
  const normalizedUrl = body.url.replace(
210
291
  "www.tiktok.com/shop/pdp",
@@ -221,18 +302,41 @@ async function handleTiktokProduct(req, res) {
221
302
  console.error(
222
303
  `[webserver] product OK | ${clientIP} | rating=${result.rating ?? "-"} sold=${result.soldCount ?? "-"} name=${(result.productName || "").slice(0, 40)}`,
223
304
  );
305
+ recordRequest(true);
224
306
  return sendJSON(res, 200, result);
225
307
  } catch (e) {
226
308
  console.error(`[webserver] product FAIL | ${clientIP} | ${e.message}`);
309
+ recordRequest(false);
227
310
 
228
- // 如果是 captcha,先触发上报+恢复(不阻塞其他请求)
229
- // 必须在 sendJSON 之前调用,否则 sendJSON 抛异常会导致验证码通知丢失
311
+ // captcha:触发上报+恢复(不阻塞其他请求),返回 503 让 nginx 可 failover
230
312
  if (e.message?.includes("Blocked by TikTok")) {
231
313
  scheduleCaptchaRecovery(normalizedUrl, SCRAPER_CONFIG.proxyServer);
314
+ return sendJSON(res, 503, {
315
+ error: "Blocked by TikTok security check (captcha)",
316
+ retryable: true,
317
+ });
232
318
  }
233
319
 
234
- sendJSON(res, 502, { error: e.message });
235
- return;
320
+ // 网络超时类错误 → 504
321
+ if (
322
+ e.name === "HeadersTimeoutError" ||
323
+ e.name === "BodyTimeoutError" ||
324
+ e.message?.includes("timeout") ||
325
+ e.message?.includes("ETIMEDOUT") ||
326
+ e.message?.includes("ECONNREFUSED") ||
327
+ e.message?.includes("ECONNRESET")
328
+ ) {
329
+ return sendJSON(res, 504, {
330
+ error: `Upstream timeout or network error: ${e.message}`,
331
+ retryable: true,
332
+ });
333
+ }
334
+
335
+ // 其他上游错误 → 502
336
+ return sendJSON(res, 502, {
337
+ error: e.message,
338
+ retryable: true,
339
+ });
236
340
  }
237
341
  }
238
342
 
@@ -311,10 +415,16 @@ async function scheduleCaptchaRecovery(url, proxy) {
311
415
  }
312
416
 
313
417
  function handleHealth(req, res) {
314
- return sendJSON(res, 200, {
315
- status: "ok",
418
+ const degraded = isDegraded();
419
+ const recentErrorRate = recentTotal > 0 ? recentErrors / recentTotal : 0;
420
+ return sendJSON(res, degraded ? 503 : 200, {
421
+ status: degraded ? "degraded" : "ok",
316
422
  scraperReady: scraperInstance !== null && !scraperClosing,
423
+ scraperInitializing: scraperInitPromise !== null,
317
424
  consecutiveErrors,
425
+ recentErrorRate: Math.round(recentErrorRate * 100) / 100,
426
+ recentTotal,
427
+ recentErrors,
318
428
  lastWarmSec: lastWarmTime
319
429
  ? Math.round((Date.now() - lastWarmTime) / 1000)
320
430
  : null,
@@ -384,11 +494,48 @@ export async function startWebserver(
384
494
  } catch (e) {
385
495
  // 自动恢复:连续错误超过阈值时重启 scraper
386
496
  consecutiveErrors++;
497
+ recordRequest(false);
498
+
499
+ // 区分错误类型,返回正确的 HTTP 状态码供 nginx 判断
500
+ let status = 500;
501
+ let retryable = false;
502
+
503
+ if (e.name === "CDNBlockedError") {
504
+ // CDN 限流(Akamai Access Denied)→ 503 临时不可用
505
+ status = 503;
506
+ retryable = true;
507
+ } else if (e.message?.includes("Server is shutting down")) {
508
+ // 服务正在关闭 → 503
509
+ status = 503;
510
+ retryable = true;
511
+ } else if (
512
+ e.message?.includes("Timeout") ||
513
+ e.message?.includes("timeout") ||
514
+ e.name === "TimeoutError"
515
+ ) {
516
+ // 超时 → 504
517
+ status = 504;
518
+ retryable = true;
519
+ } else if (
520
+ e.message?.includes("Protocol error") ||
521
+ e.message?.includes("Target closed") ||
522
+ e.message?.includes("Browser closed") ||
523
+ e.message?.includes("Session closed")
524
+ ) {
525
+ // 浏览器/页面崩溃 → 503
526
+ status = 503;
527
+ retryable = true;
528
+ }
529
+
387
530
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
388
531
  await restartScraper(`连续 ${consecutiveErrors} 次错误`);
389
532
  }
533
+
390
534
  if (!res.headersSent) {
391
- return sendJSON(res, 500, { error: e.message });
535
+ return sendJSON(res, status, {
536
+ error: e.message,
537
+ retryable,
538
+ });
392
539
  }
393
540
  // 响应已发送(流式等场景),记录日志并销毁 socket
394
541
  console.error(