Scraping por lenguaje 21 min de lectura

Web scraping en JavaScript: guía completa de lo básico a lo avanzado

Web scraping con JavaScript y Node.js: axios, cheerio, Puppeteer y Playwright, desde páginas simples hasta dinámica compleja.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 28 marzo 2025

Guía general sobre cómo descargar páginas con Node.js, extraer de ellas los datos y escalar todo esto hasta un scraper de producción: codificaciones, multihilo y concurrencia, proxies, TOR, SSL, cookies, encabezados, colas de URL y otros escollos. Con enlaces a las bibliotecas oficiales.


Índice

  1. Qué es el web scraping y de qué se compone
  2. Cómo descargamos la página: clientes HTTP
  3. Bibliotecas para parsear el contenido
  4. Obtener el código de estado y otros encabezados
  5. Solución de problemas de codificación de caracteres
  6. Trabajo con cookies
  7. Trabajo con HTTPS / SSL
  8. Uso de proxies
  9. Scraping a través de TOR
  10. Multi: multihilo y concurrencia
  11. Almacenamiento de URL y colas (panorama)
  12. Frameworks listos para usar
  13. Anti-bots, robots.txt, reintentos (lo que se suele olvidar)
  14. Principales ventajas y desventajas de la implementación en JavaScript

1. Qué es el web scraping y de qué se compone

El scraping de un sitio casi siempre se descompone en tres capas independientes, y lo más cómodo es diseñar el scraper precisamente por esas capas:

  1. Transporte — cómo obtener los bytes de la página (cliente HTTP o navegador headless).
  2. Extracción — cómo sacar del HTML/JSON los campos necesarios (parser del DOM, selectores).
  3. Orquestación — cómo recorrer muchas URL sin acabar bloqueado: colas, concurrencia, proxies, reintentos, deduplicación.

Toda la guía va de menos a más: primero «descargar una página», al final «un crawler distribuido y robusto».

Una bifurcación importante desde el principio:

  • Sitio estático (los datos ya están en el HTML) → basta con un cliente HTTP + un parser del DOM. Rápido, barato, miles de páginas por minuto.
  • Sitio dinámico (los datos los carga JavaScript) → hace falta o bien un navegador headless (Playwright / Puppeteer), o bien ingeniería inversa de la API interna del sitio (a menudo los datos están en un endpoint JSON y el navegador sobra).

Antes de tirar de un navegador pesado, compruebe siempre la pestaña Network de DevTools: si la página va a buscar los datos a /api/... y recibe JSON, lo que hay que parsear es ese JSON, no el DOM renderizado.


2. Cómo descargamos la página: clientes HTTP

2.1. fetch nativo (Node 18+): la opción por defecto

Desde Node.js 18, fetch viene integrado de forma global, es estable desde Node 21 y está soportado en las ramas LTS 22 y 24. Por debajo funciona sobre undici, así que paquetes aparte como node-fetch ya no hacen falta para las tareas básicas.

javascript
const res = await fetch('https://example.com');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();

Dos cosas con las que tropiezan todos los principiantes:

  • fetch no lanza una excepción con 404/500: hay que comprobar res.ok por cuenta propia.
  • fetch no tiene timeout por defecto: un socket colgado puede quedarse así para siempre. Configure AbortSignal.timeout():
javascript
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });

2.2. undici directamente: cuando se necesita la máxima velocidad

undici es justamente el «motor» del fetch nativo, pero su API de bajo nivel (request, pools de conexiones, pipelining) supera en los benchmarks a fetch, axios y got por un factor de varias veces. Tiene sentido cuando usted ha topado con el techo de rendimiento.

javascript
import { request } from 'undici';
const { statusCode, headers, body } = await request('https://example.com');
const html = await body.text();

2.3. got y got-scraping: comodidad + «camuflaje de navegador»

got es un cliente maduro con reintentos integrados, hooks, soporte de cookie jar y HTTP/2.

Para el scraping resulta más interesante el fork got-scraping de Apify: genera automáticamente encabezados de navegador verosímiles y en el orden correcto, lo que reduce la probabilidad de bloqueo. Es precisamente el que usa CheerioCrawler en Crawlee.

