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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.79",
3
+ "version": "1.4.80",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clash/api.js CHANGED
@@ -101,6 +101,23 @@ export async function getNodes() {
101
101
  export async function getConfigs() {
102
102
  return request("/configs");
103
103
  }
104
+
105
+ /**
106
+ * 从 Clash API 自动发现代理端口(mixed-port)
107
+ * 无需依赖任何配置文件,直接问 Clash 自己
108
+ * @returns {Promise<string>} 代理 URL,如 "http://127.0.0.1:7897"
109
+ */
110
+ export async function resolveProxyUrl() {
111
+ try {
112
+ const configs = await getConfigs();
113
+ const port = configs?.["mixed-port"] || 7897;
114
+ return `http://127.0.0.1:${port}`;
115
+ } catch {
116
+ // Clash API 不可达,回退到默认值
117
+ return "http://127.0.0.1:7897";
118
+ }
119
+ }
120
+
104
121
  export async function patchConfigs(patch) {
105
122
  return request("/configs", { method: "PATCH", body: JSON.stringify(patch) });
106
123
  }
@@ -1,16 +1,24 @@
1
1
  /**
2
- * 节点健康检测 — 纯函数模块
2
+ * Clash 节点健康检测 — 统一模块
3
3
  *
4
- * 职责:
5
- * 1. 检测 Clash Verge 进程存活(跨平台 mac/win)
6
- * 2. 检测当前代理节点对目标 URL 的健康状态
7
- * 3. 健康节点列表持久化
8
- * 4. 故障切换候选节点选取
4
+ * 职责:
5
+ * 1. 检测 Clash Verge 进程/API 存活(跨平台 mac/win)
6
+ * 2. 自动发现代理端口(GET /configs → mixed-port)
7
+ * 3. 节点健康检测(curl 穿透代理请求 TikTok)
8
+ * 4. 国家过滤(按节点名 emoji 匹配)
9
+ * 5. 健康节点列表持久化 + 故障切换
10
+ * 6. CLI 入口硬门控预检
9
11
  */
12
+
10
13
  import { spawnSync } from "child_process";
11
14
  import { readFileSync, writeFileSync, existsSync } from "fs";
12
15
  import { join } from "path";
13
16
  import { tmpdir } from "os";
17
+ import * as api from "./api.js";
18
+
19
+ // ══════════════════════════════════════════════
20
+ // 常量 & 配置
21
+ // ══════════════════════════════════════════════
14
22
 
15
23
  const IS_WIN = process.platform === "win32";
16
24
  const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
@@ -22,145 +30,261 @@ export const CONFIG = {
22
30
  groupName: "GLOBAL",
23
31
  curlTimeout: 12,
24
32
  minPageSize: 50 * 1024,
33
+ nodeCountryFilter: null,
25
34
  };
26
35
 
27
- // ---- 进程检测 ----
36
+ const CURL_UA =
37
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
38
+
39
+ const COUNTRY_PATTERNS = {
40
+ US: /[🇺🇸]\s*(美国|US|USA|United\s*States)/i,
41
+ GB: /[🇬🇧]\s*(英国|UK|GB|United\s*Kingdom)/i,
42
+ DE: /[🇩🇪]\s*(德国|DE|Germany)/i,
43
+ FR: /[🇫🇷]\s*(法国|FR|France)/i,
44
+ JP: /[🇯🇵]\s*(日本|JP|Japan)/i,
45
+ KR: /[🇰🇷]\s*(韩国|KR|Korea)/i,
46
+ SG: /[🇸🇬]\s*(新加坡|SG|Singapore)/i,
47
+ HK: /[🇭🇰]\s*(香港|HK|Hong\s*Kong)/i,
48
+ TW: /[🇹🇼]\s*(台湾|TW|Taiwan)/i,
49
+ RU: /[🇷🇺]\s*(俄罗斯|RU|Russia)/i,
50
+ NL: /[🇳🇱]\s*(荷兰|NL|Netherlands)/i,
51
+ IN: /[🇮🇳]\s*(印度|IN|India)/i,
52
+ CA: /[🇨🇦]\s*(加拿大|CA|Canada)/i,
53
+ AU: /[🇦🇺]\s*(澳大利亚|AU|Australia)/i,
54
+ MY: /[🇲🇾]\s*(马来西亚|MY|Malaysia)/i,
55
+ };
56
+
57
+ let _proxyResolved = false;
58
+
59
+ // ══════════════════════════════════════════════
60
+ // 1. 代理端口自动发现
61
+ // ══════════════════════════════════════════════
62
+
63
+ export async function resolveAndSetProxyUrl() {
64
+ if (_proxyResolved) return CONFIG.proxyUrl;
65
+ try { CONFIG.proxyUrl = await api.resolveProxyUrl(); } catch {}
66
+ _proxyResolved = true;
67
+ return CONFIG.proxyUrl;
68
+ }
69
+
70
+ export function resetProxyUrlCache() { _proxyResolved = false; }
71
+
72
+ // ══════════════════════════════════════════════
73
+ // 2. 进程 / API 可达性检测
74
+ // ══════════════════════════════════════════════
28
75
 
29
76
  export function checkClashAlive() {
77
+ return checkClashAliveWithReason().alive;
78
+ }
79
+
80
+ export function checkClashAliveWithReason() {
81
+ if (_detectProcess()) return { alive: true, reason: "verge-mihomo 进程已运行" };
82
+ if (_detectApi()) return { alive: true, reason: `API 端口 ${CONFIG.apiPort} 可达` };
83
+ return {
84
+ alive: false,
85
+ reason: `Clash Verge 未运行,或 API 端口 ${CONFIG.apiPort} 未开启(请在 Clash Verge 设置中开启"允许局域网连接"或"RESTful API")`,
86
+ };
87
+ }
88
+
89
+ function _detectProcess() {
30
90
  try {
31
91
  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;
92
+ const r = spawnSync("tasklist", ["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
93
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
94
+ return r.stdout?.includes("verge-mihomo.exe");
49
95
  }
50
- } catch {
51
- /* fallthrough */
52
- }
96
+ const r = spawnSync("pgrep", ["-x", "verge-mihomo"],
97
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
98
+ return r.status === 0 && !!r.stdout.trim();
99
+ } catch { return false; }
100
+ }
53
101
 
102
+ function _detectApi() {
54
103
  try {
55
104
  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
- );
105
+ const r = spawnSync(exe, ["-s", "--max-time", "3",
106
+ `http://127.0.0.1:${CONFIG.apiPort}/version`],
107
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
65
108
  return r.stdout.includes('"version"');
66
- } catch {
67
- return false;
109
+ } catch { return false; }
110
+ }
111
+
112
+ export async function requireClashOrExit(label = "") {
113
+ const { alive, reason } = checkClashAliveWithReason();
114
+ if (!alive) {
115
+ const pf = label ? `[${label}] ` : "";
116
+ console.error(`\n${pf}⚠ 代理不可用:${reason}`);
117
+ console.error(`${pf}请先启动 Clash Verge 并确保:`);
118
+ console.error(` 1. Clash Verge 正在运行(verge-mihomo 进程存活)`);
119
+ console.error(` 2. API 端口 ${CONFIG.apiPort} 已开启(设置 → 允许局域网连接 / RESTful API)`);
120
+ console.error(` 3. 代理端口 ${CONFIG.proxyUrl.replace("http://", "")} 正在监听\n`);
121
+ process.exit(1);
68
122
  }
123
+ return resolveAndSetProxyUrl();
69
124
  }
70
125
 
71
- // ---- 节点健康检测 ----
126
+ // ══════════════════════════════════════════════
127
+ // 3. 节点健康检测(同步 curl)
128
+ // ══════════════════════════════════════════════
72
129
 
73
130
  export function checkNodeHealth(url = CONFIG.testUrl) {
131
+ const { body, metaLine, error } = _curlViaProxy(url);
132
+ const { httpCode, timeTotal, sizeDownload } = _parseCurlMeta(metaLine);
133
+ const title = _extractTitle(body);
134
+ return {
135
+ healthy: _isHealthyPage(error, httpCode, sizeDownload, title, body),
136
+ httpCode, timeTotal, sizeDownload, title, error,
137
+ };
138
+ }
139
+
140
+ function _curlViaProxy(url) {
74
141
  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,
142
+ "-s", "-w", "\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
143
+ "--proxy", CONFIG.proxyUrl,
144
+ "--max-time", String(CONFIG.curlTimeout),
145
+ "-L", "-A", CURL_UA, url,
86
146
  ];
87
- const result = spawnSync("curl", args, {
147
+ const r = spawnSync("curl", args, {
88
148
  encoding: "utf-8",
89
149
  timeout: (CONFIG.curlTimeout + 5) * 1000,
90
150
  stdio: ["pipe", "pipe", "pipe"],
91
151
  maxBuffer: 10 * 1024 * 1024,
92
152
  });
153
+ const out = r.stdout || "";
154
+ const idx = out.lastIndexOf("__CURL_META__");
155
+ return {
156
+ body: idx >= 0 ? out.substring(0, idx).trimEnd() : "",
157
+ metaLine: idx >= 0 ? out.substring(idx + 13).trim() : "",
158
+ error: r.error ? r.error.message : r.status !== 0 ? `curl exit ${r.status}` : null,
159
+ };
160
+ }
93
161
 
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;
162
+ function _parseCurlMeta(line) {
163
+ const p = line.split("|");
164
+ return { httpCode: parseInt(p[0]) || 0, timeTotal: parseFloat(p[1]) || 0, sizeDownload: parseInt(p[2]) || 0 };
165
+ }
106
166
 
107
- let title = "";
167
+ function _extractTitle(body) {
108
168
  const m = body.match(/<title>([^<]*)<\/title>/i);
109
- if (m) title = m[1].trim();
169
+ return m ? m[1].trim() : "";
170
+ }
110
171
 
111
- let error = null;
112
- if (result.error) error = result.error.message;
113
- else if (result.status !== 0) error = `curl exit ${result.status}`;
172
+ function _isHealthyPage(error, httpCode, size, title, body) {
173
+ return !error && httpCode === 200 && size >= CONFIG.minPageSize &&
174
+ title.includes("TikTok") && !title.startsWith("About |") &&
175
+ !body.includes("SlardarWAF") && !body.includes("_wafchallengeid");
176
+ }
114
177
 
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");
178
+ // ══════════════════════════════════════════════
179
+ // 4. 国家过滤
180
+ // ══════════════════════════════════════════════
123
181
 
124
- return { healthy, httpCode, timeTotal, sizeDownload, title, error };
182
+ export function filterNodesByCountry(nodes, country) {
183
+ if (!country || !COUNTRY_PATTERNS[country]) return { filtered: nodes, excluded: 0 };
184
+ const filtered = nodes.filter(n => COUNTRY_PATTERNS[country].test(n));
185
+ return { filtered, excluded: nodes.length - filtered.length };
125
186
  }
126
187
 
127
- // ---- 健康节点列表持久化 ----
188
+ export function getSupportedCountries() {
189
+ return Object.keys(COUNTRY_PATTERNS);
190
+ }
191
+
192
+ // ══════════════════════════════════════════════
193
+ // 5. 健康节点列表持久化
194
+ // ══════════════════════════════════════════════
128
195
 
129
- export function loadHealthyList() {
196
+ export function loadHealthyList(country = null) {
130
197
  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
- }
198
+ if (!existsSync(HEALTHY_FILE)) return _emptyList();
199
+ const d = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
200
+ const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
201
+ const filter = country || CONFIG.nodeCountryFilter;
202
+ const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
203
+ return { updated: d.updated || "", nodes: filtered, totalNodes: allNodes.length, excluded, countryFilter: filter || null };
138
204
  } catch (e) {
139
205
  console.error(`[checker] 读取健康列表失败: ${e.message}`);
140
206
  }
141
- return { updated: "", nodes: [] };
207
+ return _emptyList();
142
208
  }
143
209
 
144
210
  export function saveHealthyList(nodes) {
145
211
  try {
146
- writeFileSync(
147
- HEALTHY_FILE,
148
- JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2),
149
- "utf-8",
150
- );
212
+ writeFileSync(HEALTHY_FILE,
213
+ JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2), "utf-8");
151
214
  } catch (e) {
152
215
  console.error(`[checker] 保存健康列表失败: ${e.message}`);
153
216
  }
154
217
  }
