"""美团 CPS API Playground —— 本地调参/试 API 的可视化工具。 浏览器填参数 → 本地后端用 meituan.py 的同款签名打美团 → 返回结果, 页面渲染「卡片列表 + 可折叠 JSON 树」。AppSecret 只留在后端,浏览器不接触。 跑法: /Users/pure/miniconda3/envs/price/bin/python tools/meituan_playground.py 然后开 http://127.0.0.1:8799 只读工具(只调 query 类接口),复用本仓库 .env 里的 MT_CPS 凭证。 """ from __future__ import annotations import asyncio import json import os import sys import time # 让 `from app...` 可导入 + pydantic-settings 从仓库根读 .env HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) os.chdir(ROOT) sys.path.insert(0, ROOT) import httpx # noqa: E402 from fastapi import FastAPI, Request # noqa: E402 from fastapi.responses import HTMLResponse, JSONResponse # noqa: E402 from app.core.config import settings # noqa: E402 from app.integrations.meituan import _content_md5, _sign # noqa: E402 app = FastAPI(title="Meituan CPS Playground") DEFAULT_PATH = "/cps_open/common/api/v1/query_coupon" def call_raw(path: str, body_obj: dict) -> dict: """用 meituan.py 同款 S-Ca 签名打美团,原样返回(不管 code 是否为 0,方便看错误体)。""" body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8") md5 = _content_md5(body) ts = str(int(time.time() * 1000)) signed = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts} sig = _sign(settings.MT_CPS_APP_SECRET, "POST", md5, path, signed) headers = { "Content-Type": "application/json", "Content-MD5": md5, "S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts, "S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App", "S-Ca-Signature": sig, } url = f"{settings.MT_CPS_HOST}{path}" t0 = time.time() # trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF) resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False) ms = int((time.time() - t0) * 1000) try: j = resp.json() except Exception: j = {"_raw_text": resp.text[:2000]} return {"http_status": resp.status_code, "elapsed_ms": ms, "json": j} def _is_rate_limited(j: dict) -> bool: code = str(j.get("code")) msg = str(j.get("message") or j.get("msg") or "") return code == "402" or "频繁" in msg @app.post("/api/query") async def api_query(req: Request): try: payload = await req.json() except Exception as e: return JSONResponse({"ok": False, "error": f"请求体不是合法 JSON: {e}"}) path = (payload.get("path") or DEFAULT_PATH).strip() body = payload.get("body") if not isinstance(body, dict): return JSONResponse({"ok": False, "error": "body 必须是 JSON 对象"}) if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET: return JSONResponse({"ok": False, "error": "MT_CPS_APP_KEY / SECRET 未配置(.env)"}) # 美团这接口很容易 402「调用频繁」,交互式工具自动退避重试 3 次,UX 顺一点 last = None for a in range(3): try: out = call_raw(path, body) except Exception as e: return JSONResponse({"ok": False, "error": f"{type(e).__name__}: {e}"}) if _is_rate_limited(out.get("json") or {}) and a < 2: last = out await asyncio.sleep(1.5 * (a + 1)) continue return JSONResponse({"ok": True, **out, "retries": a}) return JSONResponse({"ok": True, **(last or {}), "retries": 2}) @app.get("/", response_class=HTMLResponse) def index(): return HTML HTML = r"""