Scraping con Node.js e Cheerio: Guida Completa con Proxy Rotation

Scopri come costruire uno scraper Node.js produttivo con Cheerio e axios, integrando proxy rotanti come interceptor riutilizzabile, concorrenza con p-limit e gestione errori avanzata.

Scraping con Node.js e Cheerio: Guida Completa con Proxy Rotation

Perché Cheerio + axios è lo stack ideale per lo scraping leggero

Se stai estraendo dati da siti con HTML statico — cataloghi e-commerce, directory, pagine SERP — non hai bisogno di un browser headless. Cheerio carica HTML lato server con la sintassi jQuery che già conosci, consumando una frazione della RAM e della CPU di Puppeteer. In combinazione con axios per le richieste HTTP e un pool di proxy residenziali rotanti, ottieni uno scraper veloce, scalabile e resistente ai blocchi.

In questa guida costruiremo un sistema di scraping completo: dall'interceptor axios per la rotazione proxy fino al crawling concorrente di 10.000 URL, passando per gestione di 403/429 e circuit breaker.

Setup: axios + Cheerio per il parsing HTML lato server

Cheerio non esegue JavaScript né renderizza CSS. Per siti server-side rendered (SSR) questo è esattamente ciò che ti serve: il payload HTML è già completo nella risposta HTTP, e Cheerio lo analizza in millisecondi.

npm install axios cheerio p-limit

Il flusso minimo è semplice:

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

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

  return {
    title: $('h1.product-title').text().trim(),
    price: parseFloat($('span.price').text().replace(/[^0-9.,]/g, '')),
    availability: $('span.stock').text().trim(),
  };
}

// Utilizzo
scrapeProduct('https://example-store.com/product/1234')
  .then(console.log)
  .catch(console.error);

Nessun browser, nessun DOM reale, nessun attesa di network idle. Per pagine SSR questo è tutto ciò che serve.

Integrare i proxy con axios

axios supporta la configurazione proxy nativa tramite proxy nel config, ma ha limitazioni: non funziona bene con HTTPS verso proxy HTTP, e non supporta SOCKS5. Per un'integrazione robusta usiamo https-proxy-agent (o socks-proxy-agent per SOCKS5).

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

// Configurazione proxy ProxyHat — HTTP
const agent = new HttpsProxyAgent('http://USERNAME:PASSWORD@gate.proxyhat.com:8080');

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

// Targeting geografico: IP residenziale dagli USA
const usAgent = new HttpsProxyAgent(
  'http://user-country-US:PASSWORD@gate.proxyhat.com:8080'
);

// IP residenziale da Berlino
const deAgent = new HttpsProxyAgent(
  'http://user-country-DE-city-berlin:PASSWORD@gate.proxyhat.com:8080'
);

Con ProxyHat puoi specificare nazione e città direttamente nello username — ogni richiesta esce da un IP residenziale diverso nella località scelta.

Quando Cheerio basta e quando serve un browser headless

Non tutti i siti sono uguali. La scelta dello strumento dipende da come il contenuto arriva al browser:

Tipo di sito Rendering Strumento consigliato Overhead
E-commerce SSR, blog, directory HTML completo nel response Cheerio + axios Basso (~2 MB RAM)
SPA (React/Vue) con API JSON Contenuto caricato via JS Chiamata diretta all'API (se individuabile) Basso
SPA senza API accessibili JS-rendered, nessun endpoint API Puppeteer / Playwright Alto (~300 MB RAM)
Siti con anti-bot avanzato (Cloudflare JS challenge) Challenge JS prima del contenuto Puppeteer stealth + proxy residenziali Molto alto

Regola pratica: se curl URL restituisce il contenuto che ti serve, Cheerio è sufficiente. Se il contenuto appare solo dopo l'esecuzione JS, passa a un browser headless o cerca l'endpoint API sottostante.

Prima di costruire uno scraper complesso, ispeziona sempre il traffico di rete nel DevTools. Spesso i siti SSR espongono API JSON pulite che rendono lo scraping con Cheerio superfluo — basta un semplice axios.get() sull'endpoint API.

Proxy rotanti come interceptor axios riutilizzabile

Il pattern più pulito per integrare la rotazione proxy è un axios interceptor. Questo ti permette di mantenere la logica di rotazione separata dalla logica di business, e di riutilizzarla in qualsiasi progetto.

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

/**
 * Crea un'istanza axios con rotazione proxy automatica.
 * Ogni richiesta esce da un IP residenziale diverso.
 */
