旧活动接口 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
1485 lines
54 KiB
Python
1485 lines
54 KiB
Python
"""
|
||
虎牙 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)
|