Volver al blog
Guías
Andrei OgiolanLast updated on May 7, 202618 min read

Cómo raspar tablas HTML con Python

Cómo raspar tablas HTML con Python
En resumen: La mayoría de las tablas HTML se pueden extraer con una sola línea de pandas.read_html. Cuando la tabla esté paginada, se haya renderizado con JavaScript o tenga encabezados fusionados, cambia a Requests + BeautifulSoup o a un navegador sin interfaz gráfica como Playwright. Esta guía te ofrece una matriz de decisión, código funcional para los tres enfoques y los pasos de limpieza que convierten las filas extraídas en datos listos para procesar.

Los datos tabulares están por todas partes en la web pública, desde las infoboxes de Wikipedia y los filtros de acciones hasta las estadísticas gubernamentales, las estadísticas deportivas y las páginas de comparación de productos. Si sabes cómo extraer tablas HTML usando Python, puedes convertir esas filas en DataFrames limpios, documentos JSON o filas en tu propia base de datos en cuestión de minutos.

El problema es que las tablas HTML son una categoría engañosamente amplia. Algunas tablas se encuentran perfectamente dentro de <table> el marcado que pandas puede analizar con una sola línea. Otras son cuadrículas creadas a mano <div>, paginadas a lo largo de docenas de páginas, o que solo se rellenan después de que se ejecute JavaScript en el navegador. Un método que funciona perfectamente en Wikipedia podría devolver silenciosamente cero filas en una aplicación de una sola página.

Esta guía repasa tres enfoques en Python y estructura todo el artículo en torno a dos preguntas prácticas: ¿qué método deberías elegir y cómo mantienes tu scraper en funcionamiento cuando el sitio cambie su marcado el próximo trimestre?

Cómo extraer tablas HTML con Python: una matriz de decisión rápida

Antes de escribir una sola línea de código, decide qué herramienta se adapta a la tabla que tienes delante. Elegir mal es la razón más común por la que los tutoriales no sobreviven al contacto con sitios web reales. Utiliza la matriz siguiente para seleccionar por ti mismo.

Criterio

pandas.read_html

Requests + BeautifulSoup

Playwright (o Selenium)

Ideal cuando

La tabla está en HTML inicial y bien formada

Necesitas control o filtrado por celda

La tabla se genera mediante JavaScript

Líneas de código

~3

De 30 a 80

De 40 a 100

Velocidad por página

Rápida

Rápida

Lenta (navegador completo)

Admite JS

No

No

Paginación

Bucle manual

Bucle manual o API oculta

Hacer clic y desplazarse

Resistencia a los cambios en el marcado

Media

Alta (tú escribes los selectores)

Alta

Consumo de memoria

Bajo

Bajo

Alto

Tres reglas generales:

  • Si pd.read_html(url) devuelve las filas que esperas, detente ahí. La línea única es el código más fácil de mantener que jamás escribirás.
  • Si la tabla está en el HTML pero necesitas filtrar, fusionar o normalizar las celdas antes de que lleguen a un DataFrame, utiliza Requests + BeautifulSoup.
  • Si «Ver código fuente de la página» muestra un <div id="grid"> y los datos solo aparecen después de que se cargue la página, necesitas Playwright o un punto final JSON oculto.

El resto de este artículo muestra cómo extraer tablas HTML usando Python en cada uno de esos escenarios, además de los casos extremos que hacen que falle un código que, por lo demás, funciona.

Anatomía de una tabla HTML (y por qué el scraping resulta complicado)

Una tabla HTML típica tiene este aspecto:

<table id="employees" class="stripe">
  <thead><tr><th>Name</th><th>Position</th><th>Salary</th></tr></thead>
  <tbody>
    <tr><td>Ada Lovelace</td><td>Engineer</td><td>$120,000</td></tr>
    <tr><td>Alan Turing</td><td>Researcher</td><td>$135,000</td></tr>
  </tbody>
</table>

