448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""
|
||
Browser Use REST API Server
|
||
Простой HTTP API для управления браузером из n8n
|
||
"""
|
||
import os
|
||
import asyncio
|
||
from typing import Optional
|
||
from fastapi import FastAPI, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from browser_use import Agent, Browser, BrowserProfile, ChatOpenAI
|
||
from browser_use.agent.views import AgentHistoryList
|
||
import uvicorn
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
# Настройки Remote Debugging
|
||
REMOTE_DEBUGGING_PORT = int(os.getenv("REMOTE_DEBUGGING_PORT", "9222"))
|
||
REMOTE_DEBUGGING_ENABLED = os.getenv("REMOTE_DEBUGGING_ENABLED", "false").lower() == "true"
|
||
|
||
# Модель по умолчанию из переменной окружения
|
||
DEFAULT_MODEL = os.getenv("LLM_MODEL_NAME", "openai/gpt-4o-mini")
|
||
|
||
from contextlib import asynccontextmanager
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Startup logic
|
||
import sys
|
||
from importlib import metadata
|
||
print(f"Python version: {sys.version}")
|
||
print("Installed packages:")
|
||
dists = metadata.distributions()
|
||
for dist in dists:
|
||
print(f" {dist.metadata['Name']}=={dist.version}")
|
||
yield
|
||
# Shutdown logic (if needed)
|
||
|
||
app = FastAPI(title="Browser Use API", version="1.0.0", lifespan=lifespan)
|
||
|
||
# Модели запросов
|
||
class TaskRequest(BaseModel):
|
||
async def create_task(request: TaskRequest):
|
||
"""Создать новую задачу браузера"""
|
||
import uuid
|
||
task_id = str(uuid.uuid4())[:8]
|
||
|
||
tasks[task_id] = {
|
||
"status": "pending",
|
||
"task": request.task,
|
||
"result": None
|
||
}
|
||
|
||
# Запускаем задачу в фоне
|
||
asyncio.create_task(run_browser_task(task_id, request.task, request.max_steps, request.model))
|
||
|
||
return TaskResponse(
|
||
task_id=task_id,
|
||
status="pending"
|
||
)
|
||
|
||
async def run_browser_task(task_id: str, task: str, max_steps: int, model: str):
|
||
"""Выполнение задачи браузера"""
|
||
browser = None
|
||
try:
|
||
tasks[task_id]["status"] = "running"
|
||
|
||
# Настройка LLM через OpenRouter
|
||
api_key = os.getenv("OPENROUTER_API_KEY", "")
|
||
if not api_key:
|
||
raise ValueError("OPENROUTER_API_KEY not set")
|
||
|
||
# Читаем модель из переменной окружения в момент выполнения
|
||
actual_model = model if model else os.getenv("LLM_MODEL_NAME", "openai/gpt-4o-mini")
|
||
|
||
# Используем ChatOpenAI с base_url для OpenRouter
|
||
llm = ChatOpenAI(
|
||
model=actual_model,
|
||
base_url="https://openrouter.ai/api/v1",
|
||
api_key=api_key
|
||
)
|
||
|
||
# Настройки браузера
|
||
# Если включен remote debugging - запускаем Chrome вручную и подключаемся через CDP
|
||
if REMOTE_DEBUGGING_ENABLED:
|
||
import subprocess
|
||
import glob
|
||
import urllib.request
|
||
import sys
|
||
|
||
cdp_url = f"http://127.0.0.1:{REMOTE_DEBUGGING_PORT}"
|
||
|
||
# 1. Проверяем, не запущен ли уже Chrome с CDP
|
||
print(f"[{task_id}] Checking if Chrome is already running with CDP at {cdp_url}...")
|
||
chrome_already_running = False
|
||
try:
|
||
response = urllib.request.urlopen(f"{cdp_url}/json/version", timeout=2)
|
||
if response.status == 200:
|
||
print(f"[{task_id}] Chrome is already running, connecting to existing instance.")
|
||
chrome_already_running = True
|
||
except Exception:
|
||
print(f"[{task_id}] Chrome not detected at {cdp_url}. Starting new instance...")
|
||
|
||
if not chrome_already_running:
|
||
# Список возможных путей к Chrome
|
||
chrome_exe = None
|
||
possible_paths = [
|
||
"/root/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome",
|
||
"/root/.cache/ms-playwright/chromium-*/chrome-linux64/chrome",
|
||
"/ms-playwright/*/chrome-linux/chrome",
|
||
"/usr/bin/google-chrome",
|
||
"/usr/bin/chromium",
|
||
"/usr/bin/chromium-browser",
|
||
]
|
||
|
||
for path_pattern in possible_paths:
|
||
matches = glob.glob(path_pattern)
|
||
if matches:
|
||
chrome_exe = matches[0]
|
||
break
|
||
|
||
if not chrome_exe:
|
||
import shutil
|
||
chrome_exe = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser")
|
||
|
||
if not chrome_exe:
|
||
raise RuntimeError(f"Chrome not found. Please ensure playwright browsers are installed.")
|
||
|
||
print(f"[{task_id}] Using Chrome executable: {chrome_exe}")
|
||
|
||
# Используем постоянную директорию для сохранения сессий (логинов)
|
||
user_data_dir = "/app/browser-data/main_profile"
|
||
os.makedirs(user_data_dir, exist_ok=True)
|
||
|
||
# Запускаем Chrome с remote debugging
|
||
chrome_cmd = [
|
||
chrome_exe,
|
||
f"--remote-debugging-port={REMOTE_DEBUGGING_PORT}",
|
||
"--remote-debugging-address=0.0.0.0",
|
||
f"--user-data-dir={user_data_dir}",
|
||
"--remote-allow-origins=*",
|
||
"--headless=new",
|
||
"--no-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
"--disable-gpu",
|
||
"--no-first-run",
|
||
"--no-default-browser-check",
|
||
"--disable-extensions",
|
||
"about:blank"
|
||
]
|
||
|
||
print(f"[{task_id}] Executing command: {' '.join(chrome_cmd)}")
|
||
|
||
# Запускаем процесс
|
||
print(f"[{task_id}] Starting Chrome process...")
|
||
log_file = open(f"/tmp/chrome_{task_id}.log", "w")
|
||
process = subprocess.Popen(
|
||
chrome_cmd,
|
||
stdout=log_file,
|
||
stderr=log_file,
|
||
start_new_session=True
|
||
)
|
||
|
||
# Ждем пока Chrome запустится и CDP будет готов
|
||
cdp_ready = False
|
||
for i in range(30): # Ждем до 30 секунд
|
||
await asyncio.sleep(1)
|
||
|
||
# Проверяем порты через python (замена netstat)
|
||
import socket
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
is_open = s.connect_ex(('127.0.0.1', REMOTE_DEBUGGING_PORT)) == 0
|
||
if is_open:
|
||
print(f"[{task_id}] Port {REMOTE_DEBUGGING_PORT} is OPEN!")
|
||
|
||
try:
|
||
response = urllib.request.urlopen(f"{cdp_url}/json/version", timeout=2)
|
||
if response.status == 200:
|
||
cdp_ready = True
|
||
print(f"[{task_id}] CDP is ready at {cdp_url}")
|
||
break
|
||
except Exception as e:
|
||
if i % 5 == 0:
|
||
print(f"[{task_id}] Waiting for CDP at {cdp_url}... (attempt {i})")
|
||
# Читаем последние строки лога Chrome
|
||
try:
|
||
with open(f"/tmp/chrome_{task_id}.log", "r") as f:
|
||
lines = f.readlines()[-5:]
|
||
print(f"[{task_id}] Last Chrome logs: {''.join(lines)}")
|
||
except:
|
||
pass
|
||
|
||
if not cdp_ready:
|
||
# Читаем весь лог при ошибке
|
||
with open(f"/tmp/chrome_{task_id}.log", "r") as f:
|
||
chrome_logs = f.read()
|
||
raise RuntimeError(f"Failed to start Chrome or CDP not ready. Chrome Logs:\n{chrome_logs}")
|
||
|
||
# Подключаемся через CDP
|
||
from browser_use import BrowserConfig
|
||
browser = Browser(config=BrowserConfig(cdp_url=cdp_url))
|
||
else:
|
||
# Обычный запуск
|
||
browser_profile = BrowserProfile(
|
||
user_data_dir="/app/browser-data",
|
||
profile_directory="Default",
|
||
headless=True,
|
||
keep_alive=True,
|
||
)
|
||
browser = Browser(browser_profile=browser_profile)
|
||
|
||
# Создание агента
|
||
agent = Agent(
|
||
task=task,
|
||
llm=llm,
|
||
browser=browser,
|
||
)
|
||
|
||
# Выполнение задачи
|
||
history: AgentHistoryList = await agent.run(max_steps=max_steps)
|
||
|
||
# Получение результата
|
||
result = history.final_result()
|
||
|
||
tasks[task_id]["status"] = "completed"
|
||
tasks[task_id]["result"] = result
|
||
|
||
except Exception as e:
|
||
tasks[task_id]["status"] = "failed"
|
||
tasks[task_id]["error"] = str(e)
|
||
finally:
|
||
# Закрытие браузера (правильный способ)
|
||
if browser:
|
||
try:
|
||
# browser_use сам управляет закрытием
|
||
pass
|
||
except:
|
||
pass
|
||
|
||
@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
|
||
async def get_task(task_id: str):
|
||
"""Получить статус/результат задачи"""
|
||
if task_id not in tasks:
|
||
raise HTTPException(status_code=404, detail="Task not found")
|
||
|
||
task = tasks[task_id]
|
||
return TaskResponse(
|
||
task_id=task_id,
|
||
status=task["status"],
|
||
result=task.get("result"),
|
||
error=task.get("error")
|
||
)
|
||
|
||
@app.get("/api/tasks")
|
||
async def list_tasks():
|
||
"""Получить список всех задач"""
|
||
return {
|
||
"tasks": [
|
||
{
|
||
"task_id": task_id,
|
||
"status": task["status"],
|
||
"task": task.get("task", ""),
|
||
"result": task.get("result"),
|
||
"error": task.get("error")
|
||
}
|
||
for task_id, task in tasks.items()
|
||
]
|
||
}
|
||
|
||
@app.get("/api/health")
|
||
async def health_check():
|
||
"""Проверка здоровья сервера"""
|
||
return {
|
||
"status": "healthy",
|
||
"service": "browser-use-api",
|
||
"version": "1.0.0"
|
||
}
|
||
|
||
# Web UI - Главная страница
|
||
WEB_UI_HTML = """
|
||
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Browser Use API</title>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
|
||
h1 { color: #333; }
|
||
.card { border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||
.btn { background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; }
|
||
.btn:hover { background: #0056b3; }
|
||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
||
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Browser Use API</h1>
|
||
<p>REST API для автоматизации браузера с помощью AI</p>
|
||
|
||
<div class="card">
|
||
<h2>Быстрые ссылки</h2>
|
||
<p><a href="/dashboard" class="btn">Открыть Dashboard</a></p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>API Endpoints</h2>
|
||
<ul>
|
||
<li><code>POST /api/tasks</code> - Создать задачу</li>
|
||
<li><code>GET /api/tasks/{task_id}</code> - Получить результат</li>
|
||
<li><code>GET /api/health</code> - Проверка здоровья</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Пример использования</h2>
|
||
<pre>curl -X POST http://localhost:8383/api/tasks \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{"task": "Перейди на example.com", "max_steps": 20}'</pre>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
DASHBOARD_HTML = """
|
||
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Browser Use Dashboard</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
||
.container { max-width: 1200px; margin: 0 auto; }
|
||
h1 { color: #333; }
|
||
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||
.task-form { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
input, select { padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||
input[type="text"] { flex: 1; min-width: 200px; }
|
||
input[type="number"] { width: 100px; }
|
||
button { background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||
button:hover { background: #0056b3; }
|
||
.task-list { list-style: none; padding: 0; }
|
||
.task-item { border: 1px solid #eee; padding: 15px; margin: 10px 0; border-radius: 4px; }
|
||
.status-pending { border-left: 4px solid #ffc107; }
|
||
.status-running { border-left: 4px solid #17a2b8; }
|
||
.status-completed { border-left: 4px solid #28a745; }
|
||
.status-failed { border-left: 4px solid #dc3545; }
|
||
.badge { display: inline-block; padding: 4px 8px; border-radius: 3px; font-size: 12px; background: #eee; }
|
||
.result { background: #f8f9fa; padding: 10px; border-radius: 4px; margin-top: 10px; white-space: pre-wrap; }
|
||
.nav { margin-bottom: 20px; }
|
||
.nav a { color: #007bff; text-decoration: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="nav"><a href="/">← На главную</a></div>
|
||
<h1>Browser Use Dashboard</h1>
|
||
|
||
<div class="card">
|
||
<h2>Создать задачу</h2>
|
||
<form class="task-form" id="taskForm">
|
||
<input type="text" id="taskInput" placeholder="Описание задачи (например: Перейди на example.com)" required>
|
||
<input type="number" id="maxSteps" value="20" min="1" max="100" placeholder="Шаги">
|
||
<select id="modelSelect">
|
||
<option value="arcee-ai/trinity-large-preview:free">arcee-ai/trinity-large-preview:free</option>
|
||
<option value="openai/gpt-4o-mini">openai/gpt-4o-mini</option>
|
||
<option value="google/gemini-2.0-flash-001">google/gemini-2.0-flash-001</option>
|
||
</select>
|
||
<button type="submit">Запустить</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Задачи</h2>
|
||
<button onclick="refreshTasks()">Обновить</button>
|
||
<ul class="task-list" id="taskList"></ul>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let pollInterval;
|
||
|
||
document.getElementById('taskForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const task = document.getElementById('taskInput').value;
|
||
const max_steps = parseInt(document.getElementById('maxSteps').value);
|
||
const model = document.getElementById('modelSelect').value;
|
||
|
||
const response = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({task, max_steps, model})
|
||
});
|
||
const data = await response.json();
|
||
alert('Задача создана! ID: ' + data.task_id);
|
||
refreshTasks();
|
||
});
|
||
|
||
async function refreshTasks() {
|
||
try {
|
||
const res = await fetch('/api/tasks');
|
||
const data = await res.json();
|
||
const list = document.getElementById('taskList');
|
||
list.innerHTML = '';
|
||
|
||
const taskList = data.tasks || [];
|
||
if (taskList.length === 0) {
|
||
list.innerHTML = '<li>Нет задач. Создайте первую задачу!</li>';
|
||
return;
|
||
}
|
||
|
||
for (const task of taskList) {
|
||
const li = document.createElement('li');
|
||
li.className = 'task-item status-' + task.status;
|
||
li.innerHTML = `
|
||
<strong>Task ID:</strong> ${task.task_id}<br>
|
||
<strong>Задача:</strong> ${task.task || 'N/A'}<br>
|
||
<span class="badge">${task.status}</span><br>
|
||
<strong>Результат:</strong>
|
||
<div class="result">${task.result || task.error || 'Ожидание...'}</div>
|
||
`;
|
||
list.appendChild(li);
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
refreshTasks();
|
||
setInterval(refreshTasks, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def root():
|
||
"""Главная страница"""
|
||
return HTMLResponse(content=WEB_UI_HTML)
|
||
|
||
@app.get("/dashboard", response_class=HTMLResponse)
|
||
async def dashboard():
|
||
"""Dashboard"""
|
||
return HTMLResponse(content=DASHBOARD_HTML)
|
||
|
||
if __name__ == "__main__":
|
||
uvicorn.run(app, host="0.0.0.0", port=8383)
|