Tüm yazılar
Pazarlama 6 dk okuma

Python ile SEO otomasyonu: pratik betiklerle site denetimi

Python, requests ve BeautifulSoup ile kendi sitende çalışan SEO denetim betikleri: sitemap/robots tutarlılığı, kırık iç link, başlık, meta ve kanonik kontrolü.

#python #seo #automation

Python ile SEO otomasyonu deyince aklıma büyük ajans araçları değil, kendi sitemde düzenli aralıklarla çalıştırdığım birkaç küçük betik geliyor. Site denetimi dediğimiz iş aslında tekrar eden, sıkıcı ve gözden kaçmaya çok müsait bir kontrol listesi: sitemap ile robots.txt birbiriyle tutarlı mı, içeride kırık link var mı, başlık ve meta açıklama uzunlukları makul mü, kanonik etiketler doğru sayfayı gösteriyor mu. Bunları elle takip etmek ilk hafta keyifli, üçüncü hafta unutulmuş bir iş oluyor.

Bu yazıda kendi kullandığım yaklaşımı paylaşıyorum: requests ile sayfaları çekmek, BeautifulSoup ile ayrıştırmak, sonuçları SQLite veri tabanına yazıp oradan CSV raporları üretmek. Amaç, tek komutla çalışan ve "bu hafta ne bozuldu" sorusuna cevap veren bir zincir kurmak. Hazır SEO araçlarının yerine geçmesi gerekmiyor; ama kendi sitende neyin ne zaman değiştiğini bilmek için fazlasıyla yeterli.

Betiklerin tamamı standart kütüphane artı üç paketle çalışıyor: pip install requests beautifulsoup4 lxml. Kodları kendi sitemde çalıştırarak yazdım; okurken alan adını ve eşik değerlerini kendi yapına göre değiştirmen gerekecek. Yazının sonunda üç betiği tek bir zamanlanmış göreve bağlamayı da anlatıyorum.

Sitemap ve robots.txt tutarlılığı

En sık gördüğüm sorun şu: sitemap arama motoruna "bu sayfaları tara" diyor, robots.txt ise aynı sayfaların bir kısmını engelliyor. İki dosya farklı zamanlarda, farklı kişiler tarafından düzenlendiği için çelişki fark edilmiyor. Bir de sitemap içinde başka alan adına ya da eski http adresine işaret eden satırlar kalıyor; site https'e geçmiş, sitemap geçmemiş oluyor.

İlk betik üç şeyi kontrol ediyor:

  • robots.txt içinde ilan edilen sitemap adresleri gerçekten 200 dönüyor mu (ilan yoksa /sitemap.xml varsayılıyor)
  • dosya bir sitemap indeksiyse alt sitemap dosyalarına iniliyor mu
  • listelenen her URL kendi alan adında mı ve robots kuralları onu engelliyor mu
import requests
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser
from bs4 import BeautifulSoup

SITE = "https://www.ornek-site.com"
UA = "site-denetim-botu/1.0"
basliklar = {"User-Agent": UA}

robots_txt = requests.get(urljoin(SITE, "/robots.txt"),
                          headers=basliklar, timeout=10).text
rp = RobotFileParser()
rp.parse(robots_txt.splitlines())

# robots.txt icinde ilan edilen sitemap adresleri
kuyruk = [s.split(":", 1)[1].strip()
          for s in robots_txt.splitlines()
          if s.lower().startswith("sitemap:")]
if not kuyruk:
    kuyruk = [urljoin(SITE, "/sitemap.xml")]

sorunlar = []
while kuyruk:
    sm = kuyruk.pop(0)
    r = requests.get(sm, headers=basliklar, timeout=10)
    if r.status_code != 200:
        sorunlar.append((sm, "sitemap erisilemiyor", r.status_code))
        continue
    soup = BeautifulSoup(r.text, "xml")          # lxml gerektirir
    adresler = [loc.text.strip() for loc in soup.find_all("loc")]
    if soup.find("sitemapindex"):                # indeks: alt sitemap'lere in
        kuyruk.extend(adresler)
        continue
    for url in adresler:
        if not url.startswith(SITE):
            sorunlar.append((url, "farkli alan adi veya sema", None))
        elif not rp.can_fetch("Googlebot", url):
            sorunlar.append((url, "robots.txt engelliyor", None))

