Tecnologías y protecciones 16 min de lectura

Scraping de sitios dinámicos: dos enfoques

Dos formas de hacer scraping de sitios dinámicos: ingeniería inversa de las peticiones XHR a la API y navegadores headless — ventajas, inconvenientes y criterios de elección.

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

Los sitios modernos rara vez entregan el HTML ya listo con los datos. El catálogo de productos, el feed, los precios, las reseñas — casi todo suele cargarse después de que la página ya se ha abierto, mediante JavaScript. Para un scraper esto significa que una simple petición GET de la página y el posterior parseo del HTML devolverán un esqueleto vacío, sin datos.

Hay dos formas radicalmente distintas de resolver este problema:

  1. Interceptar las peticiones a la API — localizar las peticiones con las que el propio navegador obtiene los datos y repetirlas directamente, sin navegador.
  2. Emulación completa del navegador — arrancar un navegador real (o headless), dejar que renderice el JS y operar sobre la página como un usuario: hacer clic, desplazarse, arrastrar elementos.

El primer enfoque es más rápido y consume menos recursos; el segundo es más universal y más robusto ante lógicas poco habituales. En la práctica es frecuente combinarlos.


Enfoque 1. Interceptar y emular las peticiones a la API

La idea

Cuando una página «completa» sus datos sobre la marcha, casi siempre lanza peticiones HTTP en segundo plano (XHR/fetch) a una API interna que devuelve JSON. Si localiza ese endpoint y reproduce la petición con las cabeceras, cookies y tokens correctos, podrá obtener los datos directamente, sin pasar por el renderizado. Es decenas de veces más rápido y no requiere navegador.

Cómo localizar la API

  1. Abra las DevTools (F12) → pestaña Network.
  2. Filtre por Fetch/XHR.
  3. Desplace la página, pulse «Mostrar más», cambie de categoría — en definitiva, provoque la carga de datos.
  4. Localice la petición en cuya respuesta esté el JSON que le interesa (productos, precios, etc.).
  5. Examínela: URL, método, parámetros de la petición, cabeceras, cuerpo y cookies.
  6. Clic derecho → Copy → Copy as cURL — un excelente punto de partida: puede importarlo en Postman o traducirlo directamente a código.

Tokens: CSRF, sesiones y autenticación

La principal dificultad de este enfoque es que la petición casi nunca va «desnuda». El servidor espera un conjunto de datos de validación y, sin ellos, devuelve 401, 403 o 419.

Token CSRF (Cross-Site Request Forgery). Protección contra la falsificación de peticiones entre sitios. El servidor emite un token aleatorio que el cliente debe devolver en las peticiones que modifican datos (y, a veces, también en las de lectura). Dónde suele encontrarse:

  • en <meta name="csrf-token" content="...">, dentro del HTML de la página;
  • en un campo oculto del formulario <input type="hidden" name="_token" value="...">;
  • en una cookie (a menudo XSRF-TOKEN) que luego hay que duplicar en la cabecera X-CSRF-Token o X-XSRF-TOKEN.

El esquema es el siguiente: primero se carga la página normal, se extraen el token y las cookies de sesión y luego se incorporan a la petición a la API.

Cookies de sesión. En el primer acceso, el servidor envía Set-Cookie (por ejemplo, sessionid, PHPSESSID, laravel_session). Hay que conservarlas entre peticiones; para ello se usa un objeto de sesión (requests.Session, httpx.Client), que lo hace automáticamente.

Autenticación (Bearer / JWT / clave de API). Si los datos están tras un inicio de sesión, la cabecera suele llevar Authorization: Bearer <token>. Los tokens JWT se obtienen a través del endpoint de login y luego se adjuntan a cada petición.

Otros campos de protección. X-Requested-With: XMLHttpRequest (a menudo obligatorio en los endpoints AJAX), Referer, Origin y, a veces, parámetros firmados (signature, nonce, timestamp) que genera el JavaScript del frontend.

Cuándo falla este enfoque. Si el token o la firma de la petición los genera un JavaScript ofuscado en el propio navegador (o en WASM), reproducirlo del lado del servidor resulta muy difícil. Es la señal de que conviene pasar al segundo enfoque — la emulación del navegador, donde el JS se ejecuta solo.

Ejemplo: Python + requests (con CSRF y paginación)

python
import re
import requests

session = requests.Session()
BASE = "https://example-shop.com"

# 1. Cargamos la página para obtener el token CSRF y las cookies de sesión
resp = session.get(f"{BASE}/catalog", headers={
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
})

# El CSRF puede estar en una etiqueta meta...
m = re.search(r'name="csrf-token"\s+content="([^"]+)"', resp.text)
csrf = m.group(1) if m else session.cookies.get("XSRF-TOKEN")

headers = {
    "X-CSRF-Token": csrf,
    "X-Requested-With": "XMLHttpRequest",
    "Referer": f"{BASE}/catalog",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
    "Accept": "application/json",
}

# 2. Consultamos la API interna página a página
all_items = []
page = 1
while True:
    r = session.get(
        f"{BASE}/api/products",
        params={"category": "phones", "page": page, "per_page": 48},
        headers=headers,
    )
    r.raise_for_status()
    payload = r.json()
    items = payload.get("items", [])
    if not items:
        break
    all_items.extend(items)
    page += 1

for it in all_items:
    print(it["title"], it["price"])

Ejemplo: Python + httpx (async, más rápido con grandes volúmenes)

python
import asyncio
import httpx

async def fetch_page(client, page):
    r = await client.get("/api/products", params={"page": page, "per_page": 48})
    return r.json().get("items", [])

async def main():
    async with httpx.AsyncClient(base_url="https://example-shop.com",
                                 headers={"X-Requested-With": "XMLHttpRequest"}) as client:
        tasks = [fetch_page(client, p) for p in range(1, 11)]
        results = await asyncio.gather(*tasks)
    items = [x for chunk in results for x in chunk]
    print(len(items))

asyncio.run(main())

Ejemplo: Node.js + fetch

javascript
const csrf = "..."; // extraído del HTML/cookies de antemano

const res = await fetch("https://example-shop.com/api/products?page=1&per_page=48", {
  headers: {
    "X-CSRF-Token": csrf,
    "X-Requested-With": "XMLHttpRequest",
    "Accept": "application/json",
    "Cookie": "sessionid=abc123; XSRF-TOKEN=" + csrf,
  },
});

const data = await res.json();
data.items.forEach(item => console.log(item.title, item.price));

Enfoque 2. Emulación completa del navegador

La idea

Arrancamos un motor real (Chromium, Firefox, WebKit): descarga la página, ejecuta todo el JS y renderiza el DOM. A partir de ahí operamos sobre la página igual que una persona: esperamos a que aparezcan los elementos, hacemos clic, nos desplazamos, arrastramos deslizadores. Todos los tokens, las firmas y los scripts anti-bot se ejecutan solos — no tenemos que reproducirlos.

Inconvenientes: es un orden de magnitud más lento, voraz en CPU/RAM y más fácil de detectar por los sistemas anti-bot (aunque esto se combate con «modos stealth» específicos).

Con qué emular

  • Selenium — el estándar más veterano; admite Python, Java, C#, JavaScript y Ruby. Controla navegadores reales a través de WebDriver.
  • Playwright — un framework moderno de Microsoft. Python, JavaScript/TS, .NET, Java. Chromium, Firefox y WebKit «listos para usar», autoespera inteligente de elementos y una cómoda interceptación de peticiones de red.
  • Puppeteer — Node.js; en origen solo Chromium (hay soporte experimental para Firefox). Muy rápido y maduro para Chrome.

Acciones sobre la página

A continuación — las mismas cuatro acciones (clic, desplazamiento, desplazamiento hasta un elemento y mantener pulsado el botón del ratón + movimiento) en distintas tecnologías. Mantener pulsado + mover es la base del drag-and-drop, de los deslizadores y de los captchas de deslizador.

