@browserless/screenshot 13.6.2 → 13.6.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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@browserless/screenshot",
3
3
  "description": "Capture high-quality screenshots of websites with overlay support, device emulation, and automated image optimization.",
4
4
  "homepage": "https://browserless.js.org/#/?id=screenshoturl-options",
5
- "version": "13.6.2",
5
+ "version": "13.6.3",
6
6
  "main": "src/index.js",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@browserless/errors": "13.1.7",
36
- "@browserless/goto": "13.6.2",
36
+ "@browserless/goto": "13.6.3",
37
37
  "@kikobeats/content-type": "~1.0.4",
38
38
  "@kikobeats/time-span": "~1.0.12",
39
39
  "automad-prism-themes": "~0.3.7",
@@ -52,7 +52,7 @@
52
52
  "svg-gradient": "~1.0.4"
53
53
  },
54
54
  "devDependencies": {
55
- "@browserless/test": "13.6.2",
55
+ "@browserless/test": "13.6.3",
56
56
  "ava": "5"
57
57
  },
58
58
  "engines": {
@@ -71,5 +71,5 @@
71
71
  "timeout": "2m",
72
72
  "workerThreads": false
73
73
  },
74
- "gitHead": "7c7c6fee9e82050887d5499ed9dc3bac16c88c42"
74
+ "gitHead": "7bc1c12120186d999a5ddedbcad4e3b5b52fb8fb"
75
75
  }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ const waitForPrism = require('./pretty')
10
10
  const prettyTimeSpan = require('./time-span')
11
11
  const overlay = require('./overlay')
12
12
  const { waitForDomStability, resolveWaitForDom, DEFAULT_WAIT_FOR_DOM } = require('./wait-for-dom')
13
+ const { waitForReady } = require('./wait-for-ready')
13
14
 
14
15
  const timeSpan = require('@kikobeats/time-span')()
15
16
 
@@ -277,4 +278,5 @@ module.exports.captureWithNavigationRetry = captureWithNavigationRetry
277
278
  module.exports.isWhiteScreenshot = isWhiteScreenshot
278
279
  module.exports.waitForDomStability = waitForDomStability
279
280
  module.exports.resolveWaitForDom = resolveWaitForDom
281
+ module.exports.waitForReady = waitForReady
280
282
  module.exports.SCREENSHOT_DEFAULT_OPTS = SCREENSHOT_DEFAULT_OPTS
