Scraping por linguagem 9 min de leitura

Scraping assíncrono em Python: aiohttp e asyncio

Aumente significativamente a velocidade do seu scraping com asyncio e aiohttp: requisições concorrentes, limites de carga, tratamento de erros e timeouts.

EW
Equipe Web-Scraping.biz
Coleta de dados para as demandas do negócio
Publicado: 19 janeiro 2025

Quando é preciso baixar milhares ou dezenas de milhares de páginas, o código síncrono esbarra em um muro: cada requisição espera a resposta antes de a seguinte começar. Threads ajudam, mas consomem memória e arrastam uma sobrecarga considerável. A assincronia resolve a tarefa com mais elegância: uma única thread mantém milhares de conexões simultâneas e vai alternando entre elas enquanto esperam a rede.

Este artigo é a continuação avançada do guia geral «Web scraping com Python». As técnicas básicas (bibliotecas, codificações) estão explicadas lá; aqui veremos como escalar o scraping com asyncio.

Sumário

  1. Por que async acelera o scraping
  2. Como baixamos a página: aiohttp
  3. Parsing do conteúdo em código async
  4. Acentos e codificações no scraping assíncrono
  5. Controle da concorrência: semáforos
  6. Proxies
  7. Scraping através do TOR
  8. HTTPS/SSL
  9. Trabalho com cookies
  10. Status da resposta e cabeçalhos
  11. Filas: asyncio.Queue
  12. httpx como alternativa
  13. Vantagens e desvantagens

1. Por que async acelera o scraping

O scraping é uma tarefa I/O-bound: 99% do tempo o programa se limita a esperar a resposta do servidor. Em código síncrono, essa espera é desperdiçada. A assincronia permite que, enquanto uma requisição espera, centenas de outras sejam disparadas.

  • Síncrono: 1000 páginas a 0,5 s cada = ~500 segundos.
  • Assíncrono (100 por vez): as mesmas 1000 páginas = ~5 segundos.

Diferentemente das threads, as corrotinas quase não custam memória: dezenas de milhares de tarefas simultâneas em uma única thread são perfeitamente viáveis. Compare com a abordagem multithread do guia geral: async escala bem mais alto.


2. Como baixamos a página: aiohttp

aiohttp é o cliente HTTP assíncrono padrão. O princípio-chave: um único ClientSession para o programa inteiro (reaproveita as conexões) e múltiplas requisições simultâneas via 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: uma tarefa que falha não derruba o gather inteiro; ela volta como um objeto de exceção que poderá ser processado depois.


3. Parsing do conteúdo em código async

Um detalhe importante: a análise do HTML em si (BeautifulSoup, lxml) é uma operação de CPU síncrona. Se o HTML for pesado, o parsing bloqueia o event loop e anula o ganho do async. Páginas leves podem ser parseadas diretamente na corrotina:

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 leves, funciona bem
    return soup.find("h1").get_text(strip=True)

Se a análise for pesada, mova-a para um pool de processos para não bloquear o loop:

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)

O detalhamento dos parsers está no guia geral e no artigo sobre lxml (uma das opções mais rápidas para cargas async).

Muitas vezes o que se consulta de forma assíncrona não são páginas HTML, e sim APIs: o corpo da resposta já vem estruturado e, em vez de um parser, basta await resp.json(). É mais rápido e mais confiável do que destrinchar a marcação; as técnicas para trabalhar com essas respostas estão reunidas em «Parsing de JSON».


4. Acentos e codificações no scraping assíncrono

Ao chamar await resp.text(), o aiohttp tenta deduzir a codificação a partir dos cabeçalhos. Em sites antigos que ainda servem ISO-8859-1 ou Windows-1252 (justamente onde vivem acentos e cedilhas), a detecção às vezes falha e o texto chega ilegível. As soluções são as mesmas do código síncrono:

python
# opção 1: codificação explícita
html = await resp.text(encoding="utf-8")

# opção 2: trabalhar com os bytes e entregá-los ao parser
raw = await resp.read()
soup = BeautifulSoup(raw, "lxml")    # o parser lê o <meta charset> sozinho

# opção 3: decodificação manual
html = raw.decode("windows-1252", errors="replace")

A teoria completa do problema está no guia geral, seção «Codificações».


5. Controle da concorrência: semáforos

Disparar 10 000 requisições de uma vez significa «derrubar» o servidor e a própria rede, além de garantir um banimento. A concorrência é limitada com um semáforo:

python
import asyncio
import aiohttp

