"""斗鱼 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