Cinco etiquetas realizan la mayor parte del trabajo: <table> es el contenedor, <thead> y <tbody> agrupa las filas, <tr> es una fila, y <th> o <td> son celdas de encabezado y de datos, respectivamente. Hay dos atributos que complican las cosas: colspan hace que una celda abarque varias columnas, y rowspan hace que abarque varias filas. Ambos se utilizan mucho en tablas financieras y deportivas.

En la práctica, la mitad de estas convenciones se omiten. Muchas páginas omiten <thead> y <tbody>, omiten las etiquetas de cierre o representan las tablas como <div> que ningún analizador reconocerá como una tabla en absoluto. El scraping en el mundo real consiste principalmente en lidiar con esa variación, por lo que pandas por sí solo no es suficiente en todos los sitios.

Método 1: pandas.read_html, la línea única

pandas.read_html es una función práctica de la biblioteca de manipulación de datos pandas que toma una URL o una cadena HTML y devuelve una lista de DataFrames, uno por <table> que pueda encontrar. Según la documentación de pandas, requiere lxml, html5lib, o bs4 en segundo plano, e identifica las tablas buscando elementos de tabla estándar.

Todo el atractivo reside en que puedes escribir tres líneas de código y obtener un DataFrame tipado y consultable:

import pandas as pd

tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue")
df = tables[0]
print(df.head())

El inconveniente es que read_html solo ve lo que ya está en el cuerpo de la respuesta. Si la tabla se rellena con JavaScript después de que se cargue la página, la función genera un error ValueError: No tables found aunque la tabla sea claramente visible en el navegador. Conocer esta limitación de antemano ahorra mucho trabajo de depuración.

Configuración del entorno de Python

Puedes ejecutar todos los ejemplos de esta guía con un entorno virtual nuevo y tres paquetes:

python -m venv .venv
source .venv/bin/activate
pip install pandas requests beautifulsoup4 lxml html5lib playwright
playwright install chromium

lxml es el analizador HTML más rápido disponible para Python y es el que la mayoría de los profesionales utilizan por defecto. html5lib es más lento, pero sigue el algoritmo de análisis de WHATWG, lo que lo convierte en la opción más tolerante con el marcado defectuoso. Instala ambos para poder cambiar de analizador cuando uno se atasque.

Un tutorial completo de pandas.read_html

Vamos a extraer una tabla real y bien formada: la lista de países por PIB de Wikipedia. El flujo de trabajo completo consta de cuatro líneas.

import pandas as pd

url = "https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal)"
tables = pd.read_html(url)
print(f"Found {len(tables)} tables on the page")

gdp = tables[2]            # pick the right one by index
gdp.columns = [c[1] if isinstance(c, tuple) else c for c in gdp.columns]
print(gdp.head())

Hay tres cosas que debes tener en cuenta. En primer lugar, read_html devuelve una lista, por lo que se indexa en ella. Segundo, las tablas de Wikipedia suelen tener encabezados de varios niveles, que pandas expone como un MultiIndex. La comprensión de lista la aplana conservando el nivel inferior. Tercero, no hay iteración manual por filas: cada celda ya se encuentra en una columna tipada a la que puedes llamar .sort_values, .groupby, o .to_csv .

Cuando solo necesitas los datos para un análisis rápido, este es realmente todo el código que debes escribir.

Solución de problemas con pandas.read_html: errores comunes

pd.read_html falla de formas predecibles. Memoriza estos cuatro y resolverás la mayoría de los problemas en menos de un minuto.

  1. ValueError: No tables found. La página está renderizada con JavaScript o se encuentra tras un muro de inicio de sesión. Pasa directamente a la sección de Playwright.
  2. El fetcher interno de pandas devuelve un error HTTP 403 o 429. El urllib agente de usuario está siendo bloqueado. Recupera el HTML tú mismo con Requests y pasa la cadena a read_html:
