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
+235 -89
View File
@@ -6,6 +6,7 @@
Body: Wup 包 (含4字节长度前缀)
Response: Wup 包 (tRsp)
"""
import base64
import hashlib
import json
@@ -13,7 +14,7 @@ import random
import struct
import urllib.parse
import urllib.request
from typing import Optional, Callable
from typing import Any, Optional, Callable
from .cookie_utils import cookie_pairs, normalize_cookie_pairs, normalize_huya_cookie
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
@@ -21,9 +22,11 @@ from .wup_protocol import WupRequest, WupResponse
CDNWS_HOST = "cdnws.api.huya.com"
LIVELINK_BIND_API_BASE = "https://livelinkbind.game.qq.com"
PC_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/149.0.0.0 Safari/537.36")
PC_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/149.0.0.0 Safari/537.36"
)
HTTP_HUYA_UA = "webh5&0.0.1&websocket&&diypc_52775"
@@ -42,6 +45,7 @@ class WSConnectParaInfo(TafStruct):
tag9 sTraceId string "hex8:hex8:0:0"
tag10 mCustomHeaders Map<string,string>
"""
def __init__(self):
self.lUid: int = 0
self.sGuid: str = ""
@@ -88,12 +92,13 @@ class WSConnectParaInfo(TafStruct):
def _gen_trace_id() -> str:
"""生成 sTraceId (格式 hex8:hex8:0:0HAR 实证)"""
h = '%016x' % random.getrandbits(64)
h = "%016x" % random.getrandbits(64)
return f"{h}:{h}:0:0"
def generate_http_baseinfo(uid: int, guid: str, cookie: str,
trace_id: str = None) -> str:
def generate_http_baseinfo(
uid: int, guid: str, cookie: str, trace_id: str | None = None
) -> str:
"""
构造 HTTP POST 的 baseinfo URL 参数
@@ -111,14 +116,14 @@ def generate_http_baseinfo(uid: int, guid: str, cookie: str,
info.write_to(os)
raw = os.get_bytes()
# base64 (与 JS window.btoa 一致)
b64 = base64.b64encode(raw).decode('ascii')
return urllib.parse.quote(b64, safe='')
b64 = base64.b64encode(raw).decode("ascii")
return urllib.parse.quote(b64, safe="")
class HuyaHttpClient:
"""虎牙 HTTP POST RPC 客户端"""
def __init__(self, logger: Callable[[str], None] = None):
def __init__(self, logger: Callable[[str], None] | None = None):
self.logger = logger or print
@staticmethod
@@ -155,7 +160,11 @@ class HuyaHttpClient:
"""HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。"""
if not cookie:
return ""
pairs = [(key, value) for key, value in cookie_pairs(normalize_huya_cookie(cookie)) if key != "huya_ua"]
pairs = [
(key, value)
for key, value in cookie_pairs(normalize_huya_cookie(cookie))
if key != "huya_ua"
]
if not any(key == "guid" for key, _ in pairs):
guid = cls._resolve_cookie_guid(cookie)
if guid:
@@ -168,7 +177,7 @@ class HuyaHttpClient:
uid: int,
guid: str,
cookie: str,
trace_id: str = None,
trace_id: str | None = None,
) -> str:
"""生成与浏览器 cdnws 调用一致的 baseinfo。"""
rpc_cookie = cls._normalize_cookie(cookie)
@@ -179,6 +188,7 @@ class HuyaHttpClient:
def _build_user(uid: int, guid: str, cookie: str):
"""构造 HTTP 业务 UserId。"""
from .shop_structs import UserId
user = UserId()
user.lUid = uid
user.sGuid = guid or HuyaHttpClient._resolve_cookie_guid(cookie)
@@ -194,6 +204,7 @@ class HuyaHttpClient:
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = 7):
"""构造 HTTP 业务 ShopAppInfo。"""
from .shop_structs import ShopAppInfo
info = ShopAppInfo()
info.sAppId = "huya"
info.sBizType = ""
@@ -205,6 +216,7 @@ class HuyaHttpClient:
def _build_order_env(item_count: int = 1):
"""构造下单/支付环境参数。"""
import time
click_time = int(time.time() * 1000)
return {
"user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]',
@@ -215,16 +227,24 @@ class HuyaHttpClient:
def _build_activity_user(uid: int, cookie: str):
"""构造活动组件 UserId,与当前积分兑换组件保持一致。"""
from .activity_structs import ActivityUserId
user = ActivityUserId()
user.lUid = uid
user.sHuYaUA = HTTP_HUYA_UA
user.sCookie = HuyaHttpClient._normalize_cookie(cookie)
return user
def call_rpc(self, service: str, method: str,
req_struct: TafStruct, rsp_class=None,
uid: int = 0, guid: str = "", cookie: str = "",
timeout: float = 15.0):
def call_rpc(
self,
service: str,
method: str,
req_struct: TafStruct,
rsp_class=None,
uid: int = 0,
guid: str = "",
cookie: str = "",
timeout: float = 15.0,
):
"""
HTTP POST RPC 调用
@@ -249,28 +269,38 @@ class HuyaHttpClient:
self.logger(f"[HTTP] POST {service}.{method}")
self.logger(f"[HTTP] URL: https://{CDNWS_HOST}/?baseinfo=<redacted>")
self.logger(f"[HTTP] 发送 body: {len(wup_data)} 字节, hex前60: {wup_data[:60].hex()}")
self.logger(
f"[HTTP] 发送 body: {len(wup_data)} 字节, hex前60: {wup_data[:60].hex()}"
)
# 3. POST
req = urllib.request.Request(url, data=wup_data, method='POST', headers={
'User-Agent': PC_UA,
'Origin': 'https://zt.huya.com',
'Referer': 'https://zt.huya.com/',
'Content-Type': 'application/octet-stream',
'Accept-Language': 'zh-CN,zh;q=0.9',
})
req = urllib.request.Request(
url,
data=wup_data,
method="POST",
headers={
"User-Agent": PC_UA,
"Origin": "https://zt.huya.com",
"Referer": "https://zt.huya.com/",
"Content-Type": "application/octet-stream",
"Accept-Language": "zh-CN,zh;q=0.9",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp_data = resp.read()
# 检测 gzip 压缩 (响应 hex 1f8b 开头)
if resp_data[:2] == b'\x1f\x8b':
if resp_data[:2] == b"\x1f\x8b":
import gzip
resp_data = gzip.decompress(resp_data)
except Exception as e:
self.logger(f"[HTTP] ❌ 请求失败: {type(e).__name__}: {e}")
return None
self.logger(f"[HTTP] 收到响应: {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}")
self.logger(
f"[HTTP] 收到响应: {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}"
)
# 4. 解析响应 Wup
wup_resp = WupResponse()
@@ -289,7 +319,12 @@ class HuyaHttpClient:
if result is None:
result = wup_resp.readStruct("tResp", rsp_class)
if result is not None and hasattr(result, "to_dict"):
log_data = result.to_log_dict() if hasattr(result, "to_log_dict") else result.to_dict()
decoded_result: Any = result
log_data = (
decoded_result.to_log_dict()
if hasattr(decoded_result, "to_log_dict")
else decoded_result.to_dict()
)
decoded = json.dumps(log_data, ensure_ascii=False, separators=(",", ":"))
self.logger(f"[HTTP] 解码 tRsp: {decoded}")
return result
@@ -299,6 +334,7 @@ class HuyaHttpClient:
def query_user_score(self, uid: int, cookie: str, sid: int, timeout: float = 15.0):
"""查询活动可用积分。"""
from .activity_structs import GetUserScoreReq, GetUserScoreResp
req = GetUserScoreReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = sid
@@ -312,9 +348,12 @@ class HuyaHttpClient:
timeout=timeout,
)
def get_act_prize_list(self, uid: int, cookie: str, sid: int, timeout: float = 15.0):
def get_act_prize_list(
self, uid: int, cookie: str, sid: int, timeout: float = 15.0
):
"""查询活动可兑换商品列表。"""
from .activity_structs import GetActPrizeListReq, GetActPrizeListResp
req = GetActPrizeListReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = sid
@@ -328,9 +367,12 @@ class HuyaHttpClient:
timeout=timeout,
)
def get_user_prize_records(self, uid: int, cookie: str, sid: int, timeout: float = 15.0):
def get_user_prize_records(
self, uid: int, cookie: str, sid: int, timeout: float = 15.0
):
"""查询用户兑换记录。"""
from .activity_structs import GetUserPrizeRecordsReq, GetUserPrizeRecordsResp
req = GetUserPrizeRecordsReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = sid
@@ -344,9 +386,12 @@ class HuyaHttpClient:
timeout=timeout,
)
def score_exchange_prize(self, uid: int, cookie: str, sid: int, pid: int, timeout: float = 15.0):
def score_exchange_prize(
self, uid: int, cookie: str, sid: int, pid: int, timeout: float = 15.0
):
"""兑换活动积分商品。"""
from .activity_structs import ScoreExchangePrizeReq, ScoreExchangePrizeResp
req = ScoreExchangePrizeReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
@@ -361,9 +406,12 @@ class HuyaHttpClient:
timeout=timeout,
)
def get_act_task_detail(self, uid: int, cookie: str, act_id: int, timeout: float = 15.0):
def get_act_task_detail(
self, uid: int, cookie: str, act_id: int, timeout: float = 15.0
):
"""查询活动任务详情,用于发现充值商品 SPU。"""
from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp
req = GetActTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
@@ -389,7 +437,11 @@ class HuyaHttpClient:
timeout: float = 15.0,
):
"""查询用户游戏账号绑定状态。"""
from .activity_structs import CheckUserBindGameAccountReq, CheckUserBindGameAccountResp
from .activity_structs import (
CheckUserBindGameAccountReq,
CheckUserBindGameAccountResp,
)
req = CheckUserBindGameAccountReq()
req.userId = self._build_activity_user(uid, cookie)
req.gid = int(gid or 0)
@@ -418,7 +470,11 @@ class HuyaHttpClient:
timeout: float = 15.0,
):
"""确认虎牙号与当前活动的游戏账号绑定关系。"""
from .activity_structs import ConfirmBindActAccountReq, ConfirmBindActAccountResp
from .activity_structs import (
ConfirmBindActAccountReq,
ConfirmBindActAccountResp,
)
req = ConfirmBindActAccountReq()
req.userId = self._build_activity_user(uid, cookie)
req.gid = int(gid or 0)
@@ -446,6 +502,7 @@ class HuyaHttpClient:
):
"""获取绑定二维码拉起参数。"""
from .activity_structs import GetLiveLinkParamReq, GetLiveLinkParamResp
req = GetLiveLinkParamReq()
req.userId = self._build_activity_user(uid, cookie)
req.gid = int(gid or 0)
@@ -463,9 +520,12 @@ class HuyaHttpClient:
timeout=timeout,
)
def get_user_profile_batch(self, uid: int, cookie: str, target_uids: list[int], timeout: float = 15.0):
def get_user_profile_batch(
self, uid: int, cookie: str, target_uids: list[int], timeout: float = 15.0
):
"""批量查询虎牙用户资料,用于补齐绑定页头像和昵称。"""
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)]
@@ -493,7 +553,9 @@ class HuyaHttpClient:
mime = "image/png" if text.startswith("iVBOR") else "image/jpeg"
return f"data:{mime};base64,{text}"
def get_livelink_mini_qrcode(self, bind_page_url: str, timeout: float = 15.0) -> dict | None:
def get_livelink_mini_qrcode(
self, bind_page_url: str, timeout: float = 15.0
) -> dict | None:
"""请求绑定页内部接口,获取真正可扫的小程序码图片。"""
import gzip
import time
@@ -522,24 +584,32 @@ class HuyaHttpClient:
self.logger(
f"[LIVELINK] POST getMiniAppQrcode plat={live_plat_id or '-'} actId={act_id or '-'}"
)
req = urllib.request.Request(endpoint, data=payload, method="POST", headers={
"User-Agent": PC_UA,
"Origin": "https://livelink.qq.com",
"Referer": "https://livelink.qq.com/",
"Content-Type": "application/json;charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Gap-Plat-Id": live_plat_id,
"Gap-Act-Id": act_id,
"Gap-User-From": "pc",
"Gap-From-Id": from_id,
"Vulcan-AccType": "auth_in_h5",
"Vulcan-PlatUser-Session": "",
})
req = urllib.request.Request(
endpoint,
data=payload,
method="POST",
headers={
"User-Agent": PC_UA,
"Origin": "https://livelink.qq.com",
"Referer": "https://livelink.qq.com/",
"Content-Type": "application/json;charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Gap-Plat-Id": live_plat_id,
"Gap-Act-Id": act_id,
"Gap-User-From": "pc",
"Gap-From-Id": from_id,
"Vulcan-AccType": "auth_in_h5",
"Vulcan-PlatUser-Session": "",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp_data = resp.read()
if resp_data[:2] == b"\x1f\x8b" or resp.headers.get("Content-Encoding") == "gzip":
if (
resp_data[:2] == b"\x1f\x8b"
or resp.headers.get("Content-Encoding") == "gzip"
):
resp_data = gzip.decompress(resp_data)
except Exception as e:
self.logger(f"[LIVELINK] ❌ 小程序码请求失败: {type(e).__name__}: {e}")
@@ -552,7 +622,9 @@ class HuyaHttpClient:
return None
if data.get("iRet") != 0:
self.logger(f"[LIVELINK] ❌ 小程序码接口失败: iRet={data.get('iRet')} msg={data.get('sMsg') or '-'}")
self.logger(
f"[LIVELINK] ❌ 小程序码接口失败: iRet={data.get('iRet')} msg={data.get('sMsg') or '-'}"
)
return None
jdata = data.get("jData") if isinstance(data.get("jData"), dict) else {}
@@ -562,13 +634,17 @@ class HuyaHttpClient:
self.logger("[LIVELINK] ❌ 小程序码接口未返回 qrcode")
return None
self.logger(f"[LIVELINK] 小程序码获取成功: image={len(image_src)} 字符 token={'' if jdata.get('qrcodeToken') else ''}")
self.logger(
f"[LIVELINK] 小程序码获取成功: image={len(image_src)} 字符 token={'' if jdata.get('qrcodeToken') else ''}"
)
return {
"mini_qrcode_image": image_src,
"qrcode_token": jdata.get("qrcodeToken") or "",
}
def get_livelink_qrcode_status(self, qrcode_token: str, timeout: float = 15.0) -> dict | None:
def get_livelink_qrcode_status(
self, qrcode_token: str, timeout: float = 15.0
) -> dict | None:
"""轮询 livelink 小程序码扫码状态。"""
import gzip
@@ -582,18 +658,26 @@ class HuyaHttpClient:
separators=(",", ":"),
).encode("utf-8")
endpoint = f"{LIVELINK_BIND_API_BASE}/api/qrcode/poolingQrcodeStatus"
req = urllib.request.Request(endpoint, data=payload, method="POST", headers={
"User-Agent": PC_UA,
"Origin": "https://livelink.qq.com",
"Referer": "https://livelink.qq.com/",
"Content-Type": "application/json;charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
})
req = urllib.request.Request(
endpoint,
data=payload,
method="POST",
headers={
"User-Agent": PC_UA,
"Origin": "https://livelink.qq.com",
"Referer": "https://livelink.qq.com/",
"Content-Type": "application/json;charset=UTF-8",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp_data = resp.read()
if resp_data[:2] == b"\x1f\x8b" or resp.headers.get("Content-Encoding") == "gzip":
if (
resp_data[:2] == b"\x1f\x8b"
or resp.headers.get("Content-Encoding") == "gzip"
):
resp_data = gzip.decompress(resp_data)
except Exception as e:
self.logger(f"[LIVELINK] ❌ 二维码状态请求失败: {type(e).__name__}: {e}")
@@ -602,11 +686,15 @@ class HuyaHttpClient:
try:
data = json.loads(resp_data.decode("utf-8", "replace"))
except Exception as e:
self.logger(f"[LIVELINK] ❌ 二维码状态响应解析失败: {type(e).__name__}: {e}")
self.logger(
f"[LIVELINK] ❌ 二维码状态响应解析失败: {type(e).__name__}: {e}"
)
return None
if data.get("iRet") != 0:
self.logger(f"[LIVELINK] ❌ 二维码状态接口失败: iRet={data.get('iRet')} msg={data.get('sMsg') or '-'}")
self.logger(
f"[LIVELINK] ❌ 二维码状态接口失败: iRet={data.get('iRet')} msg={data.get('sMsg') or '-'}"
)
return None
jdata = data.get("jData") if isinstance(data.get("jData"), dict) else {}
@@ -675,9 +763,20 @@ class HuyaHttpClient:
"exchange_qq_coin_url": f"{huya_base}?{urlencode(exchange_params)}",
}
def get_goods_info(self, uid, guid, cookie, pid, spu_id, sku_id=0, game_id="",
source_id="yellowcarlist", scene=7):
def get_goods_info(
self,
uid,
guid,
cookie,
pid,
spu_id,
sku_id=0,
game_id="",
source_id="yellowcarlist",
scene=7,
):
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
req = GetGoodsInfoReqV5()
req.userId = self._build_user(uid, guid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, scene)
@@ -685,11 +784,21 @@ class HuyaHttpClient:
req.spuId = spu_id
req.skuId = sku_id
req.gameId = game_id
return self.call_rpc("shopMiddleUI", "getGoodsInfoV5", req,
GoodsInfoRsp, uid, guid, cookie)
return self.call_rpc(
"shopMiddleUI", "getGoodsInfoV5", req, GoodsInfoRsp, uid, guid, cookie
)
def query_user_order_list(self, uid, guid, cookie, offset=0, page_size=10,
order_type=1, status=0, timeout=15.0):
def query_user_order_list(
self,
uid,
guid,
cookie,
offset=0,
page_size=10,
order_type=1,
status=0,
timeout=15.0,
):
"""查询用户订单列表,用于支付二维码后的付款状态轮询。"""
from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp
@@ -724,13 +833,28 @@ class HuyaHttpClient:
)
return fallback if fallback is not None else result
def create_order(self, uid, guid, cookie, pid, spu_id, sku_id,
item_count=1, source_id="yellowcarlist", game_id="", scene=7,
order_type=None):
def create_order(
self,
uid,
guid,
cookie,
pid,
spu_id,
sku_id,
item_count=1,
source_id="yellowcarlist",
game_id="",
scene=7,
order_type=None,
):
"""order_type=None 时自动尝试从 1 到 10 找到有效值"""
from .shop_structs import (CreateOrderReqV5, CreateOrderRsp,
CreateOrderExtraParam, CreateOrderPromotionParam,
CreateOrderAccountParam)
from .shop_structs import (
CreateOrderReqV5,
CreateOrderRsp,
CreateOrderExtraParam,
CreateOrderPromotionParam,
CreateOrderAccountParam,
)
# 如果指定了具体值,直接试
types_to_try = [order_type] if order_type is not None else list(range(1, 11))
@@ -759,10 +883,13 @@ class HuyaHttpClient:
req.gameCategoryId = 507
req.ext = ""
result = self.call_rpc("shopMiddleUI", "createOrderV5", req,
CreateOrderRsp, uid, guid, cookie)
result: Any = self.call_rpc(
"shopMiddleUI", "createOrderV5", req, CreateOrderRsp, uid, guid, cookie
)
if result is not None:
self.logger(f" orderType={ot}: code={result.code} orderId={result.orderId} msg={result.message}")
self.logger(
f" orderType={ot}: code={result.code} orderId={result.orderId} msg={result.message}"
)
if result.code == 200 and result.orderId:
self.logger(f"[✓] 找到有效 orderType={ot}")
return result
@@ -781,11 +908,21 @@ class HuyaHttpClient:
return "Weixin"
return text
def pay_order_submit(self, uid, guid, cookie, order_id, pay_type=1,
pid=0, source_id="yellowcarlist", scene=7,
item_count=1):
def pay_order_submit(
self,
uid,
guid,
cookie,
order_id,
pay_type=1,
pid=0,
source_id="yellowcarlist",
scene=7,
item_count=1,
):
"""发起支付,结构与 WSS HAR 中 payOrderSubmitV5 对齐。"""
from .shop_structs import PayOrderRes
os = TafOutputStream()
os.write_struct_begin(0)
@@ -817,22 +954,31 @@ class HuyaHttpClient:
self.logger(f"[HTTP] POST shopMiddleUI.payOrderSubmitV5")
self.logger(f"[HTTP] 发送 hex前60: {wup_data[:60].hex()}")
req = urllib.request.Request(url, data=wup_data, method='POST', headers={
'User-Agent': PC_UA, 'Origin': 'https://zt.huya.com',
'Referer': 'https://zt.huya.com/',
'Content-Type': 'application/octet-stream',
})
req = urllib.request.Request(
url,
data=wup_data,
method="POST",
headers={
"User-Agent": PC_UA,
"Origin": "https://zt.huya.com",
"Referer": "https://zt.huya.com/",
"Content-Type": "application/octet-stream",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
resp_data = resp.read()
if resp_data[:2] == b'\x1f\x8b':
if resp_data[:2] == b"\x1f\x8b":
import gzip
resp_data = gzip.decompress(resp_data)
except Exception as e:
self.logger(f"[HTTP] ❌ payOrderSubmitV5 失败: {e}")
return None
self.logger(f"[HTTP] payOrderSubmitV5 响应 {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}")
self.logger(
f"[HTTP] payOrderSubmitV5 响应 {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}"
)
wup_resp = WupResponse()
wup_resp.decode(resp_data)
if wup_resp.newdata:
+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, ""))