Можно на самом деле проверить, знает ли большая языковая модель о существовании MiCode — и скажет ли она об этом тому, кто спросит. Для этого мы сделали кое-что проще, чем звучит: скрипт, который раз в день задаёт Gemini фиксированный список вопросов, сформулированных так, как их задал бы реальный клиент, включает поиск с уземлением через Google и записывает, какие источники модель при этом процитировала. Сама идея банальна. Всё остальное в этой статье — о том, как мы уместили 54 вызова модели в бесплатный лимит, который допускает 20 в день, и почему почти каждый мелкий механизм на этом пути появился из-за конкретного сбоя, а не из предусмотрительности «на всякий случай».

Монитор, который спрашивает ИИ о нас самих

Всё больше вопросов, которые раньше уходили бы в поисковик, теперь идут напрямую в ChatGPT, Perplexity или Gemini — а то, что mi-code.pl хорошо ранжируется в Google, ничего не говорит о том, знает ли о нас вообще хоть одна из этих моделей. Ответ — автоматический монитор: раз в день, без участия человека, он задаёт Gemini фиксированный список из 27 промптов — про бренд, про категорию услуг, про конкретные продукты — с включённым поиском Google в качестве уземления, и проверяет, появляется ли mi-code.pl как процитированный источник в ответе. Каждый промпт задаётся дважды, чтобы одно случайное попадание или один случайный промах не решали результат в одиночку — итого 54 вызова модели на полный обход.

Это только половина системы. У Gemini есть бесплатный API с уземлением, поэтому эту половину можно автоматизировать; у ChatGPT и Perplexity его нет, поэтому тот же проект ведёт отдельный, ручной обход раз в месяц — вход в оба сервиса вручную, те же вопросы с клавиатуры и запись результатов вместе с трафиком сайта и трафиком ИИ-краулеров из Cloudflare. Эта статья — только об автоматической, ежедневной половине, той, что должна уместиться в квоту бесплатного API.

Квота, которая не та, что в таблице цен

Первая версия проекта рассчитала бюджет по прайсингу Gemini, где указано «до 500 запросов в день» — и ошиблась. Эта цифра относится к инструменту поиска, а не к самим вызовам модели. Реальный лимит для generateContent на бесплатном тарифе в десять раз ниже, и API прямо говорит об этом в теле собственной ошибки:

Двадцать вызовов модели в день, на проект, на модель. Путаница этих двух цифр стоила команде целого дизайна — пока кто-то не прочитал детали самой ошибки вместо таблицы цен.

54 вызова в лимит на 20

Двадцать вызовов в день против 54, нужных для полного обхода, означают, что обход физически не помещается в один день. Решение — не урезать список промптов, а растянуть обход на несколько дней. Код держит дневной бюджет как константу, равную 18, а не 20 — это осознанный запас под самим лимитом, а не округление вниз. При бюджете 18 полный обход из 54 вызовов закрывается ровно за три дня: 54, делённое на 18, — это ровно 3, без остатка и без повисшего в воздухе недоделанного четвёртого дня.

Если выложить это как ограничение против потребности, несовпадение — и запас, который дневной бюджет оставляет над самим жёстким лимитом, — выглядит так:

Ограничение свободного тарифаЗначение
Вызовов модели в день (на проект, на модель)20
Реально используемый дневной бюджет18 (запас под лимитом)
Вызовов на полный обход54 (27 промптов × 2 повтора)
Дней на закрытие одного обхода3
Пауза между вызовами (лимит в минуту)6,5 секунды

Курсор, который помнит, где остановился обход

Каждый день задание измеряет следующий кусок стабильного списка работы и сохраняет своё место — позицию курсора и накопленные попытки — в одном файле, partial.json. Только запуск, который доходит до конца этого списка, собирает всё в одно целое: записывает полный результат обхода в файл с датой, пересобирает отчёт и сравнивает его с предыдущим обходом. Alert в Telegram срабатывает только из этого завершающего запуска — и только если сравнение показывает, что набор промптов, которые нас действительно цитируют, изменился. Промежуточный день, который лишь продвигает курсор, не отправляет никакого уведомления, потому что пока не о чем говорить.

