mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
Headless browser (Playwright): - New: headless_browser.py — full Playwright-based browser implementation - Supports: navigate, screenshot, get_text, click, type, evaluate, get_elements - Auto-fallback: execute_browser_tool tries Electron webview first, falls back to headless Playwright when "not found" error detected - Works everywhere: web browser, Electron app, server, CI - Pages managed per browser_id, cleaned up on session close WebSearch fix: - Changed User-Agent from bot identifier to Chrome UA - DDG serves different HTML to bots vs browsers, causing parse failures - Now returns proper results (tested: Eater SF, Infatuation, etc.) Requirements: - Added playwright to requirements.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
"""Headless browser fallback using Playwright.
|
|
|
|
Used when Electron's <webview> is not available (running in regular browser).
|
|
Provides the same browser actions as the Electron webview bridge:
|
|
navigate, click, type, screenshot, get_text, get_elements, evaluate.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_playwright = None
|
|
_browser = None
|
|
_pages: dict[str, Any] = {} # browser_id -> Page
|
|
|
|
|
|
async def _ensure_browser():
|
|
"""Start Playwright browser if not running."""
|
|
global _playwright, _browser
|
|
if _browser and _browser.is_connected():
|
|
return
|
|
|
|
from playwright.async_api import async_playwright
|
|
_playwright = await async_playwright().start()
|
|
_browser = await _playwright.chromium.launch(
|
|
headless=True,
|
|
args=["--no-sandbox", "--disable-gpu"],
|
|
)
|
|
logger.info("Headless browser started (Playwright/Chromium)")
|
|
|
|
|
|
async def _get_page(browser_id: str) -> Any:
|
|
"""Get or create a page for a browser_id."""
|
|
await _ensure_browser()
|
|
if browser_id not in _pages or _pages[browser_id].is_closed():
|
|
page = await _browser.new_page()
|
|
await page.set_viewport_size({"width": 1280, "height": 800})
|
|
_pages[browser_id] = page
|
|
return _pages[browser_id]
|
|
|
|
|
|
async def execute(browser_id: str, action: str, params: dict) -> dict:
|
|
"""Execute a browser action. Returns same format as Electron webview bridge."""
|
|
try:
|
|
page = await _get_page(browser_id)
|
|
|
|
if action == "navigate":
|
|
url = params.get("url", "")
|
|
if url:
|
|
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
await page.wait_for_timeout(1000) # Let JS settle
|
|
return {"text": f"Navigated to {page.url}", "url": page.url}
|
|
|
|
elif action == "screenshot":
|
|
screenshot_bytes = await page.screenshot(type="png")
|
|
b64 = base64.b64encode(screenshot_bytes).decode()
|
|
return {"image": b64, "url": page.url}
|
|
|
|
elif action == "get_text":
|
|
text = await page.evaluate("document.body?.innerText || ''")
|
|
return {"text": text[:15000]}
|
|
|
|
elif action == "click":
|
|
selector = params.get("selector", "")
|
|
if selector:
|
|
await page.click(selector, timeout=5000)
|
|
await page.wait_for_timeout(500)
|
|
return {"text": f"Clicked {selector}"}
|
|
|
|
elif action == "type":
|
|
selector = params.get("selector", "")
|
|
text = params.get("text", "")
|
|
if selector and text:
|
|
await page.fill(selector, text)
|
|
return {"text": f"Typed into {selector}"}
|
|
|
|
elif action == "evaluate":
|
|
expression = params.get("expression", "")
|
|
result = await page.evaluate(expression)
|
|
return {"text": str(result) if result is not None else "undefined"}
|
|
|
|
elif action == "get_elements":
|
|
selector = params.get("selector", "body")
|
|
elements = await page.evaluate(f"""
|
|
(() => {{
|
|
const els = document.querySelectorAll('{selector} a, {selector} button, {selector} input, {selector} select, {selector} textarea, {selector} [role="button"], {selector} [onclick]');
|
|
return Array.from(els).slice(0, 50).map(el => ({{
|
|
tag: el.tagName.toLowerCase(),
|
|
text: (el.textContent || '').trim().slice(0, 100),
|
|
selector: el.id ? '#' + el.id : (el.className ? '.' + el.className.split(' ')[0] : el.tagName.toLowerCase()),
|
|
href: el.href || null,
|
|
type: el.type || null,
|
|
placeholder: el.placeholder || null,
|
|
}}));
|
|
}})()
|
|
""")
|
|
lines = []
|
|
for el in (elements or []):
|
|
desc = f"{el['tag']}"
|
|
if el.get("text"):
|
|
desc += f" \"{el['text'][:50]}\""
|
|
if el.get("href"):
|
|
desc += f" → {el['href'][:80]}"
|
|
desc += f" selector: {el.get('selector', '?')}"
|
|
lines.append(desc)
|
|
return {"text": "\n".join(lines) if lines else "No interactive elements found"}
|
|
|
|
else:
|
|
return {"error": f"Unknown action: {action}"}
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Headless browser error ({action}): {e}")
|
|
return {"error": str(e)}
|
|
|
|
|
|
async def close_page(browser_id: str):
|
|
"""Close a specific page."""
|
|
page = _pages.pop(browser_id, None)
|
|
if page and not page.is_closed():
|
|
await page.close()
|
|
|
|
|
|
async def shutdown():
|
|
"""Close all pages and the browser."""
|
|
global _browser, _playwright
|
|
for page in _pages.values():
|
|
try:
|
|
if not page.is_closed():
|
|
await page.close()
|
|
except Exception:
|
|
pass
|
|
_pages.clear()
|
|
|
|
if _browser:
|
|
try:
|
|
await _browser.close()
|
|
except Exception:
|
|
pass
|
|
_browser = None
|
|
|
|
if _playwright:
|
|
try:
|
|
await _playwright.stop()
|
|
except Exception:
|
|
pass
|
|
_playwright = None
|