Pular para o conteúdo

    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:

    avancado/cap26_crawling.pylinhas 10 a 21
    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"))
    
    Saída
    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:

    Terminal
    uv add beautifulsoup4
    
    avancado/cap26_crawling.pylinhas 26 a 43
    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")])
    
    Saída
    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:

    avancado/cap26_crawling.pylinhas 48 a 58
    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"))
    
    Saída
    [{'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:

    avancado/cap26_crawling.pylinhas 63 a 97
    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()
    
    Saída
    {'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 502 do 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.