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>
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 ...
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)
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
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 |
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
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
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)
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>
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...']
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
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
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 |
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
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)
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>
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
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()
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)