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

Web Scraping Tablas JavaScript en Python: De las API ocultas a Playwright

Web Scraping Tablas JavaScript en Python: De las API ocultas a Playwright
En resumen: Para extraer datos de tablas JavaScript en Python rara vez se necesita un navegador sin interfaz gráfica. Abre DevTools, busca el punto final JSON que alimenta la tabla, reproduce el proceso con requests, paginarlo y recurrir a Playwright solo cuando la llamada de red esté firmada, encriptada o sellada de alguna otra forma.

Has escrito el código obvio. requests.get(url), pasas el HTML a BeautifulSoup, extraes las filas del <table>. El script se ejecuta, el archivo se guarda en el disco y el CSV está vacío. Bienvenido al scraping de tablas JavaScript, donde las filas que ves en tu navegador no existen en el documento que el servidor devolvió realmente.

Las tablas estáticas envían los datos dentro del HTML inicial. Las tablas dinámicas (también llamadas tablas AJAX o renderizadas con JavaScript) envían un contenedor casi vacío; luego, un script en la página llama a un punto final JSON e inyecta filas en el DOM tras la carga. Si no ejecutas ese script, no ves esas filas. Iniciar un navegador completo para solucionar esto es una solución excesiva para lo que suele ser un problema menor.

Esta guía toma el camino más corto. Comenzaremos con una escalera de decisiones para que dejes de preguntarte si debes recurrir a requests o un motor de navegador, y luego te guiaremos para encontrar el punto final JSON subyacente en DevTools, reproducirlo en Python con paginación y autenticación, analizarlo en filas limpias y exportarlo a CSV, JSON Lines o SQLite. Playwright está aquí como un recurso real para sitios que ocultan la llamada de red, no como la herramienta predeterminada. Al final tendrás un script que podrás volver a ejecutar el próximo trimestre sin tener que reescribirlo desde cero.

Por qué las tablas de JavaScript rompen los rastreadores estándar

Cuando llamas a requests.get() en una página con una tabla de JavaScript, lo que se devuelve es el documento que el servidor envió antes de que se ejecutara cualquier código del navegador. Ese documento contiene el diseño, la navegación, el contenedor de cuadrícula vacío y un paquete de JavaScript. Las filas aún no están ahí. El navegador ejecuta el script, el script recupera una carga útil JSON y solo entonces se rellena la tabla.

BeautifulSoup analiza fielmente lo que se le ha proporcionado, que es un <table> sin <tr> elementos secundarios. Tu selector no encuentra nada, tu bucle se ejecuta cero veces y el generador produce un CSV con encabezados y sin datos. El scraping de tablas JavaScript falla aquí, de forma silenciosa, porque técnicamente todas las capas funcionaron.

Elige una ruta de extracción antes de escribir código

Antes de abrir un editor, sigue una escalera de decisiones de un minuto. La clasificación importa porque cada paso cuesta más de mantener que el anterior.

  1. API oficial o exportación CSV. Muchos paneles de control muestran un botón de descarga o un punto final documentado. Úsalo. No vas a extraer lo que simplemente puedes solicitar con una clave.
  2. XHR oculto o Fetch JSON. La mayoría de las cuadrículas modernas se alimentan mediante una llamada JSON que puedes ver en DevTools. Esta debería ser tu opción predeterminada para el web scraping de tablas JavaScript. La carga útil está estructurada, el esquema es estable y te saltas toda la capa de renderizado.
  3. Estático <table> ya en la fuente. Si las filas están presentes en view-source: (sin necesidad de script), analiza el HTML con pandas.read_html() para obtener un resultado rápido o requests más BeautifulSoup con lxml para producción.
  4. Renderización con navegador sin interfaz gráfica. Recurre a Playwright solo cuando la ruta de red esté firmada, sea GraphQL con comprobaciones estrictas de origen, se alimente de WebSocket o sea inaccesible desde un cliente HTTP simple.

La mayoría de los artículos enseñan primero la ruta 4. Eso es un error. Un punto final JSON oculto, cuando existe, te proporciona datos más limpios y una superficie de fallo menor que cualquier navegador sin interfaz gráfica jamás lo hará.

Localiza el punto final JSON oculto con DevTools

La forma más rápida de confirmar que una tabla está alimentada por JavaScript es comprobar el código fuente sin procesar de la página, no el DOM renderizado. Haz clic con el botón derecho en la página, selecciona «Ver código fuente» y busca un valor de muestra visible en la tabla (un nombre, un salario, un ID único). Si la búsqueda no arroja ningún resultado, la fila se inyectó después de la carga y estás viendo una cuadrícula renderizada por JavaScript.

