629 lines
22 KiB
Python
629 lines
22 KiB
Python
"""虎牙 App 渠道协议登录模块。
|
|
|
|
流程:
|
|
1. 零设备注册链生成随机 dfpReport,获取新 safedeviceid/device_id
|
|
2. 账号+密码+新注册字段 -> WUP 密码登录 (POST wup.huya.com)
|
|
3. safe_auth 滑块自动过验 -> 提取 fresh cred 与真实 uid
|
|
4. 本地 XXTEA 算 nonce -> 铸造登录证书 (cert_forge) -> 补丁 WUP 信封 (envelope_forge)
|
|
5. 模拟扫码绑定四步流 (getQrId -> scanQrPicNotify -> bindQrLoginUser -> tryQrLogin) 获取 biztoken
|
|
6. POST /web/cookie/verify 兑换获取全套网页 Cookie
|
|
|
|
注册链不再重放旧 dfpReport 密文。登录帧的 32hex hdid 仍是服务端硬锚,
|
|
当前继续使用已注册样本;新注册链动态更新的是 safedeviceid 和 device_id。
|
|
注册链(``core/huya/dfp_register``)每次登录前执行,失败即抛错终止(``HuyaAppLoginError``),
|
|
不读取画像里的旧固定值,也不静默回退旧链。
|
|
|
|
⚠ 风险与边界(详见 ``docs/HUYA_APP_OVERVIEW.md``):
|
|
- hdid 全账号固定共享 -> 服务端可设备维度关联账号;
|
|
- 滑块 UA / session / traceId / userAction 均按当前画像和本次请求动态生成;
|
|
- qr_auth(扫码)/dx_auth(短信) 无自动闭环 -> 碰上直接失败(QR_AUTH_REQUIRED)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import re
|
|
import struct
|
|
import time
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from urllib.parse import parse_qs, quote, urlparse
|
|
|
|
import requests
|
|
from loguru import logger
|
|
|
|
from .cert_forge import build_p1, decrypt_cert, forge_cert, parse_p1
|
|
from .cookie_utils import normalize_huya_cookie
|
|
from .device_fingerprint import account_state_dir, get_huya_sdid
|
|
from .device_profile import get_profile, mobile_user_agent
|
|
from .dfp_register import DfpRegistrationError, register_device
|
|
from .envelope_forge import Envelope
|
|
from .login import HuyaLoginError, HuyaLoginResult
|
|
from .nonce_forge import K1_DEFAULT, gen_nonce
|
|
from .wup_encoder import build_password_login_wup
|
|
|
|
WUP_URL = "https://wup.huya.com"
|
|
UDB_BASE = "https://udblgn.huya.com"
|
|
|
|
APP_SIGN_WEB = "1ce3bf682483d03f146f58232ec10635"
|
|
APP_SIGN_H5 = "0ba67962ab9e12387648efeae2750777"
|
|
|
|
UA_PC = (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
|
|
)
|
|
APP_UA_MOBILE = (
|
|
"Mozilla/5.0 (Linux; Android 11; M2102J2SC Build/RKQ1.200826.002; wv) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
|
|
"Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/13.4.22/xiaomi/30"
|
|
)
|
|
|
|
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
|
|
_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 登录失败。"""
|
|
|
|
|
|
class HuyaAppQrAuthRequiredError(HuyaAppLoginError):
|
|
"""风控要求扫码验证 (qr_auth)。"""
|
|
|
|
|
|
def _golden_session_assets() -> tuple[dict, str, str]:
|
|
"""生成本次登录的会话元数据和行为轨迹。"""
|
|
from .wup_encoder import make_trace_id, make_user_action
|
|
|
|
mj = {
|
|
"associationId": 8193,
|
|
"funcName": "hypasswordLogin",
|
|
"group": 1,
|
|
"id": 4097,
|
|
"session": random.randint(1_000_000, 9_999_999),
|
|
"step": 1,
|
|
"stillLogin": False,
|
|
"traceId": make_trace_id(),
|
|
"type": 3,
|
|
"uid": 0,
|
|
"userContext": "",
|
|
}
|
|
return mj, make_user_action(), ""
|
|
|
|
|
|
def _android_version(device_info: Mapping[str, object]) -> str:
|
|
parts = str(device_info.get("screen") or "").split(",")
|
|
return parts[2] if len(parts) >= 3 and parts[2] else "11"
|
|
|
|
|
|
def wup_password_login_raw(
|
|
account: str,
|
|
password: str,
|
|
timeout: int = 15,
|
|
device_info: dict | None = None,
|
|
safedeviceid: str | None = None,
|
|
hdid: str | None = None,
|
|
proxies: dict | None = None,
|
|
) -> bytes:
|
|
"""发送 WUP 密码登录,返回原始响应字节。
|
|
|
|
未显式传入 ``safedeviceid`` 时会先执行新设备注册链,不再回退旧的
|
|
固定 action/device_id。风控重试调用方应显式复用同一注册结果。
|
|
注册链失败抛 ``HuyaAppLoginError``,绝不静默回退旧固定值。
|
|
"""
|
|
uid_str = account.removeprefix("hy_")
|
|
dev = dict(device_info) if device_info is not None else get_profile(account)
|
|
mj, ua, _old_sd = _golden_session_assets()
|
|
if not safedeviceid:
|
|
try:
|
|
_t1, safedeviceid, registered_device_id = register_device(
|
|
fingerprint=dev.get("fingerprint"),
|
|
proxies=proxies,
|
|
timeout=timeout,
|
|
device_info=dev,
|
|
)
|
|
except DfpRegistrationError as exc:
|
|
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
|
dev["device_id"] = registered_device_id
|
|
pkt = build_password_login_wup(
|
|
uid_str,
|
|
hashlib.sha1(password.encode()).hexdigest(),
|
|
safedeviceid,
|
|
hdid or dev.get("hdid") or "ed0db8334cadd236c00cadf7e11ab5a5",
|
|
mj["session"],
|
|
mj["traceId"],
|
|
ua,
|
|
dev,
|
|
)
|
|
r = requests.post(
|
|
WUP_URL,
|
|
data=pkt,
|
|
headers={
|
|
"Content-Type": "application/multipart-formdata; charset=UTF-8",
|
|
"User-Agent": f"Dalvik/2.1.0 (Linux; U; Android {_android_version(dev)})",
|
|
"Accept-Encoding": "gzip",
|
|
},
|
|
proxies=proxies,
|
|
timeout=timeout,
|
|
)
|
|
if r.status_code != 200:
|
|
raise HuyaAppLoginError(f"登录 HTTP 状态异常: {r.status_code}")
|
|
return r.content
|
|
|
|
|
|
def parse_cred(resp: bytes) -> bytes | None:
|
|
"""从 WUP 响应中提取 114 字节的 cred。"""
|
|
s = resp.find(b"\x0a\x0a", 0x40)
|
|
e = resp.find(b"_wup_header")
|
|
if s < 0 or e < 0:
|
|
return None
|
|
d = resp[s : e - 6]
|
|
m = re.search(rb"\x3d\x00([\x00-\x03])(.)", d)
|
|
if not m:
|
|
return None
|
|
ln = m.group(2)[0]
|
|
st = m.start() + 4
|
|
cred = d[st : st + ln]
|
|
if len(cred) == 114 and cred[:1] == b"\x0a":
|
|
return cred
|
|
return None
|
|
|
|
|
|
def parse_risk_url(resp: bytes) -> str | None:
|
|
"""提取 safe_auth 风控 URL。"""
|
|
urls = []
|
|
for m in RISK_URL_RE.finditer(resp):
|
|
u = m.group().decode("utf-8", "ignore")
|
|
while u and u[-1] not in _URL_TAIL_KEEP:
|
|
u = u[:-1]
|
|
urls.append(u)
|
|
if not urls:
|
|
return None
|
|
pt = [u for u in urls if "pt_auth" in u]
|
|
return (pt or urls)[0]
|
|
|
|
|
|
def solve_safe_auth(
|
|
risk_url: str,
|
|
proxies=None,
|
|
max_retry: int = 3,
|
|
device_info: Mapping[str, object] | None = None,
|
|
) -> dict:
|
|
"""解 safe_auth 风控滑块。"""
|
|
from .verification.solver import (
|
|
HuyaQrAuthRequiredError,
|
|
HuyaVerificationSolver,
|
|
)
|
|
|
|
q = {
|
|
k: v[0]
|
|
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
|
|
}
|
|
app_id = str(q.get("appId") or "5002")
|
|
last_err: Exception | None = None
|
|
for attempt in range(max_retry):
|
|
solver = HuyaVerificationSolver(
|
|
ua=mobile_user_agent(device_info or DEFAULT_GOLDEN_DEV),
|
|
proxies=proxies,
|
|
app_id=app_id,
|
|
page_url=risk_url,
|
|
use_touch_events=True,
|
|
)
|
|
try:
|
|
result = solver.solve(risk_url)
|
|
except HuyaQrAuthRequiredError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
|
last_err = exc
|
|
logger.warning(f"[safe_auth] 第 {attempt + 1} 次过验异常: {exc}")
|
|
time.sleep(1.0)
|
|
continue
|
|
auth_id = str((result or {}).get("authId") or "")
|
|
if auth_id:
|
|
logger.info(f"[safe_auth] 滑块通过 authId={auth_id[:24]}...")
|
|
return result
|
|
raise HuyaAppLoginError(f"safe_auth 滑块过验失败: {last_err or '未返回 authId'}")
|
|
|
|
|
|
def parse_real_uid(resp: bytes) -> int:
|
|
"""从登录响应中提取 12 位真实 UID。"""
|
|
s = resp.find(b"\x0a\x0a", 0x40)
|
|
e = resp.find(b"_wup_header")
|
|
if s >= 0 and e >= 0:
|
|
chunk = resp[s:e]
|
|
if len(chunk) > 243:
|
|
uid = struct.unpack_from(">Q", chunk, 235)[0]
|
|
if 1_100_000_000_000 < uid < 1_300_000_000_000:
|
|
return uid
|
|
for off in range(len(chunk) - 8):
|
|
u = struct.unpack_from(">Q", chunk, off)[0]
|
|
if 1_100_000_000_000 < u < 1_300_000_000_000:
|
|
return u
|
|
raise HuyaAppLoginError("无法从登录响应解析真实 UID")
|
|
|
|
|
|
def login_cred_with_flow(
|
|
account: str,
|
|
password: str,
|
|
max_rounds: int = 3,
|
|
device_info: dict | None = None,
|
|
proxies: dict | None = None,
|
|
) -> tuple[bytes, int]:
|
|
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
|
|
|
|
注册只执行一次;safe_auth 通过后的重发继续使用同一组设备字段。
|
|
"""
|
|
dev = dict(device_info) if device_info is not None else get_profile(account)
|
|
try:
|
|
_t1, safedeviceid, registered_device_id = register_device(
|
|
fingerprint=dev.get("fingerprint"),
|
|
proxies=proxies,
|
|
device_info=dev,
|
|
)
|
|
except DfpRegistrationError as exc:
|
|
raise HuyaAppLoginError(f"新设备注册失败: {exc}") from exc
|
|
dev["device_id"] = registered_device_id
|
|
for rnd in range(max_rounds):
|
|
resp = wup_password_login_raw(
|
|
account,
|
|
password,
|
|
device_info=dev,
|
|
safedeviceid=safedeviceid,
|
|
proxies=proxies,
|
|
)
|
|
cred = parse_cred(resp)
|
|
if cred:
|
|
uid = parse_real_uid(resp)
|
|
return cred, uid
|
|
risk_url = parse_risk_url(resp)
|
|
if risk_url:
|
|
kind = (
|
|
"pt_auth(滑块)"
|
|
if "pt_auth" in risk_url
|
|
else ("qr_auth(扫码)" if "qr_auth" in risk_url else "未知")
|
|
)
|
|
logger.info(f"[huya-app] 第 {rnd + 1} 轮触发安全验证: {kind}")
|
|
if "qr_auth" in risk_url:
|
|
raise HuyaAppQrAuthRequiredError(
|
|
"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
|
|
)
|
|
solve_safe_auth(risk_url, proxies=proxies, device_info=dev)
|
|
logger.info("[huya-app] safe_auth 滑块过验成功,重发 WUP 登录...")
|
|
continue
|
|
raise HuyaAppLoginError("登录未返回凭据也无风控URL(密码错误或账号状态异常)")
|
|
raise HuyaAppLoginError(f"{max_rounds} 轮内未取得登录凭据")
|
|
|
|
|
|
class QrRole:
|
|
"""udblgn.huya.com/qrLgn/* 的 JSON 协议封装。"""
|
|
|
|
def __init__(
|
|
self,
|
|
pc: bool,
|
|
sdid: str,
|
|
proxies: dict | None = None,
|
|
device_info: Mapping[str, object] | None = None,
|
|
):
|
|
self.pc = pc
|
|
self.sdid = sdid
|
|
self.s = requests.Session()
|
|
self.s.trust_env = False
|
|
if proxies:
|
|
self.s.proxies.update(proxies)
|
|
ctx_hex = uuid.uuid4().hex
|
|
tail = "CBC9F93DBEB000011DB11EC02A401BAD-" if pc else uuid.uuid4().hex.upper()
|
|
tail = tail if tail.endswith("-") else tail + "-"
|
|
prefix = "WB" if pc else "H5"
|
|
self.context = f"{prefix}-{ctx_hex}-{tail}"
|
|
self.page_id = random.randint(40_000_000, 41_000_000)
|
|
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),
|
|
"Origin": UDB_BASE,
|
|
"content-type": "application/json;charset=UTF-8",
|
|
"Accept": "*/*",
|
|
}
|
|
)
|
|
|
|
def _headers(self, uri: str) -> dict:
|
|
mid = "2.6" if self.pc else "2.5"
|
|
return {
|
|
"context": self.context,
|
|
"uri": uri,
|
|
"reqid": str(self.req_counter),
|
|
"lcid": "2052",
|
|
"Referer": f"{UDB_BASE}/web/middle/{mid}/{self.page_id}/https/{self.context.split('-')[1]}",
|
|
}
|
|
|
|
def call(
|
|
self, path: str, uri: str, data: dict, cookies: dict | None = None
|
|
) -> dict:
|
|
envelope = {
|
|
"uri": uri,
|
|
"version": "2.6" if self.pc else "2.5",
|
|
"context": self.context,
|
|
"appId": "5002" if self.pc else "5131",
|
|
"appSign": APP_SIGN_WEB if self.pc else APP_SIGN_H5,
|
|
"authId": "",
|
|
"sdid": self.sdid,
|
|
"lcid": "2052",
|
|
"byPass": "3",
|
|
"requestId": str(self.req_counter),
|
|
"data": data,
|
|
}
|
|
self.req_counter += random.randint(120, 400)
|
|
r = self.s.post(
|
|
f"{UDB_BASE}{path}",
|
|
json=envelope,
|
|
headers=self._headers(uri),
|
|
cookies=cookies,
|
|
timeout=20,
|
|
)
|
|
return r.json()
|
|
|
|
|
|
def web_behavior(page: str = "https://www.huya.com/g") -> tuple[str, str]:
|
|
now = int(time.time() * 1000) - random.randint(3000, 10000)
|
|
acts = []
|
|
d = random.randint(800, 1800)
|
|
for a in ("7", "7", "8", "8"):
|
|
now += random.randint(600, 2000)
|
|
d += random.randint(600, 2000)
|
|
acts.append({"id": a, "d": d, "time": now})
|
|
val = {"furl": page, "curl": page, "user_action": acts}
|
|
beh = quote(json.dumps(val, separators=(",", ":")), safe="~()*!.'")
|
|
return beh, quote(page, safe="")
|
|
|
|
|
|
class HuyaAppPasswordLogin:
|
|
"""虎牙 App 渠道纯协议密码登录器。"""
|
|
|
|
def __init__(
|
|
self,
|
|
username: str,
|
|
password: str,
|
|
proxies: Mapping[str, str] | None = None,
|
|
timeout: tuple[float, float] | None = None,
|
|
force_new_device: bool = False,
|
|
device_info: dict | None = None,
|
|
):
|
|
self.username = username.strip()
|
|
self.password = password
|
|
self.proxies = dict(proxies) if proxies else None
|
|
self.timeout = timeout or (10.0, 25.0)
|
|
self.force_new_device = force_new_device
|
|
self.device_info = device_info or get_profile(
|
|
self.username, force_new=force_new_device
|
|
)
|
|
|
|
def login(self) -> HuyaLoginResult:
|
|
"""执行完整 App 登录获取 Cookie 流程 (成功/失败均记录登录时间 → 设备绑定页)。"""
|
|
result = self._login_impl()
|
|
try:
|
|
from .device_profile import record_login
|
|
|
|
record_login(self.username, result.success, result.message)
|
|
except Exception as exc: # noqa: BLE001 # 元数据记录失败不影响登录结果
|
|
logger.debug(f"登录元数据记录失败: {exc}")
|
|
return result
|
|
|
|
def _login_impl(self) -> HuyaLoginResult:
|
|
"""登录主体 (原 login)。"""
|
|
acct = self.username
|
|
logger.info(
|
|
f"[huya-app] 开始登录账号 {acct} (机型: {self.device_info.get('model')})..."
|
|
)
|
|
|
|
# 1) 获取新鲜 cred 与 真实 uid (自动过 safe_auth 滑块)
|
|
try:
|
|
cred, uid = login_cred_with_flow(
|
|
acct,
|
|
self.password,
|
|
device_info=self.device_info,
|
|
proxies=self.proxies,
|
|
)
|
|
except HuyaAppQrAuthRequiredError as exc:
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message=str(exc),
|
|
code="QR_AUTH_REQUIRED",
|
|
)
|
|
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message=f"App 登录凭证获取失败: {exc}",
|
|
code="LOGIN_FAILED",
|
|
)
|
|
|
|
logger.info(f"[huya-app] 成功获取 cred ({len(cred)}B), uid={uid}")
|
|
|
|
# 2) 本地生成 nonce 铸造证书 (P1 指纹与该账号设备画像一致)
|
|
try:
|
|
env = Envelope.load()
|
|
orig = base64.b64decode(env.cert_b64)
|
|
f = parse_p1(decrypt_cert(orig))
|
|
st = int(time.time() * 1000)
|
|
rnd = gen_nonce(uid, K1_DEFAULT, service_time_ms=st, counter=0)
|
|
fp_bytes = self.device_info["fingerprint"].encode("ascii")
|
|
p1 = build_p1(f["app_id"], fp_bytes, cred, rnd=rnd)
|
|
cert = base64.b64encode(forge_cert(p1, key_idx=orig[1])).decode()
|
|
|
|
# 3) 信封补丁
|
|
raw = bytearray(env.raw)
|
|
if env.cert_off is None or env.uid_off is None:
|
|
raise ValueError("信封缺少证书或 uid 偏移")
|
|
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")
|
|
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message=f"证书铸造/信封补丁失败: {exc}",
|
|
code="CERT_FORGE_FAILED",
|
|
)
|
|
|
|
# 4) 模拟扫码绑定四步流获取 biztoken
|
|
try:
|
|
# 一号一设备: sdid 状态按账号隔离 (默认全局目录会让所有账号共享
|
|
# 同一份 hydevice 设备状态, 服务端可跨账号关联 — 见 R40 缺口修复)
|
|
sdid_obj = get_huya_sdid(
|
|
allow_fallback=True,
|
|
state_dir=account_state_dir(self.username),
|
|
device_hint=self.device_info,
|
|
)
|
|
sdid = sdid_obj.sdid if sdid_obj else ""
|
|
pc = QrRole(pc=True, sdid=sdid, proxies=self.proxies)
|
|
ph = QrRole(
|
|
pc=False,
|
|
sdid=sdid,
|
|
proxies=self.proxies,
|
|
device_info=self.device_info,
|
|
)
|
|
beh, page = web_behavior()
|
|
|
|
# 4.1 获取 qrId
|
|
resp = pc.call(
|
|
"/qrLgn/getQrId",
|
|
"70001",
|
|
{"behavior": beh, "type": "", "domainList": "", "page": page},
|
|
)
|
|
qrid = (resp.get("data") or {}).get("qrId")
|
|
if not qrid:
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message=f"获取 qrId 失败: {resp.get('message') or resp}",
|
|
code="QR_ID_FAILED",
|
|
)
|
|
|
|
# 4.2 扫码与绑定
|
|
cp = f"https://aq.huya.com/r/confirm.html?k={qrid}&id=5002"
|
|
ph.call(
|
|
"/qrLgn/scanQrPicNotify",
|
|
"70005",
|
|
{
|
|
"qrId": qrid,
|
|
"wupData": wup,
|
|
"behavior": quote("[]", safe=""),
|
|
"page": quote(cp, safe=""),
|
|
},
|
|
)
|
|
r2 = ph.call(
|
|
"/qrLgn/bindQrLoginUser",
|
|
"70007",
|
|
{
|
|
"qrId": qrid,
|
|
"wupData": wup,
|
|
"behavior": quote("[]", safe=""),
|
|
"page": quote(cp, safe=""),
|
|
},
|
|
)
|
|
if r2.get("returnCode") not in (0, "0", None) and r2.get("returnCode") != 0:
|
|
logger.warning(
|
|
f"[huya-app] bind 返回码: {r2.get('returnCode')} msg: {r2.get('message')}"
|
|
)
|
|
|
|
# 4.3 轮询 tryQrLogin
|
|
biztoken = None
|
|
for _ in range(12):
|
|
rt = pc.call(
|
|
"/qrLgn/tryQrLogin",
|
|
"70003",
|
|
{
|
|
"qrId": qrid,
|
|
"remember": "1",
|
|
"domainList": "",
|
|
"behavior": beh,
|
|
"page": page,
|
|
},
|
|
)
|
|
dt = rt.get("data") or {}
|
|
if dt.get("stage") == 2:
|
|
biztoken = dt.get("biztoken")
|
|
break
|
|
time.sleep(1.5)
|
|
|
|
if not biztoken:
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message="未能在轮询时间内获取到 biztoken (绑定超时或失败)",
|
|
code="BIZTOKEN_TIMEOUT",
|
|
)
|
|
|
|
# 5) POST /web/cookie/verify 兑换 Cookie
|
|
verify_resp = pc.s.post(
|
|
"https://udblgn.huya.com/web/cookie/verify",
|
|
json={"appId": 5002},
|
|
timeout=15,
|
|
)
|
|
if verify_resp.status_code != 200:
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message=f"Cookie verify 兑换 HTTP 失败: {verify_resp.status_code}",
|
|
code="VERIFY_FAILED",
|
|
)
|
|
|
|
cookie_str = normalize_huya_cookie(pc.s.cookies)
|
|
if "udb_cred" not in cookie_str and "yyuid" not in cookie_str:
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message="Cookie 兑换完成但缺失关键凭据 (udb_cred/yyuid)",
|
|
code="COOKIE_INCOMPLETE",
|
|
)
|
|
|
|
logger.info(
|
|
f"[huya-app] 账号 {acct} 登录成功,获取完整 Cookie ({len(cookie_str)}B)"
|
|
)
|
|
return HuyaLoginResult(
|
|
success=True,
|
|
cookie=cookie_str,
|
|
message="App协议登录成功",
|
|
sdid=sdid,
|
|
context=pc.context,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
|
return HuyaLoginResult(
|
|
success=False,
|
|
message=f"扫码绑定兑换 Cookie 失败: {exc}",
|
|
code="BIND_FLOW_FAILED",
|
|
)
|
|
|
|
|
|
def login_huya_app_password(
|
|
username: str,
|
|
password: str,
|
|
proxies: Mapping[str, str] | None = None,
|
|
timeout: tuple[float, float] | None = None,
|
|
force_new_device: bool = False,
|
|
) -> HuyaLoginResult:
|
|
"""函数式入口:使用 App 协议执行虎牙密码登录并获取全套 Cookie。"""
|
|
return HuyaAppPasswordLogin(
|
|
username=username,
|
|
password=password,
|
|
proxies=proxies,
|
|
timeout=timeout,
|
|
force_new_device=force_new_device,
|
|
).login()
|