@browserless/screenshot
Version:
Capture high-quality screenshots of websites with overlay support, device emulation, and automated image optimization.
249 lines (217 loc) • 7.7 kB
JavaScript
const debug = require('debug-logfmt')('browserless:screenshot')
const createGoto = require('@browserless/goto')
const pReflect = require('p-reflect')
const isWhiteScreenshot = require('./is-white-screenshot')
const waitForPrism = require('./pretty')
const timeSpan = require('./time-span')
const overlay = require('./overlay')
const { waitForDomStability, resolveWaitForDom, DEFAULT_WAIT_FOR_DOM } = require('./wait-for-dom')
const createElapsed = () => {
const start = Date.now()
return () => Date.now() - start
}
const getPageSnapshot = page =>
page.evaluate(() => ({
title: document.title || '',
bodyText: document.body ? document.body.innerText || '' : '',
url: window.location.href || ''
}))
const defaultIsPageReady = ({ isWhite }) => !isWhite
const getBoundingClientRect = element => {
const { top, left, height, width, x, y } = element.getBoundingClientRect()
return { top, left, height, width, x, y }
}
const waitForImagesOnViewport = page =>
page.$$eval('img[src]:not([aria-hidden="true"])', elements =>
Promise.all(
elements
.filter(el => {
if (el.naturalHeight === 0 || el.naturalWidth === 0) return false
const { top, left, bottom, right } = el.getBoundingClientRect()
return (
top >= 0 &&
left >= 0 &&
bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
right <= (window.innerWidth || document.documentElement.clientWidth)
)
})
.map(el => el.decode())
)
)
const scrollFullPageToLoadContent = async (page, timeout) => {
const debug = require('debug-logfmt')('browserless:goto')
const duration = debug.duration()
const result = await page.evaluate(waitForDomStability, {
idle: timeout / 2 / 2,
timeout: timeout / 2
})
duration('waitForDomStability', result)
await page.evaluate(
timeout =>
new Promise(resolve => {
let currentScrollPosition = 0
const scrollStep = Math.floor(window.innerHeight)
const pageHeight = document.body.scrollHeight
const totalSteps = Math.ceil(pageHeight / scrollStep)
const stepDelay = timeout / 2 / totalSteps
const scrollNext = async () => {
if (currentScrollPosition >= pageHeight) {
resolve()
return
}
window.scrollBy(0, scrollStep)
currentScrollPosition += scrollStep
setTimeout(scrollNext, stepDelay)
}
scrollNext()
}),
timeout
)
await page.evaluate(() => window.scrollTo(0, 0))
}
const waitForElement = async (page, element) => {
const screenshotOpts = {}
if (element) {
await page.waitForSelector(element, { visible: true })
screenshotOpts.clip = await page.$eval(element, getBoundingClientRect)
screenshotOpts.fullPage = false
return screenshotOpts
}
return screenshotOpts
}
module.exports = ({ goto, ...gotoOpts }) => {
goto = goto || createGoto(gotoOpts)
return function screenshot (page) {
return async (
url,
{
codeScheme = 'atom-dark',
overlay: overlayOpts = {},
waitUntil = 'auto',
waitForDom = DEFAULT_WAIT_FOR_DOM,
isPageReady = defaultIsPageReady,
...opts
} = {}
) => {
let screenshot
let response
const beforeScreenshot = async (page, response, { element, fullPage = false } = {}) => {
const timeout = goto.timeouts.action(opts.timeout)
const waitForDomOpts = resolveWaitForDom(waitForDom)
let screenshotOpts = {}
const tasks = [
{
fn: () => page.evaluate('document.fonts.ready'),
debug: 'beforeScreenshot:fontsReady'
},
{
fn: () => waitForImagesOnViewport(page),
debug: 'beforeScreenshot:waitForImagesOnViewport'
}
]
if (waitForDomOpts) {
tasks.push({
fn: () => page.evaluate(waitForDomStability, waitForDomOpts),
debug: 'beforeScreenshot:waitForDomStability'
})
}
if (codeScheme && response) {
tasks.push({
fn: () => waitForPrism(page, response, { codeScheme, ...opts }),
debug: 'beforeScreenshot:waitForPrism'
})
}
if (fullPage) {
tasks.push({
fn: () => scrollFullPageToLoadContent(page, timeout, goto),
debug: 'beforeScreenshot:scrollFullPageToLoadContent'
})
} else if (element) {
tasks.push({
fn: async () => {
screenshotOpts = await waitForElement(page, element)
},
debug: 'beforeScreenshot:waitForElement'
})
}
await Promise.all(
tasks.map(({ fn, ...opts }) =>
goto.run({
fn: fn(),
...opts,
timeout: fullPage ? timeout * 2 : timeout
})
)
)
return screenshotOpts
}
const takeScreenshot = async opts => {
const timeout = goto.timeouts.action(opts.timeout)
const elapsed = createElapsed()
let retry = 0
let isWhite = false
let isReady = false
do {
screenshot = await page.screenshot(opts)
isWhite = await isWhiteScreenshot(screenshot)
const snapshotResult = await pReflect(getPageSnapshot(page))
const pageSnapshot = snapshotResult.isRejected ? {} : snapshotResult.value
const pageReadyResult = await pReflect(
opts.isPageReady({
page,
response: opts.response,
screenshot,
isWhite,
isWhiteScreenshot,
...pageSnapshot
})
)
isReady = !pageReadyResult.isRejected && !!pageReadyResult.value
if (isReady || elapsed() >= timeout) break
retry += 1
await goto.waitUntilAuto(page, { timeout })
} while (!isReady)
return { isWhite, isReady, retry }
}
const onDialog = dialog => pReflect(dialog.dismiss())
page.on('dialog', onDialog)
try {
const timeScreenshot = timeSpan()
if (waitUntil !== 'auto') {
;({ response } = await goto(page, { ...opts, url, waitUntil }))
const screenshotOpts = await beforeScreenshot(page, response, opts)
screenshot = await page.screenshot({ ...opts, ...screenshotOpts })
debug('screenshot', { waitUntil, duration: timeScreenshot() })
} else {
;({ response } = await goto(page, { ...opts, url, waitUntil, waitUntilAuto }))
async function waitUntilAuto (page, { response }) {
const screenshotOpts = await beforeScreenshot(page, response, opts)
const { isWhite, isReady, retry } = await takeScreenshot({
...opts,
...screenshotOpts,
isPageReady,
response
})
debug('screenshot', {
waitUntil,
isReady,
isWhite,
retry,
duration: timeScreenshot()
})
}
}
return Object.keys(overlayOpts).length === 0
? screenshot
: overlay(screenshot, { ...opts, ...overlayOpts, viewport: page.viewport() })
} finally {
page.off('dialog', onDialog)
}
}
}
}
module.exports.isWhiteScreenshot = isWhiteScreenshot
module.exports.waitForDomStability = waitForDomStability
module.exports.resolveWaitForDom = resolveWaitForDom
module.exports.DEFAULT_WAIT_FOR_DOM = DEFAULT_WAIT_FOR_DOM