AIREITER

请求里没有 Query 的 GraphQL API:获取 Persisted Operation 的两条路

最后更新: 2026-07-31 06:39:28

打开 DevTools 的 Network 面板,看着一个 GraphQL 页面加载完成。你翻遍所有请求体,想找熟悉的 query { ... },结果一个字都没有:只看到了 operationName、一串 64 位哈希,以及一包 variables。

不是你漏看了,而是这个站点使用了 persisted operation。客户端不再发送明文查询,只提交一个预先登记过的哈希;服务端据此在自己的注册表中查回真实查询,再执行它。抓包能告诉你调用了哪个操作、传了哪些变量,却无法直接看出它请求了哪些字段,响应结构又是什么样。

这时很多人的第一反应都走偏了:想去“破解”哈希。哈希是单向的,既破解不了,也根本没必要破解。真正要做的是先分类:你面对的到底是哪一种情况?不同路径的成本能相差一个数量级,方向选错,投入再多也只是白费。

为什么抓包里只剩哈希,没有 GraphQL 查询

先理解 persisted operation 为什么存在,才能判断该怎么处理它。

明文 GraphQL 的问题很直接:查询字符串本身长,每次请求都传完整字段树很浪费;同时服务端还得接受任意查询,等于把整个 Schema 的攻击面暴露出来。Persisted operation 同时解决了这两点:构建阶段,客户端要用的每条查询都会被提取、计算哈希,并在服务端登记为白名单;运行时客户端只发送哈希和变量,服务端只接受已登记的哈希,其他查询一律拒绝。这本质上是性能与安全设计,并不是专门用来反爬。Apollo 的 Automatic Persisted Queries 文档也将其作为推荐实践:用查询的 SHA-256 替代明文内容。抓包里看不到 query,只是这种机制带来的结果。

从逆向角度看,变化很明确:“要取什么”的信息被从请求中抽走了。你手上只剩三样东西:操作标识符——哈希或可读的 operationName、变量包,以及响应结果。中间那层“这个操作究竟选了哪些字段”,已经不在网络包里了。

实际项目里,这两端都很常见。一端是平台根本没上 persisted operation,明文 query 老老实实放在请求体中;另一端则是请求被压缩成完全不透明的封套,连字段名都读不到。对应的方法完全不同,下面分开说。

两种获取方式,成本能差一个数量级

第一种,是在客户端构建产物中找到明文查询或映射表;第二种,不再追求明文,而是把整个操作当作黑盒,原样重放。

第一种听起来更彻底,所以很多人默认先走这条路。但浪费时间往往就从这里开始:只有明文确实被打包进客户端时,这条路才便宜;而这个前提经常并不成立。

路径一:从客户端构建产物里找明文或映射

成本最低的情况,是查询明文本来就没有被藏起来。

某中国短视频平台的热榜接口就是这样:只有一个 /graphql 端点,请求体采用标准的 {operationName, variables, query} 三件套,query 字段里直接放着完整的 GraphQL 明文,operationName 也是像 hotRankQuery 这样可读的名称。这里根本谈不上“获取”——一次抓包,所有信息都摆在眼前。它甚至没有使用 persisted operation,是这个谱系中最容易处理的一端。

稍微复杂一点的是:平台确实使用了 persisted operation,但客户端依然保存着映射关系。客户端要发送哈希,就必须知道哪个操作对应哪个哈希,因此 operationName 到哈希的映射表——有时还会附带明文查询——通常会被编进前端 Bundle。构建工具可能把它生成 Manifest 文件,也可能将其内联进某个模块。找到它,就能同时拿到明文和哈希,之后也可以自行增添字段或修改 selection set。

难点不在于“找”这个动作本身,而是构建产物往往有数万行,经过压缩和混淆,映射还可能被拆散并内联。这正是本文里模型最能发挥作用的地方,后面会单独展开:它是分块检索问题,不是推理问题。

路径一的判断很简单:只要明文或映射存在于客户端,先花十分钟找一找。找到了,这是最强的获取方式,你对接口拥有完整控制权。

路径二:不追明文,把操作当黑盒直接重放

问题在于,明文往往根本不在客户端。

实现得足够彻底的 persisted operation,客户端只保留哈希,明文查询只存于服务端注册表。你把 Bundle 翻得再彻底也找不到,因为它从未被下发。此时死磕路径一,本质上是在寻找一个不存在的东西。

