diff --git a/core/huya/browser_harvest.py b/core/huya/browser_harvest.py index bd3ab72..51aed2d 100755 --- a/core/huya/browser_harvest.py +++ b/core/huya/browser_harvest.py @@ -96,7 +96,12 @@ class Cdp: return r.get("result", {}).get("value") -def launch(profile: Path, port: int, headless: bool = False) -> tuple[subprocess.Popen, str]: +def launch(profile: Path, port: int, headless: bool = True) -> tuple[subprocess.Popen, str]: + """启动用于 CDP 抓取的 Chrome。 + + 默认使用无头模式,避免登录/抓取流程弹出窗口;传入 ``headless=False`` + 仍可在需要人工观察页面时启用可视化窗口。 + """ args = [ str(CHROME), "--no-first-run", "--no-default-browser-check", @@ -170,9 +175,9 @@ def cookie_names_from_header(headers: dict) -> list[str]: return [part.strip().split("=", 1)[0] for part in raw.split(";") if part.strip()] -async def capture_round(profile: Path, tag: str, port: int): +async def capture_round(profile: Path, tag: str, port: int, *, headless: bool = True): """抓取一轮:导航序列 + 全量 Cookie 快照。""" - proc, ws_url = launch(profile, port) + proc, ws_url = launch(profile, port, headless=headless) try: async with websockets.connect(ws_url, max_size=64 * 1024 * 1024) as raw_ws: cdp = Cdp(raw_ws) @@ -294,14 +299,14 @@ async def capture_round(profile: Path, tag: str, port: int): proc.kill() -async def probe_anon(profile: Path, port: int): +async def probe_anon(profile: Path, port: int, *, headless: bool = True): """复用已有 profile:复放 udblgn 匿名流程,解密请求/响应体。 抓取会话内 anonymousLogin 与 web/middle 的 postData 与响应体, 然后带全套 Cookie 用 requests 独立重放 anonymousLogin,检查 服务端是否在匿名流程中下发 guid/udb_guiddata/匿名 token 等字段。 """ - proc, ws_url = launch(profile, port) + proc, ws_url = launch(profile, port, headless=headless) try: async with websockets.connect(ws_url, max_size=64 * 1024 * 1024) as raw_ws: cdp = Cdp(raw_ws) @@ -562,6 +567,11 @@ def main(): ap.add_argument("--profile", default="/tmp/huya-browser-p1") ap.add_argument("--port", type=int, default=9333) ap.add_argument("--out", default=str(OUT_DIR)) + ap.add_argument( + "--headed", + action="store_true", + help="显示 Chrome 窗口(默认使用无头模式)", + ) args = ap.parse_args() OUT_DIR = Path(args.out) profile = Path(args.profile) @@ -572,7 +582,7 @@ def main(): if args.mode == "round2" and not (profile / "Default").exists(): print("round2 需要先运行 round1(同一 profile)") sys.exit(2) - asyncio.run(capture_round(profile, args.mode, args.port)) + asyncio.run(capture_round(profile, args.mode, args.port, headless=not args.headed)) elif args.mode == "probe": # 匿名流程只在"无 udb_deviceid 持久态"的全新 profile 触发; # probe 模式内部使用临时全新 profile,跑完自动清理。 @@ -581,7 +591,7 @@ def main(): tmp = tempfile.mkdtemp(prefix="huya-probe-") try: - asyncio.run(probe_anon(Path(tmp), args.port)) + asyncio.run(probe_anon(Path(tmp), args.port, headless=not args.headed)) finally: shutil.rmtree(tmp, ignore_errors=True) else: @@ -589,4 +599,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/core/huya/web_cookie_fields.py b/core/huya/web_cookie_fields.py index 8cf6721..379448d 100644 --- a/core/huya/web_cookie_fields.py +++ b/core/huya/web_cookie_fields.py @@ -8,7 +8,7 @@ - ``guid/_qimei_uuid42/__yamid_new/game_did`` 由页面 SDK 脚本写 document.cookie, 无服务端签发接口。其中 ``guid`` 与会话强绑定:**wsLaunch 响应返回的新 guid 必须 回写进 cookie**,否则 getConfig 无响应(2026-09-01 实测); - ``_qimei_uuid42/__yamid_new/game_did`` 为设备标识,随机值格式对齐即可; + ``_qimei_uuid42/__yamid_new/game_did`` 为设备标识,已按浏览器脚本算法生成; - 其余统计/会话字段(SoundValue/Hm_*/rep_cnt 等)服务端仅记录,可随机生成。 2026-09-01 全链路实测:账号 id=58 补齐后 checkUserBindGameAccount 返回 @@ -20,7 +20,9 @@ from __future__ import annotations import random import secrets import time +import zlib from collections.abc import Mapping +from datetime import UTC, datetime # 统计/会话字段生成规则;9.1 抓包值为样例,格式保持对齐 _FILL_MAP: tuple[tuple[str, str], ...] = ( @@ -36,7 +38,7 @@ _FILL_MAP: tuple[tuple[str, str], ...] = ( # + v1 版本位 + 时钟序列 + 48 位随机 node)。浏览器 epoch = new Date(1582,10,15, # 0,0,0,0).getTime() 实测 -12216643543000(GMT+8)。CBCC 前缀是时间戳自然结果。 # - game_did: vplayerUI.js 35 位随机,字符集 64 去掉末尾 '_'(63 选 1)。 -# - _qimei_uuid42: QIMEI SDK 生成,cookie 值首字节固定 '1a'(本地存储 44hex 截断)。 +# - _qimei_uuid42: QIMEI SDK 生成,时间字段 + CRC32 替换段 + 随机半字节段。 _SDK_EPOCH_1582 = -12216643543000 # 浏览器实测 JS Date(1582,10,15) 本地时区 ms _BASE16 = "0123456789ABCDEF" @@ -263,6 +265,41 @@ def _rand_float() -> str: return f"{random.random():.17f}" +def _qimei_uuid42_generate( + now_ns: int | None = None, + *, + cookie_enabled: bool = True, + random_value: float | None = None, +) -> str: + """逐字复刻 QIMEI Web SDK 0.6.3 的 42 位 ``h38`` 生成器。 + + ``now_ns`` 保留旧回退函数的纳秒参数约定;SDK 实际只读取本地时间的 + 年/月/日/时/分/秒,因此毫秒和纳秒不会进入结果。时间前缀的年份和月份 + 使用 SDK 的十六进制编码,最后三位来自 ``navigator.cookieEnabled``。 + ``random_value`` 仅用于固定测试;生产调用沿用 ``Math.random()`` 等价的 + Python ``random.random()``,并将理论上的 1.0 夹到合法半字节上界 15。 + """ + now_ms = time.time_ns() // 1_000_000 if now_ns is None else now_ns // 1_000_000 + current = datetime.fromtimestamp(now_ms / 1000, tz=UTC).astimezone() + + # SDK: Number(year % 100).toString(16).padStart(2, "0") + + # Number(month + 1).toString(16) + each remaining component padded to 2. + year_hex = f"{current.year % 100:x}".rjust(2, "0") + month_hex = f"{current.month:x}" + date_parts = "".join( + f"{value:x}".rjust(2, "0") + for value in (current.day, current.hour, current.minute, current.second) + ) + prefix = year_hex + month_hex + date_parts + ("100" if cookie_enabled else "000") + + value = random.random() if random_value is None else float(random_value) + nibble = min(15, max(0, int(value * 16))) + repeated = f"{nibble:x}" * 20 + template = prefix + "----" + repeated + "----" + replacement = f"{zlib.crc32(template.encode('ascii')) & 0xFFFFFFFF:08x}" + return prefix + replacement[:4] + repeated + replacement[4:] + + def fill_web_cookie_fields( cookie: str, guid: str, @@ -308,8 +345,8 @@ def fill_web_cookie_fields( elif "guid" not in pairs: # guid 结构: '0a' + 10hex + '956a'/'966a' + 16hex(浏览器实测固定段,见 trace 文档) add("guid", "0a" + secrets.token_hex(5) + "966a" + secrets.token_hex(8)) - # _qimei_uuid42: QIMEI SDK 产物,cookie 首字节 '1a' + UUID 布局 32hex + 8hex 附加 - add("_qimei_uuid42", "1a" + secrets.token_hex(20)) + # _qimei_uuid42: QIMEI SDK 0.6.3 的时间/CRC32/随机半字节组合。 + add("_qimei_uuid42", _qimei_uuid42_generate()) # __yamid_new: room_normal.js generate32 精确复刻(UUID-v1 时间戳,含 CBCC 前缀规律) add("__yamid_new", _yamid_new_generate32()) add("game_did", _game_did_generate()) # 35 位随机(真实长度) diff --git a/docs/HUYA_CHAT_缺失字段补齐方案.md b/docs/HUYA_CHAT_缺失字段补齐方案.md index 6ef0c69..2d8fbf7 100644 --- a/docs/HUYA_CHAT_缺失字段补齐方案.md +++ b/docs/HUYA_CHAT_缺失字段补齐方案.md @@ -22,7 +22,7 @@ | `udb_anouid` | anonymousLogin 响应 uid | ✅ 已接入 anon_device | | `udb_guiddata` | middle URL 第三段 hex32(客户端自选) | ✅ 已接入 anon_device | | `guid` | `HyUDBWebSDK-Exchange-2.4.js` 写 document.cookie | 会话值已接入;回退生成规则待确认 | - | `_qimei_uuid42` | QIMEI SDK 脚本写 document.cookie | 结构已对齐;SDK 语义待复刻 | + | `_qimei_uuid42` | QIMEI SDK 脚本写 document.cookie | 0.6.3 时间/CRC32/随机半字节算法已复刻 | | `__yamid_new` | 门户业务脚本写 document.cookie | `room_normal.js` `generate32` 已复刻 | | `game_did` | 门户业务脚本写 document.cookie | 35 位、63 字符集规则已复刻 | @@ -41,13 +41,13 @@ udb_appid, udb_guiddata` —— 4 个匿名设备字段已有(anon_device 协 | --- | --- | --- | --- | | `guid` | 32 hex,`0a7d…` 前缀 | udbres.huya.com SDK 脚本 | `wsLaunch` 会话值已接入;回退规则待确认 | | `game_did` | 35 位字母数字 | liveapi.ip/getIpLocation 后门户脚本 | 35 位、63 字符集已复刻 | -| `_qimei_uuid42` | 42 hex,`1a901…` 前缀 | QIMEI SDK 脚本 | `1a` + 40hex;精确算法待复刻 | +| `_qimei_uuid42` | 42 hex,时间前缀 | QIMEI SDK 脚本 | 14 位时间前缀 + CRC32 替换段 + 随机半字节 | | `__yamid_new` | 32 hex 大写 | 门户脚本 | `generate32` 已复刻并通过 WSS 验证 | 浏览器 round1 同一字段多次出现均**值稳定**(跨 profile 稳定):`_qimei_fingerprint` -恒为 `2822a43b5a3bd016fc844218c35c8a74`,`_qimei_uuid42` 前缀恒为 `1a901…`, -`guid` 前缀恒为 `0a7dfaa2…` → 存在固定前缀/哈希结构,**随机值可能被风控识别**, -需还原生成算法。 +恒为 `2822a43b5a3bd016fc844218c35c8a74`,`_qimei_uuid42` 按秒内时间前缀稳定, +`guid` 前缀恒为 `0a7dfaa2…`。QIMEI 时间/CRC32/随机半字节生成算法已由 SDK VM +固定输入探针闭合,随机字段仍会在每次生成时变化。 ### 3.2 统计/会话字段(随机即可,服务端仅记录) @@ -108,7 +108,7 @@ cd /Users/yml/codes/live-hub-py ## 六、风险与验证要求 -当前实现已经通过活动 WSS 查询链路验证。`_qimei_uuid42` 仍只保证结构,`guid` 的回退值和设备字段持久化仍需继续完善;详细状态见 [HUYA_COOKIE路线状态.md](HUYA_COOKIE路线状态.md)。 +当前实现已经通过活动 WSS 查询链路验证。`guid` 的回退值和设备字段持久化仍需继续完善;详细状态见 [HUYA_COOKIE路线状态.md](HUYA_COOKIE路线状态.md)。 ## 七、各通道 Cookie 依赖矩阵(2026-09-01 晚实测) diff --git a/docs/HUYA_COOKIE合并审计与来源矩阵.md b/docs/HUYA_COOKIE合并审计与来源矩阵.md index 64c82b8..ed060bf 100644 --- a/docs/HUYA_COOKIE合并审计与来源矩阵.md +++ b/docs/HUYA_COOKIE合并审计与来源矩阵.md @@ -30,7 +30,7 @@ 同一网页会话内这些值保持稳定: -- `_qimei_uuid42`:42 位;`_qimei_h38`:38 位;`_qimei_fingerprint`:32 位。三者是 QIMEI 设备标识;其中 fingerprint 已按 SDK 能力特征串算法复刻,uuid42 仍只保证结构。 +- `_qimei_uuid42`:42 位;`_qimei_h38`:38 位;`_qimei_fingerprint`:32 位。三者是 QIMEI 设备标识;其中 fingerprint 与 uuid42 均已按 SDK 0.6.3 算法复刻。 - `udb_anobiztoken`:344 位 URL-safe 字符串,和 `udb_anouid` 共同属于匿名设备状态,不能用重复 UUID 字节拼接。 - `guid` 与 `udb_guiddata` 均为 32 位,但抓包值不同,不能复用同一画像字段。 - `udb_deviceid` 为 `w_` 加 19 位数字;格式可以校验,但仅凭格式无法得到真实值。 @@ -40,7 +40,7 @@ ## 旧实现的问题 -旧实现曾在空 Session 中生成 `__yamid_new`、`game_did`、三个 QIMEI 字段、`udb_anobiztoken`、`HMACCOUNT`、`_yasids` 和多个固定计数器。它们只能满足长度测试,不能证明值具有协议语义;当前 fingerprint 已替换为 SDK 算法,uuid42 仍保留结构化回退。 +旧实现曾在空 Session 中生成 `__yamid_new`、`game_did`、三个 QIMEI 字段、`udb_anobiztoken`、`HMACCOUNT`、`_yasids` 和多个固定计数器。它们只能满足长度测试,不能证明值具有协议语义;当前 fingerprint 与 uuid42 均已替换为 SDK 0.6.3 算法,仍需以同一会话的线上结果确认风控关联性。 旧实现还把 `device_info["guid32"][:32]` 同时写入 `guid` 和 `udb_guiddata`,并把手机号直接写入 `udb_passport`/`username`。这两点会造成设备身份和账号映射偏差。 diff --git a/docs/HUYA_COOKIE路线状态.md b/docs/HUYA_COOKIE路线状态.md index 8890080..c8a6a3f 100644 --- a/docs/HUYA_COOKIE路线状态.md +++ b/docs/HUYA_COOKIE路线状态.md @@ -19,7 +19,7 @@ App 登录可以得到新鲜的 `udb_cred`、`udb_uid`、`yyuid`、`udb_biztoken | `__yamid_new` | 已完成 | `room_normal.js` `generate32` Python 复刻 | | `game_did` | 已完成 | 35 位、63 字符集规则复刻 | | `_yasids` | 格式完成,精确共享种子未确认 | 与 `__yamid_new` 使用同类时钟生成器 | -| `_qimei_uuid42` | 结构完成,SDK 内部算法待复刻 | `1a` + 40hex | +| `_qimei_uuid42` | 已完成 | QIMEI SDK 0.6.3 的 14 位时间前缀 + CRC32 替换段 + 20 位随机半字节段 | | `_qimei_fingerprint` | 算法已复刻 | QIMEI `x64hash128(feature_string, 31)` | | 统计字段 | 已完成 | 线上查询已接受;部分字段仍是统计用途 | @@ -46,11 +46,10 @@ SDK/Python 输出:d07a45de567322edcd42312f38e610be ## 尚未闭合的工程项 1. 将真实 WebView/Chrome 的 `getFeature()` 结果接入登录调用,替代当前的设备画像近似值。 -2. 对 `_qimei_uuid42` 做 SDK VM 级复刻,当前只保证结构。 -3. 明确 `guid` 的 `956a` / `966a` 版本差异和无 `wsLaunch` 时的真实生成规则。 -4. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 -5. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 -6. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 +2. 明确 `guid` 的 `956a` / `966a` 版本差异和无 `wsLaunch` 时的真实生成规则。 +3. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 +4. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 +5. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 ## 证据入口 @@ -58,3 +57,17 @@ SDK/Python 输出:d07a45de567322edcd42312f38e610be - [Cookie 合并审计](HUYA_COOKIE合并审计与来源矩阵.md) - [浏览器抓包报告](../evidence/browser_cookie_harvest/report.md) - [虎牙精英宝典联调指南](HUYA精英宝典联调指南.md) + +## `_qimei_uuid42` VM 复刻(2026-09-02) + +固定时间 `1700000000000`、固定随机值时,SDK 观测值如下: + +```text +random=0 17b0f060d1410085cc000000000000000000003207 +random=0.25 17b0f060d14100037f4444444444444444444437df +random=0.5 17b0f060d1410053db888888888888888888883ff6 +random=0.75 17b0f060d14100966bcccccccccccccccccccc1cf3 +random=.999 17b0f060d141003174ffffffffffffffffffffd85d +``` + +每个值都可以拆成 `14 + 4 + 20 + 4 = 42` 位。时间前缀由本地时间组成:年份末两位转十六进制并补两位、月份转十六进制、日/时/分/秒转十六进制并各补两位,最后追加 `cookieEnabled ? 100 : 000`。中间 20 位是 `floor(Math.random() * 16)` 对应的十六进制半字节重复。两个 `----` 占位组成模板后,SDK 对模板 ASCII 字节计算标准 CRC32(多项式 `0xEDB88320`),将 8 位小写结果的前后四位分别替换占位,最后调用 `toLocaleLowerCase()`。Python 实现位于 `core/huya/web_cookie_fields.py`,并用上述固定向量回归;随机上界按 15 处理,避免理论输入 `1.0` 产生两位半字节。VM 追踪在 `/tmp/qvmtrace17.out`,生成器入口为 `e=6064`,模板拼接和替换发生在 `m≈5015..5137`。该实现只用于查询链路的设备态生成,不触发兑换、下单或支付。 diff --git a/docs/HUYA_WEB_FIELD_TRACE.md b/docs/HUYA_WEB_FIELD_TRACE.md index f26c5b0..ef9ad4c 100644 --- a/docs/HUYA_WEB_FIELD_TRACE.md +++ b/docs/HUYA_WEB_FIELD_TRACE.md @@ -33,7 +33,7 @@ | --- | --- | --- | | `__yamid_new` | 88 条写入中无此 key,但最终 cookie 有值 | **CBCC 前缀 + 递增种子**(见下) | | `game_did` | 同上 | 30-35 位字母数字 | -| `_qimei_uuid42` | 同上 | `1a` 前缀 + 32hex UUID 结构 + 10hex | +| `_qimei_uuid42` | 同上 | 14 位时间前缀 + 8 位 CRC32 替换值 + 20 位随机半字节 + 4 位 CRC32 尾段 | ## 三、设备字段结构规律(真实样本) @@ -58,13 +58,13 @@ CBCC 06ECB5 0000 0001 B63016D6162BC990 ← reqable 8.31 `yamid=CBCC6479D9B0...` ↔ `rootsid=CBCC647A8A20...`(同会话,种子差 0xB1≈177) - `0001` 固定,尾部大段随机 -### _qimei_uuid42 = `1a` + 40hex(32hex UUID 结构 + 10hex 附加) +### _qimei_uuid42 = 14 位时间前缀 + CRC32 替换段 + 20 位随机半字节 + CRC32 尾段 ``` -1a81f140c08100b89fb1be450f640501568684b2ea ← reqable 8.31 -1a901123a1f10045427c1c73420287f44f49d11b45 ← round2 fresh -1a901171b051007527940c2acef15dcd2342b6ff ← live 窗口 +17b0f060d14100037f4444444444444444444437df ← 固定时间/随机 .25 +17b0f060d1410053db888888888888888888883ff6 ← 固定时间/随机 .5 +17b0f060d141003174ffffffffffffffffffffd85d ← 固定时间/随机 .999 ``` -首字节 `1a` 恒定;前 32 hex 符合 RFC 4122 UUID 布局(8-4-4-4-12)。 +时间前缀的编码为:年份末两位十六进制(补两位)+ 月份十六进制 + 日/时/分/秒各两位十六进制 + `cookieEnabled` 标志(`100`/`000`)。模板 `prefix + "----" + nibble * 20 + "----"` 的标准 CRC32 小写结果会拆成前后四位填回两个占位;`nibble = floor(Math.random() * 16)`,因此合法范围为 `0` 到 `f`。 ### _qimei_fingerprint = x64hash128(feature_string, 31) 32hex qimeisdk-web 0.6.3 源码已定位: @@ -79,7 +79,7 @@ qimeisdk-web 0.6.3 源码已定位: | --- | --- | --- | --- | | `guid` | 浏览器脚本(有固定段 `0a..966a`)| 高(设备与会话绑定) | **必须按结构仿造或复用 wsLaunch 会话值**;随机纯 hex 与真实结构不符 | | `__yamid_new` | 浏览器脚本(CBCC 前缀+种子)| 高(与 rootsid 同源)| `room_normal.js` `generate32` 已复刻;精确 rootsid 共享计数仍待确认 | -| `_qimei_uuid42` | QIMEI SDK 0.6.3(`1a` 前缀)| 中 | 前缀 `1a` + UUID 布局 | +| `_qimei_uuid42` | QIMEI SDK 0.6.3 VM 生成器 `e=6064` | 中 | Python 已按时间编码、CRC32 和随机半字节逐项复刻 | | `_qimei_fingerprint` | QIMEI SDK 0.6.3 x64hash128(feature_string, 31) | 中 | Python 已复刻,输入字段见路线状态文档 | | `game_did` | 浏览器脚本 | 中 | 长度需对齐(35 变体含 `-`)| | 统计字段(SoundValue 等)| 页面脚本 | 低 | 随机 OK | @@ -116,10 +116,17 @@ B = (t=35) => { const e="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012 - **长度 35**(旧随机 30 位是错的);字符集 64 去掉末尾 `_`(63 选 1) - cookie 名 `game_did`,无 cookie 时生成并写入 -### ⚠️ _qimei_uuid42 结构已定,算法待深挖 -- cookie 值:首字节 `1a` 固定 + 40hex(localStorage 是 44hex,cookie 为截断子串) -- 由 qimeisdk-web 0.6.3 内部 VM 生成(`_qimei_ssid`/`_qimei_last_get_id_time_<16hex>` 同源) -- 当前实现对齐结构(1a 前缀 + 42hex 随机化),精确算法待继续 +### ✅ _qimei_uuid42 = QIMEI SDK VM 生成器(已 Python 复刻) + +清理 localStorage/cookie 后对 QIMEI SDK 0.6.3 做固定时间、固定随机值追踪,定位到 VM 生成器 `e=6064`。它先拼出: + +```text +14 位时间前缀 + "----" + 20 位随机半字节重复串 + "----" +``` + +随后计算模板 ASCII 字节的标准 CRC32,将 8 位小写结果的前 4 位和后 4 位分别填入占位,最后调用 `toLocaleLowerCase()`。例如 `random=0.25` 的模板是 `17b0f060d14100----44444444444444444444----`,替换值是 `037f37df`,结果为 `17b0f060d14100037f4444444444444444444437df`。`navigator.cookieEnabled` 只影响时间前缀末三位(`100`/`000`)。 +- 由 qimeisdk-web 0.6.3 内部 VM 生成(`_qimei_ssid`/`_qimei_last_get_id_time_<16hex>` 同源);字段本身为 42 位小写十六进制。 +- Python 实现按 SDK 时间、CRC32 和随机上界复刻,固定向量覆盖 `0`、`.25`、`.5`、`.75`、`.999`。 ### ✅ _qimei_fingerprint = x64hash128(feature_string,31),已完成 Python 复刻 - Cookie 输入由屏幕分辨率、浏览器能力、WebGL 和时区字段按 `###` 拼接。 @@ -130,7 +137,7 @@ B = (t=35) => { const e="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012 - `__yamid_new` → `_yamid_new_generate32()`(UUID-v1 时间戳复刻) - `game_did` → `_game_did_generate()`(35 位 63 字符集) - `_yasids` → 与 `__yamid_new` 同源同刻生成 -- `_qimei_uuid42` → `1a` 前缀 + 42hex +- `_qimei_uuid42` → `_qimei_uuid42_generate()`(14 位时间前缀 + CRC32 替换段 + 20 位随机半字节 + CRC32 尾段) - `guid`(无会话值回退)→ `0a` + 10hex + `966a` + 16hex 结构 ## 七、逆向生成器端到端验证(2026-09-01 晚,账号 18397482576 重新登录后) @@ -138,7 +145,7 @@ B = (t=35) => { const e="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012 新版生成器(web_cookie_fields.py commit 8fef04d)产出字段: - guid=`0a43b01a366c966a7fe26a281e1d3c88`(0a 前缀 + 966a 固定段) - __yamid_new=`CBCC65D6C5200001F176114BCF181E25`(UUIDv1 时间戳) -- _qimei_uuid42=`1a552b4b...`(1a 前缀) +- _qimei_uuid42=42 位小写十六进制(SDK VM 时间/CRC32 结构) - game_did=35 位 - _yasids 与 yamid 同源(CBCC65D6C5 前缀一致) diff --git a/docs/HUYA精英宝典联调指南.md b/docs/HUYA精英宝典联调指南.md index 5e85495..1d654e6 100644 --- a/docs/HUYA精英宝典联调指南.md +++ b/docs/HUYA精英宝典联调指南.md @@ -53,8 +53,8 @@ Cookie 缺少网页设备态(udb_deviceid, udb_guiddata, udb_anobiztoken),已 ``` `guid`/`game_did`/`_qimei_uuid42`/`__yamid_new` 四个脚本字段无统一服务端签发接口; -当前登录链已接入 `guid` 会话值、`__yamid_new`、`game_did` 和 fingerprint 算法, -`_qimei_uuid42` 仍使用结构化回退,WSS 判定不要求脚本字段全部来自浏览器。 +当前登录链已接入 `guid` 会话值、`__yamid_new`、`game_did`、fingerprint 和 +`_qimei_uuid42` 的 SDK 算法,WSS 判定不要求脚本字段全部来自浏览器。 - WSS 握手、`wsLaunch`、`getConfig` 或业务 RPC 任一步失败:任务直接失败并记录具体阶段,便于修复 Cookie 或协议参数。 diff --git a/tests/test_huya_browser_harvest.py b/tests/test_huya_browser_harvest.py new file mode 100644 index 0000000..289a8f6 --- /dev/null +++ b/tests/test_huya_browser_harvest.py @@ -0,0 +1,53 @@ +from pathlib import Path + +from core.huya import browser_harvest + + +class _FakeStderr: + def readline(self): + return "DevTools listening on ws://127.0.0.1/devtools/browser/test\n" + + +class _FakeProcess: + stderr = _FakeStderr() + + def kill(self): + pass + + +def test_launch_defaults_to_headless(monkeypatch): + calls = [] + + def fake_popen(args, **kwargs): + calls.append((args, kwargs)) + return _FakeProcess() + + def no_http(*args, **kwargs): + raise OSError("test: no CDP endpoint") + + monkeypatch.setattr(browser_harvest.subprocess, "Popen", fake_popen) + monkeypatch.setattr(browser_harvest.urllib.request, "urlopen", no_http) + + browser_harvest.launch(Path("/tmp/test-huya-profile"), 9555) + + args, kwargs = calls[0] + assert "--headless=new" in args + assert kwargs["stdout"] is browser_harvest.subprocess.DEVNULL + + +def test_launch_can_show_window_when_requested(monkeypatch): + calls = [] + + def fake_popen(args, **kwargs): + calls.append(args) + return _FakeProcess() + + def no_http(*args, **kwargs): + raise OSError("test: no CDP endpoint") + + monkeypatch.setattr(browser_harvest.subprocess, "Popen", fake_popen) + monkeypatch.setattr(browser_harvest.urllib.request, "urlopen", no_http) + + browser_harvest.launch(Path("/tmp/test-huya-profile"), 9556, headless=False) + + assert "--headless=new" not in calls[0] diff --git a/tests/test_huya_web_cookie_fields.py b/tests/test_huya_web_cookie_fields.py index 76eda10..5b07621 100644 --- a/tests/test_huya_web_cookie_fields.py +++ b/tests/test_huya_web_cookie_fields.py @@ -2,6 +2,7 @@ from core.huya.web_cookie_fields import ( _qimei_feature_string, + _qimei_uuid42_generate, _x64hash128, cookie_fields, fill_web_cookie_fields, @@ -46,6 +47,49 @@ def test_fill_replaces_empty_values_and_completes_core_fields(): assert not missing_web_cookie_fields(result) +def test_qimei_uuid42_matches_sdk_vectors_and_random_upper_bound(): + base = 1700000000000000000 + assert _qimei_uuid42_generate(base, random_value=0.25) == ( + "17b0f060d14100037f4444444444444444444437df" + ) + assert _qimei_uuid42_generate(base, random_value=0.5) == ( + "17b0f060d1410053db888888888888888888883ff6" + ) + upper = _qimei_uuid42_generate(base, random_value=1.0) + assert len(upper) == 42 + assert upper[18:38] == "f" * 20 + assert all(ch in "0123456789abcdef" for ch in upper) + + +def test_qimei_uuid42_encodes_local_date_and_cookie_flag(): + base = 1700000000000000000 + assert _qimei_uuid42_generate(base, random_value=0, cookie_enabled=False) == ( + "17b0f060d140008b2b00000000000000000000dad6" + ) + + +def test_qimei_uuid42_handles_year_zero_padding(): + # 2000-01-01 08:00:00 local time; SDK emits 00 + 1 + 08 + 00 + 00 + 100. + value = _qimei_uuid42_generate(946684800000000000, random_value=0) + assert value[:14] == "00101080000100" + + +def test_sdk_uuid42_trace_vectors_have_confirmed_outer_layout(): + """锁定 SDK 追踪确认的 14+4+20+4 外层布局,不约束未闭合的替换算法。""" + vectors = { + 0.0: "17b0f060d1410085cc000000000000000000003207", + 0.25: "17b0f060d14100037f4444444444444444444437df", + 0.5: "17b0f060d1410053db888888888888888888883ff6", + 0.75: "17b0f060d14100966bcccccccccccccccccccc1cf3", + 0.999: "17b0f060d141003174ffffffffffffffffffffd85d", + } + for random_value, value in vectors.items(): + assert len(value) == 42 + assert value[:14] == "17b0f060d14100" + assert value[18:38] == f"{int(random_value * 16):x}" * 20 + assert all(char in "0123456789abcdef" for char in value) + + def test_device_profile_builds_stable_webview_feature_approximation(): feature = qimei_feature_from_device_profile({ "vendor": "vivo",