tt-help-cli-ycl 1.4.172 → 1.4.176

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,258 @@
1
+ /**
2
+ * Linux 节点环境自举(watchdog 启动时调用一次;幂等;失败不阻断心跳)
3
+ *
4
+ * 仅 Linux 加载(agent.js 有 platform guard)——Windows/macOS 稳定运行路径零改动。
5
+ * 目标:新装 Linux 节点无需任何手工环境步骤,watchdog 启动即自举完毕:
6
+ *
7
+ * 1. DISPLAY 检测:headless(systemd/ssh 无显示)时自动 spawn Xvfb 并注入
8
+ * process.env.DISPLAY —— 服务端下发的业务进程(explore 等)继承后可直接起浏览器;
9
+ * 已有可用显示(桌面会话/外部 Xvfb)则直接复用
10
+ * 2. Xvfb 生命周期:写 pid 文件(~/.tt-help/xvfb.pid),watchdog 重启时复用存活实例;
11
+ * watchdog 退出(exit handler)时清理自己起的实例
12
+ * 3. Edge 浏览器探测:msedge / microsoft-edge / microsoft-edge-stable 依次探测
13
+ * (官方 deb 命令非 msedge,F-11 实测踩坑)
14
+ * 4. 能力汇总(返回值)→ agent 随心跳上报 env 字段,服务端可见节点就绪度
15
+ * (旧版服务端忽略未知字段,向后兼容)
16
+ *
17
+ * 设计约束:任何一步失败只记日志、不抛出、不阻断心跳——问题经 env 上报在服务端可见。
18
+ */
19
+
20
+ import { spawn, execSync } from "child_process";
21
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
22
+ import { join } from "path";
23
+ import { homedir } from "os";
24
+
25
+ const X11_SOCKET_DIR = "/tmp/.X11-unix";
26
+ const XVFB_PID_FILE = join(homedir(), ".tt-help", "xvfb.pid");
27
+ const XVFB_SCREEN = "1600x900x24";
28
+
29
+ function log(m) {
30
+ console.error(`[linux-env] ${m}`);
31
+ }
32
+
33
+ /** 探测命令是否存在(command -v,PATH 语义) */
34
+ function which(cmd) {
35
+ try {
36
+ const p = execSync(`command -v ${cmd} 2>/dev/null`, { encoding: "utf-8" }).trim();
37
+ return p || null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /** display 号对应的 X socket 是否存在(= 该号被占用/可用) */
44
+ function xSocketExists(n) {
45
+ return existsSync(join(X11_SOCKET_DIR, `X${n}`));
46
+ }
47
+
48
+ /** 当前 DISPLAY 是否真的可用(变量存在且对应 socket 在) */
49
+ function displayUsable() {
50
+ const d = process.env.DISPLAY || "";
51
+ if (!d) return false;
52
+ const m = /^:(\d+)$/.exec(d.trim());
53
+ return m ? xSocketExists(parseInt(m[1], 10)) : true; // 非标准格式(如 host:N)放行交给 X 自己报错
54
+ }
55
+
56
+ /** 读取 pid 文件并校验进程存活,返回可复用的 Xvfb pid 或 0 */
57
+ function findReusableXvfb() {
58
+ try {
59
+ const pid = parseInt(readFileSync(XVFB_PID_FILE, "utf-8").trim(), 10);
60
+ if (!pid) return 0;
61
+ process.kill(pid, 0); // 探测存活
62
+ return pid;
63
+ } catch {
64
+ try {
65
+ unlinkSync(XVFB_PID_FILE);
66
+ } catch {}
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 确保可用显示:复用外部显示 > 复用历史 Xvfb > spawn 新 Xvfb。
73
+ * 成功后注入 process.env.DISPLAY(业务子进程继承)。
74
+ * @returns {{display: string, xvfbPid: number, source: string, error: string|null}}
75
+ */
76
+ function ensureDisplay() {
77
+ if (displayUsable()) {
78
+ return { display: process.env.DISPLAY, xvfbPid: 0, source: "external", error: null };
79
+ }
80
+ delete process.env.DISPLAY;
81
+
82
+ // 复用上次自托管且仍存活的 Xvfb(watchdog 重启场景,display 号可能已变)
83
+ const reusePid = findReusableXvfb();
84
+ if (reusePid) {
85
+ try {
86
+ const cmdline = readFileSync(`/proc/${reusePid}/cmdline`, "utf-8");
87
+ const m = /:(\d+)/.exec(cmdline);
88
+ if (m && xSocketExists(parseInt(m[1], 10))) {
89
+ const display = `:${m[1]}`;
90
+ process.env.DISPLAY = display;
91
+ log(`复用历史 Xvfb (pid=${reusePid}, display=${display})`);
92
+ return { display, xvfbPid: reusePid, source: "reuse", error: null };
93
+ }
94
+ } catch {}
95
+ try {
96
+ unlinkSync(XVFB_PID_FILE);
97
+ } catch {}
98
+ }
99
+
100
+ const xvfbBin = which("Xvfb");
101
+ if (!xvfbBin) {
102
+ return {
103
+ display: "",
104
+ xvfbPid: 0,
105
+ source: "none",
106
+ error: "无可用显示且未安装 Xvfb(sudo apt install -y xvfb 后重启 watchdog 自动补齐)",
107
+ };
108
+ }
109
+
110
+ // 找空闲 display 号:99 起向上,socket 文件不存在即视为空闲
111
+ let n = 99;
112
+ while (xSocketExists(n) && n < 120) n++;
113
+ const display = `:${n}`;
114
+
115
+ const child = spawn(xvfbBin, [display, "-screen", "0", XVFB_SCREEN, "-nolisten", "tcp"], {
116
+ stdio: "ignore",
117
+ detached: false,
118
+ });
119
+ // 等 X socket 就绪(正常 <500ms;2s 未起视为失败)。同步休眠:本模块在启动路径同步执行
120
+ const sleepSync = (ms) => {
121
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
122
+ };
123
+ const deadline = Date.now() + 2000;
124
+ while (Date.now() < deadline && !xSocketExists(n)) {
125
+ sleepSync(100);
126
+ }
127
+ if (!xSocketExists(n)) {
128
+ try {
129
+ child.kill("SIGKILL");
130
+ } catch {}
131
+ return { display: "", xvfbPid: 0, source: "none", error: `Xvfb 启动超时 (display=${display})` };
132
+ }
133
+
134
+ process.env.DISPLAY = display;
135
+ try {
136
+ writeFileSync(XVFB_PID_FILE, String(child.pid));
137
+ } catch {}
138
+ // watchdog 退出时清理自托管实例(pid 文件先删,防误判复用)
139
+ process.on("exit", () => {
140
+ try {
141
+ unlinkSync(XVFB_PID_FILE);
142
+ } catch {}
143
+ try {
144
+ process.kill(child.pid, "SIGTERM");
145
+ } catch {}
146
+ });
147
+ log(`已自托管 Xvfb (pid=${child.pid}, display=${display})`);
148
+ return { display, xvfbPid: child.pid, source: "self-hosted", error: null };
149
+ }
150
+
151
+ /** Edge 探测(与 cdp.js getEdgePath 同候选序,此处独立实现避免循环依赖) */
152
+ function detectEdge() {
153
+ for (const c of ["msedge", "microsoft-edge", "microsoft-edge-stable"]) {
154
+ const p = which(c);
155
+ if (p) {
156
+ let version = "";
157
+ try {
158
+ version = execSync(`${c} --version 2>/dev/null`, { encoding: "utf-8", timeout: 5000 }).trim();
159
+ } catch {}
160
+ return { path: p, version };
161
+ }
162
+ }
163
+ return { path: "", version: "" };
164
+ }
165
+
166
+ /**
167
+ * Linux 环境自举入口(幂等,可重复调用;每次启动全量探测,缺什么补什么)
168
+ * @returns {Promise<object>} envCapability(随心跳上报;旧版服务端忽略)
169
+ */
170
+ export async function ensureLinuxEnv() {
171
+ const cap = {
172
+ platform: "linux",
173
+ display: "",
174
+ xvfbPid: 0,
175
+ displaySource: "none",
176
+ edgePath: "",
177
+ edgeVersion: "",
178
+ errors: [],
179
+ };
180
+
181
+ try {
182
+ const d = ensureDisplay();
183
+ cap.display = d.display;
184
+ cap.xvfbPid = d.xvfbPid;
185
+ cap.displaySource = d.source;
186
+ if (d.error) cap.errors.push(d.error);
187
+ } catch (e) {
188
+ cap.errors.push(`display: ${e.message}`);
189
+ }
190
+
191
+ try {
192
+ const e = detectEdge();
193
+ cap.edgePath = e.path;
194
+ cap.edgeVersion = e.version;
195
+ if (!e.path) cap.errors.push("未检测到 Edge 浏览器(业务不可用;微软源 apt install microsoft-edge-stable)");
196
+ } catch (e) {
197
+ cap.errors.push(`edge: ${e.message}`);
198
+ }
199
+
200
+ if (cap.errors.length) {
201
+ for (const m of cap.errors) log(`⚠ ${m}`);
202
+ } else {
203
+ log(`✓ 环境 OK (display=${cap.display} 来源=${cap.displaySource}, Edge=${cap.edgeVersion || "?"})`);
204
+ }
205
+ return cap;
206
+ }
207
+
208
+ /**
209
+ * 生成并启用 tt-watchdog systemd 系统级服务(每台机器一次性 root 动作)。
210
+ * 由 `tt-help watchdog install-service` 调用(需 root)。
211
+ * sudo 场景:unit 必须以真实用户运行(User= + HOME 取 SUDO_USER),
212
+ * 否则 watchdog 以 root 跑、读 /root/.tt-help.json → 配置/心跳/命令匹配全错(实机踩坑)。
213
+ * @param {{clientId: string, serverUrl: string, intervalSec: number}} opts
214
+ */
215
+ export function installWatchdogService({ clientId, serverUrl, intervalSec = 15 }) {
216
+ const unitPath = "/etc/systemd/system/tt-watchdog.service";
217
+ // sudo 时还原真实用户与 HOME(直接跑 root 场景两值为空 → 退回当前环境)
218
+ let runUser = "";
219
+ let runHome = homedir();
220
+ if (process.env.SUDO_USER) {
221
+ try {
222
+ runHome = execSync(
223
+ `getent passwd ${process.env.SUDO_USER} | cut -d: -f6`,
224
+ { encoding: "utf-8" },
225
+ ).trim();
226
+ runUser = process.env.SUDO_USER;
227
+ } catch {}
228
+ }
229
+ const execLine = [
230
+ "/usr/local/bin/tt-help",
231
+ "watchdog",
232
+ serverUrl ? `--server ${serverUrl}` : "",
233
+ clientId ? `--client-id ${clientId}` : "",
234
+ `--interval ${intervalSec}`,
235
+ ]
236
+ .filter(Boolean)
237
+ .join(" ");
238
+ const userLines = runUser ? `User=${runUser}\nEnvironment=HOME=${runHome}` : `Environment=HOME=${runHome}`;
239
+ const unit = `# Generated by tt-help watchdog install-service (${new Date().toISOString()})
240
+ [Unit]
241
+ Description=tt-help watchdog (${clientId || "linux node"})
242
+ After=network-online.target
243
+
244
+ [Service]
245
+ Type=simple
246
+ ${userLines}
247
+ ExecStart=${execLine}
248
+ Restart=always
249
+ RestartSec=10
250
+
251
+ [Install]
252
+ WantedBy=multi-user.target
253
+ `;
254
+ writeFileSync(unitPath, unit, "utf-8"); // root 直写;非 root 时 writeFileSync 抛错由调用方提示
255
+ execSync("systemctl daemon-reload");
256
+ execSync("systemctl enable --now tt-watchdog.service");
257
+ return unitPath;
258
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 登录池客户端(Phase 2)— 业务进程直接向账号中心取号/还号 + 浏览器原地注入
3
+ *
4
+ * 与心跳命令通道无关:acquire 响应直接携带明文 cookies(POST 专属通道,与心跳下发同级),
5
+ * 注入由业务进程在自己持有的浏览器实例上执行(POC 实证 SOP,见知识库 edge-login-transfer.md):
6
+ * context.clearCookies() + addCookies → 导航 tiktok.com → passport/web/account/info 验证身份
7
+ *
8
+ * 语义约定(2026-10-26 定稿:以号为主键,无幂等/无端口键):
9
+ * - acquire: 挑最久未用的空闲号 → 租约 → 响应带明文 cookies;池空/服务器不可达抛错
10
+ * - 换号 = 先取新号、验证通过后再 release 旧号(无空窗,池空保旧重试)
11
+ * - release: 按账号 id 还号(谁持账号谁还);崩溃未还的租约由 TTL 兜底回收
12
+ * - port/appName 仅作租约展示元数据,不参与任何键/约束
13
+ * - 无续租心跳: 48min 换号让租约永远年轻,服务端 TTL 12h 仅为崩溃兜底
14
+ */
15
+
16
+ const REQUEST_TIMEOUT_MS = 30 * 1000;
17
+
18
+ /** 账号中心 POST(fail-fast:业务错误如「池空」直接抛错,不无限重试——重试节奏由调用方掌控) */
19
+ async function poolPost(url, body) {
20
+ const res = await fetch(url, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify(body),
24
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
25
+ });
26
+ const data = await res.json().catch(() => null);
27
+ if (!res.ok || !data || data.error) {
28
+ throw new Error((data && data.error) || `HTTP ${res.status}`);
29
+ }
30
+ return data;
31
+ }
32
+
33
+ /**
34
+ * 创建登录池客户端
35
+ * @param {object} opts { serverUrl, userId, port?, appName? }
36
+ */
37
+ export function createLoginPoolClient({ serverUrl, userId, port, appName = "explore" }) {
38
+ const base = `${String(serverUrl).replace(/\/+$/, "")}/api/login-pool`;
39
+ return {
40
+ /** 取号:挑最久未用的空闲号,响应带明文 cookies。失败抛错(池空/服务器不可达) */
41
+ async acquire() {
42
+ const r = await poolPost(`${base}/acquire`, { userId, port, appName });
43
+ return r.account;
44
+ },
45
+ /** 还号:以号为主键,按 id 释放(谁持账号谁还) */
46
+ async release(account) {
47
+ if (!account || !account.id) return null;
48
+ const r = await poolPost(`${base}/release`, { id: account.id });
49
+ return r.account;
50
+ },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * TikTok 登录身份判定(POC 实证):必须在 tiktok.com 页面上下文执行
56
+ * (about:blank 等跨源上下文 fetch account/info 会因 CORS 带不上凭证)
57
+ */
58
+ export async function whoAmI(page) {
59
+ return page.evaluate(async () => {
60
+ try {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), 10000);
63
+ const res = await fetch("https://www.tiktok.com/passport/web/account/info/", {
64
+ signal: controller.signal,
65
+ credentials: "include",
66
+ headers: { accept: "application/json" },
67
+ });
68
+ clearTimeout(timer);
69
+ const j = await res.json().catch(() => null);
70
+ const d = (j && j.data) || null;
71
+ return {
72
+ httpStatus: res.status,
73
+ username: (d && d.username) || null,
74
+ hasAccount: !!d && !!(d.username || d.user_id || d.sec_user_id),
75
+ expired: (d && d.name === "session_expired") || (d && d.error_code === 13),
76
+ };
77
+ } catch (e) {
78
+ return { httpStatus: 0, username: null, hasAccount: false, expired: false, error: String(e).slice(0, 120) };
79
+ }
80
+ });
81
+ }
82
+
83
+ /** 导航到 tiktok.com 首页(身份验证/注入前置) */
84
+ export async function gotoTiktok(page, timeout = 45000) {
85
+ await page.goto("https://www.tiktok.com/", {
86
+ waitUntil: "domcontentloaded",
87
+ timeout,
88
+ });
89
+ }
90
+
91
+ function toPlaywrightCookies(cookies) {
92
+ return (cookies || []).map((c) => ({
93
+ name: c.name,
94
+ value: c.value,
95
+ domain: c.domain || ".tiktok.com",
96
+ path: c.path || "/",
97
+ secure: c.secure !== false,
98
+ httpOnly: c.httpOnly !== false,
99
+ ...(typeof c.expires === "number" && c.expires > 0 ? { expires: c.expires } : {}),
100
+ }));
101
+ }
102
+
103
+ /**
104
+ * 原地注入账号(同一 Edge 运行实例,不重启浏览器;POC T2 实证)
105
+ * 清旧 cookie → 注入 → 导航 → passport 验证身份。
106
+ * @returns {Promise<{ok: boolean, username?: string, reason?: string}>}
107
+ */
108
+ export async function injectPoolAccount(page, account, { verifyUsername } = {}) {
109
+ if (!account || !Array.isArray(account.cookies) || account.cookies.length === 0) {
110
+ return { ok: false, reason: "账号 cookies 为空" };
111
+ }
112
+ const context = page.context();
113
+ await context.clearCookies();
114
+ await context.addCookies(toPlaywrightCookies(account.cookies));
115
+ await gotoTiktok(page);
116
+ const id = await whoAmI(page);
117
+ if (!id.hasAccount) {
118
+ return {
119
+ ok: false,
120
+ reason: id.expired ? "会话过期" : id.error || "无账号数据",
121
+ identity: id,
122
+ };
123
+ }
124
+ if (verifyUsername && id.username !== verifyUsername) {
125
+ return {
126
+ ok: false,
127
+ reason: `身份不符(期望 @${verifyUsername},实际 @${id.username})`,
128
+ identity: id,
129
+ };
130
+ }
131
+ return { ok: true, username: id.username, identity: id };
132
+ }
133
+
134
+ /**
135
+ * 浏览器重建/重启后恢复在手账号:磁盘态一致则免注入,不一致用内存 cookies 补注入。
136
+ * (以号为主键模型下仅用于「浏览器崩溃/内存重启」场景;进程启动一律直接注入新取的号)
137
+ * @returns {Promise<{ok: boolean, injected: boolean, username?: string, reason?: string}>}
138
+ */
139
+ export async function ensurePoolLogin(page, account) {
140
+ if (!account) return { ok: false, injected: false, reason: "无在手账号" };
141
+ let id = null;
142
+ try {
143
+ await gotoTiktok(page);
144
+ id = await whoAmI(page);
145
+ } catch (e) {
146
+ return { ok: false, injected: false, reason: `导航失败: ${e.message}` };
147
+ }
148
+ if (id.hasAccount && id.username === account.username) {
149
+ return { ok: true, injected: false, username: id.username };
150
+ }
151
+ const r = await injectPoolAccount(page, account, { verifyUsername: account.username }).catch((e) => ({
152
+ ok: false,
153
+ reason: e.message,
154
+ }));
155
+ return { ...r, injected: true };
156
+ }
@@ -2,7 +2,7 @@ import { delay, getDelayConfig } from "./page-helpers.js";
2
2
  import { scrollAndCollect } from "./scroll-collector.js";
3
3
  import { extractUniqueId, toProfileUrl } from "../../lib/url.js";
4
4
 
5
- const FILTER_WORDS = ["主页", "已关注", "粉丝"];
5
+ const FILTER_WORDS = ["主页", "已关注", "粉丝", "Home", "Following", "Followers"];
6
6
 
7
7
  const FOLLOW_TRIGGER_SELECTORS = [
8
8
  "[data-e2e=following]",
@@ -164,9 +164,20 @@ async function openFollowModal(page, log = () => {}) {
164
164
 
165
165
  async function switchToFollowersTab(page) {
166
166
  await page.evaluate(() => {
167
- const tabs = document.querySelectorAll("[class*=DivTabItem]");
168
- for (const tab of tabs) {
169
- if (tab.textContent?.includes("粉丝")) {
167
+ // 结构化定位(语言无关):弹窗 Tab 产品序固定 Following/Followers/Suggested,
168
+ // DivTabs 容器内第 2 个即粉丝 Tab。实测英文 profile 文本为 "Followers134",
169
+ // 旧版单中文 includes("粉丝") 必败(ubuntu26 实机踩坑;勿改回文字匹配)
170
+ const tabs = [
171
+ ...document.querySelectorAll("[class*=DivTabs] [class*=DivTabItem]"),
172
+ ];
173
+ if (tabs.length >= 2) {
174
+ tabs[1].click();
175
+ return;
176
+ }
177
+ // 兑底:任意位置的双语文本匹配(结构变化时降级)
178
+ for (const tab of document.querySelectorAll("[class*=DivTabItem]")) {
179
+ const text = tab.textContent || "";
180
+ if (text.includes("粉丝") || /followers/i.test(text)) {
170
181
  tab.click();
171
182
  return;
172
183
  }
@@ -189,7 +200,14 @@ async function closeFollowModal(page) {
189
200
 
190
201
  function createUserCollectFn() {
191
202
  return (container) => {
192
- const FILTER_WORDS = ["主页", "已关注", "粉丝"];
203
+ const FILTER_WORDS = [
204
+ "主页",
205
+ "已关注",
206
+ "粉丝",
207
+ "Home",
208
+ "Following",
209
+ "Followers",
210
+ ];
193
211
  const modal = document.querySelector("[class*=eyhy6180]");
194
212
  const root = modal || document;
195
213
  const users = [];
@@ -3,7 +3,10 @@ import { scrollAndCollect } from './scroll-collector.js';
3
3
 
4
4
  async function openGuessTab(page) {
5
5
  const tabs = page.locator('[class*="tabbar-item"]');
6
- const guessTab = tabs.filter({ hasText: /猜你喜欢/i }).first();
6
+ // 双语:中文页「猜你喜欢」;英文页 "Suggested for you"(Linux 英文 profile 踩坑)
7
+ const guessTab = tabs
8
+ .filter({ hasText: /猜你喜欢|Suggested|You may like/i })
9
+ .first();
7
10
  await guessTab.click();
8
11
  const config = getDelayConfig();
9
12
  await delay(Math.round(config.commentMax * 0.5), config.commentMax);