En resumen: Un flujo de trabajo de descarga de archivos con Puppeteer puede adoptar cuatro formas eficaces: hacer clic en un botón y dejar que Chrome guarde el archivo en una carpeta de tu elección, ejecutar fetch() dentro de la página y reenviar el código base64 a Node, utilizar el protocolo Chrome DevTools con eventos de progreso de descarga, o saltarse el navegador y extraer la URL con Axios utilizando las cookies recopiladas de la sesión de Puppeteer. Elige según el tamaño del archivo, la autenticación y cómo el sitio expone el enlace.Introducción
Si alguna vez has intentado crear un script de flujo de descarga de archivos con Puppeteer en un sitio de producción real, ya conoces el momento de la verdad: el script hace clic en el botón de descarga, la instancia de Chrome sin interfaz gráfica informa de que se ha realizado correctamente y el disco permanece vacío. Esto ocurre porque Chromium bloquea las descargas automatizadas de forma predeterminada en modo sin interfaz gráfica, y la solución no se encuentra en la API de alto nivel de Puppeteer. Se encuentra un nivel más abajo, en el Protocolo de Chrome DevTools.
Esta guía está dirigida a desarrolladores de Node.js de nivel medio, ingenieros de control de calidad y profesionales del scraping que ya saben cómo iniciar un navegador, navegar por una página y seleccionar un elemento, y que ahora necesitan capturar los bytes reales. Vamos a repasar cuatro métodos delimitados, cada uno con el código completo, y seremos sinceros sobre cuál es el adecuado para cada situación.
Verás que se reutiliza el mismo armazón básico en todas partes: una carpeta de descargas creada con fs.mkdirSync, un User-Agent realista, una ventana de visualización de escritorio y un patrón para esperar hasta que el archivo esté realmente en el disco y no se esté escribiendo todavía. Al final tendrás una receta de descarga de Puppeteer para descargas activadas por clic, descargas con autenticación, cargas binarias grandes y URL conocidas, además de una guía de decisión para elegir entre ellas y una lista de verificación de seguridad para producción.
Por qué descargar archivos con Puppeteer es más complicado de lo que parece
Cuando haces clic page.click() en un botón «Descargar CSV» en Chrome con encabezado, el archivo llega a tu carpeta de Descargas y sigues con tu día. Ejecuta el mismo script con headless: 'new' y no pasa nada. El clic se activa, la solicitud de red se envía y tu sistema de archivos permanece vacío. Eso no es un error de Puppeteer. Chromium trata intencionadamente las descargas automatizadas como sospechosas, y la solución reside en el Protocolo de Chrome DevTools en lugar de en la API pública de Puppeteer. Hasta que no actives esa opción, ningún flujo de descarga de archivos de Puppeteer dejará ni un byte en el disco.
No hay una única forma óptima de manejar esto. El enfoque adecuado depende de cómo exponga el sitio el archivo, de lo estricta que sea su autenticación, del tamaño de la carga útil y del nivel de fiabilidad que necesites. Cuatro patrones cubren casi todos los casos:
- Hacer clic en el signo más
setDownloadBehavior. Configura el directorio de descargas del navegador a través de CDP, haz clic en el botón y comprueba si se ha completado. Es la mejor opción cuando la descarga se activa mediante JavaScript y no tienes, o no quieres buscar, la URL subyacente. - In-page
fetch()más base64. Ejecutafetch()dentropage.evaluate(), codifica la respuesta y envíala de vuelta a Node como base64. Ideal para SPAs, URL de blobs y descargas restringidas por cookies que solo existen dentro del contexto del navegador. - CDP puro con eventos de descarga. Abre una sesión de CDP, llama a
Browser.setDownloadBehaviory escuchaBrowser.downloadWillBeginyBrowser.downloadProgress. Ideal cuando necesitas progreso en tiempo real, asignación de GUID a nombre de archivo o detección de errores detallada. - Pasa la URL a Axios o
https. Usa Puppeteer para renderizar la página y extraer la URL real del archivo, y luego descárgalo desde Node con las cookies y los encabezados que hayas recopilado de la sesión de Puppeteer. Ideal para archivos grandes, tareas en paralelo y siempre que el navegador sea un estorbo.
El resto de esta guía consta de una sección por método, además de una tabla de decisión, una lista de verificación de seguridad y una comparación realista entre Puppeteer y Playwright al final.
Requisitos previos y configuración del proyecto
Antes de entrar en los métodos individuales, necesitamos un proyecto que puedan compartir los cuatro. La estructura aquí es intencionadamente sencilla: una carpeta, un package.json, un directorio de descargas y un único launch.js archivo que reutilizaremos en todos los ejemplos. Mantener el entorno de prueba consistente te permite cambiar un método por otro sin tocar el resto del código, y hace que las diferencias entre los métodos sean muy evidentes cuando los comparas uno al lado del otro.
Las notas de configuración se refieren a Node.js 20 o una versión más reciente en el momento de escribir este artículo; consulta las notas de la versión actual de Puppeteer si estás utilizando una versión anterior, ya que la versión mínima de Node.js compatible cambia con cada lanzamiento importante de Puppeteer.
Instalación de Puppeteer, conceptos básicos de Node.js y estructura de carpetas
Crea un proyecto, inicializa npm e instala Puppeteer:
mkdir puppeteer-downloads
cd puppeteer-downloads
npm init -y
npm install puppeteerAbre package.json y añade "type": "module" para que podamos usar import la sintaxis en los ejemplos. Ya que estás ahí, añade algunas comodidades de desarrollo:
{
"type": "module",
"scripts": {
"method1": "node method1.js",
"method2": "node method2.js",
"method3": "node method3.js",
"method4": "node method4.js"
}
}Puppeteer incluye Chrome para pruebas y lo descarga durante la instalación en la mayoría de las plataformas, lo cual es suficiente para todo lo que se trata en esta guía. Si estás ejecutando en un contenedor simplificado, confirma el comportamiento de la instalación en las notas de la versión de Puppeteer correspondiente a la versión que hayas fijado, ya que el comportamiento de Chrome integrado ha cambiado entre versiones.
Estructura de carpetas:
puppeteer-downloads/
downloads/ # files end up here
launch.js # shared harness
method1.js
method2.js
method3.js
method4.jsCrea la downloads/ carpeta ahora (mkdir downloads), o deja que el script de inicio la cree en la primera ejecución.
Un script de inicio básico con ruta de descarga, User-Agent y ventana gráfica
Todos los métodos de esta guía parten del mismo entorno de pruebas. Coloca esto en launch.js:
// launch.js
import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const DOWNLOAD_DIR = path.resolve(__dirname, 'downloads');
export async function launchBrowser({ headless = 'new' } = {}) {
// setDownloadBehavior requires an absolute path. Relative paths silently fail.
if (!fs.existsSync(DOWNLOAD_DIR)) {
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
}
const browser = await puppeteer.launch({
headless,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled',
],
});
return browser;
}
export async function newPage(browser) {
const page = await browser.newPage();
// Realistic desktop fingerprint. Some sites hide download buttons on mobile.
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
);
await page.setViewport({ width: 1366, height: 900 });
return page;
}Tres cosas a tener en cuenta. En primer lugar, setDownloadBehavior requiere una ruta absoluta; si pasas una ruta relativa, Chrome la ignora silenciosamente y no escribe nada. Segundo, forzamos un User-Agent y una ventana gráfica de escritorio porque algunos sitios ocultan los enlaces de descarga tras un diseño móvil, y un cliente automatizado sin User-Agent a menudo obtiene uno que Chrome considera no fiable. Tercero, usamos headless: 'new' en lugar de headless: 'shell'. El comportamiento de la descarga puede variar en shell modo, especialmente con las descargas gestionadas por el navegador, por lo que nos quedamos con el valor predeterminado.
Puedes cambiar headless a false para la depuración. Observar cómo se produce el clic en Chrome real suele ser la forma más rápida de diagnosticar por qué falla silenciosamente el flujo de descarga de un archivo de Puppeteer. Una vez que funciona en modo con interfaz gráfica y no en modo sin interfaz, sabrás que el problema es la política de descarga y no tu selector.
Vale la pena hacer dos pequeñas modificaciones antes de reutilizar este arnés en todas partes. En primer lugar, establece un tiempo de espera de navegación predeterminado: page.setDefaultNavigationTimeout(60_000) en cachés frías ahorra muchas ejecuciones de CI inestables. En segundo lugar, instala un console y pageerror escucha para que cualquier error en la página durante el clic de descarga aparezca en tus registros de Node en lugar de ser ocultado por el navegador. Ambas son líneas de código, y ambas se amortizan la primera vez que falla una implementación a las 2 de la madrugada.
Este es también un lugar natural para enlazar a una guía más detallada sobre scraping con Puppeteer si necesitas los conceptos más amplios de navegación, selectores y patrones de espera que este artículo da por supuestos.
Método 1: Haz clic en el botón de descarga y espera a que se descargue el archivo
El método 1 es lo más parecido a «lo que haría un humano». Navega hasta la página, haz clic en el botón de descarga y deja que Chrome guarde el archivo en la carpeta que elijas. El truco está en que Chrome sin interfaz gráfica no guarda nada por defecto; tienes que indicarle explícitamente dónde se permiten las descargas y dónde deben ir mediante una llamada al Protocolo de Chrome DevTools. Una vez que eso está configurado, el resto del trabajo consiste en detectar cuándo el archivo está realmente terminado, porque page.click() devuelve el resultado mucho antes de que los bytes lleguen al disco.
Este método es la opción adecuada cuando:
- La descarga se activa mediante JavaScript, no un enlace simple
<a href>, por lo que no puedes extraer fácilmente la URL. - No necesitas un progreso en tiempo real (solo «¿ya ha terminado?»).
- El archivo es lo suficientemente pequeño como para que el almacenamiento en el disco sea suficiente (normalmente menos de unos pocos cientos de MB).
No es la opción adecuada cuando:
- El sitio requiere una autenticación compleja y cookies que solo existen tras varias interacciones SPA (el Método 2 es más limpio).
- Necesitas eventos de progreso o detección de interrupciones (Método 3).
- El archivo es enorme y quieres transmitirlo directamente a S3 u otro destino (Método 4).
A continuación, configuramos la carpeta de descarga, hacemos clic en el botón y comprobamos si se ha completado utilizando un .crdownload sentinel y una comprobación estable del tamaño del archivo, de modo que un archivo escrito parcialmente nunca se devuelva como finalizado.
Configuración de la carpeta de descarga con setDownloadBehavior
Hay dos llamadas a CDP que verás en la práctica. La heredada es Page.setDownloadBehavior, limitada a una sola página:
const client = await page.target().createCDPSession();
await client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR, // absolute path
});Esto sigue funcionando en muchas configuraciones, pero está oficialmente en desuso, y las versiones recientes de Chrome han comenzado a enrutar las descargas a través del destino CDP a nivel del navegador. Cuando eso ocurre, tu Page.setDownloadBehavior llamada devuelve un resultado satisfactorio y el archivo sigue acabando en ~/Downloads (o en ninguna parte) porque la sesión de la página ya no se encarga de las descargas. Si alguna vez has pasado una tarde mirando fijamente un script que «funcionaba» y que de repente dejó de escribir archivos tras una actualización automática de Chrome, este suele ser el motivo.
La llamada compatible con futuras versiones es Browser.setDownloadBehavior, con ámbito de aplicación en el navegador:
const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: true, // required for Method 3 progress events
});Browser.setDownloadBehavior se aplica a todas las páginas del navegador, no solo a aquella en la que abriste la sesión, que es exactamente lo que quieres para un flujo de trabajo de descarga con varias pestañas. También te permite optar por los eventos de descarga con eventsEnabled: true, algo que el Método 3 utilizará ampliamente. El equipo de Chrome DevTools documenta ambas llamadas, y la referencia del Protocolo de Chrome DevTools es la fuente de referencia cuando el comportamiento cambia entre versiones de Chrome.
Consejo práctico: da preferencia a Browser.setDownloadBehavior para el código nuevo. Reserva Page.setDownloadBehavior solo como alternativa para versiones muy antiguas de Chrome que no puedas actualizar. Y pasa siempre una ruta absoluta; las rutas relativas no solo son arriesgadas, sino que fallan de forma silenciosa.
Activación del clic y comprobación de finalización
La llamada a await page.click(selector) devuelve el resultado en el momento en que se dispara el evento de clic, lo cual es mucho antes de que se transfieran los bytes. Para saber cuándo ha finalizado realmente la descarga, necesitamos una función auxiliar que supervise la carpeta de descargas e ignore los archivos temporales de Chrome. Chrome escribe en something.pdf.crdownload mientras la descarga está en curso, y luego renombra el archivo con su nombre definitivo cuando los bytes se han guardado. Nuestra ayuda espera tanto al cambio de nombre como a un intervalo de tamaño de archivo estable, lo que protege contra archivos parciales en conexiones lentas y sistemas de archivos anómalos.
// waitForRealFile.js
import fs from 'fs/promises';
import path from 'path';
export async function waitForRealFile(dir, knownBefore, {
timeoutMs = 90_000,
stableChecks = 3,
intervalMs = 250,
} = {}) {
const deadline = Date.now() + timeoutMs;
let lastSize = -1;
let stable = 0;
let candidate = null;
while (Date.now() < deadline) {
const entries = await fs.readdir(dir);
const fresh = entries.filter(
(n) => !knownBefore.has(n) && !n.endsWith('.crdownload'),
);
if (fresh.length) {
candidate = path.join(dir, fresh[0]);
const { size } = await fs.stat(candidate);
if (size === lastSize && size > 0) {
if (++stable >= stableChecks) return candidate;
} else {
stable = 0;
lastSize = size;
}
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Download did not finish within ${timeoutMs}ms`);
}Los valores predeterminados de 90 segundos de tiempo de espera, tres comprobaciones de tamaño estable y un intervalo de sondeo de 250 ms son un punto de partida razonable para archivos del orden de decenas de MB. Aumenta el tiempo de espera para descargas más grandes y redúcelo para puntos finales rápidos en los que prefieras fallar rápido.
El flujo en el lado de la llamada es el siguiente:
const before = new Set(await fs.readdir(DOWNLOAD_DIR));
await page.click('[data-testid="download-button"]');
const finalPath = await waitForRealFile(DOWNLOAD_DIR, before);
console.log('Downloaded:', finalPath);Una nota sobre la integridad: waitForRealFile es heurística. Chrome puede renombrar un archivo antes de que se haya vaciado por completo en casos excepcionales, especialmente en sistemas de archivos de red. Si necesitas garantías más sólidas, combina este ayudante con el evento CDP Browser.downloadProgress del Método 3, donde la state: 'completed' señal es más fiable (aunque, como veremos, sigue sin ser absoluta).
Script completo del Método 1 y modos de fallo habituales
Poniendo todo junto en method1.js:
// method1.js
import fs from 'fs/promises';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
import { waitForRealFile } from './waitForRealFile.js';
const TARGET_URL = 'https://example.com/reports';
const DOWNLOAD_SELECTOR = '[data-testid="download-report"]';
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: false,
});
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
const before = new Set(await fs.readdir(DOWNLOAD_DIR));
await page.click(DOWNLOAD_SELECTOR);
const finalPath = await waitForRealFile(DOWNLOAD_DIR, before);
console.log('Saved to:', finalPath);
await browser.close();
})();Algunas cosas que este script hace bien y que la mayoría de los tutoriales omiten:
- Utiliza
networkidle2para que el botón de descarga esté en el DOM y vinculado antes de hacer clic. Si haces clic demasiado pronto, se activará el clic antes de que se haya cargado el JavaScript que lo gestiona. - Toma una instantánea del directorio antes de hacer clic, por lo que un archivo sobrante de una ejecución anterior no se reporta como la nueva descarga.
- Cierra explícitamente el navegador; de lo contrario, el proceso Node puede quedarse colgado en un Chrome que sigue abierto.
Errores comunes y qué comprobar:
- No se descarga nada. Confirma
Browser.setDownloadBehaviorse ejecutó antes de la navegación y quedownloadPathsea absoluta. Una ruta relativa es el fallo silencioso más común. - El selector se pulsa pero no ocurre nada. La «descarga» podría ser una navegación en lugar de una descarga. Observa la página en modo de encabezado; si la URL cambia en lugar de activar un cuadro de diálogo de guardado, cambia al Método 2 o al Método 4 para capturar los bytes directamente.
- La descarga se detiene en
.crdownload. O bien el servidor se ha colgado, tu tiempo de espera es demasiado corto o la página se ha cerrado antes de que terminara la descarga. AumentatimeoutMsy asegúrate de no llamar abrowser.close()hasta quewaitForRealFilese resuelva. - Headless funciona localmente pero no en CI. Los Chrome de los contenedores a veces se distribuyen sin permisos de escritura en la ruta de descarga, o con políticas de sandbox más estrictas. Crea la carpeta previamente y pasa
--no-sandboxsolo cuando comprendas las implicaciones de seguridad.
Otro fallo más que es fácil pasar por alto: un script del Método 1 que funciona la primera vez y falla en la segunda ejecución, porque la ejecución anterior dejó un report.pdf.crdownload en la carpeta y ahora el nuevo clic está bloqueado o el archivo se ha renombrado como report (1).pdf. Elimina *.crdownload y cualquier archivo de salida sobrante al inicio de cada ejecución para que la instantánea del directorio esté limpia antes de hacer clic. El before ajuste en waitForRealFile solo te protege contra los archivos que ya existían en el momento de la instantánea, no contra los que Chrome generó para ti con un nombre de archivo deduplicado que no esperabas.
Método 2: Recupera el archivo dentro de la página y redirígelo a Node.js
El método 1 funciona siempre que Chrome esté dispuesto a gestionar la descarga por ti. Algunos sitios no son tan amables. Generan la URL del archivo en JavaScript, la protegen con cookies que solo existen tras un inicio de sesión SPA de varios pasos, o te proporcionan una blob: URL que el propio Chrome ha creado y que ningún cliente HTTP externo puede resolver. En todos esos casos, el único lugar que puede recuperar el archivo es la propia página, ya que esta ya cuenta con la sesión adecuada.
El método 2 se ejecuta fetch() dentro page.evaluate(), lee el cuerpo de la respuesta dentro del navegador y envía los bytes de vuelta a Node a través de la capa de serialización de Puppeteer. Dado que page.evaluate() solo puede devolver valores serializables en JSON, los datos binarios deben codificarse, y la respuesta universal es base64. Node lo decodifica, escribe un Buffer en el disco, y ya tienes tu archivo.
Este método destaca en:
- SPA autenticadas en las que las cookies y los encabezados son más fáciles de «tomar prestados» dentro de la página que de recolectar y reproducir.
- Archivos servidos a través de URL de blob, URL de objetos o generación en memoria (los informes PDF creados en JavaScript son un ejemplo clásico).
- Puntos finales compatibles con CORS en los que la propia página tiene permiso para descargar el archivo.
No es adecuado para:
- Archivos muy grandes, ya que base64 aumenta la carga útil en aproximadamente un 33 % y su procesamiento a través de V8 consume muchos recursos de CPU y memoria.
- Puntos finales no compatibles con CORS que la página no puede recuperar (las reglas del navegador siguen aplicándose).
A continuación, tratamos primero el patrón de archivos pequeños a medianos y, después, una variante fragmentada que gestiona casos de varios cientos de MB sin colapsar tu proceso Node.
Uso de page.evaluate con fetch para leer la respuesta como un Blob
En el interior de page.evaluate(), fetch() se comporta exactamente como una solicitud fetch normal del navegador. Incluye cookies para las solicitudes del mismo origen, sigue las redirecciones y respeta CORS. Eso es lo que lo hace tan potente en este caso: si la página puede ver el archivo, tu script también puede.
const base64 = await page.evaluate(async (fileUrl) => {
const res = await fetch(fileUrl, { credentials: 'include' });
if (!res.ok) {
throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);
}
const buf = await res.arrayBuffer();
// Convert ArrayBuffer to base64 inside the browser.
let binary = '';
const bytes = new Uint8Array(buf);
const chunkSize = 0x8000; // 32 KB stride to avoid stack issues
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunkSize),
);
}
return btoa(binary);
}, fileUrl);Hay dos detalles de implementación que vale la pena comprender. En primer lugar, String.fromCharCode.apply(null, bigArray) satura la pila de llamadas si pasas decenas de megabytes de una sola vez, por lo que recorremos el búfer en pasos de 32 KB antes de llamar a btoa. En segundo lugar, credentials: 'include' es lo que convierte esto en un patrón de «descarga de solicitud de Puppeteer» en primer lugar; sin ello, se pierden las cookies de sesión y la solicitud ya no está autenticada.
Puedes adaptar el mismo patrón a un caso de uso de descarga de PDF con Puppeteer en el que la URL se genera dinámicamente en la SPA: extrae la URL del data- o una llamada de retorno de JS, pásala a page.evaluate(), y deja que la página realice la recuperación. Los bytes que se devuelven son solo bytes; el formato de origen no importa para Node.
Si fetch() falla con un error CORS, es el navegador indicándote que la página no tiene permiso para leer el cuerpo de la respuesta. Tienes dos opciones: cambiar al Método 1 y dejar que Chrome se encargue de la descarga (CORS no se aplica a las navegaciones ni a las descargas), o cambiar al Método 4 y repetir la solicitud desde Node, donde no se aplica la política de mismo origen.
Devolver base64 a Node y escribir el búfer en el disco
Una vez que base64 está de vuelta en Node, el resto es fácil. Buffer.from(base64, 'base64') lo decodifica, fs.writeFile lo guarda en el disco y Buffer.byteLength te permite verificar el tamaño comparándolo con cualquier Content-Length que hayas guardado anteriormente:
import fs from 'fs/promises';
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
const TARGET_URL = 'https://example.com/report-page';
const FILE_URL_SELECTOR = 'a#download-link';
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
const fileUrl = await page.$eval(FILE_URL_SELECTOR, (a) => a.href);
const base64 = await page.evaluate(async (url) => {
const res = await fetch(url, { credentials: 'include' });
const buf = await res.arrayBuffer();
let binary = '';
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + 0x8000),
);
}
return btoa(binary);
}, fileUrl);
const buffer = Buffer.from(base64, 'base64');
console.log('Bytes from page.evaluate:', buffer.byteLength);
const outPath = path.join(DOWNLOAD_DIR, 'report.pdf');
await fs.writeFile(outPath, buffer);
console.log('Saved to:', outPath);
await browser.close();
})();En una ejecución real con un PDF pequeño, este script registra algo como Bytes from page.evaluate: 3672808 y luego escribe el archivo en una sola fs.writeFile. El recuento de bytes es un indicador útil: si esperabas 5 MB y obtienes 80 KB, es casi seguro que has recibido una página de error HTML en lugar de un PDF, y deberías inspeccionar los primeros bytes del búfer para confirmarlo antes de guardar.
Este patrón funciona bien hasta unos 50 MB. Más allá de eso, la propia cadena base64 empieza a dominar el montón de Node (cada carácter ocupa dos bytes en V8), y empezarás a ver JavaScript heap out of memory fallos. Eso es lo que resuelve la siguiente subsección.
Transmisión de archivos grandes con base64 fragmentado
Para archivos de varios cientos de MB, devolver una sola cadena base64 desde page.evaluate() es una receta segura para un fallo por falta de memoria. La solución consiste en leer la respuesta como un flujo dentro del navegador, dividirla en fragmentos de aproximadamente 1 MB, codificar cada fragmento como base64 y enviarlos de vuelta a Node de uno en uno. En el lado de Node, se decodifica cada fragmento en un Buffer y se añade a un flujo de escritura, de modo que el archivo completo nunca se almacena en la RAM.
El patrón utiliza expose function para proporcionar al navegador una forma de realizar una llamada de retorno a Node, además de ReadableStream.getReader() para recorrer el cuerpo de la respuesta fragmento a fragmento:
import fs from 'fs';
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
const FILE_URL = 'https://example.com/big-archive.zip';
const OUT_PATH = path.join(DOWNLOAD_DIR, 'big-archive.zip');
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
const out = fs.createWriteStream(OUT_PATH);
let written = 0;
await page.exposeFunction('onChunk', async (b64) => {
const buf = Buffer.from(b64, 'base64');
written += buf.byteLength;
if (!out.write(buf)) {
// Apply backpressure if the write stream is saturated.
await new Promise((r) => out.once('drain', r));
}
});
await page.exposeFunction('onDone', () => {
out.end();
console.log('Total bytes:', written);
});
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.evaluate(async (url) => {
const res = await fetch(url, { credentials: 'include' });
const reader = res.body.getReader();
const CHUNK = 1 << 20; // 1 MB target
let pending = new Uint8Array(0);
const flush = (bytes) => {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + 0x8000),
);
}
return window.onChunk(btoa(binary));
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
const merged = new Uint8Array(pending.length + value.length);
merged.set(pending, 0);
merged.set(value, pending.length);
pending = merged;
while (pending.length >= CHUNK) {
await flush(pending.subarray(0, CHUNK));
pending = pending.subarray(CHUNK);
}
}
if (pending.length) await flush(pending);
await window.onDone();
}, FILE_URL);
await browser.close();
})();Hay algunas cosas que hay que interiorizar. page.exposeFunction añade una variable global en la página que, cuando se invoca, espera un controlador del lado de Node. La usamos para enviar fragmentos base64 directamente a un flujo de escritura, de modo que los bytes nunca se acumulan en la memoria de V8. También respetamos la contrapresión: si out.write() devuelve false, esperamos a 'drain' antes de continuar. Sin eso, una red rápida y un disco lento acabarían almacenando en el búfer todo el archivo en Node de todos modos, lo que iría en contra del objetivo.
El tamaño de fragmento de 1 MB es un equilibrio. Fragmentos más pequeños significan más idas y venidas entre la página y Node y más sobrecarga base64 por llamada. Fragmentos más grandes alivian la sobrecarga pero consumen más memoria en el navegador. Un MB es un punto de partida razonable; ajústalo a tu carga de trabajo.
Cuándo es adecuado la recuperación dentro de la página (autenticación, SPA, URL de blobs)
El método 2 es la respuesta correcta cuando el archivo solo «existe» dentro de la sesión del navegador, y el método 1 no puede acceder a él por una de estas tres razones.
La primera es la autenticación basada en cookies o tokens, que es resistente a la repetición. Algunos sitios vinculan la sesión a huellas digitales (User-Agent más IP más un token CSRF en un almacenamiento que no sea de cookies), y reproducir eso fuera del navegador es complicado. La recuperación dentro de la página elude eso por completo porque la solicitud proviene de la página propietaria de la sesión.
La segunda son las descargas generadas por SPA. Al hacer clic en un botón se ejecuta JavaScript que crea un Blob, lo pasa a URL.createObjectURLy activa una descarga mediante un <a download> . La URL es algo así como blob:https://app.example.com/abc-123 y solo la página de origen puede resolverla. El método 1 podría capturar la descarga resultante si setDownloadBehavior está habilitado, pero el método 2 es más determinista: recrea tú mismo la misma recuperación, codifica el resultado y omite por completo el flujo de descarga de Chrome.
El tercero son los puntos finales de exportación dinámica. Las API que toman una carga útil JSON, generan un CSV o PDF sobre la marcha y lo devuelven en línea son fáciles de programar con page.evaluate() , ya que puedes JSON.stringify la carga útil, enviar un POST y leer la respuesta como un flujo.
Cuándo la recuperación dentro de la página no es adecuada: archivos muy grandes (tratados anteriormente), archivos protegidos por CORS que la página no tiene permiso para leer, y cualquier caso en el que una simple solicitud Axios desde Node simplemente funcionaría. Utiliza la herramienta más sencilla que consiga los bytes.
Método 3: Gestionar descargas con el protocolo Chrome DevTools
El método 1 utiliza el CDP entre bastidores, pero lo trata como un paso de configuración. El método 3 convierte al CDP en el protagonista. Cuando necesitas un progreso en tiempo real, cuando estás ejecutando descargas en paralelo y necesitas asociar cada una de ellas al clic que la inició, o cuando quieres detectar interrupciones de forma temprana, te interesan los eventos del CDP a nivel del navegador: Browser.downloadWillBegin y Browser.downloadProgress. Te proporcionan un GUID por descarga, el nombre de archivo sugerido, el total de bytes si se conoce, los bytes recibidos hasta el momento y una máquina de estados de inProgress, completed, y canceled.
Este es el mismo protocolo que utiliza el propio panel DevTools de Chrome, y se acerca más a una API de descarga «real» que cualquier cosa que Puppeteer exponga de forma nativa. El inconveniente es que se encuentra un nivel por debajo de page.click(), por lo que hay que conectarlo explícitamente y escuchar los eventos en la sesión CDP en lugar de esperar a una promesa de Puppeteer.
Cuándo elegir el método 3:
- Necesitas mostrar el progreso al usuario o enviarlo a una cola de tareas.
- Estás ejecutando tareas de descarga de archivos simultáneas con Puppeteer y necesitas asignar los nombres de los archivos al contexto.
- Quieres una señal clara de «esta descarga se ha cancelado» en lugar de tener que adivinarlo a partir del sistema de archivos.
- Quieres una solución de descarga sin interfaz gráfica de Puppeteer fiable que no dependa del legado
Page.setDownloadBehavior.
Cuándo omitirlo:
- Solo necesitas un archivo a la vez y el Método 1 es suficiente.
- Puedes obtener la URL y usar Axios; en ese caso, la complejidad de la infraestructura de CDP rara vez merece la pena.
Abrir una sesión CDP con page.createCDPSession
En Puppeteer hay dos sesiones de CDP entre las que elegir: de ámbito de página y de ámbito del navegador. Para el Método 3 queremos la sesión de ámbito del navegador, porque los eventos de descarga se emiten a nivel del navegador y Browser.setDownloadBehavior es un método a nivel del navegador.
const session = await browser.target().createCDPSession();Compáralo con await page.createCDPSession(), que es de ámbito de página. Las sesiones de página siguen funcionando para llamadas de navegación, red y tiempo de ejecución limitadas a una página, pero no detectarán las descargas a nivel del navegador si Chrome las redirige a través del destino del navegador (que es la tendencia en las versiones recientes).
Un modelo mental útil: una sesión de CDP es un websocket tipado hacia un destino. browser.target() es el destino del navegador, page.target() es un destino de página, y cada uno recibe eventos diferentes. Confundirlos es una causa frecuente de errores del tipo «mi listener nunca se activa» en el Método 3. Si tu Browser.downloadProgress escucha no responde, comprueba que has abierto la sesión en browser.target(), no en la página.
Puedes tener varias sesiones de CDP abiertas a la vez, incluyendo una por página más una en el navegador. Para las tareas de descarga, basta con una sola sesión a nivel del navegador.
Browser.setDownloadBehavior y escuchar downloadWillBegin / downloadProgress
Con la sesión del navegador a mano, configura el comportamiento de descarga y suscríbete a los eventos:
const downloads = new Map(); // guid -> { filename, totalBytes, received, state }
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: true, // turn on downloadWillBegin / downloadProgress
});
session.on('Browser.downloadWillBegin', (event) => {
// event: { guid, url, suggestedFilename, frameId }
downloads.set(event.guid, {
filename: event.suggestedFilename,
received: 0,
totalBytes: 0,
state: 'inProgress',
});
console.log(`Starting download: ${event.suggestedFilename}`);
});
session.on('Browser.downloadProgress', (event) => {
// event: { guid, totalBytes, receivedBytes, state }
const entry = downloads.get(event.guid);
if (!entry) return;
entry.totalBytes = event.totalBytes;
entry.received = event.receivedBytes;
entry.state = event.state;
if (event.totalBytes > 0) {
const pct = ((event.receivedBytes / event.totalBytes) * 100).toFixed(1);
process.stdout.write(` ${entry.filename}: ${pct}%\r`);
}
if (event.state === 'completed') {
console.log(`\nFinished: ${entry.filename}`);
} else if (event.state === 'canceled') {
console.warn(`\nCanceled: ${entry.filename}`);
}
});Algunos patrones que vale la pena tener en cuenta:
- El
guidcampo es la clave para el seguimiento de descargas paralelas. Chrome asigna un GUID nuevo por descarga, y elsuggestedFilenamees el nombre que llevará el archivo en el disco (salvo colisiones, en las que Chrome añade(1),(2), etc.). totalBytespuede ser0si el servidor no envía unContent-Length. En ese caso, no puedes mostrar un porcentaje, solo un recuento de bytes en curso. Planifica tu interfaz de usuario en consecuencia.state: 'completed'es una señal clara de que la descarga ha finalizado, pero no es una garantía absoluta de que el archivo se haya volcado completamente al disco. Chrome puede informar de la finalización ligeramente antes del cambio de nombre o del volcado final, por lo que sigue siendo buena idea realizar una breve comprobación del tamaño estable además del evento.state: 'canceled'Incluye descargas canceladas por el usuario (poco frecuentes en modo sin interfaz gráfica) y descargas abortadas (fallo de red, desconexión del servidor). Trata ambas de la misma manera: reintenta o indica claramente el error.
Si no estableces eventsEnabled: true, obtienes la descarga pero no los eventos, lo que te devuelve al territorio del sondeo del Método 1. Opta siempre por el Método 3.
Para una comprobación más estricta de que «el archivo está realmente en el disco», combina el 'completed' evento con un waitForFileStable ayudante, similar al del Método 1 pero más estricto (tiempo de espera de 30 segundos, tres comprobaciones de estabilidad):
async function waitForFileStable(filePath, {
timeoutMs = 30_000,
stableChecks = 3,
intervalMs = 200,
} = {}) {
const deadline = Date.now() + timeoutMs;
let last = -1, stable = 0;
while (Date.now() < deadline) {
try {
const { size } = await fs.stat(filePath);
if (size === last && size > 0) {
if (++stable >= stableChecks) return size;
} else {
stable = 0; last = size;
}
} catch {}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`File never stabilized: ${filePath}`);
}Ahora tienes ambas señales: CDP dice «hecho» y el sistema de archivos lo confirma.
Script completo del Método 3 con registro de progreso
// method3.js
import path from 'path';
import { launchBrowser, newPage, DOWNLOAD_DIR } from './launch.js';
import { waitForFileStable } from './waitForFileStable.js';
const TARGET_URL = 'https://example.com/reports';
const SELECTOR = '[data-testid="download-report"]';
(async () => {
const browser = await launchBrowser();
const page = await newPage(browser);
const session = await browser.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: DOWNLOAD_DIR,
eventsEnabled: true,
});
let resolveDone, rejectDone;
const done = new Promise((r, j) => { resolveDone = r; rejectDone = j; });
let lastFilename = null;
session.on('Browser.downloadWillBegin', (e) => {
lastFilename = e.suggestedFilename;
console.log('Begin:', e.guid, '->', e.suggestedFilename);
});
session.on('Browser.downloadProgress', async (e) => {
if (e.state === 'completed') {
const finalPath = path.join(DOWNLOAD_DIR, lastFilename);
try {
await waitForFileStable(finalPath);
resolveDone(finalPath);
} catch (err) { rejectDone(err); }
} else if (e.state === 'canceled') {
rejectDone(new Error('Download canceled'));
}
});
await page.goto(TARGET_URL, { waitUntil: 'networkidle2' });
await page.click(SELECTOR);
const finalPath = await done;
console.log('Saved to:', finalPath);
await browser.close();
})();Lo que este script te ofrece respecto al Método 1: finalización determinista (sabes exactamente cuándo empieza y termina la descarga a través de eventos, no por conjeturas), progreso en tiempo real (el downloadProgress controlador se activa cada pocos cientos de KB) y gestión explícita de la cancelación. También se generaliza limpiamente a N descargas paralelas: mantén un Map<guid, Promise>, resuelve cada promesa dentro del controlador y Promise.all todo eso.
En producción, normalmente querrás envolver done en un tiempo de espera para que una descarga bloqueada no paralice tu trabajador para siempre. Un límite superior de 5 a 10 minutos es razonable para archivos típicos. Si lo superas, registra el GUID, elimina la página y vuelve a intentarlo. CDP te da la visibilidad para tomar esa decisión; el sistema de archivos por sí solo no lo hace.
Un segundo patrón que vale la pena conocer para el Método 3: promesas por descarga. En lugar de una única done promesa, mantenga una Map<guid, { resolve, reject }> y crea una entrada dentro de Browser.downloadWillBegin. El Browser.downloadProgress controlador llama entonces a resolve o reject en la entrada que coincida con el guid. Una vez hecho esto, puedes disparar N clics seguidos, recoger N promesas y Promise.all resolverlas. El mismo código de controlador funciona tanto para un archivo como para cincuenta, y obtienes un informe de errores claro por archivo en lugar de un único tiempo de espera global que oculta qué descarga falló realmente.
Método 4: Omite el navegador, pasa la URL a Axios o https
A veces, la mejor estrategia de descarga de archivos con Puppeteer es no usar Puppeteer casi en absoluto. Si el sitio expone una URL real y estable para el archivo (incluso si tienes que renderizar la página y hacer clic por ahí para descubrirla), puedes renderizar con Puppeteer solo el tiempo suficiente para extraer esa URL más el estado de autenticación, y luego descargar con axios o la función integrada de Node https. El resultado es más rápido que el Método 1, consume menos memoria que el Método 2 y es fácilmente paralelizable, a diferencia de lo que ocurre al ejecutar N instancias de Chrome.
Este es también el método más «aburrido», en el buen sentido. Una vez que tienes la URL, la descarga es simplemente un HTTP GET. No hay que hacer un seguimiento de la regresión del modo sin interfaz gráfica, ni de la deriva de la versión del CDP, ni de .crdownload centinela que sondear. Le pasas la URL y unos cuantos encabezados a Axios, canalizas la respuesta a un flujo de escritura y el archivo está en el disco.
Elige el método 4 cuando:
- El archivo de destino se encuentra en una URL estable que puedes extraer del DOM, una respuesta de red o una variable JS.
- El archivo es grande y quieres una verdadera transmisión al disco sin almacenamiento en búfer a través de V8.
- Necesitas ejecutar muchas descargas simultáneamente. Un grupo de solicitudes de Axios es mucho más económico que un grupo de Chrome sin interfaz gráfica.
Omite el método 4 cuando:
- La URL de descarga es de un solo uso, está firmada o vinculada a un token de la sesión del navegador de una forma que no puedes reproducir.
- El sitio impone retos de JavaScript o comprobaciones de huellas digitales que Axios no puede superar sin un esfuerzo considerable.
Cuando se da el segundo caso, normalmente se sustituye Axios por una capa de solicitudes que gestiona esas comprobaciones, pero la estructura del script no cambia.
Extraer cookies y encabezados de Puppeteer para autenticar la solicitud
El objetivo principal de un flujo híbrido es heredar la sesión de Puppeteer. Se lleva a cabo el inicio de sesión en la SPA o cualquier otro ritual que requiera el sitio, y luego se transfieren las cookies y algunos encabezados clave a Axios.
async function buildAxiosHeaders(page) {
const cookies = await page.cookies(); // current page's cookies
const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
const userAgent = await page.evaluate(() => navigator.userAgent);
const referer = page.url();
return {
Cookie: cookieHeader,
'User-Agent': userAgent,
Referer: referer,
Accept: '*/*',
'Accept-Language': 'en-US,en;q=0.9',
};
}Los cuatro encabezados anteriores cubren la gran mayoría de las comprobaciones de CDN y WAF. Cookie transporta la sesión, User-Agent coincide con lo que la página ya ha demostrado, Referer coincide con lo que el navegador enviaría al hacer clic en el enlace de descarga, y Accept-Language es una pequeña pista de que un navegador real acaba de estar allí. Si el sitio comprueba Sec-Ch-Ua u otras pistas del cliente, cópialas también con page.evaluate(() => navigator.userAgentData).
Dos cosas a tener en cuenta. Primero, page.cookies() devuelve cookies para la URL actual de forma predeterminada. Si el archivo está alojado en un subdominio diferente, pasa esa URL explícitamente: page.cookies(fileUrl). De lo contrario, las cookies que envíes no se transmitirán. Segundo, algunos sitios establecen HttpOnly o Secure indicadores que Axios respeta sin problemas, pero las cookies de ámbito de ruta (Path=/api) se ignoran a menos que las conserves al construir el encabezado. La solución más sencilla es recuperar las cookies del origen exacto al que vas a acceder e incluir solo aquellas cuyo path sea un prefijo de la ruta de la URL del archivo.
Si quieres evitar tener que hacer esto a mano, existen adaptadores maduros de axios-cookiejar que toman las cookies de Puppeteer y permiten que Axios las gestione por solicitud. Para el caso habitual, basta con un encabezado de una sola línea Cookie . Para obtener más información sobre cómo reforzar las llamadas de Axios contra la detección, una guía interna de axios-headers complementa perfectamente esta sección.
Transmisión de la respuesta con axios responseType: stream
La descarga en sí es sencilla cuando se utiliza responseType: 'stream'. Axios devuelve el cuerpo de la respuesta como un flujo Node, y tú lo canalizas a un flujo de escritura. El archivo completo nunca se almacena en la RAM:
import axios from 'axios';
import fs from 'fs';
import { pipeline } from 'stream/promises';
async function downloadToFile(url, outPath, headers) {
const res = await axios.get(url, {
headers,
responseType: 'stream',
timeout: 30_000,
maxRedirects: 5,
validateStatus: (s) => s >= 200 && s < 400,
});
await pipeline(res.data, fs.createWriteStream(outPath));
}stream.pipeline (o su versión promise, utilizada aquí) es la primitiva adecuada porque propaga los errores desde ambos lados y limpia los flujos correctamente en caso de fallo. Un res.data.pipe(write) se traga los errores del flujo de escritura, lo que hace que acabes con un archivo a medio escribir y sin excepción.
Algunos ajustes para entornos de producción:
- Tiempos de espera.
timeout: 30_000es un tiempo de espera para el establecimiento de la solicitud. Para descargas largas, envuelve también el pipeline en un watchdog para que un goteo lento no se cuelgue para siempre. - Reintentos. Envuelve la llamada en un pequeño ayudante de reintentos con retroceso exponencial, con un límite de tres intentos. La mayoría de los fallos transitorios (504, ECONNRESET) se solucionan con un reintento.
- Evita las escrituras simultáneas en la misma ruta. Dos tareas paralelas que sobrescriben
report.pdfsupone un error de corrupción silencioso. Utilice un nombre de archivo temporal y renómbrelo, o utilice nombres de archivo únicos por tarea.
Para el paralelismo, un grupo pequeño es la opción predeterminada más segura. De tres a cinco descargas simultáneas de Axios es un límite razonable, y un for...of await es la referencia más segura si no estás seguro de los límites de velocidad del lado del servidor. A partir de cinco tareas simultáneas, deberías medir en lugar de adivinar.
Descargas de URL puras sin Puppeteer en el bucle
Una vez que hayas descifrado el patrón de URL, a menudo puedes prescindir por completo de Puppeteer. Una ejecución híbrida típica utiliza Puppeteer para rastrear una cuadrícula de resultados de búsqueda, extraer una URL de página de detalles por resultado y, a continuación, visitar cada página de detalles para obtener la URL del archivo o, si el patrón de URL es predecible, derivarla directamente del listado.
Un flujo representativo de principio a fin que descarga cinco archivos de imagen tiene este aspecto:
import axios from 'axios';
import fs from 'fs';
import path from 'path';
async function downloadAll(items, headers, outDir) {
for (let i = 0; i < items.length; i++) {
const url = items[i].downloadUrl;
const out = path.join(outDir, `image-${String(i + 1).padStart(3, '0')}.jpg`);
await downloadToFile(url, out, headers);
console.log('Saved', out);
}
}Ejecuta eso con una lista de cinco URL extraídas y obtendrás image-001.jpg en image-005.jpg en el disco, sin ningún proceso de Chrome asociado para la transferencia real. Si las URL son públicas y no están firmadas, puedes prescindir por completo de Puppeteer en ejecuciones posteriores y simplemente acceder a las URL directamente. Esa suele ser la mejor opción para las actualizaciones diarias de un conjunto de datos conocido; solo pagas el coste de Puppeteer la primera vez, mientras descubres la estructura de las URL.
La lección más importante: piensa en Puppeteer como una herramienta de descubrimiento y autenticación, no como una herramienta de descarga. La función del navegador es averiguar dónde se encuentran los bytes y validar la sesión correcta; la descarga en sí misma casi siempre puede realizarse mediante un cliente más pequeño y rápido.
Hay dos patrones operativos que amplían esto. En primer lugar, almacena en caché el patrón de URL descubierto en un pequeño archivo JSON o en una base de datos indexada por sitio, y vuelve a ejecutar el paso de descubrimiento de Puppeteer solo cuando una solicitud de Axios empiece a devolver un 404 o HTML inesperado. Las URL de los archivos de la mayoría de los sitios siguen una plantilla estable (/exports/{id}/{filename}.csv), y una vez que se dispone de la plantilla, las actualizaciones diarias no necesitan en absoluto un navegador. En segundo lugar, cuando la URL está firmada pero la lógica de firma es reproducible (HMAC sobre la carga útil de una solicitud, por ejemplo), realizar ingeniería inversa de la firma una vez y omitir Puppeteer de forma permanente para ese destino. El enfoque de descarga de archivos de Puppeteer demuestra su utilidad en el primer contacto; todo lo que viene después es HTTP simple.
Elegir el método adecuado de descarga de archivos con Puppeteer: una guía de decisión
Cuatro métodos son más de los que suele mostrar la SERP, y ahí está la clave: cada uno tiene su nicho. Aquí tienes una guía de decisión que relaciona unas cuantas preguntas de sí/no con el método adecuado, además de una tabla comparativa que puedes tener abierta mientras lees esta guía.
Empieza con las preguntas:
- ¿Tienes una URL de archivo estable y reproducible? Si la respuesta es sí, pasa a la pregunta 2. Si la respuesta es no (la URL es de un solo uso, generada por JS o solo válida dentro de la sesión de la página), te encuentras en el ámbito del Método 1 o del Método 2.
- ¿El archivo está protegido por una autenticación que persiste fuera del navegador? Si puedes volcar las cookies y reproducir la solicitud, el Método 4 es casi siempre la opción correcta. Si la autenticación está vinculada al navegador (tokens CSRF almacenados en la memoria de JS, huella de sesión), utiliza el Método 2.
- ¿El archivo es muy grande (más de ~100 MB) o estás ejecutando muchos en paralelo? El Método 4 es la mejor opción. El streaming con Axios es más barato que ejecutar N Chrome, y los viajes de ida y vuelta de base64 en el Método 2 no escalan.
- ¿Necesitas eventos de progreso o una señal clara de cancelación? El método 3 es el único que te ofrece ambas cosas directamente desde Chrome.
- ¿La descarga se activa mediante un clic cuya URL no puedes inspeccionar fácilmente? El método 1 es la respuesta más sencilla y suele ser suficiente.
|
Método |
Ideal para |
Evitar para |
Perfil de memoria |
Modelo de autenticación |
|---|---|---|---|---|
|
Descargas activadas por JS, URL desconocidas |
Archivos muy grandes, interfaz de progreso |
Bajo (Chrome transmite al disco) |
Lo que vea el clic |
|
SPA, URL de blobs, autenticación vinculada al navegador |
Archivos de varios cientos de MB |
Alto sin fragmentación |
Cookies del navegador, automático |
|
Tareas en paralelo, progreso, cancelación |
Archivos pequeños puntuales |
Bajo (Chrome transmite al disco) |
Lo que vea el clic |
|
Archivos grandes, canalizaciones paralelas, URL conocidas |
URL firmadas de un solo uso |
Bajo (transmisión real) |
Cookies y encabezados reproducidos |
Una regla general: da preferencia al método que utilice menos Puppeteer y que siga funcionando. El método 4 es el predeterminado si la URL es conocida. El método 1 es el predeterminado si no lo es. El método 3 es lo que debería haber sido el método 1 cuando se necesita paralelismo o progreso. El método 2 es la vía de escape para todo lo demás.
En caso de duda, prueba primero el método 4. Si funciona, te alegrarás de no haber ejecutado un Chrome para cada archivo. Si no funciona, sabrás en cuestión de minutos si el problema es la autenticación (método 2) o la URL (método 1).
Fortalecimiento de la producción: tiempos de espera, reintentos y comprobaciones de integridad
Un script de descarga de archivos de Puppeteer que funciona en tu portátil y falla en producción casi siempre falla por una de estas cuatro razones: un tiempo de espera que olvidaste configurar, un reintento que olvidaste escribir, un .crdownload centinela que olvidaste limpiar o un archivo parcial que trataste como completo. Aquí tienes la lista de comprobación por la que pasamos los scripts antes de que se pongan en marcha.
Tiempos de espera en cada capa. Configura timeout en page.goto (el valor predeterminado es 30 s, a menudo demasiado ajustado en cachés frías), un tiempo de espera explícito en tu waitForRealFile ayudante, un Axios timeout para el Método 4 y un límite de tiempo real para todo el trabajo. Los bloqueos de CI suelen deberse a la ausencia de alguno de estos elementos, no a la presencia de un error real.
Reintentos con retroceso. Envuelve la llamada que afecta a la red en un helper de reintento, con retroceso exponencial limitado a tres intentos y un fallo definitivo final. Reintenta ante ECONNRESET, ETIMEDOUT, respuestas 5xx y cualquier cosa que parezca transitoria. No reintentes en 401, 403 o 404, ya que eso indica errores en tu código.
Limpia .crdownload los archivos entre ejecuciones. Chrome los deja ahí cuando se cancela una descarga o el proceso se cierra antes de tiempo. Si vuelves a ejecutar el script, tu waitForRealFile puede recoger el sentinel obsoleto e informar de que el archivo incorrecto es nuevo. Limpia .crdownload, .tmpy tus propios archivos de trabajo al inicio de cada ejecución.
Verifica la integridad, no solo la existencia. Tres niveles de comprobación son razonables para cargas útiles importantes: el archivo existe, el tamaño del archivo coincide con el esperado Content-Length (cuando el servidor lo proporciona) y una suma de comprobación si la fuente la publica. Una rápida comparación MD5 o SHA-256 con crypto.createHash('sha256') es rápido en archivos de varios GB y detecta truncamientos que una simple comprobación de existencia pasa por alto.
Limita la concurrencia, no te limites a paralelizar. De tres a cinco descargas simultáneas es un valor predeterminado sensato; más allá de eso, empiezas a competir contigo mismo por el disco y el ancho de banda, y muchos sitios endurecen los límites de velocidad. Un p-limit pool de estilo más límites de concurrencia por host es una pequeña cantidad de código que evita muchos informes de incidencias.
Registra las correspondencias entre GUID y nombre de archivo (Método 3) o entre URL y salida (Método 4). Cuando algo sale mal a las 3 de la madrugada, un registro estructurado del tipo «esta URL generó este archivo con este número de bytes y este estado» es lo que te salva. Conserva los registros.
Ponga en cuarentena los archivos parciales. Si una descarga falla a mitad de camino, los bytes parciales son «radiactivos». Muévalos a un partial/ directorio, no los dejes donde la siguiente etapa de tu proceso pueda leerlos como si estuvieran completos. Un archivo parcial que parece completo es el tipo de error más costoso en la automatización de descargas.
Evitar bloqueos durante las descargas automatizadas
Incluso cuando el flujo de descarga de archivos de Puppeteer es a prueba de balas en la capa de gestión de archivos, la propia solicitud puede bloquearse antes de que llegue a generar bytes. Las CDN, los WAF y los proveedores de soluciones antibots analizan las mismas huellas digitales tanto si estás extrayendo HTML como si estás descargando un CSV de 200 MB, por lo que se aplican las mismas defensas.
El refuerzo más barato y eficaz reside en tres encabezados y una decisión sobre la IP:
- User-Agent realista. Utiliza un UA de Chrome de escritorio actual que coincida con la versión de Chrome for Testing incluida, no el predeterminado de Puppeteer. Algunos servidores bloquean el UA predeterminado nada más verlo.
- Ventana de visualización adecuada. Una ventana de visualización de 1366x900 se corresponde con una sesión de escritorio real. Una ventana de visualización de 800x600 grita «automatización».
- Referer. Establece
Referera la página que enlaza con el archivo. Los WAF suelen devolver un 403 en accesos directos a los activos sin referer, especialmente en el caso de PDF e imágenes. - IP razonable. Las IP de centros de datos de proveedores de nube habituales están premarcadas por la mayoría de los proveedores de soluciones antibots. Si tus descargas reciben errores 403 en navegadores reales pero pasan cuando te conectas a una red residencial mediante VPN, tienes un problema de IP, no un problema de script.
Unos cuantos pasos adicionales ayudan en los casos más rebeldes. Añade un pequeño slowMo (de 50 a 200 ms) para espaciar los clics. Utiliza page.waitForTimeout después goto para que se estabilicen las comprobaciones de bots basadas en JavaScript. Escalona los trabajos con varios archivos para no realizar N visitas en el mismo segundo.
Cuando hayas hecho todo lo anterior y el sitio siga bloqueándote, lo mejor es delegar la capa de solicitud en lugar de seguir ajustando los encabezados. Herramientas como nuestra red de proxies residenciales optimizada para scraping o nuestro punto final de la API Scraper en WebScrapingAPI se encargan de la rotación de proxies, la reputación de IP y las comprobaciones de huellas digitales más complejas detrás de una sola solicitud, para que tu código Puppeteer pueda centrarse en controlar la página. Ese es también el lugar adecuado al que acudir si necesitas descargas específicas por país o tienes que realizar scraping detrás de páginas de desafío.
Este es también un buen momento para plantearte si realmente necesitas un navegador sin interfaz gráfica. Vale la pena leer la descripción general del navegador sin interfaz gráfica enlazada en otra parte del sitio si aún estás decidiendo entre un entorno Puppeteer personalizado y una alternativa alojada.
Puppeteer vs Playwright para la descarga de archivos
Respuesta sincera: Playwright tiene una API más agradable para las descargas, Puppeteer tiene una conexión más directa con el funcionamiento interno de Chrome, y cualquiera de los dos funciona bien en producción.
Playwright expone page.waitForEvent('download'), que devuelve un Download objeto con ayudantes como download.path(), download.saveAs(path), y download.suggestedFilename(). No es necesario tocar el CDP para el caso básico. Eso es realmente más corto que la configuración equivalente de Puppeteer, y funciona de la misma manera en Chromium, Firefox y WebKit, lo cual es la mayor ventaja en las suites de pruebas entre navegadores. Si estás empezando desde cero y tu pila aún no se basa en Puppeteer, un flujo de trabajo de descarga con Playwright requiere aproximadamente la mitad de código.
La ventaja de Puppeteer es que está más cerca del Protocolo de Chrome DevTools. Si necesitas eventos CDP sin procesar, llamadas de protocolo personalizadas o comportamientos que aún no se han encapsulado en una API de nivel superior, Puppeteer lo consigue con una capa menos de indirección. El método 3 de esta guía es un buen ejemplo. El mismo patrón en Playwright también funciona (Playwright expone una sesión CDP), pero los recursos de Puppeteer se sienten nativos porque toda la biblioteca está diseñada en torno al CDP.
Para un flujo de trabajo de descarga de archivos de Puppeteer que ya está en marcha, nada de esto es motivo para migrar. El método 1, además de Browser.setDownloadBehavior coincide con las waitForEvent('download') en cuanto a características casi exactamente; solo hay que escribir unas pocas líneas más. Migra a Playwright cuando la compatibilidad entre navegadores sea la verdadera ventaja, no solo por las descargas. Tenemos una guía más extensa sobre web scraping con Playwright en el sitio web si quieres ver la comparación completa.
Conclusiones clave
- No existe un único método ideal para descargar archivos con Puppeteer. Adapta el método a la limitación que más te afecte: URL desconocida (Método 1), autenticación vinculada al navegador (Método 2), tareas paralelas con progreso (Método 3) o URL conocida con cookies reproducibles (Método 4).
setDownloadBehaviorno es negociable. Headless Chrome bloquea las descargas por defecto. Utiliza elBrowser.setDownloadBehaviorcon una ruta absoluta; la llamada a nivel de página está en desuso y falla de forma impredecible.- Espera a que se descarguen los archivos reales, no a los eventos de clic. Haz una instantánea de la carpeta de descargas, ignora
.crdownloady exige un intervalo de tiempo con un tamaño de archivo estable antes de informar del éxito. - Omite el navegador cuando puedas. Una combinación de Puppeteer y Axios es más rápida, ligera y fácil de escalar que ejecutar N instancias de Chrome para descargas paralelas.
- Refuerza la capa de solicitud por separado del script. Un User-Agent realista, que coincida con el viewport, el referer, las IP residenciales y una concurrencia limitada evitan la mayoría de los incidentes de «403 misteriosos».
Preguntas frecuentes
En todos los proyectos de descarga de archivos con Puppeteer surgen algunas preguntas, normalmente después de que el primer script funcione más o menos en modo headed y falle en la integración continua (CI). Las respuestas que siguen omiten el resumen de los cuatro métodos, que se encuentran más arriba, y se centran en decisiones operativas: cómo elegir rápidamente cuando no se pueden prototipar los cuatro, qué hacer cuando los archivos se niegan a completarse, cómo es en la práctica la ruta más limpia sin navegador, dónde se sitúa Playwright respecto a Puppeteer para las descargas, y cómo gestionar la autenticación vinculada a la sesión sin perder un fin de semana en ello.
¿Cómo elijo el mejor método para descargar un archivo con Puppeteer?
Elabora una lista reducida. Si puedes extraer una URL estable y la autenticación es reproducible, utiliza Axios con las cookies obtenidas de la sesión de Puppeteer. Si la URL está generada por JavaScript o solo es válida dentro de la página, ejecuta fetch() dentro page.evaluate() y devuelve base64. Si solo tienes un destino de clic y necesitas una finalización básica, configura Browser.setDownloadBehavior y haz clic. Si necesitas progreso o seguridad en paralelo, gestiona todo a través de eventos CDP. Adapta el método a la restricción que más te perjudique.
¿Por qué mi descarga de Puppeteer se queda atascada en un archivo .crdownload o nunca termina?
La causa más común es que el script se cierre antes de que Chrome vacíe el archivo, así que cierra siempre el navegador solo después de que un ayudante de sondeo confirme que el nombre de archivo final existe con un tamaño estable. Otras posibles causas: una ruta relativa downloadPath (debe ser absoluto), que el clic active una navegación en lugar de una descarga, o un bloqueo del servidor que Chrome informa como cancelado. Observa la ejecución en modo «headed» una vez y la causa suele quedar clara en segundos.
¿Puedo descargar archivos sin iniciar Chrome en absoluto?
Sí, y a menudo es la decisión correcta. Si la URL del archivo es pública, o si las cookies y los encabezados necesarios para recuperarlo pueden ser reproducidos por un cliente HTTP, omite el navegador y utiliza axios o la función integrada de Node https con una escritura en streaming. Las únicas ocasiones en las que necesitas un navegador son cuando JavaScript genera la URL, cuando la autenticación está vinculada a la sesión del navegador de una forma que no puedes reproducir, o cuando una capa de detección de bots bloquea específicamente a los clientes que no son navegadores en esa URL.
¿En qué se diferencia Puppeteer de Playwright para la descarga de archivos?
Playwright envuelve las descargas en una API de eventos de alto nivel (page.waitForEvent('download')) que devuelve un Download objeto con saveAs() y path() ayudas, lo cual es más conciso que la configuración equivalente de Puppeteer más CDP. Puppeteer te obliga a conectar Browser.setDownloadBehavior y o bien sondear el sistema de archivos o bien escuchar eventos de CDP. Ambas son fiables en producción. Elige según la biblioteca que ya utilice tu pila, no solo por la API de descarga.
¿Cómo puedo descargar archivos que requieren inicio de sesión o una cookie de sesión?
Hay dos opciones claras. O bien gestionas el inicio de sesión en Puppeteer, extraes las cookies con page.cookies(), y reproducir la solicitud de archivo desde Axios con un Cookie , además de la User-Agent y Referer. O bien, ejecutar la obtención del archivo dentro de page.evaluate() para que la solicitud herede la sesión automáticamente. La primera es más rápida y fácil de escalar; la segunda es más robusta cuando la autenticación está vinculada a tokens en memoria o huellas digitales que no sobreviven a la reproducción.
Conclusión y próximos pasos
Un flujo de trabajo fiable para la descarga de archivos con Puppeteer tiene menos que ver con Puppeteer y más con elegir dónde se mueven realmente los bytes. Utiliza el Método 1 cuando solo dispongas de un clic. Recurre al Método 2 cuando la sesión de la página sea lo único que pueda recuperar el archivo. Apóyate en el Método 3 cuando necesites progreso, paralelismo o señales de cancelación claras. Opta por el Método 4 en cuanto puedas reproducir la URL, y trata a Puppeteer como una herramienta de descubrimiento en lugar de una herramienta de descarga.
Envuelve cada script con los fundamentos de refuerzo de producción: rutas de descarga absolutas, tiempos de espera en capas, reintentos con retroceso, comprobaciones de integridad más allá de la mera existencia y concurrencia limitada. Detecta .crdownload los centinelas, elimínalos entre ejecuciones y nunca dejes que un archivo parcial fluya hacia abajo como si estuviera completo.
Si tus descargas se bloquean en lugar de fallar, el problema ya no está en tu script, sino en la capa de solicitud. Ahí es donde una infraestructura de scraping gestionada demuestra su valía. La API de navegador WebScrapingAPI te ofrece navegadores en la nube totalmente alojados que puedes controlar con el mismo código de Puppeteer (o Playwright), además de una red de proxies residenciales y desbloqueo integrado para los objetivos más difíciles, de modo que puedes mantener el manual de cuatro métodos anterior y simplemente cambiar el lugar desde donde se originan las solicitudes. A partir de ahí, escalar horizontalmente un canal de descarga de archivos de Puppeteer es un cambio de configuración más que una reestructuración de la arquitectura.
Elige el método adecuado para el archivo de hoy, refuérzalo una vez y sigue adelante.




