cfpilot-scanner 0.1.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 +198 -89
  2. package/dist/cli.js +721 -54
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,89 +1,198 @@
1
- # cfpilot-scanner
2
-
3
- **CFPilot 本地真连接优选器** —— 用你自己的 Worker 域名做 SNI/Host,完成
4
- **WebSocket 101 真连接校验**,只保留真正能连通的 Cloudflare 优选 IP,
5
- 彻底消灭客户端里的 `-1`。
6
-
7
- > 绝大多数"优选 IP"工具只做 TCP 握手(端口通就算可用),
8
- > 结果填进客户端大量 `-1`。本工具会真的发起 TLS + WebSocket 升级,
9
- > 走完你自己 Worker 的入站路径,只有返回 `101` 的 IP 才会被保留。
10
-
11
- ## 安装
12
-
13
- ```bash
14
- npx -y cfpilot-scanner --help
15
- ```
16
-
17
- 无需安装,`npx` 直接运行;也可 `npm i -g cfpilot-scanner`。
18
-
19
- ## 快速开始
20
-
21
- ```bash
22
- # 1. 在你的面板里生成一次性扫描令牌(有效期短,用完即废)
23
- # 2. 执行优选(域名必须是绑好自定义域名的那个)
24
- npx -y cfpilot-scanner scan --host cfpilot.example.com --token <令牌>
25
-
26
- # 结果会自动回填到面板的「手动优选 IP」池
27
- ```
28
-
29
- 只打印不回填:
30
-
31
- ```bash
32
- npx -y cfpilot-scanner scan --host cfpilot.example.com --no-callback --top 20
33
- ```
34
-
35
- ## 三阶扫描
36
-
37
- | 阶段 | 做什么 | 默认开销 |
38
- |------|--------|----------|
39
- | 一阶 · 延迟探测 | 采样大量 IP 做 TCP 连接测延迟 | 采样 20000,并发 300 |
40
- | 二阶 · 真连接校验 | 对候选发起 **TLS + WebSocket 101** 握手 | 120 个候选 |
41
- | 三阶 · 下行测速 | 通过 `/__edge_probe` 流式下载测真实速度 | 30 个候选,5 MB |
42
-
43
- 只有通过二阶的 IP 才算"可用",三阶用于排序。
44
-
45
- ## 常用参数
46
-
47
- ```
48
- --host <domain> 你的 Worker 自定义域名(SNI 与 Host)
49
- --token <token> 面板生成的一次性令牌
50
- --top <n> 保留条数(默认 15)
51
- --concurrency <n> 一阶并发(默认 300)
52
- --sample <n> 候选采样数(默认 20000)
53
- --cidr <a,b> 自定义网段
54
- --ip-file <path> 使用本地 IP 列表(支持 CloudflareSpeedTest 的 result.csv)
55
- --max-latency <ms> 一阶延迟上限
56
- --json 以 JSON 输出
57
- ```
58
-
59
- ## 本地面板桥接
60
-
61
- ```bash
62
- npx -y cfpilot-scanner serve
63
- ```
64
-
65
- 在 `127.0.0.1:19527` 起一个本地服务,面板里点「本地真连接优选」会自动唤醒它,
66
- 通过 SSE 实时推送进度,不需要手动复制令牌。
67
-
68
- ## 输出示例
69
-
70
- ```
71
- 阶段一:采样 20000 个候选,并发 300 …
72
- 阶段二:120 个候选进入 101 校验 …
73
- 阶段三:30 个候选测速(5 MB)…
74
- ✅ 可用 14 个,已回填面板
75
-
76
- 104.17.242.168:443 12ms 18.4 MB/s
77
- 104.19.73.179:443 18ms 15.1 MB/s
78
- ...
79
- ```
80
-
81
- ## 为什么需要它
82
-
83
- 随机抽取的 Cloudflare 官方 IP 里,能真正完成 WebSocket 升级的比例只有约 1/4,
84
- 而且**因地区、运营商、时间而异**。只有用你自己的域名实测过,
85
- 才能得到当前网络下真正可用的那批 IP。
86
-
87
- ## 许可
88
-
89
- MIT
1
+ # cfpilot-scanner
2
+
3
+ **CFPilot 本地真连接优选器** —— 用你自己的 Worker 域名做 SNI/Host,完成
4
+ **WebSocket 101 真连接校验**,只保留真正能连通的 Cloudflare 优选 IP,
5
+ 彻底消灭客户端里的 `-1`。
6
+
7
+ > 绝大多数"优选 IP"工具只做 TCP 握手(端口通就算可用),
8
+ > 结果填进客户端大量 `-1`。本工具会真的发起 TLS + WebSocket 升级,
9
+ > 走完你自己 Worker 的入站路径,只有返回 `101` 的 IP 才会被保留。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ npx -y cfpilot-scanner --help
15
+ ```
16
+
17
+ 无需安装,`npx` 直接运行;也可 `npm i -g cfpilot-scanner`。
18
+
19
+ ## 快速开始
20
+
21
+ ```bash
22
+ # 1. 在你的面板里生成一次性扫描令牌(有效期短,用完即废)
23
+ # 2. 执行优选(域名必须是绑好自定义域名的那个)
24
+ npx -y cfpilot-scanner scan --host cfpilot.example.com --token <令牌>
25
+
26
+ # 结果会自动回填到面板的「手动优选 IP」池
27
+ ```
28
+
29
+ 只打印不回填:
30
+
31
+ ```bash
32
+ npx -y cfpilot-scanner scan --host cfpilot.example.com --no-callback --top 20
33
+ ```
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
+
67
+ ## 对接任意 VLESS-WS 项目(CFNext / cfnew / BPB / edgetunnel)
68
+
69
+ 不依赖 CFPilot 自己的探针端点,只要知道你项目的 **WebSocket 路径**即可:
70
+
71
+ ```bash
72
+ # CFNext:路径通常就是 UUID(或面板里配置的 path)
73
+ npx -y cfpilot-scanner scan --host your-domain.com --path /<uuid> --no-callback
74
+
75
+ # 常见约定:/vless
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
80
+ ```
81
+
82
+ **路径必须与目标项目实际的 WebSocket 路径完全一致**,填错一个 IP 都选不出来。
83
+ 不知道就分别试 `/` 和 `/vless`,能出结果的那个就是对的。
84
+
85
+ - 阶段二会对 `<path>` 发起真实的 **TLS + WebSocket 101** 握手,只有 101 才算可用
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
+ 太短会被慢启动拉低,太长浪费时间。
129
+
130
+ ## 输出:直接可复制的列表
131
+
132
+ 扫描结束后会额外打印一段,**一行一个,直接粘进同类项目的「自定义 IP / 优选 IP」输入框**:
133
+
134
+ ```
135
+ ── 可直接复制(粘贴到「自定义 IP / 优选 IP」输入框)──
136
+ 104.19.73.179:443#LAX
137
+ 104.17.242.168:443#LAX
138
+ 104.16.59.243:443#LAX
139
+ 104.27.19.224:443#AMS
140
+ ```
141
+
142
+ (`IP:端口#机房` 是这类项目通用的语法;优选几个就有几行,不用再手动删改)
143
+
144
+ ## 三阶扫描
145
+
146
+ | 阶段 | 做什么 | 默认开销 |
147
+ |------|--------|----------|
148
+ | 一阶 · 延迟探测 | 采样大量 IP 做 TCP 连接测延迟 | 采样 20000,并发 300 |
149
+ | 二阶 · 真连接校验 | 对候选发起 **TLS + WebSocket 101** 握手 | 120 个候选 |
150
+ | 三阶 · 下行测速 | 通过 `/__edge_probe` 流式下载测真实速度 | 30 个候选,5 MB |
151
+
152
+ 只有通过二阶的 IP 才算"可用",三阶用于排序。
153
+
154
+ ## 常用参数
155
+
156
+ ```
157
+ --host <domain> 你的 Worker 自定义域名(SNI 与 Host)
158
+ --token <token> 面板生成的一次性令牌
159
+ --top <n> 保留条数(默认 15)
160
+ --concurrency <n> 一阶并发(默认 300)
161
+ --sample <n> 候选采样数(默认 20000)
162
+ --cidr <a,b> 自定义网段
163
+ --ip-file <path> 使用本地 IP 列表(支持 CloudflareSpeedTest 的 result.csv)
164
+ --max-latency <ms> 一阶延迟上限
165
+ --json 以 JSON 输出
166
+ ```
167
+
168
+ ## 本地面板桥接
169
+
170
+ ```bash
171
+ npx -y cfpilot-scanner serve
172
+ ```
173
+
174
+ 在 `127.0.0.1:19527` 起一个本地服务,面板里点「本地真连接优选」会自动唤醒它,
175
+ 通过 SSE 实时推送进度,不需要手动复制令牌。
176
+
177
+ ## 输出示例
178
+
179
+ ```
180
+ 阶段一:采样 20000 个候选,并发 300 …
181
+ 阶段二:120 个候选进入 101 校验 …
182
+ 阶段三:30 个候选测速(5 MB)…
183
+ ✅ 可用 14 个,已回填面板
184
+
185
+ 104.17.242.168:443 12ms 18.4 MB/s
186
+ 104.19.73.179:443 18ms 15.1 MB/s
187
+ ...
188
+ ```
189
+
190
+ ## 为什么需要它
191
+
192
+ 随机抽取的 Cloudflare 官方 IP 里,能真正完成 WebSocket 升级的比例只有约 1/4,
193
+ 而且**因地区、运营商、时间而异**。只有用你自己的域名实测过,
194
+ 才能得到当前网络下真正可用的那批 IP。
195
+
196
+ ## 许可
197
+
198
+ MIT
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,18 +363,246 @@ 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) {
337
602
  const match = /cf-ray:\s*[0-9a-f]+-([A-Z]{3})/i.exec(text);
338
603
  return match ? match[1].toUpperCase() : "";
339
604
  }
