Streamlit uygulamasını arama motorlarına açmak: statik sayfalar ve ASGI ara katmanı

Streamlit sayfaları tarayıcıda çizildiği için arama motoruna boş görünür. Statik içerik sayfaları, rota künyesi ve gerçek 404 ile bunu nasıl çözdük.

Yazar: TS SystemLabs mühendislik ekibi · Yayın: · 9 dk okuma

Streamlit, Python ile veri uygulaması yazmanın en hızlı yollarından biri. Birkaç yüz satırla grafik, tablo, form ve filtre içeren bir ekran ayağa kalkar; ön yüz için ayrıca JavaScript yazmanız gerekmez. Biz de StockFortuna ve VedikRehber gibi ürünlerde Streamlit kullanıyoruz. Ancak bir uygulamayı ziyaretçiye açmak ile arama motorlarına açmak aynı şey değil. Streamlit'in çalışma biçimi, ilk bakışta fark edilmeyen ama arama görünürlüğünü doğrudan etkileyen birkaç sorun yaratıyor.

Bu yazıda bu sorunları ve kendi ürünlerimizde uyguladığımız iki çözümü anlatıyoruz: arama motorunun okuyabileceği statik içerik sayfaları üretmek ve Streamlit sunucusunun önüne küçük bir ASGI ara katmanı koymak. Kod örnekleri sade tutuldu; Starlette ya da başka bir çerçeveye bağımlı değiller, ASGI arayüzünü konuşan her sunucuda çalışırlar.

Sorun: arama motoru boş bir kabuk görüyor

Bir Streamlit sayfasının ham HTML'ine baktığınızda içerik göremezsiniz. Sunucu her adres için aynı küçük kabuğu döndürür: birkaç meta etiketi, bir JavaScript paketi ve "bu uygulamayı çalıştırmak için JavaScript gerekir" anlamına gelen bir not. Asıl içerik, tarayıcı paketi yükleyip sunucuyla bir WebSocket bağlantısı kurduktan sonra, Python betiğiniz çalıştıkça parça parça çizilir.

Bunun arama görünürlüğüne üç etkisi var:

  • İçerik ham HTML'de yok. Google'ın oluşturma hizmeti JavaScript çalıştırabilir, ancak içerik bir WebSocket oturumu üzerinden, betiğin çalışma süresine bağlı olarak geldiği için sonucun güvenilir olmadığını kendi denemelerimizde gördük. Diğer arama motorları ve yapay zekâ tarayıcıları çoğunlukla yalnız ham HTML'i okur.
  • Her adres aynı başlığı taşır. Kabuk tek olduğu için <title>, açıklama ve kanonik adres de tektir. Çok sayfalı bir uygulamada bütün sayfalar arama sonuçlarında aynı başlıkla görünür ya da birbirinin kopyası sayılır.
  • Olmayan adresler 200 döner. Tek sayfalık uygulama mantığı gereği bilinmeyen bir adres de kabuğu alır. Arama motoru açısından bu bir "soft 404"tür: yanlış yazılmış ya da eskimiş bağlantılar dizinde gerçek sayfa gibi birikir.

Ayrıca canlı sistemde karşılaştığımız bir yan etki de oldu: diskteki bir klasöre ait adres (/sozluk) bir katmanda sonuna eğik çizgi eklenerek, başka bir katmanda eğik çizgi silinerek yönlendiriliyordu. Sonuç, tarayıcının sonsuz yönlendirme hatası vermesiydi. Kullanıcı fark etmeden önce bunu ancak adresleri tek tek ölçerek bulabildik.

Birinci çözüm: statik içerik sayfaları üretmek

Arama motoruna göstermek istediğiniz içeriğin büyük kısmı aslında etkileşim gerektirmez. Bir hisse senedinin özet analizi, bir teknik analiz teriminin sözlük açıklaması ya da bir burcun haftalık yorumu, sayfa açıldığında okunacak metindir. Bu içerikleri Streamlit'e çizdirmek yerine, aynı veriden düz HTML dosyaları üretiyoruz. Uygulama yine Streamlit'te çalışıyor; statik sayfalar ise kapı görevi görüyor ve okuyucuyu ilgili etkileşimli ekrana bağlıyor.

Üretim betiği basit bir döngü: veriyi okur, her kayıt için bir HTML dosyası yazar ve aynı anda site haritası satırlarını biriktirir. Önemli olan, her dosyanın kendi başlığını, açıklamasını, kanonik adresini ve dil alternatiflerini taşıması.

import html
from pathlib import Path

SITE = "https://ornek.com"
CIKTI = Path("static/sozluk")

