¿El scraper devuelve una página vacía? Es JavaScript. Aquí te decimos cómo solucionarlo
La etiqueta body está vacía en tu scraper pero llena en DevTools. No es un error, es renderizado del lado del cliente. Aquí te explicamos cómo identificarlo, tres maneras de obtener los datos y cómo evitar lanzar un navegador que no necesitas.
Obtienes la página, imprimes el HTML y la parte que querías falta: un <body> vacío, una lista de nodos vacía o un mensaje que dice "Se requiere JavaScript." Mientras tanto, el inspector del navegador muestra los datos justo ahí. Esto no es un error del scraper. Es una página que renderiza su contenido del lado del cliente con JavaScript, y tu solicitud HTTP solo obtiene la carcasa vacía que se ejecuta antes que el JavaScript. Aquí te explicamos cómo confirmarlo, tres maneras de obtener los datos de todos modos y cómo evitar lanzar un navegador sin necesidad.
Por qué el cuerpo está vacío
Un cliente HTTP en bruto — Python requests, Node fetch, cURL — descarga el HTML que envía el servidor y se detiene. No ejecuta JavaScript. Los sitios construidos con React, Vue, Angular o Next.js envían un esqueleto HTML casi vacío más un paquete que obtiene datos y construye el DOM en el navegador. Analizadores como BeautifulSoup o Cheerio ven el esqueleto, por lo que un selector que funciona perfectamente en DevTools devuelve un array vacío en tu código. Cheerio devolviendo [] en una página de React es el síntoma clásico; no ejecuta JavaScript, y nunca lo hará.
import * as cheerio from "cheerio";
const html = await (await fetch("https://shop.example.com/products")).text();
const $ = cheerio.load(html);
console.log($("div.product").length);
// 0 -> the products are drawn by JavaScript, not in the HTML
Confírmalo en diez segundos
Antes de recurrir a un navegador, prueba el diagnóstico. Dos comprobaciones rápidas te dicen exactamente con qué estás lidiando: compara la respuesta en bruto con lo que renderiza el navegador y cuenta los elementos en la consola. Si la consola encuentra nodos que el HTML en bruto no contiene, el contenido es del lado del cliente. Si el HTML en bruto ya lo tiene, el problema es tu selector o tus encabezados, no el renderizado.
# does the raw response actually contain the data?
curl -s https://shop.example.com/products | grep -c 'class="product"'
# 0 -> not in the HTML | 24 -> it IS there, fix your selector
# then, in the browser DevTools console on the same page:
# document.querySelectorAll('div.product').length -> 24
Una rama más para descartar: un cuerpo vacío no es siempre JavaScript. A veces el servidor retiene el contenido hasta que envías los encabezados o cookies correctos: una cookie de sesión, un referer, un Accept-Language o un User-Agent que no grite "script." Si cURL devuelve una página corta pero tu navegador (que envía un conjunto completo de encabezados) devuelve una completa, intenta reproducir los encabezados de solicitud del navegador antes de asumir que es el renderizado. Es una prueba de cinco minutos que puede ahorrarte lanzar un navegador que nunca necesitaste.

