tt-help-cli-ycl 1.3.29 → 1.3.31
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/scripts/run-explore.bat +32 -4
- package/scripts/run-explore.ps1 +28 -2
- package/scripts/run-explore.sh +28 -3
- package/src/cli/comments.js +523 -0
- package/src/cli/explore.js +16 -8
- package/src/cli/videostats.js +81 -0
- package/src/lib/api-interceptor-comment.js +126 -0
- package/src/lib/api-interceptor.js +41 -40
- package/src/lib/args.js +278 -112
- package/src/lib/browser/cdp.js +93 -69
- package/src/lib/browser/health-checker.js +23 -11
- package/src/lib/browser/page.js +62 -35
- package/src/lib/constants.js +7 -0
- package/src/main.js +9 -5
- package/src/scraper/modules/comment-extractor.js +38 -59
- package/src/watch/data-store.js +28 -1
- package/src/watch/server.js +30 -2
package/src/cli/explore.js
CHANGED
|
@@ -71,7 +71,8 @@ export async function handleExplore(options) {
|
|
|
71
71
|
exploreMaxUsers,
|
|
72
72
|
serverUrl,
|
|
73
73
|
explorePort,
|
|
74
|
-
|
|
74
|
+
exploreBasePort,
|
|
75
|
+
explorePortCount,
|
|
75
76
|
exploreUserId,
|
|
76
77
|
exploreMaxVideos,
|
|
77
78
|
} = options;
|
|
@@ -100,28 +101,35 @@ export async function handleExplore(options) {
|
|
|
100
101
|
console.error(`服务器: ${serverUrl}(断开会自动重连)`);
|
|
101
102
|
if (exploreMaxUsers > 0) console.error(`上限: ${exploreMaxUsers} 个用户`);
|
|
102
103
|
|
|
103
|
-
const healthChecker = new HealthChecker(
|
|
104
|
+
const healthChecker = new HealthChecker({
|
|
105
|
+
basePort: exploreBasePort,
|
|
106
|
+
totalAccounts: explorePortCount,
|
|
107
|
+
});
|
|
104
108
|
let currentAccount = healthChecker.getCurrentAccount();
|
|
105
109
|
|
|
106
110
|
const cdpOptions = {};
|
|
107
|
-
if (explorePort)
|
|
108
|
-
|
|
111
|
+
if (explorePort) {
|
|
112
|
+
// 固定端口模式(调试用,关闭自动轮换)
|
|
113
|
+
cdpOptions.port = explorePort;
|
|
109
114
|
cdpOptions.userDataDir = path.join(
|
|
110
115
|
os.homedir(),
|
|
111
116
|
"Library",
|
|
112
117
|
"Application Support",
|
|
113
|
-
`Microsoft Edge For
|
|
118
|
+
`Microsoft Edge For Testing_p${explorePort}`,
|
|
114
119
|
);
|
|
115
120
|
} else {
|
|
116
121
|
cdpOptions.port = currentAccount.port;
|
|
117
122
|
cdpOptions.userDataDir = currentAccount.userDataDir;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
console.error(`CDP 端口: ${cdpOptions.port
|
|
121
|
-
|
|
125
|
+
console.error(`CDP 端口: ${cdpOptions.port}, 用户编号: ${userId}`);
|
|
126
|
+
console.error(`浏览器配置: ${path.basename(cdpOptions.userDataDir)}`);
|
|
127
|
+
if (!explorePort) {
|
|
128
|
+
const portRange = `${currentAccount.port}-${currentAccount.port + healthChecker.accounts.length - 1}`;
|
|
122
129
|
console.error(
|
|
123
|
-
|
|
130
|
+
`端口轮换范围: ${portRange}(共 ${healthChecker.accounts.length} 个)`,
|
|
124
131
|
);
|
|
132
|
+
}
|
|
125
133
|
|
|
126
134
|
let browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
127
135
|
const { processExplore } = await import("../scraper/explore-core.js");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { TikTokScraper } from '../lib/tiktok-scraper.mjs';
|
|
3
|
+
|
|
4
|
+
export async function handleVideoStats(options) {
|
|
5
|
+
const { statsFile, statsParallel } = options;
|
|
6
|
+
|
|
7
|
+
if (!statsFile) {
|
|
8
|
+
console.error('用法: tt-help videostats <文件路径> -p <并发数>');
|
|
9
|
+
console.error('示例: tt-help videostats data/result-videos.json -p 3');
|
|
10
|
+
console.error('');
|
|
11
|
+
console.error('选项: -p, --parallel <N> 并发数(默认: 3)');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 读取文件
|
|
16
|
+
const videos = JSON.parse(readFileSync(statsFile, 'utf-8'));
|
|
17
|
+
if (!Array.isArray(videos) || videos.length === 0) {
|
|
18
|
+
console.error('[videostats] 文件为空或格式错误');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const poolSize = Math.min(statsParallel, videos.length);
|
|
23
|
+
console.error(`[videostats] 读取 ${statsFile}: ${videos.length} 个视频,并发: ${poolSize}`);
|
|
24
|
+
|
|
25
|
+
// 初始化 scraper
|
|
26
|
+
const scraper = new TikTokScraper({ poolSize });
|
|
27
|
+
await scraper.init();
|
|
28
|
+
console.error('[videostats] 浏览器初始化完成,开始刷新...');
|
|
29
|
+
|
|
30
|
+
let success = 0, failed = 0, skipped = 0;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
for (let i = 0; i < videos.length; i += poolSize) {
|
|
34
|
+
const batch = videos.slice(i, i + poolSize);
|
|
35
|
+
|
|
36
|
+
const results = await Promise.allSettled(
|
|
37
|
+
batch.map(async (v) => {
|
|
38
|
+
if (!v.href) return { video: v, error: 'no href' };
|
|
39
|
+
try {
|
|
40
|
+
const info = await scraper.getVideoInfo(v.href);
|
|
41
|
+
if (!info) return { video: v, error: 'no info' };
|
|
42
|
+
return { video: v, info };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { video: v, error: err.message };
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
for (const result of results) {
|
|
50
|
+
if (result.status === 'fulfilled') {
|
|
51
|
+
const { video, info, error } = result.value;
|
|
52
|
+
if (info && info.stats) {
|
|
53
|
+
video.stats = info.stats;
|
|
54
|
+
success++;
|
|
55
|
+
} else {
|
|
56
|
+
failed++;
|
|
57
|
+
const id = video.id || video.href || '?';
|
|
58
|
+
console.error(` [失败] ${id.substring(0, 30)} - ${error || '获取失败'}`);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
failed++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const processed = Math.min(i + poolSize, videos.length);
|
|
66
|
+
console.error(`[videostats] 进度: ${processed}/${videos.length} (成功: ${success}, 失败: ${failed})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 写回文件
|
|
70
|
+
writeFileSync(statsFile, JSON.stringify(videos, null, 2), 'utf-8');
|
|
71
|
+
console.error(``);
|
|
72
|
+
console.error(`[videostats] 完成: 成功 ${success}, 失败 ${failed}`);
|
|
73
|
+
console.error(`[videostats] 已更新 ${statsFile}`);
|
|
74
|
+
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`[videostats] 异常: ${err.message}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
} finally {
|
|
79
|
+
await scraper.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { delay } from "./delay.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 拦截 /api/comment/list/ API,获取评论数据
|
|
5
|
+
*
|
|
6
|
+
* 流程:注册拦截器 → 点击评论 tab → 捕获 API response → 翻页获取全部评论
|
|
7
|
+
*
|
|
8
|
+
* @param {Page} page - Playwright page
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {number} options.maxComments - 最大评论数
|
|
11
|
+
* @param {function} options.log - 日志函数
|
|
12
|
+
* @param {function} options.onCaptcha - 验证码检测回调 (page) => Promise<{detected: boolean}>
|
|
13
|
+
* @returns {Promise<{comments: Array, total: number, captchaDetected: boolean, error: string|null}>}
|
|
14
|
+
*/
|
|
15
|
+
async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log, onCaptcha } = {}) {
|
|
16
|
+
// 先注册 API 拦截器,再点 tab(顺序不能反)
|
|
17
|
+
let apiResolve = null;
|
|
18
|
+
let apiRequestUrl = null;
|
|
19
|
+
const apiPromise = new Promise(r => { apiResolve = r; });
|
|
20
|
+
|
|
21
|
+
const handler = async (response) => {
|
|
22
|
+
const url = response.url();
|
|
23
|
+
if (response.status() === 200 && url.includes('/api/comment/list/') && !apiRequestUrl) {
|
|
24
|
+
apiRequestUrl = url;
|
|
25
|
+
try {
|
|
26
|
+
apiResolve(await response.json());
|
|
27
|
+
} catch (e) {
|
|
28
|
+
apiResolve(null);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
page.on('response', handler);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// 点击评论 tab 触发 API
|
|
37
|
+
log(' [API拦截] 点击评论 tab...');
|
|
38
|
+
const tabs = page.locator('[class*="tabbar-item"]');
|
|
39
|
+
const commentTab = tabs.filter({ hasText: /评论|Comment/ });
|
|
40
|
+
const count = await commentTab.count();
|
|
41
|
+
|
|
42
|
+
if (count === 0) {
|
|
43
|
+
return { comments: [], total: 0, captchaDetected: false, error: '未找到评论 tab' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await commentTab.first().click({ force: true });
|
|
47
|
+
|
|
48
|
+
// 等待 API 响应
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
const data = await Promise.race([
|
|
51
|
+
apiPromise,
|
|
52
|
+
page.waitForTimeout(10000).then(() => null),
|
|
53
|
+
]);
|
|
54
|
+
const elapsed = Date.now() - t0;
|
|
55
|
+
|
|
56
|
+
if (!data || !apiRequestUrl) {
|
|
57
|
+
log(` [API拦截] 点击评论 tab 后 ${elapsed}ms 未拿到 API 响应`);
|
|
58
|
+
return { comments: [], total: 0, captchaDetected: false, error: 'API 超时或未响应' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 验证码检测(API 拿完后检测)
|
|
62
|
+
let captchaDetected = false;
|
|
63
|
+
if (onCaptcha) {
|
|
64
|
+
try {
|
|
65
|
+
const captchaResult = await onCaptcha(page);
|
|
66
|
+
captchaDetected = !!captchaResult.detected;
|
|
67
|
+
if (captchaDetected) {
|
|
68
|
+
log(' [API拦截] 检测到验证码');
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
log(` [API拦截] 验证码检测异常: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const items = data.comments || [];
|
|
76
|
+
log(` [API拦截] ${elapsed}ms 后拿到 ${items.length} 条评论 (total: ${data.total || '?'})`);
|
|
77
|
+
|
|
78
|
+
if (items.length >= maxComments) {
|
|
79
|
+
return { comments: items.slice(0, maxComments), total: data.total || 0, captchaDetected };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 翻页
|
|
83
|
+
let cursor = data.cursor;
|
|
84
|
+
let hasMore = data.has_more;
|
|
85
|
+
let pageNum = 1;
|
|
86
|
+
|
|
87
|
+
while (hasMore && cursor && items.length < maxComments) {
|
|
88
|
+
pageNum++;
|
|
89
|
+
const pageUrl = apiRequestUrl.replace(/cursor=([^&]+)/, `cursor=${cursor}`);
|
|
90
|
+
|
|
91
|
+
const pageData = await page.evaluate(async (u) => {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(u);
|
|
94
|
+
return await res.json();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { error: e.message };
|
|
97
|
+
}
|
|
98
|
+
}, pageUrl);
|
|
99
|
+
|
|
100
|
+
if (pageData.error) {
|
|
101
|
+
log(` [API拦截] 翻页 ${pageNum} 失败: ${pageData.error}`);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pageComments = pageData.comments || [];
|
|
106
|
+
log(` [API拦截] 翻页 ${pageNum}: ${pageComments.length} 条 (累计: ${items.length + pageComments.length})`);
|
|
107
|
+
|
|
108
|
+
items.push(...pageComments);
|
|
109
|
+
cursor = pageData.cursor;
|
|
110
|
+
hasMore = pageData.has_more;
|
|
111
|
+
|
|
112
|
+
if (items.length >= maxComments) break;
|
|
113
|
+
|
|
114
|
+
await delay(200, 500);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = items.slice(0, maxComments);
|
|
118
|
+
log(` [API拦截] 共 ${result.length} 条评论, ${pageNum} 页`);
|
|
119
|
+
|
|
120
|
+
return { comments: result, total: data.total || 0, captchaDetected };
|
|
121
|
+
} finally {
|
|
122
|
+
page.off('response', handler);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { fetchUserCommentsAPI };
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { delay } from "./delay.js";
|
|
2
2
|
import { retryWithBackoff } from "./retry.js";
|
|
3
3
|
import { assertPageUrl } from "./browser/page.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
detectPageError,
|
|
6
|
+
detectPageErrorWithWait,
|
|
7
|
+
} from "./page-error-detector.js";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* 处理 API 响应数据:提取首页视频 + 翻页
|
|
@@ -23,18 +26,13 @@ async function processAPIResponse(
|
|
|
23
26
|
items.push({ id: item.id, href });
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
log(` [API拦截] 获取首页 ${firstPageItems.length} 条视频`);
|
|
27
|
-
|
|
28
29
|
// 翻页获取后续视频
|
|
29
30
|
let cursor = data.cursor;
|
|
30
31
|
let hasMore = data.hasMore;
|
|
31
32
|
|
|
32
33
|
while (hasMore && cursor && items.length < maxVideos) {
|
|
33
34
|
const reqUrl = apiRequestUrl || data.apiRequestUrl;
|
|
34
|
-
if (!reqUrl)
|
|
35
|
-
log(" [API拦截] 未捕获到 API 请求 URL,无法翻页");
|
|
36
|
-
break;
|
|
37
|
-
}
|
|
35
|
+
if (!reqUrl) break;
|
|
38
36
|
|
|
39
37
|
const newUrl = reqUrl.replace(/cursor=\d+/, `cursor=${cursor}`);
|
|
40
38
|
|
|
@@ -50,22 +48,18 @@ async function processAPIResponse(
|
|
|
50
48
|
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
51
49
|
items.push({ id: item.id, href });
|
|
52
50
|
}
|
|
53
|
-
log(
|
|
54
|
-
` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`,
|
|
55
|
-
);
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
cursor = pageData.cursor;
|
|
59
54
|
hasMore = pageData.hasMore;
|
|
60
|
-
} catch
|
|
61
|
-
log(` [API拦截] 翻页失败: ${e.message}`);
|
|
55
|
+
} catch {
|
|
62
56
|
break;
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
await delay(300, 600);
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
log(` [API拦截]
|
|
62
|
+
log(` [API拦截] 共 ${items.length} 条视频`);
|
|
69
63
|
|
|
70
64
|
// 转成 Map 返回,与 collectVideos 一致
|
|
71
65
|
const videoMap = new Map();
|
|
@@ -78,9 +72,10 @@ async function processAPIResponse(
|
|
|
78
72
|
|
|
79
73
|
/**
|
|
80
74
|
* 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
|
|
75
|
+
* @param {number} apiTimeout - API 响应超时时间(ms),默认 15000
|
|
81
76
|
* @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
|
|
82
77
|
*/
|
|
83
|
-
async function tryInterceptAPI(page, username, log) {
|
|
78
|
+
async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
|
|
84
79
|
const url = `https://www.tiktok.com/@${username}`;
|
|
85
80
|
let apiRequestUrl = null;
|
|
86
81
|
|
|
@@ -105,20 +100,17 @@ async function tryInterceptAPI(page, username, log) {
|
|
|
105
100
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
106
101
|
await assertPageUrl(page, `@${username}`);
|
|
107
102
|
|
|
108
|
-
// 用 waitForResponse 精确等待 API
|
|
103
|
+
// 用 waitForResponse 精确等待 API 响应(超时时间可配置)
|
|
109
104
|
const response = await page.waitForResponse(
|
|
110
105
|
(res) => res.url().includes("/api/post/item_list/"),
|
|
111
|
-
{ timeout:
|
|
106
|
+
{ timeout: apiTimeout },
|
|
112
107
|
);
|
|
113
108
|
|
|
114
109
|
const data = await response.json();
|
|
115
110
|
const elapsed = Date.now() - t0;
|
|
116
|
-
log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
|
|
117
|
-
|
|
118
111
|
return { data, apiRequestUrl, elapsed };
|
|
119
112
|
} catch (e) {
|
|
120
113
|
const elapsed = Date.now() - t0;
|
|
121
|
-
log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
|
|
122
114
|
return { data: null, apiRequestUrl, elapsed, error: e.message };
|
|
123
115
|
} finally {
|
|
124
116
|
page.off("response", responseHandler);
|
|
@@ -132,8 +124,11 @@ async function tryInterceptAPI(page, username, log) {
|
|
|
132
124
|
*
|
|
133
125
|
* 弱网优化:
|
|
134
126
|
* - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
|
|
135
|
-
* - 最多重试 3
|
|
136
|
-
*
|
|
127
|
+
* - 最多重试 3 次,递增等待 + 递增超时:
|
|
128
|
+
* - 第 1 次:等待 0s, 超时 15s → 正常尝试
|
|
129
|
+
* - 第 2 次:等待 3s, 超时 20s → 短暂波动恢复
|
|
130
|
+
* - 第 3 次:等待 10s, 超时 30s → 网络缓慢给更多时间
|
|
131
|
+
* - 每次重试前清理拦截器
|
|
137
132
|
*
|
|
138
133
|
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
139
134
|
* @param {string} username - TikTok 用户名
|
|
@@ -145,6 +140,12 @@ async function tryInterceptAPI(page, username, log) {
|
|
|
145
140
|
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
146
141
|
const items = [];
|
|
147
142
|
const maxRetries = 3;
|
|
143
|
+
// 重试策略:[等待时间(ms), API超时(ms)]
|
|
144
|
+
const retryStrategy = [
|
|
145
|
+
[0, 15000], // 第 1 次:不等待,15s 超时
|
|
146
|
+
[3000, 20000], // 第 2 次:等 3s,20s 超时(短暂波动)
|
|
147
|
+
[10000, 30000], // 第 3 次:等 10s,30s 超时(网络缓慢)
|
|
148
|
+
];
|
|
148
149
|
const noRetryErrors = new Set([
|
|
149
150
|
"service_error",
|
|
150
151
|
"not_found",
|
|
@@ -153,14 +154,20 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
153
154
|
]);
|
|
154
155
|
|
|
155
156
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const [, apiTimeout] = retryStrategy[attempt - 1];
|
|
158
|
+
if (attempt > 1) {
|
|
159
|
+
log(
|
|
160
|
+
` [API拦截] 重试 ${attempt}/${maxRetries} (超时 ${apiTimeout / 1000}s)`,
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
log(` [API拦截] 获取 @${username} 视频 ...`);
|
|
164
|
+
}
|
|
159
165
|
|
|
160
166
|
const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
|
|
161
167
|
page,
|
|
162
168
|
username,
|
|
163
169
|
log,
|
|
170
|
+
apiTimeout,
|
|
164
171
|
);
|
|
165
172
|
|
|
166
173
|
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
@@ -177,7 +184,13 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
// API 已返回但没有视频时,优先判定是否是确定性的页面错误。
|
|
180
|
-
|
|
187
|
+
let pageError = await detectPageError(page);
|
|
188
|
+
|
|
189
|
+
// service_error 经常比 API 判定晚一点渲染,补一个短等待避免漏判“被封”。
|
|
190
|
+
if (!pageError) {
|
|
191
|
+
const delayedPageError = await detectPageErrorWithWait(page, 3000);
|
|
192
|
+
if (delayedPageError) pageError = delayedPageError;
|
|
193
|
+
}
|
|
181
194
|
|
|
182
195
|
if (pageError === "service_error") {
|
|
183
196
|
throw new Error(`被封: ${username}`);
|
|
@@ -185,20 +198,13 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
185
198
|
|
|
186
199
|
if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
|
|
187
200
|
if (noRetryErrors.has(pageError)) {
|
|
188
|
-
log(
|
|
189
|
-
` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
|
|
190
|
-
);
|
|
191
201
|
return new Map();
|
|
192
202
|
}
|
|
193
203
|
|
|
194
|
-
log(` [API拦截] ${elapsed}ms 后 API 返回 0 条视频`);
|
|
195
204
|
return new Map();
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
if (noRetryErrors.has(pageError)) {
|
|
199
|
-
log(
|
|
200
|
-
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
|
|
201
|
-
);
|
|
202
208
|
return new Map();
|
|
203
209
|
}
|
|
204
210
|
|
|
@@ -206,20 +212,15 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
206
212
|
if (attempt === maxRetries) {
|
|
207
213
|
if (!pageError) {
|
|
208
214
|
throw new Error(
|
|
209
|
-
`@${username} 页面异常(${
|
|
215
|
+
`@${username} 页面异常(${maxRetries} 次重试均未拦截到 API)`,
|
|
210
216
|
);
|
|
211
217
|
}
|
|
212
|
-
log(
|
|
213
|
-
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
|
|
214
|
-
);
|
|
215
218
|
return new Map();
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
// 还有重试次数,等待后重试
|
|
219
|
-
const waitTime =
|
|
220
|
-
log(
|
|
221
|
-
` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
|
|
222
|
-
);
|
|
222
|
+
const [waitTime] = retryStrategy[attempt - 1];
|
|
223
|
+
log(` [API拦截] 失败,${waitTime / 1000}s 后重试...`);
|
|
223
224
|
await new Promise((r) => setTimeout(r, waitTime));
|
|
224
225
|
}
|
|
225
226
|
}
|