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:
OnlineStore
OK
OnlineStore
Duplikat!
BreadcrumbList
OK
{ image: […] }
Brak @type!
{ @type: Product, offers: {url} }
Niekompletny
{ name: „…” }
Brak @type!
{ aggregateRating: {…} }
Brak @type!
[{ gtin, sku }]
Array, brak @type!
{ 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 wymagaposition: 1) - Zduplikowane OnlineStore z różnymi logo
- Brak
description,brand,availabilityw Product
Opcje rozwiązania
Mieliśmy kilka opcji:
Długi proces, brak gwarancji naprawy
Ograniczony dostęp do kodu w SaaS
Client-side, crawler może nie wykonać JS
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
→
→
1. Request przechodzi przez Cloudflare Edge
↓
↓
↓
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.:
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:
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:
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:
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:
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:
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:
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
};
}
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.
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:
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:
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
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.


