"""虎牙设备指纹:通过 node 运行 hydevice.js 生成高信任 sdid。 流程与真机 App WebView 一致: df/token -> hydevice 采集+WASM 加密 -> df/collect -> sdid """ from __future__ import annotations import shutil import subprocess import tempfile import json from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path import requests from loguru import logger FINGERPRINT_DIR = Path(__file__).parent / "fingerprint" RUNNER_JS = FINGERPRINT_DIR / "runner.js" SDID_PREFIX = "__SDID__" # 每账号独立的 hydevice localStorage 状态目录 (一号一设备的关键): # 默认全局临时目录会让所有账号共用同一份设备采集状态 → sdid/40hex hdid 同源, # 服务端可跨账号关联设备。按账号分目录后, node 链路为每个账号维护独立"设备"。 FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states" def account_state_dir(account: str) -> Path: """账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。""" safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in (account or "anon"))[:64] return FP_STATE_ROOT / safe DEFAULT_TIMEOUT = (8, 40) class HuyaFingerprintError(RuntimeError): """虎牙设备指纹生成失败。""" @dataclass class HuyaSdidResult: """sdid 获取结果。""" sdid: str = "" hdid: str = "" # df/collect 同响应下发的 40hex 设备ID; 与 WUP 登录的 32hex hdid 非同一体系(见 docs/HUYA_APP_OVERVIEW.md §二) source: str = "" message: str = "" HDID_PREFIX = "__HDID__" def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> tuple[str, str]: """调用 node runner,返回 (sdid, hdid)。 若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该 设备身份运行 hydevice → 不同账号的 sdid/40hex hdid 不再同源 (一号一设备)。 """ node_bin = shutil.which("node") if not node_bin: raise HuyaFingerprintError("未找到 node 可执行文件(hydevice.js 需要 JS 引擎)") connect_timeout, read_timeout = timeout total_timeout = int(connect_timeout + read_timeout + 10) try: proc = subprocess.run( [node_bin, str(RUNNER_JS), str(state_dir), app_id], capture_output=True, text=True, timeout=total_timeout, cwd=str(FINGERPRINT_DIR), ) except subprocess.TimeoutExpired as exc: raise HuyaFingerprintError(f"hydevice runner 超时({total_timeout}s)") from exc sdid, hdid = "", "" for line in (proc.stdout or "").splitlines(): line = line.strip() if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20: sdid = line[len(SDID_PREFIX):] if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20: hdid = line[len(HDID_PREFIX):] if sdid: return sdid, hdid stderr_tail = (proc.stderr or "").strip().splitlines() detail = stderr_tail[-1][:120] if stderr_tail else f"exit={proc.returncode}" raise HuyaFingerprintError(f"hydevice runner 未返回 sdid: {detail}") def _fallback_sdid(session: requests.Session, timeout) -> str: """旧版降级:仅 token+collect,无指纹(低信任)。""" token_url = "https://df.huya.com/web/df/token" collect_url = "https://df.huya.com/web/df/collect" token_res = session.post( token_url, json={"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.45"}, timeout=timeout, ) token_res.raise_for_status() token = token_res.json().get("data", {}).get("token", "") if not token: raise HuyaFingerprintError(f"获取虎牙 df token 失败: {token_res.text[:120]}") collect_res = session.post(collect_url, json={"token": token}, timeout=timeout) collect_res.raise_for_status() return collect_res.json().get("data", {}).get("sdid", "") def write_device_hint(state_dir: str | Path, device_info: Mapping) -> Path: """把账号画像写成 hydevice 设备覆盖参数 (env.js overrides)。 来源: 画像 model/screen(格式"model,sdkVer,androidVer")/vendor + app 版本。 每账号独立 → hydevice 采集输入不同 → 服务端 sdid 不再同前缀。 """ state_dir = Path(state_dir) state_dir.mkdir(parents=True, exist_ok=True) screen = str(device_info.get("screen", "")) parts = screen.split(",") android_ver = parts[2] if len(parts) >= 3 else "11" ua = ( f"Mozilla/5.0 (Linux; Android {android_ver}; {device_info.get('model', '')} " "Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) " "Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36 " f"huya adr/13.4.22/{device_info.get('vendor', 'android')}/30" ) hint = { "model": device_info.get("model", ""), "vendor": device_info.get("vendor", "android"), "androidVer": android_ver, "appVer": str(device_info.get("app_version", "13.4.22")), "userAgent": ua, "screenWidth": int(device_info.get("width", 1080) or 1080), "screenHeight": int(device_info.get("height", 2120) or 2120), } path = state_dir / "device.json" path.write_text(json.dumps(hint, ensure_ascii=False), encoding="utf-8") return path def get_huya_sdid( app_id: str = "5008", state_dir: str | Path | None = None, session: requests.Session | None = None, proxies: Mapping | None = None, timeout: tuple[float, float] = DEFAULT_TIMEOUT, allow_fallback: bool = True, device_hint: Mapping | None = None, ) -> HuyaSdidResult: """获取虎牙高信任 sdid;node 链路失败时可选降级为低信任指纹。 Args: app_id: 虎牙应用 id,App 场景 5008。 state_dir: localStorage 持久化目录(保持设备一致性)。None 用全局临时目录 (仅单设备场景可用); 多账号请传 account_state_dir(account) 实现 一号一设备 — 同账号多次登录复用同一份设备状态。 allow_fallback: node 失败时是否降级为旧版无指纹方式。 Returns: HuyaSdidResult(sdid, source, message),source 为 fingerprint/fallback/none。 """ del session, proxies # node runner 自行发请求;保留参数便于未来代理透传 if state_dir is None: state_dir = Path(tempfile.gettempdir()) / "huya_fp_state" state_dir = Path(state_dir) if device_hint: # 一号一设备: 账号画像 → hydevice 设备覆盖 (UA/屏幕/机型) write_device_hint(state_dir, device_hint) try: sdid, hdid = _run_node_runner(state_dir, app_id, timeout) if sdid: logger.debug("虎牙设备指纹成功(node): sdid={}... hdid={}...", sdid[:24], hdid[:10]) return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint") except HuyaFingerprintError as exc: logger.warning("虎牙 hydevice 指纹失败: {}", exc) if not allow_fallback: raise if allow_fallback: try: with requests.Session() as s: s.trust_env = False sdid = _fallback_sdid(s, timeout) if sdid: logger.debug("虎牙设备指纹降级(无指纹): sdid={}...", sdid[:24]) return HuyaSdidResult( sdid=sdid, source="fallback", message="低信任指纹(降级)", ) except Exception as exc: # noqa: BLE001 logger.warning("虎牙降级指纹也失败: {}", exc) return HuyaSdidResult(source="none", message="未能获取 sdid")