tt-help-cli-ycl 1.4.177 → 1.4.179
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/test-login-pool-ui.mjs +22 -2
- package/scripts/test-login-pool.mjs +109 -0
- package/scripts/test-protocol-bridge.mjs +131 -0
- package/src/cli/login-pool.js +207 -0
- package/src/cli/protocol-bridge.js +274 -0
- package/src/lib/args.js +85 -0
- package/src/lib/constants.js +22 -4
- package/src/main.js +6 -0
- package/src/npm-main.js +6 -0
- package/src/watch/login-health.js +108 -0
- package/src/watch/login-pool.js +103 -0
- package/src/watch/public/app.js +29 -1
- package/src/watch/server.js +143 -34
package/package.json
CHANGED
|
@@ -150,14 +150,34 @@ 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("页面含协议桥四件套(protoHref/launchProto/blur 探测/降级 copyCmd)",
|
|
167
|
+
["function protoHref", "function launchProto", "addEventListener('blur'", "copyCmd("].every((s) => barSrc.includes(s)),
|
|
168
|
+
);
|
|
169
|
+
const protoUrl = await page.evaluate(() => protoHref("login-pool/relogin", { server: location.origin, username: "userA" }));
|
|
170
|
+
check("protoHref 构造协议 URL 正确(参数全编码)",
|
|
171
|
+
protoUrl === "tt-help://login-pool/relogin?server=" + encodeURIComponent(BASE) + "&username=userA", `got ${protoUrl}`);
|
|
172
|
+
const fallbackHtml = await page.evaluate(async () => {
|
|
173
|
+
launchProto("tt-help://login-pool/add?server=x", "CMD-FALLBACK-TEST", "ok");
|
|
174
|
+
await new Promise((r) => setTimeout(r, 2700));
|
|
175
|
+
return document.getElementById("msg").innerHTML;
|
|
176
|
+
});
|
|
177
|
+
check("未注册降级:2.5s 后出现复制按钮且命令正确嵌入", fallbackHtml.includes("copyCmd('CMD-FALLBACK-TEST')"), fallbackHtml.slice(0, 120));
|
|
178
|
+
check("模拟取号/入池粘cookies按钮已移除", !barSrc.includes("模拟取号") && !barSrc.includes("入池(粘cookies)"));
|
|
179
|
+
check("自动刷新无条件化(无勾选框)", !barSrc.includes('id="auto"'));
|
|
180
|
+
check("统计 chips 存在", await page.$$eval("#stats .chip", (cs) => cs.length >= 5));
|
|
161
181
|
|
|
162
182
|
// 有效期归一显示:应为 ~90 天后的日期(而非 1970)
|
|
163
183
|
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;
|
|
@@ -377,5 +383,108 @@ console.log("\n[8] 登录态导入 import");
|
|
|
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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议桥单元测试 — parseProtocolUrl 白名单/参数校验 + 双平台模板生成
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* [1] URL 解析:合法样例(大小写 normalize / origin 归一 / 尾斜杠 / note 清洗)
|
|
6
|
+
* [2] URL 拒绝:白名单外 action / 缺参 / 非 http server / username 非法 / scheme 不对
|
|
7
|
+
* [3] 模板生成:AppleScript(osacompile 源)与 Windows 包装 cmd 的结构断言
|
|
8
|
+
*
|
|
9
|
+
* 运行: node scripts/test-protocol-bridge.mjs
|
|
10
|
+
*/
|
|
11
|
+
import { strict as assert } from "assert";
|
|
12
|
+
import { parseProtocolUrl, buildAppleScript, buildWindowsCmd } from "../src/cli/protocol-bridge.js";
|
|
13
|
+
|
|
14
|
+
let pass = 0;
|
|
15
|
+
let fail = 0;
|
|
16
|
+
const check = (name, fn) => {
|
|
17
|
+
try {
|
|
18
|
+
fn();
|
|
19
|
+
pass++;
|
|
20
|
+
console.log(` ✓ ${name}`);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
fail++;
|
|
23
|
+
console.log(` ✗ ${name}: ${e.message}`);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ── [1] 合法样例 ──
|
|
28
|
+
console.log("[1] URL 解析(合法)");
|
|
29
|
+
check("add 最小样例:host/path 解析 + server 取 origin", () => {
|
|
30
|
+
const p = parseProtocolUrl("tt-help://login-pool/add?server=http%3A%2F%2F172.18.154.201%3A3001");
|
|
31
|
+
assert.equal(p.action, "login-pool/add");
|
|
32
|
+
assert.equal(p.serverUrl, "http://172.18.154.201:3001");
|
|
33
|
+
assert.equal(p.username, undefined);
|
|
34
|
+
});
|
|
35
|
+
check("hostname 大写自动 normalize(非特殊 scheme 不自动小写,实测坑)", () => {
|
|
36
|
+
const p = parseProtocolUrl("tt-help://LOGIN-POOL/RELOGIN?server=https://x.io&username=u1");
|
|
37
|
+
assert.equal(p.action, "login-pool/relogin");
|
|
38
|
+
});
|
|
39
|
+
check("pathname 尾斜杠归一", () => {
|
|
40
|
+
const p = parseProtocolUrl("tt-help://login-pool/add/?server=http://x.io");
|
|
41
|
+
assert.equal(p.action, "login-pool/add");
|
|
42
|
+
});
|
|
43
|
+
check("server 路径丢弃只留 origin", () => {
|
|
44
|
+
const p = parseProtocolUrl("tt-help://login-pool/add?server=http%3A%2F%2Fx.io%2Fevil%2Fpath%3Fq%3D1");
|
|
45
|
+
assert.equal(p.serverUrl, "http://x.io");
|
|
46
|
+
});
|
|
47
|
+
check("username 字符集:字母数字点下划线", () => {
|
|
48
|
+
const p = parseProtocolUrl("tt-help://login-pool/relogin?server=http://x.io&username=user_1.2");
|
|
49
|
+
assert.equal(p.username, "user_1.2");
|
|
50
|
+
});
|
|
51
|
+
check("note 控制符清洗 + 200 限长", () => {
|
|
52
|
+
const long = "a".repeat(500);
|
|
53
|
+
const p = parseProtocolUrl(
|
|
54
|
+
"tt-help://login-pool/add?server=http://x.io¬e=" +
|
|
55
|
+
encodeURIComponent("备\n注\x00" + long),
|
|
56
|
+
);
|
|
57
|
+
assert.equal(p.note, "备注" + "a".repeat(198));
|
|
58
|
+
assert.ok(!p.note.includes("\x00"));
|
|
59
|
+
});
|
|
60
|
+
check("未知参数静默忽略", () => {
|
|
61
|
+
const p = parseProtocolUrl("tt-help://login-pool/add?server=http://x.io&proxy=http://evil&dir=/etc");
|
|
62
|
+
assert.equal(p.proxy, undefined);
|
|
63
|
+
assert.equal(p.dir, undefined);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── [2] 拒绝样例 ──
|
|
67
|
+
console.log("[2] URL 解析(拒绝)");
|
|
68
|
+
const rejects = [
|
|
69
|
+
["白名单外 action(check 不开放)", "tt-help://login-pool/check?server=http://x.io"],
|
|
70
|
+
["任意命令形 action(防任意执行入口)", "tt-help://config/set?server=http://x.io"],
|
|
71
|
+
["路径注入形 action", "tt-help://../../win.ini?server=http://x.io"],
|
|
72
|
+
["scheme 不对", "https://login-pool/add?server=http://x.io"],
|
|
73
|
+
["缺 server", "tt-help://login-pool/add"],
|
|
74
|
+
["server 非 http(s)", "tt-help://login-pool/add?server=ftp://x.io"],
|
|
75
|
+
["server 垃圾串", "tt-help://login-pool/add?server=not-a-url"],
|
|
76
|
+
["relogin 缺 username", "tt-help://login-pool/relogin?server=http://x.io"],
|
|
77
|
+
["username 空格", "tt-help://login-pool/relogin?server=http://x.io&username=a%20b"],
|
|
78
|
+
["username 命令注入字符", "tt-help://login-pool/relogin?server=http://x.io&username=a%3Brm"],
|
|
79
|
+
["username 超长", "tt-help://login-pool/relogin?server=http://x.io&username=" + "a".repeat(65)],
|
|
80
|
+
["空 URL", ""],
|
|
81
|
+
["null", null],
|
|
82
|
+
];
|
|
83
|
+
for (const [why, raw] of rejects) {
|
|
84
|
+
check(`拒绝: ${why}`, () => {
|
|
85
|
+
assert.throws(() => parseProtocolUrl(raw), Error);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── [3] 模板生成 ──
|
|
90
|
+
console.log("[3] 双平台模板生成");
|
|
91
|
+
const NODE = "/Users/x/.nvm/versions/node/v24.14.1/bin/node";
|
|
92
|
+
const CLI = "/Users/x/pkg/tt-help-cli-ycl/cli.js";
|
|
93
|
+
check("AppleScript: open location 处理器 + quoted form 拼接", () => {
|
|
94
|
+
const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
|
|
95
|
+
assert.ok(src.startsWith("on open location theURL"), "必须有 open location 处理器");
|
|
96
|
+
assert.ok(src.includes("quoted form of theURL"), "URL 必须经 quoted form 传递");
|
|
97
|
+
assert.ok(src.includes("open -a Terminal"), "必须拉起 Terminal");
|
|
98
|
+
assert.ok(src.includes("'#!/bin/bash"), "必须生成 .command(shebang)");
|
|
99
|
+
});
|
|
100
|
+
check("AppleScript: 生成源可通过 osacompile 语法结构(do shell script 成对引号)", () => {
|
|
101
|
+
const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
|
|
102
|
+
const m = src.match(/do shell script ("(?:[^"\\]|\\.)*")/);
|
|
103
|
+
assert.ok(m, "do shell script 后必须是合法 AppleScript 字符串字面量");
|
|
104
|
+
// 拼接表达式收尾
|
|
105
|
+
assert.ok(/& quoted form of theURL & "(?:[^"\\]|\\.)*"\s*\nend open location/.test(src), "拼接式必须完整闭合");
|
|
106
|
+
});
|
|
107
|
+
check("AppleScript: node 路径烧录且单引号包裹", () => {
|
|
108
|
+
const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
|
|
109
|
+
assert.ok(src.includes(`'${NODE}'`), "node 绝对路径必须烧录");
|
|
110
|
+
assert.ok(src.includes(`'${CLI}'`), "cli.js 绝对路径必须烧录");
|
|
111
|
+
});
|
|
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'"), "含空格路径必须在单引号内");
|
|
115
|
+
});
|
|
116
|
+
check("AppleScript: .command 退出码保持 + 按键防闪退", () => {
|
|
117
|
+
const src = buildAppleScript({ nodePath: NODE, cliPath: CLI });
|
|
118
|
+
assert.ok(src.includes("st=$?"), "必须捕获退出码");
|
|
119
|
+
assert.ok(src.includes("read -n 1"), "必须按键保持窗口");
|
|
120
|
+
});
|
|
121
|
+
check("Windows cmd: 绝对路径 + %1 引号约定 + pause", () => {
|
|
122
|
+
const cmd = buildWindowsCmd({ nodePath: "C:\\n\\node.exe", cliPath: "C:\\p\\cli.js" });
|
|
123
|
+
assert.ok(cmd.startsWith("@echo off"), "必须关回显");
|
|
124
|
+
assert.ok(cmd.includes('"C:\\n\\node.exe" "C:\\p\\cli.js" protocol-handle %1'), "参数必须是单变量 %1");
|
|
125
|
+
assert.ok(cmd.includes("pause"), "必须 pause 防闪退");
|
|
126
|
+
assert.ok(cmd.includes("exit /b %RC%"), "必须透传退出码");
|
|
127
|
+
assert.ok(cmd.includes("\r\n"), "必须是 CRLF");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
131
|
+
process.exit(fail ? 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
|
+
}
|