Voltar ao blogue
Guias
Sorin-Gabriel MaricaLast updated on May 12, 202619 min read

Tutorial BeautifulSoup: Construa um Scraper Python real a partir do zero

Tutorial BeautifulSoup: Construa um Scraper Python real a partir do zero
Resumo: Este tutorial sobre o BeautifulSoup mostra-lhe como criar um scraper completo em Python, desde pip install até um script robusto que pagina o Hacker News, exporta para CSV e JSON e mantém-se suficientemente educado para não ser bloqueado. Todos os trechos de código são executáveis e indicamos os momentos exatos em que o BeautifulSoup não é a ferramenta adequada.

Se consegues escrever um for loop em Python e já olhou para uma página web a pensar: «Quero esses dados numa folha de cálculo», este tutorial do BeautifulSoup foi feito para si. O Beautiful Soup é uma biblioteca Python para analisar HTML e XML numa árvore que pode consultar com métodos familiares, ao estilo do jQuery. Não carrega páginas, não executa JavaScript e não finge ser um navegador. Apenas pega na marcação bruta e oferece-lhe uma API limpa para extrair as partes que lhe interessam.

O plano é concreto. Vamos configurar um ambiente novo, buscar uma página de listagem real com a requests biblioteca, analisá-la com o BeautifulSoup, selecionar elementos com find_all seletores CSS, seguiremos a paginação ao longo de várias páginas e escreveremos os resultados em CSV e JSON. Ao longo do processo, vamos integrar a rotação de user-agents, tentativas de repetição e limitação de taxa, porque um tutorial que ignora as defesas anti-bot falha no momento em que o direciona para um site real. No final, terá um scraper executável do tipo «copiar e colar» e uma noção clara de quando continuar a usar o BeautifulSoup e quando passar para uma ferramenta mais avançada.

O que é o BeautifulSoup e quando o utilizar

O BeautifulSoup (o bs4 pacote no PyPI, atualmente na linha 4.x) é uma biblioteca de análise, não um rastreador nem um navegador. Passa-lhe uma string de HTML e ela devolve uma árvore de análise que pode navegar por tag, atributo, seletor CSS ou relação. É essa a sua função completa. Tudo o que diz respeito a pedidos HTTP, cookies, sessões, execução de JavaScript ou filas é problema de outra pessoa, e essa separação é exatamente a razão pela qual o BeautifulSoup continua a ser a escolha padrão para páginas estáticas mais de uma década após o seu lançamento.

Ajuda colocá-la num espectro. requests Além disso, o BeautifulSoup é a configuração mais leve possível: é ideal quando os dados que se pretende já estão no HTML que o servidor devolve e se está a rastrear apenas algumas páginas em vez de um milhão. O Scrapy é a ferramenta certa quando precisas de uma estrutura de rastreamento completa com pipelines, deduplicação e concorrência. O Selenium e o Playwright são as ferramentas certas quando a página é uma aplicação de página única que só monta o seu conteúdo após a execução do JavaScript. Se conseguires fazer um curl na URL e veres os teus dados no corpo da resposta, o BeautifulSoup é quase sempre a resposta mais simples.

Configuração do ambiente: Python, Requests e BeautifulSoup4

Utilize um ambiente virtual para que este projeto não contamine os seus site-packages globais. Qualquer versão a partir do Python 3.9 funcionará bem para este tutorial do BeautifulSoup, e fixar as versões mantém os trechos de código aqui reproduzíveis.

python -m venv .venv
source .venv/bin/activate   # on Windows: .venv\Scripts\activate
pip install requests==2.32.3 beautifulsoup4==4.12.3 lxml==5.2.2

requests lida com a camada HTTP, beautifulsoup4 é a própria API do analisador, e lxml é um analisador opcional, mas fortemente recomendado, baseado em C. O BeautifulSoup recorre ao html.parser se não o instalar lxml, mas o analisador em C é significativamente mais rápido em documentos grandes e mais tolerante com marcações desorganizadas. Se precisar de suportar ambientes Python onde a compilação de extensões em C é complicada, omita lxml e perderá alguma velocidade, mas não funcionalidade.

Teste rápido num REPL de Python:

