tt-help-cli-ycl 1.3.27 → 1.3.29
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/src/cli/explore.js +7 -1
- package/src/cli/open.js +48 -39
- package/src/lib/api-interceptor.js +110 -65
package/package.json
CHANGED
package/src/cli/explore.js
CHANGED
|
@@ -129,6 +129,9 @@ export async function handleExplore(options) {
|
|
|
129
129
|
|
|
130
130
|
const page = await getOrCreatePage(browser);
|
|
131
131
|
|
|
132
|
+
// 先导航到 TikTok 首页,再检测登录状态
|
|
133
|
+
await page.goto("https://www.tiktok.com", { waitUntil: "domcontentloaded" });
|
|
134
|
+
|
|
132
135
|
// 检测登录状态(启动时只检测一次)
|
|
133
136
|
let loggedIn = await isLoggedIn(page);
|
|
134
137
|
console.error(`登录状态: ${loggedIn ? "已登录" : "未登录"}`);
|
|
@@ -174,7 +177,10 @@ export async function handleExplore(options) {
|
|
|
174
177
|
userDataDir: nextAccount.userDataDir,
|
|
175
178
|
});
|
|
176
179
|
console.error(`[健康检查] 已切换到端口 ${nextAccount.port}`);
|
|
177
|
-
//
|
|
180
|
+
// 切换账户后先导航到 TikTok,再重新检测登录状态
|
|
181
|
+
await page.goto("https://www.tiktok.com", {
|
|
182
|
+
waitUntil: "domcontentloaded",
|
|
183
|
+
});
|
|
178
184
|
loggedIn = await isLoggedIn(page);
|
|
179
185
|
console.error(
|
|
180
186
|
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
package/src/cli/open.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ensureBrowserReady, killEdgeProcesses } from
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
|
|
2
|
+
import { getOrCreatePage } from "../lib/browser/page.js";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
4
5
|
|
|
5
6
|
const BASE_PORT = 9222;
|
|
6
7
|
const TOTAL_ACCOUNTS = 10;
|
|
@@ -9,24 +10,31 @@ export async function handleOpen(parsed) {
|
|
|
9
10
|
const { openPort, openList } = parsed;
|
|
10
11
|
|
|
11
12
|
if (openList) {
|
|
12
|
-
console.error(
|
|
13
|
+
console.error("内置浏览器配置:");
|
|
13
14
|
for (let i = 0; i < TOTAL_ACCOUNTS; i++) {
|
|
14
15
|
const port = BASE_PORT + i;
|
|
15
16
|
const profile = `p${port}`;
|
|
16
|
-
const userDataDir = path.join(
|
|
17
|
-
|
|
17
|
+
const userDataDir = path.join(
|
|
18
|
+
os.homedir(),
|
|
19
|
+
"Library",
|
|
20
|
+
"Application Support",
|
|
21
|
+
`Microsoft Edge For Testing_${profile}`,
|
|
22
|
+
);
|
|
23
|
+
console.error(
|
|
24
|
+
` open ${port} → profile: ${profile}, userDataDir: ${userDataDir}`,
|
|
25
|
+
);
|
|
18
26
|
}
|
|
19
|
-
console.error(
|
|
20
|
-
console.error(
|
|
21
|
-
console.error(
|
|
27
|
+
console.error("");
|
|
28
|
+
console.error("用法: tt-help open <端口>");
|
|
29
|
+
console.error("示例: tt-help open 9222");
|
|
22
30
|
process.exit(0);
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
if (!openPort) {
|
|
26
|
-
console.error(
|
|
27
|
-
console.error(
|
|
28
|
-
console.error(
|
|
29
|
-
console.error(
|
|
34
|
+
console.error("用法: tt-help open <端口>");
|
|
35
|
+
console.error("示例: tt-help open 9222");
|
|
36
|
+
console.error("");
|
|
37
|
+
console.error("可用端口: 9222 - 9231 (共 10 个)");
|
|
30
38
|
console.error('运行 "tt-help open --list" 查看所有配置');
|
|
31
39
|
process.exit(1);
|
|
32
40
|
}
|
|
@@ -38,15 +46,20 @@ export async function handleOpen(parsed) {
|
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
const profile = `p${port}`;
|
|
41
|
-
const userDataDir = path.join(
|
|
49
|
+
const userDataDir = path.join(
|
|
50
|
+
os.homedir(),
|
|
51
|
+
"Library",
|
|
52
|
+
"Application Support",
|
|
53
|
+
`Microsoft Edge For Testing_${profile}`,
|
|
54
|
+
);
|
|
42
55
|
|
|
43
56
|
console.error(`正在启动浏览器... 端口: ${port}, profile: ${profile}`);
|
|
44
57
|
console.error(`userDataDir: ${userDataDir}`);
|
|
45
|
-
console.error(
|
|
46
|
-
console.error(
|
|
47
|
-
console.error(
|
|
48
|
-
console.error(
|
|
49
|
-
console.error(
|
|
58
|
+
console.error("");
|
|
59
|
+
console.error("启动后请在浏览器中登录 TikTok 账户");
|
|
60
|
+
console.error("登录完成后关闭浏览器即可,下次 open 会保留登录状态");
|
|
61
|
+
console.error("");
|
|
62
|
+
console.error("按 Ctrl+C 退出并关闭浏览器");
|
|
50
63
|
|
|
51
64
|
let browser = null;
|
|
52
65
|
|
|
@@ -58,39 +71,35 @@ export async function handleOpen(parsed) {
|
|
|
58
71
|
browser = await ensureBrowserReady(cdpOptions);
|
|
59
72
|
|
|
60
73
|
// Ctrl+C 关闭浏览器
|
|
61
|
-
process.on(
|
|
62
|
-
console.error(
|
|
74
|
+
process.on("SIGINT", async () => {
|
|
75
|
+
console.error("\n正在关闭浏览器...");
|
|
63
76
|
try {
|
|
64
77
|
await killEdgeProcesses(userDataDir);
|
|
65
78
|
await browser.close();
|
|
66
|
-
} catch {
|
|
67
|
-
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
console.error("浏览器已关闭");
|
|
68
83
|
process.exit(0);
|
|
69
84
|
});
|
|
70
85
|
|
|
71
|
-
console.error(
|
|
72
|
-
console.error(
|
|
73
|
-
console.error(
|
|
86
|
+
console.error("");
|
|
87
|
+
console.error("浏览器已启动并连接成功");
|
|
88
|
+
console.error("TikTok 页面将在浏览器中自动打开...");
|
|
74
89
|
|
|
75
90
|
// 打开 TikTok 首页
|
|
76
91
|
try {
|
|
77
|
-
const
|
|
78
|
-
let page = pages.find(p => !p.url().startsWith('devtools'));
|
|
79
|
-
if (!page) {
|
|
80
|
-
const context = await browser.newBrowserCDPSession();
|
|
81
|
-
await context.send('Target.createTarget', { url: '' });
|
|
82
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
83
|
-
const newPages = await browser.pages();
|
|
84
|
-
page = newPages.find(p => !p.url().startsWith('devtools'));
|
|
85
|
-
}
|
|
86
|
-
|
|
92
|
+
const page = await getOrCreatePage(browser);
|
|
87
93
|
if (page) {
|
|
88
|
-
await page.goto(
|
|
89
|
-
|
|
94
|
+
await page.goto("https://www.tiktok.com", {
|
|
95
|
+
waitUntil: "domcontentloaded",
|
|
96
|
+
timeout: 30000,
|
|
97
|
+
});
|
|
98
|
+
console.error("已打开 https://www.tiktok.com");
|
|
90
99
|
}
|
|
91
100
|
} catch (e) {
|
|
92
101
|
console.error(`打开页面时出错: ${e.message}`);
|
|
93
|
-
console.error(
|
|
102
|
+
console.error("浏览器已启动,可以手动在浏览器中打开 TikTok");
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
// 保持进程运行
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { delay } from "./delay.js";
|
|
2
2
|
import { retryWithBackoff } from "./retry.js";
|
|
3
3
|
import { assertPageUrl } from "./browser/page.js";
|
|
4
|
-
import {
|
|
5
|
-
detectPageError,
|
|
6
|
-
detectPageErrorWithWait,
|
|
7
|
-
} from "./page-error-detector.js";
|
|
4
|
+
import { detectPageError } from "./page-error-detector.js";
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
7
|
* 处理 API 响应数据:提取首页视频 + 翻页
|
|
@@ -80,35 +77,16 @@ async function processAPIResponse(
|
|
|
80
77
|
}
|
|
81
78
|
|
|
82
79
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
87
|
-
* @param {string} username - TikTok 用户名
|
|
88
|
-
* @param {number} maxVideos - 最大视频数
|
|
89
|
-
* @param {Function} log - 日志函数
|
|
90
|
-
* @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
|
|
91
|
-
* @throws {Error} API 超时或网络异常时抛出
|
|
80
|
+
* 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
|
|
81
|
+
* @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
|
|
92
82
|
*/
|
|
93
|
-
async function
|
|
83
|
+
async function tryInterceptAPI(page, username, log) {
|
|
94
84
|
const url = `https://www.tiktok.com/@${username}`;
|
|
95
|
-
const items = [];
|
|
96
|
-
|
|
97
|
-
// 1. 注册 response 拦截器 + request URL 捕获
|
|
98
|
-
let apiResolve = null;
|
|
99
|
-
const apiPromise = new Promise((r) => {
|
|
100
|
-
apiResolve = r;
|
|
101
|
-
});
|
|
102
|
-
|
|
103
85
|
let apiRequestUrl = null;
|
|
104
86
|
|
|
105
87
|
const responseHandler = async (response) => {
|
|
106
88
|
if (response.url().includes("/api/post/item_list/")) {
|
|
107
|
-
|
|
108
|
-
apiResolve(await response.json());
|
|
109
|
-
} catch (e) {
|
|
110
|
-
apiResolve(null);
|
|
111
|
-
}
|
|
89
|
+
// 只记录 URL,数据通过 waitForResponse 获取
|
|
112
90
|
}
|
|
113
91
|
};
|
|
114
92
|
|
|
@@ -121,61 +99,128 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
121
99
|
page.on("response", responseHandler);
|
|
122
100
|
page.on("request", requestHandler);
|
|
123
101
|
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
|
|
124
104
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
await assertPageUrl(page, `@${username}`);
|
|
133
|
-
},
|
|
134
|
-
{ maxRetries: 3, baseDelay: 3000, log },
|
|
105
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
106
|
+
await assertPageUrl(page, `@${username}`);
|
|
107
|
+
|
|
108
|
+
// 用 waitForResponse 精确等待 API 响应(15 秒超时,适合弱网)
|
|
109
|
+
const response = await page.waitForResponse(
|
|
110
|
+
(res) => res.url().includes("/api/post/item_list/"),
|
|
111
|
+
{ timeout: 15000 },
|
|
135
112
|
);
|
|
136
113
|
|
|
137
|
-
const data = await
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
]);
|
|
114
|
+
const data = await response.json();
|
|
115
|
+
const elapsed = Date.now() - t0;
|
|
116
|
+
log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
|
|
141
117
|
|
|
118
|
+
return { data, apiRequestUrl, elapsed };
|
|
119
|
+
} catch (e) {
|
|
142
120
|
const elapsed = Date.now() - t0;
|
|
121
|
+
log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
|
|
122
|
+
return { data: null, apiRequestUrl, elapsed, error: e.message };
|
|
123
|
+
} finally {
|
|
124
|
+
page.off("response", responseHandler);
|
|
125
|
+
page.off("request", requestHandler);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 通过拦截 TikTok 内部 API 获取用户视频列表
|
|
131
|
+
* 比 DOM 滚动解析快 5-10 倍
|
|
132
|
+
*
|
|
133
|
+
* 弱网优化:
|
|
134
|
+
* - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
|
|
135
|
+
* - 最多重试 3 次,每次重试前清理拦截器
|
|
136
|
+
* - 超时 15s,适合弱网环境
|
|
137
|
+
*
|
|
138
|
+
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
139
|
+
* @param {string} username - TikTok 用户名
|
|
140
|
+
* @param {number} maxVideos - 最大视频数
|
|
141
|
+
* @param {Function} log - 日志函数
|
|
142
|
+
* @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
|
|
143
|
+
* @throws {Error} API 超时或网络异常时抛出
|
|
144
|
+
*/
|
|
145
|
+
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
146
|
+
const items = [];
|
|
147
|
+
const maxRetries = 3;
|
|
148
|
+
const noRetryErrors = new Set([
|
|
149
|
+
"service_error",
|
|
150
|
+
"not_found",
|
|
151
|
+
"region_blocked",
|
|
152
|
+
"login_required",
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
156
|
+
log(
|
|
157
|
+
` [API拦截] 导航到用户页,等待 /api/post/item_list/ ... (尝试 ${attempt}/${maxRetries})`,
|
|
158
|
+
);
|
|
143
159
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
|
|
161
|
+
page,
|
|
162
|
+
username,
|
|
163
|
+
log,
|
|
164
|
+
);
|
|
147
165
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
166
|
+
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
167
|
+
// 成功拦截且拿到了视频,处理数据
|
|
168
|
+
return await processAPIResponse(
|
|
169
|
+
data,
|
|
170
|
+
username,
|
|
171
|
+
maxVideos,
|
|
172
|
+
items,
|
|
173
|
+
page,
|
|
174
|
+
apiRequestUrl,
|
|
175
|
+
log,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
151
178
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
179
|
+
// API 已返回但没有视频时,优先判定是否是确定性的页面错误。
|
|
180
|
+
const pageError = await detectPageError(page);
|
|
181
|
+
|
|
182
|
+
if (pageError === "service_error") {
|
|
183
|
+
throw new Error(`被封: ${username}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
|
|
187
|
+
if (noRetryErrors.has(pageError)) {
|
|
188
|
+
log(
|
|
189
|
+
` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
|
|
190
|
+
);
|
|
191
|
+
return new Map();
|
|
156
192
|
}
|
|
157
193
|
|
|
158
|
-
|
|
194
|
+
log(` [API拦截] ${elapsed}ms 后 API 返回 0 条视频`);
|
|
195
|
+
return new Map();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (noRetryErrors.has(pageError)) {
|
|
159
199
|
log(
|
|
160
200
|
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
|
|
161
201
|
);
|
|
162
202
|
return new Map();
|
|
163
203
|
}
|
|
164
204
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
205
|
+
// 最后一次重试也失败了
|
|
206
|
+
if (attempt === maxRetries) {
|
|
207
|
+
if (!pageError) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`@${username} 页面异常(${elapsed}ms,${maxRetries} 次重试均未拦截到 API)`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
log(
|
|
213
|
+
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
|
|
214
|
+
);
|
|
215
|
+
return new Map();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 还有重试次数,等待后重试
|
|
219
|
+
const waitTime = 2000 * attempt;
|
|
220
|
+
log(
|
|
221
|
+
` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
|
|
174
222
|
);
|
|
175
|
-
|
|
176
|
-
// 5. 必须清理拦截器,防止累积
|
|
177
|
-
page.off("response", responseHandler);
|
|
178
|
-
page.off("request", requestHandler);
|
|
223
|
+
await new Promise((r) => setTimeout(r, waitTime));
|
|
179
224
|
}
|
|
180
225
|
}
|
|
181
226
|
|