@browserless/agent 0.0.0-stage → 14.0.1

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/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
+ }
@@ -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
+ }