Leon4gr45 commited on
Commit
cd3dbfb
·
verified ·
1 Parent(s): 1706357

Delete browser_use/browser_use/dom/serializer/serializer.py with huggingface_hub

Browse files
browser_use/browser_use/dom/serializer/serializer.py DELETED
@@ -1,1188 +0,0 @@
1
- # @file purpose: Serializes enhanced DOM trees to string format for LLM consumption
2
-
3
- from typing import Any
4
-
5
- from browser_use.dom.serializer.clickable_elements import ClickableElementDetector
6
- from browser_use.dom.serializer.paint_order import PaintOrderRemover
7
- from browser_use.dom.utils import cap_text_length
8
- from browser_use.dom.views import (
9
- DOMRect,
10
- DOMSelectorMap,
11
- EnhancedDOMTreeNode,
12
- NodeType,
13
- PropagatingBounds,
14
- SerializedDOMState,
15
- SimplifiedNode,
16
- )
17
-
18
- DISABLED_ELEMENTS = {'style', 'script', 'head', 'meta', 'link', 'title'}
19
-
20
- # SVG child elements to skip (decorative only, no interaction value)
21
- SVG_ELEMENTS = {
22
- 'path',
23
- 'rect',
24
- 'g',
25
- 'circle',
26
- 'ellipse',
27
- 'line',
28
- 'polyline',
29
- 'polygon',
30
- 'use',
31
- 'defs',
32
- 'clipPath',
33
- 'mask',
34
- 'pattern',
35
- 'image',
36
- 'text',
37
- 'tspan',
38
- }
39
-
40
-
41
- class DOMTreeSerializer:
42
- """Serializes enhanced DOM trees to string format."""
43
-
44
- # Configuration - elements that propagate bounds to their children
45
- PROPAGATING_ELEMENTS = [
46
- {'tag': 'a', 'role': None}, # Any <a> tag
47
- {'tag': 'button', 'role': None}, # Any <button> tag
48
- {'tag': 'div', 'role': 'button'}, # <div role="button">
49
- {'tag': 'div', 'role': 'combobox'}, # <div role="combobox"> - dropdowns/selects
50
- {'tag': 'span', 'role': 'button'}, # <span role="button">
51
- {'tag': 'span', 'role': 'combobox'}, # <span role="combobox">
52
- {'tag': 'input', 'role': 'combobox'}, # <input role="combobox"> - autocomplete inputs
53
- {'tag': 'input', 'role': 'combobox'}, # <input type="text"> - text inputs with suggestions
54
- # {'tag': 'div', 'role': 'link'}, # <div role="link">
55
- # {'tag': 'span', 'role': 'link'}, # <span role="link">
56
- ]
57
- DEFAULT_CONTAINMENT_THRESHOLD = 0.99 # 99% containment by default
58
-
59
- def __init__(
60
- self,
61
- root_node: EnhancedDOMTreeNode,
62
- previous_cached_state: SerializedDOMState | None = None,
63
- enable_bbox_filtering: bool = True,
64
- containment_threshold: float | None = None,
65
- paint_order_filtering: bool = True,
66
- session_id: str | None = None,
67
- ):
68
- self.root_node = root_node
69
- self._interactive_counter = 1
70
- self._selector_map: DOMSelectorMap = {}
71
- self._previous_cached_selector_map = previous_cached_state.selector_map if previous_cached_state else None
72
- # Add timing tracking
73
- self.timing_info: dict[str, float] = {}
74
- # Cache for clickable element detection to avoid redundant calls
75
- self._clickable_cache: dict[int, bool] = {}
76
- # Bounding box filtering configuration
77
- self.enable_bbox_filtering = enable_bbox_filtering
78
- self.containment_threshold = containment_threshold or self.DEFAULT_CONTAINMENT_THRESHOLD
79
- # Paint order filtering configuration
80
- self.paint_order_filtering = paint_order_filtering
81
- # Session ID for session-specific exclude attribute
82
- self.session_id = session_id
83
-
84
- def _safe_parse_number(self, value_str: str, default: float) -> float:
85
- """Parse string to float, handling negatives and decimals."""
86
- try:
87
- return float(value_str)
88
- except (ValueError, TypeError):
89
- return default
90
-
91
- def _safe_parse_optional_number(self, value_str: str | None) -> float | None:
92
- """Parse string to float, returning None for invalid values."""
93
- if not value_str:
94
- return None
95
- try:
96
- return float(value_str)
97
- except (ValueError, TypeError):
98
- return None
99
-
100
- def serialize_accessible_elements(self) -> tuple[SerializedDOMState, dict[str, float]]:
101
- import time
102
-
103
- start_total = time.time()
104
-
105
- # Reset state
106
- self._interactive_counter = 1
107
- self._selector_map = {}
108
- self._semantic_groups = []
109
- self._clickable_cache = {} # Clear cache for new serialization
110
-
111
- # Step 1: Create simplified tree (includes clickable element detection)
112
- start_step1 = time.time()
113
- simplified_tree = self._create_simplified_tree(self.root_node)
114
- end_step1 = time.time()
115
- self.timing_info['create_simplified_tree'] = end_step1 - start_step1
116
-
117
- # Step 2: Remove elements based on paint order
118
- start_step3 = time.time()
119
- if self.paint_order_filtering and simplified_tree:
120
- PaintOrderRemover(simplified_tree).calculate_paint_order()
121
- end_step3 = time.time()
122
- self.timing_info['calculate_paint_order'] = end_step3 - start_step3
123
-
124
- # Step 3: Optimize tree (remove unnecessary parents)
125
- start_step2 = time.time()
126
- optimized_tree = self._optimize_tree(simplified_tree)
127
- end_step2 = time.time()
128
- self.timing_info['optimize_tree'] = end_step2 - start_step2
129
-
130
- # Step 3: Apply bounding box filtering (NEW)
131
- if self.enable_bbox_filtering and optimized_tree:
132
- start_step3 = time.time()
133
- filtered_tree = self._apply_bounding_box_filtering(optimized_tree)
134
- end_step3 = time.time()
135
- self.timing_info['bbox_filtering'] = end_step3 - start_step3
136
- else:
137
- filtered_tree = optimized_tree
138
-
139
- # Step 4: Assign interactive indices to clickable elements
140
- start_step4 = time.time()
141
- self._assign_interactive_indices_and_mark_new_nodes(filtered_tree)
142
- end_step4 = time.time()
143
- self.timing_info['assign_interactive_indices'] = end_step4 - start_step4
144
-
145
- end_total = time.time()
146
- self.timing_info['serialize_accessible_elements_total'] = end_total - start_total
147
-
148
- return SerializedDOMState(_root=filtered_tree, selector_map=self._selector_map), self.timing_info
149
-
150
- def _add_compound_components(self, simplified: SimplifiedNode, node: EnhancedDOMTreeNode) -> None:
151
- """Enhance compound controls with information from their child components."""
152
- # Only process elements that might have compound components
153
- if node.tag_name not in ['input', 'select', 'details', 'audio', 'video']:
154
- return
155
-
156
- # For input elements, check for compound input types
157
- if node.tag_name == 'input':
158
- if not node.attributes or node.attributes.get('type') not in [
159
- 'date',
160
- 'time',
161
- 'datetime-local',
162
- 'month',
163
- 'week',
164
- 'range',
165
- 'number',
166
- 'color',
167
- 'file',
168
- ]:
169
- return
170
- # For other elements, check if they have AX child indicators
171
- elif not node.ax_node or not node.ax_node.child_ids:
172
- return
173
-
174
- # Add compound component information based on element type
175
- element_type = node.tag_name
176
- input_type = node.attributes.get('type', '') if node.attributes else ''
177
-
178
- if element_type == 'input':
179
- # NOTE: For date/time inputs, we DON'T add compound components because:
180
- # 1. They confuse the model (seeing "Day, Month, Year" suggests DD.MM.YYYY format)
181
- # 2. HTML5 date/time inputs ALWAYS require ISO format (YYYY-MM-DD, HH:MM, etc.)
182
- # 3. The placeholder attribute clearly shows the required format
183
- # 4. These inputs use direct value assignment, not sequential typing
184
- if input_type in ['date', 'time', 'datetime-local', 'month', 'week']:
185
- # Skip compound components for date/time inputs - format is shown in placeholder
186
- pass
187
- elif input_type == 'range':
188
- # Range slider with value indicator
189
- min_val = node.attributes.get('min', '0') if node.attributes else '0'
190
- max_val = node.attributes.get('max', '100') if node.attributes else '100'
191
-
192
- node._compound_children.append(
193
- {
194
- 'role': 'slider',
195
- 'name': 'Value',
196
- 'valuemin': self._safe_parse_number(min_val, 0.0),
197
- 'valuemax': self._safe_parse_number(max_val, 100.0),
198
- 'valuenow': None,
199
- }
200
- )
201
- simplified.is_compound_component = True
202
- elif input_type == 'number':
203
- # Number input with increment/decrement buttons
204
- min_val = node.attributes.get('min') if node.attributes else None
205
- max_val = node.attributes.get('max') if node.attributes else None
206
-
207
- node._compound_children.extend(
208
- [
209
- {'role': 'button', 'name': 'Increment', 'valuemin': None, 'valuemax': None, 'valuenow': None},
210
- {'role': 'button', 'name': 'Decrement', 'valuemin': None, 'valuemax': None, 'valuenow': None},
211
- {
212
- 'role': 'textbox',
213
- 'name': 'Value',
214
- 'valuemin': self._safe_parse_optional_number(min_val),
215
- 'valuemax': self._safe_parse_optional_number(max_val),
216
- 'valuenow': None,
217
- },
218
- ]
219
- )
220
- simplified.is_compound_component = True
221
- elif input_type == 'color':
222
- # Color picker with components
223
- node._compound_children.extend(
224
- [
225
- {'role': 'textbox', 'name': 'Hex Value', 'valuemin': None, 'valuemax': None, 'valuenow': None},
226
- {'role': 'button', 'name': 'Color Picker', 'valuemin': None, 'valuemax': None, 'valuenow': None},
227
- ]
228
- )
229
- simplified.is_compound_component = True
230
- elif input_type == 'file':
231
- # File input with browse button
232
- multiple = 'multiple' in node.attributes if node.attributes else False
233
-
234
- # Extract current file selection state from AX tree
235
- current_value = 'None' # Default to explicit "None" string for clarity
236
- if node.ax_node and node.ax_node.properties:
237
- for prop in node.ax_node.properties:
238
- # Try valuetext first (human-readable display like "file.pdf")
239
- if prop.name == 'valuetext' and prop.value:
240
- value_str = str(prop.value).strip()
241
- if value_str and value_str.lower() not in ['', 'no file chosen', 'no file selected']:
242
- current_value = value_str
243
- break
244
- # Also try 'value' property (may include full path)
245
- elif prop.name == 'value' and prop.value:
246
- value_str = str(prop.value).strip()
247
- if value_str:
248
- # For file inputs, value might be a full path - extract just filename
249
- if '\\' in value_str:
250
- current_value = value_str.split('\\')[-1]
251
- elif '/' in value_str:
252
- current_value = value_str.split('/')[-1]
253
- else:
254
- current_value = value_str
255
- break
256
-
257
- node._compound_children.extend(
258
- [
259
- {'role': 'button', 'name': 'Browse Files', 'valuemin': None, 'valuemax': None, 'valuenow': None},
260
- {
261
- 'role': 'textbox',
262
- 'name': f'{"Files" if multiple else "File"} Selected',
263
- 'valuemin': None,
264
- 'valuemax': None,
265
- 'valuenow': current_value, # Always shows state: filename or "None"
266
- },
267
- ]
268
- )
269
- simplified.is_compound_component = True
270
-
271
- elif element_type == 'select':
272
- # Select dropdown with option list and detailed option information
273
- base_components = [
274
- {'role': 'button', 'name': 'Dropdown Toggle', 'valuemin': None, 'valuemax': None, 'valuenow': None}
275
- ]
276
-
277
- # Extract option information from child nodes
278
- options_info = self._extract_select_options(node)
279
- if options_info:
280
- options_component = {
281
- 'role': 'listbox',
282
- 'name': 'Options',
283
- 'valuemin': None,
284
- 'valuemax': None,
285
- 'valuenow': None,
286
- 'options_count': options_info['count'],
287
- 'first_options': options_info['first_options'],
288
- }
289
- if options_info['format_hint']:
290
- options_component['format_hint'] = options_info['format_hint']
291
- base_components.append(options_component)
292
- else:
293
- base_components.append(
294
- {'role': 'listbox', 'name': 'Options', 'valuemin': None, 'valuemax': None, 'valuenow': None}
295
- )
296
-
297
- node._compound_children.extend(base_components)
298
- simplified.is_compound_component = True
299
-
300
- elif element_type == 'details':
301
- # Details/summary disclosure widget
302
- node._compound_children.extend(
303
- [
304
- {'role': 'button', 'name': 'Toggle Disclosure', 'valuemin': None, 'valuemax': None, 'valuenow': None},
305
- {'role': 'region', 'name': 'Content Area', 'valuemin': None, 'valuemax': None, 'valuenow': None},
306
- ]
307
- )
308
- simplified.is_compound_component = True
309
-
310
- elif element_type == 'audio':
311
- # Audio player controls
312
- node._compound_children.extend(
313
- [
314
- {'role': 'button', 'name': 'Play/Pause', 'valuemin': None, 'valuemax': None, 'valuenow': None},
315
- {'role': 'slider', 'name': 'Progress', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
316
- {'role': 'button', 'name': 'Mute', 'valuemin': None, 'valuemax': None, 'valuenow': None},
317
- {'role': 'slider', 'name': 'Volume', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
318
- ]
319
- )
320
- simplified.is_compound_component = True
321
-
322
- elif element_type == 'video':
323
- # Video player controls
324
- node._compound_children.extend(
325
- [
326
- {'role': 'button', 'name': 'Play/Pause', 'valuemin': None, 'valuemax': None, 'valuenow': None},
327
- {'role': 'slider', 'name': 'Progress', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
328
- {'role': 'button', 'name': 'Mute', 'valuemin': None, 'valuemax': None, 'valuenow': None},
329
- {'role': 'slider', 'name': 'Volume', 'valuemin': 0, 'valuemax': 100, 'valuenow': None},
330
- {'role': 'button', 'name': 'Fullscreen', 'valuemin': None, 'valuemax': None, 'valuenow': None},
331
- ]
332
- )
333
- simplified.is_compound_component = True
334
-
335
- def _extract_select_options(self, select_node: EnhancedDOMTreeNode) -> dict[str, Any] | None:
336
- """Extract option information from a select element."""
337
- if not select_node.children:
338
- return None
339
-
340
- options = []
341
- option_values = []
342
-
343
- def extract_options_recursive(node: EnhancedDOMTreeNode) -> None:
344
- """Recursively extract option elements, including from optgroups."""
345
- if node.tag_name.lower() == 'option':
346
- # Extract option text and value
347
- option_text = ''
348
- option_value = ''
349
-
350
- # Get value attribute if present
351
- if node.attributes and 'value' in node.attributes:
352
- option_value = str(node.attributes['value']).strip()
353
-
354
- # Get text content from direct child text nodes only to avoid duplication
355
- def get_direct_text_content(n: EnhancedDOMTreeNode) -> str:
356
- text = ''
357
- for child in n.children:
358
- if child.node_type == NodeType.TEXT_NODE and child.node_value:
359
- text += child.node_value.strip() + ' '
360
- return text.strip()
361
-
362
- option_text = get_direct_text_content(node)
363
-
364
- # Use text as value if no explicit value
365
- if not option_value and option_text:
366
- option_value = option_text
367
-
368
- if option_text or option_value:
369
- options.append({'text': option_text, 'value': option_value})
370
- option_values.append(option_value)
371
-
372
- elif node.tag_name.lower() == 'optgroup':
373
- # Process optgroup children
374
- for child in node.children:
375
- extract_options_recursive(child)
376
- else:
377
- # Process other children that might contain options
378
- for child in node.children:
379
- extract_options_recursive(child)
380
-
381
- # Extract all options from select children
382
- for child in select_node.children:
383
- extract_options_recursive(child)
384
-
385
- if not options:
386
- return None
387
-
388
- # Prepare first 4 options for display
389
- first_options = []
390
- for option in options[:4]:
391
- # Always use text if available, otherwise use value
392
- display_text = option['text'] if option['text'] else option['value']
393
- if display_text:
394
- # Limit individual option text to avoid overly long attributes
395
- text = display_text[:30] + ('...' if len(display_text) > 30 else '')
396
- first_options.append(text)
397
-
398
- # Add ellipsis indicator if there are more options than shown
399
- if len(options) > 4:
400
- first_options.append(f'... {len(options) - 4} more options...')
401
-
402
- # Try to infer format hint from option values
403
- format_hint = None
404
- if len(option_values) >= 2:
405
- # Check for common patterns
406
- if all(val.isdigit() for val in option_values[:5] if val):
407
- format_hint = 'numeric'
408
- elif all(len(val) == 2 and val.isupper() for val in option_values[:5] if val):
409
- format_hint = 'country/state codes'
410
- elif all('/' in val or '-' in val for val in option_values[:5] if val):
411
- format_hint = 'date/path format'
412
- elif any('@' in val for val in option_values[:5] if val):
413
- format_hint = 'email addresses'
414
-
415
- return {'count': len(options), 'first_options': first_options, 'format_hint': format_hint}
416
-
417
- def _is_interactive_cached(self, node: EnhancedDOMTreeNode) -> bool:
418
- """Cached version of clickable element detection to avoid redundant calls."""
419
-
420
- if node.node_id not in self._clickable_cache:
421
- import time
422
-
423
- start_time = time.time()
424
- result = ClickableElementDetector.is_interactive(node)
425
- end_time = time.time()
426
-
427
- if 'clickable_detection_time' not in self.timing_info:
428
- self.timing_info['clickable_detection_time'] = 0
429
- self.timing_info['clickable_detection_time'] += end_time - start_time
430
-
431
- self._clickable_cache[node.node_id] = result
432
-
433
- return self._clickable_cache[node.node_id]
434
-
435
- def _create_simplified_tree(self, node: EnhancedDOMTreeNode, depth: int = 0) -> SimplifiedNode | None:
436
- """Step 1: Create a simplified tree with enhanced element detection."""
437
-
438
- if node.node_type == NodeType.DOCUMENT_NODE:
439
- # for all cldren including shadow roots
440
- for child in node.children_and_shadow_roots:
441
- simplified_child = self._create_simplified_tree(child, depth + 1)
442
- if simplified_child:
443
- return simplified_child
444
-
445
- return None
446
-
447
- if node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
448
- # ENHANCED shadow DOM processing - always include shadow content
449
- simplified = SimplifiedNode(original_node=node, children=[])
450
- for child in node.children_and_shadow_roots:
451
- simplified_child = self._create_simplified_tree(child, depth + 1)
452
- if simplified_child:
453
- simplified.children.append(simplified_child)
454
-
455
- # Always return shadow DOM fragments, even if children seem empty
456
- # Shadow DOM often contains the actual interactive content in SPAs
457
- return simplified if simplified.children else SimplifiedNode(original_node=node, children=[])
458
-
459
- elif node.node_type == NodeType.ELEMENT_NODE:
460
- # Skip non-content elements
461
- if node.node_name.lower() in DISABLED_ELEMENTS:
462
- return None
463
-
464
- # Skip SVG child elements entirely (path, rect, g, circle, etc.)
465
- if node.node_name.lower() in SVG_ELEMENTS:
466
- return None
467
-
468
- attributes = node.attributes or {}
469
- # Check for session-specific exclude attribute first, then fall back to legacy attribute
470
- exclude_attr = None
471
- attr_type = None
472
- if self.session_id:
473
- session_specific_attr = f'data-browser-use-exclude-{self.session_id}'
474
- exclude_attr = attributes.get(session_specific_attr)
475
- if exclude_attr:
476
- attr_type = 'session-specific'
477
- # Fall back to legacy attribute if session-specific not found
478
- if not exclude_attr:
479
- exclude_attr = attributes.get('data-browser-use-exclude')
480
- if isinstance(exclude_attr, str) and exclude_attr.lower() == 'true':
481
- return None
482
-
483
- if node.node_name == 'IFRAME' or node.node_name == 'FRAME':
484
- if node.content_document:
485
- simplified = SimplifiedNode(original_node=node, children=[])
486
- for child in node.content_document.children_nodes or []:
487
- simplified_child = self._create_simplified_tree(child, depth + 1)
488
- if simplified_child is not None:
489
- simplified.children.append(simplified_child)
490
- return simplified
491
-
492
- is_visible = node.is_visible
493
- is_scrollable = node.is_actually_scrollable
494
- has_shadow_content = bool(node.children_and_shadow_roots)
495
-
496
- # ENHANCED SHADOW DOM DETECTION: Include shadow hosts even if not visible
497
- is_shadow_host = any(child.node_type == NodeType.DOCUMENT_FRAGMENT_NODE for child in node.children_and_shadow_roots)
498
-
499
- # Override visibility for elements with validation attributes
500
- if not is_visible and node.attributes:
501
- has_validation_attrs = any(attr.startswith(('aria-', 'pseudo')) for attr in node.attributes.keys())
502
- if has_validation_attrs:
503
- is_visible = True # Force visibility for validation elements
504
-
505
- # EXCEPTION: File inputs are often hidden with opacity:0 but are still functional
506
- # Bootstrap and other frameworks use this pattern with custom-styled file pickers
507
- is_file_input = (
508
- node.tag_name and node.tag_name.lower() == 'input' and node.attributes and node.attributes.get('type') == 'file'
509
- )
510
- if not is_visible and is_file_input:
511
- is_visible = True # Force visibility for file inputs
512
-
513
- # Include if visible, scrollable, has children, or is shadow host
514
- if is_visible or is_scrollable or has_shadow_content or is_shadow_host:
515
- simplified = SimplifiedNode(original_node=node, children=[], is_shadow_host=is_shadow_host)
516
-
517
- # Process ALL children including shadow roots with enhanced logging
518
- for child in node.children_and_shadow_roots:
519
- simplified_child = self._create_simplified_tree(child, depth + 1)
520
- if simplified_child:
521
- simplified.children.append(simplified_child)
522
-
523
- # COMPOUND CONTROL PROCESSING: Add virtual components for compound controls
524
- self._add_compound_components(simplified, node)
525
-
526
- # SHADOW DOM SPECIAL CASE: Always include shadow hosts even if not visible
527
- # Many SPA frameworks (React, Vue) render content in shadow DOM
528
- if is_shadow_host and simplified.children:
529
- return simplified
530
-
531
- # Return if meaningful or has meaningful children
532
- if is_visible or is_scrollable or simplified.children:
533
- return simplified
534
- elif node.node_type == NodeType.TEXT_NODE:
535
- # Include meaningful text nodes
536
- is_visible = node.snapshot_node and node.is_visible
537
- if is_visible and node.node_value and node.node_value.strip() and len(node.node_value.strip()) > 1:
538
- return SimplifiedNode(original_node=node, children=[])
539
-
540
- return None
541
-
542
- def _optimize_tree(self, node: SimplifiedNode | None) -> SimplifiedNode | None:
543
- """Step 2: Optimize tree structure."""
544
- if not node:
545
- return None
546
-
547
- # Process children
548
- optimized_children = []
549
- for child in node.children:
550
- optimized_child = self._optimize_tree(child)
551
- if optimized_child:
552
- optimized_children.append(optimized_child)
553
-
554
- node.children = optimized_children
555
-
556
- # Keep meaningful nodes
557
- is_visible = node.original_node.snapshot_node and node.original_node.is_visible
558
-
559
- # EXCEPTION: File inputs are often hidden with opacity:0 but are still functional
560
- is_file_input = (
561
- node.original_node.tag_name
562
- and node.original_node.tag_name.lower() == 'input'
563
- and node.original_node.attributes
564
- and node.original_node.attributes.get('type') == 'file'
565
- )
566
-
567
- if (
568
- is_visible # Keep all visible nodes
569
- or node.original_node.is_actually_scrollable
570
- or node.original_node.node_type == NodeType.TEXT_NODE
571
- or node.children
572
- or is_file_input # Keep file inputs even if not visible
573
- ):
574
- return node
575
-
576
- return None
577
-
578
- def _collect_interactive_elements(self, node: SimplifiedNode, elements: list[SimplifiedNode]) -> None:
579
- """Recursively collect interactive elements that are also visible."""
580
- is_interactive = self._is_interactive_cached(node.original_node)
581
- is_visible = node.original_node.snapshot_node and node.original_node.is_visible
582
-
583
- # Only collect elements that are both interactive AND visible
584
- if is_interactive and is_visible:
585
- elements.append(node)
586
-
587
- for child in node.children:
588
- self._collect_interactive_elements(child, elements)
589
-
590
- def _has_interactive_descendants(self, node: SimplifiedNode) -> bool:
591
- """Check if a node has any interactive descendants (not including the node itself)."""
592
- # Check children for interactivity
593
- for child in node.children:
594
- # Check if child itself is interactive
595
- if self._is_interactive_cached(child.original_node):
596
- return True
597
- # Recursively check child's descendants
598
- if self._has_interactive_descendants(child):
599
- return True
600
-
601
- return False
602
-
603
- def _assign_interactive_indices_and_mark_new_nodes(self, node: SimplifiedNode | None) -> None:
604
- """Assign interactive indices to clickable elements that are also visible."""
605
- if not node:
606
- return
607
-
608
- # Skip assigning index to excluded nodes, or ignored by paint order
609
- if not node.excluded_by_parent and not node.ignored_by_paint_order:
610
- # Regular interactive element assignment (including enhanced compound controls)
611
- is_interactive_assign = self._is_interactive_cached(node.original_node)
612
- is_visible = node.original_node.snapshot_node and node.original_node.is_visible
613
- is_scrollable = node.original_node.is_actually_scrollable
614
-
615
- # EXCEPTION: File inputs are often hidden with opacity:0 but are still functional
616
- # Bootstrap and other frameworks use this pattern with custom-styled file pickers
617
- is_file_input = (
618
- node.original_node.tag_name
619
- and node.original_node.tag_name.lower() == 'input'
620
- and node.original_node.attributes
621
- and node.original_node.attributes.get('type') == 'file'
622
- )
623
-
624
- # Check if scrollable container should be made interactive
625
- # For scrollable elements, ONLY make them interactive if they have no interactive descendants
626
- should_make_interactive = False
627
- if is_scrollable:
628
- # For scrollable elements, check if they have interactive children
629
- has_interactive_desc = self._has_interactive_descendants(node)
630
-
631
- # Only make scrollable container interactive if it has NO interactive descendants
632
- if not has_interactive_desc:
633
- should_make_interactive = True
634
- elif is_interactive_assign and (is_visible or is_file_input):
635
- # Non-scrollable interactive elements: make interactive if visible (or file input)
636
- should_make_interactive = True
637
-
638
- # Add to selector map if element should be interactive
639
- if should_make_interactive:
640
- # Mark node as interactive
641
- node.is_interactive = True
642
- # Store backend_node_id in selector map (model outputs backend_node_id)
643
- self._selector_map[node.original_node.backend_node_id] = node.original_node
644
- self._interactive_counter += 1
645
-
646
- # Mark compound components as new for visibility
647
- if node.is_compound_component:
648
- node.is_new = True
649
- elif self._previous_cached_selector_map:
650
- # Check if node is new for regular elements
651
- previous_backend_node_ids = {node.backend_node_id for node in self._previous_cached_selector_map.values()}
652
- if node.original_node.backend_node_id not in previous_backend_node_ids:
653
- node.is_new = True
654
-
655
- # Process children
656
- for child in node.children:
657
- self._assign_interactive_indices_and_mark_new_nodes(child)
658
-
659
- def _apply_bounding_box_filtering(self, node: SimplifiedNode | None) -> SimplifiedNode | None:
660
- """Filter children contained within propagating parent bounds."""
661
- if not node:
662
- return None
663
-
664
- # Start with no active bounds
665
- self._filter_tree_recursive(node, active_bounds=None, depth=0)
666
-
667
- # Log statistics
668
- excluded_count = self._count_excluded_nodes(node)
669
- if excluded_count > 0:
670
- import logging
671
-
672
- logging.debug(f'BBox filtering excluded {excluded_count} nodes')
673
-
674
- return node
675
-
676
- def _filter_tree_recursive(self, node: SimplifiedNode, active_bounds: PropagatingBounds | None = None, depth: int = 0):
677
- """
678
- Recursively filter tree with bounding box propagation.
679
- Bounds propagate to ALL descendants until overridden.
680
- """
681
-
682
- # Check if this node should be excluded by active bounds
683
- if active_bounds and self._should_exclude_child(node, active_bounds):
684
- node.excluded_by_parent = True
685
- # Important: Still check if this node starts NEW propagation
686
-
687
- # Check if this node starts new propagation (even if excluded!)
688
- new_bounds = None
689
- tag = node.original_node.tag_name.lower()
690
- role = node.original_node.attributes.get('role') if node.original_node.attributes else None
691
- attributes = {
692
- 'tag': tag,
693
- 'role': role,
694
- }
695
- # Check if this element matches any propagating element pattern
696
- if self._is_propagating_element(attributes):
697
- # This node propagates bounds to ALL its descendants
698
- if node.original_node.snapshot_node and node.original_node.snapshot_node.bounds:
699
- new_bounds = PropagatingBounds(
700
- tag=tag,
701
- bounds=node.original_node.snapshot_node.bounds,
702
- node_id=node.original_node.node_id,
703
- depth=depth,
704
- )
705
-
706
- # Propagate to ALL children
707
- # Use new_bounds if this node starts propagation, otherwise continue with active_bounds
708
- propagate_bounds = new_bounds if new_bounds else active_bounds
709
-
710
- for child in node.children:
711
- self._filter_tree_recursive(child, propagate_bounds, depth + 1)
712
-
713
- def _should_exclude_child(self, node: SimplifiedNode, active_bounds: PropagatingBounds) -> bool:
714
- """
715
- Determine if child should be excluded based on propagating bounds.
716
- """
717
-
718
- # Never exclude text nodes - we always want to preserve text content
719
- if node.original_node.node_type == NodeType.TEXT_NODE:
720
- return False
721
-
722
- # Get child bounds
723
- if not node.original_node.snapshot_node or not node.original_node.snapshot_node.bounds:
724
- return False # No bounds = can't determine containment
725
-
726
- child_bounds = node.original_node.snapshot_node.bounds
727
-
728
- # Check containment with configured threshold
729
- if not self._is_contained(child_bounds, active_bounds.bounds, self.containment_threshold):
730
- return False # Not sufficiently contained
731
-
732
- # EXCEPTION RULES - Keep these even if contained:
733
-
734
- child_tag = node.original_node.tag_name.lower()
735
- child_role = node.original_node.attributes.get('role') if node.original_node.attributes else None
736
- child_attributes = {
737
- 'tag': child_tag,
738
- 'role': child_role,
739
- }
740
-
741
- # 1. Never exclude form elements (they need individual interaction)
742
- if child_tag in ['input', 'select', 'textarea', 'label']:
743
- return False
744
-
745
- # 2. Keep if child is also a propagating element
746
- # (might have stopPropagation, e.g., button in button)
747
- if self._is_propagating_element(child_attributes):
748
- return False
749
-
750
- # 3. Keep if has explicit onclick handler
751
- if node.original_node.attributes and 'onclick' in node.original_node.attributes:
752
- return False
753
-
754
- # 4. Keep if has aria-label suggesting it's independently interactive
755
- if node.original_node.attributes:
756
- aria_label = node.original_node.attributes.get('aria-label')
757
- if aria_label and aria_label.strip():
758
- # Has meaningful aria-label, likely interactive
759
- return False
760
-
761
- # 5. Keep if has role suggesting interactivity
762
- if node.original_node.attributes:
763
- role = node.original_node.attributes.get('role')
764
- if role in ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem', 'option']:
765
- return False
766
-
767
- # Default: exclude this child
768
- return True
769
-
770
- def _is_contained(self, child: DOMRect, parent: DOMRect, threshold: float) -> bool:
771
- """
772
- Check if child is contained within parent bounds.
773
-
774
- Args:
775
- threshold: Percentage (0.0-1.0) of child that must be within parent
776
- """
777
- # Calculate intersection
778
- x_overlap = max(0, min(child.x + child.width, parent.x + parent.width) - max(child.x, parent.x))
779
- y_overlap = max(0, min(child.y + child.height, parent.y + parent.height) - max(child.y, parent.y))
780
-
781
- intersection_area = x_overlap * y_overlap
782
- child_area = child.width * child.height
783
-
784
- if child_area == 0:
785
- return False # Zero-area element
786
-
787
- containment_ratio = intersection_area / child_area
788
- return containment_ratio >= threshold
789
-
790
- def _count_excluded_nodes(self, node: SimplifiedNode, count: int = 0) -> int:
791
- """Count how many nodes were excluded (for debugging)."""
792
- if hasattr(node, 'excluded_by_parent') and node.excluded_by_parent:
793
- count += 1
794
- for child in node.children:
795
- count = self._count_excluded_nodes(child, count)
796
- return count
797
-
798
- def _is_propagating_element(self, attributes: dict[str, str | None]) -> bool:
799
- """
800
- Check if an element should propagate bounds based on attributes.
801
- If the element satisfies one of the patterns, it propagates bounds to all its children.
802
- """
803
- keys_to_check = ['tag', 'role']
804
- for pattern in self.PROPAGATING_ELEMENTS:
805
- # Check if the element satisfies the pattern
806
- check = [pattern.get(key) is None or pattern.get(key) == attributes.get(key) for key in keys_to_check]
807
- if all(check):
808
- return True
809
-
810
- return False
811
-
812
- @staticmethod
813
- def serialize_tree(node: SimplifiedNode | None, include_attributes: list[str], depth: int = 0) -> str:
814
- """Serialize the optimized tree to string format."""
815
- if not node:
816
- return ''
817
-
818
- # Skip rendering excluded nodes, but process their children
819
- if hasattr(node, 'excluded_by_parent') and node.excluded_by_parent:
820
- formatted_text = []
821
- for child in node.children:
822
- child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, depth)
823
- if child_text:
824
- formatted_text.append(child_text)
825
- return '\n'.join(formatted_text)
826
-
827
- formatted_text = []
828
- depth_str = depth * '\t'
829
- next_depth = depth
830
-
831
- if node.original_node.node_type == NodeType.ELEMENT_NODE:
832
- # Skip displaying nodes marked as should_display=False
833
- if not node.should_display:
834
- for child in node.children:
835
- child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, depth)
836
- if child_text:
837
- formatted_text.append(child_text)
838
- return '\n'.join(formatted_text)
839
-
840
- # Special handling for SVG elements - show the tag but collapse children
841
- if node.original_node.tag_name.lower() == 'svg':
842
- shadow_prefix = ''
843
- if node.is_shadow_host:
844
- has_closed_shadow = any(
845
- child.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
846
- and child.original_node.shadow_root_type
847
- and child.original_node.shadow_root_type.lower() == 'closed'
848
- for child in node.children
849
- )
850
- shadow_prefix = '|SHADOW(closed)|' if has_closed_shadow else '|SHADOW(open)|'
851
-
852
- line = f'{depth_str}{shadow_prefix}'
853
- # Add interactive marker if clickable
854
- if node.is_interactive:
855
- new_prefix = '*' if node.is_new else ''
856
- line += f'{new_prefix}[{node.original_node.backend_node_id}]'
857
- line += '<svg'
858
- attributes_html_str = DOMTreeSerializer._build_attributes_string(node.original_node, include_attributes, '')
859
- if attributes_html_str:
860
- line += f' {attributes_html_str}'
861
- line += ' /> <!-- SVG content collapsed -->'
862
- formatted_text.append(line)
863
- # Don't process children for SVG
864
- return '\n'.join(formatted_text)
865
-
866
- # Add element if clickable, scrollable, or iframe
867
- is_any_scrollable = node.original_node.is_actually_scrollable or node.original_node.is_scrollable
868
- should_show_scroll = node.original_node.should_show_scroll_info
869
- if (
870
- node.is_interactive
871
- or is_any_scrollable
872
- or node.original_node.tag_name.upper() == 'IFRAME'
873
- or node.original_node.tag_name.upper() == 'FRAME'
874
- ):
875
- next_depth += 1
876
-
877
- # Build attributes string with compound component info
878
- text_content = ''
879
- attributes_html_str = DOMTreeSerializer._build_attributes_string(
880
- node.original_node, include_attributes, text_content
881
- )
882
-
883
- # Add compound component information to attributes if present
884
- if node.original_node._compound_children:
885
- compound_info = []
886
- for child_info in node.original_node._compound_children:
887
- parts = []
888
- if child_info['name']:
889
- parts.append(f'name={child_info["name"]}')
890
- if child_info['role']:
891
- parts.append(f'role={child_info["role"]}')
892
- if child_info['valuemin'] is not None:
893
- parts.append(f'min={child_info["valuemin"]}')
894
- if child_info['valuemax'] is not None:
895
- parts.append(f'max={child_info["valuemax"]}')
896
- if child_info['valuenow'] is not None:
897
- parts.append(f'current={child_info["valuenow"]}')
898
-
899
- # Add select-specific information
900
- if 'options_count' in child_info and child_info['options_count'] is not None:
901
- parts.append(f'count={child_info["options_count"]}')
902
- if 'first_options' in child_info and child_info['first_options']:
903
- options_str = '|'.join(child_info['first_options'][:4]) # Limit to 4 options
904
- parts.append(f'options={options_str}')
905
- if 'format_hint' in child_info and child_info['format_hint']:
906
- parts.append(f'format={child_info["format_hint"]}')
907
-
908
- if parts:
909
- compound_info.append(f'({",".join(parts)})')
910
-
911
- if compound_info:
912
- compound_attr = f'compound_components={",".join(compound_info)}'
913
- if attributes_html_str:
914
- attributes_html_str += f' {compound_attr}'
915
- else:
916
- attributes_html_str = compound_attr
917
-
918
- # Build the line with shadow host indicator
919
- shadow_prefix = ''
920
- if node.is_shadow_host:
921
- # Check if any shadow children are closed
922
- has_closed_shadow = any(
923
- child.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
924
- and child.original_node.shadow_root_type
925
- and child.original_node.shadow_root_type.lower() == 'closed'
926
- for child in node.children
927
- )
928
- shadow_prefix = '|SHADOW(closed)|' if has_closed_shadow else '|SHADOW(open)|'
929
-
930
- if should_show_scroll and not node.is_interactive:
931
- # Scrollable container but not clickable
932
- line = f'{depth_str}{shadow_prefix}|SCROLL|<{node.original_node.tag_name}'
933
- elif node.is_interactive:
934
- # Clickable (and possibly scrollable) - show backend_node_id
935
- new_prefix = '*' if node.is_new else ''
936
- scroll_prefix = '|SCROLL[' if should_show_scroll else '['
937
- line = f'{depth_str}{shadow_prefix}{new_prefix}{scroll_prefix}{node.original_node.backend_node_id}]<{node.original_node.tag_name}'
938
- elif node.original_node.tag_name.upper() == 'IFRAME':
939
- # Iframe element (not interactive)
940
- line = f'{depth_str}{shadow_prefix}|IFRAME|<{node.original_node.tag_name}'
941
- elif node.original_node.tag_name.upper() == 'FRAME':
942
- # Frame element (not interactive)
943
- line = f'{depth_str}{shadow_prefix}|FRAME|<{node.original_node.tag_name}'
944
- else:
945
- line = f'{depth_str}{shadow_prefix}<{node.original_node.tag_name}'
946
-
947
- if attributes_html_str:
948
- line += f' {attributes_html_str}'
949
-
950
- line += ' />'
951
-
952
- # Add scroll information only when we should show it
953
- if should_show_scroll:
954
- scroll_info_text = node.original_node.get_scroll_info_text()
955
- if scroll_info_text:
956
- line += f' ({scroll_info_text})'
957
-
958
- formatted_text.append(line)
959
-
960
- elif node.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
961
- # Shadow DOM representation - show clearly to LLM
962
- if node.original_node.shadow_root_type and node.original_node.shadow_root_type.lower() == 'closed':
963
- formatted_text.append(f'{depth_str}Closed Shadow')
964
- else:
965
- formatted_text.append(f'{depth_str}Open Shadow')
966
-
967
- next_depth += 1
968
-
969
- # Process shadow DOM children
970
- for child in node.children:
971
- child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, next_depth)
972
- if child_text:
973
- formatted_text.append(child_text)
974
-
975
- # Close shadow DOM indicator
976
- if node.children: # Only show close if we had content
977
- formatted_text.append(f'{depth_str}Shadow End')
978
-
979
- elif node.original_node.node_type == NodeType.TEXT_NODE:
980
- # Include visible text
981
- is_visible = node.original_node.snapshot_node and node.original_node.is_visible
982
- if (
983
- is_visible
984
- and node.original_node.node_value
985
- and node.original_node.node_value.strip()
986
- and len(node.original_node.node_value.strip()) > 1
987
- ):
988
- clean_text = node.original_node.node_value.strip()
989
- formatted_text.append(f'{depth_str}{clean_text}')
990
-
991
- # Process children (for non-shadow elements)
992
- if node.original_node.node_type != NodeType.DOCUMENT_FRAGMENT_NODE:
993
- for child in node.children:
994
- child_text = DOMTreeSerializer.serialize_tree(child, include_attributes, next_depth)
995
- if child_text:
996
- formatted_text.append(child_text)
997
-
998
- return '\n'.join(formatted_text)
999
-
1000
- @staticmethod
1001
- def _build_attributes_string(node: EnhancedDOMTreeNode, include_attributes: list[str], text: str) -> str:
1002
- """Build the attributes string for an element."""
1003
- attributes_to_include = {}
1004
-
1005
- # Include HTML attributes
1006
- if node.attributes:
1007
- attributes_to_include.update(
1008
- {
1009
- key: str(value).strip()
1010
- for key, value in node.attributes.items()
1011
- if key in include_attributes and str(value).strip() != ''
1012
- }
1013
- )
1014
-
1015
- # Add format hints for date/time inputs to help LLMs use the correct format
1016
- # NOTE: These formats are standardized by HTML5 specification (ISO 8601), NOT locale-dependent
1017
- # The browser may DISPLAY dates in locale format (MM/DD/YYYY in US, DD/MM/YYYY in EU),
1018
- # but the .value attribute and programmatic setting ALWAYS uses these ISO formats:
1019
- # - date: YYYY-MM-DD (e.g., "2024-03-15")
1020
- # - time: HH:MM or HH:MM:SS (24-hour, e.g., "14:30")
1021
- # - datetime-local: YYYY-MM-DDTHH:MM (e.g., "2024-03-15T14:30")
1022
- # Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date
1023
- if node.tag_name and node.tag_name.lower() == 'input' and node.attributes:
1024
- input_type = node.attributes.get('type', '').lower()
1025
-
1026
- # For HTML5 date/time inputs, add a highly visible "format" attribute
1027
- # This makes it IMPOSSIBLE for the model to miss the required format
1028
- if input_type in ['date', 'time', 'datetime-local', 'month', 'week']:
1029
- format_map = {
1030
- 'date': 'YYYY-MM-DD',
1031
- 'time': 'HH:MM',
1032
- 'datetime-local': 'YYYY-MM-DDTHH:MM',
1033
- 'month': 'YYYY-MM',
1034
- 'week': 'YYYY-W##',
1035
- }
1036
- # Add format as a special attribute that appears prominently
1037
- # This appears BEFORE placeholder in the serialized output
1038
- attributes_to_include['format'] = format_map[input_type]
1039
-
1040
- # Only add placeholder if it doesn't already exist
1041
- if 'placeholder' in include_attributes and 'placeholder' not in attributes_to_include:
1042
- # Native HTML5 date/time inputs - ISO format required
1043
- if input_type == 'date':
1044
- attributes_to_include['placeholder'] = 'YYYY-MM-DD'
1045
- elif input_type == 'time':
1046
- attributes_to_include['placeholder'] = 'HH:MM'
1047
- elif input_type == 'datetime-local':
1048
- attributes_to_include['placeholder'] = 'YYYY-MM-DDTHH:MM'
1049
- elif input_type == 'month':
1050
- attributes_to_include['placeholder'] = 'YYYY-MM'
1051
- elif input_type == 'week':
1052
- attributes_to_include['placeholder'] = 'YYYY-W##'
1053
- # Tel - suggest format if no pattern attribute
1054
- elif input_type == 'tel' and 'pattern' not in attributes_to_include:
1055
- attributes_to_include['placeholder'] = '123-456-7890'
1056
- # jQuery/Bootstrap/AngularJS datepickers (text inputs with datepicker classes/attributes)
1057
- elif input_type in {'text', ''}:
1058
- class_attr = node.attributes.get('class', '').lower()
1059
-
1060
- # Check for AngularJS UI Bootstrap datepicker (uib-datepicker-popup attribute)
1061
- # This takes precedence as it's the most specific indicator
1062
- if 'uib-datepicker-popup' in node.attributes:
1063
- # Extract format from uib-datepicker-popup="MM/dd/yyyy"
1064
- date_format = node.attributes.get('uib-datepicker-popup', '')
1065
- if date_format:
1066
- # Use 'expected_format' for clarity - this is the required input format
1067
- attributes_to_include['expected_format'] = date_format
1068
- # Also keep format for consistency with HTML5 date inputs
1069
- attributes_to_include['format'] = date_format
1070
- # Detect jQuery/Bootstrap datepickers by class names
1071
- elif any(indicator in class_attr for indicator in ['datepicker', 'datetimepicker', 'daterangepicker']):
1072
- # Try to get format from data-date-format attribute
1073
- date_format = node.attributes.get('data-date-format', '')
1074
- if date_format:
1075
- attributes_to_include['placeholder'] = date_format
1076
- attributes_to_include['format'] = date_format # Also add format for jQuery datepickers
1077
- else:
1078
- # Default to common US format for jQuery datepickers
1079
- attributes_to_include['placeholder'] = 'mm/dd/yyyy'
1080
- attributes_to_include['format'] = 'mm/dd/yyyy'
1081
- # Also detect by data-* attributes
1082
- elif any(attr in node.attributes for attr in ['data-datepicker']):
1083
- date_format = node.attributes.get('data-date-format', '')
1084
- if date_format:
1085
- attributes_to_include['placeholder'] = date_format
1086
- attributes_to_include['format'] = date_format
1087
- else:
1088
- attributes_to_include['placeholder'] = 'mm/dd/yyyy'
1089
- attributes_to_include['format'] = 'mm/dd/yyyy'
1090
-
1091
- # Include accessibility properties
1092
- if node.ax_node and node.ax_node.properties:
1093
- for prop in node.ax_node.properties:
1094
- try:
1095
- if prop.name in include_attributes and prop.value is not None:
1096
- # Convert boolean to lowercase string, keep others as-is
1097
- if isinstance(prop.value, bool):
1098
- attributes_to_include[prop.name] = str(prop.value).lower()
1099
- else:
1100
- prop_value_str = str(prop.value).strip()
1101
- if prop_value_str:
1102
- attributes_to_include[prop.name] = prop_value_str
1103
- except (AttributeError, ValueError):
1104
- continue
1105
-
1106
- # Special handling for form elements - ensure current value is shown
1107
- # For text inputs, textareas, and selects, prioritize showing the current value from AX tree
1108
- if node.tag_name and node.tag_name.lower() in ['input', 'textarea', 'select']:
1109
- # ALWAYS check AX tree - it reflects actual typed value, DOM attribute may not update
1110
- if node.ax_node and node.ax_node.properties:
1111
- for prop in node.ax_node.properties:
1112
- # Try valuetext first (human-readable display value)
1113
- if prop.name == 'valuetext' and prop.value:
1114
- value_str = str(prop.value).strip()
1115
- if value_str:
1116
- attributes_to_include['value'] = value_str
1117
- break
1118
- # Also try 'value' property directly
1119
- elif prop.name == 'value' and prop.value:
1120
- value_str = str(prop.value).strip()
1121
- if value_str:
1122
- attributes_to_include['value'] = value_str
1123
- break
1124
-
1125
- if not attributes_to_include:
1126
- return ''
1127
-
1128
- # Remove duplicate values
1129
- ordered_keys = [key for key in include_attributes if key in attributes_to_include]
1130
-
1131
- if len(ordered_keys) > 1:
1132
- keys_to_remove = set()
1133
- seen_values = {}
1134
-
1135
- # Attributes that should never be removed as duplicates (they serve distinct purposes)
1136
- protected_attrs = {'format', 'expected_format', 'placeholder', 'value', 'aria-label', 'title'}
1137
-
1138
- for key in ordered_keys:
1139
- value = attributes_to_include[key]
1140
- if len(value) > 5:
1141
- if value in seen_values and key not in protected_attrs:
1142
- keys_to_remove.add(key)
1143
- else:
1144
- seen_values[value] = key
1145
-
1146
- for key in keys_to_remove:
1147
- del attributes_to_include[key]
1148
-
1149
- # Remove attributes that duplicate accessibility data
1150
- role = node.ax_node.role if node.ax_node else None
1151
- if role and node.node_name == role:
1152
- attributes_to_include.pop('role', None)
1153
-
1154
- # Remove type attribute if it matches the tag name (e.g. <button type="button">)
1155
- if 'type' in attributes_to_include and attributes_to_include['type'].lower() == node.node_name.lower():
1156
- del attributes_to_include['type']
1157
-
1158
- # Remove invalid attribute if it's false (only show when true)
1159
- if 'invalid' in attributes_to_include and attributes_to_include['invalid'].lower() == 'false':
1160
- del attributes_to_include['invalid']
1161
-
1162
- boolean_attrs = {'required'}
1163
- for attr in boolean_attrs:
1164
- if attr in attributes_to_include and attributes_to_include[attr].lower() in {'false', '0', 'no'}:
1165
- del attributes_to_include[attr]
1166
-
1167
- # Remove aria-expanded if we have expanded (prefer AX tree over HTML attribute)
1168
- if 'expanded' in attributes_to_include and 'aria-expanded' in attributes_to_include:
1169
- del attributes_to_include['aria-expanded']
1170
-
1171
- attrs_to_remove_if_text_matches = ['aria-label', 'placeholder', 'title']
1172
- for attr in attrs_to_remove_if_text_matches:
1173
- if attributes_to_include.get(attr) and attributes_to_include.get(attr, '').strip().lower() == text.strip().lower():
1174
- del attributes_to_include[attr]
1175
-
1176
- if attributes_to_include:
1177
- # Format attributes, wrapping empty values in quotes for clarity
1178
- formatted_attrs = []
1179
- for key, value in attributes_to_include.items():
1180
- capped_value = cap_text_length(value, 100)
1181
- # Show empty values as key='' instead of key=
1182
- if not capped_value:
1183
- formatted_attrs.append(f"{key}=''")
1184
- else:
1185
- formatted_attrs.append(f'{key}={capped_value}')
1186
- return ' '.join(formatted_attrs)
1187
-
1188
- return ''