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 +1 -1
- package/src/webserver/server.mjs +164 -17
package/package.json
CHANGED
package/src/webserver/server.mjs
CHANGED
|
@@ -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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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,
|
|
535
|
+
return sendJSON(res, status, {
|
|
536
|
+
error: e.message,
|
|
537
|
+
retryable,
|
|
538
|
+
});
|
|
392
539
|
}
|
|
393
540
|
// 响应已发送(流式等场景),记录日志并销毁 socket
|
|
394
541
|
console.error(
|