Scraping Node.js : le guide complet avec Cheerio, Axios et rotation de proxys

Apprenez à construire un scraper Node.js performant avec Cheerio et Axios, intégrez la rotation de proxys résidentiels via un interceptor réutilisable, et passez à l'échelle avec p-limit pour scraper 10 000 URLs.

Scraping Node.js : le guide complet avec Cheerio, Axios et rotation de proxys

Pourquoi Cheerio et Axios suffisent pour 90 % de vos besoins

Si vous scrapez du HTML statique — pages produit e-commerce, résultats SERP, fiches d'annuaire — un navigateur headless est souvent surdimensionné. Cheerio parse le HTML côté serveur en quelques millisecondes, sans le coût d'un runtime Chromium. Axios gère les requêtes HTTP avec un système d'interceptors puissant, idéal pour injecter une rotation de proxys de manière transparente.

Le combo Node.js scraping Cheerio proxies est le choix le plus rationnel quand le contenu cible est rendu côté serveur (SSR). Vous économisez de la RAM, du CPU, et vous multipliez le débit par 10 à 50 comparé à Puppeteer.

Installation et premier scrape avec Axios + Cheerio

On commence par les dépendances minimales :

npm install axios cheerio https-proxy-agent p-limit

Voici un scrape basique qui récupère le titre et le prix d'une page produit :

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

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

  return {
    title: $('h1.product-title').text().trim(),
    price: $('span.price').text().trim(),
    availability: $('span.stock').text().trim(),
  };
}

scrapeProduct('https://example-shop.com/product/123')
  .then(console.log)
  .catch(console.error);

Pourquoi c'est rapide : pas de rendu CSS, pas de JavaScript à exécuter, pas de réseauaux de requêtes secondaires. Axios télécharge le HTML brut, Cheerio le parse avec l'API jQuery que vous connaissez déjà.

Intégration de proxys avec Axios

Configuration proxy native vs https-proxy-agent

Axios supporte une option proxy native, mais elle ne fonctionne pas pour les URLs HTTPS. C'est un piège classique. Pour du HTTPS à travers un proxy, il faut https-proxy-agent :

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

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

const client = axios.create({
  httpsAgent: agent,
  timeout: 15000,
  headers: {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Accept': 'text/html,application/xhtml+xml',
  },
});

client.get('https://example-shop.com/product/123')
  .then(({ data }) => console.log(data.length))
  .catch(console.error);

Avec ProxyHat, le ciblage géographique se fait dans le champ username : user-country-US pour les États-Unis, user-country-DE-city-berlin pour Berlin. Zéro configuration côté serveur — tout passe par l'URL du proxy.

Comparer les approches proxy

Méthode HTTPS Rotation automatique Complexité
axios.create({ proxy }) ❌ Non ❌ Manuelle Faible
https-proxy-agent ✅ Oui ❌ Manuelle Moyenne
Interceptor custom (voir ci-dessous) ✅ Oui ✅ Automatique Moyenne
Proxy pool externe (rotating gateway) ✅ Oui ✅ Intégrée Faible

Quand Cheerio suffit — et quand il faut un headless browser

C'est la question qui revient le plus souvent. La réponse dépend de comment le contenu est rendu :

  • HTML statique (SSR) — le contenu est dans la réponse HTTP initiale. Cheerio suffit. C'est le cas de la plupart des boutiques Shopify, Magento, PrestaShop, et des résultats Google classiques.
  • JavaScript-rendu (CSR) — le contenu est injecté par React/Vue/Angular après le chargement. Cheerio ne verra que le shell vide. Il faut Puppeteer ou Playwright.
  • Hybride — le HTML initial contient du contenu, mais des détails (prix dynamiques, stock temps réel) sont chargés en AJAX. Cheerio peut souvent récupérer l'essentiel ; complétez avec des appels API directs si nécessaire.

Règle empirique : faites un curl de la page. Si le contenu que vous voulez est dans la réponse, Cheerio est la bonne solution. Sinon, passez à un headless browser.

Rotation de proxys résidentiels en interceptor Axios

C'est ici que l'architecture devient intéressante. Plutôt que de gérer la rotation manuellement dans chaque appel, on crée un interceptor Axios qui sélectionne automatiquement un proxy différent à chaque requête — avec gestion des sessions sticky, du geo-targeting, et du fallback en cas d'erreur.

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

class ProxyRotator {
  constructor({ username, password, countries = [], sticky = false }) {
    this.username = username;
    this.password = password;
    this.countries = countries;
    this.sticky = sticky;
    this.sessionCounter = 0;
  }

  nextProxy() {
    this.sessionCounter++;
    const country = this.countries.length
      ? this.countries[this.sessionCounter % this.countries.length]
      : null;

    let userPart = this.username;
    if (country) userPart += `-country-${country}`;
    if (this.sticky) userPart += `-session-${this.sessionCounter}`;

    const proxyUrl = `http://${userPart}:${this.password}@gate.proxyhat.com:8080`;
    return new HttpsProxyAgent(proxyUrl);
  }
}