javascript
import { gotScraping } from 'got-scraping';
const { body } = await gotScraping({ url: 'https://example.com' });

2.4. axios: si necesita interceptores y una API familiar

axios (repositorio) sigue siendo el cliente más popular gracias a los interceptores, el manejo cómodo de proxies y el parseo automático de JSON. Para scraping no es más rápido que fetch, pero su ecosistema (por ejemplo, axios-retry) ahorra tiempo.

2.5. Otros

  • ky — envoltorio fino sobre fetch con valores por defecto razonables (reintentos, timeouts).
  • node-fetchlegacy, solo necesario en versiones muy antiguas de Node; en las modernas use el fetch integrado.
  • Los módulos integrados http/https — control máximo, pero mucha fontanería manual; normalmente solo se necesitan por debajo de los agentes y proxies.

Qué elegir

Escenario Recomendación
La mayoría de las tareas, Node 18+ fetch nativo
Miles de peticiones, prioridad al rendimiento undici (request/Pool)
Camuflaje de encabezados listo para usar got-scraping
Interceptores, API familiar, base de código legacy axios
Sitio dinámico con renderizado JS Playwright / Puppeteer (véase §3.5)

3. Bibliotecas para parsear el contenido

Una vez obtenida la cadena HTML, hay que convertirla en datos. El HTML no se parsea con expresiones regulares: es frágil y se rompe con la primera etiqueta anidada. Use un parser de verdad.

3.1. Cheerio: el estándar para contenido estático

Cheerio (repositorio) es un parser rápido de lado servidor con una API al estilo jQuery. No ejecuta JS ni renderiza: simplemente construye el árbol y permite recorrerlo con selectores. Ideal en combinación con fetch/got.

javascript
import * as cheerio from 'cheerio';

const html = await (await fetch('https://example.com/products')).text();
const $ = cheerio.load(html);

const items = $('.product-card').map((_, el) => ({
  title: $(el).find('.title').text().trim(),
  price: $(el).find('.price').text().trim(),
  url: new URL($(el).find('a').attr('href'), 'https://example.com').href,
})).get();

3.2. jsdom: un DOM casi de verdad

jsdom implementa una parte considerable del DOM de navegador e incluso puede ejecutar los scripts de la página. Es más pesado que Cheerio, pero ofrece los familiares querySelectorAll y document, y resulta útil cuando se necesita una API del DOM más «auténtica».

3.3. Alternativas ligeras y rápidas

  • node-html-parser — muy rápido, con selectores CSS.
  • htmlparser2 — parser de bajo nivel en streaming (sobre él está construido Cheerio).
  • parse5 — parser HTML5 fiel a la especificación.
  • linkedom — alternativa ligera a jsdom con API del DOM.

3.4. Extracción por «recetas»

x-ray permite describir la extracción de forma declarativa (selector → campo) y recorrer la paginación de inmediato. Cómodo para prototipos.

3.5. Dinámica: Playwright y Puppeteer

Cuando el contenido lo dibuja JS, hace falta un navegador headless:

  • Playwright (repositorio) — el favorito moderno: Chromium, Firefox y WebKit con una sola API, esperas automáticas de elementos, interceptación de peticiones de red, contextos para aislar cookies.
  • Puppeteer (repositorio) — el estándar de facto para Chrome/Chromium, algo más simple, con un ecosistema enorme.
javascript
import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle' });
const titles = await page.$$eval('h2', els => els.map(e => e.textContent.trim()));
await browser.close();

El navegador es la vía más cara en recursos: decenas o cientos de MB de RAM por pestaña. Úselo solo cuando de verdad no hay HTML estático ni API interna.

Consejo sobre el enfoque híbrido: a menudo lo óptimo es abrir la página en el navegador una sola vez, extraer el HTML ya renderizado con page.content() y seguir analizándolo con el veloz Cheerio: así combina el renderizado JS con la comodidad de los selectores.


4. Obtener el código de estado y otros encabezados

El estado y los encabezados son la mitad del diagnóstico de un scraper (bloqueo, redirección, límite, codificación).

Con el fetch nativo:

javascript
const res = await fetch(url, { redirect: 'follow' });