import requests, bs4
print(requests.__version__, bs4.__version__)

Se ambas as versões forem impressas sem erros, está pronto. Guarde o resto do código num ficheiro chamado hn_scraper.py e execute-o com python hn_scraper.py.

Obter HTML com Requests

O BeautifulSoup precisa de bytes para analisar. A requests biblioteca é a forma mais ergonómica de os obter. Escolha um alvo real que possa aceder educadamente: o Hacker News é a escolha clássica porque a página inicial é HTML simples renderizado pelo servidor, com uma estrutura previsível e uma proteção anti-bot muito leve, o que é ideal para aprender.

import requests

URL = "https://news.ycombinator.com/news"
HEADERS = {
    "User-Agent": "Mozilla/5.0 (compatible; LearningScraper/1.0)",
    "Accept-Language": "en-US,en;q=0.9",
}

response = requests.get(URL, headers=HEADERS, timeout=15)
response.raise_for_status()        # blows up on 4xx/5xx
html_bytes = response.content      # bytes, not str

Há duas coisas em que vale a pena parar para pensar. Primeiro, verifica sempre o código de estado. Um 403 silencioso que devolve uma página de «Acesso Negado» será analisado corretamente num objeto BeautifulSoup que não contém nenhum dos dados que realmente queres, e vais perder uma tarde a depurar seletores na página errada. raise_for_status() faz com que essa falha seja evidente.

Segundo, prefira response.content em vez de response.text ao alimentar o BeautifulSoup. .text força uma descodificação utilizando a codificação requests adivinhada a partir dos cabeçalhos, o que por vezes está errado. .content representa os bytes brutos, e o BeautifulSoup é muito melhor a detetar a codificação real a partir de uma <meta charset> tag ou do próprio documento. A diferença raramente importa em sites apenas em inglês e importa muito no momento em que se extrai qualquer coisa com caracteres acentuados.

Criar um objeto BeautifulSoup e escolher um analisador

Com os bytes em mãos, construa a árvore de análise passando-os para o BeautifulSoup construtor juntamente com um nome de analisador. A documentação oficial do Beautiful Soup lista três analisadores que vale a pena conhecer.

Analisador

Velocidade

Tolerância a HTML incorreto

Notas

html.parser

Razoável

Bom

Biblioteca padrão, sem necessidade de instalação.

lxml

Mais rápido

Bom

Extensão C; pip install lxml.

html5lib

Mais lento

Melhor

Python puro; imita a forma como os navegadores recuperam de marcação corrompida.

Para este tutorial do BeautifulSoup, vamos usar lxml porque é rápido e está presente em todo o lado hoje em dia. Recorra a html5lib apenas quando um site tiver HTML verdadeiramente malformado que lxml se distorce, e recorra ao html.parser se não conseguir instalar nada além da biblioteca padrão.

from bs4 import BeautifulSoup

soup = BeautifulSoup(html_bytes, "lxml")
print(soup.title.string)            # "Hacker News"
print(soup.prettify()[:300])        # peek at the formatted DOM

soup.title.string funciona porque o BeautifulSoup expõe as tags de nível superior como atributos. get_text(strip=True) é a alternativa de uso geral mais segura quando não sabe se uma tag contém texto simples ou filhos aninhados, e prettify() é inestimável durante a exploração porque mostra a árvore indentada que está realmente a consultar.

Selecionar elementos: find, find_all e select

O BeautifulSoup oferece três formas de localizar nós: find, find_all, e select. find retorna a primeira correspondência (ou None). find_all retorna uma lista de todas as correspondências. select e select_one utiliza strings de seletor CSS, que abordaremos na próxima subsecção.

Pesquisar por tag. A forma mais simples. soup.find_all("a") retorna todas as âncoras na página.

links = soup.find_all("a")
print(len(links), "anchors found")

Pesquisar por classe. Use a palavra-chave class_ com um sublinhado no final, porque class é uma palavra reservada em Python. Isto confunde quase todos os principiantes.

rows = soup.find_all("tr", class_="athing")          # Hacker News story rows
titles = soup.find_all("span", class_="titleline")

Pesquisar por id. Passe id= diretamente. Os IDs devem ser únicos, por isso find é normalmente o que se pretende.

