A Wikipédia é a fonte mais comum de dados tabulares na web. Também é um campo minado de casos extremos que quebram scrapers ingênuos.
Coletei os cinco padrões que mais causam problemas enquanto construía o HTML Table Exporter, com código de detecção e correções para cada um.
Padrão 1: Linhas de Navegação ("v t e")
O Problema:
<table>
<tr>
<td colspan="5">v t e Países por população</td>
</tr>
<tr>
<td>Rank</td><td>País</td><td>População</td>...
</tr>
...
</table>
A primeira linha contém links "v t e" (Ver/Falar/Editar) para páginas de template da Wikipédia. Se seu scraper trata a linha 0 como cabeçalhos, tudo quebra.
O que pd.read_html produz:
v t e Países por população
0 Rank País ...
1 1 China ...
Detecção:
def is_nav_row(row_values):
"""Detectar prefixo de navegação da Wikipédia."""
if not row_values:
return False
first_cell = str(row_values[0]).strip().lower()
patterns = [
r'^v\s+t\s+e\s', # "v t e "
r'^v\s*\|\s*t\s*\|\s*e', # "v | t | e"
r'^\[v\]\s*\[t\]\s*\[e\]' # "[v] [t] [e]"
]
import re
return any(re.match(p, first_cell) for p in patterns)
Correção:
import pandas as pd
def read_wikipedia_table(url, table_index=0):
tables = pd.read_html(url)
df = tables[table_index]
# Verificar se a primeira linha é navegação
if is_nav_row(df.iloc[0].values):
# Usar a segunda linha como cabeçalho
df.columns = df.iloc[1]
df = df.iloc[2:].reset_index(drop=True)
return df
Padrão 2: Tabelas Duplicadas Horizontalmente
O Problema:
Para economizar espaço vertical, a Wikipédia exibe algumas tabelas em múltiplas colunas:
| Rank | Nome | Pop | Rank | Nome | Pop |
|------|--------|------|------|---------|------|
| 1 | Tóquio | 37M | 11 | Paris | 11M |
| 2 | Delhi | 32M | 12 | Cairo | 10M |
Isso é logicamente UMA tabela com estrutura de colunas repetida.
O que pd.read_html produz:
Rank Nome Pop Rank.1 Nome.1 Pop.1
0 1 Tóquio 37M 11 Paris 11M
1 2 Delhi 32M 12 Cairo 10M
O Pandas vê 6 colunas. Se você filtrar por "Nome", perde metade dos dados.
Detecção:
def detect_horizontal_duplication(columns):
"""Verificar se colunas se repetem (Rank, Nome, Pop, Rank, Nome, Pop)."""
cols = list(columns)
n = len(cols)
# Tentar dividir por 2, 3, 4
for divisor in [2, 3, 4]:
if n % divisor != 0:
continue
chunk_size = n // divisor
base_pattern = [c.rstrip('.0123456789') for c in cols[:chunk_size]]
is_duplicate = True
for i in range(1, divisor):
chunk = cols[i * chunk_size : (i + 1) * chunk_size]
normalized = [c.rstrip('.0123456789') for c in chunk]
if normalized != base_pattern:
is_duplicate = False
break
if is_duplicate:
return chunk_size
return None
Correção:
def normalize_duplicated_table(df, base_columns):
"""Empilhar tabelas duplicadas horizontalmente de forma vertical."""
n_repeats = len(df.columns) // base_columns
frames = []
for i in range(n_repeats):
start = i * base_columns
end = start + base_columns
chunk = df.iloc[:, start:end].copy()
chunk.columns = df.columns[:base_columns]
# Remover linhas onde todos os valores são NaN (segunda metade vazia)
chunk = chunk.dropna(how='all')
frames.append(chunk)
return pd.concat(frames, ignore_index=True)
# Uso
df = pd.read_html(url)[0]
chunk_size = detect_horizontal_duplication(df.columns)
if chunk_size:
df = normalize_duplicated_table(df, chunk_size)
Padrão 3: Linhas de Título (Ocupando Todas as Colunas)
O Problema:
<table>
<tr>
<td colspan="4">Lista dos edifícios mais altos do mundo</td>
</tr>
<tr>
<td>Rank</td><td>Edifício</td><td>Cidade</td><td>Altura</td>
</tr>
...
</table>
A primeira linha é um título, não dados. Após a expansão do colspan, ela se torna:
['Lista dos edifícios...', 'Lista dos edifícios...', 'Lista dos edifícios...', 'Lista dos edifícios...']
Detecção:
def is_title_row(row_values, next_row_values):
"""Detectar linhas de título de largura total."""
if not row_values or not next_row_values:
return False
# Todos os valores são iguais (colspan expandido)
unique_values = set(str(v).strip() for v in row_values if str(v).strip())
# Linha de título: 1 valor único, próxima linha tem múltiplos valores únicos
# E o valor é longo (> 20 caracteres tipicamente para títulos)
if len(unique_values) == 1:
title = list(unique_values)[0]
next_unique = len(set(str(v).strip() for v in next_row_values if str(v).strip()))
return len(title) > 20 and next_unique > 2
return False
Correção:
def skip_title_rows(df):
"""Remover linhas de título do topo de um dataframe."""
skip_count = 0
for i in range(min(3, len(df) - 1)):
current_row = df.iloc[i].values
next_row = df.iloc[i + 1].values if i + 1 < len(df) else None
if is_title_row(current_row, next_row):
skip_count = i + 1
else:
break
if skip_count > 0:
# Usar a linha após os títulos como cabeçalho
df.columns = df.iloc[skip_count]
df = df.iloc[skip_count + 1:].reset_index(drop=True)
return df
Padrão 4: Cabeçalhos Agrupados (Dois Níveis)
O Problema:
| | | Estatísticas | Estatísticas |
| Rank | País | PIB (nominal) | PIB (PPP) |
|--------|---------|-------------------|------------------|
| 1 | EUA | 25,5 trilhões | 25,5 trilhões |
A linha 0 são cabeçalhos de categoria. A linha 1 são os cabeçalhos reais das colunas. Ambas são semanticamente "cabeçalhos".
O que pd.read_html produz:
Frequentemente corrompido ou com MultiIndex difícil de trabalhar.
Detecção:
def has_grouped_headers(df):
"""Detectar cabeçalhos agrupados de dois níveis."""
if len(df) < 3:
return False
row0 = df.iloc[0].values
row1 = df.iloc[1].values
# Contar valores consecutivos repetidos na linha 0
repeat_count = 0
for i in range(1, len(row0)):
if str(row0[i]).strip() == str(row0[i-1]).strip() and str(row0[i]).strip():
repeat_count += 1
repeat_ratio = repeat_count / max(1, len(row0) - 1)
# Cabeçalhos agrupados tipicamente têm 40%+ de valores repetidos
# E a linha 1 tem mais valores únicos não-vazios que a linha 0
unique0 = len(set(str(v).strip() for v in row0 if str(v).strip()))
unique1 = len(set(str(v).strip() for v in row1 if str(v).strip()))
return repeat_ratio > 0.3 and unique1 > unique0
Correção:
def merge_grouped_headers(df):
"""Mesclar cabeçalhos de dois níveis em nível único."""
group_row = df.iloc[0].values
header_row = df.iloc[1].values
merged = []
for i, (group, header) in enumerate(zip(group_row, header_row)):
g = str(group).strip()
h = str(header).strip()
if not g or g == h:
merged.append(h)
elif not h:
merged.append(g)
else:
merged.append(f"{g} - {h}")
df.columns = merged
return df.iloc[2:].reset_index(drop=True)
# Uso
if has_grouped_headers(df):
df = merge_grouped_headers(df)
Padrão 5: Tabelas Aninhadas em Infoboxes
O Problema:
Infoboxes da Wikipédia contêm tabelas dentro de células de tabelas:
<table class="infobox">
<tr>
<td>População</td>
<td>
<table> <!-- Aninhada! -->
<tr><td>Urbana</td><td>8,3M</td></tr>
<tr><td>Metro</td><td>20,1M</td></tr>
</table>
</td>
</tr>
</table>
O que pd.read_html produz:
Tanto a tabela externa quanto a interna são retornadas. Se você procura "todas as tabelas da página", obtém duplicatas e lixo aninhado.
Detecção e Filtragem:
from bs4 import BeautifulSoup
import requests
def get_top_level_tables(url):
"""Obter apenas tabelas de nível superior, não aninhadas."""
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
all_tables = soup.find_all('table')
top_level = []
for table in all_tables:
# Verificar se esta tabela está dentro de outra tabela
parent = table.parent
is_nested = False
while parent:
if parent.name == 'table':
is_nested = True
break
parent = parent.parent
if not is_nested:
top_level.append(table)
return top_level
def read_top_level_tables(url):
"""Ler apenas tabelas de nível superior como DataFrames."""
import pandas as pd
tables = get_top_level_tables(url)
dfs = []
for table in tables:
try:
# Converter tabela individual em DataFrame
df = pd.read_html(str(table))[0]
dfs.append(df)
except Exception:
continue
return dfs
Leitor Completo de Tabelas da Wikipédia
Combinando todas as correções:
import pandas as pd
import requests
from bs4 import BeautifulSoup
import re
class WikipediaTableReader:
def __init__(self, url):
self.url = url
self.soup = None
def _fetch(self):
if self.soup is None:
response = requests.get(self.url)
self.soup = BeautifulSoup(response.text, 'html.parser')
def _is_nav_row(self, values):
if not values:
return False
first = str(values[0]).strip().lower()
return bool(re.match(r'^v\s+t\s+e\s', first))
def _is_title_row(self, values, next_values):
unique = set(str(v).strip() for v in values if str(v).strip())
if len(unique) != 1:
return False
title = list(unique)[0]
next_unique = len(set(str(v).strip() for v in next_values if str(v).strip()))
return len(title) > 20 and next_unique > 2
def get_tables(self, skip_infobox=True):
"""Obter todas as tabelas de dados da página."""
self._fetch()
tables = self.soup.find_all('table')
results = []
for table in tables:
# Pular tabelas aninhadas
if table.find_parent('table'):
continue
# Pular infoboxes se solicitado
if skip_infobox and 'infobox' in table.get('class', []):
continue
try:
df = pd.read_html(str(table))[0]
df = self._clean_table(df)
if len(df) > 0 and len(df.columns) > 1:
results.append(df)
except Exception:
continue
return results
def _clean_table(self, df):
"""Aplicar todas as etapas de limpeza."""
# Pular linhas de navegação
while len(df) > 0 and self._is_nav_row(df.iloc[0].values):
df.columns = df.iloc[1] if len(df) > 1 else df.columns
df = df.iloc[2:].reset_index(drop=True) if len(df) > 2 else df.iloc[1:]
# Pular linhas de título
if len(df) > 1:
while self._is_title_row(df.iloc[0].values, df.iloc[1].values if len(df) > 1 else []):
df.columns = df.iloc[1]
df = df.iloc[2:].reset_index(drop=True)
return df
# Uso
reader = WikipediaTableReader("https://pt.wikipedia.org/wiki/Lista_de_países_por_população")
tables = reader.get_tables()
Quando Usar uma Extensão
Se você está fazendo extração pontual (não construindo um pipeline), uma extensão de navegador lida com todos esses padrões automaticamente.
O HTML Table Exporter detecta esses padrões e normaliza a saída. Um clique vs. depurar casos extremos. Para uma comparação detalhada de ferramentas de extração de tabelas, confira nosso guia de scrapers de tabelas HTML para Chrome.
Para pipelines automatizados, use o código acima. Para exportações ocasionais, use a ferramenta certa para o trabalho.
Saiba mais em gauchogrid.com/pt-br/html-table-exporter ou experimente grátis na Chrome Web Store.
Encontrou uma tabela da Wikipédia que quebra este código? Compartilhe a URL — vou adicioná-la à minha suíte de testes.