Cómo automatizar capturas de sitios web con Puppeteer
Usa Puppeteer para automatizar capturas de sitios web con JavaScript. Captura una página, páginas completas, lotes de URL y viewports móviles con un script reutilizable.
14 min de lecturaCaptura de sitios webPor Temposhot Team
Puppeteer es una biblioteca de Node.js mantenida por el equipo de Chrome. Controla Chrome o Firefox mediante una API de JavaScript y es una opción habitual para capturas de sitios web con script. Los ejemplos de abajo crecen desde una captura de cinco líneas hasta un ejecutor reutilizable que lee las URL de un archivo, ejecuta las páginas en paralelo y escribe un informe.
La última sección muestra el mismo trabajo por lotes como una ejecución de Website Capture de Temposhot en un Mac, sin ningún script que mantener. Los dos enfoques producen archivos comparables, y la elección depende de dónde se ejecuta el trabajo.
Cuándo Puppeteer es la herramienta adecuada
Puppeteer encaja cuando las capturas forman parte del código que ya ejecutas:
- un servicio de Node.js que genera vistas previas o imágenes para redes sociales
- un cron job o un paso de CI que captura páginas después de un despliegue
- un trabajo de extracción de datos que también necesita un registro visual
- un equipo que ya usa funciones del Chrome DevTools Protocol
Encaja menos con trabajos puntuales de personas que no mantienen proyectos de Node.js.
Instala Puppeteer para un script de capturas
mkdir site-shots && cd site-shots
npm init -y
npm install puppeteer
El paquete puppeteer descarga durante la instalación una compilación compatible de Chrome. El paquete más ligero puppeteer-core omite esa descarga y necesita la ruta a un navegador ya instalado. Los scripts de abajo usan archivos .mjs, para que await de nivel superior funcione.
Elige entre puppeteer y puppeteer-core
Usa puppeteer en un equipo de desarrollo donde la descarga está permitida. Usa puppeteer-core en una imagen de CI que ya tiene Chrome instalado, o en una red donde se bloquea la descarga del navegador. La API es la misma en los dos paquetes, así que el script no cambia. Solo la llamada de lanzamiento necesita un executablePath cuando el navegador está en una ubicación personalizada.
Captura una captura de un sitio web
// one.mjs
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
await page.screenshot({ path: 'example.png' });
await browser.close();
page.setViewport define el ancho y el alto. Añade deviceScaleFactor: 2 a la misma llamada para obtener una imagen con el doble de densidad de píxeles, parecida a una pantalla Retina.
Qué producen los ajustes
El script anterior, con deviceScaleFactor: 2 añadido, tiene un tamaño de salida fijo. Las cifras se deducen del viewport y del factor de escala.
La salida es el doble del viewport en cada dirección, porque el factor de escala multiplica los píxeles y el tamaño CSS se mantiene igual. Un viewport de 1440 por 900 con escala 1 produce una imagen de 1440 por 900, que es el tamaño adecuado para una vista previa web. El factor de escala sirve para una imagen nítida en una pantalla de alta densidad, y como resultado duplica el tamaño del archivo.
Captura una página completa
await page.screenshot({ path: 'example-full.png', fullPage: true });
Con fullPage: true, la imagen cubre todo el documento desplazable. Para un archivo más pequeño, guarda en JPEG o WebP:
await page.screenshot({ path: 'example.jpg', type: 'jpeg', quality: 80, fullPage: true });
quality acepta valores de 0 a 100 y solo se aplica a JPEG y WebP. Una captura de página completa de una página larga puede tener decenas de miles de píxeles de alto, así que JPEG suele mantener el tamaño del archivo manejable.
Tamaño de una página larga
El ancho se mantiene en el ancho del viewport, y la altura sigue al documento. Un artículo largo hace que la imagen sea muy alta, y las cifras siguientes muestran la escala del cambio.
Un fotograma de 3840 por 2160 tiene unos 8,3 millones de píxeles, así que la imagen de ejemplo es aproximadamente el doble que un fotograma 4K. Una imagen alta es difícil de ver de una sola vez, así que divide las páginas muy largas en secciones con clip cuando la imagen se vaya a mostrar en pantalla o en un documento.
Recorta una sección
Usa la opción clip para guardar una región. Las coordenadas son píxeles CSS desde la parte superior de la página, no desde el viewport:
await page.screenshot({
path: 'example-pricing.png',
clip: { x: 0, y: 2400, width: 1440, height: 900 },
});
Usa fullPage o clip en una misma llamada. Para trocear una página larga en archivos, lee la altura con document.body.scrollHeight y haz un recorte por fragmento. Una página de 12000 píxeles dividida en fragmentos de 900 píxeles da 14 archivos, y el último contiene solo 300 píxeles.
Crea un ejecutor de URL reutilizable
// run.mjs
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
const urls = (await readFile('urls.txt', 'utf8'))
.split('\n').map((l) => l.trim()).filter(Boolean);
const outDir = `shots/${new Date().toISOString().slice(0, 10)}`;
await mkdir(outDir, { recursive: true });
const browser = await puppeteer.launch();
async function shoot(url, index) {
const page = await browser.newPage();
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
const path = `${outDir}/${String(index + 1).padStart(3, '0')}.jpg`;
await page.screenshot({ path, type: 'jpeg', quality: 80, fullPage: true });
return { url, path, ok: true };
} finally {
await page.close();
}
}
Una página nueva por URL significa que un fallo en un sitio no afecta al siguiente. La carpeta de salida con fecha mantiene intactas las ejecuciones anteriores.
El ejecutor tiene cinco etapas. La figura siguiente las enumera en orden, con el archivo o el proceso que toca cada una.
Limpia la lista de URL
Las líneas vacías, los comentarios y las URL duplicadas desperdician tiempo de navegador, y un duplicado produce un segundo archivo para la misma página. La versión siguiente omite las líneas que empiezan por una almohadilla, elimina los duplicados exactos y descarta todo lo que no sea una dirección HTTP o HTTPS. raw contiene el contenido del archivo leído en el paso anterior.
const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
const urls = [...new Set(lines)].filter((l) => {
try {
const { protocol } = new URL(l);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
});
Set compara las cadenas de forma exacta, así que https://example.com y https://example.com/ siguen siendo dos URL. Registra también las líneas rechazadas. Una errata como htps://example.com aparece entonces en el registro al inicio de la ejecución, en lugar de como un tiempo agotado 45 segundos después.
Numeración para lotes grandes
El script rellena el índice con tres dígitos, lo que se ordena bien hasta 999 archivos. El índice 1000 se convierte en 1000.jpg, y una ordenación de texto lo coloca entre 100.jpg y 101.jpg. Rellena hasta el ancho del lote:
const width = String(urls.length).length;
const path = `${outDir}/${String(index + 1).padStart(width, '0')}.jpg`;
Un lote de 1.250 URL tiene un ancho de 4, así que el primer archivo es 0001.jpg. Calcula el ancho una sola vez, antes de que arranquen los workers.
Añade concurrencia para lotes más grandes
const LIMIT = 4;
const queue = [...urls.entries()];
const results = [];
async function worker() {
while (queue.length) {
const [i, url] = queue.shift();
results.push(await shootWithRetry(url, i));
}
}
await Promise.all(Array.from({ length: LIMIT }, worker));
await browser.close();
Cada worker toma la siguiente URL de una cola compartida. Empieza con 4 y sube el límite solo después de comprobar la memoria, porque cada página abierta usa su propio proceso de renderizado.
El script abre todas las páginas desde un único navegador. Las páginas comparten la caché del navegador y, de forma predeterminada, también las cookies. Para un lote de sitios públicos no relacionados, eso no supone problema. Para una ejecución que no debe arrastrar estado de un sitio al siguiente, crea un contexto por URL con browser.createBrowserContext() y ciérralo después de la captura. Cada contexto cuesta memoria, así que esta decisión va junto al límite de concurrencia.
Elegir el límite
El límite define cuántas páginas se renderizan a la vez. Un límite bajo mantiene la memoria estable y alarga la ejecución, y un límite alto hace lo contrario. La tabla siguiente muestra el compromiso para un lote de 200 URL, donde cada página tarda unos 6 segundos de media.
Los tiempos ideales suponen ninguna espera de red y ningún fallo. Los sitios reales varían, y los tiempos agotados y los reintentos añaden tiempo. Mide un lote de 20 URL con dos límites en la máquina de destino, y elige el límite más alto que mantenga el Mac receptivo.
Captura capturas móviles
Puppeteer incluye descriptores de dispositivos a través de KnownDevices.
import puppeteer, { KnownDevices } from 'puppeteer';
const page = await browser.newPage();
await page.emulate(KnownDevices['iPhone 13']);
await page.goto('https://example.com');
page.emulate define el viewport, la relación de píxeles, el soporte táctil y el user agent en una sola llamada. Para un ancho móvil simple sin cambiar el user agent, llama a setViewport con isMobile: true y hasTouch: true.
Usa emulate cuando un sitio sirve un marcado distinto según el user agent, y setViewport cuando solo importa el ancho del diseño. Las dos llamadas se aplican a la página en la que se ejecutan, así que una segunda página abierta después necesita su propia llamada.
Un lote que mezcla escritorio y móvil necesita un viewport por conjunto de salida, no uno por script. Guarda las imágenes móviles en su propia carpeta con fecha, por ejemplo shots/2026-04-10-mobile, para que los números de archivo de los dos conjuntos no colisionen. Website Capture de Temposhot usa un tamaño Mobile de 390 por 844 para el mismo fin, que se define por fila en la misma ejecución.
Confirma el viewport emulado
Comprueba el ancho del diseño en la página antes de la captura. Un descriptor incorrecto produce un diseño de escritorio dentro de una imagen de tamaño móvil. El valor siguiente informa del ancho y de la relación de píxeles que ve la página:
const seen = await page.evaluate(() => ({
width: window.innerWidth,
pixelRatio: window.devicePixelRatio,
}));
console.log(seen);
Para el descriptor iPhone 13 en las versiones recientes de Puppeteer, el ancho debe ser 390. La relación de píxeles también viene del descriptor. Comprueba los valores de la versión que instales, porque los datos de los descriptores cambian entre versiones.
Gestiona el contenido de carga diferida
Las imágenes que se cargan al desplazarse permanecen en blanco en una captura tomada justo después de la navegación. Recorre primero la página desplazándote:
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
window.scrollTo(0, y);
await new Promise((r) => setTimeout(r, 200));
}
window.scrollTo(0, 0);
});
La entrada del glosario carga diferida explica el patrón.
Cuánto dura el desplazamiento
El bucle espera 200 milisegundos por paso, y cada paso avanza una altura de viewport. El ejemplo siguiente es una página de 8000 píxeles con un viewport de 900 píxeles.
La página tiene nueve pasos, así que la pasada añade 1,8 segundos antes de la captura. Una página que carga las imágenes despacio necesita una pausa más larga por paso, y una página con desplazamiento infinito nunca llega a un final fijo. En el desplazamiento infinito, detente tras un número fijo de pasos en lugar de en la altura del documento.
Desplázate dentro de un contenedor
Algunos diseños desplazan un elemento interno en lugar de la ventana. El bucle anterior entonces no ve movimiento, y la parte inferior de la página se queda en blanco. Desplaza el elemento usando su scrollTop, con su propio scrollHeight y clientHeight como límites. Después de una ejecución, compara el primer y el último fotograma. Dos fotogramas idénticos significan que la página no se desplazó, lo que apunta a un contenedor.
Elige la estrategia de espera adecuada
| Opción | Devuelve el control cuando |
|---|---|
load | Se dispara el evento load. Es el valor predeterminado. |
domcontentloaded | El HTML está analizado. |
networkidle0 | No hay conexiones de red durante al menos 500 ms. |
networkidle2 | No hay más de 2 conexiones durante al menos 500 ms. |
networkidle0 puede agotar el tiempo en páginas que sondean o transmiten datos. networkidle2 tolera ese tráfico de fondo. Cuando una página tiene una señal clara de listo, page.waitForSelector sobre ese elemento es la opción más fiable. El tiempo límite de navegación predeterminado es de 30 segundos.
Elige una señal que la página active solo cuando su contenido esté listo. Un selector que existe en cada carga de la página, como la cabecera, no demuestra nada sobre los datos que hay debajo.
Mide antes de elegir una espera
Mide el tiempo de navegación de cada URL antes de decidir una estrategia de espera. El valor siguiente es el tiempo hasta que se dispara el evento load:
const started = Date.now();
await page.goto(url, { waitUntil: 'load', timeout: 45000 });
const loadMs = Date.now() - started;
Escribe loadMs en el informe junto a cada URL. Si la mayoría de las páginas carga en unos segundos, la espera predeterminada es suficiente. Si un grupo de páginas se acerca al límite de tiempo, cambia esas URL a domcontentloaded y espera un selector, y conserva el informe para confirmar el cambio.
Añade reintentos y un informe
async function shootWithRetry(url, i, tries = 3) {
for (let attempt = 1; attempt <= tries; attempt++) {
try {
return await shoot(url, i);
} catch (error) {
if (attempt === tries) return { url, ok: false, error: error.message };
}
}
}
// after Promise.all in run.mjs
await writeFile(`${outDir}/report.json`, JSON.stringify(results, null, 2));
El informe enumera cada URL con su ruta de archivo o su error. Vuelve a poner las entradas fallidas en urls.txt para una segunda pasada.
Crea la lista de reintentos a partir del informe
El informe convierte la segunda pasada en un filtro en lugar de una copia manual. Este comando de jq escribe las URL fallidas en un archivo nuevo:
jq -r '.[] | select(.ok == false) | .url' shots/2026-04-10/report.json > urls-retry.txt
Apunta el script a urls-retry.txt para la segunda pasada. El script nombra la carpeta de salida por fecha, así que una segunda ejecución el mismo día escribe en la misma carpeta. Los archivos con los mismos números sobrescriben entonces las imágenes de la primera ejecución. Añade un sufijo como -retry a outDir antes de la segunda pasada, para que las dos ejecuciones se conserven en la carpeta.
Tiempo del peor caso
Un sitio lento puede agotar todos los reintentos. El tiempo límite y el número de reintentos fijan el peor caso para una URL, y el límite reparte ese coste entre los workers. La figura siguiente usa los valores del script.
El peor caso es poco probable en un lote sano, pero marca un techo. Programa el trabajo para que una ejecución con muchos tiempos agotados termine antes de que empiece la siguiente.
Reintenta solo los errores que pueden cambiar
Un tiempo agotado de navegación o una conexión caída pueden tener éxito en un segundo intento. Una página que carga completa pero no tiene el selector esperado suele fallar igual de nuevo, y lo mismo pasa con una página que devuelve un estado 404. Reintenta el primer grupo y registra el segundo grupo una sola vez. La función shootWithRetry anterior reintenta todos los errores, así que un fallo fijo cuesta tres tiempos agotados completos antes de aparecer en el informe.
Una estructura de carpetas práctica
site-shots/
run.mjs
urls.txt
shots/
2026-04-10/
001.jpg
002.jpg
report.json
Una carpeta por fecha de ejecución, un informe por carpeta. Esa estructura basta para comparar ejecuciones más adelante sin una base de datos.
Errores habituales
- Usar
networkidle0en todos los sitios. Las páginas con analítica o widgets de chat nunca se estabilizan. - Dejar páginas abiertas. La memoria crece hasta que el proceso se ralentiza o falla.
- Ejecutar demasiadas páginas a la vez. Un límite de 20 en un portátil suele causar más tiempos agotados, no ejecuciones más rápidas.
- Capturar los avisos de consentimiento. Haz clic en el botón de rechazo por selector u oculta el banner con
page.addStyleTag. - Ignorar las condiciones del sitio. La captura automatizada envía peticiones reales. Revisa las condiciones de cada sitio y mantén la frecuencia baja.
Solución de problemas por síntoma
| Síntoma | Causa probable | Solución |
|---|---|---|
| Zona en blanco en una imagen de página completa | El contenido diferido nunca se desplazó a la vista | Ejecuta la pasada de desplazamiento antes de la captura |
| Tiempo agotado en goto | La página nunca alcanza el evento de espera | Espera un selector, o cambia a domcontentloaded |
| La misma imagen para todas las URL | La captura se ejecutó antes de que terminara la navegación | Usa await en goto en cada página, y abre una página nueva por URL |
| Imagen con el doble del tamaño esperado | deviceScaleFactor es 2 | Quita el factor o ponlo en 1 |
| La memoria crece en una ejecución larga | Las páginas siguen abiertas tras la captura | Cierra cada página en un bloque finally y limita la concurrencia |
| Banner de consentimiento en la imagen | No se gestionó el banner | Haz clic en el botón de rechazo por selector u oculta el banner |
Alternativas a un script de capturas con Puppeteer
- Playwright tiene una API parecida y también controla WebKit. Consulta cómo automatizar capturas de sitios web con Playwright.
- Las API de capturas alojadas ejecutan el navegador por ti y devuelven imágenes por HTTP.
- Las extensiones de navegador, como la extensión de Temposhot, guardan un PNG de página completa de la pestaña activa.
- Website Capture de Temposhot ejecuta una lista de URL pegada en un Mac sin código, con tamaños Desktop 1920×1080, Tablet 768×1024 o Mobile 390×844, y un retardo de 0 a 60 s por fila.
Cuándo el script no merece la pena
Un script cuesta más con el tiempo de lo que sugiere su primera versión. Cada cambio del sitio, actualización de Node.js o de Chrome puede exigir un arreglo. Un trabajo mensual sobre una lista que cambia a menudo cuesta menos en Website Capture, donde la lista de URL y los ajustes de cada fila están en una sola ventana. Conserva el script cuando sus imágenes alimentan un paso de compilación, cuando un pipeline de CI debe ejecutarlo o cuando los pasos van más allá de una visita y una captura.
El mismo lote en Website Capture de Temposhot
Website Capture de Temposhot cubre el trabajo por lotes de esta publicación sin script. Los ajustes siguientes coinciden con el ejecutor en la captura de página completa de 1440 por 900, con las opciones de espera y bloqueo que Temposhot expone por fila.
El tamaño del dispositivo difiere del script a propósito: Desktop mide 1920 por 1080 en Temposhot, mientras que el script usa 1440 por 900. Define el mismo viewport en los dos si las salidas deben coincidir. Una ejecución de Temposhot termina entonces con ZIP, Images o PDF en la barra de resultados, que exporta los fotogramas seleccionados.
Cada fila muestra su estado mientras se ejecuta el lote, como Capturing o Capture failed. Una fila que falla se puede reintentar por separado, sin reiniciar el resto del lote.
Preguntas frecuentes
¿Puppeteer funciona con Firefox?
Sí. Las versiones recientes pueden lanzar Firefox además de Chrome. Chrome sigue siendo la opción más usada.
¿Puppeteer puede guardar capturas en WebP?
Sí. Define type: 'webp' en las opciones de captura. El valor de quality se aplica a WebP igual que a JPEG.
¿Por qué page.goto agota el tiempo en algunos sitios?
La página no alcanzó el evento de espera elegido en 30 segundos. Cambia a networkidle2 o a load, sube timeout o espera un selector.
¿Por qué una parte de mi captura de página completa está en blanco?
El contenido se carga cuando entra en la vista al desplazarse. Ejecuta la pasada de desplazamiento de la sección de carga diferida antes de la captura, y espera a que aparezca la última fila.
¿Cuántas URL puede gestionar una sola ejecución?
El límite viene de la memoria y del tiempo, no del código. Mide un lote de 20 URL con la concurrencia que planeas usar, vigila la memoria y escala a partir de ahí. Website Capture de Temposhot permite que Concurrent browsers vaya de 1 a 10, con 4 como predeterminado.
¿La salida de capturas debe ir a Git?
No. Las imágenes y el informe son salidas de compilación que crecen con cada ejecución. Mantén urls.txt y el script bajo control de versiones, y guarda la carpeta de salida donde quepa su tamaño.
Referencias
¿Necesitas automatización de capturas de sitios web sin código?
Website Capture de Temposhot ejecuta el mismo tipo de lote de URL en local en un Mac, con ajustes por fila y sin script que mantener. Consulta automatización de capturas de sitios web o la guía de opciones de captura.
puppeteerwebsite-captureautomationnodejs
Términos utilizados aquí: full page screenshot, headless browser, playwright, puppeteer, screenshot api, screenshot automation, viewport screenshot
Convierte capturas repetibles en trabajo terminado
Temposhot toma capturas con un temporizador o en cada clic y exporta el resultado en PDF, imágenes, ZIP o MP4.
Publicaciones relacionadas
- Cómo capturar capturas de sitios web por lotes en MacCaptura sitios web por lotes en Mac con Temposhot. Pega una lista de URL, elige preajustes de escritorio, tableta o móvil, ejecuta capturas en paralelo y exporta para auditorías o informes.
- Cómo automatizar capturas de sitios web con PlaywrightUsa Playwright para automatizar capturas de sitios web con JavaScript. Captura una página, páginas completas, vistas móviles y lotes de URL con reintentos.
- Cómo automatizar capturas de sitios web sin códigoElige una forma sin código de automatizar capturas de sitios web: captura desde una lista de URL, una plataforma de automatización con API de capturas, herramientas de monitorización o ayudas del navegador.
- Mejores alternativas a Xnapper para Mac en 2026Compara alternativas a Xnapper para Mac: captura repetida, captura y grabación con desplazamiento, fondos gratuitos, guías multiplataforma y la app integrada.