678 lines
25 KiB
Python
678 lines
25 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 仍是服务端硬锚,
|
||
登录帧中的 HDID32 是 app 版本级协议常量;二维码信封的 ACTION、设备字段、
|
||
UID、证书和会话元数据均在本次登录中动态生成或由注册链签发。
|
||
注册链(``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, forge_cert
|
||
from .cookie_utils import normalize_huya_cookie
|
||
from .device_fingerprint import account_state_dir, get_huya_sdid, reset_account_state
|
||
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"
|
||
)
|
||
|
||
SessionAssets = tuple[dict, str, str]
|
||
|
||
RISK_URL_RE = re.compile(rb"https://aq\.huya\.com/p/safe_auth/[^\x00-\x20\"'\\<>]+")
|
||
_URL_TAIL_KEEP = set(
|
||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%"
|
||
)
|
||
|
||
|
||
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,
|
||
session_assets: SessionAssets | 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 = session_assets or _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
|
||
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,
|
||
str(hdid_value),
|
||
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 _response_markers(resp: bytes) -> str:
|
||
"""提取 WUP 错误响应中的短 ASCII 字段,便于区分签名和凭据失败。"""
|
||
markers = []
|
||
for raw in re.findall(rb"[ -~]{4,}", resp):
|
||
value = raw.decode("ascii", "ignore")
|
||
if value not in markers and len(value) <= 160:
|
||
markers.append(value)
|
||
return ", ".join(markers[:8])
|
||
|
||
|
||
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,
|
||
)
|
||
|
||
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()
|
||
}
|
||
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),
|
||
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,
|
||
include_device_token: bool = False,
|
||
) -> tuple[bytes, int] | tuple[bytes, int, str]:
|
||
"""新注册设备后登录,返回 ``(新鲜 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
|
||
# 风控验证绑定首次 WUP 请求的 session/traceId;所有重发必须复用它们。
|
||
session_assets = _golden_session_assets()
|
||
for rnd in range(max_rounds):
|
||
logger.debug(f"[huya-app] WUP 登录请求第 {rnd + 1} 轮...")
|
||
resp = wup_password_login_raw(
|
||
account,
|
||
password,
|
||
device_info=dev,
|
||
safedeviceid=safedeviceid,
|
||
proxies=proxies,
|
||
session_assets=session_assets,
|
||
)
|
||
cred = parse_cred(resp)
|
||
risk_url = parse_risk_url(resp) if not cred else None
|
||
logger.debug(
|
||
f"[huya-app] WUP 第 {rnd + 1} 轮响应: {len(resp)}B, "
|
||
f"cred={bool(cred)}, risk={bool(risk_url)}"
|
||
)
|
||
if cred:
|
||
uid = parse_real_uid(resp)
|
||
if include_device_token:
|
||
return cred, uid, safedeviceid
|
||
return cred, uid
|
||
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
|
||
markers = _response_markers(resp)
|
||
detail = f",服务端字段: {markers}" if markers else ""
|
||
logger.warning(f"[huya-app] WUP 响应未包含 cred/风控: {len(resp)}B{detail}")
|
||
if "LGN_INFO_INVALID_USER_OR_PASSWORD" in markers:
|
||
raise HuyaAppLoginError(
|
||
"虎牙账号或密码错误(服务端: LGN_INFO_INVALID_USER_OR_PASSWORD)"
|
||
)
|
||
if "APP_SIGN_NOT_MATCH" in markers:
|
||
raise HuyaAppLoginError("虎牙设备签名不匹配(服务端: APP_SIGN_NOT_MATCH)")
|
||
raise HuyaAppLoginError(
|
||
f"登录未返回凭据也无风控URL(响应 {len(resp)}B{detail})"
|
||
)
|
||
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
|
||
if not pc and device_info is None:
|
||
raise HuyaAppLoginError("移动端二维码角色缺少当前账号设备画像")
|
||
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),
|
||
"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
|
||
if force_new_device:
|
||
reset_account_state(self.username)
|
||
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, safedeviceid = login_cred_with_flow(
|
||
acct,
|
||
self.password,
|
||
device_info=self.device_info,
|
||
proxies=self.proxies,
|
||
include_device_token=True,
|
||
)
|
||
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:
|
||
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(b"5008", fp_bytes, cred, rnd=rnd)
|
||
# 证书头和 app_id 是协议版本常量,不从抓包信封读取。
|
||
cert = base64.b64encode(forge_cert(p1, key_idx=0x20)).decode()
|
||
|
||
# 3) 按当前账号画像和本次注册令牌重编码二维码信封。
|
||
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 = Envelope.build_qr(
|
||
uid=uid,
|
||
cert_b64=cert,
|
||
safedeviceid=safedeviceid,
|
||
session=qr_session,
|
||
trace_id=qr_trace,
|
||
device_info=self.device_info,
|
||
)
|
||
wup = base64.b64encode(bytes(env.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(
|
||
# App 主链只接受当前账号的 hydevice 高信任结果;旧版
|
||
# token+collect 降级没有账号画像,不再静默放行。
|
||
allow_fallback=False,
|
||
state_dir=account_state_dir(self.username),
|
||
device_hint=self.device_info,
|
||
)
|
||
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(
|
||
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")
|
||
logger.debug(f"[huya-app] getQrId 响应: qrid={bool(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=""),
|
||
},
|
||
)
|
||
logger.debug(
|
||
f"[huya-app] bindQrLoginUser 响应: returnCode={r2.get('returnCode')}"
|
||
)
|
||
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 index in range(12):
|
||
rt = pc.call(
|
||
"/qrLgn/tryQrLogin",
|
||
"70003",
|
||
{
|
||
"qrId": qrid,
|
||
"remember": "1",
|
||
"domainList": "",
|
||
"behavior": beh,
|
||
"page": page,
|
||
},
|
||
)
|
||
dt = rt.get("data") or {}
|
||
logger.debug(
|
||
f"[huya-app] tryQrLogin 第 {index + 1}/12 轮: stage={dt.get('stage')}"
|
||
)
|
||
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()
|