Scraping con nodriver: guía completa con proxies residenciales

Aprende a usar nodriver, el sucesor asíncrono de undetected-chromedriver, con proxies residenciales de ProxyHat para evadir Cloudflare e Imperva sin WebDriver.

Scraping With nodriver: A Practical Guide to Undetected Async Browser Automation
En este artículo

Si estás migrando desde undetected-chromedriver, ya sabes el problema: Cloudflare e Imperva detectan cada vez más rápido los navegadores automatizados basados en Selenium. Scraping con nodriver resuelve esto eliminando por completo la dependencia de WebDriver y hablando directamente con Chrome mediante el Chrome DevTools Protocol (CDP) sobre websockets. En esta guía verás cómo integrar nodriver proxy con endpoints residenciales de ProxyHat, lanzar navegadores indetectables a escala y cuándo conviene evitar un navegador completo.

Aviso legal: Esta guía asume que automatizas acceso a datos públicos con autorización del propietario del sitio o dentro de los límites de su ToS. El acceso no autorizado a sistemas protegidos puede violar leyes como la Computer Fraud and Abuse Act (CFAA) en EE. UU. o el GDPR en la UE. Verifica siempre robots.txt y los términos de servicio antes de scrapear.

Por qué Scraping con nodriver supera a undetected-chromedriver

nodriver (anteriormente undetected-chromedriver-py) es una biblioteca Python asíncrona creada por Ultrafunkamsterdam, el mismo autor de undetected-chromedriver. La diferencia fundamental es arquitectónica: nodriver no usa Selenium ni WebDriver en absoluto. En su lugar, se conecta directamente al Chrome DevTools Protocol mediante websockets, igual que lo hace DevTools o Puppeteer, pero sin exponer la superficie de detección que WebDriver deja visible.

Cuando Selenium arranca Chrome con --remote-debugging-port, inyecta un cliente WebDriver que establece navigator.webdriver = true y añade señales CDP detectables. nodriver evita esto porque nunca carga el chromedriver binario. El navegador arranca como una instancia Chrome normal con CDP habilitado, y la biblioteca envía comandos directamente al endpoint ws://127.0.0.1:9222.

Según la documentación de MDN, navigator.webdriver es true cuando el navegador es controlado por automatización. nodriver parchea esto a nivel de proceso Chrome, no mediante inyección JavaScript posterior, lo que lo hace significativamente más difícil de detectar por soluciones anti-bot como Cloudflare Turnstile o Imperva Incapsula.

Tabla comparativa: undetected-chromedriver vs nodriver

Característica undetected-chromedriver nodriver
Protocolo WebDriver (W3C) Chrome DevTools Protocol (CDP)
Dependencia Selenium Sí, requiere selenium No, zero Selenium
Modelo de ejecución Síncrono (threading) Asíncrono (asyncio)
navigator.webdriver Parcheado post-arranque No presente desde el inicio
Eventos CDP Limitados Acceso completo a todos los dominios CDP
Rotación de proxy integrada No No (requiere configuración manual)

Arquitectura de nodriver: CDP directo sin WebDriver

El núcleo de nodriver son tres objetos principales:

  • uc.start() — función de entrada que lanza un proceso Chrome, se conecta vía websocket al puerto de depuración y devuelve un objeto Browser.
  • Browser — representa la conexión CDP al navegador. Permite crear tabs, gestionar el ciclo de vida y configurar argumentos de lanzamiento.
  • Tab — un target CDP individual. Cada tab es una página con su propio contexto de ejecución JavaScript.

La API es completamente asíncrona. No hay WebDriverWait ni expected_conditions. En su lugar, nodriver expone await tab.find(), await tab.select() y un sistema de event hooks que se suscriben a eventos CDP como Network.requestWillBeSent o Page.loadEventFired.

API idiomática: start, find, select y event hooks

Veamos el patrón básico de nodriver async para encontrar elementos y esperar eventos:

import nodriver as uc
import asyncio

