清理虎牙登录旧数据依赖并统一动态环境
This commit is contained in:
@@ -39,6 +39,7 @@ from .app_login import HuyaAppPasswordLogin
|
||||
from .device_profile import (
|
||||
_load_db,
|
||||
_save_db,
|
||||
canonical_account_key,
|
||||
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
|
||||
)
|
||||
|
||||
@@ -68,9 +69,10 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
|
||||
(R36 解密真实结构), 每账号独立生成终身复用 → 一号一设备的一致性来源;
|
||||
当前注册链服务端不校验 cw 内容, 此字段为后续真实载荷构建预留
|
||||
"""
|
||||
env = get_profile(account, force_new=force_new)
|
||||
key = canonical_account_key(account)
|
||||
env = get_profile(key, force_new=force_new)
|
||||
db = _load_db()
|
||||
record = dict(db.get(account) or env)
|
||||
record = dict(db.get(key) or env)
|
||||
changed = False
|
||||
# guid32 / hebe: 一号一致字段, 首次生成后终身不变
|
||||
if "guid32" not in record:
|
||||
@@ -82,7 +84,7 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
|
||||
}
|
||||
changed = True
|
||||
if changed:
|
||||
db[account] = record
|
||||
db[key] = record
|
||||
_save_db(db)
|
||||
return record
|
||||
|
||||
@@ -102,7 +104,8 @@ def bind_and_login(
|
||||
就是本环境的 40hex → 签发的 t2/t5 与环境绑定); safe_auth 滑块过验后的重发
|
||||
沿用同一组设备字段 (app_login.login_cred_with_flow)。
|
||||
"""
|
||||
env = get_or_create_env(account, force_new=force_new_device)
|
||||
key = canonical_account_key(account)
|
||||
env = get_or_create_env(key, force_new=force_new_device)
|
||||
print(
|
||||
f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} "
|
||||
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
|
||||
@@ -124,14 +127,14 @@ def bind_and_login(
|
||||
|
||||
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
|
||||
db = _load_db()
|
||||
record = dict(db.get(account) or env)
|
||||
record = dict(db.get(key) or env)
|
||||
record.setdefault("bound_at", int(time.time())) # 首次绑定时间
|
||||
record["last_login"] = {
|
||||
"ok": result.success,
|
||||
"msg": result.message[:120],
|
||||
"at": int(time.time()),
|
||||
}
|
||||
db[account] = record
|
||||
db[key] = record
|
||||
_save_db(db)
|
||||
|
||||
return {
|
||||
|
||||
+28
-23
@@ -68,21 +68,6 @@ _URL_TAIL_KEEP = set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
|
||||
)
|
||||
|
||||
DEFAULT_GOLDEN_DEV = {
|
||||
"app_version": "13.4.22",
|
||||
"sdk_version": "1.0.80138",
|
||||
"vendor": "xiaomi",
|
||||
"model": "M2102J2SC",
|
||||
"os": "android",
|
||||
"ip": "127.0.0.1",
|
||||
"fingerprint": "02df398797432eadefcc12767119ad5e80999389",
|
||||
"screen": "M2102J2SC,30,11",
|
||||
"width": "1080",
|
||||
"height": "2120",
|
||||
"device_id": "7c5387e0539c023c31c4ff0e807e7256117385ee",
|
||||
"hdid": "ed0db8334cadd236c00cadf7e11ab5a5", # HDID32 登录t1.t0 (勿与GUID32混)
|
||||
}
|
||||
|
||||
|
||||
class HuyaAppLoginError(HuyaLoginError):
|
||||
"""虎牙 App 登录失败。"""
|
||||
@@ -147,11 +132,14 @@ def wup_password_login_raw(
|
||||
except DfpRegistrationError as exc:
|
||||
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
||||
dev["device_id"] = registered_device_id
|
||||
hdid_value = hdid or dev.get("hdid")
|
||||
if not hdid_value:
|
||||
raise HuyaAppLoginError("账号设备画像缺少 HDID32,拒绝使用固定金样本")
|
||||
pkt = build_password_login_wup(
|
||||
uid_str,
|
||||
hashlib.sha1(password.encode()).hexdigest(),
|
||||
safedeviceid,
|
||||
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5",
|
||||
str(hdid_value),
|
||||
mj["session"],
|
||||
mj["traceId"],
|
||||
ua,
|
||||
@@ -227,6 +215,8 @@ def solve_safe_auth(
|
||||
HuyaVerificationSolver,
|
||||
)
|
||||
|
||||
if device_info is None:
|
||||
raise HuyaAppLoginError("safe_auth 缺少当前账号设备画像")
|
||||
q = {
|
||||
k: v[0]
|
||||
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
|
||||
@@ -235,7 +225,7 @@ def solve_safe_auth(
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(max_retry):
|
||||
solver = HuyaVerificationSolver(
|
||||
ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
||||
ua=mobile_user_agent(device_info),
|
||||
proxies=proxies,
|
||||
app_id=app_id,
|
||||
page_url=risk_url,
|
||||
@@ -357,6 +347,8 @@ class QrRole:
|
||||
):
|
||||
self.pc = pc
|
||||
self.sdid = sdid
|
||||
if not pc and device_info is None:
|
||||
raise HuyaAppLoginError("移动端二维码角色缺少当前账号设备画像")
|
||||
self.s = requests.Session()
|
||||
self.s.trust_env = False
|
||||
if proxies:
|
||||
@@ -370,9 +362,7 @@ class QrRole:
|
||||
self.req_counter = random.randint(40_000_000, 41_000_000)
|
||||
self.s.headers.update(
|
||||
{
|
||||
"User-Agent": UA_PC
|
||||
if pc
|
||||
else mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
||||
"User-Agent": UA_PC if pc else mobile_user_agent(device_info),
|
||||
"Origin": UDB_BASE,
|
||||
"content-type": "application/json;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
@@ -511,7 +501,15 @@ class HuyaAppPasswordLogin:
|
||||
raw[env.cert_off : env.cert_off + env.cert_len] = cert.encode("ascii")
|
||||
if env.uid != uid:
|
||||
struct.pack_into(">Q", raw, env.uid_off, uid)
|
||||
wup = base64.b64encode(bytes(raw)).decode("ascii")
|
||||
# QR 信封只保留协议结构;不要重放抓包里的旧会话值。
|
||||
qr_session = random.randint(1_000_000, 9_999_999)
|
||||
qr_trace = (
|
||||
f"{uuid.uuid4().hex[:16]}-{random.randint(10000, 99999)}-"
|
||||
f"{time.time_ns():020d}"
|
||||
)
|
||||
env.raw = raw
|
||||
env.patch_session(qr_session).patch_meta(qr_session, qr_trace)
|
||||
wup = base64.b64encode(bytes(env.raw)).decode("ascii")
|
||||
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
return HuyaLoginResult(
|
||||
success=False,
|
||||
@@ -524,11 +522,18 @@ class HuyaAppPasswordLogin:
|
||||
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
|
||||
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
|
||||
sdid_obj = get_huya_sdid(
|
||||
allow_fallback=True,
|
||||
# App 主链只接受当前账号的 hydevice 高信任结果;旧版
|
||||
# token+collect 降级没有账号画像,不再静默放行。
|
||||
allow_fallback=False,
|
||||
state_dir=account_state_dir(self.username),
|
||||
device_hint=self.device_info,
|
||||
)
|
||||
sdid = sdid_obj.sdid if sdid_obj else ""
|
||||
if not sdid_obj or not sdid_obj.sdid or sdid_obj.source != "fingerprint":
|
||||
detail = sdid_obj.message if sdid_obj else "未返回结果"
|
||||
raise HuyaAppLoginError(
|
||||
f"账号设备指纹获取失败(必须为 hydevice): {detail}"
|
||||
)
|
||||
sdid = sdid_obj.sdid
|
||||
logger.info("[huya-app] cred 已获取,开始二维码绑定流程")
|
||||
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
||||
ph = QrRole(
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from .device_profile import mobile_user_agent
|
||||
from .device_profile import canonical_account_key, mobile_user_agent
|
||||
|
||||
FINGERPRINT_DIR = Path(__file__).parent / "fingerprint"
|
||||
RUNNER_JS = FINGERPRINT_DIR / "runner.js"
|
||||
@@ -31,10 +31,25 @@ FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states"
|
||||
|
||||
def account_state_dir(account: str) -> Path:
|
||||
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
|
||||
raw = str(account or "anon").strip()
|
||||
account = canonical_account_key(raw)
|
||||
safe = "".join(
|
||||
c if c.isalnum() or c in "-_." else "_" for c in (account or "anon")
|
||||
)[:64]
|
||||
return FP_STATE_ROOT / safe
|
||||
target = FP_STATE_ROOT / safe
|
||||
# Move a pre-rename ``hy_<numeric>`` directory on first access when the
|
||||
# canonical directory does not exist. If both exist, keep the canonical
|
||||
# state and leave the legacy directory untouched for manual cleanup.
|
||||
if raw != account:
|
||||
legacy_safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in raw)[:64]
|
||||
legacy = FP_STATE_ROOT / legacy_safe
|
||||
if not target.exists() and legacy.exists():
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy.rename(target)
|
||||
except OSError as exc:
|
||||
logger.debug("迁移旧虎牙设备状态失败: {}", exc)
|
||||
return target
|
||||
|
||||
|
||||
def reset_account_state(account: str) -> None:
|
||||
|
||||
@@ -19,6 +19,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
@@ -28,6 +29,7 @@ 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)),
|
||||
@@ -51,6 +53,19 @@ 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 "")
|
||||
@@ -107,8 +122,14 @@ def record_login(account: str, ok: bool, message: str = "") -> None:
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
key = canonical_account_key(account)
|
||||
db = _load_db()
|
||||
rec = db.get(account)
|
||||
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())
|
||||
@@ -123,11 +144,11 @@ def _load_db() -> dict:
|
||||
return json.loads(PRIMARY_PROFILE_DB.read_text("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(f"读取主设备画像库失败: {exc}")
|
||||
if FALLBACK_PROFILE_DB.exists():
|
||||
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}")
|
||||
logger.debug(f"读取显式迁移画像库失败: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
@@ -166,14 +187,20 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
|
||||
|
||||
def get_profile(account: str, force_new: bool = False) -> dict:
|
||||
"""按账号获取或创建画像(幂等:同账号复用同一套, 自动补齐缺失一致性字段)。"""
|
||||
key = canonical_account_key(account)
|
||||
db = _load_db()
|
||||
if not force_new and account in db:
|
||||
enriched, changed = _enrich_profile(db[account])
|
||||
# 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[account] = enriched
|
||||
db[key] = enriched
|
||||
_save_db(db)
|
||||
return enriched
|
||||
p, _ = _enrich_profile(generate_profile())
|
||||
db[account] = p
|
||||
db[key] = p
|
||||
_save_db(db)
|
||||
return p
|
||||
|
||||
+19
-11
@@ -90,7 +90,7 @@ def _build_select_operator_request(
|
||||
# 仅供无画像的协议级独立调用兜底;生产登录始终传入账号画像。
|
||||
"app_version": "13.4.22",
|
||||
"model": "M2102J2SC",
|
||||
"fingerprint": fingerprint or "02df398797432eadefcc12767119ad5e80999389",
|
||||
"fingerprint": fingerprint or hashlib.sha1(os.urandom(20)).hexdigest(),
|
||||
"screen": "M2102J2SC,30,11",
|
||||
}
|
||||
if device_info:
|
||||
@@ -248,16 +248,21 @@ def _build_dfp_json_plain(device_info: Mapping[str, str] | None = None) -> bytes
|
||||
|
||||
|
||||
def _select_operator_request(template: bytes, fingerprint: str | None) -> bytes:
|
||||
if fingerprint and len(fingerprint) == 40:
|
||||
old = b"02df398797432eadefcc12767119ad5e80999389"
|
||||
index = template.find(old)
|
||||
if index >= 0:
|
||||
return (
|
||||
template[:index]
|
||||
+ fingerprint.encode("ascii")
|
||||
+ template[index + len(old) :]
|
||||
)
|
||||
return template
|
||||
"""Inject a current 40-hex fingerprint into a legacy request shape.
|
||||
|
||||
This compatibility path only operates on the shape supplied by a caller;
|
||||
it does not contain or search for a particular captured device value.
|
||||
"""
|
||||
if not fingerprint or len(fingerprint) != 40:
|
||||
return template
|
||||
match = re.search(rb"(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])", template)
|
||||
if match is None:
|
||||
return template
|
||||
return (
|
||||
template[: match.start()]
|
||||
+ fingerprint.encode("ascii")
|
||||
+ template[match.end() :]
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(data: bytes) -> tuple[str, str, str]:
|
||||
@@ -284,6 +289,9 @@ def register_device(
|
||||
"""执行新注册链,返回 ``(t1, safedeviceid, device_id)``。"""
|
||||
chain = _load_chain(fingerprint=fingerprint, device_info=device_info)
|
||||
_post(chain["getDfpConfig"][0], timeout=timeout, proxies=proxies)
|
||||
# The generated request already contains the current profile fingerprint.
|
||||
# Keep a generic shape-only compatibility patch for injected test/custom
|
||||
# templates; no captured device value is embedded in this module.
|
||||
select_request = _select_operator_request(chain["selectOperator"][0], fingerprint)
|
||||
_post(select_request, "application/x-wup", timeout, proxies)
|
||||
response = _post(
|
||||
|
||||
@@ -18,7 +18,7 @@ MAP, LIST = 0x08, 0x09
|
||||
STRUCT_BEGIN, STRUCT_END = 0x0A, 0x0B
|
||||
ZERO, SIMPLE_LIST = 0x0C, 0x0D
|
||||
|
||||
DEFAULT_QURL_B64 = (
|
||||
PROTOCOL_QURL_TEMPLATE_B64 = (
|
||||
"AAAD5hADLDxCAFpBBVYMaHV5YXVkYndlYnVpZgdkZWZhdWx0fQABA7gIAAIGCV93dXBfZGF0YR0AAQOKCgoMFgMxLjAm"
|
||||
"ynsiYXNzb2NpYXRpb25JZCI6MTg0NTQ5MzkyLCJmdW5jTmFtZSI6IiIsImdyb3VwIjowLCJpZCI6MTg0NTQ5MzkyLCJz"
|
||||
"ZXNzaW9uIjo1OTE0ODg1LCJzdGVwIjowLCJzdGlsbExvZ2luIjpmYWxzZSwidHJhY2VJZCI6IjBiOGYwOThmZjY0YTVi"
|
||||
@@ -135,7 +135,7 @@ class Envelope:
|
||||
return cls._load_from_path(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(f"加载证书信封候选文件失败: {candidate}: {exc}")
|
||||
return cls(base64.b64decode(DEFAULT_QURL_B64))
|
||||
return cls(base64.b64decode(PROTOCOL_QURL_TEMPLATE_B64))
|
||||
|
||||
@classmethod
|
||||
def _load_from_path(cls, p: Path) -> Envelope:
|
||||
@@ -258,6 +258,7 @@ class Envelope:
|
||||
return self
|
||||
|
||||
def patch_session(self, session: int) -> Envelope:
|
||||
"""Patch the outer WUP session and its request copy."""
|
||||
if self.tag4_span:
|
||||
struct.pack_into(">I", self.raw, self.tag4_span[0], session & 0xFFFFFFFF)
|
||||
d = self.raw
|
||||
@@ -268,5 +269,33 @@ class Envelope:
|
||||
struct.pack_into(">I", d, j, session & 0xFFFFFFFF)
|
||||
return self
|
||||
|
||||
def patch_meta(self, session: int, trace_id: str) -> Envelope:
|
||||
"""Replace QR metadata values without carrying the capture's old state.
|
||||
|
||||
The captured envelope keeps a fixed-size JSON string. Keeping the
|
||||
replacement the same size lets us update only the value bytes and
|
||||
preserve all TAF length prefixes and offsets.
|
||||
"""
|
||||
if self.meta_json_span is None:
|
||||
raise ValueError("信封缺少元数据 JSON")
|
||||
start, end = self.meta_json_span
|
||||
current = bytes(self.raw[start:end])
|
||||
try:
|
||||
meta = json.loads(current.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("信封元数据 JSON 无法解析") from exc
|
||||
meta["session"] = int(session)
|
||||
meta["traceId"] = str(trace_id)
|
||||
updated = json.dumps(meta, ensure_ascii=False, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
if len(updated) != len(current):
|
||||
raise ValueError(
|
||||
f"信封元数据长度变化: {len(updated)} != {len(current)}; "
|
||||
"请使用固定长度 session/traceId"
|
||||
)
|
||||
self.raw[start:end] = updated
|
||||
return self
|
||||
|
||||
def wup_b64(self) -> str:
|
||||
return base64.b64encode(bytes(self.raw)).decode()
|
||||
|
||||
Reference in New Issue
Block a user