Récupérer une page avec une API de web scraping, c’est une ligne de code. Récupérer cinquante mille pages chaque nuit, sans dépasser les limites de votre plan, sans perdre de travail quand le processus plante ou sans masquer silencieusement des échecs, c’est un problème d’ingénierie. Node.js s’y prête bien : le travail consiste presque entièrement à attendre le réseau, ce qui est précisément ce que la boucle d’événements de Node fait bien.
Ce guide construit un petit pipeline, conçu comme pour la production, en Node.js au-dessus de l’API de Web Scraping de Shifter : concurrence bornée, retries pour les bonnes erreurs, sortie reprenable et journal d’erreurs séparé. Le tout fait environ soixante lignes sans dépendances, et nous l’avons testé contre un mock de l’API. Si vous n’avez pas encore fait une première requête, commencez par notre guide du débutant sur une API de web scraping.
La forme du pipeline
Un pipeline de scraping a quatre tâches :
- Une file d’attente d’URLs à récupérer.
- Des workers qui prennent des URLs dans la file et appellent l’API, un nombre fixe à la fois.
- Une gestion des erreurs qui retente ce qui peut réussir plus tard et enregistre ce qui ne le peut pas.
- Une sortie qui survit aux plantages et permet à une relance de sauter le travail déjà fait.
L’API s’occupe des proxies, des navigateurs et de certains retries de son côté. Le travail du pipeline est de l’alimenter au bon rythme et de garder un enregistrement fiable de ce qui s’est passé.
Concurrence : des workers, pas un Promise.all sur tout
L’erreur Node.js la plus courante est de faire correspondre chaque URL à une requête et de faire un await Promise.all(...) sur l’ensemble. Avec cinquante mille URLs, cela déclenche cinquante mille requêtes d’un coup. L’API répond à la plupart d’entre elles par 429 Too Many Requests, car chaque plan a une limite de concurrence (chez Shifter, de 20 à 500 requêtes simultanées selon le plan).
La solution est un pool fixe de workers partageant une seule file d’attente. Chaque worker prend la prochaine URL, la traite, puis revient en chercher une autre, de sorte qu’exactement concurrency requêtes sont en vol à tout moment. Aucune bibliothèque n’est nécessaire ; un tableau partagé et une boucle suffisent.
Retries : uniquement pour les erreurs qui peuvent réussir plus tard
Toutes les erreurs ne méritent pas un retry. Un timeout, un rendu échoué, un 429 ou une erreur serveur peuvent réussir à la tentative suivante. Une clé API invalide ou une requête malformée échoueront pour toujours, et les retenter ne fait que perdre du temps. Le pipeline retente 408, 422, 429 et 500 avec une pause qui s’allonge à chaque tentative, et enregistre immédiatement tout le reste comme un échec. Notre guide sur le retry et le backoff pour les scrapers web explique pourquoi la pause doit s’allonger.
Sortie : en ajout seul et reprenable
Chaque résultat réussi est ajouté à un fichier JSON délimité par des sauts de ligne (NDJSON) dès son arrivée : une ligne par URL avec les données et un horodatage. Cela présente trois avantages :
- Un plantage ne perd au maximum que les requêtes en vol. Tout ce qui est déjà écrit reste.
- Les relances sont peu coûteuses. Au démarrage, le pipeline lit le fichier de sortie et saute toute URL déjà présente, ce qui permet de relancer une exécution échouée sans repayer pour les mêmes pages deux fois.
- Les échecs sont conservés séparément dans un fichier
.errors, que vous pouvez examiner ou réinjecter dans l’exécution suivante.
Concevoir le système de sorte qu’une relance produise le même résultat est l’idée centrale derrière les scrapers idempotents, qui approfondit ce sujet.
Le code
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;
}
Il n’utilise que le fetch intégré de Node, AbortSignal.timeout et fs/promises, donc il fonctionne sur n’importe quelle version récente de Node.js sans rien installer.
En l’exécutant sur une liste de pages produits, en demandant à l’API du JSON plutôt que du 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 }
Comment nous l’avons testé
Nous avons exécuté le pipeline contre un substitut local de l’API servant 22 URLs : vingt pages normales, une qui échouait deux fois avec 429 avant de réussir, et une qui renvoyait toujours 401. Le test vérifiait que :
- 21 pages réussissaient et un échec était enregistré dans le fichier d’erreurs ;
- la page instable était retentée et réussissait à sa troisième tentative, tandis que le
401n’était essayé qu’une seule fois ; - jamais plus de cinq requêtes n’étaient en vol avec
concurrency: 5; - une seconde exécution sautait les 21 pages terminées et ne retentait que l’échec.
Aller plus loin
La conception mono-processus décrite ci-dessus gère confortablement des dizaines de milliers de pages par exécution. Au-delà :
- Sortez la file d’attente de la mémoire. Une table de base de données ou une file de messages permet à plusieurs processus de partager le travail et de survivre aux redémarrages de la machine entière.
- Chargez les résultats dans une base de données au fur et à mesure. NDJSON est un bon format d’atterrissage ; notre guide sur le transfert des données d’une API de web scraping vers SQL couvre l’étape de chargement.
- Appliquez une contre-pression. Si l’étape qui suit la récupération est plus lente que celle-ci, laissez-la ralentir les workers plutôt que de laisser les résultats s’accumuler en mémoire. Voir contre-pression et contrôle de flux dans les crawlers distribués.
- Surveillez les chiffres. Suivez les succès, les échecs et les retries par exécution, et alertez quand le taux d’échec évolue. Notre guide sur la surveillance d’un pipeline de scraping liste les métriques qui comptent.
FAQ
Node.js convient-il aux pipelines de web scraping ?
Oui, pour les pipelines construits sur une API. Le travail consiste surtout à attendre des réponses réseau, et Node.js gère de nombreuses requêtes concurrentes dans un seul processus avec peu de surcharge. L’analyse gourmande en CPU est mieux effectuée par les règles d’extraction de l’API ou dans une étape séparée.
Combien de requêtes concurrentes un scraper Node.js doit-il faire ?
Autant que votre plan d’API de scraping le permet, et pas plus. Utilisez un pool fixe de workers réglé sur cette limite, et traitez les réponses 429 comme un signal pour ralentir.
Comment rendre un job de scraping reprenable ?
Écrivez chaque résultat dès son arrivée dans un fichier ou une table en ajout seul, indexé par URL, et au démarrage sautez toute URL qui a déjà un résultat. Un plantage ne coûte alors que les requêtes qui étaient en vol.
Ai-je besoin d’une bibliothèque de file d’attente ?
Pas pour un seul processus. Un tableau partagé avec un nombre fixe de workers asynchrones suffit. Passez à une base de données ou une file de messages lorsque plusieurs processus ou machines doivent partager le travail.
En résumé
Un pipeline de scraping évolutif en Node.js a besoin de quatre choses : un pool fixe de workers dimensionné à la limite de concurrence de votre plan, des retries uniquement pour les erreurs qui peuvent réussir plus tard, une sortie en ajout seul dont une relance peut repartir, et un enregistrement séparé des échecs. Le code ci-dessus fait les quatre en environ soixante lignes. L’API gère les proxies et les navigateurs ; le travail de votre pipeline est de l’alimenter régulièrement et de ne jamais perdre de vue ce qui s’est passé.