Практический разбор как перевести карточку товара с размерами и цветами с одиночного
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 стандартного компонента.
<?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. Проверка и мониторинг
-
Сброс кеша компонента. Если сброс ничего не меняет, а вывод не похож на ваш файл, вы правите не тот файл.
-
Rich Results Test и validator.schema.org. Цель это 0 критичных ошибок. Жёлтые «необязательно», вроде отсутствия
aggregateRatingилиshippingDetails, допустимы. -
Контроль обязательных полей на вариантах. Особое внимание на
image,offers,nameв каждомhasVariant. -
Search Console. После релиза следите за отчётом по товарным результатам: динамика валидных элементов, новые ошибки, покрытие.
-
Канонизация как сосед задачи. Параллельно проверяйте, что краулер ходит по каноническим URL, иначе чистая разметка теряет смысл.
ссылка на оригинал статьи https://habr.com/ru/articles/1054116/