print(len(sorunlar), "sorun bulundu")
for satir in sorunlar:
    print(*satir, sep=" | ")

Standart kütüphanedeki urllib.robotparser kuralları Googlebot ile birebir aynı şekilde yorumlamayabilir; joker karakterler gibi konularda farklar var. Ben bunu kesin hüküm değil, "şuraya bir bak" listesi olarak kullanıyorum. Çıktıda görünen her satır elle doğrulanmayı hak ediyor, ama listenin boş çıkması da başlı başına bir bilgi.

İkinci betik, sitenin ana sayfasından başlayıp bulduğu her iç linki takip eden basit bir tarayıcı. Genişlik öncelikli bir kuyrukla ilerliyor, her sayfayı bir kez çekiyor, HTML olanları ayrıştırıp içindeki linkleri kuyruğa ekliyor. Aynı turda başlık, meta açıklama ve kanonik etiketi de toplayıp SQLite'a yazıyor; böylece sonraki bölümdeki kontroller için siteyi ikinci kez taramak gerekmiyor.

Kurarken verdiğim bazı kararlar şunlar:

  • Sayfalar sayfa tablosuna, sayfalar arası bağlantılar link tablosuna gidiyor. Kırık link raporunda "hangi sayfadan hangi sayfaya" bilgisi olmazsa düzeltmek için sayfayı aramak zorunda kalıyorsun.
  • URL'lerdeki #bolum parçaları atılıyor; yoksa aynı sayfa on kez taranıyor.
  • İstekler arasında yarım saniye bekleme var. Kendi sunucun bile olsa saniyede yüzlerce istek atmak iyi bir alışkanlık değil.
  • Bağlantı hatası veren sayfalar durum kodu 0 ile kaydediliyor; hata sessizce yutulmuyor.
import sqlite3
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
import requests
from bs4 import BeautifulSoup

SITE = "https://www.ornek-site.com"
HOST = urlparse(SITE).netloc
oturum = requests.Session()
oturum.headers["User-Agent"] = "site-denetim-botu/1.0"

db = sqlite3.connect("denetim.sqlite")
db.executescript("""
CREATE TABLE IF NOT EXISTS sayfa (
    url TEXT PRIMARY KEY, durum INTEGER, baslik TEXT, baslik_uz INTEGER,
    meta TEXT, meta_uz INTEGER, kanonik TEXT, tarih TEXT);
CREATE TABLE IF NOT EXISTS link (kaynak TEXT, hedef TEXT, durum INTEGER);
""")

kuyruk, gorulen = deque([SITE]), {SITE}
while kuyruk:
    url = kuyruk.popleft()
    try:
        r = oturum.get(url, timeout=10)
    except requests.RequestException as hata:
        print("baglanti hatasi:", url, hata)
        db.execute("INSERT OR REPLACE INTO sayfa (url, durum, tarih) "
                   "VALUES (?, 0, datetime('now'))", (url,))
        db.commit()
        continue

    baslik = meta = kanonik = None
    if r.ok and "text/html" in r.headers.get("Content-Type", ""):
        soup = BeautifulSoup(r.text, "html.parser")
        if soup.title and soup.title.string:
            baslik = soup.title.string.strip()
        m = soup.find("meta", attrs={"name": "description"})
        meta = m.get("content", "").strip() if m else None
        c = soup.find("link", rel="canonical")
        kanonik = c.get("href") if c else None
        for a in soup.find_all("a", href=True):
            hedef, _ = urldefrag(urljoin(url, a["href"]))
            if urlparse(hedef).netloc != HOST:
                continue                      # dis link, mailto, tel vb.
            db.execute("INSERT INTO link VALUES (?, ?, NULL)", (url, hedef))
            if hedef not in gorulen:
                gorulen.add(hedef)
                kuyruk.append(hedef)

    db.execute("INSERT OR REPLACE INTO sayfa VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
               (url, r.status_code, baslik, len(baslik or ""),
                meta, len(meta or ""), kanonik))
    db.commit()
    time.sleep(0.5)

print(len(gorulen), "sayfa tarandi")

Neden HEAD değil GET diye sorulabilir. HEAD isteği daha hafif, ama bazı sunucular HEAD isteğine farklı cevap veriyor ve başlık ile meta bilgisini zaten gövdeden okumam gerekiyor. PDF ve görsel gibi HTML olmayan kaynaklar da kuyruğa giriyor; onların yalnızca durum kodu kaydediliyor, gövdesi ayrıştırılmıyor. Böylece bir sayfadan bağlanan bozuk bir PDF de raporda görünüyor.

Büyük sitelerde bu tarama dakikalar değil saatler sürebilir; o durumda gorulen kümesini bir dosyaya yazıp kaldığı yerden devam etmesini sağlamak mantıklı. Tarayıcının robots.txt kurallarına uymasını istiyorsan ilk betikteki rp.can_fetch kontrolünü kuyruğa ekleme adımına taşıman yeterli. Birkaç yüz sayfalık sitelerde bu haliyle sorunsuz çalıştı; daha büyüğü için önce bekleme süresini ve zaman aşımını gözden geçirirdim.

Başlık, meta açıklama ve kanonik denetimi

Veri bir kez SQLite'ta olunca kontrollerin çoğu SQL sorgusuna dönüşüyor. Kod yazmak yerine sorgu yazıyorsun, bu da eşikleri değiştirmeyi kolaylaştırıyor. Kullandığım kurallar:

  • Başlık: boş ya da 30 karakterden kısa başlıklar genellikle ihmal edilmiş sayfalar; 60'ın üzerindekiler arama sonuçlarında kesilebiliyor. Kesin bir sınır değil, uyarı eşiği olarak kullanıyorum.
  • Meta açıklama: 70 ile 160 karakter arasını kabul ediyorum. Eksik meta açıklama tek başına sıralamayı belirlemez, ama arama motoru sayfadan kendi seçtiği bir parçayı gösterir ve o parça nadiren iyi görünür.
  • Kanonik: etiket yoksa ya da sayfanın kendi adresinden farklı bir yere işaret ediyorsa listeye giriyor. Sondaki eğik çizgi ve http/https farkları burada yanlış pozitif üretir; karşılaştırmadan önce iki tarafı da normalize etmek gerekiyor.
  • Tekrarlar: aynı başlığı taşıyan sayfaları GROUP BY baslik ve HAVING COUNT(*) ile bulmak tek satırlık iş.

Bu eşiklerin hiçbiri kutsal değil. Önemli olan sitenin tamamı için tek bir kuralın olması ve o kuralın her seferinde aynı şekilde uygulanması. Elle bakarken "bu sayfa özel, olsun" deme eğilimi var; sorgu bunu yapmıyor.

Sorguları ilk kez çalıştırınca liste uzun çıkıyor; bu normal. Ben önce 404 dönen sayfalara bağlanan linkleri, sonra boş başlıkları, en son meta uzunluklarını ele alıyorum. Sıralama önemli, çünkü ilk iki grup ziyaretçinin doğrudan gördüğü bir hatayı, üçüncüsü yalnızca arama sonuçlarındaki görünümü etkiliyor.

Sonuçları CSV ve SQLite ile raporlamak

Üçüncü betik önce link tablosundaki her hedefin durum kodunu sayfa tablosundan çekip dolduruyor, sonra dört ayrı CSV üretiyor. CSV'yi tercih etmemin nedeni basit: kimin eline geçerse geçsin Excel'de açılıyor. Excel ile kalite kontrol otomasyonu yazısında anlattığım akışta da aynı mantık vardı: ham veri veri tabanında kalır, insanın bakacağı şey düz bir tablo olur.

