Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

NAP-консистентность: как скриптом проверить, что адрес, телефон и часы одинаковы на сайте и в картах

У локального бизнеса данные о точке живут минимум в четырёх местах: на сайте, в карточке на Яндекс Картах, в 2ГИС и в профиле Google. Пока точка одна и ничего не меняется, всё совпадает. Потом кофейня переезжает в соседн

NAP-консистентность: как скриптом проверить, что адрес, телефон и часы одинаковы на сайте и в картах

У локального бизнеса данные о точке живут минимум в четырёх местах: на сайте, в карточке на Яндекс Картах, в 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

Ниже полный скрипт. Он делает четыре вещи:

  1. Читает эталон и снимки карточек из nap.yaml.
  2. Скачивает страницу контактов сайта и достаёт из неё блок JSON-LD с организацией.
  3. Нормализует все источники.
  4. Печатает расхождения и завершает работу с кодом 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.

Что делать с найденными расхождениями

Скрипт только показывает проблему. Исправлять её нужно в том источнике, который разошёлся с эталоном.

Короткий порядок действий:

  1. Сначала проверьте эталон. Иногда «расхождение» означает, что правильные данные как раз в карточке, а эталон устарел.
  2. Сайт правится сразу — это ваш код и ваш деплой.
  3. Карточки правятся через кабинет владельца в каждом сервисе. Изменения адреса и телефона обычно проходят модерацию, и сроки у разных сервисов разные.
  4. Если доступа к кабинету нет, это отдельная задача: сначала восстановить или получить доступ, потом править данные.
  5. Через неделю скрипт должен показать ноль по этой точке. Если правка отклонена модерацией, причину стоит записать рядом с эталоном.

Совет. Ведите в репозитории файл CHANGELOG-NAP.md: дата, точка, что поменяли и где. Через полгода это лучший ответ на вопрос «почему в 2ГИС другой номер».

Чек-лист NAP-консистентности

  • [ ] Есть один эталонный файл с данными каждой точки.
  • [ ] Страница «Контакты» и JSON-LD собираются из эталона.
  • [ ] Телефоны сравниваются в формате E.164.
  • [ ] Адреса нормализуются, порядок слов не учитывается.
  • [ ] Часы сравниваются по дням недели, а не строкой.
  • [ ] Праздничные часы заведены заранее и есть в разметке через validFrom и validThrough.
  • [ ] Данные карточек берутся из официальных API, выгрузок или снимков, без парсинга страниц.
  • [ ] Проверка запускается по расписанию и падает при расхождениях.
  • [ ] Для каждого расхождения понятно, кто и где его исправляет.

Итог

NAP-консистентность звучит как термин из чек-листа SEO-агентства, но по сути это обычная инженерная задача: один источник правды, нормализация, автоматическая сверка и алерт. Сотня строк на Python закрывает её для одной точки и для сети из десятков филиалов.

Если расхождение нашлось в карточке на Яндекс Картах, пошаговая инструкция по правке адреса, телефона, часов и названия, со сроками модерации, есть у нас в блоге: как изменить адрес и данные организации на Яндекс Картах.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.