Cuando hay que descargar miles o decenas de miles de páginas, el código síncrono se estrella contra un muro: cada petición espera su respuesta antes de que arranque la siguiente. Los hilos ayudan, pero consumen memoria y arrastran una sobrecarga considerable. La asincronía resuelve la tarea con más elegancia: un solo hilo mantiene miles de conexiones simultáneas y va alternando entre ellas mientras esperan la red.
Este artículo es la continuación avanzada de la guía general «Web scraping con Python». Las técnicas básicas (bibliotecas, codificaciones) están explicadas allí; aquí veremos cómo escalar el scraping con asyncio.
Índice
- Por qué async acelera el scraping
- Cómo descargamos la página: aiohttp
- Parseo del contenido en código async
- Acentos y codificaciones en el scraping asíncrono
- Control de la concurrencia: semáforos
- Proxies
- Scraping a través de TOR
- HTTPS/SSL
- Trabajar con cookies
- Estado de la respuesta y cabeceras
- Colas: asyncio.Queue
- httpx como alternativa
- Ventajas e inconvenientes
1. Por qué async acelera el scraping
El scraping es una tarea I/O-bound: el 99% del tiempo el programa se limita a esperar la respuesta del servidor. En código síncrono esa espera se desperdicia. La asincronía permite que, mientras una petición espera, se lancen cientos de otras.
- Síncrono: 1000 páginas a 0.5 s cada una = ~500 segundos.
- Asíncrono (100 a la vez): las mismas 1000 páginas = ~5 segundos.
A diferencia de los hilos, las corrutinas apenas cuestan memoria: decenas de miles de tareas simultáneas en un solo hilo son perfectamente viables. Compárelo con el enfoque multihilo de la guía general: async escala bastante más alto.
2. Cómo descargamos la página: aiohttp
aiohttp es el cliente HTTP asíncrono estándar. El principio clave: un único ClientSession para todo el programa (reutiliza las conexiones) y múltiples peticiones simultáneas mediante asyncio.gather.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
return await resp.text()
async def main(urls):
async with aiohttp.ClientSession(headers={"User-Agent": "MyBot/1.0"}) as session:
tasks = [fetch(session, url) for url in urls]
pages = await asyncio.gather(*tasks, return_exceptions=True)
return pages
urls = [f"https://example.com/page/{i}" for i in range(1, 1001)]
results = asyncio.run(main(urls))return_exceptions=True importa: una tarea que falla no tumba todo el gather, sino que vuelve como un objeto de excepción que luego podrá procesar.
3. Parseo del contenido en código async
Un matiz importante: el análisis del HTML en sí (BeautifulSoup, lxml) es una operación de CPU síncrona. Si el HTML es pesado, el parseo bloquea el event loop y anula la ganancia del async. Las páginas ligeras pueden parsearse directamente en la corrutina:
from bs4 import BeautifulSoup
async def fetch_and_parse(session, url):
async with session.get(url) as resp:
html = await resp.text()
soup = BeautifulSoup(html, "lxml") # para páginas ligeras, vale
return soup.find("h1").get_text(strip=True)Si el análisis es pesado, sáquelo a un pool de procesos para no bloquear el bucle:
import asyncio
from concurrent.futures import ProcessPoolExecutor
def heavy_parse(html):
soup = BeautifulSoup(html, "lxml")
return [a["href"] for a in soup.select("a")]
async def fetch_and_parse(session, url, pool):
async with session.get(url) as resp:
html = await resp.text()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(pool, heavy_parse, html)El detalle sobre los parsers está en la guía general y en el artículo sobre lxml (una de las opciones más rápidas para cargas async).
A menudo lo que se consulta de forma asíncrona no son páginas HTML, sino APIs: el cuerpo de la respuesta ya viene estructurado y, en lugar de un parser, basta con await resp.json(). Es más rápido y más fiable que desmenuzar la maquetación; las técnicas para trabajar con esas respuestas están recogidas en «Parseo de JSON».
4. Acentos y codificaciones en el scraping asíncrono
Al llamar a await resp.text(), aiohttp intenta deducir la codificación a partir de las cabeceras. En sitios antiguos que aún sirven ISO-8859-1 o Windows-1252 (justo donde viven las tildes y las eñes), la detección falla a veces y el texto llega ilegible. Las soluciones son las mismas que en código síncrono:
# opción 1: codificación explícita
html = await resp.text(encoding="utf-8")
# opción 2: trabajar con los bytes y entregárselos al parser
raw = await resp.read()
soup = BeautifulSoup(raw, "lxml") # el parser leerá <meta charset> por sí mismo
# opción 3: decodificación manual
html = raw.decode("windows-1252", errors="replace")La teoría completa del problema está en la guía general, sección «Codificaciones».
5. Control de la concurrencia: semáforos
Lanzar 10 000 peticiones de golpe significa «tumbar» el servidor y su propia red, además de ganarse un baneo. La concurrencia se limita con un semáforo:
import asyncio
import aiohttp
async def fetch(session, url, semaphore):
async with semaphore: # no más de N a la vez
async with session.get(url) as resp:
return await resp.text()
async def main(urls, concurrency=20):
semaphore = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, semaphore) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)Semaphore(20) garantiza que nunca haya más de 20 peticiones activas al mismo tiempo. Es su principal palanca de «cortesía»: ajuste el valor para no sobrecargar el sitio objetivo. Añada pequeñas pausas aleatorias (await asyncio.sleep(random.uniform(0.1, 0.5))) para un ritmo más natural.
6. Proxies
En aiohttp, el proxy se pasa como parámetro de la petición:
async with session.get(url, proxy="http://user:pass@ip:port") as resp:
html = await resp.text()La rotación consiste simplemente en elegir un proxy al azar en cada petición:
import random
PROXIES = ["http://ip1:port", "http://ip2:port", "http://ip3:port"]
async def fetch(session, url):
proxy = random.choice(PROXIES)
async with session.get(url, proxy=proxy) as resp:
return await resp.text()La estrategia general de trabajo con proxies (tipos, descarte de los caídos) está en la guía general, sección «Proxies».
7. Scraping a través de TOR
aiohttp no soporta SOCKS de forma nativa; hace falta el paquete aiohttp-socks:
# pip install aiohttp-socks
import aiohttp
from aiohttp_socks import ProxyConnector
async def main(urls):
connector = ProxyConnector.from_url("socks5://127.0.0.1:9050")
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get("https://httpbin.org/ip") as resp:
print(await resp.json()) # IP del nodo de salida de TOREl cambio de nodo de salida mediante la señal NEWNYM (biblioteca stem) está descrito en la guía general, sección «TOR». Tenga en cuenta que TOR es lento: con una concurrencia alta se convertirá en el cuello de botella.
8. HTTPS/SSL
Por defecto, aiohttp verifica los certificados. Puede desactivar la verificación (solo para depurar) o aportar su propio contexto:
import ssl
# desactivar la verificación — NO para producción
async with session.get(url, ssl=False) as resp:
...
# contexto SSL propio
ctx = ssl.create_default_context(cafile="/path/to/ca.crt")
async with session.get(url, ssl=ctx) as resp:
...Los principios de seguridad de la conexión, en la guía general, sección «HTTPS/SSL».
9. Trabajar con cookies
ClientSession conserva las cookies entre peticiones de forma automática, igual que requests.Session:
async with aiohttp.ClientSession() as session:
# login: el servidor asigna la cookie de sesión
await session.post("https://example.com/login",
data={"user": "u", "pass": "p"})
# las peticiones siguientes ya van autenticadas
async with session.get("https://example.com/profile") as resp:
html = await resp.text()También se pueden pasar cookies a mano con el parámetro cookies={...}. Más detalles en la guía general, sección «Cookies».
10. Estado de la respuesta y cabeceras
async with session.get(url) as resp:
print(resp.status) # 200, 404 ...
print(resp.headers.get("Content-Type"))
if resp.status == 429:
wait = int(resp.headers.get("Retry-After", 60))
await asyncio.sleep(wait) # ¡no bloquea las demás tareas!
resp.raise_for_status()La ventaja clave: al gestionar un 429, await asyncio.sleep() duerme solo a esa corrutina; las demás siguen trabajando. En código síncrono, time.sleep() lo congelaría todo. La lógica de los códigos de estado, en la guía general.
11. Colas: asyncio.Queue
Para el crawling «a medida que se descubren enlaces» se usan asyncio.Queue y un pool de workers-corrutinas:
import asyncio
import aiohttp
async def worker(name, queue, session, visited):
while True:
url = await queue.get()
if url not in visited:
visited.add(url)
try:
async with session.get(url) as resp:
html = await resp.text()
# ... encontrar enlaces nuevos y ponerlos en la cola:
# for link in extract_links(html):
# await queue.put(link)
except Exception as exc:
print(f"{name} error en {url}: {exc}")
queue.task_done()
async def crawl(start_urls, num_workers=10):
queue = asyncio.Queue()
visited = set()
for url in start_urls:
queue.put_nowait(url)
async with aiohttp.ClientSession() as session:
workers = [asyncio.create_task(worker(f"w{i}", queue, session, visited))
for i in range(num_workers)]
await queue.join() # esperamos a que la cola se vacíe
for w in workers:
w.cancel()El set sirve para la deduplicación y la Queue coordina a los workers: es el análogo asíncrono del frontier de la guía general. Para un recorrido distribuido, la cola se traslada a Redis. La implementación industrial de este esquema la ofrece Scrapy (que por dentro también es asíncrono).
12. httpx como alternativa
httpx es un cliente moderno con la misma API para código síncrono y asíncrono, y con soporte de HTTP/2:
import httpx
import asyncio
async def main(urls):
async with httpx.AsyncClient(http2=True, timeout=15) as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks, return_exceptions=True)
return responsesSi quiere alternar el mismo código entre sync y async y necesita HTTP/2, httpx resulta más cómodo que aiohttp. En velocidad pura sobre grandes volúmenes están a la par.
13. Ventajas e inconvenientes del scraping asíncrono
Ventajas:
- Concurrencia enorme con un consumo de memoria mínimo.
- Aceleración de varias veces en las tareas I/O-bound.
- «Pausas» baratas:
asyncio.sleepno bloquea las demás tareas. - Control fino de la velocidad mediante semáforos.
Inconvenientes:
- Es más difícil de escribir y depurar (
async/awaitpor todas partes). - El parseo CPU-bound sigue bloqueando el bucle: hace falta un pool de procesos.
- No se puede mezclar con bibliotecas bloqueantes sin
run_in_executor. - Es fácil sobrecargar el sitio objetivo: exige disciplina con los semáforos.
Cuándo elegirlo: miles de páginas o más y la velocidad importa. Para un par de cientos de páginas es más sencillo requests + ThreadPoolExecutor. Para un crawling completo de todo un sitio, Scrapy, que ya trae integradas la asincronía y las colas. Y si el scraper vive dentro de una aplicación web, tenga en cuenta que el ORM de Django sigue siendo mayoritariamente síncrono y el código async allí exige cuidado (sync_to_async); lo tratamos en «Web scraping con Django».