Практический разбор как перевести карточку товара с размерами и цветами с одиночного
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=....
Три нюанса, которые ломают наивную реализацию. И заметьте, ни один из них не встречается в типовых инструкциях.
Уровень свойства решает всё. Размер на оффере, цвет на товаре. Перепутаете, и
variesByс распределением полей между группой и вариантами поедет.Справочники отдают ID, а не ярлык. У свойств-справочников сырой
VALUEэто ID записи справочника. Человекочитаемое значение лежит вDISPLAY_PROPERTIES, вDISPLAY_VALUE. Вsizeиcolorдолжно попадать именно отображаемое значение, иначе в разметку улетит число.Кастомный компонент уже пересобрал данные. В нашем случае компонент заранее подготовил удобные структуры:
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. Аудит исходного состояния
Выгрузите список карточек и офферов, проверьте наличие разметки и обязательных полей
Product/Offer:name,image,offers,price,priceCurrency,availability.Посчитайте долю канонических URL против дублей: query-параметры, легаси-слаги, два слага категории,
.htmlпротив трейлинг-слеша.Зафиксируйте базовую метрику: сколько офферов «Подходит» и «Не подходит» в проверке ассортимента и почему. Это ваша отправная точка, без неё отследить правки по каталогу будет проблематично.
Фаза 1. Найти реальный компонент-генератор разметки
Не предполагайте, что разметку пишет стандартный
bitrix:catalog.element. Определите, какой компонент реально рендерит карточку: Режим правки → параметры компонента, плюс загляните вindex.phpраздела каталога.Если не получается найти в режиме правки попробуйте через
grepпо строкеapplication/ld+jsonвнутри/local/.Опознайте нужный файл по «сигнатуре» вывода: экранирование слешей, относительный или абсолютный URL картинки,
PREVIEW_TEXTпротивDETAIL_TEXT,@contextсо слешем или без, порядок ключей.Подтвердите, что файл реально исполняется: временно вставьте маркер-комментарий и найдите его в исходнике страницы.
Фаза 2. Прочитать реальные данные
Прочитайте символьные коды свойств в инфоблоках товара и торговых предложений: артикул, размер, цвет, картинки. Не угадывайте дефолтные коды.
Определите, какое свойство на уровне товара, а какое на уровне оффера (у нас размер на оффере, цвет на товаре).
Для свойств-справочников берите отображаемое значение (
DISPLAY_VALUEизDISPLAY_PROPERTIES), а не сыройVALUE, то есть не ID записи справочника.
Фаза 3. Спроектировать схему
Товар с торговыми предложениями превращается в
ProductGroup+hasVariant[]+AggregateOffer.variesByэто только реально варьируемые оси внутри карточки (размер). Постоянные оси (цвет) идут на уровень группы, а не вvariesBy.availabilityв каждомOfferиз остатка (CAN_BUY/CATALOG_AVAILABLE/CATALOG_QUANTITY):InStock/OutOfStock/PreOrder.В КАЖДЫЙ вариант обязательно:
name,image[],offers. Рекомендуется:description,sku,size,color.Имя группы без хвоста размера, «р.46» срезаем регуляркой.
AggregateOffer:lowPrice,highPrice,offerCountплюсavailability(InStock, если хоть один вариант в наличии).
Фаза 4. Реализовать
Перенесите логику в найденный файл-генератор и используйте переменные именно этого компонента. В кастомном компоненте данные уже пересобраны в удобные структуры.
Собирайте JSON нативным энкодером
Bitrix\Main\Web\Json::encodeс флагомJSON_UNESCAPED_UNICODE.Вывод оберните в существующий механизм (у нас это
$this->SetViewTarget(...)иEndViewTarget()).
Фаза 5. Проверить
Сбросьте кеш компонента. Эвристика на будущее: если сброс кеша ничего не меняет, а вывод не похож на ваш файл, значит вы правите не тот файл.
Rich Results Test плюс validator.schema.org. Цель это 0 критичных ошибок, жёлтые «необязательно» допустимы.
Повторно прогоните выгрузку и замерьте результаты.
Фаза 6. Полировка (опционально)
skuпо всем размерам сразу: добавьте артикул в слой данных компонента (в массивoffers/sizes), потому что в шаблоне доступен артикул только текущего выбранного оффера.Доставку и возврат задавайте один раз на уровне
Organization, а не в каждомOffer. Это рекомендация Google.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 |
|
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 стандартного компонента.
<?php use 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. Проверка и мониторинг
Сброс кеша компонента. Если сброс ничего не меняет, а вывод не похож на ваш файл, вы правите не тот файл.
Rich Results Test и validator.schema.org. Цель это 0 критичных ошибок. Жёлтые «необязательно», вроде отсутствия
aggregateRatingилиshippingDetails, допустимы.Контроль обязательных полей на вариантах. Особое внимание на
image,offers,nameв каждомhasVariant.Search Console. После релиза следите за отчётом по товарным результатам: динамика валидных элементов, новые ошибки, покрытие.
Канонизация как сосед задачи. Параллельно проверяйте, что краулер ходит по каноническим URL, иначе чистая разметка теряет смысл.