Raspado Asíncrono en Python con httpx, aiohttp y Proxies

Async convierte un scraper lento en uno rápido, y uno rápido en uno bloqueado, a menos que configures correctamente los proxies, semáforos y tiempos de espera. Aquí tienes el patrón completo para httpx y aiohttp, con código.

Si tu scraper pasa la mayor parte de su tiempo de reloj esperando en la red, async es la mayor mejora de velocidad disponible, y los proxies son lo que evita que esa velocidad te lleve a ser bloqueado. Esta guía cubre el raspado asíncrono en Python con httpx y aiohttp más proxies de principio a fin: cómo cada biblioteca configura un proxy, cómo limitar la concurrencia con un semáforo, cómo establecer tiempos de espera que realmente se activen y cómo rotar IPs sin destruir tus sesiones. Todo con código ejecutable y credenciales de marcador de posición que puedes cambiar por las tuyas.

Por qué async y dónde encajan los proxies

El requests estándar es bloqueante: cada llamada espera la respuesta antes de que comience la siguiente. Si obtienes 500 páginas, pagas 500 idas y vueltas consecutivas. Async las obtiene concurrentemente en un solo bucle de eventos, por lo que el tiempo total se reduce hacia la solicitud individual más lenta en lugar de la suma. El problema: un estallido de solicitudes concurrentes desde una IP es exactamente la firma que los sistemas anti-bot buscan. La solución no es ralentizar hasta arrastrarse, sino distribuir el tráfico a través de un pool de proxies rotativos y regular deliberadamente con un semáforo.

Configurar un proxy en httpx (async)

httpx es el predeterminado pragmático porque un modelo de cliente hace tanto sync como async, y habla HTTP/2. Nota la API moderna: es un argumento singular proxy= en el cliente, no el antiguo diccionario proxies=, una fuente común de confusión "por qué se ignora mi proxy" después de una actualización. Las credenciales van directamente en la URL del proxy.

import asyncio, httpx

PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"

async def fetch(client, url):
    r = await client.get(url, timeout=httpx.Timeout(20.0))
    return url, r.status_code, r.text

async def main(urls):
    async with httpx.AsyncClient(proxy=PROXY, http2=True) as client:
        tasks = [fetch(client, u) for u in urls]
        return await asyncio.gather(*tasks)

urls = ["https://httpbin.org/ip"] * 5
print(asyncio.run(main(urls)))

Un solo cliente, reutilizado en cada solicitud, es el punto: mantiene la piscina de conexiones caliente para que evites repetidos handshakes TLS a través del proxy. Crear un cliente nuevo por solicitud es el error de rendimiento asíncrono más común: descarta la reutilización de conexiones y el estado de cookies en cada llamada.

Configurar un proxy en aiohttp (por solicitud)

aiohttp es nativo de asyncio y te da el control de concurrencia más fino, pero su convención de proxy difiere: el proxy se pasa por solicitud en session.get(), no en la sesión. Eso es realmente conveniente para la rotación. Dos cosas muerden a las personas aquí: el User-Agent predeterminado es literalmente Python/3.x aiohttp/3.x, una señal obvia que debes sobrescribir, y debes dimensionar la piscina de conexiones explícitamente.

import aiohttp, asyncio

PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
           "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"}

async def fetch(session, url):
    timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
    async with session.get(url, proxy=PROXY, timeout=timeout) as resp:
        return url, resp.status, await resp.text()

async def main(urls):
    conn = aiohttp.TCPConnector(limit=20, limit_per_host=8)
    async with aiohttp.ClientSession(connector=conn, headers=HEADERS) as session:
        return await asyncio.gather(*(fetch(session, u) for u in urls))

print(asyncio.run(main(["https://httpbin.org/ip"] * 5)))

TCPConnector(limit=20, limit_per_host=8) limita el total de conexiones abiertas y las conexiones a cualquier host único, una primera línea de cortesía que evita que un objetivo absorba toda tu piscina.

