El scraping asíncrono es donde las configuraciones de proxy se desmoronan silenciosamente. Con requests rotas una IP por llamada y sigues adelante; con asyncio de repente tienes 200 solicitudes en vuelo, todas necesitando su propio exit, su propia afinidad de sesión y su propio reintento cuando un objetivo lanza un 403. Hazlo mal y o machacas una IP hasta el baneo o destrozas cada sesión de inicio de sesión al rotar a mitad del flujo.
Esta guía muestra los patrones que de verdad aguantan en concurrencia: rotación por solicitud en httpx y aiohttp, un pool de workers acotado, sesiones sticky para flujos con estado y reintentos conscientes de bloqueos — con código funcional que puedes pegar.
Un scraper síncrono toca un proxy a la vez, así que "rota en cada solicitud" es trivialmente correcto. Async rompe tres supuestos a la vez:
httpx.AsyncClient vincula un proxy por cliente, así que para rotar eliges el proxy por solicitud y enrutas a través de un pequeño pool de clientes indexados por exit:
import asyncio, itertools, httpx
PROXIES = [
"socks5h://USERNAME:[email protected]:913",
"socks5h://USERNAME:[email protected]:913",
"socks5h://USERNAME:[email protected]:913",
]
pool = itertools.cycle(PROXIES)
# One reusable client per exit (connection pooling stays intact)
clients = {p: httpx.AsyncClient(proxy=p, timeout=30) for p in PROXIES}
async def fetch(url):
proxy = next(pool)
r = await clients[proxy].get(url)
return r.status_code, r.text
async def main(urls):
results = await asyncio.gather(*(fetch(u) for u in urls))
for client in clients.values():
await client.aclose()
return results
Reutilizar un cliente por exit mantiene vivos HTTP/2 y el pooling de conexiones en lugar de pagar un handshake nuevo en cada solicitud. Ten en cuenta que httpx>=0.28 renombró proxies= a proxy= en el cliente.
aiohttp es más sencillo aquí — una sola ClientSession acepta un argumento proxy= por solicitud, así que rotas en línea:
import asyncio, itertools, aiohttp
pool = itertools.cycle(PROXIES) # http:// or socks5h:// (needs aiohttp-socks for SOCKS)
async def fetch(session, url):
proxy = next(pool)
async with session.get(url, proxy=proxy, timeout=aiohttp.ClientTimeout(total=30)) as r:
return r.status, await r.text()
async def main(urls):
async with aiohttp.ClientSession() as session:
return await asyncio.gather(*(fetch(session, u) for u in urls))
aiohttp habla proxies HTTP de forma nativa; para socks5h:// añade aiohttp-socks y pasa un ProxyConnector.
Un gather sin límites es la forma más rápida de que todas las IPs de tu pool sean marcadas a la vez. Acótalo para que cada exit lleve un número de solicitudes paralelas plausible para un humano:
sem = asyncio.Semaphore(10) # at most 10 in flight
async def fetch_capped(session, url):
async with sem:
return await fetch(session, url)
Regla general: mantén la concurrencia en o por debajo del número de exits distintos que tienes, para no apilar muchas solicitudes paralelas sobre una única IP residencial.
La rotación es para entrar; las sesiones sticky son para quedarte. Un inicio de sesión, un carrito, cualquier cosa atada a cookies debe mantener un exit durante toda la secuencia. Fija el proxy por tarea en lugar de por solicitud:
async def run_account(account, proxy):
# Same exit IP for every step of this account's flow
async with httpx.AsyncClient(proxy=proxy, timeout=30) as client:
await client.post("https://target.site/login", data=account.creds)
await client.get("https://target.site/dashboard")
await client.post("https://target.site/cart", json=account.order)
async def main(accounts):
# One sticky exit per account, accounts run concurrently
await asyncio.gather(*(run_account(a, p)
for a, p in zip(accounts, itertools.cycle(PROXIES))))
Usa un proveedor que admita sesiones residenciales sticky para que el mismo exit se mantenga durante toda la vida de la tarea, en lugar de rotar silenciosamente bajo tus pies.
En concurrencia no puedes confiar en un 200 — los sistemas antibot devuelven un 200 con un cuerpo de desafío. Detecta los bloqueos y reintenta la única tarea fallida en un exit nuevo:
BLOCK_MARKERS = ("just a moment", "/cdn-cgi/challenge-platform",
"datadome", "px-captcha", "access denied")
def looks_blocked(status, text):
return status in (403, 429, 503) or any(m in text[:4000].lower() for m in BLOCK_MARKERS)
async def fetch_retry(url, attempts=4):
for _ in range(attempts):
proxy = next(pool)
async with httpx.AsyncClient(proxy=proxy, timeout=30) as c:
r = await c.get(url)
if not looks_blocked(r.status_code, r.text):
return r
await asyncio.sleep(0.5)
raise RuntimeError(f"blocked after {attempts} exits: {url}")
Una pega que async no resuelve: httpx y aiohttp se apoyan ambos en la pila TLS de Python, así que envían un JA3 que ningún navegador real produce. Un pool de rotación perfecto sobre una IP residencial limpia sigue siendo identificado como automatización en el handshake. Combina la rotación asíncrona con la suplantación de TLS — consulta Evade el fingerprinting de TLS con curl_cffi, y verifica el JA3 + ASN de tu exit en www.jibaoproxy.com/tools/fingerprint.html.
Los nuevos usuarios reciben 500MB al registrarse, más un bono en la primera recarga. Oferta por tiempo limitado.