onesvm-browser-server/bench/proxy/probe.py
chii 983259836d chore: init workspace with onesvm-dev-md + casa-commander
docs: 联网搜索服务架构方案全套(plan-final/design-arch/选型决策/整合导览/MCP文档/部署预设/联调手册)
bench: 5 方案 + 代理 + 站点矩阵本机实测工程(无密钥)
部署目标:primary mgr1 先行测试(待批准后执行)
2026-09-01 15:19:52 +08:00

276 lines
9.8 KiB
Python
Executable file

#!/usr/bin/env python3
"""Probe mihomo proxies. Credentials never written to output."""
from __future__ import annotations
import argparse
import concurrent.futures
import datetime as dt
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
from lib import api_quote, infer_region, is_info_node
DEFAULT_DELAY_URL = "https://www.google.com/generate_204"
TARGETS = [
"https://www.google.com/generate_204",
"https://www.google.com/",
"https://www.bing.com/",
"https://www.reddit.com/",
"https://www.amazon.com/",
"https://r.jina.ai/",
"https://duckduckgo.com/",
]
SELECTOR = "🚀节点选择"
def now_iso() -> str:
return dt.datetime.now().astimezone().isoformat(timespec="seconds")
def api(base: str, path: str, method: str = "GET", body: Any | None = None, timeout: float = 15) -> Any:
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
def list_leaf_proxies(base: str) -> list[dict[str, str]]:
data = api(base, "/proxies")
out: list[dict[str, str]] = []
for name, meta in (data or {}).get("proxies", {}).items():
ptype = meta.get("type") or ""
if ptype in {"Selector", "URLTest", "Fallback", "LoadBalance", "Relay", "Compatible", "Direct", "Reject", "Pass", "RejectDrop"}:
continue
if name in {"DIRECT", "REJECT", "GLOBAL", "COMPATIBLE", "PASS"}:
continue
if is_info_node(name):
continue
out.append({"name": name, "type": ptype.lower(), "region": infer_region(name)})
out.sort(key=lambda x: (x["region"], x["name"]))
return out
def delay_one(base: str, name: str, url: str, timeout_ms: int) -> dict[str, Any]:
q = api_quote(name)
path = f"/proxies/{q}/delay?timeout={timeout_ms}&url={urllib.parse.quote(url, safe='')}"
started = time.perf_counter()
try:
data = api(base, path, timeout=timeout_ms / 1000 + 3)
delay = data.get("delay") if isinstance(data, dict) else None
return {
"name": name,
"ok": isinstance(delay, int) and delay > 0,
"delay_ms": delay,
"error": None,
"elapsed_s": round(time.perf_counter() - started, 3),
}
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:200]
return {
"name": name,
"ok": False,
"delay_ms": None,
"error": f"HTTP {exc.code} {body}",
"elapsed_s": round(time.perf_counter() - started, 3),
}
except Exception as exc: # noqa: BLE001
return {
"name": name,
"ok": False,
"delay_ms": None,
"error": f"{type(exc).__name__}: {exc}",
"elapsed_s": round(time.perf_counter() - started, 3),
}
def switch_selector(base: str, node: str, group: str = SELECTOR) -> None:
api(base, f"/proxies/{api_quote(group)}", method="PUT", body={"name": node})
def curl_via_proxy(proxy: str, url: str, method: str = "GET", max_time: int = 20) -> dict[str, Any]:
cmd = [
"curl", "-sS", "-o", "/dev/null",
"-X", method,
"-w", "%{http_code} %{time_namelookup} %{time_connect} %{time_starttransfer} %{time_total} %{errormsg}",
"--connect-timeout", "10",
"--max-time", str(max_time),
"-x", proxy,
"-A", "bench-proxy-probe/1.0",
url,
]
started = time.perf_counter()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
parts = (proc.stdout or "").strip().split(" ", 5)
http_code = parts[0] if parts else ""
ttfb = float(parts[3]) if len(parts) > 3 and parts[3] else None
total = float(parts[4]) if len(parts) > 4 and parts[4] else None
err = (parts[5] if len(parts) > 5 else "") or (proc.stderr or "").strip()
ok = http_code.isdigit() and http_code[0] in {"2", "3"}
return {
"url": url,
"method": method,
"ok": ok,
"http_code": http_code,
"ttfb_s": ttfb,
"total_s": total,
"error": err or None,
"elapsed_s": round(time.perf_counter() - started, 3),
"cmd": " ".join(cmd[:-1] + ["<url>"]),
}
except Exception as exc: # noqa: BLE001
return {
"url": url,
"method": method,
"ok": False,
"http_code": None,
"ttfb_s": None,
"total_s": None,
"error": f"{type(exc).__name__}: {exc}",
"elapsed_s": round(time.perf_counter() - started, 3),
"cmd": "curl -x <proxy> <url>",
}
def sample_nodes(nodes: list[dict[str, str]], min_n: int = 10) -> list[dict[str, str]]:
if len(nodes) <= min_n:
return nodes
by_region: dict[str, list[dict[str, str]]] = {}
for n in nodes:
by_region.setdefault(n["region"], []).append(n)
picked: list[dict[str, str]] = []
# Prefer at least one per region, then fill by type diversity.
for region, items in by_region.items():
hy2 = [x for x in items if x["type"] in {"hysteria2", "hysteria"}]
rest = [x for x in items if x not in hy2]
if hy2:
picked.append(hy2[0])
if rest:
picked.append(rest[0])
if len(rest) > 3:
picked.append(rest[len(rest) // 2])
# unique preserve
seen = set()
uniq = []
for n in picked:
if n["name"] in seen:
continue
seen.add(n["name"])
uniq.append(n)
i = 0
while len(uniq) < min_n and i < len(nodes):
if nodes[i]["name"] not in seen:
uniq.append(nodes[i])
seen.add(nodes[i]["name"])
i += 1
return uniq
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--api", default=os.environ.get("MIHOMO_API", "http://127.0.0.1:19090"))
ap.add_argument("--proxy", default=os.environ.get("MIXED_PROXY", "http://127.0.0.1:17890"))
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "probe-results.json"))
ap.add_argument("--delay-url", default=DEFAULT_DELAY_URL)
ap.add_argument("--timeout-ms", type=int, default=8000)
ap.add_argument("--all", action="store_true", help="probe every real node (default: sample >=10)")
ap.add_argument("--min-sample", type=int, default=12)
ap.add_argument("--workers", type=int, default=6)
args = ap.parse_args()
version = api(args.api, "/version")
leaves = list_leaf_proxies(args.api)
targets = leaves if args.all else sample_nodes(leaves, args.min_sample)
print(f"[probe] mihomo={version} leaves={len(leaves)} testing={len(targets)}", flush=True)
delay_rows: list[dict[str, Any]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = [
pool.submit(delay_one, args.api, n["name"], args.delay_url, args.timeout_ms)
for n in targets
]
for fut in concurrent.futures.as_completed(futs):
row = fut.result()
meta = next(x for x in targets if x["name"] == row["name"])
row.update({"type": meta["type"], "region": meta["region"]})
delay_rows.append(row)
status = f"{row['delay_ms']}ms" if row["ok"] else row["error"]
print(f" delay {row['region']:6} {row['type']:10} {row['name']} -> {status}", flush=True)
delay_rows.sort(key=lambda r: (not r["ok"], r["delay_ms"] is None, r["delay_ms"] or 10**9))
alive = [r for r in delay_rows if r["ok"]]
top3 = alive[:3]
matrix: list[dict[str, Any]] = []
for node in top3:
print(f"[probe] matrix via {node['name']}", flush=True)
switch_selector(args.api, node["name"])
time.sleep(0.4)
site_rows = []
for url in TARGETS:
method = "GET"
row = curl_via_proxy(args.proxy, url, method=method)
# some sites dislike HEAD; GET homepage / 204 only
site_rows.append(row)
print(
f" {row['http_code']} ttfb={row['ttfb_s']} {url} err={row['error']}",
flush=True,
)
matrix.append(
{
"node": node["name"],
"type": node["type"],
"region": node["region"],
"delay_ms": node["delay_ms"],
"sites": site_rows,
}
)
result = {
"probed_at": now_iso(),
"mihomo": version,
"api": args.api,
"mixed_proxy": args.proxy,
"delay_url": args.delay_url,
"timeout_ms": args.timeout_ms,
"leaf_count": len(leaves),
"tested_count": len(targets),
"alive_count": len(alive),
"alive_ge_3": len(alive) >= 3,
"delay": delay_rows,
"top3": [
{"name": r["name"], "type": r["type"], "region": r["region"], "delay_ms": r["delay_ms"]}
for r in top3
],
"matrix": matrix,
"commands": {
"up": "PROXY_SUB_URL=... bash bench/proxy/up.sh",
"probe": "python3 bench/proxy/probe.py --all",
"down": "bash bench/proxy/down.sh",
},
}
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(result, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"[probe] wrote {args.out} alive={len(alive)}/{len(targets)} ge3={len(alive) >= 3}")
return 0 if alive else 1
if __name__ == "__main__":
sys.exit(main())