async def main():
    browser = await uc.start(
        headless=False,
        browser_args=["--disable-blink-features=AutomationControlled"]
    )
    
    tab = await browser.get("https://example.com")
    
    # find() espera hasta que el selector aparece (sin WebDriverWait)
    heading = await tab.find("h1", timeout=10)
    print(await heading.get_attribute("textContent"))
    
    # select() devuelve el primer match sin esperar
    links = await tab.select_all("a[href]")
    for link in links:
        href = await link.get_attribute("href")
        print(href)
    
    await browser.stop()

asyncio.run(main())

Para esperar eventos de red sin polling, puedes registrar un listener sobre el dominio CDP Network:

async def main():
    browser = await uc.start()
    tab = await browser.get("https://httpbin.org/json")
    
    # Habilitar el dominio Network para recibir eventos
    await tab.send(uc.cdp.network.enable())
    
    # Registrar un callback para respuestas
    responses = []
    def on_response(event: uc.cdp.network.ResponseReceived):
        responses.append(event.response.url)
    
    tab.add_handler(uc.cdp.network.ResponseReceived, on_response)
    
    await asyncio.sleep(3)  # dejar que la página cargue
    print(f"Capturadas {len(responses)} respuestas")
    await browser.stop()

Este patrón es fundamentalmente distinto a Selenium. No hay driver.execute_script() síncrono; todo es await tab.evaluate(expression). Los event hooks reemplazan los sleeps arbitrarios y los WebDriverWait con condiciones explícitas.

Configuración de proxy en nodriver con ProxyHat

nodriver no incluye rotación de proxy nativa. La forma idiomática de configurar un proxy es pasar --proxy-server= en browser_args. Sin embargo, hay un detalle crítico: Chrome no soporta autenticación de proxy mediante --proxy-server= directamente. Necesitas inyectar credenciales mediante un handler CDP Fetch.authRequired o usar un proxy sin autenticación.

Con ProxyHat, la solución más limpia es usar el handler Fetch.authChallenge de CDP para responder al desafío de autenticación con tus credenciales de ProxyHat. Así puedes pasar --proxy-server=http://gate.proxyhat.com:8080 y manejar la auth en runtime:

import nodriver as uc
import asyncio

PROXY_HOST = "gate.proxyhat.com"
PROXY_PORT = 8080
PROXY_USER = "user-country-US-session-abc123"
PROXY_PASS = "tu_password"

async def main():
    browser = await uc.start(
        headless=True,
        browser_args=[
            f"--proxy-server=http://{PROXY_HOST}:{PROXY_PORT}",
            "--disable-blink-features=AutomationControlled",
            "--no-sandbox",
        ],
    )
    
    tab = await browser.get("https://example.com")
    
    # Habilitar Fetch domain para interceptar auth challenges
    await tab.send(uc.cdp.fetch.enable(
        handle_auth_requests=True
    ))
    
    async def on_auth_required(event: uc.cdp.fetch.AuthRequired):
        await tab.send(uc.cdp.fetch.continue_with_auth(
            request_id=event.request_id,
            auth_challenge_response=uc.cdp.fetch.AuthChallengeResponse(
                response="ProvideCredentials",
                username=PROXY_USER,
                password=PROXY_PASS,
            )
        ))
    
    tab.add_handler(uc.cdp.fetch.AuthRequired, on_auth_required)
    
    # Navegar a la página protegida
    tab = await browser.get("https://api.protegido.com/data")
    await asyncio.sleep(5)
    
    body = await tab.get_content()
    print(body[:500])
    
    await browser.stop()

asyncio.run(main())

El flag --proxy-server solo acepta el host y puerto. Las credenciales van en el handler CDP. El username de ProxyHat user-country-US-session-abc123 fija la sesión a una IP residencial en EE. UU., de modo que todas las peticiones desde ese navegador salgan con la misma IP — ideal para mantener sesiones de login.

Por qué proxies residenciales son esenciales para nodriver

