Files
live-hub-py/core/huya/wss_client.py
T
yml2213 905a7ef22c fix(huya): 查询积分改走积分中心新接口(含成长等级)
旧活动接口 webActUI.getUserScore 对任何 sid 都返回 603「活动已结束」
(接口已下线,与工作台 SID/活动配置无关),刷新积分只会报错。

- 协议层新增两个 RPC(WSS + HTTP 双侧同签名):
  shopMiddleUI.getRedeemNeedGoods(tag2=当前积分、tag3=兑换门槛,
  兑换商品号 POINTS_REDEEM_GOODS_NUM=8315982,传错直接 500)
  与 GameBizUI.getGrowLevelUserInfo(成长值/成长等级/昵称/头像)
- 新增结构体 GetRedeemNeedGoodsReq / GetGrowLevelUserInfoReq,
  与 HuyaEliteWssSession 透传
- 服务层 _execute_query_points 主源改为积分中心接口,结果带
  points/redeem_threshold/grow_level/grow_value,并同步 account.points;
  接口无响应时才回退旧活动接口并如实报告其 603 返回
- 开通支付成功后的积分刷新同样改走积分中心接口
- 前端任务页积分结果优先读 points(兼容旧记录的 available_score),
  并在有成长等级时一并展示
- 新增 tests/test_huya_points_query.py:真机响应字节回归 + 请求参数
  字面量断言 + 服务层积分来源/回退语义(已做反向验证)

真机验证:HTTP 通道取到积分 280 / 门槛 20 / 成长等级 3
2026-10-15 15:32:46 +08:00

