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.