import requests, pandas as pd
headers = {"User-Agent": "Mozilla/5.0 (compatible; analytics-bot/1.0)"}
html = requests.get(url, headers=headers, timeout=15).text
tables = pd.read_html(html)
  1. Índice de tabla incorrecto. Utiliza match= para filtrar por una cadena que aparezca dentro de la tabla de destino, por ejemplo pd.read_html(html, match="Population"). Esto es mucho más estable que confiar en tables[3].
  2. Caracteres ilegibles en contenido no ASCII. Fuerza una codificación leyendo los bytes explícitamente: response = requests.get(url); response.encoding = "utf-8"; tables = pd.read_html(response.text).

Si sigues encontrando obstáculos tras estas correcciones, es casi seguro que la tabla necesita Requests + BeautifulSoup o un navegador sin interfaz gráfica, no más read_html soluciones provisionales.

Método 2: Requests + BeautifulSoup, cuando necesitas control

pandas.read_html es ideal cuando quieres que cada celda sea exactamente como aparece en el HTML. En el momento en que necesitas filtrar filas durante la extracción, unir valores de dos columnas, eliminar símbolos de moneda sobre la marcha o extraer href de una celda enlazada, deja de ser la herramienta adecuada.

Ahí es donde entra en juego Requests + BeautifulSoup. Requests se encarga de la capa HTTP (encabezados, cookies, sesiones, reintentos), y BeautifulSoup te proporciona un árbol de análisis que puedes recorrer con selectores CSS, coincidencia de atributos o navegación entre elementos hermanos. Si eres nuevo en BeautifulSoup, nuestro análisis en profundidad sobre la extracción y el análisis de datos web con Python y BeautifulSoup repasa la superficie de la API con detalle. Esta combinación es también la que acaban eligiendo la mayoría de los scrapers en producción, ya que cada paso (recogida, análisis, extracción, transformación) es algo que tú controlas.

Las tres secciones siguientes muestran cómo extraer tablas HTML utilizando Python con esta pila: una solicitud educada, un selector robusto para la tabla y un bucle de filas que no se rompe cuando se añade una columna.

Envío de solicitudes HTTP educadas y realistas

Las defensas antibots se basan en unas pocas señales sencillas: un User-Agent ausente o predeterminado, sin Accept-Language, ausencia de cookies y tráfico que agota una sesión en un segundo. Imita un navegador real y reutiliza una conexión:

import requests
from bs4 import BeautifulSoup

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
})

response = session.get("https://example.com/employees", timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.text, "lxml")

Hay tres pequeños hábitos que importan. Session() mantiene las cookies y el agrupamiento de conexiones entre llamadas. raise_for_status() convierte las respuestas silenciosas 4xx/5xx en excepciones que puedes reintentar. Y pasar "lxml" como el analizador es aproximadamente entre cinco y diez veces más rápido que el integrado html.parser en páginas grandes.

Localizar la tabla correcta en la página

Una vez que tienes un BeautifulSoup objeto, el siguiente problema es seleccionar la tabla correcta <table>. Las páginas suelen tener entre ocho y quince de ellas (piensa en tablas de diseño, widgets de la barra lateral, controles de paginación ocultos). Prueba los selectores en este orden de estabilidad:

# 1. By stable id (best)
table = soup.find("table", id="employees")

# 2. By a class that's specific to this table
table = soup.find("table", class_="data-grid")

# 3. By a CSS selector
table = soup.select_one("section#payroll table.stripe")

# 4. By the heading that precedes it (when classes are dynamic)
heading = soup.find(["h2", "h3"], string=lambda s: s and "Employees" in s)
table = heading.find_next("table") if heading else None

Cuando los nombres de clase se generan automáticamente y cambian en cada implementación (un patrón común de React), es preferible utilizar XPath mediante lxml, ya que puede expresar «la tercera tabla dentro de la sección cuyo texto de encabezado contenga 'Empleados'» en una sola expresión. Tenemos una guía aparte sobre XPath frente a los selectores CSS que profundiza en esta disyuntiva.

Iterar por filas y extraer celdas de forma segura

