tt-help-cli-ycl 1.4.182 → 1.4.184
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 -1
- package/scripts/test-crash-circuit-breaker.mjs +250 -0
- package/scripts/test-error-auto-reset.mjs +103 -0
- package/scripts/test-error-report.mjs +215 -0
- package/scripts/test-login-pool-faceban-e2e.mjs +131 -0
- package/scripts/test-login-pool-faceban.mjs +235 -0
- package/scripts/test-process-manager-label.mjs +145 -0
- package/src/cli/explore.js +99 -17
- package/src/cli/search.js +106 -15
- package/src/lib/browser/health-checker.js +19 -3
- package/src/lib/browser/page.js +23 -0
- package/src/lib/error-report.js +119 -0
- package/src/lib/login-pool-client.js +94 -4
- package/src/watch/data-store.js +25 -0
- package/src/watch/login-pool.js +118 -81
- package/src/watch/public/app.js +7 -2
- package/src/watch/public/index.html +2 -1
- package/src/watch/server.js +53 -8
- package/src/watchdog/login-apply.js +12 -6
- package/src/watchdog/process-manager.js +71 -6
- package/src/watchdog/reporter.js +2 -0
package/src/cli/explore.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getOrCreatePage,
|
|
3
3
|
isBrowserClosedError,
|
|
4
|
+
isRetryableRestoreFailure,
|
|
4
5
|
relaunchBrowser,
|
|
5
6
|
} from "../lib/browser/page.js";
|
|
6
7
|
import {
|
|
@@ -28,7 +29,6 @@ import {
|
|
|
28
29
|
import { showResourceUsage } from "../utils/index.js";
|
|
29
30
|
import {
|
|
30
31
|
HealthChecker,
|
|
31
|
-
MEMORY_THRESHOLD,
|
|
32
32
|
ROTATE_INTERVAL_MS,
|
|
33
33
|
} from "../lib/browser/health-checker.js";
|
|
34
34
|
import {
|
|
@@ -37,8 +37,12 @@ import {
|
|
|
37
37
|
gotoTiktok,
|
|
38
38
|
injectPoolAccount,
|
|
39
39
|
ensurePoolLogin,
|
|
40
|
+
crashBackoffMs,
|
|
41
|
+
retryRestoreOnNavFailure,
|
|
40
42
|
} from "../lib/login-pool-client.js";
|
|
43
|
+
import { getClientConfig } from "../watchdog/reporter.js";
|
|
41
44
|
import { createApiClient } from "../lib/api-client.js";
|
|
45
|
+
import { createErrorReporter } from "../lib/error-report.js";
|
|
42
46
|
import { createCDNHandler } from "../lib/cdn-handler.js";
|
|
43
47
|
import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
|
|
44
48
|
import path from "path";
|
|
@@ -48,6 +52,7 @@ const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
|
48
52
|
const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
|
|
49
53
|
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
50
54
|
const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
|
|
55
|
+
const CRASH_BACKOFF_SLICE_MS = 1000; // 崩溃熔断/恢复重试等待的分片粒度(分片醒检 shuttingDown)
|
|
51
56
|
|
|
52
57
|
// 换号原因受控 code(2026-10-30 定稿:服务端 switchStats 按账号聚合展示;
|
|
53
58
|
// 只用这组固定值上报,不传自由文本;startup 首次取号不带 reason)
|
|
@@ -110,6 +115,21 @@ export async function handleExplore(options) {
|
|
|
110
115
|
let poolClient = null; // 账号中心客户端
|
|
111
116
|
let poolAccount = null; // 当前在手账号(含 cookies 与租约 id)
|
|
112
117
|
let poolAccountSince = Date.now(); // 当前账号开始使用时刻(48min 换号计时)
|
|
118
|
+
// 崩溃熔断状态(F16 实录):浏览器级注入失败轮数 + 退避窗口截止时刻(窗口内不取号)
|
|
119
|
+
let crashStreak = 0;
|
|
120
|
+
let crashBackoffUntil = 0;
|
|
121
|
+
|
|
122
|
+
/** 分片 sleep:每 CRASH_BACKOFF_SLICE_MS 醒检 shuttingDown,被中断返回 false */
|
|
123
|
+
const sleepSliced = async (totalMs) => {
|
|
124
|
+
let remaining = totalMs;
|
|
125
|
+
while (remaining > 0) {
|
|
126
|
+
if (shuttingDown) return false;
|
|
127
|
+
const slice = Math.min(CRASH_BACKOFF_SLICE_MS, remaining);
|
|
128
|
+
await new Promise((r) => setTimeout(r, slice));
|
|
129
|
+
remaining -= slice;
|
|
130
|
+
}
|
|
131
|
+
return !shuttingDown;
|
|
132
|
+
};
|
|
113
133
|
|
|
114
134
|
const shutdown = async (signal) => {
|
|
115
135
|
if (shuttingDown) return;
|
|
@@ -177,11 +197,14 @@ export async function handleExplore(options) {
|
|
|
177
197
|
|
|
178
198
|
const poolMode = !exploreNoPool;
|
|
179
199
|
const poolPort = explorePort || exploreBasePort || 9222;
|
|
200
|
+
// 内存阈值可配置覆盖(~/.tt-help.json memoryThreshold,默认 0.95):F16 类 7G 双浏览器机可调低提前重启
|
|
201
|
+
const memoryThresholdConfig = getClientConfig().memoryThreshold;
|
|
180
202
|
const healthChecker = poolMode
|
|
181
|
-
? new HealthChecker({ basePort: poolPort, totalAccounts: 1 })
|
|
203
|
+
? new HealthChecker({ basePort: poolPort, totalAccounts: 1, memoryThreshold: memoryThresholdConfig })
|
|
182
204
|
: new HealthChecker({
|
|
183
205
|
basePort: exploreBasePort,
|
|
184
206
|
totalAccounts: explorePortCount,
|
|
207
|
+
memoryThreshold: memoryThresholdConfig,
|
|
185
208
|
});
|
|
186
209
|
let currentAccount = healthChecker.getCurrentAccount();
|
|
187
210
|
|
|
@@ -246,6 +269,15 @@ export async function handleExplore(options) {
|
|
|
246
269
|
meta: { port: cdpOptions.port },
|
|
247
270
|
});
|
|
248
271
|
|
|
272
|
+
// 客户端错误上报封装:自动附带 poolUsername(报错时在手池号)与 proxyNode(当前 clash 节点)
|
|
273
|
+
const errorReporter = createErrorReporter({
|
|
274
|
+
apiPost,
|
|
275
|
+
serverUrl,
|
|
276
|
+
userId,
|
|
277
|
+
proxyUrl: resolvedProxy,
|
|
278
|
+
getState: () => ({ poolUsername: poolAccount?.username || "" }),
|
|
279
|
+
});
|
|
280
|
+
|
|
249
281
|
await apiGet(`${serverUrl}/api/stats`);
|
|
250
282
|
|
|
251
283
|
if (exploreUsernames && exploreUsernames.length > 0) {
|
|
@@ -363,8 +395,16 @@ export async function handleExplore(options) {
|
|
|
363
395
|
* @param code 受控换号原因(SWITCH_REASON,上报服务端 switchStats;可选);
|
|
364
396
|
* 首选按外层 call site 上报,候选注入失败后的再取号 = inject_fail
|
|
365
397
|
*/
|
|
366
|
-
async function switchPoolAccount(reason, code) {
|
|
398
|
+
async function switchPoolAccount(reason, code, opts = {}) {
|
|
367
399
|
console.error(`\n[换号] ${reason},从账号中心取新账号...`);
|
|
400
|
+
// 崩溃熔断:退避窗口内不取号(保护账号池),分片 sleep 响应 shuttingDown
|
|
401
|
+
const backoffWaitMs = Math.max(0, crashBackoffUntil - Date.now());
|
|
402
|
+
if (backoffWaitMs > 0) {
|
|
403
|
+
console.error(
|
|
404
|
+
`[浏览器] 崩溃熔断中,${Math.ceil(backoffWaitMs / 1000)}s 后再试(不取号,保护账号池)`,
|
|
405
|
+
);
|
|
406
|
+
if (!(await sleepSliced(backoffWaitMs))) return false;
|
|
407
|
+
}
|
|
368
408
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
369
409
|
let next = null;
|
|
370
410
|
try {
|
|
@@ -379,6 +419,31 @@ export async function handleExplore(options) {
|
|
|
379
419
|
verifyUsername: next.username,
|
|
380
420
|
}).catch((e) => ({ ok: false, reason: e.message }));
|
|
381
421
|
if (!r.ok) {
|
|
422
|
+
if (isBrowserClosedError(new Error(r.reason))) {
|
|
423
|
+
// 浏览器级崩溃:不怪账号,立即中止候选循环(不消耗 inject_fail/recover_fail 口径),
|
|
424
|
+
// 已取候选照常 release,转同号重启(与内存超限路径同语义);按轮熔断退避
|
|
425
|
+
crashStreak++;
|
|
426
|
+
const backoffMs = crashBackoffMs(crashStreak);
|
|
427
|
+
crashBackoffUntil = Date.now() + backoffMs;
|
|
428
|
+
console.error(
|
|
429
|
+
`[换号] @${next.username} 注入失败为浏览器级崩溃(${r.reason}),中止候选循环(第 ${crashStreak} 轮,熔断 ${Math.round(backoffMs / 1000)}s),释放候选...`,
|
|
430
|
+
);
|
|
431
|
+
await poolClient.release(next).catch(() => {});
|
|
432
|
+
await errorReporter.report({
|
|
433
|
+
username: poolAccount ? poolAccount.username : "unknown",
|
|
434
|
+
errorType: "account_switch_failed",
|
|
435
|
+
errorMessage: `注入失败为浏览器级崩溃,中止候选循环转同号重启(第${crashStreak}轮,熔断${Math.round(backoffMs / 1000)}s): ${r.reason}`,
|
|
436
|
+
stage: "account-switch",
|
|
437
|
+
}).catch(() => {});
|
|
438
|
+
if (opts.fromRecovery) {
|
|
439
|
+
// 恢复链中(重启后恢复失败触发的换号)不再递归重启,保留现场等熔断窗口
|
|
440
|
+
console.error(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
return await restartPoolBrowser(
|
|
444
|
+
`注入验证遭遇浏览器级崩溃(${r.reason})`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
382
447
|
console.error(
|
|
383
448
|
`[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
|
|
384
449
|
);
|
|
@@ -391,6 +456,7 @@ export async function handleExplore(options) {
|
|
|
391
456
|
poolAccountSince = Date.now();
|
|
392
457
|
loggedIn = true;
|
|
393
458
|
lastFollowSuccessTime = Date.now();
|
|
459
|
+
crashStreak = 0; // 注入验证成功即清零熔断轮数
|
|
394
460
|
captchaCount = 0;
|
|
395
461
|
blockedCount = 0;
|
|
396
462
|
cdn.reset();
|
|
@@ -407,8 +473,7 @@ export async function handleExplore(options) {
|
|
|
407
473
|
return true;
|
|
408
474
|
}
|
|
409
475
|
console.error(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
|
|
410
|
-
await
|
|
411
|
-
userId,
|
|
476
|
+
await errorReporter.report({
|
|
412
477
|
username: poolAccount ? poolAccount.username : "unknown",
|
|
413
478
|
errorType: "account_switch_failed",
|
|
414
479
|
errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
|
|
@@ -420,21 +485,41 @@ export async function handleExplore(options) {
|
|
|
420
485
|
/** 浏览器重建/重启后恢复登录态:磁盘态一致则免注入,否则用内存 cookies 补注入 */
|
|
421
486
|
async function restorePoolLogin() {
|
|
422
487
|
if (!poolAccount) return false;
|
|
423
|
-
|
|
488
|
+
let r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
|
|
424
489
|
ok: false,
|
|
425
490
|
reason: e.message,
|
|
426
491
|
}));
|
|
492
|
+
if (!r.ok) {
|
|
493
|
+
// 导航类瞬态失败(net::ERR_* / 超时 / 崩溃)重试后再弃号,重试期间不弃号;
|
|
494
|
+
// 账号级失败(身份不符/会话过期)不重试直接走换号
|
|
495
|
+
r = await retryRestoreOnNavFailure(
|
|
496
|
+
() => ensurePoolLogin(page, poolAccount),
|
|
497
|
+
r.reason,
|
|
498
|
+
{
|
|
499
|
+
isRetryable: isRetryableRestoreFailure,
|
|
500
|
+
sleep: (ms) => sleepSliced(ms),
|
|
501
|
+
shouldAbort: () => shuttingDown,
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
if (r.aborted) return false;
|
|
505
|
+
if (!r.ok && r.attempts > 1) {
|
|
506
|
+
console.error(`[登录池] 登录态恢复重试 ${r.attempts} 次后仍失败`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
427
509
|
if (r.ok) {
|
|
428
510
|
loggedIn = true;
|
|
511
|
+
crashStreak = 0; // 恢复成功(含注入验证成功)即清零熔断轮数
|
|
429
512
|
console.error(
|
|
430
513
|
`[登录池] 登录态已恢复 @${poolAccount.username}${r.injected ? "(重新注入)" : "(磁盘态一致)"}`,
|
|
431
514
|
);
|
|
432
515
|
return true;
|
|
433
516
|
}
|
|
434
517
|
console.error(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
|
|
518
|
+
// 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
|
|
435
519
|
return await switchPoolAccount(
|
|
436
520
|
`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
|
|
437
521
|
SWITCH_REASON.RECOVER_FAIL,
|
|
522
|
+
{ fromRecovery: true },
|
|
438
523
|
);
|
|
439
524
|
}
|
|
440
525
|
|
|
@@ -450,9 +535,11 @@ export async function handleExplore(options) {
|
|
|
450
535
|
Object.assign(page, rebuiltPage);
|
|
451
536
|
} catch (e) {
|
|
452
537
|
console.error(`[浏览器] 重启失败: ${e.message},转入换号流程`);
|
|
538
|
+
// 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
|
|
453
539
|
return await switchPoolAccount(
|
|
454
540
|
`浏览器重启失败(${e.message})`,
|
|
455
541
|
SWITCH_REASON.RECOVER_FAIL,
|
|
542
|
+
{ fromRecovery: true },
|
|
456
543
|
);
|
|
457
544
|
}
|
|
458
545
|
return await restorePoolLogin();
|
|
@@ -508,8 +595,7 @@ export async function handleExplore(options) {
|
|
|
508
595
|
console.error(
|
|
509
596
|
`[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
|
|
510
597
|
);
|
|
511
|
-
await
|
|
512
|
-
userId,
|
|
598
|
+
await errorReporter.report({
|
|
513
599
|
username: "account-switch",
|
|
514
600
|
errorType: "account_switch_failed",
|
|
515
601
|
errorMessage: `账户切换后导航持续失败: ${err.message}`,
|
|
@@ -570,8 +656,7 @@ export async function handleExplore(options) {
|
|
|
570
656
|
}),
|
|
571
657
|
reportError: (result, username) =>
|
|
572
658
|
withRetry("report cdn-blocked", () =>
|
|
573
|
-
|
|
574
|
-
userId,
|
|
659
|
+
errorReporter.report({
|
|
575
660
|
username,
|
|
576
661
|
errorType: "cdn_blocked",
|
|
577
662
|
errorMessage: result.error || "CDN限流 (Access Denied)",
|
|
@@ -666,7 +751,7 @@ export async function handleExplore(options) {
|
|
|
666
751
|
// 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号,不换端口)
|
|
667
752
|
const memRatio = healthChecker.memoryUsage();
|
|
668
753
|
const elapsedMs = Date.now() - poolAccountSince;
|
|
669
|
-
if (memRatio >=
|
|
754
|
+
if (memRatio >= healthChecker.memoryThreshold) {
|
|
670
755
|
await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
|
|
671
756
|
} else if (elapsedMs >= ROTATE_INTERVAL_MS) {
|
|
672
757
|
await handleAccountSwitch(
|
|
@@ -716,8 +801,7 @@ export async function handleExplore(options) {
|
|
|
716
801
|
|
|
717
802
|
// 上报
|
|
718
803
|
await withRetry("report captcha", () =>
|
|
719
|
-
|
|
720
|
-
userId,
|
|
804
|
+
errorReporter.report({
|
|
721
805
|
username: "unknown",
|
|
722
806
|
errorType: "captcha",
|
|
723
807
|
errorMessage: resolved
|
|
@@ -920,8 +1004,7 @@ export async function handleExplore(options) {
|
|
|
920
1004
|
? "network"
|
|
921
1005
|
: "other";
|
|
922
1006
|
await withRetry("report error", () =>
|
|
923
|
-
|
|
924
|
-
userId,
|
|
1007
|
+
errorReporter.report({
|
|
925
1008
|
username,
|
|
926
1009
|
errorType,
|
|
927
1010
|
errorMessage: result.error,
|
|
@@ -956,8 +1039,7 @@ export async function handleExplore(options) {
|
|
|
956
1039
|
console.error(` [验证码] 累计 ${captchaCount} 次`);
|
|
957
1040
|
|
|
958
1041
|
await withRetry("report captcha", () =>
|
|
959
|
-
|
|
960
|
-
userId,
|
|
1042
|
+
errorReporter.report({
|
|
961
1043
|
username,
|
|
962
1044
|
errorType: "captcha",
|
|
963
1045
|
errorMessage: result.captchaMessage || "页面出现验证码",
|
package/src/cli/search.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getOrCreatePage,
|
|
27
27
|
safeCheckLogin,
|
|
28
28
|
isBrowserClosedError,
|
|
29
|
+
isRetryableRestoreFailure,
|
|
29
30
|
relaunchBrowser,
|
|
30
31
|
} from "../lib/browser/page.js";
|
|
31
32
|
import { collectSearchAuthors } from "../lib/search-collector.js";
|
|
@@ -39,16 +40,19 @@ import {
|
|
|
39
40
|
closeCaptcha,
|
|
40
41
|
} from "../scraper/modules/captcha-handler.js";
|
|
41
42
|
import { createApiClient } from "../lib/api-client.js";
|
|
43
|
+
import { createErrorReporter } from "../lib/error-report.js";
|
|
42
44
|
import {
|
|
43
45
|
HealthChecker,
|
|
44
|
-
MEMORY_THRESHOLD,
|
|
45
46
|
ROTATE_INTERVAL_MS,
|
|
46
47
|
} from "../lib/browser/health-checker.js";
|
|
47
48
|
import {
|
|
48
49
|
createLoginPoolClient,
|
|
49
50
|
injectPoolAccount,
|
|
50
51
|
ensurePoolLogin,
|
|
52
|
+
crashBackoffMs,
|
|
53
|
+
retryRestoreOnNavFailure,
|
|
51
54
|
} from "../lib/login-pool-client.js";
|
|
55
|
+
import { getClientConfig } from "../watchdog/reporter.js";
|
|
52
56
|
import {
|
|
53
57
|
server as cfgServer,
|
|
54
58
|
proxy as configuredProxy,
|
|
@@ -62,6 +66,7 @@ const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
|
62
66
|
const PAGE_GOTO_TIMEOUT = 60000;
|
|
63
67
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
64
68
|
const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏览器超过该次数仍失败则上报并退出
|
|
69
|
+
const CRASH_BACKOFF_SLICE_MS = 1000; // 崩溃熔断/恢复重试等待的分片粒度(分片醒检 shuttingDown)
|
|
65
70
|
|
|
66
71
|
async function withRetry(label, fn) {
|
|
67
72
|
let backoff = 1000;
|
|
@@ -115,11 +120,14 @@ export async function handleSearch(parsed) {
|
|
|
115
120
|
// ── 初始化 ──
|
|
116
121
|
const baseUrl = serverUrl || DEFAULT_SERVER;
|
|
117
122
|
const poolMode = !searchNoPool;
|
|
123
|
+
// 内存阈值可配置覆盖(~/.tt-help.json memoryThreshold,默认 0.95):F16 类 7G 双浏览器机可调低提前重启
|
|
124
|
+
const memoryThresholdConfig = getClientConfig().memoryThreshold;
|
|
118
125
|
const healthChecker = poolMode
|
|
119
|
-
? new HealthChecker({ basePort, totalAccounts: 1 })
|
|
126
|
+
? new HealthChecker({ basePort, totalAccounts: 1, memoryThreshold: memoryThresholdConfig })
|
|
120
127
|
: new HealthChecker({
|
|
121
128
|
basePort,
|
|
122
129
|
totalAccounts: portCount,
|
|
130
|
+
memoryThreshold: memoryThresholdConfig,
|
|
123
131
|
});
|
|
124
132
|
let currentAccount = healthChecker.getCurrentAccount();
|
|
125
133
|
|
|
@@ -136,6 +144,21 @@ export async function handleSearch(parsed) {
|
|
|
136
144
|
let poolClient = null;
|
|
137
145
|
let poolAccount = null;
|
|
138
146
|
let poolAccountSince = Date.now();
|
|
147
|
+
// 崩溃熔断状态(F16 实录):浏览器级注入失败轮数 + 退避窗口截止时刻(窗口内不取号)
|
|
148
|
+
let crashStreak = 0;
|
|
149
|
+
let crashBackoffUntil = 0;
|
|
150
|
+
|
|
151
|
+
/** 分片 sleep:每 CRASH_BACKOFF_SLICE_MS 醒检 shuttingDown,被中断返回 false */
|
|
152
|
+
const sleepSliced = async (totalMs) => {
|
|
153
|
+
let remaining = totalMs;
|
|
154
|
+
while (remaining > 0) {
|
|
155
|
+
if (shuttingDown) return false;
|
|
156
|
+
const slice = Math.min(CRASH_BACKOFF_SLICE_MS, remaining);
|
|
157
|
+
await new Promise((r) => setTimeout(r, slice));
|
|
158
|
+
remaining -= slice;
|
|
159
|
+
}
|
|
160
|
+
return !shuttingDown;
|
|
161
|
+
};
|
|
139
162
|
|
|
140
163
|
const cleanup = () => {
|
|
141
164
|
if (shuttingDown) return;
|
|
@@ -167,6 +190,15 @@ export async function handleSearch(parsed) {
|
|
|
167
190
|
}
|
|
168
191
|
log(`[Search] 用户编号: ${userId}`);
|
|
169
192
|
|
|
193
|
+
// 客户端错误上报封装:自动附带 poolUsername(报错时在手池号)与 proxyNode(当前 clash 节点)
|
|
194
|
+
const errorReporter = createErrorReporter({
|
|
195
|
+
apiPost,
|
|
196
|
+
serverUrl: baseUrl,
|
|
197
|
+
userId,
|
|
198
|
+
proxyUrl: resolvedProxy,
|
|
199
|
+
getState: () => ({ poolUsername: poolAccount?.username || "" }),
|
|
200
|
+
});
|
|
201
|
+
|
|
170
202
|
setDelayConfig("normal");
|
|
171
203
|
|
|
172
204
|
try {
|
|
@@ -378,8 +410,7 @@ export async function handleSearch(parsed) {
|
|
|
378
410
|
log(
|
|
379
411
|
`[致命] 账户切换后导航持续失败,已重启浏览器 ${rebuildCount - 1} 次仍无法恢复,上报后退出`,
|
|
380
412
|
);
|
|
381
|
-
await
|
|
382
|
-
userId,
|
|
413
|
+
await errorReporter.report({
|
|
383
414
|
username: "account-switch",
|
|
384
415
|
errorType: "account_switch_failed",
|
|
385
416
|
errorMessage: `账户切换后导航持续失败: ${err.message}`,
|
|
@@ -435,8 +466,16 @@ export async function handleSearch(parsed) {
|
|
|
435
466
|
|
|
436
467
|
// ── 登录池模式辅助 ──
|
|
437
468
|
|
|
438
|
-
async function switchPoolAccount(reason) {
|
|
469
|
+
async function switchPoolAccount(reason, opts = {}) {
|
|
439
470
|
log(`\n[换号] ${reason},从账号中心取新账号...`);
|
|
471
|
+
// 崩溃熔断:退避窗口内不取号(保护账号池),分片 sleep 响应 shuttingDown
|
|
472
|
+
const backoffWaitMs = Math.max(0, crashBackoffUntil - Date.now());
|
|
473
|
+
if (backoffWaitMs > 0) {
|
|
474
|
+
log(
|
|
475
|
+
`[浏览器] 崩溃熔断中,${Math.ceil(backoffWaitMs / 1000)}s 后再试(不取号,保护账号池)`,
|
|
476
|
+
);
|
|
477
|
+
if (!(await sleepSliced(backoffWaitMs))) return false;
|
|
478
|
+
}
|
|
440
479
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
441
480
|
let next = null;
|
|
442
481
|
try {
|
|
@@ -451,7 +490,34 @@ export async function handleSearch(parsed) {
|
|
|
451
490
|
verifyUsername: next.username,
|
|
452
491
|
}).catch((e) => ({ ok: false, reason: e.message }));
|
|
453
492
|
if (!r.ok) {
|
|
454
|
-
|
|
493
|
+
if (isBrowserClosedError(new Error(r.reason))) {
|
|
494
|
+
// 浏览器级崩溃:不怪账号,立即中止候选循环(不消耗 inject_fail 口径),
|
|
495
|
+
// 已取候选照常 release,转同号重启(与内存超限路径同语义);按轮熔断退避
|
|
496
|
+
crashStreak++;
|
|
497
|
+
const backoffMs = crashBackoffMs(crashStreak);
|
|
498
|
+
crashBackoffUntil = Date.now() + backoffMs;
|
|
499
|
+
log(
|
|
500
|
+
`[换号] @${next.username} 注入失败为浏览器级崩溃(${r.reason}),中止候选循环(第 ${crashStreak} 轮,熔断 ${Math.round(backoffMs / 1000)}s),释放候选...`,
|
|
501
|
+
);
|
|
502
|
+
await poolClient.release(next).catch(() => {});
|
|
503
|
+
await errorReporter.report({
|
|
504
|
+
username: poolAccount ? poolAccount.username : "unknown",
|
|
505
|
+
errorType: "account_switch_failed",
|
|
506
|
+
errorMessage: `注入失败为浏览器级崩溃,中止候选循环转同号重启(第${crashStreak}轮,熔断${Math.round(backoffMs / 1000)}s): ${r.reason}`,
|
|
507
|
+
stage: "account-switch",
|
|
508
|
+
}).catch(() => {});
|
|
509
|
+
if (opts.fromRecovery) {
|
|
510
|
+
// 恢复链中(重启后恢复失败触发的换号)不再递归重启,保留现场等熔断窗口
|
|
511
|
+
log(`[换号] 恢复链中不再递归重启浏览器,保留现场等待熔断窗口`);
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
return await restartPoolBrowser(
|
|
515
|
+
`注入验证遭遇浏览器级崩溃(${r.reason})`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
log(
|
|
519
|
+
`[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
|
|
520
|
+
);
|
|
455
521
|
await poolClient.release(next).catch(() => {});
|
|
456
522
|
continue;
|
|
457
523
|
}
|
|
@@ -459,6 +525,7 @@ export async function handleSearch(parsed) {
|
|
|
459
525
|
poolAccount = next;
|
|
460
526
|
poolAccountSince = Date.now();
|
|
461
527
|
loggedIn = true;
|
|
528
|
+
crashStreak = 0; // 注入验证成功即清零熔断轮数
|
|
462
529
|
captchaCount = 0;
|
|
463
530
|
staleRounds = 0;
|
|
464
531
|
consecutiveNetworkErrors = 0;
|
|
@@ -472,8 +539,7 @@ export async function handleSearch(parsed) {
|
|
|
472
539
|
return true;
|
|
473
540
|
}
|
|
474
541
|
log(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
|
|
475
|
-
await
|
|
476
|
-
userId,
|
|
542
|
+
await errorReporter.report({
|
|
477
543
|
username: poolAccount ? poolAccount.username : "unknown",
|
|
478
544
|
errorType: "account_switch_failed",
|
|
479
545
|
errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
|
|
@@ -485,17 +551,39 @@ export async function handleSearch(parsed) {
|
|
|
485
551
|
/** 浏览器重建后恢复在手账号:磁盘态一致则免注入,否则用内存 cookies 补注入 */
|
|
486
552
|
async function restorePoolLogin() {
|
|
487
553
|
if (!poolAccount) return false;
|
|
488
|
-
|
|
554
|
+
let r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
|
|
489
555
|
ok: false,
|
|
490
556
|
reason: e.message,
|
|
491
557
|
}));
|
|
558
|
+
if (!r.ok) {
|
|
559
|
+
// 导航类瞬态失败(net::ERR_* / 超时 / 崩溃)重试后再弃号,重试期间不弃号;
|
|
560
|
+
// 账号级失败(身份不符/会话过期)不重试直接走换号
|
|
561
|
+
r = await retryRestoreOnNavFailure(
|
|
562
|
+
() => ensurePoolLogin(page, poolAccount),
|
|
563
|
+
r.reason,
|
|
564
|
+
{
|
|
565
|
+
isRetryable: isRetryableRestoreFailure,
|
|
566
|
+
sleep: (ms) => sleepSliced(ms),
|
|
567
|
+
shouldAbort: () => shuttingDown,
|
|
568
|
+
},
|
|
569
|
+
);
|
|
570
|
+
if (r.aborted) return false;
|
|
571
|
+
if (!r.ok && r.attempts > 1) {
|
|
572
|
+
log(`[登录池] 登录态恢复重试 ${r.attempts} 次后仍失败`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
492
575
|
if (r.ok) {
|
|
493
576
|
loggedIn = true;
|
|
577
|
+
crashStreak = 0; // 恢复成功(含注入验证成功)即清零熔断轮数
|
|
494
578
|
log(`[登录池] 登录态已恢复 @${poolAccount.username}`);
|
|
495
579
|
return true;
|
|
496
580
|
}
|
|
497
581
|
log(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
|
|
498
|
-
|
|
582
|
+
// 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
|
|
583
|
+
return await switchPoolAccount(
|
|
584
|
+
`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
|
|
585
|
+
{ fromRecovery: true },
|
|
586
|
+
);
|
|
499
587
|
}
|
|
500
588
|
|
|
501
589
|
/** 内存超限:同号重启浏览器(不换号——内存是浏览器进程问题,不是账号问题) */
|
|
@@ -509,7 +597,10 @@ export async function handleSearch(parsed) {
|
|
|
509
597
|
page = await setupNewPage(browser);
|
|
510
598
|
} catch (e) {
|
|
511
599
|
log(`[浏览器] 重启失败: ${e.message},转入换号流程`);
|
|
512
|
-
|
|
600
|
+
// 恢复链中的换号不再嵌套浏览器重启(fromRecovery):崩溃则熔断退避,不烧池
|
|
601
|
+
return await switchPoolAccount(`浏览器重启失败(${e.message})`, {
|
|
602
|
+
fromRecovery: true,
|
|
603
|
+
});
|
|
513
604
|
}
|
|
514
605
|
return await restorePoolLogin();
|
|
515
606
|
}
|
|
@@ -540,7 +631,7 @@ export async function handleSearch(parsed) {
|
|
|
540
631
|
// 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号)
|
|
541
632
|
const memRatio = healthChecker.memoryUsage();
|
|
542
633
|
const elapsedMs = Date.now() - poolAccountSince;
|
|
543
|
-
if (memRatio >=
|
|
634
|
+
if (memRatio >= healthChecker.memoryThreshold) {
|
|
544
635
|
await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
|
|
545
636
|
} else if (elapsedMs >= ROTATE_INTERVAL_MS) {
|
|
546
637
|
await handleAccountSwitch(
|
|
@@ -582,7 +673,7 @@ export async function handleSearch(parsed) {
|
|
|
582
673
|
|
|
583
674
|
// 上报
|
|
584
675
|
await withRetry("report captcha", () =>
|
|
585
|
-
|
|
676
|
+
errorReporter.report({
|
|
586
677
|
userId: clientId,
|
|
587
678
|
username: "unknown",
|
|
588
679
|
errorType: "captcha",
|
|
@@ -739,7 +830,7 @@ export async function handleSearch(parsed) {
|
|
|
739
830
|
|
|
740
831
|
const errorType = consecutiveNetworkErrors > 1 ? "network" : "other";
|
|
741
832
|
await withRetry("report error", () =>
|
|
742
|
-
|
|
833
|
+
errorReporter.report({
|
|
743
834
|
userId: clientId,
|
|
744
835
|
username: tag,
|
|
745
836
|
errorType,
|
|
@@ -772,7 +863,7 @@ export async function handleSearch(parsed) {
|
|
|
772
863
|
log(` [验证码] 搜索后检测到验证码,累计 ${captchaCount} 次`);
|
|
773
864
|
|
|
774
865
|
await withRetry("report captcha", () =>
|
|
775
|
-
|
|
866
|
+
errorReporter.report({
|
|
776
867
|
userId: clientId,
|
|
777
868
|
username: tag,
|
|
778
869
|
errorType: "captcha",
|
|
@@ -5,12 +5,28 @@ import { execSync } from "child_process";
|
|
|
5
5
|
const DEFAULT_BASE_PORT = 9222;
|
|
6
6
|
const DEFAULT_TOTAL_ACCOUNTS = 10;
|
|
7
7
|
const MEMORY_THRESHOLD = 0.95;
|
|
8
|
+
// 配置覆盖(~/.tt-help.json memoryThreshold)钳制边界:过低误触发频繁重启,过高失去保护意义
|
|
9
|
+
const MEMORY_THRESHOLD_MIN = 0.5;
|
|
10
|
+
const MEMORY_THRESHOLD_MAX = 0.98;
|
|
8
11
|
const ROTATE_INTERVAL_MS = 0.8 * 60 * 60 * 1000;
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 内存阈值解析:缺省/非法(null/undefined/空串/非数值)回退默认 0.95,
|
|
15
|
+
* 合法值钳制 [0.5, 0.98]。纯函数,供 HealthChecker 构造与单测使用。
|
|
16
|
+
*/
|
|
17
|
+
function resolveMemoryThreshold(raw) {
|
|
18
|
+
if (raw === null || raw === undefined || raw === "") return MEMORY_THRESHOLD;
|
|
19
|
+
const n = Number(raw);
|
|
20
|
+
if (!Number.isFinite(n)) return MEMORY_THRESHOLD;
|
|
21
|
+
return Math.min(MEMORY_THRESHOLD_MAX, Math.max(MEMORY_THRESHOLD_MIN, n));
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
class HealthChecker {
|
|
11
25
|
constructor(options = {}) {
|
|
12
26
|
const basePort = options.basePort ?? DEFAULT_BASE_PORT;
|
|
13
27
|
const totalAccounts = options.totalAccounts ?? DEFAULT_TOTAL_ACCOUNTS;
|
|
28
|
+
// 内存阈值可配置覆盖(默认 0.95):F16 类 7G 双浏览器机可调低提前重启(explore/search 构造时传入)
|
|
29
|
+
this.memoryThreshold = resolveMemoryThreshold(options.memoryThreshold);
|
|
14
30
|
|
|
15
31
|
this.accounts = [];
|
|
16
32
|
for (let i = 0; i < totalAccounts; i++) {
|
|
@@ -83,7 +99,7 @@ class HealthChecker {
|
|
|
83
99
|
const memUsage = this.memoryUsage();
|
|
84
100
|
const memPercent = (memUsage * 100).toFixed(1);
|
|
85
101
|
|
|
86
|
-
if (memUsage >=
|
|
102
|
+
if (memUsage >= this.memoryThreshold) {
|
|
87
103
|
const currentPort = this.accounts[this.currentIndex].port;
|
|
88
104
|
return {
|
|
89
105
|
shouldSwitch: true,
|
|
@@ -104,7 +120,7 @@ class HealthChecker {
|
|
|
104
120
|
// 计算预计切换时间
|
|
105
121
|
const remainingMs = ROTATE_INTERVAL_MS - elapsed;
|
|
106
122
|
const remainingMins = Math.round(remainingMs / 60000);
|
|
107
|
-
const memHeadroom = ((
|
|
123
|
+
const memHeadroom = ((this.memoryThreshold - memUsage) * 100).toFixed(1);
|
|
108
124
|
const currentPort = this.accounts[this.currentIndex].port;
|
|
109
125
|
|
|
110
126
|
return {
|
|
@@ -114,4 +130,4 @@ class HealthChecker {
|
|
|
114
130
|
}
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
export { HealthChecker, MEMORY_THRESHOLD, ROTATE_INTERVAL_MS };
|
|
133
|
+
export { HealthChecker, MEMORY_THRESHOLD, MEMORY_THRESHOLD_MIN, MEMORY_THRESHOLD_MAX, resolveMemoryThreshold, ROTATE_INTERVAL_MS };
|
package/src/lib/browser/page.js
CHANGED
|
@@ -15,12 +15,20 @@ const DEFAULT_USER_DATA_DIR = path.join(
|
|
|
15
15
|
const BROWSER_CLOSED_PATTERNS = [
|
|
16
16
|
"Target page, context or browser has been closed",
|
|
17
17
|
"Target closed",
|
|
18
|
+
"Target crashed",
|
|
19
|
+
"Page crashed",
|
|
18
20
|
"Browser has been closed",
|
|
19
21
|
"Protocol error",
|
|
20
22
|
"Connection closed",
|
|
21
23
|
"net::ERR_CONNECTION",
|
|
22
24
|
];
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 判定浏览器级故障:浏览器关闭**或渲染进程崩溃**。
|
|
28
|
+
* 渲染崩溃(Target/Target crashed / Page crashed)发生时 CDP 连接往往仍存活,
|
|
29
|
+
* 但页面已死,须与连接断开同语义处理:重建浏览器 + 重试。
|
|
30
|
+
* (F16 实录:崩溃曾被误判为「账号注入失败」驱动换号循环,单日烧 28 个账号)
|
|
31
|
+
*/
|
|
24
32
|
export function isBrowserClosedError(error) {
|
|
25
33
|
if (!error) return false;
|
|
26
34
|
const msg = error.message || error.toString() || "";
|
|
@@ -71,6 +79,21 @@ const DOM_CHECK_RETRIES = 3; // DOM 检测最大重试次数
|
|
|
71
79
|
const SAFE_CHECK_ROUNDS = 2; // 安全登录检测轮数(首次检测 + 额外1轮确认)
|
|
72
80
|
const SAFE_CHECK_INTERVAL = 5000; // 安全登录检测每轮间隔 5 秒
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* 登录态恢复失败是否属导航类瞬态失败(可重试,重试期间不弃号):
|
|
84
|
+
* - net::ERR_*(含 ERR_HTTP_RESPONSE_CODE_FAILURE / ERR_CONNECTION_* 等导航层错误)
|
|
85
|
+
* - 超时(Timeout ... exceeded / timed out)
|
|
86
|
+
* - 浏览器级关闭或渲染崩溃(isBrowserClosedError)
|
|
87
|
+
* 账号级失败(身份不符/会话过期/无账号数据等)不可重试,直接走换号流程。
|
|
88
|
+
*/
|
|
89
|
+
export function isRetryableRestoreFailure(reason) {
|
|
90
|
+
if (!reason) return false;
|
|
91
|
+
const msg = String(reason);
|
|
92
|
+
if (msg.includes("net::ERR_")) return true;
|
|
93
|
+
if (/timed?\s*out|timeout/i.test(msg)) return true;
|
|
94
|
+
return isBrowserClosedError(new Error(msg));
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* 判断登录状态:Cookie 为主,DOM 验真为辅。
|
|
76
99
|
* - 无 sessionid Cookie → 未登录
|