Files
live-hub-py/core/douyu/login.py
T
2026-08-31 10:55:44 +08:00

934 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼登录核心模块"""
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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