710 lines
27 KiB
Python
710 lines
27 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
|
||
import time
|
||
from collections import deque
|
||
from typing import Optional, Callable
|
||
|
||
import websockets
|
||
|
||
from .wup_protocol import WupRequest, WupResponse
|
||
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
||
from .frame_decoder import format_wss_log, _decode_taf_struct, _truncate
|
||
|
||
|
||
SHOP_WS_HOST = "77bc035c-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
|
||
|
||
# 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)
|
||
|
||
|
||
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, logger: Callable[[str], None] = None):
|
||
self.baseinfo = baseinfo or SHOP_BASEINFO
|
||
self.ws = 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 = ""
|
||
|
||
def _next_biz_seq(self) -> int:
|
||
"""业务 RPC 的 frame seq。
|
||
|
||
浏览器在商城 WSS 上连续发送多个业务 RPC 时,frame seq 都保持
|
||
0x1d000106;真正区分请求依赖 WUP requestId。
|
||
"""
|
||
return 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 _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 _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 = WSS_SHOP_SCENE):
|
||
"""构造 WSS 业务 ShopAppInfo"""
|
||
from .shop_structs import ShopAppInfo
|
||
info = ShopAppInfo()
|
||
info.sAppId = "huya"
|
||
info.sBizType = ""
|
||
info.scene = scene
|
||
info.sourceId = source_id
|
||
return info
|
||
|
||
@staticmethod
|
||
def _build_order_env(item_count: int = 1):
|
||
"""构造下单/支付环境参数"""
|
||
click_time = int(time.time() * 1000)
|
||
return {
|
||
"user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]',
|
||
"input_1": str(item_count),
|
||
}
|
||
|
||
async def connect(self, host: str = SHOP_WS_HOST, timeout: float = 15.0,
|
||
cookie: str = ""):
|
||
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"}
|
||
|
||
try:
|
||
self.ws = await asyncio.wait_for(
|
||
websockets.connect(
|
||
url,
|
||
origin="https://m-shop.yaoguo.com",
|
||
user_agent_header=BROWSER_UA,
|
||
additional_headers=headers,
|
||
open_timeout=timeout,
|
||
),
|
||
timeout=timeout)
|
||
except asyncio.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:
|
||
msg = WssMessage.decode(raw)
|
||
self.logger(format_wss_log(msg.body, msg.command, msg.sequence, "收"))
|
||
await self._handle_message(msg)
|
||
except Exception as e:
|
||
self.logger(f"[WSS] 解析消息失败: {e} raw_hex={raw[: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:
|
||
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:
|
||
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):
|
||
"""商城端点初始化: heartbeat → wsLaunch → getConfig → confirm
|
||
|
||
商城 wsLaunch 无 lUid/sGuid (匿名), tReq:
|
||
tag0 lUid=0, tag1 sGuid="", tag2 sUA="webh5&0.0.1&websocket&&h5_/index.html",
|
||
tag3 sAppSrc="HUYA&ZH&2052", tag4 tDeviceInfo={5空串}
|
||
"""
|
||
self.logger("[初始化] 开始 WSS 初始化流程 (商城端点)...")
|
||
|
||
await self.send_heartbeat()
|
||
|
||
launch_rsp = await self.call_ws_launch_shop()
|
||
if launch_rsp is None:
|
||
self.logger("[✗] wsLaunch 无响应,初始化失败")
|
||
return False
|
||
self.logger(f"[初始化] wsLaunch OK guid={self._launch_guid} ip={self._launch_ip}")
|
||
|
||
config_rsp = await self.call_get_config_shop()
|
||
self.logger(f"[初始化] getConfig {'OK' if config_rsp is not None else '无响应(可继续)'}")
|
||
|
||
if self._launch_guid:
|
||
await self.send_register(self._launch_guid)
|
||
|
||
await self.send_confirm()
|
||
|
||
await asyncio.sleep(0.3)
|
||
self.logger("[初始化] 完成 ✓")
|
||
return True
|
||
|
||
async def call_ws_launch_shop(self, timeout: float = 10.0):
|
||
"""商城版 wsLaunch (匿名, 无 lUid/sGuid, 与 conn4 格式一致)"""
|
||
ua = "webh5&0.0.1&websocket&&h5_/index.html"
|
||
app_src = "HUYA&ZH&2052"
|
||
|
||
os = TafOutputStream()
|
||
os.write_struct_begin(0)
|
||
os.write_int64(0, 0) # lUid = 0
|
||
os.write_string(1, "") # sGuid = ""
|
||
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_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 asyncio.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:
|
||
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, cookie: str):
|
||
"""cmd 0x0a AUTH — 发送 cookie"""
|
||
ua = "webh5&0.0.1&websocket&&diypc_52775"
|
||
auth_text = f"huya_ua={ua}; {cookie}"
|
||
msg = WssMessage(command=WssCommand.AUTH,
|
||
sequence=SEQ_WSLAUNCH,
|
||
body=auth_text.encode('utf-8') + TAIL_BYTES)
|
||
await self.ws.send(msg.encode())
|
||
self.logger(format_wss_log(auth_text.encode('utf-8') + TAIL_BYTES, WssCommand.AUTH, SEQ_WSLAUNCH, "发"))
|
||
|
||
async def call_get_config_shop(self, timeout: float = 10.0):
|
||
"""商城版 mobileui.getConfig
|
||
|
||
tReq 结构 (HAR 实证):
|
||
tag0: UserId {lUid=0, sGuid="", sUA="", sHuYaUA="webh5&0.0.1&websocket&&h5_/index.html",
|
||
sToken="", iTokenType=0, sDeviceInfo="", sCookie=""}
|
||
tag1: 空Map
|
||
tag2: "huya" (sAppId)
|
||
tag3: 空List
|
||
"""
|
||
ua = "webh5&0.0.1&websocket&&h5_/index.html"
|
||
|
||
os = TafOutputStream()
|
||
os.write_struct_begin(0)
|
||
# tag0: UserId (匿名)
|
||
os.write_struct_begin(0)
|
||
os.write_int64(0, 0) # lUid = 0
|
||
os.write_string(1, "") # sGuid
|
||
os.write_string(2, "") # sUA
|
||
os.write_string(3, ua) # sHuYaUA
|
||
os.write_string(4, "") # sToken
|
||
os.write_int32(5, 0) # iTokenType
|
||
os.write_string(6, "") # sDeviceInfo
|
||
os.write_string(7, "") # sCookie
|
||
os.write_struct_end()
|
||
os.write_map(1, {}) # tag1: 空Map
|
||
os.write_string(2, "huya") # tag2: sAppId
|
||
os.write_head(3, TafType.LIST) # tag3: 空List
|
||
os.write_int32(0, 0)
|
||
os.write_struct_end()
|
||
|
||
wup = WupRequest()
|
||
wup.setServant("mobileui")
|
||
wup.setFunc("getConfig")
|
||
wup.setRequestId(7)
|
||
wup.iTimeout = 0
|
||
wup.newdata["tReq"] = os.get_bytes()
|
||
wup_data = wup.encode()
|
||
body = self._encode_rpc_body(wup_data)
|
||
|
||
seq = SEQ_GETCONFIG
|
||
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 asyncio.TimeoutError:
|
||
if future in self.rpc_queue:
|
||
self.rpc_queue.remove(future)
|
||
self.logger("[✗] getConfig 超时")
|
||
return None
|
||
|
||
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):
|
||
"""cmd 0x17 CONFIRM_SEND — body = 空Map(tag0) + TAIL"""
|
||
os = TafOutputStream()
|
||
os.write_map(0, {})
|
||
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())
|
||
|
||
async def call_rpc(self, service: str, method: str,
|
||
req_struct: TafStruct, rsp_class=None,
|
||
timeout: float = 10.0):
|
||
seq = self._next_biz_seq()
|
||
|
||
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 asyncio.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:
|
||
pass
|
||
|
||
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",
|
||
scene: int = WSS_SHOP_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, 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 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 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 = "", scene: int = WSS_SHOP_SCENE,
|
||
order_type: int = 6):
|
||
from .shop_structs import (CreateOrderReqV5, CreateOrderRsp,
|
||
CreateOrderExtraParam, CreateOrderPromotionParam,
|
||
CreateOrderAccountParam)
|
||
|
||
req = CreateOrderReqV5()
|
||
req.userId = self._build_biz_user(uid, cookie)
|
||
req.shopAppInfo = self._build_shop_app(source_id, 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"
|
||
req.src = scene
|
||
req.scene = 0
|
||
req.sourceId = source_id
|
||
req.orderType = order_type
|
||
req.extraParam = CreateOrderExtraParam()
|
||
req.env = self._build_order_env(item_count)
|
||
req.orderScene = scene
|
||
req.promotionParam = CreateOrderPromotionParam()
|
||
req.accountParam = CreateOrderAccountParam()
|
||
req.bizType = 5
|
||
req.gameCategoryId = 507
|
||
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",
|
||
scene: int = WSS_SHOP_SCENE,
|
||
item_count: int = 1):
|
||
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, 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")
|
||
callback_url = (
|
||
f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback"
|
||
f"?orderId={order_id}&pid={pid}&sourceId={source_id}"
|
||
)
|
||
os.write_string(5, callback_url)
|
||
os.write_map(6, self._build_order_env(item_count))
|
||
os.write_string(7, "null")
|
||
|
||
os.write_struct_end()
|
||
|
||
seq = self._next_biz_seq()
|
||
|
||
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 asyncio.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:
|
||
pass
|
||
result = wup_resp.readStruct("tRsp", PayOrderRes)
|
||
if result is None:
|
||
result = wup_resp.readStruct("tResp", PayOrderRes)
|
||
return result
|