chore: import upstream snapshot with attribution
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (ubuntu-latest) (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use from local wheel (push) Failing after 1s
Test Browser Use CLI Install / uvx browser-use[cli] from PyPI (push) Failing after 1s
package / pip-install-on-macos-latest-py-3.11 (push) Has been skipped
package / pip-install-on-macos-latest-py-3.13 (push) Has been skipped
package / pip-install-on-ubuntu-latest-py-3.11 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.13 (push) Has been skipped
cloud_evals / trigger_cloud_eval_image_build (push) Failing after 1s
docker / build_publish_image (push) Failing after 1s
Test Browser Use CLI Install / browser-use skill sync (push) Failing after 1s
lint / code-style (push) Failing after 0s
lint / type-checker (push) Failing after 1s
package / pip-build (push) Failing after 1s
lint / syntax-errors (push) Failing after 3s
package / pip-install-on-ubuntu-latest-py-3.13 (push) Has been skipped
package / pip-install-on-windows-latest-py-3.11 (push) Has been skipped
test / ${{ matrix.test_filename }} (push) Has been skipped
test / evaluate-tasks (push) Has been skipped
test / setup-chromium (push) Failing after 2s
test / find_tests (push) Failing after 2s
Test Browser Use CLI Install / uv pip install (windows-latest) (push) Has been cancelled
Test Browser Use CLI Install / uv pip install (macos-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Type stubs for lazy imports
|
||||
if TYPE_CHECKING:
|
||||
from .profile import BrowserProfile, ProxySettings
|
||||
from .session import BrowserSession
|
||||
|
||||
|
||||
# Lazy imports mapping for heavy browser components
|
||||
_LAZY_IMPORTS = {
|
||||
'ProxySettings': ('.profile', 'ProxySettings'),
|
||||
'BrowserProfile': ('.profile', 'BrowserProfile'),
|
||||
'BrowserSession': ('.session', 'BrowserSession'),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import mechanism for heavy browser components."""
|
||||
if name in _LAZY_IMPORTS:
|
||||
module_path, attr_name = _LAZY_IMPORTS[name]
|
||||
try:
|
||||
from importlib import import_module
|
||||
|
||||
# Use relative import for current package
|
||||
full_module_path = f'browser_use.browser{module_path}'
|
||||
module = import_module(full_module_path)
|
||||
attr = getattr(module, attr_name)
|
||||
# Cache the imported attribute in the module's globals
|
||||
globals()[name] = attr
|
||||
return attr
|
||||
except ImportError as e:
|
||||
raise ImportError(f'Failed to import {name} from {full_module_path}: {e}') from e
|
||||
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
|
||||
__all__ = [
|
||||
'BrowserSession',
|
||||
'BrowserProfile',
|
||||
'ProxySettings',
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Per-CDP-request timeout wrapper around cdp_use.CDPClient.
|
||||
|
||||
cdp_use's `send_raw()` awaits a future that only resolves when the browser
|
||||
sends a matching response. If the server goes silent mid-session (observed
|
||||
failure mode against remote cloud browsers: WebSocket stays "alive" at the
|
||||
TCP/keepalive layer while the browser container is dead or the proxy has
|
||||
lost its upstream) the future never resolves and the whole agent hangs.
|
||||
|
||||
This module provides a thin subclass that wraps each `send_raw()` in
|
||||
`asyncio.wait_for`. Any CDP method that doesn't get a response within the
|
||||
cap raises `TimeoutError`, which propagates through existing
|
||||
error-handling paths in browser-use instead of hanging indefinitely.
|
||||
|
||||
Configure the cap via:
|
||||
- `BROWSER_USE_CDP_TIMEOUT_S` env var (process-wide default)
|
||||
- `TimeoutWrappedCDPClient(..., cdp_request_timeout_s=...)` constructor arg
|
||||
|
||||
Default (60s) is generous for slow operations like `Page.captureScreenshot`
|
||||
or `Page.printToPDF` on heavy pages, but well below the 180s agent step
|
||||
timeout and the typical outer agent watchdog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from cdp_use import CDPClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CDP_TIMEOUT_FALLBACK_S = 60.0
|
||||
|
||||
|
||||
def _parse_env_cdp_timeout(raw: str | None) -> float:
|
||||
"""Parse BROWSER_USE_CDP_TIMEOUT_S defensively.
|
||||
|
||||
Accepts only finite positive values; everything else falls back to the
|
||||
hardcoded default with a warning. Mirrors the guard on
|
||||
BROWSER_USE_ACTION_TIMEOUT_S in tools/service.py — a bad env value here
|
||||
would otherwise make every CDP call time out immediately (nan) or never
|
||||
(inf / negative / zero).
|
||||
"""
|
||||
if raw is None or raw == '':
|
||||
return _CDP_TIMEOUT_FALLBACK_S
|
||||
try:
|
||||
parsed = float(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
'Invalid BROWSER_USE_CDP_TIMEOUT_S=%r; falling back to %.0fs',
|
||||
raw,
|
||||
_CDP_TIMEOUT_FALLBACK_S,
|
||||
)
|
||||
return _CDP_TIMEOUT_FALLBACK_S
|
||||
if not math.isfinite(parsed) or parsed <= 0:
|
||||
logger.warning(
|
||||
'BROWSER_USE_CDP_TIMEOUT_S=%r is not a finite positive number; falling back to %.0fs',
|
||||
raw,
|
||||
_CDP_TIMEOUT_FALLBACK_S,
|
||||
)
|
||||
return _CDP_TIMEOUT_FALLBACK_S
|
||||
return parsed
|
||||
|
||||
|
||||
DEFAULT_CDP_REQUEST_TIMEOUT_S: float = _parse_env_cdp_timeout(os.getenv('BROWSER_USE_CDP_TIMEOUT_S'))
|
||||
|
||||
|
||||
def _coerce_valid_timeout(value: float | None) -> float:
|
||||
"""Normalize a user-supplied timeout to a finite positive value.
|
||||
|
||||
None / nan / inf / non-positive values all fall back to the env-derived
|
||||
default with a warning. This mirrors _parse_env_cdp_timeout so callers that
|
||||
pass cdp_request_timeout_s directly get the same defensive behaviour as
|
||||
callers that set the env var.
|
||||
"""
|
||||
if value is None:
|
||||
return DEFAULT_CDP_REQUEST_TIMEOUT_S
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
logger.warning(
|
||||
'cdp_request_timeout_s=%r is not a finite positive number; falling back to %.0fs',
|
||||
value,
|
||||
DEFAULT_CDP_REQUEST_TIMEOUT_S,
|
||||
)
|
||||
return DEFAULT_CDP_REQUEST_TIMEOUT_S
|
||||
return float(value)
|
||||
|
||||
|
||||
class TimeoutWrappedCDPClient(CDPClient):
|
||||
"""CDPClient subclass that enforces a per-request timeout on send_raw.
|
||||
|
||||
Any CDP method that doesn't receive a response within `cdp_request_timeout_s`
|
||||
raises `TimeoutError` instead of hanging forever. This turns silent-hang
|
||||
failure modes (cloud proxy alive, browser dead) into fast observable errors.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
cdp_request_timeout_s: float | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._cdp_request_timeout_s: float = _coerce_valid_timeout(cdp_request_timeout_s)
|
||||
|
||||
async def send_raw(
|
||||
self,
|
||||
method: str,
|
||||
params: Any | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
super().send_raw(method=method, params=params, session_id=session_id),
|
||||
timeout=self._cdp_request_timeout_s,
|
||||
)
|
||||
except TimeoutError as e:
|
||||
# Raise a plain TimeoutError so existing `except TimeoutError`
|
||||
# handlers in browser-use / tools treat this uniformly.
|
||||
raise TimeoutError(
|
||||
f'CDP method {method!r} did not respond within {self._cdp_request_timeout_s:.0f}s. '
|
||||
f'The browser may be unresponsive (silent WebSocket — container crashed or proxy lost upstream).'
|
||||
) from e
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _chrome_user_data_dir_for_executable(executable_path: str | None) -> Path | None:
|
||||
if executable_path is None:
|
||||
return None
|
||||
|
||||
system = platform.system()
|
||||
path = Path(executable_path)
|
||||
if system == 'Darwin':
|
||||
app_path = str(path)
|
||||
base = Path.home() / 'Library' / 'Application Support'
|
||||
if 'Chromium.app' in app_path:
|
||||
return base / 'Chromium'
|
||||
if 'Google Chrome Canary.app' in app_path:
|
||||
return base / 'Google' / 'Chrome Canary'
|
||||
if 'Google Chrome.app' in app_path:
|
||||
return base / 'Google' / 'Chrome'
|
||||
if system == 'Linux':
|
||||
name = path.name
|
||||
base = Path.home() / '.config'
|
||||
if name in {'chromium', 'chromium-browser'}:
|
||||
return base / 'chromium'
|
||||
if name in {'google-chrome', 'google-chrome-stable'}:
|
||||
return base / 'google-chrome'
|
||||
if system == 'Windows' and path.name.lower() == 'chrome.exe':
|
||||
return Path(os.path.expandvars(r'%LocalAppData%\Google\Chrome\User Data'))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_chrome_executable() -> str | None:
|
||||
"""Find Chrome/Chromium executable on the system."""
|
||||
system = platform.system()
|
||||
|
||||
if system == 'Darwin':
|
||||
for path in (
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
):
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
elif system == 'Linux':
|
||||
for cmd in ('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'):
|
||||
try:
|
||||
result = subprocess.run(['which', cmd], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif system == 'Windows':
|
||||
for path in (
|
||||
os.path.expandvars(r'%ProgramFiles%\Google\Chrome\Application\chrome.exe'),
|
||||
os.path.expandvars(r'%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe'),
|
||||
os.path.expandvars(r'%LocalAppData%\Google\Chrome\Application\chrome.exe'),
|
||||
):
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_chrome_profile_path(profile: str | None, executable_path: str | None = None) -> str | None:
|
||||
"""Get Chrome user data directory, or return a specific profile directory name."""
|
||||
if profile is not None:
|
||||
return profile
|
||||
|
||||
if browser_user_data_dir := _chrome_user_data_dir_for_executable(executable_path):
|
||||
return str(browser_user_data_dir)
|
||||
|
||||
system = platform.system()
|
||||
if system == 'Darwin':
|
||||
return str(Path.home() / 'Library' / 'Application Support' / 'Google' / 'Chrome')
|
||||
if system == 'Linux':
|
||||
base = Path.home() / '.config'
|
||||
for name in ('google-chrome', 'chromium'):
|
||||
if (base / name).is_dir():
|
||||
return str(base / name)
|
||||
return str(base / 'google-chrome')
|
||||
if system == 'Windows':
|
||||
return os.path.expandvars(r'%LocalAppData%\Google\Chrome\User Data')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def list_chrome_profiles() -> list[dict[str, str]]:
|
||||
"""List available Chrome profiles with their display names."""
|
||||
user_data_dir = get_chrome_profile_path(None, executable_path=find_chrome_executable())
|
||||
if user_data_dir is None:
|
||||
return []
|
||||
|
||||
local_state_path = Path(user_data_dir) / 'Local State'
|
||||
if not local_state_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(local_state_path, encoding='utf-8') as f:
|
||||
local_state = json.load(f)
|
||||
|
||||
if not isinstance(local_state, dict):
|
||||
return []
|
||||
info_cache = local_state.get('profile', {}).get('info_cache', {})
|
||||
if not isinstance(info_cache, dict):
|
||||
return []
|
||||
return sorted(
|
||||
[
|
||||
{
|
||||
'directory': directory,
|
||||
'name': info.get('name', directory),
|
||||
}
|
||||
for directory, info in info_cache.items()
|
||||
if isinstance(info, dict)
|
||||
],
|
||||
key=lambda profile: profile['directory'],
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, OSError, TypeError):
|
||||
return []
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Cloud browser service integration for browser-use.
|
||||
|
||||
This module provides integration with the browser-use cloud browser service.
|
||||
When cloud_browser=True, it automatically creates a cloud browser instance
|
||||
and returns the CDP URL for connection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from browser_use.browser.cloud.views import CloudBrowserAuthError, CloudBrowserError, CloudBrowserResponse, CreateBrowserRequest
|
||||
from browser_use.sync.auth import CloudAuthConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudBrowserClient:
|
||||
"""Client for browser-use cloud browser service."""
|
||||
|
||||
def __init__(self, api_base_url: str = 'https://api.browser-use.com'):
|
||||
self.api_base_url = api_base_url
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
self.current_session_id: str | None = None
|
||||
|
||||
async def create_browser(
|
||||
self, request: CreateBrowserRequest, extra_headers: dict[str, str] | None = None
|
||||
) -> CloudBrowserResponse:
|
||||
"""Create a new cloud browser instance. For full docs refer to https://docs.cloud.browser-use.com/api-reference/v-2-api-current/browsers/create-browser-session-browsers-post
|
||||
|
||||
Args:
|
||||
request: CreateBrowserRequest object containing browser creation parameters
|
||||
|
||||
Returns:
|
||||
CloudBrowserResponse: Contains CDP URL and other browser info
|
||||
"""
|
||||
url = f'{self.api_base_url}/api/v2/browsers'
|
||||
|
||||
# Try to get API key from environment variable first, then auth config
|
||||
api_token = os.getenv('BROWSER_USE_API_KEY')
|
||||
|
||||
if not api_token:
|
||||
# Fallback to auth config file
|
||||
try:
|
||||
auth_config = CloudAuthConfig.load_from_file()
|
||||
api_token = auth_config.api_token
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not api_token:
|
||||
raise CloudBrowserAuthError(
|
||||
'BROWSER_USE_API_KEY is not set. To use cloud browsers, get a key at:\n'
|
||||
'https://cloud.browser-use.com/new-api-key?utm_source=oss&utm_medium=use_cloud'
|
||||
)
|
||||
|
||||
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json', **(extra_headers or {})}
|
||||
|
||||
# Convert request to dictionary and exclude unset fields
|
||||
request_body = request.model_dump(exclude_unset=True)
|
||||
|
||||
try:
|
||||
logger.info('🌤️ Creating cloud browser instance...')
|
||||
|
||||
response = await self.client.post(url, headers=headers, json=request_body)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise CloudBrowserAuthError(
|
||||
'BROWSER_USE_API_KEY is invalid. Get a new key at:\n'
|
||||
'https://cloud.browser-use.com/new-api-key?utm_source=oss&utm_medium=use_cloud'
|
||||
)
|
||||
elif response.status_code == 403:
|
||||
raise CloudBrowserAuthError('Access forbidden. Please check your browser-use cloud subscription status.')
|
||||
elif not response.is_success:
|
||||
error_msg = f'Failed to create cloud browser: HTTP {response.status_code}'
|
||||
try:
|
||||
error_data = response.json()
|
||||
if 'detail' in error_data:
|
||||
error_msg += f' - {error_data["detail"]}'
|
||||
except Exception:
|
||||
pass
|
||||
raise CloudBrowserError(error_msg)
|
||||
|
||||
browser_data = response.json()
|
||||
browser_response = CloudBrowserResponse(**browser_data)
|
||||
|
||||
# Store session ID for cleanup
|
||||
self.current_session_id = browser_response.id
|
||||
|
||||
logger.info(f'🌤️ Cloud browser created successfully: {browser_response.id}')
|
||||
logger.debug(f'🌤️ CDP URL: {browser_response.cdpUrl}')
|
||||
# Cyan color for live URL
|
||||
logger.info(f'\033[36m🔗 Live URL: {browser_response.liveUrl}\033[0m')
|
||||
|
||||
return browser_response
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise CloudBrowserError('Timeout while creating cloud browser. Please try again.')
|
||||
except httpx.ConnectError:
|
||||
raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
|
||||
except Exception as e:
|
||||
if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
|
||||
raise
|
||||
raise CloudBrowserError(f'Unexpected error creating cloud browser: {e}')
|
||||
|
||||
async def stop_browser(
|
||||
self, session_id: str | None = None, extra_headers: dict[str, str] | None = None
|
||||
) -> CloudBrowserResponse:
|
||||
"""Stop a cloud browser session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to stop. If None, uses current session.
|
||||
|
||||
Returns:
|
||||
CloudBrowserResponse: Updated browser info with stopped status
|
||||
|
||||
Raises:
|
||||
CloudBrowserAuthError: If authentication fails
|
||||
CloudBrowserError: If stopping fails
|
||||
"""
|
||||
if session_id is None:
|
||||
session_id = self.current_session_id
|
||||
|
||||
if not session_id:
|
||||
raise CloudBrowserError('No session ID provided and no current session available')
|
||||
|
||||
url = f'{self.api_base_url}/api/v2/browsers/{session_id}'
|
||||
|
||||
# Try to get API key from environment variable first, then auth config
|
||||
api_token = os.getenv('BROWSER_USE_API_KEY')
|
||||
|
||||
if not api_token:
|
||||
# Fallback to auth config file
|
||||
try:
|
||||
auth_config = CloudAuthConfig.load_from_file()
|
||||
api_token = auth_config.api_token
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not api_token:
|
||||
raise CloudBrowserAuthError(
|
||||
'BROWSER_USE_API_KEY is not set. To use cloud browsers, get a key at:\n'
|
||||
'https://cloud.browser-use.com/new-api-key?utm_source=oss&utm_medium=use_cloud'
|
||||
)
|
||||
|
||||
headers = {'X-Browser-Use-API-Key': api_token, 'Content-Type': 'application/json', **(extra_headers or {})}
|
||||
|
||||
request_body = {'action': 'stop'}
|
||||
|
||||
try:
|
||||
logger.info(f'🌤️ Stopping cloud browser session: {session_id}')
|
||||
|
||||
response = await self.client.patch(url, headers=headers, json=request_body)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise CloudBrowserAuthError(
|
||||
'Authentication failed. Please make sure you have set the BROWSER_USE_API_KEY environment variable to authenticate with the cloud service.'
|
||||
)
|
||||
elif response.status_code == 404:
|
||||
# Session already stopped or doesn't exist - treating as error and clearing session
|
||||
logger.debug(f'🌤️ Cloud browser session {session_id} not found (already stopped)')
|
||||
# Clear current session if it was this one
|
||||
if session_id == self.current_session_id:
|
||||
self.current_session_id = None
|
||||
raise CloudBrowserError(f'Cloud browser session {session_id} not found')
|
||||
elif not response.is_success:
|
||||
error_msg = f'Failed to stop cloud browser: HTTP {response.status_code}'
|
||||
try:
|
||||
error_data = response.json()
|
||||
if 'detail' in error_data:
|
||||
error_msg += f' - {error_data["detail"]}'
|
||||
except Exception:
|
||||
pass
|
||||
raise CloudBrowserError(error_msg)
|
||||
|
||||
browser_data = response.json()
|
||||
browser_response = CloudBrowserResponse(**browser_data)
|
||||
|
||||
# Clear current session if it was this one
|
||||
if session_id == self.current_session_id:
|
||||
self.current_session_id = None
|
||||
|
||||
logger.info(f'🌤️ Cloud browser session stopped: {browser_response.id}')
|
||||
logger.debug(f'🌤️ Status: {browser_response.status}')
|
||||
|
||||
return browser_response
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise CloudBrowserError('Timeout while stopping cloud browser. Please try again.')
|
||||
except httpx.ConnectError:
|
||||
raise CloudBrowserError('Failed to connect to cloud browser service. Please check your internet connection.')
|
||||
except Exception as e:
|
||||
if isinstance(e, (CloudBrowserError, CloudBrowserAuthError)):
|
||||
raise
|
||||
raise CloudBrowserError(f'Unexpected error stopping cloud browser: {e}')
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client and cleanup any active sessions.
|
||||
|
||||
Safe to call multiple times — subsequent calls are no-ops.
|
||||
"""
|
||||
# Try to stop current session if active
|
||||
if self.current_session_id:
|
||||
try:
|
||||
await self.stop_browser()
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to stop cloud browser session during cleanup: {e}')
|
||||
|
||||
if not self.client.is_closed:
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,96 @@
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
ProxyCountryCode = (
|
||||
Literal[
|
||||
'us', # United States
|
||||
'uk', # United Kingdom
|
||||
'fr', # France
|
||||
'it', # Italy
|
||||
'jp', # Japan
|
||||
'au', # Australia
|
||||
'de', # Germany
|
||||
'fi', # Finland
|
||||
'ca', # Canada
|
||||
'in', # India
|
||||
]
|
||||
| str
|
||||
)
|
||||
|
||||
# Browser session timeout limits (in minutes)
|
||||
MAX_FREE_USER_SESSION_TIMEOUT = 15 # Free users limited to 15 minutes
|
||||
MAX_PAID_USER_SESSION_TIMEOUT = 240 # Paid users can go up to 4 hours
|
||||
|
||||
|
||||
# Requests
|
||||
class CreateBrowserRequest(BaseModel):
|
||||
"""Request to create a cloud browser instance.
|
||||
|
||||
Args:
|
||||
cloud_profile_id: The ID of the profile to use for the session
|
||||
cloud_proxy_country_code: Country code for proxy location
|
||||
cloud_timeout: The timeout for the session in minutes
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
profile_id: UUID | str | None = Field(
|
||||
default=None,
|
||||
alias='cloud_profile_id',
|
||||
description='The ID of the profile to use for the session. Can be a UUID or a string of UUID.',
|
||||
title='Cloud Profile ID',
|
||||
)
|
||||
|
||||
proxy_country_code: ProxyCountryCode | None = Field(
|
||||
default=None,
|
||||
alias='cloud_proxy_country_code',
|
||||
description='Country code for proxy location.',
|
||||
title='Cloud Proxy Country Code',
|
||||
)
|
||||
|
||||
timeout: int | None = Field(
|
||||
ge=1,
|
||||
le=MAX_PAID_USER_SESSION_TIMEOUT,
|
||||
default=None,
|
||||
alias='cloud_timeout',
|
||||
description=f'The timeout for the session in minutes. Free users are limited to {MAX_FREE_USER_SESSION_TIMEOUT} minutes, paid users can use up to {MAX_PAID_USER_SESSION_TIMEOUT} minutes ({MAX_PAID_USER_SESSION_TIMEOUT // 60} hours).',
|
||||
title='Cloud Timeout',
|
||||
)
|
||||
|
||||
enable_recording: bool = Field(
|
||||
default=False,
|
||||
alias='enableRecording',
|
||||
description='Enable session recording for playback in the cloud dashboard.',
|
||||
title='Enable Recording',
|
||||
)
|
||||
|
||||
|
||||
CloudBrowserParams = CreateBrowserRequest # alias for easier readability
|
||||
|
||||
|
||||
# Responses
|
||||
class CloudBrowserResponse(BaseModel):
|
||||
"""Response from cloud browser API."""
|
||||
|
||||
id: str
|
||||
status: str
|
||||
liveUrl: str = Field(alias='liveUrl')
|
||||
cdpUrl: str = Field(alias='cdpUrl')
|
||||
timeoutAt: str = Field(alias='timeoutAt')
|
||||
startedAt: str = Field(alias='startedAt')
|
||||
finishedAt: str | None = Field(alias='finishedAt', default=None)
|
||||
|
||||
|
||||
# Errors
|
||||
class CloudBrowserError(Exception):
|
||||
"""Exception raised when cloud browser operations fail."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CloudBrowserAuthError(CloudBrowserError):
|
||||
"""Exception raised when cloud browser authentication fails."""
|
||||
|
||||
pass
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,667 @@
|
||||
"""Event definitions for browser communication."""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from bubus import BaseEvent
|
||||
from bubus.models import T_EventResultType
|
||||
from cdp_use.cdp.target import TargetID
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from browser_use.browser.views import BrowserStateSummary
|
||||
from browser_use.dom.views import EnhancedDOMTreeNode
|
||||
|
||||
|
||||
def _get_timeout(env_var: str, default: float) -> float | None:
|
||||
"""
|
||||
Safely parse environment variable timeout values with robust error handling.
|
||||
|
||||
Args:
|
||||
env_var: Environment variable name (e.g. 'TIMEOUT_NavigateToUrlEvent')
|
||||
default: Default timeout value as float (e.g. 15.0)
|
||||
|
||||
Returns:
|
||||
Parsed float value or the default if parsing fails
|
||||
|
||||
Raises:
|
||||
ValueError: Only if both env_var and default are invalid (should not happen with valid defaults)
|
||||
"""
|
||||
# Try environment variable first
|
||||
env_value = os.getenv(env_var)
|
||||
if env_value:
|
||||
try:
|
||||
parsed = float(env_value)
|
||||
if parsed < 0:
|
||||
print(f'Warning: {env_var}={env_value} is negative, using default {default}')
|
||||
return default
|
||||
return parsed
|
||||
except (ValueError, TypeError):
|
||||
print(f'Warning: {env_var}={env_value} is not a valid number, using default {default}')
|
||||
|
||||
# Fall back to default
|
||||
return default
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent/Tools -> BrowserSession Events (High-level browser actions)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ElementSelectedEvent(BaseEvent[T_EventResultType]):
|
||||
"""An element was selected."""
|
||||
|
||||
node: EnhancedDOMTreeNode
|
||||
|
||||
@field_validator('node', mode='before')
|
||||
@classmethod
|
||||
def serialize_node(cls, data: EnhancedDOMTreeNode | None) -> EnhancedDOMTreeNode | None:
|
||||
if data is None:
|
||||
return None
|
||||
return EnhancedDOMTreeNode(
|
||||
node_id=data.node_id,
|
||||
backend_node_id=data.backend_node_id,
|
||||
session_id=data.session_id,
|
||||
frame_id=data.frame_id,
|
||||
target_id=data.target_id,
|
||||
node_type=data.node_type,
|
||||
node_name=data.node_name,
|
||||
node_value=data.node_value,
|
||||
attributes=data.attributes,
|
||||
is_scrollable=data.is_scrollable,
|
||||
is_visible=data.is_visible,
|
||||
absolute_position=data.absolute_position,
|
||||
# override the circular reference fields in EnhancedDOMTreeNode as they cant be serialized and aren't needed by event handlers
|
||||
# only used internally by the DOM service during DOM tree building process, not intended public API use
|
||||
content_document=None,
|
||||
shadow_root_type=None,
|
||||
shadow_roots=[],
|
||||
parent_node=None,
|
||||
children_nodes=[],
|
||||
ax_node=None,
|
||||
snapshot_node=None,
|
||||
)
|
||||
|
||||
|
||||
# TODO: add page handle to events
|
||||
# class PageHandle(share a base with browser.session.CDPSession?):
|
||||
# url: str
|
||||
# target_id: TargetID
|
||||
# @classmethod
|
||||
# def from_target_id(cls, target_id: TargetID) -> Self:
|
||||
# return cls(target_id=target_id)
|
||||
# @classmethod
|
||||
# def from_target_id(cls, target_id: TargetID) -> Self:
|
||||
# return cls(target_id=target_id)
|
||||
# @classmethod
|
||||
# def from_url(cls, url: str) -> Self:
|
||||
# @property
|
||||
# def root_frame_id(self) -> str:
|
||||
# return self.target_id
|
||||
# @property
|
||||
# def session_id(self) -> str:
|
||||
# return browser_session.get_or_create_cdp_session(self.target_id).session_id
|
||||
|
||||
# class PageSelectedEvent(BaseEvent[T_EventResultType]):
|
||||
# """An event like SwitchToTabEvent(page=PageHandle) or CloseTabEvent(page=PageHandle)"""
|
||||
# page: PageHandle
|
||||
|
||||
|
||||
class NavigateToUrlEvent(BaseEvent[None]):
|
||||
"""Navigate to a specific URL."""
|
||||
|
||||
url: str
|
||||
wait_until: Literal['load', 'domcontentloaded', 'networkidle', 'commit'] = 'load'
|
||||
timeout_ms: int | None = None
|
||||
new_tab: bool = Field(
|
||||
default=False, description='Set True to leave the current tab alone and open a new tab in the foreground for the new URL'
|
||||
)
|
||||
# existing_tab: PageHandle | None = None # TODO
|
||||
|
||||
# time limits enforced by bubus, not exposed to LLM:
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_NavigateToUrlEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class ClickElementEvent(ElementSelectedEvent[dict[str, Any] | None]):
|
||||
"""Click an element."""
|
||||
|
||||
node: 'EnhancedDOMTreeNode'
|
||||
button: Literal['left', 'right', 'middle'] = 'left'
|
||||
# click_count: int = 1 # TODO
|
||||
# expect_download: bool = False # moved to downloads_watchdog.py
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ClickElementEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
class ClickCoordinateEvent(BaseEvent[dict]):
|
||||
"""Click at specific coordinates."""
|
||||
|
||||
coordinate_x: int
|
||||
coordinate_y: int
|
||||
button: Literal['left', 'right', 'middle'] = 'left'
|
||||
force: bool = False # If True, skip safety checks (file input, print, select)
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ClickCoordinateEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
class TypeTextEvent(ElementSelectedEvent[dict | None]):
|
||||
"""Type text into an element."""
|
||||
|
||||
node: 'EnhancedDOMTreeNode'
|
||||
text: str
|
||||
clear: bool = True
|
||||
is_sensitive: bool = False # Flag to indicate if text contains sensitive data
|
||||
sensitive_key_name: str | None = None # Name of the sensitive key being typed (e.g., 'username', 'password')
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_TypeTextEvent', 60.0)) # seconds
|
||||
|
||||
|
||||
class ScrollEvent(ElementSelectedEvent[None]):
|
||||
"""Scroll the page or element."""
|
||||
|
||||
direction: Literal['up', 'down', 'left', 'right']
|
||||
amount: int # pixels
|
||||
node: 'EnhancedDOMTreeNode | None' = None # None means scroll page
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ScrollEvent', 8.0)) # seconds
|
||||
|
||||
|
||||
class SwitchTabEvent(BaseEvent[TargetID]):
|
||||
"""Switch to a different tab."""
|
||||
|
||||
target_id: TargetID | None = Field(default=None, description='None means switch to the most recently opened tab')
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_SwitchTabEvent', 10.0)) # seconds
|
||||
|
||||
|
||||
class CloseTabEvent(BaseEvent[None]):
|
||||
"""Close a tab."""
|
||||
|
||||
target_id: TargetID
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_CloseTabEvent', 10.0)) # seconds
|
||||
|
||||
|
||||
class ScreenshotEvent(BaseEvent[str]):
|
||||
"""Request to take a screenshot."""
|
||||
|
||||
full_page: bool = False
|
||||
clip: dict[str, float] | None = None # {x, y, width, height}
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ScreenshotEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
class BrowserStateRequestEvent(BaseEvent[BrowserStateSummary]):
|
||||
"""Request current browser state."""
|
||||
|
||||
include_dom: bool = True
|
||||
include_screenshot: bool = True
|
||||
include_recent_events: bool = False
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserStateRequestEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
# class WaitForConditionEvent(BaseEvent):
|
||||
# """Wait for a condition."""
|
||||
|
||||
# condition: Literal['navigation', 'selector', 'timeout', 'load_state']
|
||||
# timeout: float = 30000
|
||||
# selector: str | None = None
|
||||
# state: Literal['attached', 'detached', 'visible', 'hidden'] | None = None
|
||||
|
||||
|
||||
class GoBackEvent(BaseEvent[None]):
|
||||
"""Navigate back in browser history."""
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_GoBackEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
class GoForwardEvent(BaseEvent[None]):
|
||||
"""Navigate forward in browser history."""
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_GoForwardEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
class RefreshEvent(BaseEvent[None]):
|
||||
"""Refresh/reload the current page."""
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_RefreshEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
class WaitEvent(BaseEvent[None]):
|
||||
"""Wait for a specified number of seconds."""
|
||||
|
||||
seconds: float = 3.0
|
||||
max_seconds: float = 10.0 # Safety cap
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_WaitEvent', 60.0)) # seconds
|
||||
|
||||
|
||||
class SendKeysEvent(BaseEvent[None]):
|
||||
"""Send keyboard keys/shortcuts."""
|
||||
|
||||
keys: str # e.g., "ctrl+a", "cmd+c", "Enter"
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_SendKeysEvent', 60.0)) # seconds
|
||||
|
||||
|
||||
class UploadFileEvent(ElementSelectedEvent[None]):
|
||||
"""Upload a file to an element."""
|
||||
|
||||
node: 'EnhancedDOMTreeNode'
|
||||
file_path: str
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_UploadFileEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class GetDropdownOptionsEvent(ElementSelectedEvent[dict[str, str]]):
|
||||
"""Get all options from any dropdown (native <select>, ARIA menus, or custom dropdowns).
|
||||
|
||||
Returns a dict containing dropdown type, options list, and element metadata."""
|
||||
|
||||
node: 'EnhancedDOMTreeNode'
|
||||
|
||||
event_timeout: float | None = Field(
|
||||
default_factory=lambda: _get_timeout('TIMEOUT_GetDropdownOptionsEvent', 15.0)
|
||||
) # some dropdowns lazy-load the list of options on first interaction, so we need to wait for them to load (e.g. table filter lists can have thousands of options)
|
||||
|
||||
|
||||
class SelectDropdownOptionEvent(ElementSelectedEvent[dict[str, str]]):
|
||||
"""Select a dropdown option by exact text from any dropdown type.
|
||||
|
||||
Returns a dict containing success status and selection details."""
|
||||
|
||||
node: 'EnhancedDOMTreeNode'
|
||||
text: str # The option text to select
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_SelectDropdownOptionEvent', 8.0)) # seconds
|
||||
|
||||
|
||||
class ScrollToTextEvent(BaseEvent[None]):
|
||||
"""Scroll to specific text on the page. Raises exception if text not found."""
|
||||
|
||||
text: str
|
||||
direction: Literal['up', 'down'] = 'down'
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_ScrollToTextEvent', 15.0)) # seconds
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BrowserStartEvent(BaseEvent):
|
||||
"""Start/connect to browser."""
|
||||
|
||||
cdp_url: str | None = None
|
||||
launch_options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserStartEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class BrowserStopEvent(BaseEvent):
|
||||
"""Stop/disconnect from browser."""
|
||||
|
||||
force: bool = False
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserStopEvent', 45.0)) # seconds
|
||||
|
||||
|
||||
class BrowserLaunchResult(BaseModel):
|
||||
"""Result of launching a browser."""
|
||||
|
||||
# TODO: add browser executable_path, pid, version, latency, user_data_dir, X11 $DISPLAY, host IP address, etc.
|
||||
cdp_url: str
|
||||
|
||||
|
||||
class BrowserLaunchEvent(BaseEvent[BrowserLaunchResult]):
|
||||
"""Launch a local browser process."""
|
||||
|
||||
# TODO: add executable_path, proxy settings, preferences, extra launch args, etc.
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserLaunchEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class BrowserKillEvent(BaseEvent):
|
||||
"""Kill local browser subprocess."""
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserKillEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
# TODO: replace all Runtime.evaluate() calls with this event
|
||||
# class ExecuteJavaScriptEvent(BaseEvent):
|
||||
# """Execute JavaScript in page context."""
|
||||
|
||||
# target_id: TargetID
|
||||
# expression: str
|
||||
# await_promise: bool = True
|
||||
|
||||
# event_timeout: float | None = 60.0 # seconds
|
||||
|
||||
# TODO: add this and use the old BrowserProfile.viewport options to set it
|
||||
# class SetViewportEvent(BaseEvent):
|
||||
# """Set the viewport size."""
|
||||
|
||||
# width: int
|
||||
# height: int
|
||||
# device_scale_factor: float = 1.0
|
||||
|
||||
# event_timeout: float | None = 15.0 # seconds
|
||||
|
||||
|
||||
# Moved to storage state
|
||||
# class SetCookiesEvent(BaseEvent):
|
||||
# """Set browser cookies."""
|
||||
|
||||
# cookies: list[dict[str, Any]]
|
||||
|
||||
# event_timeout: float | None = (
|
||||
# 30.0 # only long to support the edge case of restoring a big localStorage / on many origins (has to O(n) visit each origin to restore)
|
||||
# )
|
||||
|
||||
|
||||
# class GetCookiesEvent(BaseEvent):
|
||||
# """Get browser cookies."""
|
||||
|
||||
# urls: list[str] | None = None
|
||||
|
||||
# event_timeout: float | None = 30.0 # seconds
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DOM-related Events
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BrowserConnectedEvent(BaseEvent):
|
||||
"""Browser has started/connected."""
|
||||
|
||||
cdp_url: str
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserConnectedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class BrowserStoppedEvent(BaseEvent):
|
||||
"""Browser has stopped/disconnected."""
|
||||
|
||||
reason: str | None = None
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserStoppedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class TabCreatedEvent(BaseEvent):
|
||||
"""A new tab was created."""
|
||||
|
||||
target_id: TargetID
|
||||
url: str
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_TabCreatedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class TabClosedEvent(BaseEvent):
|
||||
"""A tab was closed."""
|
||||
|
||||
target_id: TargetID
|
||||
|
||||
# TODO:
|
||||
# new_focus_target_id: int | None = None
|
||||
# new_focus_url: str | None = None
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_TabClosedEvent', 3.0)) # seconds
|
||||
|
||||
|
||||
# TODO: emit this when DOM changes significantly, inner frame navigates, form submits, history.pushState(), etc.
|
||||
# class TabUpdatedEvent(BaseEvent):
|
||||
# """Tab information updated (URL changed, etc.)."""
|
||||
|
||||
# target_id: TargetID
|
||||
# url: str
|
||||
|
||||
|
||||
class AgentFocusChangedEvent(BaseEvent):
|
||||
"""Agent focus changed to a different tab."""
|
||||
|
||||
target_id: TargetID
|
||||
url: str
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_AgentFocusChangedEvent', 10.0)) # seconds
|
||||
|
||||
|
||||
class TargetCrashedEvent(BaseEvent):
|
||||
"""A target has crashed."""
|
||||
|
||||
target_id: TargetID
|
||||
error: str
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_TargetCrashedEvent', 10.0)) # seconds
|
||||
|
||||
|
||||
class NavigationStartedEvent(BaseEvent):
|
||||
"""Navigation started."""
|
||||
|
||||
target_id: TargetID
|
||||
url: str
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_NavigationStartedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class NavigationCompleteEvent(BaseEvent):
|
||||
"""Navigation completed."""
|
||||
|
||||
target_id: TargetID
|
||||
url: str
|
||||
status: int | None = None
|
||||
error_message: str | None = None # Error/timeout message if navigation had issues
|
||||
loading_status: str | None = None # Detailed loading status (e.g., network timeout info)
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_NavigationCompleteEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Events
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BrowserErrorEvent(BaseEvent):
|
||||
"""An error occurred in the browser layer."""
|
||||
|
||||
error_type: str
|
||||
message: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserErrorEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class BrowserReconnectingEvent(BaseEvent):
|
||||
"""WebSocket reconnection attempt is starting."""
|
||||
|
||||
cdp_url: str
|
||||
attempt: int
|
||||
max_attempts: int
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserReconnectingEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class BrowserReconnectedEvent(BaseEvent):
|
||||
"""WebSocket reconnection succeeded."""
|
||||
|
||||
cdp_url: str
|
||||
attempt: int
|
||||
downtime_seconds: float
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_BrowserReconnectedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Storage State Events
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SaveStorageStateEvent(BaseEvent):
|
||||
"""Request to save browser storage state."""
|
||||
|
||||
path: str | None = None # Optional path, uses profile default if not provided
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_SaveStorageStateEvent', 45.0)) # seconds
|
||||
|
||||
|
||||
class StorageStateSavedEvent(BaseEvent):
|
||||
"""Notification that storage state was saved."""
|
||||
|
||||
path: str
|
||||
cookies_count: int
|
||||
origins_count: int
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_StorageStateSavedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class LoadStorageStateEvent(BaseEvent):
|
||||
"""Request to load browser storage state."""
|
||||
|
||||
path: str | None = None # Optional path, uses profile default if not provided
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_LoadStorageStateEvent', 45.0)) # seconds
|
||||
|
||||
|
||||
# TODO: refactor this to:
|
||||
# - on_BrowserConnectedEvent() -> dispatch(LoadStorageStateEvent()) -> _copy_storage_state_from_json_to_browser(json_file, new_cdp_session) + return storage_state from handler
|
||||
# - on_BrowserStopEvent() -> dispatch(SaveStorageStateEvent()) -> _copy_storage_state_from_browser_to_json(new_cdp_session, json_file)
|
||||
# and get rid of StorageStateSavedEvent and StorageStateLoadedEvent, have the original events + provide handler return values for any results
|
||||
class StorageStateLoadedEvent(BaseEvent):
|
||||
"""Notification that storage state was loaded."""
|
||||
|
||||
path: str
|
||||
cookies_count: int
|
||||
origins_count: int
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_StorageStateLoadedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# File Download Events
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class DownloadStartedEvent(BaseEvent):
|
||||
"""A file download has started (CDP downloadWillBegin received)."""
|
||||
|
||||
guid: str # CDP download GUID to correlate with FileDownloadedEvent
|
||||
url: str
|
||||
suggested_filename: str
|
||||
auto_download: bool = False # Whether this was triggered automatically
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_DownloadStartedEvent', 5.0)) # seconds
|
||||
|
||||
|
||||
class DownloadProgressEvent(BaseEvent):
|
||||
"""A file download progress update (CDP downloadProgress received)."""
|
||||
|
||||
guid: str # CDP download GUID to correlate with other download events
|
||||
received_bytes: int
|
||||
total_bytes: int # 0 if unknown
|
||||
state: str # 'inProgress', 'completed', or 'canceled'
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_DownloadProgressEvent', 5.0)) # seconds
|
||||
|
||||
|
||||
class FileDownloadedEvent(BaseEvent):
|
||||
"""A file has been downloaded."""
|
||||
|
||||
guid: str | None = None # CDP download GUID to correlate with DownloadStartedEvent
|
||||
url: str
|
||||
path: str
|
||||
file_name: str
|
||||
file_size: int
|
||||
file_type: str | None = None # e.g., 'pdf', 'zip', 'docx', etc.
|
||||
mime_type: str | None = None # e.g., 'application/pdf'
|
||||
from_cache: bool = False
|
||||
auto_download: bool = False # Whether this was an automatic download (e.g., PDF auto-download)
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_FileDownloadedEvent', 30.0)) # seconds
|
||||
|
||||
|
||||
class AboutBlankDVDScreensaverShownEvent(BaseEvent):
|
||||
"""AboutBlankWatchdog has shown DVD screensaver animation on an about:blank tab."""
|
||||
|
||||
target_id: TargetID
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class DialogOpenedEvent(BaseEvent):
|
||||
"""Event dispatched when a JavaScript dialog is opened and handled."""
|
||||
|
||||
dialog_type: str # 'alert', 'confirm', 'prompt', or 'beforeunload'
|
||||
message: str
|
||||
url: str
|
||||
frame_id: str | None = None # Can be None when frameId is not provided by CDP
|
||||
# target_id: TargetID # TODO: add this to avoid needing target_id_from_frame() later
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Captcha Solver Events
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class CaptchaSolverStartedEvent(BaseEvent):
|
||||
"""Captcha solving started by the browser proxy.
|
||||
|
||||
Emitted when the browser proxy detects a CAPTCHA and begins solving it.
|
||||
The agent should wait for a corresponding CaptchaSolverFinishedEvent before proceeding.
|
||||
"""
|
||||
|
||||
target_id: TargetID
|
||||
vendor: str # e.g. 'cloudflare', 'recaptcha', 'hcaptcha', 'datadome', 'perimeterx', 'geetest'
|
||||
url: str
|
||||
started_at: int # Unix millis
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_CaptchaSolverStartedEvent', 5.0))
|
||||
|
||||
|
||||
class CaptchaSolverFinishedEvent(BaseEvent):
|
||||
"""Captcha solving finished by the browser proxy.
|
||||
|
||||
Emitted when the browser proxy finishes solving a CAPTCHA (successfully or not).
|
||||
"""
|
||||
|
||||
target_id: TargetID
|
||||
vendor: str
|
||||
url: str
|
||||
duration_ms: int
|
||||
finished_at: int # Unix millis
|
||||
success: bool # Whether the captcha was solved successfully
|
||||
|
||||
event_timeout: float | None = Field(default_factory=lambda: _get_timeout('TIMEOUT_CaptchaSolverFinishedEvent', 5.0))
|
||||
|
||||
|
||||
# Note: Model rebuilding for forward references is handled in the importing modules
|
||||
# Events with 'EnhancedDOMTreeNode' forward references (ClickElementEvent, TypeTextEvent,
|
||||
# ScrollEvent, UploadFileEvent) need model_rebuild() called after imports are complete
|
||||
|
||||
|
||||
def _check_event_names_dont_overlap():
|
||||
"""
|
||||
check that event names defined in this file are valid and non-overlapping
|
||||
(naiively n^2 so it's pretty slow but ok for now, optimize when >20 events)
|
||||
"""
|
||||
event_names = {
|
||||
name.split('[')[0]
|
||||
for name in globals().keys()
|
||||
if not name.startswith('_')
|
||||
and inspect.isclass(globals()[name])
|
||||
and issubclass(globals()[name], BaseEvent)
|
||||
and name != 'BaseEvent'
|
||||
}
|
||||
for name_a in event_names:
|
||||
assert name_a.endswith('Event'), f'Event with name {name_a} does not end with "Event"'
|
||||
for name_b in event_names:
|
||||
if name_a != name_b: # Skip self-comparison
|
||||
assert name_a not in name_b, (
|
||||
f'Event with name {name_a} is a substring of {name_b}, all events must be completely unique to avoid find-and-replace accidents'
|
||||
)
|
||||
|
||||
|
||||
# overlapping event names are a nightmare to trace and rename later, dont do it!
|
||||
# e.g. prevent ClickEvent and FailedClickEvent are terrible names because one is a substring of the other,
|
||||
# must be ClickEvent and ClickFailedEvent to preserve the usefulnes of codebase grep/sed/awk as refactoring tools.
|
||||
# at import time, we do a quick check that all event names defined above are valid and non-overlapping.
|
||||
# this is hand written in blood by a human! not LLM slop. feel free to optimize but do not remove it without a good reason.
|
||||
_check_event_names_dont_overlap()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
"""Python-based highlighting system for drawing bounding boxes on screenshots.
|
||||
|
||||
This module replaces JavaScript-based highlighting with fast Python image processing
|
||||
to draw bounding boxes around interactive elements directly on screenshots.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from browser_use.dom.views import DOMSelectorMap, EnhancedDOMTreeNode
|
||||
from browser_use.observability import observe_debug
|
||||
from browser_use.utils import time_execution_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Font cache to prevent repeated font loading and reduce memory usage
|
||||
_FONT_CACHE: dict[tuple[str, int], ImageFont.FreeTypeFont | None] = {}
|
||||
|
||||
# Cross-platform font paths
|
||||
_FONT_PATHS = [
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', # Linux (Debian/Ubuntu)
|
||||
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf', # Linux (Arch/Fedora)
|
||||
'/System/Library/Fonts/Arial.ttf', # macOS
|
||||
'C:\\Windows\\Fonts\\arial.ttf', # Windows
|
||||
'arial.ttf', # Windows (system path)
|
||||
'Arial Bold.ttf', # macOS alternative
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', # Linux alternative
|
||||
]
|
||||
|
||||
|
||||
def get_cross_platform_font(font_size: int) -> ImageFont.FreeTypeFont | None:
|
||||
"""Get a cross-platform compatible font with caching to prevent memory leaks.
|
||||
|
||||
Args:
|
||||
font_size: Size of the font to load
|
||||
|
||||
Returns:
|
||||
ImageFont object or None if no system fonts are available
|
||||
"""
|
||||
# Use cache key based on font size
|
||||
cache_key = ('system_font', font_size)
|
||||
|
||||
# Return cached font if available
|
||||
if cache_key in _FONT_CACHE:
|
||||
return _FONT_CACHE[cache_key]
|
||||
|
||||
# Try to load a system font
|
||||
font = None
|
||||
for font_path in _FONT_PATHS:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Cache the result (even if None) to avoid repeated attempts
|
||||
_FONT_CACHE[cache_key] = font
|
||||
return font
|
||||
|
||||
|
||||
def cleanup_font_cache() -> None:
|
||||
"""Clean up the font cache to prevent memory leaks in long-running applications."""
|
||||
global _FONT_CACHE
|
||||
_FONT_CACHE.clear()
|
||||
|
||||
|
||||
# Color scheme for different element types
|
||||
ELEMENT_COLORS = {
|
||||
'button': '#FF6B6B', # Red for buttons
|
||||
'input': '#4ECDC4', # Teal for inputs
|
||||
'select': '#45B7D1', # Blue for dropdowns
|
||||
'a': '#96CEB4', # Green for links
|
||||
'textarea': '#FF8C42', # Orange for text areas (was yellow, now more visible)
|
||||
'default': '#DDA0DD', # Light purple for other interactive elements
|
||||
}
|
||||
|
||||
# Element type mappings
|
||||
ELEMENT_TYPE_MAP = {
|
||||
'button': 'button',
|
||||
'input': 'input',
|
||||
'select': 'select',
|
||||
'a': 'a',
|
||||
'textarea': 'textarea',
|
||||
}
|
||||
|
||||
|
||||
def get_element_color(tag_name: str, element_type: str | None = None) -> str:
|
||||
"""Get color for element based on tag name and type."""
|
||||
# Check input type first
|
||||
if tag_name == 'input' and element_type:
|
||||
if element_type in ['button', 'submit']:
|
||||
return ELEMENT_COLORS['button']
|
||||
|
||||
# Use tag-based color
|
||||
return ELEMENT_COLORS.get(tag_name.lower(), ELEMENT_COLORS['default'])
|
||||
|
||||
|
||||
def should_show_index_overlay(backend_node_id: int | None) -> bool:
|
||||
"""Determine if index overlay should be shown."""
|
||||
return backend_node_id is not None
|
||||
|
||||
|
||||
def draw_enhanced_bounding_box_with_text(
|
||||
draw, # ImageDraw.Draw - avoiding type annotation due to PIL typing issues
|
||||
bbox: tuple[int, int, int, int],
|
||||
color: str,
|
||||
text: str | None = None,
|
||||
font: ImageFont.FreeTypeFont | None = None,
|
||||
element_type: str = 'div',
|
||||
image_size: tuple[int, int] = (2000, 1500),
|
||||
device_pixel_ratio: float = 1.0,
|
||||
) -> None:
|
||||
"""Draw an enhanced bounding box with much bigger index containers and dashed borders."""
|
||||
x1, y1, x2, y2 = bbox
|
||||
|
||||
# Draw dashed bounding box with pattern: 1 line, 2 spaces, 1 line, 2 spaces...
|
||||
dash_length = 4
|
||||
gap_length = 8
|
||||
line_width = 2
|
||||
|
||||
# Helper function to draw dashed line
|
||||
def draw_dashed_line(start_x, start_y, end_x, end_y):
|
||||
if start_x == end_x: # Vertical line
|
||||
y = start_y
|
||||
while y < end_y:
|
||||
dash_end = min(y + dash_length, end_y)
|
||||
draw.line([(start_x, y), (start_x, dash_end)], fill=color, width=line_width)
|
||||
y += dash_length + gap_length
|
||||
else: # Horizontal line
|
||||
x = start_x
|
||||
while x < end_x:
|
||||
dash_end = min(x + dash_length, end_x)
|
||||
draw.line([(x, start_y), (dash_end, start_y)], fill=color, width=line_width)
|
||||
x += dash_length + gap_length
|
||||
|
||||
# Draw dashed rectangle
|
||||
draw_dashed_line(x1, y1, x2, y1) # Top
|
||||
draw_dashed_line(x2, y1, x2, y2) # Right
|
||||
draw_dashed_line(x2, y2, x1, y2) # Bottom
|
||||
draw_dashed_line(x1, y2, x1, y1) # Left
|
||||
|
||||
# Draw much bigger index overlay if we have index text
|
||||
if text:
|
||||
try:
|
||||
# Scale font size for appropriate sizing across different resolutions
|
||||
img_width, img_height = image_size
|
||||
|
||||
css_width = img_width # / device_pixel_ratio
|
||||
# Much smaller scaling - 1% of CSS viewport width, max 16px to prevent huge highlights
|
||||
base_font_size = max(10, min(20, int(css_width * 0.01)))
|
||||
# Use shared font loading function with caching
|
||||
big_font = get_cross_platform_font(base_font_size)
|
||||
if big_font is None:
|
||||
big_font = font # Fallback to original font if no system fonts found
|
||||
|
||||
# Get text size with bigger font
|
||||
if big_font:
|
||||
bbox_text = draw.textbbox((0, 0), text, font=big_font)
|
||||
text_width = bbox_text[2] - bbox_text[0]
|
||||
text_height = bbox_text[3] - bbox_text[1]
|
||||
else:
|
||||
# Fallback for default font
|
||||
bbox_text = draw.textbbox((0, 0), text)
|
||||
text_width = bbox_text[2] - bbox_text[0]
|
||||
text_height = bbox_text[3] - bbox_text[1]
|
||||
|
||||
# Scale padding appropriately for different resolutions
|
||||
padding = max(4, min(10, int(css_width * 0.005))) # 0.3% of CSS width, max 4px
|
||||
element_width = x2 - x1
|
||||
element_height = y2 - y1
|
||||
|
||||
# Container dimensions
|
||||
container_width = text_width + padding * 2
|
||||
container_height = text_height + padding * 2
|
||||
|
||||
# Position in top center - for small elements, place further up to avoid blocking content
|
||||
# Center horizontally within the element
|
||||
bg_x1 = x1 + (element_width - container_width) // 2
|
||||
|
||||
# Simple rule: if element is small, place index further up to avoid blocking icons
|
||||
if element_width < 60 or element_height < 30:
|
||||
# Small element: place well above to avoid blocking content
|
||||
bg_y1 = max(0, y1 - container_height - 5)
|
||||
else:
|
||||
# Regular element: place inside with small offset
|
||||
bg_y1 = y1 + 2
|
||||
|
||||
bg_x2 = bg_x1 + container_width
|
||||
bg_y2 = bg_y1 + container_height
|
||||
|
||||
# Center the number within the index box with proper baseline handling
|
||||
text_x = bg_x1 + (container_width - text_width) // 2
|
||||
# Add extra vertical space to prevent clipping
|
||||
text_y = bg_y1 + (container_height - text_height) // 2 - bbox_text[1] # Subtract top offset
|
||||
|
||||
# Ensure container stays within image bounds
|
||||
img_width, img_height = image_size
|
||||
if bg_x1 < 0:
|
||||
offset = -bg_x1
|
||||
bg_x1 += offset
|
||||
bg_x2 += offset
|
||||
text_x += offset
|
||||
if bg_y1 < 0:
|
||||
offset = -bg_y1
|
||||
bg_y1 += offset
|
||||
bg_y2 += offset
|
||||
text_y += offset
|
||||
if bg_x2 > img_width:
|
||||
offset = bg_x2 - img_width
|
||||
bg_x1 -= offset
|
||||
bg_x2 -= offset
|
||||
text_x -= offset
|
||||
if bg_y2 > img_height:
|
||||
offset = bg_y2 - img_height
|
||||
bg_y1 -= offset
|
||||
bg_y2 -= offset
|
||||
text_y -= offset
|
||||
|
||||
# Draw bigger background rectangle with thicker border
|
||||
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=color, outline='white', width=2)
|
||||
|
||||
# Draw white text centered in the index box
|
||||
draw.text((text_x, text_y), text, fill='white', font=big_font or font)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to draw enhanced text overlay: {e}')
|
||||
|
||||
|
||||
def draw_bounding_box_with_text(
|
||||
draw, # ImageDraw.Draw - avoiding type annotation due to PIL typing issues
|
||||
bbox: tuple[int, int, int, int],
|
||||
color: str,
|
||||
text: str | None = None,
|
||||
font: ImageFont.FreeTypeFont | None = None,
|
||||
) -> None:
|
||||
"""Draw a bounding box with optional text overlay."""
|
||||
x1, y1, x2, y2 = bbox
|
||||
|
||||
# Draw dashed bounding box
|
||||
dash_length = 2
|
||||
gap_length = 6
|
||||
|
||||
# Top edge
|
||||
x = x1
|
||||
while x < x2:
|
||||
end_x = min(x + dash_length, x2)
|
||||
draw.line([(x, y1), (end_x, y1)], fill=color, width=2)
|
||||
draw.line([(x, y1 + 1), (end_x, y1 + 1)], fill=color, width=2)
|
||||
x += dash_length + gap_length
|
||||
|
||||
# Bottom edge
|
||||
x = x1
|
||||
while x < x2:
|
||||
end_x = min(x + dash_length, x2)
|
||||
draw.line([(x, y2), (end_x, y2)], fill=color, width=2)
|
||||
draw.line([(x, y2 - 1), (end_x, y2 - 1)], fill=color, width=2)
|
||||
x += dash_length + gap_length
|
||||
|
||||
# Left edge
|
||||
y = y1
|
||||
while y < y2:
|
||||
end_y = min(y + dash_length, y2)
|
||||
draw.line([(x1, y), (x1, end_y)], fill=color, width=2)
|
||||
draw.line([(x1 + 1, y), (x1 + 1, end_y)], fill=color, width=2)
|
||||
y += dash_length + gap_length
|
||||
|
||||
# Right edge
|
||||
y = y1
|
||||
while y < y2:
|
||||
end_y = min(y + dash_length, y2)
|
||||
draw.line([(x2, y), (x2, end_y)], fill=color, width=2)
|
||||
draw.line([(x2 - 1, y), (x2 - 1, end_y)], fill=color, width=2)
|
||||
y += dash_length + gap_length
|
||||
|
||||
# Draw index overlay if we have index text
|
||||
if text:
|
||||
try:
|
||||
# Get text size
|
||||
if font:
|
||||
bbox_text = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox_text[2] - bbox_text[0]
|
||||
text_height = bbox_text[3] - bbox_text[1]
|
||||
else:
|
||||
# Fallback for default font
|
||||
bbox_text = draw.textbbox((0, 0), text)
|
||||
text_width = bbox_text[2] - bbox_text[0]
|
||||
text_height = bbox_text[3] - bbox_text[1]
|
||||
|
||||
# Smart positioning based on element size
|
||||
padding = 5
|
||||
element_width = x2 - x1
|
||||
element_height = y2 - y1
|
||||
element_area = element_width * element_height
|
||||
index_box_area = (text_width + padding * 2) * (text_height + padding * 2)
|
||||
|
||||
# Calculate size ratio to determine positioning strategy
|
||||
size_ratio = element_area / max(index_box_area, 1)
|
||||
|
||||
if size_ratio < 4:
|
||||
# Very small elements: place outside in bottom-right corner
|
||||
text_x = x2 + padding
|
||||
text_y = y2 - text_height
|
||||
# Ensure it doesn't go off screen
|
||||
text_x = min(text_x, 1200 - text_width - padding)
|
||||
text_y = max(text_y, 0)
|
||||
elif size_ratio < 16:
|
||||
# Medium elements: place in bottom-right corner inside
|
||||
text_x = x2 - text_width - padding
|
||||
text_y = y2 - text_height - padding
|
||||
else:
|
||||
# Large elements: place in center
|
||||
text_x = x1 + (element_width - text_width) // 2
|
||||
text_y = y1 + (element_height - text_height) // 2
|
||||
|
||||
# Ensure text stays within bounds
|
||||
text_x = max(0, min(text_x, 1200 - text_width))
|
||||
text_y = max(0, min(text_y, 800 - text_height))
|
||||
|
||||
# Draw background rectangle for maximum contrast
|
||||
bg_x1 = text_x - padding
|
||||
bg_y1 = text_y - padding
|
||||
bg_x2 = text_x + text_width + padding
|
||||
bg_y2 = text_y + text_height + padding
|
||||
|
||||
# Use white background with thick black border for maximum visibility
|
||||
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill='white', outline='black', width=2)
|
||||
|
||||
# Draw bold dark text on light background for best contrast
|
||||
draw.text((text_x, text_y), text, fill='black', font=font)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to draw text overlay: {e}')
|
||||
|
||||
|
||||
def process_element_highlight(
|
||||
element_id: int,
|
||||
element: EnhancedDOMTreeNode,
|
||||
draw,
|
||||
device_pixel_ratio: float,
|
||||
font,
|
||||
filter_highlight_ids: bool,
|
||||
image_size: tuple[int, int],
|
||||
) -> None:
|
||||
"""Process a single element for highlighting."""
|
||||
try:
|
||||
# Use absolute_position coordinates directly
|
||||
if not element.absolute_position:
|
||||
return
|
||||
|
||||
bounds = element.absolute_position
|
||||
|
||||
# Scale coordinates from CSS pixels to device pixels for screenshot
|
||||
# The screenshot is captured at device pixel resolution, but coordinates are in CSS pixels
|
||||
x1 = int(bounds.x * device_pixel_ratio)
|
||||
y1 = int(bounds.y * device_pixel_ratio)
|
||||
x2 = int((bounds.x + bounds.width) * device_pixel_ratio)
|
||||
y2 = int((bounds.y + bounds.height) * device_pixel_ratio)
|
||||
|
||||
# Ensure coordinates are within image bounds
|
||||
img_width, img_height = image_size
|
||||
x1 = max(0, min(x1, img_width))
|
||||
y1 = max(0, min(y1, img_height))
|
||||
x2 = max(x1, min(x2, img_width))
|
||||
y2 = max(y1, min(y2, img_height))
|
||||
|
||||
# Skip if bounding box is too small or invalid
|
||||
if x2 - x1 < 2 or y2 - y1 < 2:
|
||||
return
|
||||
|
||||
# Get element color based on type
|
||||
tag_name = element.tag_name if hasattr(element, 'tag_name') else 'div'
|
||||
element_type = None
|
||||
if hasattr(element, 'attributes') and element.attributes:
|
||||
element_type = element.attributes.get('type')
|
||||
|
||||
color = get_element_color(tag_name, element_type)
|
||||
|
||||
# Get element index for overlay and apply filtering
|
||||
backend_node_id = getattr(element, 'backend_node_id', None)
|
||||
index_text = None
|
||||
|
||||
if backend_node_id is not None:
|
||||
if filter_highlight_ids:
|
||||
# Use the meaningful text that matches what the LLM sees
|
||||
meaningful_text = element.get_meaningful_text_for_llm()
|
||||
# Show ID only if meaningful text is less than 5 characters
|
||||
if len(meaningful_text) < 3:
|
||||
index_text = str(backend_node_id)
|
||||
else:
|
||||
# Always show ID when filter is disabled
|
||||
index_text = str(backend_node_id)
|
||||
|
||||
# Draw enhanced bounding box with bigger index
|
||||
draw_enhanced_bounding_box_with_text(
|
||||
draw, (x1, y1, x2, y2), color, index_text, font, tag_name, image_size, device_pixel_ratio
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to draw highlight for element {element_id}: {e}')
|
||||
|
||||
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='create_highlighted_screenshot')
|
||||
@time_execution_async('create_highlighted_screenshot')
|
||||
async def create_highlighted_screenshot(
|
||||
screenshot_b64: str,
|
||||
selector_map: DOMSelectorMap,
|
||||
device_pixel_ratio: float = 1.0,
|
||||
viewport_offset_x: int = 0,
|
||||
viewport_offset_y: int = 0,
|
||||
filter_highlight_ids: bool = True,
|
||||
) -> str:
|
||||
"""Create a highlighted screenshot with bounding boxes around interactive elements.
|
||||
|
||||
Args:
|
||||
screenshot_b64: Base64 encoded screenshot
|
||||
selector_map: Map of interactive elements with their positions
|
||||
device_pixel_ratio: Device pixel ratio for scaling coordinates
|
||||
viewport_offset_x: X offset for viewport positioning
|
||||
viewport_offset_y: Y offset for viewport positioning
|
||||
|
||||
Returns:
|
||||
Base64 encoded highlighted screenshot
|
||||
"""
|
||||
try:
|
||||
# Decode screenshot
|
||||
screenshot_data = base64.b64decode(screenshot_b64)
|
||||
image = Image.open(io.BytesIO(screenshot_data)).convert('RGBA')
|
||||
|
||||
# Create drawing context
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Load font using shared function with caching
|
||||
font = get_cross_platform_font(12)
|
||||
# If no system fonts found, font remains None and will use default font
|
||||
|
||||
# Process elements sequentially to avoid ImageDraw thread safety issues
|
||||
# PIL ImageDraw is not thread-safe, so we process elements one by one
|
||||
for element_id, element in selector_map.items():
|
||||
process_element_highlight(element_id, element, draw, device_pixel_ratio, font, filter_highlight_ids, image.size)
|
||||
|
||||
# Convert back to base64
|
||||
output_buffer = io.BytesIO()
|
||||
try:
|
||||
image.save(output_buffer, format='PNG')
|
||||
output_buffer.seek(0)
|
||||
highlighted_b64 = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
logger.debug(f'Successfully created highlighted screenshot with {len(selector_map)} elements')
|
||||
return highlighted_b64
|
||||
finally:
|
||||
# Explicit cleanup to prevent memory leaks
|
||||
output_buffer.close()
|
||||
if 'image' in locals():
|
||||
image.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to create highlighted screenshot: {e}')
|
||||
# Clean up on error as well
|
||||
if 'image' in locals():
|
||||
image.close()
|
||||
# Return original screenshot on error
|
||||
return screenshot_b64
|
||||
|
||||
|
||||
async def get_viewport_info_from_cdp(cdp_session) -> tuple[float, int, int]:
|
||||
"""Get viewport information from CDP session.
|
||||
|
||||
Returns:
|
||||
Tuple of (device_pixel_ratio, scroll_x, scroll_y)
|
||||
"""
|
||||
try:
|
||||
# Get layout metrics which includes viewport info and device pixel ratio
|
||||
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
|
||||
|
||||
# Extract viewport information
|
||||
visual_viewport = metrics.get('visualViewport', {})
|
||||
css_visual_viewport = metrics.get('cssVisualViewport', {})
|
||||
css_layout_viewport = metrics.get('cssLayoutViewport', {})
|
||||
|
||||
# Calculate device pixel ratio
|
||||
css_width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1280.0))
|
||||
device_width = visual_viewport.get('clientWidth', css_width)
|
||||
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
|
||||
|
||||
# Get scroll position in CSS pixels
|
||||
scroll_x = int(css_visual_viewport.get('pageX', 0))
|
||||
scroll_y = int(css_visual_viewport.get('pageY', 0))
|
||||
|
||||
return float(device_pixel_ratio), scroll_x, scroll_y
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to get viewport info from CDP: {e}')
|
||||
return 1.0, 0, 0
|
||||
|
||||
|
||||
@time_execution_async('create_highlighted_screenshot_async')
|
||||
async def create_highlighted_screenshot_async(
|
||||
screenshot_b64: str, selector_map: DOMSelectorMap, cdp_session=None, filter_highlight_ids: bool = True
|
||||
) -> str:
|
||||
"""Async wrapper for creating highlighted screenshots.
|
||||
|
||||
Args:
|
||||
screenshot_b64: Base64 encoded screenshot
|
||||
selector_map: Map of interactive elements
|
||||
cdp_session: CDP session for getting viewport info
|
||||
filter_highlight_ids: Whether to filter element IDs based on meaningful text
|
||||
|
||||
Returns:
|
||||
Base64 encoded highlighted screenshot
|
||||
"""
|
||||
# Get viewport information if CDP session is available
|
||||
device_pixel_ratio = 1.0
|
||||
viewport_offset_x = 0
|
||||
viewport_offset_y = 0
|
||||
|
||||
if cdp_session:
|
||||
try:
|
||||
device_pixel_ratio, viewport_offset_x, viewport_offset_y = await get_viewport_info_from_cdp(cdp_session)
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to get viewport info from CDP: {e}')
|
||||
|
||||
# Create highlighted screenshot with async processing
|
||||
final_screenshot = await create_highlighted_screenshot(
|
||||
screenshot_b64, selector_map, device_pixel_ratio, viewport_offset_x, viewport_offset_y, filter_highlight_ids
|
||||
)
|
||||
|
||||
filename = os.getenv('BROWSER_USE_SCREENSHOT_FILE')
|
||||
if filename:
|
||||
|
||||
def _write_screenshot():
|
||||
try:
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(base64.b64decode(final_screenshot))
|
||||
logger.debug('Saved screenshot to ' + str(filename))
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to save screenshot to {filename}: {e}')
|
||||
|
||||
await asyncio.to_thread(_write_screenshot)
|
||||
return final_screenshot
|
||||
|
||||
|
||||
# Export the cleanup function for external use in long-running applications
|
||||
__all__ = ['create_highlighted_screenshot', 'create_highlighted_screenshot_async', 'cleanup_font_cache']
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,918 @@
|
||||
"""Event-driven CDP session management.
|
||||
|
||||
Manages CDP sessions by listening to Target.attachedToTarget and Target.detachedFromTarget
|
||||
events, ensuring the session pool always reflects the current browser state.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cdp_use.cdp.target import AttachedToTargetEvent, DetachedFromTargetEvent, SessionID, TargetID
|
||||
|
||||
from browser_use.utils import create_task_with_error_handling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_use.browser.session import BrowserSession, CDPSession, Target
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Event-driven CDP session manager.
|
||||
|
||||
Automatically synchronizes the CDP session pool with browser state via CDP events.
|
||||
|
||||
Key features:
|
||||
- Sessions added/removed automatically via Target attach/detach events
|
||||
- Multiple sessions can attach to the same target
|
||||
- Targets only removed when ALL sessions detach
|
||||
- No stale sessions - pool always reflects browser reality
|
||||
|
||||
SessionManager is the SINGLE SOURCE OF TRUTH for all targets and sessions.
|
||||
"""
|
||||
|
||||
def __init__(self, browser_session: 'BrowserSession'):
|
||||
self.browser_session = browser_session
|
||||
self.logger = browser_session.logger
|
||||
|
||||
# All targets (entities: pages, iframes, workers)
|
||||
self._targets: dict[TargetID, 'Target'] = {}
|
||||
|
||||
# All sessions (communication channels)
|
||||
self._sessions: dict[SessionID, 'CDPSession'] = {}
|
||||
|
||||
# Mapping: target -> sessions attached to it
|
||||
self._target_sessions: dict[TargetID, set[SessionID]] = {}
|
||||
|
||||
# Reverse mapping: session -> target it belongs to
|
||||
self._session_to_target: dict[SessionID, TargetID] = {}
|
||||
|
||||
# Page lifecycle events per target, fed by ONE global Page.lifecycleEvent handler
|
||||
# registered in start_monitoring(). cdp-use's event registry is single-slot per
|
||||
# CDP method, so per-session handler registrations would replace each other and
|
||||
# leave every tab but the most recently attached one without lifecycle events.
|
||||
self._lifecycle_events: dict[TargetID, deque[dict[str, Any]]] = {}
|
||||
|
||||
self._lock = asyncio.Lock()
|
||||
self._recovery_lock = asyncio.Lock()
|
||||
|
||||
# Focus recovery coordination - event-driven instead of polling
|
||||
self._recovery_in_progress: bool = False
|
||||
self._recovery_complete_event: asyncio.Event | None = None
|
||||
self._recovery_task: asyncio.Task | None = None
|
||||
|
||||
async def start_monitoring(self) -> None:
|
||||
"""Start monitoring Target attach/detach events.
|
||||
|
||||
Registers CDP event handlers to keep the session pool synchronized with browser state.
|
||||
Also discovers and initializes all existing targets on startup.
|
||||
"""
|
||||
if not self.browser_session._cdp_client_root:
|
||||
raise RuntimeError('CDP client not initialized')
|
||||
|
||||
# Capture cdp_client_root in closure to avoid type errors
|
||||
cdp_client = self.browser_session._cdp_client_root
|
||||
|
||||
# Enable target discovery to receive targetInfoChanged events automatically
|
||||
# This eliminates the need for getTargetInfo() polling calls
|
||||
await cdp_client.send.Target.setDiscoverTargets(
|
||||
params={'discover': True, 'filter': [{'type': 'page'}, {'type': 'iframe'}]}
|
||||
)
|
||||
|
||||
# Register synchronous event handlers (CDP requirement)
|
||||
def on_attached(event: AttachedToTargetEvent, session_id: SessionID | None = None):
|
||||
# _handle_target_attached() handles:
|
||||
# - setAutoAttach for children
|
||||
# - Create CDPSession
|
||||
# - Enable monitoring (for pages/tabs)
|
||||
# - Add to pool
|
||||
create_task_with_error_handling(
|
||||
self._handle_target_attached(event),
|
||||
name='handle_target_attached',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
|
||||
def on_detached(event: DetachedFromTargetEvent, session_id: SessionID | None = None):
|
||||
create_task_with_error_handling(
|
||||
self._handle_target_detached(event),
|
||||
name='handle_target_detached',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
|
||||
def on_target_info_changed(event, session_id: SessionID | None = None):
|
||||
# Update session info from targetInfoChanged events (no polling needed!)
|
||||
create_task_with_error_handling(
|
||||
self._handle_target_info_changed(event),
|
||||
name='handle_target_info_changed',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
|
||||
def on_lifecycle_event(event, session_id: SessionID | None = None):
|
||||
# ONE global handler for all targets: route by session_id -> target_id.
|
||||
# Registering per-session closures instead would clobber each other in
|
||||
# cdp-use's single-slot registry (one handler per CDP method).
|
||||
if not session_id:
|
||||
return
|
||||
target_id = self.get_target_id_from_session_id(session_id)
|
||||
if not target_id:
|
||||
return
|
||||
self.get_lifecycle_events(target_id).append(
|
||||
{
|
||||
'name': event.get('name', 'unknown'),
|
||||
'loaderId': event.get('loaderId'),
|
||||
'timestamp': asyncio.get_event_loop().time(),
|
||||
}
|
||||
)
|
||||
|
||||
cdp_client.register.Target.attachedToTarget(on_attached)
|
||||
cdp_client.register.Target.detachedFromTarget(on_detached)
|
||||
cdp_client.register.Target.targetInfoChanged(on_target_info_changed)
|
||||
cdp_client.register.Page.lifecycleEvent(on_lifecycle_event)
|
||||
|
||||
self.logger.debug('[SessionManager] Event monitoring started')
|
||||
|
||||
# Discover and initialize ALL existing targets
|
||||
await self._initialize_existing_targets()
|
||||
|
||||
def get_lifecycle_events(self, target_id: TargetID) -> 'deque[dict[str, Any]]':
|
||||
"""Get (creating if needed) the lifecycle event buffer for a target."""
|
||||
events = self._lifecycle_events.get(target_id)
|
||||
if events is None:
|
||||
events = deque(maxlen=50)
|
||||
self._lifecycle_events[target_id] = events
|
||||
return events
|
||||
|
||||
def _get_session_for_target(self, target_id: TargetID) -> 'CDPSession | None':
|
||||
"""Internal: Get ANY valid session for a target (picks first available).
|
||||
|
||||
⚠️ INTERNAL API - Use browser_session.get_or_create_cdp_session() instead!
|
||||
This method has no validation, no focus management, no recovery.
|
||||
|
||||
Args:
|
||||
target_id: Target ID to get session for
|
||||
|
||||
Returns:
|
||||
CDPSession if exists, None if target has detached
|
||||
"""
|
||||
session_ids = self._target_sessions.get(target_id, set())
|
||||
if not session_ids:
|
||||
# Check if this is the focused target - indicates stale focus that needs cleanup
|
||||
if self.browser_session.agent_focus_target_id == target_id:
|
||||
self.logger.warning(
|
||||
f'[SessionManager] ⚠️ Attempted to get session for stale focused target {target_id[:8]}... '
|
||||
f'Clearing stale focus and triggering recovery.'
|
||||
)
|
||||
|
||||
# Clear stale focus immediately (defense in depth)
|
||||
self.browser_session.agent_focus_target_id = None
|
||||
|
||||
# Trigger recovery if not already in progress
|
||||
if not self._recovery_in_progress:
|
||||
self.logger.warning('[SessionManager] Recovery was not in progress! Triggering now.')
|
||||
self._recovery_task = create_task_with_error_handling(
|
||||
self._recover_agent_focus(target_id),
|
||||
name='recover_agent_focus_from_stale_get',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=False,
|
||||
)
|
||||
return None
|
||||
return self._sessions.get(next(iter(session_ids)))
|
||||
|
||||
def get_all_page_targets(self) -> list:
|
||||
"""Get all page/tab targets using owned data.
|
||||
|
||||
Returns:
|
||||
List of Target objects for all page/tab targets
|
||||
"""
|
||||
page_targets = []
|
||||
for target in self._targets.values():
|
||||
if target.target_type in ('page', 'tab'):
|
||||
page_targets.append(target)
|
||||
return page_targets
|
||||
|
||||
async def validate_session(self, target_id: TargetID) -> bool:
|
||||
"""Check if a target still has active sessions.
|
||||
|
||||
Args:
|
||||
target_id: Target ID to validate
|
||||
|
||||
Returns:
|
||||
True if target has active sessions, False if it should be removed
|
||||
"""
|
||||
if target_id not in self._target_sessions:
|
||||
return False
|
||||
return len(self._target_sessions[target_id]) > 0
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""Clear all owned data structures for cleanup."""
|
||||
async with self._lock:
|
||||
# Clear owned data (single source of truth)
|
||||
self._targets.clear()
|
||||
self._sessions.clear()
|
||||
self._target_sessions.clear()
|
||||
self._session_to_target.clear()
|
||||
|
||||
self.logger.info('[SessionManager] Cleared all owned data (targets, sessions, mappings)')
|
||||
|
||||
async def is_target_valid(self, target_id: TargetID) -> bool:
|
||||
"""Check if a target is still valid and has active sessions.
|
||||
|
||||
Args:
|
||||
target_id: Target ID to validate
|
||||
|
||||
Returns:
|
||||
True if target is valid and has active sessions, False otherwise
|
||||
"""
|
||||
if target_id not in self._target_sessions:
|
||||
return False
|
||||
return len(self._target_sessions[target_id]) > 0
|
||||
|
||||
def get_target_id_from_session_id(self, session_id: SessionID) -> TargetID | None:
|
||||
"""Look up which target a session belongs to.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to look up
|
||||
|
||||
Returns:
|
||||
Target ID if found, None otherwise
|
||||
"""
|
||||
return self._session_to_target.get(session_id)
|
||||
|
||||
def get_target(self, target_id: TargetID) -> 'Target | None':
|
||||
"""Get target from owned data.
|
||||
|
||||
Args:
|
||||
target_id: Target ID to get
|
||||
|
||||
Returns:
|
||||
Target object if found, None otherwise
|
||||
"""
|
||||
return self._targets.get(target_id)
|
||||
|
||||
def get_all_targets(self) -> dict[TargetID, 'Target']:
|
||||
"""Get all targets (read-only access to owned data).
|
||||
|
||||
Returns:
|
||||
Dict mapping target_id to Target objects
|
||||
"""
|
||||
return self._targets
|
||||
|
||||
def get_all_target_ids(self) -> list[TargetID]:
|
||||
"""Get all target IDs from owned data.
|
||||
|
||||
Returns:
|
||||
List of all target IDs
|
||||
"""
|
||||
return list(self._targets.keys())
|
||||
|
||||
def get_all_sessions(self) -> dict[SessionID, 'CDPSession']:
|
||||
"""Get all sessions (read-only access to owned data).
|
||||
|
||||
Returns:
|
||||
Dict mapping session_id to CDPSession objects
|
||||
"""
|
||||
return self._sessions
|
||||
|
||||
def get_session(self, session_id: SessionID) -> 'CDPSession | None':
|
||||
"""Get session from owned data.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to get
|
||||
|
||||
Returns:
|
||||
CDPSession object if found, None otherwise
|
||||
"""
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def get_all_sessions_for_target(self, target_id: TargetID) -> list['CDPSession']:
|
||||
"""Get ALL sessions attached to a target from owned data.
|
||||
|
||||
Args:
|
||||
target_id: Target ID to get sessions for
|
||||
|
||||
Returns:
|
||||
List of all CDPSession objects for this target
|
||||
"""
|
||||
session_ids = self._target_sessions.get(target_id, set())
|
||||
return [self._sessions[sid] for sid in session_ids if sid in self._sessions]
|
||||
|
||||
def get_target_sessions_mapping(self) -> dict[TargetID, set[SessionID]]:
|
||||
"""Get target->sessions mapping (read-only access).
|
||||
|
||||
Returns:
|
||||
Dict mapping target_id to set of session_ids
|
||||
"""
|
||||
return self._target_sessions
|
||||
|
||||
def get_focused_target(self) -> 'Target | None':
|
||||
"""Get the target that currently has agent focus.
|
||||
|
||||
Convenience method that uses browser_session.agent_focus_target_id.
|
||||
|
||||
Returns:
|
||||
Target object if agent has focus, None otherwise
|
||||
"""
|
||||
if not self.browser_session.agent_focus_target_id:
|
||||
return None
|
||||
return self.get_target(self.browser_session.agent_focus_target_id)
|
||||
|
||||
async def ensure_valid_focus(self, timeout: float = 3.0) -> bool:
|
||||
"""Ensure agent_focus_target_id points to a valid, attached CDP session.
|
||||
|
||||
If the focus target is stale (detached), this method waits for automatic recovery.
|
||||
Uses event-driven coordination instead of polling for efficiency.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for recovery in seconds (default: 3.0)
|
||||
|
||||
Returns:
|
||||
True if focus is valid or successfully recovered, False if no focus or recovery failed
|
||||
"""
|
||||
if not self.browser_session.agent_focus_target_id:
|
||||
# No focus at all - might be initial state or complete failure
|
||||
if self._recovery_in_progress and self._recovery_complete_event:
|
||||
# Recovery is happening, wait for it
|
||||
try:
|
||||
await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=timeout)
|
||||
# Check again after recovery - simple existence check
|
||||
focus_id = self.browser_session.agent_focus_target_id
|
||||
return bool(focus_id and self._get_session_for_target(focus_id))
|
||||
except TimeoutError:
|
||||
self.logger.error(f'[SessionManager] ❌ Timed out waiting for recovery after {timeout}s')
|
||||
return False
|
||||
return False
|
||||
|
||||
# Simple existence check - does the focused target have a session?
|
||||
cdp_session = self._get_session_for_target(self.browser_session.agent_focus_target_id)
|
||||
if cdp_session:
|
||||
# Session exists - validate it's still active
|
||||
is_valid = await self.validate_session(self.browser_session.agent_focus_target_id)
|
||||
if is_valid:
|
||||
return True
|
||||
|
||||
# Focus is stale - wait for recovery using event instead of polling
|
||||
stale_target_id = self.browser_session.agent_focus_target_id
|
||||
self.logger.warning(
|
||||
f'[SessionManager] ⚠️ Stale agent_focus detected (target {stale_target_id[:8] if stale_target_id else "None"}... detached), '
|
||||
f'waiting for recovery...'
|
||||
)
|
||||
|
||||
# Check if recovery is already in progress
|
||||
if not self._recovery_in_progress:
|
||||
self.logger.warning(
|
||||
'[SessionManager] ⚠️ Recovery not in progress for stale focus! '
|
||||
'This indicates a bug - recovery should have been triggered.'
|
||||
)
|
||||
return False
|
||||
|
||||
# Wait for recovery complete event (event-driven, not polling!)
|
||||
if self._recovery_complete_event:
|
||||
try:
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=timeout)
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
|
||||
# Verify recovery succeeded - simple existence check
|
||||
focus_id = self.browser_session.agent_focus_target_id
|
||||
if focus_id and self._get_session_for_target(focus_id):
|
||||
self.logger.info(
|
||||
f'[SessionManager] ✅ Agent focus recovered to {self.browser_session.agent_focus_target_id[:8]}... '
|
||||
f'after {elapsed * 1000:.0f}ms'
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.logger.error(
|
||||
f'[SessionManager] ❌ Recovery completed but focus still invalid after {elapsed * 1000:.0f}ms'
|
||||
)
|
||||
return False
|
||||
|
||||
except TimeoutError:
|
||||
self.logger.error(
|
||||
f'[SessionManager] ❌ Recovery timed out after {timeout}s '
|
||||
f'(was: {stale_target_id[:8] if stale_target_id else "None"}..., '
|
||||
f'now: {self.browser_session.agent_focus_target_id[:8] if self.browser_session.agent_focus_target_id else "None"})'
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self.logger.error('[SessionManager] ❌ Recovery event not initialized')
|
||||
return False
|
||||
|
||||
async def _handle_target_attached(self, event: AttachedToTargetEvent) -> None:
|
||||
"""Handle Target.attachedToTarget event.
|
||||
|
||||
Called automatically by Chrome when a new target/session is created.
|
||||
This is the ONLY place where sessions are added to the pool.
|
||||
"""
|
||||
target_id = event['targetInfo']['targetId']
|
||||
session_id = event['sessionId']
|
||||
target_type = event['targetInfo']['type']
|
||||
target_info = event['targetInfo']
|
||||
waiting_for_debugger = event.get('waitingForDebugger', False)
|
||||
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Target attached: {target_id[:8]}... (session={session_id[:8]}..., '
|
||||
f'type={target_type}, waitingForDebugger={waiting_for_debugger})'
|
||||
)
|
||||
|
||||
# Defensive check: browser may be shutting down and _cdp_client_root could be None
|
||||
if self.browser_session._cdp_client_root is None:
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Skipping target attach for {target_id[:8]}... - browser shutting down (no CDP client)'
|
||||
)
|
||||
return
|
||||
|
||||
# Enable auto-attach for this session's children (do this FIRST, outside lock)
|
||||
try:
|
||||
await self.browser_session._cdp_client_root.send.Target.setAutoAttach(
|
||||
params={'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True}, session_id=session_id
|
||||
)
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
# Expected for short-lived targets (workers, temp iframes) that detach before this executes
|
||||
if '-32001' not in error_str and 'Session with given id not found' not in error_str:
|
||||
self.logger.debug(f'[SessionManager] Auto-attach failed for {target_type}: {e}')
|
||||
|
||||
from browser_use.browser.session import Target
|
||||
|
||||
async with self._lock:
|
||||
# Track this session for the target
|
||||
if target_id not in self._target_sessions:
|
||||
self._target_sessions[target_id] = set()
|
||||
|
||||
self._target_sessions[target_id].add(session_id)
|
||||
self._session_to_target[session_id] = target_id
|
||||
|
||||
# Create or update Target inside the same lock so that get_target() is never
|
||||
# called in the window between _target_sessions being set and _targets being set.
|
||||
if target_id not in self._targets:
|
||||
target = Target(
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
url=target_info.get('url', 'about:blank'),
|
||||
title=target_info.get('title', 'Unknown title'),
|
||||
)
|
||||
self._targets[target_id] = target
|
||||
self.logger.debug(f'[SessionManager] Created target {target_id[:8]}... (type={target_type})')
|
||||
else:
|
||||
# Update existing target info
|
||||
existing_target = self._targets[target_id]
|
||||
existing_target.url = target_info.get('url', existing_target.url)
|
||||
existing_target.title = target_info.get('title', existing_target.title)
|
||||
|
||||
# Create CDPSession (communication channel)
|
||||
from browser_use.browser.session import CDPSession
|
||||
|
||||
assert self.browser_session._cdp_client_root is not None, 'Root CDP client required'
|
||||
|
||||
cdp_session = CDPSession(
|
||||
cdp_client=self.browser_session._cdp_client_root,
|
||||
target_id=target_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Add to sessions dict
|
||||
self._sessions[session_id] = cdp_session
|
||||
|
||||
# If proxy auth is configured, enable Fetch auth handling on this session
|
||||
# Avoids overwriting Target.attachedToTarget handlers elsewhere
|
||||
try:
|
||||
proxy_cfg = self.browser_session.browser_profile.proxy
|
||||
username = proxy_cfg.username if proxy_cfg else None
|
||||
password = proxy_cfg.password if proxy_cfg else None
|
||||
if username and password:
|
||||
await cdp_session.cdp_client.send.Fetch.enable(
|
||||
params={'handleAuthRequests': True},
|
||||
session_id=cdp_session.session_id,
|
||||
)
|
||||
self.logger.debug(f'[SessionManager] Fetch.enable(handleAuthRequests=True) on session {session_id[:8]}...')
|
||||
except Exception as e:
|
||||
self.logger.debug(f'[SessionManager] Fetch.enable on attached session failed: {type(e).__name__}: {e}')
|
||||
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Created session {session_id[:8]}... for target {target_id[:8]}... '
|
||||
f'(total sessions: {len(self._sessions)})'
|
||||
)
|
||||
|
||||
# Enable lifecycle events and network monitoring for page targets
|
||||
if target_type in ('page', 'tab'):
|
||||
await self._enable_page_monitoring(cdp_session)
|
||||
|
||||
# Resume execution if waiting for debugger
|
||||
if waiting_for_debugger:
|
||||
try:
|
||||
assert self.browser_session._cdp_client_root is not None
|
||||
await self.browser_session._cdp_client_root.send.Runtime.runIfWaitingForDebugger(session_id=session_id)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'[SessionManager] Failed to resume execution: {e}')
|
||||
|
||||
async def _handle_target_info_changed(self, event: dict) -> None:
|
||||
"""Handle Target.targetInfoChanged event.
|
||||
|
||||
Updates target title/URL without polling getTargetInfo().
|
||||
Chrome fires this automatically when title or URL changes.
|
||||
"""
|
||||
target_info = event.get('targetInfo', {})
|
||||
target_id = target_info.get('targetId')
|
||||
|
||||
if not target_id:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
# Update target if it exists (source of truth for url/title)
|
||||
if target_id in self._targets:
|
||||
target = self._targets[target_id]
|
||||
|
||||
target.title = target_info.get('title', target.title)
|
||||
target.url = target_info.get('url', target.url)
|
||||
|
||||
async def _handle_target_detached(self, event: DetachedFromTargetEvent) -> None:
|
||||
"""Handle Target.detachedFromTarget event.
|
||||
|
||||
Called automatically by Chrome when a target/session is destroyed.
|
||||
This is the ONLY place where sessions are removed from the pool.
|
||||
"""
|
||||
session_id = event['sessionId']
|
||||
target_id = event.get('targetId') # May be empty
|
||||
|
||||
# If targetId not in event, look it up via session mapping
|
||||
if not target_id:
|
||||
async with self._lock:
|
||||
target_id = self._session_to_target.get(session_id)
|
||||
|
||||
if not target_id:
|
||||
self.logger.warning(f'[SessionManager] Session detached but target unknown (session={session_id[:8]}...)')
|
||||
return
|
||||
|
||||
agent_focus_lost = False
|
||||
target_fully_removed = False
|
||||
target_type = None
|
||||
|
||||
async with self._lock:
|
||||
# Remove this session from target's session set
|
||||
if target_id in self._target_sessions:
|
||||
self._target_sessions[target_id].discard(session_id)
|
||||
|
||||
remaining_sessions = len(self._target_sessions[target_id])
|
||||
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Session detached: target={target_id[:8]}... '
|
||||
f'session={session_id[:8]}... (remaining={remaining_sessions})'
|
||||
)
|
||||
|
||||
# Only remove target when NO sessions remain
|
||||
if remaining_sessions == 0:
|
||||
self.logger.debug(f'[SessionManager] No sessions remain for target {target_id[:8]}..., removing target')
|
||||
|
||||
target_fully_removed = True
|
||||
|
||||
# Check if agent_focus points to this target
|
||||
agent_focus_lost = self.browser_session.agent_focus_target_id == target_id
|
||||
|
||||
# Immediately clear stale focus to prevent operations on detached target
|
||||
if agent_focus_lost:
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Clearing stale agent_focus_target_id {target_id[:8]}... '
|
||||
f'to prevent operations on detached target'
|
||||
)
|
||||
self.browser_session.agent_focus_target_id = None
|
||||
|
||||
# Get target type before removing (needed for TabClosedEvent dispatch)
|
||||
target = self._targets.get(target_id)
|
||||
target_type = target.target_type if target else None
|
||||
|
||||
# Remove target (entity) from owned data
|
||||
if target_id in self._targets:
|
||||
self._targets.pop(target_id)
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Removed target {target_id[:8]}... (remaining targets: {len(self._targets)})'
|
||||
)
|
||||
|
||||
# Clean up tracking
|
||||
del self._target_sessions[target_id]
|
||||
self._lifecycle_events.pop(target_id, None)
|
||||
else:
|
||||
# Target not tracked - already removed or never attached
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Session detached from untracked target: target={target_id[:8]}... '
|
||||
f'session={session_id[:8]}... (target was already removed or attach event was missed)'
|
||||
)
|
||||
|
||||
# Remove session from owned sessions dict
|
||||
if session_id in self._sessions:
|
||||
self._sessions.pop(session_id)
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Removed session {session_id[:8]}... (remaining sessions: {len(self._sessions)})'
|
||||
)
|
||||
|
||||
# Remove from reverse mapping
|
||||
if session_id in self._session_to_target:
|
||||
del self._session_to_target[session_id]
|
||||
|
||||
# Dispatch TabClosedEvent only for page/tab targets that are fully removed (not iframes/workers or partial detaches)
|
||||
if target_fully_removed:
|
||||
if target_type in ('page', 'tab'):
|
||||
from browser_use.browser.events import TabClosedEvent
|
||||
|
||||
self.browser_session.event_bus.dispatch(TabClosedEvent(target_id=target_id))
|
||||
self.logger.debug(f'[SessionManager] Dispatched TabClosedEvent for page target {target_id[:8]}...')
|
||||
elif target_type:
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Target {target_id[:8]}... fully removed (type={target_type}) - not dispatching TabClosedEvent'
|
||||
)
|
||||
|
||||
# Auto-recover agent_focus outside the lock to avoid blocking other operations
|
||||
if agent_focus_lost:
|
||||
# Create recovery task instead of awaiting directly - allows concurrent operations to wait on same recovery
|
||||
if not self._recovery_in_progress:
|
||||
self._recovery_task = create_task_with_error_handling(
|
||||
self._recover_agent_focus(target_id),
|
||||
name='recover_agent_focus',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=False,
|
||||
)
|
||||
|
||||
async def _recover_agent_focus(self, crashed_target_id: TargetID) -> None:
|
||||
"""Auto-recover agent_focus when the focused target crashes/detaches.
|
||||
|
||||
Uses recovery lock to prevent concurrent recovery attempts from creating multiple emergency tabs.
|
||||
Coordinates with ensure_valid_focus() via events for efficient waiting.
|
||||
|
||||
Args:
|
||||
crashed_target_id: The target ID that was lost
|
||||
"""
|
||||
try:
|
||||
# Prevent concurrent recovery attempts
|
||||
async with self._recovery_lock:
|
||||
# Set recovery state INSIDE lock to prevent race conditions
|
||||
if self._recovery_in_progress:
|
||||
self.logger.debug('[SessionManager] Recovery already in progress, waiting for it to complete')
|
||||
# Wait for ongoing recovery instead of starting a new one
|
||||
if self._recovery_complete_event:
|
||||
try:
|
||||
await asyncio.wait_for(self._recovery_complete_event.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
self.logger.error('[SessionManager] Timed out waiting for ongoing recovery')
|
||||
return
|
||||
|
||||
# Set recovery state
|
||||
self._recovery_in_progress = True
|
||||
self._recovery_complete_event = asyncio.Event()
|
||||
|
||||
if self.browser_session._cdp_client_root is None:
|
||||
self.logger.debug('[SessionManager] Skipping focus recovery - browser shutting down (no CDP client)')
|
||||
return
|
||||
|
||||
# Check if another recovery already fixed agent_focus
|
||||
if self.browser_session.agent_focus_target_id and self.browser_session.agent_focus_target_id != crashed_target_id:
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Agent focus already recovered by concurrent operation '
|
||||
f'(now: {self.browser_session.agent_focus_target_id[:8]}...), skipping recovery'
|
||||
)
|
||||
return
|
||||
|
||||
# Note: agent_focus_target_id may already be None (cleared in _handle_target_detached)
|
||||
current_focus_desc = (
|
||||
f'{self.browser_session.agent_focus_target_id[:8]}...'
|
||||
if self.browser_session.agent_focus_target_id
|
||||
else 'None (already cleared)'
|
||||
)
|
||||
|
||||
self.logger.warning(
|
||||
f'[SessionManager] Agent focus target {crashed_target_id[:8]}... detached! '
|
||||
f'Current focus: {current_focus_desc}. Auto-recovering by switching to another target...'
|
||||
)
|
||||
|
||||
# Perform recovery (outside lock to allow concurrent operations)
|
||||
# Try to find another valid page target
|
||||
page_targets = self.get_all_page_targets()
|
||||
|
||||
new_target_id = None
|
||||
is_existing_tab = False
|
||||
|
||||
if page_targets:
|
||||
# Switch to most recent page that's not the crashed one
|
||||
new_target_id = page_targets[-1].target_id
|
||||
is_existing_tab = True
|
||||
self.logger.info(f'[SessionManager] Switching agent_focus to existing tab {new_target_id[:8]}...')
|
||||
else:
|
||||
# No pages exist - create a new one
|
||||
self.logger.warning('[SessionManager] No tabs remain! Creating new tab for agent...')
|
||||
new_target_id = await self.browser_session._cdp_create_new_page('about:blank')
|
||||
self.logger.info(f'[SessionManager] Created new tab {new_target_id[:8]}... for agent')
|
||||
|
||||
# Dispatch TabCreatedEvent so watchdogs can initialize
|
||||
from browser_use.browser.events import TabCreatedEvent
|
||||
|
||||
self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=new_target_id))
|
||||
|
||||
# Wait for CDP attach event to create session
|
||||
# Note: This polling is necessary - waiting for external Chrome CDP event
|
||||
# _handle_target_attached will add session to pool when Chrome fires attachedToTarget
|
||||
new_session = None
|
||||
for attempt in range(20): # Wait up to 2 seconds
|
||||
await asyncio.sleep(0.1)
|
||||
new_session = self._get_session_for_target(new_target_id)
|
||||
if new_session:
|
||||
break
|
||||
|
||||
if new_session:
|
||||
self.browser_session.agent_focus_target_id = new_target_id
|
||||
self.logger.info(f'[SessionManager] ✅ Agent focus recovered: {new_target_id[:8]}...')
|
||||
|
||||
# Visually activate the tab in browser (only for existing tabs)
|
||||
if is_existing_tab:
|
||||
try:
|
||||
assert self.browser_session._cdp_client_root is not None
|
||||
await self.browser_session._cdp_client_root.send.Target.activateTarget(params={'targetId': new_target_id})
|
||||
self.logger.debug(f'[SessionManager] Activated tab {new_target_id[:8]}... in browser UI')
|
||||
except Exception as e:
|
||||
self.logger.debug(f'[SessionManager] Failed to activate tab visually: {e}')
|
||||
|
||||
# Get target to access url (from owned data)
|
||||
target = self.get_target(new_target_id)
|
||||
target_url = target.url if target else 'about:blank'
|
||||
|
||||
# Dispatch focus changed event
|
||||
from browser_use.browser.events import AgentFocusChangedEvent
|
||||
|
||||
self.browser_session.event_bus.dispatch(AgentFocusChangedEvent(target_id=new_target_id, url=target_url))
|
||||
return
|
||||
|
||||
# Recovery failed - create emergency fallback tab
|
||||
self.logger.error(
|
||||
f'[SessionManager] ❌ Failed to get session for {new_target_id[:8]}... after 2s, creating emergency fallback tab'
|
||||
)
|
||||
|
||||
fallback_target_id = await self.browser_session._cdp_create_new_page('about:blank')
|
||||
self.logger.warning(f'[SessionManager] Created emergency fallback tab {fallback_target_id[:8]}...')
|
||||
|
||||
# Try one more time with fallback
|
||||
# Note: This polling is necessary - waiting for external Chrome CDP event
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
fallback_session = self._get_session_for_target(fallback_target_id)
|
||||
if fallback_session:
|
||||
self.browser_session.agent_focus_target_id = fallback_target_id
|
||||
self.logger.warning(f'[SessionManager] ⚠️ Agent focus set to emergency fallback: {fallback_target_id[:8]}...')
|
||||
|
||||
from browser_use.browser.events import AgentFocusChangedEvent, TabCreatedEvent
|
||||
|
||||
self.browser_session.event_bus.dispatch(TabCreatedEvent(url='about:blank', target_id=fallback_target_id))
|
||||
self.browser_session.event_bus.dispatch(
|
||||
AgentFocusChangedEvent(target_id=fallback_target_id, url='about:blank')
|
||||
)
|
||||
return
|
||||
|
||||
# Complete failure - this should never happen
|
||||
self.logger.critical(
|
||||
'[SessionManager] 🚨 CRITICAL: Failed to recover agent_focus even with fallback! Agent may be in broken state.'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'[SessionManager] ❌ Error during agent_focus recovery: {type(e).__name__}: {e}')
|
||||
finally:
|
||||
# Always signal completion and reset recovery state
|
||||
# This allows all waiting operations to proceed (success or failure)
|
||||
if self._recovery_complete_event:
|
||||
self._recovery_complete_event.set()
|
||||
self._recovery_in_progress = False
|
||||
self._recovery_task = None
|
||||
self.logger.debug('[SessionManager] Recovery state reset')
|
||||
|
||||
async def _initialize_existing_targets(self) -> None:
|
||||
"""Discover and initialize all existing targets at startup.
|
||||
|
||||
Attaches to each target and initializes it SYNCHRONOUSLY.
|
||||
Chrome will also fire attachedToTarget events, but _handle_target_attached() is
|
||||
idempotent (checks if target already in pool), so duplicate handling is safe.
|
||||
|
||||
This eliminates race conditions - monitoring is guaranteed ready before navigation.
|
||||
"""
|
||||
cdp_client = self.browser_session._cdp_client_root
|
||||
assert cdp_client is not None
|
||||
|
||||
# Get all existing targets
|
||||
targets_result = await cdp_client.send.Target.getTargets()
|
||||
existing_targets = targets_result.get('targetInfos', [])
|
||||
|
||||
self.logger.debug(f'[SessionManager] Discovered {len(existing_targets)} existing targets')
|
||||
|
||||
# Track target IDs for verification
|
||||
target_ids_to_wait_for = []
|
||||
|
||||
# Just attach to ALL existing targets - Chrome fires attachedToTarget events
|
||||
# The on_attached handler (via create_task) does ALL the work
|
||||
for target in existing_targets:
|
||||
target_id = target['targetId']
|
||||
target_type = target.get('type', 'unknown')
|
||||
|
||||
try:
|
||||
# Just attach - event handler does everything
|
||||
await cdp_client.send.Target.attachToTarget(params={'targetId': target_id, 'flatten': True})
|
||||
target_ids_to_wait_for.append(target_id)
|
||||
except Exception as e:
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Failed to attach to existing target {target_id[:8]}... (type={target_type}): {e}'
|
||||
)
|
||||
|
||||
# Wait for event handlers to complete their work (they run via create_task)
|
||||
# Use event-driven approach instead of polling for better performance
|
||||
ready_event = asyncio.Event()
|
||||
|
||||
async def check_all_ready():
|
||||
"""Check if all sessions are ready and signal completion."""
|
||||
while True:
|
||||
ready_count = 0
|
||||
for tid in target_ids_to_wait_for:
|
||||
session = self._get_session_for_target(tid)
|
||||
if session:
|
||||
target = self._targets.get(tid)
|
||||
target_type = target.target_type if target else 'unknown'
|
||||
# For pages, verify monitoring is enabled
|
||||
if target_type in ('page', 'tab'):
|
||||
if hasattr(session, '_lifecycle_events') and session._lifecycle_events is not None:
|
||||
ready_count += 1
|
||||
else:
|
||||
# Non-page targets don't need monitoring
|
||||
ready_count += 1
|
||||
|
||||
if ready_count == len(target_ids_to_wait_for):
|
||||
ready_event.set()
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Start checking in background
|
||||
check_task = create_task_with_error_handling(
|
||||
check_all_ready(), name='check_all_targets_ready', logger_instance=self.logger
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait for completion with timeout
|
||||
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
# Timeout - count what's ready
|
||||
ready_count = 0
|
||||
for tid in target_ids_to_wait_for:
|
||||
session = self._get_session_for_target(tid)
|
||||
if session:
|
||||
target = self._targets.get(tid)
|
||||
target_type = target.target_type if target else 'unknown'
|
||||
# For pages, verify monitoring is enabled
|
||||
if target_type in ('page', 'tab'):
|
||||
if hasattr(session, '_lifecycle_events') and session._lifecycle_events is not None:
|
||||
ready_count += 1
|
||||
else:
|
||||
# Non-page targets don't need monitoring
|
||||
ready_count += 1
|
||||
self.logger.warning(
|
||||
f'[SessionManager] Initialization timeout after 2.0s: {ready_count}/{len(target_ids_to_wait_for)} sessions ready'
|
||||
)
|
||||
finally:
|
||||
check_task.cancel()
|
||||
try:
|
||||
await check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _enable_page_monitoring(self, cdp_session: 'CDPSession') -> None:
|
||||
"""Enable lifecycle events and network monitoring for a page target.
|
||||
|
||||
This is called once per page when it's created, avoiding handler accumulation.
|
||||
Registers a SINGLE lifecycle handler per session that stores events for navigations to consume.
|
||||
|
||||
Args:
|
||||
cdp_session: The CDP session to enable monitoring on
|
||||
"""
|
||||
try:
|
||||
# Enable Page domain first (required for lifecycle events)
|
||||
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
|
||||
|
||||
# Enable lifecycle events (load, DOMContentLoaded, networkIdle, etc.)
|
||||
await cdp_session.cdp_client.send.Page.setLifecycleEventsEnabled(
|
||||
params={'enabled': True}, session_id=cdp_session.session_id
|
||||
)
|
||||
|
||||
# Enable network monitoring for networkIdle detection
|
||||
await cdp_session.cdp_client.send.Network.enable(session_id=cdp_session.session_id)
|
||||
|
||||
# Event storage and the Page.lifecycleEvent handler live in SessionManager
|
||||
# (one global handler registered in start_monitoring, routed by session_id):
|
||||
# cdp-use's registry is single-slot per method, so a per-session registration
|
||||
# here would replace the previous tab's handler and freeze its event buffer.
|
||||
# Expose the shared per-target buffer on the session for readiness checks.
|
||||
cdp_session._lifecycle_events = self.get_lifecycle_events(cdp_session.target_id)
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail - target might be short-lived or already detached
|
||||
error_str = str(e)
|
||||
if '-32001' in error_str or 'Session with given id not found' in error_str:
|
||||
self.logger.debug(
|
||||
f'[SessionManager] Target {cdp_session.target_id[:8]}... detached before monitoring could be enabled (normal for short-lived targets)'
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f'[SessionManager] Failed to enable monitoring for target {cdp_session.target_id[:8]}...: {e}'
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Video Recording Service for Browser Use Sessions."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from browser_use.browser.profile import ViewportSize
|
||||
|
||||
try:
|
||||
import imageio.v2 as iio # type: ignore[import-not-found]
|
||||
import numpy as np # type: ignore[import-not-found]
|
||||
from imageio.core.format import Format # type: ignore[import-not-found]
|
||||
from PIL import Image
|
||||
|
||||
IMAGEIO_AVAILABLE = True
|
||||
except ImportError:
|
||||
IMAGEIO_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_padded_size(size: ViewportSize, macro_block_size: int = 16) -> ViewportSize:
|
||||
"""Calculates the dimensions padded to the nearest multiple of macro_block_size."""
|
||||
width = int(math.ceil(size['width'] / macro_block_size)) * macro_block_size
|
||||
height = int(math.ceil(size['height'] / macro_block_size)) * macro_block_size
|
||||
return ViewportSize(width=width, height=height)
|
||||
|
||||
|
||||
class VideoRecorderService:
|
||||
"""
|
||||
Handles the video encoding process for a browser session using imageio.
|
||||
|
||||
This service captures individual frames from the CDP screencast, decodes them,
|
||||
and appends them to a video file using a pip-installable ffmpeg backend.
|
||||
It automatically resizes frames to match the target video dimensions.
|
||||
"""
|
||||
|
||||
def __init__(self, output_path: Path, size: ViewportSize, framerate: int):
|
||||
"""
|
||||
Initializes the video recorder.
|
||||
|
||||
Args:
|
||||
output_path: The full path where the video will be saved.
|
||||
size: A ViewportSize object specifying the width and height of the video.
|
||||
framerate: The desired framerate for the output video.
|
||||
"""
|
||||
self.output_path = output_path
|
||||
self.size = size
|
||||
self.framerate = framerate
|
||||
self._writer: Optional['Format.Writer'] = None
|
||||
self._is_active = False
|
||||
self.padded_size = _get_padded_size(self.size)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Prepares and starts the video writer.
|
||||
|
||||
If the required optional dependencies are not installed, this method will
|
||||
log an error and do nothing.
|
||||
"""
|
||||
if not IMAGEIO_AVAILABLE:
|
||||
logger.error(
|
||||
'MP4 recording requires optional dependencies. Please install them with: pip install "browser-use[video]"'
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# The macro_block_size is set to None because we handle padding ourselves
|
||||
self._writer = iio.get_writer(
|
||||
str(self.output_path),
|
||||
fps=self.framerate,
|
||||
codec='libx264',
|
||||
quality=8, # A good balance of quality and file size (1-10 scale)
|
||||
pixelformat='yuv420p', # Ensures compatibility with most players
|
||||
macro_block_size=None,
|
||||
)
|
||||
self._is_active = True
|
||||
logger.debug(f'Video recorder started. Output will be saved to {self.output_path}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize video writer: {e}')
|
||||
self._is_active = False
|
||||
|
||||
def add_frame(self, frame_data_b64: str) -> None:
|
||||
"""
|
||||
Decodes a base64-encoded PNG frame, resizes it, pads it to be codec-compatible,
|
||||
and appends it to the video.
|
||||
|
||||
Args:
|
||||
frame_data_b64: A base64-encoded string of the PNG frame data.
|
||||
"""
|
||||
if not self._is_active or not self._writer:
|
||||
return
|
||||
|
||||
try:
|
||||
frame_bytes = base64.b64decode(frame_data_b64)
|
||||
|
||||
# Use PIL to handle image processing in memory - much faster than spawning ffmpeg subprocess per frame
|
||||
with Image.open(io.BytesIO(frame_bytes)) as img:
|
||||
# 1. Resize if needed to target viewport size
|
||||
if img.size != (self.size['width'], self.size['height']):
|
||||
# Use BICUBIC as it's faster than LANCZOS and good enough for screen recordings
|
||||
img = img.resize((self.size['width'], self.size['height']), Image.Resampling.BICUBIC)
|
||||
|
||||
# 2. Handle Padding (Macro block alignment for codecs)
|
||||
# Check if padding is actually needed
|
||||
if self.padded_size['width'] != self.size['width'] or self.padded_size['height'] != self.size['height']:
|
||||
new_img = Image.new('RGB', (self.padded_size['width'], self.padded_size['height']), (0, 0, 0))
|
||||
# Center the image
|
||||
x_offset = (self.padded_size['width'] - self.size['width']) // 2
|
||||
y_offset = (self.padded_size['height'] - self.size['height']) // 2
|
||||
new_img.paste(img, (x_offset, y_offset))
|
||||
img = new_img
|
||||
|
||||
# 3. Convert to numpy array for imageio
|
||||
img_array = np.array(img)
|
||||
|
||||
self._writer.append_data(img_array)
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not process and add video frame: {e}')
|
||||
|
||||
def stop_and_save(self) -> None:
|
||||
"""
|
||||
Finalizes the video file by closing the writer.
|
||||
|
||||
This method should be called when the recording session is complete.
|
||||
"""
|
||||
if not self._is_active or not self._writer:
|
||||
return
|
||||
|
||||
try:
|
||||
self._writer.close()
|
||||
logger.info(f'📹 Video recording saved successfully to: {self.output_path}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to finalize and save video: {e}')
|
||||
finally:
|
||||
self._is_active = False
|
||||
self._writer = None
|
||||
@@ -0,0 +1,200 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.target import TargetID
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_serializer
|
||||
|
||||
from browser_use.dom.views import DOMInteractedElement, SerializedDOMState
|
||||
|
||||
# Known placeholder image data for about:blank pages - a 4x4 white PNG
|
||||
PLACEHOLDER_4PX_SCREENSHOT = (
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGP8//8/AwwwMSAB3BwAlm4DBfIlvvkAAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
|
||||
# Pydantic
|
||||
class TabInfo(BaseModel):
|
||||
"""Represents information about a browser tab"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra='forbid',
|
||||
validate_by_name=True,
|
||||
validate_by_alias=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
# Original fields
|
||||
url: str
|
||||
title: str
|
||||
target_id: TargetID = Field(serialization_alias='tab_id', validation_alias=AliasChoices('tab_id', 'target_id'))
|
||||
parent_target_id: TargetID | None = Field(
|
||||
default=None, serialization_alias='parent_tab_id', validation_alias=AliasChoices('parent_tab_id', 'parent_target_id')
|
||||
) # parent page that contains this popup or cross-origin iframe
|
||||
|
||||
@field_serializer('target_id')
|
||||
def serialize_target_id(self, target_id: TargetID, _info: Any) -> str:
|
||||
return target_id[-4:]
|
||||
|
||||
@field_serializer('parent_target_id')
|
||||
def serialize_parent_target_id(self, parent_target_id: TargetID | None, _info: Any) -> str | None:
|
||||
return parent_target_id[-4:] if parent_target_id else None
|
||||
|
||||
|
||||
class PageInfo(BaseModel):
|
||||
"""Comprehensive page size and scroll information"""
|
||||
|
||||
# Current viewport dimensions
|
||||
viewport_width: int
|
||||
viewport_height: int
|
||||
|
||||
# Total page dimensions
|
||||
page_width: int
|
||||
page_height: int
|
||||
|
||||
# Current scroll position
|
||||
scroll_x: int
|
||||
scroll_y: int
|
||||
|
||||
# Calculated scroll information
|
||||
pixels_above: int
|
||||
pixels_below: int
|
||||
pixels_left: int
|
||||
pixels_right: int
|
||||
|
||||
# Page statistics are now computed dynamically instead of stored
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkRequest:
|
||||
"""Information about a pending network request"""
|
||||
|
||||
url: str
|
||||
method: str = 'GET'
|
||||
loading_duration_ms: float = 0.0 # How long this request has been loading (ms since request started, max 10s)
|
||||
resource_type: str | None = None # e.g., 'Document', 'Stylesheet', 'Image', 'Script', 'XHR', 'Fetch'
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaginationButton:
|
||||
"""Information about a pagination button detected on the page"""
|
||||
|
||||
button_type: str # 'next', 'prev', 'first', 'last', 'page_number'
|
||||
backend_node_id: int # Backend node ID for clicking
|
||||
text: str # Button text/label
|
||||
selector: str # XPath or other selector to locate the element
|
||||
is_disabled: bool = False # Whether the button appears disabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserStateSummary:
|
||||
"""The summary of the browser's current state designed for an LLM to process"""
|
||||
|
||||
# provided by SerializedDOMState:
|
||||
dom_state: SerializedDOMState
|
||||
|
||||
url: str
|
||||
title: str
|
||||
tabs: list[TabInfo]
|
||||
screenshot: str | None = field(default=None, repr=False)
|
||||
page_info: PageInfo | None = None # Enhanced page information
|
||||
|
||||
# Keep legacy fields for backward compatibility
|
||||
pixels_above: int = 0
|
||||
pixels_below: int = 0
|
||||
browser_errors: list[str] = field(default_factory=list)
|
||||
is_pdf_viewer: bool = False # Whether the current page is a PDF viewer
|
||||
recent_events: str | None = None # Text summary of recent browser events
|
||||
pending_network_requests: list[NetworkRequest] = field(default_factory=list) # Currently loading network requests
|
||||
pagination_buttons: list[PaginationButton] = field(default_factory=list) # Detected pagination buttons
|
||||
closed_popup_messages: list[str] = field(default_factory=list) # Messages from auto-closed JavaScript dialogs
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserStateHistory:
|
||||
"""The summary of the browser's state at a past point in time to usse in LLM message history"""
|
||||
|
||||
url: str
|
||||
title: str
|
||||
tabs: list[TabInfo]
|
||||
interacted_element: list[DOMInteractedElement | None] | list[None]
|
||||
screenshot_path: str | None = None
|
||||
|
||||
def get_screenshot(self) -> str | None:
|
||||
"""Load screenshot from disk and return as base64 string"""
|
||||
if not self.screenshot_path:
|
||||
return None
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
path_obj = Path(self.screenshot_path)
|
||||
if not path_obj.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path_obj, 'rb') as f:
|
||||
screenshot_data = f.read()
|
||||
return base64.b64encode(screenshot_data).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {}
|
||||
data['tabs'] = [tab.model_dump() for tab in self.tabs]
|
||||
data['screenshot_path'] = self.screenshot_path
|
||||
data['interacted_element'] = [el.to_dict() if el else None for el in self.interacted_element]
|
||||
data['url'] = self.url
|
||||
data['title'] = self.title
|
||||
return data
|
||||
|
||||
|
||||
class BrowserError(Exception):
|
||||
"""Browser error with structured memory for LLM context management.
|
||||
|
||||
This exception class provides separate memory contexts for browser actions:
|
||||
- short_term_memory: Immediate context shown once to the LLM for the next action
|
||||
- long_term_memory: Persistent error information stored across steps
|
||||
"""
|
||||
|
||||
message: str
|
||||
short_term_memory: str | None = None
|
||||
long_term_memory: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
while_handling_event: BaseEvent[Any] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
short_term_memory: str | None = None,
|
||||
long_term_memory: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
event: BaseEvent[Any] | None = None,
|
||||
):
|
||||
"""Initialize a BrowserError with structured memory contexts.
|
||||
|
||||
Args:
|
||||
message: Technical error message for logging and debugging
|
||||
short_term_memory: Context shown once to LLM (e.g., available actions, options)
|
||||
long_term_memory: Persistent error info stored in agent memory
|
||||
details: Additional metadata for debugging
|
||||
event: The browser event that triggered this error
|
||||
"""
|
||||
self.message = message
|
||||
self.short_term_memory = short_term_memory
|
||||
self.long_term_memory = long_term_memory
|
||||
self.details = details
|
||||
self.while_handling_event = event
|
||||
super().__init__(message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts = [self.message]
|
||||
if self.details:
|
||||
parts.append(f'({self.details})')
|
||||
if self.while_handling_event:
|
||||
parts.append(f'during: {self.while_handling_event}')
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
class URLNotAllowedError(BrowserError):
|
||||
"""Error raised when a URL is not allowed"""
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Base watchdog class for browser monitoring components."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from bubus import BaseEvent, EventBus
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from browser_use.browser.session import BrowserSession
|
||||
|
||||
|
||||
class BaseWatchdog(BaseModel):
|
||||
"""Base class for all browser watchdogs.
|
||||
|
||||
Watchdogs monitor browser state and emit events based on changes.
|
||||
They automatically register event handlers based on method names.
|
||||
|
||||
Handler methods should be named: on_EventTypeName(self, event: EventTypeName)
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
arbitrary_types_allowed=True, # allow non-serializable objects like EventBus/BrowserSession in fields
|
||||
extra='forbid', # dont allow implicit class/instance state, everything must be a properly typed Field or PrivateAttr
|
||||
validate_assignment=False, # avoid re-triggering __init__ / validators on values on every assignment
|
||||
revalidate_instances='never', # avoid re-triggering __init__ / validators and erasing private attrs
|
||||
)
|
||||
|
||||
# Class variables to statically define the list of events relevant to each watchdog
|
||||
# (not enforced, just to make it easier to understand the code and debug watchdogs at runtime)
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [] # Events this watchdog listens to
|
||||
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = [] # Events this watchdog emits
|
||||
|
||||
# Core dependencies
|
||||
event_bus: EventBus = Field()
|
||||
browser_session: BrowserSession = Field()
|
||||
|
||||
# Shared state that other watchdogs might need to access should not be defined on BrowserSession, not here!
|
||||
# Shared helper methods needed by other watchdogs should be defined on BrowserSession, not here!
|
||||
# Alternatively, expose some events on the watchdog to allow access to state/helpers via event_bus system.
|
||||
|
||||
# Private state internal to the watchdog can be defined like this on BaseWatchdog subclasses:
|
||||
# _screenshot_cache: dict[str, bytes] = PrivateAttr(default_factory=dict)
|
||||
# _browser_crash_watcher_task: asyncio.Task | None = PrivateAttr(default=None)
|
||||
# _cdp_download_tasks: WeakSet[asyncio.Task] = PrivateAttr(default_factory=WeakSet)
|
||||
# ...
|
||||
|
||||
@property
|
||||
def logger(self):
|
||||
"""Get the logger from the browser session."""
|
||||
return self.browser_session.logger
|
||||
|
||||
@staticmethod
|
||||
def attach_handler_to_session(browser_session: 'BrowserSession', event_class: type[BaseEvent[Any]], handler) -> None:
|
||||
"""Attach a single event handler to a browser session.
|
||||
|
||||
Args:
|
||||
browser_session: The browser session to attach to
|
||||
event_class: The event class to listen for
|
||||
handler: The handler method (must start with 'on_' and end with event type)
|
||||
"""
|
||||
event_bus = browser_session.event_bus
|
||||
|
||||
# Validate handler naming convention
|
||||
assert hasattr(handler, '__name__'), 'Handler must have a __name__ attribute'
|
||||
assert handler.__name__.startswith('on_'), f'Handler {handler.__name__} must start with "on_"'
|
||||
assert handler.__name__.endswith(event_class.__name__), (
|
||||
f'Handler {handler.__name__} must end with event type {event_class.__name__}'
|
||||
)
|
||||
|
||||
# Get the watchdog instance if this is a bound method
|
||||
watchdog_instance = getattr(handler, '__self__', None)
|
||||
watchdog_class_name = watchdog_instance.__class__.__name__ if watchdog_instance else 'Unknown'
|
||||
|
||||
# Events that should always run even when CDP is disconnected (lifecycle management)
|
||||
LIFECYCLE_EVENT_NAMES = frozenset(
|
||||
{
|
||||
'BrowserStartEvent',
|
||||
'BrowserStopEvent',
|
||||
'BrowserStoppedEvent',
|
||||
'BrowserLaunchEvent',
|
||||
'BrowserErrorEvent',
|
||||
'BrowserKillEvent',
|
||||
'BrowserReconnectingEvent',
|
||||
'BrowserReconnectedEvent',
|
||||
}
|
||||
)
|
||||
|
||||
# Create a wrapper function with unique name to avoid duplicate handler warnings
|
||||
# Capture handler by value to avoid closure issues
|
||||
def make_unique_handler(actual_handler):
|
||||
async def unique_handler(event):
|
||||
# Circuit breaker: skip handler if CDP WebSocket is dead
|
||||
# (prevents handlers from hanging on broken connections until timeout)
|
||||
# Lifecycle events are exempt — they manage browser start/stop
|
||||
if event.event_type not in LIFECYCLE_EVENT_NAMES and not browser_session.is_cdp_connected:
|
||||
# If reconnection is in progress, wait for it instead of silently skipping
|
||||
if browser_session.is_reconnecting:
|
||||
wait_timeout = browser_session.RECONNECT_WAIT_TIMEOUT
|
||||
browser_session.logger.debug(
|
||||
f'🚌 [{watchdog_class_name}.{actual_handler.__name__}] ⏳ Waiting for reconnection ({wait_timeout}s)...'
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(browser_session._reconnect_event.wait(), timeout=wait_timeout)
|
||||
except TimeoutError:
|
||||
raise ConnectionError(
|
||||
f'[{watchdog_class_name}.{actual_handler.__name__}] '
|
||||
f'Reconnection wait timed out after {wait_timeout}s'
|
||||
)
|
||||
# After wait: check if reconnection actually succeeded
|
||||
if not browser_session.is_cdp_connected:
|
||||
raise ConnectionError(
|
||||
f'[{watchdog_class_name}.{actual_handler.__name__}] Reconnection failed — CDP still not connected'
|
||||
)
|
||||
# Reconnection succeeded — fall through to execute handler normally
|
||||
else:
|
||||
# Not reconnecting — intentional stop, backward compat silent skip
|
||||
browser_session.logger.debug(
|
||||
f'🚌 [{watchdog_class_name}.{actual_handler.__name__}] ⚡ Skipped — CDP not connected'
|
||||
)
|
||||
return None
|
||||
|
||||
# just for debug logging, not used for anything else
|
||||
parent_event = event_bus.event_history.get(event.event_parent_id) if event.event_parent_id else None
|
||||
grandparent_event = (
|
||||
event_bus.event_history.get(parent_event.event_parent_id)
|
||||
if parent_event and parent_event.event_parent_id
|
||||
else None
|
||||
)
|
||||
parent = (
|
||||
f'↲ triggered by on_{parent_event.event_type}#{parent_event.event_id[-4:]}'
|
||||
if parent_event
|
||||
else '👈 by Agent'
|
||||
)
|
||||
grandparent = (
|
||||
(
|
||||
f'↲ under {grandparent_event.event_type}#{grandparent_event.event_id[-4:]}'
|
||||
if grandparent_event
|
||||
else '👈 by Agent'
|
||||
)
|
||||
if parent_event
|
||||
else ''
|
||||
)
|
||||
event_str = f'#{event.event_id[-4:]}'
|
||||
time_start = time.time()
|
||||
watchdog_and_handler_str = f'[{watchdog_class_name}.{actual_handler.__name__}({event_str})]'.ljust(54)
|
||||
browser_session.logger.debug(f'🚌 {watchdog_and_handler_str} ⏳ Starting... {parent} {grandparent}')
|
||||
|
||||
try:
|
||||
# **EXECUTE THE EVENT HANDLER FUNCTION**
|
||||
result = await actual_handler(event)
|
||||
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
|
||||
# just for debug logging, not used for anything else
|
||||
time_end = time.time()
|
||||
time_elapsed = time_end - time_start
|
||||
result_summary = '' if result is None else f' ➡️ <{type(result).__name__}>'
|
||||
parents_summary = f' {parent}'.replace('↲ triggered by ', '⤴ returned to ').replace(
|
||||
'👈 by Agent', '👉 returned to Agent'
|
||||
)
|
||||
browser_session.logger.debug(
|
||||
f'🚌 {watchdog_and_handler_str} Succeeded ({time_elapsed:.2f}s){result_summary}{parents_summary}'
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
time_end = time.time()
|
||||
time_elapsed = time_end - time_start
|
||||
original_error = e
|
||||
browser_session.logger.error(
|
||||
f'🚌 {watchdog_and_handler_str} ❌ Failed ({time_elapsed:.2f}s): {type(e).__name__}: {e}'
|
||||
)
|
||||
|
||||
# attempt to repair potentially crashed CDP session
|
||||
try:
|
||||
if browser_session.agent_focus_target_id:
|
||||
# With event-driven sessions, Chrome will send detach/attach events
|
||||
# SessionManager handles pool cleanup automatically
|
||||
target_id_to_restore = browser_session.agent_focus_target_id
|
||||
browser_session.logger.debug(
|
||||
f'🚌 {watchdog_and_handler_str} ⚠️ Session error detected, waiting for CDP events to sync (target: {target_id_to_restore})'
|
||||
)
|
||||
|
||||
# Wait for new attach event to restore the session
|
||||
# This will raise ValueError if target doesn't re-attach
|
||||
await browser_session.get_or_create_cdp_session(target_id=target_id_to_restore, focus=True)
|
||||
else:
|
||||
# Try to get any available session
|
||||
await browser_session.get_or_create_cdp_session(target_id=None, focus=True)
|
||||
except Exception as sub_error:
|
||||
if 'ConnectionClosedError' in str(type(sub_error)) or 'ConnectionError' in str(type(sub_error)):
|
||||
browser_session.logger.error(
|
||||
f'🚌 {watchdog_and_handler_str} ❌ Browser closed or CDP Connection disconnected by remote. {type(sub_error).__name__}: {sub_error}\n'
|
||||
)
|
||||
raise
|
||||
else:
|
||||
browser_session.logger.error(
|
||||
f'🚌 {watchdog_and_handler_str} ❌ CDP connected but failed to re-create CDP session after error "{type(original_error).__name__}: {original_error}" in {actual_handler.__name__}({event.event_type}#{event.event_id[-4:]}): due to {type(sub_error).__name__}: {sub_error}\n'
|
||||
)
|
||||
|
||||
# Always re-raise the original error with its traceback preserved
|
||||
raise
|
||||
|
||||
return unique_handler
|
||||
|
||||
unique_handler = make_unique_handler(handler)
|
||||
unique_handler.__name__ = f'{watchdog_class_name}.{handler.__name__}'
|
||||
|
||||
# Check if this handler is already registered - throw error if duplicate
|
||||
existing_handlers = event_bus.handlers.get(event_class.__name__, [])
|
||||
handler_names = [getattr(h, '__name__', str(h)) for h in existing_handlers]
|
||||
|
||||
if unique_handler.__name__ in handler_names:
|
||||
raise RuntimeError(
|
||||
f'[{watchdog_class_name}] Duplicate handler registration attempted! '
|
||||
f'Handler {unique_handler.__name__} is already registered for {event_class.__name__}. '
|
||||
f'This likely means attach_to_session() was called multiple times.'
|
||||
)
|
||||
|
||||
event_bus.on(event_class, unique_handler)
|
||||
|
||||
@staticmethod
|
||||
def detach_handler_from_session(browser_session: 'BrowserSession', event_class: type[BaseEvent[Any]], handler) -> None:
|
||||
"""Detach a single event handler from a browser session."""
|
||||
event_bus = browser_session.event_bus
|
||||
|
||||
# Get the watchdog instance if this is a bound method
|
||||
watchdog_instance = getattr(handler, '__self__', None)
|
||||
watchdog_class_name = watchdog_instance.__class__.__name__ if watchdog_instance else 'Unknown'
|
||||
|
||||
# Find and remove the handler by its unique name pattern
|
||||
unique_handler_name = f'{watchdog_class_name}.{handler.__name__}'
|
||||
|
||||
existing_handlers = event_bus.handlers.get(event_class.__name__, [])
|
||||
for existing_handler in existing_handlers[:]: # copy list to allow modification during iteration
|
||||
if getattr(existing_handler, '__name__', '') == unique_handler_name:
|
||||
existing_handlers.remove(existing_handler)
|
||||
break
|
||||
|
||||
def attach_to_session(self) -> None:
|
||||
"""Attach watchdog to its browser session and start monitoring.
|
||||
|
||||
This method handles event listener registration. The watchdog is already
|
||||
bound to a browser session via self.browser_session from initialization.
|
||||
"""
|
||||
# Register event handlers automatically based on method names
|
||||
assert self.browser_session is not None, 'Root CDP client not initialized - browser may not be connected yet'
|
||||
|
||||
from browser_use.browser import events
|
||||
|
||||
event_classes = {}
|
||||
for name in dir(events):
|
||||
obj = getattr(events, name)
|
||||
if inspect.isclass(obj) and issubclass(obj, BaseEvent) and obj is not BaseEvent:
|
||||
event_classes[name] = obj
|
||||
|
||||
# Find all handler methods (on_EventName)
|
||||
registered_events = set()
|
||||
for method_name in dir(self):
|
||||
if method_name.startswith('on_') and callable(getattr(self, method_name)):
|
||||
# Extract event name from method name (on_EventName -> EventName)
|
||||
event_name = method_name[3:] # Remove 'on_' prefix
|
||||
|
||||
if event_name in event_classes:
|
||||
event_class = event_classes[event_name]
|
||||
|
||||
# ASSERTION: If LISTENS_TO is defined, enforce it
|
||||
if self.LISTENS_TO:
|
||||
assert event_class in self.LISTENS_TO, (
|
||||
f'[{self.__class__.__name__}] Handler {method_name} listens to {event_name} '
|
||||
f'but {event_name} is not declared in LISTENS_TO: {[e.__name__ for e in self.LISTENS_TO]}'
|
||||
)
|
||||
|
||||
handler = getattr(self, method_name)
|
||||
|
||||
# Use the static helper to attach the handler
|
||||
self.attach_handler_to_session(self.browser_session, event_class, handler)
|
||||
registered_events.add(event_class)
|
||||
|
||||
# ASSERTION: If LISTENS_TO is defined, ensure all declared events have handlers
|
||||
if self.LISTENS_TO:
|
||||
missing_handlers = set(self.LISTENS_TO) - registered_events
|
||||
if missing_handlers:
|
||||
missing_names = [e.__name__ for e in missing_handlers]
|
||||
self.logger.warning(
|
||||
f'[{self.__class__.__name__}] LISTENS_TO declares {missing_names} '
|
||||
f'but no handlers found (missing on_{"_, on_".join(missing_names)} methods)'
|
||||
)
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Clean up any running tasks during garbage collection."""
|
||||
|
||||
# A BIT OF MAGIC: Cancel any private attributes that look like asyncio tasks
|
||||
try:
|
||||
for attr_name in dir(self):
|
||||
# e.g. _browser_crash_watcher_task = asyncio.Task
|
||||
if attr_name.startswith('_') and attr_name.endswith('_task'):
|
||||
try:
|
||||
task = getattr(self, attr_name)
|
||||
if hasattr(task, 'cancel') and callable(task.cancel) and not task.done():
|
||||
task.cancel()
|
||||
# self.logger.debug(f'[{self.__class__.__name__}] Cancelled {attr_name} during cleanup')
|
||||
except Exception:
|
||||
pass # Ignore errors during cleanup
|
||||
|
||||
# e.g. _cdp_download_tasks = WeakSet[asyncio.Task] or list[asyncio.Task]
|
||||
if attr_name.startswith('_') and attr_name.endswith('_tasks') and isinstance(getattr(self, attr_name), Iterable):
|
||||
for task in getattr(self, attr_name):
|
||||
try:
|
||||
if hasattr(task, 'cancel') and callable(task.cancel) and not task.done():
|
||||
task.cancel()
|
||||
# self.logger.debug(f'[{self.__class__.__name__}] Cancelled {attr_name} during cleanup')
|
||||
except Exception:
|
||||
pass # Ignore errors during cleanup
|
||||
except Exception as e:
|
||||
from browser_use.utils import logger
|
||||
|
||||
logger.error(f'⚠️ Error during BrowserSession {self.__class__.__name__} garbage collection __del__(): {type(e)}: {e}')
|
||||
@@ -0,0 +1,259 @@
|
||||
"""About:blank watchdog for managing about:blank tabs with DVD screensaver."""
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.target import TargetID
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from browser_use.browser.events import (
|
||||
AboutBlankDVDScreensaverShownEvent,
|
||||
BrowserStopEvent,
|
||||
BrowserStoppedEvent,
|
||||
CloseTabEvent,
|
||||
NavigateToUrlEvent,
|
||||
TabClosedEvent,
|
||||
TabCreatedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class AboutBlankWatchdog(BaseWatchdog):
|
||||
"""Ensures there's always exactly one about:blank tab with DVD screensaver."""
|
||||
|
||||
# Event contracts
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
||||
BrowserStopEvent,
|
||||
BrowserStoppedEvent,
|
||||
TabCreatedEvent,
|
||||
TabClosedEvent,
|
||||
]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = [
|
||||
NavigateToUrlEvent,
|
||||
CloseTabEvent,
|
||||
AboutBlankDVDScreensaverShownEvent,
|
||||
]
|
||||
|
||||
_stopping: bool = PrivateAttr(default=False)
|
||||
|
||||
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
||||
"""Handle browser stop request - stop creating new tabs."""
|
||||
# logger.info('[AboutBlankWatchdog] Browser stop requested, stopping tab creation')
|
||||
self._stopping = True
|
||||
|
||||
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
|
||||
"""Handle browser stopped event."""
|
||||
# logger.info('[AboutBlankWatchdog] Browser stopped')
|
||||
self._stopping = True
|
||||
|
||||
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
||||
"""Check tabs when a new tab is created."""
|
||||
# logger.debug(f'[AboutBlankWatchdog] ➕ New tab created: {event.url}')
|
||||
|
||||
# If an about:blank tab was created, show DVD screensaver on all about:blank tabs
|
||||
if event.url == 'about:blank':
|
||||
await self._show_dvd_screensaver_on_about_blank_tabs()
|
||||
|
||||
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
|
||||
"""Check tabs when a tab is closed and proactively create about:blank if needed."""
|
||||
# Don't create new tabs if browser is shutting down
|
||||
if self._stopping:
|
||||
return
|
||||
|
||||
# Don't attempt CDP operations if the WebSocket is dead — dispatching
|
||||
# NavigateToUrlEvent on a broken connection will hang until timeout
|
||||
if not self.browser_session.is_cdp_connected:
|
||||
self.logger.debug('[AboutBlankWatchdog] CDP not connected, skipping tab recovery')
|
||||
return
|
||||
|
||||
# Check if we're about to close the last tab (event happens BEFORE tab closes)
|
||||
# Use _cdp_get_all_pages for quick check without fetching titles
|
||||
page_targets = await self.browser_session._cdp_get_all_pages()
|
||||
if len(page_targets) < 1:
|
||||
self.logger.debug(
|
||||
'[AboutBlankWatchdog] Last tab closing, creating new about:blank tab to avoid closing entire browser'
|
||||
)
|
||||
# Create the animation tab since no tabs should remain
|
||||
navigate_event = self.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
|
||||
await navigate_event
|
||||
# Show DVD screensaver on the new tab
|
||||
await self._show_dvd_screensaver_on_about_blank_tabs()
|
||||
else:
|
||||
# Multiple tabs exist, check after close
|
||||
await self._check_and_ensure_about_blank_tab()
|
||||
|
||||
async def attach_to_target(self, target_id: TargetID) -> None:
|
||||
"""AboutBlankWatchdog doesn't monitor individual targets."""
|
||||
pass
|
||||
|
||||
async def _check_and_ensure_about_blank_tab(self) -> None:
|
||||
"""Check current tabs and ensure exactly one about:blank tab with animation exists."""
|
||||
try:
|
||||
if not self.browser_session.is_cdp_connected:
|
||||
return
|
||||
|
||||
# For quick checks, just get page targets without titles to reduce noise
|
||||
page_targets = await self.browser_session._cdp_get_all_pages()
|
||||
|
||||
# If no tabs exist at all, create one to keep browser alive
|
||||
if len(page_targets) == 0:
|
||||
# Only create a new tab if there are no tabs at all
|
||||
self.logger.debug('[AboutBlankWatchdog] No tabs exist, creating new about:blank DVD screensaver tab')
|
||||
navigate_event = self.event_bus.dispatch(NavigateToUrlEvent(url='about:blank', new_tab=True))
|
||||
await navigate_event
|
||||
# Show DVD screensaver on the new tab
|
||||
await self._show_dvd_screensaver_on_about_blank_tabs()
|
||||
# Otherwise there are tabs, don't create new ones to avoid interfering
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'[AboutBlankWatchdog] Error ensuring about:blank tab: {e}')
|
||||
|
||||
async def _show_dvd_screensaver_on_about_blank_tabs(self) -> None:
|
||||
"""Show DVD screensaver on all about:blank pages only."""
|
||||
try:
|
||||
# Get just the page targets without expensive title fetching
|
||||
page_targets = await self.browser_session._cdp_get_all_pages()
|
||||
browser_session_label = str(self.browser_session.id)[-4:]
|
||||
|
||||
for page_target in page_targets:
|
||||
target_id = page_target['targetId']
|
||||
url = page_target['url']
|
||||
|
||||
# Only target about:blank pages specifically
|
||||
if url == 'about:blank':
|
||||
await self._show_dvd_screensaver_loading_animation_cdp(target_id, browser_session_label)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'[AboutBlankWatchdog] Error showing DVD screensaver: {e}')
|
||||
|
||||
async def _show_dvd_screensaver_loading_animation_cdp(self, target_id: TargetID, browser_session_label: str) -> None:
|
||||
"""
|
||||
Injects a DVD screensaver-style bouncing logo loading animation overlay into the target using CDP.
|
||||
This is used to visually indicate that the browser is setting up or waiting.
|
||||
"""
|
||||
try:
|
||||
# Create temporary session for this target without switching focus
|
||||
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
|
||||
|
||||
# Inject the DVD screensaver script (from main branch with idempotency added)
|
||||
script = f"""
|
||||
(function(browser_session_label) {{
|
||||
// Idempotency check
|
||||
if (window.__dvdAnimationRunning) {{
|
||||
return; // Already running, don't add another
|
||||
}}
|
||||
window.__dvdAnimationRunning = true;
|
||||
|
||||
// Ensure document.body exists before proceeding
|
||||
if (!document.body) {{
|
||||
// Try again after DOM is ready
|
||||
window.__dvdAnimationRunning = false; // Reset flag to retry
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', () => arguments.callee(browser_session_label));
|
||||
}}
|
||||
return;
|
||||
}}
|
||||
|
||||
const animated_title = `Starting agent ${{browser_session_label}}...`;
|
||||
if (document.title === animated_title) {{
|
||||
return; // already run on this tab, dont run again
|
||||
}}
|
||||
document.title = animated_title;
|
||||
|
||||
// Create the main overlay
|
||||
const loadingOverlay = document.createElement('div');
|
||||
loadingOverlay.id = 'pretty-loading-animation';
|
||||
loadingOverlay.style.position = 'fixed';
|
||||
loadingOverlay.style.top = '0';
|
||||
loadingOverlay.style.left = '0';
|
||||
loadingOverlay.style.width = '100vw';
|
||||
loadingOverlay.style.height = '100vh';
|
||||
loadingOverlay.style.background = '#000';
|
||||
loadingOverlay.style.zIndex = '99999';
|
||||
loadingOverlay.style.overflow = 'hidden';
|
||||
|
||||
// Create the image element
|
||||
const img = document.createElement('img');
|
||||
img.src = 'https://cf.browser-use.com/logo.svg';
|
||||
img.alt = 'Browser-Use';
|
||||
img.style.width = '200px';
|
||||
img.style.height = 'auto';
|
||||
img.style.position = 'absolute';
|
||||
img.style.left = '0px';
|
||||
img.style.top = '0px';
|
||||
img.style.zIndex = '2';
|
||||
img.style.opacity = '0.8';
|
||||
|
||||
loadingOverlay.appendChild(img);
|
||||
document.body.appendChild(loadingOverlay);
|
||||
|
||||
// DVD screensaver bounce logic
|
||||
let x = Math.random() * (window.innerWidth - 300);
|
||||
let y = Math.random() * (window.innerHeight - 300);
|
||||
let dx = 1.2 + Math.random() * 0.4; // px per frame
|
||||
let dy = 1.2 + Math.random() * 0.4;
|
||||
// Randomize direction
|
||||
if (Math.random() > 0.5) dx = -dx;
|
||||
if (Math.random() > 0.5) dy = -dy;
|
||||
|
||||
function animate() {{
|
||||
const imgWidth = img.offsetWidth || 300;
|
||||
const imgHeight = img.offsetHeight || 300;
|
||||
x += dx;
|
||||
y += dy;
|
||||
|
||||
if (x <= 0) {{
|
||||
x = 0;
|
||||
dx = Math.abs(dx);
|
||||
}} else if (x + imgWidth >= window.innerWidth) {{
|
||||
x = window.innerWidth - imgWidth;
|
||||
dx = -Math.abs(dx);
|
||||
}}
|
||||
if (y <= 0) {{
|
||||
y = 0;
|
||||
dy = Math.abs(dy);
|
||||
}} else if (y + imgHeight >= window.innerHeight) {{
|
||||
y = window.innerHeight - imgHeight;
|
||||
dy = -Math.abs(dy);
|
||||
}}
|
||||
|
||||
img.style.left = `${{x}}px`;
|
||||
img.style.top = `${{y}}px`;
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}}
|
||||
animate();
|
||||
|
||||
// Responsive: update bounds on resize
|
||||
window.addEventListener('resize', () => {{
|
||||
x = Math.min(x, window.innerWidth - img.offsetWidth);
|
||||
y = Math.min(y, window.innerHeight - img.offsetHeight);
|
||||
}});
|
||||
|
||||
// Add a little CSS for smoothness
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#pretty-loading-animation {{
|
||||
/*backdrop-filter: blur(2px) brightness(0.9);*/
|
||||
}}
|
||||
#pretty-loading-animation img {{
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}})('{browser_session_label}');
|
||||
"""
|
||||
|
||||
await temp_session.cdp_client.send.Runtime.evaluate(params={'expression': script}, session_id=temp_session.session_id)
|
||||
|
||||
# No need to detach - session is cached
|
||||
|
||||
# Dispatch event
|
||||
self.event_bus.dispatch(AboutBlankDVDScreensaverShownEvent(target_id=target_id))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'[AboutBlankWatchdog] Error injecting DVD screensaver: {e}')
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Captcha solver watchdog — monitors captcha events from the browser proxy.
|
||||
|
||||
Listens for BrowserUse.captchaSolverStarted/Finished CDP events and exposes a
|
||||
wait_if_captcha_solving() method that the agent step loop uses to block until
|
||||
a captcha is resolved (with a configurable timeout).
|
||||
|
||||
NOTE: Only a single captcha solve is tracked at a time. If multiple captchas
|
||||
overlap (e.g. rapid successive navigations), only the latest one is tracked and
|
||||
earlier in-flight waits may return prematurely.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.browseruse.events import CaptchaSolverFinishedEvent as CDPCaptchaSolverFinishedEvent
|
||||
from cdp_use.cdp.browseruse.events import CaptchaSolverStartedEvent as CDPCaptchaSolverStartedEvent
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserConnectedEvent,
|
||||
BrowserStoppedEvent,
|
||||
CaptchaSolverFinishedEvent,
|
||||
CaptchaSolverStartedEvent,
|
||||
_get_timeout,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
CaptchaResultType = Literal['success', 'failed', 'timeout', 'unknown']
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptchaWaitResult:
|
||||
"""Result returned by wait_if_captcha_solving() when the agent had to wait."""
|
||||
|
||||
waited: bool
|
||||
vendor: str
|
||||
url: str
|
||||
duration_ms: int
|
||||
result: CaptchaResultType
|
||||
|
||||
|
||||
class CaptchaWatchdog(BaseWatchdog):
|
||||
"""Monitors captcha solver events from the browser proxy.
|
||||
|
||||
When the proxy detects a CAPTCHA and starts solving it, a CDP event
|
||||
``BrowserUse.captchaSolverStarted`` is sent over the WebSocket. This
|
||||
watchdog catches that event and blocks the agent's step loop (via
|
||||
``wait_if_captcha_solving``) until ``BrowserUse.captchaSolverFinished``
|
||||
arrives or the configurable timeout expires.
|
||||
"""
|
||||
|
||||
# Event contracts
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
||||
BrowserConnectedEvent,
|
||||
BrowserStoppedEvent,
|
||||
]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = [
|
||||
CaptchaSolverStartedEvent,
|
||||
CaptchaSolverFinishedEvent,
|
||||
]
|
||||
|
||||
# --- private state ---
|
||||
_captcha_solving: bool = PrivateAttr(default=False)
|
||||
_captcha_solved_event: asyncio.Event = PrivateAttr(default_factory=asyncio.Event)
|
||||
_captcha_info: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
_captcha_result: CaptchaResultType = PrivateAttr(default='unknown')
|
||||
_captcha_duration_ms: int = PrivateAttr(default=0)
|
||||
_cdp_handlers_registered: bool = PrivateAttr(default=False)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
# Start in "not blocked" state so callers never wait when there is no captcha.
|
||||
self._captcha_solved_event.set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
||||
"""Register CDP event handlers for BrowserUse captcha solver events."""
|
||||
if self._cdp_handlers_registered:
|
||||
self.logger.debug('CaptchaWatchdog: CDP handlers already registered, skipping')
|
||||
return
|
||||
|
||||
cdp_client = self.browser_session.cdp_client
|
||||
|
||||
def _on_captcha_started(event_data: CDPCaptchaSolverStartedEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
self._captcha_solving = True
|
||||
self._captcha_result = 'unknown'
|
||||
self._captcha_duration_ms = 0
|
||||
self._captcha_info = {
|
||||
'vendor': event_data.get('vendor', 'unknown'),
|
||||
'url': event_data.get('url', ''),
|
||||
'targetId': event_data.get('targetId', ''),
|
||||
'startedAt': event_data.get('startedAt', 0),
|
||||
}
|
||||
# Block any waiter
|
||||
self._captcha_solved_event.clear()
|
||||
|
||||
vendor = self._captcha_info['vendor']
|
||||
url = self._captcha_info['url']
|
||||
self.logger.info(f'🔒 Captcha solving started: {vendor} on {url}')
|
||||
|
||||
self.event_bus.dispatch(
|
||||
CaptchaSolverStartedEvent(
|
||||
target_id=event_data.get('targetId', ''),
|
||||
vendor=vendor,
|
||||
url=url,
|
||||
started_at=event_data.get('startedAt', 0),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception('Error handling captchaSolverStarted CDP event')
|
||||
# Ensure consistent state: unblock any waiter
|
||||
self._captcha_solving = False
|
||||
self._captcha_solved_event.set()
|
||||
|
||||
def _on_captcha_finished(event_data: CDPCaptchaSolverFinishedEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
success = event_data.get('success', False)
|
||||
self._captcha_solving = False
|
||||
self._captcha_duration_ms = event_data.get('durationMs', 0)
|
||||
self._captcha_result = 'success' if success else 'failed'
|
||||
|
||||
vendor = event_data.get('vendor', self._captcha_info.get('vendor', 'unknown'))
|
||||
url = event_data.get('url', self._captcha_info.get('url', ''))
|
||||
duration_s = self._captcha_duration_ms / 1000
|
||||
|
||||
self.logger.info(f'🔓 Captcha solving finished: {self._captcha_result} — {vendor} on {url} ({duration_s:.1f}s)')
|
||||
|
||||
# Unblock any waiter
|
||||
self._captcha_solved_event.set()
|
||||
|
||||
self.event_bus.dispatch(
|
||||
CaptchaSolverFinishedEvent(
|
||||
target_id=event_data.get('targetId', ''),
|
||||
vendor=vendor,
|
||||
url=url,
|
||||
duration_ms=self._captcha_duration_ms,
|
||||
finished_at=event_data.get('finishedAt', 0),
|
||||
success=success,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception('Error handling captchaSolverFinished CDP event')
|
||||
# Ensure consistent state: unblock any waiter
|
||||
self._captcha_solving = False
|
||||
self._captcha_solved_event.set()
|
||||
|
||||
cdp_client.register.BrowserUse.captchaSolverStarted(_on_captcha_started)
|
||||
cdp_client.register.BrowserUse.captchaSolverFinished(_on_captcha_finished)
|
||||
self._cdp_handlers_registered = True
|
||||
self.logger.debug('🔒 CaptchaWatchdog: registered CDP event handlers for BrowserUse captcha events')
|
||||
|
||||
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
|
||||
"""Clear captcha state when the browser disconnects so nothing hangs."""
|
||||
self._captcha_solving = False
|
||||
self._captcha_result = 'unknown'
|
||||
self._captcha_duration_ms = 0
|
||||
self._captcha_info = {}
|
||||
self._captcha_solved_event.set()
|
||||
self._cdp_handlers_registered = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def wait_if_captcha_solving(self, timeout: float | None = None) -> CaptchaWaitResult | None:
|
||||
"""Wait if a captcha is currently being solved.
|
||||
|
||||
Returns:
|
||||
``None`` if no captcha was in progress.
|
||||
A ``CaptchaWaitResult`` with the outcome otherwise.
|
||||
"""
|
||||
if not self._captcha_solving:
|
||||
return None
|
||||
|
||||
if timeout is None:
|
||||
timeout = _get_timeout('TIMEOUT_CaptchaSolverWait', 120.0)
|
||||
assert timeout is not None
|
||||
vendor = self._captcha_info.get('vendor', 'unknown')
|
||||
url = self._captcha_info.get('url', '')
|
||||
self.logger.info(f'⏳ Waiting for {vendor} captcha to be solved on {url} (timeout={timeout}s)...')
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._captcha_solved_event.wait(), timeout=timeout)
|
||||
return CaptchaWaitResult(
|
||||
waited=True,
|
||||
vendor=vendor,
|
||||
url=url,
|
||||
duration_ms=self._captcha_duration_ms,
|
||||
result=self._captcha_result,
|
||||
)
|
||||
except TimeoutError:
|
||||
# Timed out — unblock and report
|
||||
self._captcha_solving = False
|
||||
self._captcha_solved_event.set()
|
||||
self.logger.warning(f'⏰ Captcha wait timed out after {timeout}s for {vendor} on {url}')
|
||||
return CaptchaWaitResult(
|
||||
waited=True,
|
||||
vendor=vendor,
|
||||
url=url,
|
||||
duration_ms=int(timeout * 1000),
|
||||
result='timeout',
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Browser watchdog for monitoring crashes and network timeouts using CDP."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import psutil
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.target import SessionID, TargetID
|
||||
from cdp_use.cdp.target.events import TargetCrashedEvent
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserConnectedEvent,
|
||||
BrowserErrorEvent,
|
||||
BrowserStoppedEvent,
|
||||
TabClosedEvent,
|
||||
TabCreatedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
from browser_use.utils import create_task_with_error_handling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class NetworkRequestTracker:
|
||||
"""Tracks ongoing network requests."""
|
||||
|
||||
def __init__(self, request_id: str, start_time: float, url: str, method: str, resource_type: str | None = None):
|
||||
self.request_id = request_id
|
||||
self.start_time = start_time
|
||||
self.url = url
|
||||
self.method = method
|
||||
self.resource_type = resource_type
|
||||
|
||||
|
||||
class CrashWatchdog(BaseWatchdog):
|
||||
"""Monitors browser health for crashes and network timeouts using CDP."""
|
||||
|
||||
# Event contracts
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
||||
BrowserConnectedEvent,
|
||||
BrowserStoppedEvent,
|
||||
TabCreatedEvent,
|
||||
TabClosedEvent,
|
||||
]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = [BrowserErrorEvent]
|
||||
|
||||
# Configuration
|
||||
network_timeout_seconds: float = Field(default=10.0)
|
||||
check_interval_seconds: float = Field(default=5.0) # Reduced frequency to reduce noise
|
||||
|
||||
# Private state
|
||||
_active_requests: dict[str, NetworkRequestTracker] = PrivateAttr(default_factory=dict)
|
||||
_monitoring_task: asyncio.Task | None = PrivateAttr(default=None)
|
||||
_last_responsive_checks: dict[str, float] = PrivateAttr(default_factory=dict) # target_url -> timestamp
|
||||
_cdp_event_tasks: set[asyncio.Task] = PrivateAttr(default_factory=set) # Track CDP event handler tasks
|
||||
_targets_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track targets that already have event listeners
|
||||
|
||||
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
||||
"""Start monitoring when browser is connected."""
|
||||
# logger.debug('[CrashWatchdog] Browser connected event received, beginning monitoring')
|
||||
|
||||
create_task_with_error_handling(
|
||||
self._start_monitoring(), name='start_crash_monitoring', logger_instance=self.logger, suppress_exceptions=True
|
||||
)
|
||||
# logger.debug(f'[CrashWatchdog] Monitoring task started: {self._monitoring_task and not self._monitoring_task.done()}')
|
||||
|
||||
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
|
||||
"""Stop monitoring when browser stops."""
|
||||
# logger.debug('[CrashWatchdog] Browser stopped, ending monitoring')
|
||||
await self._stop_monitoring()
|
||||
|
||||
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
||||
"""Attach to new tab."""
|
||||
assert self.browser_session.agent_focus_target_id is not None, 'No current target ID'
|
||||
await self.attach_to_target(self.browser_session.agent_focus_target_id)
|
||||
|
||||
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
|
||||
"""Clean up tracking when tab closes."""
|
||||
# Remove target from listener tracking to prevent memory leak
|
||||
if event.target_id in self._targets_with_listeners:
|
||||
self._targets_with_listeners.discard(event.target_id)
|
||||
self.logger.debug(f'[CrashWatchdog] Removed target {event.target_id[:8]}... from monitoring')
|
||||
|
||||
async def attach_to_target(self, target_id: TargetID) -> None:
|
||||
"""Set up crash monitoring for a specific target using CDP."""
|
||||
try:
|
||||
# Check if we already have listeners for this target
|
||||
if target_id in self._targets_with_listeners:
|
||||
self.logger.debug(f'[CrashWatchdog] Event listeners already exist for target: {target_id[:8]}...')
|
||||
return
|
||||
|
||||
# Create temporary session for monitoring without switching focus
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
|
||||
|
||||
# Register crash event handler
|
||||
def on_target_crashed(event: TargetCrashedEvent, session_id: SessionID | None = None):
|
||||
# Create and track the task
|
||||
task = create_task_with_error_handling(
|
||||
self._on_target_crash_cdp(target_id),
|
||||
name='handle_target_crash',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
self._cdp_event_tasks.add(task)
|
||||
# Remove from set when done
|
||||
task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
|
||||
|
||||
cdp_session.cdp_client.register.Target.targetCrashed(on_target_crashed)
|
||||
|
||||
# Track that we've added listeners to this target
|
||||
self._targets_with_listeners.add(target_id)
|
||||
|
||||
target = self.browser_session.session_manager.get_target(target_id)
|
||||
if target:
|
||||
self.logger.debug(f'[CrashWatchdog] Added target to monitoring: {target.url}')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'[CrashWatchdog] Failed to attach to target {target_id}: {e}')
|
||||
|
||||
async def _on_request_cdp(self, event: dict) -> None:
|
||||
"""Track new network request from CDP event."""
|
||||
request_id = event.get('requestId', '')
|
||||
request = event.get('request', {})
|
||||
|
||||
self._active_requests[request_id] = NetworkRequestTracker(
|
||||
request_id=request_id,
|
||||
start_time=time.time(),
|
||||
url=request.get('url', ''),
|
||||
method=request.get('method', ''),
|
||||
resource_type=event.get('type'),
|
||||
)
|
||||
# logger.debug(f'[CrashWatchdog] Tracking request: {request.get("method", "")} {request.get("url", "")[:50]}...')
|
||||
|
||||
def _on_response_cdp(self, event: dict) -> None:
|
||||
"""Remove request from tracking on response."""
|
||||
request_id = event.get('requestId', '')
|
||||
if request_id in self._active_requests:
|
||||
elapsed = time.time() - self._active_requests[request_id].start_time
|
||||
response = event.get('response', {})
|
||||
self.logger.debug(f'[CrashWatchdog] Request completed in {elapsed:.2f}s: {response.get("url", "")[:50]}...')
|
||||
# Don't remove yet - wait for loadingFinished
|
||||
|
||||
def _on_request_failed_cdp(self, event: dict) -> None:
|
||||
"""Remove request from tracking on failure."""
|
||||
request_id = event.get('requestId', '')
|
||||
if request_id in self._active_requests:
|
||||
elapsed = time.time() - self._active_requests[request_id].start_time
|
||||
self.logger.debug(
|
||||
f'[CrashWatchdog] Request failed after {elapsed:.2f}s: {self._active_requests[request_id].url[:50]}...'
|
||||
)
|
||||
del self._active_requests[request_id]
|
||||
|
||||
def _on_request_finished_cdp(self, event: dict) -> None:
|
||||
"""Remove request from tracking when loading is finished."""
|
||||
request_id = event.get('requestId', '')
|
||||
self._active_requests.pop(request_id, None)
|
||||
|
||||
async def _on_target_crash_cdp(self, target_id: TargetID) -> None:
|
||||
"""Handle target crash detected via CDP."""
|
||||
self.logger.debug(f'[CrashWatchdog] Target crashed: {target_id[:8]}..., waiting for detach event')
|
||||
|
||||
target = self.browser_session.session_manager.get_target(target_id)
|
||||
|
||||
is_agent_focus = (
|
||||
target
|
||||
and self.browser_session.agent_focus_target_id
|
||||
and target.target_id == self.browser_session.agent_focus_target_id
|
||||
)
|
||||
|
||||
if is_agent_focus:
|
||||
self.logger.error(f'[CrashWatchdog] 💥 Agent focus tab crashed: {target.url} (SessionManager will auto-recover)')
|
||||
|
||||
# Emit browser error event
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='TargetCrash',
|
||||
message=f'Target crashed: {target_id}',
|
||||
details={
|
||||
'url': target.url if target else None,
|
||||
'target_id': target_id,
|
||||
'was_agent_focus': is_agent_focus,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def _start_monitoring(self) -> None:
|
||||
"""Start the monitoring loop."""
|
||||
assert self.browser_session.cdp_client is not None, 'Root CDP client not initialized - browser may not be connected yet'
|
||||
|
||||
if self._monitoring_task and not self._monitoring_task.done():
|
||||
# logger.info('[CrashWatchdog] Monitoring already running')
|
||||
return
|
||||
|
||||
self._monitoring_task = create_task_with_error_handling(
|
||||
self._monitoring_loop(), name='crash_monitoring_loop', logger_instance=self.logger, suppress_exceptions=True
|
||||
)
|
||||
# logger.debug('[CrashWatchdog] Monitoring loop created and started')
|
||||
|
||||
async def _stop_monitoring(self) -> None:
|
||||
"""Stop the monitoring loop and clean up all tracking."""
|
||||
if self._monitoring_task and not self._monitoring_task.done():
|
||||
self._monitoring_task.cancel()
|
||||
try:
|
||||
await self._monitoring_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.logger.debug('[CrashWatchdog] Monitoring loop stopped')
|
||||
|
||||
# Cancel all CDP event handler tasks
|
||||
for task in list(self._cdp_event_tasks):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
# Wait for all tasks to complete cancellation
|
||||
if self._cdp_event_tasks:
|
||||
await asyncio.gather(*self._cdp_event_tasks, return_exceptions=True)
|
||||
self._cdp_event_tasks.clear()
|
||||
|
||||
# Clear all tracking
|
||||
self._active_requests.clear()
|
||||
self._targets_with_listeners.clear()
|
||||
self._last_responsive_checks.clear()
|
||||
|
||||
async def _monitoring_loop(self) -> None:
|
||||
"""Main monitoring loop."""
|
||||
await asyncio.sleep(10) # give browser time to start up and load the first page after first LLM call
|
||||
while True:
|
||||
try:
|
||||
await self._check_network_timeouts()
|
||||
await self._check_browser_health()
|
||||
await asyncio.sleep(self.check_interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f'[CrashWatchdog] Error in monitoring loop: {e}')
|
||||
|
||||
async def _check_network_timeouts(self) -> None:
|
||||
"""Check for network requests exceeding timeout."""
|
||||
current_time = time.time()
|
||||
timed_out_requests = []
|
||||
|
||||
# Debug logging
|
||||
if self._active_requests:
|
||||
self.logger.debug(
|
||||
f'[CrashWatchdog] Checking {len(self._active_requests)} active requests for timeouts (threshold: {self.network_timeout_seconds}s)'
|
||||
)
|
||||
|
||||
for request_id, tracker in self._active_requests.items():
|
||||
elapsed = current_time - tracker.start_time
|
||||
self.logger.debug(
|
||||
f'[CrashWatchdog] Request {tracker.url[:30]}... elapsed: {elapsed:.1f}s, timeout: {self.network_timeout_seconds}s'
|
||||
)
|
||||
if elapsed >= self.network_timeout_seconds:
|
||||
timed_out_requests.append((request_id, tracker))
|
||||
|
||||
# Emit events for timed out requests
|
||||
for request_id, tracker in timed_out_requests:
|
||||
self.logger.warning(
|
||||
f'[CrashWatchdog] Network request timeout after {self.network_timeout_seconds}s: '
|
||||
f'{tracker.method} {tracker.url[:100]}...'
|
||||
)
|
||||
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='NetworkTimeout',
|
||||
message=f'Network request timed out after {self.network_timeout_seconds}s',
|
||||
details={
|
||||
'url': tracker.url,
|
||||
'method': tracker.method,
|
||||
'resource_type': tracker.resource_type,
|
||||
'elapsed_seconds': current_time - tracker.start_time,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Remove from tracking
|
||||
del self._active_requests[request_id]
|
||||
|
||||
async def _check_browser_health(self) -> None:
|
||||
"""Check if browser and targets are still responsive."""
|
||||
|
||||
try:
|
||||
self.logger.debug(f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus_target_id}')
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
|
||||
for target in self.browser_session.session_manager.get_all_page_targets():
|
||||
if self._is_new_tab_page(target.url) and target.url != 'about:blank':
|
||||
self.logger.debug(f'[CrashWatchdog] Redirecting chrome://new-tab-page/ to about:blank {target.url}')
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target.target_id)
|
||||
await cdp_session.cdp_client.send.Page.navigate(
|
||||
params={'url': 'about:blank'}, session_id=cdp_session.session_id
|
||||
)
|
||||
|
||||
# Quick ping to check if session is alive
|
||||
self.logger.debug(f'[CrashWatchdog] Attempting to run simple JS test expression in session {cdp_session} 1+1')
|
||||
await asyncio.wait_for(
|
||||
cdp_session.cdp_client.send.Runtime.evaluate(params={'expression': '1+1'}, session_id=cdp_session.session_id),
|
||||
timeout=1.0,
|
||||
)
|
||||
self.logger.debug(
|
||||
f'[CrashWatchdog] Browser health check passed for target {self.browser_session.agent_focus_target_id}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f'[CrashWatchdog] ❌ Crashed/unresponsive session detected for target {self.browser_session.agent_focus_target_id} '
|
||||
f'error: {type(e).__name__}: {e} (Chrome will send detach event, SessionManager will auto-recover)'
|
||||
)
|
||||
|
||||
# Check browser process if we have PID
|
||||
if self.browser_session._local_browser_watchdog and (proc := self.browser_session._local_browser_watchdog._subprocess):
|
||||
try:
|
||||
if proc.status() in (psutil.STATUS_ZOMBIE, psutil.STATUS_DEAD):
|
||||
self.logger.error(f'[CrashWatchdog] Browser process {proc.pid} has crashed')
|
||||
|
||||
# Browser process crashed - SessionManager will clean up via detach events
|
||||
# Just dispatch error event and stop monitoring
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='BrowserProcessCrashed',
|
||||
message=f'Browser process {proc.pid} has crashed',
|
||||
details={'pid': proc.pid, 'status': proc.status()},
|
||||
)
|
||||
)
|
||||
|
||||
self.logger.warning('[CrashWatchdog] Browser process dead - stopping health monitoring')
|
||||
await self._stop_monitoring()
|
||||
return
|
||||
except Exception:
|
||||
pass # psutil not available or process doesn't exist
|
||||
|
||||
@staticmethod
|
||||
def _is_new_tab_page(url: str) -> bool:
|
||||
"""Check if URL is a new tab page."""
|
||||
return url in ['about:blank', 'chrome://new-tab-page/', 'chrome://newtab/']
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,865 @@
|
||||
"""DOM watchdog for browser DOM tree management using CDP."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserErrorEvent,
|
||||
BrowserStateRequestEvent,
|
||||
ScreenshotEvent,
|
||||
TabCreatedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
from browser_use.dom.service import DomService
|
||||
from browser_use.dom.views import (
|
||||
EnhancedDOMTreeNode,
|
||||
SerializedDOMState,
|
||||
)
|
||||
from browser_use.observability import observe_debug
|
||||
from browser_use.utils import create_task_with_error_handling, time_execution_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_use.browser.views import BrowserStateSummary, NetworkRequest, PageInfo, PaginationButton
|
||||
|
||||
|
||||
class DOMWatchdog(BaseWatchdog):
|
||||
"""Handles DOM tree building, serialization, and element access via CDP.
|
||||
|
||||
This watchdog acts as a bridge between the event-driven browser session
|
||||
and the DomService implementation, maintaining cached state and providing
|
||||
helper methods for other watchdogs.
|
||||
"""
|
||||
|
||||
LISTENS_TO = [TabCreatedEvent, BrowserStateRequestEvent]
|
||||
EMITS = [BrowserErrorEvent]
|
||||
|
||||
# Public properties for other watchdogs
|
||||
selector_map: dict[int, EnhancedDOMTreeNode] | None = None
|
||||
current_dom_state: SerializedDOMState | None = None
|
||||
enhanced_dom_tree: EnhancedDOMTreeNode | None = None
|
||||
|
||||
# Internal DOM service
|
||||
_dom_service: DomService | None = None
|
||||
|
||||
# Network tracking - maps request_id to (url, start_time, method, resource_type)
|
||||
_pending_requests: dict[str, tuple[str, float, str, str | None]] = {}
|
||||
|
||||
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
||||
# self.logger.debug('Setting up init scripts in browser')
|
||||
return None
|
||||
|
||||
def _get_recent_events_str(self, limit: int = 10) -> str | None:
|
||||
"""Get the most recent events from the event bus as JSON.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of recent events to include
|
||||
|
||||
Returns:
|
||||
JSON string of recent events or None if not available
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
# Get all events from history, sorted by creation time (most recent first)
|
||||
all_events = sorted(
|
||||
self.browser_session.event_bus.event_history.values(), key=lambda e: e.event_created_at.timestamp(), reverse=True
|
||||
)
|
||||
|
||||
# Take the most recent events and create JSON-serializable data
|
||||
recent_events_data = []
|
||||
for event in all_events[:limit]:
|
||||
event_data = {
|
||||
'event_type': event.event_type,
|
||||
'timestamp': event.event_created_at.isoformat(),
|
||||
}
|
||||
# Add specific fields for certain event types
|
||||
if hasattr(event, 'url'):
|
||||
event_data['url'] = getattr(event, 'url')
|
||||
if hasattr(event, 'error_message'):
|
||||
event_data['error_message'] = getattr(event, 'error_message')
|
||||
if hasattr(event, 'target_id'):
|
||||
event_data['target_id'] = getattr(event, 'target_id')
|
||||
recent_events_data.append(event_data)
|
||||
|
||||
return json.dumps(recent_events_data) # Return empty array if no events
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to get recent events: {e}')
|
||||
|
||||
return json.dumps([]) # Return empty JSON array on error
|
||||
|
||||
async def _get_pending_network_requests(self) -> list['NetworkRequest']:
|
||||
"""Get list of currently pending network requests.
|
||||
|
||||
Uses document.readyState and performance API to detect pending requests.
|
||||
Filters out ads, tracking, and other noise.
|
||||
|
||||
Returns:
|
||||
List of NetworkRequest objects representing currently loading resources
|
||||
"""
|
||||
from browser_use.browser.views import NetworkRequest
|
||||
|
||||
try:
|
||||
# get_or_create_cdp_session() now handles focus validation automatically
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(focus=True)
|
||||
|
||||
# Use performance API to get pending requests
|
||||
js_code = """
|
||||
(function() {
|
||||
const now = performance.now();
|
||||
const resources = performance.getEntriesByType('resource');
|
||||
const pending = [];
|
||||
|
||||
// Check document readyState
|
||||
const docLoading = document.readyState !== 'complete';
|
||||
|
||||
// Common ad/tracking domains and patterns to filter out
|
||||
const adDomains = [
|
||||
// Standard ad/tracking networks
|
||||
'doubleclick.net', 'googlesyndication.com', 'googletagmanager.com',
|
||||
'facebook.net', 'analytics', 'ads', 'tracking', 'pixel',
|
||||
'hotjar.com', 'clarity.ms', 'mixpanel.com', 'segment.com',
|
||||
// Analytics platforms
|
||||
'demdex.net', 'omtrdc.net', 'adobedtm.com', 'ensighten.com',
|
||||
'newrelic.com', 'nr-data.net', 'google-analytics.com',
|
||||
// Social media trackers
|
||||
'connect.facebook.net', 'platform.twitter.com', 'platform.linkedin.com',
|
||||
// CDN/image hosts (usually not critical for functionality)
|
||||
'.cloudfront.net/image/', '.akamaized.net/image/',
|
||||
// Common tracking paths
|
||||
'/tracker/', '/collector/', '/beacon/', '/telemetry/', '/log/',
|
||||
'/events/', '/eventBatch', '/track.', '/metrics/'
|
||||
];
|
||||
|
||||
// Get resources that are still loading (responseEnd is 0)
|
||||
let totalResourcesChecked = 0;
|
||||
let filteredByResponseEnd = 0;
|
||||
const allDomains = new Set();
|
||||
|
||||
for (const entry of resources) {
|
||||
totalResourcesChecked++;
|
||||
|
||||
// Track all domains from recent resources (for logging)
|
||||
try {
|
||||
const hostname = new URL(entry.name).hostname;
|
||||
if (hostname) allDomains.add(hostname);
|
||||
} catch (e) {}
|
||||
|
||||
if (entry.responseEnd === 0) {
|
||||
filteredByResponseEnd++;
|
||||
const url = entry.name;
|
||||
|
||||
// Filter out ads and tracking
|
||||
const isAd = adDomains.some(domain => url.includes(domain));
|
||||
if (isAd) continue;
|
||||
|
||||
// Filter out data: URLs and very long URLs (often inline resources)
|
||||
if (url.startsWith('data:') || url.length > 500) continue;
|
||||
|
||||
const loadingDuration = now - entry.startTime;
|
||||
|
||||
// Skip requests that have been loading for >10 seconds (likely stuck/polling)
|
||||
if (loadingDuration > 10000) continue;
|
||||
|
||||
const resourceType = entry.initiatorType || 'unknown';
|
||||
|
||||
// Filter out non-critical resources (images, fonts, icons) if loading >3 seconds
|
||||
const nonCriticalTypes = ['img', 'image', 'icon', 'font'];
|
||||
if (nonCriticalTypes.includes(resourceType) && loadingDuration > 3000) continue;
|
||||
|
||||
// Filter out image URLs even if type is unknown
|
||||
const isImageUrl = /\\.(jpg|jpeg|png|gif|webp|svg|ico)(\\?|$)/i.test(url);
|
||||
if (isImageUrl && loadingDuration > 3000) continue;
|
||||
|
||||
pending.push({
|
||||
url: url,
|
||||
method: 'GET',
|
||||
loading_duration_ms: Math.round(loadingDuration),
|
||||
resource_type: resourceType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pending_requests: pending,
|
||||
document_loading: docLoading,
|
||||
document_ready_state: document.readyState,
|
||||
debug: {
|
||||
total_resources: totalResourcesChecked,
|
||||
with_response_end_zero: filteredByResponseEnd,
|
||||
after_all_filters: pending.length,
|
||||
all_domains: Array.from(allDomains)
|
||||
}
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
result = await cdp_session.cdp_client.send.Runtime.evaluate(
|
||||
params={'expression': js_code, 'returnByValue': True}, session_id=cdp_session.session_id
|
||||
)
|
||||
|
||||
if result.get('result', {}).get('type') == 'object':
|
||||
data = result['result'].get('value', {})
|
||||
pending = data.get('pending_requests', [])
|
||||
doc_state = data.get('document_ready_state', 'unknown')
|
||||
doc_loading = data.get('document_loading', False)
|
||||
debug_info = data.get('debug', {})
|
||||
|
||||
# Get all domains that had recent activity (from JS)
|
||||
all_domains = debug_info.get('all_domains', [])
|
||||
all_domains_str = ', '.join(sorted(all_domains)[:5]) if all_domains else 'none'
|
||||
if len(all_domains) > 5:
|
||||
all_domains_str += f' +{len(all_domains) - 5} more'
|
||||
|
||||
# Debug logging
|
||||
self.logger.debug(
|
||||
f'🔍 Network check: document.readyState={doc_state}, loading={doc_loading}, '
|
||||
f'total_resources={debug_info.get("total_resources", 0)}, '
|
||||
f'responseEnd=0: {debug_info.get("with_response_end_zero", 0)}, '
|
||||
f'after_filters={len(pending)}, domains=[{all_domains_str}]'
|
||||
)
|
||||
|
||||
# Convert to NetworkRequest objects
|
||||
network_requests = []
|
||||
for req in pending[:20]: # Limit to 20 to avoid overwhelming the context
|
||||
network_requests.append(
|
||||
NetworkRequest(
|
||||
url=req['url'],
|
||||
method=req.get('method', 'GET'),
|
||||
loading_duration_ms=req.get('loading_duration_ms', 0.0),
|
||||
resource_type=req.get('resource_type'),
|
||||
)
|
||||
)
|
||||
|
||||
return network_requests
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to get pending network requests: {e}')
|
||||
|
||||
return []
|
||||
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='browser_state_request_event')
|
||||
async def on_BrowserStateRequestEvent(self, event: BrowserStateRequestEvent) -> 'BrowserStateSummary':
|
||||
"""Handle browser state request by coordinating DOM building and screenshot capture.
|
||||
|
||||
This is the main entry point for getting the complete browser state.
|
||||
|
||||
Args:
|
||||
event: The browser state request event with options
|
||||
|
||||
Returns:
|
||||
Complete BrowserStateSummary with DOM, screenshot, and target info
|
||||
"""
|
||||
from browser_use.browser.views import BrowserStateSummary, PageInfo
|
||||
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: STARTING browser state request')
|
||||
page_url = await self.browser_session.get_current_page_url()
|
||||
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got page URL: {page_url}')
|
||||
|
||||
# Get focused session for logging (validation already done by get_current_page_url)
|
||||
if self.browser_session.agent_focus_target_id:
|
||||
self.logger.debug(f'Current page URL: {page_url}, target_id: {self.browser_session.agent_focus_target_id}')
|
||||
|
||||
# check if we should skip DOM tree build for pointless pages
|
||||
not_a_meaningful_website = page_url.lower().split(':', 1)[0] not in ('http', 'https')
|
||||
|
||||
# Check for pending network requests BEFORE waiting (so we can see what's loading)
|
||||
# Timeout after 2s — on slow CI machines or heavy pages, this call can hang
|
||||
# for 15s+ eating into the 30s BrowserStateRequestEvent budget.
|
||||
pending_requests_before_wait = []
|
||||
if not not_a_meaningful_website:
|
||||
try:
|
||||
pending_requests_before_wait = await asyncio.wait_for(self._get_pending_network_requests(), timeout=2.0)
|
||||
if pending_requests_before_wait:
|
||||
self.logger.debug(f'🔍 Found {len(pending_requests_before_wait)} pending requests before stability wait')
|
||||
except TimeoutError:
|
||||
self.logger.debug('Pending network request check timed out (2s), skipping')
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to get pending requests before wait: {e}')
|
||||
pending_requests = pending_requests_before_wait
|
||||
# Wait for page stability using browser profile settings (main branch pattern)
|
||||
if not not_a_meaningful_website:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ⏳ Waiting for page stability...')
|
||||
try:
|
||||
if pending_requests_before_wait:
|
||||
# Reduced from 1s to 0.3s for faster DOM builds while still allowing critical resources to load
|
||||
await asyncio.sleep(0.3)
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Page stability complete')
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Network waiting failed: {e}, continuing anyway...'
|
||||
)
|
||||
|
||||
# Get tabs info once at the beginning for all paths
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting tabs info...')
|
||||
tabs_info = await self.browser_session.get_tabs()
|
||||
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got {len(tabs_info)} tabs')
|
||||
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Tabs info: {tabs_info}')
|
||||
|
||||
# Get viewport / scroll position info, remember changing scroll position should invalidate selector_map cache because it only includes visible elements
|
||||
# cdp_session = await self.browser_session.get_or_create_cdp_session(focus=True)
|
||||
# scroll_info = await cdp_session.cdp_client.send.Runtime.evaluate(
|
||||
# params={'expression': 'JSON.stringify({y: document.body.scrollTop, x: document.body.scrollLeft, width: document.documentElement.clientWidth, height: document.documentElement.clientHeight})'},
|
||||
# session_id=cdp_session.session_id,
|
||||
# )
|
||||
# self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got scroll info: {scroll_info["result"]}')
|
||||
|
||||
try:
|
||||
# Fast path for empty pages
|
||||
if not_a_meaningful_website:
|
||||
self.logger.debug(f'⚡ Skipping BuildDOMTree for empty target: {page_url}')
|
||||
self.logger.debug(f'📸 Not taking screenshot for empty page: {page_url} (non-http/https URL)')
|
||||
|
||||
# Create minimal DOM state
|
||||
content = SerializedDOMState(_root=None, selector_map={})
|
||||
|
||||
# Skip screenshot for empty pages
|
||||
screenshot_b64 = None
|
||||
|
||||
# Try to get page info from CDP, fall back to defaults if unavailable
|
||||
try:
|
||||
page_info = await self._get_page_info()
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to get page info from CDP for empty page: {e}, using fallback')
|
||||
# Use default viewport dimensions
|
||||
viewport = self.browser_session.browser_profile.viewport or {'width': 1280, 'height': 720}
|
||||
page_info = PageInfo(
|
||||
viewport_width=viewport['width'],
|
||||
viewport_height=viewport['height'],
|
||||
page_width=viewport['width'],
|
||||
page_height=viewport['height'],
|
||||
scroll_x=0,
|
||||
scroll_y=0,
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
pixels_left=0,
|
||||
pixels_right=0,
|
||||
)
|
||||
|
||||
return BrowserStateSummary(
|
||||
dom_state=content,
|
||||
url=page_url,
|
||||
title='Empty Tab',
|
||||
tabs=tabs_info,
|
||||
screenshot=screenshot_b64,
|
||||
page_info=page_info,
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
browser_errors=[],
|
||||
is_pdf_viewer=False,
|
||||
recent_events=self._get_recent_events_str() if event.include_recent_events else None,
|
||||
pending_network_requests=[], # Empty page has no pending requests
|
||||
pagination_buttons=[], # Empty page has no pagination
|
||||
closed_popup_messages=self.browser_session._closed_popup_messages.copy(),
|
||||
)
|
||||
|
||||
# Execute DOM building and screenshot capture in parallel
|
||||
dom_task = None
|
||||
screenshot_task = None
|
||||
|
||||
# Start DOM building task if requested
|
||||
if event.include_dom:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🌳 Starting DOM tree build task...')
|
||||
|
||||
previous_state = (
|
||||
self.browser_session._cached_browser_state_summary.dom_state
|
||||
if self.browser_session._cached_browser_state_summary
|
||||
else None
|
||||
)
|
||||
|
||||
dom_task = create_task_with_error_handling(
|
||||
self._build_dom_tree_without_highlights(previous_state),
|
||||
name='build_dom_tree',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
|
||||
# Start clean screenshot task if requested (without JS highlights)
|
||||
if event.include_screenshot:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Starting clean screenshot task...')
|
||||
screenshot_task = create_task_with_error_handling(
|
||||
self._capture_clean_screenshot(),
|
||||
name='capture_screenshot',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
|
||||
# Wait for both tasks to complete
|
||||
content = None
|
||||
screenshot_b64 = None
|
||||
|
||||
if dom_task:
|
||||
try:
|
||||
content = await dom_task
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ DOM tree build completed')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: DOM build failed: {e}, using minimal state')
|
||||
content = SerializedDOMState(_root=None, selector_map={})
|
||||
else:
|
||||
content = SerializedDOMState(_root=None, selector_map={})
|
||||
|
||||
if screenshot_task:
|
||||
try:
|
||||
screenshot_b64 = await screenshot_task
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Clean screenshot captured')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Clean screenshot failed: {e}')
|
||||
screenshot_b64 = None
|
||||
|
||||
# Add browser-side highlights for user visibility
|
||||
if content and content.selector_map and self.browser_session.browser_profile.dom_highlight_elements:
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🎨 Adding browser-side highlights...')
|
||||
await self.browser_session.add_highlights(content.selector_map)
|
||||
self.logger.debug(
|
||||
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Added browser highlights for {len(content.selector_map)} elements'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Browser highlighting failed: {e}')
|
||||
|
||||
# Ensure we have valid content
|
||||
if not content:
|
||||
content = SerializedDOMState(_root=None, selector_map={})
|
||||
|
||||
# Tabs info already fetched at the beginning
|
||||
|
||||
# Get target title safely
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting page title...')
|
||||
title = await asyncio.wait_for(self.browser_session.get_current_page_title(), timeout=1.0)
|
||||
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got title: {title}')
|
||||
except Exception as e:
|
||||
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Failed to get title: {e}')
|
||||
title = 'Page'
|
||||
|
||||
# Get comprehensive page info from CDP with timeout
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: Getting page info from CDP...')
|
||||
page_info = await asyncio.wait_for(self._get_page_info(), timeout=1.0)
|
||||
self.logger.debug(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Got page info from CDP: {page_info}')
|
||||
except Exception as e:
|
||||
self.logger.debug(
|
||||
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Failed to get page info from CDP: {e}, using fallback'
|
||||
)
|
||||
# Fallback to default viewport dimensions
|
||||
viewport = self.browser_session.browser_profile.viewport or {'width': 1280, 'height': 720}
|
||||
page_info = PageInfo(
|
||||
viewport_width=viewport['width'],
|
||||
viewport_height=viewport['height'],
|
||||
page_width=viewport['width'],
|
||||
page_height=viewport['height'],
|
||||
scroll_x=0,
|
||||
scroll_y=0,
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
pixels_left=0,
|
||||
pixels_right=0,
|
||||
)
|
||||
|
||||
# Check for PDF viewer
|
||||
is_pdf_viewer = page_url.endswith('.pdf') or '/pdf/' in page_url
|
||||
|
||||
# Detect pagination buttons from the DOM
|
||||
pagination_buttons_data = []
|
||||
if content and content.selector_map:
|
||||
pagination_buttons_data = self._detect_pagination_buttons(content.selector_map)
|
||||
|
||||
# Build and cache the browser state summary
|
||||
if screenshot_b64:
|
||||
self.logger.debug(
|
||||
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Creating BrowserStateSummary with screenshot, length: {len(screenshot_b64)}'
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
'🔍 DOMWatchdog.on_BrowserStateRequestEvent: 📸 Creating BrowserStateSummary WITHOUT screenshot'
|
||||
)
|
||||
|
||||
browser_state = BrowserStateSummary(
|
||||
dom_state=content,
|
||||
url=page_url,
|
||||
title=title,
|
||||
tabs=tabs_info,
|
||||
screenshot=screenshot_b64,
|
||||
page_info=page_info,
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
browser_errors=[],
|
||||
is_pdf_viewer=is_pdf_viewer,
|
||||
recent_events=self._get_recent_events_str() if event.include_recent_events else None,
|
||||
pending_network_requests=pending_requests,
|
||||
pagination_buttons=pagination_buttons_data,
|
||||
closed_popup_messages=self.browser_session._closed_popup_messages.copy(),
|
||||
)
|
||||
|
||||
# Cache the state
|
||||
self.browser_session._cached_browser_state_summary = browser_state
|
||||
|
||||
# Cache viewport size for coordinate conversion (if llm_screenshot_size is enabled)
|
||||
if page_info:
|
||||
self.browser_session._original_viewport_size = (page_info.viewport_width, page_info.viewport_height)
|
||||
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ COMPLETED - Returning browser state')
|
||||
return browser_state
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to get browser state: {e}')
|
||||
|
||||
# Return minimal recovery state
|
||||
return BrowserStateSummary(
|
||||
dom_state=SerializedDOMState(_root=None, selector_map={}),
|
||||
url=page_url if 'page_url' in locals() else '',
|
||||
title='Error',
|
||||
tabs=[],
|
||||
screenshot=None,
|
||||
page_info=PageInfo(
|
||||
viewport_width=1280,
|
||||
viewport_height=720,
|
||||
page_width=1280,
|
||||
page_height=720,
|
||||
scroll_x=0,
|
||||
scroll_y=0,
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
pixels_left=0,
|
||||
pixels_right=0,
|
||||
),
|
||||
pixels_above=0,
|
||||
pixels_below=0,
|
||||
browser_errors=[str(e)],
|
||||
is_pdf_viewer=False,
|
||||
recent_events=None,
|
||||
pending_network_requests=[], # Error state has no pending requests
|
||||
pagination_buttons=[], # Error state has no pagination
|
||||
closed_popup_messages=self.browser_session._closed_popup_messages.copy()
|
||||
if hasattr(self, 'browser_session') and self.browser_session is not None
|
||||
else [],
|
||||
)
|
||||
|
||||
@time_execution_async('build_dom_tree_without_highlights')
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='build_dom_tree_without_highlights')
|
||||
async def _build_dom_tree_without_highlights(self, previous_state: SerializedDOMState | None = None) -> SerializedDOMState:
|
||||
"""Build DOM tree without injecting JavaScript highlights (for parallel execution)."""
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: STARTING DOM tree build')
|
||||
|
||||
# Create or reuse DOM service
|
||||
if self._dom_service is None:
|
||||
self._dom_service = DomService(
|
||||
browser_session=self.browser_session,
|
||||
logger=self.logger,
|
||||
cross_origin_iframes=self.browser_session.browser_profile.cross_origin_iframes,
|
||||
paint_order_filtering=self.browser_session.browser_profile.paint_order_filtering,
|
||||
max_iframes=self.browser_session.browser_profile.max_iframes,
|
||||
max_iframe_depth=self.browser_session.browser_profile.max_iframe_depth,
|
||||
)
|
||||
|
||||
# Get serialized DOM tree using the service
|
||||
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: Calling DomService.get_serialized_dom_tree...')
|
||||
start = time.time()
|
||||
self.current_dom_state, self.enhanced_dom_tree, timing_info = await self._dom_service.get_serialized_dom_tree(
|
||||
previous_cached_state=previous_state,
|
||||
)
|
||||
end = time.time()
|
||||
total_time_ms = (end - start) * 1000
|
||||
self.logger.debug(
|
||||
'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ DomService.get_serialized_dom_tree completed'
|
||||
)
|
||||
|
||||
# Build hierarchical timing breakdown as single multi-line string
|
||||
timing_lines = [f'⏱️ Total DOM tree time: {total_time_ms:.2f}ms', '📊 Timing breakdown:']
|
||||
|
||||
# get_all_trees breakdown
|
||||
get_all_trees_ms = timing_info.get('get_all_trees_total_ms', 0)
|
||||
if get_all_trees_ms > 0:
|
||||
timing_lines.append(f' ├─ get_all_trees: {get_all_trees_ms:.2f}ms')
|
||||
iframe_scroll_ms = timing_info.get('iframe_scroll_detection_ms', 0)
|
||||
cdp_parallel_ms = timing_info.get('cdp_parallel_calls_ms', 0)
|
||||
snapshot_proc_ms = timing_info.get('snapshot_processing_ms', 0)
|
||||
if iframe_scroll_ms > 0.01:
|
||||
timing_lines.append(f' │ ├─ iframe_scroll_detection: {iframe_scroll_ms:.2f}ms')
|
||||
if cdp_parallel_ms > 0.01:
|
||||
timing_lines.append(f' │ ├─ cdp_parallel_calls: {cdp_parallel_ms:.2f}ms')
|
||||
if snapshot_proc_ms > 0.01:
|
||||
timing_lines.append(f' │ └─ snapshot_processing: {snapshot_proc_ms:.2f}ms')
|
||||
|
||||
# build_ax_lookup
|
||||
build_ax_ms = timing_info.get('build_ax_lookup_ms', 0)
|
||||
if build_ax_ms > 0.01:
|
||||
timing_lines.append(f' ├─ build_ax_lookup: {build_ax_ms:.2f}ms')
|
||||
|
||||
# build_snapshot_lookup
|
||||
build_snapshot_ms = timing_info.get('build_snapshot_lookup_ms', 0)
|
||||
if build_snapshot_ms > 0.01:
|
||||
timing_lines.append(f' ├─ build_snapshot_lookup: {build_snapshot_ms:.2f}ms')
|
||||
|
||||
# construct_enhanced_tree
|
||||
construct_tree_ms = timing_info.get('construct_enhanced_tree_ms', 0)
|
||||
if construct_tree_ms > 0.01:
|
||||
timing_lines.append(f' ├─ construct_enhanced_tree: {construct_tree_ms:.2f}ms')
|
||||
|
||||
# serialize_accessible_elements breakdown
|
||||
serialize_total_ms = timing_info.get('serialize_accessible_elements_total_ms', 0)
|
||||
if serialize_total_ms > 0.01:
|
||||
timing_lines.append(f' ├─ serialize_accessible_elements: {serialize_total_ms:.2f}ms')
|
||||
create_simp_ms = timing_info.get('create_simplified_tree_ms', 0)
|
||||
paint_order_ms = timing_info.get('calculate_paint_order_ms', 0)
|
||||
optimize_ms = timing_info.get('optimize_tree_ms', 0)
|
||||
bbox_ms = timing_info.get('bbox_filtering_ms', 0)
|
||||
assign_idx_ms = timing_info.get('assign_interactive_indices_ms', 0)
|
||||
clickable_ms = timing_info.get('clickable_detection_time_ms', 0)
|
||||
|
||||
if create_simp_ms > 0.01:
|
||||
timing_lines.append(f' │ ├─ create_simplified_tree: {create_simp_ms:.2f}ms')
|
||||
if clickable_ms > 0.01:
|
||||
timing_lines.append(f' │ │ └─ clickable_detection: {clickable_ms:.2f}ms')
|
||||
if paint_order_ms > 0.01:
|
||||
timing_lines.append(f' │ ├─ calculate_paint_order: {paint_order_ms:.2f}ms')
|
||||
if optimize_ms > 0.01:
|
||||
timing_lines.append(f' │ ├─ optimize_tree: {optimize_ms:.2f}ms')
|
||||
if bbox_ms > 0.01:
|
||||
timing_lines.append(f' │ ├─ bbox_filtering: {bbox_ms:.2f}ms')
|
||||
if assign_idx_ms > 0.01:
|
||||
timing_lines.append(f' │ └─ assign_interactive_indices: {assign_idx_ms:.2f}ms')
|
||||
|
||||
# Overheads
|
||||
get_dom_overhead_ms = timing_info.get('get_dom_tree_overhead_ms', 0)
|
||||
serialize_overhead_ms = timing_info.get('serialization_overhead_ms', 0)
|
||||
get_serialized_overhead_ms = timing_info.get('get_serialized_dom_tree_overhead_ms', 0)
|
||||
|
||||
if get_dom_overhead_ms > 0.1:
|
||||
timing_lines.append(f' ├─ get_dom_tree_overhead: {get_dom_overhead_ms:.2f}ms')
|
||||
if serialize_overhead_ms > 0.1:
|
||||
timing_lines.append(f' ├─ serialization_overhead: {serialize_overhead_ms:.2f}ms')
|
||||
if get_serialized_overhead_ms > 0.1:
|
||||
timing_lines.append(f' └─ get_serialized_dom_tree_overhead: {get_serialized_overhead_ms:.2f}ms')
|
||||
|
||||
# Calculate total tracked time for validation
|
||||
main_operations_ms = (
|
||||
get_all_trees_ms
|
||||
+ build_ax_ms
|
||||
+ build_snapshot_ms
|
||||
+ construct_tree_ms
|
||||
+ serialize_total_ms
|
||||
+ get_dom_overhead_ms
|
||||
+ serialize_overhead_ms
|
||||
+ get_serialized_overhead_ms
|
||||
)
|
||||
untracked_time_ms = total_time_ms - main_operations_ms
|
||||
|
||||
if untracked_time_ms > 1.0: # Only log if significant
|
||||
timing_lines.append(f' ⚠️ untracked_time: {untracked_time_ms:.2f}ms')
|
||||
|
||||
# Single log call with all timing info
|
||||
self.logger.debug('\n'.join(timing_lines))
|
||||
|
||||
# Update selector map for other watchdogs
|
||||
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: Updating selector maps...')
|
||||
self.selector_map = self.current_dom_state.selector_map
|
||||
# Update BrowserSession's cached selector map
|
||||
if self.browser_session:
|
||||
self.browser_session.update_cached_selector_map(self.selector_map)
|
||||
self.logger.debug(
|
||||
f'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ Selector maps updated, {len(self.selector_map)} elements'
|
||||
)
|
||||
|
||||
# Skip JavaScript highlighting injection - Python highlighting will be applied later
|
||||
self.logger.debug('🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ COMPLETED DOM tree build (no JS highlights)')
|
||||
return self.current_dom_state
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to build DOM tree without highlights: {e}')
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='DOMBuildFailed',
|
||||
message=str(e),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
@time_execution_async('capture_clean_screenshot')
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='capture_clean_screenshot')
|
||||
async def _capture_clean_screenshot(self) -> str:
|
||||
"""Capture a clean screenshot without JavaScript highlights."""
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog._capture_clean_screenshot: Capturing clean screenshot...')
|
||||
|
||||
await self.browser_session.get_or_create_cdp_session(target_id=self.browser_session.agent_focus_target_id, focus=True)
|
||||
|
||||
# Check if handler is registered
|
||||
handlers = self.event_bus.handlers.get('ScreenshotEvent', [])
|
||||
handler_names = [getattr(h, '__name__', str(h)) for h in handlers]
|
||||
self.logger.debug(f'📸 ScreenshotEvent handlers registered: {len(handlers)} - {handler_names}')
|
||||
|
||||
screenshot_event = self.event_bus.dispatch(ScreenshotEvent(full_page=False))
|
||||
self.logger.debug('📸 Dispatched ScreenshotEvent, waiting for event to complete...')
|
||||
|
||||
# Wait for the event itself to complete (this waits for all handlers)
|
||||
await screenshot_event
|
||||
|
||||
# Get the single handler result
|
||||
screenshot_b64 = await screenshot_event.event_result(raise_if_any=True, raise_if_none=True)
|
||||
if screenshot_b64 is None:
|
||||
raise RuntimeError('Screenshot handler returned None')
|
||||
self.logger.debug('🔍 DOMWatchdog._capture_clean_screenshot: ✅ Clean screenshot captured successfully')
|
||||
return str(screenshot_b64)
|
||||
|
||||
except TimeoutError:
|
||||
self.logger.warning('📸 Clean screenshot timed out after 6 seconds - no handler registered or slow page?')
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(f'📸 Clean screenshot failed: {type(e).__name__}: {e}')
|
||||
raise
|
||||
|
||||
def _detect_pagination_buttons(self, selector_map: dict[int, EnhancedDOMTreeNode]) -> list['PaginationButton']:
|
||||
"""Detect pagination buttons from the DOM selector map.
|
||||
|
||||
Args:
|
||||
selector_map: Dictionary mapping element indices to DOM tree nodes
|
||||
|
||||
Returns:
|
||||
List of PaginationButton instances found in the DOM
|
||||
"""
|
||||
from browser_use.browser.views import PaginationButton
|
||||
|
||||
pagination_buttons_data = []
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog._detect_pagination_buttons: Detecting pagination buttons...')
|
||||
pagination_buttons_raw = DomService.detect_pagination_buttons(selector_map)
|
||||
# Convert to PaginationButton instances
|
||||
pagination_buttons_data = [
|
||||
PaginationButton(
|
||||
button_type=btn['button_type'], # type: ignore
|
||||
backend_node_id=btn['backend_node_id'], # type: ignore
|
||||
text=btn['text'], # type: ignore
|
||||
selector=btn['selector'], # type: ignore
|
||||
is_disabled=btn['is_disabled'], # type: ignore
|
||||
)
|
||||
for btn in pagination_buttons_raw
|
||||
]
|
||||
if pagination_buttons_data:
|
||||
self.logger.debug(
|
||||
f'🔍 DOMWatchdog._detect_pagination_buttons: Found {len(pagination_buttons_data)} pagination buttons'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'🔍 DOMWatchdog._detect_pagination_buttons: Pagination detection failed: {e}')
|
||||
|
||||
return pagination_buttons_data
|
||||
|
||||
async def _get_page_info(self) -> 'PageInfo':
|
||||
"""Get comprehensive page information using a single CDP call.
|
||||
|
||||
TODO: should we make this an event as well?
|
||||
|
||||
Returns:
|
||||
PageInfo with all viewport, page dimensions, and scroll information
|
||||
"""
|
||||
|
||||
from browser_use.browser.views import PageInfo
|
||||
|
||||
# get_or_create_cdp_session() handles focus validation automatically
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(
|
||||
target_id=self.browser_session.agent_focus_target_id, focus=True
|
||||
)
|
||||
|
||||
# Get layout metrics which includes all the information we need
|
||||
metrics = await asyncio.wait_for(
|
||||
cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id), timeout=10.0
|
||||
)
|
||||
|
||||
# Extract different viewport types
|
||||
layout_viewport = metrics.get('layoutViewport', {})
|
||||
visual_viewport = metrics.get('visualViewport', {})
|
||||
css_visual_viewport = metrics.get('cssVisualViewport', {})
|
||||
css_layout_viewport = metrics.get('cssLayoutViewport', {})
|
||||
content_size = metrics.get('contentSize', {})
|
||||
|
||||
# Calculate device pixel ratio to convert between device pixels and CSS pixels
|
||||
# This matches the approach in dom/service.py _get_viewport_ratio method
|
||||
css_width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1280.0))
|
||||
device_width = visual_viewport.get('clientWidth', css_width)
|
||||
device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0
|
||||
|
||||
# For viewport dimensions, use CSS pixels (what JavaScript sees)
|
||||
# Prioritize CSS layout viewport, then fall back to layout viewport
|
||||
viewport_width = int(css_layout_viewport.get('clientWidth') or layout_viewport.get('clientWidth', 1280))
|
||||
viewport_height = int(css_layout_viewport.get('clientHeight') or layout_viewport.get('clientHeight', 720))
|
||||
|
||||
# For total page dimensions, content size is typically in device pixels, so convert to CSS pixels
|
||||
# by dividing by device pixel ratio
|
||||
raw_page_width = content_size.get('width', viewport_width * device_pixel_ratio)
|
||||
raw_page_height = content_size.get('height', viewport_height * device_pixel_ratio)
|
||||
page_width = int(raw_page_width / device_pixel_ratio)
|
||||
page_height = int(raw_page_height / device_pixel_ratio)
|
||||
|
||||
# For scroll position, use CSS visual viewport if available, otherwise CSS layout viewport
|
||||
# These should already be in CSS pixels
|
||||
scroll_x = int(css_visual_viewport.get('pageX') or css_layout_viewport.get('pageX', 0))
|
||||
scroll_y = int(css_visual_viewport.get('pageY') or css_layout_viewport.get('pageY', 0))
|
||||
|
||||
# Calculate scroll information - pixels that are above/below/left/right of current viewport
|
||||
pixels_above = scroll_y
|
||||
pixels_below = max(0, page_height - viewport_height - scroll_y)
|
||||
pixels_left = scroll_x
|
||||
pixels_right = max(0, page_width - viewport_width - scroll_x)
|
||||
|
||||
page_info = PageInfo(
|
||||
viewport_width=viewport_width,
|
||||
viewport_height=viewport_height,
|
||||
page_width=page_width,
|
||||
page_height=page_height,
|
||||
scroll_x=scroll_x,
|
||||
scroll_y=scroll_y,
|
||||
pixels_above=pixels_above,
|
||||
pixels_below=pixels_below,
|
||||
pixels_left=pixels_left,
|
||||
pixels_right=pixels_right,
|
||||
)
|
||||
|
||||
return page_info
|
||||
|
||||
# ========== Public Helper Methods ==========
|
||||
|
||||
async def get_element_by_index(self, index: int) -> EnhancedDOMTreeNode | None:
|
||||
"""Get DOM element by index from cached selector map.
|
||||
|
||||
Builds DOM if not cached.
|
||||
|
||||
Returns:
|
||||
EnhancedDOMTreeNode or None if index not found
|
||||
"""
|
||||
if not self.selector_map:
|
||||
# Build DOM if not cached
|
||||
await self._build_dom_tree_without_highlights()
|
||||
|
||||
return self.selector_map.get(index) if self.selector_map else None
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear cached DOM state to force rebuild on next access."""
|
||||
self.selector_map = None
|
||||
self.current_dom_state = None
|
||||
self.enhanced_dom_tree = None
|
||||
# Keep the DOM service instance to reuse its CDP client connection
|
||||
|
||||
def is_file_input(self, element: EnhancedDOMTreeNode) -> bool:
|
||||
"""Check if element is a file input."""
|
||||
return element.node_name.upper() == 'INPUT' and element.attributes.get('type', '').lower() == 'file'
|
||||
|
||||
@staticmethod
|
||||
def is_element_visible_according_to_all_parents(node: EnhancedDOMTreeNode, html_frames: list[EnhancedDOMTreeNode]) -> bool:
|
||||
"""Check if the element is visible according to all its parent HTML frames.
|
||||
|
||||
Delegates to the DomService static method.
|
||||
"""
|
||||
return DomService.is_element_visible_according_to_all_parents(node, html_frames)
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
"""Clean up DOM service on exit."""
|
||||
if self._dom_service:
|
||||
await self._dom_service.__aexit__(exc_type, exc_value, traceback)
|
||||
self._dom_service = None
|
||||
|
||||
def __del__(self):
|
||||
"""Clean up DOM service on deletion."""
|
||||
super().__del__()
|
||||
# DOM service will clean up its own CDP client
|
||||
self._dom_service = None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
"""HAR Recording Watchdog for Browser-Use sessions.
|
||||
|
||||
Captures HTTPS network activity via CDP Network domain and writes a HAR 1.2
|
||||
file on browser shutdown. Respects `record_har_content` (omit/embed/attach)
|
||||
and `record_har_mode` (full/minimal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import metadata as importlib_metadata
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.network.events import (
|
||||
DataReceivedEvent,
|
||||
LoadingFailedEvent,
|
||||
LoadingFinishedEvent,
|
||||
RequestWillBeSentEvent,
|
||||
ResponseReceivedEvent,
|
||||
)
|
||||
from cdp_use.cdp.page.events import FrameNavigatedEvent, LifecycleEventEvent
|
||||
|
||||
from browser_use.browser.events import BrowserConnectedEvent, BrowserStopEvent
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
|
||||
@dataclass
|
||||
class _HarContent:
|
||||
mime_type: str | None = None
|
||||
text_b64: str | None = None # for embed
|
||||
file_rel: str | None = None # for attach
|
||||
size: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _HarEntryBuilder:
|
||||
request_id: str = ''
|
||||
frame_id: str | None = None
|
||||
document_url: str | None = None
|
||||
url: str | None = None
|
||||
method: str | None = None
|
||||
request_headers: dict = field(default_factory=dict)
|
||||
request_body: bytes | None = None
|
||||
post_data: str | None = None # CDP postData field
|
||||
status: int | None = None
|
||||
status_text: str | None = None
|
||||
response_headers: dict = field(default_factory=dict)
|
||||
mime_type: str | None = None
|
||||
encoded_data: bytearray = field(default_factory=bytearray)
|
||||
failed: bool = False
|
||||
# timing info (CDP timestamps are monotonic seconds); wallTime is epoch seconds
|
||||
ts_request: float | None = None
|
||||
wall_time_request: float | None = None
|
||||
ts_response: float | None = None
|
||||
ts_finished: float | None = None
|
||||
encoded_data_length: int | None = None
|
||||
response_body: bytes | None = None
|
||||
content_length: int | None = None # From Content-Length header
|
||||
protocol: str | None = None
|
||||
server_ip_address: str | None = None
|
||||
server_port: int | None = None
|
||||
security_details: dict | None = None
|
||||
transfer_size: int | None = None
|
||||
|
||||
|
||||
def _is_https(url: str | None) -> bool:
|
||||
return bool(url and url.lower().startswith('https://'))
|
||||
|
||||
|
||||
def _origin(url: str) -> str:
|
||||
# Very small origin extractor, assumes https URLs
|
||||
# https://host[:port]/...
|
||||
if not url:
|
||||
return ''
|
||||
try:
|
||||
without_scheme = url.split('://', 1)[1]
|
||||
host_port = without_scheme.split('/', 1)[0]
|
||||
return f'https://{host_port}'
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _mime_to_extension(mime_type: str | None) -> str:
|
||||
"""Map MIME type to file extension, matching Playwright's behavior."""
|
||||
if not mime_type:
|
||||
return 'bin'
|
||||
|
||||
mime_lower = mime_type.lower().split(';')[0].strip()
|
||||
|
||||
# Common MIME type to extension mapping
|
||||
mime_map = {
|
||||
'text/html': 'html',
|
||||
'text/css': 'css',
|
||||
'text/javascript': 'js',
|
||||
'application/javascript': 'js',
|
||||
'application/x-javascript': 'js',
|
||||
'application/json': 'json',
|
||||
'application/xml': 'xml',
|
||||
'text/xml': 'xml',
|
||||
'text/plain': 'txt',
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/x-icon': 'ico',
|
||||
'font/woff': 'woff',
|
||||
'font/woff2': 'woff2',
|
||||
'application/font-woff': 'woff',
|
||||
'application/font-woff2': 'woff2',
|
||||
'application/x-font-woff': 'woff',
|
||||
'application/x-font-woff2': 'woff2',
|
||||
'font/ttf': 'ttf',
|
||||
'application/x-font-ttf': 'ttf',
|
||||
'font/otf': 'otf',
|
||||
'application/x-font-opentype': 'otf',
|
||||
'application/pdf': 'pdf',
|
||||
'application/zip': 'zip',
|
||||
'application/x-zip-compressed': 'zip',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/ogg': 'ogg',
|
||||
}
|
||||
|
||||
return mime_map.get(mime_lower, 'bin')
|
||||
|
||||
|
||||
def _generate_har_filename(content: bytes, mime_type: str | None) -> str:
|
||||
"""Generate a hash-based filename for HAR attach mode, matching Playwright's format."""
|
||||
content_hash = hashlib.sha1(content).hexdigest()
|
||||
extension = _mime_to_extension(mime_type)
|
||||
return f'{content_hash}.{extension}'
|
||||
|
||||
|
||||
class HarRecordingWatchdog(BaseWatchdog):
|
||||
"""Collects HTTPS requests/responses and writes a HAR 1.2 file on stop."""
|
||||
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._enabled: bool = False
|
||||
self._entries: dict[str, _HarEntryBuilder] = {}
|
||||
self._top_level_pages: dict[
|
||||
str, dict
|
||||
] = {} # frameId -> {url, title, startedDateTime, monotonic_start, onContentLoad, onLoad}
|
||||
|
||||
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
||||
profile = self.browser_session.browser_profile
|
||||
if not profile.record_har_path:
|
||||
return
|
||||
|
||||
# Normalize config
|
||||
self._content_mode = (profile.record_har_content or 'embed').lower()
|
||||
self._mode = (profile.record_har_mode or 'full').lower()
|
||||
self._har_path = Path(str(profile.record_har_path)).expanduser().resolve()
|
||||
self._har_dir = self._har_path.parent
|
||||
self._har_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Enable Network and Page domains for events
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
await cdp_session.cdp_client.send.Network.enable(session_id=cdp_session.session_id)
|
||||
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
|
||||
|
||||
# Query browser version for HAR log.browser
|
||||
try:
|
||||
version_info = await self.browser_session.cdp_client.send.Browser.getVersion()
|
||||
self._browser_name = version_info.get('product') or 'Chromium'
|
||||
self._browser_version = version_info.get('jsVersion') or ''
|
||||
except Exception:
|
||||
self._browser_name = 'Chromium'
|
||||
self._browser_version = ''
|
||||
|
||||
cdp = self.browser_session.cdp_client.register
|
||||
cdp.Network.requestWillBeSent(self._on_request_will_be_sent)
|
||||
cdp.Network.responseReceived(self._on_response_received)
|
||||
cdp.Network.dataReceived(self._on_data_received)
|
||||
cdp.Network.loadingFinished(self._on_loading_finished)
|
||||
cdp.Network.loadingFailed(self._on_loading_failed)
|
||||
cdp.Page.lifecycleEvent(self._on_lifecycle_event)
|
||||
cdp.Page.frameNavigated(self._on_frame_navigated)
|
||||
|
||||
self._enabled = True
|
||||
self.logger.info(f'📊 Starting HAR recording to {self._har_path}')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to enable HAR recording: {e}')
|
||||
self._enabled = False
|
||||
|
||||
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
try:
|
||||
await self._write_har()
|
||||
self.logger.info(f'📊 HAR file saved: {self._har_path}')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to write HAR: {e}')
|
||||
|
||||
# =============== CDP Event Handlers (sync) ==================
|
||||
def _on_request_will_be_sent(self, params: RequestWillBeSentEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
req = params.get('request', {}) if hasattr(params, 'get') else getattr(params, 'request', {})
|
||||
url = req.get('url') if isinstance(req, dict) else getattr(req, 'url', None)
|
||||
if not _is_https(url):
|
||||
return # HTTPS-only requirement (only HTTPS requests are recorded for now)
|
||||
|
||||
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
|
||||
if not request_id:
|
||||
return
|
||||
|
||||
entry = self._entries.setdefault(request_id, _HarEntryBuilder(request_id=request_id))
|
||||
entry.url = url
|
||||
entry.method = req.get('method') if isinstance(req, dict) else getattr(req, 'method', None)
|
||||
entry.post_data = req.get('postData') if isinstance(req, dict) else getattr(req, 'postData', None)
|
||||
|
||||
# Convert headers to plain dict, handling various formats
|
||||
headers_raw = req.get('headers') if isinstance(req, dict) else getattr(req, 'headers', None)
|
||||
if headers_raw is None:
|
||||
entry.request_headers = {}
|
||||
elif isinstance(headers_raw, dict):
|
||||
entry.request_headers = {k.lower(): str(v) for k, v in headers_raw.items()}
|
||||
elif isinstance(headers_raw, list):
|
||||
entry.request_headers = {
|
||||
h.get('name', '').lower(): str(h.get('value') or '') for h in headers_raw if isinstance(h, dict)
|
||||
}
|
||||
else:
|
||||
# Handle Headers type or other formats - convert to dict
|
||||
try:
|
||||
headers_dict = dict(headers_raw) if hasattr(headers_raw, '__iter__') else {}
|
||||
entry.request_headers = {k.lower(): str(v) for k, v in headers_dict.items()}
|
||||
except Exception:
|
||||
entry.request_headers = {}
|
||||
|
||||
entry.frame_id = params.get('frameId') if hasattr(params, 'get') else getattr(params, 'frameId', None)
|
||||
entry.document_url = (
|
||||
params.get('documentURL')
|
||||
if hasattr(params, 'get')
|
||||
else getattr(params, 'documentURL', None) or entry.document_url
|
||||
)
|
||||
|
||||
# Timing anchors
|
||||
entry.ts_request = params.get('timestamp') if hasattr(params, 'get') else getattr(params, 'timestamp', None)
|
||||
entry.wall_time_request = params.get('wallTime') if hasattr(params, 'get') else getattr(params, 'wallTime', None)
|
||||
|
||||
# Track top-level navigations for page context
|
||||
req_type = params.get('type') if hasattr(params, 'get') else getattr(params, 'type', None)
|
||||
is_same_doc = (
|
||||
params.get('isSameDocument', False) if hasattr(params, 'get') else getattr(params, 'isSameDocument', False)
|
||||
)
|
||||
if req_type == 'Document' and not is_same_doc:
|
||||
# best-effort: consider as navigation
|
||||
if entry.frame_id and url:
|
||||
if entry.frame_id not in self._top_level_pages:
|
||||
self._top_level_pages[entry.frame_id] = {
|
||||
'url': str(url),
|
||||
'title': str(url), # Default to URL, will be updated from DOM
|
||||
'startedDateTime': entry.wall_time_request,
|
||||
'monotonic_start': entry.ts_request, # Track monotonic start time for timing calculations
|
||||
'onContentLoad': -1,
|
||||
'onLoad': -1,
|
||||
}
|
||||
else:
|
||||
# Update startedDateTime and monotonic_start if this is earlier
|
||||
page_info = self._top_level_pages[entry.frame_id]
|
||||
if entry.wall_time_request and (
|
||||
page_info['startedDateTime'] is None or entry.wall_time_request < page_info['startedDateTime']
|
||||
):
|
||||
page_info['startedDateTime'] = entry.wall_time_request
|
||||
page_info['monotonic_start'] = entry.ts_request
|
||||
except Exception as e:
|
||||
self.logger.debug(f'requestWillBeSent handling error: {e}')
|
||||
|
||||
def _on_response_received(self, params: ResponseReceivedEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
|
||||
if not request_id or request_id not in self._entries:
|
||||
return
|
||||
response = params.get('response', {}) if hasattr(params, 'get') else getattr(params, 'response', {})
|
||||
entry = self._entries[request_id]
|
||||
entry.status = response.get('status') if isinstance(response, dict) else getattr(response, 'status', None)
|
||||
entry.status_text = (
|
||||
response.get('statusText') if isinstance(response, dict) else getattr(response, 'statusText', None)
|
||||
)
|
||||
|
||||
# Extract Content-Length for compression calculation (before converting headers)
|
||||
headers_raw = response.get('headers') if isinstance(response, dict) else getattr(response, 'headers', None)
|
||||
if headers_raw:
|
||||
if isinstance(headers_raw, dict):
|
||||
cl_str = headers_raw.get('content-length') or headers_raw.get('Content-Length')
|
||||
elif isinstance(headers_raw, list):
|
||||
cl_header = next(
|
||||
(h for h in headers_raw if isinstance(h, dict) and h.get('name', '').lower() == 'content-length'), None
|
||||
)
|
||||
cl_str = cl_header.get('value') if cl_header else None
|
||||
else:
|
||||
cl_str = None
|
||||
if cl_str:
|
||||
try:
|
||||
entry.content_length = int(cl_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Convert headers to plain dict, handling various formats
|
||||
if headers_raw is None:
|
||||
entry.response_headers = {}
|
||||
elif isinstance(headers_raw, dict):
|
||||
entry.response_headers = {k.lower(): str(v) for k, v in headers_raw.items()}
|
||||
elif isinstance(headers_raw, list):
|
||||
entry.response_headers = {
|
||||
h.get('name', '').lower(): str(h.get('value') or '') for h in headers_raw if isinstance(h, dict)
|
||||
}
|
||||
else:
|
||||
# Handle Headers type or other formats - convert to dict
|
||||
try:
|
||||
headers_dict = dict(headers_raw) if hasattr(headers_raw, '__iter__') else {}
|
||||
entry.response_headers = {k.lower(): str(v) for k, v in headers_dict.items()}
|
||||
except Exception:
|
||||
entry.response_headers = {}
|
||||
|
||||
entry.mime_type = response.get('mimeType') if isinstance(response, dict) else getattr(response, 'mimeType', None)
|
||||
entry.ts_response = params.get('timestamp') if hasattr(params, 'get') else getattr(params, 'timestamp', None)
|
||||
|
||||
protocol_raw = response.get('protocol') if isinstance(response, dict) else getattr(response, 'protocol', None)
|
||||
if protocol_raw:
|
||||
protocol_lower = str(protocol_raw).lower()
|
||||
if protocol_lower == 'h2' or protocol_lower.startswith('http/2'):
|
||||
entry.protocol = 'HTTP/2.0'
|
||||
elif protocol_lower.startswith('http/1.1'):
|
||||
entry.protocol = 'HTTP/1.1'
|
||||
elif protocol_lower.startswith('http/1.0'):
|
||||
entry.protocol = 'HTTP/1.0'
|
||||
else:
|
||||
entry.protocol = str(protocol_raw).upper()
|
||||
|
||||
entry.server_ip_address = (
|
||||
response.get('remoteIPAddress') if isinstance(response, dict) else getattr(response, 'remoteIPAddress', None)
|
||||
)
|
||||
server_port_raw = response.get('remotePort') if isinstance(response, dict) else getattr(response, 'remotePort', None)
|
||||
if server_port_raw is not None:
|
||||
try:
|
||||
entry.server_port = int(server_port_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Extract security details (TLS info)
|
||||
security_details_raw = (
|
||||
response.get('securityDetails') if isinstance(response, dict) else getattr(response, 'securityDetails', None)
|
||||
)
|
||||
if security_details_raw:
|
||||
try:
|
||||
entry.security_details = dict(security_details_raw)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.debug(f'responseReceived handling error: {e}')
|
||||
|
||||
def _on_data_received(self, params: DataReceivedEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
|
||||
if not request_id or request_id not in self._entries:
|
||||
return
|
||||
data = params.get('data') if hasattr(params, 'get') else getattr(params, 'data', None)
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
self._entries[request_id].encoded_data.extend(data.encode('latin1'))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.debug(f'dataReceived handling error: {e}')
|
||||
|
||||
def _on_loading_finished(self, params: LoadingFinishedEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
|
||||
if not request_id or request_id not in self._entries:
|
||||
return
|
||||
entry = self._entries[request_id]
|
||||
entry.ts_finished = params.get('timestamp')
|
||||
# Fetch response body via CDP as dataReceived may be incomplete
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _fetch_body(self_ref, req_id, sess_id):
|
||||
try:
|
||||
resp = await self_ref.browser_session.cdp_client.send.Network.getResponseBody(
|
||||
params={'requestId': req_id}, session_id=sess_id
|
||||
)
|
||||
data = resp.get('body', b'')
|
||||
if resp.get('base64Encoded'):
|
||||
import base64 as _b64
|
||||
|
||||
data = _b64.b64decode(data)
|
||||
else:
|
||||
# Ensure data is bytes even if CDP returns a string
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8', errors='replace')
|
||||
# Ensure we always have bytes
|
||||
if not isinstance(data, bytes):
|
||||
data = bytes(data) if data else b''
|
||||
entry.response_body = data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Always schedule the response body fetch task
|
||||
_asyncio.create_task(_fetch_body(self, request_id, session_id))
|
||||
|
||||
encoded_length = (
|
||||
params.get('encodedDataLength') if hasattr(params, 'get') else getattr(params, 'encodedDataLength', None)
|
||||
)
|
||||
if encoded_length is not None:
|
||||
try:
|
||||
entry.encoded_data_length = int(encoded_length)
|
||||
entry.transfer_size = entry.encoded_data_length
|
||||
except Exception:
|
||||
entry.encoded_data_length = None
|
||||
except Exception as e:
|
||||
self.logger.debug(f'loadingFinished handling error: {e}')
|
||||
|
||||
def _on_loading_failed(self, params: LoadingFailedEvent, session_id: str | None) -> None:
|
||||
try:
|
||||
request_id = params.get('requestId') if hasattr(params, 'get') else getattr(params, 'requestId', None)
|
||||
if request_id and request_id in self._entries:
|
||||
self._entries[request_id].failed = True
|
||||
except Exception as e:
|
||||
self.logger.debug(f'loadingFailed handling error: {e}')
|
||||
|
||||
# ===================== HAR Writing ==========================
|
||||
def _on_lifecycle_event(self, params: LifecycleEventEvent, session_id: str | None) -> None:
|
||||
"""Handle Page.lifecycleEvent for tracking page load timings."""
|
||||
try:
|
||||
frame_id = params.get('frameId') if hasattr(params, 'get') else getattr(params, 'frameId', None)
|
||||
name = params.get('name') if hasattr(params, 'get') else getattr(params, 'name', None)
|
||||
timestamp = params.get('timestamp') if hasattr(params, 'get') else getattr(params, 'timestamp', None)
|
||||
|
||||
if not frame_id or not name or frame_id not in self._top_level_pages:
|
||||
return
|
||||
|
||||
page_info = self._top_level_pages[frame_id]
|
||||
# Use monotonic_start instead of startedDateTime (wall-clock) for timing calculations
|
||||
monotonic_start = page_info.get('monotonic_start')
|
||||
|
||||
if name == 'DOMContentLoaded' and monotonic_start is not None:
|
||||
# Calculate milliseconds since page start using monotonic timestamps
|
||||
try:
|
||||
elapsed_ms = int(round((timestamp - monotonic_start) * 1000))
|
||||
page_info['onContentLoad'] = max(0, elapsed_ms)
|
||||
except Exception:
|
||||
pass
|
||||
elif name == 'load' and monotonic_start is not None:
|
||||
try:
|
||||
elapsed_ms = int(round((timestamp - monotonic_start) * 1000))
|
||||
page_info['onLoad'] = max(0, elapsed_ms)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.debug(f'lifecycleEvent handling error: {e}')
|
||||
|
||||
def _on_frame_navigated(self, params: FrameNavigatedEvent, session_id: str | None) -> None:
|
||||
"""Handle Page.frameNavigated to update page title from DOM."""
|
||||
try:
|
||||
frame = params.get('frame') if hasattr(params, 'get') else getattr(params, 'frame', None)
|
||||
if not frame:
|
||||
return
|
||||
|
||||
frame_id = frame.get('id') if isinstance(frame, dict) else getattr(frame, 'id', None)
|
||||
title = (
|
||||
frame.get('name') or frame.get('url')
|
||||
if isinstance(frame, dict)
|
||||
else getattr(frame, 'name', None) or getattr(frame, 'url', None)
|
||||
)
|
||||
|
||||
if frame_id and frame_id in self._top_level_pages:
|
||||
# Try to get actual page title via Runtime.evaluate if possible
|
||||
# For now, use frame name or URL as fallback
|
||||
if title:
|
||||
self._top_level_pages[frame_id]['title'] = str(title)
|
||||
except Exception as e:
|
||||
self.logger.debug(f'frameNavigated handling error: {e}')
|
||||
|
||||
# ===================== HAR Writing ==========================
|
||||
async def _write_har(self) -> None:
|
||||
# Filter by mode and HTTPS already respected at collection time
|
||||
entries = [e for e in self._entries.values() if self._include_entry(e)]
|
||||
|
||||
har_entries = []
|
||||
sidecar_dir: Path | None = None
|
||||
if self._content_mode == 'attach':
|
||||
sidecar_dir = self._har_dir / f'{self._har_path.stem}_har_parts'
|
||||
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for e in entries:
|
||||
content_obj: dict = {'mimeType': e.mime_type or ''}
|
||||
|
||||
# Get body data, preferring response_body over encoded_data
|
||||
if e.response_body is not None:
|
||||
body_data = e.response_body
|
||||
else:
|
||||
body_data = e.encoded_data
|
||||
|
||||
# Defensive conversion: ensure body_data is always bytes
|
||||
if isinstance(body_data, str):
|
||||
body_bytes = body_data.encode('utf-8', errors='replace')
|
||||
elif isinstance(body_data, bytearray):
|
||||
body_bytes = bytes(body_data)
|
||||
elif isinstance(body_data, bytes):
|
||||
body_bytes = body_data
|
||||
else:
|
||||
# Fallback: try to convert to bytes
|
||||
try:
|
||||
body_bytes = bytes(body_data) if body_data else b''
|
||||
except (TypeError, ValueError):
|
||||
body_bytes = b''
|
||||
|
||||
content_size = len(body_bytes)
|
||||
|
||||
# Calculate compression (bytes saved by compression)
|
||||
compression = 0
|
||||
if e.content_length is not None and e.encoded_data_length is not None:
|
||||
compression = max(0, e.content_length - e.encoded_data_length)
|
||||
|
||||
if self._content_mode == 'embed' and content_size > 0:
|
||||
# Prefer plain text; fallback to base64 only if decoding fails
|
||||
try:
|
||||
text_decoded = body_bytes.decode('utf-8')
|
||||
content_obj['text'] = text_decoded
|
||||
content_obj['size'] = content_size
|
||||
content_obj['compression'] = compression
|
||||
except UnicodeDecodeError:
|
||||
content_obj['text'] = base64.b64encode(body_bytes).decode('ascii')
|
||||
content_obj['encoding'] = 'base64'
|
||||
content_obj['size'] = content_size
|
||||
content_obj['compression'] = compression
|
||||
elif self._content_mode == 'attach' and content_size > 0 and sidecar_dir is not None:
|
||||
filename = _generate_har_filename(body_bytes, e.mime_type)
|
||||
(sidecar_dir / filename).write_bytes(body_bytes)
|
||||
content_obj['_file'] = filename
|
||||
content_obj['size'] = content_size
|
||||
content_obj['compression'] = compression
|
||||
else:
|
||||
# omit or empty
|
||||
content_obj['size'] = content_size
|
||||
if content_size > 0:
|
||||
content_obj['compression'] = compression
|
||||
|
||||
started_date_time, total_time_ms, timings = self._compute_timings(e)
|
||||
req_headers_list = [{'name': k, 'value': str(v)} for k, v in (e.request_headers or {}).items()]
|
||||
resp_headers_list = [{'name': k, 'value': str(v)} for k, v in (e.response_headers or {}).items()]
|
||||
request_headers_size = self._calc_headers_size(e.method or 'GET', e.url or '', req_headers_list)
|
||||
response_headers_size = self._calc_headers_size(None, None, resp_headers_list)
|
||||
request_body_size = self._calc_request_body_size(e)
|
||||
request_post_data = None
|
||||
if e.post_data and self._content_mode != 'omit':
|
||||
if self._content_mode == 'embed':
|
||||
request_post_data = {'mimeType': e.request_headers.get('content-type', ''), 'text': e.post_data}
|
||||
elif self._content_mode == 'attach' and sidecar_dir is not None:
|
||||
post_data_bytes = e.post_data.encode('utf-8')
|
||||
req_mime_type = e.request_headers.get('content-type', 'text/plain')
|
||||
req_filename = _generate_har_filename(post_data_bytes, req_mime_type)
|
||||
(sidecar_dir / req_filename).write_bytes(post_data_bytes)
|
||||
request_post_data = {
|
||||
'mimeType': req_mime_type,
|
||||
'_file': req_filename,
|
||||
}
|
||||
|
||||
http_version = e.protocol if e.protocol else 'HTTP/1.1'
|
||||
|
||||
response_body_size = e.transfer_size
|
||||
if response_body_size is None:
|
||||
response_body_size = e.encoded_data_length
|
||||
if response_body_size is None:
|
||||
response_body_size = content_size if content_size > 0 else -1
|
||||
|
||||
entry_dict = {
|
||||
'startedDateTime': started_date_time,
|
||||
'time': total_time_ms,
|
||||
'request': {
|
||||
'method': e.method or 'GET',
|
||||
'url': e.url or '',
|
||||
'httpVersion': http_version,
|
||||
'headers': req_headers_list,
|
||||
'queryString': [],
|
||||
'cookies': [],
|
||||
'headersSize': request_headers_size,
|
||||
'bodySize': request_body_size,
|
||||
'postData': request_post_data,
|
||||
},
|
||||
'response': {
|
||||
'status': e.status or 0,
|
||||
'statusText': e.status_text or '',
|
||||
'httpVersion': http_version,
|
||||
'headers': resp_headers_list,
|
||||
'cookies': [],
|
||||
'content': content_obj,
|
||||
'redirectURL': '',
|
||||
'headersSize': response_headers_size,
|
||||
'bodySize': response_body_size,
|
||||
},
|
||||
'cache': {},
|
||||
'timings': timings,
|
||||
'pageref': self._page_ref_for_entry(e),
|
||||
}
|
||||
|
||||
# Add security/TLS details if available
|
||||
if e.server_ip_address:
|
||||
entry_dict['serverIPAddress'] = e.server_ip_address
|
||||
if e.server_port is not None:
|
||||
entry_dict['_serverPort'] = e.server_port
|
||||
if e.security_details:
|
||||
# Filter to match Playwright's minimal security details set
|
||||
security_filtered = {}
|
||||
if 'protocol' in e.security_details:
|
||||
security_filtered['protocol'] = e.security_details['protocol']
|
||||
if 'subjectName' in e.security_details:
|
||||
security_filtered['subjectName'] = e.security_details['subjectName']
|
||||
if 'issuer' in e.security_details:
|
||||
security_filtered['issuer'] = e.security_details['issuer']
|
||||
if 'validFrom' in e.security_details:
|
||||
security_filtered['validFrom'] = e.security_details['validFrom']
|
||||
if 'validTo' in e.security_details:
|
||||
security_filtered['validTo'] = e.security_details['validTo']
|
||||
if security_filtered:
|
||||
entry_dict['_securityDetails'] = security_filtered
|
||||
if e.transfer_size is not None:
|
||||
entry_dict['response']['_transferSize'] = e.transfer_size
|
||||
|
||||
har_entries.append(entry_dict)
|
||||
|
||||
# Try to include our library version in creator
|
||||
try:
|
||||
bu_version = importlib_metadata.version('browser-use')
|
||||
except Exception:
|
||||
# Fallback when running from source without installed package metadata
|
||||
bu_version = 'dev'
|
||||
|
||||
har_obj = {
|
||||
'log': {
|
||||
'version': '1.2',
|
||||
'creator': {'name': 'browser-use', 'version': bu_version},
|
||||
'browser': {'name': self._browser_name, 'version': self._browser_version},
|
||||
'pages': [
|
||||
{
|
||||
'id': f'page@{pid}', # Use Playwright format: "page@{frame_id}"
|
||||
'title': page_info.get('title', page_info.get('url', '')),
|
||||
'startedDateTime': self._format_page_started_datetime(page_info.get('startedDateTime')),
|
||||
'pageTimings': (
|
||||
(lambda _ocl, _ol: ({k: v for k, v in (('onContentLoad', _ocl), ('onLoad', _ol)) if v is not None}))(
|
||||
(page_info.get('onContentLoad') if page_info.get('onContentLoad', -1) >= 0 else None),
|
||||
(page_info.get('onLoad') if page_info.get('onLoad', -1) >= 0 else None),
|
||||
)
|
||||
),
|
||||
}
|
||||
for pid, page_info in self._top_level_pages.items()
|
||||
],
|
||||
'entries': har_entries,
|
||||
}
|
||||
}
|
||||
|
||||
tmp_path = self._har_path.with_suffix(self._har_path.suffix + '.tmp')
|
||||
# Write as bytes explicitly to avoid any text/binary mode confusion in different environments
|
||||
tmp_path.write_bytes(json.dumps(har_obj, indent=2, ensure_ascii=False).encode('utf-8'))
|
||||
tmp_path.replace(self._har_path)
|
||||
|
||||
def _format_page_started_datetime(self, timestamp: float | None) -> str:
|
||||
"""Format page startedDateTime from timestamp."""
|
||||
if timestamp is None:
|
||||
return ''
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
def _page_ref_for_entry(self, e: _HarEntryBuilder) -> str | None:
|
||||
# Use Playwright format: "page@{frame_id}" if frame_id is known
|
||||
if e.frame_id and e.frame_id in self._top_level_pages:
|
||||
return f'page@{e.frame_id}'
|
||||
return None
|
||||
|
||||
def _include_entry(self, e: _HarEntryBuilder) -> bool:
|
||||
if not _is_https(e.url):
|
||||
return False
|
||||
# Filter out favicon requests (matching Playwright behavior)
|
||||
if e.url and '/favicon.ico' in e.url.lower():
|
||||
return False
|
||||
if getattr(self, '_mode', 'full') == 'full':
|
||||
return True
|
||||
# minimal: include main document and same-origin subresources
|
||||
if e.frame_id and e.frame_id in self._top_level_pages:
|
||||
page_info = self._top_level_pages[e.frame_id]
|
||||
page_url = page_info.get('url') if isinstance(page_info, dict) else page_info
|
||||
return _origin(e.url or '') == _origin(page_url or '')
|
||||
return False
|
||||
|
||||
# ===================== Helpers ==============================
|
||||
def _compute_timings(self, e: _HarEntryBuilder) -> tuple[str, int, dict]:
|
||||
# startedDateTime from wall_time_request in ISO8601 Z
|
||||
started = ''
|
||||
try:
|
||||
if e.wall_time_request is not None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
started = datetime.fromtimestamp(e.wall_time_request, tz=timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
except Exception:
|
||||
started = ''
|
||||
|
||||
# Calculate timings - CDP doesn't always provide DNS/connect/SSL breakdown
|
||||
# Default to 0 for unavailable timings, calculate what we can from timestamps
|
||||
dns_ms = 0
|
||||
connect_ms = 0
|
||||
ssl_ms = 0
|
||||
send_ms = 0
|
||||
wait_ms = 0
|
||||
receive_ms = 0
|
||||
|
||||
if e.ts_request is not None and e.ts_response is not None:
|
||||
wait_ms = max(0, int(round((e.ts_response - e.ts_request) * 1000)))
|
||||
|
||||
if e.ts_response is not None and e.ts_finished is not None:
|
||||
receive_ms = max(0, int(round((e.ts_finished - e.ts_response) * 1000)))
|
||||
|
||||
# Note: DNS, connect, and SSL timings would require additional CDP events or ResourceTiming API
|
||||
# For now, we structure the timings dict to match Playwright format
|
||||
# but leave DNS/connect/SSL as 0 since CDP doesn't provide this breakdown directly
|
||||
|
||||
total = dns_ms + connect_ms + ssl_ms + send_ms + wait_ms + receive_ms
|
||||
return (
|
||||
started,
|
||||
total,
|
||||
{
|
||||
'dns': dns_ms,
|
||||
'connect': connect_ms,
|
||||
'ssl': ssl_ms,
|
||||
'send': send_ms,
|
||||
'wait': wait_ms,
|
||||
'receive': receive_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def _calc_headers_size(self, method: str | None, url: str | None, headers_list: list[dict]) -> int:
|
||||
try:
|
||||
# Approximate per RFC: sum of header lines + CRLF; include request/status line only for request
|
||||
size = 0
|
||||
if method and url:
|
||||
# Use HTTP/1.1 request line approximation
|
||||
size += len(f'{method} {url} HTTP/1.1\r\n'.encode('latin1'))
|
||||
for h in headers_list:
|
||||
size += len(f'{h.get("name", "")}: {h.get("value", "")}\r\n'.encode('latin1'))
|
||||
size += len(b'\r\n')
|
||||
return size
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
def _calc_request_body_size(self, e: _HarEntryBuilder) -> int:
|
||||
# Try Content-Length header first; else post_data; else request_body; else 0 for GET/HEAD, -1 if unknown
|
||||
try:
|
||||
cl = None
|
||||
if e.request_headers:
|
||||
cl = e.request_headers.get('content-length') or e.request_headers.get('Content-Length')
|
||||
if cl is not None:
|
||||
return int(cl)
|
||||
if e.post_data:
|
||||
return len(e.post_data.encode('utf-8'))
|
||||
if e.request_body is not None:
|
||||
return len(e.request_body)
|
||||
# GET/HEAD requests typically have no body
|
||||
if e.method and e.method.upper() in ('GET', 'HEAD'):
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
return -1
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Local browser watchdog for managing browser subprocess lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
import psutil
|
||||
from bubus import BaseEvent
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserKillEvent,
|
||||
BrowserLaunchEvent,
|
||||
BrowserLaunchResult,
|
||||
BrowserStopEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
from browser_use.observability import observe_debug
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_use.browser.profile import BrowserChannel
|
||||
|
||||
|
||||
class LocalBrowserWatchdog(BaseWatchdog):
|
||||
"""Manages local browser subprocess lifecycle."""
|
||||
|
||||
# Events this watchdog listens to
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
|
||||
BrowserLaunchEvent,
|
||||
BrowserKillEvent,
|
||||
BrowserStopEvent,
|
||||
]
|
||||
|
||||
# Events this watchdog emits
|
||||
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
|
||||
|
||||
# Private state for subprocess management
|
||||
_subprocess: psutil.Process | None = PrivateAttr(default=None)
|
||||
_owns_browser_resources: bool = PrivateAttr(default=True)
|
||||
_temp_dirs_to_cleanup: list[Path] = PrivateAttr(default_factory=list)
|
||||
_original_user_data_dir: str | None = PrivateAttr(default=None)
|
||||
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='browser_launch_event')
|
||||
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> BrowserLaunchResult:
|
||||
"""Launch a local browser process."""
|
||||
|
||||
try:
|
||||
self.logger.debug('[LocalBrowserWatchdog] Received BrowserLaunchEvent, launching local browser...')
|
||||
|
||||
# self.logger.debug('[LocalBrowserWatchdog] Calling _launch_browser...')
|
||||
process, cdp_url = await self._launch_browser()
|
||||
self._subprocess = process
|
||||
# self.logger.debug(f'[LocalBrowserWatchdog] _launch_browser returned: process={process}, cdp_url={cdp_url}')
|
||||
|
||||
return BrowserLaunchResult(cdp_url=cdp_url)
|
||||
except Exception as e:
|
||||
self.logger.error(f'[LocalBrowserWatchdog] Exception in on_BrowserLaunchEvent: {e}', exc_info=True)
|
||||
raise
|
||||
|
||||
async def on_BrowserKillEvent(self, event: BrowserKillEvent) -> None:
|
||||
"""Kill the local browser subprocess."""
|
||||
self.logger.debug('[LocalBrowserWatchdog] Killing local browser process')
|
||||
|
||||
if self._subprocess:
|
||||
await self._cleanup_process(self._subprocess)
|
||||
self._subprocess = None
|
||||
|
||||
# Clean up temp directories if any were created
|
||||
for temp_dir in self._temp_dirs_to_cleanup:
|
||||
self._cleanup_temp_dir(temp_dir)
|
||||
self._temp_dirs_to_cleanup.clear()
|
||||
|
||||
# Restore original user_data_dir if it was modified
|
||||
if self._original_user_data_dir is not None:
|
||||
self.browser_session.browser_profile.user_data_dir = self._original_user_data_dir
|
||||
self._original_user_data_dir = None
|
||||
|
||||
self.logger.debug('[LocalBrowserWatchdog] Browser cleanup completed')
|
||||
|
||||
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
||||
"""Listen for BrowserStopEvent and dispatch BrowserKillEvent without awaiting it."""
|
||||
if self.browser_session.is_local and self._subprocess:
|
||||
self.logger.debug('[LocalBrowserWatchdog] BrowserStopEvent received, dispatching BrowserKillEvent')
|
||||
# Dispatch BrowserKillEvent without awaiting so it gets processed after all BrowserStopEvent handlers
|
||||
self.event_bus.dispatch(BrowserKillEvent())
|
||||
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='launch_browser_process')
|
||||
async def _launch_browser(self, max_retries: int = 3) -> tuple[psutil.Process, str]:
|
||||
"""Launch browser process and return (process, cdp_url).
|
||||
|
||||
Handles launch errors by falling back to temporary directories if needed.
|
||||
|
||||
Returns:
|
||||
Tuple of (psutil.Process, cdp_url)
|
||||
"""
|
||||
# Keep track of original user_data_dir to restore if needed
|
||||
profile = self.browser_session.browser_profile
|
||||
self._original_user_data_dir = str(profile.user_data_dir) if profile.user_data_dir else None
|
||||
self._temp_dirs_to_cleanup = []
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Get launch args from profile
|
||||
launch_args = profile.get_args()
|
||||
|
||||
# Add debugging port
|
||||
debug_port = self._find_free_port()
|
||||
launch_args.extend(
|
||||
[
|
||||
f'--remote-debugging-port={debug_port}',
|
||||
]
|
||||
)
|
||||
assert '--user-data-dir' in str(launch_args), (
|
||||
'User data dir must be set somewhere in launch args to a non-default path, otherwise Chrome will not let us attach via CDP'
|
||||
)
|
||||
|
||||
# Get browser executable
|
||||
# Priority: custom executable > fallback paths > playwright subprocess
|
||||
if profile.executable_path:
|
||||
browser_path = profile.executable_path
|
||||
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Using custom local browser executable_path= {browser_path}')
|
||||
else:
|
||||
# self.logger.debug('[LocalBrowserWatchdog] 🔍 Looking for local browser binary path...')
|
||||
# Try fallback paths first (Playwright's Chromium preferred by default)
|
||||
browser_path = self._find_installed_browser_path(channel=profile.channel)
|
||||
if not browser_path:
|
||||
self.logger.error(
|
||||
'[LocalBrowserWatchdog] ⚠️ No local browser binary found, installing browser using playwright subprocess...'
|
||||
)
|
||||
browser_path = await self._install_browser_with_playwright()
|
||||
|
||||
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Found local browser installed at executable_path= {browser_path}')
|
||||
if not browser_path:
|
||||
raise RuntimeError('No local Chrome/Chromium install found, and failed to install with playwright')
|
||||
|
||||
# Launch browser subprocess directly
|
||||
self.logger.debug(f'[LocalBrowserWatchdog] 🚀 Launching browser subprocess with {len(launch_args)} args...')
|
||||
self.logger.debug(
|
||||
f'[LocalBrowserWatchdog] 📂 user_data_dir={profile.user_data_dir}, profile_directory={profile.profile_directory}'
|
||||
)
|
||||
subprocess = await asyncio.create_subprocess_exec(
|
||||
browser_path,
|
||||
*launch_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self.logger.debug(
|
||||
f'[LocalBrowserWatchdog] 🎭 Browser running with browser_pid= {subprocess.pid} 🔗 listening on CDP port :{debug_port}'
|
||||
)
|
||||
|
||||
# Convert to psutil.Process
|
||||
process = psutil.Process(subprocess.pid)
|
||||
|
||||
# Wait for CDP to be ready and get the URL
|
||||
cdp_url = await self._wait_for_cdp_url(debug_port)
|
||||
|
||||
# Success! Clean up only the temp dirs we created but didn't use
|
||||
currently_used_dir = str(profile.user_data_dir)
|
||||
unused_temp_dirs = [tmp_dir for tmp_dir in self._temp_dirs_to_cleanup if str(tmp_dir) != currently_used_dir]
|
||||
|
||||
for tmp_dir in unused_temp_dirs:
|
||||
try:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Keep only the in-use directory for cleanup during browser kill
|
||||
if currently_used_dir and 'browseruse-tmp-' in currently_used_dir:
|
||||
self._temp_dirs_to_cleanup = [Path(currently_used_dir)]
|
||||
else:
|
||||
self._temp_dirs_to_cleanup = []
|
||||
|
||||
return process, cdp_url
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
|
||||
# Check if this is a user_data_dir related error
|
||||
if any(err in error_str for err in ['singletonlock', 'user data directory', 'cannot create', 'already in use']):
|
||||
self.logger.warning(f'Browser launch failed (attempt {attempt + 1}/{max_retries}): {e}')
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
# Create a temporary directory for next attempt
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix='browseruse-tmp-'))
|
||||
self._temp_dirs_to_cleanup.append(tmp_dir)
|
||||
|
||||
# Update profile to use temp directory
|
||||
profile.user_data_dir = str(tmp_dir)
|
||||
self.logger.debug(f'Retrying with temporary user_data_dir: {tmp_dir}')
|
||||
|
||||
# Small delay before retry
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Not a recoverable error or last attempt failed
|
||||
# Restore original user_data_dir before raising
|
||||
if self._original_user_data_dir is not None:
|
||||
profile.user_data_dir = self._original_user_data_dir
|
||||
|
||||
# Clean up any temp dirs we created
|
||||
for tmp_dir in self._temp_dirs_to_cleanup:
|
||||
try:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise
|
||||
|
||||
# Should not reach here, but just in case
|
||||
if self._original_user_data_dir is not None:
|
||||
profile.user_data_dir = self._original_user_data_dir
|
||||
raise RuntimeError(f'Failed to launch browser after {max_retries} attempts')
|
||||
|
||||
@staticmethod
|
||||
def _find_installed_browser_path(channel: BrowserChannel | None = None) -> str | None:
|
||||
"""Try to find browser executable from common fallback locations.
|
||||
|
||||
If a channel is specified, paths for that browser are searched first.
|
||||
Falls back to all known browser paths if the channel-specific search fails.
|
||||
|
||||
Prioritizes:
|
||||
1. Channel-specific paths (if channel is set to a non-default value)
|
||||
2. Playwright bundled Chromium (when no channel or default channel specified)
|
||||
3. System Chrome stable
|
||||
4. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave -> Edge)
|
||||
5. Playwright headless-shell fallback
|
||||
|
||||
Returns:
|
||||
Path to browser executable or None if not found
|
||||
"""
|
||||
import glob
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from browser_use.browser.profile import BROWSERUSE_DEFAULT_CHANNEL, BrowserChannel
|
||||
|
||||
system = platform.system()
|
||||
|
||||
# Get playwright browsers path from environment variable if set
|
||||
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
|
||||
|
||||
# Build tagged pattern lists per OS: (browser_group, path)
|
||||
# browser_group is used to match against the requested channel
|
||||
if system == 'Darwin': # macOS
|
||||
if not playwright_path:
|
||||
playwright_path = '~/Library/Caches/ms-playwright'
|
||||
all_patterns = [
|
||||
('chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
||||
('chromium', f'{playwright_path}/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium'),
|
||||
('chromium', '/Applications/Chromium.app/Contents/MacOS/Chromium'),
|
||||
('chrome-canary', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'),
|
||||
('brave', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'),
|
||||
('msedge', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'),
|
||||
('chromium', f'{playwright_path}/chromium_headless_shell-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium'),
|
||||
]
|
||||
elif system == 'Linux':
|
||||
if not playwright_path:
|
||||
playwright_path = '~/.cache/ms-playwright'
|
||||
all_patterns = [
|
||||
('chrome', '/usr/bin/google-chrome-stable'),
|
||||
('chrome', '/usr/bin/google-chrome'),
|
||||
('chrome', '/usr/local/bin/google-chrome'),
|
||||
('chromium', f'{playwright_path}/chromium-*/chrome-linux*/chrome'),
|
||||
('chromium', '/usr/bin/chromium'),
|
||||
('chromium', '/usr/bin/chromium-browser'),
|
||||
('chromium', '/usr/local/bin/chromium'),
|
||||
('chromium', '/snap/bin/chromium'),
|
||||
('chrome-beta', '/usr/bin/google-chrome-beta'),
|
||||
('chrome-dev', '/usr/bin/google-chrome-dev'),
|
||||
('brave', '/usr/bin/brave-browser'),
|
||||
('msedge', '/usr/bin/microsoft-edge-stable'),
|
||||
('msedge', '/usr/bin/microsoft-edge'),
|
||||
('chromium', f'{playwright_path}/chromium_headless_shell-*/chrome-linux*/chrome'),
|
||||
]
|
||||
elif system == 'Windows':
|
||||
if not playwright_path:
|
||||
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
|
||||
all_patterns = [
|
||||
('chrome', r'C:\Program Files\Google\Chrome\Application\chrome.exe'),
|
||||
('chrome', r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'),
|
||||
('chrome', r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
|
||||
('chrome', r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe'),
|
||||
('chrome', r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe'),
|
||||
('chromium', f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe'),
|
||||
('chromium', r'C:\Program Files\Chromium\Application\chrome.exe'),
|
||||
('chromium', r'C:\Program Files (x86)\Chromium\Application\chrome.exe'),
|
||||
('chromium', r'%LOCALAPPDATA%\Chromium\Application\chrome.exe'),
|
||||
('brave', r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe'),
|
||||
('brave', r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe'),
|
||||
('msedge', r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'),
|
||||
('msedge', r'C:\Program Files\Microsoft\Edge\Application\msedge.exe'),
|
||||
('msedge', r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe'),
|
||||
('chromium', f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe'),
|
||||
]
|
||||
else:
|
||||
all_patterns = []
|
||||
|
||||
# Map channel enum values to browser group tags
|
||||
_channel_to_group: dict[BrowserChannel, str] = {
|
||||
BrowserChannel.CHROME: 'chrome',
|
||||
BrowserChannel.CHROME_BETA: 'chrome-beta',
|
||||
BrowserChannel.CHROME_DEV: 'chrome-dev',
|
||||
BrowserChannel.CHROME_CANARY: 'chrome-canary',
|
||||
BrowserChannel.CHROMIUM: 'chromium',
|
||||
BrowserChannel.MSEDGE: 'msedge',
|
||||
BrowserChannel.MSEDGE_BETA: 'msedge',
|
||||
BrowserChannel.MSEDGE_DEV: 'msedge',
|
||||
BrowserChannel.MSEDGE_CANARY: 'msedge',
|
||||
}
|
||||
|
||||
# Prioritize the target browser group, then fall back to the rest.
|
||||
if channel and channel != BROWSERUSE_DEFAULT_CHANNEL and channel in _channel_to_group:
|
||||
target_group = _channel_to_group[channel]
|
||||
else:
|
||||
target_group = _channel_to_group[BROWSERUSE_DEFAULT_CHANNEL]
|
||||
prioritized = [p for g, p in all_patterns if g == target_group]
|
||||
rest = [p for g, p in all_patterns if g != target_group]
|
||||
patterns = prioritized + rest
|
||||
|
||||
for pattern in patterns:
|
||||
# Expand user home directory
|
||||
expanded_pattern = Path(pattern).expanduser()
|
||||
|
||||
# Handle Windows environment variables
|
||||
if system == 'Windows':
|
||||
pattern_str = str(expanded_pattern)
|
||||
for env_var in ['%LOCALAPPDATA%', '%PROGRAMFILES%', '%PROGRAMFILES(X86)%']:
|
||||
if env_var in pattern_str:
|
||||
env_key = env_var.strip('%').replace('(X86)', ' (x86)')
|
||||
env_value = os.environ.get(env_key, '')
|
||||
if env_value:
|
||||
pattern_str = pattern_str.replace(env_var, env_value)
|
||||
expanded_pattern = Path(pattern_str)
|
||||
|
||||
# Convert to string for glob
|
||||
pattern_str = str(expanded_pattern)
|
||||
|
||||
# Check if pattern contains wildcards
|
||||
if '*' in pattern_str:
|
||||
# Use glob to expand the pattern
|
||||
matches = glob.glob(pattern_str)
|
||||
if matches:
|
||||
# Sort matches and take the last one (alphanumerically highest version)
|
||||
matches.sort()
|
||||
browser_path = matches[-1]
|
||||
if Path(browser_path).exists() and Path(browser_path).is_file():
|
||||
return browser_path
|
||||
else:
|
||||
# Direct path check
|
||||
if expanded_pattern.exists() and expanded_pattern.is_file():
|
||||
return str(expanded_pattern)
|
||||
|
||||
return None
|
||||
|
||||
async def _install_browser_with_playwright(self) -> str:
|
||||
"""Get browser executable path from playwright in a subprocess to avoid thread issues."""
|
||||
import platform
|
||||
|
||||
# Build command - only use --with-deps on Linux (it fails on Windows/macOS)
|
||||
cmd = ['uvx', 'playwright', 'install', 'chromium']
|
||||
if platform.system() == 'Linux':
|
||||
cmd.append('--with-deps')
|
||||
|
||||
# Run in subprocess with timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60.0)
|
||||
self.logger.debug(f'[LocalBrowserWatchdog] 📦 Playwright install output: {stdout}')
|
||||
browser_path = self._find_installed_browser_path()
|
||||
if browser_path:
|
||||
return browser_path
|
||||
self.logger.error(f'[LocalBrowserWatchdog] ❌ Playwright local browser installation error: \n{stdout}\n{stderr}')
|
||||
raise RuntimeError('No local browser path found after: uvx playwright install chromium')
|
||||
except TimeoutError:
|
||||
# Kill the subprocess if it times out
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise RuntimeError('Timeout getting browser path from playwright')
|
||||
except Exception as e:
|
||||
# Make sure subprocess is terminated
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise RuntimeError(f'Error getting browser path: {e}')
|
||||
|
||||
@staticmethod
|
||||
def _find_free_port() -> int:
|
||||
"""Find a free port for the debugging interface."""
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_cdp_url(port: int, timeout: float = 30) -> str:
|
||||
"""Wait for the browser to start and return the CDP URL."""
|
||||
import aiohttp
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
while asyncio.get_event_loop().time() - start_time < timeout:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f'http://127.0.0.1:{port}/json/version') as resp:
|
||||
if resp.status == 200:
|
||||
# Chrome is ready
|
||||
return f'http://127.0.0.1:{port}/'
|
||||
else:
|
||||
# Chrome is starting up and returning 502/500 errors
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception:
|
||||
# Connection error - Chrome might not be ready yet
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
raise TimeoutError(f'Browser did not start within {timeout} seconds')
|
||||
|
||||
@staticmethod
|
||||
async def _cleanup_process(process: psutil.Process) -> None:
|
||||
"""Clean up browser process.
|
||||
|
||||
Args:
|
||||
process: psutil.Process to terminate
|
||||
"""
|
||||
if not process:
|
||||
return
|
||||
|
||||
try:
|
||||
# Try graceful shutdown first
|
||||
process.terminate()
|
||||
|
||||
# Use async wait instead of blocking wait
|
||||
for _ in range(50): # Wait up to 5 seconds (50 * 0.1)
|
||||
if not process.is_running():
|
||||
return
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# If still running after 5 seconds, force kill
|
||||
if process.is_running():
|
||||
process.kill()
|
||||
# Give it a moment to die
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except psutil.NoSuchProcess:
|
||||
# Process already gone
|
||||
pass
|
||||
except Exception:
|
||||
# Ignore any other errors during cleanup
|
||||
pass
|
||||
|
||||
def _cleanup_temp_dir(self, temp_dir: Path | str) -> None:
|
||||
"""Clean up temporary directory.
|
||||
|
||||
Args:
|
||||
temp_dir: Path to temporary directory to remove
|
||||
"""
|
||||
if not temp_dir:
|
||||
return
|
||||
|
||||
try:
|
||||
temp_path = Path(temp_dir)
|
||||
# Only remove if it's actually a temp directory we created
|
||||
if 'browseruse-tmp-' in str(temp_path):
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to cleanup temp dir {temp_dir}: {e}')
|
||||
|
||||
@property
|
||||
def browser_pid(self) -> int | None:
|
||||
"""Get the browser process ID."""
|
||||
if self._subprocess:
|
||||
return self._subprocess.pid
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_browser_pid_via_cdp(browser) -> int | None:
|
||||
"""Get the browser process ID via CDP SystemInfo.getProcessInfo.
|
||||
|
||||
Args:
|
||||
browser: Playwright Browser instance
|
||||
|
||||
Returns:
|
||||
Process ID or None if failed
|
||||
"""
|
||||
try:
|
||||
cdp_session = await browser.new_browser_cdp_session()
|
||||
result = await cdp_session.send('SystemInfo.getProcessInfo')
|
||||
process_info = result.get('processInfo', {})
|
||||
pid = process_info.get('id')
|
||||
await cdp_session.detach()
|
||||
return pid
|
||||
except Exception:
|
||||
# If we can't get PID via CDP, it's not critical
|
||||
return None
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Permissions watchdog for granting browser permissions on connection."""
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
|
||||
from browser_use.browser.events import BrowserConnectedEvent
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class PermissionsWatchdog(BaseWatchdog):
|
||||
"""Grants browser permissions when browser connects."""
|
||||
|
||||
# Event contracts
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
||||
BrowserConnectedEvent,
|
||||
]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
||||
|
||||
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
||||
"""Grant permissions when browser connects."""
|
||||
permissions = self.browser_session.browser_profile.permissions
|
||||
|
||||
if not permissions:
|
||||
self.logger.debug('No permissions to grant')
|
||||
return
|
||||
|
||||
self.logger.debug(f'🔓 Granting browser permissions: {permissions}')
|
||||
|
||||
try:
|
||||
# Grant permissions using CDP Browser.grantPermissions
|
||||
# origin=None means grant to all origins
|
||||
# Browser domain commands don't use session_id
|
||||
await self.browser_session.cdp_client.send.Browser.grantPermissions(
|
||||
params={'permissions': permissions} # type: ignore
|
||||
)
|
||||
self.logger.debug(f'✅ Successfully granted permissions: {permissions}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'❌ Failed to grant permissions: {str(e)}')
|
||||
# Don't raise - permissions are not critical to browser operation
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Watchdog for handling JavaScript dialogs (alert, confirm, prompt) automatically."""
|
||||
|
||||
import asyncio
|
||||
from typing import ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from browser_use.browser.events import TabCreatedEvent
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
|
||||
class PopupsWatchdog(BaseWatchdog):
|
||||
"""Handles JavaScript dialogs (alert, confirm, prompt) by automatically accepting them immediately."""
|
||||
|
||||
# Events this watchdog listens to and emits
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [TabCreatedEvent]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
||||
|
||||
# Track which targets have dialog handlers registered
|
||||
_dialog_listeners_registered: set[str] = PrivateAttr(default_factory=set)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.logger.debug(f'🚀 PopupsWatchdog initialized with browser_session={self.browser_session}, ID={id(self)}')
|
||||
|
||||
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
||||
"""Set up JavaScript dialog handling when a new tab is created."""
|
||||
target_id = event.target_id
|
||||
self.logger.debug(f'🎯 PopupsWatchdog received TabCreatedEvent for target {target_id}')
|
||||
|
||||
# Skip if we've already registered for this target
|
||||
if target_id in self._dialog_listeners_registered:
|
||||
self.logger.debug(f'Already registered dialog handlers for target {target_id}')
|
||||
return
|
||||
|
||||
self.logger.debug(f'📌 Starting dialog handler setup for target {target_id}')
|
||||
try:
|
||||
# Get all CDP sessions for this target and any child frames
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(
|
||||
target_id, focus=False
|
||||
) # don't auto-focus new tabs! sometimes we need to open tabs in background
|
||||
|
||||
# CRITICAL: Enable Page domain to receive dialog events
|
||||
try:
|
||||
await cdp_session.cdp_client.send.Page.enable(session_id=cdp_session.session_id)
|
||||
self.logger.debug(f'✅ Enabled Page domain for session {cdp_session.session_id[-8:]}')
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to enable Page domain: {e}')
|
||||
|
||||
# Also register for the root CDP client to catch dialogs from any frame
|
||||
if self.browser_session._cdp_client_root:
|
||||
self.logger.debug('📌 Also registering handler on root CDP client')
|
||||
try:
|
||||
# Enable Page domain on root client too
|
||||
await self.browser_session._cdp_client_root.send.Page.enable()
|
||||
self.logger.debug('✅ Enabled Page domain on root CDP client')
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to enable Page domain on root: {e}')
|
||||
|
||||
# Set up async handler for JavaScript dialogs - accept immediately without event dispatch
|
||||
async def handle_dialog(event_data, session_id: str | None = None):
|
||||
"""Handle JavaScript dialog events - accept immediately."""
|
||||
try:
|
||||
dialog_type = event_data.get('type', 'alert')
|
||||
message = event_data.get('message', '')
|
||||
|
||||
# Store the popup message in browser session for inclusion in browser state
|
||||
if message:
|
||||
formatted_message = f'[{dialog_type}] {message}'
|
||||
self.browser_session._closed_popup_messages.append(formatted_message)
|
||||
self.logger.debug(f'📝 Stored popup message: {formatted_message[:100]}')
|
||||
|
||||
# Choose action based on dialog type:
|
||||
# - alert: accept=true (click OK to dismiss)
|
||||
# - confirm: accept=true (click OK to proceed - safer for automation)
|
||||
# - prompt: accept=false (click Cancel since we can't provide input)
|
||||
# - beforeunload: accept=true (allow navigation)
|
||||
should_accept = dialog_type in ('alert', 'confirm', 'beforeunload')
|
||||
|
||||
action_str = 'accepting (OK)' if should_accept else 'dismissing (Cancel)'
|
||||
self.logger.info(f"🔔 JavaScript {dialog_type} dialog: '{message[:100]}' - {action_str}...")
|
||||
|
||||
dismissed = False
|
||||
|
||||
# Approach 1: Use the session that detected the dialog (most reliable)
|
||||
if self.browser_session._cdp_client_root and session_id:
|
||||
try:
|
||||
self.logger.debug(f'🔄 Approach 1: Using detecting session {session_id[-8:]}')
|
||||
await asyncio.wait_for(
|
||||
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
|
||||
params={'accept': should_accept},
|
||||
session_id=session_id,
|
||||
),
|
||||
timeout=0.5,
|
||||
)
|
||||
dismissed = True
|
||||
self.logger.info('✅ Dialog handled successfully via detecting session')
|
||||
except (TimeoutError, Exception) as e:
|
||||
self.logger.debug(f'Approach 1 failed: {type(e).__name__}')
|
||||
|
||||
# Approach 2: Try with current agent focus session
|
||||
if not dismissed and self.browser_session._cdp_client_root and self.browser_session.agent_focus_target_id:
|
||||
try:
|
||||
# Use public API with focus=False to avoid changing focus during popup dismissal
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(
|
||||
self.browser_session.agent_focus_target_id, focus=False
|
||||
)
|
||||
self.logger.debug(f'🔄 Approach 2: Using agent focus session {cdp_session.session_id[-8:]}')
|
||||
await asyncio.wait_for(
|
||||
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
|
||||
params={'accept': should_accept},
|
||||
session_id=cdp_session.session_id,
|
||||
),
|
||||
timeout=0.5,
|
||||
)
|
||||
dismissed = True
|
||||
self.logger.info('✅ Dialog handled successfully via agent focus session')
|
||||
except (TimeoutError, Exception) as e:
|
||||
self.logger.debug(f'Approach 2 failed: {type(e).__name__}')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'❌ Critical error in dialog handler: {type(e).__name__}: {e}')
|
||||
|
||||
# Register handler on the specific session
|
||||
cdp_session.cdp_client.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
|
||||
self.logger.debug(
|
||||
f'Successfully registered Page.javascriptDialogOpening handler for session {cdp_session.session_id}'
|
||||
)
|
||||
|
||||
# Also register on root CDP client to catch dialogs from any frame
|
||||
if hasattr(self.browser_session._cdp_client_root, 'register'):
|
||||
try:
|
||||
self.browser_session._cdp_client_root.register.Page.javascriptDialogOpening(handle_dialog) # type: ignore[arg-type]
|
||||
self.logger.debug('Successfully registered dialog handler on root CDP client for all frames')
|
||||
except Exception as root_error:
|
||||
self.logger.warning(f'Failed to register on root CDP client: {root_error}')
|
||||
|
||||
# Mark this target as having dialog handling set up
|
||||
self._dialog_listeners_registered.add(target_id)
|
||||
|
||||
self.logger.debug(f'Set up JavaScript dialog handling for tab {target_id}')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to set up popup handling for tab {target_id}: {e}')
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Recording Watchdog for Browser Use Sessions."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.page.events import ScreencastFrameEvent
|
||||
from pydantic import PrivateAttr
|
||||
from uuid_extensions import uuid7str
|
||||
|
||||
from browser_use.browser.events import AgentFocusChangedEvent, BrowserConnectedEvent, BrowserStopEvent
|
||||
from browser_use.browser.profile import ViewportSize
|
||||
from browser_use.browser.video_recorder import VideoRecorderService
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
from browser_use.utils import create_task_with_error_handling
|
||||
|
||||
|
||||
class RecordingWatchdog(BaseWatchdog):
|
||||
"""
|
||||
Manages video recording of a browser session using CDP screencasting.
|
||||
"""
|
||||
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent, AgentFocusChangedEvent]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
||||
|
||||
_recorder: VideoRecorderService | None = PrivateAttr(default=None)
|
||||
_current_session_id: str | None = PrivateAttr(default=None)
|
||||
_screencast_params: dict[str, Any] | None = PrivateAttr(default=None)
|
||||
|
||||
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
||||
"""
|
||||
Starts video recording if it is configured in the browser profile.
|
||||
"""
|
||||
profile = self.browser_session.browser_profile
|
||||
if not profile.record_video_dir:
|
||||
return
|
||||
|
||||
video_format = getattr(profile, 'record_video_format', 'mp4').strip('.')
|
||||
output_path = Path(profile.record_video_dir) / f'{uuid7str()}.{video_format}'
|
||||
try:
|
||||
await self.start_recording(output_path, size=profile.record_video_size, framerate=profile.record_video_framerate)
|
||||
except RuntimeError as e:
|
||||
# Preserve prior graceful degradation: a session configured with record_video_dir
|
||||
# should not fail startup when video deps are missing or viewport detection fails.
|
||||
self.logger.warning(f'Skipping video recording: {e}')
|
||||
|
||||
async def start_recording(
|
||||
self,
|
||||
output_path: Path,
|
||||
size: ViewportSize | None = None,
|
||||
framerate: int | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Begin recording the current session to `output_path`. Safe to call at any time
|
||||
after the browser has connected.
|
||||
|
||||
Returns the resolved output path. Raises RuntimeError if recording is already active
|
||||
or if the viewport size could not be determined.
|
||||
"""
|
||||
if self._recorder is not None:
|
||||
raise RuntimeError(f'Recording already in progress (output: {self._recorder.output_path})')
|
||||
|
||||
if size is None:
|
||||
self.logger.debug('record size not specified, detecting viewport size...')
|
||||
size = await self._get_current_viewport_size()
|
||||
if not size:
|
||||
raise RuntimeError('Cannot start video recording: viewport size could not be determined.')
|
||||
|
||||
if framerate is None:
|
||||
framerate = self.browser_session.browser_profile.record_video_framerate
|
||||
|
||||
output_path = Path(output_path)
|
||||
self.logger.debug(f'Initializing video recorder → {output_path}')
|
||||
recorder = VideoRecorderService(output_path=output_path, size=size, framerate=framerate)
|
||||
recorder.start()
|
||||
if not recorder._is_active:
|
||||
raise RuntimeError(
|
||||
'Failed to initialize video recorder — ensure optional deps are installed (`pip install "browser-use[video]"`).'
|
||||
)
|
||||
|
||||
self._recorder = recorder
|
||||
self.browser_session.cdp_client.register.Page.screencastFrame(self.on_screencastFrame)
|
||||
self._screencast_params = {
|
||||
'format': 'png',
|
||||
'quality': 90,
|
||||
'maxWidth': size['width'],
|
||||
'maxHeight': size['height'],
|
||||
'everyNthFrame': 1,
|
||||
}
|
||||
await self._start_screencast()
|
||||
return output_path
|
||||
|
||||
async def stop_recording(self) -> Path | None:
|
||||
"""
|
||||
Stop any in-progress recording and finalize the output file.
|
||||
|
||||
Returns the path of the saved video, or None if no recording was active.
|
||||
"""
|
||||
if not self._recorder:
|
||||
return None
|
||||
|
||||
recorder = self._recorder
|
||||
session_id = self._current_session_id
|
||||
self._recorder = None
|
||||
self._current_session_id = None
|
||||
self._screencast_params = None
|
||||
|
||||
if session_id:
|
||||
try:
|
||||
await self.browser_session.cdp_client.send.Page.stopScreencast(session_id=session_id)
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to stop CDP screencast on {session_id}: {e}')
|
||||
|
||||
output_path = recorder.output_path
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, recorder.stop_and_save)
|
||||
return output_path
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
"""Whether a recording is currently in progress."""
|
||||
return self._recorder is not None
|
||||
|
||||
async def on_AgentFocusChangedEvent(self, event: AgentFocusChangedEvent) -> None:
|
||||
"""
|
||||
Switches video recording to the new tab.
|
||||
"""
|
||||
if self._recorder:
|
||||
self.logger.debug(f'Agent focus changed to {event.target_id}, switching screencast...')
|
||||
await self._start_screencast()
|
||||
|
||||
async def _start_screencast(self) -> None:
|
||||
"""Starts screencast on the currently focused tab."""
|
||||
if not self._recorder or not self._screencast_params:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get the current session (for the focused target)
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
|
||||
# If we are already recording this session, do nothing
|
||||
if self._current_session_id == cdp_session.session_id:
|
||||
return
|
||||
|
||||
# Stop recording on the previous session
|
||||
if self._current_session_id:
|
||||
try:
|
||||
# Use the root client to stop screencast on the specific session
|
||||
await self.browser_session.cdp_client.send.Page.stopScreencast(session_id=self._current_session_id)
|
||||
except Exception as e:
|
||||
# It's possible the session is already closed
|
||||
self.logger.debug(f'Failed to stop screencast on old session {self._current_session_id}: {e}')
|
||||
|
||||
self._current_session_id = cdp_session.session_id
|
||||
|
||||
# Start recording on the new session
|
||||
await cdp_session.cdp_client.send.Page.startScreencast(
|
||||
params=self._screencast_params, # type: ignore
|
||||
session_id=cdp_session.session_id,
|
||||
)
|
||||
self.logger.info(f'📹 Started/Switched video recording to target {cdp_session.target_id}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to switch screencast via CDP: {e}')
|
||||
# If we fail to start on the new tab, we reset current session id
|
||||
self._current_session_id = None
|
||||
|
||||
async def _get_current_viewport_size(self) -> ViewportSize | None:
|
||||
"""Gets the current viewport size directly from the browser via CDP."""
|
||||
try:
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id)
|
||||
|
||||
# Use cssVisualViewport for the most accurate representation of the visible area
|
||||
viewport = metrics.get('cssVisualViewport', {})
|
||||
width = viewport.get('clientWidth')
|
||||
height = viewport.get('clientHeight')
|
||||
|
||||
if width and height:
|
||||
self.logger.debug(f'Detected viewport size: {width}x{height}')
|
||||
return ViewportSize(width=int(width), height=int(height))
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to get viewport size from browser: {e}')
|
||||
|
||||
return None
|
||||
|
||||
def on_screencastFrame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
|
||||
"""
|
||||
Synchronous handler for incoming screencast frames.
|
||||
"""
|
||||
# Only process frames from the current session we intend to record
|
||||
# This handles race conditions where old session might still send frames before stop completes
|
||||
if self._current_session_id and session_id != self._current_session_id:
|
||||
return
|
||||
|
||||
if not self._recorder:
|
||||
return
|
||||
self._recorder.add_frame(event['data'])
|
||||
create_task_with_error_handling(
|
||||
self._ack_screencast_frame(event, session_id),
|
||||
name='ack_screencast_frame',
|
||||
logger_instance=self.logger,
|
||||
suppress_exceptions=True,
|
||||
)
|
||||
|
||||
async def _ack_screencast_frame(self, event: ScreencastFrameEvent, session_id: str | None) -> None:
|
||||
"""
|
||||
Asynchronously acknowledges a screencast frame.
|
||||
"""
|
||||
try:
|
||||
await self.browser_session.cdp_client.send.Page.screencastFrameAck(
|
||||
params={'sessionId': event['sessionId']}, session_id=session_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f'Failed to acknowledge screencast frame: {e}')
|
||||
|
||||
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
||||
"""
|
||||
Stops the video recording and finalizes the video file.
|
||||
"""
|
||||
if self._recorder:
|
||||
self.logger.debug('Stopping video recording and saving file...')
|
||||
await self.stop_recording()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Screenshot watchdog for handling screenshot requests using CDP."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.page import CaptureScreenshotParameters
|
||||
|
||||
from browser_use.browser.events import ScreenshotEvent
|
||||
from browser_use.browser.views import BrowserError
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
from browser_use.observability import observe_debug
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class ScreenshotWatchdog(BaseWatchdog):
|
||||
"""Handles screenshot requests using CDP."""
|
||||
|
||||
# Events this watchdog listens to
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [ScreenshotEvent]
|
||||
|
||||
# Events this watchdog emits
|
||||
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = []
|
||||
|
||||
@observe_debug(ignore_input=True, ignore_output=True, name='screenshot_event_handler')
|
||||
async def on_ScreenshotEvent(self, event: ScreenshotEvent) -> str:
|
||||
"""Handle screenshot request using CDP.
|
||||
|
||||
Args:
|
||||
event: ScreenshotEvent with optional full_page and clip parameters
|
||||
|
||||
Returns:
|
||||
Dict with 'screenshot' key containing base64-encoded screenshot or None
|
||||
"""
|
||||
self.logger.debug('[ScreenshotWatchdog] Handler START - on_ScreenshotEvent called')
|
||||
try:
|
||||
# Validate focused target is a top-level page (not iframe/worker)
|
||||
# CDP Page.captureScreenshot only works on page/tab targets
|
||||
focused_target = self.browser_session.get_focused_target()
|
||||
|
||||
if focused_target and focused_target.target_type in ('page', 'tab'):
|
||||
target_id = focused_target.target_id
|
||||
else:
|
||||
# Focused target is iframe/worker/missing - fall back to any page target
|
||||
target_type_str = focused_target.target_type if focused_target else 'None'
|
||||
self.logger.warning(f'[ScreenshotWatchdog] Focused target is {target_type_str}, falling back to page target')
|
||||
page_targets = self.browser_session.get_page_targets()
|
||||
if not page_targets:
|
||||
raise BrowserError('[ScreenshotWatchdog] No page targets available for screenshot')
|
||||
target_id = page_targets[-1].target_id
|
||||
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=True)
|
||||
|
||||
# Remove highlights BEFORE taking the screenshot so they don't appear in the image.
|
||||
# Done here (not in finally) so CancelledError is never swallowed — any await in a
|
||||
# finally block can suppress external task cancellation.
|
||||
# remove_highlights() has its own asyncio.timeout(3.0) internally so it won't block.
|
||||
try:
|
||||
await self.browser_session.remove_highlights()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prepare screenshot parameters
|
||||
params_dict: dict[str, Any] = {'format': 'png', 'captureBeyondViewport': event.full_page}
|
||||
if event.clip:
|
||||
params_dict['clip'] = {
|
||||
'x': event.clip['x'],
|
||||
'y': event.clip['y'],
|
||||
'width': event.clip['width'],
|
||||
'height': event.clip['height'],
|
||||
'scale': 1,
|
||||
}
|
||||
params = CaptureScreenshotParameters(**params_dict)
|
||||
|
||||
# Take screenshot using CDP
|
||||
self.logger.debug(f'[ScreenshotWatchdog] Taking screenshot with params: {params}')
|
||||
result = await cdp_session.cdp_client.send.Page.captureScreenshot(params=params, session_id=cdp_session.session_id)
|
||||
|
||||
# Return base64-encoded screenshot data
|
||||
if result and 'data' in result:
|
||||
self.logger.debug('[ScreenshotWatchdog] Screenshot captured successfully')
|
||||
return result['data']
|
||||
|
||||
raise BrowserError('[ScreenshotWatchdog] Screenshot result missing data')
|
||||
except Exception as e:
|
||||
self.logger.error(f'[ScreenshotWatchdog] Screenshot failed: {e}')
|
||||
raise
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Security watchdog for enforcing URL access policies."""
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserErrorEvent,
|
||||
NavigateToUrlEvent,
|
||||
NavigationCompleteEvent,
|
||||
TabCreatedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
# Track if we've shown the glob warning
|
||||
_GLOB_WARNING_SHOWN = False
|
||||
|
||||
|
||||
class SecurityWatchdog(BaseWatchdog):
|
||||
"""Monitors and enforces security policies for URL access."""
|
||||
|
||||
# Event contracts
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
||||
NavigateToUrlEvent,
|
||||
NavigationCompleteEvent,
|
||||
TabCreatedEvent,
|
||||
]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = [
|
||||
BrowserErrorEvent,
|
||||
]
|
||||
|
||||
async def on_NavigateToUrlEvent(self, event: NavigateToUrlEvent) -> None:
|
||||
"""Check if navigation URL is allowed before navigation starts."""
|
||||
# Security check BEFORE navigation
|
||||
if not self._is_url_allowed(event.url):
|
||||
self.logger.warning(f'⛔️ Blocking navigation to disallowed URL: {event.url}')
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='NavigationBlocked',
|
||||
message=f'Navigation blocked to disallowed URL: {event.url}',
|
||||
details={'url': event.url, 'reason': 'not_in_allowed_domains'},
|
||||
)
|
||||
)
|
||||
# Stop event propagation by raising exception
|
||||
raise ValueError(f'Navigation to {event.url} blocked by security policy')
|
||||
|
||||
async def on_NavigationCompleteEvent(self, event: NavigationCompleteEvent) -> None:
|
||||
"""Check if navigated URL is allowed (catches redirects to blocked domains)."""
|
||||
# Check if the navigated URL is allowed (in case of redirects)
|
||||
if not self._is_url_allowed(event.url):
|
||||
self.logger.warning(f'⛔️ Navigation to non-allowed URL detected: {event.url}')
|
||||
|
||||
# Dispatch browser error
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='NavigationBlocked',
|
||||
message=f'Navigation blocked to non-allowed URL: {event.url} - redirecting to about:blank',
|
||||
details={'url': event.url, 'target_id': event.target_id},
|
||||
)
|
||||
)
|
||||
# Navigate to about:blank to keep session alive
|
||||
# Agent will see the error and can continue with other tasks
|
||||
try:
|
||||
session = await self.browser_session.get_or_create_cdp_session(target_id=event.target_id)
|
||||
await session.cdp_client.send.Page.navigate(params={'url': 'about:blank'}, session_id=session.session_id)
|
||||
self.logger.info(f'⛔️ Navigated to about:blank after blocked URL: {event.url}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'⛔️ Failed to navigate to about:blank: {type(e).__name__} {e}')
|
||||
|
||||
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
||||
"""Check if new tab URL is allowed."""
|
||||
if not self._is_url_allowed(event.url):
|
||||
self.logger.warning(f'⛔️ New tab created with disallowed URL: {event.url}')
|
||||
|
||||
# Dispatch error and try to close the tab
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='TabCreationBlocked',
|
||||
message=f'Tab created with non-allowed URL: {event.url}',
|
||||
details={'url': event.url, 'target_id': event.target_id},
|
||||
)
|
||||
)
|
||||
|
||||
# Try to close the offending tab
|
||||
try:
|
||||
await self.browser_session._cdp_close_page(event.target_id)
|
||||
self.logger.info(f'⛔️ Closed new tab with non-allowed URL: {event.url}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'⛔️ Failed to close new tab with non-allowed URL: {type(e).__name__} {e}')
|
||||
|
||||
def _is_root_domain(self, domain: str) -> bool:
|
||||
"""Check if a domain is a root domain (no subdomain present).
|
||||
|
||||
Simple heuristic: only add www for domains with exactly 1 dot (domain.tld).
|
||||
For complex cases like country TLDs or subdomains, users should configure explicitly.
|
||||
|
||||
Args:
|
||||
domain: The domain to check
|
||||
|
||||
Returns:
|
||||
True if it's a simple root domain, False otherwise
|
||||
"""
|
||||
# Skip if it contains wildcards or protocol
|
||||
if '*' in domain or '://' in domain:
|
||||
return False
|
||||
|
||||
return domain.count('.') == 1
|
||||
|
||||
def _log_glob_warning(self) -> None:
|
||||
"""Log a warning about glob patterns in allowed_domains."""
|
||||
global _GLOB_WARNING_SHOWN
|
||||
if not _GLOB_WARNING_SHOWN:
|
||||
_GLOB_WARNING_SHOWN = True
|
||||
self.logger.warning(
|
||||
'⚠️ Using glob patterns in allowed_domains. '
|
||||
'Note: Patterns like "*.example.com" will match both subdomains AND the main domain.'
|
||||
)
|
||||
|
||||
def _get_domain_variants(self, host: str) -> tuple[str, str]:
|
||||
"""Get both variants of a domain (with and without www prefix).
|
||||
|
||||
Args:
|
||||
host: The hostname to process
|
||||
|
||||
Returns:
|
||||
Tuple of (original_host, variant_host)
|
||||
- If host starts with www., variant is without www.
|
||||
- Otherwise, variant is with www. prefix
|
||||
"""
|
||||
if host.startswith('www.'):
|
||||
return (host, host[4:]) # ('www.example.com', 'example.com')
|
||||
else:
|
||||
return (host, f'www.{host}') # ('example.com', 'www.example.com')
|
||||
|
||||
def _is_ip_address(self, host: str) -> bool:
|
||||
"""True iff `host` matches an IPv4 or IPv6 the browser would resolve.
|
||||
|
||||
Mirrors WHATWG host canonicalization so non-standard IPv4 encodings
|
||||
(decimal, hex, octal, short-form, percent-encoded, Unicode digits)
|
||||
can't bypass `block_ip_addresses`. Never raises — unrecognizable
|
||||
hosts return False and fall through to domain-allowlist handling.
|
||||
"""
|
||||
import ipaddress
|
||||
import socket
|
||||
import unicodedata
|
||||
from urllib.parse import unquote
|
||||
|
||||
bare = host.strip('[]')
|
||||
try:
|
||||
bare = unquote(bare)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bare = unicodedata.normalize('NFKC', bare)
|
||||
except Exception:
|
||||
pass
|
||||
# IDNA label separators NFKC misses (U+3002, U+FF61 → U+3002).
|
||||
bare = bare.replace('。', '.').replace('。', '.')
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(bare)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# Non-standard IPv4 (decimal, hex, octal, short-form) — `inet_aton`
|
||||
# accepts the same liberal forms the kernel resolver does.
|
||||
try:
|
||||
socket.inet_aton(bare)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _is_url_allowed(self, url: str) -> bool:
|
||||
"""Check if a URL is allowed based on the allowed_domains configuration.
|
||||
|
||||
Args:
|
||||
url: The URL to check
|
||||
|
||||
Returns:
|
||||
True if the URL is allowed, False otherwise
|
||||
"""
|
||||
|
||||
# Always allow internal browser targets (before any other checks)
|
||||
if url in ['about:blank', 'chrome://new-tab-page/', 'chrome://new-tab-page', 'chrome://newtab/']:
|
||||
return True
|
||||
|
||||
# Parse the URL to extract components
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception:
|
||||
# Invalid URL
|
||||
return False
|
||||
|
||||
# Allow data: and blob: URLs (they don't have hostnames)
|
||||
if parsed.scheme in ['data', 'blob']:
|
||||
return True
|
||||
|
||||
# Get the actual host (domain)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Check if IP addresses should be blocked (before domain checks)
|
||||
if self.browser_session.browser_profile.block_ip_addresses:
|
||||
if self._is_ip_address(host):
|
||||
return False
|
||||
|
||||
# If no allowed_domains specified, allow all URLs
|
||||
if (
|
||||
not self.browser_session.browser_profile.allowed_domains
|
||||
and not self.browser_session.browser_profile.prohibited_domains
|
||||
):
|
||||
return True
|
||||
|
||||
# Check allowed domains (fast path for sets, slow path for lists with patterns)
|
||||
if self.browser_session.browser_profile.allowed_domains:
|
||||
allowed_domains = self.browser_session.browser_profile.allowed_domains
|
||||
|
||||
if isinstance(allowed_domains, set):
|
||||
# Fast path: O(1) exact hostname match - check both www and non-www variants
|
||||
host_variant, host_alt = self._get_domain_variants(host)
|
||||
return host_variant in allowed_domains or host_alt in allowed_domains
|
||||
else:
|
||||
# Slow path: O(n) pattern matching for lists
|
||||
for pattern in allowed_domains:
|
||||
if self._is_url_match(url, host, parsed.scheme, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Check prohibited domains (fast path for sets, slow path for lists with patterns)
|
||||
if self.browser_session.browser_profile.prohibited_domains:
|
||||
prohibited_domains = self.browser_session.browser_profile.prohibited_domains
|
||||
|
||||
if isinstance(prohibited_domains, set):
|
||||
# Fast path: O(1) exact hostname match - check both www and non-www variants
|
||||
host_variant, host_alt = self._get_domain_variants(host)
|
||||
return host_variant not in prohibited_domains and host_alt not in prohibited_domains
|
||||
else:
|
||||
# Slow path: O(n) pattern matching for lists
|
||||
for pattern in prohibited_domains:
|
||||
if self._is_url_match(url, host, parsed.scheme, pattern):
|
||||
return False
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _is_url_match(self, url: str, host: str, scheme: str, pattern: str) -> bool:
|
||||
"""Check if a URL matches a pattern."""
|
||||
|
||||
# Full URL for matching (scheme + host)
|
||||
full_url_pattern = f'{scheme}://{host}'
|
||||
|
||||
# Handle glob patterns
|
||||
if '*' in pattern:
|
||||
self._log_glob_warning()
|
||||
import fnmatch
|
||||
|
||||
# Check if pattern matches the host
|
||||
if pattern.startswith('*.'):
|
||||
# Pattern like *.example.com should match subdomains and main domain
|
||||
domain_part = pattern[2:] # Remove *.
|
||||
if host == domain_part or host.endswith('.' + domain_part):
|
||||
# Only match http/https URLs for domain-only patterns
|
||||
if scheme in ['http', 'https']:
|
||||
return True
|
||||
elif pattern.endswith('/*'):
|
||||
# Pattern like brave://* or http*://example.com/*
|
||||
if fnmatch.fnmatch(url, pattern):
|
||||
return True
|
||||
else:
|
||||
# Use fnmatch for other glob patterns
|
||||
if fnmatch.fnmatch(
|
||||
full_url_pattern if '://' in pattern else host,
|
||||
pattern,
|
||||
):
|
||||
return True
|
||||
else:
|
||||
# Exact match
|
||||
if '://' in pattern:
|
||||
# Full URL pattern
|
||||
if url.startswith(pattern):
|
||||
return True
|
||||
else:
|
||||
# Domain-only pattern (case-insensitive comparison)
|
||||
if host.lower() == pattern.lower():
|
||||
return True
|
||||
# If pattern is a root domain, also check www subdomain
|
||||
if self._is_root_domain(pattern) and host.lower() == f'www.{pattern.lower()}':
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Storage state watchdog for managing browser cookies and storage persistence."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.network import Cookie
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserConnectedEvent,
|
||||
BrowserStopEvent,
|
||||
LoadStorageStateEvent,
|
||||
SaveStorageStateEvent,
|
||||
StorageStateLoadedEvent,
|
||||
StorageStateSavedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
from browser_use.utils import create_task_with_error_handling
|
||||
|
||||
|
||||
class StorageStateWatchdog(BaseWatchdog):
|
||||
"""Monitors and persists browser storage state including cookies and localStorage."""
|
||||
|
||||
# Event contracts
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
|
||||
BrowserConnectedEvent,
|
||||
BrowserStopEvent,
|
||||
SaveStorageStateEvent,
|
||||
LoadStorageStateEvent,
|
||||
]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = [
|
||||
StorageStateSavedEvent,
|
||||
StorageStateLoadedEvent,
|
||||
]
|
||||
|
||||
# Configuration
|
||||
auto_save_interval: float = Field(default=30.0) # Auto-save every 30 seconds
|
||||
save_on_change: bool = Field(default=True) # Save immediately when cookies change
|
||||
|
||||
# Private state
|
||||
_monitoring_task: asyncio.Task | None = PrivateAttr(default=None)
|
||||
_last_cookie_state: list[dict] = PrivateAttr(default_factory=list)
|
||||
_save_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
|
||||
|
||||
async def on_BrowserConnectedEvent(self, event: BrowserConnectedEvent) -> None:
|
||||
"""Start monitoring when browser starts."""
|
||||
self.logger.debug('[StorageStateWatchdog] 🍪 Initializing auth/cookies sync <-> with storage_state.json file')
|
||||
|
||||
# Start monitoring
|
||||
await self._start_monitoring()
|
||||
|
||||
# Automatically load storage state after browser start
|
||||
await self.event_bus.dispatch(LoadStorageStateEvent())
|
||||
|
||||
async def on_BrowserStopEvent(self, event: BrowserStopEvent) -> None:
|
||||
"""Stop monitoring when browser stops."""
|
||||
self.logger.debug('[StorageStateWatchdog] Stopping storage_state monitoring')
|
||||
await self._stop_monitoring()
|
||||
|
||||
async def on_SaveStorageStateEvent(self, event: SaveStorageStateEvent) -> None:
|
||||
"""Handle storage state save request."""
|
||||
# Use provided path or fall back to profile default
|
||||
path = event.path
|
||||
if path is None:
|
||||
# Use profile default path if available
|
||||
if self.browser_session.browser_profile.storage_state:
|
||||
path = str(self.browser_session.browser_profile.storage_state)
|
||||
else:
|
||||
path = None # Skip saving if no path available
|
||||
await self._save_storage_state(path)
|
||||
|
||||
async def on_LoadStorageStateEvent(self, event: LoadStorageStateEvent) -> None:
|
||||
"""Handle storage state load request."""
|
||||
# Use provided path or fall back to profile default
|
||||
path = event.path
|
||||
if path is None:
|
||||
# Use profile default path if available
|
||||
if self.browser_session.browser_profile.storage_state:
|
||||
path = str(self.browser_session.browser_profile.storage_state)
|
||||
else:
|
||||
path = None # Skip loading if no path available
|
||||
await self._load_storage_state(path)
|
||||
|
||||
async def _start_monitoring(self) -> None:
|
||||
"""Start the monitoring task."""
|
||||
if self._monitoring_task and not self._monitoring_task.done():
|
||||
return
|
||||
|
||||
assert self.browser_session.cdp_client is not None
|
||||
|
||||
self._monitoring_task = create_task_with_error_handling(
|
||||
self._monitor_storage_changes(), name='monitor_storage_changes', logger_instance=self.logger, suppress_exceptions=True
|
||||
)
|
||||
# self.logger'[StorageStateWatchdog] Started storage monitoring task')
|
||||
|
||||
async def _stop_monitoring(self) -> None:
|
||||
"""Stop the monitoring task."""
|
||||
if self._monitoring_task and not self._monitoring_task.done():
|
||||
self._monitoring_task.cancel()
|
||||
try:
|
||||
await self._monitoring_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
# self.logger.debug('[StorageStateWatchdog] Stopped storage monitoring task')
|
||||
|
||||
async def _check_for_cookie_changes_cdp(self, event: dict) -> None:
|
||||
"""Check if a CDP network event indicates cookie changes.
|
||||
|
||||
This would be called by Network.responseReceivedExtraInfo events
|
||||
if we set up CDP event listeners.
|
||||
"""
|
||||
try:
|
||||
# Check for Set-Cookie headers in the response
|
||||
headers = event.get('headers', {})
|
||||
if 'set-cookie' in headers or 'Set-Cookie' in headers:
|
||||
self.logger.debug('[StorageStateWatchdog] Cookie change detected via CDP')
|
||||
|
||||
# If save on change is enabled, trigger save immediately
|
||||
if self.save_on_change:
|
||||
await self._save_storage_state()
|
||||
except Exception as e:
|
||||
self.logger.warning(f'[StorageStateWatchdog] Error checking for cookie changes: {e}')
|
||||
|
||||
async def _monitor_storage_changes(self) -> None:
|
||||
"""Periodically check for storage changes and auto-save."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.auto_save_interval)
|
||||
|
||||
# Check if cookies have changed
|
||||
if await self._have_cookies_changed():
|
||||
self.logger.debug('[StorageStateWatchdog] Detected changes to sync with storage_state.json')
|
||||
await self._save_storage_state()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f'[StorageStateWatchdog] Error in monitoring loop: {e}')
|
||||
|
||||
async def _have_cookies_changed(self) -> bool:
|
||||
"""Check if cookies have changed since last save."""
|
||||
if not self.browser_session.cdp_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Get current cookies using CDP
|
||||
current_cookies = await self.browser_session._cdp_get_cookies()
|
||||
|
||||
# Convert to comparable format, using .get() for optional fields
|
||||
current_cookie_set = {
|
||||
(c.get('name', ''), c.get('domain', ''), c.get('path', '')): c.get('value', '') for c in current_cookies
|
||||
}
|
||||
|
||||
last_cookie_set = {
|
||||
(c.get('name', ''), c.get('domain', ''), c.get('path', '')): c.get('value', '') for c in self._last_cookie_state
|
||||
}
|
||||
|
||||
return current_cookie_set != last_cookie_set
|
||||
except Exception as e:
|
||||
self.logger.debug(f'[StorageStateWatchdog] Error comparing cookies: {e}')
|
||||
return False
|
||||
|
||||
async def _save_storage_state(self, path: str | None = None) -> None:
|
||||
"""Save browser storage state to file."""
|
||||
async with self._save_lock:
|
||||
# Check if CDP client is available
|
||||
assert await self.browser_session.get_or_create_cdp_session(target_id=None)
|
||||
|
||||
save_path = path or self.browser_session.browser_profile.storage_state
|
||||
if not save_path:
|
||||
return
|
||||
|
||||
# Skip saving if the storage state is already a dict (indicates it was loaded from memory)
|
||||
# We only save to file if it started as a file path
|
||||
if isinstance(save_path, dict):
|
||||
self.logger.debug('[StorageStateWatchdog] Storage state is already a dict, skipping file save')
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current storage state using CDP
|
||||
storage_state = await self.browser_session._cdp_get_storage_state()
|
||||
|
||||
# Update our last known state
|
||||
self._last_cookie_state = storage_state.get('cookies', []).copy()
|
||||
|
||||
# Convert path to Path object
|
||||
json_path = Path(save_path).expanduser().resolve()
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Merge with existing state if file exists
|
||||
merged_state = storage_state
|
||||
if json_path.exists():
|
||||
try:
|
||||
existing_state = json.loads(json_path.read_text())
|
||||
merged_state = self._merge_storage_states(existing_state, dict(storage_state))
|
||||
except Exception as e:
|
||||
self.logger.error(f'[StorageStateWatchdog] Failed to merge with existing state: {e}')
|
||||
|
||||
# Write atomically
|
||||
temp_path = json_path.with_suffix('.json.tmp')
|
||||
temp_path.write_text(json.dumps(merged_state, indent=4, ensure_ascii=False), encoding='utf-8')
|
||||
|
||||
# Backup existing file
|
||||
if json_path.exists():
|
||||
backup_path = json_path.with_suffix('.json.bak')
|
||||
json_path.replace(backup_path)
|
||||
|
||||
# Move temp to final
|
||||
temp_path.replace(json_path)
|
||||
|
||||
# Emit success event
|
||||
self.event_bus.dispatch(
|
||||
StorageStateSavedEvent(
|
||||
path=str(json_path),
|
||||
cookies_count=len(merged_state.get('cookies', [])),
|
||||
origins_count=len(merged_state.get('origins', [])),
|
||||
)
|
||||
)
|
||||
|
||||
self.logger.debug(
|
||||
f'[StorageStateWatchdog] Saved storage state to {json_path} '
|
||||
f'({len(merged_state.get("cookies", []))} cookies, '
|
||||
f'{len(merged_state.get("origins", []))} origins)'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'[StorageStateWatchdog] Failed to save storage state: {e}')
|
||||
|
||||
async def _load_storage_state(self, path: str | None = None) -> None:
|
||||
"""Load browser storage state from file."""
|
||||
if not self.browser_session.cdp_client:
|
||||
self.logger.warning('[StorageStateWatchdog] No CDP client available for loading')
|
||||
return
|
||||
|
||||
load_path = path or self.browser_session.browser_profile.storage_state
|
||||
if not load_path or not os.path.exists(str(load_path)):
|
||||
return
|
||||
|
||||
try:
|
||||
# Read the storage state file asynchronously
|
||||
import anyio
|
||||
|
||||
content = await anyio.Path(str(load_path)).read_text()
|
||||
storage = json.loads(content)
|
||||
|
||||
# Apply cookies if present
|
||||
if 'cookies' in storage and storage['cookies']:
|
||||
# Playwright exports session cookies with expires=0/-1. CDP treats expires=0 as expired.
|
||||
# Normalize session cookies by omitting expires
|
||||
normalized_cookies: list[Cookie] = []
|
||||
for cookie in storage['cookies']:
|
||||
if not isinstance(cookie, dict):
|
||||
normalized_cookies.append(cookie) # type: ignore[arg-type]
|
||||
continue
|
||||
c = dict(cookie)
|
||||
expires = c.get('expires')
|
||||
if expires in (0, 0.0, -1, -1.0):
|
||||
c.pop('expires', None)
|
||||
normalized_cookies.append(Cookie(**c))
|
||||
|
||||
await self.browser_session._cdp_set_cookies(normalized_cookies)
|
||||
self._last_cookie_state = storage['cookies'].copy()
|
||||
self.logger.debug(f'[StorageStateWatchdog] Added {len(storage["cookies"])} cookies from storage state')
|
||||
|
||||
# Apply origins (localStorage/sessionStorage) if present
|
||||
if 'origins' in storage and storage['origins']:
|
||||
for origin in storage['origins']:
|
||||
origin_value = origin.get('origin')
|
||||
if not origin_value:
|
||||
continue
|
||||
|
||||
# Scope storage restoration to its origin to avoid cross-site pollution.
|
||||
if origin.get('localStorage'):
|
||||
lines = []
|
||||
for item in origin['localStorage']:
|
||||
lines.append(f'window.localStorage.setItem({json.dumps(item["name"])}, {json.dumps(item["value"])});')
|
||||
script = (
|
||||
'(function(){\n'
|
||||
f' if (window.location && window.location.origin !== {json.dumps(origin_value)}) return;\n'
|
||||
' try {\n'
|
||||
f' {" ".join(lines)}\n'
|
||||
' } catch (e) {}\n'
|
||||
'})();'
|
||||
)
|
||||
await self.browser_session._cdp_add_init_script(script)
|
||||
|
||||
if origin.get('sessionStorage'):
|
||||
lines = []
|
||||
for item in origin['sessionStorage']:
|
||||
lines.append(
|
||||
f'window.sessionStorage.setItem({json.dumps(item["name"])}, {json.dumps(item["value"])});'
|
||||
)
|
||||
script = (
|
||||
'(function(){\n'
|
||||
f' if (window.location && window.location.origin !== {json.dumps(origin_value)}) return;\n'
|
||||
' try {\n'
|
||||
f' {" ".join(lines)}\n'
|
||||
' } catch (e) {}\n'
|
||||
'})();'
|
||||
)
|
||||
await self.browser_session._cdp_add_init_script(script)
|
||||
self.logger.debug(
|
||||
f'[StorageStateWatchdog] Applied localStorage/sessionStorage from {len(storage["origins"])} origins'
|
||||
)
|
||||
|
||||
self.event_bus.dispatch(
|
||||
StorageStateLoadedEvent(
|
||||
path=str(load_path),
|
||||
cookies_count=len(storage.get('cookies', [])),
|
||||
origins_count=len(storage.get('origins', [])),
|
||||
)
|
||||
)
|
||||
|
||||
self.logger.debug(f'[StorageStateWatchdog] Loaded storage state from: {load_path}')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'[StorageStateWatchdog] Failed to load storage state: {e}')
|
||||
|
||||
@staticmethod
|
||||
def _merge_storage_states(existing: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge two storage states, with new values taking precedence."""
|
||||
merged = existing.copy()
|
||||
|
||||
# Merge cookies
|
||||
existing_cookies = {(c['name'], c['domain'], c['path']): c for c in existing.get('cookies', [])}
|
||||
|
||||
for cookie in new.get('cookies', []):
|
||||
key = (cookie['name'], cookie['domain'], cookie['path'])
|
||||
existing_cookies[key] = cookie
|
||||
|
||||
merged['cookies'] = list(existing_cookies.values())
|
||||
|
||||
# Merge origins
|
||||
existing_origins = {origin['origin']: origin for origin in existing.get('origins', [])}
|
||||
|
||||
for origin in new.get('origins', []):
|
||||
existing_origins[origin['origin']] = origin
|
||||
|
||||
merged['origins'] = list(existing_origins.values())
|
||||
|
||||
return merged
|
||||
|
||||
async def get_current_cookies(self) -> list[dict[str, Any]]:
|
||||
"""Get current cookies using CDP."""
|
||||
if not self.browser_session.cdp_client:
|
||||
return []
|
||||
|
||||
try:
|
||||
cookies = await self.browser_session._cdp_get_cookies()
|
||||
# Cookie is a TypedDict, cast to dict for compatibility
|
||||
return [dict(cookie) for cookie in cookies]
|
||||
except Exception as e:
|
||||
self.logger.error(f'[StorageStateWatchdog] Failed to get cookies: {e}')
|
||||
return []
|
||||
|
||||
async def add_cookies(self, cookies: list[dict[str, Any]]) -> None:
|
||||
"""Add cookies using CDP."""
|
||||
if not self.browser_session.cdp_client:
|
||||
self.logger.warning('[StorageStateWatchdog] No CDP client available for adding cookies')
|
||||
return
|
||||
|
||||
try:
|
||||
# Convert dicts to Cookie objects
|
||||
cookie_objects = [Cookie(**cookie_dict) if isinstance(cookie_dict, dict) else cookie_dict for cookie_dict in cookies]
|
||||
# Set cookies using CDP
|
||||
await self.browser_session._cdp_set_cookies(cookie_objects)
|
||||
self.logger.debug(f'[StorageStateWatchdog] Added {len(cookies)} cookies')
|
||||
except Exception as e:
|
||||
self.logger.error(f'[StorageStateWatchdog] Failed to add cookies: {e}')
|
||||
Reference in New Issue
Block a user