Как внедрить микроразметку Product для каталога с торговыми предложениями на 1С-Битрикс: кейс и примеры кода

от автора

Практический разбор как перевести карточку товара с размерами и цветами с одиночного Product на ProductGroup + hasVariant + AggregateOffer, проставить availability из остатков и пройти Rich Results Test без критичных ошибок.

1. Зачем это всё и почему на Битриксе непросто

Берёшь поля товара, собираешь Product в JSON-LD, вставляешь в <head>, проверяешь в валидаторе. На лендинге с одним товаром так и есть, задача максимально тривиальная. А вот на интернет-магазине под управлением 1С-Битрикс, где у товара есть торговые предлажения с разными размерами и цветами, эта простая на вид задача начинает сопротивляться.

То, что в карточке выглядит как один товар, внутри Битрикса живёт как несколько сущностей. Есть элемент инфоблока, то есть сам товар, и есть привязанные к нему торговые предложения, то есть SKU. Размеры это отдельные офферы. Цвет может жить на уровне товара. И если отдать поисковику один Product с одной ценой, вы разом теряете все варианты, плодите дубли (когда в name зашит размер вроде «… р.46») и ничего не сообщаете о наличии по каждому размеру. Если вы оставите только одиночный Product для каталога с торговыми предложениями вы упустите возможность сообщить поисковику о том, что у товара есть различные виды.

На живом проекте карточку часто рендерит не стандартный bitrix:catalog.element, а кастомный компонент со своим названием, и JSON-LD собирается внутри его шаблона. Сверху кеширование. Правки «не появляются» на странице, и очень легко уйти в ложную диагностику, обвиняя кеш, хотя дело совсем в другом.

Символьные коды свойств, уровень свойства (товар или оффер), справочники, которые отдают ID вместо человекочитаемого значения, остатки, которые надо правильно превратить в availability. Любая ошибка тут, и разметка либо не собирается вовсе, либо собирается с мусором внутри.

Дальше мы посмотрим на внедрение групп товаров по порядку. Материал оформлен как to-do, по которому можно идти сверху вниз. В конце таблица маппинга «поле схемы ← источник в Битриксе».

Контекст кейса

  • Стек: 1С-Битрикс, на витрине Vue 3 (BitrixVue).

  • Каталог рендерит кастомный комплексный компонент, а не стандартный bitrix:catalog. Детальная карточка и её JSON-LD формируются внутри шаблона этого компонента.

  • Модель товара: товар это элемент инфоблока, размеры это торговые предложения (SKU) с привязкой через CML2_LINK, цвет это отдельное свойство на уровне товара, размер это свойство на уровне торгового предложения.

  • Точка старта: разметка на карточках уже была, но это был одиночный Product без вариантов. Примерно у половины офферов отсутствовал availability, в name сидел размер («… р.46»), картинка отдавалась относительным URL, описание тянулось из PREVIEW_TEXT.

  • Цель: перевести одиночный Product в ProductGroup + hasVariant по размерам + AggregateOffer, проставить availability из остатков, добавить sku, size, color, image и пройти Rich Results Test без критичных ошибок.

2. Модель данных Битрикса: где лежат размер, цвет, артикул и остаток

Прежде чем проектировать схему, надо разложить, какая сущность битрикса какому уровню разметки соответствует. В кейсе раскладка получилась следующая:

Уровень товара (элемент инфоблока):

  • NAME это название товара. Важный момент, в нём может сидеть «хвост» размера, который придётся срезать.

  • DETAIL_TEXT или PREVIEW_TEXT это описание, его нужно чистить от HTML.

  • MORE_PHOTO или галерея это изображения товара.

  • Свойство цвета это справочник на уровне товара (в кейсе цвет внутри карточки постоянный).

  • Артикул товара (ARTICLE / CATALOG_ARTICLE) встречается и на уровне товара, и на уровне оффера, так что нужно проверять, где реальное значение.

Уровень торгового предложения (оффер, SKU):

  • Свойство размера это справочник на уровне оффера (в кейсе SIZE_ID).

  • Артикул оффера это строковое свойство (ARTICLE / CATALOG_ARTICLE).

  • Цена это оптимальная цена оффера (ITEM_PRICES[ITEM_PRICE_SELECTED]; RATIO_PRICE/PRICE).

  • Остаток и доступность это CAN_BUY, CATALOG_AVAILABLE, CATALOG_QUANTITY.

  • URL варианта это detailPageUrl оффера, часто вида ?oid=....

