├── 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 | 
6 | 
7 | 
8 | 
9 | 
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 |
--------------------------------------------------------------------------------