🎯 Um navegador em nuvem personalizável e anti-detecção alimentado por Chromium desenvolvido internamente, projetado para rastreadores web e agentes de IA. 👉Experimente agora
De volta ao blog

zendriver + Scrapeless: Controlando um Navegador Remoto Através do CDP

Sophia Martinez
Sophia Martinez

Specialist in Anti-Bot Strategies

07-Aug-2026

TL;DR:

  • zendriver é um fork da comunidade do nodriver que controla o Chrome através do CDP bruto, e assim como o nodriver, é construído para iniciar o próprio navegador.
  • Browser.start() tem um caminho connect_existing, mas a descoberta passa por HTTPApi, que codifica em dureza o HTTP simples com um host e uma porta — um endpoint WebSocket seguro transportando um token não pode ser expresso dessa maneira.
  • O verdadeiro bloqueio é o endereço da aba. zendriver constrói cada aba a partir de um host, uma porta e um id alvo, e um endpoint remoto não serve tal rota.
  • Uma URL do CDP significa um navegador: uma segunda conexão ao mesmo endpoint obtém um navegador remoto diferente e não vê nenhum dos alvos do primeiro.
  • A costura é a multiplexação de sessão plana — Target.attachToTarget(flatten=True) retorna um sessionId, e estampá-lo nos quadros de saída faz os próprios auxiliares Tab do zendriver alcançarem a página remota.
  • Com a sessão anexada, uma extração real retornou uma página totalmente renderizada e 20 cartões de produto com títulos e preços corretos.
  • Comece no plano gratuito do Scrapeless e controle um navegador que você não teve que iniciar.

O Navegador de Extração Scrapeless é um navegador em nuvem que você se conecta em vez de lançar. Ele escuta em um endpoint CDP WebSocket seguro, que é o mesmo protocolo que o zendriver usa — então, em teoria, os dois deveriam se encontrar em uma linha.

Eles não se encontram. O Playwright tem connect_over_cdp. O Puppeteer tem connect. O zendriver não tem equivalente, e o pedido por um está registrado no rastreador desde abril de 2025 como um problema aberto pedindo exatamente isso, sem resposta. O tutorial oficial do raw-CDP cobre page.send() e manipuladores de eventos e nunca menciona navegadores remotos.

Este guia explica o que realmente está no caminho — que não é a conexão — e então anexa um navegador remoto usando nada além das próprias primitivas da biblioteca.

zendriver é um fork do nodriver criado para mesclar correções de bugs não mescladas e reabrir o projeto para contribuições. Arquitetonicamente, os dois têm a mesma ideia: controlar o Chrome diretamente sobre o Protocolo DevTools do Chrome sem camada de WebDriver entre eles, o que remove a superfície de automação que as pilhas baseadas em driver expõem. O fork adiciona entrada padrão asyncio.run(), suporte a Docker e persistência de cookies.

O ponto de entrada normal inicia um navegador:

python Copy
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 Copy
cards: 20

no_sandbox=True é necessário quando o processo é executado como root, que é o caso normal dentro de um contêiner; coloque-o em uma área de trabalho. A chamada sleep ganha seu lugar por um motivo que será abordado mais adiante, e esse motivo se aplica igualmente ao caminho remoto.

Isso cria o Chrome como um processo filho na máquina que executa o script. Cada guia do zendriver no conjunto de resultados funciona dessa maneira: controlar um navegador que o script iniciou, e moldá-lo através de browser_args — um switch --proxy-server do Chromium sendo o exemplo usual. Nenhum deles se conecta a um navegador que não foi criado.

Pré-requisitos

  • Python 3.10 ou posterior.
  • Uma chave de API Scrapeless do painel, exportada como SCRAPELESS_KEY.
  • Uma instalação local do Chrome ou Chromium, somente se você quiser rodar o bloco de inicialização local acima. O caminho remoto não precisa de um binário de navegador.

Instalação

bash Copy
pip install "zendriver==0.15.5"
bash Copy
export SCRAPELESS_KEY="your_api_key_here"

O que connect_existing Realmente Faz

Ler Browser.start() dá a impressão de que a anexação remota já é suportada. Quando tanto config.host quanto config.port estão configurados, ele ativa uma bandeira e pula totalmente o lançamento de um processo. O pacote instalado lhe mostrará:

python Copy
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 Copy
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()

