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
- Estrutura de uma tabela HTML
- A via rápida: pandas.read_html
- A via flexível: BeautifulSoup na mão
- Extração dos cabeçalhos
- Tabelas complexas: colspan e rowspan
- Codificações e caracteres especiais
- Limpeza e gravação dos dados
- Tabelas dinâmicas (JavaScript)
- Vantagens e desvantagens das abordagens
1. Estrutura de uma tabela HTML
Antes de extrair qualquer coisa, é preciso entender a marcação:
<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.
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:
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
requestse 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:
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:
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:
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)
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 cellsrowspan (mesclagem vertical)
O rowspan é mais difícil — o valor «escorre» para as linhas de baixo. É preciso manter um buffer de pendências:
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:
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.
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:
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:
- 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».
- Renderizar com um navegador. Playwright/Selenium esperam a renderização, e depois você destrincha o HTML já pronto:
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».