Solución 1: accede al API JSON oculto (más rápido)
Aquí está lo que la mayoría de la gente pasa por alto: si una página se dibuja a sí misma con JavaScript, los datos llegan de algún lugar, generalmente un endpoint JSON interno que el frontend llama después de cargar. Abre la pestaña de red, filtra por Fetch/XHR, recarga y busca la solicitud que devuelve tus datos. Llamar a ese endpoint directamente es más rápido, ligero y mucho menos frágil que analizar HTML, porque te saltas todo el navegador y obtienes JSON estructurado de inmediato. A veces el endpoint necesita un token que el HTML inicial incrusta: obténlo de la página y luego llama al API.
// 1) the SPA loads data from its own endpoint after paint
const res = await fetch("https://shop.example.com/api/catalog?page=1", {
headers: { accept: "application/json", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
});
const { products } = await res.json();
console.log(products.length); // real data, no browser
// 2) if it needs a token, the initial HTML usually carries it
const page = await (await fetch("https://shop.example.com/")).text();
const token = page.match(/"s_token":\s*"([^"]+)"/)?.[1];
Solución 2: extrae el JSON de hidratación
Incluso cuando no hay una llamada API separada, los frameworks incrustan el estado inicial directamente en el HTML para que el cliente pueda "hidratar" sin un viaje de ida y vuelta. Next.js lo envía en una etiqueta <script id="__NEXT_DATA__">; otros stacks usan window.__INITIAL_STATE__ o un blob similar. Ese JSON está en la respuesta en bruto que ya descargaste, no se requiere renderizado. Analiza la etiqueta de script y a menudo obtienes los datos estructurados exactos que la página mostraría, más limpios que scrapear el DOM renderizado.
import * as cheerio from "cheerio";
const html = await (await fetch(url)).text();
const $ = cheerio.load(html);
const blob = $("#__NEXT_DATA__").html(); // Next.js embeds page state here
if (blob) {
const data = JSON.parse(blob);
const products = data.props.pageProps.products;
console.log(products.length); // structured, from static HTML
}
Entre el API oculto y la extracción de hidratación, la mayoría de los problemas de "página vacía" se resuelven sin lanzar un navegador. Nuestra guía para scrapear sitios pesados en JavaScript profundiza en encontrar estos endpoints de manera confiable.
Una advertencia sobre ambos: los endpoints ocultos y los blobs de estado son superficies no oficiales, por lo que cambian sin previo aviso. Envuelve el análisis en una comprobación que falle ruidosamente cuando la forma cambie: una clave faltante o un array de longitud cero debería levantar un error, no devolver nada en silencio. De esa manera, una reestructuración del API del sitio aparece como un error claro en tus registros en lugar de un conjunto de datos vacío que no notas durante una semana. Fija el User-Agent y los encabezados que usaste cuando funcionó también; algunos de estos endpoints silenciosamente dependen de ellos.
Solución 3: renderiza, pero trátalo como último recurso
Cuando los datos realmente se construyen en el DOM sin JSON expuesto, tienes que ejecutar el JavaScript. Playwright o Puppeteer manejan un navegador real, esperan el contenido y te entregan el HTML terminado. Funciona en todas partes, pero es la opción costosa: renderizar cuesta aproximadamente 10-50 veces el ancho de banda y tiempo de una llamada JSON, y un navegador sin cabeza en una IP de centro de datos se bloquea rápidamente. Enrútalo a través de un proxy residencial y espera la señal correcta, no un sueño fijo.
import { chromium } from "playwright";
const browser = await chromium.launch();
const context = await browser.newContext({
proxy: { server: "http://gate.quantumproxies.io:8000", username: "USER", password: "PASS" },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForSelector("div.product"); // wait for data, not a sleep
const html = await page.content();
await browser.close();
Renderiza y rota en una sola llamada con Scraper API

Cuando dejar de renderizar por ti mismo vale la pena
Ejecutar tu propia flota de navegadores significa gestionar la detección sin cabeza, la rotación de proxies, las fugas de memoria y la lógica de espera para cada objetivo. Una vez que estás manteniendo eso, un Scraper API que renderiza JavaScript, rota IPs residenciales y devuelve HTML limpio, JSON o markdown en una sola solicitud suele ser menos código y una tasa de éxito más alta, y puede ejecutar extracción de IA en la página renderizada para que te saltes los selectores por completo. Nuestro desglose de costo de renderizado versus solicitudes simples muestra cuándo hacer ese cambio.
Preguntas frecuentes
¿Por qué mi scraper web devuelve un cuerpo vacío?
Porque la página renderiza su contenido con JavaScript después de que se carga el HTML, y tu cliente HTTP no ejecuta JavaScript. Recibes el esqueleto vacío que envía el servidor. Confirma comparando la respuesta en bruto (a través de cURL) con lo que muestra DevTools: si el navegador tiene datos que el HTML en bruto no tiene, es renderizado del lado del cliente.
¿Cómo scrapeo una página renderizada con JavaScript sin un navegador?
Dos maneras de evitar un navegador por completo. Encuentra el endpoint JSON interno que la página llama en la pestaña de red y solicítalo directamente, o extrae el estado de hidratación incrustado en el HTML (por ejemplo, la etiqueta de script __NEXT_DATA__). Ambos te dan datos estructurados más rápido y de manera más confiable que renderizar.
¿BeautifulSoup o Cheerio ejecutan JavaScript?
No. Ambos analizan la cadena de HTML que les das y ninguno ejecuta JavaScript. En una página de React, Vue o Next.js solo ven el esqueleto pre-renderizado, por eso tu selector devuelve un array vacío. Necesitas un DOM renderizado (Playwright/Puppeteer) o el JSON subyacente para obtener los datos.
¿Por qué recibo un mensaje de 'JavaScript está desactivado' al scrapear?
El sitio sirve un respaldo para clientes que no ejecutan JavaScript, y tu scraper parece uno. A menudo se combina con detección de bots. Ejecutar la página en un navegador sin cabeza en una IP residencial limpia lo soluciona; si se repite, el objetivo está identificando la solicitud, no solo verificando JavaScript.
Una página vacía es un diagnóstico, no un callejón sin salida. Confirma que es del lado del cliente, busca primero el API oculto o el JSON de hidratación, y renderiza solo cuando los datos no tienen otro lugar donde vivir. Escribirás menos código, usarás una fracción del ancho de banda y serás bloqueado con menos frecuencia. Si prefieres no mantener nada de eso, el Scraper API maneja el renderizado y la rotación por ti.