Web Scraping con Node.js, Cheerio y Proxies: Guía Completa con Rotación de IP

Aprende a construir scrapers escalables en Node.js con Cheerio y axios, integrando rotación de proxies residenciales como interceptor reutilizable, manejo de errores 403/429 y concurrencia con p-limit.

Web Scraping con Node.js, Cheerio y Proxies: Guía Completa con Rotación de IP

Por qué Cheerio + axios sigue siendo la combinación ganadora

Si alguna vez intentaste scrapear un sitio a escala —digamos 10 000 URLs de un e-commerce— ya sabes el patrón: las primeras 500 peticiones funcionan, luego llegan los 403 Forbidden, los 429 Too Many Requests, y tu script se desmorona. El problema no es Cheerio; es que tu IP se expone en cada petición.

La buena noticia: Cheerio con axios es suficiente para la gran mayoría de sitios con HTML renderizado en el servidor (SSR). No necesitas Puppeteer para páginas estáticas. Lo que necesitas es una capa de proxies residenciales con rotación automática — y eso es exactamente lo que construiremos en esta guía.

Vamos a cubrir desde el parseo básico con Cheerio hasta un interceptor de axios con rotación de proxies residenciales, concurrencia controlada y circuit breaking. Todo con código real que puedes copiar y ejecutar.

Scraping básico con Cheerio y axios

Cheerio es esencialmente jQuery para el servidor: carga HTML crudo y te permite seleccionar elementos con selectores CSS familiares. Combinado con axios para las peticiones HTTP, obtienes un scraper ligero sin overhead de navegador.

Instalación

npm install axios cheerio

Ejemplo mínimo: extraer productos de una página

const axios = require('axios');
const cheerio = require('cheerio');

async function scrapeProductList(url) {
  const { data: html } = await axios.get(url);
  const $ = cheerio.load(html);

  const products = [];
  $('.product-card').each((_, el) => {
    const $el = $(el);
    products.push({
      name: $el.find('.product-title').text().trim(),
      price: $el.find('.price').text().trim(),
      url: $el.find('a.title-link').attr('href'),
    });
  });

  return products;
}

scrapeProductList('https://tienda-ejemplo.com/categorias/electronica')
  .then(console.log)
  .catch(console.error);

Eso es todo el scraper base. Sin navegador headless, sin WebKit, sin 200 MB de RAM por tab. Cheerio parsea el HTML que el servidor ya renderizó — y en sitios SSR, eso es todo lo que necesitas.

Cuándo Cheerio es suficiente y cuándo necesitas un headless browser

La decisión clave: ¿el contenido que necesitas está en el HTML original que devuelve el servidor, o se genera dinámicamente con JavaScript en el cliente?

CriterioCheerio + axiosPuppeteer / Playwright
HTML renderizado en servidor (SSR, Next.js, Django)✅ Perfecto⚠️ Overkill
Contenido cargado vía AJAX/API✅ Si puedes llamar la API directamente✅ Alternativa
SPA pesada (React/Vue sin SSR)❌ No verás el contenido✅ Necesario
Páginas con CAPTCHA interactiva❌ No puedes resolverlas⚠️ Posible con plugins
Consumo de memoria por instancia~20 MB~200–400 MB
Velocidad por petición50–200 ms1–5 s

Regla práctica: si curl muestra el contenido que necesitas, Cheerio también lo hará. Si necesitas esperar renders o interacciones, pasa a Puppeteer.

Integrar proxies con axios

Para una única petición con proxy, axios soporta configuración directa:

const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');

const agent = new HttpsProxyAgent('http://user-country-US:mipassword@gate.proxyhat.com:8080');

const { data } = await axios.get('https://tienda-ejemplo.com/producto/123', {
  httpsAgent: agent,
  headers: { 'User-Agent': 'Mozilla/5.0 ...' },
});

Esto funciona para una petición. Pero cuando escalas a miles de URLs, necesitas rotación automática — y hardcodear el agente en cada llamada no escala. Necesitamos un interceptor de axios.

Interceptor de axios con rotación de proxies residenciales

Un interceptor de axios te permite inyectar configuración antes de cada petición. Lo usaremos para rotar IPs automáticamente, incluyendo geo-targeting y sesiones sticky cuando sea necesario.

