Недавно я запустил небольшой хобби-проект 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. Но главным результатом первого публичного запуска пока стал не новый пользователь, а очень целеустремлённый робот, который доказал: календарь действительно готов к долгосрочному хранению игровой истории.
ссылка на оригинал статьи https://habr.com/ru/articles/1068334/