Ahora busca la solicitud que entregó los datos. El ejemplo de referencia utilizado a lo largo de esta guía es la demostración pública de DataTables AJAX en datatables.net/examples/data_sources/ajax.html. Abre DevTools, cambia a la pestaña Red y filtra por Fetch/XHR. Recarga la página para capturar todo el tráfico y, a continuación, activa un cambio de ordenación o paginación. Esa segunda acción es la clave: la carga útil más grande tras un cambio de ordenación es casi siempre la que transporta las filas.

Haz clic en la llamada, abre Respuesta y confirma que el formato JSON es el esperado. Comprueba los encabezados para ver el método de solicitud, los parámetros de consulta, las cookies y cualquier token personalizado (X-CSRF-Token, Authorization). Para objetivos complicados, haz clic con el botón derecho en la solicitud y selecciona «Copiar como cURL». Así se conservan los encabezados, las cookies y el cuerpo exacto, de modo que puedes pegarlo en un conversor e iniciar tu código Python sin tener que escribir nada a mano. Filtra de forma agresiva: un solo cuadro de búsqueda puede disparar diez solicitudes de autocompletado antes de la verdadera.

Reproduce la solicitud capturada en Python

Una vez que tengas la URL y los encabezados, la parte de Python es sencilla. Empieza con lo mínimo imprescindible y añade encabezados solo cuando el servidor se queje.

import requests

URL = "https://datatables.net/examples/ajax/data/objects.txt"

headers = {
    "User-Agent": "Mozilla/5.0 (compatible; tables-scraper/1.0)",
    "Accept": "application/json, text/javascript, */*; q=0.01",
}

response = requests.get(URL, headers=headers, timeout=15)
response.raise_for_status()
payload = response.json()

Hay dos cosas que destacar. En primer lugar, raise_for_status() es innegociable porque los sistemas antibots suelen devolver HTML con HTTP 200, y una comprobación de estado omitida convierte un bloqueo suave en datos corruptos. Segundo, resiste la tentación de pegar tu cookie de sesión personal desde DevTools. Esa cookie caduca, filtra contexto personal a tu repositorio y vincula el script a una sola persona. Prefiere los encabezados públicos y, a continuación, añade un flujo de inicio de sesión real con un requests.Session si el punto final realmente necesita autenticación.

Para flujos de trabajo en los que se necesita una distribución asíncrona entre muchos puntos finales, HTTPX es una alternativa lista para usar con una API síncrona casi idéntica y soporte asíncrono de primera clase. Considéralo una opción más que una recomendación estricta; requests sigue siendo un valor predeterminado perfectamente válido en 2026.

Analiza la carga útil JSON en filas limpias

El ejemplo de DataTables devuelve un diccionario de nivel superior con una data clave que contiene una lista de listas. Las API reales varían: algunas devuelven una lista de objetos, otras envuelven las filas en results o items, otras las ocultan a dos niveles de profundidad bajo payload.table.rows. Inspecciona la estructura una vez y, a continuación, escribe código defensivo.

rows = payload.get("data", [])
records = []
for r in rows:
    records.append({
        "name":       r[0],
        "position":   r[1],
        "office":     r[2],
        "extn":       r[3],
        "start_date": r[4],
        "salary":     r[5],
    })

Si el punto final devuelve una lista de objetos en lugar de matrices posicionales, cambia los índices por r.get("name"), r.get("position"), y así sucesivamente. Usa .get() en lugar de r["name"] te ahorra KeyError el día en que el backend añada o renombre un campo. Haz esta correspondencia una sola vez, en un solo lugar, para que el resto del proceso se comunique con un esquema interno estable en lugar de con lo que la API de origen haya decidido enviar esta semana.

Gestiona la paginación, los parámetros de consulta y la autenticación

Los puntos finales reales rara vez te entregan todas las filas en una sola llamada. El protocolo del lado del servidor de DataTables utiliza draw, start, length, order[0][column], y search[value]; la lista canónica de parámetros se encuentra en el manual de procesamiento del lado del servidor de DataTables. Otros backends utilizan paginación por cursor (?cursor=eyJ...), paginación por desplazamiento (?page=3&per_page=100) o un next_url campo incrustado en la respuesta.

import time

session = requests.Session()
session.headers.update(headers)

