Guía completa de Scrapy proxy middleware: rotación de proxies residenciales en producción

Aprende a integrar proxies residenciales en Scrapy usando middleware personalizado, manejar reintentos con rotación de IP, renderizar JS con proxies y desplegar en producción con monitoreo por IP.

Guía completa de Scrapy proxy middleware: rotación de proxies residenciales en producción

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:

  1. El spider genera un Request.
  2. Los middlewares de request processing se ejecutan en orden ascendente.
  3. El HttpProxyMiddleware nativo (priority 1100) lee request.meta['proxy'] y lo convierte en headers Proxy-Authorization.
  4. El request sale por la red con el proxy configurado.
  5. 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'] y request.meta['proxy_userpass'] en process_request(). El HttpProxyMiddleware nativo 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_REQUESTS y CONCURRENT_REQUESTS_PER_DOMAIN según el número de proxies en tu pool. Con proxies residenciales, puedes subir a 50-100 concurrentes sin problemas.
  • AutoThrottle: Activa AUTOTHROTTLE_ENABLED = True para 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.

¿Listo para empezar?

Accede a más de 50M de IPs residenciales en más de 148 países con filtrado impulsado por IA.

Ver preciosProxies residenciales
← Volver al Blog