@browserless/screenshot 13.6.8 → 13.6.9

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,7 +69,6 @@ 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
72
  | `isPageReady` | `function` | `({ isWhite }) => !isWhite` | Custom readiness predicate for retry loop |
74
73
  | `overlay` | `object` | `{}` | Browser overlay options |
75
74
 
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.8",
5
+ "version": "13.6.9",
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.6.7",
36
- "@browserless/goto": "13.6.8",
36
+ "@browserless/goto": "13.6.9",
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.8",
55
+ "@browserless/test": "13.6.9",
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": "1967ae39bd38666f20239ecc1befd484ed8349e9"
74
+ "gitHead": "c625834b3b37e036432a0756d67eccc149861ad0"
75
75
  }
package/src/index.js CHANGED
@@ -9,16 +9,18 @@ const isWhiteScreenshot = require('./is-white-screenshot')
9
9
  const waitForPrism = require('./pretty')
10
10
  const prettyTimeSpan = require('./time-span')
11
11
  const overlay = require('./overlay')
12
- const { waitForDomStability, resolveWaitForDom, DEFAULT_WAIT_FOR_DOM } = require('./wait-for-dom')
13
- const { waitForReady } = require('./wait-for-ready')
12
+ const { waitForDomStability } = require('./wait-for-dom')
13
+ const { waitForReady, paintSignals } = require('./wait-for-ready')
14
+ const {
15
+ expandOverflow,
16
+ scrollFullPageToLoadContent,
17
+ prepareFullDocument,
18
+ resolveScrollTimeout,
19
+ tryHydrateScroll
20
+ } = require('./prepare-full-document')
14
21
 
15
22
  const timeSpan = require('@kikobeats/time-span')()
16
23
 
