scrape dynamic web pages

Aprender a hacer scraping de páginas web dinámicas implica lidiar con contenido que no existe en el HTML que descarga tu primera petición. Los sitios modernos construyen las cuadrículas de productos, los precios, los comentarios y los feeds en el navegador usando JavaScript, así que una simple descarga HTTP devuelve un caparazón vacío donde deberían estar los datos.

Esta guía recorre el flujo de trabajo completo en Python. Empieza por detectar contenido dinámico comparando la respuesta en bruto con el DOM renderizado, y luego ordena las opciones de extracción de la más ligera a la más pesada: encontrar la API JSON oculta, controlar Playwright o Selenium, gestionar el scroll infinito y los botones de cargar más, reducir el ancho de banda del proxy, añadir reintentos, hacer scraping en paralelo y exportar JSON y CSV limpios.

DataImpulse es un proveedor de proxies ético que ofrece más de 90 millones de direcciones IP residenciales, móviles y de datacenter en 195 países. Utiliza un modelo de pago por uso desde 1 dólar por GB con tráfico que no caduca, y se usa para web scraping, verificación de anuncios, monitorización de precios, estudios de mercado y gestión de múltiples cuentas.

Datos clave

  • Scraping dinámico: el contenido construido por JavaScript no está en el HTML en bruto, así que o bien llamas a la API JSON subyacente que la página consulta, o bien renderizas la página en un navegador headless.
  • Mejor tipo de proxy: proxies residenciales rotativos, que usan IPs reales de consumidores que superan la detección.
  • Precio: desde 1 dólar por GB, pago por uso, con tráfico que no caduca y sin suscripción.
  • Cobertura: más de 90M de IPs de origen ético en 195 países.
  • Fiabilidad: 99,51% de tasa de éxito, con una valoración de 4,8 sobre 5 en G2.
  • Protocolos y segmentación: HTTP, HTTPS y SOCKS5, con segmentación por país incluida.
Prioridad de métodos para hacer scraping de páginas web dinámicas

¿Qué hace que una página web sea dinámica?

Una página es dinámica cuando el contenido relevante lo genera JavaScript en el navegador en lugar de entregarse en la respuesta HTML inicial. El servidor envía un esqueleto ligero y, después, el código del lado del cliente obtiene los datos y los inyecta en el DOM una vez abierta la página.

La comprobación manual más rápida es comparar dos vistas de la misma página. Haz clic derecho y elige Ver código fuente para ver el HTML en bruto que devolvió el servidor; luego abre las herramientas de desarrollo e inspecciona el panel Elements, que muestra el DOM en vivo después de ejecutarse los scripts. Si el precio o el listado que quieres aparece en el panel Elements pero no en Ver código fuente, el contenido es dinámico y una sola petición no lo capturará.

  • El contenido estático está presente en Ver código fuente y puede parsearse directamente desde una respuesta HTTP.
  • El contenido dinámico aparece solo en el DOM renderizado y se carga mediante peticiones en segundo plano.

Puedes automatizar este diagnóstico en código. Primero, cuenta los elementos objetivo en el HTML en bruto que descarga un cliente HTTP normal:

import requests
from bs4 import BeautifulSoup

url = "https://example.com/products"
headers = {"User-Agent": "Mozilla/5.0"}

resp = requests.get(url, headers=headers, timeout=30)
soup = BeautifulSoup(resp.text, "html.parser")

raw_cards = soup.select("div.product-card")
print("Cards in raw HTML:", len(raw_cards))

# If this prints 0 but the page clearly shows products in a browser,
# the content is injected by JavaScript and the page is dynamic.

Después, renderiza la misma URL en un navegador headless y cuenta de nuevo. La diferencia entre los dos números es la prueba:

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/products", wait_until="networkidle")
    rendered_cards = page.query_selector_all("div.product-card")
    print("Cards after JavaScript runs:", len(rendered_cards))
    browser.close()

# Raw HTML 0, rendered DOM 48 means the page builds its content dynamically.

Antes de escribir un scraper completo, también conviene confirmar que el objetivo permite el acceso automatizado. Nuestra guía sobre cómo comprobar si un sitio web permite scraping explica cómo leer las reglas de robots y los términos de servicio.

¿Qué método deberías usar para hacer scraping de páginas dinámicas?

