Files
shaguabijia-app-server/app/api/v1/meituan.py
T
chenshuobo b995ee8ada feat: 首页推荐 feed 实现 MVP 排序策略 + 标签格式化
排序策略 (app/core/ranking.py 新建):
- parse_sale_volume: "热销8.5万+" → 85000 整数销量
- get_distance_km: 距离统一(到店米 ÷1000), >50km 视为脏数据
- filter_items: 售价≤0 / 距离>8km / 脏数据剔除
- sort_by_sales: 按销量降序
- split_pages + shuffle_pages: 切页 + 页内 Fisher-Yates 打乱
- inject_billboard: 榜单商品 4 条/页注入前 5 页, 随机位置
- merge_category_pages: 外卖第K页 + 到店第K页合并后 shuffle

Feed 接口重写 (app/api/v1/meituan.py):
- 旧逻辑: 三页固定 listTopiId 主题轮, 美团给什么序就什么序
- 新逻辑: searchText="外卖"/"到店餐饮" + sortField=6 翻页 LBS 召回,
  4 任务并发(外卖召回 / 到店召回 / 外卖榜单 / 到店榜单),
  走完 ranking pipeline 后做坐标级内存缓存(5min TTL)
- 翻页上限 5 页/品类(性价比最高: 3.4s 拿到 238 条/品类)
- 直接调 _call 而非 query_coupon, 因为 searchId 翻页时不能传 pageNo

标签格式化 (app/schemas/meituan.py):
- price_label: "比日常团购省3.5元" → "比团购省 3.5 元"
- rank_label: "2小时北京外卖销量榜第1名" → "外卖榜第 1"
- rating_label: "4.6分" → "点评 4.6 分"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:13:12 +08:00

234 lines
7.2 KiB
Python

