tt-help-cli-ycl 1.4.166 → 1.4.168
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/deploy-node.sh +83 -0
- package/scripts/diag-claim-branches.js +72 -0
- package/scripts/test-claim-fix.mjs +216 -0
- package/scripts/test-precise-index.mjs +114 -0
- package/scripts/test-stats-smoke.mjs +43 -0
- package/src/clash/checker.js +96 -14
- package/src/cli/attach.js +67 -19
- package/src/lib/browser/cdp.js +182 -27
- package/src/watch/data-store.js +159 -37
- package/src/watch/server.js +102 -38
package/src/clash/checker.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. 检测 Clash Verge 进程/API 存活(跨平台 mac/win)
|
|
6
6
|
* 2. 自动发现代理端口(GET /configs → mixed-port)
|
|
7
7
|
* 3. 节点健康检测(curl 穿透代理请求 TikTok)
|
|
8
|
-
* 4.
|
|
8
|
+
* 4. 国家过滤(全词文本 + emoji 国旗 + 单字方言三层匹配)
|
|
9
9
|
* 5. 健康节点列表持久化 + 故障切换
|
|
10
10
|
* 6. CLI 入口硬门控预检
|
|
11
11
|
*/
|
|
@@ -311,18 +311,95 @@ export function normalizeCountries(v) {
|
|
|
311
311
|
return [];
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
314
|
+
// ── 层2:emoji 国旗(全局启用)──
|
|
315
|
+
// 国旗是 regional indicator 成对编码(🇩🇪 = U+1F1E9 U+1F1EA),无歧义,
|
|
316
|
+
// 比节点名文本更可靠。7899/topman 类机场节点名只有国旗或单字缩写
|
|
317
|
+
// (S德🇩🇪56、🇭🇰261008节点、S0港🇭🇰4),全词文本模式全部落空(实测 0/20)。
|
|
318
|
+
// 偏召回设计:公告伪节点误认由 delay 初测淘汰,漏检则永远进不了候选集。
|
|
319
|
+
const FLAG_RE = /[\u{1F1E6}-\u{1F1FF}]{2}/u;
|
|
320
|
+
const FLAG_OFFSET = 0x1f1e6 - 65; // regional indicator → 大写字母
|
|
321
|
+
|
|
322
|
+
/** emoji 国旗 → ISO 国家码,无旗返回 null */
|
|
323
|
+
export function matchCountryByEmoji(name) {
|
|
324
|
+
const m = String(name || "").match(FLAG_RE);
|
|
325
|
+
if (!m) return null;
|
|
326
|
+
const [a, b] = [...m[0]].map((ch) => ch.codePointAt(0) - FLAG_OFFSET);
|
|
327
|
+
return String.fromCharCode(a, b);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 层3:单字缩写(按方言门控)──
|
|
331
|
+
// 误伤半径大,仅在检测到方言命名风格的订阅上启用(见 detectSingleCharDialect)。
|
|
332
|
+
// 刻意不含 新/加/印/台/澳 等高歧义单字(新节点/加速器/打印/平台/澳门)
|
|
333
|
+
const SINGLE_CHAR_PATTERNS = {
|
|
334
|
+
美: "US",
|
|
335
|
+
英: "GB",
|
|
336
|
+
法: "FR",
|
|
337
|
+
德: "DE",
|
|
338
|
+
日: "JP",
|
|
339
|
+
韩: "KR",
|
|
340
|
+
港: "HK",
|
|
341
|
+
俄: "RU",
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// 单字边界:前后不得紧邻其他汉字 —— 放行 "S美🇺🇸86"/"S0港4"/"TEST日🇯10",
|
|
345
|
+
// 排除全词("美国"/"香港",层1已覆盖)与嵌字("完美网络"不含国家义)
|
|
346
|
+
const SINGLE_CHAR_BOUNDARY_RE = /(^|[^一-鿿])[美德日韩港英法俄](?![一-鿿])/;
|
|
347
|
+
|
|
348
|
+
// 预编译:国家码 → 单字边界正则数组
|
|
349
|
+
const SINGLE_CHAR_RES = {};
|
|
350
|
+
for (const [ch, c] of Object.entries(SINGLE_CHAR_PATTERNS)) {
|
|
351
|
+
(SINGLE_CHAR_RES[c] ??= []).push(
|
|
352
|
+
new RegExp(`(^|[^一-鿿])${ch}(?![一-鿿])`),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// "仅靠 emoji 可识别(全词文本不命中)"的节点占比 ≥ 阈值 → emoji/单字方言机场。
|
|
357
|
+
// 老机场命名带全词("🇺🇸美国HY1")文本层即可命中,占比≈0;topman 类占比 >0.6
|
|
358
|
+
const DIALECT_MIN_RATIO = 0.3;
|
|
359
|
+
const DIALECT_MIN_SAMPLE = 5; // 样本太少不判定,避免小列表误翻转
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 方言探测:该订阅是否属于 emoji/单字命名风格(决定单字层是否启用)。
|
|
363
|
+
* 两路信号取大者:① 仅靠 emoji 可识别(全词文本不命中)占比 —— topman 类;
|
|
364
|
+
* ② 单字边界风格占比 —— 兼顾无 emoji 纯单字缩写的机场。
|
|
365
|
+
* 老机场("🇺🇸美国HY1")两路占比均≈0,单字层不启用、行为与旧版一致
|
|
366
|
+
* @param {string[]} nodes 全量节点名
|
|
367
|
+
*/
|
|
368
|
+
function detectSingleCharDialect(nodes) {
|
|
369
|
+
const usable = (Array.isArray(nodes) ? nodes : []).filter(isUsableProxyNode);
|
|
370
|
+
if (usable.length < DIALECT_MIN_SAMPLE) return false;
|
|
371
|
+
let emojiOnly = 0;
|
|
372
|
+
let singleCharStyle = 0;
|
|
373
|
+
for (const n of usable) {
|
|
374
|
+
const code = matchCountryByEmoji(n);
|
|
375
|
+
const pat = code && COUNTRY_PATTERNS[code];
|
|
376
|
+
if (pat && !pat.test(n)) emojiOnly++;
|
|
377
|
+
if (SINGLE_CHAR_BOUNDARY_RE.test(n)) singleCharStyle++;
|
|
378
|
+
}
|
|
379
|
+
const ratio = Math.max(emojiOnly, singleCharStyle) / usable.length;
|
|
380
|
+
return ratio >= DIALECT_MIN_RATIO;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 单节点是否命中指定国家的任一匹配层(emoji / 全词文本 / 单字方言) */
|
|
384
|
+
function matchNodeCountry(name, code, useSingleChar) {
|
|
385
|
+
if (matchCountryByEmoji(name) === code) return true;
|
|
386
|
+
const pat = COUNTRY_PATTERNS[code];
|
|
387
|
+
if (pat && pat.test(name)) return true;
|
|
388
|
+
if (useSingleChar) {
|
|
389
|
+
for (const re of SINGLE_CHAR_RES[code] || []) {
|
|
390
|
+
if (re.test(String(name))) return true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
320
394
|
}
|
|
321
395
|
|
|
322
396
|
export function filterNodesByCountry(nodes, country) {
|
|
323
|
-
const
|
|
324
|
-
if (!
|
|
325
|
-
const
|
|
397
|
+
const countries = normalizeCountries(country);
|
|
398
|
+
if (!countries.length) return { filtered: nodes, excluded: 0 };
|
|
399
|
+
const useSingleChar = detectSingleCharDialect(nodes);
|
|
400
|
+
const filtered = nodes.filter((n) =>
|
|
401
|
+
countries.some((c) => matchNodeCountry(n, c, useSingleChar)),
|
|
402
|
+
);
|
|
326
403
|
return { filtered, excluded: nodes.length - filtered.length };
|
|
327
404
|
}
|
|
328
405
|
|
|
@@ -349,15 +426,20 @@ const BUILTIN_POLICY_NAMES = new Set([
|
|
|
349
426
|
const INFO_NODE_PATTERN =
|
|
350
427
|
/剩余|流量|到期|过期|官网|重置|套餐|订阅|工单|[\d.]\s*[KMGTP]B$/i;
|
|
351
428
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
429
|
+
// []【】()()注解段。机场常把"实时更新订阅/0倍率/移动优化"等字样放进注解,
|
|
430
|
+
// 主名才是节点标识。信息节点判定只看主名,避免注解关键词误杀真节点
|
|
431
|
+
// (2026-01 生产实锤:S韩🇰🇷55[实时更新订阅]、🇺🇸 2[0倍率][实时更新订阅] 被"订阅"误杀)
|
|
432
|
+
const ANNOTATION_RE = /\[[^\]]*\]|【[^】]*】|\([^)]*\)|([^)]*)/g;
|
|
433
|
+
|
|
434
|
+
/** 判定节点名是否可用作业务出口(内置策略与机场信息节点返回 false)
|
|
435
|
+
* 在候选集写口(runDelayProbe)与读口(loadHealthyList)统一调用
|
|
355
436
|
*/
|
|
356
437
|
export function isUsableProxyNode(name) {
|
|
357
438
|
const n = String(name || "").trim();
|
|
358
439
|
if (!n) return false;
|
|
359
440
|
if (BUILTIN_POLICY_NAMES.has(n.toUpperCase())) return false;
|
|
360
|
-
|
|
441
|
+
// 信息节点只看主名:注解里的"订阅/更新"等字样不算(见 ANNOTATION_RE 注释)
|
|
442
|
+
if (INFO_NODE_PATTERN.test(n.replace(ANNOTATION_RE, " "))) return false;
|
|
361
443
|
return true;
|
|
362
444
|
}
|
|
363
445
|
|
package/src/cli/attach.js
CHANGED
|
@@ -160,13 +160,26 @@ export async function handleAttach(options) {
|
|
|
160
160
|
data = await res.json();
|
|
161
161
|
} catch {}
|
|
162
162
|
if (!res.ok) {
|
|
163
|
-
// CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver
|
|
163
|
+
// CDN 限流:抛 CDNBlockedError,外层据此冷却(webserver 模式同样生效)。
|
|
164
|
+
// 服务端冷却期返回 503 + error="CDN cooldown active, cooling IP"(server.mjs),
|
|
165
|
+
// 并携带 cdnCooldownSec=剩余秒数,一并识别;秒数挂到 err.serverCooldownSec
|
|
166
|
+
// 供外层精确对齐冷却窗口,避免冷却期整批空转猛冲。
|
|
164
167
|
if (
|
|
165
168
|
data &&
|
|
166
169
|
typeof data.error === "string" &&
|
|
167
|
-
data.error.includes("CDN限流")
|
|
170
|
+
(data.error.includes("CDN限流") ||
|
|
171
|
+
data.error.includes("CDN cooldown"))
|
|
168
172
|
) {
|
|
169
|
-
|
|
173
|
+
const cdnErr = new CDNBlockedError(data.error);
|
|
174
|
+
if (data.cdnCooldownSec > 0)
|
|
175
|
+
cdnErr.serverCooldownSec = data.cdnCooldownSec;
|
|
176
|
+
throw cdnErr;
|
|
177
|
+
}
|
|
178
|
+
if (data && data.cdnCooldownSec > 0) {
|
|
179
|
+
// 文案兜底:只要响应带冷却秒数字段就按 CDN 限流处理
|
|
180
|
+
const cdnErr = new CDNBlockedError("CDN限流 (cooldown)");
|
|
181
|
+
cdnErr.serverCooldownSec = data.cdnCooldownSec;
|
|
182
|
+
throw cdnErr;
|
|
170
183
|
}
|
|
171
184
|
// 其余失败(504 超时 / 503 / 502 / 500 等):抛出带状态码与错误详情的异常,
|
|
172
185
|
// 让外层日志显示真实原因(如 page.goto 超时)而非笼统"未获取到用户信息"。
|
|
@@ -221,6 +234,7 @@ export async function handleAttach(options) {
|
|
|
221
234
|
let browserRestartCount = 0;
|
|
222
235
|
let taskBatchCount = 0;
|
|
223
236
|
let consecutiveCdnBlocks = 0; // 连续 CDN 限流轮数(自适应:前几次切换节点,之后长等待)
|
|
237
|
+
let consecutiveHighFail = 0; // 连续高失败率轮数(webserver 模式通用阶梯冷却)
|
|
224
238
|
|
|
225
239
|
while (!shuttingDown) {
|
|
226
240
|
loopCount++;
|
|
@@ -299,6 +313,7 @@ export async function handleAttach(options) {
|
|
|
299
313
|
let successCount = 0;
|
|
300
314
|
let failCount = 0;
|
|
301
315
|
let cdnBlockedCount = 0;
|
|
316
|
+
let serverCdnCooldownSec = 0; // 服务端告知的剩余冷却秒数(webserver 503 响应携带)
|
|
302
317
|
let needRestart = false;
|
|
303
318
|
|
|
304
319
|
// 收集抓取成功的任务,记录抓取失败的
|
|
@@ -308,8 +323,16 @@ export async function handleAttach(options) {
|
|
|
308
323
|
const { uniqueId, info, error } = result.value;
|
|
309
324
|
if (error) {
|
|
310
325
|
if (error instanceof CDNBlockedError) {
|
|
311
|
-
attachLog(
|
|
326
|
+
attachLog(
|
|
327
|
+
error.serverCooldownSec
|
|
328
|
+
? ` ⚠ @${uniqueId} CDN限流 (服务端冷却中,剩余 ${error.serverCooldownSec}s)`
|
|
329
|
+
: ` ⚠ @${uniqueId} CDN限流 (Access Denied)`,
|
|
330
|
+
);
|
|
312
331
|
cdnBlockedCount++;
|
|
332
|
+
serverCdnCooldownSec = Math.max(
|
|
333
|
+
serverCdnCooldownSec,
|
|
334
|
+
error.serverCooldownSec || 0,
|
|
335
|
+
);
|
|
313
336
|
failCount++;
|
|
314
337
|
} else if (isBrowserClosedError(error)) {
|
|
315
338
|
needRestart = true;
|
|
@@ -384,9 +407,13 @@ export async function handleAttach(options) {
|
|
|
384
407
|
|
|
385
408
|
attachLog(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
|
|
386
409
|
|
|
387
|
-
//
|
|
410
|
+
// 失败率冷却策略:
|
|
411
|
+
// - 本地浏览器模式(scraper):CDN 限流 >30% 触发切节点 + 冷却(主动绕过)
|
|
412
|
+
// - webserver 模式:统一按总失败率阶梯冷却(出口在远端,分类对客户端无意义,
|
|
413
|
+
// 唯一手段是等;冷却期服务端 503 会带 cdnCooldownSec,优先精确对齐窗口)
|
|
388
414
|
const cdnRatio = tasks.length > 0 ? cdnBlockedCount / tasks.length : 0;
|
|
389
|
-
|
|
415
|
+
const failRatio = tasks.length > 0 ? failCount / tasks.length : 0;
|
|
416
|
+
if (cdnRatio > 0.3 && scraper) {
|
|
390
417
|
consecutiveCdnBlocks++;
|
|
391
418
|
// 冷却指数退避:60s→120s→240s→…上限 300s(切换后短等,持续限流则长等窗口过去)
|
|
392
419
|
const coolDownSeconds = Math.min(
|
|
@@ -394,12 +421,7 @@ export async function handleAttach(options) {
|
|
|
394
421
|
MAX_CDN_COOLDOWN_SEC,
|
|
395
422
|
);
|
|
396
423
|
const shouldSwitch = consecutiveCdnBlocks <= MAX_CDN_SWITCH_ATTEMPTS;
|
|
397
|
-
if (
|
|
398
|
-
// webserver 模式:限流发生在远端 webserver 出口,本地切节点无效,仅冷却等待
|
|
399
|
-
attachLog(
|
|
400
|
-
` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),webserver 模式不切节点,冷却 ${coolDownSeconds} 秒...`,
|
|
401
|
-
);
|
|
402
|
-
} else if (shouldSwitch) {
|
|
424
|
+
if (shouldSwitch) {
|
|
403
425
|
// 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
|
|
404
426
|
try {
|
|
405
427
|
writeCommand({
|
|
@@ -417,16 +439,42 @@ export async function handleAttach(options) {
|
|
|
417
439
|
);
|
|
418
440
|
}
|
|
419
441
|
await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
442
|
+
await recycleScraper(
|
|
443
|
+
scraper,
|
|
444
|
+
`CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
|
|
445
|
+
);
|
|
446
|
+
browserRestartCount++;
|
|
447
|
+
taskBatchCount = 0;
|
|
448
|
+
} else if (
|
|
449
|
+
webserverUrl &&
|
|
450
|
+
tasks.length >= 5 &&
|
|
451
|
+
failRatio > 0.5
|
|
452
|
+
) {
|
|
453
|
+
// webserver 模式统一阶梯:总失败率 >50% 即冷却(含 CDN 限流/goto 超时/
|
|
454
|
+
// 验证码/网络错误等所有类型)。首次 30s 低代价试探,连续则指数升级
|
|
455
|
+
// 30→60→120→240→300s 封顶;服务端告知冷却剩余秒数时优先精确对齐;
|
|
456
|
+
// 失败率回落 ≤30% 清零
|
|
457
|
+
consecutiveHighFail++;
|
|
458
|
+
const coolDownSeconds = serverCdnCooldownSec
|
|
459
|
+
? Math.min(
|
|
460
|
+
Math.max(serverCdnCooldownSec + 5, 30),
|
|
461
|
+
MAX_CDN_COOLDOWN_SEC,
|
|
462
|
+
)
|
|
463
|
+
: Math.min(
|
|
464
|
+
30 * Math.pow(2, consecutiveHighFail - 1),
|
|
465
|
+
MAX_CDN_COOLDOWN_SEC,
|
|
466
|
+
);
|
|
467
|
+
const cdnTag = cdnBlockedCount
|
|
468
|
+
? `(其中 CDN限流 ${cdnBlockedCount})`
|
|
469
|
+
: "";
|
|
470
|
+
attachLog(
|
|
471
|
+
` [Attach] 失败率 ${(failRatio * 100).toFixed(0)}% (${failCount}/${tasks.length})${cdnTag},冷却 ${coolDownSeconds} 秒...`,
|
|
472
|
+
);
|
|
473
|
+
await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
|
|
427
474
|
taskBatchCount = 0;
|
|
428
475
|
} else {
|
|
429
476
|
consecutiveCdnBlocks = 0; // 正常批次重置连续限流计数
|
|
477
|
+
if (failRatio <= 0.3) consecutiveHighFail = 0; // 失败率回落清零阶梯计数
|
|
430
478
|
}
|
|
431
479
|
|
|
432
480
|
if (scraper) {
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -222,22 +222,66 @@ function killEdgeProcesses(targetDir, port) {
|
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/** 查询监听指定端口的进程 PID 列表(netstat,不依赖 WMI);查询失败返回 [] */
|
|
226
|
+
function getListeningPidsOnPort(port) {
|
|
227
|
+
try {
|
|
228
|
+
const out = execSync("netstat -ano", {
|
|
229
|
+
encoding: "utf-8",
|
|
230
|
+
timeout: 10000,
|
|
231
|
+
windowsHide: true,
|
|
232
|
+
});
|
|
233
|
+
return parseNetstatListeningPids(out, port);
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** tasklist 验证 PID 存活且属于 msedge(不依赖 WMI):true/false;查询失败返回 null */
|
|
240
|
+
function isEdgePidAliveWin(pid) {
|
|
241
|
+
try {
|
|
242
|
+
const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, {
|
|
243
|
+
encoding: "utf-8",
|
|
244
|
+
timeout: 5000,
|
|
245
|
+
windowsHide: true,
|
|
246
|
+
});
|
|
247
|
+
return out.toLowerCase().includes("msedge");
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
225
253
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
254
|
+
* 读取 profile 目录下 lockfile 里的数字(ProcessSingleton 持有者 PID 候选)。
|
|
255
|
+
* Chromium 写入格式随版本有差异,返回前 5 个数字串,由调用方逐一验证。
|
|
256
|
+
*/
|
|
257
|
+
function readLockPidCandidates(userDataDir) {
|
|
258
|
+
try {
|
|
259
|
+
const content = fs.readFileSync(path.join(userDataDir, "lockfile"), "utf-8");
|
|
260
|
+
return (content.match(/\d+/g) || []).slice(0, 5);
|
|
261
|
+
} catch {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 查找"残留" Edge 主进程:占着指定 user-data-dir 的实例(调用前已确认
|
|
268
|
+
* CDP 端口未监听,故带 --remote-debugging-port 参数的僵死实例同样算残留)。
|
|
269
|
+
* 排除带 --type= 的子进程:渲染进程等子进程同样带 user-data-dir,
|
|
270
|
+
* 误杀会打断同 profile 的健康实例。
|
|
231
271
|
*
|
|
232
272
|
* 这类实例必须先杀掉再启动:否则新 msedge 会因 Chromium ProcessSingleton
|
|
233
273
|
* 把命令行转发给旧实例,变成"旧实例开一个 about:blank 新 tab",CDP 端口
|
|
234
274
|
* 永远起不来 → 等待超时 → watchdog 自动重启 → 每轮再开一个 tab。
|
|
235
275
|
*
|
|
236
276
|
* killEdgeProcesses 在 Windows 上按监听端口找 PID,对这类实例无效,
|
|
237
|
-
*
|
|
277
|
+
* 故单独按命令行/lockfile 匹配精确清理,且不依赖 WMI:
|
|
278
|
+
* 1. PowerShell 枚举 msedge 命令行按 user-data-dir 匹配(需 WMI,
|
|
279
|
+
* WMI 损坏的机器上可能返回空)
|
|
280
|
+
* 2. lockfile 兜底:读 profile 目录 lockfile 中的 PID,用 tasklist 验证
|
|
281
|
+
* 存活且是 msedge(不依赖 WMI,覆盖 WMI 损坏 + 带 CDP 参数的僵死实例)
|
|
238
282
|
*
|
|
239
283
|
* @param {string} targetDir - user-data-dir 路径
|
|
240
|
-
* @returns {string[]|null} PID
|
|
284
|
+
* @returns {string[]|null} PID 列表;命令行枚举失败且 lockfile 无可信结论时返回 null
|
|
241
285
|
*/
|
|
242
286
|
function findStaleEdgePids(targetDir) {
|
|
243
287
|
const platform = os.platform();
|
|
@@ -245,31 +289,61 @@ function findStaleEdgePids(targetDir) {
|
|
|
245
289
|
let out;
|
|
246
290
|
if (platform === "win32") {
|
|
247
291
|
// PowerShell 为主(wmic 已从新版 Win11 移除;项目其他模块同样依赖 PowerShell)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
292
|
+
let byCmdline = null;
|
|
293
|
+
try {
|
|
294
|
+
out = decodeWinOutput(
|
|
295
|
+
execSync(
|
|
296
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'msedge.exe\' } | ForEach-Object { Write-Output ($_.ProcessId.ToString() + \'|\' + $_.CommandLine) }"',
|
|
297
|
+
{ encoding: "buffer", timeout: 10000, windowsHide: true },
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
const dirLower = targetDir.toLowerCase();
|
|
301
|
+
const pids = [];
|
|
302
|
+
for (const line of out.split(/\r?\n/)) {
|
|
303
|
+
const lower = line.toLowerCase();
|
|
304
|
+
if (!lower.includes(dirLower)) continue; // 只匹配本 profile
|
|
305
|
+
// 边界保护:避免 _p9222 误匹配 _p92221 这类前缀目录
|
|
306
|
+
const idx = lower.indexOf(dirLower);
|
|
307
|
+
const next = lower[idx + dirLower.length];
|
|
308
|
+
if (next && /[a-z0-9_]/.test(next)) continue;
|
|
309
|
+
if (lower.includes("--type=")) continue; // 子进程,误杀会打断健康实例
|
|
310
|
+
// 提取 PID:win 格式 "PID|命令行"
|
|
311
|
+
const m = line.match(/^\s*(\d+)\|/);
|
|
312
|
+
if (m && !pids.includes(m[1])) pids.push(m[1]);
|
|
313
|
+
}
|
|
314
|
+
byCmdline = pids;
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.error(`[findStaleEdgePids] PowerShell 枚举失败: ${e.message}`);
|
|
317
|
+
byCmdline = null;
|
|
318
|
+
}
|
|
319
|
+
if (byCmdline && byCmdline.length > 0) return byCmdline;
|
|
320
|
+
|
|
321
|
+
// lockfile 兑底:WMI 损坏时命令行枚举为空/失败;或残留实例带 CDP
|
|
322
|
+
// 参数时命令行匹配可能误报为空,lockfile 直接给出持有者 PID
|
|
323
|
+
for (const cand of readLockPidCandidates(targetDir)) {
|
|
324
|
+
if (isEdgePidAliveWin(cand) === true) return [cand];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 命令行枚举失败且 lockfile 没找到活持有者 → 无法判断
|
|
328
|
+
if (byCmdline === null) return null;
|
|
329
|
+
return [];
|
|
259
330
|
}
|
|
331
|
+
|
|
332
|
+
out = execSync("ps -eo pid,args", {
|
|
333
|
+
encoding: "utf-8",
|
|
334
|
+
timeout: 10000,
|
|
335
|
+
});
|
|
260
336
|
const dirLower = targetDir.toLowerCase();
|
|
261
337
|
const pids = [];
|
|
262
338
|
for (const line of out.split(/\r?\n/)) {
|
|
263
339
|
const lower = line.toLowerCase();
|
|
264
340
|
if (!lower.includes(dirLower)) continue; // 只匹配本 profile
|
|
265
|
-
// 边界保护:避免 _p9222 误匹配 _p92221 这类前缀目录
|
|
266
341
|
const idx = lower.indexOf(dirLower);
|
|
267
342
|
const next = lower[idx + dirLower.length];
|
|
268
343
|
if (next && /[a-z0-9_]/.test(next)) continue;
|
|
269
|
-
if (lower.includes("remote-debugging-port")) continue; // 健康 CDP 实例,不动
|
|
270
344
|
if (lower.includes("--type=")) continue; // 子进程,误杀会打断健康实例
|
|
271
|
-
// 提取 PID:
|
|
272
|
-
const m = line.match(/^\s*(\d+)
|
|
345
|
+
// 提取 PID:unix 格式 " PID 命令行"
|
|
346
|
+
const m = line.match(/^\s*(\d+)\s/);
|
|
273
347
|
if (m && !pids.includes(m[1])) pids.push(m[1]);
|
|
274
348
|
}
|
|
275
349
|
return pids;
|
|
@@ -308,6 +382,66 @@ function killEdgeStale(pids) {
|
|
|
308
382
|
});
|
|
309
383
|
}
|
|
310
384
|
|
|
385
|
+
/**
|
|
386
|
+
* CDP 等待超时后的现场诊断:端口被谁占用、msedge 进程总数、profile 锁持有者。
|
|
387
|
+
* 输出随超时错误一起打印/上报,远程节点无需人工登录即可定位问题。
|
|
388
|
+
* @param {number} port
|
|
389
|
+
* @param {string} userDataDir
|
|
390
|
+
* @returns {string} 形如 "[诊断] ...; ..." 的摘要;收集失败返回 ""
|
|
391
|
+
*/
|
|
392
|
+
function collectTimeoutDiagnostics(port, userDataDir) {
|
|
393
|
+
const parts = [];
|
|
394
|
+
try {
|
|
395
|
+
if (os.platform() === "win32") {
|
|
396
|
+
const listenPids = getListeningPidsOnPort(port);
|
|
397
|
+
if (listenPids.length > 0) {
|
|
398
|
+
const names = listenPids.map((pid) => {
|
|
399
|
+
try {
|
|
400
|
+
const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, {
|
|
401
|
+
encoding: "utf-8",
|
|
402
|
+
timeout: 5000,
|
|
403
|
+
windowsHide: true,
|
|
404
|
+
});
|
|
405
|
+
return out.split(",")[0]?.replace(/"/g, "").trim() || pid;
|
|
406
|
+
} catch {
|
|
407
|
+
return pid;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
parts.push(
|
|
411
|
+
`端口 ${port} 正被监听但 CDP 未响应 (PID: ${listenPids.join(", ")}, 进程: ${names.join(", ")})`,
|
|
412
|
+
);
|
|
413
|
+
} else {
|
|
414
|
+
parts.push(`端口 ${port} 无任何进程监听`);
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const out = execSync(
|
|
418
|
+
'tasklist /FI "IMAGENAME eq msedge.exe" /FO CSV /NH',
|
|
419
|
+
{
|
|
420
|
+
encoding: "utf-8",
|
|
421
|
+
timeout: 5000,
|
|
422
|
+
windowsHide: true,
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
const count = out
|
|
426
|
+
.split(/\r?\n/)
|
|
427
|
+
.filter((l) => l.toLowerCase().includes("msedge")).length;
|
|
428
|
+
parts.push(`msedge 进程总数: ${count}`);
|
|
429
|
+
} catch {}
|
|
430
|
+
const lockPids = readLockPidCandidates(userDataDir).filter(
|
|
431
|
+
(pid) => isEdgePidAliveWin(pid) !== false,
|
|
432
|
+
);
|
|
433
|
+
parts.push(
|
|
434
|
+
lockPids.length
|
|
435
|
+
? `profile lockfile 持有者: ${lockPids.join(", ")}`
|
|
436
|
+
: "profile lockfile 无存活持有者",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
} catch (e) {
|
|
440
|
+
parts.push(`诊断收集失败: ${e.message}`);
|
|
441
|
+
}
|
|
442
|
+
return parts.length ? `[诊断] ${parts.join("; ")}` : "";
|
|
443
|
+
}
|
|
444
|
+
|
|
311
445
|
function launchEdgeWithCDP(port, userDataDir, proxyServer) {
|
|
312
446
|
return new Promise((resolve, reject) => {
|
|
313
447
|
const platform = os.platform();
|
|
@@ -405,14 +539,32 @@ export async function ensureBrowserReady(options = {}) {
|
|
|
405
539
|
console.error(`CDP 端口 ${port} 未就绪,正在启动 Edge 浏览器...`);
|
|
406
540
|
}
|
|
407
541
|
}
|
|
408
|
-
//
|
|
409
|
-
|
|
542
|
+
// (Windows)CDP 端口被非 Edge 进程占用 → 立即报错,避免干等 60 秒超时
|
|
543
|
+
if (os.platform() === "win32") {
|
|
544
|
+
const listenPids = getListeningPidsOnPort(port);
|
|
545
|
+
if (listenPids.length > 0) {
|
|
546
|
+
const isEdge = listenPids.map((pid) => isEdgePidAliveWin(pid));
|
|
547
|
+
if (isEdge.every((v) => v === false)) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`CDP 端口 ${port} 被非 Edge 进程占用 (PID: ${listenPids.join(", ")}),` +
|
|
550
|
+
`无法启动浏览器。请释放该端口或更换 --cdp-port`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
// Edge 在监听但 HTTP 未响应 → 视为僵死,按残留处理(下方清理)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 残留实例清理:占着该 user-data-dir 但 CDP 端口未监听的旧 Edge 必须先杀掉
|
|
558
|
+
//(含带 --remote-debugging-port 参数的僵死实例),否则 launch 会被
|
|
559
|
+
// ProcessSingleton 转发成"旧实例开新 tab",CDP 永远起不来。
|
|
410
560
|
const stalePids = findStaleEdgePids(userDataDir);
|
|
411
561
|
if (stalePids === null) {
|
|
412
|
-
console.error(
|
|
562
|
+
console.error(
|
|
563
|
+
"[浏览器] 无法判断是否存在残留 Edge 实例(WMI/lockfile 均不可用),跳过清理",
|
|
564
|
+
);
|
|
413
565
|
} else if (stalePids.length > 0) {
|
|
414
566
|
console.error(
|
|
415
|
-
`[浏览器] 检测到占用 ${path.basename(userDataDir)}
|
|
567
|
+
`[浏览器] 检测到占用 ${path.basename(userDataDir)} 但 CDP 端口未监听的残留 Edge 实例 (PID: ${stalePids.join(", ")}),正在清理...`,
|
|
416
568
|
);
|
|
417
569
|
await killEdgeStale(stalePids);
|
|
418
570
|
await new Promise((r) => setTimeout(r, 3000));
|
|
@@ -427,8 +579,11 @@ export async function ensureBrowserReady(options = {}) {
|
|
|
427
579
|
os.platform() === "win32" ? 60000 : 30000,
|
|
428
580
|
);
|
|
429
581
|
if (!launched) {
|
|
582
|
+
const diag = collectTimeoutDiagnostics(port, userDataDir);
|
|
583
|
+
if (diag) console.error(`[浏览器] ${diag}`);
|
|
430
584
|
throw new Error(
|
|
431
|
-
`等待 CDP 端口 ${port}
|
|
585
|
+
`等待 CDP 端口 ${port} 超时。${diag ? diag + "\n" : ""}` +
|
|
586
|
+
`请确认 Edge 浏览器已安装,\n` +
|
|
432
587
|
`或手动启动: Microsoft Edge --remote-debugging-port=${port} [参见 cdp.js extraArgs]`,
|
|
433
588
|
);
|
|
434
589
|
}
|