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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,161 @@
"""
Enhanced snapshot processing for browser-use DOM tree extraction.
This module provides stateless functions for parsing Chrome DevTools Protocol (CDP) DOMSnapshot data
to extract visibility, clickability, cursor styles, and other layout information.
"""
from cdp_use.cdp.domsnapshot.commands import CaptureSnapshotReturns
from cdp_use.cdp.domsnapshot.types import (
LayoutTreeSnapshot,
NodeTreeSnapshot,
RareBooleanData,
)
from browser_use.dom.views import DOMRect, EnhancedSnapshotNode
# Only the ESSENTIAL computed styles for interactivity and visibility detection
REQUIRED_COMPUTED_STYLES = [
# Only styles actually accessed in the codebase (prevents Chrome crashes on heavy sites)
'display', # Used in service.py visibility detection
'visibility', # Used in service.py visibility detection
'opacity', # Used in service.py visibility detection
'overflow', # Used in views.py scrollability detection
'overflow-x', # Used in views.py scrollability detection
'overflow-y', # Used in views.py scrollability detection
'cursor', # Used in enhanced_snapshot.py cursor extraction
'pointer-events', # Used for clickability logic
'position', # Used for visibility logic
'background-color', # Used for visibility logic
]
def _parse_rare_boolean_data(rare_data: RareBooleanData, index: int) -> bool | None:
"""Parse rare boolean data from snapshot - returns True if index is in the rare data."""
return index in rare_data['index']
def _parse_computed_styles(strings: list[str], style_indices: list[int]) -> dict[str, str]:
"""Parse computed styles from layout tree using string indices."""
styles = {}
for i, style_index in enumerate(style_indices):
if i < len(REQUIRED_COMPUTED_STYLES) and 0 <= style_index < len(strings):
styles[REQUIRED_COMPUTED_STYLES[i]] = strings[style_index]
return styles
def build_snapshot_lookup(
snapshot: CaptureSnapshotReturns,
device_pixel_ratio: float = 1.0,
) -> dict[int, EnhancedSnapshotNode]:
"""Build a lookup table of backend node ID to enhanced snapshot data with everything calculated upfront."""
snapshot_lookup: dict[int, EnhancedSnapshotNode] = {}
if not snapshot['documents']:
return snapshot_lookup
strings = snapshot['strings']
for document in snapshot['documents']:
nodes: NodeTreeSnapshot = document['nodes']
layout: LayoutTreeSnapshot = document['layout']
# Build backend node id to snapshot index lookup
backend_node_to_snapshot_index = {}
if 'backendNodeId' in nodes:
for i, backend_node_id in enumerate(nodes['backendNodeId']):
backend_node_to_snapshot_index[backend_node_id] = i
# PERFORMANCE: Pre-build layout index map to eliminate O(n²) double lookups
# Preserve original behavior: use FIRST occurrence for duplicates
layout_index_map = {}
if layout and 'nodeIndex' in layout:
for layout_idx, node_index in enumerate(layout['nodeIndex']):
if node_index not in layout_index_map: # Only store first occurrence
layout_index_map[node_index] = layout_idx
# Build snapshot lookup for each backend node id
for backend_node_id, snapshot_index in backend_node_to_snapshot_index.items():
is_clickable = None
if 'isClickable' in nodes:
is_clickable = _parse_rare_boolean_data(nodes['isClickable'], snapshot_index)
# Find corresponding layout node
cursor_style = None
is_visible = None
bounding_box = None
computed_styles = {}
# Look for layout tree node that corresponds to this snapshot node
paint_order = None
client_rects = None
scroll_rects = None
stacking_contexts = None
if snapshot_index in layout_index_map:
layout_idx = layout_index_map[snapshot_index]
if layout_idx < len(layout.get('bounds') or []):
# Parse bounding box
bounds = layout['bounds'][layout_idx]
if len(bounds) >= 4:
# IMPORTANT: CDP coordinates are in device pixels, convert to CSS pixels
# by dividing by the device pixel ratio
raw_x, raw_y, raw_width, raw_height = bounds[0], bounds[1], bounds[2], bounds[3]
# Apply device pixel ratio scaling to convert device pixels to CSS pixels
bounding_box = DOMRect(
x=raw_x / device_pixel_ratio,
y=raw_y / device_pixel_ratio,
width=raw_width / device_pixel_ratio,
height=raw_height / device_pixel_ratio,
)
# Parse computed styles for this layout node
if layout_idx < len(layout.get('styles') or []):
style_indices = layout['styles'][layout_idx]
computed_styles = _parse_computed_styles(strings, style_indices)
cursor_style = computed_styles.get('cursor')
# Extract paint order if available
if layout_idx < len(layout.get('paintOrders') or []):
paint_order = layout.get('paintOrders', [])[layout_idx]
# Extract client rects if available
client_rects_data = layout.get('clientRects') or []
if layout_idx < len(client_rects_data):
client_rect_data = client_rects_data[layout_idx]
if client_rect_data and len(client_rect_data) >= 4:
client_rects = DOMRect(
x=client_rect_data[0],
y=client_rect_data[1],
width=client_rect_data[2],
height=client_rect_data[3],
)
# Extract scroll rects if available
scroll_rects_data = layout.get('scrollRects') or []
if layout_idx < len(scroll_rects_data):
scroll_rect_data = scroll_rects_data[layout_idx]
if scroll_rect_data and len(scroll_rect_data) >= 4:
scroll_rects = DOMRect(
x=scroll_rect_data[0],
y=scroll_rect_data[1],
width=scroll_rect_data[2],
height=scroll_rect_data[3],
)
# Extract stacking contexts if available
if layout_idx < len(layout.get('stackingContexts') or []):
stacking_contexts = layout.get('stackingContexts', {}).get('index', [])[layout_idx]
snapshot_lookup[backend_node_id] = EnhancedSnapshotNode(
is_clickable=is_clickable,
cursor_style=cursor_style,
bounds=bounding_box,
clientRects=client_rects,
scrollRects=scroll_rects,
computed_styles=computed_styles if computed_styles else None,
paint_order=paint_order,
stacking_contexts=stacking_contexts,
)
return snapshot_lookup
@@ -0,0 +1,312 @@
import asyncio
import json
import os
import time
import anyio
import pyperclip
import tiktoken
from browser_use.agent.prompts import AgentMessagePrompt
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.events import ClickElementEvent, TypeTextEvent
from browser_use.browser.profile import ViewportSize
from browser_use.dom.service import DomService
from browser_use.dom.views import DEFAULT_INCLUDE_ATTRIBUTES
from browser_use.filesystem.file_system import FileSystem
TIMEOUT = 60
async def test_focus_vs_all_elements():
browser_session = BrowserSession(
browser_profile=BrowserProfile(
# executable_path='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
window_size=ViewportSize(width=1100, height=1000),
disable_security=False,
wait_for_network_idle_page_load_time=1,
headless=False,
args=['--incognito'],
paint_order_filtering=True,
),
)
# 10 Sample websites with various interactive elements
sample_websites = [
'https://browser-use.github.io/stress-tests/challenges/iframe-inception-level2.html',
'https://www.google.com/travel/flights',
'https://v0-simple-ui-test-site.vercel.app',
'https://browser-use.github.io/stress-tests/challenges/iframe-inception-level1.html',
'https://browser-use.github.io/stress-tests/challenges/angular-form.html',
'https://www.google.com/travel/flights',
'https://www.amazon.com/s?k=laptop',
'https://github.com/trending',
'https://www.reddit.com',
'https://www.ycombinator.com/companies',
'https://www.kayak.com/flights',
'https://www.booking.com',
'https://www.airbnb.com',
'https://www.linkedin.com/jobs',
'https://stackoverflow.com/questions',
]
# 5 Difficult websites with complex elements (iframes, canvas, dropdowns, etc.)
difficult_websites = [
'https://www.w3schools.com/html/tryit.asp?filename=tryhtml_iframe', # Nested iframes
'https://semantic-ui.com/modules/dropdown.html', # Complex dropdowns
'https://www.dezlearn.com/nested-iframes-example/', # Cross-origin nested iframes
'https://codepen.io/towc/pen/mJzOWJ', # Canvas elements with interactions
'https://jqueryui.com/accordion/', # Complex accordion/dropdown widgets
'https://v0-simple-landing-page-seven-xi.vercel.app/', # Simple landing page with iframe
'https://www.unesco.org/en',
]
# Descriptions for difficult websites
difficult_descriptions = {
'https://www.w3schools.com/html/tryit.asp?filename=tryhtml_iframe': '🔸 NESTED IFRAMES: Multiple iframe layers',
'https://semantic-ui.com/modules/dropdown.html': '🔸 COMPLEX DROPDOWNS: Custom dropdown components',
'https://www.dezlearn.com/nested-iframes-example/': '🔸 CROSS-ORIGIN IFRAMES: Different domain iframes',
'https://codepen.io/towc/pen/mJzOWJ': '🔸 CANVAS ELEMENTS: Interactive canvas graphics',
'https://jqueryui.com/accordion/': '🔸 ACCORDION WIDGETS: Collapsible content sections',
}
websites = sample_websites + difficult_websites
current_website_index = 0
def get_website_list_for_prompt() -> str:
"""Get a compact website list for the input prompt."""
lines = []
lines.append('📋 Websites:')
# Sample websites (1-10)
for i, site in enumerate(sample_websites, 1):
current_marker = '' if (i - 1) == current_website_index else ''
domain = site.replace('https://', '').split('/')[0]
lines.append(f' {i:2d}.{domain[:15]:<15}{current_marker}')
# Difficult websites (11-15)
for i, site in enumerate(difficult_websites, len(sample_websites) + 1):
current_marker = '' if (i - 1) == current_website_index else ''
domain = site.replace('https://', '').split('/')[0]
desc = difficult_descriptions.get(site, '')
challenge = desc.split(': ')[1][:15] if ': ' in desc else ''
lines.append(f' {i:2d}.{domain[:15]:<15} ({challenge}){current_marker}')
return '\n'.join(lines)
await browser_session.start()
# Show startup info
print('\n🌐 BROWSER-USE DOM EXTRACTION TESTER')
print(f'📊 {len(websites)} websites total: {len(sample_websites)} standard + {len(difficult_websites)} complex')
print('🔧 Controls: Type 1-15 to jump | Enter to re-run | "n" next | "q" quit')
print('💾 Outputs: tmp/user_message.txt & tmp/element_tree.json\n')
dom_service = DomService(browser_session)
while True:
# Cycle through websites
if current_website_index >= len(websites):
current_website_index = 0
print('Cycled back to first website!')
website = websites[current_website_index]
# sleep 2
await browser_session._cdp_navigate(website)
await asyncio.sleep(1)
last_clicked_index = None # Track the index for text input
while True:
try:
# all_elements_state = await dom_service.get_serialized_dom_tree()
website_type = 'DIFFICULT' if website in difficult_websites else 'SAMPLE'
print(f'\n{"=" * 60}')
print(f'[{current_website_index + 1}/{len(websites)}] [{website_type}] Testing: {website}')
if website in difficult_descriptions:
print(f'{difficult_descriptions[website]}')
print(f'{"=" * 60}')
# Get/refresh the state (includes removing old highlights)
print('\nGetting page state...')
start_time = time.time()
all_elements_state = await browser_session.get_browser_state_summary(True)
end_time = time.time()
get_state_time = end_time - start_time
print(f'get_state_summary took {get_state_time:.2f} seconds')
# Get detailed timing info from DOM service
print('\nGetting detailed DOM timing...')
serialized_state, _, timing_info = await dom_service.get_serialized_dom_tree()
# Combine all timing info
all_timing = {'get_state_summary_total': get_state_time, **timing_info}
selector_map = all_elements_state.dom_state.selector_map
total_elements = len(selector_map.keys())
print(f'Total number of elements: {total_elements}')
# print(all_elements_state.element_tree.clickable_elements_to_string())
prompt = AgentMessagePrompt(
browser_state_summary=all_elements_state,
file_system=FileSystem(base_dir='./tmp'),
include_attributes=DEFAULT_INCLUDE_ATTRIBUTES,
step_info=None,
)
# Write the user message to a file for analysis
user_message = prompt.get_user_message(use_vision=False).text
# clickable_elements_str = all_elements_state.element_tree.clickable_elements_to_string()
text_to_save = user_message
os.makedirs('./tmp', exist_ok=True)
async with await anyio.open_file('./tmp/user_message.txt', 'w', encoding='utf-8') as f:
await f.write(text_to_save)
# save pure clickable elements to a file
if all_elements_state.dom_state._root:
async with await anyio.open_file('./tmp/simplified_element_tree.json', 'w', encoding='utf-8') as f:
await f.write(json.dumps(all_elements_state.dom_state._root.__json__(), indent=2))
async with await anyio.open_file('./tmp/original_element_tree.json', 'w', encoding='utf-8') as f:
await f.write(json.dumps(all_elements_state.dom_state._root.original_node.__json__(), indent=2))
# copy the user message to the clipboard
# pyperclip.copy(text_to_save)
encoding = tiktoken.encoding_for_model('gpt-4.1-mini')
token_count = len(encoding.encode(text_to_save))
print(f'Token count: {token_count}')
print('User message written to ./tmp/user_message.txt')
print('Element tree written to ./tmp/simplified_element_tree.json')
print('Original element tree written to ./tmp/original_element_tree.json')
# Save timing information
timing_text = '🔍 DOM EXTRACTION PERFORMANCE ANALYSIS\n'
timing_text += f'{"=" * 50}\n\n'
timing_text += f'📄 Website: {website}\n'
timing_text += f'📊 Total Elements: {total_elements}\n'
timing_text += f'🎯 Token Count: {token_count}\n\n'
timing_text += '⏱️ TIMING BREAKDOWN:\n'
timing_text += f'{"" * 30}\n'
for key, value in all_timing.items():
timing_text += f'{key:<35}: {value * 1000:>8.2f} ms\n'
# Calculate percentages
total_time = all_timing.get('get_state_summary_total', 0)
if total_time > 0 and total_elements > 0:
timing_text += '\n📈 PERCENTAGE BREAKDOWN:\n'
timing_text += f'{"" * 30}\n'
for key, value in all_timing.items():
if key != 'get_state_summary_total':
percentage = (value / total_time) * 100
timing_text += f'{key:<35}: {percentage:>7.1f}%\n'
timing_text += '\n🎯 CLICKABLE DETECTION ANALYSIS:\n'
timing_text += f'{"" * 35}\n'
clickable_time = all_timing.get('clickable_detection_time', 0)
if clickable_time > 0 and total_elements > 0:
avg_per_element = (clickable_time / total_elements) * 1000000 # microseconds
timing_text += f'Total clickable detection time: {clickable_time * 1000:.2f} ms\n'
timing_text += f'Average per element: {avg_per_element:.2f} μs\n'
timing_text += f'Clickable detection calls: ~{total_elements} (approx)\n'
async with await anyio.open_file('./tmp/timing_analysis.txt', 'w', encoding='utf-8') as f:
await f.write(timing_text)
print('Timing analysis written to ./tmp/timing_analysis.txt')
# also save all_elements_state.element_tree.clickable_elements_to_string() to a file
# with open('./tmp/clickable_elements.json', 'w', encoding='utf-8') as f:
# f.write(json.dumps(all_elements_state.element_tree.__json__(), indent=2))
# print('Clickable elements written to ./tmp/clickable_elements.json')
website_list = get_website_list_for_prompt()
answer = input(
"🎮 Enter: element index | 'index' click (clickable) | 'index,text' input | 'c,index' copy | Enter re-run | 'n' next | 'q' quit: "
)
if answer.lower() == 'q':
return # Exit completely
elif answer.lower() == 'n':
print('Moving to next website...')
current_website_index += 1
break # Break inner loop to go to next website
elif answer.strip() == '':
print('Re-running extraction on current page state...')
continue # Continue inner loop to re-extract DOM without reloading page
elif answer.strip().isdigit():
# Click element format: index
try:
clicked_index = int(answer)
if clicked_index in selector_map:
element_node = selector_map[clicked_index]
print(f'Clicking element {clicked_index}: {element_node.tag_name}')
event = browser_session.event_bus.dispatch(ClickElementEvent(node=element_node))
await event
print('Click successful.')
except ValueError:
print(f"Invalid input: '{answer}'. Enter an index, 'index,text', 'c,index', or 'q'.")
continue
try:
if answer.lower().startswith('c,'):
# Copy element JSON format: c,index
parts = answer.split(',', 1)
if len(parts) == 2:
try:
target_index = int(parts[1].strip())
if target_index in selector_map:
element_node = selector_map[target_index]
element_json = json.dumps(element_node.__json__(), indent=2, default=str)
pyperclip.copy(element_json)
print(f'Copied element {target_index} JSON to clipboard: {element_node.tag_name}')
else:
print(f'Invalid index: {target_index}')
except ValueError:
print(f'Invalid index format: {parts[1]}')
else:
print("Invalid input format. Use 'c,index'.")
elif ',' in answer:
# Input text format: index,text
parts = answer.split(',', 1)
if len(parts) == 2:
try:
target_index = int(parts[0].strip())
text_to_input = parts[1]
if target_index in selector_map:
element_node = selector_map[target_index]
print(
f"Inputting text '{text_to_input}' into element {target_index}: {element_node.tag_name}"
)
event = await browser_session.event_bus.dispatch(
TypeTextEvent(node=element_node, text=text_to_input)
)
print('Input successful.')
else:
print(f'Invalid index: {target_index}')
except ValueError:
print(f'Invalid index format: {parts[0]}')
else:
print("Invalid input format. Use 'index,text'.")
except Exception as action_e:
print(f'Action failed: {action_e}')
# No explicit highlight removal here, get_state handles it at the start of the loop
except Exception as e:
print(f'Error in loop: {e}')
# Optionally add a small delay before retrying
await asyncio.sleep(1)
if __name__ == '__main__':
asyncio.run(test_focus_vs_all_elements())
# asyncio.run(test_process_html_file()) # Commented out the other test
@@ -0,0 +1,32 @@
from browser_use import Agent
from browser_use.browser import BrowserProfile, BrowserSession
from browser_use.browser.types import ViewportSize
from browser_use.llm import ChatAzureOpenAI
# Initialize the Azure OpenAI client
llm = ChatAzureOpenAI(
model='gpt-4.1-mini',
)
TASK = """
Go to https://browser-use.github.io/stress-tests/challenges/react-native-web-form.html and complete the React Native Web form by filling in all required fields and submitting.
"""
async def main():
browser = BrowserSession(
browser_profile=BrowserProfile(
window_size=ViewportSize(width=1100, height=1000),
)
)
agent = Agent(task=TASK, llm=llm)
await agent.run()
if __name__ == '__main__':
import asyncio
asyncio.run(main())
@@ -0,0 +1,199 @@
from browser_use.dom.views import EnhancedDOMTreeNode, NodeType
class ClickableElementDetector:
@staticmethod
def is_interactive(node: EnhancedDOMTreeNode) -> bool:
"""Check if this node is clickable/interactive using enhanced scoring."""
# Skip non-element nodes
if node.node_type != NodeType.ELEMENT_NODE:
return False
# # if ax ignored skip
# if node.ax_node and node.ax_node.ignored:
# return False
# remove html and body nodes
if node.tag_name in {'html', 'body'}:
return False
# IFRAME elements should be interactive if they're large enough to potentially need scrolling
# Small iframes (< 100px width or height) are unlikely to have scrollable content
if node.tag_name and node.tag_name.upper() == 'IFRAME' or node.tag_name.upper() == 'FRAME':
if node.snapshot_node and node.snapshot_node.bounds:
width = node.snapshot_node.bounds.width
height = node.snapshot_node.bounds.height
# Only include iframes larger than 100x100px
if width > 100 and height > 100:
return True
# RELAXED SIZE CHECK: Allow all elements including size 0 (they might be interactive overlays, etc.)
# Note: Size 0 elements can still be interactive (e.g., invisible clickable overlays)
# Visibility is determined separately by CSS styles, not just bounding box size
# SEARCH ELEMENT DETECTION: Check for search-related classes and attributes
if node.attributes:
search_indicators = {
'search',
'magnify',
'glass',
'lookup',
'find',
'query',
'search-icon',
'search-btn',
'search-button',
'searchbox',
}
# Check class names for search indicators
class_list = node.attributes.get('class', '').lower().split()
if any(indicator in ' '.join(class_list) for indicator in search_indicators):
return True
# Check id for search indicators
element_id = node.attributes.get('id', '').lower()
if any(indicator in element_id for indicator in search_indicators):
return True
# Check data attributes for search functionality
for attr_name, attr_value in node.attributes.items():
if attr_name.startswith('data-') and any(indicator in attr_value.lower() for indicator in search_indicators):
return True
# Enhanced accessibility property checks - direct clear indicators only
if node.ax_node and node.ax_node.properties:
for prop in node.ax_node.properties:
try:
# aria disabled
if prop.name == 'disabled' and prop.value:
return False
# aria hidden
if prop.name == 'hidden' and prop.value:
return False
# Direct interactiveness indicators
if prop.name in ['focusable', 'editable', 'settable'] and prop.value:
return True
# Interactive state properties (presence indicates interactive widget)
if prop.name in ['checked', 'expanded', 'pressed', 'selected']:
# These properties only exist on interactive elements
return True
# Form-related interactiveness
if prop.name in ['required', 'autocomplete'] and prop.value:
return True
# Elements with keyboard shortcuts are interactive
if prop.name == 'keyshortcuts' and prop.value:
return True
except (AttributeError, ValueError):
# Skip properties we can't process
continue
# ENHANCED TAG CHECK: Include truly interactive elements
# Note: 'label' removed - labels are handled by other attribute checks below - other wise labels with "for" attribute can destroy the real clickable element on apartments.com
interactive_tags = {
'button',
'input',
'select',
'textarea',
'a',
'details',
'summary',
'option',
'optgroup',
}
if node.tag_name in interactive_tags:
return True
# SVG elements need special handling - only interactive if they have explicit handlers
# svg_tags = {'svg', 'path', 'circle', 'rect', 'polygon', 'ellipse', 'line', 'polyline', 'g'}
# if node.tag_name in svg_tags:
# # Only consider SVG elements interactive if they have:
# # 1. Explicit event handlers
# # 2. Interactive role attributes
# # 3. Cursor pointer style
# if node.attributes:
# # Check for event handlers
# if any(attr.startswith('on') for attr in node.attributes):
# return True
# # Check for interactive roles
# if node.attributes.get('role') in {'button', 'link', 'menuitem'}:
# return True
# # Check for cursor pointer (indicating clickability)
# if node.attributes.get('style') and 'cursor: pointer' in node.attributes.get('style', ''):
# return True
# # Otherwise, SVG elements are decorative
# return False
# Tertiary check: elements with interactive attributes
if node.attributes:
# Check for event handlers or interactive attributes
interactive_attributes = {'onclick', 'onmousedown', 'onmouseup', 'onkeydown', 'onkeyup', 'tabindex'}
if any(attr in node.attributes for attr in interactive_attributes):
return True
# Check for interactive ARIA roles
if 'role' in node.attributes:
interactive_roles = {
'button',
'link',
'menuitem',
'option',
'radio',
'checkbox',
'tab',
'textbox',
'combobox',
'slider',
'spinbutton',
'search',
'searchbox',
}
if node.attributes['role'] in interactive_roles:
return True
# Quaternary check: accessibility tree roles
if node.ax_node and node.ax_node.role:
interactive_ax_roles = {
'button',
'link',
'menuitem',
'option',
'radio',
'checkbox',
'tab',
'textbox',
'combobox',
'slider',
'spinbutton',
'listbox',
'search',
'searchbox',
}
if node.ax_node.role in interactive_ax_roles:
return True
# ICON AND SMALL ELEMENT CHECK: Elements that might be icons
if (
node.snapshot_node
and node.snapshot_node.bounds
and 10 <= node.snapshot_node.bounds.width <= 50 # Icon-sized elements
and 10 <= node.snapshot_node.bounds.height <= 50
):
# Check if this small element has interactive properties
if node.attributes:
# Small elements with these attributes are likely interactive icons
icon_attributes = {'class', 'role', 'onclick', 'data-action', 'aria-label'}
if any(attr in node.attributes for attr in icon_attributes):
return True
# Final fallback: cursor style indicates interactivity (for cases Chrome missed)
if node.snapshot_node and node.snapshot_node.cursor_style and node.snapshot_node.cursor_style == 'pointer':
return True
return False
@@ -0,0 +1,197 @@
from collections import defaultdict
from dataclasses import dataclass
from browser_use.dom.views import SimplifiedNode
"""
Helper class for maintaining a union of rectangles (used for order of elements calculation)
"""
@dataclass(frozen=True, slots=True)
class Rect:
"""Closed axis-aligned rectangle with (x1,y1) bottom-left, (x2,y2) top-right."""
x1: float
y1: float
x2: float
y2: float
def __post_init__(self):
if not (self.x1 <= self.x2 and self.y1 <= self.y2):
return False
# --- fast relations ----------------------------------------------------
def area(self) -> float:
return (self.x2 - self.x1) * (self.y2 - self.y1)
def intersects(self, other: 'Rect') -> bool:
return not (self.x2 <= other.x1 or other.x2 <= self.x1 or self.y2 <= other.y1 or other.y2 <= self.y1)
def contains(self, other: 'Rect') -> bool:
return self.x1 <= other.x1 and self.y1 <= other.y1 and self.x2 >= other.x2 and self.y2 >= other.y2
class RectUnionPure:
"""
Maintains a *disjoint* set of rectangles.
No external dependencies - fine for a few thousand rectangles.
"""
__slots__ = ('_rects',)
def __init__(self):
self._rects: list[Rect] = []
# -----------------------------------------------------------------
def _split_diff(self, a: Rect, b: Rect) -> list[Rect]:
r"""
Return list of up to 4 rectangles = a \ b.
Assumes a intersects b.
"""
parts = []
# Bottom slice
if a.y1 < b.y1:
parts.append(Rect(a.x1, a.y1, a.x2, b.y1))
# Top slice
if b.y2 < a.y2:
parts.append(Rect(a.x1, b.y2, a.x2, a.y2))
# Middle (vertical) strip: y overlap is [max(a.y1,b.y1), min(a.y2,b.y2)]
y_lo = max(a.y1, b.y1)
y_hi = min(a.y2, b.y2)
# Left slice
if a.x1 < b.x1:
parts.append(Rect(a.x1, y_lo, b.x1, y_hi))
# Right slice
if b.x2 < a.x2:
parts.append(Rect(b.x2, y_lo, a.x2, y_hi))
return parts
# -----------------------------------------------------------------
def contains(self, r: Rect) -> bool:
"""
True iff r is fully covered by the current union.
"""
if not self._rects:
return False
stack = [r]
for s in self._rects:
new_stack = []
for piece in stack:
if s.contains(piece):
# piece completely gone
continue
if piece.intersects(s):
new_stack.extend(self._split_diff(piece, s))
else:
new_stack.append(piece)
if not new_stack: # everything eaten covered
return True
stack = new_stack
return False # something survived
# -----------------------------------------------------------------
def add(self, r: Rect) -> bool:
"""
Insert r unless it is already covered.
Returns True if the union grew.
"""
if self.contains(r):
return False
pending = [r]
i = 0
while i < len(self._rects):
s = self._rects[i]
new_pending = []
changed = False
for piece in pending:
if piece.intersects(s):
new_pending.extend(self._split_diff(piece, s))
changed = True
else:
new_pending.append(piece)
pending = new_pending
if changed:
# s unchanged; proceed with next existing rectangle
i += 1
else:
i += 1
# Any leftover pieces are new, nonoverlapping areas
self._rects.extend(pending)
return True
class PaintOrderRemover:
"""
Calculates which elements should be removed based on the paint order parameter.
"""
def __init__(self, root: SimplifiedNode):
self.root = root
def calculate_paint_order(self) -> None:
all_simplified_nodes_with_paint_order: list[SimplifiedNode] = []
def collect_paint_order(node: SimplifiedNode) -> None:
if (
node.original_node.snapshot_node
and node.original_node.snapshot_node.paint_order is not None
and node.original_node.snapshot_node.bounds is not None
):
all_simplified_nodes_with_paint_order.append(node)
for child in node.children:
collect_paint_order(child)
collect_paint_order(self.root)
grouped_by_paint_order: defaultdict[int, list[SimplifiedNode]] = defaultdict(list)
for node in all_simplified_nodes_with_paint_order:
if node.original_node.snapshot_node and node.original_node.snapshot_node.paint_order is not None:
grouped_by_paint_order[node.original_node.snapshot_node.paint_order].append(node)
rect_union = RectUnionPure()
for paint_order, nodes in sorted(grouped_by_paint_order.items(), key=lambda x: -x[0]):
rects_to_add = []
for node in nodes:
if not node.original_node.snapshot_node or not node.original_node.snapshot_node.bounds:
continue # shouldn't happen by how we filter them out in the first place
rect = Rect(
x1=node.original_node.snapshot_node.bounds.x,
y1=node.original_node.snapshot_node.bounds.y,
x2=node.original_node.snapshot_node.bounds.x + node.original_node.snapshot_node.bounds.width,
y2=node.original_node.snapshot_node.bounds.y + node.original_node.snapshot_node.bounds.height,
)
if rect_union.contains(rect):
node.ignored_by_paint_order = True
# don't add to the nodes if opacity is less then 0.95 or background-color is transparent
if (
node.original_node.snapshot_node.computed_styles
and node.original_node.snapshot_node.computed_styles.get('background-color', 'rgba(0, 0, 0, 0)')
== 'rgba(0, 0, 0, 0)'
) or (
node.original_node.snapshot_node.computed_styles
and float(node.original_node.snapshot_node.computed_styles.get('opacity', '1'))
< 0.8 # this is highly vibes based number
):
continue
rects_to_add.append(rect)
for rect in rects_to_add:
rect_union.add(rect)
return None
@@ -0,0 +1,954 @@
# @file purpose: Serializes enhanced DOM trees to string format for LLM consumption
from typing import Any
from browser_use.dom.serializer.clickable_elements import ClickableElementDetector
from browser_use.dom.serializer.paint_order import PaintOrderRemover
from browser_use.dom.utils import cap_text_length
from browser_use.dom.views import (
DOMRect,
DOMSelectorMap,
EnhancedDOMTreeNode,
NodeType,
PropagatingBounds,
SerializedDOMState,
SimplifiedNode,
)
DISABLED_ELEMENTS = {'style', 'script', 'head', 'meta', 'link', 'title'}
class DOMTreeSerializer:
"""Serializes enhanced DOM trees to string format."""
# Configuration - elements that propagate bounds to their children
PROPAGATING_ELEMENTS = [
{'tag': 'a', 'role': None}, # Any <a> tag
{'tag': 'button', 'role': None}, # Any <button> tag
{'tag': 'div', 'role': 'button'}, # <div role="button">
{'tag': 'div', 'role': 'combobox'}, # <div role="combobox"> - dropdowns/selects
{'tag': 'span', 'role': 'button'}, # <span role="button">
{'tag': 'span', 'role': 'combobox'}, # <span role="combobox">
{'tag': 'input', 'role': 'combobox'}, # <input role="combobox"> - autocomplete inputs
{'tag': 'input', 'role': 'combobox'}, # <input type="text"> - text inputs with suggestions
# {'tag': 'div', 'role': 'link'}, # <div role="link">
# {'tag': 'span', 'role': 'link'}, # <span role="link">
]
DEFAULT_CONTAINMENT_THRESHOLD = 0.99 # 99% containment by default
def __init__(
self,
root_node: EnhancedDOMTreeNode,
previous_cached_state: SerializedDOMState | None = None,
enable_bbox_filtering: bool = True,
containment_threshold: float | None = None,
paint_order_filtering: bool = True,
):
self.root_node = root_node
self._interactive_counter = 1
self._selector_map: DOMSelectorMap = {}
self._previous_cached_selector_map = previous_cached_state.selector_map if previous_cached_state else None
# Add timing tracking
self.timing_info: dict[str, float] = {}
# Cache for clickable element detection to avoid redundant calls
self._clickable_cache: dict[int, bool] = {}
# Bounding box filtering configuration
self.enable_bbox_filtering = enable_bbox_filtering
self.containment_threshold = containment_threshold or self.DEFAULT_CONTAINMENT_THRESHOLD
# Paint order filtering configuration
self.paint_order_filtering = paint_order_filtering
def _safe_parse_number(self, value_str: str, default: float) -> float:
"""Parse string to float, handling negatives and decimals."""
try:
return float(value_str)
except (ValueError, TypeError):
return default
def _safe_parse_optional_number(self, value_str: str | None) -> float | None:
"""Parse string to float, returning None for invalid values."""
if not value_str:
return None
try:
return float(value_str)
except (ValueError, TypeError):
return None
def serialize_accessible_elements(self) -> tuple[SerializedDOMState, dict[str, float]]:
import time
start_total = time.time()
# Reset state
self._interactive_counter = 1
self._selector_map = {}
self._semantic_groups = []
self._clickable_cache = {} # Clear cache for new serialization
# Step 1: Create simplified tree (includes clickable element detection)
start_step1 = time.time()
simplified_tree = self._create_simplified_tree(self.root_node)
end_step1 = time.time()
self.timing_info['create_simplified_tree'] = end_step1 - start_step1
# Step 2: Remove elements based on paint order
start_step3 = time.time()
if self.paint_order_filtering and simplified_tree:
PaintOrderRemover(simplified_tree).calculate_paint_order()
end_step3 = time.time()
self.timing_info['calculate_paint_order'] = end_step3 - start_step3
# Step 3: Optimize tree (remove unnecessary parents)
start_step2 = time.time()
optimized_tree = self._optimize_tree(simplified_tree)
end_step2 = time.time()
self.timing_info['optimize_tree'] = end_step2 - start_step2
# Step 3: Apply bounding box filtering (NEW)
if self.enable_bbox_filtering and optimized_tree:
start_step3 = time.time()
filtered_tree = self._apply_bounding_box_filtering(optimized_tree)
end_step3 = time.time()
self.timing_info['bbox_filtering'] = end_step3 - start_step3
else:
filtered_tree = optimized_tree
# Step 4: Assign interactive indices to clickable elements
start_step4 = time.time()
self._assign_interactive_indices_and_mark_new_nodes(filtered_tree)
end_step4 = time.time()
self.timing_info['assign_interactive_indices'] = end_step4 - start_step4
end_total = time.time()
self.timing_info['serialize_accessible_elements_total'] = end_total - start_total
return SerializedDOMState(_root=filtered_tree, selector_map=self._selector_map), self.timing_info
def _add_compound_components(self, simplified: SimplifiedNode, node: EnhancedDOMTreeNode) -> None:
"""Enhance compound controls with information from their child components."""
# Only process elements that might have compound components
if node.tag_name not in ['input', 'select', 'details', 'audio', 'video']:
return
# For input elements, check for compound input types
if node.tag_name == 'input':
if not node.attributes or node.attributes.get('type') not in [
'date',
'time',
'datetime-local',
'month',
'week',
'range',
'number',
'color',
'file',
]:
return
# For other elements, check if they have AX child indicators
elif not node.ax_node or not node.ax_node.child_ids:
return
# Add compound component information based on element type
element_type = node.tag_name
input_type = node.attributes.get('type', '') if node.attributes else ''
if element_type == 'input':
if input_type == 'date':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Day', 'valuemin': 1, 'valuemax': 31, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Month', 'valuemin': 1, 'valuemax': 12, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'time':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Hour', 'valuemin': 0, 'valuemax': 23, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Minute', 'valuemin': 0, 'valuemax': 59, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'datetime-local':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Day', 'valuemin': 1, 'valuemax': 31, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Month', 'valuemin': 1, 'valuemax': 12, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Hour', 'valuemin': 0, 'valuemax': 23, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Minute', 'valuemin': 0, 'valuemax': 59, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'month':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Month', 'valuemin': 1, 'valuemax': 12, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'week':
node._compound_children.extend(
[
{'role': 'spinbutton', 'name': 'Week', 'valuemin': 1, 'valuemax': 53, 'valuenow': None},
{'role': 'spinbutton', 'name': 'Year', 'valuemin': 1, 'valuemax': 275760, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'range':
# Range slider with value indicator
min_val = node.attributes.get('min', '0') if node.attributes else '0'
max_val = node.attributes.get('max', '100') if node.attributes else '100'
node._compound_children.append(
{
'role': 'slider',
'name': 'Value',
'valuemin': self._safe_parse_number(min_val, 0.0),
'valuemax': self._safe_parse_number(max_val, 100.0),
'valuenow': None,
}
)
simplified.is_compound_component = True
elif input_type == 'number':
# Number input with increment/decrement buttons
min_val = node.attributes.get('min') if node.attributes else None
max_val = node.attributes.get('max') if node.attributes else None
node._compound_children.extend(
[
{'role': 'button', 'name': 'Increment', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'button', 'name': 'Decrement', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{
'role': 'textbox',
'name': 'Value',
'valuemin': self._safe_parse_optional_number(min_val),
'valuemax': self._safe_parse_optional_number(max_val),
'valuenow': None,
},
]
)
simplified.is_compound_component = True
elif input_type == 'color':
# Color picker with components
node._compound_children.extend(
[
{'role': 'textbox', 'name': 'Hex Value', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'button', 'name': 'Color Picker', 'valuemin': None, 'valuemax': None, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif input_type == 'file':
# File input with browse button
multiple = 'multiple' in node.attributes if node.attributes else False
node._compound_children.extend(
[
{'role': 'button', 'name': 'Browse Files', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{
'role': 'textbox',
'name': f'{"Files" if multiple else "File"} Selected',
'valuemin': None,
'valuemax': None,
'valuenow': None,
},
]
)
simplified.is_compound_component = True
elif element_type == 'select':
# Select dropdown with option list and detailed option information
base_components = [
{'role': 'button', 'name': 'Dropdown Toggle', 'valuemin': None, 'valuemax': None, 'valuenow': None}
]
# Extract option information from child nodes
options_info = self._extract_select_options(node)
if options_info:
options_component = {
'role': 'listbox',
'name': 'Options',
'valuemin': None,
'valuemax': None,
'valuenow': None,
'options_count': options_info['count'],
'first_options': options_info['first_options'],
}
if options_info['format_hint']:
options_component['format_hint'] = options_info['format_hint']
base_components.append(options_component)
else:
base_components.append(
{'role': 'listbox', 'name': 'Options', 'valuemin': None, 'valuemax': None, 'valuenow': None}
)
node._compound_children.extend(base_components)
simplified.is_compound_component = True
elif element_type == 'details':
# Details/summary disclosure widget
node._compound_children.extend(
[
{'role': 'button', 'name': 'Toggle Disclosure', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'region', 'name': 'Content Area', 'valuemin': None, 'valuemax': None, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif element_type == 'audio':
# Audio player controls
node._compound_children.extend(
[
{'role': 'button', 'name': 'Play/Pause', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Progress', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
{'role': 'button', 'name': 'Mute', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Volume', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
]
)
simplified.is_compound_component = True
elif element_type == 'video':
# Video player controls
node._compound_children.extend(
[
{'role': 'button', 'name': 'Play/Pause', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Progress', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
{'role': 'button', 'name': 'Mute', 'valuemin': None, 'valuemax': None, 'valuenow': None},
{'role': 'slider', 'name': 'Volume', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
{'role': 'button', 'name': 'Fullscreen', 'valuemin': None, 'valuemax': None, 'valuenow': None},
]
)
simplified.is_compound_component = True
def _extract_select_options(self, select_node: EnhancedDOMTreeNode) -> dict[str, Any] | None:
"""Extract option information from a select element."""
if not select_node.children:
return None
options = []
option_values = []
def extract_options_recursive(node: EnhancedDOMTreeNode) -> None:
"""Recursively extract option elements, including from optgroups."""
if node.tag_name.lower() == 'option':
# Extract option text and value
option_text = ''
option_value = ''
# Get value attribute if present
if node.attributes and 'value' in node.attributes:
option_value = str(node.attributes['value']).strip()
# Get text content from direct child text nodes only to avoid duplication
def get_direct_text_content(n: EnhancedDOMTreeNode) -> str:
text = ''
for child in n.children:
if child.node_type == NodeType.TEXT_NODE and child.node_value:
text += child.node_value.strip() + ' '
return text.strip()
option_text = get_direct_text_content(node)
# Use text as value if no explicit value
if not option_value and option_text:
option_value = option_text
if option_text or option_value:
options.append({'text': option_text, 'value': option_value})
option_values.append(option_value)
elif node.tag_name.lower() == 'optgroup':
# Process optgroup children
for child in node.children:
extract_options_recursive(child)
else:
# Process other children that might contain options
for child in node.children:
extract_options_recursive(child)
# Extract all options from select children
for child in select_node.children:
extract_options_recursive(child)
if not options:
return None
# Prepare first 4 options for display
first_options = []
for option in options[:4]:
if option['text'] and option['value'] and option['text'] != option['value']:
# Limit individual option text to avoid overly long attributes
text = option['text'][:20] + ('...' if len(option['text']) > 20 else '')
value = option['value'][:10] + ('...' if len(option['value']) > 10 else '')
first_options.append(f'{text} ({value})')
elif option['text']:
text = option['text'][:25] + ('...' if len(option['text']) > 25 else '')
first_options.append(text)
elif option['value']:
value = option['value'][:25] + ('...' if len(option['value']) > 25 else '')
first_options.append(value)
# Try to infer format hint from option values
format_hint = None
if len(option_values) >= 2:
# Check for common patterns
if all(val.isdigit() for val in option_values[:5] if val):
format_hint = 'numeric'
elif all(len(val) == 2 and val.isupper() for val in option_values[:5] if val):
format_hint = 'country/state codes'
elif all('/' in val or '-' in val for val in option_values[:5] if val):
format_hint = 'date/path format'
elif any('@' in val for val in option_values[:5] if val):
format_hint = 'email addresses'
return {'count': len(options), 'first_options': first_options, 'format_hint': format_hint}
def _is_interactive_cached(self, node: EnhancedDOMTreeNode) -> bool:
"""Cached version of clickable element detection to avoid redundant calls."""
if node.node_id not in self._clickable_cache:
import time
start_time = time.time()
result = ClickableElementDetector.is_interactive(node)
end_time = time.time()
if 'clickable_detection_time' not in self.timing_info:
self.timing_info['clickable_detection_time'] = 0
self.timing_info['clickable_detection_time'] += end_time - start_time
self._clickable_cache[node.node_id] = result
return self._clickable_cache[node.node_id]
def _create_simplified_tree(self, node: EnhancedDOMTreeNode, depth: int = 0) -> SimplifiedNode | None:
"""Step 1: Create a simplified tree with enhanced element detection."""
if node.node_type == NodeType.DOCUMENT_NODE:
# for all cldren including shadow roots
for child in node.children_and_shadow_roots:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child:
return simplified_child
return None
if node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
# ENHANCED shadow DOM processing - always include shadow content
simplified = SimplifiedNode(original_node=node, children=[])
for child in node.children_and_shadow_roots:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child:
simplified.children.append(simplified_child)
# Always return shadow DOM fragments, even if children seem empty
# Shadow DOM often contains the actual interactive content in SPAs
return simplified if simplified.children else SimplifiedNode(original_node=node, children=[])
elif node.node_type == NodeType.ELEMENT_NODE:
# Skip non-content elements
if node.node_name.lower() in DISABLED_ELEMENTS:
return None
if node.node_name == 'IFRAME' or node.node_name == 'FRAME':
if node.content_document:
simplified = SimplifiedNode(original_node=node, children=[])
for child in node.content_document.children_nodes or []:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child is not None:
simplified.children.append(simplified_child)
return simplified
is_visible = node.is_visible
is_scrollable = node.is_actually_scrollable
has_shadow_content = bool(node.children_and_shadow_roots)
# ENHANCED SHADOW DOM DETECTION: Include shadow hosts even if not visible
is_shadow_host = any(child.node_type == NodeType.DOCUMENT_FRAGMENT_NODE for child in node.children_and_shadow_roots)
# Override visibility for elements with validation attributes
if not is_visible and node.attributes:
has_validation_attrs = any(attr.startswith(('aria-', 'pseudo')) for attr in node.attributes.keys())
if has_validation_attrs:
is_visible = True # Force visibility for validation elements
# Include if visible, scrollable, has children, or is shadow host
if is_visible or is_scrollable or has_shadow_content or is_shadow_host:
simplified = SimplifiedNode(original_node=node, children=[], is_shadow_host=is_shadow_host)
# Process ALL children including shadow roots with enhanced logging
for child in node.children_and_shadow_roots:
simplified_child = self._create_simplified_tree(child, depth + 1)
if simplified_child:
simplified.children.append(simplified_child)
# COMPOUND CONTROL PROCESSING: Add virtual components for compound controls
self._add_compound_components(simplified, node)
# SHADOW DOM SPECIAL CASE: Always include shadow hosts even if not visible
# Many SPA frameworks (React, Vue) render content in shadow DOM
if is_shadow_host and simplified.children:
return simplified
# Return if meaningful or has meaningful children
if is_visible or is_scrollable or simplified.children:
return simplified
elif node.node_type == NodeType.TEXT_NODE:
# Include meaningful text nodes
is_visible = node.snapshot_node and node.is_visible
if is_visible and node.node_value and node.node_value.strip() and len(node.node_value.strip()) > 1:
return SimplifiedNode(original_node=node, children=[])
return None
def _optimize_tree(self, node: SimplifiedNode | None) -> SimplifiedNode | None:
"""Step 2: Optimize tree structure."""
if not node:
return None
# Process children
optimized_children = []
for child in node.children:
optimized_child = self._optimize_tree(child)
if optimized_child:
optimized_children.append(optimized_child)
node.children = optimized_children
# Keep meaningful nodes
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
if (
is_visible # Keep all visible nodes
or node.original_node.is_actually_scrollable
or node.original_node.node_type == NodeType.TEXT_NODE
or node.children
):
return node
return None
def _collect_interactive_elements(self, node: SimplifiedNode, elements: list[SimplifiedNode]) -> None:
"""Recursively collect interactive elements that are also visible."""
is_interactive = self._is_interactive_cached(node.original_node)
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
# Only collect elements that are both interactive AND visible
if is_interactive and is_visible:
elements.append(node)
for child in node.children:
self._collect_interactive_elements(child, elements)
def _assign_interactive_indices_and_mark_new_nodes(self, node: SimplifiedNode | None) -> None:
"""Assign interactive indices to clickable elements that are also visible."""
if not node:
return
# Skip assigning index to excluded nodes, or ignored by paint order
if not node.excluded_by_parent and not node.ignored_by_paint_order:
# Regular interactive element assignment (including enhanced compound controls)
is_interactive_assign = self._is_interactive_cached(node.original_node)
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
# Only add to selector map if element is both interactive AND visible
if is_interactive_assign and is_visible:
node.interactive_index = self._interactive_counter
node.original_node.element_index = self._interactive_counter
self._selector_map[self._interactive_counter] = node.original_node
self._interactive_counter += 1
# Mark compound components as new for visibility
if node.is_compound_component:
node.is_new = True
elif self._previous_cached_selector_map:
# Check if node is new for regular elements
previous_backend_node_ids = {node.backend_node_id for node in self._previous_cached_selector_map.values()}
if node.original_node.backend_node_id not in previous_backend_node_ids:
node.is_new = True
# Process children
for child in node.children:
self._assign_interactive_indices_and_mark_new_nodes(child)
def _apply_bounding_box_filtering(self, node: SimplifiedNode | None) -> SimplifiedNode | None:
"""Filter children contained within propagating parent bounds."""
if not node:
return None
# Start with no active bounds
self._filter_tree_recursive(node, active_bounds=None, depth=0)
# Log statistics
excluded_count = self._count_excluded_nodes(node)
if excluded_count > 0:
import logging
logging.debug(f'BBox filtering excluded {excluded_count} nodes')
return node
def _filter_tree_recursive(self, node: SimplifiedNode, active_bounds: PropagatingBounds | None = None, depth: int = 0):
"""
Recursively filter tree with bounding box propagation.
Bounds propagate to ALL descendants until overridden.
"""
# Check if this node should be excluded by active bounds
if active_bounds and self._should_exclude_child(node, active_bounds):
node.excluded_by_parent = True
# Important: Still check if this node starts NEW propagation
# Check if this node starts new propagation (even if excluded!)
new_bounds = None
tag = node.original_node.tag_name.lower()
role = node.original_node.attributes.get('role') if node.original_node.attributes else None
attributes = {
'tag': tag,
'role': role,
}
# Check if this element matches any propagating element pattern
if self._is_propagating_element(attributes):
# This node propagates bounds to ALL its descendants
if node.original_node.snapshot_node and node.original_node.snapshot_node.bounds:
new_bounds = PropagatingBounds(
tag=tag,
bounds=node.original_node.snapshot_node.bounds,
node_id=node.original_node.node_id,
depth=depth,
)
# Propagate to ALL children
# Use new_bounds if this node starts propagation, otherwise continue with active_bounds
propagate_bounds = new_bounds if new_bounds else active_bounds
for child in node.children:
self._filter_tree_recursive(child, propagate_bounds, depth + 1)
def _should_exclude_child(self, node: SimplifiedNode, active_bounds: PropagatingBounds) -> bool:
"""
Determine if child should be excluded based on propagating bounds.
"""
# Never exclude text nodes - we always want to preserve text content
if node.original_node.node_type == NodeType.TEXT_NODE:
return False
# Get child bounds
if not node.original_node.snapshot_node or not node.original_node.snapshot_node.bounds:
return False # No bounds = can't determine containment
child_bounds = node.original_node.snapshot_node.bounds
# Check containment with configured threshold
if not self._is_contained(child_bounds, active_bounds.bounds, self.containment_threshold):
return False # Not sufficiently contained
# EXCEPTION RULES - Keep these even if contained:
child_tag = node.original_node.tag_name.lower()
child_role = node.original_node.attributes.get('role') if node.original_node.attributes else None
child_attributes = {
'tag': child_tag,
'role': child_role,
}
# 1. Never exclude form elements (they need individual interaction)
if child_tag in ['input', 'select', 'textarea', 'label']:
return False
# 2. Keep if child is also a propagating element
# (might have stopPropagation, e.g., button in button)
if self._is_propagating_element(child_attributes):
return False
# 3. Keep if has explicit onclick handler
if node.original_node.attributes and 'onclick' in node.original_node.attributes:
return False
# 4. Keep if has aria-label suggesting it's independently interactive
if node.original_node.attributes:
aria_label = node.original_node.attributes.get('aria-label')
if aria_label and aria_label.strip():
# Has meaningful aria-label, likely interactive
return False
# 5. Keep if has role suggesting interactivity
if node.original_node.attributes:
role = node.original_node.attributes.get('role')
if role in ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem']:
return False
# Default: exclude this child
return True
def _is_contained(self, child: DOMRect, parent: DOMRect, threshold: float) -> bool:
"""
Check if child is contained within parent bounds.
Args:
threshold: Percentage (0.0-1.0) of child that must be within parent
"""
# Calculate intersection
x_overlap = max(0, min(child.x + child.width, parent.x + parent.width) - max(child.x, parent.x))
y_overlap = max(0, min(child.y + child.height, parent.y + parent.height) - max(child.y, parent.y))
intersection_area = x_overlap * y_overlap
child_area = child.width * child.height
if child_area == 0:
return False # Zero-area element
containment_ratio = intersection_area / child_area
return containment_ratio >= threshold
def _count_excluded_nodes(self, node: SimplifiedNode, count: int = 0) -> int:
"""Count how many nodes were excluded (for debugging)."""
if hasattr(node, 'excluded_by_parent') and node.excluded_by_parent:
count += 1
for child in node.children:
count = self._count_excluded_nodes(child, count)
return count
def _is_propagating_element(self, attributes: dict[str, str | None]) -> bool:
"""
Check if an element should propagate bounds based on attributes.
If the element satisfies one of the patterns, it propagates bounds to all its children.
"""
keys_to_check = ['tag', 'role']
for pattern in self.PROPAGATING_ELEMENTS:
# Check if the element satisfies the pattern
check = [pattern.get(key) is None or pattern.get(key) == attributes.get(key) for key in keys_to_check]
if all(check):
return True
return False
@staticmethod
def serialize_tree(node: SimplifiedNode | None, include_attributes: list[str], depth: int = 0) -> str:
"""Serialize the optimized tree to string format."""
if not node:
return ''
# Skip rendering excluded nodes, but process their children
if hasattr(node, 'excluded_by_parent') and node.excluded_by_parent:
formatted_text = []
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, depth)
if child_text:
formatted_text.append(child_text)
return '\n'.join(formatted_text)
formatted_text = []
depth_str = depth * '\t'
next_depth = depth
if node.original_node.node_type == NodeType.ELEMENT_NODE:
# Skip displaying nodes marked as should_display=False
if not node.should_display:
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, depth)
if child_text:
formatted_text.append(child_text)
return '\n'.join(formatted_text)
# Add element with interactive_index if clickable, scrollable, or iframe
is_any_scrollable = node.original_node.is_actually_scrollable or node.original_node.is_scrollable
should_show_scroll = node.original_node.should_show_scroll_info
if (
node.interactive_index is not None
or is_any_scrollable
or node.original_node.tag_name.upper() == 'IFRAME'
or node.original_node.tag_name.upper() == 'FRAME'
):
next_depth += 1
# Build attributes string with compound component info
text_content = ''
attributes_html_str = DOMTreeSerializer._build_attributes_string(
node.original_node, include_attributes, text_content
)
# Add compound component information to attributes if present
if node.original_node._compound_children:
compound_info = []
for child_info in node.original_node._compound_children:
parts = []
if child_info['name']:
parts.append(f'name={child_info["name"]}')
if child_info['role']:
parts.append(f'role={child_info["role"]}')
if child_info['valuemin'] is not None:
parts.append(f'min={child_info["valuemin"]}')
if child_info['valuemax'] is not None:
parts.append(f'max={child_info["valuemax"]}')
if child_info['valuenow'] is not None:
parts.append(f'current={child_info["valuenow"]}')
# Add select-specific information
if 'options_count' in child_info and child_info['options_count'] is not None:
parts.append(f'count={child_info["options_count"]}')
if 'first_options' in child_info and child_info['first_options']:
options_str = '|'.join(child_info['first_options'][:4]) # Limit to 4 options
parts.append(f'options={options_str}')
if 'format_hint' in child_info and child_info['format_hint']:
parts.append(f'format={child_info["format_hint"]}')
if parts:
compound_info.append(f'({",".join(parts)})')
if compound_info:
compound_attr = f'compound_components={",".join(compound_info)}'
if attributes_html_str:
attributes_html_str += f' {compound_attr}'
else:
attributes_html_str = compound_attr
# Build the line with shadow host indicator
shadow_prefix = ''
if node.is_shadow_host:
# Check if any shadow children are closed
has_closed_shadow = any(
child.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
and child.original_node.shadow_root_type
and child.original_node.shadow_root_type.lower() == 'closed'
for child in node.children
)
shadow_prefix = '|SHADOW(closed)|' if has_closed_shadow else '|SHADOW(open)|'
if should_show_scroll and node.interactive_index is None:
# Scrollable container but not clickable
line = f'{depth_str}{shadow_prefix}|SCROLL|<{node.original_node.tag_name}'
elif node.interactive_index is not None:
# Clickable (and possibly scrollable)
new_prefix = '*' if node.is_new else ''
scroll_prefix = '|SCROLL+' if should_show_scroll else '['
line = f'{depth_str}{shadow_prefix}{new_prefix}{scroll_prefix}{node.interactive_index}]<{node.original_node.tag_name}'
elif node.original_node.tag_name.upper() == 'IFRAME':
# Iframe element (not interactive)
line = f'{depth_str}{shadow_prefix}|IFRAME|<{node.original_node.tag_name}'
elif node.original_node.tag_name.upper() == 'FRAME':
# Frame element (not interactive)
line = f'{depth_str}{shadow_prefix}|FRAME|<{node.original_node.tag_name}'
else:
line = f'{depth_str}{shadow_prefix}<{node.original_node.tag_name}'
if attributes_html_str:
line += f' {attributes_html_str}'
line += ' />'
# Add scroll information only when we should show it
if should_show_scroll:
scroll_info_text = node.original_node.get_scroll_info_text()
if scroll_info_text:
line += f' ({scroll_info_text})'
formatted_text.append(line)
elif node.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
# Shadow DOM representation - show clearly to LLM
if node.original_node.shadow_root_type and node.original_node.shadow_root_type.lower() == 'closed':
formatted_text.append(f'{depth_str}▼ Shadow Content (Closed)')
else:
formatted_text.append(f'{depth_str}▼ Shadow Content (Open)')
next_depth += 1
# Process shadow DOM children
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, next_depth)
if child_text:
formatted_text.append(child_text)
# Close shadow DOM indicator
if node.children: # Only show close if we had content
formatted_text.append(f'{depth_str}▲ Shadow Content End')
elif node.original_node.node_type == NodeType.TEXT_NODE:
# Include visible text
is_visible = node.original_node.snapshot_node and node.original_node.is_visible
if (
is_visible
and node.original_node.node_value
and node.original_node.node_value.strip()
and len(node.original_node.node_value.strip()) > 1
):
clean_text = node.original_node.node_value.strip()
formatted_text.append(f'{depth_str}{clean_text}')
# Process children (for non-shadow elements)
if node.original_node.node_type != NodeType.DOCUMENT_FRAGMENT_NODE:
for child in node.children:
child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, next_depth)
if child_text:
formatted_text.append(child_text)
return '\n'.join(formatted_text)
@staticmethod
def _build_attributes_string(node: EnhancedDOMTreeNode, include_attributes: list[str], text: str) -> str:
"""Build the attributes string for an element."""
attributes_to_include = {}
# Include HTML attributes
if node.attributes:
attributes_to_include.update(
{
key: str(value).strip()
for key, value in node.attributes.items()
if key in include_attributes and str(value).strip() != ''
}
)
# Include accessibility properties
if node.ax_node and node.ax_node.properties:
for prop in node.ax_node.properties:
try:
if prop.name in include_attributes and prop.value is not None:
# Convert boolean to lowercase string, keep others as-is
if isinstance(prop.value, bool):
attributes_to_include[prop.name] = str(prop.value).lower()
else:
prop_value_str = str(prop.value).strip()
if prop_value_str:
attributes_to_include[prop.name] = prop_value_str
except (AttributeError, ValueError):
continue
if not attributes_to_include:
return ''
# Remove duplicate values
ordered_keys = [key for key in include_attributes if key in attributes_to_include]
if len(ordered_keys) > 1:
keys_to_remove = set()
seen_values = {}
for key in ordered_keys:
value = attributes_to_include[key]
if len(value) > 5:
if value in seen_values:
keys_to_remove.add(key)
else:
seen_values[value] = key
for key in keys_to_remove:
del attributes_to_include[key]
# Remove attributes that duplicate accessibility data
role = node.ax_node.role if node.ax_node else None
if role and node.node_name == role:
attributes_to_include.pop('role', None)
attrs_to_remove_if_text_matches = ['aria-label', 'placeholder', 'title']
for attr in attrs_to_remove_if_text_matches:
if attributes_to_include.get(attr) and attributes_to_include.get(attr, '').strip().lower() == text.strip().lower():
del attributes_to_include[attr]
if attributes_to_include:
return ' '.join(f'{key}={cap_text_length(value, 100)}' for key, value in attributes_to_include.items())
return ''
@@ -0,0 +1,741 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
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.serializer import DOMTreeSerializer
from browser_use.dom.views import (
CurrentPageTargets,
DOMRect,
EnhancedAXNode,
EnhancedAXProperty,
EnhancedDOMTreeNode,
NodeType,
SerializedDOMState,
TargetAllTrees,
)
from browser_use.observability import observe_debug
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,
):
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
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
async def _get_targets_for_page(self, target_id: TargetID | None = None) -> CurrentPageTargets:
"""Get the target info for a specific page.
Args:
target_id: The target ID to get info for. If None, uses current_target_id.
"""
targets = await self.browser_session.cdp_client.send.Target.getTargets()
# Use provided target_id or fall back to current_target_id
if target_id is None:
target_id = self.browser_session.current_target_id
if not target_id:
raise ValueError('No current target ID set in browser session')
# Find main page target by ID
main_target = next((t for t in targets['targetInfos'] if t['targetId'] == target_id), None)
if not main_target:
raise ValueError(f'No target found for target ID: {target_id}')
# Get all frames using the new method to find iframe targets for this page
all_frames, _ = await self.browser_session.get_all_frames()
# Find iframe targets that are children of this target
iframe_targets = []
for frame_info in all_frames.values():
# Check if this frame is a cross-origin iframe with its own target
if frame_info.get('isCrossOrigin') and frame_info.get('frameTargetId'):
# Check if this frame belongs to our target
parent_target = frame_info.get('parentTargetId', frame_info.get('frameTargetId'))
if parent_target == target_id:
# Find the target info for this iframe
iframe_target = next(
(t for t in targets['targetInfos'] if t['targetId'] == frame_info['frameTargetId']), None
)
if iframe_target:
iframe_targets.append(iframe_target)
return CurrentPageTargets(
page_session=main_target,
iframe_sessions=iframe_targets,
)
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=True)
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]
) -> bool:
"""Check if the element is visible according to all its parent HTML frames."""
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
# Start with the element's local bounds (in its own frame's coordinate system)
current_bounds = node.snapshot_node.bounds
if not current_bounds:
return False # If there are no bounds, the element is not visible
"""
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):
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 + 1000
and adjusted_y + current_bounds.height > viewport_top - 1000
)
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)
# Wait for all requests to complete
ax_trees = await asyncio.gather(*ax_tree_requests)
# Merge all AX nodes into a single array
merged_nodes: list[AXNode] = []
for ax_tree in ax_trees:
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
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}')
# 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 = time.time()
# Create initial tasks
tasks = {
'snapshot': asyncio.create_task(create_snapshot_request()),
'dom_tree': asyncio.create_task(create_dom_tree_request()),
'ax_tree': asyncio.create_task(self._get_ax_tree_for_all_frames(target_id)),
'device_pixel_ratio': asyncio.create_task(self._get_viewport_ratio(target_id)),
}
# 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: asyncio.create_task(create_snapshot_request()),
tasks['dom_tree']: lambda: asyncio.create_task(create_dom_tree_request()),
tasks['ax_tree']: lambda: asyncio.create_task(self._get_ax_tree_for_all_frames(target_id)),
tasks['device_pixel_ratio']: lambda: asyncio.create_task(self._get_viewport_ratio(target_id)),
}
# 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 = time.time()
cdp_timing = {'cdp_calls_total': end - start}
# 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'
)
return TargetAllTrees(
snapshot=snapshot,
dom_tree=dom_tree,
ax_tree=ax_tree,
device_pixel_ratio=device_pixel_ratio,
cdp_timing=cdp_timing,
)
@observe_debug(ignore_input=True, ignore_output=True, name='get_dom_tree')
async def get_dom_tree(
self,
target_id: TargetID,
initial_html_frames: list[EnhancedDOMTreeNode] | None = None,
initial_total_frame_offset: DOMRect | None = None,
iframe_depth: int = 0,
) -> EnhancedDOMTreeNode:
"""Get the DOM tree for a specific target.
Args:
target_id: Target ID of the page to get the DOM tree for.
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
"""
trees = await self._get_all_trees(target_id)
dom_tree = trees.dom_tree
ax_tree = trees.ax_tree
snapshot = trees.snapshot
device_pixel_ratio = trees.device_pixel_ratio
ax_tree_lookup: dict[int, AXNode] = {
ax_node['backendDOMNodeId']: ax_node for ax_node in ax_tree['nodes'] if 'backendDOMNodeId' in ax_node
}
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
snapshot_lookup = build_snapshot_lookup(snapshot, device_pixel_ratio)
async def _construct_enhanced_node(
node: Node, html_frames: list[EnhancedDOMTreeNode] | None, total_frame_offset: DOMRect | 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
accumulated_iframe_offset: Accumulated coordinate translation from parent iframes (includes scroll corrections)
"""
# 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)
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,
)
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=self.browser_session.agent_focus.session_id if self.browser_session.agent_focus else None,
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,
absolute_position=absolute_position,
element_index=None,
)
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
)
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)
# 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 = []
for child in node['children']:
dom_tree_node.children_nodes.append(
await _construct_enhanced_node(child, updated_html_frames, total_frame_offset)
)
# Set visibility using the collected HTML frames
dom_tree_node.is_visible = self.is_element_visible_according_to_all_parents(dom_tree_node, updated_html_frames)
# 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 (>= 200px 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 200px in both dimensions
if width >= 200 and height >= 200:
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 >= 200px)'
)
else:
self.logger.debug('Skipping cross-origin iframe: no bounds available')
else:
self.logger.debug('Skipping invisible cross-origin iframe')
if should_process_iframe:
# Use get_all_frames to find the iframe's target
frame_id = node.get('frameId', None)
if frame_id:
all_frames, _ = await self.browser_session.get_all_frames()
frame_info = all_frames.get(frame_id)
iframe_document_target = None
if frame_info and frame_info.get('frameTargetId'):
# Get the target info for this iframe
targets = await self.browser_session.cdp_client.send.Target.getTargets()
iframe_document_target = next(
(t for t in targets['targetInfos'] if t['targetId'] == frame_info['frameTargetId']), None
)
else:
iframe_document_target = None
# 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}'
)
content_document = await self.get_dom_tree(
target_id=iframe_document_target.get('targetId'),
# TODO: experiment with this values -> not sure whether the whole cross origin iframe should be ALWAYS included as soon as some part of it is visible or not.
# Current config: if the cross origin iframe is AT ALL visible, then just include everything inside of it!
# initial_html_frames=updated_html_frames,
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
return dom_tree_node
enhanced_dom_tree_node = await _construct_enhanced_node(dom_tree['root'], initial_html_frames, initial_total_frame_offset)
return enhanced_dom_tree_node
@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)
"""
# Use current target (None means use current)
assert self.browser_session.current_target_id is not None
enhanced_dom_tree = await self.get_dom_tree(target_id=self.browser_session.current_target_id)
start = time.time()
serialized_dom_state, serializer_timing = DOMTreeSerializer(
enhanced_dom_tree, previous_cached_state, paint_order_filtering=self.paint_order_filtering
).serialize_accessible_elements()
end = time.time()
serialize_total_timing = {'serialize_dom_tree_total': end - start}
# Combine all timing info
all_timing = {**serializer_timing, **serialize_total_timing}
return serialized_dom_state, enhanced_dom_tree, all_timing
@@ -0,0 +1,38 @@
"""
Test suite locking out TypeError in _build_dom_tree
when layout dictionary contains None values for array properties.
"""
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
def test_layout_index_map_handles_null_layout_arrays():
"""
Ensure layout array checks tolerate None for bounds, styles, paintOrders, etc.
"""
layout = {
'bounds': None,
'styles': None,
'paintOrders': None,
'clientRects': None,
'scrollRects': None,
'stackingContexts': None
}
layout_idx = 0
bounds_len = len(layout.get('bounds') or [])
styles_len = len(layout.get('styles') or [])
paint_len = len(layout.get('paintOrders') or [])
client_len = len(layout.get('clientRects') or [])
scroll_len = len(layout.get('scrollRects') or [])
stacking_len = len(layout.get('stackingContexts') or [])
assert bounds_len == 0
assert styles_len == 0
assert paint_len == 0
assert client_len == 0
assert scroll_len == 0
assert stacking_len == 0
@@ -0,0 +1,5 @@
def cap_text_length(text: str, max_length: int) -> str:
"""Cap text length for display."""
if len(text) <= max_length:
return text
return text[:max_length] + '...'
@@ -0,0 +1,873 @@
import hashlib
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any
from cdp_use.cdp.accessibility.commands import GetFullAXTreeReturns
from cdp_use.cdp.accessibility.types import AXPropertyName
from cdp_use.cdp.dom.commands import GetDocumentReturns
from cdp_use.cdp.dom.types import ShadowRootType
from cdp_use.cdp.domsnapshot.commands import CaptureSnapshotReturns
from cdp_use.cdp.target.types import SessionID, TargetID, TargetInfo
from uuid_extensions import uuid7str
from browser_use.dom.utils import cap_text_length
from browser_use.observability import observe_debug
# Serializer types
DEFAULT_INCLUDE_ATTRIBUTES = [
'title',
'type',
'checked',
# 'class',
'id',
'name',
'role',
'value',
'placeholder',
'data-date-format',
'alt',
'aria-label',
'aria-expanded',
'data-state',
'aria-checked',
# ARIA value attributes for datetime/range inputs
'aria-valuemin',
'aria-valuemax',
'aria-valuenow',
'aria-placeholder',
# Validation attributes - help agents avoid brute force attempts
'pattern',
'min',
'max',
'minlength',
'maxlength',
'step',
# Webkit shadow DOM identifiers
'pseudo',
# Accessibility properties from ax_node (ordered by importance for automation)
'checked',
'selected',
'expanded',
'pressed',
'disabled',
'invalid', # Current validation state from AX node
'valuemin', # Min value from AX node (for datetime/range)
'valuemax', # Max value from AX node (for datetime/range)
'valuenow',
'keyshortcuts',
'haspopup',
'multiselectable',
# Less commonly needed (uncomment if required):
# 'readonly',
'required',
'valuetext',
'level',
'busy',
'live',
# Accessibility name (contains text content for StaticText elements)
'ax_name',
]
STATIC_ATTRIBUTES = {
'class',
'id',
'name',
'type',
'placeholder',
'aria-label',
'title',
# 'aria-expanded',
'role',
'data-testid',
'data-test',
'data-cy',
'data-selenium',
'for',
'required',
'disabled',
'readonly',
'checked',
'selected',
'multiple',
'href',
'target',
'rel',
'aria-describedby',
'aria-labelledby',
'aria-controls',
'aria-owns',
'aria-live',
'aria-atomic',
'aria-busy',
'aria-disabled',
'aria-hidden',
'aria-pressed',
'aria-checked',
'aria-selected',
'tabindex',
'alt',
'src',
'lang',
'itemscope',
'itemtype',
'itemprop',
# Webkit shadow DOM attributes
'pseudo',
'aria-valuemin',
'aria-valuemax',
'aria-valuenow',
'aria-placeholder',
}
@dataclass
class CurrentPageTargets:
page_session: TargetInfo
iframe_sessions: list[TargetInfo]
"""
Iframe sessions are ALL the iframes sessions of all the pages (not just the current page)
"""
@dataclass
class TargetAllTrees:
snapshot: CaptureSnapshotReturns
dom_tree: GetDocumentReturns
ax_tree: GetFullAXTreeReturns
device_pixel_ratio: float
cdp_timing: dict[str, float]
@dataclass(slots=True)
class PropagatingBounds:
"""Track bounds that propagate from parent elements to filter children."""
tag: str # The tag that started propagation ('a' or 'button')
bounds: 'DOMRect' # The bounding box
node_id: int # Node ID for debugging
depth: int # How deep in tree this started (for debugging)
@dataclass(slots=True)
class SimplifiedNode:
"""Simplified tree node for optimization."""
original_node: 'EnhancedDOMTreeNode'
children: list['SimplifiedNode']
should_display: bool = True
interactive_index: int | None = None
is_new: bool = False
ignored_by_paint_order: bool = False # More info in dom/serializer/paint_order.py
excluded_by_parent: bool = False # New field for bbox filtering
is_shadow_host: bool = False # New field for shadow DOM hosts
is_compound_component: bool = False # True for virtual components of compound controls
def _clean_original_node_json(self, node_json: dict) -> dict:
"""Recursively remove children_nodes and shadow_roots from original_node JSON."""
# Remove the fields we don't want in SimplifiedNode serialization
if 'children_nodes' in node_json:
del node_json['children_nodes']
if 'shadow_roots' in node_json:
del node_json['shadow_roots']
# Clean nested content_document if it exists
if node_json.get('content_document'):
node_json['content_document'] = self._clean_original_node_json(node_json['content_document'])
return node_json
def __json__(self) -> dict:
original_node_json = self.original_node.__json__()
# Remove children_nodes and shadow_roots to avoid duplication with SimplifiedNode.children
cleaned_original_node_json = self._clean_original_node_json(original_node_json)
return {
'should_display': self.should_display,
'interactive_index': self.interactive_index,
'ignored_by_paint_order': self.ignored_by_paint_order,
'excluded_by_parent': self.excluded_by_parent,
'original_node': cleaned_original_node_json,
'children': [c.__json__() for c in self.children],
}
class NodeType(int, Enum):
"""DOM node types based on the DOM specification."""
ELEMENT_NODE = 1
ATTRIBUTE_NODE = 2
TEXT_NODE = 3
CDATA_SECTION_NODE = 4
ENTITY_REFERENCE_NODE = 5
ENTITY_NODE = 6
PROCESSING_INSTRUCTION_NODE = 7
COMMENT_NODE = 8
DOCUMENT_NODE = 9
DOCUMENT_TYPE_NODE = 10
DOCUMENT_FRAGMENT_NODE = 11
NOTATION_NODE = 12
@dataclass(slots=True)
class DOMRect:
x: float
y: float
width: float
height: float
def to_dict(self) -> dict[str, Any]:
return {
'x': self.x,
'y': self.y,
'width': self.width,
'height': self.height,
}
def __json__(self) -> dict:
return self.to_dict()
@dataclass(slots=True)
class EnhancedAXProperty:
"""we don't need `sources` and `related_nodes` for now (not sure how to use them)
TODO: there is probably some way to determine whether it has a value or related nodes or not, but for now it's kinda fine idk
"""
name: AXPropertyName
value: str | bool | None
# related_nodes: list[EnhancedAXRelatedNode] | None
@dataclass(slots=True)
class EnhancedAXNode:
ax_node_id: str
"""Not to be confused the DOM node_id. Only useful for AX node tree"""
ignored: bool
# we don't need ignored_reasons as we anyway ignore the node otherwise
role: str | None
name: str | None
description: str | None
properties: list[EnhancedAXProperty] | None
child_ids: list[str] | None
@dataclass(slots=True)
class EnhancedSnapshotNode:
"""Snapshot data extracted from DOMSnapshot for enhanced functionality."""
is_clickable: bool | None
cursor_style: str | None
bounds: DOMRect | None
"""
Document coordinates (origin = top-left of the page, ignores current scroll).
Equivalent JS API: layoutNode.boundingBox in the older API.
Typical use: Quick hit-test that doesn't care about scroll position.
"""
clientRects: DOMRect | None
"""
Viewport coordinates (origin = top-left of the visible scrollport).
Equivalent JS API: element.getClientRects() / getBoundingClientRect().
Typical use: Pixel-perfect hit-testing on screen, taking current scroll into account.
"""
scrollRects: DOMRect | None
"""
Scrollable area of the element.
"""
computed_styles: dict[str, str] | None
"""Computed styles from the layout tree"""
paint_order: int | None
"""Paint order from the layout tree"""
stacking_contexts: int | None
"""Stacking contexts from the layout tree"""
# @dataclass(slots=True)
# class SuperSelector:
# node_id: int
# backend_node_id: int
# frame_id: str | None
# target_id: TargetID
# node_type: NodeType
# node_name: str
# # is_visible: bool | None
# # is_scrollable: bool | None
# element_index: int | None
@dataclass(slots=True)
class EnhancedDOMTreeNode:
"""
Enhanced DOM tree node that contains information from AX, DOM, and Snapshot trees. It's mostly based on the types on DOM node type with enhanced data from AX and Snapshot trees.
@dev when serializing check if the value is a valid value first!
Learn more about the fields:
- (DOM node) https://chromedevtools.github.io/devtools-protocol/tot/DOM/#type-BackendNode
- (AX node) https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/#type-AXNode
- (Snapshot node) https://chromedevtools.github.io/devtools-protocol/tot/DOMSnapshot/#type-DOMNode
"""
# region - DOM Node data
node_id: int
backend_node_id: int
node_type: NodeType
"""Node types, defined in `NodeType` enum."""
node_name: str
"""Only applicable for `NodeType.ELEMENT_NODE`"""
node_value: str
"""this is where the value from `NodeType.TEXT_NODE` is stored usually"""
attributes: dict[str, str]
"""slightly changed from the original attributes to be more readable"""
is_scrollable: bool | None
"""
Whether the node is scrollable.
"""
is_visible: bool | None
"""
Whether the node is visible according to the upper most frame node.
"""
absolute_position: DOMRect | None
"""
Absolute position of the node in the document according to the top-left of the page.
"""
# frames
target_id: TargetID
frame_id: str | None
session_id: SessionID | None
content_document: 'EnhancedDOMTreeNode | None'
"""
Content document is the document inside a new iframe.
"""
# Shadow DOM
shadow_root_type: ShadowRootType | None
shadow_roots: list['EnhancedDOMTreeNode'] | None
"""
Shadow roots are the shadow DOMs of the element.
"""
# Navigation
parent_node: 'EnhancedDOMTreeNode | None'
children_nodes: list['EnhancedDOMTreeNode'] | None
# endregion - DOM Node data
# region - AX Node data
ax_node: EnhancedAXNode | None
# endregion - AX Node data
# region - Snapshot Node data
snapshot_node: EnhancedSnapshotNode | None
# endregion - Snapshot Node data
# Interactive element index
element_index: int | None = None
# Compound control child components information
_compound_children: list[dict[str, Any]] = field(default_factory=list)
uuid: str = field(default_factory=uuid7str)
@property
def parent(self) -> 'EnhancedDOMTreeNode | None':
return self.parent_node
@property
def children(self) -> list['EnhancedDOMTreeNode']:
return self.children_nodes or []
@property
def children_and_shadow_roots(self) -> list['EnhancedDOMTreeNode']:
"""
Returns all children nodes, including shadow roots
"""
children = self.children_nodes or []
if self.shadow_roots:
children.extend(self.shadow_roots)
return children
@property
def tag_name(self) -> str:
return self.node_name.lower()
@property
def xpath(self) -> str:
"""Generate XPath for this DOM node, stopping at shadow boundaries or iframes."""
segments = []
current_element = self
while current_element and (
current_element.node_type == NodeType.ELEMENT_NODE or current_element.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
):
# just pass through shadow roots
if current_element.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
current_element = current_element.parent_node
continue
# stop ONLY if we hit iframe
if current_element.parent_node and current_element.parent_node.node_name.lower() == 'iframe':
break
position = self._get_element_position(current_element)
tag_name = current_element.node_name.lower()
xpath_index = f'[{position}]' if position > 0 else ''
segments.insert(0, f'{tag_name}{xpath_index}')
current_element = current_element.parent_node
return '/'.join(segments)
def _get_element_position(self, element: 'EnhancedDOMTreeNode') -> int:
"""Get the position of an element among its siblings with the same tag name.
Returns 0 if it's the only element of its type, otherwise returns 1-based index."""
if not element.parent_node or not element.parent_node.children_nodes:
return 0
same_tag_siblings = [
child
for child in element.parent_node.children_nodes
if child.node_type == NodeType.ELEMENT_NODE and child.node_name.lower() == element.node_name.lower()
]
if len(same_tag_siblings) <= 1:
return 0 # No index needed if it's the only one
try:
# XPath is 1-indexed
position = same_tag_siblings.index(element) + 1
return position
except ValueError:
return 0
def __json__(self) -> dict:
"""Serializes the node and its descendants to a dictionary, omitting parent references."""
return {
'node_id': self.node_id,
'backend_node_id': self.backend_node_id,
'node_type': self.node_type.name,
'node_name': self.node_name,
'node_value': self.node_value,
'is_visible': self.is_visible,
'attributes': self.attributes,
'is_scrollable': self.is_scrollable,
'session_id': self.session_id,
'target_id': self.target_id,
'frame_id': self.frame_id,
'content_document': self.content_document.__json__() if self.content_document else None,
'shadow_root_type': self.shadow_root_type,
'ax_node': asdict(self.ax_node) if self.ax_node else None,
'snapshot_node': asdict(self.snapshot_node) if self.snapshot_node else None,
# these two in the end, so it's easier to read json
'shadow_roots': [r.__json__() for r in self.shadow_roots] if self.shadow_roots else [],
'children_nodes': [c.__json__() for c in self.children_nodes] if self.children_nodes else [],
}
def get_all_children_text(self, max_depth: int = -1) -> str:
text_parts = []
def collect_text(node: EnhancedDOMTreeNode, current_depth: int) -> None:
if max_depth != -1 and current_depth > max_depth:
return
# Skip this branch if we hit a highlighted element (except for the current node)
# TODO: think whether if makese sense to add text until the next clickable element or everything from children
# if node.node_type == NodeType.ELEMENT_NODE
# if isinstance(node, DOMElementNode) and node != self and node.highlight_index is not None:
# return
if node.node_type == NodeType.TEXT_NODE:
text_parts.append(node.node_value)
elif node.node_type == NodeType.ELEMENT_NODE:
for child in node.children:
collect_text(child, current_depth + 1)
collect_text(self, 0)
return '\n'.join(text_parts).strip()
def __repr__(self) -> str:
"""
@DEV ! don't display this to the LLM, it's SUPER long
"""
attributes = ', '.join([f'{k}={v}' for k, v in self.attributes.items()])
is_scrollable = getattr(self, 'is_scrollable', False)
num_children = len(self.children_nodes or [])
return (
f'<{self.tag_name} {attributes} is_scrollable={is_scrollable} '
f'num_children={num_children} >{self.node_value}</{self.tag_name}>'
)
def llm_representation(self, max_text_length: int = 100) -> str:
"""
Token friendly representation of the node, used in the LLM
"""
return f'<{self.tag_name}>{cap_text_length(self.get_all_children_text(), max_text_length) or ""}'
def get_meaningful_text_for_llm(self) -> str:
"""
Get the meaningful text content that the LLM actually sees for this element.
This matches exactly what goes into the DOMTreeSerializer output.
"""
meaningful_text = ''
if hasattr(self, 'attributes') and self.attributes:
# Priority order: value, aria-label, title, placeholder, alt, text content
for attr in ['value', 'aria-label', 'title', 'placeholder', 'alt']:
if attr in self.attributes and self.attributes[attr]:
meaningful_text = self.attributes[attr]
break
# Fallback to text content if no meaningful attributes
if not meaningful_text:
meaningful_text = self.get_all_children_text()
return meaningful_text.strip()
@property
def is_actually_scrollable(self) -> bool:
"""
Enhanced scroll detection that combines CDP detection with CSS analysis.
This detects scrollable elements that Chrome's CDP might miss, which is common
in iframes and dynamically sized containers.
"""
# First check if CDP already detected it as scrollable
if self.is_scrollable:
return True
# Enhanced detection for elements CDP missed
if not self.snapshot_node:
return False
# Check scroll vs client rects - this is the most reliable indicator
scroll_rects = self.snapshot_node.scrollRects
client_rects = self.snapshot_node.clientRects
if scroll_rects and client_rects:
# Content is larger than visible area = scrollable
has_vertical_scroll = scroll_rects.height > client_rects.height + 1 # +1 for rounding
has_horizontal_scroll = scroll_rects.width > client_rects.width + 1
if has_vertical_scroll or has_horizontal_scroll:
# Also check CSS to make sure scrolling is allowed
if self.snapshot_node.computed_styles:
styles = self.snapshot_node.computed_styles
overflow = styles.get('overflow', 'visible').lower()
overflow_x = styles.get('overflow-x', overflow).lower()
overflow_y = styles.get('overflow-y', overflow).lower()
# Only allow scrolling if overflow is explicitly set to auto, scroll, or overlay
# Do NOT consider 'visible' overflow as scrollable - this was causing the issue
allows_scroll = (
overflow in ['auto', 'scroll', 'overlay']
or overflow_x in ['auto', 'scroll', 'overlay']
or overflow_y in ['auto', 'scroll', 'overlay']
)
return allows_scroll
else:
# No CSS info, but content overflows - be more conservative
# Only consider it scrollable if it's a common scrollable container element
scrollable_tags = {'div', 'main', 'section', 'article', 'aside', 'body', 'html'}
return self.tag_name.lower() in scrollable_tags
return False
@property
def should_show_scroll_info(self) -> bool:
"""
Simple check: show scroll info only if this element is scrollable
and doesn't have a scrollable parent (to avoid nested scroll spam).
Special case for iframes: Always show scroll info since Chrome might not
always detect iframe scrollability correctly (scrollHeight: 0 issue).
"""
# Special case: Always show scroll info for iframe elements
# Even if not detected as scrollable, they might have scrollable content
if self.tag_name.lower() == 'iframe':
return True
# Must be scrollable first for non-iframe elements
if not (self.is_scrollable or self.is_actually_scrollable):
return False
# Always show for iframe content documents (body/html)
if self.tag_name.lower() in {'body', 'html'}:
return True
# Don't show if parent is already scrollable (avoid nested spam)
if self.parent_node and (self.parent_node.is_scrollable or self.parent_node.is_actually_scrollable):
return False
return True
def _find_html_in_content_document(self) -> 'EnhancedDOMTreeNode | None':
"""Find HTML element in iframe content document."""
if not self.content_document:
return None
# Check if content document itself is HTML
if self.content_document.tag_name.lower() == 'html':
return self.content_document
# Look through children for HTML element
if self.content_document.children_nodes:
for child in self.content_document.children_nodes:
if child.tag_name.lower() == 'html':
return child
return None
@property
def scroll_info(self) -> dict[str, Any] | None:
"""Calculate scroll information for this element if it's scrollable."""
if not self.is_actually_scrollable or not self.snapshot_node:
return None
# Get scroll and client rects from snapshot data
scroll_rects = self.snapshot_node.scrollRects
client_rects = self.snapshot_node.clientRects
bounds = self.snapshot_node.bounds
if not scroll_rects or not client_rects:
return None
# Calculate scroll position and percentages
scroll_top = scroll_rects.y
scroll_left = scroll_rects.x
# Total scrollable height and width
scrollable_height = scroll_rects.height
scrollable_width = scroll_rects.width
# Visible (client) dimensions
visible_height = client_rects.height
visible_width = client_rects.width
# Calculate how much content is above/below/left/right of current view
content_above = max(0, scroll_top)
content_below = max(0, scrollable_height - visible_height - scroll_top)
content_left = max(0, scroll_left)
content_right = max(0, scrollable_width - visible_width - scroll_left)
# Calculate scroll percentages
vertical_scroll_percentage = 0
horizontal_scroll_percentage = 0
if scrollable_height > visible_height:
max_scroll_top = scrollable_height - visible_height
vertical_scroll_percentage = (scroll_top / max_scroll_top) * 100 if max_scroll_top > 0 else 0
if scrollable_width > visible_width:
max_scroll_left = scrollable_width - visible_width
horizontal_scroll_percentage = (scroll_left / max_scroll_left) * 100 if max_scroll_left > 0 else 0
# Calculate pages equivalent (using visible height as page unit)
pages_above = content_above / visible_height if visible_height > 0 else 0
pages_below = content_below / visible_height if visible_height > 0 else 0
total_pages = scrollable_height / visible_height if visible_height > 0 else 1
return {
'scroll_top': scroll_top,
'scroll_left': scroll_left,
'scrollable_height': scrollable_height,
'scrollable_width': scrollable_width,
'visible_height': visible_height,
'visible_width': visible_width,
'content_above': content_above,
'content_below': content_below,
'content_left': content_left,
'content_right': content_right,
'vertical_scroll_percentage': round(vertical_scroll_percentage, 1),
'horizontal_scroll_percentage': round(horizontal_scroll_percentage, 1),
'pages_above': round(pages_above, 1),
'pages_below': round(pages_below, 1),
'total_pages': round(total_pages, 1),
'can_scroll_up': content_above > 0,
'can_scroll_down': content_below > 0,
'can_scroll_left': content_left > 0,
'can_scroll_right': content_right > 0,
}
def get_scroll_info_text(self) -> str:
"""Get human-readable scroll information text for this element."""
# Special case for iframes: check content document for scroll info
if self.tag_name.lower() == 'iframe':
# Try to get scroll info from the HTML document inside the iframe
if self.content_document:
# Look for HTML element in content document
html_element = self._find_html_in_content_document()
if html_element and html_element.scroll_info:
info = html_element.scroll_info
# Provide minimal but useful scroll info
pages_below = info.get('pages_below', 0)
pages_above = info.get('pages_above', 0)
v_pct = int(info.get('vertical_scroll_percentage', 0))
if pages_below > 0 or pages_above > 0:
return f'scroll: {pages_above:.1f}{pages_below:.1f}{v_pct}%'
return 'scroll'
scroll_info = self.scroll_info
if not scroll_info:
return ''
parts = []
# Vertical scroll info (concise format)
if scroll_info['scrollable_height'] > scroll_info['visible_height']:
parts.append(f'{scroll_info["pages_above"]:.1f} pages above, {scroll_info["pages_below"]:.1f} pages below')
# Horizontal scroll info (concise format)
if scroll_info['scrollable_width'] > scroll_info['visible_width']:
parts.append(f'horizontal {scroll_info["horizontal_scroll_percentage"]:.0f}%')
return ' '.join(parts)
@property
def element_hash(self) -> int:
return hash(self)
def __str__(self) -> str:
return f'[<{self.tag_name}>#{self.frame_id[-4:] if self.frame_id else "?"}:{self.element_index}]'
def __hash__(self) -> int:
"""
Hash the element based on its parent branch path and attributes.
TODO: migrate this to use only backendNodeId + current SessionId
"""
# Get parent branch path
parent_branch_path = self._get_parent_branch_path()
parent_branch_path_string = '/'.join(parent_branch_path)
attributes_string = ''.join(
f'{k}={v}' for k, v in sorted((k, v) for k, v in self.attributes.items() if k in STATIC_ATTRIBUTES)
)
# Combine both for final hash
combined_string = f'{parent_branch_path_string}|{attributes_string}'
element_hash = hashlib.sha256(combined_string.encode()).hexdigest()
# Convert to int for __hash__ return type - use first 16 chars and convert from hex to int
return int(element_hash[:16], 16)
def parent_branch_hash(self) -> int:
"""
Hash the element based on its parent branch path and attributes.
"""
parent_branch_path = self._get_parent_branch_path()
parent_branch_path_string = '/'.join(parent_branch_path)
element_hash = hashlib.sha256(parent_branch_path_string.encode()).hexdigest()
return int(element_hash[:16], 16)
def _get_parent_branch_path(self) -> list[str]:
"""Get the parent branch path as a list of tag names from root to current element."""
parents: list['EnhancedDOMTreeNode'] = []
current_element: 'EnhancedDOMTreeNode | None' = self
while current_element is not None:
if current_element.node_type == NodeType.ELEMENT_NODE:
parents.append(current_element)
current_element = current_element.parent_node
parents.reverse()
return [parent.tag_name for parent in parents]
DOMSelectorMap = dict[int, EnhancedDOMTreeNode]
@dataclass
class SerializedDOMState:
_root: SimplifiedNode | None
"""Not meant to be used directly, use `llm_representation` instead"""
selector_map: DOMSelectorMap
@observe_debug(ignore_input=True, ignore_output=True, name='llm_representation')
def llm_representation(
self,
include_attributes: list[str] | None = None,
) -> str:
"""Kinda ugly, but leaving this as an internal method because include_attributes are a parameter on the agent, so we need to leave it as a 2 step process"""
from browser_use.dom.serializer.serializer import DOMTreeSerializer
if not self._root:
return 'Empty DOM tree (you might have to wait for the page to load)'
include_attributes = include_attributes or DEFAULT_INCLUDE_ATTRIBUTES
return DOMTreeSerializer.serialize_tree(self._root, include_attributes)
@dataclass
class DOMInteractedElement:
"""
DOMInteractedElement is a class that represents a DOM element that has been interacted with.
It is used to store the DOM element that has been interacted with and to store the DOM element that has been interacted with.
TODO: this is a bit of a hack, we should probably have a better way to do this
"""
node_id: int
backend_node_id: int
frame_id: str | None
node_type: NodeType
node_value: str
node_name: str
attributes: dict[str, str] | None
bounds: DOMRect | None
x_path: str
element_hash: int
def to_dict(self) -> dict[str, Any]:
return {
'node_id': self.node_id,
'backend_node_id': self.backend_node_id,
'frame_id': self.frame_id,
'node_type': self.node_type.value,
'node_value': self.node_value,
'node_name': self.node_name,
'attributes': self.attributes,
'x_path': self.x_path,
'element_hash': self.element_hash,
'bounds': self.bounds.to_dict() if self.bounds else None,
}
@classmethod
def load_from_enhanced_dom_tree(cls, enhanced_dom_tree: EnhancedDOMTreeNode) -> 'DOMInteractedElement':
return cls(
node_id=enhanced_dom_tree.node_id,
backend_node_id=enhanced_dom_tree.backend_node_id,
frame_id=enhanced_dom_tree.frame_id,
node_type=enhanced_dom_tree.node_type,
node_value=enhanced_dom_tree.node_value,
node_name=enhanced_dom_tree.node_name,
attributes=enhanced_dom_tree.attributes,
bounds=enhanced_dom_tree.snapshot_node.bounds if enhanced_dom_tree.snapshot_node else None,
x_path=enhanced_dom_tree.xpath,
element_hash=hash(enhanced_dom_tree),
)