Mert Özen Her Satırda Daha İleri
SEO ve GEO Structured Data ve Schema.org — #07

Review Snippet ve AggregateRating: Kimin Review'u Sayılır? Self-serving Review Kuralı, Puan Hesabı ve Riskler

Mert Özen 15 Kas 2026 10 dk 4 görüntülenme
Review Snippet ve AggregateRating: Kimin Review'u Sayılır? Self-serving Review Kuralı, Puan Hesabı ve Riskler

Yıldız gösterimine yalnızca sayfada görünen, kullanıcıdan gelen ve değerlendirilen öğeye ait yorumlar aday olur; mağazanın kendi hakkındaki puanı Organization'a yazması self-serving kuralına takılır. Bookstore'un kitap yorumlarını moderasyonla süzüyor, ortalamayı EF Core'un SQL'e çevirdiği tek sorguyla alıp bir kez yuvarlıyor ve sayfayla markup'ı testlerle aynı tutuyoruz.

Sürüm notu: Bu yazı Google Search Central'ın Review snippet, Product snippet ve Merchant listing dokümanlarının 15 Kasım 2026'daki hâlini ve schema.org 30.1'i esas alır; kodlar .NET 10 (SDK 10.0.404), xUnit v3 4.0, AngleSharp 1.10 ve Microsoft.EntityFrameworkCore.SqlServer 10.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) ana sayfasında "Müşterilerimiz bizi 4,8 ile değerlendirdi" yazan bir kutu var; puanlar mağazanın kendi sipariş sonrası anketinden geliyor. Ekipten biri bu puanı Organization düğümüne aggregateRating olarak ekliyor. Dune sayfasında ise ortalama "4,3" görünüyor, ama markup'ta 4.2857142857142856 yazıyor ve reviewCount alanı sayfanın görüntülenme sayısından besleniyor. Soru: Bu üç işaretlemeden hangisi yıldız gösterimine aday, hangisi bir manual action riski taşıyor?

Önceki yazıda kitap sayfasının fiyatını ve stoğunu markup'a doğru aktardık. Bu yazıda okur yorumlarını ekliyoruz: hangi tiplerin puan taşıyabildiği, kimin yorumunun sayıldığı, puanın nasıl hesaplanıp yuvarlandığı ve görünen sayfayla markup'ın nasıl aynı kalacağı.

Hangi Tipler Puan Taşıyabilir?

Google'ın Review snippet dokümanı yıldızlı gösterimi belirli özelliklerle sınırlıyor: Book, Course list, Event, Local business, Movie, Product, Recipe ve Software App. Bunlara ek olarak CreativeWorkSeason, CreativeWorkSeries, Episode ve Game tiplerindeki yorumlar da destekleniyor. Bookstore'un kitap sayfası hem Product hem Book olduğu için iki kapıdan da geçiyor.

Yorumun neye ait olduğu iki yoldan söylenebiliyor: ya yorum, değerlendirilen öğenin review ya da aggregateRating property'si altına iç içe yazılıyor ya da bağımsız bir Review'da itemReviewed ile öğe gösteriliyor. Google Kasım 2025'te dokümanı, aynı yorum için bu iki yolu birlikte kullanmamanızı söyleyecek şekilde netleştirdi. Bookstore iç içe yolu kullanıyor: puan ve yorumlar doğrudan kitap düğümünün içinde.

Zorunlu alanlar kısa. Tek bir Review için author (100 karakterden kısa, geçerli bir kişi ya da kuruluş adı), reviewRating.ratingValue ve değerlendirilen öğenin adı gerekiyor; datePublished öneriliyor. AggregateRating için ratingValue ve ratingCount ile reviewCount'tan en az biri zorunlu. Ölçek belirtilmezse 1–5 varsayılıyor; başka bir ölçek kullanıyorsan bestRating ve worstRating yazmalısın. Ondalık ayırıcı nokta: 4,3 değil 4.3.

Bu bölümde resmî belgelerden yararlanılmıştır.

Kimin Yorumu Sayılır?

Google'ın yönergeleri birkaç net kural koyuyor. İşaretlenen yorumlar sayfada ziyaretçinin hemen görebileceği yerde olmalı: yorumu işaretlediysen metni ve puanı, AggregateRating kullandıysan ortalama puanı göstermelisin. Yorum belirli bir öğe hakkında olmalı, bir kategori ya da liste hakkında değil. Birden çok yorumu işaretliyorsan ortalama puanı da eklemelisin. Başka sitelerden toplanan yorum ve puanlar kullanılmamalı.

