Perché il Proxy Middleware in Scrapy è Fondamentale
Se hai mai lanciato uno spider Scrapy contro un sito protetto da Cloudflare o PerimeterX, sai cosa succede: le prime 50 richieste funzionano, poi arriva il ban di massa. Il problema non è Scrapy — è che il tuo spider usa un singolo IP per centinaia di richieste al minuto. I server target vedono questo traffico per quello che è: automatizzato.
La soluzione non è semplicemente «aggiungere un proxy». La soluzione è integrare la rotazione dei proxy nel middleware layer di Scrapy, dove puoi controllare quale IP usare per ogni richiesta, come gestire i fallimenti e quando scartare un proxy bannato. Questa guida ti porta attraverso l'intero stack — dall'architettura del middleware al deployment in produzione.
L'Architettura del Downloader Middleware in Scrapy
Scrapy processa ogni richiesta attraverso una catena di downloader middleware prima che raggiunga il server remoto, e ogni risposta attraversa la stessa catena al ritorno. Questo è il punto di estensione naturale per i proxy.
La catena del middleware è definita in settings.py tramite un dizionario ordinato dove il valore numerico determina la priorità — valori più bassi vengono eseguiti prima:
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyRotationMiddleware': 543,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 560,
}I middleware chiave per i proxy sono:
- HttpProxyMiddleware (560) — legge
http_proxydall'ambiente o dal metaproxydella richiesta e imposta l'header appropriato. - RetryMiddleware (550) — ritenta le richieste fallite, ma di default non ruota il proxy.
- Il tuo middleware personalizzato — si inserisce nella catena per assegnare proxy, gestire ban e coordinare i retry.
Il flusso è: Request → process_request(middleware) → Downloader → Response → process_response(middleware). Se un middleware solleva IgnoreRequest, la richiesta viene scartata. Se ritorna None, il prossimo middleware nella catena continua.
Il meta proxy — il punto di aggancio
Scrapy utilizza request.meta['proxy'] come URL del proxy per quella specifica richiesta. Qualsiasi middleware può impostare questo valore prima che HttpProxyMiddleware lo legga:
# In un middleware personalizzato
def process_request(self, request, spider):
request.meta['proxy'] = 'http://user-country-US:pass@gate.proxyhat.com:8080'
return None # passa al prossimo middlewareQuesto è il meccanismo fondamentale. Tutto il resto — rotazione, sticky sessions, geo-targeting — si costruisce sopra questo hook.
Implementare un ProxyRotationMiddleware Personalizzato
Il middleware built-in HttpProxyMiddleware applica un proxy statico. Per la rotazione residenziale hai bisogno di un middleware che selezioni un IP diverso per ogni richiesta, supporti sessioni sticky e gestisca il geo-targeting. Ecco un'implementazione completa:
import random
import time
from collections import defaultdict
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.request import fingerprint
class ProxyRotationMiddleware:
"""Rotazione proxy residenziali con ban tracking e sticky session."""
def __init__(self, proxy_url, max_retries=3, sticky_timeout=600):
self.proxy_url = proxy_url # es. http://USERNAME:PASSWORD@gate.proxyhat.com:8080
self.max_retries = max_retries
self.sticky_timeout = sticky_timeout
# session_id → timestamp di creazione
self.sessions = {}
# proxy_fingerprint → contatore ban
self.ban_counts = defaultdict(int)
# request_fingerprint → session_id per retry
self.request_session = {}
@classmethod
def from_crawler(cls, crawler):
proxy_url = crawler.settings.get(
'PROXYHAT_URL',
'http://USERNAME:PASSWORD@gate.proxyhat.com:8080'
)
max_retries = crawler.settings.get('PROXY_MAX_RETRIES', 3)
sticky_timeout = crawler.settings.get('PROXY_STICKY_TIMEOUT', 600)
middleware = cls(proxy_url, max_retries, sticky_timeout)
crawler.signals.connect(middleware.spider_opened, signal=signals.spider_opened)
return middleware
def spider_opened(self, spider):
spider.logger.info('ProxyRotationMiddleware attivo con %s', self.proxy_url.split('@')[-1])
def _build_proxy_url(self, request):
"""Costruisce URL proxy con geo-targeting e sessione."""
fp = fingerprint(request)
# Se la richiesta ha già una sessione assegnata (retry), riutilizzala
if fp in self.request_session:
session_id = self.request_session[fp]
# Controlla se la sessione è scaduta
if time.time() - self.sessions.get(session_id, 0) < self.sticky_timeout:
return self._inject_session(self.proxy_url, session_id, request)
# Nuova sessione
session_id = f'sess-{random.randint(100000, 999999)}'
self.sessions[session_id] = time.time()
self.request_session[fp] = session_id
return self._inject_session(self.proxy_url, session_id, request)
def _inject_session(self, base_url, session_id, request):
"""Inietta sessione e geo-targeting nell'username del proxy."""
# base_url: http://user:pass@gate.proxyhat.com:8080
country = request.meta.get('proxy_country', 'US')
# Risultato: http://user-country-US-sid-sess-123:pass@gate.proxyhat.com:8080
parts = base_url.split('://')
protocol = parts[0]
rest = parts[1]
credentials, host = rest.split('@')
user, password = credentials.split(':')
new_user = f'{user}-country-{country}-sid-{session_id}'
return f'{protocol}://{new_user}:{password}@{host}'
def process_request(self, request, spider):
proxy = self._build_proxy_url(request)
request.meta['proxy'] = proxy
request.meta['proxy_country'] = request.meta.get('proxy_country', 'US')
# Traccia il tentativo corrente
retry_times = request.meta.get('retry_times', 0)
request.meta['retry_times'] = retry_times
return None
def process_response(self, request, response, spider):
# Rileva ban — status 403, 429, o CAPTCHA pages
if response.status in (403, 429):
spider.logger.warning('Ban rilevato: %s status=%d', response.url, response.status)
self.ban_counts[fingerprint(request)] += 1
# Se non abbiamo superato il max retry, ritenta con nuovo proxy
if request.meta.get('retry_times', 0) < self.max_retries:
request.meta['retry_times'] = request.meta.get('retry_times', 0) + 1
# Forza nuova sessione rimuovendo il mapping
fp = fingerprint(request)
if fp in self.request_session:
del self.request_session[fp]
request.dont_filter = True
return request # ritenta
else:
spider.logger.error('Max retry raggiunto per %s', request.url)
return response
def process_exception(self, request, exception, spider):
# Timeout, connection errors — ritenta con nuovo proxy
if request.meta.get('retry_times', 0) < self.max_retries:
request.meta['retry_times'] = request.meta.get('retry_times', 0) + 1
fp = fingerprint(request)
if fp in self.request_session:
del self.request_session[fp]
request.dont_filter = True
spider.logger.info('Retry per eccezione: %s → %s', request.url, exception)
return request
return NoneQuesto middleware fa tre cose fondamentali: assegna un proxy diverso per ogni richiesta usando le flag di sessione di ProxyHat, mantiene sessioni sticky per un timeout configurabile, e ruota automaticamente il proxy quando rileva un ban (403/429) o un'eccezione di connessione.
Configurazione in settings.py:
# settings.py
PROXYHAT_URL = 'http://USERNAME:PASSWORD@gate.proxyhat.com:8080'
PROXY_MAX_RETRIES = 3
PROXY_STICKY_TIMEOUT = 600 # 10 minuti in secondi
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyRotationMiddleware': 543,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 560,
}
# Disabilita il RetryMiddleware di default — il nostro gestisce i retry
RETRY_ENABLED = False
# Aumenta la concorrenza per sfruttare il pool residenziale
CONCURRENT_REQUESTS = 32
CONCURRENT_REQUESTS_PER_DOMAIN = 8
DOWNLOAD_TIMEOUT = 30Nota importante: disabilitiamo il RetryMiddleware di Scrapy perché il nostro middleware gestisce i retry con rotazione proxy. Se entrambi fossero attivi, ogni richiesta bannata verrebbe ritentata due volte — una dal nostro middleware e una dal RetryMiddleware di default.
scrapy-rotating-proxies vs Middleware Personalizzato
La community offre scrapy-rotating-proxies, un pacchetto che gestisce rotazione e ban detection. Vale la pena considerarne i pro e i contro:
| Caratteristica | scrapy-rotating-proxies | Middleware Personalizzato |
|---|---|---|
| Configurazione | Lista di proxy in settings.py | URL singolo con rotazione lato provider |
| Ban detection | Basata su status code configurabili | Personalizzabile per sito (403, 429, CAPTCHA) |
| Sticky sessions | Non supportate nativamente | Supporto completo via flag session |
| Geo-targeting | Manuale — devi filtrare i proxy per paese | Per-request via flag country |
| Pool management | Gestisci tu il pool di IP | Delegato al provider (ProxyHat) |
| Proxy residenziali | Nessun supporto specifico | Ottimizzato per proxy residenziali |
| Manutenzione | Pacchetto terze parti, aggiornamenti rari | Controlli tu il codice |
Quando usare scrapy-rotating-proxies: hai una lista di proxy datacenter gratuiti o acquistati e vuoi rotazione rapida senza scrivere codice.
Quando usare un middleware personalizzato: usi proxy residenziali con sessioni sticky, geo-targeting per-request, e vuoi pieno controllo sulla logica di retry. Per la maggior parte dei casi di produzione con proxy a pagamento, il middleware personalizzato è la scelta migliore.
Gestire i Retry con Rotazione Proxy
Il pattern più comune di fallimento: una richiesta riceve status 403, il RetryMiddleware di Scrapy ritenta la stessa richiesta con lo stesso IP, e riceve di nuovo 403. È un ciclo inutile.
Il nostro middleware risolve questo problema rimuovendo il mapping della sessione quando rileva un ban, forzando la richiesta successiva a ottenere un nuovo IP. Ma c'è di più — puoi combinare questo con il RetryMiddleware di Scrapy per avere una strategia a due livelli:
# middlewares.py — RetryMiddleware personalizzato che ruota il proxy
from scrapy.downloadermiddlewares.retry import RetryMiddleware as BaseRetry
from scrapy.utils.request import fingerprint
class ProxyAwareRetryMiddleware(BaseRetry):
"""Retry che invalida la sessione proxy prima di ritentare."""
def __init__(self, settings):
super().__init__(settings)
self.max_retry_times = settings.getint('RETRY_TIMES', 3)
def process_response(self, request, response, spider):
if request.meta.get('dont_retry', False):
return response
# Se è un ban, invalida la sessione proxy
if response.status in (403, 429, 503):
fp = fingerprint(request)
# Segnala al ProxyRotationMiddleware di usare un nuovo IP
if fp in spider.proxy_middleware.request_session if hasattr(spider, 'proxy_middleware') else {}:
del spider.proxy_middleware.request_session[fp]
spider.logger.info('Ban rilevato, ruoto proxy per %s', request.url)
return super().process_response(request, response, spider)
def process_exception(self, request, exception, spider):
# Timeout e connection errors — ruota proxy
fp = fingerprint(request)
if hasattr(spider, 'proxy_middleware') and fp in spider.proxy_middleware.request_session:
del spider.proxy_middleware.request_session[fp]
return super().process_exception(request, exception, spider)Con questo approccio, ogni retry usa automaticamente un IP diverso. Configuralo così:
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyRotationMiddleware': 543,
'myproject.middlewares.ProxyAwareRetryMiddleware': 550,
}
# NON disabilitare RETRY_ENABLED qui — il nostro retry middleware lo usa
RETRY_TIMES = 3
RETRY_HTTP_CODES = [403, 429, 500, 502, 503, 504]Siti JS-Heavy: scrapy-splash e scrapy-playwright con Proxy
I siti moderni spesso richiedono esecuzione JavaScript per renderizzare il contenuto. Scrapy gestisce questo scenario tramite scrapy-splash (per Splash, un renderer headless) o scrapy-playwright (per Playwright, che controlla browser reali).
Configurare scrapy-playwright con proxy residenziali
scrapy-playwright è diventato lo standard de facto per il rendering JavaScript in Scrapy. Ecco come integrare i proxy residenziali:
# settings.py
DOWNLOAD_HANDLERS = {
'http': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
'https': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
}
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
PLAYWRIGHT_BROWSER_TYPE = 'chromium'
# Il proxy per Playwright va configurato per-request nel meta
PLAYWRIGHT_LAUNCH_OPTIONS = {
'headless': True,
}Nello spider, assegni il proxy per ogni richiesta tramite il meta playwright:
import scrapy
import random
class JSSpider(scrapy.Spider):
name = 'js_spider'
def start_requests(self):
urls = ['https://example.com/data']
for url in urls:
session_id = f'sess-{random.randint(100000, 999999)}'
yield scrapy.Request(
url=url,
callback=self.parse,
meta={
'playwright': True,
'playwright_include_page': False,
'playwright_proxy': {
'server': 'http://gate.proxyhat.com:8080',
'username': f'USERNAME-country-US-sid-{session_id}',
'password': 'PASSWORD',
},
'proxy_country': 'US',
},
)
def parse(self, response):
for item in response.css('.data-item'):
yield {
'title': item.css('h2::text').get(),
'price': item.css('.price::text').get(),
}Per scrapy-splash, il pattern è simile ma passi il proxy attraverso i parametri di Splash:
# Con scrapy-splash
yield scrapy.Request(
url=target_url,
callback=self.parse,
meta={
'splash': {
'args': {
'render': 1,
'proxy': 'http://gate.proxyhat.com:8080',
},
'endpoint': 'render.html',
}
}
)Attenzione: con scrapy-splash, il proxy è configurato lato server Splash, non per-request. Se hai bisogno di rotazione per-request, devi usare un pool di istanze Splash o passare a scrapy-playwright che supporta proxy per-request nativamente.
Deployment: Scrapyd, Docker, e Oltre
Opzione 1: Docker + cron — semplice ed efficace
Per la maggior parte dei casi d'uso, un container Docker con cron è sufficiente:
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# cron job per eseguire lo spider ogni ora
RUN echo "0 * * * * cd /app && scrapy crawl my_spider >> /var/log/spider.log 2>&1" | crontab -
CMD ["crontab", "-l", "&&", "tail", "-f", "/var/log/spider.log"]Esegui con docker run -d --name spider my-spider-image. Per scaling orizzontale, usa Docker Compose con multiple istanze, ognuna con un diverso PROXYHAT_URL per distribuire il carico.
Opzione 2: Scrapyd — gestione centralizzata
Scrapyd offre un'API per deployare e schedulare spider. È utile se hai multipli spider che girano su diverse macchine:
# Deploy su Scrapyd
scrapyd-deploy
# Schedula un job
curl http://localhost:6800/schedule.json -d project=myproject -d spider=my_spiderOpzione 3: ScrapeOps — monitoraggio avanzato
ScrapeOps fornisce un dashboard per monitorare job, success rate e log. Si integra bene con Scrapy tramite il loro scrapeops-scrapy-proxy-sdk, ma se usi ProxyHat come provider, il nostro middleware personalizzato è più flessibile.
Monitoraggio: Success Rate, Ban Detection, e Per-IP Stats
In produzione, devi sapere quali IP funzionano e quali sono bannati. Ecco un'estensione Scrapy che raccoglie statistiche per paese e tipo di risposta:
# extensions.py
from scrapy import signals
from collections import defaultdict
class ProxyStatsExtension:
"""Raccoglie statistiche per-request sui proxy."""
def __init__(self, stats):
self.stats = stats
self.country_stats = defaultdict(lambda: {'success': 0, 'ban': 0, 'error': 0})
@classmethod
def from_crawler(cls, crawler):
ext = cls(crawler.stats)
crawler.signals.connect(ext.request_scheduled, signal=signals.request_scheduled)
crawler.signals.connect(ext.response_received, signal=signals.response_received)
crawler.signals.connect(ext.request_dropped, signal=signals.request_dropped)
return ext
def request_scheduled(self, request, spider):
country = request.meta.get('proxy_country', 'unknown')
self.stats.inc_value(f'proxy/request/{country}')
def response_received(self, response, spider):
country = response.meta.get('proxy_country', 'unknown')
if response.status >= 400:
self.country_stats[country]['ban'] += 1
self.stats.inc_value(f'proxy/ban/{country}')
else:
self.country_stats[country]['success'] += 1
self.stats.inc_value(f'proxy/success/{country}')
def request_dropped(self, request, spider):
country = request.meta.get('proxy_country', 'unknown')
self.country_stats[country]['error'] += 1
self.stats.inc_value(f'proxy/error/{country}')Abilita l'estensione in settings.py:
EXTENSIONS = {
'myproject.extensions.ProxyStatsExtension': 500,
}Con queste stats puoi monitorare il tasso di successo per paese — se i proxy US hanno un success rate del 95% ma quelli DE solo del 60%, sai dove ottimizzare.
Ban detection avanzata
Non tutti i ban restituiscono 403. Alcuni siti restituiscono 200 con una pagina CAPTCHA, o redirect a una pagina di verifica. Aggiungi questi pattern al tuo middleware:
# Nel process_response del ProxyRotationMiddleware
def _is_ban(self, response):
"""Rileva ban che non sono ovvi status code."""
# Status code ban
if response.status in (403, 429, 503):
return True
# CAPTCHA pages
captcha_signals = ['captcha', 'cf-challenge', 'recaptcha', 'hcaptcha']
body_lower = response.text[:5000].lower()
if any(sig in body_lower for sig in captcha_signals):
return True
# Redirect a pagine di verifica
if response.status == 200 and '/verify' in response.url:
return True
return FalsePattern di Scaling per Produzione
Quando passi da uno spider che gira su un laptop a un sistema che processa milioni di pagine al giorno, i pattern di scaling diventano critici:
Concorrenza e rate limiting
Con proxy residenziali, puoi aumentare la concorrenza significativamente perché ogni richiesta usa un IP diverso:
CONCURRENT_REQUESTS = 64
CONCURRENT_REQUESTS_PER_DOMAIN = 16
DOWNLOAD_DELAY = 0.5 # ancora utile per non saturare la banda
# AutoThrottle per adattarsi alla velocità del sito
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 0.5
AUTOTHROTTLE_MAX_DELAY = 5
AUTOTHROTTLE_TARGET_CONCURRENCY = 16Container fleet con Docker Compose
Per scaling orizzontale, esegui multiple istanze dello spider con diverse configurazioni geo:
# docker-compose.yml
version: '3.8'
services:
spider-us:
build: .
environment:
- PROXY_COUNTRY=US
- PROXYHAT_URL=http://user-country-US:pass@gate.proxyhat.com:8080
command: scrapy crawl my_spider
spider-de:
build: .
environment:
- PROXY_COUNTRY=DE
- PROXYHAT_URL=http://user-country-DE:pass@gate.proxyhat.com:8080
command: scrapy crawl my_spider
spider-uk:
build: .
environment:
- PROXY_COUNTRY=UK
- PROXYHAT_URL=http://user-country-GB:pass@gate.proxyhat.com:8080
command: scrapy crawl my_spiderItem pipelines e deduplicazione
Con multiple istanze che girano in parallelo, hai bisogno di deduplicazione a livello di item. Usa un DuplicateFilter basato su Redis:
# pipelines.py
import redis
class RedisDuplicateFilterPipeline:
def __init__(self, redis_url):
self.redis = redis.from_url(redis_url)
self.ttl = 86400 # 24 ore
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings.get('REDIS_URL', 'redis://localhost:6379'))
def process_item(self, item, spider):
item_id = item.get('url') or item.get('id')
if self.redis.setnx(f'dedup:{item_id}', 1):
self.redis.expire(f'dedup:{item_id}', self.ttl)
return item
raise DropItem(f'Duplicato: {item_id}')Considerazioni Etiche e Legali
L'uso di proxy per web scraping solleva questioni importanti:
- Rispetta
robots.txt— usaROBOTSTXT_OBEY = Truea meno che tu non abbia una buona ragione per non farlo. - Rate limiting responsabile — anche con proxy residenziali, non bombardare un server. Usa
DOWNLOAD_DELAYe AutoThrottle. - GDPR e CCPA — se raccogli dati personali, assicurati di essere in conformità. I proxy non ti esentano dalle obbligazioni legali.
- ToS dei siti — molti siti proibiscono lo scraping nei loro Termini di Servizio. Valuta i rischi legali.
Per approfondire le best practice per lo scraping etico, consulta la nostra guida su web scraping responsabile.
Prossimi Passi
Ora hai tutte le componenti per un sistema Scrapy con proxy residenziali di livello production. Il prossimo passo è configurare il tuo account ProxyHat e testare il middleware contro il tuo sito target.
- Piani ProxyHat — scegli il pool residenziale adatto al tuo volume.
- Posizioni ProxyHat — verifica la copertura geo per i tuoi target.
- SERP tracking con proxy — caso d'uso specifico per SERP scraping.
Se hai domande sull'integrazione, il team ProxyHat è disponibile per supporto tecnico direttamente dalla dashboard.






