@browserless/screenshot 10.10.1 → 10.10.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/README.md CHANGED
@@ -69,6 +69,8 @@ const buffer = await browserless.screenshot('https://example.com', {
69
69
  | `element` | `string` | — | CSS selector for element screenshot |
70
70
  | `codeScheme` | `string` | `'atom-dark'` | Prism.js theme for code highlighting |
71
71
  | `waitUntil` | `string` | `'auto'` | When to consider navigation done |
72
+ | `waitForDom` | `number` | `0` | DOM stability window in ms (idle is `waitForDom / 10`, `0` disables DOM wait) |
73
+ | `isPageReady` | `function` | `({ isWhite }) => !isWhite` | Custom readiness predicate for retry loop |
72
74
  | `overlay` | `object` | `{}` | Browser overlay options |
73
75
 
74
76
  All [Puppeteer page.screenshot() options](https://pptr.dev/api/puppeteer.screenshotoptions) are supported.
@@ -231,8 +233,7 @@ This is a **core functionality package** for screenshot capture:
231
233
  | Package | Purpose |
232
234
  |---------|---------|
233
235
  | `@browserless/goto` | Page navigation with ad blocking |
234
- | `sharp` | Image composition for overlays |
235
- | `jimp` | White/blank screenshot detection |
236
+ | `sharp` | Image composition for overlays; White/blank screenshot detection |
236
237
  | `prism-themes` | Syntax highlighting themes |
237
238
  | `svg-gradient` | Generate gradient backgrounds |
238
239
  | `got` | Fetch remote background images |
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": "10.10.1",
5
+ "version": "10.10.3",
6
6
  "main": "src/index.js",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -32,14 +32,13 @@
32
32
  "jpeg"
33
33
  ],
34
34
  "dependencies": {
35
- "@browserless/goto": "^10.10.1",
35
+ "@browserless/goto": "^10.10.2",
36
36
  "@kikobeats/content-type": "~1.0.3",
37
37
  "@kikobeats/time-span": "~1.0.11",
38
38
  "debug-logfmt": "~1.4.7",
39
39
  "got": "~11.8.6",
40
40
  "is-html-content": "~1.0.0",
41
41
  "is-url-http": "~2.3.13",
42
- "jimp": "~1.6.0",
43
42
  "lodash": "~4.17.23",
44
43
  "map-values-deep": "~1.0.2",
45
44
  "mime": "~3.0.0",
@@ -72,5 +71,5 @@
72
71
  "timeout": "2m",
73
72
  "workerThreads": false
74
73
  },
75
- "gitHead": "32f6e72bcb489a83ac9659520a3961aeb97c47b7"
74
+ "gitHead": "4d2b893e8215a91d830f4471f51c567a53cd6bbb"
76
75
  }
package/src/index.js CHANGED
@@ -8,6 +8,21 @@ const isWhiteScreenshot = require('./is-white-screenshot')
8
8
  const waitForPrism = require('./pretty')
9
9
  const timeSpan = require('./time-span')
10
10
  const overlay = require('./overlay')
11
+ const { waitForDomStability, resolveWaitForDom, DEFAULT_WAIT_FOR_DOM } = require('./wait-for-dom')
12
+
13
+ const createElapsed = () => {
14
+ const start = Date.now()
15
+ return () => Date.now() - start
16
+ }
17
+
18
+ const getPageSnapshot = page =>
19
+ page.evaluate(() => ({
20
+ title: document.title || '',
21
+ bodyText: document.body ? document.body.innerText || '' : '',
22
+ url: window.location.href || ''
23
+ }))
24
+
25
+ const defaultIsPageReady = ({ isWhite }) => !isWhite
11
26
 
12
27
  const getBoundingClientRect = element => {
13
28
  const { top, left, height, width, x, y } = element.getBoundingClientRect()
@@ -32,37 +47,6 @@ const waitForImagesOnViewport = page =>
32
47
  )
33
48
  )
34
49
 
