修正活动WSS会话guid一致性

This commit is contained in:
yml2213
2026-09-02 18:07:31 +08:00
parent 92ba6cbc62
commit aaaa568916
9 changed files with 1490 additions and 11 deletions
@@ -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
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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"
@@ -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 <inline probe>
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
+7
View File
@@ -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
+36 -4
View File
@@ -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, "")
+4 -5
View File
@@ -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. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。
## 证据入口
+2 -2
View File
@@ -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 晚,浏览器实测 + 源码定位)
+34
View File
@@ -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