function createRotatingClient({ username, password, country, maxRetries = 3 }) {
  const client = axios.create({ timeout: 15000 });

  client.interceptors.request.use((config) => {
    // Genera un session ID univoco per ogni richiesta = IP diverso
    const sessionId = `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    const proxyUser = country
      ? `${username}-country-${country}-session-${sessionId}`
      : `${username}-session-${sessionId}`;

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

    config.httpsAgent = agent;
    config.httpAgent = agent;
    config.metadata = { sessionId, retryCount: 0 };

    return config;
  });

  // Interceptor di retry con rotazione proxy su errori specifici
  client.interceptors.response.use(
    (response) => response,
    async (error) => {
      const config = error.config;
      const retryCount = config.metadata?.retryCount || 0;

      const shouldRetry =
        retryCount < maxRetries &&
        (error.response?.status === 403 ||
         error.response?.status === 429 ||
         error.code === 'ECONNRESET' ||
         error.code === 'ETIMEDOUT');

      if (!shouldRetry) return Promise.reject(error);

      // Attesa esponenziale + jitter
      const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
        + Math.random() * 1000;

      await new Promise((r) => setTimeout(r, delay));

      // Nuovo session ID = nuovo IP residenziale
      const sessionId = `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
      const proxyUser = country
        ? `${username}-country-${country}-session-${sessionId}`
        : `${username}-session-${sessionId}`;

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

      config.httpsAgent = agent;
      config.httpAgent = agent;
      config.metadata.retryCount = retryCount + 1;
      config.metadata.sessionId = sessionId;

      return client(config);
    }
  );

  return client;
}

module.exports = { createRotatingClient };

L'interceptor gestisce due cose critiche: rotazione IP ad ogni richiesta (via session ID univoco) e retry automatico su 403/429 con backoff esponenziale. Ogni retry ottiene un IP nuovo, quindi il sito target vede traffico da dispositivi diversi.

Scraping concorrente con p-limit

Per elaborare migliaia di URL, l'elaborazione sequenziale è troppo lenta. Ma senza limiti, 10.000 richieste simultanee esauriscono memoria e socket. p-limit offre un modello semplice: coda con concorrenza controllata, senza la complessità di un sistema di job.

const cheerio = require('cheerio');
const pLimit = require('p-limit');
const { createRotatingClient } = require('./rotating-client');

const client = createRotatingClient({
  username: 'user-country-US',
  password: 'YOUR_PASSWORD',
  country: 'US',
  maxRetries: 3,
});

async function scrapePage(url) {
  try {
    const { data: html } = await client.get(url);
    const $ = cheerio.load(html);

    return {
      url,
      title: $('h1').text().trim(),
      price: $('span.price-current').text().trim(),
      inStock: !$('.out-of-stock').length,
      timestamp: new Date().toISOString(),
    };
  } catch (err) {
    return { url, error: true, status: err.response?.status, message: err.message };
  }
}

async function scrapeBulk(urls, concurrency = 20) {
  const limit = pLimit(concurrency);
  const results = await Promise.all(
    urls.map((url) => limit(() => scrapePage(url)))
  );

  const succeeded = results.filter((r) => !r.error);
  const failed = results.filter((r) => r.error);

  console.log(`Completate: ${succeeded.length}, Fallite: ${failed.length}`);
  return { succeeded, failed };
}

module.exports = { scrapePage, scrapeBulk };

Con concurrency = 20 hai 20 richieste in volo contemporaneamente. Ogni richiesta usa un IP residenziale diverso grazie all'interceptor. Se il sito risponde con un 429, l'interceptor fa retry con un IP nuovo e backoff esponenziale — tutto trasparente per il chiamante.

Esempio reale: catalogo e-commerce con 10.000 URL

Mettiamo tutto insieme per un caso reale: monitoraggio prezzi su un e-commerce che espone HTML statico. Il flusso è: (1) scoprire gli URL dei prodotti dalle pagine categoria, (2) scaricare ogni pagina prodotto in concorrenza, (3) estrarre i dati con Cheerio, (4) salvare i risultati.

Step 1 — Scoprire gli URL dei prodotti

const fs = require('fs');
const cheerio = require('cheerio');
const pLimit = require('p-limit');
const { createRotatingClient } = require('./rotating-client');

const client = createRotatingClient({
  username: 'user-country-US',
  password: 'YOUR_PASSWORD',
  country: 'US',
});