Playwright (Python)

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("https://example-shop.com/catalog")

    # CLIC
    page.click("button.load-more")

    # DESPLAZAMIENTO con la rueda
    page.mouse.wheel(0, 2000)

    # DESPLAZAMIENTO hasta un elemento concreto
    page.locator("footer").scroll_into_view_if_needed()

    # MANTENER pulsado + MOVIMIENTO (drag / deslizador)
    box = page.locator(".slider-handle").bounding_box()
    start_x = box["x"] + box["width"] / 2
    start_y = box["y"] + box["height"] / 2
    page.mouse.move(start_x, start_y)
    page.mouse.down()                                   # botón pulsado
    page.mouse.move(start_x + 200, start_y, steps=25)   # desplazamiento suave (25 pasos)
    page.mouse.up()                                     # soltamos

    browser.close()

Playwright (JavaScript/Node)

javascript
const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example-shop.com/catalog');

  // CLIC
  await page.click('button.load-more');

  // DESPLAZAMIENTO
  await page.mouse.wheel(0, 2000);

  // DESPLAZAMIENTO hasta el elemento
  await page.locator('footer').scrollIntoViewIfNeeded();

  // MANTENER PULSADO + MOVIMIENTO
  const box = await page.locator('.slider-handle').boundingBox();
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await page.mouse.down();
  await page.mouse.move(box.x + 200, box.y, { steps: 25 });
  await page.mouse.up();

  await browser.close();
})();

Selenium (Python) — con ActionChains

python
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://example-shop.com/catalog")
wait = WebDriverWait(driver, 10)

# CLIC (esperando a que sea clicable)
btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.load-more")))
btn.click()

# DESPLAZAMIENTO
driver.execute_script("window.scrollBy(0, 2000)")

# DESPLAZAMIENTO hasta el elemento
footer = driver.find_element(By.CSS_SELECTOR, "footer")
driver.execute_script("arguments[0].scrollIntoView({block:'center'})", footer)

# MANTENER PULSADO + MOVIMIENTO
handle = driver.find_element(By.CSS_SELECTOR, ".slider-handle")
(ActionChains(driver)
    .click_and_hold(handle)     # botón pulsado
    .move_by_offset(200, 0)     # desplazamos 200 px a la derecha
    .pause(0.3)
    .release()                  # soltamos
    .perform())

driver.quit()

Selenium (Java)

java
WebDriver driver = new ChromeDriver();
driver.get("https://example-shop.com/catalog");

// CLIC
driver.findElement(By.cssSelector("button.load-more")).click();

// DESPLAZAMIENTO
((JavascriptExecutor) driver).executeScript("window.scrollBy(0, 2000)");

// MANTENER PULSADO + MOVIMIENTO
WebElement handle = driver.findElement(By.cssSelector(".slider-handle"));
new Actions(driver)
    .clickAndHold(handle)
    .moveByOffset(200, 0)
    .release()
    .perform();

Puppeteer (Node.js)

javascript
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();
  await page.goto('https://example-shop.com/catalog');

  // CLIC
  await page.click('button.load-more');

  // DESPLAZAMIENTO
  await page.evaluate(() => window.scrollBy(0, 2000));

  // DESPLAZAMIENTO hasta el elemento
  await page.$eval('footer', el => el.scrollIntoView());

  // MANTENER PULSADO + MOVIMIENTO
  const handle = await page.$('.slider-handle');
  const box = await handle.boundingBox();
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await page.mouse.down();
  await page.mouse.move(box.x + 200, box.y, { steps: 25 });
  await page.mouse.up();

  await browser.close();
})();

Modos de enmascaramiento (stealth): evadir la detección de la automatización

Un navegador headless ejecutado «tal cual» se distingue con facilidad de uno real. Los sistemas anti-bot (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai, etc.) comprueban decenas de señales y, si al menos una parte delata la automatización, llega el captcha, el challenge o el bloqueo. El modo stealth es un conjunto de parches y técnicas que enmascaran estos indicios.

