Можно на самом деле проверить, знает ли большая языковая модель о существовании 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 срабатывает только из этого завершающего запуска — и только если сравнение показывает, что набор промптов, которые нас действительно цитируют, изменился. Промежуточный день, который лишь продвигает курсор, не отправляет никакого уведомления, потому что пока не о чем говорить.
Файл с датой из завершённого обхода попадает в каталог, где хранится по одному файлу на каждый завершённый обход, — так история того, что модель цитировала месяц-два назад, сохраняется, а не теряется за тем, что показывает последний отчёт. Ритм трёх дней, и место, где повторная попытка в середине обхода возвращается в ту же порцию, а не перескакивает дальше, выглядит так:
Сам список работы — не более чем 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, вместо того чтобы дрейфовать к чему угодно более новому.