ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
+253
@@ -0,0 +1,253 @@
|
||||
"""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."""
|
||||
# logger.debug('[AboutBlankWatchdog] Tab closing, checking if we need to create about:blank tab')
|
||||
|
||||
# Don't create new tabs if browser is shutting down
|
||||
if self._stopping:
|
||||
# logger.debug('[AboutBlankWatchdog] Browser is stopping, not creating new tabs')
|
||||
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:
|
||||
# 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,362 @@
|
||||
"""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,
|
||||
TabCreatedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
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,
|
||||
]
|
||||
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
|
||||
_sessions_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track sessions 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')
|
||||
|
||||
asyncio.create_task(self._start_monitoring())
|
||||
# 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 is not None, 'No current target ID'
|
||||
await self.attach_to_target(self.browser_session.agent_focus.target_id)
|
||||
|
||||
async def attach_to_target(self, target_id: TargetID) -> None:
|
||||
"""Set up crash monitoring for a specific target using CDP."""
|
||||
try:
|
||||
# Create temporary session for monitoring without switching focus
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
|
||||
|
||||
# Check if we already have listeners for this session
|
||||
if cdp_session.session_id in self._sessions_with_listeners:
|
||||
self.logger.debug(f'[CrashWatchdog] Event listeners already exist for session: {cdp_session.session_id}')
|
||||
return
|
||||
|
||||
# Set up network event handlers
|
||||
# def on_request_will_be_sent(event):
|
||||
# # Create and track the task
|
||||
# task = asyncio.create_task(self._on_request_cdp(event))
|
||||
# self._cdp_event_tasks.add(task)
|
||||
# # Remove from set when done
|
||||
# task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
|
||||
|
||||
# def on_response_received(event):
|
||||
# self._on_response_cdp(event)
|
||||
|
||||
# def on_loading_failed(event):
|
||||
# self._on_request_failed_cdp(event)
|
||||
|
||||
# def on_loading_finished(event):
|
||||
# self._on_request_finished_cdp(event)
|
||||
|
||||
# Register event handlers
|
||||
# TEMPORARILY DISABLED: Network events causing too much logging
|
||||
# cdp_client.on('Network.requestWillBeSent', on_request_will_be_sent, session_id=session_id)
|
||||
# cdp_client.on('Network.responseReceived', on_response_received, session_id=session_id)
|
||||
# cdp_client.on('Network.loadingFailed', on_loading_failed, session_id=session_id)
|
||||
# cdp_client.on('Network.loadingFinished', on_loading_finished, session_id=session_id)
|
||||
|
||||
def on_target_crashed(event: TargetCrashedEvent, session_id: SessionID | None = None):
|
||||
# Create and track the task
|
||||
task = asyncio.create_task(self._on_target_crash_cdp(target_id))
|
||||
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 session
|
||||
self._sessions_with_listeners.add(cdp_session.session_id)
|
||||
|
||||
# Get target info for logging
|
||||
targets = await cdp_session.cdp_client.send.Target.getTargets()
|
||||
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
|
||||
if target_info:
|
||||
self.logger.debug(f'[CrashWatchdog] Added target to monitoring: {target_info.get("url", "unknown")}')
|
||||
|
||||
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."""
|
||||
# Remove crashed session from pool
|
||||
if session := self.browser_session._cdp_session_pool.pop(target_id, None):
|
||||
await session.disconnect()
|
||||
self.logger.debug(f'[CrashWatchdog] Removed crashed session from pool: {target_id}')
|
||||
|
||||
# Get target info
|
||||
cdp_client = self.browser_session.cdp_client
|
||||
targets = await cdp_client.send.Target.getTargets()
|
||||
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
|
||||
if (
|
||||
target_info
|
||||
and self.browser_session.agent_focus
|
||||
and target_info['targetId'] == self.browser_session.agent_focus.target_id
|
||||
):
|
||||
self.browser_session.agent_focus.target_id = None # type: ignore
|
||||
self.browser_session.agent_focus.session_id = None # type: ignore
|
||||
self.logger.error(
|
||||
f'[CrashWatchdog] 💥 Target crashed, navigating Agent to a new tab: {target_info.get("url", "unknown")}'
|
||||
)
|
||||
|
||||
# Also emit generic browser error
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='TargetCrash',
|
||||
message=f'Target crashed: {target_id}',
|
||||
details={
|
||||
# 'url': target_url, # TODO: add url to details
|
||||
'target_id': target_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
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 = asyncio.create_task(self._monitoring_loop())
|
||||
# logger.debug('[CrashWatchdog] Monitoring loop created and started')
|
||||
|
||||
async def _stop_monitoring(self) -> None:
|
||||
"""Stop the monitoring loop."""
|
||||
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 tracking (CDP sessions are cached and managed by BrowserSession)
|
||||
self._active_requests.clear()
|
||||
self._sessions_with_listeners.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:
|
||||
try:
|
||||
self.logger.debug(f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus}')
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
except Exception as e:
|
||||
self.logger.debug(
|
||||
f'[CrashWatchdog] Checking browser health for target {self.browser_session.agent_focus} error: {type(e).__name__}: {e}'
|
||||
)
|
||||
self.agent_focus = cdp_session = await self.browser_session.get_or_create_cdp_session(
|
||||
target_id=self.agent_focus.target_id, new_socket=True, focus=True
|
||||
)
|
||||
|
||||
for target in (await self.browser_session.cdp_client.send.Target.getTargets()).get('targetInfos', []):
|
||||
if target.get('type') == 'page':
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target.get('targetId'))
|
||||
if self._is_new_tab_page(target.get('url')) and target.get('url') != 'about:blank':
|
||||
self.logger.debug(
|
||||
f'[CrashWatchdog] Redirecting chrome://new-tab-page/ to about:blank {target.get("url")}'
|
||||
)
|
||||
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}')
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f'[CrashWatchdog] ❌ Crashed session detected for target {self.browser_session.agent_focus} error: {type(e).__name__}: {e}'
|
||||
)
|
||||
# Remove crashed session from pool
|
||||
if self.browser_session.agent_focus and (target_id := self.browser_session.agent_focus.target_id):
|
||||
if session := self.browser_session._cdp_session_pool.pop(target_id, None):
|
||||
await session.disconnect()
|
||||
self.logger.debug(f'[CrashWatchdog] Removed crashed session from pool: {target_id}')
|
||||
self.browser_session.agent_focus.target_id = None # type: ignore
|
||||
|
||||
# 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')
|
||||
# Clear all sessions from pool when browser crashes
|
||||
for session in self.browser_session._cdp_session_pool.values():
|
||||
await session.disconnect()
|
||||
self.browser_session._cdp_session_pool.clear()
|
||||
self.logger.debug('[CrashWatchdog] Cleared all sessions from pool due to browser crash')
|
||||
|
||||
self.event_bus.dispatch(
|
||||
BrowserErrorEvent(
|
||||
error_type='BrowserProcessCrashed',
|
||||
message=f'Browser process {proc.pid} has crashed',
|
||||
details={'pid': proc.pid, 'status': proc.status()},
|
||||
)
|
||||
)
|
||||
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/']
|
||||
+2344
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,582 @@
|
||||
"""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 time_execution_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_use.browser.views import BrowserStateSummary, PageInfo
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@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}')
|
||||
if self.browser_session.agent_focus:
|
||||
self.logger.debug(
|
||||
f'Current page URL: {page_url}, target_id: {self.browser_session.agent_focus.target_id}, session_id: {self.browser_session.agent_focus.session_id}'
|
||||
)
|
||||
else:
|
||||
self.logger.debug(f'Current page URL: {page_url}, no cdp_session attached')
|
||||
|
||||
# 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')
|
||||
|
||||
# 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:
|
||||
await self._wait_for_stable_network()
|
||||
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,
|
||||
)
|
||||
|
||||
# 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 = asyncio.create_task(self._build_dom_tree_without_highlights(previous_state))
|
||||
|
||||
# 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 = asyncio.create_task(self._capture_clean_screenshot())
|
||||
|
||||
# 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
|
||||
|
||||
# Apply Python-based highlighting if both DOM and screenshot are available
|
||||
if screenshot_b64 and content and content.selector_map and self.browser_session.browser_profile.highlight_elements:
|
||||
try:
|
||||
self.logger.debug('🔍 DOMWatchdog.on_BrowserStateRequestEvent: 🎨 Applying Python-based highlighting...')
|
||||
from browser_use.browser.python_highlights import create_highlighted_screenshot_async
|
||||
|
||||
# Get CDP session for viewport info
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
start = time.time()
|
||||
screenshot_b64 = await create_highlighted_screenshot_async(
|
||||
screenshot_b64,
|
||||
content.selector_map,
|
||||
cdp_session,
|
||||
self.browser_session.browser_profile.filter_highlight_ids,
|
||||
)
|
||||
self.logger.debug(
|
||||
f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: ✅ Applied highlights to {len(content.selector_map)} elements in {time.time() - start:.2f}s'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'🔍 DOMWatchdog.on_BrowserStateRequestEvent: Python 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
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Cache the state
|
||||
self.browser_session._cached_browser_state_summary = browser_state
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@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()
|
||||
self.logger.debug(
|
||||
'🔍 DOMWatchdog._build_dom_tree_without_highlights: ✅ DomService.get_serialized_dom_tree completed'
|
||||
)
|
||||
|
||||
self.logger.debug(f'Time taken to get DOM tree: {end - start} seconds')
|
||||
self.logger.debug(f'Timing breakdown: {timing_info}')
|
||||
|
||||
# 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...')
|
||||
|
||||
# Ensure we have a focused CDP session
|
||||
assert self.browser_session.agent_focus is not None, 'No current target ID'
|
||||
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
|
||||
|
||||
async def _wait_for_stable_network(self):
|
||||
"""Wait for page stability - simplified for CDP-only branch."""
|
||||
start_time = time.time()
|
||||
|
||||
# Apply minimum wait time first (let page settle)
|
||||
min_wait = self.browser_session.browser_profile.minimum_wait_page_load_time
|
||||
if min_wait > 0:
|
||||
self.logger.debug(f'⏳ Minimum wait: {min_wait}s')
|
||||
await asyncio.sleep(min_wait)
|
||||
|
||||
# Apply network idle wait time (for dynamic content like iframes)
|
||||
network_idle_wait = self.browser_session.browser_profile.wait_for_network_idle_page_load_time
|
||||
if network_idle_wait > 0:
|
||||
self.logger.debug(f'⏳ Network idle wait: {network_idle_wait}s')
|
||||
await asyncio.sleep(network_idle_wait)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
self.logger.debug(f'✅ Page stability wait completed in {elapsed:.2f}s')
|
||||
|
||||
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 CDP session for the current target
|
||||
if not self.browser_session.agent_focus:
|
||||
raise RuntimeError('No active CDP session - browser may not be connected yet')
|
||||
|
||||
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
|
||||
+933
@@ -0,0 +1,933 @@
|
||||
"""Downloads watchdog for monitoring and handling file downloads."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.browser import DownloadProgressEvent, DownloadWillBeginEvent
|
||||
from cdp_use.cdp.target import SessionID, TargetID
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from browser_use.browser.events import (
|
||||
BrowserLaunchEvent,
|
||||
BrowserStateRequestEvent,
|
||||
BrowserStoppedEvent,
|
||||
FileDownloadedEvent,
|
||||
NavigationCompleteEvent,
|
||||
TabClosedEvent,
|
||||
TabCreatedEvent,
|
||||
)
|
||||
from browser_use.browser.watchdog_base import BaseWatchdog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class DownloadsWatchdog(BaseWatchdog):
|
||||
"""Monitors downloads and handles file download events."""
|
||||
|
||||
# Events this watchdog listens to (for documentation)
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent[Any]]]] = [
|
||||
BrowserLaunchEvent,
|
||||
BrowserStateRequestEvent,
|
||||
BrowserStoppedEvent,
|
||||
TabCreatedEvent,
|
||||
TabClosedEvent,
|
||||
NavigationCompleteEvent,
|
||||
]
|
||||
|
||||
# Events this watchdog emits
|
||||
EMITS: ClassVar[list[type[BaseEvent[Any]]]] = [
|
||||
FileDownloadedEvent,
|
||||
]
|
||||
|
||||
# Private state
|
||||
_sessions_with_listeners: set[str] = PrivateAttr(default_factory=set) # Track sessions that already have download listeners
|
||||
_active_downloads: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
_pdf_viewer_cache: dict[str, bool] = PrivateAttr(default_factory=dict) # Cache PDF viewer status by target URL
|
||||
_download_cdp_session_setup: bool = PrivateAttr(default=False) # Track if CDP session is set up
|
||||
_download_cdp_session: Any = PrivateAttr(default=None) # Store CDP session reference
|
||||
_cdp_event_tasks: set[asyncio.Task] = PrivateAttr(default_factory=set) # Track CDP event handler tasks
|
||||
_cdp_downloads_info: dict[str, dict[str, Any]] = PrivateAttr(default_factory=dict) # Map guid -> info
|
||||
_use_js_fetch_for_local: bool = PrivateAttr(default=False) # Guard JS fetch path for local regular downloads
|
||||
|
||||
async def on_BrowserLaunchEvent(self, event: BrowserLaunchEvent) -> None:
|
||||
self.logger.debug(f'[DownloadsWatchdog] Received BrowserLaunchEvent, EventBus ID: {id(self.event_bus)}')
|
||||
# Ensure downloads directory exists
|
||||
downloads_path = self.browser_session.browser_profile.downloads_path
|
||||
if downloads_path:
|
||||
expanded_path = Path(downloads_path).expanduser().resolve()
|
||||
expanded_path.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.debug(f'[DownloadsWatchdog] Ensured downloads directory exists: {expanded_path}')
|
||||
|
||||
async def on_TabCreatedEvent(self, event: TabCreatedEvent) -> None:
|
||||
"""Monitor new tabs for downloads."""
|
||||
# logger.info(f'[DownloadsWatchdog] TabCreatedEvent received for tab {event.target_id[-4:]}: {event.url}')
|
||||
|
||||
# Assert downloads path is configured (should always be set by BrowserProfile default)
|
||||
assert self.browser_session.browser_profile.downloads_path is not None, 'Downloads path must be configured'
|
||||
|
||||
if event.target_id:
|
||||
# logger.info(f'[DownloadsWatchdog] Found target for tab {event.target_id}, calling attach_to_target')
|
||||
await self.attach_to_target(event.target_id)
|
||||
else:
|
||||
self.logger.warning(f'[DownloadsWatchdog] No target found for tab {event.target_id}')
|
||||
|
||||
async def on_TabClosedEvent(self, event: TabClosedEvent) -> None:
|
||||
"""Stop monitoring closed tabs."""
|
||||
pass # No cleanup needed, browser context handles target lifecycle
|
||||
|
||||
async def on_BrowserStateRequestEvent(self, event: BrowserStateRequestEvent) -> None:
|
||||
"""Handle browser state request events."""
|
||||
cdp_session = self.browser_session.agent_focus
|
||||
if not cdp_session:
|
||||
return
|
||||
|
||||
url = await self.browser_session.get_current_page_url()
|
||||
if not url:
|
||||
return
|
||||
|
||||
target_id = cdp_session.target_id
|
||||
self.event_bus.dispatch(
|
||||
NavigationCompleteEvent(
|
||||
event_type='NavigationCompleteEvent',
|
||||
url=url,
|
||||
target_id=target_id,
|
||||
event_parent_id=event.event_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def on_BrowserStoppedEvent(self, event: BrowserStoppedEvent) -> None:
|
||||
"""Clean up when browser stops."""
|
||||
# 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()
|
||||
|
||||
# Clean up CDP session
|
||||
# CDP sessions are now cached and managed by BrowserSession
|
||||
self._download_cdp_session = None
|
||||
self._download_cdp_session_setup = False
|
||||
|
||||
# Clear other state
|
||||
self._sessions_with_listeners.clear()
|
||||
self._active_downloads.clear()
|
||||
self._pdf_viewer_cache.clear()
|
||||
|
||||
async def on_NavigationCompleteEvent(self, event: NavigationCompleteEvent) -> None:
|
||||
"""Check for PDFs after navigation completes."""
|
||||
self.logger.debug(f'[DownloadsWatchdog] NavigationCompleteEvent received for {event.url}, tab #{event.target_id[-4:]}')
|
||||
|
||||
# Clear PDF cache for the navigated URL since content may have changed
|
||||
if event.url in self._pdf_viewer_cache:
|
||||
del self._pdf_viewer_cache[event.url]
|
||||
|
||||
# Check if auto-download is enabled
|
||||
auto_download_enabled = self._is_auto_download_enabled()
|
||||
if not auto_download_enabled:
|
||||
return
|
||||
|
||||
# Note: Using network-based PDF detection that doesn't require JavaScript
|
||||
|
||||
target_id = event.target_id
|
||||
self.logger.debug(f'[DownloadsWatchdog] Got target_id={target_id} for tab #{event.target_id[-4:]}')
|
||||
|
||||
is_pdf = await self.check_for_pdf_viewer(target_id)
|
||||
if is_pdf:
|
||||
self.logger.debug(f'[DownloadsWatchdog] 📄 PDF detected at {event.url}, triggering auto-download...')
|
||||
download_path = await self.trigger_pdf_download(target_id)
|
||||
if not download_path:
|
||||
self.logger.warning(f'[DownloadsWatchdog] ⚠️ PDF download failed for {event.url}')
|
||||
|
||||
def _is_auto_download_enabled(self) -> bool:
|
||||
"""Check if auto-download PDFs is enabled in browser profile."""
|
||||
return self.browser_session.browser_profile.auto_download_pdfs
|
||||
|
||||
async def attach_to_target(self, target_id: TargetID) -> None:
|
||||
"""Set up download monitoring for a specific target."""
|
||||
|
||||
# Define CDP event handlers outside of try to avoid indentation/scope issues
|
||||
async def download_will_begin_handler(event: DownloadWillBeginEvent, session_id: SessionID | None):
|
||||
self.logger.debug(f'[DownloadsWatchdog] Download will begin: {event}')
|
||||
# Cache info for later completion event handling (esp. remote browsers)
|
||||
guid = event.get('guid', '')
|
||||
try:
|
||||
suggested_filename = event.get('suggestedFilename')
|
||||
assert suggested_filename, 'CDP DownloadWillBegin missing suggestedFilename'
|
||||
self._cdp_downloads_info[guid] = {
|
||||
'url': event.get('url', ''),
|
||||
'suggested_filename': suggested_filename,
|
||||
'handled': False,
|
||||
}
|
||||
except (AssertionError, KeyError):
|
||||
pass
|
||||
# Create and track the task
|
||||
task = asyncio.create_task(self._handle_cdp_download(event, target_id, session_id))
|
||||
self._cdp_event_tasks.add(task)
|
||||
# Remove from set when done
|
||||
task.add_done_callback(lambda t: self._cdp_event_tasks.discard(t))
|
||||
|
||||
async def download_progress_handler(event: DownloadProgressEvent, session_id: SessionID | None):
|
||||
# Check if download is complete
|
||||
if event.get('state') == 'completed':
|
||||
file_path = event.get('filePath')
|
||||
guid = event.get('guid', '')
|
||||
if self.browser_session.is_local:
|
||||
if file_path:
|
||||
self.logger.debug(f'[DownloadsWatchdog] Download completed: {file_path}')
|
||||
# Track the download
|
||||
self._track_download(file_path)
|
||||
# Mark as handled to prevent fallback duplicate dispatch
|
||||
try:
|
||||
if guid in self._cdp_downloads_info:
|
||||
self._cdp_downloads_info[guid]['handled'] = True
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
else:
|
||||
# No local file path provided, local polling in _handle_cdp_download will handle it
|
||||
self.logger.debug(
|
||||
'[DownloadsWatchdog] No filePath in progress event (local); polling will handle detection'
|
||||
)
|
||||
else:
|
||||
# Remote browser: do not touch local filesystem. Fallback to downloadPath+suggestedFilename
|
||||
info = self._cdp_downloads_info.get(guid, {})
|
||||
try:
|
||||
suggested_filename = info.get('suggested_filename') or (Path(file_path).name if file_path else 'download')
|
||||
downloads_path = str(self.browser_session.browser_profile.downloads_path or '')
|
||||
effective_path = file_path or str(Path(downloads_path) / suggested_filename)
|
||||
file_name = Path(effective_path).name
|
||||
file_ext = Path(file_name).suffix.lower().lstrip('.')
|
||||
self.event_bus.dispatch(
|
||||
FileDownloadedEvent(
|
||||
url=info.get('url', ''),
|
||||
path=str(effective_path),
|
||||
file_name=file_name,
|
||||
file_size=0,
|
||||
file_type=file_ext if file_ext else None,
|
||||
)
|
||||
)
|
||||
self.logger.debug(f'[DownloadsWatchdog] ✅ (remote) Download completed: {effective_path}')
|
||||
finally:
|
||||
if guid in self._cdp_downloads_info:
|
||||
del self._cdp_downloads_info[guid]
|
||||
|
||||
try:
|
||||
downloads_path_raw = self.browser_session.browser_profile.downloads_path
|
||||
if not downloads_path_raw:
|
||||
# logger.info(f'[DownloadsWatchdog] No downloads path configured, skipping target: {target_id}')
|
||||
return # No downloads path configured
|
||||
|
||||
# Check if we already have a download listener on this session
|
||||
# to prevent duplicate listeners from being added
|
||||
# Note: Since download listeners are set up once per browser session, not per target,
|
||||
# we just track if we've set up the browser-level listener
|
||||
if self._download_cdp_session_setup:
|
||||
self.logger.debug('[DownloadsWatchdog] Download listener already set up for browser session')
|
||||
return
|
||||
|
||||
# logger.debug(f'[DownloadsWatchdog] Setting up CDP download listener for target: {target_id}')
|
||||
|
||||
# Use CDP session for download events but store reference in watchdog
|
||||
if not self._download_cdp_session_setup:
|
||||
# Set up CDP session for downloads (only once per browser session)
|
||||
cdp_client = self.browser_session.cdp_client
|
||||
|
||||
# Set download behavior to allow downloads and enable events
|
||||
downloads_path = self.browser_session.browser_profile.downloads_path
|
||||
if not downloads_path:
|
||||
self.logger.warning('[DownloadsWatchdog] No downloads path configured, skipping CDP download setup')
|
||||
return
|
||||
# Ensure path is properly expanded (~ -> absolute path)
|
||||
expanded_downloads_path = Path(downloads_path).expanduser().resolve()
|
||||
await cdp_client.send.Browser.setDownloadBehavior(
|
||||
params={
|
||||
'behavior': 'allow',
|
||||
'downloadPath': str(expanded_downloads_path), # Use expanded absolute path
|
||||
'eventsEnabled': True,
|
||||
}
|
||||
)
|
||||
|
||||
# Register the handlers with CDP
|
||||
cdp_client.register.Browser.downloadWillBegin(download_will_begin_handler) # type: ignore[arg-type]
|
||||
cdp_client.register.Browser.downloadProgress(download_progress_handler) # type: ignore[arg-type]
|
||||
|
||||
self._download_cdp_session_setup = True
|
||||
self.logger.debug('[DownloadsWatchdog] Set up CDP download listeners')
|
||||
|
||||
# No need to track individual targets since download listener is browser-level
|
||||
# logger.debug(f'[DownloadsWatchdog] Successfully set up CDP download listener for target: {target_id}')
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'[DownloadsWatchdog] Failed to set up CDP download listener for target {target_id}: {e}')
|
||||
|
||||
def _track_download(self, file_path: str) -> None:
|
||||
"""Track a completed download and dispatch the appropriate event.
|
||||
|
||||
Args:
|
||||
file_path: The path to the downloaded file
|
||||
"""
|
||||
try:
|
||||
# Get file info
|
||||
path = Path(file_path)
|
||||
if path.exists():
|
||||
file_size = path.stat().st_size
|
||||
self.logger.debug(f'[DownloadsWatchdog] Tracked download: {path.name} ({file_size} bytes)')
|
||||
|
||||
# Dispatch download event
|
||||
from browser_use.browser.events import FileDownloadedEvent
|
||||
|
||||
self.event_bus.dispatch(
|
||||
FileDownloadedEvent(
|
||||
url=str(path), # Use the file path as URL for local files
|
||||
path=str(path),
|
||||
file_name=path.name,
|
||||
file_size=file_size,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.logger.warning(f'[DownloadsWatchdog] Downloaded file not found: {file_path}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'[DownloadsWatchdog] Error tracking download: {e}')
|
||||
|
||||
async def _handle_cdp_download(
|
||||
self, event: DownloadWillBeginEvent, target_id: TargetID, session_id: SessionID | None
|
||||
) -> None:
|
||||
"""Handle a CDP Page.downloadWillBegin event."""
|
||||
downloads_dir = (
|
||||
Path(
|
||||
self.browser_session.browser_profile.downloads_path
|
||||
or f'{tempfile.gettempdir()}/browser_use_downloads.{str(self.browser_session.id)[-4:]}'
|
||||
)
|
||||
.expanduser()
|
||||
.resolve()
|
||||
) # Ensure path is properly expanded
|
||||
|
||||
# Initialize variables that may be used outside try blocks
|
||||
unique_filename = None
|
||||
file_size = 0
|
||||
expected_path = None
|
||||
download_result = None
|
||||
download_url = event.get('url', '')
|
||||
suggested_filename = event.get('suggestedFilename', 'download')
|
||||
guid = event.get('guid', '')
|
||||
|
||||
try:
|
||||
self.logger.debug(f'[DownloadsWatchdog] ⬇️ File download starting: {suggested_filename} from {download_url[:100]}...')
|
||||
self.logger.debug(f'[DownloadsWatchdog] Full CDP event: {event}')
|
||||
|
||||
# Since Browser.setDownloadBehavior is already configured, the browser will download the file
|
||||
# We just need to wait for it to appear in the downloads directory
|
||||
expected_path = downloads_dir / suggested_filename
|
||||
|
||||
# Debug: List current directory contents
|
||||
self.logger.debug(f'[DownloadsWatchdog] Downloads directory: {downloads_dir}')
|
||||
if downloads_dir.exists():
|
||||
files_before = list(downloads_dir.iterdir())
|
||||
self.logger.debug(f'[DownloadsWatchdog] Files before download: {[f.name for f in files_before]}')
|
||||
|
||||
# Try manual JavaScript fetch as a fallback for local browsers (disabled for regular local downloads)
|
||||
if self.browser_session.is_local and self._use_js_fetch_for_local:
|
||||
self.logger.debug(f'[DownloadsWatchdog] Attempting JS fetch fallback for {download_url}')
|
||||
|
||||
unique_filename = None
|
||||
file_size = None
|
||||
download_result = None
|
||||
try:
|
||||
# Escape the URL for JavaScript
|
||||
import json
|
||||
|
||||
escaped_url = json.dumps(download_url)
|
||||
|
||||
# Get the proper session for the frame that initiated the download
|
||||
cdp_session = await self.browser_session.cdp_client_for_frame(event.get('frameId'))
|
||||
assert cdp_session
|
||||
|
||||
result = await cdp_session.cdp_client.send.Runtime.evaluate(
|
||||
params={
|
||||
'expression': f"""
|
||||
(async () => {{
|
||||
try {{
|
||||
const response = await fetch({escaped_url});
|
||||
if (!response.ok) {{
|
||||
throw new Error(`HTTP error! status: ${{response.status}}`);
|
||||
}}
|
||||
const blob = await response.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
return {{
|
||||
data: Array.from(uint8Array),
|
||||
size: uint8Array.length,
|
||||
contentType: response.headers.get('content-type') || 'application/octet-stream'
|
||||
}};
|
||||
}} catch (error) {{
|
||||
throw new Error(`Fetch failed: ${{error.message}}`);
|
||||
}}
|
||||
}})()
|
||||
""",
|
||||
'awaitPromise': True,
|
||||
'returnByValue': True,
|
||||
},
|
||||
session_id=cdp_session.session_id,
|
||||
)
|
||||
download_result = result.get('result', {}).get('value')
|
||||
|
||||
if download_result and download_result.get('data'):
|
||||
# Save the file
|
||||
file_data = bytes(download_result['data'])
|
||||
file_size = len(file_data)
|
||||
|
||||
# Ensure unique filename
|
||||
unique_filename = await self._get_unique_filename(str(downloads_dir), suggested_filename)
|
||||
final_path = downloads_dir / unique_filename
|
||||
|
||||
# Write the file
|
||||
import anyio
|
||||
|
||||
async with await anyio.open_file(final_path, 'wb') as f:
|
||||
await f.write(file_data)
|
||||
|
||||
self.logger.debug(f'[DownloadsWatchdog] ✅ Downloaded and saved file: {final_path} ({file_size} bytes)')
|
||||
expected_path = final_path
|
||||
# Emit download event immediately
|
||||
file_ext = expected_path.suffix.lower().lstrip('.')
|
||||
file_type = file_ext if file_ext else None
|
||||
self.event_bus.dispatch(
|
||||
FileDownloadedEvent(
|
||||
url=download_url,
|
||||
path=str(expected_path),
|
||||
file_name=unique_filename or expected_path.name,
|
||||
file_size=file_size or 0,
|
||||
file_type=file_type,
|
||||
mime_type=(download_result.get('contentType') if download_result else None),
|
||||
from_cache=False,
|
||||
auto_download=False,
|
||||
)
|
||||
)
|
||||
# Mark as handled to prevent duplicate dispatch from progress/polling paths
|
||||
try:
|
||||
if guid in self._cdp_downloads_info:
|
||||
self._cdp_downloads_info[guid]['handled'] = True
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
self.logger.debug(
|
||||
f'[DownloadsWatchdog] ✅ File download completed via CDP: {suggested_filename} ({file_size} bytes) saved to {expected_path}'
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.logger.error('[DownloadsWatchdog] ❌ No data received from fetch')
|
||||
|
||||
except Exception as fetch_error:
|
||||
self.logger.error(f'[DownloadsWatchdog] ❌ Failed to download file via fetch: {fetch_error}')
|
||||
|
||||
# For remote browsers, don't poll local filesystem; downloadProgress handler will emit the event
|
||||
if not self.browser_session.is_local:
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f'[DownloadsWatchdog] ❌ Error handling CDP download: {type(e).__name__} {e}')
|
||||
|
||||
# If we reach here, the fetch method failed, so wait for native download
|
||||
# Poll the downloads directory for new files
|
||||
self.logger.debug(f'[DownloadsWatchdog] Checking if browser auto-download saved the file for us: {suggested_filename}')
|
||||
|
||||
# Get initial list of files in downloads directory
|
||||
initial_files = set()
|
||||
if Path(downloads_dir).exists():
|
||||
for f in Path(downloads_dir).iterdir():
|
||||
if f.is_file() and not f.name.startswith('.'):
|
||||
initial_files.add(f.name)
|
||||
|
||||
# Poll for new files
|
||||
max_wait = 20 # seconds
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
while asyncio.get_event_loop().time() - start_time < max_wait:
|
||||
await asyncio.sleep(5.0) # Check every 5 seconds
|
||||
|
||||
if Path(downloads_dir).exists():
|
||||
for file_path in Path(downloads_dir).iterdir():
|
||||
# Skip hidden files and files that were already there
|
||||
if file_path.is_file() and not file_path.name.startswith('.') and file_path.name not in initial_files:
|
||||
# Check if file has content (> 4 bytes)
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > 4:
|
||||
# Found a new download!
|
||||
self.logger.debug(
|
||||
f'[DownloadsWatchdog] ✅ Found downloaded file: {file_path} ({file_size} bytes)'
|
||||
)
|
||||
|
||||
# Determine file type from extension
|
||||
file_ext = file_path.suffix.lower().lstrip('.')
|
||||
file_type = file_ext if file_ext else None
|
||||
|
||||
# Dispatch download event
|
||||
# Skip if already handled by progress/JS fetch
|
||||
info = self._cdp_downloads_info.get(guid, {})
|
||||
if info.get('handled'):
|
||||
return
|
||||
self.event_bus.dispatch(
|
||||
FileDownloadedEvent(
|
||||
url=download_url,
|
||||
path=str(file_path),
|
||||
file_name=file_path.name,
|
||||
file_size=file_size,
|
||||
file_type=file_type,
|
||||
)
|
||||
)
|
||||
# Mark as handled after dispatch
|
||||
try:
|
||||
if guid in self._cdp_downloads_info:
|
||||
self._cdp_downloads_info[guid]['handled'] = True
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.debug(f'[DownloadsWatchdog] Error checking file {file_path}: {e}')
|
||||
|
||||
self.logger.warning(f'[DownloadsWatchdog] Download did not complete within {max_wait} seconds')
|
||||
|
||||
async def _handle_download(self, download: Any) -> None:
|
||||
"""Handle a download event."""
|
||||
download_id = f'{id(download)}'
|
||||
self._active_downloads[download_id] = download
|
||||
self.logger.debug(f'[DownloadsWatchdog] ⬇️ Handling download: {download.suggested_filename} from {download.url[:100]}...')
|
||||
|
||||
# Debug: Check if download is already being handled elsewhere
|
||||
failure = (
|
||||
await download.failure()
|
||||
) # TODO: it always fails for some reason, figure out why connect_over_cdp makes accept_downloads not work
|
||||
self.logger.warning(f'[DownloadsWatchdog] ❌ Download state - canceled: {failure}, url: {download.url}')
|
||||
# logger.info(f'[DownloadsWatchdog] Active downloads count: {len(self._active_downloads)}')
|
||||
|
||||
try:
|
||||
current_step = 'getting_download_info'
|
||||
# Get download info immediately
|
||||
url = download.url
|
||||
suggested_filename = download.suggested_filename
|
||||
|
||||
current_step = 'determining_download_directory'
|
||||
# Determine download directory from browser profile
|
||||
downloads_dir = self.browser_session.browser_profile.downloads_path
|
||||
if not downloads_dir:
|
||||
downloads_dir = str(Path.home() / 'Downloads')
|
||||
else:
|
||||
downloads_dir = str(downloads_dir) # Ensure it's a string
|
||||
|
||||
# Check if Playwright already auto-downloaded the file (due to CDP setup)
|
||||
original_path = Path(downloads_dir) / suggested_filename
|
||||
if original_path.exists() and original_path.stat().st_size > 0:
|
||||
self.logger.debug(
|
||||
f'[DownloadsWatchdog] File already downloaded by Playwright: {original_path} ({original_path.stat().st_size} bytes)'
|
||||
)
|
||||
|
||||
# Use the existing file instead of creating a duplicate
|
||||
download_path = original_path
|
||||
file_size = original_path.stat().st_size
|
||||
unique_filename = suggested_filename
|
||||
else:
|
||||
current_step = 'generating_unique_filename'
|
||||
# Ensure unique filename
|
||||
unique_filename = await self._get_unique_filename(downloads_dir, suggested_filename)
|
||||
download_path = Path(downloads_dir) / unique_filename
|
||||
|
||||
self.logger.debug(f'[DownloadsWatchdog] Download started: {unique_filename} from {url[:100]}...')
|
||||
|
||||
current_step = 'calling_save_as'
|
||||
# Save the download using Playwright's save_as method
|
||||
self.logger.debug(f'[DownloadsWatchdog] Saving download to: {download_path}')
|
||||
self.logger.debug(f'[DownloadsWatchdog] Download path exists: {download_path.parent.exists()}')
|
||||
self.logger.debug(f'[DownloadsWatchdog] Download path writable: {os.access(download_path.parent, os.W_OK)}')
|
||||
|
||||
try:
|
||||
self.logger.debug('[DownloadsWatchdog] About to call download.save_as()...')
|
||||
await download.save_as(str(download_path))
|
||||
self.logger.debug(f'[DownloadsWatchdog] Successfully saved download to: {download_path}')
|
||||
current_step = 'save_as_completed'
|
||||
except Exception as save_error:
|
||||
self.logger.error(f'[DownloadsWatchdog] save_as() failed with error: {save_error}')
|
||||
raise save_error
|
||||
|
||||
# Get file info
|
||||
file_size = download_path.stat().st_size if download_path.exists() else 0
|
||||
|
||||
# Determine file type from extension
|
||||
file_ext = download_path.suffix.lower().lstrip('.')
|
||||
file_type = file_ext if file_ext else None
|
||||
|
||||
# Try to get MIME type from response headers if available
|
||||
mime_type = None
|
||||
# Note: Playwright doesn't expose response headers directly from Download object
|
||||
|
||||
# Check if this was a PDF auto-download
|
||||
auto_download = False
|
||||
if file_type == 'pdf':
|
||||
auto_download = self._is_auto_download_enabled()
|
||||
|
||||
# Emit download event
|
||||
self.event_bus.dispatch(
|
||||
FileDownloadedEvent(
|
||||
url=url,
|
||||
path=str(download_path),
|
||||
file_name=suggested_filename,
|
||||
file_size=file_size,
|
||||
file_type=file_type,
|
||||
mime_type=mime_type,
|
||||
from_cache=False,
|
||||
auto_download=auto_download,
|
||||
)
|
||||
)
|
||||
|
||||
self.logger.debug(
|
||||
f'[DownloadsWatchdog] ✅ Download completed: {suggested_filename} ({file_size} bytes) saved to {download_path}'
|
||||
)
|
||||
|
||||
# File is now tracked on filesystem, no need to track in memory
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f'[DownloadsWatchdog] Error handling download at step "{locals().get("current_step", "unknown")}", error: {e}'
|
||||
)
|
||||
self.logger.error(
|
||||
f'[DownloadsWatchdog] Download state - URL: {download.url}, filename: {download.suggested_filename}'
|
||||
)
|
||||
finally:
|
||||
# Clean up tracking
|
||||
if download_id in self._active_downloads:
|
||||
del self._active_downloads[download_id]
|
||||
|
||||
async def check_for_pdf_viewer(self, target_id: TargetID) -> bool:
|
||||
"""Check if the current target is a PDF using network-based detection.
|
||||
|
||||
This method avoids JavaScript execution that can crash WebSocket connections.
|
||||
Returns True if a PDF is detected and should be downloaded.
|
||||
"""
|
||||
self.logger.debug(f'[DownloadsWatchdog] Checking if target {target_id} is PDF viewer...')
|
||||
|
||||
# Get target info to get URL
|
||||
cdp_client = self.browser_session.cdp_client
|
||||
targets = await cdp_client.send.Target.getTargets()
|
||||
target_info = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
|
||||
if not target_info:
|
||||
self.logger.warning(f'[DownloadsWatchdog] No target info found for {target_id}')
|
||||
return False
|
||||
|
||||
page_url = target_info.get('url', '')
|
||||
|
||||
# Check cache first
|
||||
if page_url in self._pdf_viewer_cache:
|
||||
cached_result = self._pdf_viewer_cache[page_url]
|
||||
self.logger.debug(f'[DownloadsWatchdog] Using cached PDF check result for {page_url}: {cached_result}')
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
# Method 1: Check URL patterns (fastest, most reliable)
|
||||
url_is_pdf = self._check_url_for_pdf(page_url)
|
||||
if url_is_pdf:
|
||||
self.logger.debug(f'[DownloadsWatchdog] PDF detected via URL pattern: {page_url}')
|
||||
self._pdf_viewer_cache[page_url] = True
|
||||
return True
|
||||
|
||||
# Method 2: Check network response headers via CDP (safer than JavaScript)
|
||||
header_is_pdf = await self._check_network_headers_for_pdf(target_id)
|
||||
if header_is_pdf:
|
||||
self.logger.debug(f'[DownloadsWatchdog] PDF detected via network headers: {page_url}')
|
||||
self._pdf_viewer_cache[page_url] = True
|
||||
return True
|
||||
|
||||
# Method 3: Check Chrome's PDF viewer specific URLs
|
||||
chrome_pdf_viewer = self._is_chrome_pdf_viewer_url(page_url)
|
||||
if chrome_pdf_viewer:
|
||||
self.logger.debug(f'[DownloadsWatchdog] Chrome PDF viewer detected: {page_url}')
|
||||
self._pdf_viewer_cache[page_url] = True
|
||||
return True
|
||||
|
||||
# Not a PDF
|
||||
self._pdf_viewer_cache[page_url] = False
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'[DownloadsWatchdog] ❌ Error checking for PDF viewer: {e}')
|
||||
self._pdf_viewer_cache[page_url] = False
|
||||
return False
|
||||
|
||||
def _check_url_for_pdf(self, url: str) -> bool:
|
||||
"""Check if URL indicates a PDF file."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
url_lower = url.lower()
|
||||
|
||||
# Direct PDF file extensions
|
||||
if url_lower.endswith('.pdf'):
|
||||
return True
|
||||
|
||||
# PDF in path
|
||||
if '.pdf' in url_lower:
|
||||
return True
|
||||
|
||||
# PDF MIME type in URL parameters
|
||||
if any(
|
||||
param in url_lower
|
||||
for param in [
|
||||
'content-type=application/pdf',
|
||||
'content-type=application%2fpdf',
|
||||
'mimetype=application/pdf',
|
||||
'type=application/pdf',
|
||||
]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_chrome_pdf_viewer_url(self, url: str) -> bool:
|
||||
"""Check if this is Chrome's internal PDF viewer URL."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
url_lower = url.lower()
|
||||
|
||||
# Chrome PDF viewer uses chrome-extension:// URLs
|
||||
if 'chrome-extension://' in url_lower and 'pdf' in url_lower:
|
||||
return True
|
||||
|
||||
# Chrome PDF viewer internal URLs
|
||||
if url_lower.startswith('chrome://') and 'pdf' in url_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _check_network_headers_for_pdf(self, target_id: TargetID) -> bool:
|
||||
"""Infer PDF via navigation history/URL; headers are not available post-navigation in this context."""
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
# Get CDP session
|
||||
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
|
||||
|
||||
# Get navigation history to find the main resource
|
||||
history = await asyncio.wait_for(
|
||||
temp_session.cdp_client.send.Page.getNavigationHistory(session_id=temp_session.session_id), timeout=3.0
|
||||
)
|
||||
|
||||
current_entry = history.get('entries', [])
|
||||
if current_entry:
|
||||
current_index = history.get('currentIndex', 0)
|
||||
if 0 <= current_index < len(current_entry):
|
||||
current_url = current_entry[current_index].get('url', '')
|
||||
|
||||
# Check if the URL itself suggests PDF
|
||||
if self._check_url_for_pdf(current_url):
|
||||
return True
|
||||
|
||||
# Note: CDP doesn't easily expose response headers for completed navigations
|
||||
# For more complex cases, we'd need to set up Network.responseReceived listeners
|
||||
# before navigation, but that's overkill for most PDF detection cases
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f'[DownloadsWatchdog] Network headers check failed (non-critical): {e}')
|
||||
return False
|
||||
|
||||
async def trigger_pdf_download(self, target_id: TargetID) -> str | None:
|
||||
"""Trigger download of a PDF from Chrome's PDF viewer.
|
||||
|
||||
Returns the download path if successful, None otherwise.
|
||||
"""
|
||||
self.logger.debug(f'[DownloadsWatchdog] trigger_pdf_download called for target_id={target_id}')
|
||||
|
||||
if not self.browser_session.browser_profile.downloads_path:
|
||||
self.logger.warning('[DownloadsWatchdog] ❌ No downloads path configured, cannot save PDF download')
|
||||
return None
|
||||
|
||||
downloads_path = self.browser_session.browser_profile.downloads_path
|
||||
self.logger.debug(f'[DownloadsWatchdog] Downloads path: {downloads_path}')
|
||||
|
||||
try:
|
||||
# Create a temporary CDP session for this target without switching focus
|
||||
import asyncio
|
||||
|
||||
self.logger.debug(f'[DownloadsWatchdog] Creating CDP session for PDF download from target {target_id}')
|
||||
temp_session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False)
|
||||
|
||||
# Try to get the PDF URL with timeout
|
||||
result = await asyncio.wait_for(
|
||||
temp_session.cdp_client.send.Runtime.evaluate(
|
||||
params={
|
||||
'expression': """
|
||||
(() => {
|
||||
// For Chrome's PDF viewer, the actual URL is in window.location.href
|
||||
// The embed element's src is often "about:blank"
|
||||
const embedElement = document.querySelector('embed[type="application/x-google-chrome-pdf"]') ||
|
||||
document.querySelector('embed[type="application/pdf"]');
|
||||
if (embedElement) {
|
||||
// Chrome PDF viewer detected - use the page URL
|
||||
return { url: window.location.href };
|
||||
}
|
||||
// Fallback to window.location.href anyway
|
||||
return { url: window.location.href };
|
||||
})()
|
||||
""",
|
||||
'returnByValue': True,
|
||||
},
|
||||
session_id=temp_session.session_id,
|
||||
),
|
||||
timeout=5.0, # 5 second timeout to prevent hanging
|
||||
)
|
||||
pdf_info = result.get('result', {}).get('value', {})
|
||||
|
||||
pdf_url = pdf_info.get('url', '')
|
||||
if not pdf_url:
|
||||
self.logger.warning(f'[DownloadsWatchdog] ❌ Could not determine PDF URL for download {pdf_info}')
|
||||
return None
|
||||
|
||||
# Generate filename from URL
|
||||
pdf_filename = os.path.basename(pdf_url.split('?')[0]) # Remove query params
|
||||
if not pdf_filename or not pdf_filename.endswith('.pdf'):
|
||||
parsed = urlparse(pdf_url)
|
||||
pdf_filename = os.path.basename(parsed.path) or 'document.pdf'
|
||||
if not pdf_filename.endswith('.pdf'):
|
||||
pdf_filename += '.pdf'
|
||||
|
||||
self.logger.debug(f'[DownloadsWatchdog] Generated filename: {pdf_filename}')
|
||||
|
||||
# Check if already downloaded by looking in the downloads directory
|
||||
downloads_dir = str(self.browser_session.browser_profile.downloads_path)
|
||||
if os.path.exists(downloads_dir):
|
||||
existing_files = os.listdir(downloads_dir)
|
||||
if pdf_filename in existing_files:
|
||||
self.logger.debug(f'[DownloadsWatchdog] PDF already downloaded: {pdf_filename}')
|
||||
return None
|
||||
|
||||
self.logger.debug(f'[DownloadsWatchdog] Starting PDF download from: {pdf_url[:100]}...')
|
||||
|
||||
# Download using JavaScript fetch to leverage browser cache
|
||||
try:
|
||||
# Properly escape the URL to prevent JavaScript injection
|
||||
escaped_pdf_url = json.dumps(pdf_url)
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
temp_session.cdp_client.send.Runtime.evaluate(
|
||||
params={
|
||||
'expression': f"""
|
||||
(async () => {{
|
||||
try {{
|
||||
// Use fetch with cache: 'force-cache' to prioritize cached version
|
||||
const response = await fetch({escaped_pdf_url}, {{
|
||||
cache: 'force-cache'
|
||||
}});
|
||||
if (!response.ok) {{
|
||||
throw new Error(`HTTP error! status: ${{response.status}}`);
|
||||
}}
|
||||
const blob = await response.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// Check if served from cache
|
||||
const fromCache = response.headers.has('age') ||
|
||||
!response.headers.has('date');
|
||||
|
||||
return {{
|
||||
data: Array.from(uint8Array),
|
||||
fromCache: fromCache,
|
||||
responseSize: uint8Array.length,
|
||||
transferSize: response.headers.get('content-length') || 'unknown'
|
||||
}};
|
||||
}} catch (error) {{
|
||||
throw new Error(`Fetch failed: ${{error.message}}`);
|
||||
}}
|
||||
}})()
|
||||
""",
|
||||
'awaitPromise': True,
|
||||
'returnByValue': True,
|
||||
},
|
||||
session_id=temp_session.session_id,
|
||||
),
|
||||
timeout=10.0, # 10 second timeout for download operation
|
||||
)
|
||||
download_result = result.get('result', {}).get('value', {})
|
||||
|
||||
if download_result and download_result.get('data') and len(download_result['data']) > 0:
|
||||
# Ensure unique filename
|
||||
downloads_dir = str(self.browser_session.browser_profile.downloads_path)
|
||||
# Ensure downloads directory exists
|
||||
os.makedirs(downloads_dir, exist_ok=True)
|
||||
unique_filename = await self._get_unique_filename(downloads_dir, pdf_filename)
|
||||
download_path = os.path.join(downloads_dir, unique_filename)
|
||||
|
||||
# Save the PDF asynchronously
|
||||
async with await anyio.open_file(download_path, 'wb') as f:
|
||||
await f.write(bytes(download_result['data']))
|
||||
|
||||
# Verify file was written successfully
|
||||
if os.path.exists(download_path):
|
||||
actual_size = os.path.getsize(download_path)
|
||||
self.logger.debug(
|
||||
f'[DownloadsWatchdog] PDF file written successfully: {download_path} ({actual_size} bytes)'
|
||||
)
|
||||
else:
|
||||
self.logger.error(f'[DownloadsWatchdog] ❌ Failed to write PDF file to: {download_path}')
|
||||
return None
|
||||
|
||||
# Log cache information
|
||||
cache_status = 'from cache' if download_result.get('fromCache') else 'from network'
|
||||
response_size = download_result.get('responseSize', 0)
|
||||
self.logger.debug(
|
||||
f'[DownloadsWatchdog] ✅ Auto-downloaded PDF ({cache_status}, {response_size:,} bytes): {download_path}'
|
||||
)
|
||||
|
||||
# Emit file downloaded event
|
||||
self.logger.debug(f'[DownloadsWatchdog] Dispatching FileDownloadedEvent for {unique_filename}')
|
||||
self.event_bus.dispatch(
|
||||
FileDownloadedEvent(
|
||||
url=pdf_url,
|
||||
path=download_path,
|
||||
file_name=unique_filename,
|
||||
file_size=response_size,
|
||||
file_type='pdf',
|
||||
mime_type='application/pdf',
|
||||
from_cache=download_result.get('fromCache', False),
|
||||
auto_download=True,
|
||||
)
|
||||
)
|
||||
|
||||
# No need to detach - session is cached
|
||||
return download_path
|
||||
else:
|
||||
self.logger.warning(f'[DownloadsWatchdog] No data received when downloading PDF from {pdf_url}')
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'[DownloadsWatchdog] Failed to auto-download PDF from {pdf_url}: {type(e).__name__}: {e}')
|
||||
return None
|
||||
|
||||
except TimeoutError:
|
||||
self.logger.debug('[DownloadsWatchdog] PDF download operation timed out')
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f'[DownloadsWatchdog] Error in PDF download: {type(e).__name__}: {e}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _get_unique_filename(directory: str, filename: str) -> str:
|
||||
"""Generate a unique filename for downloads by appending (1), (2), etc., if a file already exists."""
|
||||
base, ext = os.path.splitext(filename)
|
||||
counter = 1
|
||||
new_filename = filename
|
||||
while os.path.exists(os.path.join(directory, new_filename)):
|
||||
new_filename = f'{base} ({counter}){ext}'
|
||||
counter += 1
|
||||
return new_filename
|
||||
|
||||
|
||||
# Fix Pydantic circular dependency - this will be called from session.py after BrowserSession is defined
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
"""Local browser watchdog for managing browser subprocess lifecycle."""
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
|
||||
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 (system browsers preferred)
|
||||
browser_path = self._find_installed_browser_path()
|
||||
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...')
|
||||
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 any temp dirs we created but didn't use
|
||||
for tmp_dir in self._temp_dirs_to_cleanup:
|
||||
try:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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() -> str | None:
|
||||
"""Try to find browser executable from common fallback locations.
|
||||
|
||||
Prioritizes:
|
||||
1. System Chrome Stable
|
||||
1. Playwright chromium
|
||||
2. Other system native browsers (Chromium -> Chrome Canary/Dev -> Brave)
|
||||
3. Playwright headless-shell fallback
|
||||
|
||||
Returns:
|
||||
Path to browser executable or None if not found
|
||||
"""
|
||||
import glob
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
system = platform.system()
|
||||
patterns = []
|
||||
|
||||
# Get playwright browsers path from environment variable if set
|
||||
playwright_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH')
|
||||
|
||||
if system == 'Darwin': # macOS
|
||||
if not playwright_path:
|
||||
playwright_path = '~/Library/Caches/ms-playwright'
|
||||
patterns = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
f'{playwright_path}/chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
||||
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'
|
||||
patterns = [
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/local/bin/google-chrome',
|
||||
f'{playwright_path}/chromium-*/chrome-linux/chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/google-chrome-beta',
|
||||
'/usr/bin/google-chrome-dev',
|
||||
'/usr/bin/brave-browser',
|
||||
f'{playwright_path}/chromium_headless_shell-*/chrome-linux/chrome',
|
||||
]
|
||||
elif system == 'Windows':
|
||||
if not playwright_path:
|
||||
playwright_path = r'%LOCALAPPDATA%\ms-playwright'
|
||||
patterns = [
|
||||
r'C:\Program Files\Google\Chrome\Application\chrome.exe',
|
||||
r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
|
||||
r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe',
|
||||
r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe',
|
||||
r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe',
|
||||
f'{playwright_path}\\chromium-*\\chrome-win\\chrome.exe',
|
||||
r'C:\Program Files\Chromium\Application\chrome.exe',
|
||||
r'C:\Program Files (x86)\Chromium\Application\chrome.exe',
|
||||
r'%LOCALAPPDATA%\Chromium\Application\chrome.exe',
|
||||
r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe',
|
||||
r'C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe',
|
||||
r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
|
||||
r'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
|
||||
r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe',
|
||||
f'{playwright_path}\\chromium_headless_shell-*\\chrome-win\\chrome.exe',
|
||||
]
|
||||
|
||||
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."""
|
||||
|
||||
# Run in subprocess with timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'uvx',
|
||||
'playwright',
|
||||
'install',
|
||||
'chrome',
|
||||
'--with-deps',
|
||||
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 chrome --with-deps')
|
||||
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://localhost:{port}/json/version') as resp:
|
||||
if resp.status == 200:
|
||||
# Chrome is ready
|
||||
return f'http://localhost:{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
|
||||
+43
@@ -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
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
|
||||
# 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')
|
||||
|
||||
# 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', '')
|
||||
|
||||
self.logger.info(f"🔔 JavaScript {dialog_type} dialog: '{message[:100]}' - attempting to accept...")
|
||||
|
||||
self.logger.debug('Trying all approaches to accept dialog...')
|
||||
|
||||
# Approach 1: Use the session that detected the dialog
|
||||
if self.browser_session._cdp_client_root and session_id:
|
||||
try:
|
||||
self.logger.debug(f'🔄 Approach 1: Using session {session_id}')
|
||||
await asyncio.wait_for(
|
||||
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
|
||||
params={'accept': True},
|
||||
session_id=session_id,
|
||||
),
|
||||
timeout=0.25,
|
||||
)
|
||||
except (TimeoutError, Exception) as e:
|
||||
pass
|
||||
|
||||
# Approach 2: Try with current agent focus session
|
||||
if self.browser_session._cdp_client_root and self.browser_session.agent_focus:
|
||||
try:
|
||||
self.logger.debug(
|
||||
f'🔄 Approach 2: Using agent focus session {self.browser_session.agent_focus.session_id}'
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
self.browser_session._cdp_client_root.send.Page.handleJavaScriptDialog(
|
||||
params={'accept': True},
|
||||
session_id=self.browser_session.agent_focus.session_id,
|
||||
),
|
||||
timeout=0.25,
|
||||
)
|
||||
except (TimeoutError, Exception) as e:
|
||||
pass
|
||||
|
||||
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}')
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
"""Recording Watchdog for Browser Use Sessions."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from bubus import BaseEvent
|
||||
from cdp_use.cdp.page.events import ScreencastFrameEvent
|
||||
from uuid_extensions import uuid7str
|
||||
|
||||
from browser_use.browser.events import 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
|
||||
|
||||
|
||||
class RecordingWatchdog(BaseWatchdog):
|
||||
"""
|
||||
Manages video recording of a browser session using CDP screencasting.
|
||||
"""
|
||||
|
||||
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [BrowserConnectedEvent, BrowserStopEvent]
|
||||
EMITS: ClassVar[list[type[BaseEvent]]] = []
|
||||
|
||||
_recorder: VideoRecorderService | None = 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
|
||||
|
||||
# Dynamically determine video size
|
||||
size = profile.record_video_size
|
||||
if not size:
|
||||
self.logger.debug('record_video_size not specified, detecting viewport size...')
|
||||
size = await self._get_current_viewport_size()
|
||||
|
||||
if not size:
|
||||
self.logger.warning('Cannot start video recording: viewport size could not be determined.')
|
||||
return
|
||||
|
||||
video_format = getattr(profile, 'record_video_format', 'mp4').strip('.')
|
||||
output_path = Path(profile.record_video_dir) / f'{uuid7str()}.{video_format}'
|
||||
|
||||
self.logger.debug(f'Initializing video recorder for format: {video_format}')
|
||||
self._recorder = VideoRecorderService(output_path=output_path, size=size, framerate=profile.record_video_framerate)
|
||||
self._recorder.start()
|
||||
|
||||
if not self._recorder._is_active:
|
||||
self._recorder = None
|
||||
return
|
||||
|
||||
self.browser_session.cdp_client.register.Page.screencastFrame(self.on_screencastFrame)
|
||||
|
||||
try:
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
await cdp_session.cdp_client.send.Page.startScreencast(
|
||||
params={
|
||||
'format': 'png',
|
||||
'quality': 90,
|
||||
'maxWidth': size['width'],
|
||||
'maxHeight': size['height'],
|
||||
'everyNthFrame': 1,
|
||||
},
|
||||
session_id=cdp_session.session_id,
|
||||
)
|
||||
self.logger.info(f'📹 Started video recording to {output_path}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to start screencast via CDP: {e}')
|
||||
if self._recorder:
|
||||
self._recorder.stop_and_save()
|
||||
self._recorder = 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.
|
||||
"""
|
||||
if not self._recorder:
|
||||
return
|
||||
self._recorder.add_frame(event['data'])
|
||||
asyncio.create_task(self._ack_screencast_frame(event, session_id))
|
||||
|
||||
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:
|
||||
recorder = self._recorder
|
||||
self._recorder = None
|
||||
|
||||
self.logger.debug('Stopping video recording and saving file...')
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, recorder.stop_and_save)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""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:
|
||||
# Get CDP client and session for current target
|
||||
cdp_session = await self.browser_session.get_or_create_cdp_session()
|
||||
|
||||
# Prepare screenshot parameters
|
||||
params = CaptureScreenshotParameters(format='png', captureBeyondViewport=False)
|
||||
|
||||
# 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
|
||||
finally:
|
||||
# Try to remove highlights even on failure
|
||||
try:
|
||||
await self.browser_session.remove_highlights()
|
||||
except Exception:
|
||||
pass
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
"""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 and close tab if not."""
|
||||
# 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 to non-allowed URL: {event.url}',
|
||||
details={'url': event.url, 'target_id': event.target_id},
|
||||
)
|
||||
)
|
||||
|
||||
# Close the target that navigated to the disallowed URL
|
||||
try:
|
||||
await self.browser_session._cdp_close_page(event.target_id)
|
||||
self.logger.info(f'⛔️ Closed target with non-allowed URL: {event.url}')
|
||||
except Exception as e:
|
||||
self.logger.error(f'⛔️ Failed to close target with non-allowed URL: {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 _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
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
# Always allow internal browser targets
|
||||
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
|
||||
|
||||
# Get the actual host (domain)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Check each allowed domain pattern
|
||||
if self.browser_session.browser_profile.allowed_domains:
|
||||
for pattern in self.browser_session.browser_profile.allowed_domains:
|
||||
if self._is_url_match(url, host, parsed.scheme, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Check each prohibited domain pattern
|
||||
if self.browser_session.browser_profile.prohibited_domains:
|
||||
for pattern in self.browser_session.browser_profile.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://* should match any brave:// URL
|
||||
prefix = pattern[:-1] # Remove the * at the end
|
||||
if url.startswith(prefix):
|
||||
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
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 = asyncio.create_task(self._monitor_storage_changes())
|
||||
# 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, new_socket=False)
|
||||
|
||||
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))
|
||||
|
||||
# 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']:
|
||||
await self.browser_session._cdp_set_cookies(storage['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']:
|
||||
if 'localStorage' in origin:
|
||||
for item in origin['localStorage']:
|
||||
script = f"""
|
||||
window.localStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])});
|
||||
"""
|
||||
await self.browser_session._cdp_add_init_script(script)
|
||||
if 'sessionStorage' in origin:
|
||||
for item in origin['sessionStorage']:
|
||||
script = f"""
|
||||
window.sessionStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])});
|
||||
"""
|
||||
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