tt-help-cli-ycl 1.4.176 → 1.4.178

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.176",
3
+ "version": "1.4.178",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,26 +1,31 @@
1
1
  #!/bin/bash
2
- # 将本地 tiktok-cli 打包部署到 Windows 采集节点(试运行,不经 npm registry)
2
+ # 将本地 tiktok-cli 打包部署到采集节点(不经 npm registry)—— npm publish 前真机测试标准通道
3
3
  # 用法:
4
- # ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验
5
- # ./scripts/deploy-node.sh tiktok-210 --bump # 先把 package.json 版本号 +1 再打包
6
- # ./scripts/deploy-node.sh tiktok-210 --bump --restart # 安装后重启走 CDP 的命令进程
7
- # #(watchdog 检测到退出会用新代码自动拉起)
8
- # 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启命令
9
- # 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210,经跳板机),BatchMode 免密可用
4
+ # ./scripts/deploy-node.sh <ssh别名> [--linux] [--bump] [--restart]
5
+ # ./scripts/deploy-node.sh tiktok-210 # 打包+传输+远程安装+校验(Windows 节点)
6
+ # ./scripts/deploy-node.sh f11 --linux --restart # Linux 节点(如 F-11),重启 systemd watchdog
7
+ # ./scripts/deploy-node.sh tiktok-210 --bump --restart # 先把 package.json 版本号 +1 再打包,安装后重启业务进程
8
+ # 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)重启
9
+ # 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210/f11,经跳板机或直连),BatchMode 免密可用
10
10
  # 说明:
11
+ # - 测试包【不要加 --bump】:同版本号覆盖安装,autoUpgrade 视为最新不动作;测完用官方包还原
12
+ # (带新版本号的测试包会让节点永远认为「已是最新」,发布同号正式版时再也升不上去)
13
+ # - Linux 重启前提:节点 /etc/sudoers.d 已放行 NOPASSWD systemctl restart tt-watchdog
11
14
  # - 无需停进程再安装:采集客户端不加载 better-sqlite3(只有 videostats/watch 服务端用),无文件锁
12
15
  # - watchdog 自升级:npm 覆盖安装后守护进程经 upgrader.js 自动重启,受管命令随后重建
13
16
  # - 节点上保留 tgz 安装包,回滚时 npm install -g 旧版 tgz 即可
14
17
  set -euo pipefail
15
18
 
16
- ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--bump] [--restart]}"
19
+ ALIAS="${1:?用法: deploy-node.sh <ssh别名> [--linux] [--bump] [--restart]}"
17
20
  shift || true
18
21
  DO_BUMP=0
19
22
  DO_RESTART=0
23
+ DO_LINUX=0
20
24
  for a in "$@"; do
21
25
  case "$a" in
22
26
  --bump) DO_BUMP=1 ;;
23
27
  --restart) DO_RESTART=1 ;;
28
+ --linux) DO_LINUX=1 ;;
24
29
  *) echo "未知参数: $a"; exit 1 ;;
25
30
  esac
26
31
  done
@@ -49,22 +54,33 @@ TGZ=$(npm pack 2>/dev/null | tail -1)
49
54
  echo "包: $TGZ ($(du -h "$TGZ" | cut -f1))"
50
55
 
51
56
  echo "==> scp → $ALIAS"
57
+ # grep 对空输出返回 1,pipefail 下会误杀脚本;scp 真实成败由远程版本校验兕底
52
58
  scp -o ConnectTimeout=25 -o BatchMode=yes "$TGZ" "$ALIAS:$TGZ" 2>&1 |
53
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null
54
- echo "已传输到 %USERPROFILE%\\$TGZ"
59
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' >/dev/null || true
60
+ echo "已传输到 ~/$TGZ"
55
61
 
56
62
  echo "==> 远程 npm install -g"
57
- ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
58
- "npm install -g %USERPROFILE%\\$TGZ 2>&1" |
59
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
60
- tail -3
63
+ if [ "$DO_LINUX" = 1 ]; then
64
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
65
+ "npm install -g \$HOME/$TGZ 2>&1" |
66
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|^Warning|Permanently added' |
67
+ tail -3
68
+ else
69
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
70
+ "npm install -g %USERPROFILE%\\$TGZ 2>&1" |
71
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
72
+ tail -3
73
+ fi
61
74
 
62
75
  echo "==> 校验远程版本"
