tt-help-cli-ycl 1.4.35 → 1.4.37

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.35",
3
+ "version": "1.4.37",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  },
@@ -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
  );
@@ -1,19 +1,42 @@
1
- import { startWebserver, closeScraper } from "../webserver/server.mjs";
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(webserverPort);
10
+ const { server, port } = await startWebserver(
11
+ webserverPort,
12
+ webserverPoolSize,
13
+ webserverProxy,
14
+ );
7
15
 
8
- process.once("SIGINT", async () => {
9
- await closeScraper();
10
- server.close();
11
- process.exit(0);
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
- process.once("SIGTERM", async () => {
15
- await closeScraper();
16
- server.close();
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",
@@ -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",
package/src/npm-main.js CHANGED
@@ -9,6 +9,7 @@ import { handleOpen } from "./cli/open.js";
9
9
  import { handleComments } from "./cli/comments.js";
10
10
  import { handleRefresh } from "./cli/refresh.js";
11
11
  import { handlePrecheck } from "./cli/precheck.js";
12
+ import { handleWebserver } from "./cli/webserver.js";
12
13
  import {
13
14
  handleTag,
14
15
  handleDiscover,
@@ -38,8 +39,9 @@ async function main() {
38
39
  return handleAttach(parsed);
39
40
  case "recheck":
40
41
  return handleRecheck(parsed);
41
- case "watch":
42
42
  case "webserver":
43
+ return handleWebserver(parsed);
44
+ case "watch":
43
45
  case "videostats":
44
46
  case "db-import":
45
47
  return exitUnsupportedCommand(parsed.subcommand);
@@ -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
- const locations = await Promise.all(
176
- sampleUrls.map((url) => extractVideoLocation(url)),
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
  );
@@ -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
- // 事务:SELECT + 立即 UPDATE refresh_time,防止多客户端重复领取
2339
- const rows = getDb().transaction(() => {
2340
- const selected = getDb()
2341
- .prepare(
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
- .all(
2355
- defaultTime,
2356
- ...targetLocations,
2357
- defaultTime,
2358
- threshold,
2359
- defaultTime,
2360
- limit,
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
- return selected;
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 rows.map((r) => ({
2373
+ return selected.map((r) => ({
2377
2374
  uniqueId: r.unique_id,
2378
2375
  nickname: r.nickname,
2379
2376
  videoCount: r.video_count || 0,
@@ -70,6 +70,7 @@ export function initDb(filePath) {
70
70
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
71
71
  db = new Database(dbPath);
72
72
  db.pragma("journal_mode = WAL");
73
+ db.pragma("busy_timeout = 5000");
73
74
 
74
75
  // ===== users 表 =====
75
76
  db.exec(`
@@ -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
- "Access-Control-Allow-Origin": "*",
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 > 10 * 1024 * 1024) {
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
- scraperInstance = new TikTokScraper({
60
- poolSize: 3,
61
- wafTTL: 120000,
62
- maxRequestsPerPage: 50,
63
- });
64
- await scraperInstance.init();
65
- return scraperInstance;
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
- try {
70
- const body = await readBody(req);
71
- if (!body.uniqueId) {
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
- try {
87
- const body = await readBody(req);
88
- if (!body.videoUrl) {
89
- return sendJSON(res, 400, { error: "missing videoUrl" });
90
- }
91
- const scraper = await getScraper();
92
- const result = await scraper.getVideoInfo(body.videoUrl);
93
- if (!result) {
94
- return sendJSON(res, 404, { error: "Video not found or failed to parse data" });
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
- try {
104
- const body = await readBody(req);
105
- if (!body.videoUrl) {
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
- export async function startWebserver(port = 3000) {
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
- if (url.pathname === "/api/tiktok/user" && req.method === "POST") {
135
- return await handleTiktokUser(req, res);
136
- }
137
- if (url.pathname === "/api/tiktok/video" && req.method === "POST") {
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
- if (url.pathname === "/api/tiktok/lookup" && req.method === "POST") {
141
- return await handleTiktokLookup(req, res);
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: POST /api/tiktok/user, /api/tiktok/video, /api/tiktok/lookup`);
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.on("error", (err) => {
161
- reject(err);
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;