Aunque nodriver elimina navigator.webdriver y las señales CDP más comunes, los sistemas anti-bot como Cloudflare también inspeccionan la reputación de la IP. Las IPs de datacenter (AWS, DigitalOcean, OVH) están en listas negras conocidas. Un navegador perfectamente indetectable con una IP de datacenter sigue siendo bloqueado porque Cloudflare marca el ASN como sospechoso.

Los proxies residenciales de ProxyHat usan IPs asignadas por ISPs reales a dispositivos reales. Esto significa que tu navegador headless aparece como un usuario residencial legítimo. Para scraping de SERP o e-commerce con protección avanzada, los proxies residenciales son no negociable. Puedes ver las ubicaciones disponibles en /es/locations y los planes en /es/pricing.

Ejemplo completo: scraping de una API protegida con nodriver y ProxyHat

A continuación, un ejemplo runnable que lanza nodriver a través de un endpoint residencial de ProxyHat con sesión fija, navega a una página protegida por Cloudflare y extrae JSON de la respuesta de red:

import nodriver as uc
import asyncio
import json

# --- Configuración ProxyHat ---
PROXY_HOST = "gate.proxyhat.com"
PROXY_PORT = 8080
PROXY_USER = "user-country-US-session-abc123"
PROXY_PASS = "tu_password"

def build_proxy_args():
    """Construye los argumentos de Chrome para el proxy de ProxyHat."""
    return [
        f"--proxy-server=http://{PROXY_HOST}:{PROXY_PORT}",
        "--disable-blink-features=AutomationControlled",
        "--disable-features=IsolateOrigins,site-per-process",
        "--no-sandbox",
        "--disable-dev-shm-usage",
    ]

async def scrape_protected_api(url: str) -> dict:
    browser = await uc.start(
        headless=True,
        browser_args=build_proxy_args(),
    )
    
    captured_json = {}
    
    tab = await browser.get("about:blank")
    
    # Habilitar dominios CDP necesarios
    await tab.send(uc.cdp.network.enable())
    await tab.send(uc.cdp.fetch.enable(handle_auth_requests=True))
    
    # Handler de autenticación del proxy
    async def on_auth(event: uc.cdp.fetch.AuthRequired):
        await tab.send(uc.cdp.fetch.continue_with_auth(
            request_id=event.request_id,
            auth_challenge_response=uc.cdp.fetch.AuthChallengeResponse(
                response="ProvideCredentials",
                username=PROXY_USER,
                password=PROXY_PASS,
            )
        ))
    
    tab.add_handler(uc.cdp.fetch.AuthRequired, on_auth)
    
    # Handler para capturar respuestas JSON
    async def on_response(event: uc.cdp.network.ResponseReceived):
        url_resp = event.response.url
        mime = event.response.mime_type
        if "json" in (mime or ""):
            # Obtener el cuerpo de la respuesta
            body = await tab.send(
                uc.cdp.network.get_response_body(
                    request_id=event.request_id
                )
            )
            try:
                captured_json["data"] = json.loads(body[0])
                captured_json["url"] = url_resp
            except json.JSONDecodeError:
                pass
    
    tab.add_handler(uc.cdp.network.ResponseReceived, on_response)
    
    # Navegar a la página protegida
    await tab.get(url)
    await asyncio.sleep(8)  # tiempo para challenge de Cloudflare
    
    await browser.stop()
    return captured_json

# Ejecutar
result = asyncio.run(
    scrape_protected_api("https://api.protegido.com/v1/products")
)
print(json.dumps(result, indent=2, ensure_ascii=False))

Este patrón es potente porque intercepta la respuesta JSON directamente del tráfico de red del navegador, sin necesidad de parsear el DOM. El navegador resuelve el challenge de Cloudflare de forma transparente y la API devuelve datos limpios.

Escalado: flota headless en Docker con nodriver

Para escalar nodriver web scraping a cientos de páginas concurrentes, necesitas un patrón de flota: múltiples contenedores Docker, cada uno con su propia instancia Chrome y su propio proxy asignado.