TERIMLER = [
    {"slug": "rsi", "tr": ("RSI nedir?", "Göreli güç endeksinin hesabı ve yorumu."),
     "en": ("What is RSI?", "How the relative strength index is calculated and read.")},
]

def sayfa(dil, baslik, aciklama, yol, alternatifler, govde):
    e = html.escape
    linkler = "\n".join(
        f'<link rel="alternate" hreflang="{d}" href="{SITE}{y}">' for d, y in alternatifler)
    return f"""<!doctype html>
<html lang="{dil}">
<head><meta charset="utf-8">
<title>{e(baslik)}</title>
<meta name="description" content="{e(aciklama)}">
<link rel="canonical" href="{SITE}{yol}">
{linkler}
</head><body><h1>{e(baslik)}</h1>{govde}</body></html>"""

haritalar = []
for t in TERIMLER:
    alternatifler = [("tr", f"/sozluk/{t['slug']}/"), ("en", f"/en/sozluk/{t['slug']}/")]
    for dil, yol in alternatifler:
        baslik, aciklama = t[dil]
        hedef = Path("static") / yol.strip("/") / "index.html"
        hedef.parent.mkdir(parents=True, exist_ok=True)
        govde = f"<p>{html.escape(aciklama)}</p>"
        hedef.write_text(sayfa(dil, baslik, aciklama, yol, alternatifler, govde), encoding="utf-8")
        haritalar.append(f"{SITE}{yol}")

Path("static/sitemap-sozluk.xml").write_text(
    '<?xml version="1.0" encoding="UTF-8"?>\n'
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
    + "".join(f"  <url><loc>{html.escape(u)}</loc></url>\n" for u in haritalar)
    + "</urlset>\n", encoding="utf-8")

Bu yaklaşımın birkaç pratik kuralı var. Birincisi, statik sayfa gerçek bilgi taşımalı; yalnız "uygulamayı açın" diyen ince sayfalar arama motorları tarafından düşük değerli içerik olarak görülür. İkincisi, her dil sürümü kendi adresinde durmalı ve hreflang yalnız gerçekten var olan çevirileri göstermeli. Üçüncüsü, sayfaları her veri güncellemesinde yeniden üretmek gerekir; bunu gece görevine bağlamak, elle unutulmasını önler. Streamlit'in kendi statik dosya sunumu dosyaları yalnız /app/static/ altında yayınlar; sayfaların kök adreste (/sozluk/ gibi) açılması için ya önde bir ters vekil sunucu kullanmak ya da kök klasörü sunan statik dosya işleyicisini başlatmada sarmalamak gerekir. Aşağıdaki katman bu ikinci yolun parçasıdır.

İkinci çözüm: sunucunun önüne ASGI ara katmanı

Statik sayfalar içerik sorununu çözer; ama adres davranışı, başlık tekrarı ve soft 404 hâlâ uygulamanın kendisinde durur. Bunları düzeltmek için gelen isteği Streamlit'e ulaşmadan önce görebilen bir katmana ihtiyaç var. Streamlit'in yeni sürümlerindeki Starlette tabanlı sunucu ASGI arayüzünü kullandığı için, bu katmanı sıradan bir ASGI ara katmanı olarak yazabiliyoruz.

ASGI'de bir uygulama, scope, receive ve send alan bir eş zamansız çağrılabilir nesnedir. Ara katman da aynı imzaya sahip bir sarmalayıcıdır: isteği inceler, isterse kendisi cevap verir, istemezse içerideki uygulamaya aktarır. İlk örnek, her HTTP yanıtına eksikse güvenlik başlıklarını ekliyor. WebSocket ve yaşam döngüsü mesajlarına dokunmuyor; bu, Streamlit'in WebSocket bağlantısının bozulmaması için önemli.

GUVENLIK_BASLIKLARI = (
    (b"x-content-type-options", b"nosniff"),
    (b"referrer-policy", b"strict-origin-when-cross-origin"),
    (b"x-frame-options", b"SAMEORIGIN"),
)

class GuvenlikBasliklari:
    def __init__(self, uygulama):
        self.uygulama = uygulama

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.uygulama(scope, receive, send)
            return

        async def gonder(mesaj):
            if mesaj["type"] == "http.response.start":
                basliklar = list(mesaj.get("headers", []))
                var = {ad.lower() for ad, _ in basliklar}
                basliklar += [(ad, deger) for ad, deger in GUVENLIK_BASLIKLARI if ad not in var]
                mesaj = dict(mesaj, headers=basliklar)
            await send(mesaj)

        await self.uygulama(scope, receive, gonder)

Bilinmeyen adrese gerçek 404

