tt-help-cli-ycl 1.4.146 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.146",
3
+ "version": "1.4.148",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -138,6 +138,37 @@ try {
138
138
  assert.strictEqual(parsed.subcommand, "product-fill"),
139
139
  );
140
140
  check("-l 3 生效", () => assert.strictEqual(parsed.productFillLimit, 3));
141
+ check("--task-delay 未传时默认 5", () =>
142
+ assert.strictEqual(parsed.productFillTaskDelay, 5),
143
+ );
144
+
145
+ process.argv = [
146
+ "node",
147
+ "tt-help",
148
+ "product-fill",
149
+ "--profiles",
150
+ "US/9223",
151
+ "--task-delay",
152
+ "30",
153
+ ];
154
+ const parsedDelay = parseArgs();
155
+ check("--task-delay 30 生效", () =>
156
+ assert.strictEqual(parsedDelay.productFillTaskDelay, 30),
157
+ );
158
+
159
+ process.argv = [
160
+ "node",
161
+ "tt-help",
162
+ "product-fill",
163
+ "--profiles",
164
+ "US/9223",
165
+ "--task-delay",
166
+ "0",
167
+ ];
168
+ const parsedDelayZero = parseArgs();
169
+ check("--task-delay 0 生效(关闭任务间延迟)", () =>
170
+ assert.strictEqual(parsedDelayZero.productFillTaskDelay, 0),
171
+ );
141
172
 
142
173
  process.argv = [
143
174
  "node",
@@ -163,6 +194,99 @@ try {
163
194
  process.argv = origArgv;
164
195
  }
165
196
 
197
+ console.log("\n[6] 浏览器断连判定(网络层错误不重启,防误杀 Edge)");
198
+ const { isBrowserDisconnected, isNetworkError, isCDPPortReady } =
199
+ await import("../src/cli/product-fill.js");
200
+
201
+ const onlineBrowser = { isConnected: () => true };
202
+ const offlineBrowser = { isConnected: () => false };
203
+
204
+ check("net::ERR_CONNECTION_CLOSED + 浏览器在线 → 不断连", () => {
205
+ assert.strictEqual(
206
+ isBrowserDisconnected(
207
+ onlineBrowser,
208
+ new Error("page.goto: net::ERR_CONNECTION_CLOSED at https://..."),
209
+ ),
210
+ false,
211
+ );
212
+ });
213
+ check("net::ERR_CONNECTION_RESET + 浏览器在线 → 不断连", () => {
214
+ assert.strictEqual(
215
+ isBrowserDisconnected(
216
+ onlineBrowser,
217
+ new Error("net::ERR_CONNECTION_RESET"),
218
+ ),
219
+ false,
220
+ );
221
+ });
222
+ check("net::ERR_PROXY_CONNECTION_FAILED + 浏览器在线 → 不断连", () => {
223
+ assert.strictEqual(
224
+ isBrowserDisconnected(
225
+ onlineBrowser,
226
+ new Error("net::ERR_PROXY_CONNECTION_FAILED"),
227
+ ),
228
+ false,
229
+ );
230
+ });
231
+ check("isConnected()=false → 断连", () => {
232
+ assert.strictEqual(
233
+ isBrowserDisconnected(offlineBrowser, new Error("any error")),
234
+ true,
235
+ );
236
+ });
237
+ check("Target closed + 在线 → 断连", () => {
238
+ assert.strictEqual(
239
+ isBrowserDisconnected(onlineBrowser, new Error("Target closed")),
240
+ true,
241
+ );
242
+ });
243
+ check("Browser has been closed + 在线 → 断连", () => {
244
+ assert.strictEqual(
245
+ isBrowserDisconnected(onlineBrowser, new Error("Browser has been closed")),
246
+ true,
247
+ );
248
+ });
249
+ check("Target page... closed + 在线 → 断连", () => {
250
+ assert.strictEqual(
251
+ isBrowserDisconnected(
252
+ onlineBrowser,
253
+ new Error("Target page, context or browser has been closed"),
254
+ ),
255
+ true,
256
+ );
257
+ });
258
+ check("Protocol error + 在线 → 不断连(交给错误计数处理)", () => {
259
+ assert.strictEqual(
260
+ isBrowserDisconnected(onlineBrowser, new Error("Protocol error")),
261
+ false,
262
+ );
263
+ });
264
+ check("无错误参数 → 不断连", () => {
265
+ assert.strictEqual(isBrowserDisconnected(onlineBrowser, null), false);
266
+ });
267
+ check("isNetworkError(net::ERR_*) → true", () => {
268
+ assert.strictEqual(
269
+ isNetworkError(new Error("net::ERR_CONNECTION_CLOSED")),
270
+ true,
271
+ );
272
+ });
273
+ check("isNetworkError(普通错误) → false", () => {
274
+ assert.strictEqual(isNetworkError(new Error("Timeout")), false);
275
+ });
276
+
277
+ console.log("\n[7] CDP 端口就绪探测(isCDPPortReady)");
278
+ // 未监听端口应快速返回 false(本机无 Edge 监听该端口)
279
+ const probeResult = await isCDPPortReady(65531);
280
+ if (probeResult === false) {
281
+ passed++;
282
+ console.log(" ✅ 未监听端口 → false");
283
+ } else {
284
+ console.error(
285
+ " ❌ 未监听端口 → false\n 期望 false,实得 " + probeResult,
286
+ );
287
+ process.exitCode = 1;
288
+ }
289
+
166
290
  console.log(
167
291
  `\n结果: ${passed} 通过${process.exitCode ? ", 有失败" : ", 0 失败"}`,
168
292
  );
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(30000),
156
+ signal: AbortSignal.timeout(WEBSERVER_FETCH_TIMEOUT_MS),
152
157
  });