Por qué indicios detectan

  • navigator.webdriver === true — la bandera más evidente, que el navegador controlado por WebDriver/CDP activa automáticamente.
  • Artefactos de headless. Ausencia de window.chrome, lista de plugins vacía (navigator.plugins), valores atípicos de navigator.languages, un renderizador WebGL del tipo SwiftShader/Google Inc. en lugar de una tarjeta gráfica real.
  • Fingerprinting. Canvas, WebGL, AudioContext y el conjunto de fuentes generan una «huella» estable del entorno; en un headless por defecto resulta sospechosamente genérica.
  • Huella TLS/JA3. A nivel de la propia conexión HTTP, el «apretón de manos» de un cliente Python o Node difiere del de Chrome — y eso se detecta incluso antes de ejecutar el JS (también aplica al enfoque 1).
  • Comportamiento. Clics instantáneos sin movimiento del ratón, tiempos perfectamente regulares, saltar directamente a una página interna sin navegación previa — todo eso no es humano.
  • Reputación de la IP. Los rangos de centros de datos (AWS, Hetzner, etc.) están marcados; las direcciones residenciales y móviles levantan menos sospechas.

Herramientas ya disponibles

  • puppeteer-extra + puppeteer-extra-plugin-stealth (Node) — el paquete más conocido; oculta navigator.webdriver, corrige WebGL/plugins/languages y decenas de otras «fugas».
  • playwright-extra con el mismo plugin stealth — el equivalente para Playwright en Node.
  • undetected-chromedriver (Python, sobre Selenium) — un ChromeDriver parcheado que supera muchas comprobaciones de Cloudflare. Su evolución es nodriver (sin el protocolo webdriver en absoluto, puramente a través de CDP).
  • SeleniumBase en modo UC (--uc) — una envoltura sobre Selenium con antidetección integrada.
  • rebrowser-patches — parches de bajo nivel para el runtime de Puppeteer/Playwright que cierran fugas de CDP más sutiles.

Importante: ningún plugin stealth ofrece garantías. Los sistemas anti-bot se actualizan constantemente y lo que pasaba ayer puede ser detectado mañana. Es una «carrera armamentística», no una configuración de una sola vez.

Ejemplos

Python — undetected-chromedriver:

python
import undetected_chromedriver as uc

options = uc.ChromeOptions()
options.add_argument("--lang=es-ES")
# para la antidetección normalmente NO se usa headless, o se usa el modo nuevo:
# options.add_argument("--headless=new")

driver = uc.Chrome(options=options)
driver.get("https://example-shop.com/catalog")
print(driver.title)
driver.quit()

Node — puppeteer-extra + stealth:

javascript
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');

puppeteer.use(StealthPlugin());

(async () => {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();
  // UA verosímil y cabeceras a juego
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
    '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
  );
  await page.goto('https://example-shop.com/catalog');
  await browser.close();
})();

Node — playwright-extra + stealth:

javascript
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();

chromium.use(stealth);

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example-shop.com/catalog');
  await browser.close();
})();

Técnicas manuales (además de los plugins o en su lugar)

  • Ocultar webdriver y arreglar el entorno. Mediante CDP/init script antes de que cargue la página:
python
# Playwright (Python): se ejecuta en cada documento nuevo ANTES que los scripts del sitio
page.add_init_script(
    "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
)
  • Nuevo modo headless. En las versiones recientes de Chrome, la bandera --headless=new se acerca más a un navegador normal que el headless antiguo; a veces sale mejor ejecutar directamente en modo no headless bajo una pantalla virtual (Xvfb).
  • Perfil persistente. Arrancar con user_data_dir conserva las cookies y el «calentamiento» de la sesión entre ejecuciones — se parece menos a un bot recién creado.
  • Comportamiento humano. Pausas aleatorias, movimiento del ratón siguiendo una curva (Bézier), desplazamiento a tirones en lugar de un único salto. Para generar trayectorias existen bibliotecas como pyautogui/bezier o el parámetro steps integrado en mouse.move.
  • Proxies. Los proxies residenciales y móviles con rotación reducen notablemente la proporción de challenges frente a las IP de centros de datos.

