tt-help-cli-ycl 1.4.112 → 1.4.114
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/monitor-refresh-mini.mjs +161 -0
- package/src/clash/checker.js +18 -4
- package/src/clash/daemon.js +9 -0
- package/src/cli/product-fill.js +127 -14
- package/src/cli/refresh-mini.js +714 -0
- package/src/cli/refresh.js +28 -12
- package/src/cli/watchdog.js +5 -2
- package/src/lib/api-client.js +57 -42
- package/src/lib/api-interceptor.js +165 -49
- package/src/lib/args.js +112 -0
- package/src/lib/constants.js +22 -0
- package/src/main.js +3 -0
- package/src/npm-main.js +3 -0
- package/src/scraper/explore-core.js +24 -9
- package/src/watch/llm-config.js +14 -0
- package/src/watch/server.js +17 -0
- package/src/watchdog/process-manager.js +58 -1
- package/src/watchdog/reporter.js +1 -0
- package/src/watchdog/standalone-server.js +228 -0
package/package.json
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* refresh-mini 运行状况监控(4222 / 5222)
|
|
3
|
+
*
|
|
4
|
+
* - 每 5 分钟采样:各端口 refresh-mini 进程 RSS + Edge 浏览器 RSS/进程数
|
|
5
|
+
* - 采样日志:../newdata/refresh-mini-monitor.log(JSONL)
|
|
6
|
+
* - 每天 08:00~08:10 自动生成报告(md 文件 + macOS 系统通知)
|
|
7
|
+
*/
|
|
8
|
+
import { execSync } from "child_process";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
|
|
11
|
+
const LOG_FILE =
|
|
12
|
+
"/Users/yinchengliang/soft/tiktok/newdata/refresh-mini-monitor.log";
|
|
13
|
+
const REPORT_DIR = "/Users/yinchengliang/soft/tiktok/newdata";
|
|
14
|
+
const INTERVAL_MS = 5 * 60 * 1000;
|
|
15
|
+
const PORTS = [4222, 5222];
|
|
16
|
+
|
|
17
|
+
function execSilent(cmd) {
|
|
18
|
+
try {
|
|
19
|
+
return execSync(cmd, { encoding: "utf8" }).trim();
|
|
20
|
+
} catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 采样一次:refresh-mini 进程 + Edge 浏览器进程 */
|
|
26
|
+
function sample() {
|
|
27
|
+
const ts = new Date();
|
|
28
|
+
const entry = { time: ts.toISOString(), ports: {} };
|
|
29
|
+
|
|
30
|
+
// ps ax -o pid=,rss=,command= :一行一个进程(macOS/Linux 兼容)
|
|
31
|
+
const psOut = execSilent("ps ax -o pid=,rss=,command=");
|
|
32
|
+
const lines = psOut ? psOut.split("\n") : [];
|
|
33
|
+
|
|
34
|
+
for (const port of PORTS) {
|
|
35
|
+
const rmList = [];
|
|
36
|
+
const edgeList = [];
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
39
|
+
if (!m) continue;
|
|
40
|
+
const [, pid, rssKb, cmd] = m;
|
|
41
|
+
const is5222 = /--port(=|\s+)5222/.test(cmd);
|
|
42
|
+
// 排除监控脚本自身(文件名含 refresh-mini)
|
|
43
|
+
if (
|
|
44
|
+
cmd.includes("refresh-mini") &&
|
|
45
|
+
!cmd.includes("monitor-refresh-mini")
|
|
46
|
+
) {
|
|
47
|
+
if ((port === 5222) === is5222) {
|
|
48
|
+
rmList.push({ pid: Number(pid), rssKb: Number(rssKb) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (cmd.includes(`remote-debugging-port=${port}`)) {
|
|
52
|
+
edgeList.push({ pid: Number(pid), rssKb: Number(rssKb) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const sum = (list) => list.reduce((a, b) => a + b.rssKb, 0);
|
|
56
|
+
entry.ports[port] = {
|
|
57
|
+
refreshMini: {
|
|
58
|
+
processes: rmList.length,
|
|
59
|
+
rssMb: +(sum(rmList) / 1024).toFixed(1),
|
|
60
|
+
pids: rmList.map((p) => p.pid),
|
|
61
|
+
},
|
|
62
|
+
edge: {
|
|
63
|
+
processes: edgeList.length,
|
|
64
|
+
rssMb: +(sum(edgeList) / 1024).toFixed(1),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n");
|
|
70
|
+
const parts = Object.entries(entry.ports).map(
|
|
71
|
+
([p, d]) =>
|
|
72
|
+
`${p}: rm ${d.refreshMini.processes}个/${d.refreshMini.rssMb}MB, Edge ${d.edge.processes}进程/${d.edge.rssMb}MB`,
|
|
73
|
+
);
|
|
74
|
+
console.log(`[${ts.toLocaleString("zh-CN")}] ${parts.join(" | ")}`);
|
|
75
|
+
return entry;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 统计日志生成报告 */
|
|
79
|
+
function generateReport() {
|
|
80
|
+
let rows = [];
|
|
81
|
+
try {
|
|
82
|
+
rows = fs
|
|
83
|
+
.readFileSync(LOG_FILE, "utf8")
|
|
84
|
+
.split("\n")
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.map((l) => JSON.parse(l));
|
|
87
|
+
} catch {
|
|
88
|
+
/* 无日志 */
|
|
89
|
+
}
|
|
90
|
+
if (rows.length === 0) return;
|
|
91
|
+
|
|
92
|
+
const fmt = (v) => (v == null ? "-" : `${v} MB`);
|
|
93
|
+
const stats = (arr) => {
|
|
94
|
+
const vals = arr.filter((v) => v != null);
|
|
95
|
+
if (vals.length === 0) return { min: "-", max: "-", avg: "-" };
|
|
96
|
+
return {
|
|
97
|
+
min: Math.min(...vals).toFixed(1),
|
|
98
|
+
max: Math.max(...vals).toFixed(1),
|
|
99
|
+
avg: (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const lines = [
|
|
104
|
+
`# refresh-mini 运行状况报告`,
|
|
105
|
+
``,
|
|
106
|
+
`生成时间: ${new Date().toLocaleString("zh-CN")}`,
|
|
107
|
+
`采样频率: 5 分钟 | 采样点: ${rows.length} 个`,
|
|
108
|
+
``,
|
|
109
|
+
];
|
|
110
|
+
for (const port of PORTS) {
|
|
111
|
+
lines.push(`## 端口 ${port}`, ``);
|
|
112
|
+
const rmRss = rows.map((r) => r.ports?.[port]?.refreshMini?.rssMb ?? null);
|
|
113
|
+
const edgeRss = rows.map((r) => r.ports?.[port]?.edge?.rssMb ?? null);
|
|
114
|
+
const rmAlive = rows.filter(
|
|
115
|
+
(r) => (r.ports?.[port]?.refreshMini?.processes ?? 0) > 0,
|
|
116
|
+
).length;
|
|
117
|
+
const edgeAlive = rows.filter(
|
|
118
|
+
(r) => (r.ports?.[port]?.edge?.processes ?? 0) > 0,
|
|
119
|
+
).length;
|
|
120
|
+
const rmS = stats(rmRss);
|
|
121
|
+
const edgeS = stats(edgeRss);
|
|
122
|
+
const last = rows[rows.length - 1]?.ports?.[port] ?? {};
|
|
123
|
+
lines.push(
|
|
124
|
+
`- refresh-mini 进程:存活采样 ${rmAlive}/${rows.length} 次,内存 min/max/avg = ${fmt(rmS.min)} / ${fmt(rmS.max)} / ${fmt(rmS.avg)}`,
|
|
125
|
+
`- 当前:${last.refreshMini?.processes ?? 0} 个进程,${fmt(last.refreshMini?.rssMb)}(PID: ${(last.refreshMini?.pids ?? []).join(", ") || "无"})`,
|
|
126
|
+
`- Edge 浏览器:存活采样 ${edgeAlive}/${rows.length} 次,内存 min/max/avg = ${fmt(edgeS.min)} / ${fmt(edgeS.max)} / ${fmt(edgeS.avg)}`,
|
|
127
|
+
`- 当前:${last.edge?.processes ?? 0} 个进程,${fmt(last.edge?.rssMb)}`,
|
|
128
|
+
``,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const now = new Date();
|
|
133
|
+
const file = `${REPORT_DIR}/refresh-mini-report-${now.toISOString().slice(0, 10)}.md`;
|
|
134
|
+
fs.writeFileSync(file, lines.join("\n"));
|
|
135
|
+
console.log(`\n=== 8 点报告已生成: ${file} ===`);
|
|
136
|
+
execSilent(
|
|
137
|
+
`osascript -e 'display notification "refresh-mini 运行报告已生成" with title "8点监控报告" subtitle "${file.split("/").pop()}"'`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 每天 08:00~08:10 窗口内报告一次
|
|
142
|
+
let reportedToday = false;
|
|
143
|
+
function maybeReport() {
|
|
144
|
+
const now = new Date();
|
|
145
|
+
const key = now.toISOString().slice(0, 10);
|
|
146
|
+
if (now.getHours() === 8 && now.getMinutes() < 10) {
|
|
147
|
+
if (reportedToday !== key) {
|
|
148
|
+
reportedToday = key;
|
|
149
|
+
generateReport();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(
|
|
155
|
+
`[监控] 启动于 ${new Date().toLocaleString("zh-CN")},每 5 分钟采样,每天 08:00 报告`,
|
|
156
|
+
);
|
|
157
|
+
sample();
|
|
158
|
+
setInterval(() => {
|
|
159
|
+
sample();
|
|
160
|
+
maybeReport();
|
|
161
|
+
}, INTERVAL_MS);
|
package/src/clash/checker.js
CHANGED
|
@@ -173,13 +173,22 @@ export async function requireClashOrExit(label = "") {
|
|
|
173
173
|
|
|
174
174
|
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
175
175
|
const { body, metaLine, error } = _curlViaProxy(url);
|
|
176
|
-
const { httpCode, timeTotal, sizeDownload } =
|
|
176
|
+
const { httpCode, timeTotal, sizeDownload, numRedirects } =
|
|
177
|
+
_parseCurlMeta(metaLine);
|
|
177
178
|
const title = _extractTitle(body);
|
|
178
179
|
return {
|
|
179
|
-
healthy: _isHealthyPage(
|
|
180
|
+
healthy: _isHealthyPage(
|
|
181
|
+
error,
|
|
182
|
+
httpCode,
|
|
183
|
+
sizeDownload,
|
|
184
|
+
title,
|
|
185
|
+
body,
|
|
186
|
+
numRedirects,
|
|
187
|
+
),
|
|
180
188
|
httpCode,
|
|
181
189
|
timeTotal,
|
|
182
190
|
sizeDownload,
|
|
191
|
+
numRedirects,
|
|
183
192
|
title,
|
|
184
193
|
error,
|
|
185
194
|
};
|
|
@@ -189,7 +198,7 @@ function _curlViaProxy(url) {
|
|
|
189
198
|
const args = [
|
|
190
199
|
"-s",
|
|
191
200
|
"-w",
|
|
192
|
-
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
201
|
+
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}|%{num_redirects}",
|
|
193
202
|
"--proxy",
|
|
194
203
|
CONFIG.proxyUrl,
|
|
195
204
|
"--max-time",
|
|
@@ -224,6 +233,7 @@ function _parseCurlMeta(line) {
|
|
|
224
233
|
httpCode: parseInt(p[0]) || 0,
|
|
225
234
|
timeTotal: parseFloat(p[1]) || 0,
|
|
226
235
|
sizeDownload: parseInt(p[2]) || 0,
|
|
236
|
+
numRedirects: parseInt(p[3]) || 0,
|
|
227
237
|
};
|
|
228
238
|
}
|
|
229
239
|
|
|
@@ -232,10 +242,14 @@ function _extractTitle(body) {
|
|
|
232
242
|
return m ? m[1].trim() : "";
|
|
233
243
|
}
|
|
234
244
|
|
|
235
|
-
function _isHealthyPage(error, httpCode, size, title, body) {
|
|
245
|
+
function _isHealthyPage(error, httpCode, size, title, body, numRedirects = 0) {
|
|
236
246
|
return (
|
|
237
247
|
!error &&
|
|
238
248
|
httpCode === 200 &&
|
|
249
|
+
// 发生跳转即不健康:探测 URL 正常时应直接 200。
|
|
250
|
+
// 跳转意味着出口地区/节点异常(如香港停运 302 → /hk/about,
|
|
251
|
+
// 该页 title 仍是 TikTok,旧版 title 规则无法识别)
|
|
252
|
+
numRedirects === 0 &&
|
|
239
253
|
size >= CONFIG.minPageSize &&
|
|
240
254
|
title.includes("TikTok") &&
|
|
241
255
|
!title.startsWith("About |") &&
|
package/src/clash/daemon.js
CHANGED
|
@@ -212,6 +212,7 @@ export class ClashDaemon {
|
|
|
212
212
|
if (!target) {
|
|
213
213
|
// 全部候选不可用,恢复原节点
|
|
214
214
|
await checker.switchToNode(node).catch(() => {});
|
|
215
|
+
await api.closeAllConnections().catch(() => {});
|
|
215
216
|
this._log(`✗ 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`);
|
|
216
217
|
ipc.writeHealth({
|
|
217
218
|
node,
|
|
@@ -226,6 +227,9 @@ export class ClashDaemon {
|
|
|
226
227
|
this._log(
|
|
227
228
|
`⚠ 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
228
229
|
);
|
|
230
|
+
// 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
|
|
231
|
+
// 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
|
|
232
|
+
await api.closeAllConnections().catch(() => {});
|
|
229
233
|
ipc.writeHealth({
|
|
230
234
|
node: target,
|
|
231
235
|
healthy: h2.healthy,
|
|
@@ -312,6 +316,8 @@ export class ClashDaemon {
|
|
|
312
316
|
});
|
|
313
317
|
}
|
|
314
318
|
const h = checker.checkNodeHealth();
|
|
319
|
+
// 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
|
|
320
|
+
await api.closeAllConnections().catch(() => {});
|
|
315
321
|
this._log(
|
|
316
322
|
`🔄 看门狗指令: 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
|
|
317
323
|
);
|
|
@@ -366,6 +372,7 @@ export class ClashDaemon {
|
|
|
366
372
|
}
|
|
367
373
|
if (!target) {
|
|
368
374
|
await checker.switchToNode(node).catch(() => {});
|
|
375
|
+
await api.closeAllConnections().catch(() => {});
|
|
369
376
|
this._log(
|
|
370
377
|
`✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
|
|
371
378
|
);
|
|
@@ -378,6 +385,8 @@ export class ClashDaemon {
|
|
|
378
385
|
});
|
|
379
386
|
}
|
|
380
387
|
this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
|
|
388
|
+
// 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
|
|
389
|
+
await api.closeAllConnections().catch(() => {});
|
|
381
390
|
ipc.writeHealth({
|
|
382
391
|
node: target,
|
|
383
392
|
healthy: h2.healthy,
|
package/src/cli/product-fill.js
CHANGED
|
@@ -9,11 +9,26 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 用法: tt-help product-fill [-s 服务端] [-w webserver] [-i 间隔秒] [-l 每批上限]
|
|
11
11
|
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
12
14
|
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
13
15
|
import { createApiClient } from "../lib/api-client.js";
|
|
14
16
|
import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
|
|
15
17
|
|
|
16
18
|
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
|
+
}
|
|
17
32
|
|
|
18
33
|
function formatNow() {
|
|
19
34
|
return new Date().toLocaleString("zh-CN", { hour12: false });
|
|
@@ -51,9 +66,16 @@ export async function handleProductFill(options) {
|
|
|
51
66
|
productFillInterval,
|
|
52
67
|
serverUrl,
|
|
53
68
|
productFillWebserver,
|
|
69
|
+
productFillProxy,
|
|
54
70
|
showHelp,
|
|
55
71
|
} = options;
|
|
56
|
-
|
|
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;
|
|
57
79
|
|
|
58
80
|
if (showHelp) {
|
|
59
81
|
log(
|
|
@@ -63,7 +85,10 @@ export async function handleProductFill(options) {
|
|
|
63
85
|
log("参数:");
|
|
64
86
|
log(" -s, --server <URL> 服务端地址(默认: http://127.0.0.1:3001)");
|
|
65
87
|
log(
|
|
66
|
-
" -w, --webserver <URL> 商品详情 webserver
|
|
88
|
+
" -w, --webserver <URL> 商品详情 webserver 地址(默认: http://127.0.0.1:3000,本地未运行会自动拉起)",
|
|
89
|
+
);
|
|
90
|
+
log(
|
|
91
|
+
" --proxy <URL> 自动拉起 webserver 时使用的代理(默认: http://127.0.0.1:7897)",
|
|
67
92
|
);
|
|
68
93
|
log(" -i, --interval <N> 无任务时轮询间隔,单位秒(默认: 10)");
|
|
69
94
|
log(" -l, --limit <N> 每批领取任务数上限(默认: 5)");
|
|
@@ -73,6 +98,9 @@ export async function handleProductFill(options) {
|
|
|
73
98
|
log(" 对每个任务调用 webserver /api/tiktok/product 抓取评分/销量/评论;");
|
|
74
99
|
log(" 成功后回传 /api/product-fill/:uniqueId 写入 jobs 表。");
|
|
75
100
|
log(" 商品详情验证码(503)自动冷却 30 秒后重试,其他失败计数后继续。");
|
|
101
|
+
log(
|
|
102
|
+
" 不传 -w 时本地无 webserver 会自动拉起(验证码时弹浏览器,人工解决后继续)。",
|
|
103
|
+
);
|
|
76
104
|
log("");
|
|
77
105
|
log("示例:");
|
|
78
106
|
log(
|
|
@@ -82,13 +110,6 @@ export async function handleProductFill(options) {
|
|
|
82
110
|
return;
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
if (!webserverUrl) {
|
|
86
|
-
log(
|
|
87
|
-
"错误: 缺少 webserver 地址,请用 -w/--webserver 指定(如 http://127.0.0.1:3000)",
|
|
88
|
-
);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
113
|
// ── 设备标识 ──
|
|
93
114
|
let userId = configuredUserId;
|
|
94
115
|
if (!userId) {
|
|
@@ -106,14 +127,101 @@ export async function handleProductFill(options) {
|
|
|
106
127
|
const { apiGet, apiPost } = createApiClient({ log: false });
|
|
107
128
|
|
|
108
129
|
let shuttingDown = false;
|
|
130
|
+
let forceExitTimer = null;
|
|
131
|
+
/** 强制退出路径清理自动拉起的 webserver(防止孤儿进程) */
|
|
132
|
+
const killLocalWebserver = () => {
|
|
133
|
+
if (localWebserverChild && !localWebserverChild.killed) {
|
|
134
|
+
localWebserverChild.kill("SIGKILL");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
109
137
|
const shutdown = () => {
|
|
110
|
-
if (shuttingDown)
|
|
138
|
+
if (shuttingDown) {
|
|
139
|
+
// 第二次信号:立即强制退出(网络重试等场景可能无法优雅退出)
|
|
140
|
+
log(`\n[ProductFill] 再次收到信号,强制退出...`);
|
|
141
|
+
killLocalWebserver();
|
|
142
|
+
process.exit(1);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
111
145
|
shuttingDown = true;
|
|
112
|
-
log(
|
|
146
|
+
log(
|
|
147
|
+
`\n[ProductFill] 收到退出信号,完成当前批次后退出(再次按 Ctrl+C 强制退出)...`,
|
|
148
|
+
);
|
|
149
|
+
// 兜底:卡在网络重试/等待中时 10 秒后强制退出
|
|
150
|
+
forceExitTimer = setTimeout(() => {
|
|
151
|
+
log("[ProductFill] 等待超时,强制退出");
|
|
152
|
+
killLocalWebserver();
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}, 10_000);
|
|
155
|
+
forceExitTimer.unref?.();
|
|
113
156
|
};
|
|
114
157
|
process.on("SIGINT", () => shutdown());
|
|
115
158
|
process.on("SIGTERM", () => shutdown());
|
|
116
159
|
|
|
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}`);
|
|
204
|
+
} else {
|
|
205
|
+
log(
|
|
206
|
+
`[ProductFill] 警告: webserver 启动超时,继续运行(抓取失败将自动重试)`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 可被退出信号中断的等待(500ms 粒度检查 shuttingDown) */
|
|
213
|
+
const interruptibleSleep = (ms) =>
|
|
214
|
+
new Promise((resolve) => {
|
|
215
|
+
const start = Date.now();
|
|
216
|
+
const tick = () => {
|
|
217
|
+
if (shuttingDown) return resolve();
|
|
218
|
+
const left = ms - (Date.now() - start);
|
|
219
|
+
if (left <= 0) return resolve();
|
|
220
|
+
setTimeout(tick, Math.min(500, left));
|
|
221
|
+
};
|
|
222
|
+
tick();
|
|
223
|
+
});
|
|
224
|
+
|
|
117
225
|
let loopCount = 0;
|
|
118
226
|
let totalSuccess = 0;
|
|
119
227
|
let totalFailed = 0;
|
|
@@ -132,7 +240,7 @@ export async function handleProductFill(options) {
|
|
|
132
240
|
log(
|
|
133
241
|
`[ProductFill] ⏳ 验证码冷却中(剩余 ${Math.ceil(waitMs / 1000)}s),暂不领取任务...`,
|
|
134
242
|
);
|
|
135
|
-
await
|
|
243
|
+
await interruptibleSleep(waitMs);
|
|
136
244
|
continue;
|
|
137
245
|
}
|
|
138
246
|
|
|
@@ -146,7 +254,7 @@ export async function handleProductFill(options) {
|
|
|
146
254
|
`[ProductFill] 当前无缺商品任务,${productFillInterval}秒后重试...`,
|
|
147
255
|
);
|
|
148
256
|
}
|
|
149
|
-
await
|
|
257
|
+
await interruptibleSleep(productFillInterval * 1000);
|
|
150
258
|
continue;
|
|
151
259
|
}
|
|
152
260
|
|
|
@@ -207,12 +315,17 @@ export async function handleProductFill(options) {
|
|
|
207
315
|
log(
|
|
208
316
|
` 本批: 成功 ${batchSuccess} | 失败 ${batchFailed} | 跳过 ${batchSkipped}(累计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped})\n`,
|
|
209
317
|
);
|
|
210
|
-
await
|
|
318
|
+
await interruptibleSleep(300);
|
|
211
319
|
}
|
|
212
320
|
} catch (err) {
|
|
213
321
|
log(`[ProductFill] 运行异常: ${err.message}`);
|
|
214
322
|
throw err;
|
|
215
323
|
} finally {
|
|
324
|
+
if (forceExitTimer) clearTimeout(forceExitTimer);
|
|
325
|
+
if (localWebserverChild && !localWebserverChild.killed) {
|
|
326
|
+
log("[ProductFill] 关闭自动拉起的本地 webserver...");
|
|
327
|
+
localWebserverChild.kill("SIGTERM");
|
|
328
|
+
}
|
|
216
329
|
log(
|
|
217
330
|
`[ProductFill] 已退出。总计: 成功 ${totalSuccess} | 失败 ${totalFailed} | 跳过 ${totalSkipped}`,
|
|
218
331
|
);
|