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

199 lines
7.5 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.
"""虎牙设备指纹:通过 node 运行 hydevice.js 生成高信任 sdid。
流程与真机 App WebView 一致:
df/token -> hydevice 采集+WASM 加密 -> df/collect -> sdid
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
import requests
from loguru import logger
from .device_profile import mobile_user_agent
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,
check=False,
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 = mobile_user_agent(device_info)
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: 虎牙应用 idApp 场景 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")