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 +1 -1
- package/scripts/test-product-fill-cdp.mjs +124 -0
- package/src/cli/attach.js +6 -1
- package/src/cli/product-fill.js +200 -34
- package/src/lib/args.js +6 -0
- package/src/lib/constants.js +2 -1
- 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
|
@@ -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(
|
|
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
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from "../lib/
|
|
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"; //
|
|
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>
|
|
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 (
|
|
249
|
+
if (resolvedProxy) cdpOptions.proxyServer = resolvedProxy;
|
|
250
|
+
const wasReady = await isCDPPortReady(profile.port);
|
|
183
251
|
log(
|
|
184
|
-
`[ProductFill] 切换浏览器端口 ${currentPort ?? "-"} → ${profile.port}(country=${profile.country}${
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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",
|
package/src/lib/constants.js
CHANGED
|
@@ -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>
|
|
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",
|
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",
|