Volver al blog

Playwright + Navegador de Scraping Sin Basura: Capturar y Reproducir una API GraphQL Oculta

Isabella Garcia
Isabella Garcia

Web Data Collection Specialist

06-Aug-2026

Abre la pestaña de Red en rickandmortyapi.com/graphql y mírala durante un minuto: la llamada de introspección del esquema que GraphiQL activa en el momento en que se carga la página, y la consulta que escribes y ejecutas tú mismo un momento después, ambas llegan a la misma URL exacta. Una API REST distribuye su comportamiento en rutas — /characters, /episodes, /locations/1 — así que la URL sola te dice para qué es una solicitud. Una API GraphQL colapsa todo eso en un único punto final y mueve la solicitud real al cuerpo del POST en su lugar: una cadena query que nombra los campos que deseas, un objeto variables que proporciona los argumentos, a veces una etiqueta operationName que identifica cuál es. Leer ese tráfico significa leer el cuerpo, no la URL, porque la URL dejó de llevar la señal.

Esta guía conecta Playwright con el Navegador de Raspado sin Scrapeless a través de CDP, activa un verdadero playground público de GraphQL para ejecutar una consulta y intercepta el POST resultante de dos maneras independientes: los propios eventos de respuesta de Playwright y el dominio CDP Network en bruto debajo de ellos, antes de reproducir esa solicitud exacta con un cliente HTTP simple y sin navegador en absoluto. Cada comando abajo se ejecutó contra el objetivo en vivo.

Un Punto Final, Cada Operación

https://rickandmortyapi.graphcdn.app/ es la dirección a la que el propio playground GraphiQL de la API de Rick y Morty realmente llama, un nivel detrás del alias más amigable rickandmortyapi.com/graphql que su documentación publicita; responde tanto a solicitudes a ese alias como a solicitudes a la dirección CDN directamente con datos idénticos. Esa única dirección sirve cada operación que el playground puede enviar: la consulta de introspección que activa automáticamente al cargar para poblar su explorador de esquemas, y cualquier consulta que escribas y ejecutes tú mismo. Un filtro de red escrito contra esa URL sola (page.route("**/graphcdn.app/**", ...), o un oyente de CDP configurado solo en el nombre de host) capturaría ambas indiscriminadamente, exactamente el problema que la propia convención de servir HTTP de GraphQL crea por diseño: una URL, un método, cada operación distinguida por lo que hay dentro de la solicitud en lugar de dónde se envía. Aislar la única consulta que realmente importa significa leer el campo operationName del cuerpo del POST o el texto query en sí, no la dirección a la que fue.

El propio playground hace que la segunda mitad de la diferencia sea obvia: a diferencia de un punto final REST activado por desplazamiento que se activa en el momento en que se carga una página o un usuario se desplaza, el editor de consultas de GraphiQL empieza vacío. No sucede nada significativo hasta que escribes una consulta y haces clic en Ejecutar; la técnica aquí tiene que impulsar esa interacción, no solo esperar a que ocurra.

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. El punto final GraphQL objetivo en sí no necesita clave ni cuenta propia; son datos públicos y no autenticados. Mantén la clave de Scrapeless en una variable de entorno en lugar de un literal en tu script, ya que viaja como el parámetro de consulta token en el punto final CDP del Navegador de Raspado.

Instalar

bash Copy
pip install playwright
bash Copy
export SCRAPELESS_API_KEY="your_scrapeless_api_key"

Conectar a través de CDP

Reutiliza el mismo patrón de generador de URL que utiliza cada script de Playwright a Navegador de Raspado en esta serie: tres parámetros de consulta en un único punto final WSS.

python Copy
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()) devuelve un objeto estándar de Playwright Browser, no se requiere instalación local de Chrome. Nada sobre las dos técnicas de interceptación a continuación es específico del Navegador de Raspado; se ejecutan contra cualquier Chromium accesible por CDP, pero ejecutar el render en la infraestructura del Navegador de Raspado significa que un frontend de GraphQL que identifica su propio cliente aún hidrata y dispara sus consultas normalmente.

Activar la Consulta y Capturarla con un Oyente de Respuesta

