У локального бизнеса данные о точке живут минимум в четырёх местах: на сайте, в карточке на Яндекс Картах, в 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", Source:https://dev.to/mapli/nap-konsistientnost-kak-skriptom-provierit-chto-adries-tieliefon-i-chasy-odinakovy-na-saitie-i-v-kartakh-4m11Related posts

The fight for tech accountability starts now




