← Voltar ao blog

Structured data no Magento 2: JSON-LD de Product do jeito certo

Rodrigo Biazzi

Structured data diz aos mecanismos de busca do que a página trata, num formato que eles conseguem interpretar sem adivinhar. Para uma loja, isso significa principalmente Product com Offer, BreadcrumbList, e Organization ou WebSite na home. Bem feito, torna as páginas de produto elegíveis para rich results com preço e disponibilidade. Mal feito, gera avisos no Search Console ou, pior, marcação que não bate com a página visível.

Este artigo cobre o que o Magento 2.4 gera por padrão, por que eu substituo por JSON-LD e como montar o structured data de produto como um módulo pequeno com um view model.

O que o Luma gera por padrão

O tema Luma adiciona microdata do schema.org na página de produto. O container do produto tem itemscope com o tipo Product, e atributos como itemprop="name", itemprop="sku" e um bloco de offer com preço e moeda ficam espalhados por vários templates. Os temas Hyvä tratam isso de outro jeito, então confira o que o seu tema gera antes de adicionar qualquer coisa.

O microdata padrão tem alguns problemas:

  • É incompleto. Campos como image, brand, availability ou identificadores como GTIN estão ausentes ou dependem do tema.
  • Fica espalhado por templates, então qualquer override de tema pode quebrá-lo sem ninguém perceber.
  • Mistura apresentação e dados, o que dificulta mudar sem mexer no HTML.

O Google suporta JSON-LD, microdata e RDFa, e recomenda JSON-LD. Um único script JSON-LD montado a partir de dados em PHP é mais fácil de testar e manter. Se você o adicionar, remova o microdata antigo dos overrides do seu tema, ou terá duas entidades Product na página com valores diferentes.

Montando o JSON-LD de produto

A estrutura é um módulo com um view model, um arquivo de layout para a página de produto e um template. O view model reúne os dados e devolve um array. O template o codifica.

O view model

<?php
declare(strict_types=1);

namespace Vendor\StructuredData\ViewModel;

use Magento\Catalog\Helper\Data as CatalogHelper;
use Magento\Catalog\Helper\Image as ImageHelper;
use Magento\Catalog\Model\Product;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;

class ProductSchema implements ArgumentInterface
{
    public function __construct(
        private readonly CatalogHelper $catalogHelper,
        private readonly ImageHelper $imageHelper,
        private readonly StoreManagerInterface $storeManager
    ) {
    }

    public function getJson(): string
    {
        $product = $this->catalogHelper->getProduct();
        if (!$product instanceof Product) {
            return '';
        }

        $price = $product->getPriceInfo()->getPrice('final_price')->getAmount()->getValue();
        $currency = $this->storeManager->getStore()->getCurrentCurrencyCode();

        $data = [
            '@context' => 'https://schema.org',
            '@type' => 'Product',
            'name' => $product->getName(),
            'sku' => $product->getSku(),
            'url' => $product->getProductUrl(),
            'image' => $this->imageHelper->init($product, 'product_page_image_large')->getUrl(),
            'description' => trim(strip_tags((string) $product->getData('short_description'))),
            'offers' => [
                '@type' => 'Offer',
                'url' => $product->getProductUrl(),
                'priceCurrency' => $currency,
                'price' => number_format((float) $price, 2, '.', ''),
                'availability' => $product->isSalable()
                    ? 'https://schema.org/InStock'
                    : 'https://schema.org/OutOfStock',
            ],
        ];

        $brand = $product->getAttributeText('manufacturer');
        if (is_string($brand) && $brand !== '') {
            $data['brand'] = ['@type' => 'Brand', 'name' => $brand];
        }

        return (string) json_encode(
            $data,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP
        );
    }
}

Alguns detalhes importam aqui. O Magento\Catalog\Helper\Data::getProduct() retorna o produto atual definido pelo controller de produto, então você evita usar o registry diretamente no seu código. O preço final vem do price info, que já aplica catalog price rules e preços especiais para o grupo de cliente atual. O JSON_HEX_TAG escapa os sinais de menor e maior, para que um nome de produto contendo </script> não consiga escapar da tag script.

Troque manufacturer pelo atributo que guarda a marca no seu catálogo. Se você tem atributos GTIN, EAN ou MPN, adicione como gtin13 ou mpn do mesmo jeito. Gere só campos que tenham valor real, já que strings vazias disparam avisos.

O layout

Crie o view/frontend/layout/catalog_product_view.xml no módulo:

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="before.body.end">
            <block name="vendor.product.schema"
                   template="Vendor_StructuredData::product-schema.phtml">
                <arguments>
                    <argument name="view_model" xsi:type="object">Vendor\StructuredData\ViewModel\ProductSchema</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

O JSON-LD não precisa ficar no head. Colocá-lo no fim do body o mantém fora do caminho crítico. Mais sobre handles de layout e containers está no guia de layout XML.

O template

