tt-help-cli-ycl 1.4.52 → 1.4.54

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.52",
3
+ "version": "1.4.54",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/attach.js CHANGED
@@ -57,11 +57,13 @@ export async function handleAttach(options) {
57
57
  attachCountries,
58
58
  customProxy,
59
59
  attachPoolSize,
60
+ attachWebserver,
60
61
  showHelp,
61
62
  } = options;
63
+ const webserverUrl = attachWebserver || null;
62
64
  const effectiveProxy = customProxy || configuredProxy;
63
65
 
64
- if (effectiveProxy) {
66
+ if (effectiveProxy && !webserverUrl) {
65
67
  attachLog(`[Attach] 使用代理: ${effectiveProxy}`);
66
68
  }
67
69
  let shuttingDown = false;
@@ -87,12 +89,18 @@ export async function handleAttach(options) {
87
89
  attachLog(
88
90
  " --size <N> 浏览器页面池大小(默认: 3,不建议超过 3)",
89
91
  );
92
+ attachLog(
93
+ " --webserver <URL> webserver 模式:通过 HTTP 调用集中式 webserver,不启动本地浏览器",
94
+ );
90
95
  attachLog("");
91
96
  attachLog("说明:");
92
97
  attachLog(
93
98
  " 后台轮询服务端 /api/user-update-tasks 接口,自动抓取 TikTok 用户信息",
94
99
  );
95
100
  attachLog(" 抓取完成后通过 POST /api/user-info-batch 批量回传结果");
101
+ attachLog(
102
+ " 默认启动本地浏览器(CDP 模式),传 --webserver 则走 HTTP 接口,无需浏览器",
103
+ );
96
104
  attachLog(" 浏览器崩溃时自动重启,支持长时间无人值守运行");
97
105
  attachLog(
98
106
  " --size 控制单个浏览器内并发的 page 数,增大可提升吞吐但可能触发 TikTok 限流",
@@ -126,10 +134,32 @@ export async function handleAttach(options) {
126
134
 
127
135
  const { apiGet, apiPost } = createApiClient({ log: false });
128
136
 
129
- const scraper = new TikTokScraper({
130
- poolSize: attachPoolSize || 3,
131
- proxyServer: effectiveProxy || null,
132
- });
137
+ // ── webserver 模式:不启动本地浏览器,通过 HTTP 调用集中式 webserver ──
138
+ let scraper = null;
139
+ let getUserInfo;
140
+
141
+ if (webserverUrl) {
142
+ attachLog(`[Attach] webserver 模式: ${webserverUrl}`);
143
+ getUserInfo = async (uniqueId) => {
144
+ const res = await fetch(`${webserverUrl}/api/tiktok/user`, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ uniqueId }),
148
+ signal: AbortSignal.timeout(30000),
149
+ });
150
+ const data = await res.json();
151
+ if (!res.ok || data.error) return null;
152
+ return data;
153
+ };
154
+ } else {
155
+ scraper = new TikTokScraper({
156
+ poolSize: attachPoolSize || 3,
157
+ proxyServer: effectiveProxy || null,
158
+ });
159
+ await scraper.init();
160
+ attachLog("[Attach] 浏览器初始化完成,开始循环接收任务...");
161
+ getUserInfo = (uniqueId) => scraper.getUserInfo(uniqueId);
162
+ }
133
163
  const shutdown = async (signal) => {
134
164
  if (shuttingDown) return;
135
165
  shuttingDown = true;
@@ -152,9 +182,6 @@ export async function handleAttach(options) {
152
182
  process.on("SIGTERM", onSigterm);
153
183
 
154
184
  try {
155
- await scraper.init();
156
- attachLog("[Attach] 浏览器初始化完成,开始循环接收任务...");
157
-
158
185
  let loopCount = 0;
159
186
  let browserRestartCount = 0;
160
187
  let taskBatchCount = 0;
@@ -162,8 +189,8 @@ export async function handleAttach(options) {
162
189
  while (!shuttingDown) {
163
190
  loopCount++;
164
191
 
165
- // 检查浏览器是否存活,不存活则重启
166
- if (!scraper.isAlive()) {
192
+ // 检查浏览器是否存活,不存活则重启(仅本地浏览器模式)
193
+ if (scraper && !scraper.isAlive()) {
167
194
  attachLog(
168
195
  `[Attach] 浏览器已关闭,正在重启 (${++browserRestartCount})...`,
169
196
  );
@@ -174,7 +201,7 @@ export async function handleAttach(options) {
174
201
  // 收到退出信号且当前无任务正在处理,则退出
175
202
  if (shuttingDown) {
176
203
  attachLog("[Attach] 当前任务已完成,正在关闭浏览器...");
177
- await scraper.close().catch(() => {});
204
+ if (scraper) await scraper.close().catch(() => {});
178
205
  if (forceExitTimer) {
179
206
  clearTimeout(forceExitTimer);
180
207
  forceExitTimer = null;
@@ -192,12 +219,14 @@ export async function handleAttach(options) {
192
219
  );
193
220
 
194
221
  if (!tasks || tasks.length === 0) {
195
- const heap = getHeapUsage();
196
- if (heap.ratio >= HEAP_RESTART_RATIO) {
197
- await recycleScraper(
198
- scraper,
199
- `空闲时堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
200
- );
222
+ if (scraper) {
223
+ const heap = getHeapUsage();
224
+ if (heap.ratio >= HEAP_RESTART_RATIO) {
225
+ await recycleScraper(
226
+ scraper,
227
+ `空闲时堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
228
+ );
229
+ }
201
230
  }
202
231
  if (loopCount === 1) {
203
232
  attachLog(`[Attach] 当前无待更新任务,${attachInterval}秒后重试...`);
@@ -223,7 +252,7 @@ export async function handleAttach(options) {
223
252
  : "";
224
253
  attachLog(` → 获取 @${uniqueId} 的用户信息...${countryTag}`);
225
254
  try {
226
- const info = await scraper.getUserInfo(uniqueId);
255
+ const info = await getUserInfo(uniqueId);
227
256
  return { uniqueId, info, error: null };
228
257
  } catch (err) {
229
258
  return { uniqueId, info: null, error: err };
@@ -319,38 +348,40 @@ export async function handleAttach(options) {
319
348
 
320
349
  attachLog(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
321
350
 
322
- // CDN 限流比例超过 30% 时,冷却 + 重启浏览器
323
- const cdnRatio = cdnBlockedCount / tasks.length;
324
- if (cdnRatio > 0.3) {
325
- const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
326
- attachLog(
327
- ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
328
- );
329
- await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
330
- await recycleScraper(
331
- scraper,
332
- `CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
333
- );
334
- browserRestartCount++;
335
- taskBatchCount = 0;
336
- }
351
+ // CDN 限流比例超过 30% 时,冷却 + 重启浏览器(仅本地浏览器模式)
352
+ if (scraper) {
353
+ const cdnRatio = cdnBlockedCount / tasks.length;
354
+ if (cdnRatio > 0.3) {
355
+ const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
356
+ attachLog(
357
+ ` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
358
+ );
359
+ await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
360
+ await recycleScraper(
361
+ scraper,
362
+ `CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
363
+ );
364
+ browserRestartCount++;
365
+ taskBatchCount = 0;
366
+ }
337
367
 
338
- const heap = getHeapUsage();
339
- if (heap.ratio >= HEAP_RESTART_RATIO) {
340
- await recycleScraper(
341
- scraper,
342
- `本批后堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
343
- );
344
- browserRestartCount++;
345
- taskBatchCount = 0;
346
- } else if (taskBatchCount >= MAX_TASK_BATCHES_BEFORE_RESTART) {
347
- await recycleScraper(scraper, `已连续处理 ${taskBatchCount} 批任务`);
348
- browserRestartCount++;
349
- taskBatchCount = 0;
350
- }
368
+ const heap = getHeapUsage();
369
+ if (heap.ratio >= HEAP_RESTART_RATIO) {
370
+ await recycleScraper(
371
+ scraper,
372
+ `本批后堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
373
+ );
374
+ browserRestartCount++;
375
+ taskBatchCount = 0;
376
+ } else if (taskBatchCount >= MAX_TASK_BATCHES_BEFORE_RESTART) {
377
+ await recycleScraper(scraper, `已连续处理 ${taskBatchCount} 批任务`);
378
+ browserRestartCount++;
379
+ taskBatchCount = 0;
380
+ }
351
381
 
352
- if (needRestart) {
353
- attachLog("[Attach] 检测到浏览器异常,将在下一轮重启...");
382
+ if (needRestart) {
383
+ attachLog("[Attach] 检测到浏览器异常,将在下一轮重启...");
384
+ }
354
385
  }
355
386
 
356
387
  await new Promise((r) => setTimeout(r, 500));
@@ -367,7 +398,7 @@ export async function handleAttach(options) {
367
398
  process.removeListener("SIGINT", onSigint);
368
399
  process.removeListener("SIGTERM", onSigterm);
369
400
  // 只有在非信号退出时才关闭浏览器(信号退出已在循环内关闭)
370
- if (!shuttingDown) {
401
+ if (!shuttingDown && scraper) {
371
402
  await scraper.close().catch(() => {});
372
403
  }
373
404
  }
package/src/lib/args.js CHANGED
@@ -627,6 +627,7 @@ function parseAttachArgs(args) {
627
627
  let countries = [];
628
628
  let customProxy = null;
629
629
  let poolSize = 3;
630
+ let attachWebserver = null;
630
631
 
631
632
  for (let i = 0; i < args.length; i++) {
632
633
  const arg = args[i];
@@ -645,6 +646,8 @@ function parseAttachArgs(args) {
645
646
  customProxy = args[++i];
646
647
  } else if (arg === "--size") {
647
648
  poolSize = parseInt(args[++i], 10) || 3;
649
+ } else if (arg === "--webserver") {
650
+ attachWebserver = args[++i];
648
651
  }
649
652
  }
650
653
 
@@ -656,6 +659,7 @@ function parseAttachArgs(args) {
656
659
  attachCountries: countries,
657
660
  customProxy,
658
661
  attachPoolSize: poolSize,
662
+ attachWebserver,
659
663
  urls: [],
660
664
  outputFormat: "json",
661
665
  exploreCount: 0,
@@ -188,12 +188,13 @@ const HELP_TEXT = [
188
188
  " 示例: tt-help info https://www.tiktok.com/@nike",
189
189
  " tt-help info https://www.tiktok.com/@nike/video/7234567890 --onlyvideo",
190
190
  "",
191
- " tt-help attach [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表]",
191
+ " tt-help attach [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表] [--webserver URL]",
192
192
  " 后台轮询服务端任务接口,自动抓取 TikTok 用户信息",
193
193
  " -p, --parallel <N> 并行抓取数(默认: 1)",
194
194
  " -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
195
195
  " -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
196
196
  " -c, --countries <A,B> 猜测国家列表(逗号分隔,如 PL,DE,FR)",
197
+ " --webserver <URL> webserver 模式(不启动本地浏览器,走 HTTP 接口)",
197
198
  " 示例: tt-help attach -p 5 -i 10",
198
199
  " tt-help attach -c PL,DE,FR -p 5",
199
200
  "",
@@ -222,14 +222,15 @@ async function handleTiktokProduct(req, res) {
222
222
  );
223
223
  return sendJSON(res, 200, result);
224
224
  } catch (e) {
225
- // 先返回错误,不阻塞请求
226
225
  console.error(`[webserver] product FAIL | ${clientIP} | ${e.message}`);
227
- sendJSON(res, 502, { error: e.message });
228
226
 
229
- // 如果是 captcha,异步弹浏览器(不阻塞其他请求)
227
+ // 如果是 captcha,先触发上报+恢复(不阻塞其他请求)
228
+ // 必须在 sendJSON 之前调用,否则 sendJSON 抛异常会导致验证码通知丢失
230
229
  if (e.message?.includes("Blocked by TikTok")) {
231
230
  scheduleCaptchaRecovery(normalizedUrl, SCRAPER_CONFIG.proxyServer);
232
231
  }
232
+
233
+ sendJSON(res, 502, { error: e.message });
233
234
  return;
234
235
  }
235
236
  }
@@ -252,7 +253,7 @@ async function scheduleCaptchaRecovery(url, proxy) {
252
253
  headers: { "Content-Type": "application/json" },
253
254
  body: JSON.stringify({
254
255
  userId: `webserver-${process.env.HOSTNAME || os.hostname() || "unknown"}`,
255
- errorType: "captcha_blocked",
256
+ errorType: "captcha",
256
257
  errorMessage:
257
258
  "TikTok Shop captcha detected, browser opened - manual intervention required",
258
259
  stage: "product_info",