Файл с датой из завершённого обхода попадает в каталог, где хранится по одному файлу на каждый завершённый обход, — так история того, что модель цитировала месяц-два назад, сохраняется, а не теряется за тем, что показывает последний отчёт. Ритм трёх дней, и место, где повторная попытка в середине обхода возвращается в ту же порцию, а не перескакивает дальше, выглядит так:

Обход на три дня: курсор над списком из 54 пунктов, закрывается на третий день

Сам список работы — не более чем 27 промптов, повторённых дважды, выстроенных в фиксированном порядке: каждое повторение промпта стоит прямо после его первого появления, а не сбито в конец списка. Именно этот порядок делает нарезку на порции предсказуемой: вспомогательная функция берёт курсор и бюджет и возвращает следующий кусок списка плюс новый курсор, ограниченный длиной всего списка, и признак, дошёл ли этот новый курсор до конца. Если посчитать это на бумаге, три дня складываются ровно так, как обещает константа. Первый день начинается с курсора 0, берёт пункты с 0 по 17 и оставляет новый курсор на 18 — обход ещё не закрыт, потому что 18 меньше 54. Второй день начинается там, где остановился первый, берёт пункты с 18 по 35 и оставляет курсор на 36 — всё ещё не закрыт. Третий день берёт последнюю порцию, пункты с 36 по 53, и новый курсор попадает на 54, что не меньше длины списка, равной 54, — значит, именно этот запуск закрывает обход. Ничего из этого не гипотеза: файл partial.json, который этот проект хранит в собственном репозитории, показывает прямо сейчас cursor: 18, calls: 18 после одного реального дня работы — то есть результат первого дня, описанный выше, зафиксированный живьём, а не выведенный задним числом.

Обход может растянуться и на нечто большее, чем одни и те же три промпта, попадающие дважды в один день. Поскольку повторы одного промпта не стоят рядом с повторами другого промпта так, чтобы уважать границы дней — список составлен по промптам, а не по дням, — две попытки одного и того же промпта могут попасть на совершенно разные дневные порции: одна в понедельничную партию, а её повтор — в среду. Этап складывания, который завершает обход, группирует каждую попытку по идентификатору промпта независимо от того, в какой день она была измерена, и сохраняет лучший статус, увиденный среди всех попыток, которые собрал этот промпт, — так что цитирование, найденное хотя бы раз, даже в другой день, чем его парная попытка, всё равно засчитывается.

Проверка цитирования, а не только его подсчёт

Уместить 54 вызова в лимит на 20 имеет смысл только тогда, когда то, что приходит от каждого вызова, читается правильно, а обоснованные ответы не возвращают чистый адрес источника. Метаданные уземления Google возвращают цитирования как ссылки-редиректы через vertexaisearch.cloud.google.com — сравнение этого хоста с mi-code.pl никогда бы ничего не нашло, сколько бы раз нас на самом деле ни процитировали.

Разворачивание этого редиректа — собственный небольшой конвейер, выстроенный от самого дешёвого шага к самому дорогому. Если фрагмент метаданных уземления уже несёт поле с доменом, оно доверяется напрямую — без дополнительной работы. Если его нет, поле заголовка этого фрагмента обычно оказывается голым доменом издателя, а не заголовком страницы — подтверждено на реальном ответе живого вызова, сохранённом в собственных фикстурах этого проекта, — так что регулярное выражение, проверяющее «похоже ли это на домен» — буквы, цифры и дефисы, хотя бы одна точка, — достаточно, чтобы довериться ему. Только если нет ни поля с доменом, ни заголовка, похожего на домен, код обращается к настоящему HEAD-запросу на сам адрес цитирования и следует за редиректом, чтобы увидеть, куда он ведёт. Этот запасной вариант — намеренно последний, а не поведение по умолчанию: полный обход может увидеть несколько сотен цитирований в своих 54 вызовах, и превращение каждого из них в отдельный HTTP-запрос умножило бы число вызовов против лимита, который сам по себе — всё ограничение, вокруг которого этот проект вообще существует.