import csv
import sqlite3

db = sqlite3.connect("denetim.sqlite")
db.execute("""UPDATE link SET durum =
    (SELECT durum FROM sayfa WHERE sayfa.url = link.hedef)""")
db.commit()

raporlar = {
    "kirik-linkler.csv":
        "SELECT kaynak, hedef, durum FROM link "
        "WHERE durum >= 400 OR durum = 0 OR durum IS NULL",
    "baslik-sorunlari.csv":
        "SELECT url, baslik_uz, baslik FROM sayfa "
        "WHERE durum = 200 AND (baslik_uz < 30 OR baslik_uz > 60)",
    "meta-sorunlari.csv":
        "SELECT url, meta_uz, meta FROM sayfa "
        "WHERE durum = 200 AND (meta_uz < 70 OR meta_uz > 160)",
    "kanonik-sorunlari.csv":
        "SELECT url, kanonik FROM sayfa "
        "WHERE durum = 200 AND (kanonik IS NULL OR kanonik != url)",
}

for dosya, sorgu in raporlar.items():
    imlec = db.execute(sorgu)
    with open(dosya, "w", newline="", encoding="utf-8") as f:
        yazici = csv.writer(f)
        yazici.writerow([k[0] for k in imlec.description])
        yazici.writerows(imlec)
    print(dosya, "yazildi")

Kırık link sorgusundaki durum IS NULL koşulu bilerek orada: bir hedef herhangi bir nedenle taranmadıysa satır boş kalır ve bunu "sorun yok" diye okumak yerine raporda görmek isterim. Dört ayrı dosya üretmemin nedeni de pratik; kırık linkleri geliştirici, başlık ve meta sorunlarını içeriği yazan kişi düzeltiyor, herkesin kendi listesi oluyor.

SQLite'ın asıl faydası zaman içinde ortaya çıkıyor. Her taramayı ayrı bir dosyaya değil aynı veri tabanına yazıp tarih sütunuyla ayırırsan, "geçen hafta 200 dönen hangi sayfa bu hafta 404 oldu" sorusunu iki tarama arasında bir JOIN ile cevaplayabiliyorsun. Tek seferlik denetim yerine bir zaman serisi elde ediyorsun; asıl değer orada.

Zamanlama ve işletme

Üç betiği tek bir denetim.py altında toplayıp haftada bir çalıştırıyorum. Linux'ta cron, Windows'ta Görev Zamanlayıcı bu iş için yeterli; PowerShell ile sistem izleme betiği yazısında anlattığım zamanlama ve loglama düzeni burada da birebir geçerli. Dikkat ettiğim birkaç nokta:

  • Betik hata verdiğinde sessiz kalmamalı. Boş CSV üretip "temiz" demekle hiç çalışmamak dışarıdan aynı görünür; bu yüzden çıkışta taranan sayfa sayısını da loga yazıyorum ve sayı sıfırsa sonucu geçerli saymıyorum.
  • Kendi sitende bile User-Agent belirt. Sunucu loglarında tarayıcını tanımak, bir sorun çıktığında zaman kazandırıyor.
  • Denetim çıktısı bir görev listesi değil, bir işaret listesi. Her satır incelenmeyi hak eder; her satır düzeltilmeyi hak etmez.
  • Taramayı Search Console'daki kapsam raporuyla karşılaştırmak iyi bir alışkanlık. İki kaynak farklı şeyler söylüyorsa genellikle biri bayat; hangisinin bayat olduğunu bulmak denetimin kendisinden daha öğretici oluyor.

Bu yaklaşımın en sevdiğim yanı, bir kez kurulduktan sonra bakım istememesi. Site büyüdükçe kontrol listesi büyümüyor, sadece SQL sorgularına bir iki satır ekleniyor. Kendi sitenizde benzer bir denetim hattı kurmak ya da mevcut otomasyonunuzu gözden geçirmek isterseniz iletişim bölümünden yazabilirsiniz; nasıl çalıştığımı da ana sayfada kısaca özetledim.