Tony Wang9 min de lecturaWeb Scraping con Node.js — Fetch, Cheerio y Puppeteer
Haz scraping en Node.js con fetch nativo y Cheerio, renderiza páginas con Puppeteer, y sabe cuándo conviene más una API de scraping.
Si escribes JavaScript todo el día, no necesitas aprender Python para hacer web scraping. Node.js trae todo lo que necesita un scraper básico: fetch viene integrado desde Node 18, Cheerio te da parsing de HTML al estilo jQuery sobre el markup crudo, y Puppeteer maneja un Chrome real cuando la página solo existe después de que se ejecuta JavaScript. Esta guía construye un scraper funcional con cada uno de ellos, en ese orden, sobre un mismo sitio de práctica, y luego es honesta sobre dónde los scrapers de Node hechos a mano chocan con un muro en producción. Es la contraparte en JavaScript de nuestra guía de web scraping con Python — el mismo recorrido, en tu lenguaje.
Páginas estáticas: fetch nativo + Cheerio
La mayoría de los tutoriales de scraping empiezan instalando un cliente HTTP. Sáltate eso — desde Node 18, fetch es global. La única dependencia para un scraper de páginas estáticas es Cheerio:
npm install cheerio
Cheerio 1.x es un paquete dual CommonJS/ESM y requiere Node 18.17 o más reciente, lo cual encaja muy bien con fetch nativo. Vamos a hacer scraping de books.toscrape.com, una librería de prueba construida exactamente para este tipo de práctica: 1,000 libros, 50 páginas, sin login, sin trucos.
El patrón nunca cambia: haces fetch del HTML, lo cargas en Cheerio, seleccionas elementos con selectores CSS y mapeas cada uno a un objeto plano.
// scrape-books.mjs — Node 18+, ejecutar con: node scrape-books.mjs
import * as cheerio from "cheerio";
import { writeFile } from "node:fs/promises";
const BASE = "https://books.toscrape.com/";
const res = await fetch(BASE);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const $ = cheerio.load(html);
const books = $("article.product_pod")
.map((_, el) => {
const $el = $(el);
const link = $el.find("h3 a");
return {
title: link.attr("title"),
price: $el.find(".price_color").text(),
inStock: $el.find(".availability").text().trim().startsWith("In stock"),
url: new URL(link.attr("href"), BASE).href,
};
})
.get();
await writeFile("books.json", JSON.stringify(books, null, 2));
console.log(`Saved ${books.length} books`);
Vale la pena notar algunas cosas:
cheerio.load(html)devuelve$— una función que usas exactamente como jQuery.$("article.product_pod")selecciona cada tarjeta de libro;.find(),.text()y.attr()recorren cada una de ellas..map(...).get()convierte una selección de Cheerio en un array real de JavaScript. Dentro del callback,eles un nodo DOM crudo, así que envuélvelo de nuevo en$()antes de consultarlo.new URL(href, BASE)resuelve enlaces relativos — el sitio enlaza acatalogue/some-book_1/index.html, y esto lo convierte en una URL absoluta que puedes usar más tarde con fetch.- Verifica
res.okantes de parsear. Una página 404 o de error sigue siendo HTML; Cheerio la parseará sin problema y te devolverá un array vacío, lo cual es un fallo mucho más confuso que un error lanzado.
¿Prefieres CSV en lugar de JSON? No necesitas una librería para una estructura plana como esta:
const header = "title,price,inStock,url";
const rows = books.map((b) =>
[JSON.stringify(b.title), b.price, b.inStock, b.url].join(",")
);
await writeFile("books.csv", [header, ...rows].join("\n"));
El JSON.stringify sobre el título es una forma económica de entrecomillar y escapar un campo que podría contener comas. Para algo anidado o más desordenado, usa un paquete de CSV apropiado — pero no lo uses antes de necesitarlo.
Algo que Cheerio nunca hará: ejecutar JavaScript. Parsea el markup que envió el servidor, y esa es toda la historia. Eso lo hace rápido — sin navegador, sin renderizado — y es la opción correcta por defecto para cualquier página cuyos datos sean visibles en "ver código fuente". Cuando los datos no están ahí, salta a la sección de Puppeteer.
Paginación y cortesía
Una sola página es una demostración. Un scraper real recorre las 50 páginas, y cómo las recorre marca la diferencia entre una herramienta útil y una IP baneada. Tres hábitos importan: identifícate, marca un ritmo y falla en voz alta.
// scrape-all.mjs
import * as cheerio from "cheerio";
import { writeFile } from "node:fs/promises";
const CATALOGUE = "https://books.toscrape.com/catalogue/";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const books = [];
for (let page = 1; ; page++) {
const res = await fetch(`${CATALOGUE}page-${page}.html`, {
headers: { "user-agent": "books-demo/1.0 (learning project)" },
});
if (res.status === 404) break; // pasamos la última página — terminamos
if (!res.ok) throw new Error(`HTTP ${res.status} on page ${page}`);
const $ = cheerio.load(await res.text());
$("article.product_pod").each((_, el) => {
books.push({
title: $(el).find("h3 a").attr("title"),
price: $(el).find(".price_color").text(),
});
});
console.log(`page ${page}: ${books.length} books so far`);
await sleep(1000); // una solicitud por segundo es un valor por defecto educado
}
await writeFile("books.json", JSON.stringify(books, null, 2));
Este bucle codifica los tres hábitos:
- Un
user-agentdescriptivo. Los operadores del sitio que vean tu tráfico en sus logs deberían poder identificar qué es. Un User-Agent vacío o falseado como navegador es lo primero que parece abuso. - Una pausa entre solicitudes. El helper
sleepes cuatro líneas más corto que cualquier paquete que haga lo mismo. Una solicitud por segundo te cuesta menos de un minuto en este sitio y te mantiene lejos de cualquier umbral de limitación de tasa. El modelo asíncrono de Node tienta a lanzar las 50 solicitudes fetch conPromise.all— resiste esa tentación. La concurrencia es para tus propios servidores, no para los de alguien más. - Manejo explícito de errores. Un 404 aquí significa "pasamos la última página", así que termina el bucle. Cualquier otra respuesta no-OK lanza un error con el número de página en el mensaje. Los fallos silenciosos producen conjuntos de datos con huecos que descubres semanas después.
Páginas renderizadas con JavaScript: Puppeteer
Tarde o temprano harás fetch de una página, la cargarás en Cheerio y no obtendrás nada — los selectores que claramente existen en DevTools devuelven un array vacío. Eso es una página renderizada del lado del cliente: el servidor envió un esqueleto y los datos llegaron después vía JavaScript. Cheerio nunca ejecuta JavaScript, así que en lo que a él respecta, esos datos no existen.
La solución es un navegador headless — un Chrome real sin ventana, controlado desde tu script. En Node, la opción estándar es Puppeteer:
npm install puppeteer
// scrape-rendered.mjs
import puppeteer from "puppeteer";
const browser = await puppeteer.launch(); // headless por defecto
const page = await browser.newPage();
await page.goto("https://quotes.toscrape.com/js/", {
waitUntil: "networkidle2",
});
await page.waitForSelector(".quote"); // espera a que el JS los renderice
const quotes = await page.$$eval(".quote", (cards) =>
cards.map((card) => ({
text: card.querySelector(".text")?.textContent.trim(),
author: card.querySelector(".author")?.textContent.trim(),
}))
);
await browser.close();
console.log(quotes);
El objetivo aquí es el sitio gemelo renderizado con JavaScript de nuestra librería de práctica — citas que solo existen después del renderizado del lado del cliente. Dos líneas cargan con todo el peso: waitUntil: "networkidle2" mantiene goto en espera hasta que la red se calma en su mayoría, y waitForSelector confirma que los elementos específicos que te interesan realmente se renderizaron antes de leerlos. page.$$eval luego ejecuta tu callback dentro del navegador, así que escribes código normal de querySelector y obtienes JSON plano de vuelta.
El costo, sin embargo, es real. Un scraping con Cheerio son unos pocos milisegundos de parseo; un scraping con Puppeteer lanza un Chrome completo, carga cada recurso y ejecuta cada script — aproximadamente dos órdenes de magnitud más lento y mucho más pesado en memoria. Úsalo solo para páginas que lo necesiten, y sigue usando fetch + Cheerio para todo lo demás. Muchas configuraciones de producción combinan ambos: Puppeteer para renderizar, y luego cheerio.load(await page.content()) para parsear con la API más cómoda.
La otra opción popular es Playwright, que tiene una API de JavaScript de primer nivel, localizadores con auto-espera y una sola interfaz para Chromium, Firefox y WebKit. Nuestra guía de scraping con Playwright está escrita en torno a Python, pero los conceptos — estrategias de espera, localizadores frente a selectores, cuándo un navegador headless vale su costo — se trasladan a la API de JS casi línea por línea. Para scraping específicamente, Puppeteer y Playwright están lo suficientemente cerca como para que cualquiera de los dos sea una buena elección; la auto-espera de Playwright elimina toda una categoría de errores de selectores inestables, mientras que Puppeteer es más liviano y exclusivo de Chrome.
Node vs Python para web scraping
La respuesta honesta, de entrada: ambos son excelentes, y el factor decisivo casi nunca es el lenguaje. Así se comparan las piezas.
| Node.js | Python | |
|---|---|---|
| Cliente HTTP | fetch integrado (Node 18+) | requests / httpx (se instala) |
| Parsing de HTML | Cheerio | BeautifulSoup, lxml, parsel |
| Navegador headless | Puppeteer, Playwright | Playwright, Selenium |
| Framework de crawling completo | Crawlee | Scrapy (más maduro) |
| Modelo asíncrono | Asíncrono por defecto — la concurrencia es natural | Síncrono por defecto; asyncio es opcional |
| Manejo de datos después del scraping | Más limitado (sin equivalente a pandas) | pandas, todo el stack de datos |
| Mejor cuando | Tu app ya es JS/TS; scraping de páginas con mucho JS | Pipelines de datos, análisis, ML aguas abajo |
Las ventajas reales de Node: fetch sin dependencias, un runtime asíncrono donde la concurrencia educada (digamos, tres sitios en paralelo, cada uno con su propio ritmo) es idiomática y no algo añadido a la fuerza, y el hecho de que las páginas que raspas están escritas en JavaScript — depurar una app renderizada del lado del cliente es más natural cuando hablas su idioma. Las ventajas reales de Python: Scrapy no tiene un par en Node a su nivel de madurez, y en el momento en que tu scraping alimenta un análisis, pandas y el stack científico toman el control.
Pero el desempate es aburrido: usa el lenguaje que tu proyecto ya usa. Un scraper es un componente, no una identidad. Si tu stack es TypeScript, escribir el scraper en Python te compra un segundo toolchain que mantener y nada más. Si de verdad estás empezando desde cero en un proyecto centrado en datos, la guía de Python presenta los argumentos del otro lado.
El muro de producción — y el puente de la API
Todo lo anterior funciona sin problemas en sitios de práctica, y eso no es casualidad: los sitios sandbox existen porque los objetivos reales se defienden. Apunta el mismo código a un sitio grande de e-commerce o viajes y te toparás con el muro de producción — Cloudflare, DataDome, PerimeterX y compañía verificando handshakes TLS, huellas digitales del navegador, reputación de IP y comportamiento antes de que tu solicitud siquiera llegue a la página. Fetch nativo falla la verificación TLS por sí solo; Puppeteer sin modificar filtra marcadores de automatización que los scripts de fingerprinting detectan directamente. Puedes pelear contra eso — plugins de sigilo, proxies residenciales rotativos, falsificación de encabezados — y nuestra guía sobre scraping de sitios que bloquean bots aborda esa carrera armamentista con honestidad. Pero es una carrera armamentista, y se convierte en un trabajo.
La alternativa es dejar que una API de web scraping pelee esa batalla por ti. Tu código se queda exactamente como ya lo conoces — una llamada a fetch — pero los proxies, las huellas digitales, el renderizado y los reintentos suceden del otro lado del endpoint, y obtienes JSON estructurado en lugar de HTML para parsear:
// El mismo fetch que has usado en toda la guía — la parte difícil se movió detrás del endpoint
const keyword = encodeURIComponent("mechanical keyboard");
const res = await fetch(
`https://api.crawlora.net/api/v1/amazon/search?k=${keyword}`,
{ headers: { "x-api-key": process.env.CRAWLORA_API_KEY } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json();
for (const item of data) {
console.log(item.title, item.price);
}
Sin Cheerio, sin selectores, sin un parser que arreglar cuando cambia el layout — el endpoint devuelve campos normalizados para resultados de búsqueda en Amazon, y hay equivalentes para otras plataformas en los docs. La facturación es pago-por-éxito: solo se te cobra por respuestas 2xx exitosas, así que una solicitud bloqueada o fallida no cuesta nada. Dónde tiene sentido cada opción — hacerlo tú mismo o usar una API — es una pregunta real con una compensación real; web scraping vs API lo explica a fondo — pero la versión corta es: hazlo tú mismo para aprender y para sitios cooperativos, usa una API cuando el objetivo se defienda o cuando el mantenimiento del parser empiece a comerse tus sprints. Los precios escalan con el uso, y el plan gratuito son 2,000 créditos al mes, sin tarjeta.
- Node 18+ instalado, para que fetch esté disponible globalmente
- Páginas estáticas: fetch + cheerio.load, seleccionar con selectores CSS, mapear a objetos, escribir JSON o CSV
- Paginación: bucle con un helper de espera, un user-agent descriptivo y manejo de errores en voz alta
- Revisar robots.txt y los términos antes de hacer scraping de cualquier sitio real
- Páginas renderizadas con JS: Puppeteer con waitForSelector, o Playwright para localizadores con auto-espera
- Al chocar con muros anti-bot: dejar de pelear contra las huellas digitales y cambiar a un endpoint de API de scraping
Sáltate la carrera armamentista anti-bot
Sigue escribiendo JavaScript — llama a endpoints documentados que devuelven JSON normalizado mientras los proxies, el renderizado y los reintentos se manejan por ti. 2,000 créditos al mes, sin tarjeta.
Lecturas relacionadas
- Web scraping con Python — el mismo recorrido en el otro lenguaje, con BeautifulSoup y Scrapy.
- Web scraping con Playwright — más a fondo sobre navegadores headless, estrategias de espera y localizadores.
- Scraping de sitios que bloquean bots — qué verifican realmente los sistemas anti-bot y qué se necesita para pasarlos.
- Web scraping vs API — un marco para la decisión de construirlo tú mismo o comprarlo.
Preguntas frecuentes
Is Node.js good for web scraping?
Yes. Node 18+ ships fetch natively, Cheerio handles HTML parsing with a jQuery-style API, and Puppeteer or Playwright drive a headless Chrome for JavaScript-rendered pages. Its async-by-default runtime also makes paced concurrency natural. If your stack is already JavaScript or TypeScript, there is no reason to switch languages to scrape.
Cheerio vs Puppeteer — which should I use?
Use Cheerio whenever the data is visible in the page source: it parses HTML in milliseconds with no browser. Use Puppeteer only when the page renders its data with client-side JavaScript, because launching Chrome is roughly two orders of magnitude slower and much heavier. Many scrapers mix them — Puppeteer renders, then Cheerio parses page.content().
Do I need axios or another HTTP library to scrape in Node.js?
No. Since Node 18, fetch is available globally, so a static-page scraper needs only Cheerio as a dependency. An HTTP library adds convenience for interceptors or advanced retry logic, but for fetching HTML and calling JSON APIs, native fetch covers it.
How do I scrape JavaScript-rendered pages in Node.js?
Use a headless browser. With Puppeteer: launch a browser, page.goto with waitUntil networkidle2, waitForSelector for the elements you need, then page.$$eval to map them into plain objects. Playwright is the main alternative, with auto-waiting locators and cross-browser support — its JS API mirrors the same concepts.
Is Node.js or Python better for web scraping?
They are equally capable, so use the language your project already speaks. Node wins on built-in fetch, natural async concurrency, and debugging JS-heavy sites in their own language; Python wins on Scrapy's maturity and the pandas data stack downstream. The language is rarely the deciding factor.
Why does my Node.js scraper get blocked on real websites?
Production sites sit behind anti-bot systems that check TLS handshakes, browser fingerprints, IP reputation, and behavior. Native fetch fails the TLS check alone, and vanilla Puppeteer leaks automation markers. You can fight it with stealth plugins and rotating proxies, or route those targets through a scraping API that handles proxies, fingerprints, and retries and returns structured JSON.
How do I be polite when scraping with Node.js?
Send a descriptive user-agent so operators can identify your traffic, add a delay between requests (one per second is a safe default), read and respect robots.txt and the site's terms, only collect public data, and fail loudly on errors instead of hammering retries.