La mayoría de los tutoriales de scraping muestran bucles de filas que indexan las celdas por su posición: cells[0] es nombre, cells[1] es la posición, cells[2] es el salario. Ese código deja de funcionar el día que alguien añade una columna «Departamento». El patrón más robusto consiste en leer los encabezados una vez y vincularlos con cada fila.

# Read headers from <thead> if present, else from the first row
header_cells = table.select("thead th") or table.select("tr:first-of-type th, tr:first-of-type td")
headers = [th.get_text(strip=True) for th in header_cells]

rows = []
for tr in table.select("tbody tr") or table.select("tr")[1:]:
    cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
    if not cells:
        continue
    rows.append(dict(zip(headers, cells)))

print(f"Extracted {len(rows)} rows with {len(headers)} columns")

Esto te ofrece tres ventajas de forma gratuita. Las nuevas columnas se incorporan automáticamente porque las claves provienen de los encabezados, no de índices. Las filas vacías (a menudo utilizadas como separadores visuales) se omiten. Y cada celda pasa por get_text(strip=True), lo que colapsa los espacios en blanco y elimina los \n caracteres que atormentan a las cell.text . Este es el bucle de filas que deberías copiar en todos tus proyectos de BeautifulSoup.

Guardar filas extraídas en JSON, CSV o Parquet

Una vez que tengas una lista de diccionarios, guardarlos es cuestión de una sola línea por formato:

import json
import pandas as pd

# JSON, human-readable, UTF-8 safe
with open("employees.json", "w", encoding="utf-8") as f:
    json.dump(rows, f, indent=2, ensure_ascii=False)

# CSV via pandas (handles quoting, encoding, and missing keys)
df = pd.DataFrame(rows)
df.to_csv("employees.csv", index=False, encoding="utf-8")

# Parquet for analytical pipelines (smaller files, typed columns)
df.to_parquet("employees.parquet", index=False)

Opta por JSON cuando el consumidor sea un script o una interfaz de usuario, por CSV cuando un usuario vaya a abrirlo en Excel o BigQuery, y por Parquet cuando el conjunto de datos supere unos cientos de miles de filas o alimente a Spark, Snowflake o DuckDB. Los archivos Parquet suelen ser entre 5 y 10 veces más pequeños que los CSV equivalentes y conservan los tipos de datos. Para cualquier cosa destinada a una base de datos relacional, ve directamente a df.to_sql para saltarte por completo el archivo intermedio.

Manejo de encabezados complejos con colspan y rowspan

Los encabezados de dos filas son habituales en las tablas de finanzas, estadísticas gubernamentales y deportes. La fila superior agrupa las columnas («Q1 2024», «Q2 2024»), y la fila inferior las etiqueta («Ingresos», «Beneficios»). Codificar en duro los nombres de las columnas como ["Name", "Position", "Contact"] funciona una vez y se rompe para siempre. He aquí un algoritmo genérico que respeta colspan y rowspan.

def expand_header(table):
    # Return a flat list of column labels from a multi-row <thead>
    rows = table.select("thead tr")
    if not rows:
        return [th.get_text(strip=True) for th in table.select("tr:first-of-type th")]

    grid = []  # grid[row_index] = list of column labels at that row
    for r, tr in enumerate(rows):
        while len(grid) <= r:
            grid.append([])
        col = 0
        for th in tr.find_all(["th", "td"]):
            # skip already-filled slots from previous rowspans
            while col < len(grid[r]) and grid[r][col] is not None:
                col += 1
            text = th.get_text(strip=True)
            colspan = int(th.get("colspan", 1))
            rowspan = int(th.get("rowspan", 1))
            for dr in range(rowspan):
                while len(grid) <= r + dr:
                    grid.append([])
                row_buf = grid[r + dr]
                # pad
                while len(row_buf) < col + colspan:
                    row_buf.append(None)
                for dc in range(colspan):
                    row_buf[col + dc] = text
            col += colspan

    # Combine the columns of each row, top-down, into a single label per column
    n_cols = max(len(r) for r in grid)
    flat = []
    for c in range(n_cols):
        parts = [grid[r][c] for r in range(len(grid)) if c < len(grid[r]) and grid[r][c]]
        # de-dup adjacent identical strings: ['Q1 2024', 'Q1 2024', 'Revenue'] -> 'Q1 2024 Revenue'
        seen = []
        for p in parts:
            if not seen or seen[-1] != p:
                seen.append(p)
        flat.append(" ".join(seen))
    return flat

