tt-help-cli-ycl 1.3.26 → 1.3.27
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 +39 -14
- package/src/lib/api-interceptor.js +63 -72
- package/src/lib/browser/health-checker.js +11 -2
- package/src/lib/page-error-detector.js +34 -0
- package/src/scraper/explore-core.js +9 -43
- package/src/videos/core.js +46 -22
package/package.json
CHANGED
package/src/cli/explore.js
CHANGED
|
@@ -125,9 +125,14 @@ export async function handleExplore(options) {
|
|
|
125
125
|
|
|
126
126
|
let browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
127
127
|
const { processExplore } = await import("../scraper/explore-core.js");
|
|
128
|
+
const { isLoggedIn } = await import("../lib/browser/page.js");
|
|
128
129
|
|
|
129
130
|
const page = await getOrCreatePage(browser);
|
|
130
131
|
|
|
132
|
+
// 检测登录状态(启动时只检测一次)
|
|
133
|
+
let loggedIn = await isLoggedIn(page);
|
|
134
|
+
console.error(`登录状态: ${loggedIn ? "已登录" : "未登录"}`);
|
|
135
|
+
|
|
131
136
|
// 全局拦截图片资源,减少内存占用和加载时间
|
|
132
137
|
await page.route("**/*", (route) => {
|
|
133
138
|
const resourceType = route.request().resourceType();
|
|
@@ -169,16 +174,22 @@ export async function handleExplore(options) {
|
|
|
169
174
|
userDataDir: nextAccount.userDataDir,
|
|
170
175
|
});
|
|
171
176
|
console.error(`[健康检查] 已切换到端口 ${nextAccount.port}`);
|
|
177
|
+
// 切换账户后重新检测登录状态
|
|
178
|
+
loggedIn = await isLoggedIn(page);
|
|
179
|
+
console.error(
|
|
180
|
+
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
|
181
|
+
);
|
|
172
182
|
lastFollowSuccessTime = Date.now(); // 切换账户后重置
|
|
183
|
+
captchaCount = 0; // 重置验证码计数
|
|
184
|
+
consecutiveNetworkErrors = 0; // 重置网络错误计数
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
let processedCount = 0;
|
|
176
188
|
let errorCount = 0;
|
|
177
189
|
let consecutiveNetworkErrors = 0;
|
|
178
190
|
let captchaCount = 0; // 验证码累计计数
|
|
179
|
-
let followBlockedCount = 0; // 关注/粉丝封禁计数
|
|
180
191
|
let lastFollowSuccessTime = Date.now(); // 最近一次成功获取关注/粉丝的时间
|
|
181
|
-
const FOLLOW_BLOCK_THRESHOLD =
|
|
192
|
+
const FOLLOW_BLOCK_THRESHOLD = 10 * 60 * 1000; // 10分钟
|
|
182
193
|
|
|
183
194
|
while (true) {
|
|
184
195
|
const job = await apiGet(
|
|
@@ -196,6 +207,20 @@ export async function handleExplore(options) {
|
|
|
196
207
|
const checkResult = healthChecker.check();
|
|
197
208
|
if (checkResult.shouldSwitch) {
|
|
198
209
|
await handleAccountSwitch(checkResult.reason);
|
|
210
|
+
} else if (checkResult.info && processedCount % 10 === 0) {
|
|
211
|
+
// 关注/粉丝封禁检测状态(仅登录状态)
|
|
212
|
+
let followStatus = "";
|
|
213
|
+
if (loggedIn && exploreEnableFollow) {
|
|
214
|
+
const followElapsed = Math.round(
|
|
215
|
+
(Date.now() - lastFollowSuccessTime) / 60000,
|
|
216
|
+
);
|
|
217
|
+
const followRemaining = Math.max(
|
|
218
|
+
0,
|
|
219
|
+
Math.round(FOLLOW_BLOCK_THRESHOLD / 60000) - followElapsed,
|
|
220
|
+
);
|
|
221
|
+
followStatus = ` | 关注/粉丝上次成功 ${followElapsed} 分钟前 | 封禁检测 ${followRemaining} 分钟后`;
|
|
222
|
+
}
|
|
223
|
+
console.error(`\n[健康检查] ${checkResult.info}${followStatus}`);
|
|
199
224
|
}
|
|
200
225
|
|
|
201
226
|
// 切换任务前检测验证码
|
|
@@ -235,18 +260,16 @@ export async function handleExplore(options) {
|
|
|
235
260
|
}
|
|
236
261
|
}
|
|
237
262
|
|
|
238
|
-
//
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
await handleAccountSwitch(`关注/粉丝功能被封 (${followBlockedCount})`);
|
|
249
|
-
lastFollowSuccessTime = Date.now();
|
|
263
|
+
// 关注/粉丝封禁检测移到健康检查中(仅登录状态下)
|
|
264
|
+
if (loggedIn && exploreEnableFollow) {
|
|
265
|
+
const followElapsed = Date.now() - lastFollowSuccessTime;
|
|
266
|
+
if (followElapsed > FOLLOW_BLOCK_THRESHOLD) {
|
|
267
|
+
const minutes = Math.round(followElapsed / 60000);
|
|
268
|
+
console.error(
|
|
269
|
+
`\n[封禁检测] 关注/粉丝功能已 ${minutes} 分钟未成功获取,切换到下一个账户...`,
|
|
270
|
+
);
|
|
271
|
+
await handleAccountSwitch(`关注/粉丝功能被封`);
|
|
272
|
+
}
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
if (consecutiveNetworkErrors > 0) {
|
|
@@ -275,6 +298,7 @@ export async function handleExplore(options) {
|
|
|
275
298
|
{
|
|
276
299
|
maxVideos: exploreMaxVideos,
|
|
277
300
|
enableFollow: exploreEnableFollow,
|
|
301
|
+
loggedIn, // 传入登录状态,避免每次调用 isLoggedIn(page)
|
|
278
302
|
maxFollowing: exploreMaxFollowing,
|
|
279
303
|
maxFollowers: exploreMaxFollowers,
|
|
280
304
|
location: exploreLocation,
|
|
@@ -299,6 +323,7 @@ export async function handleExplore(options) {
|
|
|
299
323
|
{
|
|
300
324
|
maxVideos: exploreMaxVideos,
|
|
301
325
|
enableFollow: exploreEnableFollow,
|
|
326
|
+
loggedIn, // 传入登录状态
|
|
302
327
|
maxFollowing: exploreMaxFollowing,
|
|
303
328
|
maxFollowers: exploreMaxFollowers,
|
|
304
329
|
location: exploreLocation,
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import { delay } from
|
|
2
|
-
import { retryWithBackoff } from
|
|
3
|
-
import { assertPageUrl } from
|
|
1
|
+
import { delay } from "./delay.js";
|
|
2
|
+
import { retryWithBackoff } from "./retry.js";
|
|
3
|
+
import { assertPageUrl } from "./browser/page.js";
|
|
4
|
+
import {
|
|
5
|
+
detectPageError,
|
|
6
|
+
detectPageErrorWithWait,
|
|
7
|
+
} from "./page-error-detector.js";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* 处理 API 响应数据:提取首页视频 + 翻页
|
|
7
11
|
*/
|
|
8
|
-
async function processAPIResponse(
|
|
12
|
+
async function processAPIResponse(
|
|
13
|
+
data,
|
|
14
|
+
username,
|
|
15
|
+
maxVideos,
|
|
16
|
+
items,
|
|
17
|
+
page,
|
|
18
|
+
apiRequestUrl,
|
|
19
|
+
log,
|
|
20
|
+
) {
|
|
9
21
|
// 提取首页视频
|
|
10
22
|
const firstPageItems = data.itemList || [];
|
|
11
23
|
for (const item of firstPageItems) {
|
|
@@ -23,7 +35,7 @@ async function processAPIResponse(data, username, maxVideos, items, page, apiReq
|
|
|
23
35
|
while (hasMore && cursor && items.length < maxVideos) {
|
|
24
36
|
const reqUrl = apiRequestUrl || data.apiRequestUrl;
|
|
25
37
|
if (!reqUrl) {
|
|
26
|
-
log(
|
|
38
|
+
log(" [API拦截] 未捕获到 API 请求 URL,无法翻页");
|
|
27
39
|
break;
|
|
28
40
|
}
|
|
29
41
|
|
|
@@ -41,7 +53,9 @@ async function processAPIResponse(data, username, maxVideos, items, page, apiReq
|
|
|
41
53
|
const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
|
|
42
54
|
items.push({ id: item.id, href });
|
|
43
55
|
}
|
|
44
|
-
log(
|
|
56
|
+
log(
|
|
57
|
+
` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`,
|
|
58
|
+
);
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
cursor = pageData.cursor;
|
|
@@ -82,12 +96,14 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
82
96
|
|
|
83
97
|
// 1. 注册 response 拦截器 + request URL 捕获
|
|
84
98
|
let apiResolve = null;
|
|
85
|
-
const apiPromise = new Promise(r => {
|
|
99
|
+
const apiPromise = new Promise((r) => {
|
|
100
|
+
apiResolve = r;
|
|
101
|
+
});
|
|
86
102
|
|
|
87
103
|
let apiRequestUrl = null;
|
|
88
104
|
|
|
89
105
|
const responseHandler = async (response) => {
|
|
90
|
-
if (response.url().includes(
|
|
106
|
+
if (response.url().includes("/api/post/item_list/")) {
|
|
91
107
|
try {
|
|
92
108
|
apiResolve(await response.json());
|
|
93
109
|
} catch (e) {
|
|
@@ -97,94 +113,69 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
|
97
113
|
};
|
|
98
114
|
|
|
99
115
|
const requestHandler = (request) => {
|
|
100
|
-
if (request.url().includes(
|
|
116
|
+
if (request.url().includes("/api/post/item_list/") && !apiRequestUrl) {
|
|
101
117
|
apiRequestUrl = request.url();
|
|
102
118
|
}
|
|
103
119
|
};
|
|
104
120
|
|
|
105
|
-
page.on(
|
|
106
|
-
page.on(
|
|
121
|
+
page.on("response", responseHandler);
|
|
122
|
+
page.on("request", requestHandler);
|
|
107
123
|
|
|
108
124
|
try {
|
|
109
125
|
// 2. 导航并等待 API 响应
|
|
110
|
-
log(
|
|
126
|
+
log(" [API拦截] 导航到用户页,等待 /api/post/item_list/ ...");
|
|
111
127
|
const t0 = Date.now();
|
|
112
128
|
|
|
113
|
-
await retryWithBackoff(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
await retryWithBackoff(
|
|
130
|
+
async () => {
|
|
131
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
132
|
+
await assertPageUrl(page, `@${username}`);
|
|
133
|
+
},
|
|
134
|
+
{ maxRetries: 3, baseDelay: 3000, log },
|
|
135
|
+
);
|
|
117
136
|
|
|
118
137
|
const data = await Promise.race([
|
|
119
138
|
apiPromise,
|
|
120
|
-
new Promise(r => setTimeout(() => r(null), 8000)),
|
|
139
|
+
new Promise((r) => setTimeout(() => r(null), 8000)),
|
|
121
140
|
]);
|
|
122
141
|
|
|
123
142
|
const elapsed = Date.now() - t0;
|
|
124
143
|
|
|
125
144
|
if (!data || !data.itemList) {
|
|
126
|
-
|
|
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;
|
|
145
|
+
// 使用 waitForFunction 等待错误信息出现(最多 8 秒)
|
|
146
|
+
const pageError = await detectPageErrorWithWait(page, 8000);
|
|
170
147
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
148
|
+
if (pageError === "service_error") {
|
|
149
|
+
throw new Error(`被封: ${username}`);
|
|
150
|
+
}
|
|
174
151
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
152
|
+
// API 超时了,页面肯定有问题
|
|
153
|
+
// 如果识别不出具体错误类型,抛异常(而不是静默返回空 Map)
|
|
154
|
+
if (!pageError) {
|
|
155
|
+
throw new Error(`@${username} 页面异常(无法识别错误类型)`);
|
|
179
156
|
}
|
|
157
|
+
|
|
158
|
+
// 其他已知错误(login_required, captcha, rate_limited 等)返回空 Map
|
|
159
|
+
log(
|
|
160
|
+
` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
|
|
161
|
+
);
|
|
162
|
+
return new Map();
|
|
180
163
|
}
|
|
181
164
|
|
|
182
|
-
//
|
|
183
|
-
return await processAPIResponse(
|
|
165
|
+
// 处理数据(提取首页 + 翻页)
|
|
166
|
+
return await processAPIResponse(
|
|
167
|
+
data,
|
|
168
|
+
username,
|
|
169
|
+
maxVideos,
|
|
170
|
+
items,
|
|
171
|
+
page,
|
|
172
|
+
apiRequestUrl,
|
|
173
|
+
log,
|
|
174
|
+
);
|
|
184
175
|
} finally {
|
|
185
176
|
// 5. 必须清理拦截器,防止累积
|
|
186
|
-
page.off(
|
|
187
|
-
page.off(
|
|
177
|
+
page.off("response", responseHandler);
|
|
178
|
+
page.off("request", requestHandler);
|
|
188
179
|
}
|
|
189
180
|
}
|
|
190
181
|
|
|
@@ -5,7 +5,7 @@ import { execSync } from "child_process";
|
|
|
5
5
|
const TOTAL_ACCOUNTS = 10;
|
|
6
6
|
const BASE_PORT = 9222;
|
|
7
7
|
const MEMORY_THRESHOLD = 0.9;
|
|
8
|
-
const ROTATE_INTERVAL_MS =
|
|
8
|
+
const ROTATE_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1小时
|
|
9
9
|
|
|
10
10
|
class HealthChecker {
|
|
11
11
|
constructor() {
|
|
@@ -34,6 +34,7 @@ class HealthChecker {
|
|
|
34
34
|
|
|
35
35
|
getNextAccount() {
|
|
36
36
|
this.currentIndex = (this.currentIndex + 1) % TOTAL_ACCOUNTS;
|
|
37
|
+
this.startTime = Date.now(); // 切换后重置运行时间
|
|
37
38
|
return this.accounts[this.currentIndex];
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -86,7 +87,15 @@ class HealthChecker {
|
|
|
86
87
|
return { shouldSwitch: true, reason: `运行 ${mins} 分钟` };
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
// 计算预计切换时间
|
|
91
|
+
const remainingMs = ROTATE_INTERVAL_MS - elapsed;
|
|
92
|
+
const remainingMins = Math.round(remainingMs / 60000);
|
|
93
|
+
const memHeadroom = ((MEMORY_THRESHOLD - memUsage) * 100).toFixed(1);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
shouldSwitch: false,
|
|
97
|
+
info: `内存 ${memPercent}% (余量 ${memHeadroom}%) | 定时轮换 ${remainingMins} 分钟后`,
|
|
98
|
+
};
|
|
90
99
|
}
|
|
91
100
|
}
|
|
92
101
|
|
|
@@ -49,6 +49,7 @@ const PATTERNS = {
|
|
|
49
49
|
"没有内容",
|
|
50
50
|
"发起对话",
|
|
51
51
|
"0 条评论",
|
|
52
|
+
"找不到此账号",
|
|
52
53
|
],
|
|
53
54
|
service_error: ["出错了", "很抱歉"],
|
|
54
55
|
};
|
|
@@ -69,3 +70,36 @@ export async function detectPageError(page) {
|
|
|
69
70
|
return null;
|
|
70
71
|
}, PATTERNS);
|
|
71
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 等待页面错误信息出现(轮询检测,最多等待 timeout ms)
|
|
76
|
+
* @param {import('playwright').Page} page
|
|
77
|
+
* @param {number} timeout - 超时时间(毫秒),默认 8000
|
|
78
|
+
* @returns {Promise<string|null>} 错误类型或 null
|
|
79
|
+
*/
|
|
80
|
+
export async function detectPageErrorWithWait(page, timeout = 8000) {
|
|
81
|
+
try {
|
|
82
|
+
const handle = await page.waitForFunction(
|
|
83
|
+
(patterns) => {
|
|
84
|
+
const bodyText = document.body.innerText;
|
|
85
|
+
const lower = bodyText.toLowerCase();
|
|
86
|
+
|
|
87
|
+
for (const [type, phrases] of Object.entries(patterns)) {
|
|
88
|
+
for (const phrase of phrases) {
|
|
89
|
+
if (lower.includes(phrase.toLowerCase())) {
|
|
90
|
+
return type;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
},
|
|
97
|
+
PATTERNS,
|
|
98
|
+
{ timeout },
|
|
99
|
+
);
|
|
100
|
+
return await handle.jsonValue();
|
|
101
|
+
} catch {
|
|
102
|
+
// 超时或未检测到错误
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ensureBrowserReady,
|
|
3
|
-
detectPageError,
|
|
4
|
-
isLoggedIn,
|
|
5
|
-
delay,
|
|
6
|
-
} from "./modules/page-helpers.js";
|
|
1
|
+
import { ensureBrowserReady, delay } from "./modules/page-helpers.js";
|
|
7
2
|
import { detectCaptcha } from "./modules/captcha-handler.js";
|
|
8
3
|
export { ensureBrowserReady };
|
|
9
4
|
import { getUserInfo, collectVideos } from "../videos/core.js";
|
|
@@ -18,6 +13,7 @@ async function processExplore(page, username, options, log) {
|
|
|
18
13
|
const {
|
|
19
14
|
maxVideos = 16,
|
|
20
15
|
enableFollow = true,
|
|
16
|
+
loggedIn = false, // 由外部传入登录状态,避免每次调用 isLoggedIn(page)
|
|
21
17
|
maxFollowing = 50,
|
|
22
18
|
maxFollowers = 50,
|
|
23
19
|
location = "PL,NL,BE,DE,FR,IT,ES,IE",
|
|
@@ -63,23 +59,10 @@ async function processExplore(page, username, options, log) {
|
|
|
63
59
|
result.collectedVideos = videoArray.length;
|
|
64
60
|
|
|
65
61
|
if (videoArray.length <= 0) {
|
|
62
|
+
// 视频为空:可能是页面受限或用户真的没有视频
|
|
66
63
|
result.processed = true;
|
|
67
64
|
result.noVideo = true;
|
|
68
|
-
|
|
69
|
-
if (pageError === "service_error") {
|
|
70
|
-
result.error = `被封: ${username}`;
|
|
71
|
-
result.errorStack = "";
|
|
72
|
-
result.processed = false;
|
|
73
|
-
result.noVideo = false;
|
|
74
|
-
log(` @${username} 服务异常(service_error),标记被封`);
|
|
75
|
-
} else if (pageError) {
|
|
76
|
-
result.restricted = true;
|
|
77
|
-
result.processed = true;
|
|
78
|
-
result.noVideo = true;
|
|
79
|
-
log(` @${username} 页面受限(${pageError}),标记跳过`);
|
|
80
|
-
} else {
|
|
81
|
-
log(` @${username} 没有视频,标记已处理`);
|
|
82
|
-
}
|
|
65
|
+
log(` @${username} 没有视频,标记已处理`);
|
|
83
66
|
return result;
|
|
84
67
|
}
|
|
85
68
|
|
|
@@ -111,7 +94,6 @@ async function processExplore(page, username, options, log) {
|
|
|
111
94
|
|
|
112
95
|
// 提取关注/粉丝
|
|
113
96
|
if (enableFollow) {
|
|
114
|
-
const loggedIn = await isLoggedIn(page);
|
|
115
97
|
await delay(100, 1000);
|
|
116
98
|
if (!loggedIn) {
|
|
117
99
|
log(" [跳过] 获取关注/粉丝:未登录,请先登录 TikTok");
|
|
@@ -171,27 +153,11 @@ async function processExplore(page, username, options, log) {
|
|
|
171
153
|
result.errorStack = e.stack || "";
|
|
172
154
|
log(` [错误] ${e.message}`);
|
|
173
155
|
|
|
174
|
-
//
|
|
175
|
-
if (e.message.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
(keywords) => {
|
|
180
|
-
const text = document.body.innerText;
|
|
181
|
-
return keywords.some((k) => text.includes(k));
|
|
182
|
-
},
|
|
183
|
-
["出错了", "很抱歉"],
|
|
184
|
-
{ timeout: 5000 },
|
|
185
|
-
)
|
|
186
|
-
.then(() => true)
|
|
187
|
-
.catch(() => false);
|
|
188
|
-
|
|
189
|
-
if (hasErrorPage) {
|
|
190
|
-
result.error = `被封: ${username}`;
|
|
191
|
-
result.processed = false;
|
|
192
|
-
result.noVideo = false;
|
|
193
|
-
log(` @${username} API 拦截失败且页面异常,认定为被封`);
|
|
194
|
-
}
|
|
156
|
+
// 被封会抛出 "被封: username" 异常
|
|
157
|
+
if (e.message.startsWith("被封:")) {
|
|
158
|
+
result.processed = false;
|
|
159
|
+
result.noVideo = false;
|
|
160
|
+
log(` @${username} 认定为被封`);
|
|
195
161
|
}
|
|
196
162
|
}
|
|
197
163
|
|
package/src/videos/core.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
delay,
|
|
3
|
+
ensureBrowserReady,
|
|
4
|
+
ensureTikTokPage,
|
|
5
|
+
retryWithBackoff,
|
|
6
|
+
} from "../scraper/modules/page-helpers.js";
|
|
7
|
+
import { fetchUserVideosAPI } from "../lib/api-interceptor.js";
|
|
3
8
|
|
|
4
9
|
async function getUserInfo(page) {
|
|
5
10
|
return await page.evaluate(() => {
|
|
@@ -23,15 +28,21 @@ async function getUserInfo(page) {
|
|
|
23
28
|
region: /"region":"([^"]*)/,
|
|
24
29
|
};
|
|
25
30
|
|
|
26
|
-
const boolKeys = [
|
|
27
|
-
const numKeys = [
|
|
31
|
+
const boolKeys = ["ttSeller", "verified"];
|
|
32
|
+
const numKeys = [
|
|
33
|
+
"followerCount",
|
|
34
|
+
"videoCount",
|
|
35
|
+
"followingCount",
|
|
36
|
+
"heartCount",
|
|
37
|
+
];
|
|
28
38
|
|
|
29
39
|
for (const [key, pat] of Object.entries(patterns)) {
|
|
30
40
|
const match = html.match(pat);
|
|
31
41
|
if (match) {
|
|
32
|
-
if (boolKeys.includes(key)) result[key] = match[1] ===
|
|
42
|
+
if (boolKeys.includes(key)) result[key] = match[1] === "true";
|
|
33
43
|
else if (numKeys.includes(key)) result[key] = parseInt(match[1], 10);
|
|
34
|
-
else if (key ===
|
|
44
|
+
else if (key === "signature")
|
|
45
|
+
result[key] = match[1].replace(/\\n/g, "\n").replace(/\\\\/g, "\\");
|
|
35
46
|
else result[key] = match[1];
|
|
36
47
|
}
|
|
37
48
|
}
|
|
@@ -41,16 +52,22 @@ async function getUserInfo(page) {
|
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
async function collectVideos(page, username, maxVideos, log) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
try {
|
|
56
|
+
const apiResult = await fetchUserVideosAPI(page, username, maxVideos, log);
|
|
57
|
+
if (apiResult && apiResult.size > 0) {
|
|
58
|
+
log(`收集完成: ${apiResult.size} 个视频`);
|
|
59
|
+
return apiResult;
|
|
60
|
+
}
|
|
61
|
+
// apiResult 为空 Map 表示页面有其他异常(login_required, captcha 等)
|
|
50
62
|
return new Map();
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// 被封会抛出 "被封: username" 异常
|
|
65
|
+
if (e.message.startsWith("被封:")) {
|
|
66
|
+
throw e; // 向上抛出,由 explore-core 处理
|
|
67
|
+
}
|
|
68
|
+
// 其他异常也向上抛出
|
|
69
|
+
throw new Error(`API 拦截失败:@${username} ${e.message}`);
|
|
51
70
|
}
|
|
52
|
-
// apiResult is null means timeout before refactor; now it should throw instead
|
|
53
|
-
throw new Error(`API 拦截失败:@${username} 获取视频列表超时,请检查网络`);
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
async function runGetUserVideos(options) {
|
|
@@ -61,7 +78,7 @@ async function runGetUserVideos(options) {
|
|
|
61
78
|
log(`URL: ${url}`);
|
|
62
79
|
log(`最大视频数: ${maxVideos}\n`);
|
|
63
80
|
|
|
64
|
-
log(
|
|
81
|
+
log("连接浏览器...");
|
|
65
82
|
const browser = await ensureBrowserReady();
|
|
66
83
|
|
|
67
84
|
let page;
|
|
@@ -72,15 +89,20 @@ async function runGetUserVideos(options) {
|
|
|
72
89
|
throw e;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
await retryWithBackoff(
|
|
92
|
+
await retryWithBackoff(
|
|
93
|
+
() => page.goto(url, { waitUntil: "load", timeout: 30000 }),
|
|
94
|
+
{ log },
|
|
95
|
+
);
|
|
76
96
|
await delay(3000, 5000);
|
|
77
|
-
await page
|
|
97
|
+
await page
|
|
98
|
+
.waitForSelector('[class*="DivVideoList"]', { timeout: 10000 })
|
|
99
|
+
.catch(() => {});
|
|
78
100
|
|
|
79
|
-
log(
|
|
101
|
+
log("获取用户信息...");
|
|
80
102
|
const userInfo = await getUserInfo(page);
|
|
81
|
-
log(
|
|
103
|
+
log("用户信息: " + JSON.stringify(userInfo, null, 2));
|
|
82
104
|
|
|
83
|
-
log(
|
|
105
|
+
log("\n开始滚动收集视频...");
|
|
84
106
|
const videos = await collectVideos(page, username, maxVideos, log);
|
|
85
107
|
const allVideos = Array.from(videos.values());
|
|
86
108
|
|
|
@@ -89,9 +111,11 @@ async function runGetUserVideos(options) {
|
|
|
89
111
|
const output = {
|
|
90
112
|
user: userInfo,
|
|
91
113
|
totalVideos: Math.min(allVideos.length, maxVideos),
|
|
92
|
-
videos: allVideos.slice(0, maxVideos).map(v => ({
|
|
114
|
+
videos: allVideos.slice(0, maxVideos).map((v) => ({
|
|
93
115
|
id: v.id,
|
|
94
|
-
url: v.href.startsWith(
|
|
116
|
+
url: v.href.startsWith("http")
|
|
117
|
+
? v.href
|
|
118
|
+
: `https://www.tiktok.com${v.href}`,
|
|
95
119
|
})),
|
|
96
120
|
};
|
|
97
121
|
|