res.status;        // 200, 404, 429, 503 ...
res.statusText;    // 'OK', 'Too Many Requests'
res.ok;            // true con 2xx
res.redirected;    // hubo redirecciones o no
res.url;           // URL final tras las redirecciones

res.headers.get('content-type');   // text/html; charset=windows-1252
res.headers.get('set-cookie');     // cookies
res.headers.get('retry-after');    // cuánto esperar con 429/503
[...res.headers];                  // todos los encabezados por pares

Buenos hábitos:

  • 429 / 503 → lea Retry-After y aplique backoff, en lugar de seguir martilleando.
  • 301/302/308 → decida si seguir la redirección (redirect: 'manual' da control manual).
  • Content-Type con charset= → la primera y principal fuente de verdad sobre la codificación (véase §5).
  • Gestionar los encabezados salientes (User-Agent, Accept-Language, Referer) no es menos importante: muchos sitios cortan las peticiones sin un User-Agent verosímil.

En got/axios todo esto está disponible como response.statusCode y response.headers. En el navegador, mediante la interceptación de la respuesta: page.on('response', res => res.status()).


5. Solución de problemas de codificación de caracteres

Un clásico de los sitios antiguos: la página está en windows-1252 (o ISO-8859-1) y usted recibe caracteres rotos del estilo información en lugar de «información». La causa: res.text() siempre decodifica los bytes como UTF-8, pero el sitio los envió en otra codificación.

Regla: con páginas que no están en UTF-8 no se puede usar res.text(). Tome los bytes crudos (arrayBuffer) y decodifíquelos con la codificación correcta mediante iconv-lite.

javascript
import iconv from 'iconv-lite';

const res = await fetch('https://sitio-antiguo.example/');
const buf = Buffer.from(await res.arrayBuffer());

// 1) intentamos averiguar la codificación por el encabezado Content-Type
let charset = (res.headers.get('content-type') || '').match(/charset=([^;]+)/i)?.[1];

