tt-help-cli-ycl 1.4.146 → 1.4.147

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.147",
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
  );
@@ -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;
@@ -290,23 +372,43 @@ export async function handleProductFill(options) {
290
372
  };
291
373
 
292
374
  /** 抓取商品详情,带浏览器断连恢复与空数据防御 */
293
- const getTaskDetail = async (seoUrl) => {
375
+ const getTaskDetail = async (seoUrl, country) => {
294
376
  let detail;
295
377
  try {
296
378
  detail = await fetchViaCDP(seoUrl);
297
379
  } 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;
380
+ if (isBrowserDisconnected(browser, e)) {
381
+ // 确凿断连(CDP 断开 / 明确关闭消息)才重启浏览器
382
+ log(" ↻ 浏览器断连,重启浏览器后重试...");
383
+ try {
384
+ const cdpOptions = { port: currentPort };
385
+ if (resolvedProxy) cdpOptions.proxyServer = resolvedProxy;
386
+ browser = await relaunchBrowser(cdpOptions, currentPort);
387
+ page = await getOrCreatePage(browser);
388
+ launchedPorts.add(currentPort); // 重启拉起的实例归本进程管,退出时关闭
389
+ } catch (relaunchErr) {
390
+ relaunchErr.retryable = true;
391
+ throw relaunchErr;
392
+ }
393
+ // 重启后先确认代理出口恢复正常,避免刚拉起又撞上代理断连窗口
394
+ await refreshProxyCountry();
395
+ if (!proxyCountry || (country && proxyCountry !== country)) {
396
+ const err = new Error(
397
+ `浏览器重启后代理出口异常(${proxyCountry || "探测失败"}),本轮跳过`,
398
+ );
399
+ err.retryable = true;
400
+ throw err;
401
+ }
402
+ detail = await fetchViaCDP(seoUrl);
403
+ } else if (isNetworkError(e)) {
404
+ // 网络层错误:代理断连/Clash 切节点,浏览器没死,不杀浏览器,
405
+ // 记为可重试错误跳过本轮,下轮代理探测自然跟上
406
+ const err = new Error(`网络层错误(浏览器未重启): ${e.message}`);
407
+ err.retryable = true;
408
+ throw err;
409
+ } else {
410
+ throw e;
308
411
  }
309
- detail = await fetchViaCDP(seoUrl);
310
412
  }
311
413
  // 空数据防御:回写字段全空,不回写(防止假成功死循环)
312
414
  if (isEmptyProductDetail(detail)) {
@@ -390,7 +492,7 @@ export async function handleProductFill(options) {
390
492
  }
391
493
  log(` → 补商品 @${uniqueId} ...`);
392
494
  try {
393
- const detail = await getTaskDetail(seoUrl);
495
+ const detail = await getTaskDetail(seoUrl, profile.country);
394
496
  const post = await apiPost(
395
497
  `${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
396
498
  { productDetail: detail },
@@ -441,6 +543,8 @@ export async function handleProductFill(options) {
441
543
  await noteFail(uniqueId);
442
544
  }
443
545
  }
546
+ // 每个任务处理完后随机延迟(防风控):N/2 ~ N 秒,Ctrl+C 可中断
547
+ if (!shuttingDown) await taskRandomDelay();
444
548
  }
445
549
 
446
550
  log(
@@ -453,6 +557,26 @@ export async function handleProductFill(options) {
453
557
  throw err;
454
558
  } finally {
455
559
  if (forceExitTimer) clearTimeout(forceExitTimer);
560
+ // 退出清理:关闭本进程启动的浏览器实例。
561
+ // CDP 的 browser.close() 只断开连接不关浏览器进程,需按端口 kill;
562
+ // 只杀 launchedPorts 里的端口(本进程拉起的),用户自己开着的 Edge 不动
563
+ if (browser) {
564
+ await browser.close().catch(() => {});
565
+ }
566
+ if (launchedPorts.size > 0) {
567
+ log(
568
+ `[ProductFill] 正在关闭浏览器(端口 ${[...launchedPorts].join(", ")})...`,
569
+ );
570
+ await Promise.all(
571
+ [...launchedPorts].map((port) =>
572
+ Promise.race([
573
+ killEdgeProcesses(undefined, port),
574
+ new Promise((r) => setTimeout(r, 5000)),
575
+ ]),
576
+ ),
577
+ );
578
+ log(`[ProductFill] 浏览器已关闭`);
579
+ }
456
580
  log(
457
581
  `[ProductFill] 已退出。总计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped} | 标记不可售 ${totalMarked}`,
458
582
  );
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",