155
218
 
156
- // ---- 故障切换候选选取 ----
219
+ function _emptyList() {
220
+ return { updated: "", nodes: [], totalNodes: 0, excluded: 0, countryFilter: null };
221
+ }
222
+
223
+ // ══════════════════════════════════════════════
224
+ // 6. 故障切换
225
+ // ══════════════════════════════════════════════
157
226
 
158
227
  export function pickFailoverNode(currentNode, healthyNodes) {
159
228
  if (!healthyNodes?.length) return null;
160
- const candidates = healthyNodes.filter((n) => n !== currentNode);
229
+ const candidates = healthyNodes.filter(n => n !== currentNode);
161
230
  if (!candidates.length) {
162
231
  console.error(`[checker] 无可用候选节点(共${healthyNodes.length}个)`);
163
232
  return null;
164
233
  }
165
234
  return candidates[Math.floor(Math.random() * candidates.length)];
166
235
  }
236
+
237
+ export async function switchToNode(nodeName) {
238
+ try {
239
+ await api.switchProxy(CONFIG.groupName, nodeName);
240
+ await new Promise(r => setTimeout(r, 600));
241
+ const current = await api.getCurrentNode(CONFIG.groupName);
242
+ if (current !== nodeName) {
243
+ console.error(`[checker] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
244
+ return false;
245
+ }
246
+ return true;
247
+ } catch (e) {
248
+ console.error(`[checker] 切换节点失败: ${e.message}`);
249
+ return false;
250
+ }
251
+ }
252
+
253
+ // ══════════════════════════════════════════════
254
+ // 7. 一站式巡检
255
+ // ══════════════════════════════════════════════
256
+
257
+ export async function runHealthCheck() {
258
+ if (!checkClashAlive()) return { action: "skipped", detail: "Clash Verge 未运行" };
259
+ await resolveAndSetProxyUrl();
260
+
261
+ const health = checkNodeHealth();
262
+ if (health.healthy) {
263
+ return { action: "ok", detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB` };
264
+ }
265
+
266
+ const reason = health.error
267
+ ? `错误: ${health.error}`
268
+ : `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
269
+
270
+ let currentNode;
271
+ try { currentNode = await api.getCurrentNode(CONFIG.groupName); }
272
+ catch (e) { return { action: "failed", detail: `无法获取当前节点: ${e.message}` }; }
273
+
274
+ const { nodes } = loadHealthyList();
275
+ if (!nodes.length) return { action: "failed", detail: `${reason} — 健康列表为空` };
276
+
277
+ const target = pickFailoverNode(currentNode, nodes);
278
+ if (!target) return { action: "failed", detail: `${reason} — 无可用候选节点` };
279
+
280
+ if (!(await switchToNode(target))) {
281
+ return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
282
+ }
283
+
284
+ const newHealth = checkNodeHealth();
285
+ const detail = newHealth.healthy
286
+ ? `${currentNode} → ${target} (${newHealth.timeTotal.toFixed(1)}s ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`
287
+ : `${currentNode} → ${target} (切换后仍异常: ${newHealth.httpCode} ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`;
288
+
289
+ return { action: "switched", detail };
290
+ }