用 mitmproxy 逆向 GraphQL persistedQuery 扩展:从哈希还原完整查询,让 Crawlee 爬虫稳定抓取
【免费下载链接】crawleeCrawlee—A web scraping and browser automation library for Node.js to build reliable crawlers. In JavaScript and TypeScript. Extract data for AI, LLMs, RAG, or GPTs. Download HTML, PDF, JPG, PNG, and other files from websites. Works with Puppeteer, Playwright, Cheerio, JSDOM, and raw HTTP. Both headful and headless mode. With proxy rotation.项目地址: https://gitcode.com/GitHub_Trending/cr/crawlee
GraphQL 的 persisted query(持久化查询)优化会用一段预计算的哈希代替完整查询文本来降低请求体积,却让爬虫看不到真正的查询内容。本文讲解如何利用 mitmproxy 拦截并篡改请求中的sha256Hash,触发服务端PersistedQueryNotFound错误流,迫使客户端自曝完整 GraphQL 查询,并在还原出查询与哈希后,用 Crawlee 的HttpCrawler将其落地为稳定可复现的 POST 爬虫请求。
先理解目标:GraphQL 查询请求长什么样
GraphQL 是一种从网站后端获取深层嵌套结构化数据的查询语言,与 MongoDB 的查询语法类似。它把"要什么字段、嵌套多深"直接写进请求里,因此非常灵活,也因此成为许多大型网站(房产、旅游、电商等)前端数据接口的主流选择。
一次典型的 GraphQL 请求通常是发往某个通用/graphql端点的 POST 请求,请求体是 JSON:
JSON 里包含operationName(操作名)、variables(变量)、extensions.persistedQuery(扩展信息)等字段。当我们用爬虫抓取这类网站时,常规做法是打开开发者工具,在网络面板里找到这些请求,把query字段原样复制出来用于爬虫。
但有些网站上你会发现:请求里根本看不到 GraphQL 查询文本,取而代之的是一串难以理解的哈希值。这并非请求被加密,而是网站启用了persisted queries(持久化查询)这一性能优化特性。
persisted query 机制:用哈希替换查询文本
persisted query(在 Apollo 生态中常被称为 APQ,即 Automatic Persisted Queries)的核心思想是:
客户端计算查询文本的 sha256 哈希,请求时只发送这个哈希,不再发送完整查询文本。
服务端在首次收到"哈希 + 完整查询"后,会把两者缓存起来;此后客户端只要携带哈希,服务端就能命中缓存并执行对应查询。这样一来,每个请求的 payload 大幅缩小,并且由于哈希是稳定可复现的,整个查询甚至可以放进 GET 请求的 query string 里,从而获得 HTTP 缓存能力。
以 Zillow 为例,其实际发出的请求(GET 形式)内容大致是:
extensions.persistedQuery.version = 1 extensions.persistedQuery.sha256Hash = <64 位十六进制哈希> variables = { ...要嵌入查询的变量... }也就是说,请求里只剩下关于 persistedQuery 扩展的元数据(版本号、哈希)和要注入查询的变量,查询本体被完全隐去了。Expedia(expedia.com)发出的 POST 请求也使用同样的extensions.persistedQuery结构。
查询文本是怎么被"藏"起来的
需要特别说明的是:哈希是由客户端(前端 JavaScript)在本地计算的。站点前端通常依赖 Apollo Client 等 GraphQL 客户端库,库会在运行时对查询文档做规范化处理,再计算 sha256 得到哈希。因此哈希与查询文本之间存在确定性的映射关系——只要查询文本不变,哈希就不会变。这也是后文"还原查询、持续复用"方案能够成立的根本前提。
对爬虫而言,persisted query 带来的三个痛点
persisted query 首先是一项面向网站性能的优化,但它对爬虫开发者却制造了不小的麻烦:
- GET 请求更容易被风控拦截。为了可缓存,许多站点把 GraphQL 请求改造成 GET + query string 形式,而 GET 请求在反爬体系里通常是被重点关照的对象,命中率与稳定性都不如 POST。
- 查询参数被隐藏,无法应对错误回退。我们不知道完整的查询文本,因此当服务端返回
Persisted query not found(提示"请把完整查询发过来,不要只发哈希")时,我们手里只有哈希,根本没有能力补发完整查询。 - 哈希会随着缓存淘汰而永久失效。只要网站前端做了一点点改动、客户端开始请求新查询,服务端就会很快"遗忘"旧哈希。即便旧查询在功能上仍然可用,你也无法再"提醒"服务端完整查询文本——带旧哈希的请求从此永远失效。
因此,无论是要理解网站数据模型,还是要构建长期稳定的爬虫,我们都需要把完整 GraphQL 查询文本还原出来。
核心思路:利用 PersistedQueryNotFound 错误流让客户端自曝
还原查询文本最直观的思路是去读网站的前端 JavaScript,从中拼出查询。但现实是:查询往往由多个 fragment(片段)动态拼接而成,散落在大量打包压缩后的代码里,人工拼装极其痛苦且易错。
更聪明的做法是骗客户端自己把完整查询交出来,走的是客户端-服务端自然存在的错误处理流程:
- 正常情况下,客户端用哈希发起请求,服务端命中缓存后直接返回数据;
- 但如果客户端使用的哈希是服务端不认识的,服务端会返回类似
PersistedQueryNotFound的错误; - 客户端(Apollo Client 等)收到这个错误后,会自动在紧接着的下一次请求里把完整查询文本连同哈希一起发出去,以便"教会"服务端;
- 我们只要在中间拦下这第二次请求,就能拿到完整的查询文本。
关键在于第 2 步:我们主动把原始请求里的哈希篡改成一段任意的、服务端必然不认识的伪哈希,从而人为制造出PersistedQueryNotFound,诱使客户端进入"补发完整查询"的流程。
环境准备:mitmproxy + FoxyProxy
实施上述方案需要一个能拦截并改写自己设备流量的中间人代理。mitmproxy正是为此而生的开源工具:它是一个基于 Python 的代理,可以拦截由你的设备、网站或 App 发起的请求,并通过简单的 Python 脚本来修改这些请求。
第一步:安装 mitmproxy(可通过 pip 安装,命令行工具包含mitmproxy、mitmdump、mitmweb三个入口,本文使用带 Web 界面的mitmweb)。
第二步:配置浏览器代理路由。浏览器扩展FoxyProxy(Firefox 和 Chrome 都有)可以把浏览器请求重定向到我们的 mitmproxy 代理。在 FoxyProxy 中添加一条路由,指向 mitmproxy 的监听地址与端口:
这样配置后,浏览器发出的所有请求都会先经过 mitmproxy,由我们的脚本处理后继续转发。
拦截脚本:把 sha256Hash 篡改成垃圾值
mitmproxy 的脚本机制非常简单:定义一个名为request的函数作为钩子,它会作用于每一个经过代理的请求。我们准备如下 Python 脚本:
import json def request(flow): try: dat = json.loads(flow.request.text) dat[0]["extensions"]["persistedQuery"]["sha256Hash"] = "0d9e" # any bogus hex string here flow.request.text = json.dumps(dat) except: pass逐行解读这段脚本:
flow.request.text是请求体的文本形式,这里假设请求体是 JSON 数组(许多 GraphQL 批量端点会用数组包装多个操作,如dat[0]);json.loads把请求体解析为 Python 对象;- 把第一个操作里的
extensions.persistedQuery.sha256Hash替换成任意伪造的十六进制字符串(注释里写的"0d9e"只是一个示意,任何"看起来像但实际无效"的值都可以); json.dumps把修改后的对象重新序列化,写回flow.request.text作为新的请求体;- 外层
try/except保证遇到非 JSON 或结构不符的请求时静默跳过,不影响其他流量。
注意:这里只覆盖了"哈希在dat[0](数组首个元素)"的结构。实际使用时,你需要根据目标站点的请求体结构微调取值路径——例如有些站点是单个 JSON 对象而非数组,这时应写成dat["extensions"]["persistedQuery"]["sha256Hash"]。这也是整个方案里唯一需要针对站点定制的部分。
运行并观察:从伪造哈希到完整查询
用如下命令启动 mitmproxy 并加载脚本:
mitmweb -s script.pymitmweb会打开一个浏览器标签页,实时展示所有被拦截的请求,方便我们观察流量:
接下来按步骤操作:
1. 访问目标站点,观察被篡改的哈希。打开 Zillow 等目标网站,在 mitmweb 界面中找到我们关心的 GraphQL 路径,查看该请求的 request 部分,会发现原本的sha256Hash已经被替换成了我们写入的垃圾值——这证明脚本已生效。
2. 等待服务端返回 PersistedQueryNotFound。由于哈希是伪造的,服务端自然无法命中缓存,Zillow 的响应中会出现PersistedQueryNotFound错误。这一步是预期行为,正是我们要触发的结果。
3. 观察客户端补发完整查询。前端(Zillow 的页面脚本)收到该错误后,会按 Apollo 协议的约定发起一次携带完整查询文本 + 正确哈希的 POST 请求。我们直接从这第二个请求中提取两样东西:
- 完整的 GraphQL 查询文本(
query字段); - 与它对应的正确 sha256 哈希(
extensions.persistedQuery.sha256Hash)。
至此,逆向完成:我们绕过了复杂的前端 JavaScript 逆向,只靠中间人篡改 + 客户端天然的错误处理流程,就让目标网站自己把查询交了出来。
落地到 Crawlee:把还原出的查询变成稳定的爬虫请求
拿到了完整查询和哈希之后,剩下的问题就是如何在爬虫里稳定地复现这个请求。这正是 Crawlee 的用武之地。Crawlee 是面向 Node.js 的网页抓取与浏览器自动化库,其中的HttpCrawler可以直接发送带 JSON body 的 POST 请求,非常契合 GraphQL 接口场景。
在packages/http-crawler/src/internals/http-crawler.ts#L786的getRequestOptions实现中可以看到:当请求方法是PATCH、POST或PUT时,Crawlee 会把request.payload作为请求体发送。因此我们只需要构造一个携带 GraphQL 请求体的Request即可:
import { HttpCrawler, Request } from 'crawlee'; const crawler = new HttpCrawler({ async requestHandler({ request, json }) { // 当响应 Content-Type 为 application/json 时,json 即为解析后的 GraphQL 响应体 // 这里把数据写入 dataset 或做进一步处理 }, }); await crawler.run([ new Request({ url: 'https://www.example.com/graphql', method: 'POST', headers: { 'Content-Type': 'application/json', }, payload: JSON.stringify({ operationName: 'GetListings', variables: { /* 从原始请求中复制的变量 */ }, extensions: { persistedQuery: { version: 1, sha256Hash: '从补发请求中提取到的 64 位哈希', }, }, }), // 相同 URL、不同 payload 的请求必须靠扩展唯一键区分 useExtendedUniqueKey: true, }), ]);这里有两个值得注意的源码级细节:
- POST 请求的 payload 支持:
Request类的payload字段在packages/core/src/request.ts#L121有明确定义,支持字符串或Uint8Array,并在构造校验中要求"GET 方法不能携带 payload"(request.ts#L217)。这与我们发送 GraphQL POST 请求的用法完全一致。 useExtendedUniqueKey的必要性:默认情况下 Crawlee 只用规范化后的 URL 计算uniqueKey,这意味着同一 URL 下不同 payload 的请求会被视为重复。开启useExtendedUniqueKey后,唯一键变为METHOD|payloadHash|normalizedUrl(见request.ts#L502-L504),才能区分不同变量组合的 GraphQL 查询。值得一提的巧合是:Crawlee 计算 payloadHash 使用的也是sha256(crypto.createHash('sha256'),见request.ts#L515-L517),与 persisted query 的哈希算法同源,侧面印证了这种"哈希即身份"的请求模式在工程界的普遍性。
此外,如果响应里包含分页等后续请求信息,可以使用enqueueLinks或直接pushData到 dataset;完整的HttpCrawler基础用法可参考docs/examples/http_crawler.ts与docs/examples/http_crawler.mdx。
维护:让服务端"记住"哈希
逆向出查询文本只解决了一半问题。前面提到过,persisted query 的哈希依赖服务端缓存,而服务端缓存可能被清理、重置,网站前端也可能在某个时刻切换到新查询。
因此,还原出查询与哈希之后,建议周期性(例如每天定时)重新运行一次携带完整查询文本和正确哈希的 POST 请求,即模拟"客户端首次教服务端"的行为。这样能确保服务端始终认识该哈希,即使缓存被清理或网站发生小规模变更,爬虫也能继续工作。
总结
persisted queries 是 GraphQL API 一项强大的优化手段:它通过把完整查询替换为预计算的哈希,显著压缩 payload 体积,并让查询能够以 GET 形式被缓存,从而提升网站性能。但与此同时,它给爬虫带来了实打实的挑战——查询被隐藏、哈希依赖服务端缓存、旧哈希会永久失效。
借助 mitmproxy 拦截并篡改 GraphQL 请求,我们可以在不深入复杂前端 JavaScript 的情况下高效还原完整查询文本:通过伪造哈希强制服务端返回PersistedQueryNotFound,再利用客户端自动补发完整查询的机制捕获真实 payload。拿到查询与哈希后,用 Crawlee 的HttpCrawler配合Request的method、payload、useExtendedUniqueKey选项即可稳定复现请求;周期性重放完整查询则能确保爬虫在服务端缓存重置或网站演化后依然可用。
【免费下载链接】crawleeCrawlee—A web scraping and browser automation library for Node.js to build reliable crawlers. In JavaScript and TypeScript. Extract data for AI, LLMs, RAG, or GPTs. Download HTML, PDF, JPG, PNG, and other files from websites. Works with Puppeteer, Playwright, Cheerio, JSDOM, and raw HTTP. Both headful and headless mode. With proxy rotation.项目地址: https://gitcode.com/GitHub_Trending/cr/crawlee
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考