tt-help-cli-ycl 1.3.30 → 1.3.32
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 -3
- 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 +30 -38
- 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 };
|
|
@@ -26,18 +26,13 @@ async function processAPIResponse(
|
|
|
26
26
|
items.push({ id: item.id, href });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
log(` [API拦截] 获取首页 ${firstPageItems.length} 条视频`);
|
|
30
|
-
|
|
31
29
|
// 翻页获取后续视频
|
|
32
30
|
let cursor = data.cursor;
|
|
33
31
|
let hasMore = data.hasMore;
|
|
34
32
|
|
|
35
33
|
while (hasMore && cursor && items.length < maxVideos) {
|
|
36
34
|
const reqUrl = apiRequestUrl || data.apiRequestUrl;
|
|
37
|
-
if (!reqUrl)
|
|
38
|
-
log(" [API拦截] 未捕获到 API 请求 URL,无法翻页");
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
35
|
+
if (!reqUrl) break;
|
|
41
36
|
|
|
42
37
|
const newUrl = reqUrl.replace(/cursor=\d+/, `cursor=${cursor}`);
|
|
43
38
|
|
|
@@ -53,22 +48,18 @@ async function processAPIResponse(
|
|
|
53
48
|
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
54
49
|
items.push({ id: item.id, href });
|
|
55
50
|
}
|
|
56
|
-
log(
|
|
57
|
-
` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`,
|
|
58
|
-
);
|
|
59
51
|
}
|
|
60
52
|
|
|
61
53
|
cursor = pageData.cursor;
|
|
62
54
|
hasMore = pageData.hasMore;
|
|
63
|
-
} catch
|
|
64
|
-
log(` [API拦截] 翻页失败: ${e.message}`);
|
|
55
|
+
} catch {
|
|
65
56
|
break;
|
|
66
57
|
}
|
|
67
58
|
|
|
68
59
|
await delay(300, 600);
|
|
69
60
|
}
|
|
70
61
|
|
|
71
|
-
log(` [API拦截]
|
|
62
|
+
log(` [API拦截] 共 ${items.length} 条视频`);
|
|
72
63
|
|
|
73
64
|
// 转成 Map 返回,与 collectVideos 一致
|
|
74
65
|
const videoMap = new Map();
|
|
@@ -81,9 +72,10 @@ async function processAPIResponse(
|
|
|
81
72
|
|
|
82
73
|
/**
|
|
83
74
|
* 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
|
|
75
|
+
* @param {number} apiTimeout - API 响应超时时间(ms),默认 15000
|
|
84
76
|
* @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
|
|
85
77
|
*/
|
|
86
|
-
async function tryInterceptAPI(page, username, log) {
|
|
78
|
+
async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
|
|
87
79
|
const url = `https://www.tiktok.com/@${username}`;
|
|
88
80
|
let apiRequestUrl = null;
|
|
89
81
|
|
|
@@ -108,20 +100,17 @@ async function tryInterceptAPI(page, username, log) {
|
|
|
108
100
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
109
101
|
await assertPageUrl(page, `@${username}`);
|
|
110
102
|
|
|
111
|
-
// 用 waitForResponse 精确等待 API
|
|
103
|
+
// 用 waitForResponse 精确等待 API 响应(超时时间可配置)
|
|
112
104
|
const response = await page.waitForResponse(
|
|
113
105
|
(res) => res.url().includes("/api/post/item_list/"),
|
|
114
|
-
{ timeout:
|
|
106
|
+
{ timeout: apiTimeout },
|
|
115
107
|
);
|
|
116
108
|
|
|
117
109
|
const data = await response.json();
|
|
118
110
|
const elapsed = Date.now() - t0;
|
|
119
|
-
log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
|
|
120
|
-
|
|
121
111
|
return { data, apiRequestUrl, elapsed };
|
|
122
112
|
} catch (e) {
|
|
123
113
|
const elapsed = Date.now() - t0;
|
|
124
|
-
log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
|
|
125
114
|
return { data: null, apiRequestUrl, elapsed, error: e.message };
|
|
126
115
|
} finally {
|
|
127
116
|
page.off("response", responseHandler);
|
|
@@ -135,8 +124,11 @@ async function tryInterceptAPI(page, username, log) {
|
|
|
135
124
|
*
|
|
136
125
|
* 弱网优化:
|
|
137
126
|
* - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
|
|
138
|
-
* - 最多重试 3
|
|
139
|
-
*
|
|
127
|
+
* - 最多重试 3 次,递增等待 + 递增超时:
|
|
128
|
+
* - 第 1 次:等待 0s, 超时 15s → 正常尝试
|
|
129
|
+
* - 第 2 次:等待 3s, 超时 20s → 短暂波动恢复
|
|
130
|
+
* - 第 3 次:等待 10s, 超时 30s → 网络缓慢给更多时间
|
|
131
|
+
* - 每次重试前清理拦截器
|
|
140
132
|
*
|
|
141
133
|
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
142
134
|
* @param {string} username - TikTok 用户名
|
|
@@ -148,6 +140,12 @@ async function tryInterceptAPI(page, username, log) {
|
|
|
148
140
|
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
149
141
|
const items = [];
|
|
150
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
|
+
];
|
|
151
149
|
const noRetryErrors = new Set([
|
|
152
150
|
"service_error",
|
|
153
151
|
"not_found",
|
|
@@ -156,14 +154,20 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
156
154
|
]);
|
|
157
155
|
|
|
158
156
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
162
165
|
|
|
163
166
|
const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
|
|
164
167
|
page,
|
|
165
168
|
username,
|
|
166
169
|
log,
|
|
170
|
+
apiTimeout,
|
|
167
171
|
);
|
|
168
172
|
|
|
169
173
|
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
@@ -194,20 +198,13 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
194
198
|
|
|
195
199
|
if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
|
|
196
200
|
if (noRetryErrors.has(pageError)) {
|
|
197
|
-
log(
|
|
198
|
-
` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
|
|
199
|
-
);
|
|
200
201
|
return new Map();
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
log(` [API拦截] ${elapsed}ms 后 API 返回 0 条视频`);
|
|
204
204
|
return new Map();
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
if (noRetryErrors.has(pageError)) {
|
|
208
|
-
log(
|
|
209
|
-
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
|
|
210
|
-
);
|
|
211
208
|
return new Map();
|
|
212
209
|
}
|
|
213
210
|
|
|
@@ -215,20 +212,15 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
215
212
|
if (attempt === maxRetries) {
|
|
216
213
|
if (!pageError) {
|
|
217
214
|
throw new Error(
|
|
218
|
-
`@${username} 页面异常(${
|
|
215
|
+
`@${username} 页面异常(${maxRetries} 次重试均未拦截到 API)`,
|
|
219
216
|
);
|
|
220
217
|
}
|
|
221
|
-
log(
|
|
222
|
-
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
|
|
223
|
-
);
|
|
224
218
|
return new Map();
|
|
225
219
|
}
|
|
226
220
|
|
|
227
221
|
// 还有重试次数,等待后重试
|
|
228
|
-
const waitTime =
|
|
229
|
-
log(
|
|
230
|
-
` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
|
|
231
|
-
);
|
|
222
|
+
const [waitTime] = retryStrategy[attempt - 1];
|
|
223
|
+
log(` [API拦截] 失败,${waitTime / 1000}s 后重试...`);
|
|
232
224
|
await new Promise((r) => setTimeout(r, waitTime));
|
|
233
225
|
}
|
|
234
226
|
}
|