プレイライト + スクレイプレス スクレイピング ブラウザ: 隠れた GraphQL API のキャプチャとリプレイ
Web Data Collection Specialist
rickandmortyapi.com/graphqlのネットワークタブを開いて、1分間見てみましょう:ページが読み込まれた瞬間にGraphiQLが発火させるスキーマイントロスペクションコールと、少し後に自分で入力して実行するクエリは、どちらも全く同じURLに届きます。REST APIはその動作をパスに分散させます — /characters、/episodes、/locations/1 — そのためURLだけでリクエストの目的が分かります。一方、GraphQL APIはそれらすべてを1つのエンドポイントにまとめ、実際のリクエストをPOSTボディに移動させます:欲しいフィールドを名前付けるquery文字列、引数を供給するvariablesオブジェクト、時にはこれがどのものであるかを識別するoperationNameタグです。そのトラフィックを読むことは、ボディを読むことを意味し、URLではありません。なぜならURLは信号を運ぶことができなくなったからです。
このガイドは、PlaywrightをScrapeless Scraping BrowserにCDPを介して接続し、実際の公開GraphQLプレイグラウンドを利用してクエリを発火させ、その結果のPOSTを2つの独立した方法でインターセプトします — Playwright自身のレスポンスイベントと、それらの下にある生のCDP Networkドメイン — そしてその正確なリクエストをプレーンHTTPクライアントとブラウザなしで再生します。以下のすべてのコマンドはライブターゲットに対して実行されました。
1つのエンドポイント、すべての操作
https://rickandmortyapi.graphcdn.app/は、Rick and Morty APIのGraphiQLプレイグラウンドが実際に呼び出すアドレスであり、ドキュメントが宣伝するよりフレンドリーなrickandmortyapi.com/graphqlエイリアスの1つのレイヤー後ろにあります。これは、そのエイリアスへのリクエストとCDNアドレスへのリクエストの両方に対し、同じデータで応答します。その単一のアドレスは、プレイグラウンドが送信できるすべての操作を提供します:そのスキーマエクスプローラーをポピュレートするために自動で発火するイントロスペクションクエリと、あなたが入力して実行するクエリです。そのURLに対して書かれたネットワークフィルター(page.route("**/graphcdn.app/**", ...)、またはホスト名のみにキーを付けたCDPリスナー)は、両方を無差別にキャッチします — 正確にGraphQL自身のHTTPサービングコンベンションが設計上作り出す問題です:1つのURL、1つのメソッド、すべての操作はリクエストの中身によって区別され、送信先によってではありません。本当に重要なクエリを特定するためには、POSTボディのoperationNameフィールドまたはqueryテキスト自体を読む必要があります、送信先のアドレスを読むのではありません。
プレイグラウンド自体は、違いの後半を明らかにします:ページが読み込まれたりユーザーがスクロールした瞬間に発火するスクロールトリガーのRESTエンドポイントとは異なり、GraphiQLのクエリエディタは空です。クエリを入力して実行をクリックするまで、何も意味のあることは起こりません — ここでのテクニックは、そのインタラクションを促進する必要があり、ただ待つだけではありません。
前提条件
Python 3.9以上が必要です — playwright 1.59.0はPyPIでRequires-Python >=3.9を宣言します — playwrightパッケージ、そしてapp.scrapeless.comの無料プランからのScrapeless APIキーが必要です。ターゲットのGraphQLエンドポイントは独自のキーやアカウントを必要とせず、パブリックで非認証のデータです。Scrapelessキーはスクリプト内のリテラルではなく、環境変数に保持してください。なぜなら、それはScraping BrowserのCDPエンドポイントでtokenクエリパラメーターとして送信されるからです。
インストール
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="your_scrapeless_api_key"
CDPを介して接続
このシリーズのすべてのPlaywright-to-Scraping-Browserスクリプトが使用する同じURLビルダーパターンを再利用します:1つのWSSエンドポイント上の3つのクエリパラメーター。
python
import os
from urllib.parse import urlencode
API_KEY = os.environ["SCRAPELESS_API_KEY"]
def scraping_browser_url(proxy_country="US", session_ttl=120):
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オブジェクトを返します。ローカルのChromeインストールは必要ありません。以下の2つのインターセプションテクニックはScraping-Browser特有のものではありません — それらはCDP到達可能なChromiumに対して動作します — しかし、Scraping Browserのインフラストラクチャでレンダリングを実行することは、独自のクライアントのフィンガープリンティングを持つGraphQLフロントエンドが通常通りにクエリをハイドレートし発火させることを意味します。
クエリをトリガーしてレスポンスリスナーでキャプチャする
page.expect_response()は、そのアクションをトリガーする待機にバインドされているので、そのアクションがpage.goto()であろうと、ここでのように自分でドライブするUIインタラクションであろうと、機能します。GraphiQLのエディタに実際のクエリとその変数を入力し、expect_responseコンテキスト内でExecuteをクリックすると、キャプチャされたResponseオブジェクトはサイト自身のJavaScriptが送信し受け取った正確なデータを返します:
python
import json
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
QUERY = (
"query GetCharacters($page: Int, $name: String) { "
"characters(page: $page, filter: { name: $name }) { "
"info { count pages } "
"results { id name status species } } }"
)
VARIABLES = '{"page": 1, "name": "rick"}'
def scraping_browser_url(proxy_country="US", session_ttl=120):
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()
page.goto("https://rickandmortyapi.com/graphql", wait_until="domcontentloaded")
query_editor = page.locator(".graphiql-query-editor .CodeMirror").first
query_editor.click()
page.keyboard.press("Control+A")
page.keyboard.insert_text(QUERY)
page.locator("button:has-text('Variables')").first.click()
variables_editor = page.locator(".graphiql-editor-tool .CodeMirror").first
variables_editor.click()
page.keyboard.press("Control+A")
page.keyboard.insert_text(VARIABLES)
with page.expect_response(lambda r: "graphcdn.app" in r.url and r.request.method == "POST") as run:
page.locator("button.graphiql-execute-button").click()
resp = run.value
sent = json.loads(resp.request.post_data)
data = resp.json()["data"]["characters"]
print("POST target:", resp.request.url)
print("operationName:", sent["operationName"])
print("variables sent:", sent["variables"])
print("info:", data["info"])
print("first result:", data["results"][0])
print("result count in this page:", len(data["results"]))
browser.close()
それをライブプレイグラウンドに対して実行すると、次のように表示されます:
text
POST target: https://rickandmortyapi.graphcdn.app/
operationName: GetCharacters
variables sent: {'page': 1, 'name': 'rick'}
info: {'count': 107, 'pages': 6}
first result: {'id': '1', 'name': 'Rick Sanchez', 'status': 'Alive', 'species': 'Human'}
result count in this page: 20
sent["variables"]は、エディタのVariablesパネルが保持していたのと同じPython辞書です — {"page": 1, "name": "rick"} — インターセプションが実際のリクエストボディを読み取ったことを確認します。page.keyboard.insert_text()がpage.keyboard.type()よりも重要です:GraphiQLが使用するエディタCodeMirrorは、文字ごとに入力するたびに括弧を自動的に閉じるため、{と}で満たされたクエリの個々のキー入力をシミュレーションすると、閉じる波括弧が重複し、構文エラーが発生します。insert_text()は、貼り付けのように一度に全体の文字列を挿入し、個々のキー入力の自動閉じるロジックを完全にスキップします。
生のCDPネットワークドメインでの適切なリクエストの一致
Playwrightのレスポンスイベントは、Chrome DevTools Protocol Network domainの上に存在し、Playwrightをまったく操作していない場合、CDPSessionを通じて直接アクセス可能です。これは、プロトコルイベントのみを公開するツール、または素のCDPクライアントです。エンドポイントURLだけでは操作を区別できないため、CDPレベルのフィルターは、トリガーとなるクリックと一致させることによって、postDataを同様に検査する必要があります:
python
import json
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
QUERY = (
"query GetCharacters($page: Int, $name: String) { "
"characters(page: $page, filter: { name: $name }) { "
"info { count pages } "
"results { id name status species } } }"
)
VARIABLES = '{"page": 2, "name": "rick"}'
captured = {}
def scraping_browser_url(proxy_country="US", session_ttl=120):
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()
cdp = page.context.new_cdp_session(page)
cdp.send("Network.enable")
def on_request(event):
# The playground also fires a schema-introspection POST to this same
# URL on load. Matching on operationName in the body -- not the URL
# -- is what separates it from the query this script triggers.
request = event["request"]
if "graphcdn.app" in request["url"] and "GetCharacters" in request.get("postData", ""):
captured[event["requestId"]] = None
def on_finished(event):
request_id = event["requestId"]
if request_id in captured and captured[request_id] is None:
body = cdp.send("Network.getResponseBody", {"requestId": request_id})
captured[request_id] = json.loads(body["body"])
cdp.on("Network.requestWillBeSent", on_request)
cdp.on("Network.loadingFinished", on_finished)
page.goto("https://rickandmortyapi.com/graphql", wait_until="domcontentloaded")
query_editor = page.locator(".graphiql-query-editor .CodeMirror").first
query_editor.click()
page.keyboard.press("Control+A")
page.keyboard.insert_text(QUERY)
page.locator("button:has-text('Variables')").first.click()
variables_editor = page.locator(".graphiql-editor-tool .CodeMirror").first
variables_editor.click()
page.keyboard.press("Control+A")
page.keyboard.insert_text(VARIABLES)
page.locator("button.graphiql-execute-button").click()
for _ in range(30):
if captured and all(v is not None for v in captured.values()):
break
page.wait_for_timeout(300)
data = next(iter(captured.values()))["data"]["characters"]
print("requests matched by body content:", len(captured))
print("info:", data["info"])
print("first result:", data["results"][0])
browser.close()
text
requests matched by body content: 1
info: {'count': 107, 'pages': 6}
first result: {'id': '218', 'name': 'Mechanical Rick', 'status': 'unknown', 'species': 'Robot'}
Network.requestWillBeSentは、レスポンスが存在する前に、送信リクエスト自体のpostDataフィールドが既に添付されて発火します — この特定のPOSTが追跡する価値があるかどうかを決定する自然なポイントです。Network.loadingFinishedは、一致するレスポンスが転送を完了したことを確認し、その後getResponseBodyがバイトを返します。ページ2はページ1とは異なる最初の結果を返し、それがポイントです:生のCDPパスとレスポンスリスナーパスは同じワイヤを読み取り、2つの異なる方法で一致し、両方が同じライブクエリからの実際の、異なるデータに到達します。
戻ってくるもの
両方のキャプチャパスは、このクエリに対して同じCharacterの形を返します。なぜなら、両方が同じ基礎となるレスポンスを読み取っているからです。
| フィールド | 型 | 意味 |
|---|---|---|
info.count |
整数 | 各ページのフィルターに一致する合計文字数 |
info.pages |
整数 | 現在のページサイズでの合計ページ数 |
results[].id |
文字列 | フォローアップcharacter(id: ...)クエリで直接使用可能なキャラクターID |
results[].name |
文字列 | キャラクター名 |
results[].status |
文字列 | "Alive", "Dead", または "unknown" |
results[].species |
文字列 | 種の分類 |
filter: { name: $name }をfilter: { status: "Alive" }に置き換えるか、フィルター引数を完全に削除しても、同じ2つのキャプチャスクリプトは変更せずに動作し続けます — 変わるのはvariablesペイロードと結果としてのinfo.countだけです。なぜなら、ワイヤーレベルのテクニックは特定のクエリで使用されるフィールドや引数に依存しないからです。
無料のスクレイピングブラウザのランタイムを、app.scrapeless.comにサインアップして取得し、上記のキャプチャスクリプトを自分のGraphQLエンドポイントに対して実行してください。
ブラウザなしでクエリを再生
上記の2つのインターセプトは同じことを証明しました:https://rickandmortyapi.graphcdn.app/は、query、variables、およびoperationNameを持つ平文のJSON POSTを受け入れ、認証なしで、キャプチャがすでに示したのと同じCharacterデータを返します。その形が知られると、ブラウザは同じ質問を尋ねるために必要なくなります — ただし、リクエストは通常のUser-Agentを宣言する必要があり、そうでない場合はゲートウェイのエッジがペイロードにかかわらずそれを拒否します;以下の制限セクションがその理由を説明します:
python
import json
import urllib.error
import urllib.request
ENDPOINT = "https://rickandmortyapi.graphcdn.app/"
QUERY = (
"query GetCharacters($page: Int, $name: String) { "
"characters(page: $page, filter: { name: $name }) { "
"info { count pages } "
"results { id name status species } } }"
)
payload = json.dumps({
"query": QUERY,
"variables": {"page": 1, "name": "rick"},
"operationName": "GetCharacters",
}).encode("utf-8")
req = urllib.request.Request(
ENDPOINT,
data=payload,
# A default urllib request declares "Python-urllib/x.y" as its User-Agent
# and the gateway's edge rejects that outright -- see "When the Browser
# Stays in the Loop" below for what's actually being checked.
headers={
"Content-Type": "application/json",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
raise urllib.error.HTTPError(ENDPOINT, resp.status, "unexpected status", resp.headers, None)
body = json.loads(resp.read())
data = body["data"]["characters"]
print("status: 200, no browser process involved")
print("info:", data["info"])
print("first result:", data["results"][0])
print("result count in this page:", len(data["results"]))
text
status: 200, no browser process involved
info: {'count': 107, 'pages': 6}
first result: {'id': '1', 'name': 'Rick Sanchez', 'status': 'Alive', 'species': 'Human'}
result count in this page: 20
同じinfo、同じ最初の結果、同じ20行ページはレスポンスリスナーモードキャプチャに同じです — なぜなら、それがurllibによって送信された正確に同じリクエストだからです。ブラウザのこのワークフローにおける全仕事は、エンドポイント、クエリの形、変数のフォーマットを明らかにすることでした;それらが知られると、GraphQL POSTはリクエストドキュメントを含むHTTP上の単なるJSONであり、同じ質問を再びする最も速い方法は通常、ページのレンダリングを停止して直接尋ねることです。
ブラウザがループ内に留まるとき
すべてのGraphQLエンドポイントがこのように協力的であるわけではありません。これは、GraphQLゲートウェイが一般的にどのように展開されるか特有の理由によります。多くは、フロントエンドのJavaScriptがローカルストレージまたはクッキーから添付するトークンを持つAuthorizationヘッダーを要求します。これは、実際のセッションからキャプチャしない限り再構築できないものです — これはREST形の隠れたAPIが共有する同じ制限です。さらに進んで自動永続クエリを強制するものもあり、クライアントはクエリのテキスト自体ではなく、クエリのSHA-256ハッシュを送信します;事前に登録されたハッシュのみを受け入れるサーバーは、クエリストリングから構築されたリプレイリクエストを拒否します。なぜなら、そのクライアントからはハッシュが登録されていなかったからです。どちらの場合も、インターセプトステップはここで示されているように正確に機能します:page.expect_response()とCDP Networkドメインは、ブラウザが実際に送信したものを読み取ります。認証ヘッダーや永続クエリハッシュが含まれています。ただし、直接再生の利点は適用されなくなります。なぜなら、ブラウザが添付したものを再構築することが難しい部分になるからです。
この記事の検証中に、直接名付ける価値のある微妙な制限が現れました:公開の未認証のGraphQLエンドポイントは、クエリ自体とは無関係に、フィンガープリンティングベースのボット緩和の背後に存在する可能性があります。上記のエンドポイントに対して、urllibという普通のPOSTをUser-Agentヘッダー(Pythonのデフォルトで、文字列Python-urllib/3.12そのもの)なしで送信すると、HTTP 403が返され、Cloudflareエラー1010が表示されました:「このウェブサイトの所有者が、あなたのブラウザの署名に基づいてアクセスを禁止しました。」これは毎回、一貫して発生し、ゲートウェイ自身のクエリコスト制限ヘッダーはまだ予算が残っていると報告していました。リクエストについて他に何もせずに、単一の普通のブラウザUser-Agent文字列を追加すると、次のすべての呼び出しで同じチェックを通過しました。このブロックは、リクエストの内容や到着頻度ではなく、クライアントの宣言されたアイデンティティに基づいていました。実際のChromium署名を持つクラウドブラウザセッションは、Scraping BrowserのCDPエンドポイントが提供するようなもので、最初からその不一致を持たない。
結論
GraphQL APIは、RESTの多くの自己記述的なURLを1つのエンドポイントと、その要求内容を知るために読まなければならないリクエストボディに置き換えます。page.expect_response()と生のCDP Networkドメインはどちらもそのボディを読み取り、アドレスではなくコンテンツによって一致させ、普通のHTTPクライアントは形状が確認されると同じJSONを再生します。クエリフィルタはoperationNameやクエリテキストにキーを置き、URLではなく、何か興味深いものが発火する前に本物の入力が必要な空のクエリエディタを期待し、公開エンドポイントのボット緩和層をその認証とは別の懸念として扱ってください。CDPメカニクスの両方が構築するパスについては、Chrome DevTools Protocolの説明者が、プロトコルがNetworkドメイン以外に何を露出しているかを説明しています。
無料のScraping Browserランタイムにサインアップするには、app.scrapeless.comに登録してください。または、Scraping Browser製品ページや価格を見て、スケールされた実行を確認してください。
ブラウザ自動化を構築している他の開発者とノートを比較するために、私たちのコミュニティに参加してください:Discord · Telegram。
FAQ
Q: ウェブスクレイピングにおけるGraphQLインターセプトとは何ですか?
それは、GraphQL背後にあるページのJavaScriptがデータを取得するために送信する単一のPOSTリクエストを読むことです — リクエストボディ内のqueryとvariables — そして、HTMLにレンダリングされてマークアップが再解析されるのを待たないことです。
Q: リクエストURLからどのGraphQL操作が実行されたかをなぜ言えないのですか?
GraphQLゲートウェイは通常、一つの固定エンドポイントからすべての操作を提供するためです。異なるパスが異なるリソースに対応するREST APIとは異なり、GraphQLリクエストのアイデンティティはそのPOSTボディ — operationNameフィールドまたはqueryテキスト — に存在し、送信されたアドレスにはありません。
Q: クエリ、変数、およびエンドポイントが分かったらブラウザは必要ですか?
ブラウザが提供する何か(認証ヘッダーや登録された永続クエリハッシュなど)がエンドポイントに必要な場合のみです。このガイドにあるような認証なしで完全なクエリ文字列を受け入れる公開エンドポイントは、直接再生の例が示すように、普通のHTTPクライアントを使って再生できます。
Q: page.expect_response()と生のCDP Networkドメインの違いは何ですか?
page.expect_response()はPlaywrightの高レベルラッパーで、リクエストをトリガーするアクションに束縛され、解析されたResponseオブジェクトを返します。CDP Networkドメインはその下のプロトコルです — Network.requestWillBeSent、Network.loadingFinished、およびNetwork.getResponseBody — Playwrightバインディングなしで有用であったり、フィルターが応答が存在する前に送信リクエストボディを検査する必要がある場合に役立ちます。
Q: 公開GraphQLプレイグラウンドの独自のクエリをインターセプトすることは合法ですか?
公開ページを訪問中のブラウザセッションが受信する応答を読むことは、認証済みまたは非公開データにアクセスすることとは異なる考慮事項を伴います。ワークフローの範囲を公開ページに限定し、ターゲットの利用規約およびロボット指令を尊重し、リクエストのボリュームを制限してください — インターセプションはトラフィックを正確に読む方法であり、アクセスルールを無視する権利を与えるものではありません。
Q: 認証済みまたは永続クエリ専用のGraphQL APIでは何が起こりますか?
インターセプションステップは引き続き機能します — 両方のキャプチャパスはブラウザが実際に送信した内容、認証ヘッダーまたは永続クエリハッシュを含むものを読み取ります。直接リプレイのステップが問題で、ハッシュ専用サーバーは登録されていない生のクエリ文字列から構築されたリクエストを拒否し、認証済みエンドポイントは元のセッションが持っていたヘッダーが欠けているリクエストを拒否します。
Q: ブラウザでないのに直接リプレイの例がUser-Agentヘッダーを設定するのはなぜですか?
ゲートウェイのエッジがヘッダーなしのリクエストを拒否するからです。PythonのデフォルトUser-Agent文字列を使用した単純なurllib POSTは、クエリコストのレート制限ヘッダーが残っている予算を示しているにもかかわらず、すべての試行でCloudflareエラー1010を返します — ブロックはクライアントの宣言されたアイデンティティに基づいています。リクエストに関する他の変更がない普通のブラウザのUser-Agent文字列があれば、通過するのに十分です。
Q: この技術はScrapeless Scraping Browserに特有のものですか、それとも任意のCDPに到達可能なChromiumでも機能しますか?
インターセプションのメカニクスは一般的なCDPの動作であり、connect_over_cdpを通じてアクセスできる任意のChromiumに対して機能します。Scrapeless Scraping Browserで実行すると、実際のブラウザ署名を持つクラウドChromiumセッションが追加され、最初にクエリを発火させる前にクライアントをフィンガープリンティングするフロントエンドにとって重要です。
Q: ターゲットがスキーマやクエリの形状を変更したらどうなりますか?
インターセプションコードは、エンドポイントのURLが一致する限り機能し続けます — クエリのフィールドに関係なく、ブラウザが送信するどのボディでも読み取ります。名前が変更されたフィールドや再構成されたタイプは、data["characters"]["results"]を読み取るコードを壊します。これは、クラス名が変更されたときにCSSセレクタが壊れるのと同様です。GraphQLスキーマは通常、マークアップよりも安定していますが、壊れる変更に免疫があるわけではありません。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