Combina esto con el mismo zip(headers, cells) bucle de filas anterior y obtendrás un análisis de encabezados que resiste cualquier combinación de celdas fusionadas. La misma idea (una cuadrícula 2D que se rellena colspan por colspan) se extiende al cuerpo cuando los rowspans repiten valores a lo largo de las columnas: haz un seguimiento de qué casillas ya están ocupadas y omítelas en las <tr> .

Extracción de tablas HTML paginadas (tres estrategias)

La paginación es la parte más subestimada a la hora de extraer datos de tablas HTML con Python. La mayoría de los tutoriales solo muestran «haz clic en el botón Siguiente en un navegador sin interfaz gráfica», que es el enfoque más lento y frágil. Prueba primero estas tres, por orden de preferencia.

1. Aumenta el parámetro de consulta page-size. Muchas tablas aceptan ?per_page=500 o ?length=1000. Una sola solicitud, todas las filas, sin bucles. Inspecciona la URL cuando hagas clic en el menú desplegable de tamaño de página y a menudo encontrarás esto sin esfuerzo.

2. Acede a la API JSON subyacente. Abre DevTools, ve a la pestaña Red, filtra por Fetch/XHRy haz clic en la página siguiente. Casi todas las tablas de datos modernas están respaldadas por un punto final que devuelve JSON. Al llamarlo directamente, se omite por completo el análisis de HTML:

import requests
url = "https://example.com/api/employees"
all_rows = []
for page in range(1, 20):
    payload = requests.get(url, params={"page": page, "size": 100}, timeout=15).json()
    if not payload["items"]:
        break
    all_rows.extend(payload["items"])

3. Recorre las cadenas de consulta de la página. Cuando la URL contenga el número de página (?page=2, &start=20), itera explícitamente y detente cuando la tabla vuelva vacía. Esto es más fiable que utilizar un navegador, ya que no hay nada en lo que hacer clic ni animaciones que esperar.

Un navegador sin interfaz gráfica es tu último recurso, no el primero. Resérvalo para tablas en las que el enlace a la página siguiente esté vinculado a un controlador JavaScript sin cambio de URL.

Método 3: Playwright para tablas renderizadas con JavaScript

Cuando la tabla solo aparece después de que la página se hidrate, necesitas algo que ejecute JavaScript. Playwright es la opción moderna: incluye enlaces oficiales a Python, ejecuta Chromium, Firefox o WebKit, y tiene un sólido comportamiento de espera automática. Aquí tienes la plantilla completa sobre cómo extraer tablas HTML con Python que dependen de JS:

from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup
import pandas as pd

URL = "https://example.com/dashboard"

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page(user_agent="Mozilla/5.0 ... Chrome/124.0 Safari/537.36")
    page.goto(URL, wait_until="domcontentloaded")

    # Wait for the actual data, not just the page load
    page.wait_for_selector("table#grid tbody tr", timeout=15000)

    html = page.content()
    browser.close()

# Hand the rendered HTML off to your existing parser
soup = BeautifulSoup(html, "lxml")
table = soup.find("table", id="grid")
# ... use the same row loop from earlier ...

# Or, when the table is well-formed, skip BeautifulSoup entirely:
df = pd.read_html(html, match="Department")[0]
print(df.head())

El patrón es siempre el mismo: navegar, esperar a que se carguen los datos (no solo load), capturar page.content()y, a continuación, introduce esa cadena en el mismo código de análisis que usarías para HTML estático. Consulta la documentación de Playwright para Python para obtener información sobre la instalación, las API asíncronas y el rastreo.

