From ea8a56ca66beec5149222c102a94de9780e90689 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 21 Jun 2026 23:02:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E5=8A=9F=E8=8E=B7=E5=8F=96=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=90=8E=E7=9A=84=20ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/cookies/用户9006787894.json | 5 + douyu/email_verifier.py | 261 ++++++++++++++++++++++--------- douyu/login.py | 10 +- test_email_fetch.py | 202 ++++++++++++++++++++++++ 4 files changed, 397 insertions(+), 81 deletions(-) create mode 100644 data/cookies/用户9006787894.json create mode 100644 test_email_fetch.py diff --git a/data/cookies/用户9006787894.json b/data/cookies/用户9006787894.json new file mode 100644 index 0000000..729b2a5 --- /dev/null +++ b/data/cookies/用户9006787894.json @@ -0,0 +1,5 @@ +{ + "username": "用户9006787894", + "cookie": "dy_accounts_main=; dy_auth=e105BJzhxVWQ0tJKUXiiqN8ky11wgfj7ClW%2F8qo%2B1MPv8%2BDGHBq8VnuaJ1MWnm4EZqgVfLkM6PETWtGpLAHvadcMxCn9HWvTfT8%2BSKZR2dmsvReubJmeqBo; wan_auth37wan=4dc825c71d00LrMvodf%2B7ZrhnyMcsREgwQlgd7Ec06k5%2Bm%2FM0tdv0AJJ8gpoNoinSoPHS3vbJO8iK1vqiXfF2hTOLQqJOW3Wzs19DWLwpJpQgV8J%2BF0; LTP0=eyJhbGciOiJtZDUiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOlsicGFzc3BvcnQiXSwiY3QiOjAsImN0aW1lIjoxNzgyMDU0MDA0LCJleHAiOjE3OTc4MjIwMTYsImtleSI6ImR5LWp3dC1tZDUiLCJsdGsiOiJhZDMxOTliZGRhYmM3ZWRmIiwibHRraWQiOjgwNTYxMzgyLCJzdWIiOiJsdCIsInVpZCI6OTg2Mzg1Mjk2fQ.Y2M0YTQ4YjQwZGYwZWI4YWZiZTllNmVjODNiZmJkMGE; last_login_way=nickname; PHPSESSID=ccr09aa2letju5662gdh5aih62; acf_auth=1a34kGp6rHjazzRy4fVzKXX11qCjC9RuQASDGOEfkRbLjsI3A5PzSCXawo5hZvO4g47LF9ruapNiMpf%2BE3u4TplWPMkW4c4eI0pyG05a%2F9dKr%2BOGM0hAEXk; acf_jwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk4NjM4NTI5NiwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImR5Iiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo4MDU2MTM4Miwic3RrIjoiNjFhODczZjUwZTg3M2Y5MCIsImV4cCI6MTc4MjY1ODgwNCwiaWF0IjoxNzgyMDU0MDA0LCJrZXkiOiJkeS1qd3QtbWQ1In0.OTFlMjIxOGU4MjZkMjcwMWM4NjQ1NTcwNjNmZWIzMDA; acf_dmjwt_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJtZDUifQ.eyJ1aWQiOjk4NjM4NTI5NiwiY3QiOjAsInN1YiI6InN0IiwiYXVkIjpbImRtIiwibm9uZSJdLCJiaXoiOjEsImx0a2lkIjo4MDU2MTM4Miwic3RrIjoiNjFhODczZjUwZTg3M2Y5MCIsImV4cCI6MTc4MjY1ODgwNCwiaWF0IjoxNzgyMDU0MDA0LCJrZXkiOiJkeS1qd3QtbWQ1In0.N2E0NmEyOWVlZDE1NTE4NDg0MTFlMmM5OGY1ZGQzZTk; acf_uid=986385296; acf_username=986385296; acf_nickname=%E7%94%A8%E6%88%B79006787894; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F25_; acf_ct=0; acf_ltkid=80561382; acf_biz=1; acf_stk=61a873f50e873f90; acf_devid=d53498d383f449f4c4ea28c4165aa2b4", + "timestamp": 1782054016 +} \ No newline at end of file diff --git a/douyu/email_verifier.py b/douyu/email_verifier.py index da84277..8db645b 100644 --- a/douyu/email_verifier.py +++ b/douyu/email_verifier.py @@ -2,10 +2,12 @@ import imaplib import email +import html import re import time from datetime import datetime, timedelta from email.header import decode_header +from email.utils import parsedate_to_datetime from typing import Optional from loguru import logger @@ -20,12 +22,18 @@ class EmailVerifier: username: str, password: str, timeout: float = 12, + mailbox: str = "INBOX", + lookback_minutes: int = 10, + max_messages: int = 20, ): self.imap_server = imap_server self.imap_port = imap_port self.username = username self.password = password self.timeout = timeout + self.mailbox = mailbox + self.lookback_minutes = lookback_minutes + self.max_messages = max_messages self._connection: Optional[imaplib.IMAP4_SSL] = None def connect(self) -> None: @@ -52,13 +60,21 @@ class EmailVerifier: pass self._connection = None - def get_verification_code(self, max_wait: int = 60, interval: int = 2) -> str: + def get_verification_code( + self, + max_wait: int = 60, + interval: int = 2, + after_timestamp: Optional[float] = None, + allow_old_seconds: int = 15, + ) -> str: """ 获取斗鱼验证码 Args: max_wait: 最大等待时间(秒) interval: 轮询间隔(秒) + after_timestamp: 发起发送验证码请求的时间戳,用于过滤旧邮件 + allow_old_seconds: 邮件服务器时间允许向前偏移的秒数 Returns: 6位验证码 @@ -68,9 +84,12 @@ class EmailVerifier: self.connect() try: - start_time = time.time() - while time.time() - start_time < max_wait: - code = self._fetch_latest_code() + deadline = time.monotonic() + max_wait + while time.monotonic() < deadline: + code = self._fetch_latest_code( + after_timestamp=after_timestamp, + allow_old_seconds=allow_old_seconds, + ) if code: logger.success(f"获取到验证码: {code}") return code @@ -82,56 +101,151 @@ class EmailVerifier: finally: self.disconnect() - def _fetch_latest_code(self) -> Optional[str]: + def _fetch_latest_code( + self, + after_timestamp: Optional[float] = None, + allow_old_seconds: int = 15, + ) -> Optional[str]: """从IMAP获取最新验证码""" try: - self._connection.select('INBOX') + if not self._connection: + raise RuntimeError("IMAP未连接") - # 搜索最近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}' - ) + status, _ = self._connection.select(self.mailbox, readonly=True) + if status != 'OK': + logger.warning(f"选择邮箱目录失败: {self.mailbox}") + return None + + since = self._build_since_date(after_timestamp, allow_old_seconds) + # IMAP命令只能稳定发送ASCII条件,中文主题/发件人改到本地解析过滤。 + status, messages = self._connection.search(None, '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)') + message_ids = messages[0].split() + recent_ids = list(reversed(message_ids[-self.max_messages:])) + logger.debug(f"扫描最近 {len(recent_ids)} 封邮件,SINCE {since}") - if status != 'OK': - return None + for message_id in recent_ids: + msg = self._fetch_message(message_id) + if not msg: + continue - msg = email.message_from_bytes(msg_data[0][1]) + if not self._is_recent_enough(msg, after_timestamp, allow_old_seconds): + continue - # 检查是否是斗鱼的邮件 - subject = self._decode_subject(msg.get('Subject', '')) - if not self._is_douyu_email(subject, msg.get('From', '')): - return None + subject = self._decode_subject(msg.get('Subject', '')) + from_addr = msg.get('From', '') + body = self._get_email_body(msg) - # 提取验证码 - body = self._get_email_body(msg) - code = self._extract_verification_code(body) + if not self._is_douyu_email(subject, from_addr, body): + continue - if code: - logger.info(f"从邮件中提取到验证码: {code}") + code = self._extract_verification_code(body) + if code: + logger.info(f"从邮件中提取到验证码,主题: {subject}") + return code - return code + return None + except imaplib.IMAP4.abort as e: + logger.warning(f"IMAP连接中断,准备下轮重连: {e}") + self.disconnect() + self.connect() + return None 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() + def _build_since_date( + self, + after_timestamp: Optional[float], + allow_old_seconds: int, + ) -> str: + """构造IMAP SINCE日期,月份固定用英文缩写。""" + if after_timestamp: + since_dt = datetime.fromtimestamp( + max(0, after_timestamp - allow_old_seconds) + ) + else: + since_dt = datetime.now() - timedelta(minutes=self.lookback_minutes) - return any(keyword in subject_lower or keyword in from_lower - for keyword in douyu_keywords) + return self._format_imap_date(since_dt) + + def _format_imap_date(self, value: datetime) -> str: + """格式化IMAP日期,避免系统locale影响月份名称。""" + months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + return f"{value.day:02d}-{months[value.month - 1]}-{value.year}" + + def _fetch_message(self, message_id: bytes) -> Optional[email.message.Message]: + """获取邮件完整内容,使用PEEK避免标记已读。""" + status, msg_data = self._connection.fetch(message_id, '(BODY.PEEK[])') + if status != 'OK': + return None + + raw_message = self._join_fetch_payload(msg_data) + if not raw_message: + return None + + return email.message_from_bytes(raw_message) + + def _join_fetch_payload(self, msg_data) -> bytes: + """合并IMAP fetch返回中的邮件字节内容。""" + chunks = [] + for item in msg_data: + if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes): + chunks.append(item[1]) + elif isinstance(item, bytes) and item.startswith(b'From '): + chunks.append(item) + return b''.join(chunks) + + def _is_recent_enough( + self, + msg: email.message.Message, + after_timestamp: Optional[float], + allow_old_seconds: int, + ) -> bool: + """检查邮件时间是否晚于本次发送验证码请求。""" + if not after_timestamp: + return True + + message_time = self._parse_message_time(msg) + if not message_time: + logger.debug("邮件缺少Date头,保守纳入候选") + return True + + if message_time.timestamp() < after_timestamp - allow_old_seconds: + subject = self._decode_subject(msg.get('Subject', '')) + logger.debug(f"跳过旧邮件: {subject}") + return False + + return True + + def _parse_message_time(self, msg: email.message.Message) -> Optional[datetime]: + """解析邮件Date头。""" + date_header = msg.get('Date') + if not date_header: + return None + + try: + return parsedate_to_datetime(date_header) + except (TypeError, ValueError): + return None + + def _is_douyu_email(self, subject: str, from_addr: str, body: str = "") -> bool: + """判断是否是斗鱼的邮件""" + douyu_keywords = ['斗鱼', 'douyu', 'douyutv'] + verify_keywords = ['验证码', '安全验证', '登录验证', '动态码', '校验码'] + combined = f"{subject}\n{from_addr}\n{body[:500]}".lower() + + return ( + any(keyword in combined for keyword in douyu_keywords) + or any(keyword in subject for keyword in verify_keywords) + ) def _decode_subject(self, subject: str) -> str: """解码邮件主题""" @@ -149,72 +263,63 @@ class EmailVerifier: def _get_email_body(self, msg: email.message.Message) -> str: """获取邮件正文""" - body = "" + parts = [] 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 + if part.get_content_disposition() == 'attachment': + continue - return body + if content_type == 'text/plain' or content_type == 'text/html': + decoded = self._decode_part_payload(part) + if decoded: + parts.append(decoded) + else: + decoded = self._decode_part_payload(msg) + if decoded: + parts.append(decoded) + + return "\n".join(parts) + + def _decode_part_payload(self, part: email.message.Message) -> str: + """解码邮件片段内容。""" + try: + payload = part.get_payload(decode=True) + if payload is None: + raw_payload = part.get_payload() + return raw_payload if isinstance(raw_payload, str) else "" + + charset = part.get_content_charset() or 'utf-8' + return payload.decode(charset, errors='ignore') + except Exception: + return "" def _extract_verification_code(self, text: str) -> Optional[str]: """从文本中提取验证码""" - # 清理HTML标签 + # 清理HTML标签和实体,方便匹配中文邮件模板。 + text = html.unescape(text) text = re.sub(r'<[^>]+>', ' ', text) + text = re.sub(r'\s+', ' ', 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位数字 + 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', # 候选邮件已经过滤为斗鱼验证码邮件,最后再兜底匹配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服务器""" diff --git a/douyu/login.py b/douyu/login.py index e42c18b..20acc0c 100644 --- a/douyu/login.py +++ b/douyu/login.py @@ -222,11 +222,12 @@ class DouyuLogin: # 4️⃣ 发送邮箱验证 logger.info("步骤4: 发送邮箱验证...") + email_sent_at = time.time() self._send_email_verify(remote_code) # 5️⃣ IMAP获取验证码 logger.info("步骤5: 获取邮箱验证码...") - verify_code = self._get_email_code() + verify_code = self._get_email_code(after_timestamp=email_sent_at) # 6️⃣ 提交验证码 logger.info("步骤6: 提交验证码...") @@ -455,7 +456,7 @@ class DouyuLogin: logger.info("验证邮件已发送") - def _get_email_code(self) -> str: + def _get_email_code(self, after_timestamp: Optional[float] = None) -> str: """获取邮箱验证码""" verifier = EmailVerifier( imap_server=self.account.email_imap_server, @@ -464,7 +465,10 @@ class DouyuLogin: password=self.account.email_password, ) - return verifier.get_verification_code(max_wait=60) + return verifier.get_verification_code( + max_wait=60, + after_timestamp=after_timestamp, + ) def _submit_verify_code(self, remote_code: str, verify_code: str) -> str: """ diff --git a/test_email_fetch.py b/test_email_fetch.py new file mode 100644 index 0000000..11781d6 --- /dev/null +++ b/test_email_fetch.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""邮箱IMAP读取测试脚本""" + +import argparse +import re +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +# 添加项目根目录到path +sys.path.insert(0, str(Path(__file__).parent)) + +from douyu.config import Config +from douyu.email_verifier import EmailVerifier +from utils.logger import setup_logger + + +def mask_email(email_address: str) -> str: + """隐藏邮箱中间部分,避免控制台泄露完整账号。""" + if "@" not in email_address: + return email_address + + name, domain = email_address.split("@", 1) + if len(name) <= 2: + masked_name = name[0] + "*" + else: + masked_name = name[:2] + "*" * max(2, len(name) - 4) + name[-2:] + + return f"{masked_name}@{domain}" + + +def clean_preview(text: str, max_length: int = 120) -> str: + """清理正文预览,保持输出紧凑。""" + preview = " ".join(text.split()) + preview = re.sub(r"\b\d{6}\b", "******", preview) + if len(preview) <= max_length: + return preview + return preview[:max_length] + "..." + + +def format_code(code: str, show_code: bool) -> str: + """格式化验证码输出,默认隐藏大部分数字。""" + if not code: + return "未提取到" + if show_code: + return code + return f"****{code[-2:]}" + + +def format_message_time(verifier: EmailVerifier, msg) -> str: + """格式化邮件时间。""" + message_time = verifier._parse_message_time(msg) + if not message_time: + return "未知" + return message_time.strftime("%Y-%m-%d %H:%M:%S %z") + + +def load_account(config_path: str, index: int): + """从配置文件加载指定账号。""" + config = Config(config_path) + accounts = config.get_accounts() + if not accounts: + raise ValueError("config.yaml 中没有配置账号") + + if index < 0 or index >= len(accounts): + raise IndexError(f"账号索引超出范围: {index},当前共有 {len(accounts)} 个账号") + + return accounts[index] + + +def list_recent_messages( + verifier: EmailVerifier, + lookback_minutes: int, + limit: int, + show_code: bool, +) -> None: + """列出最近邮件,并测试斗鱼验证码解析。""" + verifier.connect() + + try: + status, _ = verifier._connection.select(verifier.mailbox, readonly=True) + if status != "OK": + raise RuntimeError(f"选择邮箱目录失败: {verifier.mailbox}") + + since_dt = datetime.now() - timedelta(minutes=lookback_minutes) + since = verifier._format_imap_date(since_dt) + status, messages = verifier._connection.search(None, "SINCE", since) + if status != "OK": + raise RuntimeError(f"搜索邮件失败: status={status}") + + message_ids = messages[0].split() if messages and messages[0] else [] + if not message_ids: + print(f"最近 {lookback_minutes} 分钟没有邮件") + return + + recent_ids = list(reversed(message_ids[-limit:])) + print(f"搜索范围: 最近 {lookback_minutes} 分钟") + print(f"匹配邮件数: {len(message_ids)},展示最新 {len(recent_ids)} 封\n") + + for index, message_id in enumerate(recent_ids, start=1): + msg = verifier._fetch_message(message_id) + if not msg: + print(f"[{index}] 读取失败: id={message_id.decode(errors='ignore')}") + continue + + subject = verifier._decode_subject(msg.get("Subject", "")) + from_addr = msg.get("From", "") + body = verifier._get_email_body(msg) + is_douyu = verifier._is_douyu_email(subject, from_addr, body) + code = verifier._extract_verification_code(body) if is_douyu else "" + + print(f"[{index}] 邮件ID: {message_id.decode(errors='ignore')}") + print(f" 时间: {format_message_time(verifier, msg)}") + print(f" 发件人: {from_addr}") + print(f" 主题: {subject}") + print(f" 斗鱼邮件: {'是' if is_douyu else '否'}") + print(f" 验证码: {format_code(code, show_code)}") + print(f" 正文预览: {clean_preview(body)}\n") + finally: + verifier.disconnect() + + +def wait_verification_code( + account, + max_wait: int, + interval: int, + new_only: bool, + show_code: bool, +) -> None: + """等待并提取斗鱼验证码,不负责发送验证码邮件。""" + verifier = EmailVerifier( + imap_server=account.email_imap_server, + imap_port=account.email_imap_port, + username=account.email, + password=account.email_password, + ) + + after_timestamp = time.time() if new_only else None + code = verifier.get_verification_code( + max_wait=max_wait, + interval=interval, + after_timestamp=after_timestamp, + ) + print(f"等待验证码结果: {format_code(code, show_code)}") + + +def parse_args() -> argparse.Namespace: + """解析命令行参数。""" + parser = argparse.ArgumentParser(description="测试邮箱IMAP是否能读取邮件和验证码") + parser.add_argument("-c", "--config", default="config.yaml", help="配置文件路径") + parser.add_argument("-i", "--index", type=int, default=0, help="账号索引") + parser.add_argument("--lookback-minutes", type=int, default=30, help="读取最近多少分钟邮件") + parser.add_argument("--limit", type=int, default=5, help="展示最近多少封邮件") + parser.add_argument("--wait-code", action="store_true", help="额外等待并提取斗鱼验证码") + parser.add_argument("--new-only", action="store_true", help="等待验证码时只接受脚本启动后的新邮件") + parser.add_argument("--show-code", action="store_true", help="输出完整验证码") + parser.add_argument("--max-wait", type=int, default=60, help="等待验证码最大秒数") + parser.add_argument("--interval", type=int, default=2, help="验证码轮询间隔秒数") + parser.add_argument("--log-level", default="INFO", help="日志级别") + return parser.parse_args() + + +def main() -> None: + """脚本入口。""" + args = parse_args() + setup_logger(level=args.log_level) + + account = load_account(args.config, args.index) + print("=== 邮箱IMAP读取测试 ===") + print(f"账号索引: {args.index}") + print(f"邮箱账号: {mask_email(account.email)}") + print(f"IMAP服务器: {account.email_imap_server}:{account.email_imap_port}\n") + + verifier = EmailVerifier( + imap_server=account.email_imap_server, + imap_port=account.email_imap_port, + username=account.email, + password=account.email_password, + lookback_minutes=args.lookback_minutes, + max_messages=args.limit, + ) + list_recent_messages( + verifier=verifier, + lookback_minutes=args.lookback_minutes, + limit=args.limit, + show_code=args.show_code, + ) + + if args.wait_code: + print("=== 等待斗鱼验证码 ===") + wait_verification_code( + account=account, + max_wait=args.max_wait, + interval=args.interval, + new_only=args.new_only, + show_code=args.show_code, + ) + + +if __name__ == "__main__": + main()