AIREITER

リクエストにクエリがないGraphQL APIからPersisted Operationを扱う2つの方法

最終更新日: 2026-07-31 06:39:42

DevToolsのNetworkパネルでGraphQLベースのページ読み込みを追っても、探しているはずのquery { ... }がどのリクエストにも見当たらない。あるのはoperationName、64文字のハッシュ、そしてvariablesだけ――そんな場面がある。

見落としではない。これはPersisted Operationだ。クライアントはプレーンテキストのクエリを送らず、事前登録されたハッシュだけを送信する。サーバーはそのハッシュを自身のレジストリで引き、実際のクエリを復元して実行する。この時点で、パケットキャプチャからクエリを読むやり方は通用しない。どの操作が呼ばれ、どんな変数が渡ったかは分かっても、選択しているフィールドやレスポンス構造までは通信から読めない。

ここで「ハッシュをクラックしなければ」と考えるのは、たいてい間違いだ。ハッシュは一方向関数なのでクラックできないし、そもそもその必要もない。本質は分類にある。まず自分がどのケースにいるかを見極め、その後で取り方を決めることだ。選択を誤るとコストは一桁変わり、労力だけを無駄にする。

Persisted Operationでクエリが通信に出てこない理由

取得方法を判断するには、まずこの仕組みが必要とされる理由を押さえておきたい。

プレーンテキストのGraphQLには明確な欠点がある。クエリ文字列は長く、毎回フィールドツリー全体を送るのは無駄が大きい。さらにサーバー側は任意のクエリを受け付けることになり、スキーマ全体が攻撃対象になり得る。Persisted Operationはこの両方に対応する。ビルド時にクライアントが使うクエリをすべて抽出・ハッシュ化し、サーバーへホワイトリストとして登録する。実行時にクライアントが送るのはハッシュと変数だけで、サーバーは登録済みハッシュだけを受け入れ、ホワイトリスト外のクエリは拒否する。これはスクレイピング対策を主目的にしたものではなく、正当なパフォーマンス設計だ。ApolloのAutomatic Persisted Queriesドキュメントでも、プレーンテキストの代わりにクエリのSHA-256を使う推奨手法として紹介されている。キャプチャにクエリが現れないのは、その副作用にすぎない。

リバースエンジニアリングの観点では、「何を取得するか」がリクエストから切り離されたことになる。手元に残るのは、操作の識別子(ハッシュまたは人間が読めるoperationName)、変数のセット、レスポンスの3つだ。その間にある「この操作がどのフィールドを選択したか」は、通信上には存在しない。

実案件では、この両端に当たる。Persisted Operationを一切採用しておらず、リクエストボディにプレーンテキストのクエリがそのまま入っているケース。一方で、フィールド名すら読めない不透明なエンベロープに圧縮されているケースだ。両者では取るべき手段が違う。

コストが一桁違う、2つの取得ルート

1つ目は、クライアントビルドに含まれるプレーンテキストや対応表を見つける方法。2つ目は、プレーンテキストを追わず、操作全体をブラックボックスとしてそのままリプレイする方法だ。

前者のほうが徹底的に見えるため、最初からそちらへ進む人は多い。しかし、無駄な作業はまさにそこで始まる。クエリ本文が本当にクライアントへ配布されている場合にしか、この方法は安く済まない。

方法1:クライアントビルドからクエリ本文・対応表を探す

最も低コストなのは、そもそもプレーンテキストが隠されていないケースだ。

ある中国のショート動画プラットフォームの人気ランキングは、この形式だった。エンドポイントは単一の/graphqlで、リクエストボディには標準的な{operationName, variables, query}の3点セットが入る。queryには完全なプレーンテキストのGraphQLが入り、operationNameもhotRankQueryのように読める名前だ。ここでは「取得」するもの自体がない。1回キャプチャすれば、必要な情報はすべて目の前にある。Persisted Operationを採用しておらず、このスペクトラムでは最も簡単な側に位置する。

