豆包网页端服务转Api服务

This commit is contained in:
张梦南 2026-08-20 11:32:32 +08:00
parent e7e5c0f83d
commit b37295d2d5
17 changed files with 1829 additions and 1 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
data/
*.pyc
__pycache__/
.env
.git/
*.md

30
.env.example Normal file
View File

@ -0,0 +1,30 @@
# ===== API =====
# API key for authentication (clients must send: Authorization: Bearer <key>)
API_KEY=sk-your-api-key-here
API_PORT=8000
# ===== Browser =====
BROWSER_HEADLESS=false
DOUBAO_URL=https://www.doubao.com/chat/
DOUBAO_MODEL=doubao-pro
VIEWPORT_WIDTH=1280
VIEWPORT_HEIGHT=720
# ===== Response =====
RESPONSE_TIMEOUT=120
PAGE_LOAD_TIMEOUT=30000
TYPING_DELAY_MIN=30
TYPING_DELAY_MAX=80
INTER_MESSAGE_DELAY=0.5
# ===== Logging =====
LOG_LEVEL=INFO
# ===== VNC =====
VNC_PASSWORD=doubao123
VNC_PORT=5900
NOVNC_PORT=6080
VNC_VIEW_ONLY=false
SCREEN_WIDTH=1280
SCREEN_HEIGHT=720
SCREEN_DEPTH=24

76
Dockerfile Normal file
View File

@ -0,0 +1,76 @@
FROM rockylinux/rockylinux:9
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
DISPLAY=:99 \
SCREEN_WIDTH=1280 \
SCREEN_HEIGHT=720 \
SCREEN_DEPTH=24 \
API_PORT=8000 \
VNC_PORT=5900 \
NOVNC_PORT=6080 \
VNC_VIEW_ONLY=false \
PYTHONUNBUFFERED=1
RUN dnf install -y epel-release && \
dnf update -y && \
dnf install -y --allowerasing \
python3 \
python3-pip \
python3-devel \
xorg-x11-server-Xvfb \
x11vnc \
which \
wget \
git \
procps \
iproute \
file \
fontconfig \
liberation-sans-fonts \
dejavu-sans-fonts \
google-noto-sans-cjk-ttc-fonts \
google-noto-emoji-color-fonts \
nss \
atk \
cups-libs \
libXcomposite \
libXdamage \
libXrandr \
mesa-libgbm \
pango \
libdrm \
libxkbcommon \
alsa-lib \
at-spi2-atk \
libXScrnSaver \
&& dnf clean all && \
rm -rf /var/cache/dnf
RUN dnf install -y openbox 2>/dev/null || \
echo "WARNING: openbox not found, will run without window manager"
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir --upgrade pip && \
pip3 install --no-cache-dir -r /tmp/requirements.txt
RUN playwright install chromium
RUN git clone --depth 1 --branch v1.2.0 https://github.com/novnc/noVNC.git /opt/novnc && \
rm -rf /opt/novnc/.git && \
sed -i 's|</head>|<script>if(!location.search.includes("view_only")){var u=new URL(location.href);u.searchParams.set("view_only","1");u.searchParams.set("autoconnect","1");history.replaceState(null,"",u)}</script></head>|' /opt/novnc/vnc.html
WORKDIR /app
COPY . /app
RUN sed -i 's/\r$//' /app/scripts/entrypoint.sh /app/scripts/start_wm.sh /app/scripts/start_x11vnc.sh && \
chmod +x /app/scripts/entrypoint.sh /app/scripts/start_wm.sh /app/scripts/start_x11vnc.sh
EXPOSE ${API_PORT} ${VNC_PORT} ${NOVNC_PORT}
VOLUME ["/app/data"]
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:${API_PORT}/health || exit 1
CMD ["/app/scripts/entrypoint.sh"]

259
README.md
View File