"""美团 CPS 券列表 + 换链。
不需要登录,客户端传经纬度即可。
"""
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
from fastapi import APIRouter, HTTPException
from app.core.ranking import (
dedup,
filter_items,
get_distance_km,
inject_billboard,
merge_category_pages,
shuffle_pages,
sort_by_sales,
split_pages,
)
from app.integrations.meituan import MeituanCpsError, _call as mt_call, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
CouponListResponse,
FeedRequest,
FeedResponse,
ReferralLinkRequest,
ReferralLinkResponse,
)
logger = logging.getLogger("shagua.meituan")
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
_MAX_RECALL_PAGES = 5
_MAX_DISTANCE_KM = 8.0
_FEED_CACHE_TTL = 300
_feed_cache: dict[str, tuple[float, list[list[dict]]]] = {}
_feed_lock = Lock()
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(通用查询)")
def list_coupons(req: CouponListRequest) -> CouponListResponse:
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try:
raw = query_coupon(
longitude=req.longitude,
latitude=req.latitude,
platform=req.platform,
biz_line=req.biz_line,
list_topic_id=req.list_topic_id,
search_text=req.keyword,
search_id=req.search_id,
sort_field=req.sort_field,
page_no=req.page,
page_size=req.page_size,
)
except MeituanCpsError as e:
logger.error("query_coupon failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
return CouponListResponse(
items=items,
has_next=raw.get("hasNext", False),
search_id=raw.get("searchId"),
)
# ────────────────────── Feed 排序策略 (MVP) ──────────────────────
def _lbs_recall(
keyword: str,
lon: float,
lat: float,
is_daodian: bool,
) -> list[dict]:
"""searchText + sortField=6 翻页召回,收集 ≤8km 商品,遇到整页都 >8km 或翻满 10 页停止。"""
lon_i = int(lon * 1_000_000)
lat_i = int(lat * 1_000_000)
all_items: list[dict] = []
search_id: str | None = None
for _ in range(_MAX_RECALL_PAGES):
body: dict = {
"longitude": lon_i,
"latitude": lat_i,
"searchText": keyword,
"sortField": 6,
"pageSize": 20,
}
if search_id:
body["searchId"] = search_id
try:
data = mt_call("/cps_open/common/api/v1/query_coupon", body)
except MeituanCpsError:
break
items = data.get("data") or []
search_id = data.get("searchId")
has_next = data.get("hasNext", False)
if not items:
break
page_has_valid = False
for item in items:
dist = get_distance_km(item, is_daodian)
if dist is not None and dist <= _MAX_DISTANCE_KM:
all_items.append(item)
page_has_valid = True
if not page_has_valid or not has_next:
break
return dedup(all_items)
def _fetch_billboard(
platform: int,
topic_id: int,
lon: float,
lat: float,
) -> list[dict]:
"""listTopiId 拉榜单,固定 20 条,不做距离过滤。"""
try:
data = query_coupon(
longitude=lon,
latitude=lat,
platform=platform,
list_topic_id=topic_id,
)
return data.get("data") or []
except MeituanCpsError:
return []
def _build_feed(lon: float, lat: float) -> list[list[dict]]:
"""完整 pipeline:并发召回 → 过滤 → 销量排序 → 分页 → shuffle → 榜单加成 → 合并。"""
with ThreadPoolExecutor(max_workers=4) as pool:
f_wm = pool.submit(_lbs_recall, "外卖", lon, lat, False)
f_dd = pool.submit(_lbs_recall, "到店餐饮", lon, lat, True)
f_wm_bill = pool.submit(_fetch_billboard, 1, 1, lon, lat)
f_dd_bill = pool.submit(_fetch_billboard, 2, 3, lon, lat)
wm_raw = f_wm.result()
dd_raw = f_dd.result()
wm_billboard = f_wm_bill.result()
dd_billboard = f_dd_bill.result()
logger.info(
"[feed:build] waimai=%d daodian=%d wm_bill=%d dd_bill=%d",
len(wm_raw), len(dd_raw), len(wm_billboard), len(dd_billboard),
)
wm_filtered = filter_items(wm_raw, is_daodian=False, max_km=_MAX_DISTANCE_KM)
dd_filtered = filter_items(dd_raw, is_daodian=True, max_km=_MAX_DISTANCE_KM)
wm_sorted = sort_by_sales(wm_filtered)
dd_sorted = sort_by_sales(dd_filtered)
wm_pages = shuffle_pages(split_pages(wm_sorted))
dd_pages = shuffle_pages(split_pages(dd_sorted))
wm_pages = inject_billboard(wm_pages, wm_billboard, per_page=4, max_inject_pages=5)
dd_pages = inject_billboard(dd_pages, dd_billboard, per_page=4, max_inject_pages=5)
return merge_category_pages(wm_pages, dd_pages)
def _cache_key(lon: float, lat: float) -> str:
return f"{lon:.4f},{lat:.4f}"
def _get_or_build_feed(lon: float, lat: float) -> list[list[dict]]:
key = _cache_key(lon, lat)
now = time.time()
with _feed_lock:
entry = _feed_cache.get(key)
if entry and now - entry[0] < _FEED_CACHE_TTL:
return entry[1]
pages = _build_feed(lon, lat)
with _feed_lock:
_feed_cache[key] = (time.time(), pages)
expired = [k for k, (t, _) in _feed_cache.items() if now - t > _FEED_CACHE_TTL]
for k in expired:
del _feed_cache[k]
return pages
@router.post("/feed", response_model=FeedResponse, summary="首页推荐 feed (MVP 排序策略)")
def feed(req: FeedRequest) -> FeedResponse:
lon, lat = req.longitude, req.latitude
page_idx = req.page - 1
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
pages = _get_or_build_feed(lon, lat)
if page_idx >= len(pages):
return FeedResponse(items=[], has_next=False, page=req.page)
items = [CouponCard.from_raw(it) for it in pages[page_idx]]
has_next = page_idx + 1 < len(pages)
return FeedResponse(items=items, has_next=has_next, page=req.page)
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
try:
raw = get_referral_link(
product_view_sign=req.product_view_sign,
platform=req.platform,
biz_line=req.biz_line,
sid=req.sid,
link_type_list=req.link_type_list,
)
except MeituanCpsError as e:
logger.error("get_referral_link failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
link_map = raw.get("referralLinkMap") or {}
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
return ReferralLinkResponse(link=link, link_map=link_map)