Concurrencia con múltiples Tabs

Un solo proceso Chrome puede manejar 10-20 tabs concurrentes antes de que el consumo de memoria se vuelva problemático (cada tab consume ~150-300 MB de RAM). Para volúmenes mayores, lanza múltiples procesos:

import nodriver as uc
import asyncio

SESSIONS = [
    ("user-country-US-session-s1", "pass"),
    ("user-country-DE-session-s2", "pass"),
    ("user-country-GB-session-s3", "pass"),
    ("user-country-FR-session-s4", "pass"),
]

async def worker(session_user: str, session_pass: str, urls: list[str]):
    browser = await uc.start(
        headless=True,
        browser_args=[
            f"--proxy-server=http://gate.proxyhat.com:8080",
            "--disable-blink-features=AutomationControlled",
            "--no-sandbox",
        ],
    )
    tab = await browser.get("about:blank")
    await tab.send(uc.cdp.fetch.enable(handle_auth_requests=True))
    
    async def on_auth(event):
        await tab.send(uc.cdp.fetch.continue_with_auth(
            request_id=event.request_id,
            auth_challenge_response=uc.cdp.fetch.AuthChallengeResponse(
                response="ProvideCredentials",
                username=session_user,
                password=session_pass,
            )
        ))
    tab.add_handler(uc.cdp.fetch.AuthRequired, on_auth)
    
    results = []
    for url in urls:
        page = await browser.get(url)
        await asyncio.sleep(5)
        content = await page.get_content()
        results.append({"url": url, "length": len(content)})
    
    await browser.stop()
    return results

async def main():
    urls_per_worker = [
        ["https://ejemplo.com/page1", "https://ejemplo.com/page2"],
        ["https://ejemplo.com/page3", "https://ejemplo.com/page4"],
        ["https://ejemplo.com/page5", "https://ejemplo.com/page6"],
        ["https://ejemplo.com/page7", "https://ejemplo.com/page8"],
    ]
    
    tasks = [
        worker(s[0], s[1], urls)
        for s, urls in zip(SESSIONS, urls_per_worker)
    ]
    
    all_results = await asyncio.gather(*tasks, return_exceptions=True)
    for i, res in enumerate(all_results):
        if isinstance(res, Exception):
            print(f"Worker {i} falló: {res}")
        else:
            print(f"Worker {i}: {len(res)} páginas scrapeadas")

asyncio.run(main())

Containerización con Docker

Para producción, empaqueta cada worker en un contenedor Docker con Chrome headless:

FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
    chromium \
    chromium-driver \
    fonts-liberation \
    libasound2 \
    libnss3 \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir nodriver

WORKDIR /app
COPY scraper.py .

ENV CHROME_PATH=/usr/bin/chromium
ENV HEADLESS=true

CMD ["python", "scraper.py"]

Con 4 contenedores en paralelo, cada uno con 10 tabs concurrentes, puedes procesar ~40 páginas simultáneamente. A 5 segundos por página con un delay de 2 segundos entre requests, eso son aproximadamente 280 páginas por minuto. Para escalar más allá, añade contenedores y rota sesiones de ProxyHat por contenedor.

Shutdown graceful

Cuando ejecutas una flota, el shutdown graceful es crítico para evitar procesos Chrome zombi. Usa signal handlers y asyncio.gather con return_exceptions=True:

import signal
import asyncio

shutdown = asyncio.Event()

def handle_signal(signum, frame):
    shutdown.set()

signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)

async def run_fleet():
    browsers = []
    try:
        for session in SESSIONS:
            br = await uc.start(headless=True, browser_args=[...])
            browsers.append(br)
        
        while not shutdown.is_set():
            # ... trabajo de scraping ...
            await asyncio.sleep(1)
    finally:
        # Cerrar todos los navegadores
        await asyncio.gather(
            *[br.stop() for br in browsers],
            return_exceptions=True
        )

