@browserless/agent 0.0.0-stage → 14.0.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/LICENSE.md +21 -0
- package/README.md +637 -2
- package/examples/github.js +21 -0
- package/examples/google-flights.js +41 -0
- package/examples/hacker-news.js +23 -0
- package/examples/run.js +23 -0
- package/examples/util.js +15 -0
- package/examples/wallapop.js +26 -0
- package/examples/wikipedia.js +24 -0
- package/package.json +60 -4
- package/src/browser.js +212 -0
- package/src/errors.js +20 -0
- package/src/index.d.ts +108 -0
- package/src/index.js +333 -0
- package/src/model.js +453 -0
- package/src/outline.js +93 -0
- package/src/profiling.js +78 -0
- package/src/questions.js +13 -0
- package/src/rules.js +312 -0
- package/src/snapshot.js +339 -0
package/src/rules.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const RULE_KEYS = ['selector', 'selectorAll', 'attr', 'type']
|
|
4
|
+
const LOCATION_KEYS = ['selector', 'selectorAll']
|
|
5
|
+
const MAX_RULE_DEPTH = 4
|
|
6
|
+
const MAX_SELECTOR_LENGTH = 300
|
|
7
|
+
const MODEL_DEFAULT_ATTR = 'text'
|
|
8
|
+
|
|
9
|
+
const isPlainObject = value =>
|
|
10
|
+
value !== null &&
|
|
11
|
+
typeof value === 'object' &&
|
|
12
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
13
|
+
|
|
14
|
+
const isSelector = value =>
|
|
15
|
+
typeof value === 'string' && value.trim() !== '' && value.length <= MAX_SELECTOR_LENGTH
|
|
16
|
+
|
|
17
|
+
const isNested = rule => isPlainObject(rule?.attr)
|
|
18
|
+
|
|
19
|
+
const hasLocation = rule => LOCATION_KEYS.some(key => key in rule)
|
|
20
|
+
|
|
21
|
+
const invalidRule = (path, problem) => new TypeError(`Invalid rule \`${path}\`: ${problem}.`)
|
|
22
|
+
|
|
23
|
+
const assertSelectors = (rule, path) => {
|
|
24
|
+
for (const key of LOCATION_KEYS) {
|
|
25
|
+
if (!(key in rule)) continue
|
|
26
|
+
const selectors = Array.isArray(rule[key]) ? rule[key] : [rule[key]]
|
|
27
|
+
if (selectors.length === 0 || !selectors.every(isSelector)) {
|
|
28
|
+
throw invalidRule(path, `${key} must be a CSS selector or a list of them`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const assertRule = (rule, path, depth) => {
|
|
34
|
+
if (!isPlainObject(rule)) throw invalidRule(path, 'a rule must be an object')
|
|
35
|
+
if (depth > MAX_RULE_DEPTH) throw invalidRule(path, 'rules are nested too deeply')
|
|
36
|
+
const unknown = Object.keys(rule).find(key => !RULE_KEYS.includes(key))
|
|
37
|
+
if (unknown) throw invalidRule(path, `\`${unknown}\` is not supported`)
|
|
38
|
+
assertSelectors(rule, path)
|
|
39
|
+
if ('type' in rule && typeof rule.type !== 'string') {
|
|
40
|
+
throw invalidRule(path, 'type must be a string')
|
|
41
|
+
}
|
|
42
|
+
if (!('attr' in rule)) return
|
|
43
|
+
if (isNested(rule)) return assertRules(rule.attr, `${path}.attr`, depth + 1)
|
|
44
|
+
const attrs = Array.isArray(rule.attr) ? rule.attr : [rule.attr]
|
|
45
|
+
if (attrs.length === 0 || !attrs.every(attr => typeof attr === 'string' && attr !== '')) {
|
|
46
|
+
throw invalidRule(path, 'attr must be an attribute name, a list of them, or nested rules')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const assertRules = (rules, path = 'data', depth = 1) => {
|
|
51
|
+
if (!isPlainObject(rules) || Object.keys(rules).length === 0) {
|
|
52
|
+
throw invalidRule(path, 'rules must be an object with at least one field')
|
|
53
|
+
}
|
|
54
|
+
for (const [name, rule] of Object.entries(rules)) {
|
|
55
|
+
const alternatives = Array.isArray(rule) ? rule : [rule]
|
|
56
|
+
if (alternatives.length === 0) throw invalidRule(`${path}.${name}`, 'a rule must be an object')
|
|
57
|
+
for (const alternative of alternatives) assertRule(alternative, `${path}.${name}`, depth)
|
|
58
|
+
}
|
|
59
|
+
return rules
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pick = (rule, keys) =>
|
|
63
|
+
Object.fromEntries(keys.filter(key => key in rule).map(key => [key, rule[key]]))
|
|
64
|
+
|
|
65
|
+
const writtenLocation = (written, path) => {
|
|
66
|
+
if (!isPlainObject(written)) throw invalidRule(path, 'the model wrote no rule for this field')
|
|
67
|
+
if ('selectorAll' in written) return { selectorAll: written.selectorAll }
|
|
68
|
+
if ('selector' in written) return { selector: written.selector }
|
|
69
|
+
throw invalidRule(path, 'the model wrote no selector for this field')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const filledAttr = (field, written, path) => {
|
|
73
|
+
if (isNested(field)) {
|
|
74
|
+
if (!isNested(written)) {
|
|
75
|
+
throw invalidRule(path, 'the model wrote no nested rules for this field')
|
|
76
|
+
}
|
|
77
|
+
return { attr: fillFields(field.attr, written.attr, `${path}.attr`) }
|
|
78
|
+
}
|
|
79
|
+
if ('attr' in field) return { attr: field.attr }
|
|
80
|
+
if (isNested(written)) throw invalidRule(path, 'the model wrote nested rules for a single value')
|
|
81
|
+
return isPlainObject(written) && 'attr' in written ? { attr: written.attr } : {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fillField = (field, written, path) => {
|
|
85
|
+
if (Array.isArray(field)) return field.map(alternative => fillField(alternative, written, path))
|
|
86
|
+
if (hasLocation(field) && !isNested(field)) return field
|
|
87
|
+
const type = field.type ?? (isPlainObject(written) ? written.type : undefined)
|
|
88
|
+
return {
|
|
89
|
+
...(hasLocation(field) ? pick(field, LOCATION_KEYS) : writtenLocation(written, path)),
|
|
90
|
+
...filledAttr(field, written, path),
|
|
91
|
+
...(type === undefined ? {} : { type })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const firstAlternative = rule => (Array.isArray(rule) ? rule[0] : rule)
|
|
96
|
+
|
|
97
|
+
const fillFields = (fields, written, path = 'data') =>
|
|
98
|
+
Object.fromEntries(
|
|
99
|
+
Object.entries(fields).map(([name, field]) => [
|
|
100
|
+
name,
|
|
101
|
+
fillField(
|
|
102
|
+
field,
|
|
103
|
+
isPlainObject(written) ? firstAlternative(written[name]) : undefined,
|
|
104
|
+
`${path}.${name}`
|
|
105
|
+
)
|
|
106
|
+
])
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
function evaluateRules (data) {
|
|
110
|
+
const ATTR_PROPERTIES = { html: 'innerHTML', text: 'textContent', val: 'value' }
|
|
111
|
+
const RAW_ATTRIBUTES = ['href', 'src']
|
|
112
|
+
const DEFAULT_ATTRS = ['html']
|
|
113
|
+
const THOUSANDS_ONLY = /^(\d{1,3})([.,])\d{3}(?:\2\d{3})*$/
|
|
114
|
+
const GROUPED_WITH_DECIMALS = /^(\d{1,3}(?:([.,])\d{3})+)([.,])(\d+)$/
|
|
115
|
+
const PLAIN_DECIMALS = /^(\d+)[.,](\d+)$/
|
|
116
|
+
const ZERO_AND_ONE_GROUP_LENGTH = 5
|
|
117
|
+
|
|
118
|
+
const toList = value => (Array.isArray(value) ? value : [value])
|
|
119
|
+
const condense = text => text.replace(/\s+/g, ' ').trim()
|
|
120
|
+
const isNested = rule =>
|
|
121
|
+
rule.attr !== null && typeof rule.attr === 'object' && !Array.isArray(rule.attr)
|
|
122
|
+
const isEmpty = value => value === undefined || value === null || value === ''
|
|
123
|
+
|
|
124
|
+
const toNumber = text => {
|
|
125
|
+
const token = text.replace(/−/g, '-').match(/-?\d[\d.,]*/)?.[0]
|
|
126
|
+
if (!token) return undefined
|
|
127
|
+
const sign = token.startsWith('-') ? -1 : 1
|
|
128
|
+
const digits = token.replace(/^-/, '').replace(/[.,]+$/, '')
|
|
129
|
+
if (/^\d+$/.test(digits)) return sign * Number(digits)
|
|
130
|
+
const thousands = digits.match(THOUSANDS_ONLY)
|
|
131
|
+
const isFractionOfZero =
|
|
132
|
+
thousands && thousands[1] === '0' && digits.length === ZERO_AND_ONE_GROUP_LENGTH
|
|
133
|
+
if (thousands && !isFractionOfZero) return sign * Number(digits.replace(/[.,]/g, ''))
|
|
134
|
+
const grouped = digits.match(GROUPED_WITH_DECIMALS)
|
|
135
|
+
if (grouped && grouped[2] !== grouped[3]) {
|
|
136
|
+
return sign * Number(`${grouped[1].replace(/[.,]/g, '')}.${grouped[4]}`)
|
|
137
|
+
}
|
|
138
|
+
const decimals = digits.match(PLAIN_DECIMALS)
|
|
139
|
+
return decimals ? sign * Number(`${decimals[1]}.${decimals[2]}`) : undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const toUrl = text => {
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL(text, document.baseURI)
|
|
145
|
+
return ['http:', 'https:', 'data:'].includes(url.protocol) ? url.href : undefined
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const toDate = text => {
|
|
150
|
+
if (!/\d{4}/.test(text)) return undefined
|
|
151
|
+
const date = new Date(text)
|
|
152
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const toAuto = text => {
|
|
156
|
+
if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text)
|
|
157
|
+
if (text === 'true' || text === 'false') return text === 'true'
|
|
158
|
+
return text
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const TYPES = {
|
|
162
|
+
number: toNumber,
|
|
163
|
+
url: toUrl,
|
|
164
|
+
image: toUrl,
|
|
165
|
+
date: toDate,
|
|
166
|
+
string: text => text
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const typed = (value, rule) => {
|
|
170
|
+
if (isEmpty(value)) return undefined
|
|
171
|
+
const convert = Object.hasOwn(TYPES, rule.type) ? TYPES[rule.type] : toAuto
|
|
172
|
+
return convert(String(value))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const readAttr = (element, attr) => {
|
|
176
|
+
if (RAW_ATTRIBUTES.includes(attr)) return element.getAttribute(attr) ?? undefined
|
|
177
|
+
const property = ATTR_PROPERTIES[attr] || attr
|
|
178
|
+
const value =
|
|
179
|
+
typeof element[property] === 'string' ? element[property] : element.getAttribute(attr)
|
|
180
|
+
if (typeof value !== 'string') return undefined
|
|
181
|
+
return attr === 'text' ? condense(value) : value
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const wholePage = rule =>
|
|
185
|
+
toList(rule.attr || DEFAULT_ATTRS)[0] === 'text'
|
|
186
|
+
? condense(document.body.innerText)
|
|
187
|
+
: document.documentElement.outerHTML
|
|
188
|
+
|
|
189
|
+
const invalidSelectors = []
|
|
190
|
+
|
|
191
|
+
const splitSelectorList = selector => {
|
|
192
|
+
const parts = []
|
|
193
|
+
let depth = 0
|
|
194
|
+
let current = ''
|
|
195
|
+
for (const character of selector) {
|
|
196
|
+
if ('([{'.includes(character)) depth++
|
|
197
|
+
if (')]}'.includes(character)) depth--
|
|
198
|
+
if (character === ',' && depth === 0) {
|
|
199
|
+
parts.push(current)
|
|
200
|
+
current = ''
|
|
201
|
+
} else current += character
|
|
202
|
+
}
|
|
203
|
+
return [...parts, current]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const relativeTo = selector =>
|
|
207
|
+
splitSelectorList(selector)
|
|
208
|
+
.map(part => `:scope ${part.trim()}`)
|
|
209
|
+
.join(', ')
|
|
210
|
+
|
|
211
|
+
const query = (scope, selector) => {
|
|
212
|
+
try {
|
|
213
|
+
return [...scope.querySelectorAll(scope === document ? selector : relativeTo(selector))]
|
|
214
|
+
} catch {
|
|
215
|
+
invalidSelectors.push(selector)
|
|
216
|
+
return []
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const resolve = (element, rule) => {
|
|
221
|
+
if (!element) return undefined
|
|
222
|
+
if (!isNested(rule)) {
|
|
223
|
+
const value = toList(rule.attr || DEFAULT_ATTRS)
|
|
224
|
+
.map(attr => readAttr(element, attr))
|
|
225
|
+
.find(found => !isEmpty(found))
|
|
226
|
+
return typed(value, rule)
|
|
227
|
+
}
|
|
228
|
+
return Object.fromEntries(
|
|
229
|
+
Object.entries(rule.attr).map(([name, nested]) => [name, select(element, nested) ?? null])
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const withoutRepeatedPrimitives = values => {
|
|
234
|
+
const seen = new Set()
|
|
235
|
+
return values.filter(value => {
|
|
236
|
+
if (value !== null && typeof value === 'object') return true
|
|
237
|
+
if (seen.has(value)) return false
|
|
238
|
+
seen.add(value)
|
|
239
|
+
return true
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const selectAll = (scope, selector, rule, topLevel) => {
|
|
244
|
+
const values = query(scope, selector).map(element => resolve(element, rule) ?? null)
|
|
245
|
+
return topLevel ? withoutRepeatedPrimitives(values.filter(value => value !== null)) : values
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const select = (scope, alternatives) => {
|
|
249
|
+
const topLevel = scope === document
|
|
250
|
+
for (const rule of toList(alternatives)) {
|
|
251
|
+
const iterable = rule.selectorAll !== undefined
|
|
252
|
+
if (!iterable && rule.selector === undefined) {
|
|
253
|
+
const value = topLevel ? typed(wholePage(rule), rule) : undefined
|
|
254
|
+
if (!isEmpty(value)) return value
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
for (const selector of toList(iterable ? rule.selectorAll : rule.selector)) {
|
|
258
|
+
const value = iterable
|
|
259
|
+
? selectAll(scope, selector, rule, topLevel)
|
|
260
|
+
: resolve(query(scope, selector)[0], rule)
|
|
261
|
+
if (Array.isArray(value) ? value.length > 0 : !isEmpty(value)) return value
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const values = Object.fromEntries(
|
|
267
|
+
Object.entries(data)
|
|
268
|
+
.map(([name, alternatives]) => [name, select(document, alternatives)])
|
|
269
|
+
.filter(([, value]) => value !== undefined)
|
|
270
|
+
)
|
|
271
|
+
return { values, invalidSelectors }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const applyRules = async (page, rules) => {
|
|
275
|
+
const { values, invalidSelectors } = await page.evaluate(evaluateRules, assertRules(rules))
|
|
276
|
+
if (invalidSelectors.length > 0) {
|
|
277
|
+
throw new TypeError(`Invalid CSS selector: ${invalidSelectors[0]}`)
|
|
278
|
+
}
|
|
279
|
+
return values
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const readingText = rule => {
|
|
283
|
+
if (Array.isArray(rule)) return rule.map(readingText)
|
|
284
|
+
if (isNested(rule)) return { ...rule, attr: readingTextByDefault(rule.attr) }
|
|
285
|
+
return 'attr' in rule ? rule : { ...rule, attr: MODEL_DEFAULT_ATTR }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const readingTextByDefault = rules =>
|
|
289
|
+
Object.fromEntries(Object.entries(rules).map(([name, rule]) => [name, readingText(rule)]))
|
|
290
|
+
|
|
291
|
+
const hasEverySelector = rule =>
|
|
292
|
+
Array.isArray(rule)
|
|
293
|
+
? rule.every(hasEverySelector)
|
|
294
|
+
: hasLocation(rule) && (!isNested(rule) || Object.values(rule.attr).every(hasEverySelector))
|
|
295
|
+
|
|
296
|
+
const isComplete = rules => Object.values(rules).every(hasEverySelector)
|
|
297
|
+
|
|
298
|
+
const hasData = value => {
|
|
299
|
+
if (value === null || value === undefined || value === '') return false
|
|
300
|
+
if (typeof value !== 'object') return true
|
|
301
|
+
return Object.values(value).some(hasData)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
applyRules,
|
|
306
|
+
evaluateRules,
|
|
307
|
+
assertRules,
|
|
308
|
+
fillFields,
|
|
309
|
+
readingTextByDefault,
|
|
310
|
+
isComplete,
|
|
311
|
+
hasData
|
|
312
|
+
}
|
package/src/snapshot.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/* global location, scrollX, scrollY, innerWidth, innerHeight, NodeFilter */
|
|
2
|
+
// Adapted from browser-use/jev-ultrafast snapshot.js (MIT). See README.md.
|
|
3
|
+
module.exports = function snapshot () {
|
|
4
|
+
return (() => {
|
|
5
|
+
if (!document.body) return null
|
|
6
|
+
const cache = (window.__browserlessAgent ||= { ids: new WeakMap(), nodes: new Map(), next: 1 })
|
|
7
|
+
const identity = e => {
|
|
8
|
+
if (!cache.ids.has(e)) cache.ids.set(e, cache.next++)
|
|
9
|
+
const id = cache.ids.get(e)
|
|
10
|
+
cache.nodes.set(id, e)
|
|
11
|
+
return id
|
|
12
|
+
}
|
|
13
|
+
for (const [id, e] of cache.nodes) if (!e.isConnected) cache.nodes.delete(id)
|
|
14
|
+
const roots = [document]
|
|
15
|
+
for (const root of roots) {
|
|
16
|
+
for (const e of root.querySelectorAll('*')) if (e.shadowRoot) roots.push(e.shadowRoot)
|
|
17
|
+
}
|
|
18
|
+
const queryAll = selector => roots.flatMap(root => [...root.querySelectorAll(selector)])
|
|
19
|
+
const flatParent = e => e.assignedSlot || e.parentElement || e.getRootNode().host || null
|
|
20
|
+
const flatChildren = e => {
|
|
21
|
+
if (e.tagName === 'SLOT') {
|
|
22
|
+
const assigned = e.assignedNodes({ flatten: true })
|
|
23
|
+
return assigned.length ? assigned : [...e.childNodes]
|
|
24
|
+
}
|
|
25
|
+
return [...(e.shadowRoot || e).childNodes].filter(
|
|
26
|
+
child => !['STYLE', 'SCRIPT', 'TEMPLATE'].includes(child.tagName)
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
cache.closest = (e, selector) => {
|
|
30
|
+
for (let current = e; current; current = flatParent(current)) {
|
|
31
|
+
if (current.matches(selector)) return current
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
cache.contains = (ancestor, e) => {
|
|
36
|
+
for (let current = e; current; current = flatParent(current)) {
|
|
37
|
+
if (current === ancestor) return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
cache.elementFromPoint = (x, y) => {
|
|
42
|
+
let hit = document.elementFromPoint(x, y)
|
|
43
|
+
while (hit?.shadowRoot) {
|
|
44
|
+
const { shadowRoot } = hit
|
|
45
|
+
const inner = shadowRoot.elementsFromPoint(x, y).find(e => e.getRootNode() === shadowRoot)
|
|
46
|
+
if (!inner) break
|
|
47
|
+
hit = inner
|
|
48
|
+
}
|
|
49
|
+
return hit
|
|
50
|
+
}
|
|
51
|
+
cache.activeElement = () => {
|
|
52
|
+
let active = document.activeElement
|
|
53
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement
|
|
54
|
+
return active
|
|
55
|
+
}
|
|
56
|
+
const safe = e => !['password', 'file', 'hidden'].includes(e.type)
|
|
57
|
+
const visible = e =>
|
|
58
|
+
!cache.closest(e, '[aria-hidden="true"],[inert]') &&
|
|
59
|
+
e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
|
60
|
+
const name = (e, seen = new Set()) => {
|
|
61
|
+
if (!e || seen.has(e)) return ''
|
|
62
|
+
seen.add(e)
|
|
63
|
+
const referenced = (e.getAttribute('aria-labelledby') || '')
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.map(id => name(e.getRootNode().getElementById(id), seen))
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join(' ')
|
|
68
|
+
return (
|
|
69
|
+
referenced ||
|
|
70
|
+
e.getAttribute('aria-label') ||
|
|
71
|
+
[...(e.labels || [])]
|
|
72
|
+
.map(l => name(l, seen))
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join(' ') ||
|
|
75
|
+
(['button', 'submit', 'reset'].includes(e.type) ? e.value : '') ||
|
|
76
|
+
e.getAttribute('alt') ||
|
|
77
|
+
(e.tagName === 'INPUT'
|
|
78
|
+
? ''
|
|
79
|
+
: flatChildren(e)
|
|
80
|
+
.map(n =>
|
|
81
|
+
n.nodeType === 3
|
|
82
|
+
? n.textContent
|
|
83
|
+
: n.nodeType === 1 && n.getAttribute('aria-hidden') !== 'true'
|
|
84
|
+
? name(n, seen)
|
|
85
|
+
: ''
|
|
86
|
+
)
|
|
87
|
+
.join(' ')
|
|
88
|
+
.trim()) ||
|
|
89
|
+
e.getAttribute('title') ||
|
|
90
|
+
e.getAttribute('placeholder') ||
|
|
91
|
+
''
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
const roles = [
|
|
95
|
+
'button',
|
|
96
|
+
'link',
|
|
97
|
+
'checkbox',
|
|
98
|
+
'radio',
|
|
99
|
+
'switch',
|
|
100
|
+
'tab',
|
|
101
|
+
'menuitem',
|
|
102
|
+
'menuitemradio',
|
|
103
|
+
'option',
|
|
104
|
+
'gridcell',
|
|
105
|
+
'combobox',
|
|
106
|
+
'textbox',
|
|
107
|
+
'searchbox',
|
|
108
|
+
'spinbutton'
|
|
109
|
+
]
|
|
110
|
+
const selector =
|
|
111
|
+
'a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),' +
|
|
112
|
+
roles.map(role => '[role="' + role + '"]').join(',')
|
|
113
|
+
const editingHost = e => e.isContentEditable && !e.parentElement?.isContentEditable
|
|
114
|
+
const typeable = e => ['INPUT', 'TEXTAREA'].includes(e.tagName) || editingHost(e)
|
|
115
|
+
const role = e => {
|
|
116
|
+
const explicit = e.getAttribute('role')
|
|
117
|
+
if (roles.includes(explicit)) return explicit
|
|
118
|
+
if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button'
|
|
119
|
+
if (e.tagName === 'A') return 'link'
|
|
120
|
+
if (e.tagName === 'SELECT') return 'combobox'
|
|
121
|
+
if (e.tagName === 'TEXTAREA' || editingHost(e)) return 'textbox'
|
|
122
|
+
if (e.tagName === 'INPUT') {
|
|
123
|
+
if (['checkbox', 'radio'].includes(e.type)) return e.type
|
|
124
|
+
if (['button', 'submit', 'reset', 'image'].includes(e.type)) return 'button'
|
|
125
|
+
if (e.type === 'search') return 'searchbox'
|
|
126
|
+
if (e.type === 'number') return 'spinbutton'
|
|
127
|
+
if (['text', 'email', 'url', 'tel'].includes(e.type)) return 'textbox'
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
cache.pageKey = () => [
|
|
132
|
+
performance.timeOrigin,
|
|
133
|
+
location.href,
|
|
134
|
+
scrollX,
|
|
135
|
+
scrollY,
|
|
136
|
+
innerWidth,
|
|
137
|
+
innerHeight,
|
|
138
|
+
queryAll('input,textarea,select')
|
|
139
|
+
.filter(safe)
|
|
140
|
+
.map(e => [identity(e), e.value, e.checked, e.selectedIndex, e.disabled, e.readOnly])
|
|
141
|
+
]
|
|
142
|
+
cache.guard = e => {
|
|
143
|
+
if (!e?.isConnected || !visible(e)) return null
|
|
144
|
+
const scope =
|
|
145
|
+
cache.closest(e, 'form,dialog,[role="dialog"],article,li,tr,[role="row"]') || flatParent(e)
|
|
146
|
+
return [
|
|
147
|
+
identity(e),
|
|
148
|
+
role(e),
|
|
149
|
+
name(e),
|
|
150
|
+
e.value ?? null,
|
|
151
|
+
e.checked ?? null,
|
|
152
|
+
e.selectedIndex ?? null,
|
|
153
|
+
e.readOnly ?? null,
|
|
154
|
+
e.matches(':disabled'),
|
|
155
|
+
e.getAttribute('aria-disabled'),
|
|
156
|
+
e.getAttribute('aria-expanded'),
|
|
157
|
+
e.getAttribute('aria-checked'),
|
|
158
|
+
e.getAttribute('aria-selected'),
|
|
159
|
+
e.tagName === 'SELECT'
|
|
160
|
+
? [...e.options].map(o => [
|
|
161
|
+
o.value,
|
|
162
|
+
o.label,
|
|
163
|
+
o.disabled,
|
|
164
|
+
!!o.closest('optgroup[disabled]')
|
|
165
|
+
])
|
|
166
|
+
: null,
|
|
167
|
+
e.getAttribute('href'),
|
|
168
|
+
scope?.innerText?.slice(0, 6000) || '',
|
|
169
|
+
[...(cache.closest(e, 'form')?.querySelectorAll('input,textarea,select') || [])]
|
|
170
|
+
.filter(safe)
|
|
171
|
+
.map(field => [
|
|
172
|
+
identity(field),
|
|
173
|
+
field.value,
|
|
174
|
+
field.checked,
|
|
175
|
+
field.selectedIndex,
|
|
176
|
+
field.disabled,
|
|
177
|
+
field.readOnly
|
|
178
|
+
])
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
const actions = []
|
|
182
|
+
for (const e of queryAll(selector)) {
|
|
183
|
+
if (
|
|
184
|
+
!safe(e) ||
|
|
185
|
+
!visible(e) ||
|
|
186
|
+
e.matches(':disabled') ||
|
|
187
|
+
cache.closest(e, '[aria-disabled="true"]')
|
|
188
|
+
) {
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
const r = e.getBoundingClientRect()
|
|
192
|
+
const x = r.x + r.width / 2
|
|
193
|
+
const y = r.y + r.height / 2
|
|
194
|
+
const rname = role(e)
|
|
195
|
+
if (
|
|
196
|
+
!rname ||
|
|
197
|
+
r.width <= 0 ||
|
|
198
|
+
r.height <= 0 ||
|
|
199
|
+
x < 0 ||
|
|
200
|
+
y < 0 ||
|
|
201
|
+
x >= innerWidth ||
|
|
202
|
+
y >= innerHeight
|
|
203
|
+
) {
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue
|
|
207
|
+
const hit = cache.elementFromPoint(x, y)
|
|
208
|
+
if (hit && !cache.contains(e, hit)) continue
|
|
209
|
+
const base = {
|
|
210
|
+
node: identity(e),
|
|
211
|
+
role: rname,
|
|
212
|
+
label: name(e) || rname,
|
|
213
|
+
rect: { x: r.x, y: r.y, w: r.width, h: r.height }
|
|
214
|
+
}
|
|
215
|
+
for (const key of ['checked', 'selected', 'expanded']) {
|
|
216
|
+
const value = e.getAttribute('aria-' + key)
|
|
217
|
+
if (value !== null) base[key] = value
|
|
218
|
+
}
|
|
219
|
+
if (['checkbox', 'radio'].includes(e.type)) base.checked = String(e.checked)
|
|
220
|
+
if (e.tagName === 'SELECT') {
|
|
221
|
+
for (const o of e.options) {
|
|
222
|
+
if (!o.selected && !o.disabled && !o.closest('optgroup[disabled]')) {
|
|
223
|
+
actions.push({
|
|
224
|
+
...base,
|
|
225
|
+
kind: 'select',
|
|
226
|
+
value: o.value,
|
|
227
|
+
current_value: [...e.selectedOptions].map(o => o.label).join(', '),
|
|
228
|
+
label: base.label + ' → ' + o.label
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
const editable =
|
|
234
|
+
typeable(e) &&
|
|
235
|
+
!e.readOnly &&
|
|
236
|
+
e.getAttribute('aria-readonly') !== 'true' &&
|
|
237
|
+
(['textbox', 'searchbox', 'spinbutton'].includes(rname) ||
|
|
238
|
+
(rname === 'combobox' && ['INPUT', 'TEXTAREA'].includes(e.tagName)))
|
|
239
|
+
const value =
|
|
240
|
+
'value' in e
|
|
241
|
+
? String(e.value)
|
|
242
|
+
: e.isContentEditable || rname === 'combobox'
|
|
243
|
+
? e.innerText.trim()
|
|
244
|
+
: ''
|
|
245
|
+
actions.push({ ...base, kind: editable ? 'fill' : 'click', value })
|
|
246
|
+
if (editable) actions.push({ ...base, kind: 'click', value, label: 'Open ' + base.label })
|
|
247
|
+
if (editable && e.tagName === 'INPUT' && value) {
|
|
248
|
+
actions.push({ ...base, kind: 'submit', value, label: 'Submit ' + base.label })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const words = []
|
|
253
|
+
const range = document.createRange()
|
|
254
|
+
let length = 0
|
|
255
|
+
for (const root of roots) {
|
|
256
|
+
const walker = document.createTreeWalker(
|
|
257
|
+
root === document ? document.body : root,
|
|
258
|
+
NodeFilter.SHOW_TEXT
|
|
259
|
+
)
|
|
260
|
+
let node
|
|
261
|
+
while ((node = walker.nextNode()) && length < 6000) {
|
|
262
|
+
const value = node.textContent.trim()
|
|
263
|
+
const parent = node.parentElement || root.host
|
|
264
|
+
if (
|
|
265
|
+
!value ||
|
|
266
|
+
!parent ||
|
|
267
|
+
parent.closest('script,style,noscript,template') ||
|
|
268
|
+
!visible(parent)
|
|
269
|
+
) {
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
range.selectNodeContents(node)
|
|
273
|
+
const r = range.getBoundingClientRect()
|
|
274
|
+
if (
|
|
275
|
+
r.width > 0 &&
|
|
276
|
+
r.height > 0 &&
|
|
277
|
+
r.bottom > 0 &&
|
|
278
|
+
r.top < innerHeight &&
|
|
279
|
+
r.right > 0 &&
|
|
280
|
+
r.left < innerWidth
|
|
281
|
+
) {
|
|
282
|
+
words.push(value)
|
|
283
|
+
length += value.length
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const unsupported = []
|
|
288
|
+
for (const e of queryAll('iframe,canvas,input[type="password"],input[type="file"]')) {
|
|
289
|
+
if (visible(e)) unsupported.push(e.tagName === 'INPUT' ? e.type : e.tagName.toLowerCase())
|
|
290
|
+
}
|
|
291
|
+
const text = words.join('\n').slice(0, 6000)
|
|
292
|
+
const height = document.documentElement.scrollHeight
|
|
293
|
+
const pageKey = cache.pageKey()
|
|
294
|
+
const guards = {}
|
|
295
|
+
for (const a of actions) {
|
|
296
|
+
if (!(a.node in guards)) guards[a.node] = cache.guard(cache.nodes.get(a.node))
|
|
297
|
+
}
|
|
298
|
+
// Compare meaning and identity. Geometry is always resolved and hit-tested just before input.
|
|
299
|
+
const semantics = actions.map(({ rect, ...action }) => action)
|
|
300
|
+
const marker = [
|
|
301
|
+
performance.timeOrigin,
|
|
302
|
+
location.href,
|
|
303
|
+
scrollX,
|
|
304
|
+
scrollY,
|
|
305
|
+
innerWidth,
|
|
306
|
+
innerHeight,
|
|
307
|
+
document.title,
|
|
308
|
+
text,
|
|
309
|
+
semantics,
|
|
310
|
+
pageKey[6]
|
|
311
|
+
]
|
|
312
|
+
const omittedActions = Math.max(0, actions.length - 250)
|
|
313
|
+
actions.splice(250)
|
|
314
|
+
actions.forEach((a, i) => {
|
|
315
|
+
a.id = 'e' + (i + 1)
|
|
316
|
+
})
|
|
317
|
+
if (scrollY + innerHeight < height - 2) {
|
|
318
|
+
actions.push({ id: 'scroll_down', kind: 'scroll', label: 'Scroll down', delta: 560 })
|
|
319
|
+
}
|
|
320
|
+
if (scrollY > 0) {
|
|
321
|
+
actions.push({ id: 'scroll_up', kind: 'scroll', label: 'Scroll up', delta: -560 })
|
|
322
|
+
}
|
|
323
|
+
actions.push({ id: 'wait', kind: 'wait', label: 'Wait for the page to update' })
|
|
324
|
+
return {
|
|
325
|
+
url: location.href,
|
|
326
|
+
title: document.title,
|
|
327
|
+
w: innerWidth,
|
|
328
|
+
h: innerHeight,
|
|
329
|
+
text,
|
|
330
|
+
unsupported,
|
|
331
|
+
scroll: { y: scrollY, height },
|
|
332
|
+
actions,
|
|
333
|
+
marker,
|
|
334
|
+
pageKey,
|
|
335
|
+
guards,
|
|
336
|
+
omittedActions
|
|
337
|
+
}
|
|
338
|
+
})()
|
|
339
|
+
}
|