const axios = require('axios');
const { HttpsProxyAgent } = require('https-proxy-agent');
const crypto = require('crypto');

// --- Configuración de ProxyHat ---
const PROXYHAT_USER = process.env.PROXYHAT_USER || 'miusuario';
const PROXYHAT_PASS = process.env.PROXYHAT_PASS || 'mipassword';
const PROXY_GATE = 'gate.proxyhat.com';
const PROXY_PORT = 8080;

/**
 * Genera la URL del proxy con flags opcionales.
 * - country: código ISO2 (US, DE, ES...)
 * - city: nombre de ciudad (solo algunos planes)
 * - session: ID de sesión sticky (mantiene la misma IP)
 */
function buildProxyUrl({ country, city, session } = {}) {
  let username = PROXYHAT_USER;
  if (country) username += `-country-${country}`;
  if (city) username += `-city-${city}`;
  if (session) username += `-session-${session}`;
  return `http://${username}:${PROXYHAT_PASS}@${PROXY_GATE}:${PROXY_PORT}`;
}

// --- Interceptor de rotación ---
function createRotatingProxyInterceptor(config = {}) {
  const { country, stickySession = false } = config;
  const session = stickySession ? crypto.randomUUID() : null;

  return (requestConfig) => {
    const proxyUrl = buildProxyUrl({ country, session });
    requestConfig.httpsAgent = new HttpsProxyAgent(proxyUrl);
    requestConfig.httpAgent = new HttpsProxyAgent(proxyUrl);
    return requestConfig;
  };
}

// --- Cliente axios reutilizable ---
function createScraperClient(options = {}) {
  const client = axios.create({ timeout: 15000 });
  client.interceptors.request.use(createRotatingProxyInterceptor(options));
  return client;
}

// Uso: cada petición obtiene una IP residencial diferente
const scraper = createScraperClient({ country: 'US' });
await scraper.get('https://tienda-ejemplo.com/producto/1');

Con esto, cada petición sale por una IP residencial diferente en el país especificado. Si necesitas mantener la misma IP durante varias peticiones (por ejemplo, para un carrito de compras), usa stickySession: true.

¿Por qué un interceptor y no un wrapper manual? Porque los interceptores se integran con el ecosistema de axios: retries, logging, circuit breaking — todo se encadena limpiamente sin modificar la lógica de negocio de tu scraper.

Scraping concurrente con p-limit

Si lanzas 10 000 peticiones en paralelo, vas a saturar tu conexión, tu memoria, y el servidor objetivo. p-limit te permite controlar la concurrencia sin sacrificar el modelo async/await.

const pLimit = require('p-limit');

// Máximo 15 peticiones concurrentes
const limit = pLimit(15);

async function scrapeUrl(client, url) {
  try {
    const { data } = await client.get(url);
    const $ = cheerio.load(data);
    return {
      name: $('h1.product-title').text().trim(),
      price: $('span.price').text().trim(),
      availability: $('span.stock').text().trim(),
    };
  } catch (err) {
    return { url, error: err.response?.status || err.message };
  }
}

async function scrapeAll(urls) {
  const scraper = createScraperClient({ country: 'US' });

  const tasks = urls.map((url) =>
    limit(() => scrapeUrl(scraper, url))
  );

  const results = await Promise.all(tasks);
  const successes = results.filter((r) => !r.error);
  const failures = results.filter((r) => r.error);

  console.log(`✅ ${successes.length} exitosos, ❌ ${failures.length} fallidos`);
  return { successes, failures };
}

Con pLimit(15), solo 15 peticiones están en vuelo en cualquier momento. Las demás esperan en la cola — sin backpressure manual, sin gestión de promesas compleja.

¿Cómo elegir la concurrencia?

  • 5–10: Sitios con rate limiting agresivo o Cloudflare avanzado.
  • 15–25: Balance razonable para la mayoría de e-commerce.
  • 30–50: Solo con proxies residenciales de alta calidad y targets sin anti-bot estricto.

Empieza en 10 y sube gradualmente monitorizando tu tasa de error.

Manejo de errores: 403, 429 y circuit breaking