// Step 1: Raccogliere tutti gli URL prodotto dalle pagine categoria
async function discoverProductUrls(categoryPages) {
  const limit = pLimit(5); // Più lento: le pagine categoria sono poche ma pesanti
  const allUrls = new Set();

  await Promise.all(
    categoryPages.map((catUrl) =>
      limit(async () => {
        try {
          const { data } = await client.get(catUrl);
          const $ = cheerio.load(data);

          // Paginazione: trova il numero di pagine
          const lastPage = parseInt($('.pagination li:last-child a').text()) || 1;

          // Raccogli URL dalla prima pagina
          $('a.product-link').each((_, el) => {
            allUrls.add(new URL($(el).attr('href'), catUrl).href);
          });

          // Scarica le pagine successive
          for (let p = 2; p <= lastPage; p++) {
            const { data: pageData } = await client.get(`${catUrl}?page=${p}`);
            const $$ = cheerio.load(pageData);
            $$('a.product-link').each((_, el) => {
              allUrls.add(new URL($$(el).attr('href'), catUrl).href);
            });
            // Pausa tra le pagine per rispettare il rate limit
            await new Promise((r) => setTimeout(r, 500));
          }
        } catch (err) {
          console.error(`Errore su ${catUrl}: ${err.message}`);
        }
      })
    )
  );

  return [...allUrls];
}

// Step 2: Scraping concorrente con circuit breaker

class CircuitBreaker {
  constructor({ threshold = 0.5, window = 100, cooldown = 30000 }) {
    this.failures = 0;
    this.successes = 0;
    this.threshold = threshold;
    this.window = window;
    this.cooldown = cooldown;
    this.openUntil = 0;
  }

  get isOpen() {
    return Date.now() < this.openUntil;
  }

  recordSuccess() {
    this.successes++;
    this._maybeReset();
  }

  recordFailure() {
    this.failures++;
    this._maybeReset();
    if (this.failures / (this.failures + this.successes) > this.threshold
        && (this.failures + this.successes) >= this.window) {
      this.openUntil = Date.now() + this.cooldown;
      console.warn(`[CircuitBreaker] APERTO — pausa di ${this.cooldown / 1000}s`);
    }
  }

  _maybeReset() {
    if (this.failures + this.successes >= this.window) {
      this.failures = 0;
      this.successes = 0;
    }
  }
}

async function scrapeBulk(urls, concurrency = 20) {
  const limit = pLimit(concurrency);
  const breaker = new CircuitBreaker({ threshold: 0.3, window: 50, cooldown: 60000 });
  const results = [];
  const errors = [];

  const tasks = urls.map((url) =>
    limit(async () => {
      // Se il breaker è aperto, aspetta
      while (breaker.isOpen) {
        await new Promise((r) => setTimeout(r, 5000));
      }

      try {
        const { data } = await client.get(url);
        const $ = cheerio.load(data);

        const product = {
          url,
          title: $('h1.product-title').text().trim(),
          price: parseFloat($('span.price').text().replace(/[^0-9.]/g, '')),
          inStock: !$('.out-of-stock').length,
          timestamp: new Date().toISOString(),
        };

        breaker.recordSuccess();
        results.push(product);
      } catch (err) {
        breaker.recordFailure();
        errors.push({
          url,
          status: err.response?.status,
          message: err.message,
        });
      }
    })
  );

  await Promise.all(tasks);

  // Salvataggio risultati
  fs.writeFileSync('products.json', JSON.stringify(results, null, 2));
  fs.writeFileSync('errors.json', JSON.stringify(errors, null, 2));

  console.log(`Prodotti: ${results.length}, Errori: ${errors.length}`);
  return { results, errors };
}

// Esecuzione
(async () => {
  const categories = [
    'https://example-store.com/category/electronics',
    'https://example-store.com/category/clothing',
    'https://example-store.com/category/home',
  ];

  console.log('Fase 1: Scoperta URL...');
  const urls = await discoverProductUrls(categories);
  console.log(`Trovati ${urls.length} URL prodotto`);

  console.log('Fase 2: Scraping concorrente...');
  await scrapeBulk(urls, 25);
})();

Gestione errori: 403, 429 e circuit breaker

Lo scraping a scale incontra inevitabilmente errori. Ecco come gestirli a più livelli:

Livello 1 — Interceptor axios (già implementato)

  • 403 Forbidden: l'IP è stato bloccato → retry con session ID diverso (IP nuovo).
  • 429 Too Many Requests: rate limiting → backoff esponenziale + IP nuovo.
  • ECONNRESET / ETIMEDOUT: problema di rete → retry con nuovo proxy.

Livello 2 — Circuit breaker

Se il tasso di fallimento supera il 30% su una finestra di 50 richieste, il breaker si apre. Tutte le richieste vengono messe in pausa per 60 secondi. Questo previene spreco di proxy credit quando il sito target è effettivamente down o ha attivato difese aggressive.

Livello 3 — Logging e osservabilità

