dsh-inner-browser-use 0.13.0 → 0.27.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 +224 -5
- package/docs/ROADMAP.md +129 -3
- package/docs/VERIFY.md +69 -2
- package/index.js +3 -0
- package/package.json +9 -3
- package/read.js +981 -0
- package/sidecar.js +42 -1
- package/tools.js +1749 -0
package/read.js
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
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
|
+
/* A drawing surface has no words in the tree, but it is the whole page in
|
|
47
|
+
some products: number it, and let a picture be taken of it. */
|
|
48
|
+
'canvas',
|
|
49
|
+
].join(',')
|
|
50
|
+
|
|
51
|
+
/** How a role is written in the compact read: short enough to cost little. */
|
|
52
|
+
const SIGIL = { // ui-copy-ignore
|
|
53
|
+
a: 'link', // ui-copy-ignore
|
|
54
|
+
button: 'btn', // ui-copy-ignore
|
|
55
|
+
input: 'input', // ui-copy-ignore
|
|
56
|
+
select: 'select', // ui-copy-ignore
|
|
57
|
+
textarea: 'textarea', // ui-copy-ignore
|
|
58
|
+
summary: 'summary', // ui-copy-ignore
|
|
59
|
+
label: 'label', // ui-copy-ignore
|
|
60
|
+
role: 'role', // ui-copy-ignore
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The page-side half. Installed on every read; it is a no-op when the registry
|
|
65
|
+
* is already there, which is what makes refs survive reads.
|
|
66
|
+
* @param {string} selector - interactive selector.
|
|
67
|
+
* @param {number} version - registry version.
|
|
68
|
+
* @returns {string} an expression to evaluate.
|
|
69
|
+
*/
|
|
70
|
+
function seed(selector, version) {
|
|
71
|
+
return `(() => {
|
|
72
|
+
const w = window
|
|
73
|
+
const current = w.${REGISTRY}
|
|
74
|
+
if (current && current.version === ${version}) return 'kept'
|
|
75
|
+
const refs = new Map()
|
|
76
|
+
const ids = new WeakMap()
|
|
77
|
+
let seq = 0
|
|
78
|
+
w.${REGISTRY} = {
|
|
79
|
+
version: ${version},
|
|
80
|
+
refs,
|
|
81
|
+
/**
|
|
82
|
+
* Give an element the number it already has, or the next free one.
|
|
83
|
+
* @param {Element} el - element to number.
|
|
84
|
+
* @returns {number} its ref.
|
|
85
|
+
*/
|
|
86
|
+
id(el) {
|
|
87
|
+
const known = ids.get(el)
|
|
88
|
+
if (known !== undefined && refs.get(known) === el) return known
|
|
89
|
+
seq += 1
|
|
90
|
+
ids.set(el, seq)
|
|
91
|
+
refs.set(seq, el)
|
|
92
|
+
return seq
|
|
93
|
+
},
|
|
94
|
+
/** @param {Element} el - element to forget. */
|
|
95
|
+
drop(el) {
|
|
96
|
+
const known = ids.get(el)
|
|
97
|
+
if (known !== undefined) refs.delete(known)
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* @param {number} ref - number from a read.
|
|
101
|
+
* @returns {{el: Element, ref: number} | null} the element, if still here.
|
|
102
|
+
*/
|
|
103
|
+
find(ref) {
|
|
104
|
+
const el = refs.get(ref)
|
|
105
|
+
if (!el || !el.isConnected) { refs.delete(ref); return null }
|
|
106
|
+
return { el, ref }
|
|
107
|
+
},
|
|
108
|
+
selector: ${JSON.stringify(selector)},
|
|
109
|
+
}
|
|
110
|
+
return 'seeded'
|
|
111
|
+
})()`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the page. One evaluate, one JSON, no round trip per control.
|
|
116
|
+
* @param {string} mode - what to include: text, links, forms, full.
|
|
117
|
+
* @param {number} budget - characters of page text to keep.
|
|
118
|
+
* @param {number} max - controls to number at most.
|
|
119
|
+
* @returns {string} an expression to evaluate.
|
|
120
|
+
*/
|
|
121
|
+
function snapshot(mode, budget, max) {
|
|
122
|
+
const wanted = JSON.stringify(mode)
|
|
123
|
+
return `(() => {
|
|
124
|
+
const reg = window.${REGISTRY}
|
|
125
|
+
if (!reg) return JSON.stringify({ broken: true })
|
|
126
|
+
const mode = ${wanted}
|
|
127
|
+
const budget = ${budget}
|
|
128
|
+
const max = ${max}
|
|
129
|
+
const name = (el) => {
|
|
130
|
+
const label = el.labels && el.labels[0] ? String(el.labels[0].innerText ?? '') : ''
|
|
131
|
+
const pointing = String(el.getAttribute?.('aria-labelledby') ?? '')
|
|
132
|
+
const named = pointing.split(/\s+/).filter(Boolean).map((id) => {
|
|
133
|
+
const other = el.ownerDocument.getElementById(id)
|
|
134
|
+
return other === null ? '' : String(other.innerText ?? other.textContent ?? '')
|
|
135
|
+
}).join(' ').replace(/\s+/g, ' ').trim()
|
|
136
|
+
const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
|
|
137
|
+
const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
|
|
138
|
+
const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
|
|
139
|
+
if (el.tagName === 'CANVAS' && label === '' && own === '' && text === '' && value === '') {
|
|
140
|
+
const b = el.getBoundingClientRect()
|
|
141
|
+
return 'drawing ' + Math.round(b.width) + 'x' + Math.round(b.height)
|
|
142
|
+
}
|
|
143
|
+
return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
|
|
144
|
+
}
|
|
145
|
+
const box = (el) => el.getBoundingClientRect()
|
|
146
|
+
const shown = (el) => {
|
|
147
|
+
const b = box(el)
|
|
148
|
+
if (b.width < 2 || b.height < 2) return false
|
|
149
|
+
const s = getComputedStyle(el)
|
|
150
|
+
if (s.visibility === 'hidden' || s.display === 'none') return false
|
|
151
|
+
if (Number(s.opacity) <= 0.05) return false
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
const inView = (b) => b.bottom > 0 && b.right > 0 && b.top < innerHeight && b.left < innerWidth
|
|
155
|
+
/* Web components keep their controls in a shadow root, where
|
|
156
|
+
querySelectorAll on the document never looks. Open roots are collected
|
|
157
|
+
first, so a control inside one is numbered like any other. */
|
|
158
|
+
const roots = [document]
|
|
159
|
+
for (let i = 0; i < roots.length; i += 1) {
|
|
160
|
+
for (const el of roots[i].querySelectorAll('*')) {
|
|
161
|
+
if (el.shadowRoot) roots.push(el.shadowRoot)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const each = (fn) => {
|
|
165
|
+
for (const root of roots) for (const el of root.querySelectorAll(reg.selector)) fn(el)
|
|
166
|
+
}
|
|
167
|
+
const role = (el) => {
|
|
168
|
+
const explicit = el.getAttribute?.('role')
|
|
169
|
+
if (explicit) return explicit
|
|
170
|
+
const tag = el.tagName.toLowerCase()
|
|
171
|
+
if (tag === 'a') return 'link'
|
|
172
|
+
if (tag === 'summary') return 'button'
|
|
173
|
+
if (tag === 'input') {
|
|
174
|
+
const type = String(el.type ?? 'text')
|
|
175
|
+
if (type === 'checkbox' || type === 'radio' || type === 'submit' || type === 'button' || type === 'search') return type
|
|
176
|
+
return 'input'
|
|
177
|
+
}
|
|
178
|
+
return tag
|
|
179
|
+
}
|
|
180
|
+
const controls = []
|
|
181
|
+
const seen = new Set()
|
|
182
|
+
each((el) => {
|
|
183
|
+
if (controls.length >= max * 3) return
|
|
184
|
+
const b = box(el)
|
|
185
|
+
if (b.width < 2 || b.height < 2) return
|
|
186
|
+
if (!shown(el)) return
|
|
187
|
+
const label = name(el)
|
|
188
|
+
if (label === '' && el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return
|
|
189
|
+
const kind = role(el)
|
|
190
|
+
if (mode === 'links' && kind !== 'link') return
|
|
191
|
+
if (mode === 'forms' && kind !== 'input' && kind !== 'select' && kind !== 'textarea' && kind !== 'btn' && kind !== 'button' && kind !== 'combobox' && kind !== 'checkbox' && kind !== 'radio') return
|
|
192
|
+
const view = inView(b)
|
|
193
|
+
/* Offscreen controls are numbered but sorted last: the person is acting on
|
|
194
|
+
what is on screen, and a scroll should not renumber everything. */
|
|
195
|
+
controls.push({
|
|
196
|
+
ref: reg.id(el),
|
|
197
|
+
kind,
|
|
198
|
+
name: label,
|
|
199
|
+
value: el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '').slice(0, 80) : '',
|
|
200
|
+
checked: el.checked === true,
|
|
201
|
+
disabled: el.disabled === true || el.getAttribute?.('aria-disabled') === 'true',
|
|
202
|
+
view,
|
|
203
|
+
x: Math.round(b.left + b.width / 2),
|
|
204
|
+
y: Math.round(b.top + b.height / 2),
|
|
205
|
+
href: kind === 'link' ? String(el.href ?? '').slice(0, 120) : '',
|
|
206
|
+
})
|
|
207
|
+
seen.add(el)
|
|
208
|
+
})
|
|
209
|
+
/* Refs that pointed at elements now gone are dropped, so a later number
|
|
210
|
+
cannot quietly mean a different control. */
|
|
211
|
+
for (const ref of Array.from(reg.refs.keys())) {
|
|
212
|
+
const el = reg.refs.get(ref)
|
|
213
|
+
if (!el || !el.isConnected) reg.refs.delete(ref)
|
|
214
|
+
}
|
|
215
|
+
controls.sort((a, b) => Number(b.view) - Number(a.view))
|
|
216
|
+
/* Only the first screenful and a bit is worth a line each: an offscreen
|
|
217
|
+
control is a scroll away, and a hundred dead rows cost the reader tokens. */
|
|
218
|
+
const picked = controls.slice(0, max)
|
|
219
|
+
const parts = []
|
|
220
|
+
let used = 0
|
|
221
|
+
let full = 0
|
|
222
|
+
const take = (text) => {
|
|
223
|
+
if (text === '') return
|
|
224
|
+
full += text.length
|
|
225
|
+
if (used >= budget) return
|
|
226
|
+
const room = budget - used
|
|
227
|
+
parts.push(room >= text.length ? text : text.slice(0, room))
|
|
228
|
+
used += Math.min(room, text.length)
|
|
229
|
+
}
|
|
230
|
+
const main = document.querySelector('main, article, [role="main"]') ?? document.body
|
|
231
|
+
const texts = [main, ...roots.slice(1)]
|
|
232
|
+
if (mode !== 'links' && mode !== 'forms') {
|
|
233
|
+
const emitted = new Set()
|
|
234
|
+
for (const root of texts) {
|
|
235
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
236
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
237
|
+
const text = String(node.nodeValue ?? '').replace(/\\s+/g, ' ').trim()
|
|
238
|
+
const parent = node.parentElement
|
|
239
|
+
if (text === '' || !parent || emitted.has(parent)) continue
|
|
240
|
+
const b = box(parent)
|
|
241
|
+
const style = getComputedStyle(parent)
|
|
242
|
+
const fits = style.display !== 'none' && style.visibility !== 'hidden' && b.width > 1 && b.height > 1
|
|
243
|
+
if (!fits || !inView(b)) continue
|
|
244
|
+
emitted.add(parent)
|
|
245
|
+
take(text)
|
|
246
|
+
if (parent.tagName === 'P' || parent.tagName === 'DIV' || parent.tagName === 'LI' || /^H[1-6]$/.test(parent.tagName)) take('\\n')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/* A page with almost no words and one big picture is answering with the
|
|
251
|
+
picture. Say so here, in the same round trip, so a model that cannot open
|
|
252
|
+
files still learns that something came back: and sample it small to tell a
|
|
253
|
+
blank chart from a chart. */
|
|
254
|
+
const picture = (() => {
|
|
255
|
+
if (full >= 40) return null
|
|
256
|
+
let best = null
|
|
257
|
+
const missing = []
|
|
258
|
+
for (const el of document.querySelectorAll('img,canvas')) {
|
|
259
|
+
const b = box(el)
|
|
260
|
+
if (b.width < 64 || b.height < 64 || b.width * b.height < 8000) continue
|
|
261
|
+
const s = getComputedStyle(el)
|
|
262
|
+
if (s.visibility === 'hidden' || s.display === 'none') continue
|
|
263
|
+
/* A picture slot that never got its picture is the commonest way a
|
|
264
|
+
generated image goes missing, and it is silent by default. */
|
|
265
|
+
if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
|
|
266
|
+
missing.push({ width: Math.round(b.width), height: Math.round(b.height), name: String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) })
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
|
|
270
|
+
const h = el.tagName === 'CANVAS' ? el.height : el.naturalHeight || Math.round(b.height)
|
|
271
|
+
if (best === null || w * h > best.w * best.h) best = { el, w, h, kind: String(el.tagName).toLowerCase() }
|
|
272
|
+
}
|
|
273
|
+
if (best === null) return missing.length === 0 ? null : { missing }
|
|
274
|
+
/* What the read hands back about one picture: not a screen of fields. */
|
|
275
|
+
const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
|
|
276
|
+
said.kind = best.kind
|
|
277
|
+
said.width = best.w
|
|
278
|
+
said.height = best.h
|
|
279
|
+
said.colors = 0
|
|
280
|
+
said.flat = 0
|
|
281
|
+
try {
|
|
282
|
+
const board = document.createElement('canvas')
|
|
283
|
+
board.width = 32
|
|
284
|
+
board.height = 32
|
|
285
|
+
const pen = board.getContext('2d', { willReadFrequently: true })
|
|
286
|
+
if (pen === null) return said
|
|
287
|
+
pen.drawImage(best.el, 0, 0, 32, 32)
|
|
288
|
+
const dots = pen.getImageData(0, 0, 32, 32).data
|
|
289
|
+
const tally = new Map()
|
|
290
|
+
for (let at = 0; at < dots.length; at += 4) {
|
|
291
|
+
const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
|
|
292
|
+
tally.set(key, (tally.get(key) || 0) + 1)
|
|
293
|
+
}
|
|
294
|
+
let most = 0
|
|
295
|
+
for (const count of tally.values()) most = Math.max(most, count)
|
|
296
|
+
said.colors = tally.size
|
|
297
|
+
said.flat = Math.round((most / 1024) * 100) / 100
|
|
298
|
+
} catch {
|
|
299
|
+
/* A picture from elsewhere cannot be sampled: report its size only. */
|
|
300
|
+
}
|
|
301
|
+
return said
|
|
302
|
+
})()
|
|
303
|
+
return JSON.stringify({
|
|
304
|
+
url: location.href, // ui-copy-ignore
|
|
305
|
+
title: document.title, // ui-copy-ignore
|
|
306
|
+
text: parts.join(' ').replace(/\\n /g, '\\n').replace(/ {2,}/g, ' ').trim(),
|
|
307
|
+
textChars: full,
|
|
308
|
+
scroll: { y: Math.round(scrollY), height: Math.round(document.documentElement.scrollHeight), viewport: Math.round(innerHeight) },
|
|
309
|
+
focused: (() => { // ui-copy-ignore
|
|
310
|
+
const el = document.activeElement
|
|
311
|
+
if (!el || el === document.body) return null
|
|
312
|
+
return { ref: reg.id(el), kind: role(el), name: name(el) }
|
|
313
|
+
})(),
|
|
314
|
+
controls: picked,
|
|
315
|
+
picture,
|
|
316
|
+
})
|
|
317
|
+
})()`
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resolve a ref at action time, so a page that moved under the last read is
|
|
322
|
+
* clicked where the control is now, not where it was.
|
|
323
|
+
* @param {number} ref - ref from a read.
|
|
324
|
+
* @returns {string} an expression to evaluate.
|
|
325
|
+
*/
|
|
326
|
+
export function resolve(ref) {
|
|
327
|
+
return `(() => {
|
|
328
|
+
const reg = window.${REGISTRY}
|
|
329
|
+
if (!reg) return JSON.stringify({ gone: true })
|
|
330
|
+
const hit = reg.find(${Number(ref)})
|
|
331
|
+
if (!hit) return JSON.stringify({ gone: true })
|
|
332
|
+
const el = hit.el
|
|
333
|
+
const b = el.getBoundingClientRect()
|
|
334
|
+
if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
|
|
335
|
+
if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
336
|
+
const after = el.getBoundingClientRect()
|
|
337
|
+
const s = getComputedStyle(el)
|
|
338
|
+
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 })
|
|
339
|
+
})()`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* How big the control is and where it sits, so a picture can be of it alone.
|
|
344
|
+
* @param {number} ref - ref from a read.
|
|
345
|
+
* @returns {string} an expression that answers with the rectangle.
|
|
346
|
+
*/
|
|
347
|
+
export function bounds(ref) {
|
|
348
|
+
return `(() => {
|
|
349
|
+
const reg = window.${REGISTRY}
|
|
350
|
+
if (!reg) return JSON.stringify({ gone: true })
|
|
351
|
+
const hit = reg.find(${Number(ref)})
|
|
352
|
+
if (!hit) return JSON.stringify({ gone: true })
|
|
353
|
+
const el = hit.el
|
|
354
|
+
if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
355
|
+
const b = el.getBoundingClientRect()
|
|
356
|
+
if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
|
|
357
|
+
return JSON.stringify({ gone: false, x: b.left, y: b.top, width: b.width, height: b.height })
|
|
358
|
+
})()`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The pictures on the page, as files a person can open.
|
|
363
|
+
* A generated image or a chart is often the whole answer, and there is no
|
|
364
|
+
* wording in the tree that stands for it: take the picture itself.
|
|
365
|
+
* @param {number} [ref] - only pictures at or inside this control.
|
|
366
|
+
* @param {number} [count] - how many, largest first. Default 1.
|
|
367
|
+
* @returns {string} an expression that answers with `{pictures: [...]}`.
|
|
368
|
+
*/
|
|
369
|
+
export function pictures(ref, count) {
|
|
370
|
+
const scope = Number(ref)
|
|
371
|
+
const limit = Math.max(1, Math.min(4, Number(count) || 1))
|
|
372
|
+
return `(async () => {
|
|
373
|
+
const reg = window.${REGISTRY}
|
|
374
|
+
const where = ${Number.isFinite(scope) ? `(reg ? (reg.find(${scope}) || {}).el : null)` : 'null'}
|
|
375
|
+
const picked = []
|
|
376
|
+
const seen = new Set()
|
|
377
|
+
const add = (el, kind) => {
|
|
378
|
+
if (el === null || el === undefined || seen.has(el)) return
|
|
379
|
+
seen.add(el)
|
|
380
|
+
const b = el.getBoundingClientRect()
|
|
381
|
+
const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
|
|
382
|
+
const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
|
|
383
|
+
if (w < 48 || h < 48) return
|
|
384
|
+
if (b.width < 8 || b.height < 8) return
|
|
385
|
+
const s = getComputedStyle(el)
|
|
386
|
+
if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) < 0.05) return
|
|
387
|
+
picked.push({ el, kind, w, h, area: w * h })
|
|
388
|
+
}
|
|
389
|
+
const roots = where ? [where, ...where.querySelectorAll('*')] : [document, ...document.querySelectorAll('*')]
|
|
390
|
+
for (const node of roots) {
|
|
391
|
+
if (node === document || node === where) continue
|
|
392
|
+
const tag = node.tagName
|
|
393
|
+
if (tag === 'IMG') add(node, 'img')
|
|
394
|
+
else if (tag === 'CANVAS') add(node, 'canvas')
|
|
395
|
+
}
|
|
396
|
+
if (where && where.tagName === 'IMG') add(where, 'img')
|
|
397
|
+
if (where && where.tagName === 'CANVAS') add(where, 'canvas')
|
|
398
|
+
picked.sort((a, b) => b.area - a.area)
|
|
399
|
+
/* A picture nobody can look at still has to be describable: how much of it is
|
|
400
|
+
one colour tells a blank chart from a chart. Sampled at 32x32, which is
|
|
401
|
+
enough for that and costs nothing. */
|
|
402
|
+
const look = async (data) => {
|
|
403
|
+
try {
|
|
404
|
+
const img = new Image()
|
|
405
|
+
img.src = data
|
|
406
|
+
await img.decode()
|
|
407
|
+
const board = document.createElement('canvas')
|
|
408
|
+
board.width = 32
|
|
409
|
+
board.height = 32
|
|
410
|
+
const pen = board.getContext('2d', { willReadFrequently: true })
|
|
411
|
+
if (pen === null) return null
|
|
412
|
+
pen.drawImage(img, 0, 0, 32, 32)
|
|
413
|
+
const dots = pen.getImageData(0, 0, 32, 32).data
|
|
414
|
+
const tally = new Map()
|
|
415
|
+
for (let at = 0; at < dots.length; at += 4) {
|
|
416
|
+
const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
|
|
417
|
+
tally.set(key, (tally.get(key) || 0) + 1)
|
|
418
|
+
}
|
|
419
|
+
let most = 0
|
|
420
|
+
for (const count of tally.values()) most = Math.max(most, count)
|
|
421
|
+
return { colors: tally.size, flat: Math.round((most / 1024) * 100) / 100 }
|
|
422
|
+
} catch {
|
|
423
|
+
return null
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const out = []
|
|
427
|
+
for (const item of picked.slice(0, ${limit})) {
|
|
428
|
+
let data = ''
|
|
429
|
+
let type = 'image/png'
|
|
430
|
+
try {
|
|
431
|
+
if (item.kind === 'canvas') {
|
|
432
|
+
data = item.el.toDataURL('image/png')
|
|
433
|
+
} else {
|
|
434
|
+
const url = item.el.currentSrc || item.el.src || ''
|
|
435
|
+
const answer = await fetch(url, { credentials: 'include' })
|
|
436
|
+
const blob = await answer.blob()
|
|
437
|
+
type = blob.type || type
|
|
438
|
+
data = await new Promise((done, fail) => {
|
|
439
|
+
const reader = new FileReader()
|
|
440
|
+
reader.onload = () => done(String(reader.result))
|
|
441
|
+
reader.onerror = () => fail(new Error('unreadable'))
|
|
442
|
+
reader.readAsDataURL(blob)
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
data = ''
|
|
447
|
+
}
|
|
448
|
+
const feel = data ? await look(data) : null
|
|
449
|
+
out.push({ kind: item.kind, width: item.w, height: item.h, type, data, colors: feel?.colors ?? 0, flat: feel?.flat ?? 0 })
|
|
450
|
+
}
|
|
451
|
+
return JSON.stringify({ pictures: out })
|
|
452
|
+
})()`
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* What sits on top of a control at click time, when it is not the control.
|
|
457
|
+
* A cookie banner or a modal over the thing the agent meant to click is the
|
|
458
|
+
* commonest quiet failure on real pages: the click lands, nothing happens.
|
|
459
|
+
* @param {number} ref - ref from a read.
|
|
460
|
+
* @returns {string} an expression that answers `{tag, text, dismiss}` or an empty string.
|
|
461
|
+
*/
|
|
462
|
+
export function covering(ref) {
|
|
463
|
+
return `(() => {
|
|
464
|
+
const reg = window.${REGISTRY}
|
|
465
|
+
if (!reg) return ''
|
|
466
|
+
const hit = reg.find(${Number(ref)})
|
|
467
|
+
if (!hit) return ''
|
|
468
|
+
const el = hit.el
|
|
469
|
+
const b = el.getBoundingClientRect()
|
|
470
|
+
const x = Math.round(b.left + b.width / 2)
|
|
471
|
+
const y = Math.round(b.top + b.height / 2)
|
|
472
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return ''
|
|
473
|
+
const top = document.elementFromPoint(x, y)
|
|
474
|
+
if (top === null || top === el || el.contains(top) || top.contains(el)) return ''
|
|
475
|
+
const raw = String(top.innerText ?? top.value ?? top.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 80)
|
|
476
|
+
/* The thing in the way usually carries its own way out, and taking it is the
|
|
477
|
+
step the agent would take next anyway. Find that control while the overlay
|
|
478
|
+
is already being measured, so clearing it costs no extra round trip. The
|
|
479
|
+
words are a narrow safe list on purpose: an overlay that offers to delete,
|
|
480
|
+
pay or confirm is reported, never dismissed on the person's behalf. */
|
|
481
|
+
let scope = top
|
|
482
|
+
for (let node = top.parentElement; node !== null && node !== document.body; node = node.parentElement) {
|
|
483
|
+
const r = node.getBoundingClientRect()
|
|
484
|
+
if (r.width >= innerWidth * 0.6 && r.height >= innerHeight * 0.5) scope = node
|
|
485
|
+
}
|
|
486
|
+
const box = scope.getBoundingClientRect()
|
|
487
|
+
const wide = box.width >= innerWidth * 0.6 && box.height >= innerHeight * 0.5
|
|
488
|
+
const claimed = String(scope.innerText ?? '').replace(/\\s+/g, ' ').slice(0, 400)
|
|
489
|
+
const risky = /(delete|remove|purchase|payment|pay now|buy now|sign out|log out|transfer|删除|支付|购买|付款|注销|退出登录|转账)/i.test(claimed)
|
|
490
|
+
const words = /^(accept|accept all|agree|allow|allow all|ok|okay|got it|close|dismiss|i agree|接受|同意|允许|允许全部|关闭|确定|知道了|好的)$/i
|
|
491
|
+
let dismiss = null
|
|
492
|
+
if (wide && !risky) {
|
|
493
|
+
const cands = scope.querySelectorAll('button,[role=button],a,input[type=button],input[type=submit]')
|
|
494
|
+
for (const node of [scope, ...cands]) {
|
|
495
|
+
if (node === el || el.contains(node) || node.contains(el)) continue
|
|
496
|
+
const name = String(node.innerText ?? node.value ?? node.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 40)
|
|
497
|
+
if (name === '' || !words.test(name)) continue
|
|
498
|
+
const r = node.getBoundingClientRect()
|
|
499
|
+
if (r.width < 2 || r.height < 2) continue
|
|
500
|
+
if (r.top < 0 || r.left < 0 || r.bottom > innerHeight || r.right > innerWidth) continue
|
|
501
|
+
dismiss = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), name }
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return JSON.stringify({ tag: String(top.tagName).toLowerCase(), text: raw, dismiss })
|
|
506
|
+
})()`
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Where refs of controls inside iframes start. Main-frame refs stay small. */
|
|
510
|
+
const FRAME_BASE = 900_000
|
|
511
|
+
|
|
512
|
+
/** Room for the refs of one frame between FRAME_BASE steps. */
|
|
513
|
+
const FRAME_SLOT = 100_000
|
|
514
|
+
|
|
515
|
+
/** A frame is addressed either by its world or by the session of its own process. */
|
|
516
|
+
function asRoute(route) {
|
|
517
|
+
if (route === undefined || route === null) return {}
|
|
518
|
+
if (typeof route === 'number') return { contextId: route }
|
|
519
|
+
return route
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Evaluate in a page, in one frame of it, or in the process of a frame Chrome
|
|
524
|
+
* keeps apart.
|
|
525
|
+
* @param {any} page - sidecar page.
|
|
526
|
+
* @param {string} expression - JavaScript.
|
|
527
|
+
* @param {number|{contextId?: number, sessionId?: string}} [route] - where to run it.
|
|
528
|
+
* @returns {Promise<any>} the JSON-safe value.
|
|
529
|
+
*/
|
|
530
|
+
async function evaluateIn(page, expression, route) {
|
|
531
|
+
const where = asRoute(route)
|
|
532
|
+
const params = { expression, returnByValue: true }
|
|
533
|
+
if (where.contextId !== undefined) params.contextId = where.contextId
|
|
534
|
+
const { result } = await page.cdp.send('Runtime.evaluate', params, where.sessionId)
|
|
535
|
+
return result?.value
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @param {any} page - sidecar page. @param {any} [route] - world to seed. */
|
|
539
|
+
async function seedPage(page, route) {
|
|
540
|
+
await evaluateIn(page, seed(SELECTOR, VERSION), route)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* @param {any} page - sidecar page.
|
|
545
|
+
* @param {string} mode - read mode.
|
|
546
|
+
* @param {number} budget - text budget.
|
|
547
|
+
* @param {number} max - control cap.
|
|
548
|
+
* @param {any} [route] - world to read.
|
|
549
|
+
* @returns {Promise<any>} the snapshot.
|
|
550
|
+
*/
|
|
551
|
+
async function evaluateSnapshot(page, mode, budget, max, route) {
|
|
552
|
+
const raw = String((await evaluateIn(page, snapshot(mode, budget, max), route)) ?? '{}')
|
|
553
|
+
try {
|
|
554
|
+
return JSON.parse(raw)
|
|
555
|
+
} catch {
|
|
556
|
+
return { broken: true }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Where an iframe sits on the page. A ref inside a frame is resolved against
|
|
562
|
+
* the frame's own viewport, so its point needs the frame's corner added.
|
|
563
|
+
* @param {any} page - sidecar page.
|
|
564
|
+
* @param {{slot?: number, contextId?: number, sessionId?: string}} route - the frame.
|
|
565
|
+
* @returns {Promise<{x: number, y: number}>} the offset.
|
|
566
|
+
*/
|
|
567
|
+
async function frameOffset(page, route) {
|
|
568
|
+
const where = asRoute(route)
|
|
569
|
+
if (where.contextId !== undefined) {
|
|
570
|
+
const own = await evaluateIn(page, `(() => { try {
|
|
571
|
+
const frame = window.frameElement
|
|
572
|
+
if (!frame) return null
|
|
573
|
+
const b = frame.getBoundingClientRect()
|
|
574
|
+
return { x: Math.round(b.left), y: Math.round(b.top) }
|
|
575
|
+
} catch (error) { return null } })()`, where)
|
|
576
|
+
if (own !== null && own !== undefined && Number.isFinite(own.x)) return own
|
|
577
|
+
}
|
|
578
|
+
if (where.frameId !== undefined && typeof page.frameBox === 'function') {
|
|
579
|
+
const own = await page.frameBox(where.frameId).catch(() => null)
|
|
580
|
+
if (own !== null) return own
|
|
581
|
+
}
|
|
582
|
+
const index = Math.max(0, Number(where.slot ?? 1) - 1)
|
|
583
|
+
const fallback = await evaluateIn(page, `(() => {
|
|
584
|
+
const frame = document.querySelectorAll('iframe')[${index}]
|
|
585
|
+
if (!frame) return null
|
|
586
|
+
const b = frame.getBoundingClientRect()
|
|
587
|
+
return { x: Math.round(b.left), y: Math.round(b.top) }
|
|
588
|
+
})()`)
|
|
589
|
+
if (fallback !== null && fallback !== undefined && Number.isFinite(fallback.x)) return fallback
|
|
590
|
+
return { x: 0, y: 0 }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Where a frame's refs live: its slot in the frame tree, and how to reach it —
|
|
595
|
+
* by world when it shares the page's process, by session when Chrome runs it
|
|
596
|
+
* apart.
|
|
597
|
+
* @param {any} page - sidecar page.
|
|
598
|
+
* @param {any} frame - one entry from the frame list.
|
|
599
|
+
* @param {number} slot - its position under the main frame.
|
|
600
|
+
* @returns {{slot: number, contextId?: number, sessionId?: string, frameId?: string}|null} the route.
|
|
601
|
+
*/
|
|
602
|
+
function routeOf(page, frame, slot) {
|
|
603
|
+
const sessionId = frame.contextId === undefined && typeof page.childSession === 'function' ? page.childSession(frame.frameId) : undefined
|
|
604
|
+
if (frame.contextId === undefined && sessionId === undefined) return null
|
|
605
|
+
return { slot, contextId: frame.contextId, sessionId, frameId: frame.frameId }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Read the frames under the page, so a control inside an iframe is numbered
|
|
610
|
+
* like any other. A frame Chrome runs in its own process is read through the
|
|
611
|
+
* session of that process; the corner it sits at still comes from the page.
|
|
612
|
+
* @param {any} page - sidecar page.
|
|
613
|
+
* @param {string} mode - read mode.
|
|
614
|
+
* @param {number} budget - characters of text to keep.
|
|
615
|
+
* @returns {Promise<{text: string, controls: any[]}>} what the frames add.
|
|
616
|
+
*/
|
|
617
|
+
async function readFrames(page, mode, budget) {
|
|
618
|
+
if (typeof page.frameList !== 'function') return { text: '', controls: [] }
|
|
619
|
+
let frames = []
|
|
620
|
+
try {
|
|
621
|
+
frames = await page.frameList()
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return { text: '', controls: [] }
|
|
624
|
+
}
|
|
625
|
+
const children = frames.slice(1)
|
|
626
|
+
const controls = []
|
|
627
|
+
let text = ''
|
|
628
|
+
for (let slot = 0; slot < children.length && slot < 8; slot += 1) {
|
|
629
|
+
const route = routeOf(page, children[slot], slot + 1)
|
|
630
|
+
if (route === null) continue
|
|
631
|
+
try {
|
|
632
|
+
await seedPage(page, route)
|
|
633
|
+
const inside = await evaluateSnapshot(page, mode, Math.min(budget, 600), 40, route)
|
|
634
|
+
if (inside?.broken === true) continue
|
|
635
|
+
const offset = await frameOffset(page, route)
|
|
636
|
+
const tag = FRAME_BASE + slot * FRAME_SLOT
|
|
637
|
+
for (const item of inside.controls ?? []) {
|
|
638
|
+
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 })
|
|
639
|
+
}
|
|
640
|
+
if (text.length < 400 && typeof inside.text === 'string' && inside.text !== '') text += `${text === '' ? '' : '\n'}${inside.text.slice(0, 400)}`
|
|
641
|
+
} catch (error) {
|
|
642
|
+
continue
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return { text, controls }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Decode a ref into the frame it lives in and the number that frame knows. */
|
|
649
|
+
function splitFrameRef(ref) {
|
|
650
|
+
const number = Number(ref)
|
|
651
|
+
if (!Number.isFinite(number) || number < FRAME_BASE) return null
|
|
652
|
+
const slot = Math.floor((number - FRAME_BASE) / FRAME_SLOT)
|
|
653
|
+
return { slot, local: number - FRAME_BASE - slot * FRAME_SLOT }
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Where a ref lives, for a tool that has to look the element up itself.
|
|
658
|
+
* @param {any} page - sidecar page.
|
|
659
|
+
* @param {number} ref - ref from a read.
|
|
660
|
+
* @returns {Promise<{slot: number, local: number, contextId?: number, sessionId?: string, frameId?: string}|null>} the route, or nothing for the page itself.
|
|
661
|
+
*/
|
|
662
|
+
export async function contextFor(page, ref) {
|
|
663
|
+
const split = splitFrameRef(ref)
|
|
664
|
+
if (split === null) return null
|
|
665
|
+
if (typeof page.frameList !== 'function') return null
|
|
666
|
+
const frames = await page.frameList().catch(() => [])
|
|
667
|
+
const frame = frames[split.slot + 1]
|
|
668
|
+
if (frame === undefined) return null
|
|
669
|
+
const route = routeOf(page, frame, split.slot + 1)
|
|
670
|
+
if (route === null) return null
|
|
671
|
+
return { ...route, local: split.local }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Read the page and hand back the shape the renderer wants.
|
|
676
|
+
* @param {any} page - sidecar page.
|
|
677
|
+
* @param {{mode?: string, maxChars?: number, max?: number}} [options] - how much to read.
|
|
678
|
+
* @returns {Promise<{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}>} the read.
|
|
679
|
+
*/
|
|
680
|
+
export async function readPage(page, options = {}) {
|
|
681
|
+
const mode = String(options.mode ?? 'text')
|
|
682
|
+
const maxChars = Number.isFinite(Number(options.maxChars)) ? Math.max(400, Math.min(24_000, Number(options.maxChars))) : 6000
|
|
683
|
+
const max = Number.isFinite(Number(options.max)) ? Math.max(5, Math.min(200, Number(options.max))) : 90
|
|
684
|
+
await seedPage(page)
|
|
685
|
+
let read = await evaluateSnapshot(page, mode, maxChars, max)
|
|
686
|
+
if (read?.broken === true) {
|
|
687
|
+
await seedPage(page)
|
|
688
|
+
read = await evaluateSnapshot(page, mode, maxChars, max)
|
|
689
|
+
}
|
|
690
|
+
const framed = await readFrames(page, mode, Math.min(maxChars, 1200))
|
|
691
|
+
if (framed.controls.length > 0) read = { ...read, controls: [...(read.controls ?? []), ...framed.controls] }
|
|
692
|
+
if (framed.text !== '') read = { ...read, text: `${String(read.text ?? '')}${String(read.text ?? '') === '' ? '' : '\n'}${framed.text}` }
|
|
693
|
+
return {
|
|
694
|
+
url: String(read?.url ?? page.url),
|
|
695
|
+
title: String(read?.title ?? page.title),
|
|
696
|
+
text: String(read?.text ?? ''),
|
|
697
|
+
textChars: Number(read?.textChars ?? 0),
|
|
698
|
+
picture: read?.picture ?? null,
|
|
699
|
+
scroll: read?.scroll,
|
|
700
|
+
focused: read?.focused ?? null,
|
|
701
|
+
controls: Array.isArray(read?.controls) ? read.controls : [],
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Resolve a ref into a point, at the moment of the action.
|
|
707
|
+
* @param {any} page - sidecar page.
|
|
708
|
+
* @param {number} ref - ref from a read.
|
|
709
|
+
* @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
|
|
710
|
+
*/
|
|
711
|
+
export async function locate(page, ref) {
|
|
712
|
+
const split = splitFrameRef(ref)
|
|
713
|
+
let route = null
|
|
714
|
+
if (split !== null) {
|
|
715
|
+
route = await contextFor(page, ref)
|
|
716
|
+
if (route === null) return { gone: true }
|
|
717
|
+
}
|
|
718
|
+
const said = await evaluateIn(page, resolve(split === null ? ref : route.local), route).catch(() => undefined)
|
|
719
|
+
let spot = { gone: true }
|
|
720
|
+
try {
|
|
721
|
+
spot = JSON.parse(String(said ?? '{"gone":true}'))
|
|
722
|
+
} catch (error) {
|
|
723
|
+
spot = { gone: true }
|
|
724
|
+
}
|
|
725
|
+
if (split === null || spot.gone === true) return spot
|
|
726
|
+
const offset = await frameOffset(page, route)
|
|
727
|
+
return { ...spot, x: Number(spot.x ?? 0) + offset.x, y: Number(spot.y ?? 0) + offset.y }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Roles that read better without a sigil. */
|
|
731
|
+
function sigilOf(control) {
|
|
732
|
+
const kind = String(control.kind ?? '')
|
|
733
|
+
if (kind === 'link') return 'link'
|
|
734
|
+
if (kind === 'button') return 'btn'
|
|
735
|
+
if (kind === 'input' || kind === 'search') return 'input'
|
|
736
|
+
if (kind === 'checkbox' || kind === 'radio') return kind
|
|
737
|
+
return SIGIL[kind] ?? kind ?? 'el'
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Render a read as compact text, controls first, then the viewport's words.
|
|
742
|
+
* @param {{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}} read - page read.
|
|
743
|
+
* @param {{mode?: string, maxChars?: number}} [options] - how it was read.
|
|
744
|
+
* @returns {string} the text a model sees.
|
|
745
|
+
*/
|
|
746
|
+
export function renderPage(read, options = {}) {
|
|
747
|
+
const mode = String(options.mode ?? 'text')
|
|
748
|
+
const maxChars = Number(options.maxChars ?? 6000)
|
|
749
|
+
const lines = [read.url]
|
|
750
|
+
if (read.title !== '') lines.push(`# ${read.title}`)
|
|
751
|
+
const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
|
|
752
|
+
const hidden = read.controls.length - shown.length
|
|
753
|
+
for (const control of shown) {
|
|
754
|
+
let line = `${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`
|
|
755
|
+
if (control.kind === 'checkbox' || control.kind === 'radio') line += control.checked ? ' (checked)' : ' (unchecked)'
|
|
756
|
+
else if (control.value !== '' && control.value !== control.name) line += ` = ${control.value}`
|
|
757
|
+
if (control.disabled) line += ' (disabled)'
|
|
758
|
+
if (mode === 'links' && control.href !== '') line += ` → ${control.href}`
|
|
759
|
+
lines.push(line)
|
|
760
|
+
}
|
|
761
|
+
if (hidden > 0 && mode !== 'links' && mode !== 'forms') lines.push(`… ${hidden} more controls off screen`)
|
|
762
|
+
if (read.focused) lines.push(`focus ${read.focused.ref} [${read.focused.kind}] ${read.focused.name}`)
|
|
763
|
+
const body = read.text
|
|
764
|
+
if (body !== '') {
|
|
765
|
+
lines.push('')
|
|
766
|
+
lines.push(body)
|
|
767
|
+
if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
|
|
768
|
+
}
|
|
769
|
+
if (read.picture && Array.isArray(read.picture.missing)) {
|
|
770
|
+
/* Something was meant to be here and is not: say that instead of nothing. */
|
|
771
|
+
for (const gone of read.picture.missing.slice(0, 2)) {
|
|
772
|
+
const size = `${Math.round(Number(gone.width) || 0)}x${Math.round(Number(gone.height) || 0)}`
|
|
773
|
+
lines.push(`picture ${size}${gone.name === '' ? '' : ` "${gone.name}"`} did not load — the page may need a reload, or the picture is gone`)
|
|
774
|
+
}
|
|
775
|
+
} else if (read.picture) {
|
|
776
|
+
const pic = read.picture
|
|
777
|
+
const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
|
|
778
|
+
const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
|
|
779
|
+
lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
|
|
780
|
+
}
|
|
781
|
+
if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
|
|
782
|
+
lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
|
|
783
|
+
}
|
|
784
|
+
return lines.join('\n').slice(0, Math.max(600, maxChars) + 600)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** A ref that was valid on the previous read and is no longer here. */
|
|
788
|
+
export const REF_GONE = 'That control is gone: the page changed since the read. Call sidecar_view and use the new number.'
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The control itself, not its number: what it is, what it is called, and where
|
|
792
|
+
* it sat. Kept from the read that produced the ref so a re-render can be
|
|
793
|
+
* survived without asking the person to read the page again.
|
|
794
|
+
* @param {any} control - a control from a read.
|
|
795
|
+
* @returns {{kind: string, name: string, href: string, inert: boolean}} what to look for.
|
|
796
|
+
*/
|
|
797
|
+
export function identityOf(control) {
|
|
798
|
+
const kind = String(control?.kind ?? '')
|
|
799
|
+
const name = String(control?.name ?? '')
|
|
800
|
+
const href = String(control?.href ?? '')
|
|
801
|
+
const inert = ['input', 'select', 'textarea', 'password', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(kind)
|
|
802
|
+
return { kind, name, href, inert }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Whether a control read now is the one described by an identity: the same
|
|
807
|
+
* words, and not a container that happens to contain them.
|
|
808
|
+
* @param {{kind: string, name: string, href: string, inert: boolean}} want - identity.
|
|
809
|
+
* @param {any} control - a control from the current read.
|
|
810
|
+
* @returns {boolean} true when this is the same control.
|
|
811
|
+
*/
|
|
812
|
+
export function sameThing(want, control) {
|
|
813
|
+
/* Nothing to look for is not a licence to click whatever is nearest. */
|
|
814
|
+
if (want.name === '') return false
|
|
815
|
+
if (String(control?.name ?? '') !== want.name) return false
|
|
816
|
+
if (String(control?.kind ?? '') !== want.kind) return false
|
|
817
|
+
/* A field and the button beside it can share their words; a field never
|
|
818
|
+
becomes a button, so the kind must match exactly rather than loosely. */
|
|
819
|
+
if (want.inert && !['input', 'select', 'textarea', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(String(control?.kind ?? ''))) return false
|
|
820
|
+
if (want.href !== '' && String(control?.href ?? '') !== want.href) return false
|
|
821
|
+
return true
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** The in-page half shared by `labelSpot` and `selectSpot`: shadow roots, what says what. */
|
|
825
|
+
const NAME_PRELUDE = `
|
|
826
|
+
const roots = [document]
|
|
827
|
+
for (const el of document.querySelectorAll('*')) {
|
|
828
|
+
if (roots.length > 400) break
|
|
829
|
+
if (el.shadowRoot) roots.push(el.shadowRoot)
|
|
830
|
+
}
|
|
831
|
+
const norm = (s) => String(s ?? '').replace(/\\s+/g, ' ').trim()
|
|
832
|
+
const short = (s) => { const t = norm(s); return t !== '' && t.length <= 80 ? t : '' }
|
|
833
|
+
const editable = (el) => {
|
|
834
|
+
const tag = el.tagName
|
|
835
|
+
if (tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
836
|
+
if (tag === 'INPUT') {
|
|
837
|
+
const type = String(el.type ?? 'text').toLowerCase()
|
|
838
|
+
return !['hidden', 'button', 'submit', 'reset', 'checkbox', 'radio', 'file', 'image', 'range', 'color'].includes(type)
|
|
839
|
+
}
|
|
840
|
+
if (el.isContentEditable === true) return true
|
|
841
|
+
return ['textbox', 'searchbox', 'combobox'].includes(String(el.getAttribute('role') ?? ''))
|
|
842
|
+
}
|
|
843
|
+
const shown = (el) => {
|
|
844
|
+
const b = el.getBoundingClientRect()
|
|
845
|
+
if (b.width < 2 || b.height < 2) return false
|
|
846
|
+
const s = getComputedStyle(el)
|
|
847
|
+
return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05
|
|
848
|
+
}
|
|
849
|
+
const names = (el) => {
|
|
850
|
+
const out = []
|
|
851
|
+
const add = (v) => { const t = short(v); if (t !== '') out.push(t) }
|
|
852
|
+
if (el.labels) for (const l of el.labels) add(l.innerText ?? l.textContent)
|
|
853
|
+
for (const id of String(el.getAttribute('aria-labelledby') ?? '').split(/\\s+/).filter(Boolean)) {
|
|
854
|
+
const other = document.getElementById(id)
|
|
855
|
+
if (other) add(other.innerText ?? other.textContent)
|
|
856
|
+
}
|
|
857
|
+
add(el.getAttribute('aria-label'))
|
|
858
|
+
add(el.getAttribute('placeholder'))
|
|
859
|
+
add(el.getAttribute('title'))
|
|
860
|
+
if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
|
|
861
|
+
const prev = el.previousElementSibling
|
|
862
|
+
if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
|
|
863
|
+
const parent = el.parentElement
|
|
864
|
+
if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
|
|
865
|
+
/* Wrapped a couple of levels deep (a styled box hiding a real input), the
|
|
866
|
+
words sit before the wrapper, not before the input. */
|
|
867
|
+
let up = parent
|
|
868
|
+
for (let depth = 0; depth < 3 && up; depth += 1) {
|
|
869
|
+
const before = up.previousElementSibling
|
|
870
|
+
if (before && editable(before) !== true) add(before.innerText ?? before.textContent)
|
|
871
|
+
up = up.parentElement
|
|
872
|
+
}
|
|
873
|
+
/* The attribute is the last resort for a name on screen: it is often the
|
|
874
|
+
developer's word for the field, not the one the user reads. */
|
|
875
|
+
add(el.getAttribute('name'))
|
|
876
|
+
return out
|
|
877
|
+
}
|
|
878
|
+
const squeeze = (s) => String(s).toLowerCase().replace(/\s+/g, '')
|
|
879
|
+
const scored = (el, want) => {
|
|
880
|
+
let score = 0
|
|
881
|
+
let name = ''
|
|
882
|
+
const flat = squeeze(want)
|
|
883
|
+
for (const nm of names(el)) {
|
|
884
|
+
const low = nm.toLowerCase()
|
|
885
|
+
if (low === want || squeeze(low) === flat) score = Math.max(score, 3)
|
|
886
|
+
else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
|
|
887
|
+
else if (flat.length >= 2 && squeeze(low).includes(flat)) score = Math.max(score, 2)
|
|
888
|
+
else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
|
|
889
|
+
if (score > 0 && name === '') name = nm
|
|
890
|
+
}
|
|
891
|
+
return { score, name }
|
|
892
|
+
}
|
|
893
|
+
const boxOf = (el) => {
|
|
894
|
+
for (let node = el; node; node = node.parentElement) {
|
|
895
|
+
const b = node.getBoundingClientRect()
|
|
896
|
+
if (b.width < 8 || b.height < 8) continue
|
|
897
|
+
const s = getComputedStyle(node)
|
|
898
|
+
if (s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05) return b
|
|
899
|
+
}
|
|
900
|
+
return null
|
|
901
|
+
}
|
|
902
|
+
const bestOf = (selector, want, hiddenOk) => {
|
|
903
|
+
let best = null
|
|
904
|
+
for (const root of roots) {
|
|
905
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
906
|
+
if (hiddenOk !== true && shown(el) !== true) continue
|
|
907
|
+
const hit = scored(el, want)
|
|
908
|
+
if (hit.score === 0) continue
|
|
909
|
+
if (best === null || hit.score > best.score) best = { el, score: hit.score, name: hit.name }
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return best
|
|
913
|
+
}
|
|
914
|
+
`
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
918
|
+
* ...
|
|
919
|
+
*/
|
|
920
|
+
/**
|
|
921
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
922
|
+
*
|
|
923
|
+
* `{field: "Email"}` used to mean "click the control that says Email". On a real
|
|
924
|
+
* form the words live in a div above a bare input, so the click landed on the
|
|
925
|
+
* text and the typing went wherever focus already was — the classic silent wrong
|
|
926
|
+
* field. This finds the *editable* element the words point at: a real label, an
|
|
927
|
+
* aria name, a placeholder, a name attribute, the text right before it, or the
|
|
928
|
+
* text of the small wrapper it sits in.
|
|
929
|
+
* @param {string} words - what the field is called.
|
|
930
|
+
* @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
|
|
931
|
+
*/
|
|
932
|
+
export function labelSpot(words) {
|
|
933
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
934
|
+
return `(() => {
|
|
935
|
+
const want = ${wanted}
|
|
936
|
+
if (want === '') return JSON.stringify({})
|
|
937
|
+
${NAME_PRELUDE}
|
|
938
|
+
const selector = 'input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]'
|
|
939
|
+
let best = bestOf(selector, want)
|
|
940
|
+
let box = best === null ? null : boxOf(best.el)
|
|
941
|
+
let behind = false
|
|
942
|
+
if (best === null || box === null) {
|
|
943
|
+
/* The field a date picker shows is often a transparent box over a styled
|
|
944
|
+
div: the input stays out of sight, but the words still name it. */
|
|
945
|
+
const under = bestOf(selector, want, true)
|
|
946
|
+
const place = under === null ? null : boxOf(under.el)
|
|
947
|
+
if (under !== null && place !== null) { best = under; box = place; behind = true }
|
|
948
|
+
}
|
|
949
|
+
if (best === null || box === null) return JSON.stringify({})
|
|
950
|
+
if (editable(best.el) !== true) return JSON.stringify({})
|
|
951
|
+
best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
952
|
+
const fresh = boxOf(best.el) ?? box
|
|
953
|
+
return JSON.stringify({ x: Math.round(fresh.left + fresh.width / 2), y: Math.round(fresh.top + fresh.height / 2), name: best.name, score: best.score, behind })
|
|
954
|
+
})()`
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The dropdown the words belong to, with the wanted option already chosen.
|
|
959
|
+
* @param {string} words - what the dropdown is called on screen.
|
|
960
|
+
* @param {string} value - option label or value.
|
|
961
|
+
* @returns {string} an expression that picks the option and says what it did.
|
|
962
|
+
*/
|
|
963
|
+
export function selectSpot(words, value) {
|
|
964
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
965
|
+
const wanted2 = JSON.stringify(String(value ?? ''))
|
|
966
|
+
return `(() => {
|
|
967
|
+
const want = ${wanted}
|
|
968
|
+
const wanted = ${wanted2}
|
|
969
|
+
if (want === '') return 'no dropdown was named'
|
|
970
|
+
${NAME_PRELUDE}
|
|
971
|
+
const best = bestOf('select', want)
|
|
972
|
+
if (best === null) return 'no dropdown says ' + JSON.stringify(want)
|
|
973
|
+
const select = best.el
|
|
974
|
+
const option = Array.from(select.options).find((item) => item.value === wanted || item.text.trim() === wanted || item.text.trim().toLowerCase() === wanted.toLowerCase())
|
|
975
|
+
if (!option) return 'no option ' + wanted + ' in ' + best.name + '; options are ' + Array.from(select.options).slice(0, 12).map((item) => item.text.trim()).join(' | ')
|
|
976
|
+
select.value = option.value
|
|
977
|
+
select.dispatchEvent(new Event('input', { bubbles: true }))
|
|
978
|
+
select.dispatchEvent(new Event('change', { bubbles: true }))
|
|
979
|
+
return 'chose ' + option.text.trim() + ' in ' + best.name
|
|
980
|
+
})()`
|
|
981
|
+
}
|