@browserless/screenshot 13.6.2 → 13.6.4
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 +4 -4
- package/src/index.js +2 -0
- package/src/wait-for-ready.js +276 -0
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.
|
|
5
|
+
"version": "13.6.4",
|
|
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.
|
|
36
|
+
"@browserless/goto": "13.6.4",
|
|
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.
|
|
55
|
+
"@browserless/test": "13.6.4",
|
|
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": "
|
|
74
|
+
"gitHead": "c9d9abe1af82ce0ddf4ce1b6e8fe500c1b58532a"
|
|
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,276 @@
|
|
|
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 skipping text whose color matches its effective background
|
|
26
|
+
// (invisible on capture), and `fonts` reports whether webfonts finished
|
|
27
|
+
// loading — during a `font-display: block` period text renders invisible,
|
|
28
|
+
// exactly when a capture would be white. `covered` reports whether the counted
|
|
29
|
+
// content hides behind an opaque viewport-covering layer (a fixed white
|
|
30
|
+
// loading overlay passes every DOM signal while a capture stays white):
|
|
31
|
+
// hit-testing catches interactive overlays, and a root-level scan catches
|
|
32
|
+
// `pointer-events: none` layers hit-testing can't see. `viewport` is the
|
|
33
|
+
// in-page viewport height, so consumers can compare it against `height`
|
|
34
|
+
// without relying on `page.viewport()`, which is null under
|
|
35
|
+
// `defaultViewport: null`.
|
|
36
|
+
const snapshot = () => {
|
|
37
|
+
const vw = window.innerWidth || document.documentElement.clientWidth
|
|
38
|
+
const vh = window.innerHeight || document.documentElement.clientHeight
|
|
39
|
+
|
|
40
|
+
// Computed colors resolve to `rgb()`/`rgba()`; anything else is unknown.
|
|
41
|
+
const parseColor = value => {
|
|
42
|
+
const match = /rgba?\(([^)]+)\)/.exec(value || '')
|
|
43
|
+
if (!match) return null
|
|
44
|
+
const parts = match[1].split(',').map(parseFloat)
|
|
45
|
+
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length === 4 ? parts[3] : 1 }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// First opaque background up the ancestor chain; the canvas default (white)
|
|
49
|
+
// when every ancestor is transparent.
|
|
50
|
+
const effectiveBackground = el => {
|
|
51
|
+
for (let node = el; node; node = node.parentElement) {
|
|
52
|
+
const color = parseColor(window.getComputedStyle(node).backgroundColor)
|
|
53
|
+
if (color && color.a >= 0.99) return color
|
|
54
|
+
}
|
|
55
|
+
return { r: 255, g: 255, b: 255, a: 1 }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sameColor = (a, b) =>
|
|
59
|
+
Math.abs(a.r - b.r) < 10 && Math.abs(a.g - b.g) < 10 && Math.abs(a.b - b.b) < 10
|
|
60
|
+
|
|
61
|
+
// A layer only blanks a capture when it is itself painted solid: visible,
|
|
62
|
+
// full opacity, and an opaque background color or a background image.
|
|
63
|
+
const isOpaqueLayer = el => {
|
|
64
|
+
const style = window.getComputedStyle(el)
|
|
65
|
+
if (style.visibility === 'hidden' || parseFloat(style.opacity) < 0.99) return false
|
|
66
|
+
const background = parseColor(style.backgroundColor)
|
|
67
|
+
return (background && background.a >= 0.99) || style.backgroundImage !== 'none'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const coversViewport = el => {
|
|
71
|
+
const rect = el.getBoundingClientRect()
|
|
72
|
+
const width = Math.min(rect.right, vw) - Math.max(rect.left, 0)
|
|
73
|
+
const height = Math.min(rect.bottom, vh) - Math.max(rect.top, 0)
|
|
74
|
+
return width > 0 && height > 0 && width * height >= vw * vh * 0.9
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Up to a handful of counted content samples — enough to hit-test without
|
|
78
|
+
// turning the snapshot into a layout storm. The clamp keeps each probe point
|
|
79
|
+
// inside the viewport; the rect checks below guarantee it stays inside the
|
|
80
|
+
// sampled content's own box.
|
|
81
|
+
const contentPoints = []
|
|
82
|
+
const samplePoint = (el, rect) => {
|
|
83
|
+
if (contentPoints.length >= 4) return
|
|
84
|
+
contentPoints.push({
|
|
85
|
+
el,
|
|
86
|
+
x: Math.max(0, Math.min(vw - 1, rect.left + rect.width / 2)),
|
|
87
|
+
y: Math.max(0, Math.min(vh - 1, rect.top + rect.height / 2))
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const imgs = document.images
|
|
92
|
+
let images = 0
|
|
93
|
+
let decoded = 0
|
|
94
|
+
let painted = 0
|
|
95
|
+
for (let i = 0; i < imgs.length; i++) {
|
|
96
|
+
const img = imgs[i]
|
|
97
|
+
if (!img.complete) {
|
|
98
|
+
// Two viewports of headroom stays inside the smallest fetch threshold
|
|
99
|
+
// Chromium uses for lazy images (~1250px on fast connections), so an
|
|
100
|
+
// undecoded lazy image within it is loading and worth waiting for;
|
|
101
|
+
// beyond it, the fetch never starts.
|
|
102
|
+
if (img.loading === 'lazy' && img.getBoundingClientRect().top > vh * 2) continue
|
|
103
|
+
images++
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
images++
|
|
107
|
+
decoded++
|
|
108
|
+
if (img.naturalWidth === 0) continue
|
|
109
|
+
const rect = img.getBoundingClientRect()
|
|
110
|
+
if (rect.width * rect.height < 256) continue
|
|
111
|
+
if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
|
|
112
|
+
if (
|
|
113
|
+
typeof img.checkVisibility === 'function' &&
|
|
114
|
+
!img.checkVisibility({ visibilityProperty: true, opacityProperty: true })
|
|
115
|
+
) {
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
painted++
|
|
119
|
+
samplePoint(img, rect)
|
|
120
|
+
}
|
|
121
|
+
// Counting stops at 200 chars, so a text-heavy page costs a handful of nodes,
|
|
122
|
+
// not a full DOM walk; only a near-blank shell walks every text node.
|
|
123
|
+
let text = 0
|
|
124
|
+
const walker = document.createTreeWalker(
|
|
125
|
+
document.body || document.documentElement,
|
|
126
|
+
window.NodeFilter.SHOW_TEXT
|
|
127
|
+
)
|
|
128
|
+
while (text < 200) {
|
|
129
|
+
const node = walker.nextNode()
|
|
130
|
+
if (!node) break
|
|
131
|
+
const value = node.nodeValue.trim()
|
|
132
|
+
if (!value) continue
|
|
133
|
+
const el = node.parentElement
|
|
134
|
+
if (!el) continue
|
|
135
|
+
const tag = el.tagName
|
|
136
|
+
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue
|
|
137
|
+
if (
|
|
138
|
+
typeof el.checkVisibility === 'function' &&
|
|
139
|
+
!el.checkVisibility({ visibilityProperty: true, opacityProperty: true })
|
|
140
|
+
) {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const range = document.createRange()
|
|
144
|
+
range.selectNodeContents(node)
|
|
145
|
+
const rect = range.getBoundingClientRect()
|
|
146
|
+
if (rect.width === 0 || rect.height === 0) continue
|
|
147
|
+
if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
|
|
148
|
+
// White-on-white (or any color-on-same-color) text passes every geometry
|
|
149
|
+
// check while a capture shows nothing: don't count it as painted text.
|
|
150
|
+
const color = parseColor(window.getComputedStyle(el).color)
|
|
151
|
+
if (color && (color.a < 0.05 || sameColor(color, effectiveBackground(el)))) continue
|
|
152
|
+
text += value.length
|
|
153
|
+
samplePoint(el, rect)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Is this content sample's paint hidden behind an unrelated opaque,
|
|
157
|
+
// viewport-covering layer? Walk up from the hit-tested element: anything
|
|
158
|
+
// related to the sample (itself, an ancestor, a descendant) paints with it,
|
|
159
|
+
// and the walk stops at the first common ancestor — an ancestor's background
|
|
160
|
+
// always paints below its own descendants.
|
|
161
|
+
const coveredAt = ({ el, x, y }) => {
|
|
162
|
+
const top = document.elementFromPoint(x, y)
|
|
163
|
+
if (!top) return false
|
|
164
|
+
for (let node = top; node && node !== document.documentElement; node = node.parentElement) {
|
|
165
|
+
if (node === el || node.contains(el) || el.contains(node)) return false
|
|
166
|
+
if (coversViewport(node) && isOpaqueLayer(node)) return true
|
|
167
|
+
}
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only pages the fast path could trust get the (layout-forcing) cover check.
|
|
172
|
+
let covered = false
|
|
173
|
+
if (painted > 0 || text >= 200) {
|
|
174
|
+
covered = contentPoints.some(coveredAt)
|
|
175
|
+
// `elementFromPoint` skips `pointer-events: none` elements, exactly how
|
|
176
|
+
// fading loading overlays are styled — scan root-level layers for one.
|
|
177
|
+
// Overlays mount as direct children of <body>; a deeper scan would cost a
|
|
178
|
+
// full styled DOM walk on every poll for a marginal case.
|
|
179
|
+
if (!covered && document.body) {
|
|
180
|
+
const layers = document.body.children
|
|
181
|
+
for (let i = 0; i < layers.length && !covered; i++) {
|
|
182
|
+
const layer = layers[i]
|
|
183
|
+
const style = window.getComputedStyle(layer)
|
|
184
|
+
if (style.pointerEvents !== 'none') continue
|
|
185
|
+
if (
|
|
186
|
+
style.position !== 'fixed' &&
|
|
187
|
+
style.position !== 'absolute' &&
|
|
188
|
+
style.position !== 'sticky'
|
|
189
|
+
) {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (contentPoints.some(point => layer.contains(point.el))) continue
|
|
193
|
+
if (coversViewport(layer) && isOpaqueLayer(layer)) covered = true
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
height: document.documentElement.scrollHeight,
|
|
200
|
+
viewport: vh,
|
|
201
|
+
images,
|
|
202
|
+
decoded,
|
|
203
|
+
painted,
|
|
204
|
+
text,
|
|
205
|
+
covered,
|
|
206
|
+
fonts: !document.fonts || document.fonts.status === 'loaded',
|
|
207
|
+
complete: document.readyState === 'complete'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 300ms of held quiet plus one 150ms poll to prove height stability puts the
|
|
212
|
+
// gate's floor at ~450ms. The hold guards against lulls (a page momentarily
|
|
213
|
+
// stable between async chunks); height stability + all images decoded +
|
|
214
|
+
// `readyState === 'complete'` carry most of the settle signal, so a longer
|
|
215
|
+
// hold buys little — below ~300ms it would start trusting coincidences.
|
|
216
|
+
const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) => {
|
|
217
|
+
if (!Number.isFinite(timeout)) throw new TypeError('timeout must be a finite number')
|
|
218
|
+
// The quiet window must fit within the budget with room to observe it, or the
|
|
219
|
+
// gate could never satisfy its own requirement and would always time out.
|
|
220
|
+
quietMs = Math.min(quietMs, Math.floor(timeout / 2))
|
|
221
|
+
const deadline = Date.now() + timeout
|
|
222
|
+
// Never sleep past the deadline: with a poll larger than the remaining
|
|
223
|
+
// budget, a full-length sleep would overshoot the timeout and steal time
|
|
224
|
+
// from whatever shares the caller's budget (the blank-SPA screenshot poll).
|
|
225
|
+
const nextPoll = () => sleep(Math.min(poll, Math.max(0, deadline - Date.now())))
|
|
226
|
+
let lastHeight = -1
|
|
227
|
+
let quietSince = 0
|
|
228
|
+
let resets = 0
|
|
229
|
+
let last = {
|
|
230
|
+
height: 0,
|
|
231
|
+
viewport: 0,
|
|
232
|
+
images: 0,
|
|
233
|
+
decoded: 0,
|
|
234
|
+
painted: 0,
|
|
235
|
+
text: 0,
|
|
236
|
+
covered: false,
|
|
237
|
+
fonts: false,
|
|
238
|
+
complete: false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
let snap
|
|
243
|
+
try {
|
|
244
|
+
snap = await page.evaluate(snapshot)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Only a client-side navigation tearing down the execution context is
|
|
247
|
+
// absorbed: reset the quiet window and keep polling. Any other evaluate
|
|
248
|
+
// failure is a real error and surfaces instead of masquerading as a
|
|
249
|
+
// timeout.
|
|
250
|
+
if (!isContextDestroyed(error)) throw error
|
|
251
|
+
resets++
|
|
252
|
+
lastHeight = -1
|
|
253
|
+
quietSince = 0
|
|
254
|
+
await nextPoll()
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
last = snap
|
|
259
|
+
const imagesDecoded = snap.images === 0 || snap.decoded >= snap.images
|
|
260
|
+
const quiet = snap.complete && imagesDecoded && snap.height === lastHeight
|
|
261
|
+
|
|
262
|
+
if (quiet) {
|
|
263
|
+
if (quietSince === 0) quietSince = Date.now()
|
|
264
|
+
if (Date.now() - quietSince >= quietMs) return { ...snap, resets, timedOut: false }
|
|
265
|
+
} else {
|
|
266
|
+
quietSince = 0
|
|
267
|
+
lastHeight = snap.height
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await nextPoll()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { ...last, resets, timedOut: true }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = { waitForReady }
|