add api for post browser-use tasks
This commit is contained in:
parent
890d492de0
commit
d277955a9a
11 changed files with 320 additions and 8 deletions
|
|
@ -14,3 +14,9 @@ TELEGRAM_HOME_CHANNEL=
|
||||||
|
|
||||||
BROWSER_URL=http://browser:9222
|
BROWSER_URL=http://browser:9222
|
||||||
BROWSER_VIEW_URL=
|
BROWSER_VIEW_URL=
|
||||||
|
|
||||||
|
BROWSER_API_HOST=0.0.0.0
|
||||||
|
BROWSER_API_PORT=8088
|
||||||
|
BROWSER_USE_RPC_URL=http://browser:8787/run
|
||||||
|
BROWSER_USE_RPC_TIMEOUT=900
|
||||||
|
BROWSER_API_MAX_CONCURRENCY=2
|
||||||
13
api/Dockerfile
Normal file
13
api/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY . /app/api
|
||||||
|
|
||||||
|
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8088"]
|
||||||
17
api/README.md
Normal file
17
api/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Browser REST API
|
||||||
|
|
||||||
|
REST-обертка над `browser-use` RPC (`http://browser:8787/run`).
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /api/browser/tasks`
|
||||||
|
- `GET /api/browser/tasks/{task_id}`
|
||||||
|
- `GET /api/browser/tasks/{task_id}/result`
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST http://localhost:8088/api/browser/tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"task":"Открой example.com и верни заголовок страницы","timeout":300}'
|
||||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
31
api/browser_rpc_client.py
Normal file
31
api/browser_rpc_client.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserRpcError(RuntimeError): ...
|
||||||
|
|
||||||
|
|
||||||
|
async def run_browser_task(rpc_url: str, task: str, timeout_sec: float) -> dict[str, Any]:
|
||||||
|
payload = {"task": task}
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(timeout_sec)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(rpc_url, json=payload)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise BrowserRpcError(f"Transport error: {exc}")
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = response.text
|
||||||
|
raise BrowserRpcError(f"RPC HTTP: {response.status_code}: {body}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BrowserRpcError("RPC returned non-JSON response")
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise BrowserRpcError("RPC returned invalid payload type")
|
||||||
|
|
||||||
|
return data
|
||||||
16
api/config.py
Normal file
16
api/config.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
app_host: str = os.getenv("BROWSER_API_HOST", "0.0.0.0")
|
||||||
|
app_port: int = os.getenv("BROWSER_API_PORT", "8080")
|
||||||
|
|
||||||
|
browser_rpc_url: str = os.getenv("BROWSER_USE_RPC_URL", "http://browser:8787/run")
|
||||||
|
browser_rpc_timeout: float = float(os.getenv("BROWSER_USE_RPC_TIMEOUT", "900"))
|
||||||
|
|
||||||
|
max_concurrency = int(os.getenv("BROWSER_API_MAX_CONCURRENCY", "2"))
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
112
api/main.py
Normal file
112
api/main.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from api.browser_rpc_client import BrowserRpcError
|
||||||
|
from api.config import settings
|
||||||
|
from api.schemas import BrowserTaskRequest, BrowserTaskAcceptedResponse, BrowserTaskStatusResponse, \
|
||||||
|
BrowserTaskResultResponse, TaskStatus
|
||||||
|
from api.task_store import TaskStore
|
||||||
|
from api.browser_rpc_client import run_browser_task
|
||||||
|
|
||||||
|
store = TaskStore()
|
||||||
|
_semaphore = asyncio.Semaphore(settings.max_concurrency)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Browser API", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker(task_id: str) -> None:
|
||||||
|
rec = await store.set_running(task_id)
|
||||||
|
if rec is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with _semaphore:
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(
|
||||||
|
run_browser_task(
|
||||||
|
rpc_url=settings.browser_rpc_url,
|
||||||
|
task=rec.task,
|
||||||
|
timeout_sec=float(rec.timeout),
|
||||||
|
),
|
||||||
|
timeout=float(rec.timeout) + 5,
|
||||||
|
)
|
||||||
|
success = bool(raw.get("success"))
|
||||||
|
await store.set_done(task_id=task_id, success=success, raw_response=raw, error=None)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await store.set_done(
|
||||||
|
task_id=task_id,
|
||||||
|
success=False,
|
||||||
|
raw_response=None,
|
||||||
|
error="Timeout exceeded",
|
||||||
|
)
|
||||||
|
except BrowserRpcError as exc:
|
||||||
|
await store.set_done(
|
||||||
|
task_id=task_id,
|
||||||
|
success=False,
|
||||||
|
raw_response=None,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await store.set_done(
|
||||||
|
task_id=task_id,
|
||||||
|
success=False,
|
||||||
|
raw_response=None,
|
||||||
|
error=f"Internal error: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/browser/tasks", response_model=BrowserTaskAcceptedResponse, status_code=202)
|
||||||
|
async def create_task(payload: BrowserTaskRequest) -> BrowserTaskAcceptedResponse:
|
||||||
|
rec = await store.create(task=payload.task.strip(), timeout=payload.timeout, metadata=payload.metadata)
|
||||||
|
asyncio.create_task(_worker(rec.task_id))
|
||||||
|
return BrowserTaskAcceptedResponse(task_id=rec.task_id, status=rec.status)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/browser/tasks/{task_id}", response_model=BrowserTaskStatusResponse)
|
||||||
|
async def get_task_status(task_id: str) -> BrowserTaskStatusResponse:
|
||||||
|
rec = await store.get(task_id=task_id)
|
||||||
|
if rec is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return BrowserTaskStatusResponse(task_id=rec.task_id, status=rec.status, create_at=rec.create_at,
|
||||||
|
started_at=rec.started_at, finished_at=rec.finished_at, error=rec.error)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/browser/tasks/{task_id}/result", response_model=BrowserTaskResultResponse)
|
||||||
|
async def get_task_result(task_id: str) -> BrowserTaskResultResponse:
|
||||||
|
rec = await store.get(task_id=task_id)
|
||||||
|
if rec is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
if rec.status in (TaskStatus.queued, TaskStatus.running):
|
||||||
|
return JSONResponse(status_code=202, content={
|
||||||
|
"task_id": rec.task_id,
|
||||||
|
"status": rec.status,
|
||||||
|
"success": False,
|
||||||
|
"execution_time": rec.execution_time,
|
||||||
|
"result": None,
|
||||||
|
"error": None,
|
||||||
|
"raw_response": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return BrowserTaskResultResponse(
|
||||||
|
task_id=rec.task_id,
|
||||||
|
status=rec.status,
|
||||||
|
success=(rec.status == "succeeded"),
|
||||||
|
execution_time=rec.execution_time,
|
||||||
|
result=rec.result,
|
||||||
|
error=rec.error,
|
||||||
|
raw_response=rec.raw_response,
|
||||||
|
)
|
||||||
4
api/requirements.txt
Normal file
4
api/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
fastapi==0.135.3
|
||||||
|
uvicorn[standard]==0.44.0
|
||||||
|
httpx==0.28.1
|
||||||
|
pydantic==2.12.5
|
||||||
|
|
@ -1,15 +1,41 @@
|
||||||
from typing import Optional
|
from enum import Enum
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(str, Enum):
|
||||||
|
queued = "queued"
|
||||||
|
running = "running"
|
||||||
|
succeeded = "succeeded"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
class BrowserTaskRequest(BaseModel):
|
class BrowserTaskRequest(BaseModel):
|
||||||
task: str = Field(..., description="Задача для браузера")
|
task: str = Field(..., description="Задача для браузера")
|
||||||
timeout: int = Field(300, description="Максимальное время выполнения задачи в секундах")
|
timeout: int = Field(300, description="Максимальное время выполнения задачи в секундах")
|
||||||
|
metadata: dict[str, Any] | None = Field(default=None, description="Метаданные клиента")
|
||||||
|
|
||||||
|
|
||||||
class BrowserTaskResponse(BaseModel):
|
class BrowserTaskAcceptedResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: TaskStatus
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTaskStatusResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: TaskStatus
|
||||||
|
create_at: float
|
||||||
|
started_at: float | None = None
|
||||||
|
finished_at: float | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTaskResultResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: TaskStatus
|
||||||
success: bool
|
success: bool
|
||||||
result: Optional[str] = None
|
|
||||||
error: Optional[str] = None
|
|
||||||
execution_time: float
|
execution_time: float
|
||||||
|
result: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
raw_response: dict[str, Any] | None = None
|
||||||
|
|
|
||||||
69
api/task_store.py
Normal file
69
api/task_store.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from asyncio import Lock
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from api.schemas import TaskStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskRecord:
|
||||||
|
task_id: str
|
||||||
|
task: str
|
||||||
|
timeout: int
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
status: TaskStatus = TaskStatus.queued
|
||||||
|
create_at: float = field(default_factory=time.time)
|
||||||
|
started_at: float | None = None
|
||||||
|
finished_at: float | None = None
|
||||||
|
result: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
raw_response: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def execution_time(self) -> float:
|
||||||
|
if self.started_at is None:
|
||||||
|
return 0
|
||||||
|
end = self.finished_at if self.finished_at is not None else time.time()
|
||||||
|
return max(0, end - self.started_at)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = Lock()
|
||||||
|
self._tasks: dict[str, TaskRecord] = {}
|
||||||
|
|
||||||
|
async def create(self, task: str, timeout: int, metadata: dict[str, Any] | None) -> TaskRecord:
|
||||||
|
task_id = uuid.uuid4().hex
|
||||||
|
rec = TaskRecord(task_id=task_id, task=task, timeout=timeout, metadata=metadata)
|
||||||
|
async with self._lock:
|
||||||
|
self._tasks[task_id] = rec
|
||||||
|
return rec
|
||||||
|
|
||||||
|
async def get(self, task_id: str) -> TaskRecord | None:
|
||||||
|
async with self._lock:
|
||||||
|
return self._tasks.get(task_id)
|
||||||
|
|
||||||
|
async def set_running(self, task_id: str) -> TaskRecord | None:
|
||||||
|
async with self._lock:
|
||||||
|
rec = self._tasks.get(task_id)
|
||||||
|
if rec is None:
|
||||||
|
return None
|
||||||
|
rec.status = TaskStatus.running
|
||||||
|
rec.started_at = time.time()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
async def set_done(self, task_id: str, success: bool, raw_response: dict[str, Any] | None,
|
||||||
|
error: str | None) -> TaskRecord | None:
|
||||||
|
async with self._lock:
|
||||||
|
rec = self._tasks.get(task_id)
|
||||||
|
if rec is None:
|
||||||
|
return None
|
||||||
|
rec.finished_at = time.time()
|
||||||
|
rec.raw_response = raw_response
|
||||||
|
rec.error = error or (raw_response.get("error") if isinstance(raw_response, dict) else None)
|
||||||
|
rec.status = TaskStatus.succeeded if success else TaskStatus.failed
|
||||||
|
|
||||||
|
return rec
|
||||||
|
|
@ -35,7 +35,6 @@ services:
|
||||||
fi;
|
fi;
|
||||||
exec python -m gateway.run
|
exec python -m gateway.run
|
||||||
"
|
"
|
||||||
|
|
||||||
browser:
|
browser:
|
||||||
build:
|
build:
|
||||||
context: ./browser_env
|
context: ./browser_env
|
||||||
|
|
@ -55,12 +54,31 @@ services:
|
||||||
- browser_profiles:/src/browser_data
|
- browser_profiles:/src/browser_data
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9222/json/version >/dev/null && curl -fsS http://127.0.0.1:8787/health >/dev/null || exit 1"]
|
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:9222/json/version >/dev/null && curl -fsS http://127.0.0.1:8787/health >/dev/null || exit 1" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
|
browser-api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: hermes-browser-api
|
||||||
|
environment:
|
||||||
|
- BROWSER_USE_RPC_URL=http://browser:8787/run
|
||||||
|
- BROWSER_API_HOST=0.0.0.0
|
||||||
|
- BROWSER_API_PORT=8088
|
||||||
|
- BROWSER_API_MAX_CONCURRENCY=2
|
||||||
|
depends_on:
|
||||||
|
browser:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- hermes-net
|
||||||
|
|
||||||
tunnel:
|
tunnel:
|
||||||
image: cloudflare/cloudflared:latest
|
image: cloudflare/cloudflared:latest
|
||||||
profiles:
|
profiles:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue