Playwright + スクレイピングブラウザ:隠れたJSON APIを傍受する
Senior Web Scraping Engineer
「引用」と書かれたページは、ゼロの引用を提供します。プレーンなHTTPクライアントでhttps://quotes.toscrape.com/scrollをリクエストすると、レスポンスボディには空の<div class="quotes"></div>が含まれており、実際のテキスト、著者、タグはその後、GET /api/quotes?page=Nコールを介して到着します。ブラウザがそれをHTMLにレンダリングしてからHTMLを解析するのを待つのではなく、そのコールを直接読み取るのはネットワークリクエストのインターセプションです。Chrome DevTools Protocolを介して実際のブラウザに接続し、それが行うトラフィックを監視し、サイトがすでに自分のために生成しているJSONを読み取ります。
このガイドでは、PlaywrightをScrapeless Scraping BrowserにCDPを通じて接続し、その隠れたエンドポイントを2つの方法でインターセプトします—Playwright独自のレスポンスイベントと、下層の生のCDP Networkドメイン—エンドポイントの形状が知られるようになったら、プレーンなHTTPを介して直接再生します。以下のすべてのコマンドは、ライブターゲットに対して実行されました。
DOMではなくワイヤーを読む理由
quotes.toscrape.com/scrollは、無限スクロールをデモンストレーションするために特に構築された公開のスクレイピング練習ターゲットであり、そのマークアップはインターセプションの必要性を示しています。ページをフェッチし、データを探します:
bash
curl -s https://quotes.toscrape.com/scroll | grep -o 'class="quote"' | wc -l
これを実行すると毎回ゼロが返ってきます。なぜなら、引用はサーバーが送信するHTMLには存在しないからです。小さなjQueryブロックが、読み込み時に$.get('/api/quotes', {page: page})を1回呼び出し、その後、スクロール位置が底に近づくたびに再度呼び出し、返された行を空のコンテナに手動で追加します。JavaScriptを実行し、十分に待つブラウザは最終的にそのDOM内に同じ10の引用を表示しますが、.quote要素を数え直し、<span class="text">や<small class="author">ノードからテキストを取り出す頃には、すでにクリーンで型付けされたJSONとしてページが持っているデータを手動で再構築しています。それは、text、authorオブジェクト、tagsリストを持つ各々のquotes配列、さらにいつ止まるべきかを正確に伝えるhas_nextフラグです。レスポンスを直接読むことで、再構築のステップを省き、ノードの代わりにフィールドを得ることができます。
前提条件
Python 3.9以上が必要です—playwright 1.59.0はPyPIでRequires-Python >=3.9と宣言しています—playwrightパッケージ自体、さらにapp.scrapeless.comの無料プランからのScrapeless APIキーが必要です。このキーはScraping BrowserのCDPエンドポイントであるtokenクエリパラメータとして送信されるため、スクリプト内のリテラルではなく環境変数に保持してください。ローカルのChromeインストールは必要ありません:connect_over_cdpは、すでにクラウドに存在するブラウザにアクセスします。
CDPを介してPlaywrightをScraping Browserに接続する
クライアントをインストールし、キーを設定します:
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="your_scrapeless_api_key"
Scraping BrowserのCDPエンドポイントは、wss://browser.scrapeless.com/api/v2/browserで、3つのクエリパラメータ—token、sessionTTL、およびproxyCountry—を使用して到達します。このシリーズのすべてのPlaywright-over-Scraping-Browserスクリプトが使う同じビルダーです:
python
import os
from urllib.parse import urlencode
API_KEY = os.environ["SCRAPELESS_API_KEY"]
def scraping_browser_url(proxy_country="US", session_ttl=180):
params = urlencode({
"token": API_KEY,
"sessionTTL": session_ttl,
"proxyCountry": proxy_country,
})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
chromium.connect_over_cdp(scraping_browser_url())は標準のPlaywright Browserオブジェクトを返します。以下のインターセプションパターンはScraping-Browser特有ではなく、CDPにアクセス可能な任意のChromiumで実行されますが、Scraping Browser上で実行すると、描画がすでに住宅用の出口と検出防止型Chromiumを持つインフラストラクチャで行われるため、攻撃的にフィンガープリンティングを行うサイトも通常通りにハイドレーションされます。
レスポンスリスナーで隠れたAPIをキャプチャする
Playwrightのpage.expect_response()は、アクションがトリガーする待機をバインドします—任意のスリープや、安定して見えるまでセレクタカウントをポーリングすることはありません。最初の/api/quotes呼び出しのためにpage.goto()の周りにバインドし、次に2回目をトリガーするスクロールの周りにバインドします:
python
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
def scraping_browser_url(proxy_country="US", session_ttl=180):
params = urlencode({
"token": API_KEY,
"sessionTTL": session_ttl,
"proxyCountry": proxy_country,
})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(scraping_browser_url())
page = browser.new_page()
with page.expect_response("**/api/quotes*") as first_page:
page.goto("https://quotes.toscrape.com/scroll", wait_until="domcontentloaded")
data = first_page.value.json()
ja
print("最初のレスポンスが到着する前のDOMの引用の数:", page.locator(".quote").count())
print(f"インターセプトしたページ {data['page']} -> {len(data['quotes'])} 件の引用、has_next={data['has_next']}")
with page.expect_response("**/api/quotes*") as second_page:
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
data = second_page.value.json()
print("スクロールが安定した後のDOMの引用の数:", page.locator(".quote").count())
print(f"インターセプトしたページ {data['page']} -> {len(data['quotes'])} 件の引用、has_next={data['has_next']}")
browser.close()
実際のサイトで実行すると、次のように出力されます:
text
最初のレスポンスが到着する前のDOMの引用の数: 10
インターセプトしたページ 1 -> 10 件の引用、has_next=True
スクロールが安定した後のDOMの引用の数: 20
インターセプトしたページ 2 -> 10 件の引用、has_next=True
ワイルドカードパターン "**/api/quotes*" は、URLの形によってエンドポイントに一致します。これは、特定のネットワークレスポンスを待つためのPlaywrightのドキュメント化されたパターンであり、固定のタイムアウトではありません。待機がトリガーアクションに結びついているため、data['page']、data['quotes']、および data['has_next'] は、サイトのスクリプトが消費する同じ型のPythonの値として戻されます — <small> タグから再構築された .author.name はなく、アンカーテキストから再構築されたタグリストもありません。
さらに下へ: CDPネットワークドメインで生のフレームを読む
Playwrightのレスポンスイベントは、Chrome DevToolsプロトコルネットワークドメインの上に存在し、CDPSessionを通じてそのドメインに直接話しかけることができます。これは、Playwrightを全く運転していない場合に重要です — 別の言語の生のCDPクライアント、プロトコルイベントのみを公開するツール、または特定のバインディングが表面化しないレスポンスヘッダーとタイミングが必要なケース — なぜなら、Network.responseReceived と Network.getResponseBody は、どのクライアントライブラリがその上に存在していても同じように機能するからです:
python
import os, json
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
def scraping_browser_url(proxy_country="US", session_ttl=180):
params = urlencode({
"token": API_KEY,
"sessionTTL": session_ttl,
"proxyCountry": proxy_country,
})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
request_ids, bodies = {}, []
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(scraping_browser_url())
page = browser.new_page()
cdp = page.context.new_cdp_session(page)
cdp.send("Network.enable")
def on_response_received(event):
if "/api/quotes" in event["response"]["url"]:
request_ids[event["requestId"]] = event["response"]["url"]
def on_loading_finished(event):
rid = event["requestId"]
if rid in request_ids:
raw = cdp.send("Network.getResponseBody", {"requestId": rid})
data = json.loads(raw["body"])
bodies.append((request_ids[rid], data["page"], len(data["quotes"]), data["quotes"][0]["author"]["name"]))
cdp.on("Network.responseReceived", on_response_received)
cdp.on("Network.loadingFinished", on_loading_finished)
page.goto("https://quotes.toscrape.com/scroll", wait_until="domcontentloaded")
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
for _ in range(20):
if len(bodies) >= 2:
break
page.wait_for_timeout(300)
for url, page_no, count, author in bodies:
print(f"CDP Network.getResponseBody on {url}: page={page_no}, quotes={count}, first_author={author}")
browser.close()
text
CDP Network.getResponseBody on https://quotes.toscrape.com/api/quotes?page=1: page=1, quotes=10, first_author=アルバート・アインシュタイン
CDP Network.getResponseBody on https://quotes.toscrape.com/api/quotes?page=2: page=2, quotes=10, first_author=マリリン・モンロー
Network.responseReceived は、レスポンスが開始されるとすぐにヘッダーと requestId を発火させます。ボディ自体は、Network.loadingFinished が転送の完了を確認するまで利用できません。これが、ハンドラーが二つのイベントに分かれている理由であり、最初のものからボディを読み取るのではありません。getResponseBody は、ブラウザが受け取った正確なバイトと、バイナリペイロード用の base64Encoded フラグを返します — Playwrightの response.json() がその上に構築し、あなたから隠す層です。
app.scrapeless.com にサインアップして無料のスクレイピングブラウザランタイムを入手し、上記の両方のスクリプトを自分のターゲットに対して実行してください。
形が分かったらブラウザをスキップする
以下の2つのインターセプションは同じことを証明しました: `/api/quotes?page=N` は、`quotes`、`page`、及び `has_next` を返す公開の認証を必要としないGETリクエストです。この形状を知ってしまえば、ブラウザはもはや必要なく、普通のHTTPクライアントを使ってコレクション全体を直接ページングすることができます:
```python
import json
import urllib.error
import urllib.request
def fetch_page(n):
url = f"https://quotes.toscrape.com/api/quotes?page={n}"
req = urllib.request.Request(url, headers={"User-Agent": "network-interception-demo/1.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
raise urllib.error.HTTPError(url, resp.status, "unexpected status", resp.headers, None)
return json.loads(resp.read())
all_quotes, page = [], 1
while True:
data = fetch_page(page)
all_quotes.extend(data["quotes"])
if not data["has_next"]:
break
page += 1
print("取得したページ数:", page)
print("合計名言数:", len(all_quotes))
print("最初の名言の著者:", all_quotes[0]["author"]["name"])
print("最後の名言の著者:", all_quotes[-1]["author"]["name"])
text
取得したページ数: 10
合計名言数: 100
最初の名言の著者: アルベルト・アインシュタイン
最後の名言の著者: ジョージ・R・R・マーティン
10回の普通のHTTPリクエストで、ブラウザセッションですでに確認済みの同じJSON契約を通じて、合計100の名言コレクションを引き出すことができます。これがインターセプションの実際の利点です:ブラウザの唯一の仕事はエンドポイントを明らかにすることでした。一度それを知ると、コレクションをページングする最速の方法は通常、レンダリングを止めてエンドポイントを直接呼び出すことです。これは、ブラウザと普通のHTTPクライアントの両方が最終的に実装するFetch標準に従います。
いつブラウザがまだ必要か
すべての隠れたエンドポイントがこのように協力的であるわけではありません。多くはサーバーが以前のページロード中に設定したセッションクッキー、ページのJavaScriptバンドルに組み込まれたCSRFや署名されたリクエストトークン、クライアントサイドにのみ存在するステートから組み立てられたリクエストボディが必要とします。また、REST形式のGETの代わりにWebSocketフレームやGraphQL POSTを通じて送信されるものもあります。これらの場合、前のセクションの直接リプレイステップは適用されません — キャプチャしていない認証ヘッダーを再構築することはできませんが、インターセプションステップは依然として適用されます。page.expect_response()およびCDPのNetworkドメインは、認証が何であるか、ペイロードの形が何であるかに関係なく、ページのトラフィックを読み取ります。なぜなら、ブラウザが実際に送信したものと受信したものを観察するためです。これらのサイトでは、すべてのページでブラウザをループに維持してください:セッションを作成し、ナビゲーションを実行し、応答が到着するたびにそれを渡します。
練習対象ではなく、完全なサイトに同じ「レンダリングしてからネットワークを読む」パターンを適用した実例については、実際のフィードをスクロールしながらコメントと投稿のXHRを同じ方法でキャプチャするTikTokスクレイピングガイドをご覧ください。
インターセプションは、抽出のステップと同じくらい発見のステップです。初めて見知らぬサイトに触れるときは、そのDevToolsネットワークパネルを開き、Fetch/XHRでフィルタリングし、ページと対話している間に何が発火するかを観察します — その手動のパスが、page.expect_response()をどのエンドポイントに結び付けるかを教えてくれます。エンドポイントが知られると、上記のすべて — 応答リスナー、生のCDPセッション、そして直接リプレイ — は、自身を実行するコードとしてエンコードされた同じ発見です。
app.scrapeless.com に登録して、Scraping Browserランタイムを無料で利用するか、Scraping Browser製品ページと価格をご覧ください。
よくある質問
Q: ウェブスクレイピングにおけるネットワークリクエストのインターセプションとは何ですか?
ページ自身のJavaScriptが生成するXHR/フェッチトラフィックを読み取ることです — 通常はJSON APIコールで、トラフィックがHTMLにレンダリングされるのを待ってからレンダリングされたマークアップを再解析するのではなく。
Q: サイトのAPIコールをインターセプトすることは法的ですか?
公開ページを訪問中にブラウザが受信するレスポンスの読み取りは、認証の背後にあるコンテンツにアクセスしたり、非公開データに到達したりすることとは異なる考慮事項があります。ワークフローの範囲は公開ページに限定し、ターゲットのサービス利用規約やロボット指令を尊重し、リクエストのボリュームを制限してください。インターセプションをトラフィックをより正確に読む方法として扱い、アクセスルールを無視するためのライセンスとしては扱わないでください。
Q: 隠れたエンドポイントを知ったら、ブラウザはまだ必要ですか?
ブラウザが提供する何か(セッションクッキー、署名トークン、JavaScriptで計算された状態)が必要な場合のみ必要です。このガイドのような公開されている認証されていないエンドポイントは、プレーンなHTTPクライアントで再生できることが直接再生の例で示されています。
Q: page.expect_response()と生のCDP Networkドメインをリスニングすることの違いは何ですか?
page.expect_response()はPlaywrightの高レベルなラッパーです:URLパターンにバインドし、アクションをトリガーし、解析されたResponseオブジェクトを取得します。CDP Networkドメインはそれを下支えしているプロトコルです — Network.responseReceivedやNetwork.getResponseBody — Playwrightバインディングを全く使用していない場合や、特定のクライアントライブラリが公開していないプロトコルレベルの詳細が必要な場合に便利です。
Q: JSON以外のものを返すエンドポイントでも機能しますか?
両方のインターセプションパターンは、レスポンスが運ぶバイトを読み取ります — Playwrightのresponse.text()やresponse.body()、CDPのNetwork.getResponseBodyからの生のbodyフィールドです — そのため、HTMLフラグメント、XML、または他のペイロードも同じ方法で通過します。JSONは単にページの内部APIの一般的なケースです。
Q: page.expect_response()は、見るページが読み込まれる前に発火したリクエストをキャッチできますか?
できません — トリガーアクションが実行される前にリスニングしていなければなりません。これは、リクエストを引き起こす特定のpage.goto()またはインタラクションをラップする理由です。
Q: インターセプションにはScrapeless Scraping Browser特有のものが必要ですか、それともCDPに到達可能な任意のChromiumで機能しますか?
この技術自体は一般的なCDPの動作であり、connect_over_cdpを介して到達できる任意のChromiumで機能します。Scrapeless Scraping Browserで実行すると、検出防止機能を備えたChromiumと住宅のエグレスが追加されます。これは、ターゲットが積極的に指紋を生成しすぎて、プレーンなローカルブラウザがトラフィックを生成するためにレンダリングを通過できない場合に重要です。
Q: サイトがエンドポイントやレスポンスの形状を変更した場合、どうなりますか?
インターセプションコードは、URLパターンがまだ一致していれば動作し続けます。フィールドの名称変更やペイロードの再構造化は、data['quotes']を読み取るコードを壊します。CSSセレクタがクラス名の変更で壊れるのと同じです。どちらのアプローチも再設計には無抵抗です — APIを読むことは、マークアップ構造の代わりにJSON契約を追跡することを意味するだけで、後者の方があまり頻繁には変わらない傾向があります。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



