702 lines
27 KiB
Python
Raw Normal View History

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}')]"