tt-help-cli-ycl 1.4.172 → 1.4.176
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/reset-login-pool.mjs +66 -0
- package/scripts/test-login-pool-e2e-real.mjs +134 -0
- package/scripts/test-login-pool-ui.mjs +211 -0
- package/scripts/test-login-pool.mjs +381 -0
- package/scripts/test-subscription-allocator.mjs +52 -0
- package/scripts/test-watchdog-subs-retry.mjs +123 -0
- package/src/clash/runtime.js +8 -4
- package/src/cli/explore.js +269 -23
- package/src/cli/search.js +241 -56
- package/src/cli/watchdog.js +34 -0
- package/src/lib/args.js +15 -2
- package/src/lib/browser/cdp.js +12 -2
- package/src/lib/browser/health-checker.js +16 -13
- package/src/lib/linux-env-init.js +258 -0
- package/src/lib/login-pool-client.js +156 -0
- package/src/scraper/modules/follow-extractor.js +23 -5
- package/src/scraper/modules/guess-extractor.js +4 -1
- package/src/watch/login-pool.js +755 -0
- package/src/watch/public/app.js +90 -44
- package/src/watch/public/index.html +17 -3
- package/src/watch/server.js +252 -0
- package/src/watch/subscription-allocator.js +18 -5
- package/src/watch/subscription-center.js +18 -2
- package/src/watch/watchdog-server.js +113 -0
- package/src/watchdog/agent.js +87 -281
- package/src/watchdog/login-apply.js +232 -0
- package/src/watchdog/login-import.js +180 -0
- package/src/watchdog/upgrader.js +59 -5
package/src/cli/search.js
CHANGED
|
@@ -39,7 +39,16 @@ import {
|
|
|
39
39
|
closeCaptcha,
|
|
40
40
|
} from "../scraper/modules/captcha-handler.js";
|
|
41
41
|
import { createApiClient } from "../lib/api-client.js";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
HealthChecker,
|
|
44
|
+
MEMORY_THRESHOLD,
|
|
45
|
+
ROTATE_INTERVAL_MS,
|
|
46
|
+
} from "../lib/browser/health-checker.js";
|
|
47
|
+
import {
|
|
48
|
+
createLoginPoolClient,
|
|
49
|
+
injectPoolAccount,
|
|
50
|
+
ensurePoolLogin,
|
|
51
|
+
} from "../lib/login-pool-client.js";
|
|
43
52
|
import {
|
|
44
53
|
server as cfgServer,
|
|
45
54
|
proxy as configuredProxy,
|
|
@@ -77,6 +86,7 @@ export async function handleSearch(parsed) {
|
|
|
77
86
|
portCount = 10,
|
|
78
87
|
parallel = 1,
|
|
79
88
|
proxy: cliProxy,
|
|
89
|
+
noPool: searchNoPool,
|
|
80
90
|
} = searchOpts || {};
|
|
81
91
|
|
|
82
92
|
// 配置了什么代理就用什么代理(--proxy > ~/.tt-help.json 的 proxy);
|
|
@@ -104,10 +114,13 @@ export async function handleSearch(parsed) {
|
|
|
104
114
|
|
|
105
115
|
// ── 初始化 ──
|
|
106
116
|
const baseUrl = serverUrl || DEFAULT_SERVER;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
totalAccounts:
|
|
110
|
-
|
|
117
|
+
const poolMode = !searchNoPool;
|
|
118
|
+
const healthChecker = poolMode
|
|
119
|
+
? new HealthChecker({ basePort, totalAccounts: 1 })
|
|
120
|
+
: new HealthChecker({
|
|
121
|
+
basePort,
|
|
122
|
+
totalAccounts: portCount,
|
|
123
|
+
});
|
|
111
124
|
let currentAccount = healthChecker.getCurrentAccount();
|
|
112
125
|
|
|
113
126
|
const cdpOptions = {};
|
|
@@ -119,11 +132,21 @@ export async function handleSearch(parsed) {
|
|
|
119
132
|
|
|
120
133
|
let browser = null;
|
|
121
134
|
let shuttingDown = false;
|
|
135
|
+
// 登录池(pool 模式):账号从账号中心动态取,原地换号
|
|
136
|
+
let poolClient = null;
|
|
137
|
+
let poolAccount = null;
|
|
138
|
+
let poolAccountSince = Date.now();
|
|
122
139
|
|
|
123
140
|
const cleanup = () => {
|
|
124
141
|
if (shuttingDown) return;
|
|
125
142
|
shuttingDown = true;
|
|
126
143
|
log("\n正在清理...");
|
|
144
|
+
if (poolClient && poolAccount) {
|
|
145
|
+
poolClient
|
|
146
|
+
.release(poolAccount)
|
|
147
|
+
.then(() => log(`[登录池] 已释放 @${poolAccount.username}`))
|
|
148
|
+
.catch(() => {});
|
|
149
|
+
}
|
|
127
150
|
browser?.close().catch(() => {});
|
|
128
151
|
killEdgeProcesses(cdpOptions.userDataDir, cdpOptions.port).catch(() => {});
|
|
129
152
|
process.exit(0);
|
|
@@ -147,65 +170,112 @@ export async function handleSearch(parsed) {
|
|
|
147
170
|
setDelayConfig("normal");
|
|
148
171
|
|
|
149
172
|
try {
|
|
150
|
-
// ── 启动:逐个尝试账户,直到找到一个已登录的 ──
|
|
151
173
|
let page = null;
|
|
152
174
|
let loggedIn = false;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
|
|
176
|
+
if (poolMode) {
|
|
177
|
+
// ── pool 模式启动:取号 → 原地注入 → 验证 ──
|
|
178
|
+
poolClient = createLoginPoolClient({
|
|
179
|
+
serverUrl: baseUrl,
|
|
180
|
+
userId,
|
|
181
|
+
port: cdpOptions.port,
|
|
182
|
+
appName: "search",
|
|
183
|
+
});
|
|
184
|
+
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
185
|
+
page = await getOrCreatePage(browser);
|
|
186
|
+
const startupDeadline = Date.now() + 30 * 60 * 1000;
|
|
187
|
+
while (!poolAccount) {
|
|
188
|
+
try {
|
|
189
|
+
poolAccount = await poolClient.acquire();
|
|
190
|
+
} catch (e) {
|
|
191
|
+
if (Date.now() > startupDeadline || shuttingDown) {
|
|
192
|
+
log(`❌ 取号失败超过 30 分钟(${e.message}),退出`);
|
|
193
|
+
await browser.close().catch(() => {});
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
log(`[登录池] 取号失败: ${e.message},60 秒后重试...`);
|
|
197
|
+
await new Promise((r) => setTimeout(r, 60000));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const r = await injectPoolAccount(page, poolAccount, {
|
|
201
|
+
verifyUsername: poolAccount.username,
|
|
202
|
+
}).catch((e) => ({ ok: false, reason: e.message }));
|
|
203
|
+
if (r.ok) {
|
|
204
|
+
loggedIn = true;
|
|
205
|
+
log(`[登录池] @${poolAccount.username} 注入验证 ✓`);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
161
208
|
log(
|
|
162
|
-
|
|
209
|
+
`[登录池] @${poolAccount.username} 注入验证失败(${r.reason}),释放换下一个...`,
|
|
163
210
|
);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
211
|
+
await poolClient.release(poolAccount).catch(() => {});
|
|
212
|
+
poolAccount = null;
|
|
213
|
+
if (Date.now() > startupDeadline || shuttingDown) {
|
|
214
|
+
log("❌ 启动取号注入未成功(30 分钟窗口耗尽),退出");
|
|
215
|
+
await browser.close().catch(() => {});
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
169
219
|
}
|
|
220
|
+
} else {
|
|
221
|
+
// ── legacy:逐个尝试本地 profile,直到找到一个已登录的 ──
|
|
222
|
+
let triedAccounts = 0;
|
|
223
|
+
const maxAccounts = portCount;
|
|
224
|
+
|
|
225
|
+
while (!loggedIn && triedAccounts < maxAccounts) {
|
|
226
|
+
if (triedAccounts > 0) {
|
|
227
|
+
// 切换到下一个账户
|
|
228
|
+
const prevAccount = currentAccount;
|
|
229
|
+
currentAccount = healthChecker.getNextAccount();
|
|
230
|
+
log(
|
|
231
|
+
`\n[启动] 尝试下一个账户: 端口 ${prevAccount.port} → ${currentAccount.port}`,
|
|
232
|
+
);
|
|
233
|
+
Object.assign(cdpOptions, {
|
|
234
|
+
port: currentAccount.port,
|
|
235
|
+
userDataDir: currentAccount.userDataDir,
|
|
236
|
+
...(resolvedProxy ? { proxyServer: resolvedProxy } : {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
170
239
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
// 连接 CDP 浏览器
|
|
178
|
-
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
179
|
-
page = await getOrCreatePage(browser);
|
|
240
|
+
triedAccounts++;
|
|
241
|
+
log(
|
|
242
|
+
`\n[启动] 尝试连接端口 ${cdpOptions.port} (${triedAccounts}/${maxAccounts})...`,
|
|
243
|
+
);
|
|
180
244
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
await
|
|
184
|
-
|
|
185
|
-
|
|
245
|
+
try {
|
|
246
|
+
// 连接 CDP 浏览器
|
|
247
|
+
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
248
|
+
page = await getOrCreatePage(browser);
|
|
249
|
+
|
|
250
|
+
// 导航到 TikTok 并检测登录
|
|
251
|
+
await withRetry("启动页面导航", async () => {
|
|
252
|
+
await page.goto(STARTUP_TIKTOK_URL, {
|
|
253
|
+
waitUntil: "domcontentloaded",
|
|
254
|
+
timeout: PAGE_GOTO_TIMEOUT,
|
|
255
|
+
});
|
|
186
256
|
});
|
|
187
|
-
});
|
|
188
257
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
258
|
+
loggedIn = await safeCheckLogin(page);
|
|
259
|
+
if (!loggedIn) {
|
|
260
|
+
log(` ⚠️ 端口 ${cdpOptions.port} 未登录 TikTok`);
|
|
261
|
+
await browser.close().catch(() => {});
|
|
262
|
+
browser = null;
|
|
263
|
+
}
|
|
264
|
+
} catch (e) {
|
|
265
|
+
log(` ❌ 端口 ${cdpOptions.port} 连接失败: ${e.message}`);
|
|
266
|
+
await browser?.close().catch(() => {});
|
|
193
267
|
browser = null;
|
|
194
268
|
}
|
|
195
|
-
} catch (e) {
|
|
196
|
-
log(` ❌ 端口 ${cdpOptions.port} 连接失败: ${e.message}`);
|
|
197
|
-
await browser?.close().catch(() => {});
|
|
198
|
-
browser = null;
|
|
199
269
|
}
|
|
200
|
-
}
|
|
201
270
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
271
|
+
if (!loggedIn || !browser || !page) {
|
|
272
|
+
log(
|
|
273
|
+
`❌ 所有 ${triedAccounts} 个账户均未登录或连接失败,请至少在一个端口的浏览器中登录 TikTok 后重试`,
|
|
274
|
+
);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
log(`✅ 端口 ${cdpOptions.port} 已登录 TikTok`);
|
|
207
278
|
}
|
|
208
|
-
log(`✅ 端口 ${cdpOptions.port} 已登录 TikTok`);
|
|
209
279
|
|
|
210
280
|
// 提取当前登录用户名(通过 TikTok 内部 API)
|
|
211
281
|
async function extractMyUsername(p) {
|
|
@@ -258,6 +328,10 @@ export async function handleSearch(parsed) {
|
|
|
258
328
|
|
|
259
329
|
// ── 账户切换处理 ──
|
|
260
330
|
async function handleAccountSwitch(reason) {
|
|
331
|
+
if (poolMode) {
|
|
332
|
+
await switchPoolAccount(reason);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
261
335
|
log(`\n[健康检查] ${reason},切换到下一个账户...`);
|
|
262
336
|
const oldAccount = currentAccount;
|
|
263
337
|
const nextAccount = healthChecker.getNextAccount();
|
|
@@ -359,6 +433,87 @@ export async function handleSearch(parsed) {
|
|
|
359
433
|
consecutiveNetworkErrors = 0;
|
|
360
434
|
}
|
|
361
435
|
|
|
436
|
+
// ── 登录池模式辅助 ──
|
|
437
|
+
|
|
438
|
+
async function switchPoolAccount(reason) {
|
|
439
|
+
log(`\n[换号] ${reason},从账号中心取新账号...`);
|
|
440
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
441
|
+
let next = null;
|
|
442
|
+
try {
|
|
443
|
+
next = await poolClient.acquire();
|
|
444
|
+
} catch (e) {
|
|
445
|
+
log(
|
|
446
|
+
`[换号] 取新号失败(${e.message})${poolAccount ? `,保留当前账号 @${poolAccount.username}` : ",将在后续重试"}`,
|
|
447
|
+
);
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
const r = await injectPoolAccount(page, next, {
|
|
451
|
+
verifyUsername: next.username,
|
|
452
|
+
}).catch((e) => ({ ok: false, reason: e.message }));
|
|
453
|
+
if (!r.ok) {
|
|
454
|
+
log(`[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`);
|
|
455
|
+
await poolClient.release(next).catch(() => {});
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const prev = poolAccount;
|
|
459
|
+
poolAccount = next;
|
|
460
|
+
poolAccountSince = Date.now();
|
|
461
|
+
loggedIn = true;
|
|
462
|
+
captchaCount = 0;
|
|
463
|
+
staleRounds = 0;
|
|
464
|
+
consecutiveNetworkErrors = 0;
|
|
465
|
+
log(`[换号] @${prev ? prev.username + " → " : ""}@${next.username} 注入验证通过 ✓`);
|
|
466
|
+
if (prev) {
|
|
467
|
+
await poolClient
|
|
468
|
+
.release(prev)
|
|
469
|
+
.then(() => log(`[换号] 旧号 @${prev.username} 已还池`))
|
|
470
|
+
.catch((e) => log(`[换号] 旧号释放失败(忽略,TTL 兑底): ${e.message}`));
|
|
471
|
+
}
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
log(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
|
|
475
|
+
await apiPost(`${baseUrl}/api/error-report`, {
|
|
476
|
+
userId,
|
|
477
|
+
username: poolAccount ? poolAccount.username : "unknown",
|
|
478
|
+
errorType: "account_switch_failed",
|
|
479
|
+
errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
|
|
480
|
+
stage: "account-switch",
|
|
481
|
+
}).catch(() => {});
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 浏览器重建后恢复在手账号:磁盘态一致则免注入,否则用内存 cookies 补注入 */
|
|
486
|
+
async function restorePoolLogin() {
|
|
487
|
+
if (!poolAccount) return false;
|
|
488
|
+
const r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
|
|
489
|
+
ok: false,
|
|
490
|
+
reason: e.message,
|
|
491
|
+
}));
|
|
492
|
+
if (r.ok) {
|
|
493
|
+
loggedIn = true;
|
|
494
|
+
log(`[登录池] 登录态已恢复 @${poolAccount.username}`);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
log(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
|
|
498
|
+
return await switchPoolAccount(`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** 内存超限:同号重启浏览器(不换号——内存是浏览器进程问题,不是账号问题) */
|
|
502
|
+
async function restartPoolBrowser(reason) {
|
|
503
|
+
log(
|
|
504
|
+
`\n[浏览器] ${reason},同号重启浏览器(@${poolAccount ? poolAccount.username : "无在手账号"})...`,
|
|
505
|
+
);
|
|
506
|
+
try {
|
|
507
|
+
const relaunched = await relaunchBrowser(cdpOptions, cdpOptions.port);
|
|
508
|
+
browser = relaunched;
|
|
509
|
+
page = await setupNewPage(browser);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
log(`[浏览器] 重启失败: ${e.message},转入换号流程`);
|
|
512
|
+
return await switchPoolAccount(`浏览器重启失败(${e.message})`);
|
|
513
|
+
}
|
|
514
|
+
return await restorePoolLogin();
|
|
515
|
+
}
|
|
516
|
+
|
|
362
517
|
const clientId = randomUUID();
|
|
363
518
|
const headers = (extra = {}) => ({
|
|
364
519
|
"X-Client-Id": clientId,
|
|
@@ -381,11 +536,32 @@ export async function handleSearch(parsed) {
|
|
|
381
536
|
while (!shuttingDown) {
|
|
382
537
|
try {
|
|
383
538
|
// ── 健康检查 ──
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
539
|
+
if (poolMode) {
|
|
540
|
+
// 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号)
|
|
541
|
+
const memRatio = healthChecker.memoryUsage();
|
|
542
|
+
const elapsedMs = Date.now() - poolAccountSince;
|
|
543
|
+
if (memRatio >= MEMORY_THRESHOLD) {
|
|
544
|
+
await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
|
|
545
|
+
} else if (elapsedMs >= ROTATE_INTERVAL_MS) {
|
|
546
|
+
await handleAccountSwitch(
|
|
547
|
+
`当前账号已运行 ${Math.round(elapsedMs / 60000)} 分钟`,
|
|
548
|
+
);
|
|
549
|
+
} else if (totalSearched % 10 === 0) {
|
|
550
|
+
const remainMin = Math.max(
|
|
551
|
+
0,
|
|
552
|
+
Math.round((ROTATE_INTERVAL_MS - elapsedMs) / 60000),
|
|
553
|
+
);
|
|
554
|
+
log(
|
|
555
|
+
`\n[巡检] @${poolAccount ? poolAccount.username : "-"} | 内存 ${(memRatio * 100).toFixed(1)}% | 距换号 ${remainMin} 分钟`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
const checkResult = healthChecker.check();
|
|
560
|
+
if (checkResult.shouldSwitch) {
|
|
561
|
+
await handleAccountSwitch(checkResult.reason);
|
|
562
|
+
} else if (checkResult.info && totalSearched % 10 === 0) {
|
|
563
|
+
log(`\n[健康检查] ${checkResult.info}`);
|
|
564
|
+
}
|
|
389
565
|
}
|
|
390
566
|
|
|
391
567
|
// ── 验证码检测(切换任务前) ──
|
|
@@ -458,7 +634,8 @@ export async function handleSearch(parsed) {
|
|
|
458
634
|
log(`⏳ 连续 ${emptyRounds} 轮无标签可搜索(30 秒后重试)...`);
|
|
459
635
|
await new Promise((r) => setTimeout(r, 30000));
|
|
460
636
|
emptyRounds = 0;
|
|
461
|
-
|
|
637
|
+
// pool 模式下空任务【不换号】——无工作不是账号的问题,换号会白白燃尽池子
|
|
638
|
+
if (!poolMode) await handleAccountSwitch("连续5轮无标签可搜索");
|
|
462
639
|
continue;
|
|
463
640
|
}
|
|
464
641
|
log(`⏳ 暂无标签可搜索(连续 ${emptyRounds} 轮),10 秒后重试...`);
|
|
@@ -511,6 +688,7 @@ export async function handleSearch(parsed) {
|
|
|
511
688
|
const newBrowser = await relaunchBrowser(cdpOptions, cdpOptions.port);
|
|
512
689
|
browser = newBrowser;
|
|
513
690
|
page = await setupNewPage(browser);
|
|
691
|
+
if (poolMode) await restorePoolLogin();
|
|
514
692
|
await new Promise((r) => setTimeout(r, 3000));
|
|
515
693
|
log(`[浏览器] 已重建,重试当前搜索...`);
|
|
516
694
|
|
|
@@ -659,6 +837,7 @@ export async function handleSearch(parsed) {
|
|
|
659
837
|
const newBrowser = await relaunchBrowser(cdpOptions, cdpOptions.port);
|
|
660
838
|
browser = newBrowser;
|
|
661
839
|
page = await setupNewPage(browser);
|
|
840
|
+
if (poolMode) await restorePoolLogin();
|
|
662
841
|
await new Promise((r) => setTimeout(r, 3000));
|
|
663
842
|
log(`[浏览器] 已重建,继续处理...`);
|
|
664
843
|
continue;
|
|
@@ -672,6 +851,12 @@ export async function handleSearch(parsed) {
|
|
|
672
851
|
} finally {
|
|
673
852
|
process.removeListener("SIGINT", cleanup);
|
|
674
853
|
process.removeListener("SIGTERM", cleanup);
|
|
854
|
+
if (poolClient && poolAccount) {
|
|
855
|
+
await poolClient
|
|
856
|
+
.release(poolAccount)
|
|
857
|
+
.then(() => log(`[登录池] 已释放 @${poolAccount.username}`))
|
|
858
|
+
.catch(() => {});
|
|
859
|
+
}
|
|
675
860
|
await browser?.close().catch(() => {});
|
|
676
861
|
killEdgeProcesses(cdpOptions.userDataDir, cdpOptions.port).catch(() => {});
|
|
677
862
|
log("\n搜索采集已停止");
|
package/src/cli/watchdog.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* 看门狗 CLI 命令入口
|
|
3
3
|
*
|
|
4
4
|
* 用法: tt-help watchdog [选项]
|
|
5
|
+
* tt-help watchdog install-service [--server <URL>] [--client-id <ID>] [--interval <秒>]
|
|
5
6
|
*
|
|
6
7
|
* 选项:
|
|
7
8
|
* --server <URL> 服务端地址(默认使用 ~/.tt-help.json 中的 server)
|
|
8
9
|
* --interval <秒> 心跳间隔,默认 15 秒
|
|
9
10
|
* --client-id <ID> 客户端标识(默认本机 hostname)
|
|
10
11
|
* --no-daemon 不启动 clash daemon(默认启动)
|
|
12
|
+
* install-service (Linux) 生成并启用 systemd 开机自启服务,需 root;每台机器一次性
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import os from "os";
|
|
@@ -23,6 +25,38 @@ export async function handleWatchdog(options) {
|
|
|
23
25
|
watchdogLoginIntervalSec,
|
|
24
26
|
} = options;
|
|
25
27
|
|
|
28
|
+
// Linux 一次性部署:生成并启用 systemd 开机自启(需 root;Windows/mac 用各自既有方式)
|
|
29
|
+
if (options.watchdogInstallService) {
|
|
30
|
+
if (process.platform !== "linux") {
|
|
31
|
+
console.error("install-service 仅支持 Linux(Windows/macOS 用既有部署方式)");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
if (process.getuid && process.getuid() !== 0) {
|
|
35
|
+
console.error("需 root 权限,请用: sudo tt-help watchdog install-service");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const { installWatchdogService } = await import("../lib/linux-env-init.js");
|
|
40
|
+
const { getClientConfig } = await import("../watchdog/reporter.js");
|
|
41
|
+
const cc = getClientConfig();
|
|
42
|
+
const serverUrl = watchdogServer || cc.watchdogServer || cc.server || "";
|
|
43
|
+
const clientId =
|
|
44
|
+
watchdogClientId || (cc.userId ? `${cc.userId}@${os.hostname()}` : os.hostname());
|
|
45
|
+
const p = installWatchdogService({
|
|
46
|
+
clientId,
|
|
47
|
+
serverUrl,
|
|
48
|
+
intervalSec: watchdogInterval || 15,
|
|
49
|
+
});
|
|
50
|
+
console.error(`✓ tt-watchdog.service 已生成并启用: ${p}`);
|
|
51
|
+
console.error(` clientId: ${clientId} | server: ${serverUrl || "(未指定,unit 内省略)"}`);
|
|
52
|
+
console.error(` 状态: systemctl status tt-watchdog`);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error(`安装失败: ${e.message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
// 服务端地址:--server 参数 > ~/.tt-help.json watchdogServer > server > 默认值
|
|
27
61
|
const clientConfig = getClientConfig();
|
|
28
62
|
const serverUrl =
|
package/src/lib/args.js
CHANGED
|
@@ -179,6 +179,7 @@ function parseExploreArgs(args) {
|
|
|
179
179
|
let exploreJobLocations = null;
|
|
180
180
|
let exploreMaxUsers = 0;
|
|
181
181
|
let explorePort = null;
|
|
182
|
+
let exploreNoPool = false;
|
|
182
183
|
let exploreBasePort = null;
|
|
183
184
|
let explorePortCount = null;
|
|
184
185
|
let exploreUserId = null;
|
|
@@ -220,6 +221,8 @@ function parseExploreArgs(args) {
|
|
|
220
221
|
exploreMaxUsers = parseInt(args[++i]) || 0;
|
|
221
222
|
} else if (arg === "--port") {
|
|
222
223
|
explorePort = parseInt(args[++i]) || 9222;
|
|
224
|
+
} else if (arg === "--no-pool") {
|
|
225
|
+
exploreNoPool = true; // 回退 legacy 端口轮换模式(用端口 profile 本地登录态)
|
|
223
226
|
} else if (arg === "--base-port") {
|
|
224
227
|
exploreBasePort = parseInt(args[++i]) || 9222;
|
|
225
228
|
} else if (arg === "--port-count") {
|
|
@@ -282,6 +285,7 @@ function parseExploreArgs(args) {
|
|
|
282
285
|
serverUrl,
|
|
283
286
|
exploreMaxUsers,
|
|
284
287
|
explorePort,
|
|
288
|
+
exploreNoPool,
|
|
285
289
|
exploreBasePort,
|
|
286
290
|
explorePortCount,
|
|
287
291
|
exploreUserId,
|
|
@@ -748,11 +752,14 @@ function parseSearchArgs(args) {
|
|
|
748
752
|
let portCount = 10;
|
|
749
753
|
let parallel = 1;
|
|
750
754
|
let proxy = null;
|
|
755
|
+
let noPool = false;
|
|
751
756
|
|
|
752
757
|
for (let i = 0; i < args.length; i++) {
|
|
753
758
|
const arg = args[i];
|
|
754
759
|
if (arg === "-s" || arg === "--server") {
|
|
755
760
|
serverUrl = args[++i];
|
|
761
|
+
} else if (arg === "--no-pool") {
|
|
762
|
+
noPool = true; // 回退 legacy 本地 profile 模式
|
|
756
763
|
} else if (arg === "--base-port") {
|
|
757
764
|
basePort = parseInt(args[++i]) || 3333;
|
|
758
765
|
} else if (arg === "--port-count") {
|
|
@@ -766,7 +773,7 @@ function parseSearchArgs(args) {
|
|
|
766
773
|
|
|
767
774
|
return {
|
|
768
775
|
subcommand: "search",
|
|
769
|
-
search: { serverUrl, basePort, portCount, parallel, proxy },
|
|
776
|
+
search: { serverUrl, basePort, portCount, parallel, proxy, noPool },
|
|
770
777
|
urls: [],
|
|
771
778
|
outputFormat: "json",
|
|
772
779
|
exploreCount: 0,
|
|
@@ -786,10 +793,15 @@ function parseWatchdogArgs(args) {
|
|
|
786
793
|
let watchdogClientId = null;
|
|
787
794
|
let watchdogNoDaemon = false;
|
|
788
795
|
let watchdogLoginIntervalSec = 7200;
|
|
796
|
+
// install-service 子命令(位置参数或 --install-service 均可)
|
|
797
|
+
const watchdogInstallService =
|
|
798
|
+
args.includes("install-service") || args.includes("--install-service");
|
|
789
799
|
|
|
790
800
|
for (let i = 0; i < args.length; i++) {
|
|
791
801
|
const arg = args[i];
|
|
792
|
-
if (arg === "
|
|
802
|
+
if (arg === "install-service" || arg === "--install-service") {
|
|
803
|
+
continue; // 标记已在上方读取
|
|
804
|
+
} else if (arg === "--server" || arg === "-s") {
|
|
793
805
|
watchdogServer = args[++i];
|
|
794
806
|
} else if (arg === "--interval" || arg === "-i") {
|
|
795
807
|
watchdogInterval = parseInt(args[++i]) || 15;
|
|
@@ -804,6 +816,7 @@ function parseWatchdogArgs(args) {
|
|
|
804
816
|
|
|
805
817
|
return {
|
|
806
818
|
subcommand: "watchdog",
|
|
819
|
+
watchdogInstallService,
|
|
807
820
|
watchdogServer,
|
|
808
821
|
watchdogInterval,
|
|
809
822
|
watchdogClientId,
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -18,6 +18,14 @@ function getEdgePath() {
|
|
|
18
18
|
const platform = os.platform();
|
|
19
19
|
if (platform === "darwin") return '"Microsoft Edge"';
|
|
20
20
|
if (platform === "win32") return "msedge.exe";
|
|
21
|
+
// Linux:官方 deb 包命令为 microsoft-edge[-stable],部分环境手工装的是 msedge,
|
|
22
|
+
// 硬编码会导致 F-11 类官方源节点启动浏览器必败(Linux 适配踩坑)→ 逐个探测
|
|
23
|
+
for (const c of ["msedge", "microsoft-edge", "microsoft-edge-stable"]) {
|
|
24
|
+
try {
|
|
25
|
+
execSync(`command -v ${c} 2>/dev/null`);
|
|
26
|
+
return c;
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
21
29
|
return "msedge";
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -480,8 +488,10 @@ function launchEdgeWithCDP(port, userDataDir, proxyServer) {
|
|
|
480
488
|
command = `start msedge ${argsStr}`;
|
|
481
489
|
} else {
|
|
482
490
|
// Linux:必须重定向掉 stdout/stderr,否则 msedge 继承 exec 的管道,
|
|
483
|
-
// 回调永远不触发(浏览器启动成功但 explore
|
|
484
|
-
|
|
491
|
+
// 回调永远不触发(浏览器启动成功但 explore 卡死在启动步骤)。
|
|
492
|
+
// 用探测到的 edgePath(官方 deb 是 microsoft-edge,硬编码 msedge 在
|
|
493
|
+
// F-11 类节点必败——登录池对未运行端口注入时首次暴雷,2026-10-26)
|
|
494
|
+
command = `${edgePath} ${argsStr} >/dev/null 2>&1 &`;
|
|
485
495
|
}
|
|
486
496
|
|
|
487
497
|
console.error(`[浏览器] 启动命令: ${command}`);
|
|
@@ -35,14 +35,8 @@ class HealthChecker {
|
|
|
35
35
|
return this.accounts[this.currentIndex];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.startTime = Date.now(); // 切换后重置运行时间
|
|
41
|
-
return this.accounts[this.currentIndex];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
check() {
|
|
45
|
-
let memUsage;
|
|
38
|
+
/** 系统内存占用比例(0-1):macOS 用 vm_stat 精确计算,其余平台用 os.freemem */
|
|
39
|
+
memoryUsage() {
|
|
46
40
|
const platform = os.platform();
|
|
47
41
|
|
|
48
42
|
if (platform === "darwin") {
|
|
@@ -67,17 +61,26 @@ class HealthChecker {
|
|
|
67
61
|
);
|
|
68
62
|
const total = os.totalmem();
|
|
69
63
|
const used = (active + wired + compressor) * pageSize;
|
|
70
|
-
|
|
64
|
+
return used / total;
|
|
71
65
|
} catch {
|
|
72
66
|
// 降级:使用估算
|
|
73
67
|
const total = os.totalmem();
|
|
74
68
|
const free = os.freemem();
|
|
75
69
|
const inactiveEstimate = total * 0.35;
|
|
76
|
-
|
|
70
|
+
return 1 - (free + inactiveEstimate) / total;
|
|
77
71
|
}
|
|
78
|
-
} else {
|
|
79
|
-
memUsage = 1 - os.freemem() / os.totalmem();
|
|
80
72
|
}
|
|
73
|
+
return 1 - os.freemem() / os.totalmem();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getNextAccount() {
|
|
77
|
+
this.currentIndex = (this.currentIndex + 1) % this.accounts.length;
|
|
78
|
+
this.startTime = Date.now(); // 切换后重置运行时间
|
|
79
|
+
return this.accounts[this.currentIndex];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
check() {
|
|
83
|
+
const memUsage = this.memoryUsage();
|
|
81
84
|
const memPercent = (memUsage * 100).toFixed(1);
|
|
82
85
|
|
|
83
86
|
if (memUsage >= MEMORY_THRESHOLD) {
|
|
@@ -111,4 +114,4 @@ class HealthChecker {
|
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
export { HealthChecker };
|
|
117
|
+
export { HealthChecker, MEMORY_THRESHOLD, ROTATE_INTERVAL_MS };
|