路径二其实是这里最容易被低估的解法:你不一定需要明文。你真正需要的通常是响应数据,而不是查询的字段树。因此,只要记录这个操作的标识符——哈希或 operationName——以及变量封套,之后原样发送,只替换你关心的输入参数即可。你依然不知道它选了哪些字段,但服务端照样会把这些字段全部返回。对于绝大多数数据获取和监控场景,这已经足够。

YouTube 的 innertube 就是这条路径的典型形态。它甚至不是 GraphQL,而是一组固定且自描述的端点:youtubei/v1/{player,search,next}。请求体由 context 封套——客户端类型、版本——和一包参数组成。没有人会试图“重建” YouTube 的内部查询图谱,这既做不到,也不值得做。正确做法是从当前页面资源中读取一次客户端版本和 context,之后每次请求都原样携带这个封套,只替换 videoId 或搜索词等输入,调用固定端点即可。整个过程中,操作语义始终是黑盒。至于某些关键值不在静态代码里、必须从运行时资源读取的情况,则属于另一类逆向问题,这里有单独说明。

路径二的优势是,它不依赖明文是否存在于客户端。无论面对哈希还是不透明封套,都不用试图理解它,只要忠实复现即可。代价是你被限制在客户端原本就会发出的请求范围内:如果想拿一个客户端从未请求过的字段,黑盒重放无能为力。

还有一种情况会让重放失效:封套中带有每次请求都要重新计算、且会过期的签名字段。黑盒方法到这里就走不通了,必须单独还原这个字段;而识别签名属于哪一类算法,则是另一篇文章讨论的内容。

两类平台分别该走哪条路

把上面两个真实案例放进一张表里,适用路径和原因就很清楚了:

平台案例

请求形态

客户端是否有明文?

适合的方法

原因

某短视频热榜 GraphQL

/graphql + {operationName, variables, query}

有,明文就在请求体中

路径一(成本接近于零)

没有 persisted operation,operationName 可读,查询明文直接可见,一次抓包就够

YouTube innertube

固定端点 + context 封套

没有可言的明文查询

路径二(黑盒重放)

不是 GraphQL,没有可重建的 query;读取一次 context 封套后原样携带即可

这两个极端说明了一件事:该怎么获取,不是由你主观选择,而是由平台的 API 设计决定。前者把防护重点放在其他地方,明文暴露无妨,因此顺手就能拿到;后者把“要取什么”封装为不透明请求,自然没有明文可找,能做的只有重放。

真正考验判断的,是中间那大片真实的 persisted GraphQL 场景。明文可能在客户端——映射被编进 Bundle,对应路径一;也可能只在服务端——客户端只拿到哈希,对应路径二。动手前必须先将它归类到其中一端。

先判断是否需要明文,否则很容易白忙

两条路之间那一个数量级的成本差,完全取决于一个判断。

如果平台实际上只向客户端下发哈希,把明文锁在服务端,而你执意走路径一恢复明文,可能在 Bundle 里挖上几天,最后才发现目标从未下发过。这不是能力不够,而是方向错了;再多投入也换不来结果。

反过来,如果你必须修改查询——例如请求客户端从未请求过的字段——路径二的黑盒重放也帮不上忙。你只能回到路径一寻找明文;如果拿不到,就会卡住。

因此,正确的起点不是“我该怎么拿到 query”,而是先问:我真的需要明文吗?

  • 只需复现客户端已有请求并读取响应:选路径二,即黑盒重放。它成本最低,也最常被忽略;不管明文在不在客户端都能用,应当作为默认选择。

  • 需要修改 selection set,或构造客户端从未发送过的查询:必须走路径一,尝试恢复明文。成本是否可控,取决于客户端有没有下发映射。如果没有,这就是成本陡增一个数量级的地方;你要么接受它,要么重新审视自己是否真的必须改查询。

把这个判断放在最前面,能避免大多数“先在路径一上冲三天,才发现本该走路径二”的浪费。这种“重写协议还是接受黑盒”的降级取舍,purification ladder 一文有更完整的讨论;在这里,它只用于帮助你选择获取路径。

在超大 Bundle 里找映射,本质是分块检索

路径一中最有技术门槛的一步,是在数万行前端构建产物里找到操作声明与映射。模型确实能在这一步节省大量时间,但首先要明确:这究竟是什么类型的任务。

它不是推理任务。你不需要模型理解整段代码最终计算了什么,而是要它从大段文本中定位:哪一块声明了 operationName 到哈希的映射,哪个模块内联了明文查询,context 封套又是在哪里组装的。这是分块检索,考验的是一次能容纳多少上下文、能否精确指出位置,而不是模型能否自我辩论。