Elige el método más ligero que devuelva los datos de forma fiable: llama a una API JSON oculta cuando exista y recurre a un navegador headless solo cuando no la haya. Cada opción compensa velocidad y coste frente a cuánto de la página es capaz de reproducir.

La tabla siguiente compara los cuatro enfoques habituales para que puedas elegir antes de escribir código. En la práctica, la mayoría de los proyectos combinan dos de ellos, usando una API oculta para la carga masiva y un navegador para las páginas que se resisten.

Método Velocidad Coste Mejor para Limitación principal
API JSON oculta La más rápida El más bajo Datos tras una llamada XHR limpia El endpoint puede cambiar o requerir tokens
Playwright Moderada Más alto Renderizado completo, control asíncrono moderno Consume CPU, memoria y ancho de banda
Selenium Moderada Más alto Stacks heredados, amplio soporte de lenguajes Configuración más lenta, esperas más verbosas
requests-html Lenta Bajo JavaScript ligero en páginas sencillas Prácticamente sin mantenimiento, renderizado frágil

El coste importa porque un navegador headless enrutado a través de un proxy medido descarga muchos más bytes que una sola llamada a la API. Si haces scraping a gran escala, el método que elijas cambia la factura tanto como el código. Para la disciplina más amplia del ritmo y las cabeceras, consulta nuestras mejores prácticas de web scraping.

¿Cómo encuentras la API oculta detrás de una página dinámica?

Busca la petición en segundo plano que hace la página para obtener sus datos, porque ese endpoint suele devolver JSON limpio que puedes llamar directamente. Es el enfoque más rápido y fiable, y evita por completo ejecutar un navegador.

Abre las herramientas de desarrollo, ve a la pestaña Network, filtra por Fetch/XHR y recarga la página. A medida que aparece el contenido, fíjate en las peticiones que devuelven JSON con los valores que quieres. Inspecciona la URL de la petición, el método, los parámetros de consulta y cualquier cabecera o token que envíe; luego haz clic derecho y cópiala como cURL para ver su forma completa. Una vez que puedas reproducir esa llamada con un cliente HTTP, te saltas por completo el renderizado y obtienes datos estructurados por una fracción del coste.

Aquí tienes un ejemplo mínimo que llama a un endpoint JSON a través de un proxy y lee los campos directamente:

import requests

proxy = "http://USERNAME:[email protected]:823"
proxies = {"http": proxy, "https": proxy}

headers = {
    "User-Agent": "Mozilla/5.0",
    "Accept": "application/json",
    "Referer": "https://example.com/products",
}
params = {"category": "laptops", "page": 1}

r = requests.get(
    "https://example.com/api/v2/products",
    headers=headers,
    params=params,
    proxies=proxies,
    timeout=30,
)
r.raise_for_status()
data = r.json()

for item in data["items"]:
    print(item["name"], item["price"])

Si el endpoint está paginado, incrementa el parámetro de página o sigue el cursor siguiente que devuelve la respuesta. Cuando una petición necesita un token, captura dónde lo emite la página por primera vez y reprodúcelo dentro de la misma sesión. Enrutar la llamada a través de DataImpulse hace que el endpoint vea una IP residencial en lugar de la dirección de tu servidor, lo cual importa cuando la API limita la tasa por origen. Si te topas con un muro de autenticación en el propio proxy, nuestra nota sobre autenticación de proxy explica el formato de las credenciales.

¿Cómo haces scraping de páginas dinámicas con Playwright?

Cuando no hay una API limpia a la que llamar, controla un navegador headless como Playwright para que el JavaScript se ejecute exactamente como lo haría para un usuario, y luego lee el DOM ya terminado. Playwright es la opción moderna por defecto porque incorpora una espera automática fiable, soporte asíncrono de primera clase y una opción de proxy sencilla.

Primero instálalo y descarga un binario del navegador:

pip install playwright
playwright install chromium

La disciplina fundamental es esperar el elemento concreto que necesitas en lugar de adivinar con esperas fijas. El ejemplo siguiente inicia Chromium, lo enruta a través de un proxy usando la forma de diccionario, espera la cuadrícula de productos y extrae los valores renderizados:

from playwright.sync_api import sync_playwright

