Files
live-hub-py/core/huya/elite_session.py
T
yml2213 f540dcfc29 feat(huya): 对齐积分中心开通链路(2026-10-13 抓包)
按 10.13「开通精英宝典/购买幸运币」抓包(m-shop.huya.com/points.html)对齐商城链路:

- WSS host 改为 launch.queryHttpDns 动态解析(失败回退抓包实测 host);握手 baseinfo
  只带 uid/guid/页面 UA/exp,Origin 与页面同源
- wsLaunch 改为带真实 lUid/sGuid;初始化补上 AUTH(0x0A) → getConfig →
  UpdateUserExps(0x17),并把 getConfig 下发的版本 map 原样回传(不回传则业务 RPC 无响应)
- 补齐新 RPC:getSupplierInfoV5 / acceptHyProtocolV5 / calcOrderPromotion /
  PlayMallUI.getMyPromotion|queryAllocation / wupui.* / huyauserui.getUserProfile
- 下单参数对齐:shopAppInfo.scene=7 与订单 src/orderScene=4 分离、orderType=6、
  bizType=5、gameCategoryId=507、extraParam.bizParams 标记 jifencenter、env 仅空点击
  序列、支付回调指向 m-shop.huya.com/points.html
- 开通链路抽到 services/huya_shop_flow.py,充值任务改为调用它

验证:新增 tests/test_huya_points_shop_protocol.py 与抓包逐字节比对(心跳、版本回传、
wsLaunch/getConfig/下单/支付帧与新 RPC 参数,共 10 条);按 AGENTS.md 做了反向验证
(把 jifencenter 改成 0 后该用例变红,还原后恢复)。ruff format --check 与全量 pytest 通过。
2026-10-15 14:56:30 +08:00

