Çok dilli Flask sitesi: URL önekleri, hreflang ve site haritası
Tek görünümü iki URL kuralıyla kaydetmek, dili çerez ve tarayıcıdan seçmek, hreflang ile kanoniği doğru basmak ve site haritasını üretmek.
Bir siteyi birden çok dilde yayınlamak ilk bakışta çeviri işi gibi görünür: metinleri bir sözlüğe taşırsınız, şablonda doğru anahtarı çağırırsınız ve iş biter. Pratikte zor kısım metin değil, adreslerdir. Her dilin kendi kalıcı URL'si olmalı, ziyaretçi ilk gelişte kendi diline yönlenmeli ama seçimini değiştirebilmeli, arama motoru hangi sayfanın hangi sayfanın çevirisi olduğunu anlamalı, çevirisi olmayan sayfa ise başka bir dilin kopyası gibi görünmemelidir.
Bu yazıda, okuduğunuz sitenin kendi Flask kodunda kullandığımız düzeni anlatıyoruz. Site Türkçe, İngilizce, Almanca ve İspanyolca yayınlanıyor; Türkçe kökte (/), diğer diller önekle (/en/, /de/, /es/) duruyor. Kod örneklerini sadeleştirdik ama her biri tek başına çalışacak biçimde.
URL şemasını baştan seçmek
Çok dilli bir sitede üç yaygın seçenek vardır: dil başına ayrı alan adı, alt alan adı ya da yol öneki. Biz yol önekini seçtik, çünkü tek bir uygulama, tek bir sertifika ve tek bir Search Console mülküyle çalışmak küçük bir ekip için en az bakım gerektiren yol. Bir dili varsayılan sayıp öneksiz bırakmak da bilinçli bir karar: sitenin ilk dili Türkçe ve mevcut bağlantıların hiçbiri değişmesin istedik.
Önemli bir ayrıntı daha: yol parçalarını dile göre çevirmedik. /hakkinda sayfasının İngilizcesi /en/hakkinda adresinde. Çevrilmiş yollar (/en/about) daha okunaklı olabilir, ancak her rota için dil başına ayrı eşleme tablosu tutmayı ve bağlantı üretirken bu tabloya bakmayı gerektirir. Aynı yolu korumak, bir sayfanın her dildeki karşılığını tek satırda hesaplamamızı sağlıyor; bu kolaylık aşağıdaki hreflang ve site haritası kodunun da temeli.
Tek görünüm, iki URL kuralı
Her sayfa görünümünü iki kez kaydediyoruz: bir kez öneksiz (Türkçe varsayılanla), bir kez /<dil> önekiyle. Bunu her rotada elle yazmamak için küçük bir dekoratör kullanıyoruz. Önekli kuralın uç nokta adı _dil ekiyle bitiyor; böylece şablonda hangi kuralın üretileceği açıkça seçilebiliyor.
from flask import Flask, abort, g
DILLER = ("tr", "en", "de", "es")
VARSAYILAN = "tr"
app = Flask(__name__)
def rota(yol, **opts):
"""Aynı görünümü öneksiz (TR) ve /<dil> önekli iki kuralla kaydeder."""
def sarmal(f):
app.add_url_rule(yol, view_func=f, defaults={"dil": VARSAYILAN}, **opts)
onekli = "/<dil>" + ("" if yol == "/" else yol)
app.add_url_rule(onekli + ("/" if yol == "/" else ""), view_func=f,
endpoint=f.__name__ + "_dil", **opts)
return f
return sarmal
@app.url_value_preprocessor
def dil_al(endpoint, values):
if values and "dil" in values:
dil = values.pop("dil")
if dil not in DILLER:
abort(404)
g.dil = dil
@app.url_defaults
def dil_ver(endpoint, values):
if endpoint and endpoint.endswith("_dil"):
values.setdefault("dil", getattr(g, "dil", VARSAYILAN))
@rota("/hakkinda")
def hakkinda():
return f"Sayfa dili: {g.dil}"
url_value_preprocessor, dil değerini URL değişkenlerinden çıkarıp g.dil içine koyuyor; böylece görünüm fonksiyonlarının hiçbiri dil parametresi almak zorunda kalmıyor. Desteklenmeyen bir önek (/xx/hakkinda) doğrudan 404 dönüyor; aksi hâlde sonsuz sayıda sahte dil adresi üretilebilirdi. url_defaults ise ters yönde çalışıyor: şablonda url_for('hakkinda_dil') yazdığınızda o anki dili kendiliğinden ekliyor.
Bu düzende /tr/hakkinda adresi de teknik olarak açılır, çünkü tr geçerli bir dil kodu. Bu adresi kanonik etiketle çözüyoruz: sayfanın kanonik adresi her zaman öneksiz Türkçe sürüm olarak basılıyor. İsterseniz önekli varsayılan dili ayrıca 301 ile öneksiz adrese yönlendirebilirsiniz.
Ziyaretçinin dilini seçmek: tarayıcı, çerez ve kök yönlendirmesi
Ziyaretçi siteye ilk kez kök adresten geldiğinde tarayıcının Accept-Language başlığına bakıyoruz. Bu başlık birden çok dili ağırlıklarıyla (q değeri) sıralar; yalnız ilk dile bakmak yanlış sonuç verebilir. Örneğin en-GB;q=0.7,en;q=0.8,de;q=0.9 başlığında en yüksek ağırlık Almancadadır.
def tarayici_dili(accept_language):
"""Accept-Language başlığından desteklenen en yüksek ağırlıklı dil; yoksa varsayılan."""
if not accept_language:
return VARSAYILAN
adaylar = []
for parca in accept_language.split(","):
p = parca.strip().split(";")
kod = p[0].strip().lower()[:2]
q = 1.0
for ek in p[1:]:
ek = ek.strip()
if ek.startswith("q="):
try:
q = float(ek[2:])
except ValueError:
q = 0.0
if kod in DILLER:
adaylar.append((q, kod))
if not adaylar:
return VARSAYILAN
adaylar.sort(key=lambda x: -x[0])
return adaylar[0][1]
assert tarayici_dili("en-GB;q=0.7,en;q=0.8,de;q=0.9") == "de"
assert tarayici_dili("en-GB,en;q=0.8,de;q=0.9") == "en"
Sıralama kararlı olduğu için eşit ağırlıkta başlıktaki ilk dil kazanıyor. Tarayıcı tercihi yalnız ilk izlenimdir; ziyaretçi dil seçiciden başka bir dil seçerse bunu bir dil çerezine yazıyoruz ve bundan sonra çerez tarayıcı başlığından önce geliyor.
Yönlendirmeyi yalnız kök adreste ve yalnız 302 ile yapıyoruz. Ziyaretçi /en/hakkinda gibi bir iç adresi açtığında, hangi dilde olursa olsun o sayfayı görür; bir bağlantıyı paylaşan kişinin karşı tarafa başka bir sayfa göstermesi istemediğimiz bir sürpriz olurdu. Kök yönlendirmesi kalıcı değildir, çünkü sonucu ziyaretçiye bağlıdır; bu yüzden yanıtın Vary: Accept-Language, Cookie başlığı taşıması gerekir ki ara önbellekler bir ziyaretçinin yönlendirmesini başkasına sunmasın.
from flask import redirect, request, url_for
@app.before_request
def kok_yonlendir():
if request.path != "/" or request.method != "GET" or "dil" in request.args:
return None
tercih = request.cookies.get("dil")
if tercih not in DILLER:
tercih = tarayici_dili(request.headers.get("Accept-Language"))
if tercih != VARSAYILAN:
yanit = redirect(url_for("ana_dil", dil=tercih), code=302)
yanit.headers["Vary"] = "Accept-Language, Cookie"
return yanit
return None
@app.after_request
def dil_cerezi(yanit):
secim = request.args.get("dil")
if secim not in DILLER:
uc = request.endpoint or ""
secim = g.get("dil") if uc.endswith("_dil") else None
if secim and request.cookies.get("dil") != secim:
yanit.set_cookie("dil", secim, max_age=60 * 60 * 24 * 365, samesite="Lax")
return yanit
@rota("/")
def ana():
return f"Ana sayfa: {g.dil}"
Burada küçük ama önemli bir sorun çıkıyor: İngilizce çerezi olan bir ziyaretçi dil seçicide "Türkçe"yi tıkladığında kök adrese gelir, çerez hâlâ İngilizce olduğu için yeniden /en/ adresine yönlenir. Bunu Türkçe bağlantılara ?dil=tr ekleyerek çözüyoruz. Sorgu parametresi varken kök yönlendirmesi çalışmıyor ve after_request çerezi Türkçeye çeviriyor. Kanonik ve hreflang adreslerinde bu parametreyi elbette basmıyoruz.
Arama motoru tarayıcıları çoğunlukla Accept-Language başlığı göndermez ve çerez tutmaz; bu yüzden kökte varsayılan dili görürler. Diğer dillere ulaşmaları için yönlendirmeye değil, her sayfadaki hreflang etiketlerine ve dil seçicideki düz bağlantılara güveniyoruz.
hreflang, x-default ve kanonik
hreflang etiketleri, bir sayfanın hangi adreslerde hangi dilde karşılığı olduğunu söyler. Google'ın beklentisi, her dil sürümünün kendisi dâhil bütün karşılıkları listelemesi ve listelerin karşılıklı olmasıdır: Türkçe sayfa İngilizceyi gösteriyorsa İngilizce sayfa da Türkçeyi göstermelidir. x-default, ziyaretçinin dili hiçbirine uymadığında hangi adresin kullanılacağını belirtir; bizde bu öneksiz Türkçe sürüm. Kanonik etiket ise her dil sürümünde o dilin kendi adresini gösterir. Bir dil sürümünün kanoniğini başka bir dile vermek, o sürümü arama sonuçlarından fiilen silmek anlamına gelir.
<link rel="canonical" href="{{ SITE_URL }}{{ kanonik_yol }}">
{% for d in hreflang_baglantilari %}
<link rel="alternate" hreflang="{{ d.hreflang }}" href="{{ SITE_URL }}{{ d.url }}">
{% endfor %}
<link rel="alternate" hreflang="x-default" href="{{ SITE_URL }}{{ varsayilan_url }}">
Bu kuralın iki bilinçli istisnası var. Birincisi gizlilik metni: yasal metni yalnız Türkçe tutuyoruz, çünkü yasal bir metnin çevirisi ancak hukuki açıdan gözden geçirildiğinde güvenilirdir. Diğer dillerdeki gizlilik adresleri menü ve altbilgi o dilde olsa da aynı Türkçe metni gösteriyor. Bu sayfalarda kanoniği Türkçe adrese veriyor ve hiç hreflang basmıyoruz; aynı metni dört dilin çevirisiymiş gibi göstermek yanıltıcı olurdu. Site haritasında da bu sayfa tek adresle yer alıyor.
İkincisi, şu an okuduğunuz yazılar bölümü. Yazılar yalnız Türkçe ve İngilizce yayınlanıyor. Almanca ya da İspanyolca bir yazı adresi açıldığında 301 ile İngilizce sürüme yönlendiriyoruz, hreflang listesine yalnız var olan iki çeviriyi koyuyoruz. Dil seçici bu sayfalarda Almanca ve İspanyolca için o dilin ana sayfasına gidiyor. Kural basit: hreflang yalnız gerçekten var olan çeviriye verilir. Var olmayan bir çeviriyi işaret eden etiket, arama motoruna yanlış bir harita çizer.
Site haritasında dil alternatifleri
hreflang bilgisi HTML yerine site haritasında da verilebilir. İkisini birlikte kullanmak zorunlu değil ama tutarlı oldukları sürece zararı yok; biz ikisini de aynı kaynaktan ürettiğimiz için çelişme riski düşük. Site haritasında her dil sürümü ayrı bir <url> kaydıdır ve her kayıt bütün alternatifleri xhtml:link öğeleriyle listeler.
from flask import Response
def sitemap_uret(kok, yollar, diller=DILLER):
satirlar = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
'xmlns:xhtml="http://www.w3.org/1999/xhtml">']
def adres(yol, kod):
if kod == VARSAYILAN:
return yol
return "/" + kod + ("/" if yol == "/" else yol)
for yol in yollar:
for kod in diller:
satirlar.append(f" <url><loc>{kok}{adres(yol, kod)}</loc>")
for k2 in diller:
satirlar.append(f' <xhtml:link rel="alternate" hreflang="{k2}" '
f'href="{kok}{adres(yol, k2)}"/>')
satirlar.append(f' <xhtml:link rel="alternate" hreflang="x-default" '
f'href="{kok}{yol}"/>')
satirlar.append(" </url>")
satirlar.append("</urlset>")
return "\n".join(satirlar) + "\n"
@app.get("/sitemap.xml")
def sitemap():
xml = sitemap_uret("https://ornek.com", ["/", "/hakkinda"])
return Response(xml, mimetype="application/xml")
Yazılar bölümü için aynı fonksiyonu diller=("tr", "en") ile çağırmak yeterli. Her kayda bir lastmod eklemeyi de öneririz; ancak bu tarih gerçekten içeriğin değiştiği günü göstermeli. Her istekte bugünün tarihini basmak, arama motorlarının bu alana güvenmemesine yol açar. Yeni ya da değişen adresleri Bing ve Yandex gibi motorlara hemen bildirmek için ayrıca IndexNow yazımızda anlattığımız yolu kullanıyoruz.
Eksik çeviriyi testle yakalamak
Çok dilli sitelerin en sinsi hatası sessiz eksik çeviridir. Türkçe şablona yeni bir anahtar eklenir, diğer dillere eklenmesi unutulur ve sayfa o dilde Türkçe bir parçayla yayına çıkar. Bizde eksik anahtar Türkçeye düşüyor, yani sayfa çökmüyor; bu iyi bir emniyet ama hatayı görünmez de yapıyor. Bu yüzden bütün dil dosyalarının Türkçe sözlükteki her anahtarı taşıdığını denetleyen bir test yazdık. Ayrıca ürün, modül ve güncelleme listelerinin çevirilerinin kaynak listeyle aynı uzunlukta olduğunu da kontrol ediyor; listeler sıraya göre eşlendiği için bir satırın eksik olması bütün çevirileri bir satır kaydırırdı.
import ceviri
import ceviri_de
import ceviri_en
import ceviri_es
def test_ceviri_anahtarlari_tam():
for modul in (ceviri_en, ceviri_de, ceviri_es):
eksik = set(ceviri.UI_TR) - set(modul.UI)
assert not eksik, (modul.__name__, sorted(eksik))
def test_tum_sayfalar_dort_dilde(client):
for yol in ["/", "/hakkinda", "/iletisim"]:
for dil in ["en", "de", "es"]:
adres = f"/{dil}/" if yol == "/" else f"/{dil}{yol}"
yanit = client.get(adres)
assert yanit.status_code == 200, adres
assert f'<html lang="{dil}">' in yanit.get_data(as_text=True)
Bu testler yeni bir metin eklendiğinde geliştiriciyi üç dosyayı daha güncellemeye zorluyor. İlk bakışta yük gibi görünüyor, ama yayına çıkmış yarım bir çeviriyi sonradan fark edip düzeltmekten çok daha ucuz. Aynı testler sayfaların her dilde açıldığını ve lang özniteliğinin doğru basıldığını da güvence altına alıyor.
Sonuç
Çok dilli bir Flask sitesinin iskeleti birkaç küçük parçadan oluşuyor: aynı görünümü iki URL kuralıyla kaydeden bir dekoratör, dili URL'den alıp geri ekleyen iki kanca, yalnız kökte çalışan ve Vary başlığı taşıyan bir yönlendirme, ziyaretçinin seçimini hatırlayan bir çerez, hreflang ve site haritasını aynı kaynaktan üreten şablon kodu ve eksik çeviriyi yakalayan testler. Bu parçaların her biri tek başına basit. Asıl özen, istisnalarda gerekiyor: çevirisi olmayan bir sayfayı çeviri varmış gibi göstermemek, kanoniği doğru dile vermek ve arama motoruna yalnız gerçekten var olan alternatifleri bildirmek. Benzer bir yapı kurmayı düşünüyorsanız sitenin güncellemeler sayfasında bu düzenin nasıl geliştiğini izleyebilir ya da bize yazabilirsiniz.
Benzer bir sistem mi kuruyorsunuz?
İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.
Bize yazın