Scraping por linguagem 7 min de leitura

Extrair tabelas HTML com Python e BeautifulSoup

Extraia tabelas HTML passo a passo com Python e BeautifulSoup: células mescladas, combinações colspan e rowspan e exportação do resultado para CSV e Excel.

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

Tabelas são um dos formatos mais frequentes de dados estruturados na web: taxas de câmbio, estatísticas esportivas, listas de preços, rankings. Neste artigo, veremos como extrair uma tabela HTML e transformá-la em um conjunto de dados limpo — do pandas.read_html de uma linha só ao destrinchamento manual de tabelas complexas com células mescladas usando BeautifulSoup.

É um desdobramento prático do guia panorâmico «Web scraping com Python», que explica as técnicas básicas de download de páginas e o trabalho com as bibliotecas.

Índice

  1. Estrutura de uma tabela HTML
  2. A via rápida: pandas.read_html
  3. A via flexível: BeautifulSoup na mão
  4. Extração dos cabeçalhos
  5. Tabelas complexas: colspan e rowspan
  6. Codificações e caracteres especiais
  7. Limpeza e gravação dos dados
  8. Tabelas dinâmicas (JavaScript)
  9. Vantagens e desvantagens das abordagens

1. Estrutura de uma tabela HTML

Antes de extrair qualquer coisa, é preciso entender a marcação:

html
<table>
  <thead>
    <tr><th>Cidade</th><th>População</th></tr>
  </thead>
  <tbody>
    <tr><td>Tóquio</td><td>13 100 000</td></tr>
    <tr><td>Sydney</td><td>5 600 000</td></tr>
  </tbody>
</table>
  • <table> — o contêiner da tabela;
  • <thead> / <tbody> — o cabeçalho e o corpo (nem sempre presentes);
  • <tr> — a linha (table row);
  • <th> — célula de cabeçalho; <td> — célula de dados.

2. A via rápida: pandas.read_html

Se a tabela é «bem-comportada» (um <table> normal, sem truques), o pandas a destrincha em uma linha. Por baixo, ele usa lxml ou BeautifulSoup.

python
import pandas as pd

# read_html retorna uma LISTA com todas as tabelas da página
tables = pd.read_html("https://example.com/stats")
df = tables[0]          # a primeira tabela
print(df.head())
df.to_csv("data.csv", index=False)

Parâmetros úteis:

python
tables = pd.read_html(
    url,
    match="População",   # pegar só as tabelas que contêm esta palavra
    header=0,            # qual linha é o cabeçalho
    thousands=" ",       # separador de milhar (para "13 100 000")
    decimal=",",         # separador decimal (formato brasileiro)
)

Dica: se o site bloqueia as requisições do pandas, baixe o HTML com requests e os cabeçalhos adequados e passe o texto: pd.read_html(response.text).

O read_html é ideal para tabelas simples. Mas tropeça em layouts fora do padrão, células mescladas e tabelas montadas com «div no lugar de table». Aí entra o destrinchamento manual.


3. A via flexível: BeautifulSoup na mão

O controle total vem com o BeautifulSoup. O laço básico por linhas e células:

python
import requests
from bs4 import BeautifulSoup

resp = requests.get("https://example.com/stats", timeout=10)
resp.encoding = resp.apparent_encoding
soup = BeautifulSoup(resp.content, "lxml")

table = soup.find("table")
rows = []
for tr in table.find_all("tr"):
    cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
    if cells:                      # pulamos as linhas vazias
        rows.append(cells)

for row in rows:
    print(row)

find_all(["td", "th"]) captura tanto as células comuns quanto as de cabeçalho. get_text(strip=True) elimina os espaços e as quebras de linha excedentes.

Escolher uma tabela específica

Se há várias tabelas, apoie-se na classe, no id ou na vizinhança:

python
table = soup.find("table", class_="prices")
table = soup.select_one("#main-table")
table = soup.find("h2", string="Preços").find_next("table")

4. Extração dos cabeçalhos

Para obter um dicionário ou um DataFrame que faça sentido, separe os cabeçalhos dos dados:

python
table = soup.find("table")

# cabeçalhos: do thead ou da primeira linha
headers = [th.get_text(strip=True) for th in table.select("thead th")]
if not headers:
    first_row = table.find("tr")
    headers = [c.get_text(strip=True) for c in first_row.find_all(["th", "td"])]

# dados
data = []
for tr in table.select("tbody tr"):
    cells = [td.get_text(strip=True) for td in tr.find_all("td")]
    if len(cells) == len(headers):
        data.append(dict(zip(headers, cells)))

import pandas as pd
df = pd.DataFrame(data)

dict(zip(headers, cells)) transforma a linha em um dicionário «cabeçalho → valor» — daí em diante é fácil montar o DataFrame.


5. Tabelas complexas: colspan e rowspan

As células mescladas quebram o destrinchamento simples: o número de <td> deixa de bater entre as linhas. É preciso «desdobrar» as mesclagens.