PROXY = {
    "server": "http://gw.dataimpulse.com:823",
    "username": "USERNAME",
    "password": "PASSWORD",
}

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True, proxy=PROXY)
    context = browser.new_context(user_agent="Mozilla/5.0")
    page = context.new_page()
    page.goto("https://example.com/products", wait_until="domcontentloaded")

    page.wait_for_selector("div.product-card", timeout=15000)

    cards = page.query_selector_all("div.product-card")
    for card in cards:
        name = card.query_selector(".title").inner_text()
        price = card.query_selector(".price").inner_text()
        print(name, price)

    browser.close()

La espera es donde la mayoría de los scrapers dinámicos triunfan o fracasan, así que conviene conocer las cuatro estrategias y cuándo encaja cada una. Prefiere esperar por un selector o una condición concreta antes que por networkidle, que puede quedarse colgado en páginas que mantienen conexiones abiertas de larga duración:

# Wait for a specific element (most reliable)
page.wait_for_selector("div.product-card", timeout=15000)

# Wait for the initial DOM to be built, before every script finishes
page.goto(url, wait_until="domcontentloaded")

# Wait until there are no network connections for 500 ms
page.goto(url, wait_until="networkidle")

# Wait for a custom condition, such as a minimum item count
page.wait_for_function(
    "document.querySelectorAll('div.product-card').length > 20"
)

Si tu objetivo depende en gran medida de scripts que se ejecutan en el navegador, nuestro artículo complementario sobre web scraping con JavaScript profundiza en el renderizado del lado del cliente que hace difíciles estas páginas.

¿Cómo gestionas el scroll infinito y los botones de cargar más?

Dispara los mismos eventos que la página usa para cargar más contenido y luego espera a que cada nuevo lote se renderice antes de leerlo. El scroll infinito y los botones de cargar más solo obtienen datos a medida que el usuario actúa, así que una sola carga de página captura únicamente la primera pantalla.

Antes de recurrir al navegador, revisa de nuevo la pestaña Network, porque el scroll infinito casi siempre se alimenta de una llamada XHR paginada, y recorrer ese endpoint con el enfoque de requests anterior es mucho más barato que hacer scroll. Si tienes que hacer scroll, hazlo en un bucle controlado que se detenga cuando no aparezcan elementos nuevos:

def scroll_until_stable(page, item_selector, max_rounds=30):
    seen = 0
    for _ in range(max_rounds):
        page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
        page.wait_for_timeout(1500)
        count = len(page.query_selector_all(item_selector))
        if count == seen:
            break            # no new items loaded, stop
        seen = count
    return seen

Las páginas que usan un botón explícito en lugar de scroll necesitan un bucle de clics. Lee el recuento de elementos antes de cada clic, haz clic en el botón y espera a que el recuento crezca para no adelantarte nunca al renderizado:

def click_load_more(page, button_selector, item_selector, max_clicks=50):
    for _ in range(max_clicks):
        button = page.query_selector(button_selector)
        if button is None or not button.is_visible():
            break
        before = len(page.query_selector_all(item_selector))
        button.click()
        page.wait_for_function(
            "([sel, n]) => document.querySelectorAll(sel).length > n",
            arg=[item_selector, before],
        )
    return len(page.query_selector_all(item_selector))

Elimina duplicados a medida que recopilas, ya que releer los mismos nodos en cada pasada es habitual. Mantén un conjunto de IDs o URLs únicos y añade solo los registros que no hayas visto.

¿Puedes usar Selenium en lugar de Playwright?

Sí, Selenium controla un navegador real de la misma manera y es una opción sólida cuando tu equipo ya lo usa o necesita su amplio soporte de lenguajes. El patrón refleja el de Playwright: carga la página, espera una condición con WebDriverWait y luego lee los elementos del DOM renderizado.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

options = Options()
options.add_argument("--headless=new")
options.add_argument("--proxy-server=http://gw.dataimpulse.com:823")

driver = webdriver.Chrome(options=options)
driver.get("https://example.com/products")

WebDriverWait(driver, 15).until(
    EC.presence_of_element_located((By.CSS_SELECTOR, "div.product-card"))
)

for card in driver.find_elements(By.CSS_SELECTOR, "div.product-card"):
    name = card.find_element(By.CSS_SELECTOR, ".title").text
    price = card.find_element(By.CSS_SELECTOR, ".price").text
    print(name, price)

driver.quit()

