返回博客

Playwright + Scrapeless Scraping Browser:捕获并重放隐藏的 GraphQL API

Isabella Garcia
Isabella Garcia

Web Data Collection Specialist

06-Aug-2026

在 rickandmortyapi.com/graphql 上打开网络选项卡并观察一分钟:当页面加载时,GraphiQL 触发的架构自省调用瞬间火出,而你自己输入并运行的查询,两个请求都落在完全相同的 URL 上。REST API 将其行为分散到路径上 — /characters、/episodes、/locations/1 — 因此仅凭 URL 就可以告诉你请求的目的。GraphQL API 将所有这些折叠成一个端点,并将实际请求移到 POST 主体中:一个 query 字符串命名你想要的字段,一个 variables 对象提供参数,有时还有一个 operationName 标签来识别这是什么。读取该流量意味着读取主体,而不是 URL,因为 URL 已不再携带信号。

本指南通过 CDP 将 Playwright 连接到 Scrapeless Scraping Browser,驱动一个真实的公共 GraphQL 沙盒执行一个查询,并以两种独立方式拦截生成的 POST — Playwright 自己的响应事件,以及它们下面的原始 CDP Network 域 — 然后用一个普通的 HTTP 客户端和完全不使用浏览器的方式重放该精确请求。下面的每个命令都针对实时目标执行。

一个端点,每个操作

https://rickandmortyapi.graphcdn.app/ 是 Rick 和 Morty API 自己的 GraphiQL 沙盒实际调用的地址,位于其文档宣传的更友好的 rickandmortyapi.com/graphql 别名后面一层;它直接对该别名的请求和 CDN 地址的请求作出相同的数据响应。该单一地址服务于沙盒可以发送的每个操作:它在加载时自动触发以填充其架构浏览器的自省查询,以及你自己输入并执行的任何查询。仅针对该 URL 编写的网络过滤器(page.route("**/graphcdn.app/**", ...),或一个仅根据主机名键入的 CDP 监听器)会毫无区分地捕捉到这两者 — 这正是 GraphQL 自身的 HTTP 服务约定 设计上所创造的问题:一个 URL,一种方法,每个操作通过请求内容而非发送位置来区分。隔离真正重要的查询意味着读取 POST 主体的 operationName 字段或 query 文本本身,而不是它发送到的地址。

沙盒本身使差异的后半部分变得明显:与在页面加载或用户滚动时触发的滚动触发的 REST 端点不同,GraphiQL 的查询编辑器一开始是空的。直到你输入查询并点击执行之前,没有什么有意义的事情发生 — 这里的技术必须驱动该交互,而不仅仅是等待。

先决条件

你需要 Python 3.9 或更新的版本 — playwright 1.59.0 在 PyPI 声明了 Requires-Python >=3.9 — playwright 包,以及来自 app.scrapeless.com 免费计划的 Scrapeless API 密钥。目标 GraphQL 端点本身不需要自己的密钥或帐户;它是公共的、未经身份验证的数据。将 Scrapeless 密钥保存在环境变量中,而不是脚本中的字面量,因为它作为 token 查询参数在 Scraping Browser 的 CDP 端点上传输。

安装

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

通过 CDP 连接

重用此系列中每个 Playwright 到 Scraping Browser 脚本使用的相同 URL 构建模式:在一个 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()) 返回一个标准的 Playwright Browser 对象,不需要本地 Chrome 安装。下面这两种拦截技术没有 Scraping Browser 特定的内容 — 它们可以在任何可通过 CDP 访问的 Chromium 上运行 — 但在 Scraping Browser 的基础设施上运行渲染意味着一个 GraphQL 前端仍然会指纹其自己的客户端并正常运行其查询。

触发查询并用响应监听器捕获它

page.expect_response() 将等待绑定到触发它的操作,因此无论该操作是 page.goto() 还是像这里一样,你自己驱动的 UI 交互,它都能工作。在 GraphiQL 的编辑器中输入一个真实的查询及其变量,在 expect_response 上下文中点击执行,而捕获的 Response 对象将返回网站自己的 JavaScript 发送和接收的确切内容:

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

在实时沙盒上运行它会打印:

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"] 是编辑器的变量面板所持有的相同 Python 字典 — {"page": 1, "name": "rick"} — 确认拦截读取了实际请求主体,而不是对查询可能包含内容的猜测。page.keyboard.insert_text() 而不是 page.keyboard.type() 在这里更为重要:CodeMirror,GraphiQL 使用的编辑器,在你逐字输入时会自动关闭括号,因此对充满 { 和 } 的查询进行单个按键模拟会产生重复的闭合花括号和语法错误。insert_text() 一次性插入整个字符串,就像粘贴一样,完全跳过逐键自动关闭逻辑。

