Jak naprawiamy Schema.org na platformie e-commerce bez dostępu do kodu

Masz sklep na platformie SaaS (Shoper, Shopify, PrestaShop Cloud) i widzisz błędy w danych strukturalnych? Platforma generuje fragmentaryczne JSON-LD, które Google ignoruje? Nie masz dostępu do szablonów? W tym artykule pokażę, jak rozwiązaliśmy ten problem używając Cloudflare Workers — bez modyfikacji kodu źródłowego sklepu.

Problem: fragmentaryczne JSON-LD

Podczas audytu SEO sklepu na platformie Shoper odkryliśmy poważny problem ze structured data. Zamiast jednego spójnego obiektu Product, dane były rozrzucone w 9 osobnych blokach JSON-LD:

Index 0
OnlineStore
OK
Index 1
OnlineStore
Duplikat!
Index 2
BreadcrumbList
OK
Index 3
{ image: […] }
Brak @type!
Index 4
{ @type: Product, offers: {url} }
Niekompletny
Index 5
{ name: „…” }
Brak @type!
Index 6
{ aggregateRating: {…} }
Brak @type!
Index 7
[{ gtin, sku }]
Array, brak @type!
Index 8
{ offers: {price, …} }
Brak @type!

Google nie potrafi połączyć tych fragmentów w całość. Efekt? Brak rich snippets w wynikach wyszukiwania, mimo że wszystkie dane były na stronie.

Dodatkowo znaleźliśmy:

  • FAQPage z błędną strukturą JSON (tablice tablic zamiast tablicy)
  • BreadcrumbList z position: 0 (spec wymaga position: 1)
  • Zduplikowane OnlineStore z różnymi logo
  • Brak description, brand, availability w Product

Opcje rozwiązania

Mieliśmy kilka opcji:

1

Kontakt z platformą
Długi proces, brak gwarancji naprawy
2

Edycja szablonu
Ograniczony dostęp do kodu w SaaS
3

Google Tag Manager
Client-side, crawler może nie wykonać JS
4

Cloudflare Worker
Server-side, modyfikacja HTML „w locie” — WYBRANE ✓

Dlaczego Worker?

  • Crawler widzi poprawne dane natychmiast (server-side)
  • Zero wpływu na wydajność strony dla użytkownika
  • Pełna kontrola nad strukturą danych
  • Możliwość wzbogacenia danych z API sklepu

Architektura rozwiązania

Crawler
(Google, Bing)

Cloudflare Edge
Schema Fixer Worker

Shoper
(origin)

1. Request przechodzi przez Cloudflare Edge

HTML Response
Błędne JSON-LD

HTMLRewriter
Streaming parser

KV Cache
Produkty
Shop API
Dane źródłowe
KV Config
Flagi, szablony

HTML Response
Poprawne JSON-LD ✓

Worker działa na Cloudflare Edge, między użytkownikiem a serwerem sklepu. Przechwytuje odpowiedź HTML, przetwarza ją przez HTMLRewriter (streaming parser), usuwa błędne bloki JSON-LD i wstawia nowe, poprawne.

Implementacja

1. Detekcja typu strony

Pierwszy krok to rozpoznanie czy to strona produktu, kategorii, bloga itp.:

detectPageType.js

function detectPageType(url) {
  const path = url.pathname;

  if (path.match(/^\/pl\/p\/.+\/\d+$/)) return 'product';
  if (path.match(/^\/pl\/c\/.+\/\d+$/)) return 'category';
  if (path.match(/^\/pl\/blog\/.+\/\d+$/)) return 'blog';
  if (path === '/' || path === '/pl/') return 'homepage';

  return 'other';
}

2. Zbieranie istniejących danych (faza 1)

HTMLRewriter pozwala na streaming parsing HTML. W pierwszej fazie zbieramy dane z istniejących schematów:

SchemaCollector.js

class SchemaCollector {
  constructor() {
    this.buffer = '';
  }

  text(text) {
    this.buffer += text.text;
  }

