tt-help-cli-ycl 1.4.33 → 1.4.35

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.33",
3
+ "version": "1.4.35",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@ import {
25
25
  import { showResourceUsage } from "../utils/index.js";
26
26
  import { HealthChecker } from "../lib/browser/health-checker.js";
27
27
  import { createApiClient } from "../lib/api-client.js";
28
+ import { createCDNHandler } from "../lib/cdn-handler.js";
28
29
  import path from "path";
29
30
  import os from "os";
30
31
 
@@ -245,9 +246,30 @@ export async function handleExplore(options) {
245
246
  lastFollowSuccessTime = Date.now(); // 切换账户后重置
246
247
  captchaCount = 0; // 重置验证码计数
247
248
  blockedCount = 0; // 重置被封计数
249
+ cdn.reset();
248
250
  consecutiveNetworkErrors = 0; // 重置网络错误计数
249
251
  }
250
252
 
253
+ // CDN 限流处理器(共享逻辑)
254
+ const cdn = createCDNHandler({
255
+ submitJob: (result, username) =>
256
+ apiPost(`${serverUrl}/api/job/${username}`, {
257
+ error: result.error || "CDN限流",
258
+ }),
259
+ reportError: (result, username) =>
260
+ withRetry("report cdn-blocked", () =>
261
+ apiPost(`${serverUrl}/api/error-report`, {
262
+ userId,
263
+ username,
264
+ errorType: "cdn_blocked",
265
+ errorMessage: result.error || "CDN限流 (Access Denied)",
266
+ stage: "process",
267
+ errorStack: result.errorStack || "",
268
+ }),
269
+ ),
270
+ switchAccount: (reason) => handleAccountSwitch(reason),
271
+ });
272
+
251
273
  let processedCount = 0;
252
274
  let errorCount = 0;
253
275
  let consecutiveNetworkErrors = 0;
@@ -377,6 +399,7 @@ export async function handleExplore(options) {
377
399
  location: exploreLocation,
378
400
  proxyServer: cdpOptions.proxyServer || null,
379
401
  browser,
402
+ shopPage,
380
403
  },
381
404
  console.error,
382
405
  );
