tt-help-cli-ycl 1.4.145 → 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.
@@ -1,34 +1,43 @@
1
1
  /**
2
2
  * product-fill 命令 — 循环到服务端领取"缺商品详情"任务,补全商品信息
3
3
  *
4
- * 与 attach 类似的多用户循环客户端:
5
- * 1. 通过 webserver(集中式)抓取商品详情(PDP 评分/销量/评论)
6
- * 2. 循环 GET /api/product-tasks 领取任务
7
- * 3. 对每个任务 POST webserver /api/tiktok/product 抓取商品详情
4
+ * 使用前提:--profiles 国家/端口对 配置端口与国家绑定(如 US/9223,JP/1222)。
5
+ * TikTok 区域判定 = 浏览器 profile 区域 + 代理出口,商品国家必须与两者
6
+ * 匹配才能抓到数据(如 9223=US profile、1222=JP profile)。
7
+ *
8
+ * 流程:
9
+ * 1. 每轮领取前探测代理出口国家,与配置不匹配则跳过本轮
10
+ * 2. GET /api/product-tasks?country=XX 领取该国家任务
11
+ * 3. CDP 直抓商品详情(评分/销量/评论),验证码弹浏览器人工解决
8
12
  * 4. 成功 → POST /api/product-fill/:uniqueId 回写 jobs 表
13
+ * 5. 下架壳页/连续失败/无效 URL → 回写 rating=-1 哨兵,永久移出候选
9
14
  *
10
- * 用法: tt-help product-fill [-s 服务端] [-w webserver] [-i 间隔秒] [-l 每批上限]
15
+ * 用法: tt-help product-fill --profiles <XX/port>[,<XX/port>...] [-s 服务端] [-i 间隔秒] [-l 每批上限] [--proxy URL]
11
16
  */
12
- import { spawn } from "node:child_process";
13
- import { fileURLToPath } from "node:url";
17
+ import { ProxyAgent, request } from "undici";
14
18
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
15
19
  import { createApiClient } from "../lib/api-client.js";
