b995ee8ada
排序策略 (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>
234 lines
7.2 KiB
Python
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)
|