"""虎牙短信登录流程。""" from __future__ import annotations import base64 import json import random import time import uuid from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import quote, urlparse, urlunparse import requests from loguru import logger from .login import ( APP_ID, APP_SIGN, APP_VERSION, DEFAULT_UA, HUYA_PAGE, HUYA_PAGE_URL, LCID, HuyaLoginError, cookie_mapping, cookie_string, generate_context, generate_request_id, ) SMS_CODE_URI = "60027" SMS_LOGIN_URI = "60025" SMS_CODE_URL = "https://udblgn.huya.com/web/v2/smsCode" SMS_LOGIN_URL = "https://udblgn.huya.com/web/v2/smsLogin" DF_TOKEN_URL = "https://df.huya.com/web/df/token" DF_COLLECT_URL = "https://df.huya.com/web/df/collect" HUYA_COUNTRY_CALLING_CODES = frozenset({ "1", "7", "20", "27", "30", "31", "32", "33", "34", "36", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "52", "55", "60", "61", "62", "63", "64", "65", "66", "81", "82", "84", "86", "90", "91", "92", "93", "94", "95", "98", "212", "213", "216", "218", "234", "254", "351", "352", "353", "354", "355", "356", "357", "358", "359", "370", "371", "372", "373", "374", "375", "376", "377", "378", "380", "381", "382", "385", "386", "387", "389", "420", "421", "852", "853", "855", "856", "886", "960", "961", "962", "963", "964", "965", "966", "967", "968", "971", "972", "973", "974", "975", "976", "977", "992", "993", "994", "995", "996", "998", }) @dataclass class HuyaSmsCodeResult: """虎牙短信发码结果。""" success: bool state: str = "" message: str = "" session_data: str = "" raw: dict | None = None sdid: str = "" context: str = "" request_id: str = "" def _split_country_calling_code(digits: str) -> tuple[str, str]: """从国际号码中拆出国家/地区区号。""" for size in (3, 2, 1): code = digits[:size] if code in HUYA_COUNTRY_CALLING_CODES and len(digits) > size: return code, digits[size:] return "", digits def _format_huya_international_phone(code: str, national_number: str) -> str: """转换成虎牙短信接口使用的三位区号格式。""" return f"{code.zfill(3)}{national_number}" def normalize_huya_phone(phone: str) -> str: """归一化虎牙短信接口手机号格式,兼容国内号和显式国际区号。""" raw = str(phone or "").strip() digits = "".join(ch for ch in raw if ch.isdigit()) if not digits: return "" if raw.startswith("+"): code, national_number = _split_country_calling_code(digits) if code: return _format_huya_international_phone(code, national_number) if digits.startswith("00") and len(digits) > 4: code, national_number = _split_country_calling_code(digits[2:]) if code: return _format_huya_international_phone(code, national_number) return digits if digits.startswith("0") and len(digits) > 11: return digits if digits.startswith("086"): return digits if digits.startswith("86") and len(digits) == 13: return f"0{digits}" if len(digits) == 11: return f"086{digits}" if len(digits) == 10: return f"001{digits}" return digits def encode_sms_behavior(page: str = HUYA_PAGE_URL) -> str: """生成短信登录抓包同款行为轨迹。""" now = int(time.time() * 1000) - random.randint(8000, 18000) elapsed = random.randint(1200, 2400) actions = [{ "id": "12", "x": random.randint(520, 620), "y": random.randint(45, 75), "d": elapsed, "time": now, }] for action_id in ("17", "17"): now += random.randint(1800, 4200) elapsed += random.randint(1800, 4200) actions.append({"id": action_id, "d": elapsed, "time": now}) now += random.randint(80, 240) elapsed += random.randint(80, 240) actions.append({ "id": "18", "x": random.randint(610, 660), "y": random.randint(165, 195), "d": elapsed, "time": now, }) value = {"furl": page, "curl": page, "user_action": actions} return quote(json.dumps(value, separators=(",", ":"), ensure_ascii=False), safe="~()*!.'") class HuyaSmsLogin: """虎牙手机号短信登录器。""" timeout = (8, 20) def __init__( self, phone: 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.phone = normalize_huya_phone(phone) 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.context = generate_context(self.device_id) 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 = urlparse(url) return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", "")) @staticmethod def _payload_data(payload: dict) -> dict: """兼容虎牙失败响应里的 data=null。""" data = payload.get("data") return data if isinstance(data, dict) else {} 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 _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) def _common_payload(self, uri: str, data: dict) -> dict: return { "uri": uri, "version": APP_VERSION, "context": self.context, "appId": APP_ID, "appSign": APP_SIGN, "authId": "", "sdid": self.sdid, "lcid": LCID, "byPass": "3", "requestId": self.request_id, "data": data, } def _headers_for_uri(self, uri: str) -> dict: return { "Origin": "https://udblgn.huya.com", "Referer": self.middle_url, "context": self.context, "lcid": LCID, "reqid": self.request_id, "uri": uri, } def _sms_code_once(self) -> dict: data = { "phone": self.phone, "behavior": encode_sms_behavior(), "page": HUYA_PAGE, } return self._request_json( "post", SMS_CODE_URL, "虎牙短信发码", json=self._common_payload(SMS_CODE_URI, data), headers=self._headers_for_uri(SMS_CODE_URI), ) def send_code(self) -> HuyaSmsCodeResult: """发送短信验证码,必要时自动处理滑块。""" logger.info(f"开始虎牙短信发码: {self.phone}") self.prepare_device() self.init_udb_middle() payload = self._sms_code_once() for index in range(3): return_code = int(payload.get("returnCode") or 0) session_data = str(self._payload_data(payload).get("sessionData") or "") if return_code == 0 and session_data: state = self._dump_state(session_data) logger.info("虎牙短信发码成功") return HuyaSmsCodeResult( success=True, state=state, message="短信已发送", session_data=session_data, raw=payload, sdid=self.sdid, context=self.context, request_id=self.request_id, ) if return_code not in (10030, 10039): return HuyaSmsCodeResult( success=False, message=f"虎牙短信发码失败: {payload}", raw=payload, sdid=self.sdid, context=self.context, request_id=self.request_id, ) logger.info(f"虎牙短信发码触发风控: {return_code} ({index + 1}/3)") self._solve_verification(payload) time.sleep(0.5) payload = self._sms_code_once() return HuyaSmsCodeResult( success=False, message=f"虎牙短信发码风控后仍失败: {payload}", raw=payload, sdid=self.sdid, context=self.context, request_id=self.request_id, ) def _sms_login_once(self, authcode: str, session_data: str) -> dict: data = { "phone": self.phone, "authId": "", "authcode": authcode, "sessionData": session_data, "domainList": "", "remember": "1", "behavior": encode_sms_behavior(), "page": HUYA_PAGE, } return self._request_json( "post", SMS_LOGIN_URL, "虎牙短信登录", json=self._common_payload(SMS_LOGIN_URI, data), headers=self._headers_for_uri(SMS_LOGIN_URI), ) def submit_code(self, authcode: str, session_data: str = ""): """提交短信验证码并返回登录结果。""" from .login import HuyaLoginResult session_data = session_data or getattr(self, "session_data", "") if not session_data: raise HuyaLoginError("缺少短信登录 sessionData,请先发送短信") payload = self._sms_login_once(authcode, session_data) for index in range(3): return_code = int(payload.get("returnCode") or 0) if return_code == 0: cookie = cookie_string(self.session.cookies) logger.success(f"虎牙短信登录成功: {self.phone}, 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, ) 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} ({index + 1}/3)") self._solve_verification(payload) time.sleep(0.5) payload = self._sms_login_once(authcode, session_data) return HuyaLoginResult( success=False, message=f"虎牙短信登录风控后仍失败: {payload}", code=int(payload.get("returnCode") or 0), raw=payload, sdid=self.sdid, context=self.context, request_id=self.request_id, ) def _dump_state(self, session_data: str) -> str: """把两步短信登录需要的状态编码给调用方保存。""" data = { "phone": self.phone, "sessionData": session_data, "cookies": self.session.cookies.get_dict(), "ua": self.ua, "sdid": self.sdid, "context": self.context, "requestId": self.request_id, "exchange": self.exchange, "deviceId": self.device_id, "middleUrl": self.middle_url, "createdAt": int(time.time()), } raw = json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8") return base64.urlsafe_b64encode(raw).decode("ascii") @classmethod def from_state( cls, state: str, phone: str = "", proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, ) -> "HuyaSmsLogin": """从发码阶段返回的 state 恢复短信登录会话。""" try: raw = base64.urlsafe_b64decode(state.encode("ascii")) data = json.loads(raw.decode("utf-8")) except (ValueError, json.JSONDecodeError) as exc: raise HuyaLoginError("短信登录 state 无效") from exc state_phone = str(data.get("phone") or "") expected_phone = normalize_huya_phone(phone or state_phone) if state_phone and expected_phone != state_phone: raise HuyaLoginError("短信登录手机号与发码阶段不一致") login = cls( phone=expected_phone, cookie=data.get("cookies") or {}, ua=str(data.get("ua") or DEFAULT_UA), proxies=proxies, timeout=timeout, ) login.sdid = str(data.get("sdid") or "") login.context = str(data.get("context") or login.context) login.request_id = str(data.get("requestId") or login.request_id) login.exchange = str(data.get("exchange") or login.exchange) login.device_id = str(data.get("deviceId") or login.device_id) login.middle_url = str(data.get("middleUrl") or login.middle_url) login.session_data = str(data.get("sessionData") or "") login.session.headers.update({ "Origin": "https://udblgn.huya.com", "Referer": login.middle_url, }) return login def send_huya_sms_code( phone: str, cookie: Mapping[str, str] | str | None = None, ua: str = DEFAULT_UA, proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, ) -> HuyaSmsCodeResult: """函数式入口:发送虎牙短信验证码。""" return HuyaSmsLogin( phone=phone, cookie=cookie, ua=ua, proxies=proxies, timeout=timeout, ).send_code() def login_huya_sms( authcode: str, state: str, phone: str = "", proxies: Mapping[str, str] | None = None, timeout: tuple[float, float] | None = None, ): """函数式入口:提交短信验证码登录虎牙。""" return HuyaSmsLogin.from_state( state=state, phone=phone, proxies=proxies, timeout=timeout, ).submit_code(authcode=authcode)