zendriver + Scrapeless: Conduciendo un Navegador Remoto a Través de CDP
Specialist in Anti-Bot Strategies
TL;DR:
- zendriver es un fork comunitario de nodriver que controla Chrome a través de CDP en crudo, y al igual que nodriver, está diseñado para lanzar el propio navegador.
Browser.start()tiene una rutaconnect_existing, pero el descubrimiento se realiza a través deHTTPApi, que codifica en duro HTTP puro con un host y un puerto: un endpoint de WebSocket seguro que lleva un token no puede expresarse de esa manera.- El verdadero obstáculo es la dirección de la pestaña. zendriver construye cada pestaña a partir de un host, un puerto y un id de destino, y un endpoint remoto no sirve tal ruta.
- Una URL CDP significa un navegador: una segunda conexión al mismo endpoint obtiene un navegador remoto diferente y no ve ninguno de los objetivos del primero.
- La costura es el multiplexado de sesión plana:
Target.attachToTarget(flatten=True)devuelve un sessionId, y estamparlo en los frames salientes hace que los propios ayudantesTabde zendriver lleguen a la página remota. - Con la sesión adjunta, una extracción real devolvió una página completamente renderizada y 20 tarjetas de producto con títulos y precios correctos.
- Comienza en el plan gratuito de Scrapeless y controla un navegador que no tuviste que lanzar.
El Navegador de Scraping de Scrapeless es un navegador en la nube al que te conectas en lugar de lanzar. Escucha en un endpoint de CDP de WebSocket seguro, que es el mismo protocolo que habla zendriver, por lo que en teoría los dos deberían coincidir en una línea.
No lo hacen. Playwright tiene connect_over_cdp. Puppeteer tiene connect. zendriver no tiene un equivalente, y la solicitud de uno ha estado en el rastreador desde abril de 2025 como un problema abierto solicitando exactamente esto, sin respuesta. El tutorial oficial de raw-CDP cubre page.send() y controladores de eventos y nunca menciona navegadores remotos en absoluto.
Esta guía trabaja lo que realmente está en el camino — que no es la conexión — y luego adjunta un navegador remoto usando nada más que los propios primitivos de la biblioteca.
Cómo zendriver Inicia un Navegador
zendriver es un fork de nodriver creado para fusionar correcciones de errores no fusionadas y reabrir el proyecto a contribuciones. Arquitectónicamente, los dos son la misma idea: controlar Chrome directamente a través de el Protocolo de DevTools de Chrome sin una capa de WebDriver entre medias, lo que elimina la superficie de automatización que las pilas basadas en controladores exponen. El fork añade una entrada estándar asyncio.run(), soporte para Docker y persistencia de cookies.
El punto de entrada normal lanza un navegador:
python
import asyncio
import zendriver as zd
async def main():
browser = await zd.start(headless=True, no_sandbox=True)
page = await browser.get("https://books.toscrape.com/")
await page.wait_for("h3 a", timeout=20)
await page.sleep(2)
print("cards:", len(await page.select_all("article.product_pod")))
await browser.stop()
asyncio.run(main())
text
cards: 20
no_sandbox=True es necesario cuando el proceso se ejecuta como root, que es el caso normal dentro de un contenedor; colócalo en un escritorio. La llamada sleep se gana su lugar por una razón que se cubre más adelante, y esa razón se aplica igualmente a la ruta remota.
Eso inicia Chrome como un proceso hijo en cualquier máquina que ejecute el script. Cada guía de zendriver en el conjunto de resultados funciona de esta manera: controla un navegador que el script lanzó por sí mismo, y lo configura a través de browser_args — un --proxy-server de Chromium siendo el ejemplo usual. Ninguna de ellas se adjunta a un navegador que no ha creado.
Prerequisitos
- Python 3.10 o posterior.
- Una clave API de Scrapeless del panel de control, exportada como
SCRAPELESS_KEY. - Una instalación local de Chrome o Chromium, solo si deseas ejecutar el bloque de lanzamiento local anterior. La ruta remota no necesita un binario de navegador.
Instalar
bash
pip install "zendriver==0.15.5"
bash
export SCRAPELESS_KEY="your_api_key_here"
Lo Que connect_existing Realmente Hace
Leer Browser.start() da la impresión de que la conexión remota ya está soportada. Cuando tanto config.host como config.port están configurados, activa una bandera y salta completamente el lanzamiento de un proceso. El paquete instalado te mostrará:
python
import inspect
import textwrap
from zendriver.core.browser import Browser
source = inspect.getsource(Browser.start).splitlines()
start = next(i for i, line in enumerate(source) if "connect_existing = False" in line)
print(textwrap.dedent("\n".join(source[start:start + 6])))
text
connect_existing = False
if self.config.host is not None and self.config.port is not None:
connect_existing = True
else:
self.config.host = "127.0.0.1"
self.config.port = util.free_port()
Eso es genuino: se conecta a un navegador que no inició. La limitación es lo que viene a continuación. El descubrimiento pasa a través de HTTPApi, que toma un par de host-y-puerto y lo interpolan en una plantilla de dirección fija, luego obtiene /json/version con urllib para aprender la URL de WebSocket del navegador. Puedes ver la forma de esa dirección sin salir de Python:
python
from zendriver.core.browser import HTTPApi
api = HTTPApi(("example-host", 9222))
print("scheme:", api.api.split("://")[0])
print("room for a query string:", "?" in api.api)
print("room for a path:", api.api.count("/") > 2)
text
scheme: http
room for a query string: False
room for a path: False
Tres cosas se rompen a la vez para un endpoint en la nube. El esquema está fijo en http puro. La dirección es un host y un puerto, por lo que no hay lugar para poner una ruta, una cadena de consulta o un token de autenticación. Y un endpoint alojado generalmente no expone las rutas de descubrimiento HTTP de CDP a Internet público: solicitar /json/version y /json/list en el host de Scrapeless devuelve HTTP 403 en ambos casos.
Así que connect_existing es para un Chrome que iniciaste tú mismo con --remote-debugging-port, en un host y puerto que controlas. No es una característica de conexión remota.
El verdadero bloqueo es la URL de la pestaña
Supongamos que el descubrimiento se resolviera. La conexión aún no sería utilizable, y la razón está un nivel por debajo de donde la mayoría de la gente busca.
zendriver construye conexiones de pestañas generándolas a partir del mismo host y puerto, interpoladas juntas con el id de destino en una ruta /devtools/page/<target_id>. Hace esto en dos lugares separados en browser.py, que puedes confirmar desde el paquete instalado:
python
import inspect
from zendriver.core import browser
source = inspect.getsource(browser)
print("tab addresses built from host and port:", source.count("{self.config.host}:{self.config.port}"))
print("devtools path template present:", "/devtools/" in source)
text
tab addresses built from host and port: 2
devtools path template present: True
Un Chrome local sirve esa ruta. Un navegador en la nube no lo hace: expone un endpoint, y las rutas de DevTools por objetivo no son parte de su superficie pública. Se probaron cuatro formas plausibles contra una sesión en vivo: con el token, sin él, bajo el prefijo /api/v2/browser, y como una ruta /page/. Las cuatro fueron rechazadas con HTTP 404.
Esta es la razón por la cual el problema no puede resolverse encontrando la URL correcta. No hay una URL por pestaña que encontrar.
Una URL CDP, Un Navegador
El siguiente instinto es abrir una segunda conexión para la pestaña. Eso tampoco funciona, y falla de una manera lo suficientemente silenciosa como para desperdiciar una tarde.
python
import asyncio
import os
from urllib.parse import urlencode
import zendriver as zd
from zendriver import cdp
def cdp_url():
return "wss://browser.scrapeless.com/api/v2/browser?" + urlencode(
{"token": os.environ["SCRAPELESS_KEY"], "sessionTTL": 300, "proxyCountry": "US"}
)
async def main():
a = zd.Connection(cdp_url())
b = zd.Connection(cdp_url())
tid = await a.send(cdp.target.create_target("https://books.toscrape.com/"))
a_ids = {str(t.target_id) for t in await a.send(cdp.target.get_targets())}
b_ids = {str(t.target_id) for t in await b.send(cdp.target.get_targets())}
print("A sees its own target:", str(tid) in a_ids)
print("B sees it:", str(tid) in b_ids)
print("shared target ids:", len(a_ids & b_ids))
await a.aclose()
await b.aclose()
asyncio.run(main())
text
A sees its own target: True
B sees it: False
shared target ids: 0
Cada conexión al endpoint es su propio navegador. Las dos sesiones no comparten nada: ni el destino que acabas de crear, ni un solo id de destino. Cualquier cosa construida sobre "un socket por pestaña" está silenciosamente dirigiendo un navegador diferente al que piensa.
Sin embargo, nota lo que sí funcionó. zd.Connection(cdp_url()) se conectó y respondió a los comandos CDP. El transporte nunca fue el problema.
¿Listo para controlar un navegador que no tuviste que iniciar? Crea una cuenta gratuita en Scrapeless y apunta zendriver hacia él.
Adjuntar una sesión plana
Todo tiene que viajar a través de una única conexión, que es para lo que sirve el método CDP Target.attachToTarget. Con flatten configurado, devuelve un sessionId, y cualquier frame que lleve ese sessionId se enruta al objetivo adjunto en lugar de al navegador.
zendriver no lo utiliza. Cada frame saliente se serializa por una propiedad, Transaction.message:
python
import inspect
from zendriver.core.connection import Transaction
body = inspect.getsource(Transaction.message.fget)
print(body.strip().splitlines()[-1].strip())
print("sessionId present:", "sessionId" in body)
text
return json.dumps({"method": self.method, "params": self.params, "id": self.id})
sessionId present: False
No hay un campo sessionId, por lo que los comandos siempre aterrizan en el navegador. Agregar uno es toda la integración: adjuntar la sesión, luego estamparlo en los frames a medida que salen.
python
import asyncio
import json
import os
from urllib.parse import urlencode
import zendriver as zd
from zendriver import cdp
CDP_URL = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode(
{"token": os.environ["SCRAPELESS_KEY"], "sessionTTL": 300, "proxyCountry": "US"}
)
class ScrapelessTab(zd.Tab):
"""A zendriver Tab bound to a remote browser over a single websocket."""
def __init__(self, url):
super().__init__(url, target=None)
self._session_id = None
async def open_page(self, url):
tid = await self.send(cdp.target.create_target(url))
infos = await self.send(cdp.target.get_targets())
self._target = next(t for t in infos if str(t.target_id) == str(tid))
self._session_id = await self.send(
cdp.target.attach_to_target(tid, flatten=True)
)
websocket, session_id = self.websocket, self._session_id
original_send = websocket.send
async def send_with_session(message, *args, **kwargs):
frame = json.loads(message)
frame.setdefault("sessionId", str(session_id))
return await original_send(json.dumps(frame), *args, **kwargs)
websocket.send = send_with_session
return self
async def main():
tab = ScrapelessTab(CDP_URL)
await tab.open_page("https://books.toscrape.com/")
print("session attached:", bool(tab._session_id))
await tab.aclose()
asyncio.run(main())
text
session attached: True
Subclassing Tab en lugar de Connection es deliberado: Tab ya lleva cada helper de página, y extiende Connection, así que un objeto tiene un socket, un listener y un mapa de respuestas. Pasar target=None al constructor está bien porque el objetivo real se asigna una vez que existe. Las respuestas llegan con el mismo id con el que se enviaron, por lo que el despachador existente de zendriver las resuelve sin toques.
Ejecutar una extracción real
Con la sesión adjunta, la API ordinaria funciona. Dos detalles de tiempo muerden primero, y ninguno es específico de navegadores remotos: ambos se comportan de la misma manera frente a uno lanzado localmente.
get_content() envía su comando inmediatamente en lugar de hacer polling. Llamarlo demasiado pronto devuelve el esqueleto de documento vacío, 39 caracteres, sin error en absoluto — lo que se lee como una conexión rota en lugar de una página que no se ha renderizado.
wait_for() hace polling, pero devuelve tan pronto como su selector coincide una vez. En una página cuyo marcado todavía está llegando, esa es una garantía más débil de lo que parece: seleccionar justo después de que wait_for("h3 a") devolvió 9 tarjetas en una ejecución local y 4 en la siguiente, contra 20 una vez que el DOM se estabilizó. Dale un momento a la página antes de contar cualquier cosa.
python
await tab.wait_for("h3 a", timeout=20)
await tab.sleep(2)
html = await tab.get_content()
print("fully rendered page:", len(html) > 40000)
print("catalogue marker present:", "All products" in html)
cards = await tab.select_all("article.product_pod")
print("product cards:", len(cards))
for card in cards[:5]:
link = await card.query_selector("h3 a")
price = await card.query_selector("p.price_color")
print(f" {link.attrs.get('title')} — {price.text}")
text
fully rendered page: True
catalogue marker present: True
product cards: 20
A Light in the Attic — £51.77
Tipping the Velvet — £53.74
Soumission — £50.10
Sharp Objects — £47.82
Sapiens: A Brief History of Humankind — £54.23
Los selectores y las consultas de elementos se comportan exactamente como lo hacen contra un navegador local, porque desde el lado de zendriver nada cambió excepto por qué socket pasan los frames. proxyCountry toma un código de dos letras cuando la solicitud necesita salir de un país en particular, y sessionTTL limita cuánto tiempo permanece abierta la sesión remota.
Conclusión
zendriver no tiene connect_over_cdp, y la razón no es que falte el transporte WebSocket — zd.Connection se comunica con un endpoint remoto en el primer intento. El obstáculo es que las conexiones de pestañas están construidas a partir de un host y un puerto, y un navegador en la nube no tiene una ruta por pestaña a la que apuntar.
Las sesiones planas cierran esa brecha. Una conexión, una llamada Target.attachToTarget, y un sessionId estampado en frames salientes convierte la propia Tab de la biblioteca en un identificador en una página remota, con cada helper de selector intacto.
Cuando un conjunto como este funciona mal, dos verificaciones suelen solucionarlo. Si los resultados parecen vacíos en lugar de erróneos, espera a un selector antes de leer el contenido — get_content() no espera. Si parece que se abren pestañas pero nunca se encuentra nada en ellas, confirma que no estás abriendo una segunda conexión, porque eso es un navegador diferente.
Para obtener información sobre el protocolo subyacente, consulta qué es el Protocolo de DevTools de Chrome y la comparación de nodriver y Patchright como herramientas de sigilo a nivel de controlador. Los detalles del plan están en la página de precios de Scrapeless, y los parámetros de sesión están en la documentación de Scrapeless.
FAQ
P: ¿zendriver tiene un método connect_over_cdp?
No. No hay un equivalente al connect_over_cdp de Playwright o al connect de Puppeteer. El comportamiento de connect_existing dentro de Browser.start() es cercano pero apunta a un host y puerto accesibles localmente, no a una URL de WebSocket remota con un token.
P: ¿Por qué no funciona establecer el host y el puerto a un punto final remoto?
Porque HTTPApi interpola un host y un puerto en una dirección HTTP simple fija y obtiene /json/version de ella. Una URL de WebSocket segura con una cadena de consulta no puede ser representada como un host y un puerto, y los puntos finales alojados generalmente no exponen esas rutas de descubrimiento públicamente.
P: ¿Puedo abrir varias pestañas en un solo navegador remoto?
Sí, pero deben compartir la conexión. Llama a Target.attachToTarget una vez por objetivo y sella el sessionId coincidente en los frames de esa pestaña. Abrir otra conexión al punto final te da un navegador separado en su lugar.
P: ¿Se aplica el parche anti-detección del fork a un navegador remoto?
Esos parches actúan sobre cómo se lanza y configura un navegador, por lo que pertenecen al proceso que inicia zendriver. Cuando te conectas a un navegador que no iniciaste, su configuración es la que estableció el proveedor, y zendriver actúa puramente como un cliente de protocolo.
P: ¿Por qué get_content() devolvió un documento casi vacío?
Porque no espera. Emite su comando de inmediato, por lo que una página que no ha terminado de renderizar devuelve el esqueleto del documento vacío — 39 caracteres — sin error. Espera a un selector con wait_for() primero.
P: ¿Necesito tener Chrome instalado localmente para la ruta remota?
No. No se lanza nada en tu máquina, por lo que no se requiere binario de navegador. Solo necesitas uno para ejecutar el ejemplo local de zd.start().
P: ¿Qué controla sessionTTL?
Cuánto tiempo se mantiene abierta la sesión del navegador remoto, en segundos. Establézcalo por encima del tiempo que necesita tu ejecución; la sesión termina cuando se cierra la conexión o expira la ventana, lo que ocurra primero.
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