Cuándo NO usar un navegador completo

nodriver es potente pero caro en recursos. Un navegador Chrome headless consume 200-500 MB de RAM y 1-3 segundos de arranque. Si el sitio objetivo no usa Cloudflare o challenges JavaScript complejos, HTTP directo con curl_cffi es órdenes de magnitud más eficiente:

# Alternativa sin navegador: curl_cffi con TLS fingerprinting
from curl_cffi import requests

proxies = {
    "http": "http://user-country-US-session-abc123:pass@gate.proxyhat.com:8080",
    "https": "http://user-country-US-session-abc123:pass@gate.proxyhat.com:8080",
}

r = requests.get(
    "https://api.protegida.com/data",
    proxies=proxies,
    impersonate="chrome",  # fingerprint TLS de Chrome real
)
print(r.json())

La regla práctica: si el sitio devuelve datos con un simple GET y tu IP residencial no es bloqueada, usa curl_cffi. Si necesitas resolver un challenge JavaScript interactivo o Cloudflare Turnstile, usa nodriver. Para casos de uso de web scraping a escala, consulta nuestra guía de web scraping y para tracking de SERP específicamente, nuestra guía de SERP tracking.

Errores comunes y casos límite

  • Olvidar habilitar Fetch.enable antes de navegar: El handler de auth debe registrarse antes de cualquier request. Si lo haces después, la primera petición falla con 407 Proxy Authentication Required.
  • Usar headless=True sin flags adicionales: Chrome headless antiguo tiene diferencias detectables. Usa --headless=new (Chrome 112+) que es menos distinguible de Chrome headful.
  • No rotar sesiones entre workers: Si todos tus workers usan la misma sesión de ProxyHat, todas las peticiones salen de la misma IP. Para scraping distribuido, asigna una sesión única por worker.
  • Ignorar robots.txt: Aunque nodriver pueda acceder, no significa que deba. Verifica siempre robots.txt y respeta Crawl-delay.
  • No manejar browser.stop() en excepciones: Usa try/finally o context managers para evitar procesos Chrome colgados que consumen memoria indefinidamente.

Para más detalles sobre la API de nodriver, consulta la documentación de ProxyHat y el repositorio oficial de nodriver en GitHub.

Puntos clave

  • nodriver elimina WebDriver — usa CDP directo sobre websockets, sin navigator.webdriver ni señales Selenium.
  • Proxy via --proxy-server con auth CDP Fetch.authRequired — nodriver no tiene rotación integrada.
  • Proxies residenciales son obligatorios para evadir Cloudflare — las IPs de datacenter están en listas negras por ASN.
  • Escalado con contenedores Docker — 4 contenedores × 10 tabs = ~280 páginas/minuto.
  • curl_cffi es más barato cuando no hay challenges JavaScript — úsalo como primera opción.
  • Ética primero — autorización, robots.txt y cumplimiento de CFAA/GDPR.

FAQ

¿Qué es el scraping con nodriver?

Scraping con nodriver es la práctica de extraer datos web usando la biblioteca Python nodriver, que controla Chrome mediante el Chrome DevTools Protocol (CDP) directamente sobre websockets, sin Selenium ni WebDriver. Esto elimina señales de detección como navigator.webdriver, lo que lo hace más efectivo contra anti-bot como Cloudflare e Imperva. Es el sucesor espiritual de undetected-chromedriver, del mismo autor.

¿Por qué importa nodriver para usuarios de proxies?

nodriver importa para usuarios de proxies porque, aunque el navegador sea indetectable a nivel JavaScript, la reputación de la IP sigue siendo verificada por sistemas anti-bot. Un navegador perfectamente stealth con una IP de datacenter sigue siendo bloqueado. Combinar nodriver con proxies residenciales de ProxyHat asegura que tanto el fingerprint del navegador como la reputación de la IP parezcan legítimos, maximizando la tasa de éxito.

¿Qué tipo de proxy funciona mejor con nodriver?

