🎯 カスタマイズ可能で検出回避型のクラウドブラウザ。自社開発のChromiumを搭載し、ウェブクローラーAIエージェント向けに設計されています。👉今すぐ試す
ブログに戻ります

zendriver + Scrapeless: CDP経由でリモートブラウザを操作する

Sophia Martinez
Sophia Martinez

Specialist in Anti-Bot Strategies

07-Aug-2026

TL;DR:

  • zendriverは、raw CDPを介してChromeを操作するnodriverのコミュニティフォークであり、nodriverと同様にブラウザ自体を起動するために構築されています。
  • Browser.start()にはconnect_existingパスがありますが、発見はHTTPApiを通じて行われ、ホストとポートでプレーンHTTPをハードコードします — トークンを持つセキュアなWebSocketエンドポイントはそのようには表現できません。
  • 実際の阻害要因はタブアドレスです。zendriverは、ホスト、ポート、ターゲットIDから各タブを構築し、リモートエンドポイントはそのようなルートを提供しません。
  • 一つのCDP URLは一つのブラウザを意味します:同じエンドポイントへの二回目の接続は異なるリモートブラウザを取得し、最初のもののターゲットは見えません。
  • シームはフラットセッションマルチプレクシングです — Target.attachToTarget(flatten=True)はsessionIdを返し、それを外向きのフレームにスタンプすることでzendriver自身のTabヘルパーがリモートページに到達します。
  • セッションが添付されると、実際の抽出は完全にレンダリングされたページと正しいタイトルと価格の20の製品カードを返しました。
  • Scrapelessの無料プランに登録し、起動する必要のないブラウザを操作してください。

Scrapeless Scraping Browserは、起動するのではなく接続するクラウドブラウザです。これはセキュアなWebSocket CDPエンドポイントでリスニングしており、zendriverが使用するプロトコルと同じです — 理論的には、二つは一つの行で合流するはずです。

しかし、実際にはそうではありません。Playwrightにはconnect_over_cdpがあります。Puppeteerにはconnectがあります。zendriverには同等のものはなく、その要求は2025年4月から< a href="https://github.com/cdpdriver/zendriver/issues/98" rel="nofollow">正確にこれを要求しているオープンな問題としてトラッカーに留まっていますが、未回答です。公式のraw-CDPチュートリアルはpage.send()とイベントハンドラについてカバーしていますが、リモートブラウザについては一切言及されていません。

このガイドは、実際に何が妨げとなっているのかを明らかにします — それは接続ではなく — そしてライブラリ自身のプリミティブだけを使用してリモートブラウザを接続します。

How zendriver Starts a Browser

zendriverは、マージされていないバグ修正を統合するために作成されたnodriverのフォークで、プロジェクトへの貢献を再開します。アーキテクチャ的には、二つは同じアイデアです:WebDriverレイヤーが介在しない形でChrome DevTools Protocolを介してChromeを直接操作し、ドライバーベースのスタックが露出させる自動化層を取り除きます。このフォークは、標準のasyncio.run()エントリ、Dockerサポート、およびクッキーの永続性を追加しています。

通常のエントリポイントはブラウザを起動します:

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は、プロセスがルートとして実行されているときに必要です。これはコンテナ内の通常のケースです;デスクトップにドロップしてください。sleep呼び出しは、さらに下で説明される理由からその場所を獲得しており、その理由はリモートパスにも同様に適用されます。

それは、スクリプトが実行されているマシン上でChromeを子プロセスとして生成します。結果セット内のすべてのzendriverガイドはこの方法で機能します:スクリプトが自ら起動したブラウザを操作し、browser_argsで形を整えます — Chromium --proxy-serverスイッチが通常の例です。どれも自分が生成していないブラウザには接続しません。

Prerequisites

  • Python 3.10以降。
  • ダッシュボードからのScrapeless APIキー、SCRAPELESS_KEYとしてエクスポート。
  • ローカルChromeまたはChromiumのインストール、上記のローカル起動ブロックを実行したい場合のみ。リモートパスにはブラウザバイナリは不要です。

Install

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

What connect_existing Actually Does

