cfpilot-scanner 0.2.0 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +81 -2
  2. package/dist/cli.js +657 -42
  3. package/package.json +41 -41
package/README.md CHANGED
@@ -32,6 +32,38 @@ npx -y cfpilot-scanner scan --host cfpilot.example.com --token <令牌>
32
32
  npx -y cfpilot-scanner scan --host cfpilot.example.com --no-callback --top 20
33
33
  ```
34
34
 
35
+ ## 运行前必须关闭代理工具
36
+
37
+ > ⚠️ 这是最容易踩的坑,也是「结果看起来正常但导入客户端全是 -1」的头号原因。
38
+
39
+ 本工具测的是**本机直连某个 IP 的真实质量**。
40
+ 只要本机有代理工具在接管流量,测出来的就是**到代理的延迟**,而不是到目标 IP 的延迟:
41
+
42
+ - Clash / Mihomo(含 TUN 模式、系统代理)
43
+ - v2rayN / v2rayNG
44
+ - sing-box / Nekoray
45
+ - Surge / Quantumult
46
+
47
+ **症状**:扫描不报错、结果看起来正常,但所有候选 IP 成绩趋于一致
48
+ (常常集中在一个小网段),导入客户端后大量 `-1` —— 因为从未真正直连验证过。
49
+
50
+ 工具会在扫描前自动检测,发现异常会打印提醒:
51
+
52
+ ```
53
+ ⚠️ 检测到可能干扰优选的因素:
54
+ 本机有代理程序在监听:
55
+ Clash Verge → 127.0.0.1:7897
56
+ → 关闭这些代理程序,或在其中把本程序加入直连/绕过名单
57
+ DNS 把 your-domain.com 解析到 198.18.0.82(198.18/15 保留段),TUN / fake-ip 正在生效
58
+ → 关闭代理工具的 TUN 模式与 fake-ip DNS
59
+ ```
60
+
61
+ 检测到问题**不会阻断扫描**(有些人确实需要走代理,比如域名在本地被污染),
62
+ 但结果的可信度由你自己判断。
63
+
64
+ 如果必须开着代理,请把它加入直连/绕过名单,
65
+ 并确保**待测的 Cloudflare IP 段**走直连而不是代理节点。
66
+
35
67
  ## 对接任意 VLESS-WS 项目(CFNext / cfnew / BPB / edgetunnel)
36
68
 
37
69
  不依赖 CFPilot 自己的探针端点,只要知道你项目的 **WebSocket 路径**即可:
@@ -42,11 +74,58 @@ npx -y cfpilot-scanner scan --host your-domain.com --path /<uuid> --no-callback
42
74
 
43
75
  # 常见约定:/vless
44
76
  npx -y cfpilot-scanner scan --host your-domain.com --path /vless --no-callback
77
+
78
+ # 有些项目把 WebSocket 放在根路径,那就填 /
79
+ npx -y cfpilot-scanner scan --host your-domain.com --path / --no-callback
45
80
  ```
46
81
 
82
+ **路径必须与目标项目实际的 WebSocket 路径完全一致**,填错一个 IP 都选不出来。
83
+ 不知道就分别试 `/` 和 `/vless`,能出结果的那个就是对的。
84
+
47
85
  - 阶段二会对 `<path>` 发起真实的 **TLS + WebSocket 101** 握手,只有 101 才算可用
48
- - 如果目标没有测速端点,会自动跳过测速、只按延迟排序(不会再干等 12 秒)
49
- - 想测速可加 `--probe-path <路径>` 与 `--no-speed` 控制行为
86
+ - **测速支持任意项目**,不需要对方提供任何端点(见下节)
87
+ - 想控制行为可用 `--speed-mode`、`--speed-url`、`--no-speed`
88
+
89
+ ## 测速:两种方式,只对已校验的 IP 生效
90
+
91
+ **关键前提**:测速**只作用于已通过 101 校验的 IP**。这一点很重要 ——
92
+ 同类工具常见的失败模式是「只测带宽、不校验能不能服务你的域名」,
93
+ 结果选出的 IP 速度很快,导入客户端却全是 `-1`。
94
+
95
+ | 方式 | 什么时候用 | 要求 | 忠实度 |
96
+ |------|-----------|------|--------|
97
+ | `probe` | 目标有 CFPilot 探针端点(如 `/__edge_probe`) | 目标须提供该端点 | 高(走代理真实路径) |
98
+ | `url` | 其它任何项目 | 无 | 中(测「候选 IP 到 Cloudflare 边缘」的带宽) |
99
+
100
+ 默认 `--speed-mode auto`:自动探测目标有没有探针端点,有就走 `probe`,没有就切 `url`。
101
+ **因此对接第三方项目时速度不再永远是 0。**
102
+
103
+ ```bash
104
+ # 强制用公开测速地址(任何项目都能测出速度)
105
+ npx -y cfpilot-scanner scan --host your-domain.com --path /vless \
106
+ --speed-mode url --no-callback
107
+
108
+ # 换成自己的测速地址(需返回大文件)
109
+ npx -y cfpilot-scanner scan --host your-domain.com --path /vless \
110
+ --speed-url "cdn.example.com/big.bin" --no-callback
111
+ ```
112
+
113
+ ### 测速精度的几个设计点
114
+
115
+ 1. **取滑动窗口峰值,而不是总平均**
116
+ TCP 慢启动会让开头 1~2 秒的速率远低于链路实际能力。
117
+ 实测同一 IP:峰值 `51.7 MB/s`、总平均仅 `16.4 MB/s` —— 用平均会严重低估。
118
+ 因此默认丢弃前 1 秒的样本,再取窗口速率的最大值。
119
+
120
+ 2. **默认 3 条并发连接**(`--speed-conns`)
121
+ 单条 TCP 连接受拥塞窗口与 BDP 限制,高延迟线路上往往填不满实际带宽。
122
+
123
+ 3. **测速地址可用性预检**
124
+ 测速地址本身可能在本网络下不可用。启动时并行探测候选地址,
125
+ 自动选用可用的那个,避免把「地址不可用」误判成「所有 IP 速度都是 0」。
126
+
127
+ 4. **默认 6 秒测量时长**(`--speed-duration`)
128
+ 太短会被慢启动拉低,太长浪费时间。
50
129
 
51
130
  ## 输出:直接可复制的列表
52
131
 
package/dist/cli.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
2
8
 
3
9
  // src/cli.ts
4
10
  import { readFileSync } from "node:fs";