İkinci ara katman, uygulamanın tanıdığı rotaların listesini tutar. İstek bu listede yoksa, diskte karşılığı olan bir dosya değilse ve Streamlit'in kendi iç uç noktalarından biri değilse, sade bir 404 sayfası döndürür. Rota listesini elle tutmak yerine uygulama dosyasından okumak mümkün; biz sayfa tanımlarını kaynak koddan ayrıştırarak elde ediyoruz, böylece yeni bir sayfa eklendiğinde liste kendiliğinden güncelleniyor. Aşağıdaki örnekte sadelik için sabit bir küme kullandık.

import os

BILINEN_ROTALAR = {"", "borsalar", "hisse", "iletisim"}
KORUNAN_ONEKLER = ("_stcore", "static", "media", "component")
SAYFA_404 = ("<!doctype html><html lang=\"tr\"><meta charset=\"utf-8\">"
             "<title>Sayfa bulunamadı</title><h1>404</h1>"
             "<p><a href=\"/\">Ana sayfaya dön</a></p>").encode("utf-8")

def diskte_var(kok, yol):
    parcalar = [p for p in yol.split("/") if p]
    if any(p in (".", "..") or "\\" in p for p in parcalar):
        return False
    tam = os.path.join(kok, *parcalar)
    return os.path.isfile(tam) or os.path.isfile(os.path.join(tam, "index.html"))

class GercekDortYuzDort:
    def __init__(self, uygulama, kok):
        self.uygulama = uygulama
        self.kok = kok

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http" and scope["method"] in ("GET", "HEAD"):
            rota = scope["path"].strip("/")
            bilinen = rota in BILINEN_ROTALAR or rota.split("/")[0] in KORUNAN_ONEKLER
            if not bilinen and not diskte_var(self.kok, scope["path"]):
                await send({"type": "http.response.start", "status": 404, "headers": [
                    (b"content-type", b"text/html; charset=utf-8"),
                    (b"content-length", str(len(SAYFA_404)).encode()),
                    (b"cache-control", b"no-cache"),
                    (b"x-robots-tag", b"noindex"),
                ]})
                await send({"type": "http.response.body", "body": SAYFA_404})
                return
        await self.uygulama(scope, receive, send)

diskte_var fonksiyonundaki .. denetimi gereksiz gibi görünebilir, ama dışarıdan gelen bir yolu dosya sistemine çevirdiğiniz her yerde şart. Korunan önekler listesi de kritik: Streamlit'in WebSocket ve sağlık uçlarını yanlışlıkla 404'e düşürürseniz uygulama hiç açılmaz. Yükseltmeden sonra bu listenin hâlâ doğru olduğunu doğrulamak gerekir.

Klasör adresleri ve rota künyesi

