Недавно я запустил небольшой хобби-проект GameStory — сервис, в котором можно собирать игровые коллекции, отмечать прохождения, выставлять оценки и сохранять свою игровую историю. Изначально проект планировался вообще как личный, но, увидев поддержку друзей, я решил превратить его во что-то большее.
Одной из функций проекта стал календарь прохождений. Он показывает, в какие дни пользователь играл, начинал или заканчивал игры. Я рассчитывал, что календарём будут пользоваться люди — ну или хотя бы один человек: я.
Первым по-настоящему заинтересовался робот.
За один день он открыл мой календарь 88 632 раза, дошёл до 7273 года и заодно бесплатно провёл нагрузочное тестирование проекта.
Немного о проекте
GameStory написан на Laravel.
Пользователь может:
создавать несколько списков игр;
импортировать библиотеку из Steam;
добавлять игры из обычного текстового списка;
отмечать статусы и периоды прохождения;
ставить оценки и писать впечатления;
смотреть историю игр в виде календаря;
пользоваться другими функциями: друзьями, достижениями, профилями и т. д.
Календарь не был одной из моих любимых функций, но он превращал обычный список пройденных игр в небольшую личную временную линию.
Адрес публичного календаря выглядел так:
/calendar/chrono
Между месяцами можно было перемещаться стрелками вперёд и назад. Технически выбранный месяц передавался через GET-параметр:
/calendar/chrono?month=2026-08 /calendar/chrono?month=2026-09 /calendar/chrono?month=2026-10
В интерфейсе всё выглядело нормально: пользователь мог открыть предыдущий или следующий месяц и посмотреть свою историю. Проблема была в том, что ссылка на следующий месяц существовала всегда.
Собственная система посещений
Кроме Google Analytics и Яндекс Метрики, я сделал простую собственную систему учёта визитов.
Она сохраняла:
IP-адрес;
User-Agent;
тип устройства;
браузер;
посещённый путь;
время визита.
Мне было интереснее видеть не только количество пользователей, но и их реальные маршруты:
Главная → профиль → список игр → календарь → авторизация
Почти сразу после запуска я заметил много поисковых роботов и других ботов. Это, возможно, было ожидаемо. Неожиданным оказалось другое: один из них почти постоянно открывал мой календарь.
Сначала я не придал этому большого значения. Затем количество записей в таблице визитов превысило 128 тысяч. Из них 88 632 запроса за один день приходились на:
/calendar/chrono
Почему все запросы выглядели одинаково
В статистике я сохранял путь примерно так:
$request->path()
GET-параметры при этом не попадали в запись. Поэтому следующие адреса выглядели одинаково:
/calendar/chrono?month=2026-08 /calendar/chrono?month=2047-03 /calendar/chrono?month=7273-12
В базе сохранялось только:
calendar/chrono
Я видел почти девяносто тысяч обращений к календарю, но не мог понять, какие именно даты открывает бот. Оставалось только предполагать, что он методично нажимает «следующий месяц».
Позже выяснилось, что предположение было довольно точным.
Робот, который хотел увидеть будущее
Я изменил структуру URL и перенёс дату непосредственно в путь:
/calendar/{login}/{year}/{month}
Теперь адреса стали выглядеть так:
/calendar/chrono/2026/08 /calendar/chrono/2027/01 /calendar/chrono/2047/03
После публикации обновления в статистике начали появляться настоящие маршруты бота. Среди них был, например, такой:
/calendar/chrono/7271/12
А затем:
/calendar/chrono/7273/12
При этом номера месяцев всегда оставались корректными — от 1 до 12. То есть робот не подставлял случайные значения: он действительно следовал календарной навигации.
Возможно, часть ссылок уже находилась в его внутренней очереди, поэтому даже после обновления он ещё некоторое время продолжал обходить старые даты. Иногда отдельные месяцы посещались повторно:
/calendar/chrono/7273/12 /calendar/chrono/7273/05 /calendar/chrono/1182/07 /calendar/chrono/1725/01
Позже я изменил отображение несуществующих периодов, поэтому повторные обращения могли быть проверкой обновившегося ответа.
Впрочем, календарём робот не ограничился. Он также открывал:
/search /chrono/games/completed
Иногда он заходил и на страницы отдельных игр. Получился довольно старательный пользователь — просто с необычно долгосрочными планами.
Что именно было сломано
Формально календарь работал правильно. Следующий месяц после декабря 2026 года — январь 2027 года. Следующий после декабря 7272-го — январь 7273-го.
Ошибка находилась не в арифметике дат. Проблема заключалась в том, что приложение создавало бесконечный граф доступных ссылок.
Для обычного человека календарь практически ограничен здравым смыслом. Никто не станет несколько часов нажимать стрелку перехода к следующему месяцу. У робота таких ограничений не было: безумие и отвага не позволили ему остановиться.
Если на странице существует ссылка, робот может:
добавить её в очередь;
открыть страницу;
найти на ней следующую ссылку;
повторять процесс до тех пор, пока ссылки не закончатся.
А они не заканчивались. Каждая страница календаря создавала следующую страницу календаря:
2026-08 → 2026-09 → 2026-10 → ... → 7273-12
Вероятно, если бы я ничего не изменил, путешествие продолжалось бы и дальше.
Неожиданный нагрузочный тест
88 632 запроса за сутки — это в среднем примерно:
3693 запроса в час 61 запрос в минуту около одного запроса в секунду, но чаще всего двух, потому что другие роботы тоже прикладывали усилия
При этом проект продолжал работать без ошибок. Не появилось:
заметного роста нагрузки;
ошибок 500;
проблем с памятью;
зависших запросов;
сбоев базы данных.
Страница календаря оказалась достаточно лёгкой, а сервер спокойно пережил путешествие на пять тысяч лет вперёд.
Это не полноценный нагрузочный тест, но всё равно полезный результат. Я точно не планировал проверять проект таким способом сразу после запуска.
Как я исправил навигацию
Первым делом я оставил новые понятные URL:
/calendar/chrono /calendar/chrono/2026 /calendar/chrono/2026/08
Теперь для внутренней аналитики достаточно сохранять обычный путь: год и месяц уже находятся внутри него.
Затем я изменил стрелки календаря. Если дата неактуальна или находится в будущем, элемент больше не получает href — ссылку на следующую страницу.
То есть он может визуально оставаться стрелкой, но больше не является ссылкой:
<span class="calendar-arrow disabled">→</span>
Вместо:
<a href="/calendar/chrono/7274/01">→</a>
Это остановило автоматическое создание новых календарных маршрутов. При этом я пока сохранил возможность вручную открыть произвольный URL. Например:
/calendar/chrono/7273/12
Мне хотелось оставить такую возможность хотя бы для отладки. Если расчёт допустимого диапазона когда-нибудь окажется неправильным, нужная страница не будет полностью заблокирована.
Для неактуальных страниц я добавил:
<meta name="robots" content="noindex, follow">
Таким образом:
страница остаётся доступной по прямому адресу;
поисковику предлагается не добавлять её в индекс;
ссылки на профиль, главную и другие полезные страницы остаются доступными для обхода.
nofollow я намеренно не использовал, потому что даже на пустой странице календаря остаются полезные ссылки:
/ /chrono
Почему я не стал сразу возвращать 404
Самым строгим решением было бы возвращать 404 Not Found для дат за пределами реальной игровой истории пользователя.
Например:
abort_if( $year < $firstActivityYear || $year > now()->year, 404 );
Но на первом этапе я решил не делать ограничение жёстким. Причины довольно практичные:
расчёт первой и последней доступной даты может содержать ошибку;
произвольные даты полезны для ручного тестирования;
сама страница почти не нагружает сервер;
новые ссылки на несуществующие даты больше не генерируются;
такие страницы получают
noindex.
Возможно, позже я всё же перейду на 404 или 410. Пока доступность страницы и её индексируемость разделены.
Что показала собственная аналитика
Google Analytics и Яндекс Метрика полезны для общей статистики, но в этой ситуации собственная таблица визитов оказалась гораздо нагляднее.
Я мог увидеть:
конкретный IP;
User-Agent;
последовательность запросов;
страницы, которые посещались повторно;
точные URL после изменения маршрутов;
момент, когда поведение робота изменилось.
Без этого я бы увидел просто большую цифру просмотров календаря и, возможно, решил бы, что неожиданно стал очень популярным.
После очистки старых записей картина стала гораздо реалистичнее: вместо бесконечного потока календарных запросов начали появляться обычные гости, которые открывали главную страницу и раздел «О проекте».
Чем всё закончилось
Я не стал превращать несуществующие даты в пустую техническую страницу или сразу возвращать 404.
Теперь, если открыть период, для которого у пользователя нет игровой истории, календарь объясняет, что записей за эту дату не найдено, и предлагает несколько полезных направлений:
перейти в профиль пользователя;
посмотреть его игровые коллекции;
открыть полную историю игр;
вернуться к сегодняшней дате в календаре.
Например, так выглядит календарь за несуществующий период:
https://gamestory.kz/calendar/chrono/2027
При этом страница получает:
<meta name="robots" content="noindex, follow">
Получился компромисс:
бессмысленные календарные периоды не должны попадать в поисковую выдачу;
робот всё ещё может обнаружить профиль, коллекции и другие полезные страницы;
пользователь не оказывается в тупике;
произвольный URL остаётся доступным для ручной проверки;
календарь больше не создаёт бесконечную цепочку новых ссылок.
После изменения навигации активность робота постепенно стабилизировалась. В статистике перестали доминировать десятки тысяч переходов по календарю, а среди посещений начали появляться обычные гости.
Так бот не только обнаружил ошибку в архитектуре навигации, но и заставил меня улучшить страницу для реальных пользователей.
Какие выводы я сделал
1. Визуально отключённая ссылка всё ещё остаётся ссылкой
Недостаточно изменить цвет элемента или добавить класс disabled. Если у элемента остаётся href, робот всё ещё видит полноценный маршрут.
2. Любая навигация должна иметь естественные границы
Календари, пагинация, архивы и фильтры особенно легко создают бесконечные последовательности URL. Человек останавливается сам. Робот — необязательно.
3. Для аналитики полезно сохранять полный адрес
Если поведение страницы зависит от GET-параметров, одного $request->path() недостаточно.
Полезно хранить хотя бы отдельно:
$request->path(); $request->getQueryString(); $request->fullUrl();
Иначе тысячи разных страниц могут выглядеть как одна.
4. Боты иногда оказываются неплохими тестировщиками
Этот робот проверил:
календарную навигацию;
работу дат на тысячелетия вперёд;
стабильность Laravel-приложения;
производительность базы;
систему собственной аналитики.
И не создал ни одного баг-репорта.
5. Crawl budget можно потратить очень странным способом
Для небольшого сайта это вряд ли критично, но бессмысленные календарные URL отнимают у поискового робота время, которое он мог бы потратить на профили, списки и страницы игр.
Поэтому бесконечную цепочку всё равно стоило остановить, даже если сервер спокойно её выдерживал.
Итог
Я запускал сервис для хранения игровой истории, а получил историю о роботе, который отправился исследовать игровой календарь до 7273 года.
Баг оказался не в вычислении дат и не в Laravel. Каждая отдельная ссылка была корректной. Ошибка возникла из-за того, что последовательность корректных ссылок не имела конца.
После исправления робот постепенно успокоился, количество запросов стабилизировалось, а в аналитике наконец начали появляться обычные гости.
Теперь мне интересно мнение сообщества.
Какое поведение вы бы выбрали для календаря за пределами реальных данных пользователя?
жёсткий
404;410 Gone;перенаправление на ближайший существующий период;
доступная страница с
noindex;другой вариант?
Сам проект можно посмотреть на gamestory.kz. Но главным результатом первого публичного запуска пока стал не новый пользователь, а очень целеустремлённый робот, который доказал: календарь действительно готов к долгосрочному хранению игровой истории.

