🚀 Auto-deploy: BotVPS atualizado em 02/05/2026 15:37:40
This commit is contained in:
238
browser_cloud.py
Normal file
238
browser_cloud.py
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Browser Cloud Service — Playwright-based browser automation server
|
||||
Exposes a simple REST API for spawning/controlling browser sessions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import json
|
||||
import base64
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from playwright.async_api import async_playwright, TimeoutError as PWTimeoutError, BrowserContext, Page, Playwright
|
||||
|
||||
# Max concurrent browsers
|
||||
MAX_BROWSERS = 5
|
||||
BROWSER_TIMEOUT = 30000 # ms
|
||||
|
||||
# Active browser contexts
|
||||
active_contexts: dict[str, BrowserContext] = {}
|
||||
active_pages: dict[str, Page] = {}
|
||||
active_playwrights: dict[str, Playwright] = {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("[BrowserCloud] Iniciando Playwright service...")
|
||||
yield
|
||||
# Shutdown: close all browsers
|
||||
print(f"[BrowserCloud] Fechando {len(active_contexts)} contextos...")
|
||||
for ctx_id in list(active_contexts.keys()):
|
||||
try:
|
||||
await active_contexts[ctx_id].close()
|
||||
except Exception:
|
||||
pass
|
||||
active_contexts.clear()
|
||||
active_pages.clear()
|
||||
print("[BrowserCloud] Done.")
|
||||
|
||||
|
||||
app = FastAPI(title="BrowserCloud", lifespan=lifespan)
|
||||
|
||||
|
||||
# ─── Models ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class BrowserOptions(BaseModel):
|
||||
headless: bool = True
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
user_agent: Optional[str] = None
|
||||
|
||||
|
||||
class NavigateOptions(BaseModel):
|
||||
url: str
|
||||
wait_until: str = "load" # load|domcontentloaded|networkidle
|
||||
|
||||
|
||||
class ScreenshotOptions(BaseModel):
|
||||
full_page: bool = False
|
||||
format: str = "png" # png|jpg
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_page(ctx_id: str) -> Page:
|
||||
if ctx_id not in active_pages:
|
||||
raise HTTPException(404, f"Sessão {ctx_id} não encontrada")
|
||||
return active_pages[ctx_id]
|
||||
|
||||
|
||||
async def _cleanup(ctx_id: str):
|
||||
"""Close a session and remove from active dicts."""
|
||||
if ctx_id in active_pages:
|
||||
try:
|
||||
await active_pages[ctx_id].close()
|
||||
except Exception:
|
||||
pass
|
||||
del active_pages[ctx_id]
|
||||
if ctx_id in active_contexts:
|
||||
try:
|
||||
await active_contexts[ctx_id].close()
|
||||
except Exception:
|
||||
pass
|
||||
del active_contexts[ctx_id]
|
||||
if ctx_id in active_playwrights:
|
||||
try:
|
||||
await active_playwrights[ctx_id].stop()
|
||||
except Exception:
|
||||
pass
|
||||
del active_playwrights[ctx_id]
|
||||
|
||||
|
||||
# ─── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"active_sessions": len(active_contexts),
|
||||
"max_sessions": MAX_BROWSERS
|
||||
}
|
||||
|
||||
|
||||
@app.post("/session")
|
||||
async def create_session(opts: BrowserOptions = BrowserOptions()):
|
||||
"""Cria uma nova sessão de browser (contexto + página)."""
|
||||
if len(active_contexts) >= MAX_BROWSERS:
|
||||
raise HTTPException(503, f"Limite de {MAX_BROWSERS} sessões atingido")
|
||||
|
||||
ctx_id = uuid.uuid4().hex[:12]
|
||||
|
||||
try:
|
||||
pw = await async_playwright().start()
|
||||
browser = await pw.chromium.launch(headless=opts.headless)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": opts.viewport_width, "height": opts.viewport_height},
|
||||
user_agent=opts.user_agent or "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
accept_downloads=True,
|
||||
)
|
||||
page = await context.new_page()
|
||||
except Exception as e:
|
||||
await pw.stop()
|
||||
raise HTTPException(500, f"Falha ao iniciar browser: {e}")
|
||||
|
||||
active_contexts[ctx_id] = context
|
||||
active_pages[ctx_id] = page
|
||||
active_playwrights[ctx_id] = pw
|
||||
|
||||
# Auto-cleanup on context close
|
||||
context.on("close", lambda: asyncio.create_task(_cleanup(ctx_id)))
|
||||
|
||||
return {"session_id": ctx_id, "url": page.url}
|
||||
|
||||
|
||||
@app.get("/session/{ctx_id}")
|
||||
async def get_session(ctx_id: str):
|
||||
page = _get_page(ctx_id)
|
||||
return {
|
||||
"session_id": ctx_id,
|
||||
"url": page.url,
|
||||
"title": await page.title(),
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/session/{ctx_id}")
|
||||
async def close_session(ctx_id: str):
|
||||
await _cleanup(ctx_id)
|
||||
return {"status": "closed", "session_id": ctx_id}
|
||||
|
||||
|
||||
@app.post("/session/{ctx_id}/navigate")
|
||||
async def navigate(ctx_id: str, opts: NavigateOptions):
|
||||
page = _get_page(ctx_id)
|
||||
try:
|
||||
await page.goto(opts.url, wait_until=opts.wait_until, timeout=BROWSER_TIMEOUT)
|
||||
except PWTimeoutError:
|
||||
raise HTTPException(408, "Navegação deu timeout")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
return {"ok": True, "url": page.url, "title": await page.title()}
|
||||
|
||||
|
||||
@app.post("/session/{ctx_id}/click")
|
||||
async def click(ctx_id: str, selector: str, timeout: int = 10000):
|
||||
page = _get_page(ctx_id)
|
||||
try:
|
||||
await page.click(selector, timeout=timeout)
|
||||
except PWTimeoutError:
|
||||
raise HTTPException(408, f"Elemento não encontrado: {selector}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/session/{ctx_id}/fill")
|
||||
async def fill(ctx_id: str, selector: str, value: str, submit: bool = False):
|
||||
page = _get_page(ctx_id)
|
||||
try:
|
||||
await page.fill(selector, value, timeout=10000)
|
||||
if submit:
|
||||
await page.press(selector, "Enter")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/session/{ctx_id}/screenshot")
|
||||
async def screenshot(ctx_id: str, opts: ScreenshotOptions = ScreenshotOptions()):
|
||||
page = _get_page(ctx_id)
|
||||
try:
|
||||
img = await page.screenshot(full_page=opts.full_page, type=opts.format)
|
||||
b64 = base64.b64encode(img).decode()
|
||||
return {"format": opts.format, "data": b64}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.get("/session/{ctx_id}/html")
|
||||
async def get_html(ctx_id: str):
|
||||
page = _get_page(ctx_id)
|
||||
return {"html": await page.content()}
|
||||
|
||||
|
||||
@app.get("/session/{ctx_id}/text")
|
||||
async def get_text(ctx_id: str, selector: str = "body"):
|
||||
page = _get_page(ctx_id)
|
||||
try:
|
||||
el = await page.query_selector(selector)
|
||||
if not el:
|
||||
raise HTTPException(404, f"Elemento {selector} não encontrado")
|
||||
text = await el.inner_text()
|
||||
return {"selector": selector, "text": text}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/session/{ctx_id}/evaluate")
|
||||
async def evaluate(ctx_id: str, script: str):
|
||||
page = _get_page(ctx_id)
|
||||
try:
|
||||
result = await page.evaluate(script)
|
||||
return {"result": result}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(__import__("os").getenv("PORT", 8088))
|
||||
uvicorn.run("browser_cloud:app", host="0.0.0.0", port=port, reload=False)
|
||||
Reference in New Issue
Block a user