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.
@@ -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. 国家过滤(按节点名 emoji 匹配)
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
- function getCountryPatterns(country) {
316
- const pats = normalizeCountries(country)
317
- .map((c) => COUNTRY_PATTERNS[c])
318
- .filter(Boolean);
319
- return pats.length ? pats : null;
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 pats = getCountryPatterns(country);
324
- if (!pats) return { filtered: nodes, excluded: 0 };
325
- const filtered = nodes.filter((n) => pats.some((p) => p.test(n)));
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
- * 判定节点名是否可用作业务出口(内置策略与机场信息节点返回 false)
354
- * 在候选集写口(runDelayProbe)与读口(loadHealthyList)统一调用
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
- if (INFO_NODE_PATTERN.test(n)) return false;
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
- throw new CDNBlockedError(data.error);
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(` ⚠ @${uniqueId} CDN限流 (Access Denied)`);
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
- // CDN 限流比例超过 30% 时:自适应策略(前几次切节点,连续限流则长等待)
410
+ // 失败率冷却策略:
411
+ // - 本地浏览器模式(scraper):CDN 限流 >30% 触发切节点 + 冷却(主动绕过)
412
+ // - webserver 模式:统一按总失败率阶梯冷却(出口在远端,分类对客户端无意义,
413
+ // 唯一手段是等;冷却期服务端 503 会带 cdnCooldownSec,优先精确对齐窗口)
388
414
  const cdnRatio = tasks.length > 0 ? cdnBlockedCount / tasks.length : 0;
389
- if (cdnRatio > 0.3) {
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 (!scraper) {
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
- if (scraper) {
421
- await recycleScraper(
422
- scraper,
423
- `CDN限流比例过高 (${cdnBlockedCount}/${tasks.length})`,
424
- );
425
- browserRestartCount++;
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) {
@@ -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
- * 查找"残留" Edge 主进程:命令行包含指定 user-data-dir、但不带
227
- * --remote-debugging-port(Edge 自动更新/崩溃自恢复、上次强杀残留等,
228
- * 占着 profile 却没监听 CDP 端口)。
229
- * 排除带 --type= 的子进程:渲染进程等子进程同样带 user-data-dir 却没有
230
- * CDP 参数,误杀会打断同 profile 的健康实例。
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 列表;无法枚举进程命令行(PowerShell/WMI 不可用)时返回 null
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
- out = decodeWinOutput(
249
- execSync(
250
- 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'msedge.exe\' } | ForEach-Object { Write-Output ($_.ProcessId.ToString() + \'|\' + $_.CommandLine) }"',
251
- { encoding: "buffer", timeout: 10000, windowsHide: true },
252
- ),
253
- );
254
- } else {
255
- out = execSync("ps -eo pid,args", {
256
- encoding: "utf-8",
257
- timeout: 10000,
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:win 格式 "PID|命令行",unix 格式 " PID 命令行"
272
- const m = line.match(/^\s*(\d+)(?:\||\s)/);
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
- // 残留实例清理:占着该 user-data-dir 却没开 CDP 的旧 Edge 必须先杀掉,
409
- // 否则 launch 会被 ProcessSingleton 转发成"旧实例开新 tab",CDP 永远起不来。
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("[浏览器] 无法枚举 Edge 进程命令行,跳过残留实例检测");
562
+ console.error(
563
+ "[浏览器] 无法判断是否存在残留 Edge 实例(WMI/lockfile 均不可用),跳过清理",
564
+ );
413
565
  } else if (stalePids.length > 0) {
414
566
  console.error(
415
- `[浏览器] 检测到占用 ${path.basename(userDataDir)} 但未开 CDP 的残留 Edge 实例 (PID: ${stalePids.join(", ")}),正在清理...`,
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} 超时。请确认 Edge 浏览器已安装,\n` +
585
+ `等待 CDP 端口 ${port} 超时。${diag ? diag + "\n" : ""}` +
586
+ `请确认 Edge 浏览器已安装,\n` +
432
587
  `或手动启动: Microsoft Edge --remote-debugging-port=${port} [参见 cdp.js extraArgs]`,
433
588
  );
434
589
  }