63
- REMOTE_VER=$(
64
- ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
65
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
66
- tail -1 | tr -d '\r'
67
- )
76
+ VER_FILTER='post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added'
77
+ if [ "$DO_LINUX" = 1 ]; then
78
+ REMOTE_VER=$(ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>/dev/null" 2>/dev/null |
79
+ grep -vE "$VER_FILTER" | tail -1 | tr -d '\r')
80
+ else
81
+ REMOTE_VER=$(ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" "tt-help --version 2>nul" 2>/dev/null |
82
+ grep -vE "$VER_FILTER" | tail -1 | tr -d '\r')
83
+ fi
68
84
  if [ "$REMOTE_VER" = "$VER" ]; then
69
85
  echo "✓ 远程版本 $REMOTE_VER"
70
86
  else
@@ -73,11 +89,19 @@ else
73
89
  fi
74
90
 
75
91
  if [ "$DO_RESTART" = 1 ]; then
76
- echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
77
- ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
78
- grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
79
- head -10 || true
80
- echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
92
+ if [ "$DO_LINUX" = 1 ]; then
93
+ echo "==> 重启 watchdog(systemd)"
94
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" \
95
+ "sudo -n systemctl restart tt-watchdog && sleep 2 && systemctl is-active tt-watchdog" 2>&1 |
96
+ grep -vE 'post-quantum|openssh.com/pq|Permanently added' | tail -1
97
+ else
98
+ echo "==> 重启走 CDP 的命令进程(排除 watchdog/webserver,浏览器进程保留、新客户端直接重连)"
99
+ ssh -o ConnectTimeout=25 -o BatchMode=yes "$ALIAS" 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match \"cli\.js\" -and $_.CommandLine -notmatch \"watchdog|webserver\" } | ForEach-Object { taskkill /F /PID $_.ProcessId }"' 2>&1 |
100
+ grep -vE 'post-quantum|openssh.com/pq|store now|upgraded|CLIXML|^Warning|Permanently added' |
101
+ head -10 || true
102
+ echo "(watchdog 检测到异常退出后会在 5-30s 内用新代码自动拉起,Edge 进程保留、客户端直连不重启浏览器)"
103
+ echo "(如需重启 watchdog 本体:杀 PID + schtasks /run \tt-watchdog)"
104
+ fi
81
105
  fi
82
106
 
