Files
live-hub-py/core/huya/login.py
T
2026-08-31 10:55:44 +08:00

504 lines
18 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.
"""虎牙密码登录流程。"""
from __future__ import annotations
import datetime as dt
import hashlib
import json
import random
import time
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from http.cookies import SimpleCookie
from urllib.parse import quote, urlsplit, urlunsplit
import requests
from loguru import logger
from requests.cookies import RequestsCookieJar
from .cookie_utils import normalize_huya_cookie
APP_ID = "5002"
APP_VERSION = "2.6"
APP_SIGN = "1ce3bf682483d03f146f58232ec10635"
# App 场景(appId=5008): 风控走短信验证(dx_auth)。
# appSign 算法(逆向自 APK com/duowan/kiwi/base/login UdbNetHelper):
# md5(f"{appId}{versionCode}{appKey}")[:8], versionCode=115315(13.4.22),
# appKey 来自 APK 资源 R.string.cgs。
APP_ID_MOBILE = "5008"
APP_SIGN_MOBILE = "60576904"
# App 场景 UA(与 core/huya/fingerprint/env.js 的 Android WebView 仿真一致)
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"
)
LCID = "2052"
PASSWORD_LOGIN_URI = "30001"
DF_TOKEN_URL = "https://df.huya.com/web/df/token"
DF_COLLECT_URL = "https://df.huya.com/web/df/collect"
PASSWORD_LOGIN_URL = "https://udblgn.huya.com/web/v2/passwordLogin"
HUYA_PAGE_URL = "https://www.huya.com/g"
HUYA_PAGE = "https%3A%2F%2Fwww.huya.com%2Fg"
DEFAULT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/150.0.0.0 Safari/537.36"
)
class HuyaLoginError(RuntimeError):
"""虎牙登录失败。"""
class HuyaCredentialError(HuyaLoginError):
"""账号或密码错误,不应继续重试。"""
@dataclass
class HuyaLoginResult:
"""虎牙登录结果。"""
success: bool
cookie: str = ""
message: str = ""
code: int | str = ""
raw: dict | None = None
sdid: str = ""
context: str = ""
request_id: str = ""
def password_sha1(password: str) -> str:
"""虎牙 passwordLogin 使用的密码摘要。"""
return hashlib.sha1(password.encode("utf-8")).hexdigest()
def cookie_string(cookies: RequestsCookieJar | Mapping[str, str]) -> str:
"""把 CookieJar/dict 转成浏览器 Cookie 字符串。"""
return normalize_huya_cookie(cookies)
def cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
"""兼容 dict 和 Cookie 字符串。"""
if not cookie:
return {}
if isinstance(cookie, str):
parsed = SimpleCookie()
parsed.load(cookie)
return {key: morsel.value for key, morsel in parsed.items()}
return {str(key): str(value) for key, value in cookie.items()}
def generate_context(device_id: str | None = None, mid: str | None = None) -> str:
"""生成抓包同款虎牙 web 登录 context。"""
device_id = device_id or uuid.uuid4().hex
mid = mid or uuid.uuid4().hex.upper()
return f"WB-{device_id}-{mid}-"
def generate_request_id() -> str:
"""生成 requestId,形态参考旧实现的日内毫秒数。"""
now = dt.datetime.now(dt.UTC)
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
return str(int((now - midnight).total_seconds() * 1000))
def encode_behavior(page: str = HUYA_PAGE_URL) -> str:
"""生成基础行为轨迹。"""
now = int(time.time() * 1000) - random.randint(3000, 12000)
actions = []
elapsed = random.randint(900, 2200)
for action_id in ("7", "7", "8", "8"):
now += random.randint(700, 2500)
elapsed += random.randint(700, 2500)
actions.append({"id": action_id, "d": elapsed, "time": now})
now += random.randint(120, 400)
elapsed += random.randint(120, 400)
actions.append(
{
"id": "11",
"x": random.randint(430, 560),
"y": random.randint(250, 330),
"d": elapsed,
"time": now,
}
)
value = {"furl": page, "curl": page, "user_action": actions}
return quote(
json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'"
)
class HuyaPasswordLogin:
"""虎牙账号密码登录器。"""
timeout = (8, 20)
def __init__(
self,
username: str,
password: str,
cookie: Mapping[str, str] | str | None = None,
ua: str | None = None,
session: requests.Session | None = None,
proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] | None = None,
app_id: str = APP_ID,
proxy_fetcher=None,
):
self.username = username
self.password = password
self.app_id = app_id if app_id in (APP_ID, APP_ID_MOBILE) else APP_ID
self.app_sign = APP_SIGN_MOBILE if self.app_id == APP_ID_MOBILE else APP_SIGN
# ua 显式传入优先;否则按 app_id 自动选择(App 场景用 Android WebView UA
if ua:
self.ua = ua
elif self.app_id == APP_ID_MOBILE:
self.ua = APP_UA_MOBILE
else:
self.ua = DEFAULT_UA
self.timeout = timeout or self.timeout
self.request_id = generate_request_id()
self.exchange = self.request_id
self.device_id = uuid.uuid4().hex
self.mid = uuid.uuid4().hex.upper()
self.context = generate_context(self.device_id, self.mid)
self.middle_url = f"https://udblgn.huya.com/web/middle/{APP_VERSION}/{self.exchange}/https/{self.device_id}"
self.sdid = ""
self.sdid_source = ""
# 代理源(鸭子类型,需提供 fetch_new_proxy() -> Optional[str])。
# 用于扫码/短信风控时换 IP 重试,尝试把风控等级降回滑块。
self.proxy_fetcher = proxy_fetcher
self.session = session or requests.Session()
self.session.trust_env = False
if cookie:
self.session.cookies.update(cookie_mapping(cookie))
if proxies:
self.session.proxies = dict(proxies)
self._setup_headers()
def _setup_headers(self) -> None:
self.session.headers.update(
{
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/json;charset=UTF-8",
"Origin": "https://aq.huya.com",
"Pragma": "no-cache",
"Referer": "https://aq.huya.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"User-Agent": self.ua,
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
}
)
@staticmethod
def _safe_url(url: str) -> str:
parsed = urlsplit(url)
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
logger.debug(
f"{method.upper()} {self._safe_url(url)} -> {response.status_code}"
)
response.raise_for_status()
try:
return response.json()
except json.JSONDecodeError as exc:
preview = response.text[:200].replace("\n", "\\n")
raise HuyaLoginError(f"{source} 返回不是 JSON: {preview}") from exc
def init_udb_middle(self) -> None:
"""初始化 UDB middle,补齐抓包中的登录 Referer。"""
for host in ("udblgn.huya.com", "udb3lgn.huya.com", "udbreg.huya.com"):
url = f"https://{host}/web/middle/{APP_VERSION}/{self.exchange}/https/{self.device_id}"
try:
response = self.session.get(url, timeout=self.timeout)
logger.debug(f"GET {self._safe_url(url)} -> {response.status_code}")
except requests.RequestException as exc:
logger.debug(f"虎牙UDB middle初始化失败: {host}: {exc}")
self.session.headers.update(
{
"Origin": "https://udblgn.huya.com",
"Referer": self.middle_url,
}
)
def prepare_device(self) -> str:
"""获取虎牙风控 sdid(优先 hydevice 高信任指纹,失败降级旧版)。"""
from .device_fingerprint import account_state_dir, get_huya_sdid
try:
# 一号一设备: 每账号独立 hydevice 状态, sdid/40hex hdid 不跨账号同源
result = get_huya_sdid(
app_id="5008",
timeout=self.timeout,
state_dir=account_state_dir(self.username),
)
if result.sdid:
self.sdid = result.sdid
self.sdid_source = result.source
logger.debug("虎牙 sdid 来源: {}", result.source)
return self.sdid
logger.warning("虎牙高信任指纹未返回 sdid: {},降级旧流程", result.message)
except Exception as exc: # noqa: BLE001
logger.warning("虎牙高信任指纹异常: {},降级旧流程", exc)
token_payload = {"encryptVersion": "1.0.1", "fingerprintVersion": "1.2.41"}
token_res = self._request_json(
"post", DF_TOKEN_URL, "获取虎牙 df token", json=token_payload
)
token = token_res.get("data", {}).get("token")
if not token:
raise HuyaLoginError(f"获取虎牙 df token 失败: {token_res}")
collect_res = self._request_json(
"post", DF_COLLECT_URL, "获取虎牙 sdid", json={"token": token}
)
self.sdid = collect_res.get("data", {}).get("sdid", "")
if not self.sdid:
raise HuyaLoginError(f"获取虎牙 sdid 失败: {collect_res}")
return self.sdid
def _password_payload(self, auth_id: str = "", session_data: str = "") -> dict:
data = {
"userName": self.username,
"password": password_sha1(self.password),
"domainList": "",
"remember": "1",
"behavior": encode_behavior(),
"randomStr": "",
"page": HUYA_PAGE,
}
if auth_id or session_data:
data["authcode"] = ""
data["sessionData"] = session_data
return {
"uri": PASSWORD_LOGIN_URI,
"version": APP_VERSION,
"context": self.context,
"appId": self.app_id,
"appSign": self.app_sign,
"authId": auth_id,
"sdid": self.sdid,
"lcid": LCID,
"byPass": "3",
"requestId": self.request_id,
"data": data,
}
def _password_login_once(self, auth_id: str = "", session_data: str = "") -> dict:
headers = {
"Origin": "https://udblgn.huya.com",
"Referer": self.middle_url,
"context": self.context,
"lcid": LCID,
"reqid": self.request_id,
"uri": PASSWORD_LOGIN_URI,
}
return self._request_json(
"post",
PASSWORD_LOGIN_URL,
"虎牙密码登录",
json=self._password_payload(auth_id=auth_id, session_data=session_data),
headers=headers,
)
def _solve_verification(self, payload: dict) -> str:
"""处理 10030/10039 风控并返回 authId;未下发图形验证时返回空串。"""
from .verification import HuyaNoSessionError, HuyaVerificationSolver
solver = HuyaVerificationSolver(
cookie={
key: value
for key, value in self.session.cookies.get_dict().items()
if value is not None
},
ua=self.ua,
sdid=self.sdid,
session=self.session,
proxies=self.session.proxies,
timeout=self.timeout,
)
try:
verify_data = solver.solve(payload)
except HuyaNoSessionError as exc:
logger.warning("虎牙未下发图形验证({}),将直接重试登录", exc)
return ""
auth_id = verify_data.get("authId") if isinstance(verify_data, dict) else ""
if not auth_id:
raise HuyaLoginError(f"虎牙风控验证未返回 authId: {verify_data}")
return str(auth_id)
@staticmethod
def _is_credential_error(payload: dict) -> bool:
text = json.dumps(payload, ensure_ascii=False)
return any(
word in text
for word in ("密码错误", "账号或密码", "账号不存在", "用户不存在")
)
@staticmethod
def _payload_data(payload: dict) -> dict:
"""兼容虎牙失败响应里的 data=null。"""
data = payload.get("data")
return data if isinstance(data, dict) else {}
def _swap_proxy(self) -> bool:
"""换一个代理 IP,成功返回 True。"""
if not self.proxy_fetcher:
return False
try:
proxy = self.proxy_fetcher.fetch_new_proxy()
except Exception as exc: # noqa: BLE001
logger.warning(f"获取新代理失败: {exc}")
return False
if not proxy:
return False
self.session.proxies = {"http": proxy, "https": proxy}
logger.info(f"虎牙登录切换代理 IP: {proxy}")
return True
def login(self, max_ip_retries: int = 3) -> HuyaLoginResult:
"""执行密码登录,成功返回 Cookie。
遇到扫码/短信风控(无法自动化)时,若有代理源则换 IP 重试,
尝试把风控等级降回可自动化的滑块。
"""
from .verification import HuyaQrAuthRequiredError, HuyaSmsAuthRequiredError
last_exc: Exception | None = None
for attempt in range(max_ip_retries + 1):
if attempt > 0 and not self._swap_proxy():
logger.warning("无可用代理可切换,停止换 IP 重试")
break
try:
return self._login_once()
except (HuyaQrAuthRequiredError, HuyaSmsAuthRequiredError) as exc:
last_exc = exc
kind = "扫码" if isinstance(exc, HuyaQrAuthRequiredError) else "短信"
logger.warning(
f"虎牙风控要求{kind}验证({exc}),换 IP 重试 ({attempt + 1}/{max_ip_retries})"
)
continue
return HuyaLoginResult(
success=False,
message=f"换 IP 后仍触发风控: {last_exc}",
code="risk",
sdid=self.sdid,
context=self.context,
request_id=self.request_id,
)
def _login_once(self) -> HuyaLoginResult:
"""单次登录流程(不换 IP),成功返回 Cookie。"""
logger.info(f"开始虎牙密码登录: {self.username}")
self.prepare_device()
self.init_udb_middle()
payload = self._password_login_once()
return_code = int(payload.get("returnCode") or 0)
session_data = str(self._payload_data(payload).get("sessionData") or "")
if return_code == 0:
return self._success(payload)
if self._is_credential_error(payload):
raise HuyaCredentialError(f"虎牙账号或密码错误: {payload}")
if return_code not in (10030, 10039):
return HuyaLoginResult(
success=False,
message=f"虎牙密码登录失败: {payload}",
code=return_code,
raw=payload,
sdid=self.sdid,
context=self.context,
request_id=self.request_id,
)
logger.info(f"虎牙密码登录触发风控: {return_code}")
auth_id = self._solve_verification(payload)
for index in range(3):
payload = self._password_login_once(
auth_id=auth_id, session_data=session_data
)
return_code = int(payload.get("returnCode") or 0)
if return_code == 0:
return self._success(payload)
if self._is_credential_error(payload):
raise HuyaCredentialError(f"虎牙账号或密码错误: {payload}")
session_data = str(
self._payload_data(payload).get("sessionData") or session_data
)
if return_code in (10030, 10039):
logger.info(f"虎牙密码登录二次风控: {return_code} ({index + 1}/3)")
auth_id = self._solve_verification(payload)
continue
break
return HuyaLoginResult(
success=False,
message=f"虎牙密码登录失败: {payload}",
code=return_code,
raw=payload,
sdid=self.sdid,
context=self.context,
request_id=self.request_id,
)
def _success(self, payload: dict) -> HuyaLoginResult:
cookie = cookie_string(self.session.cookies)
logger.success(f"虎牙密码登录成功: {self.username}, Cookie长度: {len(cookie)}")
return HuyaLoginResult(
success=True,
cookie=cookie,
message="登录成功",
code=0,
raw=payload,
sdid=self.sdid,
context=self.context,
request_id=self.request_id,
)
def login_huya_password(
username: str,
password: str,
cookie: Mapping[str, str] | str | None = None,
ua: str | None = None,
proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] | None = None,
app_id: str = APP_ID,
proxy_fetcher=None,
max_ip_retries: int = 3,
) -> HuyaLoginResult:
"""函数式入口,便于服务层直接调用。
app_id 可选 APP_ID(5002, web) 或 APP_ID_MOBILE(5008, App 场景)
传 5008 且未显式传 ua 时,内部自动切换 appSign 与 Android WebView UA。
proxy_fetcher: 代理源(需提供 fetch_new_proxy()),扫码/短信风控时换 IP 重试。
max_ip_retries: 最多换 IP 次数。
"""
return HuyaPasswordLogin(
username=username,
password=password,
cookie=cookie,
ua=ua,
proxies=proxies,
timeout=timeout,
app_id=app_id,
proxy_fetcher=proxy_fetcher,
).login(max_ip_retries=max_ip_retries)