153
158
  let data = null;
154
159
  try {
@@ -17,13 +17,13 @@
17
17
  import { ProxyAgent, request } from "undici";
18
18
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
19
19
  import { createApiClient } from "../lib/api-client.js";
20
- import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
21
- import { ensureBrowserReady } from "../lib/browser/cdp.js";
22
20
  import {
23
- getOrCreatePage,
24
- isBrowserClosedError,
25
- relaunchBrowser,
26
- } from "../lib/browser/page.js";
21
+ userId as configuredUserId,
22
+ saveUserId,
23
+ proxy as configuredProxy,
24
+ } from "../lib/constants.js";
25
+ import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
26
+ import { getOrCreatePage, relaunchBrowser } from "../lib/browser/page.js";
27
27
  import {
28
28
  getProductInfoCDP,
29
29
  isEmptyProductDetail,
@@ -37,7 +37,7 @@ const CAPTCHA_BACKOFF_MS = 30_000; // 验证码后冷却时长
37
37
  const CDP_CAPTCHA_WAIT_MS = 180_000; // CDP 模式人工解验证码最长等待
38
38
  const FAIL_MARK_LIMIT = 3; // 连续失败达到该次数后哨兵放弃
39
39
  const SENTINEL_RATING = -1; // 哨兵:商品不可售/已下架(正常评分 0-5,-1 不会冲突)
40
- const DEFAULT_PROXY = "http://127.0.0.1:7897"; // 未传 --proxy 时按本机 Clash 默认端口探测
40
+ const DEFAULT_PROXY = "http://127.0.0.1:7897"; // 未配置任何代理时,出口探测按本机 Clash 默认端口尝试
41
41
 
42
42
  function formatNow() {
43
43
  return new Date().toLocaleString("zh-CN", { hour12: false });
@@ -47,6 +47,62 @@ function log(message = "") {
47
47
  console.error(`[${formatNow()}] ${message}`);
48
48
  }
49
49
 
50
+ /**
51
+ * 判断浏览器是否真的已断开(用户手动关窗口 / CDP 断连)。
52
+ * 网络层错误(net::ERR_CONNECTION_CLOSED/RESET/REFUSED...)是代理断连/节点切换,
53
+ * 浏览器进程还活着,不算断连 —— 只认两种确凿信号:
54
+ * 1. browser.isConnected() === false(CDP 连接已断)
55
+ * 2. 明确的关闭消息(Target closed / Browser has been closed)
56
+ * 参考 tag.js 的 isBrowserDisconnected;page.js 的 isBrowserClosedError
57
+ * 会把 net::ERR_CONNECTION_* 误判为浏览器关闭,进而误杀共用端口的 Edge。
58
+ */
59
+ export function isBrowserDisconnected(browser, error) {
60
+ try {
61
+ if (
62
+ browser &&
63
+ typeof browser.isConnected === "function" &&
64
+ !browser.isConnected()
65
+ ) {
66
+ return true;
67
+ }
68
+ } catch {}
69
+ if (!error) return false;
70
+ const msg = error.message || "";
71
+ if (msg.includes("net::ERR_")) return false; // 网络层错误不代表浏览器关闭
72
+ return (
73
+ msg.includes("Target closed") ||
74
+ msg.includes("Browser has been closed") ||
75
+ msg.includes("Target page, context or browser has been closed")
76
+ );
77
+ }
78
+
79
+ /** 网络层错误(net::ERR_*):代理断连/节点切换,浏览器没死,不应触发重启 */
80
+ export function isNetworkError(error) {
81
+ if (!error) return false;
82
+ const msg = error.message || "";
83
+ return msg.includes("net::ERR_");
84
+ }
85
+
86
+ /**
87
+ * 探测 CDP 端口是否已有浏览器在监听(连接前调用,
88
+ * 用于区分"本进程启动的浏览器"和"复用的已存在实例",
89
+ * 退出时只关闭前者,不误杀用户自己开着的 Edge)。
90
+ */
91
+ export async function isCDPPortReady(port) {
92
+ try {
93
+ const { statusCode, body } = await request(
94
+ `http://127.0.0.1:${port}/json`,
95
+ { method: "GET", headersTimeout: 2000, bodyTimeout: 2000 },
96
+ );
97
+ try {
98
+ await body.dump();
99
+ } catch {}
100
+ return statusCode === 200;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
50
106
  /**
51
107
  * 通过代理探测出口 IP 国家码(ip-api.com 免费接口)。
52
108
  * 探测失败(超时/代理未开/接口异常)返回 null。
@@ -77,10 +133,15 @@ export async function handleProductFill(options) {
77
133
  productFillInterval,
78
134
  serverUrl,
79
135
  productFillProxy,
136
+ productFillTaskDelay,
80
137
  productFillProfiles,
81
138
  showHelp,
82
139
  } = options;
83
140
 
141
+ // 与其他命令口径一致:--proxy 参数 > 配置文件 ~/.tt-help.json 的 proxy;
142
+ // 均未配置则不注入 proxyServer,浏览器走系统默认代理(explore/attach 同款)
143
+ const resolvedProxy = productFillProxy || configuredProxy || null;
144
+
84
145
  if (showHelp) {
85
146
  log(
86
147
  "用法: tt-help product-fill --profiles <国家/端口>[,<国家/端口>...] [-s 服务端] [-i 间隔秒] [-l 每批上限] [--proxy URL]",
@@ -95,10 +156,13 @@ export async function handleProductFill(options) {
95
156
  );
96
157
  log(" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)");
97
158
  log(
98
- " --proxy <URL> 浏览器启动代理及出口探测(默认: http://127.0.0.1:7897)",
159
+ " --proxy <URL> 浏览器启动代理及出口探测(未指定时用配置文件 ~/.tt-help.json 的 proxy;均未配置则浏览器走系统默认代理)",
99
160
  );
100
161
  log(" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)");
101
162
  log(" -l, --limit <N> 每批领取任务数上限(默认: 5)");
163
+ log(
164
+ " --task-delay <N> 每个任务处理完后的随机延迟上限,秒(默认: 5,实际等待 N/2~N 秒随机)",
165
+ );
102
166
  log("");
103
167
  log("说明:");
104
168
  log(" 循环向服务端 /api/product-tasks?country=XX 领取缺商品详情任务;");
@@ -171,6 +235,9 @@ export async function handleProductFill(options) {
171
235
  let browser = null;
172
236
  let page = null;
173
237
  let currentPort = null;
238
+ // 本进程启动(或断连重启拉起)的浏览器端口:退出时按端口关闭;
239
+ // 连接前端口已有浏览器(用户自己开的)→ 不记录,退出时不杀
240
+ const launchedPorts = new Set();
174
241
  const connectProfile = async (profile) => {
175
242
  if (currentPort === profile.port && browser) return;
176
243
  if (browser) {
@@ -179,18 +246,21 @@ export async function handleProductFill(options) {
179
246
  page = null;
180
247
  }
181
248
  const cdpOptions = { port: profile.port };
182
- if (productFillProxy) cdpOptions.proxyServer = productFillProxy;
249
+ if (resolvedProxy) cdpOptions.proxyServer = resolvedProxy;
250
+ const wasReady = await isCDPPortReady(profile.port);
183
251
  log(
184
- `[ProductFill] 切换浏览器端口 ${currentPort ?? "-"} → ${profile.port}(country=${profile.country}${productFillProxy ? `,代理 ${productFillProxy}` : ""})`,
252
+ `[ProductFill] 切换浏览器端口 ${currentPort ?? "-"} → ${profile.port}(country=${profile.country}${resolvedProxy ? `,代理 ${resolvedProxy}` : ""})`,
185
253
  );
186
254
  browser = await ensureBrowserReady(cdpOptions);
255
+ if (!wasReady) launchedPorts.add(profile.port);
187
256
  page = await getOrCreatePage(browser);
188
257
  currentPort = profile.port;
189
258
  log(`[ProductFill] CDP 浏览器已就绪(当前页: ${page.url()})`);
190
259
  };
191
260
 
192
261
  // ── 代理出口国家探测 ──
193
- const effectiveProxyUrl = productFillProxy || DEFAULT_PROXY;
262
+ // 与浏览器启动通道保持一致;未配置任何代理时按本机 Clash 默认端口探测
263
+ const effectiveProxyUrl = resolvedProxy || DEFAULT_PROXY;
194
264
  let proxyCountry = null;
195
265
  const refreshProxyCountry = async () => {
196
266
  const prev = proxyCountry;
@@ -218,6 +288,18 @@ export async function handleProductFill(options) {
218
288
  tick();
219
289
  });
220
290
 
291
+ /**
292
+ * 任务间随机延迟(可被退出信号中断):--task-delay 为最大秒数 N,
293
+ * 实际等待 N/2 ~ N 秒随机(随机口径与 delay.js 的 delay(min, max) 一致)。
294
+ * N=0 时立即返回(关闭任务间延迟)。
295
+ */
296
+ const taskRandomDelay = () => {
297
+ const maxMs = (productFillTaskDelay ?? 0) * 1000;
298
+ const minMs = Math.floor(maxMs / 2);
299
+ const ms = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
300
+ return interruptibleSleep(ms);
301
+ };
302
+
221
303
  let loopCount = 0;
222
304
  let totalSuccess = 0;
223
305
  let totalFailed = 0;
@@ -260,29 +342,68 @@ export async function handleProductFill(options) {
260
342
  };
261
343
 
262
344
  /** CDP 直抓:验证码在已登录浏览器中弹出时,等待人工解决后自动重试 */
263
- const fetchViaCDP = async (seoUrl) => {
345
+ const fetchViaCDP = async (seoUrl, uniqueId) => {
264
346
  try {
265
347
  return await getProductInfoCDP(page, seoUrl);
266
348
  } catch (e) {
267
349
  if (!e.message?.includes("Blocked by TikTok")) throw e;
268
- // 验证码:优先检测可见容器并等待人工解决(浏览器就在本机屏幕上)
269
- const cap = await detectCaptcha(page);
270
- if (cap?.exists) {
271
- log(" ⏳ 检测到验证码,请在已登录的浏览器窗口中手动解决...");
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(" ⏳ 请在已登录的浏览器窗口中手动解决验证码...");
272
385
  const r = await waitAndGetCaptcha(page, {
273
- waitMs: CDP_CAPTCHA_WAIT_MS,
386
+ waitMs: Math.max(0, deadline - Date.now()),
274
387
  pollInterval: 5000,
275
388
  log,
276
389
  });
277
390
  if (r.resolved) {
278
391
  log(" ✓ 验证码已解决,重新抓取...");
279
- return getProductInfoCDP(page, seoUrl);
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
+ }
280
401
  }
281
402
  }
282
403
  const err = new Error(
283
- cap?.exists
404
+ cap
284
405
  ? "验证码等待人工解决超时"
285
- : "验证码拦截(无可见验证码容器)",
406
+ : "验证码拦截(3 分钟内未出现容器且未自动解除)",
286
407
  );
287
408
  err.captcha = true;
288
409
  throw err;
@@ -290,23 +411,43 @@ export async function handleProductFill(options) {
290
411
  };
291
412
 
292
413
  /** 抓取商品详情,带浏览器断连恢复与空数据防御 */
293
- const getTaskDetail = async (seoUrl) => {
414
+ const getTaskDetail = async (seoUrl, country, uniqueId) => {
294
415
  let detail;
295
416
  try {
296
- detail = await fetchViaCDP(seoUrl);
417
+ detail = await fetchViaCDP(seoUrl, uniqueId);
297
418
  } catch (e) {
298
- if (!isBrowserClosedError(e)) throw e;
299
- log(" ↻ 浏览器断连,重启浏览器后重试...");
300
- try {
301
- const cdpOptions = { port: currentPort };
302
- if (productFillProxy) cdpOptions.proxyServer = productFillProxy;
303
- browser = await relaunchBrowser(cdpOptions, currentPort);
304
- page = await getOrCreatePage(browser);
305
- } catch (relaunchErr) {
306
- relaunchErr.retryable = true;
307
- throw relaunchErr;
419
+ if (isBrowserDisconnected(browser, e)) {
420
+ // 确凿断连(CDP 断开 / 明确关闭消息)才重启浏览器
421
+ log(" ↻ 浏览器断连,重启浏览器后重试...");
422
+ try {
423
+ const cdpOptions = { port: currentPort };
424
+ if (resolvedProxy) cdpOptions.proxyServer = resolvedProxy;
425
+ browser = await relaunchBrowser(cdpOptions, currentPort);
426
+ page = await getOrCreatePage(browser);
427
+ launchedPorts.add(currentPort); // 重启拉起的实例归本进程管,退出时关闭
428
+ } catch (relaunchErr) {
429
+ relaunchErr.retryable = true;
430
+ throw relaunchErr;
431
+ }
432
+ // 重启后先确认代理出口恢复正常,避免刚拉起又撞上代理断连窗口
433
+ await refreshProxyCountry();
434
+ if (!proxyCountry || (country && proxyCountry !== country)) {
435
+ const err = new Error(
436
+ `浏览器重启后代理出口异常(${proxyCountry || "探测失败"}),本轮跳过`,
437
+ );
438
+ err.retryable = true;
439
+ throw err;
440
+ }
441
+ detail = await fetchViaCDP(seoUrl, uniqueId);
442
+ } else if (isNetworkError(e)) {
443
+ // 网络层错误:代理断连/Clash 切节点,浏览器没死,不杀浏览器,
444
+ // 记为可重试错误跳过本轮,下轮代理探测自然跟上
445
+ const err = new Error(`网络层错误(浏览器未重启): ${e.message}`);
446
+ err.retryable = true;
447
+ throw err;
448
+ } else {
449
+ throw e;
308
450
  }
309
- detail = await fetchViaCDP(seoUrl);
310
451
  }
311
452
  // 空数据防御:回写字段全空,不回写(防止假成功死循环)
312
453
  if (isEmptyProductDetail(detail)) {
@@ -390,7 +531,7 @@ export async function handleProductFill(options) {
390
531
  }
391
532
  log(` → 补商品 @${uniqueId} ...`);
392
533
  try {
393
- const detail = await getTaskDetail(seoUrl);
534
+ const detail = await getTaskDetail(seoUrl, profile.country, uniqueId);
394
535
  const post = await apiPost(
395
536
  `${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
396
537
  { productDetail: detail },
@@ -416,6 +557,9 @@ export async function handleProductFill(options) {
416
557
  log(
417
558
  ` ⏳ @${uniqueId} 验证码拦截,冷却 ${CAPTCHA_BACKOFF_MS / 1000} 秒`,
418
559
  );
560
+ // 验证码是全局风控:立即终止本批次剩余任务,避免批次内
561
+ // 继续 goto 顶着风控连刷;30s 冷却在 while 顶部领取前生效
562
+ break;
419
563
  } else if (e.delisted) {
420
564
  // 壳页/下架:seller_id 为空且无销售数据 → 立即哨兵放弃
421
565
  batchSkipped++;
@@ -441,6 +585,8 @@ export async function handleProductFill(options) {
441
585
  await noteFail(uniqueId);
442
586
  }
443
587
  }
588
+ // 每个任务处理完后随机延迟(防风控):N/2 ~ N 秒,Ctrl+C 可中断
589
+ if (!shuttingDown) await taskRandomDelay();
444
590
  }
445
591
 
446
592
  log(
@@ -453,6 +599,26 @@ export async function handleProductFill(options) {
453
599
  throw err;
454
600
  } finally {
455
601
  if (forceExitTimer) clearTimeout(forceExitTimer);
602
+ // 退出清理:关闭本进程启动的浏览器实例。
603
+ // CDP 的 browser.close() 只断开连接不关浏览器进程,需按端口 kill;
604
+ // 只杀 launchedPorts 里的端口(本进程拉起的),用户自己开着的 Edge 不动
605
+ if (browser) {
606
+ await browser.close().catch(() => {});
607
+ }
608
+ if (launchedPorts.size > 0) {
609
+ log(
610
+ `[ProductFill] 正在关闭浏览器(端口 ${[...launchedPorts].join(", ")})...`,
611
+ );
612
+ await Promise.all(
613
+ [...launchedPorts].map((port) =>
614
+ Promise.race([
615
+ killEdgeProcesses(undefined, port),
616
+ new Promise((r) => setTimeout(r, 5000)),
617
+ ]),
618
+ ),
619
+ );
620
+ log(`[ProductFill] 浏览器已关闭`);
621
+ }
456
622
  log(
457
623
  `[ProductFill] 已退出。总计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped} | 标记不可售 ${totalMarked}`,
458
624
  );
package/src/lib/args.js CHANGED
@@ -895,6 +895,7 @@ function parseProductFillArgs(args) {
895
895
  let interval = 10;
896
896
  let serverUrl = defaultServer;
897
897
  let proxyUrl = null;
898
+ let taskDelay = 5; // --task-delay:每个任务处理完后的随机延迟上限,秒
898
899
  /** 端口/国家配置:--profiles US/9223,JP/1222 → [{country:"US",port:9223},...] */
899
900
  let profiles = [];
900
901
 
@@ -919,6 +920,10 @@ function parseProductFillArgs(args) {
919
920
  interval = parseInt(args[++i], 10) || 10;
920
921
  } else if (arg === "-l" || arg === "--limit") {
921
922
  limit = parseInt(args[++i], 10) || 5;
923
+ } else if (arg === "--task-delay") {
924
+ // 允许 0(关闭任务间延迟),非法值回退默认 5
925
+ const v = parseInt(args[++i], 10);
926
+ taskDelay = Number.isFinite(v) && v >= 0 ? v : 5;
922
927
  }
923
928
  }
924
929
 
@@ -928,6 +933,7 @@ function parseProductFillArgs(args) {
928
933
  productFillInterval: interval,
929
934
  serverUrl,
930
935
  productFillProxy: proxyUrl,
936
+ productFillTaskDelay: taskDelay,
931
937
  productFillProfiles: profiles,
932
938
  urls: [],
933
939
  outputFormat: "json",
@@ -227,9 +227,10 @@ const HELP_TEXT = [
227
227
  " --profiles 国家/端口对绑定(端口 profile 区域需与商品国家一致,如 US/9223,JP/1222);",
228
228
  " 每轮探测代理出口,在配置中匹配端口领取对应国家任务,出口不在配置范围则跳过",
229
229
  " -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)",
230
- " --proxy <URL> 浏览器启动代理及出口探测(默认: http://127.0.0.1:7897)",
230
+ " --proxy <URL> 浏览器启动代理及出口探测(未指定时用配置文件 proxy;均未配置则浏览器走系统默认代理)",
231
231
  " -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)",
232
232
  " -l, --limit <N> 每批领取任务数上限(默认: 5)",
233
+ " --task-delay <N> 每个任务处理完后的随机延迟上限,秒(默认: 5,实际 N/2~N 秒随机)",
233
234
  " 下架壳页/连续失败3次/无效URL → 回写 rating=-1 哨兵永久移出候选",
234
235
  " 示例: tt-help product-fill --profiles US/9223",
235
236
  " tt-help product-fill --profiles US/9223,JP/1222 -s http://117.71.53.99:17301 -i 5",
@@ -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);
@@ -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
- (isOnline
5290
- ? tag(
5291
- "button",
5292
- {
5293
- onclick:
5294
- "wdToggleClientCmd('" + esc(userId) + "'," + idx + ")",
5295
- style:
5296
- "padding:1px 5px;font-size:11px;background:" +
5297
- (c.enabled ? "#f59e0b" : "#4ade80") +
5298
- ";color:#000;border:none;border-radius:2px;cursor:pointer",
5299
- title: c.enabled ? "停止" : "启动",
5300
- },
5301
- c.enabled ? "⏹" : "▶",
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
  {
@@ -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
- COUNT(*) as jobsTotal,
109
- COUNT(*) as done,
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
- SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
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",