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:
- Interceptar las peticiones a la API — localizar las peticiones con las que el propio navegador obtiene los datos y repetirlas directamente, sin navegador.
- 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
- Abra las DevTools (
F12) → pestaña Network. - Filtre por Fetch/XHR.
- Desplace la página, pulse «Mostrar más», cambie de categoría — en definitiva, provoque la carga de datos.
- Localice la petición en cuya respuesta esté el JSON que le interesa (productos, precios, etc.).
- Examínela: URL, método, parámetros de la petición, cabeceras, cuerpo y cookies.
- 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 cabeceraX-CSRF-TokenoX-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)
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)
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
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)
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)
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
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)
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)
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 denavigator.languages, un renderizador WebGL del tipoSwiftShader/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; ocultanavigator.webdriver, corrige WebGL/plugins/languages y decenas de otras «fugas».playwright-extracon 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 esnodriver(sin el protocolo webdriver en absoluto, puramente a través de CDP).SeleniumBaseen 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:
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:
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:
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
webdrivery arreglar el entorno. Mediante CDP/init scriptantes de que cargue la página:
# 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=newse 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_dirconserva 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/beziero el parámetrostepsintegrado enmouse.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:
# 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 | Sí | Sí | Control del navegador |
| Playwright | Python/JS/.NET/Java | Sí | Sí | Control del navegador |
| Puppeteer | Node.js | Sí (Chromium) | Sí | Control del navegador |
| Cypress | JS | Sí | 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 | Sí | Sí | 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 API →
requests/httpx(Python),fetch/got(Node),Colly(Go). - Parsear un HTML ya obtenido →
BeautifulSoup/lxml,cheerio,jsoup. - Necesita renderizado de JS y acciones con el ratón →
Playwright,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.
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:
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:
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 manejadorSuele 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).