Registra sempre il session ID proxy, lo status code e il tempo di risposta. Questo ti permette di identificare pattern — per esempio se i proxy di una certa località hanno più blocchi.

// Middleware di logging per l'istanza axios
client.interceptors.response.use(
  (response) => {
    const { sessionId, retryCount } = response.config.metadata || {};
    console.log(JSON.stringify({
      url: response.config.url,
      status: response.status,
      sessionId,
      retryCount,
      duration: response.headers['x-response-time'] || 'N/A',
    }));
    return response;
  },
  (error) => {
    const { sessionId, retryCount } = error.config?.metadata || {};
    console.error(JSON.stringify({
      url: error.config?.url,
      status: error.response?.status,
      sessionId,
      retryCount,
      code: error.code,
    }));
    return Promise.reject(error);
  }
);

Pattern di scaling: containerizzazione e headless fleet

Per volumi oltre le 100.000 pagine, un singolo processo Node.js diventa un collo di bottiglia. Ecco i pattern di scaling più efficaci:

Containerizzazione con Docker

Dividi la lista di URL in chunk e distribuiscili a container separati. Ogni container esegue la stessa funzione scrapeBulk con il proprio subset. I proxy residenziali di ProxyHat garantiscono che ogni container esce da IP diversi.

Worker queue con Redis

Usa BullMQ o Redis come coda di messaggi. I worker prelevano URL dalla coda, processano con Cheerio + proxy, e scrivono i risultati su un database condiviso. Se un worker crasha, il job torna in coda automaticamente.

Auto-scaling basato sul circuit breaker

Monitora lo stato del circuit breaker. Se si apre frequentemente, riduci la concorrenza o aggiungi un ritardo tra le richieste. Se il tasso di successo è > 95%, puoi aumentare la concorrenza.

Regola d'oro: inizia con concorrenza bassa (10-20) e aumenta gradualmente monitorando il tasso di successo. È meglio completare 10.000 URL in 2 ore al 98% di successo che in 30 minuti al 60% con metà dei retry che falliscono.

Confronto: approcci per lo scraping Node.js

Approccio RAM per istanza Velocità Siti supportati Complessità
axios + Cheerio ~20-50 MB Alta (100+ req/s) SSR, API statiche Bassa
axios + Cheerio + proxy rotanti ~30-60 MB Alta SSR con anti-bot Media
Puppeteer + proxy residenziali ~300-500 MB Bassa (5-10 req/s) Tutti inclusi SPA Alta
Puppeteer cluster (10 istanze) ~3-5 GB Media Tutti Molto alta

Per la maggior parte dei casi di scraping su siti SSR — monitoraggio prezzi, raccolta SERP, estrazione di directory — axios + Cheerio + proxy residenziali è il miglior rapporto tra semplicità, prestazioni e efficacia.

Considerazioni etiche e legali

  • Rispetta robots.txt: controlla sempre prima di avviare un crawl su larga scala.
  • Rate limiting responsabile: anche con proxy rotanti, mantieni una concorrenza ragionevole. Non bombardare il server.
  • Dati personali: se estrai dati che riguardano persone (GDPR, CCPA), assicurati di avere una base legale.
  • ToS del sito: alcuni siti proibiscono lo scraping nei termini di servizio. Valuta il rischio.
  • Cache intelligente: salva i risultati localmente ed esegui scrape incrementali solo sulle pagine nuove o modificate.

Key Takeaways

  • Cheerio è sufficiente per siti SSR: se il contenuto è nell'HTML iniziale, non serve un browser headless — risparmi RAM e tempo.
  • Proxy rotanti come interceptor: incapsula la logica di rotazione in un interceptor axios riutilizzabile, separandola dalla logica di business.
  • Session ID = IP diverso: con ProxyHat, ogni session ID univoco ti assegna un IP residenziale diverso — perfetto per evitare blocchi.
  • Concorrenza controllata con p-limit: limita le richieste simultanee per non esaurire risorse e non triggerare anti-bot.
  • Circuit breaker per autodifesa: se il tasso di fallimento supera la soglia, metti in pausa anziché sprecare risorse.
  • Geo-targeting nello username: specifica paese e città direttamente nel formato username di ProxyHat per IP locali precisi.

Pronto a costruire il tuo scraper con proxy residenziali? Dai un'occhiata alla pagina prezzi di ProxyHat per iniziare, o esplora i paesi disponibili per il geo-targeting. Per casi d'uso specifici, consulta la nostra guida sullo scraping web e sul tracking SERP.

Pronto per iniziare?

Accedi a oltre 50M di IP residenziali in oltre 148 paesi con filtraggio AI.

Vedi i prezziProxy residenziali
← Torna al Blog