匹配原始 CDP 网络域中的正确请求

Playwright 的响应事件位于 Chrome DevTools Protocol 网络域 之上,可以通过 CDPSession 直接访问,适用于您完全不使用 Playwright 的情况——一个裸 CDP 客户端或仅暴露协议事件的工具。由于端点 URL 本身并不能区分操作,因此 CDP 级别的过滤器必须以与较高级别的捕获隐式匹配触发点击的方式检索 postData:

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 在响应存在之前,已附加了发出请求的 postData 字段——这是决定这个特定 POST 是否值得跟踪的自然时机。Network.loadingFinished 确认匹配的响应完成传输,只有在那时 getResponseBody 才返回字节。第 2 页返回的第一个结果与第 1 页不同,这就是关键:原始 CDP 路径和响应监听器路径读取相同的线路,通过两种不同方式匹配,并且都获得来自同一实时查询的真实、不同数据。

您得到的返回

两个捕获路径为此查询返回相同的 Character 形状,因为两者都读取相同的底层响应。

字段 类型 意义
info.count 整数 所有页面中匹配过滤器的总字符数
info.pages 整数 当前页面大小的总页面数
results[].id 字符串 字符 ID,可直接用于后续 character(id: ...) 查询
results[].name 字符串 字符名称
results[].status 字符串 "Alive"、"Dead" 或 "unknown"
results[].species 字符串 物种分类

用 filter: { name: $name } 替换 filter: { status: "Alive" } 或完全删除过滤器参数,这两个捕获脚本仍然可以正常工作——只有 variables 有效负载和结果 info.count 发生变化,因为线路级技术不依赖于特定查询使用的字段或参数。

通过在 app.scrapeless.com 注册获取免费的 Scraping Browser 运行时,并针对您自己的 GraphQL 端点运行上面的两个捕获脚本。

完全不使用浏览器重放查询

上述两个拦截证明了同一件事:https://rickandmortyapi.graphcdn.app/ 接受一个包含 query、variables 和 operationName 的普通 JSON POST,无需身份验证,并返回与两个捕获显示的相同 Character 数据。一旦知道了这个形状,就不再需要浏览器来询问相同的问题——尽管请求必须声明一个普通的 User-Agent,否则网关的边缘会直接拒绝它,而不考虑有效负载;下面的限制部分涵盖了原因:

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

同样的 info,同样的第一个结果,同样的 20 行页面作为响应监听器捕获——因为这是完全相同的请求,由 urllib 发送,而不是 GraphiQL 自身的 fetch 调用。在这个工作流中,浏览器的全部工作是揭示端点、查询形状和变量格式;一旦这些被知道,GraphQL POST 就只是通过 HTTP 发送的 JSON,承载形状 GraphQL 规范本身定义的请求文档,通常再次询问同一问题的最快方法是停止渲染页面并直接询问。

当浏览器保持在循环中时

并非每个 GraphQL 端点都是如此配合,这与 GraphQL 网关的常见部署方式有关。许多需要一个 Authorization 头,其中包含来自前端自己 JavaScript 附加的本地存储或 cookie 中的令牌,除非您从真实会话中捕获到它,否则无法重新构建——这是 REST 形状的隐藏 API 所面临的相同限制。有些更进一步,强制实施 自动持久化查询,其中客户端发送查询的 SHA-256 哈希,而不是查询文本本身;仅接受预注册哈希的服务器会拒绝仅从查询字符串构建的重放请求,因为该客户端未注册过该哈希。在这两种情况下,拦截步骤仍然如这里所示那样正常工作:page.expect_response() 和 CDP Network 域读取浏览器实际发送的内容,包括身份验证头或持久化查询哈希。只有直接重放的收益停止适用,因为重构浏览器附加的内容变成了困难之处。
在验证本文时出现了一个更微妙的限制,值得直接提及:一个公共的、未经身份验证的 GraphQL 端点仍然可以位于基于指纹的机器人的减轻机制后面,这与查询本身没有任何关系。对上述端点发出的普通 urllib POST 请求,未携带 User-Agent 标头(Python 的默认标头,实际上是字符串 Python-urllib/3.12),返回了一个 HTTP 403,附带了 Cloudflare 错误 1010:“该网站的所有者基于您的浏览器签名禁止了您的访问。” 每次都会发生这种情况,并且是可重复的,即使网关自己的查询成本速率限制标头报告仍有预算剩余。添加一个普通浏览器的 User-Agent 字符串,并且请求中没有其他任何内容,仍通过了后续每次调用的相同检查。该阻止是以客户端声明的身份为关键,而不是请求的内容或其到达的频率。呈现真实 Chromium 签名的云浏览器会话(Scraping Browser 的 CDP 端点提供的那种)根本不会产生这种不匹配。