@ -1,3 +1,260 @@
# DouBao2Api
将豆包网页端服务转换为Api格式进行调用使用VNC进行后端可视化模拟
将网页版豆包doubao.com转换为 OpenAI 兼容 API 的 Docker 化方案。通过 VNC + Playwright 模拟真人浏览器操作,间接发送消息并捕获回复,对外暴露标准 `/v1/chat/completions` 接口。
<div align="center">
<br>
[**中文**](README.md) [English](README_EN.md)
<br>
</div>
## 工作原理
```
┌──────────────────────────────────────────────────────────┐
│ Docker 容器 (RockyLinux 9) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Xvfb │──│ openbox │──│ Chromium │──│Playwright│ │
│ │ 虚拟显示 │ │ 窗口管理器 │ │ 浏览器 │ │ 自动化 │ │
│ └─────────┘ └──────────┘ └──────────┘ └────────┘ │
│ │ │ │
│ │ ┌──────────┐ │ │
│ └──────────│ x11vnc │◄───────────────────┘ │
│ │ VNC 服务 │ │
│ └────┬─────┘ │
│ │ │
│ ┌────┴─────┐ ┌──────────┐ │
│ │websockify│──│ noVNC │ │
│ │ 代理 │ │ Web 客户端 │ │
│ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ FastAPI (端口 8000) │ │
│ │ OpenAI 兼容 API │ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
API :8000 VNC :5900 noVNC :6080
```
**核心流程**API 收到请求 → Playwright 在 Chromium 中操作豆包页面输入消息、Enter 发送) → DOM 轮询捕获流式回复 → 以 SSE 或 JSON 返回。
## 功能特性
- **OpenAI 兼容**:标准 `/v1/chat/completions` 接口支持流式SSE和非流式响应
- **VNC 可视化**:通过 noVNC Web 客户端实时查看浏览器画面,支持只读/交互切换
- **反检测**Playwright stealth 脚本隐藏 webdriver 标志,模拟人类打字延迟
- **会话持久化**:登录状态通过 `storage_state` 保存到 Docker volume重启免登录
- **聊天复用**:默认复用当前聊天会话,降低风控风险;支持按需新建对话
- **调试端点**`/inspect` 返回页面 DOM 结构,方便选择器适配
## 快速开始
### 1. 构建与启动
```bash
docker compose build
docker compose up -d
```
### 2. 登录豆包
容器启动后,浏览器打开 noVNC
```
http://localhost:6080/vnc.html
```
VNC 密码:`doubao123`(可在 `docker-compose.yml` 中修改 `VNC_PASSWORD`
noVNC 默认为**只读模式**(防止误触)。在左侧设置面板取消勾选 "View Only" 即可切换到交互模式。
在 VNC 中的 Chromium 浏览器里登录豆包账号,确认能看到聊天输入框。
### 3. 保存登录状态
```bash
curl -X POST http://localhost:8000/login/save
```
### 4. 调用 API
```bash
# 非流式
curl http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"model":"doubao-pro","messages":[{"role":"user","content":"你好"}]}'
# 流式
curl http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"model":"doubao-pro","messages":[{"role":"user","content":"你好"}],"stream":true}'
```
## 配置项
所有配置通过环境变量设置,在 `docker-compose.yml``.env` 中修改:
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `API_KEY` | `sk-your-api-key-here` | API 鉴权密钥,客户端需在 Header 中携带 `Authorization: Bearer <key>` |
| `API_PORT` | `8000` | API 服务端口 |
| `VNC_PASSWORD` | `doubao123` | VNC 连接密码 |
| `VNC_VIEW_ONLY` | `false` | x11vnc 服务端是否强制只读(`true` 时即使 noVNC 取消勾选也无法操作) |
| `VNC_PORT` | `5900` | VNC 直连端口 |
| `NOVNC_PORT` | `6080` | noVNC Web 客户端端口 |
| `DOUBAO_URL` | `https://www.doubao.com/chat/` | 豆包聊天页面 URL |
| `BROWSER_HEADLESS` | `false` | 浏览器是否无头模式VNC 方案需设为 `false` |
| `RESPONSE_TIMEOUT` | `120` | 响应超时时间(秒) |
| `TYPING_DELAY_MIN` | `30` | 打字延迟下限(毫秒/字符) |
| `TYPING_DELAY_MAX` | `80` | 打字延迟上限(毫秒/字符) |
| `INTER_MESSAGE_DELAY` | `0.5` | 消息发送前等待时间(秒) |
| `SCREEN_WIDTH` | `1280` | 虚拟屏幕宽度 |
| `SCREEN_HEIGHT` | `720` | 虚拟屏幕高度 |
| `SCREEN_DEPTH` | `24` | 虚拟屏幕色深 |
| `LOG_LEVEL` | `INFO` | 日志级别 |
## API 接口
### `POST /v1/chat/completions`
OpenAI 兼容的对话接口。
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `model` | string | `doubao-pro` | 模型名称 |
| `messages` | array | 必填 | 消息数组,同 OpenAI 格式 |
| `stream` | bool | `false` | 是否流式返回 |
| `new_chat` | bool | `false` | 是否新建聊天会话(自定义扩展字段) |
### `GET /v1/models`
返回支持的模型列表。
### `GET /login/status`
检查当前豆包登录状态。
### `POST /login/save`
保存当前浏览器状态cookies、storage到磁盘。
### `POST /chat/new`
显式开启新的豆包聊天会话。
### `GET /inspect`
返回当前页面的 DOM 结构信息,用于调试选择器。
### `GET /vnc/mode`
查看当前 VNC 模式(`viewonly``interactive`)。
### `POST /vnc/mode`
切换 VNC 模式(服务端强制):
```bash
# 切换到交互模式
curl -X POST http://localhost:8000/vnc/mode \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"mode":"interactive"}'
# 切换到只读模式
curl -X POST http://localhost:8000/vnc/mode \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"mode":"viewonly"}'
```
### `POST /browser/restart`
重启浏览器会话。
### `GET /health`
健康检查端点。
## VNC 模式说明
提供两层只读控制:
| 层级 | 控制方式 | 默认 | 说明 |
|------|---------|------|------|
| 客户端 | noVNC 侧边栏 "View Only" 勾选框 | 勾选(只读) | 取消勾选即时切换到交互模式,刷新后恢复只读 |
| 服务端 | API `POST /vnc/mode` | `interactive` | `viewonly` 模式下 x11vnc 拒绝所有输入,即使客户端取消勾选也无效 |
日常使用:客户端层即可满足需求。如需更强控制(防止任何 VNC 客户端操作),使用 API 切换服务端模式。
## 项目结构
```
DouBao2Api/
├── Dockerfile # Docker 镜像构建文件
├── docker-compose.yml # 容器编排配置
├── requirements.txt # Python 依赖
├── .env.example # 环境变量模板
├── app/
│ ├── __init__.py
│ ├── config.py # 配置管理pydantic-settings
│ ├── models.py # OpenAI 兼容数据模型
│ ├── browser_manager.py # Playwright 浏览器管理
│ ├── doubao.py # 豆包页面交互逻辑
│ └── main.py # FastAPI 服务入口
└── scripts/
├── entrypoint.sh # 容器入口脚本
├── supervisord.conf # 进程管理配置
├── start_wm.sh # 窗口管理器启动脚本
└── start_x11vnc.sh # x11vnc 启动脚本(支持只读/交互切换)
```
## 进程架构
容器内通过 supervisord 管理五个进程,按优先级启动:
| 优先级 | 进程 | 说明 |
|--------|------|------|
| 10 | Xvfb | 虚拟帧缓冲 X 服务器 |
| 20 | openbox | 轻量级窗口管理器 |
| 30 | x11vnc | VNC 服务器,映射 Xvfb 显示 |
| 40 | websockify | WebSocket 代理,提供 noVNC Web 访问 |
| 50 | uvicorn | FastAPI 应用服务器 |
## 常见问题
### 构建失败:找不到包
本项目基于 RockyLinux 9。RHEL 10 / RockyLinux 10 移除了 X11 server 包不支持本方案。Docker 容器独立于宿主系统,在 RL10 宿主上运行 RL9 容器完全兼容。
### API 返回空响应
豆包前端更新可能导致 CSS 选择器失效。调用 `GET /inspect` 查看当前 DOM 结构,然后修改 `app/doubao.py` 中的选择器常量。
### 提示 "Not logged in"
需先通过 VNC 浏览器登录豆包,再调用 `POST /login/save` 保存状态。调用 `GET /login/status` 检查登录状态。
### VNC 无法操作
确认 noVNC 侧边栏 "View Only" 已取消勾选。若仍无法操作,检查服务端模式:`GET /vnc/mode`,如为 `viewonly` 则调用 API 切换为 `interactive`
## 技术栈
- **RockyLinux 9** — 容器基础系统
- **Xvfb + x11vnc + noVNC** — 虚拟显示与远程查看
- **Playwright** — 浏览器自动化
- **FastAPI + Uvicorn** — API 服务
- **Supervisor** — 进程管理
- **websockify** — VNC over WebSocket 代理
## 免责声明
本项目仅供学习和研究用途。使用前请确保遵守豆包的服务条款。作者不对因使用本项目而产生的任何直接或间接后果承担责任。

260
README_EN.md Normal file
View File

@ -0,0 +1,260 @@
# DouBao2Api
A Dockerized solution that converts the web version of Doubao (doubao.com) into an OpenAI-compatible API. It uses VNC + Playwright to simulate real human browser interaction — sending messages indirectly and capturing replies — while exposing a standard `/v1/chat/completions` endpoint.
<div align="center">
<br>
[**中文**](README.md) [English](README_EN.md)
<br>
</div>
## How It Works
```
┌──────────────────────────────────────────────────────────┐
│ Docker Container (RockyLinux 9) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Xvfb │──│ openbox │──│ Chromium │──│Playwright│ │
│ │ Virtual │ │ Window Mgr │ │ Browser │ │ Automation│ │
│ │ Display │ └──────────┘ └──────────┘ └────────┘ │
│ └─────────┘ │ │
│ │ ┌──────────┐ │ │
│ └──────────│ x11vnc │◄───────────────────────┘ │
│ │ VNC Server│ │
│ └────┬─────┘ │
│ │ │
│ ┌────┴─────┐ ┌──────────┐ │
│ │websockify│──│ noVNC │ │
│ │ Proxy │ │ Web Client│ │
│ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ FastAPI (port 8000) │ │
│ │ OpenAI-compatible API │ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
API :8000 VNC :5900 noVNC :6080
```
**Core flow**: API receives request → Playwright operates the Doubao page in Chromium (type message, press Enter) → DOM polling captures streaming reply → returned as SSE or JSON.
## Features
- **OpenAI-compatible**: Standard `/v1/chat/completions` endpoint with both streaming (SSE) and non-streaming responses
- **VNC visualization**: Real-time browser view via noVNC web client, with view-only/interactive toggle
- **Anti-detection**: Playwright stealth script hides webdriver flags, simulates human typing delays
- **Session persistence**: Login state saved via `storage_state` to a Docker volume — no re-login after restart
- **Chat reuse**: Reuses the current chat session by default to reduce risk control triggers; supports on-demand new chat creation
- **Debug endpoint**: `/inspect` returns page DOM structure for easy selector adaptation
## Quick Start
### 1. Build & Start
```bash
docker compose build
docker compose up -d
```
### 2. Login to Doubao
After the container starts, open noVNC in your browser:
```
http://localhost:6080/vnc.html
```
VNC password: `doubao123` (configurable via `VNC_PASSWORD` in `docker-compose.yml`)
noVNC defaults to **view-only mode** (to prevent accidental interaction). Uncheck "View Only" in the left sidebar settings to switch to interactive mode.
Log in to your Doubao account in the Chromium browser within VNC. Make sure you can see the chat input box.
### 3. Save Login State
```bash
curl -X POST http://localhost:8000/login/save
```
### 4. Call the API
```bash
# Non-streaming
curl http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"model":"doubao-pro","messages":[{"role":"user","content":"Hello"}]}'
# Streaming
curl http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"model":"doubao-pro","messages":[{"role":"user","content":"Hello"}],"stream":true}'
```
## Configuration
All settings are configured via environment variables in `docker-compose.yml` or `.env`:
| Variable | Default | Description |
|----------|---------|-------------|
| `API_KEY` | `sk-your-api-key-here` | API authentication key; clients must send `Authorization: Bearer <key>` header |
| `API_PORT` | `8000` | API service port |
| `VNC_PASSWORD` | `doubao123` | VNC connection password |
| `VNC_VIEW_ONLY` | `false` | Force x11vnc server-side view-only (`true` blocks all input even if noVNC unchecks View Only) |
| `VNC_PORT` | `5900` | Direct VNC port |
| `NOVNC_PORT` | `6080` | noVNC web client port |
| `DOUBAO_URL` | `https://www.doubao.com/chat/` | Doubao chat page URL |
| `BROWSER_HEADLESS` | `false` | Run browser in headless mode (must be `false` for VNC) |
| `RESPONSE_TIMEOUT` | `120` | Response timeout in seconds |
| `TYPING_DELAY_MIN` | `30` | Minimum typing delay (ms per character) |
| `TYPING_DELAY_MAX` | `80` | Maximum typing delay (ms per character) |
| `INTER_MESSAGE_DELAY` | `0.5` | Delay before sending message (seconds) |
| `SCREEN_WIDTH` | `1280` | Virtual screen width |
| `SCREEN_HEIGHT` | `720` | Virtual screen height |
| `SCREEN_DEPTH` | `24` | Virtual screen color depth |
| `LOG_LEVEL` | `INFO` | Log level |
## API Reference
### `POST /v1/chat/completions`
OpenAI-compatible chat endpoint.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model` | string | `doubao-pro` | Model name |
| `messages` | array | required | Message array, same format as OpenAI |
| `stream` | bool | `false` | Enable SSE streaming response |
| `new_chat` | bool | `false` | Start a new chat session (custom extension field) |
### `GET /v1/models`
Returns the list of supported models.
### `GET /login/status`
Check current Doubao login status.
### `POST /login/save`
Persist current browser state (cookies, storage) to disk.
### `POST /chat/new`
Explicitly start a new Doubao chat session.
### `GET /inspect`
Return current page DOM structure for debugging selectors.
### `GET /vnc/mode`
Get current VNC mode (`viewonly` or `interactive`).
### `POST /vnc/mode`
Switch VNC mode (server-side enforcement):
```bash
# Switch to interactive mode
curl -X POST http://localhost:8000/vnc/mode \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"mode":"interactive"}'
# Switch to view-only mode
curl -X POST http://localhost:8000/vnc/mode \
-H "Authorization: Bearer sk-your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"mode":"viewonly"}'
```
### `POST /browser/restart`
Restart the browser session.
### `GET /health`
Health check endpoint.
## VNC Modes
Two layers of view-only control:
| Layer | Control | Default | Description |
|-------|---------|---------|-------------|
| Client | noVNC sidebar "View Only" checkbox | Checked (read-only) | Uncheck to switch to interactive instantly; refresh restores read-only |
| Server | API `POST /vnc/mode` | `interactive` | `viewonly` mode makes x11vnc reject all input, even if the client unchecks View Only |
For daily use, the client layer is sufficient. For stronger enforcement (prevent any VNC client from interacting), use the API to switch the server-side mode.
## Project Structure
```
DouBao2Api/
├── Dockerfile # Docker image build file
├── docker-compose.yml # Container orchestration
├── requirements.txt # Python dependencies
├── .env.example # Environment variable template
├── app/
│ ├── __init__.py
│ ├── config.py # Configuration management (pydantic-settings)
│ ├── models.py # OpenAI-compatible data models
│ ├── browser_manager.py # Playwright browser management
│ ├── doubao.py # Doubao page interaction logic
│ └── main.py # FastAPI service entry point
└── scripts/
├── entrypoint.sh # Container entrypoint script
├── supervisord.conf # Process management config
├── start_wm.sh # Window manager startup script
└── start_x11vnc.sh # x11vnc startup script (view-only/interactive toggle)
```
## Process Architecture
Five processes managed by supervisord inside the container, started by priority:
| Priority | Process | Description |
|----------|---------|-------------|
| 10 | Xvfb | Virtual framebuffer X server |
| 20 | openbox | Lightweight window manager |
| 30 | x11vnc | VNC server, maps Xvfb display |
| 40 | websockify | WebSocket proxy, provides noVNC web access |
| 50 | uvicorn | FastAPI application server |
## Troubleshooting
### Build failure: package not found
This project is based on RockyLinux 9. RHEL 10 / RockyLinux 10 removed X11 server packages and does not support this approach. Docker containers are independent of the host OS — running an RL9 container on an RL10 host is fully compatible.
### API returns empty response
Doubao frontend updates may break CSS selectors. Call `GET /inspect` to view the current DOM structure, then update the selector constants in `app/doubao.py`.
### "Not logged in" error
You must first log in to Doubao through the VNC browser, then call `POST /login/save` to persist the state. Use `GET /login/status` to check login status.
### VNC cannot interact
Make sure "View Only" is unchecked in the noVNC sidebar. If still unable to interact, check server-side mode: `GET /vnc/mode`. If it returns `viewonly`, use the API to switch to `interactive`.
## Tech Stack
- **RockyLinux 9** — Container base OS
- **Xvfb + x11vnc + noVNC** — Virtual display and remote viewing
- **Playwright** — Browser automation
- **FastAPI + Uvicorn** — API service
- **Supervisor** — Process management
- **websockify** — VNC over WebSocket proxy
## Disclaimer
This project is for educational and research purposes only. Please ensure compliance with Doubao's terms of service before use. The author is not responsible for any direct or indirect consequences arising from the use of this project.

0
app/__init__.py Normal file
View File

193
app/browser_manager.py Normal file
View File

@ -0,0 +1,193 @@
import asyncio
import json
from typing import Optional
from loguru import logger
from playwright.async_api import (
Browser,
BrowserContext,
Page,
async_playwright,
)
from .config import settings
STEALTH_JS = """
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5],
});
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en'],
});
Object.defineProperty(navigator, 'platform', {
get: () => 'Win32',
});
window.chrome = { runtime: {} };
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
"""
class BrowserManager:
_instance: Optional["BrowserManager"] = None
_lock: asyncio.Lock = asyncio.Lock()
def __new__(cls) -> "BrowserManager":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "_initialized"):
self._initialized = True
self.playwright = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None
self._ready = False
@property
def ready(self) -> bool:
return self._ready and self.page is not None and not self.page.is_closed()
async def start(self) -> Page:
async with self._lock:
if self.ready:
return self.page
logger.info("Starting Playwright browser...")
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(
headless=settings.browser_headless,
args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
"--window-size=1280,720",
],
)
storage_state = (
str(settings.state_file) if settings.state_file.exists() else None
)
self.context = await self.browser.new_context(
viewport={
"width": settings.viewport_width,
"height": settings.viewport_height,
},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
locale="zh-CN",
timezone_id="Asia/Shanghai",
storage_state=storage_state,
)
await self.context.add_init_script(STEALTH_JS)
self.context.set_default_timeout(settings.page_load_timeout)
self.context.set_default_navigation_timeout(60000)
self.page = await self.context.new_page()
await self._navigate_to_doubao()
self._ready = True
logger.info("Browser started and navigated to Doubao")
return self.page
async def _navigate_to_doubao(self):
if not self.page:
return
try:
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
await self.page.wait_for_timeout(3000)
except Exception as e:
logger.warning(f"Navigation warning: {e}")
async def save_state(self):
if self.context:
await self.context.storage_state(path=str(settings.state_file))
cookies = await self.context.cookies()
with open(settings.cookies_file, "w", encoding="utf-8") as f:
json.dump(cookies, f, ensure_ascii=False, indent=2)
logger.info(f"Saved browser state ({len(cookies)} cookies)")
async def check_login(self) -> bool:
"""Check if the user is logged in by looking for login indicators."""
if not self.ready:
return False
try:
login_indicators = await self.page.evaluate(
"""
() => {
const body = document.body ? document.body.innerText : '';
const url = window.location.href;
if (url.includes('login') || url.includes('passport')) return false;
const loginBtn = document.querySelector(
'[class*="login"], [class*="sign-in"], [data-testid*="login"]'
);
if (loginBtn && loginBtn.offsetParent !== null) return false;
const inputArea = document.querySelector(
'textarea, [contenteditable="true"], [role="textbox"]'
);
return !!inputArea;
}
"""
)
return login_indicators
except Exception as e:
logger.warning(f"Login check failed: {e}")
return False
async def get_page(self) -> Page:
if not self.ready:
await self.start()
return self.page
async def reload(self):
if self.page:
await self.page.reload(wait_until="domcontentloaded")
await self.page.wait_for_timeout(2000)
async def restart(self):
logger.info("Restarting browser session...")
await self.stop()
await asyncio.sleep(1)
await self.start()
async def stop(self):
self._ready = False
try:
if self.context:
await self.save_state()
except Exception as e:
logger.warning(f"Failed to save state during stop: {e}")
try:
if self.browser:
await self.browser.close()
except Exception:
pass
try:
if self.playwright:
await self.playwright.stop()
except Exception:
pass
self.page = None
self.context = None
self.browser = None
self.playwright = None
logger.info("Browser stopped")
browser_manager = BrowserManager()

38
app/config.py Normal file
View File

@ -0,0 +1,38 @@
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_key: str = "sk-your-api-key-here"
api_port: int = 8000
browser_headless: bool = False
doubao_url: str = "https://www.doubao.com/chat/"
doubao_model: str = "doubao-pro"
viewport_width: int = 1280
viewport_height: int = 720
data_dir: Path = Path("/app/data")
cookies_file: Path = Path("/app/data/cookies.json")
state_file: Path = Path("/app/data/browser_state.json")
response_timeout: int = 120
page_load_timeout: int = 30000
typing_delay_min: int = 30
typing_delay_max: int = 80
inter_message_delay: float = 0.5
log_level: str = "INFO"
vnc_port: int = 5900
novnc_port: int = 6080
vnc_view_only: bool = False
screen_width: int = 1280
screen_height: int = 720
screen_depth: int = 24
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
settings.data_dir.mkdir(parents=True, exist_ok=True)

396
app/doubao.py Normal file
View File

@ -0,0 +1,396 @@
import asyncio
import random
import time
from typing import AsyncGenerator, List, Optional, Tuple
from loguru import logger
from playwright.async_api import Locator, Page, TimeoutError as PWTimeout
from .config import settings
from .models import ChatMessage
FIND_INPUT_JS = """
() => {
const selectors = [
'.tiptap.ProseMirror[contenteditable="true"]',
'div[contenteditable="true"]',
'textarea[placeholder*=""]',
'textarea[placeholder*="输入"]',
'textarea[placeholder*=""]',
'textarea',
'[role="textbox"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) return sel;
}
}
return null;
}
"""
FIND_SEND_BTN_JS = """
() => {
const selectors = [
'button[aria-label*="发送"]',
'button[aria-label*="Send"]',
'button[type="submit"]',
'button[class*="send"]',
'button[class*="submit"]',
'[data-testid*="send"]',
'button[class*="Send"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return sel;
}
return null;
}
"""
GET_RESPONSE_TEXT_JS = """
() => {
const messageList = document.querySelector('[class*="message-list"]');
if (messageList) {
const msgs = Array.from(messageList.children).filter(el => {
const cls = el.className || '';
return el.offsetParent !== null
&& !cls.includes('action-bar')
&& !cls.includes('suggest-')
&& el.innerText.trim();
});
if (msgs.length > 0) {
return msgs[msgs.length - 1].innerText.trim();
}
}
return null;
}
"""
IS_GENERATING_JS = """
() => {
const stopSelectors = [
'[class*="stop-generate"]',
'[class*="stop_generate"]',
'[aria-label*="停止"]',
'[aria-label*="Stop"]',
'[data-testid*="stop"]',
'button[disabled][class*="send"]',
];
for (const sel of stopSelectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return true;
}
const spinnerSelectors = [
'[class*="typing-indicator"]',
'[class*="thinking"]',
'[class*="loading-dot"]',
'svg[class*="loading"]',
'[class*="generating"]',
];
for (const sel of spinnerSelectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return true;
}
return false;
}
"""
NEW_CHAT_BTN_JS = """
() => {
const selectors = [
'button[aria-label*="新对话"]',
'button[aria-label*="New"]',
'a[aria-label*="新对话"]',
'a[aria-label*="New"]',
'[class*="new-chat"]',
'[class*="new_chat"]',
'[data-testid*="new-chat"]',
'[data-testid*="new_chat"]',
'a[href*="/chat"][class*="new"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return sel;
}
return null;
}
"""
INSPECT_DOM_JS = """
() => {
const result = {
url: window.location.href,
title: document.title,
textareas: [],
contentEditables: [],
buttons: [],
messageContainers: [],
};
document.querySelectorAll('textarea').forEach(el => {
result.textareas.push({
placeholder: el.placeholder,
className: el.className.slice(0, 80),
visible: el.offsetParent !== null,
});
});
document.querySelectorAll('[contenteditable="true"]').forEach(el => {
result.contentEditables.push({
className: el.className.slice(0, 80),
visible: el.offsetParent !== null,
});
});
document.querySelectorAll('button').forEach(el => {
if (el.offsetParent !== null) {
result.buttons.push({
text: el.innerText.slice(0, 40),
ariaLabel: el.getAttribute('aria-label'),
className: el.className.slice(0, 80),
});
}
});
const msgPatterns = ['message', 'chat-item', 'conversation', 'receive', 'bot', 'ai-msg'];
msgPatterns.forEach(pattern => {
document.querySelectorAll(`[class*="${pattern}"]`).forEach(el => {
if (el.offsetParent !== null && result.messageContainers.length < 20) {
result.messageContainers.push({
pattern: pattern,
className: el.className.slice(0, 80),
text: el.innerText.slice(0, 100),
childCount: el.children.length,
});
}
});
});
return result;
}
"""
class DoubaoChat:
def __init__(self, page: Page):
self.page = page
async def start_new_chat(self) -> bool:
"""Try to start a new chat conversation."""
try:
selector = await self.page.evaluate(NEW_CHAT_BTN_JS)
if selector:
btn = self.page.locator(selector).first
await btn.click()
await self.page.wait_for_timeout(2000)
logger.info("Started new chat via button")
return True
except Exception as e:
logger.debug(f"New chat button not found: {e}")
try:
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
await self.page.wait_for_timeout(3000)
logger.info("Navigated to fresh chat URL")
return True
except Exception as e:
logger.warning(f"Failed to navigate to chat URL: {e}")
return False
async def _find_input(self) -> Optional[Tuple[Locator, str]]:
"""Find the input element, returns (locator, selector)."""
selector = await self.page.evaluate(FIND_INPUT_JS)
if not selector:
return None
loc = self.page.locator(selector).first
try:
await loc.wait_for(state="visible", timeout=5000)
return loc, selector
except PWTimeout:
return None
async def _find_send_button(self) -> Optional[Locator]:
"""Find the send button."""
selector = await self.page.evaluate(FIND_SEND_BTN_JS)
if not selector:
return None
loc = self.page.locator(selector).first
try:
await loc.wait_for(state="visible", timeout=3000)
return loc
except PWTimeout:
return None
async def _type_message(self, input_loc: Locator, text: str):
"""Type a message with human-like delays."""
await input_loc.click()
await asyncio.sleep(0.2)
if len(text) > 1000:
await self.page.keyboard.insert_text(text)
await asyncio.sleep(0.3)
else:
delay = random.randint(settings.typing_delay_min, settings.typing_delay_max)
await self.page.keyboard.type(text, delay=delay)
await asyncio.sleep(settings.inter_message_delay)
async def _send_via_enter(self, input_loc: Locator) -> bool:
"""Try sending by pressing Enter."""
await input_loc.press("Enter")
await asyncio.sleep(0.5)
return True
async def _send_via_button(self) -> bool:
"""Try sending by clicking the send button."""
btn = await self._find_send_button()
if btn:
await btn.click()
await asyncio.sleep(0.5)
return True
return False
async def _is_input_empty(self, input_loc: Locator, selector: str) -> bool:
"""Check if the input element is empty (message was sent)."""
try:
if "textarea" in selector:
val = await input_loc.input_value()
else:
val = await input_loc.inner_text()
return not val.strip()
except Exception:
return True
async def send_message(self, text: str) -> str:
"""Send a message to the Doubao chat.
Returns the previous response text (for change detection).
"""
result = await self._find_input()
if not result:
logger.error("Input element not found on page")
raise RuntimeError("无法找到输入框请通过VNC检查页面状态")
input_loc, selector = result
old_response = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
await self._type_message(input_loc, text)
sent = await self._send_via_enter(input_loc)
if sent:
await asyncio.sleep(0.8)
if not await self._is_input_empty(input_loc, selector):
logger.info("Enter didn't send, trying send button")
sent = await self._send_via_button()
if not sent:
logger.error("Failed to send message")
raise RuntimeError("消息发送失败请通过VNC检查页面状态")
return old_response
async def _wait_for_response_start(
self, old_text: str, timeout: int = 30
) -> Optional[str]:
"""Wait for the response to start appearing.
Uses two-phase detection:
Phase 1: wait for user message to appear (text changes from old_text)
Phase 2: wait for AI response to start (text changes from baseline)
Returns baseline text (after user message, before AI response),
or None on timeout.
"""
start = time.time()
phase = 1
baseline = None
while time.time() - start < timeout:
current = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
if phase == 1:
if current and current != old_text:
baseline = current
phase = 2
await asyncio.sleep(0.5)
elif phase == 2:
if current != baseline:
logger.debug("Response started")
return baseline
await asyncio.sleep(0.3)
if baseline is not None:
logger.warning("Response may have started during baseline capture")
return baseline
logger.warning("Response did not start within timeout")
return None
async def stream_response(self, old_text: str = "") -> AsyncGenerator[str, None]:
"""Yield response text chunks as they appear.
Args:
old_text: Previous response text from before the message was sent.
Used to detect when the new response starts.
"""
baseline = await self._wait_for_response_start(old_text)
if baseline is None:
return
last_text = baseline
stable_count = 0
STABLE_THRESHOLD = 6
POLL_INTERVAL = 0.15
start_time = time.time()
while True:
current_text = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
if current_text and len(current_text) > len(last_text):
new_chunk = current_text[len(last_text):]
last_text = current_text
stable_count = 0
if new_chunk.strip():
yield new_chunk
elif current_text and current_text == last_text:
stable_count += 1
if stable_count >= STABLE_THRESHOLD:
is_gen = await self.page.evaluate(IS_GENERATING_JS)
if not is_gen:
logger.debug("Response complete (stable + not generating)")
break
elapsed = time.time() - start_time
if elapsed > settings.response_timeout:
logger.warning(f"Response timeout after {elapsed:.1f}s")
break
await asyncio.sleep(POLL_INTERVAL)
async def get_response_full(self, old_text: str = "") -> str:
"""Get the complete response text (non-streaming)."""
chunks = []
async for chunk in self.stream_response(old_text):
chunks.append(chunk)
return "".join(chunks)
async def inspect(self) -> dict:
"""Return DOM structure info for debugging."""
return await self.page.evaluate(INSPECT_DOM_JS)
@staticmethod
def messages_to_prompt(messages: List[ChatMessage]) -> str:
"""Convert OpenAI messages array to a single text prompt."""
if len(messages) == 1 and messages[0].role == "user":
return messages[0].content
parts: List[str] = []
for msg in messages:
if msg.role == "system":
parts.append(f"【系统设定】\n{msg.content}")
elif msg.role == "user":
parts.append(f"【用户】\n{msg.content}")
elif msg.role == "assistant":
parts.append(f"【AI回复】\n{msg.content}")
return "\n\n---\n\n".join(parts)

346
app/main.py Normal file
View File

@ -0,0 +1,346 @@
import asyncio
import json
import os
import subprocess
import time
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator, Optional
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
from .browser_manager import browser_manager
from .config import settings
from .doubao import DoubaoChat
from .models import (
ChatCompletionChunk,
ChatCompletionRequest,
ChatCompletionResponse,
Choice,
ChoiceMessage,
ChunkChoice,
ChunkDelta,
ModelInfo,
ModelListResponse,
SUPPORTED_MODELS,
)
DEFAULT_API_KEY = "sk-your-api-key-here"
VNC_MODE_FILE = Path("/app/data/.vnc_mode")
def _read_vnc_mode() -> str:
if VNC_MODE_FILE.exists():
mode = VNC_MODE_FILE.read_text().strip()
return "viewonly" if mode in ("true", "viewonly") else "interactive"
return "viewonly" if settings.vnc_view_only else "interactive"
def _write_vnc_mode(mode: str):
VNC_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
VNC_MODE_FILE.write_text(mode)
def _restart_x11vnc():
try:
subprocess.run(
["supervisorctl", "restart", "x11vnc"],
capture_output=True, text=True, timeout=10,
)
except Exception as e:
logger.warning(f"supervisorctl restart failed: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("DouBao2Api starting up...")
try:
await browser_manager.start()
logged_in = await browser_manager.check_login()
if not logged_in:
logger.warning(
"Not logged in! Open noVNC at "
f"http://localhost:{settings.novnc_port}/vnc.html to login."
)
else:
logger.info("Login detected, ready to serve requests.")
except Exception as e:
logger.error(f"Browser startup failed: {e}")
yield
logger.info("DouBao2Api shutting down...")
await browser_manager.stop()
app = FastAPI(title="DouBao2Api", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
_request_lock = asyncio.Lock()
async def verify_api_key(authorization: Optional[str] = Header(None)):
if not settings.api_key or settings.api_key == DEFAULT_API_KEY:
return
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Authorization header. Expected: Bearer <key>")
key = authorization[7:]
if key != settings.api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
@app.get("/")
async def root():
return {
"service": "DouBao2Api",
"version": "1.0.0",
"browser_ready": browser_manager.ready,
"endpoints": {
"chat_completions": "POST /v1/chat/completions",
"models": "GET /v1/models",
"new_chat": "POST /chat/new",
"login_status": "GET /login/status",
"save_state": "POST /login/save",
"inspect": "GET /inspect",
"vnc_mode": "GET/POST /vnc/mode",
"restart_browser": "POST /browser/restart",
},
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
"vnc_mode": _read_vnc_mode(),
}
@app.get("/health")
async def health():
return {"status": "ok", "browser_ready": browser_manager.ready}
@app.get("/v1/models")
async def list_models():
return ModelListResponse(data=[ModelInfo(**m) for m in SUPPORTED_MODELS])
@app.get("/login/status")
async def login_status():
logged_in = await browser_manager.check_login()
return {
"logged_in": logged_in,
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
"instructions": (
"Open the VNC URL in your browser, login to Doubao manually, "
"then call POST /login/save to persist the session."
)
if not logged_in
else "Session is active.",
}
@app.post("/login/save")
async def save_state():
await browser_manager.save_state()
return {"status": "saved", "message": "Browser state persisted to disk."}
@app.get("/inspect")
async def inspect_dom():
page = await browser_manager.get_page()
doubao = DoubaoChat(page)
result = await doubao.inspect()
return result
@app.post("/chat/new")
async def new_chat(_: bool = Depends(verify_api_key)):
page = await browser_manager.get_page()
doubao = DoubaoChat(page)
ok = await doubao.start_new_chat()
return {"status": "new_chat_started" if ok else "failed", "ok": ok}
@app.get("/vnc/mode")
async def get_vnc_mode():
mode = _read_vnc_mode()
return {
"mode": mode,
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
"hint": "POST /vnc/mode with {'mode':'interactive'} to enable mouse/keyboard" if mode == "viewonly" else "VNC is interactive",
}
@app.post("/vnc/mode")
async def set_vnc_mode(request: Request, _: bool = Depends(verify_api_key)):
body = await request.json()
mode = body.get("mode", "").lower()
if mode not in ("viewonly", "interactive"):
raise HTTPException(status_code=400, detail="mode must be 'viewonly' or 'interactive'")
_write_vnc_mode(mode)
_restart_x11vnc()
logger.info(f"VNC mode set to {mode}, x11vnc restarted")
return {"status": "ok", "mode": mode}
@app.post("/browser/restart")
async def restart_browser(_: bool = Depends(verify_api_key)):
await browser_manager.restart()
return {"status": "restarted", "browser_ready": browser_manager.ready}
@app.post("/v1/chat/completions")
async def chat_completions(
request: ChatCompletionRequest,
_=Depends(verify_api_key),
):
async with _request_lock:
if not browser_manager.ready:
try:
await browser_manager.start()
except Exception as e:
raise HTTPException(
status_code=503,
detail=f"Browser not ready: {e}. Please retry shortly.",
)
if not await browser_manager.check_login():
raise HTTPException(
status_code=401,
detail=(
"Not logged in to Doubao. Open "
f"http://localhost:{settings.novnc_port}/vnc.html "
"to login manually, then call POST /login/save."
),
)
page = await browser_manager.get_page()
doubao = DoubaoChat(page)
if request.new_chat:
await doubao.start_new_chat()
logger.info("Started new chat session")
prompt = DoubaoChat.messages_to_prompt(request.messages)
logger.info(f"Sending prompt ({len(prompt)} chars), stream={request.stream}")
try:
old_text = await doubao.send_message(prompt)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
logger.error(f"Unexpected send error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to send message: {e}")
if request.stream:
return StreamingResponse(
_stream_sse(doubao, request.model, old_text),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
try:
full_response = await doubao.get_response_full(old_text)
except Exception as e:
logger.error(f"Response capture failed: {e}")
raise HTTPException(status_code=502, detail=f"Failed to get response: {e}")
await browser_manager.save_state()
return ChatCompletionResponse(
model=request.model,
choices=[
Choice(
index=0,
message=ChoiceMessage(role="assistant", content=full_response),
finish_reason="stop",
)
],
)
async def _stream_sse(
doubao: DoubaoChat,
model: str,
old_text: str,
) -> AsyncGenerator[str, None]:
chat_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created = int(time.time())
first_chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[ChunkChoice(index=0, delta=ChunkDelta(role="assistant"))],
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
try:
async for chunk_text in doubao.stream_response(old_text):
if not chunk_text:
continue
chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[
ChunkChoice(index=0, delta=ChunkDelta(content=chunk_text))
],
)
yield f"data: {chunk.model_dump_json()}\n\n"
except Exception as e:
logger.error(f"Streaming error: {e}")
err_chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=ChunkDelta(content=f"\n[Error: {e}]"),
finish_reason="stop",
)
],
)
yield f"data: {err_chunk.model_dump_json()}\n\n"
else:
final_chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[
ChunkChoice(index=0, delta=ChunkDelta(), finish_reason="stop")
],
)
yield f"data: {final_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
try:
await browser_manager.save_state()
except Exception:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=settings.api_port,
log_level=settings.log_level.lower(),
)

87
app/models.py Normal file
View File

@ -0,0 +1,87 @@
import time
import uuid
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant", "tool"]
content: str
class ChatCompletionRequest(BaseModel):
model: str = "doubao-pro"
messages: List[ChatMessage]
temperature: Optional[float] = 0.7
max_tokens: Optional[int] = None
stream: Optional[bool] = False
top_p: Optional[float] = 1.0
frequency_penalty: Optional[float] = 0.0
presence_penalty: Optional[float] = 0.0
stop: Optional[Any] = None
user: Optional[str] = None
new_chat: Optional[bool] = False
class CompletionUsage(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
class ChoiceMessage(ChatMessage):
pass
class Choice(BaseModel):
index: int = 0
message: ChoiceMessage
finish_reason: str = "stop"
class ChunkDelta(BaseModel):
role: Optional[str] = None
content: Optional[str] = None
class ChunkChoice(BaseModel):
index: int = 0
delta: ChunkDelta = ChunkDelta()
finish_reason: Optional[str] = None
class ChatCompletionResponse(BaseModel):
id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:24]}")
object: str = "chat.completion"
created: int = Field(default_factory=lambda: int(time.time()))
model: str = "doubao-pro"
choices: List[Choice]
usage: CompletionUsage = CompletionUsage()
class ChatCompletionChunk(BaseModel):
id: str
object: str = "chat.completion.chunk"
created: int = Field(default_factory=lambda: int(time.time()))
model: str = "doubao-pro"
choices: List[ChunkChoice]
class ModelInfo(BaseModel):
id: str
object: str = "model"
created: int = Field(default_factory=lambda: int(time.time()))
owned_by: str = "doubao"
class ModelListResponse(BaseModel):
object: str = "list"
data: List[ModelInfo]
SUPPORTED_MODELS = [
{"id": "doubao-pro", "owned_by": "doubao"},
{"id": "doubao-lite", "owned_by": "doubao"},
{"id": "doubao-pro-32k", "owned_by": "doubao"},
]

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
services:
doubao2api:
build: .
container_name: doubao2api
restart: unless-stopped
ports:
- "8000:8000" # OpenAI-compatible API
- "5900:5900" # VNC (direct)
- "6080:6080" # noVNC web client
environment:
- VNC_PASSWORD=doubao123
- VNC_VIEW_ONLY=false
- API_KEY=sk-your-api-key-here
- DOUBAO_URL=https://www.doubao.com/chat/
- DOUBAO_MODEL=doubao-pro
- LOG_LEVEL=INFO
- BROWSER_HEADLESS=false
- RESPONSE_TIMEOUT=120
- TYPING_DELAY_MIN=30
- TYPING_DELAY_MAX=80
volumes:
- doubao_data:/app/data
shm_size: "2g"
dns:
- 8.8.8.8
- 8.8.4.4
volumes:
doubao_data:

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
supervisor==4.2.5
websockify==0.13.0
pydantic==2.10.4
pydantic-settings==2.7.1
python-dotenv==1.0.1
loguru==0.7.3

24
scripts/entrypoint.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
set -e
VNC_PASSWORD="${VNC_PASSWORD:-doubao123}"
SCREEN_WIDTH="${SCREEN_WIDTH:-1280}"
SCREEN_HEIGHT="${SCREEN_HEIGHT:-720}"
SCREEN_DEPTH="${SCREEN_DEPTH:-24}"
mkdir -p /root/.vnc /app/data
x11vnc -storepasswd "$VNC_PASSWORD" /root/.vnc/passwd
# Always set VNC mode from env var on container startup (overwrites API changes from previous run)
echo "${VNC_VIEW_ONLY:-false}" > /app/data/.vnc_mode
echo "============================================"
echo " DouBao2Api Docker Container Starting..."
echo "============================================"
echo " Screen: ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH}"
echo " VNC: localhost:${VNC_PORT:-5900} (password: ${VNC_PASSWORD})"
echo " noVNC: http://localhost:${NOVNC_PORT:-6080}/vnc.html"
echo " API: http://localhost:${API_PORT:-8000}"
echo "============================================"
exec supervisord -c /app/scripts/supervisord.conf

7
scripts/start_wm.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
if command -v openbox &> /dev/null; then
exec openbox
else
echo "No window manager installed, running without one"
sleep infinity
fi

16
scripts/start_x11vnc.sh Normal file
View File

@ -0,0 +1,16 @@
#!/bin/bash
MODE_FILE="/app/data/.vnc_mode"
if [ ! -f "$MODE_FILE" ]; then
echo "${VNC_VIEW_ONLY:-false}" > "$MODE_FILE"
fi
MODE=$(cat "$MODE_FILE" 2>/dev/null || echo "false")
if [ "$MODE" = "true" ] || [ "$MODE" = "viewonly" ]; then
echo "Starting x11vnc in VIEW-ONLY mode (no mouse/keyboard input)"
exec x11vnc -display :99 -forever -shared -rfbport "${VNC_PORT:-5900}" -rfbauth /root/.vnc/passwd -noxdamage -cursor arrow -viewonly
else
echo "Starting x11vnc in INTERACTIVE mode"
exec x11vnc -display :99 -forever -shared -rfbport "${VNC_PORT:-5900}" -rfbauth /root/.vnc/passwd -noxdamage -cursor arrow
fi

54
scripts/supervisord.conf Normal file
View File

@ -0,0 +1,54 @@
[supervisord]
nodaemon=true
user=root
logfile=/app/data/supervisord.log
pidfile=/tmp/supervisord.pid
[unix_http_server]
file=/tmp/supervisor.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock
[program:xvfb]
command=Xvfb :99 -screen 0 %(ENV_SCREEN_WIDTH)sx%(ENV_SCREEN_HEIGHT)sx%(ENV_SCREEN_DEPTH)s
priority=10
autorestart=true
stdout_logfile=/app/data/xvfb.log
stderr_logfile=/app/data/xvfb_err.log
[program:wm]
command=/app/scripts/start_wm.sh
environment=DISPLAY=":99"
priority=20
autorestart=true
stdout_logfile=/app/data/wm.log
stderr_logfile=/app/data/wm_err.log
[program:x11vnc]
command=/app/scripts/start_x11vnc.sh
environment=DISPLAY=":99",VNC_PORT="%(ENV_VNC_PORT)s",VNC_VIEW_ONLY="%(ENV_VNC_VIEW_ONLY)s"
priority=30
autorestart=true
stdout_logfile=/app/data/x11vnc.log
stderr_logfile=/app/data/x11vnc_err.log
[program:novnc]
command=websockify --web /opt/novnc %(ENV_NOVNC_PORT)s localhost:%(ENV_VNC_PORT)s
priority=40
autorestart=true
stdout_logfile=/app/data/novnc.log
stderr_logfile=/app/data/novnc_err.log
[program:api]
command=python3 -m uvicorn app.main:app --host 0.0.0.0 --port %(ENV_API_PORT)s
directory=/app
environment=DISPLAY=":99",PYTHONUNBUFFERED="1"
priority=50
autorestart=true
stdout_logfile=/app/data/api.log
stderr_logfile=/app/data/api_err.log