main = soup.find(id="hnmain")

Pesquisar por atributo. Qualquer atributo arbitrário pode ser passado dentro de um attrs dict. É assim que se seleciona data-* atributos, aria-* atributos ou qualquer outra coisa que não seja uma tag, um id ou uma classe.

rows = soup.find_all("tr", attrs={"data-row-type": "story"})

Filtrar por uma função. Quando precisar de uma lógica que nenhuma palavra-chave capture, passe uma função lambda. A função recebe cada tag e retorna True para a manter.

def is_external_link(tag):
    return tag.name == "a" and tag.get("href", "").startswith("http")

external = soup.find_all(is_external_link)

Também pode passar uma lambda para o string argumento para filtrar por conteúdo de texto. A correspondência de subcadeias sem distinção entre maiúsculas e minúsculas é um caso de uso comum:

python_links = soup.find_all("a", string=lambda s: s and "python" in s.lower())

Uma regra prática: use find e find_all quando a pesquisa envolver um ou dois atributos. Quando precisar de combinar uma classe, um pai e uma posição, mude para seletores CSS. São mais fáceis de ler e mais fáceis de copiar a partir das DevTools do navegador.

Análise aprofundada dos seletores CSS com select() e select_one()

select() aceita as mesmas cadeias de caracteres de seletores CSS que utiliza em document.querySelectorAll. Isso significa que combinadores de descendentes, combinadores de filhos, seletores de atributos, pseudoclasses e nomes de classes encadeados funcionam todos.

# Descendant: any .titleline inside a tr.athing, at any depth
titles = soup.select("tr.athing .titleline")

# Direct child: only immediate children
direct = soup.select("tr.athing > td.title > span.titleline")

# Attribute selector: links to PDFs
pdfs = soup.select("a[href$='.pdf']")

# Positional: every fifth story row
every_fifth = soup.select("tr.athing:nth-of-type(5n)")

# Multiple classes at once
emphasized = soup.select("span.titleline.featured")

Aqui está o mapeamento prático entre as duas APIs.

find_all form

select form

find_all("a", class_="storylink")

select("a.storylink")

find_all("div", id="main")

select("div#main")

find_all("input", attrs={"type": "hidden"})

select("input[type='hidden']")

Os seletores não são um detalhe secundário neste tutorial do BeautifulSoup, são a principal estratégia de manutenção. O truque que mantém os scrapers ativos quando a marcação muda é definir os seus seletores como constantes nomeadas no topo do módulo. Quando o site renomeia uma classe, corrige-se uma linha em vez de ter de procurar em toda a base de código.

STORY_ROW = "tr.athing"
TITLE_LINK = "span.titleline > a"
RANK = "span.rank"

Como hábito, copie um seletor funcional a partir do Chrome DevTools (clique com o botão direito num elemento, Copiar > Copiar seletor) e, em seguida, reduza a cadeia gerada automaticamente até à versão mais curta que ainda identifique de forma única o que pretende. Os seletores longos são os primeiros a falhar quando a marcação muda; os seletores curtos e nomeados sobrevivem a pequenas reformulações.

Percorrendo o DOM: pais, irmãos e filhos

Às vezes, o elemento que consegue identificar claramente não é o elemento que realmente quer. Um padrão comum: consegue selecionar um <span class="rank"> facilmente, mas o título e o link residem num nó irmão. Em vez de escrever um seletor composto frágil, percorra a árvore.

Cada tag do BeautifulSoup expõe atributos de navegação:

  • .parent: a tag imediatamente envolvente.
  • .parents: um gerador que retorna todos os antepassados até à raiz do documento.
  • .next_sibling e .previous_sibling: nós adjacentes na mesma profundidade (podem ser espaços em branco).
  • .find_next("tag") e .find_previous("tag"): ignorar os nós de espaços em branco e encontrar a próxima tag real.
  • .children e .descendants: filhos diretos ou todos os nós aninhados.

Um exemplo prático. Suponha que recolheu todos os .titleline spans no Hacker News e queira, para cada um, a linha circundante mais a linha seguinte (que contém a pontuação e o autor).

