DEV Community

Cover image for 5 Tablas de Wikipedia que Rompen la Mayoría de los Scrapers (Y Cómo Solucionarlo)
circobit
circobit

Posted on

5 Tablas de Wikipedia que Rompen la Mayoría de los Scrapers (Y Cómo Solucionarlo)

Wikipedia es la fuente más común de datos tabulares web. También es un campo minado de casos extremos que rompen los scrapers ingenuos.

Recopilé los cinco patrones que más problemas causan mientras construía HTML Table Exporter, con código de detección y soluciones para cada uno.

Patrón 1: Filas de Navegación ("v t e")

El problema:

<table>
  <tr>
    <td colspan="5">v t e Países por población</td>
  </tr>
  <tr>
    <td>Rango</td><td>País</td><td>Población</td>...
  </tr>
  ...
</table>
Enter fullscreen mode Exit fullscreen mode

La primera fila contiene enlaces "v t e" (Ver/Discusión/Editar) a páginas de plantillas de Wikipedia. Si tu scraper trata la fila 0 como encabezados, todo se rompe.

Lo que produce pd.read_html:

          v t e Países por población
0   Rango                        País    ...
1       1                       China    ...
Enter fullscreen mode Exit fullscreen mode

Detección:

def is_nav_row(row_values):
    """Detectar prefijo de navegación de Wikipedia."""
    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

Solución:

import pandas as pd

def read_wikipedia_table(url, table_index=0):
    tables = pd.read_html(url)
    df = tables[table_index]

    # Verificar si la primera fila es de navegación
    if is_nav_row(df.iloc[0].values):
        # Usar la segunda fila como encabezado
        df.columns = df.iloc[1]
        df = df.iloc[2:].reset_index(drop=True)

    return df
Enter fullscreen mode Exit fullscreen mode

Patrón 2: Tablas Duplicadas Horizontalmente

El problema:

Para ahorrar espacio vertical, Wikipedia muestra algunas tablas en múltiples columnas:

| Rango | Nombre | Pob  | Rango | Nombre  | Pob  |
|-------|--------|------|-------|---------|------|
| 1     | Tokio  | 37M  | 11    | París   | 11M  |
| 2     | Delhi  | 32M  | 12    | Cairo   | 10M  |
Enter fullscreen mode Exit fullscreen mode

Esto es lógicamente UNA tabla con estructura de columnas repetida.

Lo que produce pd.read_html:

   Rango  Nombre  Pob  Rango.1  Nombre.1  Pob.1
0      1   Tokio  37M       11     París    11M
1      2   Delhi  32M       12     Cairo    10M
Enter fullscreen mode Exit fullscreen mode

Pandas lo ve como 6 columnas. Si filtras por "Nombre", te pierdes la mitad de los datos.

Detección:

def detect_horizontal_duplication(columns):
    """Verificar si las columnas se repiten (Rango, Nombre, Pob, Rango, Nombre, Pob)."""
    cols = list(columns)
    n = len(cols)

    # Intentar 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

Solución:

def normalize_duplicated_table(df, base_columns):
    """Apilar tablas 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]
        # Eliminar filas donde todos los valores son NaN (segunda mitad vacía)
        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

Patrón 3: Filas de Título (Abarcando Todas las Columnas)

El problema:

<table>
  <tr>
    <td colspan="4">Lista de los edificios más altos del mundo</td>
  </tr>
  <tr>
    <td>Rango</td><td>Edificio</td><td>Ciudad</td><td>Altura</td>
  </tr>
  ...
</table>
Enter fullscreen mode Exit fullscreen mode

La primera fila es un título, no datos. Después de la expansión de colspan, se convierte en:

['Lista de los más altos...', 'Lista de los más altos...', 'Lista de los más altos...', 'Lista de los más altos...']
Enter fullscreen mode Exit fullscreen mode

Detección:

def is_title_row(row_values, next_row_values):
    """Detectar filas de título de ancho completo."""
    if not row_values or not next_row_values:
        return False

    # Todos los valores son iguales (colspan expandido)
    unique_values = set(str(v).strip() for v in row_values if str(v).strip())

    # Fila de título: 1 valor único, la siguiente fila tiene múltiples valores únicos
    # Y el valor es largo (> 20 caracteres típicamente 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

Solución:

def skip_title_rows(df):
    """Eliminar filas de título del inicio de un 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 la fila después de los títulos como encabezado
        df.columns = df.iloc[skip_count]
        df = df.iloc[skip_count + 1:].reset_index(drop=True)

    return df
