"""虎牙密码登录流程。""" 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 APP_ID = "5002" APP_VERSION = "2.6" APP_SIGN = "1ce3bf682483d03f146f58232ec10635" 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: requests.cookies.RequestsCookieJar | Mapping[str, str]) -> str: """把 CookieJar/dict 转成浏览器 Cookie 字符串。""" items = cookies.items() if isinstance(cookies, Mapping) else cookies.items() return "; ".join(f"{key}={value}" for key, value in items if value is not None) 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.timezone.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 = DEFAULT_UA, session: requests.Session | None = None, proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, ): self.username = username self.password = password self.ua = ua or 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.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。""" 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": APP_ID, "appSign": 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 HuyaVerificationSolver solver = HuyaVerificationSolver( cookie=self.session.cookies.get_dict(), ua=self.ua, sdid=self.sdid, session=self.session, proxies=self.session.proxies, timeout=self.timeout, ) verify_data = solver.solve(payload) 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 login(self) -> HuyaLoginResult: """执行密码登录,成功返回 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 = DEFAULT_UA, proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, ) -> HuyaLoginResult: """函数式入口,便于服务层直接调用。""" return HuyaPasswordLogin( username=username, password=password, cookie=cookie, ua=ua, proxies=proxies, timeout=timeout, ).login()