Search Console API ve BigQuery Export: C# ile Kendi SEO Raporunu Otomatikleştirmek
Search Console verisini 16 ayın ötesinde saklamak için Search Analytics API'den her günü ayrı çekip kendi veritabanına yazar ya da bugünden itibaren BigQuery bulk export'u açarsın; ikisi de geçmişi geriye doğru doldurmaz. Service account yetkisini, sayfalamayı, kotaları, sahte handler'la testi, SQL Server'a idempotent arşivi, eşik tabanlı haftalık raporu ve export'un maliyet notlarını C# koduyla anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Google.Apis.SearchConsole.v1 1.78.0.4234 ve Microsoft.Data.SqlClient 7.1.2 ile derlenip denenmiştir; arşiv SQL Server 2025'te çalıştı. Bu yazıda resmî belgelerden yararlanılmıştır: Search Console API referansı, kullanım limitleri ve bulk data export yardım sayfaları, 15 Kasım 2026'daki hâliyle.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) blog'u için Emre, Search Console API'den son bir ayın verisini page ve query dimension'larıyla çekip topladı. Toplam click, arayüzdeki grafiğin gösterdiğinden düşük çıktı. Selin ise iki yıllık bir karşılaştırma istiyor. Sence API'nin toplamı neden tutmadı? Ve bugün başlasan, iki yıl önceki verinin hangi kısmına ulaşabilirsin?
Kimlik Doğrulama: Service Account ve Property Yetkisi
Bir job'un Search Console'a kendi adına erişmesi için service account kullanıyorsun. Akış üç adım: kendi Google Cloud projende bir service account oluşturup Search Console API'yi etkinleştiriyorsun; service account'un key dosyasındaki client_email adresini Search Console'da property'ye kullanıcı olarak ekliyorsun; job da key dosyasıyla webmasters.readonly scope'unda token alıyor. Google'ın API dokümanına göre searchAnalytics.query için property üzerinde okuma yetkisi yeterli, yani owner yetkisi vermene gerek yok.
using Google.Apis.Auth.OAuth2;
using Google.Apis.SearchConsole.v1;
using Google.Apis.Services;
namespace Bookstore.Analytics.SearchConsole;
public static class SearchConsoleClient
{
// Key dosyası repository'nin dışında durur; yolu configuration'dan gelir.
public static async Task<SearchConsoleService> CreateAsync(string keyFilePath, CancellationToken ct = default)
{
ServiceAccountCredential account =
await CredentialFactory.FromFileAsync<ServiceAccountCredential>(keyFilePath, ct);
GoogleCredential credential = account.ToGoogleCredential()
.CreateScoped(SearchConsoleService.ScopeConstants.WebmastersReadonly);
return new SearchConsoleService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "bookstore-analytics",
});
}
// Stub server'a karşı yerel çalıştırma; credential yok.
public static SearchConsoleService CreateForStub(Uri baseUri) =>
new(new BaseClientService.Initializer
{
BaseUri = baseUri.ToString(),
ApplicationName = "bookstore-analytics",
});
}
CredentialFactory.FromFileAsync<ServiceAccountCredential> dosyanın gerçekten bir service account key'i olmasını şart koşuyor. Google.Apis.Auth'un kendi dokümanı, dışarıdan gelen bir credential dosyasını doğrulamadan kullanmamak gerektiğini söylüyor; tipi baştan sabitlemek bu doğrulamanın en basit hâli. Key dosyasının yolu BOOKSTORE_GSC_KEY_FILE environment variable'ından geliyor; dosya repository'de değil, sunucuda ya da secret store'da duruyor.
Search Analytics Sorgusu
Tek endpoint searchAnalytics.query. Önemli alanlar: startDate ve endDate Pasifik saatine göre ve ikisi de dahil; dimensions country, device, page, query, searchAppearance, date ve hour olabiliyor; type varsayılan olarak web; filtrelerde equals, contains ve RE2 (Google'ın regex motoru) ifadeleri için includingRegex/excludingRegex var; rowLimit 1 ile 25.000 arasında, varsayılanı 1.000; dataState varsayılan olarak final, yani yalnız kesinleşmiş veri. Yanıt satırları click'e göre azalan sırada geliyor ve dokümanın açık uyarısı şu: API bütün satırları değil, en üsttekileri döndürmeyi garanti ediyor.
Google'ın "Getting all your data" rehberinin önerileri koda doğrudan dönüşüyor: her istekte tek bir gün sor; rowLimit'i 25.000 yap ve startRow'u artırarak boş yanıt gelene kadar devam et; veri genelde 2-3 gün sonra hazır. Rehber gün ve arama tipi başına en fazla 50.000 satır verildiğini, page ve query dimension'ları eklenince sistemin bazı verileri düşürebileceğini, doğru toplamlar için bu iki dimension'ı dışarıda bırakmak gerektiğini söylüyor. Başlangıçtaki sorunun ilk cevabı bu: Emre'nin toplamı satırlardan geliyor, satırlarda hem anonim sorgular hem düşürülen veri yok. Bu yüzden fetcher her gün için iki sorgu atıyor: biri dimension'sız toplam, biri ayrıntı.
using System.Globalization;
using Google.Apis.SearchConsole.v1;
using Google.Apis.SearchConsole.v1.Data;
namespace Bookstore.Analytics.SearchConsole;
public sealed class SearchAnalyticsFetcher(SearchConsoleService service, int pageSize = 25_000)
{
private static readonly string[] DetailDimensions = ["page", "query", "country", "device"];
// Bir günün sayfa + sorgu satırları; bu ayrıntı seviyesinde Google bazı satırları düşürebilir.
public async Task<List<SearchAnalyticsRow>> FetchDetailAsync(
string siteUrl, DateOnly day, string type = "web", CancellationToken ct = default)
{
List<SearchAnalyticsRow> rows = [];
for (int startRow = 0; ; startRow += pageSize)
{
SearchAnalyticsQueryResponse response = await QueryAsync(siteUrl, day, type, DetailDimensions, startRow, ct);
if (response.Rows is not { Count: > 0 } page)
{
return rows;
}
rows.AddRange(page.Select(r => new SearchAnalyticsRow(
day, type, r.Keys[0], r.Keys[1], r.Keys[2], r.Keys[3],
(int)(r.Clicks ?? 0), (int)(r.Impressions ?? 0), r.Position ?? 0)));
}
}
// Page ve query dimension'ları olmadan toplamlar eksiksizdir.
public async Task<DailyTotal> FetchTotalAsync(
string siteUrl, DateOnly day, string type = "web", CancellationToken ct = default)
{
SearchAnalyticsQueryResponse response = await QueryAsync(siteUrl, day, type, [], 0, ct);
ApiDataRow? row = response.Rows?.FirstOrDefault();
return new DailyTotal(day, type, (int)(row?.Clicks ?? 0), (int)(row?.Impressions ?? 0), row?.Position ?? 0);
}
private Task<SearchAnalyticsQueryResponse> QueryAsync(
string siteUrl, DateOnly day, string type, IList<string> dimensions, int startRow, CancellationToken ct)
{
string date = day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
SearchAnalyticsQueryRequest body = new()
{
StartDate = date,
EndDate = date,
Type = type,
Dimensions = dimensions,
DataState = "final",
RowLimit = pageSize,
StartRow = startRow,
};
return service.Searchanalytics.Query(body, siteUrl).ExecuteAsync(ct);
}
}
Limitler de bu tasarımı destekliyor. Search Analytics için site başına ve kullanıcı başına dakikada 1.200, proje başına dakikada 40.000 ve günde 30 milyon sorgu sınırı var. Bunun yanında bir "load" kotası var: page ve query'ye göre gruplamak ya da filtrelemek, özellikle ikisi birlikte, en pahalı sorgular; altı aylık bir sorgu bir günlükten çok daha pahalı. Kısa vadeli kotayı aşarsan dokümana göre 15 dakika bekleyip tekrar denemen gerekiyor.
Google Client'ını Test Etmek
Gerçek bir property'ye ve credential'a dokunmadan test yazmak için Google client kütüphanesinin HttpClientFactory'sini değiştirip araya sahte bir HttpMessageHandler koyuyoruz. İlk denemede testler JSON parse hatasıyla düştü: client request body'lerini varsayılan olarak gzip'liyor. Sahte handler bu yüzden Content-Encoding: gzip görünce body'yi açıyor.
public sealed class FakeGoogleHttpClientFactory(HttpMessageHandler handler) : Google.Apis.Http.HttpClientFactory
{
protected override HttpMessageHandler CreateHandler(Google.Apis.Http.CreateHttpClientArgs args) => handler;
}
[Fact]
public async Task Pages_with_startRow_until_an_empty_response()
{
FakeHandler handler = new((_, body) =>
{
int startRow = (int)JsonNode.Parse(body)!["startRow"]!;
return (HttpStatusCode.OK, startRow switch { 0 => Rows(2), 2 => Rows(1), _ => "{}" });
});
SearchAnalyticsFetcher fetcher = new(Service(handler), pageSize: 2);
List<SearchAnalyticsRow> rows = await fetcher.FetchDetailAsync("sc-domain:bookstore.example", new DateOnly(2026, 11, 9));
Assert.Equal(3, rows.Count);
Assert.Equal([0, 2, 4], handler.Calls.Select(c => (int)JsonNode.Parse(c.Body)!["startRow"]!));
JsonNode first = JsonNode.Parse(handler.Calls[0].Body)!;
Assert.Equal("2026-11-09", (string)first["startDate"]!);
Assert.Equal("2026-11-09", (string)first["endDate"]!);
Assert.Equal("final", (string)first["dataState"]!);
Assert.EndsWith("/webmasters/v3/sites/sc-domain%3Abookstore.example/searchAnalytics/query",
handler.Calls[0].Request.RequestUri!.AbsolutePath);
}
Test, sayfalamanın startRow 0, 2 ve 4 ile ilerleyip boş yanıtta durduğunu, tarihin tek gün ve final olduğunu ve URL'de sc-domain: önekinin encode edildiğini doğruluyor. Bir diğer test, toplam sorgusunda dimension gönderilmediğini, üçüncüsü de son kesin günün Pasifik saatine göre hesaplandığını kontrol ediyor:
$ dotnet test --filter "FullyQualifiedName~SearchAnalyticsFetcherTests|FullyQualifiedName~WeeklyReportTests"
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5, Duration: 55 ms - Bookstore.Analytics.Tests.dll (net10.0)
Yerel Deneme: Stub Server ve SQL Server Arşivi
Uçtan uca denemek için tools/Bookstore.Analytics.GscStub adında, yalnız 127.0.0.1'e bağlanan küçük bir minimal API yazdım. Google değil; Search Analytics isteğinin ve yanıtının şeklini taklit edip sentetik satırlar dönüyor. Gerçek API'deki "toplam satırlardan büyük" farkını göstermek için toplam yanıtına satırların click'inden yüzde 8 fazlasını koyuyor. Stub'ın kendi kodu da gzip'li body'ler için UseRequestDecompression kullanıyor. CLI'a --stub ile bu adresi verince client BaseUri'yi oraya çeviriyor:
$ dotnet run --project tools/Bookstore.Analytics.GscStub --urls http://127.0.0.1:52703
$ dotnet run --project src/Bookstore.Analytics.Cli -- fetch sc-domain:bookstore.example \
--from 2026-10-29 --to 2026-11-11 --stub http://127.0.0.1:52703/ --page-size 25
2026-10-29 web: 28 detail rows, 141 clicks in rows, 152 clicks in total
2026-10-30 web: 28 detail rows, 141 clicks in rows, 152 clicks in total
2026-10-31 web: 28 detail rows, 84 clicks in rows, 91 clicks in total
2026-11-01 web: 28 detail rows, 84 clicks in rows, 91 clicks in total
2026-11-02 web: 28 detail rows, 141 clicks in rows, 152 clicks in total
2026-11-03 web: 28 detail rows, 141 clicks in rows, 152 clicks in total
2026-11-04 web: 28 detail rows, 141 clicks in rows, 152 clicks in total
2026-11-05 web: 28 detail rows, 131 clicks in rows, 141 clicks in total
2026-11-06 web: 28 detail rows, 131 clicks in rows, 141 clicks in total
2026-11-07 web: 28 detail rows, 77 clicks in rows, 83 clicks in total
2026-11-08 web: 28 detail rows, 77 clicks in rows, 83 clicks in total
2026-11-09 web: 28 detail rows, 131 clicks in rows, 141 clicks in total
2026-11-10 web: 28 detail rows, 131 clicks in rows, 141 clicks in total
2026-11-11 web: 28 detail rows, 131 clicks in rows, 141 clicks in total
Stub'ın log'u her gün için bir toplam ve üç sayfalı bir ayrıntı sorgusu görüyor; üçüncü sayfa boş geliyor ve döngü orada bitiyor:
query site=sc-domain:bookstore.example date=2026-10-29 dims=[] startRow=0 rowLimit=25
query site=sc-domain:bookstore.example date=2026-10-29 dims=[page,query,country,device] startRow=0 rowLimit=25
query site=sc-domain:bookstore.example date=2026-10-29 dims=[page,query,country,device] startRow=25 rowLimit=25
query site=sc-domain:bookstore.example date=2026-10-29 dims=[page,query,country,device] startRow=50 rowLimit=25
Arşiv tarafı, job aynı gün için iki kez çalışsa da tek kopya bırakacak şekilde yazıldı: önce o günün satırlarını siliyor, sonra SqlBulkCopy ile yeniden yazıyor, ikisi aynı transaction'da.
// Idempotent: job aynı gün için iki kez çalışsa da tek kopya kalır.
public async Task ReplaceDayAsync(DailyTotal total, IReadOnlyList<SearchAnalyticsRow> rows, CancellationToken ct = default)
{
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync(ct);
await using SqlTransaction tx = (SqlTransaction)await connection.BeginTransactionAsync(ct);
await using (SqlCommand delete = new("""
DELETE dbo.SearchAnalyticsDaily WHERE DataDate = @d AND SearchType = @t;
DELETE dbo.SearchAnalyticsTotals WHERE DataDate = @d AND SearchType = @t;
INSERT dbo.SearchAnalyticsTotals VALUES (@d, @t, @c, @i, @p);
""", connection, tx))
{
delete.Parameters.Add("@d", SqlDbType.Date).Value = total.Date.ToDateTime(TimeOnly.MinValue);
delete.Parameters.Add("@t", SqlDbType.VarChar, 16).Value = total.SearchType;
delete.Parameters.Add("@c", SqlDbType.Int).Value = total.Clicks;
delete.Parameters.Add("@i", SqlDbType.Int).Value = total.Impressions;
delete.Parameters.Add("@p", SqlDbType.Float).Value = total.Position;
await delete.ExecuteNonQueryAsync(ct);
}
using DataTable table = ToTable(rows);
using SqlBulkCopy bulk = new(connection, SqlBulkCopyOptions.Default, tx)
{
DestinationTableName = "dbo.SearchAnalyticsDaily",
};
await bulk.WriteToServerAsync(table, ct);
await tx.CommitAsync(ct);
}
11 Kasım'ı ikinci kez çektikten sonra tabloda o gün için yine 28 satır ve 131 click vardı. Bu job'u günde bir kez çalıştırmak için cron, Windows Task Scheduler ya da bir BackgroundService kullanabilirsin; zamanlama, retry ve idempotency'nin ayrıntısı Background Job'lar ve Scheduled Job'lar serisinin "Cron İfadeleri" ve "Background Job'larda Retry ve Idempotency" yazılarında. Saat dilimine dikkat: job'u Pasifik saatine göre gün bittikten ve veri kesinleştikten sonra, yani en az üç gün geriden çalıştır.
Haftalık Rapor ve Eşik Tabanlı Uyarı
Arşivin amacı haftalık bir özet: bu hafta ile önceki haftanın site toplamı, en çok kazanan ve kaybeden sayfalar ve bir eşik aşılırsa uyarı. Site toplamı dimension'sız toplam tablosundan, sayfa değişimleri ayrıntı tablosundan geliyor:
public static WeeklySummary Build(
int previousSiteClicks, int currentSiteClicks,
IEnumerable<PageWeek> previous, IEnumerable<PageWeek> current,
int top = 3, double dropThreshold = 0.30, int minClicks = 50)
{
Dictionary<string, int> before = previous.ToDictionary(p => p.Page, p => p.Clicks);
Dictionary<string, int> after = current.ToDictionary(p => p.Page, p => p.Clicks);
List<PageChange> changes = before.Keys.Union(after.Keys)
.Select(page => new PageChange(page, before.GetValueOrDefault(page), after.GetValueOrDefault(page)))
.ToList();
List<string> alerts = [];
if (previousSiteClicks >= minClicks && currentSiteClicks < previousSiteClicks * (1 - dropThreshold))
{
alerts.Add($"site clicks fell {Drop(previousSiteClicks, currentSiteClicks)} week over week");
}
alerts.AddRange(changes
.Where(c => c.PreviousClicks >= minClicks && c.CurrentClicks < c.PreviousClicks * (1 - dropThreshold))
.OrderByDescending(c => c.PreviousClicks - c.CurrentClicks)
.Select(c => $"{c.Page} fell {Drop(c.PreviousClicks, c.CurrentClicks)} ({c.PreviousClicks} -> {c.CurrentClicks})"));
return new WeeklySummary(
previousSiteClicks,
currentSiteClicks,
changes.Where(c => c.Delta > 0).OrderByDescending(c => c.Delta).ThenBy(c => c.Page, StringComparer.Ordinal).Take(top).ToList(),
changes.Where(c => c.Delta < 0).OrderBy(c => c.Delta).ThenBy(c => c.Page, StringComparer.Ordinal).Take(top).ToList(),
alerts);
}
Uyarı iki şarta bağlı: düşüş yüzde 30'u geçmeli ve önceki hafta en az 50 click olmalı. İkinci şart olmadan 4 click'ten 2'ye inen bir sayfa her hafta alarm verir. Stub'ın sentetik verisinde ikinci haftaya bir düşüş ve bir artış koyduk; rapor bunları buluyor ve uyarı olduğunda sıfırdan farklı bir exit code dönüyor, böylece job'u çalıştıran araç bir bildirim gönderebiliyor:
$ dotnet run --project src/Bookstore.Analytics.Cli -- report 2026-11-11; echo "exit code: $?"
week 2026-11-05..2026-11-11 vs 2026-10-29..2026-11-04
site clicks: 942 -> 871
gainers:
+105 /blog/cs8618-nullable-uyarisi (301 -> 406)
losers:
-169 /blog/ef-core-migration-rehberi (391 -> 222)
alerts:
! /blog/ef-core-migration-rehberi fell 43% (391 -> 222)
exit code: 3
16 Ayı Aşmak: BigQuery Bulk Data Export
Kendi arşivinin alternatifi Search Console'un BigQuery'ye günlük bulk export'u. Kurulum: billing'i açık bir Cloud projesinde BigQuery API ve BigQuery Storage API'yi etkinleştiriyorsun, search-console-data-export@system.gserviceaccount.com hesabına BigQuery Job User ve BigQuery Data Editor rollerini veriyorsun, sonra Search Console'da Settings, Bulk data export altında proje ID'sini, dataset adını (varsayılan searchconsole) ve location'ı giriyorsun. Location sonradan kolay değişmiyor. İlk export kurulumdan sonraki 48 saat içinde geliyor ve geçmişi doldurmuyor: export kurulduğu günden itibaren başlıyor. Başlangıçtaki ikinci sorunun cevabı bu yüzden "bugünden geriye 16 ay": daha eskisi için ne API ne export yardımcı olur.
Export'ta searchdata_site_impression (property bazında) ve searchdata_url_impression (URL bazında) tabloları ve bir ExportLog tablosu var. Anonim sorgular satırlarda boş sorgu ve is_anonymized_query ile işaretli. Position sıfır tabanlı toplamlarla tutuluyor; ortalamayı SUM(sum_position) / SUM(impressions) + 1 ile hesaplıyorsun. Tablolar data_date'e göre partition'lı ve maliyetin büyük kısmı taranan veri; sorguyu her zaman tarih filtresiyle yaz:
SELECT
url,
SUM(clicks) AS clicks,
SUM(impressions) AS impressions,
SUM(sum_position) / SUM(impressions) + 1 AS avg_position
FROM searchconsole.searchdata_url_impression
WHERE data_date BETWEEN DATE '2026-11-05' AND DATE '2026-11-11'
AND search_type = 'WEB'
GROUP BY url
ORDER BY clicks DESC
LIMIT 20;
Maliyet ve işletme notları: depolama ve sorgu ücretsiz kotayı aşınca ücretli; partition expiration koymazsan veri sonsuza kadar birikiyor, koyarsan en az 14 gün olmalı. Tablo şemasına sütun eklemek export'u bozuyor. Kalıcı bir hatada Search Console ertesi gün tekrar deniyor, ama bir günün verisi yaklaşık bir hafta sonra düşüyor ve yaklaşık bir ay hata alan export tamamen duruyor.
Üretimde Dikkat
Key dosyasını repository'ye koyma ve log'a yazma. Dosyanın yolu configuration'dan gelsin, dosyanın kendisi secret store'da dursun; service account'a property üzerinde yalnız okuma yetkisi ver.
Kotaya saygı göster. Günlük job'u gün gün çalıştır, aynı veriyi tekrar tekrar sorgulama ve hata aldığında hemen değil bekleyerek tekrar dene.
Toplamı ve ayrıntıyı ayrı sakla. Site trendi için dimension'sız toplam tabloyu, sayfa ve sorgu analizi için ayrıntı tablosunu kullan; ikisini birbirinden türetmeye çalışma.
Uyarıyı hafta bazında tut. Günlük click hafta sonu ve tatillerde doğal olarak düşüyor; günlük eşikler bu yüzden gereksiz alarm üretir.
Sık Düşülen Tuzaklar
API'nin arayüzle aynı sayıyı vermesini beklemek. Page ve query ile gruplanan sorgular anonim sorguları ve kesilen satırları içermiyor; aggregation türü de farklı olabiliyor.
Son günleri final diye çekip boş görmek ya da all ile çekip sonradan değişen sayılarla rapor yazmak. Kesinleşmiş veri 2-3 gün geriden geliyor.
Export'u "sonra kurarız" diye ertelemek. Bulk export geriye dönük veri getirmiyor; erteledikçe kaybettiğin geçmiş büyüyor.
Service account key'ini örnek kodda ya da bir test dosyasında commit'lemek. Sahte bir değer bile gerçek bir formatta olursa secret scanning araçları onu yakalar; testlerde credential'a hiç ihtiyaç duymayan sahte handler'ı kullan.
Küçük Bir Deneme
★☆☆ Stub'ı çalıştırıp fetch'i --page-size 10 ile tekrar çalıştır. Stub log'unda bir gün için kaç ayrıntı sorgusu görüyorsun, neden?
★★☆ SearchAnalyticsFetcher'a searchAppearance desteği ekle. Dokümana göre bu dimension başka dimension'larla birlikte kullanılamıyor; önce yalnız onunla sorgulayıp tipleri bul, sonra her tip için filtreli ikinci bir sorgu at. Sahte handler'la test et.
★★★ WeeklyReport'a "yeni kaybedilen sorgular" bölümü ekle: önceki hafta en az 20 impression alan ama bu hafta hiç görünmeyen sorgular. Arşiv tablosundan sorguyu yaz ve testini ekle.
Search Console verisi artık bizde ve 16 ayla sınırlı değil. Bir sonraki yazıda yolculuğun diğer yarısına, okurun sitede ne yaptığına geçiyoruz: GA4'ün (Google Analytics 4; Google'ın web analitik ürünü) event modeli, Angular SPA'da (Single Page Application; tek sayfalık uygulama) page_view ve key event'ler.