199 lines
7.5 KiB
Python
199 lines
7.5 KiB
Python
"""虎牙设备指纹:通过 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: 虎牙应用 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")
|