优化登录重试与配置清理

This commit is contained in:
yml2213
2026-06-24 10:20:33 +08:00
parent 72248eb3cc
commit 0d03b2c242
19 changed files with 136 additions and 273 deletions
+10 -137
View File
@@ -1,6 +1,7 @@
"""邮箱验证模块 - 优先通过 Roundcube Webmail API 获取验证码read.php 作为备用"""
"""邮箱验证模块 - 通过 Roundcube Webmail API 获取验证码"""
import html
import os
import re
import threading
import time
@@ -11,15 +12,12 @@ from loguru import logger
import requests
# 旧接口(时间解析不可靠,作为备用)
READ_PHP_URL = "http://111.229.206.54:8000/read.php"
# Roundcube Webmail 地址
ROUNDCUBE_URL = "http://111.229.206.54:8000/"
# Roundcube Webmail 地址。可通过环境变量覆盖。
ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL", "http://111.229.206.54:8000/")
class EmailVerifier:
"""邮箱验证器(Roundcube API 优先,read.php 备用"""
"""邮箱验证器(Roundcube API"""
def __init__(
self,
@@ -32,7 +30,7 @@ class EmailVerifier:
lookback_minutes: int = 10,
max_messages: int = 20,
use_ssl: bool = False,
mail_api_url: str = READ_PHP_URL,
roundcube_url: str = "",
):
# 兼容旧参数名
self.imap_server = imap_server
@@ -40,8 +38,7 @@ class EmailVerifier:
self.username = username # 邮箱地址
self.password = password # 邮箱密码
self.timeout = timeout
self.read_php_url = mail_api_url or READ_PHP_URL
self.roundcube_url = ROUNDCUBE_URL
self.roundcube_url = roundcube_url or ROUNDCUBE_URL
# Roundcube 会话(懒初始化)
self._rc_session: Optional[requests.Session] = None
@@ -265,7 +262,7 @@ class EmailVerifier:
stop_event: Optional[threading.Event] = None,
) -> str:
"""
轮询获取斗鱼验证码。优先使用 Roundcube API,失败则回退到 read.php。
轮询获取斗鱼验证码。
Args:
max_wait: 最大等待时间(秒)
@@ -281,33 +278,19 @@ class EmailVerifier:
deadline = time.monotonic() + max_wait
last_error = ""
tried_roundcube = False
while time.monotonic() < deadline:
if stop_event and stop_event.is_set():
raise InterruptedError("任务已停止")
# 优先尝试 Roundcube
if not tried_roundcube or self._rc_logged_in:
try:
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
if code:
logger.success(f"获取到验证码: {code}")
return code
except Exception as e:
last_error = str(e)
logger.warning(f"Roundcube 读邮件异常: {e}")
tried_roundcube = True
# 回退到 read.php
try:
code = self._fetch_code_via_readphp(after_timestamp, allow_old_seconds)
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
if code:
logger.success(f"获取到验证码: {code}")
return code
except Exception as e:
last_error = str(e)
logger.warning(f"read.php 读邮件异常: {e}")
logger.warning(f"Roundcube 读邮件异常: {e}")
logger.debug("未找到验证码,等待中...")
sleep_deadline = time.monotonic() + interval
@@ -354,118 +337,8 @@ class EmailVerifier:
return None
# ── read.php 方式获取验证码(备用) ─────────────────────
def _fetch_code_via_readphp(
self,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> Optional[str]:
"""通过 read.php HTTP 接口读取最新邮件并提取验证码"""
try:
response = requests.get(
self.read_php_url,
params={
"yhm": self.username,
"mm": self.password,
},
timeout=self.timeout,
)
response.raise_for_status()
content = response.text
# 检查是否是斗鱼验证码邮件
if not self._is_douyu_email(content):
logger.debug("read.php: 最新邮件不是斗鱼验证码邮件")
return None
# 检查邮件时间(read.php 时间不可靠,放宽判断)
if after_timestamp and not self._is_email_recent(content, after_timestamp, allow_old_seconds):
logger.debug("read.php: 邮件时间早于验证码发送时间,跳过")
return None
# 提取验证码
code = self._extract_verification_code(content)
if code:
logger.info(f"read.php: 从邮件中提取到验证码: {code}")
return code
return None
except requests.RequestException as e:
logger.warning(f"read.php: 请求失败: {e}")
return None
# ── 通用工具方法 ───────────────────────────────────────
def _is_douyu_email(self, content: str) -> bool:
"""判断邮件内容是否是斗鱼验证码邮件"""
douyu_keywords = ['斗鱼', 'douyu', 'douyutv']
verify_keywords = ['验证码', '安全验证', '登录验证', '动态码', '校验码']
combined = content.lower()
return (
any(keyword in combined for keyword in douyu_keywords)
or any(keyword in content for keyword in verify_keywords)
)
def _is_email_recent(
self,
content: str,
after_timestamp: float,
allow_old_seconds: int = 15,
) -> bool:
"""
检查 read.php 返回的邮件时间是否晚于验证码发送时间。
注意:read.php 的时间解析不可靠,此处仅作参考判断。
"""
from email.utils import parsedate_to_datetime
# 先清理 HTML 标签,再提取时间
clean = re.sub(r'<[^>]+>', ' ', content)
clean = re.sub(r'\s+', ' ', clean)
# 匹配 RFC2822 格式时间
time_match = re.search(
r'发信时间[:]?\s*'
r'((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*\d{1,2}\s+'
r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+'
r'\d{4}\s+\d{2}:\d{2}:\d{2}\s*[+-]\d{4})',
clean,
)
if not time_match:
# 尝试匹配不带星期的时间格式
time_match2 = re.search(
r'发信时间[:]?\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})',
clean,
)
if time_match2:
time_str = time_match2.group(1).strip()
try:
from datetime import datetime as dt
msg_time = dt.strptime(time_str, '%Y-%m-%d %H:%M:%S')
if msg_time.timestamp() < after_timestamp - allow_old_seconds:
logger.debug(f"邮件时间 {time_str} 早于发送时间,跳过旧邮件")
return False
return True
except ValueError:
pass
# read.php 时间不可靠时,不再保守拒绝,改为放行
# 让验证码提取逻辑自行判断
logger.debug("read.php: 未找到可解析的发信时间,跳过时间检查直接提取验证码")
return True
time_str = time_match.group(1).strip()
try:
msg_time = parsedate_to_datetime(time_str)
if msg_time and msg_time.timestamp() < after_timestamp - allow_old_seconds:
logger.debug(f"邮件时间 {time_str} 早于发送时间,跳过旧邮件")
return False
return True
except (TypeError, ValueError):
logger.debug(f"无法解析邮件时间: {time_str},跳过时间检查")
return True
def _extract_verification_code(self, text: str) -> Optional[str]:
"""从文本中提取6位验证码"""
# 清理HTML标签和实体
+47 -31
View File
@@ -14,7 +14,6 @@ from .crypto import encrypt_password, encrypt_nickname_or_phone
from .email_verifier import EmailVerifier
from .proxy import ProxyManager, get_proxy_manager
from core.geetest import run_solver
from core.geetest.v3_slide.solver import (
_generate_seed, get_w1, get_w2,
)
@@ -53,7 +52,6 @@ class DouyuLogin:
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"
LOGIN_CALLBACK_API = "https://www.douyu.com/api/passport/login"
WEBLOGIN_API = "https://msg.douyu.com/webLogin"
LOGIN_REFERER = (
"https://passport.douyu.com/index/login?"
@@ -72,10 +70,9 @@ class DouyuLogin:
proxy: Optional[str | Mapping[str, str]] = None,
proxy_api_url: Optional[str] = None,
timeout: tuple[float, float] = REQUEST_TIMEOUT,
max_geetest_retries: int = 5,
max_proxy_retries: int = 0,
max_login_retries: int = 3,
max_total_time: float = 300,
max_login_retries: int = 0,
max_total_time: float = 0,
whitelist_uid: str = "",
whitelist_ukey: str = "",
proxy_manager: Optional[ProxyManager] = None,
@@ -84,10 +81,9 @@ class DouyuLogin:
self.account = account
self.proxy = proxy
self.timeout = timeout
self.max_geetest_retries = max_geetest_retries
self.max_proxy_retries = max_proxy_retries # 0=无限切换直到成功
self.max_login_retries = max_login_retries # 登录整体重试次数(换代理从头重跑)
self.max_total_time = max_total_time # 单账号登录总时长上限(秒),超时则放弃
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()
@@ -308,34 +304,39 @@ class DouyuLogin:
"""
完整登录流程(带整体重试)。
任何步骤失败时,换新代理从头重跑,最多重试 max_login_retries 次
整体超时 max_total_time 秒后放弃
任何步骤失败时,换新代理从头重跑。
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
deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0
for attempt in range(1, self.max_login_retries + 1):
attempt = 0
while True:
attempt += 1
if self._is_stopped():
logger.warning("登录任务已停止")
return LoginResult(success=False, message="任务已停止")
elapsed = time.monotonic() - start_time
if elapsed > self.max_total_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:
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换代理重新开始")
if self.max_login_retries > 0:
logger.info(f"登录整体重试 {attempt}/{self.max_login_retries},换代理重新开始")
else:
logger.info(f"登录整体重试 {attempt} (无限重试),换代理重新开始")
# 重试前:换新代理 + 重置 session(清 cookies
self._prepare_retry()
try:
# 1️⃣ 第一次登录(获取极验参数)
logger.info("步骤1: 第一次登录,获取极验参数...")
gt, challenge, code_token, initial_cookies = self._first_login()
gt, challenge, code_token, _ = self._first_login()
# 2️⃣ 极验 fullpage 验证
logger.info("步骤2: 极验 fullpage 验证...")
@@ -376,8 +377,14 @@ class DouyuLogin:
return LoginResult(success=False, message=str(e))
except Exception as e:
elapsed = time.monotonic() - start_time
logger.error(f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s: {e}")
if attempt < self.max_login_retries and elapsed < self.max_total_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:
# 还有重试机会且未超时:标记当前代理坏,下一轮自动换新代理
if self.proxy_manager and self._current_proxy_url:
self.proxy_manager.mark_bad(self._current_proxy_url)
@@ -386,8 +393,6 @@ class DouyuLogin:
# 所有重试耗尽或超时
return LoginResult(success=False, message=str(e))
return LoginResult(success=False, message=f"登录失败,已重试 {self.max_login_retries}")
def _prepare_retry(self) -> None:
"""重试前准备:换新代理、重置 session cookies。"""
# 重置 session(清掉旧 cookies,避免残留状态干扰)
@@ -471,24 +476,35 @@ class DouyuLogin:
"""
logger.info("开始极验 fullpage 验证...")
# max_proxy_retries=0 表示无限重试直到成功
max_attempts = self.max_proxy_retries if self.max_proxy_retries > 0 else 999999
# max_proxy_retries=0 表示不限代理切换次数。
max_proxy_switches = self.max_proxy_retries if self.max_proxy_retries > 0 else 999999
proxy_switches = 0
# 连续临时失败计数(同一代理下),超过阈值才换代理
_soft_fail_streak = 0
_SOFT_FAIL_THRESHOLD = 2 # 同一代理连续临时失败2次才换
for attempt in range(max_attempts):
def refresh_proxy(mark_bad: bool) -> None:
"""按代理切换上限刷新代理。"""
nonlocal proxy_switches
if not self.proxy_manager:
return
if proxy_switches >= max_proxy_switches:
raise ValueError(f"极验验证代理切换次数已达上限 {self.max_proxy_retries}")
new_proxy = self._refresh_proxy(mark_bad=mark_bad)
if new_proxy:
proxy_switches += 1
attempt = 0
while True:
attempt += 1
self._ensure_not_stopped()
# 超时兜底:极验验证不应超过登录整体时间上限
if deadline and time.monotonic() > deadline:
raise ValueError(f"极验验证超时(登录整体时间耗尽)")
try:
if self.max_proxy_retries > 0:
logger.info(f"极验验证尝试 {attempt + 1}/{max_attempts}")
else:
logger.info(f"极验验证尝试 {attempt + 1} (无限重试)")
logger.info(f"极验验证尝试 {attempt} (无限重试)")
# 按斗鱼登录页 HAR:fullpage 智能检测流程,不进入图片滑块。
str_16 = _generate_seed()
@@ -525,7 +541,7 @@ class DouyuLogin:
_soft_fail_streak += 1
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
logger.warning(f"极验验证失败: {message},同一代理连续 {_soft_fail_streak} 次,换代理")
self._refresh_proxy(mark_bad=False)
refresh_proxy(mark_bad=False)
_soft_fail_streak = 0
else:
logger.warning(f"极验验证失败: {message},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
@@ -540,27 +556,27 @@ class DouyuLogin:
except Exception as e:
err_str = str(e)
if "代理切换次数已达上限" in err_str:
raise
is_proxy_dead = self._is_proxy_connection_error(err_str)
if is_proxy_dead:
# 代理确实不可用:立即换,标记坏
logger.warning(f"极验验证代理连接失败: {self._truncate_error(err_str)},换代理")
self._refresh_proxy(mark_bad=True)
refresh_proxy(mark_bad=True)
_soft_fail_streak = 0
else:
# 临时异常(KeyError、网络不给力等):先原代理重试
_soft_fail_streak += 1
if _soft_fail_streak >= _SOFT_FAIL_THRESHOLD:
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},连续 {_soft_fail_streak} 次,换代理")
self._refresh_proxy(mark_bad=False)
refresh_proxy(mark_bad=False)
_soft_fail_streak = 0
else:
logger.warning(f"极验验证临时异常: {self._truncate_error(err_str)},原代理重试 ({_soft_fail_streak}/{_SOFT_FAIL_THRESHOLD})")
self._sleep_interruptible(2)
continue
if self.max_proxy_retries > 0:
raise ValueError(f"极验验证失败,已重试 {max_attempts}")
raise ValueError("极验验证失败(无限重试模式仍未能通过)")
def _second_login(self, gt: str, challenge: str, validate: str,