Selenium y Pyppeteer son alternativas válidas. Selenium cuenta con un ecosistema más amplio y es la opción segura si tu equipo ya lo utiliza para pruebas de extremo a extremo; además, nuestro tutorial paso a paso de Selenium cubre la configuración equivalente. Pyppeteer es más ligero, pero su mantenimiento es menos activo. Para una comparación más completa de las herramientas sin interfaz gráfica, consulta nuestra guía de extracción web con Playwright. Para proyectos nuevos, Playwright suele ser la opción más ergonómica.

Elección de un analizador HTML y gestión de celdas vacías

BeautifulSoup es un envoltorio. El análisis real se delega a uno de los tres backends, y la elección es más importante de lo que la mayoría de los tutoriales admiten.

Analizador

Velocidad

Tolerancia al HTML incorrecto

Instalación

html.parser

Lenta

Media (integrado en Python)

Ninguna

lxml

Rápido

Bastante estricto, pero pragmático

pip install lxml

html5lib

El más lento

El más alto, sigue WHATWG

pip install html5lib

Predeterminado lxml. Cambiar a html5lib solo cuando lxml devuelva un árbol parcial en una página con marcado defectuoso (falta el </td>, sin cerrar <tr>, < ). Puedes verificarlo rápidamente:

import time
from bs4 import BeautifulSoup

for parser in ["lxml", "html.parser", "html5lib"]:
    t0 = time.perf_counter()
    soup = BeautifulSoup(html, parser)
    rows = soup.select("tbody tr")
    print(f"{parser:10} {len(rows):4} rows in {time.perf_counter()-t0:.3f}s")

Para las celdas vacías, escribe una función auxiliar que devuelva un valor predeterminado razonable en lugar de bloquearse:

def cell_text(cell, default=""):
    if cell is None:
        return default
    text = cell.get_text(" ", strip=True)
    return text if text else default

Úsalo siempre que indexes una fila. None Las comprobaciones en cada punto de llamada saturan el bucle y pasan por alto el caso en el que la celda existe pero solo contiene &nbsp;. Esta función auxiliar gestiona ambos casos.

Evitar bloqueos: encabezados, sesiones y proxies

Un código de estado 200 significa que la solicitud fue aceptada. Cualquier otro (especialmente 403, 429 o 503) suele significar que el sitio detectó tu rastreador. Sube esta escalera en orden, deteniéndote en el primer peldaño que funcione.

  1. Encabezados realistas. Establece User-Agent, Accept-Languagey Referer a los valores que enviaría una sesión real de Chrome. Esto por sí solo soluciona un número sorprendente de bloqueos.
  2. Sesiones persistentes. Usa requests.Session() para que las cookies establecidas por la página de inicio se envíen en las llamadas posteriores. Muchos sitios emiten una cookie de sesión en la primera visita y rechazan las solicitudes que carecen de ella.
  3. Retraso exponencial en 429 y 503. Espera 2 ** attempt segundos y reintenta hasta cinco veces. Respeta Retry-After los encabezados cuando el servidor los proporcione.
  4. Proxies de centros de datos. Baratos, rápidos y suficientes para la mayoría de sitios estáticos. Rotar las IP entre tu grupo de trabajadores.
  5. Proxies residenciales. IP residenciales reales de 195 países, que se utilizan cuando los rangos de los centros de datos ya están bloqueados. Más lentos, pero más difíciles de detectar.
  6. API de scraping gestionadas. Cuando quieras centrarte en el análisis en lugar de en la infraestructura, servicios como nuestra API Scraper en WebScrapingAPI se encargan de la rotación de proxies, la generación de encabezados y los reintentos detrás de un único punto final, de modo que el mismo código de BeautifulSoup o pandas sigue funcionando.

La mayoría de los proyectos necesitan los pasos del uno al tres. Para una lista más extensa de señales de detección, nuestra guía sobre por qué se bloquean los scrapers o se les prohíbe el acceso por IP profundiza en las huellas digitales TLS, el orden de los encabezados y los cálculos de límite de velocidad. Si te bloquean en un artículo de Wikipedia, algo más está mal.