Los errores HTTP no son excepciones — son señales. Un 429 significa «más despacio». Un 403 puede significar que tu IP fue bloqueada o que necesitas headers diferentes. Necesitas un strategy pattern, no un simple try/catch.

Interceptor de retry con rotación de IP

function createRetryInterceptor(client, options = {}) {
  const { maxRetries = 3, backoffBase = 1000 } = options;
  let consecutive429s = 0;
  let circuitOpen = false;
  let circuitOpenUntil = 0;

  return async (error) => {
    const status = error.response?.status;
    const config = error.config;

    // --- Circuit breaker ---
    if (circuitOpen && Date.now() < circuitOpenUntil) {
      throw new Error('Circuit breaker abierto — esperando cooldown');
    }
    if (circuitOpen && Date.now() >= circuitOpenUntil) {
      circuitOpen = false;
      consecutive429s = 0;
    }

    // --- Sin reintentos disponibles ---
    if (!config || config.__retryCount >= maxRetries) {
      throw error;
    }

    config.__retryCount = config.__retryCount || 0;
    config.__retryCount += 1;

    // --- 429: backoff exponencial + circuit breaker ---
    if (status === 429) {
      consecutive429s++;
      if (consecutive429s >= 5) {
        circuitOpen = true;
        circuitOpenUntil = Date.now() + 30_000; // 30 s cooldown
        console.warn('⚡ Circuit breaker activado — 5 x 429 consecutivos');
      }
      const delay = backoffBase * Math.pow(2, config.__retryCount);
      console.warn(`⏳ 429 recibido — reintentando en ${delay}ms (intento ${config.__retryCount})`);
      await new Promise((r) => setTimeout(r, delay));
    }

    // --- 403: forzar nueva IP rotando sesión ---
    if (status === 403) {
      const newSession = crypto.randomUUID();
      const proxyUrl = buildProxyUrl({ country: 'US', session: newSession });
      config.httpsAgent = new HttpsProxyAgent(proxyUrl);
      config.httpAgent = new HttpsProxyAgent(proxyUrl);
      console.warn(`🔄 403 recibido — rotando IP (sesión ${newSession.slice(0, 8)})`);
    }

    return client(config);
  };
}

// Integración con el cliente
function createScraperClient(options = {}) {
  const client = axios.create({ timeout: 15000 });
  client.interceptors.request.use(createRotatingProxyInterceptor(options));
  client.interceptors.response.use(
    (response) => { consecutive429s = 0; return response; },
    createRetryInterceptor(client, { maxRetries: 3 })
  );
  return client;
}

Este patrón cubre tres escenarios críticos:

  • 429 (rate limit): Backoff exponencial. Si recibes 5 consecutivos, el circuit breaker se abre por 30 segundos para evitar quemar tu cuota de proxies.
  • 403 (bloqueo de IP): Fuerza una nueva IP residencial generando un session ID nuevo — el proxy te asigna una IP diferente.
  • Errores de red: Reintento simple sin cambiar IP (puede ser un problema temporal).

Ejemplo real: scrapear 10 000 URLs de un e-commerce

Pongamos todo junto. El escenario: tienes un CSV con 10 000 URLs de productos de un e-commerce estático. Necesitas nombre, precio y disponibilidad de cada uno.

const fs = require('fs');
const pLimit = require('p-limit');
const cheerio = require('cheerio');
const { createScraperClient, buildProxyUrl } = require('./scraper-client');

// --- 1. Cargar URLs desde CSV ---
const urls = fs.readFileSync('product_urls.csv', 'utf8')
  .split('\n')
  .filter(Boolean);

console.log(`📋 ${urls.length} URLs cargadas`);

// --- 2. Configurar scraper con proxies US ---
const scraper = createScraperClient({ country: 'US' });
const limit = pLimit(15);

// --- 3. Función de parseo por URL ---
function parseProduct(html, url) {
  const $ = cheerio.load(html);
  return {
    url,
    name: $('h1.product-title').text().trim(),
    price: $('span.price-current').text().trim(),
    availability: $('[itemprop="availability"]').attr('content') || 'unknown',
    timestamp: new Date().toISOString(),
  };
}

