Scraping por lenguaje 7 min de lectura

Extraer tablas HTML con Python y BeautifulSoup

Extraiga tablas HTML paso a paso con Python y BeautifulSoup: celdas anidadas, combinaciones colspan y rowspan, y exportación del resultado a CSV y Excel.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 15 enero 2025

Las tablas — uno de los formatos más frecuentes de datos estructurados en la web: tipos de cambio, estadísticas deportivas, listas de precios, rankings. En este artículo veremos cómo extraer una tabla HTML y convertirla en un conjunto de datos limpio — desde el pandas.read_html de una sola línea hasta el desglose manual de tablas complejas con celdas combinadas mediante BeautifulSoup.

Es una derivación práctica de la guía panorámica «Web scraping con Python», donde se explican las técnicas básicas de descarga de páginas y el trabajo con las bibliotecas.

Índice

  1. Estructura de una tabla HTML
  2. La vía rápida: pandas.read_html
  3. La vía flexible: BeautifulSoup a mano
  4. Extracción de los encabezados
  5. Tablas complejas: colspan y rowspan
  6. Codificaciones y caracteres especiales
  7. Limpieza y guardado de los datos
  8. Tablas dinámicas (JavaScript)
  9. Ventajas y desventajas de los enfoques

1. Estructura de una tabla HTML

Antes de extraer nada, hay que entender el marcado:

html
<table>
  <thead>
    <tr><th>Ciudad</th><th>Población</th></tr>
  </thead>
  <tbody>
    <tr><td>Tokio</td><td>13 100 000</td></tr>
    <tr><td>Sídney</td><td>5 600 000</td></tr>
  </tbody>
</table>
  • <table> — el contenedor de la tabla;
  • <thead> / <tbody> — la cabecera y el cuerpo (no siempre presentes);
  • <tr> — la fila (table row);
  • <th> — celda de encabezado, <td> — celda de datos.

2. La vía rápida: pandas.read_html

Si la tabla es «correcta» (un <table> normal, sin trucos), pandas la desglosa en una línea. Por debajo usa lxml o BeautifulSoup.

python
import pandas as pd

# read_html devuelve una LISTA con todas las tablas de la página
tables = pd.read_html("https://example.com/stats")
df = tables[0]          # la primera tabla
print(df.head())
df.to_csv("data.csv", index=False)

Parámetros útiles:

python
tables = pd.read_html(
    url,
    match="Población",   # tomar solo las tablas que contienen esta palabra
    header=0,            # qué fila es el encabezado
    thousands=" ",       # separador de millares (para "13 100 000")
    decimal=",",         # separador decimal (formato español)
)

Consejo: si el sitio bloquea las peticiones de pandas, descargue el HTML con requests y las cabeceras adecuadas y pase el texto: pd.read_html(response.text).

read_html es ideal para tablas simples. Pero tropieza con la maquetación no estándar, las celdas combinadas y las tablas montadas con «div en lugar de table». Entonces hace falta el desglose manual.


3. La vía flexible: BeautifulSoup a mano

El control total lo da BeautifulSoup. El bucle básico por filas y celdas:

python
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:                      # saltamos las filas vacías
        rows.append(cells)

for row in rows:
    print(row)

find_all(["td", "th"]) captura tanto las celdas normales como las de encabezado. get_text(strip=True) elimina los espacios y saltos de línea sobrantes.

Elegir una tabla concreta

Si hay varias tablas, agárrese a la clase, al id o al entorno:

python
table = soup.find("table", class_="prices")
table = soup.select_one("#main-table")
table = soup.find("h2", string="Precios").find_next("table")

4. Extracción de los encabezados

Para obtener un diccionario o un DataFrame con sentido, separe los encabezados de los datos:

python
table = soup.find("table")

# encabezados: del thead o de la primera fila
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"])]

# datos
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)) convierte la fila en un diccionario «encabezado → valor» — a partir de ahí es fácil montar el DataFrame.


