Saltar al contenido
Empezar gratis
Volver al blog

Adiós al web scraping en Node.js: load() para un scraper probado

Raluca PenciucÚltima actualización el 21 min read
Adiós al web scraping en Node.js: load() para un scraper probado
En resumen: Cheerio es un analizador HTML rápido para Node.js con una API al estilo de jQuery, así que sirve para web scraping cuando los datos ya están en el HTML sin procesar de la página. Usa fetch con Cheerio para páginas estáticas, llama directamente al JSON cuando la página carga así los datos y renderiza con un navegador solo cuando JavaScript construye el contenido. Esta guía crea un scraper probado con paginación, reintentos y exportación a JSON o CSV.

Al finalizar esta guía, tendrás un scraper de Cheerio que carga una página, extrae los campos de entrada, sigue la paginación, vuelve a intentarlo de forma educada cuando el servidor devuelve un error, guarda los resultados en formato JSON y CSV, y supera una prueba sin conexión. Los ejemplos en vivo utilizan books.toscrape.com y quotes.toscrape.com, dos sitios web de práctica creados para el scraping, por lo que puedes ejecutar el código tal cual. También aprenderás a detectar las páginas que Cheerio no puede leer y a solucionar los errores de cheerio.load que más problemas causan a los usuarios. ¿Eres nuevo en el scraping con Node? Empieza con nuestra guía sobre scraping web con JavaScript y Node.js.

¿Qué es Cheerio y cuándo deberías utilizarlo para el scraping web?

Cheerio es un rápido analizador sintáctico de HTML y XML para Node.js con una API al estilo de jQuery. Lee el código de marcado que ya tienes y te permite seleccionar elementos con selectores CSS. No ejecuta JavaScript, ni carga CSS, ni renderiza páginas, así que utilízalo cuando los datos que necesitas ya se encuentren en el HTML sin procesar de la página.

Piensa en Cheerio como lo contrario de un navegador. Le pasas una cadena de código de marcado y él construye un árbol de elementos que puedes consultar. No se ejecuta nada: ni maquetación, ni solicitudes de imágenes o hojas de estilo, ni scripts que añadan contenido posteriormente. Por eso Cheerio es rápido y ligero, y por eso a veces no encuentra nada en páginas que parecen estar llenas de datos en Chrome.

En segundo plano, el proyecto Cheerio en GitHub analiza el HTML con parse5 y puede cambiar al más tolerante htmlparser2. Por encima se encuentra la conocida API al estilo jQuery: $('selector'), .find(), .text(), y .attr(). Para saber cómo se compara con otras herramientas, consulta nuestro resumen de bibliotecas de web scraping para Node.js.

En primer lugar, decide con qué tipo de página estás trabajando:

Tipo de página

Qué utilizar

Por qué

Datos en HTML sin procesar

fetch + Cheerio

Es la opción más rápida y económica. No se ejecuta nada

Datos en JSON solicitados por la página

Llamar directamente al JSON

Sin análisis del código de marcado

Contenido renderizado o controlado por JavaScript

HTML renderizado (API de Scraper render_js o API del navegador), y luego Cheerio

Cheerio no puede ejecutar scripts

Una vez que sepas que Cheerio puede leer la página, extrae la información en este orden:

  • Busca primero datos estructurados, como bloques JSON-LD o un objeto de datos dentro de una etiqueta de script.
  • Recurre a los selectores CSS en el marcado visible cuando no existan datos estructurados.
  • Recurre a la representación solo cuando los datos no aparezcan en el HTML que envía el servidor.

Si estás sopesando entre un analizador sintáctico y un navegador completo, nuestra comparación entre Cheerio y Puppeteer te explica las ventajas e inconvenientes.

¿Cómo se configura un proyecto de Cheerio en Node.js?

Instala una versión LTS actual de Node.js, crea un proyecto con `npm init`, añade «type»: «module» a `package.json` y ejecuta `npm install cheerio`. Importa el módulo con `import * as cheerio from 'cheerio'`. La función `fetch` integrada en Node's descarga páginas, por lo que no necesitas Axios ni ningún otro cliente HTTP para empezar.

Utiliza Node.js 22 o una versión posterior; la versión LTS actual es la mejor opción. Comprueba tu versión y, a continuación, crea el proyecto:

node -v
mkdir cheerio-scraper && cd cheerio-scraper
npm init -y
npm pkg set type=module
npm install cheerio

La .mjs extensión indica a Node que trate cada archivo como un módulo ES. En un proyecto CommonJS, const cheerio = require('cheerio') también funciona. La guía de instalación de Cheerio describe los mismos pasos.

La función `fetch` integrada en Node se encarga de las descargas. Se basa en undici, no requiere ninguna importación y es compatible con AbortSignal.timeout().

¿Ya utilizas Axios? Sigue utilizándolo. Pasa response.data a cheerio.load() en lugar de await res.text(), y el resto de esta guía se aplica tal cual. A continuación te explicamos cómo configurar los encabezados en Axios para el scraping, o cómo comparar el método `fetch` nativo y sus alternativas.

Uso de Cheerio con TypeScript

Cheerio incluye sus propias definiciones de tipos, por lo que no hay que @types/cheerio paquete que instalar. Importa load y el CheerioAPI tipo, anota tus tipos de retorno y el compilador comprueba tu analizador como cualquier otro código:

// TypeScript: Cheerio ships its own types, so no @types package is needed.
import { load, type CheerioAPI } from 'cheerio';

interface Book {
  title: string;
  price: number;
}

export function parseBooks($: CheerioAPI): Book[] {
  return $('article.product_pod')
    .map((_, el) => ({
      title: $(el).find('h3 a').attr('title') ?? '',
      price: Number($(el).find('.price_color').text().replace(/[^0-9.]/g, '')),
    }))
    .get();
}

