Pourquoi un middleware proxy Scrapy est indispensable
Si vous avez déjà lancé un spider Scrapy en production sans proxy, vous connaissez la suite : blocages IP, CAPTCHAs en cascade, et un taux de succès qui chute de 95 % à 15 % en quelques heures. Les proxies résidentiels ne sont pas un luxe — ils sont le socle de toute pipeline de scraping sérieuse.
Mais injecter un proxy dans Scrapy ne se résume pas à coller une URL dans settings.py. Le framework offre un modèle de downloader middleware puissant, avec des points d'extension précis. Comprendre ce modèle est la clé pour construire une rotation robuste, gérer les échecs par requête, et scaler sans surprise.
Ce guide est conçu pour des ingénieurs Python qui font tourner Scrapy en production. Nous couvrirons l'architecture du middleware, une implémentation complète de rotation résidentielle, la gestion des retries avec changement de proxy, l'intégration avec scrapy-playwright, et les patterns de déploiement et monitoring.
L'architecture du downloader middleware Scrapy
Scrapy traite chaque requête à travers une pile de downloader middlewares. Chaque middleware implémente des hooks définis dans la documentation officielle :
process_request(request, spider)— appelé avant que la requête n'atteigne le downloader. C'est ici qu'on injecte le proxy.process_response(request, response, spider)— appelé quand une réponse revient. Idéal pour détecter les bans (403, pages CAPTCHA).process_exception(request, exception, spider)— appelé sur erreur réseau (timeout, connexion refusée). Parfait pour relancer avec un autre proxy.
L'ordre d'exécution des middlewares est déterminé par DOWNLOADER_MIDDLEWARES dans settings.py. Plus la valeur est faible, plus tôt le middleware s'exécute dans process_request — et plus tard dans process_response.
Le middleware proxy natif de Scrapy (HttpProxyMiddleware) se contente de lire l'attribut request.meta['proxy'] et de le passer au downloader. Il ne fait aucune rotation. C'est pourquoi vous devez construire votre propre couche au-dessus.
Où les proxies s'insèrent dans le cycle de vie
Voici le flux simplifié :
- Le spider émet un
Request. - Les
process_requestdes middlewares s'exécutent (du plus petit au plus grand ordinal). - Votre middleware proxy assigne
request.meta['proxy']. - Le downloader envoie la requête via le proxy.
- La réponse remonte à travers les
process_response. - En cas d'exception,
process_exceptionest appelé.
Concrètement, votre middleware doit intercepter à l'étape 2, et éventuellement à l'étape 5 et 6.
Implémenter un ProxyRotationMiddleware résidentiel
Voici un middleware complet qui rotationne les proxies résidentiels de ProxyHat à chaque requête, avec support du geo-targeting et des sticky sessions.
import random
import logging
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.request import request_fingerprint
logger = logging.getLogger(__name__)
class ProxyHatRotationMiddleware:
"""Downloader middleware that rotates residential proxies on every request.
Settings (define in settings.py):
PROXYHAT_USER – your ProxyHat username
PROXYHAT_PASS – your ProxyHat password
PROXYHAT_GATEWAY – default: gate.proxyhat.com
PROXYHAT_HTTP_PORT – default: 8080
PROXYHAT_DEFAULT_COUNTRY – default: None (random)
PROXYHAT_STICKY_SESSIONS – default: False
"""
def __init__(self, user, password, gateway, port, default_country, sticky):
self.user = user
self.password = password
self.gateway = gateway
self.port = port
self.default_country = default_country
self.sticky = sticky
self.session_map = {} # fingerprint -> session_id
self.stats = {} # proxy_url -> {successes, failures}
@classmethod
def from_crawler(cls, crawler):
s = crawler.settings
return cls(
user=s.get('PROXYHAT_USER'),
password=s.get('PROXYHAT_PASS'),
gateway=s.get('PROXYHAT_GATEWAY', 'gate.proxyhat.com'),
port=s.get('PROXYHAT_HTTP_PORT', 8080),
default_country=s.get('PROXYHAT_DEFAULT_COUNTRY'),
sticky=s.getbool('PROXYHAT_STICKY_SESSIONS', False),
)
def _build_proxy_url(self, country=None, session_id=None):
"""Construct proxy URL with optional geo-targeting and session."""
parts = [self.user]
if country:
parts.append(f'country-{country}')
if session_id:
parts.append(f'session-{session_id}')
username = '-'.join(parts)
return f'http://{username}:{self.password}@{self.gateway}:{self.port}'
def process_request(self, request, spider):
"""Assign a proxy to every outgoing request."""
# Skip non-HTTP requests
if request.url.startswith('ftp://'):
return
country = request.meta.get('proxy_country', self.default_country)
if self.sticky:
fp = request_fingerprint(request)
session_id = self.session_map.get(fp)
if not session_id:
session_id = f's{random.randint(100000, 999999)}'
self.session_map[fp] = session_id
proxy_url = self._build_proxy_url(country=country, session_id=session_id)
else:
proxy_url = self._build_proxy_url(country=country)
request.meta['proxy'] = proxy_url
# Track which proxy was used for this request
request.meta['proxy_url'] = proxy_url
logger.debug(f"Proxy assigned: {proxy_url.split('@')[1]}")
def process_response(self, request, response, spider):
"""Detect bans and record success/failure stats."""
proxy_url = request.meta.get('proxy_url')
if proxy_url not in self.stats:
self.stats[proxy_url] = {'successes': 0, 'failures': 0}
ban_signals = [403, 429, 503]
if response.status in ban_signals:
self.stats[proxy_url]['failures'] += 1
logger.warning(f"Ban detected: {response.status} via {proxy_url.split('@')[1]}")
# Retry with a different proxy by removing the old session
return self._retry_with_new_proxy(request, spider)
self.stats[proxy_url]['successes'] += 1
return response
def process_exception(self, request, exception, spider):
"""Handle connection errors by rotating proxy."""
proxy_url = request.meta.get('proxy_url')
if proxy_url and proxy_url in self.stats:
self.stats[proxy_url]['failures'] += 1
logger.warning(f"Proxy error: {exception} via {proxy_url}")
return self._retry_with_new_proxy(request, spider)
def _retry_with_new_proxy(self, request, spider):
"""Re-queue the request with a fresh proxy (max 3 retries)."""
retries = request.meta.get('proxy_retries', 0)
if retries >= 3:
logger.error(f"Max proxy retries reached for {request.url}")
raise IgnoreRequest()
new_request = request.replace(dont_filter=True, meta={
**request.meta,
'proxy_retries': retries + 1,
'proxy_country': request.meta.get('proxy_country'),
})
# Force new session on sticky mode
fp = request_fingerprint(new_request)
self.session_map.pop(fp, None)
return new_request
Activez-le dans settings.py :
# settings.py
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyHatRotationMiddleware': 400,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None, # désactiver le natif
}
PROXYHAT_USER = 'your_username'
PROXYHAT_PASS = 'your_password'
PROXYHAT_DEFAULT_COUNTRY = 'FR' # optionnel
PROXYHAT_STICKY_SESSIONS = True # pour les sessions persistantes
Dans vos spiders, vous pouvez cibler un pays spécifique par requête :
import scrapy
class PriceSpider(scrapy.Spider):
name = 'prices'
def start_requests(self):
urls = ['https://example.fr/products', 'https://example.de/products']
yield scrapy.Request(urls[0], meta={'proxy_country': 'FR'})
yield scrapy.Request(urls[1], meta={'proxy_country': 'DE'})
scrapy-rotating-proxies vs. middleware maison : comparaison
La communauté Scrapy propose scrapy-rotating-proxies, un middleware prêt à l'emploi. Est-ce suffisant pour votre cas ? Voici un comparatif honnête.
| Critère | scrapy-rotating-proxies | Middleware maison (ProxyHat) |
|---|---|---|
| Installation | pip install + config minimale |
Code à maintenir dans le projet |
| Liste de proxies | Fichier texte statique ou callback dynamique | Générée dynamiquement via l'API ProxyHat |
| Rotation | Aléatoire ou round-robin | Par requête, sticky session, geo-ciblée |
| Gestion des bans | Détection basique (page ban configurable) | Par code HTTP + exception + retry avec nouveau proxy |
| Geo-targeting | Non supporté nativement | Intégré via le champ username ProxyHat |
| Sticky sessions | Non | Oui (flag session-*) |
| Maintenance | Dépend du mainteneur open-source | Contrôle total, mais responsabilité totale |
| Pool infini | Non — limité à votre liste | Oui — pool résidentiel ProxyHat |
En résumé : scrapy-rotating-proxies convient pour un prototype rapide avec une liste de proxies datacenter statique. Pour une production sérieuse avec des résidentiels rotatifs et du geo-targeting, un middleware maison est nettement supérieur — et pas beaucoup plus complexe à maintenir.
Retry middleware avec rotation de proxy
Scrapy possède un RetryMiddleware natif, mais il ne change pas le proxy entre les tentatives. Le résultat ? Si un proxy est bloqué, Scrapy réessaie… avec le même proxy bloqué. Pour corriger cela, vous devez personnaliser le retry pour qu'il force un nouveau proxy à chaque tentative.
Notre ProxyHatRotationMiddleware ci-dessus gère déjà les retries dans process_response et process_exception. Mais si vous voulez séparer les responsabilités, voici un middleware de retry dédié :
from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.request import request_fingerprint
import random
import logging
logger = logging.getLogger(__name__)
class ProxyAwareRetryMiddleware(RetryMiddleware):
"""Retry with a different proxy on each attempt."""
def _retry(self, request, reason, spider):
retries = request.meta.get('retry_times', 0) + 1
max_retries = self.max_retry_times
if retries <= max_retries:
logger.info(f"Retrying ({retries}/{max_retries}): {request.url} — {reason}")
# Invalidate old session so ProxyHatRotationMiddleware assigns a new one
new_meta = dict(request.meta)
new_meta['retry_times'] = retries
new_meta['proxy_retries'] = retries # utilisé par notre middleware proxy
new_meta.pop('proxy', None) # forcer la réassignation
new_meta.pop('proxy_url', None)
# Change session ID for sticky proxies
new_meta['force_new_session'] = True
return request.replace(
dont_filter=True,
meta=new_meta,
)
return None
Dans settings.py, remplacez le retry natif :
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ProxyHatRotationMiddleware': 400,
'myproject.middlewares.ProxyAwareRetryMiddleware': 550,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': None,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
}
RETRY_TIMES = 3
RETRY_HTTP_CODES = [403, 429, 500, 502, 503, 504]
L'interaction entre les deux middlewares est critique : ProxyAwareRetryMiddleware supprime le proxy existant et marque la requête pour une nouvelle session, puis ProxyHatRotationMiddleware réassigne un proxy frais au passage suivant.
Sites JS-heavy : scrapy-playwright avec proxies
De nombreux sites modernes nécessitent un navigateur headless. scrapy-playwright intègre Playwright dans Scrapy, et supporte les proxies via request.meta.
Configuration de base
# settings.py
DOWNLOAD_HANDLERS = {
'http': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
'https': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
}
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
PLAYWRIGHT_BROWSER_TYPE = 'chromium'
PLAYWRIGHT_LAUNCH_OPTIONS = { 'headless': True }
Passer un proxy résidentiel à Playwright
Playwright accepte un dictionnaire proxy dans ses options de contexte. Pour l'intégrer avec ProxyHat :
import scrapy
class JSSpider(scrapy.Spider):
name = 'js_heavy'
def start_requests(self):
yield scrapy.Request(
'https://spa-example.com/dashboard',
meta={
'playwright': True,
'playwright_include_page': True,
'playwright_context_kwargs': {
'proxy': {
'server': 'http://gate.proxyhat.com:8080',
'username': 'user-country-FR',
'password': 'your_password',
}
},
},
)
async def parse(self, response):
page = response.meta.get('playwright_page')
if page:
await page.close()
# Parse the rendered content
for item in response.css('.product-item'):
yield {
'name': item.css('h3::text').get(),
'price': item.css('.price::text').get(),
}
Pour une rotation par requête avec scrapy-playwright, vous devez créer un nouveau contexte navigateur pour chaque requête — Playwright ne permet pas de changer le proxy d'un contexte existant. Cela a un coût en performance, mais c'est la seule approche fiable.
Conseil : Pour les sites extrêmement protégés (Cloudflare, Datadome), combinez un proxy résidentiel avec un navigateur stealth. Le proxy résidentiel place votre trafic dans le réseau domestique ordinaire, et le navigateur stealth réduit les empreintes d'automatisation. Les deux sont nécessaires — l'un sans l'autre ne suffit pas.
Déploiement : Scrapyd, Docker, ou ScrapeOps
Option 1 — Docker + cron (simple et suffisant)
Pour un ou deux spiders qui tournent sur un schedule, Docker est la voie la plus directe :
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["scrapy", "crawl", "prices"]
Puis en crontab :
0 */6 * * * docker run --rm --env-file /opt/proxyhat.env myscraper:latest
Option 2 — Scrapyd (pool de workers)
Scrapyd est le daemon officiel de Scrapy. Il expose une API JSON pour déployer et lancer des spiders. C'est adapté quand vous avez plusieurs spiders qui tournent en parallèle avec des priorités différentes.
Attention : Scrapyd ne gère pas nativement la concurrence par spider. Utilisez CONCURRENT_REQUESTS et CONCURRENT_REQUESTS_PER_DOMAIN pour éviter de saturer vos proxies.
Option 3 — ScrapeOps ou solutions managées
ScrapeOps offre un tableau de bord avec monitoring, scheduling et alertes. C'est pertinent si vous ne voulez pas maintenir l'infrastructure vous-même. L'inconvénient : un coût mensuel supplémentaire et moins de contrôle sur la configuration bas niveau.
Recommandation
Pour la plupart des équipes : commencez par Docker + cron. Ajoutez Scrapyd quand vous avez plus de 5 spiders en production. Considérez ScrapeOps uniquement si le monitoring interne ne suffit pas.
Monitoring : taux de succès par IP et détection de bans
Un spider sans monitoring est un spider aveugle. Voici les métriques essentielles à suivre :
1. Taux de succès par proxy
Notre middleware ProxyHatRotationMiddleware accumule déjà des stats dans self.stats. Exposez-les via le signal spider_closed :
from scrapy import signals
class ProxyHatRotationMiddleware:
# ... (code existant) ...
@classmethod
def from_crawler(cls, crawler):
middleware = cls.from_settings(crawler.settings)
crawler.signals.connect(middleware.spider_closed, signal=signals.spider_closed)
return middleware
def spider_closed(self, spider, reason):
spider.logger.info("=== Proxy Stats ===")
for proxy, counts in self.stats.items():
total = counts['successes'] + counts['failures']
rate = counts['successes'] / total * 100 if total else 0
spider.logger.info(
f"{proxy}: {rate:.1f}% success ({counts['successes']}/{total})"
)
2. Détection de bans en temps réel
Au-delà des codes HTTP évidents (403, 429), surveillez les signaux subtils :
- Pages CAPTCHA — détectez-les par des patterns HTML (ex. présence de
g-recaptcha). - Redirections inattendues — vers
/blockedou/captcha. - Contenu tronqué — la page charge mais manque des données.
- Timeouts répétés — un proxy qui timeout 3 fois de suite est probablement mort.
Implémentez un check dans votre spider :
def parse(self, response):
# Détection de CAPTCHA
if response.css('div.g-recaptcha'):
self.logger.warning(f"CAPTCHA détecté sur {response.url}")
yield response.request.replace(
dont_filter=True,
meta={**response.request.meta, 'proxy_retries': 0}
)
return
# Détection de blocage
if 'access denied' in response.text.lower():
self.logger.warning(f"Blocage détecté sur {response.url}")
return
3. Logging structuré pour l'observabilité
En production, envoyez vos métriques vers un système externe (Prometheus, Datadog, ou même un simple webhook Slack). Voici un pattern avec les stats Scrapy :
# Dans votre pipeline ou extension
import json
import requests
class ProxyMetricsExtension:
def __init__(self, webhook_url):
self.webhook_url = webhook_url
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings.get('METRICS_WEBHOOK_URL'))
def spider_closed(self, spider, reason):
stats = spider.crawler.stats.get_stats()
payload = {
'spider': spider.name,
'items_scraped': stats.get('item_scraped_count', 0),
'responses_403': stats.get('downloader/response_status_count/403', 0),
'responses_429': stats.get('downloader/response_status_count/429', 0),
'retry_count': stats.get('retry/count', 0),
}
requests.post(self.webhook_url, json=payload, timeout=10)
Patterns de scaling : concurrence et containerisation
Concurrence
Les proxies résidentiels de ProxyHat supportent un haut niveau de concurrence, mais votre spider doit être configuré en conséquence :
# settings.py
CONCURRENT_REQUESTS = 100
CONCURRENT_REQUESTS_PER_DOMAIN = 20
CONCURRENT_REQUESTS_PER_IP = 0 # 0 = pas de limite par IP
DOWNLOAD_TIMEOUT = 30
DOWNLOAD_DELAY = 0.5 # 500ms entre les requêtes par domaine
Avec un pool résidentiel rotatif, vous pouvez monter CONCURRENT_REQUESTS à 100+ sans problème — chaque requête utilise une IP différente. En revanche, avec des sticky sessions, baissez la concurrence et augmentez le delay pour éviter les soupçons.
Flotte headless avec Docker Compose
Pour les sites JS-heavy qui nécessitent Playwright, un seul conteneur ne suffit pas. Utilisez Docker Compose pour orchestrer une flotte :
# docker-compose.yml
version: '3.8'
services:
scraper-1:
build: .
environment:
- PROXYHAT_USER=user-country-FR
- PROXYHAT_PASS=${PROXYHAT_PASS}
command: scrapy crawl prices -a category=electronics
scraper-2:
build: .
environment:
- PROXYHAT_USER=user-country-DE
- PROXYHAT_PASS=${PROXYHAT_PASS}
command: scrapy crawl prices -a category=fashion
scraper-3:
build: .
environment:
- PROXYHAT_USER=user-country-ES
- PROXYHAT_PASS=${PROXYHAT_PASS}
command: scrapy crawl prices -a category=home
Chaque conteneur cible un pays différent, distribuant la charge géographiquement. C'est le pattern le plus efficace pour le monitoring de prix multi-pays à grande échelle.
Points clés à retenir
- Désactivez le HttpProxyMiddleware natif et remplacez-le par votre propre middleware pour garder le contrôle total de la rotation.
- La rotation par requête avec un pool résidentiel est le pattern le plus fiable — chaque requête obtient une IP fraîche automatiquement.
- Les sticky sessions sont nécessaires pour les sites avec authentification ou panier — ProxyHat les supporte via le flag
session-*.- Le retry doit changer de proxy — réessayer avec le même proxy bloqué est inutile. Notre
ProxyAwareRetryMiddlewarerésout cela.- scrapy-playwright + proxy résidentiel est la combinaison gagnante pour les sites SPA/JS-heavy protégés.
- Monitorer les taux de succès par IP est non-négociable en production — sans visibilité, vous volez aveugle.
Pour aller plus loin sur l'intégration des proxies dans vos pipelines de scraping, consultez notre guide sur le web scraping avec proxies résidentiels et notre page de tarifs ProxyHat pour choisir le plan adapté à votre volume.