Son iki yılda iki madde eklendi. Ocak 2025'te Google, yalnızca yorum metni ve yazar adıyla gelen puanları kabul etmeyi önerdi; zorunlu değil, ama puanın arkasındaki gerekçeyi okura gösterir. 24 Temmuz 2026'da ise sahte ve teşvik edildiği açıklanmayan yorumlar hakkında bir yönerge eklendi: gerçek bir deneyime dayanmayan yorumlar ve para, indirim ya da ücretsiz ürün karşılığında yazılıp bunu açıkça belirtmeyen yorumlar ne sayfada ne markup'ta yer almalı.

Bookstore'da bu kurallar veri modeline dönüşüyor. Her yorumun bir moderasyon durumu var (Pending, Approved, Rejected) ve yalnızca onaylananlar sayılıyor. Puanın metni isteğe bağlı: metinsiz bir puan ortalamaya giriyor ama yorum listesine girmiyor. Bookstore'dan ücretsiz kopya alan bir okurun yorumu IsIncentivized ile işaretleniyor ve sayfada "Yorumcu kitabın ücretsiz bir kopyasını Bookstore'dan aldı" notuyla görünüyor. schema.org'un Review tipinde bu notu taşıyacak bir alan olmadığı için Bookstore bu yorumları tek tek markup'a koymuyor; ortalamada kalıyorlar, çünkü sayfada da ortalamaya dahiller. Bu, dokümanın açıkça söylemediği bir noktada verilmiş temkinli bir karar.

Self-serving Kuralı: Bookstore'un Kendi Puanı

Baştaki ilk işaretleme bu kurala takılıyor. Google, yorum snippet'i bir yerel işletme ya da kuruluş içinse ek bir yönerge uyguluyor: değerlendirilen kuruluş kendisi hakkındaki yorumları kontrol ediyorsa, onun LocalBusiness ya da başka bir Organization tipi kullanan sayfaları yıldız gösterimine aday değil. Doküman örneği de açık: A kuruluşu hakkındaki bir yorum A'nın kendi sitesinde duruyorsa, ister doğrudan structured data'da ister gömülü bir üçüncü parti widget'ında (Google Business ya da Facebook yorumları gibi), sonuç aynı.

Bookstore'un "4,8" kutusu tam bu durum: Bookstore hakkındaki puanlar Bookstore'un sitesinde. Kutunun sayfada durmasında sorun yok; sorun onu Organization markup'ına yazmak. Dune hakkındaki okur yorumları ise farklı: değerlendirilen şey Bookstore değil, kitap. Bu ayrımı bir testle koruyoruz:

[Theory]
[InlineData("/")]
[InlineData("/books/dune-paperback")]
public async Task Store_never_rates_itself(string path)
{
    JsonLdPage page = await JsonLdPage.LoadAsync(factory.CreateClient(), path);
    JsonElement store = Assert.Single(page.Nodes, n =>
        n.TryGetProperty("@id", out JsonElement id) && id.GetString() == "https://bookstore.example/#organization");

    // self-serving: Bookstore hakkındaki yorumlar Bookstore'un kendi sitesinde aday değil
    Assert.False(store.TryGetProperty("aggregateRating", out _));
    Assert.False(store.TryGetProperty("review", out _));
}

Puanı Hesaplamak: Bir Sorgu, Bir Yuvarlama

Ortalama puanı uygulama kodunda listeyi gezerek hesaplamak yerine tek bir sorguyla almak istiyoruz. Sorguyu IQueryable üzerinde yazınca aynı kod bellekteki örnek veride de, Bookstore'un Entity Framework Core (EF Core) ile çalışan BookstoreContext'inde de çalışıyor:

public sealed record RatingSummary(string Isbn, int RatingCount, int ReviewCount, double Average)
{
    // Bir kez yuvarlanır; sayfa da JSON-LD de bu değeri yazar
    public decimal RatingValue => Math.Round((decimal)Average, 1, MidpointRounding.AwayFromZero);
}

