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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * handleProtocolBridge — 协议桥:tt-help:// 自定义协议(网页 /login-pool 一键拉起本机命令)
3
+ *
4
+ * 用法:
5
+ * tt-help protocol register 注册本机协议处理器(幂等,可重复执行)
6
+ * tt-help protocol unregister 注销协议处理器
7
+ * tt-help protocol handle <URL> 内部入口:处理器被系统拉起时回调(勿手动执行)
8
+ *
9
+ * 链路(2026-10-29 设计定稿,macOS 路线已经真机 POC):
10
+ * 页面按钮 → tt-help://login-pool/<add|relogin>?server=…&username=…
11
+ * → OS 协议处理器
12
+ * ├─ Windows: HKCU\Software\Classes\tt-help → 包内 scripts/protocol-bridge.cmd(新控制台窗+pause)
13
+ * └─ macOS: ~/Applications/tt-help.app(osacompile AppleScript applet,open location 收 URL
14
+ * —— shell 型 .app 收 argv 已实测不通:LS 只发 AppleEvent,argc=0)
15
+ * → tt-help protocol handle <URL> → 白名单校验 → 进程内直调 handleLoginPool(全程不过 shell)
16
+ *
17
+ * 安全: action 白名单只认 login-pool/add、login-pool/relogin;参数只透传
18
+ * server(origin)/username(字符集白名单)/note(去控制符限长);proxy/dir/timeout 不开放。
19
+ */
20
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
21
+ import { homedir, platform, tmpdir } from "os";
22
+ import { join } from "path";
23
+ import { spawnSync } from "child_process";
24
+ import { fileURLToPath } from "url";
25
+ import { handleLoginPool } from "./login-pool.js";
26
+
27
+ const SCHEME = "tt-help";
28
+ const ALLOWED_ACTIONS = new Set(["login-pool/add", "login-pool/relogin"]);
29
+ const MAC_APP_DIR = join(homedir(), "Applications");
30
+ const MAC_APP = join(MAC_APP_DIR, "tt-help.app");
31
+ const BUNDLE_ID = "com.ycl.tt-help";
32
+ const WIN_REG_KEY = `HKCU\\Software\\Classes\\${SCHEME}`;
33
+
34
+ function log(t) {
35
+ console.error(`[协议桥] ${t}`);
36
+ }
37
+
38
+ /** POSIX 单引号转义(用于 .command / do shell script 中的路径与 URL) */
39
+ function shQ(s) {
40
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
41
+ }
42
+
43
+ /** package 根目录(本文件位于 <pkg>/src/cli/) */
44
+ function pkgRoot() {
45
+ return fileURLToPath(new URL("../..", import.meta.url));
46
+ }
47
+
48
+ // ────────────────────────── URL 解析(handle 入口) ──────────────────────────
49
+
50
+ /**
51
+ * 解析并校验协议 URL → { action, serverUrl, username?, note? }
52
+ * 注意: 非特殊 scheme 的 URL.hostname 不自动小写,必须显式 normalize(实测 LOGIN-POOL 原样保留)
53
+ */
54
+ export function parseProtocolUrl(raw) {
55
+ if (!raw) throw new Error("缺少 URL 参数");
56
+ let u;
57
+ try {
58
+ u = new URL(String(raw).trim());
59
+ } catch {
60
+ throw new Error(`URL 无法解析: ${String(raw).slice(0, 120)}`);
61
+ }
62
+ if (u.protocol !== `${SCHEME}:`) throw new Error(`不支持的协议: ${u.protocol}(只认 ${SCHEME}://)`);
63
+ const action = `${u.hostname}${u.pathname.replace(/\/+$/, "")}`.toLowerCase();
64
+ if (!ALLOWED_ACTIONS.has(action)) {
65
+ throw new Error(`不支持的 action: ${action}(白名单: ${[...ALLOWED_ACTIONS].join(", ")})`);
66
+ }
67
+ const server = u.searchParams.get("server") || "";
68
+ let s;
69
+ try {
70
+ s = new URL(server);
71
+ } catch {
72
+ throw new Error("server 参数缺失或非法(需完整 http(s) URL)");
73
+ }
74
+ if (s.protocol !== "http:" && s.protocol !== "https:") throw new Error("server 仅支持 http/https");
75
+ const out = { action, serverUrl: s.origin };
76
+ if (action === "login-pool/relogin") {
77
+ const username = u.searchParams.get("username") || "";
78
+ if (!/^[A-Za-z0-9._]{1,64}$/.test(username)) throw new Error(`username 参数缺失或含非法字符: "${username.slice(0, 40)}"`);
79
+ out.username = username;
80
+ }
81
+ const note = (u.searchParams.get("note") || "").replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
82
+ if (note) out.note = note;
83
+ return out;
84
+ }
85
+
86
+ /** handle 入口:解析 URL → 进程内直调 handleLoginPool(不经过任何 shell) */
87
+ async function cmdHandle(rawUrl) {
88
+ let p;
89
+ try {
90
+ p = parseProtocolUrl(rawUrl);
91
+ } catch (e) {
92
+ log(`✗ ${e.message}`);
93
+ throw e;
94
+ }
95
+ log(`收到 ${p.action}(server=${p.serverUrl}${p.username ? `,@${p.username}` : ""})`);
96
+ const parsed = {
97
+ subcommand: "login-pool",
98
+ action: p.action.split("/")[1],
99
+ username: p.username || null,
100
+ serverUrl: p.serverUrl,
101
+ proxy: null, // 走本机 ~/.tt-help.json 默认(与命令行行为一致)
102
+ noProxy: false,
103
+ note: p.note || null,
104
+ dir: null,
105
+ timeoutSec: 300,
106
+ ids: null,
107
+ json: false,
108
+ showHelp: false,
109
+ };
110
+ await handleLoginPool(parsed);
111
+ }
112
+
113
+ // ────────────────────────── macOS: osacompile applet ──────────────────────────
114
+
115
+ /** 生成 .applescript 源:open location → 建 .command(烧录绝对路径)→ open -a Terminal */
116
+ export function buildAppleScript({ nodePath, cliPath }) {
117
+ const asStr = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
118
+ // .command 内容: #!/bin/bash + 跑 node(不 exec,退出码非 0 时窗口必留)+ 按键保持
119
+ const shellP1 =
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' " +
122
+ `${shQ(nodePath)} ${shQ(cliPath)} `;
123
+ const shellP2 = ' > "$f"; chmod +x "$f"; /usr/bin/open -a Terminal "$f"';
124
+ return (
125
+ "on open location theURL\n" +
126
+ " do shell script " + asStr(shellP1) + " & quoted form of theURL & " + asStr(shellP2) + "\n" +
127
+ "end open location\n"
128
+ );
129
+ }
130
+
131
+ const MAC_LSREG =
132
+ "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
133
+ const MAC_PLISTBUDDY = "/usr/libexec/PlistBuddy";
134
+
135
+ function macRun(cmd, args, { check = true } = {}) {
136
+ const r = spawnSync(cmd, args, { encoding: "utf-8" });
137
+ if (check && r.status !== 0) {
138
+ throw new Error(`${cmd} 失败(退出码 ${r.status}): ${(r.stderr || r.stdout || "").slice(0, 300)}`);
139
+ }
140
+ return r;
141
+ }
142
+
143
+ /** PlistBuddy 设值:存在则 Set,不存在则 Add */
144
+ function plistSet(plist, key, type, value) {
145
+ let r = macRun(MAC_PLISTBUDDY, ["-c", `Set :${key} ${value}`, plist], { check: false });
146
+ if (r.status !== 0) {
147
+ r = macRun(MAC_PLISTBUDDY, ["-c", `Add :${key} ${type} ${value}`, plist], { check: false });
148
+ if (r.status !== 0) throw new Error(`PlistBuddy 写入 ${key} 失败: ${(r.stderr || "").slice(0, 200)}`);
149
+ }
150
+ }
151
+
152
+ function registerDarwin() {
153
+ const osacompile = "/usr/bin/osacompile";
154
+ if (!existsSync(osacompile)) throw new Error("找不到 /usr/bin/osacompile(macOS 系统自带,异常情况)");
155
+ const nodePath = process.execPath;
156
+ const cliPath = join(pkgRoot(), "cli.js");
157
+ if (!existsSync(cliPath)) throw new Error(`找不到 cli.js: ${cliPath}`);
158
+
159
+ mkdirSync(MAC_APP_DIR, { recursive: true });
160
+ const src = join(mkdtempSync(join(tmpdir(), "tt-help-reg-")), "tt-help.applescript");
161
+ writeFileSync(src, buildAppleScript({ nodePath, cliPath }));
162
+
163
+ log(`编译 applet(node=${nodePath})…`);
164
+ rmSync(MAC_APP, { recursive: true, force: true });
165
+ macRun(osacompile, ["-o", MAC_APP, src]);
166
+
167
+ const plist = join(MAC_APP, "Contents", "Info.plist"); // PlistBuddy 只认 plist 文件,不认 .app 目录
168
+ plistSet(plist, "CFBundleIdentifier", "string", BUNDLE_ID);
169
+ plistSet(plist, "CFBundleName", "string", "tt-help");
170
+ plistSet(plist, "LSUIElement", "bool", "true"); // 不占 Dock
171
+ // URL scheme 声明(POC 验证过的 PlistBuddy 注入路径)
172
+ macRun(MAC_PLISTBUDDY, ["-c", "Add :CFBundleURLTypes array", plist], { check: false });
173
+ macRun(MAC_PLISTBUDDY, ["-c", "Add :CFBundleURLTypes:0 dict", plist], { check: false });
174
+ plistSet(plist, "CFBundleURLTypes:0:CFBundleURLName", "string", BUNDLE_ID);
175
+ macRun(MAC_PLISTBUDDY, ["-c", "Add :CFBundleURLTypes:0:CFBundleURLSchemes array", plist], { check: false });
176
+ macRun(MAC_PLISTBUDDY, ["-c", `Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string ${SCHEME}`, plist], { check: false });
177
+ macRun("/usr/bin/codesign", ["--force", "--deep", "-s", "-", MAC_APP], { check: false }); // ad-hoc 重签,尽力而为
178
+
179
+ macRun(MAC_LSREG, ["-f", MAC_APP]);
180
+ rmSync(src, { force: true });
181
+ rmSync(join(src, ".."), { recursive: true, force: true });
182
+ log(`✓ 已注册 ${SCHEME}:// 协议处理器: ${MAC_APP}`);
183
+ log(" 验证: 浏览器打开 /login-pool 页面点「新增账号」即可一键拉起;或执行:");
184
+ log(` open "${SCHEME}://login-pool/add?server=http%3A%2F%2F127.0.0.1%3A3001"`);
185
+ }
186
+
187
+ function unregisterDarwin() {
188
+ if (!existsSync(MAC_APP)) {
189
+ log(`未发现已注册的处理器(${MAC_APP} 不存在),无需注销`);
190
+ return;
191
+ }
192
+ macRun(MAC_LSREG, ["-u", MAC_APP], { check: false });
193
+ rmSync(MAC_APP, { recursive: true, force: true });
194
+ log(`✓ 已注销并删除 ${MAC_APP}`);
195
+ }
196
+
197
+ // ────────────────────────── Windows: HKCU + 包内包装 cmd ──────────────────────────
198
+
199
+ /**
200
+ * 生成包装 cmd 内容(register 时写进包 scripts/ 目录,npm 升级原地覆盖,注册表路径永不变)
201
+ * %1 恒由注册表带引号传入 + URL 参数百分号编码 = 无注入面;pause 保证最终结果不闪退
202
+ */
203
+ export function buildWindowsCmd({ nodePath, cliPath }) {
204
+ return [
205
+ "@echo off",
206
+ "title tt-help 登录池",
207
+ `"${nodePath}" "${cliPath}" protocol-handle %1`,
208
+ "set RC=%ERRORLEVEL%",
209
+ "echo.",
210
+ `pause`,
211
+ "exit /b %RC%",
212
+ "",
213
+ ].join("\r\n");
214
+ }
215
+
216
+ function registerWindows() {
217
+ const nodePath = process.execPath;
218
+ const cliPath = join(pkgRoot(), "cli.js");
219
+ const wrapperPath = join(pkgRoot(), "scripts", "protocol-bridge.cmd");
220
+ writeFileSync(wrapperPath, buildWindowsCmd({ nodePath, cliPath }), "utf-8");
221
+
222
+ const regAdd = (args) => macRun("reg", ["add", ...args]);
223
+ regAdd([WIN_REG_KEY, "/ve", "/d", `URL:${SCHEME}`, "/f"]);
224
+ regAdd([WIN_REG_KEY, "/v", "URL Protocol", "/d", "", "/f"]);
225
+ regAdd([`${WIN_REG_KEY}\\shell\\open\\command`, "/ve", "/d", `"${wrapperPath}" "%1"`, "/f"]);
226
+ log(`✓ 已注册 ${SCHEME}:// 协议处理器(HKCU,免管理员): ${wrapperPath}`);
227
+ log(" 验证: 浏览器打开 /login-pool 页面点「新增账号」;首次系统会弹「打开 tt-help?」确认框");
228
+ }
229
+
230
+ function unregisterWindows() {
231
+ const r = macRun("reg", ["delete", WIN_REG_KEY, "/f"], { check: false });
232
+ if (r.status !== 0) {
233
+ log(`未发现已注册的处理器(注册表 ${WIN_REG_KEY} 不存在),无需注销`);
234
+ return;
235
+ }
236
+ log(`✓ 已注销注册表项 ${WIN_REG_KEY}(包装 cmd 随包升级/卸载自然消失)`);
237
+ }
238
+
239
+ // ────────────────────────── 入口 ──────────────────────────
240
+
241
+ /**
242
+ * @param {object} parsed parseProtocolBridgeArgs 结果 { action, url, showHelp }
243
+ */
244
+ export async function handleProtocolBridge(parsed) {
245
+ const { action, url, showHelp } = parsed;
246
+ if (showHelp || !action) {
247
+ console.error(
248
+ [
249
+ "用法:",
250
+ ` tt-help protocol register 注册 ${SCHEME}:// 协议处理器(幂等)`,
251
+ ` tt-help protocol unregister 注销协议处理器`,
252
+ ` tt-help protocol handle <URL> 内部命令:处理器回调入口(勿手动执行)`,
253
+ "",
254
+ " 协议桥 = 网页 /login-pool「新增账号/重登录」按钮一键拉起本机 tt-help 命令。",
255
+ " 注册一次后,页面按钮直接弹出登录窗口(Windows/macOS;Linux 后置)。",
256
+ ].join("\n"),
257
+ );
258
+ return;
259
+ }
260
+ if (action === "handle") return cmdHandle(url);
261
+ if (action === "register") {
262
+ if (platform() === "darwin") return registerDarwin();
263
+ if (platform() === "win32") return registerWindows();
264
+ log("Linux 平台暂不支持(后置)。协议桥面向操作员桌面机(Windows/macOS)。");
265
+ return;
266
+ }
267
+ if (action === "unregister") {
268
+ if (platform() === "darwin") return unregisterDarwin();
269
+ if (platform() === "win32") return unregisterWindows();
270
+ log("Linux 平台暂不支持(后置)。");
271
+ return;
272
+ }
273
+ throw new Error(`未知子命令: ${action}(支持 register / unregister / handle)`);
274
+ }
package/src/lib/args.js CHANGED
@@ -1167,6 +1167,83 @@ 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
+
1235
+ function parseProtocolBridgeArgs(args) {
1236
+ let action = null;
1237
+ let url = null;
1238
+ let showHelp = false;
1239
+ for (const arg of args) {
1240
+ if (arg === "-h" || arg === "--help") showHelp = true;
1241
+ else if (!action) action = arg;
1242
+ else if (!url) url = arg;
1243
+ }
1244
+ return { subcommand: "protocol", action, url, showHelp };
1245
+ }
1246
+
1170
1247
  function parseOpenArgs(args) {
1171
1248
  let openPort = null;
1172
1249
  let openList = false;
@@ -1721,6 +1798,14 @@ export function parseArgs() {
1721
1798
  return parseLoginCheckArgs(args.slice(1));
1722
1799
  }
1723
1800
 
1801
+ if (args.length > 0 && args[0] === "login-pool") {
1802
+ return parseLoginPoolArgs(args.slice(1));
1803
+ }
1804
+
1805
+ if (args.length > 0 && args[0] === "protocol") {
1806
+ return parseProtocolBridgeArgs(args.slice(1));
1807
+ }
1808
+
1724
1809
  if (args.length > 0 && args[0] === "comments") {
1725
1810
  return parseCommentsArgs(args.slice(1));
1726
1811
  }
@@ -291,12 +291,30 @@ 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
  "",
311
+ " tt-help protocol <register|unregister|handle> [URL]",
312
+ " 协议桥:注册 tt-help:// 协议处理器,网页 /login-pool 按钮一键拉起本机命令(Windows/macOS)",
313
+ " register 注册处理器(幂等;Windows 写 HKCU,macOS 生成 ~/Applications/tt-help.app)",
314
+ " unregister 注销处理器",
315
+ " handle <URL> 内部命令:处理器回调入口(系统拉起时调用,勿手动执行)",
316
+ " 示例: tt-help protocol register",
317
+ "",
300
318
  " db-import --db <db路径> [--users users.json] [--done done.json] [--videos videos.json]",
301
319
  " 将 legacy JSON 显式导入 SQLite;运行期不再自动读取 result*.json",
302
320
  " 示例: tt-help db-import --db data/result.db --users data/result.json --done data/result-done.json --videos data/result-videos.json",
package/src/main.js CHANGED
@@ -27,6 +27,8 @@ import { handleSearch } from "./cli/search.js";
27
27
  import { handleWatchdog } from "./cli/watchdog.js";
28
28
  import { handleClashDaemon } from "./cli/clash-daemon.js";
29
29
  import { handleLoginCheck } from "./cli/login-check.js";
30
+ import { handleLoginPool } from "./cli/login-pool.js";
31
+ import { handleProtocolBridge } from "./cli/protocol-bridge.js";
30
32
 
31
33
  async function main() {
32
34
  const parsed = parseArgs();
@@ -80,6 +82,10 @@ async function main() {
80
82
  return handleClashDaemon(parsed);
81
83
  case "login-check":
82
84
  return handleLoginCheck(parsed);
85
+ case "login-pool":
86
+ return handleLoginPool(parsed);
87
+ case "protocol":
88
+ return handleProtocolBridge(parsed);
83
89
  }
84
90
 
85
91
  const {
package/src/npm-main.js CHANGED
@@ -23,6 +23,8 @@ import { handleRawSync } from "./cli/raw-sync.js";
23
23
  import { handleSearch } from "./cli/search.js";
24
24
  import { handleWatchdog } from "./cli/watchdog.js";
25
25
  import { handleLoginCheck } from "./cli/login-check.js";
26
+ import { handleLoginPool } from "./cli/login-pool.js";
27
+ import { handleProtocolBridge } from "./cli/protocol-bridge.js";
26
28
 
27
29
  function exitUnsupportedCommand(command) {
28
30
  console.error(
@@ -79,6 +81,10 @@ async function main() {
79
81
  return handleWatchdog(parsed);
80
82
  case "login-check":
81
83
  return handleLoginCheck(parsed);
84
+ case "login-pool":
85
+ return handleLoginPool(parsed);
86
+ case "protocol":
87
+ return handleProtocolBridge(parsed);
82
88
  }
83
89
 
84
90
  const {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 登录态批量巡检(账号中心)— 服务端无浏览器纯 HTTP 探测
3
+ *
4
+ * 原理(POC 实证 2026-10-28,见知识库 worklog):
5
+ * undici + Cookie 头 + 真实 UA + 代理出口,直探 passport/web/account/info/。
6
+ * 浏览器里必须页面上下文 fetch(CORS),服务端无此限制。阳性对照(真实 cookies)
7
+ * 判活 / 阴性对照(垃圾 sessionid)判死均已验证,单次 ~700ms。
8
+ *
9
+ * 判据边界:探测只回答「登录态还活着吗」,不回答「号还能不能干活」——
10
+ * 风控限流/影子封禁只有真实业务出活能暴露。
11
+ */
12
+ import { request, ProxyAgent } from "undici";
13
+ import { healthTargets, recordHealth } from "./login-pool.js";
14
+
15
+ const ACCOUNT_INFO_URL = "https://www.tiktok.com/passport/web/account/info/";
16
+ const UA =
17
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36 Edg/155.0.0.0";
18
+ const DEFAULT_TIMEOUT_MS = 20 * 1000;
19
+ const DEFAULT_PACING_MS = 250;
20
+
21
+ /** proxyHint 归一化:纯数字 → http://127.0.0.1:<port>;http(s):// 开头 → 原样;其余忽略 */
22
+ function normalizeProxy(hint) {
23
+ const s = String(hint || "").trim();
24
+ if (!s) return null;
25
+ if (/^\d{2,5}$/.test(s)) return `http://127.0.0.1:${s}`;
26
+ if (/^https?:\/\//.test(s)) return s;
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * 无浏览器探测单个账号登录态
32
+ * @param {Array} cookies 池内 cookies 数组 [{name,value,domain?...}]
33
+ * @param {object} opts { proxy?: string|null, timeoutMs?: number }
34
+ * @returns {Promise<{alive: boolean, username: ?string, expired: boolean, httpStatus: number, error: ?string, ms: number}>}
35
+ */
36
+ export async function probeLogin(cookies, { proxy = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
37
+ const cookieHeader = (cookies || [])
38
+ .filter((c) => c && c.name && c.value != null)
39
+ .filter((c) => !c.domain || String(c.domain).includes("tiktok"))
40
+ .map((c) => `${c.name}=${c.value}`)
41
+ .join("; ");
42
+ const t0 = Date.now();
43
+ try {
44
+ const res = await request(ACCOUNT_INFO_URL, {
45
+ method: "GET",
46
+ headers: {
47
+ cookie: cookieHeader,
48
+ "user-agent": UA,
49
+ accept: "application/json",
50
+ "accept-language": "en-US,en;q=0.9",
51
+ },
52
+ ...(proxy ? { dispatcher: new ProxyAgent(proxy) } : {}),
53
+ headersTimeout: timeoutMs,
54
+ bodyTimeout: timeoutMs,
55
+ });
56
+ const body = await res.body.json().catch(() => null);
57
+ const ms = Date.now() - t0;
58
+ const d = (body && body.data) || null;
59
+ return {
60
+ httpStatus: res.status,
61
+ alive: !!d && !!(d.username || d.user_id || d.sec_user_id),
62
+ username: (d && d.username) || null,
63
+ expired: (d && d.name === "session_expired") || (body && body.error_code === 13),
64
+ error: body && !d ? JSON.stringify(body).slice(0, 120) : null,
65
+ ms,
66
+ };
67
+ } catch (e) {
68
+ return { httpStatus: 0, alive: false, username: null, expired: false, error: String(e).slice(0, 120), ms: Date.now() - t0 };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 批量巡检:默认探 pool+suspect(leased 由消费方验证兜底、retired 不探测),指定 ids 精确取。
74
+ * 结果逐号 recordHealth 落账(dead 且在池 → suspect);串行 + pacing,对 TikTok 友好。
75
+ * @param {object} opts { ids?: string[], proxy?: string|null(缺省用各号 proxyHint), pacingMs?, probeFn?(测试注入) }
76
+ * @returns {Promise<{checked: number, alive: number, dead: number, error: number, migrated: number, results: Array}>}
77
+ */
78
+ export async function checkPoolAccounts(opts = {}) {
79
+ const { ids, proxy = null, pacingMs = DEFAULT_PACING_MS, probeFn = probeLogin } = opts;
80
+ const targets = healthTargets({ ids });
81
+ const summary = { checked: 0, alive: 0, dead: 0, error: 0, migrated: 0, results: [] };
82
+ for (let i = 0; i < targets.length; i++) {
83
+ const t = targets[i];
84
+ const exit = proxy || normalizeProxy(t.proxyHint);
85
+ const r = await probeFn(t.cookies, { proxy: exit });
86
+ const ok = r.error ? "error" : r.alive ? "alive" : "dead";
87
+ let rec = { username: t.username, health: ok, status: t.status, ms: r.ms };
88
+ try {
89
+ const applied = recordHealth({
90
+ id: t.id,
91
+ ok,
92
+ detail: r.error ? `探测异常 ${r.error}` : r.alive ? "" : r.expired ? "会话过期" : "未登录",
93
+ });
94
+ rec.status = applied.status;
95
+ if (applied.migrated) rec.migrated = true;
96
+ } catch (e) {
97
+ rec.error = e.message;
98
+ }
99
+ summary[ok]++;
100
+ summary.checked++;
101
+ if (rec.migrated) summary.migrated++;
102
+ summary.results.push(rec);
103
+ if (i < targets.length - 1 && pacingMs > 0) {
104
+ await new Promise((res) => setTimeout(res, pacingMs));
105
+ }
106
+ }
107
+ return summary;
108
+ }
@@ -229,6 +229,7 @@ function publicView(acc) {
229
229
  lastImportFrom: acc.lastImportFrom || null,
230
230
  // 最近使用镜像(脱离 lease 独立存在,释放/回收后仍可见)
231
231
  lastHealthAt: acc.lastHealthAt || null,
232
+ lastHealth: acc.lastHealth || null,
232
233
  lastBusinessOkAt: acc.lastBusinessOkAt || null,
233
234
  lastUsedAt: acc.lastUsedAt || null,
234
235
  lease,
@@ -754,6 +755,108 @@ export function importAccount({ userId, port, username, cookies, note, proxyHint
754
755
  /** 来源元数据(供节点热路径跳过 + 账号中心展示),非租约;新入池与幂等补写共用此定义 */
755
756
  const importMeta = (userId, portNum, at) => ({ userId, port: portNum, at });
756
757
 
758
+ /**
759
+ * 重登录:按 username 覆盖刷新 cookies(区别于 import 的「已在池即幂等跳过」)。
760
+ * 守卫:目标必须已在池(新号走 import/add);使用中(leased)拒绝——先等节点换号/手动释放;
761
+ * retired 重登录=复活回 pool;suspect 刷新后回 pool;空闲则原地刷新。
762
+ * username 必须来自 passport 活体验证(调用方已验,且须与目标一致——身份不符由调用方拦截),
763
+ * cookies 必须非空。
764
+ */
765
+ export function reloginAccount({ username, cookies, userId, note } = {}) {
766
+ ensureLoaded();
767
+ reapExpiredLeases();
768
+ if (!username) throw new Error("username 必填(重登录目标)");
769
+ if (!userId) throw new Error("userId 必填(操作来源)");
770
+ const norm = normalizeCookies(cookies);
771
+ if (!norm) throw new Error("cookies 必须是非空数组,且每项含 name/value");
772
+ const acc = _accounts.find((a) => a.username === username);
773
+ if (!acc) throw new Error(`账号 ${username} 不在池中(重登录仅针对已有账号,新号走 import/add)`);
774
+ if (acc.status === "leased") {
775
+ const w = acc.lease;
776
+ throw new Error(
777
+ `账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
778
+ );
779
+ }
780
+ const revived = acc.status === "retired";
781
+ const wasSuspect = acc.status === "suspect";
782
+ acc.cookies = norm;
783
+ acc.status = "pool";
784
+ acc.updatedAt = now();
785
+ pushHistory(
786
+ acc,
787
+ "relogin",
788
+ `${revived ? "重登录复活(已停用→池)" : wasSuspect ? "重登录(疑似→池)" : "重登录刷新"} · ${userId}${note ? ` · ${String(note).slice(0, 80)}` : ""}`,
789
+ );
790
+ scheduleSave();
791
+ return { account: publicView(acc), revived, wasSuspect };
792
+ }
793
+
794
+ /**
795
+ * 巡检目标:默认取 pool+suspect(leased 由消费方验证兜底、retired 不探测);
796
+ * 指定 ids 时按 id 精确取(仍排除 retired)。返回含明文 cookies,仅限服务端进程内使用。
797
+ */
798
+ export function healthTargets({ ids } = {}) {
799
+ ensureLoaded();
800
+ reapExpiredLeases();
801
+ const idSet = Array.isArray(ids) && ids.length ? new Set(ids) : null;
802
+ return _accounts
803
+ .filter((a) => a.status !== "retired")
804
+ .filter((a) => (idSet ? idSet.has(a.id) : a.status === "pool" || a.status === "suspect"))
805
+ .map((a) => ({
806
+ id: a.id,
807
+ username: a.username,
808
+ status: a.status,
809
+ proxyHint: a.proxyHint || "",
810
+ cookies: a.cookies,
811
+ }));
812
+ }
813
+
814
+ /**
815
+ * 巡检结果落账:lastHealthAt + lastHealth('alive'|'dead'|'error')。
816
+ * dead 且在池 → 迁移 suspect(业务不再自动取到死号);alive 不写 history(避免刷屏,
817
+ * HISTORY_MAX 只有 20 条);dead/error 才记 history。leased/retired 仅记录不迁移状态。
818
+ */
819
+ export function recordHealth({ id, ok, detail } = {}) {
820
+ ensureLoaded();
821
+ const acc = _accounts.find((x) => x.id === id);
822
+ if (!acc) throw new Error(`账号不存在: ${id}`);
823
+ acc.lastHealthAt = now();
824
+ acc.lastHealth = ok; // 'alive' | 'dead' | 'error'
825
+ let migrated = false;
826
+ if (ok === "dead" && acc.status === "pool") {
827
+ acc.status = "suspect";
828
+ migrated = true;
829
+ }
830
+ if (ok !== "alive" || migrated) {
831
+ pushHistory(
832
+ acc,
833
+ "health",
834
+ `${ok}${detail ? ` · ${String(detail).slice(0, 80)}` : ""}${migrated ? " · pool→suspect" : ""}`,
835
+ );
836
+ }
837
+ scheduleSave();
838
+ return { username: acc.username, status: acc.status, migrated };
839
+ }
840
+
841
+ /**
842
+ * 重登录预备:返回目标账号明文 cookies(relogin 命令注入旧登录态用)。
843
+ * 守卫与 reloginAccount 一致:必须在池、使用中拒绝。
844
+ */
845
+ export function prepareRelogin({ username } = {}) {
846
+ ensureLoaded();
847
+ reapExpiredLeases();
848
+ if (!username) throw new Error("username 必填");
849
+ const acc = _accounts.find((a) => a.username === username);
850
+ if (!acc) throw new Error(`账号 ${username} 不在池中`);
851
+ if (acc.status === "leased") {
852
+ const w = acc.lease;
853
+ throw new Error(
854
+ `账号 ${username} 使用中(${w?.userId || "?"}${w?.port ? ":" + w.port : ""}),先释放再重登录`,
855
+ );
856
+ }
857
+ return { account: fullView(acc) };
858
+ }
859
+
757
860
  /** 内部:注入账号(测试/迁移工具用,跳过命令队列直接改状态) */
758
861
  export function _accountsInternal() {
759
862
  ensureLoaded();