"""虎牙积分中心唯一目录读取实现。 2026-11-13 两份 HAR(``11.13-兑换记录.har``、 ``11.13-虎牙充值2元-兑换幸运币.har``)确认: * ``PlayMallUI.queryAllocation`` 是积分中心商品、页签、活动 SID 和赛季配置的 唯一权威来源; * 基础响应只返回第一个页签,官方页面随后按 ``pageId + waterfallTabId`` 补拉其它页签; * ``webActUI.getActPrizeList`` 不在最新兑换链路中,旧实现已删除。 商品刷新、实时下拉、兑换前校验、外部单积分解析、CK 预览、兑换记录 SID 和 宝典赛季都只能调用本模块,避免各入口重复实现后再次出现旧 prize_id。 """ from __future__ import annotations import re from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any from loguru import logger from sqlalchemy.orm import Session from core.huya.shop_structs import ( extract_points_center_sid, extract_points_center_tabs, parse_points_center_gifts, ) from ..huya_defaults import ( HUYA_DEFAULT_POINTS_PAGE_CODE, HUYA_DEFAULT_POINTS_SCENE, HUYA_DEFAULT_POINTS_SOURCE_ID, ) from ..models import HuyaConfig from .huya_service import huya_config_value GIFTS_LIMIT = 200 POINTS_SEASON_MS_FLOOR = 1_000_000_000_000 def _clip(value: Any, limit: int = 200) -> str: text = str(value if value is not None else "").strip() return text if len(text) <= limit else text[: limit - 3] + "..." def _walk_season_nodes(node: Any, found: list[dict]) -> None: if isinstance(node, dict): config = node.get("tag0") if isinstance(config, dict): try: start_ms = int(config.get("tag10") or 0) end_ms = int(config.get("tag11") or 0) config_id = int(config.get("tag0") or 0) except (TypeError, ValueError): start_ms = end_ms = config_id = 0 if start_ms >= POINTS_SEASON_MS_FLOOR and end_ms > start_ms: found.append( { "name": _clip(config.get("tag2"), 128), "start_ms": start_ms, "end_ms": end_ms, "config_id": config_id, } ) for value in node.values(): _walk_season_nodes(value, found) elif isinstance(node, list): for value in node: _walk_season_nodes(value, found) def extract_points_center_season(payload: Any) -> dict | None: """取积分中心当前宝典赛季;认不出结构时返回 ``None``。""" found: list[dict] = [] _walk_season_nodes(payload, found) return max(found, key=lambda item: item["end_ms"]) if found else None def resolve_points_center_config(db: Session) -> dict[str, Any]: """读取积分中心请求参数;空配置自动回落到当前页面默认值。""" row = db.query(HuyaConfig).first() def value(field: str, default: str) -> str: return ( huya_config_value( field, getattr(row, field, None) if row is not None else None ) or default ) try: scene = int(value("points_scene", HUYA_DEFAULT_POINTS_SCENE)) except (TypeError, ValueError): scene = int(HUYA_DEFAULT_POINTS_SCENE) page_id_text = value("points_page_id", "") try: page_id = int(page_id_text) if page_id_text else None except ValueError: page_id = None tab_ids: list[int] = [] for part in re.split(r"[,,\s]+", value("points_tab_ids", "")): if part.isdigit(): tab_id = int(part) if tab_id not in tab_ids: tab_ids.append(tab_id) return points_center_options_from_config( { "points_page_code": value( "points_page_code", HUYA_DEFAULT_POINTS_PAGE_CODE ), "points_source_id": value( "points_source_id", HUYA_DEFAULT_POINTS_SOURCE_ID ), "points_scene": scene, "points_page_id": page_id or "", "points_tab_ids": ",".join(str(item) for item in tab_ids), } ) def points_center_options_from_config( config_info: Mapping[str, Any], ) -> dict[str, Any]: """把配置字段规范的公共入口,任务和路由不再各自解析。""" tab_ids = [ int(part) for part in str(config_info.get("points_tab_ids") or "") .replace(",", ",") .replace(" ", ",") .split(",") if part.isdigit() ] return { "page_code": ( str(config_info.get("points_page_code") or "").strip() or HUYA_DEFAULT_POINTS_PAGE_CODE ), "source_id": ( str(config_info.get("points_source_id") or "").strip() or HUYA_DEFAULT_POINTS_SOURCE_ID ), "scene": int(config_info.get("points_scene") or 0) or int(HUYA_DEFAULT_POINTS_SCENE), "page_id": int(config_info.get("points_page_id") or 0) or None, "tab_ids": tab_ids or None, } @dataclass class PointsCenterCatalog: goods: list[dict] = field(default_factory=list) tabs: list[dict] = field(default_factory=list) sid: int | None = None season: dict | None = None payload: dict | None = None message: str = "" def fetch_points_center_catalog( uid: int, cookie: str, *, client: Any | None = None, page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE, source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID, scene: int = int(HUYA_DEFAULT_POINTS_SCENE), page_id: int | None = None, tab_ids: list[int] | None = None, include_all_tabs: bool = True, ) -> PointsCenterCatalog: """一次读取积分中心的商品、页签、SID 和赛季配置。 ``include_all_tabs=False`` 仅供只取 SID/赛季的调用方使用,避免为了元数据 额外请求所有商品页签。商品列表调用必须保持默认 ``True``。 """ if client is None: from core.huya.http_client import HuyaHttpClient client = HuyaHttpClient( logger=lambda message: logger.debug(f"[huya-points-center] {message}") ) def call( tab_id: int | None = None, tab_page_id: int | None = None ) -> tuple[dict | None, str]: kwargs: dict[str, Any] = { "uid": int(uid), "guid": "", "cookie": cookie, "page_code": page_code, "source_id": source_id, "scene": scene, "deep": True, } if tab_id is not None: kwargs["page_id"] = int(page_id or tab_page_id or 0) kwargs["waterfall_tab_id"] = int(tab_id) try: response = client.play_mall_query_allocation(**kwargs) except Exception as exc: # noqa: BLE001 外部接口异常收敛为目录消息 logger.debug( f"[huya-points-center] queryAllocation 异常:" f"{type(exc).__name__}: {exc}" ) return None, f"{type(exc).__name__}: {_clip(exc)}" if response is None: return None, "积分中心接口没有返回数据" code = getattr(response, "code", None) if code is None: code = getattr(response, "status", 0) code = int(code or 0) if code != 200: message = _clip( getattr(response, "message", "") or getattr(response, "msg", "") ) return None, f"code={code}{(':' + message) if message else ''}" payload = getattr(response, "payload", None) if payload is None and hasattr(response, "to_dict"): try: payload = (response.to_dict() or {}).get("payload") except Exception: # noqa: BLE001 第三方响应结构异常时按空处理 payload = None return payload if isinstance(payload, dict) else None, "" base_payload, error = call() if error: return PointsCenterCatalog(message=f"拉取商品失败:{error}") if base_payload is None: return PointsCenterCatalog(message="积分中心接口没有返回商品数据") goods = parse_points_center_gifts(base_payload) tabs = extract_points_center_tabs(base_payload) sid = extract_points_center_sid(base_payload) season = extract_points_center_season(base_payload) if not include_all_tabs or (not goods and not tabs): message = ( f"已拉取 {len(goods)} 个礼包(积分中心)" if goods else "积分中心没返回商品:接口通了但列表是空的" ) return PointsCenterCatalog( goods=goods[:GIFTS_LIMIT], tabs=tabs, sid=sid, season=season, payload=base_payload, message=message, ) discovered = {(int(tab["page_id"]), int(tab["tab_id"])): tab for tab in tabs} loaded_categories = {str(gift.get("category_name") or "").strip() for gift in goods} tab_errors: list[str] = [] if tab_ids: targets: list[dict] = [] for tab_id in tab_ids: matched = next( ( tab for (tab_page_id, tab_id_value), tab in discovered.items() if tab_id_value == int(tab_id) and (page_id is None or tab_page_id == int(page_id)) ), None, ) if matched is not None: targets.append(matched) elif page_id is not None: targets.append( { "page_id": int(page_id), "tab_id": int(tab_id), "name": f"页签 {tab_id}", } ) else: tab_errors.append(f"页签 {tab_id}:响应里没发现,且未配置 pageId") else: targets = [ tab for tab in tabs if str(tab.get("name") or "").strip() not in loaded_categories ] seen_products = { str(gift.get("product_id") or "") for gift in goods if gift.get("product_id") } loaded_tabs = len(loaded_categories) if goods else 0 for tab in targets: target_page_id = int(page_id or tab["page_id"]) tab_payload, tab_error = call(int(tab["tab_id"]), target_page_id) if tab_error: tab_errors.append(f"{tab.get('name') or tab['tab_id']}:{tab_error}") continue tab_goods = parse_points_center_gifts(tab_payload) added = 0 for gift in tab_goods: product_id = str(gift.get("product_id") or "") if product_id and product_id not in seen_products: seen_products.add(product_id) goods.append(gift) added += 1 if added or tab_goods: loaded_tabs += 1 goods = goods[:GIFTS_LIMIT] if not goods: return PointsCenterCatalog( tabs=tabs, sid=sid, season=season, payload=base_payload, message="积分中心没返回商品:接口通了但列表是空的", ) suffix = f",{loaded_tabs} 个页签" if loaded_tabs > 1 else "" if tab_errors: suffix += f";部分页签未加载:{';'.join(tab_errors[:3])}" return PointsCenterCatalog( goods=goods, tabs=tabs, sid=sid, season=season, payload=base_payload, message=f"已拉取 {len(goods)} 个礼包(积分中心{suffix})", )