17
- // Retry a page capture (screenshot/pdf) that races with a client-side
18
- // navigation. When the execution context is destroyed mid-capture, the page is
19
- // navigating: wait for it to settle via `waitUntilAuto` and retry in-place,
20
- // bounded by `timeout`, rather than failing the whole request. SPAs (e.g.
21
- // scribd) navigate client-side after load, so the initial capture often races.
22
24
  const captureWithNavigationRetry = async (capture, { page, goto, timeout }) => {
23
25
  const elapsed = timeSpan()
24
26
  while (true) {
@@ -32,7 +34,7 @@ const captureWithNavigationRetry = async (capture, { page, goto, timeout }) => {
32
34
  }
33
35
  }
34
36
 
35
- const getPageSnapshot = page =>
37
+ const getPageMeta = page =>
36
38
  page.evaluate(() => ({
37
39
  title: document.title || '',
38
40
  bodyText: document.body ? document.body.innerText || '' : '',
@@ -41,6 +43,18 @@ const getPageSnapshot = page =>
41
43
 
42
44
  const defaultIsPageReady = ({ isWhite }) => !isWhite
43
45
 
46
+ const checkPageReady = async (page, { isPageReady, response, screenshot, isWhite } = {}) => {
47
+ let pageMeta = {}
48
+ if (isPageReady !== defaultIsPageReady) {
49
+ const pageMetaResult = await pReflect(getPageMeta(page))
50
+ pageMeta = pageMetaResult.isRejected ? {} : pageMetaResult.value
51
+ }
52
+ const pageReadyResult = await pReflect(
53
+ isPageReady({ page, response, screenshot, isWhite, isWhiteScreenshot, ...pageMeta })
54
+ )
55
+ return !pageReadyResult.isRejected && !!pageReadyResult.value
56
+ }
57
+
44
58
  const getBoundingClientRect = element => {
45
59
  const { top, left, height, width, x, y } = element.getBoundingClientRect()
46
60
  return { top, left, height, width, x, y }
@@ -64,41 +78,6 @@ const waitForImagesOnViewport = page =>
64
78
  )
65
79
  )
66
80
 
67
- const scrollFullPageToLoadContent = async (page, timeout) => {
68
- const debug = require('debug-logfmt')('browserless:goto')
69
-
70
- const duration = debug.duration()
71
- const result = await page.evaluate(waitForDomStability, {
72
- idle: timeout / 2 / 2,
73
- timeout: timeout / 2
74
- })
75
-
76
- duration('waitForDomStability', result)
77
-
78
- await page.evaluate(
79
- timeout =>
80
- new Promise(resolve => {
81
- let currentScrollPosition = 0
82
- const scrollStep = Math.floor(window.innerHeight)
83
- const pageHeight = document.body.scrollHeight
84
- const totalSteps = Math.ceil(pageHeight / scrollStep)
85
- const stepDelay = timeout / 2 / totalSteps
86
- const scrollNext = async () => {
87
- if (currentScrollPosition >= pageHeight) {
88
- resolve()
89
- return
90
- }
91
- window.scrollBy(0, scrollStep)
92
- currentScrollPosition += scrollStep
93
- setTimeout(scrollNext, stepDelay)
94
- }
95
- scrollNext()
96
- }),
97
- timeout
98
- )
99
- await page.evaluate(() => window.scrollTo(0, 0))
100
- }
101
-
102
81
  const waitForElement = async (page, element) => {
103
82
  const screenshotOpts = {}
104
83
  if (element) {
@@ -114,7 +93,6 @@ const SCREENSHOT_DEFAULT_OPTS = {
114
93
  codeScheme: 'atom-dark',
115
94
  overlay: {},
116
95
  waitUntil: 'auto',
117
- waitForDom: DEFAULT_WAIT_FOR_DOM,
118
96
  isPageReady: defaultIsPageReady
119
97
  }
120
98
 
@@ -128,7 +106,6 @@ module.exports = ({ goto, ...gotoOpts }) => {
128
106
  codeScheme = SCREENSHOT_DEFAULT_OPTS.codeScheme,
129
107
  overlay: overlayOpts = SCREENSHOT_DEFAULT_OPTS.overlay,
130
108
  waitUntil = SCREENSHOT_DEFAULT_OPTS.waitUntil,
131
- waitForDom = SCREENSHOT_DEFAULT_OPTS.waitForDom,
132
109
  isPageReady = SCREENSHOT_DEFAULT_OPTS.isPageReady,
133
110
  ...opts
134
111
  } = {}
@@ -136,9 +113,17 @@ module.exports = ({ goto, ...gotoOpts }) => {
136
113
  let screenshot
137
114
  let response
138
115
 
116
+ const captureExpanded = (expand, screenshotOpts, timeout) =>
117
+ captureWithNavigationRetry(
118
+ async () => {
119
+ if (expand) await pReflect(expandOverflow(page))
120
+ return page.screenshot(screenshotOpts)
121
+ },
122
+ { page, goto, timeout }
123
+ )
124
+
139
125
  const beforeScreenshot = async (page, response, { element, fullPage = false } = {}) => {
140
126
  const timeout = goto.timeouts.action(opts.timeout)
141
- const waitForDomOpts = resolveWaitForDom(waitForDom)
142
127
 
143
128
  let screenshotOpts = {}
144
129
  const tasks = [
@@ -152,13 +137,6 @@ module.exports = ({ goto, ...gotoOpts }) => {
152
137
  }
153
138
  ]
154
139
 
155
- if (waitForDomOpts) {
156
- tasks.push({
157
- fn: () => page.evaluate(waitForDomStability, waitForDomOpts),
158
- debug: 'beforeScreenshot:waitForDomStability'
159
- })
160
- }
161
-
162
140
  if (codeScheme && response) {
163
141
  tasks.push({
164
142
  fn: () => waitForPrism(page, response, { codeScheme, ...opts }),
@@ -166,12 +144,7 @@ module.exports = ({ goto, ...gotoOpts }) => {
166
144
  })
167
145
  }
168
146
 
169
- if (fullPage) {
170
- tasks.push({
171
- fn: () => scrollFullPageToLoadContent(page, timeout, goto),
172
- debug: 'beforeScreenshot:scrollFullPageToLoadContent'
173
- })
174
- } else if (element) {
147
+ if (element && !fullPage) {
175
148
  tasks.push({
176
149
  fn: async () => {
177
150
  screenshotOpts = await waitForElement(page, element)
@@ -185,7 +158,7 @@ module.exports = ({ goto, ...gotoOpts }) => {
185
158
  goto.run({
186
159
  fn: fn(),
187
160
  ...opts,
188
- timeout: fullPage ? timeout * 2 : timeout
161
+ timeout
189
162
  })
190
163
  )
191
164
  )
@@ -199,34 +172,56 @@ module.exports = ({ goto, ...gotoOpts }) => {
199
172
  let retry = 0
200
173
  let isWhite = false
201
174
  let isReady = false
175
+ let didHydrateScroll = false
176
+ let didHydrateAttempt = false
202
177
 
203
178
  do {
204
- screenshot = await captureWithNavigationRetry(() => page.screenshot(opts), {
205
- page,
206
- goto,
207
- timeout
208
- })
209
- isWhite = await isWhiteScreenshot(screenshot)
210
- const snapshotResult = await pReflect(getPageSnapshot(page))
211
- const pageSnapshot = snapshotResult.isRejected ? {} : snapshotResult.value
212
- const pageReadyResult = await pReflect(
213
- opts.isPageReady({
214
- page,
215
- response: opts.response,
216
- screenshot,
217
- isWhite,
218
- isWhiteScreenshot,
219
- ...pageSnapshot
220
- })
179
+ screenshot = await captureWithNavigationRetry(
180
+ () =>
181
+ page.screenshot({
182
+ ...opts,
183
+ ...(opts.fullPage ? { fullPage: false, path: undefined } : {})
184
+ }),
185
+ { page, goto, timeout }
221
186
  )
222
- isReady = !pageReadyResult.isRejected && !!pageReadyResult.value
187
+ isWhite = await isWhiteScreenshot(screenshot)
188
+ isReady = await checkPageReady(page, {
189
+ isPageReady: opts.isPageReady,
190
+ response: opts.response,
191
+ screenshot,
192
+ isWhite
193
+ })
223
194
 
224
195
  if (isReady || elapsed() >= timeout) break
225
196
 
197
+ const remaining = timeout - elapsed()
198
+ if (opts.fullPage && !didHydrateAttempt && !isWhite) {
199
+ didHydrateAttempt = true
200
+ const { hydrated, info } = await tryHydrateScroll(page, remaining)
201
+ didHydrateScroll = hydrated
202
+ debug('screenshot:hydrateScroll', { remaining, hydrated, ...info })
203
+ }
204
+
226
205
  retry += 1
227
206
  await goto.waitUntilAuto(page, { timeout })
228
207
  } while (!isReady)
229
208
 
209
+ if (opts.fullPage) {
210
+ if (isReady) {
211
+ await prepareFullDocument(page, {
212
+ goto,
213
+ timeout: opts.timeout,
214
+ scrolled: didHydrateScroll
215
+ })
216
+ }
217
+ screenshot = await captureExpanded(
218
+ isReady,
219
+ { ...opts, fullPage: true },
220
+ resolveScrollTimeout(goto, opts.timeout)
221
+ )
222
+ isWhite = await isWhiteScreenshot(screenshot)
223
+ }
224
+
230
225
  return { isWhite, isReady, retry }
231
226
  }
232
227
 
@@ -239,9 +234,13 @@ module.exports = ({ goto, ...gotoOpts }) => {
239
234
  if (waitUntil !== 'auto') {
240
235
  ;({ response } = await goto(page, { ...opts, url, waitUntil }))
241
236
  const screenshotOpts = await beforeScreenshot(page, response, opts)
242
- screenshot = await captureWithNavigationRetry(
243
- () => page.screenshot({ ...opts, ...screenshotOpts }),
244
- { page, goto, timeout: goto.timeouts.action(opts.timeout) }
237
+ if (opts.fullPage) {
238
+ await prepareFullDocument(page, { goto, timeout: opts.timeout })
239
+ }
240
+ screenshot = await captureExpanded(
241
+ opts.fullPage,
242
+ { ...opts, ...screenshotOpts },
243
+ goto.timeouts.action(opts.timeout)
245
244
  )
246
245
  debug('screenshot', { waitUntil, duration: timeScreenshot() })
247
246
  } else {
@@ -277,6 +276,13 @@ module.exports = ({ goto, ...gotoOpts }) => {
277
276
  module.exports.captureWithNavigationRetry = captureWithNavigationRetry
278
277
  module.exports.isWhiteScreenshot = isWhiteScreenshot
279
278
  module.exports.waitForDomStability = waitForDomStability
280
- module.exports.resolveWaitForDom = resolveWaitForDom
281
279
  module.exports.waitForReady = waitForReady
280
+ module.exports.paintSignals = paintSignals
281
+ module.exports.scrollFullPageToLoadContent = scrollFullPageToLoadContent
282
+ module.exports.expandOverflow = expandOverflow
283
+ module.exports.getPageMeta = getPageMeta
284
+ module.exports.checkPageReady = checkPageReady
285
+ module.exports.tryHydrateScroll = tryHydrateScroll
286
+ module.exports.resolveScrollTimeout = resolveScrollTimeout
287
+ module.exports.prepareFullDocument = prepareFullDocument
282
288
  module.exports.SCREENSHOT_DEFAULT_OPTS = SCREENSHOT_DEFAULT_OPTS
@@ -0,0 +1,249 @@
1
+ 'use strict'
2
+
3
+ const debug = require('debug-logfmt')('browserless:prepare')
4
+ const pReflect = require('p-reflect')
5
+
6
+ const { waitForDomStability } = require('./wait-for-dom')
7
+
8
+ const SCROLL_STEP_MS = 50
9
+ const OVERFLOW_MIN_PX = 200
10
+ const OVERFLOW_WAIT_MS = 1500
11
+ const PRE_QUIET_MS = 50
12
+ const POST_QUIET_MS = 200
13
+ const SETTLE_MS = 400
14
+
15
+ // The single overflow scanner: the tallest whole-document element that scrolls
16
+ // its own overflow past minPx. Runs in the page.
17
+ function findTallestOverflowScroller (minPx) {
18
+ let best = null
19
+ for (const el of document.querySelectorAll('*')) {
20
+ const style = window.getComputedStyle(el)
21
+ if (style.overflowY !== 'auto' && style.overflowY !== 'scroll') continue
22
+ if (el.scrollHeight <= el.clientHeight + minPx) continue
23
+ if (!best || el.scrollHeight > best.scrollHeight) best = el
24
+ }
25
+ return best
26
+ }
27
+
28
+ // page.evaluate only serializes the function it is given, so an in-page helper
29
+ // can't be shared across evaluates by reference. Compose the scanner source in
30
+ // front of `fn` on the Node side (no in-page eval, so page CSP is untouched) to
31
+ // give every caller the same scanner.
32
+ const evaluateInPage = (page, fn, ...args) => {
33
+ const source = `${findTallestOverflowScroller}\nreturn (${fn}).apply(null, arguments)`
34
+ // eslint-disable-next-line no-new-func
35
+ return page.evaluate(new Function(source), ...args)
36
+ }
37
+
38
+ const waitForOverflowHeight = (page, timeout = OVERFLOW_WAIT_MS) =>
39
+ evaluateInPage(
40
+ page,
41
+ (timeout, minPx) =>
42
+ new Promise(resolve => {
43
+ const started = Date.now()
44
+ let last = 0
45
+ let stable = 0
46
+ const tick = () => {
47
+ const scroll = findTallestOverflowScroller(minPx)
48
+ const height = scroll
49
+ ? scroll.scrollHeight
50
+ : (document.scrollingElement || document.documentElement).scrollHeight
51
+ const tall = height > window.innerHeight + minPx
52
+ if (height === last && tall) {
53
+ if (++stable >= 2) return resolve(height)
54
+ } else {
55
+ stable = 0
56
+ last = height
57
+ }
58
+ if (Date.now() - started >= timeout) return resolve(height)
59
+ // Shell pages grow late — poll a bit slower until content appears.
60
+ setTimeout(tick, tall ? 100 : 150)
61
+ }
62
+ tick()
63
+ }),
64
+ timeout,
65
+ OVERFLOW_MIN_PX
66
+ )
67
+
68
+ const expandOverflow = (page, minPx = OVERFLOW_MIN_PX) =>
69
+ evaluateInPage(
70
+ page,
71
+ minPx => {
72
+ const scroll = findTallestOverflowScroller(minPx)
73
+ if (!scroll) return false
74
+ let el = scroll
75
+ while (el) {
76
+ const pos = window.getComputedStyle(el).position
77
+ el.style.setProperty('overflow', 'visible', 'important')
78
+ el.style.setProperty('height', 'auto', 'important')
79
+ el.style.setProperty('max-height', 'none', 'important')
80
+ if (pos === 'absolute' || pos === 'fixed') {
81
+ el.style.setProperty('position', 'relative', 'important')
82
+ el.style.setProperty('inset', 'auto', 'important')
83
+ }
84
+ if (el === document.documentElement) break
85
+ el = el.parentElement
86
+ }
87
+ return true
88
+ },
89
+ minPx
90
+ )
91
+
92
+ const settleDom = async (page, { idle, timeout }, label) => {
93
+ const started = Date.now()
94
+ const result = await page.evaluate(waitForDomStability, { idle, timeout })
95
+ debug(label, { ...result, duration: Date.now() - started })
96
+ }
97
+
98
+ const scrollFullPageToLoadContent = async (page, timeout) => {
99
+ const preQuiet = Math.min(PRE_QUIET_MS, Math.floor(timeout / 20))
100
+ const postQuiet = Math.min(POST_QUIET_MS, Math.floor(timeout / 20))
101
+ const scrollBudget = Math.max(0, timeout - preQuiet - postQuiet)
102
+ const started = Date.now()
103
+
104
+ if (preQuiet > 0) {
105
+ await settleDom(page, { idle: preQuiet / 2, timeout: preQuiet }, 'waitForDomStability:pre')
106
+ }
107
+
108
+ const scroll = await evaluateInPage(
109
+ page,
110
+ (scrollBudget, stepMs, minPx) =>
111
+ new Promise(resolve => {
112
+ const doc = () => document.scrollingElement || document.documentElement
113
+ let root = null
114
+ let pageHeight = doc() ? doc().scrollHeight : 0
115
+ let viewport = window.innerHeight
116
+ let currentScrollPosition = 0
117
+ const scrollStarted = Date.now()
118
+
119
+ const measure = () => {
120
+ if (!root) {
121
+ const overflow = findTallestOverflowScroller(minPx)
122
+ if (overflow) root = overflow
123
+ }
124
+ if (root) {
125
+ pageHeight = root.scrollHeight
126
+ viewport = root.clientHeight || window.innerHeight
127
+ } else {
128
+ const el = doc()
129
+ pageHeight = el ? el.scrollHeight : 0
130
+ viewport = window.innerHeight
131
+ }
132
+ }
133
+
134
+ const finish = () => {
135
+ window.scrollTo(0, 0)
136
+ if (root) root.scrollTop = 0
137
+ resolve({
138
+ hasOverflow: !!root,
139
+ pageHeight,
140
+ viewport,
141
+ scrolledPx: currentScrollPosition,
142
+ duration: Date.now() - scrollStarted
143
+ })
144
+ }
145
+
146
+ const scrollNext = () => {
147
+ measure()
148
+ const step = Math.max(1, Math.floor(viewport * 0.95))
149
+ if (currentScrollPosition >= pageHeight || Date.now() - scrollStarted >= scrollBudget) {
150
+ return finish()
151
+ }
152
+ if (root) root.scrollBy(0, step)
153
+ else window.scrollBy(0, step)
154
+ currentScrollPosition += step
155
+ setTimeout(scrollNext, stepMs)
156
+ }
157
+
158
+ measure()
159
+ // Viewport-sized shell with no overflow yet: brief wait for the SPA
160
+ // scroller before falling through to window scroll / budget exit.
161
+ if (pageHeight <= viewport + 1 && !root) {
162
+ const waitUntil = scrollStarted + Math.min(1000, Math.floor(scrollBudget / 4))
163
+ const waitForRoot = () => {
164
+ measure()
165
+ if (root || Date.now() >= waitUntil) return scrollNext()
166
+ setTimeout(waitForRoot, stepMs)
167
+ }
168
+ return waitForRoot()
169
+ }
170
+ scrollNext()
171
+ }),
172
+ scrollBudget,
173
+ SCROLL_STEP_MS,
174
+ OVERFLOW_MIN_PX
175
+ )
176
+ debug('scrollFullPage', { ...scroll, duration: Date.now() - started })
177
+
178
+ if (postQuiet > 0) {
179
+ await settleDom(
180
+ page,
181
+ { idle: Math.min(100, postQuiet / 2), timeout: postQuiet },
182
+ 'waitForDomStability:post'
183
+ )
184
+ }
185
+
186
+ const hydrated = !!(scroll?.hasOverflow && scroll.scrolledPx >= (scroll.viewport || 0))
187
+ return { ...scroll, hydrated, duration: Date.now() - started }
188
+ }
189
+
190
+ const resolveScrollTimeout = (goto, timeout) =>
191
+ typeof goto.timeouts.goto === 'function'
192
+ ? goto.timeouts.goto(timeout)
193
+ : goto.timeouts.action(timeout)
194
+
195
+ // A hydrate scroll spends up to half the remaining budget (capped) scrolling,
196
+ // and the capture that follows still needs time. Below HYDRATE_MIN_BUDGET_MS
197
+ // there isn't enough left for both, so skip it.
198
+ const HYDRATE_MIN_BUDGET_MS = 1000
199
+ const HYDRATE_MAX_SCROLL_MS = 5000
200
+
201
+ const tryHydrateScroll = async (page, remaining) => {
202
+ if (remaining <= HYDRATE_MIN_BUDGET_MS) return { hydrated: false, info: {} }
203
+ const hydrate = await pReflect(
204
+ scrollFullPageToLoadContent(page, Math.min(remaining / 2, HYDRATE_MAX_SCROLL_MS))
205
+ )
206
+ return {
207
+ hydrated: !hydrate.isRejected && !!hydrate.value?.hydrated,
208
+ info: hydrate.isRejected ? {} : hydrate.value
209
+ }
210
+ }
211
+
212
+ const prepareFullDocument = async (page, { goto, timeout, scrolled = false } = {}) => {
213
+ const scrollTimeout = resolveScrollTimeout(goto, timeout)
214
+ const elapsed = require('@kikobeats/time-span')({ format: n => Math.round(n) })()
215
+
216
+ if (!scrolled) {
217
+ const height = await pReflect(
218
+ waitForOverflowHeight(page, Math.min(OVERFLOW_WAIT_MS, Math.round(scrollTimeout / 8)))
219
+ )
220
+ debug('prepareFullDocument:overflowHeight', {
221
+ height: height.isRejected ? null : height.value,
222
+ duration: elapsed()
223
+ })
224
+
225
+ await scrollFullPageToLoadContent(page, scrollTimeout)
226
+ debug('prepareFullDocument:scroll', { duration: elapsed() })
227
+ } else {
228
+ debug('prepareFullDocument:skipScroll', { duration: elapsed() })
229
+ }
230
+
231
+ const settleMs = Math.min(SETTLE_MS, Math.max(0, scrollTimeout - elapsed()))
232
+ if (settleMs > 0 && typeof page.waitForNetworkIdle === 'function') {
233
+ await pReflect(page.waitForNetworkIdle({ idleTime: 200, concurrency: 2, timeout: settleMs }))
234
+ }
235
+
236
+ const expandResult = await pReflect(expandOverflow(page))
237
+ const expanded = !expandResult.isRejected && expandResult.value
238
+ debug('prepareFullDocument:expandOverflow', { expanded, duration: elapsed() })
239
+
240
+ return { expanded, duration: elapsed(), scrolled }
241
+ }
242
+
243
+ module.exports = {
244
+ expandOverflow,
245
+ scrollFullPageToLoadContent,
246
+ prepareFullDocument,
247
+ resolveScrollTimeout,
248
+ tryHydrateScroll
249
+ }
@@ -1,19 +1,5 @@
1
1
  'use strict'
2
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
3
  const waitForDomStability = ({ idle, timeout } = {}) =>
18
4
  new Promise(resolve => {
19
5
  if (!document.body) return resolve({ status: 'no-body' })
@@ -45,9 +31,4 @@ const waitForDomStability = ({ idle, timeout } = {}) =>
45
31
  })()
46
32
  })
47
33
 
48
- module.exports = {
49
- DEFAULT_WAIT_FOR_DOM,
50
- WAIT_FOR_DOM_IDLE_RATIO,
51
- resolveWaitForDom,
52
- waitForDomStability
53
- }
34
+ module.exports = { waitForDomStability }
@@ -1,47 +1,21 @@
1
1
  'use strict'
2
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
3
  const { setTimeout: sleep } = require('node:timers/promises')
12
4
  const { isContextDestroyed } = require('@browserless/errors')
13
5
 
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
- // A document that is mid-parse or detached can have a null `documentElement`
38
- // (the same state the text walk below guards against), so every dereference
39
- // of it in this snapshot goes through `root`.
6
+ const DEFAULT_QUIET_MS = 300
7
+ const DEFAULT_POLL_MS = 150
8
+
9
+ const paintSignals = () => {
10
+ const TEXT_PAINT_CAP = 200
11
+ const MIN_PAINTED_AREA_PX = 256
12
+ const LAZY_IMAGE_VIEWPORTS = 2
13
+ const CONTENT_SAMPLE_LIMIT = 4
14
+
40
15
  const root = document.documentElement
41
16
  const vw = window.innerWidth || (root ? root.clientWidth : 0)
42
17
  const vh = window.innerHeight || (root ? root.clientHeight : 0)
43
18
 
44
- // Computed colors resolve to `rgb()`/`rgba()`; anything else is unknown.
45
19
  const parseColor = value => {
46
20
  const match = /rgba?\(([^)]+)\)/.exec(value || '')
47
21
  if (!match) return null
@@ -49,8 +23,6 @@ const snapshot = () => {
49
23
  return { r: parts[0], g: parts[1], b: parts[2], a: parts.length === 4 ? parts[3] : 1 }
50
24
  }
51
25
 
52
- // First opaque background up the ancestor chain; the canvas default (white)
53
- // when every ancestor is transparent.
54
26
  const effectiveBackground = el => {
55
27
  for (let node = el; node; node = node.parentElement) {
56
28
  const color = parseColor(window.getComputedStyle(node).backgroundColor)
@@ -62,8 +34,6 @@ const snapshot = () => {
62
34
  const sameColor = (a, b) =>
63
35
  Math.abs(a.r - b.r) < 10 && Math.abs(a.g - b.g) < 10 && Math.abs(a.b - b.b) < 10
64
36
 
65
- // A layer only blanks a capture when it is itself painted solid: visible,
66
- // full opacity, and an opaque background color or a background image.
67
37
  const isOpaqueLayer = el => {
68
38
  const style = window.getComputedStyle(el)
69
39
  if (style.visibility === 'hidden' || parseFloat(style.opacity) < 0.99) return false
@@ -78,13 +48,9 @@ const snapshot = () => {
78
48
  return width > 0 && height > 0 && width * height >= vw * vh * 0.9
79
49
  }
80
50
 
81
- // Up to a handful of counted content samples — enough to hit-test without
82
- // turning the snapshot into a layout storm. The clamp keeps each probe point
83
- // inside the viewport; the rect checks below guarantee it stays inside the
84
- // sampled content's own box.
85
51
  const contentPoints = []
86
52
  const samplePoint = (el, rect) => {
87
- if (contentPoints.length >= 4) return
53
+ if (contentPoints.length >= CONTENT_SAMPLE_LIMIT) return
88
54
  contentPoints.push({
89
55
  el,
90
56
  x: Math.max(0, Math.min(vw - 1, rect.left + rect.width / 2)),
@@ -99,11 +65,9 @@ const snapshot = () => {
99
65
  for (let i = 0; i < imgs.length; i++) {
100
66
  const img = imgs[i]
101
67
  if (!img.complete) {
102
- // Two viewports of headroom stays inside the smallest fetch threshold
103
- // Chromium uses for lazy images (~1250px on fast connections), so an
104
- // undecoded lazy image within it is loading and worth waiting for;
105
- // beyond it, the fetch never starts.
106
- if (img.loading === 'lazy' && img.getBoundingClientRect().top > vh * 2) continue
68
+ if (img.loading === 'lazy' && img.getBoundingClientRect().top > vh * LAZY_IMAGE_VIEWPORTS) {
69
+ continue
70
+ }
107
71
  images++
108
72
  continue
109
73
  }
@@ -111,7 +75,7 @@ const snapshot = () => {
111
75
  decoded++
112
76
  if (img.naturalWidth === 0) continue
113
77
  const rect = img.getBoundingClientRect()
114
- if (rect.width * rect.height < 256) continue
78
+ if (rect.width * rect.height < MIN_PAINTED_AREA_PX) continue
115
79
  if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
116
80
  if (
117
81
  typeof img.checkVisibility === 'function' &&
@@ -122,21 +86,14 @@ const snapshot = () => {
122
86
  painted++
123
87
  samplePoint(img, rect)
124
88
  }
125
- // Counting stops at 200 chars, so a text-heavy page costs a handful of nodes,
126
- // not a full DOM walk; only a near-blank shell walks every text node.
89
+
127
90
  let text = 0
128
- // A document that is mid-parse or detached can have neither `body` nor
129
- // `documentElement`, and `createTreeWalker` throws on a null root. Text is only
130
- // a paint signal, so a count that cannot be taken means "no text seen yet": the
131
- // gate stays conservative and re-polls, rather than failing a render that would
132
- // otherwise succeed. The same holds mid-walk — a DOM mutating under the walker
133
- // must not take the capture down with it.
134
91
  const textRoot = document.body || root
135
92
  const walker = textRoot
136
93
  ? document.createTreeWalker(textRoot, window.NodeFilter.SHOW_TEXT)
137
94
  : { nextNode: () => null }
138
95
  try {
139
- while (text < 200) {
96
+ while (text < TEXT_PAINT_CAP) {
140
97
  const node = walker.nextNode()
141
98
  if (!node) break
142
99
  const value = node.nodeValue.trim()
@@ -156,22 +113,13 @@ const snapshot = () => {
156
113
  const rect = range.getBoundingClientRect()
157
114
  if (rect.width === 0 || rect.height === 0) continue
158
115
  if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
159
- // White-on-white (or any color-on-same-color) text passes every geometry
160
- // check while a capture shows nothing: don't count it as painted text.
161
116
  const color = parseColor(window.getComputedStyle(el).color)
162
117
  if (color && (color.a < 0.05 || sameColor(color, effectiveBackground(el)))) continue
163
118
  text += value.length
164
119
  samplePoint(el, rect)
165
120
  }
166
- } catch {
167
- // Keep whatever was counted before the DOM shifted underneath the walk.
168
- }
121
+ } catch {}
169
122
 
170
- // Is this content sample's paint hidden behind an unrelated opaque,
171
- // viewport-covering layer? Walk up from the hit-tested element: anything
172
- // related to the sample (itself, an ancestor, a descendant) paints with it,
173
- // and the walk stops at the first common ancestor — an ancestor's background
174
- // always paints below its own descendants.
175
123
  const coveredAt = ({ el, x, y }) => {
176
124
  const top = document.elementFromPoint(x, y)
177
125
  if (!top) return false
@@ -182,14 +130,9 @@ const snapshot = () => {
182
130
  return false
183
131
  }
184
132
 
185
- // Only pages the fast path could trust get the (layout-forcing) cover check.
186
133
  let covered = false
187
- if (painted > 0 || text >= 200) {
134
+ if (painted > 0 || text >= TEXT_PAINT_CAP) {
188
135
  covered = contentPoints.some(coveredAt)
189
- // `elementFromPoint` skips `pointer-events: none` elements, exactly how
190
- // fading loading overlays are styled — scan root-level layers for one.
191
- // Overlays mount as direct children of <body>; a deeper scan would cost a
192
- // full styled DOM walk on every poll for a marginal case.
193
136
  if (!covered && document.body) {
194
137
  const layers = document.body.children
195
138
  for (let i = 0; i < layers.length && !covered; i++) {
@@ -222,20 +165,13 @@ const snapshot = () => {
222
165
  }
223
166
  }
224
167
 
225
- // 300ms of held quiet plus one 150ms poll to prove height stability puts the
226
- // gate's floor at ~450ms. The hold guards against lulls (a page momentarily
227
- // stable between async chunks); height stability + all images decoded +
228
- // `readyState === 'complete'` carry most of the settle signal, so a longer
229
- // hold buys little — below ~300ms it would start trusting coincidences.
230
- const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) => {
168
+ const waitForReady = async (
169
+ page,
170
+ { timeout, quietMs = DEFAULT_QUIET_MS, poll = DEFAULT_POLL_MS } = {}
171
+ ) => {
231
172
  if (!Number.isFinite(timeout)) throw new TypeError('timeout must be a finite number')
232
- // The quiet window must fit within the budget with room to observe it, or the
233
- // gate could never satisfy its own requirement and would always time out.
234
173
  quietMs = Math.min(quietMs, Math.floor(timeout / 2))
235
174
  const deadline = Date.now() + timeout
236
- // Never sleep past the deadline: with a poll larger than the remaining
237
- // budget, a full-length sleep would overshoot the timeout and steal time
238
- // from whatever shares the caller's budget (the blank-SPA screenshot poll).
239
175
  const nextPoll = () => sleep(Math.min(poll, Math.max(0, deadline - Date.now())))
240
176
  let lastHeight = -1
241
177
  let quietSince = 0
@@ -253,14 +189,10 @@ const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) =
253
189
  }
254
190
 
255
191
  while (Date.now() < deadline) {
256
- let snap
192
+ let signals
257
193
  try {
258
- snap = await page.evaluate(snapshot)
194
+ signals = await page.evaluate(paintSignals)
259
195
  } catch (error) {
260
- // Only a client-side navigation tearing down the execution context is
261
- // absorbed: reset the quiet window and keep polling. Any other evaluate
262
- // failure is a real error and surfaces instead of masquerading as a
263
- // timeout.
264
196
  if (!isContextDestroyed(error)) throw error
265
197
  resets++
266
198
  lastHeight = -1
@@ -269,16 +201,16 @@ const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) =
269
201
  continue
270
202
  }
271
203
 
272
- last = snap
273
- const imagesDecoded = snap.images === 0 || snap.decoded >= snap.images
274
- const quiet = snap.complete && imagesDecoded && snap.height === lastHeight
204
+ last = signals
205
+ const imagesDecoded = signals.images === 0 || signals.decoded >= signals.images
206
+ const quiet = signals.complete && imagesDecoded && signals.height === lastHeight
275
207
 
276
208
  if (quiet) {
277
209
  if (quietSince === 0) quietSince = Date.now()
278
- if (Date.now() - quietSince >= quietMs) return { ...snap, resets, timedOut: false }
210
+ if (Date.now() - quietSince >= quietMs) return { ...signals, resets, timedOut: false }
279
211
  } else {
280
212
  quietSince = 0
281
- lastHeight = snap.height
213
+ lastHeight = signals.height
282
214
  }
283
215
 
284
216
  await nextPoll()
@@ -287,4 +219,4 @@ const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) =
287
219
  return { ...last, resets, timedOut: true }
288
220
  }
289
221
 
290
- module.exports = { waitForReady }
222
+ module.exports = { waitForReady, paintSignals }