diff --git a/core/douyu/cookie_enricher.py b/core/douyu/cookie_enricher.py new file mode 100644 index 0000000..76cfdce --- /dev/null +++ b/core/douyu/cookie_enricher.py @@ -0,0 +1,137 @@ +"""斗鱼 Web Cookie 补齐逻辑。""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +import requests +from loguru import logger + + +class CookieEnricher: + """补齐登录后 Web 侧需要的 Cookie。""" + + CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf" + CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298" + ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" + ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0" + TIMEOUT = (5, 10) + RETRIES = 3 + + def __init__( + self, + session: requests.Session, + request_func: Callable[..., requests.Response], + ensure_not_stopped: Callable[[], None], + sleep_interruptible: Callable[[float], None], + ): + self.session = session + self.request = request_func + self.ensure_not_stopped = ensure_not_stopped + self.sleep_interruptible = sleep_interruptible + + def enrich_with_retry(self, max_attempts: int = RETRIES) -> None: + """独立重试补齐 cvl_csrf_token 和 acf_ccn,不触发整条登录链路重跑。""" + last_error: Exception | None = None + for attempt in range(1, max_attempts + 1): + self.ensure_not_stopped() + try: + logger.info(f"补CK尝试 {attempt}/{max_attempts}...") + self.generate_csrf_cookie() + self.generate_acf_ccn_cookie() + logger.info("补CK完成") + return + except Exception as e: + last_error = e + logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") + if attempt < max_attempts: + self.sleep_interruptible(1) + raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error + + def generate_csrf_cookie(self) -> str: + """访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。""" + logger.info("补齐CSRF Cookie...") + headers = { + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5', + 'Origin': 'https://www.douyu.com', + 'Referer': self.CSRF_REFERER, + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-origin', + # 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。 + 'Content-Type': None, + 'X-Requested-With': None, + } + response = self.request( + 'post', + self.CSRF_API, + headers=headers, + timeout=self.TIMEOUT, + max_retries=1, + ) + response.raise_for_status() + + body = response.text.strip() + if not body: + raise ValueError("生成CSRF失败: 响应为空") + + try: + payload = response.json() + except json.JSONDecodeError as exc: + preview = body[:200].replace("\n", "\\n") + raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc + + if payload.get('error') != 0: + raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}") + + cookies = self.session.cookies.get_dict() + csrf_token = cookies.get('cvl_csrf_token', '') + if not csrf_token: + raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token") + + logger.info("CSRF Cookie已补齐") + return csrf_token + + def generate_acf_ccn_cookie(self) -> str: + """访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。""" + logger.info("补齐 acf_ccn Cookie...") + headers = { + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'Priority': 'u=1, i', + 'Referer': self.ACF_CCN_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', + # 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。 + 'Origin': None, + 'Content-Type': None, + 'X-Requested-With': None, + } + response = self.request( + 'get', + self.ACF_CCN_API, + headers=headers, + timeout=self.TIMEOUT, + max_retries=1, + ) + response.raise_for_status() + + cookies = self.session.cookies.get_dict() + acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '') + if acf_ccn and not cookies.get('acf_ccn'): + # 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。 + self.session.cookies.set('acf_ccn', acf_ccn, domain='.douyu.com', path='/') + + if not acf_ccn: + raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn") + + logger.info("acf_ccn Cookie已补齐") + return acf_ccn diff --git a/core/douyu/login.py b/core/douyu/login.py index f688b6b..1eae6d0 100644 --- a/core/douyu/login.py +++ b/core/douyu/login.py @@ -9,6 +9,7 @@ from typing import Mapping, Optional, Protocol, Tuple from urllib.parse import urlsplit, urlunsplit from loguru import logger +from .cookie_enricher import CookieEnricher from .crypto import encrypt_password, encrypt_nickname_or_phone from .email_verifier import EmailVerifier from .proxy import ProxyManager, get_proxy_manager @@ -54,12 +55,6 @@ class DouyuLogin: VERIFY_API = "https://passport.douyu.com/wgapi/member/passport/remotelogin/verify" LOGIN_CALLBACK_API = "https://www.douyu.com/api/passport/login" WEBLOGIN_API = "https://msg.douyu.com/webLogin" - CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf" - CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298" - ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" - ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0" - COOKIE_ENRICH_TIMEOUT = (5, 10) - COOKIE_ENRICH_RETRIES = 3 LOGIN_REFERER = ( "https://passport.douyu.com/index/login?" "passport_reg_callback=PASSPORT_REG_SUCCESS_CALLBACK&" @@ -739,7 +734,12 @@ class DouyuLogin: # 登录成功后补齐 Web 侧 Cookie。补 CK 失败只影响完整度,不回滚已成功的登录态。 self._cookie_enrich_error = "" try: - self._enrich_web_cookies_with_retry() + 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: @@ -748,115 +748,6 @@ class DouyuLogin: return self._format_cookie_string() - def _enrich_web_cookies_with_retry(self, max_attempts: int = COOKIE_ENRICH_RETRIES) -> None: - """独立重试补齐 cvl_csrf_token 和 acf_ccn,不触发整条登录链路重跑。""" - last_error: Exception | None = None - for attempt in range(1, max_attempts + 1): - self._ensure_not_stopped() - try: - logger.info(f"补CK尝试 {attempt}/{max_attempts}...") - self._generate_csrf_cookie() - self._generate_acf_ccn_cookie() - logger.info("补CK完成") - return - except Exception as e: - last_error = e - logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") - if attempt < max_attempts: - self._sleep_interruptible(1) - raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error - - def _generate_csrf_cookie(self) -> str: - """ - 访问斗鱼 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。 - - requests.Session 会自动接收响应中的 Set-Cookie,最终导出时会保留到 CK。 - """ - logger.info("补齐CSRF Cookie...") - headers = { - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5', - 'Origin': 'https://www.douyu.com', - 'Referer': self.CSRF_REFERER, - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-origin', - # 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。 - 'Content-Type': None, - 'X-Requested-With': None, - } - response = self._request( - 'post', - self.CSRF_API, - headers=headers, - timeout=self.COOKIE_ENRICH_TIMEOUT, - max_retries=1, - ) - response.raise_for_status() - - body = response.text.strip() - if not body: - raise ValueError("生成CSRF失败: 响应为空") - - try: - payload = response.json() - except json.JSONDecodeError as exc: - preview = body[:200].replace("\n", "\\n") - raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc - - if payload.get('error') != 0: - raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}") - - cookies = self.session.cookies.get_dict() - csrf_token = cookies.get('cvl_csrf_token', '') - if not csrf_token: - raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token") - - logger.info("CSRF Cookie已补齐") - return csrf_token - - def _generate_acf_ccn_cookie(self) -> str: - """访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。""" - logger.info("补齐 acf_ccn Cookie...") - headers = { - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache', - 'Priority': 'u=1, i', - 'Referer': self.ACF_CCN_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', - # 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。 - 'Origin': None, - 'Content-Type': None, - 'X-Requested-With': None, - } - response = self._request( - 'get', - self.ACF_CCN_API, - headers=headers, - timeout=self.COOKIE_ENRICH_TIMEOUT, - max_retries=1, - ) - response.raise_for_status() - - cookies = self.session.cookies.get_dict() - acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '') - if acf_ccn and not cookies.get('acf_ccn'): - # 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。 - self.session.cookies.set('acf_ccn', acf_ccn, domain='.douyu.com', path='/') - - if not acf_ccn: - raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn") - - logger.info("acf_ccn Cookie已补齐") - return acf_ccn - def _format_cookie_string(self) -> str: """把当前 session 中的 Cookie 格式化为可导出的 CK 字符串。""" cookies = self.session.cookies.get_dict() @@ -865,16 +756,3 @@ class DouyuLogin: cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()]) return cookie_str - - def save_cookie(self, cookie: str, filepath: str) -> None: - """保存Cookie到文件""" - Path(filepath).parent.mkdir(parents=True, exist_ok=True) - - with open(filepath, 'w', encoding='utf-8') as f: - json.dump({ - 'username': self.account.username, - 'cookie': cookie, - 'timestamp': int(time.time()), - }, f, ensure_ascii=False, indent=2) - - logger.info(f"Cookie已保存到: {filepath}")