tt-help-cli-ycl 1.4.42 → 1.4.44

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.42",
3
+ "version": "1.4.44",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -170,9 +170,6 @@ export async function handleExplore(options) {
170
170
 
171
171
  const page = await getOrCreatePage(browser);
172
172
 
173
- // 创建专用商品页(后台复用,避免重复打开/关闭闪屏)
174
- const shopPage = await browser.contexts()[0].newPage();
175
-
176
173
  // 先导航到 TikTok 页面,再检测登录状态
177
174
  await withRetry("启动页面导航", async () => {
178
175
  await page.goto(STARTUP_TIKTOK_URL, {
@@ -222,9 +219,6 @@ export async function handleExplore(options) {
222
219
  browser = newBrowser;
223
220
  const newPage = await setupNewPage(browser);
224
221
  Object.assign(page, newPage);
225
- // 重建商品页(旧 browser 已关闭)
226
- await shopPage.close().catch(() => {});
227
- Object.assign(shopPage, await newBrowser.contexts()[0].newPage());
228
222
  Object.assign(cdpOptions, {
229
223
  port: nextAccount.port,
230
224
  userDataDir: nextAccount.userDataDir,
@@ -394,14 +388,13 @@ export async function handleExplore(options) {
394
388
  {
395
389
  maxVideos: exploreMaxVideos,
396
390
  enableFollow: exploreEnableFollow,
391
+ enableVideos: false,
397
392
  loggedIn, // 传入登录状态,避免每次调用 isLoggedIn(page)
398
393
  maxFollowing: exploreMaxFollowing,
399
394
  maxFollowers: exploreMaxFollowers,
400
395
  location: exploreLocation,
401
396
  proxyServer: cdpOptions.proxyServer || null,
402
397
  webserverUrl: exploreWebserver || null,
403
- browser,
404
- shopPage,
405
398
  },
406
399
  console.error,
407
400
  );
@@ -415,9 +408,6 @@ export async function handleExplore(options) {
415
408
  browser = newBrowser;
416
409
  const newPage = await setupNewPage(browser);
417
410
  Object.assign(page, newPage);
418
- // 重建商品页
419
- await shopPage.close().catch(() => {});
420
- Object.assign(shopPage, await newBrowser.contexts()[0].newPage());
421
411
  // 重试当前用户
422
412
  result = await processExplore(
423
413
  page,
@@ -426,13 +416,13 @@ export async function handleExplore(options) {
426
416
  maxVideos: exploreMaxVideos,
427
417
  enableFollow: exploreEnableFollow,
428
418
  loggedIn, // 传入登录状态
419
+ enableVideos: false,
429
420
  maxFollowing: exploreMaxFollowing,
430
421
  maxFollowers: exploreMaxFollowers,
431
422
  location: exploreLocation,
432
423
  proxyServer: cdpOptions.proxyServer || null,
433
424
  webserverUrl: exploreWebserver || null,
434
425
  browser,
435
- shopPage,
436
426
  },
437
427
  console.error,
438
428
  );
@@ -475,13 +465,13 @@ export async function handleExplore(options) {
475
465
  maxVideos: exploreMaxVideos,
476
466
  enableFollow: exploreEnableFollow,
477
467
  loggedIn,
468
+ enableVideos: false,
478
469
  maxFollowing: exploreMaxFollowing,
479
470
  maxFollowers: exploreMaxFollowers,
480
471
  location: exploreLocation,
481
472
  proxyServer: cdpOptions.proxyServer || null,
482
473
  webserverUrl: exploreWebserver || null,
483
474
  browser,
484
- shopPage,
485
475
  },
486
476
  console.error,
487
477
  );
@@ -172,9 +172,6 @@ export async function handleRefresh(options) {
172
172
 
173
173
  const page = await getOrCreatePage(browser);
174
174
 
175
- // 创建专用商品页(后台复用,避免重复打开/关闭闪屏)
176
- const shopPage = await browser.contexts()[0].newPage();
177
-
178
175
  // 导航到 TikTok 页面
179
176
  await withRetry("启动页面导航", async () => {
180
177
  await page.goto(STARTUP_TIKTOK_URL, {
@@ -223,9 +220,6 @@ export async function handleRefresh(options) {
223
220
  browser = newBrowser;
224
221
  const newPage = await setupNewPage(browser);
225
222
  Object.assign(page, newPage);
226
- // 重建商品页(旧 browser 已关闭)
227
- await shopPage.close().catch(() => {});
228
- Object.assign(shopPage, await newBrowser.contexts()[0].newPage());
229
223
  Object.assign(cdpOptions, {
230
224
  port: nextAccount.port,
231
225
  userDataDir: nextAccount.userDataDir,
@@ -404,13 +398,13 @@ export async function handleRefresh(options) {
404
398
  {
405
399
  maxVideos: exploreMaxVideos || 16,
406
400
  enableFollow: exploreEnableFollow !== false,
401
+ enableVideos: false,
407
402
  loggedIn,
408
403
  maxFollowing: exploreMaxFollowing || 100,
409
404
  maxFollowers: exploreMaxFollowers || 100,
410
405
  location: exploreLocation,
411
406
  locationMode: "refresh",
412
407
  browser,
413
- shopPage,
414
408
  proxyServer: cdpOptions.proxyServer || null,
415
409
  webserverUrl: exploreWebserver || null,
416
410
  },
@@ -426,22 +420,19 @@ export async function handleRefresh(options) {
426
420
  browser = newBrowser;
427
421
  const newPage = await setupNewPage(browser);
428
422
  Object.assign(page, newPage);
429
- // 重建商品页
430
- await shopPage.close().catch(() => {});
431
- Object.assign(shopPage, await newBrowser.contexts()[0].newPage());
432
423
  result = await processExplore(
433
424
  page,
434
425
  username,
435
426
  {
436
427
  maxVideos: exploreMaxVideos || 16,
437
428
  enableFollow: exploreEnableFollow !== false,
429
+ enableVideos: false,
438
430
  loggedIn,
439
431
  maxFollowing: exploreMaxFollowing || 100,
440
432
  maxFollowers: exploreMaxFollowers || 100,
441
433
  location: exploreLocation,
442
434
  locationMode: "refresh",
443
435
  browser,
444
- shopPage,
445
436
  proxyServer: cdpOptions.proxyServer || null,
446
437
  webserverUrl: exploreWebserver || null,
447
438
  },
@@ -485,13 +476,13 @@ export async function handleRefresh(options) {
485
476
  {
486
477
  maxVideos: exploreMaxVideos || 16,
487
478
  enableFollow: exploreEnableFollow !== false,
479
+ enableVideos: false,
488
480
  loggedIn,
489
481
  maxFollowing: exploreMaxFollowing || 100,
490
482
  maxFollowers: exploreMaxFollowers || 100,
491
483
  location: exploreLocation,
492
484
  locationMode: "refresh",
493
485
  browser,
494
- shopPage,
495
486
  proxyServer: cdpOptions.proxyServer || null,
496
487
  webserverUrl: exploreWebserver || null,
497
488
  },
@@ -5,12 +5,14 @@ import {
5
5
  } from "../webserver/server.mjs";
6
6
 
7
7
  export async function handleWebserver(options) {
8
- const { webserverPort, webserverPoolSize, webserverProxy } = options;
8
+ const { webserverPort, webserverPoolSize, webserverProxy, webserverNotify } =
9
+ options;
9
10
 
10
11
  const { server, port } = await startWebserver(
11
12
  webserverPort,
12
13
  webserverPoolSize,
13
14
  webserverProxy,
15
+ webserverNotify,
14
16
  );
15
17
 
16
18
  let shuttingDown = false;
package/src/lib/args.js CHANGED
@@ -181,6 +181,7 @@ function parseExploreArgs(args) {
181
181
  let exploreUserId = null;
182
182
  let exploreMaxVideos = 16;
183
183
  let exploreProxy = null;
184
+ let exploreWebserver = null;
184
185
 
185
186
  const positional = [];
186
187
  const PRESETS = ["fast", "normal", "slow", "stealth"];
@@ -221,6 +222,8 @@ function parseExploreArgs(args) {
221
222
  exploreMaxVideos = parseInt(args[++i]) || 16;
222
223
  } else if (arg === "--proxy") {
223
224
  exploreProxy = args[++i];
225
+ } else if (arg === "--webserver") {
226
+ exploreWebserver = args[++i];
224
227
  } else {
225
228
  positional.push(arg);
226
229
  }
@@ -262,6 +265,7 @@ function parseExploreArgs(args) {
262
265
  exploreUserId,
263
266
  exploreMaxVideos,
264
267
  exploreProxy,
268
+ exploreWebserver,
265
269
  urls: [],
266
270
  outputFormat: "json",
267
271
  exploreCount: 0,
@@ -403,6 +407,7 @@ function parseWebserverArgs(args) {
403
407
  let webserverPort = 3000;
404
408
  let webserverPoolSize = 0;
405
409
  let webserverProxy = null;
410
+ let webserverNotify = null;
406
411
 
407
412
  for (let i = 0; i < args.length; i++) {
408
413
  const arg = args[i];
@@ -412,6 +417,8 @@ function parseWebserverArgs(args) {
412
417
  webserverPoolSize = parseInt(args[++i]) || 0;
413
418
  } else if (arg === "--proxy") {
414
419
  webserverProxy = args[++i] || null;
420
+ } else if (arg === "--notify") {
421
+ webserverNotify = args[++i] || null;
415
422
  }
416
423
  }
417
424
 
@@ -420,6 +427,7 @@ function parseWebserverArgs(args) {
420
427
  webserverPort,
421
428
  webserverPoolSize,
422
429
  webserverProxy,
430
+ webserverNotify,
423
431
  urls: [],
424
432
  outputFile: null,
425
433
  outputFormat: "json",
@@ -26,6 +26,16 @@ const PDP_HEADERS = {
26
26
  "Accept-Language": "en-US,en;q=0.9",
27
27
  };
28
28
 
29
+ // 浏览器解验证码后缓存的 Cookie,给 undici 复用
30
+ let cachedCookies = null;
31
+ let cookiesExpireAt = 0;
32
+ const COOKIE_TTL_MS = 30 * 60 * 1000; // Cookie 有效期 30 分钟
33
+
34
+ function getCookieHeader() {
35
+ if (!cachedCookies || Date.now() > cookiesExpireAt) return null;
36
+ return cachedCookies.map((c) => `${c.name}=${c.value}`).join("; ");
37
+ }
38
+
29
39
  /**
30
40
  * Extract a JSON object by its key name, handling nested braces and strings.
31
41
  */
@@ -71,15 +81,11 @@ function extractJsonByKey(html, keyPattern) {
71
81
  }
72
82
 
73
83
  /**
74
- * Open a headed browser for the user to manually solve TikTok captcha.
75
- * Returns true once the user closes the browser (captcha assumed solved).
76
- */
77
- /**
78
- * Open a headed browser for manual captcha solving. Polls the page until
79
- * product content appears, then auto-closes the browser.
84
+ * Open a headed browser for manual captcha solving, extract cookies
85
+ * so undici can reuse them for a while.
80
86
  * @param {string} url - TikTok Shop PDP URL to open in browser
81
87
  * @param {string} [proxyUrl]
82
- * @returns {Promise<boolean>} true if captcha was solved, false if timed out
88
+ * @returns {Promise<boolean>} true if captcha was solved and cookies cached
83
89
  */
84
90
  export async function resolveCaptchaWithBrowser(url, proxyUrl) {
85
91
  const { chromium } = await import("playwright");
@@ -110,10 +116,10 @@ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
110
116
  "[shop-scraper] Browser opened, waiting for captcha to be solved...",
111
117
  );
112
118
 
113
- // Poll until captcha is solved (page loads real content) or user closes browser
119
+ // Poll until captcha is solved (page loads real content) or browser closed
114
120
  let solved = false;
115
- const POLL_MS = 1000;
116
- const MAX_WAIT_MS = 120000;
121
+ const POLL_MS = 2000;
122
+ const MAX_WAIT_MS = 3600_000; // 最长等 1 小时
117
123
  const start = Date.now();
118
124
 
119
125
  while (!solved && Date.now() - start < MAX_WAIT_MS) {
@@ -126,19 +132,29 @@ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
126
132
  solved = true;
127
133
  break;
128
134
  }
129
- // Check if browser was closed by user
130
135
  if (!browser.isConnected()) break;
131
136
  } catch (e) {
132
- // Page might be closed, check browser
133
137
  if (!browser.isConnected()) break;
134
138
  }
135
139
  await new Promise((r) => setTimeout(r, POLL_MS));
136
140
  }
137
141
 
138
142
  if (solved) {
139
- console.error("[shop-scraper] Captcha solved, closing browser...");
143
+ // 提取 Cookie 给 undici 复用
144
+ try {
145
+ const cookies = await page.context().cookies();
146
+ if (cookies.length > 0) {
147
+ cachedCookies = cookies;
148
+ cookiesExpireAt = Date.now() + COOKIE_TTL_MS;
149
+ console.error(
150
+ `[shop-scraper] Captcha solved, cached ${cookies.length} cookies for undici reuse`,
151
+ );
152
+ }
153
+ } catch (e) {
154
+ console.error("[shop-scraper] Failed to extract cookies:", e.message);
155
+ }
140
156
  } else {
141
- console.error("[shop-scraper] Timed out or browser closed, proceeding...");
157
+ console.error("[shop-scraper] Timed out or browser closed");
142
158
  }
143
159
 
144
160
  await browser.close().catch(() => {});
@@ -160,8 +176,14 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
160
176
  let lastError;
161
177
  for (let attempt = 1; attempt <= 3; attempt++) {
162
178
  try {
179
+ const headers = { ...PDP_HEADERS };
180
+ const cookieStr = getCookieHeader();
181
+ if (cookieStr) {
182
+ headers["Cookie"] = cookieStr;
183
+ }
184
+
163
185
  const opts = {
164
- headers: PDP_HEADERS,
186
+ headers,
165
187
  ...(agent ? { dispatcher: agent } : {}),
166
188
  };
167
189
 
@@ -173,15 +195,18 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
173
195
  } catch (err) {
174
196
  lastError = err;
175
197
 
176
- // Captcha: try browser fallback if enabled
198
+ // Captcha:清除过期 Cookie,弹浏览器重新获取
177
199
  if (
178
200
  err.message?.includes("Blocked by TikTok") &&
179
201
  options.captchaFallback
180
202
  ) {
181
- console.error("[shop-scraper] Captcha hit, opening browser...");
203
+ console.error("[shop-scraper] Captcha hit, clearing cached cookies...");
204
+ cachedCookies = null;
205
+ cookiesExpireAt = 0;
206
+ console.error("[shop-scraper] Opening browser for manual captcha...");
182
207
  await resolveCaptchaWithBrowser(url, proxy);
183
- // Retry HTTP after captcha solved
184
- attempt = 0; // reset counter for fresh retries
208
+ // 重试(带着新 Cookie)
209
+ attempt = 0;
185
210
  continue;
186
211
  }
187
212
 
@@ -60,7 +60,10 @@ async function processExplore(page, username, options, log) {
60
60
 
61
61
  try {
62
62
  log(` 访问 @${username} 主页...`);
63
- const videoList = await collectVideos(page, username, maxVideos, log);
63
+ const videoList =
64
+ options.enableVideos !== false
65
+ ? await collectVideos(page, username, maxVideos, log)
66
+ : null;
64
67
 
65
68
  log(" 获取用户信息...");
66
69
  const info = await getUserInfo(page);
@@ -130,19 +133,22 @@ async function processExplore(page, username, options, log) {
130
133
  seoUrl: topVideo.seoUrl || null,
131
134
  };
132
135
 
133
- // 如果有商品链接,抓取商品详情页(评分、销量等)
134
- if (topVideo.seoUrl && options.shopPage && !options.shopPage.isClosed()) {
136
+ // 如果有商品链接,通过 WebService API 抓取商品详情页(评分、销量等)
137
+ if (topVideo.seoUrl && options.webserverUrl) {
135
138
  try {
136
- const { getProductInfoCDP } = await import("../lib/shop-scraper.mjs");
137
- const shopUrl = topVideo.seoUrl.replace(
138
- "www.tiktok.com/shop/pdp",
139
- "shop.tiktok.com/us/pdp",
140
- );
141
-
142
- const productDetail = await getProductInfoCDP(
143
- options.shopPage,
144
- shopUrl,
139
+ const resp = await fetch(
140
+ `${options.webserverUrl}/api/tiktok/product`,
141
+ {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({ url: topVideo.seoUrl }),
145
+ },
145
146
  );
147
+ if (!resp.ok) {
148
+ const err = await resp.json().catch(() => ({}));
149
+ throw new Error(err.error || `HTTP ${resp.status}`);
150
+ }
151
+ const productDetail = await resp.json();
146
152
  result.productDetail = productDetail;
147
153
  log(
148
154
  ` 商品详情: 评分${productDetail.rating ?? "-"} | 销量${productDetail.soldCount ?? "-"} | 评论${productDetail.reviewCount ?? "-"}`,
@@ -1,6 +1,7 @@
1
1
  import http from "http";
2
2
  import os from "os";
3
3
  import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
4
+ import { getProductInfo } from "../lib/shop-scraper.mjs";
4
5
 
5
6
  // ---- 工具函数 ----
6
7
 
@@ -61,6 +62,7 @@ const SCRAPER_CONFIG = {
61
62
  wafTTL: 120000,
62
63
  maxRequestsPerPage: 50,
63
64
  proxyServer: null,
65
+ captchaNotifyUrl: null,
64
66
  };
65
67
 
66
68
  let scraperInstance = null;
@@ -144,6 +146,7 @@ const AVAILABLE_APIS = [
144
146
  "/api/tiktok/user",
145
147
  "/api/tiktok/video",
146
148
  "/api/tiktok/lookup",
149
+ "/api/tiktok/product",
147
150
  "/health",
148
151
  ];
149
152
 
@@ -192,6 +195,82 @@ async function handleTiktokLookup(req, res) {
192
195
  return sendJSON(res, 200, result);
193
196
  }
194
197
 
198
+ async function handleTiktokProduct(req, res) {
199
+ const body = await readBody(req);
200
+ if (!body.url) {
201
+ return sendJSON(res, 400, { error: "missing url" });
202
+ }
203
+ // URL 标准化:www.tiktok.com/shop/pdp → shop.tiktok.com/us/pdp
204
+ const normalizedUrl = body.url.replace(
205
+ "www.tiktok.com/shop/pdp",
206
+ "shop.tiktok.com/us/pdp",
207
+ );
208
+ try {
209
+ const result = await getProductInfo(
210
+ normalizedUrl,
211
+ SCRAPER_CONFIG.proxyServer,
212
+ {
213
+ captchaFallback: false,
214
+ },
215
+ );
216
+ return sendJSON(res, 200, result);
217
+ } catch (e) {
218
+ // 先返回错误,不阻塞请求
219
+ sendJSON(res, 502, { error: e.message });
220
+
221
+ // 如果是 captcha,异步弹浏览器(不阻塞其他请求)
222
+ if (e.message?.includes("Blocked by TikTok")) {
223
+ scheduleCaptchaRecovery(normalizedUrl, SCRAPER_CONFIG.proxyServer);
224
+ }
225
+ return;
226
+ }
227
+ }
228
+
229
+ // 防止重复弹浏览器
230
+ let captchaRecoveryRunning = false;
231
+
232
+ async function scheduleCaptchaRecovery(url, proxy) {
233
+ if (captchaRecoveryRunning) {
234
+ console.error("[webserver] Captcha recovery already in progress, skipping");
235
+ return;
236
+ }
237
+ captchaRecoveryRunning = true;
238
+
239
+ // 通知 Watch Server:这台机器出现验证码需要人工处理
240
+ if (SCRAPER_CONFIG.captchaNotifyUrl) {
241
+ try {
242
+ await fetch(`${SCRAPER_CONFIG.captchaNotifyUrl}/api/error-report`, {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json" },
245
+ body: JSON.stringify({
246
+ userId: `webserver-${process.env.HOSTNAME || os.hostname() || "unknown"}`,
247
+ errorType: "captcha_blocked",
248
+ errorMessage:
249
+ "TikTok Shop captcha detected, browser opened - manual intervention required",
250
+ stage: "product_info",
251
+ errorStack: url,
252
+ }),
253
+ });
254
+ console.error("[webserver] Captcha alert sent to watch server");
255
+ } catch (e) {
256
+ console.error("[webserver] Failed to notify watch server:", e.message);
257
+ }
258
+ }
259
+
260
+ try {
261
+ const { getProductInfo } = await import("../lib/shop-scraper.mjs");
262
+ console.error(
263
+ "[webserver] Starting async captcha recovery, browser will open...",
264
+ );
265
+ await getProductInfo(url, proxy, { captchaFallback: true });
266
+ console.error("[webserver] Captcha recovery completed, cookies cached");
267
+ } catch (err) {
268
+ console.error("[webserver] Captcha recovery failed:", err.message);
269
+ } finally {
270
+ captchaRecoveryRunning = false;
271
+ }
272
+ }
273
+
195
274
  function handleHealth(req, res) {
196
275
  return sendJSON(res, 200, {
197
276
  status: "ok",
@@ -219,6 +298,7 @@ const ROUTES = [
219
298
  { method: "POST", path: "/api/tiktok/user", handler: handleTiktokUser },
220
299
  { method: "POST", path: "/api/tiktok/video", handler: handleTiktokVideo },
221
300
  { method: "POST", path: "/api/tiktok/lookup", handler: handleTiktokLookup },
301
+ { method: "POST", path: "/api/tiktok/product", handler: handleTiktokProduct },
222
302
  ];
223
303
 
224
304
  // ---- 服务器 ----
@@ -227,6 +307,7 @@ export async function startWebserver(
227
307
  port = 3000,
228
308
  poolSize = 0,
229
309
  proxyServer = null,
310
+ captchaNotifyUrl = null,
230
311
  ) {
231
312
  if (poolSize > 0) {
232
313
  SCRAPER_CONFIG.poolSize = poolSize;
@@ -234,6 +315,9 @@ export async function startWebserver(
234
315
  if (proxyServer) {
235
316
  SCRAPER_CONFIG.proxyServer = proxyServer;
236
317
  }
318
+ if (captchaNotifyUrl) {
319
+ SCRAPER_CONFIG.captchaNotifyUrl = captchaNotifyUrl;
320
+ }
237
321
  const server = http.createServer(async (req, res) => {
238
322
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
239
323