问题:HTTP读邮件接口返回最新一封邮件,但可能是之前其他账号的旧验证码。 日志显示提取到485932(06-22的)和242740(25分钟前的),提交后报邮箱验证码错误。 根因: 1. 时间解析正则匹配到了HTML标签'</label>'前缀,导致parsedate_to_datetime失败 2. 解析失败后保守'纳入候选',导致旧验证码被使用 修复: - 先清理HTML标签再提取时间 - 支持RFC2822格式和YYYY-MM-DD HH:MM:SS格式 - 解析失败时保守'拒绝'(而不是纳入候选),避免使用旧验证码 - 5项时间解析测试全部通过
226 lines
7.9 KiB
Python
226 lines
7.9 KiB
Python
"""邮箱验证模块 - 通过 HTTP 接口获取验证码(替代 IMAP)"""
|
||
|
||
import html
|
||
import re
|
||
import time
|
||
from typing import Optional
|
||
from loguru import logger
|
||
import requests
|
||
|
||
|
||
# HTTP 读邮件接口地址
|
||
MAIL_API_URL = "http://111.229.206.54:8000/read.php"
|
||
|
||
|
||
class EmailVerifier:
|
||
"""邮箱验证器(HTTP 方式)"""
|
||
|
||
def __init__(
|
||
self,
|
||
imap_server: str = "",
|
||
imap_port: int = 143,
|
||
username: str = "",
|
||
password: str = "",
|
||
timeout: float = 10,
|
||
mailbox: str = "INBOX",
|
||
lookback_minutes: int = 10,
|
||
max_messages: int = 20,
|
||
use_ssl: bool = False,
|
||
mail_api_url: str = MAIL_API_URL,
|
||
):
|
||
# 兼容旧参数名,HTTP 模式下 imap_* 参数仅用于日志
|
||
self.imap_server = imap_server
|
||
self.imap_port = imap_port
|
||
self.username = username # 邮箱地址
|
||
self.password = password # 邮箱密码
|
||
self.timeout = timeout
|
||
self.mail_api_url = mail_api_url
|
||
self.use_ssl = use_ssl
|
||
|
||
def connect(self) -> None:
|
||
"""兼容接口:HTTP 模式下无需持久连接"""
|
||
pass
|
||
|
||
def disconnect(self) -> None:
|
||
"""兼容接口"""
|
||
pass
|
||
|
||
def get_verification_code(
|
||
self,
|
||
max_wait: int = 60,
|
||
interval: int = 3,
|
||
after_timestamp: Optional[float] = None,
|
||
allow_old_seconds: int = 15,
|
||
) -> str:
|
||
"""
|
||
轮询 HTTP 读邮件接口获取斗鱼验证码。
|
||
|
||
Args:
|
||
max_wait: 最大等待时间(秒)
|
||
interval: 轮询间隔(秒)
|
||
after_timestamp: 发送验证码请求的时间戳,用于过滤旧邮件
|
||
allow_old_seconds: 允许的时间偏移(秒)
|
||
|
||
Returns:
|
||
6位验证码
|
||
"""
|
||
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
|
||
|
||
deadline = time.monotonic() + max_wait
|
||
last_error = ""
|
||
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
code = self._fetch_code_via_http(after_timestamp, allow_old_seconds)
|
||
if code:
|
||
logger.success(f"获取到验证码: {code}")
|
||
return code
|
||
logger.debug("未找到验证码,等待中...")
|
||
except Exception as e:
|
||
last_error = str(e)
|
||
logger.warning(f"读取邮件异常: {e}")
|
||
time.sleep(interval)
|
||
|
||
raise TimeoutError(f"等待验证码超时{'(' + last_error + ')' if last_error else ''}")
|
||
|
||
def _fetch_code_via_http(
|
||
self,
|
||
after_timestamp: Optional[float] = None,
|
||
allow_old_seconds: int = 15,
|
||
) -> Optional[str]:
|
||
"""通过 HTTP 接口读取最新邮件并提取验证码"""
|
||
try:
|
||
response = requests.get(
|
||
self.mail_api_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("最新邮件不是斗鱼验证码邮件")
|
||
return None
|
||
|
||
# 检查邮件时间是否足够新(发送验证码请求之后)
|
||
if after_timestamp and not self._is_email_recent(content, after_timestamp, allow_old_seconds):
|
||
logger.debug("邮件时间早于验证码发送时间,跳过")
|
||
return None
|
||
|
||
# 提取验证码
|
||
code = self._extract_verification_code(content)
|
||
if code:
|
||
logger.info(f"从邮件中提取到验证码: {code}")
|
||
return code
|
||
|
||
return None
|
||
|
||
except requests.RequestException as e:
|
||
logger.warning(f"HTTP读邮件请求失败: {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:
|
||
"""
|
||
检查邮件时间是否晚于验证码发送时间。
|
||
|
||
HTTP 接口返回的 HTML 中包含 "发信时间: Tue, 23 Jun 2026 01:06:09 +0800"
|
||
但可能被 HTML 标签包裹,如 "</label> Tue, 23 Jun 2026 01:06:09 +0800"
|
||
"""
|
||
from email.utils import parsedate_to_datetime
|
||
|
||
# 先清理 HTML 标签,再提取时间
|
||
clean = re.sub(r'<[^>]+>', ' ', content)
|
||
clean = re.sub(r'\s+', ' ', clean)
|
||
|
||
# 匹配 RFC2822 格式时间:Day, DD Mon YYYY HH:MM:SS ±HHMM
|
||
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:
|
||
# 也尝试匹配不带星期的时间格式:2026-06-23 01:31:38
|
||
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
|
||
msg_time = datetime.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
|
||
# 找不到时间且无法解析,保守拒绝(避免使用旧验证码)
|
||
logger.debug("邮件中未找到可解析的发信时间,保守拒绝")
|
||
return False
|
||
|
||
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 False
|
||
|
||
def _extract_verification_code(self, text: str) -> Optional[str]:
|
||
"""从文本中提取6位验证码"""
|
||
# 清理HTML标签和实体
|
||
text = html.unescape(text)
|
||
text = re.sub(r'<[^>]+>', ' ', text)
|
||
text = re.sub(r'\s+', ' ', text)
|
||
|
||
# 查找6位数字验证码
|
||
patterns = [
|
||
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})',
|
||
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',
|
||
r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)',
|
||
r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})',
|
||
r'\b(\d{6})\b',
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
|
||
return None
|
||
|
||
|
||
def get_email_config_for_account(email_address: str) -> dict:
|
||
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
|
||
return {
|
||
'server': '',
|
||
'port': 143,
|
||
'ssl': False,
|
||
}
|