Как мы собрали карту 6500+ АЗС из OpenStreetMap и не смешали справочник с данными «прямо сейчас»
Когда водитель ищет заправку, у него на самом деле два разных вопроса:
Где физически находится АЗС и какое топливо там обычно продают?
Есть ли нужное топливо сейчас?
Большинство карт хорошо отвечает на первый вопрос. Второй заметно сложнее: остатки меняются быстрее, чем обновляются справочники, а открытого источника актуальных данных по всем сетям нет.
Мы начали делать BenzinNow как карту, на которой водители могут отмечать наличие топлива. Сейчас в каталоге более 6500 российских АЗС. В статье — не история про «ещё одну карту», а разбор архитектурного решения: как разделить относительно стабильные сведения OpenStreetMap и короткоживущие сообщения пользователей, не отправлять браузер напрямую в Overpass API и не создавать тысячи бесполезных страниц для поисковых роботов.
Два слоя данных с разной скоростью жизни
Главное решение — не хранить «АЗС» и «наличие топлива» как одну сущность.
Справочный слой отвечает за координаты, адрес, название, оператора, бренд и перечень видов топлива. Его источником служит OpenStreetMap. Эти сведения меняются сравнительно редко и обновляются фоновым импортом.
Оперативный слой состоит из сообщений водителей. Он отвечает на вопрос, видел ли конкретный пользователь конкретное топливо на этой станции в определённый момент. Такие данные быстро устаревают, проходят отдельную проверку и никогда не перезаписывают справочник.
Это различие кажется очевидным, но сильно упрощает дальнейшую систему. Если пользователь сообщает, что АИ-95 закончился, станция не исчезает с карты, а АИ-95 не удаляется из её постоянного списка. Меняется только вычисляемый текущий статус топлива.
Импорт OpenStreetMap: не из браузера и не одним огромным запросом
Для получения объектов АЗС используется стандартный тег amenity=fuel. Упрощённый запрос к Overpass выглядит так:
[out:json][timeout:90];
(
nwr["amenity"="fuel"](around:RADIUS,LAT,LON);
);
out center tags;
node, way и relation приходят в разных формах, поэтому для площадных объектов берём center, а затем нормализуем поля из tags. Название, сеть, адрес и виды топлива в OSM заполнены неодинаково: где‑то есть полный addr:*, где‑то только название, а где‑то — одни координаты. Импортёр должен спокойно принимать неполные объекты и не выдавать предположение за факт.
Первичное наполнение мы разбили на запросы вокруг 26 крупных городов. Несколько Overpass‑серверов используются по очереди, есть тайм‑ауты и повторные попытки. Это пригодно для ограниченного каталога, но не является способом регулярно выкачивать всю Россию: для полного массового импорта правильнее брать региональные PBF‑выгрузки и обрабатывать их локально.
Самое важное ограничение: веб‑приложение не обращается к публичному Overpass API при каждом перемещении карты. Пользователь получает уже подготовленные данные из нашей базы. Иначе десятки движений карты превращались бы в десятки тяжёлых внешних запросов, а доступность приложения зависела бы от чужого публичного сервиса.
Базовый каталог обновляется отдельной ежедневной задачей. Новая версия сначала собирается целиком, а затем атомарно заменяет предыдущую. Приложение перечитывает готовый набор, поэтому пользователь не видит наполовину завершённый импорт.
Импорт по спросу для новых областей
Пакетный импорт крупных городов даёт быстрый старт, но не покрывает каждую дорогу. Поэтому есть второй, необязательный контур — фоновое расширение каталога по области, которую реально открывают пользователи.
При масштабе карты от 9 браузер отправляет не запрос в Overpass, а только описание покрытия. На сервере область переводится в ячейки Web Mercator уровня 8. За один раз в очередь попадает не более шести ближайших ячеек.
Очередь хранится в PostgreSQL/PostGIS и защищена от дублей. Готовая ячейка не запрашивается повторно 30 дней, после ошибки действует пауза 6 часов, а число активных заданий ограничено. На постановку действует лимит 12 запросов с одного IP в минуту. Воркеры забирают задания независимо:
SELECT id
FROM osm_import_queue
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;
SKIP LOCKED позволяет запустить несколько воркеров без двойной обработки одной области. Результат добавляется к каталогу, а не заменяет его. Пользовательские отметки при таком импорте сохраняются, потому что живут в отдельном слое и привязаны к идентификатору станции.
Такой механизм превращает спрос в подсказку для фонового наполнения, но требует жёстких ограничений. Без дедупликации, TTL и ограничения очереди любой активный пользователь мог бы случайно устроить сканирование огромной территории.
Почему одной кнопки «есть бензин» недостаточно
Краудсорсинговая отметка полезна только вместе с контекстом: кто, где и когда её оставил. При этом мы не хотели заставлять водителя регистрироваться и собирать лишние персональные данные.
Перед отправкой браузер получает свежую геопозицию. Сервер принимает координаты не старше 30 секунд, проверяет заявленную точность до 500 метров и расстояние до АЗС. Сообщить статус можно только в радиусе 300 метров от станции.
Упрощённо проверка выглядит так:
const MAX_LOCATION_AGE_MS = 30_000;
const MAX_ACCURACY_METERS = 500;
const REPORT_RADIUS_METERS = 300;
if (locationAge > MAX_LOCATION_AGE_MS) reject('stale_location');
if (accuracy > MAX_ACCURACY_METERS) reject('low_accuracy');
if (distanceToStation > REPORT_RADIUS_METERS) reject('too_far');
Это не доказывает, что сообщение истинно, но отсеивает массовые отметки «с дивана». Дополнительно действуют ограничение частоты записи, 15-минутный интервал для повторов и идемпотентность запроса на 24 часа. Повторная отправка из‑за плохой сети не должна создавать несколько одинаковых событий.
Статус хранится отдельно для каждого вида топлива. Пользователь может отметить, что АИ-92 есть, а АИ-95 нет. В событии также могут присутствовать длина очереди и ограничение отпуска в литрах.
Перед публикацией событие проходит модерационный контур. Наружу выдаются только принятые сообщения; внутренние причины риска, хеши и технические признаки в публичный API не попадают. При вычислении текущего состояния свежие отчёты имеют больший вес, а от одного устройства учитывается его последнее принятое сообщение. Равный конфликт «есть» и «нет» лучше показывать как неопределённость, а не выбирать удобный ответ.
Есть принципиальное ограничение: при небольшом числе пользователей карта не может обещать актуальность для каждой станции. Поэтому отсутствие свежей отметки показывается как отсутствие данных, а не как отсутствие топлива. Сообщения водителей носят информационный характер и не являются официальными данными сети АЗС.
API и кэширование карты
Карта запрашивает станции по видимой области, городу, виду топлива и статусу. Один ответ ограничен 500 объектами: это защищает и базу, и клиент от случайной загрузки всего каталога.
Для пространственных запросов используется PostGIS. Горячие ответы API и общие лимиты хранятся в Redis; если Redis недоступен, приложение может использовать локальный запасной механизм. Координаты границ области перед формированием ключа кэша округляются до четырёх знаков. Без нормализации почти одинаковые движения карты создавали бы разные ключи и резко снижали пользу кэша.
Здесь есть компромисс: слишком грубое округление возвращает лишние объекты, слишком точное — создаёт большое число почти одинаковых записей. Четыре знака оказались разумным уровнем для текущего масштаба и размера ответов, но это не универсальная константа.
SEO для карты: страницы должны отвечать на вопрос
Одностраничное приложение удобно человеку, но само по себе даёт поисковому роботу мало контекста. Поэтому сервер формирует отдельный HTML для страниц городов, регионов, видов топлива, брендов и станций. Метаданные и содержимое собираются из фактического каталога, а не из набора заранее написанных шаблонов с подменой одного слова.
Для станции выводятся адрес, оператор и известные виды топлива, а в JSON‑LD используется тип GasStation. Страницы списков получают BreadcrumbList, CollectionPage или ItemList. Карта остаётся интерактивной, но важная информация доступна уже в серверном HTML.
При генерации посадочных страниц обнаружилась другая проблема: неполные записи OSM легко превращаются в тысячи «тонких» страниц. Мы разрешаем индексирование страницы станции только при наличии содержательного названия, пригодного адреса и хотя бы бренда или сведений о топливе. Для дублей по сочетанию города и адреса выбирается один канонический объект. Остальные страницы остаются доступными человеку, но получают noindex, follow и заголовок X-Robots-Tag.
Упрощённое правило:
qualified = meaningfulName && usableAddress && (brand || fuels)
canonical = firstStationFor(city, normalizedAddress)
index = qualified && station.id == canonical.id
robots = index ? "index, follow" : "noindex, follow"
Sitemap разделён на группы: статические страницы, география, фильтры, контент и станции. Это облегчает диагностику индексации и позволяет не пересобирать один огромный файл при любом изменении. В robots.txt закрыт служебный /api/, а параметры интерфейса не создают отдельные индексируемые URL.
Что оказалось важнее количества объектов
За время разработки сформировалось несколько практических выводов.
Во‑первых, открытый справочник и оперативный сигнал нельзя смешивать. OSM хорошо сообщает, что станция существует и где она находится, но не подтверждает остатки топлива в конкретную минуту.
Во‑вторых, внешние геосервисы лучше использовать в фоновых контролируемых процессах. Кэш, очередь и локальная база здесь важнее красивого запроса из браузера.
В‑третьих, краудсорсинг начинается не с кнопки, а с модели доверия: близость к объекту, свежесть координат, идемпотентность, лимиты, модерация и честное состояние «данных нет».
Наконец, программная генерация страниц не означает, что все страницы следует индексировать. Для каталога качество и канонизация важнее количества URL.
Посмотреть текущую реализацию можно на BenzinNow. Карта бесплатная, отметку можно оставить без регистрации. Данные о расположении и свойствах АЗС основаны на OpenStreetMap и зависят от полноты его разметки; пользовательские сообщения не заменяют официальную информацию операторов заправок.