Diagrama de un bucle de raspado asíncrono: asyncio.gather alimentando un semáforo, luego un gateway de proxy rotativo, luego obtenciones de objetivos en paralelo
El semáforo regula; el gateway rotativo disfraza. Necesitas ambos: velocidad asíncrona sin ninguno es una ola de bloqueos.

Limitar la concurrencia con un semáforo

Disparar solicitudes concurrentes ilimitadas es la forma más rápida de quemar un pool de proxies y activar límites de tasa. Un asyncio.Semaphore es el regulador: limita cuántas solicitudes están en vuelo a la vez, independientemente de cuántas tareas encolas. Establece un límite global, y para trabajos agresivos, un límite por dominio también.

sem = asyncio.Semaphore(15)  # never more than 15 requests in flight

async def guarded_fetch(client, url):
    async with sem:
        return await fetch(client, url)

async def main(urls):
    async with httpx.AsyncClient(proxy=PROXY) as client:
        return await asyncio.gather(*(guarded_fetch(client, u) for u in urls))

El número correcto depende de la tolerancia del objetivo y el tamaño de tu pool, no de cuán rápido puede ir tu máquina. Comienza conservador (10–20), observa tu tasa de bloqueos y solo aumenta mientras el éxito se mantenga alto. En una sola IP fija, mantenlo en dígitos únicos.

Tiempos de espera: modela cada fase

Una solicitud con proxy falla en más lugares que una directa: resolución DNS, conexión al proxy, configuración de túnel, conexión al objetivo, espera de encabezados y lectura del cuerpo son todas pausas distintas. Un único tiempo de espera general oculta qué fase se colgó. ClientTimeout(total=, connect=, sock_read=) de aiohttp y Timeout() de httpx te permiten delimitarlas por separado. La única regla sin excepciones: nunca emitas una solicitud sin un tiempo de espera, o una salida muerta colgará una corrutina para siempre y agotará silenciosamente tu bucle de eventos.

Rotar proxies sin romper sesiones

Existen dos estrategias de rotación y elegir la incorrecta corrompe tus datos. La rotación aleatoria por solicitud es perfecta para obtenciones de páginas sin estado. Pero destruye cualquier flujo que dependa de cookies, inicio de sesión o localización, porque la segunda solicitud aterriza en una IP diferente a la primera. La división limpia: rota en el límite de la unidad lógica, una IP por segmento de rastreo o por cuenta, y usa un gateway rotativo que te entrega una salida nueva automáticamente para que tu código nunca maneje una lista.

# rotating gateway: one endpoint, new exit IP per request
ROT = "http://USER:PASS@rotating.quantumproxies.io:8000"

# sticky session: same IP for a multi-step flow, tag the session id
STICKY = "http://USER-session-a1b2:PASS@gate.quantumproxies.io:8000"

async def crawl_segment(urls):
    async with httpx.AsyncClient(proxy=ROT) as client:  # rotates per call
        return await asyncio.gather(*(fetch(client, u) for u in urls))

Un gateway residencial rotativo es el predeterminado pragmático para raspado de alta concurrencia: rotación por solicitud a través de más de 90 millones de IPs en más de 200 países, con sesiones fijas cuando un carrito o inicio de sesión necesita la misma salida por unos minutos. Si estás sopesando residencial contra ISP o centro de datos para el trabajo, nuestra guía sobre qué tipo de proxy usar expone las compensaciones.

Obtén un gateway residencial rotativo

Reintentos, retroceso y jitter

Salidas muertas y bloqueos transitorios son normales a escala, no excepcionales. Pero los reintentos ingenuos empeoran las cosas: cuando 50 tareas asíncronas fallan al mismo instante y todas reintentan inmediatamente, disparas un estallido sincronizado que golpea el objetivo más fuerte que la ejecución original. Añade retroceso exponencial más jitter aleatorio para que los reintentos se distribuyan, limita los intentos y rota la IP en caso de fallo en lugar de reutilizar la quemada.

import random
from httpx import HTTPError