<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
/** @var \Vendor\StructuredData\ViewModel\ProductSchema $viewModel */
$viewModel = $block->getData('view_model');
$json = $viewModel->getJson();
if ($json === '') {
    return;
}
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', ['type' => 'application/ld+json'], $json, false) ?>

Usar o SecureHtmlRenderer faz a tag script receber um nonce de CSP quando a página precisa, o que evita problemas em lojas com Content Security Policy estrita.

Full page cache e precisão de preço

As páginas de produto são cacheadas pelo Varnish ou pelo cache nativo, e o JSON-LD faz parte do HTML cacheado. Tudo bem, porque o cache é invalidado quando o produto muda. Dois casos pedem atenção:

  • Preços por grupo de clientes. A página cacheada para visitantes contém o preço de visitante, que é o que os mecanismos de busca veem. Isso está correto.
  • Mudanças de preço por catalog price rules. Os preços de regra mudam no início e no fim de uma regra, e o cache precisa ser limpo para esses produtos. Se as suas regras valem em horários agendados, garanta que o indexer rode e invalide o cache. Veja indexação de catalog price rules.

O Google confere se o preço no structured data bate com o preço visível. Uma divergência, por exemplo quando o preço visível é atualizado por JavaScript mas o JSON-LD não, pode fazer os rich results serem removidos.

Produtos configuráveis

Para um produto configurável, a abordagem correta mais simples é um Product com um AggregateOffer que tem lowPrice, highPrice e offerCount, montado a partir dos filhos vendáveis. Se você quer rich results em nível de variante, o schema.org tem o ProductGroup com hasVariant, em que cada filho é um Product com a própria offer. Dá mais trabalho, e as URLs dos filhos precisam levar a uma página que mostre aquela variante selecionada. Confira a documentação atual do Google sobre marcação de variantes antes de construir, já que os requisitos mudaram com o tempo.

Breadcrumbs, Organization e WebSite

O BreadcrumbList nas páginas de produto e categoria ajuda os mecanismos de busca a mostrar o caminho nos resultados. Monte-o a partir dos mesmos dados que o block de breadcrumb usa, para que a marcação e o breadcrumb visível batam. Note que o breadcrumb da página de produto no Luma depende de como o cliente chegou, enquanto uma URL de produto sem caminho de categoria não tem uma categoria única. Escolha uma categoria por produto, por exemplo a mais profunda, e use nos dois lugares.

A marcação Organization com nome, logo e links sameAs para perfis oficiais vai na home. O WebSite com uma potentialAction SearchAction apontando para /catalogsearch/result/?q={search_term_string} descreve a busca do site. São valores estáticos e podem ficar em campos de configuração do seu módulo.

Reviews e avaliações

Se a loja usa as reviews de produto nativas do Magento, você pode adicionar aggregateRating com ratingValue e reviewCount ao Product. Os valores vêm do resumo de reviews que o Magento mantém por produto e store. Valem duas regras. Só gere quando o produto tem reviews aprovadas, e só quando essas reviews estão visíveis na página. Marcação de avaliações que o visitante não vê vai contra as diretrizes do Google. Lembre também que o Magento guarda o resumo da avaliação como porcentagem, então converta para a escala de cinco pontos que as suas estrelas mostram antes de colocar na marcação.

Se as reviews vêm de um serviço externo, esse serviço costuma injetar a própria marcação. Confira o código-fonte da página para não acabar com duas avaliações no mesmo produto.

Testes

  1. Passe uma URL de produto pelo Rich Results Test do Google e pelo Schema Markup Validator.
  2. Teste um produto com preço especial, um sem estoque e um configurável.
  3. Veja o código-fonte de uma página cacheada como visitante para confirmar que o preço é o de visitante.
  4. Confira os relatórios de merchant listings e product snippets do Search Console depois do release em busca de erros novos.

Structured data é uma parte do SEO técnico. O resto da lista está no checklist de SEO técnico do Magento 2.

FAQ

O Magento 2 inclui structured data por padrão?

O Luma inclui microdata básico do schema.org na página de produto, mas é incompleto. A maioria das lojas se beneficia de trocá-lo por um único bloco JSON-LD.

O JSON-LD vai no head ou no body no Magento?

Os dois funcionam para mecanismos de busca. Colocá-lo no container before.body.end o mantém fora do head e não afeta a renderização.

O full page cache vai servir preços errados no structured data?

Não, se a invalidação de cache funciona. A página é limpa quando o produto ou o índice de preços dele muda. Confira se indexers e cron rodam para que os preços baseados em regra sejam atualizados a tempo.

Preciso remover o microdata do Luma ao adicionar JSON-LD?

Sim. Duas entidades Product com dados diferentes na mesma página confundem os validadores e podem causar avisos. Remova os atributos de microdata nos overrides do seu tema.

Atendo como freelancer em Magento e Adobe Commerce, incluindo desenvolvimento voltado a SEO. Se precisa de ajuda com isso, fale comigo.

Artigos relacionados