426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""虎牙网页 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",
|
||
]
|