2025-08-11 13:28:18 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
"""
|
|
|
|
Playwright-compatible WebSocket Browser Client
|
|
|
|
Provides a Playwright-like API for controlling remote browsers via WebSocket.
|
|
|
|
"""
|
|
|
|
|
2025-07-17 00:51:02 +02:00
|
|
|
import asyncio
|
|
|
|
import websockets
|
|
|
|
import json
|
2025-08-11 13:28:18 +02:00
|
|
|
import uuid
|
|
|
|
import time
|
2025-07-17 00:51:02 +02:00
|
|
|
import base64
|
2025-08-11 13:28:18 +02:00
|
|
|
import logging
|
|
|
|
from typing import Optional, Dict, Any, List, Callable, Union
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
import weakref
|
|
|
|
|
|
|
|
# Configure logging
|
|
|
|
logging.basicConfig(
|
|
|
|
level=logging.INFO,
|
|
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
class TimeoutError(Exception):
|
|
|
|
"""Raised when an operation times out"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
class BrowserError(Exception):
|
|
|
|
"""Raised when a browser operation fails"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class ElementHandle:
|
|
|
|
"""Represents a handle to a DOM element"""
|
|
|
|
page: 'Page'
|
|
|
|
selector: str
|
|
|
|
|
|
|
|
async def click(self, **options):
|
|
|
|
"""Click the element"""
|
|
|
|
return await self.page._playwright_action("click", {"selector": self.selector, "options": options})
|
|
|
|
|
|
|
|
async def fill(self, value: str):
|
|
|
|
"""Fill the element with text"""
|
|
|
|
return await self.page._playwright_action("fill", {"selector": self.selector, "value": value})
|
|
|
|
|
|
|
|
async def type(self, text: str, delay: int = 0):
|
|
|
|
"""Type text into the element"""
|
|
|
|
return await self.page.type(self.selector, text, delay=delay)
|
|
|
|
|
|
|
|
async def press(self, key: str):
|
|
|
|
"""Press a key"""
|
|
|
|
return await self.page._playwright_action("press", {"selector": self.selector, "key": key})
|
|
|
|
|
|
|
|
async def hover(self):
|
|
|
|
"""Hover over the element"""
|
|
|
|
return await self.page._playwright_action("hover", {"selector": self.selector})
|
|
|
|
|
|
|
|
async def focus(self):
|
|
|
|
"""Focus the element"""
|
|
|
|
return await self.page._playwright_action("focus", {"selector": self.selector})
|
|
|
|
|
|
|
|
async def get_attribute(self, name: str) -> Optional[str]:
|
|
|
|
"""Get element attribute"""
|
|
|
|
result = await self.page._playwright_action("get_attribute", {"selector": self.selector, "name": name})
|
|
|
|
return result.get("value") if result.get("success") else None
|
|
|
|
|
|
|
|
async def inner_text(self) -> str:
|
|
|
|
"""Get inner text"""
|
|
|
|
result = await self.page._playwright_action("inner_text", {"selector": self.selector})
|
|
|
|
return result.get("text", "") if result.get("success") else ""
|
|
|
|
|
|
|
|
async def inner_html(self) -> str:
|
|
|
|
"""Get inner HTML"""
|
|
|
|
result = await self.page._playwright_action("inner_html", {"selector": self.selector})
|
|
|
|
return result.get("html", "") if result.get("success") else ""
|
|
|
|
|
|
|
|
async def is_visible(self) -> bool:
|
|
|
|
"""Check if element is visible"""
|
|
|
|
result = await self.page._playwright_action("is_visible", {"selector": self.selector})
|
|
|
|
return result.get("visible", False) if result.get("success") else False
|
|
|
|
|
|
|
|
async def is_enabled(self) -> bool:
|
|
|
|
"""Check if element is enabled"""
|
|
|
|
result = await self.page._playwright_action("is_enabled", {"selector": self.selector})
|
|
|
|
return result.get("enabled", False) if result.get("success") else False
|
|
|
|
|
|
|
|
async def is_checked(self) -> bool:
|
|
|
|
"""Check if element is checked"""
|
|
|
|
result = await self.page._playwright_action("is_checked", {"selector": self.selector})
|
|
|
|
return result.get("checked", False) if result.get("success") else False
|
|
|
|
|
|
|
|
async def check(self):
|
|
|
|
"""Check a checkbox or radio button"""
|
|
|
|
return await self.page._playwright_action("check", {"selector": self.selector})
|
|
|
|
|
|
|
|
async def uncheck(self):
|
|
|
|
"""Uncheck a checkbox"""
|
|
|
|
return await self.page._playwright_action("uncheck", {"selector": self.selector})
|
|
|
|
|
|
|
|
async def select_option(self, values: Union[str, List[str]]):
|
|
|
|
"""Select option(s) in a select element"""
|
|
|
|
return await self.page._playwright_action("select_option", {"selector": self.selector, "values": values})
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
class Locator:
|
|
|
|
"""Playwright-style locator for finding elements"""
|
|
|
|
|
|
|
|
def __init__(self, page: 'Page', selector: str):
|
|
|
|
self.page = page
|
|
|
|
self.selector = selector
|
|
|
|
|
|
|
|
async def click(self, **options):
|
|
|
|
"""Click the first matching element"""
|
|
|
|
return await self.page.click(self.selector, **options)
|
|
|
|
|
|
|
|
async def fill(self, value: str):
|
|
|
|
"""Fill the first matching element"""
|
|
|
|
return await self.page.fill(self.selector, value)
|
|
|
|
|
|
|
|
async def type(self, text: str, delay: int = 0):
|
|
|
|
"""Type into the first matching element"""
|
|
|
|
return await self.page.type(self.selector, text, delay=delay)
|
|
|
|
|
|
|
|
async def press(self, key: str):
|
|
|
|
"""Press a key on the first matching element"""
|
|
|
|
return await self.page.press(self.selector, key)
|
|
|
|
|
|
|
|
async def hover(self):
|
|
|
|
"""Hover over the first matching element"""
|
|
|
|
return await self.page.hover(self.selector)
|
|
|
|
|
|
|
|
async def focus(self):
|
|
|
|
"""Focus the first matching element"""
|
|
|
|
return await self.page.focus(self.selector)
|
|
|
|
|
|
|
|
async def get_attribute(self, name: str) -> Optional[str]:
|
|
|
|
"""Get attribute of the first matching element"""
|
|
|
|
element = ElementHandle(self.page, self.selector)
|
|
|
|
return await element.get_attribute(name)
|
|
|
|
|
|
|
|
async def inner_text(self) -> str:
|
|
|
|
"""Get inner text of the first matching element"""
|
|
|
|
element = ElementHandle(self.page, self.selector)
|
|
|
|
return await element.inner_text()
|
|
|
|
|
|
|
|
async def inner_html(self) -> str:
|
|
|
|
"""Get inner HTML of the first matching element"""
|
|
|
|
element = ElementHandle(self.page, self.selector)
|
|
|
|
return await element.inner_html()
|
|
|
|
|
|
|
|
async def is_visible(self) -> bool:
|
|
|
|
"""Check if the first matching element is visible"""
|
|
|
|
element = ElementHandle(self.page, self.selector)
|
|
|
|
return await element.is_visible()
|
|
|
|
|
|
|
|
async def is_enabled(self) -> bool:
|
|
|
|
"""Check if the first matching element is enabled"""
|
|
|
|
element = ElementHandle(self.page, self.selector)
|
|
|
|
return await element.is_enabled()
|
|
|
|
|
|
|
|
async def is_checked(self) -> bool:
|
|
|
|
"""Check if the first matching element is checked"""
|
|
|
|
element = ElementHandle(self.page, self.selector)
|
|
|
|
return await element.is_checked()
|
|
|
|
|
|
|
|
async def check(self):
|
|
|
|
"""Check the first matching checkbox or radio"""
|
|
|
|
return await self.page.check(self.selector)
|
|
|
|
|
|
|
|
async def uncheck(self):
|
|
|
|
"""Uncheck the first matching checkbox"""
|
|
|
|
return await self.page.uncheck(self.selector)
|
|
|
|
|
|
|
|
async def select_option(self, values: Union[str, List[str]]):
|
|
|
|
"""Select option(s) in the first matching select"""
|
|
|
|
return await self.page.select_option(self.selector, values)
|
|
|
|
|
|
|
|
async def count(self) -> int:
|
|
|
|
"""Count matching elements"""
|
|
|
|
result = await self.page._playwright_action("query_selector_all", {"selector": self.selector})
|
|
|
|
return result.get("count", 0) if result.get("success") else 0
|
|
|
|
|
|
|
|
async def first(self) -> ElementHandle:
|
|
|
|
"""Get the first matching element"""
|
|
|
|
return ElementHandle(self.page, self.selector)
|
|
|
|
|
|
|
|
async def wait_for(self, **options):
|
|
|
|
"""Wait for the element to appear"""
|
|
|
|
return await self.page.wait_for_selector(self.selector, **options)
|
|
|
|
|
|
|
|
class Page:
|
|
|
|
"""Represents a browser page with Playwright-compatible API"""
|
|
|
|
|
|
|
|
def __init__(self, browser: 'Browser', connection_id: str):
|
|
|
|
self.browser = browser
|
|
|
|
self.connection_id = connection_id
|
|
|
|
self._closed = False
|
|
|
|
self._event_listeners: Dict[str, List[Callable]] = {}
|
|
|
|
|
|
|
|
async def goto(self, url: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Navigate to a URL"""
|
|
|
|
return await self.browser._send_command({
|
|
|
|
"command": "goto",
|
|
|
|
"url": url,
|
|
|
|
"options": options
|
|
|
|
})
|
|
|
|
|
|
|
|
async def go_back(self, **options) -> Dict[str, Any]:
|
|
|
|
"""Navigate back"""
|
|
|
|
return await self.browser._send_command({"command": "go_back"})
|
|
|
|
|
|
|
|
async def go_forward(self, **options) -> Dict[str, Any]:
|
|
|
|
"""Navigate forward"""
|
|
|
|
return await self.browser._send_command({"command": "go_forward"})
|
|
|
|
|
|
|
|
async def reload(self, **options) -> Dict[str, Any]:
|
|
|
|
"""Reload the page"""
|
|
|
|
return await self.browser._send_command({"command": "reload"})
|
|
|
|
|
|
|
|
async def set_content(self, html: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Set page content"""
|
|
|
|
return await self.browser._send_command({
|
|
|
|
"command": "set_content",
|
|
|
|
"html": html,
|
|
|
|
"options": options
|
|
|
|
})
|
|
|
|
|
|
|
|
async def content(self) -> str:
|
|
|
|
"""Get page content"""
|
|
|
|
result = await self.browser._send_command({"command": "content"})
|
|
|
|
return result if isinstance(result, str) else ""
|
|
|
|
|
|
|
|
async def title(self) -> str:
|
|
|
|
"""Get page title"""
|
|
|
|
result = await self.browser._send_command({"command": "title"})
|
|
|
|
return result if isinstance(result, str) else ""
|
|
|
|
|
|
|
|
async def url(self) -> str:
|
|
|
|
"""Get page URL"""
|
|
|
|
result = await self.browser._send_command({"command": "url"})
|
|
|
|
return result if isinstance(result, str) else ""
|
|
|
|
|
|
|
|
async def evaluate(self, expression: str, arg=None) -> Any:
|
|
|
|
"""Evaluate JavaScript in the page"""
|
|
|
|
if arg is not None:
|
|
|
|
# Simple argument serialization
|
|
|
|
expression = f"({expression})({json.dumps(arg)})"
|
|
|
|
return await self.browser._send_command({
|
|
|
|
"command": "evaluate",
|
|
|
|
"expression": expression
|
|
|
|
})
|
|
|
|
|
|
|
|
async def evaluate_handle(self, expression: str, arg=None) -> Any:
|
|
|
|
"""Evaluate JavaScript and return a handle (simplified)"""
|
|
|
|
return await self.evaluate(expression, arg)
|
|
|
|
|
|
|
|
async def screenshot(self, **options) -> bytes:
|
|
|
|
"""Take a screenshot"""
|
|
|
|
result = await self.browser._send_command({
|
|
|
|
"command": "screenshot",
|
|
|
|
"options": options
|
|
|
|
})
|
|
|
|
if isinstance(result, dict) and "screenshot" in result:
|
|
|
|
return base64.b64decode(result["screenshot"])
|
|
|
|
raise BrowserError("Failed to capture screenshot")
|
|
|
|
|
|
|
|
async def click(self, selector: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Click an element"""
|
|
|
|
return await self._playwright_action("click", {"selector": selector, "options": options})
|
|
|
|
|
|
|
|
async def dblclick(self, selector: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Double-click an element"""
|
|
|
|
# Simulate double click with two clicks
|
|
|
|
await self.click(selector, **options)
|
|
|
|
return await self.click(selector, **options)
|
|
|
|
|
|
|
|
async def fill(self, selector: str, value: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Fill an input element"""
|
|
|
|
return await self._playwright_action("fill", {"selector": selector, "value": value})
|
|
|
|
|
|
|
|
async def type(self, selector: str, text: str, delay: int = 0) -> Dict[str, Any]:
|
|
|
|
"""Type text into an element"""
|
|
|
|
return await self.browser._send_command({
|
|
|
|
"command": "type",
|
|
|
|
"selector": selector,
|
|
|
|
"text": text,
|
|
|
|
"options": {"delay": delay}
|
|
|
|
})
|
|
|
|
|
|
|
|
async def press(self, selector: str, key: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Press a key"""
|
|
|
|
return await self._playwright_action("press", {"selector": selector, "key": key})
|
|
|
|
|
|
|
|
async def check(self, selector: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Check a checkbox or radio button"""
|
|
|
|
return await self._playwright_action("check", {"selector": selector})
|
|
|
|
|
|
|
|
async def uncheck(self, selector: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Uncheck a checkbox"""
|
|
|
|
return await self._playwright_action("uncheck", {"selector": selector})
|
|
|
|
|
|
|
|
async def select_option(self, selector: str, values: Union[str, List[str]], **options) -> Dict[str, Any]:
|
|
|
|
"""Select option(s) in a select element"""
|
|
|
|
return await self._playwright_action("select_option", {"selector": selector, "values": values})
|
|
|
|
|
|
|
|
async def hover(self, selector: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Hover over an element"""
|
|
|
|
return await self._playwright_action("hover", {"selector": selector})
|
|
|
|
|
|
|
|
async def focus(self, selector: str, **options) -> Dict[str, Any]:
|
|
|
|
"""Focus an element"""
|
|
|
|
return await self._playwright_action("focus", {"selector": selector})
|
|
|
|
|
|
|
|
async def wait_for_selector(self, selector: str, **options) -> Optional[ElementHandle]:
|
|
|
|
"""Wait for a selector to appear"""
|
|
|
|
result = await self._playwright_action("wait_for_selector", {
|
|
|
|
"selector": selector,
|
|
|
|
"options": options
|
|
|
|
})
|
|
|
|
if result.get("success"):
|
|
|
|
return ElementHandle(self, selector)
|
|
|
|
return None
|
|
|
|
|
|
|
|
async def wait_for_load_state(self, state: str = "load", **options) -> None:
|
|
|
|
"""Wait for a load state"""
|
|
|
|
await self._playwright_action("wait_for_load_state", {
|
|
|
|
"state": state,
|
|
|
|
"timeout": options.get("timeout", 30000)
|
|
|
|
})
|
|
|
|
|
|
|
|
async def wait_for_timeout(self, timeout: int) -> None:
|
|
|
|
"""Wait for a timeout"""
|
|
|
|
await self._playwright_action("wait_for_timeout", {"timeout": timeout})
|
|
|
|
|
|
|
|
async def wait_for_function(self, expression: str, **options) -> Any:
|
|
|
|
"""Wait for a function to return true"""
|
|
|
|
# Simplified implementation using polling
|
|
|
|
timeout = options.get("timeout", 30000)
|
|
|
|
polling = options.get("polling", 100)
|
|
|
|
start_time = time.time()
|
|
|
|
|
|
|
|
while (time.time() - start_time) * 1000 < timeout:
|
|
|
|
result = await self.evaluate(expression)
|
|
|
|
if result:
|
|
|
|
return result
|
|
|
|
await asyncio.sleep(polling / 1000)
|
|
|
|
|
|
|
|
raise TimeoutError(f"Timeout waiting for function: {expression}")
|
|
|
|
|
|
|
|
def locator(self, selector: str) -> Locator:
|
|
|
|
"""Create a locator for the given selector"""
|
|
|
|
return Locator(self, selector)
|
|
|
|
|
|
|
|
async def query_selector(self, selector: str) -> Optional[ElementHandle]:
|
|
|
|
"""Query for a single element"""
|
|
|
|
result = await self._playwright_action("query_selector", {"selector": selector})
|
|
|
|
if result.get("success") and result.get("found"):
|
|
|
|
return ElementHandle(self, selector)
|
|
|
|
return None
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
async def query_selector_all(self, selector: str) -> List[ElementHandle]:
|
|
|
|
"""Query for all matching elements"""
|
|
|
|
result = await self._playwright_action("query_selector_all", {"selector": selector})
|
|
|
|
if result.get("success"):
|
|
|
|
count = result.get("count", 0)
|
|
|
|
# For simplicity, we return handles with indexed selectors
|
|
|
|
return [ElementHandle(self, f"{selector}:nth-of-type({i+1})") for i in range(count)]
|
|
|
|
return []
|
|
|
|
|
|
|
|
async def _playwright_action(self, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
"""Execute a Playwright-style action"""
|
|
|
|
result = await self.browser._send_command({
|
|
|
|
"command": "playwright",
|
|
|
|
"action": action,
|
|
|
|
"params": params
|
|
|
|
})
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
# Handle string results from JavaScript execution
|
|
|
|
if isinstance(result, str):
|
|
|
|
try:
|
|
|
|
return json.loads(result)
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
return {"success": False, "error": f"Invalid response: {result}"}
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
return result or {}
|
|
|
|
|
|
|
|
def on(self, event: str, callback: Callable) -> None:
|
|
|
|
"""Register an event listener"""
|
|
|
|
if event not in self._event_listeners:
|
|
|
|
self._event_listeners[event] = []
|
|
|
|
self._event_listeners[event].append(callback)
|
|
|
|
|
|
|
|
def once(self, event: str, callback: Callable) -> None:
|
|
|
|
"""Register a one-time event listener"""
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
self.remove_listener(event, wrapper)
|
|
|
|
return callback(*args, **kwargs)
|
|
|
|
self.on(event, wrapper)
|
|
|
|
|
|
|
|
def remove_listener(self, event: str, callback: Callable) -> None:
|
|
|
|
"""Remove an event listener"""
|
|
|
|
if event in self._event_listeners:
|
|
|
|
self._event_listeners[event] = [
|
|
|
|
cb for cb in self._event_listeners[event] if cb != callback
|
|
|
|
]
|
|
|
|
|
|
|
|
def emit(self, event: str, data: Any) -> None:
|
|
|
|
"""Emit an event to all listeners"""
|
|
|
|
if event in self._event_listeners:
|
|
|
|
for callback in self._event_listeners[event][:]:
|
|
|
|
try:
|
|
|
|
callback(data)
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error in event listener for {event}: {e}")
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
"""Close the page"""
|
|
|
|
if not self._closed:
|
|
|
|
self._closed = True
|
|
|
|
await self.browser._send_command({"command": "close"})
|
|
|
|
|
|
|
|
class BrowserContext:
|
|
|
|
"""Browser context (simplified for single-page support)"""
|
|
|
|
|
|
|
|
def __init__(self, browser: 'Browser'):
|
|
|
|
self.browser = browser
|
|
|
|
self.pages: List[Page] = []
|
|
|
|
|
|
|
|
async def new_page(self) -> Page:
|
|
|
|
"""Create a new page (currently limited to one per context)"""
|
|
|
|
if self.pages:
|
|
|
|
raise BrowserError("Multiple pages not supported in this implementation")
|
|
|
|
|
|
|
|
page = Page(self.browser, self.browser.connection_id)
|
|
|
|
self.pages.append(page)
|
|
|
|
return page
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
"""Close the context and all its pages"""
|
|
|
|
for page in self.pages[:]:
|
|
|
|
await page.close()
|
|
|
|
self.pages.clear()
|
|
|
|
|
|
|
|
class Browser:
|
|
|
|
"""Represents a browser instance with Playwright-compatible API"""
|
|
|
|
|
|
|
|
def __init__(self, ws_url: str = "ws://localhost:8765"):
|
|
|
|
self.ws_url = ws_url
|
|
|
|
self.websocket: Optional[websockets.WebSocketClientProtocol] = None
|
|
|
|
self.connection_id: Optional[str] = None
|
|
|
|
self._response_futures: Dict[str, asyncio.Future] = {}
|
|
|
|
self._event_listeners: Dict[str, List[Callable]] = {}
|
|
|
|
self._receive_task: Optional[asyncio.Task] = None
|
|
|
|
self._closed = False
|
|
|
|
self.contexts: List[BrowserContext] = []
|
|
|
|
|
|
|
|
async def connect(self) -> None:
|
|
|
|
"""Connect to the browser server"""
|
|
|
|
self.websocket = await websockets.connect(self.ws_url)
|
|
|
|
self._receive_task = asyncio.create_task(self._receive_messages())
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
# Wait for connection confirmation
|
|
|
|
timeout = 15
|
|
|
|
start_time = time.time()
|
|
|
|
while not self.connection_id and time.time() - start_time < timeout:
|
|
|
|
await asyncio.sleep(0.1)
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
if not self.connection_id:
|
|
|
|
raise TimeoutError("Failed to establish browser connection")
|
|
|
|
|
|
|
|
async def _receive_messages(self) -> None:
|
|
|
|
"""Receive messages from the WebSocket"""
|
2025-07-17 00:51:02 +02:00
|
|
|
try:
|
|
|
|
async for message in self.websocket:
|
2025-08-11 13:28:18 +02:00
|
|
|
try:
|
|
|
|
data = json.loads(message)
|
|
|
|
msg_type = data.get("type")
|
|
|
|
|
|
|
|
if msg_type == "connected":
|
|
|
|
self.connection_id = data.get("connection_id")
|
|
|
|
logger.info(f"Connected with ID: {self.connection_id}")
|
|
|
|
|
|
|
|
elif msg_type == "response":
|
|
|
|
request_id = data.get("request_id")
|
|
|
|
if request_id in self._response_futures:
|
|
|
|
future = self._response_futures.pop(request_id)
|
|
|
|
if data.get("error"):
|
|
|
|
future.set_exception(BrowserError(data["error"]))
|
|
|
|
else:
|
|
|
|
future.set_result(data.get("result"))
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
elif msg_type == "event":
|
|
|
|
event_type = data.get("event")
|
|
|
|
event_data = data.get("data", {})
|
|
|
|
|
|
|
|
# Emit to browser-level listeners
|
|
|
|
self._emit_event(event_type, event_data)
|
|
|
|
|
|
|
|
# Emit to page-level listeners if applicable
|
|
|
|
for context in self.contexts:
|
|
|
|
for page in context.pages:
|
|
|
|
page.emit(event_type, event_data)
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
logger.error(f"Failed to parse message: {e}")
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error handling message: {e}")
|
|
|
|
|
2025-07-17 00:51:02 +02:00
|
|
|
except websockets.exceptions.ConnectionClosed:
|
2025-08-11 13:28:18 +02:00
|
|
|
logger.info("WebSocket connection closed")
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error in receive loop: {e}")
|
|
|
|
finally:
|
|
|
|
# Clean up pending futures
|
|
|
|
for future in self._response_futures.values():
|
|
|
|
if not future.done():
|
|
|
|
future.set_exception(BrowserError("Connection closed"))
|
|
|
|
self._response_futures.clear()
|
|
|
|
|
|
|
|
async def _send_command(self, command: Dict[str, Any]) -> Any:
|
|
|
|
"""Send a command and wait for response"""
|
|
|
|
if not self.websocket or self._closed:
|
|
|
|
raise BrowserError("WebSocket connection is closed")
|
|
|
|
|
|
|
|
request_id = str(uuid.uuid4())
|
|
|
|
command["request_id"] = request_id
|
2025-07-17 00:51:02 +02:00
|
|
|
|
|
|
|
# Create future for response
|
|
|
|
future = asyncio.Future()
|
2025-08-11 13:28:18 +02:00
|
|
|
self._response_futures[request_id] = future
|
2025-07-17 00:51:02 +02:00
|
|
|
|
|
|
|
try:
|
2025-08-11 13:28:18 +02:00
|
|
|
await self.websocket.send(json.dumps(command))
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
# Wait for response with timeout
|
|
|
|
timeout = command.get("timeout", 30)
|
|
|
|
return await asyncio.wait_for(future, timeout=timeout)
|
|
|
|
|
2025-07-17 00:51:02 +02:00
|
|
|
except asyncio.TimeoutError:
|
2025-08-11 13:28:18 +02:00
|
|
|
self._response_futures.pop(request_id, None)
|
|
|
|
raise TimeoutError(f"Command timeout: {command.get('command')}")
|
|
|
|
except Exception as e:
|
|
|
|
self._response_futures.pop(request_id, None)
|
|
|
|
raise BrowserError(f"Command failed: {e}")
|
|
|
|
|
|
|
|
def _emit_event(self, event: str, data: Any) -> None:
|
|
|
|
"""Emit an event to all browser-level listeners"""
|
|
|
|
if event in self._event_listeners:
|
|
|
|
for callback in self._event_listeners[event][:]:
|
|
|
|
try:
|
|
|
|
callback(data)
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error in browser event listener for {event}: {e}")
|
|
|
|
|
|
|
|
def on(self, event: str, callback: Callable) -> None:
|
|
|
|
"""Register a browser-level event listener"""
|
|
|
|
if event not in self._event_listeners:
|
|
|
|
self._event_listeners[event] = []
|
|
|
|
self._event_listeners[event].append(callback)
|
|
|
|
|
|
|
|
def once(self, event: str, callback: Callable) -> None:
|
|
|
|
"""Register a one-time browser-level event listener"""
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
self.remove_listener(event, wrapper)
|
|
|
|
return callback(*args, **kwargs)
|
|
|
|
self.on(event, wrapper)
|
|
|
|
|
|
|
|
def remove_listener(self, event: str, callback: Callable) -> None:
|
|
|
|
"""Remove a browser-level event listener"""
|
|
|
|
if event in self._event_listeners:
|
|
|
|
self._event_listeners[event] = [
|
|
|
|
cb for cb in self._event_listeners[event] if cb != callback
|
|
|
|
]
|
|
|
|
|
|
|
|
async def new_context(self, **options) -> BrowserContext:
|
|
|
|
"""Create a new browser context"""
|
|
|
|
context = BrowserContext(self)
|
|
|
|
self.contexts.append(context)
|
|
|
|
return context
|
|
|
|
|
|
|
|
async def new_page(self) -> Page:
|
|
|
|
"""Create a new page in the default context"""
|
|
|
|
if not self.contexts:
|
|
|
|
context = await self.new_context()
|
|
|
|
else:
|
|
|
|
context = self.contexts[0]
|
|
|
|
return await context.new_page()
|
|
|
|
|
|
|
|
async def close(self) -> None:
|
|
|
|
"""Close the browser"""
|
|
|
|
if self._closed:
|
|
|
|
return
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
self._closed = True
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
# Close all contexts
|
|
|
|
for context in self.contexts[:]:
|
|
|
|
await context.close()
|
|
|
|
|
|
|
|
# Cancel receive task
|
|
|
|
if self._receive_task:
|
|
|
|
self._receive_task.cancel()
|
|
|
|
try:
|
|
|
|
await self._receive_task
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Close WebSocket
|
2025-07-17 00:51:02 +02:00
|
|
|
if self.websocket:
|
|
|
|
await self.websocket.close()
|
2025-08-11 13:28:18 +02:00
|
|
|
|
|
|
|
logger.info("Browser closed")
|
|
|
|
|
|
|
|
@property
|
|
|
|
def is_connected(self) -> bool:
|
|
|
|
"""Check if browser is connected"""
|
|
|
|
return bool(self.websocket and not self.websocket.closed and self.connection_id)
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
class Playwright:
|
|
|
|
"""Main Playwright-compatible API entry point"""
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
def __init__(self):
|
|
|
|
self.browsers: List[Browser] = []
|
|
|
|
|
|
|
|
class chromium:
|
|
|
|
"""Chromium browser launcher (uses WebKit in this implementation)"""
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
async def launch(**options) -> Browser:
|
|
|
|
"""Launch a browser instance"""
|
|
|
|
ws_url = options.get("ws_endpoint", "ws://localhost:8765")
|
|
|
|
browser = Browser(ws_url)
|
|
|
|
await browser.connect()
|
|
|
|
return browser
|
|
|
|
|
|
|
|
class firefox:
|
|
|
|
"""Firefox browser launcher (uses WebKit in this implementation)"""
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
async def launch(**options) -> Browser:
|
|
|
|
"""Launch a browser instance"""
|
|
|
|
return await Playwright.chromium.launch(**options)
|
|
|
|
|
|
|
|
class webkit:
|
|
|
|
"""WebKit browser launcher"""
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
@staticmethod
|
|
|
|
async def launch(**options) -> Browser:
|
|
|
|
"""Launch a browser instance"""
|
|
|
|
return await Playwright.chromium.launch(**options)
|
|
|
|
|
|
|
|
# Convenience function for async context manager
|
|
|
|
@asynccontextmanager
|
|
|
|
async def browser(**options):
|
|
|
|
"""Async context manager for browser"""
|
|
|
|
playwright = Playwright()
|
|
|
|
browser_instance = await playwright.chromium.launch(**options)
|
|
|
|
try:
|
|
|
|
yield browser_instance
|
2025-07-17 00:51:02 +02:00
|
|
|
finally:
|
2025-08-11 13:28:18 +02:00
|
|
|
await browser_instance.close()
|
|
|
|
|
|
|
|
# Convenience function for creating a browser and page
|
|
|
|
async def launch(**options) -> Browser:
|
|
|
|
"""Launch a browser instance"""
|
|
|
|
playwright = Playwright()
|
|
|
|
return await playwright.chromium.launch(**options)
|
|
|
|
|
|
|
|
# Example usage functions for compatibility
|
|
|
|
async def goto(page: Page, url: str, **options):
|
|
|
|
"""Navigate to URL (convenience function)"""
|
|
|
|
return await page.goto(url, **options)
|
|
|
|
|
|
|
|
async def screenshot(page: Page, path: str = None, **options):
|
|
|
|
"""Take screenshot (convenience function)"""
|
|
|
|
data = await page.screenshot(**options)
|
|
|
|
if path:
|
|
|
|
with open(path, 'wb') as f:
|
|
|
|
f.write(data)
|
|
|
|
return data
|
|
|
|
|
|
|
|
async def evaluate(page: Page, expression: str, arg=None):
|
|
|
|
"""Evaluate JavaScript (convenience function)"""
|
|
|
|
return await page.evaluate(expression, arg)
|
|
|
|
|
|
|
|
# Selector engine helpers
|
|
|
|
def css(selector: str) -> str:
|
|
|
|
"""CSS selector helper"""
|
|
|
|
return selector
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
def xpath(selector: str) -> str:
|
|
|
|
"""XPath selector helper (converted to CSS where possible)"""
|
|
|
|
# Simple XPath to CSS conversion for common cases
|
|
|
|
if selector.startswith("//"):
|
|
|
|
return selector # Return as-is, will need special handling
|
|
|
|
return selector
|
2025-07-17 00:51:02 +02:00
|
|
|
|
2025-08-11 13:28:18 +02:00
|
|
|
def text(text_content: str) -> str:
|
|
|
|
"""Text selector helper"""
|
|
|
|
return f"//*[contains(text(), '{text_content}')]"
|