for title_span in soup.select("span.titleline"):
    row = title_span.find_parent("tr")               # the .athing row
    meta_row = row.find_next_sibling("tr")           # the subtext row
    score = meta_row.find("span", class_="score")
    print(title_span.get_text(strip=True), score.get_text() if score else "-")

A verdadeira escolha é entre legibilidade e robustez. Um seletor CSS encadeado é mais curto, mas percorrer a árvore é frequentemente mais resiliente quando a página envolve os mesmos dados em diferentes contêineres dependendo do contexto. Opte pela travessia quando uma única consulta não conseguir expressar a relação de que precisa.

Projeto de ponta a ponta: extrair a classificação, o título e a URL do Hacker News

É hora de parar de mostrar trechos isolados e construir o núcleo do scraper. A página inicial do Hacker News apresenta cada notícia como uma tr.athing linha, onde a classificação reside em span.rank, o título e o link externo residem dentro de span.titleline > a, e uma linha irmã contém a pontuação e o autor. A nossa tarefa é transformar cada notícia num dicionário.

Aqui está a primeira versão do analisador. Repare que ele não faz qualquer recuperação de dados; aceita uma cadeia de caracteres HTML e devolve registos estruturados. Manter a recuperação e a análise separadas é o que permite testar o analisador em unidades contra HTML de fixture sem recorrer à rede.

from bs4 import BeautifulSoup

def parse_stories(html: bytes) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    stories = []
    for row in soup.select("tr.athing"):
        rank_tag = row.select_one("span.rank")
        link_tag = row.select_one("span.titleline > a")
        if not (rank_tag and link_tag):
            continue                                # skip malformed rows
        stories.append({
            "rank": rank_tag.get_text(strip=True).rstrip("."),
            "title": link_tag.get_text(strip=True),
            "url": link_tag.get("href", ""),
            "id": row.get("id"),
        })
    return stories

Alguns detalhes que são mais importantes do que parecem. rank_tag.get_text(strip=True).rstrip(".") lida com o ponto final que o Hacker News exibe após cada classificação ("1." torna-se "1"). link_tag.get("href", "") retorna a string vazia em vez de gerar uma exceção KeyError se o atributo estiver em falta, o que é o tipo de alteração de um único caractere que transforma um scraper frágil num robusto. E o continue mantém o loop ativo quando o site ocasionalmente insere uma linha de anúncio ou um espaço reservado patrocinado que não corresponde ao esquema.

Junte o analisador ao extrator:

import requests

def fetch(url: str) -> bytes:
    headers = {"User-Agent": "LearningScraper/1.0"}
    response = requests.get(url, headers=headers, timeout=15)
    response.raise_for_status()
    return response.content

if __name__ == "__main__":
    stories = parse_stories(fetch("https://news.ycombinator.com/news"))
    for story in stories[:5]:
        print(story["rank"], story["title"])

Executar isto deve imprimir as cinco primeiras manchetes classificadas tal como aparecem na página neste momento. Tem um scraper de página única funcional em menos de trinta linhas. As secções restantes deste tutorial do BeautifulSoup adicionam paginação, exportações, novas tentativas e os retoques que fazem com que o script consiga ser executado num site real durante uma hora em vez de um minuto.

Lidar com paginação e rastreamentos de várias páginas

O Hacker News pagina com um parâmetro de consulta: ?p=2, ?p=3, e assim por diante. Na parte inferior de cada página encontra-se uma <a class="morelink"> âncora que aponta para a página seguinte. Detetar essa âncora é a condição de paragem mais simples, porque funciona quer o site utilize páginas sequenciais, tokens de cursor ou parâmetros de deslocamento.

import time
from urllib.parse import urljoin

BASE = "https://news.ycombinator.com/"

def scrape_all(start_url: str, max_pages: int = 5, delay: float = 1.5) -> list[dict]:
    url = start_url
    pages_done = 0
    all_stories: list[dict] = []

    while url and pages_done < max_pages:
        html = fetch(url)
        all_stories.extend(parse_stories(html))

        soup = BeautifulSoup(html, "lxml")
        more = soup.select_one("a.morelink")
        url = urljoin(BASE, more["href"]) if more else None

        pages_done += 1
        time.sleep(delay)
    return all_stories

