"""斗鱼登录核心模块""" import json import re import threading import time from collections.abc import Mapping from typing import Protocol from urllib.parse import urlsplit, urlunsplit import requests from loguru import logger from core.geetest.common.network import ( get_c_s, get_js_address, req_fullpage_validate, ) from core.geetest.v3_slide.solver import ( _generate_seed, get_w1, get_w2, ) from .cookie_enricher import CookieEnricher from .email_verifier import EmailLoginError, EmailVerifier from .login_api import LoginAPIStrategy from .login_api_wgapi import WgapiLoginAPI from .proxy_fetcher import ProxyFetcher # ── 全局极验并发限制:同一时刻最多2个线程做极验验证 ── _geetest_semaphore = threading.Semaphore(2) class CredentialError(ValueError): """账号或密码错误,不应重试。""" def __init__( self, message: str, code: str = "credential_error", status_message: str = "", ): super().__init__(message) self.code = code self.status_message = status_message or message class AccountLike(Protocol): """DouyuLogin 所需的最小账号接口,ORM 对象或 SimpleNamespace 均可满足。""" username: str password: str email: str email_password: str email_imap_server: str email_imap_port: int email_imap_ssl: bool class LoginResult: """登录结果""" def __init__( self, success: bool, cookie: str = "", message: str = "", code: str = "" ): self.success = success self.cookie = cookie self.message = message self.code = code # 需要验证时的code class DouyuLogin: """斗鱼登录器""" # 斗鱼API地址 LOGIN_API = "https://passport.douyu.com/wgapi/member/passport/login" SEND_EMAIL_API = ( "https://passport.douyu.com/wgapi/member/passport/remotelogin/sendemail" ) VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify" WEBLOGIN_API = "https://msg.douyu.com/webLogin" CP_RPC_API = "https://www.douyu.com/member/cp/cp_rpc_ajax" CP_REFERER = "https://www.douyu.com/member/cp" LOGIN_REFERER = ( "https://passport.douyu.com/index/login?" "passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&" "passport_login_callback=PASSPORT_LOGIN_SUCCESS_CALLBACK&" "passport_close_callback=PASSPORT_CLOSE_CALLBACK&" "passport_dp_callback=PASSPORT_DP_CALLBACK&" "type=login&client_id=1&" "state=https%3A%2F%2Fwww.douyu.com%2Fdirectory" ) REQUEST_TIMEOUT = (10, 30) MAX_STATIC_RETRY = 3 # 静态代理失败上限(无法切换代理时的最大重试次数) def __init__( self, account: AccountLike, proxy: str | Mapping[str, str] | None = None, proxy_api_url: str | None = None, timeout: tuple[float, float] = REQUEST_TIMEOUT, max_login_retries: int = 0, max_total_time: float = 0, proxy_fetcher: ProxyFetcher | None = None, stop_event: threading.Event | None = None, api_strategy: LoginAPIStrategy | None = None, ): self.account = account self.proxy = proxy self.timeout = timeout self.max_login_retries = max_login_retries # 0=无限重试直到成功 self.max_total_time = max_total_time # 0=不限制单账号登录总时长 self.stop_event = stop_event self.session = requests.Session() self.api = api_strategy or WgapiLoginAPI() # 默认使用 wgapi 接口 self.proxy_fetcher = proxy_fetcher or ( ProxyFetcher(api_url=proxy_api_url) if proxy_api_url else None ) self._current_proxy_url: str | None = None self._cookie_enrich_error = "" self._static_retry_count = 0 self._setup_session() def _is_stopped(self) -> bool: """判断外部批量任务是否请求停止。""" return bool(self.stop_event and self.stop_event.is_set()) def _ensure_not_stopped(self) -> None: """在长流程边界主动中断。""" if self._is_stopped(): raise InterruptedError("任务已停止") def _sleep_interruptible(self, seconds: float) -> None: """可被 stop_event 打断的短睡眠。""" if seconds <= 0: self._ensure_not_stopped() return deadline = time.monotonic() + seconds while True: self._ensure_not_stopped() remaining = deadline - time.monotonic() if remaining <= 0: return time.sleep(min(0.2, remaining)) def _setup_session(self) -> None: """配置Session""" # 只使用配置文件里显式传入的代理,避免系统环境变量悄悄影响请求。 self.session.trust_env = False self.session.headers.update( { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": self.LOGIN_REFERER, "Origin": "https://passport.douyu.com", "X-Requested-With": "XMLHttpRequest", } ) # 设置代理 self._apply_proxy() def _apply_proxy(self, proxy: str | None = None) -> None: """应用代理到Session""" if proxy: # 使用指定的代理 self.session.proxies = { "http": proxy, "https": proxy, } self._current_proxy_url = proxy elif self.proxy: # 使用配置的静态代理 if isinstance(self.proxy, str): self.session.proxies = { "http": self.proxy, "https": self.proxy, } self._current_proxy_url = self.proxy else: self.session.proxies = { scheme: url for scheme, url in self.proxy.items() if url } self._current_proxy_url = self.proxy.get("http") or self.proxy.get( "https" ) elif self.proxy_fetcher: # 从代理获取器取新代理 new_proxy = self.proxy_fetcher.fetch_new_proxy() if new_proxy: self.session.proxies = { "http": new_proxy, "https": new_proxy, } self._current_proxy_url = new_proxy else: logger.warning("获取代理失败,将尝试直连") @staticmethod def _truncate_error(err_str: str, max_len: int = 80) -> str: """截断错误信息,避免日志刷屏。""" if len(err_str) <= max_len: return err_str return err_str[:max_len] + "..." def _safe_url(self, url: str) -> str: """隐藏查询参数,避免日志泄露登录回调 code。""" parsed = urlsplit(url) return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) def _request(self, method: str, url: str, **kwargs) -> requests.Response: """ 统一发送请求,附带分段超时和更明确的错误信息。 代理失败/超时直接抛异常,由 login 整体重试换新代理(短效代理失效后重试同一个无意义)。 """ timeout = kwargs.pop("timeout", self.timeout) safe_url = self._safe_url(url) self._ensure_not_stopped() started = time.monotonic() try: response = self.session.request(method, url, timeout=timeout, **kwargs) elapsed = time.monotonic() - started logger.debug( f"{method.upper()} {safe_url} -> {response.status_code} " f"({elapsed:.2f}s)" ) return response except requests.Timeout as exc: elapsed = time.monotonic() - started raise TimeoutError( f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s," f"timeout={timeout}" ) from exc except requests.ConnectionError as exc: err_str = str(exc) raise ConnectionError( f"{method.upper()} {safe_url} 连接失败: {self._truncate_error(err_str)}" ) from exc except requests.RequestException as exc: elapsed = time.monotonic() - started raise ConnectionError( f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}" ) from exc def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict: """请求 JSON 接口,并在响应异常时输出可定位的信息。""" response = self._request(method, url, **kwargs) response.raise_for_status() body = response.text.strip() if not body: raise ValueError(f"{source} 返回为空,无法解析 JSON") try: return response.json() except json.JSONDecodeError as exc: preview = body[:200].replace("\n", "\\n") raise ValueError(f"{source} 返回的不是有效 JSON: {preview}") from exc @staticmethod def _classify_credential_payload(payload: dict) -> tuple[str, str] | None: """识别登录接口返回的账号类终态。""" error_code = payload.get("error") error_msg = str(payload.get("msg") or "") if error_code == 110022 or "账号不存在" in error_msg: return "account_cancelled", "账号已注销" password_keywords = ["密码错误", "账号或密码", "账号或者密码"] if error_code == 110018 or any(kw in error_msg for kw in password_keywords): return "password_wrong", "账号密码错误" return None def _credential_error_from_payload( self, stage: str, payload: dict ) -> CredentialError | None: """把账号类错误转换为不重试的 CredentialError。""" classified = self._classify_credential_payload(payload) if not classified: return None code, status_message = classified return CredentialError( f"{stage}失败: {status_message}", code=code, status_message=status_message, ) def _run_login_steps(self, deadline: float = 0) -> str: """执行一次完整登录链路,成功后返回 Cookie。""" # 1️⃣ 第一次登录(获取极验参数) logger.info("步骤1: 第一次登录,获取极验参数...") gt, challenge, code_token, _ = self._first_login() # 2️⃣ 极验 fullpage 验证 logger.info("步骤2: 极验 fullpage 验证...") validate, seccode = self._solve_geetest(gt, challenge, deadline=deadline) # 3️⃣ 第二次登录(带极验) logger.info("步骤3: 第二次登录(带极验验证)...") next_step, value = self._second_login( gt, challenge, validate, seccode, code_token, ) if next_step == "mobile_bind_skip": logger.info("步骤4: 跳过手机号绑定,完成登录...") return self._complete_login(value) # 4️⃣ 发送邮箱验证 logger.info("步骤4: 发送邮箱验证...") email_sent_at = time.time() self._send_email_verify(value) # 5️⃣ IMAP获取验证码 logger.info("步骤5: 获取邮箱验证码...") verify_code = self._get_email_code(after_timestamp=email_sent_at) # 6️⃣ 提交验证码 logger.info("步骤6: 提交验证码...") login_url = self._submit_verify_code(value, verify_code) # 7️⃣ 完成登录获取Cookie logger.info("步骤7: 完成登录,获取Cookie...") return self._complete_login(login_url) def login(self) -> LoginResult: """ 完整登录流程(带整体重试)。 每一轮用一个代理走完所有步骤,任何步骤失败就取新代理从头重来。 max_login_retries=0 表示无限重试,max_total_time=0 表示不限制总时长。 Returns: LoginResult: 登录结果,包含cookie """ logger.info(f"开始登录账号: {self.account.username}") start_time = time.monotonic() deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0 attempt = 0 while True: attempt += 1 if self._is_stopped(): logger.warning("登录任务已停止") return LoginResult(success=False, message="任务已停止") elapsed = time.monotonic() - start_time if self.max_total_time > 0 and elapsed > self.max_total_time: logger.warning( f"登录总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃" ) return LoginResult( success=False, message=f"登录超时({elapsed:.0f}s > {self.max_total_time}s)", ) if attempt > 1: if self.max_login_retries > 0: logger.info( f"登录整体重试 {attempt}/{self.max_login_retries},换新代理从头开始" ) else: logger.info(f"登录整体重试 {attempt} (无限重试),换新代理从头开始") # 重试前:取新代理 + 重置 session if not self._prepare_retry(): return LoginResult( success=False, message=f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理", ) try: cookie = self._run_login_steps(deadline=deadline) message = "登录成功" if self._cookie_enrich_error: message = f"登录成功,补CK失败: {self._cookie_enrich_error}" logger.success(f"登录成功! Cookie长度: {len(cookie)}") return LoginResult(success=True, cookie=cookie, message=message) except InterruptedError as e: logger.warning(f"登录任务已停止: {e}") return LoginResult(success=False, message=str(e)) except CredentialError as e: logger.error(f"登录失败(凭据错误,不再重试): {e}") return LoginResult(success=False, message=str(e), code=e.code) 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: logger.error( f"登录失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}" ) else: logger.error( f"登录失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}" ) has_retry = ( self.max_login_retries <= 0 or attempt < self.max_login_retries ) has_time = self.max_total_time <= 0 or elapsed < self.max_total_time if has_retry and has_time: self._sleep_interruptible(1) continue # 所有重试耗尽或超时 return LoginResult(success=False, message=str(e)) def check_account(self) -> LoginResult: """ 检测账号状态。 可直接识别注销/密码错误;登录成功后请求个人中心接口判断实名状态。 """ logger.info(f"开始检测账号: {self.account.username}") start_time = time.monotonic() deadline = start_time + self.max_total_time if self.max_total_time > 0 else 0 attempt = 0 while True: attempt += 1 if self._is_stopped(): logger.warning("账号检测任务已停止") return LoginResult(success=False, message="任务已停止") elapsed = time.monotonic() - start_time if self.max_total_time > 0 and elapsed > self.max_total_time: logger.warning( f"账号检测总耗时 {elapsed:.0f}s 超过上限 {self.max_total_time}s,放弃" ) return LoginResult( success=False, message=f"账号检测超时({elapsed:.0f}s > {self.max_total_time}s)", ) if attempt > 1: if self.max_login_retries > 0: logger.info( f"账号检测整体重试 {attempt}/{self.max_login_retries},换新代理从头开始" ) else: logger.info( f"账号检测整体重试 {attempt} (无限重试),换新代理从头开始" ) if not self._prepare_retry(): return LoginResult( success=False, message=f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理", ) try: self._run_login_steps(deadline=deadline) return self._check_certification_status() except InterruptedError as e: logger.warning(f"账号检测任务已停止: {e}") return LoginResult(success=False, message=str(e)) except CredentialError as e: logger.info(f"账号检测完成: {e.status_message}") return LoginResult(success=True, message=e.status_message, code=e.code) 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: logger.error( f"账号检测失败(尝试 {attempt}/{self.max_login_retries},已耗时 {elapsed:.0f}s): {e}" ) else: logger.error( f"账号检测失败(尝试 {attempt},已耗时 {elapsed:.0f}s): {e}" ) has_retry = ( self.max_login_retries <= 0 or attempt < self.max_login_retries ) has_time = self.max_total_time <= 0 or elapsed < self.max_total_time if has_retry and has_time: self._sleep_interruptible(1) continue return LoginResult(success=False, message=str(e)) def _check_certification_status(self) -> LoginResult: """请求个人中心接口并判断实名状态。""" headers = { "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6", "Cache-Control": "no-cache", "Pragma": "no-cache", "Priority": "u=1, i", "Referer": self.CP_REFERER, "Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', "Sec-CH-UA-Mobile": "?0", "Sec-CH-UA-Platform": '"macOS"', "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "X-Requested-With": "XMLHttpRequest", "Origin": None, "Content-Type": None, } payload = self._request_json( "get", self.CP_RPC_API, "账号认证状态接口", headers=headers, timeout=(5, 10), ) info = payload.get("info") or {} ident_status = str(info.get("ident_status", "")) ident_type = str(info.get("ident_type", "")) if ident_status == "0" and ident_type == "0": logger.success("账号检测完成: 账号未认证") return LoginResult( success=True, message="账号未认证", code="account_unverified" ) if ident_status == "2" and ident_type == "2": logger.success("账号检测完成: 账号已认证") return LoginResult( success=True, message="账号已认证", code="account_verified" ) message = f"账号认证状态未知: ident_status={ident_status or '-'}, ident_type={ident_type or '-'}" logger.warning(message) return LoginResult(success=True, message=message, code="account_auth_unknown") def _prepare_retry(self) -> bool: """重试前准备:取新代理、重置 session cookies。 Returns: True - 可以继续重试 False - 静态代理已连续失败 MAX_STATIC_RETRY 次,无法切换代理,应放弃 """ self.session.cookies.clear() if self.proxy_fetcher: new_proxy = self.proxy_fetcher.fetch_new_proxy() if new_proxy: self._apply_proxy(new_proxy) logger.info(f"重试换新代理: {new_proxy}") return True if self.proxy: self._static_retry_count += 1 if self._static_retry_count > self.MAX_STATIC_RETRY: logger.error( f"静态代理连续失败 {self.MAX_STATIC_RETRY} 次,无法切换代理" ) return False logger.warning( f"静态代理失败重试 {self._static_retry_count}/{self.MAX_STATIC_RETRY}" ) return True def _first_login(self) -> tuple[str, str, str, dict]: """ 第一次登录,获取极验参数 Returns: (gt, challenge, code_token, cookies) """ data = self.api.build_first_login_data( self.account.username, self.account.password, self.LOGIN_REFERER, ) payload = self._request_json( "post", self.api.first_login_url, "第一次登录接口", data=data, ) logger.debug(f"第一次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}") if payload.get("error") != 81: error_msg = payload.get("msg", "未知错误") credential_error = self._credential_error_from_payload( "第一次登录", payload ) if credential_error: raise credential_error raise ValueError(f"第一次登录失败: {error_msg}") # 提取极验参数 gt, challenge, code_token = self.api.extract_geetest_params(payload) if not gt or not challenge: raise ValueError("获取极验参数失败") logger.info(f"获取极验参数成功: gt={gt[:10]}..., challenge={challenge[:10]}...") return gt, challenge, code_token, self.session.cookies.get_dict() def _solve_geetest( self, gt: str, challenge: str, deadline: float = 0 ) -> tuple[str, str]: """ 解决极验 fullpage 验证(最多3次尝试,失败直接抛异常回到login换新代理) 不在内部做代理切换——短效代理寿命宝贵,换代理由 login 整体重试负责。 Args: gt: 极验gt参数 challenge: 极验challenge参数(第一次登录返回的) deadline: 登录整体超时截止时间(monotonic),0=不限 Returns: (validate, seccode) """ logger.info("开始极验 fullpage 验证...") # ── 全局并发限制:同一时刻最多2个线程做极验,避免被极验限流 ── acquired = _geetest_semaphore.acquire(timeout=120) if not acquired: raise ValueError("极验验证等待超时(并发排队120秒未获得信号量)") try: return self._solve_geetest_inner(gt, challenge, deadline) finally: _geetest_semaphore.release() def _solve_geetest_inner( self, gt: str, challenge: str, deadline: float = 0 ) -> tuple[str, str]: """极验验证内部实现:最多3次尝试,失败直接抛异常。""" _MAX_ATTEMPTS = 3 attempt = 0 while True: attempt += 1 self._ensure_not_stopped() if attempt > _MAX_ATTEMPTS: raise ValueError( f"极验验证已尝试 {attempt - 1} 次,超过上限 {_MAX_ATTEMPTS}," f"回到 login 整体重试换新代理" ) # 超时兜底 if deadline and time.monotonic() > deadline: raise ValueError("极验验证超时(登录整体时间耗尽)") try: logger.info(f"极验验证尝试 {attempt}/{_MAX_ATTEMPTS}") str_16 = _generate_seed() proxies = dict(self.session.proxies) # 获取JS地址 get_js_address(gt, proxies=proxies) # 获取第一个w值 w1 = get_w1(gt, challenge, str_16) # 获取c和s c, s = get_c_s(gt, challenge, w1, proxies=proxies) # 获取第二个w值 w2 = get_w2(gt, challenge, c, s, str_16) # HAR 中 ajax.php 直接返回 validate。 result = req_fullpage_validate(gt, challenge, w2, proxies=proxies) # 从 fullpage ajax.php 响应中提取 validate if isinstance(result, dict): data = result.get("data", {}) validate = data.get("validate", "") or result.get("validate", "") success = ( data.get("result") == "success" or result.get("success") == 1 ) message = data.get("result") or result.get("message", "") if success and validate: seccode = f"{validate}|jordan" logger.success( f"极验 fullpage 验证成功! validate={validate[:20]}..." ) return validate, seccode else: logger.warning(f"极验验证失败: {message},回到 login 换新代理") raise ValueError(f"极验验证失败: {message}") else: validate = str(result) if validate: seccode = f"{validate}|jordan" logger.success( f"极验 fullpage 验证成功! validate={validate[:20]}..." ) return validate, seccode except ValueError: # 极验逻辑失败(slide等)或超过上限,直接上抛 raise except Exception as e: err_str = str(e) # 所有网络/代理异常都直接上抛,由 login 换新代理 logger.warning( f"极验验证异常: {self._truncate_error(err_str)},回到 login 换新代理" ) raise ValueError( f"极验验证异常: {self._truncate_error(err_str)}" ) from e def _second_login( self, gt: str, challenge: str, validate: str, seccode: str, code_token: str ) -> tuple[str, str]: """ 第二次登录(带极验验证) Args: gt: 极验gt参数 challenge: 第一次登录返回的challenge validate: 极验验证返回的validate seccode: 极验验证返回的seccode code_token: 第一次登录返回的code_token Returns: (next_step, value): - ('remote_email', remote_code): 继续邮箱验证流程 - ('mobile_bind_skip', login_url): 已跳过手机号绑定,可完成登录 """ data = self.api.build_second_login_data( self.account.username, self.account.password, gt, challenge, validate, seccode, code_token, self.LOGIN_REFERER, ) logger.debug( f"第二次登录参数: challenge={challenge[:20]}..., validate={validate[:20]}..." ) payload = self._request_json( "post", self.api.login_url, "第二次登录接口", data=data, ) logger.debug(f"第二次登录响应: {json.dumps(payload, ensure_ascii=False)[:200]}") # 新版登录在账号未绑定手机号时会返回一次性 uniqueKey。浏览器点击 # “跳过”后会向同一接口提交 type=3 + uniqueKey,服务端才会发放回调 URL。 unique_key = self.api.extract_mobile_bind_unique_key(payload) if unique_key: logger.info("检测到需要绑定手机号,按网页登录流程跳过绑定") login_url = self._skip_mobile_bind(unique_key) return "mobile_bind_skip", login_url if payload.get("error") != 130014: error_msg = payload.get("msg", "未知错误") credential_error = self._credential_error_from_payload( "第二次登录", payload ) if credential_error: raise credential_error raise ValueError(f"第二次登录失败: {error_msg}") # 提取remote_code remote_code = self.api.extract_remote_code(payload) if not remote_code: # 提取可能的风控提示 data = payload.get("data", {}) security_quiz = data.get("securityQuiz", "") msg = payload.get("msg", "") detail = security_quiz or msg or "未知原因" raise ValueError(f"获取remote_code失败: {detail}") logger.info(f"获取remote_code成功: {remote_code[:20]}...") return "remote_email", remote_code def _skip_mobile_bind(self, unique_key: str) -> str: """跳过手机号绑定并获取后续登录回调地址。""" payload = self._request_json( "post", self.api.login_url, "跳过手机号绑定接口", data=self.api.build_skip_mobile_bind_data(unique_key), ) if payload.get("error") != 0: raise ValueError(f"跳过手机号绑定失败: {payload.get('msg', '未知错误')}") login_url = self.api.extract_login_url(payload) if not login_url: raise ValueError("跳过手机号绑定后未获取登录URL") login_url = self._normalize_login_url(login_url) logger.info("手机号绑定已跳过,获取登录URL成功") return login_url def _send_email_verify(self, remote_code: str) -> None: """发送邮箱验证""" data = self.api.build_send_email_data(remote_code) payload = self._request_json( "post", self.api.send_email_url, "发送验证邮件接口", data=data, ) if payload.get("error") != 0: raise ValueError(f"发送验证邮件失败: {payload.get('msg')}") logger.info("验证邮件已发送") def _get_email_code(self, after_timestamp: float | None = None) -> str: """获取邮箱验证码""" verifier = EmailVerifier( imap_server=self.account.email_imap_server, imap_port=self.account.email_imap_port, username=self.account.email, password=self.account.email_password, use_ssl=self.account.email_imap_ssl, ) return verifier.get_verification_code( max_wait=60, after_timestamp=after_timestamp, stop_event=self.stop_event, ) def _submit_verify_code(self, remote_code: str, verify_code: str) -> str: """ 提交验证码 Returns: login_url: 登录回调URL """ data = self.api.build_verify_data(remote_code, verify_code) payload = self._request_json( "post", self.api.verify_url, "提交验证码接口", data=data, ) if payload.get("error") != 0: raise ValueError(f"提交验证码失败: {payload.get('msg')}") login_url = self.api.extract_login_url(payload) if not login_url: raise ValueError("获取登录URL失败") login_url = self._normalize_login_url(login_url) logger.info(f"获取登录URL成功: {login_url[:50]}...") return login_url @staticmethod def _normalize_login_url(login_url: str) -> str: """补全斗鱼接口返回的协议相对登录回调地址。""" if login_url.startswith("//"): return "https:" + login_url return login_url def _complete_login(self, login_url: str) -> str: """ 完成登录,获取Cookie Returns: cookie: 完整的Cookie字符串 """ # 访问登录URL response = self._request("get", login_url) response.raise_for_status() # 尝试访问webLogin获取用户信息 try: code_match = re.search(r"code=([^&]+)", login_url) if code_match: code = code_match.group(1) weblogin_url = f"{self.WEBLOGIN_API}?code={code}" response2 = self._request("get", weblogin_url) if response2.status_code == 200: logger.info("WebLogin成功") except Exception as e: logger.warning(f"WebLogin请求失败(不影响登录): {e}") # 登录成功后补齐 Web 侧 Cookie。补 CK 失败只影响完整度,不回滚已成功的登录态。 self._cookie_enrich_error = "" try: CookieEnricher( session=self.session, request_func=self._request, ensure_not_stopped=self._ensure_not_stopped, sleep_interruptible=self._sleep_interruptible, ).enrich_with_retry() except InterruptedError: raise except Exception as e: self._cookie_enrich_error = self._truncate_error(str(e), 160) logger.warning( f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}" ) return self._format_cookie_string() def _format_cookie_string(self) -> str: """把当前 session 中的 Cookie 格式化为可导出的 CK 字符串。""" cookies = self.session.cookies.get_dict() # 格式化Cookie字符串 cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()]) return cookie_str