Saltar al contenido

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.

Lista de seis señales de que Puppeteer encaja: ya existe un proyecto de Node.js, las capturas se ejecutan de forma programada o en CI, los archivos alimentan otro programa, el equipo revisa los cambios de script como cualquier código, un servidor ejecuta el trabajo y el trabajo necesita pasos de navegador a medida.

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.

Árbol de archivos del proyecto site-shots: archivos del paquete, los scripts one y run, un archivo de texto urls y una carpeta shots con una subcarpeta con fecha por ejecución.

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.

Ajustes del script de una página: viewport de 1440 por 900 píxeles CSS, factor de escala del dispositivo 2, espera hasta networkidle0 sin conexiones, salida PNG y tamaño de salida de 2880 por 1800 píxeles.

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.

Cuatro cifras de una captura de página completa de un artículo largo de 1440 píxeles de ancho: 12000 píxeles de alto, 17,3 millones de píxeles, unas 2,1 veces el área de un fotograma 4K y un ajuste de JPEG con calidad 80.

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.

Flujo de cinco recuadros: leer urls.txt, crear la carpeta de salida con fecha, lanzar un solo navegador, capturar cada URL en su propia página y escribir report.json con cada resultado.

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.

Comparación de los límites de concurrencia 1 y 10 para 200 URL con unos 6 segundos por página: una página cada vez tarda unos 20 minutos, y diez páginas a la vez tardan unos 2 minutos más el coste de memoria de diez renderizadores.

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.

Comparación de dos configuraciones móviles: emulate con un descriptor de dispositivo cambia el viewport, la relación de píxeles, el soporte táctil y el user agent, mientras que setViewport con isMobile cambia solo el viewport y el comportamiento táctil.

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.

Cronología de la pasada de desplazamiento en una página de 8000 píxeles: desplazamiento a 0 a los 0,0 segundos, 900 píxeles a los 0,2 segundos, 7200 píxeles a los 1,6 segundos y de vuelta al principio a los 1,8 segundos.

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ónDevuelve el control cuando
loadSe dispara el evento load. Es el valor predeterminado.
domcontentloadedEl HTML está analizado.
networkidle0No hay conexiones de red durante al menos 500 ms.
networkidle2No 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.

Lista de señales de listo para una página de panel: el contenedor del gráfico principal está presente, el indicador de carga ha desaparecido, la última fila de datos es visible, las fuentes web han terminado de cargarse y el banner de consentimiento está cerrado.

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.

Cuatro cifras del peor caso: 3 intentos por URL, 45 segundos por intento, 135 segundos por URL cuando todos los intentos agotan el tiempo y unos 56 minutos para 100 URL con 4 workers.

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

  1. Usar networkidle0 en todos los sitios. Las páginas con analítica o widgets de chat nunca se estabilizan.
  2. Dejar páginas abiertas. La memoria crece hasta que el proceso se ralentiza o falla.
  3. 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.
  4. Capturar los avisos de consentimiento. Haz clic en el botón de rechazo por selector u oculta el banner con page.addStyleTag.
  5. 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íntomaCausa probableSolución
Zona en blanco en una imagen de página completaEl contenido diferido nunca se desplazó a la vistaEjecuta la pasada de desplazamiento antes de la captura
Tiempo agotado en gotoLa página nunca alcanza el evento de esperaEspera un selector, o cambia a domcontentloaded
La misma imagen para todas las URLLa captura se ejecutó antes de que terminara la navegaciónUsa await en goto en cada página, y abre una página nueva por URL
Imagen con el doble del tamaño esperadodeviceScaleFactor es 2Quita el factor o ponlo en 1
La memoria crece en una ejecución largaLas páginas siguen abiertas tras la capturaCierra cada página en un bloque finally y limita la concurrencia
Banner de consentimiento en la imagenNo se gestionó el bannerHaz clic en el botón de rechazo por selector u oculta el banner
Lista de hacer y no hacer para un script de capturas: cerrar cada página, limitar la concurrencia, esperar una señal de listo y conservar un informe; no usar networkidle0 en todas partes ni reutilizar una carpeta entre ejecuciones.

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.

Ajustes de un lote de Temposhot Website Capture: dispositivo Desktop, área Full page, espera Network idle, retardo de 2 segundos, Reject banners para cookies, anuncios y rastreadores bloqueados, calidad JPEG 80 y 4 navegadores concurrentes.

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.

Cinco pasos para ejecutar el lote de URL en Website Capture de Temposhot: pegar la lista con Bulk insert, definir Run Settings, iniciar la captura, reintentar las filas fallidas y exportar los resultados como ZIP.

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.

Comparación de estados de fila: Capture pending espera una ranura de navegador, Capturing carga la página y Capture complete significa que la captura se guardó; Capture failed significa que la página no produjo ninguna captura.

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