function createScraperClient(proxyConfig) {
  const rotator = new ProxyRotator(proxyConfig);
  const client = axios.create({ timeout: 15000 });

  client.interceptors.request.use((config) => {
    config.httpsAgent = rotator.nextProxy();
    config.httpAgent = rotator.nextProxy();
    return config;
  });

  return { client, rotator };
}

module.exports = { ProxyRotator, createScraperClient };

Ce pattern sépare la logique de rotation de la logique de scraping. Votre code métier n'a aucune connaissance du proxy — l'interceptor s'en charge. Pour du scraping massif, vous instanciez plusieurs clients avec des pays différents et vous les répartissez dans votre queue concurrente.

Scraping concurrent avec p-limit

Quand vous passez de 100 URLs à 10 000, la concurrence devient critique. Promise.all sur 10 000 requêtes va saturer la mémoire et déclencher des rate limits. p-limit contrôle exactement combien de requêtes s'exécutent en parallèle :

const pLimit = require('p-limit');
const { createScraperClient } = require('./proxy-rotator');
const cheerio = require('cheerio');

const CONCURRENCY = 25;
const limit = pLimit(CONCURRENCY);

const { client } = createScraperClient({
  username: 'YOUR_USER',
  password: 'YOUR_PASS',
  countries: ['FR', 'DE', 'US'],
  sticky: false,
});

async function scrapePage(url) {
  try {
    const { data } = await client.get(url);
    const $ = cheerio.load(data);
    return {
      url,
      title: $('h1').text().trim(),
      price: $('span.price').text().trim(),
      inStock: !$('.out-of-stock').length,
    };
  } catch (err) {
    return { url, error: err.message, status: err.response?.status };
  }
}

