Открываете Network в DevTools, страница на GraphQL заканчивает загружаться — а привычного query { ... } в телах запросов нет. Вместо него видны лишь operationName, хеш из шестидесяти четырёх символов и набор variables.
Вы ничего не пропустили. Это persisted operation: клиент больше не отправляет GraphQL-запрос открытым текстом, а передаёт зарегистрированный заранее хеш. Сервер находит по нему реальный запрос в своём реестре и выполняет его. На этом месте обычный маршрут через перехват пакетов обрывается: видно, какую операцию вызвали и с какими параметрами, но нельзя узнать, какие поля она запрашивает и как устроен ответ.
Первая реакция обычно неверная: кажется, что хеш нужно «взломать». Не нужно. Хеш односторонний, восстановить его нельзя — да и задача вообще в другом. Сначала определите тип ситуации, а уже затем выбирайте подход. Разница в стоимости между ними достигает порядка величины; ошибётесь с направлением — зря потратите время.
Почему в persisted operation нет текста запроса
Для начала разберёмся, зачем вообще нужен этот механизм. Без этого невозможно правильно выбрать тактику.
У GraphQL в открытом виде есть очевидные минусы: строка запроса может быть длинной, пересылать полное дерево полей при каждом обращении неэкономно, а серверу приходится принимать произвольные запросы — то есть открывать доступ ко всей поверхности схемы. Persisted operations решают обе проблемы. На этапе сборки все запросы, которые использует клиент, извлекаются, хешируются и регистрируются на сервере как белый список. Во время работы клиент отправляет только хеш и переменные, сервер принимает лишь зарегистрированные хеши, а всё остальное отклоняет. Это нормальное архитектурное решение для производительности, а не специальная защита от скрейпинга. В документации Apollo по Automatic Persisted Queries этот подход прямо рекомендуется: вместо исходного текста запроса используется его SHA-256. То, что в перехвате нет query, — лишь следствие такого устройства.
Для реверс-инжиниринга это означает одно: из сетевого запроса вынесли ответ на вопрос «что именно получать». На руках остаются идентификатор операции — хеш или читаемый operationName, — набор переменных и ответ. Промежуточного слоя, то есть выбранных операцией полей, в трафике больше нет.
На реальных проектах встречаются оба края спектра. Где-то persisted operations вообще не внедряли, и текст GraphQL честно лежит в теле запроса. Где-то всё сведено к непрозрачному конверту, из которого нельзя прочитать даже имя поля. Для этих случаев нужны разные подходы.
Два подхода с разницей в трудозатратах на порядок
Первый путь — найти открытый текст запроса или таблицу соответствий в клиентской сборке. Второй — вообще не искать текст и воспроизводить операцию целиком как чёрный ящик.
Первый вариант кажется более основательным, поэтому многие по умолчанию идут именно туда. На этом чаще всего и начинается потеря времени. Он дёшев только в одном случае: если исходный текст действительно поставляется клиенту. А это предположение нередко ошибочно.
Путь первый: искать query или таблицу соответствий в клиентской сборке
Самый простой сценарий — когда текст запроса никто и не скрывал.
Так устроен hot-rank одной китайской платформы коротких видео: есть единый endpoint /graphql, тело запроса содержит стандартную тройку {operationName, variables, query}, а поле query хранит полный текст GraphQL. В operationName стоит читаемое имя наподобие hotRankQuery. Здесь нечего добывать: одного перехвата достаточно. Persisted operations просто не используются — это самый лёгкий край спектра.
Чуть сложнее случай, когда persisted operations применяются, но клиент всё ещё хранит соответствие. Чтобы отправить хеш, клиент должен знать, какому запросу он соответствует. Поэтому таблица operationName-to-hash, а иногда и исходный текст query рядом с ней, обычно попадает во фронтенд-бандл. Сборщики могут вынести её в manifest или встроить в модуль. Найдёте эту точку — получите и текст, и хеш, а затем сможете добавлять поля или менять selection set.
Проблема здесь не в самом поиске. Бандл состоит из десятков тысяч строк, минифицирован и обфусцирован, а нужное соответствие может быть разбито на части и встроено в разные места. Именно здесь модели действительно есть что делать — об этом ниже. Это задача поиска по чанкам, а не рассуждения.
Проверка для первого пути проста: если в клиенте вообще может быть текст запроса или таблица соответствий, потратьте сначала десять минут на поиск. Если нашли — это самый сильный вариант: вы полностью контролируете запрос к endpoint.
Путь второй: не искать query, а воспроизводить операцию как чёрный ящик
Но очень часто исходного текста в клиенте просто нет.
В корректно реализованной persisted operation у клиента остаётся только хеш, а исходный GraphQL-запрос хранится исключительно в серверном реестре. Можно разобрать бандл до последнего байта и ничего не найти — потому что искать там нечего. Упорно идти первым путём в такой ситуации значит охотиться за тем, чего никогда не поставляли.
Второй путь сильно недооценивают: текст запроса вам может быть вообще не нужен. Цель — получить ответ, а не восстановить дерево выбранных полей. Сохраните идентификатор операции — хеш либо operationName — и оболочку с переменными, отправляйте их без изменений, меняя только нужные входные параметры. Вы не узнаете, какие именно поля выбрала операция, но сервер всё равно вернёт их в ответе. Для подавляющего большинства задач по сбору данных и мониторингу этого достаточно.
Классический пример — YouTube innertube. Это даже не GraphQL: здесь используется фиксированный набор самодостаточных endpoint'ов youtubei/v1/{player,search,next}, а тело запроса состоит из оболочки context с типом и версией клиента и набора параметров. Никто не пытается «восстановить» внутренний граф запросов YouTube: это и невозможно, и бессмысленно. Рабочая схема — один раз извлечь версию клиента и context из актуальных ресурсов страницы, затем передавать эту оболочку в неизменном виде в каждом запросе, подставляя лишь входные данные, например videoId или поисковую фразу, и обращаться к фиксированному endpoint. Семантика операции всё время остаётся чёрным ящиком. Ситуация, когда важное значение отсутствует в статическом коде и его приходится получать из runtime-ресурсов, относится к отдельному классу задач реверс-инжиниринга; она разобрана отдельно.
Плюс второго подхода в том, что он не зависит от наличия открытого текста у клиента. Хеш это или непрозрачный конверт — неважно: вы не пытаетесь его понять, а точно воспроизводите. Цена такого решения — привязка к запросам, которые уже делает клиент. Если нужно поле, которое клиент никогда не запрашивает, воспроизведение чёрного ящика его не даст.
Есть и ещё один случай, в котором replay ломается: в конверте присутствует подпись, рассчитываемая заново для каждого запроса и имеющая срок действия. Здесь чёрный ящик заканчивается: придётся отдельно разбираться именно с этим полем. А определить, к какому семейству алгоритмов относится подпись, помогает другой материал.
Где находятся реальные платформы на этой шкале
Если свести два реальных сценария в одну таблицу, выбор становится очевидным:
Сценарий | Вид запроса | Есть ли plaintext у клиента? | Подход | Почему |
|---|---|---|---|---|
GraphQL hot-rank платформы коротких видео |
| Да, текст находится прямо в теле запроса | Первый путь, почти без затрат | Persisted operations нет, operationName читаемый, query открыт: всё видно в одном перехвате |
YouTube innertube | Фиксированные endpoint'ы + оболочка | Осмысленного plaintext query здесь нет | Второй путь: replay чёрного ящика | Это не GraphQL, восстанавливать нечего: достаточно один раз прочитать context и передавать его как есть |
Контраст между этими крайними случаями показывает главное: подход выбираете не вы, его задаёт дизайн API платформы. В первом случае защита от злоупотреблений построена иначе, поэтому открытый текст допустим и достаётся без усилий. Во втором «что именно запрашивать» стало непрозрачной оболочкой — искать исходник бессмысленно, остаётся только воспроизведение.
Самая широкая и интересная зона посередине — настоящий persisted GraphQL. Исходный текст может быть у клиента, в таблице соответствий внутри бандла, и тогда работает первый путь. А может храниться только на сервере, оставляя клиенту лишь хеш, — тогда нужен второй. До начала работы важно отнести платформу к одному из этих случаев.
Не начинайте с поиска query: сначала решите, нужен ли он вам
Вся разница в затратах на порядок упирается в одно решение.
Если платформа действительно отправляет клиенту только хеш, а текст хранит на сервере, то попытка восстановить query первым путём может занять дни. В конце вы обнаружите, что искомое никогда не входило в поставку. Это не проблема сложности, а неверно выбранное направление: дополнительные усилия результата не приблизят.
Обратная ситуация тоже возможна. Если вам нужно изменить запрос — например, запросить поле, которое клиент никогда не использует, — replay чёрного ящика не поможет. Тогда придётся идти первым путём за исходным текстом, а при его недоступности вы упрётесь в тупик.
Поэтому вопрос должен звучать не «как добыть query?», а «нужен ли мне вообще plaintext?»
Нужно лишь повторить существующий запрос клиента и прочитать ответ. Выбирайте второй путь — воспроизведение чёрного ящика. Это самый дешёвый и самый недооценённый вариант; он работает независимо от того, есть ли у клиента исходный текст. Начинайте с него.
Нужно изменить selection set или собрать запрос, которого клиент не отправляет. Тогда обязателен первый путь — восстановление plaintext. Его цена зависит от того, поставлял ли клиент таблицу соответствий. Если нет, трудозатраты вырастают на порядок: либо принимаете эту цену, либо пересматриваете, действительно ли необходимо менять запрос.
Если поставить это решение в начало, удастся избежать типичной потери времени: три дня идти первым путём и только потом понять, что нужен был второй. Компромисс между переписыванием протокола и готовностью жить с чёрным ящиком разобран в материале о лестнице очистки; здесь он определяет выбор способа получить операцию.
Поиск таблицы соответствий — задача retrieval, а не reasoning
Самый технически тяжёлый этап первого пути — найти объявление операции и соответствие в фронтенд-сборке на десятках тысяч строк. Именно здесь модель способна заметно сэкономить время. Но важно понимать характер задачи.
Это не reasoning-задача. Модели не требуется разбираться, что вычисляет код. Нужно найти в большом массиве текста конкретный блок: где объявлено соответствие operationName и хеша, в каком модуле встроен открытый GraphQL-запрос, где собирается оболочка context. Это retrieval по чанкам. Ключевое требование — уместить достаточно контекста за раз и точно указать место, а не построить сложную цепочку рассуждений.
Сначала выполните механическое разбиение: скриптом разделите сборку на модули, проиндексируйте их, отфильтруйте polyfill'ы и несвязанные бизнес-модули. После этого задача для модели становится чистой и понятной: «найди объявление в этих блоках».
В этом процессе четыре уровня хорошо разделяются по нужным возможностям:
Этап | Что требуется от модели | Выбор | model id |
|---|---|---|---|
Найти таблицу соответствий или объявление операции среди десятков тысяч строк | Длинный контекст: принять большой фрагмент сборки и точно указать место | Kimi K3 |
|
Выбрать первый или второй путь, определить по нескольким образцам изменяемые поля конверта | Сильное reasoning: увидеть структуру и оценить компромисс | Claude Opus 5 |
|
Массово разметить сотни операций, создать replay-заготовки, заполнить типы переменных | Низкая стоимость и высокая параллельность | Claude Sonnet 5 |
|
Если replay не подключается — разобрать различия и установить причину: нет поля context, изменилась версия хеша? | Средний уровень reasoning: объяснить расхождение по разнице в ответах | GPT-5.6 Sol |
|
Первый уровень — основная тема этой статьи. На этапе поиска смена модели заметно влияет на результат, потому что ограничение здесь задаёт размер контекстного окна. Сборка занимает десятки тысяч строк; модель с коротким контекстом не вместит её целиком и будет вынуждена обрезать данные. Одно такое обрезание может выкинуть таблицу соответствий. Тогда ответ «не нашёл» означает не то, что модель не умеет искать, а то, что она этого места вообще не видела.
Разницу лучше не принимать на веру, а проверить:
Перехватите реальный запрос и сохраните идентификатор операции — operationName или хеш — вместе с variables.
Разбейте фронтенд-сборку скриптом на чанки, передайте их вместе с идентификатором в
kimi-k3и попросите найти, где объявлена эта операция и какой блок содержит соответствующий plaintext query либо таблицу хешей.Оцените один показатель: модель сразу указывает на нужную строку, промахивается или показывает близкое, но неверное место.
Для контроля отправьте те же данные модели с коротким контекстом и посмотрите, пропускает ли она цель из-за того, что не может вместить входные данные. Критерий выбора — hit rate.
Одного прогона достаточно, чтобы увидеть: длинный контекст в этой задаче не просто «немного лучше», а отделяет возможность выполнить работу от невозможности.
Главная проблема — цена переключения между моделями
Четыре модели от трёх вендоров, три SDK, три схемы авторизации, три формата ошибок. Если использовать отдельную модель для retrieval, оценки, массовой обработки и атрибуции, в лоб придётся подключать всех трёх клиентов. Многие смотрят на объём этой работы, решают, что оно того не стоит, и проходят весь путь на одной модели. В итоге на этапе long-context retrieval используют уровень, который не вмещает сборку, а потом заключают, что «модель не может ничего найти».
AIReiter убирает этот слой сложности: один ключ, один 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 используйте POST /api/v1/messages с тем же ключом.
По цене: модели Claude доступны со скидкой 30% от прайс-листа, GPT — за половину цены, а Kimi K3 вызывается тем же ключом. В этом процессе расходы сосредоточены в двух местах: поиск в первом пути, когда в kimi-k3 передаётся вся чанкированная сборка объёмом в несколько сотен тысяч токенов на вход; и массовая разметка сотен операций с генерацией replay-заготовок — самый насыщенный вызовами этап, где используется claude-sonnet-5 со скидкой 30%. Скидка приходится именно на наиболее плотную по вызовам пакетную работу.
Попробовать без регистрации: сначала вручную передайте фрагмент сборки и проверьте, находит ли long-context уровень таблицу соответствий за один проход, а уже потом решайте, подключать ли его в проект.
Итог
GraphQL API без документации ещё не означает, что с ним нельзя интегрироваться. Persisted operation лишь вынесла из запроса информацию о том, «что получать», в одно из двух мест: в клиентскую сборку — тогда её нужно найти первым путём; либо только на сервер — тогда не ищите plaintext и воспроизводите операцию как чёрный ящик вторым путём.
По стоимости эти пути различаются на порядок. Выбор определяется не тем, какой из них выглядит основательнее, а двумя вопросами: есть ли plaintext у клиента и нужно ли вам менять запрос. Ответьте на них до начала работы — и избежите большей части бесполезных усилий.
Роль модели здесь вполне конкретна: на первом пути нужно найти объявление среди десятков тысяч строк, а это чистый retrieval. Модель с длинным контекстом принимает сборку за один проход и точно указывает нужное место, сокращая дни ручных поисков до минут. Она не решает за вас, какой путь выбрать: это решение вы должны принять сами. Её задача — выполнить рутинную работу по поиску таблицы соответствий.