  element(element) {
    const collector = this;
    element.onEndTag(() => {
      try {
        // Napraw błędną strukturę (tablice tablic -> tablica)
        let jsonStr = collector.buffer
          .replace(/\}\s*\]\s*,\s*\[\s*\{/g, '}, {');

        const parsed = JSON.parse(jsonStr);
        existingSchemas.push(parsed);
      } catch (e) {
        // Ignoruj błędne JSON
      }
      collector.buffer = '';
    });
  }
}

let rewriter = new HTMLRewriter()
  .on('script[type="application/ld+json"]', new SchemaCollector());

3. Pobieranie danych z API sklepu

Platforma ma API, z którego możemy pobrać kompletne dane produktu:

getProductData.js

async function getProductData(productId, env) {
  // Sprawdź cache
  const cacheKey = `product:${productId}`;
  const cached = await env.HDWR_SCHEMA_CACHE.get(cacheKey, { type: 'json' });
  if (cached) return cached;

  // Pobierz z API
  const token = await getApiToken(env);
  const response = await fetch(`${SHOPER_API_URL}/products/${productId}`, {
    headers: { 'Authorization': `Bearer ${token}` }
  });

  const product = await response.json();
  const processed = processProductData(product);

  // Cache na 1h
  await env.HDWR_SCHEMA_CACHE.put(cacheKey, JSON.stringify(processed), {
    expirationTtl: 3600
  });

  return processed;
}

4. Generowanie poprawnego Schema (faza 2)

Teraz budujemy kompletny, poprawny obiekt Product:

generateProductSchema.js

async function generateProductSchema(url, env, existingSchemas) {
  const productId = extractProductId(url);
  const productData = await getProductData(productId, env);
  const existingData = mergeExistingSchemas(existingSchemas);

  return {
    "@context": "https://schema.org",
    "@type": "Product",
    "@id": url.href,
    "name": productData.name,
    "description": productData.description,  // BRAKUJĄCE w oryginale!
    "sku": productData.sku,
    "gtin13": productData.gtin13,
    "brand": {
      "@type": "Brand",
      "name": productData.brand || "HDWR"    // BRAKUJĄCE w oryginale!
    },
    "image": existingData.images,
    "offers": {
      "@type": "Offer",
      "url": url.href,
      "price": productData.price,
      "priceCurrency": "PLN",
      "availability": productData.availability,  // BRAKUJĄCE!
      "priceValidUntil": productData.priceValidUntil,
      "itemCondition": "https://schema.org/NewCondition"
    },
    // Rating z istniejących danych (jeśli były)
    ...(existingData.ratingValue && {
      "aggregateRating": {
        "@type": "AggregateRating",
        "ratingValue": existingData.ratingValue,
        "reviewCount": existingData.reviewCount
      }
    })
  };
}

5. Transformacja HTML

Finalna faza — usunięcie starych bloków i wstrzyknięcie nowych:

transform.js

class SchemaRemover {
  element(element) {
    element.remove();
  }
}

class SchemaInjector {
  constructor(schemas) {
    this.schemas = schemas;
    this.injected = false;
  }

  element(element) {
    if (!this.injected && this.schemas.length > 0) {
      const schemaScript = this.schemas.map(s =>
        `<script type="application/ld+json">${JSON.stringify(s)}</script>`
      ).join('\n');

      element.append(schemaScript, { html: true });
      this.injected = true;
    }
  }
}

let rewriter2 = new HTMLRewriter()
  .on('script[type="application/ld+json"]', new SchemaRemover())
  .on('head', new SchemaInjector(newSchemas));

return rewriter2.transform(response);

Mapowanie dostępności

Shoper używa własnych ID dla statusów dostępności. Musimy je zmapować na schema.org:

availabilityMap.js

const AVAILABILITY_MAP = {
  // can_buy = 1 (można kupić)
  2: 'https://schema.org/InStock',      // na wyczerpaniu
  4: 'https://schema.org/InStock',      // średnia ilość
  5: 'https://schema.org/InStock',      // duża ilość
  6: 'https://schema.org/PreOrder',     // dostępny na zamówienie

  // can_buy = 0 (nie można kupić)
  1: 'https://schema.org/BackOrder',    // spodziewana dostawa
  7: 'https://schema.org/Discontinued', // wycofany
  9: 'https://schema.org/OutOfStock'    // niedostępny
};

Konfiguracja i test mode

