Scrapy Proxy Middleware : Rotation Résidentielle en Production

Apprenez à intégrer des proxies résidentiels dans Scrapy via un middleware personnalisé, gérer les échecs par requête, déployer à l'échelle et surveiller les taux de succès par IP.

Scrapy Proxy Middleware : Rotation Résidentielle en Production

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é :

  1. Le spider émet un Request.
  2. Les process_request des middlewares s'exécutent (du plus petit au plus grand ordinal).
  3. Votre middleware proxy assigne request.meta['proxy'].
  4. Le downloader envoie la requête via le proxy.
  5. La réponse remonte à travers les process_response.
  6. En cas d'exception, process_exception est 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 /blocked ou /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 ProxyAwareRetryMiddleware ré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.

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