340
- function probeWs101(ip, port, host, timeoutMs = 3e3) {
605
+ function probeWs101(ip, port, host, timeoutMs = 3e3, path = "/__edge_probe") {
341
606
  return new Promise((resolve) => {
342
607
  const started = Date.now();
343
608
  const key = crypto.randomBytes(16).toString("base64");
@@ -354,7 +619,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3) {
354
619
  resolve({ ...result, latencyMs: Date.now() - started });
355
620
  };
356
621
  const request = [
357
- `GET /__edge_probe HTTP/1.1`,
622
+ `GET ${path} HTTP/1.1`,
358
623
  `Host: ${host}`,
359
624
  `Upgrade: websocket`,
360
625
  `Connection: Upgrade`,
@@ -364,7 +629,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3) {
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,
@@ -405,7 +670,7 @@ function probeWs101(ip, port, host, timeoutMs = 3e3) {
405
670
  });
406
671
  });
407
672
  }
408
- function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
673
+ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3, path = "/__edge_probe") {
409
674
  return new Promise((resolve) => {
410
675
  const started = Date.now();
411
676
  let headerEnd = -1;
@@ -423,14 +688,14 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
423
688
  resolve({ ...result, ms: Date.now() - started });
424
689
  };
425
690
  const request = [
426
- `GET /__edge_probe?bytes=${bytes} HTTP/1.1`,
691
+ `GET ${path}?bytes=${bytes} HTTP/1.1`,
427
692
  `Host: ${host}`,
428
693
  `User-Agent: ${USER_AGENT}`,
429
694
  `Accept: */*`,
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,
@@ -463,6 +728,11 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
463
728
  socket.on("error", (err) => {
464
729
  finish({ ok: false, bytes: received, speedMBps: 0, error: err.message });
465
730
  });
731
+ const settle = () => {
732
+ if (settled) return;
733
+ const seconds = Math.max(1e-3, (Date.now() - started) / 1e3);
734
+ finish({ ok: received > 0, bytes: received, speedMBps: received / 1024 / 1024 / seconds });
735
+ };
466
736
  socket.on("data", (chunk) => {
467
737
  if (headerEnd < 0) {
468
738
  buffer += chunk.toString("latin1");
@@ -475,19 +745,224 @@ function measureDownload(ip, port, host, bytes, timeoutMs = 12e3) {
475
745
  const body = Buffer.from(buffer, "latin1").subarray(headerEnd);
476
746
  received += body.byteLength;
477
747
  headerEnd = 0;
478
- return;
748
+ } else {
749
+ received += chunk.byteLength;
479
750
  }
480
- received += chunk.byteLength;
751
+ if (received >= bytes) settle();
481
752
  });
753
+ socket.on("end", settle);
754
+ socket.on("close", settle);
755
+ });
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
+ }
482
898
  });
