Capturas de pantalla automáticas de páginas web con Puppeteer y Sharp

Hace poco estuve trabajando en una tarea de automatización para uno de nuestros servicios internos: hacer capturas de página completa con calidad retina de una web. Debería haber sido un problema trivial, pero, por lo visto, en 2022 todavía no hay ninguna librería de testing o automatización de navegadores que lo resuelva.
Probé Playwright, Puppeteer y Selenium, y todas tienen los mismos problemas: las capturas de página completa hechas con la API estándar o incluyen secciones en blanco (las zonas de la web donde está el contenido dinámico), o el proceso entero falla si la página es lo bastante larga, como en mi caso, con un viewport de 1920px de ancho y una altura de más de 10.000px.
Este es el ejemplo de mi primer enfoque, directo al grano, con una llamada genérica a la API de Puppeteer:
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();
Que, en ciertos casos, da como resultado esto:
Después de darle vueltas durante unas horas, encontré una solución alternativa: hacer varias capturas de la página y luego unirlas. En este artículo te enseño cómo hacerlo con Puppeteer y la librería Sharp.
Empecemos instalando Puppeteer y Sharp:
npm install puppeteer sharp
Después, declara unas cuantas constantes; las vamos a necesitar más adelante.
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;
Para calcular correctamente cuántas capturas hay que hacer, necesitamos conocer la altura exacta de la página. Para ello usaremos esta función:
const getHeight = async (page) => { const height = await page.evaluate(() => { return document.body.scrollHeight; }); return height;};
Y otra función auxiliar más, que nos permitirá hacer scroll automático hasta el final de la página para disparar todas las animaciones y la carga de imágenes antes de hacer la captura.
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); }}
Lo último es hacer la captura. Aquí tienes el fragmento de código completo, con comentarios adicionales que te pueden ayudar a conseguir el mismo resultado.
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();
¡Y listo! Guárdate este truco y úsalo para crear automáticamente capturas bonitas y de alta calidad de cualquier página web que quieras.