Limpieza, conversión de tipos y exportación para producción

Las tablas extraídas casi nunca están listas para el análisis. Los símbolos de moneda, los signos de porcentaje, los marcadores de notas al pie y los espacios en blanco al final se cuelan como cadenas. Corrígelos de una sola vez antes de guardar:

import pandas as pd

df = pd.DataFrame(rows)

# 1. Strip whitespace on every text column
str_cols = df.select_dtypes(include="object").columns
df[str_cols] = df[str_cols].apply(lambda s: s.str.strip())

# 2. Coerce numeric columns (errors='coerce' turns junk into NaN)
df["salary"] = pd.to_numeric(df["salary"].str.replace(r"[^0-9.\-]", "", regex=True),
                             errors="coerce")
df["growth_pct"] = pd.to_numeric(df["growth_pct"].str.rstrip("%"), errors="coerce")

# 3. Coerce dates
df["hired_at"] = pd.to_datetime(df["hired_at"], errors="coerce")

# 4. Drop rows where the primary key failed to parse
df = df.dropna(subset=["employee_id"])

# 5. Persist
df.to_parquet("employees.parquet", index=False)
df.to_sql("employees", con=engine, if_exists="replace", index=False)

La errors="coerce" es el héroe subestimado de este proceso: las celdas erróneas se convierten en NaN en lugar de generar un error, y puedes investigarlas más tarde con df[df["salary"].isna()]. Para los procesos de producción, escribe en Parquet para el almacenamiento y utiliza to_sql para volcar los datos limpios en Postgres o en el almacén de datos que prefieras.

Mediras de seguridad legales y éticas

Esto es una guía para la reducción de riesgos, no un consejo legal. Consulta con un abogado antes de extraer cualquier dato sensible.

  • Lee el archivo robots.txt. Este expresa la preferencia del propietario del sitio, no una norma legal, pero ignorarlo es una forma rápida de que te bloqueen. La especificación está documentada en RFC 9309.
  • Lee los Términos de servicio. El scraping tras iniciar sesión, en particular, suele infringir los Términos de servicio incluso cuando el archivo robots.txt no dice nada al respecto.
  • Límitate a ti mismo. Una solicitud por segundo es un valor predeterminado razonable para proyectos pequeños. Añade fluctuación para no parecer un reloj.
  • Evita los datos personales a menos que tengas una base legal. El RGPD y leyes similares se aplican incluso cuando los datos son técnicamente públicos.
  • Atribuye la fuente al volver a publicar. Cita la URL de origen y la fecha de scraping.

Saber cómo extraer tablas HTML con Python es mitad técnico, mitad ético. La parte técnica se rompe una vez; la parte ética puede arruinar tu empresa.

Conclusiones clave

  • Elige la herramienta más sencilla que funcione. pandas.read_html Para tablas estáticas limpias, Requests + BeautifulSoup; para control, Playwright; para tablas renderizadas en JS o basadas en la interacción.
  • Encabezados, no índices. Combina el texto del encabezado con el de las celdas para que tu rastreador sobreviva a una columna añadida. El código cells[0], cells[1] es deuda técnica.
  • La paginación tiene tres niveles. Prueba per_page=500, luego una API JSON oculta y, por último, bucles de números de página. Un navegador sin interfaz gráfica es el último recurso.
  • Limpia antes de guardar. pd.to_numeric, pd.to_datetime, y errors="coerce" convierte las filas extraídas sin depurar en un DataFrame tipado listo para el análisis.
  • Respeta el sitio. Respeta el archivo robots.txt, limita las solicitudes y evita los datos personales a menos que tengas una base legal clara.

Preguntas frecuentes

¿Cuál es la diferencia entre pandas.read_html y BeautifulSoup para extraer tablas?