5. Tablas complejas: colspan y rowspan

Las celdas combinadas rompen el desglose simple: el número de <td> deja de coincidir entre filas. Hay que «desplegar» las combinaciones.

colspan (combinación horizontal)

python
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 según el ancho de la combinación
    return cells

rowspan (combinación vertical)

El rowspan es más difícil — el valor «se filtra» hacia las filas inferiores. Hay que llevar un búfer de arrastres:

python
def parse_table_with_rowspan(table):
    result = []
    rowspans = {}          # {índice_de_columna: (valor, filas_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:
            # primero rellenamos las celdas que «se filtran» desde arriba
            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

Es un esqueleto simplificado — las tablas reales pueden ser más caprichosas. Pero el principio queda claro: mantener un diccionario de rowspan activos y sustituir los valores en las filas siguientes. A menudo es más sencillo probar primero pandas.read_html (sabe desplegar muchas combinaciones) y pasar al desglose manual solo si pandas no lo consigue.


6. Codificaciones y caracteres especiales en las tablas

Si en las celdas aparecen caracteres corruptos en lugar de tildes y eñes, el problema está en la codificación de la respuesta, no en la tabla. Pase bytes al parser (resp.content) o fije la codificación (resp.encoding = resp.apparent_encoding). El desglose completo — en el hub, sección sobre codificaciones.

Un matiz aparte para los números con separador de millares: «13 100 000» con espacio. Límpielos antes de convertirlos a número:

python
value = "13 100 000".replace("\xa0", "").replace(" ", "")
number = int(value)   # 13100000

\xa0 — el espacio de no separación, un «invitado invisible» frecuente en las tablas web. Y si el sitio separa los millares con puntos («13.100.000»), elimínelos del mismo modo antes de la conversión.


7. Limpieza y guardado de los datos

Tras la extracción, los datos casi siempre están «sucios»: espacios, símbolos de moneda, unidades de medida.

python
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)

Guardado en distintos formatos mediante pandas:

python
df.to_csv("data.csv", index=False, encoding="utf-8-sig")   # -sig para Excel
df.to_excel("data.xlsx", index=False)
df.to_json("data.json", orient="records", force_ascii=False)

utf-8-sig añade el BOM para que Excel muestre correctamente las tildes y las eñes. force_ascii=False conserva los caracteres acentuados tal cual, y no como secuencias \uXXXX. Más sobre el trabajo con JSON — en «Parseo de JSON».


8. Tablas dinámicas (JavaScript)

Si la tabla se carga mediante un script (paginación sin recargar, AJAX), no estará en el HTML original. Hay dos caminos:

  1. Encontrar la fuente de los datos. Abra la pestaña Network del navegador — a menudo la tabla se alimenta de una API JSON. Scrapear la API es más simple y fiable que el HTML; vea «Parseo de JSON».
  2. Renderizar con un navegador. Playwright/Selenium esperan al renderizado, y después usted desglosa el HTML ya listo:
python
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")
# ... y a partir de aquí, como con una tabla normal

9. Ventajas y desventajas de los enfoques

Enfoque Ventajas Desventajas
pandas.read_html una línea, parseo automático, DataFrame directo tropieza con la maquetación no estándar y las combinaciones complejas
BeautifulSoup control total, cualquier maquetación más código; las celdas combinadas se desglosan a mano
lxml + XPath máxima velocidad con grandes volúmenes API menos amigable (vea el artículo sobre lxml)
Playwright/Selenium funciona con tablas JS lento, dependencia pesada

Recomendación práctica: empiece con pandas.read_html. Si no lo consigue — BeautifulSoup. Si la tabla es de JavaScript — busque la API JSON, y solo en último extremo renderice con el navegador. Y si tablas idénticas están repartidas por cientos de páginas (un catálogo paginado, un archivo de cotizaciones), descárguelas en paralelo — acelera la recolección varias veces; vea «Scraping asíncrono en Python».