- _password_payload 改用 self.app_id/self.app_sign(原先硬编码5002, 导致5008实例静默发错appId) - app_id=5008 时自动切换 Android WebView UA - login() 增加换IP重试循环: 扫码/短信风控时经 proxy_fetcher 换代理重试, 上限 max_ip_retries, 耗尽后返回 code=risk
475 lines
18 KiB
Python
475 lines
18 KiB
Python
"""虎牙密码登录流程。"""
|
||
|
||
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 .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: requests.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.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 | 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 get_huya_sdid
|
||
|
||
try:
|
||
result = get_huya_sdid(app_id="5008", timeout=self.timeout)
|
||
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=self.session.cookies.get_dict(),
|
||
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:
|
||
if 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)
|