934 lines
35 KiB
Python
934 lines
35 KiB
Python
"""斗鱼登录核心模块"""
|
||
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
from collections.abc import Mapping
|
||
from typing import Protocol
|
||
from urllib.parse import urlsplit, urlunsplit
|
||
|
||
import requests
|
||
from loguru import logger
|
||
|
||
from core.geetest.common.network import (
|
||
get_c_s,
|
||
get_js_address,
|
||
req_fullpage_validate,
|
||
)
|
||
from core.geetest.v3_slide.solver import (
|
||
_generate_seed,
|
||
get_w1,
|
||
get_w2,
|
||
)
|
||
|
||
from .cookie_enricher import CookieEnricher
|
||
from .email_verifier import EmailLoginError, EmailVerifier
|
||
from .login_api import LoginAPIStrategy
|
||
from .login_api_wgapi import WgapiLoginAPI
|
||
from .proxy_fetcher import ProxyFetcher
|
||
|
||
# ── 全局极验并发限制:同一时刻最多2个线程做极验验证 ──
|
||
_geetest_semaphore = threading.Semaphore(2)
|
||
|
||
|
||
class CredentialError(ValueError):
|
||
"""账号或密码错误,不应重试。"""
|
||
|
||
def __init__(
|
||
self,
|
||
message: str,
|
||
code: str = "credential_error",
|
||
status_message: str = "",
|
||
):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.status_message = status_message or message
|
||
|
||
|
||
class AccountLike(Protocol):
|
||
"""DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。"""
|
||
|
||
username: str
|
||
password: str
|
||
email: str
|
||
email_password: str
|
||
email_imap_server: str
|
||
email_imap_port: int
|
||
email_imap_ssl: bool
|
||
|
||
|
||
class LoginResult:
|
||
"""登录结果"""
|
||
|
||
def __init__(
|
||
self, success: bool, cookie: str = "", message: str = "", code: str = ""
|
||
):
|
||
self.success = success
|
||
self.cookie = cookie
|
||
self.message = message
|
||
self.code = code # 需要验证时的code
|
||
|
||
|
||
class DouyuLogin:
|
||
"""斗鱼登录器"""
|
||
|
||
# 斗鱼API地址
|
||
LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login"
|
||
SEND_EMAIL_API = (
|
||
"https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail"
|
||
)
|
||
VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify"
|
||
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
|
||
CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax"
|
||
CP_REFERER = "https://www.douyu.com/member/cp"
|
||
LOGIN_REFERER = (
|
||
"https://passport.douyu.com/index/login?"
|
||
"passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&"
|
||
"passport_login_callback=PASSPORT_LOGIN_SUCCESS_CALLBACK&"
|
||
"passport_close_callback=PASSPORT_CLOSE_CALLBACK&"
|
||
"passport_dp_callback=PASSPORT_DP_CALLBACK&"
|
||
"type=login&client_id=1&"
|
||
"state=https%3A%2F%2Fwww.douyu.com%2Fdirectory"
|
||
)
|
||
REQUEST_TIMEOUT = (10, 30)
|
||
MAX_STATIC_RETRY = 3 # 静态代理失败上限(无法切换代理时的最大重试次数)
|
||
|
||
def __init__(
|
||
self,
|
||
account: AccountLike,
|
||
proxy: str | Mapping[str, str] | None = None,
|
||
proxy_api_url: str | None = None,
|
||
timeout: tuple[float, float] = REQUEST_TIMEOUT,
|
||
max_login_retries: int = 0,
|
||
max_total_time: float = 0,
|
||
proxy_fetcher: ProxyFetcher | None = None,
|
||
stop_event: threading.Event | None = None,
|
||
api_strategy: LoginAPIStrategy | None = None,
|
||
):
|
||
self.account = account
|
||
self.proxy = proxy
|
||
self.timeout = timeout
|
||
self.max_login_retries = max_login_retries # 0=无限重试直到成功
|
||
self.max_total_time = max_total_time # 0=不限制单账号登录总时长
|
||
self.stop_event = stop_event
|
||
self.session = requests.Session()
|
||
self.api = api_strategy or WgapiLoginAPI() # 默认使用 wgapi 接口
|
||
|
||
self.proxy_fetcher = proxy_fetcher or (
|
||
ProxyFetcher(api_url=proxy_api_url) if proxy_api_url else None
|
||
)
|
||
|
||
self._current_proxy_url: str | None = None
|
||
self._cookie_enrich_error = ""
|
||
self._static_retry_count = 0
|
||
self._setup_session()
|
||
|
||
def _is_stopped(self) -> bool:
|
||
"""判断外部批量任务是否请求停止。"""
|
||
return bool(self.stop_event and self.stop_event.is_set())
|
||
|
||
def _ensure_not_stopped(self) -> None:
|
||
"""在长流程边界主动中断。"""
|
||
if self._is_stopped():
|
||
raise InterruptedError("任务已停止")
|
||
|
||
def _sleep_interruptible(self, seconds: float) -> None:
|
||
"""可被 stop_event 打断的短睡眠。"""
|
||
if seconds <= 0:
|
||
self._ensure_not_stopped()
|
||
return
|
||
deadline = time.monotonic() + seconds
|
||
while True:
|
||
self._ensure_not_stopped()
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
return
|
||
time.sleep(min(0.2, remaining))
|
||
|
||
def _setup_session(self) -> None:
|
||
"""配置Session"""
|
||
# 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。
|
||
self.session.trust_env = False
|
||
self.session.headers.update(
|
||
{
|
||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||
"Referer": self.LOGIN_REFERER,
|
||
"Origin": "https://passport.douyu.com",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
}
|
||
)
|
||
|
||
# 设置代理
|
||
self._apply_proxy()
|
||
|
||
def _apply_proxy(self, proxy: str | None = None) -> None:
|
||
"""应用代理到Session"""
|
||
if proxy:
|
||
# 使用指定的代理
|
||
self.session.proxies = {
|
||
"http": proxy,
|
||
"https": proxy,
|
||
}
|
||
self._current_proxy_url = proxy
|
||
elif self.proxy:
|
||
# 使用配置的静态代理
|
||
if isinstance(self.proxy, str):
|
||
self.session.proxies = {
|
||
"http": self.proxy,
|
||
"https": self.proxy,
|
||
}
|
||
self._current_proxy_url = self.proxy
|
||
else:
|
||
self.session.proxies = {
|
||
scheme: url for scheme, url in self.proxy.items() if url
|
||
}
|
||
self._current_proxy_url = self.proxy.get("http") or self.proxy.get(
|
||
"https"
|
||
)
|
||
elif self.proxy_fetcher:
|
||
# 从代理获取器取新代理
|
||
new_proxy = self.proxy_fetcher.fetch_new_proxy()
|
||
if new_proxy:
|
||
self.session.proxies = {
|
||
"http": new_proxy,
|
||
"https": new_proxy,
|
||
}
|
||
self._current_proxy_url = new_proxy
|
||
else:
|
||
logger.warning("获取代理失败,将尝试直连")
|
||
|
||
@staticmethod
|
||
def _truncate_error(err_str: str, max_len: int = 80) -> str:
|
||
"""截断错误信息,避免日志刷屏。"""
|
||
if len(err_str) <= max_len:
|
||
return err_str
|
||
return err_str[:max_len] + "..."
|
||
|
||
def _safe_url(self, url: str) -> str:
|
||
"""隐藏查询参数,避免日志泄露登录回调 code。"""
|
||
parsed = urlsplit(url)
|
||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
|
||
|
||
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
||
"""
|
||
统一发送请求,附带分段超时和更明确的错误信息。
|
||
代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。
|
||
"""
|
||
timeout = kwargs.pop("timeout", self.timeout)
|
||
safe_url = self._safe_url(url)
|
||
|
||
self._ensure_not_stopped()
|
||
started = time.monotonic()
|
||
|
||
try:
|
||
response = self.session.request(method, url, timeout=timeout, **kwargs)
|
||
elapsed = time.monotonic() - started
|
||
logger.debug(
|
||
f"{method.upper()} {safe_url} -> {response.status_code} "
|
||
f"({elapsed:.2f}s)"
|
||
)
|
||
return response
|
||
except requests.Timeout as exc:
|
||
elapsed = time.monotonic() - started
|
||
raise TimeoutError(
|
||
f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s,"
|
||
f"timeout={timeout}"
|
||
) from exc
|
||
except requests.ConnectionError as exc:
|
||
err_str = str(exc)
|
||
raise ConnectionError(
|
||
f"{method.upper()} {safe_url} 连接失败: {self._truncate_error(err_str)}"
|
||
) from exc
|
||
except requests.RequestException as exc:
|
||
elapsed = time.monotonic() - started
|
||
raise ConnectionError(
|
||
f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}"
|
||
) from exc
|
||
|
||
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict:
|
||
"""请求 JSON 接口,并在响应异常时输出可定位的信息。"""
|
||
response = self._request(method, url, **kwargs)
|
||
response.raise_for_status()
|
||
|
||
body = response.text.strip()
|
||
if not body:
|
||
raise ValueError(f"{source} 返回为空,无法解析 JSON")
|
||
|
||
try:
|
||
return response.json()
|
||
except json.JSONDecodeError as exc:
|
||
preview = body[:200].replace("\n", "\\n")
|
||
raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc
|
||
|
||
@staticmethod
|
||
def _classify_credential_payload(payload: dict) -> tuple[str, str] | None:
|
||
"""识别登录接口返回的账号类终态。"""
|
||
error_code = payload.get("error")
|
||
error_msg = str(payload.get("msg") or "")
|
||
|
||
if error_code == 110022 or "账号不存在" in error_msg:
|
||
return "account_cancelled", "账号已注销"
|
||
|
||
password_keywords = ["密码错误", "账号或密码", "账号或者密码"]
|
||
if error_code == 110018 or any(kw in error_msg for kw in password_keywords):
|
||
return "password_wrong", "账号密码错误"
|
||
|
||
return None
|
||
|
||
def _credential_error_from_payload(
|
||
self, stage: str, payload: dict
|
||
) -> CredentialError | None:
|
||
"""把账号类错误转换为不重试的 CredentialError。"""
|
||
classified = self._classify_credential_payload(payload)
|
||
if not classified:
|
||
return None
|
||
code, status_message = classified
|
||
return CredentialError(
|
||
f"{stage}失败: {status_message}",
|
||
code=code,
|
||
status_message=status_message,
|
||
)
|
||
|
||
def _run_login_steps(self, deadline: float = 0) -> str:
|
||
"""执行一次完整登录链路,成功后返回 Cookie。"""
|
||
# 1️⃣ 第一次登录(获取极验参数)
|
||
logger.info("步骤1: 第一次登录,获取极验参数...")
|
||
gt, challenge, code_token, _ = self._first_login()
|
||
|
||
# 2️⃣ 极验 fullpage 验证
|
||
logger.info("步骤2: 极验 fullpage 验证...")
|
||
validate, seccode = self._solve_geetest(gt, challenge, deadline=deadline)
|
||
|
||
# 3️⃣ 第二次登录(带极验)
|
||
logger.info("步骤3: 第二次登录(带极验验证)...")
|
||
next_step, value = self._second_login(
|
||
gt,
|
||
challenge,
|
||
validate,
|
||
seccode,
|
||
code_token,
|
||
)
|
||
|
||
if next_step == "mobile_bind_skip":
|
||
logger.info("步骤4: 跳过手机号绑定,完成登录...")
|
||
return self._complete_login(value)
|
||
|
||
# 4️⃣ 发送邮箱验证
|
||
logger.info("步骤4: 发送邮箱验证...")
|
||
email_sent_at = time.time()
|
||
self._send_email_verify(value)
|
||
|
||
# 5️⃣ IMAP获取验证码
|
||
logger.info("步骤5: 获取邮箱验证码...")
|
||
verify_code = self._get_email_code(after_timestamp=email_sent_at)
|
||
|
||
# 6️⃣ 提交验证码
|
||
logger.info("步骤6: 提交验证码...")
|
||
login_url = self._submit_verify_code(value, verify_code)
|
||
|
||
# 7️⃣ 完成登录获取Cookie
|
||
logger.info("步骤7: 完成登录,获取Cookie...")
|
||
return self._complete_login(login_url)
|
||
|
||
def login(self) -> LoginResult:
|
||
"""
|
||
完整登录流程(带整体重试)。
|
||
|
||
每一轮用一个代理走完所有步骤,任何步骤失败就取新代理从头重来。
|
||
max_login_retries=0 表示无限重试,max_total_time=0 表示不限制总时长。
|
||
|
||
Returns:
|
||
LoginResult: 登录结果,包含cookie
|
||
"""
|
||
logger.info(f"开始登录账号: {self.account.username}")
|
||
start_time = time.monotonic()
|
||
deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0
|
||
|
||
attempt = 0
|
||
while True:
|
||
attempt += 1
|
||
if self._is_stopped():
|
||
logger.warning("登录任务已停止")
|
||
return LoginResult(success=False, message="任务已停止")
|
||
elapsed = time.monotonic() - start_time
|
||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||
logger.warning(
|
||
f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
|
||
)
|
||
return LoginResult(
|
||
success=False,
|
||
message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)",
|
||
)
|
||
|
||
if attempt > 1:
|
||
if self.max_login_retries > 0:
|
||
logger.info(
|
||
f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||
)
|
||
else:
|
||
logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始")
|
||
# 重试前:取新代理 + 重置 session
|
||
if not self._prepare_retry():
|
||
return LoginResult(
|
||
success=False,
|
||
message=f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理",
|
||
)
|
||
|
||
try:
|
||
cookie = self._run_login_steps(deadline=deadline)
|
||
message = "登录成功"
|
||
if self._cookie_enrich_error:
|
||
message = f"登录成功,补CK失败: {self._cookie_enrich_error}"
|
||
|
||
logger.success(f"登录成功! Cookie长度: {len(cookie)}")
|
||
return LoginResult(success=True, cookie=cookie, message=message)
|
||
|
||
except InterruptedError as e:
|
||
logger.warning(f"登录任务已停止: {e}")
|
||
return LoginResult(success=False, message=str(e))
|
||
except CredentialError as e:
|
||
logger.error(f"登录失败(凭据错误,不再重试): {e}")
|
||
return LoginResult(success=False, message=str(e), code=e.code)
|
||
except EmailLoginError as e:
|
||
logger.error(f"登录失败(邮箱登录失败,不再重试): {e}")
|
||
return LoginResult(
|
||
success=False, message=str(e), code="email_login_failed"
|
||
)
|
||
except Exception as e:
|
||
elapsed = time.monotonic() - start_time
|
||
if self.max_login_retries > 0:
|
||
logger.error(
|
||
f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}"
|
||
)
|
||
else:
|
||
logger.error(
|
||
f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}"
|
||
)
|
||
|
||
has_retry = (
|
||
self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||
)
|
||
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||
if has_retry and has_time:
|
||
self._sleep_interruptible(1)
|
||
continue
|
||
# 所有重试耗尽或超时
|
||
return LoginResult(success=False, message=str(e))
|
||
|
||
def check_account(self) -> LoginResult:
|
||
"""
|
||
检测账号状态。
|
||
|
||
可直接识别注销/密码错误;登录成功后请求个人中心接口判断实名状态。
|
||
"""
|
||
logger.info(f"开始检测账号: {self.account.username}")
|
||
start_time = time.monotonic()
|
||
deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0
|
||
|
||
attempt = 0
|
||
while True:
|
||
attempt += 1
|
||
if self._is_stopped():
|
||
logger.warning("账号检测任务已停止")
|
||
return LoginResult(success=False, message="任务已停止")
|
||
elapsed = time.monotonic() - start_time
|
||
if self.max_total_time > 0 and elapsed > self.max_total_time:
|
||
logger.warning(
|
||
f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃"
|
||
)
|
||
return LoginResult(
|
||
success=False,
|
||
message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)",
|
||
)
|
||
|
||
if attempt > 1:
|
||
if self.max_login_retries > 0:
|
||
logger.info(
|
||
f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始"
|
||
)
|
||
if not self._prepare_retry():
|
||
return LoginResult(
|
||
success=False,
|
||
message=f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理",
|
||
)
|
||
|
||
try:
|
||
self._run_login_steps(deadline=deadline)
|
||
return self._check_certification_status()
|
||
|
||
except InterruptedError as e:
|
||
logger.warning(f"账号检测任务已停止: {e}")
|
||
return LoginResult(success=False, message=str(e))
|
||
except CredentialError as e:
|
||
logger.info(f"账号检测完成: {e.status_message}")
|
||
return LoginResult(success=True, message=e.status_message, code=e.code)
|
||
except EmailLoginError as e:
|
||
logger.error(f"账号检测失败(邮箱登录失败,不再重试): {e}")
|
||
return LoginResult(
|
||
success=False, message=str(e), code="email_login_failed"
|
||
)
|
||
except Exception as e:
|
||
elapsed = time.monotonic() - start_time
|
||
if self.max_login_retries > 0:
|
||
logger.error(
|
||
f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}"
|
||
)
|
||
else:
|
||
logger.error(
|
||
f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}"
|
||
)
|
||
|
||
has_retry = (
|
||
self.max_login_retries <= 0 or attempt < self.max_login_retries
|
||
)
|
||
has_time = self.max_total_time <= 0 or elapsed < self.max_total_time
|
||
if has_retry and has_time:
|
||
self._sleep_interruptible(1)
|
||
continue
|
||
return LoginResult(success=False, message=str(e))
|
||
|
||
def _check_certification_status(self) -> LoginResult:
|
||
"""请求个人中心接口并判断实名状态。"""
|
||
headers = {
|
||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
|
||
"Cache-Control": "no-cache",
|
||
"Pragma": "no-cache",
|
||
"Priority": "u=1, i",
|
||
"Referer": self.CP_REFERER,
|
||
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||
"Sec-CH-UA-Mobile": "?0",
|
||
"Sec-CH-UA-Platform": '"macOS"',
|
||
"Sec-Fetch-Dest": "empty",
|
||
"Sec-Fetch-Mode": "cors",
|
||
"Sec-Fetch-Site": "same-origin",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
"Origin": None,
|
||
"Content-Type": None,
|
||
}
|
||
payload = self._request_json(
|
||
"get",
|
||
self.CP_RPC_API,
|
||
"账号认证状态接口",
|
||
headers=headers,
|
||
timeout=(5, 10),
|
||
)
|
||
info = payload.get("info") or {}
|
||
ident_status = str(info.get("ident_status", ""))
|
||
ident_type = str(info.get("ident_type", ""))
|
||
|
||
if ident_status == "0" and ident_type == "0":
|
||
logger.success("账号检测完成: 账号未认证")
|
||
return LoginResult(
|
||
success=True, message="账号未认证", code="account_unverified"
|
||
)
|
||
|
||
if ident_status == "2" and ident_type == "2":
|
||
logger.success("账号检测完成: 账号已认证")
|
||
return LoginResult(
|
||
success=True, message="账号已认证", code="account_verified"
|
||
)
|
||
|
||
message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}"
|
||
logger.warning(message)
|
||
return LoginResult(success=True, message=message, code="account_auth_unknown")
|
||
|
||
def _prepare_retry(self) -> bool:
|
||
"""重试前准备:取新代理、重置 session cookies。
|
||
|
||
Returns:
|
||
True - 可以继续重试
|
||
False - 静态代理已连续失败 MAX_STATIC_RETRY 次,无法切换代理,应放弃
|
||
"""
|
||
self.session.cookies.clear()
|
||
if self.proxy_fetcher:
|
||
new_proxy = self.proxy_fetcher.fetch_new_proxy()
|
||
if new_proxy:
|
||
self._apply_proxy(new_proxy)
|
||
logger.info(f"重试换新代理: {new_proxy}")
|
||
return True
|
||
if self.proxy:
|
||
self._static_retry_count += 1
|
||
if self._static_retry_count > self.MAX_STATIC_RETRY:
|
||
logger.error(
|
||
f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理"
|
||
)
|
||
return False
|
||
logger.warning(
|
||
f"静态代理失败重试 {self._static_retry_count}/{self.MAX_STATIC_RETRY}"
|
||
)
|
||
return True
|
||
|
||
def _first_login(self) -> tuple[str, str, str, dict]:
|
||
"""
|
||
第一次登录,获取极验参数
|
||
|
||
Returns:
|
||
(gt, challenge, code_token, cookies)
|
||
"""
|
||
data = self.api.build_first_login_data(
|
||
self.account.username,
|
||
self.account.password,
|
||
self.LOGIN_REFERER,
|
||
)
|
||
|
||
payload = self._request_json(
|
||
"post",
|
||
self.api.first_login_url,
|
||
"第一次登录接口",
|
||
data=data,
|
||
)
|
||
|
||
logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||
|
||
if payload.get("error") != 81:
|
||
error_msg = payload.get("msg", "未知错误")
|
||
credential_error = self._credential_error_from_payload(
|
||
"第一次登录", payload
|
||
)
|
||
if credential_error:
|
||
raise credential_error
|
||
raise ValueError(f"第一次登录失败: {error_msg}")
|
||
|
||
# 提取极验参数
|
||
gt, challenge, code_token = self.api.extract_geetest_params(payload)
|
||
|
||
if not gt or not challenge:
|
||
raise ValueError("获取极验参数失败")
|
||
|
||
logger.info(f"获取极验参数成功: gt={gt[:10]}..., challenge={challenge[:10]}...")
|
||
|
||
return gt, challenge, code_token, self.session.cookies.get_dict()
|
||
|
||
def _solve_geetest(
|
||
self, gt: str, challenge: str, deadline: float = 0
|
||
) -> tuple[str, str]:
|
||
"""
|
||
解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理)
|
||
|
||
不在内部做代理切换——短效代理寿命宝贵,换代理由 login 整体重试负责。
|
||
|
||
Args:
|
||
gt: 极验gt参数
|
||
challenge: 极验challenge参数(第一次登录返回的)
|
||
deadline: 登录整体超时截止时间(monotonic),0=不限
|
||
|
||
Returns:
|
||
(validate, seccode)
|
||
"""
|
||
logger.info("开始极验 fullpage 验证...")
|
||
|
||
# ── 全局并发限制:同一时刻最多2个线程做极验,避免被极验限流 ──
|
||
acquired = _geetest_semaphore.acquire(timeout=120)
|
||
if not acquired:
|
||
raise ValueError("极验验证等待超时(并发排队120秒未获得信号量)")
|
||
try:
|
||
return self._solve_geetest_inner(gt, challenge, deadline)
|
||
finally:
|
||
_geetest_semaphore.release()
|
||
|
||
def _solve_geetest_inner(
|
||
self, gt: str, challenge: str, deadline: float = 0
|
||
) -> tuple[str, str]:
|
||
"""极验验证内部实现:最多3次尝试,失败直接抛异常。"""
|
||
_MAX_ATTEMPTS = 3
|
||
|
||
attempt = 0
|
||
while True:
|
||
attempt += 1
|
||
self._ensure_not_stopped()
|
||
|
||
if attempt > _MAX_ATTEMPTS:
|
||
raise ValueError(
|
||
f"极验验证已尝试 {attempt - 1} 次,超过上限 {_MAX_ATTEMPTS},"
|
||
f"回到 login 整体重试换新代理"
|
||
)
|
||
|
||
# 超时兜底
|
||
if deadline and time.monotonic() > deadline:
|
||
raise ValueError("极验验证超时(登录整体时间耗尽)")
|
||
|
||
try:
|
||
logger.info(f"极验验证尝试 {attempt}/{_MAX_ATTEMPTS}")
|
||
|
||
str_16 = _generate_seed()
|
||
proxies = dict(self.session.proxies)
|
||
|
||
# 获取JS地址
|
||
get_js_address(gt, proxies=proxies)
|
||
|
||
# 获取第一个w值
|
||
w1 = get_w1(gt, challenge, str_16)
|
||
|
||
# 获取c和s
|
||
c, s = get_c_s(gt, challenge, w1, proxies=proxies)
|
||
|
||
# 获取第二个w值
|
||
w2 = get_w2(gt, challenge, c, s, str_16)
|
||
|
||
# HAR 中 ajax.php 直接返回 validate。
|
||
result = req_fullpage_validate(gt, challenge, w2, proxies=proxies)
|
||
|
||
# 从 fullpage ajax.php 响应中提取 validate
|
||
if isinstance(result, dict):
|
||
data = result.get("data", {})
|
||
validate = data.get("validate", "") or result.get("validate", "")
|
||
success = (
|
||
data.get("result") == "success" or result.get("success") == 1
|
||
)
|
||
message = data.get("result") or result.get("message", "")
|
||
|
||
if success and validate:
|
||
seccode = f"{validate}|jordan"
|
||
logger.success(
|
||
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||
)
|
||
return validate, seccode
|
||
else:
|
||
logger.warning(f"极验验证失败: {message},回到 login 换新代理")
|
||
raise ValueError(f"极验验证失败: {message}")
|
||
else:
|
||
validate = str(result)
|
||
if validate:
|
||
seccode = f"{validate}|jordan"
|
||
logger.success(
|
||
f"极验 fullpage 验证成功! validate={validate[:20]}..."
|
||
)
|
||
return validate, seccode
|
||
|
||
except ValueError:
|
||
# 极验逻辑失败(slide等)或超过上限,直接上抛
|
||
raise
|
||
except Exception as e:
|
||
err_str = str(e)
|
||
# 所有网络/代理异常都直接上抛,由 login 换新代理
|
||
logger.warning(
|
||
f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理"
|
||
)
|
||
raise ValueError(
|
||
f"极验验证异常: {self._truncate_error(err_str)}"
|
||
) from e
|
||
|
||
def _second_login(
|
||
self, gt: str, challenge: str, validate: str, seccode: str, code_token: str
|
||
) -> tuple[str, str]:
|
||
"""
|
||
第二次登录(带极验验证)
|
||
|
||
Args:
|
||
gt: 极验gt参数
|
||
challenge: 第一次登录返回的challenge
|
||
validate: 极验验证返回的validate
|
||
seccode: 极验验证返回的seccode
|
||
code_token: 第一次登录返回的code_token
|
||
|
||
Returns:
|
||
(next_step, value):
|
||
- ('remote_email', remote_code): 继续邮箱验证流程
|
||
- ('mobile_bind_skip', login_url): 已跳过手机号绑定,可完成登录
|
||
"""
|
||
data = self.api.build_second_login_data(
|
||
self.account.username,
|
||
self.account.password,
|
||
gt,
|
||
challenge,
|
||
validate,
|
||
seccode,
|
||
code_token,
|
||
self.LOGIN_REFERER,
|
||
)
|
||
|
||
logger.debug(
|
||
f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}..."
|
||
)
|
||
|
||
payload = self._request_json(
|
||
"post",
|
||
self.api.login_url,
|
||
"第二次登录接口",
|
||
data=data,
|
||
)
|
||
|
||
logger.debug(f"第二次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}")
|
||
|
||
# 新版登录在账号未绑定手机号时会返回一次性 uniqueKey。浏览器点击
|
||
# “跳过”后会向同一接口提交 type=3 + uniqueKey,服务端才会发放回调 URL。
|
||
unique_key = self.api.extract_mobile_bind_unique_key(payload)
|
||
if unique_key:
|
||
logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定")
|
||
login_url = self._skip_mobile_bind(unique_key)
|
||
return "mobile_bind_skip", login_url
|
||
|
||
if payload.get("error") != 130014:
|
||
error_msg = payload.get("msg", "未知错误")
|
||
credential_error = self._credential_error_from_payload(
|
||
"第二次登录", payload
|
||
)
|
||
if credential_error:
|
||
raise credential_error
|
||
raise ValueError(f"第二次登录失败: {error_msg}")
|
||
|
||
# 提取remote_code
|
||
remote_code = self.api.extract_remote_code(payload)
|
||
|
||
if not remote_code:
|
||
# 提取可能的风控提示
|
||
data = payload.get("data", {})
|
||
security_quiz = data.get("securityQuiz", "")
|
||
msg = payload.get("msg", "")
|
||
detail = security_quiz or msg or "未知原因"
|
||
raise ValueError(f"获取remote_code失败: {detail}")
|
||
|
||
logger.info(f"获取remote_code成功: {remote_code[:20]}...")
|
||
|
||
return "remote_email", remote_code
|
||
|
||
def _skip_mobile_bind(self, unique_key: str) -> str:
|
||
"""跳过手机号绑定并获取后续登录回调地址。"""
|
||
payload = self._request_json(
|
||
"post",
|
||
self.api.login_url,
|
||
"跳过手机号绑定接口",
|
||
data=self.api.build_skip_mobile_bind_data(unique_key),
|
||
)
|
||
|
||
if payload.get("error") != 0:
|
||
raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}")
|
||
|
||
login_url = self.api.extract_login_url(payload)
|
||
if not login_url:
|
||
raise ValueError("跳过手机号绑定后未获取登录URL")
|
||
login_url = self._normalize_login_url(login_url)
|
||
|
||
logger.info("手机号绑定已跳过,获取登录URL成功")
|
||
return login_url
|
||
|
||
def _send_email_verify(self, remote_code: str) -> None:
|
||
"""发送邮箱验证"""
|
||
data = self.api.build_send_email_data(remote_code)
|
||
|
||
payload = self._request_json(
|
||
"post",
|
||
self.api.send_email_url,
|
||
"发送验证邮件接口",
|
||
data=data,
|
||
)
|
||
|
||
if payload.get("error") != 0:
|
||
raise ValueError(f"发送验证邮件失败: {payload.get('msg')}")
|
||
|
||
logger.info("验证邮件已发送")
|
||
|
||
def _get_email_code(self, after_timestamp: float | None = None) -> str:
|
||
"""获取邮箱验证码"""
|
||
verifier = EmailVerifier(
|
||
imap_server=self.account.email_imap_server,
|
||
imap_port=self.account.email_imap_port,
|
||
username=self.account.email,
|
||
password=self.account.email_password,
|
||
use_ssl=self.account.email_imap_ssl,
|
||
)
|
||
|
||
return verifier.get_verification_code(
|
||
max_wait=60,
|
||
after_timestamp=after_timestamp,
|
||
stop_event=self.stop_event,
|
||
)
|
||
|
||
def _submit_verify_code(self, remote_code: str, verify_code: str) -> str:
|
||
"""
|
||
提交验证码
|
||
|
||
Returns:
|
||
login_url: 登录回调URL
|
||
"""
|
||
data = self.api.build_verify_data(remote_code, verify_code)
|
||
|
||
payload = self._request_json(
|
||
"post",
|
||
self.api.verify_url,
|
||
"提交验证码接口",
|
||
data=data,
|
||
)
|
||
|
||
if payload.get("error") != 0:
|
||
raise ValueError(f"提交验证码失败: {payload.get('msg')}")
|
||
|
||
login_url = self.api.extract_login_url(payload)
|
||
|
||
if not login_url:
|
||
raise ValueError("获取登录URL失败")
|
||
|
||
login_url = self._normalize_login_url(login_url)
|
||
|
||
logger.info(f"获取登录URL成功: {login_url[:50]}...")
|
||
|
||
return login_url
|
||
|
||
@staticmethod
|
||
def _normalize_login_url(login_url: str) -> str:
|
||
"""补全斗鱼接口返回的协议相对登录回调地址。"""
|
||
if login_url.startswith("//"):
|
||
return "https:" + login_url
|
||
return login_url
|
||
|
||
def _complete_login(self, login_url: str) -> str:
|
||
"""
|
||
完成登录,获取Cookie
|
||
|
||
Returns:
|
||
cookie: 完整的Cookie字符串
|
||
"""
|
||
# 访问登录URL
|
||
response = self._request("get", login_url)
|
||
response.raise_for_status()
|
||
|
||
# 尝试访问webLogin获取用户信息
|
||
try:
|
||
code_match = re.search(r"code=([^&]+)", login_url)
|
||
if code_match:
|
||
code = code_match.group(1)
|
||
weblogin_url = f"{self.WEBLOGIN_API}?code={code}"
|
||
response2 = self._request("get", weblogin_url)
|
||
|
||
if response2.status_code == 200:
|
||
logger.info("WebLogin成功")
|
||
except Exception as e:
|
||
logger.warning(f"WebLogin请求失败(不影响登录): {e}")
|
||
|
||
# 登录成功后补齐 Web 侧 Cookie。补 CK 失败只影响完整度,不回滚已成功的登录态。
|
||
self._cookie_enrich_error = ""
|
||
try:
|
||
CookieEnricher(
|
||
session=self.session,
|
||
request_func=self._request,
|
||
ensure_not_stopped=self._ensure_not_stopped,
|
||
sleep_interruptible=self._sleep_interruptible,
|
||
).enrich_with_retry()
|
||
except InterruptedError:
|
||
raise
|
||
except Exception as e:
|
||
self._cookie_enrich_error = self._truncate_error(str(e), 160)
|
||
logger.warning(
|
||
f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}"
|
||
)
|
||
|
||
return self._format_cookie_string()
|
||
|
||
def _format_cookie_string(self) -> str:
|
||
"""把当前 session 中的 Cookie 格式化为可导出的 CK 字符串。"""
|
||
cookies = self.session.cookies.get_dict()
|
||
|
||
# 格式化Cookie字符串
|
||
cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
||
|
||
return cookie_str
|