const $ = load('<article class="product_pod"><h3><a title="Sample">S</a></h3><p class="price_color">£9.99</p></article>');
console.log(parseBooks($));

Ejecútalo con npx tsx 09-typed.ts:

[ { title: 'Sample', price: 9.99 } ]

¿Y si lo compilas tsc ¿En su lugar? Añade @types/node como dependencia de desarrollo, ya que las definiciones de tipos de Cheerio hacen referencia a elementos integrados de Node como Buffer y node:stream. La lógica de análisis sintáctico es idéntica a la de la versión de JavaScript.

¿Cómo funciona cheerio.load()?

cheerio.load() analiza una cadena HTML y devuelve una función $. Llama a $ con un selector CSS para obtener los elementos coincidentes y, a continuación, léelos con .text() o .attr(). Utiliza .find() para buscar dentro de un elemento, .map() con .get() para crear matrices y .next() o .parent() para desplazarte por el árbol del documento.

Empieza con una pequeña página estática antes de conectarte a la red. En ella se tratan todos los métodos utilizados en el resto de la guía:

// Cheerio basics: load HTML, select elements, read text and attributes.
import * as cheerio from 'cheerio';

const html = `
<ul id="products">
  <li class="product" data-sku="A1"><a href="/p/a1">Desk lamp</a><span class="price">$24.00</span></li>
  <li class="product" data-sku="B2"><a href="/p/b2">Monitor arm</a><span class="price">$61.50</span></li>
  <li class="product sold-out" data-sku="C3"><a href="/p/c3">Cable tray</a><span class="price">$12.99</span></li>
</ul>`;

const $ = cheerio.load(html);

// One element: .text() and .attr()
console.log('First product:', $('li.product a').first().text());
console.log('First link:', $('li.product a').first().attr('href'));

// Many elements: .map() returns a Cheerio object, .get() turns it into an array
const products = $('li.product')
  .map((_, el) => {
    const item = $(el);
    return {
      sku: item.attr('data-sku'),
      name: item.find('a').text().trim(),
      price: Number(item.find('.price').text().replace(/[^0-9.]/g, '')),
      inStock: !item.hasClass('sold-out'),
    };
  })
  .get();
console.log(products);

// Traversal: move from one element to its neighbors
console.log('After A1:', $('li[data-sku="A1"]').next().find('a').text());
console.log('Parent id:', $('.price').first().parent().parent().attr('id'));

El resultado de node 01-basics.mjs:

First product: Desk lamp
First link: /p/a1
[
  { sku: 'A1', name: 'Desk lamp', price: 24, inStock: true },
  { sku: 'B2', name: 'Monitor arm', price: 61.5, inStock: true },
  { sku: 'C3', name: 'Cable tray', price: 12.99, inStock: false }
]
After A1: Monitor arm
Parent id: products

Selección de elementos

La cadena que pasas a $() es un selector CSS estándar, por lo que los nombres de etiquetas, .class, #id, los selectores de atributos como [data-sku="A1"], y combinadores como ul > li funcionan todos. La guía de MDN sobre selectores CSS es la mejor referencia. Cheerio añade algunas extensiones al estilo de jQuery, como :contains() y :first, pero no admite XPath; a continuación se indica cuándo es adecuado cada lenguaje de selectores.

Lectura de texto y atributos

.text() devuelve el texto combinado de todos los elementos coincidentes y sus descendientes. .attr('name') devuelve un atributo del primer elemento coincidente, tal y como aparece escrito en el código de marcado. .hasClass() comprueba si existe una clase, lo cual resulta útil para indicadores como «agotado».

.map() es el paso que más confunde a los principiantes. En Cheerio devuelve otro objeto Cheerio en lugar de una matriz simple, por lo que console.log() imprime un contenedor lleno de datos internos. Añade .get() al final para obtener el array de objetos simples que esperabas.

Cuando el elemento que buscas no tenga un selector útil propio, empieza por uno que sí lo tenga y recorre el árbol hasta llegar a él. .find() busca descendientes, .parent() sube un nivel, .next() se desplaza al hermano siguiente y .first() conserva solo la primera coincidencia. Las dos últimas líneas del ejemplo pasan de un producto al siguiente y de un precio a su lista.

¿Cómo se extrae información de una página web real con Cheerio?

Descarga la página con `fetch`, comprueba el estado de la respuesta y pasa el texto HTML a `cheerio.load()`. A continuación, recorre cada tarjeta repetida con `.map()`, lee todos los campos que necesites, convierte los precios a números y resuelve los enlaces relativos con `new URL()` para que el resultado sean datos limpios y tipificados, en lugar de cadenas sin procesar.

«Books to Scrape» muestra 20 libros por página, cada uno dentro de una article.product_pod tarjeta. Este script recupera la primera página y convierte cada tarjeta en un registro tipado:

// Fetch one page with Node's built-in fetch and parse it with Cheerio.
import * as cheerio from 'cheerio';

const START_URL = 'https://books.toscrape.com/';
const RATINGS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };

async function getHtml(url) {
  const res = await fetch(url, {
    headers: { 'User-Agent': 'cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)' },
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  return res.text();
}

const html = await getHtml(START_URL);
const $ = cheerio.load(html);

const books = $('article.product_pod')
  .map((_, el) => {
    const card = $(el);
    const link = card.find('h3 a');
    const ratingWord = card.find('p.star-rating').attr('class').replace('star-rating', '').trim();
    return {
      title: link.attr('title'),
      price: Number(card.find('.price_color').text().replace(/[^0-9.]/g, '')),
      rating: RATINGS[ratingWord] ?? null,
      inStock: card.find('.availability').text().includes('In stock'),
      url: new URL(link.attr('href'), START_URL).href,
    };
  })
  .get();

console.log(`Found ${books.length} books`);
console.log(books.slice(0, 3));

Aquí están los tres primeros registros de node 02-books-page.mjs:

Found 20 books
[
  {
    title: 'A Light in the Attic',
    price: 51.77,
    rating: 3,
    inStock: true,
    url: 'https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html'
  },
  {
    title: 'Tipping the Velvet',
    price: 53.74,
    rating: 1,
    inStock: true,
    url: 'https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html'
  },
  {
    title: 'Soumission',
    price: 50.1,
    rating: 1,
    inStock: true,
    url: 'https://books.toscrape.com/catalogue/soumission_998/index.html'
  }
]

Cuatro pequeños trucos convierten las cadenas sin formato en datos utilizables:

  • Precio: la página muestra «£51,77». Elimina todo excepto los dígitos y el punto decimal, llama a Number(), y obtendrás 51,77, que podrás ordenar y sumar.
  • Valoración: las estrellas son puro CSS. La única señal en el HTML es una clase como star-rating Three, por lo que el script elimina star-rating y asigna un número a la palabra restante.
  • Stock: «En stock» se convierte en un valor booleano, lo cual es más fácil de filtrar que el texto libre.
  • URL: los href valores son relativos, como catalogue/a-light-in-the-attic_1000/index.html. El constructor de URL las resuelve en función de la dirección de la página para convertirlas en enlaces que funcionan en cualquier sitio.

Elige los selectores en «Ver código fuente», no en el panel «Elementos» de DevTools. DevTools muestra el DOM después de que se hayan ejecutado los scripts, mientras que Cheerio solo ve el código fuente sin procesar que envió el servidor. Un selector que funcione en DevTools pero no en «Ver código fuente» no coincidirá con nada en tu script.

La llamada fetch también establece un tiempo de espera, de modo que una respuesta bloqueada no pueda paralizar la ejecución, y un User-Agent honesto que identifique tu herramienta. Sin él, Node se identifica a sí mismo simplemente como «node», tal y como muestra httpbin:

// See which User-Agent your scraper sends, then set an honest one.
const plain = await fetch('https://httpbin.org/user-agent').then((r) => r.json());
console.log('default:', plain['user-agent']);

const named = await fetch('https://httpbin.org/user-agent', {
  headers: { 'User-Agent': 'cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)' },
}).then((r) => r.json());
console.log('custom:', named['user-agent']);
default: node
custom: cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)

¿Quieres extraer filas y columnas en lugar de tarjetas? Sigue nuestro tutorial sobre extracción de tablas HTML en JavaScript.

¿Cómo se extraen varias páginas con Cheerio?

Extrae la primera página, lee el href de su enlace «siguiente», resuélvelo con respecto a la URL actual y repite el proceso hasta que no quede ningún enlace siguiente o alcances el límite de páginas. Espera entre solicitudes, vuelve a intentarlo solo ante respuestas 429 y 5xx con retrasos cada vez mayores, y guarda los resultados en JSON o CSV al final.

Este rastreador se basa en el script anterior. Sigue el botón «Siguiente» a lo largo del catálogo, reintenta los fallos temporales y guarda los resultados en dos archivos:

// Follow "next" links, retry politely, and save results to JSON and CSV.
import * as cheerio from 'cheerio';
import { writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';

const START_URL = 'https://books.toscrape.com/catalogue/page-1.html';
const MAX_PAGES = Number(process.argv[2] ?? 3);
const DELAY_MS = 1_000;
const RATINGS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };

async function getHtml(url, attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    let waitMs = DELAY_MS * 2 ** attempt; // 2 s, then 4 s
    try {
      const res = await fetch(url, {
        headers: { 'User-Agent': 'cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)' },
        signal: AbortSignal.timeout(15_000),
      });
      if (res.ok) return res.text();
      // Retry only on rate limits and server errors
      if (res.status !== 429 && res.status < 500) throw new Error(`HTTP ${res.status} for ${url}`);
      // Honor Retry-After when the server sends it in seconds
      const retryAfter = Number(res.headers.get('retry-after'));
      if (retryAfter > 0) waitMs = retryAfter * 1_000;
      console.warn(`HTTP ${res.status} on attempt ${attempt} for ${url}`);
    } catch (err) {
      if (attempt === attempts || err.message.startsWith('HTTP 4')) throw err;
      console.warn(`${err.name} on attempt ${attempt} for ${url}`);
    }
    if (attempt < attempts) await sleep(waitMs);
  }
  throw new Error(`Gave up on ${url}`);
}

function parseBooks($, pageUrl) {
  return $('article.product_pod')
    .map((_, el) => {
      const card = $(el);
      const link = card.find('h3 a');
      const ratingWord = card.find('p.star-rating').attr('class').replace('star-rating', '').trim();
      return {
        title: link.attr('title'),
        price: Number(card.find('.price_color').text().replace(/[^0-9.]/g, '')),
        rating: RATINGS[ratingWord] ?? null,
        inStock: card.find('.availability').text().includes('In stock'),
        url: new URL(link.attr('href'), pageUrl).href,
      };
    })
    .get();
}