colspan (mesclagem horizontal)

python
def expand_row(tr):
    cells = []
    for td in tr.find_all(["td", "th"]):
        text = td.get_text(strip=True)
        span = int(td.get("colspan", 1))
        cells.extend([text] * span)   # duplicamos conforme a largura da mesclagem
    return cells

rowspan (mesclagem vertical)

O rowspan é mais difícil — o valor «escorre» para as linhas de baixo. É preciso manter um buffer de pendências:

python
def parse_table_with_rowspan(table):
    result = []
    rowspans = {}          # {índice_da_coluna: (valor, linhas_restantes)}

    for tr in table.find_all("tr"):
        row = []
        col = 0
        cells = tr.find_all(["td", "th"])
        cell_iter = iter(cells)

        while col < len(rowspans) or cells:
            # primeiro preenchemos as células que «escorrem» de cima
            if col in rowspans and rowspans[col][1] > 0:
                value, left = rowspans[col]
                row.append(value)
                rowspans[col] = (value, left - 1)
                col += 1
                continue
            try:
                td = next(cell_iter)
            except StopIteration:
                break
            text = td.get_text(strip=True)
            rs = int(td.get("rowspan", 1))
            if rs > 1:
                rowspans[col] = (text, rs - 1)
            row.append(text)
            col += 1
        if row:
            result.append(row)
    return result

É um esqueleto simplificado — as tabelas reais podem ser mais caprichosas. Mas o princípio fica claro: manter um dicionário de rowspans ativos e substituir os valores nas linhas seguintes. Muitas vezes é mais simples testar primeiro o pandas.read_html (ele sabe desdobrar muitas mesclagens) e partir para o destrinchamento manual só se o pandas não der conta.


6. Codificações e caracteres especiais nas tabelas

Se nas células aparecem caracteres corrompidos no lugar de acentos e cedilhas, o problema está na codificação da resposta, não na tabela. Passe bytes ao parser (resp.content) ou fixe a codificação (resp.encoding = resp.apparent_encoding). O detalhamento completo está no hub, seção sobre codificações.

Um detalhe à parte para os números com separador de milhar: «13 100 000» com espaço. Limpe-os antes de converter para número:

python
value = "13 100 000".replace("\xa0", "").replace(" ", "")
number = int(value)   # 13100000

\xa0 é o espaço não quebrável (non-breaking space), um «convidado invisível» frequente nas tabelas web. E se o site separa os milhares com pontos («13.100.000»), remova-os do mesmo jeito antes da conversão.


7. Limpeza e gravação dos dados

Depois da extração, os dados quase sempre estão «sujos»: espaços, símbolos de moeda, unidades de medida.

python
import re

def clean_price(text):
    # "1 299 €" -> 1299
    digits = re.sub(r"[^\d]", "", text)
    return int(digits) if digits else None

df["price"] = df["price"].apply(clean_price)

Gravação em diferentes formatos via pandas:

python
df.to_csv("data.csv", index=False, encoding="utf-8-sig")   # -sig para o Excel
df.to_excel("data.xlsx", index=False)
df.to_json("data.json", orient="records", force_ascii=False)

O utf-8-sig acrescenta o BOM para que o Excel exiba corretamente os acentos e as cedilhas. O force_ascii=False mantém os caracteres acentuados como são, e não como sequências \uXXXX. Mais sobre o trabalho com JSON em «Parsing de JSON».


8. Tabelas dinâmicas (JavaScript)

Se a tabela é carregada por um script (paginação sem recarregar, AJAX), ela não estará no HTML original. Há dois caminhos:

  1. Encontrar a fonte dos dados. Abra a aba Network do navegador — muitas vezes a tabela é alimentada por uma API JSON. Scrapear a API é mais simples e confiável que o HTML; veja «Parsing de JSON».
  2. Renderizar com um navegador. Playwright/Selenium esperam a renderização, e depois você destrincha o HTML já pronto:
python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(url)
    page.wait_for_selector("table")
    html = page.content()
    browser.close()

soup = BeautifulSoup(html, "lxml")
# ... e daqui em diante, como com uma tabela normal

9. Vantagens e desvantagens das abordagens

Abordagem Vantagens Desvantagens
pandas.read_html uma linha, parsing automático, DataFrame direto tropeça em layouts fora do padrão e mesclagens complexas
BeautifulSoup controle total, qualquer layout mais código; as células mescladas se destrincham na mão
lxml + XPath velocidade máxima em grandes volumes API menos amigável (veja o artigo sobre lxml)
Playwright/Selenium funciona com tabelas JS lento, dependência pesada

Recomendação prática: comece com o pandas.read_html. Se não der certo — BeautifulSoup. Se a tabela é de JavaScript — procure a API JSON, e só em último caso renderize com o navegador. E se tabelas idênticas estão espalhadas por centenas de páginas (um catálogo paginado, um arquivo de cotações), baixe-as em paralelo — isso acelera a coleta várias vezes; veja «Scraping assíncrono em Python».