@@ -414,6 +437,7 @@ export async function handleExplore(options) {
414
437
 
415
438
  if (result.restricted) {
416
439
  consecutiveNetworkErrors = 0;
440
+ cdn.reset();
417
441
  await apiPost(`${serverUrl}/api/job/${username}`, {
418
442
  restricted: true,
419
443
  userInfo: result.userInfo || {},
@@ -425,6 +449,14 @@ export async function handleExplore(options) {
425
449
  continue;
426
450
  }
427
451
 
452
+ // CDN 限流检测:渐进退避,避免快速空转消耗任务
453
+ if (result.cdnBlocked) {
454
+ consecutiveNetworkErrors++;
455
+ errorCount++;
456
+ await cdn.handle(result, username);
457
+ continue;
458
+ }
459
+
428
460
  if (result.error) {
429
461
  consecutiveNetworkErrors++;
430
462
  errorCount++;
@@ -516,6 +548,7 @@ export async function handleExplore(options) {
516
548
  }
517
549
 
518
550
  consecutiveNetworkErrors = 0;
551
+ cdn.reset();
519
552
 
520
553
  // 更新关注/粉丝成功时间(仅登录状态下)
521
554
  if (result.hasFollowData && result.keepFollow) {
@@ -0,0 +1,425 @@
1
+ import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
2
+ import { CDNBlockedError } from "../lib/parse-ssr.mjs";
3
+ import {
4
+ proxy as configuredProxy,
5
+ userId as configuredUserId,
6
+ saveUserId,
7
+ } from "../lib/constants.js";
8
+ import { getMacOrUuid } from "../lib/mac-or-uuid.js";
9
+ import { createApiClient } from "../lib/api-client.js";
10
+ import { DEFAULT_TARGET_LOCATIONS } from "../lib/target-locations.js";
11
+ import v8 from "node:v8";
12
+
13
+ const HEAP_RESTART_RATIO = 0.72;
14
+ const MAX_TASK_BATCHES_BEFORE_RESTART = 200;
15
+
16
+ function formatNow() {
17
+ return new Date().toLocaleString("zh-CN", {
18
+ hour12: false,
19
+ });
20
+ }
21
+
22
+ function precheckLog(message = "") {
23
+ console.error(`[${formatNow()}] ${message}`);
24
+ }
25
+
26
+ function isBrowserClosedError(err) {
27
+ if (!err) return false;
28
+ const msg = err.message || err.toString() || "";
29
+ return (
30
+ msg.includes("Target page, context or browser has been closed") ||
31
+ msg.includes("browser has been closed") ||
32
+ msg.includes("browserContext.newPage") ||
33
+ msg.includes("Protocol error")
34
+ );
35
+ }
36
+
37
+ function getHeapUsage() {
38
+ const heapUsed = process.memoryUsage().heapUsed;
39
+ const heapLimit = v8.getHeapStatistics().heap_size_limit;
40
+ return {
41
+ heapUsed,
42
+ heapLimit,
43
+ ratio: heapLimit > 0 ? heapUsed / heapLimit : 0,
44
+ };
45
+ }
46
+
47
+ async function recycleScraper(scraper, reason) {
48
+ precheckLog(`[Precheck] ${reason},正在重启浏览器实例...`);
49
+ await scraper.restart();
50
+ precheckLog("[Precheck] 浏览器实例重启完成");
51
+ }
52
+
53
+ export async function handlePrecheck(options) {
54
+ const {
55
+ precheckInterval = 60,
56
+ precheckMaxAge = 86400,
57
+ precheckParallel = 20,
58
+ precheckSize = 5,
59
+ precheckCountries,
60
+ serverUrl,
61
+ customProxy,
62
+ showHelp,
63
+ } = options;
64
+ const effectiveProxy = customProxy || configuredProxy;
65
+
66
+ if (showHelp) {
67
+ precheckLog(
68
+ "用法: tt-help precheck [-s 服务端地址] [-i 轮询间隔] [--max-age 过期阈值] [-p 并行数] [-c 国家列表] [--proxy 代理] [--size 池大小]",
69
+ );
70
+ precheckLog("");
71
+ precheckLog("参数:");
72
+ precheckLog(
73
+ " -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
74
+ );
75
+ precheckLog(" -i, --interval <N> 空闲轮询间隔,单位秒(默认: 60)");
76
+ precheckLog(
77
+ " --max-age <N> 用户过期阈值,单位秒(默认: 86400 = 24小时)",
78
+ );
79
+ precheckLog(" -p, --parallel <N> 每批检测用户数(默认: 20)");
80
+ precheckLog(" --size <N> 浏览器页面池大小(默认: 5)");
81
+ precheckLog(
82
+ " -c, --countries <A,B> 目标国家列表(默认: 使用内置目标国家)",
83
+ );
84
+ precheckLog(" --proxy <代理地址> HTTP 代理地址");
85
+ precheckLog("");
86
+ precheckLog("说明:");
87
+ precheckLog(
88
+ " 从服务端拉取 refresh_time 超过 --max-age 的商家用户,HTTP 获取 videoCount 对比。",
89
+ );
90
+ precheckLog(" 有更新 → 标记 pending_refresh=1(等待 refresh 客户端处理)");
91
+ precheckLog(" 无更新 → 更新 refresh_time(跳过本轮)");
92
+ precheckLog(" 支持多实例并行,服务端统一管理 DB。");
93
+ precheckLog("");
94
+ precheckLog("示例:");
95
+ precheckLog(" tt-help precheck -s http://127.0.0.1:3001");
96
+ precheckLog(" tt-help precheck --max-age 43200 -p 30 --size 5");
97
+ precheckLog(" tt-help precheck -c DE,ES,FR --proxy http://127.0.0.1:7890");
98
+ return;
99
+ }
100
+
101
+ const locations =
102
+ precheckCountries && precheckCountries.length > 0
103
+ ? precheckCountries
104
+ : DEFAULT_TARGET_LOCATIONS;
105
+
106
+ precheckLog(`[Precheck] 服务端: ${serverUrl}`);
107
+ precheckLog(
108
+ `[Precheck] 过期阈值: ${precheckMaxAge}秒 (${Math.round(precheckMaxAge / 3600)}小时), 轮询间隔: ${precheckInterval}秒`,
109
+ );
110
+ precheckLog(
111
+ `[Precheck] 每批数量: ${precheckParallel}, 页面池: ${precheckSize}`,
112
+ );
113
+ precheckLog(`[Precheck] 目标国家: ${locations.join(", ")}`);
114
+ if (effectiveProxy) {
115
+ precheckLog(`[Precheck] 代理: ${effectiveProxy}`);
116
+ }
117
+
118
+ // ── 设备标识 ──
119
+ let userId = configuredUserId;
120
+ if (!userId) {
121
+ userId = await getMacOrUuid();
122
+ saveUserId(userId);
123
+ precheckLog(`[Precheck] 未检测到本地用户编号,已生成并使用: ${userId}`);
124
+ }
125
+ precheckLog(`[Precheck] 用户编号: ${userId}`);
126
+
127
+ let shuttingDown = false;
128
+ let forceExitTimer = null;
129
+
130
+ const shutdown = async (signal) => {
131
+ if (shuttingDown) return;
132
+ shuttingDown = true;
133
+ forceExitTimer = setTimeout(() => {
134
+ precheckLog("[Precheck] 关闭超时,强制退出");
135
+ process.exit(0);
136
+ }, 10000);
137
+ forceExitTimer.unref?.();
138
+ precheckLog(`\n[Precheck] 收到 ${signal},等待当前批次完成后退出...`);
139
+ };
140
+
141
+ process.on("SIGINT", () => {
142
+ void shutdown("SIGINT");
143
+ });
144
+ process.on("SIGTERM", () => {
145
+ void shutdown("SIGTERM");
146
+ });
147
+
148
+ try {
149
+ const { apiGet, apiPost } = createApiClient({ log: false });
150
+
151
+ const scraper = new TikTokScraper({
152
+ poolSize: precheckSize,
153
+ proxyServer: effectiveProxy || null,
154
+ });
155
+ await scraper.init();
156
+ precheckLog("[Precheck] 浏览器初始化完成,开始循环预检...");
157
+
158
+ let loopCount = 0;
159
+ let browserRestartCount = 0;
160
+ let taskBatchCount = 0;
161
+
162
+ while (!shuttingDown) {
163
+ loopCount++;
164
+
165
+ // 检查浏览器是否存活
166
+ if (!scraper.isAlive()) {
167
+ precheckLog(
168
+ `[Precheck] 浏览器已关闭,正在重启 (${++browserRestartCount})...`,
169
+ );
170
+ await scraper.restart();
171
+ precheckLog("[Precheck] 浏览器重启完成");
172
+ }
173
+
174
+ // 收到退出信号,完成清理
175
+ if (shuttingDown) {
176
+ precheckLog("[Precheck] 正在关闭浏览器...");
177
+ await scraper.close().catch(() => {});
178
+ if (forceExitTimer) {
179
+ clearTimeout(forceExitTimer);
180
+ forceExitTimer = null;
181
+ }
182
+ precheckLog("[Precheck] 已退出");
183
+ process.exit(0);
184
+ }
185
+
186
+ // 从服务端获取候选用户
187
+ const locationsParam = encodeURIComponent(locations.join(","));
188
+ const { candidates } = await apiGet(
189
+ `${serverUrl}/api/precheck-candidates?limit=${precheckParallel}&maxAge=${precheckMaxAge}&locations=${locationsParam}`,
190
+ );
191
+
192
+ // 收到退出信号,不再处理新任务
193
+ if (shuttingDown) {
194
+ precheckLog("[Precheck] 已收到退出信号,跳过本轮...");
195
+ await scraper.close().catch(() => {});
196
+ if (forceExitTimer) {
197
+ clearTimeout(forceExitTimer);
198
+ forceExitTimer = null;
199
+ }
200
+ precheckLog("[Precheck] 已退出");
201
+ process.exit(0);
202
+ }
203
+
204
+ if (!candidates || candidates.length === 0) {
205
+ const heap = getHeapUsage();
206
+ if (heap.ratio >= HEAP_RESTART_RATIO) {
207
+ await recycleScraper(
208
+ scraper,
209
+ `空闲时堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
210
+ );
211
+ }
212
+ if (loopCount === 1) {
213
+ precheckLog(
214
+ `[Precheck] 当前无到期用户,${precheckInterval}秒后重试...`,
215
+ );
216
+ }
217
+ await new Promise((r) => {
218
+ const timer = setTimeout(r, precheckInterval * 1000);
219
+ // 可中断的等待:收到退出信号立即返回
220
+ const check = setInterval(() => {
221
+ if (shuttingDown) {
222
+ clearTimeout(timer);
223
+ clearInterval(check);
224
+ r();
225
+ }
226
+ }, 500);
227
+ });
228
+ continue;
229
+ }
230
+
231
+ precheckLog(
232
+ `\n[Precheck] 第 ${loopCount} 轮,${candidates.length} 个候选用户...`,
233
+ );
234
+ taskBatchCount++;
235
+
236
+ // 收到退出信号但已有任务在跑,完成当前批次
237
+ if (shuttingDown) {
238
+ precheckLog("[Precheck] 正在处理当前批次,完成后退出...");
239
+ }
240
+
241
+ const results = await Promise.allSettled(
242
+ candidates.map(async (candidate) => {
243
+ const uniqueId = candidate.uniqueId;
244
+ try {
245
+ const info = await scraper.getUserInfo(uniqueId);
246
+ return {
247
+ uniqueId,
248
+ info,
249
+ error: null,
250
+ storedVideoCount: candidate.videoCount,
251
+ };
252
+ } catch (err) {
253
+ return {
254
+ uniqueId,
255
+ info: null,
256
+ error: err,
257
+ storedVideoCount: candidate.videoCount,
258
+ };
259
+ }
260
+ }),
261
+ );
262
+
263
+ // 批次完成后检查退出信号
264
+ if (shuttingDown) {
265
+ precheckLog("[Precheck] 正在关闭浏览器...");
266
+ await scraper.close().catch(() => {});
267
+ if (forceExitTimer) {
268
+ clearTimeout(forceExitTimer);
269
+ forceExitTimer = null;
270
+ }
271
+ precheckLog("[Precheck] 已退出");
272
+ process.exit(0);
273
+ }
274
+
275
+ let needRefreshCount = 0;
276
+ let skipCount = 0;
277
+ let failCount = 0;
278
+ let cdnBlockedCount = 0;
279
+ let needRestart = false;
280
+
281
+ // 收集预检结果,批量提交
282
+ const batchResults = [];
283
+
284
+ for (const result of results) {
285
+ if (result.status !== "fulfilled") {
286
+ failCount++;
287
+ precheckLog(
288
+ ` ✗ 任务执行异常: ${result.reason?.message || "未知错误"}`,
289
+ );
290
+ continue;
291
+ }
292
+
293
+ const { uniqueId, info, error, storedVideoCount } = result.value;
294
+
295
+ if (error) {
296
+ if (error instanceof CDNBlockedError) {
297
+ precheckLog(` ⚠ @${uniqueId} CDN限流 (Access Denied)`);
298
+ cdnBlockedCount++;
299
+ } else if (isBrowserClosedError(error)) {
300
+ needRestart = true;
301
+ precheckLog(` ✗ @${uniqueId} 浏览器断开: ${error.message}`);
302
+ } else {
303
+ precheckLog(
304
+ ` ✗ @${uniqueId} 获取失败: ${error.message || "未知错误"}`,
305
+ );
306
+ }
307
+ failCount++;
308
+ continue;
309
+ }
310
+
311
+ if (!info) {
312
+ precheckLog(` ✗ @${uniqueId} 未获取到用户信息`);
313
+ failCount++;
314
+ continue;
315
+ }
316
+
317
+ // 私有账号或异常状态:跳过,更新 refresh_time
318
+ if (info.error || info.statusCode !== 0) {
319
+ batchResults.push({
320
+ uniqueId,
321
+ needRefresh: false,
322
+ videoCount: info.videoCount,
323
+ });
324
+ skipCount++;
325
+ const statusTag = info.statusCode
326
+ ? ` (statusCode=${info.statusCode})`
327
+ : "";
328
+ precheckLog(` - @${uniqueId} 跳过${statusTag}`);
329
+ continue;
330
+ }
331
+
332
+ const currentVideoCount = info.videoCount || 0;
333
+
334
+ if (currentVideoCount > storedVideoCount) {
335
+ batchResults.push({
336
+ uniqueId,
337
+ needRefresh: true,
338
+ videoCount: currentVideoCount,
339
+ });
340
+ needRefreshCount++;
341
+ precheckLog(
342
+ ` ✓ @${uniqueId} 有更新 (${storedVideoCount} → ${currentVideoCount})`,
343
+ );
344
+ } else {
345
+ batchResults.push({
346
+ uniqueId,
347
+ needRefresh: false,
348
+ videoCount: currentVideoCount,
349
+ });
350
+ skipCount++;
351
+ }
352
+ }
353
+
354
+ // 批量提交预检结果到服务端
355
+ if (batchResults.length > 0) {
356
+ try {
357
+ const submitRet = await apiPost(`${serverUrl}/api/precheck-results`, {
358
+ results: batchResults,
359
+ });
360
+ precheckLog(
361
+ ` 提交: ${submitRet.saved || batchResults.length}/${batchResults.length} 条`,
362
+ );
363
+ } catch (err) {
364
+ precheckLog(` ✗ 提交预检结果失败: ${err.message}`);
365
+ }
366
+ }
367
+
368
+ precheckLog(
369
+ ` 本轮结果: ${needRefreshCount} 需刷新, ${skipCount} 跳过, ${failCount} 失败`,
370
+ );
371
+
372
+ // CDN 限流比例超过 30% 时,冷却 + 重启浏览器
373
+ const cdnRatio =
374
+ candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
375
+ if (cdnRatio > 0.3) {
376
+ const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
377
+ precheckLog(
378
+ ` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
379
+ );
380
+ await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
381
+ await recycleScraper(
382
+ scraper,
383
+ `CDN限流比例过高 (${cdnBlockedCount}/${candidates.length})`,
384
+ );
385
+ browserRestartCount++;
386
+ taskBatchCount = 0;
387
+ }
388
+
389
+ const heap = getHeapUsage();
390
+ if (heap.ratio >= HEAP_RESTART_RATIO) {
391
+ await recycleScraper(
392
+ scraper,
393
+ `本批后堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
394
+ );
395
+ browserRestartCount++;
396
+ taskBatchCount = 0;
397
+ } else if (taskBatchCount >= MAX_TASK_BATCHES_BEFORE_RESTART) {
398
+ await recycleScraper(scraper, `已连续处理 ${taskBatchCount} 批任务`);
399
+ browserRestartCount++;
400
+ taskBatchCount = 0;
401
+ }
402
+
403
+ if (needRestart) {
404
+ precheckLog("[Precheck] 检测到浏览器异常,将在下一轮重启...");
405
+ }
406
+
407
+ // 批次间短暂休息
408
+ await new Promise((r) => setTimeout(r, 500));
409
+ }
410
+ } catch (err) {
411
+ precheckLog(`[Precheck] 运行异常: ${err.message}`);
412
+ throw err;
413
+ } finally {
414
+ if (forceExitTimer) {
415
+ clearTimeout(forceExitTimer);
416
+ forceExitTimer = null;
417
+ }
418
+ process.removeListener("SIGINT", () => {
419
+ void shutdown("SIGINT");
420
+ });
421
+ process.removeListener("SIGTERM", () => {
422
+ void shutdown("SIGTERM");
423
+ });
424
+ }
425
+ }
@@ -24,6 +24,7 @@ import {
24
24
  } from "../lib/browser/cdp.js";
25
25
  import { HealthChecker } from "../lib/browser/health-checker.js";
26
26
  import { createApiClient } from "../lib/api-client.js";
27
+ import { createCDNHandler } from "../lib/cdn-handler.js";
27
28
  import path from "path";
28
29
  import os from "os";
29
30
 
@@ -245,9 +246,30 @@ export async function handleRefresh(options) {
245
246
  lastFollowSuccessTime = Date.now();
246
247
  captchaCount = 0;
247
248
  blockedCount = 0;
249
+ cdn.reset();
248
250
  consecutiveNetworkErrors = 0;
249
251
  }
250
252
 
253
+ // CDN 限流处理器(共享逻辑)
254
+ const cdn = createCDNHandler({
255
+ submitJob: (result, username) =>
256
+ apiPost(`${serverUrl}/api/redo-job/${username}`, {
257
+ error: result.error || "CDN限流",
258
+ }),
259
+ reportError: (result, username) =>
260
+ withRetry("report cdn-blocked", () =>
261
+ apiPost(`${serverUrl}/api/error-report`, {
262
+ userId,
263
+ username,
264
+ errorType: "cdn_blocked",
265
+ errorMessage: result.error || "CDN限流 (Access Denied)",
266
+ stage: "process",
267
+ errorStack: result.errorStack || "",
268
+ }),
269
+ ),
270
+ switchAccount: (reason) => handleAccountSwitch(reason),
271
+ });
272
+
251
273
  let processedCount = 0;
252
274
  let errorCount = 0;
253
275
  let consecutiveNetworkErrors = 0;
@@ -426,6 +448,7 @@ export async function handleRefresh(options) {
426
448
 
427
449
  if (result.restricted) {
428
450
  consecutiveNetworkErrors = 0;
451
+ cdn.reset();
429
452
  await apiPost(`${serverUrl}/api/redo-job/${username}`, {
430
453
  restricted: true,
431
454
  userInfo: result.userInfo || {},
@@ -437,6 +460,14 @@ export async function handleRefresh(options) {
437
460
  continue;
438
461
  }
439
462
 
463
+ // CDN 限流检测:渐进退避,避免快速空转消耗任务
464
+ if (result.cdnBlocked) {
465
+ consecutiveNetworkErrors++;
466
+ errorCount++;
467
+ await cdn.handle(result, username);
468
+ continue;
469
+ }
470
+
440
471
  if (result.error) {
441
472
  consecutiveNetworkErrors++;
442
473
  errorCount++;
@@ -526,6 +557,7 @@ export async function handleRefresh(options) {
526
557
  }
527
558
 
528
559
  consecutiveNetworkErrors = 0;
560
+ cdn.reset();
529
561
 
530
562
  // 更新关注/粉丝成功时间
531
563
  if (result.hasFollowData && result.keepFollow) {
@@ -222,6 +222,10 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
222
222
  throw new Error(`被封: ${username}`);
223
223
  }
224
224
 
225
+ if (pageError === "cdn_blocked") {
226
+ throw new Error(`CDN限流: ${username}`);
227
+ }
228
+
225
229
  if (pageError) {
226
230
  log(` [API拦截] 数据为空,识别到页面错误: ${pageError}`);
227
231
  return new Map();
package/src/lib/args.js CHANGED
@@ -690,6 +690,61 @@ function parseRecheckArgs(args) {
690
690
  };
691
691
  }
692
692
 
693
+ function parsePrecheckArgs(args) {
694
+ let serverUrl = defaultServer;
695
+ let precheckInterval = 60;
696
+ let precheckMaxAge = 86400;
697
+ let precheckParallel = 20;
698
+ let precheckSize = 5;
699
+ let precheckCountries = null;
700
+ let customProxy = null;
701
+ let showHelp = false;
702
+
703
+ for (let i = 0; i < args.length; i++) {
704
+ const arg = args[i];
705
+ if (arg === "-s" || arg === "--server") {
706
+ serverUrl = args[++i];
707
+ } else if (arg === "-i" || arg === "--interval") {
708
+ precheckInterval = parseInt(args[++i], 10) || 60;
709
+ } else if (arg === "--max-age") {
710
+ precheckMaxAge = parseInt(args[++i], 10) || 86400;
711
+ } else if (arg === "-p" || arg === "--parallel") {
712
+ precheckParallel = parseInt(args[++i], 10) || 20;
713
+ } else if (arg === "--size") {
714
+ precheckSize = parseInt(args[++i], 10) || 5;
715
+ } else if (arg === "-c" || arg === "--countries") {
716
+ precheckCountries = args[++i]
717
+ .split(",")
718
+ .map((c) => c.trim().toUpperCase())
719
+ .filter(Boolean);
720
+ } else if (arg === "--proxy") {
721
+ customProxy = args[++i];
722
+ } else if (arg === "--help" || arg === "-h") {
723
+ showHelp = true;
724
+ }
725
+ }
726
+
727
+ return {
728
+ subcommand: "precheck",
729
+ serverUrl,
730
+ precheckInterval,
731
+ precheckMaxAge,
732
+ precheckParallel,
733
+ precheckSize,
734
+ precheckCountries,
735
+ customProxy,
736
+ showHelp,
737
+ urls: [],
738
+ outputFormat: "json",
739
+ exploreCount: 0,
740
+ showConfig: false,
741
+ configAction: null,
742
+ configValue: null,
743
+ pipeMode: false,
744
+ filterStr: null,
745
+ };
746
+ }
747
+
693
748
  function parseOpenArgs(args) {
694
749
  let openPort = null;
695
750
  let openList = false;
@@ -1227,6 +1282,10 @@ export function parseArgs() {
1227
1282
  return parseRawSyncArgs(args.slice(1));
1228
1283
  }
1229
1284
 
1285
+ if (args.length > 0 && args[0] === "precheck") {
1286
+ return parsePrecheckArgs(args.slice(1));
1287
+ }
1288
+
1230
1289
  const urls = [];
1231
1290
  let inputFile = null;
1232
1291
  let outputFile = null;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * CDN 限流处理器(explore / refresh 共用)
3
+ *
4
+ * 用法:
5
+ * const cdn = createCDNHandler({ submitJob, reportError, switchAccount });
6
+ *
7
+ * // 重置(切换账户后 / 任务成功后)
8
+ * cdn.reset();
9
+ *
10
+ * // 处理 CDN 限流
11
+ * if (result.cdnBlocked) {
12
+ * await cdn.handle(result, username);
13
+ * continue;
14
+ * }
15
+ */
16
+
17
+ const THRESHOLD = 3;
18
+
19
+ export function createCDNHandler({
20
+ submitJob,
21
+ reportError,
22
+ switchAccount,
23
+ log = console.error,
24
+ }) {
25
+ let count = 0;
26
+
27
+ return {
28
+ reset() {
29
+ count = 0;
30
+ },
31
+
32
+ async handle(result, username) {
33
+ count++;
34
+
35
+ const waitSec = count <= 1 ? 30 : count <= 2 ? 60 : 120;
36
+ log(` [CDN限流] 第 ${count} 次,等待 ${waitSec}s...`);
37
+
38
+ await submitJob(result, username).catch(() => {});
39
+ await reportError(result, username).catch(() => {});
40
+
41
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
42
+
43
+ if (count >= THRESHOLD) {
44
+ log(` [CDN限流] 累计 ${count} 次,切换账户...`);
45
+ await switchAccount(`CDN限流累计 ${count} 次`);
46
+ }
47
+ },
48
+ };
49
+ }
@@ -160,6 +160,22 @@ const HELP_TEXT = [
160
160
  " --user-id <编号> 客户端编号(设备ID),默认自动生成",
161
161
  "--proxy <地址> 浏览器代理(如 socks5://127.0.0.1:1080),未指定时使用配置文件 proxy",
162
162
  "",
163
+ " tt-help precheck [选项]",
164
+ " 对目标商家用户进行轻量预检,通过 HTTP 获取 videoCount 判断是否有新视频",
165
+ " 有更新 → 标记 pending_refresh=1(等待 refresh 客户端完整采集)",
166
+ " 无更新 → 更新 refresh_time(跳过本轮,24h 后重新检查)",
167
+ " 选项:",
168
+ " -s, --server <URL> 服务端地址,默认 http://127.0.0.1:3001",
169
+ " -i, --interval <秒数> 空闲轮询间隔,默认 60 秒",
170
+ " --max-age <秒数> 用户过期阈值,默认 86400(24小时)",
171
+ " -p, --parallel <数量> 每批检测用户数,默认 20",
172
+ " --size <数量> 浏览器页面池大小,默认 5",
173
+ ` -c, --countries <国家> 目标国家列表,逗号分隔,默认 ${DEFAULT_TARGET_LOCATIONS_CSV}`,
174
+ " --proxy <地址> HTTP 代理(如 http://127.0.0.1:7890)",
175
+ " 示例: tt-help precheck -s http://127.0.0.1:3001",
176
+ " tt-help precheck -i 60 --max-age 86400 -p 20 -c DE,ES,FR",
177
+ " tt-help precheck --proxy http://127.0.0.1:7890",
178
+ "",
163
179
  " tt-help info <URL> [URL2 ...] [--onlyvideo]",
164
180
  " 获取用户/视频信息,支持多个 URL",
165
181
  " 主页 URL → 返回用户信息",
@@ -52,29 +52,31 @@ const PATTERNS = {
52
52
  "找不到此账号",
53
53
  ],
54
54
  service_error: ["出错了", "很抱歉"],
55
+ cdn_blocked: [
56
+ "Access Denied",
57
+ "Reference #",
58
+ "You don't have permission to access",
59
+ ],
55
60
  };
56
61
 
57
62
  export async function detectPageError(page, timeout = 10000) {
58
63
  try {
59
- return await page.evaluate(
60
- (patterns) => {
61
- const body = document.body;
62
- if (!body) return null;
63
- const bodyText = body.innerText;
64
- const lower = bodyText.toLowerCase();
64
+ return await page.evaluate((patterns) => {
65
+ const body = document.body;
66
+ if (!body) return null;
67
+ const bodyText = body.innerText;
68
+ const lower = bodyText.toLowerCase();
65
69
 
66
- for (const [type, phrases] of Object.entries(patterns)) {
67
- for (const phrase of phrases) {
68
- if (lower.includes(phrase.toLowerCase())) {
69
- return type;
70
- }
70
+ for (const [type, phrases] of Object.entries(patterns)) {
71
+ for (const phrase of phrases) {
72
+ if (lower.includes(phrase.toLowerCase())) {
73
+ return type;
71
74
  }
72
75
  }
76
+ }
73
77
 
74
- return null;
75
- },
76
- PATTERNS,
77
- );
78
+ return null;
79
+ }, PATTERNS);
78
80
  } catch (e) {
79
81
  // CDP 断连或超时:返回 null 而非永久挂起
80
82
  if (
package/src/main.js CHANGED
@@ -12,6 +12,7 @@ import { handleVideoStats } from "./cli/videostats.js";
12
12
  import { handleDbImport } from "./cli/db-import.js";
13
13
  import { handleWebserver } from "./cli/webserver.js";
14
14
  import { handleRefresh } from "./cli/refresh.js";
15
+ import { handlePrecheck } from "./cli/precheck.js";
15
16
  import {
16
17
  handleTag,
17
18
  handleDiscover,
@@ -48,6 +49,8 @@ async function main() {
48
49
  return handleDbImport(parsed);
49
50
  case "refresh":
50
51
  return handleRefresh(parsed);
52
+ case "precheck":
53
+ return handlePrecheck(parsed);
51
54
  case "tag":
52
55
  return handleTag(parsed);
53
56
  case "raw-sync":
package/src/npm-main.js CHANGED
@@ -8,6 +8,7 @@ import { handleConfig, showConfig, showUsage, version } from "./cli/config.js";
8
8
  import { handleOpen } from "./cli/open.js";
9
9
  import { handleComments } from "./cli/comments.js";
10
10
  import { handleRefresh } from "./cli/refresh.js";
11
+ import { handlePrecheck } from "./cli/precheck.js";
11
12
  import {
12
13
  handleTag,
13
14
  handleDiscover,
@@ -48,6 +49,8 @@ async function main() {
48
49
  return handleComments(parsed);
49
50
  case "refresh":
50
51
  return handleRefresh(parsed);
52
+ case "precheck":
53
+ return handlePrecheck(parsed);
51
54
  case "tag":
52
55
  return handleTag(parsed);
53
56
  case "tag-discover":
@@ -46,6 +46,7 @@ async function processExplore(page, username, options, log) {
46
46
  productDetail: null,
47
47
  noVideo: false,
48
48
  restricted: false,
49
+ cdnBlocked: false,
49
50
  error: null,
50
51
  };
51
52
 
@@ -129,15 +130,8 @@ async function processExplore(page, username, options, log) {
129
130
  };
130
131
 
131
132
  // 如果有商品链接,抓取商品详情页(评分、销量等)
132
- if (topVideo.seoUrl && options.shopPage) {
133
+ if (topVideo.seoUrl && options.shopPage && !options.shopPage.isClosed()) {
133
134
  try {
134
- // 检查 shopPage 是否被关闭(浏览器切换账号等),尝试恢复
135
- if (options.shopPage.isClosed()) {
136
- const ctx = page.context();
137
- options.shopPage = await ctx.newPage();
138
- log(` [商品页] 已恢复 shopPage`);
139
- }
140
-
141
135
  const { getProductInfoCDP } = await import("../lib/shop-scraper.mjs");
142
136
  const shopUrl = topVideo.seoUrl.replace(
143
137
  "www.tiktok.com/shop/pdp",
@@ -303,6 +297,13 @@ async function processExplore(page, username, options, log) {
303
297
  log(` @${username} 认定为被封`);
304
298
  }
305
299
 
300
+ // CDN 限流(HTTP 路径: "CDN限流 (Access Denied...)",CDP 路径: "CDN限流: username")
301
+ if (e.message.startsWith("CDN限流")) {
302
+ result.processed = false;
303
+ result.cdnBlocked = true;
304
+ log(` @${username} CDN限流`);
305
+ }
306
+
306
307
  // 临时性错误:超时、执行上下文销毁、网络异常 — 标记为可重试
307
308
  const retryablePatterns = [
308
309
  "Timeout",
@@ -87,6 +87,10 @@ async function collectVideos(page, username, maxVideos, log) {
87
87
  if (e.message.startsWith("被封:")) {
88
88
  throw e; // 向上抛出,由 explore-core 处理
89
89
  }
90
+ // CDN 限流会抛出 "CDN限流..." 异常(HTTP 路径 CDNBlockedError / CDP 路径 api-interceptor)
91
+ if (e.message.startsWith("CDN限流")) {
92
+ throw e; // 向上抛出,由 explore-core 处理
93
+ }
90
94
  // 其他异常也向上抛出
91
95
  throw new Error(`API 拦截失败:@${username} ${e.message}`);
92
96
  }
@@ -2162,9 +2162,10 @@ export function createStore(filePath, options = {}) {
2162
2162
  FROM jobs
2163
2163
  WHERE tt_seller = 1
2164
2164
  AND verified = 0
2165
+ AND (pending_refresh IS NULL OR pending_refresh = 1)
2165
2166
  AND location_created IN (${placeholders})
2166
- AND COALESCE(refresh_time, ?) < ?
2167
- ORDER BY COALESCE(pinned, 0) DESC, COALESCE(refresh_time, ?) ASC
2167
+ AND (pending_refresh = 1 OR COALESCE(refresh_time, ?) < ?)
2168
+ ORDER BY COALESCE(pinned, 0) DESC, pending_refresh DESC, COALESCE(refresh_time, ?) ASC
2168
2169
  LIMIT 1
2169
2170
  `,
2170
2171
  )
@@ -2192,21 +2193,27 @@ export function createStore(filePath, options = {}) {
2192
2193
  (u) =>
2193
2194
  u.ttSeller &&
2194
2195
  u.verified === false &&
2196
+ u.pendingRefresh !== false &&
2195
2197
  targetLocations.includes(u.locationCreated),
2196
2198
  );
2197
2199
  if (targetUsers.length === 0) return null;
2198
2200
 
2199
2201
  const recentEnough = targetUsers.filter((u) => {
2202
+ // pendingRefresh=true(需刷新)的用户跳过时间窗口限制
2203
+ if (u.pendingRefresh === true) return true;
2200
2204
  const rt = u.refreshTime || defaultTime;
2201
2205
  return rt < threshold;
2202
2206
  });
2203
2207
  if (recentEnough.length === 0) return null;
2204
2208
 
2205
2209
  recentEnough.sort((a, b) => {
2206
- // pinned 优先,其次按 refreshTime 升序
2210
+ // pinned 优先,其次 pendingRefresh=true 优先,再按 refreshTime 升序
2207
2211
  if ((a.pinned ? 1 : 0) !== (b.pinned ? 1 : 0)) {
2208
2212
  return (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0);
2209
2213
  }
2214
+ if ((a.pendingRefresh ? 1 : 0) !== (b.pendingRefresh ? 1 : 0)) {
2215
+ return (b.pendingRefresh ? 1 : 0) - (a.pendingRefresh ? 1 : 0);
2216
+ }
2210
2217
  const ta = a.refreshTime || defaultTime;
2211
2218
  const tb = b.refreshTime || defaultTime;
2212
2219
  return ta - tb;
@@ -2227,6 +2234,7 @@ export function createStore(filePath, options = {}) {
2227
2234
  const user = getJob(uniqueId);
2228
2235
  if (!user) return { saved: false, error: "user not found" };
2229
2236
  user.refreshTime = Date.now();
2237
+ user.pendingRefresh = false;
2230
2238
  if (result.userInfo) {
2231
2239
  const info = result.userInfo;
2232
2240
  for (const key of Object.keys(info)) {
@@ -2275,6 +2283,7 @@ export function createStore(filePath, options = {}) {
2275
2283
  if (!user) return { saved: false, error: "user not found" };
2276
2284
 
2277
2285
  user.refreshTime = Date.now();
2286
+ user.pendingRefresh = false;
2278
2287
 
2279
2288
  if (result.userInfo) {
2280
2289
  const info = result.userInfo;
@@ -2314,6 +2323,83 @@ export function createStore(filePath, options = {}) {
2314
2323
  return { saved: true, newUsers };
2315
2324
  }
2316
2325
 
2326
+ function getPrecheckCandidates({
2327
+ limit = 50,
2328
+ intervalSeconds = 86400,
2329
+ locations = null,
2330
+ }) {
2331
+ if (!getDb()) return [];
2332
+ const now = Date.now();
2333
+ const threshold = now - intervalSeconds * 1000;
2334
+ const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
2335
+ const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
2336
+ const placeholders = targetLocations.map(() => "?").join(",");
2337
+
2338
+ // 事务:SELECT + 立即 UPDATE refresh_time,防止多客户端重复领取
2339
+ const rows = getDb().transaction(() => {
2340
+ const selected = getDb()
2341
+ .prepare(
2342
+ `
2343
+ SELECT unique_id, nickname, video_count, COALESCE(refresh_time, ?) AS refresh_time
2344
+ FROM jobs
2345
+ WHERE tt_seller = 1
2346
+ AND verified = 0
2347
+ AND (pending_refresh IS NULL OR pending_refresh = 0)
2348
+ AND location_created IN (${placeholders})
2349
+ AND COALESCE(refresh_time, ?) < ?
2350
+ ORDER BY COALESCE(refresh_time, ?) ASC
2351
+ LIMIT ?
2352
+ `,
2353
+ )
2354
+ .all(
2355
+ defaultTime,
2356
+ ...targetLocations,
2357
+ defaultTime,
2358
+ threshold,
2359
+ defaultTime,
2360
+ limit,
2361
+ );
2362
+
2363
+ if (selected.length > 0) {
2364
+ const ids = selected.map((r) => r.unique_id);
2365
+ const idPlaceholders = ids.map(() => "?").join(",");
2366
+ getDb()
2367
+ .prepare(
2368
+ `UPDATE jobs SET refresh_time = ?, updated_at = ? WHERE unique_id IN (${idPlaceholders})`,
2369
+ )
2370
+ .run(now, now, ...ids);
2371
+ }
2372
+
2373
+ return selected;
2374
+ })();
2375
+
2376
+ return rows.map((r) => ({
2377
+ uniqueId: r.unique_id,
2378
+ nickname: r.nickname,
2379
+ videoCount: r.video_count || 0,
2380
+ refreshTime: r.refresh_time,
2381
+ }));
2382
+ }
2383
+
2384
+ function markPrecheckResult(uniqueId, { needRefresh, videoCount }) {
2385
+ if (!getDb()) return { saved: false, error: "no db" };
2386
+ const now = Date.now();
2387
+ if (needRefresh) {
2388
+ getDb()
2389
+ .prepare(
2390
+ "UPDATE jobs SET pending_refresh = 1, video_count = ?, refresh_time = ?, updated_at = ? WHERE unique_id = ?",
2391
+ )
2392
+ .run(videoCount ?? null, now, now, uniqueId);
2393
+ } else {
2394
+ getDb()
2395
+ .prepare(
2396
+ "UPDATE jobs SET pending_refresh = 0, video_count = ?, refresh_time = ?, updated_at = ? WHERE unique_id = ?",
2397
+ )
2398
+ .run(videoCount ?? null, now, now, uniqueId);
2399
+ }
2400
+ return { saved: true };
2401
+ }
2402
+
2317
2403
  function reportClientError(
2318
2404
  userId,
2319
2405
  errorType,
@@ -3297,6 +3383,8 @@ export function createStore(filePath, options = {}) {
3297
3383
  togglePin,
3298
3384
  getNextRedoJob,
3299
3385
  commitRedoJob,
3386
+ getPrecheckCandidates,
3387
+ markPrecheckResult,
3300
3388
  getPendingUserUpdateTasks,
3301
3389
  getRecheckJobs,
3302
3390
  batchUpdateRecheckJobs,
@@ -33,6 +33,7 @@ export const JOB_CORE_COLUMNS = [
33
33
  "refresh_time",
34
34
  "processed",
35
35
  "processed_at",
36
+ "pending_refresh",
36
37
  "created_at",
37
38
  "updated_at",
38
39
  "region",
@@ -100,6 +101,7 @@ export const WRITABLE_JOB_COLUMNS = new Set([
100
101
  "product_name",
101
102
  "user_create_time",
102
103
  "recent_ec_videos",
104
+ "pending_refresh",
103
105
  ]);
104
106
 
105
107
  // ===== Boolean 列(存储时 0/1,读取时转为 true/false/null) =====
@@ -110,6 +112,7 @@ export const JOB_BOOLEAN_COLUMNS = new Set([
110
112
  "processed",
111
113
  "tt_seller",
112
114
  "verified",
115
+ "pending_refresh",
113
116
  "error",
114
117
  ]);
115
118
 
@@ -44,7 +44,7 @@ export function moveJobsToRawByCountry(scope, country) {
44
44
  let scopeWhere = "";
45
45
 
46
46
  if (normalizedScope === "pending") {
47
- sourceTable = "jobs";
47
+ sourceTable = "task_queue";
48
48
  scopeWhere = `status = 'pending'`;
49
49
  } else if (normalizedScope === "userUpdate") {
50
50
  sourceTable = "jobs_base";
@@ -111,6 +111,7 @@ export function initDb(filePath) {
111
111
  refresh_time INTEGER,
112
112
  processed INTEGER DEFAULT 0,
113
113
  processed_at INTEGER,
114
+ pending_refresh INTEGER DEFAULT 1,
114
115
  created_at INTEGER,
115
116
  updated_at INTEGER,
116
117
  region TEXT,
@@ -120,6 +121,7 @@ export function initDb(filePath) {
120
121
  )
121
122
  `);
122
123
  migrateColumns("jobs", {
124
+ pending_refresh: "INTEGER",
123
125
  status_code: "INTEGER",
124
126
  latest_video_time: "INTEGER",
125
127
  confirmed_location: "TEXT",
@@ -155,6 +157,7 @@ export function initDb(filePath) {
155
157
  }
156
158
  // 后续迁移也要给 task_queue 补列
157
159
  migrateColumns("task_queue", {
160
+ pending_refresh: "INTEGER",
158
161
  status_code: "INTEGER",
159
162
  latest_video_time: "INTEGER",
160
163
  confirmed_location: "TEXT",
@@ -202,6 +205,7 @@ export function initDb(filePath) {
202
205
  refresh_time INTEGER,
203
206
  processed INTEGER DEFAULT 0,
204
207
  processed_at INTEGER,
208
+ pending_refresh INTEGER DEFAULT 1,
205
209
  created_at INTEGER,
206
210
  updated_at INTEGER,
207
211
  region TEXT,
@@ -213,6 +217,7 @@ export function initDb(filePath) {
213
217
  )
214
218
  `);
215
219
  migrateColumns("jobs_base", {
220
+ pending_refresh: "INTEGER",
216
221
  status_code: "INTEGER",
217
222
  latest_video_time: "INTEGER",
218
223
  confirmed_location: "TEXT",
@@ -286,6 +286,7 @@ function renderActiveClients(clients) {
286
286
  const types = [
287
287
  "explore",
288
288
  "refresh",
289
+ "precheck",
289
290
  "attach",
290
291
  "recheck",
291
292
  "comments",
@@ -296,6 +297,7 @@ function renderActiveClients(clients) {
296
297
  const labels = {
297
298
  explore: "Explore",
298
299
  refresh: "Refresh",
300
+ precheck: "Precheck",
299
301
  attach: "Attach",
300
302
  recheck: "Recheck",
301
303
  comments: "Comments",
@@ -151,6 +151,7 @@ function sendCSV(res, columns, rows) {
151
151
  }
152
152
 
153
153
  function inferClientType(routePath) {
154
+ if (routePath.startsWith("/api/precheck")) return "precheck";
154
155
  if (routePath.startsWith("/api/redo-job")) return "refresh";
155
156
  if (routePath.startsWith("/api/user-update-tasks")) return "attach";
156
157
  if (routePath.startsWith("/api/recheck-jobs")) return "recheck";
@@ -843,6 +844,52 @@ export function startWatchServer(
843
844
  return;
844
845
  }
845
846
 
847
+ if (req.method === "GET" && routePath === "/api/precheck-candidates") {
848
+ const limit = parseInt(params.limit) || 50;
849
+ const maxAge = parseInt(params.maxAge) || 86400;
850
+ const locationsParam = params.locations || "";
851
+ const locations = locationsParam
852
+ ? locationsParam
853
+ .split(",")
854
+ .map((s) => s.trim().toUpperCase())
855
+ .filter(Boolean)
856
+ : null;
857
+ const candidates = store.getPrecheckCandidates({
858
+ limit,
859
+ intervalSeconds: maxAge,
860
+ locations,
861
+ });
862
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
863
+ console.error(
864
+ `[JOB ${ts}] PRECHECK-CANDIDATES: ${candidates.length} candidates (maxAge=${maxAge}s, limit=${limit})`,
865
+ );
866
+ sendJSON(res, 200, { candidates });
867
+ return;
868
+ }
869
+
870
+ if (req.method === "POST" && routePath === "/api/precheck-results") {
871
+ try {
872
+ const body = await readBody(req);
873
+ const results = body.results || [];
874
+ let saved = 0;
875
+ for (const r of results) {
876
+ const ret = store.markPrecheckResult(r.uniqueId, {
877
+ needRefresh: r.needRefresh,
878
+ videoCount: r.videoCount,
879
+ });
880
+ if (ret.saved) saved++;
881
+ }
882
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
883
+ console.error(
884
+ `[JOB ${ts}] PRECHECK-RESULTS: ${saved}/${results.length} saved`,
885
+ );
886
+ sendJSON(res, 200, { saved, total: results.length });
887
+ } catch (e) {
888
+ sendJSON(res, 400, { error: e.message });
889
+ }
890
+ return;
891
+ }
892
+
846
893
  if (req.method === "GET" && routePath === "/api/user-update-tasks") {
847
894
  const limit = params.limit;
848
895
  const countries = params.countries