结论

GraphQL API 使用一个端点和必须阅读的请求体来替代 REST 的许多自描述 URL,以了解其请求的内容。page.expect_response() 和原始 CDP Network 域都不论如何读取该请求体,以内容而非地址进行匹配,而普通的 HTTP 客户端在确认形状后将相同的 JSON 重放。保持查询过滤器以 operationName 或查询文本为关键,而不是 URL,预计一个空查询编辑器在触发任何有趣的事件之前需要真实的输入,并将公共端点的机器人减轻层视为与其身份验证的单独关注。对于 CDP 机制,两者捕获的路径建立在 Chrome DevTools 协议解释器 中,了解该协议在网络域之外所暴露的内容。

在 app.scrapeless.com 注册以获取免费的 Scraping Browser 运行时,或查看 Scraping Browser 产品页面 以及 定价 以获取规模运行。

加入我们的社区,与其他构建浏览器自动化的开发者交流: Discord · Telegram。

常见问题

问:在网络爬虫中,GraphQL 拦截是什么?
它是在等待响应渲染为 HTML 并解析标记之前,读取一个 GraphQL 支持页面的 JavaScript 发送的单个 POST 请求 — 该请求体中的 query 和 variables。

问:为什么仅从请求 URL 不能确定哪个 GraphQL 操作运行了?
因为 GraphQL 网关通常从一个固定端点服务每个操作。与不同路径对应不同资源的 REST API 不同,GraphQL 请求的身份存在于其 POST 主体中 — operationName 字段或 query 文本 — 而不是在发送到的地址中。

问:一旦知道查询、变量和端点,你还需要浏览器吗?
只有在端点需要浏览器提供的某些东西时,例如授权标头或注册的持久查询哈希。一个允许全查询字符串并且无需身份验证的公共端点,如本指南中的端点,可以通过普通的 HTTP 客户端重放,如直接重放示例所示。

问:page.expect_response() 和原始 CDP Network 域之间有什么区别?
page.expect_response() 是 Playwright 的更高层包装,与触发请求的操作绑定,并返回解析后的 Response 对象。CDP Network 域是其底层协议 — Network.requestWillBeSent、Network.loadingFinished 和 Network.getResponseBody — 在没有 Playwright 绑定的情况下也很有用,或者当筛选器需要在响应存在之前检查传出的请求体时。

问:拦截公共 GraphQL playground 的查询是否合法?
在访问公共页面时,读取自己的浏览器会话已经接收到的响应与访问经过身份验证或非公共数据的考虑有所不同。将任何工作流程限制在公共页面,尊重目标的服务条款和爬虫指令,并保持请求量在合理范围内——拦截是精确读取流量的方式,而不是忽视访问规则的许可证。

问:经过身份验证或只有持久查询的 GraphQL API 会发生什么?
拦截步骤仍然有效——两个捕获路径读取浏览器实际发送的内容,包括授权头或持久查询哈希。直接重放步骤出现问题,因为仅哈希的服务器拒绝基于从未注册的原始查询字符串构建的请求,而经过身份验证的端点拒绝缺少原始会话所携带的头的请求。

问:为什么直接重放示例设置 User-Agent 头,即使它不是浏览器?
因为网关的边缘在没有该头的情况下拒绝请求。使用 Python 默认的 User-Agent 字符串进行普通的 urllib POST,每次尝试都会返回 Cloudflare 错误 1010,即使查询成本限速头显示剩余预算——阻塞是基于客户端声明的身份,而不是查询或发送的频率。一个普通的浏览器 User-Agent 字符串,且请求的其他内容没有改变,就足以通过。

问:这种技术是否需要特定的 Scrapeless Scraping Browser,还是任何可通过 CDP 访问的 Chromium 都有效?
拦截机制是通用的 CDP 行为,适用于任何可以通过 connect_over_cdp 访问的 Chromium,无论是本地还是远程。在 Scrapeless Scraping Browser 上运行它们会添加一个带有真实浏览器签名的云 Chromium 会话,这对于在让查询首先发出之前对其客户端进行指纹识别的前端非常重要。

问:如果目标更改其架构或查询形状,会发生什么?
只要端点 URL 仍然匹配,拦截代码就会继续工作——它会读取浏览器发送的任何请求体,而不管查询的字段。重命名字段或重构类型会破坏读取 data["characters"]["results"] 的代码,就像类名改变时 CSS 选择器会失效一样;GraphQL 架构通常比标记更稳定,但不能免于破坏性更改。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录