El blog de LiveCommerce

Un blog de comercio electrónico y tiendas online

Tutorial: Cómo montar una API de scraping robusta y resistente a saturación con Node.js, Redis y PM2

1. Contexto de partida

Partíamos de un api.js sencillo con un endpoint /scrape en Express, lanzado con PM2 en modo cluster (14 instancias). El contenedor Debian se caía por saturación de RAM.

pm2 start cluster.js --name scraper-api --node-args="--max-old-space-size=1500"

2. Problemas detectados

  • Demasiadas peticiones simultáneas → OOM Killer.
  • Falta de visibilidad sobre peticiones activas.
  • Sin control de recursos: entraba todo.

3. Estrategia de defensa

ÁreaSoluciónHerramienta
RAMVerificar uso & rechazar > 40 GBsysteminformation
Concurrencia globalContador distribuidoRedis INCR/DECR
Historial atendidasÚltimas 50 URLsRedis LPUSH/LTRIM
RechazosÚltimas 100 URLs rechazadasRedis LPUSH/LTRIM
LatenciaMedir tiempos de scrapingRedis LPUSH/LTRIM

4. Instalación de dependencias

# SO
sudo apt update && sudo apt install nodejs npm redis-server -y

# Node
npm install express dotenv ioredis systeminformation

Habilitar y arrancar Redis:

sudo systemctl enable redis-server
sudo systemctl start redis-server
redis-cli ping   # → PONG

5. Código final (api.js)

Mostrar/ocultar
// api.js
require('dotenv').config();
const express = require('express');
const si      = require('systeminformation');
const Redis   = require('ioredis');
const scrape  = require('./scraper');

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
  maxRetriesPerRequest: null,
  reconnectOnError: () => true
});

const app  = express();
const PORT = process.env.PORT || 3000;

/* Control de RAM */
const memoryCheck = async () => {
  const mem = await si.mem();
  return (mem.active / 1024 / 1024 / 1024) > 40;
};

/* Endpoint principal */
app.get('/scrape', async (req, res) => {
  const { url, proxy } = req.query;
  const view = req.query.view === '1';
  if (!url) return res.status(400).json({ error: 'Falta ?url=' });

  if (await memoryCheck()) {
    await redis.lpush('scraper:rejectedUrls', url);
    await redis.ltrim('scraper:rejectedUrls', 0, 99);
    return res.status(503).json({ error: '? RAM demasiado alta. Rechazada.' });
  }

  await redis.incr('scraper:activeRequests');
  const start      = Date.now();
  const controller = new AbortController();
  const timeout    = setTimeout(() => controller.abort(), 60000);

  try {
    const result = await scrape(url, proxy, { abortSignal: controller.signal });

    await redis.lpush('scraper:lastUrls', url);
    await redis.ltrim('scraper:lastUrls', 0, 49);

    const elapsed = Date.now() - start;
    await redis.lpush('scraper:times', elapsed);
    await redis.ltrim('scraper:times', 0, 99);

    if (view) {
      res.set('Content-Type', 'text/html; charset=utf-8').send(result.body);
    } else {
      res.json({ ok: true, ...result });
    }
  } catch (e) {
    res.status(500).json({ error: controller.signal.aborted ? '⏱️ Timeout' : e.message });
  } finally {
    clearTimeout(timeout);
    redis.decr('scraper:activeRequests').catch(() => {});
  }
});

/* Endpoints de monitorización */
app.get('/status', async (_, res) => {
  const c = await redis.get('scraper:activeRequests');
  res.json({ activeRequests: Math.max(0, parseInt(c) || 0) });
});

app.get('/last-urls',     async (_, r) => r.json({ urls: await redis.lrange('scraper:lastUrls',     0, -1) }));
app.get('/rejected-urls', async (_, r) => r.json({ urls: await redis.lrange('scraper:rejectedUrls', 0, -1) }));
app.get('/avg-time',      async (_, r) => {
  const t = await redis.lrange('scraper:times', 0, -1);
  const avg = t.length ? Math.round(t.reduce((a,b)=>a+ +b,0) / t.length) : 0;
  r.json({ avgMs: avg });
});

app.listen(PORT, () => console.log(`? API en http://0.0.0.0:${PORT}`));

6. Despliegue con PM2

npm install -g pm2
pm2 start api.js -i 14 --name scraper-api --node-args="--max-old-space-size=1500"
pm2 save

7. Endpoints de control

  • /status → peticiones activas
  • /last-urls → últimas 50 URLs procesadas
  • /rejected-urls → últimas 100 rechazadas
  • /avg-time → latencia media (ms)

8. Pruebas rápidas

curl "http://localhost:3000/scrape?url=https://example.com&view=1"
curl "http://localhost:3000/status"
curl "http://localhost:3000/last-urls"
curl "http://localhost:3000/avg-time"

9. Solución a problemas comunes

  • Cannot find module 'systeminformation'npm install systeminformation
  • MaxRetriesPerRequestError → Redis no levantado — instala y arranca redis-server
  • Alias de servicio en Debian → usar redis-server.service
  • Contador negativo → mover decr() al finally y evitar doble decremento

10. Beneficios de esta arquitectura

  • Estabilidad: rechaza exceso antes de colapsar.
  • Observabilidad sin SSH.
  • Escala horizontal con PM2 + Redis.
  • Bajo coste: Redis local y dependencias ligeras.

11. Siguientes pasos opcionales

  • Bloqueo dinámico por IP abusiva.
  • Exportar métricas a Prometheus/Grafana.
  • Auto‑scaling de instancias según carga.
  • Persistir resultados en S3 + CDN.

12. Conclusión

Con este setup tu API de scraping se mantiene viva aun bajo alta carga. Si la demanda crece, solo necesitas escalar CPU/RAM o afinar límites. ¡A raspar sin miedo a los cuelgues!

Compártelo:

¿Tienes alguna consulta?

Si tienes alguna pregunta o sabes la respuesta sobre algún comentario, no dudes en contribuir.
Responderemos rápidamente.
Puedes utilizar etiquetas BBCode para escribir negrita, enlaces, imágenes, etc...
Más información en la página oficial de BBCOde http://www.bbcode.org/ Ejemplo:
[url=http://google.com]links[/url], [color=red]colores[/color] [b]negrita[/b]...

¿Has visto los videos en nuestro canal de Youtube?

En nuestro canal de Youtube publicamos periódicamente mejoras y funcionalidades del software de ecommerce.