├── compose.yml ├── .dockerignore ├── .gitignore ├── requirements.txt ├── utils ├── paths.py ├── common.py ├── logger.py ├── url_helper.py ├── cookie_handler.py └── cookie_manager.py ├── Dockerfile ├── .env.example ├── browser ├── cookie_validator.py ├── navigation.py └── instance.py ├── README.md └── main.py /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | aistudio-websocket-app: 3 | image: aistudio-websocket 4 | build: . 5 | pull_policy: never 6 | container_name: aistudio-websocket-app 7 | env_file: 8 | - .env 9 | volumes: 10 | - ./cookies:/app/cookies:rw 11 | - ./logs:/app/logs:rw 12 | restart: on-failure 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git repository files 2 | .git 3 | .gitignore 4 | 5 | # Docker files 6 | Dockerfile 7 | compose.yml 8 | .dockerignore 9 | 10 | # Environment configuration files 11 | # These are injected via Docker Compose, not baked into the image. 12 | .env 13 | .env.example 14 | 15 | # Mounted directories 16 | # These are managed as volumes and should not be part of the image. 17 | cookies/ 18 | logs/ 19 | 20 | # Python cache files 21 | __pycache__/ 22 | *.pyc 23 | *.pyo 24 | *.pyd 25 | 26 | # IDE and editor configuration 27 | .vscode/ 28 | .idea/ 29 | 30 | # Claude guidance file 31 | CLAUDE.md 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | 7 | # Environment variables 8 | # Do not commit .env files containing sensitive credentials. 9 | # .env.example is a template and should be committed. 10 | .env 11 | 12 | # Runtime data 13 | # These directories are generated at runtime and may contain sensitive information. 14 | logs/ 15 | cookies/ 16 | 17 | # IDE / Editor specific files 18 | .vscode/ 19 | .idea/ 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # OS-generated files 27 | .DS_Store 28 | Thumbs.db 29 | 30 | # AI files 31 | .claude/ 32 | .serena/ 33 | CLAUDE.md 34 | AGENTS.md 35 | GEMINI.md 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | camoufox[geoip]==0.4.11 2 | aiohappyeyeballs==2.6.1 3 | aiohttp==3.12.13 4 | aiosignal==1.3.2 5 | attrs==25.3.0 6 | browserforge==1.2.3 7 | certifi==2025.6.15 8 | charset-normalizer==3.4.2 9 | click==8.2.1 10 | frozenlist==1.7.0 11 | flask==3.0.0 12 | geoip2==5.1.0 13 | greenlet==3.2.3 14 | idna==3.10 15 | language-tags==1.2.0 16 | lxml==5.4.0 17 | maxminddb==2.7.0 18 | multidict==6.5.0 19 | numpy==2.3.0 20 | orjson==3.10.18 21 | platformdirs==4.3.8 22 | playwright==1.52.0 23 | propcache==0.3.2 24 | pyee==13.0.0 25 | PySocks==1.7.1 26 | requests==2.32.4 27 | screeninfo==0.8.1 28 | tqdm==4.67.1 29 | typing_extensions==4.14.0 30 | ua-parser==1.0.1 31 | ua-parser-builtins==0.18.0.post1 32 | urllib3==2.4.0 33 | yarl==1.20.1 -------------------------------------------------------------------------------- /utils/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import lru_cache 3 | from pathlib import Path 4 | 5 | 6 | @lru_cache(maxsize=1) 7 | def project_root() -> Path: 8 | """ 9 | 返回代码仓库根目录,使调用者能够构建不依赖当前工作目录的绝对路径。 10 | """ 11 | env_root = os.getenv("CAMOUFOX_PROJECT_ROOT") 12 | if env_root: 13 | return Path(env_root).expanduser().resolve() 14 | 15 | current = Path(__file__).resolve() 16 | for parent in current.parents: 17 | if (parent / "cookies").exists(): 18 | return parent 19 | 20 | # 如果标记目录缺失,则回退到原始行为 21 | return current.parents[min(2, len(current.parents) - 1)] 22 | 23 | 24 | def logs_dir() -> Path: 25 | """存储日志文件和截图的根级目录。""" 26 | return project_root() / "logs" 27 | 28 | 29 | def cookies_dir() -> Path: 30 | """存储持久化Cookie JSON文件的根级目录。""" 31 | return project_root() / "cookies" 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用一个轻量的 Python 官方镜像作为基础 2 | FROM python:3.11-slim-bookworm 3 | 4 | # 设置工作目录,后续的命令都在这个目录下执行 5 | WORKDIR /app 6 | 7 | # 安装运行 Playwright 所需的最小系统依赖集 8 | # 在同一层中清理 apt 缓存以减小镜像体积 9 | RUN apt-get update && apt-get install -y --no-install-recommends \ 10 | libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 \ 11 | libnspr4 libnss3 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 \ 12 | libxext6 libxfixes3 libxrandr2 libxrender1 libxtst6 ca-certificates \ 13 | fonts-liberation libasound2 libpangocairo-1.0-0 libpango-1.0-0 libu2f-udev xvfb \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # 拷贝并安装 Python 依赖 17 | COPY requirements.txt . 18 | RUN pip install --no-cache-dir -r requirements.txt 19 | 20 | # 下载 camoufox 21 | RUN camoufox fetch 22 | 23 | # 将项目中的所有文件拷贝到工作目录 24 | COPY . . 25 | 26 | # 暴露 Hugging Face Spaces 期望的端口(仅在服务器模式下使用) 27 | EXPOSE 7860 28 | 29 | 30 | # 设置容器启动时要执行的命令 31 | CMD ["python", "main.py"] -------------------------------------------------------------------------------- /utils/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | 通用工具函数 3 | 提供项目中常用的基础功能 4 | """ 5 | 6 | import os 7 | from pathlib import Path 8 | 9 | def clean_env_value(value): 10 | """ 11 | 清理环境变量值,去除首尾空白字符 12 | 13 | Args: 14 | value: 环境变量的原始值 15 | 16 | Returns: 17 | str or None: 清理后的值,如果为空或None则返回None 18 | """ 19 | if value is None: 20 | return None 21 | stripped = value.strip() 22 | return stripped or None 23 | 24 | 25 | def parse_headless_mode(headless_setting): 26 | """ 27 | 解析headless模式配置 28 | 29 | Args: 30 | headless_setting: headless配置值 31 | 32 | Returns: 33 | bool or str: True表示headless,False表示有界面,'virtual'表示虚拟模式 34 | """ 35 | if str(headless_setting).lower() == 'true': 36 | return True 37 | elif str(headless_setting).lower() == 'false': 38 | return False 39 | else: 40 | return 'virtual' 41 | 42 | 43 | def ensure_dir(path): 44 | """ 45 | 确保目录存在,如果不存在则创建 46 | 47 | Args: 48 | path: 目录路径(可以是字符串或Path对象) 49 | """ 50 | if isinstance(path, str): 51 | path = Path(path) 52 | os.makedirs(path, exist_ok=True) -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # (必需) 所有浏览器实例将要访问的统一目标 URL 2 | CAMOUFOX_INSTANCE_URL="https://aistudio.google.com/apps/drive/1CPW7FpWGsDZzkaYgYOyXQ_6FWgxieLmL" 3 | 4 | # (可选) 浏览器运行模式。默认为 "virtual"。 5 | # 可选值: 6 | # - "true": 完全无头模式,没有图形界面。 7 | # - "false": 正常模式,会显示浏览器窗口。 8 | # - "virtual": 虚拟显示模式,在无头环境下模拟一个显示器,兼容性最好。 9 | # CAMOUFOX_HEADLESS="virtual" 10 | 11 | # (可选) 设置代理服务器。如果不需要代理,请将此行注释掉或留空。 12 | # 格式: http://:@: 或 http://: 13 | # CAMOUFOX_PROXY="http://proxy.example.com:8080" 14 | 15 | # (可选) 每个浏览器实例启动之间的延迟时间(秒),默认为 30 秒。 16 | # 增加此值可以降低启动多个实例时的 CPU 峰值负载。 17 | # INSTANCE_START_DELAY=30 18 | 19 | # (可选) KeepAliveError 最大重试次数,默认为 5 次。 20 | # 达到上限后,该实例将停止重启并退出,避免频繁重启消耗资源。 21 | # MAX_RESTART_RETRIES=5 22 | 23 | # (可选) 日志时区偏移量(小时)。默认为 8 (UTC+8 北京时间)。 24 | # 设置为 0 即为 UTC 时间。 25 | # TZ_OFFSET=8 26 | 27 | # (可选) 项目根目录路径。通常不需要手动设置,程序会自动检测。 28 | # 仅在特定的容器环境或自定义部署结构中需要。 29 | # CAMOUFOX_PROJECT_ROOT="/app" 30 | 31 | # (可选) 运行模式。 32 | # 如果设置为 "true",程序将以 Web 服务器模式运行(提供健康检查端点),适用于 HuggingFace Spaces。 33 | # 如果未设置或为其他值,程序将以独立的 CLI 模式运行。 34 | # HG="true" 35 | 36 | # (必需/可选) 用户 Cookie 配置。 37 | # 至少需要配置一个有效的 Cookie 来源(环境变量或 cookies/ 目录下的 JSON 文件)。 38 | # 支持配置多个账号,通过后缀数字区分:_1, _2, _3 ... 39 | # 值可以是 JSON 字符串(Cookie-Editor 导出格式)或 KV 字符串 ("name=value; name2=value2")。 40 | # USER_COOKIE_1='[{"name": "__Secure-1PSID", "value": "..."}]' 41 | # USER_COOKIE_2='__Secure-1PSID=...; __Secure-1PAPISID=...' -------------------------------------------------------------------------------- /utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import os 4 | 5 | def custom_timezone_converter(timestamp): 6 | """ 7 | 将时间戳转换为指定时区 (默认 UTC+8/Asia/Shanghai) 的 struct_time。 8 | 时区通过环境变量 TZ_OFFSET (小时数) 配置。 9 | """ 10 | 11 | # 尝试从环境变量获取偏移量,默认为 8 (北京时间) 12 | try: 13 | offset_hours = float(os.getenv('TZ_OFFSET', 8)) 14 | except (ValueError, TypeError): 15 | offset_hours = 8 16 | 17 | # 创建时区对象 18 | target_timezone = datetime.timezone(datetime.timedelta(hours=offset_hours)) 19 | 20 | # 转换时间 21 | dt_time = datetime.datetime.fromtimestamp(timestamp, target_timezone) 22 | return dt_time.timetuple() 23 | 24 | def setup_logging(log_file, prefix=None, level=logging.INFO): 25 | """ 26 | 配置日志记录器,使其输出到文件和控制台。 27 | 支持一个可选的前缀,用于标识日志来源。 28 | 29 | 每次调用都会重新配置处理器,以适应多进程环境。 30 | 31 | 时间显示默认为 UTC+8 (北京时间),可通过环境变量 TZ_OFFSET 修改。 32 | 33 | :param log_file: 日志文件的路径。 34 | :param prefix: (可选) 要添加到每条日志消息开头的字符串前缀。 35 | :param level: 日志级别。 36 | """ 37 | logger = logging.getLogger('my_app_logger') 38 | logger.setLevel(level) 39 | 40 | if logger.hasHandlers(): 41 | logger.handlers.clear() 42 | 43 | base_format = '%(asctime)s - %(process)d - %(levelname)s - %(message)s' 44 | 45 | if prefix: 46 | log_format = f'%(asctime)s - %(process)d - %(levelname)s - {prefix} - %(message)s' 47 | else: 48 | log_format = base_format 49 | 50 | fh = logging.FileHandler(log_file) 51 | fh.setLevel(level) 52 | 53 | ch = logging.StreamHandler() 54 | ch.setLevel(level) 55 | 56 | formatter = logging.Formatter(log_format) 57 | # 设置自定义的时间转换器 58 | formatter.converter = custom_timezone_converter 59 | 60 | fh.setFormatter(formatter) 61 | ch.setFormatter(formatter) 62 | 63 | logger.addHandler(fh) 64 | logger.addHandler(ch) 65 | 66 | logger.propagate = False 67 | 68 | return logger -------------------------------------------------------------------------------- /browser/cookie_validator.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | from playwright.sync_api import TimeoutError, Error as PlaywrightError 4 | 5 | 6 | class CookieValidator: 7 | """Cookie验证器,负责定期验证Cookie的有效性。""" 8 | 9 | def __init__(self, page, context, logger): 10 | """ 11 | 初始化Cookie验证器 12 | 13 | Args: 14 | page: 主页面实例 15 | context: 浏览器上下文 16 | logger: 日志记录器 17 | """ 18 | self.page = page 19 | self.context = context 20 | self.logger = logger 21 | 22 | 23 | def validate_cookies_in_main_thread(self): 24 | """ 25 | 在主线程中执行Cookie验证(由主线程调用) 26 | 27 | Returns: 28 | bool: Cookie是否有效 29 | """ 30 | validation_page = None 31 | try: 32 | # 创建新标签页(在主线程中执行) 33 | self.logger.info("开始Cookie验证...") 34 | validation_page = self.context.new_page() 35 | 36 | # 访问验证URL 37 | validation_url = "https://aistudio.google.com/apps" 38 | validation_page.goto(validation_url, wait_until='domcontentloaded', timeout=30000) 39 | 40 | # 等待页面加载 41 | validation_page.wait_for_timeout(2000) 42 | 43 | # 获取最终URL 44 | final_url = validation_page.url 45 | 46 | # 检查是否被重定向到登录页面 47 | if "accounts.google.com/v3/signin/identifier" in final_url: 48 | self.logger.error("Cookie验证失败: 被重定向到登录页面") 49 | return False 50 | 51 | if "accounts.google.com/v3/signin/accountchooser" in final_url: 52 | self.logger.error("Cookie验证失败: 被重定向到账户选择页面") 53 | return False 54 | 55 | # 如果没有跳转到登录页面,就算成功 56 | self.logger.info("Cookie验证成功") 57 | return True 58 | 59 | except TimeoutError: 60 | self.logger.error("Cookie验证失败: 页面加载超时") 61 | return False 62 | 63 | except PlaywrightError as e: 64 | self.logger.error(f"Cookie验证失败: {e}") 65 | return False 66 | 67 | except Exception as e: 68 | self.logger.error(f"Cookie验证失败: {e}") 69 | return False 70 | 71 | finally: 72 | # 关闭验证标签页 73 | if validation_page: 74 | try: 75 | validation_page.close() 76 | except Exception: 77 | pass # 忽略关闭错误 78 | 79 | def shutdown_instance_on_cookie_failure(self): 80 | """ 81 | 因Cookie失效而关闭实例 82 | """ 83 | self.logger.error("Cookie失效,关闭实例") 84 | time.sleep(1) 85 | sys.exit(1) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **请注意:** 本教程的部署方案需配合 `CLIProxyAPI` 使用。在开始之前,请确保你已有一个正在运行的 `CLIProxyAPI` 实例。 2 | 3 | CLIProxyAPI 自 v6.3.x 版本起,开始支持通过 WebSocket 方式接入 AI Provider,并首个支持了 AIStudio。 4 | 5 | 然而,这种方式需要一个始终开启的浏览器来运行 AIStudioBuild 上的 WebSocket 通信程序,这总归有些不便。如果选择将其部署在 VPS 上,又会面临 VPS 内存要求较高的问题。 6 | 7 | 为了解决这个问题,我花了点时间尝试多种无头浏览器方案。最终,我选择使用 Docker 在 HuggingFace 上进行部署,此举能充分利用 HuggingFace 免费实例的大内存优势,实现零成本部署。 8 | 9 | ### 第一步:配置 AIStudioBuild 应用 10 | 11 | 需要根据你的 `CLIProxyAPI` 的设置,配置好 AIStudioBuild 上的 WebSocket 通信程序:打开官方提供的[示例程序](https://aistudio.google.com/apps/drive/1CPW7FpWGsDZzkaYgYOyXQ_6FWgxieLmL),复制该程序后**需**修改图中红框处的两个地方。其中,如果 `CLIProxyAPI` 中设置了 `wsauth` 为 `true`,那么就需要设置 `JWT_TOKEN`为 `CLIProxyAPI` 中的拟用于鉴权的 `api-keys` 值;设置 `WEBSOCKET_PROXY_URL` 为 `CLIProxyAPI` 所在的地址,例如:`wss://mycap.example.com/v1/ws`。设置完成后保存,并记录这个应用的链接备用。 12 | 13 | ![](https://img.072899.xyz/2025/11/359a2572d0206c20dba7fe12a136d6e8.png) 14 | 15 | 多账户使用时,需要多操作一个步骤,将该应用访问权限设置为 `Public`。 16 | 17 | ![](https://img.072899.xyz/2025/11/69c6395d1a98c38c68bc6c8dd46b3014.png) 18 | 19 | **安全警告:** 设置为 `Public` 后,请务必妥善保管你的链接。**切勿**将此链接公开分享,以免导致授权信息泄露。 20 | 21 | ### 第二步:准备 AIStudio Cookie 22 | 23 | Cookie 可以通过两种方式获取,两种方式选一种即可,推荐使用指纹浏览器方式获取: 24 | 25 | 第一种方式:使用 AdsPower 指纹浏览器,登录 https://aistudio.google.com/ ,退出后编辑浏览器环境,复制 Cookie 内容,具体如下图所示: 26 | 27 | ![](https://img.072899.xyz/2025/11/c60399120703a24bdd450d38e31052a5.png) 28 | 29 | 第二种方式:通过 Chrome 等普通浏览器的隐私模式,登录 https://aistudio.google.com/ ,在浏览器的开发者工具中复制 Cookie 内容,具体位置如下图所示: 30 | 31 | ![](https://img.072899.xyz/2025/11/51f860bf363cab01aa4c3fd5181b7f72.png) 32 | 33 | ### 第三步(1):部署 HuggingFace Space 34 | 35 | 打开 https://huggingface.co/spaces/hkfires/AIStudioBuildWS ,复制该 Space。在 `CAMOUFOX_INSTANCE_URL` 处填入第一步准备的程序的链接,在 `USER_COOKIE_1` 处填入第二步准备的 Cookie,点击 Duplicate Space。 36 | 37 | ![](https://img.072899.xyz/2025/11/04e84ce3b0f2abe7ae9e717ac8b5aa0b.png) 38 | 39 | 等待 HuggingFace 构建完成,出现如下日志,即部署成功: 40 | 41 | ![](https://img.072899.xyz/2025/11/e818f38cfb272c1fc10ca97c2ef23c6b.png) 42 | 43 | 如果有多个账户,参考 `USER_COOKIE_1`,在 HuggingFace Space 的设置中依次增加 `USER_COOKIE_2`、`USER_COOKIE_3` 等环境变量即可。 44 | 45 | **可选配置:** 如果需要修改日志显示时区,可以添加 `TZ_OFFSET` 环境变量,例如设置为 `8` 表示 UTC+8(北京时间,默认值),设置为 `0` 表示 UTC。 46 | 47 | **重要提醒:** Cookie 属于敏感信息,请**务必使用 "Secrets"** (而不是 "Variables") 来存储,以防止 Cookie 外泄。 48 | 49 | ### 第三步(2):服务器 Docker 部署 50 | 51 | 如果你拥有自己的服务器(VPS),也可以使用 Docker Compose 进行部署。 52 | 53 | 1. **下载代码** 54 | ```bash 55 | git clone https://github.com/hkfires/AIStudioBuildWS.git 56 | cd AIStudioBuildWS 57 | ``` 58 | 59 | 2. **配置环境变量** 60 | 复制 `.env.example` 为 `.env`,并填入必要信息(`CAMOUFOX_INSTANCE_URL` 和 `USER_COOKIE_1` 等)。 61 | 62 | 也可以在 `cookies` 目录下放置 JSON 格式的 Cookie 文件(文件名任意),程序会自动读取。 63 | ```bash 64 | cp .env.example .env 65 | nano .env 66 | ``` 67 | 68 | 3. **启动服务** 69 | ```bash 70 | docker compose up -d --build 71 | ``` 72 | 73 | 部署成功后,我们应该在 `CLIProxyAPI` 的中看到类似如下的日志。至此,整个部署全部完成。 74 | 75 | ![](https://img.072899.xyz/2025/11/e0db39f81a3bbb956cbe9364e656a76f.png) -------------------------------------------------------------------------------- /browser/navigation.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | from playwright.sync_api import Page, expect 4 | from utils.paths import logs_dir 5 | from utils.common import ensure_dir 6 | 7 | class KeepAliveError(Exception): 8 | pass 9 | 10 | def handle_untrusted_dialog(page: Page, logger=None): 11 | """ 12 | 检查并处理 "Last modified by..." 的弹窗。 13 | 如果弹窗出现,则点击 "OK" 按钮。 14 | """ 15 | ok_button_locator = page.get_by_role("button", name="OK") 16 | 17 | try: 18 | if ok_button_locator.is_visible(timeout=10000): # 等待最多10秒 19 | logger.info(f"检测到弹窗,正在点击 'OK' 按钮...") 20 | 21 | ok_button_locator.click(force=True) 22 | logger.info(f"'OK' 按钮已点击") 23 | expect(ok_button_locator).to_be_hidden(timeout=1000) 24 | logger.info(f"弹窗已确认关闭") 25 | else: 26 | logger.info(f"在10秒内未检测到弹窗,继续执行...") 27 | except Exception as e: 28 | logger.info(f"检查弹窗时发生意外:{e},将继续执行...") 29 | 30 | def handle_successful_navigation(page: Page, logger, cookie_file_config, shutdown_event=None, cookie_validator=None): 31 | """ 32 | 在成功导航到目标页面后,执行后续操作(处理弹窗、保持运行)。 33 | """ 34 | logger.info("已成功到达目标页面") 35 | page.click('body') # 给予页面焦点 36 | 37 | # 检查并处理 "Last modified by..." 的弹窗 38 | handle_untrusted_dialog(page, logger=logger) 39 | 40 | if cookie_validator: 41 | logger.info("Cookie验证器已创建,将定期验证Cookie有效性") 42 | 43 | logger.info("实例将保持运行状态。每10秒点击一次页面以保持活动") 44 | 45 | # 等待页面加载和渲染 46 | time.sleep(15) 47 | 48 | # 添加Cookie验证计数器 49 | click_counter = 0 50 | 51 | while True: 52 | # 检查是否收到关闭信号 53 | if shutdown_event and shutdown_event.is_set(): 54 | logger.info("收到关闭信号,正在优雅退出保持活动循环...") 55 | break 56 | 57 | try: 58 | page.click('body') 59 | click_counter += 1 60 | 61 | # 每360次点击(1小时)执行一次完整的Cookie验证 62 | if cookie_validator and click_counter >= 360: # 360 * 10秒 = 3600秒 = 1小时 63 | is_valid = cookie_validator.validate_cookies_in_main_thread() 64 | 65 | if not is_valid: 66 | cookie_validator.shutdown_instance_on_cookie_failure() 67 | return 68 | 69 | click_counter = 0 # 重置计数器 70 | 71 | # 使用可中断的睡眠,每秒检查一次关闭信号 72 | for _ in range(10): # 10秒 = 10次1秒检查 73 | if shutdown_event and shutdown_event.is_set(): 74 | logger.info("收到关闭信号,正在优雅退出保持活动循环...") 75 | return 76 | time.sleep(1) 77 | 78 | except Exception as e: 79 | logger.error(f"在保持活动循环中出错: {e}") 80 | # 在保持活动循环中出错时截屏 81 | try: 82 | screenshot_dir = logs_dir() 83 | ensure_dir(screenshot_dir) 84 | screenshot_filename = os.path.join(screenshot_dir, f"FAIL_keep_alive_error_{cookie_file_config}.png") 85 | page.screenshot(path=screenshot_filename, full_page=True) 86 | logger.info(f"已在保持活动循环出错时截屏: {screenshot_filename}") 87 | except Exception as screenshot_e: 88 | logger.error(f"在保持活动循环出错时截屏失败: {screenshot_e}") 89 | raise KeepAliveError(f"在保持活动循环时出错: {e}") -------------------------------------------------------------------------------- /utils/url_helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL处理辅助函数 3 | 4 | 提供URL解析和路径提取功能,用于导航验证中的域名无关匹配。 5 | """ 6 | 7 | from urllib.parse import urlparse 8 | 9 | 10 | def extract_url_path(url: str) -> str: 11 | """ 12 | 提取URL的路径和查询参数部分,忽略协议和域名差异 13 | 14 | 用于验证导航是否到达正确页面,允许域名重定向。 15 | 16 | Args: 17 | url: 完整URL字符串 18 | 19 | Returns: 20 | 路径+查询参数+片段(例如:"/apps/drive/123?param=value#section") 21 | 如果URL为空或无效,返回空字符串 22 | 23 | Examples: 24 | >>> extract_url_path("https://ai.studio/apps/drive/123?param=value") 25 | '/apps/drive/123?param=value' 26 | 27 | >>> extract_url_path("https://aistudio.google.com/apps/drive/123") 28 | '/apps/drive/123' 29 | 30 | >>> extract_url_path("https://example.com/path") 31 | '/path' 32 | """ 33 | if not url: 34 | return "" 35 | 36 | try: 37 | parsed = urlparse(url) 38 | result = parsed.path 39 | if parsed.query: 40 | result += '?' + parsed.query 41 | if parsed.fragment: 42 | result += '#' + parsed.fragment 43 | return result 44 | except Exception: 45 | # 如果URL格式无效,返回空字符串 46 | return "" 47 | 48 | 49 | def mask_path_for_logging(path: str) -> str: 50 | """ 51 | 对路径进行脱敏处理,用于日志输出 52 | 53 | 脱敏规则: 54 | 1. 对于 /apps/drive/XXXXXXXXXX 路径,保留头4位和尾4位,中间用***代替 55 | 2. 如果不是 /apps/drive/XXXXXXXXXX 路径,返回完整路径 56 | 57 | Args: 58 | path: URL路径字符串 59 | 60 | Returns: 61 | 脱敏后的路径字符串 62 | 63 | Examples: 64 | >>> mask_path_for_logging("/apps/drive/abcdef123456") 65 | '/apps/drive/abcd***3456' 66 | 67 | >>> mask_path_for_logging("/apps/drive/xyz789") 68 | '/apps/drive/xyz789' 69 | 70 | >>> mask_path_for_logging("/other/path") 71 | '/other/path' 72 | """ 73 | if not path: 74 | return "" 75 | 76 | # 检查是否为 /apps/drive/ 路径 77 | if path.startswith('/apps/drive/'): 78 | # 提取路径中的ID部分 79 | path_parts = path.split('/') 80 | if len(path_parts) >= 4: # ['', 'apps', 'drive', 'ID'] 81 | drive_id = path_parts[3] 82 | 83 | # 如果ID长度大于8,则进行脱敏处理 84 | if len(drive_id) > 8: 85 | # 使用与URL脱敏相同的格式 86 | masked_id = f"{drive_id[:4]}***{drive_id[-4:]}" 87 | # 重新构建路径 88 | masked_parts = path_parts[:3] + [masked_id] + path_parts[4:] 89 | return '/'.join(masked_parts) 90 | 91 | # 如果不符合脱敏条件,返回原始路径 92 | return path 93 | 94 | 95 | def mask_url_for_logging(url: str) -> str: 96 | """ 97 | 对URL进行脱敏处理,用于日志输出 98 | 99 | 脱敏规则: 100 | 1. 对于 /apps/drive/XXXXXXXXXX 路径,保留头4位和尾4位,中间用***代替 101 | 2. 如果不是 /apps/drive/XXXXXXXXXX 路径,返回完整URL 102 | 103 | Args: 104 | url: 完整URL字符串 105 | 106 | Returns: 107 | 脱敏后的URL字符串 108 | 109 | Examples: 110 | >>> mask_url_for_logging("https://ai.studio/apps/drive/abcdef123456") 111 | 'https://ai.studio/apps/drive/abcd***3456' 112 | 113 | >>> mask_url_for_logging("https://aistudio.google.com/apps/drive/xyz789") 114 | 'https://aistudio.google.com/apps/drive/xyz789' 115 | 116 | >>> mask_url_for_logging("https://example.com/other/path") 117 | 'https://example.com/other/path' 118 | """ 119 | if not url: 120 | return "" 121 | 122 | try: 123 | parsed = urlparse(url) 124 | 125 | # 检查是否为 /apps/drive/ 路径 126 | if parsed.path.startswith('/apps/drive/'): 127 | # 提取路径中的ID部分 128 | path_parts = parsed.path.split('/') 129 | if len(path_parts) >= 4: # ['', 'apps', 'drive', 'ID'] 130 | drive_id = path_parts[3] 131 | 132 | # 如果ID长度大于8,则进行脱敏处理 133 | if len(drive_id) > 8: 134 | masked_id = f"{drive_id[:4]}***{drive_id[-4:]}" 135 | # 重新构建路径 136 | masked_parts = path_parts[:3] + [masked_id] + path_parts[4:] 137 | masked_path = '/'.join(masked_parts) 138 | 139 | # 重新构建URL 140 | result = f"{parsed.scheme}://{parsed.netloc}{masked_path}" 141 | if parsed.query: 142 | result += '?' + parsed.query 143 | if parsed.fragment: 144 | result += '#' + parsed.fragment 145 | return result 146 | 147 | # 如果不符合脱敏条件,返回原始URL 148 | return url 149 | 150 | except Exception: 151 | # 如果URL解析失败,返回原始URL 152 | return url 153 | -------------------------------------------------------------------------------- /utils/cookie_handler.py: -------------------------------------------------------------------------------- 1 | def convert_cookie_editor_to_playwright(cookies_from_editor, logger=None): 2 | """ 3 | 将从 Cookie-Editor 插件导出的 Cookie 列表转换为 Playwright 兼容的格式。 4 | """ 5 | playwright_cookies = [] 6 | 7 | for cookie in cookies_from_editor: 8 | pw_cookie = {} 9 | for key in ['name', 'value', 'domain', 'path', 'httpOnly', 'secure']: 10 | if key in cookie: 11 | pw_cookie[key] = cookie[key] 12 | if cookie.get('session', False): 13 | pw_cookie['expires'] = -1 14 | elif 'expirationDate' in cookie: 15 | if cookie['expirationDate'] is not None: 16 | pw_cookie['expires'] = int(cookie['expirationDate']) 17 | else: 18 | pw_cookie['expires'] = -1 19 | 20 | if 'sameSite' in cookie: 21 | same_site_value = str(cookie['sameSite']).lower() 22 | if same_site_value == 'no_restriction': 23 | pw_cookie['sameSite'] = 'None' 24 | elif same_site_value in ['lax', 'strict']: 25 | pw_cookie['sameSite'] = same_site_value.capitalize() 26 | elif same_site_value == 'unspecified': 27 | pw_cookie['sameSite'] = 'Lax' 28 | 29 | if all(key in pw_cookie for key in ['name', 'value', 'domain', 'path']): 30 | playwright_cookies.append(pw_cookie) 31 | else: 32 | if logger: 33 | logger.warning(f"跳过一个格式不完整的 Cookie: {cookie}") 34 | 35 | return playwright_cookies 36 | 37 | 38 | def convert_kv_to_playwright(kv_string, default_domain=".google.com", logger=None): 39 | """ 40 | 将键值对格式的 Cookie 字符串转换为 Playwright 兼容的格式。 41 | 42 | Args: 43 | kv_string (str): 包含 Cookie 的键值对字符串,格式为 "name1=value1; name2=value2; ..." 44 | default_domain (str): 默认域名,默认为".google.com" 45 | logger: 日志记录器 46 | 47 | Returns: 48 | list: Playwright 兼容的 Cookie 列表 49 | """ 50 | playwright_cookies = [] 51 | 52 | # 按分号分割 Cookie 53 | cookie_pairs = kv_string.split(';') 54 | 55 | for pair in cookie_pairs: 56 | pair = pair.strip() # 去除首尾空白字符 57 | 58 | if not pair: # 跳过空字符串 59 | continue 60 | 61 | # 跳过无效的 Cookie(不包含等号) 62 | if '=' not in pair: 63 | if logger: 64 | logger.warning(f"跳过无效的 Cookie 格式: '{pair}'") 65 | continue 66 | 67 | # 分割name和value 68 | name, value = pair.split('=', 1) # 只分割第一个等号 69 | name = name.strip() 70 | value = value.strip() 71 | 72 | if not name: # 跳过空名称 73 | if logger: 74 | logger.warning(f"跳过空名称的 Cookie: '{pair}'") 75 | continue 76 | 77 | # 构造 Playwright 格式的 Cookie 78 | pw_cookie = { 79 | 'name': name, 80 | 'value': value, 81 | 'domain': default_domain, 82 | 'path': '/', 83 | 'expires': -1, # 默认为会话 Cookie 84 | 'httpOnly': False, # KV 格式无法确定 httpOnly 状态,默认为 False 85 | 'secure': True, # 假设为安全 Cookie 86 | 'sameSite': 'Lax' # 默认 SameSite 策略 87 | } 88 | 89 | playwright_cookies.append(pw_cookie) 90 | 91 | if logger: 92 | logger.debug(f"成功转换 Cookie: {name} -> domain={default_domain}") 93 | 94 | return playwright_cookies 95 | 96 | 97 | def auto_convert_to_playwright(cookie_data, default_domain=".google.com", logger=None): 98 | """ 99 | 自动识别 Cookie 数据格式并转换为 Playwright 兼容格式。 100 | 支持两种输入格式: 101 | 1. JSON 数组 (Cookie-Editor 导出格式) 102 | 2. KV 字符串 (键值对格式: "name1=value1; name2=value2; ...") 103 | 104 | Args: 105 | cookie_data: Cookie 数据,可以是 list (JSON格式) 或 str (KV格式) 106 | default_domain (str): KV格式使用的默认域名,默认为".google.com" 107 | logger: 日志记录器 108 | 109 | Returns: 110 | list: Playwright 兼容的 Cookie 列表 111 | 112 | Raises: 113 | ValueError: 当格式无法识别时抛出异常 114 | """ 115 | # 格式1: JSON 数组格式 (Cookie-Editor 导出格式) 116 | if isinstance(cookie_data, list): 117 | if logger: 118 | logger.debug(f"检测到 JSON 数组格式的 Cookie 数据,共 {len(cookie_data)} 个条目") 119 | return convert_cookie_editor_to_playwright(cookie_data, logger=logger) 120 | 121 | # 格式2: KV 字符串格式 122 | if isinstance(cookie_data, str): 123 | # 去除首尾空白字符 124 | cookie_str = cookie_data.strip() 125 | 126 | if not cookie_str: 127 | if logger: 128 | logger.warning("收到空的 Cookie 字符串") 129 | return [] 130 | 131 | if logger: 132 | logger.debug(f"检测到 KV 字符串格式的 Cookie 数据") 133 | 134 | return convert_kv_to_playwright( 135 | cookie_str, 136 | default_domain=default_domain, 137 | logger=logger 138 | ) 139 | 140 | # 无法识别的格式 141 | error_msg = f"无法识别的 Cookie 数据格式: {type(cookie_data).__name__}" 142 | if logger: 143 | logger.error(error_msg) 144 | raise ValueError(error_msg) 145 | -------------------------------------------------------------------------------- /utils/cookie_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 统一的Cookie管理器 3 | 整合JSON文件和环境变量Cookie的检测、加载和管理功能 4 | """ 5 | 6 | import os 7 | import json 8 | from dataclasses import dataclass 9 | from typing import List, Dict, Optional 10 | from utils.paths import cookies_dir 11 | from utils.cookie_handler import auto_convert_to_playwright 12 | from utils.common import clean_env_value 13 | 14 | @dataclass 15 | class CookieSource: 16 | """Cookie来源的统一表示""" 17 | type: str # "file" | "env_var" 18 | identifier: str # filename or "USER_COOKIE_1" 19 | display_name: str # 显示名称 20 | 21 | def __str__(self): 22 | return f"{self.type}:{self.identifier}" 23 | 24 | 25 | class CookieManager: 26 | """ 27 | 统一的Cookie管理器 28 | 负责检测、加载和缓存所有来源的Cookie数据 29 | """ 30 | 31 | def __init__(self, logger=None): 32 | self.logger = logger 33 | self._detected_sources: Optional[List[CookieSource]] = None 34 | self._cookie_cache: Dict[str, List[Dict]] = {} 35 | 36 | def detect_all_sources(self) -> List[CookieSource]: 37 | """ 38 | 检测所有可用的Cookie来源(JSON文件 + 环境变量) 39 | 结果会被缓存,避免重复扫描 40 | """ 41 | if self._detected_sources is not None: 42 | return self._detected_sources 43 | 44 | sources = [] 45 | 46 | # 1. 扫描Cookies目录中的JSON文件 47 | try: 48 | cookie_path = cookies_dir() 49 | if os.path.isdir(cookie_path): 50 | cookie_files = [f for f in os.listdir(cookie_path) if f.lower().endswith('.json')] 51 | 52 | for cookie_file in cookie_files: 53 | source = CookieSource( 54 | type="file", 55 | identifier=cookie_file, 56 | display_name=cookie_file 57 | ) 58 | sources.append(source) 59 | 60 | if cookie_files and self.logger: 61 | self.logger.info(f"发现 {len(cookie_files)} 个 Cookie 文件") 62 | elif self.logger: 63 | self.logger.info(f"在 {cookie_path} 目录下未找到任何格式的 Cookie 文件") 64 | else: 65 | if self.logger: 66 | self.logger.error(f"Cookie 目录不存在: {cookie_path}") 67 | 68 | except Exception as e: 69 | if self.logger: 70 | self.logger.error(f"扫描 Cookie 目录时出错: {e}") 71 | 72 | # 2. 扫描USER_COOKIE环境变量 73 | cookie_index = 1 74 | env_cookie_count = 0 75 | 76 | while True: 77 | env_var_name = f"USER_COOKIE_{cookie_index}" 78 | env_value = clean_env_value(os.getenv(env_var_name)) 79 | 80 | if not env_value: 81 | if cookie_index == 1 and self.logger: 82 | self.logger.info(f"未检测到任何 USER_COOKIE 环境变量") 83 | break 84 | 85 | source = CookieSource( 86 | type="env_var", 87 | identifier=env_var_name, 88 | display_name=env_var_name 89 | ) 90 | sources.append(source) 91 | 92 | env_cookie_count += 1 93 | cookie_index += 1 94 | 95 | if env_cookie_count > 0 and self.logger: 96 | self.logger.info(f"发现 {env_cookie_count} 个 Cookie 环境变量") 97 | 98 | # 缓存结果 99 | self._detected_sources = sources 100 | return sources 101 | 102 | def load_cookies(self, source: CookieSource) -> List[Dict]: 103 | """ 104 | 从指定来源加载Cookie数据 105 | 106 | Args: 107 | source: Cookie来源对象 108 | 109 | Returns: 110 | Playwright兼容的cookie列表 111 | """ 112 | cache_key = str(source) 113 | 114 | # 检查缓存 115 | if cache_key in self._cookie_cache: 116 | if self.logger: 117 | self.logger.debug(f"从缓存加载 Cookie: {source.display_name}") 118 | return self._cookie_cache[cache_key] 119 | 120 | cookies = [] 121 | 122 | try: 123 | if source.type == "file": 124 | cookies = self._load_from_file(source.identifier) 125 | elif source.type == "env_var": 126 | cookies = self._load_from_env(source.identifier) 127 | else: 128 | if self.logger: 129 | self.logger.error(f"未知的 Cookie 来源类型: {source.type}") 130 | return [] 131 | 132 | # 缓存结果 133 | self._cookie_cache[cache_key] = cookies 134 | 135 | if self.logger: 136 | self.logger.info(f"从 {source.display_name} 加载了 {len(cookies)} 个 Cookie 数据") 137 | 138 | except Exception as e: 139 | if self.logger: 140 | self.logger.error(f"从 {source.display_name} 加载 Cookie 时出错: {e}") 141 | return [] 142 | 143 | return cookies 144 | 145 | def _load_from_file(self, filename: str) -> List[Dict]: 146 | """从文件加载 Cookie,自动识别 JSON 或 KV 格式""" 147 | cookie_path = cookies_dir() / filename 148 | 149 | if not os.path.exists(cookie_path): 150 | raise FileNotFoundError(f"Cookie 文件不存在: {cookie_path}") 151 | 152 | with open(cookie_path, 'r', encoding='utf-8') as f: 153 | file_content = f.read().strip() 154 | 155 | # 尝试解析为 JSON 156 | try: 157 | cookies_from_file = json.loads(file_content) 158 | # JSON 解析成功,使用自动转换函数 159 | return auto_convert_to_playwright( 160 | cookies_from_file, 161 | default_domain=".google.com", 162 | logger=self.logger 163 | ) 164 | except json.JSONDecodeError: 165 | # JSON 解析失败,当作 KV 格式处理 166 | if self.logger: 167 | self.logger.info(f"文件 {filename} 不是有效的 JSON 格式,尝试作为 KV 格式解析") 168 | return auto_convert_to_playwright( 169 | file_content, 170 | default_domain=".google.com", 171 | logger=self.logger 172 | ) 173 | 174 | def _load_from_env(self, env_var_name: str) -> List[Dict]: 175 | """从环境变量加载 Cookie,自动识别 JSON 或 KV 格式""" 176 | env_value = clean_env_value(os.getenv(env_var_name)) 177 | 178 | if not env_value: 179 | raise ValueError(f"环境变量 {env_var_name} 不存在或为空") 180 | 181 | # 尝试解析为 JSON 182 | try: 183 | cookies_from_env = json.loads(env_value) 184 | # JSON 解析成功,使用自动转换函数 185 | return auto_convert_to_playwright( 186 | cookies_from_env, 187 | default_domain=".google.com", 188 | logger=self.logger 189 | ) 190 | except json.JSONDecodeError: 191 | # JSON 解析失败,当作 KV 格式处理 192 | if self.logger: 193 | self.logger.debug(f"环境变量 {env_var_name} 不是有效的 JSON 格式,作为 KV 格式解析") 194 | return auto_convert_to_playwright( 195 | env_value, 196 | default_domain=".google.com", 197 | logger=self.logger 198 | ) -------------------------------------------------------------------------------- /browser/instance.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import time 4 | from playwright.sync_api import TimeoutError, Error as PlaywrightError 5 | from utils.logger import setup_logging 6 | from utils.cookie_manager import CookieManager 7 | from browser.navigation import handle_successful_navigation, KeepAliveError 8 | from browser.cookie_validator import CookieValidator 9 | from camoufox.sync_api import Camoufox 10 | from utils.paths import logs_dir 11 | from utils.common import parse_headless_mode, ensure_dir 12 | from utils.url_helper import extract_url_path, mask_url_for_logging, mask_path_for_logging 13 | 14 | 15 | def run_browser_instance(config, shutdown_event=None): 16 | """ 17 | 根据最终合并的配置,启动并管理一个单独的 Camoufox 浏览器实例。 18 | 使用CookieManager统一管理Cookie加载,避免重复的扫描逻辑。 19 | """ 20 | # 重置信号处理器,确保子进程能响应 SIGTERM 21 | signal.signal(signal.SIGTERM, signal.SIG_DFL) 22 | # 忽略 SIGINT (Ctrl+C),让主进程统一处理 23 | signal.signal(signal.SIGINT, signal.SIG_IGN) 24 | 25 | cookie_source = config.get('cookie_source') 26 | if not cookie_source: 27 | # 使用默认logger进行错误报告 28 | logger = setup_logging(os.path.join(logs_dir(), 'app.log')) 29 | logger.error("错误: 配置中缺少cookie_source对象") 30 | return 31 | 32 | instance_label = cookie_source.display_name 33 | logger = setup_logging( 34 | os.path.join(logs_dir(), 'app.log'), prefix=instance_label 35 | ) 36 | diagnostic_tag = instance_label.replace(os.sep, "_") 37 | 38 | expected_url = config.get('url') 39 | proxy = config.get('proxy') 40 | headless_setting = config.get('headless', 'virtual') 41 | 42 | # 使用CookieManager加载Cookie 43 | cookie_manager = CookieManager(logger) 44 | all_cookies = [] 45 | 46 | try: 47 | # 直接使用CookieSource对象加载Cookie 48 | cookies = cookie_manager.load_cookies(cookie_source) 49 | all_cookies.extend(cookies) 50 | 51 | except Exception as e: 52 | logger.error(f"从Cookie来源加载时出错: {e}") 53 | return 54 | 55 | # 3. 检查是否有任何Cookie可用 56 | if not all_cookies: 57 | logger.error("错误: 没有可用的Cookie(既没有有效的JSON文件,也没有环境变量)") 58 | return 59 | 60 | cookies = all_cookies 61 | 62 | headless_mode = parse_headless_mode(headless_setting) 63 | launch_options = {"headless": headless_mode} 64 | # launch_options["block_images"] = True # 禁用图片加载 65 | 66 | if proxy: 67 | logger.info(f"使用代理: {proxy} 访问") 68 | launch_options["proxy"] = {"server": proxy, "bypass": "localhost, 127.0.0.1"} 69 | 70 | screenshot_dir = logs_dir() 71 | ensure_dir(screenshot_dir) 72 | 73 | # 重启控制变量 74 | max_retries = int(os.getenv("MAX_RESTART_RETRIES", "5")) 75 | retry_count = 0 76 | base_delay = 3 77 | 78 | while True: 79 | # 检查是否收到全局关闭信号 80 | if shutdown_event and shutdown_event.is_set(): 81 | logger.info("检测到全局关闭事件,浏览器实例不再启动,准备退出") 82 | return 83 | 84 | try: 85 | with Camoufox(**launch_options) as browser: 86 | context = browser.new_context() 87 | context.add_cookies(cookies) 88 | page = context.new_page() 89 | 90 | # 创建Cookie验证器 91 | cookie_validator = CookieValidator(page, context, logger) 92 | 93 | # #################################################################### 94 | # ############ 增强的 page.goto() 错误处理和日志记录 ############### 95 | # #################################################################### 96 | 97 | response = None 98 | try: 99 | logger.info(f"正在导航到: {mask_url_for_logging(expected_url)} (超时设置为 90 秒)") 100 | # page.goto() 会返回一个 response 对象,我们可以用它来获取状态码等信息 101 | response = page.goto(expected_url, wait_until='domcontentloaded', timeout=90000) 102 | 103 | # 检查HTTP响应状态码 104 | if response: 105 | logger.info(f"导航初步成功,服务器响应状态码: {response.status} {response.status_text}") 106 | if not response.ok: # response.ok 检查状态码是否在 200-299 范围内 107 | logger.warning(f"警告:页面加载成功,但HTTP状态码表示错误: {response.status}") 108 | # 即使状态码错误,也保存快照以供分析 109 | page.screenshot(path=os.path.join(screenshot_dir, f"WARN_http_status_{response.status}_{diagnostic_tag}.png")) 110 | else: 111 | # 对于非http/https的导航(如 about:blank),response可能为None 112 | logger.warning("page.goto 未返回响应对象,可能是一个非HTTP导航") 113 | 114 | except TimeoutError: 115 | # 这是最常见的错误:超时 116 | logger.error(f"导航到 {mask_url_for_logging(expected_url)} 超时 (超过90秒)") 117 | logger.error("可能原因:网络连接缓慢、目标网站服务器无响应、代理问题、或页面资源被阻塞") 118 | # 尝试保存诊断信息 119 | try: 120 | # 截图对于看到页面卡在什么状态非常有帮助(例如,空白页、加载中、Chrome错误页) 121 | screenshot_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.png") 122 | page.screenshot(path=screenshot_path, full_page=True) 123 | logger.info(f"已截取超时时的屏幕快照: {screenshot_path}") 124 | 125 | # 保存HTML可以帮助分析DOM结构,即使在无头模式下也很有用 126 | html_path = os.path.join(screenshot_dir, f"FAIL_timeout_{diagnostic_tag}.html") 127 | with open(html_path, 'w', encoding='utf-8') as f: 128 | f.write(page.content()) 129 | logger.info(f"已保存超时时的页面HTML: {html_path}") 130 | except Exception as diag_e: 131 | logger.error(f"在尝试进行超时诊断(截图/保存HTML)时发生额外错误: {diag_e}") 132 | return # 超时后,后续操作无意义,直接终止 133 | 134 | except PlaywrightError as e: 135 | # 捕获其他Playwright相关的网络错误,例如DNS解析失败、连接被拒绝等 136 | error_message = str(e) 137 | logger.error(f"导航到 {mask_url_for_logging(expected_url)} 时发生 Playwright 网络错误") 138 | logger.error(f"错误详情: {error_message}") 139 | 140 | # Playwright的错误信息通常很具体,例如 "net::ERR_CONNECTION_REFUSED" 141 | if "net::ERR_NAME_NOT_RESOLVED" in error_message: 142 | logger.error("排查建议:检查DNS设置或域名是否正确") 143 | elif "net::ERR_CONNECTION_REFUSED" in error_message: 144 | logger.error("排查建议:目标服务器可能已关闭,或代理/防火墙阻止了连接") 145 | elif "net::ERR_INTERNET_DISCONNECTED" in error_message: 146 | logger.error("排查建议:检查本机的网络连接") 147 | 148 | # 同样,尝试截图,尽管此时页面可能完全无法访问 149 | try: 150 | screenshot_path = os.path.join(screenshot_dir, f"FAIL_network_error_{diagnostic_tag}.png") 151 | page.screenshot(path=screenshot_path) 152 | logger.info(f"已截取网络错误时的屏幕快照: {screenshot_path}") 153 | except Exception as diag_e: 154 | logger.error(f"在尝试进行网络错误诊断(截图)时发生额外错误: {diag_e}") 155 | return # 网络错误,终止 156 | 157 | # --- 如果导航没有抛出异常,继续执行后续逻辑 --- 158 | 159 | logger.info("页面初步加载完成,正在检查并处理初始弹窗...") 160 | page.wait_for_timeout(2000) 161 | 162 | final_url = page.url 163 | logger.info(f"导航完成。最终URL为: {mask_url_for_logging(final_url)}") 164 | 165 | # ... 你原有的URL检查逻辑保持不变 ... 166 | if "accounts.google.com/v3/signin/identifier" in final_url: 167 | logger.error("检测到Google登录页面(需要输入邮箱)。Cookie已完全失效") 168 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_identifier_page_{diagnostic_tag}.png")) 169 | return 170 | 171 | # 提取路径部分进行匹配(允许域名重定向) 172 | expected_path = extract_url_path(expected_url).split('?')[0] 173 | final_path = extract_url_path(final_url) 174 | 175 | if expected_path and expected_path in final_path: 176 | logger.info(f"URL验证通过。预期路径: {mask_path_for_logging(expected_path)}") 177 | 178 | # --- 新的健壮策略:等待加载指示器消失 --- 179 | # 这是解决竞态条件的关键。错误消息或内容只在初始加载完成后才会出现。 180 | spinner_locator = page.locator('mat-spinner') 181 | try: 182 | logger.info("正在等待加载指示器 (spinner) 消失... (最长等待30秒)") 183 | # 我们等待spinner变为'隐藏'状态或从DOM中消失。 184 | spinner_locator.wait_for(state='hidden', timeout=30000) 185 | logger.info("加载指示器已消失。页面已完成异步加载") 186 | except TimeoutError: 187 | logger.error("页面加载指示器在30秒内未消失。页面可能已卡住") 188 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_spinner_stuck_{diagnostic_tag}.png")) 189 | raise KeepAliveError("页面加载指示器超时") 190 | 191 | # --- 现在我们可以安全地检查错误消息 --- 192 | # 我们使用最具体的文本以避免误判。 193 | auth_error_text = "authentication error" 194 | auth_error_locator = page.get_by_text(auth_error_text, exact=False) 195 | 196 | # 这里我们只需要很短的超时时间,因为页面应该是稳定的。 197 | if auth_error_locator.is_visible(timeout=2000): 198 | logger.error(f"检测到认证失败的错误横幅: '{auth_error_text}'. Cookie已过期或无效") 199 | screenshot_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.png") 200 | page.screenshot(path=screenshot_path) 201 | 202 | # html_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{diagnostic_tag}.html") 203 | # with open(html_path, 'w', encoding='utf-8') as f: 204 | # f.write(page.content()) 205 | # logger.info(f"已保存包含错误信息的页面HTML: {html_path}") 206 | return # 明确的失败,因此我们退出。 207 | 208 | # --- 如果没有错误,进行最终确认(作为后备方案) --- 209 | logger.info("未检测到认证错误横幅。进行最终确认") 210 | login_button_cn = page.get_by_role('button', name='登录') 211 | login_button_en = page.get_by_role('button', name='Login') 212 | 213 | if login_button_cn.is_visible(timeout=1000) or login_button_en.is_visible(timeout=1000): 214 | logger.error("页面上仍显示'登录'按钮。Cookie无效") 215 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_login_button_visible_{diagnostic_tag}.png")) 216 | return 217 | 218 | # --- 如果所有检查都通过,我们假设成功 --- 219 | logger.info("所有验证通过,确认已成功登录") 220 | 221 | handle_successful_navigation(page, logger, diagnostic_tag, shutdown_event, cookie_validator) 222 | elif "accounts.google.com/v3/signin/accountchooser" in final_url: 223 | logger.warning("检测到Google账户选择页面。登录失败或Cookie已过期") 224 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_chooser_click_failed_{diagnostic_tag}.png")) 225 | return 226 | else: 227 | logger.error(f"导航到了意外的URL") 228 | logger.error(f" 预期路径: {mask_path_for_logging(expected_path)}") 229 | logger.error(f" 最终路径: {mask_path_for_logging(final_path)}") 230 | logger.error(f" 最终URL: {mask_url_for_logging(final_url)}") 231 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_unexpected_url_{diagnostic_tag}.png")) 232 | return 233 | 234 | # 如果运行到这里且没有异常,表示实例正常结束(例如收到关闭信号) 235 | # 正常结束时重置重试计数器 236 | retry_count = 0 237 | return 238 | 239 | except KeepAliveError as e: 240 | retry_count += 1 241 | if retry_count > max_retries: 242 | logger.error(f"重试次数已达上限 ({max_retries}),实例不再重启,退出") 243 | return 244 | 245 | # 指数退避:3秒、6秒、12秒、24秒...最长60秒 246 | delay = min(base_delay * (2 ** (retry_count - 1)), 60) 247 | logger.error(f"浏览器实例出现错误 (重试 {retry_count}/{max_retries}),将在 {delay} 秒后重启浏览器实例: {e}") 248 | time.sleep(delay) 249 | continue 250 | except KeyboardInterrupt: 251 | logger.info(f"用户中断,正在关闭...") 252 | return 253 | except SystemExit as e: 254 | # 捕获Cookie验证失败时的系统退出 255 | if e.code == 1: 256 | logger.error("Cookie验证失败,关闭进程实例") 257 | else: 258 | logger.info(f"实例正常退出,退出码: {e.code}") 259 | return 260 | except Exception as e: 261 | # 这是一个最终的捕获,用于捕获所有未预料到的错误 262 | logger.exception(f"运行 Camoufox 实例时发生未预料的严重错误: {e}") 263 | return 264 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import multiprocessing 4 | import signal 5 | import sys 6 | import time 7 | 8 | from browser.instance import run_browser_instance 9 | from utils.logger import setup_logging 10 | from utils.paths import cookies_dir, logs_dir 11 | from utils.cookie_manager import CookieManager 12 | from utils.common import clean_env_value, ensure_dir 13 | 14 | # 全局变量 15 | app_running = False 16 | flask_app = None 17 | # 使用 multiprocessing.Event 实现跨进程通信 18 | shutdown_event = multiprocessing.Event() 19 | 20 | 21 | class ProcessManager: 22 | """进程管理器,负责跟踪和管理浏览器进程""" 23 | 24 | def __init__(self): 25 | self.processes = {} # {process_id: process_info} 26 | self.lock = threading.RLock() 27 | ensure_dir(logs_dir()) 28 | self.logger = setup_logging(str(logs_dir() / 'app.log'), prefix="manager") 29 | 30 | def add_process(self, process, config=None): 31 | """添加进程到管理器""" 32 | with self.lock: 33 | pid = process.pid if process and hasattr(process, 'pid') else None 34 | 35 | # 允许添加PID为None的进程(可能还在启动中),但会记录这个情况 36 | if pid is None: 37 | # 使用临时ID作为key,等获得真实PID后再更新 38 | temp_id = f"temp_{len(self.processes)}" 39 | self.logger.warning(f"进程PID暂时为None,使用临时ID {temp_id}") 40 | else: 41 | temp_id = pid 42 | 43 | process_info = { 44 | 'process': process, 45 | 'config': config, 46 | 'pid': pid, 47 | 'is_alive': True, 48 | 'start_time': time.time() 49 | } 50 | self.processes[temp_id] = process_info 51 | 52 | def update_temp_pids(self): 53 | """更新临时PID为真实PID""" 54 | with self.lock: 55 | temp_ids = [k for k in self.processes.keys() if isinstance(k, str) and k.startswith("temp_")] 56 | for temp_id in temp_ids: 57 | process_info = self.processes[temp_id] 58 | process = process_info['process'] 59 | 60 | if process and hasattr(process, 'pid') and process.pid is not None: 61 | # 更新为真实PID 62 | self.processes[process.pid] = process_info 63 | del self.processes[temp_id] 64 | process_info['pid'] = process.pid 65 | 66 | def remove_process(self, pid): 67 | """从管理器中移除进程""" 68 | with self.lock: 69 | if pid in self.processes: 70 | del self.processes[pid] 71 | 72 | def get_alive_processes(self): 73 | """获取所有存活进程""" 74 | with self.lock: 75 | # 首先尝试更新临时PID 76 | self.update_temp_pids() 77 | 78 | alive = [] 79 | dead_pids = [] 80 | 81 | for pid, info in self.processes.items(): 82 | process = info['process'] 83 | try: 84 | # 检查进程是否真实存在且是子进程 85 | if process and hasattr(process, 'is_alive') and process.is_alive(): 86 | alive.append(process) 87 | else: 88 | dead_pids.append(pid) 89 | except (ValueError, ProcessLookupError) as e: 90 | # 进程已经不存在 91 | dead_pids.append(pid) 92 | self.logger.warning(f"进程 {pid} 检查时出错: {e}") 93 | 94 | # 清理死进程记录 95 | for pid in dead_pids: 96 | self.remove_process(pid) 97 | 98 | return alive 99 | 100 | def terminate_all(self, timeout=10): 101 | """优雅地终止所有进程""" 102 | with self.lock: 103 | # logger = setup_logging(str(logs_dir() / 'app.log'), prefix="signal") 104 | # 直接使用 self.logger,避免重复 setup_logging 105 | 106 | # 首先更新临时PID 107 | self.update_temp_pids() 108 | 109 | if not self.processes: 110 | self.logger.info("没有活跃的进程需要关闭") 111 | return 112 | 113 | self.logger.info(f"开始关闭 {len(self.processes)} 个进程...") 114 | 115 | # 第一阶段:发送SIGTERM信号 116 | active_pids = [] 117 | for pid, info in list(self.processes.items()): 118 | process = info['process'] 119 | try: 120 | # 检查进程对象是否有效且进程存活 121 | if process and hasattr(process, 'is_alive') and process.is_alive() and pid is not None: 122 | self.logger.info(f"发送SIGTERM给进程 {pid} (运行时长: {time.time() - info['start_time']:.1f}秒)") 123 | process.terminate() 124 | active_pids.append(pid) 125 | else: 126 | self.logger.info(f"进程 {pid if pid is not None else 'None'} 已经停止或无效") 127 | except (ValueError, ProcessLookupError, AttributeError) as e: 128 | self.logger.warning(f"进程 {pid if pid is not None else 'None'} 访问出错: {e}") 129 | 130 | if not active_pids: 131 | self.logger.info("所有进程已经停止") 132 | return 133 | 134 | # 第二阶段:等待进程退出 135 | self.logger.info(f"等待 {len(active_pids)} 个进程优雅退出...") 136 | start_wait = time.time() 137 | while time.time() - start_wait < 5: # 最多等待5秒 138 | still_alive = [] 139 | for pid in active_pids: 140 | if pid in self.processes: 141 | process = self.processes[pid]['process'] 142 | try: 143 | if process and hasattr(process, 'is_alive') and process.is_alive(): 144 | still_alive.append(pid) 145 | except (ValueError, ProcessLookupError, AttributeError): 146 | pass 147 | if not still_alive: 148 | self.logger.info("所有进程已优雅退出") 149 | return 150 | time.sleep(0.5) 151 | 152 | self.logger.info(f"仍有 {len(still_alive)} 个进程在运行,准备强制关闭...") 153 | 154 | # 第三阶段:强制杀死仍在运行的进程 155 | for pid in active_pids: 156 | if pid in self.processes and pid is not None: 157 | process = self.processes[pid]['process'] 158 | try: 159 | if process and hasattr(process, 'is_alive') and process.is_alive(): 160 | self.logger.warning(f"进程 {pid} 未响应SIGTERM,强制终止") 161 | process.kill() 162 | except (ValueError, ProcessLookupError, AttributeError) as e: 163 | self.logger.info(f"进程 {pid} 已终止: {e}") 164 | 165 | self.logger.info("所有进程关闭完成") 166 | 167 | def get_count(self): 168 | """获取管理的进程总数""" 169 | with self.lock: 170 | return len(self.processes) 171 | 172 | def get_alive_count(self): 173 | """获取存活进程数""" 174 | return len(self.get_alive_processes()) 175 | 176 | 177 | # 全局进程管理器 178 | process_manager = ProcessManager() 179 | 180 | 181 | def load_instance_configurations(logger): 182 | """ 183 | 使用CookieManager解析环境变量和Cookies目录,为每个Cookie来源创建独立的浏览器实例配置。 184 | """ 185 | # 1. 读取所有实例共享的URL 186 | shared_url = clean_env_value(os.getenv("CAMOUFOX_INSTANCE_URL")) 187 | if not shared_url: 188 | logger.error("错误: 缺少环境变量 CAMOUFOX_INSTANCE_URL。所有实例需要一个共享的目标URL") 189 | return None, None 190 | 191 | # 2. 读取全局设置 192 | global_settings = { 193 | "headless": clean_env_value(os.getenv("CAMOUFOX_HEADLESS")) or "virtual", 194 | "url": shared_url # 所有实例都使用这个URL 195 | } 196 | 197 | proxy_value = clean_env_value(os.getenv("CAMOUFOX_PROXY")) 198 | if proxy_value: 199 | global_settings["proxy"] = proxy_value 200 | 201 | # 3. 使用CookieManager检测所有Cookie来源 202 | cookie_manager = CookieManager(logger) 203 | sources = cookie_manager.detect_all_sources() 204 | 205 | # 检查是否有任何Cookie来源 206 | if not sources: 207 | logger.error("错误: 未找到任何Cookie来源(既没有JSON文件,也没有环境变量Cookie)") 208 | return None, None 209 | 210 | # 4. 为每个Cookie来源创建实例配置 211 | instances = [] 212 | for source in sources: 213 | if source.type == "file": 214 | instances.append({ 215 | "cookie_file": source.identifier, 216 | "cookie_source": source 217 | }) 218 | elif source.type == "env_var": 219 | # 从环境变量名中提取索引,如 "USER_COOKIE_1" -> 1 220 | env_index = source.identifier.split("_")[-1] 221 | instances.append({ 222 | "cookie_file": None, 223 | "env_cookie_index": int(env_index), 224 | "cookie_source": source 225 | }) 226 | 227 | logger.info(f"将启动 {len(instances)} 个浏览器实例") 228 | 229 | return global_settings, instances 230 | 231 | def start_browser_instances(run_mode="standalone"): 232 | """启动浏览器实例的核心逻辑""" 233 | global app_running, process_manager, shutdown_event 234 | 235 | log_dir = logs_dir() 236 | logger = setup_logging(str(log_dir / 'app.log')) 237 | logger.info("---------------------Camoufox 实例管理器开始启动---------------------") 238 | start_delay = int(os.getenv("INSTANCE_START_DELAY", "30")) 239 | logger.info(f"运行模式: {run_mode}; 实例启动间隔: {start_delay} 秒") 240 | 241 | global_settings, instance_profiles = load_instance_configurations(logger) 242 | if not instance_profiles: 243 | logger.error("错误: 环境变量中未找到任何实例配置") 244 | return 245 | 246 | for i, profile in enumerate(instance_profiles, 1): 247 | if not app_running: 248 | break 249 | 250 | final_config = global_settings.copy() 251 | final_config.update(profile) 252 | 253 | if 'url' not in final_config: 254 | logger.warning(f"警告: 跳过一个无效的配置项 (缺少 url): {profile}") 255 | continue 256 | 257 | cookie_source = final_config.get('cookie_source') 258 | 259 | if cookie_source: 260 | if cookie_source.type == "file": 261 | logger.info( 262 | f"正在启动第 {i}/{len(instance_profiles)} 个浏览器实例 (file: {cookie_source.display_name})..." 263 | ) 264 | elif cookie_source.type == "env_var": 265 | logger.info( 266 | f"正在启动第 {i}/{len(instance_profiles)} 个浏览器实例 (env: {cookie_source.display_name})..." 267 | ) 268 | else: 269 | logger.error(f"错误: 配置中缺少cookie_source对象") 270 | continue 271 | 272 | # 传递 shutdown_event 给子进程 273 | process = multiprocessing.Process(target=run_browser_instance, args=(final_config, shutdown_event)) 274 | process.start() 275 | # 等待一小段时间让进程获得PID,然后再添加到管理器 276 | time.sleep(0.1) 277 | process_manager.add_process(process, final_config) 278 | 279 | # 等待配置的时间,避免并发启动导致的高CPU占用 280 | # 即使是最后一个实例,也等待一段时间让其初始化,然后再进入主循环 281 | time.sleep(start_delay) 282 | 283 | # 等待所有进程 284 | previous_count = None 285 | last_log_time = 0 286 | try: 287 | while app_running: 288 | alive_processes = process_manager.get_alive_processes() 289 | current_count = len(alive_processes) 290 | 291 | # 仅在数量变化或间隔一段时间后再记录,避免过于频繁的日志 292 | now = time.time() 293 | if current_count != previous_count or now - last_log_time >= 600: 294 | logger.info(f"当前运行的浏览器实例数: {current_count}") 295 | previous_count = current_count 296 | last_log_time = now 297 | 298 | if not alive_processes: 299 | logger.info("所有浏览器进程已结束,主进程即将退出") 300 | break 301 | 302 | # 等待进程并清理死进程 303 | for process in alive_processes: 304 | try: 305 | process.join(timeout=1) 306 | except: 307 | pass 308 | 309 | time.sleep(1) 310 | except KeyboardInterrupt: 311 | logger.info("捕获到键盘中断信号,等待信号处理器完成关闭...") 312 | # 不在这里关闭进程,让信号处理器统一处理 313 | pass 314 | 315 | # 确保在所有进程结束后退出 316 | logger.info("浏览器实例管理器运行结束") 317 | 318 | def run_standalone_mode(): 319 | """独立模式""" 320 | global app_running 321 | app_running = True 322 | 323 | start_browser_instances(run_mode="standalone") 324 | 325 | def run_server_mode(): 326 | """服务器模式""" 327 | global app_running, flask_app 328 | 329 | log_dir = logs_dir() 330 | server_logger = setup_logging(str(log_dir / 'app.log'), prefix="server") 331 | 332 | # 动态导入 Flask(只在需要时) 333 | try: 334 | from flask import Flask, jsonify 335 | flask_app = Flask(__name__) 336 | except ImportError: 337 | server_logger.error("错误: 服务器模式需要 Flask,请安装: pip install flask") 338 | return 339 | 340 | app_running = True 341 | 342 | # 在后台线程中启动浏览器实例 343 | browser_thread = threading.Thread(target=lambda: start_browser_instances(run_mode="server"), daemon=True) 344 | browser_thread.start() 345 | 346 | # 定义路由 347 | @flask_app.route('/health') 348 | def health_check(): 349 | """健康检查端点""" 350 | global process_manager 351 | running_count = process_manager.get_alive_count() 352 | total_count = process_manager.get_count() 353 | return jsonify({ 354 | 'status': 'healthy', 355 | 'browser_instances': total_count, 356 | 'running_instances': running_count, 357 | 'message': f'Application is running with {running_count} active browser instances' 358 | }) 359 | 360 | @flask_app.route('/') 361 | def index(): 362 | """主页端点""" 363 | global process_manager 364 | running_count = process_manager.get_alive_count() 365 | total_count = process_manager.get_count() 366 | return jsonify({ 367 | 'status': 'running', 368 | 'browser_instances': total_count, 369 | 'running_instances': running_count, 370 | 'run_mode': 'server', 371 | 'message': 'Camoufox Browser Automation is running in server mode' 372 | }) 373 | 374 | # 禁用 Flask 的默认日志 375 | import logging 376 | log = logging.getLogger('werkzeug') 377 | log.setLevel(logging.ERROR) 378 | 379 | # 启动 Flask 服务器 380 | try: 381 | flask_app.run(host='0.0.0.0', port=7860, debug=False) 382 | except KeyboardInterrupt: 383 | server_logger.info("服务器正在关闭...") 384 | 385 | def signal_handler(signum, frame): 386 | """统一的信号处理器 - 只有主进程应该执行这个逻辑""" 387 | global app_running, process_manager, shutdown_event 388 | 389 | # 立即设置日志,确保能看到后续信息 390 | logger = setup_logging(str(logs_dir() / 'app.log'), prefix="signal") 391 | logger.info(f"接收到信号 {signum},开始处理...") 392 | 393 | # 检查是否是主进程,防止子进程执行关闭逻辑 394 | current_pid = os.getpid() 395 | 396 | # 使用一个简单的方法来判断:如果是子进程,通常没有全局变量 process_manager 的控制权 397 | # 或者通过判断 multiprocessing.current_process().name 398 | if multiprocessing.current_process().name != 'MainProcess': 399 | # 子进程接收到信号,通常应该由主进程来管理,或者子进程会因为主进程发送的SIGTERM而终止 400 | # 这里我们选择忽略,让主进程通过terminate来管理,或者子进程通过shutdown_event来退出 401 | logger.info(f"子进程 {current_pid} 接收到信号 {signum},忽略主进程信号处理逻辑") 402 | return 403 | 404 | logger.info(f"主进程 {current_pid} 接收到信号 {signum},正在关闭应用...") 405 | 406 | # 1. 立即设置全局标志,阻止新的进程创建 407 | app_running = False 408 | 409 | # 2. 设置跨进程关闭事件,通知所有子进程优雅退出 410 | try: 411 | shutdown_event.set() 412 | logger.info("已设置全局关闭事件 (shutdown_event)") 413 | except Exception as e: 414 | logger.error(f"设置关闭事件时发生错误: {e}") 415 | 416 | # 3. 调用进程管理器的优雅终止方法 417 | try: 418 | process_manager.terminate_all(timeout=10) 419 | except Exception as e: 420 | logger.error(f"调用 terminate_all 时发生错误: {e}") 421 | 422 | logger.info("应用关闭流程结束,主进程退出") 423 | sys.exit(0) 424 | 425 | def main(): 426 | """主入口函数""" 427 | # 初始化必要的目录 428 | ensure_dir(logs_dir()) 429 | ensure_dir(cookies_dir()) 430 | 431 | # 注册信号处理器 - 添加更多信号的捕获 432 | signal.signal(signal.SIGTERM, signal_handler) 433 | signal.signal(signal.SIGINT, signal_handler) 434 | # 在某些环境中可能还有其他信号 435 | try: 436 | signal.signal(signal.SIGQUIT, signal_handler) 437 | except (ValueError, AttributeError): 438 | pass 439 | try: 440 | signal.signal(signal.SIGHUP, signal_handler) 441 | except (ValueError, AttributeError): 442 | pass 443 | 444 | # 检查运行模式环境变量 445 | hg_mode = os.getenv('HG', '').lower() 446 | 447 | if hg_mode == 'true': 448 | run_server_mode() 449 | else: 450 | run_standalone_mode() 451 | 452 | if __name__ == "__main__": 453 | multiprocessing.freeze_support() 454 | main() 455 | --------------------------------------------------------------------------------