NAP-консистентность: как скриптом проверить, что адрес, телефон и часы одинаковы на сайте и в картах
У локального бизнеса данные о точке живут минимум в четырёх местах: на сайте, в карточке на Яндекс Картах, в 2ГИС и в профиле Google. Пока точка одна и ничего не меняется, всё совпадает. Потом кофейня переезжает в соседн
У локального бизнеса данные о точке живут минимум в четырёх местах: на сайте, в карточке на Яндекс Картах, в 2ГИС и в профиле Google. Пока точка одна и ничего не меняется, всё совпадает. Потом кофейня переезжает в соседний корпус, у салона появляется новый номер, клиника меняет часы на лето. Правку вносят в одно место, остальные три остаются со старыми данными.
В локальном SEO для этого есть термин NAP — Name, Address, Phone: название, адрес и телефон. На практике к ним всегда добавляют часы работы, потому что именно часы устаревают чаще всего. NAP-консистентность — это когда во всех источниках эти данные одинаковы.
Эта статья для разработчиков и вебмастеров, которые ведут сайты кафе, клиник, автосервисов и сетей с филиалами. Разберём:
- почему расхождения NAP вредят и клиенту, и поиску;
- как завести единый эталон данных точки;
- как нормализовать адреса, телефоны и часы, чтобы сравнение было честным;
- готовый скрипт на Python, который сверяет JSON-LD сайта с данными карточек;
- праздничные и специальные часы в schema.org;
- как запускать проверку по расписанию и что делать с найденными расхождениями.
Почему расхождения NAP — это проблема
Самый очевидный ущерб — человеческий. Клиент видит на сайте «до 21:00», приходит в 20:30 и упирается в закрытую дверь, потому что в карточке давно стоит «до 20:00», а на сайт правку забыли внести. Или звонит по номеру из 2ГИС, который отключили полгода назад. Такой клиент не возвращается и иногда оставляет отзыв.
Второй уровень — поисковый. Поисковики и картографические сервисы сопоставляют данные из разных источников, чтобы понять, что перед ними одна и та же организация. Когда адрес на сайте и в карточке пишется по-разному, а телефоны не совпадают, связать их сложнее. Насколько сильно это влияет на ранжирование в конкретном сервисе, публично никто не раскрывает, поэтому громких цифр здесь не будет. Но совпадающие данные точно не мешают, а расходящиеся точно путают людей.
Третий уровень — внутренний. Когда никто не знает, какой номер правильный, каждая правка превращается в расследование. Скрипт, который раз в неделю говорит «всё совпадает» или «вот три расхождения», снимает этот вопрос.
Совет. Начните с аудита, а не с кода. Откройте все карточки точки, сайт и выпишите данные в таблицу. Если расхождений нет ни одного — автоматизация нужна для будущего. Если они уже есть — сначала поправьте, потом закрепляйте скриптом.
Шаг 1. Единый эталон данных точки
Главная идея: должен существовать один файл, где записано, как правильно. Не «как на сайте» и не «как в Яндексе», а эталон, с которым сверяется всё остальное. Сайт, карточки и печатные материалы — это копии.
Удобнее всего хранить эталон в YAML рядом с кодом сайта. Его можно положить в репозиторий, правки идут через пул-реквест, история изменений видна в git.
points:
- id: lesnaya-12
site_url: https://example.ru/contacts/
reference:
name: Кофейня «Зерно»
address: Москва, ул. Лесная, 12к2
phone: +7 495 123-45-67
hours: Пн-Пт 08:00-21:00; Сб 10:00-20:00
cards:
map_a:
name: Кофейня «Зерно»
address: Москва, Лесная улица, 12 к2
phone: 8 (495) 123-45-67
hours: Пн-Пт 08:00-21:00; Сб 10:00-20:00
map_b:
name: Кофейня «Зерно»
address: Москва, ул. Лесная, д. 12, корп. 2
phone: +7 495 123-45-76
hours: Пн-Пт 08:00-21:00; Сб-Вс 10:00-20:00
Пример выдуманный, но типичный: в одной карточке адрес записан в другом порядке и с другим сокращением, а в другой перепутаны две цифры телефона и добавлено воскресенье.
Для сети с филиалами каждая точка — отдельный элемент списка points со своим id. Это же значение удобно использовать как @id в JSON-LD и как ключ в CMS.
Откуда брать данные карточек
Здесь важно не перехитрить самого себя. Парсить публичные страницы карточек в картах — плохая идея: это нарушает условия использования большинства сервисов, вёрстка меняется без предупреждения, а за частые запросы можно получить блокировку.
Нормальные варианты такие:
- Официальные API справочников. У Google есть Places API с полями адреса, телефона и часов. У 2ГИС есть API справочника, у Яндекса — API поиска по организациям. Всем нужен ключ, у каждого свои условия: что можно хранить, как долго и с какой пометкой. Прочитайте их до того, как писать код.
- Выгрузки из кабинетов. Кабинеты для владельцев бизнеса у сетей часто позволяют выгрузить список точек файлом. Такой файл можно класть рядом с эталоном и разбирать тем же скриптом.
-
Снимок, заполненный сотрудником. Для одной-трёх точек проще всего раз в месяц скопировать данные из каждой карточки в блок
cards. Скрипт всё равно сделает самое неприятное: нормализует и сравнит.
Скрипт ниже работает с блоком cards как с готовыми данными, поэтому источник можно поменять, не трогая логику сравнения.
Шаг 2. Нормализация: сравнивать смысл, а не символы
Если сравнивать строки как есть, проверка утонет в ложных тревогах. «ул. Лесная, д. 12, корп. 2», «Лесная улица, 12 к2» и «улица Лесная, дом 12, корпус 2» — один и тот же адрес. «+7 (495) 123-45-67» и «8 495 1234567» — один и тот же номер.
Поэтому перед сравнением каждое поле приводится к канонической форме.
Телефон. Убираем всё, кроме цифр. Российские номера, начинающиеся с 8, переводим в формат с 7. Получаем E.164 вида +74951234567. Для международных сетей лучше взять библиотеку phonenumbers, она знает правила разных стран.
Адрес. Задача сложнее, и полностью её решают только геокодеры. Но для сверки своих же данных хватает простых правил:
- нижний регистр и «ё» → «е»;
- точки и запятые → пробелы;
- полные слова → стандартные сокращения: «улица» → «ул», «корпус» → «к», «строение» → «стр»;
- «12 к 2» и «12 к2» → «12к2»;
- «д 12» → «12»;
- сравнение без учёта порядка слов, потому что сервисы по-разному ставят тип улицы: до названия или после.
Часы. Приводим к словарю «день недели → интервал» или closed. Тогда «Пн-Пт 08:00-21:00» и пять отдельных записей openingHoursSpecification дают одинаковый результат.
Название. Сравниваем без учёта регистра. Если в карточке к названию дописаны ключевые слова, а на сайте их нет, это повод разобраться: правила картографических сервисов обычно запрещают такие дописки.
Совет. Не пытайтесь сделать нормализацию адресов идеальной. Цель — чтобы одинаковые адреса не давали ложных тревог. Если правило ловит лишнее, добавьте его в список замен и двигайтесь дальше.
Шаг 3. Скрипт сверки на Python
Ниже полный скрипт. Он делает четыре вещи:
- Читает эталон и снимки карточек из
nap.yaml. - Скачивает страницу контактов сайта и достаёт из неё блок JSON-LD с организацией.
- Нормализует все источники.
- Печатает расхождения и завершает работу с кодом 1, если они есть. Это удобно для CI.
Зависимости: requests, beautifulsoup4, pyyaml.
import json, re, sys
import yaml # pip install pyyaml requests beautifulsoup4
import requests
from bs4 import BeautifulSoup
DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
RU_DAYS = {"пн": "Mo", "вт": "Tu", "ср": "We", "чт": "Th",
"пт": "Fr", "сб": "Sa", "вс": "Su"}
def norm_phone(raw):
digits = re.sub(r"\D", "", raw or "")
if len(digits) == 11 and digits[0] in "78":
digits = "7" + digits[1:]
elif len(digits) == 10:
digits = "7" + digits
return "+" + digits if digits else ""
ADDR_RULES = [
(r"\bулица\b", "ул"), (r"\bпроспект\b", "пр-т"), (r"\bпр-кт\b", "пр-т"),
(r"\bпереулок\b", "пер"), (r"\bдом\b", "д"), (r"\bкорпус\b", "к"),
(r"\bкорп\b", "к"), (r"\bстроение\b", "стр"), (r"\bэтаж\b", "эт"),
]
def norm_address(raw):
s = (raw or "").lower().replace("ё", "е")
s = re.sub(r"[.,]", " ", s)
for pattern, repl in ADDR_RULES:
s = re.sub(pattern, repl, s)
s = re.sub(r"\b(\d+)\s*к\s*(\d+)", r"\1к\2", s) # 12 к 2, 12 к2 -> 12к2
s = re.sub(r"\bд\s+(?=\d)", "", s) # «д 12» -> «12»
return " ".join(s.split())
def expand_days(token):
token = token.strip().lower()
if "-" in token:
a, b = [RU_DAYS.get(t.strip(), t.strip()) for t in token.split("-")]
i, j = DAYS.index(a), DAYS.index(b)
return DAYS[i:j + 1]
return [RU_DAYS.get(token, token)]
def norm_hours_text(raw):
"""'Пн-Пт 09:00-20:00; Сб 10:00-18:00' -> {'Mo': '09:00-20:00', ...}"""
week = {d: "closed" for d in DAYS}
for part in (raw or "").split(";"):
m = re.match(r"\s*([а-яa-z\-\s]+?)\s+(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2})",
part.strip().lower())
if m:
for d in expand_days(m.group(1)):
week[d] = f"{int(m.group(2)[:-3]):02d}{m.group(2)[-3:]}-{m.group(3)}"
return week
def norm_hours_schema(specs):
week = {d: "closed" for d in DAYS}
for spec in specs or []:
days = spec.get("dayOfWeek", [])
days = days if isinstance(days, list) else [days]
for d in days:
short = d.rsplit("/", 1)[-1][:2] # https://schema.org/Monday -> Mo
week[short] = f'{spec["opens"][:5]}-{spec["closes"][:5]}'
return week
def site_record(url):
resp = requests.get(url, timeout=20, headers={"User-Agent": "nap-check/1.0"})
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "html.parser") # bytes: кодировку определит сам
for tag in soup.find_all("script", type="application/ld+json"):
data = json.loads(tag.string or "{}")
items = data if isinstance(data, list) else data.get("@graph", [data])
for item in items:
if "address" in item and "telephone" in item:
a = item["address"]
addr = ", ".join(filter(None, [a.get("addressLocality"),
a.get("streetAddress")]))
return {
"name": item.get("name", ""),
"address": norm_address(addr),
"phone": norm_phone(item["telephone"]),
"hours": norm_hours_schema(item.get("openingHoursSpecification")),
}
raise SystemExit(f"{url}: JSON-LD с адресом и телефоном не найден")
def card_record(card):
return {
"name": card["name"],
"address": norm_address(card["address"]),
"phone": norm_phone(card["phone"]),
"hours": norm_hours_text(card["hours"]),
}
def compare(reference, other):
diffs = []
if reference["name"].lower() != other["name"].lower():
diffs.append(("name", reference["name"], other["name"]))
# порядок слов не важен: «ул Лесная» и «Лесная ул» — один адрес
if sorted(reference["address"].split()) != sorted(other["address"].split()):
diffs.append(("address", reference["address"], other["address"]))
if reference["phone"] != other["phone"]:
diffs.append(("phone", reference["phone"], other["phone"]))
for day in DAYS:
if reference["hours"][day] != other["hours"][day]:
diffs.append((f"hours.{day}", reference["hours"][day], other["hours"][day]))
return diffs
def main(path):
cfg = yaml.safe_load(open(path, encoding="utf-8"))
errors = 0
for point in cfg["points"]:
ref = card_record(point["reference"])
sources = {"site": site_record(point["site_url"])}
sources.update({k: card_record(v) for k, v in point["cards"].items()})
for source, rec in sources.items():
for field, want, got in compare(ref, rec):
errors += 1
print(f"[{point['id']}] {source:8} {field:10} эталон: {want!r} | там: {got!r}")
print("OK: расхождений нет" if not errors else f"Найдено расхождений: {errors}")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "nap.yaml")
Запуск на примере выше:
$ python nap_check.py nap.yaml
[lesnaya-12] map_b phone эталон: '+74951234567' | там: '+74951234576'
[lesnaya-12] map_b hours.Su эталон: 'closed' | там: '10:00-20:00'
Найдено расхождений: 2
Этот вывод получен реальным запуском скрипта на тестовой странице. Адрес в map_a записан иначе, чем в эталоне, но после нормализации совпал, поэтому в отчёт не попал. Остались две настоящие ошибки: переставленные цифры телефона и лишнее воскресенье.
Грабли, на которые мы наступили при написании
-
Кодировка. Первая версия брала
resp.text. Тестовый сервер не прислалcharsetв заголовке,requestsпо умолчанию решил, что это ISO-8859-1, и кириллица превратилась в кракозябры. Решение — передавать в BeautifulSoup байты (resp.content): он сам найдёт<meta charset>в документе. -
@graph. Многие CMS и SEO-плагины кладут организацию не в корень JSON-LD, а в массив@graphрядом сWebSiteиBreadcrumbList. Скрипт проверяет оба варианта. -
Несколько блоков разметки. На странице бывает три-четыре
<script type="application/ld+json">. Ищем тот, где есть иaddress, иtelephone. -
dayOfWeekв разных формах. Встречаются"Monday","https://schema.org/Monday"и"http://schema.org/Monday". Берём последнюю часть после слэша и первые две буквы.
Шаг 4. Праздничные и специальные часы
Обычный график совпадает чаще всего. Расходятся праздники: в карточке на картах владелец отметил сокращённый день 31 декабря, а на сайте об этом никто не вспомнил. Или наоборот.
В schema.org для этого есть тот же openingHoursSpecification, но с полями validFrom и validThrough. Запись с датами действует только в указанный период и уточняет обычный график:
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "08:00",
"closes": "21:00"
},
{
"@type": "OpeningHoursSpecification",
"validFrom": "2026-12-31",
"validThrough": "2026-12-31",
"opens": "08:00",
"closes": "16:00"
},
{
"@type": "OpeningHoursSpecification",
"validFrom": "2027-01-01",
"validThrough": "2027-01-02",
"opens": "00:00",
"closes": "00:00"
}
]
Последний блок по соглашению schema.org означает, что в эти дни точка закрыта. Перед релизом проверьте такую разметку валидатором.
Как встроить это в проверку:
- храните особые даты в эталоне отдельным списком:
special: [{date: 2026-12-31, hours: "08:00-16:00"}, {date: 2027-01-01, hours: closed}]; - генерируйте из этого списка и обычный график, и записи с
validFrom; - в скрипт добавьте функцию, которая для каждой даты из ближайших 14 дней считает «какие часы будут в этот день» по каждому источнику, и сравнивайте уже их.
Последний пункт удобнее, чем сравнивать списки исключений напрямую: неважно, как именно сервис хранит праздник, важно, что в итоге увидит клиент 31 декабря.
Совет. Заведите в календаре команды напоминание за две недели до каждого длинного праздника: «обновить особые часы в эталоне». Скрипт поймает расхождение, только если праздник уже есть хотя бы в одном источнике.
Шаг 5. Генерируйте разметку сайта из эталона
Сверять сайт с эталоном полезно, но ещё лучше, когда сайт вообще не может разойтись с эталоном. Если контакты и JSON-LD собираются из того же nap.yaml, половина проверок становится формальностью.
Минимальный генератор блока разметки:
import json, yaml
def to_jsonld(point):
ref = point["reference"]
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": f'https://example.ru/#{point["id"]}',
"name": ref["name"],
"telephone": ref["phone"],
"address": {
"@type": "PostalAddress",
"addressLocality": ref["city"],
"streetAddress": ref["street"],
},
"openingHoursSpecification": ref["hours_spec"],
}
cfg = yaml.safe_load(open("nap.yaml", encoding="utf-8"))
for p in cfg["points"]:
print(json.dumps(to_jsonld(p), ensure_ascii=False, indent=2))
Здесь предполагается, что в эталоне адрес хранится по частям (city, street), а часы — сразу в структуре schema.org. Формат эталона подберите под свою CMS: главное, чтобы источник был один.
Текст на странице «Контакты» тоже стоит выводить из эталона, а не набирать в визуальном редакторе. Тогда правка номера в одном файле меняет и видимый текст, и разметку, и ссылку tel:.
Шаг 6. Запуск по расписанию
Данные в карточках меняются не только по вашей воле. Пользователи предлагают правки, сервисы иногда обновляют адреса после переименования улиц, сотрудник с доступом к кабинету меняет телефон и забывает сказать. Поэтому проверка должна запускаться сама.
Самый простой вариант — GitHub Actions по расписанию:
name: nap-check
on:
schedule:
- cron: "0 6 * * 1" # каждый понедельник, 06:00 UTC
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install requests beautifulsoup4 pyyaml
- run: python nap_check.py nap.yaml
Если скрипт нашёл расхождения, он вернёт код 1, задача упадёт, и ответственные получат стандартное уведомление о сбое. Подойдёт и обычный cron на сервере с отправкой вывода в рабочий чат.
Что ещё стоит добавить по мере роста:
- проверку, что страница контактов отвечает 200, а не редиректом на главную;
- сверку координат: точка в разметке
geoи точка в карточке не должны расходиться больше чем на 50–100 метров; - отдельный отчёт по сети: сколько точек проверено и где расхождения;
- проверку ссылки на сайт в карточках: часто там остаётся старый домен или страница без HTTPS.
Что делать с найденными расхождениями
Скрипт только показывает проблему. Исправлять её нужно в том источнике, который разошёлся с эталоном.
Короткий порядок действий:
- Сначала проверьте эталон. Иногда «расхождение» означает, что правильные данные как раз в карточке, а эталон устарел.
- Сайт правится сразу — это ваш код и ваш деплой.
- Карточки правятся через кабинет владельца в каждом сервисе. Изменения адреса и телефона обычно проходят модерацию, и сроки у разных сервисов разные.
- Если доступа к кабинету нет, это отдельная задача: сначала восстановить или получить доступ, потом править данные.
- Через неделю скрипт должен показать ноль по этой точке. Если правка отклонена модерацией, причину стоит записать рядом с эталоном.
Совет. Ведите в репозитории файл
CHANGELOG-NAP.md: дата, точка, что поменяли и где. Через полгода это лучший ответ на вопрос «почему в 2ГИС другой номер».
Чек-лист NAP-консистентности
- [ ] Есть один эталонный файл с данными каждой точки.
- [ ] Страница «Контакты» и JSON-LD собираются из эталона.
- [ ] Телефоны сравниваются в формате E.164.
- [ ] Адреса нормализуются, порядок слов не учитывается.
- [ ] Часы сравниваются по дням недели, а не строкой.
- [ ] Праздничные часы заведены заранее и есть в разметке через
validFromиvalidThrough. - [ ] Данные карточек берутся из официальных API, выгрузок или снимков, без парсинга страниц.
- [ ] Проверка запускается по расписанию и падает при расхождениях.
- [ ] Для каждого расхождения понятно, кто и где его исправляет.
Итог
NAP-консистентность звучит как термин из чек-листа SEO-агентства, но по сути это обычная инженерная задача: один источник правды, нормализация, автоматическая сверка и алерт. Сотня строк на Python закрывает её для одной точки и для сети из десятков филиалов.
Если расхождение нашлось в карточке на Яндекс Картах, пошаговая инструкция по правке адреса, телефона, часов и названия, со сроками модерации, есть у нас в блоге: как изменить адрес и данные организации на Яндекс Картах.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.



