tt-help-cli-ycl 1.3.28 → 1.3.30
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/open.js +48 -39
- package/src/lib/api-interceptor.js +115 -61
package/package.json
CHANGED
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
|
// 保持进程运行
|
|
@@ -80,35 +80,16 @@ async function processAPIResponse(
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
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 超时或网络异常时抛出
|
|
83
|
+
* 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
|
|
84
|
+
* @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
|
|
92
85
|
*/
|
|
93
|
-
async function
|
|
86
|
+
async function tryInterceptAPI(page, username, log) {
|
|
94
87
|
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
88
|
let apiRequestUrl = null;
|
|
104
89
|
|
|
105
90
|
const responseHandler = async (response) => {
|
|
106
91
|
if (response.url().includes("/api/post/item_list/")) {
|
|
107
|
-
|
|
108
|
-
apiResolve(await response.json());
|
|
109
|
-
} catch (e) {
|
|
110
|
-
apiResolve(null);
|
|
111
|
-
}
|
|
92
|
+
// 只记录 URL,数据通过 waitForResponse 获取
|
|
112
93
|
}
|
|
113
94
|
};
|
|
114
95
|
|
|
@@ -121,61 +102,134 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
121
102
|
page.on("response", responseHandler);
|
|
122
103
|
page.on("request", requestHandler);
|
|
123
104
|
|
|
105
|
+
const t0 = Date.now();
|
|
106
|
+
|
|
124
107
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
await assertPageUrl(page, `@${username}`);
|
|
133
|
-
},
|
|
134
|
-
{ maxRetries: 3, baseDelay: 3000, log },
|
|
108
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
109
|
+
await assertPageUrl(page, `@${username}`);
|
|
110
|
+
|
|
111
|
+
// 用 waitForResponse 精确等待 API 响应(15 秒超时,适合弱网)
|
|
112
|
+
const response = await page.waitForResponse(
|
|
113
|
+
(res) => res.url().includes("/api/post/item_list/"),
|
|
114
|
+
{ timeout: 15000 },
|
|
135
115
|
);
|
|
136
116
|
|
|
137
|
-
const data = await
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
]);
|
|
117
|
+
const data = await response.json();
|
|
118
|
+
const elapsed = Date.now() - t0;
|
|
119
|
+
log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
|
|
141
120
|
|
|
121
|
+
return { data, apiRequestUrl, elapsed };
|
|
122
|
+
} catch (e) {
|
|
142
123
|
const elapsed = Date.now() - t0;
|
|
124
|
+
log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
|
|
125
|
+
return { data: null, apiRequestUrl, elapsed, error: e.message };
|
|
126
|
+
} finally {
|
|
127
|
+
page.off("response", responseHandler);
|
|
128
|
+
page.off("request", requestHandler);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 通过拦截 TikTok 内部 API 获取用户视频列表
|
|
134
|
+
* 比 DOM 滚动解析快 5-10 倍
|
|
135
|
+
*
|
|
136
|
+
* 弱网优化:
|
|
137
|
+
* - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
|
|
138
|
+
* - 最多重试 3 次,每次重试前清理拦截器
|
|
139
|
+
* - 超时 15s,适合弱网环境
|
|
140
|
+
*
|
|
141
|
+
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
142
|
+
* @param {string} username - TikTok 用户名
|
|
143
|
+
* @param {number} maxVideos - 最大视频数
|
|
144
|
+
* @param {Function} log - 日志函数
|
|
145
|
+
* @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
|
|
146
|
+
* @throws {Error} API 超时或网络异常时抛出
|
|
147
|
+
*/
|
|
148
|
+
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
149
|
+
const items = [];
|
|
150
|
+
const maxRetries = 3;
|
|
151
|
+
const noRetryErrors = new Set([
|
|
152
|
+
"service_error",
|
|
153
|
+
"not_found",
|
|
154
|
+
"region_blocked",
|
|
155
|
+
"login_required",
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
159
|
+
log(
|
|
160
|
+
` [API拦截] 导航到用户页,等待 /api/post/item_list/ ... (尝试 ${attempt}/${maxRetries})`,
|
|
161
|
+
);
|
|
143
162
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
|
|
164
|
+
page,
|
|
165
|
+
username,
|
|
166
|
+
log,
|
|
167
|
+
);
|
|
147
168
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
170
|
+
// 成功拦截且拿到了视频,处理数据
|
|
171
|
+
return await processAPIResponse(
|
|
172
|
+
data,
|
|
173
|
+
username,
|
|
174
|
+
maxVideos,
|
|
175
|
+
items,
|
|
176
|
+
page,
|
|
177
|
+
apiRequestUrl,
|
|
178
|
+
log,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
151
181
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
182
|
+
// API 已返回但没有视频时,优先判定是否是确定性的页面错误。
|
|
183
|
+
let pageError = await detectPageError(page);
|
|
184
|
+
|
|
185
|
+
// service_error 经常比 API 判定晚一点渲染,补一个短等待避免漏判“被封”。
|
|
186
|
+
if (!pageError) {
|
|
187
|
+
const delayedPageError = await detectPageErrorWithWait(page, 3000);
|
|
188
|
+
if (delayedPageError) pageError = delayedPageError;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (pageError === "service_error") {
|
|
192
|
+
throw new Error(`被封: ${username}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
|
|
196
|
+
if (noRetryErrors.has(pageError)) {
|
|
197
|
+
log(
|
|
198
|
+
` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
|
|
199
|
+
);
|
|
200
|
+
return new Map();
|
|
156
201
|
}
|
|
157
202
|
|
|
158
|
-
|
|
203
|
+
log(` [API拦截] ${elapsed}ms 后 API 返回 0 条视频`);
|
|
204
|
+
return new Map();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (noRetryErrors.has(pageError)) {
|
|
159
208
|
log(
|
|
160
209
|
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
|
|
161
210
|
);
|
|
162
211
|
return new Map();
|
|
163
212
|
}
|
|
164
213
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
214
|
+
// 最后一次重试也失败了
|
|
215
|
+
if (attempt === maxRetries) {
|
|
216
|
+
if (!pageError) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`@${username} 页面异常(${elapsed}ms,${maxRetries} 次重试均未拦截到 API)`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
log(
|
|
222
|
+
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
|
|
223
|
+
);
|
|
224
|
+
return new Map();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 还有重试次数,等待后重试
|
|
228
|
+
const waitTime = 2000 * attempt;
|
|
229
|
+
log(
|
|
230
|
+
` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
|
|
174
231
|
);
|
|
175
|
-
|
|
176
|
-
// 5. 必须清理拦截器,防止累积
|
|
177
|
-
page.off("response", responseHandler);
|
|
178
|
-
page.off("request", requestHandler);
|
|
232
|
+
await new Promise((r) => setTimeout(r, waitTime));
|
|
179
233
|
}
|
|
180
234
|
}
|
|
181
235
|
|