新增虎牙账号和任务基础功能

This commit is contained in:
yml2213
2026-07-04 16:31:12 +08:00
parent 3df247e4e5
commit e1d47a85be
26 changed files with 4513 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
"""虎牙协议与业务客户端。"""
from .http_client import HuyaHttpClient
from .wss_client import HuyaWssClient
__all__ = [
"HuyaHttpClient",
"HuyaWssClient",
]
+309
View File
@@ -0,0 +1,309 @@
"""
TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出
"""
from .taf_protocol import TafInputStream, TafType
from .wup_protocol import normalize_wup_payload
# cmd 编号 → 名称
CMD_NAMES = {
0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH",
0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV",
0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP",
}
# TAIL_BYTES = 2c36004c5c6600
_TAIL = bytes.fromhex("2c36004c5c6600")
def _strip_tail(body: bytes) -> bytes:
"""裁掉 body 末尾的 TAIL_BYTES"""
if body.endswith(_TAIL):
return body[:-len(_TAIL)]
# 有时 TAIL 前还有 0c (ZERO tag)
if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL):
return body[:-len(_TAIL)-1]
return body
def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object:
"""递归解码单个 TAF 值"""
if dtype == TafType.ZERO:
return 0
if dtype in (TafType.INT8, TafType.INT16, TafType.INT32, TafType.INT64):
return ins._read_int_value(dtype)
if dtype in (TafType.FLOAT, TafType.DOUBLE):
import struct as _s
if dtype == TafType.FLOAT:
return round(_s.unpack('>f', ins.buf.read(4))[0], 4)
return round(_s.unpack('>d', ins.buf.read(8))[0], 6)
if dtype == TafType.STRING1:
ln = ins.buf.read(1)[0]
return ins.buf.read(ln).decode('utf-8', errors='replace')
if dtype == TafType.STRING4:
import struct as _s
ln = _s.unpack('>I', ins.buf.read(4))[0]
return ins.buf.read(ln).decode('utf-8', errors='replace')
if dtype == TafType.MAP:
cnt = ins._read_int_len()
m = {}
for _ in range(cnt):
_, kt = ins.read_head()
k = _decode_taf_value(ins, kt, depth + 1)
_, vt = ins.read_head()
v = _decode_taf_value(ins, vt, depth + 1)
m[k] = v
return m
if dtype == TafType.LIST:
cnt = ins._read_int_len()
items = []
for _ in range(cnt):
_, it = ins.read_head()
items.append(_decode_taf_value(ins, it, depth + 1))
return items
if dtype == TafType.SIMPLE_LIST:
ins.read_head() # element type INT8
ln = ins._read_int_len()
return f"<bytes {ln}B>"
if dtype == TafType.STRUCT_BEGIN:
return _decode_taf_struct(ins, depth + 1)
return f"<0x{dtype:02x}>"
def _decode_taf_struct(ins: TafInputStream, depth: int = 0) -> dict:
"""解码一个 TAF 结构体 (已消费 STRUCT_BEGIN) → 有序字典"""
fields = {}
while True:
try:
tag, dtype = ins.peek_head()
except EOFError:
break
if dtype == TafType.STRUCT_END:
ins.read_head()
break
ins.read_head()
# 限制递归深度
if depth < 5:
try:
val = _decode_taf_value(ins, dtype, depth)
except Exception:
val = f"<decode_err:0x{dtype:02x}>"
else:
val = f"<...>"
try:
ins.skip_field(dtype)
except Exception:
break
key = f"tag{tag}"
if key in fields:
key = f"{key}_2"
fields[key] = val
return fields
def _extract_wup(body: bytes) -> bytes:
"""从 WSS 响应 body 中提取干净的 WUP 包"""
if len(body) < 5:
return body
# 大包格式: [1B prefix][4B wup_len][wup_body][tail]
# prefix 可能是 0x00,必须优先于 4B total_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]
total_len = int.from_bytes(body[0:4], 'big')
if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03':
return body[:total_len]
if 5 + wup_len <= len(body):
return body[5:5 + wup_len]
return body
def _decode_wup_body(body: bytes) -> dict:
"""解码 WUP 封装 (请求/响应通用) → {servant, func, data}"""
result = {}
wup = normalize_wup_payload(_extract_wup(body))
# 手动解析 WUP,WSS 大帧常在 tag10 后追加校验/尾部字节
try:
ins = TafInputStream(wup)
# 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾)
sBuffer = b''
while True:
try:
tag, dtype = ins.peek_head()
except EOFError:
break
# tag10 是最后一个合法 WUP 字段
if tag > 10:
break
ins.read_head()
if tag == 1:
if dtype != TafType.ZERO:
ins._read_int_value(dtype)
elif tag in (2, 3):
if dtype != TafType.ZERO:
ins._read_int_value(dtype)
elif tag == 4:
if dtype != TafType.ZERO:
ins._read_int_value(dtype)
elif tag == 5:
result["servant"] = _decode_taf_value(ins, dtype)
elif tag == 6:
result["func"] = _decode_taf_value(ins, dtype)
elif tag == 7:
if dtype == TafType.SIMPLE_LIST:
ins.read_head()
ln = ins._read_int_len()
sBuffer = ins.buf.read(ln)
else:
ins.skip_field(dtype)
elif tag == 8:
if dtype != TafType.ZERO:
ins._read_int_value(dtype)
elif tag in (9, 10):
ins.skip_field(dtype)
if tag == 10:
break
else:
ins.skip_field(dtype)
# 解析 sBuffer → newdata MAP
if sBuffer:
try:
sins = TafInputStream(sBuffer)
stag, sdt = sins.read_head()
if stag == 0 and sdt == TafType.MAP:
cnt = sins._read_int_len()
for _ in range(cnt):
_, kt = sins.read_head()
k = _decode_taf_value(sins, kt)
_, vt = sins.read_head()
if vt == TafType.SIMPLE_LIST:
sins.read_head()
vln = sins._read_int_len()
v = sins.buf.read(vln)
if v:
try:
tins = TafInputStream(v)
ttag, tdt = tins.peek_head()
if tdt == TafType.STRUCT_BEGIN:
tins.read_head()
result[k] = _decode_taf_struct(tins)
else:
result[k] = f"<{len(v)}B>"
except Exception:
result[k] = f"<{len(v)}B>"
else:
result[k] = _decode_taf_value(sins, vt)
except Exception:
pass
except Exception as e:
result["err"] = str(e)
return result
def _truncate(obj, max_str=80):
"""截断过长的值,使日志紧凑"""
if isinstance(obj, str):
if len(obj) > max_str:
return obj[:max_str] + f"...({len(obj)})"
return obj
if isinstance(obj, dict):
return {k: _truncate(v, max_str) for k, v in obj.items()}
if isinstance(obj, list):
return [_truncate(v, max_str) for v in obj[:5]]
return obj
def _fmt_fields(fields: dict) -> str:
"""把字段字典格式化为一行摘要"""
parts = []
for k, v in fields.items():
if isinstance(v, dict):
parts.append(f"{k}={{...}}")
elif isinstance(v, list):
parts.append(f"{k}=[{len(v)}项]")
else:
sv = str(v)
if len(sv) > 60:
sv = sv[:60] + "..."
parts.append(f"{k}={sv}")
return ", ".join(parts)
def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str:
"""格式化单条 WSS 帧的日志行
Args:
body: 帧体 (不含 6B 头)
cmd: 命令字
seq: 序列号
direction: """"
Returns:
可读日志字符串
"""
prefix = f"[{direction}]"
# 心跳精简
if cmd == 0x10:
return f"{prefix} HB →"
if cmd == 0x11:
return f"{prefix} HB ←"
# RPC 请求/响应
if cmd in (0x03, 0x04):
decoded = _decode_wup_body(body)
svc = decoded.get("servant", "")
func = decoded.get("func", "")
err = decoded.get("err")
if err:
return f"{prefix} RPC ERR: {err}"
label = f"{svc}.{func}" if svc else "RPC"
# 找 tRsp/tResp/tReq
for key in ("tRsp", "tResp", "tReq"):
data = decoded.get(key)
if isinstance(data, dict):
return f"{prefix} {label} {_fmt_fields(_truncate(data))}"
return f"{prefix} {label}"
# AUTH
if cmd == 0x0a:
text = _strip_tail(body).decode('utf-8', errors='replace')
return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}"
# REGISTER / CONFIRM / PUSH 等
clean = _strip_tail(body)
try:
ins = TafInputStream(clean)
# 看第一个 head
tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN:
ins.read_head()
fields = _decode_taf_struct(ins)
elif dtype == TafType.MAP:
ins.read_head()
cnt = ins._read_int_len()
fields = {}
for _ in range(cnt):
_, kt = ins.read_head()
k = _decode_taf_value(ins, kt)
_, vt = ins.read_head()
v = _decode_taf_value(ins, vt)
fields[k] = v
elif dtype == TafType.LIST:
ins.read_head()
cnt = ins._read_int_len()
items = []
for _ in range(cnt):
_, it = ins.read_head()
items.append(_decode_taf_value(ins, it))
fields = {"items": items}
else:
fields = {}
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
if fields:
return f"{prefix} {cmd_name} {_fmt_fields(_truncate(fields))}"
return f"{prefix} {cmd_name}"
except Exception:
cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
return f"{prefix} {cmd_name} ({len(body)}B)"
+357
View File
@@ -0,0 +1,357 @@
"""
虎牙 HTTP POST RPC 通道(cdnws.api.huya.com
用于 WSS 业务通道被设备态 Cookie 静默拒收时的兜底:
URL: https://cdnws.api.huya.com/?baseinfo=<base64(WSConnectParaInfo)>
Body: Wup 包 (含4字节长度前缀)
Response: Wup 包 (tRsp)
"""
import base64
import json
import random
import struct
import urllib.parse
import urllib.request
from typing import Optional, Callable
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
from .wup_protocol import WupRequest, WupResponse
CDNWS_HOST = "cdnws.api.huya.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")
HTTP_HUYA_UA = "webh5&0.0.1&websocket&&diypc_52775"
class WSConnectParaInfo(TafStruct):
"""WSS/HTTP 连接参数(baseinfo 的内容),从前端 SDK 逆向
tag0 lUid int64
tag1 sGuid string
tag2 sUA string "webh5&0.0.1&websocket&&diypc_52775"
tag3 sAppSrc string "HUYA&ZH&2052"
tag4 sMid string
tag5 sExp string
tag6 iTokenType int32
tag7 sToken string
tag8 sCookie string (lUid>0 时设 document.cookie)
tag9 sTraceId string "hex8:hex8:0:0"
tag10 mCustomHeaders Map<string,string>
"""
def __init__(self):
self.lUid: int = 0
self.sGuid: str = ""
self.sUA: str = "webh5&0.0.1&websocket&&diypc_52775"
self.sAppSrc: str = "HUYA&ZH&2052"
self.sMid: str = ""
self.sExp: str = ""
self.iTokenType: int = 0
self.sToken: str = ""
self.sCookie: str = ""
self.sTraceId: str = ""
self.mCustomHeaders: dict = {}
def write_to(self, os: TafOutputStream):
# 与浏览器一致: 空字符串也写 (HAR 实证 tag4/5/7/8 都写了空串)
if self.lUid:
os.write_int64(0, self.lUid)
else:
os.write_int64(0, 0) # ZERO
os.write_string(1, self.sGuid)
os.write_string(2, self.sUA)
os.write_string(3, self.sAppSrc)
os.write_string(4, self.sMid)
os.write_string(5, self.sExp)
os.write_int32(6, self.iTokenType)
os.write_string(7, self.sToken)
os.write_string(8, self.sCookie)
os.write_string(9, self.sTraceId)
os.write_map(10, self.mCustomHeaders)
def read_from(self, ins: TafInputStream):
pass
def _gen_trace_id() -> str:
"""生成 sTraceId (格式 hex8:hex8:0:0HAR 实证)"""
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:
"""
构造 HTTP POST 的 baseinfo URL 参数
HAR 实证: getGoodsInfoV5 的 baseinfo lUid=0, sCookie="" (身份靠 Wup body 的 userId.sCookie)
"""
info = WSConnectParaInfo()
info.lUid = 0 # HAR 实证: baseinfo 里 lUid=0
info.sGuid = guid
info.sUA = "webh5&0.0.1&websocket&&diypc_52775"
info.sAppSrc = "HUYA&ZH&2052"
info.sCookie = "" # HAR 实证: baseinfo 不带 cookie, cookie 在 Wup body
info.sTraceId = trace_id or _gen_trace_id()
os = TafOutputStream()
os.write_struct_begin(0) # WSConnectParaInfo 是顶层 struct? 实际 baseinfo 是裸 struct 字段
# 实际 HAR baseinfo 解码: 顶层直接是字段(tag0 lUid...), 不包 STRUCT_BEGIN
# 重置, 不写 STRUCT_BEGIN
os = TafOutputStream()
info.write_to(os)
raw = os.get_bytes()
# base64 (与 JS window.btoa 一致)
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):
self.logger = logger or print
@staticmethod
def _normalize_cookie(cookie: str) -> str:
"""HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。"""
import re
cookie = (cookie or "").strip()
normalized = f"huya_ua={HTTP_HUYA_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_user(uid: int, guid: str, cookie: str):
"""构造 HTTP 业务 UserId。"""
from .shop_structs import UserId
user = UserId()
user.lUid = uid
user.sGuid = guid
user.sToken = ""
user.sHuYaUA = HTTP_HUYA_UA
user.sCookie = HuyaHttpClient._normalize_cookie(cookie)
user.iTokenType = 0
user.sDeviceInfo = ""
user.sQIMEI = ""
return user
@staticmethod
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 = ""
info.scene = scene
info.sourceId = source_id
return info
@staticmethod
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":""}}]',
"input_1": str(item_count),
}
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 调用
Args:
service: servant 名 (shopMiddleUI)
method: 方法名 (getGoodsInfoV5)
req_struct: 请求结构体 (TafStruct)
rsp_class: 响应类
uid/guid/cookie: 用于构造 baseinfo (cookie 主要放 Wup body)
"""
# 1. 构造 Wup 请求体
wup = WupRequest()
wup.setServant(service)
wup.setFunc(method)
wup.setRequestId(1)
wup.writeStruct("tReq", req_struct)
wup_data = wup.encode() # [4字节长度][wup body]
# 2. 构造 baseinfo
baseinfo = generate_http_baseinfo(uid, guid, cookie)
url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}"
self.logger(f"[HTTP] POST {service}.{method}")
self.logger(f"[HTTP] URL: {url[:100]}...")
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',
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp_data = resp.read()
# 检测 gzip 压缩 (响应 hex 1f8b 开头)
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()}")
# 4. 解析响应 Wup
wup_resp = WupResponse()
wup_resp.decode(resp_data)
if wup_resp.newdata:
self.logger(f"[HTTP] 响应 newdata keys: {list(wup_resp.newdata.keys())}")
for k, v in wup_resp.newdata.items():
self.logger(f"[HTTP] {k} hex({len(v)}): {v.hex()[:200]}")
else:
self.logger(f"[HTTP] 响应无 newdata (servant={wup_resp.sServantName})")
if rsp_class is None:
return wup_resp
result = wup_resp.readStruct("tRsp", rsp_class)
if result is None:
result = wup_resp.readStruct("tResp", rsp_class)
return result
# ---------- 业务便捷方法 ----------
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)
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.gameId = game_id
return self.call_rpc("shopMiddleUI", "getGoodsInfoV5", req,
GoodsInfoRsp, uid, guid, cookie)
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)
# 如果指定了具体值,直接试
types_to_try = [order_type] if order_type is not None else list(range(1, 11))
last_result = None
for ot in types_to_try:
req = CreateOrderReqV5()
req.userId = self._build_user(uid, guid, 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 = ot
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 = ""
result = 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}")
if result.code == 200 and result.orderId:
self.logger(f"[✓] 找到有效 orderType={ot}")
return result
last_result = result
else:
self.logger(f" orderType={ot}: 无响应")
return last_result
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)
os.write_struct(0, self._build_user(uid, guid, 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()
# 构造 Wup
wup = WupRequest()
wup.setServant("shopMiddleUI")
wup.setFunc("payOrderSubmitV5")
wup.setRequestId(1)
wup.newdata["tReq"] = os.get_bytes()
wup_data = wup.encode()
baseinfo = generate_http_baseinfo(uid, guid, cookie)
url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}"
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',
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
resp_data = resp.read()
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()}")
wup_resp = WupResponse()
wup_resp.decode(resp_data)
if wup_resp.newdata:
self.logger(f"[HTTP] 响应 keys: {list(wup_resp.newdata.keys())}")
for k, v in wup_resp.newdata.items():
self.logger(f"[HTTP] {k} hex({len(v)}): {v.hex()[:200]}")
result = wup_resp.readStruct("tRsp", PayOrderRes)
if result is None:
result = wup_resp.readStruct("tResp", PayOrderRes)
return result
+583
View File
@@ -0,0 +1,583 @@
"""
虎牙充值相关 JCE 结构定义
字段顺序 = TAF tag 顺序(从 JS 逆向所得)
来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts
"""
from typing import List, Dict, Optional
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
class OrderType:
HUYA_PHYSICAL = 1
TENCENT_VIRTUAL = 2
MAGIC_BOX = 3
MAGIC_BOX_DELIVERY = 4
PAID_COURSE = 5
HUYA_VIRTUAL = 6
LOTTERY_PICKUP = 7
DELIVERY_WORKBENCH = 11
GAME_ACCOUNT = 12
SHELF_GOODS = 17
class OrderStatus:
DEPOSIT_WAIT_PAY = 10
DEPOSIT_PAID = 20
WAIT_DELIVER = 30
WAIT_RECEIVE = 40
FINISHED = 50
FINISHED_CLOSED = 60
CANCELLED = 90
BALANCE_WAIT_PAY = 1000
CANCELLED_BALANCE_EXPIRED = 1001
def _opt_str(os: TafOutputStream, tag: int, val: str):
"""非空才写字符串(与浏览器一致,省默认值)"""
if val:
os.write_string(tag, val)
def _opt_int(os: TafOutputStream, tag: int, val):
"""非 0 才写整数"""
if val:
os.write_int64(tag, val)
def _opt_struct(os: TafOutputStream, tag: int, val):
"""非 None 才写结构体"""
if val is not None:
os.write_struct(tag, val)
def _opt_list(os: TafOutputStream, tag: int, val):
"""非空才写 list"""
if val:
os.write_list(tag, val)
def _opt_map(os: TafOutputStream, tag: int, val):
"""非空才写 map"""
if val:
os.write_map(tag, val)
def _skip_to_struct_end(ins: TafInputStream):
"""跳过当前结构里未解析的尾部字段,停在 STRUCT_END 前。"""
while True:
pos = ins.buf.tell()
tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END:
ins.buf.seek(pos)
return
ins.skip_field(dtype)
# ============================================================
# 基础结构
# ============================================================
class UserId(TafStruct):
"""用户标识(cookie 在这里)"""
def __init__(self):
self.lUid: int = 0 # tag 0
self.sGuid: str = "" # tag 1
self.sToken: str = "" # tag 2
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
self.sCookie: str = "" # tag 4 完整 cookie
self.iTokenType: int = 0 # tag 5
self.sDeviceInfo: str = "" # tag 6
self.sQIMEI: str = "" # tag 7
def write_to(self, os: TafOutputStream):
# HAR2 实证: 浏览器不优化空串/0值, 全写字段
os.write_int64(0, self.lUid)
os.write_string(1, self.sGuid)
os.write_string(2, self.sToken)
os.write_string(3, self.sHuYaUA)
os.write_string(4, self.sCookie)
os.write_int32(5, self.iTokenType)
os.write_string(6, self.sDeviceInfo)
os.write_string(7, self.sQIMEI)
def read_from(self, ins: TafInputStream):
self.lUid = ins.read_int64(0, default=self.lUid)
self.sGuid = ins.read_string(1, default=self.sGuid)
self.sToken = ins.read_string(2, default=self.sToken)
self.sHuYaUA = ins.read_string(3, default=self.sHuYaUA)
self.sCookie = ins.read_string(4, default=self.sCookie)
self.iTokenType = ins.read_int32(5, default=self.iTokenType)
self.sDeviceInfo = ins.read_string(6, default=self.sDeviceInfo)
self.sQIMEI = ins.read_string(7, default=self.sQIMEI)
class ShopAppInfo(TafStruct):
"""应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId"""
def __init__(self):
self.sAppId: str = "huya" # tag 0
self.sBizType: str = "" # tag 1
self.scene: int = 0 # tag 4
self.sourceId: str = "" # tag 5
def write_to(self, os: TafOutputStream):
# HAR2 实证: 全写字段(含空串/0)
os.write_string(0, self.sAppId)
os.write_string(1, self.sBizType)
os.write_int32(4, self.scene)
os.write_string(5, self.sourceId)
def read_from(self, ins: TafInputStream):
self.sAppId = ins.read_string(0, default=self.sAppId)
self.sBizType = ins.read_string(1, default=self.sBizType)
self.scene = ins.read_int32(4, default=self.scene)
self.sourceId = ins.read_string(5, default=self.sourceId)
# ============================================================
# wsLaunch 初始化
# ============================================================
class WsLaunchSubStruct(TafStruct):
"""wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)"""
def __init__(self):
self.s0: str = "" # tag 0
self.s1: str = "" # tag 1
self.s2: str = "" # tag 2
self.s3: str = "" # tag 3
self.s4: str = "" # tag 4
def write_to(self, os: TafOutputStream):
# 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配
os.write_string(0, self.s0)
os.write_string(1, self.s1)
os.write_string(2, self.s2)
os.write_string(3, self.s3)
os.write_string(4, self.s4)
def read_from(self, ins: TafInputStream):
pass
class WsLaunchReq(TafStruct):
"""launch.wsLaunch 请求(tReq 是 STRUCT,不是 list
从 HAR 逆向:
tag0: lUid (int64)
tag1: string ""
tag2: sHuYaUA "webh5&1.0.0&huya"
tag3: appSrc "HUYA&ZH&2052"
tag4: 子struct (5个空字符串)
"""
def __init__(self):
self.lUid: int = 0 # tag 0
self.s1: str = "" # tag 1
self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2
self.appSrc: str = "HUYA&ZH&2052" # tag 3
self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4
def write_to(self, os: TafOutputStream):
# 注意: 浏览器不优化空字符串,tag1 强制写(HAR 实证)
_opt_int(os, 0, self.lUid)
os.write_string(1, self.s1) # 强制写空字符串
_opt_str(os, 2, self.sHuYaUA)
_opt_str(os, 3, self.appSrc)
_opt_struct(os, 4, self.sub)
def read_from(self, ins: TafInputStream):
pass
# ============================================================
# 商品查询
# ============================================================
class GetGoodsInfoReqV5(TafStruct):
"""商品查询请求 (shopMiddleUI.getGoodsInfoV5)"""
def __init__(self):
self.userId = UserId() # tag 0
self.shopAppInfo = ShopAppInfo() # tag 1
self.pid: int = 0 # tag 2
self.gameId: str = "" # tag 3
self.spuId: str = "" # tag 4
self.channelStockCode: str = "" # tag 5
self.skuId: int = 0 # tag 6
self.inviterUid: int = 0 # tag 7
self.userModifyPriceId: int = 0 # tag 8
def write_to(self, os: TafOutputStream):
# HAR2 实证: 全写字段
os.write_struct(0, self.userId)
os.write_struct(1, self.shopAppInfo)
os.write_int64(2, self.pid)
os.write_string(3, self.gameId)
os.write_string(4, self.spuId)
os.write_string(5, self.channelStockCode)
os.write_int64(6, self.skuId)
os.write_int64(7, self.inviterUid)
os.write_int64(8, self.userModifyPriceId)
def read_from(self, ins: TafInputStream):
# 请求结构体一般不需要读,但保留
pass
class GoodsInfoRsp(TafStruct):
"""商品查询响应(简化:只取关键字段)"""
def __init__(self):
self.code: int = 0 # tag 0
self.message: str = "" # tag 1
# tag 2 是 goodsInfo 结构,简化跳过
self.selfGoods: int = 0 # tag 3
self.marketStatus: int = 0 # tag 5
self.timestamp: int = 0 # tag 6
def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code)
self.message = ins.read_string(1, default=self.message)
# goodsInfo (tag 2) 跳过
self.selfGoods = ins.read_int32(3, default=self.selfGoods)
self.marketStatus = ins.read_int32(5, default=self.marketStatus)
self.timestamp = ins.read_int64(6, default=self.timestamp)
def write_to(self, os: TafOutputStream):
pass
# ============================================================
# 订单历史
# ============================================================
class OrderListShopInfo(TafStruct):
"""订单明细里的店铺信息(只取展示需要的字段)"""
def __init__(self):
self.shopName: str = "" # tag 0
def read_from(self, ins: TafInputStream):
self.shopName = ins.read_string(0, default=self.shopName)
_skip_to_struct_end(ins)
def write_to(self, os: TafOutputStream):
pass
class OrderListGoodsDetail(TafStruct):
"""订单明细(queryUserOrderList 响应 tag16"""
def __init__(self):
self.spuId: str = "" # tag 4
self.skuId: int = 0 # tag 16
self.buyerUid: int = 0 # tag 18
self.virtualType: int = 0 # tag 19
self.quantity: int = 0 # tag 20
self.shopInfo: Optional[OrderListShopInfo] = None # tag 21
self.points: int = 0 # tag 23
def read_from(self, ins: TafInputStream):
self.spuId = ins.read_string(4, default=self.spuId)
self.skuId = ins.read_int64(16, default=self.skuId)
self.buyerUid = ins.read_int64(18, default=self.buyerUid)
self.virtualType = ins.read_int32(19, default=self.virtualType)
self.quantity = ins.read_int64(20, default=self.quantity)
self.shopInfo = ins.read_struct(21, OrderListShopInfo)
self.points = ins.read_int64(23, default=self.points)
_skip_to_struct_end(ins)
def write_to(self, os: TafOutputStream):
pass
class OrderListItem(TafStruct):
"""订单列表项(queryUserOrderList 响应 tag3 的 list 元素)"""
def __init__(self):
self.bizOrderId: str = "" # tag 0 shop10148750
self.appId: str = "" # tag 1 shop
self.orderId: str = "" # tag 2
self.pid: int = 0 # tag 3
self.shopName: str = "" # tag 4
self.orderStatus: int = 0 # tag 5
self.itemName: str = "" # tag 8
self.unitPrice: int = 0 # tag 9 分
self.quantity: int = 0 # tag 10
self.totalPrice: int = 0 # tag 12 分
self.createTime: int = 0 # tag 14 毫秒时间戳
self.payTime: int = 0 # tag 15 毫秒时间戳
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16
def read_from(self, ins: TafInputStream):
self.bizOrderId = ins.read_string(0, default=self.bizOrderId)
self.appId = ins.read_string(1, default=self.appId)
self.orderId = ins.read_string(2, default=self.orderId)
self.pid = ins.read_int64(3, default=self.pid)
self.shopName = ins.read_string(4, default=self.shopName)
self.orderStatus = ins.read_int32(5, default=self.orderStatus)
self.itemName = ins.read_string(8, default=self.itemName)
self.unitPrice = ins.read_int64(9, default=self.unitPrice)
self.quantity = ins.read_int64(10, default=self.quantity)
self.totalPrice = ins.read_int64(12, default=self.totalPrice)
self.createTime = ins.read_int64(14, default=self.createTime)
self.payTime = ins.read_int64(15, default=self.payTime)
self.goodsDetail = ins.read_struct(16, OrderListGoodsDetail)
_skip_to_struct_end(ins)
def write_to(self, os: TafOutputStream):
pass
class QueryUserOrderListReq(TafStruct):
"""查询购买历史订单 (revenueWebUI.queryUserOrderList)"""
def __init__(self):
self.userId = UserId() # tag 0
self.offset: int = 0 # tag 1
self.orderType: int = 1 # tag 2
self.pageSize: int = 10 # tag 3
self.status: int = 0 # tag 4
def write_to(self, os: TafOutputStream):
os.write_struct(0, self.userId)
os.write_int32(1, self.offset)
os.write_int32(2, self.orderType)
os.write_int32(3, self.pageSize)
os.write_int32(4, self.status)
def read_from(self, ins: TafInputStream):
pass
class QueryUserOrderListRsp(TafStruct):
"""购买历史订单响应"""
def __init__(self):
self.code: int = 0
self.message: str = ""
self.totalCount: int = 0
self.orders: List[OrderListItem] = []
@staticmethod
def _read_order_item(ins: TafInputStream, _tag: int):
item = OrderListItem()
item.read_from(ins)
_end_tag, dtype = ins.read_head()
if dtype != TafType.STRUCT_END:
raise ValueError(f"期望订单 STRUCT_END,实际 0x{dtype:02x}")
return item
def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code)
self.message = ins.read_string(1, default=self.message)
self.totalCount = ins.read_int64(2, default=self.totalCount)
self.orders = ins.read_list(3, item_reader=self._read_order_item)
def write_to(self, os: TafOutputStream):
pass
# ============================================================
# 下单
# ============================================================
class CreateOrderExtraParam(TafStruct):
def __init__(self):
self.freight: int = 0 # tag 0
self.channelStockType: str = "" # tag 1
self.channelStockCode: str = "" # tag 2
self.relatedBizId: str = "" # tag 3
self.bizParams: str = "" # tag 4
self.popupTraceId: str = "" # tag 5
self.supplierUid: int = 0 # tag 6
self.categoryId: str = "" # tag 7
self.ext: str = "" # tag 8
def write_to(self, os: TafOutputStream):
# HAR 实证:下单 extraParam 会强制写默认 0/空串字段
os.write_int64(0, self.freight)
os.write_string(1, self.channelStockType)
os.write_string(2, self.channelStockCode)
os.write_string(3, self.relatedBizId)
os.write_string(4, self.bizParams)
os.write_string(5, self.popupTraceId)
os.write_int64(6, self.supplierUid)
if self.categoryId:
os.write_string(7, self.categoryId)
os.write_string(8, self.ext)
def read_from(self, ins: TafInputStream):
pass
class CreateOrderPromotionParam(TafStruct):
def __init__(self):
self.yxjDeductPrice: int = 0 # tag 0
self.userModifyPriceId: int = 0 # tag 1
self.enablePromotion: int = 1 # tag 2
def write_to(self, os: TafOutputStream):
# HAR 实证:tag0/tag1 为 0tag2 为 1
os.write_int64(0, self.yxjDeductPrice)
os.write_int64(1, self.userModifyPriceId)
os.write_int32(2, self.enablePromotion)
def read_from(self, ins: TafInputStream):
pass
class CreateOrderAccountParam(TafStruct):
def __init__(self):
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32>
self.payoutChargeAmount: int = 0 # tag 1
self.cancelPayoutTypeList: List[int] = [] # tag 2
self.recycleSupplierId: int = 0 # tag 3
self.claimPrice: int = 0 # tag 4
def write_to(self, os: TafOutputStream):
# HAR 实证:空 list/0 值也会写出
os.write_list(0, self.payoutTypeList)
os.write_int64(1, self.payoutChargeAmount)
os.write_list(2, self.cancelPayoutTypeList)
os.write_int64(3, self.recycleSupplierId)
os.write_int64(4, self.claimPrice)
def read_from(self, ins: TafInputStream):
pass
class PromotionItem(TafStruct):
def __init__(self):
self.promotionId: int = 0 # tag 0
self.promotionType: int = 0 # tag 1
def write_to(self, os: TafOutputStream):
_opt_int(os, 0, self.promotionId)
_opt_int(os, 1, self.promotionType)
def read_from(self, ins: TafInputStream):
self.promotionId = ins.read_int64(0, default=self.promotionId)
self.promotionType = ins.read_int32(1, default=self.promotionType)
class CreateOrderReqV5(TafStruct):
"""下单请求 (shopMiddleUI.createOrderV5)"""
def __init__(self):
self.userId = UserId() # tag 0
self.shopAppInfo = ShopAppInfo() # tag 1
self.receiveId: int = 0 # tag 2
self.pid: int = 0 # tag 3
self.skuId: int = 0 # tag 4
self.itemCount: int = 1 # tag 5
self.remark: str = "" # tag 6
self.spuId: str = "" # tag 7
self.gameId: str = "" # tag 8
self.orderId: int = 0 # tag 9
self.src: int = 0 # tag 10
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64>
self.orderType: int = 0 # tag 12
self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13
self.scene: int = 0 # tag 14
self.promotionItems: List = [] # tag 15 Vector<PromotionItem>
self.sourceId: str = "" # tag 16
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
self.orderScene: int = 0 # tag 18
self.watchWord: str = "" # tag 19
self.marketingChannel: str = "" # tag 20
self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21
self.externalTraceKey: str = "" # tag 22
self.kefuUid: int = 0 # tag 23
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24
self.parentOrderId: int = 0 # tag 25
self.vendorAccountType: str = "" # tag 26
self.vendorAccountVal: str = "" # tag 27
self.vendorSubAccountVal: str = "" # tag 28
self.vendorSubAccountType: str = "" # tag 29
self.bizType: int = 0 # tag 30
self.gameCategoryId: int = 0 # tag 31
self.ext: str = "" # tag 32
def write_to(self, os: TafOutputStream):
# HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list
os.write_struct(0, self.userId)
os.write_struct(1, self.shopAppInfo)
os.write_int64(2, self.receiveId)
os.write_int64(3, self.pid)
os.write_int64(4, self.skuId)
os.write_int64(5, self.itemCount)
os.write_string(6, self.remark)
os.write_string(7, self.spuId)
os.write_string(8, self.gameId)
os.write_int64(9, self.orderId)
os.write_int32(10, self.src)
os.write_list(11, self.couponUserIds)
os.write_int32(12, self.orderType)
os.write_struct(13, self.extraParam or CreateOrderExtraParam())
os.write_int32(14, self.scene)
os.write_list(15, self.promotionItems)
os.write_string(16, self.sourceId)
os.write_map(17, self.env)
os.write_int32(18, self.orderScene)
os.write_string(19, self.watchWord)
os.write_string(20, self.marketingChannel)
os.write_struct(21, self.promotionParam or CreateOrderPromotionParam())
os.write_string(22, self.externalTraceKey)
os.write_int64(23, self.kefuUid)
os.write_struct(24, self.accountParam or CreateOrderAccountParam())
os.write_int64(25, self.parentOrderId)
os.write_string(26, self.vendorAccountType)
os.write_string(27, self.vendorAccountVal)
os.write_string(28, self.vendorSubAccountVal)
os.write_string(29, self.vendorSubAccountType)
os.write_int32(30, self.bizType)
os.write_int32(31, self.gameCategoryId)
os.write_string(32, self.ext)
def read_from(self, ins: TafInputStream):
pass
class CreateOrderRsp(TafStruct):
"""下单响应 (shopMiddleUI.createOrderV5)"""
def __init__(self):
self.code: int = 0 # tag 0
self.message: str = "" # tag 1
self.orderId: int = 0 # tag 2 虎牙订单号
self.subOrderId: int = 0 # tag 4
self.orderStatus: int = 0 # tag 5
self.riskUrl: str = "" # tag 8 风控跳转URLcode!=200时可能有)
def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code)
self.message = ins.read_string(1, default=self.message)
self.orderId = ins.read_int64(2, default=self.orderId)
self.subOrderId = ins.read_int64(4, default=self.subOrderId)
self.orderStatus = ins.read_int32(5, default=self.orderStatus)
self.riskUrl = ins.read_string(8, default=self.riskUrl)
def write_to(self, os: TafOutputStream):
pass
# ============================================================
# 支付(payOrderSubmitV5
# ============================================================
class PayOrderRes(TafStruct):
"""
发起支付响应 (shopMiddleUI.payOrderSubmitV5)
结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序
"""
def __init__(self):
self.code: int = 0 # tag 0
self.message: str = "" # tag 1
self.orderId: int = 0 # tag 2 虎牙订单号
self.appOrderId: str = "" # tag 3
self.payOrderId: str = "" # tag 4 支付宝 payOrderId
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
self.amount: int = 0 # tag 6
def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code)
self.message = ins.read_string(1, default=self.message)
self.orderId = ins.read_int64(2, default=self.orderId)
self.appOrderId = ins.read_string(3, default=self.appOrderId)
self.payOrderId = ins.read_string(4, default=self.payOrderId)
self.payUrl = ins.read_string(5, default=self.payUrl)
self.amount = ins.read_int64(6, default=self.amount)
def write_to(self, os: TafOutputStream):
pass
+477
View File
@@ -0,0 +1,477 @@
"""
虎牙 TAF 协议 Python 实现
基于前端 TAF/WUP 实现逆向
类型标签:
0x00 INT8 0x01 INT16 0x02 INT32 0x03 INT64
0x04 FLOAT 0x05 DOUBLE 0x06 STRING1 0x07 STRING4
0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END
0x0c ZERO 0x0d SIMPLE_LIST
"""
import struct
import io
from typing import Any, Dict, List, Optional, Tuple
class TafType:
INT8 = 0x00
INT16 = 0x01
INT32 = 0x02
INT64 = 0x03
FLOAT = 0x04
DOUBLE = 0x05
STRING1 = 0x06
STRING4 = 0x07
MAP = 0x08
LIST = 0x09
STRUCT_BEGIN = 0x0a
STRUCT_END = 0x0b
ZERO = 0x0c
SIMPLE_LIST = 0x0d
# ============================================================
# 输出流(编码)
# ============================================================
class TafOutputStream:
"""TAF 编码输出流"""
def __init__(self):
self.buf = io.BytesIO()
def get_bytes(self) -> bytes:
return self.buf.getvalue()
# ---- head ----
def write_head(self, tag: int, data_type: int):
if tag < 15:
self.buf.write(struct.pack('B', (tag << 4) | data_type))
else:
self.buf.write(struct.pack('BB', 0xF0 | data_type, tag))
# ---- 整数(带自动优化) ----
def write_int8(self, tag: int, value: int):
if value == 0:
self.write_head(tag, TafType.ZERO)
else:
self.write_head(tag, TafType.INT8)
self.buf.write(struct.pack('b', value))
def write_int16(self, tag: int, value: int):
if -128 <= value <= 127:
self.write_int8(tag, value)
else:
self.write_head(tag, TafType.INT16)
self.buf.write(struct.pack('>h', value))
def write_int32(self, tag: int, value: int):
if -32768 <= value <= 32767:
self.write_int16(tag, value)
else:
self.write_head(tag, TafType.INT32)
self.buf.write(struct.pack('>i', value))
def write_int64(self, tag: int, value: int):
if -2147483648 <= value <= 2147483647:
self.write_int32(tag, value)
else:
self.write_head(tag, TafType.INT64)
self.buf.write(struct.pack('>q', value))
def write_uint64(self, tag: int, value: int):
"""uint64:超过 int32 范围用 INT64"""
if value <= 2147483647:
self.write_int32(tag, value)
else:
self.write_head(tag, TafType.INT64)
self.buf.write(struct.pack('>Q', value))
# ---- 浮点 ----
def write_float(self, tag: int, value: float):
self.write_head(tag, TafType.FLOAT)
self.buf.write(struct.pack('>f', value))
def write_double(self, tag: int, value: float):
self.write_head(tag, TafType.DOUBLE)
self.buf.write(struct.pack('>d', value))
# ---- 字符串 ----
def write_string(self, tag: int, value: str):
encoded = value.encode('utf-8')
length = len(encoded)
if length > 255:
self.write_head(tag, TafType.STRING4)
self.buf.write(struct.pack('>I', length))
else:
self.write_head(tag, TafType.STRING1)
self.buf.write(struct.pack('B', length))
self.buf.write(encoded)
# ---- 字节数组 ----
def write_bytes(self, tag: int, value: bytes):
self.write_head(tag, TafType.SIMPLE_LIST)
self.write_head(0, TafType.INT8) # 元素类型固定 INT8
self.write_int32(0, len(value))
self.buf.write(value)
# ---- 布尔 ----
def write_boolean(self, tag: int, value: bool):
self.write_int8(tag, 1 if value else 0)
# ---- 结构体 ----
def write_struct_begin(self, tag: int):
self.write_head(tag, TafType.STRUCT_BEGIN)
def write_struct_end(self):
self.write_head(0, TafType.STRUCT_END)
def write_struct(self, tag: int, struct_obj):
"""写入结构体对象(需实现 write_to)"""
self.write_struct_begin(tag)
struct_obj.write_to(self)
self.write_struct_end()
# ---- Map ----
def write_map(self, tag: int, value: Dict[Any, Any],
key_writer=None, val_writer=None):
self.write_head(tag, TafType.MAP)
self.write_int32(0, len(value))
for k, v in value.items():
if key_writer:
key_writer(self, 0, k)
else:
self._write_any(0, k)
if val_writer:
val_writer(self, 1, v)
else:
self._write_any(1, v)
# ---- List ----
def write_list(self, tag: int, value: List[Any], item_writer=None):
self.write_head(tag, TafType.LIST)
self.write_int32(0, len(value))
for item in value:
if item_writer:
item_writer(self, 0, item)
else:
self._write_any(0, item)
def _write_any(self, tag: int, value: Any):
if isinstance(value, bool):
self.write_boolean(tag, value)
elif isinstance(value, int):
self.write_int64(tag, value)
elif isinstance(value, float):
self.write_double(tag, value)
elif isinstance(value, str):
self.write_string(tag, value)
elif isinstance(value, bytes):
self.write_bytes(tag, value)
elif isinstance(value, dict):
self.write_map(tag, value)
elif isinstance(value, (list, tuple)):
self.write_list(tag, list(value))
elif hasattr(value, 'write_to'):
self.write_struct(tag, value)
else:
raise TypeError(f"不支持的类型: {type(value)}")
# ============================================================
# 输入流(解码)—— 完整实现,支持所有类型
# ============================================================
class TafInputStream:
"""TAF 解码输入流"""
def __init__(self, data: bytes):
self.buf = io.BytesIO(data)
def peek_head(self) -> Tuple[int, int]:
"""读取 head 但不消费(用于探测)"""
pos = self.buf.tell()
try:
return self.read_head()
finally:
self.buf.seek(pos)
def read_head(self) -> Tuple[int, int]:
"""返回 (tag, type)"""
data = self.buf.read(1)
if not data:
raise EOFError("读取到文件末尾")
b = struct.unpack('B', data)[0]
tag = (b >> 4) & 0x0F
data_type = b & 0x0F
if tag == 15:
data = self.buf.read(1)
if not data:
raise EOFError("读取 tag 扩展字节失败")
tag = struct.unpack('B', data)[0]
return tag, data_type
# ---- 跳过 ----
def skip_field(self, data_type: int):
if data_type == TafType.ZERO:
return
if data_type == TafType.INT8:
self.buf.read(1)
elif data_type == TafType.INT16:
self.buf.read(2)
elif data_type == TafType.INT32:
self.buf.read(4)
elif data_type == TafType.INT64:
self.buf.read(8)
elif data_type == TafType.FLOAT:
self.buf.read(4)
elif data_type == TafType.DOUBLE:
self.buf.read(8)
elif data_type == TafType.STRING1:
length = struct.unpack('B', self.buf.read(1))[0]
self.buf.read(length)
elif data_type == TafType.STRING4:
length = struct.unpack('>I', self.buf.read(4))[0]
self.buf.read(length)
elif data_type == TafType.MAP:
self._skip_map()
elif data_type == TafType.LIST:
self._skip_list()
elif data_type == TafType.SIMPLE_LIST:
# [head(0,INT8)] [int32 length] [bytes]
self.read_head() # 元素类型
length = self._read_int_len()
self.buf.read(length)
elif data_type == TafType.STRUCT_BEGIN:
self._skip_struct()
elif data_type == TafType.STRUCT_END:
pass
else:
raise ValueError(f"未知类型 0x{data_type:02x}")
def _read_int_len(self) -> int:
"""读 map/list 长度(int32 带优化)"""
tag, dtype = self.read_head()
return self._read_int_value(dtype)
def _read_int_value(self, dtype: int) -> int:
if dtype == TafType.ZERO:
return 0
if dtype == TafType.INT8:
return struct.unpack('b', self.buf.read(1))[0]
if dtype == TafType.INT16:
return struct.unpack('>h', self.buf.read(2))[0]
if dtype == TafType.INT32:
return struct.unpack('>i', self.buf.read(4))[0]
if dtype == TafType.INT64:
return struct.unpack('>q', self.buf.read(8))[0]
raise ValueError(f"期望整数, 实际 0x{dtype:02x}")
def _skip_struct(self):
while True:
tag, dtype = self.read_head()
if dtype == TafType.STRUCT_END:
break
self.skip_field(dtype)
def _skip_map(self):
count = self._read_int_len()
for _ in range(count):
_, kt = self.read_head()
self.skip_field(kt)
_, vt = self.read_head()
self.skip_field(vt)
def _skip_list(self):
count = self._read_int_len()
for _ in range(count):
_, it = self.read_head()
self.skip_field(it)
# ---- 带跳过策略的字段读取:找到 tag,否则返回默认 ----
def _find_tag(self, target_tag: int, required: bool) -> Optional[Tuple[int, int]]:
"""逐个读 head,tag 相等则返回,tag 超过则回退并返回 None"""
while True:
pos = self.buf.tell()
tag, dtype = self.read_head()
if tag == target_tag:
return (tag, dtype)
if dtype == TafType.STRUCT_END:
# 回退,让上层处理 STRUCT_END
self.buf.seek(pos)
if required:
raise ValueError(f"未找到 tag={target_tag} (遇到 STRUCT_END)")
return None
if tag > target_tag:
# 超过目标 tag,回退(字段不存在)
self.buf.seek(pos)
if required:
raise ValueError(f"未找到 tag={target_tag} (遇到 tag={tag})")
return None
# tag < target_tag,跳过此字段
self.skip_field(dtype)
# ---- 基本类型读取 ----
def read_int8(self, tag: int, required: bool = False, default: int = 0) -> int:
found = self._find_tag(tag, required)
if not found:
return default
return self._read_int_value(found[1])
def read_int16(self, tag: int, required: bool = False, default: int = 0) -> int:
return self.read_int8(tag, required, default)
def read_int32(self, tag: int, required: bool = False, default: int = 0) -> int:
return self.read_int8(tag, required, default)
def read_int64(self, tag: int, required: bool = False, default: int = 0) -> int:
return self.read_int8(tag, required, default)
def read_uint64(self, tag: int, required: bool = False, default: int = 0) -> int:
found = self._find_tag(tag, required)
if not found:
return default
dtype = found[1]
if dtype == TafType.ZERO:
return 0
if dtype == TafType.INT8:
return struct.unpack('B', self.buf.read(1))[0]
if dtype == TafType.INT16:
return struct.unpack('>H', self.buf.read(2))[0]
if dtype == TafType.INT32:
return struct.unpack('>I', self.buf.read(4))[0]
if dtype == TafType.INT64:
return struct.unpack('>Q', self.buf.read(8))[0]
raise ValueError(f"期望 uint, 实际 0x{dtype:02x}")
def read_boolean(self, tag: int, required: bool = False, default: bool = False) -> bool:
return bool(self.read_int8(tag, required, 1 if default else 0))
def read_float(self, tag: int, required: bool = False, default: float = 0.0) -> float:
found = self._find_tag(tag, required)
if not found:
return default
dtype = found[1]
if dtype == TafType.ZERO:
return 0.0
if dtype == TafType.FLOAT:
return struct.unpack('>f', self.buf.read(4))[0]
if dtype == TafType.DOUBLE:
return struct.unpack('>d', self.buf.read(8))[0]
return float(self._read_int_value(dtype))
def read_double(self, tag: int, required: bool = False, default: float = 0.0) -> float:
return self.read_float(tag, required, default)
def read_string(self, tag: int, required: bool = False, default: str = "") -> str:
found = self._find_tag(tag, required)
if not found:
return default
dtype = found[1]
if dtype == TafType.STRING1:
length = struct.unpack('B', self.buf.read(1))[0]
elif dtype == TafType.STRING4:
length = struct.unpack('>I', self.buf.read(4))[0]
else:
raise ValueError(f"期望 string, 实际 0x{dtype:02x}")
return self.buf.read(length).decode('utf-8', errors='replace')
def read_bytes(self, tag: int, required: bool = False, default: bytes = b'') -> bytes:
found = self._find_tag(tag, required)
if not found:
return default
dtype = found[1]
if dtype != TafType.SIMPLE_LIST:
raise ValueError(f"期望 bytes, 实际 0x{dtype:02x}")
self.read_head() # 元素类型 INT8
length = self._read_int_len()
return self.buf.read(length)
# ---- 复合类型 ----
def read_map(self, tag: int, required: bool = False,
key_reader=None, val_reader=None) -> Dict:
found = self._find_tag(tag, required)
if not found:
return {}
if found[1] != TafType.MAP:
raise ValueError(f"期望 map, 实际 0x{found[1]:02x}")
count = self._read_int_len()
result = {}
for _ in range(count):
_, kt = self.read_head()
k = self._read_value(kt, key_reader)
_, vt = self.read_head()
v = self._read_value(vt, val_reader)
result[k] = v
return result
def read_list(self, tag: int, required: bool = False,
item_reader=None) -> List:
found = self._find_tag(tag, required)
if not found:
return []
if found[1] != TafType.LIST:
raise ValueError(f"期望 list, 实际 0x{found[1]:02x}")
count = self._read_int_len()
result = []
for _ in range(count):
_, it = self.read_head()
result.append(self._read_value(it, item_reader))
return result
def read_struct(self, tag: int, struct_class, required: bool = False):
"""读取结构体,struct_class 需有无参构造 + read_from"""
found = self._find_tag(tag, required)
if not found:
return None
if found[1] != TafType.STRUCT_BEGIN:
raise ValueError(f"期望 struct, 实际 0x{found[1]:02x}")
obj = struct_class()
obj.read_from(self)
# 消费 STRUCT_END
t, dt = self.read_head()
if dt != TafType.STRUCT_END:
raise ValueError(f"期望 STRUCT_END, 实际 0x{dt:02x}")
return obj
def _read_value(self, dtype: int, reader=None):
if reader:
return reader(self, 0)
# 自动推断
if dtype == TafType.STRING1:
length = struct.unpack('B', self.buf.read(1))[0]
return self.buf.read(length).decode('utf-8', errors='replace')
if dtype == TafType.STRING4:
length = struct.unpack('>I', self.buf.read(4))[0]
return self.buf.read(length).decode('utf-8', errors='replace')
if dtype in (TafType.ZERO, TafType.INT8, TafType.INT16,
TafType.INT32, TafType.INT64):
return self._read_int_value(dtype)
if dtype == TafType.STRUCT_BEGIN:
# 未知 struct,跳过
self._skip_struct()
return None
self.skip_field(dtype)
return None
# ============================================================
# 结构体基类
# ============================================================
class TafStruct:
"""TAF 结构体基类:子类实现 write_to / read_from"""
def write_to(self, os: TafOutputStream):
raise NotImplementedError
def read_from(self, ins: TafInputStream):
raise NotImplementedError
def to_dict(self) -> Dict[str, Any]:
"""调试用:转字典"""
return {k: v for k, v in self.__dict__.items()
if not k.startswith('_')}
def __repr__(self):
return f"{self.__class__.__name__}({self.to_dict()})"
+709
View File
@@ -0,0 +1,709 @@
"""
虎牙 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 requestIdHAR 中 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 业务 UserIdHAR 实证: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
+281
View File
@@ -0,0 +1,281 @@
"""
虎牙 Wup 协议 Python 实现
基于前端 WUP 实现逆向
Wup 包结构:
[4字节长度][Wup body]
Wup body = tag1:iVersion, tag2:cPacketType, tag3:iMessageType,
tag4:iRequestId, tag5:sServantName, tag6:sFuncName,
tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map)
sBuffer = Map<"tReq", 编码后的请求结构体>
"""
import struct
from typing import Any, Dict, Optional
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
class WupRequest:
"""Wup 请求对象"""
def __init__(self):
self.iVersion: int = 3 # tag 1
self.cPacketType: int = 0 # tag 2
self.iMessageType: int = 0 # tag 3
self.iRequestId: int = 0 # tag 4
self.sServantName: str = "" # tag 5
self.sFuncName: str = "" # tag 6
self.sBuffer: bytes = b'' # tag 7
self.iTimeout: int = 3000 # tag 8
self.context: Dict[str, str] = {} # tag 9
self.status: Dict[str, str] = {} # tag 10
self.newdata: Dict[str, bytes] = {}
def setServant(self, name: str):
self.sServantName = name
def setFunc(self, name: str):
self.sFuncName = name
def setRequestId(self, req_id: int):
self.iRequestId = req_id
def writeStruct(self, key: str, struct_data):
"""
写入请求参数到 newdata[key]
Args:
key: 键名(通常是 "tReq"
struct_data: TafStruct 对象 / dict / list
"""
os = TafOutputStream()
if isinstance(struct_data, TafStruct) or hasattr(struct_data, 'write_to'):
# 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END
os.write_struct(0, struct_data)
elif isinstance(struct_data, dict):
os.write_struct_begin(0)
for tag_id, (field_name, field_value) in enumerate(struct_data.items()):
self._write_field(os, tag_id, field_value)
os.write_struct_end()
elif isinstance(struct_data, (list, tuple)):
# wsLaunch 等用 list 参数
os.write_head(0, TafType.LIST)
os.write_int32(0, len(struct_data))
for i, item in enumerate(struct_data, start=1):
self._write_field(os, i, item)
else:
raise TypeError(f"不支持的 struct_data 类型: {type(struct_data)}")
self.newdata[key] = os.get_bytes()
def _write_field(self, os: TafOutputStream, tag: int, value: Any):
if isinstance(value, bool):
os.write_boolean(tag, value)
elif isinstance(value, int):
os.write_int64(tag, value)
elif isinstance(value, float):
os.write_double(tag, value)
elif isinstance(value, str):
os.write_string(tag, value)
elif isinstance(value, bytes):
os.write_bytes(tag, value)
elif isinstance(value, dict):
os.write_map(tag, value)
elif isinstance(value, (list, tuple)):
os.write_list(tag, list(value))
elif hasattr(value, 'write_to'):
os.write_struct(tag, value)
else:
raise TypeError(f"不支持的字段类型: {type(value)}")
def encode(self) -> bytes:
"""编码为完整 Wup 包(含长度前缀)"""
# 1. newdata -> Map (tag 0)
data_os = TafOutputStream()
data_os.write_head(0, TafType.MAP)
data_os.write_int32(0, len(self.newdata))
for k, v in self.newdata.items():
data_os.write_string(0, k)
data_os.write_bytes(1, v)
self.sBuffer = data_os.get_bytes()
# 2. Wup 头
wup_os = TafOutputStream()
wup_os.write_int16(1, self.iVersion)
wup_os.write_int8(2, self.cPacketType)
wup_os.write_int32(3, self.iMessageType)
wup_os.write_int32(4, self.iRequestId)
wup_os.write_string(5, self.sServantName)
wup_os.write_string(6, self.sFuncName)
wup_os.write_bytes(7, self.sBuffer)
wup_os.write_int32(8, self.iTimeout)
wup_os.write_map(9, self.context)
wup_os.write_map(10, self.status)
wup_body = wup_os.get_bytes()
# 3. 长度前缀
length = 4 + len(wup_body)
return struct.pack('>I', length) + wup_body
def normalize_wup_payload(data: bytes) -> bytes:
"""去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body"""
if len(data) < 4:
return data
declared_len = struct.unpack('>I', data[0:4])[0]
if declared_len == len(data) or declared_len + 4 == len(data):
return data[4:]
return data
class WupResponse:
"""Wup 响应对象"""
def __init__(self):
self.iVersion: int = 0
self.cPacketType: int = 0
self.iMessageType: int = 0
self.iRequestId: int = 0
self.sServantName: str = ""
self.sFuncName: str = ""
self.sBuffer: bytes = b''
self.iTimeout: int = 0
self.context: Dict[str, str] = {}
self.status: Dict[str, str] = {}
self.newdata: Dict[str, bytes] = {}
def decode(self, data: bytes):
"""解码响应(不包含长度前缀;若含前缀会自动跳过)"""
data = normalize_wup_payload(data)
ins = TafInputStream(data)
# 按字段读,直到结束
try:
while True:
tag, dtype = ins.peek_head()
if tag > 10:
break
if tag == 1:
ins.read_head()
self.iVersion = ins._read_int_value(dtype)
elif tag == 2:
ins.read_head()
self.cPacketType = ins._read_int_value(dtype)
elif tag == 3:
ins.read_head()
self.iMessageType = ins._read_int_value(dtype)
elif tag == 4:
ins.read_head()
self.iRequestId = ins._read_int_value(dtype)
elif tag == 5:
ins.read_head()
self.sServantName = _read_string_value(ins, dtype)
elif tag == 6:
ins.read_head()
self.sFuncName = _read_string_value(ins, dtype)
elif tag == 7:
ins.read_head()
self.sBuffer = _read_bytes_value(ins, dtype)
self._decode_buffer()
elif tag == 8:
ins.read_head()
self.iTimeout = ins._read_int_value(dtype)
elif tag == 9:
# context map
ins.read_head()
self.context = _read_map_value(ins, dtype)
elif tag == 10:
ins.read_head()
self.status = _read_map_value(ins, dtype)
break
else:
ins.read_head()
ins.skip_field(dtype)
except EOFError:
pass
def _decode_buffer(self):
"""解析 sBuffer 里的 newdata Map"""
if not self.sBuffer:
return
ins = TafInputStream(self.sBuffer)
try:
tag, dtype = ins.read_head()
if tag == 0 and dtype == TafType.MAP:
count = ins._read_int_len()
for _ in range(count):
_, kt = ins.read_head()
key = _read_string_value(ins, kt)
_, vt = ins.read_head()
val = _read_bytes_value(ins, vt)
self.newdata[key] = val
except Exception as e:
print(f"[WupResponse] 解析 newdata 失败: {e}")
def readStruct(self, key: str, struct_class=None):
"""
读取响应结构体
Args:
key: "tRsp" / "tResp" / "tReq"(响应里通常是 tRsp
struct_class: 结构体类(需实现 read_from),None 则返回原始 bytes
"""
data = self.newdata.get(key)
if not data and key == "tRsp":
data = self.newdata.get("tResp")
if not data and key == "tResp":
data = self.newdata.get("tRsp")
if not data:
return None
if struct_class is None:
return data
ins = TafInputStream(data)
# newdata 里的结构体以 STRUCT_BEGIN 开头
try:
tag, dtype = ins.peek_head()
if dtype == TafType.STRUCT_BEGIN:
ins.read_head() # 消费 STRUCT_BEGIN
obj = struct_class()
obj.read_from(ins)
return obj
except Exception as e:
print(f"[WupResponse] 解析 {struct_class.__name__} 失败: {e}")
return None
# ============================================================
# 辅助:按已知 dtype 读取值
# ============================================================
def _read_string_value(ins: TafInputStream, dtype: int) -> str:
if dtype == TafType.STRING1:
length = struct.unpack('B', ins.buf.read(1))[0]
elif dtype == TafType.STRING4:
length = struct.unpack('>I', ins.buf.read(4))[0]
else:
return ""
return ins.buf.read(length).decode('utf-8', errors='replace')
def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes:
if dtype != TafType.SIMPLE_LIST:
return b''
ins.read_head() # 元素类型 INT8
length = ins._read_int_len()
return ins.buf.read(length)
def _read_map_value(ins: TafInputStream, dtype: int) -> Dict:
if dtype != TafType.MAP:
return {}
count = ins._read_int_len()
result = {}
for _ in range(count):
_, kt = ins.read_head()
k = _read_string_value(ins, kt)
_, vt = ins.read_head()
v = _read_string_value(ins, vt) if vt in (TafType.STRING1, TafType.STRING4) else ""
result[k] = v
return result