diff --git a/core/douyu/email_verifier.py b/core/douyu/email_verifier.py index aa9f48d..f8dc312 100644 --- a/core/douyu/email_verifier.py +++ b/core/douyu/email_verifier.py @@ -14,6 +14,15 @@ import requests # Roundcube Webmail 地址。可通过环境变量覆盖。空值时回退到默认。 ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL") or "http://111.229.206.54:8000/" +EMAIL_BACKUP_PASSWORDS = [ + pwd.strip() + for pwd in os.getenv("MAIL_BACKUP_PASSWORDS", "aa778899").split(";") + if pwd.strip() +] + + +class EmailLoginError(RuntimeError): + """邮箱登录失败,通常是邮箱账号或密码错误。""" class EmailVerifier: @@ -31,6 +40,7 @@ class EmailVerifier: max_messages: int = 20, use_ssl: bool = False, roundcube_url: str = "", + backup_passwords: Optional[list[str]] = None, ): # 兼容旧参数名 self.imap_server = imap_server @@ -39,11 +49,13 @@ class EmailVerifier: self.password = password # 邮箱密码 self.timeout = timeout self.roundcube_url = roundcube_url or ROUNDCUBE_URL + self.backup_passwords = backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS # Roundcube 会话(懒初始化) self._rc_session: Optional[requests.Session] = None self._rc_token: Optional[str] = None self._rc_logged_in = False + self._rc_login_error = "" def connect(self) -> None: """兼容接口""" @@ -69,6 +81,39 @@ class EmailVerifier: if self._rc_logged_in and self._rc_session and self._rc_token: return True + self._rc_login_error = "" + credential_failed = False + passwords = self._candidate_passwords() + + for index, password in enumerate(passwords): + is_backup = index > 0 + ok, password_failed = self._try_roundcube_login(password, is_backup=is_backup) + if ok: + return True + credential_failed = credential_failed or password_failed + if password_failed and index == 0 and len(passwords) > 1: + logger.info("Roundcube: 主邮箱密码失败,尝试备用邮箱密码") + + if credential_failed: + self._rc_login_error = "邮箱登录失败" + logger.warning(f"Roundcube: {self._rc_login_error}") + + return False + + def _candidate_passwords(self) -> list[str]: + """返回去重后的邮箱密码候选列表:主密码优先,其次备用密码。""" + candidates = [self.password, *self.backup_passwords] + result = [] + seen = set() + for password in candidates: + if not password or password in seen: + continue + result.append(password) + seen.add(password) + return result + + def _try_roundcube_login(self, password: str, is_backup: bool = False) -> tuple[bool, bool]: + """尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。""" try: session = requests.Session() session.headers.update({ @@ -80,7 +125,7 @@ class EmailVerifier: token_match = re.search(r'name="_token"\s+value="([^"]+)"', resp.text) if not token_match: logger.warning("Roundcube: 未获取到登录 token") - return False + return False, False token = token_match.group(1) # 2. 登录 @@ -91,27 +136,41 @@ class EmailVerifier: "_task": "login", "_action": "login", "_user": self.username, - "_pass": self.password, + "_pass": password, }, timeout=self.timeout, allow_redirects=True, ) - # 3. 检查登录是否成功(成功会跳到 ?_task=mail) + # 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。 new_token_match = re.search(r'request_token["\s:]+["\']([^"\']+)', resp.text) if not new_token_match: logger.warning("Roundcube: 登录失败(未找到 request_token)") - return False + return False, False + + text_lower = resp.text.lower() + login_failed = "_err=loginfailed" in resp.url or "loginfailed" in text_lower + login_form_present = 'name="_user"' in resp.text and 'name="_pass"' in resp.text + mail_page = ( + "_task=mail" in resp.url + or re.search(r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text) + ) + + if login_failed or login_form_present or not mail_page: + password_name = "备用邮箱密码" if is_backup else "主邮箱密码" + logger.warning(f"Roundcube: {password_name}登录失败") + return False, True 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 + password_name = "备用邮箱密码" if is_backup else "主邮箱密码" + logger.debug(f"Roundcube: {password_name}登录成功 ({self.username})") + return True, False except Exception as e: logger.warning(f"Roundcube: 登录异常: {e}") - return False + return False, False # ── Roundcube 邮件列表(含精确日期) ──────────────────── @@ -121,6 +180,8 @@ class EmailVerifier: 返回 [{"uid": 2, "subject": "验证码 - 斗鱼", "date": "今天 09:02"}, ...] """ if not self._ensure_roundcube_session(): + if self._rc_login_error: + raise EmailLoginError(self._rc_login_error) return [] try: @@ -166,6 +227,8 @@ class EmailVerifier: def _rc_fetch_email_body(self, uid: int) -> Optional[str]: """通过 Roundcube 读取指定 UID 邮件的正文""" if not self._ensure_roundcube_session(): + if self._rc_login_error: + raise EmailLoginError(self._rc_login_error) return None try: @@ -288,6 +351,8 @@ class EmailVerifier: if code: logger.success(f"获取到验证码: {code}") return code + except EmailLoginError: + raise except Exception as e: last_error = str(e) logger.warning(f"Roundcube 读邮件异常: {e}") diff --git a/core/douyu/login.py b/core/douyu/login.py index eff5893..28c8280 100644 --- a/core/douyu/login.py +++ b/core/douyu/login.py @@ -11,7 +11,7 @@ from loguru import logger from .cookie_enricher import CookieEnricher from .crypto import encrypt_password, encrypt_nickname_or_phone -from .email_verifier import EmailVerifier +from .email_verifier import EmailLoginError, EmailVerifier from .proxy_fetcher import ProxyFetcher from core.geetest.v3_slide.solver import ( @@ -310,6 +310,9 @@ class DouyuLogin: except InterruptedError as e: logger.warning(f"登录任务已停止: {e}") return LoginResult(success=False, message=str(e)) + except EmailLoginError as e: + logger.error(f"登录失败(邮箱登录失败,不再重试): {e}") + return LoginResult(success=False, message=str(e), code="email_login_failed") except Exception as e: elapsed = time.monotonic() - start_time if self.max_login_retries > 0: