Capítulo 26, Avançado
Web crawling com BeautifulSoup
Quando não existe uma API, às vezes a única fonte é a página HTML. Extrair dados dela é possível, mas é frágil, tem limites legais e éticos, e deve ser o último recurso.
Antes de escrever uma linha
Você pode coletar dados desse site? Três perguntas, nesta ordem: o site oferece uma API ou um feed RSS (muito mais estável)? Os termos de uso permitem coleta automatizada? O robots.txt do site permite acessar aquele caminho? E, se os dados incluem pessoas, a LGPD se aplica. Este capítulo é só técnico e usa HTML escrito aqui mesmo, sem acessar site nenhum.
O robots.txt pode ser lido com a biblioteca padrão:
from urllib.robotparser import RobotFileParser
regras = """User-agent: *
Disallow: /privado/
Crawl-delay: 5
"""
leitor = RobotFileParser()
leitor.parse(regras.splitlines())
print(leitor.can_fetch("meu-bot", "https://site.exemplo/noticias"))
print(leitor.can_fetch("meu-bot", "https://site.exemplo/privado/dados"))
print(leitor.crawl_delay("meu-bot"))
True
False
5
O Crawl-delay pede 5 segundos entre requisições. Respeitar isso (e se identificar com um User-Agent verdadeiro) é o mínimo de educação, e evita que o seu IP seja bloqueado.
Extrair dados de um HTML
O BeautifulSoup transforma o HTML em uma árvore que você consulta com seletores CSS, os mesmos do navegador. Use as ferramentas de desenvolvedor do navegador (clique direito, "Inspecionar") para descobrir a tag e a classe do que você quer:
uv add beautifulsoup4
from urllib.parse import urljoin
from bs4 import BeautifulSoup
HTML = """
<html><head><title>Notícias do dia</title></head><body>
<article><a class="manchete" href="/a">Primeira manchete</a><span class="hora">10:00</span></article>
<article><a class="manchete" href="/b">Segunda manchete</a><span class="hora">11:30</span></article>
<article><a class="manchete" href="https://outro.site/c">Terceira manchete</a></article>
<a href="/sobre">Sobre nós</a>
</body></html>
"""
sopa = BeautifulSoup(HTML, "html.parser")
print(sopa.title.text)
print([a.get_text(strip=True) for a in sopa.select("a.manchete")])
print([a["href"] for a in sopa.select("a.manchete")])
print([urljoin("https://site.exemplo", a["href"]) for a in sopa.select("a.manchete")])
Notícias do dia
['Primeira manchete', 'Segunda manchete', 'Terceira manchete']
['/a', '/b', 'https://outro.site/c']
['https://site.exemplo/a', 'https://site.exemplo/b', 'https://outro.site/c']
O select("a.manchete") pegou só os links com aquela classe, e deixou o "Sobre nós" de fora. O urljoin transforma links relativos (/a) em absolutos e mantém os que já eram absolutos. O html.parser é o analisador que vem com o Python, sem instalar nada.
Uma função pura, fácil de testar
Separe buscar o HTML de extrair os dados. A extração, uma função pura que recebe texto e devolve dados, testa-se sem rede e sem site:
def extrair_manchetes(html: str, base: str) -> list[dict]:
sopa = BeautifulSoup(html, "html.parser")
manchetes = [
{"titulo": a.get_text(strip=True), "link": urljoin(base, a["href"])}
for a in sopa.select("a.manchete")
if a.get("href")
]
return manchetes
print(extrair_manchetes(HTML, "https://site.exemplo"))
[{'titulo': 'Primeira manchete', 'link': 'https://site.exemplo/a'}, {'titulo': 'Segunda manchete', 'link': 'https://site.exemplo/b'}, {'titulo': 'Terceira manchete', 'link': 'https://outro.site/c'}]
Dentro de uma API
A rota usa a função de extração, e trata o caso mais comum de falha de um crawler: a página mudou. Quando o seletor deixa de achar qualquer coisa, a coleta devolve uma lista vazia sem nenhum erro, e isso passa despercebido por semanas. É melhor falhar de forma ruidosa:
from typing import Annotated, Callable
import httpx2
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
def buscar_html() -> str:
resposta = httpx2.get(
"https://site.exemplo/noticias",
headers={"User-Agent": "meu-bot/1.0 (contato@exemplo.com)"},
timeout=5.0,
)
resposta.raise_for_status()
return resposta.text
@app.get("/manchetes")
def manchetes(html: Annotated[str, Depends(buscar_html)], limite: int = 5):
itens = extrair_manchetes(html, "https://site.exemplo")
if not itens:
raise HTTPException(502, "A estrutura da página mudou: nenhuma manchete encontrada")
return {"total": len(itens), "dados": itens[:limite]}
app.dependency_overrides[buscar_html] = lambda: HTML
cliente = TestClient(app)
print(cliente.get("/manchetes?limite=2").json())
app.dependency_overrides[buscar_html] = lambda: "<html><body>redesenhado</body></html>"
resposta = cliente.get("/manchetes")
print(resposta.status_code, resposta.json())
app.dependency_overrides.clear()
{'total': 3, 'dados': [{'titulo': 'Primeira manchete', 'link': 'https://site.exemplo/a'}, {'titulo': 'Segunda manchete', 'link': 'https://site.exemplo/b'}]}
502 {'detail': 'A estrutura da página mudou: nenhuma manchete encontrada'}
Como buscar_html é uma dependência, o teste a troca por um HTML fixo (o dependency_overrides do capítulo 24), e a rota roda sem nenhum acesso à rede. O segundo teste simula a página redesenhada e confirma que a falha aparece como 502.
Crawler é frágil por natureza
A estrutura de uma página muda sem aviso, e o seu código quebra. Por isso: prefira uma API ou um feed quando existir; guarde em cache o resultado (capítulo 28), para não pedir a página a cada requisição; limite a frequência; e monitore o
502do exemplo acima. Um crawler em produção é uma dívida de manutenção, e eu só aceito essa dívida quando não há outro caminho.
Exercício 1
Links únicos e absolutos
Escreva extrair_links(html, base), que devolva todos os links (<a href>) da página como endereços absolutos, sem repetição e na ordem em que aparecem.