public static class ReviewQueries
{
    // Yalnız onaylanmış yorumlar sayılır; EF Core SQL'de çalıştırabilsin diye IQueryable
    public static IQueryable<RatingSummary> Summaries(this IQueryable<BookReview> reviews) =>
        reviews
            .Where(r => r.Status == ReviewStatus.Approved)
            .GroupBy(r => r.Isbn)
            .Select(g => new RatingSummary(
                g.Key,
                g.Count(),
                g.Count(r => r.Text != null),
                g.Average(r => r.Rating)));
}

RatingCount onaylanmış bütün puanları, ReviewCount ise metni olanları sayıyor. Google'ın tanımında ratingCount sitedeki toplam puan sayısı, reviewCount ise yorum bırakan kişi sayısı; ikisini ayrı tutmak sayfada "7 puan, 4 yorum" yazmayı da mümkün kılıyor. EF Core bu sorguyu SQL Server'da tek bir GROUP BY ifadesine çeviriyor. Test projesinde ToQueryString() ile, veritabanına bağlanmadan, üretilen SQL'e bakabiliyoruz:

SELECT [r].[Isbn], COUNT(*), COUNT(CASE
    WHEN [r].[Text] IS NOT NULL THEN 1
END), AVG(CAST([r].[Rating] AS float))
FROM [Reviews] AS [r]
WHERE [r].[Status] = 1
GROUP BY [r].[Isbn]

AVG'nin içindeki CAST önemli. SQL Server'ın dokümanına göre AVG int bir ifade için int döndürüyor; yani elle yazılmış SELECT AVG(Rating) 4,29 ortalamayı 4 olarak verir. EF Core, Average'ın sonucu double olduğu için cast'i kendisi ekliyor. Dapper ya da ham SQL kullanıyorsan bunu senin yazman gerekiyor. Test projesindeki bu kontrol, sorgunun beklediğimiz biçimde çevrildiğini sabitliyor:

public void Summary_query_runs_in_sql_server_as_one_grouped_statement()
{
    DbContextOptions<ReviewsContext> options = new DbContextOptionsBuilder<ReviewsContext>().UseSqlServer().Options;
    using ReviewsContext db = new(options);

    // ToQueryString sorguyu bağlantı açmadan SQL'e çevirir
    string sql = db.Reviews.Summaries().ToQueryString();
    TestContext.Current.TestOutputHelper?.WriteLine(sql);

    Assert.Contains("GROUP BY [r].[Isbn]", sql);
    Assert.Contains("AVG(CAST([r].[Rating] AS float))", sql);
}

Yuvarlama ikinci tuzak. Ortalama tam ortada kaldığında .NET'te farklı yollar farklı sonuç veriyor:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
double average = (5 + 4 + 4 + 4) / 4.0;

Print("average", average);
Print("average.ToString(\"F1\")", average.ToString("F1"));
Print("Math.Round(average, 1)", Math.Round(average, 1));
Print("Math.Round(.., AwayFromZero)", Math.Round((decimal)average, 1, MidpointRounding.AwayFromZero));

static void Print(string label, object value) => Console.WriteLine($"{label,-30}{value}");
$ dotnet run samples/rating-rounding.cs
average                       4.25
average.ToString("F1")        4.2
Math.Round(average, 1)        4.2
Math.Round(.., AwayFromZero)  4.3

Microsoft'un biçimlendirme dokümanına göre .NET Core 2.1'den bu yana ToString("F1") gibi standart biçimler tam ortadaki değeri son hanesi çift olacak şekilde yuvarlıyor; Math.Round'un varsayılanı da MidpointRounding.ToEven. Sayfada "4,2", markup'ta 4.3 yazan bir kod bu yüzden kolayca çıkar. Çözüm, değeri tek yerde (RatingSummary.RatingValue) bir kez yuvarlamak ve hem sayfanın hem markup'ın bu değeri kullanması. Hangi kuralı seçtiğinin bir önemi yok; önemli olan tek kural olması.

Özet ve Liste Birlikte Cache'lenmeli

Ortalama her sayfa görüntülemesinde yeniden hesaplanmasın diye cache'liyoruz. Burada sık yapılan hata, ortalamayı cache'leyip yorum listesini her istekte taze çekmek: yeni onaylanan bir yorum listede görünür ama sayaç beş dakika boyunca eski kalır ve sayfa "4 yorum" yazarken beş yorum gösterir. Bookstore ikisini aynı cache girdisinde tutuyor:

