Download gemini_session.py from longxing/gmn2a: direct link, hf CLI and curl.
- Browser
- Download file 25.9 kB
-
https://huggingface.co/spaces/longxing/gmn2a/resolve/main/gemini_session.py
- Command line
-
hf download hf://spaces/longxing/gmn2a/gemini_session.py
-
curl -L -o gemini_session.py https://huggingface.co/spaces/longxing/gmn2a/resolve/main/gemini_session.py
25.9 kB
| """Cookie 驱动的 Gemini 会话生命周期管理。 | |
| 本模块把「基于 Cookie 初始化客户端」和「进程常驻期间持续自动轮换 | |
| ``__Secure-1PSIDTS``」这两件事收敛到一个地方,供 ``main.py`` 直接使用。 | |
| 为什么要单独写这一层,而不是直接 ``GeminiClient.init(auto_refresh=True)``: | |
| 1. **统一凭据来源。** ``SECURE_1PSID`` / ``SECURE_1PSIDTS`` 环境变量、上一次轮换 | |
| 落盘的文本缓存、gemini-webapi 自己的 JSON 缓存,三者需要按「谁更新用谁」的 | |
| 顺序收敛成一个候选序列,逐个尝试初始化。 | |
| 2. **补上缺失的自愈能力。** gemini-webapi 的后台轮换任务在账号状态不是 | |
| ``AVAILABLE`` 时会直接 ``break`` 并把 ``refresh_task`` 置空,活跃度看门狗同理; | |
| 之后再没有任何机制把它拉起来。对 7x24 常驻的 HF Space 来说,只要出现一次 | |
| 「配额耗尽 / 临时不可用」,Cookie 就再也不会被轮换,直到进程重启——这正是 | |
| 「进程一直在跑但 1PSIDTS 还是过期了」的根因。本模块用监督协程负责重新拉起。 | |
| 3. **可观测。** 轮换是否发生、最后一次换值距今多久、后台任务是否还活着,都能通过 | |
| ``status()`` 暴露给管理面板。 | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import time | |
| from pathlib import Path | |
| from typing import Any, Awaitable, Callable, Optional | |
| from gemini_webapi import GeminiClient | |
| from gemini_webapi.client import rotate_1psidts | |
| logger = logging.getLogger(__name__) | |
| PSID_COOKIE = "__Secure-1PSID" | |
| PSIDTS_COOKIE = "__Secure-1PSIDTS" | |
| _SAFE_PSID_PATTERN = re.compile(r"^[\w\-.]+$") | |
| CredentialProvider = Callable[[], tuple[str, str]] | |
| Validator = Callable[[GeminiClient, str], Awaitable[None]] | |
| def is_safe_psid(psid: str) -> bool: | |
| """判断 1PSID 能否安全地拼进缓存文件名。""" | |
| return bool(psid) and bool(_SAFE_PSID_PATTERN.match(psid)) | |
| def extract_cookie(cookies: Any, name: str) -> str: | |
| """从 curl_cffi / httpx 的 cookie 容器中读取指定 cookie 的值。 | |
| 直接遍历 ``cookies.jar``,这样对 httpx 那种「必须带 domain 才能 get 到」的 | |
| 容器同样有效。 | |
| """ | |
| if cookies is None: | |
| return "" | |
| jar = getattr(cookies, "jar", None) | |
| if jar is not None: | |
| try: | |
| for cookie in jar: | |
| if getattr(cookie, "name", None) == name: | |
| return getattr(cookie, "value", "") or "" | |
| except TypeError: | |
| pass | |
| try: | |
| return cookies.get(name) or "" | |
| except Exception: | |
| return "" | |
| def mask_secret(value: str, keep: int = 4) -> str: | |
| """脱敏显示,用于日志和管理面板。""" | |
| if not value: | |
| return "" | |
| if len(value) <= keep * 2: | |
| return "*" * len(value) | |
| return f"{value[:keep]}{'*' * 6}{value[-keep:]}" | |
| class CookieStore: | |
| """统一管理 ``__Secure-1PSIDTS`` 的落盘缓存。 | |
| 同时维护两份文件,读取时取「修改时间更新」的那一份: | |
| * ``.cached_1psidts_{psid}.txt`` —— 纯文本,只存最新值。便于人工查看和复制, | |
| 也是排查凭据问题最直观的来源。 | |
| * ``.cached_cookies_{psid}.json`` —— gemini-webapi 自带格式,``get_access_token`` | |
| 初始化时会优先读取它,所以必须让两边保持一致,否则会出现「缓存里是旧值、 | |
| 但初始化却用了它」的诡异情况。 | |
| """ | |
| def __init__(self, cache_dir: str | os.PathLike[str]): | |
| self.cache_dir = Path(cache_dir) | |
| def text_path(self, psid: str) -> Optional[Path]: | |
| if not is_safe_psid(psid): | |
| return None | |
| return self.cache_dir / f".cached_1psidts_{psid}.txt" | |
| def json_path(self, psid: str) -> Optional[Path]: | |
| if not psid: | |
| return None | |
| return self.cache_dir / f".cached_cookies_{psid}.json" | |
| def read_1psidts(self, psid: str) -> tuple[str, str]: | |
| """返回 ``(值, 来源)``,来源取值 ``text-cache`` / ``json-cache`` / ``""``。""" | |
| candidates: list[tuple[float, str, str]] = [] | |
| text_path = self.text_path(psid) | |
| if text_path is not None and text_path.is_file(): | |
| try: | |
| value = text_path.read_text(encoding="utf-8").strip() | |
| if value: | |
| candidates.append((text_path.stat().st_mtime, value, "text-cache")) | |
| except OSError as e: | |
| logger.warning("Failed to read 1PSIDTS text cache %s: %s", text_path, e) | |
| json_path = self.json_path(psid) | |
| if json_path is not None and json_path.is_file(): | |
| try: | |
| value = self._read_from_json(json_path) | |
| if value: | |
| candidates.append((json_path.stat().st_mtime, value, "json-cache")) | |
| except (OSError, ValueError) as e: | |
| logger.warning("Failed to read cookie cache %s: %s", json_path, e) | |
| if not candidates: | |
| return "", "" | |
| candidates.sort(key=lambda item: item[0], reverse=True) | |
| _, value, source = candidates[0] | |
| return value, source | |
| def write_1psidts(self, psid: str, value: str) -> bool: | |
| """把最新 1PSIDTS 写入文本缓存(原子替换)。返回是否真的写入了新值。""" | |
| if not value: | |
| return False | |
| path = self.text_path(psid) | |
| if path is None: | |
| logger.warning("Skip caching 1PSIDTS: __Secure-1PSID is not a safe filename token.") | |
| return False | |
| try: | |
| existing = path.read_text(encoding="utf-8").strip() if path.is_file() else "" | |
| except OSError: | |
| existing = "" | |
| if existing == value: | |
| return False | |
| try: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| tmp_path = path.parent / f"{path.name}.{os.getpid()}.tmp" | |
| tmp_path.write_text(value, encoding="utf-8") | |
| os.replace(tmp_path, path) | |
| try: | |
| os.chmod(path, 0o600) | |
| except OSError: | |
| pass | |
| return True | |
| except OSError as e: | |
| logger.warning("Failed to persist 1PSIDTS to %s: %s", path, e) | |
| return False | |
| def _read_from_json(path: Path) -> str: | |
| payload = json.loads(path.read_text(encoding="utf-8")) | |
| if isinstance(payload, dict): | |
| payload = payload.get("cookies", []) | |
| for cookie in payload or []: | |
| if isinstance(cookie, dict) and cookie.get("name") == PSIDTS_COOKIE: | |
| return cookie.get("value", "") or "" | |
| return "" | |
| class HfCookieBackup: | |
| """把 Cookie 缓存备份到 Hugging Face,用于跨重启续期(可选)。 | |
| 两种目标可以单独或同时开启: | |
| * ``dataset_repo``:上传/下载缓存文件到私有 Dataset。**不会**触发 Space 重启, | |
| 推荐用这种方式做持久化。 | |
| * ``space_repo``:回写 Space 的 Secret(默认 key 为 ``SECURE_1PSIDTS``)。HF 的 | |
| 规则是「任何 Space 配置变更都会重启 Space」,所以这条路径默认关闭,并且带 | |
| 最小间隔保护——它只在「进程已经要重启」这类场景下才有意义。 | |
| """ | |
| def __init__( | |
| self, | |
| cache_dir: str | os.PathLike[str], | |
| token: str = "", | |
| dataset_repo: str = "", | |
| space_repo: str = "", | |
| secret_key: str = PSIDTS_COOKIE, | |
| min_interval: float = 3600, | |
| ): | |
| self.store = CookieStore(cache_dir) | |
| self.token = token | |
| self.dataset_repo = dataset_repo | |
| self.space_repo = space_repo | |
| self.secret_key = secret_key | |
| self.min_interval = max(0.0, min_interval) | |
| self._last_push_at = 0.0 | |
| self._api: Any = None | |
| def enabled(self) -> bool: | |
| return bool(self.token and (self.dataset_repo or self.space_repo)) | |
| async def restore(self, psid: str) -> None: | |
| """本地没有缓存时,尝试从 Dataset 拉回上次备份。""" | |
| if not (self.token and self.dataset_repo): | |
| return | |
| text_path = self.store.text_path(psid) | |
| if text_path is not None and text_path.is_file(): | |
| return | |
| await asyncio.to_thread(self._restore_blocking, psid) | |
| async def push(self, psid: str, value: str, force: bool = False) -> dict: | |
| """备份当前 1PSIDTS,带最小间隔保护。""" | |
| if not self.enabled: | |
| return {"skipped": "backup not configured"} | |
| now = time.time() | |
| if not force and now - self._last_push_at < self.min_interval: | |
| return {"skipped": "rate limited"} | |
| self._last_push_at = now | |
| return await asyncio.to_thread(self._push_blocking, psid, value) | |
| def _restore_blocking(self, psid: str) -> None: | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| except ImportError: | |
| logger.warning("huggingface_hub is not installed; cookie backup is disabled.") | |
| return | |
| for path in (self.store.json_path(psid), self.store.text_path(psid)): | |
| if path is None or path.is_file(): | |
| continue | |
| try: | |
| downloaded = hf_hub_download( | |
| repo_id=self.dataset_repo, | |
| filename=path.name, | |
| repo_type="dataset", | |
| token=self.token, | |
| local_dir=str(self.store.cache_dir), | |
| ) | |
| logger.info("Restored cookie cache from HF dataset: %s", downloaded) | |
| except Exception as e: | |
| logger.debug("No remote cookie cache for %s: %s", path.name, e) | |
| def _push_blocking(self, psid: str, value: str) -> dict: | |
| try: | |
| from huggingface_hub import HfApi | |
| except ImportError: | |
| return {"skipped": "huggingface_hub is not installed"} | |
| result: dict = {} | |
| api = self._api or HfApi(token=self.token) | |
| self._api = api | |
| if self.dataset_repo: | |
| uploaded = [] | |
| for path in (self.store.text_path(psid), self.store.json_path(psid)): | |
| if path is None or not path.is_file(): | |
| continue | |
| try: | |
| api.upload_file( | |
| path_or_fileobj=str(path), | |
| path_in_repo=path.name, | |
| repo_id=self.dataset_repo, | |
| repo_type="dataset", | |
| ) | |
| uploaded.append(path.name) | |
| except Exception as e: | |
| logger.warning("Failed to upload %s to HF dataset: %s", path.name, e) | |
| result["dataset_uploaded"] = uploaded | |
| if self.space_repo: | |
| try: | |
| api.add_space_secret( | |
| repo_id=self.space_repo, | |
| key=self.secret_key, | |
| value=value, | |
| ) | |
| result["space_secret_updated"] = True | |
| logger.warning( | |
| "Wrote %s back to Space secret %s; note that HF restarts the Space on any config change.", | |
| self.secret_key, | |
| self.space_repo, | |
| ) | |
| except Exception as e: | |
| logger.warning("Failed to update Space secret: %s", e) | |
| result["space_secret_updated"] = False | |
| return result | |
| class GeminiSessionManager: | |
| """维护「基于 Cookie 初始化」+「后台持续轮换 1PSIDTS」的会话生命周期。 | |
| 与直接 ``GeminiClient.init(auto_refresh=True)`` 的区别在于自愈能力:监督协程会 | |
| 周期性地检查会话健康度,发现后台轮换任务停止就重新拉起,发现会话失效就重建。 | |
| """ | |
| def __init__( | |
| self, | |
| credential_provider: CredentialProvider, | |
| cache_dir: str | os.PathLike[str], | |
| validate: Optional[Validator] = None, | |
| backup: Optional[HfCookieBackup] = None, | |
| proxy: str = "", | |
| timeout: float = 300, | |
| watchdog_timeout: float = 120, | |
| refresh_interval: float = 600, | |
| health_interval: float = 120, | |
| rearm_cooldown: float = 300, | |
| init_retry_interval: float = 300, | |
| verbose: bool = False, | |
| ): | |
| self.credential_provider = credential_provider | |
| self.validate = validate | |
| self.backup = backup | |
| self.proxy = proxy | |
| self.timeout = max(30.0, float(timeout)) | |
| self.watchdog_timeout = max(30.0, float(watchdog_timeout)) | |
| self.refresh_interval = max(60.0, float(refresh_interval)) | |
| self.health_interval = max(30.0, float(health_interval)) | |
| self.rearm_cooldown = max(60.0, float(rearm_cooldown)) | |
| self.init_retry_interval = max(60.0, float(init_retry_interval)) | |
| self.verbose = verbose | |
| self.client: Optional[GeminiClient] = None | |
| self.lock = asyncio.Lock() | |
| self.store = CookieStore(cache_dir) | |
| self._supervisor_task: Optional[asyncio.Task] = None | |
| self._rotation_task: Optional[asyncio.Task] = None | |
| self._closing = False | |
| self._next_init_at = 0.0 | |
| self._rearm_until = 0.0 | |
| self._source = "" | |
| self._last_error = "" | |
| self._last_1psidts = "" | |
| self._last_psidts_change_at = 0.0 | |
| self._init_count = 0 | |
| self._recycle_count = 0 | |
| self._rearm_count = 0 | |
| self._rotation_count = 0 | |
| # ------------------------------------------------------------------ 生命周期 | |
| async def start(self) -> None: | |
| """启动后台监督协程(幂等)。""" | |
| if self._supervisor_task is not None and not self._supervisor_task.done(): | |
| return | |
| self._closing = False | |
| self._supervisor_task = asyncio.create_task(self._supervisor(), name="gemini-session-supervisor") | |
| logger.info( | |
| "Gemini session supervisor started (health check every %ss, rotation interval %ss)", | |
| int(self.health_interval), | |
| int(self.refresh_interval), | |
| ) | |
| async def stop(self) -> None: | |
| """停止监督协程并关闭客户端(关闭时会把最新 Cookie 落盘)。""" | |
| self._closing = True | |
| task, self._supervisor_task = self._supervisor_task, None | |
| if task is not None and not task.done(): | |
| task.cancel() | |
| try: | |
| await task | |
| except asyncio.CancelledError: | |
| pass | |
| rotation_task, self._rotation_task = self._rotation_task, None | |
| if rotation_task is not None and not rotation_task.done(): | |
| rotation_task.cancel() | |
| try: | |
| await rotation_task | |
| except asyncio.CancelledError: | |
| pass | |
| async with self.lock: | |
| await self._drop_client_locked("service shutdown") | |
| # ------------------------------------------------------------------ 对外接口 | |
| async def get_client(self) -> GeminiClient: | |
| """返回一个可用的客户端;只在传输层失效时按需重建。 | |
| ``gemini-webapi`` 的用户状态探针可能因为 Google 的辅助 RPC、出口限制或 | |
| 短暂响应异常把 ``account_status`` 标为 ``UNAUTHENTICATED``,但主生成流仍然 | |
| 可以工作。这里不能仅凭这个字段关闭正在服务请求的客户端;真正的认证失败由 | |
| 生成请求的异常路径触发重建。 | |
| """ | |
| client = self.client | |
| if client is not None: | |
| reason = self.session_transport_problem(client) | |
| if not reason: | |
| return client | |
| logger.warning("Gemini client transport is unusable (%s); rebuilding on demand", reason) | |
| async with self.lock: | |
| if self.client is not None: | |
| reason = self.session_transport_problem(self.client) | |
| if reason: | |
| await self._drop_client_locked(f"unusable transport: {reason}") | |
| if self.client is None: | |
| await self._initialize_locked() | |
| if self.client is None: | |
| raise RuntimeError(self._last_error or "Gemini client is unavailable") | |
| return self.client | |
| async def recover(self, reason: str = "manual recovery") -> GeminiClient: | |
| """强制丢弃当前会话并重建,供请求失败重试 / 管理面板使用。""" | |
| async with self.lock: | |
| await self._drop_client_locked(reason) | |
| await self._initialize_locked() | |
| if self.client is None: | |
| raise RuntimeError(self._last_error or "Gemini client is unavailable") | |
| return self.client | |
| def status(self) -> dict: | |
| """运行时状态快照,用于日志和管理面板展示。""" | |
| client = self.client | |
| psid, env_psidts = self.credential_provider() | |
| now = time.time() | |
| cached_psidts, cached_source = self.store.read_1psidts(psid) | |
| return { | |
| "initialized": client is not None, | |
| "supervisor_alive": bool(self._supervisor_task is not None and not self._supervisor_task.done()), | |
| "credential_source": self._source, | |
| "secure_1psid_masked": mask_secret(psid), | |
| "env_1psidts_masked": mask_secret(env_psidts), | |
| "live_1psidts_masked": mask_secret(self._last_1psidts), | |
| "cached_1psidts_masked": mask_secret(cached_psidts), | |
| "cached_1psidts_source": cached_source, | |
| "rotation_count": self._rotation_count, | |
| "last_1psidts_change_age_sec": int(now - self._last_psidts_change_at) if self._last_psidts_change_at else None, | |
| "rotation_task_alive": bool( | |
| client is not None and ( | |
| (client.refresh_task is not None and not client.refresh_task.done()) or | |
| (self._rotation_task is not None and not self._rotation_task.done()) | |
| ) | |
| ), | |
| "managed_rotation_task_alive": bool(self._rotation_task is not None and not self._rotation_task.done()), | |
| "auto_refresh": bool(getattr(client, "auto_refresh", False)), | |
| "refresh_interval": int(self.refresh_interval), | |
| "request_timeout": int(self.timeout), | |
| "watchdog_timeout": int(self.watchdog_timeout), | |
| "rearm_count": self._rearm_count, | |
| "recycle_count": self._recycle_count, | |
| "init_count": self._init_count, | |
| "account_status": getattr(getattr(client, "account_status", None), "name", "unknown"), | |
| "last_error": self._last_error, | |
| "cache_dir": str(self.store.cache_dir), | |
| "backup_enabled": bool(self.backup is not None and self.backup.enabled), | |
| } | |
| # ------------------------------------------------------------------ 内部实现 | |
| async def _initialize_locked(self) -> None: | |
| """按「缓存 -> 环境变量」的顺序逐个尝试初始化。调用方需持有 self.lock。""" | |
| psid, env_psidts = self.credential_provider() | |
| if not psid: | |
| self._last_error = "SECURE_1PSID is not configured" | |
| logger.error("%s", self._last_error) | |
| return | |
| cached_psidts, cached_source = self.store.read_1psidts(psid) | |
| if self.backup is not None and not cached_psidts: | |
| await self.backup.restore(psid) | |
| cached_psidts, cached_source = self.store.read_1psidts(psid) | |
| attempts: list[tuple[str, str]] = [] | |
| seen: set[str] = set() | |
| for source, psidts in ( | |
| (f"cache ({cached_source})" if cached_source else "cache", cached_psidts), | |
| ("environment", env_psidts), | |
| ): | |
| if not psidts or psidts in seen: | |
| continue | |
| seen.add(psidts) | |
| attempts.append((source, psidts)) | |
| if not attempts: | |
| self._last_error = "SECURE_1PSIDTS is missing and no rotated value has been cached yet" | |
| logger.error("%s", self._last_error) | |
| return | |
| self._init_count += 1 | |
| last_error: Optional[BaseException] = None | |
| for source, psidts in attempts: | |
| kwargs = {"proxy": self.proxy} if self.proxy else {} | |
| candidate = GeminiClient(psid, psidts, **kwargs) | |
| try: | |
| logger.info("Initializing Gemini client using %s credentials", source) | |
| await candidate.init( | |
| timeout=self.timeout, | |
| auto_refresh=True, | |
| refresh_interval=self.refresh_interval, | |
| auto_close=False, | |
| watchdog_timeout=self.watchdog_timeout, | |
| verbose=self.verbose, | |
| ) | |
| if self.validate is not None: | |
| await self.validate(candidate, source) | |
| except Exception as e: | |
| last_error = e | |
| logger.warning("Gemini session setup failed using %s 1PSIDTS: %s", source, e) | |
| try: | |
| await candidate.close() | |
| except Exception: | |
| pass | |
| continue | |
| self.client = candidate | |
| if self._rotation_task is not None and not self._rotation_task.done(): | |
| self._rotation_task.cancel() | |
| self._rotation_task = asyncio.create_task(self._rotation_supervisor(candidate), name="gemini-cookie-rotation-supervisor") | |
| self._source = source | |
| self._last_error = "" | |
| live_psidts = extract_cookie(candidate.cookies, PSIDTS_COOKIE) or psidts | |
| self._last_1psidts = live_psidts | |
| self._last_psidts_change_at = time.time() | |
| if self.store.write_1psidts(psid, live_psidts): | |
| logger.info("Seeded __Secure-1PSIDTS cache from %s credentials", source) | |
| logger.info( | |
| "Gemini client ready (source=%s, 1psidts=%s, auto_refresh=%s, interval=%ss)", | |
| source, | |
| mask_secret(live_psidts), | |
| candidate.auto_refresh, | |
| int(self.refresh_interval), | |
| ) | |
| return | |
| self._last_error = str(last_error) if last_error is not None else "initialization failed" | |
| logger.error("Failed to initialize Gemini client: %s", self._last_error) | |
| async def _drop_client_locked(self, reason: str) -> None: | |
| """关闭并丢弃当前客户端。调用方需持有 self.lock。""" | |
| client, self.client = self.client, None | |
| if client is None: | |
| return | |
| if self._rotation_task is not None and not self._rotation_task.done(): | |
| self._rotation_task.cancel() | |
| self._rotation_task = None | |
| self._recycle_count += 1 | |
| logger.warning("Dropping Gemini client (%s)", reason) | |
| # 关闭前先把当前 1PSIDTS 落盘;close() 内部也会保存 gemini-webapi 的 JSON 缓存 | |
| psid, _ = self.credential_provider() | |
| live_psidts = extract_cookie(getattr(client, "cookies", None), PSIDTS_COOKIE) | |
| if live_psidts: | |
| self._last_1psidts = live_psidts | |
| self.store.write_1psidts(psid, live_psidts) | |
| try: | |
| await client.close() | |
| except Exception as e: | |
| logger.warning("Failed to close Gemini client: %s", e) | |
| def session_transport_problem(client: GeminiClient) -> str: | |
| """判断客户端传输层是否已关闭,不采信易误报的账号状态探针。""" | |
| if client is None: | |
| return "client is missing" | |
| if not getattr(client, "_running", False): | |
| return "client is not running" | |
| if getattr(client, "client", None) is None: | |
| return "underlying HTTP session is closed" | |
| return "" | |
| def session_problem(cls, client: GeminiClient) -> str: | |
| """返回完整诊断;认证状态供异常恢复判断,不再触发后台主动关闭。""" | |
| reason = cls.session_transport_problem(client) | |
| if reason: | |
| return reason | |
| account_status = getattr(client, "account_status", None) | |
| if getattr(account_status, "name", "") == "UNAUTHENTICATED": | |
| return "account status is UNAUTHENTICATED" | |
| return "" | |
| async def _rotation_supervisor(self, client: GeminiClient) -> None: | |
| """轮换 Cookie,不依赖 gemini-webapi 的辅助账号状态探针。""" | |
| while not self._closing and self.client is client and getattr(client, "_running", False): | |
| try: | |
| await asyncio.sleep(max(60.0, self.refresh_interval)) | |
| except asyncio.CancelledError: | |
| raise | |
| if self._closing or self.client is not client or not getattr(client, "_running", False): | |
| return | |
| try: | |
| async with client._lock: | |
| rotated = await rotate_1psidts(client.client, getattr(client, "verbose", False)) | |
| if rotated: | |
| self._observe_rotation(client) | |
| logger.info("Managed Cookie rotation completed despite account-status probe=%s", getattr(getattr(client, "account_status", None), "name", "unknown")) | |
| except asyncio.CancelledError: | |
| raise | |
| except Exception as e: | |
| logger.warning("Managed Cookie rotation failed: %s", e) | |
| async def _supervisor(self) -> None: | |
| """常驻监督循环:保活 + 自愈。""" | |
| logger.info("Gemini session supervisor loop running") | |
| while not self._closing: | |
| try: | |
| await asyncio.sleep(self.health_interval) | |
| except asyncio.CancelledError: | |
| raise | |
| if self._closing: | |
| break | |
| try: | |
| await self._supervise_once() | |
| except asyncio.CancelledError: | |
| raise | |
| except Exception as e: | |
| logger.warning("Supervisor tick failed: %s", e, exc_info=True) | |
| async def _supervise_once(self) -> None: | |
| client = self.client | |
| if client is None: | |
| # 还没有可用会话:按退避策略尝试初始化,避免密集重试 | |
| now = time.time() | |
| if now >= self._next_init_at: | |
| try: | |
| await self.get_client() | |
| except Exception as e: | |
| self._last_error = str(e) | |
| self._next_init_at = now + self.init_retry_interval | |
| logger.warning("Supervisor could not (re)initialize Gemini client: %s", e) | |
| return | |
| reason = self.session_transport_problem(client) | |
| if reason: | |
| logger.warning("Gemini client transport became unusable (%s); rebuilding", reason) | |
| async with self.lock: | |
| await self._drop_client_locked(reason) | |
| try: | |
| await self.get_client() | |
| except Exception as e: | |
| self._last_error = str(e) | |
| self._next_init_at = time.time() + self.init_retry_interval | |
| logger.warning("Rebuild after session loss did not succeed: %s", e) | |
| return | |
| self._observe_rotation(client) | |
| await self._rearm_background_tasks(client) | |
| def _observe_rotation(self, client: GeminiClient) -> None: | |
| """读取活跃会话里的 ``__Secure-1PSIDTS``,发现变化就落盘并触发备份。 | |
| gemini-webapi 的 ``rotate_1psidts`` 会把轮换结果直接写回 ``client.cookies``, | |
| 所以这里读到的就是当前真实生效的值。 | |
| """ | |
| live_psidts = extract_cookie(getattr(client, "cookies", None), PSIDTS_COOKIE) | |
| if not live_psidts or live_psidts == self._last_1psidts: | |
| return | |
| previous = self._last_1psidts | |
| self._last_1psidts = live_psidts | |
| self._last_psidts_change_at = time.time() | |
| self._rotation_count += 1 | |
| logger.info( | |
| "__Secure-1PSIDTS rotated in the background (%s -> %s)", | |
| mask_secret(previous) or "(none)", | |
| mask_secret(live_psidts), | |
| ) | |
| psid, _ = self.credential_provider() | |
| if self.store.write_1psidts(psid, live_psidts): | |
| logger.info("Persisted the rotated __Secure-1PSIDTS to disk") | |
| if self.backup is not None and self.backup.enabled: | |
| asyncio.create_task(self._push_backup(psid, live_psidts)) | |
| async def _rearm_background_tasks(self, client: GeminiClient) -> None: | |
| """把 gemini-webapi 的后台轮换任务和活跃度看门狗重新拉起来。 | |
| ``start_auto_refresh`` 在账号状态不是 ``AVAILABLE`` 时会直接退出并把 | |
| ``refresh_task`` 置空,此后没有任何机制会再拉起它。``rearm_cooldown`` 用来 | |
| 避免状态持续异常时的忙循环——每轮最多重启一次。 | |
| """ | |
| if not getattr(client, "auto_refresh", False) or not getattr(client, "_running", False): | |
| return | |
| # gemini-webapi 2.1.x only starts these tasks when its auxiliary account | |
| # status RPC says AVAILABLE. That RPC can be false-negative while the main | |
| # generation endpoint still works, so the supervisor owns the re-arm decision. | |
| now = time.time() | |
| if now < self._rearm_until: | |
| return | |
| if client.refresh_task is None or client.refresh_task.done(): | |
| client.refresh_task = asyncio.create_task(client.start_auto_refresh()) | |
| self._rearm_until = now + self.rearm_cooldown | |
| self._rearm_count += 1 | |
| logger.info( | |
| "Re-armed the __Secure-1PSIDTS rotation task (account status: %s)", | |
| getattr(getattr(client, "account_status", None), "name", "unknown"), | |
| ) | |
| return | |
| if client.activity_task is None or client.activity_task.done(): | |
| client.activity_task = asyncio.create_task(client.start_activity_watchdog()) | |
| self._rearm_until = now + self.rearm_cooldown | |
| self._rearm_count += 1 | |
| logger.info("Re-armed the Gemini activity watchdog") | |
| async def _push_backup(self, psid: str, value: str) -> None: | |
| if self.backup is None: | |
| return | |
| try: | |
| result = await self.backup.push(psid, value) | |
| if result and not result.get("skipped"): | |
| logger.info("Cookie backup result: %s", result) | |
| except Exception as e: | |
| logger.warning("Cookie backup failed: %s", e) | |