Três detalhes que vale a pena destacar. urljoin(BASE, more["href"]) é como transformar hrefs relativos como news?p=2 em um URL absoluto real, o que requests requer. O max_pages limite é uma rede de segurança para que uma condição de paragem com erros não possa executar-se indefinidamente. E time.sleep(delay) é o limitador de taxa mais simples possível; iremos substituí-lo por algo mais inteligente quando chegarmos ao anti-bloqueio.

Este padrão de paginação generaliza-se muito além do Hacker News. Em qualquer lugar onde a página seguinte seja uma âncora real na marcação, pode inserir um seletor diferente em select_one e o resto do loop permanece idêntico. Para sites que paginam com rolagem infinita, o BeautifulSoup por si só não ajudará, e abordamos essa limitação na secção de JavaScript mais adiante neste tutorial do BeautifulSoup.

Exportar dados extraídos para CSV e JSON

Assim que tiver uma lista de dicionários, exportá-los para o disco é uma tarefa mecânica. Os dois formatos que todos os analistas esperam são CSV e JSON, e não há razão para não produzir ambos no mesmo fluxo de trabalho.

import csv, json
from pathlib import Path

def export(records: list[dict], out_dir: str = "out") -> None:
    out = Path(out_dir)
    out.mkdir(exist_ok=True)

    csv_path = out / "stories.csv"
    with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        writer.writeheader()
        writer.writerows(records)

    json_path = out / "stories.json"
    with json_path.open("w", encoding="utf-8") as f:
        json.dump(records, f, ensure_ascii=False, indent=2)

Algumas armadilhas de codificação merecem destaque. Use encoding="utf-8-sig" para o CSV se os dados forem abertos no Excel no Windows, porque o BOM é o que indica ao Excel que o ficheiro está em UTF-8 (sem ele, os caracteres acentuados são apresentados como caracteres sem sentido). Passe newline="" para open ao gravar CSV para evitar linhas em branco no Windows. Para JSON, ensure_ascii=False mantém os caracteres não-ASCII como estão, em vez de \uXXXX escapes, o que torna a saída legível para o utilizador.

Para analistas que trabalham em um notebook, pandas.DataFrame(records).to_csv("stories.csv", index=False) é a alternativa de uma linha. É mais pesado, mas agradável quando se está prestes a fazer uma análise exploratória nos mesmos dados de qualquer forma.

Armadilhas comuns: elementos em falta, codificação e erros NoneType

O bug mais comum que irá encontrar em qualquer código de tutorial do BeautifulSoup é AttributeError: 'NoneType' object has no attribute 'get_text'. Isso significa sempre find ou select_one devolvido None, e depois tentaste chamar um método sobre ele. A solução é verificar sempre antes de encadear.

# Brittle
title = row.find("span", class_="titleline").a.get_text()

# Defensive
line = row.find("span", class_="titleline")
anchor = line.find("a") if line else None
title = anchor.get_text(strip=True) if anchor else None

Dois hábitos relacionados vão poupar-lhe horas:

  • Use .get(attr, default) em vez de tag[attr]. A indexação gera KeyError quando o atributo está em falta, enquanto .get retorna discretamente o seu valor padrão e permite que o ciclo continue.
  • Sempre .get_text(strip=True) em vez de .string. .string é None sempre que uma tag tem vários filhos, o que a torna surpreendentemente frágil.

A codificação é a segunda armadilha clássica. Se alimentares o BeautifulSoup response.text e o site mentir sobre a sua codificação no Content-Type cabeçalho, obtém-se caracteres ilegíveis. Alimentá-lo response.content (bytes) permite que o BeautifulSoup detecte a codificação real a partir do documento.

Por fim, escreva os seus seletores com base num fixture HTML guardado durante o desenvolvimento. Guarde o response.content uma vez e itere localmente. O seu scraper fica então fácil de testar unitariamente e deixa de sobrecarregar o site de destino sempre que altera um seletor.

Vencendo as defesas anti-scraping sem deixar de ser educado

