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

207 lines
7.9 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.
"""虎牙多账号设备画像生成与管理。
为每个账号生成并持久化独立的设备身份画像(机型、屏幕、指纹、设备ID等)。
注意:画像只承载 *soft* 设备字段(机型/屏幕/随机指纹/随机 device_id)。
``safedeviceid``RSA action 令牌)与登录帧 ``device_id`` 由
:mod:`core.huya.dfp_register` 在每次 WUP 登录前经新设备注册链实时签发,
不再写入画像,也不再允许任何固定金样本令牌被多账号复用。
⚠ 字段状态速览(详见 ``docs/HUYA_APP_OVERVIEW.md`` §四):
- 随机: fingerprint / 机型 / 屏幕 / 宽高 / device_id(画像侧)
- 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version
- 动态签发: safedeviceid、登录帧 device_id。
"""
from __future__ import annotations
import hashlib
import json
import os
import random
import re
from collections.abc import Mapping
from pathlib import Path
from loguru import logger
ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = ROOT / "data"
PRIMARY_PROFILE_DB = DATA_DIR / "huya_device_profiles.json"
FALLBACK_PROFILE_DB = ROOT / "evidence" / "device_profiles.json"
ALLOW_LEGACY_PROFILE_IMPORT = os.getenv("HUYA_ALLOW_LEGACY_PROFILE_IMPORT") == "1"
REAL_MODELS = [
("xiaomi", "M2102J2SC", "M2102J2SC,30,11", (1080, 2120)),
("vivo", "V2370A", "V2370A,34,13", (1080, 2412)),
("oppo", "PFFM20", "PFFM20,34,13", (1080, 2412)),
("honor", "SDY-AN00", "SDY-AN00,31,12", (1080, 2400)),
("samsung", "SM-G9910", "SM-G9910,31,12", (1440, 3200)),
("oneplus", "PGZ110", "PGZ110,34,13", (1080, 2412)),
("redmi", "23049PCD8G", "23049PCD8G,34,13", (1080, 2400)),
("realme", "RMX3366", "RMX3366,34,13", (1080, 2412)),
("iqoo", "V2183A", "V2183A,34,13", (1080, 2400)),
("nubia", "NX729J", "NX729J,33,13", (1080, 2400)),
("motorola", "XT2301-5", "XT2301-5,33,13", (1080, 2400)),
("gionee", "GN9013", "GN9013,29,10", (720, 1560)),
]
# 登录帧 t1.t0 32hex 设备证书 (R15: md5("5008_13.4.22_"+k1); k1 见 R39 datadiv 常量;
# 换 app 版本时需按新 app_version 重算 — 与 GUID32/doLaunch 体系无关)
HDID32 = "ed0db8334cadd236c00cadf7e11ab5a5"
APP_VERSION = "13.4.22"
SDK_VERSION = "1.0.80138"
def canonical_account_key(account: str) -> str:
"""Return one stable environment key for equivalent Huya account forms.
The WUP protocol keeps the ``hy_`` prefix for Huya IDs, but the device
environment must not split ``300023887`` and ``hy_300023887`` into two
records. Phone numbers and other usernames remain unchanged.
"""
value = str(account or "").strip()
if re.fullmatch(r"hy_\d+", value):
return value[3:]
return value
def mobile_user_agent(device_info: Mapping[str, object]) -> str:
"""根据统一设备画像生成 App WebView UA。"""
screen = str(device_info.get("screen") or "")
parts = screen.split(",")
android_ver = parts[2] if len(parts) >= 3 and parts[2] else "11"
api_level = parts[1] if len(parts) >= 2 and parts[1] else "30"
model = str(device_info.get("model") or "M2102J2SC")
vendor = str(device_info.get("vendor") or "android")
app_version = str(device_info.get("app_version") or APP_VERSION)
return (
f"Mozilla/5.0 (Linux; Android {android_ver}; {model} "
"Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Version/4.0 Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/"
f"{app_version}/{vendor}/{api_level}"
)
def _rand_sha1_hex() -> str:
return hashlib.sha1(os.urandom(20)).hexdigest()
def generate_profile(model_pick=None) -> dict:
"""生成一套随机设备画像。"""
vendor, model, screen, (w, h) = (
random.choice(REAL_MODELS) if model_pick is None else model_pick
)
return {
"app_version": APP_VERSION,
"sdk_version": SDK_VERSION,
"vendor": vendor,
"model": model,
"os": "android",
"ip": "127.0.0.1",
"fingerprint": _rand_sha1_hex(),
"screen": screen,
"width": str(w),
"height": str(h),
"device_id": _rand_sha1_hex(),
# hdid = 登录帧 t1.t0 的 32hex appSign (R15 定案):
# md5("5008_" + app_version + "_" + k1) = ed0db833...ab5a5
# k1 = 865a4924a40897ac1fcfe6b4c2cbb0e3 (libhydeviceid.so datadiv 内嵌常量, R39)
# → 是 app 版本级常量 (同版本所有真机同值), 非设备级, 离线可算
"hdid": HDID32,
# 注: 不含 safedeviceid —— 每次登录前由 dfp_register 注册链签发,
# 画像不再持久化固定令牌 (见模块注释)。
}
def record_login(account: str, ok: bool, message: str = "") -> None:
"""记录该账号环境的一次登录结果 (最后登录时间/成败)。
幂等元数据: bound_at 只在首次出现时写入; 失败也记录 (设备绑定页要展示)。
供 app_login 登录流程调用, GUI 设备绑定页读取展示。
"""
import time as _time
key = canonical_account_key(account)
db = _load_db()
rec = db.get(key)
if rec is None and key != account:
rec = db.get(account)
if rec is not None:
db[key] = rec
del db[account]
if rec is None:
return # 尚无环境的账号 (纯 Cookie 导入) 不生成记录
now = int(_time.time())
rec.setdefault("bound_at", now)
rec["last_login"] = {"ok": bool(ok), "msg": str(message or "")[:120], "at": now}
_save_db(db)
def _load_db() -> dict:
if PRIMARY_PROFILE_DB.exists():
try:
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
except Exception as exc: # noqa: BLE001
logger.debug(f"读取主设备画像库失败: {exc}")
if ALLOW_LEGACY_PROFILE_IMPORT and FALLBACK_PROFILE_DB.exists():
try:
return json.loads(FALLBACK_PROFILE_DB.read_text("utf-8"))
except Exception as exc: # noqa: BLE001
logger.debug(f"读取显式迁移画像库失败: {exc}")
return {}
def _save_db(db: dict) -> None:
try:
PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True)
PRIMARY_PROFILE_DB.write_text(
json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8"
)
except Exception as exc: # noqa: BLE001
logger.debug(f"保存设备画像库失败: {exc}")
def _enrich_profile(profile: dict) -> tuple[dict, bool]:
"""补齐 dfp 一致性字段 (幂等): guid32 / Hebe_D1-D5, 返回 (新画像, 是否有变更)。
来源: R36 解密的真实 dfp 指纹 JSON 结构 (deviceinfo.guid + deviceinfo.Hebe_D1-D5)。
一号一设备: 每账号独立生成后终身不变, 供后续真实载荷构建与 GUI 设备绑定页展示。
注意: 返回新字典 (不原地改), 调用方据此判断是否需要回写。
"""
out = dict(profile)
changed = False
if not out.get("guid32"):
out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest()
changed = True
if len(out.get("hebe") or {}) < 5:
out["hebe"] = {
f"Hebe_D{i}": hashlib.sha256(
os.urandom(16) + f"hebe{i}".encode()
).hexdigest()
for i in range(1, 6)
}
changed = True
return out, changed
def get_profile(account: str, force_new: bool = False) -> dict:
"""按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。"""
key = canonical_account_key(account)
db = _load_db()
# Migrate the old prefixed key lazily without discarding its environment.
legacy_key = str(account or "").strip()
if key not in db and legacy_key != key and legacy_key in db:
db[key] = db.pop(legacy_key)
_save_db(db)
if not force_new and key in db:
enriched, changed = _enrich_profile(db[key])
if changed:
db[key] = enriched
_save_db(db)
return enriched
p, _ = _enrich_profile(generate_profile())
db[key] = p
_save_db(db)
return p