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.
@@ -26,7 +26,18 @@ import {
26
26
  switchAccount,
27
27
  } from "../lib/browser/cdp.js";
28
28
  import { showResourceUsage } from "../utils/index.js";
29
- import { HealthChecker } from "../lib/browser/health-checker.js";
29
+ import {
30
+ HealthChecker,
31
+ MEMORY_THRESHOLD,
32
+ ROTATE_INTERVAL_MS,
33
+ } from "../lib/browser/health-checker.js";
34
+ import {
35
+ createLoginPoolClient,
36
+ whoAmI,
37
+ gotoTiktok,
38
+ injectPoolAccount,
39
+ ensurePoolLogin,
40
+ } from "../lib/login-pool-client.js";
30
41
  import { createApiClient } from "../lib/api-client.js";
31
42
  import { createCDNHandler } from "../lib/cdn-handler.js";
32
43
  import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
@@ -67,6 +78,7 @@ export async function handleExplore(options) {
67
78
  exploreMaxUsers,
68
79
  serverUrl,
69
80
  explorePort,
81
+ exploreNoPool,
70
82
  exploreBasePort,
71
83
  explorePortCount,
72
84
  exploreUserId,
@@ -83,11 +95,22 @@ export async function handleExplore(options) {
83
95
  let browser = null;
84
96
  let cdpOptions = null;
85
97
  let shuttingDown = false;
98
+ // 登录池(pool 模式):账号从账号中心动态取,原地切号替代换端口
99
+ let poolClient = null; // 账号中心客户端
100
+ let poolAccount = null; // 当前在手账号(含 cookies 与租约 id)
101
+ let poolAccountSince = Date.now(); // 当前账号开始使用时刻(48min 换号计时)
86
102
 
87
103
  const shutdown = async (signal) => {
88
104
  if (shuttingDown) return;
89
105
  shuttingDown = true;
90
- console.error(`\n[Explore] 收到 ${signal},正在关闭浏览器...`);
106
+ console.error(`\n[Explore] 收到 ${signal},正在关闭...`);
107
+ // 登录池模式:先还号(本地 profile 保留当前登录态,clearOnNode 默认否),再关浏览器
108
+ if (poolClient && poolAccount) {
109
+ await poolClient
110
+ .release(poolAccount)
111
+ .then(() => console.error(`[登录池] 已释放 @${poolAccount.username}`))
112
+ .catch((e) => console.error(`[登录池] 释放失败(忽略): ${e.message}`));
113
+ }
91
114
  // 先杀死 Edge 进程(CDP 模式下 browser.close() 只断开连接,不杀进程)
92
115
  if (cdpOptions) {
93
116
  await killEdgeProcesses(cdpOptions.userDataDir, cdpOptions.port);
@@ -141,14 +164,28 @@ export async function handleExplore(options) {
141
164
  console.error(`服务器: ${serverUrl}(断开会自动重连)`);
142
165
  if (exploreMaxUsers > 0) console.error(`上限: ${exploreMaxUsers} 个用户`);
143
166
 
144
- const healthChecker = new HealthChecker({
145
- basePort: exploreBasePort,
146
- totalAccounts: explorePortCount,
147
- });
167
+ const poolMode = !exploreNoPool;
168
+ const poolPort = explorePort || exploreBasePort || 9222;
169
+ const healthChecker = poolMode
170
+ ? new HealthChecker({ basePort: poolPort, totalAccounts: 1 })
171
+ : new HealthChecker({
172
+ basePort: exploreBasePort,
173
+ totalAccounts: explorePortCount,
174
+ });
148
175
  let currentAccount = healthChecker.getCurrentAccount();
149
176
 
150
177
  cdpOptions = {};
151
- if (explorePort) {
178
+ if (poolMode) {
179
+ // 登录池模式:端口退化为节点本地固定槽位(无轮换),账号从账号中心动态取。
180
+ // --base-port 兼容旧命令配置:作为固定口使用(轮换已取消);--port-count 无意义,打日志忽略
181
+ cdpOptions.port = explorePort || exploreBasePort || 9222;
182
+ cdpOptions.userDataDir = path.join(
183
+ os.homedir(),
184
+ "Library",
185
+ "Application Support",
186
+ `Microsoft Edge For Testing_p${cdpOptions.port}`,
187
+ );
188
+ } else if (explorePort) {
152
189
  // 固定端口模式(调试用,关闭自动轮换)
153
190
  cdpOptions.port = explorePort;
154
191
  cdpOptions.userDataDir = path.join(
@@ -178,6 +215,21 @@ export async function handleExplore(options) {
178
215
 
179
216
  console.error(`CDP 端口: ${cdpOptions.port}, 用户编号: ${userId}`);
180
217
  console.error(`浏览器配置: ${path.basename(cdpOptions.userDataDir)}`);
218
+ if (poolMode) {
219
+ console.error(
220
+ `登录池模式: 账号中心 ${serverUrl},端口固定 ${cdpOptions.port}(--no-pool 回退端口轮换)`,
221
+ );
222
+ if (exploreBasePort && !explorePort) {
223
+ console.error(
224
+ ` [--base-port ${exploreBasePort}] 在 pool 模式下作为固定端口使用(轮换已取消)`,
225
+ );
226
+ }
227
+ if (explorePortCount != null) {
228
+ console.error(
229
+ ` [--port-count ${explorePortCount}] 在 pool 模式下无意义(无端口轮换),已忽略`,
230
+ );
231
+ }
232
+ }
181
233
 
182
234
  const { apiGet, apiPost } = createApiClient({
183
235
  meta: { port: cdpOptions.port },
@@ -191,7 +243,7 @@ export async function handleExplore(options) {
191
243
  });
192
244
  console.error(`种子用户: ${added} 个新增, ${skipped} 个已存在`);
193
245
  }
194
- if (!explorePort) {
246
+ if (!poolMode && !explorePort) {
195
247
  const portRange = `${currentAccount.port}-${currentAccount.port + healthChecker.accounts.length - 1}`;
196
248
  console.error(
197
249
  `端口轮换范围: ${portRange}(共 ${healthChecker.accounts.length} 个)`,
@@ -212,8 +264,50 @@ export async function handleExplore(options) {
212
264
  });
213
265
  });
214
266
 
215
- // 检测登录状态(启动时只检测一次)
216
- let loggedIn = await safeCheckLogin(page);
267
+ // 检测登录状态(legacy 模式启动时只检测一次;pool 模式在取号注入后置位)
268
+ let loggedIn = false;
269
+ if (poolMode) {
270
+ poolClient = createLoginPoolClient({
271
+ serverUrl,
272
+ userId,
273
+ port: cdpOptions.port,
274
+ appName: "explore",
275
+ });
276
+ // 取号 + 注入验证(60s 退避重试至 30min,仍失败退出交 watchdog 重启循环)
277
+ const startupDeadline = Date.now() + 30 * 60 * 1000;
278
+ while (!poolAccount) {
279
+ try {
280
+ poolAccount = await poolClient.acquire();
281
+ } catch (e) {
282
+ if (Date.now() > startupDeadline || shuttingDown) {
283
+ await poolFatalExit(`取号失败超过 30 分钟(${e.message})`);
284
+ }
285
+ console.error(`[登录池] 取号失败: ${e.message},60 秒后重试...`);
286
+ await new Promise((r) => setTimeout(r, 60 * 1000));
287
+ continue;
288
+ }
289
+ // 以号为主键:拿到的号直接注入验证(本地磁盘态与取号结果无关)
290
+ const r = await injectPoolAccount(page, poolAccount, {
291
+ verifyUsername: poolAccount.username,
292
+ }).catch((e) => ({ ok: false, reason: e.message }));
293
+ if (r.ok) {
294
+ loggedIn = true;
295
+ console.error(`[登录池] @${poolAccount.username} 注入验证 ✓`);
296
+ break;
297
+ }
298
+ console.error(
299
+ `[登录池] @${poolAccount.username} 注入验证失败(${r.reason}),释放回池换下一个...`,
300
+ );
301
+ await poolClient.release(poolAccount).catch(() => {});
302
+ poolAccount = null;
303
+ if (Date.now() > startupDeadline || shuttingDown) {
304
+ await poolFatalExit("启动取号注入未成功(30 分钟窗口耗尽)");
305
+ }
306
+ await new Promise((r) => setTimeout(r, 10 * 1000));
307
+ }
308
+ } else {
309
+ loggedIn = await safeCheckLogin(page);
310
+ }
217
311
 
218
312
  // 全局拦截图片资源,减少内存占用和加载时间
219
313
  await page.route("**/*", (route) => {
@@ -239,7 +333,117 @@ export async function handleExplore(options) {
239
333
  return newPage;
240
334
  }
241
335
 
336
+ // ── 登录池模式辅助 ──
337
+
338
+ /** 致命退出:杀 Edge + 退出(启动期取号/注入超时等) */
339
+ async function poolFatalExit(msg) {
340
+ console.error(`[登录池] ${msg}`);
341
+ if (cdpOptions) {
342
+ await killEdgeProcesses(cdpOptions.userDataDir, cdpOptions.port).catch(() => {});
343
+ }
344
+ await browser?.close().catch(() => {});
345
+ process.exit(1);
346
+ }
347
+
348
+ /**
349
+ * 换号(以号为主键):先取新号(旧号仍在手,无空窗)→ 注入验证 → 还旧号。
350
+ * 候选最多试 5 个(注入失败的释放回池);池空/服务器不可达 → 保旧号返回 false,
351
+ * 由主循环「无在手账号」补取逻辑兑底。
352
+ */
353
+ async function switchPoolAccount(reason) {
354
+ console.error(`\n[换号] ${reason},从账号中心取新账号...`);
355
+ for (let attempt = 0; attempt < 5; attempt++) {
356
+ let next = null;
357
+ try {
358
+ next = await poolClient.acquire();
359
+ } catch (e) {
360
+ console.error(
361
+ `[换号] 取新号失败(${e.message})${poolAccount ? `,保留当前账号 @${poolAccount.username}` : ",将在主循环补取"}`,
362
+ );
363
+ return false;
364
+ }
365
+ const r = await injectPoolAccount(page, next, {
366
+ verifyUsername: next.username,
367
+ }).catch((e) => ({ ok: false, reason: e.message }));
368
+ if (!r.ok) {
369
+ console.error(
370
+ `[换号] @${next.username} 注入验证失败(${r.reason}),释放候选换下一个...`,
371
+ );
372
+ await poolClient.release(next).catch(() => {});
373
+ continue;
374
+ }
375
+ // 新号验证通过:切换在手状态,再还旧号(谁持账号谁还)
376
+ const prev = poolAccount;
377
+ poolAccount = next;
378
+ poolAccountSince = Date.now();
379
+ loggedIn = true;
380
+ lastFollowSuccessTime = Date.now();
381
+ captchaCount = 0;
382
+ blockedCount = 0;
383
+ cdn.reset();
384
+ consecutiveNetworkErrors = 0;
385
+ console.error(`[换号] @${prev ? prev.username + " → " : ""}@${next.username} 注入验证通过 ✓`);
386
+ if (prev) {
387
+ await poolClient
388
+ .release(prev)
389
+ .then(() => console.error(`[换号] 旧号 @${prev.username} 已还池`))
390
+ .catch((e) =>
391
+ console.error(`[换号] 旧号释放失败(忽略,TTL 兑底): ${e.message}`),
392
+ );
393
+ }
394
+ return true;
395
+ }
396
+ console.error(`[换号] 连续 5 个候选注入验证失败,保留现场等待下轮重试`);
397
+ await apiPost(`${serverUrl}/api/error-report`, {
398
+ userId,
399
+ username: poolAccount ? poolAccount.username : "unknown",
400
+ errorType: "account_switch_failed",
401
+ errorMessage: "连续5个候选账号注入验证失败(详情见节点日志)",
402
+ stage: "account-switch",
403
+ }).catch(() => {});
404
+ return false;
405
+ }
406
+
407
+ /** 浏览器重建/重启后恢复登录态:磁盘态一致则免注入,否则用内存 cookies 补注入 */
408
+ async function restorePoolLogin() {
409
+ if (!poolAccount) return false;
410
+ const r = await ensurePoolLogin(page, poolAccount).catch((e) => ({
411
+ ok: false,
412
+ reason: e.message,
413
+ }));
414
+ if (r.ok) {
415
+ loggedIn = true;
416
+ console.error(
417
+ `[登录池] 登录态已恢复 @${poolAccount.username}${r.injected ? "(重新注入)" : "(磁盘态一致)"}`,
418
+ );
419
+ return true;
420
+ }
421
+ console.error(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
422
+ return await switchPoolAccount(`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`);
423
+ }
424
+
425
+ /** 内存超限:同号重启浏览器(不换号——内存是浏览器进程问题,不是账号问题) */
426
+ async function restartPoolBrowser(reason) {
427
+ console.error(
428
+ `\n[浏览器] ${reason},同号重启浏览器(@${poolAccount ? poolAccount.username : "无在手账号"})...`,
429
+ );
430
+ try {
431
+ const relaunched = await relaunchBrowser(cdpOptions, cdpOptions.port || 9222);
432
+ browser = relaunched;
433
+ const rebuiltPage = await setupNewPage(browser);
434
+ Object.assign(page, rebuiltPage);
435
+ } catch (e) {
436
+ console.error(`[浏览器] 重启失败: ${e.message},转入换号流程`);
437
+ return await switchPoolAccount(`浏览器重启失败(${e.message})`);
438
+ }
439
+ return await restorePoolLogin();
440
+ }
441
+
242
442
  async function handleAccountSwitch(reason) {
443
+ if (poolMode) {
444
+ await switchPoolAccount(reason);
445
+ return;
446
+ }
243
447
  console.error(`\n[健康检查] ${reason},切换到下一个账户...`);
244
448
  const oldAccount = currentAccount;
245
449
  const nextAccount = healthChecker.getNextAccount();
@@ -407,6 +611,14 @@ export async function handleExplore(options) {
407
611
 
408
612
  while (!shuttingDown) {
409
613
  try {
614
+ // 登录池模式:无在手账号(上次换号未完成/候选全败)先补取,补不到退避后重来
615
+ if (poolMode && !poolAccount) {
616
+ const ok = await switchPoolAccount("补取账号(当前无在手租约)");
617
+ if (!ok) {
618
+ await new Promise((r) => setTimeout(r, 60 * 1000));
619
+ continue;
620
+ }
621
+ }
410
622
  // 周期性同步服务端目标国家(内部节流,几乎零开销)
411
623
  if (!options.locationExplicit) {
412
624
  const synced = await syncTargetLocationsFromServer(serverUrl);
@@ -431,23 +643,44 @@ export async function handleExplore(options) {
431
643
  showResourceUsage();
432
644
  }
433
645
 
434
- const checkResult = healthChecker.check();
435
- if (checkResult.shouldSwitch) {
436
- await handleAccountSwitch(checkResult.reason);
437
- } else if (checkResult.info && processedCount % 10 === 0) {
438
- // 关注/粉丝封禁检测状态(仅登录状态)
439
- let followStatus = "";
440
- if (loggedIn && exploreEnableFollow) {
441
- const followElapsed = Math.round(
442
- (Date.now() - lastFollowSuccessTime) / 60000,
646
+ if (poolMode) {
647
+ // 登录池模式:内存超限→同号重启浏览器;48min→换号(原地切号,不换端口)
648
+ const memRatio = healthChecker.memoryUsage();
649
+ const elapsedMs = Date.now() - poolAccountSince;
650
+ if (memRatio >= MEMORY_THRESHOLD) {
651
+ await restartPoolBrowser(`系统内存 ${(memRatio * 100).toFixed(1)}% 超阈值`);
652
+ } else if (elapsedMs >= ROTATE_INTERVAL_MS) {
653
+ await handleAccountSwitch(
654
+ `当前账号已运行 ${Math.round(elapsedMs / 60000)} 分钟`,
443
655
  );
444
- const followRemaining = Math.max(
656
+ } else if (processedCount % 10 === 0) {
657
+ const remainMin = Math.max(
445
658
  0,
446
- Math.round(FOLLOW_BLOCK_THRESHOLD / 60000) - followElapsed,
659
+ Math.round((ROTATE_INTERVAL_MS - elapsedMs) / 60000),
660
+ );
661
+ console.error(
662
+ `\n[巡检] @${poolAccount.username} | 内存 ${(memRatio * 100).toFixed(1)}% | 距换号 ${remainMin} 分钟`,
447
663
  );
448
- followStatus = ` | 关注/粉丝上次成功 ${followElapsed} 分钟前 | 封禁检测 ${followRemaining} 分钟后`;
449
664
  }
450
- console.error(`\n[健康检查] ${checkResult.info}${followStatus}`);
665
+ } else {
666
+ const checkResult = healthChecker.check();
667
+ if (checkResult.shouldSwitch) {
668
+ await handleAccountSwitch(checkResult.reason);
669
+ } else if (checkResult.info && processedCount % 10 === 0) {
670
+ // 关注/粉丝封禁检测状态(仅登录状态)
671
+ let followStatus = "";
672
+ if (loggedIn && exploreEnableFollow) {
673
+ const followElapsed = Math.round(
674
+ (Date.now() - lastFollowSuccessTime) / 60000,
675
+ );
676
+ const followRemaining = Math.max(
677
+ 0,
678
+ Math.round(FOLLOW_BLOCK_THRESHOLD / 60000) - followElapsed,
679
+ );
680
+ followStatus = ` | 关注/粉丝上次成功 ${followElapsed} 分钟前 | 封禁检测 ${followRemaining} 分钟后`;
681
+ }
682
+ console.error(`\n[健康检查] ${checkResult.info}${followStatus}`);
683
+ }
451
684
  }
452
685
 
453
686
  // 切换任务前检测验证码
@@ -546,6 +779,7 @@ export async function handleExplore(options) {
546
779
  browser = newBrowser;
547
780
  const newPage = await setupNewPage(browser);
548
781
  Object.assign(page, newPage);
782
+ if (poolMode) await restorePoolLogin();
549
783
  // 重试当前用户
550
784
  result = await processExplore(
551
785
  page,
@@ -805,6 +1039,7 @@ export async function handleExplore(options) {
805
1039
  browser = newBrowser;
806
1040
  const newPage = await setupNewPage(browser);
807
1041
  Object.assign(page, newPage);
1042
+ if (poolMode) await restorePoolLogin();
808
1043
  // 重建后等待页面稳定
809
1044
  await new Promise((r) => setTimeout(r, 3000));
810
1045
  console.error(`[浏览器] 已重建,继续处理...`);
@@ -816,11 +1051,22 @@ export async function handleExplore(options) {
816
1051
  }
817
1052
  }
818
1053
 
1054
+ // 登录池模式:自然退出(上限达量/任务空转退出等)也必须还号,否则租约被占到 TTL
1055
+ if (poolClient && poolAccount) {
1056
+ await poolClient
1057
+ .release(poolAccount)
1058
+ .then(() => console.error(`[登录池] 已释放 @${poolAccount.username}`))
1059
+ .catch((e) => console.error(`[登录池] 释放失败(忽略): ${e.message}`));
1060
+ }
1061
+
819
1062
  const stats = await apiGet(`${serverUrl}/api/stats`);
820
1063
  console.error(`\n完成: ${processedCount} 个用户处理, ${errorCount} 个出错`);
821
1064
  console.error(
822
1065
  ` 总用户: ${stats.totalUsers}, 已完成: ${stats.processedUsers}, 待处理: ${stats.pendingUsers}, 错误: ${stats.errorUsers}`,
823
1066
  );
1067
+ // CDP 连接会挂住 node event loop(browser.close 只断连不保证 loop 清空,
1068
+ // 见知识库 POC 坑①/F-6 实录)——自然退出必须显式 exit,否则进程悬挂
1069
+ process.exit(0);
824
1070
  } finally {
825
1071
  process.removeListener("SIGINT", onSigint);
826
1072
  process.removeListener("SIGTERM", onSigterm);