pandas.read_html es un atajo de alto nivel: devuelve DataFrames directamente, pero solo maneja tablas que ya están en la respuesta HTML. BeautifulSoup es un analizador HTML de bajo nivel que te da control total sobre qué celdas conservas, cómo las transformas y cómo navegar por el marcado no estándar. Usa read_html para datos listos para el análisis, y BeautifulSoup cuando las reglas que necesitas no se puedan expresar como «dame la tabla N».

¿Cómo extraigo una tabla HTML que solo aparece después de que se ejecute JavaScript?

Primero confirma que realmente se renderiza con JavaScript: mira el código fuente de la página (Ctrl+U), busca una palabra de la tabla y, si no aparece, la tabla se hidrata en el lado del cliente. La solución más rápida es encontrar el punto final JSON subyacente en la pestaña Red de DevTools y llamarlo directamente. Si eso no es viable, ejecuta un navegador sin interfaz gráfica como Playwright, espera a un selector de fila y luego pasa page.content() a tu analizador habitual.

¿Qué debo hacer cuando una tabla tiene celdas fusionadas (rowspan o colspan)?

Trata la tabla como una cuadrícula 2D que rellenas celda por celda, respetando colspan los rowspan , en lugar de como una lista de filas. Para cada <th> o <td>, repite su valor en las casillas que cubre su extensión y omite las casillas ya rellenadas por un rowspan anterior. Esto produce una matriz rectangular que puedes pasar a pd.DataFrame sin que se produzcan discrepancias en el recuento de columnas.

¿Cómo mantengo el tipo correcto de las columnas numéricas y de fecha después de extraer los datos de una tabla?

Elimina los caracteres no numéricos con una expresión regular (str.replace(r"[^0-9.\-]", "", regex=True)), y luego llama a pd.to_numeric(series, errors="coerce") para que los valores no analizables se conviertan en NaN en lugar de generar un error. Para las fechas, pd.to_datetime(series, errors="coerce", format="%Y-%m-%d") es el equivalente. Añadir el format hace que el análisis sea aproximadamente 10 veces más rápido en columnas grandes y evita falsos positivos de cadenas ambiguas.

¿Puedo ejecutar pandas.read_html en un archivo HTML local o en una cadena HTML sin formato?

Sí. pd.read_html Acepta una URL, una ruta a un archivo local o una cadena HTML sin formato. Pásale pd.read_html(open("page.html").read()) para proporcionarle una cadena, o pd.read_html("page.html") para una ruta de archivo. Esto resulta útil para pruebas unitarias (confirmar un fixture HTML que se sabe que funciona) y para separar la obtención de datos del análisis en rastreadores de producción.

Conclusión

Saber cómo extraer datos de tablas HTML con Python consiste principalmente en elegir la herramienta adecuada para la tabla. Empieza pandas.read_html primero, pasa a Requests + BeautifulSoup cuando necesites control a nivel de celda y solo utiliza Playwright cuando JavaScript renderice los datos. Añade bucles de filas que tengan en cuenta los encabezados, análisis genérico de colspan/rowspan, paginación inteligente y una pasada de limpieza con pandas, y tendrás un scraper que sobreviva a los cambios en el marcado en lugar de fallar en la siguiente implementación.

Cuando la rotación de proxies y el renderizado de JavaScript que haces tú mismo se te queden pequeños, WebScrapingAPI ofrece una API de scraper que gestiona la capa de solicitudes detrás de un único punto final, para que tu código de análisis siga funcionando. A partir de aquí, echa un vistazo a nuestras guías más detalladas sobre tablas JavaScript y sobre cómo evitar bloqueos.

Acerca del autor
Andrei Ogiolan, Desarrollador Full Stack @ WebScrapingAPI
Andrei OgiolanDesarrollador Full Stack

Andrei Ogiolan es desarrollador full stack en WebScrapingAPI, donde colabora en todas las áreas del producto y ayuda a crear herramientas y funciones fiables para la plataforma.

Empieza a crear

¿Estás listo para ampliar tu recopilación de datos?

Únete a más de 2000 empresas que utilizan WebScrapingAPI para extraer datos de la web a escala empresarial sin ningún gasto de infraestructura.