dsh-inner-browser-use 0.13.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/README.zh.md +3 -1
- package/docs/ARCHITECTURE.md +15 -1
- package/docs/BENCH.md +115 -5
- package/docs/ROADMAP.md +71 -3
- package/docs/VERIFY.md +55 -2
- package/index.js +3 -0
- package/package.json +5 -2
- package/read.js +718 -0
- package/sidecar.js +42 -1
- package/tools.js +1652 -0
package/read.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read model: what the page is, said in as few tokens as the truth allows.
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape this file.
|
|
5
|
+
*
|
|
6
|
+
* 1. A number means the same control twice. Playwright MCP and browser-use both
|
|
7
|
+
* hand out refs, and both pay for it by making the model re-read after every
|
|
8
|
+
* action. Here the page keeps a registry, so a ref survives a read, a
|
|
9
|
+
* checkbox and a tool boundary; only a navigation drops it, and a click on a
|
|
10
|
+
* dead ref says so instead of clicking something else.
|
|
11
|
+
*
|
|
12
|
+
* 2. Text is read from the viewport, not the document. A 60-paragraph article
|
|
13
|
+
* is 2.4k tokens if the whole page is printed. The person is looking at a
|
|
14
|
+
* screenful, the model is acting on a screenful, and scrolling is a call it
|
|
15
|
+
* can make on purpose.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Name of the page-side registry. */
|
|
19
|
+
const REGISTRY = '__dshRead'
|
|
20
|
+
|
|
21
|
+
/** Bumped when the page-side half changes, so a stale page is re-seeded. */
|
|
22
|
+
const VERSION = 2
|
|
23
|
+
|
|
24
|
+
/** Interactive elements worth numbering, in the order a reader would meet them. */
|
|
25
|
+
const SELECTOR = [
|
|
26
|
+
'a[href]',
|
|
27
|
+
'button',
|
|
28
|
+
'input:not([type="hidden"])',
|
|
29
|
+
'select',
|
|
30
|
+
'textarea',
|
|
31
|
+
'summary',
|
|
32
|
+
'label[for]',
|
|
33
|
+
'[contenteditable="true"]',
|
|
34
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
35
|
+
'[role="button"]',
|
|
36
|
+
'[role="link"]',
|
|
37
|
+
'[role="tab"]',
|
|
38
|
+
'[role="checkbox"]',
|
|
39
|
+
'[role="radio"]',
|
|
40
|
+
'[role="switch"]',
|
|
41
|
+
'[role="combobox"]',
|
|
42
|
+
'[role="menuitem"]',
|
|
43
|
+
'[role="option"]',
|
|
44
|
+
'[role="textbox"]',
|
|
45
|
+
'[role="searchbox"]',
|
|
46
|
+
].join(',')
|
|
47
|
+
|
|
48
|
+
/** How a role is written in the compact read: short enough to cost little. */
|
|
49
|
+
const SIGIL = { // ui-copy-ignore
|
|
50
|
+
a: 'link', // ui-copy-ignore
|
|
51
|
+
button: 'btn', // ui-copy-ignore
|
|
52
|
+
input: 'input', // ui-copy-ignore
|
|
53
|
+
select: 'select', // ui-copy-ignore
|
|
54
|
+
textarea: 'textarea', // ui-copy-ignore
|
|
55
|
+
summary: 'summary', // ui-copy-ignore
|
|
56
|
+
label: 'label', // ui-copy-ignore
|
|
57
|
+
role: 'role', // ui-copy-ignore
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The page-side half. Installed on every read; it is a no-op when the registry
|
|
62
|
+
* is already there, which is what makes refs survive reads.
|
|
63
|
+
* @param {string} selector - interactive selector.
|
|
64
|
+
* @param {number} version - registry version.
|
|
65
|
+
* @returns {string} an expression to evaluate.
|
|
66
|
+
*/
|
|
67
|
+
function seed(selector, version) {
|
|
68
|
+
return `(() => {
|
|
69
|
+
const w = window
|
|
70
|
+
const current = w.${REGISTRY}
|
|
71
|
+
if (current && current.version === ${version}) return 'kept'
|
|
72
|
+
const refs = new Map()
|
|
73
|
+
const ids = new WeakMap()
|
|
74
|
+
let seq = 0
|
|
75
|
+
w.${REGISTRY} = {
|
|
76
|
+
version: ${version},
|
|
77
|
+
refs,
|
|
78
|
+
/**
|
|
79
|
+
* Give an element the number it already has, or the next free one.
|
|
80
|
+
* @param {Element} el - element to number.
|
|
81
|
+
* @returns {number} its ref.
|
|
82
|
+
*/
|
|
83
|
+
id(el) {
|
|
84
|
+
const known = ids.get(el)
|
|
85
|
+
if (known !== undefined && refs.get(known) === el) return known
|
|
86
|
+
seq += 1
|
|
87
|
+
ids.set(el, seq)
|
|
88
|
+
refs.set(seq, el)
|
|
89
|
+
return seq
|
|
90
|
+
},
|
|
91
|
+
/** @param {Element} el - element to forget. */
|
|
92
|
+
drop(el) {
|
|
93
|
+
const known = ids.get(el)
|
|
94
|
+
if (known !== undefined) refs.delete(known)
|
|
95
|
+
},
|
|
96
|
+
/**
|
|
97
|
+
* @param {number} ref - number from a read.
|
|
98
|
+
* @returns {{el: Element, ref: number} | null} the element, if still here.
|
|
99
|
+
*/
|
|
100
|
+
find(ref) {
|
|
101
|
+
const el = refs.get(ref)
|
|
102
|
+
if (!el || !el.isConnected) { refs.delete(ref); return null }
|
|
103
|
+
return { el, ref }
|
|
104
|
+
},
|
|
105
|
+
selector: ${JSON.stringify(selector)},
|
|
106
|
+
}
|
|
107
|
+
return 'seeded'
|
|
108
|
+
})()`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read the page. One evaluate, one JSON, no round trip per control.
|
|
113
|
+
* @param {string} mode - what to include: text, links, forms, full.
|
|
114
|
+
* @param {number} budget - characters of page text to keep.
|
|
115
|
+
* @param {number} max - controls to number at most.
|
|
116
|
+
* @returns {string} an expression to evaluate.
|
|
117
|
+
*/
|
|
118
|
+
function snapshot(mode, budget, max) {
|
|
119
|
+
const wanted = JSON.stringify(mode)
|
|
120
|
+
return `(() => {
|
|
121
|
+
const reg = window.${REGISTRY}
|
|
122
|
+
if (!reg) return JSON.stringify({ broken: true })
|
|
123
|
+
const mode = ${wanted}
|
|
124
|
+
const budget = ${budget}
|
|
125
|
+
const max = ${max}
|
|
126
|
+
const name = (el) => {
|
|
127
|
+
const label = el.labels && el.labels[0] ? String(el.labels[0].innerText ?? '') : ''
|
|
128
|
+
const pointing = String(el.getAttribute?.('aria-labelledby') ?? '')
|
|
129
|
+
const named = pointing.split(/\s+/).filter(Boolean).map((id) => {
|
|
130
|
+
const other = el.ownerDocument.getElementById(id)
|
|
131
|
+
return other === null ? '' : String(other.innerText ?? other.textContent ?? '')
|
|
132
|
+
}).join(' ').replace(/\s+/g, ' ').trim()
|
|
133
|
+
const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
|
|
134
|
+
const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
|
|
135
|
+
const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
|
|
136
|
+
return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
|
|
137
|
+
}
|
|
138
|
+
const box = (el) => el.getBoundingClientRect()
|
|
139
|
+
const shown = (el) => {
|
|
140
|
+
const b = box(el)
|
|
141
|
+
if (b.width < 2 || b.height < 2) return false
|
|
142
|
+
const s = getComputedStyle(el)
|
|
143
|
+
if (s.visibility === 'hidden' || s.display === 'none') return false
|
|
144
|
+
if (Number(s.opacity) <= 0.05) return false
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
const inView = (b) => b.bottom > 0 && b.right > 0 && b.top < innerHeight && b.left < innerWidth
|
|
148
|
+
/* Web components keep their controls in a shadow root, where
|
|
149
|
+
querySelectorAll on the document never looks. Open roots are collected
|
|
150
|
+
first, so a control inside one is numbered like any other. */
|
|
151
|
+
const roots = [document]
|
|
152
|
+
for (let i = 0; i < roots.length; i += 1) {
|
|
153
|
+
for (const el of roots[i].querySelectorAll('*')) {
|
|
154
|
+
if (el.shadowRoot) roots.push(el.shadowRoot)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const each = (fn) => {
|
|
158
|
+
for (const root of roots) for (const el of root.querySelectorAll(reg.selector)) fn(el)
|
|
159
|
+
}
|
|
160
|
+
const role = (el) => {
|
|
161
|
+
const explicit = el.getAttribute?.('role')
|
|
162
|
+
if (explicit) return explicit
|
|
163
|
+
const tag = el.tagName.toLowerCase()
|
|
164
|
+
if (tag === 'a') return 'link'
|
|
165
|
+
if (tag === 'summary') return 'button'
|
|
166
|
+
if (tag === 'input') {
|
|
167
|
+
const type = String(el.type ?? 'text')
|
|
168
|
+
if (type === 'checkbox' || type === 'radio' || type === 'submit' || type === 'button' || type === 'search') return type
|
|
169
|
+
return 'input'
|
|
170
|
+
}
|
|
171
|
+
return tag
|
|
172
|
+
}
|
|
173
|
+
const controls = []
|
|
174
|
+
const seen = new Set()
|
|
175
|
+
each((el) => {
|
|
176
|
+
if (controls.length >= max * 3) return
|
|
177
|
+
const b = box(el)
|
|
178
|
+
if (b.width < 2 || b.height < 2) return
|
|
179
|
+
if (!shown(el)) return
|
|
180
|
+
const label = name(el)
|
|
181
|
+
if (label === '' && el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return
|
|
182
|
+
const kind = role(el)
|
|
183
|
+
if (mode === 'links' && kind !== 'link') return
|
|
184
|
+
if (mode === 'forms' && kind !== 'input' && kind !== 'select' && kind !== 'textarea' && kind !== 'btn' && kind !== 'button' && kind !== 'combobox' && kind !== 'checkbox' && kind !== 'radio') return
|
|
185
|
+
const view = inView(b)
|
|
186
|
+
/* Offscreen controls are numbered but sorted last: the person is acting on
|
|
187
|
+
what is on screen, and a scroll should not renumber everything. */
|
|
188
|
+
controls.push({
|
|
189
|
+
ref: reg.id(el),
|
|
190
|
+
kind,
|
|
191
|
+
name: label,
|
|
192
|
+
value: el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '').slice(0, 80) : '',
|
|
193
|
+
checked: el.checked === true,
|
|
194
|
+
disabled: el.disabled === true || el.getAttribute?.('aria-disabled') === 'true',
|
|
195
|
+
view,
|
|
196
|
+
x: Math.round(b.left + b.width / 2),
|
|
197
|
+
y: Math.round(b.top + b.height / 2),
|
|
198
|
+
href: kind === 'link' ? String(el.href ?? '').slice(0, 120) : '',
|
|
199
|
+
})
|
|
200
|
+
seen.add(el)
|
|
201
|
+
})
|
|
202
|
+
/* Refs that pointed at elements now gone are dropped, so a later number
|
|
203
|
+
cannot quietly mean a different control. */
|
|
204
|
+
for (const ref of Array.from(reg.refs.keys())) {
|
|
205
|
+
const el = reg.refs.get(ref)
|
|
206
|
+
if (!el || !el.isConnected) reg.refs.delete(ref)
|
|
207
|
+
}
|
|
208
|
+
controls.sort((a, b) => Number(b.view) - Number(a.view))
|
|
209
|
+
/* Only the first screenful and a bit is worth a line each: an offscreen
|
|
210
|
+
control is a scroll away, and a hundred dead rows cost the reader tokens. */
|
|
211
|
+
const picked = controls.slice(0, max)
|
|
212
|
+
const parts = []
|
|
213
|
+
let used = 0
|
|
214
|
+
let full = 0
|
|
215
|
+
const take = (text) => {
|
|
216
|
+
if (text === '') return
|
|
217
|
+
full += text.length
|
|
218
|
+
if (used >= budget) return
|
|
219
|
+
const room = budget - used
|
|
220
|
+
parts.push(room >= text.length ? text : text.slice(0, room))
|
|
221
|
+
used += Math.min(room, text.length)
|
|
222
|
+
}
|
|
223
|
+
const main = document.querySelector('main, article, [role="main"]') ?? document.body
|
|
224
|
+
const texts = [main, ...roots.slice(1)]
|
|
225
|
+
if (mode !== 'links' && mode !== 'forms') {
|
|
226
|
+
const emitted = new Set()
|
|
227
|
+
for (const root of texts) {
|
|
228
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
229
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
230
|
+
const text = String(node.nodeValue ?? '').replace(/\\s+/g, ' ').trim()
|
|
231
|
+
const parent = node.parentElement
|
|
232
|
+
if (text === '' || !parent || emitted.has(parent)) continue
|
|
233
|
+
const b = box(parent)
|
|
234
|
+
const style = getComputedStyle(parent)
|
|
235
|
+
const fits = style.display !== 'none' && style.visibility !== 'hidden' && b.width > 1 && b.height > 1
|
|
236
|
+
if (!fits || !inView(b)) continue
|
|
237
|
+
emitted.add(parent)
|
|
238
|
+
take(text)
|
|
239
|
+
if (parent.tagName === 'P' || parent.tagName === 'DIV' || parent.tagName === 'LI' || /^H[1-6]$/.test(parent.tagName)) take('\\n')
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return JSON.stringify({
|
|
244
|
+
url: location.href, // ui-copy-ignore
|
|
245
|
+
title: document.title, // ui-copy-ignore
|
|
246
|
+
text: parts.join(' ').replace(/\\n /g, '\\n').replace(/ {2,}/g, ' ').trim(),
|
|
247
|
+
textChars: full,
|
|
248
|
+
scroll: { y: Math.round(scrollY), height: Math.round(document.documentElement.scrollHeight), viewport: Math.round(innerHeight) },
|
|
249
|
+
focused: (() => { // ui-copy-ignore
|
|
250
|
+
const el = document.activeElement
|
|
251
|
+
if (!el || el === document.body) return null
|
|
252
|
+
return { ref: reg.id(el), kind: role(el), name: name(el) }
|
|
253
|
+
})(),
|
|
254
|
+
controls: picked,
|
|
255
|
+
})
|
|
256
|
+
})()`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve a ref at action time, so a page that moved under the last read is
|
|
261
|
+
* clicked where the control is now, not where it was.
|
|
262
|
+
* @param {number} ref - ref from a read.
|
|
263
|
+
* @returns {string} an expression to evaluate.
|
|
264
|
+
*/
|
|
265
|
+
export function resolve(ref) {
|
|
266
|
+
return `(() => {
|
|
267
|
+
const reg = window.${REGISTRY}
|
|
268
|
+
if (!reg) return JSON.stringify({ gone: true })
|
|
269
|
+
const hit = reg.find(${Number(ref)})
|
|
270
|
+
if (!hit) return JSON.stringify({ gone: true })
|
|
271
|
+
const el = hit.el
|
|
272
|
+
const b = el.getBoundingClientRect()
|
|
273
|
+
if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
|
|
274
|
+
if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
275
|
+
const after = el.getBoundingClientRect()
|
|
276
|
+
const s = getComputedStyle(el)
|
|
277
|
+
return JSON.stringify({ gone: false, x: Math.round(after.left + after.width / 2), y: Math.round(after.top + after.height / 2), name: String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? '').trim().slice(0, 80), kind: String(el.tagName).toLowerCase(), hidden: s.visibility === 'hidden' || s.display === 'none', disabled: el.disabled === true })
|
|
278
|
+
})()`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* What sits on top of a control at click time, when it is not the control.
|
|
283
|
+
* A cookie banner or a modal over the thing the agent meant to click is the
|
|
284
|
+
* commonest quiet failure on real pages: the click lands, nothing happens.
|
|
285
|
+
* @param {number} ref - ref from a read.
|
|
286
|
+
* @returns {string} an expression that answers `{tag, text, dismiss}` or an empty string.
|
|
287
|
+
*/
|
|
288
|
+
export function covering(ref) {
|
|
289
|
+
return `(() => {
|
|
290
|
+
const reg = window.${REGISTRY}
|
|
291
|
+
if (!reg) return ''
|
|
292
|
+
const hit = reg.find(${Number(ref)})
|
|
293
|
+
if (!hit) return ''
|
|
294
|
+
const el = hit.el
|
|
295
|
+
const b = el.getBoundingClientRect()
|
|
296
|
+
const x = Math.round(b.left + b.width / 2)
|
|
297
|
+
const y = Math.round(b.top + b.height / 2)
|
|
298
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return ''
|
|
299
|
+
const top = document.elementFromPoint(x, y)
|
|
300
|
+
if (top === null || top === el || el.contains(top) || top.contains(el)) return ''
|
|
301
|
+
const raw = String(top.innerText ?? top.value ?? top.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 80)
|
|
302
|
+
/* The thing in the way usually carries its own way out, and taking it is the
|
|
303
|
+
step the agent would take next anyway. Find that control while the overlay
|
|
304
|
+
is already being measured, so clearing it costs no extra round trip. The
|
|
305
|
+
words are a narrow safe list on purpose: an overlay that offers to delete,
|
|
306
|
+
pay or confirm is reported, never dismissed on the person's behalf. */
|
|
307
|
+
let scope = top
|
|
308
|
+
for (let node = top.parentElement; node !== null && node !== document.body; node = node.parentElement) {
|
|
309
|
+
const r = node.getBoundingClientRect()
|
|
310
|
+
if (r.width >= innerWidth * 0.6 && r.height >= innerHeight * 0.5) scope = node
|
|
311
|
+
}
|
|
312
|
+
const box = scope.getBoundingClientRect()
|
|
313
|
+
const wide = box.width >= innerWidth * 0.6 && box.height >= innerHeight * 0.5
|
|
314
|
+
const claimed = String(scope.innerText ?? '').replace(/\\s+/g, ' ').slice(0, 400)
|
|
315
|
+
const risky = /(delete|remove|purchase|payment|pay now|buy now|sign out|log out|transfer|删除|支付|购买|付款|注销|退出登录|转账)/i.test(claimed)
|
|
316
|
+
const words = /^(accept|accept all|agree|allow|allow all|ok|okay|got it|close|dismiss|i agree|接受|同意|允许|允许全部|关闭|确定|知道了|好的)$/i
|
|
317
|
+
let dismiss = null
|
|
318
|
+
if (wide && !risky) {
|
|
319
|
+
const cands = scope.querySelectorAll('button,[role=button],a,input[type=button],input[type=submit]')
|
|
320
|
+
for (const node of [scope, ...cands]) {
|
|
321
|
+
if (node === el || el.contains(node) || node.contains(el)) continue
|
|
322
|
+
const name = String(node.innerText ?? node.value ?? node.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 40)
|
|
323
|
+
if (name === '' || !words.test(name)) continue
|
|
324
|
+
const r = node.getBoundingClientRect()
|
|
325
|
+
if (r.width < 2 || r.height < 2) continue
|
|
326
|
+
if (r.top < 0 || r.left < 0 || r.bottom > innerHeight || r.right > innerWidth) continue
|
|
327
|
+
dismiss = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), name }
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return JSON.stringify({ tag: String(top.tagName).toLowerCase(), text: raw, dismiss })
|
|
332
|
+
})()`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Where refs of controls inside iframes start. Main-frame refs stay small. */
|
|
336
|
+
const FRAME_BASE = 900_000
|
|
337
|
+
|
|
338
|
+
/** Room for the refs of one frame between FRAME_BASE steps. */
|
|
339
|
+
const FRAME_SLOT = 100_000
|
|
340
|
+
|
|
341
|
+
/** A frame is addressed either by its world or by the session of its own process. */
|
|
342
|
+
function asRoute(route) {
|
|
343
|
+
if (route === undefined || route === null) return {}
|
|
344
|
+
if (typeof route === 'number') return { contextId: route }
|
|
345
|
+
return route
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Evaluate in a page, in one frame of it, or in the process of a frame Chrome
|
|
350
|
+
* keeps apart.
|
|
351
|
+
* @param {any} page - sidecar page.
|
|
352
|
+
* @param {string} expression - JavaScript.
|
|
353
|
+
* @param {number|{contextId?: number, sessionId?: string}} [route] - where to run it.
|
|
354
|
+
* @returns {Promise<any>} the JSON-safe value.
|
|
355
|
+
*/
|
|
356
|
+
async function evaluateIn(page, expression, route) {
|
|
357
|
+
const where = asRoute(route)
|
|
358
|
+
const params = { expression, returnByValue: true }
|
|
359
|
+
if (where.contextId !== undefined) params.contextId = where.contextId
|
|
360
|
+
const { result } = await page.cdp.send('Runtime.evaluate', params, where.sessionId)
|
|
361
|
+
return result?.value
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** @param {any} page - sidecar page. @param {any} [route] - world to seed. */
|
|
365
|
+
async function seedPage(page, route) {
|
|
366
|
+
await evaluateIn(page, seed(SELECTOR, VERSION), route)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @param {any} page - sidecar page.
|
|
371
|
+
* @param {string} mode - read mode.
|
|
372
|
+
* @param {number} budget - text budget.
|
|
373
|
+
* @param {number} max - control cap.
|
|
374
|
+
* @param {any} [route] - world to read.
|
|
375
|
+
* @returns {Promise<any>} the snapshot.
|
|
376
|
+
*/
|
|
377
|
+
async function evaluateSnapshot(page, mode, budget, max, route) {
|
|
378
|
+
const raw = String((await evaluateIn(page, snapshot(mode, budget, max), route)) ?? '{}')
|
|
379
|
+
try {
|
|
380
|
+
return JSON.parse(raw)
|
|
381
|
+
} catch {
|
|
382
|
+
return { broken: true }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Where an iframe sits on the page. A ref inside a frame is resolved against
|
|
388
|
+
* the frame's own viewport, so its point needs the frame's corner added.
|
|
389
|
+
* @param {any} page - sidecar page.
|
|
390
|
+
* @param {{slot?: number, contextId?: number, sessionId?: string}} route - the frame.
|
|
391
|
+
* @returns {Promise<{x: number, y: number}>} the offset.
|
|
392
|
+
*/
|
|
393
|
+
async function frameOffset(page, route) {
|
|
394
|
+
const where = asRoute(route)
|
|
395
|
+
if (where.contextId !== undefined) {
|
|
396
|
+
const own = await evaluateIn(page, `(() => { try {
|
|
397
|
+
const frame = window.frameElement
|
|
398
|
+
if (!frame) return null
|
|
399
|
+
const b = frame.getBoundingClientRect()
|
|
400
|
+
return { x: Math.round(b.left), y: Math.round(b.top) }
|
|
401
|
+
} catch (error) { return null } })()`, where)
|
|
402
|
+
if (own !== null && own !== undefined && Number.isFinite(own.x)) return own
|
|
403
|
+
}
|
|
404
|
+
if (where.frameId !== undefined && typeof page.frameBox === 'function') {
|
|
405
|
+
const own = await page.frameBox(where.frameId).catch(() => null)
|
|
406
|
+
if (own !== null) return own
|
|
407
|
+
}
|
|
408
|
+
const index = Math.max(0, Number(where.slot ?? 1) - 1)
|
|
409
|
+
const fallback = await evaluateIn(page, `(() => {
|
|
410
|
+
const frame = document.querySelectorAll('iframe')[${index}]
|
|
411
|
+
if (!frame) return null
|
|
412
|
+
const b = frame.getBoundingClientRect()
|
|
413
|
+
return { x: Math.round(b.left), y: Math.round(b.top) }
|
|
414
|
+
})()`)
|
|
415
|
+
if (fallback !== null && fallback !== undefined && Number.isFinite(fallback.x)) return fallback
|
|
416
|
+
return { x: 0, y: 0 }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Where a frame's refs live: its slot in the frame tree, and how to reach it —
|
|
421
|
+
* by world when it shares the page's process, by session when Chrome runs it
|
|
422
|
+
* apart.
|
|
423
|
+
* @param {any} page - sidecar page.
|
|
424
|
+
* @param {any} frame - one entry from the frame list.
|
|
425
|
+
* @param {number} slot - its position under the main frame.
|
|
426
|
+
* @returns {{slot: number, contextId?: number, sessionId?: string, frameId?: string}|null} the route.
|
|
427
|
+
*/
|
|
428
|
+
function routeOf(page, frame, slot) {
|
|
429
|
+
const sessionId = frame.contextId === undefined && typeof page.childSession === 'function' ? page.childSession(frame.frameId) : undefined
|
|
430
|
+
if (frame.contextId === undefined && sessionId === undefined) return null
|
|
431
|
+
return { slot, contextId: frame.contextId, sessionId, frameId: frame.frameId }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Read the frames under the page, so a control inside an iframe is numbered
|
|
436
|
+
* like any other. A frame Chrome runs in its own process is read through the
|
|
437
|
+
* session of that process; the corner it sits at still comes from the page.
|
|
438
|
+
* @param {any} page - sidecar page.
|
|
439
|
+
* @param {string} mode - read mode.
|
|
440
|
+
* @param {number} budget - characters of text to keep.
|
|
441
|
+
* @returns {Promise<{text: string, controls: any[]}>} what the frames add.
|
|
442
|
+
*/
|
|
443
|
+
async function readFrames(page, mode, budget) {
|
|
444
|
+
if (typeof page.frameList !== 'function') return { text: '', controls: [] }
|
|
445
|
+
let frames = []
|
|
446
|
+
try {
|
|
447
|
+
frames = await page.frameList()
|
|
448
|
+
} catch (error) {
|
|
449
|
+
return { text: '', controls: [] }
|
|
450
|
+
}
|
|
451
|
+
const children = frames.slice(1)
|
|
452
|
+
const controls = []
|
|
453
|
+
let text = ''
|
|
454
|
+
for (let slot = 0; slot < children.length && slot < 8; slot += 1) {
|
|
455
|
+
const route = routeOf(page, children[slot], slot + 1)
|
|
456
|
+
if (route === null) continue
|
|
457
|
+
try {
|
|
458
|
+
await seedPage(page, route)
|
|
459
|
+
const inside = await evaluateSnapshot(page, mode, Math.min(budget, 600), 40, route)
|
|
460
|
+
if (inside?.broken === true) continue
|
|
461
|
+
const offset = await frameOffset(page, route)
|
|
462
|
+
const tag = FRAME_BASE + slot * FRAME_SLOT
|
|
463
|
+
for (const item of inside.controls ?? []) {
|
|
464
|
+
controls.push({ ...item, ref: tag + Number(item.ref), frame: slot + 1, x: Number(item.x ?? 0) + offset.x, y: Number(item.y ?? 0) + offset.y })
|
|
465
|
+
}
|
|
466
|
+
if (text.length < 400 && typeof inside.text === 'string' && inside.text !== '') text += `${text === '' ? '' : '\n'}${inside.text.slice(0, 400)}`
|
|
467
|
+
} catch (error) {
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return { text, controls }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Decode a ref into the frame it lives in and the number that frame knows. */
|
|
475
|
+
function splitFrameRef(ref) {
|
|
476
|
+
const number = Number(ref)
|
|
477
|
+
if (!Number.isFinite(number) || number < FRAME_BASE) return null
|
|
478
|
+
const slot = Math.floor((number - FRAME_BASE) / FRAME_SLOT)
|
|
479
|
+
return { slot, local: number - FRAME_BASE - slot * FRAME_SLOT }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Where a ref lives, for a tool that has to look the element up itself.
|
|
484
|
+
* @param {any} page - sidecar page.
|
|
485
|
+
* @param {number} ref - ref from a read.
|
|
486
|
+
* @returns {Promise<{slot: number, local: number, contextId?: number, sessionId?: string, frameId?: string}|null>} the route, or nothing for the page itself.
|
|
487
|
+
*/
|
|
488
|
+
export async function contextFor(page, ref) {
|
|
489
|
+
const split = splitFrameRef(ref)
|
|
490
|
+
if (split === null) return null
|
|
491
|
+
if (typeof page.frameList !== 'function') return null
|
|
492
|
+
const frames = await page.frameList().catch(() => [])
|
|
493
|
+
const frame = frames[split.slot + 1]
|
|
494
|
+
if (frame === undefined) return null
|
|
495
|
+
const route = routeOf(page, frame, split.slot + 1)
|
|
496
|
+
if (route === null) return null
|
|
497
|
+
return { ...route, local: split.local }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Read the page and hand back the shape the renderer wants.
|
|
502
|
+
* @param {any} page - sidecar page.
|
|
503
|
+
* @param {{mode?: string, maxChars?: number, max?: number}} [options] - how much to read.
|
|
504
|
+
* @returns {Promise<{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}>} the read.
|
|
505
|
+
*/
|
|
506
|
+
export async function readPage(page, options = {}) {
|
|
507
|
+
const mode = String(options.mode ?? 'text')
|
|
508
|
+
const maxChars = Number.isFinite(Number(options.maxChars)) ? Math.max(400, Math.min(24_000, Number(options.maxChars))) : 6000
|
|
509
|
+
const max = Number.isFinite(Number(options.max)) ? Math.max(5, Math.min(200, Number(options.max))) : 90
|
|
510
|
+
await seedPage(page)
|
|
511
|
+
let read = await evaluateSnapshot(page, mode, maxChars, max)
|
|
512
|
+
if (read?.broken === true) {
|
|
513
|
+
await seedPage(page)
|
|
514
|
+
read = await evaluateSnapshot(page, mode, maxChars, max)
|
|
515
|
+
}
|
|
516
|
+
const framed = await readFrames(page, mode, Math.min(maxChars, 1200))
|
|
517
|
+
if (framed.controls.length > 0) read = { ...read, controls: [...(read.controls ?? []), ...framed.controls] }
|
|
518
|
+
if (framed.text !== '') read = { ...read, text: `${String(read.text ?? '')}${String(read.text ?? '') === '' ? '' : '\n'}${framed.text}` }
|
|
519
|
+
return {
|
|
520
|
+
url: String(read?.url ?? page.url),
|
|
521
|
+
title: String(read?.title ?? page.title),
|
|
522
|
+
text: String(read?.text ?? ''),
|
|
523
|
+
textChars: Number(read?.textChars ?? 0),
|
|
524
|
+
scroll: read?.scroll,
|
|
525
|
+
focused: read?.focused ?? null,
|
|
526
|
+
controls: Array.isArray(read?.controls) ? read.controls : [],
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Resolve a ref into a point, at the moment of the action.
|
|
532
|
+
* @param {any} page - sidecar page.
|
|
533
|
+
* @param {number} ref - ref from a read.
|
|
534
|
+
* @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
|
|
535
|
+
*/
|
|
536
|
+
export async function locate(page, ref) {
|
|
537
|
+
const split = splitFrameRef(ref)
|
|
538
|
+
let route = null
|
|
539
|
+
if (split !== null) {
|
|
540
|
+
route = await contextFor(page, ref)
|
|
541
|
+
if (route === null) return { gone: true }
|
|
542
|
+
}
|
|
543
|
+
const said = await evaluateIn(page, resolve(split === null ? ref : route.local), route).catch(() => undefined)
|
|
544
|
+
let spot = { gone: true }
|
|
545
|
+
try {
|
|
546
|
+
spot = JSON.parse(String(said ?? '{"gone":true}'))
|
|
547
|
+
} catch (error) {
|
|
548
|
+
spot = { gone: true }
|
|
549
|
+
}
|
|
550
|
+
if (split === null || spot.gone === true) return spot
|
|
551
|
+
const offset = await frameOffset(page, route)
|
|
552
|
+
return { ...spot, x: Number(spot.x ?? 0) + offset.x, y: Number(spot.y ?? 0) + offset.y }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Roles that read better without a sigil. */
|
|
556
|
+
function sigilOf(control) {
|
|
557
|
+
const kind = String(control.kind ?? '')
|
|
558
|
+
if (kind === 'link') return 'link'
|
|
559
|
+
if (kind === 'button') return 'btn'
|
|
560
|
+
if (kind === 'input' || kind === 'search') return 'input'
|
|
561
|
+
if (kind === 'checkbox' || kind === 'radio') return kind
|
|
562
|
+
return SIGIL[kind] ?? kind ?? 'el'
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Render a read as compact text, controls first, then the viewport's words.
|
|
567
|
+
* @param {{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}} read - page read.
|
|
568
|
+
* @param {{mode?: string, maxChars?: number}} [options] - how it was read.
|
|
569
|
+
* @returns {string} the text a model sees.
|
|
570
|
+
*/
|
|
571
|
+
export function renderPage(read, options = {}) {
|
|
572
|
+
const mode = String(options.mode ?? 'text')
|
|
573
|
+
const maxChars = Number(options.maxChars ?? 6000)
|
|
574
|
+
const lines = [read.url]
|
|
575
|
+
if (read.title !== '') lines.push(`# ${read.title}`)
|
|
576
|
+
const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
|
|
577
|
+
const hidden = read.controls.length - shown.length
|
|
578
|
+
for (const control of shown) {
|
|
579
|
+
let line = `${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`
|
|
580
|
+
if (control.kind === 'checkbox' || control.kind === 'radio') line += control.checked ? ' (checked)' : ' (unchecked)'
|
|
581
|
+
else if (control.value !== '' && control.value !== control.name) line += ` = ${control.value}`
|
|
582
|
+
if (control.disabled) line += ' (disabled)'
|
|
583
|
+
if (mode === 'links' && control.href !== '') line += ` → ${control.href}`
|
|
584
|
+
lines.push(line)
|
|
585
|
+
}
|
|
586
|
+
if (hidden > 0 && mode !== 'links' && mode !== 'forms') lines.push(`… ${hidden} more controls off screen`)
|
|
587
|
+
if (read.focused) lines.push(`focus ${read.focused.ref} [${read.focused.kind}] ${read.focused.name}`)
|
|
588
|
+
const body = read.text
|
|
589
|
+
if (body !== '') {
|
|
590
|
+
lines.push('')
|
|
591
|
+
lines.push(body)
|
|
592
|
+
if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
|
|
593
|
+
}
|
|
594
|
+
if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
|
|
595
|
+
lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
|
|
596
|
+
}
|
|
597
|
+
return lines.join('\n').slice(0, Math.max(600, maxChars) + 600)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** A ref that was valid on the previous read and is no longer here. */
|
|
601
|
+
export const REF_GONE = 'That control is gone: the page changed since the read. Call sidecar_view and use the new number.'
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The control itself, not its number: what it is, what it is called, and where
|
|
605
|
+
* it sat. Kept from the read that produced the ref so a re-render can be
|
|
606
|
+
* survived without asking the person to read the page again.
|
|
607
|
+
* @param {any} control - a control from a read.
|
|
608
|
+
* @returns {{kind: string, name: string, href: string, inert: boolean}} what to look for.
|
|
609
|
+
*/
|
|
610
|
+
export function identityOf(control) {
|
|
611
|
+
const kind = String(control?.kind ?? '')
|
|
612
|
+
const name = String(control?.name ?? '')
|
|
613
|
+
const href = String(control?.href ?? '')
|
|
614
|
+
const inert = ['input', 'select', 'textarea', 'password', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(kind)
|
|
615
|
+
return { kind, name, href, inert }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Whether a control read now is the one described by an identity: the same
|
|
620
|
+
* words, and not a container that happens to contain them.
|
|
621
|
+
* @param {{kind: string, name: string, href: string, inert: boolean}} want - identity.
|
|
622
|
+
* @param {any} control - a control from the current read.
|
|
623
|
+
* @returns {boolean} true when this is the same control.
|
|
624
|
+
*/
|
|
625
|
+
export function sameThing(want, control) {
|
|
626
|
+
/* Nothing to look for is not a licence to click whatever is nearest. */
|
|
627
|
+
if (want.name === '') return false
|
|
628
|
+
if (String(control?.name ?? '') !== want.name) return false
|
|
629
|
+
if (String(control?.kind ?? '') !== want.kind) return false
|
|
630
|
+
/* A field and the button beside it can share their words; a field never
|
|
631
|
+
becomes a button, so the kind must match exactly rather than loosely. */
|
|
632
|
+
if (want.inert && !['input', 'select', 'textarea', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(String(control?.kind ?? ''))) return false
|
|
633
|
+
if (want.href !== '' && String(control?.href ?? '') !== want.href) return false
|
|
634
|
+
return true
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
639
|
+
*
|
|
640
|
+
* `{field: "Email"}` used to mean "click the control that says Email". On a real
|
|
641
|
+
* form the words live in a div above a bare input, so the click landed on the
|
|
642
|
+
* text and the typing went wherever focus already was — the classic silent wrong
|
|
643
|
+
* field. This finds the *editable* element the words point at: a real label, an
|
|
644
|
+
* aria name, a placeholder, a name attribute, the text right before it, or the
|
|
645
|
+
* text of the small wrapper it sits in.
|
|
646
|
+
* @param {string} words - what the field is called.
|
|
647
|
+
* @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
|
|
648
|
+
*/
|
|
649
|
+
export function labelSpot(words) {
|
|
650
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
651
|
+
return `(() => {
|
|
652
|
+
const want = ${wanted}
|
|
653
|
+
if (want === '') return JSON.stringify({})
|
|
654
|
+
const roots = [document]
|
|
655
|
+
for (const el of document.querySelectorAll('*')) {
|
|
656
|
+
if (roots.length > 400) break
|
|
657
|
+
if (el.shadowRoot) roots.push(el.shadowRoot)
|
|
658
|
+
}
|
|
659
|
+
const norm = (s) => String(s ?? '').replace(/\\s+/g, ' ').trim()
|
|
660
|
+
const short = (s) => { const t = norm(s); return t !== '' && t.length <= 80 ? t : '' }
|
|
661
|
+
const editable = (el) => {
|
|
662
|
+
const tag = el.tagName
|
|
663
|
+
if (tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
664
|
+
if (tag === 'INPUT') {
|
|
665
|
+
const type = String(el.type ?? 'text').toLowerCase()
|
|
666
|
+
return !['hidden', 'button', 'submit', 'reset', 'checkbox', 'radio', 'file', 'image', 'range', 'color'].includes(type)
|
|
667
|
+
}
|
|
668
|
+
if (el.isContentEditable === true) return true
|
|
669
|
+
return ['textbox', 'searchbox', 'combobox'].includes(String(el.getAttribute('role') ?? ''))
|
|
670
|
+
}
|
|
671
|
+
const shown = (el) => {
|
|
672
|
+
const b = el.getBoundingClientRect()
|
|
673
|
+
if (b.width < 2 || b.height < 2) return false
|
|
674
|
+
const s = getComputedStyle(el)
|
|
675
|
+
return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05
|
|
676
|
+
}
|
|
677
|
+
const names = (el) => {
|
|
678
|
+
const out = []
|
|
679
|
+
const add = (v) => { const t = short(v); if (t !== '') out.push(t) }
|
|
680
|
+
if (el.labels) for (const l of el.labels) add(l.innerText ?? l.textContent)
|
|
681
|
+
for (const id of String(el.getAttribute('aria-labelledby') ?? '').split(/\\s+/).filter(Boolean)) {
|
|
682
|
+
const other = document.getElementById(id)
|
|
683
|
+
if (other) add(other.innerText ?? other.textContent)
|
|
684
|
+
}
|
|
685
|
+
add(el.getAttribute('aria-label'))
|
|
686
|
+
add(el.getAttribute('placeholder'))
|
|
687
|
+
add(el.getAttribute('title'))
|
|
688
|
+
add(el.getAttribute('name'))
|
|
689
|
+
if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
|
|
690
|
+
const prev = el.previousElementSibling
|
|
691
|
+
if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
|
|
692
|
+
const parent = el.parentElement
|
|
693
|
+
if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
|
|
694
|
+
return out
|
|
695
|
+
}
|
|
696
|
+
let best = null
|
|
697
|
+
for (const root of roots) {
|
|
698
|
+
for (const el of root.querySelectorAll('input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]')) {
|
|
699
|
+
if (editable(el) !== true || shown(el) !== true) continue
|
|
700
|
+
let score = 0
|
|
701
|
+
let name = ''
|
|
702
|
+
for (const nm of names(el)) {
|
|
703
|
+
const low = nm.toLowerCase()
|
|
704
|
+
if (low === want) score = Math.max(score, 3)
|
|
705
|
+
else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
|
|
706
|
+
else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
|
|
707
|
+
if (score > 0 && name === '') name = nm
|
|
708
|
+
}
|
|
709
|
+
if (score === 0) continue
|
|
710
|
+
if (best === null || score > best.score) best = { el, score, name }
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (best === null) return JSON.stringify({})
|
|
714
|
+
best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
715
|
+
const b = best.el.getBoundingClientRect()
|
|
716
|
+
return JSON.stringify({ x: Math.round(b.left + b.width / 2), y: Math.round(b.top + b.height / 2), name: best.name, score: best.score })
|
|
717
|
+
})()`
|
|
718
|
+
}
|