async def robust_fetch(client, url, tries=3):
    for attempt in range(tries):
        try:
            r = await client.get(url, timeout=httpx.Timeout(20.0))
            if r.status_code < 400 and looks_real(r.text):
                return r
        except HTTPError:
            pass
        # exponential backoff + jitter before the next attempt
        await asyncio.sleep((2 ** attempt) + random.uniform(0, 1))
    return None
Comparación lado a lado de las convenciones de proxy, tiempos de espera y controles de concurrencia de httpx y aiohttp para raspado asíncrono
Dos bibliotecas, dos convenciones de proxy: httpx configura el proxy en el cliente, aiohttp por solicitud.

Un 200 no es un éxito

El error más sutil en el raspado asíncrono es tratar HTTP 200 como completado. Los sistemas anti-bot devuelven 200 con una página CAPTCHA, un aviso de acceso denegado, un conjunto de resultados vacío o un desafío JS, por lo que un proxy que califica "éxito" solo por el código de estado te está alimentando silenciosamente páginas bloqueadas. Valida el contenido: verifica un elemento conocido, una longitud mínima o la ausencia de marcadores de desafío antes de confiar en una respuesta. Esa es la puerta looks_real() en el bucle de reintento anterior.

Cuándo dejar de construir la pila a mano

El patrón anterior: cliente asíncrono, semáforo, tiempos de espera, rotación, validación de contenido, maneja la mayoría de los objetivos limpiamente. Pero una vez que un sitio añade Cloudflare, huellas digitales TLS o renderizado pesado del lado del cliente, el HTTP asíncrono puro comienza a fallar independientemente de tu proxy, porque un handshake TLS en Python no se parece en nada al de Chrome. En esa línea, un Scraper API que lleva una huella digital de navegador real, rota IPs y renderiza JavaScript bajo demanda es menos código y una tasa de éxito más alta que mantenerlo todo a mano. Nuestro post sobre navegador sin cabeza vs costo de HTTP cubre dónde esa escalada vale la pena.

Preguntas frecuentes

¿Cómo uso un proxy con aiohttp?

Pasa la URL del proxy por solicitud: session.get(url, proxy="http://user:pass@host:port"). A diferencia de requests, aiohttp no toma un diccionario de proxies en la sesión. Siempre sobrescribe el User-Agent predeterminado (Python/3.x aiohttp/3.x es una señal de bot obvia) y establece un ClientTimeout para que una salida muerta no pueda colgar la corrutina.

¿Es mejor httpx o aiohttp para raspado asíncrono?

Elige httpx como predeterminado: un cliente funciona sync y async, HTTP/2 está integrado y el proxy es un solo argumento. Elige aiohttp cuando quieras el máximo control de concurrencia: límites explícitos de la piscina de conexiones y proxies por solicitud se adaptan a rastreos grandes y rápidos. Ambos están bien; la estrategia de proxy importa más que la biblioteca.

¿Cuántas solicitudes concurrentes debo ejecutar?

No tantas como permite tu máquina, sino tantas como el objetivo y tu pool toleren. Comienza con un semáforo de 10–20 en vuelo, observa la tasa de bloqueos y errores, y solo aumenta mientras el éxito se mantenga alto. En una sola IP fija, mantente en dígitos únicos. Una rotación de IPs más amplia te permite ejecutar una concurrencia total más alta de manera segura.

¿Por qué mi scraper asíncrono es bloqueado cuando el síncrono no lo fue?

Porque la concurrencia concentra la señal: muchas solicitudes simultáneas desde una IP es un patrón clásico de bot. Distribuye la carga a través de un pool rotativo, regula con un semáforo, añade retroceso con jitter en los reintentos y valida el contenido de la respuesta: un 200 aún puede ser una página de desafío. La velocidad sin rotación es lo que te bloqueó.

Ese es el patrón asíncrono completo: elige un cliente, configura el proxy de la manera correcta para él, limita la concurrencia con un semáforo, delimita cada fase de tiempo de espera, rota en el límite lógico y nunca confíes en un 200 desnudo. Configura correctamente la capa de proxy primero y la mayoría de la lista de bloqueos desaparece antes de que la alcances.

Prueba el QuantumProxies Scraper API