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.
315 lines
11 KiB
Python
Executable file
315 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
"""蛮荒 Quote Aggregator — 本地只读 API(stdlib,无框架)。
|
||
|
||
把已有缓存合成统一 HTTP 出口,面板/脚本/其他 VM 只读这里,不直连外店。
|
||
|
||
GET /health
|
||
GET /v1/skus
|
||
GET /v1/quotes?sku=codex_quota
|
||
GET /v1/quotes?demand=1
|
||
GET /v1/relays
|
||
GET /v1/channels
|
||
GET /v1/community
|
||
GET /v1/changedetection
|
||
GET /v1/summary
|
||
|
||
默认:127.0.0.1:8877
|
||
python3 scripts/manhuang/quote-aggregator.py
|
||
python3 scripts/manhuang/quote-aggregator.py --port 8877 --host 127.0.0.1
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
CACHE = Path.home() / ".cache" / "manhuang"
|
||
QUOTES = CACHE / "quotes" / "ai-accounts.json"
|
||
COMMUNITY = CACHE / "quotes" / "community-prices.json"
|
||
MSRP = CACHE / "quotes" / "msrp-litellm.json"
|
||
FREE = CACHE / "quotes" / "free-llm-lists.json"
|
||
RELAYS = CACHE / "relays" / "status.json"
|
||
TAB = CACHE / "tab-caches.json"
|
||
CD = CACHE / "changedetection" / "status.json"
|
||
CHANNELS_USER = Path.home() / ".config" / "manhuang" / "channels.json"
|
||
CHANNELS_REPO = Path(__file__).resolve().parents[2] / "configs" / "manhuang" / "channels.json"
|
||
PRODUCT_TYPES = Path(__file__).resolve().parents[2] / "configs" / "manhuang" / "product-types.json"
|
||
|
||
DEMAND_FALLBACK = {
|
||
"codex_quota", "openai", "chatgpt_pro", "chatgpt_team", "claude", "claude_max",
|
||
"cursor", "relay_premium", "newapi_relay", "openrouter", "opencode_go",
|
||
"sms_otp", "sms_us", "sms_nl", "sms_id", "bulk_mail", "captcha_solve",
|
||
"supergrok", "giftcard_us", "giftcard_tr", "virtual_card",
|
||
}
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def _load(path: Path) -> Any:
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError, UnicodeError):
|
||
return None
|
||
|
||
|
||
def _channels() -> dict[str, Any]:
|
||
for p in (CHANNELS_USER, CHANNELS_REPO):
|
||
d = _load(p)
|
||
if isinstance(d, dict):
|
||
d["_path"] = str(p)
|
||
return d
|
||
return {"ok": False, "error": "no channels.json"}
|
||
|
||
|
||
def _quotes_doc() -> dict[str, Any]:
|
||
d = _load(QUOTES)
|
||
return d if isinstance(d, dict) else {"ok": False, "skus": []}
|
||
|
||
|
||
def _community() -> dict[str, Any]:
|
||
d = _load(COMMUNITY)
|
||
return d if isinstance(d, dict) else {"ok": False, "sku_overlay": {}}
|
||
|
||
|
||
def _relays() -> dict[str, Any]:
|
||
d = _load(RELAYS)
|
||
return d if isinstance(d, dict) else {"ok": False, "relays": []}
|
||
|
||
|
||
def _cd() -> dict[str, Any]:
|
||
d = _load(CD)
|
||
return d if isinstance(d, dict) else {"ok": False, "watches": []}
|
||
|
||
|
||
def _msrp() -> dict[str, Any]:
|
||
d = _load(MSRP)
|
||
if isinstance(d, dict):
|
||
return d
|
||
# fallback: community embedded
|
||
c = _community()
|
||
anchors = c.get("msrp_anchor") if isinstance(c, dict) else None
|
||
if isinstance(anchors, dict) and anchors:
|
||
return {"ok": True, "anchors": anchors, "source": "community-prices"}
|
||
return {"ok": False, "anchors": {}}
|
||
|
||
|
||
def _free() -> dict[str, Any]:
|
||
d = _load(FREE)
|
||
if isinstance(d, dict):
|
||
return d
|
||
c = _community()
|
||
fl = c.get("free_llm") if isinstance(c, dict) else None
|
||
if isinstance(fl, dict) and fl:
|
||
return {"ok": bool(fl.get("ok")), "summary": fl, "source": "community-prices"}
|
||
return {"ok": False}
|
||
|
||
|
||
def _demand_ids() -> set[str]:
|
||
ch = _channels()
|
||
# optional future field; else fallback
|
||
ids = ch.get("demand_sku_ids") if isinstance(ch, dict) else None
|
||
if isinstance(ids, list) and ids:
|
||
return {str(x) for x in ids}
|
||
return set(DEMAND_FALLBACK)
|
||
|
||
|
||
def _merge_sku_views() -> list[dict[str, Any]]:
|
||
"""quotes skus + community overlay 合成视图。"""
|
||
q = _quotes_doc()
|
||
skus = list(q.get("skus") or []) if isinstance(q.get("skus"), list) else []
|
||
overlay = (_community().get("sku_overlay") or {}) if isinstance(_community(), dict) else {}
|
||
# reload once
|
||
comm = _community()
|
||
overlay = comm.get("sku_overlay") if isinstance(comm, dict) else {}
|
||
if not isinstance(overlay, dict):
|
||
overlay = {}
|
||
|
||
out = []
|
||
seen = set()
|
||
for s in skus:
|
||
if not isinstance(s, dict) or not s.get("id"):
|
||
continue
|
||
sid = str(s["id"])
|
||
seen.add(sid)
|
||
row = {
|
||
"id": sid,
|
||
"label": s.get("label"),
|
||
"tier": s.get("tier"),
|
||
"category": s.get("category"),
|
||
"best": s.get("best"),
|
||
"best_market": s.get("best_market"),
|
||
"official_msrp": s.get("official_msrp"),
|
||
"spread": s.get("spread"),
|
||
"community": overlay.get(sid),
|
||
}
|
||
# 若无 marketplace 但有 community,补 virtual best_market 提示
|
||
if not row["best_market"] and overlay.get(sid):
|
||
o = overlay[sid]
|
||
row["best_market"] = {
|
||
"channel": o.get("channel"),
|
||
"price": o.get("price"),
|
||
"currency": o.get("currency") or "USD",
|
||
"url": o.get("url"),
|
||
"kind": o.get("kind"),
|
||
"unit": o.get("unit"),
|
||
"source": "community_overlay",
|
||
}
|
||
out.append(row)
|
||
|
||
for sid, o in overlay.items():
|
||
if sid in seen or not isinstance(o, dict):
|
||
continue
|
||
out.append({
|
||
"id": sid,
|
||
"label": sid,
|
||
"tier": None,
|
||
"best": None,
|
||
"best_market": {
|
||
"channel": o.get("channel"),
|
||
"price": o.get("price"),
|
||
"currency": o.get("currency") or "USD",
|
||
"url": o.get("url"),
|
||
"kind": o.get("kind"),
|
||
"unit": o.get("unit"),
|
||
"source": "community_only",
|
||
},
|
||
"community": o,
|
||
})
|
||
return out
|
||
|
||
|
||
def _summary() -> dict[str, Any]:
|
||
skus = _merge_sku_views()
|
||
demand = _demand_ids()
|
||
market_n = sum(1 for s in skus if (s.get("best_market") or {}).get("price") is not None)
|
||
demand_market = sum(
|
||
1 for s in skus
|
||
if s.get("id") in demand and (s.get("best_market") or {}).get("price") is not None
|
||
)
|
||
rel = _relays()
|
||
counts = rel.get("counts") if isinstance(rel, dict) else {}
|
||
return {
|
||
"ts": _now(),
|
||
"skus_n": len(skus),
|
||
"market_n": market_n,
|
||
"demand_n": len(demand),
|
||
"demand_with_market_n": demand_market,
|
||
"demand_coverage": round(demand_market / max(1, len(demand)), 3),
|
||
"relays": counts,
|
||
"community_overlay_n": len((_community().get("sku_overlay") or {})),
|
||
"msrp_anchor_n": len((_msrp().get("anchors") or {})),
|
||
"free_providers_n": (_free().get("mnfst") or {}).get("providers_n")
|
||
or (_free().get("summary") or {}).get("providers_n")
|
||
or (_free().get("providers_n")),
|
||
"changedetection_watches": len((_cd().get("watches") or [])),
|
||
"paths": {
|
||
"quotes": str(QUOTES),
|
||
"community": str(COMMUNITY),
|
||
"msrp": str(MSRP),
|
||
"free": str(FREE),
|
||
"relays": str(RELAYS),
|
||
"changedetection": str(CD),
|
||
},
|
||
}
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "manhuang-quote-aggregator/1.0"
|
||
|
||
def log_message(self, fmt: str, *args: Any) -> None:
|
||
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||
|
||
def _json(self, code: int, obj: Any) -> None:
|
||
body = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_GET(self) -> None: # noqa: N802
|
||
u = urlparse(self.path)
|
||
path = u.path.rstrip("/") or "/"
|
||
qs = parse_qs(u.query)
|
||
|
||
if path in ("/", "/health"):
|
||
self._json(200, {"ok": True, "service": "manhuang-quote-aggregator", "ts": _now()})
|
||
return
|
||
if path == "/v1/summary":
|
||
self._json(200, _summary())
|
||
return
|
||
if path == "/v1/channels":
|
||
self._json(200, _channels())
|
||
return
|
||
if path == "/v1/product-types":
|
||
d = _load(PRODUCT_TYPES)
|
||
self._json(200, d if isinstance(d, dict) else {"ok": False, "types": []})
|
||
return
|
||
if path == "/v1/relays":
|
||
self._json(200, _relays())
|
||
return
|
||
if path == "/v1/community":
|
||
self._json(200, _community())
|
||
return
|
||
if path == "/v1/msrp":
|
||
self._json(200, _msrp())
|
||
return
|
||
if path == "/v1/free":
|
||
self._json(200, _free())
|
||
return
|
||
if path == "/v1/changedetection":
|
||
self._json(200, _cd())
|
||
return
|
||
if path == "/v1/skus":
|
||
skus = _merge_sku_views()
|
||
if qs.get("demand", ["0"])[0] in ("1", "true", "yes"):
|
||
dem = _demand_ids()
|
||
skus = [s for s in skus if s.get("id") in dem]
|
||
self._json(200, {"ok": True, "n": len(skus), "skus": skus, "ts": _now()})
|
||
return
|
||
if path == "/v1/quotes":
|
||
skus = _merge_sku_views()
|
||
sku = (qs.get("sku") or [None])[0]
|
||
if sku:
|
||
skus = [s for s in skus if s.get("id") == sku]
|
||
elif qs.get("demand", ["0"])[0] in ("1", "true", "yes"):
|
||
dem = _demand_ids()
|
||
skus = [s for s in skus if s.get("id") in dem]
|
||
self._json(200, {
|
||
"ok": True,
|
||
"n": len(skus),
|
||
"quotes": skus,
|
||
"summary": _summary(),
|
||
"ts": _now(),
|
||
})
|
||
return
|
||
|
||
self._json(404, {"ok": False, "error": "not_found", "path": path})
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="Manhuang quote aggregator (read-only)")
|
||
ap.add_argument("--host", default="127.0.0.1")
|
||
ap.add_argument("--port", type=int, default=8877)
|
||
args = ap.parse_args()
|
||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||
print(f"manhuang quote-aggregator http://{args.host}:{args.port}/ (Ctrl+C stop)", flush=True)
|
||
print(" /v1/summary /v1/quotes?demand=1 /v1/relays /v1/community /v1/msrp /v1/free", flush=True)
|
||
try:
|
||
httpd.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\nbye", flush=True)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|