Enmascaramiento (stealth) para el enfoque 1 (sin navegador)

La interceptación de la API también se detecta — por la huella TLS del cliente HTTP. Para que la petición parezca, ya en el «apretón de manos», un Chrome real, se usan clientes que falsean la huella TLS:

python
# curl_cffi sabe imitar el TLS/JA3 de un navegador concreto
from curl_cffi import requests

r = requests.get(
    "https://example-shop.com/api/products?page=1",
    impersonate="chrome124",   # falseamos el apretón de manos de Chrome 124
)
print(r.json())

Alternativas: tls-client (Python/Go), curl-impersonate (un binario del sistema). Esto suele resolver el problema cuando un requests «desnudo» recibe un 403 mientras que en el navegador esa misma página se abre.


Qué sabe hacer cada biblioteca

La línea divisoria clave: si la herramienta ejecuta JavaScript y si sabe imitar las acciones del ratón. Los clientes HTTP y los parsers de HTML no hacen ninguna de las dos cosas — solo sirven para sitios estáticos o para el primer enfoque (la emulación de la API).

Biblioteca Lenguaje Renderizado de JS Acciones (clic/scroll/drag) Uso
requests / httpx Python No No Cliente HTTP
aiohttp Python No No HTTP asíncrono
BeautifulSoup / lxml Python No No Parseo de HTML
Scrapy Python No (requiere el plugin Splash/Playwright) No Framework de crawling
Selenium Python/Java/C#/JS/Ruby Control del navegador
Playwright Python/JS/.NET/Java Control del navegador
Puppeteer Node.js Sí (Chromium) Control del navegador
Cypress JS Sí (pero orientado a pruebas e2e) Pruebas
axios / fetch / got Node.js No No Cliente HTTP
cheerio Node.js No No Parseo de HTML (estilo jQuery)
Colly Go No No Framework de crawling
chromedp / rod Go Control del navegador
HtmlUnit Java Parcial/inestable Limitadas Navegador headless
jsoup Java No No Parseo de HTML

Conclusión breve:

  • Solo necesita repetir una petición a la APIrequests/httpx (Python), fetch/got (Node), Colly (Go).
  • Parsear un HTML ya obtenidoBeautifulSoup/lxml, cheerio, jsoup.
  • Necesita renderizado de JS y acciones con el ratónPlaywright, Selenium, Puppeteer, chromedp/rod.
  • HtmlUnit maneja algo de JS, pero con las SPA modernas tropieza a menudo — para un renderizado serio se recurre a Playwright/Selenium.

Caso práctico: monitorización de tiendas online y scroll infinito

Es, probablemente, la tarea práctica más habitual. En los catálogos los productos no suelen mostrarse todos de golpe: se aplica lazy loading / scroll infinito — las nuevas fichas se cargan a medida que se desplaza la página (o al pulsar «Mostrar más»). Una simple petición del HTML solo devolverá la primera «tanda».

Hay dos caminos, y ambos se usan mucho en la monitorización de precios y surtido:

Camino A (preferente): interceptar la API de paginación

Al desplazarse, la tienda casi siempre llama a algo parecido a /api/catalog?page=2&offset=48. Si es así — olvídese del navegador y recopile los datos página a página de forma directa (véase el enfoque 1). Es rápido, estable y escala a miles de productos. Así es como se construyen la mayoría de las monitorizaciones industriales: el navegador se usa una sola vez — para explorar la estructura de la API y los tokens, y la recolección en sí se hace con un cliente HTTP.

Camino B: renderizado + scroll cuando la API está cerrada