Worker ma konfigurację w KV, którą można zmieniać bez redeployu:

config.js

async function getConfig(env) {
  const configStr = await env.HDWR_CONTENT.get('schema_config');
  if (configStr) return JSON.parse(configStr);

  return {
    enabled: false,
    test_mode: true,
    test_product_ids: [1918, 2001],  // Tylko te produkty
    fix_product: true,
    fix_category: false,
    fix_homepage: false
  };
}
Pro tip: Dzięki test_mode możesz przetestować na wybranych produktach przed wdrożeniem na cały sklep. Wystarczy dodać ID produktów do tablicy test_product_ids.

Dodatkowe naprawy

Naprawa nagłówków H2

Shoper generuje <h2 class="p align_center">Twój koszyk jest pusty</h2> przed H1. SEO tools (Ahrefs, Screaming Frog) raportują to jako błąd hierarchii nagłówków.

HeadingFixer.js

class HeadingFixer {
  element(element) {
    const className = element.getAttribute('class') || '';

    // H2 koszyka -> P
    if (className.includes('p') && className.includes('align_center')) {
      element.tagName = 'p';
    }
  }
}

Cron trigger do odświeżania tokena

Token API wygasa po 24h. Worker ma scheduled trigger, który odświeża token co 7 dni:

scheduled.js

export default {
  async scheduled(event, env, ctx) {
    const response = await fetch(`${SHOPER_API_URL}/auth`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: `client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`
    });

    const data = await response.json();

    // Zapisz token do KV (ważny 30 dni, odświeżamy co 7)
    await env.HDWR_SCHEMA_CACHE.put('api_token', data.access_token, {
      expirationTtl: 2592000  // 30 dni
    });
  }
};

Wyniki

✘ Przed

  • 9 fragmentarycznych bloków JSON-LD
  • Brak rich snippets w Google
  • Błędy walidacji w Schema.org Validator
  • Ahrefs raportowało 200+ błędów

✔ Po

  • 3-4 spójne bloki (Product, BreadcrumbList, OnlineStore, FAQPage)
  • Rich snippets w Google Search Console
  • Walidacja 100% poprawna
  • Zero błędów w Ahrefs

Bonus: llms.txt dla crawlerów AI

Przy okazji wdrożyliśmy też llms.txt — plik dla crawlerów AI (ChatGPT, Perplexity, Claude). Shoper nie pozwala na tworzenie plików w root, więc użyliśmy tego samego podejścia:

llms-worker.js

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    const kvFiles = {
      '/llms.txt': 'llms.txt',
      '/.well-known/llms.txt': 'llms.txt'
    };

    const kvKey = kvFiles[url.pathname];

    if (kvKey) {
      const content = await env.HDWR_CONTENT.get(kvKey);
      if (content) {
        return new Response(content, {
          headers: {
            'Content-Type': 'text/plain; charset=utf-8',
            'X-Served-By': 'cloudflare-worker'
          }
        });
      }
    }

    return fetch(request);  // Pass through do Shoper
  }
}

Treść llms.txt zarządzamy przez KV Storage — można edytować w dashboardzie Cloudflare bez redeployu.

Podsumowanie

Cloudflare Workers to potężne narzędzie do modyfikacji odpowiedzi HTTP „w locie”. Szczególnie przydatne gdy:

  • Nie masz dostępu do kodu źródłowego (platforma SaaS)
  • Potrzebujesz server-side modyfikacji (SEO, crawlery)
  • Chcesz wzbogacić dane z zewnętrznego API
  • Chcesz testować zmiany na wybranych stronach przed wdrożeniem
Kluczowe technologie:
HTMLRewriter — streaming parser HTML, niskie zużycie pamięci
KV Storage — cache danych, konfiguracja, treści statyczne
Scheduled Triggers — automatyczne odświeżanie tokenów

Kod tego projektu ma ~1900 linii i obsługuje produkty, kategorie, kolekcje, blogi i strony informacyjne. Cała logika jest na edge — zero wpływu na serwer sklepu.

Artykuł powstał na podstawie rzeczywistego wdrożenia dla sklepu e-commerce na platformie Shoper. Jeśli masz podobny problem ze swoją platformą — Cloudflare Workers mogą być rozwiązaniem.