Когда реальный хост цитирования уже известен, классификация — простое ранжирование, а не один вопрос да-нет. Бренд может появиться в тексте ответа, никогда не будучи связан как источник, — код отслеживает это отдельно как «упомянут», и это никогда не может засчитываться как цитирование, каким бы похожим одно на другое ни казалось на первый взгляд. Только хост, который разрешается в собственный домен, получает статус «процитирован»; всё остальное, что хотя бы называет бренд в тексте ответа, — «упомянут»; всё прочее — «отсутствует». У самого сопоставления собственных доменов есть одна намеренная защита: хост-кандидат засчитывается как наш только если он в точности равен одному из собственных доменов или заканчивается этим доменом с предшествующей точкой — так что похожая по виду регистрация вроде noteksiegowyai.pl никогда не будет спутана с настоящим eksiegowyai.pl только потому, что эта строка встречается внутри него. А поскольку каждый промпт задаётся дважды, две попытки не усредняются между собой — выигрывает лучшая из двух: «процитирован» превосходит «упомянут», «упомянут» превосходит «отсутствует», — так что одно удачное попадание из двух всё равно засчитывается как настоящее цитирование, а не размывается пропуском.

Три типа ошибок, три разные реакции

Не каждая ошибка заслуживает одинаковой реакции, поэтому код не обращается с ними одинаково.

Разрыв соединения — fetch, который выбрасывает исключение вместо того, чтобы хоть как-то ответить, — повторяется один раз, через пять секунд, так же, как ошибка сервера. Этот конкретный случай существует, потому что однажды произошёл на самом деле: один разрыв соединения, который тогда не повторялся, оборвал весь обход на середине и забрал с собой всю дневную квоту — оставшимся в тот день вызовам просто не на что было тратиться.

Лимит скорости, код 429, получает другое обращение: тоже одна повторная попытка, но только через тридцать секунд, а не через пять. Лимит скорости проходит по часам — по истечении заданного интервала он просто перестаёт действовать, — тогда как ошибка сервера проходит, когда ей вздумается. Тридцать секунд выбраны так, чтобы реально выйти за пределы окна лимита в минуту, а не потратить единственную повторную попытку на несколько секунд раньше времени.

Ошибка 4xx, не являющаяся 429, не повторяется вообще. Это сломанный запрос, который во второй раз провалится точно так же, — повтор ничего не чинит, а лишь тратит второй вызов из того же дневного бюджета впустую.

Пауза в 6,5 секунды, которой сначала не было

Дневной лимит — не единственный лимит, в который может упереться серия из десятков вызовов подряд, — есть ещё лимит в минуту. Первый реальный запуск отправлял вызовы вообще без паузы между ними и за несколько секунд получил 429, хотя единичный вызов в состоянии покоя проходил без проблем. Решение — пауза в 6,5 секунды между последовательными вызовами внутри дневного среза — не перед самым первым вызовом дня, потому что ожидание до того, как что-либо началось, лишь тратит время впустую, а только между последующими.

Отпечаток, который не даёт сравнить половину обхода с целым

Список промптов в prompts.json не заморожен — он меняется каждый раз, когда появляется новый продукт или статья, и это ожидаемо, потому что один обход растягивается на несколько дней. Проблема в том, что обход, измеренный по двум разным спискам промптов, не значит ничего: часть промптов посчиталась бы дважды, а новые вообще никогда не были бы заданы в рамках этого обхода. Поэтому partial.json хранит отпечаток — sha1-хеш, вычисленный из идентификаторов промптов и числа повторов. Если список промптов меняется в середине обхода, отпечаток при следующем запуске перестаёт совпадать, незавершённый обход отбрасывается, а новый начинается с нуля, с пустым курсором. Это единственный способ, которым правка промптов посреди недели никогда не портит данные, — ценой того, что уже сделанная частичная работа выбрасывается, когда это происходит.

У списка есть и постоянное ядро: промпты про сам бренд остаются в нём всегда, именно потому что они — канарейка. Если даже такой простой вопрос, как «чем занимается MiCode», перестанет нас находить, это значит, что сломалась индексация, а не что не хватает контента, — и никакая новая статья это не починит.

Что закрывает обход, а что лишь его охраняет

