type: 收窄虎牙 HTTP 与 WSS 客户端类型

This commit is contained in:
yml2213
2026-08-30 20:04:08 +08:00
parent ec3c14d0a3
commit 6439dc1942
2 changed files with 445 additions and 201 deletions
+210 -112
View File
@@ -14,13 +14,14 @@
- 服务端响应 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
from typing import Any, Optional, Callable, cast
import websockets
@@ -41,19 +42,19 @@ 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)
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
AUTH = 0x0A
SERVER_PUSH1 = 0x0B
HEARTBEAT_SEND = 0x10
HEARTBEAT_RECV = 0x11
CONFIRM_SEND = 0x17
@@ -68,15 +69,15 @@ class WssMessage:
self.body = body
def encode(self) -> bytes:
header = struct.pack('>BBI', 0x00, self.command, self.sequence)
header = struct.pack(">BBI", 0x00, self.command, self.sequence)
return header + self.body
@classmethod
def decode(cls, data: bytes) -> 'WssMessage':
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]
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)
@@ -84,9 +85,11 @@ class WssMessage:
class HuyaWssClient:
"""虎牙 WSS 客户端 — 商城通道"""
def __init__(self, baseinfo: str = None, logger: Callable[[str], None] = None):
def __init__(
self, baseinfo: str | None = None, logger: Callable[[str], None] | None = None
):
self.baseinfo = baseinfo or SHOP_BASEINFO
self.ws = None
self.ws: Any = None
self._biz_seq = SEQ_BUSINESS # 业务 RPC 递增用
self._wup_req_id = 8
self.rpc_queue = deque()
@@ -135,7 +138,12 @@ class HuyaWssClient:
# 大包头里的长度使用原始 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()
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:
@@ -143,7 +151,12 @@ class HuyaWssClient:
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)
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}"
@@ -152,6 +165,7 @@ class HuyaWssClient:
def _build_biz_user(uid: int, cookie: str):
"""构造 WSS 业务 UserIdHAR 实证:guid 为空,cookie 不额外加前缀)"""
from .shop_structs import UserId
user = UserId()
user.lUid = uid
user.sGuid = ""
@@ -167,6 +181,7 @@ class HuyaWssClient:
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 = ""
@@ -183,28 +198,33 @@ class HuyaWssClient:
"input_1": str(item_count),
}
async def connect(self, host: str = SHOP_WS_HOST, timeout: float = 15.0,
cookie: str = ""):
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")
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",
origin=cast(Any, "https://m-shop.yaoguo.com"),
user_agent_header=BROWSER_UA,
additional_headers=headers,
open_timeout=timeout,
),
timeout=timeout)
timeout=timeout,
)
except asyncio.TimeoutError:
self.logger(f"[WSS] 连接超时({timeout}s")
raise
@@ -230,11 +250,16 @@ class HuyaWssClient:
try:
async for raw in self.ws:
try:
msg = WssMessage.decode(raw)
self.logger(format_wss_log(msg.body, msg.command, msg.sequence, ""))
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:
self.logger(f"[WSS] 解析消息失败: {e} raw_hex={raw[:50].hex()}")
self.logger(
f"[WSS] 解析消息失败: {e} raw_hex={raw_bytes[:50].hex()}"
)
except asyncio.CancelledError:
pass
except websockets.exceptions.ConnectionClosed as e:
@@ -270,14 +295,17 @@ class HuyaWssClient:
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 "")
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, ""))
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):
@@ -295,10 +323,14 @@ class HuyaWssClient:
if launch_rsp is None:
self.logger("[✗] wsLaunch 无响应,初始化失败")
return False
self.logger(f"[初始化] wsLaunch OK guid={self._launch_guid} ip={self._launch_ip}")
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 '无响应(可继续)'}")
self.logger(
f"[初始化] getConfig {'OK' if config_rsp is not None else '无响应(可继续)'}"
)
if self._launch_guid:
await self.send_register(self._launch_guid)
@@ -316,11 +348,11 @@ class HuyaWssClient:
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
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()
@@ -336,8 +368,7 @@ class HuyaWssClient:
body = self._encode_rpc_body(wup_data)
seq = SEQ_WSLAUNCH
msg = WssMessage(command=WssCommand.RPC_REQUEST,
sequence=seq, body=body)
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, ""))
@@ -363,11 +394,13 @@ class HuyaWssClient:
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())}")
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
if dtype != 0x0A: # STRUCT_BEGIN
self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}")
return
ins.read_head()
@@ -376,19 +409,29 @@ class HuyaWssClient:
ftag, ftype = ins.peek_head()
except EOFError:
break
if ftype == 0x0b: # STRUCT_END
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')
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')
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}")
self.logger(
f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}"
)
except Exception as e:
self.logger(f"[RPC] wsLaunch 响应解析失败: {e}")
@@ -403,27 +446,36 @@ class HuyaWssClient:
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]
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 = 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[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)
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, ""))
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
@@ -441,18 +493,18 @@ class HuyaWssClient:
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_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_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()
@@ -466,8 +518,7 @@ class HuyaWssClient:
body = self._encode_rpc_body(wup_data)
seq = SEQ_GETCONFIG
msg = WssMessage(command=WssCommand.RPC_REQUEST,
sequence=seq, body=body)
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, ""))
@@ -503,8 +554,7 @@ class HuyaWssClient:
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)
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())
@@ -513,14 +563,20 @@ class HuyaWssClient:
os = TafOutputStream()
os.write_map(0, {})
body = os.get_bytes() + TAIL_BYTES
msg = WssMessage(command=WssCommand.CONFIRM_SEND,
sequence=SEQ_CONFIRM, body=body)
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):
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()
@@ -532,8 +588,7 @@ class HuyaWssClient:
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
msg = WssMessage(command=WssCommand.RPC_REQUEST,
sequence=seq, body=body)
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, ""))
@@ -559,7 +614,9 @@ class HuyaWssClient:
if dtype == TafType.STRUCT_BEGIN:
ins.read_head()
decoded = _decode_taf_struct(ins)
self.logger(f"[←] {service}.{method} {key}: {_truncate(decoded)}")
self.logger(
f"[←] {service}.{method} {key}: {_truncate(decoded)}"
)
except Exception:
pass
@@ -571,10 +628,18 @@ class HuyaWssClient:
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):
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()
@@ -585,12 +650,20 @@ class HuyaWssClient:
req.skuId = sku_id
req.gameId = game_id
return await self.call_rpc("shopMiddleUI", "getGoodsInfoV5",
req, GoodsInfoRsp, timeout=15.0)
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):
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()
@@ -600,17 +673,35 @@ class HuyaWssClient:
req.pageSize = page_size
req.status = status
return await self.call_rpc("revenueWebUI", "queryUserOrderList",
req, QueryUserOrderListRsp, timeout=15.0)
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)
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)
@@ -635,14 +726,22 @@ class HuyaWssClient:
req.ext = ""
self.logger(f"[下单] orderType={order_type} (HUYA_VIRTUAL)")
return await self.call_rpc("shopMiddleUI", "createOrderV5",
req, CreateOrderRsp, timeout=15.0)
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):
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()
@@ -674,8 +773,7 @@ class HuyaWssClient:
wup_data = wup.encode()
body = self._encode_rpc_body(wup_data)
msg = WssMessage(command=WssCommand.RPC_REQUEST,
sequence=seq, body=body)
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, ""))