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/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 { HealthChecker } from "../lib/browser/health-checker.js";
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 healthChecker = new HealthChecker({
108
- basePort,
109
- totalAccounts: portCount,
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
- let triedAccounts = 0;
154
- const maxAccounts = portCount;
155
-
156
- while (!loggedIn && triedAccounts < maxAccounts) {
157
- if (triedAccounts > 0) {
158
- // 切换到下一个账户
159
- const prevAccount = currentAccount;
160
- currentAccount = healthChecker.getNextAccount();
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
- `\n[启动] 尝试下一个账户: 端口 ${prevAccount.port} → ${currentAccount.port}`,
209
+ `[登录池] @${poolAccount.username} 注入验证失败(${r.reason}),释放换下一个...`,
163
210
  );
164
- Object.assign(cdpOptions, {
165
- port: currentAccount.port,
166
- userDataDir: currentAccount.userDataDir,
167
- ...(resolvedProxy ? { proxyServer: resolvedProxy } : {}),
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
- triedAccounts++;
172
- log(
173
- `\n[启动] 尝试连接端口 ${cdpOptions.port} (${triedAccounts}/${maxAccounts})...`,
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
- // 导航到 TikTok 并检测登录
182
- await withRetry("启动页面导航", async () => {
183
- await page.goto(STARTUP_TIKTOK_URL, {
184
- waitUntil: "domcontentloaded",
185
- timeout: PAGE_GOTO_TIMEOUT,
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
- loggedIn = await safeCheckLogin(page);
190
- if (!loggedIn) {
191
- log(` ⚠️ 端口 ${cdpOptions.port} 未登录 TikTok`);
192
- await browser.close().catch(() => {});
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
- if (!loggedIn || !browser || !page) {
203
- log(
204
- `❌ 所有 ${triedAccounts} 个账户均未登录或连接失败,请至少在一个端口的浏览器中登录 TikTok 后重试`,
205
- );
206
- process.exit(1);
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
- const checkResult = healthChecker.check();
385
- if (checkResult.shouldSwitch) {
386
- await handleAccountSwitch(checkResult.reason);
387
- } else if (checkResult.info && totalSearched % 10 === 0) {
388
- log(`\n[健康检查] ${checkResult.info}`);
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
- await handleAccountSwitch("连续5轮无标签可搜索");
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搜索采集已停止");
@@ -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 === "--server" || arg === "-s") {
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,
@@ -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
- command = `msedge ${argsStr} >/dev/null 2>&1 &`;
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
- getNextAccount() {
39
- this.currentIndex = (this.currentIndex + 1) % this.accounts.length;
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
- memUsage = used / total;
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
- memUsage = 1 - (free + inactiveEstimate) / total;
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 };