少し手間がかかるのは、Persisted Operationを使っていてもクライアント側に対応表が残っているケースだ。ハッシュを送るには、クライアント自身がどの操作がどのハッシュに対応するかを知っていなければならない。そのoperationNameとハッシュの対応表は、クエリ本文を伴う場合もあり、通常はフロントエンドのバンドルに組み込まれている。ビルドツールがマニフェストファイルとして生成することもあれば、モジュール内へインライン展開することもある。これを見つければ、クエリ本文とハッシュを一度に入手でき、その後はフィールド追加やselection setの変更もできる。

難しいのは探索行為そのものではない。ビルド結果が数万行に及び、minifyや難読化が施され、対応表も分割・インライン化されている点だ。この工程こそ、後述するモデルの使いどころになる。必要なのは推論ではなく、チャンク単位の検索である。

方法1の判断基準は単純だ。プレーンテキストまたは対応表がクライアントのどこかにあるなら、まず10分かけて探す。見つかればこれが最も強力な方法であり、エンドポイントを完全に制御できる。

方法2:クエリ本文を追わず、操作をブラックボックスとしてリプレイする

問題は、クエリ本文がクライアントに存在しないことも珍しくない点だ。

正しく実装されたPersisted Operationでは、クライアントが持つのはハッシュだけで、プレーンテキストのクエリはサーバーのレジストリにしかない。バンドルを徹底的に検索しても見つからないのは当然で、最初から配布されていない。方法1に固執するのは、存在しないものを探していることになる。

ここで過小評価されがちなのが方法2だ。クエリ本文を知る必要はない。欲しいのはクエリのフィールドツリーではなく、レスポンスだからだ。操作の識別子(ハッシュまたはoperationName)と変数を含むエンベロープを記録し、それをそのまま送信する。変えるのは必要な入力パラメータだけでよい。どのフィールドが選択されているかは分からなくても、サーバーは同じレスポンスを返す。大半のデータ取得や監視用途なら、それで十分だ。

YouTubeのinnertubeは、この方法の典型例である。これはGraphQLですらない。youtubei/v1/{player,search,next}という自己記述的な固定エンドポイントがあり、リクエストボディはcontextエンベロープ(クライアント種別、バージョン)とパラメータ群で構成される。YouTube内部のクエリグラフを「復元」しようとする人はいない。それは可能でも価値があることでもないからだ。実際にやるべきなのは、現在のページリソースからクライアントバージョンとcontextを一度読み出し、そのエンベロープを以後のリクエストでそのまま使うことだ。videoIdや検索語といった入力だけを差し替え、固定エンドポイントを叩く。操作の意味論は、最後までブラックボックスのままでよい。静的コードにはなく実行時リソースから読み出す必要がある値は、別種のリバースエンジニアリング課題であり、別記事で扱っている。

方法2の強みは、クエリ本文がクライアントにあるかどうかに左右されないことだ。ハッシュでも不透明なエンベロープでも、理解しようとせず忠実に再現すればよい。ただし、クライアントがすでに送っているリクエストに縛られる。クライアントが一度も要求しないフィールドが欲しい場合、ブラックボックスリプレイでは取得できない。

もう1つ、リプレイが破綻するケースがある。エンベロープに、リクエストごとに新しく計算され、期限切れになる署名フィールドが含まれる場合だ。そこでブラックボックスは通用しなくなり、そのフィールドだけは個別に解明する必要がある。どの署名アルゴリズム系統かを見分ける方法は、別の記事で扱っている。

プラットフォームごとの位置づけ

両極端の実例を表にすると、どこに位置し、なぜその方法になるのかが明確になる。

プラットフォームのケース

リクエスト形式

クライアントに本文があるか

自然な選択

理由

ショート動画の人気ランキングGraphQL

/graphql + {operationName, variables, query}

ある。リクエストボディに本文がある

方法1(ほぼゼロコスト)

Persisted Operationを使わず、operationNameも読め、クエリ本文も1回のキャプチャで得られる

YouTube innertube

固定エンドポイント + contextエンベロープ

該当するクエリ本文はない

方法2(ブラックボックスリプレイ)

GraphQLではなく、復元すべきクエリがない。contextエンベロープを一度取得してそのまま使う

この対比が示すのは1点だけだ。どの取得方法を選ぶかは、自分の好みではなくプラットフォームのAPI設計で決まる。前者は別の手段で不正利用を防ぐ設計であり、プレーンテキストを公開していても問題ない。そのため、キャプチャするだけで手に入る。後者では「何を取得するか」が不透明なエンベロープにされているので、追うべきプレーンテキストはなく、リプレイするしかない。

本当に判断が問われるのは、その中間にあるPersisted GraphQLだ。クエリ本文がクライアントにある場合もある。バンドルに対応表が組み込まれているなら方法1だ。サーバーにしかなく、クライアントにハッシュだけがあるなら方法2になる。着手前にどちら側かを分類しなければならない。

最初に確認すべきは、クエリ本文が本当に必要か

コストが一桁違ってくるかどうかは、結局この判断にかかっている。

プラットフォームがハッシュだけを配布し、クエリ本文をサーバーに閉じ込めているのに、方法1で本文を回収しようとすればどうなるか。数日かけてバンドルを掘り、最後に「探していたものは最初から配布されていなかった」と分かる。これは難易度の問題ではなく、方向の問題だ。努力量を増やしても結果は出ない。

逆に、クエリを変更する必要がある場合、たとえばクライアントが要求しないフィールドを取りたい場合には、方法2のブラックボックスリプレイでは対応できない。方法1でクエリ本文を得る必要があり、取得できなければそこで詰まる。

順番は「どうすればクエリを手に入れられるか」ではない。最初に問うべきは、クエリ本文が本当に必要なのか、である。

  • クライアントがすでに送っているリクエストを再現し、レスポンスを読みたいだけ:方法2のブラックボックスリプレイを選ぶ。最も安く、見落とされがちだが、クエリ本文の有無にかかわらず機能する。まずはこちらを標準にする。

  • selection setを変えたい、クライアントが送らないクエリを組み立てたい:方法1でクエリ本文を回収する必要がある。安く済むかどうかは、クライアントが対応表を配布しているか次第だ。配布されていなければ、コストは一桁跳ね上がる。その負担を受け入れるか、本当にクエリ変更が必要なのかを見直すことになる。

この判断を先に置けば、「方法1へ突っ込み、3日間詰まった後で方法2にすべきだったと気づく」といった無駄の大半を防げる。「書き換えるか、ブラックボックスを受け入れるか」という劣化とのトレードオフについては、purification ladderの記事で扱っている。ここでは、それがどちらの取得方法を選ぶかを決める。

対応表の探索は推論ではなくチャンク検索

方法1で技術的に手間がかかるのは、数万行のフロントエンドビルドから操作宣言や対応表を探す工程だ。ここではモデルが実際に時間を節約してくれる。ただし、まずタスクの性質を正しく捉える必要がある。

これは推論タスクではない。コードが何を計算するかをモデルに理解させる必要はなく、大量のテキストから、operationNameとハッシュの対応表を宣言しているブロック、クエリ本文をインラインで持つモジュール、contextエンベロープを組み立てている場所を特定してもらえばよい。つまりチャンク検索であり、問われるのは自己反論の巧さではなく、十分な文脈を一度に収め、その中の位置を正確に指せるかだ。

先に機械的な分割工程を済ませる。スクリプトでビルドをモジュールごとに分け、インデックス化し、polyfillや無関係な業務モジュールを除外する。その結果をモデルに渡せば、作業は「このブロック群から宣言を探す」という明快なものになる。

このフローでは、4つの層が必要な能力によってきれいに分かれる。

工程

必要な能力

選択

model id

数万行から対応表・操作宣言を特定する

大きなビルド断片を読み込み、正確な位置を指す長コンテキスト

Kimi K3

kimi-k3

方法1か方法2かを判断し、少数サンプルから可変のエンベロープ項目を導く

構造を読み、トレードオフを判断する強い推論力

Claude Opus 5

claude-opus-5

数百の操作を一括でラベル付けし、リプレイ用スタブを生成し、変数型を補う

低コスト・高並列

Claude Sonnet 5

claude-sonnet-5

リプレイ接続に失敗したとき、差分から原因を切り分ける(context項目の不足、ハッシュバージョン変更など)

レスポンス差分に基づいて説明できる中程度の推論力

GPT-5.6 Sol

gpt-5.6-sol

このうち最初の層が、本記事の主戦場だ。探索工程でモデルを替えると結果が目に見えて変わる。制約になるのはコンテキストウィンドウだからである。ビルドは数万行に達し、短コンテキストのモデルでは入り切らず、途中で切り詰める必要がある。そこで対応表の部分が落ちれば、「見つからない」のは検索能力の不足ではなく、単にその箇所を見ていないためだ。

違いは実際に試せばよい。

  1. 実際のリクエストをキャプチャし、操作識別子(operationNameまたはハッシュ)と変数を保存する。

  2. スクリプトでフロントエンドビルドをチャンク化し、その識別子とともにkimi-k3へ渡す。「この操作がどこで宣言され、対応するクエリ本文またはハッシュ対応表がどのブロックにあるか」を特定させる。

  3. 見るべき点は1つだけだ。該当行へ直接たどり着くか。正解、見落とし、または近いが誤った箇所のどれか。

  4. 比較対象として、同じ入力を短コンテキストモデルにも渡し、収まり切らないことが原因で見落とすかを確認する。ヒット率が選定基準になる。

1回試せば、この種のタスクにおける長コンテキストは「少し良い」のではなく、「できるか、できないか」の差だと分かる。

本当の障害はモデル切り替えのコスト

3社の4モデルを使うとなると、SDKは3種類、認証方式も3種類、エラー形式も3種類になる。検索・判断・一括処理・原因切り分けでモデルを使い分けるには、素直に実装すれば各クライアントを接続しなければならない。そこで多くの人は手間に見合わないと判断し、結局は全工程で1モデルを使う。その結果、長コンテキスト検索でビルドを収められない層を使い、「モデルが見つけられない」と結論づけてしまう。

AIReiterはこの層を平坦化する。キーは1つ、インターフェースはOpenAI互換で1つ。4つの層を同じ基盤から使え、リクエストボディの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": "<chunked frontend build + the operation identifier to locate>"}]
  }'

# 操作のラベル付け / リプレイ用スタブの一括生成: modelフィールドだけ変更し、残りは同じ
#   "model": "claude-sonnet-5"
# リプレイ失敗の原因切り分け:
#   "model": "gpt-5.6-sol"

すでにOpenAI SDKを使っているなら、base_urlをhttps://aireiter.com/api/v1に向けるだけで、ほかは変更不要だ。Anthropic SDKでは、同じキーでPOST /api/v1/messagesを呼び出せる。

価格面では、Claudeモデルは定価から30%オフ、GPTモデルは半額で、Kimi K3も同じキーから呼び出せる。このフローでコストが集中する箇所は2つある。方法1で数十万トークン規模のチャンク化済みビルドを入力するkimi-k3での探索と、数百操作のラベル付け・リプレイ用スタブ生成をまとめて行う、最も呼び出し回数の多いclaude-sonnet-5での一括処理だ。30%オフは、最も呼び出し密度が高いバッチ工程に効く。

まとめ

ドキュメントのないGraphQL APIでも、連携できないとは限らない。Persisted Operationは「何を取得するか」をリクエストから外しただけであり、その置き場所は2つしかない。クライアントビルドにあるなら探す――方法1。サーバーにしかないなら、プレーンテキストを追わずブラックボックスとしてリプレイする――方法2だ。

この2つには一桁のコスト差がある。どちらがより徹底的かで選ぶのではない。先に確認すべきなのは、プレーンテキストがクライアントにあるか、そしてクエリを変更する必要があるか、この2点である。始める前に答えを出しておけば、無駄な作業の大半は避けられる。

この文脈でのモデルの役割も限定的だ。方法1における「数万行から宣言を探す」工程は純粋な検索であり、長コンテキスト層なら一度に読み込んで正確な位置を示せる。何日もかかる掘り起こしを数分へ縮められる。一方で、どちらの方法を選ぶべきかまではモデルが決めるものではない。それはこの記事を読んだ後に自分で下す判断であり、モデルは対応表を見つける地道な作業を担うだけだ。