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 URLprocess_response(request, response, spider)— analizuje response; tutaj wykrywasz banyprocess_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ć
| Strategia | Zastosowanie | Zalety | Wady |
|---|---|---|---|
| per_request | SERP scraping, masowe crawlowanie | Maksymalna anonimowość, trudne do zablokowania | Brak sesji — problemy z logowaniem |
| sticky | E-commerce, formularze, koszyki | Utrzymanie sesji na domenę | IP może zostać zbanowany w ramach sesji |
| geo | Lokalizowane treści, ceny regionalne | Dokładne targetowanie kraju/miasta | Wię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:
| Kryterium | scrapy-rotating-proxies | Własny middleware |
|---|---|---|
| Czas wdrożenia | 5 minut (pip install) | 1-2 godziny |
| Zarządzanie pulą IP | Automatyczne (ban/probe) | Ręczne lub semi-automatyczne |
| Integracja z residential proxy | Ograniczona — wymaga listy IP | Natywna — przez gateway URL |
| Geotargeting | Brak | Pełny — przez username flags |
| Sticky sessions | Podstawowe | Pelna kontrola (per-domain, TTL) |
| Elastyczność | Niska — konfiguracja przez settings | Wysoka — pełna logika Python |
| Utrzymanie | Zależność zewnętrzna | Peł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
| Platforma | Koszt | Kontrola proxy | Scaling | Złożoność |
|---|---|---|---|---|
| Docker + cron | Darmowy (VPS) | Pełna | Ręczny | Niska |
| Scrapyd | Darmowy (VPS) | Pełna | Średni | Średnia |
| ScrapeOps | Płatny | Ograniczona | Auto | Niska |
| Zyte Cloud | Płatny | Własne proxy | Auto | Niska |
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 domainCONCURRENT_REQUESTS_PER_IP = 2— ważne przy sticky sessionsDOWNLOAD_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_requestwstrzykuje IP,process_responsewykrywa 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