Три нюанса, которые ломают наивную реализацию. И заметьте, ни один из них не встречается в типовых инструкциях.

  1. Уровень свойства решает всё. Размер на оффере, цвет на товаре. Перепутаете, и variesBy с распределением полей между группой и вариантами поедет.

  2. Справочники отдают ID, а не ярлык. У свойств-справочников сырой VALUE это ID записи справочника. Человекочитаемое значение лежит в DISPLAY_PROPERTIES, в DISPLAY_VALUE. В size и color должно попадать именно отображаемое значение, иначе в разметку улетит число.

  3. Кастомный компонент уже пересобрал данные. В нашем случае компонент заранее подготовил удобные структуры: gallery[], sizes[] (с полями id, name, isAvailable, isPreorder, detailPageUrl), color{name, image}, catalogArticle (только текущего выбранного оффера), priceValue, isAvailable/isPreorder, meta.pageTitle. Это и плюс (данные под рукой), и ловушка (артикул доступен только для выбранного оффера).

3. Целевая схема: почему ProductGroup, а не одиночный Product

Когда у товара есть варианты, которые отличаются по одной или нескольким осям (размер, цвет), правильная модель в Schema.org это ProductGroup. Внутри неё:

  • hasVariant[] это массив Product, по одному на каждый реально покупаемый вариант (у нас на каждый размер);

  • variesBy это перечень осей, по которым варианты отличаются (только размер, потому что цвет внутри карточки постоянный и живёт на уровне группы);

  • offers как AggregateOffer это агрегированное предложение с lowPrice, highPrice, offerCount и общим availability.

Почему не одиночный Product, если совсем коротко. Он отдаёт ровно один оффер по выбранной цене и игнорирует остальные размеры, так что поисковик не видит ассортимент. Если зашить размер в name, появляются дубли карточек, которые конкурируют между собой. А AggregateOffer честно показывает диапазон цен и тот факт, что хотя бы один вариант есть в наличии.

Собирать эту структуру руками муторно, легко забыть image у варианта или перепутать variesBy. Чтобы не держать всё в голове, я завёл под этот сценарий режим в своём генераторе разметки Schema.org. Вводишь варианты по размерам, он сам раскидывает картинки в каждый hasVariant, считает lowPrice/highPrice для AggregateOffer и проставляет availability. Дальше по тексту разберём ту же логику вручную, чтобы было понятно, что именно генератор делает.

Обязательные и рекомендованные поля

На уровне ProductGroup:

  • Обязательно по смыслу модели: name (без размера), image[], offers (AggregateOffer), hasVariant[], productGroupID, variesBy.

  • Рекомендуется: description, brand, color (если он постоянен в карточке).

На уровне каждого hasVariant (Product):

  • Обязательно: name, image[], offers. И это нужно на КАЖДОМ варианте, а не только на группе. Самая частая критичная ошибка Rich Results Test это как раз отсутствие image у вариантов.

  • Рекомендуется: description, sku, size, color, brand.

Внутри offers варианта (Offer):

  • priceCurrency, price, availability, itemCondition, url.

Логика availability простая. InStock, если CATALOG_AVAILABLE = 'Y', либо CATALOG_QUANTITY > 0, либо CAN_BUY = true. PreOrder, если предзаказ. Иначе OutOfStock. На практике CAN_BUY часто уже учитывает остаток (в result_modifier его принудительно гасят при quantity = 0), так что это удобная отправная точка.

4. Пошаговый план внедрения (to-do)

Идите строго сверху вниз. Каждая фаза закрывает свой источник ошибок.

Фаза 0. Аудит исходного состояния

  1. Выгрузите список карточек и офферов, проверьте наличие разметки и обязательных полей Product/Offer: name, image, offers, price, priceCurrency, availability.

  2. Посчитайте долю канонических URL против дублей: query-параметры, легаси-слаги, два слага категории, .html против трейлинг-слеша.

  3. Зафиксируйте базовую метрику: сколько офферов «Подходит» и «Не подходит» в проверке ассортимента и почему. Это ваша отправная точка, без неё отследить правки по каталогу будет проблематично.

Фаза 1. Найти реальный компонент-генератор разметки

  1. Не предполагайте, что разметку пишет стандартный bitrix:catalog.element. Определите, какой компонент реально рендерит карточку: Режим правки → параметры компонента, плюс загляните в index.php раздела каталога.

  2. Если не получается найти в режиме правки попробуйте через grep по строке application/ld+json внутри /local/.

  3. Опознайте нужный файл по «сигнатуре» вывода: экранирование слешей, относительный или абсолютный URL картинки, PREVIEW_TEXT против DETAIL_TEXT, @context со слешем или без, порядок ключей.

  4. Подтвердите, что файл реально исполняется: временно вставьте маркер-комментарий и найдите его в исходнике страницы.

