e1bd0e3ef7
改了什么:新增新版大盘日期窗口聚合、广告收益拆分、比价耗时字段、美团 CPS 拉单入库与大盘展示,并同步反馈审核和邀请奖励下线口径。 验证:python -m pytest tests/test_admin_read.py tests/test_admin_write.py tests/test_compare_record.py tests/test_invite.py tests/test_feedback.py -q。
496 lines
25 KiB
Python
496 lines
25 KiB
Python
"""美团 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"""<!doctype html>
|
||
<html lang="zh"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>美团 CPS Playground</title>
|
||
<style>
|
||
:root{ --bd:#e3e6ea; --mut:#6b7280; --bg:#f6f7f9; --pri:#ffb000; --pri2:#ff8a00; }
|
||
*{box-sizing:border-box}
|
||
body{margin:0;font:13px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;color:#1f2328;background:var(--bg)}
|
||
header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#fff;border-bottom:1px solid var(--bd);position:sticky;top:0;z-index:5}
|
||
header b{font-size:15px}
|
||
header .path{flex:1;min-width:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:5px 8px;border:1px solid var(--bd);border-radius:6px}
|
||
#status{font-size:12px;color:var(--mut);white-space:nowrap}
|
||
#status .ok{color:#0a7d28;font-weight:600}
|
||
#status .err{color:#c0392b;font-weight:600}
|
||
main{display:flex;gap:0;height:calc(100vh - 49px)}
|
||
section{height:100%;overflow:auto;padding:12px}
|
||
#params{width:380px;min-width:340px;border-right:1px solid var(--bd);background:#fff}
|
||
#cards{flex:1;min-width:300px;border-right:1px solid var(--bd)}
|
||
#jsonpane{flex:1;min-width:300px;background:#fff}
|
||
h3{margin:2px 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
|
||
#cards h3{display:flex;align-items:center;gap:8px}
|
||
.presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
|
||
.presets button{font-size:12px;padding:4px 8px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
|
||
.presets button:hover{border-color:var(--pri2);color:var(--pri2)}
|
||
.cgrp{font-size:12px;font-weight:700;color:#374151;margin:9px 0 4px;padding-left:2px;border-left:3px solid var(--pri);padding-left:7px}
|
||
.chtag{display:inline-block;min-width:14px;text-align:center;font-size:10px;font-weight:800;padding:0 3px;border-radius:3px;margin-right:4px}
|
||
.ch-r{background:#fff1f0;color:#cf1322}
|
||
.ch-b{background:#eef3ff;color:#1d39c4}
|
||
.ch-a{background:#fff7e6;color:#d46b08}
|
||
.grid{display:grid;grid-template-columns:auto 1fr;gap:6px 8px;align-items:center;margin-bottom:10px}
|
||
.grid label{color:var(--mut);font-size:12px;text-align:right}
|
||
.grid input,.grid select{width:100%;padding:5px 7px;border:1px solid var(--bd);border-radius:6px;font-size:12px;font-family:inherit}
|
||
textarea{width:100%;height:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:8px;border:1px solid var(--bd);border-radius:6px;resize:vertical;white-space:pre}
|
||
.btns{display:flex;gap:8px;margin:8px 0}
|
||
button.act{padding:6px 10px;border:1px solid var(--bd);background:#fff;border-radius:6px;cursor:pointer;font-size:12px}
|
||
button.send{flex:1;background:linear-gradient(180deg,var(--pri),var(--pri2));border:none;color:#3a2600;font-weight:700;padding:9px;border-radius:7px;cursor:pointer;font-size:13px}
|
||
button.send:active{transform:translateY(1px)}
|
||
.hint{font-size:11px;color:var(--mut);margin:6px 0}
|
||
#pageBtn{margin-left:auto;font-size:12px;padding:4px 12px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
|
||
#pageBtn:hover:not(:disabled){border-color:var(--pri2);color:var(--pri2)}
|
||
#pageBtn:disabled{opacity:.4;cursor:not-allowed}
|
||
/* cards */
|
||
.cardgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
|
||
.pagediv{grid-column:1/-1;text-align:center;color:#9aa0a6;font-size:12px;border-top:1px dashed var(--bd);padding:9px 0 3px;margin-top:6px}
|
||
.card{border:1px solid var(--bd);border-radius:10px;background:#fff;overflow:hidden;display:flex;flex-direction:column}
|
||
.card .thumb{width:100%;height:118px;object-fit:cover;background:#f0f1f3}
|
||
.card .body{padding:8px 9px;display:flex;flex-direction:column;gap:5px}
|
||
.card .brand{display:flex;align-items:center;gap:5px;color:var(--mut);font-size:11px}
|
||
.card .brand img{width:15px;height:15px;border-radius:3px;object-fit:cover}
|
||
.card .nm{font-weight:600;font-size:13px;line-height:1.3;max-height:2.6em;overflow:hidden}
|
||
.card .pr{display:flex;align-items:baseline;gap:6px}
|
||
.card .pr .sell{color:#e8420f;font-weight:800;font-size:17px}
|
||
.card .pr .sell:before{content:"¥";font-size:12px;font-weight:600}
|
||
.card .pr .ori{color:#9aa0a6;text-decoration:line-through;font-size:11px}
|
||
.badges{display:flex;flex-wrap:wrap;gap:4px}
|
||
.b{font-size:10px;padding:1px 6px;border-radius:8px;background:#f1f3f5;color:#445}
|
||
.b.sale{background:#fff2e8;color:#d4380d}
|
||
.b.comm{background:#e6fffb;color:#08979c}
|
||
.b.poi{background:#f0f5ff;color:#2f54eb}
|
||
.b.rank{background:#fff7e6;color:#d46b08}
|
||
.b.dist{background:#eaf7ee;color:#0a7d28;font-weight:600}
|
||
.pvs{font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#888;display:flex;align-items:center;gap:5px;border-top:1px dashed var(--bd);padding-top:5px;margin-top:2px}
|
||
.pvs code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.pvs button{font-size:10px;border:1px solid var(--bd);background:#fff;border-radius:4px;cursor:pointer;padding:1px 5px}
|
||
.empty{color:var(--mut);padding:20px;text-align:center}
|
||
/* json tree */
|
||
.jtools{display:flex;gap:6px;margin-bottom:8px}
|
||
.jtools button{font-size:11px;border:1px solid var(--bd);background:#fff;border-radius:5px;cursor:pointer;padding:3px 7px}
|
||
#tree{font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.65}
|
||
.jrow{padding-left:14px}
|
||
.jhead{cursor:pointer;user-select:none}
|
||
.jtog{display:inline-block;width:12px;color:#9aa0a6}
|
||
.jkey{color:#8250df}
|
||
.jsum{color:#9aa0a6}
|
||
.jval.str{color:#0a7d28}
|
||
.jval.num{color:#0550ae}
|
||
.jval.bool,.jval.null{color:#cf222e}
|
||
.jkids{border-left:1px dotted #e3e6ea;margin-left:5px}
|
||
</style></head>
|
||
<body>
|
||
<header>
|
||
<b>🍔 美团 CPS Playground</b>
|
||
<input id="path" class="path" value="/cps_open/common/api/v1/query_coupon">
|
||
<span id="status">就绪</span>
|
||
</header>
|
||
<main>
|
||
<section id="params">
|
||
<h3>快捷模板</h3>
|
||
<div class="hint" style="margin:0 0 4px">渠道:<span class="chtag ch-r">榜</span>榜单 <span class="chtag ch-b">搜</span>搜索词 <span class="chtag ch-a">供</span>供给</div>
|
||
<div class="cgrp">到店 · 团购</div>
|
||
<div class="presets" id="presets-dd"></div>
|
||
<div class="cgrp">到家 · 外卖</div>
|
||
<div class="presets" id="presets-dj"></div>
|
||
<h3>测试坐标(点一下填经纬度)</h3>
|
||
<div class="presets" id="coords"></div>
|
||
<h3>参数(改完点「用表单生成 body」)</h3>
|
||
<div class="grid">
|
||
<label>召回方式</label>
|
||
<select id="channel">
|
||
<option value="topic">榜单 listTopiId</option>
|
||
<option value="search">搜索 searchText</option>
|
||
<option value="supply">多业务供给 multipleSupplyList</option>
|
||
<option value="ids">按 ID productViewSignList</option>
|
||
</select>
|
||
<label>platform</label>
|
||
<select id="platform"><option value="1">1 到家/外卖</option><option value="2" selected>2 到店</option></select>
|
||
<label>bizLine</label>
|
||
<input id="bizLine" value="1" placeholder="到店:1到餐2到综3酒店4门票;外卖填1">
|
||
<label>listTopiId</label>
|
||
<input id="listTopiId" value="5" placeholder="到店:2必推3热销5实时;到家:1精选2必推3热销">
|
||
<label>searchText</label>
|
||
<input id="searchText" placeholder="搜索关键词(选搜索时填)">
|
||
<label>productViewSignList</label>
|
||
<input id="ids" placeholder="逗号分隔的商品ID(按ID查时填)">
|
||
<label>sortField</label>
|
||
<select id="sortField">
|
||
<option value="">(默认/不传)</option>
|
||
<option value="1">1 售价</option><option value="2">2 销量</option>
|
||
<option value="3">3 佣金</option><option value="6">6 离我最近</option>
|
||
</select>
|
||
<label>cityId</label>
|
||
<input id="cityId" value="WKV2HMXUEK634WP64CUCUQGM64" placeholder="城市编码(默认北京)">
|
||
<label>经度</label>
|
||
<input id="lon" value="116.404" placeholder="十进制,自动×100万">
|
||
<label>纬度</label>
|
||
<input id="lat" value="39.928" placeholder="十进制,自动×100万">
|
||
<label>pageSize</label>
|
||
<input id="pageSize" value="20">
|
||
<label>pageNo</label>
|
||
<input id="pageNo" value="" placeholder="留空=默认1">
|
||
<label>searchId</label>
|
||
<input id="searchId" value="" placeholder="翻页用,一般不手填">
|
||
</div>
|
||
<div class="btns"><button class="act" id="genBtn">⟳ 用表单生成 body</button></div>
|
||
<h3>请求 body(可直接手改,发送以这里为准)</h3>
|
||
<textarea id="body"></textarea>
|
||
<div class="btns"><button class="send" id="sendBtn">▶ 发送请求(第 1 页)</button></div>
|
||
<div class="hint">经纬度填十进制(116.404)自动 ×100万;翻页按钮在中间卡片栏右上角(供给/搜索有下一页,榜单没有)。</div>
|
||
</section>
|
||
|
||
<section id="cards">
|
||
<h3>卡片列表 <span id="cardCount" style="color:#9aa0a6"></span><button id="pageBtn" disabled>下一页 ›</button></h3>
|
||
<div id="cardlist"><div class="empty">点「发送请求」后,这里渲染商品卡片</div></div>
|
||
</section>
|
||
|
||
<section id="jsonpane">
|
||
<h3>原始 JSON(点三角折叠/展开)</h3>
|
||
<div class="jtools">
|
||
<button id="expandAll">全部展开</button>
|
||
<button id="collapseAll">全部折叠</button>
|
||
</div>
|
||
<div id="tree"><div class="empty">原始响应在这里</div></div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const $ = id => document.getElementById(id);
|
||
const esc = s => String(s==null?"":s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">","\"":"""}[c]));
|
||
|
||
// 翻页状态
|
||
let lastJson = null; // 最近一页响应
|
||
let pageNum = 0; // 已展示页数
|
||
let totalShown = 0; // 累计卡片数
|
||
|
||
// ---------- 快捷模板(按 到店/到家 分组 + 渠道标识) ----------
|
||
const LON = 116404000, LAT = 39928000, BJ = "WKV2HMXUEK634WP64CUCUQGM64";
|
||
const ZLON = 116316000, ZLAT = 39984000; // 中关村
|
||
// ch: 榜=榜单(红) 搜=搜索词(蓝) 供=供给(琥珀)
|
||
const PRESETS = [
|
||
// 到店 · 团购
|
||
{g:"dd", ch:"榜", label:"实时热销", body:{platform:2, bizLine:1, listTopiId:5, longitude:LON, latitude:LAT, pageSize:20}},
|
||
{g:"dd", ch:"榜", label:"同城热销", body:{platform:2, bizLine:1, listTopiId:3, longitude:LON, latitude:LAT, pageSize:20}},
|
||
{g:"dd", ch:"供", label:"到餐·按销量(翻页)", body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:2, pageSize:20}},
|
||
{g:"dd", ch:"供", label:"到餐·按距离(中关村)", body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||
// 到家 · 外卖
|
||
{g:"dj", ch:"榜", label:"同城热销·按销量", body:{platform:1, listTopiId:3, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||
{g:"dj", ch:"搜", label:"按距离(中关村)", body:{searchText:"美食", sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||
{g:"dj", ch:"搜", label:"价格升序", body:{searchText:"美食", sortField:2, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||
{g:"dj", ch:"搜", label:"综合(供给深)", body:{searchText:"美食", sortField:1, longitude:ZLON, latitude:ZLAT, pageSize:20}},
|
||
];
|
||
const CH_CLS = {"榜":"ch-r", "搜":"ch-b", "供":"ch-a"};
|
||
PRESETS.forEach(p=>{
|
||
const box = $(p.g==="dd" ? "presets-dd" : "presets-dj");
|
||
const b = document.createElement("button");
|
||
b.innerHTML = `<span class="chtag ${CH_CLS[p.ch]}">${p.ch}</span>${p.label}`;
|
||
b.onclick = ()=>{ $("body").value = JSON.stringify(p.body, null, 2); };
|
||
box.appendChild(b);
|
||
});
|
||
|
||
// ---------- 测试坐标 ----------
|
||
const COORDS = {
|
||
"天安门":[116.397,39.909], "王府井":[116.418,39.914], "中关村":[116.316,39.984],
|
||
"国贸":[116.461,39.909], "三里屯":[116.455,39.937], "望京":[116.470,39.997],
|
||
};
|
||
const cbox = $("coords");
|
||
Object.entries(COORDS).forEach(([nm,[lo,la]])=>{
|
||
const b=document.createElement("button"); b.textContent=nm;
|
||
b.onclick=()=>applyCoords(lo,la); cbox.appendChild(b);
|
||
});
|
||
function applyCoords(lo, la){
|
||
$("lon").value = lo; $("lat").value = la;
|
||
// 若当前 body 里已有经纬度,顺手原地替换,立即生效(供给查询用 cityId 无经纬度则不动)
|
||
try{
|
||
const o = JSON.parse($("body").value);
|
||
if(o && typeof o==="object" && ("longitude" in o || "latitude" in o)){
|
||
o.longitude = Math.round(lo*1e6); o.latitude = Math.round(la*1e6);
|
||
$("body").value = JSON.stringify(o, null, 2);
|
||
}
|
||
}catch(e){}
|
||
}
|
||
|
||
// ---------- 表单 → body ----------
|
||
function num(id){ const v=$(id).value.trim(); return v===""? null : Number(v); }
|
||
function str(id){ const v=$(id).value.trim(); return v===""? null : v; }
|
||
function buildBody(){
|
||
const ch = $("channel").value;
|
||
const b = {};
|
||
const ps = num("pageSize"); if(ps!=null) b.pageSize = ps;
|
||
const pn = num("pageNo"); if(pn!=null) b.pageNo = pn;
|
||
const lon = num("lon"), lat = num("lat");
|
||
if(lon!=null) b.longitude = Math.round(lon*1e6);
|
||
if(lat!=null) b.latitude = Math.round(lat*1e6);
|
||
const sf = str("sortField"); if(sf!=null) b.sortField = Number(sf);
|
||
const city = str("cityId"); if(city!=null) b.cityId = city;
|
||
const sid = str("searchId"); if(sid!=null) b.searchId = sid;
|
||
const plat = Number($("platform").value);
|
||
const bl = num("bizLine");
|
||
|
||
if(ch==="supply"){
|
||
b.multipleSupplyList = [{platform:plat, bizLineParamList:[{bizLine: bl!=null? bl : 1}]}];
|
||
if(b.sortField==null) b.sortField = 2; // 多供给查询 sortField 必填
|
||
} else if(ch==="ids"){
|
||
const ids = str("ids");
|
||
b.productViewSignList = ids? ids.split(",").map(s=>s.trim()).filter(Boolean) : [];
|
||
} else {
|
||
b.platform = plat;
|
||
if(bl!=null) b.bizLine = bl;
|
||
if(ch==="topic"){ const t=num("listTopiId"); if(t!=null) b.listTopiId=t; }
|
||
else if(ch==="search"){ const s=str("searchText"); if(s!=null) b.searchText=s; }
|
||
}
|
||
return b;
|
||
}
|
||
$("genBtn").onclick = ()=>{ $("body").value = JSON.stringify(buildBody(), null, 2); };
|
||
|
||
// ---------- 发送 / 翻页 ----------
|
||
function setStatus(t, cls){ const s=$("status"); s.innerHTML = cls? `<span class="${cls}">${t}</span>` : t; }
|
||
|
||
async function doQuery(body, append){
|
||
setStatus("请求中…", "");
|
||
$("pageBtn").disabled = true;
|
||
let res;
|
||
try{
|
||
res = await fetch("/api/query", {method:"POST", headers:{"Content-Type":"application/json"},
|
||
body: JSON.stringify({path: $("path").value.trim(), body})}).then(r=>r.json());
|
||
}catch(e){ setStatus("本地请求失败: "+e.message, "err"); updatePageBtn(); return; }
|
||
if(!res.ok){ setStatus("❌ "+res.error, "err"); renderTree({error:res.error}); if(!append) renderCards({}, false); updatePageBtn(); return; }
|
||
const j = res.json || {};
|
||
lastJson = j;
|
||
const code = j.code, n = Array.isArray(j.data)? j.data.length : 0;
|
||
const cls = code===0? "ok":"err";
|
||
const rt = res.retries? ` · 退避${res.retries}次`:"";
|
||
setStatus(`HTTP ${res.http_status} · code=${code} · 本页${n}条 · hasNext=${j.hasNext} · ${res.elapsed_ms}ms${rt}`, cls);
|
||
renderCards(j, append);
|
||
renderTree(j);
|
||
updatePageBtn();
|
||
}
|
||
|
||
async function send(){
|
||
let body;
|
||
try{ body = JSON.parse($("body").value); }
|
||
catch(e){ setStatus("body 不是合法 JSON: "+e.message, "err"); return; }
|
||
pageNum = 1;
|
||
await doQuery(body, false);
|
||
}
|
||
async function nextPage(){
|
||
if(!lastJson) return;
|
||
let body;
|
||
try{ body = JSON.parse($("body").value); }
|
||
catch(e){ setStatus("body 不是合法 JSON,无法翻页: "+e.message, "err"); return; }
|
||
if(lastJson.searchId){ body.searchId = lastJson.searchId; } // 供给/搜索:带令牌
|
||
else if(lastJson.hasNext){ body.pageNo = (Number(body.pageNo)||1)+1; } // pageNo 翻页
|
||
else { setStatus("没有下一页(searchId 为空且 hasNext=false)", "err"); return; }
|
||
$("body").value = JSON.stringify(body, null, 2); // 反映当前翻页状态
|
||
pageNum += 1;
|
||
await doQuery(body, true);
|
||
}
|
||
function updatePageBtn(){
|
||
const b = $("pageBtn");
|
||
const canNext = !!(lastJson && (lastJson.searchId || lastJson.hasNext===true));
|
||
b.disabled = !canNext;
|
||
b.textContent = canNext? "下一页 ›" : "没有下一页";
|
||
}
|
||
$("sendBtn").onclick = send;
|
||
$("pageBtn").onclick = nextPage;
|
||
|
||
// ---------- 卡片渲染 ----------
|
||
function clean(u){ return u? String(u).split("@")[0] : ""; }
|
||
function fmtDist(d){ if(d==null||d==="") return null; const v=Number(d); if(!isFinite(v)) return null; return v>=1000? (v/1000).toFixed(1)+"km" : Math.round(v)+"m"; }
|
||
function cardGrid(){
|
||
let g = document.querySelector("#cardlist .cardgrid");
|
||
if(!g){ $("cardlist").innerHTML=""; g=document.createElement("div"); g.className="cardgrid"; $("cardlist").appendChild(g); }
|
||
return g;
|
||
}
|
||
function makeCardHtml(it){
|
||
const cpd = it.couponPackDetail || {};
|
||
const br = it.brandInfo || {};
|
||
const ci = it.commissionInfo || {};
|
||
const poi = it.availablePoiInfo || {};
|
||
const dp = it.deliverablePoiInfo || {};
|
||
const lab = it.productLabel || {};
|
||
const pp = lab.pricePowerLabel || {};
|
||
const pvs = cpd.productViewSign || cpd.skuViewId || "";
|
||
const commPct = ci.commissionPercent!=null ? (Number(ci.commissionPercent)/100)+"%"
|
||
: (ci.commission!=null? "¥"+ci.commission : null);
|
||
const dist = fmtDist(dp.deliveryDistance);
|
||
const badges = [];
|
||
if(dist) badges.push(`<span class="b dist" title="${esc((dp.poiName||"")+" · "+(dp.deliveryDistance||"")+"米")}">📍 ${dist}</span>`);
|
||
if(cpd.saleVolume) badges.push(`<span class="b sale">${esc(cpd.saleVolume)}</span>`);
|
||
if(commPct) badges.push(`<span class="b comm">佣金 ${esc(commPct)}</span>`);
|
||
if(poi.availablePoiNum) badges.push(`<span class="b poi">门店 ${esc(poi.availablePoiNum)}</span>`);
|
||
if(dp.deliveryDistance) badges.push(`<span class="b">${esc(dp.deliveryDistance)}</span>`);
|
||
if(pp.beatMTLabel) badges.push(`<span class="b rank">${esc(pp.beatMTLabel)}</span>`);
|
||
else if(pp.historyPriceLabel) badges.push(`<span class="b rank">${esc(pp.historyPriceLabel)}</span>`);
|
||
if(lab.productRankLabel) badges.push(`<span class="b rank">${esc(lab.productRankLabel)}</span>`);
|
||
return `<div class="card">
|
||
${cpd.headUrl? `<img class="thumb" src="${esc(clean(cpd.headUrl))}" loading="lazy" onerror="this.style.visibility='hidden'">` : ``}
|
||
<div class="body">
|
||
<div class="brand">${br.brandLogoUrl? `<img src="${esc(clean(br.brandLogoUrl))}">`:``}${esc(br.brandName||dp.poiName||"—")}</div>
|
||
<div class="nm">${esc(cpd.name||"(无名称)")}</div>
|
||
<div class="pr"><span class="sell">${esc(cpd.sellPrice??"?")}</span>${cpd.originalPrice!=null?`<span class="ori">¥${esc(cpd.originalPrice)}</span>`:``}</div>
|
||
<div class="badges">${badges.join("")}</div>
|
||
${pvs? `<div class="pvs"><code title="${esc(pvs)}">${esc(pvs)}</code><button onclick="navigator.clipboard.writeText('${esc(pvs)}')">复制ID</button></div>`:``}
|
||
</div></div>`;
|
||
}
|
||
function renderCards(json, append){
|
||
const data = json && Array.isArray(json.data)? json.data : [];
|
||
if(!append){ $("cardlist").innerHTML=""; totalShown=0; }
|
||
const g = cardGrid();
|
||
if(!data.length){
|
||
if(!append) $("cardlist").innerHTML = `<div class="empty">无 data 数组可渲染(看右侧 JSON)</div>`;
|
||
else g.insertAdjacentHTML("beforeend", `<div class="pagediv">— 第 ${pageNum} 页 · 0 条 —</div>`);
|
||
} else {
|
||
const divider = append? `<div class="pagediv">— 第 ${pageNum} 页 · ${data.length} 条 —</div>` : "";
|
||
g.insertAdjacentHTML("beforeend", divider + data.map(makeCardHtml).join(""));
|
||
totalShown += data.length;
|
||
}
|
||
$("cardCount").textContent = totalShown? `(累计 ${totalShown} 条 / ${pageNum} 页)` : "";
|
||
}
|
||
|
||
// ---------- JSON 树(可折叠) ----------
|
||
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; }
|
||
function fmt(v){ return v===null? "null" : typeof v==="string"? `"${v}"` : String(v); }
|
||
function typeCls(v){ return v===null?"null":typeof v==="string"?"str":typeof v==="number"?"num":"bool"; }
|
||
function buildNode(key, val, depth){
|
||
const wrap = el("div","jrow");
|
||
const isObj = val && typeof val==="object";
|
||
if(isObj){
|
||
const entries = Array.isArray(val)? val.map((v,i)=>[i,v]) : Object.entries(val);
|
||
const head = el("div","jhead");
|
||
const open = depth < 2;
|
||
const tog = el("span","jtog", entries.length? (open?"▼":"▶") : "·");
|
||
const k = el("span","jkey", key!==null? key+": " : "");
|
||
const sum = el("span","jsum", Array.isArray(val)? `[${entries.length}]` : `{${entries.length}}`);
|
||
head.append(tog,k,sum);
|
||
const kids = el("div","jkids");
|
||
entries.forEach(([ck,cv])=> kids.append(buildNode(ck,cv,depth+1)));
|
||
kids.style.display = open? "block":"none";
|
||
head.onclick = ()=>{ const o=kids.style.display!=="none"; kids.style.display=o?"none":"block"; if(entries.length) tog.textContent=o?"▶":"▼"; };
|
||
wrap.append(head,kids);
|
||
} else {
|
||
wrap.append(el("span","jkey", key+": "), el("span","jval "+typeCls(val), fmt(val)));
|
||
}
|
||
return wrap;
|
||
}
|
||
function renderTree(obj){ const t=$("tree"); t.innerHTML=""; t.append(buildNode(null, obj, 0)); }
|
||
$("expandAll").onclick = ()=>{ document.querySelectorAll("#tree .jkids").forEach(k=>k.style.display="block");
|
||
document.querySelectorAll("#tree .jtog").forEach(x=>{ if(x.textContent==="▶")x.textContent="▼"; }); };
|
||
$("collapseAll").onclick = ()=>{ document.querySelectorAll("#tree .jrow .jkids").forEach((k,i)=>{ if(i>0) k.style.display="none"; });
|
||
document.querySelectorAll("#tree .jtog").forEach((x,i)=>{ if(i>0 && x.textContent==="▼")x.textContent="▶"; }); };
|
||
|
||
// 初始填一个默认 body
|
||
$("body").value = JSON.stringify(PRESETS[0].body, null, 2);
|
||
</script>
|
||
</body></html>"""
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
port = int(os.environ.get("MT_PLAYGROUND_PORT", "8799"))
|
||
print(f"\n 美团 CPS Playground → http://127.0.0.1:{port}\n (Ctrl-C 退出)\n")
|
||
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|