import asyncio import base64 import os import random import json import logging import hashlib from typing import ( Any, Awaitable, Callable, Dict, Optional, Tuple, Union, cast, List, Literal, ) import warnings from playwright.async_api import Locator from playwright.async_api import Error as PlaywrightError from playwright.async_api import TimeoutError as PlaywrightTimeoutError from playwright.async_api import Download, Page, BrowserContext from .utils.animation_utils import AnimationUtilsPlaywright from .utils.webpage_text_utils import WebpageTextUtilsPlaywright from ..url_status_manager import UrlStatusManager from .types import ( InteractiveRegion, VisualViewport, interactiveregion_from_dict, visualviewport_from_dict, ) warnings.filterwarnings(action="ignore", module="markitdown") logger = logging.getLogger(__name__) # Some of the Code for clicking coordinates and keypresses adapted from https://github.com/openai/openai-cua-sample-app/blob/main/computers/base_playwright.py # Copyright 2025 OpenAI - MIT License CUA_KEY_TO_PLAYWRIGHT_KEY = { "/": "Divide", "\\": "Backslash", "alt": "Alt", "arrowdown": "ArrowDown", "arrowleft": "ArrowLeft", "arrowright": "ArrowRight", "arrowup": "ArrowUp", "backspace": "Backspace", "capslock": "CapsLock", "cmd": "Meta", "ctrl": "Control", "delete": "Delete", "end": "End", "enter": "Enter", "esc": "Escape", "home": "Home", "insert": "Insert", "option": "Alt", "pagedown": "PageDown", "pageup": "PageUp", "shift": "Shift", "space": " ", "super": "Meta", "tab": "Tab", "win": "Meta", } class PlaywrightController: """ A helper class to allow Playwright to interact with web pages to perform actions such as clicking, filling, and scrolling. Args: downloads_folder (str, optional): The folder to save downloads to. If None, downloads are not saved. Default: None animate_actions (bool, optional): Whether to animate the actions (create fake cursor to click). Default: False viewport_width (int, optional): The width of the viewport. Default: 1440 viewport_height (int, optional): The height of the viewport. Default: 1440 _download_handler (callable, None], optional): A function to handle downloads. to_resize_viewport (bool, optional): Whether to resize the viewport. Default: True timeout_load (int | float, optional): Amount of time (in secs) to wait before timeout on actions. Default: 1 sleep_after_action (int | float, optional): Amount of time (in secs) to sleep after performing action. Default: 1 single_tab_mode (bool, optional): If True, forces navigation to happen in the same tab rather than opening new tabs/windows. Default: False url_status_manager (UrlStatusManager, optional): A list of websites to allow or deny. If None, all websites are allowed. url_validation_callback (callable, optional): A callback function to validate URLs. It should return a tuple of (str, bool) where the str is a failure string and bool indicates if the URL is allowed. """ def __init__( self, downloads_folder: str | None = None, animate_actions: bool = False, viewport_width: int = 1440, viewport_height: int = 1440, _download_handler: Optional[Callable[[Download], None]] = None, to_resize_viewport: bool = True, timeout_load: Union[int, float] = 1, sleep_after_action: Union[int, float] = 0.1, single_tab_mode: bool = False, url_status_manager: UrlStatusManager | None = None, url_validation_callback: Optional[ Callable[[str], Awaitable[Tuple[str, bool]]] ] = None, ) -> None: """ Initialize the PlaywrightController. """ assert isinstance(animate_actions, bool) assert isinstance(viewport_width, int) assert isinstance(viewport_height, int) assert viewport_height > 0 assert viewport_width > 0 assert timeout_load > 0 self.animate_actions = animate_actions self.downloads_folder = downloads_folder self.viewport_width = viewport_width self.viewport_height = viewport_height self._download_handler = _download_handler self.to_resize_viewport = to_resize_viewport self._timeout_load = timeout_load self._sleep_after_action = sleep_after_action self.single_tab_mode = single_tab_mode self._url_status_manager = url_status_manager self._url_validation_callback = url_validation_callback self._page_script: str = "" self._markdown_converter: Optional[Any] | None = None # Create animation utils instance self._animation = AnimationUtilsPlaywright() # Use animation utils for cursor position tracking self.last_cursor_position = self._animation.last_cursor_position # Read page_script with open( os.path.join(os.path.abspath(os.path.dirname(__file__)), "page_script.js"), "rt", encoding="utf-8", ) as fh: self._page_script = fh.read() # Initialize WebpageTextUtils self._text_utils = WebpageTextUtilsPlaywright() async def on_new_page(self, page: Page) -> None: """ Handle actions to perform on a new page. Args: page (Page): The Playwright page object. """ assert page is not None awaiting_approval = False tentative_url = page.url tentative_url_approved = True # If the page is not whitelisted, block the site before asking if the user wants to allow it if self._url_status_manager and not self._url_status_manager.is_url_allowed( tentative_url ): await page.route("**/*", lambda route: route.abort("blockedbyclient")) try: # This will raise an exception, but we don't care about it await page.reload() except PlaywrightError: pass await page.unroute("**/*") if self._url_validation_callback is not None: awaiting_approval = True _, tentative_url_approved = await self._url_validation_callback( tentative_url ) # Wait for page load try: await page.wait_for_load_state(timeout=15000) except PlaywrightTimeoutError: logger.warning("Page load timeout, page might not be loaded") # refresh await page.reload() try: await page.wait_for_load_state(timeout=15000) except Exception: # stop page loading await page.evaluate("window.stop()") except Exception: pass if awaiting_approval and tentative_url_approved: # Visit the page if permission has been given await self.visit_page(page, tentative_url) page.on("download", self._download_handler) # type: ignore # check if there is a need to resize the viewport page_viewport_size = page.viewport_size if self.to_resize_viewport and self.viewport_width and self.viewport_height: if ( page_viewport_size is None or page_viewport_size["width"] != self.viewport_width or page_viewport_size["height"] != self.viewport_height ): await page.set_viewport_size( {"width": self.viewport_width, "height": self.viewport_height} ) await page.add_init_script( path=os.path.join( os.path.abspath(os.path.dirname(__file__)), "page_script.js" ) ) async def _ensure_page_ready(self, page: Page) -> None: """ Ensure the page is properly configured before performing any action. Args: page (Page): The Playwright page object. """ assert page is not None # bring the page to front await page.bring_to_front() await self.on_new_page(page) async def get_current_url_title(self, page: Page) -> Tuple[str, str]: """ Get the current URL and title of the page. Args: page (Page): The Playwright page object. Returns: A tuple containing: - str: The current URL of the page. - str: The title of the page. """ try: url = page.url title = await page.title() return url, title except Exception: logger.warning("Error getting current URL and title, returning unknown") return "Unknown", "Unknown" async def get_screenshot(self, page: Page, path: str | None = None) -> bytes: """ Capture a screenshot of the current page. Args: page (Page): The Playwright page object. path (str, optional): The file path to save the screenshot. If None, the screenshot will be returned as bytes. Default: None """ try: screenshot = await page.screenshot(path=path, timeout=7000) return screenshot except Exception: logger.warning( "Screenshot failed, page might not be loaded, stopping page and taking screenshot again" ) # refresh the page await page.reload() await self._ensure_page_ready(page) # try again screenshot = await page.screenshot(path=path, timeout=10000) return screenshot async def sleep(self, page: Page, duration: Union[int, float]) -> None: """ Pause the execution for a specified duration. Args: page (Page): The Playwright page object. duration (int | float): The duration to sleep in seconds. """ await self._ensure_page_ready(page) await page.wait_for_timeout(duration * 1000) async def get_interactive_rects(self, page: Page) -> Dict[str, InteractiveRegion]: """ Retrieve interactive regions from the web page. Args: page (Page): The Playwright page object. Returns: Dict[str, InteractiveRegion]: A dictionary of interactive regions. """ await self._ensure_page_ready(page) # Read the regions from the DOM try: await page.evaluate(self._page_script) except Exception: pass result = cast( Dict[str, Dict[str, Any]], await page.evaluate("WebSurfer.getInteractiveRects();"), ) # Convert the results into appropriate types assert isinstance(result, dict) typed_results: Dict[str, InteractiveRegion] = {} for k in result: assert isinstance(k, str) typed_results[k] = interactiveregion_from_dict(result[k]) return typed_results async def get_visual_viewport(self, page: Page) -> VisualViewport: """ Retrieve the visual viewport of the web page. Args: page (Page): The Playwright page object. Returns: VisualViewport: The visual viewport of the page. """ await self._ensure_page_ready(page) try: await page.evaluate(self._page_script) except Exception: pass return visualviewport_from_dict( await page.evaluate("WebSurfer.getVisualViewport();") ) async def get_focused_rect_id(self, page: Page) -> str: """ Retrieve the ID of the currently focused element. Args: page (Page): The Playwright page object. Returns: str: The ID of the focused element. """ await self._ensure_page_ready(page) try: await page.evaluate(self._page_script) except Exception: pass result = await page.evaluate("WebSurfer.getFocusedElementId();") return str(result) async def get_page_metadata(self, page: Page) -> Dict[str, Any]: """ Retrieve metadata from the web page. Args: page (Page): The Playwright page object. Returns: Dict[str, Any]: A dictionary of page metadata. """ await self._ensure_page_ready(page) try: await page.evaluate(self._page_script) except Exception: pass result = await page.evaluate("WebSurfer.getPageMetadata();") assert isinstance(result, dict) return cast(Dict[str, Any], result) async def go_back(self, page: Page) -> bool: """ Navigate back to the previous page. Args: page (Page): The Playwright page object. Returns: bool: True if navigation was successful, False otherwise. """ await self._ensure_page_ready(page) response = await page.go_back( wait_until="load", timeout=self._timeout_load * 1000 ) if response is None: return False else: return True async def go_forward(self, page: Page) -> bool: """ Navigate forward to the next page. Args: page (Page): The Playwright page object. Returns: bool: True if navigation was successful, False otherwise. """ await self._ensure_page_ready(page) response = await page.go_forward( wait_until="load", timeout=self._timeout_load * 1000 ) if response is None: return False else: return True async def visit_page(self, page: Page, url: str) -> Tuple[bool, bool]: """ Visit a specified URL. Args: page (Page): The Playwright page object. url (str): The URL to visit. Returns: A tuple containing: - bool: Whether to reset prior metadata hash. - bool: Whether to reset the last download. """ await self._ensure_page_ready(page) reset_prior_metadata_hash = False reset_last_download = False download = None download_future: asyncio.Task[Download] | None = None # If downloads are enabled, start listening for a download event before navigation. if self.downloads_folder: try: # Create a timeout for the download listener - use a longer timeout download_future = asyncio.create_task( page.wait_for_event( # type: ignore "download", timeout=self._timeout_load * 5000 ) ) except Exception as e: logger.warning(f"Failed to set up download listener: {e}") download_future = None try: # Attempt normal navigation. await page.goto(url) await page.wait_for_load_state("load", timeout=self._timeout_load * 1000) await asyncio.sleep(1) reset_prior_metadata_hash = True except Exception as e: # If a navigation error occurs and it's likely due to a download, # use the already waiting download event. if self.downloads_folder and "net::ERR_ABORTED" in str(e): if download_future: try: # Wait for download with a reasonable timeout download = await asyncio.wait_for( download_future, timeout=self._timeout_load * 1000 ) except asyncio.TimeoutError: logger.warning( "Download timeout exceeded, continuing without download" ) except Exception as download_error: logger.warning(f"Download error: {download_error}") else: raise finally: # Clean up the download future if it exists and hasn't completed if download_future and not download_future.done(): download_future.cancel() # Process any download that was detected if download: assert self.downloads_folder is not None fname = os.path.join(self.downloads_folder, download.suggested_filename) await download.save_as(fname) message = ( f"
" f"