Isso é genuíno — ele se conecta a um navegador que não iniciou. A limitação é o que vem a seguir. A descoberta passa por HTTPApi, que pega um par host-e-porta e o interpola em um template de endereço fixo, e, então, busca /json/version com urllib para aprender a URL WebSocket do navegador. Você pode ver a forma desse endereço sem sair do Python:

python Copy
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 Copy
scheme: http
room for a query string: False
room for a path: False

Três coisas quebram de uma vez para um endpoint em nuvem. O esquema é fixo para http simples. O endereço é um host e uma porta, então não há lugar para colocar um caminho, uma string de consulta ou um token de autenticação. E um endpoint hospedado geralmente não expõe as rotas de descoberta HTTP do CDP para a internet pública — solicitando /json/version e /json/list no host Scrapeless retorna HTTP 403 em ambos os casos.
Então connect_existing é para um Chrome que você iniciou por conta própria com --remote-debugging-port, em um host e porta que você controla. Não é um recurso de anexar remotamente.

O verdadeiro bloqueador é a URL da aba

Suponha que a descoberta fosse resolvida. A conexão ainda não seria utilizável, e a razão está uma camada abaixo de onde a maioria das pessoas procura.

zendriver constrói conexões de abas criando-as a partir do mesmo host e porta, intercaladas juntas com o id de destino em um caminho /devtools/page/<target_id>. Isso é feito em dois lugares separados em browser.py, que você pode confirmar a partir do pacote instalado:

python Copy
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 Copy
tab addresses built from host and port: 2
devtools path template present: True

Um Chrome local serve essa rota. Um navegador em nuvem não - ele expõe um endpoint, e os caminhos do DevTools por destino não fazem parte de sua superfície pública. Quatro formas plausíveis foram testadas contra uma sessão ao vivo: com o token, sem ele, sob o prefixo /api/v2/browser, e como uma rota /page/. Todas as quatro foram rejeitadas com HTTP 404.

É por isso que o problema não pode ser resolvido encontrando a URL certa. Não há URL por aba a ser encontrada.

O próximo instinto é abrir uma segunda conexão para a aba. Isso também não funciona, e falha de uma maneira que é discreta o suficiente para desperdiçar uma tarde.

python Copy
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 Copy
A sees its own target: True
B sees it: False
shared target ids: 0

Cada conexão ao endpoint é seu próprio navegador. As duas sessões não compartilham nada - nem mesmo o alvo que você acabou de criar, nem um único id de alvo. Qualquer coisa construída em "um socket por aba" está silenciosamente dirigindo um navegador diferente do que pensa.

Observe o que funcionou, porém. zd.Connection(cdp_url()) conectou-se e respondeu a comandos CDP. O transporte nunca foi o problema.

Pronto para controlar um navegador que você não precisou lançar? Crie uma conta gratuita no Scrapeless e aponte o zendriver para ele.

Anexar uma Sessão Plana

Tudo tem que viajar pela única conexão, que é para isso que o método CDP Target.attachToTarget serve. Com flatten configurado, ele retorna um sessionId, e qualquer quadro que carregue esse sessionId é roteado para o alvo anexado em vez do navegador.

zendriver não o utiliza. Cada quadro de saída é serializado por uma propriedade, Transaction.message:

python Copy
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 Copy
return json.dumps({"method": self.method, "params": self.params, "id": self.id})
sessionId present: False

Não há campo sessionId, então os comandos sempre chegam ao navegador. Adicionar um é toda a integração: anexar a sessão, depois estampá-la nos quadros na saída.

python Copy
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 Copy
session attached: True

Substituir Tab em vez de Connection é deliberado: Tab já carrega cada auxiliar de página, e estende Connection, então um objeto contém um socket, um ouvinte e um mapa de respostas. Passar target=None para o construtor está bem porque o verdadeiro alvo é atribuído assim que existe. As respostas chegam com o mesmo id com que foram enviadas, então a distribuição existente do zendriver as resolve intactas.

Realizar uma Extração Real

Com a sessão anexada, a API comum funciona. Dois detalhes de tempo pegam primeiro, e nenhum é específico para navegadores remotos - ambos se comportam da mesma forma em relação a um lançado localmente.

get_content() envia seu comando imediatamente em vez de fazer polling. Chamá-lo muito cedo retorna a estrutura vazia do documento, 39 caracteres, sem erro algum - o que parece uma conexão quebrada em vez de uma página que não foi renderizada.