Enter fullscreen mode Exit fullscreen mode

Patrón 4: Encabezados Agrupados (Dos Niveles)

El problema:

|        |         | Estadísticas      | Estadísticas     |
| Rango  | País    | PIB (nominal)     | PIB (PPA)        |
|--------|---------|-------------------|------------------|
| 1      | EEUU    | 25.5 billones     | 25.5 billones    |
Enter fullscreen mode Exit fullscreen mode

La fila 0 son encabezados de categoría. La fila 1 son los encabezados reales de columna. Ambas son semánticamente "encabezados."

Lo que produce pd.read_html:

Frecuentemente corrupto o con MultiIndex incómodo de manejar.

Detección:

def has_grouped_headers(df):
    """Detectar encabezados agrupados de dos niveles."""
    if len(df) < 3:
        return False

    row0 = df.iloc[0].values
    row1 = df.iloc[1].values

    # Contar valores consecutivos repetidos en row0
    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)

    # Los encabezados agrupados típicamente tienen 40%+ de valores repetidos
    # Y row1 tiene más valores únicos no vacíos que row0
    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

Solución:

def merge_grouped_headers(df):
    """Combinar encabezados de dos niveles en uno solo."""
    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

Patrón 5: Tablas Anidadas en Infoboxes

El problema:

Los infoboxes de Wikipedia contienen tablas dentro de celdas de tabla:

<table class="infobox">
  <tr>
    <td>Población</td>
    <td>
      <table>  <!-- ¡Anidada! -->
        <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

Lo que produce pd.read_html:

Tanto la tabla externa como la interna son devueltas. Si buscas "todas las tablas de la página", obtienes duplicados y basura anidada.

Detección y Filtrado:

from bs4 import BeautifulSoup
import requests

def get_top_level_tables(url):
    """Obtener solo tablas de nivel superior, no anidadas."""
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'html.parser')

    all_tables = soup.find_all('table')

    top_level = []
    for table in all_tables:
        # Verificar si esta tabla está dentro de otra tabla
        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):
    """Leer solo tablas de nivel superior como DataFrames."""
    import pandas as pd

    tables = get_top_level_tables(url)

    dfs = []
    for table in tables:
        try:
            # Convertir tabla individual a DataFrame
            df = pd.read_html(str(table))[0]
            dfs.append(df)
        except Exception:
            continue

    return dfs
Enter fullscreen mode Exit fullscreen mode

Lector Completo de Tablas de Wikipedia

Combinando todas las soluciones:

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):
        """Obtener todas las tablas de datos de la página."""
        self._fetch()

        tables = self.soup.find_all('table')
        results = []

        for table in tables:
            # Saltar tablas anidadas
            if table.find_parent('table'):
                continue

            # Saltar infoboxes si se solicita
            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 todos los pasos de limpieza."""
        # Saltar filas de navegación
        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:]

        # Saltar filas 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://es.wikipedia.org/wiki/Anexo:Países_por_población")
tables = reader.get_tables()
Enter fullscreen mode Exit fullscreen mode

Cuándo Usar una Extensión en su Lugar

Si estás haciendo extracción ad-hoc (no construyendo un pipeline), una extensión de navegador maneja todos estos patrones automáticamente.

HTML Table Exporter detecta estos patrones y normaliza la salida. Un clic vs. debuggear casos extremos.

Para un tutorial práctico, mira nuestra guía sobre las mejores extensiones de Chrome para exportar tablas.

Para pipelines automatizados, usa el código de arriba. Para exportaciones ocasionales, usa la herramienta correcta para el trabajo.

Más información en gauchogrid.com/es/html-table-exporter o pruébalo gratis en la Chrome Web Store.


¿Encontraste una tabla de Wikipedia que rompe este código? Comparte la URL—la voy a agregar a mi suite de tests.

Top comments (0)