Scrapy Proxy Middleware: kompletny przewodnik po rotacji residential proxy

Dowiedz się, jak zintegrować residential proxies ze Scrapy przez własny downloader middleware, obsługiwać retry z rotacją IP i wdrożyć monitorowanie banów w produkcji.

Scrapy Proxy Middleware: kompletny przewodnik po rotacji residential proxy

Dlaczego Scrapy potrzebuje proxy middleware?

Każdy inżynier scrapingu zna ten scenariusz: Twój spider działa świetnie przez godzinę, potem dostajesz 403, CAPTCHA, a w końcu ban na poziomie IP. Scrapy jest potężnym frameworkiem, ale domyślnie wysyła wszystkie requesty z jednego adresu — co czyni Cię łatwym celem dla anti-bot systemów.

Rozwiązanie to Scrapy proxy middleware, który wstrzykuje różne adresy IP do każdego żądania lub sesji. Z residential proxies zyskujesz adresy prawdziwych urządzeń, które są znacznie trudniejsze do zablokowania niż datacenter IP. W tym przewodniku zbudujesz kompletne rozwiązanie od zera — od middleware po monitoring w produkcji.

Architektura downloader middleware w Scrapy

Scrapy przetwarza każde żądanie przez sekwencję downloader middlewares — komponentów, które mogą modyfikować request przed wysłaniem i response po otrzymaniu. To właśnie tutaj proxy wchodzą do gry.

Kluczowe metody middleware to:

  • process_request(request, spider) — modyfikuje request przed wysłaniem; tutaj ustawiasz proxy URL
  • process_response(request, response, spider) — analizuje response; tutaj wykrywasz bany
  • process_exception(request, exception, spider) — obsługuje błędy sieciowe; tutaj przełączasz proxy i retry

Kolejność middleware ma znaczenie — Scrapy przetwarza je w kolejności numerycznej (niższy numer = wyższy priorytet). Wbudowany HttpProxyMiddleware ma priorytet 750. Twój custom middleware powinien działać przed nim lub go zastąpić.

# settings.py — konfiguracja kolejności middleware
DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.ProxyRotatingMiddleware': 100,
    'myproject.middlewares.BanDetectionMiddleware': 200,
    'myproject.middlewares.RetryWithProxyMiddleware': 550,
    # Wyłącz domyślny HttpProxyMiddleware, bo własny go zastępuje
    'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
}

Implementacja własnego ProxyRotatingMiddleware

Czas na najważniejszy element — middleware, który przypisuje residential proxy do każdego requestu. Użyjemy ProxyHat jako dostawcy z geotargetingiem w username.

# middlewares.py — pełna klasa ProxyRotatingMiddleware
import random
import hashlib
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from urllib.parse import urlencode


class ProxyRotatingMiddleware:
    """
    Scrapy downloader middleware do rotacji residential proxy.
    Wstrzykuje proxy URL do każdego requestu na podstawie strategii:
    - 'per_request': losowy IP na każde żądanie
    - 'sticky': ten sam IP dla domeny przez określony czas
    - 'geo': proxy z określonego kraju
    """

    def __init__(self, proxy_user, proxy_pass, proxy_host, proxy_port, strategy):
        self.proxy_user = proxy_user
        self.proxy_pass = proxy_pass
        self.proxy_host = proxy_host
        self.proxy_port = proxy_port
        self.strategy = strategy
        self._session_map = {}  # domain -> session_id

    @classmethod
    def from_crawler(cls, crawler):
        s = crawler.settings
        return cls(
            proxy_user=s.get('PROXYHAT_USER'),
            proxy_pass=s.get('PROXYHAT_PASS'),
            proxy_host=s.get('PROXYHAT_HOST', 'gate.proxyhat.com'),
            proxy_port=s.get('PROXYHAT_PORT', '8080'),
            strategy=s.get('PROXY_STRATEGY', 'per_request'),
        )

    def _build_proxy_url(self, request):
        """Konstruuje proxy URL z flagami w username."""
        username = self.proxy_user

        if self.strategy == 'geo':
            country = request.meta.get('proxy_country', 'US')
            username = f"{self.proxy_user}-country-{country}"

        elif self.strategy == 'sticky':
            domain = self._get_domain(request.url)
            if domain not in self._session_map:
                self._session_map[domain] = hashlib.md5(
                    f"{domain}:{random.random()}".encode()
                ).hexdigest()[:12]
            username = f"{self.proxy_user}-session-{self._session_map[domain]}"

        elif self.strategy == 'per_request':
            # Bez flagi session = nowy IP na każde żądanie
            username = self.proxy_user

        return f"http://{username}:{self.proxy_pass}@{self.proxy_host}:{self.proxy_port}"

    def _get_domain(self, url):
        from urllib.parse import urlparse
        return urlparse(url).netloc.split(':')[0]

    def process_request(self, request, spider):
        proxy_url = self._build_proxy_url(request)
        request.meta['proxy'] = proxy_url
        request.meta['proxy_strategy'] = self.strategy
        # Zapisz czysty proxy URL do logowania (bez credentials)
        request.meta['proxy_host'] = f"{self.proxy_host}:{self.proxy_port}"

    def process_exception(self, request, exception, spider):
        # Przy błędzie połączenia — wyczyść sesję sticky
        if self.strategy == 'sticky':
            domain = self._get_domain(request.url)
            self._session_map.pop(domain, None)
        return None  # Pozwól RetryMiddleware przejąć