start, length, rows = 0, 100, []
while True:
    r = session.get(URL, params={"draw": 1, "start": start, "length": length}, timeout=15)
    if r.status_code == 429:
        time.sleep(2 ** (start // length))  # crude exponential backoff
        continue
    r.raise_for_status()
    page = r.json().get("data", [])
    if not page:
        break
    rows.extend(page)
    start += length

Si el punto final está protegido por un inicio de sesión, inicia sesión primero con session.post() y deja que el almacén de cookies gestione la sesión. Para los POST protegidos contra CSRF, extrae el token de un campo oculto o de una XSRF-TOKEN cookie y reenvíalo como un encabezado. Nunca pegues una cadena de cookie estática. Caduca de la noche a la mañana y rompe cada ejecución de cron a partir de entonces.

Exporta las filas a CSV, JSON Lines o SQLite

Elige el formato de salida que realmente utilicen tus herramientas posteriores. CSV está bien para hojas de cálculo, JSON Lines es más adecuado para la ingesta en streaming y los pipelines LLM o RAG, y SQLite es la opción más ligera y fácil de usar para analistas que sobrevive a un reinicio del sistema.

import csv, json, sqlite3

# CSV with named headers (clearer than raw csv.writer)
with open("rows.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=records[0].keys())
    writer.writeheader()
    writer.writerows(records)

# JSON Lines
with open("rows.jsonl", "w", encoding="utf-8") as f:
    for r in records:
        f.write(json.dumps(r, ensure_ascii=False) + "\n")

# SQLite
con = sqlite3.connect("rows.db")
con.execute("CREATE TABLE IF NOT EXISTS staff (name TEXT, position TEXT, office TEXT, extn TEXT, start_date TEXT, salary TEXT)")
con.executemany("INSERT INTO staff VALUES (:name, :position, :office, :extn, :start_date, :salary)", records)
con.commit(); con.close()

csv.DictWriter merece la pena esas pocas líneas extra porque la fila de encabezado se mantiene sincronizada con las claves del diccionario; nadie tiene que recordar qué columna era el índice 3. La misma records lista alimenta a los tres escritores, por lo que cambiar de formato es un cambio de una sola línea en producción.

Solución alternativa: renderiza la tabla con Playwright cuando la red esté bloqueada

Algunos sitios realmente no te dejan acercarte al JSON. Las URL firmadas que caducan en segundos, los puntos finales de GraphQL con Origin , cuadrículas alimentadas por WebSocket y un puñado de configuraciones a medida te empujan a renderizar la página en un navegador real. Playwright para Python es una opción predeterminada sólida y moderna para esa tarea, aunque Selenium sigue siendo una opción razonable en pilas heredadas.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com/grid", wait_until="networkidle")
    page.wait_for_selector("table.grid tbody tr")
    rows = page.locator("table.grid tbody tr").all_text_contents()
    browser.close()

Una trampa a tener en cuenta en cualquier alternativa de web scraping de tablas JavaScript: las bibliotecas de cuadrículas del lado del cliente, como DataTables, AG Grid y TanStack Table, suelen virtualizar la representación, lo que significa que solo las filas visibles actualmente en la ventana gráfica se montan en el DOM en un momento dado. El recuento exacto de filas depende del tamaño de la ventana gráfica y de la configuración de la biblioteca, así que no confíes en una tr para capturar todo. Desplaza el contenedor en un bucle, escucha nuevas filas con un MutationObserver, o llama a la API de paginación de la propia biblioteca hasta que el total de filas deje de crecer.

Errores comunes al extraer datos de tablas JavaScript

La mayoría de los fallos en el scraping de tablas JavaScript son silenciosos. El script se ejecuta, el archivo se escribe y nadie se da cuenta de que los datos son incorrectos hasta que lo hace un panel de control. Presta atención a lo siguiente:

  • Seleccionar tablas por índice. tables[2] se rompe en el momento en que marketing añade un widget de comparación encima de la cuadrícula. En su lugar, haz la coincidencia por el texto del título, el ID o un encabezado único.
  • Cuadrículas virtualizadas. Un scraping ingenuo en DataTables, AG Grid o TanStack Table solo puede capturar las filas visibles en la ventana gráfica, mientras que miles quedan sin montar. Confirma los totales de filas con un recuento de la API o una solicitud paginada.
  • Números con formato regional. 1.000,50 es europeo para 1000.50, pero Python float() lo lee como 1.0. Normalice la cadena antes de convertirla.
  • Zonas horarias en las fechas. "2025-04-01" Si se analiza sin zona, se convierte silenciosamente en medianoche UTC, desplazando los totales diarios una fila.
  • Símbolos de moneda y separadores de miles. "$1,234" No se convertirá a un tipo float. Elimina primero los caracteres no numéricos.
  • Cookies caducadas. Una cookie de sesión pegada funciona durante un día, luego devuelve silenciosamente 401 que algunos servidores disfrazan como HTML HTTP 200.
  • Códigos 200 anti-bot. Un WAF puede devolver una página de desafío captcha con estado 200. r.json() Se produce un error, pero solo si te acuerdas de llamarlo.

Valida y supervisa el proceso de extracción

Un rastreo no termina al «crearse el CSV». Termina cuando confías en el archivo al día siguiente. Añade una pequeña capa de validación después del escritor: comprueba que el recuento de filas se encuentre dentro de un rango razonable respecto a la ejecución de ayer, da un error evidente si alguna columna requerida tiene una tasa de valores nulos superior a un umbral (entre el 1 y el 5 % suele funcionar), y compara el conjunto de columnas con un manifiesto guardado para que un campo renombrado señale una desviación del esquema en lugar de contaminar una unión posterior. Alerta por separado sobre ejecuciones con cero filas. La mayoría de los procesos de scraping de tablas JavaScript mueren por una reducción silenciosa, no por fallos evidentes.

Puntos clave

  • La ruta predeterminada para el web scraping de tablas JavaScript es el punto final JSON oculto, no un navegador sin interfaz gráfica. Utiliza la escalera de decisiones antes de escribir cualquier código.
  • La pestaña Red de DevTools, junto con una acción de ordenación o paginación activada, es la forma más rápida de identificar la llamada que realmente transporta las filas.
  • Reproduzca la solicitud sin estado: encabezados públicos, raise_for_status(), una sesión real para los inicios de sesión y nunca una cookie personal pegada a mano.
  • Los patrones de paginación varían (DataTables draw/start/length, cursores, desplazamientos); trata el bucle, no la solicitud individual, como la unidad de trabajo.
  • Playwright es la herramienta adecuada cuando la ruta de red está firmada, cifrada o ausente, y solo en esos casos. Presta atención a las cuadrículas virtualizadas que solo montan filas de la ventana de visualización.
  • Un proceso que puedas volver a ejecutar el próximo trimestre tiene verificaciones del recuento de filas, umbrales de tasa de valores nulos y un manifiesto de columnas, no solo un CSV que funcione hoy.

Preguntas frecuentes

¿Por qué requests.get() devuelve filas vacías para una tabla de JavaScript?

Porque requests no ejecuta JavaScript. Descarga el documento que el servidor sirvió en primer lugar, que contiene la estructura de la página y un paquete de scripts, pero no las filas. Las filas se añaden más tarde mediante código del lado del cliente que llama a un punto final JSON. Tu analizador ve la <table> y no devuelve nada.

¿De verdad necesito Selenium o Playwright para extraer datos de una tabla dinámica?

Normalmente no. Si DevTools muestra una solicitud JSON que hidrata la cuadrícula, reproducir esa solicitud con requests o httpx es más rápido, más barato y más fiable que un navegador. Recurre a Playwright solo cuando la llamada esté firmada, sea GraphQL con comprobaciones estrictas de origen, esté basada en WebSocket o sea inaccesible desde un cliente HTTP simple.

¿Cómo extraigo datos de una tabla JavaScript que requiere inicio de sesión o un token CSRF?

Utiliza un requests.Session para que las cookies persistan entre llamadas. Envía tus credenciales al punto final de inicio de sesión, luego lee el valor CSRF de un campo oculto o de la XSRF-TOKEN cookie y reenvíalo como encabezado en la solicitud de datos. Nunca codifiques de forma fija una cookie de sesión copiada de tu propio navegador.

¿Qué pasa si la API oculta solo devuelve una página de filas a la vez?

Haz un bucle. Inspecciona los parámetros de la solicitud (start, length, cursor, page, offset) e incrémentalos hasta que la respuesta devuelva cero filas o una has_more: false indicador. Añade un retroceso exponencial ante el error HTTP 429 y un límite estricto de solicitudes para que un error del servidor no pueda convertir tu rastreador en un bucle infinito.

Conclusión

El scraping de tablas JavaScript deja de dar miedo en el momento en que dejas de tratar la página renderizada como la fuente de verdad. El navegador es un renderizador; el punto final JSON detrás de la cuadrícula es la fuente de datos real. Encuentra ese punto final en DevTools, reprodúcelo con requests, pagínalo correctamente, valida la salida y tendrás un script que sobrevivirá al próximo rediseño, en lugar de uno que llene silenciosamente tu almacén de filas vacías.

Reserva el navegador sin interfaz gráfica para los casos que realmente lo necesiten. Los sitios con llamadas de red firmadas, cuadrículas alimentadas por WebSocket o protección agresiva contra bots te obligarán a recurrir a él, y ahí es precisamente donde importa contar con una ruta alternativa. Cuando recurras a un navegador, sé prudente con la renderización virtualizada, valida los totales de las filas y mantén tu capa de monitorización en su sitio.

Si prefieres no mantener tú mismo la rotación de proxies, las huellas digitales de los navegadores y los gestores de CAPTCHA, WebScrapingAPI puede situarse delante de tu requests y devolver HTML o JSON limpio de sitios que, de otro modo, bloquearían el acceso directo, dejando sin cambios la lógica de análisis y paginación anterior. Sea cual sea la ruta que elijas, la estrategia es la misma: elige la ruta de extracción más barata que funcione y haz que el script sea lo suficientemente honesto como para avisarte cuando deje de funcionar.

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.