// Özet ve görünen yorumlar birlikte cache'lenir; sayıları hep tutarlı kalır
public sealed record ReviewSection(RatingSummary? Summary, IReadOnlyList<BookReview> Latest);

public sealed class ReviewService(IReviewSource source, IMemoryCache cache)
{
    public ReviewSection ForBook(string isbn) =>
        cache.GetOrCreate($"reviews:{isbn}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            IQueryable<BookReview> forBook = source.Reviews.Where(r => r.Isbn == isbn);

            RatingSummary? summary = forBook.Summaries().SingleOrDefault();
            List<BookReview> latest = forBook
                .Where(r => r.Status == ReviewStatus.Approved && r.Text != null)
                .OrderByDescending(r => r.CreatedAt)
                .Take(3)
                .ToList();
            return new ReviewSection(summary, latest);
        })!;
}

Bu bir Bookstore kararı, Google kuralı değil; ama Google'ın "ortalama puan sayfada görünmeli" şartını sağlamanın en kısa yolu, ikisini aynı nesneden üretmek.

Markup: Kitap Düğümüne Eklenen İki Property

SiteGraph.Book artık ReviewSection'ı da alıyor. Yorum yoksa iki property de hiç yazılmıyor:

// Teşvikli yorumlar notuyla sayfada kalır, ama schema.org'da bu not için bir alan yok
private static List<BookReview> MarkupReviews(ReviewSection reviews) =>
    [.. reviews.Latest.Where(r => !r.IsIncentivized)];
Offers: offer,
// yorum yoksa puan da yok: ratingValue 0 ya da boş liste yayımlanmaz
AggregateRating: reviews.Summary is { } summary
    ? new AggregateRatingNode(summary.RatingValue, summary.RatingCount, summary.ReviewCount)
    : null,
Review: MarkupReviews(reviews) is { Count: > 0 } marked
    ? [.. marked.Select(r => new ReviewNode(
        new NamedNode("Person", r.AuthorName),
        r.CreatedAt.ToString("yyyy-MM-dd"),
        r.Text!,
        new RatingNode(r.Rating)))]
    : null);

Dune'un EN sayfasındaki sonuç:

{
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": 4.3,
    "ratingCount": 7,
    "reviewCount": 4,
    "bestRating": 5,
    "worstRating": 1
  },
  "review": [
    {
      "@type": "Review",
      "author": {
        "@type": "Person",
        "name": "Linus T."
      },
      "datePublished": "2026-11-03",
      "reviewBody": "The appendices are more interesting than the ending.",
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": 3,
        "bestRating": 5
      }
    },
    {
      "@type": "Review",
      "author": {
        "@type": "Person",
        "name": "Alan T."
      },
      "datePublished": "2026-10-09",
      "reviewBody": "Slow first hundred pages, then impossible to put down.",
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": 4,
        "bestRating": 5
      }
    }
  ]
}

Sayfada üç yorum görünüyor, markup'ta iki yorum var: aradaki fark teşvikli yorum. ratingValue 4.3, sayfadaki "4.3 / 5 · 7 ratings, 4 reviews" metniyle aynı değerden geliyor. Spam olarak reddedilen yorum ve moderasyon bekleyen "bent cover" şikâyeti ne sayfada ne markup'ta.

Testler

Önceki yazının testleri yerinde; yeni test sınıfı puan markup'ını sayfayla karşılaştırıyor:

[Theory]
[InlineData("/books/dune-paperback", "tr")]
[InlineData("/en/books/dune-paperback", "en")]
public async Task Rating_markup_matches_what_the_reader_sees(string path, string lang)
{
    JsonLdPage page = await JsonLdPage.LoadAsync(factory.CreateClient(), path);
    JsonElement product = page.Single("Product");
    JsonElement rating = product.GetProperty("aggregateRating");

    string visible = page.Document.QuerySelector(".rating-summary")!.TextContent;
    Assert.Equal(visible, BookTexts.RatingSummary(
        rating.GetProperty("ratingValue").GetDecimal(),
        rating.GetProperty("ratingCount").GetInt32(),
        rating.GetProperty("reviewCount").GetInt32(), lang));

    // markup'taki her yorum yazarıyla birlikte sayfada okunabilmeli
    string[] texts = [.. page.Document.QuerySelectorAll(".review-text").Select(e => e.TextContent)];
    string[] authors = [.. page.Document.QuerySelectorAll(".review-author").Select(e => e.TextContent)];
    foreach (JsonElement review in product.GetProperty("review").EnumerateArray())
    {
        Assert.Contains(review.GetProperty("reviewBody").GetString(), texts);
        Assert.Contains(review.GetProperty("author").GetProperty("name").GetString(), authors);
    }
}