483
899
  }
484
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
+ }
959
+
485
960
  // src/scan.ts
486
961
  async function tcpPing(ip, port, timeoutMs) {
487
962
  return new Promise((resolve) => {
488
963
  const started = Date.now();
489
964
  let settled = false;
490
- const socket = new net.Socket();
965
+ const socket = new net2.Socket();
491
966
  const done = (ok) => {
492
967
  if (settled) return;
493
968
  settled = true;
@@ -554,7 +1029,7 @@ async function runScan(options) {
554
1029
  report({ type: "stage", stage: 2, name: "WebSocket 101 \u771F\u8FDE\u63A5\u6821\u9A8C", total: stage1.length });
555
1030
  let probeDone = 0;
556
1031
  const probeResults = await pool(stage1, probeConcurrency, async (item) => {
557
- const result = await probeWs101(item.ip, options.port, options.host, probeTimeoutMs);
1032
+ const result = await probeWs101(item.ip, options.port, options.host, probeTimeoutMs, options.wsPath ?? "/__edge_probe");
558
1033
  probeDone++;
559
1034
  report({
560
1035
  type: "progress",
@@ -566,34 +1041,93 @@ async function runScan(options) {
566
1041
  return { ...item, probe: result };
567
1042
  });
568
1043
  const wsOk = probeResults.filter((r) => r.probe.ok);
569
- const stage2 = wsOk.sort((a, b) => a.latency - b.latency).slice(0, speedLimit);
570
- report({ type: "stage", stage: 3, name: "\u771F\u5B9E\u4E0B\u884C\u901F\u5EA6\u6D4B\u8BD5", total: stage2.length });
571
- let speedDone = 0;
572
- const speedResults = await pool(stage2, speedConcurrency, async (item) => {
573
- const result = await measureDownload(
574
- item.ip,
575
- options.port,
576
- options.host,
577
- speedBytes,
578
- speedTimeoutMs
579
- );
580
- speedDone++;
581
- report({
582
- type: "progress",
583
- stage: 3,
584
- done: speedDone,
585
- total: stage2.length,
586
- message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.speedMBps.toFixed(1)} MB/s`
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);
1070
+ let speedResults;
1071
+ const speedMode = options.speedMode ?? "probe";
1072
+ if (options.skipSpeed) {
1073
+ report({ type: "stage", stage: 3, name: "\u8DF3\u8FC7\u6D4B\u901F\uFF08\u6309\u5EF6\u8FDF\u6392\u5E8F\uFF09", total: stage2.length });
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 };
587
1100
  });
588
- return { ...item, download: result };
589
- });
590
- const entries = speedResults.filter((r) => r.download.ok && r.download.speedMBps > 0).map((r) => ({
1101
+ } else {
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 });
1103
+ let speedDone = 0;
1104
+ speedResults = await pool(stage2, speedConcurrency, async (item) => {
1105
+ const result = await measureDownload(
1106
+ item.ip,
1107
+ options.port,
1108
+ options.host,
1109
+ speedBytes,
1110
+ speedTimeoutMs,
1111
+ options.probePath ?? "/__edge_probe"
1112
+ );
1113
+ speedDone++;
1114
+ report({
1115
+ type: "progress",
1116
+ stage: 3,
1117
+ done: speedDone,
1118
+ total: stage2.length,
1119
+ message: `\u6D4B\u901F ${speedDone}/${stage2.length} ${result.speedMBps.toFixed(1)} MB/s`
1120
+ });
1121
+ return { ...item, speed: result.ok ? result.speedMBps : 0 };
1122
+ });
1123
+ }
1124
+ const entries = speedResults.filter((r) => options.skipSpeed ? true : Boolean(r.speed && r.speed > 0)).map((r) => ({
591
1125
  ip: r.ip,
592
1126
  port: options.port,
593
1127
  colo: r.probe.colo || "UNK",
594
1128
  latency: r.latency,
595
- speed: Number(r.download.speedMBps.toFixed(1))
596
- })).sort((a, b) => b.speed - a.speed || a.latency - b.latency).slice(0, top);
1129
+ speed: r.speed ? Number(r.speed.toFixed(2)) : 0
1130
+ })).sort((a, b) => options.skipSpeed ? a.latency - b.latency : b.speed - a.speed || a.latency - b.latency).slice(0, top);
597
1131
  for (const entry of entries) {
598
1132
  report({ type: "result", entry });
599
1133
  }
@@ -604,12 +1138,17 @@ async function runScan(options) {
604
1138
  tcpOk: tcpOk.length,
605
1139
  wsOk: wsOk.length,
606
1140
  speedOk: entries.length,
607
- elapsedMs: Date.now() - started
1141
+ elapsedMs: Date.now() - started,
1142
+ countries: appliedCountries,
1143
+ wsFiltered: wsFiltered.length
608
1144
  }
609
1145
  };
610
1146
  report({ type: "done", count: entries.length });
611
1147
  return summary;
612
1148
  }
1149
+ function formatCopyable(entries) {
1150
+ return entries.map((e) => `${e.ip}:${e.port}#${e.colo}`).join("\n");
1151
+ }
613
1152
  function formatEntries(entries) {
614
1153
  return entries.map((e) => `${e.ip}:${e.port}#${e.colo}_${e.latency}_${e.speed}`);
615
1154
  }
@@ -639,7 +1178,7 @@ async function postResults(callbackUrl, token, entries, timeoutMs = 15e3) {
639
1178
 
640
1179
  // src/server.ts
641
1180
  import { createServer } from "node:http";
642
- var BRIDGE_VERSION = "0.1.0";
1181
+ var BRIDGE_VERSION = "0.2.0";
643
1182
  var DEFAULT_BRIDGE_PORT = 19527;
644
1183
  function normalizeSample(value) {
645
1184
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -839,6 +1378,8 @@ async function handleRequest(req, res) {
839
1378
  host: body.host.trim(),
840
1379
  port: body.port ?? 443,
841
1380
  candidates: plan.candidates,
1381
+ // 国家过滤(可多选):不传 = 全量;无效代码在 normalizeCountryFilter 里被丢弃
1382
+ countries: Array.isArray(body.countries) ? body.countries : void 0,
842
1383
  top: body.top ?? 15,
843
1384
  onProgress: (event) => broadcast(job, event)
844
1385
  });
@@ -906,7 +1447,7 @@ async function handleRequest(req, res) {
906
1447
  }
907
1448
 
908
1449
  // src/version.ts
909
- var VERSION = "0.1.0";
1450
+ var VERSION = "0.2.0";
910
1451
  var NAME = "cfpilot-scanner";
911
1452
 
912
1453
  // src/cli.ts
@@ -928,9 +1469,24 @@ scan \u9009\u9879:
928
1469
  --token <token> \u56DE\u586B\u4EE4\u724C\uFF08\u9762\u677F\u751F\u6210\u7684\u4E00\u6B21\u6027\u4EE4\u724C\u6216 SCANNER_TOKEN\uFF09
929
1470
  --callback <url> \u56DE\u586B\u5730\u5740\uFF0C\u9ED8\u8BA4 https://<host>/api/v1/preferred-ips
930
1471
  --no-callback \u53EA\u6253\u5370\u7ED3\u679C\uFF0C\u4E0D\u56DE\u586B
1472
+ --path <path> \u9636\u6BB5\u4E8C\u6821\u9A8C\u7684 WebSocket \u8DEF\u5F84\uFF0C\u9ED8\u8BA4 /__edge_probe
1473
+ \uFF08\u5BF9\u63A5\u5176\u5B83\u9879\u76EE\u65F6\u586B\u5B83\u81EA\u5DF1\u7684 WS \u8DEF\u5F84\uFF0C\u4F8B\u5982 /vless \u6216 /<uuid>\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
931
1485
  --top <n> \u8FD4\u56DE\u6761\u6570\uFF0C\u9ED8\u8BA4 15
932
1486
  --concurrency <n> \u9636\u6BB5\u4E00\u5E76\u53D1\uFF0C\u9ED8\u8BA4 300
933
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
934
1490
  --cidr <a,b> \u81EA\u5B9A\u4E49\u7F51\u6BB5\uFF08\u53EF\u91CD\u590D\uFF0C\u7528\u9017\u53F7\u5206\u9694\uFF09
935
1491
  --ip-file <path> \u4F7F\u7528\u672C\u5730 IP \u5217\u8868\u6587\u4EF6
936
1492
  --max-latency <ms> \u9636\u6BB5\u4E00\u5EF6\u8FDF\u4E0A\u9650\uFF0C\u9ED8\u8BA4 0\uFF08\u4E0D\u9650\uFF09
@@ -950,6 +1506,35 @@ serve \u9009\u9879:
950
1506
  ${NAME} scan --host cfpilot.example.com --token abc123
951
1507
  ${NAME} serve
952
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
+ }
953
1538
  async function main() {
954
1539
  const args = parseArgs(process.argv.slice(2));
955
1540
  const command = args.command && !args.command.startsWith("-") ? args.command : "";
@@ -1057,6 +1642,11 @@ function printTable(entries) {
1057
1642
  `);
1058
1643
  });
1059
1644
  }
1645
+ function printCopyable(entries) {
1646
+ if (entries.length === 0) return;
1647
+ process.stdout.write("\n\u2500\u2500 \u53EF\u76F4\u63A5\u590D\u5236\uFF08\u7C98\u8D34\u5230\u300C\u81EA\u5B9A\u4E49 IP / \u4F18\u9009 IP\u300D\u8F93\u5165\u6846\uFF09\u2500\u2500\n");
1648
+ process.stdout.write(formatCopyable(entries) + "\n");
1649
+ }
1060
1650
  async function commandScan(args) {
1061
1651
  const host = flagString(args, "host");
1062
1652
  const quiet = flagBool(args, "quiet");
@@ -1070,6 +1660,10 @@ async function commandScan(args) {
1070
1660
  const top = flagNumber(args, "top", 15);
1071
1661
  const token = flagString(args, "token");
1072
1662
  const noCallback = flagBool(args, "no-callback");
1663
+ const wsPath = normalizeWsPath(flagString(args, "path"), "/__edge_probe");
1664
+ const probePath = normalizeWsPath(flagString(args, "probe-path"), "/__edge_probe");
1665
+ const noSpeed = flagBool(args, "no-speed");
1666
+ const countries = normalizeCountryFilter(flagList(args, "countries"));
1073
1667
  const callback = noCallback ? "" : flagString(args, "callback", token ? `https://${host}/api/v1/preferred-ips` : "");
1074
1668
  if (!quiet) {
1075
1669
  process.stdout.write(`
@@ -1077,8 +1671,31 @@ ${NAME} v${VERSION}
1077
1671
  `);
1078
1672
  process.stdout.write(`\u76EE\u6807\u57DF\u540D\uFF1A${host}:${port}\uFF08\u5C06\u4F5C\u4E3A TLS SNI \u4E0E HTTP Host\uFF09
1079
1673
  `);
1080
- 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
+ }
1081
1697
  }
1698
+ if (!quiet) process.stdout.write("\u6B63\u5728\u51C6\u5907 Cloudflare \u7F51\u6BB5\u2026\n");
1082
1699
  const candidates = await resolveCandidatesFromArgs(args);
1083
1700
  if (candidates.length === 0) {
1084
1701
  process.stderr.write("\u5019\u9009 IP \u4E3A\u7A7A\uFF0C\u8BF7\u68C0\u67E5 --ip-file / --cidr \u53C2\u6570\n");
@@ -1086,10 +1703,55 @@ ${NAME} v${VERSION}
1086
1703
  }
1087
1704
  if (!quiet) process.stdout.write(`\u5019\u9009 IP\uFF1A${candidates.length} \u4E2A
1088
1705
  `);
1706
+ let skipSpeed = noSpeed;
1707
+ let speedMode = "probe";
1708
+ let speedUrl = flagString(args, "speed-url", DEFAULT_SPEED_URL);
1709
+ if (!skipSpeed) {
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";
1733
+ if (!quiet) {
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");
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
+ );
1744
+ }
1745
+ }
1746
+ }
1089
1747
  const summary = await runScan({
1090
1748
  host,
1091
1749
  port,
1092
1750
  candidates,
1751
+ countries,
1752
+ wsPath,
1753
+ probePath,
1754
+ skipSpeed,
1093
1755
  concurrency: flagNumber(args, "concurrency", 300),
1094
1756
  tcpTimeoutMs: flagNumber(args, "timeout", 1200),
1095
1757
  maxLatencyMs: flagNumber(args, "max-latency", 0),
@@ -1098,6 +1760,10 @@ ${NAME} v${VERSION}
1098
1760
  speedLimit: flagNumber(args, "speed-limit", 30),
1099
1761
  speedTimeoutMs: flagNumber(args, "speed-timeout", 12e3),
1100
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),
1101
1767
  top,
1102
1768
  onProgress: (event) => printProgress(event, quiet)
1103
1769
  });
@@ -1108,6 +1774,7 @@ ${NAME} v${VERSION}
1108
1774
  );
1109
1775
  } else {
1110
1776
  printTable(summary.entries);
1777
+ printCopyable(summary.entries);
1111
1778
  }
1112
1779
  if (callback && token) {
1113
1780
  const result = await postResults(callback, token, formatEntries(summary.entries));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cfpilot-scanner",
3
- "version": "0.1.0",
4
- "description": "CFPilot 本地真连接优选器:用你自己的 Worker 域名做 SNI/Host 完成 WebSocket 101 真连接校验,彻底消灭 -1 节点",
3
+ "version": "0.3.0",
4
+ "description": "CFPilot 优选器:用你自己的域名做 WebSocket 101 真连接校验,只保留真正可用的 Cloudflare IP;兼容任意 VLESS-WS 项目",
5
5
  "license": "MIT",
6
6
  "author": "CFPilot Contributors",
7
7
  "repository": {