ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -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())
|
||||
+199
@@ -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 left‑over pieces are new, non‑overlapping 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
|
||||
+38
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user