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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端错误上报封装(方案甲:客户端报错自带字段)
|
|
3
|
+
*
|
|
4
|
+
* 背景:/api/error-report 的旧 username 字段语义混乱(在手池号/"account-switch"
|
|
5
|
+
* 占位/被探索目标/"unknown" 四种混传),且代理节点完全没报。本封装统一附加:
|
|
6
|
+
* - poolUsername:报错时在手池号(getState 注入,无在手号空串,不传 "unknown")
|
|
7
|
+
* - proxyNode:当前 clash 节点名(三级 fallback:端口匹配通道 → 主通道 → 空串)
|
|
8
|
+
* 旧 username 字段原样透传(兼容存量页面显示),语义不动。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* const errorReporter = createErrorReporter({
|
|
12
|
+
* apiPost, serverUrl, userId,
|
|
13
|
+
* proxyUrl: resolvedProxy, // 可选,缺省用 ~/.tt-help.json 的 proxy
|
|
14
|
+
* getState: () => ({ poolUsername: poolAccount?.username || "" }),
|
|
15
|
+
* });
|
|
16
|
+
* await errorReporter.report({ errorType, errorMessage, stage, errorStack, username });
|
|
17
|
+
*
|
|
18
|
+
* 单测:scripts/test-error-report.mjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { proxy as configuredProxy } from "./constants.js";
|
|
22
|
+
import {
|
|
23
|
+
getClashHealth,
|
|
24
|
+
getClashHealthChannels,
|
|
25
|
+
} from "../watchdog/reporter.js";
|
|
26
|
+
|
|
27
|
+
/** 从代理地址提取端口(http://127.0.0.1:7897 → 7897;解析不出返回 0) */
|
|
28
|
+
export function parseProxyPort(url) {
|
|
29
|
+
if (!url) return 0;
|
|
30
|
+
try {
|
|
31
|
+
const u = new URL(url);
|
|
32
|
+
const p = parseInt(u.port, 10);
|
|
33
|
+
if (p > 0) return p;
|
|
34
|
+
} catch {
|
|
35
|
+
/* 非 URL 形态走正则兑底 */
|
|
36
|
+
}
|
|
37
|
+
const m = String(url).match(/:(\d+)/);
|
|
38
|
+
return m ? parseInt(m[1], 10) || 0 : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 解析当前 clash 节点名(三级 fallback):
|
|
43
|
+
* 1. 与业务代理端口匹配且非 stale 的通道(clash-health-{port}.json)的 node
|
|
44
|
+
* 2. 主通道(clash-health.json,daemon 对主控制端双写)非 stale 的 node
|
|
45
|
+
* 3. 读不到(无 clash daemon / 文件 stale / 无 node 字段)→ ""
|
|
46
|
+
*
|
|
47
|
+
* @param {string|null} proxyUrl - 业务代理地址(--proxy 或 ~/.tt-help.json 的 proxy)
|
|
48
|
+
* @param {{channels?: Array<object>, health?: object|null}} [deps] - 测试注入用
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveProxyNode(proxyUrl, deps = {}) {
|
|
52
|
+
const channels = deps.channels ?? getClashHealthChannels();
|
|
53
|
+
const health = deps.health !== undefined ? deps.health : getClashHealth();
|
|
54
|
+
const port = parseProxyPort(proxyUrl);
|
|
55
|
+
if (port > 0) {
|
|
56
|
+
const ch = channels.find((c) => c && c.port === port && !c.stale);
|
|
57
|
+
if (ch && ch.node) return String(ch.node);
|
|
58
|
+
}
|
|
59
|
+
if (health && !health.stale && health.node) return String(health.node);
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 创建错误上报器
|
|
65
|
+
*
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {(url: string, body: object) => Promise<any>} opts.apiPost - API 客户端 POST(createApiClient 产出)
|
|
68
|
+
* @param {string} opts.serverUrl - watch 服务端地址
|
|
69
|
+
* @param {string} opts.userId - 客户端用户编号(机器级;单次调用可用 userId 覆盖)
|
|
70
|
+
* @param {string|null} [opts.proxyUrl] - 业务代理地址(缺省读 ~/.tt-help.json 的 proxy)
|
|
71
|
+
* @param {() => {poolUsername?: string}} [opts.getState] - 报错时刻状态(CLI 闭包注入在手池号)
|
|
72
|
+
* @returns {{ report: (info: object) => Promise<any> }}
|
|
73
|
+
*/
|
|
74
|
+
export function createErrorReporter({
|
|
75
|
+
apiPost,
|
|
76
|
+
serverUrl,
|
|
77
|
+
userId,
|
|
78
|
+
proxyUrl,
|
|
79
|
+
getState,
|
|
80
|
+
}) {
|
|
81
|
+
if (!apiPost || !serverUrl) {
|
|
82
|
+
throw new Error("createErrorReporter 需要 apiPost 与 serverUrl");
|
|
83
|
+
}
|
|
84
|
+
// proxyUrl 未显式传(undefined)才回退配置文件;显式传 null 表示确无代理
|
|
85
|
+
const effectiveProxyUrl = proxyUrl === undefined ? configuredProxy : proxyUrl;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
/**
|
|
89
|
+
* 上报一条客户端异常
|
|
90
|
+
* @param {object} info
|
|
91
|
+
* @param {string} [info.userId] - 单次覆盖(如 search 每轮标签搜索的随机 clientId)
|
|
92
|
+
* @param {string} info.errorType
|
|
93
|
+
* @param {string} [info.errorMessage]
|
|
94
|
+
* @param {string} [info.stage]
|
|
95
|
+
* @param {string} [info.errorStack]
|
|
96
|
+
* @param {string} [info.username] - 旧字段原样透传(兼容),语义不动
|
|
97
|
+
*/
|
|
98
|
+
async report({
|
|
99
|
+
userId: callUserId,
|
|
100
|
+
errorType,
|
|
101
|
+
errorMessage,
|
|
102
|
+
stage,
|
|
103
|
+
errorStack,
|
|
104
|
+
username,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const state = getState ? getState() || {} : {};
|
|
107
|
+
return apiPost(`${serverUrl}/api/error-report`, {
|
|
108
|
+
userId: callUserId || userId,
|
|
109
|
+
username: username || "",
|
|
110
|
+
errorType: errorType || "other",
|
|
111
|
+
errorMessage: errorMessage || "",
|
|
112
|
+
stage: stage || "",
|
|
113
|
+
errorStack: errorStack || "",
|
|
114
|
+
poolUsername: state.poolUsername || "",
|
|
115
|
+
proxyNode: resolveProxyNode(effectiveProxyUrl),
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 与心跳命令通道无关:acquire 响应直接携带明文 cookies(POST 专属通道,与心跳下发同级),
|
|
5
5
|
* 注入由业务进程在自己持有的浏览器实例上执行(POC 实证 SOP,见知识库 edge-login-transfer.md):
|
|
6
|
-
* context.clearCookies() + addCookies → 导航 tiktok.com → passport/web/account/info 验证身份
|
|
6
|
+
* context.clearCookies() + 挂载一次性设备档案清档 + addCookies → 导航 tiktok.com → passport/web/account/info 验证身份
|
|
7
7
|
*
|
|
8
8
|
* 语义约定(2026-10-26 定稿:以号为主键,无幂等/无端口键):
|
|
9
9
|
* - acquire: 挑最久未用的空闲号 → 租约 → 响应带明文 cookies;池空/服务器不可达抛错
|
|
@@ -93,6 +93,34 @@ export async function gotoTiktok(page, timeout = 45000) {
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* 挂载「下一次文档加载起点」的一次性设备档案清空(addInitScript + token 自限,无需拆卸)。
|
|
98
|
+
* token 不匹配才清,清后写入新 token:下一次导航起 token 相等直接跳过,不误伤账号正常续用。
|
|
99
|
+
* 为什么不在活页上直接清:活页的 TikTok JS 会在清理后数秒内把旧设备 id 写回(实测),
|
|
100
|
+
* 必须赶在「新文档自身 JS 运行前」清,新会话才以全新设备初始化(配对软封修复,2026-11-01 实验)。
|
|
101
|
+
* 挂载失败仅告警不阻断(退化为旧行为)。返回的函数为兼容保留的空操作。
|
|
102
|
+
*/
|
|
103
|
+
export async function armDeviceWipeOnNextLoad(context, page) {
|
|
104
|
+
try {
|
|
105
|
+
const token = `w${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
106
|
+
await page.addInitScript((t) => {
|
|
107
|
+
try {
|
|
108
|
+
if (localStorage.getItem("__tt_wipe_token") === t) return;
|
|
109
|
+
localStorage.clear();
|
|
110
|
+
sessionStorage.clear();
|
|
111
|
+
localStorage.setItem("__tt_wipe_token", t);
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
try { if (window.caches && caches.keys) { caches.keys().then((ks) => Promise.all(ks.map((k) => caches.delete(k)))).catch(() => {}); } } catch (e) {}
|
|
114
|
+
try { if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) { navigator.serviceWorker.getRegistrations().then((rs) => Promise.all(rs.map((r) => r.unregister()))).catch(() => {}); } } catch (e) {}
|
|
115
|
+
try { if (window.indexedDB && indexedDB.databases) { indexedDB.databases().then((dbs) => (dbs || []).forEach((d) => { try { const rq = indexedDB.deleteDatabase(d.name); rq.onsuccess = rq.onerror = rq.onblocked = () => {}; } catch (e) {} })).catch(() => {}); } } catch (e) {}
|
|
116
|
+
}, token);
|
|
117
|
+
return async () => {};
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`[登录池] 设备档案清理挂载失败(忽略,退化为旧行为): ${String(e).slice(0, 120)}`);
|
|
120
|
+
return async () => {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
96
124
|
function toPlaywrightCookies(cookies) {
|
|
97
125
|
return (cookies || []).map((c) => ({
|
|
98
126
|
name: c.name,
|
|
@@ -107,7 +135,7 @@ function toPlaywrightCookies(cookies) {
|
|
|
107
135
|
|
|
108
136
|
/**
|
|
109
137
|
* 原地注入账号(同一 Edge 运行实例,不重启浏览器;POC T2 实证)
|
|
110
|
-
* 清旧 cookie →
|
|
138
|
+
* 清旧 cookie → 挂载一次性清档 → 注入 → 导航(新文档起点清上一账号设备档案)→ passport 验证身份。
|
|
111
139
|
* @returns {Promise<{ok: boolean, username?: string, reason?: string}>}
|
|
112
140
|
*/
|
|
113
141
|
export async function injectPoolAccount(page, account, { verifyUsername } = {}) {
|
|
@@ -116,8 +144,14 @@ export async function injectPoolAccount(page, account, { verifyUsername } = {})
|
|
|
116
144
|
}
|
|
117
145
|
const context = page.context();
|
|
118
146
|
await context.clearCookies();
|
|
119
|
-
|
|
120
|
-
await
|
|
147
|
+
// 换号配对软封修复(2026-11-01 实验):新文档起点清上一账号设备档案,让新会话以全新设备初始化
|
|
148
|
+
const disarmWipe = await armDeviceWipeOnNextLoad(context, page);
|
|
149
|
+
try {
|
|
150
|
+
await context.addCookies(toPlaywrightCookies(account.cookies));
|
|
151
|
+
await gotoTiktok(page);
|
|
152
|
+
} finally {
|
|
153
|
+
await disarmWipe();
|
|
154
|
+
}
|
|
121
155
|
const id = await whoAmI(page);
|
|
122
156
|
if (!id.hasAccount) {
|
|
123
157
|
return {
|
|
@@ -159,3 +193,59 @@ export async function ensurePoolLogin(page, account) {
|
|
|
159
193
|
}));
|
|
160
194
|
return { ...r, injected: true };
|
|
161
195
|
}
|
|
196
|
+
|
|
197
|
+
// ── 崩溃熔断与恢复重试(纯函数/纯常量,2026-11-04 F16 实录驱动)──
|
|
198
|
+
// 背景:渲染进程崩溃(Target/Page crashed)曾被误判为「账号注入失败」→ 换号循环烧池
|
|
199
|
+
// (单日 28 号)。浏览器级失败须熔断退避(不取号保护账号池),账号级失败才候选轮换。
|
|
200
|
+
// 本组常量/函数保持零依赖(login-pool-client 同时被 watchdog 侧引用,勿引入 playwright 链)。
|
|
201
|
+
|
|
202
|
+
/** 崩溃熔断退避基数:第 1 轮 60s */
|
|
203
|
+
export const CRASH_BACKOFF_BASE_MS = 60 * 1000;
|
|
204
|
+
/** 崩溃熔断退避上限:15 分钟 */
|
|
205
|
+
export const CRASH_BACKOFF_MAX_MS = 15 * 60 * 1000;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 崩溃熔断退避时长:第 n 轮 = base × 2^(n-1),封顶 max;n 非正整数返回 0。
|
|
209
|
+
* 注入验证成功即清零轮数(清零由调用方闭包状态负责)。
|
|
210
|
+
*/
|
|
211
|
+
export function crashBackoffMs(crashStreak, base = CRASH_BACKOFF_BASE_MS, max = CRASH_BACKOFF_MAX_MS) {
|
|
212
|
+
if (!Number.isInteger(crashStreak) || crashStreak <= 0) return 0;
|
|
213
|
+
return Math.min(base * 2 ** (crashStreak - 1), max);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 登录态恢复首败后追加重试次数(共 3 次尝试,全败才弃号转 recover_fail) */
|
|
217
|
+
export const RESTORE_RETRY_MAX_RETRIES = 2;
|
|
218
|
+
/** 恢复重试间隔 */
|
|
219
|
+
export const RESTORE_RETRY_DELAY_MS = 10 * 1000;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 登录态恢复重试驱动(纯逻辑,I/O 与谓词均注入,可单测;本文件保持零依赖):
|
|
223
|
+
* 首次尝试已失败(firstFailure 为其失败原因),谓词判定为导航类瞬态失败则再试
|
|
224
|
+
* maxRetries 次、间隔 delayMs;账号级失败不重试直接返回。
|
|
225
|
+
* 调用方须传入 isRetryable(客户端用 page.js 的 isRetryableRestoreFailure)。
|
|
226
|
+
* @param {() => Promise<{ok: boolean, reason?: string}>} attemptFn 重试尝试
|
|
227
|
+
* @param {string} firstFailure 首次失败原因
|
|
228
|
+
* @param {object} opts {isRetryable, maxRetries, delayMs, sleep, shouldAbort}
|
|
229
|
+
* (sleep/shouldAbort 供调用方接入分片中断)
|
|
230
|
+
* @returns {Promise<{ok: boolean, reason?: string, attempts: number, aborted?: boolean}>}
|
|
231
|
+
*/
|
|
232
|
+
export async function retryRestoreOnNavFailure(attemptFn, firstFailure, opts = {}) {
|
|
233
|
+
const isRetryable = opts.isRetryable;
|
|
234
|
+
const maxRetries = opts.maxRetries ?? RESTORE_RETRY_MAX_RETRIES;
|
|
235
|
+
const delayMs = opts.delayMs ?? RESTORE_RETRY_DELAY_MS;
|
|
236
|
+
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
237
|
+
const shouldAbort = opts.shouldAbort || (() => false);
|
|
238
|
+
let reason = firstFailure;
|
|
239
|
+
let attempts = 1; // 首次尝试已发生
|
|
240
|
+
while (typeof isRetryable === "function" && attempts <= maxRetries && isRetryable(reason)) {
|
|
241
|
+
await sleep(delayMs);
|
|
242
|
+
if (shouldAbort()) return { ok: false, reason, attempts, aborted: true };
|
|
243
|
+
attempts++;
|
|
244
|
+
const r = await Promise.resolve()
|
|
245
|
+
.then(attemptFn)
|
|
246
|
+
.catch((e) => ({ ok: false, reason: e?.message || String(e) }));
|
|
247
|
+
if (r.ok) return { ...r, attempts };
|
|
248
|
+
reason = r.reason;
|
|
249
|
+
}
|
|
250
|
+
return { ok: false, reason, attempts };
|
|
251
|
+
}
|
package/src/watch/data-store.js
CHANGED
|
@@ -2391,6 +2391,23 @@ export function createStore(filePath, options = {}) {
|
|
|
2391
2391
|
return { saved: true };
|
|
2392
2392
|
}
|
|
2393
2393
|
|
|
2394
|
+
/**
|
|
2395
|
+
* 自动恢复候选:updated_at 早于 maxAgeMs 的 error 行(B 方案:无次数上限全自动回流)。
|
|
2396
|
+
* 仅扫 jobs——commitJob 会把 error 行从 task_queue 搬到 jobs(与页面「批量重新处理」同口径),
|
|
2397
|
+
* 回流本身交给 resetJob(三表迁移 + 空池缓存失效 + 统计置脏全复用)。
|
|
2398
|
+
* @returns {string[]} unique_id 列表(无 DB 的 JSON 模式返回空)
|
|
2399
|
+
*/
|
|
2400
|
+
function listStaleErrorJobs(maxAgeMs, limit = 500) {
|
|
2401
|
+
if (!getDb()) return [];
|
|
2402
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
2403
|
+
return getDb()
|
|
2404
|
+
.prepare(
|
|
2405
|
+
`SELECT unique_id FROM jobs WHERE status = 'error' AND updated_at < ? ORDER BY updated_at LIMIT ?`,
|
|
2406
|
+
)
|
|
2407
|
+
.all(cutoff, limit)
|
|
2408
|
+
.map((r) => r.unique_id);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2394
2411
|
function togglePin(uniqueId) {
|
|
2395
2412
|
if (getDb()) {
|
|
2396
2413
|
const user = getJob(uniqueId);
|
|
@@ -2686,6 +2703,8 @@ export function createStore(filePath, options = {}) {
|
|
|
2686
2703
|
errorStack,
|
|
2687
2704
|
hostname,
|
|
2688
2705
|
clientId,
|
|
2706
|
+
poolUsername,
|
|
2707
|
+
proxyNode,
|
|
2689
2708
|
) {
|
|
2690
2709
|
const existing = clientErrors.get(userId);
|
|
2691
2710
|
if (existing) {
|
|
@@ -2708,6 +2727,9 @@ export function createStore(filePath, options = {}) {
|
|
|
2708
2727
|
existing.reportCount = (existing.reportCount || 1) + 1;
|
|
2709
2728
|
}
|
|
2710
2729
|
if (username) existing.username = username;
|
|
2730
|
+
// 在手池号/代理节点(方案甲新增):非空才覆盖,同 username 语义
|
|
2731
|
+
if (poolUsername) existing.poolUsername = poolUsername;
|
|
2732
|
+
if (proxyNode) existing.proxyNode = proxyNode;
|
|
2711
2733
|
} else {
|
|
2712
2734
|
clientErrors.set(userId, {
|
|
2713
2735
|
userId,
|
|
@@ -2718,6 +2740,8 @@ export function createStore(filePath, options = {}) {
|
|
|
2718
2740
|
errorStack: errorStack || "",
|
|
2719
2741
|
username,
|
|
2720
2742
|
stage: stage || "",
|
|
2743
|
+
poolUsername: poolUsername || "",
|
|
2744
|
+
proxyNode: proxyNode || "",
|
|
2721
2745
|
timestamp: Date.now(),
|
|
2722
2746
|
reportCount: 1,
|
|
2723
2747
|
captchaCount: errorType === "captcha" ? 1 : 0,
|
|
@@ -3816,6 +3840,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3816
3840
|
commitJob,
|
|
3817
3841
|
commitNewExplore,
|
|
3818
3842
|
resetJob,
|
|
3843
|
+
listStaleErrorJobs,
|
|
3819
3844
|
togglePin,
|
|
3820
3845
|
getNextRedoJob,
|
|
3821
3846
|
commitRedoJob,
|