先完成机械化拆分步骤:用脚本将构建产物拆成模块、建立索引,过滤掉 polyfill 和无关业务模块。再把处理结果交给模型,任务就会变成干净明确的一句话:“在这些块中找出声明位置。”

这个流程里,四个层级按能力分工得很清楚:

步骤

所需能力

选择

model id

在数万行代码中定位映射 / 操作声明

长上下文,能吞下大块构建产物并精确定位

Kimi K3

kimi-k3

判断走路径一还是路径二,根据少量样本归纳哪些封套字段会变化

强推理能力,能读懂结构并做取舍

Claude Opus 5

claude-opus-5

批量标注数百个操作、生成重放 Stub、补齐变量类型

低成本、高并发

Claude Sonnet 5

claude-sonnet-5

重放连不上时,阅读差异并归因(缺少 context 字段?哈希版本变了?)

中等推理能力,能结合响应差异解释问题

GPT-5.6 Sol

gpt-5.6-sol

第一层正是本文关注的核心。在定位阶段切换模型,结果会明显不同,因为真正的限制来自上下文窗口。构建产物有数万行,短上下文模型装不下,只能截断;而一次截断就可能恰好把映射裁掉。之后它说“找不到”,不是因为不会搜索,而是因为压根没见过那段代码。

不必相信我的说法,自己测一次:

  1. 抓取一条真实请求,保存操作标识符——operationName 或哈希——以及 variables。

  2. 用脚本切分前端构建产物,将其连同该标识符一起交给 kimi-k3,让它定位“这个操作在哪里声明,以及哪个块包含匹配的明文查询或哈希映射”。

  3. 只看一件事:它是否直接跳到对应位置。命中、漏掉,还是落在相邻但错误的位置。

  4. 再做对照:把同一份输入交给短上下文模型,观察它是否因为装不下而漏掉。命中率就是你的选型依据。

一次测试就足以说明:在这类任务中,长上下文并非“好一点”,而是“能不能做成”的差别。

真正的阻力,是切换模型的成本

四个模型、三个供应商、三套 SDK、三种认证方式、三种报错格式。若要让检索、判断、批量处理和归因分别使用合适模型,最直接的做法是接入三套客户端。多数人算完成本觉得不值,最后从头到尾只用一个模型:在长上下文检索阶段拿了一个装不下 Bundle 的层级,再抱怨“模型找不到”。

AIReiter 将这一层抹平:一个 key、一个兼容 OpenAI 的接口,背后接入全部四个层级;只需修改请求体中的 model 字段,就能切换模型。

# Locate the mapping: the long-context tier, swallows a big chunked build at once
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k3",
    "messages": [{"role": "user", "content": "<chunked frontend build + the operation identifier to locate>"}]
  }'

# Label operations / generate replay stubs in bulk: change the model field, leave the rest
#   "model": "claude-sonnet-5"
# Replay attribution:
#   "model": "gpt-5.6-sol"

如果你已经在使用 OpenAI SDK,只需将 base_url 指向 https://aireiter.com/api/v1,其他无需修改。使用 Anthropic SDK 时,则以同一个 key 请求 POST /api/v1/messages。

价格方面,Claude 模型按官方价格七折计费,GPT 模型半价,Kimi K3 也可通过同一个 key 调用。这个流程的成本主要集中在两处:路径一的定位环节,需要向 kimi-k3 输入每次数十万 token 的完整分块构建产物;以及批量标注数百个操作、生成重放 Stub——调用最密集的一步——使用七折的 claude-sonnet-5。折扣正好落在调用最密集的批处理环节。

结语

没有文档的 GraphQL API,不代表无法集成。Persisted operation 只是把“要取什么”从请求中移到了两个地方之一:客户端构建产物中——找到它,走路径一;或者只存在于服务端——不要追明文,把操作当黑盒重放,走路径二。

两条路径的成本能相差一个数量级。决定选择的从来不是“哪种更彻底”,而是两个更早的问题:明文是否在客户端,以及你是否需要修改查询。动手前先回答这两个问题,能避开绝大多数无效投入。

模型在这里的角色也很具体:路径一中“在数万行代码里定位声明”是纯粹的检索工作,长上下文层级可以一遍吞下内容并精确定位,把几天的翻找压缩到几分钟。它不会替你决定该走哪条路——那是读完本文后你应当自行做出的判断——它负责的只是找到映射这份苦力活。