83
107
  echo "==> 完成"
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 登录导入跳过逻辑单元测试 — syncLoginStates 本地 marker 永久跳过 + fetch 省略
3
+ *
4
+ * 覆盖:
5
+ * 1. 全部端口在 marker → 不发中心 fetch(不可达服务端也不报错)→ 全部跳过「本地已导入」
6
+ * 2. 部分端口在 marker → marker 口跳过,未导入口因总超时放弃(不起 Edge)
7
+ *
8
+ * 跑法: HOME=$(mktemp -d) node scripts/test-login-import-skip.mjs
9
+ */
10
+ import assert from "assert";
11
+ import { writeFileSync } from "fs";
12
+ import { join } from "path";
13
+
14
+ const HOME = process.env.HOME;
15
+ if (!HOME || HOME === "/") {
16
+ console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-import-skip.mjs");
17
+ process.exit(1);
18
+ }
19
+
20
+ const { syncLoginStates } = await import("../src/watchdog/login-import.js");
21
+
22
+ let pass = 0, fail = 0;
23
+ const check = (name, cond, extra = "") => {
24
+ if (cond) { pass++; console.log(` ✓ ${name}`); }
25
+ else { fail++; console.log(` ✗ ${name} ${extra}`); }
26
+ };
27
+
28
+ // 预置 marker:9222/9223 已导入过
29
+ writeFileSync(join(HOME, ".tt-help-watchdog-login-imported.json"), JSON.stringify({ 9222: "userA", 9223: "userB" }));
30
+
31
+ // 不可达服务端:若代码仍尝试 fetch,会等最多 15s 超时(用耗时断言识别)
32
+ const UNREACHABLE = "http://127.0.0.1:1";
33
+
34
+ console.log("\n[1] 全口 marker:零 fetch、全跳过");
35
+ {
36
+ const t0 = Date.now();
37
+ const s = await syncLoginStates({ serverUrl: UNREACHABLE, userId: "T", ports: [9222, 9223], portTimeoutMs: 1000, totalTimeoutMs: 60000 });
38
+ check("两口全部 skipped", s.skipped.length === 2 && s.imported.length === 0 && s.failed.length === 0, JSON.stringify(s));
39
+ check("原因=本地已导入", s.skipped.every((x) => x.reason === "本地已导入"), JSON.stringify(s.skipped));
40
+ check("未发中心 fetch(<2s,fetch 会撞 15s 级等待)", Date.now() - t0 < 2000, `${Date.now() - t0}ms`);
41
+ }
42
+
43
+ console.log("\n[2] 混合:marker 口跳过,新口进不了 Edge(总超时拦截)");
44
+ {
45
+ const s = await syncLoginStates({ serverUrl: UNREACHABLE, userId: "T", ports: [9222, 9444], portTimeoutMs: 1000, totalTimeoutMs: 0 });
46
+ const sk = s.skipped.find((x) => x.port === 9222);
47
+ check("marker 口 9222 跳过", sk && sk.reason === "本地已导入", JSON.stringify(s));
48
+ check("新口 9444 未起 Edge(总超时放弃)", s.failed.some((x) => x.port === 9444 && x.reason === "总超时"), JSON.stringify(s.failed));
49
+ }
50
+
51
+ console.log(`\n结果: ${pass} 过 / ${fail} 挂`);
52
+ process.exit(fail ? 1 : 0);
@@ -150,14 +150,23 @@ await page.waitForFunction(() => document.querySelector("#content table"), { tim
150
150
  const headers = await page.textContent("#content table tr:first-child");
151
151
  check(
152
152
  "独立页新列齐全",
153
- ["账号", "状态", "使用者", "最近使用", "次数", "有效期", "操作", "历史"].every((h) => headers.includes(h)),
153
+ ["账号", "状态", "使用者", "最近使用", "次数", "有效期", "检测", "操作", "历史"].every((h) => headers.includes(h)),
154
154
  );
155
155
  const row1 = await page.textContent("#content table tr:nth-child(2)");
156
156
  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[disabled]", (bs) => bs.some((b) => b.textContent.includes("重登录"))));
160
+ check("独立页重登录按钮已点亮(不再是置灰占位)", await page.$$eval("#content button:not([disabled])", (bs) => bs.some((b) => b.textContent.includes("重登录"))));
161
+ check("独立页含新增账号/批量检测按钮", await page.$$eval(".bar button", (bs) => {
162
+ const t = bs.map((b) => b.textContent).join();
163
+ return t.includes("新增账号") && t.includes("批量检测");
164
+ }));
165
+ const barSrc = await page.content();
166
+ check("页面含命令化文案(协议桥后置,先复制命令)", barSrc.includes("tt-help login-pool"));
167
+ check("模拟取号/入池粘cookies按钮已移除", !barSrc.includes("模拟取号") && !barSrc.includes("入池(粘cookies)"));
168
+ check("自动刷新无条件化(无勾选框)", !barSrc.includes('id="auto"'));
169
+ check("统计 chips 存在", await page.$$eval("#stats .chip", (cs) => cs.length >= 5));
161
170
 
162
171
  // 有效期归一显示:应为 ~90 天后的日期(而非 1970)
163
172
  const day90 = new Date(Date.now() + 90 * 86400000).toLocaleDateString("zh-CN");
@@ -32,7 +32,13 @@ const {
32
32
  importAccount,
33
33
  heartbeatLoginPool,
34
34
  reapExpiredLeases,
35
+ reloginAccount,
36
+ prepareRelogin,
37
+ healthTargets,
38
+ recordHealth,
39
+ _accountsInternal,
35
40
  } = await import("../src/watch/login-pool.js");
41
+ const { checkPoolAccounts } = await import("../src/watch/login-health.js");
36
42
 
37
43
  let pass = 0;
38
44
  let fail = 0;
@@ -370,12 +376,115 @@ console.log("\n[8] 登录态导入 import");
370
376
  const imp2 = importAccount({ userId: "F-8", port: 9301, username: "userD", cookies: [{ name: "sessionid", value: "OTHER-SESSION" }], note: "另机重复会话" });
371
377
  check("重复导入幂等(dedup=true)", imp2.dedup === true);
372
378
  check("不覆盖池内 cookies", !JSON.stringify(imp2.account).includes("OTHER-SESSION"));
373
- check("原来源元数据不动", imp2.account.lastImportFrom.userId === "F-9");
379
+ check("幂等补写来源元数据(最近导入者,供节点热路径跳过)", imp2.account.lastImportFrom.userId === "F-8" && imp2.account.lastImportFrom.port === 9301);
374
380
 
375
381
  let threw = false;
376
382
  try { importAccount({ userId: "F-1", username: "x" }); } catch (e) { threw = true; check("缺 cookies 拒绝", /cookies/.test(e.message)); }
377
383
  check("缺 cookies 确实抛错", threw);
378
384
  }
379
385
 
386
+ console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
387
+ {
388
+ for (const a of getLoginPoolView().accounts) {
389
+ try { releaseAccount({ id: a.id }); } catch {}
390
+ try { updateAccount({ id: a.id, status: "retired" }); } catch {}
391
+ try { deleteAccount(a.id); } catch {}
392
+ }
393
+ const D1 = [
394
+ { name: "sessionid", value: "sid-d-old", domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 },
395
+ { name: "unique_id", value: "userD", domain: ".tiktok.com" },
396
+ ];
397
+ importAccount({ userId: "F-9", username: "userD", cookies: D1 });
398
+ const D2 = [
399
+ { name: "sessionid", value: "sid-d-new", domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 * 2 },
400
+ { name: "unique_id", value: "userD", domain: ".tiktok.com" },
401
+ { name: "poc_marker", value: "x", domain: ".tiktok.com" },
402
+ ];
403
+
404
+ let r = reloginAccount({ username: "userD", cookies: D2, userId: "cli", note: "单测" });
405
+ const after = JSON.parse(JSON.stringify(_accountsInternal().find((a) => a.username === "userD")));
406
+ check("覆盖刷新(新 cookies 落池)", JSON.stringify(after.cookies).includes("sid-d-new") && JSON.stringify(after.cookies).includes("poc_marker"));
407
+ check("历史含 relogin 事件", (after.history || []).some((h) => h.event === "relogin"));
408
+ check("正常刷新不改状态不复活", r.revived === false && r.wasSuspect === false && r.account.status === "pool");
409
+
410
+ let threw = false;
411
+ try { reloginAccount({ username: "ghost", cookies: D2, userId: "cli" }); } catch (e) { threw = /不在池中/.test(e.message); }
412
+ check("不在池拒绝", threw);
413
+ threw = false;
414
+ try { reloginAccount({ username: "userD", cookies: D2 }); } catch (e) { threw = /userId/.test(e.message); }
415
+ check("缺 userId 拒绝", threw);
416
+ threw = false;
417
+ try { reloginAccount({ username: "userD", userId: "cli" }); } catch (e) { threw = /cookies/.test(e.message); }
418
+ check("缺 cookies 拒绝", threw);
419
+
420
+ acquireAccount({ userId: "T1", port: 9400 });
421
+ threw = false;
422
+ try { reloginAccount({ username: "userD", cookies: D2, userId: "cli" }); } catch (e) { threw = /使用中/.test(e.message); }
423
+ check("使用中拒绝重登录", threw);
424
+ releaseAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id });
425
+
426
+ updateAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id, status: "suspect" });
427
+ r = reloginAccount({ username: "userD", cookies: D2, userId: "cli" });
428
+ check("suspect → 刷新后回 pool", r.wasSuspect === true && r.account.status === "pool");
429
+
430
+ updateAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id, status: "retired" });
431
+ r = reloginAccount({ username: "userD", cookies: D2, userId: "cli" });
432
+ check("retired → 重登录复活", r.revived === true && r.account.status === "pool");
433
+ }
434
+
435
+ console.log("\n[10] 巡检 healthTargets/recordHealth/checkPoolAccounts(注入假探测)");
436
+ {
437
+ for (const a of getLoginPoolView().accounts) {
438
+ try { releaseAccount({ id: a.id }); } catch {}
439
+ try { updateAccount({ id: a.id, status: "retired" }); } catch {}
440
+ try { deleteAccount(a.id); } catch {}
441
+ }
442
+ const mk = (sid) => [
443
+ { name: "sessionid", value: sid, domain: ".tiktok.com", expires: Math.floor(Date.now() / 1000) + 86400 },
444
+ { name: "unique_id", value: "u-" + sid, domain: ".tiktok.com" },
445
+ ];
446
+ importAccount({ userId: "F-9", username: "u-alive", cookies: mk("alive-1") });
447
+ importAccount({ userId: "F-9", username: "u-dead", cookies: mk("dead-1") });
448
+ const retired = importAccount({ userId: "F-9", username: "u-retired", cookies: mk("ret-1") });
449
+ updateAccount({ id: retired.account.id, status: "retired" });
450
+
451
+ const targets = healthTargets();
452
+ check("巡检目标默认含 pool/suspect 排除 retired", targets.length === 2 && targets.every((t) => t.username !== "u-retired"));
453
+ check("巡检目标带 cookies/proxyHint", targets.every((t) => Array.isArray(t.cookies) && t.cookies.length > 0));
454
+
455
+ // 假探测:u-alive 判活,u-dead 判死(带标记 cookie 验证探测拿到的是池内 cookies)
456
+ const fakeProbe = async (cookies) => {
457
+ const isAlive = JSON.stringify(cookies).includes("alive-1");
458
+ check(isAlive ? "探测[活]拿到的是池内 cookies" : "探测[死]拿到的是池内 cookies", Array.isArray(cookies));
459
+ return isAlive
460
+ ? { alive: true, username: "u-alive", expired: false, httpStatus: 200, error: null, ms: 5 }
461
+ : { alive: false, username: null, expired: true, httpStatus: 200, error: null, ms: 5 };
462
+ };
463
+ const sum = await checkPoolAccounts({ probeFn: fakeProbe, pacingMs: 0 });
464
+ check("巡检计数 alive/dead", sum.checked === 2 && sum.alive === 1 && sum.dead === 1);
465
+ check("死号自动 pool→suspect", sum.migrated === 1 && sum.results.find((x) => x.username === "u-dead").migrated === true);
466
+
467
+ const deadAcc = getLoginPoolView().accounts.find((a) => a.username === "u-dead");
468
+ check("死号 lastHealth=dead + 时间戳", deadAcc.lastHealth === "dead" && deadAcc.lastHealthAt > 0);
469
+ check("死号历史含 health 事件", (deadAcc.history || []).some((h) => h.event === "health"));
470
+ const aliveAcc = getLoginPoolView().accounts.find((a) => a.username === "u-alive");
471
+ check("活号不写 history(防刷屏)", !(aliveAcc.history || []).some((h) => h.event === "health"));
472
+
473
+ acquireAccount({ userId: "T2", port: 9401 }); // 租走最久未用的(u-alive 或 u-dead 中 LRU 者)
474
+ const leased = getLoginPoolView().accounts.find((a) => a.status === "leased");
475
+ const rec = recordHealth({ id: leased.id, ok: "dead", detail: "单测" });
476
+ check("leased 死号仅记录不迁移状态", rec.status === "leased" && leased.username === rec.username);
477
+
478
+ const notLeased = getLoginPoolView().accounts.find((a) => a.status !== "leased").username;
479
+ const prep = prepareRelogin({ username: notLeased });
480
+ check("prepare 返回明文 cookies", Array.isArray(prep.account.cookies) && prep.account.cookies.length > 0);
481
+ let threw = false;
482
+ try { prepareRelogin({ username: leased.username }); } catch (e) { threw = /使用中/.test(e.message); }
483
+ check("prepare 使用中拒绝", threw);
484
+ threw = false;
485
+ try { prepareRelogin({ username: "ghost" }); } catch (e) { threw = /不在池中/.test(e.message); }
486
+ check("prepare 不在池拒绝", threw);
487
+ }
488
+
380
489
  console.log(`\n═══ 结果: ${pass} 通过, ${fail} 失败 ═══`);
381
490
  process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,207 @@
1
+ /**
2
+ * handleLoginPool — 账号中心一条命令:新登录收号 / 重登录 / 巡检触发
3
+ *
4
+ * 用法:
5
+ * tt-help login-pool add [--server URL] [--proxy URL|--no-proxy] [--note S] [--dir D] [--timeout S]
6
+ * tt-help login-pool relogin <username> [--server URL] [--proxy URL|--no-proxy] [--dir D] [--timeout S]
7
+ * tt-help login-pool check [--server URL] [--proxy URL] [--ids id1,id2] [--json]
8
+ *
9
+ * add: 起独立 Edge(临时目录+代理)→ 人工在窗口里登录 TikTok → 轮询 passport 检出
10
+ * → 导出 cookies → POST /api/login-pool/import(幂等,同号跳过)
11
+ * relogin: POST /prepare 取池内旧 cookies → 注入本机 Edge 试复活(快路径零操作)
12
+ * → 会话已死则提示人工在窗口登录目标账号 → 身份校验 → POST /relogin 覆盖刷新
13
+ * check: 服务端无浏览器 HTTP 探测各号登录态(默认空闲+疑似),死号自动标疑似
14
+ *
15
+ * 协议桥(页面一键拉起本命令)为独立后续项,本命令即其执行端。
16
+ */
17
+ import { mkdtempSync, rmSync } from "fs";
18
+ import { tmpdir } from "os";
19
+ import { join } from "path";
20
+ import { chromium } from "playwright";
21
+ import { gotoTiktok, whoAmI } from "../lib/login-pool-client.js";
22
+ import { proxy as configuredProxy } from "../lib/constants.js";
23
+
24
+ const POLL_INTERVAL_MS = 3000;
25
+ const CDP_FALLBACK_NOTE = "(关掉窗口或 Ctrl+C 可中止)";
26
+
27
+ function log(t) {
28
+ console.error(`[登录池] ${t}`);
29
+ }
30
+
31
+ /** HTTP POST,默认 30s 超时(check 场景调用方自带更长超时) */
32
+ async function post(base, path, body, timeoutMs = 30 * 1000) {
33
+ const res = await fetch(`${String(base).replace(/\/+$/, "")}/api/login-pool/${path}`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify(body || {}),
37
+ signal: AbortSignal.timeout(timeoutMs),
38
+ });
39
+ const j = await res.json().catch(() => null);
40
+ if (!res.ok || !j || j.error) throw new Error((j && j.error) || `HTTP ${res.status}`);
41
+ return j;
42
+ }
43
+
44
+ /** 起独立 Edge 会话(launchPersistentContext: 跨平台自动找 Edge;临时目录随用随清) */
45
+ async function openEdgeSession({ proxy, dir } = {}) {
46
+ const userDataDir = dir || mkdtempSync(join(tmpdir(), "tt-login-pool-"));
47
+ const context = await chromium.launchPersistentContext(userDataDir, {
48
+ channel: "msedge",
49
+ headless: false,
50
+ ...(proxy ? { proxy: { server: proxy } } : {}),
51
+ viewport: { width: 1280, height: 900 },
52
+ args: ["--no-first-run", "--no-default-browser-check", "--disable-sync"],
53
+ });
54
+ const page = context.pages()[0] || (await context.newPage());
55
+ return {
56
+ context,
57
+ page,
58
+ userDataDir,
59
+ cleanup: async () => {
60
+ await context.close().catch(() => {}); // persistent context close = 关浏览器
61
+ if (!dir) rmSync(userDataDir, { recursive: true, force: true });
62
+ },
63
+ };
64
+ }
65
+
66
+ /** 轮询等登录:注入 preCookies(重登录快路径)后循环 passport 检测 */
67
+ async function waitForLogin({ page, preCookies, toPlaywrightCookies, timeoutMs }) {
68
+ const t0 = Date.now();
69
+ if (preCookies) {
70
+ await page.context().addCookies(preCookies);
71
+ }
72
+ await gotoTiktok(page);
73
+ for (;;) {
74
+ const id = await whoAmI(page);
75
+ if (id.hasAccount) return id;
76
+ if (Date.now() - t0 > timeoutMs) {
77
+ throw new Error(`等待登录超时(${Math.round(timeoutMs / 1000)}s)${CDP_FALLBACK_NOTE}`);
78
+ }
79
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
80
+ }
81
+ }
82
+
83
+ /** add 子命令 */
84
+ async function cmdAdd(opts) {
85
+ const { serverUrl, proxy, note, timeoutMs, dir } = opts;
86
+ log("启动 Edge,请在打开的窗口里登录 TikTok(等待中,随时可关窗口中止)…");
87
+ const session = await openEdgeSession({ proxy, dir });
88
+ try {
89
+ const id = await waitForLogin({ page: session.page, timeoutMs });
90
+ log(`检测到登录: @${id.username},导出 cookies 并入池…`);
91
+ const cookies = await session.context.cookies();
92
+ const r = await post(serverUrl, "import", {
93
+ userId: "cli-login-pool",
94
+ username: id.username,
95
+ cookies,
96
+ note: note || "命令行新登录收号",
97
+ });
98
+ if (r.dedup) {
99
+ log(`✓ @${id.username} 已在池,幂等跳过(不覆盖)`);
100
+ } else {
101
+ log(`✓ @${id.username} 新入池(空闲),共 ${r.account.cookieCount} 条 cookies`);
102
+ }
103
+ log(`完成。服务端: ${serverUrl}`);
104
+ } finally {
105
+ await session.cleanup();
106
+ }
107
+ }
108
+
109
+ /** relogin 子命令 */
110
+ async function cmdRelogin(opts) {
111
+ const { serverUrl, proxy, username, timeoutMs, dir } = opts;
112
+ 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 });
127
+ 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 });
137
+ }
138
+ if (id.username !== username) {
139
+ throw new Error(`身份不符:窗口登录的是 @${id.username},目标是 @${username},已中止(不更新池)`);
140
+ }
141
+ const cookies = await session.context.cookies();
142
+ const r = await post(serverUrl, "relogin", { username, cookies, userId: "cli-login-pool", note: "命令行重登录" });
143
+ log(
144
+ `✓ @${username} 重登录完成: cookies 已覆盖刷新(${r.account.cookieCount} 条)${r.revived ? ",已停用账号复活回池" : r.wasSuspect ? ",疑似已清回池" : ""}`,
145
+ );
146
+ } finally {
147
+ await session.cleanup();
148
+ }
149
+ }
150
+
151
+ /** check 子命令:触发服务端巡检(探测在服务端做,cookies 不出服务端) */
152
+ async function cmdCheck(opts) {
153
+ const { serverUrl, proxy, ids, json } = opts;
154
+ log(`触发服务端巡检${proxy ? `(出口 ${proxy})` : ""}…`);
155
+ const r = await post(serverUrl, "check", { ids: ids || undefined, proxy: proxy || undefined }, 15 * 60 * 1000);
156
+ if (json) {
157
+ console.log(JSON.stringify(r, null, 2));
158
+ return;
159
+ }
160
+ log(`巡检完成: 探测 ${r.checked},活 ${r.alive} / 死 ${r.dead} / 异常 ${r.error}${r.migrated ? `,死号标疑似 ${r.migrated} 个` : ""}`);
161
+ for (const x of r.results || []) {
162
+ if (x.health !== "alive") {
163
+ log(` ${x.health === "dead" ? "✗" : "?"} @${x.username} → ${x.status}${x.migrated ? "(pool→suspect)" : ""}`);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * @param {object} parsed parseLoginPoolArgs 结果
170
+ * { action, username, serverUrl, proxy(null=用配置默认), noProxy, note, dir, timeoutSec, ids, showHelp, json }
171
+ */
172
+ export async function handleLoginPool(parsed) {
173
+ const {
174
+ action,
175
+ username,
176
+ serverUrl,
177
+ proxy: proxyArg,
178
+ noProxy,
179
+ note,
180
+ dir,
181
+ timeoutSec = 300,
182
+ ids,
183
+ json,
184
+ showHelp,
185
+ } = parsed;
186
+ if (showHelp || !action) {
187
+ console.error(
188
+ [
189
+ "用法:",
190
+ " tt-help login-pool add [--server URL] [--proxy URL|--no-proxy] [--note S] [--dir D] [--timeout S]",
191
+ " tt-help login-pool relogin <username> [--server URL] [--proxy URL|--no-proxy] [--dir D] [--timeout S]",
192
+ " tt-help login-pool check [--server URL] [--proxy URL] [--ids id1,id2] [--json]",
193
+ "",
194
+ " add 新登录收号:弹出 Edge 人工登录 TikTok,检出后自动入池(幂等)",
195
+ " relogin 重登录:先试旧登录态复活(零操作),死了引导人工登录,身份校验后覆盖刷新",
196
+ " check 巡检:服务端 HTTP 探测各号登录态,死号自动标疑似",
197
+ ].join("\n"),
198
+ );
199
+ return;
200
+ }
201
+ const proxy = noProxy ? null : proxyArg || configuredProxy || null;
202
+ const opts = { serverUrl, proxy, note, dir, timeoutMs: timeoutSec * 1000, ids, json };
203
+ if (action === "add") return cmdAdd(opts);
204
+ if (action === "relogin") return cmdRelogin({ ...opts, username: username || parsed.username });
205
+ if (action === "check") return cmdCheck(opts);
206
+ throw new Error(`未知子命令: ${action}(支持 add / relogin / check)`);
207
+ }
package/src/lib/args.js CHANGED
@@ -1167,6 +1167,71 @@ function parseLoginCheckArgs(args) {
1167
1167
  };
1168
1168
  }
1169
1169
 
1170
+ function parseLoginPoolArgs(args) {
1171
+ let action = null;
1172
+ let username = null;
1173
+ let serverUrl = defaultServer;
1174
+ let proxy = null;
1175
+ let noProxy = false;
1176
+ let note = null;
1177
+ let dir = null;
1178
+ let timeoutSec = 300;
1179
+ let ids = null;
1180
+ let json = false;
1181
+ let showHelp = false;
1182
+
1183
+ for (let i = 0; i < args.length; i++) {
1184
+ const arg = args[i];
1185
+ if (arg === "--server" && i + 1 < args.length) {
1186
+ serverUrl = args[++i];
1187
+ } else if (arg === "--proxy" && i + 1 < args.length) {
1188
+ proxy = args[++i];
1189
+ } else if (arg === "--no-proxy") {
1190
+ noProxy = true;
1191
+ } else if (arg === "--note" && i + 1 < args.length) {
1192
+ note = args[++i];
1193
+ } else if (arg === "--dir" && i + 1 < args.length) {
1194
+ dir = args[++i];
1195
+ } else if (arg === "--timeout" && i + 1 < args.length) {
1196
+ timeoutSec = parseInt(args[++i], 10) || 300;
1197
+ } else if (arg === "--ids" && i + 1 < args.length) {
1198
+ ids = args[++i].split(",").map((s) => s.trim()).filter(Boolean);
1199
+ } else if (arg === "--json") {
1200
+ json = true;
1201
+ } else if (arg === "-h" || arg === "--help") {
1202
+ showHelp = true;
1203
+ } else if (!arg.startsWith("-")) {
1204
+ if (!action) action = arg;
1205
+ else if (!username) username = arg;
1206
+ }
1207
+ }
1208
+
1209
+ return {
1210
+ subcommand: "login-pool",
1211
+ action,
1212
+ username,
1213
+ serverUrl,
1214
+ proxy,
1215
+ noProxy,
1216
+ note,
1217
+ dir,
1218
+ timeoutSec,
1219
+ ids,
1220
+ json,
1221
+ showHelp,
1222
+ urls: [],
1223
+ outputFormat: "json",
1224
+ exploreCount: 0,
1225
+ showConfig: false,
1226
+ showVersion: false,
1227
+ customProxy: null,
1228
+ configAction: null,
1229
+ configValue: null,
1230
+ pipeMode: false,
1231
+ filterStr: null,
1232
+ };
1233
+ }
1234
+
1170
1235
  function parseOpenArgs(args) {
1171
1236
  let openPort = null;
1172
1237
  let openList = false;
@@ -1721,6 +1786,10 @@ export function parseArgs() {
1721
1786
  return parseLoginCheckArgs(args.slice(1));
1722
1787
  }
1723
1788
 
1789
+ if (args.length > 0 && args[0] === "login-pool") {
1790
+ return parseLoginPoolArgs(args.slice(1));
1791
+ }
1792
+
1724
1793
  if (args.length > 0 && args[0] === "comments") {
1725
1794
  return parseCommentsArgs(args.slice(1));
1726
1795
  }
@@ -291,10 +291,21 @@ const HELP_TEXT = [
291
291
  " 示例: tt-help login-check 9222,9223,9224 --proxy http://127.0.0.1:7897",
292
292
  " tt-help login-check --json",
293
293
  "",
294
- " videostats <db路径> -p <并发数>",
295
- " 批量刷新视频统计数据(playCount, diggCount, commentCount, shareCount, collectCount)",
296
- " 直接读取并更新 SQLite videos 表",
297
- " -p, --parallel <N> 并发数(默认: 3)",
294
+ " tt-help login-pool <add|relogin|check> [选项]",
295
+ " 账号中心:新登录收号 / 重登录 / 巡检(与网页 /login-pool 同一套账号库)",
296
+ " add 弹出独立 Edge,人工登录 TikTok 后自动导出登录态入池(幂等)",
297
+ " relogin <username> 先试旧登录态复活(零操作),失效则引导人工登录,覆盖刷新池内 cookies",
298
+ " 选项:",
299
+ " --server <URL> 账号中心地址(watch 服务端)",
300
+ " --proxy <地址> Edge/探测代理,默认配置文件 proxy;--no-proxy 直连",
301
+ " --note <备注> add 备注信息",
302
+ " --dir <路径> 自定义 Edge 用户目录(默认临时目录用后即清)",
303
+ " --timeout <秒> 等待人工登录上限,默认 300",
304
+ " --ids <id1,id2> check 只探测指定账号",
305
+ " --json check 输出 JSON",
306
+ " 示例: tt-help login-pool add --server http://172.18.154.201:3011",
307
+ " tt-help login-pool relogin user8868337530661 --server http://172.18.154.201:3011",
308
+ " tt-help login-pool check --server http://172.18.154.201:3011 --proxy http://127.0.0.1:7897",
298
309
  " 示例: tt-help videostats data/result.db -p 3",
299
310
  "",
300
311
  " db-import --db <db路径> [--users users.json] [--done done.json] [--videos videos.json]",