Una advertencia: el simple flag –proxy-server no acepta usuario y contraseña, así que un proxy autenticado necesita una ayuda extra. La biblioteca Selenium Wire inyecta las credenciales de forma limpia, y nuestro tutorial sobre la configuración del proxy con Selenium Wire muestra la configuración exacta. Selenium es más pesado de configurar que Playwright y sus esperas son más verbosas, así que los proyectos nuevos suelen empezar con Playwright salvo que haya un motivo para quedarse en Selenium.

¿Cómo reduces el ancho de banda del proxy al renderizar páginas?

Bloquea los recursos que no necesitas para que el navegador deje de descargar imágenes, fuentes y medios que nunca parseas. Un navegador headless obtiene todos los recursos por defecto, y cada uno de esos bytes cuesta dinero cuando se enruta a través de un proxy con precio por gigabyte.

Playwright te permite interceptar peticiones y abortar las que son irrelevantes para tu extracción. Bloquear imágenes, medios y fuentes puede reducir drásticamente el tráfico del proxy sin tocar el JSON y el HTML que realmente lees:

BLOCK = {"image", "media", "font"}

def block_heavy(route):
    if route.request.resource_type in BLOCK:
        route.abort()
    else:
        route.continue_()

context = browser.new_context()
context.route("**/*", block_heavy)
page = context.new_page()
page.goto("https://example.com/products", wait_until="domcontentloaded")

Puedes ampliar el mismo manejador para saltar los dominios de analítica y publicidad que añaden carga sin añadir datos. Con el tráfico de DataImpulse facturado por uso desde 1 dólar por GB y sin caducidad, recortar bytes desperdiciados reduce directamente lo que cuesta cada scrape. Reutilizar un mismo contexto de navegador entre páginas relacionadas también evita el coste repetido de arranque, y la petición más barata sigue siendo la que evitas recurriendo a una API oculta.

¿Cómo añades manejo de errores y reintentos?

Envuelve cada navegación en un bucle de reintentos con retroceso exponencial para que una única carga lenta o un bloqueo transitorio no arruine toda la ejecución. Las páginas dinámicas fallan de forma intermitente, y un scraper que se rinde al primer timeout perderá una gran parte de los registros a gran escala.

Captura el timeout que lanza Playwright, espera un intervalo creciente y reintenta un número limitado de veces antes de registrar el fallo y continuar:

import time
from playwright.sync_api import TimeoutError as PlaywrightTimeout

def fetch_with_retries(page, url, selector, retries=3, backoff=2):
    for attempt in range(1, retries + 1):
        try:
            page.goto(url, wait_until="domcontentloaded", timeout=30000)
            page.wait_for_selector(selector, timeout=15000)
            return page.query_selector_all(selector)
        except PlaywrightTimeout:
            wait = backoff ** attempt
            print(f"Attempt {attempt} timed out, retrying in {wait}s")
            time.sleep(wait)
    raise RuntimeError(f"Failed to load {url} after {retries} attempts")

Combina los reintentos con la rotación de proxies para que cada intento salga a través de una IP nueva. Si una salida está limitada por tasa, la siguiente petición aterriza en una dirección distinta y a menudo tiene éxito. Un pool rotativo de proxies residenciales o proxies móviles asigna automáticamente una IP nueva por petición, lo que convierte muchos bloqueos duros en un simple reintento. Para gestionar específicamente los fallos a nivel de proxy, nuestra nota sobre el error HTTP 407 cubre la respuesta de autenticación más común.

¿Cómo haces scraping de muchas páginas dinámicas de forma concurrente?

Usa la API asíncrona de Playwright con un semáforo de asyncio para que varias páginas se rendericen a la vez sin saturar tu máquina ni el objetivo. La concurrencia es donde el scraping dinámico recupera su rendimiento, ya que cada página del navegador pasa la mayor parte del tiempo esperando a la red.

El semáforo limita cuántas páginas se ejecutan en paralelo. Comparte un mismo navegador y contexto entre las tareas, abre una página por URL y recopila los resultados:

import asyncio
from playwright.async_api import async_playwright

PROXY = {
    "server": "http://gw.dataimpulse.com:823",
    "username": "USERNAME",
    "password": "PASSWORD",
}

async def scrape_one(context, url, sem):
    async with sem:
        page = await context.new_page()
        try:
            await page.goto(url, wait_until="domcontentloaded")
            await page.wait_for_selector("div.product-card", timeout=15000)
            cards = await page.query_selector_all("div.product-card")
            return [await c.inner_text() for c in cards]
        finally:
            await page.close()