Statik sayfaları klasör içinde index.html olarak ürettiğimiz için iki kural koyduk: /sozluk/ isteği doğrudan klasördeki index.html dosyasına çevrilir ve 200 döner; /sozluk isteği ise 301 ile /sozluk/ adresine gönderilir. Bu katmanın sardığı iç uygulamanın kok klasörünü kökten sunan bir statik dosya uygulaması olduğunu varsayıyoruz (bizde başlatmada sarmalanan statik işleyici; başka bir kurulumda bir ters vekil ya da Starlette'in StaticFiles bağlaması olabilir). Yönlendirme hedefini mutlak değil göreli yazmak önemli: ters vekil arkasında uygulama isteği HTTP olarak görür ve mutlak adres üretirse ziyaretçiyi HTTPS'ten HTTP'ye düşürebilir.

import os
from urllib.parse import quote

class KlasorAdresi:
    def __init__(self, uygulama, kok):
        self.uygulama = uygulama
        self.kok = kok

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http" and scope["method"] in ("GET", "HEAD"):
            yol = scope["path"]
            parcalar = [p for p in yol.split("/") if p]
            guvenli = parcalar and not any(p in (".", "..") or "\\" in p for p in parcalar)
            if guvenli and os.path.isfile(os.path.join(self.kok, *parcalar, "index.html")):
                if yol.endswith("/"):
                    yeni = yol + "index.html"
                    scope = dict(scope, path=yeni, raw_path=quote(yeni).encode("ascii"))
                else:
                    hedef = quote(yol) + "/"
                    sorgu = scope.get("query_string", b"")
                    if sorgu:
                        hedef += "?" + sorgu.decode("latin-1")
                    await send({"type": "http.response.start", "status": 301,
                                "headers": [(b"location", hedef.encode("latin-1")),
                                            (b"content-length", b"0")]})
                    await send({"type": "http.response.body", "body": b""})
                    return
        await self.uygulama(scope, receive, send)

# Sıralama: dıştan içe
# uygulama = GuvenlikBasliklari(GercekDortYuzDort(KlasorAdresi(asgi_uygulamasi, KOK), KOK))

Rota künyesi için aynı katmanı genişletiyoruz: istek bilinen bir Streamlit rotasına geliyorsa, kabuk HTML'ini okuyup o rotaya ait başlığı, açıklamayı, kanonik adresi ve sosyal paylaşım etiketlerini sunucuda yazıyoruz. Böylece /borsalar ile /iletisim artık aynı başlığı taşımıyor. Değiştirme işlemi basit metin işlemi; değerleri mutlaka html.escape ile kaçışlamak gerekiyor:

import html
import re

def kunye_yaz(kabuk: str, baslik: str, aciklama: str, kanonik: str) -> str:
    e = html.escape
    yeni = (f"<title>{e(baslik)}</title>"
            f'<meta name="description" content="{e(aciklama)}">'
            f'<link rel="canonical" href="{e(kanonik)}">')
    kabuk = re.sub(r"<title>.*?</title>", "", kabuk, count=1, flags=re.S)
    return kabuk.replace("</head>", yeni + "</head>", 1)

Katmanı Streamlit'e bağlamak: sürüm uyarısı

Buraya kadar yazılan her şey standart ASGI. Zor kısım, bu katmanı Streamlit'in içine yerleştirmek. Streamlit, kendi sunucusunu kendisi kurar ve bunun için resmi bir ara katman ekleme noktası sunmaz. Biz Streamlit'in Starlette tabanlı sunucusunda (1.58 sürümüyle denendi), uygulama kurulmadan önce statik dosya ve ara katman listesini üreten iç fonksiyonları sarmalayan küçük bir başlatma betiği kullanıyoruz. Bu çalışıyor, ama iç modüllere dayandığı için resmi bir API değil; modül adları ve fonksiyon imzaları bir sonraki sürümde değişebilir.

Bu riski yönetmek için üç şey yapıyoruz. Başlatma betiğinde her yama kendi try/except bloğunda; biri başarısız olursa uygulama yine açılıyor ve günlüğe hangi adımın atlandığı yazılıyor. Streamlit sürümünü bağımlılık dosyasında sabitliyoruz. Ve her yükseltmeden sonra aynı adres listesini ölçen bir test koşuyoruz: ana sayfa 200, bilinmeyen adres 404, klasör adresi 301 ve ardından 200, her rotada farklı başlık. Daha az kırılgan bir yol arıyorsanız, aynı kuralları uygulamanın önündeki Nginx, Caddy ya da Traefik gibi bir ters vekilde de kurabilirsiniz; o zaman Streamlit'in iç yapısına hiç dokunmazsınız, ama rota listesini iki yerde tutmanız gerekir.

Önbellek, robots ve doğrulama

Önünde bir içerik dağıtım ağı olan sitelerde küçük ama can sıkıcı bir sorun daha çıktı: robots.txt, site haritaları ve llms.txt gibi kök dosyalar, statik dosya varsayılanlarıyla çok uzun süre önbellekte tutuluyordu. Site haritası güncellendiğinde arama motorları eski sürümü görmeye devam ediyordu. Bu dosyalar için Cache-Control: no-cache ya da kısa bir süre tanımlamak yeterli oldu.

Doğrulama için tarayıcıya değil ham HTTP yanıtına bakmak gerekiyor. curl -I ile durum kodlarını ve başlıkları, curl -s ile de her rotanın <title> değerini kontrol ediyoruz. Search Console'daki adres denetimi aracı, Google'ın sayfayı nasıl gördüğünü ayrıca gösterir; ama değişikliklerin dizine yansıması günler sürebileceği için yerel ve canlı ölçüm her zaman ilk adım olmalı.

Sonuç

Streamlit'i bırakmadan arama motorlarına okunabilir bir site sunmak mümkün. Okunacak içeriği statik sayfalara taşımak işin büyük kısmını çözer; adres davranışı, rota başına başlık, gerçek 404 ve güvenlik başlıkları ise küçük, çerçeveden bağımsız ASGI ara katmanlarıyla düzelir. Dikkat edilmesi gereken tek nokta, bu katmanı Streamlit'e bağlarken iç yapıya dayandığınızı bilmek ve her yükseltmeyi ölçerek yapmaktır. Bu yaklaşımı kullandığımız ürünleri StockFortuna ve VedikRehber sayfalarında inceleyebilirsiniz.

Benzer bir sistem mi kuruyorsunuz?

İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.

Bize yazın