@browserless/agent 14.0.1 → 14.0.3

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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/snapshot.js +96 -34
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@browserless/agent",
3
3
  "description": "Drive a Puppeteer page toward a natural-language goal using finite decisions.",
4
4
  "homepage": "https://browserless.js.org",
5
- "version": "14.0.1",
5
+ "version": "14.0.3",
6
6
  "main": "src",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -47,6 +47,7 @@
47
47
  "test": "ava",
48
48
  "start": "browserless exec examples/wallapop.js --no-headless",
49
49
  "benchmark": "browserless exec scripts/benchmark.js",
50
+ "perf": "node scripts/perf-baseline.js",
50
51
  "compare": "browserless exec scripts/compare.js"
51
52
  },
52
53
  "license": "MIT",
@@ -58,5 +59,5 @@
58
59
  "access": "public"
59
60
  },
60
61
  "types": "src/index.d.ts",
61
- "gitHead": "3a6195884bad55e5b3bd1a9aaf221ceaeb7d3d32"
62
+ "gitHead": "f26f8ff96f57c2f2a848837097b560bd25ddd78a"
62
63
  }
package/src/snapshot.js CHANGED
@@ -1,4 +1,4 @@
1
- /* global location, scrollX, scrollY, innerWidth, innerHeight, NodeFilter */
1
+ /* global HTMLElement, location, scrollX, scrollY, innerWidth, innerHeight */
2
2
  // Adapted from browser-use/jev-ultrafast snapshot.js (MIT). See README.md.
3
3
  module.exports = function snapshot () {
4
4
  return (() => {
@@ -54,9 +54,39 @@ module.exports = function snapshot () {
54
54
  return active
55
55
  }
56
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 })
57
+ const hiddenMemo = new WeakMap()
58
+ const visibleMemo = new WeakMap()
59
+ const hiddenAncestor = element => {
60
+ const seen = []
61
+ for (let current = element; current; current = flatParent(current)) {
62
+ if (hiddenMemo.has(current)) {
63
+ const found = hiddenMemo.get(current)
64
+ for (const item of seen) hiddenMemo.set(item, found)
65
+ return found
66
+ }
67
+ seen.push(current)
68
+ if (current.matches('[aria-hidden="true"],[inert]')) {
69
+ for (const item of seen) hiddenMemo.set(item, current)
70
+ return current
71
+ }
72
+ }
73
+ for (const item of seen) hiddenMemo.set(item, null)
74
+ return null
75
+ }
76
+ const visible = e => {
77
+ if (visibleMemo.has(e)) return visibleMemo.get(e)
78
+ const shown =
79
+ !hiddenAncestor(e) && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
80
+ visibleMemo.set(e, shown)
81
+ return shown
82
+ }
83
+ // cache.guard runs again at input time, so it must not reuse this snapshot's memos.
84
+ const visibleNow = e => {
85
+ for (let current = e; current; current = flatParent(current)) {
86
+ if (current.matches('[aria-hidden="true"],[inert]')) return false
87
+ }
88
+ return e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
89
+ }
60
90
  const name = (e, seen = new Set()) => {
61
91
  if (!e || seen.has(e)) return ''
62
92
  seen.add(e)
@@ -139,8 +169,10 @@ module.exports = function snapshot () {
139
169
  .filter(safe)
140
170
  .map(e => [identity(e), e.value, e.checked, e.selectedIndex, e.disabled, e.readOnly])
141
171
  ]
172
+ // A named form control shadows the form's own innerText property.
173
+ const readInnerText = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText').get
142
174
  cache.guard = e => {
143
- if (!e?.isConnected || !visible(e)) return null
175
+ if (!e?.isConnected || !visibleNow(e)) return null
144
176
  const scope =
145
177
  cache.closest(e, 'form,dialog,[role="dialog"],article,li,tr,[role="row"]') || flatParent(e)
146
178
  return [
@@ -165,7 +197,9 @@ module.exports = function snapshot () {
165
197
  ])
166
198
  : null,
167
199
  e.getAttribute('href'),
168
- scope?.innerText?.slice(0, 6000) || '',
200
+ (scope instanceof HTMLElement
201
+ ? readInnerText.call(scope).slice(0, 6000)
202
+ : scope?.textContent?.slice(0, 6000)) || '',
169
203
  [...(cache.closest(e, 'form')?.querySelectorAll('input,textarea,select') || [])]
170
204
  .filter(safe)
171
205
  .map(field => [
@@ -252,37 +286,65 @@ module.exports = function snapshot () {
252
286
  const words = []
253
287
  const range = document.createRange()
254
288
  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
- }
289
+ const TEXT_SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'])
290
+ const intersectsViewport = rect =>
291
+ rect.width > 0 &&
292
+ rect.height > 0 &&
293
+ rect.bottom > 0 &&
294
+ rect.top < innerHeight &&
295
+ rect.right > 0 &&
296
+ rect.left < innerWidth
297
+ const outsideViewport = rect =>
298
+ rect.width > 0 &&
299
+ rect.height > 0 &&
300
+ (rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth)
301
+ const fullyInside = rect =>
302
+ rect.top >= 0 &&
303
+ rect.left >= 0 &&
304
+ rect.bottom <= innerHeight &&
305
+ rect.right <= innerWidth &&
306
+ rect.width > 0 &&
307
+ rect.height > 0
308
+ const pushText = (node, skipRect) => {
309
+ if (length >= 6000 || node.nodeType !== 3) return
310
+ const value = node.textContent.trim()
311
+ if (!value) return
312
+ if (!skipRect) {
272
313
  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
314
+ if (!intersectsViewport(range.getBoundingClientRect())) return
315
+ }
316
+ words.push(value)
317
+ length += value.length
318
+ }
319
+ // Off-screen subtrees are skipped. A fixed node inside one of those subtrees is not read.
320
+ const collectText = element => {
321
+ if (length >= 6000 || TEXT_SKIP.has(element.tagName)) return
322
+ const rect = element.getBoundingClientRect()
323
+ if (outsideViewport(rect)) return
324
+ const inside = fullyInside(rect)
325
+ if (!intersectsViewport(rect) || !visible(element)) {
326
+ for (const node of element.childNodes) {
327
+ if (node.nodeType === 1) collectText(node)
284
328
  }
329
+ return
330
+ }
331
+ for (const node of element.childNodes) {
332
+ if (length >= 6000) return
333
+ if (node.nodeType === 3) pushText(node, inside)
334
+ else if (node.nodeType === 1) collectText(node)
335
+ }
336
+ }
337
+ for (const root of roots) {
338
+ if (length >= 6000) break
339
+ if (root === document) {
340
+ if (document.body) collectText(document.body)
341
+ continue
342
+ }
343
+ const host = root.host
344
+ if (host && visible(host)) {
345
+ for (const node of root.childNodes) pushText(node)
285
346
  }
347
+ for (const child of root.children) collectText(child)
286
348
  }
287
349
  const unsupported = []
288
350
  for (const e of queryAll('iframe,canvas,input[type="password"],input[type="file"]')) {