Konfiguracja w settings.py:

# settings.py — konfiguracja ProxyHat residential proxies
PROXYHAT_USER = 'myuser'
PROXYHAT_PASS = 'mypass'
PROXYHAT_HOST = 'gate.proxyhat.com'
PROXYHAT_PORT = '8080'
PROXY_STRATEGY = 'per_request'  # 'per_request', 'sticky', 'geo'

DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.ProxyRotatingMiddleware': 100,
    'myproject.middlewares.BanDetectionMiddleware': 200,
    'myproject.middlewares.RetryWithProxyMiddleware': 550,
    'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
}

RETRY_TIMES = 5
RETRY_HTTP_CODES = [403, 429, 500, 502, 503, 504]

Strategie rotacji — kiedy której używać

StrategiaZastosowanieZaletyWady
per_requestSERP scraping, masowe crawlowanieMaksymalna anonimowość, trudne do zablokowaniaBrak sesji — problemy z logowaniem
stickyE-commerce, formularze, koszykiUtrzymanie sesji na domenęIP może zostać zbanowany w ramach sesji
geoLokalizowane treści, ceny regionalneDokładne targetowanie kraju/miastaWiększe zużycie puli IP

scrapy-rotating-proxies vs własny middleware

Pakiet scrapy-rotating-proxies to popularne rozwiązanie open-source. Warto rozważyć jego zalety i ograniczenia:

Kryteriumscrapy-rotating-proxiesWłasny middleware
Czas wdrożenia5 minut (pip install)1-2 godziny
Zarządzanie pulą IPAutomatyczne (ban/probe)Ręczne lub semi-automatyczne
Integracja z residential proxyOgraniczona — wymaga listy IPNatywna — przez gateway URL
GeotargetingBrakPełny — przez username flags
Sticky sessionsPodstawowePelna kontrola (per-domain, TTL)
ElastycznośćNiska — konfiguracja przez settingsWysoka — pełna logika Python
UtrzymanieZależność zewnętrznaPełna własność

Kiedy wybrać scrapy-rotating-proxies: szybki prototyp z listą datacenter IP, proste projekty bez geotargetingu.

Kiedy roll your own: residential proxy z gateway API (jak ProxyHat), zaawansowany geotargeting, sticky sessions z TTL, integracja z systemem monitorowania.

Dla Scrapy residential proxies z gateway API, własny middleware jest praktycznie konieczny — pakiety community nie wspierają username-flag rotation natywnie.

Retry z rotacją proxy — BanDetectionMiddleware

Samo przypisanie proxy to połowa sukcesu. Druga połowa to wykrywanie banów i przełączanie IP przy retry. Zbudujmy middleware, który wykrywa bany i wymusza zmianę proxy.

# middlewares.py — BanDetection i Retry z proxy rotation
import logging
from scrapy.utils.response import response_status_message

logger = logging.getLogger(__name__)


