Taking automated web page screenshots with Puppeteer and Sharp

I was recently working on an automation task for one of our internal tools: taking full-page, retina-quality screenshots of a website. It should have been a trivial problem, but apparently, in 2022, there still isn't a single browser testing or automation library that solves it.
I tried Playwright, Puppeteer, and Selenium, and they all have the same issues: full-page screenshots taken with the standard API either include blank sections (the areas of the site where the dynamic content lives), or the whole process fails if the page is long enough, as in my case, with a 1920px-wide viewport and a height of over 10,000px.
Here's an example of my first, straightforward attempt, with a generic call to the Puppeteer API:
const puppeteer = require('puppeteer');const url = 'https://stripe.com/';const screenshot = async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 2, }); await page.goto(url); await page.screenshot({ path: 'screenshot.png', fullPage: true, }); await browser.close();};screenshot();
Which, in certain cases, results in this:
After puzzling over it for a few hours, I found a workaround: take several screenshots of the page and then stitch them together. In this article I'll show you how to do it with Puppeteer and the Sharp library.
Let's start by installing Puppeteer and Sharp:
npm install puppeteer sharp
Next, declare a few constants; we'll need them later.
const puppeteer = require('puppeteer');const fs = require('fs');const sharp = require('sharp');// URL of the webpage you want to take screenshot ofconst URL = 'https://tailwindcss.com/';// viewport configurationconst VIEWPORT_WIDTH = 1920;const VIEWPORT_HEIGHT = 1080;const DEVICE_SCALE_FACTOR = 2;// Chunk height is a value in px that defines// maximum height of each screenshot.// Use a smaller or bigger number based on the tests.const CHUNK_HEIGHT = 6000;
To correctly calculate how many screenshots we need to take, we need to know the page's exact height. For that we'll use this function:
const getHeight = async (page) => { const height = await page.evaluate(() => { return document.body.scrollHeight; }); return height;};
And one more helper function, which will let us auto-scroll to the bottom of the page to trigger all the animations and image loading before taking the screenshot.
async function autoScroll(page) { try { await page.evaluate(async () => { // Scroll to the bottom of the page, so that all the content is visible and loaded await new Promise((resolve, reject) => { var totalHeight = 0; var distance = 100; var timer = setInterval(() => { var scrollHeight = document.body.scrollHeight; window.scrollBy(0, distance); totalHeight += distance; if (totalHeight >= scrollHeight) { clearInterval(timer); resolve(); } }, 50); }); // Scroll back to the top of the page, so sticky menus will appear at the top document.body.scrollTop = document.documentElement.scrollTop = 0; }); } catch (err) { console.log(err); }}
The last step is taking the screenshot. Here's the complete code snippet, with extra comments that can help you achieve the same result.
const puppeteer = require('puppeteer');const fs = require('fs');const sharp = require('sharp');// Puppeteer configuration optionsconst URL = 'https://tailwindcss.com/';const VIEWPORT_WIDTH = 1920;const VIEWPORT_HEIGHT = 1080;const DEVICE_SCALE_FACTOR = 2;const CHUNK_HEIGHT = 6000;// Helper function, needed for calculating number of chunksconst getHeight = async (page) => { const height = await page.evaluate(() => { return document.body.scrollHeight; }); return height;};// Auto scroll the page to trigger all page animations and image loadingasync function autoScroll(page) { try { await page.evaluate(async () => { await new Promise((resolve, reject) => { var totalHeight = 0; var distance = 100; var timer = setInterval(() => { var scrollHeight = document.body.scrollHeight; window.scrollBy(0, distance); totalHeight += distance; if (totalHeight >= scrollHeight) { clearInterval(timer); resolve(); } }, 50); }); // Scroll back to the top of the page, so sticky menus appears at the right place. document.body.scrollTop = document.documentElement.scrollTop = 0; }); } catch (err) { console.log(err); }}const screenshot = async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, deviceScaleFactor: DEVICE_SCALE_FACTOR, }); await page.goto(URL); await autoScroll(page); const pageHeight = await getHeight(page); await page.waitForNetworkIdle(); // If page has height more than 6000px, then split it into the chunks and take screenshots of each chunk if (pageHeight > CHUNK_HEIGHT) { const chunks = Math.ceil(pageHeight / CHUNK_HEIGHT); const chunkCompositeOptions = []; for (let i = 0; i < chunks; i++) { let height = 0; // Calculate chunk offset top for a screenshot const offsetTop = i * CHUNK_HEIGHT; const filePath = `./screenshot-${i}.png`; if (pageHeight - CHUNK_HEIGHT * i < CHUNK_HEIGHT) { height = pageHeight - CHUNK_HEIGHT * i; // Take a screenshot using clip option passing top offset await page.screenshot({ path: filePath, clip: { x: 0, y: offsetTop, width: VIEWPORT_WIDTH, height: pageHeight - CHUNK_HEIGHT * i, }, }); } else { height = CHUNK_HEIGHT; await page.screenshot({ path: filePath, clip: { x: 0, y: offsetTop, width: VIEWPORT_WIDTH, height: CHUNK_HEIGHT, }, }); } // Prepare image merge options for sharp library api chunkCompositeOptions.push({ input: filePath, top: offsetTop * DEVICE_SCALE_FACTOR, left: 0, width: VIEWPORT_WIDTH, height: height * DEVICE_SCALE_FACTOR, }); } // An example to find batch of files in the folder const files = fs.readdirSync('./'); const images = files.filter((file) => file.includes('screenshot')); // Merge images into one await sharp({ create: { width: VIEWPORT_WIDTH * DEVICE_SCALE_FACTOR, height: pageHeight * DEVICE_SCALE_FACTOR, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 0 }, }, }) .composite(chunkCompositeOptions) .toFile('./merged.png'); // Delete all the screenshots images.forEach((image) => fs.unlinkSync(image)); } else { await page.screenshot({ path: './screenshot.png', fullPage: true, }); } await browser.close();};screenshot();
And that's it! Save this trick and use it to automatically create beautiful, high-quality screenshots of any web page you want.