Browser.start()を読むと、リモート接続がすでにサポートされているという印象を受けます。両方のconfig.hostconfig.portが設定されると、フラグが反転し、プロセスの起動を完全にスキップします。インストールされたパッケージは次のように表示します:

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

それは本物です — 自分がスタートしていないブラウザに接続します。制限は次に来るものです。発見はHTTPApiを介して行われ、ホストとポートのペアを取り、それを固定アドレステンプレートに補間し、/json/versionurllibで取得してブラウザのWebSocket URLを学びます。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

クラウドエンドポイントの場合、三つのことが同時に壊れます。スキームはプレーンhttpに固定されています。アドレスはホストとポートなので、パス、クエリ文字列、または認証トークンを置く場所がありません。そして、ホスティングされたエンドポイントは一般的にCDP HTTP発見ルートをパブリックインターネットに公開しません — Scrapelessホストで/json/versionおよび/json/listを要求すると、両方の場合にHTTP 403が返されます。
connect_existing は、--remote-debugging-port で自分で開始した Chrome で、あなたが制御するホストとポート上にあるものです。これはリモートアタッチ機能ではありません。

実際のブロッカーはタブの URL

発見が解決されたと仮定しましょう。接続はまだ利用できず、その理由はほとんどの人が見る場所の一層下にあります。

zendriver は、同じホストとポートから文字列ビルディングによってタブ接続を構築し、それをターゲット ID と補完して /devtools/page/<target_id> パスにします。これは browser.py の2つの異なる場所で行われており、インストールされたパッケージから確認できます:

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

ローカルの Chrome がそのルートを提供します。クラウドブラウザはそうではなく、エンドポイントを一つだけ公開し、対象ごとの DevTools パスはその公開サーフェスの一部ではありません。生セッションに対して4つの有望な形が試されました:トークンあり、なし、/api/v2/browser プレフィックス下、および /page/ ルートとして。全てが HTTP 404 で拒否されました。

これが問題が正しい URL を見つけることで解決できない理由です。見つけるべきタブごとの URL はありません。

1つの CDP URL、1つのブラウザ

次の本能は、タブ用に2番目の接続を開くことです。それもうまくいかず、静かに午後を無駄にするほどで失敗します。

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

エンドポイントへの各接続はそれぞれ独自のブラウザです。2つのセッションは何も共有していません ― たった今作成したターゲットや、単一のターゲット ID すらありません。「タブごとに1つのソケット」を基に構築されたものは、自身が考えているとは異なるブラウザを静かに駆動しています。

ただし、うまくいったことに注意してください。zd.Connection(cdp_url()) は接続して CDP コマンドに応答しました。輸送が問題だったことはありません。

起動する必要のないブラウザを操作する準備はできましたか? 無料の Scrapeless アカウントを作成する して、zendriver をそこにポイントしてください。

フラットセッションを添付する

すべては1つの接続を介して移動しなければならず、これが CDP Target.attachToTarget メソッド の目的です。flatten が設定されていると、sessionId が返され、その sessionId を持つフレームはブラウザの代わりに接続されたターゲットにルーティングされます。

zendriver はそれを使用しません。すべての送信フレームは1つのプロパティによってシリアライズされます、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

sessionId フィールドは存在しないため、コマンドは常にブラウザに届きます。一つを追加することが全体の統合です:セッションを添付し、次に出力時にフレームにスタンプします。

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

Tab ではなく Connection をサブクラス化することは意図的です:Tab はすでにすべてのページヘルパーを持ち、Connection を拡張しているため、1つのオブジェクトが1つのソケット、1つのリスナー、および1つの応答マップを保持します。コンストラクタに target=None を渡すことは、実際のターゲットが存在するように割り当てられるので問題ありません。返信は送信された際の同じ id で到着し、zendriver の既存のディスパッチはそれらを未変更のままで解決します。

実際のデータ抽出を実行する

セッションが添付されると、通常の API が機能します。2つのタイミングの詳細が最初に問題になり、どちらもリモートブラウザに特有ではありません ― 両方ともローカルに起動されたブラウザに対して同じように振る舞います。

get_content() は、ポーリングではなく即座にコマンドを送信します。早すぎるタイミングで呼び出すと、エラーなしで39文字の空のドキュメントスケルトンが返されます ― これはレンダリングされていないページではなく、接続が壊れたように見えます。