class BanDetectionMiddleware:
    """Wykrywa bany na podstawie status code i treści response."""

    BAN_CODES = {403, 429, 503}
    BAN_PATTERNS = [
        b'captcha',
        b'blocked',
        b'access denied',
        b'rate limit',
        b'cloudflare',
    ]

    def process_response(self, request, response, spider):
        if response.status in self.BAN_CODES:
            logger.warning(
                f"Ban detected: {response.status} for {request.url} "
                f"via proxy {request.meta.get('proxy_host', 'direct')}"
            )
            # Oznacz request jako wymagający retry z nowym proxy
            request.meta['ban_detected'] = True
            request.meta['dont_redirect'] = True
            # Zwróć ponownie ten request — Scrapy wywoła process_request
            return request.replace(dont_filter=True)

        # Sprawdź treść response pod kątem ukrytych banów
        if response.status == 200:
            body_lower = response.body.lower()
            for pattern in self.BAN_PATTERNS:
                if pattern in body_lower:
                    logger.warning(
                        f"Soft ban detected: pattern '{pattern.decode()}' "
                        f"in {request.url}"
                    )
                    request.meta['ban_detected'] = True
                    return request.replace(dont_filter=True)

        return response

    def process_exception(self, request, exception, spider):
        logger.warning(
            f"Connection error: {type(exception).__name__} "
            f"for {request.url}"
        )
        # Timeout / connection error — retry z nowym proxy
        request.meta['ban_detected'] = True
        return request.replace(dont_filter=True)


class RetryWithProxyMiddleware:
    """Retry z wymuszoną zmianą proxy po banie."""

    def __init__(self, max_retry_times=3):
        self.max_retry_times = max_retry_times

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            max_retry_times=crawler.settings.getint('RETRY_TIMES', 3),
        )

    def process_request(self, request, spider):
        # Jeśli to retry po banie — wymuś nowy proxy
        retry_count = request.meta.get('retry_times', 0)
        if request.meta.get('ban_detected') or retry_count > 0:
            # Usuń stary session ID, żeby ProxyRotatingMiddleware
            # wygenerował nowy
            request.meta.pop('proxy', None)
            request.meta['force_new_proxy'] = True
            logger.info(f"Retry #{retry_count} with new proxy for {request.url}")

        if retry_count >= self.max_retry_times:
            logger.error(f"Max retries exceeded: {request.url}")
            raise IgnoreRequest(f"Max retries ({self.max_retry_times}) exceeded")

        return None  # Kontynuuj pipeline

Scrapy-Splash i Scrapy-Playwright z proxy

Strony JS-heavy wymagają renderowania przeglądarki. Proxy musisz przekazać nie tylko do Scrapy, ale też do instancji Splash lub Playwright.

Scrapy-Splash z proxy

Splash przyjmuje parametr proxy w request body. Middleware musi ustawić go w splash_args:

# middlewares.py — Splash proxy integration
from scrapy_splash import SplashRequest


class SplashProxyMiddleware:
    """Wstrzykuje proxy do Splash requestów."""

    def __init__(self, proxy_user, proxy_pass, proxy_host, proxy_port):
        self.proxy_user = proxy_user
        self.proxy_pass = proxy_pass
        self.proxy_host = proxy_host
        self.proxy_port = proxy_port

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            proxy_user=crawler.settings.get('PROXYHAT_USER'),
            proxy_pass=crawler.settings.get('PROXYHAT_PASS'),
            proxy_host=crawler.settings.get('PROXYHAT_HOST', 'gate.proxyhat.com'),
            proxy_port=crawler.settings.get('PROXYHAT_PORT', '8080'),
        )

    def process_request(self, request, spider):
        if not isinstance(request, SplashRequest):
            return None

        proxy_url = (
            f"http://{self.proxy_user}:{self.proxy_pass}"
            f"@{self.proxy_host}:{self.proxy_port}"
        )
        # Splash przyjmuje proxy jako argument renderowania
        request.meta['splash']['args']['proxy'] = proxy_url
        return None


# W spiderze:
import scrapy
from scrapy_splash import SplashRequest

class JSSpider(scrapy.Spider):
    name = 'js_heavy'

    def start_requests(self):
        urls = ['https://example.com/dynamic-page']
        for url in urls:
            yield SplashRequest(
                url,
                callback=self.parse,
                endpoint='render.html',
                args={'wait': 2},
            )

    def parse(self, response):
        for item in response.css('.product'):
            yield {'name': item.css('h2::text').get()}

Scrapy-Playwright z proxy

Dla nowszych projektów, scrapy-playwright oferuje lepszą kontrolę nad przeglądarką. Proxy konfiguruje się przez meta['playwright_context_kwargs']:

# spider z scrapy-playwright i ProxyHat
import scrapy


class PlaywrightProxySpider(scrapy.Spider):
    name = 'playwright_proxy'

    custom_settings = {
        'DOWNLOAD_HANDLERS': {
            'http': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
            'https': 'scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler',
        },
        'TWISTED_REACTOR': 'twisted.internet.asyncioreactor.AsyncioSelectorReactor',
        'PLAYWRIGHT_BROWSER_TYPE': 'chromium',
    }

    def start_requests(self):
        yield scrapy.Request(
            'https://example.com/protected',
            callback=self.parse,
            meta={
                'playwright': True,
                'playwright_context_kwargs': {
                    'proxy': {
                        'server': f'http://gate.proxyhat.com:8080',
                        'username': 'myuser-country-US',
                        'password': 'mypass',
                    },
                },
            },
        )

    def parse(self, response):
        yield {'title': response.css('title::text').get()}

