Raspado de Flujos de Eventos Enviados por el Servidor (SSE) con Scrapeless
Expert Network Defense Engineer
El contador de comentarios de un blog en vivo, el indicador de "el agente está escribiendo" de un widget de soporte y una respuesta de chat de IA que se completa palabra por palabra comparten una característica: el navegador abrió la conexión una vez y el servidor ha estado enviando cada actualización a esa misma respuesta abierta desde entonces. No hubo una segunda solicitud para el contador de comentarios, ni un bucle de sondeo para el indicador de escritura: una solicitud HTTP GET, mantenida abierta, con Content-Type: text/event-stream, y el servidor escribiendo data: {...}\n\n en ella cada vez que algo cambia. Una herramienta que obtiene la página una vez y continúa nunca ve nada de eso, porque los datos llegan después de los encabezados de respuesta iniciales, en una conexión que nunca se cierra.
Conéctate a Playwright con el navegador de scraping Scrapeless a través de wss://browser.scrapeless.com/api/v2/browser, y la sesión CDP subyacente te da dos formas separadas de leer esos marcos enviados a medida que llegan: el lector de respuesta transmitida de Playwright y el evento bruto Network.eventSourceMessageReceived del propio Protocolo de Herramientas de Desarrollo de Chrome. Esta guía se conecta a ese navegador en la nube, abre un verdadero flujo de Eventos Enviados por el Servidor (SSE) y captura marcos de ambas maneras, con cada ruta de código ejecutándose contra un feed público en vivo.
Por qué SSE necesita un camino de captura diferente
page.goto() seguido de una lectura del DOM solo muestra lo que el marcado de la página contiene en ese momento. Un widget alimentado por SSE nunca vuelve a renderizar toda la página: agrega o reemplaza un fragmento cada vez que aterriza una nueva línea data:, por lo que una sola instantánea del DOM solo captura la actualización que estaba vigente cuando miraste. Las actualizaciones en sí nunca tocan el DOM si nada en la página se molesta en renderizarlas; el único lugar confiable para leerlas es el flujo mismo.
SSE también es un caso más estrecho que los otros dos transportes en tiempo real que un navegador puede abrir. Un punto final JSON oculto responde a una solicitud con una respuesta: léelo con page.expect_response() y habrás terminado. Un WebSocket necesita un apretón de manos Upgrade: websocket antes de que cualquiera de los lados pueda enviar un marco, y una vez abierto es dúplex total: cualquiera de las partes puede escribir en cualquier momento. SSE no necesita nada de esto: la especificación de Eventos Enviados por el Servidor de WHATWG lo define como una respuesta HTTP simple cuyo cuerpo nunca termina, enviada en respuesta a un GET ordinario, legible con nada más exótico que un lector de cuerpo de transmisión. El servidor escribe en él; el cliente solo lee.
El Protocolo de Herramientas de Desarrollo de Chrome expone ese flujo directamente, de la misma manera que expone tramas de WebSocket y respuestas HTTP interceptadas. Su dominio de red activa un evento eventSourceMessageReceived dedicado para cada mensaje que recibe la conexión EventSource de una página, separado de los eventos genéricos de cuerpo de respuesta que se activan para una obtención ordinaria. El navegador de scraping Scrapeless es una sesión de Chromium en la nube accesible solo a través de CDP: no hay un punto final WebDriver/Selenium para controlarlo, por lo que cualquier cliente capaz de CDP, aquí Playwright, puede leer cualquiera de las capas: el cuerpo transmitido del navegador o el evento del protocolo debajo de él.
Requisitos previos
Necesitas Python 3.9 o más reciente: playwright 1.59.0 declara Requires-Python >=3.9 en PyPI, el paquete playwright y una clave API de Scrapeless del plan gratuito en app.scrapeless.com. No se requiere un binario de Chrome local: connect_over_cdp accede a un navegador que ya existe en la nube de Scrapeless.
Los ejemplos a continuación se conectan al flujo público recentchange de la Fundación Wikimedia, documentado en la página del servicio EventStreams de Wikimedia. No necesita ninguna clave API ni cuenta: cada edición en cada proyecto de Wikimedia es pública por diseño, y el flujo existe específicamente para que las herramientas puedan consumirlo. Ambos ejemplos se detienen después de cinco marcos reales, por lo que ninguna ejecución mantiene la conexión abierta más tiempo del necesario para demostrar que la captura funciona.
Instalar
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="tu_clave_api_de_scrapeless"
Conectar a través de CDP
Reutiliza el mismo patrón de constructor de URL que cualquier script de Playwright a navegador de scraping utiliza: tres parámetros de consulta en un extremo 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}"
A diferencia de un socket de datos de mercado geocercado, el flujo público de Wikimedia acepta conexiones de cualquier región: tanto proxyCountry="US" como proxyCountry="DE" completan el apretón de manos y comienzan a entregar tramas en las ejecuciones en vivo de esta sesión, sin código de cierre ni fallo en la conexión de ninguna manera. proxyCountry sigue siendo importante para muchos objetivos reales: un flujo restringido a un mercado específico es un modo de fallo real en otro lugar de esta serie; simplemente no es la restricción en este flujo público particular. Confírmalo con tu propio objetivo en lugar de asumir cualquiera de los resultados.
Captura de Tramas Con la Transmisión de Respuestas de Playwright
La API de eventos de respuesta de Playwright activa page.on("response") tan pronto como llegan los encabezados de una respuesta, sin esperar a que termine el cuerpo — lo cual es relevante aquí, porque una respuesta SSE nunca termina por sí sola. Combina eso con el propio fetch() de la página y un lector de ReadableStream, y puedes leer el cuerpo a medida que llegan los fragmentos en lugar de esperar un evento de finalización que nunca llega:
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"respuesta vista a través de page.on('response'): estado={responses_seen[0][0]}, tipo de contenido={responses_seen[0][1]}")
print(f"capturados {len(frames)} tramas a través del lector de flujo fetch() en la página")
print(json.dumps(json.loads(frames[0]), indent=2))
Ejecutarlo contra el flujo en vivo imprime un evento de edición real de Wikimedia, junto con la respuesta que el propio Playwright observó:
text
respuesta vista a través de page.on('response'): estado=200, tipo de contenido=text/event-stream; charset=utf-8
capturados 5 tramas a través del lector de flujo fetch() en la 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 la propia capa de red de Playwright vio un 200 con el tipo de contenido SSE en la respuesta HTTP exterior — prueba de que esta es una conexión, no cinco solicitudes separadas. El bucle dentro de la página luego lee el cuerpo de esa misma conexión un fragmento a la vez, separa en la línea en blanco que el formato de flujo de eventos utiliza para separar registros, y extrae la línea data: de cada uno. reader.cancel() cierra la conexión subyacente en el momento en que se tienen cinco tramas, por lo que nada aquí mantiene abierto el flujo de Wikimedia más allá de lo que la prueba necesita.
Captura de Tramas Crudas Desde el Dominio de Red CDP
Leer un flujo de fetch() manualmente funciona, pero no hace que la propia pila de red de Chrome reconozca la conexión como un EventSource; ese reconocimiento es lo que dispara el evento eventSourceMessageReceived del CDP, y solo se activa cuando la página abre el flujo con el objeto nativo EventSource del navegador en lugar de una simple llamada fetch(). Apóyate en el evento crudo del CDP cuando quieras la contabilización del flujo por parte del navegador en lugar de un analizador hecho a mano: devuelve eventName, eventId y data como campos separados en lugar de texto sin procesar que debes dividir tú mismo.
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"capturado {len(cdp_events)} eventos raw eventSourceMessageReceived del CDP")
event = cdp_events[0]
print(f"eventName={event['eventName']}")
print(f"eventId={event['eventId']}")
print(json.dumps(json.loads(event["data"]), indent=2))
El evento de protocolo en bruto lleva la misma carga de edición, además de campos que el analizador basado en fetch tuvo que omitir:
text
capturado 5 eventos raw eventSourceMessageReceived del CDP
eventName=mensaje
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": "edición"
}
page.context.new_cdp_session(page) abre una sesión cuya interfaz CDPSession expone send() para comandos de protocolo y on() para eventos de protocolo; Network.enable activa el informe de eventos, y cada evento posterior eventSourceMessageReceived se activa con la exacta forma eventName/eventId/data que el panel de red de DevTools lee para una fila de EventSource. eventId aquí no es un contador simple: Wikimedia codifica metadatos sobre el tema de Kafka, partición y desplazamiento en él, porque ese valor también funciona como un cursor de reanudación: reconéctate con él como el encabezado de solicitud Last-Event-ID, y el servicio retomará desde esa posición exacta en lugar de repetir desde el principio.
Lo Que Obtienes de Vuelta
Ambos caminos devuelven el mismo evento de edición subyacente para este flujo, porque ambos están leyendo los mismos registros empujados de la conexión abierta — uno a través de un analizador hecho a mano sobre un fetch genérico, otro directamente de la contabilidad SSE del propio protocolo.
| Campo | Fuente | Significado |
|---|---|---|
event / eventName |
cuadro SSE | Tipo de evento; "mensaje" para cada registro en este flujo |
id / eventId |
cuadro SSE | Cursor de reanudación: devolver como Last-Event-ID al reconectar |
data |
cuadro SSE | La carga útil JSON misma |
$schema |
carga útil | URI del esquema para la forma de este registro |
meta.domain |
carga útil | En qué proyecto de Wikimedia ocurrió la edición |
meta.dt |
carga útil | Marca de tiempo ISO 8601 del cambio |
type |
carga útil | edición, nuevo, registro o categorizar |
La propia documentación de Wikimedia recomienda filtrar los registros donde meta.domain sea igual a "canary" — eventos de latido sintético que el servicio inyecta para su propia monitorización, no ediciones reales. Cualquier consumidor de este feed debe eliminar esos registros antes de tratar un registro como actividad del usuario, de la misma manera que eliminarías una línea de comentario de keep-alive (: keepalive\n\n) que algunos servidores SSE envían para mantener abiertas las conexiones inactivas; el formato permite que una línea SSE que comience con : sea un comentario sin campo data: en absoluto, y ambos caminos de captura anteriores ya lo omiten de forma natural, ya que ninguno busca contenido allí.
Consigue tu clave API en el plan gratuito: app.scrapeless.com
SSE vs WebSocket en la práctica
Los dos protocolos resuelven problemas superpuestos con diferentes compensaciones, y elegir el incorrecto para buscar cuesta tiempo real de depuración. Un WebSocket necesita un apretón de manos 101 Switching Protocols antes de que se mueva cualquier marco y permanece abierto a doble vía; según la guía de captura de WebSocket en esta serie, nada reconecta automáticamente un WebSocket caído. SSE responde a un simple GET con un 200 y Content-Type: text/event-stream, solo el servidor escribe, y el objeto nativo EventSource del navegador se reconecta por su cuenta. Según la especificación WHATWG, si la conexión se cierra, el agente de usuario espera un retraso definido por la implementación (comúnmente unos segundos, ajustable por flujo a través de un campo de retraso de reconexión dedicado que el formato define), luego reabre la solicitud, adjuntando automáticamente Last-Event-ID para que el servidor pueda reanudar en lugar de volver a reproducir todo.
Ese comportamiento de reconexión es la razón por la cual la distinción a nivel de CDP en esta guía es importante en la práctica: un lector fetch() en bruto tiene que reimplementar la reconexión y el seguimiento de Last-Event-ID manualmente, mientras que un verdadero objeto EventSource lo obtiene gratis del navegador — a costa de perder el control directo sobre exactamente cuándo se abre una nueva conexión. Lee el tráfico de un objetivo con un verdadero EventSource cuando quieras que el navegador administre la reconexión; léelo con fetch() más un lector de flujo cuando necesites controlar el ciclo de vida de la conexión tú mismo, como cancelar después de un número limitado de registros de la manera en que ambos ejemplos anteriores lo hacen.
Lectura de una conexión SSE que una página real ya abrió
Ambos métodos de captura mencionados anteriormente abren la conexión desde una página en blanco, porque eso mantiene el objetivo pequeño y público. Un panel de control en vivo, una interfaz de chat o un feed de notificaciones abre su propio EventSource o fetch() transmitido de la misma manera, desde su propio JavaScript agrupado, tan pronto como se monta el componente relevante — y page.on("response") más el dominio Network del CDP se activan idénticamente de cualquier manera. Adjunta los mismos controladores antes de llamar a page.goto() en el objetivo real en lugar de en about:blank, y los marcos llegan a medida que el propio script de la página los recibe; nada de la lógica de captura cambia porque la conexión pertenece a la página en lugar de a una llamada page.evaluate(). Lo que sí cambia es el descubrimiento: abre el panel de Red del objetivo una vez, filtra por EventSource o Fetch/XHR, y confirma el endpoint y su Content-Type antes de escribir un controlador a su alrededor, en lugar de adivinar una URL de flujo de antemano.
Conclusión
Una conexión SSE es el más simple de los tres transportes en tiempo real que un navegador puede abrir — una GET, una respuesta abierta, sin apretón de manos — y esa simplicidad es exactamente por qué una herramienta que solo lee el DOM o espera a que una respuesta termine nunca la ve. page.on("response") de Playwright con un lector de flujo dentro de la página y el evento crudo Network.eventSourceMessageReceived del CDP ambos leen esos mismos datos enviados, uno a través de un analizador manual y uno directamente del protocolo, y ambos funcionaron idénticamente contra un verdadero flujo de ediciones públicas de Wikimedia a través de la conexión CDP del Navegador de Scraping de Scrapeless. Limita cuántos registros capturas antes de cerrar la conexión, respeta el cursor de reanudación que el campo id: de un flujo lleva si te reconectas, y el resto del script es el puñado de llamadas de Playwright que esta guía ya ha recorrido. Lee los límites de sesión actuales y de egreso en la página de producto de Scraping Browser y verifica los límites del plan en la página de precios. Para los mecánicos de conexión que esta guía construye, el explicador del Protocolo de DevTools de Chrome detallará lo que CDP expone más allá del dominio de Red.
Únete a nuestra comunidad para reclamar un plan gratuito y comparar notas con otros desarrolladores que construyen automatización en el navegador: Discord · Telegram.
Preguntas Frecuentes
P: ¿Necesito Selenium o WebDriver para capturar marcos SSE de esta manera?
No. El Navegador de Rasgado Sin Desperdicios solo es accesible a través del Protocolo de Herramientas de Desarrollo de Chrome, por lo que cualquier cliente que hable CDP — Playwright aquí, o Puppeteer — puede conectarse y leer el flujo. No hay un punto final de WebDriver, así que Selenium no puede manejar esta conexión.
P: ¿Por qué un lector simple de fetch() no activa el evento eventSourceMessageReceived de CDP?
La pila de red de Chrome solo clasifica una conexión como EventSource y la informa a través de ese evento dedicado, cuando la página la abre con el objeto nativo EventSource. Una llamada fetch() transmite bytes de la misma manera a nivel de transporte, pero Chrome no lo analiza ni lo etiqueta como SSE, por lo que leerlo requiere analizar el formato text/event-stream tú mismo, como hace el ejemplo de transmisión de respuesta en esta guía.
P: ¿Se reconecta automáticamente la conexión SSE si se cae?
Solo cuando se abre con el objeto nativo EventSource. Según la especificación WHATWG, el navegador espera un retraso definido por la implementación y luego reabre la solicitud con un encabezado Last-Event-ID configurado al último valor de id: que vio, para que un servidor bien comportado pueda reanudar en lugar de reproducir desde el principio. Un lector basado en fetch() no recibe nada de esto automáticamente — la reconexión y el seguimiento de Last-Event-ID deben ser escritos a mano.
P: ¿Cómo se diferencia esto de la técnica de interceptación de solicitudes de red en esta serie?
La interceptación lee pares discretos de solicitud/respuesta — una página genera una nueva solicitud HTTP cada vez que necesita datos frescos, y tú capturas cada una. Una conexión SSE es una única solicitud cuyo cuerpo de respuesta nunca termina; no hay nada que interceptar repetidamente, porque el servidor sigue escribiendo en la única respuesta que ya envió.
P: ¿Qué pasa con las líneas : comment o los eventos canary en el flujo de Wikimedia?
Una línea que comienza con : en el formato SSE es un comentario sin campo data:, enviado por algunos servidores para mantener una conexión inactiva viva; ambos caminos de captura en esta guía solo buscan líneas data:, así que una línea de comentario se salta automáticamente. Wikimedia también inyecta registros sintéticos meta.domain: "canary" para su propio monitoreo — filtra esos antes de tratar un registro como una edición real.
P: ¿Es seguro ejecutar esto contra cualquier punto final SSE que encuentre?
Solo contra puntos finales públicos y no autenticados que tengas permiso para leer, y solo en un volumen que la documentación del propio punto final permita. El ejemplo aquí se dirige al flujo de edición pública documentado de Wikimedia, no necesita clave, y cierra la conexión después de cinco registros en lugar de mantenerla abierta indefinidamente.
P: ¿Cuánto tiempo puede permanecer abierta la conexión SSE?
El parámetro de consulta sessionTTL limita la sesión del navegador en segundos. Un valor corto es suficiente para una captura limitada como la que se presenta en esta guía; un valor más largo mantiene la sesión — y cualquier conexión de flujo abierta — viva para un consumidor que funcione durante más tiempo.
P: ¿Puedo leer un flujo SSE sin un navegador en absoluto?
Sí, para un punto final público no autenticado como este — un cliente HTTP simple que soporte lecturas en fragmentos puede analizar directamente el mismo formato text/event-stream. La sesión del navegador en esta guía vale la pena cuando el destino requiere una verdadera huella digital de Chromium para establecer la conexión en primer lugar, o cuando una página abre el flujo por sí misma como un efecto secundario de su propio JavaScript en lugar de exponer un punto final independiente documentado.
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