Diğer testler reddedilen ve bekleyen yorumların sayfaya ve markup'a girmediğini, yorumu olmayan ciltli baskının hiç puan yayımlamadığını ve mağaza düğümünün kendine puan vermediğini kontrol ediyor. Yuvarlama testi de dört puanlık (5, 4, 4, 4) bir örnekte 4.25'in 4.3 olduğunu sabitliyor.

$ dotnet test --project Bookstore.Web.Tests
Test run summary: Passed!
  total: 21
  failed: 0
  succeeded: 17
  skipped: 4

Üretimde Dikkat

Tekrar eden yorumları yazarken engelle. Aynı müşterinin aynı kitaba beş yorum yazabildiği bir sistemde, sayarken tekilleştirmek her sorguyu karmaşıklaştırır. Bookstore'da (Isbn, CustomerId) üzerinde unique index var; test projesindeki ReviewsContext bunu model düzeyinde gösteriyor.

Moderasyonu markup'tan önce düşün. Google'ın yönergesi sahte yorumları yasaklıyor ama nasıl ayıklanacağını söylemiyor; bu uygulamanın işi. Bekleyen yorumlar sayılmamalı, reddedilenler hiçbir yerde görünmemeli. Yorum spam'ini engellemek ayrı bir güvenlik konusu; Web Güvenliği (OWASP Top 10) serisindeki girdi doğrulama yazıları başlangıç noktası.

Puanın kaynağını değiştirirken markup'ı da değiştir. Yorumları bir üçüncü parti servise taşıdığın gün, Google'ın "başka sitelerden toplanan yorumları kullanma" kuralı devreye girebilir; puanın kendi sitende toplandığından emin ol.

Rich Results Test'te uyarı görebilirsin. Google'ın product snippet dokümanı, review ya da aggregateRating olmadan yalnızca offers verildiğinde aracın bir uyarı gösterebileceğini yazıyor; yorumu olmayan ciltli baskı için bu beklenen bir durum ve uydurma bir puanla kapatılmaz.

Sık Düşülen Tuzaklar

Organization'a kendi mağaza puanını basmak. Self-serving kuralı yüzünden yıldız gösterimine aday değil; puan bir widget'tan geliyorsa da sonuç aynı.

Yorum sayısı yerine görüntülenme ya da satış sayısı kullanmak. reviewCount yorum bırakan kişileri sayar; sayfanın kaç kez açıldığı ya da kitabın kaç adet satıldığı bu alana yazılmaz.

Yorum yokken ratingValue: 0 yazmak. Sıfır bir puan değil, eksik veri; property hiç yazılmamalı.

Mağaza sayfasına pros/cons eklemek. Google'ın product snippet dokümanı artı/eksi gösterimini yalnızca editoryal ürün incelemeleri için açıyor; satıcı ürün sayfaları ve müşteri yorumları bu kapsama girmiyor.

Küçük Bir Deneme

★☆☆ SampleReviewSource'a beşinci metinli bir yorum ekle ve sayfada en yeni üç yorumun değiştiğini, reviewCount'un 5 olduğunu doğrula.

★★☆ RatingSummary.RatingValue'yu MidpointRounding.ToEven ile değiştir. Hangi test düşüyor? Sayfa ile markup arasındaki tutarlılık testi neden hâlâ geçiyor?

★★★ ReviewService'i IReviewSource yerine bir BookstoreContext ile çalışacak şekilde async yaz (SingleOrDefaultAsync, ToListAsync) ve Testcontainers ile açılan bir SQL Server'da ortalamanın CAST olmadan nasıl değiştiğini ölç.

Kitap sayfası artık fiyatını, stoğunu ve okurların ne düşündüğünü anlatıyor. Eksik kalan, mağazanın kendisi: kargo ücreti, teslim süresi ve iade koşulları. Sonraki yazıda bunları Organization düzeyinde bir kez tanımlıyoruz ve Google'ın hangi kaynağa öncelik verdiğine bakıyoruz.