tt-help-cli-ycl 1.4.78 → 1.4.79

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.78",
3
+ "version": "1.4.79",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,14 @@ function sendJSON(res, status, data) {
35
35
 
36
36
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
37
37
 
38
+ function getClientIP(req) {
39
+ return (
40
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
41
+ req.socket.remoteAddress ||
42
+ ""
43
+ );
44
+ }
45
+
38
46
  function readBody(req) {
39
47
  return new Promise((resolve, reject) => {
40
48
  let body = "";
@@ -73,6 +81,11 @@ let consecutiveErrors = 0;
73
81
  const MAX_CONSECUTIVE_ERRORS = 10;
74
82
  let warmTimer = null;
75
83
  let lastWarmTime = 0;
84
+ // 滑动窗口错误率追踪(用于 /health 上报,不触发重启)
85
+ let recentTotal = 0;
86
+ let recentErrors = 0;
87
+ let recentWindowStart = Date.now();
88
+ const RECENT_WINDOW_MS = 60000; // 60 秒滑动窗口
76
89
 
77
90
  async function getScraper() {
78
91
  if (scraperInstance && !scraperClosing) {
@@ -100,6 +113,23 @@ async function getScraper() {
100
113
  }
101
114
  }
102
115
 
116
+ function recordRequest(success) {
117
+ const now = Date.now();
118
+ if (now - recentWindowStart > RECENT_WINDOW_MS) {
119
+ recentTotal = 0;
120
+ recentErrors = 0;
121
+ recentWindowStart = now;
122
+ }
123
+ recentTotal++;
124
+ if (!success) recentErrors++;
125
+ if (success) consecutiveErrors = 0;
126
+ }
127
+
128
+ function isDegraded() {
129
+ // 仅当 scraper 正在恢复/关闭中才算降级,初始冷状态不算
130
+ return scraperClosing || scraperInitPromise !== null;
131
+ }
132
+
103
133
  async function restartScraper(reason) {
104
134
  console.error(`[webserver] 重启 scraper: ${reason}`);
105
135
  if (scraperInstance) {
@@ -156,13 +186,39 @@ async function handleTiktokUser(req, res) {
156
186
  if (!body.uniqueId) {
157
187
  return sendJSON(res, 400, { error: "missing uniqueId" });
158
188
  }
189
+ const clientIP = getClientIP(req);
190
+ const startTime = Date.now();
159
191
  const scraper = await getScraper();
160
192
  const result = await scraper.getUserInfo(body.uniqueId);
193
+ const elapsed = Date.now() - startTime;
194
+
161
195
  if (!result) {
162
- return sendJSON(res, 404, {
163
- error: "User not found or failed to parse data",
196
+ console.error(
197
+ `[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | empty SSR / parse failed`,
198
+ );
199
+ recordRequest(false);
200
+ return sendJSON(res, 503, {
201
+ error: "Temporary failure: unable to fetch user data",
202
+ retryable: true,
164
203
  });
165
204
  }
205
+
206
+ if (result.error) {
207
+ console.error(
208
+ `[webserver] user FAIL | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | TikTok statusCode=${result.statusCode}`,
209
+ );
210
+ recordRequest(false);
211
+ return sendJSON(res, 502, {
212
+ error: `TikTok error (statusCode=${result.statusCode})`,
213
+ retryable: false,
214
+ tiktokStatusCode: result.statusCode,
215
+ });
216
+ }
217
+
218
+ console.error(
219
+ `[webserver] user OK | ${clientIP} | @${body.uniqueId} | ${elapsed}ms | verified=${result.verified} followers=${result.followerCount}`,
220
+ );
221
+ recordRequest(true);
166
222
  return sendJSON(res, 200, result);
167
223
  }
168
224
 
@@ -171,13 +227,27 @@ async function handleTiktokVideo(req, res) {
171
227
  if (!body.videoUrl) {
172
228
  return sendJSON(res, 400, { error: "missing videoUrl" });
173
229
  }
230
+ const clientIP = getClientIP(req);
231
+ const startTime = Date.now();
174
232
  const scraper = await getScraper();
175
233
  const result = await scraper.getVideoInfo(body.videoUrl);
234
+ const elapsed = Date.now() - startTime;
235
+
176
236
  if (!result) {
177
- return sendJSON(res, 404, {
178
- error: "Video not found or failed to parse data",
237
+ console.error(
238
+ `[webserver] video FAIL | ${clientIP} | ${body.videoUrl.slice(0, 60)} | ${elapsed}ms | empty SSR / parse failed`,
239
+ );
240
+ recordRequest(false);
241
+ return sendJSON(res, 503, {
242
+ error: "Temporary failure: unable to fetch video data",
243
+ retryable: true,
179
244
  });
180
245
  }
246
+
247
+ console.error(
248
+ `[webserver] video OK | ${clientIP} | @${result.author?.uniqueId || "?"} | ${elapsed}ms | plays=${result.stats?.playCount}`,
249
+ );
250
+ recordRequest(true);
181
251
  return sendJSON(res, 200, result);
182
252
  }
183
253
 
@@ -186,13 +256,27 @@ async function handleTiktokLookup(req, res) {
186
256
  if (!body.videoUrl) {
187
257
  return sendJSON(res, 400, { error: "missing videoUrl" });
188
258
  }
259
+ const clientIP = getClientIP(req);
260
+ const startTime = Date.now();
189
261
  const scraper = await getScraper();
190
262
  const result = await scraper.getUserAndVideo(body.videoUrl);
263
+ const elapsed = Date.now() - startTime;
264
+
191
265
  if (!result) {
192
- return sendJSON(res, 404, {
193
- error: "Video not found or failed to parse data",
266
+ console.error(
267
+ `[webserver] lookup FAIL | ${clientIP} | ${body.videoUrl.slice(0, 60)} | ${elapsed}ms | empty SSR / parse failed`,
268
+ );
269
+ recordRequest(false);
270
+ return sendJSON(res, 503, {
271
+ error: "Temporary failure: unable to fetch video/user data",
272
+ retryable: true,
194
273
  });
195
274
  }
275
+
276
+ console.error(
277
+ `[webserver] lookup OK | ${clientIP} | @${result.user?.uniqueId || "?"} | ${elapsed}ms | videoId=${result.video?.id}`,
278
+ );
279
+ recordRequest(true);
196
280
  return sendJSON(res, 200, result);
197
281
  }
198
282
 
@@ -201,10 +285,7 @@ async function handleTiktokProduct(req, res) {
201
285
  if (!body.url) {
202
286
  return sendJSON(res, 400, { error: "missing url" });
203
287
  }
204
- const clientIP =
205
- req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
206
- req.socket.remoteAddress ||
207
- "";
288
+ const clientIP = getClientIP(req);
208
289
  // URL 标准化:www.tiktok.com/shop/pdp → shop.tiktok.com/us/pdp
209
290
  const normalizedUrl = body.url.replace(
210
291
  "www.tiktok.com/shop/pdp",
@@ -221,18 +302,41 @@ async function handleTiktokProduct(req, res) {
221
302
  console.error(
222
303
  `[webserver] product OK | ${clientIP} | rating=${result.rating ?? "-"} sold=${result.soldCount ?? "-"} name=${(result.productName || "").slice(0, 40)}`,
223
304
  );
305
+ recordRequest(true);
224
306
  return sendJSON(res, 200, result);
225
307
  } catch (e) {
226
308
  console.error(`[webserver] product FAIL | ${clientIP} | ${e.message}`);
309
+ recordRequest(false);
227
310
 
228
- // 如果是 captcha,先触发上报+恢复(不阻塞其他请求)
229
- // 必须在 sendJSON 之前调用,否则 sendJSON 抛异常会导致验证码通知丢失
311
+ // captcha:触发上报+恢复(不阻塞其他请求),返回 503 让 nginx 可 failover
230
312
  if (e.message?.includes("Blocked by TikTok")) {
231
313
  scheduleCaptchaRecovery(normalizedUrl, SCRAPER_CONFIG.proxyServer);
314
+ return sendJSON(res, 503, {
315
+ error: "Blocked by TikTok security check (captcha)",
316
+ retryable: true,
317
+ });
232
318
  }
233
319
 
234
- sendJSON(res, 502, { error: e.message });
235
- return;
320
+ // 网络超时类错误 → 504
321
+ if (
322
+ e.name === "HeadersTimeoutError" ||
323
+ e.name === "BodyTimeoutError" ||
324
+ e.message?.includes("timeout") ||
325
+ e.message?.includes("ETIMEDOUT") ||
326
+ e.message?.includes("ECONNREFUSED") ||
327
+ e.message?.includes("ECONNRESET")
328
+ ) {
329
+ return sendJSON(res, 504, {
330
+ error: `Upstream timeout or network error: ${e.message}`,
331
+ retryable: true,
332
+ });
333
+ }
334
+
335
+ // 其他上游错误 → 502
336
+ return sendJSON(res, 502, {
337
+ error: e.message,
338
+ retryable: true,
339
+ });
236
340
  }
237
341
  }
238
342
 
@@ -311,10 +415,16 @@ async function scheduleCaptchaRecovery(url, proxy) {
311
415
  }
312
416
 
313
417
  function handleHealth(req, res) {
314
- return sendJSON(res, 200, {
315
- status: "ok",
418
+ const degraded = isDegraded();
419
+ const recentErrorRate = recentTotal > 0 ? recentErrors / recentTotal : 0;
420
+ return sendJSON(res, degraded ? 503 : 200, {
421
+ status: degraded ? "degraded" : "ok",
316
422
  scraperReady: scraperInstance !== null && !scraperClosing,
423
+ scraperInitializing: scraperInitPromise !== null,
317
424
  consecutiveErrors,
425
+ recentErrorRate: Math.round(recentErrorRate * 100) / 100,
426
+ recentTotal,
427
+ recentErrors,
318
428
  lastWarmSec: lastWarmTime
319
429
  ? Math.round((Date.now() - lastWarmTime) / 1000)
320
430
  : null,
@@ -384,11 +494,48 @@ export async function startWebserver(
384
494
  } catch (e) {
385
495
  // 自动恢复:连续错误超过阈值时重启 scraper
386
496
  consecutiveErrors++;
497
+ recordRequest(false);
498
+
499
+ // 区分错误类型,返回正确的 HTTP 状态码供 nginx 判断
500
+ let status = 500;
501
+ let retryable = false;
502
+
503
+ if (e.name === "CDNBlockedError") {
504
+ // CDN 限流(Akamai Access Denied)→ 503 临时不可用
505
+ status = 503;
506
+ retryable = true;
507
+ } else if (e.message?.includes("Server is shutting down")) {
508
+ // 服务正在关闭 → 503
509
+ status = 503;
510
+ retryable = true;
511
+ } else if (
512
+ e.message?.includes("Timeout") ||
513
+ e.message?.includes("timeout") ||
514
+ e.name === "TimeoutError"
515
+ ) {
516
+ // 超时 → 504
517
+ status = 504;
518
+ retryable = true;
519
+ } else if (
520
+ e.message?.includes("Protocol error") ||
521
+ e.message?.includes("Target closed") ||
522
+ e.message?.includes("Browser closed") ||
523
+ e.message?.includes("Session closed")
524
+ ) {
525
+ // 浏览器/页面崩溃 → 503
526
+ status = 503;
527
+ retryable = true;
528
+ }
529
+
387
530
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
388
531
  await restartScraper(`连续 ${consecutiveErrors} 次错误`);
389
532
  }
533
+
390
534
  if (!res.headersSent) {
391
- return sendJSON(res, 500, { error: e.message });
535
+ return sendJSON(res, status, {
536
+ error: e.message,
537
+ retryable,
538
+ });
392
539
  }
393
540
  // 响应已发送(流式等场景),记录日志并销毁 socket
394
541
  console.error(