page.expect_response() vincula la espera a la acción que la activa, por lo que funciona ya sea que esa acción sea un page.goto() o, como aquí, una interacción de UI que tú mismo impulsas. Escribe una consulta real y sus variables en el editor de GraphiQL, haz clic en Ejecutar dentro del contexto expect_response, y el objeto Response capturado devuelve exactamente lo que el propio JavaScript del sitio envió y recibió:

python Copy
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()

Ejecutarlo contra el playground en vivo imprime:

text Copy
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"] es el mismo diccionario de Python que el panel de Variables del editor tenía — {"page": 1, "name": "rick"} — confirmando que la interceptación leyó el cuerpo de la solicitud real, no una suposición de lo que la consulta podría contener. page.keyboard.insert_text() en lugar de page.keyboard.type() importa aquí: CodeMirror, el editor que utiliza GraphiQL, cierra automáticamente paréntesis a medida que los escribes carácter por carácter, así que simular pulsaciones de teclas individuales para una consulta llena de { y } produce llaves de cierre duplicadas y un error de sintaxis. insert_text() inserta toda la cadena de una vez, como lo haría un pegado, y omite por completo la lógica de cierre automático por cada pulsación de tecla.

Coincidir la Solicitud Correcta en el Dominio de Red CDP Crudo

Los eventos de respuesta de Playwright se encuentran en la parte superior del dominio de red del Protocolo de DevTools de Chrome, accesible directamente a través de un CDPSession para casos en los que no estás controlando Playwright en absoluto — un cliente CDP básico, o una herramienta que solo expone eventos de protocolo. Dado que la URL del endpoint por sí sola no distingue operaciones, el filtro a nivel de CDP debe inspeccionar postData de la misma manera en que la captura de nivel superior lo hace implícitamente al coincidir con el clic que desencadena:

python Copy
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 Copy
requests matched by body content: 1
info: {'count': 107, 'pages': 6}
first result: {'id': '218', 'name': 'Mechanical Rick', 'status': 'unknown', 'species': 'Robot'}

Network.requestWillBeSent se activa con el propio postData de la solicitud saliente ya adjunto, antes de que exista la respuesta — el punto natural para decidir si este POST en particular es el que vale la pena rastrear. Network.loadingFinished confirma que la respuesta coincidente terminó de transferirse, y solo entonces getResponseBody devuelve los bytes. La página 2 vuelve con un resultado primero diferente al de la página 1, que es el punto: la ruta CDP cruda y la ruta del oyente de respuesta están leyendo el mismo cable, emparejadas de dos maneras diferentes, y ambas llegan a datos reales y distintos de la misma consulta activa.

Lo Que Obtienes de Vuelta

Ambas rutas de captura devuelven la misma forma Character para esta consulta, porque ambas leen la misma respuesta subyacente.

Campo Tipo Significado
info.count entero Total de caracteres que coinciden con el filtro en cada página
info.pages entero Total de número de páginas en el tamaño de página actual
results[].id cadena ID del personaje, utilizable directamente en una consulta de seguimiento character(id: ...)
results[].name cadena Nombre del personaje
results[].status cadena "Alive", "Dead", o "unknown"
results[].species cadena Clasificación de especie

Cambia filter: { name: $name } por filter: { status: "Alive" } o elimina el argumento de filtro por completo, y los mismos dos scripts de captura continúan funcionando sin modificaciones — solo el variables payload y el info.count resultante cambian, porque la técnica a nivel de cable no depende de cuáles campos o argumentos usa una consulta particular.

Obtén tiempo de ejecución gratuito de Scraping Browser registrándote en app.scrapeless.com y ejecutando ambos scripts de captura anteriores contra un endpoint de GraphQL propio.

Ambas interceptaciones anteriores demostraron la misma cosa: https://rickandmortyapi.graphcdn.app/ acepta un POST JSON simple con query, variables, y operationName, sin autenticación, y devuelve los mismos datos Character que cualquiera de las capturas ya mostró. Una vez que se conoce esa forma, ya no se requiere un navegador para hacer la misma pregunta idéntica — aunque la solicitud tiene que declarar un User-Agent normal, o el borde de la puerta de enlace la rechaza de inmediato independientemente del payload; la sección de límites a continuación cubre por qué:

python Copy
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 Copy
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

