@browserless/screenshot 13.6.1 → 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 +4 -4
- package/src/index.js +16 -6
- package/src/wait-for-ready.js +167 -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.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.
|
|
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.
|
|
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": "
|
|
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
|
|
|
@@ -109,6 +110,14 @@ const waitForElement = async (page, element) => {
|
|
|
109
110
|
return screenshotOpts
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
const SCREENSHOT_DEFAULT_OPTS = {
|
|
114
|
+
codeScheme: 'atom-dark',
|
|
115
|
+
overlay: {},
|
|
116
|
+
waitUntil: 'auto',
|
|
117
|
+
waitForDom: DEFAULT_WAIT_FOR_DOM,
|
|
118
|
+
isPageReady: defaultIsPageReady
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
module.exports = ({ goto, ...gotoOpts }) => {
|
|
113
122
|
goto = goto || createGoto(gotoOpts)
|
|
114
123
|
|
|
@@ -116,11 +125,11 @@ module.exports = ({ goto, ...gotoOpts }) => {
|
|
|
116
125
|
return async (
|
|
117
126
|
url,
|
|
118
127
|
{
|
|
119
|
-
codeScheme =
|
|
120
|
-
overlay: overlayOpts =
|
|
121
|
-
waitUntil =
|
|
122
|
-
waitForDom =
|
|
123
|
-
isPageReady =
|
|
128
|
+
codeScheme = SCREENSHOT_DEFAULT_OPTS.codeScheme,
|
|
129
|
+
overlay: overlayOpts = SCREENSHOT_DEFAULT_OPTS.overlay,
|
|
130
|
+
waitUntil = SCREENSHOT_DEFAULT_OPTS.waitUntil,
|
|
131
|
+
waitForDom = SCREENSHOT_DEFAULT_OPTS.waitForDom,
|
|
132
|
+
isPageReady = SCREENSHOT_DEFAULT_OPTS.isPageReady,
|
|
124
133
|
...opts
|
|
125
134
|
} = {}
|
|
126
135
|
) => {
|
|
@@ -269,4 +278,5 @@ module.exports.captureWithNavigationRetry = captureWithNavigationRetry
|
|
|
269
278
|
module.exports.isWhiteScreenshot = isWhiteScreenshot
|
|
270
279
|
module.exports.waitForDomStability = waitForDomStability
|
|
271
280
|
module.exports.resolveWaitForDom = resolveWaitForDom
|
|
272
|
-
module.exports.
|
|
281
|
+
module.exports.waitForReady = waitForReady
|
|
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 }
|