35
- const waitForDomStability = ({ idle, timeout } = {}) =>
36
- new Promise(resolve => {
37
- if (!document.body) return resolve({ status: 'no-body' })
38
-
39
- let lastChange = performance.now()
40
- const observer = new window.MutationObserver(() => {
41
- lastChange = performance.now()
42
- })
43
- observer.observe(document.body, {
44
- childList: true,
45
- subtree: true,
46
- attributes: false,
47
- characterData: false
48
- })
49
-
50
- const deadline = performance.now() + timeout
51
-
52
- ;(function check () {
53
- const now = performance.now()
54
- if (now - lastChange >= idle) {
55
- observer.disconnect()
56
- return resolve({ status: 'idle' })
57
- }
58
- if (now >= deadline) {
59
- observer.disconnect()
60
- return resolve({ status: 'timeout' })
61
- }
62
- window.requestAnimationFrame(check)
63
- })()
64
- })
65
-
66
50
  const scrollFullPageToLoadContent = async (page, timeout) => {
67
51
  const debug = require('debug-logfmt')('browserless:goto')
68
52
 
@@ -115,13 +99,21 @@ module.exports = ({ goto, ...gotoOpts }) => {
115
99
  return function screenshot (page) {
116
100
  return async (
117
101
  url,
118
- { codeScheme = 'atom-dark', overlay: overlayOpts = {}, waitUntil = 'auto', ...opts } = {}
102
+ {
103
+ codeScheme = 'atom-dark',
104
+ overlay: overlayOpts = {},
105
+ waitUntil = 'auto',
106
+ waitForDom = DEFAULT_WAIT_FOR_DOM,
107
+ isPageReady = defaultIsPageReady,
108
+ ...opts
109
+ } = {}
119
110
  ) => {
120
111
  let screenshot
121
112
  let response
122
113
 
123
114
  const beforeScreenshot = async (page, response, { element, fullPage = false } = {}) => {
124
115
  const timeout = goto.timeouts.action(opts.timeout)
116
+ const waitForDomOpts = resolveWaitForDom(waitForDom)
125
117
 
126
118
  let screenshotOpts = {}
127
119
  const tasks = [
@@ -135,6 +127,13 @@ module.exports = ({ goto, ...gotoOpts }) => {
135
127
  }
136
128
  ]
137
129
 
130
+ if (waitForDomOpts) {
131
+ tasks.push({
132
+ fn: () => page.evaluate(waitForDomStability, waitForDomOpts),
133
+ debug: 'beforeScreenshot:waitForDomStability'
134
+ })
135
+ }
136
+
138
137
  if (codeScheme && response) {
139
138
  tasks.push({
140
139
  fn: () => waitForPrism(page, response, { codeScheme, ...opts }),
@@ -170,38 +169,80 @@ module.exports = ({ goto, ...gotoOpts }) => {
170
169
  }
171
170
 
172
171
  const takeScreenshot = async opts => {
173
- screenshot = await page.screenshot(opts)
174
- const isWhite = await isWhiteScreenshot(screenshot)
175
- if (isWhite) {
176
- await goto.waitUntilAuto(page, opts)
172
+ const timeout = goto.timeouts.action(opts.timeout)
173
+ const elapsed = createElapsed()
174
+ let retry = 0
175
+ let isWhite = false
176
+ let isReady = false
177
+
178
+ do {
177
179
  screenshot = await page.screenshot(opts)
178
- }
179
- return { isWhite }
180
+ isWhite = await isWhiteScreenshot(screenshot)
181
+ const snapshotResult = await pReflect(getPageSnapshot(page))
182
+ const pageSnapshot = snapshotResult.isRejected ? {} : snapshotResult.value
183
+ const pageReadyResult = await pReflect(
184
+ opts.isPageReady({
185
+ page,
186
+ response: opts.response,
187
+ screenshot,
188
+ isWhite,
189
+ isWhiteScreenshot,
190
+ ...pageSnapshot
191
+ })
192
+ )
193
+ isReady = !pageReadyResult.isRejected && !!pageReadyResult.value
194
+
195
+ if (isReady || elapsed() >= timeout) break
196
+
197
+ retry += 1
198
+ await goto.waitUntilAuto(page, { timeout })
199
+ } while (!isReady)
200
+
201
+ return { isWhite, isReady, retry }
180
202
  }
181
203
 
182
- page.on('dialog', dialog => pReflect(dialog.dismiss()))
204
+ const onDialog = dialog => pReflect(dialog.dismiss())
205
+ page.on('dialog', onDialog)
183
206
 
184
- const timeScreenshot = timeSpan()
207
+ try {
208
+ const timeScreenshot = timeSpan()
185
209
 
186
- if (waitUntil !== 'auto') {
187
- ;({ response } = await goto(page, { ...opts, url, waitUntil }))
188
- const screenshotOpts = await beforeScreenshot(page, response, opts)
189
- screenshot = await page.screenshot({ ...opts, ...screenshotOpts })
190
- debug('screenshot', { waitUntil, duration: timeScreenshot() })
191
- } else {
192
- ;({ response } = await goto(page, { ...opts, url, waitUntil, waitUntilAuto }))
193
- async function waitUntilAuto (page, { response }) {
210
+ if (waitUntil !== 'auto') {
211
+ ;({ response } = await goto(page, { ...opts, url, waitUntil }))
194
212
  const screenshotOpts = await beforeScreenshot(page, response, opts)
195
- const { isWhite } = await takeScreenshot({ ...opts, ...screenshotOpts })
196
- debug('screenshot', { waitUntil, isWhite, duration: timeScreenshot() })
213
+ screenshot = await page.screenshot({ ...opts, ...screenshotOpts })
214
+ debug('screenshot', { waitUntil, duration: timeScreenshot() })
215
+ } else {
216
+ ;({ response } = await goto(page, { ...opts, url, waitUntil, waitUntilAuto }))
217
+ async function waitUntilAuto (page, { response }) {
218
+ const screenshotOpts = await beforeScreenshot(page, response, opts)
219
+ const { isWhite, isReady, retry } = await takeScreenshot({
220
+ ...opts,
221
+ ...screenshotOpts,
222
+ isPageReady,
223
+ response
224
+ })
225
+ debug('screenshot', {
226
+ waitUntil,
227
+ isReady,
228
+ isWhite,
229
+ retry,
230
+ duration: timeScreenshot()
231
+ })
232
+ }
197
233
  }
198
- }
199
234
 
200
- return Object.keys(overlayOpts).length === 0
201
- ? screenshot
202
- : overlay(screenshot, { ...opts, ...overlayOpts, viewport: page.viewport() })
235
+ return Object.keys(overlayOpts).length === 0
236
+ ? screenshot
237
+ : overlay(screenshot, { ...opts, ...overlayOpts, viewport: page.viewport() })
238
+ } finally {
239
+ page.off('dialog', onDialog)
240
+ }
203
241
  }
204
242
  }
205
243
  }
206
244
 
207
245
  module.exports.isWhiteScreenshot = isWhiteScreenshot
246
+ module.exports.waitForDomStability = waitForDomStability
247
+ module.exports.resolveWaitForDom = resolveWaitForDom
248
+ module.exports.DEFAULT_WAIT_FOR_DOM = DEFAULT_WAIT_FOR_DOM
@@ -1,32 +1,67 @@
1
1
  'use strict'
2
2
 
3
- const { Jimp } = require('jimp')
3
+ const sharp = require('sharp')
4
4
 
5
- module.exports = async uint8array => {
6
- try {
7
- const image = await Jimp.fromBuffer(Buffer.from(uint8array))
8
- const firstPixel = image.getPixelColor(0, 0)
9
- const height = image.bitmap.height
10
- const width = image.bitmap.width
11
-
12
- // For 2D grid sampling, calculate stepSize to achieve approximately the target sample percentage.
13
- // When sampling every 'stepSize' pixels in both dimensions, actual samples = (height/stepSize) * (width/stepSize).
14
- // To achieve samplePercentage, we need: (h*w)/(stepSize²) ≈ samplePercentage*(h*w)
15
- // Therefore: stepSize ≈ sqrt(1 / samplePercentage)
16
- const samplePercentage = 0.25 // Sample ~25% of the image
17
- const stepSize = Math.max(1, Math.ceil(Math.sqrt(1 / samplePercentage)))
18
-
19
- for (let i = 0; i < height; i += stepSize) {
20
- for (let j = 0; j < width; j += stepSize) {
21
- if (firstPixel !== image.getPixelColor(j, i)) return false
5
+ const SAMPLE_PERCENTAGE = 0.25
6
+ const SAMPLE_STEP_SIZE = Math.max(1, Math.ceil(Math.sqrt(1 / SAMPLE_PERCENTAGE)))
7
+ const WHITE_PIXEL_THRESHOLD = 245
8
+ const WHITE_COLOR_VARIANCE_TOLERANCE = 8
9
+
10
+ const blendOnWhite = (channel, alpha) => (channel * alpha + 255 * (255 - alpha)) / 255
11
+
12
+ const isWhiteSampledImage = (data, { width, height, channels }) => {
13
+ if (!width || !height || !channels || data.length < channels) return false
14
+
15
+ let minR = 255
16
+ let minG = 255
17
+ let minB = 255
18
+ let maxR = 0
19
+ let maxG = 0
20
+ let maxB = 0
21
+
22
+ for (let y = 0; y < height; y += SAMPLE_STEP_SIZE) {
23
+ const rowOffset = y * width * channels
24
+
25
+ for (let x = 0; x < width; x += SAMPLE_STEP_SIZE) {
26
+ const pixelOffset = rowOffset + x * channels
27
+ const a = channels > 3 ? data[pixelOffset + 3] : 255
28
+ const r = blendOnWhite(data[pixelOffset], a)
29
+ const g = blendOnWhite(data[pixelOffset + 1], a)
30
+ const b = blendOnWhite(data[pixelOffset + 2], a)
31
+
32
+ if (r < WHITE_PIXEL_THRESHOLD || g < WHITE_PIXEL_THRESHOLD || b < WHITE_PIXEL_THRESHOLD) {
33
+ return false
22
34
  }
23
- }
24
35
 
25
- return true
26
- } catch (error) {
27
- if (error.message.includes('maxMemoryUsageInMB')) {
28
- return false
36
+ if (r < minR) minR = r
37
+ if (g < minG) minG = g
38
+ if (b < minB) minB = b
39
+ if (r > maxR) maxR = r
40
+ if (g > maxG) maxG = g
41
+ if (b > maxB) maxB = b
29
42
  }
30
- throw error
31
43
  }
44
+
45
+ return (
46
+ maxR - minR <= WHITE_COLOR_VARIANCE_TOLERANCE &&
47
+ maxG - minG <= WHITE_COLOR_VARIANCE_TOLERANCE &&
48
+ maxB - minB <= WHITE_COLOR_VARIANCE_TOLERANCE
49
+ )
32
50
  }
51
+
52
+ module.exports = async uint8array => {
53
+ const input = Buffer.isBuffer(uint8array) ? uint8array : Buffer.from(uint8array)
54
+ const { data, info } = await sharp(input)
55
+ .ensureAlpha()
56
+ .raw()
57
+ .toBuffer({ resolveWithObject: true })
58
+
59
+ return isWhiteSampledImage(data, info)
60
+ }
61
+
62
+ module.exports.SAMPLE_PERCENTAGE = SAMPLE_PERCENTAGE
63
+ module.exports.SAMPLE_STEP_SIZE = SAMPLE_STEP_SIZE
64
+ module.exports.WHITE_PIXEL_THRESHOLD = WHITE_PIXEL_THRESHOLD
65
+ module.exports.WHITE_COLOR_VARIANCE_TOLERANCE = WHITE_COLOR_VARIANCE_TOLERANCE
66
+ module.exports.blendOnWhite = blendOnWhite
67
+ module.exports.isWhiteSampledImage = isWhiteSampledImage
@@ -0,0 +1,53 @@
1
+ 'use strict'
2
+
3
+ const DEFAULT_WAIT_FOR_DOM = 0
4
+ const WAIT_FOR_DOM_IDLE_RATIO = 10
5
+
6
+ const resolveWaitForDom = waitForDom => {
7
+ const timeout = Number.isFinite(waitForDom) && waitForDom >= 0 ? waitForDom : DEFAULT_WAIT_FOR_DOM
8
+
9
+ if (timeout === 0) return undefined
10
+
11
+ return {
12
+ timeout,
13
+ idle: timeout / WAIT_FOR_DOM_IDLE_RATIO
14
+ }
15
+ }
16
+
17
+ const waitForDomStability = ({ idle, timeout } = {}) =>
18
+ new Promise(resolve => {
19
+ if (!document.body) return resolve({ status: 'no-body' })
20
+
21
+ let lastChange = performance.now()
22
+ const observer = new window.MutationObserver(() => {
23
+ lastChange = performance.now()
24
+ })
25
+ observer.observe(document.body, {
26
+ childList: true,
27
+ subtree: true,
28
+ attributes: false,
29
+ characterData: false
30
+ })
31
+
32
+ const deadline = performance.now() + timeout
33
+
34
+ ;(function check () {
35
+ const now = performance.now()
36
+ if (now - lastChange >= idle) {
37
+ observer.disconnect()
38
+ return resolve({ status: 'idle' })
39
+ }
40
+ if (now >= deadline) {
41
+ observer.disconnect()
42
+ return resolve({ status: 'timeout' })
43
+ }
44
+ window.requestAnimationFrame(check)
45
+ })()
46
+ })
47
+
48
+ module.exports = {
49
+ DEFAULT_WAIT_FOR_DOM,
50
+ WAIT_FOR_DOM_IDLE_RATIO,
51
+ resolveWaitForDom,
52
+ waitForDomStability
53
+ }