Misma info, mismo primer resultado, misma página de 20 filas como la captura del oyente de respuesta — porque es la misma solicitud exacta, enviada por urllib en lugar de por la propia llamada fetch de GraphiQL. El trabajo completo del navegador en este flujo de trabajo fue revelar el endpoint, la forma de la consulta y el formato de las variables; una vez que esos son conocidos, un POST de GraphQL es solo JSON sobre HTTP que lleva un documento de solicitud en la forma que la propia especificación de GraphQL define, y la forma más rápida de hacer la misma pregunta nuevamente generalmente es dejar de renderizar una página y preguntarlo directamente.

No todos los endpoints de GraphQL son tan cooperativos, por razones específicas de cómo se implementan comúnmente las puertas de enlace de GraphQL. Muchos requieren un encabezado Authorization que lleve un token que el propio JavaScript del frontend adjunta desde el almacenamiento local o una cookie, algo que no puedes reconstruir a menos que lo hayas capturado de una sesión real — la misma limitación que comparten las API ocultas en forma REST. Algunos van más allá y aplican Consultas Persistentes Automáticas, donde el cliente envía un hash SHA-256 de la consulta en lugar del texto de la consulta misma; un servidor que solo acepta hashes pre-registrados rechaza una solicitud reproducida construida a partir de una cadena de consulta sola, porque el hash nunca fue registrado desde ese cliente. En ambos casos, el paso de interceptación sigue funcionando exactamente como se muestra aquí: page.expect_response() y el dominio Network de CDP leen lo que el navegador realmente envió, incluido el encabezado de autorización o el hash de consulta persistente. Solo la recompensa de repetición directa deja de aplicarse, porque reconstruir lo que el navegador adjuntó se convierte en la parte difícil.
Un límite más sutil apareció durante la verificación de este artículo y vale la pena nombrarlo directamente: un endpoint de GraphQL público, no autenticado, todavía puede estar detrás de una mitigación de bots basada en huellas digitales que no tiene nada que ver con la consulta en sí. Un urllib POST simple contra el endpoint anterior, enviado sin el encabezado User-Agent (el propio valor predeterminado de Python, literalmente la cadena Python-urllib/3.12), devolvió un HTTP 403 con error de Cloudflare 1010: "el propietario de este sitio web ha prohibido su acceso basado en la firma de su navegador." Eso ocurrió cada vez, de manera reproducible, a pesar de que los encabezados de límite de tasa de costo de consulta del propio gateway informaron que aún quedaba presupuesto. Agregar una sola cadena User-Agent de un navegador ordinario, y nada más sobre la solicitud, pasó la misma verificación en cada llamada siguiente. El bloqueo se basaba en la identidad declarada del cliente, no en el contenido de la solicitud o en cuán a menudo llegaba. Una sesión de navegador en la nube que presenta una firma de Chromium real, el tipo que proporciona el endpoint CDP del Scraping Browser, nunca lleva esa discrepancia en primer lugar.

Conclusión

Una API de GraphQL intercambia las muchas URL autorreferenciales de REST por un solo endpoint y un cuerpo de solicitud que debe leerse para saber qué está pidiendo. page.expect_response() y el dominio CDP bruto Network leen ese cuerpo de todos modos, emparejados por contenido en lugar de por dirección, y un cliente HTTP simple reproduce el mismo JSON una vez que se confirma la forma. Mantenga el filtro de consulta vinculado a operationName o al texto de la consulta en lugar de la URL, espere que un editor de consultas vacío necesite una entrada real antes de que ocurra algo interesante, y trate la capa de mitigación de bots de un endpoint público como una preocupación separada de su autenticación. Para la mecánica de CDP que construyen ambos caminos de captura, el explicador del Protocolo de DevTools de Chrome describe lo que el protocolo expone más allá del dominio de la Red.

Regístrese en app.scrapeless.com para obtener gratuitamente la ejecución del Scraping Browser, o vea la página del producto Scraping Browser y precios para ejecuciones a gran escala.

Únase a nuestra comunidad para comparar notas con otros desarrolladores que construyen automatización de navegadores: Discord · Telegram.

FAQ

P: ¿Qué es la interceptación de GraphQL en web scraping?
Es leer la única solicitud POST que el propio JavaScript de una página respaldada por GraphQL envía para obtener sus datos: el query y variables en ese cuerpo de solicitud, en lugar de esperar a que la respuesta se renderice en HTML y se vuelva a analizar el marcado.

