import asyncio import logging import time from typing import TYPE_CHECKING, Any from cdp_use.cdp.accessibility.commands import GetFullAXTreeReturns from cdp_use.cdp.accessibility.types import AXNode from cdp_use.cdp.dom.types import Node from cdp_use.cdp.target import TargetID from browser_use.dom.enhanced_snapshot import ( REQUIRED_COMPUTED_STYLES, build_snapshot_lookup, ) from browser_use.dom.serializer.clickable_elements import ClickableElementDetector from browser_use.dom.serializer.serializer import DOMTreeSerializer from browser_use.dom.views import ( DOMRect, EnhancedAXNode, EnhancedAXProperty, EnhancedDOMTreeNode, NodeType, SerializedDOMState, TargetAllTrees, ) from browser_use.observability import observe_debug from browser_use.utils import create_task_with_error_handling if TYPE_CHECKING: from browser_use.browser.session import BrowserSession # Note: iframe limits are now configurable via BrowserProfile.max_iframes and BrowserProfile.max_iframe_depth class DomService: """ Service for getting the DOM tree and other DOM-related information. Either browser or page must be provided. TODO: currently we start a new websocket connection PER STEP, we should definitely keep this persistent """ logger: logging.Logger def __init__( self, browser_session: 'BrowserSession', logger: logging.Logger | None = None, cross_origin_iframes: bool = False, paint_order_filtering: bool = True, max_iframes: int = 100, max_iframe_depth: int = 5, viewport_threshold: int | None = 1000, ): self.browser_session = browser_session self.logger = logger or browser_session.logger self.cross_origin_iframes = cross_origin_iframes self.paint_order_filtering = paint_order_filtering self.max_iframes = max_iframes self.max_iframe_depth = max_iframe_depth self.viewport_threshold = viewport_threshold async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_value, traceback): pass # no need to cleanup anything, browser_session auto handles cleaning up session cache def _count_hidden_elements_in_iframes(self, node: EnhancedDOMTreeNode) -> None: """Collect hidden interactive elements in iframes for LLM hints. For each iframe, collects details of hidden interactive elements including tag, text/name, and scroll distance in pages so the agent knows how far to scroll. """ def is_hidden_by_threshold(element: EnhancedDOMTreeNode) -> bool: """Check if element is hidden by viewport threshold (not CSS).""" if element.is_visible or not element.snapshot_node or not element.snapshot_node.bounds: return False computed_styles = element.snapshot_node.computed_styles or {} display = computed_styles.get('display', '').lower() visibility = computed_styles.get('visibility', '').lower() opacity = computed_styles.get('opacity', '1') css_hidden = display == 'none' or visibility == 'hidden' try: css_hidden = css_hidden or float(opacity) <= 0 except (ValueError, TypeError): pass return not css_hidden def collect_hidden_elements(subtree_root: EnhancedDOMTreeNode, viewport_height: float) -> list[dict[str, Any]]: """Collect hidden interactive elements from subtree.""" hidden: list[dict[str, Any]] = [] if subtree_root.node_type == NodeType.ELEMENT_NODE: is_interactive = ClickableElementDetector.is_interactive(subtree_root) if is_interactive and is_hidden_by_threshold(subtree_root): # Get element text/name text = '' if subtree_root.ax_node and subtree_root.ax_node.name: text = subtree_root.ax_node.name[:40] elif subtree_root.attributes: text = ( subtree_root.attributes.get('placeholder', '') or subtree_root.attributes.get('title', '') or subtree_root.attributes.get('aria-label', '') )[:40] # Get y position and convert to pages y_pos = 0.0 if subtree_root.snapshot_node and subtree_root.snapshot_node.bounds: y_pos = subtree_root.snapshot_node.bounds.y pages_down = round(y_pos / viewport_height, 1) if viewport_height > 0 else 0 hidden.append( { 'tag': subtree_root.tag_name or '?', 'text': text or '(no label)', 'pages': pages_down, } ) for child in subtree_root.children_nodes or []: hidden.extend(collect_hidden_elements(child, viewport_height)) for shadow_root in subtree_root.shadow_roots or []: hidden.extend(collect_hidden_elements(shadow_root, viewport_height)) return hidden def has_any_hidden_content(subtree_root: EnhancedDOMTreeNode) -> bool: """Check if there's any hidden content (interactive or not) in subtree.""" if is_hidden_by_threshold(subtree_root): return True for child in subtree_root.children_nodes or []: if has_any_hidden_content(child): return True for shadow_root in subtree_root.shadow_roots or []: if has_any_hidden_content(shadow_root): return True return False def process_node(current_node: EnhancedDOMTreeNode) -> None: """Process node and descendants, collecting hidden elements for iframes.""" if ( current_node.node_type == NodeType.ELEMENT_NODE and current_node.tag_name and current_node.tag_name.upper() in ('IFRAME', 'FRAME') and current_node.content_document ): # Get viewport height from iframe's client rect viewport_height = 0.0 if current_node.snapshot_node and current_node.snapshot_node.clientRects: viewport_height = current_node.snapshot_node.clientRects.height hidden = collect_hidden_elements(current_node.content_document, viewport_height) # Sort by pages and limit to avoid bloating context hidden.sort(key=lambda x: x['pages']) current_node.hidden_elements_info = hidden[:10] # Limit to 10 # Check for hidden non-interactive content when no interactive elements found if not hidden and has_any_hidden_content(current_node.content_document): current_node.has_hidden_content = True for child in current_node.children_nodes or []: process_node(child) if current_node.content_document: process_node(current_node.content_document) for shadow_root in current_node.shadow_roots or []: process_node(shadow_root) process_node(node) def _build_enhanced_ax_node(self, ax_node: AXNode) -> EnhancedAXNode: properties: list[EnhancedAXProperty] | None = None if 'properties' in ax_node and ax_node['properties']: properties = [] for property in ax_node['properties']: try: # test whether property name can go into the enum (sometimes Chrome returns some random properties) properties.append( EnhancedAXProperty( name=property['name'], value=property.get('value', {}).get('value', None), # related_nodes=[], # TODO: add related nodes ) ) except ValueError: pass enhanced_ax_node = EnhancedAXNode( ax_node_id=ax_node['nodeId'], ignored=ax_node['ignored'], role=ax_node.get('role', {}).get('value', None), name=ax_node.get('name', {}).get('value', None), description=ax_node.get('description', {}).get('value', None), properties=properties, child_ids=ax_node.get('childIds', []) if ax_node.get('childIds') else None, ) return enhanced_ax_node async def _get_viewport_ratio(self, target_id: TargetID) -> float: """Get viewport dimensions, device pixel ratio, and scroll position using CDP.""" cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target_id, focus=False) try: # Get the layout metrics which includes the visual viewport metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id) visual_viewport = metrics.get('visualViewport', {}) # IMPORTANT: Use CSS viewport instead of device pixel viewport # This fixes the coordinate mismatch on high-DPI displays css_visual_viewport = metrics.get('cssVisualViewport', {}) css_layout_viewport = metrics.get('cssLayoutViewport', {}) # Use CSS pixels (what JavaScript sees) instead of device pixels width = css_visual_viewport.get('clientWidth', css_layout_viewport.get('clientWidth', 1920.0)) # Calculate device pixel ratio device_width = visual_viewport.get('clientWidth', width) css_width = css_visual_viewport.get('clientWidth', width) device_pixel_ratio = device_width / css_width if css_width > 0 else 1.0 return float(device_pixel_ratio) except Exception as e: self.logger.debug(f'Viewport size detection failed: {e}') # Fallback to default viewport size return 1.0 @classmethod def is_element_visible_according_to_all_parents( cls, node: EnhancedDOMTreeNode, html_frames: list[EnhancedDOMTreeNode], viewport_threshold: int | None = 1000 ) -> bool: """Check if the element is visible according to all its parent HTML frames. Args: node: The DOM node to check visibility for html_frames: List of parent HTML frame nodes viewport_threshold: Pixel threshold beyond viewport to consider visible. Default 1000px. Set to None to disable threshold checking entirely. """ if not node.snapshot_node: return False computed_styles = node.snapshot_node.computed_styles or {} display = computed_styles.get('display', '').lower() visibility = computed_styles.get('visibility', '').lower() opacity = computed_styles.get('opacity', '1') if display == 'none' or visibility == 'hidden': return False try: if float(opacity) <= 0: return False except (ValueError, TypeError): pass if not node.snapshot_node.bounds: return False # If there are no bounds, the element is not visible # work on a copy: snapshot bounds are shared, in-place mutation corrupts other consumers current_bounds = DOMRect( x=node.snapshot_node.bounds.x, y=node.snapshot_node.bounds.y, width=node.snapshot_node.bounds.width, height=node.snapshot_node.bounds.height, ) # If threshold is None, skip all viewport-based filtering (only check CSS visibility) if viewport_threshold is None: return True """ Reverse iterate through the html frames (that can be either iframe or document -> if it's a document frame compare if the current bounds interest with it (taking scroll into account) otherwise move the current bounds by the iframe offset) """ for frame in reversed(html_frames): # skip self: a frame node appears in its own frame chain and must not offset itself if frame is node: continue if ( frame.node_type == NodeType.ELEMENT_NODE and (frame.node_name.upper() == 'IFRAME' or frame.node_name.upper() == 'FRAME') and frame.snapshot_node and frame.snapshot_node.bounds ): iframe_bounds = frame.snapshot_node.bounds # negate the values added in `_construct_enhanced_node` current_bounds.x += iframe_bounds.x current_bounds.y += iframe_bounds.y if ( frame.node_type == NodeType.ELEMENT_NODE and frame.node_name == 'HTML' and frame.snapshot_node and frame.snapshot_node.scrollRects and frame.snapshot_node.clientRects ): # For iframe content, we need to check visibility within the iframe's viewport # The scrollRects represent the current scroll position # The clientRects represent the viewport size # Elements are visible if they fall within the viewport after accounting for scroll # The viewport of the frame (what's actually visible) viewport_left = 0 # Viewport always starts at 0 in frame coordinates viewport_top = 0 viewport_right = frame.snapshot_node.clientRects.width viewport_bottom = frame.snapshot_node.clientRects.height # Adjust element bounds by the scroll offset to get position relative to viewport # When scrolled down, scrollRects.y is positive, so we subtract it from element's y adjusted_x = current_bounds.x - frame.snapshot_node.scrollRects.x adjusted_y = current_bounds.y - frame.snapshot_node.scrollRects.y frame_intersects = ( adjusted_x < viewport_right and adjusted_x + current_bounds.width > viewport_left and adjusted_y < viewport_bottom + viewport_threshold and adjusted_y + current_bounds.height > viewport_top - viewport_threshold ) if not frame_intersects: return False # Keep the original coordinate adjustment to maintain consistency # This adjustment is needed for proper coordinate transformation current_bounds.x -= frame.snapshot_node.scrollRects.x current_bounds.y -= frame.snapshot_node.scrollRects.y # If we reach here, element is visible in main viewport and all containing iframes return True async def _get_ax_tree_for_all_frames(self, target_id: TargetID) -> GetFullAXTreeReturns: """Recursively collect all frames and merge their accessibility trees into a single array.""" cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target_id, focus=False) frame_tree = await cdp_session.cdp_client.send.Page.getFrameTree(session_id=cdp_session.session_id) def collect_all_frame_ids(frame_tree_node) -> list[str]: """Recursively collect all frame IDs from the frame tree.""" frame_ids = [frame_tree_node['frame']['id']] if 'childFrames' in frame_tree_node and frame_tree_node['childFrames']: for child_frame in frame_tree_node['childFrames']: frame_ids.extend(collect_all_frame_ids(child_frame)) return frame_ids # Collect all frame IDs recursively all_frame_ids = collect_all_frame_ids(frame_tree['frameTree']) # Get accessibility tree for each frame ax_tree_requests = [] for frame_id in all_frame_ids: ax_tree_request = cdp_session.cdp_client.send.Accessibility.getFullAXTree( params={'frameId': frame_id}, session_id=cdp_session.session_id ) ax_tree_requests.append(ax_tree_request) # return_exceptions=True so a child frame detaching mid-request (e.g. ad iframes) # doesn't discard AX data from the rest. The root frame is required — if it # fails, propagate so the caller's retry/empty-DOM path runs instead of # silently returning a tree with no main-document AX properties. ax_trees = await asyncio.gather(*ax_tree_requests, return_exceptions=True) root_result = ax_trees[0] if isinstance(root_result, BaseException): raise root_result merged_nodes: list[AXNode] = list(root_result['nodes']) for frame_id, ax_tree in zip(all_frame_ids[1:], ax_trees[1:]): if isinstance(ax_tree, BaseException): self.logger.debug(f'Skipping AX tree for detached/unreachable child frame {frame_id}: {ax_tree}') continue merged_nodes.extend(ax_tree['nodes']) return {'nodes': merged_nodes} async def _get_all_trees(self, target_id: TargetID) -> TargetAllTrees: cdp_session = await self.browser_session.get_or_create_cdp_session(target_id=target_id, focus=False) # Wait for the page to be ready first try: ready_state = await cdp_session.cdp_client.send.Runtime.evaluate( params={'expression': 'document.readyState'}, session_id=cdp_session.session_id ) except Exception as e: pass # Page might not be ready yet # DEBUG: Log before capturing snapshot self.logger.debug(f'🔍 DEBUG: Capturing DOM snapshot for target {target_id}') # Get actual scroll positions for all iframes before capturing snapshot start_iframe_scroll = time.time() iframe_scroll_positions = {} try: scroll_result = await cdp_session.cdp_client.send.Runtime.evaluate( params={ 'expression': """ (() => { const scrollData = {}; const iframes = document.querySelectorAll('iframe'); iframes.forEach((iframe, index) => { try { const doc = iframe.contentDocument || iframe.contentWindow.document; if (doc) { scrollData[index] = { scrollTop: doc.documentElement.scrollTop || doc.body.scrollTop || 0, scrollLeft: doc.documentElement.scrollLeft || doc.body.scrollLeft || 0 }; } } catch (e) { // Cross-origin iframe, can't access } }); return scrollData; })() """, 'returnByValue': True, }, session_id=cdp_session.session_id, ) if scroll_result and 'result' in scroll_result and 'value' in scroll_result['result']: iframe_scroll_positions = scroll_result['result']['value'] for idx, scroll_data in iframe_scroll_positions.items(): self.logger.debug( f'🔍 DEBUG: Iframe {idx} actual scroll position - scrollTop={scroll_data.get("scrollTop", 0)}, scrollLeft={scroll_data.get("scrollLeft", 0)}' ) except Exception as e: self.logger.debug(f'Failed to get iframe scroll positions: {e}') iframe_scroll_ms = (time.time() - start_iframe_scroll) * 1000 # Detect elements with JavaScript click event listeners (without mutating DOM) # On heavy pages (>10k elements) the querySelectorAll('*') + getEventListeners() # loop plus per-element DOM.describeNode CDP calls can take 10s+. # The JS expression below bails out early if the page is too heavy. # Elements are still detected via the accessibility tree and ClickableElementDetector. start_js_listener_detection = time.time() js_click_listener_backend_ids: set[int] = set() try: # Step 1: Run JS to find elements with click listeners and return them by reference js_listener_result = await cdp_session.cdp_client.send.Runtime.evaluate( params={ 'expression': """ (() => { // getEventListeners is only available in DevTools context via includeCommandLineAPI if (typeof getEventListeners !== 'function') { return null; } const allElements = document.querySelectorAll('*'); // Skip on heavy pages — listener detection is too expensive if (allElements.length > 10000) { return null; } const elementsWithListeners = []; for (const el of allElements) { try { const listeners = getEventListeners(el); // Check for click-related event listeners if (listeners.click || listeners.mousedown || listeners.mouseup || listeners.pointerdown || listeners.pointerup) { elementsWithListeners.push(el); } } catch (e) { // Ignore errors for individual elements (e.g., cross-origin) } } return elementsWithListeners; })() """, 'includeCommandLineAPI': True, # enables getEventListeners() 'returnByValue': False, # Return object references, not values }, session_id=cdp_session.session_id, ) result_object_id = js_listener_result.get('result', {}).get('objectId') if result_object_id: # Step 2: Get array properties to access each element array_props = await cdp_session.cdp_client.send.Runtime.getProperties( params={ 'objectId': result_object_id, 'ownProperties': True, }, session_id=cdp_session.session_id, ) # Step 3: For each element, get its backend node ID via DOM.describeNode element_object_ids: list[str] = [] for prop in array_props.get('result', []): # Array indices are numeric property names prop_name = prop.get('name', '') if isinstance(prop, dict) else '' if isinstance(prop_name, str) and prop_name.isdigit(): prop_value = prop.get('value', {}) if isinstance(prop, dict) else {} if isinstance(prop_value, dict): object_id = prop_value.get('objectId') if object_id and isinstance(object_id, str): element_object_ids.append(object_id) # Batch resolve backend node IDs (run in parallel) async def get_backend_node_id(object_id: str) -> int | None: try: node_info = await cdp_session.cdp_client.send.DOM.describeNode( params={'objectId': object_id}, session_id=cdp_session.session_id, ) return node_info.get('node', {}).get('backendNodeId') except Exception: return None # Resolve all element object IDs to backend node IDs in parallel backend_ids = await asyncio.gather(*[get_backend_node_id(oid) for oid in element_object_ids]) js_click_listener_backend_ids = {bid for bid in backend_ids if bid is not None} # Release the array object to avoid memory leaks try: await cdp_session.cdp_client.send.Runtime.releaseObject( params={'objectId': result_object_id}, session_id=cdp_session.session_id, ) except Exception: pass # Best effort cleanup self.logger.debug(f'Detected {len(js_click_listener_backend_ids)} elements with JS click listeners') except Exception as e: self.logger.debug(f'Failed to detect JS event listeners: {e}') js_listener_detection_ms = (time.time() - start_js_listener_detection) * 1000 # Define CDP request factories to avoid duplication def create_snapshot_request(): return cdp_session.cdp_client.send.DOMSnapshot.captureSnapshot( params={ 'computedStyles': REQUIRED_COMPUTED_STYLES, 'includePaintOrder': True, 'includeDOMRects': True, 'includeBlendedBackgroundColors': False, 'includeTextColorOpacities': False, }, session_id=cdp_session.session_id, ) def create_dom_tree_request(): return cdp_session.cdp_client.send.DOM.getDocument( params={'depth': -1, 'pierce': True}, session_id=cdp_session.session_id ) start_cdp_calls = time.time() # Create initial tasks tasks = { 'snapshot': create_task_with_error_handling(create_snapshot_request(), name='get_snapshot'), 'dom_tree': create_task_with_error_handling(create_dom_tree_request(), name='get_dom_tree'), 'ax_tree': create_task_with_error_handling(self._get_ax_tree_for_all_frames(target_id), name='get_ax_tree'), 'device_pixel_ratio': create_task_with_error_handling(self._get_viewport_ratio(target_id), name='get_viewport_ratio'), } # Wait for all tasks with timeout done, pending = await asyncio.wait(tasks.values(), timeout=10.0) # Retry any failed or timed out tasks if pending: for task in pending: task.cancel() # Retry mapping for pending tasks retry_map = { tasks['snapshot']: lambda: create_task_with_error_handling(create_snapshot_request(), name='get_snapshot_retry'), tasks['dom_tree']: lambda: create_task_with_error_handling(create_dom_tree_request(), name='get_dom_tree_retry'), tasks['ax_tree']: lambda: create_task_with_error_handling( self._get_ax_tree_for_all_frames(target_id), name='get_ax_tree_retry' ), tasks['device_pixel_ratio']: lambda: create_task_with_error_handling( self._get_viewport_ratio(target_id), name='get_viewport_ratio_retry' ), } # Create new tasks only for the ones that didn't complete for key, task in tasks.items(): if task in pending and task in retry_map: tasks[key] = retry_map[task]() # Wait again with shorter timeout done2, pending2 = await asyncio.wait([t for t in tasks.values() if not t.done()], timeout=2.0) if pending2: for task in pending2: task.cancel() # Extract results, tracking which ones failed results = {} failed = [] for key, task in tasks.items(): if task.done() and not task.cancelled(): try: results[key] = task.result() except Exception as e: self.logger.warning(f'CDP request {key} failed with exception: {e}') failed.append(key) else: self.logger.warning(f'CDP request {key} timed out') failed.append(key) # If any required tasks failed, raise an exception if failed: raise TimeoutError(f'CDP requests failed or timed out: {", ".join(failed)}') snapshot = results['snapshot'] dom_tree = results['dom_tree'] ax_tree = results['ax_tree'] device_pixel_ratio = results['device_pixel_ratio'] end_cdp_calls = time.time() cdp_calls_ms = (end_cdp_calls - start_cdp_calls) * 1000 # Calculate total time for _get_all_trees and overhead start_snapshot_processing = time.time() # DEBUG: Log snapshot info and limit documents to prevent explosion if snapshot and 'documents' in snapshot: original_doc_count = len(snapshot['documents']) # Limit to max_iframes documents to prevent iframe explosion if original_doc_count > self.max_iframes: self.logger.warning( f'⚠️ Limiting processing of {original_doc_count} iframes on page to only first {self.max_iframes} to prevent crashes!' ) snapshot['documents'] = snapshot['documents'][: self.max_iframes] total_nodes = sum(len(doc.get('nodes', [])) for doc in snapshot['documents']) self.logger.debug(f'🔍 DEBUG: Snapshot contains {len(snapshot["documents"])} frames with {total_nodes} total nodes') # Log iframe-specific info for doc_idx, doc in enumerate(snapshot['documents']): if doc_idx > 0: # Not the main document self.logger.debug( f'🔍 DEBUG: Iframe #{doc_idx} {doc.get("frameId", "no-frame-id")} {doc.get("url", "no-url")} has {len(doc.get("nodes", []))} nodes' ) snapshot_processing_ms = (time.time() - start_snapshot_processing) * 1000 # Return with detailed timing breakdown return TargetAllTrees( snapshot=snapshot, dom_tree=dom_tree, ax_tree=ax_tree, device_pixel_ratio=device_pixel_ratio, cdp_timing={ 'iframe_scroll_detection_ms': iframe_scroll_ms, 'js_listener_detection_ms': js_listener_detection_ms, 'cdp_parallel_calls_ms': cdp_calls_ms, 'snapshot_processing_ms': snapshot_processing_ms, }, js_click_listener_backend_ids=js_click_listener_backend_ids if js_click_listener_backend_ids else None, ) @observe_debug(ignore_input=True, ignore_output=True, name='get_dom_tree') async def get_dom_tree( self, target_id: TargetID, all_frames: dict | None = None, initial_html_frames: list[EnhancedDOMTreeNode] | None = None, initial_total_frame_offset: DOMRect | None = None, iframe_depth: int = 0, ) -> tuple[EnhancedDOMTreeNode, dict[str, float]]: """Get the DOM tree for a specific target. Args: target_id: Target ID of the page to get the DOM tree for. all_frames: Pre-fetched frame hierarchy to avoid redundant CDP calls (optional, lazy fetch if None) initial_html_frames: List of HTML frame nodes encountered so far initial_total_frame_offset: Accumulated coordinate offset iframe_depth: Current depth of iframe nesting to prevent infinite recursion Returns: Tuple of (enhanced_dom_tree_node, timing_info) """ timing_info: dict[str, float] = {} timing_start_total = time.time() # Get all trees from CDP (snapshot, DOM, AX, viewport ratio) start_get_trees = time.time() trees = await self._get_all_trees(target_id) get_trees_ms = (time.time() - start_get_trees) * 1000 timing_info.update(trees.cdp_timing) timing_info['get_all_trees_total_ms'] = get_trees_ms dom_tree = trees.dom_tree ax_tree = trees.ax_tree snapshot = trees.snapshot device_pixel_ratio = trees.device_pixel_ratio js_click_listener_backend_ids = trees.js_click_listener_backend_ids or set() # Build AX tree lookup start_ax = time.time() ax_tree_lookup: dict[int, AXNode] = { ax_node['backendDOMNodeId']: ax_node for ax_node in ax_tree['nodes'] if 'backendDOMNodeId' in ax_node } timing_info['build_ax_lookup_ms'] = (time.time() - start_ax) * 1000 enhanced_dom_tree_node_lookup: dict[int, EnhancedDOMTreeNode] = {} """ NodeId (NOT backend node id) -> enhanced dom tree node""" # way to get the parent/content node # Parse snapshot data with everything calculated upfront start_snapshot = time.time() snapshot_lookup = build_snapshot_lookup(snapshot, device_pixel_ratio) timing_info['build_snapshot_lookup_ms'] = (time.time() - start_snapshot) * 1000 async def _construct_enhanced_node( node: Node, html_frames: list[EnhancedDOMTreeNode] | None, total_frame_offset: DOMRect | None, all_frames: dict | None, ) -> EnhancedDOMTreeNode: """ Recursively construct enhanced DOM tree nodes. Args: node: The DOM node to construct html_frames: List of HTML frame nodes encountered so far total_frame_offset: Accumulated coordinate translation from parent iframes (includes scroll corrections) all_frames: Pre-fetched frame hierarchy to avoid redundant CDP calls """ # Initialize lists if not provided if html_frames is None: html_frames = [] # to get rid of the pointer references if total_frame_offset is None: total_frame_offset = DOMRect(x=0.0, y=0.0, width=0.0, height=0.0) else: total_frame_offset = DOMRect( total_frame_offset.x, total_frame_offset.y, total_frame_offset.width, total_frame_offset.height ) # memoize the mf (I don't know if some nodes are duplicated) if node['nodeId'] in enhanced_dom_tree_node_lookup: return enhanced_dom_tree_node_lookup[node['nodeId']] ax_node = ax_tree_lookup.get(node['backendNodeId']) if ax_node: enhanced_ax_node = self._build_enhanced_ax_node(ax_node) else: enhanced_ax_node = None # To make attributes more readable attributes: dict[str, str] | None = None if 'attributes' in node and node['attributes']: attributes = {} for i in range(0, len(node['attributes']), 2): attributes[node['attributes'][i]] = node['attributes'][i + 1] shadow_root_type = None if 'shadowRootType' in node and node['shadowRootType']: try: shadow_root_type = node['shadowRootType'] except ValueError: pass # Get snapshot data and calculate absolute position snapshot_data = snapshot_lookup.get(node['backendNodeId'], None) # DIAGNOSTIC: Log when interactive elements don't have snapshot data if not snapshot_data and node['nodeName'].upper() in ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'A']: parent_has_shadow = False parent_info = '' if 'parentId' in node and node['parentId'] in enhanced_dom_tree_node_lookup: parent = enhanced_dom_tree_node_lookup[node['parentId']] if parent.shadow_root_type: parent_has_shadow = True parent_info = f'parent={parent.tag_name}(shadow={parent.shadow_root_type})' attr_str = '' if 'attributes' in node and node['attributes']: attrs_dict = {node['attributes'][i]: node['attributes'][i + 1] for i in range(0, len(node['attributes']), 2)} attr_str = f'name={attrs_dict.get("name", "N/A")} id={attrs_dict.get("id", "N/A")}' self.logger.debug( f'🔍 NO SNAPSHOT DATA for <{node["nodeName"]}> backendNodeId={node["backendNodeId"]} ' f'{attr_str} {parent_info} (snapshot_lookup has {len(snapshot_lookup)} entries)' ) absolute_position = None if snapshot_data and snapshot_data.bounds: absolute_position = DOMRect( x=snapshot_data.bounds.x + total_frame_offset.x, y=snapshot_data.bounds.y + total_frame_offset.y, width=snapshot_data.bounds.width, height=snapshot_data.bounds.height, ) try: session = await self.browser_session.get_or_create_cdp_session(target_id, focus=False) session_id = session.session_id except ValueError: # Target may have detached during DOM construction session_id = None dom_tree_node = EnhancedDOMTreeNode( node_id=node['nodeId'], backend_node_id=node['backendNodeId'], node_type=NodeType(node['nodeType']), node_name=node['nodeName'], node_value=node['nodeValue'], attributes=attributes or {}, is_scrollable=node.get('isScrollable', None), frame_id=node.get('frameId', None), session_id=session_id, target_id=target_id, content_document=None, shadow_root_type=shadow_root_type, shadow_roots=None, parent_node=None, children_nodes=None, ax_node=enhanced_ax_node, snapshot_node=snapshot_data, is_visible=None, has_js_click_listener=node['backendNodeId'] in js_click_listener_backend_ids, absolute_position=absolute_position, ) enhanced_dom_tree_node_lookup[node['nodeId']] = dom_tree_node if 'parentId' in node and node['parentId']: dom_tree_node.parent_node = enhanced_dom_tree_node_lookup[ node['parentId'] ] # parents should always be in the lookup # Check if this is an HTML frame node and add it to the list updated_html_frames = html_frames.copy() if node['nodeType'] == NodeType.ELEMENT_NODE.value and node['nodeName'] == 'HTML' and node.get('frameId') is not None: updated_html_frames.append(dom_tree_node) # and adjust the total frame offset by scroll if snapshot_data and snapshot_data.scrollRects: total_frame_offset.x -= snapshot_data.scrollRects.x total_frame_offset.y -= snapshot_data.scrollRects.y # DEBUG: Log iframe scroll information self.logger.debug( f'🔍 DEBUG: HTML frame scroll - scrollY={snapshot_data.scrollRects.y}, scrollX={snapshot_data.scrollRects.x}, frameId={node.get("frameId")}, nodeId={node["nodeId"]}' ) # Calculate new iframe offset for content documents, accounting for iframe scroll if ( (node['nodeName'].upper() == 'IFRAME' or node['nodeName'].upper() == 'FRAME') and snapshot_data and snapshot_data.bounds ): if snapshot_data.bounds: updated_html_frames.append(dom_tree_node) total_frame_offset.x += snapshot_data.bounds.x total_frame_offset.y += snapshot_data.bounds.y if 'contentDocument' in node and node['contentDocument']: dom_tree_node.content_document = await _construct_enhanced_node( node['contentDocument'], updated_html_frames, total_frame_offset, all_frames ) dom_tree_node.content_document.parent_node = dom_tree_node # forcefully set the parent node to the content document node (helps traverse the tree) if 'shadowRoots' in node and node['shadowRoots']: dom_tree_node.shadow_roots = [] for shadow_root in node['shadowRoots']: shadow_root_node = await _construct_enhanced_node( shadow_root, updated_html_frames, total_frame_offset, all_frames ) # forcefully set the parent node to the shadow root node (helps traverse the tree) shadow_root_node.parent_node = dom_tree_node dom_tree_node.shadow_roots.append(shadow_root_node) if 'children' in node and node['children']: dom_tree_node.children_nodes = [] # Build set of shadow root node IDs to filter them out from children shadow_root_node_ids = set() if 'shadowRoots' in node and node['shadowRoots']: for shadow_root in node['shadowRoots']: shadow_root_node_ids.add(shadow_root['nodeId']) for child in node['children']: # Skip shadow roots - they should only be in shadow_roots list if child['nodeId'] in shadow_root_node_ids: continue dom_tree_node.children_nodes.append( await _construct_enhanced_node(child, updated_html_frames, total_frame_offset, all_frames) ) # Set visibility using the collected HTML frames and viewport threshold dom_tree_node.is_visible = self.is_element_visible_according_to_all_parents( dom_tree_node, updated_html_frames, self.viewport_threshold ) # DEBUG: Log visibility info for form elements in iframes if dom_tree_node.tag_name and dom_tree_node.tag_name.upper() in ['INPUT', 'SELECT', 'TEXTAREA', 'LABEL']: attrs = dom_tree_node.attributes or {} elem_id = attrs.get('id', '') elem_name = attrs.get('name', '') if ( 'city' in elem_id.lower() or 'city' in elem_name.lower() or 'state' in elem_id.lower() or 'state' in elem_name.lower() or 'zip' in elem_id.lower() or 'zip' in elem_name.lower() ): self.logger.debug( f"🔍 DEBUG: Form element {dom_tree_node.tag_name} id='{elem_id}' name='{elem_name}' - visible={dom_tree_node.is_visible}, bounds={dom_tree_node.snapshot_node.bounds if dom_tree_node.snapshot_node else 'NO_SNAPSHOT'}" ) # handle cross origin iframe (just recursively call the main function with the proper target if it exists in iframes) # only do this if the iframe is visible (otherwise it's not worth it) if ( # TODO: hacky way to disable cross origin iframes for now self.cross_origin_iframes and node['nodeName'].upper() == 'IFRAME' and node.get('contentDocument', None) is None ): # None meaning there is no content # Check iframe depth to prevent infinite recursion if iframe_depth >= self.max_iframe_depth: self.logger.debug( f'Skipping iframe at depth {iframe_depth} to prevent infinite recursion (max depth: {self.max_iframe_depth})' ) else: # Check if iframe is visible and large enough (>= 50px in both dimensions) should_process_iframe = False # First check if the iframe element itself is visible if dom_tree_node.is_visible: # Check iframe dimensions if dom_tree_node.snapshot_node and dom_tree_node.snapshot_node.bounds: bounds = dom_tree_node.snapshot_node.bounds width = bounds.width height = bounds.height # Only process if iframe is at least 50px in both dimensions if width >= 50 and height >= 50: should_process_iframe = True self.logger.debug(f'Processing cross-origin iframe: visible=True, width={width}, height={height}') else: self.logger.debug( f'Skipping small cross-origin iframe: width={width}, height={height} (needs >= 50px)' ) else: self.logger.debug('Skipping cross-origin iframe: no bounds available') else: self.logger.debug('Skipping invisible cross-origin iframe') if should_process_iframe: # Lazy fetch all_frames only when actually needed (for cross-origin iframes) if all_frames is None: all_frames, _ = await self.browser_session.get_all_frames() # Use pre-fetched all_frames to find the iframe's target (no redundant CDP call) frame_id = node.get('frameId', None) # Fallback: if frameId is missing or not in all_frames, try URL matching via # the src attribute. This handles dynamically-injected iframes (e.g. HubSpot # popups, chat widgets) where Chrome hasn't yet registered the frameId in the # frame tree at DOM-snapshot time. if (not frame_id or frame_id not in all_frames) and attributes: src = attributes.get('src', '') if src: src_base = src.split('?')[0].rstrip('/') for fid, finfo in all_frames.items(): frame_url = finfo.get('url', '').split('?')[0].rstrip('/') if frame_url and frame_url == src_base: frame_id = fid self.logger.debug(f'Matched cross-origin iframe by src URL: {src!r} -> frameId={fid}') break iframe_document_target = None if frame_id: frame_info = all_frames.get(frame_id) if frame_info and frame_info.get('frameTargetId'): iframe_target_id = frame_info['frameTargetId'] # Use frameTargetId directly from all_frames — get_all_frames() already # validated connectivity. Do NOT gate on session_manager.get_target(): # there is a race where _target_sessions is set (inside the lock in # _handle_target_attached) before _targets is populated (outside the # lock), so get_target() can transiently return None for a live target. iframe_target = self.browser_session.session_manager.get_target(iframe_target_id) iframe_document_target = { 'targetId': iframe_target_id, 'url': iframe_target.url if iframe_target else frame_info.get('url', ''), 'title': iframe_target.title if iframe_target else frame_info.get('title', ''), 'type': iframe_target.target_type if iframe_target else 'iframe', } # if target actually exists in one of the frames, just recursively build the dom tree for it if iframe_document_target: self.logger.debug( f'Getting content document for iframe {node.get("frameId", None)} at depth {iframe_depth + 1}' ) try: content_document, _ = await self.get_dom_tree( target_id=iframe_document_target['targetId'], all_frames=all_frames, # Current config: if the cross origin iframe is AT ALL visible, include everything inside it initial_total_frame_offset=total_frame_offset, iframe_depth=iframe_depth + 1, ) dom_tree_node.content_document = content_document dom_tree_node.content_document.parent_node = dom_tree_node except Exception as e: self.logger.debug(f'Failed to get DOM tree for cross-origin iframe {frame_id}: {e}') return dom_tree_node # Build enhanced DOM tree recursively # Note: all_frames stays None and will be lazily fetched inside _construct_enhanced_node # only if/when a cross-origin iframe is encountered start_construct = time.time() enhanced_dom_tree_node = await _construct_enhanced_node( dom_tree['root'], initial_html_frames, initial_total_frame_offset, all_frames ) timing_info['construct_enhanced_tree_ms'] = (time.time() - start_construct) * 1000 # Count hidden elements per iframe for LLM hints self._count_hidden_elements_in_iframes(enhanced_dom_tree_node) # Calculate total time for get_dom_tree total_get_dom_tree_ms = (time.time() - timing_start_total) * 1000 timing_info['get_dom_tree_total_ms'] = total_get_dom_tree_ms # Calculate overhead in get_dom_tree (time not accounted for by sub-operations) tracked_sub_operations_ms = ( timing_info.get('get_all_trees_total_ms', 0) + timing_info.get('build_ax_lookup_ms', 0) + timing_info.get('build_snapshot_lookup_ms', 0) + timing_info.get('construct_enhanced_tree_ms', 0) ) get_dom_tree_overhead_ms = total_get_dom_tree_ms - tracked_sub_operations_ms if get_dom_tree_overhead_ms > 0.1: timing_info['get_dom_tree_overhead_ms'] = get_dom_tree_overhead_ms return enhanced_dom_tree_node, timing_info @observe_debug(ignore_input=True, ignore_output=True, name='get_serialized_dom_tree') async def get_serialized_dom_tree( self, previous_cached_state: SerializedDOMState | None = None ) -> tuple[SerializedDOMState, EnhancedDOMTreeNode, dict[str, float]]: """Get the serialized DOM tree representation for LLM consumption. Returns: Tuple of (serialized_dom_state, enhanced_dom_tree_root, timing_info) """ timing_info: dict[str, float] = {} start_total = time.time() # Use current target (None means use current) assert self.browser_session.agent_focus_target_id is not None session_id = self.browser_session.id # Build DOM tree (includes CDP calls for snapshot, DOM, AX tree) # Note: all_frames is fetched lazily inside get_dom_tree only if cross-origin iframes need it enhanced_dom_tree, dom_tree_timing = await self.get_dom_tree( target_id=self.browser_session.agent_focus_target_id, all_frames=None, # Lazy - will fetch if needed ) # Add sub-timings from DOM tree construction timing_info.update(dom_tree_timing) # Serialize DOM tree for LLM start_serialize = time.time() serialized_dom_state, serializer_timing = DOMTreeSerializer( enhanced_dom_tree, previous_cached_state, paint_order_filtering=self.paint_order_filtering, session_id=session_id ).serialize_accessible_elements() total_serialization_ms = (time.time() - start_serialize) * 1000 # Add serializer sub-timings (convert to ms) for key, value in serializer_timing.items(): timing_info[f'{key}_ms'] = value * 1000 # Calculate untracked time in serialization tracked_serialization_ms = sum(value * 1000 for value in serializer_timing.values()) serialization_overhead_ms = total_serialization_ms - tracked_serialization_ms if serialization_overhead_ms > 0.1: # Only log if significant timing_info['serialization_overhead_ms'] = serialization_overhead_ms # Calculate total time for get_serialized_dom_tree total_get_serialized_dom_tree_ms = (time.time() - start_total) * 1000 timing_info['get_serialized_dom_tree_total_ms'] = total_get_serialized_dom_tree_ms # Calculate overhead in get_serialized_dom_tree (time not accounted for) tracked_major_operations_ms = timing_info.get('get_dom_tree_total_ms', 0) + total_serialization_ms get_serialized_overhead_ms = total_get_serialized_dom_tree_ms - tracked_major_operations_ms if get_serialized_overhead_ms > 0.1: timing_info['get_serialized_dom_tree_overhead_ms'] = get_serialized_overhead_ms return serialized_dom_state, enhanced_dom_tree, timing_info @staticmethod def detect_pagination_buttons(selector_map: dict[int, EnhancedDOMTreeNode]) -> list[dict[str, str | int | bool]]: """Detect pagination buttons from the selector map. Args: selector_map: Map of element indices to EnhancedDOMTreeNode Returns: List of pagination button information dicts with: - button_type: 'next', 'prev', 'first', 'last', 'page_number' - backend_node_id: Backend node ID for clicking - text: Button text/label - selector: XPath selector - is_disabled: Whether the button appears disabled """ pagination_buttons: list[dict[str, str | int | bool]] = [] # Common pagination patterns to look for # `«` and `»` are ambiguous across sites, so treat them only as prev/next # fallback symbols and let word-based first/last signals win next_patterns = ['next', '>', '»', '→', 'siguiente', 'suivant', 'weiter', 'volgende'] prev_patterns = ['prev', 'previous', '<', '«', '←', 'anterior', 'précédent', 'zurück', 'vorige'] first_patterns = ['first', '⇤', 'primera', 'première', 'erste', 'eerste'] last_patterns = ['last', '⇥', 'última', 'dernier', 'letzte', 'laatste'] for index, node in selector_map.items(): # Skip non-clickable elements if not node.snapshot_node or not node.snapshot_node.is_clickable: continue # Get element text and attributes text = node.get_all_children_text().lower().strip() aria_label = node.attributes.get('aria-label', '').lower() title = node.attributes.get('title', '').lower() class_name = node.attributes.get('class', '').lower() role = node.attributes.get('role', '').lower() # Combine all text sources for pattern matching all_text = f'{text} {aria_label} {title} {class_name}'.strip() # Check if it's disabled is_disabled = ( node.attributes.get('disabled') == 'true' or node.attributes.get('aria-disabled') == 'true' or 'disabled' in class_name ) button_type: str | None = None # Match specific first/last semantics before generic prev/next fallbacks. if any(pattern in all_text for pattern in first_patterns): button_type = 'first' # Check for last button elif any(pattern in all_text for pattern in last_patterns): button_type = 'last' # Check for next button elif any(pattern in all_text for pattern in next_patterns): button_type = 'next' # Check for previous button elif any(pattern in all_text for pattern in prev_patterns): button_type = 'prev' # Check for numeric page buttons (single or double digit) elif text.isdigit() and len(text) <= 2 and role in ['button', 'link', '']: button_type = 'page_number' if button_type: pagination_buttons.append( { 'button_type': button_type, 'backend_node_id': index, 'text': node.get_all_children_text().strip() or aria_label or title, 'selector': node.xpath, 'is_disabled': is_disabled, } ) return pagination_buttons