tt-help-cli-ycl 1.4.147 → 1.4.148
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 +6 -1
- package/src/cli/product-fill.js +55 -13
- package/src/watch/db-schema.js +5 -1
- package/src/watch/public/app.js +14 -15
- package/src/watch/server.js +15 -5
package/package.json
CHANGED
package/src/cli/attach.js
CHANGED
|
@@ -143,12 +143,17 @@ export async function handleAttach(options) {
|
|
|
143
143
|
|
|
144
144
|
if (webserverUrl) {
|
|
145
145
|
attachLog(`[Attach] webserver 模式: ${webserverUrl}`);
|
|
146
|
+
// webserver 端共享 scraper(poolSize 默认 8,每 slot 串行),当 -p 并发数大于
|
|
147
|
+
// poolSize 时,请求会在 webserver 内排队,端到端耗时可达数分钟(实测 270~310s)。
|
|
148
|
+
// 30s 超时太短会让整批任务被误判失败(aborted due to timeout),故放宽到 10 分钟
|
|
149
|
+
// 作为兜底——webserver 进程若真的挂了,连接会立即断开报错,不会真的空等 10 分钟。
|
|
150
|
+
const WEBSERVER_FETCH_TIMEOUT_MS = 600000;
|
|
146
151
|
getUserInfo = async (uniqueId) => {
|
|
147
152
|
const res = await fetch(`${webserverUrl}/api/tiktok/user`, {
|
|
148
153
|
method: "POST",
|
|
149
154
|
headers: { "Content-Type": "application/json" },
|
|
150
155
|
body: JSON.stringify({ uniqueId }),
|
|
151
|
-
signal: AbortSignal.timeout(
|
|
156
|
+
signal: AbortSignal.timeout(WEBSERVER_FETCH_TIMEOUT_MS),
|
|
152
157
|
});
|
|
153
158
|
let data = null;
|
|
154
159
|
try {
|
package/src/cli/product-fill.js
CHANGED
|
@@ -342,29 +342,68 @@ export async function handleProductFill(options) {
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
/** CDP 直抓:验证码在已登录浏览器中弹出时,等待人工解决后自动重试 */
|
|
345
|
-
const fetchViaCDP = async (seoUrl) => {
|
|
345
|
+
const fetchViaCDP = async (seoUrl, uniqueId) => {
|
|
346
346
|
try {
|
|
347
347
|
return await getProductInfoCDP(page, seoUrl);
|
|
348
348
|
} catch (e) {
|
|
349
349
|
if (!e.message?.includes("Blocked by TikTok")) throw e;
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
350
|
+
// 撞码即向服务端报一次(errorType: captcha),之后无论人工解决、
|
|
351
|
+
// 自动放行还是超时冷却,本任务流程不再重复上报
|
|
352
|
+
await apiPost(`${serverUrl}/api/error-report`, {
|
|
353
|
+
userId,
|
|
354
|
+
username: uniqueId,
|
|
355
|
+
errorType: "captcha",
|
|
356
|
+
errorMessage: e.message,
|
|
357
|
+
stage: "product-fill",
|
|
358
|
+
}).catch(() => {});
|
|
359
|
+
// 验证码拦截统一等待(上限 3 分钟):容器可能稍后才渲染(HTML 已含
|
|
360
|
+
// 标记、DOM 未挂载),也可能全程不出现(风控软拦/结构变化)。
|
|
361
|
+
// 轮询期间容器出现 → 交人工解决;容器未出现但页面恢复出商品数据 →
|
|
362
|
+
// 直接重试;到点仍拦截 → 抛 captcha 错误走 30s 冷却
|
|
363
|
+
log(
|
|
364
|
+
` ⏳ 验证码拦截,等待处理(上限 ${CDP_CAPTCHA_WAIT_MS / 1000}s,可在浏览器中人工解决)...`,
|
|
365
|
+
);
|
|
366
|
+
const deadline = Date.now() + CDP_CAPTCHA_WAIT_MS;
|
|
367
|
+
let cap = await detectCaptcha(page);
|
|
368
|
+
while (!cap && Date.now() < deadline) {
|
|
369
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
370
|
+
cap = await detectCaptcha(page);
|
|
371
|
+
if (!cap) {
|
|
372
|
+
// 容器始终没出现:探测页面是否已自动放行
|
|
373
|
+
const html = await page.content().catch(() => "");
|
|
374
|
+
if (
|
|
375
|
+
html.includes('"product_model"') ||
|
|
376
|
+
html.includes('"review_ratings"')
|
|
377
|
+
) {
|
|
378
|
+
log(" ✓ 拦截已自动解除,重新抓取...");
|
|
379
|
+
return getProductInfoCDP(page, seoUrl);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (cap) {
|
|
384
|
+
log(" ⏳ 请在已登录的浏览器窗口中手动解决验证码...");
|
|
354
385
|
const r = await waitAndGetCaptcha(page, {
|
|
355
|
-
waitMs:
|
|
386
|
+
waitMs: Math.max(0, deadline - Date.now()),
|
|
356
387
|
pollInterval: 5000,
|
|
357
388
|
log,
|
|
358
389
|
});
|
|
359
390
|
if (r.resolved) {
|
|
360
391
|
log(" ✓ 验证码已解决,重新抓取...");
|
|
361
|
-
|
|
392
|
+
try {
|
|
393
|
+
return await getProductInfoCDP(page, seoUrl);
|
|
394
|
+
} catch (retryErr) {
|
|
395
|
+
// 重试又撞码:仍标记 captcha 走冷却,避免误计 noteFail 哨兵
|
|
396
|
+
if (retryErr.message?.includes("Blocked by TikTok")) {
|
|
397
|
+
retryErr.captcha = true;
|
|
398
|
+
}
|
|
399
|
+
throw retryErr;
|
|
400
|
+
}
|
|
362
401
|
}
|
|
363
402
|
}
|
|
364
403
|
const err = new Error(
|
|
365
|
-
cap
|
|
404
|
+
cap
|
|
366
405
|
? "验证码等待人工解决超时"
|
|
367
|
-
: "
|
|
406
|
+
: "验证码拦截(3 分钟内未出现容器且未自动解除)",
|
|
368
407
|
);
|
|
369
408
|
err.captcha = true;
|
|
370
409
|
throw err;
|
|
@@ -372,10 +411,10 @@ export async function handleProductFill(options) {
|
|
|
372
411
|
};
|
|
373
412
|
|
|
374
413
|
/** 抓取商品详情,带浏览器断连恢复与空数据防御 */
|
|
375
|
-
const getTaskDetail = async (seoUrl, country) => {
|
|
414
|
+
const getTaskDetail = async (seoUrl, country, uniqueId) => {
|
|
376
415
|
let detail;
|
|
377
416
|
try {
|
|
378
|
-
detail = await fetchViaCDP(seoUrl);
|
|
417
|
+
detail = await fetchViaCDP(seoUrl, uniqueId);
|
|
379
418
|
} catch (e) {
|
|
380
419
|
if (isBrowserDisconnected(browser, e)) {
|
|
381
420
|
// 确凿断连(CDP 断开 / 明确关闭消息)才重启浏览器
|
|
@@ -399,7 +438,7 @@ export async function handleProductFill(options) {
|
|
|
399
438
|
err.retryable = true;
|
|
400
439
|
throw err;
|
|
401
440
|
}
|
|
402
|
-
detail = await fetchViaCDP(seoUrl);
|
|
441
|
+
detail = await fetchViaCDP(seoUrl, uniqueId);
|
|
403
442
|
} else if (isNetworkError(e)) {
|
|
404
443
|
// 网络层错误:代理断连/Clash 切节点,浏览器没死,不杀浏览器,
|
|
405
444
|
// 记为可重试错误跳过本轮,下轮代理探测自然跟上
|
|
@@ -492,7 +531,7 @@ export async function handleProductFill(options) {
|
|
|
492
531
|
}
|
|
493
532
|
log(` → 补商品 @${uniqueId} ...`);
|
|
494
533
|
try {
|
|
495
|
-
const detail = await getTaskDetail(seoUrl, profile.country);
|
|
534
|
+
const detail = await getTaskDetail(seoUrl, profile.country, uniqueId);
|
|
496
535
|
const post = await apiPost(
|
|
497
536
|
`${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
|
|
498
537
|
{ productDetail: detail },
|
|
@@ -518,6 +557,9 @@ export async function handleProductFill(options) {
|
|
|
518
557
|
log(
|
|
519
558
|
` ⏳ @${uniqueId} 验证码拦截,冷却 ${CAPTCHA_BACKOFF_MS / 1000} 秒`,
|
|
520
559
|
);
|
|
560
|
+
// 验证码是全局风控:立即终止本批次剩余任务,避免批次内
|
|
561
|
+
// 继续 goto 顶着风控连刷;30s 冷却在 while 顶部领取前生效
|
|
562
|
+
break;
|
|
521
563
|
} else if (e.delisted) {
|
|
522
564
|
// 壳页/下架:seller_id 为空且无销售数据 → 立即哨兵放弃
|
|
523
565
|
batchSkipped++;
|
package/src/watch/db-schema.js
CHANGED
|
@@ -103,6 +103,9 @@ export function initDb(filePath) {
|
|
|
103
103
|
db = new Database(dbPath);
|
|
104
104
|
db.pragma("journal_mode = WAL");
|
|
105
105
|
db.pragma("busy_timeout = 5000");
|
|
106
|
+
// 调大 SQLite 页缓存:主库 7.3GB,task_queue 等热表的回表若走磁盘 I/O 会非常慢
|
|
107
|
+
// (实测同规模数据 16MB 缓存 1.5s → 256MB 1.1s)。512MB 足以让 task_queue 全量驻留内存。
|
|
108
|
+
db.pragma("cache_size = -524288");
|
|
106
109
|
|
|
107
110
|
// ===== users 表 =====
|
|
108
111
|
db.exec(`
|
|
@@ -423,10 +426,11 @@ export function initDb(filePath) {
|
|
|
423
426
|
`CREATE INDEX IF NOT EXISTS idx_jobs_list_done ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
|
|
424
427
|
`CREATE INDEX IF NOT EXISTS idx_jobs_list_error ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'error'`,
|
|
425
428
|
`CREATE INDEX IF NOT EXISTS idx_jobs_list_restricted ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'restricted'`,
|
|
426
|
-
// jobs_done 冷归档表索引(done 列表 / 国家统计 /
|
|
429
|
+
// jobs_done 冷归档表索引(done 列表 / 国家统计 / 重检 / no_video 计数)
|
|
427
430
|
`CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
|
|
428
431
|
`CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)`,
|
|
429
432
|
`CREATE INDEX IF NOT EXISTS idx_jobs_done_status_updated ON jobs_done(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
|
|
433
|
+
`CREATE INDEX IF NOT EXISTS idx_jobs_done_novideo ON jobs_done(no_video) WHERE no_video = 1`,
|
|
430
434
|
];
|
|
431
435
|
for (const sql of indexes) {
|
|
432
436
|
db.exec(sql);
|
package/src/watch/public/app.js
CHANGED
|
@@ -5286,21 +5286,20 @@ function renderCmdRows(myCmds, userId, procs, isOnline, llmAnalysis, clientId) {
|
|
|
5286
5286
|
styled(
|
|
5287
5287
|
"span",
|
|
5288
5288
|
"display:flex;gap:2px",
|
|
5289
|
-
(
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
: "") +
|
|
5289
|
+
tag(
|
|
5290
|
+
"button",
|
|
5291
|
+
{
|
|
5292
|
+
onclick: "wdToggleClientCmd('" + esc(userId) + "'," + idx + ")",
|
|
5293
|
+
style:
|
|
5294
|
+
"padding:1px 5px;font-size:11px;background:" +
|
|
5295
|
+
(c.enabled ? "#f59e0b" : "#4ade80") +
|
|
5296
|
+
";color:#000;border:none;border-radius:2px;cursor:pointer",
|
|
5297
|
+
title:
|
|
5298
|
+
(c.enabled ? "停止" : "启动") +
|
|
5299
|
+
(isOnline ? "" : "(设备离线,上线后生效)"),
|
|
5300
|
+
},
|
|
5301
|
+
c.enabled ? "⏹" : "▶",
|
|
5302
|
+
) +
|
|
5304
5303
|
tag(
|
|
5305
5304
|
"button",
|
|
5306
5305
|
{
|
package/src/watch/server.js
CHANGED
|
@@ -104,14 +104,16 @@ const STAT_GROUPS = {
|
|
|
104
104
|
SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
|
|
105
105
|
FROM jobs
|
|
106
106
|
UNION ALL
|
|
107
|
+
-- jobs_done 冷归档表:全部是 done 且只增不改。改用覆盖索引/部分索引计数,
|
|
108
|
+
-- 避免对 147 万行全表回表(原 SUM(CASE WHEN no_video=1) 无法匹配索引)
|
|
107
109
|
SELECT
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
c.cnt as jobsTotal,
|
|
111
|
+
c.cnt as done,
|
|
110
112
|
0 as error,
|
|
111
113
|
0 as restricted,
|
|
112
114
|
0 as targetUsers,
|
|
113
|
-
|
|
114
|
-
FROM jobs_done
|
|
115
|
+
(SELECT COUNT(*) FROM jobs_done WHERE no_video = 1) as noVideo
|
|
116
|
+
FROM (SELECT COUNT(*) as cnt FROM jobs_done) c
|
|
115
117
|
)`,
|
|
116
118
|
},
|
|
117
119
|
{
|
|
@@ -389,8 +391,15 @@ function computeStatsIncremental(st) {
|
|
|
389
391
|
};
|
|
390
392
|
}
|
|
391
393
|
|
|
392
|
-
// 快项失效:任务领取/提交后调用,下一次 /api/stats 请求即触发 fast
|
|
394
|
+
// 快项失效:任务领取/提交后调用,下一次 /api/stats 请求即触发 fast 组后台刷新。
|
|
395
|
+
// 节流:53 个采集进程高频领/交任务,若每次都失效会导致 fast 组几乎每 10s 全量重算,
|
|
396
|
+
// 把失效限制为至少间隔 FAST_DIRTY_MIN_INTERVAL,重算节奏由 fast TTL(30s) 主导。
|
|
397
|
+
let lastFastDirtyTime = 0;
|
|
398
|
+
const FAST_DIRTY_MIN_INTERVAL = 30 * 1000;
|
|
393
399
|
function markFastStatsDirty() {
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
if (now - lastFastDirtyTime < FAST_DIRTY_MIN_INTERVAL) return;
|
|
402
|
+
lastFastDirtyTime = now;
|
|
394
403
|
groupState.fast.cacheTime = 0;
|
|
395
404
|
}
|
|
396
405
|
|
|
@@ -579,6 +588,7 @@ export function startWatchServer(
|
|
|
579
588
|
"CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status)",
|
|
580
589
|
"CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'",
|
|
581
590
|
"CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)",
|
|
591
|
+
"CREATE INDEX IF NOT EXISTS idx_jobs_done_novideo ON jobs_done(no_video) WHERE no_video = 1",
|
|
582
592
|
"CREATE INDEX IF NOT EXISTS idx_videos_user_update ON videos(user_update_count)",
|
|
583
593
|
// product-fill 领任务查询的部分索引:只索引候选行,避免全表扫描 164 万行
|
|
584
594
|
"CREATE INDEX IF NOT EXISTS idx_jobs_product_fill ON jobs(claimed_at, updated_at) WHERE top_video_seo_url IS NOT NULL AND top_video_seo_url != '' AND (product_name IS NULL OR product_name = '') AND product_rating IS NULL AND product_sold_count IS NULL",
|