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
En este artículo

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.

Preguntas frecuentes

¿Cómo configuro un proxy residencial en Scrapy?

Crea un downloader middleware que asigne request.meta['proxy'] antes del HttpProxyMiddleware nativo (priority < 1100). Usa el formato http://user-country-US:pass@gate.proxyhat.com:8080 para geo-targeting. El HttpProxyMiddleware nativo se encarga de convertir el proxy en headers Proxy-Authorization.

¿Cuál es la diferencia entre rotación por request y sesión sticky?

La rotación por request asigna una IP nueva a cada solicitud — ideal para scraping masivo donde no necesitas consistencia de sesión. La sesión sticky mantiene la misma IP para todas las solicitudes a un dominio — necesaria para sitios con login, carritos de compra o flujos multi-página que requieren la misma sesión.

¿Cómo reintento un request con una IP diferente en Scrapy?

Crea un RetryMiddleware personalizado que elimine request.meta['proxy'] y request.meta['_proxy_url'] antes de reencolar el request. Esto fuerza al middleware de rotación a asignar una IP nueva. Asegúrate de configurar request.dont_filter = True para permitir duplicados.

¿Puedo usar proxies con scrapy-playwright?

Sí. Pasa el proxy directamente en request.meta['proxy'] junto con meta['playwright'] = True. Playwright maneja la conexión proxy internamente. No uses HttpProxyMiddleware nativo junto con Playwright — el navegador gestiona la conexión.

¿Cómo detecto bans y errores de proxy en Scrapy?

En tu middleware de proxy, implementa process_response() para trackear status codes 403, 429 y 503. También escanea el body de la respuesta buscando patrones como 'captcha', 'cloudflare' o 'access denied'. Si detectas un ban, limpia el proxy del request meta para forzar rotación en el reintento.

¿Listo para empezar?

Proxies residenciales, ISP y móviles en más de 148 países. Crea una cuenta gratis.

Crear cuenta gratis
← Volver al Blog