tt-help-cli-ycl 1.4.35 → 1.4.36
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 -0
- package/src/cli/refresh.js +3 -0
- package/src/cli/webserver.js +35 -12
- package/src/lib/args.js +12 -0
- package/src/lib/constants.js +9 -1
- package/src/scraper/explore-core.js +26 -3
- package/src/watch/data-store.js +24 -27
- package/src/watch/db-schema.js +1 -0
- package/src/webserver/server.mjs +162 -71
package/package.json
CHANGED
package/src/cli/explore.js
CHANGED
|
@@ -398,6 +398,7 @@ export async function handleExplore(options) {
|
|
|
398
398
|
maxFollowers: exploreMaxFollowers,
|
|
399
399
|
location: exploreLocation,
|
|
400
400
|
proxyServer: cdpOptions.proxyServer || null,
|
|
401
|
+
webserverUrl: exploreWebserver || null,
|
|
401
402
|
browser,
|
|
402
403
|
shopPage,
|
|
403
404
|
},
|
|
@@ -428,6 +429,7 @@ export async function handleExplore(options) {
|
|
|
428
429
|
maxFollowers: exploreMaxFollowers,
|
|
429
430
|
location: exploreLocation,
|
|
430
431
|
proxyServer: cdpOptions.proxyServer || null,
|
|
432
|
+
webserverUrl: exploreWebserver || null,
|
|
431
433
|
browser,
|
|
432
434
|
shopPage,
|
|
433
435
|
},
|
|
@@ -476,6 +478,7 @@ export async function handleExplore(options) {
|
|
|
476
478
|
maxFollowers: exploreMaxFollowers,
|
|
477
479
|
location: exploreLocation,
|
|
478
480
|
proxyServer: cdpOptions.proxyServer || null,
|
|
481
|
+
webserverUrl: exploreWebserver || null,
|
|
479
482
|
browser,
|
|
480
483
|
shopPage,
|
|
481
484
|
},
|
package/src/cli/refresh.js
CHANGED
|
@@ -411,6 +411,7 @@ export async function handleRefresh(options) {
|
|
|
411
411
|
browser,
|
|
412
412
|
shopPage,
|
|
413
413
|
proxyServer: cdpOptions.proxyServer || null,
|
|
414
|
+
webserverUrl: exploreWebserver || null,
|
|
414
415
|
},
|
|
415
416
|
console.error,
|
|
416
417
|
);
|
|
@@ -441,6 +442,7 @@ export async function handleRefresh(options) {
|
|
|
441
442
|
browser,
|
|
442
443
|
shopPage,
|
|
443
444
|
proxyServer: cdpOptions.proxyServer || null,
|
|
445
|
+
webserverUrl: exploreWebserver || null,
|
|
444
446
|
},
|
|
445
447
|
console.error,
|
|
446
448
|
);
|
|
@@ -490,6 +492,7 @@ export async function handleRefresh(options) {
|
|
|
490
492
|
browser,
|
|
491
493
|
shopPage,
|
|
492
494
|
proxyServer: cdpOptions.proxyServer || null,
|
|
495
|
+
webserverUrl: exploreWebserver || null,
|
|
493
496
|
},
|
|
494
497
|
console.error,
|
|
495
498
|
);
|
package/src/cli/webserver.js
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
startWebserver,
|
|
3
|
+
closeScraper,
|
|
4
|
+
closeServer,
|
|
5
|
+
} from "../webserver/server.mjs";
|
|
2
6
|
|
|
3
7
|
export async function handleWebserver(options) {
|
|
4
|
-
const { webserverPort } = options;
|
|
8
|
+
const { webserverPort, webserverPoolSize, webserverProxy } = options;
|
|
5
9
|
|
|
6
|
-
const { server, port } = await startWebserver(
|
|
10
|
+
const { server, port } = await startWebserver(
|
|
11
|
+
webserverPort,
|
|
12
|
+
webserverPoolSize,
|
|
13
|
+
webserverProxy,
|
|
14
|
+
);
|
|
7
15
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
let shuttingDown = false;
|
|
17
|
+
|
|
18
|
+
async function gracefulShutdown(signal) {
|
|
19
|
+
if (shuttingDown) return;
|
|
20
|
+
shuttingDown = true;
|
|
21
|
+
|
|
22
|
+
console.error(`\n[webserver] Received ${signal}, shutting down...`);
|
|
13
23
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
try {
|
|
25
|
+
await closeScraper();
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(`[webserver] scraper close error: ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await closeServer(server);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`[webserver] server close error: ${e.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.error("[webserver] Shutdown complete.");
|
|
17
37
|
process.exit(0);
|
|
18
|
-
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
process.once("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
41
|
+
process.once("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
19
42
|
}
|
package/src/lib/args.js
CHANGED
|
@@ -401,17 +401,25 @@ function parseWatchArgs(args) {
|
|
|
401
401
|
|
|
402
402
|
function parseWebserverArgs(args) {
|
|
403
403
|
let webserverPort = 3000;
|
|
404
|
+
let webserverPoolSize = 0;
|
|
405
|
+
let webserverProxy = null;
|
|
404
406
|
|
|
405
407
|
for (let i = 0; i < args.length; i++) {
|
|
406
408
|
const arg = args[i];
|
|
407
409
|
if (arg === "-p" || arg === "--port") {
|
|
408
410
|
webserverPort = parseInt(args[++i]) || 3000;
|
|
411
|
+
} else if (arg === "--pool-size") {
|
|
412
|
+
webserverPoolSize = parseInt(args[++i]) || 0;
|
|
413
|
+
} else if (arg === "--proxy") {
|
|
414
|
+
webserverProxy = args[++i] || null;
|
|
409
415
|
}
|
|
410
416
|
}
|
|
411
417
|
|
|
412
418
|
return {
|
|
413
419
|
subcommand: "webserver",
|
|
414
420
|
webserverPort,
|
|
421
|
+
webserverPoolSize,
|
|
422
|
+
webserverProxy,
|
|
415
423
|
urls: [],
|
|
416
424
|
outputFile: null,
|
|
417
425
|
outputFormat: "json",
|
|
@@ -482,6 +490,7 @@ function parseRefreshArgs(args) {
|
|
|
482
490
|
let exploreRedoMaxAge = 0;
|
|
483
491
|
let exploreProxy = null;
|
|
484
492
|
let exploreJobLocations = null;
|
|
493
|
+
let exploreWebserver = null;
|
|
485
494
|
|
|
486
495
|
for (let i = 0; i < args.length; i++) {
|
|
487
496
|
const arg = args[i];
|
|
@@ -511,6 +520,8 @@ function parseRefreshArgs(args) {
|
|
|
511
520
|
exploreRedoMaxAge = parseInt(args[++i]) || 43200;
|
|
512
521
|
} else if (arg === "--proxy") {
|
|
513
522
|
exploreProxy = args[++i];
|
|
523
|
+
} else if (arg === "--webserver") {
|
|
524
|
+
exploreWebserver = args[++i];
|
|
514
525
|
} else if (arg === "--location") {
|
|
515
526
|
exploreLocation = args[++i];
|
|
516
527
|
} else if (arg === "--job-locations") {
|
|
@@ -542,6 +553,7 @@ function parseRefreshArgs(args) {
|
|
|
542
553
|
exploreMaxVideos,
|
|
543
554
|
exploreRedoMaxAge,
|
|
544
555
|
exploreProxy,
|
|
556
|
+
exploreWebserver,
|
|
545
557
|
serverUrl,
|
|
546
558
|
urls: [],
|
|
547
559
|
outputFormat: "json",
|
package/src/lib/constants.js
CHANGED
|
@@ -138,6 +138,7 @@ const HELP_TEXT = [
|
|
|
138
138
|
" --port-count <数量> 端口数量(账户数),默认 10",
|
|
139
139
|
" --user-id <编号> 客户端编号(设备ID),默认自动生成",
|
|
140
140
|
"--proxy <地址> 浏览器代理(如 socks5://127.0.0.1:1080),未指定时使用配置文件 proxy",
|
|
141
|
+
"--webserver <URL> 集中式视频国家查询服务(如 http://127.0.0.1:3000),省去本地 Playwright 浏览器",
|
|
141
142
|
"",
|
|
142
143
|
" tt-help refresh [选项]",
|
|
143
144
|
" 对目标商家用户进行轮回刷新,重新采集视频 + 关注 + 粉丝",
|
|
@@ -159,6 +160,7 @@ const HELP_TEXT = [
|
|
|
159
160
|
" --port-count <数量> 端口数量(账户数),默认 10",
|
|
160
161
|
" --user-id <编号> 客户端编号(设备ID),默认自动生成",
|
|
161
162
|
"--proxy <地址> 浏览器代理(如 socks5://127.0.0.1:1080),未指定时使用配置文件 proxy",
|
|
163
|
+
"--webserver <URL> 集中式视频国家查询服务(如 http://127.0.0.1:3000),省去本地 Playwright 浏览器",
|
|
162
164
|
"",
|
|
163
165
|
" tt-help precheck [选项]",
|
|
164
166
|
" 对目标商家用户进行轻量预检,通过 HTTP 获取 videoCount 判断是否有新视频",
|
|
@@ -240,11 +242,15 @@ const HELP_TEXT = [
|
|
|
240
242
|
" 启动 HTTP 服务,对外提供 TikTok 数据抓取 API",
|
|
241
243
|
" 选项:",
|
|
242
244
|
" -p, --port <端口号> 监听端口,默认 3000",
|
|
245
|
+
" --pool-size <数量> 浏览器页面池大小,默认 8",
|
|
246
|
+
" --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
|
|
243
247
|
" API:",
|
|
248
|
+
" GET /health 健康检查",
|
|
249
|
+
" GET / 服务信息",
|
|
244
250
|
" POST /api/tiktok/user 获取用户信息 { uniqueId: string }",
|
|
245
251
|
" POST /api/tiktok/video 获取视频信息 { videoUrl: string }",
|
|
246
252
|
" POST /api/tiktok/lookup 同时获取视频和作者信息 { videoUrl: string }",
|
|
247
|
-
" 示例: tt-help webserver -p 3000",
|
|
253
|
+
" 示例: tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
|
|
248
254
|
"",
|
|
249
255
|
" comments [选项]",
|
|
250
256
|
" 评论采集:从视频评论中发现新用户,来源标记为 comment",
|
|
@@ -377,7 +383,9 @@ const HELP_TEXT = [
|
|
|
377
383
|
"",
|
|
378
384
|
" 示例: tt-help info https://www.tiktok.com/@nike https://www.tiktok.com/@adidas",
|
|
379
385
|
" tt-help explore qiqi23280 fast --location ES --max-comments 50",
|
|
386
|
+
" tt-help explore --webserver http://127.0.0.1:3000 --location DE,ES,FR",
|
|
380
387
|
" tt-help refresh --server http://127.0.0.1:3001 --port 9222",
|
|
388
|
+
" tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
|
|
381
389
|
" tt-help config set server http://127.0.0.1:3001",
|
|
382
390
|
" tt-help attach -p 5 -i 10",
|
|
383
391
|
" tt-help watch -o data/result.db",
|
|
@@ -26,6 +26,7 @@ async function processExplore(page, username, options, log) {
|
|
|
26
26
|
location = DEFAULT_TARGET_LOCATIONS_CSV,
|
|
27
27
|
locationMode = "explore", // "explore" | "refresh"
|
|
28
28
|
proxyServer = null,
|
|
29
|
+
webserverUrl = null,
|
|
29
30
|
} = options;
|
|
30
31
|
|
|
31
32
|
const result = {
|
|
@@ -172,9 +173,31 @@ async function processExplore(page, username, options, log) {
|
|
|
172
173
|
const sampleUrls = sampleVideos.map((v) =>
|
|
173
174
|
v.href.startsWith("http") ? v.href : `https://www.tiktok.com${v.href}`,
|
|
174
175
|
);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
let locations;
|
|
177
|
+
if (webserverUrl) {
|
|
178
|
+
// 走集中式 webserver,不再启动本地 TikTokScraper
|
|
179
|
+
locations = await Promise.all(
|
|
180
|
+
sampleUrls.map(async (url) => {
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch(`${webserverUrl}/api/tiktok/video`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "Content-Type": "application/json" },
|
|
185
|
+
body: JSON.stringify({ videoUrl: url }),
|
|
186
|
+
signal: AbortSignal.timeout(30000),
|
|
187
|
+
});
|
|
188
|
+
const data = await res.json();
|
|
189
|
+
return data?.locationCreated || null;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
log(` [webserver] 视频查询失败: ${e.message}`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
locations = await Promise.all(
|
|
198
|
+
sampleUrls.map((url) => extractVideoLocation(url)),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
178
201
|
log(
|
|
179
202
|
` 国家采样(${locations.length}个): [${locations.filter(Boolean).join(", ") || "无数据"}]`,
|
|
180
203
|
);
|
package/src/watch/data-store.js
CHANGED
|
@@ -2335,11 +2335,10 @@ export function createStore(filePath, options = {}) {
|
|
|
2335
2335
|
const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
|
|
2336
2336
|
const placeholders = targetLocations.map(() => "?").join(",");
|
|
2337
2337
|
|
|
2338
|
-
//
|
|
2339
|
-
const
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
`
|
|
2338
|
+
// SELECT 候选用户
|
|
2339
|
+
const selected = getDb()
|
|
2340
|
+
.prepare(
|
|
2341
|
+
`
|
|
2343
2342
|
SELECT unique_id, nickname, video_count, COALESCE(refresh_time, ?) AS refresh_time
|
|
2344
2343
|
FROM jobs
|
|
2345
2344
|
WHERE tt_seller = 1
|
|
@@ -2350,30 +2349,28 @@ export function createStore(filePath, options = {}) {
|
|
|
2350
2349
|
ORDER BY COALESCE(refresh_time, ?) ASC
|
|
2351
2350
|
LIMIT ?
|
|
2352
2351
|
`,
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
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
|
-
}
|
|
2352
|
+
)
|
|
2353
|
+
.all(
|
|
2354
|
+
defaultTime,
|
|
2355
|
+
...targetLocations,
|
|
2356
|
+
defaultTime,
|
|
2357
|
+
threshold,
|
|
2358
|
+
defaultTime,
|
|
2359
|
+
limit,
|
|
2360
|
+
);
|
|
2372
2361
|
|
|
2373
|
-
|
|
2374
|
-
|
|
2362
|
+
// 立即更新 refresh_time 防重复领取(非事务,小概率并发重复可接受)
|
|
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
|
+
}
|
|
2375
2372
|
|
|
2376
|
-
return
|
|
2373
|
+
return selected.map((r) => ({
|
|
2377
2374
|
uniqueId: r.unique_id,
|
|
2378
2375
|
nickname: r.nickname,
|
|
2379
2376
|
videoCount: r.video_count || 0,
|
package/src/watch/db-schema.js
CHANGED
package/src/webserver/server.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import http from "http";
|
|
|
2
2
|
import os from "os";
|
|
3
3
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
4
4
|
|
|
5
|
+
// ---- 工具函数 ----
|
|
6
|
+
|
|
5
7
|
function getLocalIP() {
|
|
6
8
|
const ifaces = os.networkInterfaces();
|
|
7
9
|
for (const name of Object.keys(ifaces)) {
|
|
@@ -14,23 +16,29 @@ function getLocalIP() {
|
|
|
14
16
|
return "127.0.0.1";
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
const CORS_HEADERS = {
|
|
20
|
+
"Access-Control-Allow-Origin": "*",
|
|
21
|
+
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
22
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
23
|
+
};
|
|
24
|
+
|
|
17
25
|
function sendJSON(res, status, data) {
|
|
18
26
|
const body = JSON.stringify(data, null, 2);
|
|
19
27
|
res.writeHead(status, {
|
|
20
28
|
"Content-Type": "application/json; charset=utf-8",
|
|
21
|
-
|
|
22
|
-
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
23
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
29
|
+
...CORS_HEADERS,
|
|
24
30
|
});
|
|
25
31
|
res.end(body);
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
|
|
35
|
+
|
|
28
36
|
function readBody(req) {
|
|
29
37
|
return new Promise((resolve, reject) => {
|
|
30
38
|
let body = "";
|
|
31
39
|
req.on("data", (chunk) => {
|
|
32
40
|
body += chunk.toString();
|
|
33
|
-
if (body.length >
|
|
41
|
+
if (body.length > MAX_BODY_SIZE) {
|
|
34
42
|
req.destroy();
|
|
35
43
|
reject(new Error("Request body too large"));
|
|
36
44
|
}
|
|
@@ -46,8 +54,18 @@ function readBody(req) {
|
|
|
46
54
|
});
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
// ---- Scraper 生命周期 ----
|
|
58
|
+
|
|
59
|
+
const SCRAPER_CONFIG = {
|
|
60
|
+
poolSize: 8,
|
|
61
|
+
wafTTL: 120000,
|
|
62
|
+
maxRequestsPerPage: 50,
|
|
63
|
+
proxyServer: null,
|
|
64
|
+
};
|
|
65
|
+
|
|
49
66
|
let scraperInstance = null;
|
|
50
67
|
let scraperClosing = false;
|
|
68
|
+
let scraperInitPromise = null;
|
|
51
69
|
|
|
52
70
|
async function getScraper() {
|
|
53
71
|
if (scraperInstance && !scraperClosing) {
|
|
@@ -56,115 +74,188 @@ async function getScraper() {
|
|
|
56
74
|
if (scraperClosing) {
|
|
57
75
|
throw new Error("Server is shutting down");
|
|
58
76
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
// 防止并发调用重复初始化
|
|
78
|
+
if (scraperInitPromise) {
|
|
79
|
+
return scraperInitPromise;
|
|
80
|
+
}
|
|
81
|
+
scraperInitPromise = (async () => {
|
|
82
|
+
const scraper = new TikTokScraper(SCRAPER_CONFIG);
|
|
83
|
+
await scraper.init();
|
|
84
|
+
scraperInstance = scraper;
|
|
85
|
+
return scraper;
|
|
86
|
+
})();
|
|
87
|
+
try {
|
|
88
|
+
return await scraperInitPromise;
|
|
89
|
+
} finally {
|
|
90
|
+
scraperInitPromise = null;
|
|
91
|
+
}
|
|
66
92
|
}
|
|
67
93
|
|
|
94
|
+
// ---- API 处理器 ----
|
|
95
|
+
|
|
96
|
+
const AVAILABLE_APIS = [
|
|
97
|
+
"/api/tiktok/user",
|
|
98
|
+
"/api/tiktok/video",
|
|
99
|
+
"/api/tiktok/lookup",
|
|
100
|
+
"/health",
|
|
101
|
+
];
|
|
102
|
+
|
|
68
103
|
async function handleTiktokUser(req, res) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return sendJSON(res, 400, { error: "missing uniqueId" });
|
|
73
|
-
}
|
|
74
|
-
const scraper = await getScraper();
|
|
75
|
-
const result = await scraper.getUserInfo(body.uniqueId);
|
|
76
|
-
if (!result) {
|
|
77
|
-
return sendJSON(res, 404, { error: "User not found or failed to parse data" });
|
|
78
|
-
}
|
|
79
|
-
return sendJSON(res, 200, result);
|
|
80
|
-
} catch (e) {
|
|
81
|
-
return sendJSON(res, 500, { error: e.message });
|
|
104
|
+
const body = await readBody(req);
|
|
105
|
+
if (!body.uniqueId) {
|
|
106
|
+
return sendJSON(res, 400, { error: "missing uniqueId" });
|
|
82
107
|
}
|
|
108
|
+
const scraper = await getScraper();
|
|
109
|
+
const result = await scraper.getUserInfo(body.uniqueId);
|
|
110
|
+
if (!result) {
|
|
111
|
+
return sendJSON(res, 404, {
|
|
112
|
+
error: "User not found or failed to parse data",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return sendJSON(res, 200, result);
|
|
83
116
|
}
|
|
84
117
|
|
|
85
118
|
async function handleTiktokVideo(req, res) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
return sendJSON(res, 200, result);
|
|
97
|
-
} catch (e) {
|
|
98
|
-
return sendJSON(res, 500, { error: e.message });
|
|
119
|
+
const body = await readBody(req);
|
|
120
|
+
if (!body.videoUrl) {
|
|
121
|
+
return sendJSON(res, 400, { error: "missing videoUrl" });
|
|
122
|
+
}
|
|
123
|
+
const scraper = await getScraper();
|
|
124
|
+
const result = await scraper.getVideoInfo(body.videoUrl);
|
|
125
|
+
if (!result) {
|
|
126
|
+
return sendJSON(res, 404, {
|
|
127
|
+
error: "Video not found or failed to parse data",
|
|
128
|
+
});
|
|
99
129
|
}
|
|
130
|
+
return sendJSON(res, 200, result);
|
|
100
131
|
}
|
|
101
132
|
|
|
102
133
|
async function handleTiktokLookup(req, res) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return sendJSON(res, 400, { error: "missing videoUrl" });
|
|
107
|
-
}
|
|
108
|
-
const scraper = await getScraper();
|
|
109
|
-
const result = await scraper.getUserAndVideo(body.videoUrl);
|
|
110
|
-
if (!result) {
|
|
111
|
-
return sendJSON(res, 404, { error: "Video not found or failed to parse data" });
|
|
112
|
-
}
|
|
113
|
-
return sendJSON(res, 200, result);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
return sendJSON(res, 500, { error: e.message });
|
|
134
|
+
const body = await readBody(req);
|
|
135
|
+
if (!body.videoUrl) {
|
|
136
|
+
return sendJSON(res, 400, { error: "missing videoUrl" });
|
|
116
137
|
}
|
|
138
|
+
const scraper = await getScraper();
|
|
139
|
+
const result = await scraper.getUserAndVideo(body.videoUrl);
|
|
140
|
+
if (!result) {
|
|
141
|
+
return sendJSON(res, 404, {
|
|
142
|
+
error: "Video not found or failed to parse data",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return sendJSON(res, 200, result);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function handleHealth(req, res) {
|
|
149
|
+
return sendJSON(res, 200, {
|
|
150
|
+
status: "ok",
|
|
151
|
+
scraperReady: scraperInstance !== null && !scraperClosing,
|
|
152
|
+
uptime: process.uptime(),
|
|
153
|
+
});
|
|
117
154
|
}
|
|
118
155
|
|
|
119
|
-
|
|
156
|
+
function handleRoot(req, res) {
|
|
157
|
+
return sendJSON(res, 200, {
|
|
158
|
+
service: "TikTok Scraper WebService",
|
|
159
|
+
available: AVAILABLE_APIS,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- 路由表 ----
|
|
164
|
+
|
|
165
|
+
const ROUTES = [
|
|
166
|
+
{ method: "GET", path: "/", handler: handleRoot },
|
|
167
|
+
{ method: "GET", path: "/health", handler: handleHealth },
|
|
168
|
+
{ method: "POST", path: "/api/tiktok/user", handler: handleTiktokUser },
|
|
169
|
+
{ method: "POST", path: "/api/tiktok/video", handler: handleTiktokVideo },
|
|
170
|
+
{ method: "POST", path: "/api/tiktok/lookup", handler: handleTiktokLookup },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
// ---- 服务器 ----
|
|
174
|
+
|
|
175
|
+
export async function startWebserver(
|
|
176
|
+
port = 3000,
|
|
177
|
+
poolSize = 0,
|
|
178
|
+
proxyServer = null,
|
|
179
|
+
) {
|
|
180
|
+
if (poolSize > 0) {
|
|
181
|
+
SCRAPER_CONFIG.poolSize = poolSize;
|
|
182
|
+
}
|
|
183
|
+
if (proxyServer) {
|
|
184
|
+
SCRAPER_CONFIG.proxyServer = proxyServer;
|
|
185
|
+
}
|
|
120
186
|
const server = http.createServer(async (req, res) => {
|
|
121
187
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
122
188
|
|
|
189
|
+
// CORS 预检
|
|
123
190
|
if (req.method === "OPTIONS") {
|
|
124
|
-
res.writeHead(204,
|
|
125
|
-
"Access-Control-Allow-Origin": "*",
|
|
126
|
-
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
127
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
128
|
-
});
|
|
191
|
+
res.writeHead(204, CORS_HEADERS);
|
|
129
192
|
res.end();
|
|
130
193
|
return;
|
|
131
194
|
}
|
|
132
195
|
|
|
196
|
+
// 路由匹配
|
|
197
|
+
const route = ROUTES.find(
|
|
198
|
+
(r) => r.method === req.method && r.path === url.pathname,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (!route) {
|
|
202
|
+
return sendJSON(res, 404, {
|
|
203
|
+
error: "Not found",
|
|
204
|
+
available: AVAILABLE_APIS,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
133
208
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return await handleTiktokVideo(req, res);
|
|
209
|
+
return await route.handler(req, res);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (!res.headersSent) {
|
|
212
|
+
return sendJSON(res, 500, { error: e.message });
|
|
139
213
|
}
|
|
140
|
-
|
|
141
|
-
|
|
214
|
+
// 响应已发送(流式等场景),记录日志并销毁 socket
|
|
215
|
+
console.error(
|
|
216
|
+
`[webserver] unhandled error after response sent: ${e.message}`,
|
|
217
|
+
);
|
|
218
|
+
if (!res.writableEnded) {
|
|
219
|
+
res.destroy();
|
|
142
220
|
}
|
|
143
|
-
|
|
144
|
-
sendJSON(res, 404, { error: "Not found", available: ["/api/tiktok/user", "/api/tiktok/video", "/api/tiktok/lookup"] });
|
|
145
|
-
} catch (e) {
|
|
146
|
-
sendJSON(res, 500, { error: e.message });
|
|
147
221
|
}
|
|
148
222
|
});
|
|
149
223
|
|
|
224
|
+
// 连接超时配置
|
|
225
|
+
server.keepAliveTimeout = 65000;
|
|
226
|
+
server.headersTimeout = 66000;
|
|
227
|
+
server.requestTimeout = 120000;
|
|
228
|
+
|
|
229
|
+
server.on("error", (err) => {
|
|
230
|
+
console.error(`[webserver] server error: ${err.message}`);
|
|
231
|
+
});
|
|
232
|
+
|
|
150
233
|
return new Promise((resolve, reject) => {
|
|
151
234
|
server.listen(port, () => {
|
|
152
235
|
const localIP = getLocalIP();
|
|
153
236
|
console.error(`\nTikTok Scraper WebService started`);
|
|
154
237
|
console.error(` Local: http://127.0.0.1:${port}`);
|
|
155
238
|
console.error(` Network: http://${localIP}:${port}`);
|
|
156
|
-
console.error(` APIs:
|
|
239
|
+
console.error(` APIs: ${AVAILABLE_APIS.join(", ")}`);
|
|
157
240
|
console.error(` Press Ctrl+C to stop.\n`);
|
|
158
241
|
resolve({ server, port });
|
|
159
242
|
});
|
|
160
|
-
server.
|
|
161
|
-
|
|
243
|
+
server.once("error", reject);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function closeServer(server) {
|
|
248
|
+
if (!server || !server.listening) return;
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
server.close((err) => {
|
|
251
|
+
if (err) reject(err);
|
|
252
|
+
else resolve();
|
|
162
253
|
});
|
|
163
254
|
});
|
|
164
255
|
}
|
|
165
256
|
|
|
166
257
|
export async function closeScraper() {
|
|
167
|
-
if (scraperInstance) {
|
|
258
|
+
if (scraperInstance && !scraperClosing) {
|
|
168
259
|
scraperClosing = true;
|
|
169
260
|
await scraperInstance.close();
|
|
170
261
|
scraperInstance = null;
|