async def fetch(session, url, semaphore):
    async with semaphore:                      # no máximo N por 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) garante que nunca haja mais de 20 requisições ativas ao mesmo tempo. É a sua principal alavanca de «cortesia»: ajuste o valor para não sobrecarregar o site-alvo. Acrescente pequenas pausas aleatórias (await asyncio.sleep(random.uniform(0.1, 0.5))) para um ritmo mais natural.


6. Proxies

No aiohttp, o proxy é passado como parâmetro da requisição:

python
async with session.get(url, proxy="http://user:pass@ip:port") as resp:
    html = await resp.text()

A rotação consiste simplesmente em escolher um proxy ao acaso a cada requisição:

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()

A estratégia geral de trabalho com proxies (tipos, descarte dos que caíram) está no guia geral, seção «Proxies».


7. Scraping através do TOR

aiohttp não suporta SOCKS nativamente; é preciso o pacote 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 do nó de saída do TOR

A troca do nó de saída por meio do sinal NEWNYM (biblioteca stem) está descrita no guia geral, seção «TOR». Leve em conta que o TOR é lento: com uma concorrência alta, ele vira o gargalo.


8. HTTPS/SSL

Por padrão, o aiohttp verifica os certificados. É possível desativar a verificação (somente para depurar) ou fornecer um contexto próprio:

python
import ssl

# desativar a verificação — NÃO use em produção
async with session.get(url, ssl=False) as resp:
    ...

# contexto SSL próprio
ctx = ssl.create_default_context(cafile="/path/to/ca.crt")
async with session.get(url, ssl=ctx) as resp:
    ...

Os princípios de segurança da conexão, no guia geral, seção «HTTPS/SSL».


ClientSession conserva os cookies entre requisições automaticamente, assim como requests.Session:

python
async with aiohttp.ClientSession() as session:
    # login: o servidor atribui o cookie de sessão
    await session.post("https://example.com/login",
                       data={"user": "u", "pass": "p"})
    # as requisições seguintes já vão autenticadas
    async with session.get("https://example.com/profile") as resp:
        html = await resp.text()

Também dá para passar cookies manualmente com o parâmetro cookies={...}. Mais detalhes no guia geral, seção «Cookies».


10. Status da resposta e cabeçalhos

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)            # não bloqueia as demais tarefas!
    resp.raise_for_status()

A vantagem-chave: ao tratar um 429, await asyncio.sleep() adormece apenas aquela corrotina; as demais continuam trabalhando. Em código síncrono, time.sleep() congelaria tudo. A lógica dos códigos de status, no guia geral.


11. Filas: asyncio.Queue

Para o crawling «à medida que os links vão sendo descobertos», usam-se asyncio.Queue e um pool de workers (corrotinas):

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 links novos e colocá-los na fila:
                # for link in extract_links(html):
                #     await queue.put(link)
            except Exception as exc:
                print(f"{name} erro em {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 fila esvaziar
        for w in workers:
            w.cancel()

O set serve para a deduplicação e a Queue coordena os workers: é o análogo assíncrono do frontier do guia geral. Para um percurso distribuído, a fila é movida para o Redis. A implementação industrial desse esquema é oferecida pelo Scrapy (que por dentro também é assíncrono).


12. httpx como alternativa

httpx é um cliente moderno com a mesma API para código síncrono e assíncrono, e com suporte a 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

Para alternar o mesmo código entre sync e async com necessidade de HTTP/2, o httpx é mais cômodo que o aiohttp. Em velocidade pura sobre grandes volumes, os dois empatam.


13. Vantagens e desvantagens do scraping assíncrono

Vantagens:

  • Concorrência enorme com consumo mínimo de memória.
  • Aceleração de várias vezes nas tarefas I/O-bound.
  • «Pausas» baratas: asyncio.sleep não bloqueia as demais tarefas.
  • Controle fino da velocidade por meio de semáforos.

Desvantagens:

  • É mais difícil de escrever e depurar (async/await por toda parte).
  • O parsing CPU-bound continua bloqueando o loop: é preciso um pool de processos.
  • Não dá para misturar com bibliotecas bloqueantes sem run_in_executor.
  • É fácil sobrecarregar o site-alvo: exige disciplina com os semáforos.

Quando escolher: milhares de páginas ou mais e a velocidade importa. Para algumas centenas de páginas, requests + ThreadPoolExecutor é mais simples. Para um crawling completo de um site inteiro, Scrapy, que já traz integradas a assincronia e as filas. E se o scraper vive dentro de uma aplicação web, tenha em mente que o ORM do Django continua majoritariamente síncrono e o código async ali exige cuidado (sync_to_async); tratamos disso em «Web scraping com Django».