joy-cli/scripts/manhuang/quote-aggregator.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

315 lines
11 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
"""蛮荒 Quote Aggregator — 本地只读 APIstdlib无框架
把已有缓存合成统一 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())