tt-help-cli-ycl 1.3.48 → 1.3.50
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 +33 -33
- package/cli.js +9 -9
- package/package.json +52 -52
- package/scripts/run-explore copy.bat +101 -101
- package/scripts/run-explore.bat +134 -134
- package/scripts/run-explore.ps1 +159 -159
- package/scripts/run-explore.sh +121 -121
- 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/scripts/test-watch-db-smoke.mjs +246 -0
- package/src/cli/attach.js +331 -331
- package/src/cli/auto.js +265 -265
- package/src/cli/comments.js +620 -620
- package/src/cli/config.js +170 -170
- package/src/cli/db-import.js +51 -51
- package/src/cli/explore.js +555 -555
- package/src/cli/open.js +109 -111
- package/src/cli/progress.js +111 -111
- package/src/cli/refresh.js +288 -288
- package/src/cli/scrape.js +47 -47
- package/src/cli/utils.js +18 -18
- package/src/cli/videos.js +41 -41
- package/src/cli/videostats.js +196 -196
- package/src/cli/watch.js +30 -30
- package/src/lib/api-interceptor.js +161 -161
- package/src/lib/args.js +809 -809
- package/src/lib/browser/anti-detect.js +23 -23
- package/src/lib/browser/cdp.js +261 -261
- package/src/lib/browser/health-checker.js +114 -114
- package/src/lib/browser/launch.js +43 -43
- package/src/lib/browser/page.js +184 -184
- package/src/lib/constants.js +297 -297
- 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 +109 -109
- 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 +90 -90
- package/src/lib/target-locations.js +61 -61
- package/src/lib/tiktok-scraper.mjs +98 -61
- package/src/lib/url.js +52 -52
- package/src/main.js +73 -73
- package/src/npm-main.js +70 -70
- 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 +255 -255
- package/src/scraper/explore-core.js +208 -208
- package/src/scraper/modules/captcha-handler.js +114 -114
- package/src/scraper/modules/follow-extractor.js +250 -250
- package/src/scraper/modules/guess-extractor.js +51 -51
- package/src/scraper/modules/page-helpers.js +48 -48
- package/src/scraper/refresh-core.js +213 -213
- package/src/videos/core.js +143 -143
- package/src/watch/data-store.js +2980 -2980
- package/src/watch/public/index.html +2355 -2355
- package/src/watch/server.js +727 -727
package/src/cli/explore.js
CHANGED
|
@@ -1,555 +1,555 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getOrCreatePage,
|
|
3
|
-
isBrowserClosedError,
|
|
4
|
-
relaunchBrowser,
|
|
5
|
-
} from "../lib/browser/page.js";
|
|
6
|
-
import {
|
|
7
|
-
delay,
|
|
8
|
-
getDelayConfig,
|
|
9
|
-
setDelayConfig,
|
|
10
|
-
} from "../scraper/modules/page-helpers.js";
|
|
11
|
-
import {
|
|
12
|
-
detectCaptcha,
|
|
13
|
-
closeCaptcha,
|
|
14
|
-
} from "../scraper/modules/captcha-handler.js";
|
|
15
|
-
import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
|
|
16
|
-
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
17
|
-
import {
|
|
18
|
-
ensureBrowserReady as ensureBrowserReadyCDP,
|
|
19
|
-
switchAccount,
|
|
20
|
-
} from "../lib/browser/cdp.js";
|
|
21
|
-
import { showResourceUsage } from "../utils/index.js";
|
|
22
|
-
import { HealthChecker } from "../lib/browser/health-checker.js";
|
|
23
|
-
import path from "path";
|
|
24
|
-
import os from "os";
|
|
25
|
-
|
|
26
|
-
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
27
|
-
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
28
|
-
|
|
29
|
-
async function withRetry(label, fn) {
|
|
30
|
-
let backoff = 1000;
|
|
31
|
-
while (true) {
|
|
32
|
-
try {
|
|
33
|
-
return await fn();
|
|
34
|
-
} catch (err) {
|
|
35
|
-
console.error(
|
|
36
|
-
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
37
|
-
);
|
|
38
|
-
await new Promise((r) => setTimeout(r, backoff));
|
|
39
|
-
if (backoff < MAX_RETRY_WAIT) backoff *= 2;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function apiPost(url, body) {
|
|
45
|
-
return withRetry(`POST ${url}`, async () => {
|
|
46
|
-
const res = await fetch(url, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: { "Content-Type": "application/json" },
|
|
49
|
-
body: JSON.stringify(body),
|
|
50
|
-
});
|
|
51
|
-
return res.json();
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function apiGet(url) {
|
|
56
|
-
return withRetry(`GET ${url}`, async () => {
|
|
57
|
-
const res = await fetch(url);
|
|
58
|
-
return res.json();
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function handleExplore(options) {
|
|
63
|
-
const {
|
|
64
|
-
exploreUsernames,
|
|
65
|
-
explorePreset,
|
|
66
|
-
exploreInterval,
|
|
67
|
-
exploreMaxComments,
|
|
68
|
-
exploreMaxGuess,
|
|
69
|
-
exploreEnableFollow,
|
|
70
|
-
exploreMaxFollowing,
|
|
71
|
-
exploreMaxFollowers,
|
|
72
|
-
exploreLocation,
|
|
73
|
-
exploreJobLocations,
|
|
74
|
-
exploreMaxUsers,
|
|
75
|
-
serverUrl,
|
|
76
|
-
explorePort,
|
|
77
|
-
exploreBasePort,
|
|
78
|
-
explorePortCount,
|
|
79
|
-
exploreUserId,
|
|
80
|
-
exploreMaxVideos,
|
|
81
|
-
} = options;
|
|
82
|
-
|
|
83
|
-
let userId = exploreUserId || configuredUserId;
|
|
84
|
-
let browser = null;
|
|
85
|
-
let shuttingDown = false;
|
|
86
|
-
|
|
87
|
-
const shutdown = async (signal) => {
|
|
88
|
-
if (shuttingDown) return;
|
|
89
|
-
shuttingDown = true;
|
|
90
|
-
console.error(`\n[Explore] 收到 ${signal},正在关闭浏览器...`);
|
|
91
|
-
await browser?.close().catch(() => {});
|
|
92
|
-
console.error("[Explore] 已退出");
|
|
93
|
-
process.exit(0);
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const onSigint = () => {
|
|
97
|
-
void shutdown("SIGINT");
|
|
98
|
-
};
|
|
99
|
-
const onSigterm = () => {
|
|
100
|
-
void shutdown("SIGTERM");
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
process.once("SIGINT", onSigint);
|
|
104
|
-
process.once("SIGTERM", onSigterm);
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
if (!userId) {
|
|
108
|
-
userId = await getMacOrUuid();
|
|
109
|
-
saveUserId(userId);
|
|
110
|
-
console.error(`[初始化] 未检测到本地用户编号,已生成并使用: ${userId}`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
setDelayConfig(explorePreset);
|
|
114
|
-
|
|
115
|
-
await apiGet(`${serverUrl}/api/stats`);
|
|
116
|
-
|
|
117
|
-
if (exploreUsernames && exploreUsernames.length > 0) {
|
|
118
|
-
const { added, skipped } = await apiPost(`${serverUrl}/api/users`, {
|
|
119
|
-
usernames: exploreUsernames,
|
|
120
|
-
});
|
|
121
|
-
console.error(`种子用户: ${added} 个新增, ${skipped} 个已存在`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
console.error(`\n国家筛选: ${exploreLocation}`);
|
|
125
|
-
if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
|
|
126
|
-
console.error(`视频采集: ${exploreMaxVideos || 1}`);
|
|
127
|
-
console.error(`关注/粉丝: ${exploreEnableFollow ? "启用" : "禁用"}`);
|
|
128
|
-
console.error(`空闲间隔: ${exploreInterval} 秒`);
|
|
129
|
-
console.error(`服务器: ${serverUrl}(断开会自动重连)`);
|
|
130
|
-
if (exploreMaxUsers > 0) console.error(`上限: ${exploreMaxUsers} 个用户`);
|
|
131
|
-
|
|
132
|
-
const healthChecker = new HealthChecker({
|
|
133
|
-
basePort: exploreBasePort,
|
|
134
|
-
totalAccounts: explorePortCount,
|
|
135
|
-
});
|
|
136
|
-
let currentAccount = healthChecker.getCurrentAccount();
|
|
137
|
-
|
|
138
|
-
const cdpOptions = {};
|
|
139
|
-
if (explorePort) {
|
|
140
|
-
// 固定端口模式(调试用,关闭自动轮换)
|
|
141
|
-
cdpOptions.port = explorePort;
|
|
142
|
-
cdpOptions.userDataDir = path.join(
|
|
143
|
-
os.homedir(),
|
|
144
|
-
"Library",
|
|
145
|
-
"Application Support",
|
|
146
|
-
`Microsoft Edge For Testing_p${explorePort}`,
|
|
147
|
-
);
|
|
148
|
-
} else {
|
|
149
|
-
cdpOptions.port = currentAccount.port;
|
|
150
|
-
cdpOptions.userDataDir = currentAccount.userDataDir;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
console.error(`CDP 端口: ${cdpOptions.port}, 用户编号: ${userId}`);
|
|
154
|
-
console.error(`浏览器配置: ${path.basename(cdpOptions.userDataDir)}`);
|
|
155
|
-
if (!explorePort) {
|
|
156
|
-
const portRange = `${currentAccount.port}-${currentAccount.port + healthChecker.accounts.length - 1}`;
|
|
157
|
-
console.error(
|
|
158
|
-
`端口轮换范围: ${portRange}(共 ${healthChecker.accounts.length} 个)`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
163
|
-
const { processExplore } = await import("../scraper/explore-core.js");
|
|
164
|
-
const { isLoggedIn } = await import("../lib/browser/page.js");
|
|
165
|
-
|
|
166
|
-
const page = await getOrCreatePage(browser);
|
|
167
|
-
|
|
168
|
-
// 先导航到 TikTok 页面,再检测登录状态
|
|
169
|
-
await page.goto(STARTUP_TIKTOK_URL, {
|
|
170
|
-
waitUntil: "domcontentloaded",
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// 检测登录状态(启动时只检测一次)
|
|
174
|
-
let loggedIn = await isLoggedIn(page);
|
|
175
|
-
console.error(`登录状态: ${loggedIn ? "已登录" : "未登录"}`);
|
|
176
|
-
|
|
177
|
-
// 全局拦截图片资源,减少内存占用和加载时间
|
|
178
|
-
await page.route("**/*", (route) => {
|
|
179
|
-
const resourceType = route.request().resourceType();
|
|
180
|
-
if (resourceType === "image" || resourceType === "stylesheet") {
|
|
181
|
-
route.abort();
|
|
182
|
-
} else {
|
|
183
|
-
route.continue();
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// 账户切换后,重新初始化 page
|
|
188
|
-
async function setupNewPage(newBrowser) {
|
|
189
|
-
const newPage = await getOrCreatePage(newBrowser);
|
|
190
|
-
await newPage.route("**/*", (route) => {
|
|
191
|
-
const resourceType = route.request().resourceType();
|
|
192
|
-
if (resourceType === "image" || resourceType === "stylesheet") {
|
|
193
|
-
route.abort();
|
|
194
|
-
} else {
|
|
195
|
-
route.continue();
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
return newPage;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function handleAccountSwitch(reason) {
|
|
202
|
-
console.error(`\n[健康检查] ${reason},切换到下一个账户...`);
|
|
203
|
-
const oldAccount = currentAccount;
|
|
204
|
-
const nextAccount = healthChecker.getNextAccount();
|
|
205
|
-
currentAccount = nextAccount;
|
|
206
|
-
const newBrowser = await switchAccount(
|
|
207
|
-
{ port: oldAccount.port, userDataDir: oldAccount.userDataDir },
|
|
208
|
-
{ port: nextAccount.port, userDataDir: nextAccount.userDataDir },
|
|
209
|
-
);
|
|
210
|
-
browser = newBrowser;
|
|
211
|
-
const newPage = await setupNewPage(browser);
|
|
212
|
-
Object.assign(page, newPage);
|
|
213
|
-
Object.assign(cdpOptions, {
|
|
214
|
-
port: nextAccount.port,
|
|
215
|
-
userDataDir: nextAccount.userDataDir,
|
|
216
|
-
});
|
|
217
|
-
console.error(`[健康检查] 已切换到端口 ${nextAccount.port}`);
|
|
218
|
-
// 切换账户后先导航到 TikTok 页面,再重新检测登录状态
|
|
219
|
-
await page.goto(STARTUP_TIKTOK_URL, {
|
|
220
|
-
waitUntil: "domcontentloaded",
|
|
221
|
-
});
|
|
222
|
-
loggedIn = await isLoggedIn(page);
|
|
223
|
-
console.error(
|
|
224
|
-
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
|
225
|
-
);
|
|
226
|
-
lastFollowSuccessTime = Date.now(); // 切换账户后重置
|
|
227
|
-
captchaCount = 0; // 重置验证码计数
|
|
228
|
-
blockedCount = 0; // 重置被封计数
|
|
229
|
-
consecutiveNetworkErrors = 0; // 重置网络错误计数
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
let processedCount = 0;
|
|
233
|
-
let errorCount = 0;
|
|
234
|
-
let consecutiveNetworkErrors = 0;
|
|
235
|
-
let captchaCount = 0; // 验证码累计计数
|
|
236
|
-
let blockedCount = 0; // 被封累计计数
|
|
237
|
-
let lastFollowSuccessTime = Date.now(); // 最近一次成功获取关注/粉丝的时间
|
|
238
|
-
const FOLLOW_BLOCK_THRESHOLD = 10 * 60 * 1000; // 10分钟
|
|
239
|
-
|
|
240
|
-
while (!shuttingDown) {
|
|
241
|
-
const jobQuery = exploreJobLocations
|
|
242
|
-
? `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&locations=${encodeURIComponent(exploreJobLocations)}&loggedIn=${loggedIn}`
|
|
243
|
-
: `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&loggedIn=${loggedIn}`;
|
|
244
|
-
const job = await apiGet(jobQuery);
|
|
245
|
-
if (!job.hasJob) {
|
|
246
|
-
console.error(`\n[Explore] 当前无任务,${exploreInterval} 秒后重试...`);
|
|
247
|
-
await new Promise((r) => setTimeout(r, exploreInterval * 1000));
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const username = job.user.uniqueId;
|
|
252
|
-
processedCount++;
|
|
253
|
-
|
|
254
|
-
if (processedCount % 10 === 0) {
|
|
255
|
-
showResourceUsage();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const checkResult = healthChecker.check();
|
|
259
|
-
if (checkResult.shouldSwitch) {
|
|
260
|
-
await handleAccountSwitch(checkResult.reason);
|
|
261
|
-
} else if (checkResult.info && processedCount % 10 === 0) {
|
|
262
|
-
// 关注/粉丝封禁检测状态(仅登录状态)
|
|
263
|
-
let followStatus = "";
|
|
264
|
-
if (loggedIn && exploreEnableFollow) {
|
|
265
|
-
const followElapsed = Math.round(
|
|
266
|
-
(Date.now() - lastFollowSuccessTime) / 60000,
|
|
267
|
-
);
|
|
268
|
-
const followRemaining = Math.max(
|
|
269
|
-
0,
|
|
270
|
-
Math.round(FOLLOW_BLOCK_THRESHOLD / 60000) - followElapsed,
|
|
271
|
-
);
|
|
272
|
-
followStatus = ` | 关注/粉丝上次成功 ${followElapsed} 分钟前 | 封禁检测 ${followRemaining} 分钟后`;
|
|
273
|
-
}
|
|
274
|
-
console.error(`\n[健康检查] ${checkResult.info}${followStatus}`);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// 切换任务前检测验证码
|
|
278
|
-
const switchCaptcha = await detectCaptcha(page);
|
|
279
|
-
if (switchCaptcha && switchCaptcha.visible) {
|
|
280
|
-
console.error(`\n[验证码] 切换任务前检测到验证码,等待3分钟...`);
|
|
281
|
-
captchaCount++;
|
|
282
|
-
console.error(` [验证码] 累计 ${captchaCount} 次`);
|
|
283
|
-
|
|
284
|
-
// 等待3分钟
|
|
285
|
-
await new Promise((r) => setTimeout(r, 3 * 60 * 1000));
|
|
286
|
-
|
|
287
|
-
// 尝试关闭
|
|
288
|
-
const closeResult = await closeCaptcha(page);
|
|
289
|
-
if (closeResult.success) {
|
|
290
|
-
console.error(` [验证码] 已关闭`);
|
|
291
|
-
} else {
|
|
292
|
-
console.error(` [验证码] 关闭失败: ${closeResult.reason}`);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// 上报
|
|
296
|
-
await withRetry("report captcha", () =>
|
|
297
|
-
apiPost(`${serverUrl}/api/error-report`, {
|
|
298
|
-
userId,
|
|
299
|
-
username: "unknown",
|
|
300
|
-
errorType: "captcha",
|
|
301
|
-
errorMessage: "切换任务前检测到验证码",
|
|
302
|
-
stage: "between-tasks",
|
|
303
|
-
errorStack: "",
|
|
304
|
-
}),
|
|
305
|
-
).catch(() => {});
|
|
306
|
-
|
|
307
|
-
// 累计2次切换账户
|
|
308
|
-
if (captchaCount >= 2) {
|
|
309
|
-
await handleAccountSwitch(`验证码累计 ${captchaCount} 次`);
|
|
310
|
-
captchaCount = 0;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// 关注/粉丝封禁检测移到健康检查中(仅登录状态下)
|
|
315
|
-
if (loggedIn && exploreEnableFollow) {
|
|
316
|
-
const followElapsed = Date.now() - lastFollowSuccessTime;
|
|
317
|
-
if (followElapsed > FOLLOW_BLOCK_THRESHOLD) {
|
|
318
|
-
const minutes = Math.round(followElapsed / 60000);
|
|
319
|
-
console.error(
|
|
320
|
-
`\n[封禁检测] 关注/粉丝功能已 ${minutes} 分钟未成功获取,切换到下一个账户...`,
|
|
321
|
-
);
|
|
322
|
-
await handleAccountSwitch(`关注/粉丝功能被封`);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
if (consecutiveNetworkErrors > 0) {
|
|
327
|
-
const waitTime =
|
|
328
|
-
consecutiveNetworkErrors <= 2
|
|
329
|
-
? 0
|
|
330
|
-
: consecutiveNetworkErrors <= 5
|
|
331
|
-
? 30000
|
|
332
|
-
: 300000;
|
|
333
|
-
if (waitTime > 0) {
|
|
334
|
-
console.error(
|
|
335
|
-
` [网络] 连续 ${consecutiveNetworkErrors} 次网络异常,等待 ${waitTime / 1000}s 后重试...`,
|
|
336
|
-
);
|
|
337
|
-
await new Promise((r) => setTimeout(r, waitTime));
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
console.error(`\n[${processedCount}] 探索 @${username}...`);
|
|
342
|
-
|
|
343
|
-
const { switchMax } = getDelayConfig();
|
|
344
|
-
await delay(switchMax, switchMax * 3);
|
|
345
|
-
|
|
346
|
-
let result = await processExplore(
|
|
347
|
-
page,
|
|
348
|
-
username,
|
|
349
|
-
{
|
|
350
|
-
maxVideos: exploreMaxVideos,
|
|
351
|
-
enableFollow: exploreEnableFollow,
|
|
352
|
-
loggedIn, // 传入登录状态,避免每次调用 isLoggedIn(page)
|
|
353
|
-
maxFollowing: exploreMaxFollowing,
|
|
354
|
-
maxFollowers: exploreMaxFollowers,
|
|
355
|
-
location: exploreLocation,
|
|
356
|
-
browser,
|
|
357
|
-
},
|
|
358
|
-
console.error,
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
// 浏览器关闭检测:processExplore 内部 catch 了异常,需要从 result.error 判断
|
|
362
|
-
if (result.error && isBrowserClosedError(new Error(result.error))) {
|
|
363
|
-
const newBrowser = await relaunchBrowser(
|
|
364
|
-
cdpOptions,
|
|
365
|
-
cdpOptions.port || 9222,
|
|
366
|
-
);
|
|
367
|
-
browser = newBrowser;
|
|
368
|
-
const newPage = await setupNewPage(browser);
|
|
369
|
-
Object.assign(page, newPage);
|
|
370
|
-
// 重试当前用户
|
|
371
|
-
result = await processExplore(
|
|
372
|
-
page,
|
|
373
|
-
username,
|
|
374
|
-
{
|
|
375
|
-
maxVideos: exploreMaxVideos,
|
|
376
|
-
enableFollow: exploreEnableFollow,
|
|
377
|
-
loggedIn, // 传入登录状态
|
|
378
|
-
maxFollowing: exploreMaxFollowing,
|
|
379
|
-
maxFollowers: exploreMaxFollowers,
|
|
380
|
-
location: exploreLocation,
|
|
381
|
-
browser,
|
|
382
|
-
},
|
|
383
|
-
console.error,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (result.restricted) {
|
|
388
|
-
consecutiveNetworkErrors = 0;
|
|
389
|
-
await apiPost(`${serverUrl}/api/job/${username}`, {
|
|
390
|
-
restricted: true,
|
|
391
|
-
userInfo: result.userInfo || {},
|
|
392
|
-
});
|
|
393
|
-
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
394
|
-
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (result.error) {
|
|
401
|
-
consecutiveNetworkErrors++;
|
|
402
|
-
errorCount++;
|
|
403
|
-
|
|
404
|
-
// 临时性错误:自动重试一次
|
|
405
|
-
if (result.retryable) {
|
|
406
|
-
console.error(` [临时错误] 等待 5 秒后重试 @${username}...`);
|
|
407
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
408
|
-
result = await processExplore(
|
|
409
|
-
page,
|
|
410
|
-
username,
|
|
411
|
-
{
|
|
412
|
-
maxVideos: exploreMaxVideos,
|
|
413
|
-
enableFollow: exploreEnableFollow,
|
|
414
|
-
loggedIn,
|
|
415
|
-
maxFollowing: exploreMaxFollowing,
|
|
416
|
-
maxFollowers: exploreMaxFollowers,
|
|
417
|
-
location: exploreLocation,
|
|
418
|
-
browser,
|
|
419
|
-
},
|
|
420
|
-
console.error,
|
|
421
|
-
);
|
|
422
|
-
// 重试成功后继续正常流程
|
|
423
|
-
if (!result.error) {
|
|
424
|
-
consecutiveNetworkErrors = 0;
|
|
425
|
-
errorCount--;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (result.error) {
|
|
430
|
-
// 上报错误(重试后仍有错误才上报)
|
|
431
|
-
await apiPost(`${serverUrl}/api/job/${username}`, {
|
|
432
|
-
error: result.error,
|
|
433
|
-
});
|
|
434
|
-
const errorType = result.error.startsWith("被封:")
|
|
435
|
-
? "被封"
|
|
436
|
-
: consecutiveNetworkErrors > 1
|
|
437
|
-
? "network"
|
|
438
|
-
: "other";
|
|
439
|
-
await withRetry("report error", () =>
|
|
440
|
-
apiPost(`${serverUrl}/api/error-report`, {
|
|
441
|
-
userId,
|
|
442
|
-
username,
|
|
443
|
-
errorType,
|
|
444
|
-
errorMessage: result.error,
|
|
445
|
-
stage: "process",
|
|
446
|
-
errorStack: result.errorStack || "",
|
|
447
|
-
}),
|
|
448
|
-
).catch(() => {});
|
|
449
|
-
if (errorType === "被封") {
|
|
450
|
-
blockedCount++;
|
|
451
|
-
console.error(` [被封] 累计 ${blockedCount} 次`);
|
|
452
|
-
if (blockedCount >= 3) {
|
|
453
|
-
await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
|
|
454
|
-
blockedCount = 0;
|
|
455
|
-
}
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
459
|
-
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
if (result.captchaDetected) {
|
|
467
|
-
captchaCount++;
|
|
468
|
-
console.error(` [验证码] 累计 ${captchaCount} 次`);
|
|
469
|
-
|
|
470
|
-
await withRetry("report captcha", () =>
|
|
471
|
-
apiPost(`${serverUrl}/api/error-report`, {
|
|
472
|
-
userId,
|
|
473
|
-
username,
|
|
474
|
-
errorType: "captcha",
|
|
475
|
-
errorMessage: result.captchaMessage || "页面出现验证码",
|
|
476
|
-
stage: result.captchaStage || "video-page",
|
|
477
|
-
errorStack: "",
|
|
478
|
-
}),
|
|
479
|
-
).catch(() => {});
|
|
480
|
-
|
|
481
|
-
// 累计2次验证码,切换账户
|
|
482
|
-
if (captchaCount >= 2) {
|
|
483
|
-
await handleAccountSwitch(`验证码累计 ${captchaCount} 次`);
|
|
484
|
-
captchaCount = 0;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
consecutiveNetworkErrors = 0;
|
|
489
|
-
|
|
490
|
-
// 更新关注/粉丝成功时间(仅登录状态下)
|
|
491
|
-
if (result.hasFollowData && result.keepFollow) {
|
|
492
|
-
const totalFollows =
|
|
493
|
-
(result.discoveredFollowing || []).length +
|
|
494
|
-
(result.discoveredFollowers || []).length;
|
|
495
|
-
if (totalFollows > 0) {
|
|
496
|
-
lastFollowSuccessTime = Date.now();
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const guessedLocation = result.locationCreated || null;
|
|
501
|
-
|
|
502
|
-
const payload = {
|
|
503
|
-
userInfo: result.userInfo || {},
|
|
504
|
-
discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
|
|
505
|
-
handle: Array.isArray(f) ? f[0] : f,
|
|
506
|
-
displayName: Array.isArray(f) ? f[1] : null,
|
|
507
|
-
guessedLocation,
|
|
508
|
-
})),
|
|
509
|
-
discoveredFollowers: (result.discoveredFollowers || []).map((f) => ({
|
|
510
|
-
handle: Array.isArray(f) ? f[0] : f,
|
|
511
|
-
displayName: Array.isArray(f) ? f[1] : null,
|
|
512
|
-
guessedLocation,
|
|
513
|
-
})),
|
|
514
|
-
processed: result.processed,
|
|
515
|
-
hasFollowData: result.hasFollowData,
|
|
516
|
-
keepFollow: result.keepFollow,
|
|
517
|
-
locationCreated: result.locationCreated,
|
|
518
|
-
noVideo: result.noVideo,
|
|
519
|
-
collectedVideos: result.collectedVideos,
|
|
520
|
-
};
|
|
521
|
-
await apiPost(`${serverUrl}/api/job/${username}`, payload);
|
|
522
|
-
|
|
523
|
-
// 视频登记
|
|
524
|
-
if (result.videoList && result.videoList.length > 0) {
|
|
525
|
-
const { registered, skipped } = await apiPost(
|
|
526
|
-
`${serverUrl}/api/videos`,
|
|
527
|
-
{
|
|
528
|
-
sourceUser: username,
|
|
529
|
-
videoList: result.videoList,
|
|
530
|
-
locationCreated: result.locationCreated,
|
|
531
|
-
ttSeller: result.userInfo?.ttSeller || false,
|
|
532
|
-
},
|
|
533
|
-
);
|
|
534
|
-
console.error(` 视频登记: ${registered} 条新增, ${skipped} 条已存在`);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
console.error(" 已提交");
|
|
538
|
-
|
|
539
|
-
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
540
|
-
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
541
|
-
break;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const stats = await apiGet(`${serverUrl}/api/stats`);
|
|
546
|
-
console.error(`\n完成: ${processedCount} 个用户处理, ${errorCount} 个出错`);
|
|
547
|
-
console.error(
|
|
548
|
-
` 总用户: ${stats.totalUsers}, 已完成: ${stats.processedUsers}, 待处理: ${stats.pendingUsers}, 错误: ${stats.errorUsers}`,
|
|
549
|
-
);
|
|
550
|
-
} finally {
|
|
551
|
-
process.removeListener("SIGINT", onSigint);
|
|
552
|
-
process.removeListener("SIGTERM", onSigterm);
|
|
553
|
-
await browser?.close().catch(() => {});
|
|
554
|
-
}
|
|
555
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
getOrCreatePage,
|
|
3
|
+
isBrowserClosedError,
|
|
4
|
+
relaunchBrowser,
|
|
5
|
+
} from "../lib/browser/page.js";
|
|
6
|
+
import {
|
|
7
|
+
delay,
|
|
8
|
+
getDelayConfig,
|
|
9
|
+
setDelayConfig,
|
|
10
|
+
} from "../scraper/modules/page-helpers.js";
|
|
11
|
+
import {
|
|
12
|
+
detectCaptcha,
|
|
13
|
+
closeCaptcha,
|
|
14
|
+
} from "../scraper/modules/captcha-handler.js";
|
|
15
|
+
import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
|
|
16
|
+
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
17
|
+
import {
|
|
18
|
+
ensureBrowserReady as ensureBrowserReadyCDP,
|
|
19
|
+
switchAccount,
|
|
20
|
+
} from "../lib/browser/cdp.js";
|
|
21
|
+
import { showResourceUsage } from "../utils/index.js";
|
|
22
|
+
import { HealthChecker } from "../lib/browser/health-checker.js";
|
|
23
|
+
import path from "path";
|
|
24
|
+
import os from "os";
|
|
25
|
+
|
|
26
|
+
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
27
|
+
const STARTUP_TIKTOK_URL = "https://www.tiktok.com/@ycl5007";
|
|
28
|
+
|
|
29
|
+
async function withRetry(label, fn) {
|
|
30
|
+
let backoff = 1000;
|
|
31
|
+
while (true) {
|
|
32
|
+
try {
|
|
33
|
+
return await fn();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(
|
|
36
|
+
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
37
|
+
);
|
|
38
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
39
|
+
if (backoff < MAX_RETRY_WAIT) backoff *= 2;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function apiPost(url, body) {
|
|
45
|
+
return withRetry(`POST ${url}`, async () => {
|
|
46
|
+
const res = await fetch(url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
return res.json();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function apiGet(url) {
|
|
56
|
+
return withRetry(`GET ${url}`, async () => {
|
|
57
|
+
const res = await fetch(url);
|
|
58
|
+
return res.json();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function handleExplore(options) {
|
|
63
|
+
const {
|
|
64
|
+
exploreUsernames,
|
|
65
|
+
explorePreset,
|
|
66
|
+
exploreInterval,
|
|
67
|
+
exploreMaxComments,
|
|
68
|
+
exploreMaxGuess,
|
|
69
|
+
exploreEnableFollow,
|
|
70
|
+
exploreMaxFollowing,
|
|
71
|
+
exploreMaxFollowers,
|
|
72
|
+
exploreLocation,
|
|
73
|
+
exploreJobLocations,
|
|
74
|
+
exploreMaxUsers,
|
|
75
|
+
serverUrl,
|
|
76
|
+
explorePort,
|
|
77
|
+
exploreBasePort,
|
|
78
|
+
explorePortCount,
|
|
79
|
+
exploreUserId,
|
|
80
|
+
exploreMaxVideos,
|
|
81
|
+
} = options;
|
|
82
|
+
|
|
83
|
+
let userId = exploreUserId || configuredUserId;
|
|
84
|
+
let browser = null;
|
|
85
|
+
let shuttingDown = false;
|
|
86
|
+
|
|
87
|
+
const shutdown = async (signal) => {
|
|
88
|
+
if (shuttingDown) return;
|
|
89
|
+
shuttingDown = true;
|
|
90
|
+
console.error(`\n[Explore] 收到 ${signal},正在关闭浏览器...`);
|
|
91
|
+
await browser?.close().catch(() => {});
|
|
92
|
+
console.error("[Explore] 已退出");
|
|
93
|
+
process.exit(0);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const onSigint = () => {
|
|
97
|
+
void shutdown("SIGINT");
|
|
98
|
+
};
|
|
99
|
+
const onSigterm = () => {
|
|
100
|
+
void shutdown("SIGTERM");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
process.once("SIGINT", onSigint);
|
|
104
|
+
process.once("SIGTERM", onSigterm);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
if (!userId) {
|
|
108
|
+
userId = await getMacOrUuid();
|
|
109
|
+
saveUserId(userId);
|
|
110
|
+
console.error(`[初始化] 未检测到本地用户编号,已生成并使用: ${userId}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setDelayConfig(explorePreset);
|
|
114
|
+
|
|
115
|
+
await apiGet(`${serverUrl}/api/stats`);
|
|
116
|
+
|
|
117
|
+
if (exploreUsernames && exploreUsernames.length > 0) {
|
|
118
|
+
const { added, skipped } = await apiPost(`${serverUrl}/api/users`, {
|
|
119
|
+
usernames: exploreUsernames,
|
|
120
|
+
});
|
|
121
|
+
console.error(`种子用户: ${added} 个新增, ${skipped} 个已存在`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.error(`\n国家筛选: ${exploreLocation}`);
|
|
125
|
+
if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
|
|
126
|
+
console.error(`视频采集: ${exploreMaxVideos || 1}`);
|
|
127
|
+
console.error(`关注/粉丝: ${exploreEnableFollow ? "启用" : "禁用"}`);
|
|
128
|
+
console.error(`空闲间隔: ${exploreInterval} 秒`);
|
|
129
|
+
console.error(`服务器: ${serverUrl}(断开会自动重连)`);
|
|
130
|
+
if (exploreMaxUsers > 0) console.error(`上限: ${exploreMaxUsers} 个用户`);
|
|
131
|
+
|
|
132
|
+
const healthChecker = new HealthChecker({
|
|
133
|
+
basePort: exploreBasePort,
|
|
134
|
+
totalAccounts: explorePortCount,
|
|
135
|
+
});
|
|
136
|
+
let currentAccount = healthChecker.getCurrentAccount();
|
|
137
|
+
|
|
138
|
+
const cdpOptions = {};
|
|
139
|
+
if (explorePort) {
|
|
140
|
+
// 固定端口模式(调试用,关闭自动轮换)
|
|
141
|
+
cdpOptions.port = explorePort;
|
|
142
|
+
cdpOptions.userDataDir = path.join(
|
|
143
|
+
os.homedir(),
|
|
144
|
+
"Library",
|
|
145
|
+
"Application Support",
|
|
146
|
+
`Microsoft Edge For Testing_p${explorePort}`,
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
cdpOptions.port = currentAccount.port;
|
|
150
|
+
cdpOptions.userDataDir = currentAccount.userDataDir;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.error(`CDP 端口: ${cdpOptions.port}, 用户编号: ${userId}`);
|
|
154
|
+
console.error(`浏览器配置: ${path.basename(cdpOptions.userDataDir)}`);
|
|
155
|
+
if (!explorePort) {
|
|
156
|
+
const portRange = `${currentAccount.port}-${currentAccount.port + healthChecker.accounts.length - 1}`;
|
|
157
|
+
console.error(
|
|
158
|
+
`端口轮换范围: ${portRange}(共 ${healthChecker.accounts.length} 个)`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
163
|
+
const { processExplore } = await import("../scraper/explore-core.js");
|
|
164
|
+
const { isLoggedIn } = await import("../lib/browser/page.js");
|
|
165
|
+
|
|
166
|
+
const page = await getOrCreatePage(browser);
|
|
167
|
+
|
|
168
|
+
// 先导航到 TikTok 页面,再检测登录状态
|
|
169
|
+
await page.goto(STARTUP_TIKTOK_URL, {
|
|
170
|
+
waitUntil: "domcontentloaded",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// 检测登录状态(启动时只检测一次)
|
|
174
|
+
let loggedIn = await isLoggedIn(page);
|
|
175
|
+
console.error(`登录状态: ${loggedIn ? "已登录" : "未登录"}`);
|
|
176
|
+
|
|
177
|
+
// 全局拦截图片资源,减少内存占用和加载时间
|
|
178
|
+
await page.route("**/*", (route) => {
|
|
179
|
+
const resourceType = route.request().resourceType();
|
|
180
|
+
if (resourceType === "image" || resourceType === "stylesheet") {
|
|
181
|
+
route.abort();
|
|
182
|
+
} else {
|
|
183
|
+
route.continue();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// 账户切换后,重新初始化 page
|
|
188
|
+
async function setupNewPage(newBrowser) {
|
|
189
|
+
const newPage = await getOrCreatePage(newBrowser);
|
|
190
|
+
await newPage.route("**/*", (route) => {
|
|
191
|
+
const resourceType = route.request().resourceType();
|
|
192
|
+
if (resourceType === "image" || resourceType === "stylesheet") {
|
|
193
|
+
route.abort();
|
|
194
|
+
} else {
|
|
195
|
+
route.continue();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return newPage;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function handleAccountSwitch(reason) {
|
|
202
|
+
console.error(`\n[健康检查] ${reason},切换到下一个账户...`);
|
|
203
|
+
const oldAccount = currentAccount;
|
|
204
|
+
const nextAccount = healthChecker.getNextAccount();
|
|
205
|
+
currentAccount = nextAccount;
|
|
206
|
+
const newBrowser = await switchAccount(
|
|
207
|
+
{ port: oldAccount.port, userDataDir: oldAccount.userDataDir },
|
|
208
|
+
{ port: nextAccount.port, userDataDir: nextAccount.userDataDir },
|
|
209
|
+
);
|
|
210
|
+
browser = newBrowser;
|
|
211
|
+
const newPage = await setupNewPage(browser);
|
|
212
|
+
Object.assign(page, newPage);
|
|
213
|
+
Object.assign(cdpOptions, {
|
|
214
|
+
port: nextAccount.port,
|
|
215
|
+
userDataDir: nextAccount.userDataDir,
|
|
216
|
+
});
|
|
217
|
+
console.error(`[健康检查] 已切换到端口 ${nextAccount.port}`);
|
|
218
|
+
// 切换账户后先导航到 TikTok 页面,再重新检测登录状态
|
|
219
|
+
await page.goto(STARTUP_TIKTOK_URL, {
|
|
220
|
+
waitUntil: "domcontentloaded",
|
|
221
|
+
});
|
|
222
|
+
loggedIn = await isLoggedIn(page);
|
|
223
|
+
console.error(
|
|
224
|
+
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
|
225
|
+
);
|
|
226
|
+
lastFollowSuccessTime = Date.now(); // 切换账户后重置
|
|
227
|
+
captchaCount = 0; // 重置验证码计数
|
|
228
|
+
blockedCount = 0; // 重置被封计数
|
|
229
|
+
consecutiveNetworkErrors = 0; // 重置网络错误计数
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let processedCount = 0;
|
|
233
|
+
let errorCount = 0;
|
|
234
|
+
let consecutiveNetworkErrors = 0;
|
|
235
|
+
let captchaCount = 0; // 验证码累计计数
|
|
236
|
+
let blockedCount = 0; // 被封累计计数
|
|
237
|
+
let lastFollowSuccessTime = Date.now(); // 最近一次成功获取关注/粉丝的时间
|
|
238
|
+
const FOLLOW_BLOCK_THRESHOLD = 10 * 60 * 1000; // 10分钟
|
|
239
|
+
|
|
240
|
+
while (!shuttingDown) {
|
|
241
|
+
const jobQuery = exploreJobLocations
|
|
242
|
+
? `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&locations=${encodeURIComponent(exploreJobLocations)}&loggedIn=${loggedIn}`
|
|
243
|
+
: `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&loggedIn=${loggedIn}`;
|
|
244
|
+
const job = await apiGet(jobQuery);
|
|
245
|
+
if (!job.hasJob) {
|
|
246
|
+
console.error(`\n[Explore] 当前无任务,${exploreInterval} 秒后重试...`);
|
|
247
|
+
await new Promise((r) => setTimeout(r, exploreInterval * 1000));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const username = job.user.uniqueId;
|
|
252
|
+
processedCount++;
|
|
253
|
+
|
|
254
|
+
if (processedCount % 10 === 0) {
|
|
255
|
+
showResourceUsage();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const checkResult = healthChecker.check();
|
|
259
|
+
if (checkResult.shouldSwitch) {
|
|
260
|
+
await handleAccountSwitch(checkResult.reason);
|
|
261
|
+
} else if (checkResult.info && processedCount % 10 === 0) {
|
|
262
|
+
// 关注/粉丝封禁检测状态(仅登录状态)
|
|
263
|
+
let followStatus = "";
|
|
264
|
+
if (loggedIn && exploreEnableFollow) {
|
|
265
|
+
const followElapsed = Math.round(
|
|
266
|
+
(Date.now() - lastFollowSuccessTime) / 60000,
|
|
267
|
+
);
|
|
268
|
+
const followRemaining = Math.max(
|
|
269
|
+
0,
|
|
270
|
+
Math.round(FOLLOW_BLOCK_THRESHOLD / 60000) - followElapsed,
|
|
271
|
+
);
|
|
272
|
+
followStatus = ` | 关注/粉丝上次成功 ${followElapsed} 分钟前 | 封禁检测 ${followRemaining} 分钟后`;
|
|
273
|
+
}
|
|
274
|
+
console.error(`\n[健康检查] ${checkResult.info}${followStatus}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 切换任务前检测验证码
|
|
278
|
+
const switchCaptcha = await detectCaptcha(page);
|
|
279
|
+
if (switchCaptcha && switchCaptcha.visible) {
|
|
280
|
+
console.error(`\n[验证码] 切换任务前检测到验证码,等待3分钟...`);
|
|
281
|
+
captchaCount++;
|
|
282
|
+
console.error(` [验证码] 累计 ${captchaCount} 次`);
|
|
283
|
+
|
|
284
|
+
// 等待3分钟
|
|
285
|
+
await new Promise((r) => setTimeout(r, 3 * 60 * 1000));
|
|
286
|
+
|
|
287
|
+
// 尝试关闭
|
|
288
|
+
const closeResult = await closeCaptcha(page);
|
|
289
|
+
if (closeResult.success) {
|
|
290
|
+
console.error(` [验证码] 已关闭`);
|
|
291
|
+
} else {
|
|
292
|
+
console.error(` [验证码] 关闭失败: ${closeResult.reason}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 上报
|
|
296
|
+
await withRetry("report captcha", () =>
|
|
297
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
298
|
+
userId,
|
|
299
|
+
username: "unknown",
|
|
300
|
+
errorType: "captcha",
|
|
301
|
+
errorMessage: "切换任务前检测到验证码",
|
|
302
|
+
stage: "between-tasks",
|
|
303
|
+
errorStack: "",
|
|
304
|
+
}),
|
|
305
|
+
).catch(() => {});
|
|
306
|
+
|
|
307
|
+
// 累计2次切换账户
|
|
308
|
+
if (captchaCount >= 2) {
|
|
309
|
+
await handleAccountSwitch(`验证码累计 ${captchaCount} 次`);
|
|
310
|
+
captchaCount = 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 关注/粉丝封禁检测移到健康检查中(仅登录状态下)
|
|
315
|
+
if (loggedIn && exploreEnableFollow) {
|
|
316
|
+
const followElapsed = Date.now() - lastFollowSuccessTime;
|
|
317
|
+
if (followElapsed > FOLLOW_BLOCK_THRESHOLD) {
|
|
318
|
+
const minutes = Math.round(followElapsed / 60000);
|
|
319
|
+
console.error(
|
|
320
|
+
`\n[封禁检测] 关注/粉丝功能已 ${minutes} 分钟未成功获取,切换到下一个账户...`,
|
|
321
|
+
);
|
|
322
|
+
await handleAccountSwitch(`关注/粉丝功能被封`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (consecutiveNetworkErrors > 0) {
|
|
327
|
+
const waitTime =
|
|
328
|
+
consecutiveNetworkErrors <= 2
|
|
329
|
+
? 0
|
|
330
|
+
: consecutiveNetworkErrors <= 5
|
|
331
|
+
? 30000
|
|
332
|
+
: 300000;
|
|
333
|
+
if (waitTime > 0) {
|
|
334
|
+
console.error(
|
|
335
|
+
` [网络] 连续 ${consecutiveNetworkErrors} 次网络异常,等待 ${waitTime / 1000}s 后重试...`,
|
|
336
|
+
);
|
|
337
|
+
await new Promise((r) => setTimeout(r, waitTime));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.error(`\n[${processedCount}] 探索 @${username}...`);
|
|
342
|
+
|
|
343
|
+
const { switchMax } = getDelayConfig();
|
|
344
|
+
await delay(switchMax, switchMax * 3);
|
|
345
|
+
|
|
346
|
+
let result = await processExplore(
|
|
347
|
+
page,
|
|
348
|
+
username,
|
|
349
|
+
{
|
|
350
|
+
maxVideos: exploreMaxVideos,
|
|
351
|
+
enableFollow: exploreEnableFollow,
|
|
352
|
+
loggedIn, // 传入登录状态,避免每次调用 isLoggedIn(page)
|
|
353
|
+
maxFollowing: exploreMaxFollowing,
|
|
354
|
+
maxFollowers: exploreMaxFollowers,
|
|
355
|
+
location: exploreLocation,
|
|
356
|
+
browser,
|
|
357
|
+
},
|
|
358
|
+
console.error,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
// 浏览器关闭检测:processExplore 内部 catch 了异常,需要从 result.error 判断
|
|
362
|
+
if (result.error && isBrowserClosedError(new Error(result.error))) {
|
|
363
|
+
const newBrowser = await relaunchBrowser(
|
|
364
|
+
cdpOptions,
|
|
365
|
+
cdpOptions.port || 9222,
|
|
366
|
+
);
|
|
367
|
+
browser = newBrowser;
|
|
368
|
+
const newPage = await setupNewPage(browser);
|
|
369
|
+
Object.assign(page, newPage);
|
|
370
|
+
// 重试当前用户
|
|
371
|
+
result = await processExplore(
|
|
372
|
+
page,
|
|
373
|
+
username,
|
|
374
|
+
{
|
|
375
|
+
maxVideos: exploreMaxVideos,
|
|
376
|
+
enableFollow: exploreEnableFollow,
|
|
377
|
+
loggedIn, // 传入登录状态
|
|
378
|
+
maxFollowing: exploreMaxFollowing,
|
|
379
|
+
maxFollowers: exploreMaxFollowers,
|
|
380
|
+
location: exploreLocation,
|
|
381
|
+
browser,
|
|
382
|
+
},
|
|
383
|
+
console.error,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (result.restricted) {
|
|
388
|
+
consecutiveNetworkErrors = 0;
|
|
389
|
+
await apiPost(`${serverUrl}/api/job/${username}`, {
|
|
390
|
+
restricted: true,
|
|
391
|
+
userInfo: result.userInfo || {},
|
|
392
|
+
});
|
|
393
|
+
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
394
|
+
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (result.error) {
|
|
401
|
+
consecutiveNetworkErrors++;
|
|
402
|
+
errorCount++;
|
|
403
|
+
|
|
404
|
+
// 临时性错误:自动重试一次
|
|
405
|
+
if (result.retryable) {
|
|
406
|
+
console.error(` [临时错误] 等待 5 秒后重试 @${username}...`);
|
|
407
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
408
|
+
result = await processExplore(
|
|
409
|
+
page,
|
|
410
|
+
username,
|
|
411
|
+
{
|
|
412
|
+
maxVideos: exploreMaxVideos,
|
|
413
|
+
enableFollow: exploreEnableFollow,
|
|
414
|
+
loggedIn,
|
|
415
|
+
maxFollowing: exploreMaxFollowing,
|
|
416
|
+
maxFollowers: exploreMaxFollowers,
|
|
417
|
+
location: exploreLocation,
|
|
418
|
+
browser,
|
|
419
|
+
},
|
|
420
|
+
console.error,
|
|
421
|
+
);
|
|
422
|
+
// 重试成功后继续正常流程
|
|
423
|
+
if (!result.error) {
|
|
424
|
+
consecutiveNetworkErrors = 0;
|
|
425
|
+
errorCount--;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (result.error) {
|
|
430
|
+
// 上报错误(重试后仍有错误才上报)
|
|
431
|
+
await apiPost(`${serverUrl}/api/job/${username}`, {
|
|
432
|
+
error: result.error,
|
|
433
|
+
});
|
|
434
|
+
const errorType = result.error.startsWith("被封:")
|
|
435
|
+
? "被封"
|
|
436
|
+
: consecutiveNetworkErrors > 1
|
|
437
|
+
? "network"
|
|
438
|
+
: "other";
|
|
439
|
+
await withRetry("report error", () =>
|
|
440
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
441
|
+
userId,
|
|
442
|
+
username,
|
|
443
|
+
errorType,
|
|
444
|
+
errorMessage: result.error,
|
|
445
|
+
stage: "process",
|
|
446
|
+
errorStack: result.errorStack || "",
|
|
447
|
+
}),
|
|
448
|
+
).catch(() => {});
|
|
449
|
+
if (errorType === "被封") {
|
|
450
|
+
blockedCount++;
|
|
451
|
+
console.error(` [被封] 累计 ${blockedCount} 次`);
|
|
452
|
+
if (blockedCount >= 3) {
|
|
453
|
+
await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
|
|
454
|
+
blockedCount = 0;
|
|
455
|
+
}
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
459
|
+
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (result.captchaDetected) {
|
|
467
|
+
captchaCount++;
|
|
468
|
+
console.error(` [验证码] 累计 ${captchaCount} 次`);
|
|
469
|
+
|
|
470
|
+
await withRetry("report captcha", () =>
|
|
471
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
472
|
+
userId,
|
|
473
|
+
username,
|
|
474
|
+
errorType: "captcha",
|
|
475
|
+
errorMessage: result.captchaMessage || "页面出现验证码",
|
|
476
|
+
stage: result.captchaStage || "video-page",
|
|
477
|
+
errorStack: "",
|
|
478
|
+
}),
|
|
479
|
+
).catch(() => {});
|
|
480
|
+
|
|
481
|
+
// 累计2次验证码,切换账户
|
|
482
|
+
if (captchaCount >= 2) {
|
|
483
|
+
await handleAccountSwitch(`验证码累计 ${captchaCount} 次`);
|
|
484
|
+
captchaCount = 0;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
consecutiveNetworkErrors = 0;
|
|
489
|
+
|
|
490
|
+
// 更新关注/粉丝成功时间(仅登录状态下)
|
|
491
|
+
if (result.hasFollowData && result.keepFollow) {
|
|
492
|
+
const totalFollows =
|
|
493
|
+
(result.discoveredFollowing || []).length +
|
|
494
|
+
(result.discoveredFollowers || []).length;
|
|
495
|
+
if (totalFollows > 0) {
|
|
496
|
+
lastFollowSuccessTime = Date.now();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const guessedLocation = result.locationCreated || null;
|
|
501
|
+
|
|
502
|
+
const payload = {
|
|
503
|
+
userInfo: result.userInfo || {},
|
|
504
|
+
discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
|
|
505
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
506
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
507
|
+
guessedLocation,
|
|
508
|
+
})),
|
|
509
|
+
discoveredFollowers: (result.discoveredFollowers || []).map((f) => ({
|
|
510
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
511
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
512
|
+
guessedLocation,
|
|
513
|
+
})),
|
|
514
|
+
processed: result.processed,
|
|
515
|
+
hasFollowData: result.hasFollowData,
|
|
516
|
+
keepFollow: result.keepFollow,
|
|
517
|
+
locationCreated: result.locationCreated,
|
|
518
|
+
noVideo: result.noVideo,
|
|
519
|
+
collectedVideos: result.collectedVideos,
|
|
520
|
+
};
|
|
521
|
+
await apiPost(`${serverUrl}/api/job/${username}`, payload);
|
|
522
|
+
|
|
523
|
+
// 视频登记
|
|
524
|
+
if (result.videoList && result.videoList.length > 0) {
|
|
525
|
+
const { registered, skipped } = await apiPost(
|
|
526
|
+
`${serverUrl}/api/videos`,
|
|
527
|
+
{
|
|
528
|
+
sourceUser: username,
|
|
529
|
+
videoList: result.videoList,
|
|
530
|
+
locationCreated: result.locationCreated,
|
|
531
|
+
ttSeller: result.userInfo?.ttSeller || false,
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
console.error(` 视频登记: ${registered} 条新增, ${skipped} 条已存在`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
console.error(" 已提交");
|
|
538
|
+
|
|
539
|
+
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
540
|
+
console.error(`\n已达上限 ${exploreMaxUsers} 个用户,停止处理`);
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const stats = await apiGet(`${serverUrl}/api/stats`);
|
|
546
|
+
console.error(`\n完成: ${processedCount} 个用户处理, ${errorCount} 个出错`);
|
|
547
|
+
console.error(
|
|
548
|
+
` 总用户: ${stats.totalUsers}, 已完成: ${stats.processedUsers}, 待处理: ${stats.pendingUsers}, 错误: ${stats.errorUsers}`,
|
|
549
|
+
);
|
|
550
|
+
} finally {
|
|
551
|
+
process.removeListener("SIGINT", onSigint);
|
|
552
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
553
|
+
await browser?.close().catch(() => {});
|
|
554
|
+
}
|
|
555
|
+
}
|