Deployment — od development do produkcji

Opcja 1: Docker + cron (najprostsza)

Dla pojedynczych spiderów uruchamianych cyklicznie, Docker + cron jest wystarczający i daje pełną kontrolę.

# Dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

CMD ["scrapy", "crawl", "my_spider"]
# docker-compose.yml z harmonogramem
version: '3.8'
services:
  scraper:
    build: .
    environment:
      - PROXYHAT_USER=${PROXYHAT_USER}
      - PROXYHAT_PASS=${PROXYHAT_PASS}
    volumes:
      - ./data:/app/data
    restart: unless-stopped

  scheduler:
    image: mcuadros/ofelia:latest
    depends_on:
      - scraper
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./ofelia.ini:/etc/ofelia/config.ini

Opcja 2: Scrapyd + Scrapyd-client

Scrapyd to daemon do zarządzania wieloma spiderami. Uruchamia spider na żądanie przez HTTP API.

  • Plusy: harmonogramowanie, API, logi per-job, izolacja egg
  • Minusy: brak wbudowanego UI, wymaga osobnego serwera

Opcja 3: ScrapeOps / Zyte Scrapy Cloud

Zarządzane platformy z dashboard, scheduling, proxy built-in i monitoring.

  • Plusy: zero infrastruktury, wbudowane proxy, auto-scaling
  • Minusy: koszty rosną z objętością, vendor lock-in, ograniczona kontrola nad proxy
PlatformaKosztKontrola proxyScalingZłożoność
Docker + cronDarmowy (VPS)PełnaRęcznyNiska
ScrapydDarmowy (VPS)PełnaŚredniŚrednia
ScrapeOpsPłatnyOgraniczonaAutoNiska
Zyte CloudPłatnyWłasne proxyAutoNiska

Monitorowanie — per-IP success rates i ban detection

Bez monitorowania leczysz objawy, nie przyczyny. Zbudujmy system statystyk per-proxy, który pokaże, które IP/regiony działają najlepiej.

Statystyki middleware

Scrapy ma wbudowany system stats. Użyjmy go do śledzenia sukcesów i banów per proxy:

# middlewares.py — ProxyStatsMiddleware
import time
from collections import defaultdict


class ProxyStatsMiddleware:
    """Zbiera statystyki sukcesów/banów per proxy."""

    def __init__(self, stats):
        self.stats = stats
        self._proxy_stats = defaultdict(lambda: {
            'success': 0, 'ban': 0, 'error': 0,
            'last_success': 0, 'last_ban': 0
        })

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.stats)

    def process_response(self, request, response, spider):
        proxy_host = request.meta.get('proxy_host', 'direct')
        stats = self._proxy_stats[proxy_host]

        if response.status == 200 and not request.meta.get('ban_detected'):
            stats['success'] += 1
            stats['last_success'] = time.time()
            self.stats.inc_value('proxy/success')
        elif response.status in {403, 429, 503}:
            stats['ban'] += 1
            stats['last_ban'] = time.time()
            self.stats.inc_value('proxy/ban')
            self.stats.inc_value(f'proxy/ban/{response.status}')
        else:
            stats['error'] += 1
            self.stats.inc_value('proxy/error')

        # Loguj co 100 requestów
        total = stats['success'] + stats['ban'] + stats['error']
        if total % 100 == 0:
            rate = stats['success'] / total * 100 if total else 0
            spider.logger.info(
                f"Proxy {proxy_host}: {rate:.1f}% success rate "
                f"({stats['success']} ok, {stats['ban']} bans, "
                f"{stats['error']} errors)"
            )

        return response

    def spider_closed(self, spider):
        """Wypisz końcowe statystyki."""
        for proxy, stats in self._proxy_stats.items():
            total = stats['success'] + stats['ban'] + stats['error']
            rate = stats['success'] / total * 100 if total else 0
            spider.logger.info(
                f"FINAL {proxy}: {rate:.1f}% success ({total} total requests)"
            )

Co monitorować — kluczowe metryki

  • Success rate per proxy — jeśli spada poniżej 80%, pula jest wykrywana
  • Ban rate per status code — 403 vs 429 vs 503 oznaczają różne problemy
  • Avg response time per proxy — wolne IP mogą być rate-limited
  • Captcha frequency — sygnał, że target wykrywa automatyzację
  • Geographic success rates — niektóre kraje mogą mieć wyższy ban rate