Фаза 2. Прочитать реальные данные

  1. Прочитайте символьные коды свойств в инфоблоках товара и торговых предложений: артикул, размер, цвет, картинки. Не угадывайте дефолтные коды.

  2. Определите, какое свойство на уровне товара, а какое на уровне оффера (у нас размер на оффере, цвет на товаре).

  3. Для свойств-справочников берите отображаемое значение (DISPLAY_VALUE из DISPLAY_PROPERTIES), а не сырой VALUE, то есть не ID записи справочника.

Фаза 3. Спроектировать схему

  1. Товар с торговыми предложениями превращается в ProductGroup + hasVariant[] + AggregateOffer.

  2. variesBy это только реально варьируемые оси внутри карточки (размер). Постоянные оси (цвет) идут на уровень группы, а не в variesBy.

  3. availability в каждом Offer из остатка (CAN_BUY / CATALOG_AVAILABLE / CATALOG_QUANTITY): InStock / OutOfStock / PreOrder.

  4. В КАЖДЫЙ вариант обязательно: name, image[], offers. Рекомендуется: description, sku, size, color.

  5. Имя группы без хвоста размера, «р.46» срезаем регуляркой.

  6. AggregateOffer: lowPrice, highPrice, offerCount плюс availability (InStock, если хоть один вариант в наличии).

Фаза 4. Реализовать

  1. Перенесите логику в найденный файл-генератор и используйте переменные именно этого компонента. В кастомном компоненте данные уже пересобраны в удобные структуры.

  2. Собирайте JSON нативным энкодером Bitrix\Main\Web\Json::encode с флагом JSON_UNESCAPED_UNICODE.

  3. Вывод оберните в существующий механизм (у нас это $this->SetViewTarget(...) и EndViewTarget()).

Фаза 5. Проверить

  1. Сбросьте кеш компонента. Эвристика на будущее: если сброс кеша ничего не меняет, а вывод не похож на ваш файл, значит вы правите не тот файл.

  2. Rich Results Test плюс validator.schema.org. Цель это 0 критичных ошибок, жёлтые «необязательно» допустимы.

  3. Повторно прогоните выгрузку и замерьте результаты.

Фаза 6. Полировка (опционально)

  1. sku по всем размерам сразу: добавьте артикул в слой данных компонента (в массив offers/sizes), потому что в шаблоне доступен артикул только текущего выбранного оффера.

  2. Доставку и возврат задавайте один раз на уровне Organization, а не в каждом Offer. Это рекомендация Google.

  3. URL-канонизация: 301 со старых и дублирующих форм, rel=canonical для ?oid, ?nav, ?ADD_TO_FAVORITE и подобного.

5. Маппинг «поле схемы ← источник в Битриксе»

Уровень группы (ProductGroup)

Поле схемы

Источник в Битриксе

productGroupID

ID элемента-товара (или XML_ID)

name

NAME товара без размера

description

DETAIL_TEXT (или PREVIEW_TEXT), очищенный от HTML

image

MORE_PHOTO / галерея, абсолютные или относительные URL

brand

константа = название бренда (консистентно с Organization)

color

свойство-справочник уровня товара (DISPLAY_VALUE)

variesBy

