zendriver + Scrapeless: CDP経由でリモートブラウザを操作する
Specialist in Anti-Bot Strategies
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
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は、プロセスがルートとして実行されているときに必要です。これはコンテナ内の通常のケースです;デスクトップにドロップしてください。sleep呼び出しは、さらに下で説明される理由からその場所を獲得しており、その理由はリモートパスにも同様に適用されます。
それは、スクリプトが実行されているマシン上でChromeを子プロセスとして生成します。結果セット内のすべてのzendriverガイドはこの方法で機能します:スクリプトが自ら起動したブラウザを操作し、browser_argsで形を整えます — Chromium --proxy-serverスイッチが通常の例です。どれも自分が生成していないブラウザには接続しません。
Prerequisites
- Python 3.10以降。
- ダッシュボードからのScrapeless APIキー、
SCRAPELESS_KEYとしてエクスポート。 - ローカルChromeまたはChromiumのインストール、上記のローカル起動ブロックを実行したい場合のみ。リモートパスにはブラウザバイナリは不要です。
Install
bash
pip install "zendriver==0.15.5"
bash
export SCRAPELESS_KEY="your_api_key_here"
What connect_existing Actually Does
Browser.start()を読むと、リモート接続がすでにサポートされているという印象を受けます。両方のconfig.hostとconfig.portが設定されると、フラグが反転し、プロセスの起動を完全にスキップします。インストールされたパッケージは次のように表示します:
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()
それは本物です — 自分がスタートしていないブラウザに接続します。制限は次に来るものです。発見はHTTPApiを介して行われ、ホストとポートのペアを取り、それを固定アドレステンプレートに補間し、/json/versionをurllibで取得してブラウザのWebSocket URLを学びます。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
クラウドエンドポイントの場合、三つのことが同時に壊れます。スキームはプレーン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
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
ローカルの Chrome がそのルートを提供します。クラウドブラウザはそうではなく、エンドポイントを一つだけ公開し、対象ごとの DevTools パスはその公開サーフェスの一部ではありません。生セッションに対して4つの有望な形が試されました:トークンあり、なし、/api/v2/browser プレフィックス下、および /page/ ルートとして。全てが HTTP 404 で拒否されました。
これが問題が正しい URL を見つけることで解決できない理由です。見つけるべきタブごとの URL はありません。
1つの CDP URL、1つのブラウザ
次の本能は、タブ用に2番目の接続を開くことです。それもうまくいかず、静かに午後を無駄にするほどで失敗します。
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
エンドポイントへの各接続はそれぞれ独自のブラウザです。2つのセッションは何も共有していません ― たった今作成したターゲットや、単一のターゲット ID すらありません。「タブごとに1つのソケット」を基に構築されたものは、自身が考えているとは異なるブラウザを静かに駆動しています。
ただし、うまくいったことに注意してください。zd.Connection(cdp_url()) は接続して CDP コマンドに応答しました。輸送が問題だったことはありません。
起動する必要のないブラウザを操作する準備はできましたか? 無料の Scrapeless アカウントを作成する して、zendriver をそこにポイントしてください。
フラットセッションを添付する
すべては1つの接続を介して移動しなければならず、これが CDP Target.attachToTarget メソッド の目的です。flatten が設定されていると、sessionId が返され、その sessionId を持つフレームはブラウザの代わりに接続されたターゲットにルーティングされます。
zendriver はそれを使用しません。すべての送信フレームは1つのプロパティによってシリアライズされます、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
sessionId フィールドは存在しないため、コマンドは常にブラウザに届きます。一つを追加することが全体の統合です:セッションを添付し、次に出力時にフレームにスタンプします。
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
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
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
セレクタと要素クエリは、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サイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