Ежедневная задача, которая тихо ничего не делает и всё равно выходит с зелёной галочкой, хуже той, что громко проваливается, — потому что на зелёное никто не смотрит. Этот риск здесь конкретный, а не теоретический: дневной бюджет считывается из переменной окружения, а опечатка, из-за которой AI_VIS_DAILY_BUDGET станет не числом, в момент разбора превратится в NaN. Бюджет, равный NaN, не измерил бы ничего, не продвинул бы ничего, а скрипт всё равно завершился бы с кодом 0 — задание было бы зелёным каждый день, пока REPORT.md тихо перестал бы обновляться, и так до тех пор, пока кто-нибудь не заметит, что даты перестали двигаться. Код защищается от этого именно такой вспомогательной функцией, которая требует, чтобы заданное значение было положительным целым числом, а если это не так — выбрасывает исключение, превращая тихий, безусильный успех в громкий, красный сбой CI в тот же день, когда это происходит. Неустановленная переменная возвращается к значению по умолчанию, а не запускает эту защиту, потому что Actions отображает неустановленную переменную как пустую строку, а не как что-то по-настоящему неопределённое, — а обращение с этим как с плохим значением заставило бы защиту срабатывать на обычном случае, а не на сломанном.

Стоит назвать и второй особый случай, потому что снаружи он выглядит идентично первому, а обрабатывать его нужно наоборот: порция, которая возвращается пустой, потому что курсор уже давно прошёл конец списка работы. Это не ошибка конфигурации — это просто обход, который закончился, — и он всё равно должен правильно закрыться, записав обход с датой и сбросив курсор, иначе задание выглядело бы точно как тот тихий no-op выше, каждый день после этого. Только порция, которая пуста и одновременно ещё не завершена, считается настоящим случаем «нечего делать» — логируется и завершается досрочно, не трогая никаких файлов.

Закрытие обхода — единственный путь, который выполняет эту дорогую, видимую работу: файл обхода с датой попадает в каталог, который накапливает по одному файлу на каждый завершённый обход, REPORT.md пересобирается с нуля на основе этого нового обхода, новый обход сравнивается с тем предыдущим, что записан, и отправляется сводка в Telegram — но всегда несущая сравнение, никогда в тот день, который лишь продвинул курсор. Самый последний шаг сбрасывает partial.json к свежему курсору на нуле для следующего обхода, но намеренно переносит тот же отпечаток дальше, а не генерирует новый; без этого запуск следующего дня прочитал бы частичный результат, который ничему из ожидаемого не соответствует, и начинал бы обход с нуля каждый день, с курсором, навечно застрявшим около нуля.

Почему это по-прежнему Gemini 2.5 Flash

Поиск с уземлением через Google бесплатен только на Gemini 2.5 Flash и Flash-Lite. Модель серии 3.x начала бы выставлять счёт за сам инструмент поиска после 5000 вызовов в месяц — а для монитора, который и так живёт на самой границе бесплатного лимита, одно случайное обновление модели превратило бы бесплатный проект в платный. Поэтому модель по умолчанию зашита в коде жёстко как gemini-2.5-flash, вместо того чтобы дрейфовать к чему угодно более новому при первой возможности.

Чего не видит автоматическая половина

Gemini — это не ChatGPT, и ежедневный скрипт, который разговаривает только с одной моделью, не может заменить две остальные. Поскольку ни ChatGPT, ни Perplexity не предлагают сопоставимого бесплатного, автоматизируемого API с уземлением, проект вместо этого ведёт второй, ручной обход раз в месяц — те же промпты, набранные вручную в оба интерфейса, с результатами, записанными вместе с трафиком сайта и трафиком ИИ-краулеров. Последний зафиксированный обход, проведённый 12.08.2026, показал, что ChatGPT процитировал mi-code.pl в 0 из 6 опробованных промптов, а Perplexity — в 1 из 1.

