tt-help-cli-ycl 1.4.179 → 1.4.182

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.
@@ -157,7 +157,7 @@ check("状态徽章=使用中", row1.includes("使用中"));
157
157
  check("使用者=机器:口", row1.includes("F-11:9222"));
158
158
  const poolSrc = await page.content();
159
159
  check("独立页无 cookie 明文", !poolSrc.includes("SECRET-SID-XYZ"));
160
- check("独立页重登录按钮已点亮(不再是置灰占位)", await page.$$eval("#content button:not([disabled])", (bs) => bs.some((b) => b.textContent.includes("重登录"))));
160
+ check("独立页检测按钮已点亮(重登录已改名)", await page.$$eval("#content button:not([disabled])", (bs) => bs.some((b) => b.textContent.trim() === "检测")));
161
161
  check("独立页含新增账号/批量检测按钮", await page.$$eval(".bar button", (bs) => {
162
162
  const t = bs.map((b) => b.textContent).join();
163
163
  return t.includes("新增账号") && t.includes("批量检测");
@@ -1,15 +1,17 @@
1
1
  /**
2
- * 协议桥单元测试 — parseProtocolUrl 白名单/参数校验 + 双平台模板生成
2
+ * 协议桥单元测试 — parseProtocolUrl 白名单/参数校验 + 双平台模板生成 + 单 token 别名路由
3
3
  *
4
4
  * 覆盖:
5
5
  * [1] URL 解析:合法样例(大小写 normalize / origin 归一 / 尾斜杠 / note 清洗)
6
6
  * [2] URL 拒绝:白名单外 action / 缺参 / 非 http server / username 非法 / scheme 不对
7
7
  * [3] 模板生成:AppleScript(osacompile 源)与 Windows 包装 cmd 的结构断言
8
+ * [4] 别名路由:protocol-handle 单 token(OS 拉起链路兼容;parseArgs 无参签名读 process.argv)
8
9
  *
9
10
  * 运行: node scripts/test-protocol-bridge.mjs
10
11
  */
11
12
  import { strict as assert } from "assert";
12
13
  import { parseProtocolUrl, buildAppleScript, buildWindowsCmd } from "../src/cli/protocol-bridge.js";
14
+ import { parseArgs } from "../src/lib/args.js";
13
15
 
14
16
  let pass = 0;
15
17
  let fail = 0;
@@ -101,31 +103,60 @@ check("AppleScript: 生成源可通过 osacompile 语法结构(do shell script
101
103
  const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
102
104
  const m = src.match(/do shell script ("(?:[^"\\]|\\.)*")/);
103
105
  assert.ok(m, "do shell script 后必须是合法 AppleScript 字符串字面量");
104
- // 拼接表达式收尾
105
106
  assert.ok(/& quoted form of theURL & "(?:[^"\\]|\\.)*"\s*\nend open location/.test(src), "拼接式必须完整闭合");
107
+ assert.ok(src.includes('protocol handle \\"%s\\"'), "URL 槽必须引号化(quoted form 引号会被生成侧 shell 消费)");
106
108
  });
107
109
  check("AppleScript: node 路径烧录且单引号包裹", () => {
108
110
  const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
109
111
  assert.ok(src.includes(`'${NODE}'`), "node 绝对路径必须烧录");
110
112
  assert.ok(src.includes(`'${CLI}'`), "cli.js 绝对路径必须烧录");
111
113
  });
112
- check("AppleScript: 路径含空格时仍单引号安全", () => {
113
- const src = buildAppleScript({ nodePath: "/Users/a b/n/node", cliPath: "/Users/a b/n/cli.js" });
114
- assert.ok(src.includes("'/Users/a b/n/node'"), "含空格路径必须在单引号内");
114
+ check("AppleScript: .command 用 protocol handle 两 token(对齐路由)", () => {
115
+ const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
116
+ assert.ok(src.includes('protocol handle \\"%s\\"'), "必须 protocol handle 且 URL 槽引号化");
117
+ assert.ok(!src.includes("protocol-handle"), "不得残留单 token 旧形态");
115
118
  });
116
- check("AppleScript: .command 退出码保持 + 按键防闪退", () => {
119
+ check("AppleScript: .command 退出码保持 + 按键防闪退 + 自清理", () => {
117
120
  const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
118
121
  assert.ok(src.includes("st=$?"), "必须捕获退出码");
119
122
  assert.ok(src.includes("read -n 1"), "必须按键保持窗口");
123
+ assert.ok(src.includes("rm -f"), "必须自清理临时 .command");
120
124
  });
121
- check("Windows cmd: 绝对路径 + %1 引号约定 + pause", () => {
125
+ check("Windows cmd: 绝对路径 + protocol handle %1 + pause", () => {
122
126
  const cmd = buildWindowsCmd({ nodePath: "C:\\n\\node.exe", cliPath: "C:\\p\\cli.js" });
123
127
  assert.ok(cmd.startsWith("@echo off"), "必须关回显");
124
- assert.ok(cmd.includes('"C:\\n\\node.exe" "C:\\p\\cli.js" protocol-handle %1'), "参数必须是单变量 %1");
128
+ assert.ok(cmd.includes('"C:\\n\\node.exe" "C:\\p\\cli.js" protocol handle "%~1"'), "URL 槽必须 %~1 再引号化");
129
+ assert.ok(!cmd.includes("protocol-handle"), "不得残留单 token 旧形态");
125
130
  assert.ok(cmd.includes("pause"), "必须 pause 防闪退");
126
131
  assert.ok(cmd.includes("exit /b %RC%"), "必须透传退出码");
127
132
  assert.ok(cmd.includes("\r\n"), "必须是 CRLF");
128
133
  });
129
134
 
135
+ // ── [4] 单 token 别名路由 ──
136
+ console.log("[4] 别名路由(parseArgs 无参签名,读 process.argv)");
137
+ check("protocol-handle 单 token → protocol/handle(OS 拉起链路兼容)", () => {
138
+ const oldArgv = process.argv;
139
+ process.argv = ["node", "tt-help", "protocol-handle", "tt-help://login-pool/add?server=http://x.io"];
140
+ try {
141
+ const p = parseArgs();
142
+ assert.equal(p.subcommand, "protocol");
143
+ assert.equal(p.action, "handle");
144
+ assert.equal(p.url, "tt-help://login-pool/add?server=http://x.io");
145
+ } finally {
146
+ process.argv = oldArgv;
147
+ }
148
+ });
149
+ check("protocol handle 两 token 仍正常", () => {
150
+ const oldArgv = process.argv;
151
+ process.argv = ["node", "tt-help", "protocol", "handle", "tt-help://login-pool/add?server=http://x.io"];
152
+ try {
153
+ const p = parseArgs();
154
+ assert.equal(p.subcommand, "protocol");
155
+ assert.equal(p.action, "handle");
156
+ } finally {
157
+ process.argv = oldArgv;
158
+ }
159
+ });
160
+
130
161
  console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
131
162
  process.exit(fail ? 1 : 0);
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // 验证模板输出的 .command 里 URL 是否带引号(模拟 printf 行为)
3
+ import { buildAppleScript, buildWindowsCmd } from "../src/cli/protocol-bridge.js";
4
+ import { execFileSync } from "child_process";
5
+ import { readFileSync } from "fs";
6
+ import { strict as assert } from "assert";
7
+
8
+ const NODE = "/Users/x/.nvm/versions/node/v24.14.1/bin/node";
9
+ const CLI = "/Users/x/pkg/cli.js";
10
+ const URL = "tt-help://login-pool/relogin?server=http%3A%2F%2F117.71.53.99%3A17301&username=asset698";
11
+
12
+ const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
13
+
14
+ // 从 AppleScript 源抽取 sh 命令(模拟 AppleScript 字符串求值:\" → ",\\ → \)
15
+ const m = src.match(/do shell script "((?:[^"\\]|\\.)*)" & quoted form of theURL & "((?:[^"\\]|\\.)*)"\n/);
16
+ assert.ok(m, "do shell script 拼接式结构完整");
17
+ const unesc = (s) => s.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
18
+ const sh = unesc(m[1]) + "'" + URL + "'" + unesc(m[2]);
19
+
20
+ // 用 /bin/sh 真实执行 printf(macOS 上等价;这里在 Linux 模拟——printf POSIX 行为一致)
21
+ const out2 = execFileSync("/bin/sh", ["-c", sh.replace("open -a Terminal", "echo OPEN-TARGET:")], { encoding: "utf-8" });
22
+ const target = (out2.match(/OPEN-TARGET: (\S+)/) || [])[1];
23
+ assert.ok(target, "未取得 .command 路径");
24
+ const out = readFileSync(target, "utf-8");
25
+
26
+
27
+ // 断言:URL 必须带引号出现在 .command 里
28
+ assert.ok(out.includes(`protocol handle "${URL}"`), "URL 必须被双引号包裹");
29
+ assert.ok(out.includes(`"${NODE}"`), "node 路径必须被双引号包裹");
30
+ assert.ok(out.includes(`"${CLI}"`), "cli 路径必须被双引号包裹");
31
+ console.log("✓ macOS:URL/路径均已引号化(& 不再截断命令)");
32
+
33
+ const w = buildWindowsCmd({ nodePath: "C:\\n\\node.exe", cliPath: "C:\\p\\cli.js" });
34
+ assert.ok(w.includes('protocol handle "%~1"'), "Windows 必须用 %~1 再引号化");
35
+ console.log("✓ Windows:protocol handle \"%~1\"(引号恒在)");
@@ -49,6 +49,17 @@ const MAX_REBUILD_ATTEMPTS = 5; // 账户切换后导航恢复:连续重启浏
49
49
  const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
50
50
  const PAGE_GOTO_TIMEOUT = 60000; // 页面导航超时 60 秒(账户切换后需要更长时间)
51
51
 
52
+ // 换号原因受控 code(2026-10-30 定稿:服务端 switchStats 按账号聚合展示;
53
+ // 只用这组固定值上报,不传自由文本;startup 首次取号不带 reason)
54
+ const SWITCH_REASON = {
55
+ BANNED: "banned", // 关注/粉丝功能被封、被封累计
56
+ RECOVER_FAIL: "recover_fail", // 浏览器重启/登录态恢复失败
57
+ SCHEDULED: "scheduled", // 48min 周期例行换号
58
+ INJECT_FAIL: "inject_fail", // 候选号注入验证失败后的再取号
59
+ CAPTCHA: "captcha", // 验证码累计超时未解决
60
+ NETWORK: "network", // CDN 限流累计(网络侧限流)
61
+ };
62
+
52
63
  async function withRetry(label, fn) {
53
64
  let backoff = 1000;
54
65
  while (true) {
@@ -349,13 +360,15 @@ export async function handleExplore(options) {
349
360
  * 换号(以号为主键):先取新号(旧号仍在手,无空窗)→ 注入验证 → 还旧号。
350
361
  * 候选最多试 5 个(注入失败的释放回池);池空/服务器不可达 → 保旧号返回 false,
351
362
  * 由主循环「无在手账号」补取逻辑兑底。
363
+ * @param code 受控换号原因(SWITCH_REASON,上报服务端 switchStats;可选);
364
+ * 首选按外层 call site 上报,候选注入失败后的再取号 = inject_fail
352
365
  */
353
- async function switchPoolAccount(reason) {
366
+ async function switchPoolAccount(reason, code) {
354
367
  console.error(`\n[换号] ${reason},从账号中心取新账号...`);
355
368
  for (let attempt = 0; attempt < 5; attempt++) {
356
369
  let next = null;
357
370
  try {
358
- next = await poolClient.acquire();
371
+ next = await poolClient.acquire(attempt === 0 ? code : SWITCH_REASON.INJECT_FAIL);
359
372
  } catch (e) {
360
373
  console.error(
361
374
  `[换号] 取新号失败(${e.message})${poolAccount ? `,保留当前账号 @${poolAccount.username}` : ",将在主循环补取"}`,
@@ -419,7 +432,10 @@ export async function handleExplore(options) {
419
432
  return true;
420
433
  }
421
434
  console.error(`[登录池] 登录态恢复失败(${r.reason}),转入换号流程`);
422
- return await switchPoolAccount(`浏览器重启后无法恢复 @${poolAccount.username} 的登录态`);
435
+ return await switchPoolAccount(
436
+ `浏览器重启后无法恢复 @${poolAccount.username} 的登录态`,
437
+ SWITCH_REASON.RECOVER_FAIL,
438
+ );
423
439
  }
424
440
 
425
441
  /** 内存超限:同号重启浏览器(不换号——内存是浏览器进程问题,不是账号问题) */
@@ -434,14 +450,17 @@ export async function handleExplore(options) {
434
450
  Object.assign(page, rebuiltPage);
435
451
  } catch (e) {
436
452
  console.error(`[浏览器] 重启失败: ${e.message},转入换号流程`);
437
- return await switchPoolAccount(`浏览器重启失败(${e.message})`);
453
+ return await switchPoolAccount(
454
+ `浏览器重启失败(${e.message})`,
455
+ SWITCH_REASON.RECOVER_FAIL,
456
+ );
438
457
  }
439
458
  return await restorePoolLogin();
440
459
  }
441
460
 
442
- async function handleAccountSwitch(reason) {
461
+ async function handleAccountSwitch(reason, code) {
443
462
  if (poolMode) {
444
- await switchPoolAccount(reason);
463
+ await switchPoolAccount(reason, code);
445
464
  return;
446
465
  }
447
466
  console.error(`\n[健康检查] ${reason},切换到下一个账户...`);
@@ -560,7 +579,7 @@ export async function handleExplore(options) {
560
579
  errorStack: result.errorStack || "",
561
580
  }),
562
581
  ),
563
- switchAccount: (reason) => handleAccountSwitch(reason),
582
+ switchAccount: (reason) => handleAccountSwitch(reason, SWITCH_REASON.NETWORK),
564
583
  });
565
584
 
566
585
  /** 等待人工在浏览器中解决验证码:轮询检测,解决即继续;超时返回 false */
@@ -652,6 +671,7 @@ export async function handleExplore(options) {
652
671
  } else if (elapsedMs >= ROTATE_INTERVAL_MS) {
653
672
  await handleAccountSwitch(
654
673
  `当前账号已运行 ${Math.round(elapsedMs / 60000)} 分钟`,
674
+ SWITCH_REASON.SCHEDULED,
655
675
  );
656
676
  } else if (processedCount % 10 === 0) {
657
677
  const remainMin = Math.max(
@@ -713,6 +733,7 @@ export async function handleExplore(options) {
713
733
  if (captchaCount >= 3) {
714
734
  await handleAccountSwitch(
715
735
  `验证码累计 ${captchaCount} 次(超时未解决)`,
736
+ SWITCH_REASON.CAPTCHA,
716
737
  );
717
738
  captchaCount = 0;
718
739
  }
@@ -729,7 +750,7 @@ export async function handleExplore(options) {
729
750
  console.error(
730
751
  `\n[封禁检测] 关注/粉丝功能已 ${minutes} 分钟未成功获取,切换到下一个账户...`,
731
752
  );
732
- await handleAccountSwitch(`关注/粉丝功能被封`);
753
+ await handleAccountSwitch(`关注/粉丝功能被封`, SWITCH_REASON.BANNED);
733
754
  }
734
755
  }
735
756
 
@@ -914,7 +935,10 @@ export async function handleExplore(options) {
914
935
  // 触发代理切换(api-interceptor 已内置 30/40/50s 重试,到这里仍封直接切代理)
915
936
  await triggerProxySwitch(`账号被封 @${username}`, proxyPort);
916
937
  if (blockedCount >= 3) {
917
- await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
938
+ await handleAccountSwitch(
939
+ `账号被封累计 ${blockedCount} 次`,
940
+ SWITCH_REASON.BANNED,
941
+ );
918
942
  blockedCount = 0;
919
943
  }
920
944
  continue;
@@ -952,6 +976,7 @@ export async function handleExplore(options) {
952
976
  // 累计3次验证码(均超时未解决),切换账户
953
977
  await handleAccountSwitch(
954
978
  `验证码累计 ${captchaCount} 次(超时未解决)`,
979
+ SWITCH_REASON.CAPTCHA,
955
980
  );
956
981
  captchaCount = 0;
957
982
  }
@@ -8,13 +8,14 @@
8
8
  *
9
9
  * add: 起独立 Edge(临时目录+代理)→ 人工在窗口里登录 TikTok → 轮询 passport 检出
10
10
  * → 导出 cookies → POST /api/login-pool/import(幂等,同号跳过)
11
- * relogin: POST /prepare 取池内旧 cookies → 注入本机 Edge 试复活(快路径零操作)
12
- * → 会话已死则提示人工在窗口登录目标账号 → 身份校验 → POST /relogin 覆盖刷新
11
+ * relogin: 检测:POST /prepare 取池内旧 cookies → 注入本机 Edge 打开账号当前登录态
12
+ * → 人工核对/退出重登(机器不做任何自动判定)→ 回车提交 → 身份校验 → POST /relogin 覆盖刷新
13
13
  * check: 服务端无浏览器 HTTP 探测各号登录态(默认空闲+疑似),死号自动标疑似
14
14
  *
15
15
  * 协议桥(页面一键拉起本命令)为独立后续项,本命令即其执行端。
16
16
  */
17
17
  import { mkdtempSync, rmSync } from "fs";
18
+ import { createInterface } from "readline";
18
19
  import { tmpdir } from "os";
19
20
  import { join } from "path";
20
21
  import { chromium } from "playwright";
@@ -23,11 +24,37 @@ import { proxy as configuredProxy } from "../lib/constants.js";
23
24
 
24
25
  const POLL_INTERVAL_MS = 3000;
25
26
  const CDP_FALLBACK_NOTE = "(关掉窗口或 Ctrl+C 可中止)";
27
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
28
 
27
29
  function log(t) {
28
30
  console.error(`[登录池] ${t}`);
29
31
  }
30
32
 
33
+ /** 等待回车提交;stdin 关闭或超时则中止(不更新池)。返回 {promise, abort}:失败路径须 abort() 关闭监听,否则进程不退 */
34
+ function waitEnter(timeoutMs) {
35
+ let finish = null;
36
+ const promise = new Promise((resolve, reject) => {
37
+ const rl = createInterface({ input: process.stdin });
38
+ let done = false;
39
+ finish = (err) => {
40
+ if (done) return;
41
+ done = true;
42
+ clearTimeout(timer);
43
+ rl.close();
44
+ process.stdin.pause();
45
+ process.stdin.unref(); // 释放事件循环对 stdin 的引用:提交/中止后进程立即退出
46
+ err ? reject(err) : resolve();
47
+ };
48
+ const timer = setTimeout(
49
+ () => finish(new Error(`等待回车超时(${Math.round(timeoutMs / 1000)}s),已中止(不更新池)`)),
50
+ timeoutMs,
51
+ );
52
+ rl.once("line", () => finish(null));
53
+ rl.once("close", () => finish(new Error("stdin 已关闭,已中止(不更新池)")));
54
+ });
55
+ return { promise, abort: (msg) => finish && finish(new Error(msg || "已中止")) };
56
+ }
57
+
31
58
  /** HTTP POST,默认 30s 超时(check 场景调用方自带更长超时) */
32
59
  async function post(base, path, body, timeoutMs = 30 * 1000) {
33
60
  const res = await fetch(`${String(base).replace(/\/+$/, "")}/api/login-pool/${path}`, {
@@ -106,45 +133,66 @@ async function cmdAdd(opts) {
106
133
  }
107
134
  }
108
135
 
109
- /** relogin 子命令 */
136
+ /** relogin 子命令:检测——打开账号当前登录态,人工核对/退出重登后回车提交更新 */
110
137
  async function cmdRelogin(opts) {
111
138
  const { serverUrl, proxy, username, timeoutMs, dir } = opts;
112
139
  if (!username) throw new Error("用法: tt-help login-pool relogin <username>");
113
- const prep = await post(serverUrl, "prepare", { username });
114
- const oldCookies = prep.account.cookies;
115
- log(`目标 @${username}(池内 ${oldCookies.length} 条 cookies)。启动 Edge,先试旧登录态复活…`);
116
- const toPlaywrightCookies = (cookies) =>
117
- (cookies || []).map((c) => ({
118
- name: c.name,
119
- value: c.value,
120
- domain: c.domain || ".tiktok.com",
121
- path: c.path || "/",
122
- secure: c.secure !== false,
123
- httpOnly: c.httpOnly !== false,
124
- ...(typeof c.expires === "number" && c.expires > 0 ? { expires: c.expires } : {}),
125
- }));
126
- const session = await openEdgeSession({ proxy, dir });
140
+ // 尽早挂回车监听:fd0 在重活(Edge 启动/页面导航)期间可能被环境消费,迟挂会丢事件;
141
+ // 提前监听则回车随时可达,结果由下方 await 消费
142
+ const enter = waitEnter(timeoutMs);
143
+ enter.promise.catch(() => {}); // 防 unhandled rejection(真正消费在下方 await/abort)
144
+ let session = null;
127
145
  try {
128
- let id = await waitForLogin({ page: session.page, preCookies: toPlaywrightCookies(oldCookies), timeoutMs: 20000 });
129
- if (id.hasAccount && id.username === username) {
130
- log("✓ 旧登录态仍存活(快路径,零操作)");
131
- } else {
132
- // 死号分支:清掉死 cookies,提示人工在窗口登录目标账号
133
- log(`旧登录态已失效(${id.expired ? "会话过期" : "未登录"})。请在窗口里登录 @${username}…`);
134
- await session.context.clearCookies();
135
- await gotoTiktok(session.page);
136
- id = await waitForLogin({ page: session.page, timeoutMs });
146
+ const prep = await post(serverUrl, "prepare", { username });
147
+ const oldCookies = prep.account.cookies;
148
+ log(`目标 @${username}(池内 ${oldCookies.length} 条 cookies)。在窗口中打开当前登录态…`);
149
+ const toPlaywrightCookies = (cookies) =>
150
+ (cookies || []).map((c) => ({
151
+ name: c.name,
152
+ value: c.value,
153
+ domain: c.domain || ".tiktok.com",
154
+ path: c.path || "/",
155
+ secure: c.secure !== false,
156
+ httpOnly: c.httpOnly !== false,
157
+ ...(typeof c.expires === "number" && c.expires > 0 ? { expires: c.expires } : {}),
158
+ }));
159
+ session = await openEdgeSession({ proxy, dir });
160
+ await session.context.addCookies(toPlaywrightCookies(oldCookies));
161
+ await gotoTiktok(session.page);
162
+ log(`已在窗口打开 @${username}。可保持不动 / 退出重登 / 直接处理异常(人工操作)`);
163
+ log(
164
+ `操作完成后回这里按回车,提交最新登录态(${Math.round(timeoutMs / 1000)}s 无操作自动中止;关窗口=中止)…`,
165
+ );
166
+ await enter.promise;
167
+
168
+ // 读提交时刻的登录态:页面可能在加载,未检出时短暂重试
169
+ let id = null;
170
+ for (let i = 0; i < 3; i++) {
171
+ id = await whoAmI(session.page);
172
+ if (id.hasAccount) break;
173
+ if (i < 2) {
174
+ log("未检测到登录态,3 秒后重试…");
175
+ await sleep(3000);
176
+ }
177
+ }
178
+ if (!id || !id.hasAccount) {
179
+ enter.abort();
180
+ throw new Error("窗口当前未登录(登出后未重登?),已中止(不更新池)");
137
181
  }
138
182
  if (id.username !== username) {
183
+ enter.abort();
139
184
  throw new Error(`身份不符:窗口登录的是 @${id.username},目标是 @${username},已中止(不更新池)`);
140
185
  }
141
186
  const cookies = await session.context.cookies();
142
- const r = await post(serverUrl, "relogin", { username, cookies, userId: "cli-login-pool", note: "命令行重登录" });
187
+ const r = await post(serverUrl, "relogin", { username, cookies, userId: "cli-login-pool", note: "命令行检测更新" });
143
188
  log(
144
- `✓ @${username} 重登录完成: cookies 已覆盖刷新(${r.account.cookieCount} 条)${r.revived ? ",已停用账号复活回池" : r.wasSuspect ? ",疑似已清回池" : ""}`,
189
+ `✓ @${username} 检测完成: cookies 已覆盖刷新(${r.account.cookieCount} 条)${r.revived ? ",已停用账号复活回池" : r.wasSuspect ? ",疑似已清回池" : ""}`,
145
190
  );
191
+ } catch (e) {
192
+ enter.abort(); // 失败路径关闭回车监听,否则 readline 持有事件循环、进程不退
193
+ throw e;
146
194
  } finally {
147
- await session.cleanup();
195
+ if (session) await session.cleanup();
148
196
  }
149
197
  }
150
198
 
@@ -192,7 +240,7 @@ export async function handleLoginPool(parsed) {
192
240
  " tt-help login-pool check [--server URL] [--proxy URL] [--ids id1,id2] [--json]",
193
241
  "",
194
242
  " add 新登录收号:弹出 Edge 人工登录 TikTok,检出后自动入池(幂等)",
195
- " relogin 重登录:先试旧登录态复活(零操作),死了引导人工登录,身份校验后覆盖刷新",
243
+ " relogin 检测:窗口打开账号当前登录态,人工核对/退出重登后按回车提交,身份校验后覆盖刷新",
196
244
  " check 巡检:服务端 HTTP 探测各号登录态,死号自动标疑似",
197
245
  ].join("\n"),
198
246
  );
@@ -102,7 +102,7 @@ async function cmdHandle(rawUrl) {
102
102
  noProxy: false,
103
103
  note: p.note || null,
104
104
  dir: null,
105
- timeoutSec: 300,
105
+ timeoutSec: 600,
106
106
  ids: null,
107
107
  json: false,
108
108
  showHelp: false,
@@ -118,7 +118,7 @@ export function buildAppleScript({ nodePath, cliPath }) {
118
118
  // .command 内容: #!/bin/bash + 跑 node(不 exec,退出码非 0 时窗口必留)+ 按键保持
119
119
  const shellP1 =
120
120
  "t=$(mktemp ${TMPDIR:-/tmp}/tt-help-bridge.XXXXXX); f=\"$t.command\"; mv \"$t\" \"$f\"; " + // mktemp 要求 X 结尾,先随机后 mv 补 .command 后缀(实测:直接带后缀会退化成固定名,并发互踩)
121
- "printf '#!/bin/bash\\n%s %s protocol-handle %s\\nst=$?\\necho\\nrm -f \"$0\"\\nread -n 1 -s -r -p \"完成(退出码 $st)——按任意键关闭…\"\\nexit $st\\n' " +
121
+ "printf '#!/bin/bash\\n\"%s\" \"%s\" protocol handle \"%s\"\\nst=$?\\necho\\nrm -f \"$0\"\\nread -n 1 -s -r -p \"完成(退出码 $st)——按任意键关闭…\"\\nexit $st\\n' " +
122
122
  `${shQ(nodePath)} ${shQ(cliPath)} `;
123
123
  const shellP2 = ' > "$f"; chmod +x "$f"; /usr/bin/open -a Terminal "$f"';
124
124
  return (
@@ -204,7 +204,7 @@ export function buildWindowsCmd({ nodePath, cliPath }) {
204
204
  return [
205
205
  "@echo off",
206
206
  "title tt-help 登录池",
207
- `"${nodePath}" "${cliPath}" protocol-handle %1`,
207
+ `"${nodePath}" "${cliPath}" protocol handle "%~1"`,
208
208
  "set RC=%ERRORLEVEL%",
209
209
  "echo.",
210
210
  `pause`,
package/src/lib/args.js CHANGED
@@ -1175,7 +1175,7 @@ function parseLoginPoolArgs(args) {
1175
1175
  let noProxy = false;
1176
1176
  let note = null;
1177
1177
  let dir = null;
1178
- let timeoutSec = 300;
1178
+ let timeoutSec = 600;
1179
1179
  let ids = null;
1180
1180
  let json = false;
1181
1181
  let showHelp = false;
@@ -1193,7 +1193,7 @@ function parseLoginPoolArgs(args) {
1193
1193
  } else if (arg === "--dir" && i + 1 < args.length) {
1194
1194
  dir = args[++i];
1195
1195
  } else if (arg === "--timeout" && i + 1 < args.length) {
1196
- timeoutSec = parseInt(args[++i], 10) || 300;
1196
+ timeoutSec = parseInt(args[++i], 10) || 600;
1197
1197
  } else if (arg === "--ids" && i + 1 < args.length) {
1198
1198
  ids = args[++i].split(",").map((s) => s.trim()).filter(Boolean);
1199
1199
  } else if (arg === "--json") {
@@ -1806,6 +1806,11 @@ export function parseArgs() {
1806
1806
  return parseProtocolBridgeArgs(args.slice(1));
1807
1807
  }
1808
1808
 
1809
+ // protocol-handle 单 token 别名:协议处理器拉起链路的历史形态,兼容已注册机器
1810
+ if (args.length > 0 && args[0] === "protocol-handle") {
1811
+ return parseProtocolBridgeArgs(["handle", ...args.slice(1)]);
1812
+ }
1813
+
1809
1814
  if (args.length > 0 && args[0] === "comments") {
1810
1815
  return parseCommentsArgs(args.slice(1));
1811
1816
  }
@@ -294,13 +294,13 @@ const HELP_TEXT = [
294
294
  " tt-help login-pool <add|relogin|check> [选项]",
295
295
  " 账号中心:新登录收号 / 重登录 / 巡检(与网页 /login-pool 同一套账号库)",
296
296
  " add 弹出独立 Edge,人工登录 TikTok 后自动导出登录态入池(幂等)",
297
- " relogin <username> 先试旧登录态复活(零操作),失效则引导人工登录,覆盖刷新池内 cookies",
297
+ " relogin <username> 检测:窗口打开账号当前登录态,人工核对/退出重登后按回车提交,覆盖刷新池内 cookies",
298
298
  " 选项:",
299
299
  " --server <URL> 账号中心地址(watch 服务端)",
300
300
  " --proxy <地址> Edge/探测代理,默认配置文件 proxy;--no-proxy 直连",
301
301
  " --note <备注> add 备注信息",
302
302
  " --dir <路径> 自定义 Edge 用户目录(默认临时目录用后即清)",
303
- " --timeout <秒> 等待人工登录上限,默认 300",
303
+ " --timeout <秒> 等待人工操作上限,默认 600",
304
304
  " --ids <id1,id2> check 只探测指定账号",
305
305
  " --json check 输出 JSON",
306
306
  " 示例: tt-help login-pool add --server http://172.18.154.201:3011",
@@ -37,9 +37,14 @@ async function poolPost(url, body) {
37
37
  export function createLoginPoolClient({ serverUrl, userId, port, appName = "explore" }) {
38
38
  const base = `${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`;
39
39
  return {
40
- /** 取号:挑最久未用的空闲号,响应带明文 cookies。失败抛错(池空/服务器不可达) */
41
- async acquire() {
42
- const r = await poolPost(`${base}/acquire`, { userId, port, appName });
40
+ /** 取号:按服务端排序规则(2026-10-30:休息窗+同类/非同类)挑空闲号,响应带明文 cookies。
41
+ * 失败抛错(池空/服务器不可达)。
42
+ * reason 可选:换号原因受控 code(banned/recover_fail/scheduled/inject_fail/captcha/network),
43
+ * 服务端按账号聚合 switchStats;不带则不计(仅显式上报才计数,旧客户端兼容) */
44
+ async acquire(reason) {
45
+ const body = { userId, port, appName };
46
+ if (reason) body.reason = String(reason);
47
+ const r = await poolPost(`${base}/acquire`, body);
43
48
  return r.account;
44
49
  },
45
50
  /** 还号:以号为主键,按 id 释放(谁持账号谁还) */