P: ¿Por qué no puedes saber qué operación de GraphQL se ejecutó solo a partir de la URL de la solicitud?
Porque un gateway de GraphQL normalmente sirve cada operación desde un único endpoint fijo. A diferencia de una API REST, donde diferentes rutas corresponden a diferentes recursos, la identidad de una solicitud de GraphQL vive en su cuerpo POST: el campo operationName o el texto query, no en la dirección a la que se envió.

P: ¿Necesitas un navegador una vez que conoces la consulta, variables y endpoint?
Solo si el endpoint requiere algo que el navegador proporciona, como un encabezado de autorización o un hash de consulta persistente registrado. Un endpoint público que acepta una cadena de consulta completa sin autenticación, como el que se describe en esta guía, puede ser reproducido con un cliente HTTP simple, como muestra el ejemplo de reproducción directa.

P: ¿Cuál es la diferencia entre page.expect_response() y el dominio CDP bruto Network aquí?
page.expect_response() es el envoltorio de nivel superior de Playwright, vinculado a la acción que desencadena la solicitud y devuelve un objeto Response analizado. El dominio CDP Network es el protocolo subyacente: Network.requestWillBeSent, Network.loadingFinished y Network.getResponseBody — útil sin un enlace de Playwright en absoluto, o cuando un filtro necesita inspeccionar el cuerpo de la solicitud saliente antes de que exista la respuesta.

P: ¿Es legal interceptar las propias consultas de un playground de GraphQL público?
Leer las respuestas que tu propia sesión del navegador ya recibe mientras visitas una página pública conlleva consideraciones diferentes a las de acceder a datos autenticados o no públicos. Limita cualquier flujo de trabajo a páginas públicas, respeta los términos de servicio del objetivo y las directrices de robots, y mantiene el volumen de solicitudes acotado: la interceptación es una forma de leer el tráfico con precisión, no una licencia para ignorar las reglas de acceso.

Q: ¿Qué sucede con una API de GraphQL autenticada o solo de consulta persistida?
El paso de interceptación sigue funcionando: ambos caminos de captura leen lo que el navegador realmente envió, incluyendo el encabezado de autorización o el hash de la consulta persistida. El paso de repetición directa es el que falla, porque un servidor solo de hash rechaza una solicitud construida a partir de una cadena de consulta cruda que nunca fue registrada, y un endpoint autenticado rechaza una solicitud que falta el encabezado que la sesión original llevó.

Q: ¿Por qué el ejemplo de repetición directa establece un encabezado User-Agent si no es un navegador?
Porque el borde del gateway rechaza la solicitud sin uno. Un simple POST urllib usando la cadena User-Agent por defecto de Python devuelve un error 1010 de Cloudflare en cada intento, aunque los encabezados de límite de tasa de costo de consulta muestran presupuesto restante: el bloqueo se basa en la identidad declarada del cliente, no en la consulta o en cuán a menudo se envía. Una cadena ordinaria de navegador User-Agent, sin nada más cambiado en la solicitud, es suficiente para pasar.

Q: ¿Esta técnica necesita específicamente el Scrapeless Scraping Browser, o funciona con cualquier Chromium accesible a través de CDP?
La mecánica de interceptación es un comportamiento genérico de CDP y funciona contra cualquier Chromium accesible a través de connect_over_cdp, local o remoto. Ejecutarlos en el Scrapeless Scraping Browser añade una sesión de Chromium en la nube con una firma de navegador real, lo cual es importante para un frontend que identifica huellas de su cliente antes de permitir que se ejecute una consulta en primer lugar.

Q: ¿Qué sucede si el objetivo cambia su esquema o forma de consulta?
El código de interceptación sigue funcionando siempre que la URL del endpoint siga coincidiendo: lee cualquier cuerpo que envíe el navegador independientemente de los campos de la consulta. Un campo renombrado o un tipo reestructurado rompe el código que lee data["characters"]["results"], de la misma manera que un selector CSS se rompe cuando cambia un nombre de clase; un esquema de GraphQL es típicamente más estable que una estructura de marcado, pero no es inmune a un cambio disruptivo.

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.

Artículos más populares

Catalogar