wait_for() はポーリングしますが、セレクタが1回一致するやいなや返ります。まだマークアップが届いているページでは、それは見た目ほど強い保証ではありません:wait_for("h3 a") の後すぐに選択すると、1回のローカル実行で9枚のカードが返り、次の実行で4枚が返り、DOM が安定したときには20枚になりました。何かを数える前にページに少し時間を与えてください。

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

セレクタと要素クエリは、zendriver の側から何が変わったかというと、フレームが通過するソケットがどれであるかだけで、ローカルブラウザに対して行うのと全く同じように動作します。proxyCountry は、リクエストが特定の国から出る必要があるときに2文字のコードを受け取り、sessionTTL はリモートセッションがどれだけ長くオープンしているかを制限します。

結論

zendriver には connect_over_cdp がなく、その理由は WebSocket 輸送が欠けているのではありません — zd.Connection は初回の試行でリモートエンドポイントに話しかけます。障害は、タブ接続がホストとポートから文字列ビルドされていることであり、クラウドブラウザにはそれらを指すタブごとのルートがありません。

フラットセッションがそのギャップを埋めます。1つの接続、1つの Target.attachToTarget 呼び出し、そして出力フレームにスタンプされた sessionId は、ライブラリの Tab をリモートページのハンドルに変え、すべてのセレクタヘルパーをそのまま保持します。
このようなセットアップが正常に動作しない場合、通常は2つのチェックで解決します。結果が間違っているのではなく空に見える場合は、内容を読む前にセレクタを待ってください — get_content() は待機しません。タブが開いているように見えるが、何も見つからない場合は、別の接続を開いていないことを確認してください。それは異なるブラウザです。

プロトコルのバックグラウンドについては、Chrome DevToolsプロトコルとは何かnodriverとPatchrightのドライバーレベルのステルスツールの比較 を参照してください。プランの詳細は Scrapelessの料金ページ にあり、セッションパラメータは Scrapelessのドキュメント にあります。

FAQ

Q: zendriverにはconnect_over_cdpメソッドがありますか?

いいえ。Playwrightの connect_over_cdp やPuppeteerの connect に相当するものはありません。 connect_existing の動作は Browser.start() 内で近いですが、リモートのWebSocket URLとトークンではなく、ローカルで到達可能なホストとポートを対象とします。

Q: リモートエンドポイントにホストとポートを設定しても機能しないのはなぜですか?

HTTPApi がホストとポートを固定のプレーンHTTPアドレスに補完し、そこから /json/version を取得するためです。クエリストリングを持つセキュアWebSocket URLは、ホストとポートとして表現できず、ホスティングされたエンドポイントは一般にそれらの発見ルートを公開していません。

Q: 1つのリモートブラウザで複数のタブを開くことはできますか?

はい、ただし接続を共有する必要があります。ターゲットごとに Target.attachToTarget を1回呼び出し、そのタブのフレームに一致するsessionIdをスタンプしてください。エンドポイントに別の接続を開くと、別のブラウザが得られます。

Q: フォークのアンチ検出パッチはリモートブラウザに適用されますか?

これらのパッチはブラウザの起動と構成の仕方に作用するため、zendriverが開始するプロセスに属します。自分が起動していないブラウザに接続すると、その構成はプロバイダが設定したものであり、zendriverはプロトコルクライアントとしてのみ動作しています。

Q: get_content() がほぼ空のドキュメントを返すのはなぜですか?

それは待機しないからです。コマンドをすぐに発行するため、レンダリングが完了していないページは空のドキュメントスケルトン — 39文字 — をエラーなしで返します。最初に wait_for() でセレクタが待機してください。

Q: リモートパスにChromeをローカルにインストールする必要がありますか?

いいえ。あなたのマシンでは何も起動しないため、ブラウザバイナリは必要ありません。ローカルの zd.start() の例を実行するためにだけ1つ必要です。

Q: sessionTTLは何を制御しますか?

リモートブラウザセッションがオープンのままでいる時間(秒単位)です。実行に必要な時間を超えて設定してください; セッションは接続が閉じられるか、ウィンドウが期限切れになると終了します。どちらが先に来るかです。

Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。

最も人気のある記事

カタログ