Integracja z Prometheus/Grafana

Dla produkcji, eksportuj statystyki do Prometheus przez prometheus_client:

# monitoring.py — Prometheus metrics dla Scrapy
from prometheus_client import Counter, Histogram, start_http_server

PROXY_REQUESTS = Counter(
    'scrapy_proxy_requests_total',
    'Total proxy requests',
    ['proxy_host', 'status']
)

PROXY_LATENCY = Histogram(
    'scrapy_proxy_latency_seconds',
    'Proxy request latency',
    ['proxy_host']
)

BAN_COUNTER = Counter(
    'scrapy_proxy_bans_total',
    'Proxy bans detected',
    ['proxy_host', 'ban_type']
)


def start_metrics_server(port=9010):
    start_http_server(port)

Skalowanie — concurrency, headless fleet, containerization

Concurrency tuning

Scrapy domyślnie uruchamia 16 równoległych requestów. Z residential proxy możesz bezpiecznie zwiększyć tę wartość:

  • CONCURRENT_REQUESTS = 64 — residential proxy z rotacją
  • CONCURRENT_REQUESTS_PER_DOMAIN = 4 — ogranicz per domain
  • CONCURRENT_REQUESTS_PER_IP = 2 — ważne przy sticky sessions
  • DOWNLOAD_DELAY = 1 — opóźnienie zmniejsza ban rate

Headless fleet z Docker Compose

Dla dużych zadań, uruchom wiele instancji Scrapy jako osobne kontenery, każda z innym regionem proxy:

# docker-compose.yml — fleet z regionalnym proxy
version: '3.8'
services:
  scraper-us:
    build: .
    environment:
      - PROXY_STRATEGY=geo
      - PROXY_COUNTRY=US
      - PROXYHAT_USER=myuser
      - PROXYHAT_PASS=mypass
    command: scrapy crawl my_spider -a region=us

  scraper-de:
    build: .
    environment:
      - PROXY_STRATEGY=geo
      - PROXY_COUNTRY=DE
      - PROXYHAT_USER=myuser
      - PROXYHAT_PASS=mypass
    command: scrapy crawl my_spider -a region=de

  scraper-jp:
    build: .
    environment:
      - PROXY_STRATEGY=geo
      - PROXY_COUNTRY=JP
      - PROXYHAT_USER=myuser
      - PROXYHAT_PASS=mypass
    command: scrapy crawl my_spider -a region=jp

Najlepsze praktyki — podsumowanie

Kluczowa zasada: proxy to nie tylko IP — to strategia. Dobierz rotację do celu: per-request dla SERP, sticky dla e-commerce, geo dla lokalizowanych treści.

  • Nigdy nie hardkoduj credentials — używaj zmiennych środowiskowych lub Scrapy settings
  • Zawsze implementuj retry z proxy rotation — sam retry bez zmiany IP powtarza błąd
  • Monitoruj success rate — jeśli spada poniżej 80%, zmień strategię lub dostawcę
  • Używaj residential proxy dla anti-bot — datacenter IP są łatwe do wykrycia
  • Szacuj koszty — przy 1M requestów/dzień, bandwidth i concurrent connections mają znaczenie
  • Testuj z CONCURRENT_REQUESTS_PER_IP = 0 — przy rotacji per-request, Scrapy nie powinien ograniczać per-IP

Key Takeaways

  • Scrapy downloader middleware to właściwe miejsce na proxy — process_request wstrzykuje IP, process_response wykrywa bany
  • Własny ProxyRotatingMiddleware daje pełną kontrolę nad rotacją, geotargetingiem i sticky sessions
  • Przy residential proxy z gateway API (jak ProxyHat), własny middleware jest lepszy niż scrapy-rotating-proxies
  • Retry bez zmiany proxy jest bezcelowe — BanDetectionMiddleware musi wymuszać nowy IP
  • JS-heavy strony wymagają przekazania proxy do Splash lub Playwright — nie tylko do Scrapy
  • Monitoruj per-IP success rates — bez danych leczysz objawy, nie przyczyny
  • Sprawdź plany ProxyHat i dostępne lokalizacje dla Twojego przypadku użycia

Gotowy, aby zacząć?

Dostęp do ponad 50 mln rezydencjalnych IP w ponad 148 krajach z filtrowaniem AI.

Zobacz cenyProxy rezydencjalne
← Powrót do Bloga