// --- 4. Scrapear con concurrencia controlada ---
async function main() {
  const results = [];
  const errors = [];

  const tasks = urls.map((url) =>
    limit(async () => {
      try {
        const { data } = await scraper.get(url);
        const product = parseProduct(data, url);
        results.push(product);
      } catch (err) {
        errors.push({ url, status: err.response?.status, message: err.message });
      }
    })
  );

  await Promise.all(tasks);

  // --- 5. Guardar resultados ---
  fs.writeFileSync('products.json', JSON.stringify(results, null, 2));
  fs.writeFileSync('errors.json', JSON.stringify(errors, null, 2));

  console.log(`✅ ${results.length} productos guardados`);
  console.log(`❌ ${errors.length} errores`);
}

main();

Con 15 peticiones concurrentes y proxies residenciales rotando por petición, 10 000 URLs a ~200 ms cada una se completan en aproximadamente 13 minutos — sin bloqueos.

Patrones de escalado: de contenedores a flotas headless

Cuando tu volumen crece más allá de lo que un solo proceso puede manejar, necesitas arquitectura:

1. Contenedores Docker con un scraper por contenedor

Cada contenedor ejecuta una instancia de tu scraper con su propio pLimit. Distribuye URLs entre contenedores con una cola Redis o un archivo CSV particionado.

# docker-compose.yml fragmento
services:
  scraper-1:
    build: .
    environment:
      - PROXYHAT_USER=miusuario
      - PROXYHAT_PASS=mipassword
      - BATCH=0
    command: node scraper.js
  scraper-2:
    build: .
    environment:
      - PROXYHAT_USER=miusuario
      - PROXYHAT_PASS=mipassword
      - BATCH=1
    command: node scraper.js

2. Cola de trabajos con BullMQ

Para producción, usa BullMQ con Redis como backend de cola. Cada trabajo es una URL; los workers procesan con concurrencia configurada. Esto te da reintentos automáticos, priorización y dashboard de monitoreo.

3. Headless fleet para sitios dinámicos

Para el ~20% de sitios que requieren renderizado JavaScript, complementa Cheerio con Puppeteer. Usa puppeteer-core con chromium en Docker, conectado a través del mismo proxy residencial. La regla: Cheerio primero, Puppeteer solo cuando sea estrictamente necesario.

Mejores prácticas para scraping responsable

  • Respeta robots.txt: Si un path está bloqueado, no lo scrapees. Es la señal más clara de que el propietario no quiere scraping automatizado.
  • Limita tu concurrencia: Incluso con proxies residenciales, 50 peticiones concurrentes a un mismo dominio es agresivo. Mantén 10–25.
  • Implementa backoff: Si recibes 429, reduce velocidad — no escale paralelismo.
  • Identifícate: Usa un User-Agent descriptivo como MiBot/1.0 (+https://misitio.com/bot) cuando sea apropiado.
  • Cumple con GDPR/CCPA: No almacenes datos personales sin consentimiento. Verifica los términos de servicio del sitio.

Puntos clave

Cheerio + axios es suficiente para sitios SSR. No pagues el costo de un navegador headless si no lo necesitas. Verifica con curl primero.

La rotación de proxies residenciales es obligatoria a escala. Sin ella, tu IP será bloqueada después de cientos de peticiones. Con ProxyHat, cada petición obtiene una IP residencial diferente.

Los interceptores de axios son el lugar correcto para la lógica de proxy. Mantienen tu código de scraping limpio y separan la lógica de red de la lógica de parseo.

Controla la concurrencia con p-limit. No lances todo en paralelo. Empieza en 10–15 y ajusta según tu tasa de error.

Maneja 403 y 429 como señales, no como excepciones. Rota IP en 403, backoff en 429, circuit breaker si el problema persiste.

Si estás listo para llevar tu scraping al siguiente nivel, explora los planes de proxies residenciales de ProxyHat o consulta nuestra cobertura de ubicaciones para geo-targeting a nivel de ciudad.

¿Listo para empezar?

Accede a más de 50M de IPs residenciales en más de 148 países con filtrado impulsado por IA.

Ver preciosProxies residenciales
← Volver al Blog