Files
live-hub-py/core/huya/web_cookie_fields.py
T

426 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙网页 Cookie 缺失字段补齐生成器。
账号 App 登录态只有 16 个 udb_* 认证字段,而活动/商城 WSS 需要完整的网页设备态。
浏览器实测(evidence/browser_cookie_harvest/report.md)确认:
- ``udb_deviceid/udb_guiddata/udb_anobiztoken/udb_anouid`` 由匿名设备态协议
(core/huya/anon_device.py,middle + anonymousLogin)签发;
- ``guid/_qimei_uuid42/__yamid_new/game_did`` 由页面 SDK 脚本写 document.cookie,
无服务端签发接口。其中 ``guid`` 与会话强绑定:**wsLaunch 响应返回的新 guid 必须
回写进 cookie**,否则 getConfig 无响应(2026-09-01 实测);
``_qimei_uuid42/__yamid_new/game_did`` 为设备标识,已按浏览器脚本算法生成;
- 其余统计/会话字段(SoundValue/Hm_*/rep_cnt 等)服务端仅记录,可随机生成。
2026-09-01 全链路实测:账号 id=58 补齐后 checkUserBindGameAccount 返回
status=200(和平精英已绑定)、getUserScore 返回 status=200(积分 4880)。
"""
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], ...] = (
("SoundValue", "0.50"),
("alphaValue", "0.80"),
("isInLiveRoom", "true"),
("udb_passdata", "3"),
("udb_accdata", "undefined"),
)
# 浏览器实测逆向(2026-09-01,见 docs/HUYA_WEB_FIELD_TRACE.md):
# - __yamid_new: room_normal.js generate32(UUID-v1 风格,1582-epoch 本地时区时间戳
# + 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 生成,时间字段 + CRC32 替换段 + 随机半字节段。
_SDK_EPOCH_1582 = -12216643543000 # 浏览器实测 JS Date(1582,10,15) 本地时区 ms
_BASE16 = "0123456789ABCDEF"
_GAME_DID_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
_DEFAULT_QIMEI_FEATURE = {
"screen": {"resolution": "800x600", "availResolution": "800x600"},
"navigator": {
"deviceMemory": 16,
"pdfViewerEnabled": True,
"cookieEnabled": True,
"productSub": "20030107",
"hardwareConcurrency": 10,
},
"webgl": "Google Inc. (Apple)~ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)",
"timezoneOffset": -480,
}
_DEVICE_GPU_FEATURES = {
"xiaomi": ("Qualcomm", "Adreno (TM) 650"),
"redmi": ("Qualcomm", "Adreno (TM) 650"),
"vivo": ("Qualcomm", "Adreno (TM) 650"),
"iqoo": ("Qualcomm", "Adreno (TM) 650"),
"oppo": ("Qualcomm", "Adreno (TM) 650"),
"oneplus": ("Qualcomm", "Adreno (TM) 650"),
"samsung": ("ARM", "Mali-G78"),
"honor": ("ARM", "Mali-G78"),
"motorola": ("Qualcomm", "Adreno (TM) 642L"),
"realme": ("Qualcomm", "Adreno (TM) 650"),
"nubia": ("Qualcomm", "Adreno (TM) 650"),
"gionee": ("Qualcomm", "Adreno (TM) 506"),
}
def _return_base16(a: int) -> str:
"""JS returnBase(a,16):正整数转大写十六进制;a<=0 返回空串。"""
if a <= 0:
return ""
out = ""
while a > 0:
out = _BASE16[a % 16] + out
a //= 16
return out
def _generate_bits(a: int, b: int, c: int) -> str:
"""JS generateBits(a,b,c):取 a 的 16 进制串 [floor(b/4)..floor(c/4)] 字符,越界补0。"""
d = _return_base16(a)
out = ""
for g in range(b // 4, c // 4 + 1):
out += d[g] if g < len(d) and d[g] != "" else "0"
return out
def _yamid_new_generate32(rng=None) -> str:
"""逐字复刻 room_normal.js generate32(__yamid_new 生成器)。"""
import random
r = rng if rng is not None else random.Random()
now_ms = int(time.time() * 1000)
c = now_ms - _SDK_EPOCH_1582
return "".join(
(
_generate_bits(c, 0, 31),
_generate_bits(c, 32, 47),
_generate_bits(c, 48, 59) + "1",
_generate_bits(r.randrange(4095), 0, 7),
_generate_bits(r.randrange(4095), 0, 7),
_generate_bits(r.randrange(8191), 0, 7)
+ _generate_bits(r.randrange(8191), 8, 15)
+ _generate_bits(r.randrange(8191), 0, 7)
+ _generate_bits(r.randrange(8191), 8, 15)
+ _generate_bits(r.randrange(8191), 0, 15),
)
)
def _game_did_generate(length: int = 35) -> str:
"""逐字复刻 vplayerUI.js game_did:35 位随机,字符集 64 去掉末尾 '_'。"""
import random
out = ""
for _ in range(length):
idx = int(random.random() * (len(_GAME_DID_CHARS) - 1)) # 63 → 排除 '_'
out += _GAME_DID_CHARS[idx]
return out
_MASK64 = (1 << 64) - 1
def _rotl64(value: int, shift: int) -> int:
shift %= 64
if not shift:
return value & _MASK64
return ((value << shift) | (value >> (64 - shift))) & _MASK64
def _fmix64(value: int) -> int:
value ^= value >> 33
value = value * 0xFF51AFD7ED558CCD & _MASK64
value ^= value >> 33
value = value * 0xC4CEB9FE1A85EC53 & _MASK64
value ^= value >> 33
return value & _MASK64
def _x64hash128(value: str, seed: int = 0) -> str:
"""复刻 QIMEI SDK 的 x64hash128(MurmurHash3 变体)。
SDK 按 JavaScript ``charCodeAt`` 取每个 UTF-16 code unit 的低 8 位;尾部
的第一个字节也保留其实现中的高 32 位布局,而不是标准 MurmurHash 布局。
"""
encoded = value.encode("utf-16-le", "surrogatepass")
data = bytes(encoded[index] for index in range(0, len(encoded), 2))
length = len(data)
h1 = h2 = seed & _MASK64
c1 = 0x87C37B91114253D5
c2 = 0x4CF5AD432745937F
for offset in range(0, length - length % 16, 16):
k1 = int.from_bytes(data[offset : offset + 8], "little")
k2 = int.from_bytes(data[offset + 8 : offset + 16], "little")
k1 = _rotl64(k1 * c1 & _MASK64, 31) * c2 & _MASK64
h1 ^= k1
h1 = (_rotl64(h1, 27) + h2) & _MASK64
h1 = (h1 * 5 + 0x52DCE729) & _MASK64
k2 = _rotl64(k2 * c2 & _MASK64, 33) * c1 & _MASK64
h2 ^= k2
h2 = (_rotl64(h2, 31) + h1) & _MASK64
h2 = (h2 * 5 + 0x38495AB5) & _MASK64
tail = data[length - length % 16 :]
tail_len = len(tail)
k1 = k2 = 0
for index in range(tail_len - 1, 7, -1):
k2 ^= tail[index] << (8 * (index - 8))
if tail_len >= 9:
h2 ^= _rotl64(k2 * c2 & _MASK64, 33) * c1 & _MASK64
for index in range(min(tail_len, 8) - 1, 0, -1):
k1 ^= tail[index] << (8 * index)
if tail_len >= 1:
# QIMEI's JS implementation passes [charCodeAt(g)] here, placing the
# first tail byte in the high 32-bit word.
k1 ^= tail[0] << 32
h1 ^= _rotl64(k1 * c1 & _MASK64, 31) * c2 & _MASK64
h1 ^= length
h2 ^= length
h1 = (h1 + h2) & _MASK64
h2 = (h2 + h1) & _MASK64
h1 = _fmix64(h1)
h2 = _fmix64(h2)
h1 = (h1 + h2) & _MASK64
h2 = (h2 + h1) & _MASK64
return f"{h1:016x}{h2:016x}"
def _js_text(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _qimei_feature_string(feature: Mapping[str, object]) -> str:
screen = feature.get("screen")
screen = screen if isinstance(screen, Mapping) else {}
navigator = feature.get("navigator")
navigator = navigator if isinstance(navigator, Mapping) else {}
values = (
screen.get("resolution"),
screen.get("availResolution"),
navigator.get("deviceMemory"),
navigator.get("pdfViewerEnabled"),
navigator.get("cookieEnabled"),
navigator.get("productSub"),
navigator.get("hardwareConcurrency"),
feature.get("webgl"),
feature.get("timezoneOffset"),
)
return "###".join(_js_text(item) for item in values) + "###"
def qimei_fingerprint_generate(feature: Mapping[str, object] | None = None) -> str:
"""生成 QIMEI ``_qimei_fingerprint``;输入应为 SDK ``getFeature()`` 结果。"""
source = feature or _DEFAULT_QIMEI_FEATURE
return _x64hash128(_qimei_feature_string(source), 31)
def qimei_feature_from_device_profile(
device_info: Mapping[str, object] | None,
) -> dict[str, object]:
"""将 Android 设备画像映射为 QIMEI WebView 能力特征的近似值。"""
info = device_info or {}
width = str(info.get("width") or "1080")
height = str(info.get("height") or "2400")
vendor = str(info.get("vendor") or "").strip().lower()
model = str(info.get("model") or "Android WebView").strip()
gpu_vendor, renderer = _DEVICE_GPU_FEATURES.get(
vendor, ("Qualcomm", f"{model} Graphics")
)
device_memory = info.get("device_memory") or 8
hardware_concurrency = info.get("hardware_concurrency") or 8
return {
"screen": {
"resolution": f"{width}x{height}",
"availResolution": f"{width}x{height}",
},
"navigator": {
"deviceMemory": device_memory,
"pdfViewerEnabled": False,
"cookieEnabled": True,
"productSub": "20030107",
"hardwareConcurrency": hardware_concurrency,
},
"webgl": f"Google Inc. ({gpu_vendor})~ANGLE ({gpu_vendor}, {renderer})",
"timezoneOffset": -480,
}
def _rand_float() -> str:
import random
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`` 仅用于固定测试;生产调用会像 SDK 一样独立调用 20 次
``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")
random_source = random.random if random_value is None else lambda: random_value
random_segment = "".join(
f"{min(15, max(0, int(float(random_source()) * 16))):x}" for _ in range(20)
)
template = prefix + "----" + random_segment + "----"
replacement = f"{zlib.crc32(template.encode('ascii')) & 0xFFFFFFFF:08x}"
return prefix + replacement[:4] + random_segment + replacement[4:]
def fill_web_cookie_fields(
cookie: str,
guid: str,
uid: int | str = 0,
*,
keep_guid: bool = True,
qimei_feature: Mapping[str, object] | None = None,
) -> str:
"""补齐网页 Cookie 中缺失的字段。
Args:
cookie: 当前 Cookie(可只有 udb_* 认证字段)。
guid: wsLaunch 响应返回的会话 guid(32hex);缺核心字段时必填。
uid: 虎牙 uid,用于 __yaoldyyuid 等字段。
keep_guid: 已存在 guid 且非空时是否保留(默认 True)。
qimei_feature: QIMEI SDK ``getFeature()`` 结果;未提供时使用稳定默认档案。
Returns:
补齐后的 Cookie 字符串(原始字段值不变,仅补缺失项)。
"""
pairs: dict[str, str] = {}
for raw in (cookie or "").split(";"):
item = raw.strip()
if not item or "=" not in item:
continue
key, value = item.split("=", 1)
key = key.strip()
if key:
pairs[key] = value.strip()
def add(key: str, value: str):
if value and (key not in pairs or not pairs[key]):
pairs[key] = value
uid = int(uid or 0)
# 4 个核心设备字段
if guid:
if keep_guid and pairs.get("guid"):
pass # 已有真实 guid 则保留
else:
add("guid", str(guid))
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 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 位随机(真实长度)
# qimei 皮肤字段
# _qimei_fingerprint: QIMEI SDK x64hash128(浏览器能力特征,31) 32hex。
add("_qimei_fingerprint", qimei_fingerprint_generate(qimei_feature))
# SDK 内部 h38 就是 42 位 UUID42,并不存在独立的 38 位生成器。当前活动
# getUserScore 只检查同名 Cookie 键是否存在,不检查值;复用 SDK h38 的真实值。
add("_qimei_h38", pairs["_qimei_uuid42"])
# 会话/统计字段
yasmid = _rand_float()
add("__yasmid", yasmid)
add("__yamid_tt1", yasmid)
add("_rep_cnt", str(random.randint(0, 9)))
add("rep_cnt", str(random.randint(0, 9)))
add("h_unt", str(int(time.time())))
if uid:
add("__yaoldyyuid", str(uid))
# _yasids 与 __yamid_new 同源(共享时间戳生成器,浏览器实测前缀一致)
add("_yasids", "__rootsid%3D" + _yamid_new_generate32())
add("Hm_lvt_51700b6c722f5bb4cf39906a596ea41f", str(int(time.time())))
add("Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f", str(int(time.time())))
add("HMACCOUNT", secrets.token_hex(8).upper())
add("huya_flash_rep_cnt", str(random.randint(1, 400)))
add("huyasp_rep_cnt", str(random.randint(1, 40)))
add("huya_hd_rep_cnt", str(random.randint(1, 40)))
add("huya_web_rep_cnt", str(random.randint(300, 900)))
for key, value in _FILL_MAP:
add(key, value)
return "; ".join(f"{key}={pairs[key]}" for key in pairs)
def missing_web_cookie_fields(cookie: str) -> tuple[str, ...]:
"""返回缺失的网页核心字段(不含统计字段)。"""
from .cookie_utils import cookie_value
core = (
"guid",
"_qimei_uuid42",
"__yamid_new",
"game_did",
"_qimei_fingerprint",
"_qimei_h38",
"__yasmid",
"__yamid_tt1",
"udb_passdata",
"_yasids",
"HMACCOUNT",
)
return tuple(key for key in core if not cookie_value(cookie, key))
def cookie_fields(cookie: str) -> dict[str, str]:
"""把 Cookie 字符串转 dict(用于测试/调试)。"""
out = {}
for raw in (cookie or "").split(";"):
item = raw.strip()
if not item or "=" not in item:
continue
key, value = item.split("=", 1)
out[key.strip()] = value.strip()
return out
__all__ = [
"cookie_fields",
"fill_web_cookie_fields",
"missing_web_cookie_fields",
"qimei_feature_from_device_profile",
"qimei_fingerprint_generate",
]