Scraping por lenguaje 9 min de lectura

Scraping asíncrono en Python: aiohttp y asyncio

Acelere significativamente su scraping con asyncio y aiohttp: peticiones concurrentes, límites de carga, manejo de errores y timeouts.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 19 enero 2025

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

  1. Por qué async acelera el scraping
  2. Cómo descargamos la página: aiohttp
  3. Parseo del contenido en código async
  4. Acentos y codificaciones en el scraping asíncrono
  5. Control de la concurrencia: semáforos
  6. Proxies
  7. Scraping a través de TOR
  8. HTTPS/SSL
  9. Trabajar con cookies
  10. Estado de la respuesta y cabeceras
  11. Colas: asyncio.Queue
  12. httpx como alternativa
  13. 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.

python
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:

python
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:

python
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:

python
# 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:

python
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:

python
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:

python
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:

python
# 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 TOR

El 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:

python
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».


ClientSession conserva las cookies entre peticiones de forma automática, igual que requests.Session:

python
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

python
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:

python
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:

python
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 responses

Si 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.sleep no bloquea las demás tareas.
  • Control fino de la velocidad mediante semáforos.

Inconvenientes:

  • Es más difícil de escribir y depurar (async/await por 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».