Mesmo um alvo amigável bloqueará um scraper que o bombardeie com milhares de pedidos idênticos a partir de um único IP. A educação é, em parte, uma preocupação de engenharia e, em parte, a coisa certa a fazer. Cinco técnicas cobrem a maior parte do que irá precisar.

1. Alterne os user agents. Uma impressão digital de navegador real, juntamente com um pequeno conjunto de strings de User-Agent realistas, é suficiente para fazer com que os filtros casuais o ignorem. Escolha um por pedido.

import random
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
    "Mozilla/5.0 (X11; Linux x86_64) Firefox/124.0",
]
headers = {"User-Agent": random.choice(UAS)}

2. Limite a taxa com jitter. Uma taxa fixa time.sleep(1) é uma impressão digital por si só. Adicione um jitter aleatório para que a cadência pareça humana.

time.sleep(random.uniform(1.0, 2.5))

3. Repita a tentativa com recuo exponencial. Falhas transitórias (5xx, reinicializações de ligação, tempos de espera) são a norma. Envolva os pedidos com recuo para que uma falha pontual não interrompa a execução.

def fetch_with_retry(url, headers, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers=headers, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i)
                continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"giving up on {url}")

4. Alterne proxies. Se o seu IP doméstico ficar sobrecarregado, encaminhe as solicitações através de um conjunto de proxies residenciais ou de centros de dados. requests aceita um proxies={"http": ..., "https": ...} argumento; a lógica de rotação reside um nível acima.

5. Leia robots.txt e os Termos de Serviço. A documentação do robots.txt do Google é uma boa introdução ao protocolo. Respeitar Disallow diretivas não é juridicamente vinculativo em todos os lugares, mas é a linha que separa um scraper educado de um irritante, e ignorá-las é o que faz com que os projetos acabem em listas de bloqueio.

Quando os sites recorrem a pilhas anti-bot robustas (o gestor de bots da Cloudflare, PerimeterX, DataDome), o custo de construir tudo isto por conta própria ultrapassa o custo de usar um desbloqueador gerido. A nossa API Scraper lida com rotação, CAPTCHAs e novas tentativas por trás de um único ponto de extremidade, pelo que o código de análise do BeautifulSoup neste tutorial permanece exatamente o mesmo e apenas a camada de obtenção de dados muda.

Quando o BeautifulSoup não é suficiente: páginas renderizadas em JavaScript

O BeautifulSoup analisa o que o servidor enviou. Se o servidor enviou um shell HTML quase vazio e a página só monta o seu conteúdo depois de o JavaScript ser executado no navegador, o BeautifulSoup analisará alegremente o shell e não encontrará nada de útil. Esta é a única limitação mais difícil em relação ao que este tutorial do BeautifulSoup pode fazer por si, e vale a pena reconhecer os sintomas.

Sinais reveladores de que está a ver uma aplicação de página única:

  • view-source: mostra um pequeno <div id="root"></div> e uma parede de <script> , mas a página renderizada no navegador está cheia de conteúdo.
  • O seu scraper vê um DOM diferente do que o DevTools vê. O DevTools mostra o DOM em tempo real, que inclui nós injetados por JS; requests apenas vê a resposta inicial.
  • A guia de rede mostra uma enxurrada de XHR ou fetch chamadas após o carregamento da página.

Tem três boas opções:

  • Encontre a API. Observe a guia de rede. Se a página estiver a buscar JSON de um backend, aceda diretamente a esse endpoint com requests e ignore completamente a renderização. Este é normalmente o caminho mais rápido e estável.
  • Utilize um navegador real. Use o Playwright ou o Selenium para carregar a página, aguarde os dados e, em seguida, passe o HTML renderizado para o BeautifulSoup para análise.
  • Utilize uma API de navegador gerido. Para os casos em que pretende o navegador sem gerir a infraestrutura, um ponto de extremidade de navegador na nuvem devolve o HTML renderizado e continua a analisá-lo com o mesmo find_all/select código que já escreveu.

Script final: juntando a obtenção, a análise, a paginação e a exportação

Aqui está a versão consolidada do código do tutorial do BeautifulSoup. Ele pagina, repete tentativas, limita a taxa de pedidos com jitter, alterna os user agents e exporta tanto para CSV como para JSON.

import csv, json, random, time
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

BASE = "https://news.ycombinator.com/"
START = urljoin(BASE, "news")
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
]

def fetch(url, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers={"User-Agent": random.choice(UAS)}, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i); continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"failed: {url}")

def parse_stories(html):
    soup = BeautifulSoup(html, "lxml")
    out = []
    for row in soup.select("tr.athing"):
        rank = row.select_one("span.rank")
        link = row.select_one("span.titleline > a")
        if not (rank and link):
            continue
        out.append({
            "rank": rank.get_text(strip=True).rstrip("."),
            "title": link.get_text(strip=True),
            "url": link.get("href", ""),
            "id": row.get("id"),
        })
    return out

def next_page(html):
    soup = BeautifulSoup(html, "lxml")
    more = soup.select_one("a.morelink")
    return urljoin(BASE, more["href"]) if more else None

def crawl(start, max_pages=3):
    url, pages, rows = start, 0, []
    while url and pages < max_pages:
        html = fetch(url)
        rows.extend(parse_stories(html))
        url = next_page(html)
        pages += 1
        time.sleep(random.uniform(1.0, 2.5))
    return rows

def export(rows, out_dir="out"):
    out = Path(out_dir); out.mkdir(exist_ok=True)
    with (out / "stories.csv").open("w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        w.writeheader(); w.writerows(rows)
    with (out / "stories.json").open("w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False, indent=2)

if __name__ == "__main__":
    rows = crawl(START)
    export(rows)
    print(f"saved {len(rows)} stories")

Coloque isso no hn_scraper.py, execute python hn_scraper.pye deverá ver três páginas de histórias gravadas em out/stories.csv e out/stories.json.

Para onde levar este tutorial do BeautifulSoup a seguir

Agora tem um scraper completo para sites estáticos, mas o mesmo analisador adapta-se a fluxos de trabalho muito maiores. Três próximos passos sensatos:

  • Passe para o Scrapy quando precisar de rastrear milhares de páginas, deduplicar URLs, gerir a concorrência e executar tarefas agendadas. O Scrapy usa expressões de seleção semelhantes, pelo que o modelo mental que construiu neste tutorial do BeautifulSoup se transfere facilmente.
  • Adicione um navegador headless quando os dados estiverem protegidos por JavaScript. Tanto o Playwright como o Selenium permitem renderizar a página primeiro e analisar o HTML renderizado com o BeautifulSoup posteriormente, o que preserva o seu código de análise existente e os seus seletores CSS.
  • Externalize a camada de obtenção de dados quando os blocos se tornarem o gargalo. Uma API de scraping gerida lida com proxies, cabeçalhos e resolução de CAPTCHA, para que possa continuar a iterar sobre seletores em vez de sobre impressões digitais.

Seja qual for a direção que seguir, mantenha a separação entre análise e obtenção de dados que criou aqui. É a única escolha de design que permite que um scraper sobreviva à inevitável reformulação do site, e é o que torna o código deste guia reutilizável à medida que as suas necessidades crescem.

Pontos-chave

  • O BeautifulSoup analisa HTML, nada mais. Combine-o com requests para páginas estáticas e um navegador real para as renderizadas em JavaScript.
  • Os seletores CSS escalam melhor do que chamadas encadeadas find_all . Define-os como constantes nomeadas no topo do teu módulo para que uma alteração na marcação seja uma correção de uma linha.
  • Proteja-se sempre contra None. Use find_parent com cuidado, prefira .get("attr", "") em vez de indexação e verifique antes de encadear chamadas de método.
  • A paginação é uma condição de paragem. Deteta a âncora da página seguinte, constrói URLs absolutas com urljoine limite o loop com max_pages para que um bug não possa executar-se indefinidamente.
  • A cortesia é engenharia. A rotação de UA, o sono com jitter, o backoff exponencial e o respeito robots.txt são práticas básicas, não um refinamento opcional, para qualquer tutorial do BeautifulSoup que pretenda executar mais de uma vez.

Perguntas frequentes

Qual é a diferença entre o html.parser do BeautifulSoup, o lxml e o html5lib?

html.parser Vem com o Python e não precisa de instalação, mas é o mais lento dos três. lxml é uma extensão em C que é a mais rápida na prática e lida bem com a maioria do HTML malformado; instale-a com pip install lxml. html5lib é Python puro e o mais tolerante, imitando a forma como um navegador real recupera de marcação danificada, ao custo de ser visivelmente mais lento.

Quando devo usar o BeautifulSoup em vez do Scrapy, do Selenium ou do Playwright?

Use o BeautifulSoup para scripts pontuais e páginas estáticas onde pode obter o HTML com requests. Use o Scrapy quando precisar de um verdadeiro rastreador com concorrência, pipelines e agendamento em milhares de URLs. Use o Selenium ou o Playwright quando a página depender de JavaScript para renderizar conteúdo e, opcionalmente, passe o HTML renderizado de volta para o BeautifulSoup para análise.

O BeautifulSoup consegue extrair páginas renderizadas em JavaScript por si só?

Não. O BeautifulSoup apenas analisa o HTML que recebe, e requests retorna a resposta inicial do servidor sem executar JavaScript. Para aplicações de página única ou conteúdo injetado após o carregamento da página, é necessário um navegador headless (Playwright, Selenium ou um endpoint de navegador na nuvem) para renderizar o DOM primeiro. Uma vez renderizado, ainda é possível passar esse HTML para o BeautifulSoup para análise.

Como evito que o meu IP seja bloqueado enquanto faço scraping com o BeautifulSoup?

Alterne as cadeias de caracteres do User-Agent, adicione atrasos aleatórios entre os pedidos e tente novamente em caso de erros transitórios com recuo exponencial. Para volumes maiores, encaminhe o tráfego através de proxies residenciais ou de centros de dados rotativos. Respeite robots.txt e evite extrair conteúdo protegido por login. Pilhas anti-bot agressivas como o Cloudflare requerem frequentemente um desbloqueador gerido em vez de ajustes de cabeçalho «faça você mesmo».

A biblioteca em si apenas analisa texto e não constitui a questão legal. A legalidade de um scraping específico depende geralmente dos Termos de Serviço do site de destino, das leis de direitos de autor e de utilização indevida de computadores aplicáveis na sua jurisdição e do facto de os dados serem pessoais ao abrigo de regulamentos como o RGPD ou a CCPA. Esta é informação geral e não constitui aconselhamento jurídico; consulte um advogado para qualquer assunto que envolva dados pessoais, paywalls ou redistribuição comercial.

Conclusão

Começou este tutorial do BeautifulSoup com pip install e terminou com um scraper que pagina, repete tentativas, alterna user agents e exporta CSV e JSON limpos. A estrutura desse script é mais importante do que qualquer fragmento isolado: mantenha o fetch separado do parse, selecione elementos com seletores CSS nomeados, proteja cada acesso a atributos encadeados contra Nonee trate as práticas anti-bloqueio como parte da construção, em vez de uma reflexão tardia. Os sites continuarão a ser redesenhados, os analisadores continuarão a ser bloqueados e as bases de código que envelhecem bem são aquelas que respeitam essa separação desde o primeiro dia.

Se a camada de fetching começar a consumir mais do seu tempo do que a camada de análise, esse é o sinal para a externalizar. A WebScrapingAPI lida com a rotação de proxies, a identificação de cabeçalhos e a resolução de CAPTCHAs por trás de um único endpoint, para que possa manter o código BeautifulSoup que escreveu aqui e apenas trocar a solicitação que lhe fornece o HTML. Boa sorte, e que os seus seletores permaneçam verdes.

Sobre o autor
Sorin-Gabriel Marica, Desenvolvedor Full-Stack @ WebScrapingAPI
Sorin-Gabriel MaricaDesenvolvedor Full-Stack

Sorin Marica é engenheiro Full Stack e DevOps na WebScrapingAPI, onde desenvolve funcionalidades do produto e mantém a infraestrutura que garante o bom funcionamento da plataforma.

Comece a construir

Pronto para expandir a sua recolha de dados?

Junte-se a mais de 2.000 empresas que utilizam a WebScrapingAPI para extrair dados da Web à escala empresarial, sem quaisquer custos de infraestrutura.