Scrapelessを使ったサーバー送信イベント(SSE)ストリームのスクレイピング
Expert Network Defense Engineer
ライブブログのコメントカウンター、サポートウィジェットの「エージェントが入力中」インジケーター、単語ごとに埋め込むAIチャットの返信は、共通の特性があります。それは、ブラウザが一度接続を開き、サーバーがそのオープンなレスポンスに対してすべての更新をプッシュし続けているということです。コメントカウンターのための二回目のリクエストはなく、入力中インジケーターのためのポーリングループもありません — 一度のHTTP GETで接続を保持し、Content-Type: text/event-streamが設定され、サーバーが何かが変わるたびにdata: {...}\n\nを書き込んでいます。一度ページを取得して次に進むツールは、そのデータが初回のレスポンスヘッダーの後に到着し、決して閉じない接続上であるため、これを目にすることはありません。
wss://browser.scrapeless.com/api/v2/browserを介してScrapeless Scraping BrowserにPlaywrightを接続すると、下にあるCDPセッションは、発生するフレームを受信するための2つの別々の方法を提供します。Playwrightの独自のストリーム応答リーダーと、Chrome DevToolsプロトコル自体からの生のNetwork.eventSourceMessageReceivedイベントです。このガイドでは、そのクラウドブラウザに接続し、実際のサーバー送信イベント(SSE)ストリームを開き、どちらの方法でもフレームをキャプチャし、すべてのコードパスをライブの公開フィードに対して実行します。
なぜSSEは異なるキャプチャパスを必要とするのか
page.goto()に続いてDOMを読み取ると、その瞬間にページのマークアップが含むものだけが表示されます。SSEを利用したウィジェットは、全体のページを再描画することは決してなく、毎回新しいdata:行が到着するたびにフラグメントを追加または置き換えますので、単一のDOMスナップショットは、見るときに現在の更新をキャッチするだけです。更新自体は、ページ内の何もレンダリングしない限りDOMに触れることはありません。確認する信頼できる場所はストリーム自体だけです。
SSEは、ブラウザが開くことができる他の2つのリアルタイムトランスポートよりも狭いケースです。隠れたJSONエンドポイントは、一つのリクエストに対して一つのレスポンスを返します — page.expect_response()で読み取ると完了です。WebSocketは、いずれの側もフレームを送信できる前にUpgrade: websocketハンドシェイクが必要で、一度接続が開かれると双方向通信が可能です — いずれの側も常に書き込むことができます。SSEはそのどちらも必要としません:WHATWGのサーバー送信イベント仕様は、普通のGETに応じて送信される終了しない通常のHTTPレスポンスのボディとして定義され、ストリーミングボディリーダー以外のもので読み取れることはありません。サーバーが書き込みを行い、クライアントは常に読み取るだけです。
Chrome DevToolsプロトコルは、そのストリームを直接公開し、WebSocketフレームやインターセプトされたHTTPレスポンスを公開するのと同様の方法です。そのネットワークドメインは、ページのEventSource接続が受信する各メッセージのために専用のeventSourceMessageReceivedイベントを発火し、通常のフェッチのために発火する一般的なレスポンスボディイベントとは別です。Scrapeless Scraping Browserは、CDP経由でのみアクセス可能なクラウドChromiumセッションであり、そこを制御するためのWebDriver/Seleniumエンドポイントはないため、CDP対応の任意のクライアント(ここではPlaywright)は、ブラウザのストリーミングボディまたはその下のプロトコルイベントのいずれかの層を読み取ることができます。
前提条件
Python 3.9以上が必要です — playwright 1.59.0はPyPIにおいてRequires-Python >=3.9と宣言されています — playwrightパッケージ、そしてapp.scrapeless.comの無料プランからのScrapeless APIキーが必要です。ローカルのChromeバイナリは必要ありません:connect_over_cdpはScrapelessのクラウド内にすでに存在するブラウザに到達します。
以下の例は、Wikimedia財団の公開recentchangeストリームに接続し、Wikimedia自身のEventStreamsサービスページで文書化されています。APIキーやアカウントは必要ありません — すべてのWikimediaプロジェクトの編集は設計上公開されており、ストリームはツールがそれを消費できるように存在しています。両方の例は5つの実際のフレームの後に自分自身を停止するため、どちらも接続を開いたままにはせず、キャプチャが機能することを証明するのにかかる時間を超えません。
インストール
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="your_scrapeless_api_key"
CDP経由で接続
すべてのPlaywright-to-Scraping-Browserスクリプトが使用するURLビルダーパターンを再利用します:一つの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=60):
params = urlencode({"token": API_KEY, "sessionTTL": session_ttl, "proxyCountry": proxy_country})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
ジオフェンス市場データソケットとは異なり、ウィキメディアの公開ストリームは、どの地域からの接続も受け付けます。proxyCountry="US" と proxyCountry="DE" の両方がハンドシェイクを完了し、このセッションのライブ実行でフレームの配信を開始します。どちらのパターンでも接続の失敗やクローズコードはありません。proxyCountry は多くの実際のターゲットには重要ですが、特定の市場に制限されたストリームは、このシリーズの他の場所では本物の失敗モードです—つまり、この特定の公開フィードにおける制約ではありません。どちらの結果を仮定するのではなく、あなた自身のターゲットに対して確認してください。
Playwrightのレスポンスストリーミングでフレームをキャプチャする
PlaywrightのレスポンスイベントAPIは、レスポンスのヘッダーが到着するやいなやpage.on("response")を発火させ、ボディが完了するのを待ちません—これはここで重要です。なぜなら、SSEレスポンスは決して自分自身では完了しないからです。ページのfetch()とReadableStreamリーダーを組み合わせることで、完了イベントを待つのではなく、チャンクが到着するたびにボディを読み取ることができます。完了イベントは決して来ません。
python
import json
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
FRAME_LIMIT = 5
STREAM_URL = "https://stream.wikimedia.org/v2/stream/recentchange"
def scraping_browser_url(proxy_country="US", session_ttl=60):
params = urlencode({"token": API_KEY, "sessionTTL": session_ttl, "proxyCountry": proxy_country})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
responses_seen = []
def handle_response(response):
if response.url == STREAM_URL:
responses_seen.append((response.status, response.headers.get("content-type")))
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(scraping_browser_url())
page = browser.new_page()
page.on("response", handle_response)
page.goto("about:blank")
frames = page.evaluate(
f"""async () => {{
const resp = await fetch("{STREAM_URL}", {{ headers: {{ "Accept": "text/event-stream" }} }});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const out = [];
while (out.length < {FRAME_LIMIT}) {{
const {{ done, value }} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {{ stream: true }});
let idx;
while ((idx = buffer.indexOf("\\n\\n")) !== -1 && out.length < {FRAME_LIMIT}) {{
const rawEvent = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const dataLine = rawEvent.split("\\n").find(l => l.startsWith("data:"));
if (dataLine) out.push(dataLine.slice(5).trim());
}}
}}
await reader.cancel();
return out;
}}"""
)
browser.close()
print(f"response seen via page.on('response'): status={responses_seen[0][0]}, content-type={responses_seen[0][1]}")
print(f"captured {len(frames)} frames via in-page fetch() stream reader")
print(json.dumps(json.loads(frames[0]), indent=2))
ライブフィードに対して実行すると、実際のウィキメディア編集イベントが印刷され、Playwright自自身が観測したレスポンスも表示されます:
text
response seen via page.on('response'): status=200, content-type=text/event-stream; charset=utf-8
captured 5 frames via in-page fetch() stream reader
{
"$schema": "/mediawiki/recentchange/1.0.0",
"meta": {
"uri": "https://de.wikipedia.org/wiki/Liste_der_Kulturdenkmale_in_Oschatz",
"domain": "de.wikipedia.org",
"stream": "mediawiki.recentchange",
"dt": "2026-07-28T14:16:13.757Z"
},
"id": 382817780,
"type": "edit"
}
page.on("response")は、Playwright自身のネットワークレイヤーが外部HTTPレスポンスでSSEコンテンツタイプを持つ200を認識したことを確認します—これは5つの独立したリクエストではなく1つの接続の証明です。ページ内のループは、その同じ接続のボディを1チャンクずつ読み取り、イベントストリーム形式がレコードを分けるために使用する空白行で分割し、各行からdata:行を抽出します。reader.cancel()は、5つのフレームを取得すると同時に基盤となる接続を閉じるので、ここではウィキメディアのストリームを必要以上に開いたままにはなりません。
CDPネットワークドメインからの生フレームをキャプチャする
fetch() ストリームを手動で読み取ることは可能ですが、Chrome のネットワークスタックが接続を EventSource として認識するわけではありません — この認識が CDP eventSourceMessageReceived イベントを発火させますが、これはページが単なる fetch() 呼び出しの代わりにブラウザのネイティブ EventSource オブジェクトでストリームを開いたときにのみ発火します。ストリームのブラウザ独自のアカウンティングを取得したい場合は、生の CDP イベントを使用してください。それは eventName、eventId、および data を生のテキストではなく、個別のフィールドとして返します。
python
import json
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
API_KEY = os.environ["SCRAPELESS_API_KEY"]
FRAME_LIMIT = 5
STREAM_URL = "https://stream.wikimedia.org/v2/stream/recentchange"
def scraping_browser_url(proxy_country="US", session_ttl=60):
params = urlencode({"token": API_KEY, "sessionTTL": session_ttl, "proxyCountry": proxy_country})
return f"wss://browser.scrapeless.com/api/v2/browser?{params}"
cdp_events = []
def on_sse_message(event):
cdp_events.append(event)
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")
cdp.on("Network.eventSourceMessageReceived", on_sse_message)
page.goto("about:blank")
page.evaluate(
f"""() => {{
window.__count = 0;
const es = new EventSource("{STREAM_URL}");
window.__es = es;
es.onmessage = () => {{
window.__count += 1;
if (window.__count >= {FRAME_LIMIT}) {{ es.close(); }}
}};
}}"""
)
page.wait_for_function(f"window.__count >= {FRAME_LIMIT}", timeout=20000)
page.wait_for_timeout(300)
browser.close()
print(f"キャプチャされた {len(cdp_events)} 生の CDP eventSourceMessageReceived イベント")
event = cdp_events[0]
print(f"eventName={event['eventName']}")
print(f"eventId={event['eventId']}")
print(json.dumps(json.loads(event["data"]), indent=2))
生のプロトコルイベントは、同じ編集ペイロードを含み、fetch ベースのパーサーがスキップしなければならなかったフィールドも含みます:
text
キャプチャされた 5 生の CDP eventSourceMessageReceived イベント
eventName=message
eventId=[{"topic":"eqiad.mediawiki.recentchange","partition":0,"timestamp":1785248186774},{"topic":"codfw.mediawiki.recentchange","partition":0,"offset":-1}]
{
"$schema": "/mediawiki/recentchange/1.0.0",
"meta": {
"uri": "https://www.wikidata.org/wiki/Q100886493",
"domain": "www.wikidata.org",
"stream": "mediawiki.recentchange",
"dt": "2026-07-28T14:16:26.773Z"
},
"id": 2602974671,
"type": "edit"
}
page.context.new_cdp_session(page) は、プロトコルコマンド用の send() とプロトコルイベント用の on() を公開するセッションを開きます; Network.enable はイベント報告をオンにし、その後のすべての eventSourceMessageReceived イベントは、EventSource 行のために DevTools ネットワークパネルが読み取る正確な eventName/eventId/data 構造で発火します。ここでの eventId は単なるカウンタではなく — Wikimedia は Kafka トピック、パーティション、およびオフセットメタデータをエンコードします。なぜなら、その値は再開カーソルとしても機能するからです:それを Last-Event-ID リクエストヘッダーとして再接続すると、その位置から正確に再開し、開始からの再生を避けます。
受け取るもの
両方の経路は、このストリームの同じ基礎となる編集イベントを返します。なぜなら、どちらも同じオープンな接続のプッシュされたレコードを読み取っているからです — 一つは一般的な fetch による手作りパーサーを通して、もう一つはプロトコルの独自の SSE アカウンティングから直接です。
| フィールド | ソース | 意味 |
|---|---|---|
event / eventName |
SSE フレーム | イベントタイプ; このストリームのすべてのレコードに対して "message" |
id / eventId |
SSE フレーム | 再開カーソル — 再接続時に Last-Event-ID として渡す |
data |
SSE フレーム | JSON ペイロード自体 |
$schema |
ペイロード | このレコードの形状のスキーマ URI |
meta.domain |
ペイロード | 編集が発生した Wikimedia プロジェクト |
meta.dt |
ペイロード | 変更の ISO 8601 タイムスタンプ |
type |
ペイロード | edit、new、log、または categorize |
Wikimedia のドキュメントでは、meta.domain が "canary" に等しいレコードをフィルタリングすることを推奨しています — サービスが自己監視のために注入する合成ハートビートイベントで、実際の編集ではありません。このフィードの利用者は、レコードをユーザーアクティビティとして扱う前にそれらを削除すべきです。これは、いくつかの SSE サーバーがアイドル接続を維持するために送信するキープアライブコメント行 (: keepalive\n\n) を削除する場合と同じです;この形式では、: で始まる SSE 行は data: フィールドを全く持たないコメントであり、上記の両方のキャプチャパスは既にそこにコンテンツを探していないため、自然にそれをスキップします。
APIキーを無料プランで取得するには: app.scrapeless.com
実践におけるSSEとWebSocket
この2つのプロトコルは、異なるトレードオフを持ちながら重複する問題を解決します。そして、間違ったものを選ぶと、実際のデバッグ時間がかかります。WebSocketは、フレームが移動する前に101 Switching Protocolsハンドシェークが必要で、フルデュプレックスでオープンのままです; このシリーズのWebSocketキャプチャガイドによれば、切断されたWebSocketは自動的に再接続されることはありません。SSEは単純なGETに200とContent-Type: text/event-streamで応答し、サーバーのみが書き込み、ブラウザのネイティブEventSourceオブジェクトは自動的に再接続します。WHATWGの仕様によれば、接続が閉じた場合、ユーザーエージェントは実装に依存した遅延(一般的に数秒間、フォーマットが定義する専用の再接続遅延フィールドによって調整可能)を待ち、その後リクエストを再度開いて、サーバーがすべてを再生するのではなく再開できるようにLast-Event-IDを自動的に付加します。
その再接続動作は、実際のガイドでCDPレベルの区別が重要である理由です: 生のfetch()リーダーは再接続とLast-Event-IDの追跡を手動で再実装する必要がありますが、本物のEventSourceオブジェクトはブラウザから無料で得られます — 新しい接続が開く正確なタイミングを直接制御する権限を失う代償として。ブラウザに再接続を管理させたいときは本物のEventSourceでターゲットのトラフィックを読み取り、接続ライフサイクルを自分で制御したいときはfetch()とストリームリーダーで読み取り、上記の両方の例で行ったように限られた数のレコードの後にキャンセルします。
実際に開かれたページのSSE接続を読む
上記の2つのキャプチャ方法は、ターゲットを小さくて公開された状態に保つために、空白のページから接続を開きます。ライブダッシュボード、チャットUI、または通知フィードは、関連するコンポーネントがマウントされるとすぐに自分のバンドルされたJavaScriptから自分のEventSourceまたはストリーミングされたfetch()を開きます — そしてpage.on("response")とCDPのNetworkドメインはどちらでも同じようにトリガーされます。about:blankではなく実際のターゲットに対してpage.goto()を呼び出す前に同じハンドラーをattachし、フレームはページ独自のスクリプトが受け取るとともに届きます; 接続がページに属しているからといってキャプチャロジックには何も変わりません。変わるのは発見です — ターゲットの独自のネットワークパネルを一度開き、EventSourceまたはFetch/XHRでフィルタリングし、それに対してハンドラーを書く前にエンドポイントとContent-Typeを確認し、あらかじめストリームURLを推測するのではありません。
結論
SSE接続はブラウザが開ける3つのリアルタイムトランスポートの中で最もシンプルなものです — GETが1回、応答がオープンのまま、ハンドシェークはなし — そしてその単純さが、DOMを読み取るか、応答が終了するのを待つだけのツールが決してそれを見られない理由です。Playwrightのpage.on("response")とインページストリームリーダー、そして生のCDP Network.eventSourceMessageReceivedイベントは、同じプッシュデータを読み取ります; 一つは手作りのパーサーを通し、一つはプロトコルから直接であり、どちらもScrapeless Scraping BrowserのCDP接続を通じて実際の公共Wikimedia編集ストリームに対して同じように機能しました。接続を閉じる前にキャプチャするレコード数を制限し、再接続する場合はストリームのid:フィールドが持つ再開カーソルを尊重し、スクリプトの残りはこのガイドですでに説明したPlaywrightの呼び出しの数少ないものです。現在のセッションと出口制限をScraping Browser製品ページで確認し、料金ページでプランの制限を確認してください。このガイドで構築された接続メカニズムについては、Chrome DevTools Protocolの説明がNetworkドメインを超えてCDPが公開する内容を逐一解説しています。
無料プランを申し込み、ブラウザ自動化を構築する他の開発者とメモを比較するためにコミュニティに参加してください: Discord · Telegram。
FAQ
Q: この方法でSSEフレームをキャプチャするためにSeleniumやWebDriverは必要ですか?
いいえ。Scrapeless Scraping BrowserはChrome DevTools Protocolを介してのみアクセス可能であるため、CDP(ここではPlaywrightやPuppeteer)を話すクライアントが接続し、ストリームを読み取ることができます。WebDriverエンドポイントはないため、Seleniumはこの接続を操作できません。
Q: なぜ普通のfetch()リーダーはCDPのeventSourceMessageReceivedイベントをトリガーしないのですか?
Chromeのネットワークスタックは接続をEventSourceとしてのみ分類し、ページがネイティブのEventSourceオブジェクトでそれを開いたときに、その専用イベントを通じて報告します。fetch()呼び出しは運送レベルで同じ方法でバイトをストリーミングしますが、ChromeはそれをSSEとして解析またはタグ付けしないため、これを読むにはtext/event-streamフォーマットを自分で解析する必要があります。このガイドのレスポンスストリーミングの例のように。
Q: SSE接続は切断された場合、自動的に再接続しますか?
ネイティブのEventSourceオブジェクトで開かれた場合のみ。WHATWG仕様によれば、ブラウザは実装に依存する遅延を待った後、Last-Event-IDヘッダーを設定して最後に見たid:値でリクエストを再オープンします。これにより、適切に動作するサーバーは開始からの再生を行う代わりに再開できます。fetch()ベースのリーダーはこれを自動的に取得しません — 再接続とLast-Event-IDトラッキングは手動で書く必要があります。
Q: これはこのシリーズのネットワークリクエストインターセプション技術と何が異なるのですか?
インターセプションは、離散的なリクエスト/レスポンスペアを読み取ります — ページは新しいデータが必要なたびに新しいHTTPリクエストを発火し、あなたはそれぞれをキャッチします。SSE接続は応答ボディが決して完了しない単一のリクエストです;サーバーはすでに送信した1つの応答に書き続けているため、繰り返しインターセプトするものはありません。
Q: Wikimediaのストリームの: comment行やcanaryイベントはどうなりますか?
SSEフォーマットで:で始まる行は、data:フィールドのないコメントであり、接続をアイドル状態に保つために一部のサーバーによって送信されます;このガイドの両方のキャプチャパスはdata:行のみを探すため、コメント行は自動的にスキップされます。Wikimediaはまた、独自の監視のために合成されたmeta.domain: "canary"レコードを追加します — レコードを実際の編集として扱う前にそれらをフィルタリングしてください。
Q: 見つけた任意のSSEエンドポイントに対してこれを実行するのは安全ですか?
許可された公開の非認証エンドポイントにのみ、またエンドポイント自身の文書で許可されるボリュームでのみです。ここでの例はWikimediaの文書化された公開編集ストリームをターゲットにしており、キーは必要なく、無限に接続を開いたままにするのではなく、5つのレコードの後に接続を閉じます。
Q: SSE接続はどれくらいの間オープンにしておけますか?
sessionTTLクエリパラメーターは、ブラウザセッションを秒単位で制限します。短い値は、このガイドのような限られたキャプチャには十分です;より長い値は、セッションおよび開いているストリーム接続をより長く維持します。
Q: ブラウザなしでSSEストリームを読み取ることはできますか?
はい、このような公開の非認証エンドポイントの場合 — チャンク読み取りをサポートする通常のHTTPクライアントは、同じtext/event-streamフォーマットを直接解析できます。このガイドのブラウザセッションは、対象が接続を確立するために実際のChromiumフィンガープリントを必要とする場合や、ページが独自のJavaScriptの副作用としてストリームを自ら開く場合に価値があります。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



