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
- Por que async acelera o scraping
- Como baixamos a página: aiohttp
- Parsing do conteúdo em código async
- Acentos e codificações no scraping assíncrono
- Controle da concorrência: semáforos
- Proxies
- Scraping através do TOR
- HTTPS/SSL
- Trabalho com cookies
- Status da resposta e cabeçalhos
- Filas: asyncio.Queue
- httpx como alternativa
- 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.
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:
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:
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:
# 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:
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:
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:
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:
# 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 TORA 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:
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».
9. Trabalho com cookies
ClientSession conserva os cookies entre requisições automaticamente, assim como requests.Session:
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
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):
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:
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 responsesPara 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.sleepnão bloqueia as demais tarefas. - Controle fino da velocidade por meio de semáforos.
Desvantagens:
- É mais difícil de escrever e depurar (
async/awaitpor 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».