添加虎牙短信登录
This commit is contained in:
@@ -12,9 +12,13 @@ __all__ = [
|
|||||||
"HuyaLoginError",
|
"HuyaLoginError",
|
||||||
"HuyaLoginResult",
|
"HuyaLoginResult",
|
||||||
"HuyaPasswordLogin",
|
"HuyaPasswordLogin",
|
||||||
|
"HuyaSmsCodeResult",
|
||||||
|
"HuyaSmsLogin",
|
||||||
"HuyaVerificationError",
|
"HuyaVerificationError",
|
||||||
"HuyaVerificationSolver",
|
"HuyaVerificationSolver",
|
||||||
"login_huya_password",
|
"login_huya_password",
|
||||||
|
"login_huya_sms",
|
||||||
|
"send_huya_sms_code",
|
||||||
"solve_huya_verification",
|
"solve_huya_verification",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,6 +57,27 @@ def __getattr__(name: str):
|
|||||||
}
|
}
|
||||||
globals().update(values)
|
globals().update(values)
|
||||||
return values[name]
|
return values[name]
|
||||||
|
if name in {
|
||||||
|
"HuyaSmsCodeResult",
|
||||||
|
"HuyaSmsLogin",
|
||||||
|
"login_huya_sms",
|
||||||
|
"send_huya_sms_code",
|
||||||
|
}:
|
||||||
|
from .sms_login import (
|
||||||
|
HuyaSmsCodeResult,
|
||||||
|
HuyaSmsLogin,
|
||||||
|
login_huya_sms,
|
||||||
|
send_huya_sms_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"HuyaSmsCodeResult": HuyaSmsCodeResult,
|
||||||
|
"HuyaSmsLogin": HuyaSmsLogin,
|
||||||
|
"login_huya_sms": login_huya_sms,
|
||||||
|
"send_huya_sms_code": send_huya_sms_code,
|
||||||
|
}
|
||||||
|
globals().update(values)
|
||||||
|
return values[name]
|
||||||
if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}:
|
if name in {"HuyaVerificationError", "HuyaVerificationSolver", "solve_huya_verification"}:
|
||||||
from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification
|
from .verification import HuyaVerificationError, HuyaVerificationSolver, solve_huya_verification
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""虎牙短信登录流程。"""
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HuyaSmsCodeResult:
|
||||||
|
"""虎牙短信发码结果。"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
state: str = ""
|
||||||
|
message: str = ""
|
||||||
|
session_data: str = ""
|
||||||
|
raw: dict | None = None
|
||||||
|
sdid: str = ""
|
||||||
|
context: str = ""
|
||||||
|
request_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_huya_phone(phone: str) -> str:
|
||||||
|
"""归一化虎牙短信接口手机号格式。"""
|
||||||
|
digits = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
||||||
|
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}"
|
||||||
|
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)
|
||||||
@@ -11,7 +11,13 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from core.huya import HuyaCredentialError, HuyaLoginError, login_huya_password
|
from core.huya import (
|
||||||
|
HuyaCredentialError,
|
||||||
|
HuyaLoginError,
|
||||||
|
login_huya_password,
|
||||||
|
login_huya_sms,
|
||||||
|
send_huya_sms_code,
|
||||||
|
)
|
||||||
from core.huya.cookie_utils import normalize_huya_cookie
|
from core.huya.cookie_utils import normalize_huya_cookie
|
||||||
|
|
||||||
from ..database import SessionLocal, get_db
|
from ..database import SessionLocal, get_db
|
||||||
@@ -31,6 +37,8 @@ from ..schemas import (
|
|||||||
HuyaPasswordLoginRequest,
|
HuyaPasswordLoginRequest,
|
||||||
HuyaPasswordLoginSelectedRequest,
|
HuyaPasswordLoginSelectedRequest,
|
||||||
HuyaRechargeGoodsOut,
|
HuyaRechargeGoodsOut,
|
||||||
|
HuyaSmsCodeRequest,
|
||||||
|
HuyaSmsLoginRequest,
|
||||||
HuyaTaskBatchRequest,
|
HuyaTaskBatchRequest,
|
||||||
HuyaTaskOut,
|
HuyaTaskOut,
|
||||||
)
|
)
|
||||||
@@ -258,6 +266,77 @@ def password_login_account(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/accounts/sms-code")
|
||||||
|
def send_sms_code(
|
||||||
|
req: HuyaSmsCodeRequest,
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""发送虎牙短信验证码,返回提交登录所需 state。"""
|
||||||
|
_require_huya_perm(current, "huya:import")
|
||||||
|
try:
|
||||||
|
result = send_huya_sms_code(
|
||||||
|
phone=req.phone.strip(),
|
||||||
|
cookie=req.cookie.strip() or None,
|
||||||
|
)
|
||||||
|
except HuyaLoginError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"虎牙短信发码失败: {exc}") from exc
|
||||||
|
|
||||||
|
if not result.success or not result.state:
|
||||||
|
raise HTTPException(status_code=502, detail=result.message or "虎牙短信发码失败")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": result.message or "短信已发送",
|
||||||
|
"success": True,
|
||||||
|
"state": result.state,
|
||||||
|
"sdid": result.sdid,
|
||||||
|
"context": result.context,
|
||||||
|
"request_id": result.request_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/accounts/sms-login")
|
||||||
|
def sms_login_account(
|
||||||
|
req: HuyaSmsLoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""提交虎牙短信验证码登录,成功后保存 Cookie。"""
|
||||||
|
_require_huya_perm(current, "huya:import")
|
||||||
|
try:
|
||||||
|
result = login_huya_sms(
|
||||||
|
authcode=req.authcode.strip(),
|
||||||
|
state=req.state.strip(),
|
||||||
|
phone=req.phone.strip(),
|
||||||
|
)
|
||||||
|
except HuyaLoginError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"虎牙短信登录失败: {exc}") from exc
|
||||||
|
|
||||||
|
if not result.success or not result.cookie:
|
||||||
|
raise HTTPException(status_code=502, detail=result.message or "虎牙短信登录失败")
|
||||||
|
|
||||||
|
try:
|
||||||
|
account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint="")
|
||||||
|
phone = (req.phone or "").strip()
|
||||||
|
if phone:
|
||||||
|
account.game_phone = phone
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(account)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "登录成功,Cookie 已保存",
|
||||||
|
"success": True,
|
||||||
|
"account": _account_out(account),
|
||||||
|
"sdid": result.sdid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accounts/password-login/selected")
|
@router.post("/accounts/password-login/selected")
|
||||||
def password_login_selected_accounts(
|
def password_login_selected_accounts(
|
||||||
req: HuyaPasswordLoginSelectedRequest,
|
req: HuyaPasswordLoginSelectedRequest,
|
||||||
|
|||||||
@@ -183,6 +183,20 @@ class HuyaPasswordLoginRequest(BaseModel):
|
|||||||
cookie: str = ""
|
cookie: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaSmsCodeRequest(BaseModel):
|
||||||
|
"""发送虎牙短信验证码。"""
|
||||||
|
phone: str = Field(..., min_length=5, max_length=32)
|
||||||
|
cookie: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaSmsLoginRequest(BaseModel):
|
||||||
|
"""提交虎牙短信验证码并保存 Cookie。"""
|
||||||
|
authcode: str = Field(..., min_length=4, max_length=8)
|
||||||
|
state: str = Field(..., min_length=1)
|
||||||
|
phone: str = ""
|
||||||
|
tag: str = ""
|
||||||
|
|
||||||
|
|
||||||
class HuyaPasswordAccountImport(BaseModel):
|
class HuyaPasswordAccountImport(BaseModel):
|
||||||
"""导入虎牙账号密码,稍后再选择登录。"""
|
"""导入虎牙账号密码,稍后再选择登录。"""
|
||||||
text: str = Field(..., min_length=1)
|
text: str = Field(..., min_length=1)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import type {
|
|||||||
HuyaPasswordLoginResult,
|
HuyaPasswordLoginResult,
|
||||||
HuyaPasswordLoginSelectedRequest,
|
HuyaPasswordLoginSelectedRequest,
|
||||||
HuyaRechargeGoodsItem,
|
HuyaRechargeGoodsItem,
|
||||||
|
HuyaSmsCodeRequest,
|
||||||
|
HuyaSmsCodeResult,
|
||||||
|
HuyaSmsLoginRequest,
|
||||||
|
HuyaSmsLoginResult,
|
||||||
HuyaTaskBatchRequest,
|
HuyaTaskBatchRequest,
|
||||||
HuyaTaskBatchResult,
|
HuyaTaskBatchResult,
|
||||||
HuyaTaskItem,
|
HuyaTaskItem,
|
||||||
@@ -31,6 +35,10 @@ export const huyaApi = {
|
|||||||
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
|
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
|
||||||
passwordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) =>
|
passwordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) =>
|
||||||
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>('/huya/accounts/password-login/selected', data),
|
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>('/huya/accounts/password-login/selected', data),
|
||||||
|
smsCode: (data: HuyaSmsCodeRequest) =>
|
||||||
|
api.post<HuyaSmsCodeResult, HuyaSmsCodeResult>('/huya/accounts/sms-code', data, { timeout: 120000 }),
|
||||||
|
smsLogin: (data: HuyaSmsLoginRequest) =>
|
||||||
|
api.post<HuyaSmsLoginResult, HuyaSmsLoginResult>('/huya/accounts/sms-login', data, { timeout: 120000 }),
|
||||||
assign: (id: number, assigned_to: number | null) =>
|
assign: (id: number, assigned_to: number | null) =>
|
||||||
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
|
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
|
||||||
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
batchAssign: (account_ids: number[], assigned_to: number | null) =>
|
||||||
|
|||||||
@@ -149,6 +149,30 @@ export interface HuyaPasswordLoginResult extends MessageResponse {
|
|||||||
sdid: string;
|
sdid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HuyaSmsCodeRequest {
|
||||||
|
phone: string;
|
||||||
|
cookie?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuyaSmsCodeResult extends MessageResponse {
|
||||||
|
state: string;
|
||||||
|
sdid: string;
|
||||||
|
context: string;
|
||||||
|
request_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuyaSmsLoginRequest {
|
||||||
|
authcode: string;
|
||||||
|
state: string;
|
||||||
|
phone?: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HuyaSmsLoginResult extends MessageResponse {
|
||||||
|
account: HuyaAccountItem;
|
||||||
|
sdid: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HuyaPasswordLoginBatchItem {
|
export interface HuyaPasswordLoginBatchItem {
|
||||||
line: number;
|
line: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Button, Card, Col, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography,
|
Button, Card, Col, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
huyaApi,
|
huyaApi,
|
||||||
type HuyaAccountItem,
|
type HuyaAccountItem,
|
||||||
@@ -53,6 +53,13 @@ export default function HuyaAccountsPage() {
|
|||||||
const [passwordLoginResultOpen, setPasswordLoginResultOpen] = useState(false);
|
const [passwordLoginResultOpen, setPasswordLoginResultOpen] = useState(false);
|
||||||
const [passwordLogging, setPasswordLogging] = useState(false);
|
const [passwordLogging, setPasswordLogging] = useState(false);
|
||||||
const [passwordLoginResults, setPasswordLoginResults] = useState<HuyaPasswordLoginBatchItem[]>([]);
|
const [passwordLoginResults, setPasswordLoginResults] = useState<HuyaPasswordLoginBatchItem[]>([]);
|
||||||
|
const [smsLoginOpen, setSmsLoginOpen] = useState(false);
|
||||||
|
const [smsPhone, setSmsPhone] = useState('');
|
||||||
|
const [smsCode, setSmsCode] = useState('');
|
||||||
|
const [smsTag, setSmsTag] = useState<string[]>([]);
|
||||||
|
const [smsState, setSmsState] = useState('');
|
||||||
|
const [smsSending, setSmsSending] = useState(false);
|
||||||
|
const [smsLogging, setSmsLogging] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [tagFilter, setTagFilter] = useState('');
|
const [tagFilter, setTagFilter] = useState('');
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
@@ -159,6 +166,67 @@ export default function HuyaAccountsPage() {
|
|||||||
setPasswordImportOpen(true);
|
setPasswordImportOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSmsLogin = () => {
|
||||||
|
setSmsPhone('');
|
||||||
|
setSmsCode('');
|
||||||
|
setSmsTag([]);
|
||||||
|
setSmsState('');
|
||||||
|
setSmsLoginOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendSmsCode = async () => {
|
||||||
|
const phone = smsPhone.trim();
|
||||||
|
if (!phone) {
|
||||||
|
message.warning('请先输入手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSmsSending(true);
|
||||||
|
try {
|
||||||
|
const result = await huyaApi.smsCode({ phone });
|
||||||
|
setSmsState(result.state);
|
||||||
|
message.success(result.message || '短信已发送');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(getErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setSmsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmsLogin = async () => {
|
||||||
|
const phone = smsPhone.trim();
|
||||||
|
const authcode = smsCode.trim();
|
||||||
|
if (!smsState) {
|
||||||
|
message.warning('请先发送短信验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!authcode) {
|
||||||
|
message.warning('请先输入短信验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSmsLogging(true);
|
||||||
|
try {
|
||||||
|
const tag = smsTag.length > 0 ? smsTag[smsTag.length - 1].trim() : '';
|
||||||
|
const result = await huyaApi.smsLogin({
|
||||||
|
phone,
|
||||||
|
authcode,
|
||||||
|
state: smsState,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
message.success(result.message || '登录成功,Cookie 已保存');
|
||||||
|
setSmsLoginOpen(false);
|
||||||
|
setSmsPhone('');
|
||||||
|
setSmsCode('');
|
||||||
|
setSmsTag([]);
|
||||||
|
setSmsState('');
|
||||||
|
loadAccounts();
|
||||||
|
loadTags();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error(getErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setSmsLogging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportPasswordAccounts = async () => {
|
const handleImportPasswordAccounts = async () => {
|
||||||
if (!passwordImportText.trim()) {
|
if (!passwordImportText.trim()) {
|
||||||
message.warning('请先粘贴虎牙账号密码');
|
message.warning('请先粘贴虎牙账号密码');
|
||||||
@@ -491,6 +559,15 @@ export default function HuyaAccountsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
|
{canImport && (
|
||||||
|
<Button
|
||||||
|
icon={<MobileOutlined />}
|
||||||
|
loading={smsSending || smsLogging}
|
||||||
|
onClick={openSmsLogin}
|
||||||
|
>
|
||||||
|
短信登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canImport && (
|
{canImport && (
|
||||||
<Button icon={<ImportOutlined />} onClick={openPasswordImport}>
|
<Button icon={<ImportOutlined />} onClick={openPasswordImport}>
|
||||||
导入账号密码
|
导入账号密码
|
||||||
@@ -656,6 +733,62 @@ export default function HuyaAccountsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="虎牙短信登录"
|
||||||
|
open={smsLoginOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
if (smsSending || smsLogging) return;
|
||||||
|
setSmsLoginOpen(false);
|
||||||
|
}}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" disabled={smsSending || smsLogging} onClick={() => setSmsLoginOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button key="send" icon={<MobileOutlined />} loading={smsSending} disabled={smsLogging} onClick={handleSendSmsCode}>
|
||||||
|
{smsState ? '重新发码' : '发送短信'}
|
||||||
|
</Button>,
|
||||||
|
<Button key="login" type="primary" icon={<LoginOutlined />} loading={smsLogging} disabled={!smsState || smsSending} onClick={handleSmsLogin}>
|
||||||
|
登录并保存
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
maskClosable={!(smsSending || smsLogging)}
|
||||||
|
closable={!(smsSending || smsLogging)}
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||||
|
<Input
|
||||||
|
value={smsPhone}
|
||||||
|
onChange={(e) => setSmsPhone(e.target.value)}
|
||||||
|
placeholder="手机号"
|
||||||
|
disabled={smsSending || smsLogging || Boolean(smsState)}
|
||||||
|
prefix={<MobileOutlined />}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={smsCode}
|
||||||
|
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
placeholder="短信验证码"
|
||||||
|
maxLength={8}
|
||||||
|
disabled={!smsState || smsSending || smsLogging}
|
||||||
|
onPressEnter={handleSmsLogin}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="可选,保存到账号标签"
|
||||||
|
maxCount={1}
|
||||||
|
value={smsTag}
|
||||||
|
onChange={setSmsTag}
|
||||||
|
options={tags.map((tag) => ({ value: tag, label: tag }))}
|
||||||
|
disabled={smsSending || smsLogging}
|
||||||
|
/>
|
||||||
|
{smsState ? (
|
||||||
|
<Text type="secondary">短信已发送,输入验证码后提交登录。</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">发码时会自动处理滑块验证,完成后继续输入短信验证码。</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="虎牙登录结果"
|
title="虎牙登录结果"
|
||||||
open={passwordLoginResultOpen}
|
open={passwordLoginResultOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user