Playwright + Navegador de Scraping Sem Rastreio: Capturar e Reproduzir uma API GraphQL Oculta
Web Data Collection Specialist
Abra a aba de Rede em rickandmortyapi.com/graphql e observe por um minuto: a chamada de introspecção do esquema que o GraphiQL dispara no instante em que a página carrega, e a consulta que você digita e executa um momento depois, ambas chegam na mesma URL exata. Uma API REST espalha seu comportamento por caminhos — /characters, /episodes, /locations/1 — então a URL sozinha informa para que serve uma solicitação. Uma API GraphQL colapsa tudo isso em um único endpoint e move a solicitação real para o corpo do POST em vez disso: uma string query nomeando os campos que você deseja, um objeto variables fornecendo os argumentos, às vezes uma tag operationName identificando qual é. Ler esse tráfego significa ler o corpo, não a URL, porque a URL parou de carregar o sinal.
Este guia conecta o Playwright ao Scrapeless Scraping Browser via CDP, aciona um verdadeiro playground público de GraphQL para disparar uma consulta e intercepta o POST resultante de duas maneiras independentes — os próprios eventos de resposta do Playwright e o domínio CDP bruto Network por baixo deles — antes de reproduzir essa solicitação exata com um cliente HTTP simples e sem navegador algum. Cada comando abaixo foi executado contra o alvo ao vivo.
Um Endpoint, Cada Operação
https://rickandmortyapi.graphcdn.app/ é o endereço que o próprio playground GraphiQL da API Rick e Morty realmente chama, um nível abaixo do rickandmortyapi.com/graphql apelido mais amigável que sua documentação informa; ele responde tanto às solicitações para esse apelido quanto às solicitações para o endereço CDN diretamente com dados idênticos. Esse único endereço atende cada operação que o playground pode enviar: a consulta de introspecção que ele dispara automaticamente ao carregar para popular seu explorador de esquema, e qualquer consulta que você digitar e executar por conta própria. Um filtro de rede escrito apenas contra essa URL (page.route("**/graphcdn.app/**", ...), ou um ouvinte CDP focado apenas no nome do host) pegaria ambos indiscriminadamente — exatamente o problema que a convenção de serviço HTTP do GraphQL cria por design: uma URL, um método, cada operação diferenciada pelo que está dentro da solicitação em vez de onde é enviada. Isolar a única consulta que realmente importa significa ler o campo operationName do corpo do POST ou o texto query em si, não o endereço para o qual foi enviada.
O próprio playground torna a segunda metade da diferença óbvia: ao contrário de um endpoint REST acionado por rolagem que dispara no momento em que uma página carrega ou um usuário rola, o editor de consultas do GraphiQL começa vazio. Nada significativo acontece até que você digite uma consulta e clique em Executar — a técnica aqui tem que dirigir essa interação, não apenas esperar por ela.
Pré-requisitos
Você precisa do Python 3.9 ou mais recente — playwright 1.59.0 declara Requires-Python >=3.9 no PyPI — o pacote playwright, e uma chave de API do Scrapeless do plano gratuito em app.scrapeless.com. O endpoint GraphQL alvo em si não precisa de chave ou conta própria; é um dado público, não autenticado. Mantenha a chave do Scrapeless em uma variável de ambiente em vez de um literal no seu script, uma vez que ela viaja como o parâmetro de consulta token no endpoint CDP do Scraping Browser.
Instalar
bash
pip install playwright
bash
export SCRAPELESS_API_KEY="your_scrapeless_api_key"
Conectar via CDP
Use o mesmo padrão de construtor de URL que cada script de Playwright para Scraping Browser nesta série utiliza: 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=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()) devolve um objeto padrão do Playwright Browser, sem necessidade de instalação local do Chrome. Nada sobre as duas técnicas de interceptação abaixo é específico do Scraping Browser — elas funcionam contra qualquer Chromium acessível pelo CDP — mas executar a renderização na infraestrutura do Scraping Browser significa que um frontend GraphQL que identifica seu próprio cliente ainda hidrata e dispara suas consultas normalmente.
Disparar a Consulta e Capturá-la com um Ouvinte de Resposta
page.expect_response() vincula a espera à ação que a aciona, de modo que funcione quer essa ação seja uma page.goto() ou, como aqui, uma interação de UI que você controla. Digite uma consulta real e suas variáveis no editor do GraphiQL, clique em Executar dentro do contexto expect_response, e o objeto Response capturado devolve exatamente o que o próprio JavaScript do site enviou e recebeu:
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()
Executá-lo contra o playground ao vivo imprime:
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"] é o mesmo dicionário Python que o painel de Variáveis do editor mantinha — {"page": 1, "name": "rick"} — confirmando que a interceptação leu o corpo da solicitação real, não uma suposição do que a consulta poderia conter. page.keyboard.insert_text() ao invés de page.keyboard.type() importa aqui: CodeMirror, o editor que o GraphiQL usa, fecha automaticamente os parênteses à medida que você os digita caractere por caractere, portanto, simular pressionamentos de tecla individuais para uma consulta cheia de { e } produz chaves de fechamento duplicadas e um erro de sintaxe. insert_text() insere toda a string de uma vez, da maneira que uma colagem faria, e ignora a lógica de fechamento automático por pressionamento de tecla completamente.
Combine a Solicitação Correta no Domínio de Rede CDP Bruto
Os eventos de resposta do Playwright estão no topo do domínio de rede do Chrome DevTools Protocol, acessível diretamente através de um CDPSession para casos em que você não está controlando o Playwright de forma alguma — um cliente CDP puro ou uma ferramenta que apenas expõe eventos de protocolo. Como a URL do endpoint sozinha não distingue operações, o filtro de nível CDP precisa inspecionar postData da mesma forma que a captura de nível superior o faz implicitamente, correspondendo ao clique que a acionou:
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 dispara com o próprio campo postData da requisição de saída já anexado, antes que a resposta exista — o ponto natural para decidir se este POST específico é o que vale a pena acompanhar. Network.loadingFinished confirma que a resposta correspondente terminou de transferir, e só então getResponseBody retorna os bytes. A página 2 retorna com um primeiro resultado diferente do que a página 1 fez, que é o ponto: o caminho raw-CDP e o caminho do listener de resposta estão lendo o mesmo cabo, correspondendo de duas maneiras diferentes, e ambos chegam a dados reais e distintos da mesma consulta ao vivo.
O Que Você Recebe de Volta
Ambos os caminhos de captura retornam a mesma forma Character para esta consulta, porque ambos leem a mesma resposta subjacente.
| Campo | Tipo | Significado |
|---|---|---|
info.count |
inteiro | Total de caracteres que correspondem ao filtro em todas as páginas |
info.pages |
inteiro | Total de páginas no tamanho da página atual |
results[].id |
string | ID do personagem, utilizável diretamente em uma consulta de character(id: ...) de acompanhamento |
results[].name |
string | Nome do personagem |
results[].status |
string | "Alive", "Dead", ou "unknown" |
results[].species |
string | Classificação de espécie |
Troque filter: { name: $name } por filter: { status: "Alive" } ou elimine completamente o argumento de filtro, e os mesmos dois scripts de captura continuam funcionando sem modificações — apenas a carga variables e a info.count resultante mudam, porque a técnica de nível de cabo não depende de quais campos ou argumentos uma consulta específica acaba usando.
Obtenha um runtime gratuito do Scraping Browser cadastrando-se em app.scrapeless.com e executando ambos os scripts de captura acima contra um endpoint GraphQL próprio.
Reproduza a Consulta Sem Navegador Algum
Ambas as interceptações acima provaram a mesma coisa: https://rickandmortyapi.graphcdn.app/ aceita um POST JSON simples com query, variables, e operationName, sem autenticação, e retorna os mesmos dados Character que já foram mostrados por ambas as capturas. Uma vez que essa forma é conhecida, um navegador não é mais necessário para fazer a mesma pergunta — embora a requisição tenha que declarar um User-Agent normal, ou a borda do gateway a rejeita imediatamente, independentemente da carga; a seção de limites abaixo cobre o porquê:
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
Mesma info, mesmo primeiro resultado, mesma página de 20 linhas como a captura do listener de resposta — porque é exatamente a mesma solicitação, enviada por urllib em vez de pela própria chamada fetch do GraphiQL. O trabalho inteiro do navegador neste fluxo de trabalho foi revelar o endpoint, a forma da consulta e o formato das variáveis; uma vez que esses são conhecidos, um POST GraphQL é apenas JSON sobre HTTP carregando um documento de solicitação na forma que a própria especificação GraphQL define, e a maneira mais rápida de repetir a mesma pergunta geralmente é parar de renderizar uma página e perguntar diretamente.
Quando o Navegador Permanece no Loop
Nem todo endpoint GraphQL é tão cooperativo, por razões específicas de como os gateways GraphQL são comumente implantados. Muitos requerem um cabeçalho Authorization que carrega um token que o próprio JavaScript do frontend anexa do armazenamento local ou de um cookie, algo que você não pode reconstruir, a menos que tenha capturado de uma sessão real — a mesma limitação que as APIs ocultas em forma de REST compartilham. Alguns vão além e impõem Consultas Persistidas Automáticas, onde o cliente envia um hash SHA-256 da consulta em vez do texto da consulta em si; um servidor que aceita apenas hashes pré-registrados rejeita uma solicitação reproduzida construída a partir de uma string de consulta sozinha, porque o hash nunca foi registrado a partir desse cliente. Em ambos os casos, o passo de interceptação ainda funciona exatamente como mostrado aqui: page.expect_response() e o domínio Network CDP leem o que o navegador realmente enviou, incluindo o cabeçalho de autenticação ou o hash de consulta persistido. Apenas o payoff da reprodução direta deixa de se aplicar, porque reconstruir o que o navegador anexou se torna a parte difícil.
Um limite mais sutil apareceu durante a verificação deste artigo e vale a pena ser nomeado diretamente: um endpoint GraphQL público e não autenticado ainda pode estar atrás de mitigação de bots baseada em impressão digital que nada tem a ver com a consulta em si. Um simples urllib POST contra o endpoint acima, enviado sem o cabeçalho User-Agent (o padrão do Python, literalmente a string Python-urllib/3.12), retornou um HTTP 403 com erro 1010 do Cloudflare: "o proprietário deste website baniu seu acesso com base na assinatura do seu navegador." Isso aconteceu sempre, de forma reproduzível, mesmo que os cabeçalhos de limite de taxa de custo de consulta do gateway relataram orçamento ainda disponível. Adicionar uma única string de navegador comum User-Agent à solicitação, e nada mais sobre a requisição, passou pelo mesmo teste em cada chamada subsequente. O bloqueio foi baseado na identidade declarada do cliente, não no conteúdo da solicitação ou na frequência com que ela chegava. Uma sessão de navegador em nuvem que apresenta uma assinatura real do Chromium, o tipo que o endpoint CDP do Scraping Browser fornece, nunca carrega essa incompatibilidade em primeiro lugar.
Conclusão
Uma API GraphQL troca os muitos URLs auto-descritivos do REST por um único endpoint e um corpo de solicitação que precisa ser lido para saber o que está pedindo. page.expect_response() e o domínio CDP bruto Network leem esse corpo de qualquer forma, correspondendo por conteúdo em vez de endereço, e um cliente HTTP simples reproduz o mesmo JSON uma vez que a forma é confirmada. Mantenha o filtro de consulta baseado em operationName ou no texto da consulta em vez da URL, espere que um editor de consulta vazio precise de uma entrada real digitada antes que algo interessante aconteça, e trate a camada de mitigação de bots de um endpoint público como uma preocupação separada de sua autenticação. Para as mecânicas do CDP, ambos os caminhos de captura nos quais se baseiam, o explicador do Chrome DevTools Protocol passa pelo que o protocolo expõe além do domínio Network.
Inscreva-se em app.scrapeless.com para um tempo de execução gratuito do Scraping Browser, ou veja a página do produto Scraping Browser e preços para execuções escaladas.
Junte-se à nossa comunidade para trocar ideias com outros desenvolvedores que estão construindo automação de navegador: Discord · Telegram.
FAQ
Q: O que é a interceptação de GraphQL em web scraping?
É ler a única solicitação POST que o próprio JavaScript de uma página com GraphQL envia para buscar seus dados — o query e variables nesse corpo de solicitação — em vez de esperar pela resposta para renderizar em HTML e analisar a marcação de volta.
Q: Por que você não pode dizer qual operação GraphQL foi executada apenas a partir da URL da solicitação?
Porque um gateway GraphQL geralmente serve todas as operações de um único endpoint fixo. Ao contrário de uma API REST, onde diferentes caminhos correspondem a recursos diferentes, a identidade de uma solicitação GraphQL vive em seu corpo POST — o campo operationName ou o texto query — não no endereço para o qual foi enviada.
Q: Você precisa de um navegador uma vez que conhece a consulta, variáveis e endpoint?
Apenas se o endpoint exigir algo que o navegador fornece, como um cabeçalho de autorização ou um hash de consulta persistente registrado. Um endpoint público que aceita uma string de consulta completa sem autenticação, como o que está neste guia, pode ser reproduzido com um cliente HTTP simples, como o exemplo de reprodução direta mostra.
Q: Qual é a diferença entre page.expect_response() e o domínio CDP Network bruto aqui?
page.expect_response() é o wrapper de nível mais alto do Playwright, vinculado à ação que aciona a solicitação e retornando um objeto Response analisado. O domínio Network do CDP é o protocolo por baixo — Network.requestWillBeSent, Network.loadingFinished e Network.getResponseBody — útil sem um vínculo do Playwright, ou quando um filtro precisa inspecionar o corpo da solicitação de saída antes que a resposta exista.
Q: É legal interceptar as próprias consultas de um playground GraphQL público?
Ler respostas que sua própria sessão de navegador já recebe enquanto visita uma página pública tem considerações diferentes do que acessar dados autenticados ou não públicos. Limite qualquer fluxo de trabalho a páginas públicas, respeite os termos de serviço do alvo e as diretrizes de bots, e mantenha o volume de solicitações contido — a interceptação é uma maneira de ler o tráfego de forma precisa, não uma licença para ignorar as regras de acesso.
Q: O que acontece com uma API GraphQL autenticada ou somente com consulta persistida?
A etapa de interceptação ainda funciona — ambos os caminhos de captura leem o que o navegador realmente enviou, incluindo o cabeçalho de autorização ou o hash da consulta persistida. A etapa de reprodução direta é a que quebra, porque um servidor somente hash rejeita uma solicitação construída a partir de uma string de consulta bruta que nunca foi registrada, e um endpoint autenticado rejeita uma solicitação que falta o cabeçalho que a sessão original carregava.
Q: Por que o exemplo de reprodução direta define um cabeçalho User-Agent se não é um navegador?
Porque a borda do gateway rejeita a solicitação sem um. Um simples POST urllib usando a string padrão User-Agent do Python retorna um erro 1010 do Cloudflare em cada tentativa, mesmo que os cabeçalhos de limite de taxa de custo de consulta mostrem orçamento restante — o bloqueio é baseado na identidade declarada do cliente, não na consulta ou com que frequência é enviada. Uma string User-Agent de um navegador comum, sem mais nada sobre a solicitação alterada, é suficiente para passar.
Q: Essa técnica precisa do Scrapeless Scraping Browser especificamente, ou funciona com qualquer Chromium acessível pelo CDP?
A mecânica de interceptação é um comportamento genérico do CDP e funciona contra qualquer Chromium acessível através de connect_over_cdp, local ou remoto. Executá-las no Scrapeless Scraping Browser adiciona uma sessão de Chromium em nuvem com uma assinatura de navegador real, o que é importante para um frontend que identifica seu cliente antes de permitir que uma consulta seja executada em primeiro lugar.
Q: O que acontece se o alvo mudar seu esquema ou formato de consulta?
O código de interceptação continua funcionando enquanto a URL do endpoint ainda corresponder — ele lê qualquer corpo que o navegador envia, independentemente dos campos da consulta. Um campo renomeado ou um tipo reestruturado quebra o código que lê data["characters"]["results"], da mesma forma que um seletor CSS quebra quando um nome de classe muda; um esquema GraphQL é tipicamente mais estável do que a marcação, mas não é imune a uma mudança quebradora.
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.



