Obtener una página con una API de web scraping es una sola línea de código. Obtener cincuenta mil páginas cada noche, sin sobrepasar los límites de tu plan, sin perder trabajo cuando el proceso falla o sin descartar errores en silencio, es un problema de ingeniería. Node.js encaja bien para esto: el trabajo consiste casi por completo en esperar a la red, que es precisamente lo que el bucle de eventos de Node hace bien.
Esta guía construye un pequeño pipeline con forma de producción en Node.js sobre la Web Scraping API de Shifter: concurrencia acotada, reintentos para los errores correctos, salida reanudable y un registro de errores separado. Todo ello son unas sesenta líneas sin dependencias, y lo hemos probado contra una simulación de la API. Si aún no has hecho una primera petición, empieza por nuestra guía para principiantes sobre una API de web scraping.
La forma del pipeline
Un pipeline de scraping tiene cuatro tareas:
- Una cola de URLs que obtener.
- Workers que toman URLs de la cola y llaman a la API, un número fijo a la vez.
- Gestión de errores que reintenta lo que pueda tener éxito más tarde y registra lo que no puede.
- Una salida que sobrevive a los fallos y permite que una repetición omita el trabajo ya terminado.
La API se encarga de los proxies, los navegadores y algunos reintentos por su parte. El trabajo del pipeline es alimentarla al ritmo correcto y mantener un registro fiable de lo ocurrido.
Concurrencia: workers, no Promise.all sobre todo
El error más común en Node.js es mapear cada URL a una petición y hacer await Promise.all(...) sobre todas ellas. Con cincuenta mil URLs, eso dispara cincuenta mil peticiones a la vez. La API responde a la mayoría con 429 Too Many Requests, porque cada plan tiene un límite de concurrencia (en Shifter, de 20 a 500 peticiones simultáneas según el plan).
La solución es un grupo fijo de workers que comparten una única cola. Cada worker toma la siguiente URL, la procesa y vuelve a por otra, de modo que exactamente concurrency peticiones están en vuelo en todo momento. No hace falta ninguna librería; un array compartido y un bucle bastan.
Reintentos: solo para errores que pueden tener éxito más tarde
No todos los errores merecen un reintento. Un timeout, un renderizado fallido, un 429 o un error de servidor pueden tener éxito en el siguiente intento. Una clave de API incorrecta o una petición malformada fallarán siempre, y reintentarlas solo desperdicia tiempo. El pipeline reintenta 408, 422, 429 y 500 con una pausa que crece en cada intento, y registra todo lo demás como fallo de inmediato. Nuestra guía sobre reintentos y backoff para web scrapers explica por qué la pausa debe crecer.
Salida: solo adición y reanudable
Cada resultado correcto se añade a un archivo JSON delimitado por saltos de línea (NDJSON) en cuanto llega: una línea por URL con los datos y una marca de tiempo. Eso tiene tres ventajas:
- Un fallo pierde, como mucho, las peticiones en vuelo. Todo lo ya escrito permanece.
- Las repeticiones son baratas. Al iniciar, el pipeline lee el archivo de salida y omite cualquier URL que ya esté ahí, de modo que puedes reiniciar una ejecución fallida sin pagar dos veces por las mismas páginas.
- Los fallos se guardan por separado en un archivo
.errors, que puedes revisar o reintroducir en la siguiente ejecución.
Diseñar de modo que una repetición produzca el mismo resultado es la idea central detrás de los scrapers idempotentes, que lo cubre con más detalle.
El código
import { appendFile, readFile } from 'node:fs/promises';
const API = process.env.SCRAPER_API_URL ?? 'https://scrape.shifter.io/v1';
const RETRY = new Set([408, 422, 429, 500]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fetch one URL through the Web Scraping API, retrying only errors that can succeed later.
export async function fetchPage(url, { extractRules, attempts = 3, backoffMs = 2000 } = {}) {
const params = new URLSearchParams({ api_key: process.env.SHIFTER_SCRAPER_KEY, url });
if (extractRules) params.set('extract_rules', JSON.stringify(extractRules));
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${API}?${params}`, { signal: AbortSignal.timeout(120_000) });
if (res.ok) return extractRules ? res.json() : res.text();
if (!RETRY.has(res.status) || attempt === attempts) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
await sleep(backoffMs * attempt);
}
}
// URLs already written to the output file, so a restarted run skips them.
async function alreadyDone(outFile) {
try {
const lines = (await readFile(outFile, 'utf8')).split('\n').filter(Boolean);
return new Set(lines.map((line) => JSON.parse(line).url));
} catch {
return new Set();
}
}
// Run `concurrency` workers over the queue; each result becomes one NDJSON line.
export async function runPipeline(urls, { outFile, concurrency = 10, extractRules, backoffMs } = {}) {
const done = await alreadyDone(outFile);
const queue = urls.filter((u) => !done.has(u));
const stats = { skipped: urls.length - queue.length, ok: 0, failed: 0 };
async function worker() {
for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
try {
const data = await fetchPage(url, { extractRules, backoffMs });
await appendFile(outFile, JSON.stringify({ url, data, at: new Date().toISOString() }) + '\n');
stats.ok++;
} catch (err) {
await appendFile(`${outFile}.errors`, JSON.stringify({ url, error: err.message }) + '\n');
stats.failed++;
}
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return stats;
}
Solo usa el fetch integrado de Node, AbortSignal.timeout y fs/promises, de modo que funciona en cualquier versión actual de Node.js sin instalar nada.
Ejecutándolo sobre una lista de páginas de producto, pidiendo a la API JSON en lugar de HTML:
import { readFile } from 'node:fs/promises';
import { runPipeline } from './pipeline.mjs';
const urls = (await readFile('urls.txt', 'utf8')).split('\n').filter(Boolean);
const stats = await runPipeline(urls, {
outFile: 'products.ndjson',
concurrency: 20, // stay under your plan's concurrency limit
extractRules: {
name: { selector: 'h1', output: 'text' },
price: { selector: '.price', output: 'text' },
},
});
console.log(stats); // { skipped, ok, failed }
Cómo lo hemos probado
Ejecutamos el pipeline contra un sustituto local de la API que servía 22 URLs: veinte páginas normales, una que fallaba dos veces con 429 antes de tener éxito, y otra que siempre devolvía 401. La prueba comprobó que:
- 21 páginas tuvieron éxito y un fallo quedó registrado en el archivo de errores;
- la página inestable se reintentó y tuvo éxito en su tercer intento, mientras que la de
401solo se intentó una vez; - nunca hubo más de cinco peticiones en vuelo con
concurrency: 5; - una segunda ejecución omitió las 21 páginas terminadas y solo reintentó el fallo.
Escalar más allá
El diseño de un solo proceso descrito arriba maneja cómodamente decenas de miles de páginas por ejecución. Más allá de eso:
- Saca la cola de la memoria. Una tabla de base de datos o una cola de mensajes permite que varios procesos compartan el trabajo y sobrevivan a reinicios de toda la máquina.
- Carga los resultados en una base de datos a medida que llegan. NDJSON es un buen formato de aterrizaje; nuestra guía sobre mover datos de una web scraping API a SQL cubre el paso de carga.
- Aplica contrapresión. Si el paso posterior a la obtención es más lento que la propia obtención, deja que frene a los workers en lugar de dejar que los resultados se acumulen en memoria. Consulta contrapresión y control de flujo en crawlers distribuidos.
- Vigila las cifras. Registra aciertos, fallos y reintentos por ejecución, y avisa cuando la tasa de fallos se mueva. Nuestra guía sobre monitorización de un pipeline de scraping enumera las métricas que importan.
Preguntas frecuentes
¿Es Node.js bueno para pipelines de web scraping?
Sí, para pipelines construidos sobre una API. El trabajo consiste principalmente en esperar respuestas de red, y Node.js maneja muchas peticiones concurrentes en un solo proceso con poca sobrecarga. El análisis intensivo de CPU se hace mejor con las reglas de extracción de la API o en un paso separado.
¿Cuántas peticiones concurrentes debería hacer un scraper de Node.js?
Tantas como permita tu plan de la API de scraping, y no más. Usa un grupo fijo de workers ajustado a ese límite, y trata las respuestas 429 como una señal para frenar.
¿Cómo hago que un trabajo de scraping sea reanudable?
Escribe cada resultado en cuanto llega en un archivo o tabla de solo adición indexado por URL, y al iniciar omite cualquier URL que ya tenga un resultado. Un fallo entonces solo cuesta las peticiones que estaban en vuelo.
¿Necesito una librería de colas?
No para un solo proceso. Un array compartido con un número fijo de workers asíncronos basta. Pasa a una base de datos o cola de mensajes cuando varios procesos o máquinas necesiten compartir el trabajo.
La conclusión
Un pipeline de scraping escalable en Node.js necesita cuatro cosas: un grupo fijo de workers dimensionado según la concurrencia de tu plan, reintentos solo para errores que puedan tener éxito más tarde, una salida de solo adición desde la que una repetición pueda reanudarse, y un registro separado de los fallos. El código anterior hace las cuatro cosas en unas sesenta líneas. La API se encarga de los proxies y los navegadores; el trabajo de tu pipeline es alimentarla de forma constante y no perder nunca el rastro de lo ocurrido.