async def main(urls):
    sem = asyncio.Semaphore(5)   # at most 5 pages at once
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True, proxy=PROXY)
        context = await browser.new_context()
        tasks = [scrape_one(context, u, sem) for u in urls]
        results = await asyncio.gather(*tasks)
        await browser.close()
    return results

urls = ["https://example.com/products?page=%d" % i for i in range(1, 21)]
data = asyncio.run(main(urls))

Mantén moderado el límite de concurrencia. Demasiadas páginas en paralelo agotan la memoria y disparan los límites de tasa, lo que te cuesta más reintentos de los que ahorra el paralelismo. Empieza en torno a cinco y súbelo solo mientras las tasas de éxito se mantengan altas.

¿Cómo exportas los datos extraídos a JSON y CSV?

Reúne los resultados de cada página en una lista de diccionarios y luego escribe esa lista en JSON para datos anidados o en CSV para tablas planas y aptas para hojas de cálculo. Mantener separadas la extracción y la exportación facilita probar y volver a ejecutar el scraper.

import json
import csv

records = [
    {"name": "Laptop A", "price": "999"},
    {"name": "Laptop B", "price": "1299"},
]

# Export to JSON
with open("products.json", "w", encoding="utf-8") as f:
    json.dump(records, f, ensure_ascii=False, indent=2)

# Export to CSV
with open("products.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "price"])
    writer.writeheader()
    writer.writerows(records)

Elige JSON cuando los registros contengan listas u objetos anidados, y CSV cuando cada registro comparta los mismos campos planos y los analistas vayan a abrirlo en una hoja de cálculo. Normaliza tus campos antes de escribir para que cada registro tenga las mismas claves, y el paso de exportación seguirá siendo trivial sin importar cómo se renderizó la página dinámica. A partir de aquí, el mismo JSON alimenta una carga en base de datos o un trabajo de analítica sin más parseo.

Estrategias de espera para contenido que carga tarde

Preguntas frecuentes

¿Cómo puedo saber si una página es dinámica antes de escribir un scraper?

Compara Ver código fuente con el panel Elements en las herramientas de desarrollo, o cuenta un elemento objetivo en el HTML en bruto frente al DOM renderizado. Si los datos aparecen solo después de ejecutarse JavaScript, la página es dinámica y necesita una llamada a la API o un navegador headless.

¿Es mejor llamar a la API oculta o usar un navegador headless?

Llama a la API oculta cuando puedas encontrarla, porque devuelve JSON limpio a una fracción del coste y con más velocidad que el renderizado. Usa un navegador headless solo cuando no exista una API reproducible o los datos dependan de scripts complejos del lado del cliente.

¿Debería usar Playwright o Selenium para el scraping dinámico?

Playwright es la opción moderna por defecto gracias a su espera automática fiable y su soporte asíncrono integrado, así que los proyectos nuevos suelen empezar ahí. Selenium encaja bien cuando tu stack ya lo usa o necesitas su mayor soporte de lenguajes.

¿Cómo reduzco el ancho de banda del proxy al usar un navegador headless?

Intercepta las peticiones y aborta imágenes, fuentes, medios y analítica que no parseas. Esto puede reducir el tráfico de forma significativa, ya que el coste del proxy se mide por gigabyte, y además acelera cada carga de página.

¿Cómo hago scraping de contenido que carga con scroll infinito?

Primero revisa la pestaña Network en busca de la llamada XHR paginada que hay detrás del scroll y recorre ese endpoint directamente. Si tienes que hacer scroll, hazlo en un bucle controlado y espera a que cada nuevo lote se renderice antes de leerlo.

¿Cuándo no es DataImpulse la opción adecuada?

Si necesitas proxies ISP estáticos, una API de scraping totalmente gestionada o acceso a sitios bancarios y gubernamentales, DataImpulse no es la herramienta adecuada. Se centra en proxies residenciales, móviles y de datacenter rotativos para recopilar datos públicos y acceder a contenido.

Haz scraping de páginas dinámicas con proxies fiables

Si tu scraper necesita IPs residenciales, móviles o de datacenter que se comporten como visitantes reales, puedes empezar con tráfico de pago por uso desde 1 dólar por GB. Crea una cuenta de DataImpulse y enruta tu scraping dinámico a través de sesiones rotativas o sticky.


Share article: