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 +1 -1
- package/scripts/deploy-node.sh +48 -24
- package/scripts/test-login-import-skip.mjs +52 -0
- package/scripts/test-login-pool-ui.mjs +11 -2
- package/scripts/test-login-pool.mjs +110 -1
- package/src/cli/login-pool.js +207 -0
- package/src/lib/args.js +69 -0
- package/src/lib/constants.js +15 -4
- package/src/main.js +3 -0
- package/src/npm-main.js +3 -0
- package/src/watch/login-health.js +108 -0
- package/src/watch/login-pool.js +111 -2
- package/src/watch/public/app.js +29 -1
- package/src/watch/server.js +123 -34
- package/src/watchdog/login-import.js +43 -28
package/package.json
CHANGED
package/scripts/deploy-node.sh
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# 将本地 tiktok-cli
|
|
2
|
+
# 将本地 tiktok-cli 打包部署到采集节点(不经 npm registry)—— npm publish 前真机测试标准通道
|
|
3
3
|
# 用法:
|
|
4
|
-
# ./scripts/deploy-node.sh
|
|
5
|
-
# ./scripts/deploy-node.sh tiktok-210
|
|
6
|
-
# ./scripts/deploy-node.sh
|
|
7
|
-
#
|
|
8
|
-
# 流程: (可选)升版本号 → npm pack → scp → 远程 npm install -g → 校验 → (可选)
|
|
9
|
-
# 前提: ~/.ssh/config 已配置节点别名(如 tiktok-210
|
|
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 "已传输到
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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("
|
|
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("
|
|
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
|
}
|
package/src/lib/constants.js
CHANGED
|
@@ -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
|
-
"
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
"
|
|
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]",
|