1
- import { AccessibilityNode , TreeResult , AXNode } from "../../types/context" ;
1
+ import {
2
+ AccessibilityNode ,
3
+ TreeResult ,
4
+ AXNode ,
5
+ DOMNode ,
6
+ } from "../../types/context" ;
2
7
import { StagehandPage } from "../StagehandPage" ;
3
8
import { LogLine } from "../../types/log" ;
4
9
import { CDPSession , Page , Locator } from "playwright" ;
@@ -46,6 +51,41 @@ export function formatSimplifiedTree(
46
51
return result ;
47
52
}
48
53
54
+ /**
55
+ * Builds a map of `backendNodeId to tagName` with a single CDP round-trip.
56
+ */
57
+ async function buildBackendIdTagNameMap (
58
+ page : StagehandPage ,
59
+ ) : Promise < Map < number , string > > {
60
+ const map = new Map < number , string > ( ) ;
61
+
62
+ await page . enableCDP ( "DOM" ) ;
63
+ try {
64
+ const { root } = await page . sendCDP < { root : DOMNode } > ( "DOM.getDocument" , {
65
+ depth : - 1 ,
66
+ pierce : true ,
67
+ } ) ;
68
+
69
+ /* Recursively walk the DOM tree */
70
+ const walk = ( node : DOMNode ) : void => {
71
+ if ( node . backendNodeId && node . nodeName ) {
72
+ map . set ( node . backendNodeId , String ( node . nodeName ) . toLowerCase ( ) ) ;
73
+ }
74
+
75
+ // Children, shadow roots, template content, etc.
76
+ if ( node . children ) node . children . forEach ( walk ) ;
77
+ if ( node . shadowRoots ) node . shadowRoots . forEach ( walk ) ;
78
+ if ( node . contentDocument ) walk ( node . contentDocument ) ;
79
+ } ;
80
+
81
+ walk ( root ) ;
82
+ } finally {
83
+ await page . disableCDP ( "DOM" ) ;
84
+ }
85
+
86
+ return map ;
87
+ }
88
+
49
89
/**
50
90
* Helper function to remove or collapse unnecessary structural nodes
51
91
* Handles three cases:
@@ -56,7 +96,7 @@ export function formatSimplifiedTree(
56
96
*/
57
97
async function cleanStructuralNodes (
58
98
node : AccessibilityNode ,
59
- page ?: StagehandPage ,
99
+ tagNameMap : Map < number , string > ,
60
100
logger ?: ( logLine : LogLine ) => void ,
61
101
) : Promise < AccessibilityNode | null > {
62
102
// 1) Filter out nodes with negative IDs
@@ -72,7 +112,7 @@ async function cleanStructuralNodes(
72
112
73
113
// 3) Recursively clean children
74
114
const cleanedChildrenPromises = node . children . map ( ( child ) =>
75
- cleanStructuralNodes ( child , page , logger ) ,
115
+ cleanStructuralNodes ( child , tagNameMap , logger ) ,
76
116
) ;
77
117
const resolvedChildren = await Promise . all ( cleanedChildrenPromises ) ;
78
118
let cleanedChildren = resolvedChildren . filter (
@@ -94,67 +134,14 @@ async function cleanStructuralNodes(
94
134
}
95
135
96
136
// 5) If we still have a "generic"/"none" node after pruning
97
- // (i.e., because it had multiple children), now we try
98
- // to resolve and replace its role with the DOM tag name.
137
+ // (i.e., because it had multiple children), replace the role
138
+ // with the DOM tag name.
99
139
if (
100
- page &&
101
- logger &&
102
- node . backendDOMNodeId !== undefined &&
103
- ( node . role === "generic" || node . role === "none" )
140
+ ( node . role === "generic" || node . role === "none" ) &&
141
+ node . backendDOMNodeId !== undefined
104
142
) {
105
- try {
106
- const { object } = await page . sendCDP < {
107
- object : { objectId ?: string } ;
108
- } > ( "DOM.resolveNode" , {
109
- backendNodeId : node . backendDOMNodeId ,
110
- } ) ;
111
-
112
- if ( object && object . objectId ) {
113
- try {
114
- // Get the tagName for the node
115
- const { result } = await page . sendCDP < {
116
- result : { type : string ; value ?: string } ;
117
- } > ( "Runtime.callFunctionOn" , {
118
- objectId : object . objectId ,
119
- functionDeclaration : `
120
- function() {
121
- return this.tagName ? this.tagName.toLowerCase() : "";
122
- }
123
- ` ,
124
- returnByValue : true ,
125
- } ) ;
126
-
127
- // If we got a tagName, update the node's role
128
- if ( result ?. value ) {
129
- node . role = result . value ;
130
- }
131
- } catch ( tagNameError ) {
132
- logger ( {
133
- category : "observation" ,
134
- message : `Could not fetch tagName for node ${ node . backendDOMNodeId } ` ,
135
- level : 2 ,
136
- auxiliary : {
137
- error : {
138
- value : tagNameError . message ,
139
- type : "string" ,
140
- } ,
141
- } ,
142
- } ) ;
143
- }
144
- }
145
- } catch ( resolveError ) {
146
- logger ( {
147
- category : "observation" ,
148
- message : `Could not resolve DOM node ID ${ node . backendDOMNodeId } ` ,
149
- level : 2 ,
150
- auxiliary : {
151
- error : {
152
- value : resolveError . message ,
153
- type : "string" ,
154
- } ,
155
- } ,
156
- } ) ;
157
- }
143
+ const tagName = tagNameMap . get ( node . backendDOMNodeId ) ;
144
+ if ( tagName ) node . role = tagName ;
158
145
}
159
146
160
147
// rm redundant StaticText children
@@ -183,7 +170,7 @@ async function cleanStructuralNodes(
183
170
*/
184
171
export async function buildHierarchicalTree (
185
172
nodes : AccessibilityNode [ ] ,
186
- page ?: StagehandPage ,
173
+ tagNameMap : Map < number , string > ,
187
174
logger ?: ( logLine : LogLine ) => void ,
188
175
) : Promise < TreeResult > {
189
176
// Map to store nodeId -> URL for only those nodes that do have a URL.
@@ -264,7 +251,7 @@ export async function buildHierarchicalTree(
264
251
. filter ( Boolean ) as AccessibilityNode [ ] ;
265
252
266
253
const cleanedTreePromises = rootNodes . map ( ( node ) =>
267
- cleanStructuralNodes ( node , page , logger ) ,
254
+ cleanStructuralNodes ( node , tagNameMap , logger ) ,
268
255
) ;
269
256
const finalTree = ( await Promise . all ( cleanedTreePromises ) ) . filter (
270
257
Boolean ,
@@ -291,6 +278,9 @@ export async function getAccessibilityTree(
291
278
logger : ( logLine : LogLine ) => void ,
292
279
selector ?: string ,
293
280
) : Promise < TreeResult > {
281
+ /* Build tag-name map once, reuse everywhere */
282
+ const tagNameMap = await buildBackendIdTagNameMap ( page ) ;
283
+
294
284
await page . enableCDP ( "Accessibility" ) ;
295
285
296
286
try {
@@ -371,7 +361,7 @@ export async function getAccessibilityTree(
371
361
properties : node . properties ,
372
362
} ;
373
363
} ) ,
374
- page ,
364
+ tagNameMap ,
375
365
logger ,
376
366
) ;
377
367
0 commit comments