278 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙精英宝典的同步 WSS 会话封装。
任务执行器运行在线程中,使用本类把异步 WSS 生命周期限制在一个账号任务内,
避免在每次 RPC 时重复建连,也不把 asyncio 对象跨线程传递。
"""
from __future__ import annotations
import asyncio
import urllib.parse
from collections.abc import Callable
from typing import Literal
from .http_client import (
HuyaHttpClient,
generate_http_baseinfo,
generate_ws_baseinfo,
resolve_wss_host,
)
from .web_cookie_fields import missing_web_cookie_fields
from .wss_client import (
ACTIVITY_WS_HOST,
POINTS_ORIGIN,
POINTS_PAGE_UA,
HuyaWssClient,
)
class HuyaEliteWssSession:
"""一个账号、一个通道、一个可复用的同步 WSS 会话。"""
def __init__(
self,
uid: int,
cookie: str,
kind: Literal["activity", "shop"],
logger: Callable[[str], None] | None = None,
host: str | None = None,
):
self.uid = int(uid or 0)
self.cookie = cookie or ""
self._original_cookie = self.cookie
self.guid = HuyaHttpClient._resolve_cookie_guid(self.cookie)
self.kind = kind
self.logger = logger or (lambda _message: None)
self.http = HuyaHttpClient(logger=self.logger)
self.loop = asyncio.new_event_loop()
# 构造期必定赋值;提前声明为具体类型,避免每个透传方法都被判定为可选成员。
self.client: HuyaWssClient
self._closed = False
if kind == "activity":
baseinfo = urllib.parse.unquote(
generate_http_baseinfo(self.uid, self.guid, "")
)
connect_host = host or ACTIVITY_WS_HOST
origin = "https://zt.huya.com"
else:
# 积分中心通道(2026-10-13 抓包对齐):
# - host 由 launch.queryHttpDns 动态解析(失败回退抓包实测 host)
# - 握手 baseinfo 带真实 uid/guid + h5_/points.html UA,cookie/trace 为空
# - Origin 与页面同源(m-shop.huya.com)
baseinfo = generate_ws_baseinfo(self.uid, self.guid, POINTS_PAGE_UA)
connect_host = host or resolve_wss_host(
self.uid, self.cookie, self.guid, self.logger
)
origin = POINTS_ORIGIN
self.client = HuyaWssClient(baseinfo=baseinfo, logger=self.logger)
try:
if kind == "activity" and missing_web_cookie_fields(self.cookie):
# 网页设备态缺核心字段(guid/_qimei_uuid42/__yamid_new/game_did 等):
# 先跑一次 wsLaunch 拿到服务端认可的会话 guid,写回 cookie 补齐后再建会话。
# 否则 getConfig / 业务 RPC 无响应(2026-09-01 实测)。
probe = HuyaWssClient(baseinfo=baseinfo, logger=self.logger)
try:
self.loop.run_until_complete(
probe.connect(
host=connect_host, origin=origin, cookie=self.cookie
)
)
self.loop.run_until_complete(
probe.call_ws_launch_activity(self.uid, self.guid)
)
launch_guid = probe._launch_guid or ""
self.logger(
f"[WSS] 预取会话 guid={launch_guid or '(空)'},补齐网页 Cookie 字段"
)
finally:
if probe.ws is not None:
try:
self.loop.run_until_complete(probe.disconnect())
except Exception: # noqa: BLE001 清理探针连接失败不影响主流程
self.logger("[WSS] 预取探针清理失败")
from .web_cookie_fields import fill_web_cookie_fields
self.cookie = fill_web_cookie_fields(self.cookie, launch_guid, self.uid)
self.guid = HuyaHttpClient._resolve_cookie_guid(self.cookie)
baseinfo = urllib.parse.unquote(
generate_http_baseinfo(self.uid, self.guid, "")
)
self.client = HuyaWssClient(baseinfo=baseinfo, logger=self.logger)
self.loop.run_until_complete(
self.client.connect(
host=connect_host, origin=origin, cookie=self.cookie
)
)
initialized = self.loop.run_until_complete(
self.client.initialize_activity(self.uid, self.guid, self.cookie)
if kind == "activity"
else self.client.initialize(self.uid, self.guid, self.cookie)
)
if not initialized:
raise RuntimeError(f"虎牙 {kind} WSS 初始化失败")
if self.client._launch_guid:
# wsLaunch 的 sGuid 是当前连接的真实会话值,供后续业务 Cookie
# 与 HTTP baseinfo 继续复用,避免沿用登录时的旧 guid。
self.cookie = self.client._cookie_with_guid(
self.cookie, self.client._launch_guid
)
self.guid = self.client._launch_guid
except Exception:
self.close()
raise
def _run(self, coroutine):
if self._closed or self.client is None:
raise RuntimeError("虎牙 WSS 会话已关闭")
return self.loop.run_until_complete(coroutine)
def _resolve_cookie(self, cookie: str) -> str:
"""业务 RPC 统一使用补齐后的网页 Cookie。
任务侧往往传原始登录态 Cookie(缺网页设备字段);初始化阶段已经
wsLaunch 取回会话 guid 并补齐字段(见 __init__),这里把缺字段的
输入替换为补齐后的 self.cookie。
"""
if (
not cookie
or cookie == self._original_cookie
or missing_web_cookie_fields(cookie)
):
return self.cookie
return cookie
def close(self):
if self._closed:
return
self._closed = True
if self.client is not None and self.client.ws is not None:
try:
self.loop.run_until_complete(self.client.disconnect())
except Exception: # noqa: BLE001 - cleanup must not mask task result
self.logger("[WSS] 会话清理异常")
self.loop.close()
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
self.close()
def get_act_info(self, act_id: int):
return self._run(self.client.get_act_info(act_id))
def get_act_task_detail(self, uid: int, cookie: str, act_id: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.get_act_task_detail(uid, cookie, act_id))
def get_act_user_task_detail(self, uid: int, cookie: str, act_id: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.get_act_user_task_detail(uid, cookie, act_id))
def get_user_score(self, uid: int, cookie: str, sid: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.get_user_score(uid, cookie, sid))
def query_user_score(self, uid: int, cookie: str, sid: int):
return self.get_user_score(uid, cookie, sid)
def get_act_prize_list(self, uid: int, cookie: str, sid: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.get_act_prize_list(uid, cookie, sid))
def get_act_prize_detail(self, uid: int, cookie: str, sid: int, pid: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.get_act_prize_detail(uid, cookie, sid, pid))
def score_exchange_prize(self, uid: int, cookie: str, sid: int, pid: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.score_exchange_prize(uid, cookie, sid, pid))
def get_user_prize_records(self, uid: int, cookie: str, sid: int):
cookie = self._resolve_cookie(cookie)
return self._run(self.client.get_user_prize_records(uid, cookie, sid))
def _resolve_kwargs(self, kwargs: dict) -> dict:
if kwargs.get("cookie") is not None:
kwargs = dict(kwargs)
kwargs["cookie"] = self._resolve_cookie(kwargs["cookie"])
return kwargs
def check_user_bind_game_account(self, **kwargs):
kwargs = self._resolve_kwargs(kwargs)
return self._run(self.client.check_user_bind_game_account(**kwargs))
def confirm_bind_act_account(self, **kwargs):
kwargs = self._resolve_kwargs(kwargs)
return self._run(self.client.confirm_bind_act_account(**kwargs))
def get_live_link_param(self, **kwargs):
kwargs = self._resolve_kwargs(kwargs)
return self._run(self.client.get_live_link_param(**kwargs))
def get_user_profile_batch(self, **kwargs):
kwargs = self._resolve_kwargs(kwargs)
return self._run(self.client.get_user_profile_batch(**kwargs))
def build_bind_urls(self, *args, **kwargs):
return self.http.build_bind_urls(*args, **kwargs)
def get_livelink_mini_qrcode(self, *args, **kwargs):
return self.http.get_livelink_mini_qrcode(*args, **kwargs)
def get_livelink_qrcode_status(self, *args, **kwargs):
return self.http.get_livelink_qrcode_status(*args, **kwargs)
def get_goods_info(self, **kwargs):
return self._run(self.client.get_goods_info(**kwargs))
def list_pay_channels(self, **kwargs):
return self._run(self.client.list_pay_channels(**kwargs))
def check_hy_protocol(self, **kwargs):
return self._run(self.client.check_hy_protocol(**kwargs))
def check_user_buy_auth(self, **kwargs):
return self._run(self.client.check_user_buy_auth(**kwargs))
def create_order(self, **kwargs):
return self._run(self.client.create_order(**kwargs))
def pay_order_submit(self, **kwargs):
return self._run(self.client.pay_order_submit(**kwargs))
def order_detail(self, **kwargs):
return self._run(self.client.order_detail(**kwargs))
def query_user_order_list(self, **kwargs):
return self._run(self.client.query_user_order_list(**kwargs))
# ---- 积分中心链路新增 RPC(2026-10-13 抓包) ----
def get_supplier_info(self, **kwargs):
return self._run(self.client.get_supplier_info(**kwargs))
def accept_hy_protocol(self, **kwargs):
return self._run(self.client.accept_hy_protocol(**kwargs))
def calc_order_promotion(self, **kwargs):
return self._run(self.client.calc_order_promotion(**kwargs))
def play_mall_query_allocation(self, **kwargs):
return self._run(self.client.play_mall_query_allocation(**kwargs))
def play_mall_get_my_promotion(self, **kwargs):
return self._run(self.client.play_mall_get_my_promotion(**kwargs))
def get_user_profile(self, **kwargs):
return self._run(self.client.get_user_profile(**kwargs))
def get_follow_wechat_status(self, **kwargs):
return self._run(self.client.get_follow_wechat_status(**kwargs))
__all__ = ["HuyaEliteWssSession"]