232 lines
7.7 KiB
Python
232 lines
7.7 KiB
Python
"""邮箱验证模块 - IMAP获取验证码"""
|
||
|
||
import imaplib
|
||
import email
|
||
import re
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from email.header import decode_header
|
||
from typing import Optional
|
||
from loguru import logger
|
||
|
||
|
||
class EmailVerifier:
|
||
"""邮箱验证器"""
|
||
|
||
def __init__(
|
||
self,
|
||
imap_server: str,
|
||
imap_port: int,
|
||
username: str,
|
||
password: str,
|
||
timeout: float = 12,
|
||
):
|
||
self.imap_server = imap_server
|
||
self.imap_port = imap_port
|
||
self.username = username
|
||
self.password = password
|
||
self.timeout = timeout
|
||
self._connection: Optional[imaplib.IMAP4_SSL] = None
|
||
|
||
def connect(self) -> None:
|
||
"""连接IMAP服务器"""
|
||
try:
|
||
logger.info(f"连接IMAP服务器: {self.imap_server}:{self.imap_port}")
|
||
self._connection = imaplib.IMAP4_SSL(
|
||
self.imap_server,
|
||
self.imap_port,
|
||
timeout=self.timeout,
|
||
)
|
||
self._connection.login(self.username, self.password)
|
||
logger.info("IMAP连接成功")
|
||
except Exception as e:
|
||
logger.error(f"IMAP连接失败: {e}")
|
||
raise
|
||
|
||
def disconnect(self) -> None:
|
||
"""断开IMAP连接"""
|
||
if self._connection:
|
||
try:
|
||
self._connection.logout()
|
||
except:
|
||
pass
|
||
self._connection = None
|
||
|
||
def get_verification_code(self, max_wait: int = 60, interval: int = 2) -> str:
|
||
"""
|
||
获取斗鱼验证码
|
||
|
||
Args:
|
||
max_wait: 最大等待时间(秒)
|
||
interval: 轮询间隔(秒)
|
||
|
||
Returns:
|
||
6位验证码
|
||
"""
|
||
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
|
||
|
||
self.connect()
|
||
|
||
try:
|
||
start_time = time.time()
|
||
while time.time() - start_time < max_wait:
|
||
code = self._fetch_latest_code()
|
||
if code:
|
||
logger.success(f"获取到验证码: {code}")
|
||
return code
|
||
|
||
logger.debug("未找到验证码,等待中...")
|
||
time.sleep(interval)
|
||
|
||
raise TimeoutError("等待验证码超时")
|
||
finally:
|
||
self.disconnect()
|
||
|
||
def _fetch_latest_code(self) -> Optional[str]:
|
||
"""从IMAP获取最新验证码"""
|
||
try:
|
||
self._connection.select('INBOX')
|
||
|
||
# 搜索最近5分钟的邮件
|
||
since = (datetime.now() - timedelta(minutes=5)).strftime("%d-%b-%Y")
|
||
status, messages = self._connection.search(
|
||
None,
|
||
f'(OR (FROM "douyu") (FROM "斗鱼") (SUBJECT "验证码")) SINCE {since}'
|
||
)
|
||
|
||
if status != 'OK' or not messages[0]:
|
||
return None
|
||
|
||
# 获取最新的一封邮件
|
||
latest_id = messages[0].split()[-1]
|
||
status, msg_data = self._connection.fetch(latest_id, '(RFC822)')
|
||
|
||
if status != 'OK':
|
||
return None
|
||
|
||
msg = email.message_from_bytes(msg_data[0][1])
|
||
|
||
# 检查是否是斗鱼的邮件
|
||
subject = self._decode_subject(msg.get('Subject', ''))
|
||
if not self._is_douyu_email(subject, msg.get('From', '')):
|
||
return None
|
||
|
||
# 提取验证码
|
||
body = self._get_email_body(msg)
|
||
code = self._extract_verification_code(body)
|
||
|
||
if code:
|
||
logger.info(f"从邮件中提取到验证码: {code}")
|
||
|
||
return code
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取邮件失败: {e}")
|
||
return None
|
||
|
||
def _is_douyu_email(self, subject: str, from_addr: str) -> bool:
|
||
"""判断是否是斗鱼的邮件"""
|
||
douyu_keywords = ['斗鱼', 'douyu', '验证码', '安全验证']
|
||
subject_lower = subject.lower()
|
||
from_lower = from_addr.lower()
|
||
|
||
return any(keyword in subject_lower or keyword in from_lower
|
||
for keyword in douyu_keywords)
|
||
|
||
def _decode_subject(self, subject: str) -> str:
|
||
"""解码邮件主题"""
|
||
if not subject:
|
||
return ""
|
||
|
||
decoded_parts = decode_header(subject)
|
||
result = []
|
||
for part, charset in decoded_parts:
|
||
if isinstance(part, bytes):
|
||
result.append(part.decode(charset or 'utf-8', errors='ignore'))
|
||
else:
|
||
result.append(part)
|
||
return ' '.join(result)
|
||
|
||
def _get_email_body(self, msg: email.message.Message) -> str:
|
||
"""获取邮件正文"""
|
||
body = ""
|
||
|
||
if msg.is_multipart():
|
||
for part in msg.walk():
|
||
content_type = part.get_content_type()
|
||
if content_type == 'text/plain' or content_type == 'text/html':
|
||
try:
|
||
payload = part.get_payload(decode=True)
|
||
charset = part.get_content_charset() or 'utf-8'
|
||
body += payload.decode(charset, errors='ignore')
|
||
except:
|
||
pass
|
||
else:
|
||
try:
|
||
payload = msg.get_payload(decode=True)
|
||
charset = msg.get_content_charset() or 'utf-8'
|
||
body = payload.decode(charset, errors='ignore')
|
||
except:
|
||
pass
|
||
|
||
return body
|
||
|
||
def _extract_verification_code(self, text: str) -> Optional[str]:
|
||
"""从文本中提取验证码"""
|
||
# 清理HTML标签
|
||
text = re.sub(r'<[^>]+>', ' ', text)
|
||
|
||
# 查找6位数字验证码
|
||
# 注意:中文冒号和英文冒号需要分别处理
|
||
patterns = [
|
||
r'验证码[::]\s*(\d{6})', # 验证码:123456 或 验证码:123456
|
||
r'验证码\s+(\d{6})', # 验证码 123456
|
||
r'(\d{6})\s*是您的验证码', # 123456是您的验证码
|
||
r'您的验证码[是为]\s*[::]\s*(\d{6})', # 您的验证码是:123456
|
||
r'您的验证码[是为]\s*(\d{6})', # 您的验证码是123456
|
||
r'verification code[::]\s*(\d{6})',
|
||
r'code[::]\s*(\d{6})',
|
||
r'(\d{6})', # 最后尝试匹配任何6位数字
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if match:
|
||
code = match.group(1)
|
||
# 验证码有效性检查(简单过滤明显不是验证码的数字)
|
||
if not self._is_likely_code(code):
|
||
continue
|
||
return code
|
||
|
||
return None
|
||
|
||
def _is_likely_code(self, code: str) -> bool:
|
||
"""判断是否是有效的验证码"""
|
||
# 过滤掉一些明显不是验证码的数字
|
||
invalid_patterns = [
|
||
r'^(\d)\1{5}$', # 全部相同:111111, 222222
|
||
r'^123456$', # 顺序数字
|
||
r'^654321$', # 逆序数字
|
||
]
|
||
|
||
for pattern in invalid_patterns:
|
||
if re.match(pattern, code):
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def get_email_config_for_account(email_address: str) -> dict:
|
||
"""根据邮箱地址自动配置IMAP服务器"""
|
||
configs = {
|
||
'qq.com': {'server': 'imap.qq.com', 'port': 993},
|
||
'163.com': {'server': 'imap.163.com', 'port': 993},
|
||
'126.com': {'server': 'imap.126.com', 'port': 993},
|
||
'gmail.com': {'server': 'imap.gmail.com', 'port': 993},
|
||
'outlook.com': {'server': 'outlook.office365.com', 'port': 993},
|
||
'hotmail.com': {'server': 'outlook.office365.com', 'port': 993},
|
||
}
|
||
|
||
domain = email_address.split('@')[-1].lower()
|
||
return configs.get(domain, {'server': f'imap.{domain}', 'port': 993})
|