diff --git a/core/douyu/email_verifier.py b/core/douyu/email_verifier.py index e7fd5e8..454b7a3 100644 --- a/core/douyu/email_verifier.py +++ b/core/douyu/email_verifier.py @@ -1,19 +1,24 @@ -"""邮箱验证模块 - 通过 HTTP 接口获取验证码(替代 IMAP)""" +"""邮箱验证模块 - 优先通过 Roundcube Webmail API 获取验证码,read.php 作为备用""" import html import re import time +from datetime import datetime, timedelta from typing import Optional + from loguru import logger import requests -# HTTP 读邮件接口地址 -MAIL_API_URL = "http://111.229.206.54:8000/read.php" +# 旧接口(时间解析不可靠,作为备用) +READ_PHP_URL = "http://111.229.206.54:8000/read.php" + +# Roundcube Webmail 地址 +ROUNDCUBE_URL = "http://111.229.206.54:8000/" class EmailVerifier: - """邮箱验证器(HTTP 方式)""" + """邮箱验证器(Roundcube API 优先,read.php 备用)""" def __init__( self, @@ -26,24 +31,229 @@ class EmailVerifier: lookback_minutes: int = 10, max_messages: int = 20, use_ssl: bool = False, - mail_api_url: str = MAIL_API_URL, + mail_api_url: str = READ_PHP_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 + self.read_php_url = mail_api_url or READ_PHP_URL + self.roundcube_url = ROUNDCUBE_URL + + # Roundcube 会话(懒初始化) + self._rc_session: Optional[requests.Session] = None + self._rc_token: Optional[str] = None + self._rc_logged_in = False def connect(self) -> None: - """兼容接口:HTTP 模式下无需持久连接""" + """兼容接口""" pass def disconnect(self) -> None: - """兼容接口""" - pass + """关闭 Roundcube 会话""" + if self._rc_session: + try: + self._rc_session.get( + f"{self.roundcube_url}?_task=logout", + timeout=5, + ) + except Exception: + pass + self._rc_session = None + self._rc_logged_in = False + + # ── Roundcube 会话管理 ────────────────────────────────── + + def _ensure_roundcube_session(self) -> bool: + """确保已登录 Roundcube,返回是否成功""" + if self._rc_logged_in and self._rc_session and self._rc_token: + return True + + try: + session = requests.Session() + session.headers.update({ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + }) + + # 1. 访问首页获取 token + resp = session.get(self.roundcube_url, timeout=self.timeout) + token_match = re.search(r'name="_token"\s+value="([^"]+)"', resp.text) + if not token_match: + logger.warning("Roundcube: 未获取到登录 token") + return False + token = token_match.group(1) + + # 2. 登录 + resp = session.post( + f"{self.roundcube_url}?_task=login", + data={ + "_token": token, + "_task": "login", + "_action": "login", + "_user": self.username, + "_pass": self.password, + }, + timeout=self.timeout, + allow_redirects=True, + ) + + # 3. 检查登录是否成功(成功会跳到 ?_task=mail) + new_token_match = re.search(r'request_token["\s:]+["\']([^"\']+)', resp.text) + if not new_token_match: + logger.warning("Roundcube: 登录失败(未找到 request_token)") + return False + + self._rc_session = session + self._rc_token = new_token_match.group(1) + self._rc_logged_in = True + logger.debug(f"Roundcube: 登录成功 ({self.username})") + return True + + except Exception as e: + logger.warning(f"Roundcube: 登录异常: {e}") + return False + + # ── Roundcube 邮件列表(含精确日期) ──────────────────── + + def _rc_fetch_mail_list(self) -> list[dict]: + """ + 通过 Roundcube JSON API 获取 INBOX 邮件列表。 + 返回 [{"uid": 2, "subject": "验证码 - 斗鱼", "date": "今天 09:02"}, ...] + """ + if not self._ensure_roundcube_session(): + return [] + + try: + resp = self._rc_session.get( + f"{self.roundcube_url}?_task=mail&_action=list" + f"&_mbox=INBOX&_remote=1&_unlock=1", + headers={"X-Requested-With": "XMLHttpRequest"}, + timeout=self.timeout, + ) + # Roundcube 返回的 JSON 可能带 BOM,需用 utf-8-sig 解码 + text = resp.content.decode("utf-8-sig") + data = __import__("json").loads(text) + + # 从 exec 字段中解析 add_message_row 调用 + exec_text = data.get("exec", "") + messages = [] + + # 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...); + for m in re.finditer( + r'add_message_row\((\d+),\s*(\{[^}]+\})', + exec_text, + ): + uid = int(m.group(1)) + props_str = m.group(2) + # 提取各字段 + subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str) + date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str) + + messages.append({ + "uid": uid, + "subject": subject_m.group(1) if subject_m else "", + "date": date_m.group(1) if date_m else "", + }) + + return messages + + except Exception as e: + logger.warning(f"Roundcube: 获取邮件列表失败: {e}") + return [] + + # ── Roundcube 读取邮件内容 ───────────────────────────── + + def _rc_fetch_email_body(self, uid: int) -> Optional[str]: + """通过 Roundcube 读取指定 UID 邮件的正文""" + if not self._ensure_roundcube_session(): + return None + + try: + resp = self._rc_session.get( + f"{self.roundcube_url}?_task=mail&_action=show" + f"&_mbox=INBOX&_uid={uid}", + timeout=self.timeout, + ) + return resp.text + + except Exception as e: + logger.warning(f"Roundcube: 读取邮件 {uid} 失败: {e}") + return None + + # ── Roundcube 日期解析 ───────────────────────────────── + + @staticmethod + def _parse_rc_date(date_str: str) -> Optional[datetime]: + """ + 解析 Roundcube 返回的日期字符串为 datetime。 + + 格式示例: + - "今天 09:02" → 今天的 09:02 + - "昨天 23:29" → 昨天的 23:29 + - "星期一 23:29" → 本周星期一的 23:29 + - "2026-06-20" → 直接解析 + - "06-20" → 今年的该日期 + """ + now = datetime.now() + + # 今天 + if date_str.startswith("今天"): + time_str = date_str.replace("今天", "").strip() + try: + h, m = time_str.split(":") + return now.replace(hour=int(h), minute=int(m), second=0, microsecond=0) + except (ValueError, AttributeError): + return None + + # 昨天 + if date_str.startswith("昨天"): + time_str = date_str.replace("昨天", "").strip() + try: + h, m = time_str.split(":") + dt = now - timedelta(days=1) + return dt.replace(hour=int(h), minute=int(m), second=0, microsecond=0) + except (ValueError, AttributeError): + return None + + # 星期X + weekday_map = { + "星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3, + "星期五": 4, "星期六": 5, "星期日": 6, "星期天": 6, + } + for prefix, wd in weekday_map.items(): + if date_str.startswith(prefix): + time_str = date_str.replace(prefix, "").strip() + try: + h, m = time_str.split(":") + # 计算本周对应日期 + days_ago = (now.weekday() - wd) % 7 + if days_ago == 0: + # 同一天但可能是上一周 + target = now + else: + target = now - timedelta(days=days_ago) + return target.replace(hour=int(h), minute=int(m), second=0, microsecond=0) + except (ValueError, AttributeError): + return None + + # 日期格式 2026-06-20 + try: + return datetime.strptime(date_str.strip(), "%Y-%m-%d") + except ValueError: + pass + + # 日期格式 06-20 + try: + dt = datetime.strptime(date_str.strip(), "%m-%d") + return dt.replace(year=now.year) + except ValueError: + pass + + return None + + # ── 主入口 ───────────────────────────────────────────── def get_verification_code( self, @@ -53,7 +263,7 @@ class EmailVerifier: allow_old_seconds: int = 15, ) -> str: """ - 轮询 HTTP 读邮件接口获取斗鱼验证码。 + 轮询获取斗鱼验证码。优先使用 Roundcube API,失败则回退到 read.php。 Args: max_wait: 最大等待时间(秒) @@ -68,30 +278,83 @@ class EmailVerifier: deadline = time.monotonic() + max_wait last_error = "" + tried_roundcube = False while time.monotonic() < deadline: + # 优先尝试 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_http(after_timestamp, allow_old_seconds) + code = self._fetch_code_via_readphp(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}") + logger.warning(f"read.php 读邮件异常: {e}") + + logger.debug("未找到验证码,等待中...") time.sleep(interval) raise TimeoutError(f"等待验证码超时{'(' + last_error + ')' if last_error else ''}") - def _fetch_code_via_http( + # ── Roundcube 方式获取验证码 ──────────────────────────── + + def _fetch_code_via_roundcube( self, after_timestamp: Optional[float] = None, allow_old_seconds: int = 15, ) -> Optional[str]: - """通过 HTTP 接口读取最新邮件并提取验证码""" + """通过 Roundcube API 获取最新斗鱼验证码""" + messages = self._rc_fetch_mail_list() + + # 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看) + douyu_msgs = [ + msg for msg in messages + if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "") + ] + + for msg in reversed(douyu_msgs): # UID 最大的先看 + # 检查时间 + if after_timestamp and msg.get("date"): + msg_dt = self._parse_rc_date(msg["date"]) + if msg_dt and msg_dt.timestamp() < after_timestamp - allow_old_seconds: + logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过") + continue + + # 读取邮件正文提取验证码 + body_html = self._rc_fetch_email_body(msg["uid"]) + if not body_html: + continue + + code = self._extract_verification_code(body_html) + if code: + logger.info(f"Roundcube: 从邮件 UID={msg['uid']} 提取到验证码: {code}") + return code + + 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.mail_api_url, + self.read_php_url, params={ "yhm": self.username, "mm": self.password, @@ -103,26 +366,28 @@ class EmailVerifier: # 检查是否是斗鱼验证码邮件 if not self._is_douyu_email(content): - logger.debug("最新邮件不是斗鱼验证码邮件") + 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("邮件时间早于验证码发送时间,跳过") + logger.debug("read.php: 邮件时间早于验证码发送时间,跳过") return None # 提取验证码 code = self._extract_verification_code(content) if code: - logger.info(f"从邮件中提取到验证码: {code}") + logger.info(f"read.php: 从邮件中提取到验证码: {code}") return code return None except requests.RequestException as e: - logger.warning(f"HTTP读邮件请求失败: {e}") + logger.warning(f"read.php: 请求失败: {e}") return None + # ── 通用工具方法 ─────────────────────────────────────── + def _is_douyu_email(self, content: str) -> bool: """判断邮件内容是否是斗鱼验证码邮件""" douyu_keywords = ['斗鱼', 'douyu', 'douyutv'] @@ -141,10 +406,8 @@ class EmailVerifier: allow_old_seconds: int = 15, ) -> bool: """ - 检查邮件时间是否晚于验证码发送时间。 - - HTTP 接口返回的 HTML 中包含 "发信时间: Tue, 23 Jun 2026 01:06:09 +0800" - 但可能被 HTML 标签包裹,如 " Tue, 23 Jun 2026 01:06:09 +0800" + 检查 read.php 返回的邮件时间是否晚于验证码发送时间。 + 注意:read.php 的时间解析不可靠,此处仅作参考判断。 """ from email.utils import parsedate_to_datetime @@ -152,7 +415,7 @@ class EmailVerifier: clean = re.sub(r'<[^>]+>', ' ', content) clean = re.sub(r'\s+', ' ', clean) - # 匹配 RFC2822 格式时间:Day, DD Mon YYYY HH:MM:SS ±HHMM + # 匹配 RFC2822 格式时间 time_match = re.search( r'发信时间[::]?\s*' r'((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*\d{1,2}\s+' @@ -161,7 +424,7 @@ class EmailVerifier: 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, @@ -169,17 +432,18 @@ class EmailVerifier: 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') + 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 - # 找不到时间且无法解析,保守拒绝(避免使用旧验证码) - logger.debug("邮件中未找到可解析的发信时间,保守拒绝") - return False + # read.php 时间不可靠时,不再保守拒绝,改为放行 + # 让验证码提取逻辑自行判断 + logger.debug("read.php: 未找到可解析的发信时间,跳过时间检查直接提取验证码") + return True time_str = time_match.group(1).strip() try: @@ -189,8 +453,8 @@ class EmailVerifier: return False return True except (TypeError, ValueError): - logger.debug(f"无法解析邮件时间: {time_str},保守拒绝") - return False + logger.debug(f"无法解析邮件时间: {time_str},跳过时间检查") + return True def _extract_verification_code(self, text: str) -> Optional[str]: """从文本中提取6位验证码""" @@ -199,7 +463,7 @@ class EmailVerifier: text = re.sub(r'<[^>]+>', ' ', text) text = re.sub(r'\s+', ' ', text) - # 查找6位数字验证码 + # 查找6位数字验证码(优先匹配带上下文的) patterns = [ r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})', r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',