Tabelas da Wikipédia Que Quebram a Maioria dos Scrapers (E Como Corrigir)

python dev.to

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>
Enter fullscreen mode Exit fullscreen mode

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    ...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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  |
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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...']
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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    |
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials