diff --git a/artifacts/guid_session_consistency/DIFF_FILE b/artifacts/guid_session_consistency/DIFF_FILE new file mode 100644 index 0000000..0f2dbd0 --- /dev/null +++ b/artifacts/guid_session_consistency/DIFF_FILE @@ -0,0 +1,188 @@ +diff --git a/core/huya/elite_session.py b/core/huya/elite_session.py +index 31f2449..0eb740c 100644 +--- a/core/huya/elite_session.py ++++ b/core/huya/elite_session.py +@@ -94,6 +94,13 @@ class HuyaEliteWssSession: + ) + if not initialized: + raise RuntimeError(f"虎牙 {kind} WSS 初始化失败") ++ if kind == "activity" and self.client._launch_guid: ++ # wsLaunch 的 sGuid 是当前连接的真实会话值,供后续业务 Cookie ++ # 与 HTTP baseinfo 继续复用,避免沿用登录时的旧 guid。 ++ self.cookie = self.client._cookie_with_guid( ++ self.cookie, self.client._launch_guid ++ ) ++ self.guid = self.client._launch_guid + except Exception: + self.close() + raise +diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py +index 929c622..c665afd 100644 +--- a/core/huya/wss_client.py ++++ b/core/huya/wss_client.py +@@ -26,6 +26,7 @@ from typing import Any, cast + + import websockets + ++from .cookie_utils import cookie_pairs, normalize_cookie_pairs + from .frame_decoder import _decode_taf_struct, _truncate, format_wss_log + from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType + from .wup_protocol import WupRequest, WupResponse +@@ -192,6 +193,26 @@ class HuyaWssClient: + return normalized + return f"{normalized}; {cookie}" + ++ @staticmethod ++ def _cookie_with_guid(cookie: str, guid: str) -> str: ++ """用当前 WSS 会话的 sGuid 替换 Cookie 中的旧 guid。""" ++ value = str(guid or "").strip() ++ if not value: ++ return cookie or "" ++ pairs = cookie_pairs(cookie) ++ replaced = False ++ normalized: list[tuple[str, str]] = [] ++ for key, item_value in pairs: ++ if key == "guid": ++ if not replaced: ++ normalized.append((key, value)) ++ replaced = True ++ continue ++ normalized.append((key, item_value)) ++ if not replaced: ++ normalized.append(("guid", value)) ++ return normalize_cookie_pairs(normalized) ++ + @staticmethod + def _build_biz_user(uid: int, cookie: str): + """构造 WSS 业务 UserId(HAR 实证:guid 为空,cookie 不额外加前缀)""" +@@ -389,7 +410,12 @@ class HuyaWssClient: + if launch_rsp is None: + self.logger("[WSS] 活动 wsLaunch 无响应") + return False +- await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ) ++ # wsLaunch 会为当前连接签发新的 sGuid。AUTH、getConfig 和后续业务 ++ # 请求必须使用同一个值;登录 Cookie 里的旧 guid 不能继续复用。 ++ if self._launch_guid: ++ cookie = self._cookie_with_guid(cookie, self._launch_guid) ++ guid = self._launch_guid ++ await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ, guid=guid) + # 9.1 抓包:getConfig 使用 wsLaunch 响应返回的新 guid;业务 UserId 仍为空 guid。 + config_rsp = await self.call_get_config_activity( + uid, cookie, guid=self._launch_guid +@@ -570,7 +596,13 @@ class HuyaWssClient: + return body[5 : 5 + wup_len] + return body + +- async def send_auth(self, uid: int, cookie: str, sequence: int = SEQ_WSLAUNCH): ++ async def send_auth( ++ self, ++ uid: int, ++ cookie: str, ++ sequence: int = SEQ_WSLAUNCH, ++ guid: str = "", ++ ): + """cmd 0x0a AUTH — 对齐 9.1 抓包(828 活动帧)。 + + 9.1 活动 AUTH 结构(逐字节核验): +@@ -591,8 +623,8 @@ class HuyaWssClient: + os.write_string(1, WSS_COOKIE_UA) + os.write_string(2, HuyaWssClient._normalize_biz_cookie(cookie)) + guid_match = re.search(r"(?:^|;\s*)guid=([^;]+)", cookie or "") +- guid = (guid_match.group(1).strip() if guid_match else "") or self._launch_guid +- os.write_string(3, guid or "") ++ auth_guid = (guid or "").strip() or (guid_match.group(1).strip() if guid_match else "") or self._launch_guid ++ os.write_string(3, auth_guid) + os.write_int8(4, 1) + os.write_string(5, "HUYA&ZH&2052") + os.write_string(6, "") +diff --git "a/docs/HUYA_COOKIE\350\267\257\347\272\277\347\212\266\346\200\201.md" "b/docs/HUYA_COOKIE\350\267\257\347\272\277\347\212\266\346\200\201.md" +index 69e5423..d67e890 100644 +--- "a/docs/HUYA_COOKIE\350\267\257\347\272\277\347\212\266\346\200\201.md" ++++ "b/docs/HUYA_COOKIE\350\267\257\347\272\277\347\212\266\346\200\201.md" +@@ -15,7 +15,7 @@ App 登录可以得到新鲜的 `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken + | `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken` | 已完成 | App 登录与 QR 绑定链 | + | `sdid`、`hdid` | 已完成 | hydevice / `df/collect` | + | `udb_deviceid`、`udb_guiddata`、`udb_anobiztoken`、`udb_anouid` | 已完成 | `anon_device.py` | +-| `guid` | 会话路径完成,回退算法未完全确认 | `wsLaunch` 返回值优先;回退值为结构仿造 | ++| `guid` | 会话路径已闭合 | 活动 `wsLaunch` 每次签发 32 位 `sGuid`;初始化时统一覆盖 AUTH、getConfig 和后续业务 Cookie,`956a/966a/976a` 等中间段不是本地版本常量 | + | `__yamid_new` | 已完成 | `room_normal.js` `generate32` Python 复刻 | + | `game_did` | 已完成 | 35 位、63 字符集规则复刻 | + | `_yasids` | 格式完成,精确共享种子未确认 | 与 `__yamid_new` 使用同类时钟生成器 | +@@ -47,10 +47,9 @@ SDK/Python 输出:d07a45de567322edcd42312f38e610be + ## 尚未闭合的工程项 + + 1. 将真实 WebView/Chrome 的 `getFeature()` 结果接入登录调用,替代当前的设备画像近似值。 +-2. 明确 `guid` 的 `956a` / `966a` 版本差异和无 `wsLaunch` 时的真实生成规则。 +-3. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 +-4. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 +-5. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 ++2. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 ++3. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 ++4. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 + + ## 证据入口 + +diff --git a/docs/HUYA_WEB_FIELD_TRACE.md b/docs/HUYA_WEB_FIELD_TRACE.md +index 8a7f9fe..4c82581 100644 +--- a/docs/HUYA_WEB_FIELD_TRACE.md ++++ b/docs/HUYA_WEB_FIELD_TRACE.md +@@ -77,7 +77,7 @@ qimeisdk-web 0.6.3 源码已定位: + + | 字段 | 来源 | 风控关联性 | 建议 | + | --- | --- | --- | --- | +-| `guid` | 浏览器脚本(有固定段 `0a..966a`)| 高(设备与会话绑定) | **必须按结构仿造或复用 wsLaunch 会话值**;随机纯 hex 与真实结构不符 | ++| `guid` | 活动 `wsLaunch` 响应 `sGuid`;页面 SDK 负责持久化 | 高(设备与会话绑定) | **优先复用当前 wsLaunch 会话值**;`956a/966a/976a` 等中间段随服务端返回变化,不能固化为版本常量 | + | `__yamid_new` | 浏览器脚本(CBCC 前缀+种子)| 高(与 rootsid 同源)| `room_normal.js` `generate32` 已复刻;精确 rootsid 共享计数仍待确认 | + | `_qimei_uuid42` | QIMEI SDK 0.6.3 VM 生成器 `e=6064` | 中 | Python 已按时间编码、CRC32 和随机半字节逐项复刻 | + | `_qimei_fingerprint` | QIMEI SDK 0.6.3 x64hash128(feature_string, 31) | 中 | Python 已复刻,输入字段见路线状态文档 | +@@ -87,7 +87,7 @@ qimeisdk-web 0.6.3 源码已定位: + + **当时的下一步记录(部分已完成,保留作历史)**: + 1. `_qimei_fingerprint` 已改为浏览器能力特征串的 `x64hash128`,见路线状态文档。 +-2. `guid` 已优先使用 `wsLaunch` 会话值,回退固定段仍待确认。 ++2. `guid` 已统一使用 `wsLaunch` 会话值;活动 AUTH、getConfig 与后续业务 Cookie 均复用同一返回值。 + 3. `__yamid_new` 已定位 `room_normal.js` `generate32` 并完成 Python 复刻。 + ## 六、核心字段生成算法逆向结果(2026-09-01 晚,浏览器实测 + 源码定位) + +diff --git a/tests/test_huya_elite_protocol.py b/tests/test_huya_elite_protocol.py +index 71a10e6..ce4360d 100644 +--- a/tests/test_huya_elite_protocol.py ++++ b/tests/test_huya_elite_protocol.py +@@ -146,3 +146,37 @@ def test_activity_auth_frame_matches_latest_capture_layout(): + + # body 必须与 9.1 828 AUTH 同样以 TAIL_BYTES 收尾 + assert message.body[-len(TAIL_BYTES):] == TAIL_BYTES ++ ++ ++def test_activity_cookie_guid_is_replaced_by_launch_guid(): ++ old = "0a" + "1" * 30 ++ new = "0a" + "2" * 30 ++ cookie = f"udb_uid=1; guid={old}; x=y; guid={old}" ++ ++ result = HuyaWssClient._cookie_with_guid(cookie, new) ++ ++ assert result == f"udb_uid=1; guid={new}; x=y" ++ ++ ++def test_activity_auth_accepts_explicit_launch_guid_over_cookie_guid(): ++ async def build_frame(): ++ client = HuyaWssClient() ++ client._activity_mode = True ++ client.ws = type("FakeWs", (), {"send": AsyncMock()})() ++ await client.send_auth( ++ 1199664135026, ++ "guid=" + "0a" * 16 + "; udb_cred=" + "x" * 300, ++ sequence=0x1D000109, ++ guid="0b" * 16, ++ ) ++ return client.ws.send.call_args.args[0] ++ ++ message = WssMessage.decode(asyncio.run(build_frame())) ++ stream = TafInputStream(message.body[: -len(TAIL_BYTES)]) ++ assert stream.read_head() == (3, TafType.ZERO) ++ stream.read_int64(0) ++ stream.read_string(1) ++ assert stream.read_head() == (2, TafType.STRING4) ++ cookie_len = int.from_bytes(stream.buf.read(4), "big") ++ stream.buf.read(cookie_len) ++ assert stream.read_string(3) == "0b" * 16 diff --git a/artifacts/guid_session_consistency/MODIFIED_FILE b/artifacts/guid_session_consistency/MODIFIED_FILE new file mode 100644 index 0000000..c665afd --- /dev/null +++ b/artifacts/guid_session_consistency/MODIFIED_FILE @@ -0,0 +1,1164 @@ +""" +虎牙 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 collections.abc import Callable +from typing import Any, cast + +import websockets + +from .cookie_utils import cookie_pairs, normalize_cookie_pairs +from .frame_decoder import _decode_taf_struct, _truncate, format_wss_log +from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType +from .wup_protocol import WupRequest, WupResponse + +SHOP_WS_HOST = "77bc035c-ws.va.huya.com" +ACTIVITY_WS_HOST = "d35bf373-ws.va.huya.com" +# 商城端点 baseinfo (conn4, h5_/index.html) — 商城业务 shopMiddleUI 走此通道 +# wsLaunch tReq: lUid=0, sGuid="", sUA="webh5&0.0.1&websocket&&h5_/index.html" +SHOP_BASEINFO = "DBYAJiV3ZWJoNSYwLjAuMSZ3ZWJzb2NrZXQmJmg1Xy9pbmRleC5odG1sNgxIVVlBJlpIJjIwNTJGAFYAbHYAhgCWAKgM" + +TAIL_BYTES = bytes.fromhex("2c36004c5c6600") +BIG_TAIL_SUFFIX = bytes.fromhex("4c5c6600") +WSS_BIZ_UA = "web&1.0.0&huya" +WSS_COOKIE_UA = "webh5&0.0.1&websocket&&diypc_52775" +WSS_SHOP_SCENE = 4 + + +class _ActivityConfigReq(TafStruct): + def __init__(self, user): + self.user = user + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.user) + os.write_map(1, {}) + os.write_string(2, "huya") + os.write_list(3, []) + + def read_from(self, _ins): + pass + +# seq 按消息类型固定 (HAR 实证,跨两次抓包一致) +SEQ_HEARTBEAT = 0x1D00000D +SEQ_WSLAUNCH = 0x1D000078 +SEQ_GETCONFIG = 0x1D000076 +SEQ_REGISTER = 0x1D00003C +SEQ_CONFIRM = 0x1D000002 +SEQ_BUSINESS = 0x1D000106 # 业务 RPC (HAR 7.4 实证:业务帧固定使用该 seq) +SEQ_ACTIVITY_WSLAUNCH = 0x1D000100 +SEQ_ACTIVITY_BIZ = 0x1D000109 + + +class WssCommand: + RPC_REQUEST = 0x03 + RPC_RESPONSE = 0x04 + AUTH = 0x0A + SERVER_PUSH1 = 0x0B + HEARTBEAT_SEND = 0x10 + HEARTBEAT_RECV = 0x11 + CONFIRM_SEND = 0x17 + SERVER_PUSH2 = 0x18 + REGISTER = 0x21 + + +class WssMessage: + def __init__(self, command: int, sequence: int, body: bytes): + self.command = command + self.sequence = sequence + self.body = body + + def encode(self) -> bytes: + header = struct.pack(">BBI", 0x00, self.command, self.sequence) + return header + self.body + + @classmethod + def decode(cls, data: bytes) -> "WssMessage": + if len(data) < 6: + raise ValueError(f"消息太短: {len(data)} bytes") + _version, command = struct.unpack(">BB", data[0:2]) + sequence = struct.unpack(">I", data[2:6])[0] + body = data[6:] + return cls(command=command, sequence=sequence, body=body) + + +class HuyaWssClient: + """虎牙 WSS 客户端 — 商城通道""" + + def __init__( + self, baseinfo: str | None = None, logger: Callable[[str], None] | None = None + ): + self.baseinfo = baseinfo or SHOP_BASEINFO + self.ws: Any = None + self._biz_seq = SEQ_BUSINESS # 业务 RPC 递增用 + self._wup_req_id = 8 + self.rpc_queue = deque() + self.logger = logger or print + self._recv_task = None + self._heartbeat_task = None + self._launch_guid = "" + self._launch_ip = "" + self._activity_mode = False + + def _next_biz_seq(self) -> int: + """业务 RPC 的 frame seq。 + + 浏览器在商城 WSS 上连续发送多个业务 RPC 时,frame seq 都保持 + 0x1d000106;真正区分请求依赖 WUP requestId。 + """ + return SEQ_ACTIVITY_BIZ if self._activity_mode else SEQ_BUSINESS + + def _next_wup_req_id(self) -> int: + """业务 WUP requestId,HAR 中 getGoodsInfoV5 从 8 开始递增""" + req_id = self._wup_req_id + self._wup_req_id += 1 + return req_id + + @staticmethod + def _make_big_tail() -> bytes: + """生成 WSS 大包尾部 trace(格式与浏览器 HAR 一致)""" + trace = f"{random.getrandbits(64):016x}" + return f",6%{trace}:{trace}:0:3".encode("ascii") + BIG_TAIL_SUFFIX + + @staticmethod + def _encode_rpc_body(wup_data: bytes) -> bytes: + """按浏览器规则封装 WSS RPC body + + 小包: [4B WUP长度][WUP body] + TAIL_BYTES + 大包: [1B len低字节][4B WUP body长度][裸WUP body] + trace尾部 + """ + if len(wup_data) < 4: + return wup_data + TAIL_BYTES + declared_len = int.from_bytes(wup_data[:4], "big") + if declared_len == len(wup_data): + wup_body = wup_data[4:] + else: + wup_body = wup_data + if len(wup_body) <= 0xFF: + return wup_data + TAIL_BYTES + # 大包头里的长度使用原始 WUP 包长度(含 4B 长度前缀),但正文只放裸 WUP body; + # 浏览器随后用 trace 尾部的前 4 字节补足该长度窗口。 + packet_len = len(wup_data) + return ( + bytes([packet_len & 0xFF]) + + packet_len.to_bytes(4, "big") + + wup_body + + HuyaWssClient._make_big_tail() + ) + + @staticmethod + def _encode_activity_launch_body(wup_data: bytes) -> bytes: + """活动 wsLaunch 使用浏览器的固定大包头,即使包体小于 255B。""" + if len(wup_data) < 4: + return wup_data + TAIL_BYTES + packet_len = len(wup_data) + return ( + bytes([packet_len & 0xFF]) + + packet_len.to_bytes(4, "big") + + wup_data[4:] + + TAIL_BYTES + ) + + @staticmethod + def _normalize_biz_cookie(cookie: str) -> str: + """业务 RPC 的 huya_ua Cookie 值按浏览器 WSS 抓包修正""" + cookie = (cookie or "").strip() + normalized = f"huya_ua={WSS_COOKIE_UA}" + if re.search(r"(?:^|;\s*)huya_ua=", cookie): + return re.sub( + r"(^|;\s*)huya_ua=[^;]*", + lambda m: f"{m.group(1)}{normalized}", + cookie, + count=1, + ) + if not cookie: + return normalized + return f"{normalized}; {cookie}" + + @staticmethod + def _cookie_with_guid(cookie: str, guid: str) -> str: + """用当前 WSS 会话的 sGuid 替换 Cookie 中的旧 guid。""" + value = str(guid or "").strip() + if not value: + return cookie or "" + pairs = cookie_pairs(cookie) + replaced = False + normalized: list[tuple[str, str]] = [] + for key, item_value in pairs: + if key == "guid": + if not replaced: + normalized.append((key, value)) + replaced = True + continue + normalized.append((key, item_value)) + if not replaced: + normalized.append(("guid", value)) + return normalize_cookie_pairs(normalized) + + @staticmethod + def _build_biz_user(uid: int, cookie: str): + """构造 WSS 业务 UserId(HAR 实证:guid 为空,cookie 不额外加前缀)""" + from .shop_structs import UserId + + user = UserId() + user.lUid = uid + user.sGuid = "" + user.sToken = "" + user.sHuYaUA = WSS_BIZ_UA + user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie) + user.iTokenType = 0 + user.sDeviceInfo = "" + user.sQIMEI = "" + return user + + @staticmethod + def _build_shop_app(source_id: str = "yellowcarlist", scene: int = 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 = "", + origin: str = "https://m-shop.yaoguo.com", + ): + from urllib.parse import quote + + url = f"wss://{host}/?baseinfo={quote(self.baseinfo)}" + self.logger(f"[WSS] 正在连接 {host} ...") + self.logger(f"[WSS] URL: {url[:100]}...") + + BROWSER_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/149.0.0.0 Safari/537.36" + ) + headers = {"Accept-Language": "zh-CN,zh;q=0.9"} + if cookie: + headers["Cookie"] = cookie + + try: + self.ws = await asyncio.wait_for( + websockets.connect( + url, + origin=cast(Any, origin), + user_agent_header=BROWSER_UA, + additional_headers=headers, + open_timeout=timeout, + # 不信任环境代理:web 进程可能带 SOCKS 代理变量, + # 会导致 WSS 直连失败(需 python-socks);虎牙国内直连。 + proxy=None, + ), + timeout=timeout, + ) + except TimeoutError: + self.logger(f"[WSS] 连接超时({timeout}s)") + raise + except Exception as e: + self.logger(f"[WSS] 连接失败: {type(e).__name__}: {e}") + raise + + self.logger(f"[WSS] 握手成功, 实际连接: {self.ws.remote_address}") + self._recv_task = asyncio.create_task(self._recv_loop()) + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + async def disconnect(self): + if self._heartbeat_task: + self._heartbeat_task.cancel() + if self._recv_task: + self._recv_task.cancel() + if self.ws: + await self.ws.close() + self.ws = None + self.logger("[WSS] 已断开") + + async def _recv_loop(self): + try: + async for raw in self.ws: + try: + raw_bytes = raw if isinstance(raw, bytes) else raw.encode() + msg = WssMessage.decode(raw_bytes) + self.logger( + format_wss_log(msg.body, msg.command, msg.sequence, "收") + ) + await self._handle_message(msg) + except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 + self.logger( + f"[WSS] 解析消息失败: {e} raw_hex={raw_bytes[:50].hex()}" + ) + except asyncio.CancelledError: + pass + except websockets.exceptions.ConnectionClosed as e: + self.logger(f"[WSS] 连接关闭: code={e.code} reason={e.reason}") + except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 + self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}") + + async def _handle_message(self, msg: WssMessage): + if msg.command == WssCommand.RPC_RESPONSE: + if self.rpc_queue: + future = self.rpc_queue.popleft() + if not future.done(): + future.set_result(msg.body) + else: + self.logger(f"[WSS] 未匹配的 RPC 响应 seq={msg.sequence}") + elif msg.command == WssCommand.HEARTBEAT_RECV: + pass # 心跳已在上层 _recv_loop 打过日志 + elif msg.command in (WssCommand.SERVER_PUSH1, WssCommand.SERVER_PUSH2): + pass # 推送已在上层打过日志 + else: + pass # 未知消息已在上层打过日志 + + async def _heartbeat_loop(self): + try: + while True: + await asyncio.sleep(8) + await self.send_heartbeat() + except asyncio.CancelledError: + pass + except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 + self.logger(f"[WSS] 心跳循环异常: {e}") + + async def send_heartbeat(self): + # body = LIST(tag0,count=1) + 元素{tag0="live:0", tag1=""} + TAIL + os = TafOutputStream() + os.write_head(0, TafType.LIST) # 09 + os.write_int32(0, 1) # 00 01 (count=1) + os.write_string(0, "live:0") # 06 06 live:0 + os.buf.write(b"\x16\x00") # 16 00 (tag1 "") + body = os.get_bytes() + TAIL_BYTES + msg = WssMessage( + command=WssCommand.HEARTBEAT_SEND, sequence=SEQ_HEARTBEAT, body=body + ) + self.logger( + format_wss_log(body, WssCommand.HEARTBEAT_SEND, SEQ_HEARTBEAT, "发") + ) + await self.ws.send(msg.encode()) + + async def initialize(self, uid: int, guid: str, cookie: str): + """商城端点初始化: 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 initialize_activity(self, uid: int, guid: str, cookie: str): + """活动通道初始化:heartbeat -> wsLaunch -> AUTH -> getConfig -> confirm。""" + self._activity_mode = True + await self.send_heartbeat() + launch_rsp = await self.call_ws_launch_activity(uid, guid) + if launch_rsp is None: + self.logger("[WSS] 活动 wsLaunch 无响应") + return False + # wsLaunch 会为当前连接签发新的 sGuid。AUTH、getConfig 和后续业务 + # 请求必须使用同一个值;登录 Cookie 里的旧 guid 不能继续复用。 + if self._launch_guid: + cookie = self._cookie_with_guid(cookie, self._launch_guid) + guid = self._launch_guid + await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ, guid=guid) + # 9.1 抓包:getConfig 使用 wsLaunch 响应返回的新 guid;业务 UserId 仍为空 guid。 + config_rsp = await self.call_get_config_activity( + uid, cookie, guid=self._launch_guid + ) + if config_rsp is None: + self.logger("[WSS] 活动 getConfig 无响应,继续发送 confirm") + await self.send_confirm() + await asyncio.sleep(0.1) + return True + + async def call_ws_launch_activity(self, uid: int, guid: str = "", timeout: float = 10.0): + ua = WSS_COOKIE_UA + os = TafOutputStream() + os.write_struct_begin(0) + os.write_int64(0, int(uid or 0)) + os.write_string(1, guid or "") + os.write_string(2, ua) + os.write_string(3, "HUYA&ZH&2052") + os.write_struct_begin(4) + for i in range(5): + os.write_string(i, "") + os.write_struct_end() + os.write_struct_end() + wup = WupRequest() + wup.setServant("launch") + wup.setFunc("wsLaunch") + wup.setRequestId(-1) + wup.iTimeout = 0 + wup.newdata["tReq"] = os.get_bytes() + body = self._encode_activity_launch_body(wup.encode()) + msg = WssMessage(WssCommand.RPC_REQUEST, SEQ_ACTIVITY_WSLAUNCH, body) + future = asyncio.Future() + self.rpc_queue.append(future) + self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, SEQ_ACTIVITY_WSLAUNCH, "发")) + await self.ws.send(msg.encode()) + try: + response = await asyncio.wait_for(future, timeout) + except TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + return None + self.logger(format_wss_log(response, WssCommand.RPC_RESPONSE, SEQ_ACTIVITY_WSLAUNCH, "收")) + self._parse_launch_response(response) + return response + + async def call_get_config_activity( + self, uid: int, cookie: str, timeout: float = 10.0, guid: str = "" + ): + user = self._build_activity_user(uid, cookie, guid=guid) + req = _ActivityConfigReq(user) + return await self.call_rpc("mobileui", "getConfig", req, None, timeout=timeout) + + @staticmethod + def _build_activity_user(uid: int, cookie: str, guid: str = ""): + from .activity_structs import ActivityUserId + + user = ActivityUserId() + user.lUid = int(uid or 0) + user.sGuid = guid or "" + user.sHuYaUA = WSS_COOKIE_UA + user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie) + return user + + async def call_ws_launch_shop(self, 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 TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger("[✗] wsLaunch 超时") + return None + + self._parse_launch_response(body) + return body + + def _parse_launch_response(self, body: bytes): + """解析 wsLaunch 响应 (WSLaunchRsp: tag0=sGuid, tag1=sClientIp)""" + try: + # 从 body 中提取干净的 WUP 包(去掉尾部垃圾字节) + wup = self._extract_wup(body) + wup_resp = WupResponse() + wup_resp.decode(wup) + treq = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp") + if not treq: + self.logger( + f"[RPC] wsLaunch 响应无 tRsp, keys={list(wup_resp.newdata.keys())}" + ) + return + ins = TafInputStream(treq) + _tag, dtype = ins.peek_head() + if dtype != 0x0A: # STRUCT_BEGIN + self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}") + return + ins.read_head() + while True: + try: + ftag, ftype = ins.peek_head() + except EOFError: + break + if ftype == 0x0B: # STRUCT_END + ins.read_head() + break + ins.read_head() + if ftag == 0 and ftype in (0x06, 0x07): + ln = ( + ins.buf.read(1)[0] + if ftype == 0x06 + else int.from_bytes(ins.buf.read(4), "big") + ) + self._launch_guid = ins.buf.read(ln).decode("utf-8", "replace") + elif ftag == 1 and ftype in (0x06, 0x07): + ln = ( + ins.buf.read(1)[0] + if ftype == 0x06 + else int.from_bytes(ins.buf.read(4), "big") + ) + self._launch_ip = ins.buf.read(ln).decode("utf-8", "replace") + else: + ins.skip_field(ftype) + self.logger( + f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}" + ) + except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 + self.logger(f"[RPC] wsLaunch 响应解析失败: {e}") + + @staticmethod + def _extract_wup(body: bytes) -> bytes: + """从 WSS 响应 body 中提取干净的 WUP 包 + + 响应 body 格式: + 小包: [4B BE total_len][wup_body][tail] + 大包: [1B prefix][4B BE wup_body_len][wup_body][tail] + """ + if len(body) < 5: + return body + # 大包格式需优先判断;prefix 可能是 0x00,容易被误判成小包长度 8。 + wup_len = int.from_bytes(body[1:5], "big") + if 5 + wup_len <= len(body) and body[5:7] == b"\x10\x03": + return body[5 : 5 + wup_len] + # 尝试 4B total_len 小包格式 + total_len = int.from_bytes(body[0:4], "big") + if 8 <= total_len <= len(body) and body[4:6] == b"\x10\x03": + # total_len 包含自身 4B + return body[:total_len] + if 5 + wup_len <= len(body): + return body[5 : 5 + wup_len] + return body + + async def send_auth( + self, + uid: int, + cookie: str, + sequence: int = SEQ_WSLAUNCH, + guid: str = "", + ): + """cmd 0x0a AUTH — 对齐 9.1 抓包(828 活动帧)。 + + 9.1 活动 AUTH 结构(逐字节核验): + tag3 ZERO ← 首字段(活动通道) + tag0 INT64 = uid + tag1 STRING1 = UA + tag2 STRING4 = cookie(含 huya_ua= 前缀) + tag3 STRING1 = guid(32hex,9.1 中为页面 Cookie 的 guid) + tag4 INT8 = 1 + tag5 STRING1 = "HUYA&ZH&2052" + tag6 STRING1 = "" + += TAIL_BYTES(tag2 ZERO/tag3 ""/tag4 ZERO/tag5 ZERO/tag6 "") + """ + os = TafOutputStream() + if self._activity_mode: + os.write_head(3, TafType.ZERO) + os.write_int64(0, int(uid or 0)) + os.write_string(1, WSS_COOKIE_UA) + os.write_string(2, HuyaWssClient._normalize_biz_cookie(cookie)) + guid_match = re.search(r"(?:^|;\s*)guid=([^;]+)", cookie or "") + auth_guid = (guid or "").strip() or (guid_match.group(1).strip() if guid_match else "") or self._launch_guid + os.write_string(3, auth_guid) + os.write_int8(4, 1) + os.write_string(5, "HUYA&ZH&2052") + os.write_string(6, "") + auth_body = os.get_bytes() + TAIL_BYTES + msg = WssMessage( + command=WssCommand.AUTH, + sequence=sequence, + body=auth_body, + ) + await self.ws.send(msg.encode()) + self.logger( + format_wss_log( + auth_body, + WssCommand.AUTH, + sequence, + "发", + ) + ) + + async def call_get_config_shop(self, 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 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 TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger(f"[✗] {service}.{method} 超时") + return None + + wup_resp = WupResponse() + wup_resp.decode(self._extract_wup(body)) + # 响应日志已由 _recv_loop 打出,此处只打解码后的业务字段 + for key in ("tRsp", "tResp"): + data = wup_resp.newdata.get(key) + if data and isinstance(data, bytes) and len(data) > 0: + try: + ins = TafInputStream(data) + _tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_BEGIN: + ins.read_head() + decoded = _decode_taf_struct(ins) + self.logger( + f"[←] {service}.{method} {key}: {_truncate(decoded)}" + ) + except Exception as exc: # noqa: BLE001 + self.logger( + f"[debug] WUP 响应字段解码失败: {service}.{method}.{key}: {exc}" + ) + + if rsp_class is None: + return body + + result = wup_resp.readStruct("tRsp", rsp_class) + if result is None: + result = wup_resp.readStruct("tResp", rsp_class) + return result + + async def get_goods_info( + self, + uid: int, + guid: str, + cookie: str, + pid: int, + spu_id: str, + sku_id: int = 0, + game_id: str = "", + source_id: str = "yellowcarlist", + 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 get_act_info(self, act_id: int): + from .activity_structs import GetActInfoReq, GetActInfoResp + + req = GetActInfoReq() + req.actId = int(act_id or 0) + return await self.call_rpc("webActUI", "getActInfo", req, GetActInfoResp) + + async def get_act_task_detail(self, uid: int, cookie: str, act_id: int): + from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp + + req = GetActTaskDetailReq() + req.userId = self._build_activity_user(uid, cookie) + req.actId = int(act_id or 0) + return await self.call_rpc("webActUI", "getActTaskDetail", req, GetActTaskDetailResp) + + async def get_act_user_task_detail(self, uid: int, cookie: str, act_id: int): + from .activity_structs import GetActUserTaskDetailReq, GetActUserTaskDetailResp + + req = GetActUserTaskDetailReq() + req.userId = self._build_activity_user(uid, cookie) + req.actId = int(act_id or 0) + return await self.call_rpc("webActUI", "getActUserTaskDetail", req, GetActUserTaskDetailResp) + + async def get_user_score(self, uid: int, cookie: str, sid: int): + from .activity_structs import GetUserScoreReq, GetUserScoreResp + + req = GetUserScoreReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = int(sid or 0) + return await self.call_rpc("webActUI", "getUserScore", req, GetUserScoreResp) + + async def get_act_prize_list(self, uid: int, cookie: str, sid: int): + from .activity_structs import GetActPrizeListReq, GetActPrizeListResp + + req = GetActPrizeListReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = int(sid or 0) + return await self.call_rpc("webActUI", "getActPrizeList", req, GetActPrizeListResp) + + async def get_act_prize_detail(self, uid: int, cookie: str, sid: int, pid: int): + from .activity_structs import GetActPrizeDetailReq, GetActPrizeDetailResp + + req = GetActPrizeDetailReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = int(sid or 0) + req.pid = int(pid or 0) + return await self.call_rpc("webActUI", "getActPrizeDetail", req, GetActPrizeDetailResp) + + async def score_exchange_prize(self, uid: int, cookie: str, sid: int, pid: int): + from .activity_structs import ScoreExchangePrizeReq, ScoreExchangePrizeResp + + req = ScoreExchangePrizeReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = int(sid or 0) + req.pid = int(pid or 0) + return await self.call_rpc("webActUI", "scoreExchangePrize", req, ScoreExchangePrizeResp) + + async def get_user_prize_records(self, uid: int, cookie: str, sid: int): + from .activity_structs import GetUserPrizeRecordsReq, GetUserPrizeRecordsResp + + req = GetUserPrizeRecordsReq() + req.userId = self._build_activity_user(uid, cookie) + req.sid = int(sid or 0) + return await self.call_rpc("webActUI", "getUserPrizeRecords", req, GetUserPrizeRecordsResp) + + async def check_user_bind_game_account( + self, uid: int, cookie: str, b_act_id: int, is_use_outer_act_id: int = 1, + gid: int = 0, outer_act_id: str = "", scene: str = "", + ): + from .activity_structs import ( + CheckUserBindGameAccountReq, + CheckUserBindGameAccountResp, + ) + + req = CheckUserBindGameAccountReq() + req.userId = self._build_activity_user(uid, cookie) + req.gid = int(gid or 0) + req.outerActId = str(outer_act_id or "") + req.scene = scene or "" + req.bActId = int(b_act_id or 0) + req.isUseOuterActId = int(is_use_outer_act_id or 0) + return await self.call_rpc( + "webActUI", "checkUserBindGameAccount", req, CheckUserBindGameAccountResp + ) + + async def confirm_bind_act_account( + self, uid: int, cookie: str, b_act_id: int, gid: int = 0, outer_act_id: str = "" + ): + from .activity_structs import ( + ConfirmBindActAccountReq, + ConfirmBindActAccountResp, + ) + + req = ConfirmBindActAccountReq() + req.userId = self._build_activity_user(uid, cookie) + req.gid = int(gid or 0) + req.outerActId = str(outer_act_id or "") + req.bActId = int(b_act_id or 0) + return await self.call_rpc( + "webActUI", "confirmBindActAccount", req, ConfirmBindActAccountResp + ) + + async def get_live_link_param( + self, uid: int, cookie: str, b_act_id: int, gid: int = 0, + outer_act_id: int = 0, game_auth_scene: str = "", + ): + from .activity_structs import GetLiveLinkParamReq, GetLiveLinkParamResp + + req = GetLiveLinkParamReq() + req.userId = self._build_activity_user(uid, cookie) + req.gid = int(gid or 0) + req.outerActId = int(outer_act_id or 0) + req.gameAuthScene = game_auth_scene or "" + req.bActId = int(b_act_id or 0) + return await self.call_rpc("webActUI", "getLiveLinkParam", req, GetLiveLinkParamResp) + + async def get_user_profile_batch(self, uid: int, cookie: str, target_uids: list[int]): + from .activity_structs import GetUserProfileBatchReq, GetUserProfileBatchResp + + req = GetUserProfileBatchReq() + req.userId = self._build_activity_user(0, "") + req.uidList = [int(item) for item in target_uids if int(item or 0)] + if not req.uidList: + return None + return await self.call_rpc( + "huyauserui", "getUserProfileBatch", req, GetUserProfileBatchResp + ) + + async def list_pay_channels( + self, uid: int, guid: str, cookie: str, spu_id: str, sku_id: int = 0, + supplier_id: int = 87401, + ): + from .shop_structs import ListPayChannelReq, ListPayChannelRsp + + req = ListPayChannelReq() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app("yellowcarlist", WSS_SHOP_SCENE) + req.supplierId = int(supplier_id or 0) + req.spuId = spu_id + req.skuId = int(sku_id or 0) + return await self.call_rpc( + "shopMiddleUI", "listPayChannelV5", req, ListPayChannelRsp, timeout=15.0 + ) + + async def check_hy_protocol(self, uid: int, guid: str, cookie: str): + from .shop_structs import CheckHyProtocolReq, ShopCodeRsp + + req = CheckHyProtocolReq() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app("yellowcarlist", WSS_SHOP_SCENE) + return await self.call_rpc( + "shopMiddleUI", "checkHyProtocolV5", req, ShopCodeRsp, timeout=15.0 + ) + + async def check_user_buy_auth( + self, uid: int, guid: str, cookie: str, spu_id: str, sku_id: int, + ): + from .shop_structs import CheckUserBuyAuthReq, ShopCodeRsp + + req = CheckUserBuyAuthReq() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app("yellowcarlist", WSS_SHOP_SCENE) + req.spuId = spu_id + req.skuId = int(sku_id or 0) + return await self.call_rpc( + "shopMiddleUI", "checkUserBuyAuth", req, ShopCodeRsp, timeout=15.0 + ) + + async def query_user_order_list( + self, + uid: int, + guid: str, + cookie: str, + offset: int = 0, + page_size: int = 10, + order_type: int = 1, + status: int = 0, + ): + from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp + + req = QueryUserOrderListReq() + req.userId = self._build_biz_user(uid, cookie) + req.offset = offset + req.orderType = order_type + req.pageSize = page_size + req.status = status + + return await self.call_rpc( + "revenueWebUI", + "queryUserOrderList", + req, + QueryUserOrderListRsp, + timeout=15.0, + ) + + async def order_detail( + self, + uid: int, + guid: str, + cookie: str, + order_id: int, + source_id: str = "yellowcarlist", + scene: int = WSS_SHOP_SCENE, + ): + """按订单号查询商城订单详情,匹配 9.1 支付后轮询。""" + from .shop_structs import OrderDetailReq, OrderDetailRsp + + req = OrderDetailReq() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, scene) + req.orderId = int(order_id or 0) + return await self.call_rpc( + "shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=15.0 + ) + + async def create_order( + self, + uid: int, + guid: str, + cookie: str, + pid: int, + spu_id: str, + sku_id: int, + item_count: int = 1, + source_id: str = "yellowcarlist", + game_id: str = "", + scene: int = WSS_SHOP_SCENE, + order_type: int = 6, + ): + from .shop_structs import ( + CreateOrderAccountParam, + CreateOrderExtraParam, + CreateOrderPromotionParam, + CreateOrderReqV5, + CreateOrderRsp, + ) + + req = CreateOrderReqV5() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, 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 TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger("[✗] payOrderSubmitV5 超时") + return None + + wup_resp = WupResponse() + wup_resp.decode(self._extract_wup(body)) + for key in ("tRsp", "tResp"): + data = wup_resp.newdata.get(key) + if data and isinstance(data, bytes) and len(data) > 0: + try: + ins = TafInputStream(data) + _tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_BEGIN: + ins.read_head() + decoded = _decode_taf_struct(ins) + self.logger(f"[←] payOrderSubmitV5 {key}: {_truncate(decoded)}") + except Exception as exc: # noqa: BLE001 + self.logger(f"[debug] WUP 支付响应字段解码失败: {key}: {exc}") + result = wup_resp.readStruct("tRsp", PayOrderRes) + if result is None: + result = wup_resp.readStruct("tResp", PayOrderRes) + return result diff --git a/artifacts/guid_session_consistency/ROLLBACK.sh b/artifacts/guid_session_consistency/ROLLBACK.sh new file mode 100755 index 0000000..7b146bf --- /dev/null +++ b/artifacts/guid_session_consistency/ROLLBACK.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +target=${1:-core/huya/wss_client.py} +baseline_ref=${GUID_SESSION_BASELINE_REF:-HEAD} +root=$(git rev-parse --show-toplevel) +case "$target" in + "$root"/*) file=$target; rel=${target#"$root"/} ;; + /*) file=$target; rel=core/huya/wss_client.py ;; + *) file="$root/$target"; rel=$target ;; +esac +git show "$baseline_ref:$rel" > "$file" +printf '已恢复 %s(基线 %s)\n' "$file" "$baseline_ref" diff --git a/artifacts/guid_session_consistency/VERIFICATION.txt b/artifacts/guid_session_consistency/VERIFICATION.txt new file mode 100644 index 0000000..8ac0093 --- /dev/null +++ b/artifacts/guid_session_consistency/VERIFICATION.txt @@ -0,0 +1,42 @@ +guid 会话一致性验证记录 + +changed branch/field: core/huya/wss_client.py::HuyaWssClient._cookie_with_guid、initialize_activity、send_auth;core/huya/elite_session.py::HuyaEliteWssSession.__init__ 初始化后的会话 guid 回写。 + +MODIFIED_FILE: /Users/yml/codes/live-hub-py/artifacts/guid_session_consistency/MODIFIED_FILE +DIFF_FILE: /Users/yml/codes/live-hub-py/artifacts/guid_session_consistency/DIFF_FILE +VERIFICATION.txt: /Users/yml/codes/live-hub-py/artifacts/guid_session_consistency/VERIFICATION.txt +ROLLBACK.sh: /Users/yml/codes/live-hub-py/artifacts/guid_session_consistency/ROLLBACK.sh (executable) + +BASELINE +command: /Users/yml/codes/live-hub-py/.venv/bin/pytest -q +input: HEAD baseline before guid consistency change +literal output/result: 151 passed, 1 warning in 1.34s +exit status: 0 +restored behavior/status: baseline test copy remained unchanged + +MODIFIED +command: /Users/yml/codes/live-hub-py/.venv/bin/pytest -q +input: current worktree with wsLaunch sGuid propagation and regression tests +literal output/result: 153 passed, 1 warning in 1.34s +exit status: 0 +additional checks: +command: /Users/yml/codes/live-hub-py/.venv/bin/ruff check core/huya/wss_client.py core/huya/elite_session.py tests/test_huya_elite_protocol.py +literal output/result: All checks passed! +exit status: 0 +command: git diff --check +literal output/result: no output (clean) +exit status: 0 + +READ-ONLY ONLINE +command: DB_HOST=127.0.0.1 DB_PORT=3307 DB_NAME=douyu_login DB_USER=douyu_login /Users/yml/codes/live-hub-py/.venv/bin/python +input: account id=60; activity WSS; wsLaunch + getUserScore only; no exchange/order/payment calls +literal output/result: old_guid=0a43b01a366c966a7fe26a281e1d3c88; launch_guid=0a89d51717f5976ab502cf9dd09d03d8; session_cookie_guid=0a89d51717f5976ab502cf9dd09d03d8; guid_consistent=True; score_status=200; score=260; elapsed=0.286s +exit status: 0 +restored behavior/status: WSS session closed; no write operation invoked + +ROLLBACK +command: /Users/yml/codes/live-hub-py/artifacts/guid_session_consistency/ROLLBACK.sh /tmp/live-hub-guid-rollback/wss_client.py +input: independent copy of MODIFIED_FILE; GUID_SESSION_BASELINE_REF=HEAD +literal output/result: 已恢复 /tmp/live-hub-guid-rollback/wss_client.py(基线 HEAD) +exit status: 0 +restored hash/status: independent copy matched git show HEAD:core/huya/wss_client.py; current worktree remains on modified branch diff --git a/core/huya/elite_session.py b/core/huya/elite_session.py index 31f2449..0eb740c 100644 --- a/core/huya/elite_session.py +++ b/core/huya/elite_session.py @@ -94,6 +94,13 @@ class HuyaEliteWssSession: ) if not initialized: raise RuntimeError(f"虎牙 {kind} WSS 初始化失败") + if kind == "activity" and self.client._launch_guid: + # wsLaunch 的 sGuid 是当前连接的真实会话值,供后续业务 Cookie + # 与 HTTP baseinfo 继续复用,避免沿用登录时的旧 guid。 + self.cookie = self.client._cookie_with_guid( + self.cookie, self.client._launch_guid + ) + self.guid = self.client._launch_guid except Exception: self.close() raise diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index 929c622..c665afd 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -26,6 +26,7 @@ from typing import Any, cast import websockets +from .cookie_utils import cookie_pairs, normalize_cookie_pairs from .frame_decoder import _decode_taf_struct, _truncate, format_wss_log from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType from .wup_protocol import WupRequest, WupResponse @@ -192,6 +193,26 @@ class HuyaWssClient: return normalized return f"{normalized}; {cookie}" + @staticmethod + def _cookie_with_guid(cookie: str, guid: str) -> str: + """用当前 WSS 会话的 sGuid 替换 Cookie 中的旧 guid。""" + value = str(guid or "").strip() + if not value: + return cookie or "" + pairs = cookie_pairs(cookie) + replaced = False + normalized: list[tuple[str, str]] = [] + for key, item_value in pairs: + if key == "guid": + if not replaced: + normalized.append((key, value)) + replaced = True + continue + normalized.append((key, item_value)) + if not replaced: + normalized.append(("guid", value)) + return normalize_cookie_pairs(normalized) + @staticmethod def _build_biz_user(uid: int, cookie: str): """构造 WSS 业务 UserId(HAR 实证:guid 为空,cookie 不额外加前缀)""" @@ -389,7 +410,12 @@ class HuyaWssClient: if launch_rsp is None: self.logger("[WSS] 活动 wsLaunch 无响应") return False - await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ) + # wsLaunch 会为当前连接签发新的 sGuid。AUTH、getConfig 和后续业务 + # 请求必须使用同一个值;登录 Cookie 里的旧 guid 不能继续复用。 + if self._launch_guid: + cookie = self._cookie_with_guid(cookie, self._launch_guid) + guid = self._launch_guid + await self.send_auth(uid, cookie, sequence=SEQ_ACTIVITY_BIZ, guid=guid) # 9.1 抓包:getConfig 使用 wsLaunch 响应返回的新 guid;业务 UserId 仍为空 guid。 config_rsp = await self.call_get_config_activity( uid, cookie, guid=self._launch_guid @@ -570,7 +596,13 @@ class HuyaWssClient: return body[5 : 5 + wup_len] return body - async def send_auth(self, uid: int, cookie: str, sequence: int = SEQ_WSLAUNCH): + async def send_auth( + self, + uid: int, + cookie: str, + sequence: int = SEQ_WSLAUNCH, + guid: str = "", + ): """cmd 0x0a AUTH — 对齐 9.1 抓包(828 活动帧)。 9.1 活动 AUTH 结构(逐字节核验): @@ -591,8 +623,8 @@ class HuyaWssClient: os.write_string(1, WSS_COOKIE_UA) os.write_string(2, HuyaWssClient._normalize_biz_cookie(cookie)) guid_match = re.search(r"(?:^|;\s*)guid=([^;]+)", cookie or "") - guid = (guid_match.group(1).strip() if guid_match else "") or self._launch_guid - os.write_string(3, guid or "") + auth_guid = (guid or "").strip() or (guid_match.group(1).strip() if guid_match else "") or self._launch_guid + os.write_string(3, auth_guid) os.write_int8(4, 1) os.write_string(5, "HUYA&ZH&2052") os.write_string(6, "") diff --git a/docs/HUYA_COOKIE路线状态.md b/docs/HUYA_COOKIE路线状态.md index 69e5423..d67e890 100644 --- a/docs/HUYA_COOKIE路线状态.md +++ b/docs/HUYA_COOKIE路线状态.md @@ -15,7 +15,7 @@ App 登录可以得到新鲜的 `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken | `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken` | 已完成 | App 登录与 QR 绑定链 | | `sdid`、`hdid` | 已完成 | hydevice / `df/collect` | | `udb_deviceid`、`udb_guiddata`、`udb_anobiztoken`、`udb_anouid` | 已完成 | `anon_device.py` | -| `guid` | 会话路径完成,回退算法未完全确认 | `wsLaunch` 返回值优先;回退值为结构仿造 | +| `guid` | 会话路径已闭合 | 活动 `wsLaunch` 每次签发 32 位 `sGuid`;初始化时统一覆盖 AUTH、getConfig 和后续业务 Cookie,`956a/966a/976a` 等中间段不是本地版本常量 | | `__yamid_new` | 已完成 | `room_normal.js` `generate32` Python 复刻 | | `game_did` | 已完成 | 35 位、63 字符集规则复刻 | | `_yasids` | 格式完成,精确共享种子未确认 | 与 `__yamid_new` 使用同类时钟生成器 | @@ -47,10 +47,9 @@ SDK/Python 输出:d07a45de567322edcd42312f38e610be ## 尚未闭合的工程项 1. 将真实 WebView/Chrome 的 `getFeature()` 结果接入登录调用,替代当前的设备画像近似值。 -2. 明确 `guid` 的 `956a` / `966a` 版本差异和无 `wsLaunch` 时的真实生成规则。 -3. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 -4. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 -5. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 +2. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 +3. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 +4. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 ## 证据入口 diff --git a/docs/HUYA_WEB_FIELD_TRACE.md b/docs/HUYA_WEB_FIELD_TRACE.md index 8a7f9fe..4c82581 100644 --- a/docs/HUYA_WEB_FIELD_TRACE.md +++ b/docs/HUYA_WEB_FIELD_TRACE.md @@ -77,7 +77,7 @@ qimeisdk-web 0.6.3 源码已定位: | 字段 | 来源 | 风控关联性 | 建议 | | --- | --- | --- | --- | -| `guid` | 浏览器脚本(有固定段 `0a..966a`)| 高(设备与会话绑定) | **必须按结构仿造或复用 wsLaunch 会话值**;随机纯 hex 与真实结构不符 | +| `guid` | 活动 `wsLaunch` 响应 `sGuid`;页面 SDK 负责持久化 | 高(设备与会话绑定) | **优先复用当前 wsLaunch 会话值**;`956a/966a/976a` 等中间段随服务端返回变化,不能固化为版本常量 | | `__yamid_new` | 浏览器脚本(CBCC 前缀+种子)| 高(与 rootsid 同源)| `room_normal.js` `generate32` 已复刻;精确 rootsid 共享计数仍待确认 | | `_qimei_uuid42` | QIMEI SDK 0.6.3 VM 生成器 `e=6064` | 中 | Python 已按时间编码、CRC32 和随机半字节逐项复刻 | | `_qimei_fingerprint` | QIMEI SDK 0.6.3 x64hash128(feature_string, 31) | 中 | Python 已复刻,输入字段见路线状态文档 | @@ -87,7 +87,7 @@ qimeisdk-web 0.6.3 源码已定位: **当时的下一步记录(部分已完成,保留作历史)**: 1. `_qimei_fingerprint` 已改为浏览器能力特征串的 `x64hash128`,见路线状态文档。 -2. `guid` 已优先使用 `wsLaunch` 会话值,回退固定段仍待确认。 +2. `guid` 已统一使用 `wsLaunch` 会话值;活动 AUTH、getConfig 与后续业务 Cookie 均复用同一返回值。 3. `__yamid_new` 已定位 `room_normal.js` `generate32` 并完成 Python 复刻。 ## 六、核心字段生成算法逆向结果(2026-09-01 晚,浏览器实测 + 源码定位) diff --git a/tests/test_huya_elite_protocol.py b/tests/test_huya_elite_protocol.py index 71a10e6..ce4360d 100644 --- a/tests/test_huya_elite_protocol.py +++ b/tests/test_huya_elite_protocol.py @@ -146,3 +146,37 @@ def test_activity_auth_frame_matches_latest_capture_layout(): # body 必须与 9.1 828 AUTH 同样以 TAIL_BYTES 收尾 assert message.body[-len(TAIL_BYTES):] == TAIL_BYTES + + +def test_activity_cookie_guid_is_replaced_by_launch_guid(): + old = "0a" + "1" * 30 + new = "0a" + "2" * 30 + cookie = f"udb_uid=1; guid={old}; x=y; guid={old}" + + result = HuyaWssClient._cookie_with_guid(cookie, new) + + assert result == f"udb_uid=1; guid={new}; x=y" + + +def test_activity_auth_accepts_explicit_launch_guid_over_cookie_guid(): + async def build_frame(): + client = HuyaWssClient() + client._activity_mode = True + client.ws = type("FakeWs", (), {"send": AsyncMock()})() + await client.send_auth( + 1199664135026, + "guid=" + "0a" * 16 + "; udb_cred=" + "x" * 300, + sequence=0x1D000109, + guid="0b" * 16, + ) + return client.ws.send.call_args.args[0] + + message = WssMessage.decode(asyncio.run(build_frame())) + stream = TafInputStream(message.body[: -len(TAIL_BYTES)]) + assert stream.read_head() == (3, TafType.ZERO) + stream.read_int64(0) + stream.read_string(1) + assert stream.read_head() == (2, TafType.STRING4) + cookie_len = int.from_bytes(stream.buf.read(4), "big") + stream.buf.read(cookie_len) + assert stream.read_string(3) == "0b" * 16