async function scrapeBatch(urls) {
  const tasks = urls.map((url) => limit(() => scrapePage(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} OK — ❌ ${failures.length} échouées`);
  return { successes, failures };
}

module.exports = { scrapePage, scrapeBatch };

La concurrence à 25 est un bon point de départ avec des proxys résidentiels. Augmentez progressivement et surveillez le taux d'erreur. Si les 429 augmentent, baissez la concurrence ou ajoutez un délai entre les requêtes.

Exemple complet : scrapper 10 000 URLs e-commerce avec rotation

Mettons tout ensemble. L'objectif : scraper un catalogue e-commerce de 10 000 pages produit, avec rotation de proxys, gestion d'erreur robuste, et circuit breaker.

const fs = require('fs');
const pLimit = require('p-limit');
const cheerio = require('cheerio');
const { createScraperClient } = require('./proxy-rotator');

const CONCURRENCY = 20;
const BATCH_SIZE = 500;
const MAX_RETRIES = 3;
const CIRCUIT_BREAKER_THRESHOLD = 50;

const { client } = createScraperClient({
  username: 'YOUR_USER',
  password: 'YOUR_PASS',
  countries: ['FR', 'DE', 'ES', 'IT', 'GB'],
  sticky: false,
});

let consecutiveErrors = 0;
let circuitOpen = false;

async function scrapeWithRetry(url, retries = MAX_RETRIES) {
  if (circuitOpen) throw new Error('Circuit breaker ouvert');

  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const { data, status } = await client.get(url, {
        headers: {
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
          'Accept-Language': 'fr-FR,fr;q=0.9',
        },
      });

      consecutiveErrors = 0;

      const $ = cheerio.load(data);
      return {
        url,
        title: $('h1.product-title').text().trim(),
        price: $('meta[property="product:price:amount"]').attr('content') ||
               $('span.price').text().trim(),
        availability: $('meta[property="product:availability"]').attr('content') || 'unknown',
      };
    } catch (err) {
      const status = err.response?.status;

      if (status === 403 || status === 429) {
        consecutiveErrors++;
        if (consecutiveErrors >= CIRCUIT_BREAKER_THRESHOLD) {
          circuitOpen = true;
          console.error('🔴 Circuit breaker déclenché — pause 60s');
          await new Promise((r) => setTimeout(r, 60000));
          circuitOpen = false;
          consecutiveErrors = 0;
        }
        const backoff = Math.min(1000 * Math.pow(2, attempt), 30000);
        console.warn(`⚠️ ${status} sur ${url} — retry ${attempt}/${retries} dans ${backoff}ms`);
        await new Promise((r) => setTimeout(r, backoff));
        continue;
      }

      if (status === 404) return { url, error: 'not_found', status };
      if (attempt === retries) return { url, error: err.message, status };
    }
  }
  return { url, error: 'max_retries_exceeded' };
}

async function main() {
  const urls = JSON.parse(fs.readFileSync('product-urls.json', 'utf-8'));
  console.log(`🚀 Démarrage : ${urls.length} URLs à scraper`);

  const limit = pLimit(CONCURRENCY);
  const allResults = [];

  for (let i = 0; i < urls.length; i += BATCH_SIZE) {
    const batch = urls.slice(i, i + BATCH_SIZE);
    const tasks = batch.map((url) => limit(() => scrapeWithRetry(url)));
    const results = await Promise.all(tasks);
    allResults.push(...results);

    const ok = results.filter((r) => !r.error).length;
    console.log(`📦 Batch ${Math.floor(i / BATCH_SIZE) + 1} : ${ok}/${results.length} OK`);

    fs.writeFileSync(
      `results-batch-${i}.json`,
      JSON.stringify(results, null, 2)
    );

    await new Promise((r) => setTimeout(r, 2000));
  }

  const successCount = allResults.filter((r) => !r.error).length;
  console.log(`✅ Terminé : ${successCount}/${urls.length} produits scrapés`);
  fs.writeFileSync('results-final.json', JSON.stringify(allResults, null, 2));
}

main().catch(console.error);

Ce script gère les trois problèmes majeurs du scraping à grande échelle :

  • Rotation de proxys — chaque requête passe par un IP résidentielle différente via l'interceptor.
  • Backoff exponentiel — les erreurs 403/429 déclenchent un retry avec délai croissant.
  • Circuit breaker — après 50 erreurs consécutives, le script s'interrompt 60 secondes pour laisser respirer la cible et le pool de proxys.

Gestion avancée des erreurs : 403, 429 et circuit breaking

Les sites anti-bot renvoient des 403 Forbidden ou 429 Too Many Requests. Votre stratégie doit être nuancée :

  • 403 isolé — l'IP a été flaguée. La rotation de proxys résout le problème naturellement.
  • 403 en rafale — le site a détecté un pattern. Réduisez la concurrence et ajoutez de la randomisation dans les headers et les délais.
  • 429 — rate limit atteint. Backoff exponentiel obligatoire. Les proxys résidentiels répartissent la charge sur des milliers d'IPs, ce qui rend les 429 rares.
  • Circuit breaker — quand le taux d'erreur dépasse un seuil, arrêtez tout temporairement. C'est mieux que de gaspiller des crédits proxy sur une cible qui vous bloque.

Exemple curl avec proxy ProxyHat

Pour tester rapidement avant de coder :

curl -x http://user-country-FR:PASSWORD@gate.proxyhat.com:8080 \
  https://example-shop.com/product/123

Patterns de scaling : conteneurs, queues et monitoring

Quand votre liste d'URLs dépasse les 100 000, un seul process Node.js ne suffit plus. Voici les patterns qui fonctionnent :

Architecture multi-conteneurs

Lancez N instances Docker identiques, chacune avec son propre ProxyRotator. Répartissez les URLs avec un modulo sur l'ID du conteneur :

  • Container 0 : URLs 0, N, 2N…
  • Container 1 : URLs 1, N+1, 2N+1…
  • Et ainsi de suite.

Pas besoin de message broker — un simple slice sur le fichier d'entrée suffit.

Queue persistante avec Redis

Pour les jobs longue durée, utilisez BullMQ (basé sur Redis) comme queue. Chaque job est une URL à scraper. Si un worker crash, le job retourne dans la queue. C'est la solution de production pour les pipelines de scraping qui tournent 24/7.

Métriques essentielles

Surveillez au minimum :

  • Taux de succès HTTP — ciblez > 95 % avec des proxys résidentiels.
  • Latence P50/P95 — les proxys résidentiels ajoutent 200-800ms ; c'est normal.
  • Taux d'erreur par code — distinguez les 404 (normaux) des 403/429 (problème de proxy ou de rate limit).
  • Débit (pages/minute) — votre métrique de productivité finale.

Points clés à retenir

  • Cheerio suffit pour le HTML statique — 50x plus rapide et léger qu'un headless browser. Vérifiez avec curl avant de choisir votre outil.
  • L'interceptor Axios est le bon endroit pour la rotation de proxys — votre code métier reste propre et testable.
  • p-limit contrôle la concurrence — ne lancez jamais 10 000 Promise.all sans limite.
  • Le circuit breaker vous protège — arrêtez d'insister quand la cible vous bloque, reprenez après une pause.
  • Les proxys résidentiels éliminent la plupart des 403 — chaque requête vient d'une IP réelle différente, pas d'un range de datacenter flagué.
  • Sauvegardez par batch — ne perdez pas 9 000 résultats si le process crash au bout de la 9 001e URL.

Pour aller plus loin sur l'intégration des proxys dans vos pipelines de scraping, consultez notre guide sur les cas d'usage de scraping ou explorez les tarifs ProxyHat pour trouver le plan adapté à votre volume.

Prêt à commencer ?

Accédez à plus de 50M d'IPs résidentielles dans plus de 148 pays avec filtrage IA.

Voir les tarifsProxies résidentiels
← Retour au Blog