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