tt-help-cli-ycl 1.4.52 → 1.4.54
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 +4 -0
- package/src/lib/constants.js +2 -1
- package/src/webserver/server.mjs +5 -4
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
|
@@ -627,6 +627,7 @@ function parseAttachArgs(args) {
|
|
|
627
627
|
let countries = [];
|
|
628
628
|
let customProxy = null;
|
|
629
629
|
let poolSize = 3;
|
|
630
|
+
let attachWebserver = null;
|
|
630
631
|
|
|
631
632
|
for (let i = 0; i < args.length; i++) {
|
|
632
633
|
const arg = args[i];
|
|
@@ -645,6 +646,8 @@ function parseAttachArgs(args) {
|
|
|
645
646
|
customProxy = args[++i];
|
|
646
647
|
} else if (arg === "--size") {
|
|
647
648
|
poolSize = parseInt(args[++i], 10) || 3;
|
|
649
|
+
} else if (arg === "--webserver") {
|
|
650
|
+
attachWebserver = args[++i];
|
|
648
651
|
}
|
|
649
652
|
}
|
|
650
653
|
|
|
@@ -656,6 +659,7 @@ function parseAttachArgs(args) {
|
|
|
656
659
|
attachCountries: countries,
|
|
657
660
|
customProxy,
|
|
658
661
|
attachPoolSize: poolSize,
|
|
662
|
+
attachWebserver,
|
|
659
663
|
urls: [],
|
|
660
664
|
outputFormat: "json",
|
|
661
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
|
"",
|
package/src/webserver/server.mjs
CHANGED
|
@@ -222,14 +222,15 @@ async function handleTiktokProduct(req, res) {
|
|
|
222
222
|
);
|
|
223
223
|
return sendJSON(res, 200, result);
|
|
224
224
|
} catch (e) {
|
|
225
|
-
// 先返回错误,不阻塞请求
|
|
226
225
|
console.error(`[webserver] product FAIL | ${clientIP} | ${e.message}`);
|
|
227
|
-
sendJSON(res, 502, { error: e.message });
|
|
228
226
|
|
|
229
|
-
// 如果是 captcha
|
|
227
|
+
// 如果是 captcha,先触发上报+恢复(不阻塞其他请求)
|
|
228
|
+
// 必须在 sendJSON 之前调用,否则 sendJSON 抛异常会导致验证码通知丢失
|
|
230
229
|
if (e.message?.includes("Blocked by TikTok")) {
|
|
231
230
|
scheduleCaptchaRecovery(normalizedUrl, SCRAPER_CONFIG.proxyServer);
|
|
232
231
|
}
|
|
232
|
+
|
|
233
|
+
sendJSON(res, 502, { error: e.message });
|
|
233
234
|
return;
|
|
234
235
|
}
|
|
235
236
|
}
|
|
@@ -252,7 +253,7 @@ async function scheduleCaptchaRecovery(url, proxy) {
|
|
|
252
253
|
headers: { "Content-Type": "application/json" },
|
|
253
254
|
body: JSON.stringify({
|
|
254
255
|
userId: `webserver-${process.env.HOSTNAME || os.hostname() || "unknown"}`,
|
|
255
|
-
errorType: "
|
|
256
|
+
errorType: "captcha",
|
|
256
257
|
errorMessage:
|
|
257
258
|
"TikTok Shop captcha detected, browser opened - manual intervention required",
|
|
258
259
|
stage: "product_info",
|