├── .gitignore ├── camoufox-py ├── cookies │ └── user1_cookie.json.example ├── requirements.txt ├── .gitignore ├── config.yaml.example ├── utils │ ├── logger.py │ └── cookie_handler.py ├── browser │ ├── navigation.py │ └── instance.py ├── run_camoufox.py └── readme.md ├── img ├── Cookie_Editor.png ├── running_example.gif ├── Containers_Stats.png ├── Global_Cookie_Manager.png └── Global_Cookie_Manager2.png ├── golang ├── go.mod ├── go.sum ├── .gitignore └── main.go ├── docker-compose.yml ├── supervisord.conf ├── Dockerfile └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /camoufox-py/cookies/user1_cookie.json.example: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/Cookie_Editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cliouo/aistudio-build-proxy-all/HEAD/img/Cookie_Editor.png -------------------------------------------------------------------------------- /img/running_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cliouo/aistudio-build-proxy-all/HEAD/img/running_example.gif -------------------------------------------------------------------------------- /img/Containers_Stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cliouo/aistudio-build-proxy-all/HEAD/img/Containers_Stats.png -------------------------------------------------------------------------------- /img/Global_Cookie_Manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cliouo/aistudio-build-proxy-all/HEAD/img/Global_Cookie_Manager.png -------------------------------------------------------------------------------- /img/Global_Cookie_Manager2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cliouo/aistudio-build-proxy-all/HEAD/img/Global_Cookie_Manager2.png -------------------------------------------------------------------------------- /golang/go.mod: -------------------------------------------------------------------------------- 1 | module wsproxy 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/google/uuid v1.6.0 // indirect 7 | github.com/gorilla/websocket v1.5.3 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /golang/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 4 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | camoufox: 3 | build: . 4 | ports: 5 | - "5345:5345" 6 | environment: 7 | # 设置一个你最终Gemini服务的API密钥 8 | - AUTH_API_KEY=your_set_api_key_here 9 | volumes: 10 | - ./camoufox-py/config.yaml:/app/config.yaml 11 | - ./camoufox-py/cookies:/app/cookies 12 | - ./camoufox-py/logs:/app/logs 13 | restart: always -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true ; 关键!让 Supervisor 在前台运行,以保持容器存活 3 | 4 | [program:python_app] 5 | command=python run_camoufox.py config.yaml 6 | directory=/app ; 指定程序运行的目录 7 | autostart=true 8 | autorestart=true 9 | stdout_logfile=/dev/stdout ; 将标准输出重定向到容器的 stdout 10 | stdout_logfile_maxbytes=0 ; 禁用日志轮转 11 | stderr_logfile=/dev/stderr ; 将标准错误重定向到容器的 stderr 12 | stderr_logfile_maxbytes=0 ; 禁用日志轮转 13 | 14 | [program:go_app] 15 | command=/app/go_app_binary ; 这是我们编译后的 Go 程序路径 16 | directory=/app ; Go 程序可以在 app 根目录运行,或指定其自己的目录 17 | autostart=true 18 | autorestart=true 19 | stdout_logfile=/dev/stdout 20 | stdout_logfile_maxbytes=0 21 | stderr_logfile=/dev/stderr 22 | stderr_logfile_maxbytes=0 -------------------------------------------------------------------------------- /camoufox-py/requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML==6.0.2 2 | camoufox[geoip]==0.4.11 3 | aiohappyeyeballs==2.6.1 4 | aiohttp==3.12.13 5 | aiosignal==1.3.2 6 | attrs==25.3.0 7 | browserforge==1.2.3 8 | certifi==2025.6.15 9 | charset-normalizer==3.4.2 10 | click==8.2.1 11 | frozenlist==1.7.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 -------------------------------------------------------------------------------- /camoufox-py/.gitignore: -------------------------------------------------------------------------------- 1 | # Python Bytecode 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | 7 | # Operating System Files 8 | .DS_Store 9 | Thumbs.db 10 | 11 | # Virtual Environment 12 | venv/ 13 | env/ 14 | .venv/ 15 | 16 | # Editor/IDE specific (customize as needed) 17 | # IntelliJ IDEA / PyCharm 18 | .idea/ 19 | # IntelliJ IDEA module files 20 | *.iml 21 | # VS Code 22 | .vscode/ 23 | # Sublime Text 24 | *.sublime-project 25 | # Sublime Text 26 | *.sublime-workspace 27 | 28 | # Logs and databases 29 | *.log 30 | *.sqlite3 31 | 32 | # Distribution and packaging 33 | dist/ 34 | build/ 35 | *.egg-info/ 36 | .eggs/ 37 | 38 | # Testing 39 | .pytest_cache/ 40 | htmlcov/ 41 | .coverage 42 | 43 | # Jupyter Notebook (if applicable) 44 | .ipynb_checkpoints/ 45 | 46 | # config files 47 | config.yaml 48 | 49 | # cookies/ 50 | cookies/*.json 51 | 52 | # logs 53 | logs/ -------------------------------------------------------------------------------- /camoufox-py/config.yaml.example: -------------------------------------------------------------------------------- 1 | # 全局设置,将应用于所有实例,除非在特定实例中被覆盖 2 | global_settings: 3 | # 无头模式: true (标准无头), false (有头), or 'virtual' (虚拟显示, Linux使用) 4 | headless: virtual 5 | 6 | # 全局代理 (可选)。如果大多数实例都使用同一个代理,请在这里设置。 7 | # 格式: "http://user:pass@host:port" 8 | proxy: null 9 | 10 | # 要并发运行的浏览器实例列表 11 | instances: 12 | # 实例 1: 使用全局设置 13 | - cookie_file: "user1_cookie.json" 14 | # 自动连接本地websocket的Build链接 15 | url: "https://aistudio.google.com/apps/drive/1YL729RgvbQc4Zw3z47gghil0abBqg3Rh" 16 | 17 | # 实例 2: 覆盖全局设置,使用自己的代理并以有头模式运行 18 | # - cookie_file: "user2_cookie.json" 19 | # # 实例2想要打开的Build demo的链接, 一般复制上方一样的即可. 20 | # url: "https://some-other-website.com/" 21 | # headless: false # 覆盖全局设置,此实例将显示浏览器窗口, 可选 22 | # proxy: "http://user:pass@specific-proxy.com:9999" # 使用特定代理, 可选 23 | 24 | # 实例 3: 另一个使用全局设置的例子 25 | # - cookie_file: "user3_cookie.json" 26 | # url: "https://some-other-website.com/" 27 | # 此处未指定 headless 或 proxy,因此它将使用 global_settings 中的值 -------------------------------------------------------------------------------- /camoufox-py/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def setup_logging(log_file, prefix=None, level=logging.INFO): 4 | """ 5 | 配置日志记录器,使其输出到文件和控制台。 6 | 支持一个可选的前缀,用于标识日志来源。 7 | 8 | 每次调用都会重新配置处理器,以适应多进程环境。 9 | 10 | :param log_file: 日志文件的路径。 11 | :param prefix: (可选) 要添加到每条日志消息开头的字符串前缀。 12 | :param level: 日志级别。 13 | """ 14 | logger = logging.getLogger('my_app_logger') 15 | logger.setLevel(level) 16 | 17 | if logger.hasHandlers(): 18 | logger.handlers.clear() 19 | 20 | base_format = '%(asctime)s - %(process)d - %(levelname)s - %(message)s' 21 | 22 | if prefix: 23 | log_format = f'%(asctime)s - %(process)d - %(levelname)s - {prefix} - %(message)s' 24 | else: 25 | log_format = base_format 26 | 27 | fh = logging.FileHandler(log_file) 28 | fh.setLevel(level) 29 | 30 | ch = logging.StreamHandler() 31 | ch.setLevel(level) 32 | 33 | formatter = logging.Formatter(log_format) 34 | fh.setFormatter(formatter) 35 | ch.setFormatter(formatter) 36 | 37 | logger.addHandler(fh) 38 | logger.addHandler(ch) 39 | 40 | logger.propagate = False 41 | 42 | return logger -------------------------------------------------------------------------------- /golang/.gitignore: -------------------------------------------------------------------------------- 1 | # Go 编译产物 2 | ## Windows 可执行文件 3 | *.exe 4 | ## Windows 动态链接库 5 | *.dll 6 | ## Linux/macOS 共享库 7 | *.so 8 | ## macOS 动态库 9 | *.dylib 10 | ## Go 测试缓存 11 | *.test 12 | ## Go 性能分析文件 13 | *.prof 14 | 15 | # Go 模块相关 16 | ## 模块校验和文件(如果团队共享,可以考虑不忽略) 17 | # go.sum 18 | ## Go 模块的本地副本(如果团队共享,可以考虑不忽略) 19 | vendor/ 20 | 21 | # GoLand IDE 特定文件 22 | ## GoLand 项目配置目录 23 | .idea/ 24 | ## GoLand 模块文件 25 | *.iml 26 | ## GoLand 项目文件 27 | *.ipr 28 | ## GoLand 工作区文件 29 | *.iws 30 | ## 编译输出目录,GoLand 可能会生成 31 | out/ 32 | ## macOS 隐藏文件 33 | .DS_Store 34 | 35 | # 依赖管理工具 36 | # 如果使用 vendoring,则 vendor/ 目录通常需要被版本控制。 37 | # 如果不使用 vendoring 而是每次构建都下载依赖,则可以忽略 vendor/。 38 | # vendoring 是 Go 早期常用的方式,现在 Go Modules 更常见。 39 | # 如果团队约定不提交 vendor/,可以取消注释下面一行 40 | # vendor/ 41 | 42 | # 编译和运行时文件 43 | ## 编译后的可执行文件目录 44 | bin/ 45 | # 编译后的包文件目录(Go 1.10 之前常见,现在多在 go build 时直接生成) 46 | pkg/ 47 | # 日志文件 48 | *.log 49 | # 临时文件 50 | tmp/ 51 | 52 | # 包管理工具(如果使用其他) 53 | # dep 54 | Gopkg.lock 55 | Gopkg.toml 56 | 57 | # glide 58 | glide.lock 59 | 60 | # 其他常见文件 61 | ## 环境变量文件 62 | .env 63 | .env.* 64 | ## 私钥文件 65 | *.pem 66 | ## 密钥文件 67 | *.key 68 | ## 证书文件 69 | *.crt 70 | ## Java KeyStore (有时会出现在Go项目中,比如某些Java-Go互操作) 71 | *.jks 72 | ## PKCS#12 (有时会出现在Go项目中) 73 | *.p12 74 | ## 密钥存储 75 | *.keystore 76 | ## 如果你的Go项目也包含前端代码 77 | node_modules/ 78 | ## Go 测试覆盖率文件 79 | coverage.out 80 | ## Go CPU/Memory 性能分析文件 81 | profile.pprof 82 | 83 | # macOS 特定 84 | .DS_Store 85 | 86 | # Windows 特定 87 | Thumbs.db -------------------------------------------------------------------------------- /camoufox-py/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 | allowed_keys = {'name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'} 7 | 8 | for cookie in cookies_from_editor: 9 | pw_cookie = {} 10 | for key in ['name', 'value', 'domain', 'path', 'httpOnly', 'secure']: 11 | if key in cookie: 12 | pw_cookie[key] = cookie[key] 13 | if cookie.get('session', False): 14 | pw_cookie['expires'] = -1 15 | elif 'expirationDate' in cookie: 16 | if cookie['expirationDate'] is not None: 17 | pw_cookie['expires'] = int(cookie['expirationDate']) 18 | else: 19 | pw_cookie['expires'] = -1 20 | 21 | if 'sameSite' in cookie: 22 | same_site_value = str(cookie['sameSite']).lower() 23 | if same_site_value == 'no_restriction': 24 | pw_cookie['sameSite'] = 'None' 25 | elif same_site_value in ['lax', 'strict']: 26 | pw_cookie['sameSite'] = same_site_value.capitalize() 27 | elif same_site_value == 'unspecified': 28 | pw_cookie['sameSite'] = 'Lax' 29 | 30 | if all(key in pw_cookie for key in ['name', 'value', 'domain', 'path']): 31 | playwright_cookies.append(pw_cookie) 32 | else: 33 | if logger: 34 | logger.warning(f"跳过一个格式不完整的 cookie: {cookie}") 35 | 36 | return playwright_cookies -------------------------------------------------------------------------------- /camoufox-py/browser/navigation.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | from playwright.sync_api import Page, expect 4 | 5 | def handle_untrusted_dialog(page: Page, logger=None): 6 | """ 7 | 检查并处理 "Last modified by..." 的弹窗。 8 | 如果弹窗出现,则点击 "OK" 按钮。 9 | """ 10 | ok_button_locator = page.get_by_role("button", name="OK") 11 | 12 | try: 13 | if ok_button_locator.is_visible(timeout=10000): # 等待最多10秒 14 | logger.info(f"检测到弹窗,正在点击 'OK' 按钮...") 15 | 16 | ok_button_locator.click(force=True) 17 | logger.info(f"'OK' 按钮已点击。") 18 | expect(ok_button_locator).to_be_hidden(timeout=1000) 19 | logger.info(f"弹窗已确认关闭。") 20 | else: 21 | logger.info(f"在10秒内未检测到弹窗,继续执行...") 22 | except Exception as e: 23 | logger.info(f"检查弹窗时发生意外:{e},将继续执行...") 24 | 25 | def handle_successful_navigation(page: Page, logger, cookie_file_config): 26 | """ 27 | 在成功导航到目标页面后,执行后续操作(处理弹窗、截图、保持运行)。 28 | """ 29 | logger.info("已成功到达目标页面。") 30 | page.click('body') # 给予页面焦点 31 | 32 | # 检查并处理 "Last modified by..." 的弹窗 33 | handle_untrusted_dialog(page, logger=logger) 34 | 35 | # 等待页面加载和渲染后截图 36 | logger.info("等待15秒以便页面完全渲染...") 37 | time.sleep(15) 38 | 39 | screenshot_dir = 'logs' 40 | screenshot_filename = os.path.join(screenshot_dir, f"screenshot_{cookie_file_config}_{int(time.time())}.png") 41 | try: 42 | page.screenshot(path=screenshot_filename, full_page=True) 43 | logger.info(f"已截屏到: {screenshot_filename}") 44 | except Exception as e: 45 | logger.error(f"截屏时出错: {e}") 46 | 47 | logger.info("实例将保持运行状态。每10秒点击一次页面以保持活动。") 48 | while True: 49 | try: 50 | page.click('body') 51 | time.sleep(10) 52 | except Exception as e: 53 | logger.error(f"在保持活动循环中出错: {e}") 54 | break # 如果页面关闭或出错,则退出循环 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # --- Stage 1: Go Builder --- 2 | # 使用官方的 Go 镜像作为构建环境 3 | FROM golang:1.22-alpine AS builder-go 4 | 5 | # 设置工作目录 6 | WORKDIR /src 7 | 8 | # 复制 Go 项目的模块文件并下载依赖 9 | COPY golang/go.mod ./ 10 | RUN go mod download 11 | 12 | # 复制 Go 项目的源代码 13 | COPY golang/ . 14 | 15 | # 编译 Go 应用。CGO_ENABLED=0 创建一个静态链接的二进制文件,更适合容器环境 16 | # -o 指定输出文件名和路径 17 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /go_app_binary . 18 | 19 | 20 | # --- Stage 2: Final Image --- 21 | # 使用你原来的 Python 基础镜像 22 | FROM python:3.11-slim 23 | 24 | # 设置主工作目录 25 | WORKDIR /app 26 | 27 | # 安装 Supervisor 和你的 Python 依赖 28 | # 将 supervisor 添加到 apt-get install 列表中 29 | RUN apt-get update && apt-get install -y --no-install-recommends \ 30 | supervisor \ 31 | libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 \ 32 | libnspr4 libnss3 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 \ 33 | libxext6 libxfixes3 libxrandr2 libxrender1 libxtst6 ca-certificates \ 34 | fonts-liberation libasound2 libpangocairo-1.0-0 libpango-1.0-0 libu2f-udev xvfb \ 35 | && rm -rf /var/lib/apt/lists/* 36 | 37 | # 从 Go 构建阶段复制编译好的二进制文件到最终镜像中 38 | COPY --from=builder-go /go_app_binary . 39 | 40 | # 复制 Python 项目的 requirements.txt 并安装依赖 41 | COPY camoufox-py/requirements.txt ./camoufox-py/requirements.txt 42 | RUN pip install --no-cache-dir -r ./camoufox-py/requirements.txt 43 | 44 | # 运行 camoufox fetch 45 | # 注意:如果 camoufox 需要在项目根目录运行,需要调整 WORKDIR 或命令路径 46 | RUN camoufox fetch 47 | 48 | # 复制 Python 项目的所有文件 49 | COPY camoufox-py/ . 50 | # 为了保持目录结构清晰,我们把它放到 camoufox-py 子目录中 51 | # WORKDIR /app/camoufox-py 52 | # COPY camoufox-py/ . 53 | # WORKDIR /app 54 | # 上面的注释是一种替代方案,但当前方案更简单 55 | 56 | # 复制 Supervisor 的配置文件 57 | COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf 58 | 59 | # 容器启动时,运行 Supervisor 60 | # 它会根据 supervisord.conf 的配置来启动你的 Python 和 Go 应用 61 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Docker版 aistudio-build-proxy 2 | 集成 无头浏览器 + Websocket代理 3 | 4 | 问题: ~~当前cookie导出方式导出的cookie可能时效较短.~~ 指纹浏览器导出cookie很稳 5 | 6 | ## 使用方法: 7 | 1. 导出Cookie到项目`camoufox-py/cookies/`文件夹下 8 | 9 | #### 更稳定的方法: 10 | 用指纹浏览器开个新窗口登录 google, 然后到指纹浏览器`编辑窗口`,把 cookie 复制出来用,然后删除浏览器窗口就行,这个 cookie 超稳!!! 11 | 12 |
13 | 旧方法(不再推荐):cookie很容易因为主账户的个人使用活动导致导出的cookie失效。 14 | (1) 安装导出Cookie的插件, 这里推荐 [Global Cookie Manager浏览器插件](https://chromewebstore.google.com/detail/global-cookie-manager/bgffajlinmbdcileomeilpihjdgjiphb) 15 | 16 | (2) 使用插件导出浏览器内所有涉及`google`的Cookie 17 | 18 | 导出Cookie示例图: 19 | ![Global Cookie Manager](/img/Global_Cookie_Manager.png) 20 | ![Global Cookie Manager2](/img/Global_Cookie_Manager2.png) 21 | 22 | (3) 粘贴到项目 `camoufox-py/cookies/[自己命名].json` 中 23 |
24 | 2. 修改浏览器配置`camoufox-py/config.yaml` 25 | 26 | (1) 在`camoufox-py`下, 将示例配置文件`config.yaml.example`, 重命名为 `config.yaml`, 然后修改`config.yaml` 27 | 28 | (2) 实例 1 的`cookie_file` 填入自己创建 cookie文件名 29 | 30 | (3) (可选项) `url` 默认为项目提供的AIStudio Build 链接(会连接本地5345的ws服务), 可修改为自己的 31 | 32 | (4) (可选项) proxy配置指定浏览器使用的代理服务器 33 | 34 | 3. 修改`docker-compose.yml` 35 | 36 | (1) 自己设置一个 `AUTH_API_KEY` , 最后自己调 gemini 时要使用该 apikey 调用, 不支持无 key 37 | 4. 在项目根目录, 通过`docker-compose.yml`启动Docker容器 38 | 39 | (1) 运行命令启动容器 40 | ```bash 41 | docker compose up -d 42 | ``` 43 | 44 | 5. 等待一段时间后, 通过 http://127.0.0.1:5345 和 自己设置的`AUTH_API_KEY`使用. 45 | 46 | 注1: 由于只是反代Gemini, 因此[接口文档](https://ai.google.dev/api)和Gemini API: `https://generativelanguage.googleapis.com`端点 完全相同, 使用只需将该url替换为`http://127.0.0.1:5345`即可, 支持原生Google Search、代码执行等工具。 47 | 48 | 注2: Cherry Studio等工具使用时, 务必记得选择提供商为 `Gemini`。 49 | 50 | ## 日志查看 51 | 1. docker日志 52 | ```bash 53 | docker logs [容器名] 54 | ``` 55 | 2. 单独查看camoufox-py日志 56 | 57 | camoufox-py/logs/app.log 58 | 59 | 且每次运行, logs下会有一张截图 60 | 61 | ## 容器资源占用: 62 | ![Containers Stats](/img/Containers_Stats.png) 63 | 本图为仅使用一个cookie的占用 64 | 65 | ## 运行效果示例: 66 | 快速模型首字吐出很快,表明该代理网络较好,本程序到google链路通畅 67 | 68 | ![running example](/img/running_example.gif) 69 | 70 | 如果使用推理模型慢,那就是 aistudio 的问题, 和本项目没关系 71 | -------------------------------------------------------------------------------- /camoufox-py/run_camoufox.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import multiprocessing 3 | import os 4 | import yaml 5 | 6 | from browser.instance import run_browser_instance 7 | from utils.logger import setup_logging 8 | 9 | def main(): 10 | """ 11 | 主函数,读取 YAML 配置并为每个实例启动一个独立的浏览器进程。 12 | """ 13 | log_dir = 'logs' 14 | os.makedirs(log_dir, exist_ok=True) 15 | 16 | logger = setup_logging(os.path.join(log_dir, 'app.log')) 17 | 18 | logger.info("---------------------Camoufox 实例管理器开始启动---------------------") 19 | 20 | parser = argparse.ArgumentParser(description="通过 YAML 配置文件并发运行多个 Camoufox 实例。") 21 | parser.add_argument("config_file", help="YAML 配置文件的路径。") 22 | args = parser.parse_args() 23 | 24 | if not os.path.exists(args.config_file): 25 | logger.error(f"错误: 配置文件未找到 at {args.config_file}") 26 | return 27 | 28 | try: 29 | with open(args.config_file, 'r') as f: 30 | config = yaml.safe_load(f) 31 | except Exception as e: 32 | logger.exception(f"读取或解析 YAML 配置文件时出错: {e}") 33 | return 34 | 35 | global_settings = config.get('global_settings', {}) 36 | instance_profiles = config.get('instances', []) 37 | 38 | if not instance_profiles: 39 | logger.error("错误: 在配置文件中没有找到 'instances' 列表。") 40 | return 41 | 42 | processes = [] 43 | for profile in instance_profiles: 44 | final_config = global_settings.copy() 45 | final_config.update(profile) 46 | 47 | if 'cookie_file' not in final_config or 'url' not in final_config: 48 | logger.warning(f"警告: 跳过一个无效的配置项 (缺少 cookie_file 或 url): {profile}") 49 | continue 50 | 51 | process = multiprocessing.Process(target=run_browser_instance, args=(final_config,)) 52 | processes.append(process) 53 | process.start() 54 | 55 | logger.info(f"已成功启动 {len(processes)} 个浏览器实例。按 Ctrl+C 终止所有实例。") 56 | 57 | try: 58 | for process in processes: 59 | process.join() 60 | except KeyboardInterrupt: 61 | logger.info("捕获到 Ctrl+C, 正在终止所有子进程...") 62 | for process in processes: 63 | process.terminate() 64 | process.join() 65 | logger.info("所有进程已终止。") 66 | 67 | if __name__ == "__main__": 68 | multiprocessing.freeze_support() 69 | main() -------------------------------------------------------------------------------- /camoufox-py/readme.md: -------------------------------------------------------------------------------- 1 | # Camoufox 多实例自动化工具 (YAML 配置版) 2 | 3 | 这是一个 Python 脚本,用于使用 [Camoufox](https://camoufox.com/) 库,通过一个简单易读的 `config.yaml` 文件来并发运行多个独立的浏览器实例。 4 | 5 | ## 功能 6 | 7 | * **YAML 集中配置**:所有运行参数(代理、无头模式、实例列表)都集中在单一的 `config.yaml` 文件中,无需命令行参数。 8 | * **全局与局部设置**:可以设置全局配置,并为特定实例覆盖这些配置,提供了极大的灵活性。 9 | * **多实例并发**:通过一个配置文件定义并启动任意数量的浏览器实例。 10 | * **独立进程**:每个浏览器实例都在其自己的进程中运行,确保了稳定性和隔离性。 11 | * **独立 Cookie**:为每个实例指定不同的 Cookie 文件,实现多账户同时在线。 12 | 13 | ## 安装 14 | 15 | ### 1. 先决条件 16 | 17 | * Python 3.8+ 18 | * pip 19 | 20 | ### 2. 安装依赖 21 | 22 | 您需要安装 `camoufox`、`pyyaml`(用于解析配置文件)以及可选的 `geoip`。 23 | 24 | ```bash 25 | pip install -U "camoufox[geoip]" pyyaml 26 | ``` 27 | 28 | 安装包后,您需要下载 Camoufox 浏览器本身: 29 | 30 | ```bash 31 | camoufox fetch 32 | ``` 33 | 34 | ### 3. (可选) 在 Linux 上安装虚拟显示 35 | 36 | 为了在 Linux 上获得最佳的无头浏览体验 (`headless: virtual`),建议安装 `xvfb`。 37 | 38 | ```bash 39 | # 对于 Debian/Ubuntu 40 | sudo apt-get install xvfb 41 | 42 | # 对于 Arch Linux 43 | sudo pacman -S xorg-server-xvfb 44 | ``` 45 | 46 | ## 用法 47 | 48 | ### 1. 准备 Cookie 文件 49 | 50 | 为每个您想运行的账户准备一个 `cookie.json` 文件。例如,`user1_cookie.json`, `user2_cookie.json` 等, 放在cookies/ 文件夹下。 51 | 52 | ### 2. 创建 `config.yaml` 配置文件 53 | 54 | 在项目根目录创建一个名为 `config.yaml` 的文件。这是控制整个脚本行为的核心。 55 | 56 | **`config.yaml` 示例:** 57 | 58 | ```yaml 59 | # 全局设置,将应用于所有实例,除非在特定实例中被覆盖 60 | global_settings: 61 | # 无头模式: true (标准无头), false (有头), or 'virtual' (虚拟显示, 推荐) 62 | headless: virtual 63 | 64 | # 全局代理 (可选)。如果大多数实例都使用同一个代理,请在这里设置。 65 | # 格式: "http://user:pass@host:port" 66 | proxy: null # "http://user:pass@globalproxy.com:8080" 67 | 68 | # 要并发运行的浏览器实例列表 69 | instances: 70 | # 实例 1: 使用全局设置 71 | - cookie_file: "user1_cookie.json" 72 | url: "https://aistudio.google.com/apps/drive/your_project_id_1" 73 | 74 | # 实例 2: 覆盖全局设置,使用自己的代理并以有头模式运行 75 | - cookie_file: "user2_cookie.json" 76 | url: "https://aistudio.google.com/apps/drive/your_project_id_2" 77 | headless: false # 覆盖全局设置,此实例将显示浏览器窗口 78 | proxy: "http://user:pass@specific-proxy.com:9999" # 使用特定代理 79 | 80 | # 实例 3: 另一个使用全局设置的例子 81 | - cookie_file: "user3_cookie.json" 82 | url: "https://some-other-website.com/" 83 | # 此处未指定 headless 或 proxy,因此它将使用 global_settings 中的值 84 | ``` 85 | 86 | ### 3. 运行脚本 87 | 88 | 现在,运行脚本非常简单,只需提供配置文件的路径即可。 89 | 90 | ```bash 91 | python3 run_camoufox.py config.yaml 92 | ``` 93 | 94 | 脚本将会读取 `config.yaml`,并根据您的配置启动所有定义的浏览器实例。 95 | 96 | ### 4. 停止脚本 97 | 98 | 脚本会一直运行,直到您手动停止它。在运行脚本的终端中按 `Ctrl+C`,主程序会捕获到信号并终止所有正在运行的浏览器子进程。 -------------------------------------------------------------------------------- /camoufox-py/browser/instance.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from playwright.sync_api import TimeoutError, Error as PlaywrightError # 导入特定的Playwright异常 4 | from utils.logger import setup_logging 5 | from utils.cookie_handler import convert_cookie_editor_to_playwright 6 | from browser.navigation import handle_successful_navigation 7 | from camoufox.sync_api import Camoufox 8 | 9 | def run_browser_instance(config): 10 | """ 11 | 根据最终合并的配置,启动并管理一个单独的 Camoufox 浏览器实例。 12 | 新增了对导航后URL的检查和处理逻辑,并极大地增强了 page.goto 的错误处理和日志记录。 13 | """ 14 | cookie_file_config = config.get('cookie_file') 15 | cookie_file = os.path.join('cookies', cookie_file_config) 16 | logger = setup_logging(os.path.join('logs', 'app.log'), prefix=f"{cookie_file_config}") 17 | 18 | logger.info(f"尝试加载 Cookie 文件: {cookie_file}") 19 | 20 | expected_url = config.get('url') 21 | proxy = config.get('proxy') 22 | headless_setting = config.get('headless', 'virtual') 23 | 24 | if not cookie_file or not expected_url or not os.path.exists(cookie_file): 25 | logger.error(f"错误: 无效的配置或 Cookie 文件未找到 for {cookie_file}") 26 | return 27 | 28 | try: 29 | with open(cookie_file, 'r') as f: 30 | cookies_from_file = json.load(f) 31 | except Exception as e: 32 | logger.exception(f"读取或解析 {cookie_file} 时出错: {e}") 33 | return 34 | 35 | cookies = convert_cookie_editor_to_playwright(cookies_from_file, logger=logger) 36 | 37 | if str(headless_setting).lower() == 'true': 38 | headless_mode = True 39 | elif str(headless_setting).lower() == 'false': 40 | headless_mode = False 41 | else: 42 | headless_mode = 'virtual' 43 | 44 | launch_options = {"headless": headless_mode} 45 | if proxy: 46 | logger.info(f"使用代理: {proxy} 访问") 47 | launch_options["proxy"] = {"server": proxy, "bypass": "localhost, 127.0.0.1"} 48 | # 无需禁用图片加载, 因为图片很少, 禁用还可能导致风控增加 49 | # launch_options["block_images"] = True 50 | 51 | screenshot_dir = 'logs' 52 | os.makedirs(screenshot_dir, exist_ok=True) 53 | 54 | try: 55 | with Camoufox(**launch_options) as browser: 56 | context = browser.new_context() 57 | context.add_cookies(cookies) 58 | logger.info(f"已为上下文添加 {len(cookies)} 个 Cookie。") 59 | page = context.new_page() 60 | 61 | # #################################################################### 62 | # ############ 增强的 page.goto() 错误处理和日志记录 ############### 63 | # #################################################################### 64 | 65 | response = None 66 | try: 67 | logger.info(f"正在导航到: {expected_url} (超时设置为 120 秒)") 68 | # page.goto() 会返回一个 response 对象,我们可以用它来获取状态码等信息 69 | response = page.goto(expected_url, wait_until='domcontentloaded', timeout=120000) 70 | 71 | # 检查HTTP响应状态码 72 | if response: 73 | logger.info(f"导航初步成功,服务器响应状态码: {response.status} {response.status_text}") 74 | if not response.ok: # response.ok 检查状态码是否在 200-299 范围内 75 | logger.warning(f"警告:页面加载成功,但HTTP状态码表示错误: {response.status}") 76 | # 即使状态码错误,也保存快照以供分析 77 | page.screenshot(path=os.path.join(screenshot_dir, f"WARN_http_status_{response.status}_{cookie_file_config}.png")) 78 | else: 79 | # 对于非http/https的导航(如 about:blank),response可能为None 80 | logger.warning("page.goto 未返回响应对象,可能是一个非HTTP导航。") 81 | 82 | except TimeoutError: 83 | # 这是最常见的错误:超时 84 | logger.error(f"导航到 {expected_url} 超时 (超过120秒)。") 85 | logger.error("可能原因:网络连接缓慢、目标网站服务器无响应、代理问题、或页面资源被阻塞。") 86 | # 尝试保存诊断信息 87 | try: 88 | # 截图对于看到页面卡在什么状态非常有帮助(例如,空白页、加载中、Chrome错误页) 89 | screenshot_path = os.path.join(screenshot_dir, f"FAIL_timeout_{cookie_file_config}.png") 90 | page.screenshot(path=screenshot_path, full_page=True) 91 | logger.info(f"已截取超时时的屏幕快照: {screenshot_path}") 92 | 93 | # 保存HTML可以帮助分析DOM结构,即使在无头模式下也很有用 94 | html_path = os.path.join(screenshot_dir, f"FAIL_timeout_{cookie_file_config}.html") 95 | with open(html_path, 'w', encoding='utf-8') as f: 96 | f.write(page.content()) 97 | logger.info(f"已保存超时时的页面HTML: {html_path}") 98 | except Exception as diag_e: 99 | logger.error(f"在尝试进行超时诊断(截图/保存HTML)时发生额外错误: {diag_e}") 100 | return # 超时后,后续操作无意义,直接终止 101 | 102 | except PlaywrightError as e: 103 | # 捕获其他Playwright相关的网络错误,例如DNS解析失败、连接被拒绝等 104 | error_message = str(e) 105 | logger.error(f"导航到 {expected_url} 时发生 Playwright 网络错误。") 106 | logger.error(f"错误详情: {error_message}") 107 | 108 | # Playwright的错误信息通常很具体,例如 "net::ERR_CONNECTION_REFUSED" 109 | if "net::ERR_NAME_NOT_RESOLVED" in error_message: 110 | logger.error("排查建议:检查DNS设置或域名是否正确。") 111 | elif "net::ERR_CONNECTION_REFUSED" in error_message: 112 | logger.error("排查建议:目标服务器可能已关闭,或代理/防火墙阻止了连接。") 113 | elif "net::ERR_INTERNET_DISCONNECTED" in error_message: 114 | logger.error("排查建议:检查本机的网络连接。") 115 | 116 | # 同样,尝试截图,尽管此时页面可能完全无法访问 117 | try: 118 | screenshot_path = os.path.join(screenshot_dir, f"FAIL_network_error_{cookie_file_config}.png") 119 | page.screenshot(path=screenshot_path) 120 | logger.info(f"已截取网络错误时的屏幕快照: {screenshot_path}") 121 | except Exception as diag_e: 122 | logger.error(f"在尝试进行网络错误诊断(截图)时发生额外错误: {diag_e}") 123 | return # 网络错误,终止 124 | 125 | # --- 如果导航没有抛出异常,继续执行后续逻辑 --- 126 | 127 | logger.info("页面初步加载完成,正在检查并处理初始弹窗...") 128 | page.wait_for_timeout(2000) 129 | 130 | final_url = page.url 131 | logger.info(f"导航完成。最终URL为: {final_url}") 132 | 133 | # ... 你原有的URL检查逻辑保持不变 ... 134 | if "accounts.google.com/v3/signin/identifier" in final_url: 135 | logger.error("检测到Google登录页面(需要输入邮箱)。Cookie已完全失效。") 136 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_identifier_page_{cookie_file_config}.png")) 137 | return 138 | elif expected_url.split('?')[0] in final_url: 139 | 140 | logger.info("URL正确。现在等待页面完成初始加载...") 141 | 142 | # --- NEW ROBUST STRATEGY: Wait for the loading spinner to disappear --- 143 | # This is the key to solving the race condition. The error message or 144 | # content will only appear AFTER the initial loading is done. 145 | spinner_locator = page.locator('mat-spinner') 146 | try: 147 | logger.info("正在等待加载指示器 (spinner) 消失... (最长等待30秒)") 148 | # We wait for the spinner to be 'hidden' or not present in the DOM. 149 | spinner_locator.wait_for(state='hidden', timeout=30000) 150 | logger.info("加载指示器已消失。页面已完成异步加载。") 151 | except TimeoutError: 152 | logger.error("页面加载指示器在30秒内未消失。页面可能已卡住。") 153 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_spinner_stuck_{cookie_file_config}.png")) 154 | return # Exit if the page is stuck loading 155 | 156 | # --- NOW, we can safely check for the error message --- 157 | # We use the most specific text possible to avoid false positives. 158 | auth_error_text = "authentication error" 159 | auth_error_locator = page.get_by_text(auth_error_text, exact=False) 160 | 161 | # We only need a very short timeout here because the page should be stable. 162 | if auth_error_locator.is_visible(timeout=2000): 163 | logger.error(f"检测到认证失败的错误横幅: '{auth_error_text}'. Cookie已过期或无效。") 164 | screenshot_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{cookie_file_config}.png") 165 | page.screenshot(path=screenshot_path) 166 | 167 | # html_path = os.path.join(screenshot_dir, f"FAIL_auth_error_banner_{cookie_file_config}.html") 168 | # with open(html_path, 'w', encoding='utf-8') as f: 169 | # f.write(page.content()) 170 | # logger.info(f"已保存包含错误信息的页面HTML: {html_path}") 171 | return # Definitive failure, so we exit. 172 | 173 | # --- If no error, proceed to final confirmation (as a fallback) --- 174 | logger.info("未检测到认证错误横幅。进行最终确认。") 175 | login_button_cn = page.get_by_role('button', name='登录') 176 | login_button_en = page.get_by_role('button', name='Login') 177 | 178 | if login_button_cn.is_visible(timeout=1000) or login_button_en.is_visible(timeout=1000): 179 | logger.error("页面上仍显示'登录'按钮。Cookie无效。") 180 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_login_button_visible_{cookie_file_config}.png")) 181 | return 182 | 183 | # --- If all checks pass, we assume success --- 184 | logger.info("所有验证通过,确认已成功登录。") 185 | handle_successful_navigation(page, logger, cookie_file_config) 186 | elif "accounts.google.com/v3/signin/accountchooser" in final_url: 187 | logger.warning("检测到Google账户选择页面。登录失败或Cookie已过期。") 188 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_chooser_click_failed_{cookie_file_config}.png")) 189 | return 190 | else: 191 | logger.error(f"导航到了一个意外的URL: {final_url}") 192 | page.screenshot(path=os.path.join(screenshot_dir, f"FAIL_unexpected_url_{cookie_file_config}.png")) 193 | return 194 | 195 | except KeyboardInterrupt: 196 | logger.info(f"用户中断,正在关闭...") 197 | except Exception as e: 198 | # 这是一个最终的捕获,用于捕获所有未预料到的错误 199 | logger.exception(f"运行 Camoufox 实例时发生未预料的严重错误: {e}") -------------------------------------------------------------------------------- /golang/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "sync" 12 | "time" 13 | 14 | "github.com/google/uuid" 15 | "github.com/gorilla/websocket" 16 | ) 17 | 18 | // --- Constants --- 19 | const ( 20 | wsPath = "/v1/ws" 21 | proxyListenAddr = ":5345" 22 | wsReadTimeout = 60 * time.Second 23 | proxyRequestTimeout = 600 * time.Second 24 | ) 25 | 26 | // --- 1. 连接管理与负载均衡 --- 27 | 28 | // UserConnection 存储单个WebSocket连接及其元数据 29 | type UserConnection struct { 30 | Conn *websocket.Conn 31 | UserID string 32 | LastActive time.Time 33 | writeMutex sync.Mutex // 保护对此单个连接的并发写入 34 | } 35 | 36 | // safeWriteJSON 线程安全地向单个WebSocket连接写入JSON 37 | func (uc *UserConnection) safeWriteJSON(v interface{}) error { 38 | uc.writeMutex.Lock() 39 | defer uc.writeMutex.Unlock() 40 | return uc.Conn.WriteJSON(v) 41 | } 42 | 43 | // UserConnections 维护单个用户的所有连接和负载均衡状态 44 | type UserConnections struct { 45 | sync.Mutex 46 | Connections []*UserConnection 47 | NextIndex int // 用于轮询 (round-robin) 48 | } 49 | 50 | // ConnectionPool 全局连接池,并发安全 51 | type ConnectionPool struct { 52 | sync.RWMutex 53 | Users map[string]*UserConnections 54 | } 55 | 56 | var globalPool = &ConnectionPool{ 57 | Users: make(map[string]*UserConnections), 58 | } 59 | 60 | // AddConnection 将新连接添加到池中 61 | func (p *ConnectionPool) AddConnection(userID string, conn *websocket.Conn) *UserConnection { 62 | userConn := &UserConnection{ 63 | Conn: conn, 64 | UserID: userID, 65 | LastActive: time.Now(), 66 | } 67 | 68 | p.Lock() 69 | defer p.Unlock() 70 | 71 | userConns, exists := p.Users[userID] 72 | if !exists { 73 | userConns = &UserConnections{ 74 | Connections: make([]*UserConnection, 0), 75 | NextIndex: 0, 76 | } 77 | p.Users[userID] = userConns 78 | } 79 | 80 | userConns.Lock() 81 | userConns.Connections = append(userConns.Connections, userConn) 82 | userConns.Unlock() 83 | 84 | log.Printf("WebSocket connected: UserID=%s, Total connections for user: %d", userID, len(userConns.Connections)) 85 | return userConn 86 | } 87 | 88 | // RemoveConnection 从池中移除连接 89 | func (p *ConnectionPool) RemoveConnection(userID string, conn *websocket.Conn) { 90 | p.Lock() 91 | defer p.Unlock() 92 | 93 | userConns, exists := p.Users[userID] 94 | if !exists { 95 | return 96 | } 97 | 98 | userConns.Lock() 99 | defer userConns.Unlock() 100 | 101 | // 查找并移除连接 102 | for i, uc := range userConns.Connections { 103 | if uc.Conn == conn { 104 | // 高效删除:将最后一个元素移到当前位置,然后截断切片 105 | userConns.Connections[i] = userConns.Connections[len(userConns.Connections)-1] 106 | userConns.Connections = userConns.Connections[:len(userConns.Connections)-1] 107 | log.Printf("WebSocket disconnected: UserID=%s, Remaining connections for user: %d", userID, len(userConns.Connections)) 108 | break 109 | } 110 | } 111 | 112 | // 如果该用户没有连接了,可以从主map中删除用户条目(可选) 113 | if len(userConns.Connections) == 0 { 114 | delete(p.Users, userID) 115 | } 116 | } 117 | 118 | // GetConnection 使用轮询策略为用户选择一个连接 119 | func (p *ConnectionPool) GetConnection(userID string) (*UserConnection, error) { 120 | p.RLock() 121 | userConns, exists := p.Users[userID] 122 | p.RUnlock() 123 | 124 | if !exists { 125 | return nil, errors.New("no available client for this user") 126 | } 127 | 128 | userConns.Lock() 129 | defer userConns.Unlock() 130 | 131 | numConns := len(userConns.Connections) 132 | if numConns == 0 { 133 | // 理论上如果存在于p.Users中,这里不应该为0,但为了健壮性还是检查 134 | return nil, errors.New("no available client for this user") 135 | } 136 | 137 | // 轮询负载均衡 138 | idx := userConns.NextIndex % numConns 139 | selectedConn := userConns.Connections[idx] 140 | userConns.NextIndex = (userConns.NextIndex + 1) % numConns // 更新索引 141 | 142 | return selectedConn, nil 143 | } 144 | 145 | // --- 2. WebSocket 消息结构 & 待处理请求 --- 146 | 147 | // WSMessage 是前后端之间通信的基本结构 148 | type WSMessage struct { 149 | ID string `json:"id"` // 请求/响应的唯一ID 150 | Type string `json:"type"` // ping, pong, http_request, http_response, stream_start, stream_chunk, stream_end, error 151 | Payload map[string]interface{} `json:"payload"` // 具体数据 152 | } 153 | 154 | // pendingRequests 存储待处理的HTTP请求,等待WS响应 155 | // key: reqID (string), value: chan *WSMessage 156 | var pendingRequests sync.Map 157 | 158 | // --- 3. WebSocket 处理器和心跳 --- 159 | 160 | var upgrader = websocket.Upgrader{ 161 | ReadBufferSize: 1024, 162 | WriteBufferSize: 1024, 163 | // 生产环境中应设置严格的CheckOrigin 164 | CheckOrigin: func(r *http.Request) bool { return true }, 165 | } 166 | 167 | func handleWebSocket(w http.ResponseWriter, r *http.Request) { 168 | // 认证 169 | authToken := r.URL.Query().Get("auth_token") 170 | userID, err := validateJWT(authToken) 171 | if err != nil { 172 | log.Printf("WebSocket authentication failed: %v", err) 173 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 174 | return 175 | } 176 | 177 | // 升级连接 178 | conn, err := upgrader.Upgrade(w, r, nil) 179 | if err != nil { 180 | log.Printf("Failed to upgrade to WebSocket: %v", err) 181 | return 182 | } 183 | 184 | // 添加到连接池 185 | userConn := globalPool.AddConnection(userID, conn) 186 | 187 | // 启动读取循环 188 | go readPump(userConn) 189 | } 190 | 191 | // readPump 处理来自单个WebSocket连接的所有传入消息 192 | func readPump(uc *UserConnection) { 193 | defer func() { 194 | globalPool.RemoveConnection(uc.UserID, uc.Conn) 195 | uc.Conn.Close() 196 | log.Printf("readPump closed for user %s", uc.UserID) 197 | }() 198 | 199 | // 设置读取超时 (心跳机制) 200 | uc.Conn.SetReadDeadline(time.Now().Add(wsReadTimeout)) 201 | 202 | for { 203 | _, message, err := uc.Conn.ReadMessage() 204 | if err != nil { 205 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 206 | log.Printf("WebSocket read error for user %s: %v", uc.UserID, err) 207 | } else { 208 | log.Printf("WebSocket closed for user %s: %v", uc.UserID, err) 209 | } 210 | // 如果读取失败(包括超时),退出循环并清理连接 211 | break 212 | } 213 | 214 | // 收到任何消息,重置读取超时 215 | uc.Conn.SetReadDeadline(time.Now().Add(wsReadTimeout)) 216 | uc.LastActive = time.Now() 217 | 218 | // 解析消息 219 | var msg WSMessage 220 | if err := json.Unmarshal(message, &msg); err != nil { 221 | log.Printf("Error unmarshalling WebSocket message: %v", err) 222 | continue 223 | } 224 | 225 | switch msg.Type { 226 | case "ping": 227 | // 心跳响应 228 | err := uc.safeWriteJSON(map[string]string{"type": "pong", "id": msg.ID}) 229 | if err != nil { 230 | log.Printf("Error sending pong: %v", err) 231 | return // 发送失败,认为连接已断 232 | } 233 | case "http_response", "stream_start", "stream_chunk", "stream_end", "error": 234 | // 路由响应到等待的HTTP Handler 235 | if ch, ok := pendingRequests.Load(msg.ID); ok { 236 | respChan := ch.(chan *WSMessage) 237 | // 尝试发送,如果通道已满(不太可能,但为了安全),则记录日志 238 | select { 239 | case respChan <- &msg: 240 | default: 241 | log.Printf("Warning: Response channel full for request ID %s, dropping message type %s", msg.ID, msg.Type) 242 | } 243 | } else { 244 | log.Printf("Received response for unknown or timed-out request ID: %s", msg.ID) 245 | } 246 | default: 247 | log.Printf("Received unknown message type from client: %s", msg.Type) 248 | } 249 | } 250 | } 251 | 252 | // --- 4. HTTP 反向代理与 WS 隧道 --- 253 | 254 | func handleProxyRequest(w http.ResponseWriter, r *http.Request) { 255 | // 1. 认证并获取UserID (这里模拟) 256 | userID, err := authenticateHTTPRequest(r) 257 | if err != nil { 258 | http.Error(w, "Proxy authentication failed", http.StatusUnauthorized) 259 | return 260 | } 261 | 262 | // 2. 生成唯一请求ID 263 | reqID := uuid.NewString() 264 | 265 | // 3. 创建响应通道并注册 266 | // 使用带缓冲的通道以适应流式响应块 267 | respChan := make(chan *WSMessage, 10) 268 | pendingRequests.Store(reqID, respChan) 269 | defer pendingRequests.Delete(reqID) // 确保请求结束后清理 270 | 271 | // 4. 选择一个WebSocket连接 272 | selectedConn, err := globalPool.GetConnection(userID) 273 | if err != nil { 274 | log.Printf("Error getting connection for user %s: %v", userID, err) 275 | http.Error(w, "Service Unavailable: No active client connected", http.StatusServiceUnavailable) 276 | return 277 | } 278 | 279 | // 5. 封装HTTP请求为WS消息 280 | bodyBytes, err := io.ReadAll(r.Body) 281 | if err != nil { 282 | http.Error(w, "Failed to read request body", http.StatusInternalServerError) 283 | return 284 | } 285 | defer r.Body.Close() 286 | 287 | // 注意:将Header直接序列化为JSON可能需要一些处理,这里简化处理 288 | // 对于生产环境,可能需要更精细的Header转换 289 | headers := make(map[string][]string) 290 | for k, v := range r.Header { 291 | // 过滤掉一些HTTP/1.1特有的或代理不应转发的头 292 | if k != "Connection" && k != "Keep-Alive" && k != "Proxy-Authenticate" && k != "Proxy-Authorization" && k != "Te" && k != "Trailers" && k != "Transfer-Encoding" && k != "Upgrade" { 293 | headers[k] = v 294 | } 295 | } 296 | 297 | requestPayload := WSMessage{ 298 | ID: reqID, 299 | Type: "http_request", 300 | Payload: map[string]interface{}{ 301 | "method": r.Method, 302 | // 假设前端知道如何处理这个相对URL,或者您在这里构建完整的外部URL 303 | "url": "https://generativelanguage.googleapis.com" + r.URL.String(), 304 | "headers": headers, 305 | "body": string(bodyBytes), // 对于二进制数据,应使用base64编码 306 | }, 307 | } 308 | 309 | // 6. 发送请求到WebSocket客户端 310 | if err := selectedConn.safeWriteJSON(requestPayload); err != nil { 311 | log.Printf("Failed to send request over WebSocket: %v", err) 312 | http.Error(w, "Bad Gateway: Failed to send request to client", http.StatusBadGateway) 313 | return 314 | } 315 | 316 | // 7. 异步等待并处理响应 317 | processWebSocketResponse(w, r, respChan) 318 | } 319 | 320 | // processWebSocketResponse 处理来自WS通道的响应,构建HTTP响应 321 | func processWebSocketResponse(w http.ResponseWriter, r *http.Request, respChan chan *WSMessage) { 322 | // 设置超时 323 | ctx, cancel := context.WithTimeout(r.Context(), proxyRequestTimeout) 324 | defer cancel() 325 | 326 | // 获取Flusher以支持流式响应 327 | flusher, ok := w.(http.Flusher) 328 | if !ok { 329 | log.Println("Warning: ResponseWriter does not support flushing, streaming will be buffered.") 330 | } 331 | 332 | headersSet := false 333 | 334 | for { 335 | select { 336 | case msg, ok := <-respChan: 337 | if !ok { 338 | // 通道被关闭,理论上不应该发生,除非有panic 339 | if !headersSet { 340 | http.Error(w, "Internal Server Error: Response channel closed unexpectedly", http.StatusInternalServerError) 341 | } 342 | return 343 | } 344 | 345 | switch msg.Type { 346 | case "http_response": 347 | // 标准单个响应 348 | if headersSet { 349 | log.Println("Received http_response after headers were already set. Ignoring.") 350 | return 351 | } 352 | setResponseHeaders(w, msg.Payload) 353 | writeStatusCode(w, msg.Payload) 354 | writeBody(w, msg.Payload) 355 | return // 请求结束 356 | 357 | case "stream_start": 358 | // 流开始 359 | if headersSet { 360 | log.Println("Received stream_start after headers were already set. Ignoring.") 361 | continue 362 | } 363 | setResponseHeaders(w, msg.Payload) 364 | writeStatusCode(w, msg.Payload) 365 | headersSet = true 366 | if flusher != nil { 367 | flusher.Flush() 368 | } 369 | 370 | case "stream_chunk": 371 | // 流数据块 372 | if !headersSet { 373 | // 如果还没收到stream_start,先设置默认头 374 | log.Println("Warning: Received stream_chunk before stream_start. Using default 200 OK.") 375 | w.WriteHeader(http.StatusOK) 376 | headersSet = true 377 | } 378 | writeBody(w, msg.Payload) 379 | if flusher != nil { 380 | flusher.Flush() // 立即将数据块发送给客户端 381 | } 382 | 383 | case "stream_end": 384 | // 流结束 385 | if !headersSet { 386 | // 如果流结束了但还没设置头,设置一个默认的 387 | w.WriteHeader(http.StatusOK) 388 | } 389 | return // 请求结束 390 | 391 | case "error": 392 | // 前端返回错误 393 | if !headersSet { 394 | errMsg := "Bad Gateway: Client reported an error" 395 | if payloadErr, ok := msg.Payload["error"].(string); ok { 396 | errMsg = payloadErr 397 | } 398 | statusCode := http.StatusBadGateway 399 | if code, ok := msg.Payload["status"].(float64); ok { 400 | statusCode = int(code) 401 | } 402 | http.Error(w, errMsg, statusCode) 403 | } else { 404 | // 如果已经开始发送流,我们只能记录错误并关闭连接 405 | log.Printf("Error received from client after stream started: %v", msg.Payload) 406 | } 407 | return // 请求结束 408 | 409 | default: 410 | log.Printf("Received unexpected message type %s while waiting for response", msg.Type) 411 | } 412 | 413 | case <-ctx.Done(): 414 | // 超时 415 | if !headersSet { 416 | log.Printf("Gateway Timeout: No response from client for request %s", r.URL.Path) 417 | http.Error(w, "Gateway Timeout", http.StatusGatewayTimeout) 418 | } else { 419 | // 如果流已经开始,我们只能记录日志并断开连接 420 | log.Printf("Gateway Timeout: Stream incomplete for request %s", r.URL.Path) 421 | } 422 | return 423 | } 424 | } 425 | } 426 | 427 | // --- 辅助函数 --- 428 | 429 | // setResponseHeaders 从payload中解析并设置HTTP响应头 430 | func setResponseHeaders(w http.ResponseWriter, payload map[string]interface{}) { 431 | headers, ok := payload["headers"].(map[string]interface{}) 432 | if !ok { 433 | return 434 | } 435 | for key, value := range headers { 436 | // 假设值是 []interface{} 或 string 437 | if values, ok := value.([]interface{}); ok { 438 | for _, v := range values { 439 | if strV, ok := v.(string); ok { 440 | w.Header().Add(key, strV) 441 | } 442 | } 443 | } else if strV, ok := value.(string); ok { 444 | w.Header().Set(key, strV) 445 | } 446 | } 447 | } 448 | 449 | // writeStatusCode 从payload中解析并设置HTTP状态码 450 | func writeStatusCode(w http.ResponseWriter, payload map[string]interface{}) { 451 | status, ok := payload["status"].(float64) // JSON数字默认为float64 452 | if !ok { 453 | w.WriteHeader(http.StatusOK) // 默认200 454 | return 455 | } 456 | w.WriteHeader(int(status)) 457 | } 458 | 459 | // writeBody 从payload中解析并写入HTTP响应体 460 | func writeBody(w http.ResponseWriter, payload map[string]interface{}) { 461 | var bodyData []byte 462 | // 对于 http_response,body 键通常包含数据 463 | if body, ok := payload["body"].(string); ok { 464 | bodyData = []byte(body) 465 | } 466 | // 对于 stream_chunk,data 键通常包含数据 467 | if data, ok := payload["data"].(string); ok { 468 | bodyData = []byte(data) 469 | } 470 | // 注意:如果前端发送的是二进制数据,这里应该假设它是base64编码的字符串并进行解码 471 | 472 | if len(bodyData) > 0 { 473 | w.Write(bodyData) 474 | } 475 | } 476 | 477 | // validateJWT 模拟JWT验证并返回userID 478 | func validateJWT(token string) (string, error) { 479 | if token == "" { 480 | return "", errors.New("missing auth_token") 481 | } 482 | // 实际应用中,这里需要使用JWT库(如golang-jwt/jwt)来验证签名和过期时间 483 | // 这里我们简单地将token当作userID 484 | if token == "valid-token-user-1" { 485 | return "user-1", nil 486 | } 487 | //if token == "valid-token-user-2" { 488 | // return "user-2", nil 489 | //} 490 | return "", errors.New("invalid token") 491 | } 492 | 493 | // authenticateHTTPRequest 模拟HTTP代理请求的认证 494 | func authenticateHTTPRequest(r *http.Request) (string, error) { 495 | // 实际应用中,可能检查Authorization头或其他API Key 496 | apiKey := r.Header.Get("x-goog-api-key") 497 | if apiKey == "" { 498 | // r.URL.Query() 会解析URL中的查询参数,返回一个 map[string][]string 499 | // .Get() 方法可以方便地获取指定参数的第一个值,如果参数不存在则返回空字符串 500 | apiKey = r.URL.Query().Get("key") 501 | } 502 | 503 | // 从环境变量中获取预期的API密钥 504 | expectedAPIKey := os.Getenv("AUTH_API_KEY") 505 | if expectedAPIKey == "" { 506 | log.Println("CRITICAL: AUTH_API_KEY environment variable not set.") 507 | // 在生产环境中,您可能希望完全阻止请求 508 | return "", errors.New("server configuration error") 509 | } 510 | 511 | if apiKey == expectedAPIKey { 512 | // 单租户 513 | return "user-1", nil 514 | } 515 | 516 | return "", errors.New("invalid API key") 517 | } 518 | 519 | // --- 主函数 --- 520 | 521 | func main() { 522 | // WebSocket 路由 523 | http.HandleFunc(wsPath, handleWebSocket) 524 | 525 | // HTTP 反向代理路由 (捕获所有其他请求) 526 | http.HandleFunc("/", handleProxyRequest) 527 | 528 | log.Printf("Starting server on %s", proxyListenAddr) 529 | log.Printf("WebSocket endpoint available at ws://%s%s", proxyListenAddr, wsPath) 530 | log.Printf("HTTP proxy available at http://%s/", proxyListenAddr) 531 | 532 | if err := http.ListenAndServe(proxyListenAddr, nil); err != nil { 533 | log.Fatalf("Could not start server: %s\n", err) 534 | } 535 | } 536 | --------------------------------------------------------------------------------