tt-help-cli-ycl 1.4.83 → 1.4.87
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 -2
- package/src/clash/checker.js +53 -6
- package/src/clash/daemon.js +123 -17
- package/src/clash/ipc.js +7 -4
- package/src/cli/explore.js +69 -8
- package/src/cli/refresh.js +78 -10
- package/src/cli/search.js +69 -8
- package/src/watch/public/app.js +242 -5
- package/src/watch/public/index.html +56 -1
- package/src/watch/watchdog-server.js +157 -1
- package/src/watchdog/agent.js +288 -17
- package/src/watchdog/process-manager.js +34 -2
- package/src/watchdog/upgrader.js +40 -3
- package/src/webserver/server.mjs +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tt-help-cli-ycl",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.87",
|
|
4
4
|
"description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"axios": "^1.16.1",
|
|
44
44
|
"https-proxy-agent": "^9.0.0",
|
|
45
45
|
"playwright": "^1.59.1",
|
|
46
|
-
"tt-help-cli-ycl": "^1.3.36",
|
|
47
46
|
"undici": "^8.1.0"
|
|
48
47
|
},
|
|
49
48
|
"devDependencies": {
|
package/src/clash/checker.js
CHANGED
|
@@ -81,6 +81,15 @@ export function checkClashAlive() {
|
|
|
81
81
|
return checkClashAliveWithReason().alive;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* 仅以 API 控制端口可达判定 Clash 就绪
|
|
86
|
+
* daemon 的节点读取/切换/delay 全部依赖 API 端口,进程存活不等于控制端口已开启
|
|
87
|
+
* (Clash Verge 进程可能已在跑但 RESTful API 未开启/端口未监听)
|
|
88
|
+
*/
|
|
89
|
+
export function checkClashApiAlive() {
|
|
90
|
+
return _detectApi();
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
export function checkClashAliveWithReason() {
|
|
85
94
|
if (_detectProcess())
|
|
86
95
|
return { alive: true, reason: "verge-mihomo 进程已运行" };
|
|
@@ -116,12 +125,25 @@ function _detectProcess() {
|
|
|
116
125
|
function _detectApi() {
|
|
117
126
|
try {
|
|
118
127
|
const exe = IS_WIN ? "curl.exe" : "curl";
|
|
128
|
+
const devNull = IS_WIN ? "NUL" : "/dev/null";
|
|
119
129
|
const r = spawnSync(
|
|
120
130
|
exe,
|
|
121
|
-
[
|
|
131
|
+
[
|
|
132
|
+
"-s",
|
|
133
|
+
"--max-time",
|
|
134
|
+
"3",
|
|
135
|
+
"-o",
|
|
136
|
+
devNull,
|
|
137
|
+
"-w",
|
|
138
|
+
"%{http_code}",
|
|
139
|
+
`http://127.0.0.1:${CONFIG.apiPort}/version`,
|
|
140
|
+
],
|
|
122
141
|
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
123
142
|
);
|
|
124
|
-
|
|
143
|
+
// 只要有 HTTP 响应(含 401 未授权)即代表控制端口已开启、正在监听;
|
|
144
|
+
// 认证由上层 api.js 的 Bearer secret 处理,不在此判定
|
|
145
|
+
const code = parseInt(String(r.stdout || "").trim(), 10);
|
|
146
|
+
return Number.isInteger(code) && code > 0 && code < 600;
|
|
125
147
|
} catch {
|
|
126
148
|
return false;
|
|
127
149
|
}
|
|
@@ -226,10 +248,31 @@ function _isHealthyPage(error, httpCode, size, title, body) {
|
|
|
226
248
|
// 4. 国家过滤
|
|
227
249
|
// ══════════════════════════════════════════════
|
|
228
250
|
|
|
251
|
+
/**
|
|
252
|
+
* 归一化国家配置为去重大写数组(支持字符串或数组)
|
|
253
|
+
* daemon 的国家配置下发与 checker 的节点过滤统一使用该入口,保证单一来源
|
|
254
|
+
*/
|
|
255
|
+
export function normalizeCountries(v) {
|
|
256
|
+
if (Array.isArray(v))
|
|
257
|
+
return [
|
|
258
|
+
...new Set(v.map((c) => String(c).trim().toUpperCase()).filter(Boolean)),
|
|
259
|
+
];
|
|
260
|
+
if (typeof v === "string" && v.trim()) return [v.trim().toUpperCase()];
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 把国家过滤值归一化为匹配模式数组(多国取并集),未知国家自动忽略 */
|
|
265
|
+
function getCountryPatterns(country) {
|
|
266
|
+
const pats = normalizeCountries(country)
|
|
267
|
+
.map((c) => COUNTRY_PATTERNS[c])
|
|
268
|
+
.filter(Boolean);
|
|
269
|
+
return pats.length ? pats : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
229
272
|
export function filterNodesByCountry(nodes, country) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const filtered = nodes.filter((n) =>
|
|
273
|
+
const pats = getCountryPatterns(country);
|
|
274
|
+
if (!pats) return { filtered: nodes, excluded: 0 };
|
|
275
|
+
const filtered = nodes.filter((n) => pats.some((p) => p.test(n)));
|
|
233
276
|
return { filtered, excluded: nodes.length - filtered.length };
|
|
234
277
|
}
|
|
235
278
|
|
|
@@ -245,7 +288,7 @@ export function getSupportedCountries() {
|
|
|
245
288
|
* 对 GLOBAL 组节点做 delay 初测(不切换当前节点)
|
|
246
289
|
* 并发测延迟,筛出可连通且延迟达标的节点,按延迟升序返回,作为故障切换的候选集
|
|
247
290
|
* @param {object} opts
|
|
248
|
-
* @param {string} [opts.country=null] - 国家过滤(默认 CONFIG.nodeCountryFilter
|
|
291
|
+
* @param {string|string[]} [opts.country=null] - 国家过滤(默认 CONFIG.nodeCountryFilter,支持多国数组)
|
|
249
292
|
* @param {number} [opts.timeoutMs=5000] - 单节点超时(毫秒)
|
|
250
293
|
* @param {number} [opts.thresholdMs=4000] - 延迟阈值(超过视为慢,不入选,毫秒)
|
|
251
294
|
* @param {number} [opts.concurrency=10] - 并发数
|
|
@@ -304,6 +347,10 @@ export async function runDelayProbe({
|
|
|
304
347
|
// 5. 健康节点列表持久化
|
|
305
348
|
// ══════════════════════════════════════════════
|
|
306
349
|
|
|
350
|
+
/**
|
|
351
|
+
* 读取健康节点列表(可按国家过滤)
|
|
352
|
+
* @param {string|string[]} [country=null] - 国家过滤(默认 CONFIG.nodeCountryFilter,支持多国数组)
|
|
353
|
+
*/
|
|
307
354
|
export function loadHealthyList(country = null) {
|
|
308
355
|
try {
|
|
309
356
|
if (!existsSync(HEALTHY_FILE)) return _emptyList();
|
package/src/clash/daemon.js
CHANGED
|
@@ -6,10 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
|
+
import { normalizeCountries } from "./checker.js";
|
|
9
10
|
import * as api from "./api.js";
|
|
10
11
|
import * as checker from "./checker.js";
|
|
11
12
|
import * as ipc from "./ipc.js";
|
|
12
13
|
|
|
14
|
+
// ── 国家过滤辅助(normalizeCountries 由 checker.js 提供单一来源) ──
|
|
15
|
+
/** 国家配置显示(数组 join) */
|
|
16
|
+
function fmtCountries(v) {
|
|
17
|
+
const list = normalizeCountries(v);
|
|
18
|
+
return list.length ? list.join(",") : "无";
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
// ══════════════════════════════════════════════
|
|
14
22
|
// ClashDaemon
|
|
15
23
|
// ══════════════════════════════════════════════
|
|
@@ -21,8 +29,11 @@ export class ClashDaemon {
|
|
|
21
29
|
this.dryRun = !!opts.dryRun;
|
|
22
30
|
this._running = false;
|
|
23
31
|
this._timers = [];
|
|
32
|
+
this._waitTimer = null; // 等待 Clash 就绪的重试定时器
|
|
33
|
+
this._startedOnce = false; // 首次启动标记(banner/信号只打印注册一次)
|
|
24
34
|
this._benchmarkRunning = false;
|
|
25
|
-
|
|
35
|
+
const initCountries = normalizeCountries(opts.country);
|
|
36
|
+
if (initCountries.length) checker.CONFIG.nodeCountryFilter = initCountries;
|
|
26
37
|
this._meta = {
|
|
27
38
|
groupName: checker.CONFIG.groupName,
|
|
28
39
|
proxyUrl: checker.CONFIG.proxyUrl,
|
|
@@ -40,17 +51,39 @@ export class ClashDaemon {
|
|
|
40
51
|
async start() {
|
|
41
52
|
if (this._running) return;
|
|
42
53
|
this._running = true;
|
|
43
|
-
this._logBanner();
|
|
44
|
-
this._setupSignals();
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
// 首次进入时打印 banner 并注册信号(等待重试时不再重复)
|
|
56
|
+
if (!this._startedOnce) {
|
|
57
|
+
this._startedOnce = true;
|
|
58
|
+
this._logBanner();
|
|
59
|
+
this._setupSignals();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 等待 Clash 就绪(容错启动顺序:先启 watchdog 后开 Clash 控制端口也可正常工作)
|
|
63
|
+
// 以 API 控制端口为准:进程在但控制端口未开启时同样等待,避免误判后所有 API 操作失败
|
|
64
|
+
const alive = checker.checkClashApiAlive();
|
|
65
|
+
const reason = alive
|
|
66
|
+
? `API 端口 ${checker.CONFIG.apiPort} 可达`
|
|
67
|
+
: `Clash 控制端口 ${checker.CONFIG.apiPort} 未开启`;
|
|
47
68
|
if (!alive) {
|
|
69
|
+
this._log(`⚠ Clash 未就绪:${reason}`);
|
|
48
70
|
this._log(
|
|
49
|
-
|
|
71
|
+
` 等待 API 端口 ${checker.CONFIG.apiPort} 可用,10s 后重试(Clash 就绪后自动接入)...`,
|
|
50
72
|
);
|
|
73
|
+
ipc.writeHealth({
|
|
74
|
+
node: "?",
|
|
75
|
+
healthy: false,
|
|
76
|
+
error: `等待 Clash 就绪: ${reason}`,
|
|
77
|
+
meta: this._buildMeta(),
|
|
78
|
+
});
|
|
51
79
|
this._running = false;
|
|
80
|
+
this._waitTimer = setTimeout(() => this.start(), 10_000);
|
|
52
81
|
return;
|
|
53
82
|
}
|
|
83
|
+
// 等待期间的重试定时器已触发,清理引用
|
|
84
|
+
if (this._waitTimer) {
|
|
85
|
+
this._waitTimer = null;
|
|
86
|
+
}
|
|
54
87
|
const proxyUrl = await checker.resolveAndSetProxyUrl();
|
|
55
88
|
this._log(
|
|
56
89
|
`✓ Clash 已连接 (${reason}), 代理端口: ${proxyUrl.replace("http://", "")}`,
|
|
@@ -83,6 +116,10 @@ export class ClashDaemon {
|
|
|
83
116
|
|
|
84
117
|
async stop() {
|
|
85
118
|
this._running = false;
|
|
119
|
+
if (this._waitTimer) {
|
|
120
|
+
clearTimeout(this._waitTimer);
|
|
121
|
+
this._waitTimer = null;
|
|
122
|
+
}
|
|
86
123
|
this._timers.forEach(clearInterval);
|
|
87
124
|
this._timers = [];
|
|
88
125
|
this._log("守护进程已停止");
|
|
@@ -227,13 +264,15 @@ export class ClashDaemon {
|
|
|
227
264
|
_pollCommands() {
|
|
228
265
|
if (!this._running) return;
|
|
229
266
|
try {
|
|
230
|
-
const
|
|
231
|
-
if (!
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
handler
|
|
235
|
-
|
|
236
|
-
|
|
267
|
+
const cmds = ipc.readCommand();
|
|
268
|
+
if (!cmds) return;
|
|
269
|
+
for (const cmd of cmds) {
|
|
270
|
+
const handler = this._cmdHandlers[cmd.action];
|
|
271
|
+
if (handler) {
|
|
272
|
+
handler(cmd);
|
|
273
|
+
} else {
|
|
274
|
+
this._log(`📋 未知命令: ${JSON.stringify(cmd)}`);
|
|
275
|
+
}
|
|
237
276
|
}
|
|
238
277
|
ipc.clearCommand();
|
|
239
278
|
} catch {}
|
|
@@ -273,6 +312,66 @@ export class ClashDaemon {
|
|
|
273
312
|
}
|
|
274
313
|
}
|
|
275
314
|
|
|
315
|
+
/**
|
|
316
|
+
* 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
|
|
317
|
+
* 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
|
|
318
|
+
*/
|
|
319
|
+
async _handleFailover() {
|
|
320
|
+
try {
|
|
321
|
+
const node = await api
|
|
322
|
+
.getCurrentNode(checker.CONFIG.groupName)
|
|
323
|
+
.catch(() => "?");
|
|
324
|
+
this._log(`🔄 看门狗指令: 触发故障切换 (当前: ${node})`);
|
|
325
|
+
const { nodes } = checker.loadHealthyList();
|
|
326
|
+
if (!nodes.length) {
|
|
327
|
+
this._log("🔄 候选集为空,触发 delay 初测");
|
|
328
|
+
await this._runDelayProbe();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
|
|
332
|
+
let target = null;
|
|
333
|
+
let h2 = null;
|
|
334
|
+
for (const cand of nodes) {
|
|
335
|
+
if (cand === node) continue;
|
|
336
|
+
this._log(`↺ 尝试候选: ${cand}`);
|
|
337
|
+
if (!(await checker.switchToNode(cand))) {
|
|
338
|
+
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
h2 = checker.checkNodeHealth();
|
|
342
|
+
if (h2.healthy) {
|
|
343
|
+
target = cand;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
this._log(` ↺ ${cand} 内容验证未通过,下一个`);
|
|
347
|
+
}
|
|
348
|
+
if (!target) {
|
|
349
|
+
await checker.switchToNode(node).catch(() => {});
|
|
350
|
+
this._log(`✗ failover: 候选全部不可用,恢复原节点`);
|
|
351
|
+
return ipc.writeHealth({
|
|
352
|
+
node,
|
|
353
|
+
healthy: false,
|
|
354
|
+
error: "failover: 候选全部不可用",
|
|
355
|
+
lastSwitch: "failover: 候选全部不可用",
|
|
356
|
+
meta: this._buildMeta(),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
this._log(`⚠ failover: ${node} → ${target}`);
|
|
360
|
+
ipc.writeHealth({
|
|
361
|
+
node: target,
|
|
362
|
+
healthy: h2.healthy,
|
|
363
|
+
httpCode: h2.httpCode,
|
|
364
|
+
timeTotal: h2.timeTotal,
|
|
365
|
+
sizeDownload: h2.sizeDownload,
|
|
366
|
+
title: h2.title,
|
|
367
|
+
lastSwitch: `failover: ${node} → ${target}`,
|
|
368
|
+
meta: this._buildMeta(),
|
|
369
|
+
});
|
|
370
|
+
} catch (e) {
|
|
371
|
+
this._log(`🔄 故障切换异常: ${e.message}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
276
375
|
// ── helpers ──
|
|
277
376
|
|
|
278
377
|
_buildMeta() {
|
|
@@ -296,7 +395,7 @@ export class ClashDaemon {
|
|
|
296
395
|
|
|
297
396
|
_logBanner() {
|
|
298
397
|
const filterTag = checker.CONFIG.nodeCountryFilter
|
|
299
|
-
? ` | 节点: ${checker.CONFIG.nodeCountryFilter}`
|
|
398
|
+
? ` | 节点: ${fmtCountries(checker.CONFIG.nodeCountryFilter)}`
|
|
300
399
|
: "";
|
|
301
400
|
console.error("═".repeat(50));
|
|
302
401
|
console.error(" Clash Verge 节点健康守护");
|
|
@@ -328,11 +427,17 @@ function _buildCmdHandlers(daemon) {
|
|
|
328
427
|
return {
|
|
329
428
|
setCountryFilter(cmd) {
|
|
330
429
|
const prev = checker.CONFIG.nodeCountryFilter;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
daemon._log(
|
|
334
|
-
`📋 节点过滤: ${prev || "无"} → ${checker.CONFIG.nodeCountryFilter || "无"}`,
|
|
430
|
+
const next = normalizeCountries(
|
|
431
|
+
cmd.countries || (cmd.country ? [cmd.country] : []),
|
|
335
432
|
);
|
|
433
|
+
checker.CONFIG.nodeCountryFilter = next.length ? next : null;
|
|
434
|
+
daemon._meta.countryFilter = checker.CONFIG.nodeCountryFilter;
|
|
435
|
+
daemon._log(`📋 节点过滤: ${fmtCountries(prev)} → ${fmtCountries(next)}`);
|
|
436
|
+
// 幂等:国家配置变化时才立即刷新候选集,避免每次心跳重复初测
|
|
437
|
+
if (JSON.stringify(prev || []) !== JSON.stringify(next)) {
|
|
438
|
+
daemon._log(`📋 国家变化,立即刷新候选集...`);
|
|
439
|
+
daemon._runDelayProbe().catch(() => {});
|
|
440
|
+
}
|
|
336
441
|
},
|
|
337
442
|
setTestUrl(cmd) {
|
|
338
443
|
if (cmd.url) {
|
|
@@ -352,6 +457,7 @@ function _buildCmdHandlers(daemon) {
|
|
|
352
457
|
}
|
|
353
458
|
},
|
|
354
459
|
switchNode: (cmd) => daemon._handleSwitchNode(cmd),
|
|
460
|
+
failover: () => daemon._handleFailover(),
|
|
355
461
|
};
|
|
356
462
|
}
|
|
357
463
|
|
package/src/clash/ipc.js
CHANGED
|
@@ -66,11 +66,13 @@ export function readHealth() {
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* 写入命令(watchdog 调用,daemon 轮询读取)
|
|
69
|
-
*
|
|
69
|
+
* 支持单条对象或命令数组,统一按数组存储,daemon 一次处理一批
|
|
70
|
+
* @param {object|object[]} cmd - 命令对象或命令数组,如 { action: "setCountryFilter", countries: ["US"] }
|
|
70
71
|
*/
|
|
71
72
|
export function writeCommand(cmd) {
|
|
72
73
|
try {
|
|
73
|
-
|
|
74
|
+
const payload = Array.isArray(cmd) ? cmd : [cmd];
|
|
75
|
+
writeFileSync(CMD_FILE, JSON.stringify(payload, null, 2), "utf-8");
|
|
74
76
|
} catch {
|
|
75
77
|
/* 非关键路径 */
|
|
76
78
|
}
|
|
@@ -80,8 +82,9 @@ export function readCommand() {
|
|
|
80
82
|
try {
|
|
81
83
|
if (!existsSync(CMD_FILE)) return null;
|
|
82
84
|
const text = readFileSync(CMD_FILE, "utf-8").trim();
|
|
83
|
-
if (!text || text === "{}") return null;
|
|
84
|
-
|
|
85
|
+
if (!text || text === "{}" || text === "[]") return null;
|
|
86
|
+
const parsed = JSON.parse(text);
|
|
87
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
85
88
|
} catch {
|
|
86
89
|
return null;
|
|
87
90
|
}
|
package/src/cli/explore.js
CHANGED
|
@@ -32,6 +32,7 @@ import path from "path";
|
|
|
32
32
|
import os from "os";
|
|
33
33
|
|
|
34
34
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
35
|
+
const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
|
|
35
36
|
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
36
37
|
const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
|
|
37
38
|
|
|
@@ -45,7 +46,7 @@ async function withRetry(label, fn) {
|
|
|
45
46
|
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
46
47
|
);
|
|
47
48
|
await new Promise((r) => setTimeout(r, backoff));
|
|
48
|
-
|
|
49
|
+
backoff = Math.min(backoff * 2, MAX_RETRY_WAIT);
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -244,13 +245,73 @@ export async function handleExplore(options) {
|
|
|
244
245
|
console.error(
|
|
245
246
|
`[健康检查] 已切换到端口 ${nextAccount.port}${effectiveProxy ? ", 代理: " + effectiveProxy : ""}`,
|
|
246
247
|
);
|
|
247
|
-
// 切换账户后先导航到 TikTok
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
248
|
+
// 切换账户后先导航到 TikTok 页面:退避重试 + 持续失败时重启浏览器重建 page。
|
|
249
|
+
// 重启超过 MAX_REBUILD_ATTEMPTS 次仍失败 → 上报并退出,避免僵尸进程。
|
|
250
|
+
let navFailures = 0;
|
|
251
|
+
let rebuildCount = 0;
|
|
252
|
+
let backoff = 1000;
|
|
253
|
+
while (true) {
|
|
254
|
+
try {
|
|
255
|
+
await page.goto(STARTUP_TIKTOK_URL, {
|
|
256
|
+
waitUntil: "domcontentloaded",
|
|
257
|
+
timeout: PAGE_GOTO_TIMEOUT,
|
|
258
|
+
});
|
|
259
|
+
rebuildCount = 0;
|
|
260
|
+
break;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
navFailures++;
|
|
263
|
+
console.error(
|
|
264
|
+
`[连接] 账户切换后页面导航 失败 (${navFailures}): ${err.message},${Math.round(backoff / 1000)}秒后重试...`,
|
|
265
|
+
);
|
|
266
|
+
if (isBrowserClosedError(err) || navFailures >= 3) {
|
|
267
|
+
rebuildCount++;
|
|
268
|
+
if (rebuildCount > MAX_REBUILD_ATTEMPTS) {
|
|
269
|
+
console.error(
|
|
270
|
+
`[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
|
|
271
|
+
);
|
|
272
|
+
await apiPost(`${serverUrl}/api/error-report`, {
|
|
273
|
+
userId,
|
|
274
|
+
username: "account-switch",
|
|
275
|
+
errorType: "account_switch_failed",
|
|
276
|
+
errorMessage: `账户切换后导航持续失败: ${err.message}`,
|
|
277
|
+
stage: "account-switch",
|
|
278
|
+
errorStack: err.stack || "",
|
|
279
|
+
}).catch(() => {});
|
|
280
|
+
if (cdpOptions) {
|
|
281
|
+
await killEdgeProcesses(
|
|
282
|
+
cdpOptions.userDataDir,
|
|
283
|
+
cdpOptions.port,
|
|
284
|
+
).catch(() => {});
|
|
285
|
+
}
|
|
286
|
+
await browser?.close().catch(() => {});
|
|
287
|
+
console.error("[Explore] 已退出(账户切换无法恢复)");
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
// 浏览器已关闭或连续 3 次导航失败 → 重启浏览器 + 重建 page 后再试
|
|
291
|
+
console.error(
|
|
292
|
+
` [恢复] (${rebuildCount}/${MAX_REBUILD_ATTEMPTS}) 重启浏览器 (端口 ${cdpOptions.port || 9222})...`,
|
|
293
|
+
);
|
|
294
|
+
try {
|
|
295
|
+
const relaunched = await relaunchBrowser(
|
|
296
|
+
cdpOptions,
|
|
297
|
+
cdpOptions.port || 9222,
|
|
298
|
+
);
|
|
299
|
+
browser = relaunched;
|
|
300
|
+
const rebuiltPage = await setupNewPage(browser);
|
|
301
|
+
Object.assign(page, rebuiltPage);
|
|
302
|
+
navFailures = 0;
|
|
303
|
+
backoff = 1000;
|
|
304
|
+
continue;
|
|
305
|
+
} catch (rebuildErr) {
|
|
306
|
+
console.error(
|
|
307
|
+
` [恢复] 重启浏览器失败: ${rebuildErr.message},等待后继续重试...`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
312
|
+
backoff = Math.min(backoff * 2, MAX_RETRY_WAIT);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
254
315
|
loggedIn = await safeCheckLogin(page);
|
|
255
316
|
console.error(
|
|
256
317
|
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
package/src/cli/refresh.js
CHANGED
|
@@ -31,6 +31,7 @@ import path from "path";
|
|
|
31
31
|
import os from "os";
|
|
32
32
|
|
|
33
33
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
34
|
+
const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
|
|
34
35
|
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
35
36
|
const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
|
|
36
37
|
|
|
@@ -44,7 +45,7 @@ async function withRetry(label, fn) {
|
|
|
44
45
|
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
45
46
|
);
|
|
46
47
|
await new Promise((r) => setTimeout(r, backoff));
|
|
47
|
-
|
|
48
|
+
backoff = Math.min(backoff * 2, MAX_RETRY_WAIT);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -228,9 +229,14 @@ export async function handleRefresh(options) {
|
|
|
228
229
|
const oldAccount = currentAccount;
|
|
229
230
|
const nextAccount = healthChecker.getNextAccount();
|
|
230
231
|
currentAccount = nextAccount;
|
|
232
|
+
// 关键:把当前代理传给 switchAccount。
|
|
233
|
+
// 否则新启动的浏览器不带代理直连 TikTok,连接会被重置(ERR_CONNECTION_RESET),
|
|
234
|
+
// 之后无论怎么重试 goto 都过不去,程序卡死无法恢复。
|
|
235
|
+
const effectiveProxy = cdpOptions.proxyServer || resolvedProxy;
|
|
231
236
|
const newBrowser = await switchAccount(
|
|
232
237
|
{ port: oldAccount.port, userDataDir: oldAccount.userDataDir },
|
|
233
238
|
{ port: nextAccount.port, userDataDir: nextAccount.userDataDir },
|
|
239
|
+
effectiveProxy,
|
|
234
240
|
);
|
|
235
241
|
browser = newBrowser;
|
|
236
242
|
const newPage = await setupNewPage(browser);
|
|
@@ -238,17 +244,79 @@ export async function handleRefresh(options) {
|
|
|
238
244
|
Object.assign(cdpOptions, {
|
|
239
245
|
port: nextAccount.port,
|
|
240
246
|
userDataDir: nextAccount.userDataDir,
|
|
247
|
+
...(effectiveProxy ? { proxyServer: effectiveProxy } : {}),
|
|
241
248
|
});
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
console.error(
|
|
250
|
+
`[健康检查] 已切换到端口 ${nextAccount.port}${effectiveProxy ? ", 代理: " + effectiveProxy : ""}`,
|
|
251
|
+
);
|
|
252
|
+
// 账户切换后重新导航:退避重试 + 持续失败时重启浏览器重建 page。
|
|
253
|
+
// 重启超过 MAX_REBUILD_ATTEMPTS 次仍失败 → 上报并退出,避免僵尸进程。
|
|
254
|
+
let navFailures = 0;
|
|
255
|
+
let rebuildCount = 0;
|
|
256
|
+
let backoff = 1000;
|
|
257
|
+
while (true) {
|
|
258
|
+
try {
|
|
259
|
+
await page.goto(STARTUP_TIKTOK_URL, {
|
|
260
|
+
waitUntil: "domcontentloaded",
|
|
261
|
+
timeout: PAGE_GOTO_TIMEOUT,
|
|
262
|
+
});
|
|
263
|
+
rebuildCount = 0;
|
|
264
|
+
break;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
navFailures++;
|
|
267
|
+
console.error(
|
|
268
|
+
`[连接] 账户切换后页面导航 失败 (${navFailures}): ${err.message},${Math.round(backoff / 1000)}秒后重试...`,
|
|
269
|
+
);
|
|
270
|
+
if (isBrowserClosedError(err) || navFailures >= 3) {
|
|
271
|
+
rebuildCount++;
|
|
272
|
+
if (rebuildCount > MAX_REBUILD_ATTEMPTS) {
|
|
273
|
+
// 恢复不了 → 上报并退出,避免无限重试占用进程
|
|
274
|
+
console.error(
|
|
275
|
+
`[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
|
|
276
|
+
);
|
|
277
|
+
await apiPost(`${serverUrl}/api/error-report`, {
|
|
278
|
+
userId,
|
|
279
|
+
username: "account-switch",
|
|
280
|
+
errorType: "account_switch_failed",
|
|
281
|
+
errorMessage: `账户切换后导航持续失败: ${err.message}`,
|
|
282
|
+
stage: "account-switch",
|
|
283
|
+
errorStack: err.stack || "",
|
|
284
|
+
}).catch(() => {});
|
|
285
|
+
if (cdpOptions) {
|
|
286
|
+
await killEdgeProcesses(
|
|
287
|
+
cdpOptions.userDataDir,
|
|
288
|
+
cdpOptions.port,
|
|
289
|
+
).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
await browser?.close().catch(() => {});
|
|
292
|
+
console.error("[Refresh] 已退出(账户切换无法恢复)");
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
// 浏览器已关闭或连续 3 次导航失败 → 重启浏览器 + 重建 page 后再试
|
|
296
|
+
console.error(
|
|
297
|
+
` [恢复] (${rebuildCount}/${MAX_REBUILD_ATTEMPTS}) 重启浏览器 (端口 ${cdpOptions.port || 9222})...`,
|
|
298
|
+
);
|
|
299
|
+
try {
|
|
300
|
+
const relaunched = await relaunchBrowser(
|
|
301
|
+
cdpOptions,
|
|
302
|
+
cdpOptions.port || 9222,
|
|
303
|
+
);
|
|
304
|
+
browser = relaunched;
|
|
305
|
+
const rebuiltPage = await setupNewPage(browser);
|
|
306
|
+
Object.assign(page, rebuiltPage);
|
|
307
|
+
navFailures = 0;
|
|
308
|
+
backoff = 1000;
|
|
309
|
+
continue;
|
|
310
|
+
} catch (rebuildErr) {
|
|
311
|
+
console.error(
|
|
312
|
+
` [恢复] 重启浏览器失败: ${rebuildErr.message},等待后继续重试...`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
317
|
+
backoff = Math.min(backoff * 2, MAX_RETRY_WAIT);
|
|
318
|
+
}
|
|
244
319
|
}
|
|
245
|
-
console.error(`[健康检查] 已切换到端口 ${nextAccount.port}`);
|
|
246
|
-
await withRetry("账户切换后页面导航", async () => {
|
|
247
|
-
await page.goto(STARTUP_TIKTOK_URL, {
|
|
248
|
-
waitUntil: "domcontentloaded",
|
|
249
|
-
timeout: PAGE_GOTO_TIMEOUT,
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
320
|
loggedIn = await isLoggedIn(page);
|
|
253
321
|
console.error(
|
|
254
322
|
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
package/src/cli/search.js
CHANGED
|
@@ -53,6 +53,7 @@ const DEFAULT_SERVER = cfgServer || "http://127.0.0.1:3000";
|
|
|
53
53
|
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
54
54
|
const PAGE_GOTO_TIMEOUT = 60000;
|
|
55
55
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
56
|
+
const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
|
|
56
57
|
|
|
57
58
|
async function withRetry(label, fn) {
|
|
58
59
|
let backoff = 1000;
|
|
@@ -64,7 +65,7 @@ async function withRetry(label, fn) {
|
|
|
64
65
|
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
65
66
|
);
|
|
66
67
|
await new Promise((r) => setTimeout(r, backoff));
|
|
67
|
-
|
|
68
|
+
backoff = Math.min(backoff * 2, MAX_RETRY_WAIT);
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
}
|
|
@@ -286,13 +287,73 @@ export async function handleSearch(parsed) {
|
|
|
286
287
|
log(
|
|
287
288
|
`[健康检查] 已切换到端口 ${nextAccount.port}${resolvedProxy ? ", 代理: " + resolvedProxy : ""}`,
|
|
288
289
|
);
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
290
|
+
// 重新导航并检测登录:退避重试 + 持续失败时重启浏览器重建 page。
|
|
291
|
+
// 重启超过 MAX_REBUILD_ATTEMPTS 次仍失败 → 上报并退出,避免僵尸进程。
|
|
292
|
+
let navFailures = 0;
|
|
293
|
+
let rebuildCount = 0;
|
|
294
|
+
let backoff = 1000;
|
|
295
|
+
while (true) {
|
|
296
|
+
try {
|
|
297
|
+
await page.goto(STARTUP_TIKTOK_URL, {
|
|
298
|
+
waitUntil: "domcontentloaded",
|
|
299
|
+
timeout: PAGE_GOTO_TIMEOUT,
|
|
300
|
+
});
|
|
301
|
+
rebuildCount = 0;
|
|
302
|
+
break;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
navFailures++;
|
|
305
|
+
log(
|
|
306
|
+
`[连接] 账户切换后页面导航 失败 (${navFailures}): ${err.message},${Math.round(backoff / 1000)}秒后重试...`,
|
|
307
|
+
);
|
|
308
|
+
if (isBrowserClosedError(err) || navFailures >= 3) {
|
|
309
|
+
rebuildCount++;
|
|
310
|
+
if (rebuildCount > MAX_REBUILD_ATTEMPTS) {
|
|
311
|
+
log(
|
|
312
|
+
`[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
|
|
313
|
+
);
|
|
314
|
+
await apiPost(`${baseUrl}/api/error-report`, {
|
|
315
|
+
userId,
|
|
316
|
+
username: "account-switch",
|
|
317
|
+
errorType: "account_switch_failed",
|
|
318
|
+
errorMessage: `账户切换后导航持续失败: ${err.message}`,
|
|
319
|
+
stage: "account-switch",
|
|
320
|
+
errorStack: err.stack || "",
|
|
321
|
+
}).catch(() => {});
|
|
322
|
+
if (cdpOptions) {
|
|
323
|
+
await killEdgeProcesses(
|
|
324
|
+
cdpOptions.userDataDir,
|
|
325
|
+
cdpOptions.port,
|
|
326
|
+
).catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
await browser?.close().catch(() => {});
|
|
329
|
+
log("[Search] 已退出(账户切换无法恢复)");
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
// 浏览器已关闭或连续 3 次导航失败 → 重启浏览器 + 重建 page 后再试
|
|
333
|
+
log(
|
|
334
|
+
` [恢复] (${rebuildCount}/${MAX_REBUILD_ATTEMPTS}) 重启浏览器 (端口 ${cdpOptions.port || 9222})...`,
|
|
335
|
+
);
|
|
336
|
+
try {
|
|
337
|
+
const relaunched = await relaunchBrowser(
|
|
338
|
+
cdpOptions,
|
|
339
|
+
cdpOptions.port || 9222,
|
|
340
|
+
);
|
|
341
|
+
browser = relaunched;
|
|
342
|
+
const rebuiltPage = await setupNewPage(browser);
|
|
343
|
+
page = rebuiltPage;
|
|
344
|
+
navFailures = 0;
|
|
345
|
+
backoff = 1000;
|
|
346
|
+
continue;
|
|
347
|
+
} catch (rebuildErr) {
|
|
348
|
+
log(
|
|
349
|
+
` [恢复] 重启浏览器失败: ${rebuildErr.message},等待后继续重试...`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
354
|
+
backoff = Math.min(backoff * 2, MAX_RETRY_WAIT);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
296
357
|
loggedIn = await safeCheckLogin(page);
|
|
297
358
|
log(`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`);
|
|
298
359
|
// 重新提取用户名
|