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
- Qué es el web scraping y de qué se compone
- Cómo descargamos la página: clientes HTTP
- Bibliotecas para parsear el contenido
- Obtener el código de estado y otros encabezados
- Solución de problemas de codificación de caracteres
- Trabajo con cookies
- Trabajo con HTTPS / SSL
- Uso de proxies
- Scraping a través de TOR
- Multi: multihilo y concurrencia
- Almacenamiento de URL y colas (panorama)
- Frameworks listos para usar
- Anti-bots, robots.txt, reintentos (lo que se suele olvidar)
- 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:
- Transporte — cómo obtener los bytes de la página (cliente HTTP o navegador headless).
- Extracción — cómo sacar del HTML/JSON los campos necesarios (parser del DOM, selectores).
- 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.
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:
fetchno lanza una excepción con 404/500: hay que comprobarres.okpor cuenta propia.fetchno tiene timeout por defecto: un socket colgado puede quedarse así para siempre. ConfigureAbortSignal.timeout():
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.
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.
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 sobrefetchcon valores por defecto razonables (reintentos, timeouts).node-fetch— legacy, solo necesario en versiones muy antiguas de Node; en las modernas use elfetchintegrado.- 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.
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.
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:
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 paresBuenos hábitos:
- 429 / 503 → lea
Retry-Aftery aplique backoff, en lugar de seguir martilleando. - 301/302/308 → decida si seguir la redirección (
redirect: 'manual'da control manual). Content-Typeconcharset=→ 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 unUser-Agentverosí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.
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 correctosSi la codificación no está declarada en ninguna parte, se puede detectar de forma heurística:
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 uniconv.decodeexplí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 (
,ó): los parsers en condiciones (Cheerio, parse5) las decodifican por usted.
6. Trabajo con cookies
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
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ónVale para casos simples, pero mantener a mano el conjunto de cookies entre peticiones es un suplicio.
6.2. Almacén de cookies (cookie jar): lo recomendado
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:
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 solasPara 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:
// 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.
// 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)
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:
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:
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-chainde 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).
// 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) → residential → mobile (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:
SocksPort 9050
ControlPort 9051
# la contraseña se genera con el comando: tor --hash-password "su_contrasena"
HashedControlPassword 16:....
CookieAuthentication 19.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):
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 TOR9.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:
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:
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— unmapcon 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.
import { Worker } from 'node:worker_threads';
// cada worker parsea su fragmento de HTML en paralelo, sin bloquear el hilo principal10.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
Setsobre la URL normalizada; con grandes volúmenes, un filtro de Bloom (compacto, al precio de raras falsas coincidencias), por ejemplobloom-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. Ayudanormalize-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-Agentverosímil,Accept-LanguageyReferer. Lista de UA reales:user-agents. - Generación de conjuntos coherentes de encabezados y huellas:
got-scrapingy 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.txtallí donde corresponda; para parsearlo ayudarobots-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_threadso 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.