Statik sitede yapılandırılmış veri: JSON-LD üretimi ve doğrulanması
Statik sitede schema.org: BlogPosting, Person, WebSite ve BreadcrumbList için hangi alan gerekli, JSON-LD'yi articles.json'dan Python ile üretme ve doğrulama.
Yapılandırılmış veri, sayfanın ne hakkında olduğunu arama motoruna insan diline bağımlı kalmadan söylemenin yolu: bu bir blog yazısı, şu tarihte yayımlandı, yazarı şu kişi, kapak görseli şurada. Statik sitelerde asıl zorluk JSON-LD sözdizimini öğrenmek değil; hangi sayfaya hangi schema.org tipinin yakıştığını seçmek, o tipin hangi alanlarını doldurmanın gerçekten karşılığı olduğunu bilmek ve bunu her yazıda elle tekrarlamak yerine tek bir kaynaktan üretmek. Elle yazılan bloklar ilk hafta doğru oluyor, üçüncü ayda yarısı sayfayla çelişmeye başlıyor.
Statik sitede arkada bir CMS eklentisi yok; JSON-LD'yi ya şablona gömersiniz ya da yayın betiği üretir. Ben ikincisini yapıyorum. Yazıların başlığı, özeti, tarihi ve etiketleri zaten articles.json dosyasında duruyor; JSON-LD'yi aynı dosyadan türetince başlık ya da tarih değiştiğinde tek yeri güncelliyorum ve şema, sayfada görünen içerikle hiçbir zaman ayrışmıyor.
Bu yazıda dört tipi (BlogPosting, Person, WebSite ve BreadcrumbList) hangi sayfada kullandığımı, hangi alanların zorunlu hangilerinin zengin sonuç için gerekli olduğunu, üretimi yapan küçük Python betiğini, doğrulama araçlarını ve en sık gördüğüm hataları anlatacağım. Sonda önerdiğim uygulama sırası var.
Hangi schema.org tipi hangi sayfaya
schema.org sözlüğünde yüzlerce tip var; kişisel bir blogda bunların dördü işin neredeyse tamamını karşılıyor. Her birini nerede kullandığım ve neden o tipi seçtiğim şöyle:
- BlogPosting: her yazı sayfasında.
Article'ın alt tipi; Google'ın makale zengin sonucuArticle,NewsArticleveBlogPostingüçünü de kabul ediyor. Kişisel blog için en dürüst tip bu;NewsArticlehaber sitesi ima ediyor, düzArticleise fazla genel. - Person: yalnızca ana sayfada, tam haliyle. Kişisel markanın kimliği bu blok; yazılardaki
authoralanı aynı kişiyi@idile işaret ediyor. Bu bağlantının neden önemli olduğunu Entity SEO ve kişisel markalaşma yazısında anlattım; JSON-LD o fikrin teknik karşılığı. - WebSite: ana sayfada, sitenin adını ve dilini bildirmek için.
publisheralanı Person'a bağlanıyor. Eskiden buraya arama kutusu içinpotentialActioneklenirdi; Google o özelliği artık göstermediği için ben eklemiyorum. - BreadcrumbList: her yazı sayfasında. Arama sonucunda ham URL yerine "Ana Sayfa › Blog › Yazı" yolunu gösteriyor ve sitenin hiyerarşisini anlatıyor. Sayfadaki görünür kırıntı ile birebir aynı olmak zorunda.
Bilerek kullanmadıklarım da var: Organization tek kişilik bir marka için fazla, Person yeterli; FAQPage ve HowTo zengin sonuçlarını Google büyük ölçüde kısıtladığı için bunlara ayrı blok üretmenin artık görünür bir karşılığı yok.
Zorunlu alanlar ile rich result için gerekenler
Burada iki ayrı kaynağı karıştırmamak gerekiyor. schema.org sözlüğünün kendisinde hiçbir alan zorunlu değil; sözlük yalnızca hangi tipin hangi özellikleri taşıyabileceğini tanımlıyor. "Zorunlu" ve "önerilen" ayrımı Google'ın zengin sonuç belgelerinden geliyor ve tipten tipe değişiyor. Önerdiğim asgari küme şu:
- BlogPosting:
headline,image,datePublished,dateModifiedveauthor. Google bunları makale için "önerilen" sınıfında tutuyor ama pratikte biri eksikse Rich Results Test uyarı veriyor ve zengin sonuç görünmüyor. Başlığı 110 karakterin altında tutmak, görseli en az 1200 piksel genişlikte ve mutlak URL ile vermek, yazardanameile birlikteurlbulundurmak benim asgari çizgim. - BreadcrumbList:
itemListElementzorunlu; herListItemiçinpositionvenamezorunlu,item(URL) son öge dışında zorunlu. Son öge zaten bulunulan sayfa olduğu için URL istenmiyor. - Person:
namedışında zorunlu alan yok. Kişisel marka için işe yarayanlarurl,sameAs(LinkedIn ve diğer profiller),jobTitle,knowsAboutve konum içinaddress. Bunlar zengin sonuç üretmiyor; varlığın kim olduğunu netleştiriyor. - WebSite:
nameveurl. Site adının arama sonucunda doğru görünmesi için Google'ın baktığı yerlerden biri bu blok.
description, inLanguage, mainEntityOfPage, keywords ve wordCount zengin sonuç için gerekmiyor; üretim otomatik olunca maliyeti sıfır olduğu için ben yine de ekliyorum. Ana sayfadaki iki bloğu tek bir @graph içinde tutuyorum:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://ekremdegirmenci.com/#website",
"url": "https://ekremdegirmenci.com/",
"name": "Ekrem De\u011firmenci",
"inLanguage": "tr-TR",
"publisher": {"@id": "https://ekremdegirmenci.com/#person"}
},
{
"@type": "Person",
"@id": "https://ekremdegirmenci.com/#person",
"name": "Ekrem De\u011firmenci",
"url": "https://ekremdegirmenci.com/",
"knowsAbout": ["veri otomasyonu", "Python", "SEO"],
"sameAs": ["https://www.linkedin.com/in/ekrem-degirmenci/"]
}
]
}
Buradaki \u011f, JSON'un "ğ" harfi için kaçış yazımı; düz yazmakla eşdeğer. Asıl önemli olan @id değerleri: Person'a verdiğim /#person kimliği her yazının author alanında tekrar kullanılıyor; böylece yazı ile kişi aynı varlığa bağlanıyor.
JSON-LD'yi elle yazmak yerine articles.json'dan üretmek
Aşağıdaki betik articles.json'daki her kayıt için iki blok üretiyor: BlogPosting ve BreadcrumbList. Yazar bilgisini ayrı bir yazar.json dosyasından okuyor; Google sayfalar arası @id referansını çözmediği için yazının içinde de name ve url bulunmak zorunda, yalnızca kimlik vermek yetmiyor. Zorunlu bir alan eksikse betik sessizce boş bırakmak yerine duruyor; yanlış şemayı yayına almaktansa yayını durdurmayı tercih ederim.
import json
from pathlib import Path
SITE = "https://ekremdegirmenci.com"
VARSAYILAN_GORSEL = SITE + "/assets/images/og-card.png" # 1200 px genislik, 16:9
# yazar.json: {"@type": "Person", "@id": SITE + "/#person", "name": ..., "url": ...}
KISI = json.loads(Path("yazar.json").read_text(encoding="utf-8"))
def blog_posting(y):
url = f"{SITE}/blog/articles/{y['slug']}"
veri = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
"headline": y["title"],
"description": y["excerpt"],
"datePublished": y["publishDate"], # ISO 8601: YYYY-AA-GG
"dateModified": y.get("updatedDate") or y["publishDate"],
"inLanguage": "tr-TR",
"image": y.get("image") or VARSAYILAN_GORSEL, # mutlak URL; gorsel yoksa varsayilan
"author": KISI,
}
if y.get("tags"):
veri["keywords"] = ", ".join(y["tags"])
return veri
def kirinti(y):
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Ana Sayfa", "item": SITE + "/"},
{"@type": "ListItem", "position": 2, "name": "Blog", "item": SITE + "/blog"},
{"@type": "ListItem", "position": 3, "name": y["title"]}, # son ogede item gerekmez
],
}
def script_blogu(veri):
metin = json.dumps(veri, ensure_ascii=False, indent=2)
metin = metin.replace("</", "<\\/") # icerikteki </ dizisi script etiketini erken kapatmasin
return '<script type="application/ld+json">' + metin + '</script>'
if __name__ == "__main__":
yazilar = json.loads(Path("articles.json").read_text(encoding="utf-8"))
for y in yazilar:
for alan in ("slug", "title", "excerpt", "publishDate"):
if not y.get(alan):
raise SystemExit(f"{y.get('slug', '?')}: '{alan}' eksik, uretim durdu")
cikti = Path("build/ld") / f"{y['slug']}.html"
cikti.parent.mkdir(parents=True, exist_ok=True)
cikti.write_text(script_blogu(blog_posting(y)) + script_blogu(kirinti(y)), encoding="utf-8")
print("uretildi:", cikti)
Çıktı, yazı başına tek bir HTML parçası. Benim yayın betiğim bu parçayı ilgili sayfanın <head> bölümüne yerleştiriyor; sizde Jekyll, Hugo ya da elle kurulmuş bir şablon varsa parça oraya gömülür. Bir ayrıntıya dikkat: json.dumps çıktısında geçebilecek </ dizisi tarayıcıda </script> gibi yorumlanıp bloğu erken kapatabilir; o yüzden betik bunu <\/ olarak kaçırıyor. JSON tarafında ikisi aynı şey, HTML tarafında ikincisi güvenli.
Asıl kazanç şemanın kendisi değil, kaynağın tekliği. Aynı articles.json blog listesini, sitemap'i ve og: etiketlerini de besliyor; Python ile SEO otomasyonu yazısındaki denetim betikleri de yazı listesini oradan alıyor. Başlık bir yerde değişince h1, title, og:title ve headline aynı anda değişiyor.
Doğrulama araçları ve sık yapılan hatalar
Üç araç kullanıyorum ve üçü farklı soruya cevap veriyor:
- Rich Results Test: Google'ın gözü. Yalnızca Google'ın zengin sonuç olarak desteklediği tipleri raporluyor; Person ya da WebSite için "tespit edildi" bile demeyebiliyor, bu bir hata değil. Canlı URL'yi ya da yayına almadan önce ham HTML'i yapıştırıp test edebiliyorsunuz.
- Schema Markup Validator: validator.schema.org adresinde; sözlüğün tamamına karşı sözdizimi ve tip uyumunu denetliyor. Rich Results Test'in umursamadığı Person ve WebSite bloklarını burada kontrol ediyorum.
- Search Console: canlıdaki gerçek durum. Geliştirmeler bölümündeki raporlar, Google'ın taradığı sayfalarda hangi bloğu hangi uyarıyla okuduğunu gösteriyor. Testler tek sayfayı anlık, Search Console bütün siteyi zaman içinde denetliyor.
Denetlediğim sitelerde tekrar tekrar karşıma çıkan hatalar şunlar:
- Tarih formatı:
17.09.2026ya da17 Eylül 2026yazılmış. Tarih ISO 8601 olmalı:2026-09-17ya da saatle birlikte2026-09-17T09:00:00+03:00. Saat dilimi verilmezse Google tahmin ediyor ve yayın tarihi bir gün kayabiliyor. - Görsel yok ya da göreli:
imagealanı boş veya/assets/kapak.pnggibi göreli yazılmış. Mutlak URL şart; görseli olmayan yazı için varsayılan bir kapak tanımlayın, betik onu kullansın. - Yazar URL'si yok: yalnızca
"author": "Ekrem"yazılmış. Yazar bir nesne olmalı:@type,nameveurlile. URL, yazarın kim olduğunu doğrulayabilecek bir sayfaya gitmeli. - Görünür içerikle çelişki:
headlineh1'den farklı,datePublishedsayfadaki tarihten farklı, kırıntı listesi sayfada olmayan bir yol gösteriyor. Google bunu yanıltıcı yapılandırılmış veri sayıyor ve elle işlem sebebi olabiliyor. Tek kaynaktan üretim tam olarak bu hatayı önlüyor. - dateModified tutarsız: güncelleme tarihi yayın tarihinden eski ya da hiç yok. Yoksa yayın tarihine eşitleyin; eskiyse veri girişinde bir sorun var demektir.
- Bozuk JSON: sondaki fazla virgül, kaçırılmamış tırnak. Tarayıcı bunu size söylemiyor; blok sessizce yok sayılıyor. Elle yazılan şemalarda en sık gördüğüm hata bu ve üretimi betiğe bırakmanın en somut gerekçesi.
Uygulama sırası
Bütün bunları bir sıraya oturtunca iş, tek seferlik bir kurulum artı her yayında koşan küçük bir kontrol haline geliyor:
- Ana sayfaya WebSite ve Person bloğunu bir kez elle yazdım; bu iki blok ayda bir bile değişmiyor, otomasyona gerek yok.
articles.json'a eksik alanları tamamladım: her yazıdaexcerpt,publishDateve varsaupdatedDate, görseli olanlardaimage.- Üretim betiğini yayın akışına ekledim; sitemap ve blog listesi ne zaman üretiliyorsa JSON-LD de o anda üretiliyor.
- Yayın öncesi kontrol adımına şemayı da kattım: betik zorunlu alanı eksik olan yazıda duruyor, tıpkı Core Web Vitals denetiminde eşiği aşan sayfanın yayına çıkmaması gibi.
- Yeni bir yazı ilk kez yayına girdiğinde canlı URL'yi Rich Results Test'te bir kere açıyorum; sonrası için Search Console raporuna haftalık bakıyorum.
Özetle: kişisel blogda BlogPosting, Person, WebSite ve BreadcrumbList dördü yetiyor; zorunlu alan listesi schema.org'dan değil Google'ın belgelerinden geliyor; JSON-LD'yi elle yazmak yerine yazı verisinin zaten durduğu dosyadan üretmek hem hatayı hem çelişkiyi ortadan kaldırıyor; doğrulama için Rich Results Test, Schema Markup Validator ve Search Console'un üçü ayrı işe bakıyor. Kendi siteniz için böyle bir üretim ve doğrulama düzeni kurmak isterseniz veri ve otomasyon danışmanlığı işimi anlattığım ana sayfadaki /#contact bölümünden bana yazabilirsiniz.