joy-cli/scripts/manhuang/probe-named-relays.py
feibisi 2c6281d041 feat(manhuang): supply-chain dashboard, probes, and side-cache data
Expand manhuang collect/UI (tabs, markets, guishi/quote probes), add
configs and launchd helpers, ops handbooks, and scripts for channel
validation and price/threat enrichment. Keys stay in keys.env.example only.
2026-07-13 02:08:29 +08:00

269 lines
9.2 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""具名中转站存活/延迟探针(无 API key
读配置:
1) ~/.config/manhuang/relays.json
2) joy-cli/configs/manhuang/relays.json
3) 可选awesome-ai-api-proxy providers.yaml 中 status=active 扩候选
写出:~/.cache/manhuang/relays/status.json
"""
from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
UA = "joy-manhuang-relay-probe/1.0 (+health-only; no-secrets)"
OUT_DIR = Path.home() / ".cache" / "manhuang" / "relays"
OUT = OUT_DIR / "status.json"
CANDIDATES_OUT = OUT_DIR / "candidates-from-providers.json"
CFG_USER = Path.home() / ".config" / "manhuang" / "relays.json"
CFG_REPO = Path(__file__).resolve().parents[2] / "configs" / "manhuang" / "relays.json"
PROVIDERS_YAML = (
"https://raw.githubusercontent.com/howardpen9/awesome-ai-api-proxy"
"/main/data/providers.yaml"
)
TIMEOUT = 12
# 动态扩容上限(避免一次打太多站)
MAX_DYNAMIC = 10
# reverse 类型默认不进主探针(可改为 True 观察)
INCLUDE_REVERSE = False
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _slug(name: str) -> str:
s = re.sub(r"[^a-zA-Z0-9]+", "-", (name or "").strip().lower()).strip("-")
return (s or "relay")[:40]
def _load_cfg() -> dict[str, Any]:
for p in (CFG_USER, CFG_REPO):
if p.is_file():
try:
d = json.loads(p.read_text(encoding="utf-8"))
if isinstance(d, dict) and d.get("relays"):
d["_cfg_path"] = str(p)
return d
except (OSError, json.JSONDecodeError):
continue
return {
"relays": [
{"id": "tokenx24", "label": "tokenx24", "base_url": "https://tokenx24.com",
"health_paths": ["/v1/models", "/"], "sku_ids": ["relay_premium", "codex_quota"]},
{"id": "geshihua", "label": "geshihua.top", "base_url": "https://api.geshihua.top",
"health_paths": ["/v1/models", "/"], "sku_ids": ["relay_premium", "codex_quota"]},
],
"_cfg_path": "builtin",
}
def _parse_providers_yaml(text: str) -> list[dict[str, Any]]:
"""轻量解析 providers.yaml抽出 name/url/type/status无需 PyYAML"""
rows: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None
for ln in text.splitlines():
if re.match(r"^\s+-\s+name:\s+", ln):
if cur and cur.get("url"):
rows.append(cur)
name = re.sub(r"^\s+-\s+name:\s+", "", ln).strip().strip("\"'")
cur = {"name": name}
continue
if cur is None:
continue
m = re.match(r"^\s+(url|type|status):\s*(.+)$", ln)
if m:
cur[m.group(1)] = m.group(2).strip().strip("\"'")
if cur and cur.get("url"):
rows.append(cur)
return rows
def _fetch_provider_candidates(existing_bases: set[str]) -> list[dict[str, Any]]:
req = urllib.request.Request(
PROVIDERS_YAML,
headers={"User-Agent": UA, "Accept": "text/plain,*/*"},
)
try:
with urllib.request.urlopen(req, timeout=25) as r:
text = r.read().decode("utf-8", "replace")
except Exception as e:
return [{"_error": str(e)[:120]}]
out: list[dict[str, Any]] = []
for row in _parse_providers_yaml(text):
status = str(row.get("status") or "").lower()
if status != "active":
continue
rtype = str(row.get("type") or "").lower()
if rtype == "reverse" and not INCLUDE_REVERSE:
continue
if rtype in ("gateway-oss", "observability"):
# 自建网关类不当成采购中转主探针
continue
url = str(row.get("url") or "").rstrip("/")
if not url.startswith("http"):
continue
# github 文档站不当 health 目标
host = (urlparse(url).hostname or "").lower()
if host in ("github.com", "www.github.com", "raw.githubusercontent.com"):
continue
base_key = host
if base_key in existing_bases:
continue
existing_bases.add(base_key)
name = str(row.get("name") or host)
out.append({
"id": f"awp-{_slug(name)}",
"label": name,
"base_url": url,
"health_paths": ["/", "/v1/models", "/api/pricing", "/api/v1/models"],
"sku_ids": ["relay_premium", "newapi_relay"],
"source": "awesome-providers-yaml",
"provider_type": rtype or "unknown",
"dynamic": True,
})
if len(out) >= MAX_DYNAMIC:
break
return out
def _probe_url(url: str) -> dict[str, Any]:
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json,*/*"})
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
body = r.read(8000)
ms = int((time.monotonic() - t0) * 1000)
code = int(getattr(r, "status", 200) or 200)
# 200-499 除 5xx 外算「站活着」401/403 也说明服务在)
alive = 200 <= code < 500
return {
"url": url, "http": code, "ms": ms, "alive": alive,
"bytes": len(body), "error": None,
}
except urllib.error.HTTPError as e:
ms = int((time.monotonic() - t0) * 1000)
code = int(e.code)
alive = 200 <= code < 500
return {"url": url, "http": code, "ms": ms, "alive": alive, "bytes": 0, "error": f"http_{code}"}
except Exception as e:
ms = int((time.monotonic() - t0) * 1000)
return {"url": url, "http": 0, "ms": ms, "alive": False, "bytes": 0, "error": str(e)[:120]}
def probe_all() -> dict[str, Any]:
cfg = _load_cfg()
static = [r for r in (cfg.get("relays") or []) if isinstance(r, dict)]
existing: set[str] = set()
for r in static:
host = urlparse(str(r.get("base_url") or "")).hostname or ""
if host:
existing.add(host.lower())
dynamic = _fetch_provider_candidates(existing)
dyn_err = None
if dynamic and dynamic[0].get("_error"):
dyn_err = dynamic[0]["_error"]
dynamic = []
OUT_DIR.mkdir(parents=True, exist_ok=True)
CANDIDATES_OUT.write_text(
json.dumps({
"ts": _now(),
"source": PROVIDERS_YAML,
"n": len(dynamic),
"error": dyn_err,
"candidates": dynamic,
}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
pool = static + dynamic
relays_out = []
for r in pool:
if not isinstance(r, dict):
continue
base = (r.get("base_url") or "").rstrip("/")
if not base:
continue
paths = r.get("health_paths") or ["/"]
attempts = []
best = None
for path in paths:
if not str(path).startswith("/"):
path = "/" + str(path)
rec = _probe_url(base + path)
attempts.append(rec)
if rec.get("alive"):
best = rec
break
if best is None or (rec.get("http") or 0) > (best.get("http") or 0):
best = rec
status = "ok" if best and best.get("alive") else "down"
if best and best.get("alive") and (best.get("ms") or 0) > 3000:
status = "slow"
relays_out.append({
"id": r.get("id"),
"label": r.get("label") or r.get("id"),
"base_url": base,
"status": status,
"best": best,
"attempts": attempts,
"sku_ids": r.get("sku_ids") or [],
"source": r.get("source") or "relays.json",
"dynamic": bool(r.get("dynamic")),
"provider_type": r.get("provider_type"),
})
time.sleep(0.3)
ok_n = sum(1 for x in relays_out if x.get("status") in ("ok", "slow"))
dyn_ok = sum(1 for x in relays_out if x.get("dynamic") and x.get("status") in ("ok", "slow"))
result = {
"ts": _now(),
"ok": True,
"cfg": cfg.get("_cfg_path"),
"providers_yaml": PROVIDERS_YAML,
"providers_yaml_error": dyn_err,
"relays": relays_out,
"counts": {
"n": len(relays_out),
"ok": ok_n,
"down": len(relays_out) - ok_n,
"static": len(static),
"dynamic": len(dynamic),
"dynamic_ok": dyn_ok,
},
}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return result
def main() -> int:
r = probe_all()
c = r["counts"]
print(
"wrote", OUT,
"n=", c["n"], "ok=", c["ok"],
"static=", c.get("static"), "dynamic=", c.get("dynamic"),
"dyn_ok=", c.get("dynamic_ok"),
)
if r.get("providers_yaml_error"):
print("providers_yaml_error", r["providers_yaml_error"])
for x in r.get("relays") or []:
b = x.get("best") or {}
tag = "dyn" if x.get("dynamic") else "cfg"
print(f"- [{tag}] {x.get('id')}: {x.get('status')} http={b.get('http')} {b.get('ms')}ms {b.get('url')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())