Si el endpoint está protegido por una firma no trivial o los datos se generan exclusivamente en el cliente, no queda más que desplazar con el navegador y recoger las fichas del DOM. El algoritmo: desplazamos hacia abajo → esperamos la carga → contamos las fichas → repetimos mientras la cantidad crezca.

python
from playwright.sync_api import sync_playwright

def scrape_catalog(url):
    products = []
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url)

        prev_count = -1
        stable_rounds = 0

        while stable_rounds < 2:  # 2 desplazamientos «vacíos» seguidos = hemos llegado al final
            # nos desplazamos hasta el fondo
            page.mouse.wheel(0, 4000)
            page.wait_for_timeout(1500)  # damos tiempo a que cargue

            cards = page.locator(".product-card")
            count = cards.count()

            if count == prev_count:
                stable_rounds += 1
            else:
                stable_rounds = 0
            prev_count = count

        # recogemos todas las fichas tras el desplazamiento completo
        cards = page.locator(".product-card")
        for i in range(cards.count()):
            card = cards.nth(i)
            products.append({
                "title": card.locator(".title").inner_text(),
                "price": card.locator(".price").inner_text(),
                "url":   card.locator("a").get_attribute("href"),
            })

        browser.close()
    return products

data = scrape_catalog("https://example-shop.com/catalog/phones")
print(f"Productos recopilados: {len(data)}")

La misma técnica para el botón «Mostrar más» — hacemos clic mientras el botón exista:

python
while page.locator("button.load-more").count() > 0:
    page.click("button.load-more")
    page.wait_for_timeout(1200)

Híbrido: lo mejor de ambos mundos

Playwright y Puppeteer saben escuchar el tráfico de red. Puede abrir la página en el navegador (para que pasen todos los tokens y las comprobaciones anti-bot), pero obtener los datos no del DOM, sino de las respuestas de esa misma API que el navegador llama al desplazarse:

python
def handle_response(response):
    if "/api/products" in response.url:
        data = response.json()
        # guardamos el JSON ya listo: no hace falta parsear HTML
        save(data["items"])

page.on("response", handle_response)
page.goto("https://example-shop.com/catalog")
# luego solo nos desplazamos: los datos «llegan» solos al manejador

Suele ser la opción óptima para la monitorización: la robustez de la emulación de navegador + un JSON limpio y estructurado en lugar del frágil análisis del maquetado.

Consejos prácticos para la monitorización

  • Espere a los datos, no al reloj. En lugar de «dormir 1,5 segundos», use la espera a que aparezca un elemento (wait_for_selector) o a que la red quede en calma (wait_for_load_state("networkidle")) — es más fiable y a menudo más rápido.
  • Deduplicación. Con el scroll infinito, algunas fichas pueden leerse por duplicado — recopile por un id/url único.
  • Limite la frecuencia. Las peticiones demasiado agresivas sobrecargan el sitio y provocan un bloqueo rápido. Añada retardos y use los pools de proxies con cuidado y dentro de la ley.
  • Cachee la exploración previa. Averigüe la estructura de la API y los tokens una sola vez; en producción ejecute una recolección HTTP ligera y mantenga el navegador pesado como reserva.

Cómo elegir el enfoque

Criterio Interceptar la API Emulación de navegador
Velocidad Muy alta Baja
Consumo de recursos Mínimo Alto (CPU/RAM)
Complejidad de configuración Mayor (ingeniería inversa de tokens) Menor (todo «como un usuario»)
Robustez ante cambios de maquetado Alta (depende de la API) Media (depende de los selectores)
Superar tokens/firmas del cliente Difícil Automático
Escalado por volumen Excelente Limitado

Regla práctica: compruebe siempre primero si basta con interceptar la API — es más rápido, más barato y más estable. Pase a la emulación de navegador solo cuando la API esté oculta tras criptografía del lado del cliente, protegida por una lógica anti-bot compleja, o cuando necesite reproducir una interacción no trivial (drag-and-drop, deslizadores, formularios por pasos).