Этот ноль говорит больше, чем кажется. Для одного польского промпта о том, существует ли AI-ассистент, который подключается к wFirma и отвечает на вопросы про VAT и PIT, ChatGPT процитировал ровно один источник — сам wfirma.pl — и, не найдя там ничего про такого ассистента, прямо заявил, что такого продукта не существует. Это не проигрыш в ранжировании и не проблема качества чего-либо, что опубликовал MiCode: для этого вопроса ChatGPT идёт на один конкретный сайт и читает только то, что там есть. Пост в блоге, каким бы хорошо написанным он ни был, всё равно никогда не имел шанса быть прочитанным для этого вопроса. Потеря цитирования, в случаях, подобных этому, оказывается не столько вопросом того, чтобы писать больше, сколько вопросом отсутствия внутри того самого источника, который движок уже признал авторитетом именно для этого вопроса, — а это другая проблема, чем та, которую призван отслеживать автоматический обход Gemini, и именно поэтому ручной обход остаётся в проекте, хотя его нельзя автоматизировать.

Что это даёт на практике

В этом мониторе нет ничего эффектного — это ежедневная задача, которая в большинство дней просто сдвигает курсор на шаг и никому ничего не сообщает. Вся настоящая работа ушла в вещи, которые никогда не попадут на скриншот: повтор, который знает, когда сдаться, вместо того чтобы пытаться бесконечно; пауза, которая предотвращает проблему до того, как та возникнет; отпечаток, который следит, чтобы обход, растянутый на несколько дней, всё это время измерял одно и то же; защита, которая превращает опечатку в громкий сбой, а не в тихий, вечный no-op. Именно это — а не сам факт, что что-то раз в день спрашивает ИИ-модель, — и есть разница между задачей, которая работает без присмотра месяцами, и той, которую приходится перезапускать вручную каждый раз, когда что-то идёт не так.

Мы делаем подобные вещи — автоматизацию LLMOps, которая должна выживать в реальных лимитах API, а не только в демо, — и для клиентов тоже. Если вы сталкиваетесь с похожим ограничением, напишите нам на development@mi-code.pl.

Частые вопросы

Что такое монитор цитирований ИИ в MiCode?
Это ежедневная автоматическая задача, которая задаёт Gemini (gemini-2.5-flash) 27 промптов с включённым поиском Google в качестве уземления и проверяет, цитируют ли ответы mi-code.pl. Каждый промпт задаётся дважды, что даёт 54 вызова модели на полный обход.
Почему 54 вызова модели не помещаются в один день?
Бесплатный тариф Gemini допускает 20 вызовов generateContent в день на проект и на модель — API прямо указывает это в деталях собственной ошибки (id = GenerateRequestsPerDayPerProjectPerModel-FreeTier, value = 20). Это отдельный лимит от «до 500 запросов в день» из таблицы цен, которая относится к инструменту поиска, а не к вызовам самой модели. Код держит дневной бюджет на уровне 18, поэтому полный обход из 54 вызовов растягивается ровно на три дня.
Что происходит, когда вызов к Gemini завершается ошибкой?
Это зависит от типа ошибки. Разрыв соединения и ошибка сервера 5xx повторяются один раз, через пять секунд. Лимит скорости, код 429, тоже повторяется один раз, но через тридцать секунд, потому что лимит скорости проходит по часам, а ошибка сервера — когда ей вздумается. Ошибка 4xx, не являющаяся 429, не повторяется вообще, потому что это сломанный запрос, который во второй раз провалится точно так же.
Почему правка списка промптов посреди обхода обнуляет прогресс?
Потому что обход, измеренный по двум разным спискам промптов, ничего не значит — часть промптов посчиталась бы дважды, а новые никогда не были бы заданы в рамках этого обхода. Файл partial.json хранит отпечаток (sha1-хеш из идентификаторов промптов и числа повторов); когда prompts.json меняется, отпечаток при следующем запуске перестаёт совпадать, незавершённый обход отбрасывается, а новый начинается с нуля.
Почему монитор по-прежнему использует Gemini 2.5 Flash, а не более новую модель?
Потому что поиск с уземлением через Google бесплатен только на Gemini 2.5 Flash и Flash-Lite. Модель серии 3.x начала бы выставлять счёт за сам инструмент поиска после 5000 вызовов в месяц, поэтому модель по умолчанию жёстко зашита в коде как gemini-2.5-flash, вместо того чтобы дрейфовать к чему угодно более новому.