Soft-404, kendine işaret etmeyen hreflang, NUL bayt dolu yasal sayfalar, otomatik link spam'i ve sonsuz ?page=N: 14 sitemde yaşadığım beş sıralama katili.
Bu yazının bir versiyonu her CMS blogunda var: canonical, alt metin, TTFB, bilindik şüpheliler. O versiyonu ben de yazmıştım. Bu farklı. Aşağıdaki beş hata, jekcms üzerinde işlettiğim 14 içerik sitesinde bizzat başıma geldi; sıralama kaybettirdi ve bir AdSense başvurusuna mal oldu. CMS'i yazan benim; en büyük sitede 2.300'den fazla yazı var, filo toplamı 5.500'ü geçiyor. Saklanacak yer yok.
Her hatada aynı çizgiyi izleyeceğim: nasıl fark ettim, siteye ne yapıyordu, düzeltmesi ne oldu.
1. Headers gittikten sonra gelen 404
Search Console URL'leri "Soft 404" altında toplamaya başladı. Normal "Bulunamadı (404)" değil — soft 404: Googlebot sayfayı çekmiş, 200 OK almış ama sayfanın aslında boş olduğuna kendisi karar vermiş. URL'ler silinmiş ya da slug'ı değişmiş yazılardı. Tarayıcıda her şey doğru görünüyordu: ölü slug, "yazı bulunamadı" mesajı, geç git. Tarayıcının size göstermediği şey durum satırı.
Tekil yazı şablonu önce header.php'yi yüklüyor, veritabanına sonra soruyordu. PHP, yanıt durumunu çıktının ilk baytıyla birlikte gönderir; header.php de bolca bayt basar. Kod yazının var olmadığını öğrendiğinde 200 çoktan yola çıkmıştı ve http_response_code(404) çalışıp hiçbir şey yapmayan bir satıra dönüşmüştü:
// bozuk sıra
require 'includes/header.php'; // çıktı başladı — 200 kilitlendi
$post = $db->fetch("SELECT * FROM posts WHERE slug = ?", [$slug]);
if (!$post) {
http_response_code(404); // çalışır ama etkisi yoktur
include 'includes/not-found.php';
}
Neden önemli: dürüst bir 404 indeksten düşer ve unutulur. Soft 404 ise Google'a "bu site çöp için de başarı döndürüyor" der ve servis ettiğiniz diğer bütün 200'lerin değerini sessizce aşındırır. Bendeki kova her silinen yazıyla büyüdü; cesetler yaşadıklarını iddia ettiği için Googlebot onları taramaya devam etti.
Düzeltme akıllıca bir numara değil, bir sıralama kuralı: tek bayt çıkmadan önce içeriği çöz.
$post = $db->fetch("SELECT * FROM posts WHERE slug = ?", [$slug]);
if (!$post) {
http_response_code(404);
header('X-Robots-Tag: noindex');
require 'includes/404.php';
exit;
}
require 'includes/header.php'; // HTML ancak şimdi başlar
X-Robots-Tag başlığı aynı kapıya vurulmuş ikinci kilit: sunucuyla crawler arasında bir şey durum kodunu bozsa bile noindex yerine ulaşır.
2. 87 hreflang etiketi, sıfır öz-referans
jekcms İngilizce ve Türkçeyi tek kod tabanından çalıştırır ve sluglar yerelleştirilmiştir — İngilizce fiyat sayfası /pricing, Türkçesi /tr/fiyatlandirma; /tr/pricing değil. Dokümantasyon bölümünde rutin bir tur atarken rastgele derin bir sayfada kaynağı görüntüledim ve hreflang bloğuna bir süre baktım. İki alternatif de docs açılış sayfasını gösteriyordu. Başka bir sayfa açtım. Aynı çift. 87 dokümantasyon sayfasının tamamı dil alternatifi olarak /docs ve /tr/dokumantasyon'u ilan ediyordu; çünkü blok layout şablonuna gömülüydü ve kimse sorgulamamıştı.
İşi özensizlikten ölümcüle çeviren kısım şu: hreflang kümeleri set olarak doğrulanır. Her sayfa kendine bir referans içermek zorundadır ve her alternatif geri link vermelidir. /docs/automation/cron-setup'ta duran ama İngilizce sürümünün /docs olduğunu iddia eden bir sayfa iki kontrolü de geçemez — ve Google'ın geçersiz küme karşısındaki belgelenmiş davranışı etiketleri tamamen yok saymaktır. Yani docs bölümünün tamamı, iki dilde neredeyse paralel içerik, aralarında ilan edilmiş hiçbir ilişki olmadan kendi çevirileriyle yarışıyordu. 87 sayfalık, kendi elimizle üretilmiş bir çiftlenme.
Kalıcı düzeltme bir kural oldu: hreflang, şablona yapıştırılan işaretleme değil, istek yolundan türetilen veridir.
$path = strtok($_SERVER['REQUEST_URI'], '?');
$en = docs_path_to_en($path); // /tr/dokumantasyon/x => /docs/x
$tr = docs_path_to_tr($path); // /docs/x => /tr/dokumantasyon/x
echo '<link rel="alternate" hreflang="en" href="' . SITE_URL . $en . '">';
echo '<link rel="alternate" hreflang="tr" href="' . SITE_URL . $tr . '">';
echo '<link rel="alternate" hreflang="x-default" href="' . SITE_URL . $en . '">';
Yerelleştirilmiş slug eşlemesinin tamamını ayrı bir yazıda anlattım; çok dilli SEO kurulumu yapıyorsanız oradan devam edin.
3. 14.616 baytlık hiçlik
Haziran 2026, AdSense reddi sonrası otopsi. Geri bildirimin bir kısmı site kalitesini işaret ediyordu; ben de kendi ağımı bir denetçinin bakacağı gibi taradım. Dört site gizlilik sayfasını bomboş servis ediyordu.
Dosya oradaydı. legal.php, 14.616 bayt, makul bir zaman damgası, PHP hatası yok, HTTP 200. Hex görüntüleyicide açtım: her bayt 0x00. Baştan sona NUL. On dört kilobaytlık dosya, hiçlik olarak render oluyordu — hem de bir reklam ağı denetçisinin bakacağı garanti olan sayfada.
Boş gizlilik politikası küçük bir kusur değil. AdSense için tek başına diskalifiye nedeni; kullanıcı için var olmayan bir güven sayfası. Ve hiçbir şey alarm vermemişti, çünkü normalde güvendiğim her katman sağlıklı bir dosya görüyordu: dosya vardı, boyutu makuldü, parse ediliyordu (tamamı NUL olan bir dosya PHP'yi şaşırtıcı derecede az rahatsız eder) ve 200 dönüyordu.
Aynı gün CI hattına, bozuk ya da güdük PHP dosyası içeren her deploy'u durduran bir bekçi eklendi:
# deploy kapisi: NUL bayt yok, gudik PHP dosyasi yok
if grep -rlaP '\x00' --include='*.php' .; then
echo "PHP dosyalarinda NUL bayt bulundu — durduruluyor"; exit 1
fi
find . -name '*.php' -size -32c -not -path './vendor/*' | grep . \
&& { echo "supheli gudik PHP dosyalari"; exit 1; }
Ders genelleştirilebilir: diskte duranı değil, sunucunun servis ettiğini doğrula. Arada bir kendi yasal sayfalarınızı curl'leyin. Bedava — ve bunu aylar önce yakalardı.
4. İç linkler kataloğa benzemeye başlayınca
Bunu kendime, üstelik gurur duyduğum bir araçla yaptım. Bir otomatik iç-linkleyici yazmıştım: anahtar kelime → URL haritası verin, bütün korpusa iç link bassın. İç link SEO'ya iyi gelir, o hâlde fazlası daha iyidir mantığıyla ağın tamamında agresif çalıştırdım.
Aylar sonra, aynı AdSense otopsisi sırasında kendi yazılarımı bir yabancı gibi yeniden okurken etkiyi görmemek imkânsızdı. Aynı çapa kelimeler her geçtiği yerde linkli. Cümle ortasına düşen, ucundan alakalı sayfalara giden bağlantılar. Üst üste üç satırın üçünde de link taşıyan paragraflar. Yazılar blog kılığına girmiş bir ürün kataloğu gibi okunuyordu — ağı işaretleten makine-üretimi doku tam olarak bu ve redde katkı verdi.
Onarımın kestirmesi yoktu. Yazılar elden geçti; linkler artık tek tek, editoryal olarak seçiliyor. Vardığım kurallar kısa:
- Link, okurun gerçekten "dur, nasıl?" diyeceği yere girer — anahtar kelimenin geçtiği her yere değil.
- Çapa metni çeşitlenir; aynı ifade bir sayfada iki kez linkleniyorsa biri gider.
- Başlık içinde asla, kod içinde asla; yazı başına bir avuçtan fazlası nadiren.
Otomatik linkleyici hâlâ duruyor ama öneri aracına indirildi: o teklif eder, insan karar verir.
5. ?page=9999 gayet geçerli bir sayfaydı
Blog listesi ?page=N parametresi alıyor ve ona güveniyordu. Yedi sayfalık bir arşivde 7. sayfa yazıları gösteriyordu; 8. sayfa aynı header'ı, sidebar'ı ve footer'ı boş bir listenin etrafında — 200 ile — render ediyordu. 500. sayfa da öyle. Tarama istatistikleri Googlebot'un bu boşluğa sadakatle yürüdüğünü gösteriyordu: sınırsız sayıda ince, birbirinin neredeyse aynısı URL. Filo paylaşımlı hostingde yaşıyor; crawl budget burada teorik bir kavram değil.
O sayfaların her biri saf kabuktu. Tam layout, sıfır içerik, hepsi gayet iyi durumda olduğunu iddia ediyor.
Aralık dışı sayfalama artık sert bir 404:
$perPage = 10;
$total = (int)$db->fetch("SELECT COUNT(*) AS c FROM posts WHERE status = 'published'")['c'];
$last = max(1, (int)ceil($total / $perPage));
$page = (int)($_GET['page'] ?? 1);
if ($page < 1 || $page > $last) {
http_response_code(404);
require 'includes/404.php';
exit; // kabuk yok, layout yok — gerçek bir 404
}
Normal sayfalarda query parametrelerini söküp atan canonical etiketiyle birleşince, sonsuz URL uzayı gerçekten var olan sayfalara geri çöktü.
Ortak payda
Beşinin hiçbiri tarayıcıda görünmüyor. Beşi de bir crawler'a görünüyor. Bütün hikâye bu asimetri: sitelerime yıllarca her gün bir ziyaretçi gibi baktım — ve yine de yalnızca durum satırlarını ve head etiketlerini okuyan bir HTTP istemcisinin görebileceği hataları yayına aldım.
Değişen alışkanlık şu oldu: kendi URL'lerime curl -sI, derin sayfalarda kaynak görüntüleme ve Search Console kapsam raporunu gösterge paneli değil hata takipçisi gibi okumak. Bu vakalardan doğan daha geniş tarama kendi serisine dönüştü — denetim bulguları yazısı, aynı yöntemin ölçekte neler çıkardığını anlatıyor.
Beş düzeltmenin beşi de artık jekcms'te varsayılan olarak geliyor. Bir operatör hatasını düzeltmenin en iyi yeri, onu bir daha tekrarlayamayacağım yer: ürünün içi.