tt-help-cli-ycl 1.4.43 → 1.4.45
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 +105 -17
- 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
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { ProxyAgent, request } from "undici";
|
|
20
|
+
import { execSync } from "child_process";
|
|
20
21
|
import { DEFAULT_PROXY } from "./constants.js";
|
|
21
22
|
|
|
22
23
|
const PDP_HEADERS = {
|
|
@@ -26,6 +27,16 @@ const PDP_HEADERS = {
|
|
|
26
27
|
"Accept-Language": "en-US,en;q=0.9",
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
// 浏览器解验证码后缓存的 Cookie,给 undici 复用
|
|
31
|
+
let cachedCookies = null;
|
|
32
|
+
let cookiesExpireAt = 0;
|
|
33
|
+
const COOKIE_TTL_MS = 30 * 60 * 1000; // Cookie 有效期 30 分钟
|
|
34
|
+
|
|
35
|
+
function getCookieHeader() {
|
|
36
|
+
if (!cachedCookies || Date.now() > cookiesExpireAt) return null;
|
|
37
|
+
return cachedCookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* Extract a JSON object by its key name, handling nested braces and strings.
|
|
31
42
|
*/
|
|
@@ -71,19 +82,77 @@ function extractJsonByKey(html, keyPattern) {
|
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
85
|
+
* Windows: 后台服务的 Playwright 浏览器在 Session 0(隔离桌面),
|
|
86
|
+
* RDP 看不到。改为用 start 命令推到交互桌面,然后轮询 undici 等 IP 恢复信用。
|
|
76
87
|
*/
|
|
88
|
+
async function resolveCaptchaWindows(url, proxyUrl) {
|
|
89
|
+
// 用 Windows start 命令在交互桌面打开 Edge
|
|
90
|
+
const edgeUrl = url.replace("shop.tiktok.com", "www.tiktok.com/shop/pdp");
|
|
91
|
+
try {
|
|
92
|
+
execSync(`start "" "microsoft-edge:${edgeUrl}"`, {
|
|
93
|
+
shell: "cmd.exe",
|
|
94
|
+
timeout: 5000,
|
|
95
|
+
});
|
|
96
|
+
console.error(
|
|
97
|
+
"[shop-scraper] Windows: 已用 start 命令在交互桌面打开 Edge,请在 RDP 中完成验证码",
|
|
98
|
+
);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error("[shop-scraper] Windows: start 命令失败:", e.message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 轮询 undici 等待 IP 信用恢复(用户解完验证码后代理 IP 就有信用了)
|
|
104
|
+
const proxy = proxyUrl || DEFAULT_PROXY;
|
|
105
|
+
const agent = proxy ? new ProxyAgent(proxy) : null;
|
|
106
|
+
const POLL_MS = 5000;
|
|
107
|
+
const MAX_WAIT_MS = 3600_000; // 最长等 1 小时
|
|
108
|
+
const start = Date.now();
|
|
109
|
+
|
|
110
|
+
while (Date.now() - start < MAX_WAIT_MS) {
|
|
111
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
112
|
+
try {
|
|
113
|
+
const { body } = await request(url, {
|
|
114
|
+
headers: PDP_HEADERS,
|
|
115
|
+
...(agent ? { dispatcher: agent } : {}),
|
|
116
|
+
});
|
|
117
|
+
const html = await body.text();
|
|
118
|
+
if (
|
|
119
|
+
html.includes('"product_model"') ||
|
|
120
|
+
html.includes('"review_ratings"')
|
|
121
|
+
) {
|
|
122
|
+
console.error("[shop-scraper] Captcha solved, IP credit restored");
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// 继续轮询
|
|
127
|
+
}
|
|
128
|
+
// 每 60 秒打印一次状态
|
|
129
|
+
if (Math.round((Date.now() - start) / 60000) % 5 === 0) {
|
|
130
|
+
console.error(
|
|
131
|
+
`[shop-scraper] 等待验证码解决中... 已等待 ${Math.round((Date.now() - start) / 60000)} 分钟`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.error("[shop-scraper] Windows: 等待验证码超时");
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
77
140
|
/**
|
|
78
|
-
* Open a headed browser for manual captcha solving
|
|
79
|
-
*
|
|
141
|
+
* Open a headed browser for manual captcha solving, extract cookies
|
|
142
|
+
* so undici can reuse them for a while.
|
|
80
143
|
* @param {string} url - TikTok Shop PDP URL to open in browser
|
|
81
144
|
* @param {string} [proxyUrl]
|
|
82
|
-
* @returns {Promise<boolean>} true if captcha was solved
|
|
145
|
+
* @returns {Promise<boolean>} true if captcha was solved and cookies cached
|
|
83
146
|
*/
|
|
84
147
|
export async function resolveCaptchaWithBrowser(url, proxyUrl) {
|
|
85
148
|
const { chromium } = await import("playwright");
|
|
86
149
|
|
|
150
|
+
// Windows 后台服务启动的浏览器在 Session 0,RDP 看不到。
|
|
151
|
+
// 用 start 命令推到当前交互桌面。
|
|
152
|
+
if (process.platform === "win32") {
|
|
153
|
+
return resolveCaptchaWindows(url, proxyUrl);
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
const args = ["--no-sandbox"];
|
|
88
157
|
if (proxyUrl) {
|
|
89
158
|
args.push(`--proxy-server=${proxyUrl.replace("http://", "http://")}`);
|
|
@@ -110,10 +179,10 @@ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
|
|
|
110
179
|
"[shop-scraper] Browser opened, waiting for captcha to be solved...",
|
|
111
180
|
);
|
|
112
181
|
|
|
113
|
-
// Poll until captcha is solved (page loads real content) or
|
|
182
|
+
// Poll until captcha is solved (page loads real content) or browser closed
|
|
114
183
|
let solved = false;
|
|
115
|
-
const POLL_MS =
|
|
116
|
-
const MAX_WAIT_MS =
|
|
184
|
+
const POLL_MS = 2000;
|
|
185
|
+
const MAX_WAIT_MS = 3600_000; // 最长等 1 小时
|
|
117
186
|
const start = Date.now();
|
|
118
187
|
|
|
119
188
|
while (!solved && Date.now() - start < MAX_WAIT_MS) {
|
|
@@ -126,19 +195,29 @@ export async function resolveCaptchaWithBrowser(url, proxyUrl) {
|
|
|
126
195
|
solved = true;
|
|
127
196
|
break;
|
|
128
197
|
}
|
|
129
|
-
// Check if browser was closed by user
|
|
130
198
|
if (!browser.isConnected()) break;
|
|
131
199
|
} catch (e) {
|
|
132
|
-
// Page might be closed, check browser
|
|
133
200
|
if (!browser.isConnected()) break;
|
|
134
201
|
}
|
|
135
202
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
136
203
|
}
|
|
137
204
|
|
|
138
205
|
if (solved) {
|
|
139
|
-
|
|
206
|
+
// 提取 Cookie 给 undici 复用
|
|
207
|
+
try {
|
|
208
|
+
const cookies = await page.context().cookies();
|
|
209
|
+
if (cookies.length > 0) {
|
|
210
|
+
cachedCookies = cookies;
|
|
211
|
+
cookiesExpireAt = Date.now() + COOKIE_TTL_MS;
|
|
212
|
+
console.error(
|
|
213
|
+
`[shop-scraper] Captcha solved, cached ${cookies.length} cookies for undici reuse`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
console.error("[shop-scraper] Failed to extract cookies:", e.message);
|
|
218
|
+
}
|
|
140
219
|
} else {
|
|
141
|
-
console.error("[shop-scraper] Timed out or browser closed
|
|
220
|
+
console.error("[shop-scraper] Timed out or browser closed");
|
|
142
221
|
}
|
|
143
222
|
|
|
144
223
|
await browser.close().catch(() => {});
|
|
@@ -160,8 +239,14 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
160
239
|
let lastError;
|
|
161
240
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
162
241
|
try {
|
|
242
|
+
const headers = { ...PDP_HEADERS };
|
|
243
|
+
const cookieStr = getCookieHeader();
|
|
244
|
+
if (cookieStr) {
|
|
245
|
+
headers["Cookie"] = cookieStr;
|
|
246
|
+
}
|
|
247
|
+
|
|
163
248
|
const opts = {
|
|
164
|
-
headers
|
|
249
|
+
headers,
|
|
165
250
|
...(agent ? { dispatcher: agent } : {}),
|
|
166
251
|
};
|
|
167
252
|
|
|
@@ -173,15 +258,18 @@ export async function getProductInfo(url, proxyUrl, options = {}) {
|
|
|
173
258
|
} catch (err) {
|
|
174
259
|
lastError = err;
|
|
175
260
|
|
|
176
|
-
// Captcha
|
|
261
|
+
// Captcha:清除过期 Cookie,弹浏览器重新获取
|
|
177
262
|
if (
|
|
178
263
|
err.message?.includes("Blocked by TikTok") &&
|
|
179
264
|
options.captchaFallback
|
|
180
265
|
) {
|
|
181
|
-
console.error("[shop-scraper] Captcha hit,
|
|
266
|
+
console.error("[shop-scraper] Captcha hit, clearing cached cookies...");
|
|
267
|
+
cachedCookies = null;
|
|
268
|
+
cookiesExpireAt = 0;
|
|
269
|
+
console.error("[shop-scraper] Opening browser for manual captcha...");
|
|
182
270
|
await resolveCaptchaWithBrowser(url, proxy);
|
|
183
|
-
//
|
|
184
|
-
attempt = 0;
|
|
271
|
+
// 重试(带着新 Cookie)
|
|
272
|
+
attempt = 0;
|
|
185
273
|
continue;
|
|
186
274
|
}
|
|
187
275
|
|
|
@@ -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
|
|