Los proxies residenciales funcionan mejor con nodriver para sitios con protección anti-bot avanzada (Cloudflare, Imperva, PerimeterX). Las IPs residenciales provienen de ISPs reales y no están en listas negras por ASN. Para sitios menos protegidos, los proxies datacenter son más rápidos y económicos. Los proxies móviles ofrecen la máxima confianza pero a mayor costo. La elección depende del nivel de protección del sitio objetivo.

¿Cómo evitas bloqueos al implementar scraping con nodriver?

Para evitar bloqueos: (1) usa proxies residenciales con rotación de sesiones por worker; (2) añade delays aleatorios entre peticiones (2-5 segundos); (3) usa --headless=new en lugar del headless antiguo; (4) pasa --disable-blink-features=AutomationControlled; (5) rota el User-Agent y acepta cookies; (6) habilita el handler Fetch.authRequired antes de navegar; (7) respeta robots.txt y los límites de rate del sitio.

¿Puedo usar SOCKS5 con nodriver y ProxyHat?

Sí. Para SOCKS5, cambia el argumento a --proxy-server=socks5://gate.proxyhat.com:1080 y ajusta el handler de autenticación igual que con HTTP. SOCKS5 es útil cuando necesitas tunelizar tráfico no HTTP o evitar intermediarios que inspeccionan tráfico HTTP. El puerto SOCKS5 de ProxyHat es 1080, mientras que HTTP usa 8080.

Preguntas frecuentes

¿Qué es el scraping con nodriver?

Scraping con nodriver es la práctica de extraer datos web usando la biblioteca Python nodriver, que controla Chrome mediante el Chrome DevTools Protocol (CDP) directamente sobre websockets, sin Selenium ni WebDriver. Esto elimina señales de detección como navigator.webdriver, lo que lo hace más efectivo contra anti-bot como Cloudflare e Imperva. Es el sucesor espiritual de undetected-chromedriver, del mismo autor.

¿Por qué importa nodriver para usuarios de proxies?

nodriver importa para usuarios de proxies porque, aunque el navegador sea indetectable a nivel JavaScript, la reputación de la IP sigue siendo verificada por sistemas anti-bot. Un navegador perfectamente stealth con una IP de datacenter sigue siendo bloqueado. Combinar nodriver con proxies residenciales de ProxyHat asegura que tanto el fingerprint del navegador como la reputación de la IP parezcan legítimos, maximizando la tasa de éxito.

¿Qué tipo de proxy funciona mejor con nodriver?

Los proxies residenciales funcionan mejor con nodriver para sitios con protección anti-bot avanzada (Cloudflare, Imperva, PerimeterX). Las IPs residenciales provienen de ISPs reales y no están en listas negras por ASN. Para sitios menos protegidos, los proxies datacenter son más rápidos y económicos. Los proxies móviles ofrecen la máxima confianza pero a mayor costo.

¿Cómo evitas bloqueos al implementar scraping con nodriver?

Para evitar bloqueos: usa proxies residenciales con rotación de sesiones por worker; añade delays aleatorios entre peticiones (2-5 segundos); usa --headless=new en lugar del headless antiguo; pasa --disable-blink-features=AutomationControlled; rota el User-Agent y acepta cookies; habilita el handler Fetch.authRequired antes de navegar; respeta robots.txt y los límites de rate del sitio.

¿Puedo usar SOCKS5 con nodriver y ProxyHat?

Sí. Para SOCKS5, cambia el argumento a --proxy-server=socks5://gate.proxyhat.com:1080 y ajusta el handler de autenticación igual que con HTTP. SOCKS5 es útil cuando necesitas tunelizar tráfico no HTTP o evitar intermediarios que inspeccionan tráfico HTTP. El puerto SOCKS5 de ProxyHat es 1080, mientras que HTTP usa 8080.

¿Listo para empezar?

Proxies residenciales, ISP y móviles en más de 148 países. Crea una cuenta gratis.

Crear cuenta gratis
← Volver al Blog