На странице услуги пользователь видел цену «от 25 000 ₽», а meta description и JSON‑LD продолжали отдавать 50000. Валидатор был зелёным.
И это был собственный сайт веб‑студии!
Разберёмся, как валидная микроразметка начинает публиковать неверные данные, почему замена одного числа не решает проблему и что на самом деле нужно проверять после изменений сайта.
Во время аудита одного из сайтов я заметил странное расхождение. На странице услуги в видимой карточке было написано: от 25 000 ₽ В теге description и JSON‑LD при этом оставалось старое значение: от 50 000 ₽ Одна страница, одна услуга, две цены.
И вот что особенно хорошо характеризует эту ошибку: это был собственный сайт веб‑студии. Компании, которая сама разрабатывает сайты и должна понимать, что карточка, description и JSON‑LD не могут жить как три независимых источника данных.
Сайт при этом работал. Страница открывалась, вёрстка не ехала, JSON‑LD разбирался. Валидатор не ругался, потому что число 50000 было записано совершенно правильно. Просто это было не то число.
Видимая карточка: от 25000 руб.Meta description: разработка сайта от 50000 руб.JSON-LD offers.price: 50000
Откуда взялись две цены
Сайт работал на Yii. Код ниже обезличен, но логика ошибки сохранена.
Карточка брала цену из модели:
<?php/** @var app\models\Service $service */$visiblePrice = $service->price_from;?><div class="service-card__price"> от <?= number_format($visiblePrice, 0, ',', ' ') ?> руб.</div>
Meta description жил отдельно:
<?php$this->registerMetaTag([ 'name' => 'description', 'content' => 'Разработка сайта от 50 000 рублей',]);
Рядом собирался JSON‑LD:
<?php$schema = [ '@context' => 'https://schema.org', '@type' => 'Service', 'name' => $service->title, 'offers' => [ '@type' => 'Offer', 'price' => 50000, 'priceCurrency' => 'RUB', ],];
Ничего экзотического. Одно значение брали из модели, два других когда‑то написали руками. Пока цена не менялась, все три версии совпадали. Потом карточку обновили, а две строки в шаблоне забыли.
Можно заменить 50000 на 25000. Через несколько месяцев повторится то же самое. Поэтому исправлять надо не число, а способ его получения.
<?phpuse yii\helpers\Html;use yii\helpers\Json;/** @var app\models\Service $service */$rawPrice = $service->price_from;$priceFrom = $rawPrice !== null && (int) $rawPrice > 0 ? (int) $rawPrice : null;$description = $service->title;$schema = [ '@context' => 'https://schema.org', '@type' => 'Service', 'name' => $service->title,];if ($priceFrom !== null) { $formattedPrice = number_format($priceFrom, 0, ',', ' '); $description .= " от {$formattedPrice} рублей"; $schema['offers'] = [ '@type' => 'Offer', 'price' => $priceFrom, 'priceCurrency' => 'RUB', ];}$this->registerMetaTag([ 'name' => 'description', 'content' => $description,]);echo Html::tag('script', Json::htmlEncode($schema), [ 'type' => 'application/ld+json',]);
Теперь цена в карточке, meta description и JSON‑LD берётся из одного поля. Если публичной цены нет, код не превращает пустое значение в ноль и не формирует offers.
А если цены нет вообще
Для услуг это обычный случай: стоимость появляется после брифа или расчёта. Тут часто ставят 0, потому что поле хочется заполнить.
'offers' => [ '@type' => 'Offer', 'price' => 0, 'priceCurrency' => 'RUB',]
Но 0 не означает «цена неизвестна». Для машины это конкретная цена — ноль рублей. В этом проекте правило простое: нет публичной цены — нет offers.
В другом проекте решение может отличаться. Выдумывать сумму ради заполненного свойства всё равно не надо.
Почему валидатор был зелёным
Потому что с JSON всё было нормально. Тип Service существовал, Offer был записан корректно, цена была числом, валюта — RUB. Валидатор не мог знать, что в карточке уже показывается другая сумма.
Это вообще разные проверки. Сначала надо убедиться, что данные можно разобрать. Потом — что они подходят под требования конкретного поисковика. И отдельно проверить, что они совпадают с самой страницей.
Последняя часть обычно и выпадает. Инструмент видит 50000, но не знает, что в интерфейсе уже стоит 25000.
В правилах Google для структурированных данных отдельно сказано, что разметка должна соответствовать содержимому страницы. Зелёная галочка этого соответствия не доказывает.
Небольшая оговорка про Service
Service подходит для описания услуги, его можно связать с Offer, указать цену и валюту. Но валидная разметка не означает, что Google обязан показать расширенный результат. У поисковика есть отдельный список поддерживаемых типов, и отдельного результата для Service там нет.
Это не делает тип бесполезным. Просто Schema.org описывает сущность, а поисковик сам решает, как использовать эти данные.
Как я проверяю страницы
Я никогда не начинаю с валидатора. Сначала открываю страницу как пользователь и смотрю, какие факты она сообщает: цену, наличие, рейтинг, адрес, автора, дату изменения. Потом ищу те же значения в meta description, Open Graph, JSON‑LD, фидах и других выгрузках.
Дальше вопрос простой: откуда взялось каждое значение? Если карточка читает service.price_from, а JSON‑LD — строку в шаблоне, ошибка уже найдена, даже когда сегодня цифры случайно совпадают.
Валидаторы я запускаю позже. Они помогают увидеть ошибки структуры и требования конкретного потребителя. Причину старой цены они не найдут.
Один тест на всякий случай
Для критичных шаблонов полезно проверить не наличие JSON‑LD, а совпадение значений. Вспомогательные методы здесь условные, чтобы не тащить в статью весь тестовый код.
public function testServicePriceMarkup(): void{ $service = ServiceFactory::create([ 'title' => 'Разработка сайта', 'price_from' => 25000, ]); $html = $this->renderServicePage($service); $this->assertStringContainsString('от 25 000 руб.', $html); $this->assertStringContainsString( 'Разработка сайта от 25 000 рублей', $this->extractMetaDescription($html) ); $schema = $this->extractJsonLdByType($html, 'Service'); $this->assertSame(25000, $schema['offers']['price']); $this->assertSame('RUB', $schema['offers']['priceCurrency']);}
Для страницы без цены нужен второй сценарий: в карточке не появляется «от 0 ₽», в meta description нет выдуманной суммы, а в JSON‑LD отсутствует offers.
Как поставить задачу разработчику
«Добавить Schema.org на страницы услуг» — плохая задача. После неё разработчик начинает сам решать, откуда брать цену, что делать без цены и какие поля считать основными.
Нормальная постановка выглядит примерно так:
На страницах услуг вывести JSON‑LD типа
Service. Цену брать изservice.price_from, как и в карточке. Видимая цена, meta description иoffers.priceдолжны использовать одно значение. Если публичной цены нет, не выводитьoffersи не писать сумму вручную в description. Проверить услугу с ценой и без неё.
Такую реализацию можно отдать джуниору под ревью. Но правило, какое поле считается источником цены и что делать в пограничных случаях, должен определить не он.
Где ещё это всплывает
С ценами случай самый наглядный, но далеко не единственный. После удаления блока отзывов в JSON‑LD может остаться AggregateRating. Филиал переехал, адрес на странице поправили, а старая микроразметка продолжает жить в региональном шаблоне. У товара поменялась логика наличия, но Offer по‑прежнему отдаёт InStock.
Обычно это происходит не в день внедрения. В этот день всё проверили и сдали. Расхождения появляются потом, когда одну часть страницы меняют, а про остальные представления никто не вспоминает.
Сейчас к поисковикам и агрегаторам добавились системы генерации ответов. Суть от этого не поменялась: машинным потребителям тоже не стоит отдавать старые данные.
Вместо вывода
Микроразметка сама по себе не врёт. Врут данные, которым разрешили жить отдельно друг от друга. Интерфейс может выглядеть нормально, JSON‑LD может быть валидным, а страница всё равно будет сообщать разным читателям разные вещи.
Не чините строку. Чините правило, которое позволило строке устареть.
А у вас JSON‑LD собирается из тех же данных, что и видимая страница, или это отдельный шаблон, который проверяли только при внедрении?
ссылка на оригинал статьи https://habr.com/ru/articles/1049472/