diff --git a/tools/meituan_playground.py b/tools/meituan_playground.py new file mode 100644 index 0000000..0e9f165 --- /dev/null +++ b/tools/meituan_playground.py @@ -0,0 +1,494 @@ +"""美团 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() + resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC) + 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""" +
+ +