打開 DevTools 的 Network 面板,看著一個由 GraphQL 驅動的頁面載入完成。你逐一翻閱請求 body,想找出那段熟悉的 query { ... },卻完全找不到。封包裡只有 operationName、一串 64 字元的 hash,以及一包 variables。
不是你漏看了,而是這個站點用了 persisted operation。前端不再傳送明文 query,而是只帶上預先註冊好的 hash;伺服器收到 hash 後,到自己的 registry 查出真正的 query,再執行它。傳統抓包流程到這裡就斷了:你看得出呼叫了哪個 operation、帶了哪些變數,卻看不到它選取了哪些欄位,也無法直接得知回應結構。
遇到這種情況,很多人的第一反應其實走錯方向:想去「破解」hash。Hash 是單向的,既破解不了,也根本不必破解。真正要先做的是分類:先判斷你身處哪一種情境,再決定怎麼取得它。不同做法的成本可能相差一個數量級,起手選錯,後面投入的工夫很可能全白費。
為什麼抓包看不到 query:Persisted Operation 的運作方式
先理解這套機制為什麼存在,才能判斷該怎麼處理。
明文 GraphQL 的問題很直接:query 字串通常又長又重,每次請求都傳完整欄位樹相當浪費;伺服器還得接受任意 query,等於把整個 schema 的攻擊面暴露出來。Persisted operation 同時解決了這兩件事。建置階段會擷取前端要使用的每一個 query,算出 hash,並在伺服器註冊成白名單。執行時,前端只送 hash 與 variables;伺服器只接受已註冊的 hash,白名單外的 query 一律拒絕。這本質上是效能與安全設計,並不是刻意反爬。Apollo 的 Automatic Persisted Queries 文件也將它列為推薦做法:以 query 的 SHA-256 取代明文內容。抓包看不到 query,只是這項設計帶來的結果。
從逆向角度來看,影響很明確:請求中原本負責描述「要抓什麼」的那一層被抽走了。你手上只剩三樣東西:operation 識別資訊,例如 hash 或可讀的 operationName;一包 variables;以及回應內容。最關鍵的中間層,也就是「這個 operation 選了哪些欄位」,不再出現在網路封包裡。
實務上,兩端的情況都很常見。一端是平台根本沒採用 persisted operation,明文 query 依然老老實實躺在 request body 裡;另一端則是請求被壓縮成完全不透明的 envelope,連一個欄位名稱都讀不到。兩種端點對應的取得方式不同,下面分開談。
兩條取得路徑,成本可差一個數量級
第一條路,是前端建置檔裡本來就有明文 query 或映射表,直接把它找出來。第二條路,則是不再追明文,把整個 operation 當成黑箱,原樣重放。
第一條路聽起來比較完整,因此很多人會預設往這邊衝;而浪費時間通常就是從這裡開始。只有明文真的被打包交付到前端時,這條路才便宜,但這個前提經常不成立。
做法一:從前端建置檔找明文 query 或 hash 映射
成本最低的情況,是 query 從來就沒有被藏起來。
某中國短影音平台的熱榜就是如此:它有一個 /graphql endpoint,request body 使用標準的 {operationName, variables, query} 三件組;query 欄位直接放完整明文 GraphQL,而 operationName 也是像 hotRankQuery 這樣可讀的名稱。這裡根本沒有什麼需要「取得」的內容,一次抓包全都有了。它甚至沒使用 persisted operation,屬於光譜中最容易處理的一端。
稍微麻煩一點的情況,是平台確實用了 persisted operation,但前端仍保有映射關係。前端若要送出 hash,就必須知道每個 operation 對應哪個 hash;這張 operationName 對 hash 的表,偶爾還會連同明文 query 一起,通常會被編進前端 bundle。建置工具常把它生成 manifest 檔,或直接 inline 在某個 module 裡。只要找到它,就能同時拿到明文與 hash,之後也可以自行新增欄位或修改 selection set。
困難的地方不在搜尋這個動作,而在於建置檔可能有數萬行、經過 minify 與混淆,映射還可能被拆散後 inline 到不同位置。這正是本文中模型真正派得上用場的環節,後面會獨立說明。它是分塊檢索問題,不是推理問題。
做法一的判斷很簡單:如果明文或映射表有可能在前端裡,先花十分鐘找它。找到了,這是最強的取得方式,你對 endpoint 也有完整控制權。
做法二:別追明文,直接把 operation 當黑箱重放
問題在於,明文 query 很多時候根本不在前端。
實作完整的 persisted operation,前端手上只會留下 hash;明文 query 只存在伺服器的 registry。你就算把 bundle 搜到爛,也不會找到它,因為它從未被交付到瀏覽器。此時繼續死磕做法一,本質上是在找一個不存在的東西。
做法二其實是這類問題裡最被低估的解法:你未必需要明文。你要的通常是回應資料,不是 query 的欄位樹。因此,只要記錄這個 operation 的識別資訊(hash 或 operationName)與 variables envelope,原樣送出,再替換你真正關心的輸入參數即可。你仍然不知道它選了哪些欄位,但伺服器照樣會完整回傳。對絕大多數資料擷取與監控工作來說,這就夠了。
YouTube 的 innertube 是這條路的標準案例。它甚至不是 GraphQL,而是一組固定、具自我描述性的 endpoint:youtubei/v1/{player,search,next}。request body 由 context envelope(客戶端類型、版本)加上一包參數組成。沒有人會想去「重建」YouTube 的內部 query graph,這件事既做不到,也不值得做。實際做法是從目前頁面的 runtime resources 讀出 client version 與 context,一次取得後,每次請求都原樣帶上這個 envelope,只替換 videoId 或搜尋詞等輸入,然後打向固定 endpoint。整個過程中,operation 的語意都維持黑箱。這種關鍵值不在靜態程式碼、必須從執行期資源取得的案例,屬於另一類逆向問題,另有文章說明。
做法二的好處,是完全不依賴明文是否存在於前端。無論你拿到的是 hash 還是不透明 envelope,都不必理解它,只要忠實重現即可。代價則是你被鎖定在前端已經會發出的請求範圍內;如果你想拿一個前端從未請求過的欄位,黑箱重放無法幫你取得。
還有一種情況會讓重放失效:envelope 裡帶著每次請求都要重新計算、且會過期的 signature 欄位。黑箱策略到這裡就走不通,必須單獨處理那個欄位;至於如何辨識它屬於哪一類 signature 演算法,則是另一篇文章的主題。
兩種平台案例:API 設計決定你該走哪條路
把前面兩個實際案例放在同一張表裡,落點與原因就很清楚:
平台案例 | 請求形式 | 前端是否有明文? | 適合的做法 | 原因 |
|---|---|---|---|---|
短影音熱榜 GraphQL |
| 有,明文直接位於 request body | 做法一(幾乎零成本) | 未使用 persisted operation,operationName 可讀,query 是明文;一次抓包就能取得全部資訊 |
YouTube innertube | 固定 endpoint + | 沒有可說的明文 query | 做法二(黑箱重放) | 不是 GraphQL,沒有 query 可重建;讀取一次 context envelope 後原樣攜帶即可 |
這兩端的對比說明了一件事:該怎麼取得,不是由你偏好決定,而是平台的 API 設計已經決定了。第一種平台把防護重點放在其他地方,明文公開並不構成問題,因此順手就能拿到;第二種則把「要抓什麼」封裝成不透明 envelope,既然沒有明文可追,能做的就只有重放。
真正需要判斷的,是中間那一大片,也就是真正的 persisted GraphQL。明文可能還在前端,例如 bundle 內的映射表,這時走做法一;也可能只存在伺服器,前端僅有 hash,這時該走做法二。開始之前,必須先把它歸到其中一端。
別一開始就找 query:先確認你是否真的需要明文
兩條路成本相差一個數量級,關鍵完全取決於這個判斷。
如果平台真的只把 hash 交給前端,明文被鎖在伺服器上,而你仍堅持用做法一還原明文,可能翻了好幾天 bundle,最後才發現目標從來沒有被交付過。這不是技術難度問題,而是方向問題;再多投入也不會換來結果。
反過來說,如果你需要修改 query,例如取得前端從未請求過的欄位,做法二的黑箱重放就無能為力。你得回頭走做法一取得明文;拿不到的話,也只能卡在那裡。
因此,問題不該從「我要怎麼拿到 query」開始,而應該先問:我真的需要明文嗎?
只想重現前端既有請求並讀取回應:選做法二(黑箱重放)。這是成本最低、也最常被忽略的方法;不管明文存不存在都可行,應該優先預設使用它。
需要修改 selection set,或組出前端從未發送過的 query:就必須走做法一(還原明文)。成本高低取決於前端是否交付了映射表;若沒有,成本就會膨脹一個數量級。你要嘛接受這個代價,要嘛重新評估是否真的有必要改 query。
先做這個判斷,可以避免大多數「一頭栽進做法一,三天後才發現本該走做法二」的浪費。這種「自行重寫協定,或接受黑箱」之間的降級取捨,是purification ladder 文章討論的主題;在這裡,它只影響你該選哪種取得方式。
在大型 bundle 找映射,考驗的是分塊檢索,不是推理
做法一裡唯一真正技術密集的步驟,是在數萬行前端建置檔中找出 operation 宣告與映射;這也是模型能實際替你省下大量時間的地方。不過先要搞清楚,這究竟是哪一類任務。
它不是推理題。你不需要模型理解程式碼算出了什麼,而是要它在大量文字裡定位:哪一段宣告了 operationName 對 hash 的映射、哪個 module inline 了明文 query、context envelope 在哪裡組裝。這是分塊檢索;真正考驗的是一次能放進多少上下文,以及能否精準指出位置,而不是模型是否善於自我辯論。
先完成機械式切分步驟:用腳本將建置檔拆成 modules、建立索引,並濾掉 polyfill 和不相關的業務 modules。再把結果交給模型,任務就會變成乾淨明確的「請在這些區塊中找出宣告位置」。
在這個流程裡,四個層級的能力分工很清楚:
步驟 | 需要的能力 | 選擇 | model id |
|---|---|---|---|
在數萬行程式碼中定位映射/operation 宣告 | 長上下文,可吞入大片建置檔並精準指出位置 | Kimi K3 |
|
判斷該走做法一或做法二,從少量樣本歸納哪些 envelope 欄位會變動 | 強推理能力,能讀出結構並做取捨 | Claude Opus 5 |
|
批次標註數百個 operation、產生 replay stub、補齊 variable 型別 | 低成本、高併發 | Claude Sonnet 5 |
|
重放連不上時,解讀差異並歸因(缺少 context 欄位?hash 版本變了?) | 中等推理能力,能依回應差異提出解釋 | GPT-5.6 Sol |
|
第一層正是本文的主場。定位階段換模型,結果會明顯不同,因為它受限的是 context window。建置檔有數萬行,短上下文模型放不下,只能截斷;而一次截斷就可能正好把映射表切掉。此時它說「找不到」,不是因為不會搜尋,而是因為它根本沒看見。
不必只聽我說,自己測一次就知道差異:
抓一個真實請求,保存它的 operation 識別資訊(operationName 或 hash)與 variables。
用腳本切分前端建置檔,連同該識別資訊一起餵給
kimi-k3,要求它定位「這個 operation 在哪裡宣告,以及哪個區塊保存對應的明文 query 或 hash 映射」。只看一件事:它是否直接跳到正確的行。命中、漏掉,或是落在相鄰但錯誤的位置。
對照組:把同一份輸入交給短上下文模型,看看它是否因為裝不下而漏掉。命中率就是你的選型依據。
跑一輪就會發現:對這類任務而言,長上下文不是「稍微好一點」,而是能不能完成的差別。
真正的阻力,是切換模型的成本
四個模型、三家供應商、三套 SDK、三種驗證方式、三種錯誤格式。若要分別用不同模型做檢索、判斷、批次處理與歸因,最直覺的做法是把三個 client 都接起來;但大多數人算完成本後會覺得不值得,最後整個流程只用一個模型。於是在長上下文檢索步驟用了放不下 bundle 的層級,然後得出「模型找不到」的結論。
AIReiter把這一層攤平:一把 key、一個 OpenAI 相容介面,四個層級都在後面;只要修改 request body 裡的 model 欄位,就能切換模型。
# 定位映射:使用長上下文層級,一次吞入大片切分後的建置檔
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": "<切分後的前端建置檔 + 要定位的 operation 識別資訊>"}]
}'
# 批次標註 operation/產生 replay stub:只改 model 欄位,其餘不變
# "model": "claude-sonnet-5"
# 重放問題歸因:
# "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 的完整切分建置檔;以及批次標註數百個 operation、生成 replay stub,這是呼叫最密集的步驟,使用七折的 claude-sonnet-5。折扣正好落在呼叫密度最高的批次工作上。
免註冊試用:先手動貼一段建置檔,看看長上下文層級能否一次定位映射,再決定是否要接進流程。
結語
沒有文件的 GraphQL API,不代表無法整合。Persisted operation 只是把「要抓什麼」從請求裡移到了兩個可能的位置之一:前端建置檔裡,找到它就是做法一;或是只存在伺服器端,這時別再追明文,直接把 operation 當黑箱重放,也就是做法二。
這兩條路的成本可相差一個數量級。決定因素從來不是哪種方式比較完整,而是兩個更早該回答的問題:明文是否在前端?你是否需要修改 query?先回答這兩題,能避開大多數無效投入。
模型在這裡的定位也很明確:做法一中「在數萬行程式碼裡找到宣告」是純檢索工作;長上下文層級可以一口吞下內容並精準定位,將幾天的翻找縮短成幾分鐘。它不會替你決定該選哪條路,那是讀完本文後你應該具備的判斷;它負責的,是替你完成找出映射這份苦工。