// 2) si no está en el encabezado, la buscamos en <meta> (decodificamos el fragmento como latin1 para leer la etiqueta)
if (!charset) {
  const head = iconv.decode(buf, 'latin1');
  charset = head.match(/<meta[^>]+charset=["']?([\w-]+)/i)?.[1]
         || head.match(/charset=([\w-]+)/i)?.[1];
}

charset = (charset || 'utf-8').toLowerCase().replace('windows-', 'win');

const html = iconv.decode(buf, charset); // acentos y eñes correctos

Si la codificación no está declarada en ninguna parte, se puede detectar de forma heurística:

  • jschardet — port del Universal Charset Detector de Mozilla.
  • chardet — detector alternativo.
javascript
import jschardet from 'jschardet';
const guess = jschardet.detect(buf); // { encoding: 'windows-1252', confidence: 0.99 }

Además:

  • Cheerio sabe decodificar por sí mismo si se le pasa el búfer y una pista: cheerio.load(buf, { decodeEntities: true }), pero un iconv.decode explícito es más fiable.
  • En un navegador headless el problema de codificación normalmente no existe: el navegador decodifica la página por su cuenta y page.content() devuelve UTF-8 correcto.
  • No olvide las entidades HTML (&nbsp;, &oacute;): los parsers en condiciones (Cheerio, parse5) las decodifican por usted.

Las cookies hacen falta para las zonas con autenticación, las sesiones, los carritos y para saltarse las pantallas de «primera visita». Hay tres niveles.

6.1. A mano, mediante encabezados

javascript
const res = await fetch(url, { headers: { cookie: 'sid=abc123; lang=es' } });
const setCookie = res.headers.get('set-cookie'); // parsearla y reenviarla en la siguiente petición

Vale para casos simples, pero mantener a mano el conjunto de cookies entre peticiones es un suplicio.

tough-cookie es la implementación de referencia de un almacén de cookies que respeta dominio, ruta, caducidad y flags. Muchos clientes se integran con él de serie.

got acepta el jar directamente y lleva la sesión por sí solo:

javascript
import got from 'got';
import { CookieJar } from 'tough-cookie';

const cookieJar = new CookieJar();
await got('https://example.com/login', { cookieJar, method: 'POST', form: { user, pass } });
const profile = await got('https://example.com/account', { cookieJar }); // las cookies se añaden solas

Para axios existe el envoltorio axios-cookiejar-support; con el fetch nativo tocará conectar tough-cookie a mano o usar got/undici.

6.3. En el navegador

En Playwright/Puppeteer las cookies viven en el contexto y se pueden guardar y restaurar, algo muy práctico para iniciar sesión una sola vez y reutilizar la sesión:

javascript
// guardar el estado (cookies + localStorage)
await context.storageState({ path: 'state.json' });
// restaurarlo en una nueva ejecución
const context = await browser.newContext({ storageState: 'state.json' });

7. Trabajo con HTTPS / SSL

Un sitio HTTPS normal no exige ningún esfuerzo: fetch/got/axios verifican el certificado automáticamente. Casos especiales:

7.1. Certificados autofirmados o caducados

A veces hay que desactivar la verificación (por ejemplo, al trabajar a través de un proxy MITM o con un entorno de pruebas). Hágalo con conocimiento de causa: elimina la protección contra la suplantación del tráfico.

javascript
// undici / fetch nativo — mediante el dispatcher
import { Agent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new Agent({ connect: { rejectUnauthorized: false } }));

// got / axios — mediante https.Agent
import https from 'node:https';
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
// got:  got(url, { agent: { https: httpsAgent } })
// axios: axios.get(url, { httpsAgent })

El «hachazo» global NODE_TLS_REJECT_UNAUTHORIZED=0 desactiva la verificación para todo el proceso; mejor no hacerlo en producción.

7.2. Certificados raíz propios / certificados de cliente (mTLS)

javascript
import https from 'node:https';
import fs from 'node:fs';

const agent = new https.Agent({
  ca:  fs.readFileSync('./ca.pem'),     // autoridad certificadora propia
  cert: fs.readFileSync('./client.pem'), // certificado de cliente para mTLS
  key:  fs.readFileSync('./client.key'),
});

7.3. Huella TLS (JA3): anti-bots avanzado

Las protecciones modernas (Cloudflare, DataDome) saben distinguir a los clientes por el handshake TLS (JA3/JA4): el de un cliente Node no es igual al de un Chrome de verdad, y eso delata al bot incluso con encabezados impecables. Node puro no puede «arreglarlo»; ayudan:

  • got-scraping — camufla parcialmente la capa de encabezados;
  • CycleTLS — suplantación de la huella TLS;
  • un navegador headless de verdad (Playwright) — aporta el handshake TLS «real» de un navegador.

8. Uso de proxies

Los proxies sirven para repartir la carga entre varias IP, sortear restricciones geográficas y bloqueos por IP. Tipos: HTTP, HTTPS y SOCKS5 (este último es el más universal: transporta cualquier tráfico y también el DNS).

8.1. fetch nativo (¡particularidad importante de 2026!)

El fetch nativo no tiene la vieja opción { agent }. El proxy se configura mediante el dispatcher de undici, ProxyAgent:

javascript
import { ProxyAgent, setGlobalDispatcher } from 'undici';

// globalmente: todos los fetch pasarán por el proxy
setGlobalDispatcher(new ProxyAgent('http://user:pass@proxy.host:8080'));
const res = await fetch('https://example.com');

// o de forma puntual, para una sola petición
const res2 = await fetch('https://example.com', {
  dispatcher: new ProxyAgent('http://user:pass@proxy.host:8080'),
});

En Node 24+ se puede activar la lectura de HTTP_PROXY/HTTPS_PROXY del entorno con el flag NODE_USE_ENV_PROXY=1 (o --use-env-proxy), pero un ProxyAgent explícito es más fiable.

8.2. got / axios mediante agentes

Con los agentes https-proxy-agent y socks-proxy-agent:

javascript
import got from 'got';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { SocksProxyAgent } from 'socks-proxy-agent';

const httpsAgent  = new HttpsProxyAgent('http://user:pass@proxy:8080');
const socksAgent  = new SocksProxyAgent('socks5h://127.0.0.1:9050'); // h = DNS a través del proxy

const r1 = await got('https://example.com', { agent: { https: httpsAgent } });
const r2 = await got('https://example.com', { agent: { http: socksAgent, https: socksAgent } });

8.3. Rotación y pools de proxies

Para escalar hace falta un pool de proxies con rotación y descarte de las direcciones «muertas». La variante más simple: elegir un proxy aleatorio o por round-robin en cada petición. Herramientas listas:

  • proxy-chain de Apify — levanta un proxy local que reenvía al upstream (incluso con autenticación, algo importante para Chromium, que no admite usuario/contraseña en --proxy-server).
  • En Crawlee la rotación de proxies y sesiones viene integrada (ProxyConfiguration).
javascript
// un proxy aleatorio del pool en cada petición
const pool = ['http://p1:8080', 'http://p2:8080', 'http://p3:8080'];
const pick = () => pool[Math.floor(Math.random() * pool.length)];
await fetch(url, { dispatcher: new ProxyAgent(pick()) });

Tipos de proxy por calidad: datacenter (barato, se detecta fácil) → residentialmobile (caro, casi nunca se bloquea). La elección depende de la agresividad de la protección del objetivo.


9. Scraping a través de TOR

TOR ofrece rotación de IP gratuita: el tráfico pasa por una cadena de nodos y se puede cambiar la IP de salida bajo demanda. Resulta útil para aprender y para tareas pequeñas, pero el enfoque tiene limitaciones serias (véase el final de la sección).

9.1. Configuración

TOR levanta un proxy SOCKS en el puerto 9050 y un puerto de control 9051 para gestionarlo. En el archivo de configuración torrc:

code
SocksPort 9050
ControlPort 9051
# la contraseña se genera con el comando: tor --hash-password "su_contrasena"
HashedControlPassword 16:....
CookieAuthentication 1

9.2. Peticiones a través de TOR

Basta con apuntar el cliente al SOCKS5 local (use socks5h para que también el DNS se resuelva a través de TOR; de lo contrario se filtra la IP real):

javascript
import got from 'got';
import { SocksProxyAgent } from 'socks-proxy-agent';

const agent = new SocksProxyAgent('socks5h://127.0.0.1:9050');
const res = await got('https://httpbin.org/ip', {
  agent: { http: agent, https: agent },
});
console.log(JSON.parse(res.body).origin); // IP de salida actual de TOR

9.3. Cambio de circuito (nueva identidad) y de IP de salida

Para obtener una nueva IP de salida se envía al puerto de control la señal NEWNYM. Puede hacerse con la biblioteca tor-request o a mano, con un socket TCP normal y sin dependencias:

javascript
import net from 'node:net';

function newTorIdentity(password = '') {
  return new Promise((resolve, reject) => {
    const socket = net.connect(9051, '127.0.0.1', () => {
      socket.write(`AUTHENTICATE "${password}"\r\nSIGNAL NEWNYM\r\nQUIT\r\n`);
    });
    socket.once('error', reject);
    socket.once('end', resolve);
    socket.resume();
  });
}

// entre peticiones:
await newTorIdentity('su_contrasena');

Importante: TOR mantiene un enfriamiento de ~10 segundos entre cambios de circuito; no podrá rotar la IP más a menudo.

9.4. Varias instancias para aumentar el rendimiento

Un TOR = una sola IP de salida en cada momento y un enfriamiento lento. Para montar un pool de «proxies gratuitos» se levantan varios procesos de TOR en puertos distintos (9050/9051, 9052/9053, ...) y se reparten las peticiones por round-robin. Hay una imagen Docker lista para esto: rotating-tor-http-proxy (varias instancias detrás de un único endpoint HTTP mediante HAProxy).

9.5. Limitaciones (lectura obligatoria)

  • Los nodos de salida de TOR son unos 1500, sus listas son públicas y Cloudflare/DataDome y la mayoría de los sistemas anti-bots los bloquean de antemano: en objetivos protegidos TOR es casi inútil.
  • La velocidad es baja e inestable, y la nueva IP no está garantizada como «limpia» y funcional.
  • Sirve para aprender y para objetivos pequeños sin protección; para producción, use proxies residential/mobile.
  • TOR es una herramienta de privacidad; úselo dentro de la ley y de las normas de los sitios.

10. Concurrencia y multihilo

Aquí es importante distinguir dos conceptos diferentes.

10.1. Primero: concurrencia asíncrona (y no hilos)

El scraping es una tarea I/O-bound (esperamos a la red). Node, con un solo hilo y gracias a su event loop, mantiene sin esfuerzo cientos de peticiones simultáneas: los hilos de verdad casi nunca hacen falta aquí. El peligro es exactamente el contrario: lanzar un Promise.all sobre 10 000 URL de golpe y tumbar tanto su propia red como el servidor objetivo. Por eso la concurrencia se limita.

p-limit — limitador de tareas simultáneas:

javascript
import pLimit from 'p-limit';

const limit = pLimit(5); // máximo 5 peticiones a la vez
const results = await Promise.all(
  urls.map(url => limit(() => scrape(url)))
);

Parientes cercanos:

  • p-queue — cola con prioridades, intervalos y rate limit (por ejemplo, «no más de 10 peticiones por segundo»).
  • p-map — un map con límite de concurrencia.
  • bottleneck — rate limiter avanzado (incluso distribuido vía Redis).

10.2. worker_threads: para el parseo con carga computacional (CPU-bound)

Si el cuello de botella no es la red sino el parseo pesado (HTML/JSON gigantescos, expresiones regulares, posprocesado), tiene sentido llevarlo a hilos con worker_threads para no bloquear el event loop. Un envoltorio cómodo son los pools tipo piscina.

javascript
import { Worker } from 'node:worker_threads';
// cada worker parsea su fragmento de HTML en paralelo, sin bloquear el hilo principal

10.3. cluster / varios procesos: escalar entre varios núcleos

cluster, o simplemente lanzar N procesos (a menudo en Docker), reparte la carga entre los núcleos de CPU y aporta tolerancia a fallos. En la práctica, para un crawler esto suele ser «varios workers leen de una cola común (Redis)»; véase §11.

10.4. Autoescalado «de serie»

Crawlee ajusta por sí mismo la concurrencia a la CPU/RAM disponibles (AutoscaledPool): menos riesgo de caerse en un contenedor pequeño y máximo aprovechamiento en uno grande.

Receta práctica: para la mayoría de los scrapers, fetch + p-limit/p-queue con un límite de 5--20 peticiones simultáneas. Añada hilos o procesos solo cuando haya topado con la CPU o con los límites de un único proceso.


11. Almacenamiento de URL y colas (panorama)

En cuanto el crawler recorre más de una página aparece el frontier, el frente de rastreo: la cola de URL «por visitar» más el conjunto de las «ya visitadas».

Tareas clave:

  • Deduplicación. No hay que visitar la misma URL dos veces. En memoria, un simple Set sobre la URL normalizada; con grandes volúmenes, un filtro de Bloom (compacto, al precio de raras falsas coincidencias), por ejemplo bloom-filters.
  • Normalización de URL. Llévelas a la forma canónica (ordenar la query, quitar #, la barra final y las etiquetas utm); de lo contrario los «duplicados» se multiplican. Ayuda normalize-url.
  • Persistencia. Si el proceso se cae, la cola no debe perderse. La memoria no vale para tareas serias.
  • Prioridades y orden de recorrido — en anchura (BFS) o en profundidad (DFS), con prioridad para las secciones importantes.

Dónde almacenar:

Escala Solución
Script pequeño de un solo uso Set + array en memoria
Worker único con reinicios archivo / SQLite, o la RequestQueue de Crawlee
Varios workers / distribuido Redis (ioredis) como cola común + conjunto de visitadas
Cola de tareas de nivel industrial BullMQ (repositorio) sobre Redis: reintentos, retardos, prioridades, concurrencia

Crawlee ofrece una RequestQueue persistente integrada, con deduplicación y recorrido en anchura o profundidad: si no quiere montar el frontier a mano, es el camino más rápido.

La arquitectura típica «de mayores»: Redis/BullMQ como cola de URL → un pool de workers toma tareas, parsea, devuelve a la cola los enlaces encontrados (tras el dedupe) y escribe el resultado en la base de datos o en un archivo.


12. Frameworks listos para usar

Si no quiere ensamblar a mano todo lo anterior:

  • Crawlee (repositorio) — el principal framework moderno para Node.js/TS, de Apify. Interfaz única para el crawling por HTTP y por navegador (CheerioCrawler, PuppeteerCrawler, PlaywrightCrawler), cola de URL persistente, rotación de proxies y sesiones, autoescalado, huellas de navegador «humanas», reintentos. Las versiones recientes añaden un crawler adaptativo (decide por sí mismo si hace falta renderizado JS) y capacidades orientadas a la IA. Requiere Node 16+.

```js import { CheerioCrawler } from 'crawlee';

const crawler = new CheerioCrawler({ maxConcurrency: 10, async requestHandler({ $, request, enqueueLinks, pushData }) { await pushData({ url: request.url, title: $('title').text() }); await enqueueLinks(); // encuentra los enlaces por sí solo y los pone en la cola con deduplicación }, }); await crawler.run(['https://example.com']); ```

  • node-crawler — un crawler más clásico con cola, límites y Cheerio integrado.
  • x-ray — extracción declarativa + paginación.

Para la mayoría de los proyectos serios en JS, la respuesta por defecto hoy es Crawlee.


13. Anti-bots, robots.txt, reintentos (lo que se suele olvidar)

Estos temas no figuraban en el plan inicial, pero sin ellos un scraper de producción no sobrevive.

13.1. Camuflarse como un cliente normal

  • Configure un User-Agent verosímil, Accept-Language y Referer. Lista de UA reales: user-agents.
  • Generación de conjuntos coherentes de encabezados y huellas: got-scraping y fingerprint-suite de Apify.
  • Con las protecciones fuertes (Cloudflare y similares) solo salva un navegador de verdad (Playwright) o la suplantación de la huella TLS (véase §7.3).

13.2. Cortesía y reintentos

  • Respete robots.txt allí donde corresponda; para parsearlo ayuda robots-parser.
  • Aplique rate limiting y retardos aleatorios entre peticiones (p-queue/bottleneck).
  • Ante 429/503, respete Retry-After, use backoff exponencial con jitter y limite el número de reintentos.
  • Cachee lo ya descargado para no volver a golpear el sitio tras un reinicio.

14. Principales ventajas y desventajas de la implementación en JavaScript

Ventajas

  • El mismo lenguaje que la página. Los sitios están escritos en JS: los selectores, la lógica del DOM e incluso la ejecución de los scripts de la página conviven cómodamente en el mismo entorno.
  • Los mejores navegadores headless son nativos de JS. Playwright y Puppeteer son ciudadanos de primera clase en Node; para la dinámica pesada es una ventaja seria frente a otros ecosistemas.
  • Asincronía de serie. El event loop encaja a la perfección con el scraping I/O-bound: alta concurrencia en un solo proceso, sin pelearse con los hilos.
  • Ecosistema maduro. fetch/undici, Cheerio, Crawlee, BullMQ, agentes de proxy listos: todo a mano.
  • Crawlee cubre la «orquestación» (colas, proxies, huellas, escalado) casi sin código.

Desventajas

  • El parseo CPU-bound (documentos enormes, posprocesado pesado) es el punto débil del Node monohilo; hacen falta worker_threads o varios procesos, mientras que en Go/Rust esto resulta más sencillo.
  • La voracidad de los navegadores. Playwright/Puppeteer consumen mucha RAM/CPU; a escala son gastos tangibles.
  • El infierno de callbacks/promesas en una orquestación manual sin framework degenera fácilmente en espagueti.
  • La huella TLS. Los clientes Node se delatan por JA3/JA4; «arreglarlo» con Node puro es más difícil de lo que parece (hacen falta CycleTLS o un navegador).
  • La fragilidad de los selectores. Es el mal común del scraping (el maquetado cambia), y el ecosistema JS no le libra del mantenimiento manual de los selectores CSS/XPath.
  • La ciencia de datos posterior. Python con pandas/numpy es más fuerte en la analítica de lo recolectado; a veces resulta más cómodo «recolectar con JS, procesar con Python».

Cuándo JS es una buena elección: sitios dinámicos, necesidad de un navegador headless, un equipo que ya trabaja con Node, alta concurrencia de I/O o integración con servicios web en JS. Cuándo considerar una alternativa: procesamiento puramente CPU-bound de terabytes de HTML o una integración estrecha con la analítica en Python.