Extraindo Streams de Eventos Enviados pelo Servidor (SSE) com Scrapeless
Expert Network Defense Engineer
Um contador de comentários de blog ao vivo, um indicador de "agente digitando" de um widget de suporte e uma resposta de chat de IA que preenche palavra por palavra compartilham uma característica: o navegador abriu a conexão uma vez e o servidor tem enviado todas as atualizações por aquela mesma resposta aberta desde então. Não houve uma segunda solicitação para o contador de comentários, nem um loop de polling para o indicador de digitação — uma solicitação HTTP GET, mantida aberta, com Content-Type: text/event-stream, e o servidor escrevendo data: {...}\n\n sempre que algo muda. Uma ferramenta que busca a página uma vez e segue em frente nunca vê nada disso, porque os dados chegam após os cabeçalhos de resposta iniciais, em uma conexão que nunca se fecha.
Conecte o Playwright ao Scrapeless Scraping Browser através de wss://browser.scrapeless.com/api/v2/browser, e a sessão CDP por baixo lhe dá duas maneiras separadas de ler esses quadros enviados à medida que chegam: o próprio leitor de resposta transmitida do Playwright e o evento bruto Network.eventSourceMessageReceived do próprio Protocolo do DevTools Chrome. Este guia se conecta a esse navegador em nuvem, abre um verdadeiro fluxo de Eventos Enviados pelo Servidor (SSE) e captura quadros de ambas as maneiras, com cada caminho de código sendo executado contra um feed público ao vivo.
Por que o SSE precisa de um caminho de captura diferente
page.goto() seguido por uma leitura do DOM mostra apenas o que o markup da página contém naquele momento. Um widget alimentado por SSE nunca re-renderiza a página inteira — ele anexa ou substitui um fragmento toda vez que uma nova linha data: chega, então uma única captura do DOM só pega a atualização que estava atual quando você olhou. As atualizações em si nunca tocam o DOM se nada na página se dá ao trabalho de renderizá-las; o único lugar confiável para lê-las é o próprio fluxo.
O SSE também é um caso mais restrito do que os outros dois transportes em tempo real que um navegador pode abrir. Um endpoint JSON oculto responde a uma solicitação com uma resposta — leia-o com page.expect_response() e você está feito. Um WebSocket precisa de um handshake Upgrade: websocket antes que qualquer lado possa enviar um quadro, e uma vez aberto é de duplex inteiro — qualquer lado pode escrever a qualquer momento. O SSE não precisa de nada disso: a especificação de Eventos Enviados pelo Servidor da WHATWG define-o como uma resposta HTTP simples cujo corpo nunca termina, enviada em resposta a um GET comum, legível com nada mais exótico do que um leitor de corpo de fluxo. O servidor escreve para ele; o cliente só lê.
O Protocolo do DevTools Chrome expõe esse fluxo diretamente, da mesma forma que expõe quadros de WebSocket e respostas HTTP interceptadas. Seu domínio de Rede dispara um evento dedicado eventSourceMessageReceived para cada mensagem recebida na conexão EventSource de uma página, separado dos eventos genéricos de corpo de resposta que são disparados para um fetch comum. O Scrapeless Scraping Browser é uma sessão Chromium em nuvem acessível apenas via CDP — não há um endpoint WebDriver/Selenium para controlá-lo — então qualquer cliente compatível com CDP, o Playwright aqui, pode ler qualquer camada: o próprio corpo transmitido do navegador ou o evento de protocolo abaixo dele.
Pré-requisitos
Você precisa do Python 3.9 ou mais recente — playwright 1.59.0 declara Requires-Python >=3.9 no PyPI — do pacote playwright, e de uma chave de API do Scrapeless do plano gratuito em app.scrapeless.com. Nenhum binário local do Chrome é necessário: connect_over_cdp alcança um navegador que já existe na nuvem do Scrapeless.
Os exemplos abaixo conectam ao fluxo público recentchange da Wikimedia Foundation, documentado em a página do serviço EventStreams da Wikimedia. Não precisa de chave de API e nem de conta — cada edição em todos os projetos da Wikimedia é pública por design, e o fluxo existe especificamente para que ferramentas possam consumi-lo. Ambos os exemplos se interrompem após cinco quadros reais, de modo que nenhum deles mantém a conexão aberta mais tempo do que o necessário para provar que a captura funciona.
Instalação
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="sua_chave_de_api_scrapeless"
Conectar via CDP
Reuse o mesmo padrão de construtor de URL que qualquer script Playwright-para-Scraping-Browser usa: três parâmetros de consulta em um endpoint WSS.
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}"
Ao contrário de um socket de dados de mercado geofenciado, o stream público da Wikimedia aceita conexões de qualquer região: tanto proxyCountry="US" quanto proxyCountry="DE" completam o handshake e começam a entregar frames nas execuções ao vivo desta sessão, sem código de fechamento ou falha de conexão em nenhum dos casos. proxyCountry ainda é importante para muitos alvos reais — um stream limitado a um mercado específico é uma verdadeira falha em outro lugar nesta série — simplesmente não é a restrição neste feed público em particular. Confirme isso contra seu próprio alvo, em vez de assumir qualquer resultado.
Capturar Frames Com Streaming de Respostas do Playwright
A API de eventos de resposta do Playwright dispara page.on("response") assim que os cabeçalhos de uma resposta chegam, sem esperar que o corpo termine — o que é importante aqui, porque uma resposta SSE nunca termina sozinha. Combine isso com o fetch() da página e um leitor ReadableStream, e você pode ler o corpo à medida que os chunks chegam, em vez de esperar por um evento de conclusão que nunca vem:
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"resposta vista via page.on('response'): status={responses_seen[0][0]}, content-type={responses_seen[0][1]}")
print(f"capturados {len(frames)} frames via reader de stream fetch() na página")
print(json.dumps(json.loads(frames[0]), indent=2))
Executá-lo contra o feed ao vivo imprime um verdadeiro evento de edição da Wikimedia, juntamente com a resposta que o Playwright observou:
text
resposta vista via page.on('response'): status=200, content-type=text/event-stream; charset=utf-8
capturados 5 frames via reader de stream fetch() na página
{
"$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") confirma que a própria camada de rede do Playwright viu um 200 com o tipo de conteúdo SSE na resposta HTTP externa — prova de que esta é uma conexão, não cinco solicitações separadas. O loop dentro da página então lê o corpo dessa mesma conexão um chunk de cada vez, divide na linha em branco que o formato de evento-stream usa para separar registros, e puxa a linha data: de cada um. reader.cancel() fecha a conexão subjacente no momento em que cinco frames estão em mãos, então nada aqui mantém o stream da Wikimedia aberto além do que a prova precisa.
Capturar Frames Brutos Do Domínio de Rede CDP
Ler um stream fetch() manualmente funciona, mas não faz com que a própria pilha de rede do Chrome reconheça a conexão como um EventSource — esse reconhecimento é o que dispara o evento eventSourceMessageReceived do CDP, e ele só é disparado quando a página abre o stream com o objeto nativo EventSource do navegador em vez de uma simples chamada fetch(). Procure o evento CDP bruto quando você quiser o próprio registro do navegador do stream em vez de um parser improvisado: ele retorna eventName, eventId e data como campos separados em vez de texto bruto que você precisa dividir por conta própria.
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"captured {len(cdp_events)} raw CDP eventSourceMessageReceived events")
event = cdp_events[0]
print(f"eventName={event['eventName']}")
print(f"eventId={event['eventId']}")
print(json.dumps(json.loads(event["data"]), indent=2))
O evento do protocolo bruto carrega o mesmo payload de edição, além de campos que o parser baseado em fetch teve que ignorar:
text
captured 5 raw CDP eventSourceMessageReceived events
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) abre uma sessão cuja interface CDPSession expõe send() para comandos de protocolo e on() para eventos de protocolo; Network.enable ativa o relatório de eventos, e cada evento subsequente eventSourceMessageReceived é disparado com a forma exata de eventName/eventId/data que o painel de Rede do DevTools lê para uma linha de EventSource. eventId aqui não é um contador simples — a Wikimedia codifica metadados de tópico, partição e deslocamento do Kafka nele, porque esse valor também serve como um cursor de retomar: reconecte-se com ele como o cabeçalho de solicitação Last-Event-ID, e o serviço retoma a partir dessa posição exata em vez de reproduzir desde o início.
O Que Você Recebe de Volta
Ambos os caminhos retornam o mesmo evento de edição subjacente para este stream, porque ambos estão lendo os registros enviados pela mesma conexão aberta — um através de um parser improvisado sobre um fetch genérico, o outro diretamente da própria contabilização SSE do protocolo.
| Campo | Fonte | Significado |
|---|---|---|
event / eventName |
Quadro SSE | Tipo de evento; "message" para cada registro neste stream |
id / eventId |
Quadro SSE | Cursor de retomar — devolva como Last-Event-ID ao reconectar |
data |
Quadro SSE | O payload JSON em si |
$schema |
payload | URI do esquema para a forma deste registro |
meta.domain |
payload | Em qual projeto da Wikimedia a edição ocorreu |
meta.dt |
payload | Timestamp ISO 8601 da alteração |
type |
payload | edit, new, log ou categorize |
A própria documentação da Wikimedia recomenda filtrar registros onde meta.domain é igual a "canary" — eventos de heartbeat sintéticos que o serviço injeta para sua própria monitoração, não edições reais. Qualquer consumidor deste feed deve descartar esses registros antes de tratar um registro como atividade do usuário, da mesma forma que você descartaria uma linha de comentário de keep-alive (: keepalive\n\n) que alguns servidores SSE enviam para manter as conexões ociosas abertas; o formato permite que uma linha SSE que comece com : seja um comentário sem campo data: algum, e ambos os caminhos de captura acima já o ignoram naturalmente, uma vez que nenhum deles busca conteúdo ali.
Obtenha sua chave de API no plano gratuito: app.scrapeless.com
SSE vs WebSocket na Prática
Os dois protocolos resolvem problemas sobrepostos com diferentes compromissos, e escolher o errado para buscar custa tempo real de depuração. Um WebSocket precisa de um aperto de mão 101 Switching Protocols antes que qualquer quadro seja movido e permanece aberto em modo duplex total; conforme o guia de captura do WebSocket nesta série, nada reconecta um WebSocket desconectado automaticamente. O SSE responde a um simples GET com um 200 e Content-Type: text/event-stream, apenas o servidor escreve, e o objeto nativo EventSource do navegador reconecta por conta própria. De acordo com a especificação WHATWG, se a conexão fechar, o agente do usuário aguarda um atraso definido pela implementação (comumente alguns segundos, ajustável por fluxo através de um campo dedicado de atraso de reconexão que o formato define), então reabre a solicitação, anexando Last-Event-ID automaticamente para que o servidor possa retomar em vez de reproduzir tudo.
Esse comportamento de reconexão é o que torna a distinção em nível CDP neste guia relevante na prática: um leitor fetch() bruto precisa reimplementar a reconexão e o rastreamento de Last-Event-ID manualmente, enquanto um verdadeiro objeto EventSource obtém isso gratuitamente do navegador - ao custo de perder o controle direto sobre exatamente quando uma nova conexão é aberta. Leia o tráfego de um alvo com um verdadeiro EventSource quando você quiser que o navegador gerencie a reconexão; leia com fetch() mais um leitor de stream quando precisar controlar o ciclo de vida da conexão por conta própria, como cancelar após um número limitado de registros da maneira que ambos os exemplos acima fazem.
Lendo uma Conexão SSE que Uma Página Real Já Abriu
Ambos os métodos de captura acima abrem a conexão a partir de uma página em branco, pois isso mantém o alvo pequeno e público. Um painel ao vivo, uma interface de chat ou um feed de notificações abre seu próprio EventSource ou fetch() transmitido da mesma maneira, do seu próprio JavaScript agrupado, assim que o componente relevante é montado - e page.on("response") mais o domínio Network do CDP disparam idênticamente de qualquer forma. Anexe os mesmos manipuladores antes de chamar page.goto() no alvo real em vez de em about:blank, e os quadros chegam à medida que o script da página os recebe; nada sobre a lógica de captura muda porque a conexão pertence à página em vez de a uma chamada page.evaluate(). O que muda é a descoberta - abra o painel de Rede do próprio alvo uma vez, filtre por EventSource ou Fetch/XHR, e confirme o endpoint e seu Content-Type antes de escrever um manipulador em torno disso, em vez de adivinhar uma URL de stream com antecedência.
Conclusão
Uma conexão SSE é o mais simples dos três transportes em tempo real que um navegador pode abrir - um GET, uma resposta aberta, sem aperto de mão - e essa simplicidade é exatamente o motivo pelo qual uma ferramenta que apenas lê o DOM ou espera que uma resposta termine nunca a vê. O page.on("response") do Playwright com um leitor de stream embutido na página e o evento bruto Network.eventSourceMessageReceived do CDP ambos leem esses mesmos dados enviados, um através de um parser feito à mão e um diretamente do protocolo, e ambos funcionaram de forma idêntica contra um verdadeiro fluxo de edição público da Wikimedia através da conexão CDP do Scrapeless Scraping Browser. Limite quantos registros você captura antes de fechar a conexão, respeite o cursor de retomada que o campo id: de um stream carrega se você se reconectar, e o restante do script é o punhado de chamadas do Playwright que este guia já abordou. Leia os limites atuais de sessão e saída na página do produto Scraping Browser e verifique os limites do plano na página de preços. Para os mecanismos de conexão sobre os quais este guia se baseia, o explicador do Chrome DevTools Protocol percorre o que o CDP expõe além do domínio Network.
Junte-se à nossa comunidade para reivindicar um plano gratuito e trocar experiências com outros desenvolvedores que criam automação de navegador: Discord · Telegram.
FAQ
P: Preciso de Selenium ou WebDriver para capturar quadros SSE dessa maneira?
Não. O Navegador de Scraping Sem Lixo só é acessível por meio do Protocolo DevTools do Chrome, então qualquer cliente que fale CDP — Playwright aqui, ou Puppeteer — pode se conectar e ler o fluxo. Não há um ponto de extremidade WebDriver, então o Selenium não consegue conduzir essa conexão.
P: Por que um leitor fetch() simples não aciona o evento eventSourceMessageReceived do CDP?
A pilha de rede do Chrome só classifica uma conexão como um EventSource e a relata por meio desse evento dedicado, quando a página a abre com o objeto nativo EventSource. Uma chamada fetch() transmite bytes da mesma forma no nível de transporte, mas o Chrome não a analisa ou rotula como SSE, então a leitura requer que você analise o formato text/event-stream por conta própria, como o exemplo de streaming de resposta neste guia faz.
P: A conexão SSE se reconecta automaticamente se for interrompida?
Somente quando aberta com o objeto nativo EventSource. De acordo com a especificação WHATWG, o navegador aguarda um atraso definido pela implementação e, em seguida, reabre a solicitação com um cabeçalho Last-Event-ID definido para o último valor id: que viu, para que um servidor bem-comportado possa retomar em vez de recomeçar do início. Um leitor baseado em fetch() não recebe nada disso automaticamente — a reconexão e o rastreamento de Last-Event-ID devem ser escritos manualmente.
P: Como isso é diferente da técnica de interceptação de requisição de rede nesta série?
A interceptação lê pares discretos de requisição/resposta — uma página dispara uma nova requisição HTTP toda vez que precisa de dados frescos, e você captura cada uma. Uma conexão SSE é uma única requisição cujo corpo de resposta nunca termina; não há nada para interceptar repetidamente, porque o servidor continua escrevendo para a única resposta que já enviou.
P: O que acontece com linhas : comment ou eventos canary no fluxo da Wikimedia?
Uma linha que começa com : no formato SSE é um comentário sem campo data:, enviado por alguns servidores para manter uma conexão ociosa viva; ambos os caminhos de captura neste guia só buscam por linhas data:, então uma linha de comentário é pulada automaticamente. A Wikimedia também injeta registros sintéticos meta.domain: "canary" para sua própria monitoramento — filtre isso antes de tratar um registro como uma edição real.
P: É seguro executar isso contra qualquer ponto de extremidade SSE que eu encontrar?
Apenas contra pontos de extremidade públicos e não autenticados que você está autorizado a ler, e apenas em um volume que a própria documentação do ponto de extremidade permite. O exemplo aqui tem como alvo o fluxo de edição pública documentado da Wikimedia, não precisa de chave e fecha a conexão após cinco registros em vez de mantê-la aberta indefinidamente.
P: Quanto tempo a conexão SSE pode ficar aberta?
O parâmetro de consulta sessionTTL limita a sessão do navegador em segundos. Um valor curto é suficiente para uma captura limitada como a do guia; um valor mais longo mantêm a sessão — e quaisquer conexões de fluxo abertas — ativas para um consumidor de longa duração.
P: Posso ler um fluxo SSE sem um navegador?
Sim, para um ponto de extremidade pública não autenticada como este — um cliente HTTP simples que suporte leituras em partes pode analisar diretamente o mesmo formato text/event-stream. A sessão do navegador neste guia vale a pena quando o alvo requer uma impressão digital real do Chromium para estabelecer a conexão em primeiro lugar, ou quando uma página abre o fluxo por conta própria como um efeito colateral de seu próprio JavaScript, em vez de expor um ponto de extremidade autônomo documentado.
Na Scorretless, acessamos apenas dados disponíveis ao público, enquanto cumprem estritamente as leis, regulamentos e políticas de privacidade do site aplicáveis. O conteúdo deste blog é apenas para fins de demonstração e não envolve atividades ilegais ou infratoras. Não temos garantias e negamos toda a responsabilidade pelo uso de informações deste blog ou links de terceiros. Antes de se envolver em qualquer atividade de raspagem, consulte seu consultor jurídico e revise os termos de serviço do site de destino ou obtenha as permissões necessárias.



