tt-help-cli-ycl 1.4.43 → 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 +1 -1
- package/src/cli/explore.js +3 -13
- package/src/cli/refresh.js +3 -12
- package/src/cli/webserver.js +3 -1
- package/src/lib/args.js +4 -0
- package/src/lib/shop-scraper.mjs +44 -19
- package/src/scraper/explore-core.js +18 -12
- package/src/webserver/server.mjs +84 -0
package/package.json
CHANGED
package/src/cli/explore.js
CHANGED
|
@@ -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
|
);
|
package/src/cli/refresh.js
CHANGED
|
@@ -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
|
},
|
package/src/cli/webserver.js
CHANGED
|
@@ -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 } =
|
|
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
|
@@ -407,6 +407,7 @@ function parseWebserverArgs(args) {
|
|
|
407
407
|
let webserverPort = 3000;
|
|
408
408
|
let webserverPoolSize = 0;
|
|
409
409
|
let webserverProxy = null;
|
|
410
|
+
let webserverNotify = null;
|
|
410
411
|
|
|
411
412
|
for (let i = 0; i < args.length; i++) {
|
|
412
413
|
const arg = args[i];
|
|
@@ -416,6 +417,8 @@ function parseWebserverArgs(args) {
|
|
|
416
417
|
webserverPoolSize = parseInt(args[++i]) || 0;
|
|
417
418
|
} else if (arg === "--proxy") {
|
|
418
419
|
webserverProxy = args[++i] || null;
|
|
420
|
+
} else if (arg === "--notify") {
|
|
421
|
+
webserverNotify = args[++i] || null;
|
|
419
422
|
}
|
|
420
423
|
}
|
|
421
424
|
|
|
@@ -424,6 +427,7 @@ function parseWebserverArgs(args) {
|
|
|
424
427
|
webserverPort,
|
|
425
428
|
webserverPoolSize,
|
|
426
429
|
webserverProxy,
|
|
430
|
+
webserverNotify,
|
|
427
431
|
urls: [],
|
|
428
432
|
outputFile: null,
|
|
429
433
|
outputFormat: "json",
|
package/src/lib/shop-scraper.mjs
CHANGED
|
@@ -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
|
|
75
|
-
*
|
|
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
|
|
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
|
|
119
|
+
// Poll until captcha is solved (page loads real content) or browser closed
|
|
114
120
|
let solved = false;
|
|
115
|
-
const POLL_MS =
|
|
116
|
-
const MAX_WAIT_MS =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
184
|
-
attempt = 0;
|
|
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 =
|
|
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.
|
|
136
|
+
// 如果有商品链接,通过 WebService API 抓取商品详情页(评分、销量等)
|
|
137
|
+
if (topVideo.seoUrl && options.webserverUrl) {
|
|
135
138
|
try {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 ?? "-"}`,
|
package/src/webserver/server.mjs
CHANGED
|
@@ -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
|
|