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.
269 lines
9.2 KiB
Python
Executable file
269 lines
9.2 KiB
Python
Executable file
#!/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())
|