function toCsv(rows) {
  const cols = Object.keys(rows[0]);
  const escape = (v) => {
    const s = String(v ?? '');
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  return [cols.join(','), ...rows.map((r) => cols.map((c) => escape(r[c])).join(','))].join('\n');
}

const books = [];
let url = START_URL;
for (let page = 1; url && page <= MAX_PAGES; page++) {
  const $ = cheerio.load(await getHtml(url));
  const found = parseBooks($, url);
  books.push(...found);
  console.log(`Page ${page}: ${found.length} books`);

  const next = $('li.next a').attr('href');
  url = next ? new URL(next, url).href : null;
  if (url) await sleep(DELAY_MS);
}

await writeFile('out/books.json', JSON.stringify(books, null, 2));
await writeFile('out/books.csv', toCsv(books));
console.log(`Saved ${books.length} books to out/books.json and out/books.csv`);

Crea la carpeta de salida y, a continuación, ejecútalo con un límite de tres páginas:

mkdir out
node 03-crawl-books.mjs 3
Page 1: 20 books
Page 2: 20 books
Page 3: 20 books
Saved 60 books to out/books.json and out/books.csv

Las primeras líneas de out/books.csv:

title,price,rating,inStock,url
A Light in the Attic,51.77,3,true,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html

Tres medidas de seguridad garantizan que el rastreador funcione de forma correcta y predecible:

  • Un límite de páginas. El primer argumento de la línea de comandos establece MAX_PAGES. El bucle también se detiene cuando una página no tiene enlace «Siguiente».
  • Un retraso fijo. El script espera un segundo entre páginas.
  • Una política de reintentos restrictiva. Solo se reintentan los errores HTTP 429 (Demasiadas solicitudes) y los errores de servidor 5xx, tras 2 segundos y luego 4 segundos, o tras el Retry-After valor que el servidor envíe en segundos. Cualquier otro error 4xx, como un 404, se da por fallido de inmediato, ya que volver a intentarlo no cambiará la respuesta.

El generador de CSV pone entre comillas cualquier campo que contenga una coma, una comilla doble o un salto de línea, y duplica las comillas que haya en su interior. Muchos títulos de libros contienen comas, y una sola coma sin comillas desplazaría todas las columnas posteriores.

Los retrasos y los reintentos son lo mínimo. Nuestra guía de buenas prácticas de web scraping abarca los límites de frecuencia, la programación y la supervisión.

¿Cómo se utilizan las funciones fromURL() y extract() de Cheerio?

fromURL() descarga una página y la carga en una sola llamada con espera. A continuación, extract() asigna un objeto simple de selectores a valores: una cadena devuelve el texto de la primera coincidencia, un array devuelve todas las coincidencias, y un objeto o función anidados crean registros estructurados. Juntos sustituyen a la mayoría de los bucles .map() escritos a mano para páginas sencillas.

Aquí tienes la misma idea aplicada a «Citas para extraer», sin llamada fetch ni bucle manual:

// Cheerio's fromURL() and extract(): fetch and map fields in one step.
import { fromURL } from 'cheerio';

const PAGE_URL = 'https://quotes.toscrape.com/';
const $ = await fromURL(PAGE_URL);

const data = $.extract({
  pageTitle: 'title',
  quotes: [
    {
      selector: 'div.quote',
      value: {
        text: 'span.text',
        author: 'small.author',
        authorUrl: {
          selector: 'a[href^="/author/"]',
          value: (el) => new URL($(el).attr('href'), PAGE_URL).href, // resolve relative links
        },
        tags: { selector: '.tags', value: (el) => $(el).find('a.tag').map((_, a) => $(a).text()).get() },
      },
    },
  ],
});

console.log(data.pageTitle.trim());
console.log(`Quotes on page: ${data.quotes.length}`);
console.log(data.quotes[0]);
Quotes to Scrape
Quotes on page: 10
{
  text: '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”',
  author: 'Albert Einstein',
  authorUrl: 'https://quotes.toscrape.com/author/Albert-Einstein',
  tags: [ 'change', 'deep-thoughts', 'thinking', 'world' ]
}

Cada clave describe un campo de salida. pageTitle: 'title' lee el texto del primer <title> elemento. Al envolver un descriptor en un array, tal y como quotes se hace aquí, devuelve un resultado por cada coincidencia, y la value propiedad puede ser un selector, un objeto anidado o una función que reciba el elemento. La referencia de extract() enumera todas las opciones.

Resuelve los enlaces tú mismo. Dependiendo de tu versión de Cheerio, href los valores leídos a través de extract() o .prop('href') pueden seguir apareciendo como relativos después de fromURL(), por lo que el fragmento de código los procesa uno a uno new URL(). Esa pequeña precaución garantiza que el resultado sea correcto en cualquier caso.

La documentación de Cheerio sobre cómo carga los documentos añade dos detalles más. fromURL() Rechaza las respuestas que no sean 2xx con un error, en lugar de mostrarte una página de error para que la analices. Y si pasas requestOptions para establecer encabezados, tienes que configurar method tú mismo, ya que no se rellenan automáticamente.

Utiliza fetch además de load() en su lugar cuando necesites reintentos personalizados, retrasos o una capa de proxy, como en el rastreador anterior.

¿Cómo se obtienen datos de JSON-LD y de las etiquetas de script?

Antes de escribir selectores, busca en el HTML bloques de tipo «script type="application/ld+json"» y scripts en línea que contengan datos. Selecciónalos con Cheerio, lee su texto y utilice JSON.parse dentro de un try/catch. Los datos estructurados suelen sobrevivir mejor a los rediseños que el marcado visible y, a menudo, incluyen precios, SKU y disponibilidad bien definidos.

Muchas páginas de tiendas y de artículos incorporan datos de schema.org para los motores de búsqueda y, cuando están presentes, suelen ser la fuente más clara de la página:

// Read structured data (JSON-LD) before writing selectors for visible text.
import { load } from 'cheerio';

const html = `<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Product","name":"Desk lamp","sku":"A1",
 "offers":{"@type":"Offer","price":"24.00","priceCurrency":"USD","availability":"https://schema.org/InStock"}}
</script></head><body><h1>Desk lamp</h1></body></html>`;

const $ = load(html);
const items = $('script[type="application/ld+json"]')
  .map((_, el) => {
    try {
      return JSON.parse($(el).text());
    } catch {
      return null; // skip malformed blocks instead of crashing
    }
  })
  .get()
  .filter(Boolean);

const product = items.find((item) => item['@type'] === 'Product');
console.log({ name: product.name, price: Number(product.offers.price), currency: product.offers.priceCurrency });
{ name: 'Desk lamp', price: 24, currency: 'USD' }

El try/catch evita que un solo bloque malformado provoque un error en la ejecución. Filtra por el @type que necesites, como «Producto» o «Artículo», ya que una misma página suele contener varios bloques.

Algunos sitios envían todo su conjunto de datos en un script en línea y construyen el marcado visible con JavaScript. Ese caso parece fallar a primera vista, por lo que tiene su propia sección.

¿Por qué Cheerio no devuelve ningún resultado en páginas renderizadas con JavaScript?

Cheerio solo ve el HTML que envía el servidor. Si una página genera su contenido con JavaScript en el navegador, tus selectores no encontrarán nada porque los elementos aún no existen. Comprueba primero el HTML sin procesar. A continuación, busca los datos en los scripts o en un punto final JSON, o bien recupera el HTML renderizado y analízalo con Cheerio.

Quotes to Scrape tiene una versión en JavaScript en /js/ que tiene un aspecto idéntico en el navegador. Este script muestra lo que Cheerio recibe realmente y dónde se ocultan los datos:

// Spot a JavaScript-rendered page, then look for the data inside a <script> tag.
import { fromURL } from 'cheerio';

const $ = await fromURL('https://quotes.toscrape.com/js/');

// 1. The selector that works on the static version finds nothing here
console.log('div.quote elements in raw HTML:', $('div.quote').length);

// 2. The quotes still ship in the HTML, inside an inline script
const script = $('script')
  .map((_, el) => $(el).text())
  .get()
  .find((code) => code.includes('var data ='));

// Capture the array literal between "var data =" and the first "];"
const json = script.match(/var data = (\[[\s\S]*?\]);/)[1];
const quotes = JSON.parse(json);

console.log(`Quotes found in <script>: ${quotes.length}`);
console.log({ author: quotes[0].author.name, tags: quotes[0].tags });
div.quote elements in raw HTML: 0
Quotes found in <script>: 10
{
  author: 'Albert Einstein',
  tags: [ 'change', 'deep-thoughts', 'thinking', 'world' ]
}

El selector que encontró 10 citas en la página estática no encuentra ninguna aquí, porque los div.quote elementos se crean en el navegador. Las citas siguen estando en el HTML, dentro de una var data = [...] asignación, por lo que una expresión regular y JSON.parse() recuperan las 10. Eso es, en la práctica, la diferencia entre el renderizado del lado del servidor y el del lado del cliente.

Cuando los datos no estén en un script, prueba estas opciones por orden:

  1. Busca la solicitud JSON. En DevTools, abre la pestaña «Red», filtra por «Fetch/XHR» y actualiza la página. Muchas páginas cargan su contenido desde un punto final JSON. Si es público y tienes permiso para utilizarlo, llámalo directamente y omite el análisis del HTML.
  2. Primero renderiza, luego analiza. Renderizar significa ejecutar la página en un motor real, normalmente un navegador sin interfaz gráfica, antes de analizarla. Tu código de Cheerio no cambia; solo cambia el código fuente del HTML.

Puedes ejecutar un navegador tú mismo o dejar que una API lo haga por ti. La API de Scraper puede renderizar la página y devolver el HTML final, que pasas directamente a cheerio.load(). Añade render_js=1 a la solicitud, y la respuesta será el HTML tras la ejecución de los scripts. La representación de JavaScript y la geolocalización no suponen ningún coste adicional, y las solicitudes fallidas no se facturan.

// When a page needs a browser, fetch rendered HTML from Scraper API and keep parsing with Cheerio.
import { load } from 'cheerio';

const apiKey = process.env.WSA_API_KEY;
if (!apiKey) {
  console.error('Set WSA_API_KEY to run this example.');
  process.exit(1);
}

const params = new URLSearchParams({
  api_key: apiKey,
  url: 'https://quotes.toscrape.com/js/',
  render_js: '1',
});

const res = await fetch(`https://api.webscrapingapi.com/v2?${params}`, {
  signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`Scraper API returned HTTP ${res.status}`);

const $ = load(await res.text());
console.log('div.quote elements after rendering:', $('div.quote').length);
console.log('First author:', $('div.quote small.author').first().text());

Ejecútalo con tu clave en una variable de entorno, para que nunca aparezca en tu código:

WSA_API_KEY=your_key node 08-render-handoff.mjs

Cuando los datos solo aparecen tras hacer clic, desplazarse o esperar, la API del navegador ejecuta esos pasos por ti. Describes las esperas y las acciones, como hacer clic, desplazarse, escribir y enviar, en una solicitud REST; el navegador se ejecuta en nuestro servidor y el código HTML que devuelve se integra en cheerio.load() como cualquier otra página.

¿Por qué no funciona cheerio.load? Errores comunes y soluciones

La mayoría de los problemas con cheerio.load se deben a unas pocas causas: una importación predeterminada que Cheerio 1.x ya no proporciona, selectores escritos en función del DOM renderizado por el navegador, fragmentos envueltos en etiquetas html y body, XML analizado como HTML, y enlaces relativos o espacios en blanco desordenados. Cada solución que se indica a continuación es un cambio de una sola línea que puedes probar en segundos.

Este script muestra cinco de las siete soluciones que se indican a continuación, antes y después:

// Fixes for the errors people hit most often with cheerio.load().
import * as cheerio from 'cheerio';
import { load } from 'cheerio';

// Fix 1: there is no default export in Cheerio 1.x.
// `import cheerio from 'cheerio'` fails; use a namespace or named import.
console.log('namespace load is a function:', typeof cheerio.load === 'function');
console.log('named load is a function:', typeof load === 'function');

// Fix 2: load() wraps fragments in <html><head><body> by default.
const fragment = '<li>Apples</li><li>Pears</li>';
console.log('document mode:', load(fragment).html());
console.log('fragment mode:', load(fragment, null, false).html());

// Fix 3: parse XML (RSS, sitemaps) with the xml option so tags like <link> keep their text.
const rss = `<rss><channel><item><title>Post</title><link>https://example.com/post</link></item></channel></rss>`;
console.log('HTML mode <link>:', JSON.stringify(load(rss)('item link').text()));
console.log('XML mode <link>:', JSON.stringify(load(rss, { xml: true })('item link').text()));

// Fix 4: .text() keeps whitespace and joins nested text. Normalize it yourself.
const $ = load('<p class="desc">\n  Fast,\n  <b>small</b>   and   tested.\n</p>');
console.log('raw text:', JSON.stringify($('.desc').text()));
console.log('clean text:', JSON.stringify($('.desc').text().replace(/\s+/g, ' ').trim()));

// Fix 5: .attr('href') returns the value as written. Resolve it against the page URL.
const page = 'https://books.toscrape.com/catalogue/page-2.html';
console.log('absolute URL:', new URL('../index.html', page).href);
namespace load is a function: true
named load is a function: true
document mode: <html><head></head><body><li>Apples</li><li>Pears</li></body></html>
fragment mode: <li>Apples</li><li>Pears</li>
HTML mode <link>: ""
XML mode <link>: "https://example.com/post"
raw text: "\n  Fast,\n  small   and   tested.\n"
clean text: "Fast, small and tested."
absolute URL: https://books.toscrape.com/index.html

«cheerio.load no es una función» o «cheerio.default no está definido»

Síntoma: tu script falla en su primera llamada a Cheerio, o cheerio.default devuelve «undefined». En los módulos ES nativos, import cheerio from 'cheerio' falla incluso antes con el mensaje «no proporciona una exportación llamada 'default'».

Causa: Cheerio 1.x no tiene una exportación por defecto. Los tutoriales más antiguos y el código anterior a la versión 1.0 utilizaban una.

Solución: utiliza una importación de espacio de nombres, import * as cheerio from 'cheerio'o una importación con nombre, import { load } from 'cheerio'. En CommonJS, const cheerio = require('cheerio') funciona.

Tus selectores no devuelven nada

Síntoma: $('...').length es 0, y .text() devuelve una cadena vacía.

Causa: una de estas tres cosas. Has copiado el selector de DevTools, que muestra el DOM después de que se hayan ejecutado los scripts; el contenido se renderiza mediante JavaScript, tal y como se explica en la sección sobre páginas renderizadas con JavaScript; o el servidor ha devuelto una página bloqueada, una pantalla de inicio de sesión o un error.

Solución: registra res.status y los primeros 200 caracteres del HTML con console.log(res.status, html.slice(0, 200)). A continuación, compara tu selector con el código fuente, no con el panel «Elementos».

Etiquetas html, head y body adicionales en la salida

Síntoma: cargas un fragmento de código como <li>Apples</li>, y .html() te lo devuelve envuelto en <html><head></head><body>.

Causa: por defecto, load() trata la entrada como un documento completo y añade las etiquetas de contenedor que faltan, tal y como lo haría un navegador.

Solución: carga los fragmentos en modo fragmento con load(html, null, false). El tercer argumento desactiva el modo de documento.

Los feeds XML y RSS pierden datos

Síntoma: al analizar una fuente RSS o un mapa del sitio, campos como <link> aparecen vacíos.

Causa: en modo HTML, <link> es un elemento vacío y no puede contener texto, por lo que la URL que hay en su interior se omite. En la salida anterior, el modo HTML devolvió "" y el modo XML devolvió la URL.

Solución: utiliza load(xml, { xml: true }). El modo XML también respeta las mayúsculas y minúsculas de los nombres de las etiquetas y no añade etiquetas envolventes html ni body.

El texto se muestra con espacios en blanco adicionales

Síntoma: .text() devuelve cadenas llenas de saltos de línea y secuencias de espacios.

Causa: .text() conserva los espacios en blanco del código fuente, incluida la sangría, y une elementos anidados como <b> sin separadores.

Solución: normalízalo con .text().replace(/\s+/g, ' ').trim(). Para una página completa, selecciona primero el elemento de contenido principal para excluir el texto de navegación y del pie de página.

Errores 403 o 429

Síntoma: la solicitud falla con el código HTTP 403 Prohibido o 429 Demasiadas solicitudes, y el analizador nunca se ejecuta.

Causa: estos errores provienen del servidor, no de Cheerio. Cheerio solo ve el HTML una vez que la solicitud se ha completado con éxito.

Solución: configura un User-Agent válido, reduce la velocidad, respeta Retry-Aftery respeta el archivo robots.txt y las condiciones de uso del sitio. Si un sitio sigue bloqueando las solicitudes simples en páginas que tienes permiso para recopilar, redirige la recuperación a través de la API de Scraper y mantén tu analizador.

Los enlaces se devuelven de forma relativa

Síntoma: .attr('href') devuelve rutas como ../index.html o /author/Albert-Einstein que no funcionan fuera de la página.

Causa: .attr() devuelve el valor exactamente tal y como está escrito en el código de marcado.

Solución: resuelve cada enlace con new URL(href, pageUrl).href, utilizando la dirección de la página que has obtenido como base.

¿Cómo se evita que un scraper de Cheerio deje de funcionar?

Mantén el análisis separado de la obtención de datos, guarda una página real como fixture y prueba el analizador sin conexión con `node:test`. Da preferencia a los hooks estables, como los ID, los atributos de datos y `itemprop`, en lugar de las clases de estilo, y genera un error cuando una página no devuelva ningún registro, de modo que un rediseño falle de forma evidente en lugar de guardar archivos vacíos.

Los rastreadores rara vez fallan con un error. Normalmente, el diseño cambia, todos los selectores dejan de coincidir con nada sin que se note, y te encuentras con una semana de archivos vacíos. Haz del analizador una función pura que puedas probar y haz que avise cuando no encuentre nada.

Guarda una copia de una página real como fixture:

mkdir fixtures
curl -s https://books.toscrape.com/catalogue/page-1.html -o fixtures/books-page-1.html

Traslada el análisis a su propio módulo. Acepta HTML y una URL, y no le importa de dónde provenga el HTML:

// parse-books.mjs: keep parsing separate from fetching so it can be tested offline.
import { load } from 'cheerio';

const RATINGS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };

export function parseBooks(html, pageUrl) {
  const $ = load(html);
  const books = $('article.product_pod')
    .map((_, el) => {
      const card = $(el);
      const link = card.find('h3 a');
      const ratingWord = card.find('p.star-rating').attr('class').replace('star-rating', '').trim();
      return {
        title: link.attr('title'),
        price: Number(card.find('.price_color').text().replace(/[^0-9.]/g, '')),
        rating: RATINGS[ratingWord] ?? null,
        inStock: card.find('.availability').text().includes('In stock'),
        url: new URL(link.attr('href'), pageUrl).href,
      };
    })
    .get();

  // Fail loudly when the layout changes instead of saving an empty file
  if (books.length === 0) throw new Error(`No books found on ${pageUrl}; check the selectors`);
  return books;
}

A continuación, pruébalo con el ejecutor de pruebas integrado de Node, sin dependencias adicionales:

// Run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { parseBooks } from './parse-books.mjs';

const PAGE_URL = 'https://books.toscrape.com/catalogue/page-1.html';

test('parses every book card on a saved page', async () => {
  const html = await readFile(new URL('./fixtures/books-page-1.html', import.meta.url), 'utf8');
  const books = parseBooks(html, PAGE_URL);

  assert.equal(books.length, 20);
  for (const book of books) {
    assert.ok(book.title.length > 0);
    assert.ok(book.price > 0);
    assert.ok(book.rating >= 1 && book.rating <= 5);
    assert.match(book.url, /^https:\/\/books\.toscrape\.com\/catalogue\//);
  }
});

test('throws when the layout changes', () => {
  assert.throws(() => parseBooks('<html><body><p>Redesigned</p></body></html>', PAGE_URL), /No books found/);
});

Ejecuta node --test, y el resumen termina con:

# tests 2
# pass 2
# fail 0

La primera prueba comprueba que el analizador lee las 20 tarjetas con valores válidos; la segunda, que una página rediseñada lanza un error en lugar de devolver un array vacío. Como el fixture se guarda en el disco, las pruebas se ejecutan sin conexión. Cuando cambie el sitio web, guarda un nuevo fixture y actualiza los selectores hasta que las pruebas pasen.

Hay algunos hábitos que hacen que los selectores duren más:

  • Prefiere los identificadores (ids) y data-* atributos y itemprop atributos. Estos tienen significado y cambian con menos frecuencia que las clases de estilo.
  • Evita las clases de utilidad y los nombres de clases generados, que pueden cambiar con cada implementación.
  • Basarse en la estructura en lugar de en la posición. «El precio dentro de cada ficha de producto» sobrevive a un nuevo banner; «el tercer div», no.

Si el mantenimiento de la capa de obtención de datos cuesta más que el análisis, compara las API de web scraping por tarea y coste.

Preguntas frecuentes

¿Es Cheerio más rápido que Puppeteer?

Normalmente, en páginas estáticas. Cheerio solo analiza texto, mientras que Puppeteer inicia un navegador completo, carga recursos y ejecuta scripts, por lo que Cheerio realiza mucho menos trabajo por página. Mide en tu propio entorno en lugar de fiarte de pruebas de rendimiento genéricas. Si los datos necesitan JavaScript para aparecer, Cheerio por sí solo no puede acceder a ellos a ninguna velocidad.

¿Puede Cheerio ejecutar JavaScript o extraer datos de páginas dinámicas?

No. Cheerio analiza la cadena HTML que le proporcionas y nunca ejecuta scripts. Para páginas dinámicas, busca primero los datos en scripts en línea o en las solicitudes JSON de la página. Si ninguna de estas opciones funciona, obtén el HTML renderizado desde un navegador sin interfaz gráfica o una API de renderizado y pásalo a cheerio.load().

¿Necesito Axios para usar Cheerio?

No. Las versiones actuales de Node.js incluyen una función global `fetch`, por lo que puedes llamar a `await fetch(url)` y pasar `await res.text()` a `cheerio.load()`. La función `fromURL()` de Cheerio también puede realizar la recuperación por ti. Si tu proyecto ya utiliza Axios, eso también funciona: pasa `response.data` a `cheerio.load()` de la misma manera.

¿Cheerio es compatible con XPath?

No. Cheerio selecciona elementos mediante selectores CSS, además de algunas extensiones al estilo de jQuery, como :contains() y :first, y no cuenta con un motor de XPath. La mayoría de las expresiones XPath se traducen a un selector CSS más métodos de recorrido como .parent(), .next() o .filter(). Si necesitas utilizar XPath, utiliza un analizador que lo admita.

¿Funciona Cheerio con TypeScript?

Sí. Cheerio incluye sus propias definiciones de tipos, por lo que no es necesario instalar ningún paquete @types adicional. Importa load y el tipo CheerioAPI, anota el tipo de retorno de tu analizador y, a continuación, ejecuta el archivo con tsx, o añade @types/node y compila con tsc. La lógica de análisis sigue siendo idéntica a la de la versión de JavaScript.

¿Por qué cheerio.load devuelve un resultado vacío?

Normalmente, los elementos no se encuentran en el HTML sin procesar. Es posible que hayas copiado selectores de DevTools, que muestra la página tras la ejecución de los scripts, o que el servidor haya devuelto una página en blanco o un error. Registra el código de estado y las primeras líneas del HTML y, a continuación, compara tu selector con el código fuente.

¿Cómo obtengo texto sin formato a partir de HTML con Cheerio?

Llama a .text() sobre la selección y, a continuación, normaliza los espacios en blanco con .replace(/\s+/g, ' ').trim(). El método .text() une el texto de todos los elementos anidados, conservando los saltos de línea y la sangría del código fuente. Para una página completa, selecciona primero el elemento de contenido principal para excluir del resultado el texto de navegación y del pie de página.

¿Puede Cheerio analizar XML y fuentes RSS?

Sí. Pasa { xml: true } como opciones: cheerio.load(xml, { xml: true }). En el modo HTML predeterminado, las etiquetas como «link» se tratan como elementos HTML vacíos y pierden su texto, lo que impide el análisis de RSS. El modo XML también mantiene las mayúsculas y minúsculas de los nombres de las etiquetas y omite los contenedores «html», «head» y «body».

¿Cómo evito que me bloqueen al extraer datos con Cheerio?

El bloqueo se produce en la solicitud, no en Cheerio. Envía un User-Agent válido, añade un retraso entre solicitudes, reintenta las respuestas 429 y 5xx con un tiempo de espera, respeta el Retry-After y sigue el archivo robots.txt y las condiciones de uso del sitio. Si las páginas que tienes permiso para recopilar siguen devolviendo errores 403, cambia la capa de obtención de datos y mantén tu analizador.

Cheerio es solo un analizador, por lo que la legalidad depende de lo que recopiles y de cómo lo hagas. Los datos públicos recopilados de forma respetuosa y dentro de los términos del sitio suelen suponer un menor riesgo. Los datos personales, el contenido protegido por inicio de sesión y el material protegido por derechos de autor conllevan obligaciones adicionales. Las normas varían según el país, así que busca asesoramiento legal para proyectos comerciales. Esto no constituye asesoramiento legal.

¿Cuándo deberías delegar la obtención de datos a una API?

Utiliza Cheerio para lo que mejor sabe hacer: convertir HTML en datos limpios. Cuando las páginas requieran JavaScript o sigan devolviendo errores 403, deja que otra herramienta se encargue de recuperarlas. Los planes de la API de Scraper comienzan con una prueba gratuita de 5.000 solicitudes. La renderización con JavaScript y la geolocalización no suponen ningún coste adicional, y las solicitudes fallidas no se facturan. Para flujos que requieran clics, desplazamiento o tiempos de espera, solicita un presupuesto de la API de navegador adaptado a tu volumen de trabajo. Tu analizador seguirá siendo el mismo en ambos casos.

Acerca del autor

Raluca Penciuc, Desarrollador full-stack @ WebScrapingAPI

Raluca Penciuc

Desarrollador full-stack

Raluca Penciuc es desarrolladora full stack en WebScrapingAPI, donde se dedica a crear rastreadores, mejorar las técnicas de evasión y buscar formas fiables de reducir la detección en los sitios web de destino.

Extracción de datos web con AWS Lambda: guía para Python y Java 2026
Guías

Extracción de datos web con AWS Lambda: guía para Python y Java 2026

TL;DR: En resumen: el web scraping con AWS Lambda funciona mejor cuando cada invocación es breve, está delimitada y se puede reintentar de forma independiente. Empieza con HTTP directo, AWS SAM y S3, y añade SQS, contenedores, renderización en navegador, proxies o una capa de recuperación gestionada solo cuando la carga de trabajo demuestre que los necesita.

Suciu Dan33 min read
Leer artículo
Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos
Guías

Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos

TL;DR: En resumen: GoSpider es un rastreador de línea de comandos diseñado para descubrir URL, no un extractor completo de datos estructurados. Esta guía sobre cómo utilizar GoSpider muestra cómo realizar un rastreo limitado, gestionar correctamente los resultados, pasar los datos de Colly a CSV y seguir una ruta de diagnóstico para respuestas 403 o páginas que requieren renderización con JavaScript.

Suciu Dan24 min read
Leer artículo
Cómo raspar Redfin: Guía Python de Datos Inmobiliarios
Guías

Cómo raspar Redfin: Guía Python de Datos Inmobiliarios

TL;DR: Redfin expone puntos finales de API ocultos que devuelven JSON estructurado para los listados de propiedades, lo que permite omitir por completo el frágil análisis HTML. Esta guía te guía a través de la construcción de un raspador de Python que extrae datos de alquiler y venta, busca por ubicación, supervisa los nuevos listados a través de mapas de sitio XML y exporta resultados limpios a CSV o JSON.

Suciu Dan14 min read
Leer artículo

Empieza a crear

¿Estás listo para ampliar tu recopilación de datos?

Únete a más de 2000 empresas que utilizan WebScrapingAPI para extraer datos de la web a escala empresarial sin ningún gasto de infraestructura.