@@ -0,0 +1,167 @@
1
+ 'use strict'
2
+
3
+ // Navigation-tolerant readiness gate. Resolves once the page has been visually
4
+ // quiet — document height stable, every image decoded, `readyState` complete —
5
+ // for `quietMs`, bounded by `timeout`. Unlike a screenshot poll it takes no
6
+ // screenshots (which are expensive under software rendering, e.g. the GPU-less
7
+ // fleet's llvmpipe) and it survives a client-side navigation: when the
8
+ // execution context is destroyed mid-check the quiet window resets and polling
9
+ // continues instead of throwing (SPAs re-commit their own URL after load).
10
+
11
+ const { setTimeout: sleep } = require('node:timers/promises')
12
+ const { isContextDestroyed } = require('@browserless/errors')
13
+
14
+ // Evaluated in-page: a cheap snapshot of the paint/settle signals. `images`
15
+ // counts only images expected to settle: a lazy image well below the viewport
16
+ // sits outside Chromium's lazy-load fetch threshold and never starts loading
17
+ // without a scroll, so counting it could only stall the gate into its timeout.
18
+ // `decoded` counts every counted image that finished loading — a broken `<img>`
19
+ // counts too, so it can't stall the settle gate — while `painted` counts only
20
+ // images a screenshot would actually see: successfully decoded, rendered at a
21
+ // visible size (a 16×16 box or larger, so a tracking pixel doesn't count),
22
+ // inside the viewport, and not hidden via CSS — so a blank shell can't pass for
23
+ // content. `text` is the equivalent paint signal for imageless pages: visible
24
+ // characters inside the viewport, counted up to 200 (the threshold consumers
25
+ // rely on), and `fonts` reports whether webfonts finished loading — during a
26
+ // `font-display: block` period text renders invisible, exactly when a capture
27
+ // would be white. `viewport` is the in-page viewport height, so consumers can
28
+ // compare it against `height` without relying on `page.viewport()`, which is
29
+ // null under `defaultViewport: null`.
30
+ const snapshot = () => {
31
+ const vw = window.innerWidth || document.documentElement.clientWidth
32
+ const vh = window.innerHeight || document.documentElement.clientHeight
33
+ const imgs = document.images
34
+ let images = 0
35
+ let decoded = 0
36
+ let painted = 0
37
+ for (let i = 0; i < imgs.length; i++) {
38
+ const img = imgs[i]
39
+ if (!img.complete) {
40
+ // Two viewports of headroom stays inside the smallest fetch threshold
41
+ // Chromium uses for lazy images (~1250px on fast connections), so an
42
+ // undecoded lazy image within it is loading and worth waiting for;
43
+ // beyond it, the fetch never starts.
44
+ if (img.loading === 'lazy' && img.getBoundingClientRect().top > vh * 2) continue
45
+ images++
46
+ continue
47
+ }
48
+ images++
49
+ decoded++
50
+ if (img.naturalWidth === 0) continue
51
+ const rect = img.getBoundingClientRect()
52
+ if (rect.width * rect.height < 256) continue
53
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
54
+ if (
55
+ typeof img.checkVisibility === 'function' &&
56
+ !img.checkVisibility({ visibilityProperty: true, opacityProperty: true })
57
+ ) {
58
+ continue
59
+ }
60
+ painted++
61
+ }
62
+ // Counting stops at 200 chars, so a text-heavy page costs a handful of nodes,
63
+ // not a full DOM walk; only a near-blank shell walks every text node.
64
+ let text = 0
65
+ const walker = document.createTreeWalker(
66
+ document.body || document.documentElement,
67
+ window.NodeFilter.SHOW_TEXT
68
+ )
69
+ while (text < 200) {
70
+ const node = walker.nextNode()
71
+ if (!node) break
72
+ const value = node.nodeValue.trim()
73
+ if (!value) continue
74
+ const el = node.parentElement
75
+ if (!el) continue
76
+ const tag = el.tagName
77
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue
78
+ if (
79
+ typeof el.checkVisibility === 'function' &&
80
+ !el.checkVisibility({ visibilityProperty: true, opacityProperty: true })
81
+ ) {
82
+ continue
83
+ }
84
+ const range = document.createRange()
85
+ range.selectNodeContents(node)
86
+ const rect = range.getBoundingClientRect()
87
+ if (rect.width === 0 || rect.height === 0) continue
88
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
89
+ text += value.length
90
+ }
91
+ return {
92
+ height: document.documentElement.scrollHeight,
93
+ viewport: vh,
94
+ images,
95
+ decoded,
96
+ painted,
97
+ text,
98
+ fonts: !document.fonts || document.fonts.status === 'loaded',
99
+ complete: document.readyState === 'complete'
100
+ }
101
+ }
102
+
103
+ // 300ms of held quiet plus one 150ms poll to prove height stability puts the
104
+ // gate's floor at ~450ms. The hold guards against lulls (a page momentarily
105
+ // stable between async chunks); height stability + all images decoded +
106
+ // `readyState === 'complete'` carry most of the settle signal, so a longer
107
+ // hold buys little — below ~300ms it would start trusting coincidences.
108
+ const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) => {
109
+ if (!Number.isFinite(timeout)) throw new TypeError('timeout must be a finite number')
110
+ // The quiet window must fit within the budget with room to observe it, or the
111
+ // gate could never satisfy its own requirement and would always time out.
112
+ quietMs = Math.min(quietMs, Math.floor(timeout / 2))
113
+ const deadline = Date.now() + timeout
114
+ // Never sleep past the deadline: with a poll larger than the remaining
115
+ // budget, a full-length sleep would overshoot the timeout and steal time
116
+ // from whatever shares the caller's budget (the blank-SPA screenshot poll).
117
+ const nextPoll = () => sleep(Math.min(poll, Math.max(0, deadline - Date.now())))
118
+ let lastHeight = -1
119
+ let quietSince = 0
120
+ let resets = 0
121
+ let last = {
122
+ height: 0,
123
+ viewport: 0,
124
+ images: 0,
125
+ decoded: 0,
126
+ painted: 0,
127
+ text: 0,
128
+ fonts: false,
129
+ complete: false
130
+ }
131
+
132
+ while (Date.now() < deadline) {
133
+ let snap
134
+ try {
135
+ snap = await page.evaluate(snapshot)
136
+ } catch (error) {
137
+ // Only a client-side navigation tearing down the execution context is
138
+ // absorbed: reset the quiet window and keep polling. Any other evaluate
139
+ // failure is a real error and surfaces instead of masquerading as a
140
+ // timeout.
141
+ if (!isContextDestroyed(error)) throw error
142
+ resets++
143
+ lastHeight = -1
144
+ quietSince = 0
145
+ await nextPoll()
146
+ continue
147
+ }
148
+
149
+ last = snap
150
+ const imagesDecoded = snap.images === 0 || snap.decoded >= snap.images
151
+ const quiet = snap.complete && imagesDecoded && snap.height === lastHeight
152
+
153
+ if (quiet) {
154
+ if (quietSince === 0) quietSince = Date.now()
155
+ if (Date.now() - quietSince >= quietMs) return { ...snap, resets, timedOut: false }
156
+ } else {
157
+ quietSince = 0
158
+ lastHeight = snap.height
159
+ }
160
+
161
+ await nextPoll()
162
+ }
163
+
164
+ return { ...last, resets, timedOut: true }
165
+ }
166
+
167
+ module.exports = { waitForReady }