@@ -212,25 +218,56 @@ function expandCidrs(cidrs, options = {}) {
212
218
  const random = makeRandom(options.seed ?? 24301);
213
219
  const parsed = cidrs.map(parseCidr).filter((c) => c !== null);
214
220
  if (parsed.length === 0) return [];
215
- const totalSize = parsed.reduce((sum, c) => sum + c.size, 0n);
216
- const out = [];
217
- for (const cidr of parsed) {
218
- if (sample <= 0 || cidr.size <= 2000n) {
221
+ if (sample <= 0) {
222
+ const all = [];
223
+ for (const cidr of parsed) {
219
224
  for (let i = 0n; i < cidr.size; i++) {
220
225
  const value = cidr.base + i;
221
- out.push(cidr.family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
226
+ all.push(cidr.family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
222
227
  }
223
- continue;
224
228
  }
225
- const ratio = Number(totalSize === 0n ? 1n : cidr.size * BigInt(sample) / totalSize);
226
- const count = Math.max(64, Math.min(2e4, ratio));
227
- const step = Number(cidr.size / BigInt(count));
228
- const offset = Math.floor(random() * step);
229
- for (let i = 0; i < count; i++) {
230
- const offsetValue = BigInt(offset + Math.floor(i * step));
231
- if (offsetValue >= cidr.size) break;
232
- const value = cidr.base + offsetValue;
233
- out.push(cidr.family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
229
+ return all;
230
+ }
231
+ const minPerCidr = sample <= 1e3 ? 1 : 64;
232
+ const out = [];
233
+ for (const family of [4, 6]) {
234
+ const group = parsed.filter((c) => c.family === family);
235
+ if (group.length === 0) continue;
236
+ const familyTotal = group.reduce((sum, c) => sum + c.size, 0n);
237
+ if (familyTotal === 0n) continue;
238
+ const hasOtherFamily = parsed.some((c) => c.family !== family);
239
+ const familyBudget = Math.max(1, Math.round(hasOtherFamily ? sample / 2 : sample));
240
+ const plans = group.map((cidr) => {
241
+ const ratio = Number(cidr.size * BigInt(familyBudget) / familyTotal);
242
+ const wanted = Math.max(minPerCidr, Math.min(2e4, ratio));
243
+ return { cidr, count: Math.min(Number(cidr.size), wanted) };
244
+ });
245
+ let familySum = plans.reduce((sum, p) => sum + p.count, 0);
246
+ while (familySum > familyBudget) {
247
+ let target = null;
248
+ for (const plan of plans) {
249
+ if (plan.count > 1 && (!target || plan.count > target.count)) target = plan;
250
+ }
251
+ if (!target) break;
252
+ target.count -= 1;
253
+ familySum -= 1;
254
+ }
255
+ for (const { cidr, count } of plans) {
256
+ if (count >= Number(cidr.size)) {
257
+ for (let i = 0n; i < cidr.size; i++) {
258
+ const value = cidr.base + i;
259
+ out.push(family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
260
+ }
261
+ continue;
262
+ }
263
+ const step = Math.floor(Number(cidr.size) / count);
264
+ const offset = Math.floor(random() * step);
265
+ for (let i = 0; i < count; i++) {
266
+ const offsetValue = BigInt(offset + Math.floor(i * step));
267
+ if (offsetValue >= cidr.size) break;
268
+ const value = cidr.base + offsetValue;
269
+ out.push(family === 4 ? bigIntToIpv4(value) : bigIntToIpv6(value));
270
+ }
234
271
  }
235
272
  }
236
273
  return out;
@@ -326,11 +363,239 @@ function normalizeCandidateList(input) {
326
363
  return out;
327
364
  }
328
365
 
329
- // src/scan.ts
366
+ // src/net-env.ts
330
367
  import * as net from "node:net";
368
+ import { execFile } from "node:child_process";
369
+ var PROXY_PORTS = [
370
+ { port: 7890, name: "Clash / Mihomo\uFF08HTTP\uFF09" },
371
+ { port: 7891, name: "Clash / Mihomo\uFF08SOCKS\uFF09" },
372
+ { port: 7897, name: "Clash Verge" },
373
+ { port: 1080, name: "\u901A\u7528 SOCKS" },
374
+ { port: 10808, name: "v2rayN\uFF08SOCKS\uFF09" },
375
+ { port: 10809, name: "v2rayN\uFF08HTTP\uFF09" },
376
+ { port: 2080, name: "Nekoray / sing-box" },
377
+ { port: 2081, name: "Nekoray / sing-box" },
378
+ { port: 8889, name: "Surge" },
379
+ { port: 6152, name: "Surge\uFF08HTTP\uFF09" }
380
+ ];
381
+ function isPortListening(port, timeoutMs = 400) {
382
+ return new Promise((resolve) => {
383
+ const socket = new net.Socket();
384
+ let settled = false;
385
+ const done = (open) => {
386
+ if (settled) return;
387
+ settled = true;
388
+ try {
389
+ socket.destroy();
390
+ } catch {
391
+ }
392
+ resolve(open);
393
+ };
394
+ socket.setTimeout(timeoutMs, () => done(false));
395
+ socket.once("connect", () => done(true));
396
+ socket.once("error", () => done(false));
397
+ try {
398
+ socket.connect(port, "127.0.0.1");
399
+ } catch {
400
+ done(false);
401
+ }
402
+ });
403
+ }
404
+ async function readSystemProxy() {
405
+ if (process.platform !== "win32") return "";
406
+ const query = (key) => new Promise((resolve) => {
407
+ execFile(
408
+ "reg",
409
+ ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "/v", key],
410
+ { timeout: 3e3, windowsHide: true },
411
+ (err, stdout) => resolve(err ? "" : String(stdout))
412
+ );
413
+ });
414
+ try {
415
+ const enabled = await query("ProxyEnable");
416
+ if (!/ProxyEnable\s+REG_DWORD\s+0x1/i.test(enabled)) return "";
417
+ const server = await query("ProxyServer");
418
+ const m = /ProxyServer\s+REG_SZ\s+(\S+)/i.exec(server);
419
+ return m ? m[1] : "(\u5DF2\u542F\u7528\uFF0C\u4F46\u672A\u8BFB\u5230\u5730\u5740)";
420
+ } catch {
421
+ return "";
422
+ }
423
+ }
424
+ function isFakeIp(ip) {
425
+ const parts = ip.split(".").map(Number);
426
+ const [a, b] = parts;
427
+ return a === 198 && (b === 18 || b === 19);
428
+ }
429
+ async function detectProxyInterference(host) {
430
+ const findings = [];
431
+ const listening = [];
432
+ await Promise.all(
433
+ PROXY_PORTS.map(async ({ port, name }) => {
434
+ if (await isPortListening(port)) listening.push(`${name} \u2192 127.0.0.1:${port}`);
435
+ })
436
+ );
437
+ if (listening.length > 0) {
438
+ findings.push({
439
+ message: "\u672C\u673A\u6709\u4EE3\u7406\u7A0B\u5E8F\u5728\u76D1\u542C\uFF1A\n " + listening.join("\n "),
440
+ advice: "\u5173\u95ED\u8FD9\u4E9B\u4EE3\u7406\u7A0B\u5E8F\uFF0C\u6216\u5728\u5176\u4E2D\u628A\u672C\u7A0B\u5E8F\u52A0\u5165\u76F4\u8FDE/\u7ED5\u8FC7\u540D\u5355"
441
+ });
442
+ }
443
+ const sysProxy = await readSystemProxy();
444
+ if (sysProxy) {
445
+ findings.push({
446
+ message: `\u7CFB\u7EDF\u4EE3\u7406\u5DF2\u542F\u7528\uFF1A${sysProxy}`,
447
+ advice: "\u5728\u300C\u8BBE\u7F6E \u2192 \u7F51\u7EDC\u548C Internet \u2192 \u4EE3\u7406\u300D\u4E2D\u5173\u95ED\u7CFB\u7EDF\u4EE3\u7406"
448
+ });
449
+ }
450
+ const probeHost = host?.trim() || "www.cloudflare.com";
451
+ try {
452
+ const { resolve4 } = await import("node:dns/promises");
453
+ const ips = await resolve4(probeHost).catch(() => []);
454
+ const fake = ips.find((ip) => isFakeIp(ip));
455
+ if (fake) {
456
+ findings.push({
457
+ message: `DNS \u628A ${probeHost} \u89E3\u6790\u5230 ${fake}\uFF08198.18/15 \u4FDD\u7559\u6BB5\uFF09\uFF0CTUN / fake-ip \u6B63\u5728\u751F\u6548`,
458
+ advice: "\u5173\u95ED\u4EE3\u7406\u5DE5\u5177\u7684 TUN \u6A21\u5F0F\u4E0E fake-ip DNS"
459
+ });
460
+ }
461
+ } catch {
462
+ }
463
+ return { clean: findings.length === 0, findings };
464
+ }
465
+ function isMsysPollutedPath(value) {
466
+ const looksWindows = /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\");
467
+ const hasMsysHint = /node_modules|bash-win32|@dsh-ops|msys/i.test(value);
468
+ return looksWindows || hasMsysHint;
469
+ }
331
470
 
332
- // src/true-probe.ts
471
+ // src/speed-url.ts
333
472
  import * as tls from "node:tls";
473
+ var SPEED_URL_PRESETS = [
474
+ { url: "speed.cloudflare.com/__down?bytes=104857600", label: "Cloudflare \u5B98\u65B9" },
475
+ { url: "cf.090227.xyz/__down?bytes=104857600", label: "CM \u63D0\u4F9B" }
476
+ ];
477
+ var DEFAULT_SPEED_URL = SPEED_URL_PRESETS[0].url;
478
+ function parseSpeedUrl(raw, fallbackScheme = "https") {
479
+ const value = (raw ?? "").trim();
480
+ if (!value) throw new Error("\u6D4B\u901F\u5730\u5740\u4E3A\u7A7A");
481
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(value);
482
+ if (schemeMatch) {
483
+ const scheme2 = schemeMatch[1].toLowerCase();
484
+ if (scheme2 !== "http" && scheme2 !== "https") {
485
+ throw new Error(`\u4E0D\u652F\u6301\u7684\u6D4B\u901F\u5730\u5740\u534F\u8BAE\uFF1A${scheme2}\uFF08\u53EA\u652F\u6301 http / https\uFF09`);
486
+ }
487
+ }
488
+ let normalized = value;
489
+ if (normalized.startsWith("//")) {
490
+ normalized = `${fallbackScheme}:${normalized}`;
491
+ } else if (!/^https?:\/\//i.test(normalized)) {
492
+ normalized = `${fallbackScheme}://${normalized}`;
493
+ }
494
+ let parsed;
495
+ try {
496
+ parsed = new URL(normalized);
497
+ } catch {
498
+ throw new Error(`\u6D4B\u901F\u5730\u5740\u65E0\u6CD5\u89E3\u6790\uFF1A${value}`);
499
+ }
500
+ const scheme = parsed.protocol.replace(":", "").toLowerCase();
501
+ if (scheme !== "http" && scheme !== "https") {
502
+ throw new Error(`\u4E0D\u652F\u6301\u7684\u6D4B\u901F\u5730\u5740\u534F\u8BAE\uFF1A${scheme}`);
503
+ }
504
+ if (!parsed.hostname) throw new Error(`\u6D4B\u901F\u5730\u5740\u7F3A\u5C11\u57DF\u540D\uFF1A${value}`);
505
+ return {
506
+ raw: value,
507
+ scheme,
508
+ host: parsed.hostname,
509
+ path: `${parsed.pathname}${parsed.search}` || "/"
510
+ };
511
+ }
512
+ function schemeForPort(port) {
513
+ return port === 443 || port === 2053 || port === 2083 || port === 2087 || port === 2096 || port === 8443 ? "https" : "http";
514
+ }
515
+ function checkSpeedUrlAvailable(parsed, timeoutMs = 6e3) {
516
+ return new Promise((resolve) => {
517
+ let settled = false;
518
+ let received = "";
519
+ const finish = (available, status, error) => {
520
+ if (settled) return;
521
+ settled = true;
522
+ clearTimeout(timer);
523
+ try {
524
+ socket.destroy();
525
+ } catch {
526
+ }
527
+ resolve({ available, status, error });
528
+ };
529
+ const path = parsed.path.includes("bytes=") ? parsed.path.replace(/bytes=\d+/, "bytes=1024") : parsed.path;
530
+ const request = [
531
+ `GET ${path} HTTP/1.1`,
532
+ `Host: ${parsed.host}`,
533
+ `User-Agent: ${USER_AGENT_FOR_CHECK}`,
534
+ "Accept: */*",
535
+ "Connection: close",
536
+ "",
537
+ ""
538
+ ].join("\r\n");
539
+ const connectOptions = {
540
+ host: parsed.host,
541
+ port: parsed.scheme === "https" ? 443 : 80,
542
+ servername: parsed.host,
543
+ rejectUnauthorized: false
544
+ };
545
+ const socket = parsed.scheme === "https" ? tls.connect(connectOptions, () => socket.write(request)) : (
546
+ // http 明文:用 net 连接
547
+ (() => {
548
+ const net3 = __require("node:net");
549
+ const s = net3.connect({ host: parsed.host, port: 80 }, () => s.write(request));
550
+ return s;
551
+ })()
552
+ );
553
+ const timer = setTimeout(() => finish(false, 0, "\u9884\u68C0\u8D85\u65F6"), timeoutMs);
554
+ socket.setTimeout(timeoutMs, () => finish(false, 0, "\u9884\u68C0\u8D85\u65F6"));
555
+ socket.on("error", (err) => finish(false, 0, err.message));
556
+ socket.on("data", (chunk) => {
557
+ received += chunk.toString("utf8");
558
+ const m = /^HTTP\/1\.[01]\s+(\d{3})/.exec(received);
559
+ if (m) {
560
+ const status = Number(m[1]);
561
+ finish(status === 200 || status === 206, status);
562
+ }
563
+ });
564
+ });
565
+ }
566
+ var USER_AGENT_FOR_CHECK = "CFPilot-Scanner/1.0 (+https://github.com/xmwengxing/CFPilot)";
567
+ async function pickAvailableSpeedUrl(candidates, log) {
568
+ const list = candidates.length > 0 ? candidates : [DEFAULT_SPEED_URL];
569
+ for (const candidate of list) {
570
+ let parsed;
571
+ try {
572
+ parsed = parseSpeedUrl(candidate, "https");
573
+ } catch {
574
+ continue;
575
+ }
576
+ const result = await checkSpeedUrlAvailable(parsed);
577
+ if (result.available) {
578
+ log?.(`\u6D4B\u901F\u5730\u5740\u53EF\u7528\uFF1A${candidate}`);
579
+ return candidate;
580
+ }
581
+ log?.(`\u6D4B\u901F\u5730\u5740\u4E0D\u53EF\u7528\uFF08${result.error ?? "HTTP " + result.status}\uFF09\uFF1A${candidate}`);
582
+ }
583
+ log?.(`\u6240\u6709\u6D4B\u901F\u5730\u5740\u90FD\u4E0D\u53EF\u7528\uFF0C\u56DE\u9000\u5230\u9ED8\u8BA4\uFF1A${DEFAULT_SPEED_URL}`);
584
+ return DEFAULT_SPEED_URL;
585
+ }
586
+ function withBytes(parsed, bytes) {
587
+ if (/bytes=\d+/.test(parsed.path)) {
588
+ return parsed.path.replace(/bytes=\d+/, `bytes=${bytes}`);
589
+ }
590
+ const sep = parsed.path.includes("?") ? "&" : "?";
591
+ return `${parsed.path}${sep}bytes=${bytes}`;
592
+ }
593
+
594
+ // src/scan.ts
595
+ import * as net2 from "node:net";
596
+
597
+ // src/true-probe.ts
598
+ import * as tls2 from "node:tls";
334
599
  import * as crypto from "node:crypto";
335
600
  var USER_AGENT = "CFPilot-Scanner/1.0 (+https://github.com/xmwengxing/CFPilot)";
336
601
  function extractColo(text) {
@@ -364,7 +629,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3, path = "/__edge_probe") {
364
629
  "",
365
630
  ""
366
631
  ].join("\r\n");
367
- const socket = tls.connect(
632
+ const socket = tls2.connect(
368
633
  {
369
634
  host: ip,
370
635
  port,
@@ -430,7 +695,7 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3, path = "/__edg
430
695
  "",
431
696
  ""
432
697
  ].join("\r\n");
433
- const socket = tls.connect(
698
+ const socket = tls2.connect(
434
699
  {
435
700
  host: ip,
436
701
  port,
@@ -489,13 +754,215 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3, path = "/__edg
489
754
  socket.on("close", settle);
490
755
  });
491
756
  }
757
+ function measureThroughput(options) {
758
+ const {
759
+ ip,
760
+ port,
761
+ sniHost,
762
+ path,
763
+ requestHost = sniHost,
764
+ durationMs = 6e3,
765
+ warmupMs = 1e3,
766
+ windowMs = 400,
767
+ connections = 3,
768
+ timeoutMs = 2e4
769
+ } = options;
770
+ const connCount = Math.max(1, Math.min(8, connections));
771
+ return new Promise((resolve) => {
772
+ const started = Date.now();
773
+ let settled = false;
774
+ let totalBytes = 0;
775
+ let windowBytes = 0;
776
+ let windowStart = started;
777
+ let peak = 0;
778
+ let openedCount = 0;
779
+ let lastError = "";
780
+ const sockets = [];
781
+ const finish = () => {
782
+ if (settled) return;
783
+ settled = true;
784
+ clearInterval(ticker);
785
+ clearTimeout(timer);
786
+ for (const s of sockets) {
787
+ try {
788
+ s.destroy();
789
+ } catch {
790
+ }
791
+ }
792
+ const elapsedNow = Date.now() - started;
793
+ if (elapsedNow >= warmupMs) {
794
+ const dt = (Date.now() - windowStart) / 1e3;
795
+ if (dt > 0) {
796
+ const speed = windowBytes / dt / 1024 / 1024;
797
+ if (speed > peak) peak = speed;
798
+ }
799
+ }
800
+ const elapsed = Math.max(1e-3, (Date.now() - started) / 1e3);
801
+ resolve({
802
+ ok: totalBytes > 0,
803
+ bytes: totalBytes,
804
+ ms: Date.now() - started,
805
+ peakMBps: Number(peak.toFixed(2)),
806
+ avgMBps: Number((totalBytes / 1024 / 1024 / elapsed).toFixed(2)),
807
+ connections: openedCount,
808
+ error: totalBytes > 0 ? void 0 : lastError || "\u672A\u6536\u5230\u6570\u636E"
809
+ });
810
+ };
811
+ const request = [
812
+ `GET ${path} HTTP/1.1`,
813
+ `Host: ${requestHost}`,
814
+ `User-Agent: ${USER_AGENT}`,
815
+ "Accept: */*",
816
+ "Accept-Encoding: identity",
817
+ "Connection: close",
818
+ "",
819
+ ""
820
+ ].join("\r\n");
821
+ const openOne = () => {
822
+ let headerDone = false;
823
+ let headerBuffer = "";
824
+ const socket = tls2.connect(
825
+ {
826
+ host: ip,
827
+ port,
828
+ servername: sniHost,
829
+ rejectUnauthorized: false,
830
+ ALPNProtocols: ["http/1.1"]
831
+ },
832
+ () => {
833
+ openedCount++;
834
+ socket.write(request);
835
+ }
836
+ );
837
+ sockets.push(socket);
838
+ socket.setTimeout(timeoutMs, () => {
839
+ try {
840
+ socket.destroy();
841
+ } catch {
842
+ }
843
+ });
844
+ socket.on("error", (err) => {
845
+ lastError = err.message;
846
+ });
847
+ socket.on("data", (chunk) => {
848
+ if (!headerDone) {
849
+ headerBuffer += chunk.toString("latin1");
850
+ const idx = headerBuffer.indexOf("\r\n\r\n");
851
+ if (idx < 0) {
852
+ if (headerBuffer.length > 8192) {
853
+ lastError = "\u54CD\u5E94\u5934\u5F02\u5E38";
854
+ socket.destroy();
855
+ }
856
+ return;
857
+ }
858
+ const head = headerBuffer.slice(0, idx);
859
+ const status = /^HTTP\/1\.[01]\s+(\d{3})/.exec(head)?.[1];
860
+ if (status !== "200") {
861
+ lastError = `HTTP ${status ?? "?"}`;
862
+ socket.destroy();
863
+ return;
864
+ }
865
+ headerDone = true;
866
+ const body = Buffer.from(headerBuffer, "latin1").subarray(idx + 4);
867
+ totalBytes += body.byteLength;
868
+ windowBytes += body.byteLength;
869
+ headerBuffer = "";
870
+ } else {
871
+ totalBytes += chunk.byteLength;
872
+ windowBytes += chunk.byteLength;
873
+ }
874
+ if (Date.now() - started > durationMs) finish();
875
+ });
876
+ socket.on("end", () => {
877
+ if (sockets.every((s) => s.destroyed || s.readableEnded)) finish();
878
+ });
879
+ socket.on("close", () => {
880
+ if (sockets.every((s) => s.destroyed)) finish();
881
+ });
882
+ };
883
+ const ticker = setInterval(() => {
884
+ const now = Date.now();
885
+ const dt = (now - windowStart) / 1e3;
886
+ if (dt <= 0) return;
887
+ const bytes = windowBytes;
888
+ windowBytes = 0;
889
+ windowStart = now;
890
+ if (now - started < warmupMs) return;
891
+ const speed = bytes / dt / 1024 / 1024;
892
+ if (speed > peak) peak = speed;
893
+ }, windowMs);
894
+ const timer = setTimeout(finish, durationMs + 4e3);
895
+ for (let i = 0; i < connCount; i++) {
896
+ setTimeout(openOne, i * 60);
897
+ }
898
+ });
899
+ }
900
+
901
+ // src/countries.ts
902
+ var COUNTRY_COLOS = {
903
+ HK: ["HKG"],
904
+ TW: ["TPE", "KHH"],
905
+ JP: ["NRT", "KIX", "CTS", "FUK"],
906
+ KR: ["ICN", "PUS"],
907
+ SG: ["SIN"],
908
+ MY: ["KUL"],
909
+ TH: ["BKK"],
910
+ VN: ["SGN", "HAN"],
911
+ ID: ["CGK", "DPS"],
912
+ PH: ["MNL"],
913
+ IN: ["BOM", "MAA", "DEL"],
914
+ AU: ["SYD", "MEL", "BNE", "PER"],
915
+ NZ: ["AKL"],
916
+ US: ["LAX", "SJC", "SEA", "ORD", "DFW", "IAD", "EWR", "ATL", "MIA", "DEN", "PHX", "BOS"],
917
+ CA: ["YYZ", "YVR", "YYC"],
918
+ GB: ["LHR", "MAN"],
919
+ DE: ["FRA", "MUC", "BER"],
920
+ FR: ["CDG", "MRS"],
921
+ NL: ["AMS"],
922
+ IT: ["MXP", "FCO"],
923
+ ES: ["MAD", "BCN"],
924
+ SE: ["ARN"],
925
+ CH: ["ZRH"],
926
+ AT: ["VIE"],
927
+ PL: ["WAW"],
928
+ RU: ["DME", "SVO"],
929
+ TR: ["IST"],
930
+ AE: ["DXB"],
931
+ IL: ["TLV"],
932
+ ZA: ["JNB"],
933
+ BR: ["GRU", "GIG"],
934
+ AR: ["EZE"],
935
+ CL: ["SCL"],
936
+ MX: ["MEX"]
937
+ };
938
+ var coloToCountry = /* @__PURE__ */ new Map();
939
+ for (const [country, colos] of Object.entries(COUNTRY_COLOS)) {
940
+ for (const colo of colos) coloToCountry.set(colo, country);
941
+ }
942
+ function countryOfColo(colo) {
943
+ return coloToCountry.get(colo.toUpperCase()) ?? "";
944
+ }
945
+ function isKnownCountry(code) {
946
+ return Object.prototype.hasOwnProperty.call(COUNTRY_COLOS, code.toUpperCase());
947
+ }
948
+ var SUPPORTED_COUNTRIES = Object.keys(COUNTRY_COLOS);
949
+ function normalizeCountryFilter(input) {
950
+ if (!Array.isArray(input)) return [];
951
+ const out = [];
952
+ for (const raw of input) {
953
+ const code = String(raw ?? "").trim().toUpperCase();
954
+ if (!code || out.includes(code)) continue;
955
+ if (isKnownCountry(code)) out.push(code);
956
+ }
957
+ return out;
958
+ }
492
959
 
493
960
  // src/scan.ts
494
961
  async function tcpPing(ip, port, timeoutMs) {
495
962
  return new Promise((resolve) => {
496
963
  const started = Date.now();
497
964
  let settled = false;
498
- const socket = new net.Socket();
965
+ const socket = new net2.Socket();
499
966
  const done = (ok) => {
500
967
  if (settled) return;
501
968
  settled = true;
@@ -574,13 +1041,65 @@ async function runScan(options) {
574
1041
  return { ...item, probe: result };
575
1042
  });
576
1043
  const wsOk = probeResults.filter((r) => r.probe.ok);
577
- const stage2 = wsOk.sort((a, b) => a.latency - b.latency).slice(0, speedLimit);
1044
+ const countryFilter = normalizeCountryFilter(options.countries ?? []);
1045
+ let wsFiltered = wsOk;
1046
+ let appliedCountries = [];
1047
+ if (countryFilter.length > 0 && wsOk.length > 0) {
1048
+ const matched = wsOk.filter((r) => countryFilter.includes(countryOfColo(r.probe.colo || "")));
1049
+ if (matched.length > 0) {
1050
+ wsFiltered = matched;
1051
+ appliedCountries = countryFilter;
1052
+ report({
1053
+ type: "progress",
1054
+ stage: 2,
1055
+ done: wsOk.length,
1056
+ total: wsOk.length,
1057
+ message: `\u56FD\u5BB6\u7B5B\u9009 ${countryFilter.join("/")}\uFF1A\u4FDD\u7559 ${matched.length}/${wsOk.length}\uFF0C\u8FDB\u5165\u6D4B\u901F`
1058
+ });
1059
+ } else {
1060
+ report({
1061
+ type: "progress",
1062
+ stage: 2,
1063
+ done: wsOk.length,
1064
+ total: wsOk.length,
1065
+ message: `\u56FD\u5BB6\u7B5B\u9009 ${countryFilter.join("/")} \u540E\u65E0\u5019\u9009\uFF0C\u5DF2\u5FFD\u7565\uFF08\u91C7\u6837\u592A\u5C0F\uFF1F\u8C03\u5230 500~1000 \u518D\u8BD5\uFF09`
1066
+ });
1067
+ }
1068
+ }
1069
+ const stage2 = wsFiltered.sort((a, b) => a.latency - b.latency).slice(0, speedLimit);
578
1070
  let speedResults;
1071
+ const speedMode = options.speedMode ?? "probe";
579
1072
  if (options.skipSpeed) {
580
1073
  report({ type: "stage", stage: 3, name: "\u8DF3\u8FC7\u6D4B\u901F\uFF08\u6309\u5EF6\u8FDF\u6392\u5E8F\uFF09", total: stage2.length });
581
1074
  speedResults = stage2.map((r) => ({ ...r }));
1075
+ } else if (speedMode === "url") {
1076
+ report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5\uFF08\u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF09", total: stage2.length });
1077
+ const parsedUrl = parseSpeedUrl(options.speedUrl ?? DEFAULT_SPEED_URL, schemeForPort(options.port));
1078
+ const requestPath = withBytes(parsedUrl, options.speedBytes ?? 104857600);
1079
+ let speedDone = 0;
1080
+ speedResults = await pool(stage2, speedConcurrency, async (item) => {
1081
+ const result = await measureThroughput({
1082
+ ip: item.ip,
1083
+ port: options.port,
1084
+ sniHost: parsedUrl.host,
1085
+ requestHost: parsedUrl.host,
1086
+ path: requestPath,
1087
+ connections: options.speedConnections ?? 3,
1088
+ durationMs: options.speedDurationMs ?? 6e3,
1089
+ timeoutMs: options.speedTimeoutMs ?? 2e4
1090
+ });
1091
+ speedDone++;
1092
+ report({
1093
+ type: "progress",
1094
+ stage: 3,
1095
+ done: speedDone,
1096
+ total: stage2.length,
1097
+ message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.peakMBps.toFixed(1)} MB/s`
1098
+ });
1099
+ return { ...item, speed: result.ok ? result.peakMBps : 0 };
1100
+ });
582
1101
  } else {
583
- report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5", total: stage2.length });
1102
+ report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5\uFF08\u9879\u76EE\u63A2\u9488\u7AEF\u70B9\uFF09", total: stage2.length });
584
1103
  let speedDone = 0;
585
1104
  speedResults = await pool(stage2, speedConcurrency, async (item) => {
586
1105
  const result = await measureDownload(
@@ -599,15 +1118,15 @@ async function runScan(options) {
599
1118
  total: stage2.length,
600
1119
  message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.speedMBps.toFixed(1)} MB/s`
601
1120
  });
602
- return { ...item, download: result };
1121
+ return { ...item, speed: result.ok ? result.speedMBps : 0 };
603
1122
  });
604
1123
  }
605
- const entries = speedResults.filter((r) => options.skipSpeed ? true : Boolean(r.download && r.download.ok && r.download.speedMBps > 0)).map((r) => ({
1124
+ const entries = speedResults.filter((r) => options.skipSpeed ? true : Boolean(r.speed && r.speed > 0)).map((r) => ({
606
1125
  ip: r.ip,
607
1126
  port: options.port,
608
1127
  colo: r.probe.colo || "UNK",
609
1128
  latency: r.latency,
610
- speed: r.download ? Number(r.download.speedMBps.toFixed(2)) : 0
1129
+ speed: r.speed ? Number(r.speed.toFixed(2)) : 0
611
1130
  })).sort((a, b) => options.skipSpeed ? a.latency - b.latency : b.speed - a.speed || a.latency - b.latency).slice(0, top);
612
1131
  for (const entry of entries) {
613
1132
  report({ type: "result", entry });
@@ -619,7 +1138,9 @@ async function runScan(options) {
619
1138
  tcpOk: tcpOk.length,
620
1139
  wsOk: wsOk.length,
621
1140
  speedOk: entries.length,
622
- elapsedMs: Date.now() - started
1141
+ elapsedMs: Date.now() - started,
1142
+ countries: appliedCountries,
1143
+ wsFiltered: wsFiltered.length
623
1144
  }
624
1145
  };
625
1146
  report({ type: "done", count: entries.length });
@@ -857,6 +1378,8 @@ async function handleRequest(req, res) {
857
1378
  host: body.host.trim(),
858
1379
  port: body.port ?? 443,
859
1380
  candidates: plan.candidates,
1381
+ // 国家过滤(可多选):不传 = 全量;无效代码在 normalizeCountryFilter 里被丢弃
1382
+ countries: Array.isArray(body.countries) ? body.countries : void 0,
860
1383
  top: body.top ?? 15,
861
1384
  onProgress: (event) => broadcast(job, event)
862
1385
  });
@@ -948,11 +1471,22 @@ scan \u9009\u9879:
948
1471
  --no-callback \u53EA\u6253\u5370\u7ED3\u679C\uFF0C\u4E0D\u56DE\u586B
949
1472
  --path <path> \u9636\u6BB5\u4E8C\u6821\u9A8C\u7684 WebSocket \u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
950
1473
  \uFF08\u5BF9\u63A5\u5176\u5B83\u9879\u76EE\u65F6\u586B\u5B83\u81EA\u5DF1\u7684 WS \u8DEF\u5F84\uFF0C\u4F8B\u5982 /vless \u6216 /<uuid>\uFF09
951
- --probe-path <path> \u9636\u6BB5\u4E09\u6D4B\u901F\u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
952
- --no-speed \u8DF3\u8FC7\u6D4B\u901F\uFF0C\u53EA\u6309\u5EF6\u8FDF\u6392\u5E8F\uFF08\u76EE\u6807\u6CA1\u6709 CFPilot \u63A2\u9488\u65F6\u7528\uFF09
1474
+ --probe-path <path> probe \u6A21\u5F0F\u7684\u6D4B\u901F\u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
1475
+ --speed-mode <mode> \u6D4B\u901F\u65B9\u5F0F\uFF1Aauto\uFF08\u9ED8\u8BA4\uFF09/ probe / url
1476
+ auto = \u76EE\u6807\u6709\u63A2\u9488\u7AEF\u70B9\u5C31\u7528\u5B83\uFF0C\u5426\u5219\u81EA\u52A8\u6539\u7528\u516C\u5F00\u6D4B\u901F\u5730\u5740
1477
+ probe = \u53EA\u7528\u76EE\u6807\u9879\u76EE\u7684\u63A2\u9488\u7AEF\u70B9\uFF08\u6700\u5FE0\u5B9E\uFF0C\u8981\u6C42\u5BF9\u65B9\u6709\u8BE5\u7AEF\u70B9\uFF09
1478
+ url = \u53EA\u7528\u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF08\u4EFB\u4F55\u9879\u76EE\u90FD\u80FD\u6D4B\uFF09
1479
+ --speed-url <url> \u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF0C\u9ED8\u8BA4 ${DEFAULT_SPEED_URL}
1480
+ \u53EF\u6362\u6210\u4EFB\u610F\u8FD4\u56DE\u5927\u6587\u4EF6\u7684\u5730\u5740
1481
+ --speed-conns <n> \u6D4B\u901F\u5E76\u53D1\u8FDE\u63A5\u6570\uFF0C\u9ED8\u8BA4 3
1482
+ \u5355\u6761 TCP \u8FDE\u63A5\u53D7\u62E5\u585E\u7A97\u53E3\u9650\u5236\uFF0C\u591A\u8FDE\u63A5\u66F4\u63A5\u8FD1\u771F\u5B9E\u5E26\u5BBD
1483
+ --speed-duration <ms> \u6D4B\u901F\u65F6\u957F\uFF0C\u9ED8\u8BA4 6000
1484
+ --no-speed \u8DF3\u8FC7\u6D4B\u901F\uFF0C\u53EA\u6309\u5EF6\u8FDF\u6392\u5E8F
953
1485
  --top <n> \u8FD4\u56DE\u6761\u6570\uFF0C\u9ED8\u8BA4 15
954
1486
  --concurrency <n> \u9636\u6BB5\u4E00\u5E76\u53D1\uFF0C\u9ED8\u8BA4 300
955
1487
  --sample <n> \u5019\u9009 IP \u91C7\u6837\u6570\uFF0C\u9ED8\u8BA4 20000
1488
+ --countries <a,b> \u56FD\u5BB6 / \u5730\u533A\u8FC7\u6EE4\uFF08\u53EF\u591A\u9009\uFF0C\u9017\u53F7\u5206\u9694\uFF0C\u5982 HK,JP,SG\uFF09
1489
+ \u4E0D\u9009 = \u5168\u91CF\u3002\u673A\u623F\u6765\u81EA 101 \u63E1\u624B\u54CD\u5E94\uFF1B\u7B5B\u5B8C\u4E3A\u7A7A\u4F1A\u81EA\u52A8\u5FFD\u7565
956
1490
  --cidr <a,b> \u81EA\u5B9A\u4E49\u7F51\u6BB5\uFF08\u53EF\u91CD\u590D\uFF0C\u7528\u9017\u53F7\u5206\u9694\uFF09
957
1491
  --ip-file <path> \u4F7F\u7528\u672C\u5730 IP \u5217\u8868\u6587\u4EF6
958
1492
  --max-latency <ms> \u9636\u6BB5\u4E00\u5EF6\u8FDF\u4E0A\u9650\uFF0C\u9ED8\u8BA4 0\uFF08\u4E0D\u9650\uFF09
@@ -972,6 +1506,35 @@ serve \u9009\u9879:
972
1506
  ${NAME} scan --host cfpilot.example.com --token abc123
973
1507
  ${NAME} serve
974
1508
  `;
1509
+ function normalizeWsPath(raw, fallback) {
1510
+ const value = (raw ?? fallback).trim();
1511
+ if (!value) return fallback;
1512
+ if (isMsysPollutedPath(value)) {
1513
+ process.stderr.write(
1514
+ `
1515
+ \u2717 --path \u88AB MSYS\uFF08Git-Bash\uFF09\u8F6C\u6362\u6210\u4E86 Windows \u8DEF\u5F84\uFF1A
1516
+ \u6536\u5230\u7684\u662F\uFF1A${value}
1517
+
1518
+ \u8FD9\u662F Git-Bash \u7684\u81EA\u52A8\u8DEF\u5F84\u8F6C\u6362\u884C\u4E3A\uFF0C\u4E0D\u662F\u672C\u5DE5\u5177\u7684\u95EE\u9898\u3002
1519
+ \u4E09\u79CD\u7ED5\u5F00\u65B9\u5F0F\uFF0C\u4EFB\u9009\u5176\u4E00\uFF1A
1520
+ 1. \u7528\u7B49\u53F7\u5199\u6CD5\uFF1A --path=/vless
1521
+ 2. \u52A0\u73AF\u5883\u53D8\u91CF\uFF1A MSYS_NO_PATHCONV=1 cfpilot-scanner scan ...
1522
+ 3. \u6362\u7528 PowerShell / CMD \u6267\u884C
1523
+ `
1524
+ );
1525
+ process.exit(1);
1526
+ }
1527
+ if (!value.startsWith("/")) {
1528
+ process.stderr.write(
1529
+ `
1530
+ \u2717 --path \u5FC5\u987B\u4EE5 / \u5F00\u5934\uFF0C\u6536\u5230\u7684\u662F\uFF1A\u300C${value}\u300D
1531
+ \u4F8B\u5982\uFF1A--path /vless \u6216 --path /\uFF08\u76EE\u6807\u9879\u76EE\u7528\u6839\u8DEF\u5F84\u65F6\uFF09
1532
+ `
1533
+ );
1534
+ process.exit(1);
1535
+ }
1536
+ return value;
1537
+ }
975
1538
  async function main() {
976
1539
  const args = parseArgs(process.argv.slice(2));
977
1540
  const command = args.command && !args.command.startsWith("-") ? args.command : "";
@@ -1097,9 +1660,10 @@ async function commandScan(args) {
1097
1660
  const top = flagNumber(args, "top", 15);
1098
1661
  const token = flagString(args, "token");
1099
1662
  const noCallback = flagBool(args, "no-callback");
1100
- const wsPath = flagString(args, "path", "/__edge_probe");
1101
- const probePath = flagString(args, "probe-path", "/__edge_probe");
1663
+ const wsPath = normalizeWsPath(flagString(args, "path"), "/__edge_probe");
1664
+ const probePath = normalizeWsPath(flagString(args, "probe-path"), "/__edge_probe");
1102
1665
  const noSpeed = flagBool(args, "no-speed");
1666
+ const countries = normalizeCountryFilter(flagList(args, "countries"));
1103
1667
  const callback = noCallback ? "" : flagString(args, "callback", token ? `https://${host}/api/v1/preferred-ips` : "");
1104
1668
  if (!quiet) {
1105
1669
  process.stdout.write(`
@@ -1107,8 +1671,31 @@ ${NAME} v${VERSION}
1107
1671
  `);
1108
1672
  process.stdout.write(`\u76EE\u6807\u57DF\u540D\uFF1A${host}:${port}\uFF08\u5C06\u4F5C\u4E3A TLS SNI \u4E0E HTTP Host\uFF09
1109
1673
  `);
1110
- process.stdout.write("\u6B63\u5728\u51C6\u5907 Cloudflare \u7F51\u6BB5\u2026\n");
1674
+ process.stdout.write(`WS \u8DEF\u5F84\uFF1A${wsPath}
1675
+ `);
1676
+ if (countries.length > 0) {
1677
+ process.stdout.write(`\u56FD\u5BB6\u7B5B\u9009\uFF1A${countries.join(" / ")}\uFF08\u4E0D\u586B\u5219\u5168\u91CF\uFF09
1678
+ `);
1679
+ }
1680
+ }
1681
+ if (!quiet) {
1682
+ const env = await detectProxyInterference(host);
1683
+ if (!env.clean) {
1684
+ process.stdout.write("\n\u26A0\uFE0F \u68C0\u6D4B\u5230\u53EF\u80FD\u5E72\u6270\u4F18\u9009\u7684\u56E0\u7D20\uFF1A\n");
1685
+ for (const f of env.findings) {
1686
+ process.stdout.write(` ${f.message}
1687
+ `);
1688
+ process.stdout.write(` \u2192 ${f.advice}
1689
+ `);
1690
+ }
1691
+ process.stdout.write("\n \u4F18\u9009\u6D4B\u7684\u662F\u300C\u672C\u673A\u76F4\u8FDE\u8BE5 IP\u300D\u7684\u771F\u5B9E\u8D28\u91CF\u3002\n");
1692
+ process.stdout.write(" \u6D41\u91CF\u88AB\u4EE3\u7406\u63A5\u7BA1\u65F6\uFF0C\u7ED3\u679C\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u6240\u6709 IP \u6210\u7EE9\u8D8B\u4E8E\u4E00\u81F4\uFF0C\n");
1693
+ process.stdout.write(" \u5BFC\u5165\u5BA2\u6237\u7AEF\u540E\u5927\u91CF -1\uFF08\u56E0\u4E3A\u4ECE\u672A\u771F\u6B63\u76F4\u8FDE\u9A8C\u8BC1\u8FC7\uFF09\u3002\n");
1694
+ process.stdout.write(" \u2192 \u5EFA\u8BAE\u5173\u95ED\u4EE3\u7406\u5DE5\u5177\uFF08\u542B TUN \u6A21\u5F0F\u4E0E\u7CFB\u7EDF\u4EE3\u7406\uFF09\u540E\u91CD\u8DD1\u3002\n");
1695
+ process.stdout.write(" \u2192 \u6216\u628A\u672C\u7A0B\u5E8F\u4E0E\u5F85\u6D4B IP \u6BB5\u8BBE\u4E3A\u76F4\u8FDE\u3002\n\n");
1696
+ }
1111
1697
  }
1698
+ if (!quiet) process.stdout.write("\u6B63\u5728\u51C6\u5907 Cloudflare \u7F51\u6BB5\u2026\n");
1112
1699
  const candidates = await resolveCandidatesFromArgs(args);
1113
1700
  if (candidates.length === 0) {
1114
1701
  process.stderr.write("\u5019\u9009 IP \u4E3A\u7A7A\uFF0C\u8BF7\u68C0\u67E5 --ip-file / --cidr \u53C2\u6570\n");
@@ -1117,27 +1704,51 @@ ${NAME} v${VERSION}
1117
1704
  if (!quiet) process.stdout.write(`\u5019\u9009 IP\uFF1A${candidates.length} \u4E2A
1118
1705
  `);
1119
1706
  let skipSpeed = noSpeed;
1707
+ let speedMode = "probe";
1708
+ let speedUrl = flagString(args, "speed-url", DEFAULT_SPEED_URL);
1120
1709
  if (!skipSpeed) {
1121
- try {
1122
- const probe = await fetch(`https://${host}${probePath}?bytes=1024`, {
1123
- method: "GET",
1124
- signal: AbortSignal.timeout(8e3)
1125
- });
1126
- if (!probe.headers.get("x-probe-bytes")) {
1127
- skipSpeed = true;
1710
+ const forcedMode = flagString(args, "speed-mode");
1711
+ if (forcedMode === "url") {
1712
+ speedMode = "url";
1713
+ if (!quiet) process.stdout.write(`\u6D4B\u901F\u65B9\u5F0F\uFF1A\u516C\u5F00\u6D4B\u901F\u5730\u5740\uFF08${speedUrl}\uFF09
1714
+ `);
1715
+ } else if (forcedMode === "probe") {
1716
+ speedMode = "probe";
1717
+ } else {
1718
+ let hasProbe = false;
1719
+ try {
1720
+ const probe = await fetch(`https://${host}${probePath}?bytes=1024`, {
1721
+ method: "GET",
1722
+ signal: AbortSignal.timeout(8e3)
1723
+ });
1724
+ hasProbe = Boolean(probe.headers.get("x-probe-bytes"));
1725
+ } catch {
1726
+ hasProbe = false;
1727
+ }
1728
+ if (hasProbe) {
1729
+ speedMode = "probe";
1730
+ if (!quiet) process.stdout.write("\u6D4B\u901F\u65B9\u5F0F\uFF1A\u9879\u76EE\u63A2\u9488\u7AEF\u70B9\uFF08\u6700\u5FE0\u5B9E\uFF09\n");
1731
+ } else {
1732
+ speedMode = "url";
1128
1733
  if (!quiet) {
1129
- process.stdout.write("\n\u63D0\u793A\uFF1A" + probePath + " \u4E0D\u662F CFPilot \u63A2\u9488\u7AEF\u70B9\uFF0C\u5DF2\u8DF3\u8FC7\u6D4B\u901F\uFF08\u53EA\u6309\u5EF6\u8FDF\u6392\u5E8F\uFF09\n");
1130
- process.stdout.write(" \u5982\u9700\u6D4B\u901F\uFF0C\u8BF7\u7528 --probe-path \u6307\u5B9A\u76EE\u6807\u9879\u76EE\u7684\u6D4B\u901F\u8DEF\u5F84\n");
1734
+ process.stdout.write(`
1735
+ \u63D0\u793A\uFF1A${probePath} \u4E0D\u662F CFPilot \u63A2\u9488\u7AEF\u70B9\uFF0C\u6539\u7528\u516C\u5F00\u6D4B\u901F\u5730\u5740\u6D4B\u901F
1736
+ `);
1737
+ process.stdout.write(" \uFF08\u4ECD\u4F1A\u5148\u505A 101 \u6821\u9A8C\uFF0C\u56E0\u6B64\u4E0D\u4F1A\u9009\u51FA\u8FDE\u4E0D\u4E0A\u7684 IP\uFF09\n");
1131
1738
  }
1739
+ speedUrl = await pickAvailableSpeedUrl(
1740
+ [speedUrl, ...SPEED_URL_PRESETS.map((p) => p.url)],
1741
+ quiet ? void 0 : (m) => process.stdout.write(` ${m}
1742
+ `)
1743
+ );
1132
1744
  }
1133
- } catch {
1134
- skipSpeed = true;
1135
1745
  }
1136
1746
  }
1137
1747
  const summary = await runScan({
1138
1748
  host,
1139
1749
  port,
1140
1750
  candidates,
1751
+ countries,
1141
1752
  wsPath,
1142
1753
  probePath,
1143
1754
  skipSpeed,
@@ -1149,6 +1760,10 @@ ${NAME} v${VERSION}
1149
1760
  speedLimit: flagNumber(args, "speed-limit", 30),
1150
1761
  speedTimeoutMs: flagNumber(args, "speed-timeout", 12e3),
1151
1762
  speedBytes: flagNumber(args, "speed-bytes", 5 * 1024 * 1024),
1763
+ speedMode,
1764
+ speedUrl,
1765
+ speedConnections: flagNumber(args, "speed-conns", 3),
1766
+ speedDurationMs: flagNumber(args, "speed-duration", 6e3),
1152
1767
  top,
1153
1768
  onProgress: (event) => printProgress(event, quiet)
1154
1769
  });
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
- {
2
- "name": "cfpilot-scanner",
3
- "version": "0.2.0",
4
- "description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
5
- "license": "MIT",
6
- "author": "CFPilot Contributors",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/xmwengxing/CFPilot.git"
10
- },
11
- "keywords": [
12
- "cloudflare",
13
- "ip",
14
- "optimize",
15
- "scanner",
16
- "vless",
17
- "websocket",
18
- "cfpilot"
19
- ],
20
- "type": "module",
21
- "bin": {
22
- "cfpilot-scanner": "dist/cli.js"
23
- },
24
- "files": [
25
- "dist",
26
- "README.md"
27
- ],
28
- "engines": {
29
- "node": ">=18.17.0"
30
- },
31
- "scripts": {
32
- "build": "node scripts/build.mjs",
33
- "typecheck": "tsc --noEmit -p tsconfig.json",
34
- "prepublishOnly": "npm run build"
35
- },
36
- "devDependencies": {
37
- "@types/node": "^26.6.5",
38
- "esbuild": "^0.28.2",
39
- "typescript": "^5.9.3"
40
- }
41
- }
1
+ {
2
+ "name": "cfpilot-scanner",
3
+ "version": "0.3.0",
4
+ "description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
5
+ "license": "MIT",
6
+ "author": "CFPilot Contributors",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/xmwengxing/CFPilot.git"
10
+ },
11
+ "keywords": [
12
+ "cloudflare",
13
+ "ip",
14
+ "optimize",
15
+ "scanner",
16
+ "vless",
17
+ "websocket",
18
+ "cfpilot"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "cfpilot-scanner": "dist/cli.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18.17.0"
30
+ },
31
+ "scripts": {
32
+ "build": "node scripts/build.mjs",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^26.6.5",
38
+ "esbuild": "^0.28.2",
39
+ "typescript": "^5.9.3"
40
+ }
41
+ }