[‘https://schema.org/size‘], только размер

Уровень варианта (на каждый оффер)

Поле схемы

Источник в Битриксе

sku

свойство-строка оффера «Артикул» (ARTICLE / CATALOG_ARTICLE)

size

свойство-справочник оффера «Размер» (DISPLAY_VALUE)

image

картинки товара (обязательно для варианта)

offers.price

оптимальная цена оффера (ITEM_PRICES[ITEM_PRICE_SELECTED] → RATIO_PRICE/PRICE)

offers.priceCurrency

RUB

offers.availability

из CAN_BUY / CATALOG_AVAILABLE: InStock / OutOfStock / PreOrder

offers.itemCondition

https://schema.org/NewCondition

offers.url

detailPageUrl оффера (вариант ?oid=…)

Логика availability: InStock, если CATALOG_AVAILABLE = ‘Y’ или CATALOG_QUANTITY > 0 или CAN_BUY = true. PreOrder, если предзаказ. Иначе OutOfStock. CAN_BUY часто уже учитывает остаток (в result_modifier его принудительно гасят при quantity = 0).

Про кастомный компонент: в нашем кейсе данные были заранее собраны в структуры gallery[], sizes[] (id, name, isAvailable, isPreorder, detailPageUrl), color{name, image}, catalogArticle (только текущего оффера), priceValue, isAvailable/isPreorder, meta.pageTitle. Вывод JSON-LD шёл через SetViewTarget(‘catalog_detail_schema’).

7. Итоговый код

7.1. Целевой JSON-LD (ProductGroup)

Товар с тремя размерами, цвет постоянный, две картинки.

{  "@context": "https://schema.org",  "@type": "ProductGroup",  "productGroupID": "12345",  "name": "Куртка утеплённая (пример)",  "description": "Обезличенное описание товара без HTML-тегов.",  "image": [    "https://example.test/upload/iblock/aaa/img-1.jpg",    "https://example.test/upload/iblock/bbb/img-2.jpg"  ],  "brand": { "@type": "Brand", "name": "Бренд" },  "color": "серый",  "variesBy": ["https://schema.org/size"],  "offers": {    "@type": "AggregateOffer",    "priceCurrency": "RUB",    "lowPrice": "1990",    "highPrice": "2490",    "offerCount": 3,    "availability": "https://schema.org/InStock"  },  "hasVariant": [    {      "@type": "Product",      "name": "Куртка утеплённая (пример), размер 46",      "size": "46",      "color": "серый",      "sku": "ART-0001",      "image": [        "https://example.test/upload/iblock/aaa/img-1.jpg",        "https://example.test/upload/iblock/bbb/img-2.jpg"      ],      "description": "Обезличенное описание товара без HTML-тегов.",      "brand": { "@type": "Brand", "name": "Бренд" },      "offers": {        "@type": "Offer",        "priceCurrency": "RUB",        "price": "1990",        "availability": "https://schema.org/InStock",        "itemCondition": "https://schema.org/NewCondition",        "url": "https://example.test/catalog/item/?oid=1001"      }    },    {      "@type": "Product",      "name": "Куртка утеплённая (пример), размер 48",      "size": "48",      "color": "серый",      "image": [        "https://example.test/upload/iblock/aaa/img-1.jpg",        "https://example.test/upload/iblock/bbb/img-2.jpg"      ],      "description": "Обезличенное описание товара без HTML-тегов.",      "brand": { "@type": "Brand", "name": "Бренд" },      "offers": {        "@type": "Offer",        "priceCurrency": "RUB",        "price": "2290",        "availability": "https://schema.org/InStock",        "itemCondition": "https://schema.org/NewCondition",        "url": "https://example.test/catalog/item/?oid=1002"      }    },    {      "@type": "Product",      "name": "Куртка утеплённая (пример), размер 50",      "size": "50",      "color": "серый",      "image": [        "https://example.test/upload/iblock/aaa/img-1.jpg",        "https://example.test/upload/iblock/bbb/img-2.jpg"      ],      "description": "Обезличенное описание товара без HTML-тегов.",      "brand": { "@type": "Brand", "name": "Бренд" },      "offers": {        "@type": "Offer",        "priceCurrency": "RUB",        "price": "2490",        "availability": "https://schema.org/OutOfStock",        "itemCondition": "https://schema.org/NewCondition",        "url": "https://example.test/catalog/item/?oid=1003"      }    }  ]}

Обратите внимание на пару деталей. У размера 46 есть sku, это текущий выбранный оффер, артикул доступен. У 48 и 50 sku намеренно опущен, чтобы не подставлять чужой идентификатор (грабля №8). AggregateOffer.availability равен InStock, потому что хотя бы один вариант в наличии. И везде наличие это https://schema.org/InStock со слешем, а не та битая строка без слеша, что гуляет по выдаче.

7.2. Фрагмент PHP-шаблона компонента (обобщённо)

Сборка идёт через Bitrix\Main\Web\Json::encode с JSON_UNESCAPED_UNICODE, вывод через SetViewTarget/EndViewTarget. Имена переменных это пересобранный слой данных кастомного компонента ($product, $sizes, $gallery и так далее), а не сырой $arResult стандартного компонента.

<?phpuse Bitrix\Main\Web\Json;// $product — пересформированные данные карточки в кастомном компоненте.// Структуры: $product['gallery'] (массив абсолютных URL),// $product['sizes'] (id, name, isAvailable, isPreorder, detailPageUrl, sku?),// $product['color'] (name, image), $product['priceValue'], $product['meta'].$brandName = 'Бренд'; // единое значение, согласованное с Organization// 1. Имя группы без хвоста размера ("… р.46" срезаем).$groupName = trim(preg_replace('/,?\s*р\.?\s*\d+.*$/iu', '', $product['name']));// 2. Описание без HTML.$description = trim(strip_tags($product['detailText'] ?? $product['previewText'] ?? ''));// 3. Галерея (image обязателен и на группе, и на каждом варианте).$images = array_values(array_filter($product['gallery'] ?? []));// 4. Маппер доступности из остатка/флагов оффера.$mapAvailability = static function (array $size): string {    if (!empty($size['isPreorder'])) {        return 'https://schema.org/PreOrder';    }    return !empty($size['isAvailable'])        ? 'https://schema.org/InStock'        : 'https://schema.org/OutOfStock';};// 5. Сборка вариантов hasVariant[] и сбор цен для AggregateOffer.$variants = [];$prices = [];$anyInStock = false;foreach ($product['sizes'] as $size) {    $availability = $mapAvailability($size);    if ($availability === 'https://schema.org/InStock') {        $anyInStock = true;    }    $price = (string)($size['price'] ?? $product['priceValue']);    $prices[] = (float)$price;    $variant = [        '@type'       => 'Product',        'name'        => $groupName . ', размер ' . $size['name'],        'size'        => (string)$size['name'], // DISPLAY_VALUE справочника, не ID        'color'       => $product['color']['name'] ?? null,        'image'       => $images, // image на КАЖДОМ варианте — обязательно        'description' => $description,        'brand'       => ['@type' => 'Brand', 'name' => $brandName],        'offers'      => [            '@type'         => 'Offer',            'priceCurrency' => 'RUB',            'price'         => $price,            'availability'  => $availability,            'itemCondition' => 'https://schema.org/NewCondition',            'url'           => $size['detailPageUrl'] ?? $product['url'],        ],    ];    // sku ставим ТОЛЬКО если есть реальный артикул именно этого размера.    if (!empty($size['sku'])) {        $variant['sku'] = $size['sku'];    }    // Уберём null-поля (например, color, если его нет).    $variants[] = array_filter($variant, static fn ($v) => $v !== null);}// 6. AggregateOffer.$aggregate = [    '@type'         => 'AggregateOffer',    'priceCurrency' => 'RUB',    'lowPrice'      => (string)(int)min($prices),    'highPrice'     => (string)(int)max($prices),    'offerCount'    => count($variants),    'availability'  => $anyInStock        ? 'https://schema.org/InStock'        : 'https://schema.org/OutOfStock',];// 7. Корневой ProductGroup.$schema = [    '@context'       => 'https://schema.org',    '@type'          => 'ProductGroup',    'productGroupID' => (string)$product['id'],    'name'           => $groupName,    'description'    => $description,    'image'          => $images,    'brand'          => ['@type' => 'Brand', 'name' => $brandName],    'color'          => $product['color']['name'] ?? null,    'variesBy'       => ['https://schema.org/size'],    'offers'         => $aggregate,    'hasVariant'     => $variants,];$schema = array_filter($schema, static fn ($v) => $v !== null);// 8. Вывод в нужную зону страницы через существующий механизм.$this->SetViewTarget('catalog_detail_schema');?><script type="application/ld+json"><?= Json::encode($schema, JSON_UNESCAPED_UNICODE) ?></script><?php$this->EndViewTarget();

Замечание про энкодер. Bitrix\Main\Web\Json::encode по умолчанию ставит JSON_UNESCAPED_SLASHES, а флаг JSON_UNESCAPED_UNICODE нужен, чтобы кириллица не превращалась в \uXXXX. Бывает, что JSON собирают так, что русские буквы уезжают в экранированный вид. Регулярка среза размера тут синтетическая, на реальном проекте подгоните её под фактический формат хвоста в NAME.

8. Проверка и мониторинг

  1. Сброс кеша компонента. Если сброс ничего не меняет, а вывод не похож на ваш файл, вы правите не тот файл.

  2. Rich Results Test и validator.schema.org. Цель это 0 критичных ошибок. Жёлтые «необязательно», вроде отсутствия aggregateRating или shippingDetails, допустимы.

  3. Контроль обязательных полей на вариантах. Особое внимание на image, offers, name в каждом hasVariant.

  4. Search Console. После релиза следите за отчётом по товарным результатам: динамика валидных элементов, новые ошибки, покрытие.

  5. Канонизация как сосед задачи. Параллельно проверяйте, что краулер ходит по каноническим URL, иначе чистая разметка теряет смысл.

ссылка на оригинал статьи https://habr.com/ru/articles/1054116/