1485 lines
54 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 客户端 — 商城通道 (77bc035c-ws.va.huya.com)
初始化流程 (Chrome WS 帧实证):
1. HEARTBEAT_SEND (cmd 0x10)
2. RPC: launch.wsLaunch (cmd 0x03) — 商城版无 lUid
3. RPC: mobileui.getConfig (cmd 0x03)
4. 等待 wsLaunch/getConfig 响应
5. REGISTER (cmd 0x21) — 回传 wsLaunch 返回的 sGuid
6. CONFIRM_SEND (cmd 0x17)
7. 业务 RPC ...
关键发现 (Chrome WS 帧分析):
- 服务端响应 seq ≠ 请求 seq → 必须用 FIFO 匹配
- 初始化 wsLaunch iRequestId=-1,业务 requestId 从 8 开始递增
"""
import asyncio
import random
import re
import struct
from collections import deque
from collections.abc import Callable
from typing import Any, cast
import websockets
from .cookie_utils import cookie_pairs, normalize_cookie_pairs
from .frame_decoder import (
_decode_taf_struct,
_truncate,
decode_struct_bytes,
format_wss_log,
)
from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType
from .wup_protocol import WupRequest, WupResponse
SHOP_WS_HOST = "77bc035c-ws.va.huya.com"
ACTIVITY_WS_HOST = "d35bf373-ws.va.huya.com"
# 商城端点 baseinfo (conn4, h5_/index.html) — 商城业务 shopMiddleUI 走此通道
# wsLaunch tReq: lUid=0, sGuid="", sUA="webh5&0.0.1&websocket&&h5_/index.html"
SHOP_BASEINFO = "DBYAJiV3ZWJoNSYwLjAuMSZ3ZWJzb2NrZXQmJmg1Xy9pbmRleC5odG1sNgxIVVlBJlpIJjIwNTJGAFYAbHYAhgCWAKgM"
TAIL_BYTES = bytes.fromhex("2c36004c5c6600")
BIG_TAIL_SUFFIX = bytes.fromhex("4c5c6600")
WSS_BIZ_UA = "web&1.0.0&huya"
WSS_COOKIE_UA = "webh5&0.0.1&websocket&&diypc_52775"
WSS_SHOP_SCENE = 4
# ============================================================
# 积分中心页面上下文(m-shop.huya.com/points.html)
#
# 2026-10-13「开通精英宝典 / 购买幸运币」抓包实证:servant 仍是 shopMiddleUI,
# 但页面 UA / Origin / Referer / 下单参数与 7.4 的 m-shop.yaoguo.com 通道不同:
# - wsLaunch 带真实 lUid + sGuid,UA 为 h5_/points.html
# - 先 VerifyCookie(0x0A) 再 getConfig,最后用 getConfig 的版本 map 回传(0x17)
# - shopAppInfo.scene = 7,而 createOrderV5 的 src/orderScene = 4
# - 下单 extraParam.bizParams 标记积分中心来源,env 只有空 user_click_seq
# ============================================================
POINTS_PAGE_UA = "webh5&0.0.1&websocket&&h5_/points.html"
POINTS_ORIGIN = "https://m-shop.huya.com"
POINTS_REFERER = (
"https://m-shop.huya.com/points.html"
"?pagecode=heping_baodian_credit_center_page&sourceId=yellowcarlist"
)
POINTS_PAGE_CODE = "heping_baodian_credit_center_page"
POINTS_PAY_CALLBACK = "https://m-shop.huya.com/points.html#/consumer/paycallback"
# wsLaunch 响应里的 host 是动态的(launch.queryHttpDns);此为抓包实测值兼兜底。
SHOP_POINTS_WS_HOST = "d35bf373-ws.va.huya.com"
SUPPLIER_ID_YELLOW_CAR = 87401
GAME_CATEGORY_ID_HUYA = 507
ORDER_BIZ_TYPE = 5
SHOP_APP_SCENE = 7
ORDER_SCENE = 4
PEACE_ELITE_GAME_ID = 9815
# getRedeemNeedGoods 的兑换商品号:抓包里的精英宝典兑换商品 hy-8315982 → 8315982。
# 服务端只认这个商品号(传 0/其它值直接 500「系统异常」)。
POINTS_REDEEM_GOODS_NUM = 8315982
# createOrderV5 的 extraParam.bizParams:标记「积分中心」来源,缺了会走成普通商城单。
JIFENCENTER_BIZ_PARAMS = '{ "jifencenter": 1 }'
class _ActivityConfigReq(TafStruct):
def __init__(self, user):
self.user = user
def write_to(self, os: TafOutputStream):
os.write_struct(0, self.user)
os.write_map(1, {})
os.write_string(2, "huya")
os.write_list(3, [])
def read_from(self, _ins):
pass
# seq 按消息类型固定 (HAR 实证,跨两次抓包一致)
SEQ_HEARTBEAT = 0x1D00000D
SEQ_WSLAUNCH = 0x1D000078
SEQ_GETCONFIG = 0x1D000076
SEQ_REGISTER = 0x1D00003C
SEQ_CONFIRM = 0x1D000002
SEQ_BUSINESS = 0x1D000106 # 业务 RPC (HAR 7.4 实证:业务帧固定使用该 seq)
SEQ_ACTIVITY_WSLAUNCH = 0x1D000100
SEQ_ACTIVITY_BIZ = 0x1D000109
# 积分中心通道业务帧(2026-10-13 抓包:AUTH/getConfig/业务统一 0x1d000107,
# 仅 createOrderV5 / payOrderSubmitV5 为 0x1d000108)。
SEQ_POINTS_BIZ = 0x1D000107
SEQ_POINTS_ORDER = 0x1D000108
# 积分中心通道里用 0x1d000108 的两帧
POINTS_ORDER_METHODS = frozenset({"createOrderV5", "payOrderSubmitV5"})
class WssCommand:
RPC_REQUEST = 0x03
RPC_RESPONSE = 0x04
AUTH = 0x0A
SERVER_PUSH1 = 0x0B
HEARTBEAT_SEND = 0x10
HEARTBEAT_RECV = 0x11
CONFIRM_SEND = 0x17
SERVER_PUSH2 = 0x18
REGISTER = 0x21
class WssMessage:
def __init__(self, command: int, sequence: int, body: bytes):
self.command = command
self.sequence = sequence
self.body = body
def encode(self) -> bytes:
header = struct.pack(">BBI", 0x00, self.command, self.sequence)
return header + self.body
@classmethod
def decode(cls, data: bytes) -> "WssMessage":
if len(data) < 6:
raise ValueError(f"消息太短: {len(data)} bytes")
_version, command = struct.unpack(">BB", data[0:2])
sequence = struct.unpack(">I", data[2:6])[0]
body = data[6:]
return cls(command=command, sequence=sequence, body=body)
class HuyaWssClient:
"""虎牙 WSS 客户端 — 商城通道"""
def __init__(
self, baseinfo: str | None = None, logger: Callable[[str], None] | None = None
):
self.baseinfo = baseinfo or SHOP_BASEINFO
self.ws: Any = None
self._biz_seq = SEQ_BUSINESS # 业务 RPC 递增用
self._wup_req_id = 8
self.rpc_queue = deque()
self.logger = logger or print
self._recv_task = None
self._heartbeat_task = None
self._launch_guid = ""
self._launch_ip = ""
self._activity_mode = False
# 积分中心通道:0x10 组播 + wsLaunch(带 uid) + AUTH + getConfig + 版本回传
self._points_mode = False
# getConfig 下发的版本 map,UpdateUserExps(0x17) 必须原样回传
self._config_versions: dict[str, str] = {}
def _next_biz_seq(self, order_frame: bool = False) -> int:
"""业务 RPC 的 frame seq。
浏览器在商城 WSS 上连续发送多个业务 RPC 时,frame seq 基本保持固定值;
真正区分请求依赖 WUP requestId。积分中心通道里 createOrderV5 /
payOrderSubmitV5 两帧用 0x1d000108,其余用 0x1d000107(抓包实证)。
"""
if self._points_mode:
return SEQ_POINTS_ORDER if order_frame else SEQ_POINTS_BIZ
return SEQ_ACTIVITY_BIZ if self._activity_mode else SEQ_BUSINESS
def _next_wup_req_id(self) -> int:
"""业务 WUP requestId,HAR 中 getGoodsInfoV5 从 8 开始递增"""
req_id = self._wup_req_id
self._wup_req_id += 1
return req_id
@staticmethod
def _make_big_tail() -> bytes:
"""生成 WSS 大包尾部 trace(格式与浏览器 HAR 一致)"""
trace = f"{random.getrandbits(64):016x}"
return f",6%{trace}:{trace}:0:3".encode("ascii") + BIG_TAIL_SUFFIX
@staticmethod
def _encode_rpc_body(wup_data: bytes) -> bytes:
"""按浏览器规则封装 WSS RPC body
小包: [4B WUP长度][WUP body] + TAIL_BYTES
大包: [1B len低字节][4B WUP body长度][裸WUP body] + trace尾部
"""
if len(wup_data) < 4:
return wup_data + TAIL_BYTES
declared_len = int.from_bytes(wup_data[:4], "big")
if declared_len == len(wup_data):
wup_body = wup_data[4:]
else:
wup_body = wup_data
if len(wup_body) <= 0xFF:
return wup_data + TAIL_BYTES
# 大包头里的长度使用原始 WUP 包长度(含 4B 长度前缀),但正文只放裸 WUP body;
# 浏览器随后用 trace 尾部的前 4 字节补足该长度窗口。
packet_len = len(wup_data)
return (
bytes([packet_len & 0xFF])
+ packet_len.to_bytes(4, "big")
+ wup_body
+ HuyaWssClient._make_big_tail()
)
@staticmethod
def _encode_activity_launch_body(wup_data: bytes) -> bytes:
"""活动 wsLaunch 使用浏览器的固定大包头,即使包体小于 255B。"""
if len(wup_data) < 4:
return wup_data + TAIL_BYTES
packet_len = len(wup_data)
return (
bytes([packet_len & 0xFF])
+ packet_len.to_bytes(4, "big")
+ wup_data[4:]
+ TAIL_BYTES
)
@staticmethod
def _normalize_biz_cookie(cookie: str) -> str:
"""业务 RPC 的 huya_ua Cookie 值按浏览器 WSS 抓包修正"""
cookie = (cookie or "").strip()
normalized = f"huya_ua={WSS_COOKIE_UA}"
if re.search(r"(?:^|;\s*)huya_ua=", cookie):
return re.sub(
r"(^|;\s*)huya_ua=[^;]*",
lambda m: f"{m.group(1)}{normalized}",
cookie,
count=1,
)
if not cookie:
return normalized
return f"{normalized}; {cookie}"
@staticmethod
def _cookie_with_guid(cookie: str, guid: str) -> str:
"""用当前 WSS 会话的 sGuid 替换 Cookie 中的旧 guid。"""
value = str(guid or "").strip()
if not value:
return cookie or ""
pairs = cookie_pairs(cookie)
replaced = False
normalized: list[tuple[str, str]] = []
for key, item_value in pairs:
if key == "guid":
if not replaced:
normalized.append((key, value))
replaced = True
continue
normalized.append((key, item_value))
if not replaced:
normalized.append(("guid", value))
return normalize_cookie_pairs(normalized)
@staticmethod
def _build_biz_user(uid: int, cookie: str):
"""构造 WSS 业务 UserId(HAR 实证:guid 为空,cookie 不额外加前缀)"""
from .shop_structs import UserId
user = UserId()
user.lUid = uid
user.sGuid = ""
user.sToken = ""
user.sHuYaUA = WSS_BIZ_UA
user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie)
user.iTokenType = 0
user.sDeviceInfo = ""
user.sQIMEI = ""
return user
@staticmethod
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = SHOP_APP_SCENE):
"""构造 WSS 业务 ShopAppInfo(积分中心抓包 scene 为 7)"""
from .shop_structs import ShopAppInfo
info = ShopAppInfo()
info.sAppId = "huya"
info.sBizType = ""
info.scene = scene
info.sourceId = source_id
return info
@staticmethod
def _build_play_mall_app(source_id: str = "yellowcarlist"):
"""构造 PlayMallUI 系接口的应用信息(字段序与 ShopAppInfo 不同)"""
from .shop_structs import PlayMallAppInfo
info = PlayMallAppInfo()
info.sourceId = source_id
return info
@staticmethod
def _build_order_env():
"""下单/支付环境参数。
2026-10-13 积分中心抓包:仅一个空点击序列,没有 7.4 那版坐标数组与 input_1。
"""
return {"user_click_seq": "[]"}
@staticmethod
def _build_config_user(uid: int, guid: str, cookie: str):
"""getConfig 的 UserId:带真实 uid/guid/cookie,UA 用页面 UA。"""
from .shop_structs import UserId
user = UserId()
user.lUid = int(uid or 0)
user.sGuid = guid or ""
user.sToken = ""
user.sHuYaUA = POINTS_PAGE_UA
user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie)
user.iTokenType = 0
user.sDeviceInfo = ""
user.sQIMEI = ""
return user
async def connect(
self,
host: str = SHOP_WS_HOST,
timeout: float = 15.0,
cookie: str = "",
origin: str = "https://m-shop.yaoguo.com",
):
from urllib.parse import quote
url = f"wss://{host}/?baseinfo={quote(self.baseinfo)}"
self.logger(f"[WSS] 正在连接 {host} ...")
self.logger(f"[WSS] URL: {url[:100]}...")
BROWSER_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/149.0.0.0 Safari/537.36"
)
headers = {"Accept-Language": "zh-CN,zh;q=0.9"}
if cookie:
headers["Cookie"] = cookie
try:
self.ws = await asyncio.wait_for(
websockets.connect(
url,
origin=cast(Any, origin),
user_agent_header=BROWSER_UA,
additional_headers=headers,
open_timeout=timeout,
# 不信任环境代理:web 进程可能带 SOCKS 代理变量,
# 会导致 WSS 直连失败(需 python-socks);虎牙国内直连。
proxy=None,
),
timeout=timeout,
)
except TimeoutError:
self.logger(f"[WSS] 连接超时({timeout}s)")
raise
except Exception as e:
self.logger(f"[WSS] 连接失败: {type(e).__name__}: {e}")
raise
self.logger(f"[WSS] 握手成功, 实际连接: {self.ws.remote_address}")
self._recv_task = asyncio.create_task(self._recv_loop())
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
async def disconnect(self):
if self._heartbeat_task:
self._heartbeat_task.cancel()
if self._recv_task:
self._recv_task.cancel()
if self.ws:
await self.ws.close()
self.ws = None
self.logger("[WSS] 已断开")
async def _recv_loop(self):
try:
async for raw in self.ws:
try:
raw_bytes = raw if isinstance(raw, bytes) else raw.encode()
msg = WssMessage.decode(raw_bytes)
self.logger(
format_wss_log(msg.body, msg.command, msg.sequence, "收")
)
await self._handle_message(msg)
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(
f"[WSS] 解析消息失败: {e} raw_hex={raw_bytes[:50].hex()}"
)
except asyncio.CancelledError:
pass
except websockets.exceptions.ConnectionClosed as e:
self.logger(f"[WSS] 连接关闭: code={e.code} reason={e.reason}")
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}")
async def _handle_message(self, msg: WssMessage):
if msg.command == WssCommand.RPC_RESPONSE:
if self.rpc_queue:
future = self.rpc_queue.popleft()
if not future.done():
future.set_result(msg.body)
else:
self.logger(f"[WSS] 未匹配的 RPC 响应 seq={msg.sequence}")
elif msg.command == WssCommand.HEARTBEAT_RECV:
pass # 心跳已在上层 _recv_loop 打过日志
elif msg.command in (WssCommand.SERVER_PUSH1, WssCommand.SERVER_PUSH2):
pass # 推送已在上层打过日志
else:
pass # 未知消息已在上层打过日志
async def _heartbeat_loop(self):
try:
while True:
await asyncio.sleep(8)
await self.send_heartbeat()
except asyncio.CancelledError:
pass
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[WSS] 心跳循环异常: {e}")
async def send_heartbeat(self):
# body = LIST(tag0,count=1) + 元素{tag0="live:0", tag1=""} + TAIL
os = TafOutputStream()
os.write_head(0, TafType.LIST) # 09
os.write_int32(0, 1) # 00 01 (count=1)
os.write_string(0, "live:0") # 06 06 live:0
os.buf.write(b"\x16\x00") # 16 00 (tag1 "")
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(
command=WssCommand.HEARTBEAT_SEND, sequence=SEQ_HEARTBEAT, body=body
)
self.logger(
format_wss_log(body, WssCommand.HEARTBEAT_SEND, SEQ_HEARTBEAT, "发")
)
await self.ws.send(msg.encode())
async def initialize(self, uid: int, guid: str, cookie: str):
"""商城(积分中心)端点初始化,对齐 2026-10-13 抓包帧序。
RegisterGroup(0x10, live:0)
→ launch.wsLaunch(带真实 lUid + sGuid,UA=h5_/points.html)
→ VerifyCookie(0x0A,页面 UA)
→ mobileui.getConfig(带真实 uid/guid/cookie)
→ UpdateUserExps(0x17),回传 getConfig 下发的版本 map
getConfig 之后浏览器还会补发一次 RegisterGroup;业务 RPC 期间用 0x14 心跳维持。
"""
self.logger("[初始化] 开始 WSS 初始化流程 (积分中心端点)...")
self._points_mode = True
await self.send_heartbeat()
launch_rsp = await self.call_ws_launch_shop(uid, guid)
if launch_rsp is None:
self.logger("[✗] wsLaunch 无响应,初始化失败")
return False
self.logger(
f"[初始化] wsLaunch OK guid={self._launch_guid} ip={self._launch_ip}"
)
# wsLaunch 为当前连接签发新的 sGuid:AUTH、getConfig 与后续业务必须复用它。
if self._launch_guid:
guid = self._launch_guid
cookie = self._cookie_with_guid(cookie, guid)
await self.send_auth(
uid, cookie, sequence=SEQ_BUSINESS, guid=guid, ua=POINTS_PAGE_UA
)
await self.call_get_config_shop(uid=uid, guid=guid, cookie=cookie)
await self.send_confirm(self._config_versions)
await asyncio.sleep(0.2)
self.logger("[初始化] 完成 ✓")
return True
async def initialize_activity(self, uid: int, guid: str, cookie: str):
"""活动通道初始化:heartbeat -> wsLaunch -> AUTH -> getConfig -> confirm。"""
self._activity_mode = True
await self.send_heartbeat()
launch_rsp = await self.call_ws_launch_activity(uid, guid)
if launch_rsp is None:
self.logger("[WSS] 活动 wsLaunch 无响应")
return False
# wsLaunch 会为当前连接签发新的 sGuid。AUTH、getConfig 和后续业务
# 请求必须使用同一个值;登录 Cookie 里的旧 guid 不能继续复用。
if self._launch_guid:
cookie = self._cookie_with_guid(cookie, self._launch_guid)
guid = self._launch_guid
await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ, guid=guid)
# 9.1 抓包:getConfig 使用 wsLaunch 响应返回的新 guid;业务 UserId 仍为空 guid。
config_rsp = await self.call_get_config_activity(
uid, cookie, guid=self._launch_guid
)
if config_rsp is None:
self.logger("[WSS] 活动 getConfig 无响应,继续发送 confirm")
await self.send_confirm()
await asyncio.sleep(0.1)
return True
async def call_ws_launch_activity(
self, uid: int, guid: str = "", timeout: float = 10.0
):
ua = WSS_COOKIE_UA
os = TafOutputStream()
os.write_struct_begin(0)
os.write_int64(0, int(uid or 0))
os.write_string(1, guid or "")
os.write_string(2, ua)
os.write_string(3, "HUYA&ZH&2052")
os.write_struct_begin(4)
for i in range(5):
os.write_string(i, "")
os.write_struct_end()
os.write_struct_end()
wup = WupRequest()
wup.setServant("launch")
wup.setFunc("wsLaunch")
wup.setRequestId(-1)
wup.iTimeout = 0
wup.newdata["tReq"] = os.get_bytes()
body = self._encode_activity_launch_body(wup.encode())
msg = WssMessage(WssCommand.RPC_REQUEST, SEQ_ACTIVITY_WSLAUNCH, body)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(
format_wss_log(body, WssCommand.RPC_REQUEST, SEQ_ACTIVITY_WSLAUNCH, "发")
)
await self.ws.send(msg.encode())
try:
response = await asyncio.wait_for(future, timeout)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
return None
self.logger(
format_wss_log(
response, WssCommand.RPC_RESPONSE, SEQ_ACTIVITY_WSLAUNCH, "收"
)
)
self._parse_launch_response(response)
return response
async def call_get_config_activity(
self, uid: int, cookie: str, timeout: float = 10.0, guid: str = ""
):
user = self._build_activity_user(uid, cookie, guid=guid)
req = _ActivityConfigReq(user)
return await self.call_rpc("mobileui", "getConfig", req, None, timeout=timeout)
@staticmethod
def _build_activity_user(uid: int, cookie: str, guid: str = ""):
from .activity_structs import ActivityUserId
user = ActivityUserId()
user.lUid = int(uid or 0)
user.sGuid = guid or ""
user.sHuYaUA = WSS_COOKIE_UA
user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie)
return user
async def call_ws_launch_shop(
self, uid: int = 0, guid: str = "", timeout: float = 10.0
):
"""商城 wsLaunch。
2026-10-13 积分中心抓包:带真实 lUid 与 sGuid(不再是匿名),
UA 为页面 UA(h5_/points.html),与活动通道的字段布局一致。
"""
ua = POINTS_PAGE_UA
app_src = "HUYA&ZH&2052"
os = TafOutputStream()
os.write_struct_begin(0)
os.write_int64(0, int(uid or 0)) # lUid = 真实 uid
os.write_string(1, guid or "") # sGuid = 真实 guid
os.write_string(2, ua) # sUA
os.write_string(3, app_src) # sAppSrc
os.write_struct_begin(4) # tDeviceInfo
for i in range(5):
os.write_string(i, "")
os.write_struct_end()
os.write_struct_end()
wup = WupRequest()
wup.setServant("launch")
wup.setFunc("wsLaunch")
wup.setRequestId(-1)
wup.iTimeout = 0
wup.newdata["tReq"] = os.get_bytes()
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
seq = SEQ_ACTIVITY_WSLAUNCH
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=seq, body=body)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, timeout)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self.logger("[✗] wsLaunch 超时")
return None
self._parse_launch_response(body)
return body
def _parse_launch_response(self, body: bytes):
"""解析 wsLaunch 响应 (WSLaunchRsp: tag0=sGuid, tag1=sClientIp)"""
try:
# 从 body 中提取干净的 WUP 包(去掉尾部垃圾字节)
wup = self._extract_wup(body)
wup_resp = WupResponse()
wup_resp.decode(wup)
treq = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
if not treq:
self.logger(
f"[RPC] wsLaunch 响应无 tRsp, keys={list(wup_resp.newdata.keys())}"
)
return
ins = TafInputStream(treq)
_tag, dtype = ins.peek_head()
if dtype != 0x0A: # STRUCT_BEGIN
self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}")
return
ins.read_head()
while True:
try:
ftag, ftype = ins.peek_head()
except EOFError:
break
if ftype == 0x0B: # STRUCT_END
ins.read_head()
break
ins.read_head()
if ftag == 0 and ftype in (0x06, 0x07):
ln = (
ins.buf.read(1)[0]
if ftype == 0x06
else int.from_bytes(ins.buf.read(4), "big")
)
self._launch_guid = ins.buf.read(ln).decode("utf-8", "replace")
elif ftag == 1 and ftype in (0x06, 0x07):
ln = (
ins.buf.read(1)[0]
if ftype == 0x06
else int.from_bytes(ins.buf.read(4), "big")
)
self._launch_ip = ins.buf.read(ln).decode("utf-8", "replace")
else:
ins.skip_field(ftype)
self.logger(
f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}"
)
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self.logger(f"[RPC] wsLaunch 响应解析失败: {e}")
@staticmethod
def _extract_wup(body: bytes) -> bytes:
"""从 WSS 响应 body 中提取干净的 WUP 包
响应 body 格式:
小包: [4B BE total_len][wup_body][tail]
大包: [1B prefix][4B BE wup_body_len][wup_body][tail]
"""
if len(body) < 5:
return body
# 大包格式需优先判断;prefix 可能是 0x00,容易被误判成小包长度 8。
wup_len = int.from_bytes(body[1:5], "big")
if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03":
return body[5 : 5 + wup_len]
# 尝试 4B total_len 小包格式
total_len = int.from_bytes(body[0:4], "big")
if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03":
# total_len 包含自身 4B
return body[:total_len]
if 5 + wup_len <= len(body):
return body[5 : 5 + wup_len]
return body
async def send_auth(
self,
uid: int,
cookie: str,
sequence: int = SEQ_WSLAUNCH,
guid: str = "",
ua: str = "",
):
"""cmd 0x0a AUTH — 对齐 9.1 抓包(828 活动帧)。
9.1 活动 AUTH 结构(逐字节核验):
tag3 ZERO ← 首字段(活动通道)
tag0 INT64 = uid
tag1 STRING1 = UA
tag2 STRING4 = cookie(含 huya_ua= 前缀)
tag3 STRING1 = guid(32hex,9.1 中为页面 Cookie 的 guid)
tag4 INT8 = 1
tag5 STRING1 = "HUYA&ZH&2052"
tag6 STRING1 = ""
+= TAIL_BYTES(tag2 ZERO/tag3 ""/tag4 ZERO/tag5 ZERO/tag6 "")
``ua`` 可覆盖:积分中心页面为 ``h5_/points.html``(2026-10-13 抓包)。
"""
os = TafOutputStream()
if self._activity_mode or self._points_mode:
os.write_head(3, TafType.ZERO)
os.write_int64(0, int(uid or 0))
os.write_string(1, ua or WSS_COOKIE_UA)
os.write_string(2, HuyaWssClient._normalize_biz_cookie(cookie))
guid_match = re.search(r"(?:^|;\s*)guid=([^;]+)", cookie or "")
auth_guid = (
(guid or "").strip()
or (guid_match.group(1).strip() if guid_match else "")
or self._launch_guid
)
os.write_string(3, auth_guid)
os.write_int8(4, 1)
os.write_string(5, "HUYA&ZH&2052")
os.write_string(6, "")
auth_body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(
command=WssCommand.AUTH,
sequence=sequence,
body=auth_body,
)
await self.ws.send(msg.encode())
self.logger(
format_wss_log(
auth_body,
WssCommand.AUTH,
sequence,
"发",
)
)
async def call_get_config_shop(
self, uid: int = 0, guid: str = "", cookie: str = "", timeout: float = 10.0
):
"""商城/积分中心 mobileui.getConfig。
tReq 结构 (2026-10-13 抓包实证):
tag0: UserId {lUid=真实uid, sGuid=真实guid, sHuYaUA=h5_/points.html,
sCookie=完整 Cookie}
tag1: 空Map
tag2: "huya" (sAppId)
tag3: 空List
响应 tag1 是版本 map(实测 ``{"58664":"107263"}``),必须缓存下来,
因为 UpdateUserExps(0x17) 要把它原样回传。
"""
user = self._build_config_user(uid, guid, cookie)
req = _ActivityConfigReq(user)
body = await self.call_rpc("mobileui", "getConfig", req, None, timeout=timeout)
if body is None:
self.logger("[✗] getConfig 超时")
return None
payload = self._decode_rsp_dict(body)
versions = payload.get("tag1")
if isinstance(versions, dict):
self._config_versions = {
str(key): str(value) for key, value in versions.items()
}
self.logger(f"[初始化] getConfig 下发版本 {self._config_versions or '{}'}")
return body
async def send_register(self, guid_from_launch: str):
"""cmd 0x21 REGISTER — 回传 wsLaunch 的 sGuid
body = tag0("HUYA&ZH&2052") + tag1(sGuid) + tag2=0 + tag3=0
+ tag6 struct{tag0空Map, tag1空Map, tag3=0}
+ tag7空Map + tag8=0 + TAIL
"""
os = TafOutputStream()
os.write_string(0, "HUYA&ZH&2052")
os.write_string(1, guid_from_launch)
os.write_int32(2, 0)
os.write_int32(3, 0)
os.write_struct_begin(6)
os.write_map(0, {})
os.write_map(1, {})
os.write_int32(3, 0)
os.write_struct_end()
os.write_map(7, {})
os.write_int32(8, 0)
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(command=WssCommand.REGISTER, sequence=SEQ_REGISTER, body=body)
self.logger(format_wss_log(body, WssCommand.REGISTER, SEQ_REGISTER, "发"))
await self.ws.send(msg.encode())
async def send_confirm(self, version_map: dict[str, str] | None = None):
"""cmd 0x17 UpdateUserExps — body = 版本 map(tag0) + TAIL
2026-10-13 抓包实证:body 是 getConfig 下发的版本 map
(``{"58664":"107263"}``),不是空 map;不回传会导致业务 RPC 无响应。
"""
os = TafOutputStream()
os.write_map(0, version_map or {})
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(
command=WssCommand.CONFIRM_SEND, sequence=SEQ_CONFIRM, body=body
)
self.logger(format_wss_log(body, WssCommand.CONFIRM_SEND, SEQ_CONFIRM, "发"))
await self.ws.send(msg.encode())
@staticmethod
def _decode_rsp_dict(body: bytes) -> dict:
"""把 RPC 响应的 tRsp 解成通用 dict(未建模的接口用它取字段)。"""
try:
wup_resp = WupResponse()
wup_resp.decode(HuyaWssClient._extract_wup(body))
except Exception: # noqa: BLE001 响应解析失败按空结构处理
return {}
data = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
return decode_struct_bytes(data) if isinstance(data, bytes) else {}
@classmethod
def _raw_rsp_from_body(cls, body: bytes):
"""响应 → ShopRawRsp(code/message + 通用解码结构)。"""
from .shop_structs import ShopRawRsp
return ShopRawRsp.from_payload(cls._decode_rsp_dict(body))
async def call_rpc_raw(
self,
service: str,
method: str,
req_struct: TafStruct,
timeout: float = 15.0,
):
"""未逐字段建模的 RPC:返回 ShopRawRsp,取不到响应时返回 None。"""
body = await self.call_rpc(service, method, req_struct, None, timeout=timeout)
if body is None:
return None
return self._raw_rsp_from_body(body)
async def call_rpc(
self,
service: str,
method: str,
req_struct: TafStruct,
rsp_class=None,
timeout: float = 10.0,
):
seq = self._next_biz_seq(method in POINTS_ORDER_METHODS)
wup = WupRequest()
wup.setServant(service)
wup.setFunc(method)
wup.setRequestId(self._next_wup_req_id())
wup.iTimeout = 0
wup.writeStruct("tReq", req_struct)
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=seq, body=body)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, timeout)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self.logger(f"[✗] {service}.{method} 超时")
return None
wup_resp = WupResponse()
wup_resp.decode(self._extract_wup(body))
# 响应日志已由 _recv_loop 打出,此处只打解码后的业务字段
for key in ("tRsp", "tResp"):
data = wup_resp.newdata.get(key)
if data and isinstance(data, bytes) and len(data) > 0:
try:
ins = TafInputStream(data)
_tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN:
ins.read_head()
decoded = _decode_taf_struct(ins)
self.logger(
f"[←] {service}.{method} {key}: {_truncate(decoded)}"
)
except Exception as exc: # noqa: BLE001
self.logger(
f"[debug] WUP 响应字段解码失败: {service}.{method}.{key}: {exc}"
)
if rsp_class is None:
return body
result = wup_resp.readStruct("tRsp", rsp_class)
if result is None:
result = wup_resp.readStruct("tResp", rsp_class)
return result
async def get_goods_info(
self,
uid: int,
guid: str,
cookie: str,
pid: int,
spu_id: str,
sku_id: int = 0,
game_id: str = "",
source_id: str = "yellowcarlist",
app_scene: int = SHOP_APP_SCENE,
):
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
req = GetGoodsInfoReqV5()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.gameId = game_id
return await self.call_rpc(
"shopMiddleUI", "getGoodsInfoV5", req, GoodsInfoRsp, timeout=15.0
)
async def get_act_info(self, act_id: int):
from .activity_structs import GetActInfoReq, GetActInfoResp
req = GetActInfoReq()
req.actId = int(act_id or 0)
return await self.call_rpc("webActUI", "getActInfo", req, GetActInfoResp)
async def get_act_task_detail(self, uid: int, cookie: str, act_id: int):
from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp
req = GetActTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
return await self.call_rpc(
"webActUI", "getActTaskDetail", req, GetActTaskDetailResp
)
async def get_act_user_task_detail(self, uid: int, cookie: str, act_id: int):
from .activity_structs import GetActUserTaskDetailReq, GetActUserTaskDetailResp
req = GetActUserTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
return await self.call_rpc(
"webActUI", "getActUserTaskDetail", req, GetActUserTaskDetailResp
)
async def get_user_score(self, uid: int, cookie: str, sid: int):
from .activity_structs import GetUserScoreReq, GetUserScoreResp
req = GetUserScoreReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
return await self.call_rpc("webActUI", "getUserScore", req, GetUserScoreResp)
async def get_act_prize_list(self, uid: int, cookie: str, sid: int):
from .activity_structs import GetActPrizeListReq, GetActPrizeListResp
req = GetActPrizeListReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
return await self.call_rpc(
"webActUI", "getActPrizeList", req, GetActPrizeListResp
)
async def get_act_prize_detail(self, uid: int, cookie: str, sid: int, pid: int):
from .activity_structs import GetActPrizeDetailReq, GetActPrizeDetailResp
req = GetActPrizeDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
return await self.call_rpc(
"webActUI", "getActPrizeDetail", req, GetActPrizeDetailResp
)
async def score_exchange_prize(self, uid: int, cookie: str, sid: int, pid: int):
from .activity_structs import ScoreExchangePrizeReq, ScoreExchangePrizeResp
req = ScoreExchangePrizeReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
return await self.call_rpc(
"webActUI", "scoreExchangePrize", req, ScoreExchangePrizeResp
)
async def get_user_prize_records(self, uid: int, cookie: str, sid: int):
from .activity_structs import GetUserPrizeRecordsReq, GetUserPrizeRecordsResp
req = GetUserPrizeRecordsReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
return await self.call_rpc(
"webActUI", "getUserPrizeRecords", req, GetUserPrizeRecordsResp
)
async def check_user_bind_game_account(
self,
uid: int,
cookie: str,
b_act_id: int,
is_use_outer_act_id: int = 1,
gid: int = 0,
outer_act_id: str = "",
scene: str = "",
):
from .activity_structs import (
CheckUserBindGameAccountReq,
CheckUserBindGameAccountResp,
)
req = CheckUserBindGameAccountReq()
req.userId = self._build_activity_user(uid, cookie)
req.gid = int(gid or 0)
req.outerActId = str(outer_act_id or "")
req.scene = scene or ""
req.bActId = int(b_act_id or 0)
req.isUseOuterActId = int(is_use_outer_act_id or 0)
return await self.call_rpc(
"webActUI", "checkUserBindGameAccount", req, CheckUserBindGameAccountResp
)
async def confirm_bind_act_account(
self, uid: int, cookie: str, b_act_id: int, gid: int = 0, outer_act_id: str = ""
):
from .activity_structs import (
ConfirmBindActAccountReq,
ConfirmBindActAccountResp,
)
req = ConfirmBindActAccountReq()
req.userId = self._build_activity_user(uid, cookie)
req.gid = int(gid or 0)
req.outerActId = str(outer_act_id or "")
req.bActId = int(b_act_id or 0)
return await self.call_rpc(
"webActUI", "confirmBindActAccount", req, ConfirmBindActAccountResp
)
async def get_live_link_param(
self,
uid: int,
cookie: str,
b_act_id: int,
gid: int = 0,
outer_act_id: int = 0,
game_auth_scene: str = "",
):
from .activity_structs import GetLiveLinkParamReq, GetLiveLinkParamResp
req = GetLiveLinkParamReq()
req.userId = self._build_activity_user(uid, cookie)
req.gid = int(gid or 0)
req.outerActId = int(outer_act_id or 0)
req.gameAuthScene = game_auth_scene or ""
req.bActId = int(b_act_id or 0)
return await self.call_rpc(
"webActUI", "getLiveLinkParam", req, GetLiveLinkParamResp
)
async def get_user_profile_batch(
self, uid: int, cookie: str, target_uids: list[int]
):
from .activity_structs import GetUserProfileBatchReq, GetUserProfileBatchResp
req = GetUserProfileBatchReq()
req.userId = self._build_activity_user(0, "")
req.uidList = [int(item) for item in target_uids if int(item or 0)]
if not req.uidList:
return None
return await self.call_rpc(
"huyauserui", "getUserProfileBatch", req, GetUserProfileBatchResp
)
async def list_pay_channels(
self,
uid: int,
guid: str,
cookie: str,
spu_id: str,
sku_id: int = 0,
supplier_id: int = SUPPLIER_ID_YELLOW_CAR,
):
from .shop_structs import ListPayChannelReq, ListPayChannelRsp
req = ListPayChannelReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.supplierId = int(supplier_id or 0)
req.bizType = ORDER_BIZ_TYPE
req.gameCategoryId = GAME_CATEGORY_ID_HUYA
req.spuId = spu_id
req.skuId = int(sku_id or 0)
return await self.call_rpc(
"shopMiddleUI", "listPayChannelV5", req, ListPayChannelRsp, timeout=15.0
)
async def check_hy_protocol(self, uid: int, guid: str, cookie: str):
from .shop_structs import CheckHyProtocolReq, ShopCodeRsp
req = CheckHyProtocolReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
return await self.call_rpc(
"shopMiddleUI", "checkHyProtocolV5", req, ShopCodeRsp, timeout=15.0
)
async def check_user_buy_auth(
self,
uid: int,
guid: str,
cookie: str,
spu_id: str,
sku_id: int,
):
from .shop_structs import CheckUserBuyAuthReq, ShopCodeRsp
req = CheckUserBuyAuthReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.spuId = spu_id
req.skuId = int(sku_id or 0)
return await self.call_rpc(
"shopMiddleUI", "checkUserBuyAuth", req, ShopCodeRsp, timeout=15.0
)
async def query_user_order_list(
self,
uid: int,
guid: str,
cookie: str,
offset: int = 0,
page_size: int = 10,
order_type: int = 1,
status: int = 0,
):
from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp
req = QueryUserOrderListReq()
req.userId = self._build_biz_user(uid, cookie)
req.offset = offset
req.orderType = order_type
req.pageSize = page_size
req.status = status
return await self.call_rpc(
"revenueWebUI",
"queryUserOrderList",
req,
QueryUserOrderListRsp,
timeout=15.0,
)
async def order_detail(
self,
uid: int,
guid: str,
cookie: str,
order_id: int,
source_id: str = "yellowcarlist",
app_scene: int = SHOP_APP_SCENE,
):
"""按订单号查询商城订单详情,匹配 9.1 支付后轮询。"""
from .shop_structs import OrderDetailReq, OrderDetailRsp
req = OrderDetailReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.orderId = int(order_id or 0)
return await self.call_rpc(
"shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=15.0
)
async def create_order(
self,
uid: int,
guid: str,
cookie: str,
pid: int,
spu_id: str,
sku_id: int,
item_count: int = 1,
source_id: str = "yellowcarlist",
game_id: str = "",
app_scene: int = SHOP_APP_SCENE,
order_scene: int = ORDER_SCENE,
order_type: int = 6,
biz_params: str = JIFENCENTER_BIZ_PARAMS,
):
from .shop_structs import (
CreateOrderAccountParam,
CreateOrderExtraParam,
CreateOrderPromotionParam,
CreateOrderReqV5,
CreateOrderRsp,
)
req = CreateOrderReqV5()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.receiveId = 0
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.itemCount = item_count
req.gameId = game_id or "0"
# 订单侧的 scene 与 shopAppInfo.scene 是两个值(抓包:src/orderScene=4、appInfo=7)
req.src = order_scene
req.scene = 0
req.sourceId = source_id
req.orderType = order_type
extra = CreateOrderExtraParam()
# 标记积分中心来源;缺了会走成普通商城单(抓包实证的固定值)。
extra.bizParams = biz_params
req.extraParam = extra
req.env = self._build_order_env()
req.orderScene = order_scene
req.promotionParam = CreateOrderPromotionParam()
req.accountParam = CreateOrderAccountParam()
req.bizType = ORDER_BIZ_TYPE
req.gameCategoryId = GAME_CATEGORY_ID_HUYA
req.ext = ""
self.logger(f"[下单] orderType={order_type} (HUYA_VIRTUAL)")
return await self.call_rpc(
"shopMiddleUI", "createOrderV5", req, CreateOrderRsp, timeout=15.0
)
async def pay_order_submit(
self,
uid: int,
guid: str,
cookie: str,
order_id: int,
pay_type: int = 1,
pid: int = 0,
source_id: str = "yellowcarlist",
app_scene: int = SHOP_APP_SCENE,
item_count: int = 1,
callback_url: str = "",
):
from .shop_structs import PayOrderRes
os = TafOutputStream()
os.write_struct_begin(0)
os.write_struct(0, self._build_biz_user(uid, cookie))
os.write_struct(1, self._build_shop_app(source_id, app_scene))
os.write_int64(2, order_id)
os.write_string(3, "Zfb" if pay_type == 1 else str(pay_type))
os.write_string(4, "QrCode")
# 积分中心抓包的回调指向 m-shop.huya.com/points.html,不是 yaoguo 域名。
call_back = callback_url or (
f"{POINTS_PAY_CALLBACK}?orderId={order_id}&pid={pid}&sourceId={source_id}"
)
os.write_string(5, call_back)
os.write_map(6, self._build_order_env())
os.write_string(7, "null")
os.write_string(8, "")
os.write_struct_end()
seq = self._next_biz_seq(True)
wup = WupRequest()
wup.setServant("shopMiddleUI")
wup.setFunc("payOrderSubmitV5")
wup.setRequestId(self._next_wup_req_id())
wup.iTimeout = 0
wup.newdata["tReq"] = os.get_bytes()
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
msg = WssMessage(command=WssCommand.RPC_REQUEST, sequence=seq, body=body)
future = asyncio.Future()
self.rpc_queue.append(future)
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, 15.0)
except TimeoutError:
if future in self.rpc_queue:
self.rpc_queue.remove(future)
self.logger("[✗] payOrderSubmitV5 超时")
return None
wup_resp = WupResponse()
wup_resp.decode(self._extract_wup(body))
for key in ("tRsp", "tResp"):
data = wup_resp.newdata.get(key)
if data and isinstance(data, bytes) and len(data) > 0:
try:
ins = TafInputStream(data)
_tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN:
ins.read_head()
decoded = _decode_taf_struct(ins)
self.logger(f"[←] payOrderSubmitV5 {key}: {_truncate(decoded)}")
except Exception as exc: # noqa: BLE001
self.logger(f"[debug] WUP 支付响应字段解码失败: {key}: {exc}")
result = wup_resp.readStruct("tRsp", PayOrderRes)
if result is None:
result = wup_resp.readStruct("tResp", PayOrderRes)
return result
# ============================================================
# 积分中心链路新增 RPC(2026-10-13「开通精英宝典 / 购买幸运币」抓包)
# ============================================================
async def get_supplier_info(
self,
uid: int,
guid: str,
cookie: str,
supplier_id: int = SUPPLIER_ID_YELLOW_CAR,
):
"""shopMiddleUI.getSupplierInfoV5 — 供应商(小店)信息。"""
from .shop_structs import GetSupplierInfoReqV5
req = GetSupplierInfoReqV5()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.supplierId = int(supplier_id or 0)
return await self.call_rpc_raw("shopMiddleUI", "getSupplierInfoV5", req)
async def accept_hy_protocol(self, uid: int, guid: str, cookie: str):
"""shopMiddleUI.acceptHyProtocolV5 — 同意虎牙商城协议(下单前的必做步骤)。"""
from .shop_structs import AcceptHyProtocolReq, ShopCodeRsp
req = AcceptHyProtocolReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
return await self.call_rpc(
"shopMiddleUI", "acceptHyProtocolV5", req, ShopCodeRsp, timeout=15.0
)
async def calc_order_promotion(
self,
uid: int,
guid: str,
cookie: str,
pid: int,
spu_id: str,
sku_id: int = 0,
item_count: int = 1,
game_id: int = PEACE_ELITE_GAME_ID,
):
"""shopMiddleUI.calcOrderPromotion — 下单前试算价格/返利。"""
from .shop_structs import CalcOrderPromotionReqV5, shop_spu_number
spu_num = shop_spu_number(spu_id)
req = CalcOrderPromotionReqV5()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.spuNum = spu_num
req.skuId = int(sku_id or 0)
req.itemCount = int(item_count or 1)
req.pid = int(pid or 0)
req.spuNum2 = spu_num
req.gameId = int(game_id or 0)
return await self.call_rpc_raw("shopMiddleUI", "calcOrderPromotion", req)
async def play_mall_query_allocation(
self,
uid: int,
guid: str,
cookie: str,
source_id: str = "yellowcarlist",
page_code: str = POINTS_PAGE_CODE,
):
"""PlayMallUI.queryAllocation — 积分中心页面配额查询。"""
from .shop_structs import PlayMallQueryAllocationReq
req = PlayMallQueryAllocationReq()
req.userId = self._build_biz_user(uid, cookie)
req.appInfo = self._build_play_mall_app(source_id)
req.pageCode = page_code
return await self.call_rpc_raw("PlayMallUI", "queryAllocation", req)
async def play_mall_get_my_promotion(
self,
uid: int,
guid: str,
cookie: str,
spu_id: str,
sku_id: int = 0,
price: int = 0,
supplier_id: int = SUPPLIER_ID_YELLOW_CAR,
game_id: int = PEACE_ELITE_GAME_ID,
source_id: str = "yellowcarlist",
):
"""PlayMallUI.getMyPromotion — 查询用户可用返利/促销。"""
from .shop_structs import (
PlayMallGetMyPromotionReq,
PromotionGoodsKey,
shop_spu_number,
)
spu_num = shop_spu_number(spu_id)
req = PlayMallGetMyPromotionReq()
req.userId = self._build_biz_user(uid, cookie)
req.appInfo = self._build_play_mall_app(source_id)
goods = PromotionGoodsKey()
goods.supplierId = int(supplier_id or 0)
goods.spuNum = spu_num
goods.skuId = int(sku_id or 0)
goods.price = int(price or 0)
goods.gameId = int(game_id or 0)
goods.spuNum2 = spu_num
req.goods = goods
return await self.call_rpc_raw("PlayMallUI", "getMyPromotion", req)
async def get_user_profile(
self, uid: int, guid: str, cookie: str, target_uid: int = 0
):
"""huyauserui.getUserProfile — 单个用户资料(昵称/头像)。
``target_uid`` 省略时查询自己;响应里资料在 ``tag0.tag0``。
"""
from .shop_structs import GetUserProfileReq
req = GetUserProfileReq()
req.userId = self._build_biz_user(uid, cookie)
req.targetUid = int(target_uid or uid or 0)
return await self.call_rpc_raw("huyauserui", "getUserProfile", req)
async def get_follow_wechat_status(self, uid: int, guid: str, cookie: str):
"""wupui.getUserFollowWeChatOfficialStatus — 是否已关注微信公众号。"""
from .shop_structs import FollowWeChatStatusReq
req = FollowWeChatStatusReq()
req.userId = self._build_biz_user(uid, cookie)
return await self.call_rpc_raw(
"wupui", "getUserFollowWeChatOfficialStatus", req
)
async def get_redeem_need_goods(
self,
uid: int,
guid: str,
cookie: str,
goods_num: int = POINTS_REDEEM_GOODS_NUM,
):
"""shopMiddleUI.getRedeemNeedGoods — 兑换所需商品与当前积分。
响应:``tag2`` 当前积分、``tag3`` 兑换门槛积分(抓包 + 真机实证)。
"""
from .shop_structs import GetRedeemNeedGoodsReq
req = GetRedeemNeedGoodsReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", SHOP_APP_SCENE)
req.goodsNum = int(goods_num or 0)
return await self.call_rpc_raw("shopMiddleUI", "getRedeemNeedGoods", req)
async def get_grow_level_user_info(self, uid: int, guid: str, cookie: str):
"""GameBizUI.getGrowLevelUserInfo — 成长值/成长等级/昵称。
响应:``tag1.tag0`` 成长值、``tag1.tag1`` 成长等级、``tag2`` 昵称、
``tag3`` 头像(抓包 + 真机实证)。
"""
from .shop_structs import GetGrowLevelUserInfoReq
req = GetGrowLevelUserInfoReq()
req.userId = self._build_biz_user(uid, cookie)
return await self.call_rpc_raw("GameBizUI", "getGrowLevelUserInfo", req)