tt-help-cli-ycl 1.3.24 → 1.3.26
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/README.md +17 -17
- package/cli.js +9 -9
- package/package.json +47 -47
- package/scripts/run-explore copy.bat +68 -68
- package/scripts/run-explore.bat +68 -68
- package/scripts/run-explore.ps1 +81 -81
- package/scripts/run-explore.sh +73 -73
- package/scripts/test-captcha-lib.mjs +68 -0
- package/scripts/test-captcha.mjs +81 -0
- package/scripts/test-incognito-lib.mjs +36 -0
- package/scripts/test-login-state.mjs +128 -0
- package/scripts/test-safe-click.mjs +45 -0
- package/src/cli/attach.js +180 -180
- package/src/cli/auto.js +240 -186
- package/src/cli/config.js +152 -152
- package/src/cli/explore.js +439 -253
- package/src/cli/info.js +88 -88
- package/src/cli/open.js +102 -0
- package/src/cli/progress.js +111 -111
- package/src/cli/refresh.js +216 -216
- package/src/cli/scrape.js +47 -47
- package/src/cli/utils.js +18 -18
- package/src/cli/videos.js +41 -41
- package/src/cli/watch.js +31 -31
- package/src/lib/api-interceptor.js +191 -191
- package/src/lib/args.js +551 -517
- package/src/lib/browser/anti-detect.js +23 -23
- package/src/lib/browser/cdp.js +237 -194
- package/src/lib/browser/health-checker.js +93 -0
- package/src/lib/browser/launch.js +43 -43
- package/src/lib/browser/page.js +156 -156
- package/src/lib/constants.js +206 -199
- package/src/lib/delay.js +54 -54
- package/src/lib/explore-fetch.js +118 -118
- package/src/lib/fetcher.js +45 -45
- package/src/lib/filter.js +66 -66
- package/src/lib/io.js +54 -54
- package/src/lib/output.js +80 -80
- package/src/lib/page-error-detector.js +71 -71
- package/src/lib/parse-ssr.mjs +69 -69
- package/src/lib/parser.js +47 -47
- package/src/lib/retry.js +45 -45
- package/src/lib/scrape.js +89 -89
- package/src/lib/tiktok-scraper.mjs +194 -194
- package/src/lib/url.js +52 -52
- package/src/main.js +44 -42
- package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
- package/src/scraper/auto-core.js +203 -203
- package/src/scraper/core.js +211 -211
- package/src/scraper/explore-core.js +201 -189
- package/src/scraper/modules/captcha-handler.js +114 -114
- package/src/scraper/modules/comment-extractor.js +74 -74
- package/src/scraper/modules/follow-extractor.js +118 -118
- package/src/scraper/modules/guess-extractor.js +51 -51
- package/src/scraper/modules/page-helpers.js +48 -48
- package/src/scraper/refresh-core.js +179 -179
- package/src/videos/core.js +101 -101
- package/src/watch/data-store.js +828 -828
- package/src/watch/public/index.html +753 -753
- package/src/watch/server.js +705 -705
package/src/cli/watch.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import { existsSync } from 'fs';
|
|
2
|
-
import { createStore } from '../watch/data-store.js';
|
|
3
|
-
import { startWatchServer, openBrowser } from '../watch/server.js';
|
|
4
|
-
|
|
5
|
-
export async function handleWatch(options) {
|
|
6
|
-
const { outputFile, watchPort } = options;
|
|
7
|
-
|
|
8
|
-
if (!outputFile) {
|
|
9
|
-
console.error('用法: tt-help watch -o <数据文件> [-p 端口]');
|
|
10
|
-
console.error('示例: tt-help watch -o data.json');
|
|
11
|
-
console.error(' tt-help watch -o data.json -p 8080');
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (!existsSync(outputFile)) {
|
|
16
|
-
console.error(`文件不存�? ${outputFile}`);
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const store = createStore(outputFile);
|
|
21
|
-
const { server, port } = await startWatchServer(outputFile, watchPort, store);
|
|
22
|
-
openBrowser(port);
|
|
23
|
-
|
|
24
|
-
process.once('SIGINT', () => {
|
|
25
|
-
store.stopBackup();
|
|
26
|
-
server.close();
|
|
27
|
-
process.exit(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
console.error('�?Ctrl+C 停止监控服务');
|
|
31
|
-
}
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { createStore } from '../watch/data-store.js';
|
|
3
|
+
import { startWatchServer, openBrowser } from '../watch/server.js';
|
|
4
|
+
|
|
5
|
+
export async function handleWatch(options) {
|
|
6
|
+
const { outputFile, watchPort } = options;
|
|
7
|
+
|
|
8
|
+
if (!outputFile) {
|
|
9
|
+
console.error('用法: tt-help watch -o <数据文件> [-p 端口]');
|
|
10
|
+
console.error('示例: tt-help watch -o data.json');
|
|
11
|
+
console.error(' tt-help watch -o data.json -p 8080');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!existsSync(outputFile)) {
|
|
16
|
+
console.error(`文件不存�? ${outputFile}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const store = createStore(outputFile);
|
|
21
|
+
const { server, port } = await startWatchServer(outputFile, watchPort, store);
|
|
22
|
+
openBrowser(port);
|
|
23
|
+
|
|
24
|
+
process.once('SIGINT', () => {
|
|
25
|
+
store.stopBackup();
|
|
26
|
+
server.close();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
console.error('�?Ctrl+C 停止监控服务');
|
|
31
|
+
}
|
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
import { delay } from './delay.js';
|
|
2
|
-
import { retryWithBackoff } from './retry.js';
|
|
3
|
-
import { assertPageUrl } from './browser/page.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 处理 API 响应数据:提取首页视频 + 翻页
|
|
7
|
-
*/
|
|
8
|
-
async function processAPIResponse(data, username, maxVideos, items, page, apiRequestUrl, log) {
|
|
9
|
-
// 提取首页视频
|
|
10
|
-
const firstPageItems = data.itemList || [];
|
|
11
|
-
for (const item of firstPageItems) {
|
|
12
|
-
if (items.length >= maxVideos) break;
|
|
13
|
-
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
14
|
-
items.push({ id: item.id, href });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
log(` [API拦截] 获取首页 ${firstPageItems.length} 条视频`);
|
|
18
|
-
|
|
19
|
-
// 翻页获取后续视频
|
|
20
|
-
let cursor = data.cursor;
|
|
21
|
-
let hasMore = data.hasMore;
|
|
22
|
-
|
|
23
|
-
while (hasMore && cursor && items.length < maxVideos) {
|
|
24
|
-
const reqUrl = apiRequestUrl || data.apiRequestUrl;
|
|
25
|
-
if (!reqUrl) {
|
|
26
|
-
log(' [API拦截] 未捕获到 API 请求 URL,无法翻页');
|
|
27
|
-
break;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const newUrl = reqUrl.replace(/cursor=\d+/, `cursor=${cursor}`);
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
const pageData = await page.evaluate(async (u) => {
|
|
34
|
-
const res = await fetch(u);
|
|
35
|
-
return await res.json();
|
|
36
|
-
}, newUrl);
|
|
37
|
-
|
|
38
|
-
if (pageData && pageData.itemList) {
|
|
39
|
-
for (const item of pageData.itemList) {
|
|
40
|
-
if (items.length >= maxVideos) break;
|
|
41
|
-
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
42
|
-
items.push({ id: item.id, href });
|
|
43
|
-
}
|
|
44
|
-
log(` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
cursor = pageData.cursor;
|
|
48
|
-
hasMore = pageData.hasMore;
|
|
49
|
-
} catch (e) {
|
|
50
|
-
log(` [API拦截] 翻页失败: ${e.message}`);
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
await delay(300, 600);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
log(` [API拦截] 总计获取 ${items.length} 条视频`);
|
|
58
|
-
|
|
59
|
-
// 转成 Map 返回,与 collectVideos 一致
|
|
60
|
-
const videoMap = new Map();
|
|
61
|
-
for (const v of items) {
|
|
62
|
-
if (!videoMap.has(v.id)) videoMap.set(v.id, v);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return videoMap;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 通过拦截 TikTok 内部 API 获取用户视频列表
|
|
70
|
-
* 比 DOM 滚动解析快 5-10 倍
|
|
71
|
-
*
|
|
72
|
-
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
73
|
-
* @param {string} username - TikTok 用户名
|
|
74
|
-
* @param {number} maxVideos - 最大视频数
|
|
75
|
-
* @param {Function} log - 日志函数
|
|
76
|
-
* @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
|
|
77
|
-
* @throws {Error} API 超时或网络异常时抛出
|
|
78
|
-
*/
|
|
79
|
-
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
80
|
-
const url = `https://www.tiktok.com/@${username}`;
|
|
81
|
-
const items = [];
|
|
82
|
-
|
|
83
|
-
// 1. 注册 response 拦截器 + request URL 捕获
|
|
84
|
-
let apiResolve = null;
|
|
85
|
-
const apiPromise = new Promise(r => { apiResolve = r; });
|
|
86
|
-
|
|
87
|
-
let apiRequestUrl = null;
|
|
88
|
-
|
|
89
|
-
const responseHandler = async (response) => {
|
|
90
|
-
if (response.url().includes('/api/post/item_list/')) {
|
|
91
|
-
try {
|
|
92
|
-
apiResolve(await response.json());
|
|
93
|
-
} catch (e) {
|
|
94
|
-
apiResolve(null);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const requestHandler = (request) => {
|
|
100
|
-
if (request.url().includes('/api/post/item_list/') && !apiRequestUrl) {
|
|
101
|
-
apiRequestUrl = request.url();
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
page.on('response', responseHandler);
|
|
106
|
-
page.on('request', requestHandler);
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
// 2. 导航并等待 API 响应
|
|
110
|
-
log(' [API拦截] 导航到用户页,等待 /api/post/item_list/ ...');
|
|
111
|
-
const t0 = Date.now();
|
|
112
|
-
|
|
113
|
-
await retryWithBackoff(async () => {
|
|
114
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
115
|
-
await assertPageUrl(page, `@${username}`);
|
|
116
|
-
}, { maxRetries: 3, baseDelay: 3000, log });
|
|
117
|
-
|
|
118
|
-
const data = await Promise.race([
|
|
119
|
-
apiPromise,
|
|
120
|
-
new Promise(r => setTimeout(() => r(null), 8000)),
|
|
121
|
-
]);
|
|
122
|
-
|
|
123
|
-
const elapsed = Date.now() - t0;
|
|
124
|
-
|
|
125
|
-
if (!data || !data.itemList) {
|
|
126
|
-
log(` [API拦截] ${elapsed}ms 后未拿到 API 数据,重试...`);
|
|
127
|
-
// 清理后重试
|
|
128
|
-
page.off('response', responseHandler);
|
|
129
|
-
page.off('request', requestHandler);
|
|
130
|
-
|
|
131
|
-
// 重试:重新注册拦截器并导航
|
|
132
|
-
let apiResolve2 = null;
|
|
133
|
-
const apiPromise2 = new Promise(r => { apiResolve2 = r; });
|
|
134
|
-
let apiRequestUrl2 = null;
|
|
135
|
-
|
|
136
|
-
const responseHandler2 = async (response) => {
|
|
137
|
-
if (response.url().includes('/api/post/item_list/')) {
|
|
138
|
-
try {
|
|
139
|
-
const json = await response.json().catch(() => null);
|
|
140
|
-
apiResolve2(json);
|
|
141
|
-
} catch (e) {
|
|
142
|
-
apiResolve2(null);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const requestHandler2 = (request) => {
|
|
148
|
-
if (request.url().includes('/api/post/item_list/') && !apiRequestUrl2) {
|
|
149
|
-
apiRequestUrl2 = request.url();
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
page.on('response', responseHandler2);
|
|
154
|
-
page.on('request', requestHandler2);
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
await retryWithBackoff(
|
|
158
|
-
async () => {
|
|
159
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
160
|
-
assertPageUrl(page, url);
|
|
161
|
-
},
|
|
162
|
-
{ retries: 1, log },
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const data2 = await Promise.race([
|
|
166
|
-
apiPromise2,
|
|
167
|
-
new Promise(r => setTimeout(() => r(null), 8000)),
|
|
168
|
-
]);
|
|
169
|
-
const elapsed2 = Date.now() - t0;
|
|
170
|
-
|
|
171
|
-
if (!data2 || !data2.itemList) {
|
|
172
|
-
throw new Error(`[API拦截] ${elapsed2}ms 后仍未拿到 API 数据(含重试),请检查网络`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return await processAPIResponse(data2, username, maxVideos, items, page, apiRequestUrl2, log);
|
|
176
|
-
} finally {
|
|
177
|
-
page.off('response', responseHandler2);
|
|
178
|
-
page.off('request', requestHandler2);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 3. 处理数据(提取首页 + 翻页)
|
|
183
|
-
return await processAPIResponse(data, username, maxVideos, items, page, apiRequestUrl, log);
|
|
184
|
-
} finally {
|
|
185
|
-
// 5. 必须清理拦截器,防止累积
|
|
186
|
-
page.off('response', responseHandler);
|
|
187
|
-
page.off('request', requestHandler);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export { fetchUserVideosAPI };
|
|
1
|
+
import { delay } from './delay.js';
|
|
2
|
+
import { retryWithBackoff } from './retry.js';
|
|
3
|
+
import { assertPageUrl } from './browser/page.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 处理 API 响应数据:提取首页视频 + 翻页
|
|
7
|
+
*/
|
|
8
|
+
async function processAPIResponse(data, username, maxVideos, items, page, apiRequestUrl, log) {
|
|
9
|
+
// 提取首页视频
|
|
10
|
+
const firstPageItems = data.itemList || [];
|
|
11
|
+
for (const item of firstPageItems) {
|
|
12
|
+
if (items.length >= maxVideos) break;
|
|
13
|
+
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
14
|
+
items.push({ id: item.id, href });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
log(` [API拦截] 获取首页 ${firstPageItems.length} 条视频`);
|
|
18
|
+
|
|
19
|
+
// 翻页获取后续视频
|
|
20
|
+
let cursor = data.cursor;
|
|
21
|
+
let hasMore = data.hasMore;
|
|
22
|
+
|
|
23
|
+
while (hasMore && cursor && items.length < maxVideos) {
|
|
24
|
+
const reqUrl = apiRequestUrl || data.apiRequestUrl;
|
|
25
|
+
if (!reqUrl) {
|
|
26
|
+
log(' [API拦截] 未捕获到 API 请求 URL,无法翻页');
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const newUrl = reqUrl.replace(/cursor=\d+/, `cursor=${cursor}`);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const pageData = await page.evaluate(async (u) => {
|
|
34
|
+
const res = await fetch(u);
|
|
35
|
+
return await res.json();
|
|
36
|
+
}, newUrl);
|
|
37
|
+
|
|
38
|
+
if (pageData && pageData.itemList) {
|
|
39
|
+
for (const item of pageData.itemList) {
|
|
40
|
+
if (items.length >= maxVideos) break;
|
|
41
|
+
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
42
|
+
items.push({ id: item.id, href });
|
|
43
|
+
}
|
|
44
|
+
log(` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cursor = pageData.cursor;
|
|
48
|
+
hasMore = pageData.hasMore;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
log(` [API拦截] 翻页失败: ${e.message}`);
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await delay(300, 600);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log(` [API拦截] 总计获取 ${items.length} 条视频`);
|
|
58
|
+
|
|
59
|
+
// 转成 Map 返回,与 collectVideos 一致
|
|
60
|
+
const videoMap = new Map();
|
|
61
|
+
for (const v of items) {
|
|
62
|
+
if (!videoMap.has(v.id)) videoMap.set(v.id, v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return videoMap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 通过拦截 TikTok 内部 API 获取用户视频列表
|
|
70
|
+
* 比 DOM 滚动解析快 5-10 倍
|
|
71
|
+
*
|
|
72
|
+
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
73
|
+
* @param {string} username - TikTok 用户名
|
|
74
|
+
* @param {number} maxVideos - 最大视频数
|
|
75
|
+
* @param {Function} log - 日志函数
|
|
76
|
+
* @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
|
|
77
|
+
* @throws {Error} API 超时或网络异常时抛出
|
|
78
|
+
*/
|
|
79
|
+
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
80
|
+
const url = `https://www.tiktok.com/@${username}`;
|
|
81
|
+
const items = [];
|
|
82
|
+
|
|
83
|
+
// 1. 注册 response 拦截器 + request URL 捕获
|
|
84
|
+
let apiResolve = null;
|
|
85
|
+
const apiPromise = new Promise(r => { apiResolve = r; });
|
|
86
|
+
|
|
87
|
+
let apiRequestUrl = null;
|
|
88
|
+
|
|
89
|
+
const responseHandler = async (response) => {
|
|
90
|
+
if (response.url().includes('/api/post/item_list/')) {
|
|
91
|
+
try {
|
|
92
|
+
apiResolve(await response.json());
|
|
93
|
+
} catch (e) {
|
|
94
|
+
apiResolve(null);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const requestHandler = (request) => {
|
|
100
|
+
if (request.url().includes('/api/post/item_list/') && !apiRequestUrl) {
|
|
101
|
+
apiRequestUrl = request.url();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
page.on('response', responseHandler);
|
|
106
|
+
page.on('request', requestHandler);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// 2. 导航并等待 API 响应
|
|
110
|
+
log(' [API拦截] 导航到用户页,等待 /api/post/item_list/ ...');
|
|
111
|
+
const t0 = Date.now();
|
|
112
|
+
|
|
113
|
+
await retryWithBackoff(async () => {
|
|
114
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
115
|
+
await assertPageUrl(page, `@${username}`);
|
|
116
|
+
}, { maxRetries: 3, baseDelay: 3000, log });
|
|
117
|
+
|
|
118
|
+
const data = await Promise.race([
|
|
119
|
+
apiPromise,
|
|
120
|
+
new Promise(r => setTimeout(() => r(null), 8000)),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const elapsed = Date.now() - t0;
|
|
124
|
+
|
|
125
|
+
if (!data || !data.itemList) {
|
|
126
|
+
log(` [API拦截] ${elapsed}ms 后未拿到 API 数据,重试...`);
|
|
127
|
+
// 清理后重试
|
|
128
|
+
page.off('response', responseHandler);
|
|
129
|
+
page.off('request', requestHandler);
|
|
130
|
+
|
|
131
|
+
// 重试:重新注册拦截器并导航
|
|
132
|
+
let apiResolve2 = null;
|
|
133
|
+
const apiPromise2 = new Promise(r => { apiResolve2 = r; });
|
|
134
|
+
let apiRequestUrl2 = null;
|
|
135
|
+
|
|
136
|
+
const responseHandler2 = async (response) => {
|
|
137
|
+
if (response.url().includes('/api/post/item_list/')) {
|
|
138
|
+
try {
|
|
139
|
+
const json = await response.json().catch(() => null);
|
|
140
|
+
apiResolve2(json);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
apiResolve2(null);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const requestHandler2 = (request) => {
|
|
148
|
+
if (request.url().includes('/api/post/item_list/') && !apiRequestUrl2) {
|
|
149
|
+
apiRequestUrl2 = request.url();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
page.on('response', responseHandler2);
|
|
154
|
+
page.on('request', requestHandler2);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await retryWithBackoff(
|
|
158
|
+
async () => {
|
|
159
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
160
|
+
assertPageUrl(page, url);
|
|
161
|
+
},
|
|
162
|
+
{ retries: 1, log },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const data2 = await Promise.race([
|
|
166
|
+
apiPromise2,
|
|
167
|
+
new Promise(r => setTimeout(() => r(null), 8000)),
|
|
168
|
+
]);
|
|
169
|
+
const elapsed2 = Date.now() - t0;
|
|
170
|
+
|
|
171
|
+
if (!data2 || !data2.itemList) {
|
|
172
|
+
throw new Error(`[API拦截] ${elapsed2}ms 后仍未拿到 API 数据(含重试),请检查网络`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return await processAPIResponse(data2, username, maxVideos, items, page, apiRequestUrl2, log);
|
|
176
|
+
} finally {
|
|
177
|
+
page.off('response', responseHandler2);
|
|
178
|
+
page.off('request', requestHandler2);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 3. 处理数据(提取首页 + 翻页)
|
|
183
|
+
return await processAPIResponse(data, username, maxVideos, items, page, apiRequestUrl, log);
|
|
184
|
+
} finally {
|
|
185
|
+
// 5. 必须清理拦截器,防止累积
|
|
186
|
+
page.off('response', responseHandler);
|
|
187
|
+
page.off('request', requestHandler);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { fetchUserVideosAPI };
|