Por qué tu spider de Scrapy necesita un middleware de proxies robusto
Si alguna vez lanzaste un spider contra un sitio protegido y viste cómo tus requests empezaban a devolver 403 Forbidden o captchas interminables, ya conoces el problema: las defensas anti-bot detectan tu IP de datacenter y la bloquean en minutos. La solución no es simplemente «añadir un proxy» — es integrar un middleware de proxies residenciales que rote IPs de forma inteligente, reintente con IPs nuevas cuando falla, y te dé visibilidad sobre qué IPs funcionan y cuáles no.
Este artículo cubre todo el stack: desde el modelo de middleware de Scrapy hasta despliegue en contenedores con monitoreo por IP. Vamos directo al código.
El modelo de downloader middleware en Scrapy y dónde encajan los proxies
Scrapy procesa cada request a través de una cadena de downloader middlewares. Cada middleware puede modificar el request antes de que salga, o modificar la response antes de que llegue a tu spider. El orden importa: Scrapy ejecuta los middlewares según el valor de priority (menor = más cerca del engine, mayor = más cerca del downloader).
El flujo relevante para proxies es:
- El spider genera un
Request. - Los middlewares de request processing se ejecutan en orden ascendente.
- El
HttpProxyMiddlewarenativo (priority 1100) leerequest.meta['proxy']y lo convierte en headersProxy-Authorization. - El request sale por la red con el proxy configurado.
- La respuesta vuelve y los middlewares de response processing se ejecutan en orden descendente.
Para rotación de proxies, necesitamos un middleware que se ejecute antes del HttpProxyMiddleware nativo — es decir, con un priority menor a 1100 — para inyectar request.meta['proxy'] antes de que Scrapy lo procese.
Regla clave: Tu middleware de rotación debe asignar
request.meta['proxy']yrequest.meta['proxy_userpass']enprocess_request(). ElHttpProxyMiddlewarenativo se encarga del resto.
Middleware personalizado: ProxyHatRotatingMiddleware
Aquí está el middleware completo. Soporta rotación por request, sesiones sticky, geo-targeting y tracking de éxito/fracaso por IP.
import random
import time
from collections import defaultdict
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.project import get_project_settings
class ProxyHatRotatingMiddleware:
"""Downloader middleware que rota proxies residenciales de ProxyHat."""
def __init__(self, proxy_user, proxy_pass, gateway, port, countries=None):
self.proxy_user = proxy_user
self.proxy_pass = proxy_pass
self.gateway = gateway
self.port = port
self.countries = countries or []
# Tracking de éxito/fracaso por proxy URL
self.stats = defaultdict(lambda: {"success": 0, "fail": 0, "last_fail": 0})
self._sticky_sessions = {} # spider_key -> proxy_url
@classmethod
def from_crawler(cls, crawler):
s = get_project_settings()
mw = cls(
proxy_user=s.get("PROXYHAT_USER"),
proxy_pass=s.get("PROXYHAT_PASS"),
gateway=s.get("PROXYHAT_GATEWAY", "gate.proxyhat.com"),
port=s.getint("PROXYHAT_PORT", 8080),
countries=s.getlist("PROXYHAT_COUNTRIES", []),
)
crawler.signals.connect(mw.spider_opened, signal=signals.spider_opened)
return mw
def spider_opened(self, spider):
spider.logger.info("ProxyHatRotatingMiddleware activado")
def _build_proxy_url(self, country=None, session_id=None):
"""Construye la URL del proxy con flags de geo y sesión."""
username = self.proxy_user
if country:
username = f"{username}-country-{country}"
if session_id:
username = f"{username}-session-{session_id}"
return f"http://{username}:{self.proxy_pass}@{self.gateway}:{self.port}"
def _select_proxy(self, request, spider):
"""Selecciona proxy: sticky si el spider lo pide, si no rotación aleatoria."""
# Si el request ya tiene proxy (por ejemplo, reintento), respetarlo
if request.meta.get("proxy"):
return request.meta["proxy"]
# Sesión sticky por dominio si el spider lo solicita
use_sticky = request.meta.get("sticky_proxy", False)
if use_sticky:
domain = request.url.split("//")[1].split("/")[0]
if domain in self._sticky_sessions:
return self._sticky_sessions[domain]
# Rotación: elegir país si está configurado
country = random.choice(self.countries) if self.countries else None
proxy_url = self._build_proxy_url(country=country)
if use_sticky:
domain = request.url.split("//")[1].split("/")[0]
self._sticky_sessions[domain] = proxy_url
return proxy_url
def process_request(self, request, spider):
proxy_url = self._select_proxy(request, spider)
request.meta["proxy"] = proxy_url
# Guardar la URL para tracking en process_response
request.meta["_proxy_url"] = proxy_url
# Para que HttpProxyMiddleware nativo funcione, extraer userpass
# (Scrapy >= 2.5 ya lo hace, pero lo dejamos explícito)
request.meta["proxy_userpass"] = f"{self.proxy_user}:{self.proxy_pass}"
spider.logger.debug(f"Proxy asignado: {self.gateway}:{self.port}")
def process_response(self, request, response, spider):
proxy_url = request.meta.get("_proxy_url")
if response.status in (200, 201, 202, 204):
self.stats[proxy_url]["success"] += 1
elif response.status in (403, 429, 503):
self.stats[proxy_url]["fail"] += 1
self.stats[proxy_url]["last_fail"] = int(time.time())
# Si es ban, marcar para rotar en reintento
if response.status == 403:
request.meta["proxy"] = None # Forzar nueva IP
request.meta.pop("_proxy_url", None)
return response
def process_exception(self, request, exception, spider):
proxy_url = request.meta.get("_proxy_url")
if proxy_url:
self.stats[proxy_url]["fail"] += 1
self.stats[proxy_url]["last_fail"] = int(time.time())
spider.logger.warning(f"Excepción con proxy: {exception}")
return None # Dejar que RetryMiddleware maneje el reintento
Configuración en settings.py:
# settings.py
# Desactivar el HttpProxyMiddleware nativo y usar el nuestro
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyHatRotatingMiddleware": 1000,
"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 1100,
}
PROXYHAT_USER = "tu_usuario"
PROXYHAT_PASS = "tu_password"
PROXYHAT_GATEWAY = "gate.proxyhat.com"
PROXYHAT_PORT = 8080
PROXYHAT_COUNTRIES = ["US", "DE", "GB"] # Rotar entre estos países
# Reintentos
RETRY_TIMES = 3
RETRY_HTTP_CODES = [403, 429, 500, 502, 503, 504]
Uso en el spider:
import scrapy
class PriceSpider(scrapy.Spider):
name = "prices"
def start_requests(self):
urls = ["https://ejemplo.com/producto/1"]
for url in urls:
# Sesión sticky: misma IP para todo el dominio
yield scrapy.Request(
url,
callback=self.parse,
meta={"sticky_proxy": True}
)
def parse(self, response):
# Rotación por request: nueva IP cada request
for next_page in response.css("a.next::attr(href)"):
yield response.follow(next_page, callback=self.parse)
scrapy-rotating-proxies vs. middleware propio: comparación
La comunidad mantiene scrapy-rotating-proxies, un paquete que gestiona una lista de proxies y los rota automáticamente. Es útil para prototipos, pero tiene limitaciones en producción:
| Característica | scrapy-rotating-proxies | Middleware propio (ProxyHat) |
|---|---|---|
| Fuente de proxies | Lista estática en settings | Pool dinámico residencial |
| Geo-targeting | No soportado | Por país/ciudad en el username |
| Sesiones sticky | No nativo | Por dominio o por request |
| Rotación por request | Sí (round-robin) | Sí (aleatorio ponderado) |
| Tracking de bans | Básico (ban list) | Stats por IP con timestamps |
| Mantenimiento | Comunidad (poco activo) | Tú lo controlas |
| Integración con proveedor | Genérico | Específico ProxyHat |
Cuándo usar scrapy-rotating-proxies: prototipos rápidos con una lista de proxies datacenter baratos que ya tienes.
Cuándo rolling your own: producción con proxies residenciales, geo-targeting, sesiones sticky, y necesidad de monitoreo granular. Básicamente, siempre que uses proxies residenciales en Scrapy para scraping serio.
RetryMiddleware con rotación de proxy: reintentar con IP nueva
El RetryMiddleware nativo de Scrapy reintenta requests fallidos, pero reutiliza el mismo proxy. Para producción, necesitamos que cada reintento use una IP diferente. La solución: un middleware de retry personalizado que limpie el proxy antes de reencolar.
from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.response import response_status_message
import logging
class ProxyRetryMiddleware(RetryMiddleware):
"""Retry que fuerza rotación de proxy en cada reintento."""
logger = logging.getLogger(__name__)
def process_response(self, request, response, spider):
# Si es un ban claro, forzar nueva IP y reintento
if response.status in (403, 429):
reason = response_status_message(response.status)
# Limpiar proxy para que ProxyHatRotatingMiddleware asigne uno nuevo
request.meta.pop("proxy", None)
request.meta.pop("_proxy_url", None)
request.meta.pop("proxy_userpass", None)
# Incrementar contador de reintentos
retries = request.meta.get("retry_times", 0) + 1
if retries <= self.max_retry_times:
request.meta["retry_times"] = retries
request.dont_filter = True # Permitir duplicados
spider.logger.info(
f"Reintento {retries}/{self.max_retry_times} "
f"con nueva IP para {request.url}"
)
return request
else:
spider.logger.error(
f"Max reintentos alcanzado para {request.url}"
)
return response
def process_exception(self, request, exception, spider):
# Timeout o error de conexión: rotar proxy
if isinstance(exception, (TimeoutError, ConnectionError)):
request.meta.pop("proxy", None)
request.meta.pop("_proxy_url", None)
request.meta.pop("proxy_userpass", None)
retries = request.meta.get("retry_times", 0) + 1
if retries <= self.max_retry_times:
request.meta["retry_times"] = retries
request.dont_filter = True
spider.logger.warning(
f"Timeout/error con proxy, rotando IP "
f"({retries}/{self.max_retry_times})"
)
return request
return None
Configuración:
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyHatRotatingMiddleware": 1000,
"myproject.middlewares.ProxyRetryMiddleware": 550, # Antes del retry nativo
"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 1100,
}
# Desactivar RetryMiddleware nativo
DOWNLOADER_MIDDLEWARES["scrapy.downloadermiddlewares.retry.RetryMiddleware"] = None
RETRY_TIMES = 5
DOWNLOAD_TIMEOUT = 30
scrapy-splash y scrapy-playwright: proxies para sitios con JavaScript
Los sitios modernos renderizan contenido crítico con JavaScript. Scrapy no ejecuta JS por defecto, así que necesitas un navegador headless. Dos opciones populares:
scrapy-splash (Splash, ligero)
Splash es un servicio de renderizado ligero basado en Qt. Pasa el proxy a través de Splash:
# settings.py
SPLASH_URL = "http://localhost:8050"
DOWNLOADER_MIDDLEWARES = {
"scrapy_splash.SplashDeduplicateArgsMiddleware": 723,
"scrapy_splash.SplashCookiesMiddleware": 723,
"scrapy_splash.SplashMiddleware": 725,
"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 1100,
}
DUPEFILTER_CLASS = "scrapy_splash.SplashAwareDupeFilter"
HTTPCACHE_STORAGE = "scrapy_splash.SplashAwareFSCacheStorage"
En el spider, pasa el proxy al request de Splash:
import scrapy
from scrapy_splash import SplashRequest
class JSSpider(scrapy.Spider):
name = "js_spider"
def start_requests(self):
yield SplashRequest(
"https://ejemplo.com/datos-dinamicos",
callback=self.parse,
endpoint="render.html",
args={
"proxy": "http://user-country-US:pass@gate.proxyhat.com:8080",
"wait": 3,
},
)
def parse(self, response):
for item in response.css(".product-item"):
yield {"name": item.css("h2::text").get()}
scrapy-playwright (Playwright, completo)
Playwright ofrece un navegador Chromium completo con mejor soporte para sitios modernos. Pasa el proxy directamente en meta:
# settings.py
DOWNLOAD_HANDLERS = {
"http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
"https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
}
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
# Playwright usará el proxy de request.meta["proxy"]
# En el spider
import scrapy
class PlaywrightSpider(scrapy.Spider):
name = "pw_spider"
def start_requests(self):
yield scrapy.Request(
"https://ejemplo.com/app",
callback=self.parse,
meta={
"playwright": True,
"playwright_include_page": False,
"proxy": "http://user-country-US:pass@gate.proxyhat.com:8080",
},
)
def parse(self, response):
yield {"title": response.css("title::text").get()}
Nota importante: Con Playwright, el proxy se pasa directamente en meta["proxy"]. No uses el HttpProxyMiddleware nativo junto con Playwright — el navegador maneja la conexión proxy internamente.
Despliegue: Scrapyd, ScrapeOps o Docker + cron
Opción 1: Scrapyd (simple, para 1-5 spiders)
Scrapyd es un servicio que corre spiders bajo demanda. Ideal si tienes pocos spiders y no necesitas orquestación compleja.
# Instalar Scrapyd
pip install scrapyd scrapyd-client
# Lanzar el servidor
scrapyd
# Desplegar
scrapyd-deploy
# Ejecutar spider
curl http://localhost:6800/schedule.json -d project=myproject -d spider=prices
Opción 2: ScrapeOps (monitoreo + scheduling)
ScrapeOps añade monitoreo, alertas y scheduling visual. Útil si necesitas visibilidad sin construir tu propio dashboard.
Opción 3: Docker + cron (máximo control)
Para producción seria, contenedores Docker con rotación de proxies integrada:
# 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"]
# docker-compose.yml
version: "3.8"
services:
spider:
build: .
environment:
- PROXYHAT_USER=${PROXYHAT_USER}
- PROXYHAT_PASS=${PROXYHAT_PASS}
deploy:
replicas: 3 # 3 instancias paralelas
restart_policy:
condition: on-failure
max_attempts: 3
Ejecuta con docker-compose up --scale spider=3 para 3 workers paralelos, cada uno con su propio proxy residencial rotando.
Monitoreo: tasas de éxito por IP y detección de bans
El ProxyHatRotatingMiddleware que construimos ya trackea stats por proxy. Ahora vamos a exponer esos datos y detectar bans automáticamente.
Extensión de monitoreo con exportación de métricas
import json
from scrapy import signals
from scrapy.exceptions import NotConfigured
class ProxyMonitorExtension:
"""Extensión que exporta métricas de proxy al cerrar el spider."""
def __init__(self, middleware, output_path):
self.middleware = middleware
self.output_path = output_path
@classmethod
def from_crawler(cls, crawler):
mw = None
# Buscar el middleware de rotación en los middlewares activos
for mw_cls in crawler.engine.downloader.middleware.middlewares:
if isinstance(mw_cls, ProxyHatRotatingMiddleware):
mw = mw_cls
break
if not mw:
raise NotConfigured("ProxyHatRotatingMiddleware no encontrado")
output_path = crawler.settings.get("PROXY_STATS_PATH", "proxy_stats.json")
ext = cls(mw, output_path)
crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed)
return ext
def spider_closed(self, spider):
stats = {}
for proxy_url, counts in self.middleware.stats.items():
total = counts["success"] + counts["fail"]
rate = counts["success"] / total if total > 0 else 0
stats[proxy_url] = {
"success": counts["success"],
"fail": counts["fail"],
"success_rate": f"{rate:.1%}",
"last_fail_ts": counts["last_fail"],
}
with open(self.output_path, "w") as f:
json.dump(stats, f, indent=2)
# Loggear resumen
spider.logger.info("=== ESTADÍSTICAS DE PROXY ===")
for proxy, data in stats.items():
spider.logger.info(
f"{proxy}: {data['success_rate']} éxito "
f"({data['success']} OK / {data['fail']} fail)"
)
# Alertar si la tasa global baja del 80%
total_success = sum(d["success"] for d in self.middleware.stats.values())
total_fail = sum(d["fail"] for d in self.middleware.stats.values())
total = total_success + total_fail
if total > 0 and total_success / total < 0.8:
spider.logger.error(
f"⚠ Tasa de éxito global baja: {total_success}/{total} "
f"({total_success/total:.1%}). Revisa tus proxies."
)
Detección de bans en tiempo real
Añade estas señales a tu middleware para detectar patrones de ban:
# Añadir a ProxyHatRotatingMiddleware.process_response
BAN_PATTERNS = [
"captcha", "cloudflare", "access denied",
"rate limit", "too many requests",
]
def _is_ban_response(self, response):
"""Detecta bans por status code o contenido."""
if response.status in (403, 429, 503):
return True
body_lower = response.text[:5000].lower()
return any(pattern in body_lower for pattern in self.BAN_PATTERNS)
Con esto, cada response se evalúa contra patrones de ban conocidos. Si se detecta un ban, el middleware fuerza rotación de IP en el reintento.
Patrones de escalado: concurrencia, headless fleet y containerización
Para escalar de 1 spider a una flota que procesa millones de páginas:
- Concurrencia: Ajusta
CONCURRENT_REQUESTSyCONCURRENT_REQUESTS_PER_DOMAINsegún el número de proxies en tu pool. Con proxies residenciales, puedes subir a 50-100 concurrentes sin problemas. - AutoThrottle: Activa
AUTOTHROTTLE_ENABLED = Truepara que Scrapy ajuste la concurrencia automáticamente según la latencia del servidor objetivo. - Fleet headless: Para JS rendering, lanza múltiples instancias de Playwright en contenedores separados, cada una con su propio proxy residencial.
- Containerización: Usa Kubernetes o Docker Swarm para escalar horizontalmente. Cada pod ejecuta un spider con configuración de proxy independiente.
# settings.py para alta concurrencia con proxies residenciales
CONCURRENT_REQUESTS = 64
CONCURRENT_REQUESTS_PER_DOMAIN = 8
CONCURRENT_REQUESTS_PER_IP = 2 # Limitar por IP para evitar bans
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_TARGET_CONCURRENCY = 4
AUTOTHROTTLE_MAX_DELAY = 30
DOWNLOAD_TIMEOUT = 30
RETRY_TIMES = 5
# Rotación agresiva de proxies residenciales
PROXYHAT_COUNTRIES = ["US", "DE", "GB", "FR", "ES"]
Tip de producción: Con proxies residenciales de ProxyHat, no necesitas gestionar una lista de IPs. El pool se rota automáticamente en el backend — solo configura el geo-targeting y la rotación se maneja sola.
Key Takeaways
- El middleware de proxy se ejecuta antes de HttpProxyMiddleware (priority < 1100) para inyectar
request.meta['proxy']antes de que Scrapy lo procese. - Rotación por request para scraping genérico, sesiones sticky para sitios que requieren consistencia de sesión (login, carritos).
- RetryMiddleware personalizado que limpia el proxy en cada reintento asegura que cada reintento use una IP nueva, no la misma que falló.
- scrapy-rotating-proxies es bueno para prototipos, pero en producción necesitas geo-targeting, sesiones sticky y tracking granular — mejor rolling your own.
- Para JS rendering, pasa el proxy directamente a Splash o Playwright — no uses HttpProxyMiddleware con ellos.
- Monitorea tasas de éxito por IP y detecta bans automáticamente con patrones de contenido y status codes.
- Escala con contenedores y concurrencia ajustada, no con una sola instancia gigante.
Si estás listo para llevar tu scraping a producción con proxies residenciales confiables, consulta los planes de ProxyHat — con geo-targeting por país y ciudad, rotación automática y pool de millones de IPs residenciales.






