tt-help-cli-ycl 1.4.51 → 1.4.53
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/attach.js +80 -49
- package/src/lib/args.js +11 -1
- package/src/lib/constants.js +4 -2
- package/src/webserver/server.mjs +9 -1
package/package.json
CHANGED
package/src/cli/attach.js
CHANGED
|
@@ -57,11 +57,13 @@ export async function handleAttach(options) {
|
|
|
57
57
|
attachCountries,
|
|
58
58
|
customProxy,
|
|
59
59
|
attachPoolSize,
|
|
60
|
+
attachWebserver,
|
|
60
61
|
showHelp,
|
|
61
62
|
} = options;
|
|
63
|
+
const webserverUrl = attachWebserver || null;
|
|
62
64
|
const effectiveProxy = customProxy || configuredProxy;
|
|
63
65
|
|
|
64
|
-
if (effectiveProxy) {
|
|
66
|
+
if (effectiveProxy && !webserverUrl) {
|
|
65
67
|
attachLog(`[Attach] 使用代理: ${effectiveProxy}`);
|
|
66
68
|
}
|
|
67
69
|
let shuttingDown = false;
|
|
@@ -87,12 +89,18 @@ export async function handleAttach(options) {
|
|
|
87
89
|
attachLog(
|
|
88
90
|
" --size <N> 浏览器页面池大小(默认: 3,不建议超过 3)",
|
|
89
91
|
);
|
|
92
|
+
attachLog(
|
|
93
|
+
" --webserver <URL> webserver 模式:通过 HTTP 调用集中式 webserver,不启动本地浏览器",
|
|
94
|
+
);
|
|
90
95
|
attachLog("");
|
|
91
96
|
attachLog("说明:");
|
|
92
97
|
attachLog(
|
|
93
98
|
" 后台轮询服务端 /api/user-update-tasks 接口,自动抓取 TikTok 用户信息",
|
|
94
99
|
);
|
|
95
100
|
attachLog(" 抓取完成后通过 POST /api/user-info-batch 批量回传结果");
|
|
101
|
+
attachLog(
|
|
102
|
+
" 默认启动本地浏览器(CDP 模式),传 --webserver 则走 HTTP 接口,无需浏览器",
|
|
103
|
+
);
|
|
96
104
|
attachLog(" 浏览器崩溃时自动重启,支持长时间无人值守运行");
|
|
97
105
|
attachLog(
|
|
98
106
|
" --size 控制单个浏览器内并发的 page 数,增大可提升吞吐但可能触发 TikTok 限流",
|
|
@@ -126,10 +134,32 @@ export async function handleAttach(options) {
|
|
|
126
134
|
|
|
127
135
|
const { apiGet, apiPost } = createApiClient({ log: false });
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
// ── webserver 模式:不启动本地浏览器,通过 HTTP 调用集中式 webserver ──
|
|
138
|
+
let scraper = null;
|
|
139
|
+
let getUserInfo;
|
|
140
|
+
|
|
141
|
+
if (webserverUrl) {
|
|
142
|
+
attachLog(`[Attach] webserver 模式: ${webserverUrl}`);
|
|
143
|
+
getUserInfo = async (uniqueId) => {
|
|
144
|
+
const res = await fetch(`${webserverUrl}/api/tiktok/user`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ uniqueId }),
|
|
148
|
+
signal: AbortSignal.timeout(30000),
|
|
149
|
+
});
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
if (!res.ok || data.error) return null;
|
|
152
|
+
return data;
|
|
153
|
+
};
|
|
154
|
+
} else {
|
|
155
|
+
scraper = new TikTokScraper({
|
|
156
|
+
poolSize: attachPoolSize || 3,
|
|
157
|
+
proxyServer: effectiveProxy || null,
|
|
158
|
+
});
|
|
159
|
+
await scraper.init();
|
|
160
|
+
attachLog("[Attach] 浏览器初始化完成,开始循环接收任务...");
|
|
161
|
+
getUserInfo = (uniqueId) => scraper.getUserInfo(uniqueId);
|
|
162
|
+
}
|
|
133
163
|
const shutdown = async (signal) => {
|
|
134
164
|
if (shuttingDown) return;
|
|
135
165
|
shuttingDown = true;
|
|
@@ -152,9 +182,6 @@ export async function handleAttach(options) {
|
|
|
152
182
|
process.on("SIGTERM", onSigterm);
|
|
153
183
|
|
|
154
184
|
try {
|
|
155
|
-
await scraper.init();
|
|
156
|
-
attachLog("[Attach] 浏览器初始化完成,开始循环接收任务...");
|
|
157
|
-
|
|
158
185
|
let loopCount = 0;
|
|
159
186
|
let browserRestartCount = 0;
|
|
160
187
|
let taskBatchCount = 0;
|
|
@@ -162,8 +189,8 @@ export async function handleAttach(options) {
|
|
|
162
189
|
while (!shuttingDown) {
|
|
163
190
|
loopCount++;
|
|
164
191
|
|
|
165
|
-
//
|
|
166
|
-
if (!scraper.isAlive()) {
|
|
192
|
+
// 检查浏览器是否存活,不存活则重启(仅本地浏览器模式)
|
|
193
|
+
if (scraper && !scraper.isAlive()) {
|
|
167
194
|
attachLog(
|
|
168
195
|
`[Attach] 浏览器已关闭,正在重启 (${++browserRestartCount})...`,
|
|
169
196
|
);
|
|
@@ -174,7 +201,7 @@ export async function handleAttach(options) {
|
|
|
174
201
|
// 收到退出信号且当前无任务正在处理,则退出
|
|
175
202
|
if (shuttingDown) {
|
|
176
203
|
attachLog("[Attach] 当前任务已完成,正在关闭浏览器...");
|
|
177
|
-
await scraper.close().catch(() => {});
|
|
204
|
+
if (scraper) await scraper.close().catch(() => {});
|
|
178
205
|
if (forceExitTimer) {
|
|
179
206
|
clearTimeout(forceExitTimer);
|
|
180
207
|
forceExitTimer = null;
|
|
@@ -192,12 +219,14 @@ export async function handleAttach(options) {
|
|
|
192
219
|
);
|
|
193
220
|
|
|
194
221
|
if (!tasks || tasks.length === 0) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
222
|
+
if (scraper) {
|
|
223
|
+
const heap = getHeapUsage();
|
|
224
|
+
if (heap.ratio >= HEAP_RESTART_RATIO) {
|
|
225
|
+
await recycleScraper(
|
|
226
|
+
scraper,
|
|
227
|
+
`空闲时堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
201
230
|
}
|
|
202
231
|
if (loopCount === 1) {
|
|
203
232
|
attachLog(`[Attach] 当前无待更新任务,${attachInterval}秒后重试...`);
|
|
@@ -223,7 +252,7 @@ export async function handleAttach(options) {
|
|
|
223
252
|
: "";
|
|
224
253
|
attachLog(` → 获取 @${uniqueId} 的用户信息...${countryTag}`);
|
|
225
254
|
try {
|
|
226
|
-
const info = await
|
|
255
|
+
const info = await getUserInfo(uniqueId);
|
|
227
256
|
return { uniqueId, info, error: null };
|
|
228
257
|
} catch (err) {
|
|
229
258
|
return { uniqueId, info: null, error: err };
|
|
@@ -319,38 +348,40 @@ export async function handleAttach(options) {
|
|
|
319
348
|
|
|
320
349
|
attachLog(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
|
|
321
350
|
|
|
322
|
-
// CDN 限流比例超过 30% 时,冷却 +
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
351
|
+
// CDN 限流比例超过 30% 时,冷却 + 重启浏览器(仅本地浏览器模式)
|
|
352
|
+
if (scraper) {
|
|
353
|
+
const cdnRatio = cdnBlockedCount / tasks.length;
|
|
354
|
+
if (cdnRatio > 0.3) {
|
|
355
|
+
const coolDownSeconds = cdnRatio > 0.8 ? 120 : 60;
|
|
356
|
+
attachLog(
|
|
357
|
+
` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),冷却 ${coolDownSeconds} 秒后重启浏览器...`,
|
|
358
|
+
);
|
|
359
|
+
await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
|
|
360
|
+
await recycleScraper(
|
|
361
|
+
scraper,
|
|
362
|
+
`CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
|
|
363
|
+
);
|
|
364
|
+
browserRestartCount++;
|
|
365
|
+
taskBatchCount = 0;
|
|
366
|
+
}
|
|
337
367
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
368
|
+
const heap = getHeapUsage();
|
|
369
|
+
if (heap.ratio >= HEAP_RESTART_RATIO) {
|
|
370
|
+
await recycleScraper(
|
|
371
|
+
scraper,
|
|
372
|
+
`本批后堆占用 ${(heap.ratio * 100).toFixed(1)}%`,
|
|
373
|
+
);
|
|
374
|
+
browserRestartCount++;
|
|
375
|
+
taskBatchCount = 0;
|
|
376
|
+
} else if (taskBatchCount >= MAX_TASK_BATCHES_BEFORE_RESTART) {
|
|
377
|
+
await recycleScraper(scraper, `已连续处理 ${taskBatchCount} 批任务`);
|
|
378
|
+
browserRestartCount++;
|
|
379
|
+
taskBatchCount = 0;
|
|
380
|
+
}
|
|
351
381
|
|
|
352
|
-
|
|
353
|
-
|
|
382
|
+
if (needRestart) {
|
|
383
|
+
attachLog("[Attach] 检测到浏览器异常,将在下一轮重启...");
|
|
384
|
+
}
|
|
354
385
|
}
|
|
355
386
|
|
|
356
387
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -367,7 +398,7 @@ export async function handleAttach(options) {
|
|
|
367
398
|
process.removeListener("SIGINT", onSigint);
|
|
368
399
|
process.removeListener("SIGTERM", onSigterm);
|
|
369
400
|
// 只有在非信号退出时才关闭浏览器(信号退出已在循环内关闭)
|
|
370
|
-
if (!shuttingDown) {
|
|
401
|
+
if (!shuttingDown && scraper) {
|
|
371
402
|
await scraper.close().catch(() => {});
|
|
372
403
|
}
|
|
373
404
|
}
|
package/src/lib/args.js
CHANGED
|
@@ -418,7 +418,13 @@ function parseWebserverArgs(args) {
|
|
|
418
418
|
} else if (arg === "--proxy") {
|
|
419
419
|
webserverProxy = args[++i] || null;
|
|
420
420
|
} else if (arg === "--notify") {
|
|
421
|
-
|
|
421
|
+
const next = args[i + 1];
|
|
422
|
+
// 没写地址或下一个是别的参数 → 使用默认 watch 服务器
|
|
423
|
+
if (!next || next.startsWith("--")) {
|
|
424
|
+
webserverNotify = "http://117.71.53.99:17301";
|
|
425
|
+
} else {
|
|
426
|
+
webserverNotify = args[++i];
|
|
427
|
+
}
|
|
422
428
|
}
|
|
423
429
|
}
|
|
424
430
|
|
|
@@ -621,6 +627,7 @@ function parseAttachArgs(args) {
|
|
|
621
627
|
let countries = [];
|
|
622
628
|
let customProxy = null;
|
|
623
629
|
let poolSize = 3;
|
|
630
|
+
let attachWebserver = null;
|
|
624
631
|
|
|
625
632
|
for (let i = 0; i < args.length; i++) {
|
|
626
633
|
const arg = args[i];
|
|
@@ -639,6 +646,8 @@ function parseAttachArgs(args) {
|
|
|
639
646
|
customProxy = args[++i];
|
|
640
647
|
} else if (arg === "--size") {
|
|
641
648
|
poolSize = parseInt(args[++i], 10) || 3;
|
|
649
|
+
} else if (arg === "--webserver") {
|
|
650
|
+
attachWebserver = args[++i];
|
|
642
651
|
}
|
|
643
652
|
}
|
|
644
653
|
|
|
@@ -650,6 +659,7 @@ function parseAttachArgs(args) {
|
|
|
650
659
|
attachCountries: countries,
|
|
651
660
|
customProxy,
|
|
652
661
|
attachPoolSize: poolSize,
|
|
662
|
+
attachWebserver,
|
|
653
663
|
urls: [],
|
|
654
664
|
outputFormat: "json",
|
|
655
665
|
exploreCount: 0,
|
package/src/lib/constants.js
CHANGED
|
@@ -188,12 +188,13 @@ const HELP_TEXT = [
|
|
|
188
188
|
" 示例: tt-help info https://www.tiktok.com/@nike",
|
|
189
189
|
" tt-help info https://www.tiktok.com/@nike/video/7234567890 --onlyvideo",
|
|
190
190
|
"",
|
|
191
|
-
" tt-help attach [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表]",
|
|
191
|
+
" tt-help attach [-p 并行数] [-i 间隔秒数] [-s 服务端地址] [-c 国家列表] [--webserver URL]",
|
|
192
192
|
" 后台轮询服务端任务接口,自动抓取 TikTok 用户信息",
|
|
193
193
|
" -p, --parallel <N> 并行抓取数(默认: 1)",
|
|
194
194
|
" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
|
|
195
195
|
" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
|
|
196
196
|
" -c, --countries <A,B> 猜测国家列表(逗号分隔,如 PL,DE,FR)",
|
|
197
|
+
" --webserver <URL> webserver 模式(不启动本地浏览器,走 HTTP 接口)",
|
|
197
198
|
" 示例: tt-help attach -p 5 -i 10",
|
|
198
199
|
" tt-help attach -c PL,DE,FR -p 5",
|
|
199
200
|
"",
|
|
@@ -246,13 +247,14 @@ const HELP_TEXT = [
|
|
|
246
247
|
" -p, --port <端口号> 监听端口,默认 3000",
|
|
247
248
|
" --pool-size <数量> 浏览器页面池大小,默认 8",
|
|
248
249
|
" --proxy <地址> HTTP 代理(如 127.0.0.1:7897)",
|
|
250
|
+
" --notify [地址] 验证码通知,默认 http://117.71.53.99:17301",
|
|
249
251
|
" API:",
|
|
250
252
|
" GET /health 健康检查",
|
|
251
253
|
" GET / 服务信息",
|
|
252
254
|
" POST /api/tiktok/user 获取用户信息 { uniqueId: string }",
|
|
253
255
|
" POST /api/tiktok/video 获取视频信息 { videoUrl: string }",
|
|
254
256
|
" POST /api/tiktok/lookup 同时获取视频和作者信息 { videoUrl: string }",
|
|
255
|
-
" 示例: tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897",
|
|
257
|
+
" 示例: tt-help webserver -p 3000 --pool-size 8 --proxy 127.0.0.1:7897 --notify",
|
|
256
258
|
"",
|
|
257
259
|
" comments [选项]",
|
|
258
260
|
" 评论采集:从视频评论中发现新用户,来源标记为 comment",
|
package/src/webserver/server.mjs
CHANGED
|
@@ -200,6 +200,10 @@ async function handleTiktokProduct(req, res) {
|
|
|
200
200
|
if (!body.url) {
|
|
201
201
|
return sendJSON(res, 400, { error: "missing url" });
|
|
202
202
|
}
|
|
203
|
+
const clientIP =
|
|
204
|
+
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
|
205
|
+
req.socket.remoteAddress ||
|
|
206
|
+
"";
|
|
203
207
|
// URL 标准化:www.tiktok.com/shop/pdp → shop.tiktok.com/us/pdp
|
|
204
208
|
const normalizedUrl = body.url.replace(
|
|
205
209
|
"www.tiktok.com/shop/pdp",
|
|
@@ -213,9 +217,13 @@ async function handleTiktokProduct(req, res) {
|
|
|
213
217
|
captchaFallback: false,
|
|
214
218
|
},
|
|
215
219
|
);
|
|
220
|
+
console.error(
|
|
221
|
+
`[webserver] product OK | ${clientIP} | rating=${result.rating ?? "-"} sold=${result.soldCount ?? "-"} name=${(result.productName || "").slice(0, 40)}`,
|
|
222
|
+
);
|
|
216
223
|
return sendJSON(res, 200, result);
|
|
217
224
|
} catch (e) {
|
|
218
225
|
// 先返回错误,不阻塞请求
|
|
226
|
+
console.error(`[webserver] product FAIL | ${clientIP} | ${e.message}`);
|
|
219
227
|
sendJSON(res, 502, { error: e.message });
|
|
220
228
|
|
|
221
229
|
// 如果是 captcha,异步弹浏览器(不阻塞其他请求)
|
|
@@ -244,7 +252,7 @@ async function scheduleCaptchaRecovery(url, proxy) {
|
|
|
244
252
|
headers: { "Content-Type": "application/json" },
|
|
245
253
|
body: JSON.stringify({
|
|
246
254
|
userId: `webserver-${process.env.HOSTNAME || os.hostname() || "unknown"}`,
|
|
247
|
-
errorType: "
|
|
255
|
+
errorType: "captcha",
|
|
248
256
|
errorMessage:
|
|
249
257
|
"TikTok Shop captcha detected, browser opened - manual intervention required",
|
|
250
258
|
stage: "product_info",
|