├── app ├── core │ ├── __init__.py │ └── config.py ├── providers │ ├── __init__.py │ ├── base_provider.py │ └── doubao_provider.py ├── services │ ├── credential_manager.py │ ├── session_manager.py │ └── playwright_manager.py └── utils │ └── sse_utils.py ├── requirements.txt ├── docker-compose.yml ├── .env.example ├── nginx.conf ├── Dockerfile ├── .env ├── main.py ├── README.md ├── LICENSE └── 单个项目完整结构代码.txt /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # /requirements.txt 2 | fastapi 3 | uvicorn 4 | pydantic-settings 5 | python-dotenv 6 | cloudscraper 7 | cachetools 8 | httpx 9 | loguru 10 | playwright==1.44.0 11 | playwright-stealth==1.0.6 12 | -------------------------------------------------------------------------------- /app/providers/base_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, Any 3 | from fastapi.responses import StreamingResponse, JSONResponse 4 | 5 | class BaseProvider(ABC): 6 | @abstractmethod 7 | async def chat_completion( 8 | self, 9 | request_data: Dict[str, Any] 10 | ) -> StreamingResponse: 11 | pass 12 | 13 | @abstractmethod 14 | async def get_models(self) -> JSONResponse: 15 | pass 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # /docker-compose.yml 2 | services: 3 | nginx: 4 | image: nginx:latest 5 | container_name: doubao-2api-nginx 6 | restart: always 7 | ports: 8 | - "${NGINX_PORT:-8088}:80" 9 | volumes: 10 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 11 | depends_on: 12 | - app 13 | networks: 14 | - doubao-net 15 | 16 | app: 17 | build: 18 | context: . 19 | dockerfile: Dockerfile 20 | container_name: doubao-2api-app 21 | restart: unless-stopped 22 | env_file: 23 | - .env 24 | networks: 25 | - doubao-net 26 | 27 | networks: 28 | doubao-net: 29 | driver: bridge 30 | -------------------------------------------------------------------------------- /app/services/credential_manager.py: -------------------------------------------------------------------------------- 1 | # /app/services/credential_manager.py 2 | import threading 3 | from typing import List 4 | from loguru import logger 5 | 6 | class CredentialManager: 7 | def __init__(self, credentials: List[str]): 8 | if not credentials: 9 | raise ValueError("凭证列表不能为空。") 10 | self.credentials = credentials 11 | self.index = 0 12 | self.lock = threading.Lock() 13 | logger.info(f"凭证管理器已初始化,共加载 {len(self.credentials)} 个凭证。") 14 | 15 | def get_credential(self) -> str: 16 | with self.lock: 17 | credential = self.credentials[self.index] 18 | self.index = (self.index + 1) % len(self.credentials) 19 | logger.debug(f"轮询到凭证索引: {self.index}") 20 | return credential 21 | -------------------------------------------------------------------------------- /app/services/session_manager.py: -------------------------------------------------------------------------------- 1 | # /app/services/session_manager.py 2 | import threading 3 | from cachetools import TTLCache 4 | from typing import Dict, Any, Optional 5 | from app.core.config import settings 6 | from loguru import logger 7 | 8 | class SessionManager: 9 | def __init__(self): 10 | self.cache = TTLCache(maxsize=1024, ttl=settings.SESSION_CACHE_TTL) 11 | self.lock = threading.Lock() 12 | logger.info(f"会话管理器已初始化,缓存 TTL: {settings.SESSION_CACHE_TTL} 秒。") 13 | 14 | def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: 15 | with self.lock: 16 | return self.cache.get(session_id) 17 | 18 | def update_session(self, session_id: str, data: Dict[str, Any]): 19 | with self.lock: 20 | self.cache[session_id] = data 21 | logger.debug(f"会话 {session_id} 已更新。") 22 | -------------------------------------------------------------------------------- /app/utils/sse_utils.py: -------------------------------------------------------------------------------- 1 | # /app/utils/sse_utils.py 2 | import json 3 | import time 4 | from typing import Dict, Any, Optional 5 | 6 | DONE_CHUNK = b"data: [DONE]\n\n" 7 | 8 | def create_sse_data(data: Dict[str, Any]) -> bytes: 9 | return f"data: {json.dumps(data)}\n\n".encode('utf-8') 10 | 11 | def create_chat_completion_chunk( 12 | request_id: str, 13 | model: str, 14 | content: str, 15 | finish_reason: Optional[str] = None 16 | ) -> Dict[str, Any]: 17 | return { 18 | "id": request_id, 19 | "object": "chat.completion.chunk", 20 | "created": int(time.time()), 21 | "model": model, 22 | "choices": [ 23 | { 24 | "index": 0, 25 | "delta": {"content": content}, 26 | "finish_reason": finish_reason 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # /.env.example 2 | # ==================================================================== 3 | # doubao-2api 配置文件模板 4 | # ==================================================================== 5 | # 6 | # 请将此文件重命名为 ".env" 并填入您的凭证。 7 | # 8 | 9 | # --- 核心安全配置 (必须设置) --- 10 | # 用于保护您 API 服务的访问密钥。 11 | API_MASTER_KEY=sk-doubao-2api-default-key-please-change-me 12 | 13 | # --- 部署配置 (可选) --- 14 | # Nginx 对外暴露的端口 15 | NGINX_PORT=8088 16 | 17 | # --- 豆包凭证 (必须设置) --- 18 | # 请从浏览器开发者工具中获取完整的 Cookie 字符串。 19 | # 登录 https://www.doubao.com/chat/ 后,按 F12 打开开发者工具, 20 | # 切换到“网络(Network)”面板,随便发送一条消息, 21 | # 在请求列表中找到 `completion` 请求,右键 -> 复制 -> 复制为 cURL (bash), 22 | # 然后从 cURL 命令中找到 `--cookie '...'` 部分,将其中的内容粘贴到下方。 23 | # 24 | # 支持多账号轮询,只需按格式添加 DOUBAO_COOKIE_2, DOUBAO_COOKIE_3, ... 25 | DOUBAO_COOKIE_1="在此处粘贴您的完整 Cookie 字符串" 26 | 27 | # --- 会话管理 (可选) --- 28 | # 对话历史在内存中的缓存时间(秒),默认1小时 29 | SESSION_CACHE_TTL=3600 30 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | # /nginx.conf 2 | worker_processes auto; 3 | 4 | events { 5 | worker_connections 1024; 6 | } 7 | 8 | http { 9 | upstream doubao_backend { 10 | # 由于应用内部已实现有状态会话管理,此处无需 ip_hash 11 | server app:8000; 12 | } 13 | 14 | server { 15 | listen 80; 16 | server_name localhost; 17 | 18 | location / { 19 | proxy_pass http://doubao_backend; 20 | proxy_set_header Host $host; 21 | proxy_set_header X-Real-IP $remote_addr; 22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 23 | proxy_set_header X-Forwarded-Proto $scheme; 24 | 25 | # 流式传输优化 26 | proxy_buffering off; 27 | proxy_cache off; 28 | proxy_set_header Connection ''; 29 | proxy_http_version 1.1; 30 | chunked_transfer_encoding off; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # /Dockerfile 2 | # ==================================================================== 3 | # Dockerfile for doubao-2api (v1.4 - Patched for User Permissions) 4 | # ==================================================================== 5 | 6 | # 使用一个稳定、广泛支持的 Debian 版本作为基础镜像 7 | FROM python:3.10-slim-bookworm 8 | 9 | ENV PYTHONDONTWRITEBYTECODE=1 10 | ENV PYTHONUNBUFFERED=1 11 | WORKDIR /app 12 | 13 | # 关键修正: 一次性、完整地安装所有系统依赖 14 | # 合并了 Playwright 官方建议的核心库和我们之前发现的字体库 15 | RUN apt-get update && apt-get install -y --no-install-recommends \ 16 | # Playwright 核心依赖 17 | libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ 18 | libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \ 19 | libxrandr2 libgbm1 libasound2 libatspi2.0-0 \ 20 | # 官方错误日志中明确提示缺少的关键库 21 | libpango-1.0-0 libcairo2 \ 22 | # 解决字体问题的包 23 | fonts-unifont fonts-liberation \ 24 | # 清理 apt 缓存以减小镜像体积 25 | && rm -rf /var/lib/apt/lists/* 26 | 27 | # 安装 Python 依赖 28 | COPY requirements.txt . 29 | RUN pip install --no-cache-dir --upgrade pip && \ 30 | pip install --no-cache-dir -r requirements.txt 31 | 32 | # 复制应用代码 33 | COPY . . 34 | 35 | # 创建并切换到非 root 用户 36 | # 这一步很关键,我们先创建用户,然后以该用户身份安装浏览器 37 | RUN useradd --create-home appuser && \ 38 | chown -R appuser:appuser /app 39 | USER appuser 40 | 41 | # 核心修复:以 appuser 的身份安装 Chromium 浏览器 42 | # 这可以确保浏览器安装在 /home/appuser/.cache/ms-playwright/ 目录下, 43 | # 与应用运行时查找的路径一致,从而解决 "Executable doesn't exist" 错误。 44 | RUN playwright install chromium 45 | 46 | # 暴露端口并启动 47 | EXPOSE 8000 48 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] 49 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | # /app/core/config.py 2 | import os 3 | import uuid 4 | from pydantic_settings import BaseSettings, SettingsConfigDict 5 | from pydantic import model_validator 6 | from typing import Optional, List, Dict 7 | 8 | class Settings(BaseSettings): 9 | model_config = SettingsConfigDict( 10 | env_file=".env", 11 | env_file_encoding='utf-8', 12 | extra="ignore" 13 | ) 14 | 15 | APP_NAME: str = "doubao-2api" 16 | APP_VERSION: str = "1.0.0" 17 | DESCRIPTION: str = "一个将 doubao.com 转换为兼容 OpenAI 格式 API 的高性能代理,内置 a_bogus 签名解决方案。" 18 | 19 | # --- 核心安全与部署配置 --- 20 | API_MASTER_KEY: Optional[str] = "1" 21 | NGINX_PORT: int = 8088 22 | 23 | # --- Doubao 凭证 --- 24 | DOUBAO_COOKIES: List[str] = [] 25 | 26 | # --- 核心变更: 静态设备指纹配置 --- 27 | # 从您提供的有效请求中提取的静态设备指纹,这比动态嗅探稳定得多 28 | # 如果未来失效,只需从浏览器抓取新的请求并更新此处的值 29 | DOUBAO_DEVICE_ID: Optional[str] = None 30 | DOUBAO_FP: Optional[str] = None 31 | DOUBAO_TEA_UUID: Optional[str] = None 32 | DOUBAO_WEB_ID: Optional[str] = None 33 | 34 | # --- 上游 API 配置 --- 35 | API_REQUEST_TIMEOUT: int = 180 36 | 37 | # --- 会话管理 --- 38 | SESSION_CACHE_TTL: int = 3600 39 | 40 | # --- 模型配置 --- 41 | DEFAULT_MODEL: str = "doubao-pro-chat" 42 | MODEL_MAPPING: Dict[str, str] = { 43 | "doubao-pro-chat": "7338286299411103781", # 默认模型 Bot ID 44 | } 45 | 46 | @model_validator(mode='after') 47 | def validate_settings(self) -> 'Settings': 48 | # 从环境变量 DOUBAO_COOKIE_1, DOUBAO_COOKIE_2, ... 加载 cookies 49 | i = 1 50 | while True: 51 | cookie_str = os.getenv(f"DOUBAO_COOKIE_{i}") 52 | if cookie_str: 53 | self.DOUBAO_COOKIES.append(cookie_str) 54 | i += 1 55 | else: 56 | break 57 | 58 | if not self.DOUBAO_COOKIES: 59 | raise ValueError("必须在 .env 文件中至少配置一个有效的 DOUBAO_COOKIE_1") 60 | 61 | # --- 核心变更: 验证设备指纹是否已配置 --- 62 | if not all([self.DOUBAO_DEVICE_ID, self.DOUBAO_FP, self.DOUBAO_TEA_UUID, self.DOUBAO_WEB_ID]): 63 | raise ValueError("必须在 .env 文件中配置完整的设备指纹参数 (DOUBAO_DEVICE_ID, DOUBAO_FP, DOUBAO_TEA_UUID, DOUBAO_WEB_ID)") 64 | 65 | return self 66 | 67 | settings = Settings() 68 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # [自动填充] doubao-2api 生产环境配置 2 | # 该文件由 Project Chimera: Synthesis Edition 自动生成。 3 | 4 | # --- 核心安全配置 --- 5 | # 用于保护您的 API 服务的访问密钥。为安全起见,建议修改为您自己的复杂密钥。 6 | API_MASTER_KEY=1 7 | 8 | # --- 部署配置 --- 9 | # Nginx 对外暴露的端口 10 | NGINX_PORT=8088 11 | 12 | # --- 豆包凭证 (支持多账号) --- 13 | # 已从您提供的抓包数据中自动提取。 14 | # 您可以添加 DOUBAO_COOKIE_2, DOUBAO_COOKIE_3 等来启用多账号轮询。 15 | # 关键修复:将所有 $ 替换为 $$ 以防止 docker-compose 错误地进行变量替换 16 | DOUBAO_COOKIE_1="_ga=GA1.1.106161677.1751986993; flow_user_country=CN; gd_random=eyJtYXRjaCI6dHJ1ZSwicGVyY2VudCI6MC40MDU4OTQxMTgwNjU5MTE5fQ==.uh5yd/EnUakcjRfWWa6OAVFeFHG5u3323TQ8c+A+MLk=; i18next=zh; flow_ssr_sidebar_expand=1; s_v_web_id=verify_mgyqvccs_blJSa2yy_7EW7_4Hyr_Ato6_bIPsXGNXitoz; passport_csrf_token=9eb3d0afec2be115cdb721e991cad1b3; passport_csrf_token_default=9eb3d0afec2be115cdb721e991cad1b3; passport_mfa_token=CjH5jul%2F30qQ%2BaY1jB%2Bnx8LpCcE48Hfop4c3MhxuEeBUFGs%2F8N4JhuZF4s7GeMeZN4w7GkoKPAAAAAAAAAAAAABPnWDhWnZOf2mKtl3lVJ%2FVMkKzWl5s%2BC8ctO%2BrbX8YwHlINTsmlfrNIskE71bYKnJQZRCEqf8NGPax0WwgAiIBA94Lbu8%3D; d_ticket=36e308ea7e3ffb14723f2139e51a83650034a; odin_tt=2eaa81b7b48fba60218c5f378553f4ce3c982fb25d8ee50efd4068f0427b0d95acf5f4570a1dc7c49aca53a61ed61ffb61c9e3f6c3eed5aa61fdb4ad6e00367f; n_mh=-FPXT10Y1ouY2RTXstCfFAbnlgz1v6FIer_PG9SzZ44; passport_auth_status=ba23b06ba9b3eb71cad40d03cc59fee6%2C; passport_auth_status_ss=ba23b06ba9b3eb71cad40d03cc59fee6%2C; sid_guard=3aa7bb87c6eeb0906760f16063aed75e%7C1760941125%7C2592000%7CWed%2C+19-Nov-2025+06%3A18%3A45+GMT; uid_tt=111f0e12e200a498139cb9e298827580; uid_tt_ss=111f0e12e200a498139cb9e298827580; sid_tt=3aa7bb87c6eeb0906760f16063aed75e; sessionid=3aa7bb87c6eeb0906760f16063aed75e; sessionid_ss=3aa7bb87c6eeb0906760f16063aed75e; session_tlb_tag=sttt%7C12%7COqe7h8busJBnYPFgY67XXv__________uXbtksAL_RkZw0L15F060kJ4FWWF3mfsmREcj6H4lXQ%3D; is_staff_user=false; sid_ucp_v1=1.0.0-KGI3YjM1OTk4NzUzOTM2MTY1Yjc4ZWM2M2Y0MDM3NmYwZTZhYzdjMzQKIAj5tLDq9a3wARDFqNfHBhjCsR4gDDDFqNfHBjgCQOwHGgJsZiIgM2FhN2JiODdjNmVlYjA5MDY3NjBmMTYwNjNhZWQ3NWU; ssid_ucp_v1=1.0.0-KGI3YjM1OTk4NzUzOTM2MTY1Yjc4ZWM2M2Y0MDM3NmYwZTZhYzdjMzQKIAj5tLDq9a3wARDFqNfHBhjCsR4gDDDFqNfHBjgCQOwHGgJsZiIgM2FhN2JiODdjNmVlYjA5MDY3NjBmMTYwNjNhZWQ3NWU; ttwid=1%7CYEzH0bhSHZjjqJLjeG5eHfpnB2RWiIe7DuumYAzUrDM%7C1760941158%7Cb7af9ac18f1a4364c95082df532abdbe68c1cb101f0af864be1eed84eff356a2; passport_fe_beating_status=true; _ga_G8EP5CG8VZ=GS2.1.s1760941083$$o54$$g1$$t1760941158$$j60$$l0$$h0" 17 | 18 | # --- 核心变更: 静态设备指纹 --- 19 | # 从浏览器抓包的有效请求中提取,以替换不稳定、易失效的动态嗅探。 20 | # 如果未来服务失效,大概率是这些值过期了,届时请重新抓包并更新。 21 | DOUBAO_DEVICE_ID=7524726744148264511 22 | DOUBAO_FP=verify_mgyqvccs_blJSa2yy_7EW7_4Hyr_Ato6_bIPsXGNXitoz 23 | DOUBAO_TEA_UUID=7524726753203160619 24 | DOUBAO_WEB_ID=7524726753203160619 25 | 26 | # --- 会话管理 (可选) --- 27 | # 对话历史在内存中的缓存时间(秒),默认1小时 28 | SESSION_CACHE_TTL=3600 29 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # /main.py 2 | import sys 3 | import json 4 | from contextlib import asynccontextmanager 5 | from typing import Optional 6 | 7 | from fastapi import FastAPI, Request, HTTPException, Depends, Header 8 | from fastapi.responses import JSONResponse, StreamingResponse 9 | from loguru import logger 10 | 11 | from app.core.config import settings 12 | from app.providers.doubao_provider import DoubaoProvider 13 | 14 | # --- 配置 Loguru --- 15 | logger.remove() 16 | logger.add( 17 | sys.stdout, 18 | level="INFO", 19 | format="{time:YYYY-MM-DD HH:mm:ss.SSS} | " 20 | "{level: <8} | " 21 | "{name}:{function}:{line} - {message}", 22 | colorize=True 23 | ) 24 | 25 | # --- 全局 Provider 实例 --- 26 | provider: Optional[DoubaoProvider] = None 27 | 28 | @asynccontextmanager 29 | async def lifespan(app: FastAPI): 30 | global provider 31 | logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}") 32 | provider = DoubaoProvider() 33 | await provider.initialize() 34 | logger.info("服务已进入 'JS-Signature-as-a-Service' 模式。") 35 | logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用") 36 | yield 37 | await provider.close() 38 | logger.info("应用关闭。") 39 | 40 | app = FastAPI( 41 | title=settings.APP_NAME, 42 | version=settings.APP_VERSION, 43 | description=settings.DESCRIPTION, 44 | lifespan=lifespan 45 | ) 46 | 47 | # --- 安全依赖 --- 48 | async def verify_api_key(authorization: Optional[str] = Header(None)): 49 | if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1": 50 | if not authorization or "bearer" not in authorization.lower(): 51 | raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。") 52 | token = authorization.split(" ")[-1] 53 | if token != settings.API_MASTER_KEY: 54 | raise HTTPException(status_code=403, detail="无效的 API Key。") 55 | 56 | # --- API 路由 --- 57 | @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) 58 | async def chat_completions(request: Request): 59 | try: 60 | request_data = await request.json() 61 | logger.info(f"收到客户端请求 /v1/chat/completions:\n{json.dumps(request_data, indent=2, ensure_ascii=False)}") 62 | return await provider.chat_completion(request_data) 63 | except Exception as e: 64 | logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True) 65 | if isinstance(e, HTTPException): 66 | raise e 67 | raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}") 68 | 69 | @app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse) 70 | async def list_models(): 71 | return await provider.get_models() 72 | 73 | @app.get("/", summary="根路径", include_in_schema=False) 74 | def root(): 75 | return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"} 76 | -------------------------------------------------------------------------------- /app/services/playwright_manager.py: -------------------------------------------------------------------------------- 1 | # /app/services/playwright_manager.py 2 | import asyncio 3 | import json 4 | import uuid 5 | from typing import Optional, Dict, List 6 | from urllib.parse import urlencode, urlparse 7 | 8 | from playwright_stealth import stealth_async 9 | from playwright.async_api import async_playwright, Browser, Page, ConsoleMessage, TimeoutError, Route, Request 10 | from loguru import logger 11 | 12 | from app.core.config import settings # 导入 settings 13 | 14 | def handle_console_message(msg: ConsoleMessage): 15 | """将浏览器控制台日志转发到 Loguru,并过滤已知噪音""" 16 | log_level = msg.type.upper() 17 | text = msg.text 18 | # 过滤掉常见的、无害的浏览器噪音 19 | if "Failed to load resource" in text or "net::ERR_FAILED" in text: 20 | return 21 | if "WebSocket connection" in text: 22 | return 23 | if "Content Security Policy" in text: 24 | return 25 | if "Scripts may close only the windows that were opened by them" in text: 26 | return 27 | if "Ignoring too frequent calls to print()" in text: 28 | return 29 | 30 | log_message = f"[Browser Console] {text}" 31 | if log_level == "ERROR": 32 | logger.error(log_message) 33 | elif log_level == "WARNING": 34 | logger.warning(log_message) 35 | else: 36 | pass 37 | 38 | class PlaywrightManager: 39 | _instance = None 40 | _lock = asyncio.Lock() 41 | 42 | def __new__(cls): 43 | if cls._instance is None: 44 | cls._instance = super(PlaywrightManager, cls).__new__(cls) 45 | cls._instance._initialized = False 46 | return cls._instance 47 | 48 | async def initialize(self, cookies: List[str]): 49 | if self._initialized: 50 | return 51 | async with self._lock: 52 | if self._initialized: 53 | return 54 | logger.info("正在初始化 Playwright 管理器 (签名服务模式)...") 55 | self.playwright = await async_playwright().start() 56 | self.browser = await self.playwright.chromium.launch( 57 | headless=True, 58 | args=["--no-sandbox", "--disable-setuid-sandbox"] 59 | ) 60 | self.page = await self.browser.new_page() 61 | 62 | await stealth_async(self.page) 63 | self.page.on("console", handle_console_message) 64 | await self.page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") 65 | 66 | self.static_device_fingerprint = { 67 | 'device_id': settings.DOUBAO_DEVICE_ID, 68 | 'fp': settings.DOUBAO_FP, 69 | 'web_id': settings.DOUBAO_WEB_ID, 70 | 'tea_uuid': settings.DOUBAO_TEA_UUID 71 | } 72 | logger.success(f"已从配置中加载静态设备指纹: {self.static_device_fingerprint}") 73 | 74 | self.ms_token = None 75 | 76 | async def _handle_response(response): 77 | try: 78 | if 'x-ms-token' in response.headers: 79 | token = response.headers['x-ms-token'] 80 | if token != self.ms_token: 81 | self.ms_token = token 82 | logger.success(f"通过响应头捕获到新的 msToken: {self.ms_token}") 83 | except Exception as e: 84 | logger.warning(f"处理响应时出错: {e} (URL: {response.url})") 85 | 86 | self.page.on("response", _handle_response) 87 | 88 | if not cookies: 89 | raise ValueError("Playwright 初始化需要至少一个有效的 Cookie。") 90 | 91 | logger.info("正在为初始页面加载设置 Cookie...") 92 | initial_cookie_str = cookies[0] 93 | try: 94 | cookie_list = [ 95 | {"name": c.split('=')[0].strip(), "value": c.split('=', 1)[1].strip(), "domain": ".doubao.com", "path": "/"} 96 | for c in initial_cookie_str.split(';') if '=' in c 97 | ] 98 | await self.page.context.add_cookies(cookie_list) 99 | logger.success("初始 Cookie 设置完成。") 100 | except IndexError as e: 101 | logger.error(f"解析 Cookie 时出错: '{initial_cookie_str}'. 请确保 Cookie 格式正确。错误: {e}") 102 | raise ValueError("Cookie 格式无效,无法进行初始化。") from e 103 | 104 | try: 105 | logger.info("正在导航到豆包官网以加载签名脚本 (超时时间: 60秒)...") 106 | await self.page.goto( 107 | "https://www.doubao.com/chat/", 108 | wait_until="load", 109 | timeout=60000 110 | ) 111 | logger.info("页面导航完成 (load 事件触发)。") 112 | except TimeoutError as e: 113 | logger.error(f"导航到豆包官网超时: {e}") 114 | raise RuntimeError("无法访问豆包官网,初始化失败。") from e 115 | 116 | try: 117 | logger.info("正在等待关键签名函数 (window.byted_acrawler.frontierSign) 加载 (超时时间: 30秒)...") 118 | await self.page.wait_for_function( 119 | "() => typeof window.byted_acrawler?.frontierSign === 'function'", 120 | timeout=30000 121 | ) 122 | logger.success("关键签名函数已在启动时成功加载!") 123 | except TimeoutError: 124 | logger.error("等待签名函数超时!这很可能是因为 Cookie 无效或已过期。") 125 | raise RuntimeError("无法加载豆包签名函数,请检查并更新 Cookie。") 126 | 127 | if not self.ms_token: 128 | logger.info("等待 msToken 出现,最长等待 10 秒...") 129 | await asyncio.sleep(10) 130 | if not self.ms_token: 131 | logger.warning("在额外等待后,依然未能捕获到初始 msToken。后续请求将依赖响应头更新。") 132 | 133 | logger.success("Playwright 管理器 (签名服务模式) 初始化完成。") 134 | self._initialized = True 135 | 136 | def update_ms_token(self, token: str): 137 | self.ms_token = token 138 | 139 | async def get_signed_url(self, base_url: str, cookie: str, base_params: Dict[str, str]) -> Optional[str]: 140 | async with self._lock: 141 | if not self._initialized: 142 | raise RuntimeError("PlaywrightManager 未初始化。") 143 | 144 | try: 145 | logger.info("正在使用 Playwright 生成 a_bogus 签名...") 146 | 147 | final_params = base_params.copy() 148 | final_params.update(self.static_device_fingerprint) 149 | 150 | final_params['web_tab_id'] = str(uuid.uuid4()) 151 | if self.ms_token: 152 | final_params['msToken'] = self.ms_token 153 | else: 154 | logger.error("msToken 未被初始化,无法构建有效请求!") 155 | return None 156 | 157 | # --- 核心修复: 对参数进行字母排序,以生成正确的签名 --- 158 | sorted_params = dict(sorted(final_params.items())) 159 | final_query_string = urlencode(sorted_params) 160 | url_with_params = f"{base_url}?{final_query_string}" 161 | 162 | logger.info(f"正在使用静态指纹和排序后的参数调用 window.byted_acrawler.frontierSign: \"{final_query_string}\"") 163 | signature_obj = await self.page.evaluate(f'window.byted_acrawler.frontierSign("{final_query_string}")') 164 | 165 | if isinstance(signature_obj, dict) and ('a_bogus' in signature_obj or 'X-Bogus' in signature_obj): 166 | bogus_value = signature_obj.get('a_bogus') or signature_obj.get('X-Bogus') 167 | logger.success(f"成功解析签名对象,获取到 a_bogus: {bogus_value}") 168 | 169 | signed_url = f"{url_with_params}&a_bogus={bogus_value}" 170 | return signed_url 171 | else: 172 | logger.error(f"调用签名函数失败,返回值不是预期的字典格式或缺少 a_bogus: {signature_obj}") 173 | return None 174 | 175 | except Exception as e: 176 | logger.error(f"Playwright 签名时发生严重错误: {e}", exc_info=True) 177 | return None 178 | 179 | async def close(self): 180 | if self._initialized: 181 | async with self._lock: 182 | if self.browser: 183 | await self.browser.close() 184 | if self.playwright: 185 | await self.playwright.stop() 186 | self._initialized = False 187 | logger.info("Playwright 管理器已关闭。") 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # doubao-2api - 豆包转 OpenAI API ✨ 2 | 3 |
4 | 5 | ![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg) 6 | ![GitHub Repo stars](https://img.shields.io/github/stars/lzA6/doubao-2api?style=social) 7 | ![Docker](https://img.shields.io/badge/Docker-Ready-blue?logo=docker) 8 | ![Python](https://img.shields.io/badge/Python-3.9%2B-blue?logo=python) 9 | ![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-green?logo=fastapi) 10 | 11 | **English | [中文](#)** 12 | 13 | > "我们并非在编写代码,而是在与数字世界对话。这个项目,就是我们教给计算机的一种新的方言。" 14 | 15 |
16 | 17 | ## 📖 目录 18 | 19 | - [🌟 核心特性](#-核心特性) 20 | - [🎯 适用场景](#-适用场景) 21 | - [🚀 快速开始](#-快速开始) 22 | - [准备工作](#准备工作) 23 | - [第一步:克隆项目](#第一步克隆项目) 24 | - [第二步:配置环境](#第二步配置环境) 25 | - [第三步:启动服务](#第三步启动服务) 26 | - [第四步:验证部署](#第四步验证部署) 27 | - [🏗️ 系统架构](#️-系统架构) 28 | - [🔧 工作原理](#-工作原理) 29 | - [📊 技术栈详解](#-技术栈详解) 30 | - [🗺️ 项目规划](#️-项目规划) 31 | - [📁 项目结构](#-项目结构) 32 | - [❓ 常见问题](#-常见问题) 33 | - [🤝 贡献指南](#-贡献指南) 34 | 35 | ## 🌟 核心特性 36 | 37 |
38 | 39 | | 特性 | 描述 | 状态 | 40 | |------|------|------| 41 | | **OpenAI 兼容性** | 完全模拟 `/v1/chat/completions` 和 `/v1/models` 接口 | ✅ 已实现 | 42 | | **动态签名破解** | 基于 Playwright 的 `a_bogus` 签名生成 | ✅ 已实现 | 43 | | **多账号轮询** | 支持多个豆包 Cookie 负载均衡 | ✅ 已实现 | 44 | | **Docker 部署** | 一键容器化部署方案 | ✅ 已实现 | 45 | | **状态化会话** | 智能对话上下文管理 | ✅ 已实现 | 46 | | **流式响应** | 支持 SSE 流式输出 | ✅ 已实现 | 47 | 48 |
49 | 50 | ## 🎯 适用场景 51 | 52 | - 🧑‍💻 **开发者与 AI 爱好者** - 集成免费强大的中文大模型 53 | - 💰 **成本敏感型用户** - 寻找高质量免费 AI 服务替代品 54 | - 📱 **第三方客户端用户** - 支持 ChatGPT-Next-Web、LobeChat 等 55 | - 🔬 **技术探索者** - 学习 Web-API、反爬虫、浏览器自动化技术 56 | 57 | ## 🚀 快速开始 58 | 59 | ### 准备工作 60 | 61 | 1. 安装 `Docker` 和 `docker-compose` 62 | 2. 准备可用的豆包账号 63 | 64 | ### 第一步:克隆项目 65 | 66 | ```bash 67 | git clone https://github.com/lzA6/doubao-2api.git 68 | cd doubao-2api 69 | ``` 70 | 71 | ### 第二步:配置环境 72 | 73 | 复制环境配置文件: 74 | 75 | ```bash 76 | cp .env.example .env 77 | ``` 78 | 79 | 编辑配置文件: 80 | 81 | ```bash 82 | nano .env 83 | ``` 84 | 85 | 关键配置项: 86 | 87 | ```env 88 | # API 认证密钥(请修改为复杂字符串) 89 | API_MASTER_KEY="sk-your-secret-key-here" 90 | 91 | # 豆包 Cookie(必填) 92 | DOUBAO_COOKIE_1="your_doubao_cookie_here" 93 | 94 | # 可选:多账号支持 95 | DOUBAO_COOKIE_2="another_cookie_here" 96 | DOUBAO_COOKIE_3="third_cookie_here" 97 | ``` 98 | 99 | #### 🔍 获取 Cookie 教程 100 | 101 | 1. 使用 Chrome/Edge 浏览器登录 [豆包官网](https://www.doubao.com/chat/) 102 | 2. 按 `F12` 打开开发者工具 → 切换到 **Network** 标签 103 | 3. 发送任意消息 → 筛选 `completion` 请求 104 | 4. 右键复制为 cURL → 提取 Cookie 字段 105 | 106 | ### 第三步:启动服务 107 | 108 | ```bash 109 | docker-compose up -d 110 | ``` 111 | 112 | ### 第四步:验证部署 113 | 114 | 服务启动后,可通过以下方式验证: 115 | 116 | **API 配置信息:** 117 | - 地址:`http://your-server-ip:8088` 118 | - 密钥:`.env` 中设置的 `API_MASTER_KEY` 119 | - 模型:`doubao-pro-chat` 120 | 121 | **测试请求:** 122 | ```bash 123 | curl -X POST "http://localhost:8088/v1/chat/completions" \ 124 | -H "Authorization: Bearer sk-your-secret-key-here" \ 125 | -H "Content-Type: application/json" \ 126 | -d '{ 127 | "model": "doubao-pro-chat", 128 | "messages": [{"role": "user", "content": "你好"}], 129 | "stream": false 130 | }' 131 | ``` 132 | 133 | ## 🏗️ 系统架构 134 | 135 |
136 | 137 | ```mermaid 138 | graph TB 139 | subgraph "客户端 Client" 140 | A[OpenAI 兼容客户端] --> B[HTTP 请求] 141 | end 142 | 143 | subgraph "doubao-2api 服务层" 144 | B --> C{Nginx 反向代理} 145 | C --> D[FastAPI 应用] 146 | 147 | subgraph "核心服务 Core Services" 148 | D --> E[凭证管理器] 149 | D --> F[会话管理器] 150 | D --> G[签名服务] 151 | end 152 | 153 | subgraph "浏览器自动化" 154 | G --> H[Playwright] 155 | H --> I[无头浏览器] 156 | I --> J[生成 a_bogus 签名] 157 | end 158 | end 159 | 160 | subgraph "上游服务 Upstream" 161 | J --> K[豆包 API] 162 | K --> L[响应处理] 163 | L --> M[格式转换] 164 | M --> A 165 | end 166 | 167 | E --> H 168 | F --> K 169 | 170 | style A fill:#e1f5fe 171 | style D fill:#f3e5f5 172 | style G fill:#fff3e0 173 | style K fill:#e8f5e8 174 | ``` 175 | 176 |
177 | 178 | ## 🔧 工作原理 179 | 180 | ### 核心挑战:`a_bogus` 签名机制 181 | 182 | 豆包网使用复杂的 JavaScript 签名算法生成 `a_bogus` 参数,作为 API 请求的安全验证。 183 | 184 | ### 解决方案:JS 执行即服务 185 | 186 | | 步骤 | 技术实现 | 说明 | 187 | |------|----------|------| 188 | | **1. 环境准备** | Playwright + Stealth | 启动无头浏览器,加载豆包页面 | 189 | | **2. 函数注入** | 执行 `frontierSign` | 调用豆包签名函数 | 190 | | **3. 动态签名** | 参数传递 + JS 执行 | 生成有效的 `a_bogus` 签名 | 191 | | **4. 请求转发** | httpx 客户端 | 携带签名调用豆包 API | 192 | | **5. 格式转换** | OpenAI 兼容格式 | 将响应转换为标准格式 | 193 | 194 | ### 签名生成流程 195 | 196 | ```python 197 | # 伪代码示例 198 | async def generate_signature(params): 199 | # 1. 通过 Playwright 调用浏览器环境 200 | signature = await page.evaluate(""" 201 | (params) => { 202 | return window.byted_acrawler.frontierSign(params); 203 | } 204 | """, params) 205 | 206 | # 2. 将签名附加到请求 URL 207 | url = f"https://www.doubao.com/api?{params}&a_bogus={signature}" 208 | 209 | # 3. 发送请求 210 | response = await httpx.post(url, cookies=cookies) 211 | return response 212 | ``` 213 | 214 | ## 📊 技术栈详解 215 | 216 |
217 | 218 | | 技术组件 | 版本 | 用途 | 状态 | 219 | |----------|------|------|------| 220 | | **FastAPI** | 0.100+ | Web API 框架 | ✅ 稳定 | 221 | | **Playwright** | 1.40+ | 浏览器自动化 | ✅ 稳定 | 222 | | **Docker** | 20.10+ | 容器化部署 | ✅ 稳定 | 223 | | **Nginx** | 1.24+ | 反向代理 | ✅ 稳定 | 224 | | **Python** | 3.9+ | 后端语言 | ✅ 稳定 | 225 | | **httpx** | 0.25+ | HTTP 客户端 | ✅ 稳定 | 226 | 227 |
228 | 229 | ### 组件职责说明 230 | 231 | - **🎯 FastAPI**: 提供 OpenAI 兼容的 RESTful API 接口 232 | - **🕷️ Playwright**: 管理浏览器实例,执行 JS 签名函数 233 | - **🐳 Docker**: 封装运行环境,简化部署流程 234 | - **🔀 Nginx**: 负载均衡、请求路由、静态文件服务 235 | - **📦 CredentialManager**: 多账号 Cookie 轮询管理 236 | - **💬 SessionManager**: 对话会话状态维护 237 | 238 | ## 🗺️ 项目规划 239 | 240 | ### ✅ 已实现功能 (v1.0) 241 | 242 | - [x] OpenAI API 标准兼容 243 | - [x] 动态 `a_bogus` 签名生成 244 | - [x] 流式/非流式响应支持 245 | - [x] 多 Cookie 负载均衡 246 | - [x] Docker 容器化部署 247 | - [x] 内存会话管理 248 | - [x] 设备指纹固化 249 | 250 | ### 🚧 当前限制 251 | 252 | 1. **资源消耗** - Playwright 浏览器实例占用较高内存 253 | 2. **启动速度** - 冷启动需要 30-60 秒初始化时间 254 | 3. **依赖浏览器** - 尚未实现纯 Python 签名算法 255 | 256 | ### 🔮 未来规划 257 | 258 | #### 🎯 高优先级 259 | - [ ] **纯 Python 签名实现** - 移除 Playwright 依赖 260 | - [ ] **Cookie 自动检测** - 失效通知和自动刷新 261 | - [ ] **性能优化** - 减少内存占用,提高并发能力 262 | 263 | #### 📈 中优先级 264 | - [ ] **Web 管理界面** - 可视化配置和监控 265 | - [ ] **Redis 支持** - 分布式会话存储 266 | - [ ] **健康检查** - 服务状态监控和自愈 267 | 268 | #### 💡 扩展功能 269 | - [ ] **多模型支持** - 扩展到其他 AI 服务平台 270 | - [ ] **插件架构** - 支持自定义签名算法 271 | - [ ] **API 速率限制** - 防止滥用保护服务 272 | 273 | ## 📁 项目结构 274 | 275 | ``` 276 | doubao-2api/ 277 | ├── 🐳 Docker 相关 278 | │ ├── Dockerfile # 容器构建配置 279 | │ └── docker-compose.yml # 服务编排配置 280 | ├── 🔧 配置文件 281 | │ ├── .env.example # 环境变量模板 282 | │ ├── nginx.conf # Nginx 配置 283 | │ └── requirements.txt # Python 依赖 284 | ├── 🐍 应用代码 285 | │ └── app/ 286 | │ ├── main.py # FastAPI 应用入口 287 | │ ├── core/ # 核心模块 288 | │ │ ├── config.py # 配置管理 289 | │ │ └── __init__.py 290 | │ ├── providers/ # 服务提供者 291 | │ │ ├── base_provider.py 292 | │ │ └── doubao_provider.py 293 | │ ├── services/ # 业务服务 294 | │ │ ├── credential_manager.py 295 | │ │ ├── playwright_manager.py 296 | │ │ └── session_manager.py 297 | │ └── utils/ # 工具函数 298 | │ └── sse_utils.py 299 | └── 📚 文档 300 | ├── README.md # 项目说明 301 | └── README_en.md # 英文文档 302 | ``` 303 | 304 | ## ❓ 常见问题 305 | 306 | ### 🔧 技术问题 307 | 308 | **Q: 服务启动失败怎么办?** 309 | **A:** 检查以下项目: 310 | 1. Docker 和 docker-compose 是否正常安装 311 | 2. `.env` 文件中的 Cookie 是否有效 312 | 3. 服务器网络能否访问豆包官网 313 | 314 | **Q: 请求返回签名错误?** 315 | **A:** 通常是因为: 316 | 1. Cookie 过期 - 重新获取最新 Cookie 317 | 2. 浏览器实例异常 - 重启服务 `docker-compose restart` 318 | 319 | **Q: 如何查看服务日志?** 320 | **A:** 使用命令: 321 | ```bash 322 | docker-compose logs -f app 323 | ``` 324 | 325 | ### 💰 费用相关 326 | 327 | **Q: 会消耗豆包付费额度吗?** 328 | **A:** 不会,本项目仅调用豆包免费模型,与付费服务无关。 329 | 330 | ### 🔒 安全相关 331 | 332 | **Q: 我的 Cookie 安全吗?** 333 | **A:** 只要部署在可信服务器并设置复杂 API 密钥,您的凭证就是安全的。项目不会向第三方发送任何用户数据。 334 | 335 | ## 🤝 贡献指南 336 | 337 | 我们欢迎各种形式的贡献! 338 | 339 | ### 🐛 报告问题 340 | - 使用 GitHub Issues 报告 bug 341 | - 提供详细的重现步骤和环境信息 342 | 343 | ### 💡 功能建议 344 | - 在 Discussions 中提出新想法 345 | - 描述使用场景和预期效果 346 | 347 | ### 🔨 代码贡献 348 | 1. Fork 本仓库 349 | 2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) 350 | 3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) 351 | 4. 推送到分支 (`git push origin feature/AmazingFeature`) 352 | 5. 创建 Pull Request 353 | 354 | ### 📋 开发规范 355 | - 遵循 PEP 8 Python 代码规范 356 | - 添加适当的注释和文档 357 | - 确保所有测试通过 358 | 359 | --- 360 | 361 |
362 | 363 | ## 🌟 星星的力量 364 | 365 | 如果这个项目对您有帮助,请给它一个 ⭐️ 星星!您的支持是我们持续改进的最大动力。 366 | 367 | **用代码对话世界,让技术触手可及。** 368 | 369 |
370 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /app/providers/doubao_provider.py: -------------------------------------------------------------------------------- 1 | # /app/providers/doubao_provider.py 2 | import json 3 | import re 4 | import time 5 | import uuid 6 | from typing import Dict, Any, AsyncGenerator, List 7 | 8 | import httpx 9 | from fastapi import HTTPException 10 | from fastapi.responses import StreamingResponse, JSONResponse 11 | from loguru import logger 12 | 13 | from app.core.config import settings 14 | from app.providers.base_provider import BaseProvider 15 | from app.services.credential_manager import CredentialManager 16 | from app.services.playwright_manager import PlaywrightManager 17 | from app.services.session_manager import SessionManager 18 | from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK 19 | 20 | 21 | class DoubaoProvider(BaseProvider): 22 | def __init__(self): 23 | self.credential_manager = CredentialManager(settings.DOUBAO_COOKIES) 24 | self.session_manager = SessionManager() 25 | self.playwright_manager = PlaywrightManager() 26 | self.client: httpx.AsyncClient = None 27 | 28 | async def initialize(self): 29 | self.client = httpx.AsyncClient(timeout=settings.API_REQUEST_TIMEOUT) 30 | await self.playwright_manager.initialize(self.credential_manager.credentials) 31 | 32 | async def close(self): 33 | if self.client: 34 | await self.client.aclose() 35 | await self.playwright_manager.close() 36 | 37 | def _get_dynamic_cookie(self, base_cookie: str) -> str: 38 | """ 39 | 用 Playwright 捕获的最新 msToken 更新基础 Cookie 字符串。 40 | 这是确保签名和请求头一致性的关键。 41 | """ 42 | latest_ms_token = self.playwright_manager.ms_token 43 | if not latest_ms_token: 44 | logger.warning("动态 Cookie 更新失败:Playwright 管理器中没有可用的 msToken。将使用原始 Cookie。") 45 | return base_cookie 46 | 47 | if 'msToken=' in base_cookie: 48 | new_cookie = re.sub(r'msToken=[^;]+', f'msToken={latest_ms_token}', base_cookie) 49 | logger.info("成功将动态 msToken 更新到 Cookie 头中。") 50 | else: 51 | new_cookie = f"{base_cookie.strip(';')}; msToken={latest_ms_token}" 52 | logger.info("原始 Cookie 中未找到 msToken,已追加最新的 msToken。") 53 | 54 | return new_cookie 55 | 56 | async def chat_completion(self, request_data: Dict[str, Any]): 57 | """ 58 | 根据请求中的 'stream' 参数,分发到流式或非流式处理函数。 59 | """ 60 | is_stream = request_data.get("stream", True) 61 | 62 | if is_stream: 63 | return StreamingResponse(self._stream_generator(request_data), media_type="text/event-stream") 64 | else: 65 | return await self._non_stream_completion(request_data) 66 | 67 | async def _non_stream_completion(self, request_data: Dict[str, Any]) -> JSONResponse: 68 | """ 69 | 处理非流式聊天补全请求。 70 | """ 71 | session_id = request_data.get("user", f"session-{uuid.uuid4().hex}") 72 | messages = request_data.get("messages", []) 73 | user_model = request_data.get("model", settings.DEFAULT_MODEL) 74 | 75 | bot_id = settings.MODEL_MAPPING.get(user_model) 76 | if not bot_id: 77 | raise HTTPException(status_code=400, detail=f"不支持的模型: {user_model}") 78 | 79 | session_data = self.session_manager.get_session(session_id) or {} 80 | conversation_id = session_data.get("conversation_id", "0") 81 | is_new_conversation = conversation_id == "0" 82 | 83 | request_id = f"chatcmpl-{uuid.uuid4()}" 84 | new_conversation_id = None 85 | full_content = [] 86 | streamed_any_data = False 87 | 88 | try: 89 | base_cookie = self.credential_manager.get_credential() 90 | final_cookie = self._get_dynamic_cookie(base_cookie) 91 | base_url = "https://www.doubao.com/samantha/chat/completion" 92 | base_params = { 93 | "aid": "497858", "device_platform": "web", "language": "zh", 94 | "pc_version": "2.41.0", "pkg_type": "release_version", "real_aid": "497858", 95 | "region": "CN", "samantha_web": "1", "sys_region": "CN", 96 | "use-olympus-account": "1", "version_code": "20800", 97 | } 98 | headers = self._prepare_headers(final_cookie) 99 | payload = self._prepare_payload(messages, bot_id, conversation_id) 100 | 101 | log_headers = headers.copy() 102 | log_headers["Cookie"] = "[REDACTED FOR SECURITY]" 103 | logger.info("--- 准备向上游发送的完整请求包 (非流式) ---") 104 | logger.info(f"请求方法: POST") 105 | logger.info(f"基础URL: {base_url}") 106 | logger.info(f"请求头 (Headers):\n{json.dumps(log_headers, indent=2)}") 107 | logger.info(f"请求载荷 (Payload):\n{json.dumps(payload, indent=2, ensure_ascii=False)}") 108 | logger.info("------------------------------------") 109 | 110 | signed_url = await self.playwright_manager.get_signed_url(base_url, final_cookie, base_params) 111 | if not signed_url: 112 | raise Exception("无法获取 a_bogus 签名, Playwright 服务可能异常。") 113 | 114 | logger.info(f"签名成功,最终请求 URL: {signed_url}") 115 | 116 | async with self.client.stream("POST", signed_url, headers=headers, json=payload) as response: 117 | new_ms_token = response.headers.get("x-ms-token") 118 | if new_ms_token: 119 | self.playwright_manager.update_ms_token(new_ms_token) 120 | logger.success(f"从响应头中捕获并更新了 msToken: {new_ms_token}") 121 | 122 | if response.status_code != 200: 123 | error_content = await response.aread() 124 | logger.error(f"上游服务器返回错误状态码: {response.status_code}。") 125 | logger.error(f"上游服务器响应内容: {error_content.decode(errors='ignore')}") 126 | response.raise_for_status() 127 | 128 | logger.success(f"成功连接到上游服务器, 状态码: {response.status_code}. 开始接收响应...") 129 | 130 | async for line in response.aiter_lines(): 131 | # [诊断日志] 打印从上游收到的每一行原始数据 132 | logger.info(f"上游原始响应行: {line}") 133 | streamed_any_data = True 134 | if not line.startswith("data:"): 135 | continue 136 | content_str = line[len("data:"):].strip() 137 | if not content_str: 138 | continue 139 | 140 | try: 141 | data = json.loads(content_str) 142 | if data.get("event_type") == 2002 and not new_conversation_id: 143 | event_data = json.loads(data.get("event_data", "{}")) 144 | new_conversation_id = event_data.get("conversation_id") 145 | logger.info(f"捕获到新会话 ID: {new_conversation_id}") 146 | 147 | if data.get("event_type") == 2001: 148 | event_data = json.loads(data.get("event_data", "{}")) 149 | message_data = event_data.get("message", {}) 150 | content_json = json.loads(message_data.get("content", "{}")) 151 | delta_content = content_json.get("text", "") 152 | if delta_content: 153 | full_content.append(delta_content) 154 | except (json.JSONDecodeError, KeyError) as e: 155 | logger.warning(f"解析 SSE 数据块时跳过: {e}, 内容: {content_str}") 156 | continue 157 | 158 | if not streamed_any_data: 159 | logger.error("上游服务器返回了 200 OK,但没有发送任何数据流。这通常是由于反爬虫策略触发。") 160 | raise Exception("服务器连接成功但未返回数据流,请求可能被上游服务拦截。请检查Cookie是否过期或IP是否被限制。") 161 | 162 | if is_new_conversation and new_conversation_id: 163 | self.session_manager.update_session(session_id, {"conversation_id": new_conversation_id}) 164 | logger.info(f"为用户 '{session_id}' 保存了新的会话 ID: {new_conversation_id}") 165 | 166 | final_text = "".join(full_content) 167 | 168 | # 按照用户要求,将完整的响应内容打印到终端 169 | print("\n--- [非流式] 完整响应内容 ---") 170 | print(final_text) 171 | print("---------------------------------\n") 172 | 173 | response_data = { 174 | "id": request_id, 175 | "object": "chat.completion", 176 | "created": int(time.time()), 177 | "model": user_model, 178 | "choices": [{ 179 | "index": 0, 180 | "message": {"role": "assistant", "content": final_text}, 181 | "finish_reason": "stop" 182 | }], 183 | "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} 184 | } 185 | return JSONResponse(content=response_data) 186 | 187 | except Exception as e: 188 | logger.error(f"处理非流式请求时发生严重错误: {e}", exc_info=True) 189 | return JSONResponse( 190 | status_code=500, 191 | content={"error": {"message": f"内部服务器错误: {str(e)}", "type": "server_error", "code": None}} 192 | ) 193 | 194 | async def _stream_generator(self, request_data: Dict[str, Any]) -> AsyncGenerator[bytes, None]: 195 | """ 196 | 处理流式聊天补全请求。 197 | """ 198 | session_id = request_data.get("user", f"session-{uuid.uuid4().hex}") 199 | messages = request_data.get("messages", []) 200 | user_model = request_data.get("model", settings.DEFAULT_MODEL) 201 | 202 | bot_id = settings.MODEL_MAPPING.get(user_model) 203 | if not bot_id: 204 | # This should be handled before calling the generator, but as a safeguard: 205 | error_chunk = create_chat_completion_chunk(f"chatcmpl-{uuid.uuid4()}", user_model, f"不支持的模型: {user_model}", "stop") 206 | yield create_sse_data(error_chunk) 207 | yield DONE_CHUNK 208 | return 209 | 210 | session_data = self.session_manager.get_session(session_id) or {} 211 | conversation_id = session_data.get("conversation_id", "0") 212 | is_new_conversation = conversation_id == "0" 213 | 214 | request_id = f"chatcmpl-{uuid.uuid4()}" 215 | new_conversation_id = None 216 | streamed_any_data = False 217 | 218 | try: 219 | base_cookie = self.credential_manager.get_credential() 220 | final_cookie = self._get_dynamic_cookie(base_cookie) 221 | base_url = "https://www.doubao.com/samantha/chat/completion" 222 | base_params = { 223 | "aid": "497858", "device_platform": "web", "language": "zh", 224 | "pc_version": "2.41.0", "pkg_type": "release_version", "real_aid": "497858", 225 | "region": "CN", "samantha_web": "1", "sys_region": "CN", 226 | "use-olympus-account": "1", "version_code": "20800", 227 | } 228 | headers = self._prepare_headers(final_cookie) 229 | payload = self._prepare_payload(messages, bot_id, conversation_id) 230 | 231 | log_headers = headers.copy() 232 | log_headers["Cookie"] = "[REDACTED FOR SECURITY]" 233 | logger.info("--- 准备向上游发送的完整请求包 (流式) ---") 234 | logger.info(f"请求方法: POST") 235 | logger.info(f"基础URL: {base_url}") 236 | logger.info(f"请求头 (Headers):\n{json.dumps(log_headers, indent=2)}") 237 | logger.info(f"请求载荷 (Payload):\n{json.dumps(payload, indent=2, ensure_ascii=False)}") 238 | logger.info("------------------------------------") 239 | 240 | signed_url = await self.playwright_manager.get_signed_url(base_url, final_cookie, base_params) 241 | if not signed_url: 242 | raise Exception("无法获取 a_bogus 签名, Playwright 服务可能异常。") 243 | 244 | logger.info(f"签名成功,最终请求 URL: {signed_url}") 245 | 246 | # 按照用户要求,在流式输出前打印一个标识 247 | print("\n--- [流式] 响应内容 ---") 248 | 249 | async with self.client.stream("POST", signed_url, headers=headers, json=payload) as response: 250 | new_ms_token = response.headers.get("x-ms-token") 251 | if new_ms_token: 252 | self.playwright_manager.update_ms_token(new_ms_token) 253 | logger.success(f"从响应头中捕获并更新了 msToken: {new_ms_token}") 254 | 255 | if response.status_code != 200: 256 | error_content = await response.aread() 257 | logger.error(f"上游服务器返回错误状态码: {response.status_code}。") 258 | logger.error(f"上游服务器响应内容: {error_content.decode(errors='ignore')}") 259 | response.raise_for_status() 260 | 261 | logger.success(f"成功连接到上游服务器, 状态码: {response.status_code}. 开始接收响应...") 262 | 263 | async for line in response.aiter_lines(): 264 | # [诊断日志] 打印从上游收到的每一行原始数据 265 | logger.info(f"上游原始响应行: {line}") 266 | streamed_any_data = True 267 | if not line.startswith("data:"): 268 | continue 269 | content_str = line[len("data:"):].strip() 270 | if not content_str: 271 | continue 272 | 273 | try: 274 | data = json.loads(content_str) 275 | if data.get("event_type") == 2002 and not new_conversation_id: 276 | event_data = json.loads(data.get("event_data", "{}")) 277 | new_conversation_id = event_data.get("conversation_id") 278 | logger.info(f"捕获到新会话 ID: {new_conversation_id}") 279 | 280 | if data.get("event_type") == 2001: 281 | event_data = json.loads(data.get("event_data", "{}")) 282 | message_data = event_data.get("message", {}) 283 | content_json = json.loads(message_data.get("content", "{}")) 284 | delta_content = content_json.get("text", "") 285 | if delta_content: 286 | # 按照用户要求,将流式数据块直接打印到终端 287 | print(delta_content, end="", flush=True) 288 | chunk = create_chat_completion_chunk(request_id, user_model, delta_content) 289 | yield create_sse_data(chunk) 290 | except (json.JSONDecodeError, KeyError) as e: 291 | logger.warning(f"解析 SSE 数据块时跳过: {e}, 内容: {content_str}") 292 | continue 293 | 294 | # 在流式输出结束后打印换行符和结束标识 295 | if streamed_any_data: 296 | print("\n--------------------------\n") 297 | 298 | if not streamed_any_data: 299 | logger.error("上游服务器返回了 200 OK,但没有发送任何数据流。这通常是由于反爬虫策略触发。") 300 | error_message = "服务器连接成功但未返回数据流,请求可能被上游服务拦截。请检查Cookie是否过期或IP是否被限制。" 301 | error_chunk = create_chat_completion_chunk(request_id, user_model, error_message, "stop") 302 | yield create_sse_data(error_chunk) 303 | yield DONE_CHUNK 304 | return 305 | 306 | if is_new_conversation and new_conversation_id: 307 | self.session_manager.update_session(session_id, {"conversation_id": new_conversation_id}) 308 | logger.info(f"为用户 '{session_id}' 保存了新的会话 ID: {new_conversation_id}") 309 | 310 | final_chunk = create_chat_completion_chunk(request_id, user_model, "", "stop") 311 | yield create_sse_data(final_chunk) 312 | yield DONE_CHUNK 313 | 314 | except Exception as e: 315 | logger.error(f"处理流时发生严重错误: {e}", exc_info=True) 316 | # 在流式输出结束后打印换行符和结束标识 317 | print("\n--- [流式] 发生错误 ---\n") 318 | error_chunk = create_chat_completion_chunk(request_id, user_model, f"内部服务器错误: {str(e)}", "stop") 319 | yield create_sse_data(error_chunk) 320 | yield DONE_CHUNK 321 | 322 | def _prepare_headers(self, cookie: str) -> Dict[str, str]: 323 | return { 324 | "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 325 | "Content-Type": "application/json", "Cookie": cookie, 326 | "Origin": "https://www.doubao.com", "Referer": "https://www.doubao.com/chat/", 327 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", 328 | "agw-js-conv": "str, str", 329 | "sec-ch-ua": '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', 330 | "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', 331 | "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", 332 | } 333 | 334 | def _prepare_payload(self, messages: List[Dict[str, Any]], bot_id: str, conversation_id: str) -> Dict[str, Any]: 335 | last_user_message = next((m for m in reversed(messages) if m.get("role") == "user"), None) 336 | if not last_user_message: 337 | raise HTTPException(status_code=400, detail="未找到用户消息。") 338 | 339 | payload = { 340 | "messages": [{"content": json.dumps({"text": last_user_message["content"]}), "content_type": 2001, "attachments": [], "references": []}], 341 | "completion_option": { 342 | "is_regen": False, "with_suggest": True, "need_create_conversation": conversation_id == "0", 343 | "launch_stage": 1, "is_replace": False, "is_delete": False, "message_from": 0, 344 | "action_bar_skill_id": 0, "use_deep_think": False, "use_auto_cot": True, 345 | "resend_for_regen": False, "enable_commerce_credit": False, "event_id": "0" 346 | }, 347 | "evaluate_option": {"web_ab_params": ""}, 348 | "conversation_id": conversation_id, 349 | "local_conversation_id": f"local_{uuid.uuid4().hex}", 350 | "local_message_id": str(uuid.uuid4()) 351 | } 352 | 353 | if conversation_id != "0": 354 | payload["bot_id"] = bot_id 355 | 356 | return payload 357 | 358 | async def get_models(self) -> JSONResponse: 359 | return JSONResponse(content={ 360 | "object": "list", 361 | "data": [{"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"} for name in settings.MODEL_MAPPING.keys()] 362 | }) 363 | -------------------------------------------------------------------------------- /单个项目完整结构代码.txt: -------------------------------------------------------------------------------- 1 | 项目 'doubao-2api' 的结构树: 2 | 📂 doubao-2api/ 3 | 📄 .env 4 | 📄 .env.example 5 | 📄 Dockerfile 6 | 📄 docker-compose.yml 7 | 📄 main.py 8 | 📄 nginx.conf 9 | 📄 requirements.txt 10 | 📂 app/ 11 | 📂 core/ 12 | 📄 __init__.py 13 | 📄 config.py 14 | 📂 providers/ 15 | 📄 __init__.py 16 | 📄 base_provider.py 17 | 📄 doubao_provider.py 18 | 📂 services/ 19 | 📄 credential_manager.py 20 | 📄 playwright_manager.py 21 | 📄 session_manager.py 22 | 📂 utils/ 23 | 📄 sse_utils.py 24 | ================================================================================ 25 | 26 | --- 文件路径: .env --- 27 | 28 | # [自动填充] doubao-2api 生产环境配置 29 | # 该文件由 Project Chimera: Synthesis Edition 自动生成。 30 | 31 | # --- 核心安全配置 --- 32 | # 用于保护您的 API 服务的访问密钥。为安全起见,建议修改为您自己的复杂密钥。 33 | API_MASTER_KEY=1 34 | 35 | # --- 部署配置 --- 36 | # Nginx 对外暴露的端口 37 | NGINX_PORT=8088 38 | 39 | # --- 豆包凭证 (支持多账号) --- 40 | # 已从您提供的抓包数据中自动提取。 41 | # 您可以添加 DOUBAO_COOKIE_2, DOUBAO_COOKIE_3 等来启用多账号轮询。 42 | # 关键修复:将所有 $ 替换为 $$ 以防止 docker-compose 错误地进行变量替换 43 | DOUBAO_COOKIE_1="_ga=GA1.1.106161677.1751986993; flow_user_country=CN; gd_random=eyJtYXRjaCI6dHJ1ZSwicGVyY2VudCI6MC40MDU4OTQxMTgwNjU5MTE5fQ==.uh5yd/EnUakcjRfWWa6OAVFeFHG5u3323TQ8c+A+MLk=; i18next=zh; flow_ssr_sidebar_expand=1; s_v_web_id=verify_mgyqvccs_blJSa2yy_7EW7_4Hyr_Ato6_bIPsXGNXitoz; passport_csrf_token=9eb3d0afec2be115cdb721e991cad1b3; passport_csrf_token_default=9eb3d0afec2be115cdb721e991cad1b3; passport_mfa_token=CjH5jul%2F30qQ%2BaY1jB%2Bnx8LpCcE48Hfop4c3MhxuEeBUFGs%2F8N4JhuZF4s7GeMeZN4w7GkoKPAAAAAAAAAAAAABPnWDhWnZOf2mKtl3lVJ%2FVMkKzWl5s%2BC8ctO%2BrbX8YwHlINTsmlfrNIskE71bYKnJQZRCEqf8NGPax0WwgAiIBA94Lbu8%3D; d_ticket=36e308ea7e3ffb14723f2139e51a83650034a; odin_tt=2eaa81b7b48fba60218c5f378553f4ce3c982fb25d8ee50efd4068f0427b0d95acf5f4570a1dc7c49aca53a61ed61ffb61c9e3f6c3eed5aa61fdb4ad6e00367f; n_mh=-FPXT10Y1ouY2RTXstCfFAbnlgz1v6FIer_PG9SzZ44; passport_auth_status=ba23b06ba9b3eb71cad40d03cc59fee6%2C; passport_auth_status_ss=ba23b06ba9b3eb71cad40d03cc59fee6%2C; sid_guard=3aa7bb87c6eeb0906760f16063aed75e%7C1760941125%7C2592000%7CWed%2C+19-Nov-2025+06%3A18%3A45+GMT; uid_tt=111f0e12e200a498139cb9e298827580; uid_tt_ss=111f0e12e200a498139cb9e298827580; sid_tt=3aa7bb87c6eeb0906760f16063aed75e; sessionid=3aa7bb87c6eeb0906760f16063aed75e; sessionid_ss=3aa7bb87c6eeb0906760f16063aed75e; session_tlb_tag=sttt%7C12%7COqe7h8busJBnYPFgY67XXv__________uXbtksAL_RkZw0L15F060kJ4FWWF3mfsmREcj6H4lXQ%3D; is_staff_user=false; sid_ucp_v1=1.0.0-KGI3YjM1OTk4NzUzOTM2MTY1Yjc4ZWM2M2Y0MDM3NmYwZTZhYzdjMzQKIAj5tLDq9a3wARDFqNfHBhjCsR4gDDDFqNfHBjgCQOwHGgJsZiIgM2FhN2JiODdjNmVlYjA5MDY3NjBmMTYwNjNhZWQ3NWU; ssid_ucp_v1=1.0.0-KGI3YjM1OTk4NzUzOTM2MTY1Yjc4ZWM2M2Y0MDM3NmYwZTZhYzdjMzQKIAj5tLDq9a3wARDFqNfHBhjCsR4gDDDFqNfHBjgCQOwHGgJsZiIgM2FhN2JiODdjNmVlYjA5MDY3NjBmMTYwNjNhZWQ3NWU; ttwid=1%7CYEzH0bhSHZjjqJLjeG5eHfpnB2RWiIe7DuumYAzUrDM%7C1760941158%7Cb7af9ac18f1a4364c95082df532abdbe68c1cb101f0af864be1eed84eff356a2; passport_fe_beating_status=true; _ga_G8EP5CG8VZ=GS2.1.s1760941083$$o54$$g1$$t1760941158$$j60$$l0$$h0" 44 | 45 | # --- 核心变更: 静态设备指纹 --- 46 | # 从浏览器抓包的有效请求中提取,以替换不稳定、易失效的动态嗅探。 47 | # 如果未来服务失效,大概率是这些值过期了,届时请重新抓包并更新。 48 | DOUBAO_DEVICE_ID=7524726744148264511 49 | DOUBAO_FP=verify_mgyqvccs_blJSa2yy_7EW7_4Hyr_Ato6_bIPsXGNXitoz 50 | DOUBAO_TEA_UUID=7524726753203160619 51 | DOUBAO_WEB_ID=7524726753203160619 52 | 53 | # --- 会话管理 (可选) --- 54 | # 对话历史在内存中的缓存时间(秒),默认1小时 55 | SESSION_CACHE_TTL=3600 56 | 57 | 58 | --- 文件路径: .env.example --- 59 | 60 | # /.env.example 61 | # ==================================================================== 62 | # doubao-2api 配置文件模板 63 | # ==================================================================== 64 | # 65 | # 请将此文件重命名为 ".env" 并填入您的凭证。 66 | # 67 | 68 | # --- 核心安全配置 (必须设置) --- 69 | # 用于保护您 API 服务的访问密钥。 70 | API_MASTER_KEY=sk-doubao-2api-default-key-please-change-me 71 | 72 | # --- 部署配置 (可选) --- 73 | # Nginx 对外暴露的端口 74 | NGINX_PORT=8088 75 | 76 | # --- 豆包凭证 (必须设置) --- 77 | # 请从浏览器开发者工具中获取完整的 Cookie 字符串。 78 | # 登录 https://www.doubao.com/chat/ 后,按 F12 打开开发者工具, 79 | # 切换到“网络(Network)”面板,随便发送一条消息, 80 | # 在请求列表中找到 `completion` 请求,右键 -> 复制 -> 复制为 cURL (bash), 81 | # 然后从 cURL 命令中找到 `--cookie '...'` 部分,将其中的内容粘贴到下方。 82 | # 83 | # 支持多账号轮询,只需按格式添加 DOUBAO_COOKIE_2, DOUBAO_COOKIE_3, ... 84 | DOUBAO_COOKIE_1="在此处粘贴您的完整 Cookie 字符串" 85 | 86 | # --- 会话管理 (可选) --- 87 | # 对话历史在内存中的缓存时间(秒),默认1小时 88 | SESSION_CACHE_TTL=3600 89 | 90 | 91 | --- 文件路径: Dockerfile --- 92 | 93 | # /Dockerfile 94 | # ==================================================================== 95 | # Dockerfile for doubao-2api (v1.4 - Patched for User Permissions) 96 | # ==================================================================== 97 | 98 | # 使用一个稳定、广泛支持的 Debian 版本作为基础镜像 99 | FROM python:3.10-slim-bookworm 100 | 101 | ENV PYTHONDONTWRITEBYTECODE=1 102 | ENV PYTHONUNBUFFERED=1 103 | WORKDIR /app 104 | 105 | # 关键修正: 一次性、完整地安装所有系统依赖 106 | # 合并了 Playwright 官方建议的核心库和我们之前发现的字体库 107 | RUN apt-get update && apt-get install -y --no-install-recommends \ 108 | # Playwright 核心依赖 109 | libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ 110 | libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \ 111 | libxrandr2 libgbm1 libasound2 libatspi2.0-0 \ 112 | # 官方错误日志中明确提示缺少的关键库 113 | libpango-1.0-0 libcairo2 \ 114 | # 解决字体问题的包 115 | fonts-unifont fonts-liberation \ 116 | # 清理 apt 缓存以减小镜像体积 117 | && rm -rf /var/lib/apt/lists/* 118 | 119 | # 安装 Python 依赖 120 | COPY requirements.txt . 121 | RUN pip install --no-cache-dir --upgrade pip && \ 122 | pip install --no-cache-dir -r requirements.txt 123 | 124 | # 复制应用代码 125 | COPY . . 126 | 127 | # 创建并切换到非 root 用户 128 | # 这一步很关键,我们先创建用户,然后以该用户身份安装浏览器 129 | RUN useradd --create-home appuser && \ 130 | chown -R appuser:appuser /app 131 | USER appuser 132 | 133 | # 核心修复:以 appuser 的身份安装 Chromium 浏览器 134 | # 这可以确保浏览器安装在 /home/appuser/.cache/ms-playwright/ 目录下, 135 | # 与应用运行时查找的路径一致,从而解决 "Executable doesn't exist" 错误。 136 | RUN playwright install chromium 137 | 138 | # 暴露端口并启动 139 | EXPOSE 8000 140 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] 141 | 142 | 143 | --- 文件路径: docker-compose.yml --- 144 | 145 | # /docker-compose.yml 146 | services: 147 | nginx: 148 | image: nginx:latest 149 | container_name: doubao-2api-nginx 150 | restart: always 151 | ports: 152 | - "${NGINX_PORT:-8088}:80" 153 | volumes: 154 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 155 | depends_on: 156 | - app 157 | networks: 158 | - doubao-net 159 | 160 | app: 161 | build: 162 | context: . 163 | dockerfile: Dockerfile 164 | container_name: doubao-2api-app 165 | restart: unless-stopped 166 | env_file: 167 | - .env 168 | networks: 169 | - doubao-net 170 | 171 | networks: 172 | doubao-net: 173 | driver: bridge 174 | 175 | 176 | --- 文件路径: main.py --- 177 | 178 | # /main.py 179 | import sys 180 | import json 181 | from contextlib import asynccontextmanager 182 | from typing import Optional 183 | 184 | from fastapi import FastAPI, Request, HTTPException, Depends, Header 185 | from fastapi.responses import JSONResponse, StreamingResponse 186 | from loguru import logger 187 | 188 | from app.core.config import settings 189 | from app.providers.doubao_provider import DoubaoProvider 190 | 191 | # --- 配置 Loguru --- 192 | logger.remove() 193 | logger.add( 194 | sys.stdout, 195 | level="INFO", 196 | format="{time:YYYY-MM-DD HH:mm:ss.SSS} | " 197 | "{level: <8} | " 198 | "{name}:{function}:{line} - {message}", 199 | colorize=True 200 | ) 201 | 202 | # --- 全局 Provider 实例 --- 203 | provider: Optional[DoubaoProvider] = None 204 | 205 | @asynccontextmanager 206 | async def lifespan(app: FastAPI): 207 | global provider 208 | logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}") 209 | provider = DoubaoProvider() 210 | await provider.initialize() 211 | logger.info("服务已进入 'JS-Signature-as-a-Service' 模式。") 212 | logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用") 213 | yield 214 | await provider.close() 215 | logger.info("应用关闭。") 216 | 217 | app = FastAPI( 218 | title=settings.APP_NAME, 219 | version=settings.APP_VERSION, 220 | description=settings.DESCRIPTION, 221 | lifespan=lifespan 222 | ) 223 | 224 | # --- 安全依赖 --- 225 | async def verify_api_key(authorization: Optional[str] = Header(None)): 226 | if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1": 227 | if not authorization or "bearer" not in authorization.lower(): 228 | raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。") 229 | token = authorization.split(" ")[-1] 230 | if token != settings.API_MASTER_KEY: 231 | raise HTTPException(status_code=403, detail="无效的 API Key。") 232 | 233 | # --- API 路由 --- 234 | @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) 235 | async def chat_completions(request: Request): 236 | try: 237 | request_data = await request.json() 238 | logger.info(f"收到客户端请求 /v1/chat/completions:\n{json.dumps(request_data, indent=2, ensure_ascii=False)}") 239 | return await provider.chat_completion(request_data) 240 | except Exception as e: 241 | logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True) 242 | if isinstance(e, HTTPException): 243 | raise e 244 | raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}") 245 | 246 | @app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse) 247 | async def list_models(): 248 | return await provider.get_models() 249 | 250 | @app.get("/", summary="根路径", include_in_schema=False) 251 | def root(): 252 | return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"} 253 | 254 | 255 | --- 文件路径: nginx.conf --- 256 | 257 | # /nginx.conf 258 | worker_processes auto; 259 | 260 | events { 261 | worker_connections 1024; 262 | } 263 | 264 | http { 265 | upstream doubao_backend { 266 | # 由于应用内部已实现有状态会话管理,此处无需 ip_hash 267 | server app:8000; 268 | } 269 | 270 | server { 271 | listen 80; 272 | server_name localhost; 273 | 274 | location / { 275 | proxy_pass http://doubao_backend; 276 | proxy_set_header Host $host; 277 | proxy_set_header X-Real-IP $remote_addr; 278 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 279 | proxy_set_header X-Forwarded-Proto $scheme; 280 | 281 | # 流式传输优化 282 | proxy_buffering off; 283 | proxy_cache off; 284 | proxy_set_header Connection ''; 285 | proxy_http_version 1.1; 286 | chunked_transfer_encoding off; 287 | } 288 | } 289 | } 290 | 291 | 292 | --- 文件路径: requirements.txt --- 293 | 294 | # /requirements.txt 295 | fastapi 296 | uvicorn 297 | pydantic-settings 298 | python-dotenv 299 | cloudscraper 300 | cachetools 301 | httpx 302 | loguru 303 | playwright==1.44.0 304 | playwright-stealth==1.0.6 305 | 306 | 307 | --- 文件路径: app\core\__init__.py --- 308 | 309 | 310 | 311 | --- 文件路径: app\core\config.py --- 312 | 313 | # /app/core/config.py 314 | import os 315 | import uuid 316 | from pydantic_settings import BaseSettings, SettingsConfigDict 317 | from pydantic import model_validator 318 | from typing import Optional, List, Dict 319 | 320 | class Settings(BaseSettings): 321 | model_config = SettingsConfigDict( 322 | env_file=".env", 323 | env_file_encoding='utf-8', 324 | extra="ignore" 325 | ) 326 | 327 | APP_NAME: str = "doubao-2api" 328 | APP_VERSION: str = "1.0.0" 329 | DESCRIPTION: str = "一个将 doubao.com 转换为兼容 OpenAI 格式 API 的高性能代理,内置 a_bogus 签名解决方案。" 330 | 331 | # --- 核心安全与部署配置 --- 332 | API_MASTER_KEY: Optional[str] = "1" 333 | NGINX_PORT: int = 8088 334 | 335 | # --- Doubao 凭证 --- 336 | DOUBAO_COOKIES: List[str] = [] 337 | 338 | # --- 核心变更: 静态设备指纹配置 --- 339 | # 从您提供的有效请求中提取的静态设备指纹,这比动态嗅探稳定得多 340 | # 如果未来失效,只需从浏览器抓取新的请求并更新此处的值 341 | DOUBAO_DEVICE_ID: Optional[str] = None 342 | DOUBAO_FP: Optional[str] = None 343 | DOUBAO_TEA_UUID: Optional[str] = None 344 | DOUBAO_WEB_ID: Optional[str] = None 345 | 346 | # --- 上游 API 配置 --- 347 | API_REQUEST_TIMEOUT: int = 180 348 | 349 | # --- 会话管理 --- 350 | SESSION_CACHE_TTL: int = 3600 351 | 352 | # --- 模型配置 --- 353 | DEFAULT_MODEL: str = "doubao-pro-chat" 354 | MODEL_MAPPING: Dict[str, str] = { 355 | "doubao-pro-chat": "7338286299411103781", # 默认模型 Bot ID 356 | } 357 | 358 | @model_validator(mode='after') 359 | def validate_settings(self) -> 'Settings': 360 | # 从环境变量 DOUBAO_COOKIE_1, DOUBAO_COOKIE_2, ... 加载 cookies 361 | i = 1 362 | while True: 363 | cookie_str = os.getenv(f"DOUBAO_COOKIE_{i}") 364 | if cookie_str: 365 | self.DOUBAO_COOKIES.append(cookie_str) 366 | i += 1 367 | else: 368 | break 369 | 370 | if not self.DOUBAO_COOKIES: 371 | raise ValueError("必须在 .env 文件中至少配置一个有效的 DOUBAO_COOKIE_1") 372 | 373 | # --- 核心变更: 验证设备指纹是否已配置 --- 374 | if not all([self.DOUBAO_DEVICE_ID, self.DOUBAO_FP, self.DOUBAO_TEA_UUID, self.DOUBAO_WEB_ID]): 375 | raise ValueError("必须在 .env 文件中配置完整的设备指纹参数 (DOUBAO_DEVICE_ID, DOUBAO_FP, DOUBAO_TEA_UUID, DOUBAO_WEB_ID)") 376 | 377 | return self 378 | 379 | settings = Settings() 380 | 381 | 382 | --- 文件路径: app\providers\__init__.py --- 383 | 384 | 385 | 386 | --- 文件路径: app\providers\base_provider.py --- 387 | 388 | from abc import ABC, abstractmethod 389 | from typing import Dict, Any 390 | from fastapi.responses import StreamingResponse, JSONResponse 391 | 392 | class BaseProvider(ABC): 393 | @abstractmethod 394 | async def chat_completion( 395 | self, 396 | request_data: Dict[str, Any] 397 | ) -> StreamingResponse: 398 | pass 399 | 400 | @abstractmethod 401 | async def get_models(self) -> JSONResponse: 402 | pass 403 | 404 | 405 | --- 文件路径: app\providers\doubao_provider.py --- 406 | 407 | # /app/providers/doubao_provider.py 408 | import json 409 | import re 410 | import time 411 | import uuid 412 | from typing import Dict, Any, AsyncGenerator, List 413 | 414 | import httpx 415 | from fastapi import HTTPException 416 | from fastapi.responses import StreamingResponse, JSONResponse 417 | from loguru import logger 418 | 419 | from app.core.config import settings 420 | from app.providers.base_provider import BaseProvider 421 | from app.services.credential_manager import CredentialManager 422 | from app.services.playwright_manager import PlaywrightManager 423 | from app.services.session_manager import SessionManager 424 | from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK 425 | 426 | 427 | class DoubaoProvider(BaseProvider): 428 | def __init__(self): 429 | self.credential_manager = CredentialManager(settings.DOUBAO_COOKIES) 430 | self.session_manager = SessionManager() 431 | self.playwright_manager = PlaywrightManager() 432 | self.client: httpx.AsyncClient = None 433 | 434 | async def initialize(self): 435 | self.client = httpx.AsyncClient(timeout=settings.API_REQUEST_TIMEOUT) 436 | await self.playwright_manager.initialize(self.credential_manager.credentials) 437 | 438 | async def close(self): 439 | if self.client: 440 | await self.client.aclose() 441 | await self.playwright_manager.close() 442 | 443 | def _get_dynamic_cookie(self, base_cookie: str) -> str: 444 | """ 445 | 用 Playwright 捕获的最新 msToken 更新基础 Cookie 字符串。 446 | 这是确保签名和请求头一致性的关键。 447 | """ 448 | latest_ms_token = self.playwright_manager.ms_token 449 | if not latest_ms_token: 450 | logger.warning("动态 Cookie 更新失败:Playwright 管理器中没有可用的 msToken。将使用原始 Cookie。") 451 | return base_cookie 452 | 453 | if 'msToken=' in base_cookie: 454 | new_cookie = re.sub(r'msToken=[^;]+', f'msToken={latest_ms_token}', base_cookie) 455 | logger.info("成功将动态 msToken 更新到 Cookie 头中。") 456 | else: 457 | new_cookie = f"{base_cookie.strip(';')}; msToken={latest_ms_token}" 458 | logger.info("原始 Cookie 中未找到 msToken,已追加最新的 msToken。") 459 | 460 | return new_cookie 461 | 462 | async def chat_completion(self, request_data: Dict[str, Any]): 463 | """ 464 | 根据请求中的 'stream' 参数,分发到流式或非流式处理函数。 465 | """ 466 | is_stream = request_data.get("stream", True) 467 | 468 | if is_stream: 469 | return StreamingResponse(self._stream_generator(request_data), media_type="text/event-stream") 470 | else: 471 | return await self._non_stream_completion(request_data) 472 | 473 | async def _non_stream_completion(self, request_data: Dict[str, Any]) -> JSONResponse: 474 | """ 475 | 处理非流式聊天补全请求。 476 | """ 477 | session_id = request_data.get("user", f"session-{uuid.uuid4().hex}") 478 | messages = request_data.get("messages", []) 479 | user_model = request_data.get("model", settings.DEFAULT_MODEL) 480 | 481 | bot_id = settings.MODEL_MAPPING.get(user_model) 482 | if not bot_id: 483 | raise HTTPException(status_code=400, detail=f"不支持的模型: {user_model}") 484 | 485 | session_data = self.session_manager.get_session(session_id) or {} 486 | conversation_id = session_data.get("conversation_id", "0") 487 | is_new_conversation = conversation_id == "0" 488 | 489 | request_id = f"chatcmpl-{uuid.uuid4()}" 490 | new_conversation_id = None 491 | full_content = [] 492 | streamed_any_data = False 493 | 494 | try: 495 | base_cookie = self.credential_manager.get_credential() 496 | final_cookie = self._get_dynamic_cookie(base_cookie) 497 | base_url = "https://www.doubao.com/samantha/chat/completion" 498 | base_params = { 499 | "aid": "497858", "device_platform": "web", "language": "zh", 500 | "pc_version": "2.41.0", "pkg_type": "release_version", "real_aid": "497858", 501 | "region": "CN", "samantha_web": "1", "sys_region": "CN", 502 | "use-olympus-account": "1", "version_code": "20800", 503 | } 504 | headers = self._prepare_headers(final_cookie) 505 | payload = self._prepare_payload(messages, bot_id, conversation_id) 506 | 507 | log_headers = headers.copy() 508 | log_headers["Cookie"] = "[REDACTED FOR SECURITY]" 509 | logger.info("--- 准备向上游发送的完整请求包 (非流式) ---") 510 | logger.info(f"请求方法: POST") 511 | logger.info(f"基础URL: {base_url}") 512 | logger.info(f"请求头 (Headers):\n{json.dumps(log_headers, indent=2)}") 513 | logger.info(f"请求载荷 (Payload):\n{json.dumps(payload, indent=2, ensure_ascii=False)}") 514 | logger.info("------------------------------------") 515 | 516 | signed_url = await self.playwright_manager.get_signed_url(base_url, final_cookie, base_params) 517 | if not signed_url: 518 | raise Exception("无法获取 a_bogus 签名, Playwright 服务可能异常。") 519 | 520 | logger.info(f"签名成功,最终请求 URL: {signed_url}") 521 | 522 | async with self.client.stream("POST", signed_url, headers=headers, json=payload) as response: 523 | new_ms_token = response.headers.get("x-ms-token") 524 | if new_ms_token: 525 | self.playwright_manager.update_ms_token(new_ms_token) 526 | logger.success(f"从响应头中捕获并更新了 msToken: {new_ms_token}") 527 | 528 | if response.status_code != 200: 529 | error_content = await response.aread() 530 | logger.error(f"上游服务器返回错误状态码: {response.status_code}。") 531 | logger.error(f"上游服务器响应内容: {error_content.decode(errors='ignore')}") 532 | response.raise_for_status() 533 | 534 | logger.success(f"成功连接到上游服务器, 状态码: {response.status_code}. 开始接收响应...") 535 | 536 | async for line in response.aiter_lines(): 537 | # [诊断日志] 打印从上游收到的每一行原始数据 538 | logger.info(f"上游原始响应行: {line}") 539 | streamed_any_data = True 540 | if not line.startswith("data:"): 541 | continue 542 | content_str = line[len("data:"):].strip() 543 | if not content_str: 544 | continue 545 | 546 | try: 547 | data = json.loads(content_str) 548 | if data.get("event_type") == 2002 and not new_conversation_id: 549 | event_data = json.loads(data.get("event_data", "{}")) 550 | new_conversation_id = event_data.get("conversation_id") 551 | logger.info(f"捕获到新会话 ID: {new_conversation_id}") 552 | 553 | if data.get("event_type") == 2001: 554 | event_data = json.loads(data.get("event_data", "{}")) 555 | message_data = event_data.get("message", {}) 556 | content_json = json.loads(message_data.get("content", "{}")) 557 | delta_content = content_json.get("text", "") 558 | if delta_content: 559 | full_content.append(delta_content) 560 | except (json.JSONDecodeError, KeyError) as e: 561 | logger.warning(f"解析 SSE 数据块时跳过: {e}, 内容: {content_str}") 562 | continue 563 | 564 | if not streamed_any_data: 565 | logger.error("上游服务器返回了 200 OK,但没有发送任何数据流。这通常是由于反爬虫策略触发。") 566 | raise Exception("服务器连接成功但未返回数据流,请求可能被上游服务拦截。请检查Cookie是否过期或IP是否被限制。") 567 | 568 | if is_new_conversation and new_conversation_id: 569 | self.session_manager.update_session(session_id, {"conversation_id": new_conversation_id}) 570 | logger.info(f"为用户 '{session_id}' 保存了新的会话 ID: {new_conversation_id}") 571 | 572 | final_text = "".join(full_content) 573 | 574 | # 按照用户要求,将完整的响应内容打印到终端 575 | print("\n--- [非流式] 完整响应内容 ---") 576 | print(final_text) 577 | print("---------------------------------\n") 578 | 579 | response_data = { 580 | "id": request_id, 581 | "object": "chat.completion", 582 | "created": int(time.time()), 583 | "model": user_model, 584 | "choices": [{ 585 | "index": 0, 586 | "message": {"role": "assistant", "content": final_text}, 587 | "finish_reason": "stop" 588 | }], 589 | "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} 590 | } 591 | return JSONResponse(content=response_data) 592 | 593 | except Exception as e: 594 | logger.error(f"处理非流式请求时发生严重错误: {e}", exc_info=True) 595 | return JSONResponse( 596 | status_code=500, 597 | content={"error": {"message": f"内部服务器错误: {str(e)}", "type": "server_error", "code": None}} 598 | ) 599 | 600 | async def _stream_generator(self, request_data: Dict[str, Any]) -> AsyncGenerator[bytes, None]: 601 | """ 602 | 处理流式聊天补全请求。 603 | """ 604 | session_id = request_data.get("user", f"session-{uuid.uuid4().hex}") 605 | messages = request_data.get("messages", []) 606 | user_model = request_data.get("model", settings.DEFAULT_MODEL) 607 | 608 | bot_id = settings.MODEL_MAPPING.get(user_model) 609 | if not bot_id: 610 | # This should be handled before calling the generator, but as a safeguard: 611 | error_chunk = create_chat_completion_chunk(f"chatcmpl-{uuid.uuid4()}", user_model, f"不支持的模型: {user_model}", "stop") 612 | yield create_sse_data(error_chunk) 613 | yield DONE_CHUNK 614 | return 615 | 616 | session_data = self.session_manager.get_session(session_id) or {} 617 | conversation_id = session_data.get("conversation_id", "0") 618 | is_new_conversation = conversation_id == "0" 619 | 620 | request_id = f"chatcmpl-{uuid.uuid4()}" 621 | new_conversation_id = None 622 | streamed_any_data = False 623 | 624 | try: 625 | base_cookie = self.credential_manager.get_credential() 626 | final_cookie = self._get_dynamic_cookie(base_cookie) 627 | base_url = "https://www.doubao.com/samantha/chat/completion" 628 | base_params = { 629 | "aid": "497858", "device_platform": "web", "language": "zh", 630 | "pc_version": "2.41.0", "pkg_type": "release_version", "real_aid": "497858", 631 | "region": "CN", "samantha_web": "1", "sys_region": "CN", 632 | "use-olympus-account": "1", "version_code": "20800", 633 | } 634 | headers = self._prepare_headers(final_cookie) 635 | payload = self._prepare_payload(messages, bot_id, conversation_id) 636 | 637 | log_headers = headers.copy() 638 | log_headers["Cookie"] = "[REDACTED FOR SECURITY]" 639 | logger.info("--- 准备向上游发送的完整请求包 (流式) ---") 640 | logger.info(f"请求方法: POST") 641 | logger.info(f"基础URL: {base_url}") 642 | logger.info(f"请求头 (Headers):\n{json.dumps(log_headers, indent=2)}") 643 | logger.info(f"请求载荷 (Payload):\n{json.dumps(payload, indent=2, ensure_ascii=False)}") 644 | logger.info("------------------------------------") 645 | 646 | signed_url = await self.playwright_manager.get_signed_url(base_url, final_cookie, base_params) 647 | if not signed_url: 648 | raise Exception("无法获取 a_bogus 签名, Playwright 服务可能异常。") 649 | 650 | logger.info(f"签名成功,最终请求 URL: {signed_url}") 651 | 652 | # 按照用户要求,在流式输出前打印一个标识 653 | print("\n--- [流式] 响应内容 ---") 654 | 655 | async with self.client.stream("POST", signed_url, headers=headers, json=payload) as response: 656 | new_ms_token = response.headers.get("x-ms-token") 657 | if new_ms_token: 658 | self.playwright_manager.update_ms_token(new_ms_token) 659 | logger.success(f"从响应头中捕获并更新了 msToken: {new_ms_token}") 660 | 661 | if response.status_code != 200: 662 | error_content = await response.aread() 663 | logger.error(f"上游服务器返回错误状态码: {response.status_code}。") 664 | logger.error(f"上游服务器响应内容: {error_content.decode(errors='ignore')}") 665 | response.raise_for_status() 666 | 667 | logger.success(f"成功连接到上游服务器, 状态码: {response.status_code}. 开始接收响应...") 668 | 669 | async for line in response.aiter_lines(): 670 | # [诊断日志] 打印从上游收到的每一行原始数据 671 | logger.info(f"上游原始响应行: {line}") 672 | streamed_any_data = True 673 | if not line.startswith("data:"): 674 | continue 675 | content_str = line[len("data:"):].strip() 676 | if not content_str: 677 | continue 678 | 679 | try: 680 | data = json.loads(content_str) 681 | if data.get("event_type") == 2002 and not new_conversation_id: 682 | event_data = json.loads(data.get("event_data", "{}")) 683 | new_conversation_id = event_data.get("conversation_id") 684 | logger.info(f"捕获到新会话 ID: {new_conversation_id}") 685 | 686 | if data.get("event_type") == 2001: 687 | event_data = json.loads(data.get("event_data", "{}")) 688 | message_data = event_data.get("message", {}) 689 | content_json = json.loads(message_data.get("content", "{}")) 690 | delta_content = content_json.get("text", "") 691 | if delta_content: 692 | # 按照用户要求,将流式数据块直接打印到终端 693 | print(delta_content, end="", flush=True) 694 | chunk = create_chat_completion_chunk(request_id, user_model, delta_content) 695 | yield create_sse_data(chunk) 696 | except (json.JSONDecodeError, KeyError) as e: 697 | logger.warning(f"解析 SSE 数据块时跳过: {e}, 内容: {content_str}") 698 | continue 699 | 700 | # 在流式输出结束后打印换行符和结束标识 701 | if streamed_any_data: 702 | print("\n--------------------------\n") 703 | 704 | if not streamed_any_data: 705 | logger.error("上游服务器返回了 200 OK,但没有发送任何数据流。这通常是由于反爬虫策略触发。") 706 | error_message = "服务器连接成功但未返回数据流,请求可能被上游服务拦截。请检查Cookie是否过期或IP是否被限制。" 707 | error_chunk = create_chat_completion_chunk(request_id, user_model, error_message, "stop") 708 | yield create_sse_data(error_chunk) 709 | yield DONE_CHUNK 710 | return 711 | 712 | if is_new_conversation and new_conversation_id: 713 | self.session_manager.update_session(session_id, {"conversation_id": new_conversation_id}) 714 | logger.info(f"为用户 '{session_id}' 保存了新的会话 ID: {new_conversation_id}") 715 | 716 | final_chunk = create_chat_completion_chunk(request_id, user_model, "", "stop") 717 | yield create_sse_data(final_chunk) 718 | yield DONE_CHUNK 719 | 720 | except Exception as e: 721 | logger.error(f"处理流时发生严重错误: {e}", exc_info=True) 722 | # 在流式输出结束后打印换行符和结束标识 723 | print("\n--- [流式] 发生错误 ---\n") 724 | error_chunk = create_chat_completion_chunk(request_id, user_model, f"内部服务器错误: {str(e)}", "stop") 725 | yield create_sse_data(error_chunk) 726 | yield DONE_CHUNK 727 | 728 | def _prepare_headers(self, cookie: str) -> Dict[str, str]: 729 | return { 730 | "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 731 | "Content-Type": "application/json", "Cookie": cookie, 732 | "Origin": "https://www.doubao.com", "Referer": "https://www.doubao.com/chat/", 733 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", 734 | "agw-js-conv": "str, str", 735 | "sec-ch-ua": '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', 736 | "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', 737 | "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", 738 | } 739 | 740 | def _prepare_payload(self, messages: List[Dict[str, Any]], bot_id: str, conversation_id: str) -> Dict[str, Any]: 741 | last_user_message = next((m for m in reversed(messages) if m.get("role") == "user"), None) 742 | if not last_user_message: 743 | raise HTTPException(status_code=400, detail="未找到用户消息。") 744 | 745 | payload = { 746 | "messages": [{"content": json.dumps({"text": last_user_message["content"]}), "content_type": 2001, "attachments": [], "references": []}], 747 | "completion_option": { 748 | "is_regen": False, "with_suggest": True, "need_create_conversation": conversation_id == "0", 749 | "launch_stage": 1, "is_replace": False, "is_delete": False, "message_from": 0, 750 | "action_bar_skill_id": 0, "use_deep_think": False, "use_auto_cot": True, 751 | "resend_for_regen": False, "enable_commerce_credit": False, "event_id": "0" 752 | }, 753 | "evaluate_option": {"web_ab_params": ""}, 754 | "conversation_id": conversation_id, 755 | "local_conversation_id": f"local_{uuid.uuid4().hex}", 756 | "local_message_id": str(uuid.uuid4()) 757 | } 758 | 759 | if conversation_id != "0": 760 | payload["bot_id"] = bot_id 761 | 762 | return payload 763 | 764 | async def get_models(self) -> JSONResponse: 765 | return JSONResponse(content={ 766 | "object": "list", 767 | "data": [{"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"} for name in settings.MODEL_MAPPING.keys()] 768 | }) 769 | 770 | 771 | --- 文件路径: app\services\credential_manager.py --- 772 | 773 | # /app/services/credential_manager.py 774 | import threading 775 | from typing import List 776 | from loguru import logger 777 | 778 | class CredentialManager: 779 | def __init__(self, credentials: List[str]): 780 | if not credentials: 781 | raise ValueError("凭证列表不能为空。") 782 | self.credentials = credentials 783 | self.index = 0 784 | self.lock = threading.Lock() 785 | logger.info(f"凭证管理器已初始化,共加载 {len(self.credentials)} 个凭证。") 786 | 787 | def get_credential(self) -> str: 788 | with self.lock: 789 | credential = self.credentials[self.index] 790 | self.index = (self.index + 1) % len(self.credentials) 791 | logger.debug(f"轮询到凭证索引: {self.index}") 792 | return credential 793 | 794 | 795 | --- 文件路径: app\services\playwright_manager.py --- 796 | 797 | # /app/services/playwright_manager.py 798 | import asyncio 799 | import json 800 | import uuid 801 | from typing import Optional, Dict, List 802 | from urllib.parse import urlencode, urlparse 803 | 804 | from playwright_stealth import stealth_async 805 | from playwright.async_api import async_playwright, Browser, Page, ConsoleMessage, TimeoutError, Route, Request 806 | from loguru import logger 807 | 808 | from app.core.config import settings # 导入 settings 809 | 810 | def handle_console_message(msg: ConsoleMessage): 811 | """将浏览器控制台日志转发到 Loguru,并过滤已知噪音""" 812 | log_level = msg.type.upper() 813 | text = msg.text 814 | # 过滤掉常见的、无害的浏览器噪音 815 | if "Failed to load resource" in text or "net::ERR_FAILED" in text: 816 | return 817 | if "WebSocket connection" in text: 818 | return 819 | if "Content Security Policy" in text: 820 | return 821 | if "Scripts may close only the windows that were opened by them" in text: 822 | return 823 | if "Ignoring too frequent calls to print()" in text: 824 | return 825 | 826 | log_message = f"[Browser Console] {text}" 827 | if log_level == "ERROR": 828 | logger.error(log_message) 829 | elif log_level == "WARNING": 830 | logger.warning(log_message) 831 | else: 832 | pass 833 | 834 | class PlaywrightManager: 835 | _instance = None 836 | _lock = asyncio.Lock() 837 | 838 | def __new__(cls): 839 | if cls._instance is None: 840 | cls._instance = super(PlaywrightManager, cls).__new__(cls) 841 | cls._instance._initialized = False 842 | return cls._instance 843 | 844 | async def initialize(self, cookies: List[str]): 845 | if self._initialized: 846 | return 847 | async with self._lock: 848 | if self._initialized: 849 | return 850 | logger.info("正在初始化 Playwright 管理器 (签名服务模式)...") 851 | self.playwright = await async_playwright().start() 852 | self.browser = await self.playwright.chromium.launch( 853 | headless=True, 854 | args=["--no-sandbox", "--disable-setuid-sandbox"] 855 | ) 856 | self.page = await self.browser.new_page() 857 | 858 | await stealth_async(self.page) 859 | self.page.on("console", handle_console_message) 860 | await self.page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})") 861 | 862 | self.static_device_fingerprint = { 863 | 'device_id': settings.DOUBAO_DEVICE_ID, 864 | 'fp': settings.DOUBAO_FP, 865 | 'web_id': settings.DOUBAO_WEB_ID, 866 | 'tea_uuid': settings.DOUBAO_TEA_UUID 867 | } 868 | logger.success(f"已从配置中加载静态设备指纹: {self.static_device_fingerprint}") 869 | 870 | self.ms_token = None 871 | 872 | async def _handle_response(response): 873 | try: 874 | if 'x-ms-token' in response.headers: 875 | token = response.headers['x-ms-token'] 876 | if token != self.ms_token: 877 | self.ms_token = token 878 | logger.success(f"通过响应头捕获到新的 msToken: {self.ms_token}") 879 | except Exception as e: 880 | logger.warning(f"处理响应时出错: {e} (URL: {response.url})") 881 | 882 | self.page.on("response", _handle_response) 883 | 884 | if not cookies: 885 | raise ValueError("Playwright 初始化需要至少一个有效的 Cookie。") 886 | 887 | logger.info("正在为初始页面加载设置 Cookie...") 888 | initial_cookie_str = cookies[0] 889 | try: 890 | cookie_list = [ 891 | {"name": c.split('=')[0].strip(), "value": c.split('=', 1)[1].strip(), "domain": ".doubao.com", "path": "/"} 892 | for c in initial_cookie_str.split(';') if '=' in c 893 | ] 894 | await self.page.context.add_cookies(cookie_list) 895 | logger.success("初始 Cookie 设置完成。") 896 | except IndexError as e: 897 | logger.error(f"解析 Cookie 时出错: '{initial_cookie_str}'. 请确保 Cookie 格式正确。错误: {e}") 898 | raise ValueError("Cookie 格式无效,无法进行初始化。") from e 899 | 900 | try: 901 | logger.info("正在导航到豆包官网以加载签名脚本 (超时时间: 60秒)...") 902 | await self.page.goto( 903 | "https://www.doubao.com/chat/", 904 | wait_until="load", 905 | timeout=60000 906 | ) 907 | logger.info("页面导航完成 (load 事件触发)。") 908 | except TimeoutError as e: 909 | logger.error(f"导航到豆包官网超时: {e}") 910 | raise RuntimeError("无法访问豆包官网,初始化失败。") from e 911 | 912 | try: 913 | logger.info("正在等待关键签名函数 (window.byted_acrawler.frontierSign) 加载 (超时时间: 30秒)...") 914 | await self.page.wait_for_function( 915 | "() => typeof window.byted_acrawler?.frontierSign === 'function'", 916 | timeout=30000 917 | ) 918 | logger.success("关键签名函数已在启动时成功加载!") 919 | except TimeoutError: 920 | logger.error("等待签名函数超时!这很可能是因为 Cookie 无效或已过期。") 921 | raise RuntimeError("无法加载豆包签名函数,请检查并更新 Cookie。") 922 | 923 | if not self.ms_token: 924 | logger.info("等待 msToken 出现,最长等待 10 秒...") 925 | await asyncio.sleep(10) 926 | if not self.ms_token: 927 | logger.warning("在额外等待后,依然未能捕获到初始 msToken。后续请求将依赖响应头更新。") 928 | 929 | logger.success("Playwright 管理器 (签名服务模式) 初始化完成。") 930 | self._initialized = True 931 | 932 | def update_ms_token(self, token: str): 933 | self.ms_token = token 934 | 935 | async def get_signed_url(self, base_url: str, cookie: str, base_params: Dict[str, str]) -> Optional[str]: 936 | async with self._lock: 937 | if not self._initialized: 938 | raise RuntimeError("PlaywrightManager 未初始化。") 939 | 940 | try: 941 | logger.info("正在使用 Playwright 生成 a_bogus 签名...") 942 | 943 | final_params = base_params.copy() 944 | final_params.update(self.static_device_fingerprint) 945 | 946 | final_params['web_tab_id'] = str(uuid.uuid4()) 947 | if self.ms_token: 948 | final_params['msToken'] = self.ms_token 949 | else: 950 | logger.error("msToken 未被初始化,无法构建有效请求!") 951 | return None 952 | 953 | # --- 核心修复: 对参数进行字母排序,以生成正确的签名 --- 954 | sorted_params = dict(sorted(final_params.items())) 955 | final_query_string = urlencode(sorted_params) 956 | url_with_params = f"{base_url}?{final_query_string}" 957 | 958 | logger.info(f"正在使用静态指纹和排序后的参数调用 window.byted_acrawler.frontierSign: \"{final_query_string}\"") 959 | signature_obj = await self.page.evaluate(f'window.byted_acrawler.frontierSign("{final_query_string}")') 960 | 961 | if isinstance(signature_obj, dict) and ('a_bogus' in signature_obj or 'X-Bogus' in signature_obj): 962 | bogus_value = signature_obj.get('a_bogus') or signature_obj.get('X-Bogus') 963 | logger.success(f"成功解析签名对象,获取到 a_bogus: {bogus_value}") 964 | 965 | signed_url = f"{url_with_params}&a_bogus={bogus_value}" 966 | return signed_url 967 | else: 968 | logger.error(f"调用签名函数失败,返回值不是预期的字典格式或缺少 a_bogus: {signature_obj}") 969 | return None 970 | 971 | except Exception as e: 972 | logger.error(f"Playwright 签名时发生严重错误: {e}", exc_info=True) 973 | return None 974 | 975 | async def close(self): 976 | if self._initialized: 977 | async with self._lock: 978 | if self.browser: 979 | await self.browser.close() 980 | if self.playwright: 981 | await self.playwright.stop() 982 | self._initialized = False 983 | logger.info("Playwright 管理器已关闭。") 984 | 985 | 986 | --- 文件路径: app\services\session_manager.py --- 987 | 988 | # /app/services/session_manager.py 989 | import threading 990 | from cachetools import TTLCache 991 | from typing import Dict, Any, Optional 992 | from app.core.config import settings 993 | from loguru import logger 994 | 995 | class SessionManager: 996 | def __init__(self): 997 | self.cache = TTLCache(maxsize=1024, ttl=settings.SESSION_CACHE_TTL) 998 | self.lock = threading.Lock() 999 | logger.info(f"会话管理器已初始化,缓存 TTL: {settings.SESSION_CACHE_TTL} 秒。") 1000 | 1001 | def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: 1002 | with self.lock: 1003 | return self.cache.get(session_id) 1004 | 1005 | def update_session(self, session_id: str, data: Dict[str, Any]): 1006 | with self.lock: 1007 | self.cache[session_id] = data 1008 | logger.debug(f"会话 {session_id} 已更新。") 1009 | 1010 | 1011 | --- 文件路径: app\utils\sse_utils.py --- 1012 | 1013 | # /app/utils/sse_utils.py 1014 | import json 1015 | import time 1016 | from typing import Dict, Any, Optional 1017 | 1018 | DONE_CHUNK = b"data: [DONE]\n\n" 1019 | 1020 | def create_sse_data(data: Dict[str, Any]) -> bytes: 1021 | return f"data: {json.dumps(data)}\n\n".encode('utf-8') 1022 | 1023 | def create_chat_completion_chunk( 1024 | request_id: str, 1025 | model: str, 1026 | content: str, 1027 | finish_reason: Optional[str] = None 1028 | ) -> Dict[str, Any]: 1029 | return { 1030 | "id": request_id, 1031 | "object": "chat.completion.chunk", 1032 | "created": int(time.time()), 1033 | "model": model, 1034 | "choices": [ 1035 | { 1036 | "index": 0, 1037 | "delta": {"content": content}, 1038 | "finish_reason": finish_reason 1039 | } 1040 | ] 1041 | } 1042 | 1043 | 1044 | 1045 | --------------------------------------------------------------------------------