16
- import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
20
+ import {
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
+ import {
28
+ getProductInfoCDP,
29
+ isEmptyProductDetail,
30
+ } from "../lib/shop-scraper.mjs";
31
+ import {
32
+ detectCaptcha,
33
+ waitAndGetCaptcha,
34
+ } from "../scraper/modules/captcha-handler.js";
17
35
 
18
36
  const CAPTCHA_BACKOFF_MS = 30_000; // 验证码后冷却时长
19
- const WEBSERVER_READY_TIMEOUT_MS = 60_000; // 等待自动拉起的 webserver 就绪上限
20
-
21
- /** 探测 webserver /health 是否可用 */
22
- async function probeWebserverHealth(url, timeoutMs = 1500) {
23
- try {
24
- const res = await fetch(`${url}/health`, {
25
- signal: AbortSignal.timeout(timeoutMs),
26
- });
27
- return res.ok;
28
- } catch {
29
- return false;
30
- }
31
- }
37
+ const CDP_CAPTCHA_WAIT_MS = 180_000; // CDP 模式人工解验证码最长等待
38
+ const FAIL_MARK_LIMIT = 3; // 连续失败达到该次数后哨兵放弃
39
+ const SENTINEL_RATING = -1; // 哨兵:商品不可售/已下架(正常评分 0-5,-1 不会冲突)
40
+ const DEFAULT_PROXY = "http://127.0.0.1:7897"; // 未配置任何代理时,出口探测按本机 Clash 默认端口尝试
32
41
 
33
42
  function formatNow() {
34
43
  return new Date().toLocaleString("zh-CN", { hour12: false });
@@ -38,26 +47,84 @@ function log(message = "") {
38
47
  console.error(`[${formatNow()}] ${message}`);
39
48
  }
40
49
 
41
- /** 抓取商品详情(webserver /api/tiktok/product);503 验证码 → retryable 错误 */
42
- async function fetchProductDetail(webserverUrl, seoUrl) {
43
- const res = await fetch(`${webserverUrl}/api/tiktok/product`, {
44
- method: "POST",
45
- headers: { "Content-Type": "application/json" },
46
- body: JSON.stringify({ url: seoUrl }),
47
- signal: AbortSignal.timeout(30_000),
48
- });
49
- if (!res.ok) {
50
- const body = await res.json().catch(() => ({}));
51
- const err = new Error(
52
- `${body.error || `HTTP ${res.status}`}${res.status === 503 ? " (captcha)" : ""}`,
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 },
53
96
  );
54
- err.status = res.status;
55
- // webserver 约定:503=验证码;504/502 响应体带 retryable=true 表示可重试
56
- err.captcha = res.status === 503;
57
- err.retryable = !!body.retryable || res.status === 503;
58
- throw err;
97
+ try {
98
+ await body.dump();
99
+ } catch {}
100
+ return statusCode === 200;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 通过代理探测出口 IP 国家码(ip-api.com 免费接口)。
108
+ * 探测失败(超时/代理未开/接口异常)返回 null。
109
+ */
110
+ export async function detectProxyCountry(proxyUrl, timeoutMs = 8000) {
111
+ const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : null;
112
+ try {
113
+ const { statusCode, body } = await request(
114
+ "http://ip-api.com/json?fields=countryCode",
115
+ {
116
+ method: "GET",
117
+ ...(dispatcher ? { dispatcher } : {}),
118
+ headersTimeout: timeoutMs,
119
+ bodyTimeout: timeoutMs,
120
+ },
121
+ );
122
+ if (statusCode !== 200) return null;
123
+ const data = JSON.parse(await body.text());
124
+ return data?.countryCode || null;
125
+ } catch {
126
+ return null;
59
127
  }
60
- return res.json();
61
128
  }
62
129
 
63
130
  export async function handleProductFill(options) {
@@ -65,48 +132,63 @@ export async function handleProductFill(options) {
65
132
  productFillLimit,
66
133
  productFillInterval,
67
134
  serverUrl,
68
- productFillWebserver,
69
135
  productFillProxy,
136
+ productFillTaskDelay,
137
+ productFillProfiles,
70
138
  showHelp,
71
139
  } = options;
72
- // 不指定 -w 时默认使用本地 webserver(http://127.0.0.1:3000),
73
- // 且本地无响应时自动拉起(适合单独机器:验证码时弹浏览器人工处理)
74
- const spawnLocalWebserver = !productFillWebserver;
75
- const webserverUrl = productFillWebserver || "http://127.0.0.1:3000";
76
- // 自动拉起 webserver 时透传的代理(默认本机 clash 7897)
77
- const localWebserverProxy = productFillProxy || "http://127.0.0.1:7897";
78
- let localWebserverChild = null;
140
+
141
+ // 与其他命令口径一致:--proxy 参数 > 配置文件 ~/.tt-help.json 的 proxy;
142
+ // 均未配置则不注入 proxyServer,浏览器走系统默认代理(explore/attach 同款)
143
+ const resolvedProxy = productFillProxy || configuredProxy || null;
79
144
 
80
145
  if (showHelp) {
81
146
  log(
82
- "用法: tt-help product-fill [-s 服务端] [-w webserver地址] [-i 间隔秒] [-l 每批上限]",
147
+ "用法: tt-help product-fill --profiles <国家/端口>[,<国家/端口>...] [-s 服务端] [-i 间隔秒] [-l 每批上限] [--proxy URL]",
83
148
  );
84
149
  log("");
85
150
  log("参数:");
86
- log(" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)");
87
151
  log(
88
- " -w, --webserver <URL> 商品详情 webserver 地址(默认: http://127.0.0.1:3000,本地未运行会自动拉起)",
152
+ " --profiles <XX/port> 必填。国家/端口对,逗号分隔可传多个(如 US/9223,JP/1222);",
153
+ );
154
+ log(
155
+ " 每轮探测代理出口,在配置中匹配对应端口自动领取该国家任务",
89
156
  );
157
+ log(" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)");
90
158
  log(
91
- " --proxy <URL> 自动拉起 webserver 时使用的代理(默认: http://127.0.0.1:7897)",
159
+ " --proxy <URL> 浏览器启动代理及出口探测(未指定时用配置文件 ~/.tt-help.json 的 proxy;均未配置则浏览器走系统默认代理)",
92
160
  );
93
161
  log(" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)");
94
162
  log(" -l, --limit <N> 每批领取任务数上限(默认: 5)");
95
- log("");
96
- log("说明:");
97
- log(" 循环向服务端 /api/product-tasks 领取缺商品详情的任务;");
98
- log(" 对每个任务调用 webserver /api/tiktok/product 抓取评分/销量/评论;");
99
- log(" 成功后回传 /api/product-fill/:uniqueId 写入 jobs 表。");
100
- log(" 商品详情验证码(503)自动冷却 30 秒后重试,其他失败计数后继续。");
101
163
  log(
102
- " 不传 -w 时本地无 webserver 会自动拉起(验证码时弹浏览器,人工解决后继续)。",
164
+ " --task-delay <N> 每个任务处理完后的随机延迟上限,秒(默认: 5,实际等待 N/2~N 秒随机)",
103
165
  );
104
166
  log("");
167
+ log("说明:");
168
+ log(" 循环向服务端 /api/product-tasks?country=XX 领取缺商品详情任务;");
169
+ log(" 代理出口不在配置范围 → 本轮不领任务,下轮重新探测;");
170
+ log(" 商品下架壳页立即标记不可售(rating=-1),连续失败 3 次同样标记;");
171
+ log(" 验证码在浏览器中弹出时等待人工解决(最长 3 分钟)后自动重试。");
172
+ log("");
105
173
  log("示例:");
174
+ log(" tt-help product-fill --profiles US/9223 -s http://127.0.0.1:3001");
106
175
  log(
107
- " tt-help product-fill -s http://127.0.0.1:3001 -w http://127.0.0.1:3000",
176
+ " tt-help product-fill --profiles US/9223,JP/1222 -s http://117.71.53.99:17301 -i 5",
177
+ );
178
+ return;
179
+ }
180
+
181
+ // ── 参数校验:至少一个有效的国家/端口对;同端口重复保留最后一个 ──
182
+ const finalProfiles = [];
183
+ for (const p of productFillProfiles || []) {
184
+ const idx = finalProfiles.findIndex((x) => x.port === p.port);
185
+ if (idx >= 0) finalProfiles[idx] = p;
186
+ else finalProfiles.push(p);
187
+ }
188
+ if (finalProfiles.length === 0) {
189
+ log(
190
+ "[ProductFill] 错误: 请传 --profiles 国家/端口对(如 --profiles US/9223,JP/1222)",
108
191
  );
109
- log(" tt-help product-fill -w http://117.71.53.99:17300 -i 5 -l 10");
110
192
  return;
111
193
  }
112
194
 
@@ -118,7 +200,7 @@ export async function handleProductFill(options) {
118
200
  log(`[ProductFill] 未检测到本地用户编号,已生成并使用: ${userId}`);
119
201
  }
120
202
  log(
121
- `[ProductFill] 用户编号: ${userId} | 服务端: ${serverUrl} | webserver: ${webserverUrl}`,
203
+ `[ProductFill] 用户编号: ${userId} | 服务端: ${serverUrl} | 端口配置: ${finalProfiles.map((p) => `${p.port}=${p.country}`).join(", ")}`,
122
204
  );
123
205
  log(
124
206
  `[ProductFill] 每批上限: ${productFillLimit} | 空闲间隔: ${productFillInterval}秒`,
@@ -128,17 +210,10 @@ export async function handleProductFill(options) {
128
210
 
129
211
  let shuttingDown = false;
130
212
  let forceExitTimer = null;
131
- /** 强制退出路径清理自动拉起的 webserver(防止孤儿进程) */
132
- const killLocalWebserver = () => {
133
- if (localWebserverChild && !localWebserverChild.killed) {
134
- localWebserverChild.kill("SIGKILL");
135
- }
136
- };
137
213
  const shutdown = () => {
138
214
  if (shuttingDown) {
139
215
  // 第二次信号:立即强制退出(网络重试等场景可能无法优雅退出)
140
216
  log(`\n[ProductFill] 再次收到信号,强制退出...`);
141
- killLocalWebserver();
142
217
  process.exit(1);
143
218
  return;
144
219
  }
@@ -149,7 +224,6 @@ export async function handleProductFill(options) {
149
224
  // 兜底:卡在网络重试/等待中时 10 秒后强制退出
150
225
  forceExitTimer = setTimeout(() => {
151
226
  log("[ProductFill] 等待超时,强制退出");
152
- killLocalWebserver();
153
227
  process.exit(0);
154
228
  }, 10_000);
155
229
  forceExitTimer.unref?.();
@@ -157,57 +231,49 @@ export async function handleProductFill(options) {
157
231
  process.on("SIGINT", () => shutdown());
158
232
  process.on("SIGTERM", () => shutdown());
159
233
 
160
- // ── 确保 webserver 可用:未指定 -w 且本地无响应时自动拉起 ──
161
- if (await probeWebserverHealth(webserverUrl)) {
162
- log(`[ProductFill] webserver 已就绪: ${webserverUrl}`);
163
- } else {
164
- if (!spawnLocalWebserver) {
165
- log(
166
- `[ProductFill] 警告: webserver ${webserverUrl} 无响应,任务将按失败重试`,
167
- );
168
- } else {
169
- const portMatch = webserverUrl.match(/:(\d+)\/?$/);
170
- const port = portMatch ? parseInt(portMatch[1], 10) : 3000;
171
- // cli.js 在包根(源码与 npm 包结构一致),webserver 命令两者都支持
172
- const cliJs = fileURLToPath(new URL("../../cli.js", import.meta.url));
173
- log(
174
- `[ProductFill] 本地 webserver 未运行,自动拉起(端口 ${port},代理 ${localWebserverProxy})...`,
175
- );
176
- localWebserverChild = spawn(
177
- process.execPath,
178
- [
179
- cliJs,
180
- "webserver",
181
- "-p",
182
- String(port),
183
- "--proxy",
184
- localWebserverProxy,
185
- ],
186
- {
187
- stdio: ["ignore", "ignore", "inherit"], // webserver 日志(含验证码提示)透传到本终端
188
- windowsHide: true,
189
- },
190
- );
191
- localWebserverChild.on("exit", (code) => {
192
- if (!shuttingDown && code !== null && code !== 0) {
193
- log(`[ProductFill] ⚠ 本地 webserver 已退出 (code=${code})`);
194
- }
195
- });
196
- // 等待就绪(最多 60 秒)
197
- const deadline = Date.now() + WEBSERVER_READY_TIMEOUT_MS;
198
- while (!shuttingDown && Date.now() < deadline) {
199
- if (await probeWebserverHealth(webserverUrl, 1000)) break;
200
- await new Promise((r) => setTimeout(r, 1000));
201
- }
202
- if (await probeWebserverHealth(webserverUrl, 1000)) {
203
- log(`[ProductFill] webserver 已就绪: ${webserverUrl}`);
234
+ // ── 浏览器连接管理:按出口国家自动切换端口(只换 CDP 连接,Edge 实例不杀) ──
235
+ let browser = null;
236
+ let page = null;
237
+ let currentPort = null;
238
+ // 本进程启动(或断连重启拉起)的浏览器端口:退出时按端口关闭;
239
+ // 连接前端口已有浏览器(用户自己开的)→ 不记录,退出时不杀
240
+ const launchedPorts = new Set();
241
+ const connectProfile = async (profile) => {
242
+ if (currentPort === profile.port && browser) return;
243
+ if (browser) {
244
+ await browser.close().catch(() => {});
245
+ browser = null;
246
+ page = null;
247
+ }
248
+ const cdpOptions = { port: profile.port };
249
+ if (resolvedProxy) cdpOptions.proxyServer = resolvedProxy;
250
+ const wasReady = await isCDPPortReady(profile.port);
251
+ log(
252
+ `[ProductFill] 切换浏览器端口 ${currentPort ?? "-"} → ${profile.port}(country=${profile.country}${resolvedProxy ? `,代理 ${resolvedProxy}` : ""})`,
253
+ );
254
+ browser = await ensureBrowserReady(cdpOptions);
255
+ if (!wasReady) launchedPorts.add(profile.port);
256
+ page = await getOrCreatePage(browser);
257
+ currentPort = profile.port;
258
+ log(`[ProductFill] CDP 浏览器已就绪(当前页: ${page.url()})`);
259
+ };
260
+
261
+ // ── 代理出口国家探测 ──
262
+ // 与浏览器启动通道保持一致;未配置任何代理时按本机 Clash 默认端口探测
263
+ const effectiveProxyUrl = resolvedProxy || DEFAULT_PROXY;
264
+ let proxyCountry = null;
265
+ const refreshProxyCountry = async () => {
266
+ const prev = proxyCountry;
267
+ proxyCountry = await detectProxyCountry(effectiveProxyUrl);
268
+ // 只在国家变化时打印,避免每轮探测刷屏
269
+ if (proxyCountry !== prev) {
270
+ if (proxyCountry) {
271
+ log(`[ProductFill] 代理出口国家: ${proxyCountry}`);
204
272
  } else {
205
- log(
206
- `[ProductFill] 警告: webserver 启动超时,继续运行(抓取失败将自动重试)`,
207
- );
273
+ log(`[ProductFill] ⚠ 无法探测代理出口国家`);
208
274
  }
209
275
  }
210
- }
276
+ };
211
277
 
212
278
  /** 可被退出信号中断的等待(500ms 粒度检查 shuttingDown) */
213
279
  const interruptibleSleep = (ms) =>
@@ -222,18 +288,160 @@ export async function handleProductFill(options) {
222
288
  tick();
223
289
  });
224
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
+
225
303
  let loopCount = 0;
226
304
  let totalSuccess = 0;
227
305
  let totalFailed = 0;
228
306
  let totalSkipped = 0;
307
+ let totalMarked = 0; // 标记不可售/放弃数
229
308
  let captchaBackoffUntil = 0; // 验证码后冷却到的时间戳
309
+ let lastMismatchLogAt = 0; // 出口不一致日志节流
310
+ /** 连续失败计数:uniqueId → 次数,达到 FAIL_MARK_LIMIT 后哨兵放弃 */
311
+ const failCount = new Map();
312
+
313
+ /** 哨兵回写:product_rating = -1,任务永久移出候选(以后不再领取) */
314
+ const markUnavailable = async (uniqueId, reason) => {
315
+ try {
316
+ const post = await apiPost(
317
+ `${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
318
+ { productDetail: { rating: SENTINEL_RATING } },
319
+ );
320
+ if (post && post.ok) {
321
+ totalMarked++;
322
+ failCount.delete(uniqueId);
323
+ log(` ⊘ @${uniqueId} 已标记不可售(${reason})`);
324
+ return true;
325
+ }
326
+ log(` ✗ @${uniqueId} 标记失败: ${post?.error || "未知"}`);
327
+ } catch (e) {
328
+ log(` ✗ @${uniqueId} 标记异常: ${e.message || "未知"}`);
329
+ }
330
+ return false;
331
+ };
332
+
333
+ /** 记录抓取失败次数,达到上限后哨兵放弃(防止死任务无限轮转) */
334
+ const noteFail = async (uniqueId) => {
335
+ const n = (failCount.get(uniqueId) || 0) + 1;
336
+ if (n < FAIL_MARK_LIMIT) {
337
+ failCount.set(uniqueId, n);
338
+ return;
339
+ }
340
+ failCount.delete(uniqueId);
341
+ await markUnavailable(uniqueId, `连续失败 ${n} 次`);
342
+ };
343
+
344
+ /** CDP 直抓:验证码在已登录浏览器中弹出时,等待人工解决后自动重试 */
345
+ const fetchViaCDP = async (seoUrl) => {
346
+ try {
347
+ return await getProductInfoCDP(page, seoUrl);
348
+ } catch (e) {
349
+ if (!e.message?.includes("Blocked by TikTok")) throw e;
350
+ // 验证码:优先检测可见容器并等待人工解决(浏览器就在本机屏幕上)
351
+ const cap = await detectCaptcha(page);
352
+ if (cap?.exists) {
353
+ log(" ⏳ 检测到验证码,请在已登录的浏览器窗口中手动解决...");
354
+ const r = await waitAndGetCaptcha(page, {
355
+ waitMs: CDP_CAPTCHA_WAIT_MS,
356
+ pollInterval: 5000,
357
+ log,
358
+ });
359
+ if (r.resolved) {
360
+ log(" ✓ 验证码已解决,重新抓取...");
361
+ return getProductInfoCDP(page, seoUrl);
362
+ }
363
+ }
364
+ const err = new Error(
365
+ cap?.exists
366
+ ? "验证码等待人工解决超时"
367
+ : "验证码拦截(无可见验证码容器)",
368
+ );
369
+ err.captcha = true;
370
+ throw err;
371
+ }
372
+ };
373
+
374
+ /** 抓取商品详情,带浏览器断连恢复与空数据防御 */
375
+ const getTaskDetail = async (seoUrl, country) => {
376
+ let detail;
377
+ try {
378
+ detail = await fetchViaCDP(seoUrl);
379
+ } catch (e) {
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;
411
+ }
412
+ }
413
+ // 空数据防御:回写字段全空,不回写(防止假成功死循环)
414
+ if (isEmptyProductDetail(detail)) {
415
+ const err = new Error("商品详情数据为空(页面被拦截或结构变化)");
416
+ err.retryable = true;
417
+ err.empty = true;
418
+ throw err;
419
+ }
420
+ return detail;
421
+ };
230
422
 
231
423
  try {
232
424
  while (!shuttingDown) {
233
425
  loopCount++;
234
426
  if (shuttingDown) break;
235
427
 
236
- // 验证码冷却期间不领任务(避免反复触发 webserver 限流)
428
+ // 每轮领取前探测出口:在配置中找匹配端口,找不到则不领任务,
429
+ // 下轮重新探测(代理切换生效有窗口期,等生效即可)
430
+ await refreshProxyCountry();
431
+ const profile = finalProfiles.find((p) => p.country === proxyCountry);
432
+ if (!profile) {
433
+ if (loopCount === 1 || Date.now() - lastMismatchLogAt > 60_000) {
434
+ log(
435
+ `[ProductFill] ⏳ 出口 ${proxyCountry || "未知"} 不在配置范围(${finalProfiles.map((p) => p.country).join("/")}),本轮跳过(等待代理切换)`,
436
+ );
437
+ lastMismatchLogAt = Date.now();
438
+ }
439
+ await interruptibleSleep(productFillInterval * 1000);
440
+ continue;
441
+ }
442
+ await connectProfile(profile);
443
+
444
+ // 验证码冷却期间不领任务
237
445
  const now = Date.now();
238
446
  if (now < captchaBackoffUntil) {
239
447
  const waitMs = captchaBackoffUntil - now;
@@ -244,8 +452,14 @@ export async function handleProductFill(options) {
244
452
  continue;
245
453
  }
246
454
 
455
+ // 领任务:服务端按国家过滤,只发该国家任务
456
+ const taskQuery = new URLSearchParams({
457
+ limit: String(productFillLimit),
458
+ userId,
459
+ country: profile.country,
460
+ });
247
461
  const { tasks = [] } = await apiGet(
248
- `${serverUrl}/api/product-tasks?limit=${productFillLimit}&userId=${encodeURIComponent(userId)}`,
462
+ `${serverUrl}/api/product-tasks?${taskQuery.toString()}`,
249
463
  );
250
464
 
251
465
  if (!tasks || tasks.length === 0) {
@@ -263,19 +477,22 @@ export async function handleProductFill(options) {
263
477
  let batchSuccess = 0;
264
478
  let batchFailed = 0;
265
479
  let batchSkipped = 0;
480
+ let batchMarked = 0;
266
481
 
267
482
  for (const task of tasks) {
268
483
  const uniqueId = task.uniqueId;
269
484
  const seoUrl = task.seoUrl;
270
- if (!seoUrl) {
271
- log(` ✗ @${uniqueId} 无 seoUrl,跳过`);
485
+ if (!seoUrl || !String(seoUrl).startsWith("http")) {
486
+ // 残缺 URL(如仅 ?source=anchor):永远无效,直接哨兵放弃,
487
+ // 避免进入 page.goto 触发 Protocol error 被误判为浏览器断连
272
488
  batchSkipped++;
273
489
  totalSkipped++;
490
+ if (await markUnavailable(uniqueId, "seoUrl 无效")) batchMarked++;
274
491
  continue;
275
492
  }
276
493
  log(` → 补商品 @${uniqueId} ...`);
277
494
  try {
278
- const detail = await fetchProductDetail(webserverUrl, seoUrl);
495
+ const detail = await getTaskDetail(seoUrl, profile.country);
279
496
  const post = await apiPost(
280
497
  `${serverUrl}/api/product-fill/${encodeURIComponent(uniqueId)}`,
281
498
  { productDetail: detail },
@@ -283,6 +500,7 @@ export async function handleProductFill(options) {
283
500
  if (post && post.ok) {
284
501
  batchSuccess++;
285
502
  totalSuccess++;
503
+ failCount.delete(uniqueId);
286
504
  log(
287
505
  ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"})`,
288
506
  );
@@ -290,6 +508,7 @@ export async function handleProductFill(options) {
290
508
  batchFailed++;
291
509
  totalFailed++;
292
510
  log(` ✗ @${uniqueId} 回传失败: ${post?.error || "未知"}`);
511
+ await noteFail(uniqueId);
293
512
  }
294
513
  } catch (e) {
295
514
  if (e.captcha) {
@@ -299,8 +518,21 @@ export async function handleProductFill(options) {
299
518
  log(
300
519
  ` ⏳ @${uniqueId} 验证码拦截,冷却 ${CAPTCHA_BACKOFF_MS / 1000} 秒`,
301
520
  );
521
+ } else if (e.delisted) {
522
+ // 壳页/下架:seller_id 为空且无销售数据 → 立即哨兵放弃
523
+ batchSkipped++;
524
+ totalSkipped++;
525
+ if (await markUnavailable(uniqueId, "商品下架/不可售")) {
526
+ batchMarked++;
527
+ }
528
+ } else if (e.empty) {
529
+ // 解析全空(回写字段全无):计入熔断,连续 N 次后哨兵放弃
530
+ batchFailed++;
531
+ totalFailed++;
532
+ log(` ✗ @${uniqueId} 商品详情数据为空: ${e.message}`);
533
+ await noteFail(uniqueId);
302
534
  } else if (e.retryable) {
303
- // 504 超时等可重试错误:未回写,下轮自然重试,不触发全局冷却
535
+ // 超时等可重试错误:未回写,下轮自然重试,不触发全局冷却
304
536
  batchSkipped++;
305
537
  totalSkipped++;
306
538
  log(` ⏳ @${uniqueId} 临时失败(可重试): ${e.message}`);
@@ -308,12 +540,15 @@ export async function handleProductFill(options) {
308
540
  batchFailed++;
309
541
  totalFailed++;
310
542
  log(` ✗ @${uniqueId} 商品详情失败: ${e.message || "未知错误"}`);
543
+ await noteFail(uniqueId);
311
544
  }
312
545
  }
546
+ // 每个任务处理完后随机延迟(防风控):N/2 ~ N 秒,Ctrl+C 可中断
547
+ if (!shuttingDown) await taskRandomDelay();
313
548
  }
314
549
 
315
550
  log(
316
- ` 本批: 成功 ${batchSuccess} | 失败 ${batchFailed} | 跳过 ${batchSkipped}(累计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped})\n`,
551
+ ` 本批: 成功 ${batchSuccess} | 失败 ${batchFailed} | 跳过 ${batchSkipped} | 标记 ${batchMarked}(累计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped} | 标记 ${totalMarked})\n`,
317
552
  );
318
553
  await interruptibleSleep(300);
319
554
  }
@@ -322,12 +557,28 @@ export async function handleProductFill(options) {
322
557
  throw err;
323
558
  } finally {
324
559
  if (forceExitTimer) clearTimeout(forceExitTimer);
325
- if (localWebserverChild && !localWebserverChild.killed) {
326
- log("[ProductFill] 关闭自动拉起的本地 webserver...");
327
- localWebserverChild.kill("SIGTERM");
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] 浏览器已关闭`);
328
579
  }
329
580
  log(
330
- `[ProductFill] 已退出。总计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped}`,
581
+ `[ProductFill] 已退出。总计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped} | 标记不可售 ${totalMarked}`,
331
582
  );
332
583
  }
333
584
  }