wait_for() faz polling, mas retorna assim que seu seletor corresponde uma vez. Em uma página cujo markup ainda está chegando, isso é uma garantia mais fraca do que parece: selecionar logo após wait_for("h3 a") retornou 9 cartões em uma execução local e 4 na próxima, contra 20 assim que o DOM se estabilizou. Dê um momento à página antes de contar qualquer coisa.

python Copy
    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 Copy
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

Seletores e consultas de elementos se comportam exatamente como fazem contra um navegador local, porque do lado do zendriver nada foi alterado, exceto qual socket os quadros passam. proxyCountry aceita um código de duas letras quando a solicitação precisa sair de um país específico, e sessionTTL limita quanto tempo a sessão remota permanece aberta.

Conclusão

zendriver não tem connect_over_cdp, e a razão não é que o transporte WebSocket esteja ausente - zd.Connection fala com um endpoint remoto na primeira tentativa. O obstáculo é que as conexões de aba são construídas a partir de um host e uma porta, e um navegador em nuvem não tem uma rota por aba para apontá-las.

Sessões planas fecham essa lacuna. Uma conexão, uma chamada Target.attachToTarget, e um sessionId estampado em quadros de saída transforma a própria Tab da biblioteca em um manipulador de uma página remota, com todos os auxiliares de seletor intactos.
Quando uma configuração como esta não se comporta, duas verificações geralmente resolvem. Se os resultados parecem vazios em vez de errados, aguarde por um seletor antes de ler o conteúdo — get_content() não espera. Se as abas parecem abrir, mas nada é encontrado nelas, confirme se você não está abrindo uma segunda conexão, pois isso é um navegador diferente.

Para informações de fundo sobre o protocolo subjacente, veja o que é o Protocolo Chrome DevTools e a comparação entre nodriver e Patchright como ferramentas de furtividade em nível de driver. Os detalhes do plano estão na página de preços do Scrapeless, e os parâmetros da sessão estão na documentação do Scrapeless.

FAQ

Q: O zendriver tem um método connect_over_cdp?

Não. Não há equivalente ao connect_over_cdp do Playwright ou ao connect do Puppeteer. O comportamento de connect_existing dentro de Browser.start() é próximo, mas visa um host e porta acessíveis localmente, e não uma URL de WebSocket remota com um token.

Q: Por que definir host e porta para um endpoint remoto não funciona?

Porque HTTPApi injeta um host e uma porta em um endereço fixo de HTTP simples e obtém /json/version dele. Uma URL de WebSocket segura com uma string de consulta não pode ser representada como um host e uma porta, e endpoints hospedados geralmente não expõem essas rotas de descoberta publicamente.

Q: Posso abrir várias abas em um navegador remoto?

Sim, mas elas devem compartilhar a conexão. Chame Target.attachToTarget uma vez por destino e carimbe o sessionId correspondente nos frames daquela aba. Abrir outra conexão com o endpoint dá a você um navegador separado.

Q: O patch de anti-detecção do fork ainda se aplica a um navegador remoto?

Esses patches agem sobre como um navegador é iniciado e configurado, então pertencem ao processo que o zendriver inicia. Quando você se conecta a um navegador que não lançou, sua configuração é o que o provedor definiu, e o zendriver age puramente como um cliente de protocolo.

Q: Por que get_content() retornou um documento quase vazio?

Porque não espera. Emite seu comando imediatamente, então uma página que não terminou de renderizar retorna o esqueleto do documento vazio — 39 caracteres — sem erro. Aguarde por um seletor com wait_for() primeiro.

Q: Preciso do Chrome instalado localmente para o caminho remoto?

Não. Nada é iniciado na sua máquina, então nenhum binário de navegador é necessário. Você só precisa de um para executar o exemplo local de zd.start().

Q: O que o sessionTTL controla?

Quanto tempo a sessão do navegador remoto permanece aberta, em segundos. Defina-o acima do tempo que sua execução precisa; a sessão termina quando a conexão fecha ou a janela expira, o que acontecer primeiro.

Na Scorretless, acessamos apenas dados disponíveis ao público, enquanto cumprem estritamente as leis, regulamentos e políticas de privacidade do site aplicáveis. O conteúdo deste blog é apenas para fins de demonstração e não envolve atividades ilegais ou infratoras. Não temos garantias e negamos toda a responsabilidade pelo uso de informações deste blog ou links de terceiros. Antes de se envolver em qualquer atividade de raspagem, consulte seu consultor jurídico e revise os termos de serviço do site de destino ou obtenha as permissões necessárias.

Artigos mais populares

Catálogo