diff --git a/README.md b/README.md index 95ba437..07ff4e3 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,17 @@ uv run xrayst ## Quick Start -Windows 下推荐直接使用 `run.bat`: +Windows 下推荐直接使用 `run.ps1`: ```powershell -.\run.bat "vless://你的节点" +powershell -ExecutionPolicy Bypass -File .\run.ps1 "vless://你的节点" ``` -`run.bat` 只接受一个 VLESS 参数,并固定执行: +`run.ps1` 只接受一个 VLESS 参数,并固定执行: -- 第一阶段随机抽取 `3000` 个 IP,其他参数使用 `auto` 默认值。 -- 第二阶段保留真实延迟前 `200` 个,其他参数使用 `auto` 默认值。 -- 第三阶段使用第二阶段全部结果测速,最终保留前 `10` 个,并发 `5`,timeout `10s`。 +- 第一阶段随机抽取 `3000` 个 IP,按 TCP 延迟保留前 `500` 个,并发 `200`。 +- 第二阶段读取第一阶段结果,保留真实延迟前 `50` 个,并发 `30`,timeout `8s`。 +- 第三阶段读取第二阶段全部结果测速,最终保留前 `20` 个;默认下载 `10MB`,硬性总耗时上限 `10s`。 完整流程: @@ -65,8 +65,8 @@ uv run auto 'vless://你的节点' -r 1000 -p 10 -c 20 --speed-count 50 | 阶段 | 命令 | 输入 | 输出 | | --- | --- | --- | --- | -| `ping` | `uv run auto --stage ping -r 1000 -c 20` | Cloudflare IPv4 段 | `txt/auto_01_ping_ips.txt` | -| `latency` | `uv run auto --stage latency 'vless://你的节点' -p 200 -c 20` | `txt/auto_01_ping_ips.txt` | `txt/auto_02_latency.txt` | +| `ping` | `uv run auto --stage ping -r 1000 -p 500 -c 20` | Cloudflare IPv4 段 | `txt/auto_01_ping_ips.txt` | +| `latency` | `uv run auto --stage latency 'vless://你的节点' -p 200 -c 20 --latency-timeout 8` | `txt/auto_01_ping_ips.txt` | `txt/auto_02_latency.txt` | | `speed` | `uv run auto --stage speed 'vless://你的节点' --speed-count 50 -p 10` | `txt/auto_02_latency.txt` | `txt/result.txt` | ### Stage 1: Ping @@ -74,13 +74,14 @@ uv run auto 'vless://你的节点' -r 1000 -p 10 -c 20 --speed-count 50 随机抽取 Cloudflare IP,并测试 TCP 443 是否可连通。 ```powershell -uv run auto --stage ping -r 1000 -c 20 +uv run auto --stage ping -r 1000 -p 500 -c 20 ``` 行为: - 通过的 IP 会实时追加写入 `txt/auto_01_ping_ips.txt`。 - 控制台会实时输出 `OK` 或 `FAIL`。 +- 阶段结束后会按 TCP 探测延迟升序排序,并只保留前 `-p` 个写回文件。 - 这一阶段不需要 VLESS 节点。 ### Stage 2: Latency @@ -88,7 +89,7 @@ uv run auto --stage ping -r 1000 -c 20 读取第一阶段输出,通过 Xray 临时代理测试真实 VLESS 链路延迟。 ```powershell -uv run auto --stage latency 'vless://你的节点' -p 200 -c 20 +uv run auto --stage latency 'vless://你的节点' -p 200 -c 20 --latency-timeout 8 ``` 配置: @@ -98,7 +99,7 @@ uv run auto --stage latency 'vless://你的节点' -p 200 -c 20 | 输入文件 | `txt/auto_01_ping_ips.txt` | | 输出文件 | `txt/auto_02_latency.txt` | | 测试地址 | `https://www.google.com/generate_204` | -| Timeout | `3s` | +| Timeout | 默认 `3s`,可用 `--latency-timeout` 调整 | | 排序规则 | 延迟低的优先 | `-p` 控制这一阶段保留多少个结果。 @@ -119,7 +120,7 @@ uv run auto --stage speed 'vless://你的节点' --speed-count 50 -p 10 --speed- | 输出文件 | `txt/result.txt` | | 下载地址 | `https://cachefly.cachefly.net/50mb.test` | | 实际读取 | `10MB` | -| Timeout | `10s` | +| Timeout | 默认 `10s`,是硬性总耗时上限 | 排序规则: @@ -172,6 +173,8 @@ uv run auto [node] [options] | `-r, --random-count` | `100` | 第一阶段随机抽取的 IP 数量 | | `-p, --top` | `10` | 当前阶段保留数量;speed 阶段表示最终输出数量 | | `-c, --concurrency` | `20` | ping 和 latency 阶段并发数 | +| `--node-file` | 无 | 从文件读取 VLESS 节点,适合避免命令行中的 `&` 被 shell 解析 | +| `--latency-timeout` | `3` | latency 阶段真实延迟测试 timeout 秒数 | | `--speed-count` | `0` | speed 阶段取多少个延迟结果测试速度,`0` 表示全部 | | `--speed-concurrency` | `1` | speed 阶段并发测速数量 | | `--speed-timeout` | `10` | speed 阶段单个下载测速 timeout 秒数 | @@ -180,20 +183,20 @@ uv run auto [node] [options] | `--latency-output` | `txt/auto_02_latency.txt` | 第二阶段输出 | | `-o, --output` | `txt/result.txt` | 最终节点输出 | -### run.bat +### run.ps1 -Windows 批处理入口,用于固定执行推荐三阶段流程。 +Windows PowerShell 入口,用于固定执行推荐三阶段流程。VLESS URL 包含 `&`,不建议再用 `.bat` 直接接收参数。 ```powershell -.\run.bat "vless://你的节点" +powershell -ExecutionPolicy Bypass -File .\run.ps1 "vless://你的节点" ``` 等价于依次执行: ```powershell -uv run auto --stage ping -r 3000 -uv run auto --stage latency "vless://你的节点" -p 200 -uv run auto --stage speed "vless://你的节点" --speed-count 0 -p 10 --speed-concurrency 5 --speed-timeout 10 +uv run auto --stage ping -r 3000 -p 500 -c 200 +uv run auto --stage latency --node-file "临时节点文件" -p 50 -c 30 --latency-timeout 8 +uv run auto --stage speed --node-file "临时节点文件" --speed-count 0 -p 20 ``` ### cfst diff --git a/auto.py b/auto.py index dc43e2c..fd5f6bc 100644 --- a/auto.py +++ b/auto.py @@ -56,7 +56,7 @@ def with_alias(node_url: str, alias: str) -> str: return urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, quote(alias))) -async def ping_candidates(random_count: int, concurrency: int, output_path: Path) -> list[str]: +async def ping_candidates(random_count: int, concurrency: int, keep_count: int, output_path: Path) -> list[str]: networks = cfst.load_networks(cfst.DEFAULT_IP_FILE, "", not cfst.DEFAULT_IP_FILE.exists()) if not networks: return [] @@ -73,19 +73,22 @@ async def ping_candidates(random_count: int, concurrency: int, output_path: Path return await cfst.probe_ip(ip, "tcp", TARGET_PORT, 1, CF_TIMEOUT_MS, semaphore) tasks = [asyncio.create_task(probe_one(ip)) for ip in candidates] - passed: list[str] = [] + passed: list[cfst.ProbeResult] = [] with output_path.open("a", encoding="utf-8") as fp: for task in asyncio.as_completed(tasks): result = await task if result.received > 0: - passed.append(result.ip) + passed.append(result) fp.write(result.ip + "\n") fp.flush() - console.print(f"OK {result.ip:<15}") + console.print(f"OK {result.ip:<15} {result.avg_latency_ms:>8.2f} ms") else: console.print(f"[dim]FAIL {result.ip:<15}[/dim]") - return passed + selected = sorted(passed, key=lambda item: item.avg_latency_ms)[:keep_count] + selected_ips = [result.ip for result in selected] + write_ip_list(output_path, selected_ips) + return selected_ips def run_xray_proxy_measure( @@ -124,6 +127,7 @@ def test_real_latency( keep_count: int, output_path: Path, concurrency: int, + timeout_s: float, ) -> list[LatencyResult]: results: list[LatencyResult] = [] with ThreadPoolExecutor(max_workers=max(1, concurrency)) as executor: @@ -133,7 +137,7 @@ def test_real_latency( node, ip, xray_path, - lambda port: xrayst.measure_latency(port, xrayst.DEFAULT_LATENCY_URL, XRAY_LATENCY_TIMEOUT_S), + lambda port: xrayst.measure_latency(port, xrayst.DEFAULT_LATENCY_URL, timeout_s), ): ip for ip in ips } @@ -258,11 +262,13 @@ def print_results(results: list[xrayst.TestResult]) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="One-command Cloudflare IP + xray real speed tester.") parser.add_argument("node", nargs="?", help="Input vless:// node URL. Required for latency, speed, and all stages.") + parser.add_argument("--node-file", type=Path, help="Read input vless:// node URL from a file.") parser.add_argument("--stage", choices=["all", "ping", "latency", "speed"], default="all", help="Run one stage or the full pipeline.") parser.add_argument("-r", "--random-count", type=int, default=100, help="Random candidate IP count.") parser.add_argument("-p", "--top", type=int, default=10, help="Final node count to keep.") parser.add_argument("--speed-count", type=int, default=0, help="How many latency results to speed-test. 0 means all.") parser.add_argument("-c", "--concurrency", type=int, default=20, help="Concurrent probes for ping and latency stages.") + parser.add_argument("--latency-timeout", type=float, default=XRAY_LATENCY_TIMEOUT_S, help="Latency test timeout seconds.") parser.add_argument("--speed-concurrency", type=int, default=1, help="Concurrent speed tests.") parser.add_argument("--speed-timeout", type=float, default=XRAY_DOWNLOAD_TIMEOUT_S, help="Download speed test timeout seconds.") parser.add_argument("--xray", type=Path, default=xrayst.DEFAULT_XRAY, help="Path to xray executable.") @@ -280,7 +286,14 @@ async def main() -> int: random.seed(time.time_ns()) needs_node = args.stage in {"all", "latency", "speed"} - if needs_node and not args.node: + node_text = args.node + if args.node_file: + if not args.node_file.exists(): + console.print(f"[red]Node file not found:[/red] {args.node_file}") + return 1 + node_text = args.node_file.read_text(encoding="utf-8").strip().lstrip("\ufeff") + + if needs_node and not node_text: console.print("[red]VLESS node is required for this stage.[/red]") return 1 @@ -291,7 +304,7 @@ async def main() -> int: node: xrayst.VlessNode | None = None if needs_node: try: - node = xrayst.parse_vless(args.node) + node = xrayst.parse_vless(node_text) except ValueError as exc: console.print(f"[red]{exc}[/red]") return 1 @@ -303,7 +316,7 @@ async def main() -> int: if args.stage in {"all", "ping"}: console.print("1/3 TCP probing random IPs...") - ips = await ping_candidates(args.random_count, args.concurrency, args.ping_output) + ips = await ping_candidates(args.random_count, args.concurrency, args.top, args.ping_output) if not ips: console.print("[red]No IP passed the TCP probe.[/red]") return 1 @@ -318,7 +331,15 @@ async def main() -> int: console.print(f"[red]No IPs found in {args.ping_output}.[/red]") return 1 console.print(f"Loaded ping IPs: [cyan]{len(ips)}[/cyan] from [green]{args.ping_output}[/green]") - latency_results = test_real_latency(node, ips, args.xray, args.top, args.latency_output, args.concurrency) + latency_results = test_real_latency( + node, + ips, + args.xray, + args.top, + args.latency_output, + args.concurrency, + args.latency_timeout, + ) if not latency_results: console.print("[red]No IP passed the real latency test.[/red]") return 1 diff --git a/run.bat b/run.bat deleted file mode 100644 index 33d5461..0000000 --- a/run.bat +++ /dev/null @@ -1,20 +0,0 @@ -@echo off -setlocal EnableExtensions EnableDelayedExpansion - -if "%~1"=="" ( - echo Usage: run.bat "vless://..." - exit /b 1 -) - -set "NODE=%~1" - -uv run auto --stage ping -r 3000 -if errorlevel 1 exit /b %errorlevel% - -uv run auto --stage latency "!NODE!" -p 200 -if errorlevel 1 exit /b %errorlevel% - -uv run auto --stage speed "!NODE!" --speed-count 0 -p 10 --speed-concurrency 5 --speed-timeout 10 -if errorlevel 1 exit /b %errorlevel% - -endlocal diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..1b5b339 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,23 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$Node +) + +$ErrorActionPreference = "Stop" + +$nodeFile = Join-Path $env:TEMP "58_fast_vless_node.txt" +Set-Content -LiteralPath $nodeFile -Value $Node -NoNewline -Encoding UTF8 + +try { + uv run auto --stage ping -r 50 -p 500 -c 200 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + uv run auto --stage latency --node-file $nodeFile -p 50 -c 30 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + uv run auto --stage speed --node-file $nodeFile --speed-count 0 -p 20 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} +finally { + Remove-Item -LiteralPath $nodeFile -Force -ErrorAction SilentlyContinue +} diff --git a/xrayst.py b/xrayst.py index 21c5400..0d126eb 100644 --- a/xrayst.py +++ b/xrayst.py @@ -59,9 +59,10 @@ def parse_vless(url: str) -> VlessNode: query = parse_qs(parsed.query) if not parsed.username or not parsed.hostname: raise ValueError("Invalid VLESS URL: missing uuid or host.") + original_host = query.get("host", query.get("sni", [parsed.hostname]))[0] return VlessNode( uuid=parsed.username, - original_host=parsed.hostname, + original_host=original_host, original_port=parsed.port or 443, remark=parsed.fragment, encryption=query.get("encryption", ["none"])[0], @@ -191,11 +192,14 @@ def measure_download(proxy_port: int, url: str, timeout_s: float, limit_bytes: i proxy = f"http://127.0.0.1:{proxy_port}" timeout = httpx.Timeout(timeout_s, connect=timeout_s) started = time.perf_counter() + deadline = started + timeout_s bytes_read = 0 with httpx.Client(proxy=proxy, timeout=timeout, follow_redirects=True) as client: with client.stream("GET", url) as response: response.raise_for_status() for chunk in response.iter_bytes(chunk_size=65536): + if time.perf_counter() >= deadline: + break if not chunk: continue bytes_read += len(chunk)