diff --git a/.gitignore b/.gitignore
index 09ea8f9..eed9051 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,6 @@ next-env.d.ts
# Python
__pycache__/
*.pyc
+
+# YÖK Atlas ham API çekimleri (scripts/refresh.ts; ~yıllık, büyük, yeniden üretilebilir)
+data/ham/
diff --git a/data/yokatlas.db b/data/yokatlas.db
index 1258876..bd2ed1f 100644
Binary files a/data/yokatlas.db and b/data/yokatlas.db differ
diff --git a/docs/veri/yillik-guncelleme.md b/docs/veri/yillik-guncelleme.md
new file mode 100644
index 0000000..eab6772
--- /dev/null
+++ b/docs/veri/yillik-guncelleme.md
@@ -0,0 +1,230 @@
+# Yıllık veri güncellemesi (YÖK Atlas → `data/yokatlas.db`)
+
+Her yıl yerleştirme sonuçları açıklandıktan sonra **bir kez** yapılır. Bu sayfayı
+yukarıdan aşağıya izleyen biri, betiklerin içini bilmeden güncellemeyi
+tamamlayabilir. Toplam süre: yaklaşık **30 dakika** (çoğu bekleme ve gözle kontrol).
+
+> Ürünün bütün değeri bu verinin doğruluğuna dayanır. Yanlış bir taban sıralaması,
+> bir öğrencinin yanlış tercih yapması demektir. Bu yüzden betikler şüphede
+> **durur**; "DURDU:" ile başlayan bir mesaj hata değil, emniyet kilididir.
+> Kilidi aşmaya çalışma — mesajı oku, aşağıdaki "Bir şey durursa" tablosuna bak.
+
+Örneklerde yeni yıl `2027` diye yazıldı; o yıl hangisiyse onu yaz.
+
+## 0. Ne zaman?
+
+- ÖSYM yerleştirme sonuçlarını açıklar (genelde **Ağustos sonu**), YÖK Atlas verisini
+ birkaç gün–birkaç hafta sonra günceller. Tarih her yıl değişir → **doğrulanmalı**.
+- Erken koşmanın zararı yok: YÖK Atlas henüz güncellenmediyse 3. adım
+ "`… hâlâ 2026 verisi`" diyerek **hiçbir şey yazmadan** durur. Bir hafta sonra tekrar dene.
+- Dosya indirmen gerekmez. Veri, YÖK Atlas'ın herkese açık tercih kılavuzu
+ servisinden (`https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search`) betikle çekilir:
+ 43 istek, aralarında yarım saniye — kaynağı yormaz. Betiği art arda defalarca koşma;
+ ilk çekimden sonra hep kaydedilmiş ham dosyayı (`--ham`) kullan.
+
+## 1. Hazırlık (5 dk)
+
+```bash
+git switch -c veri/2027 # main'de çalışma
+```
+
+`src/lib/veri-yillari.ts` dosyasında **tek satırı** değiştir:
+
+```ts
+export const SON_YIL = 2027;
+```
+
+Betikler ve site yılı yalnız buradan okur. Betiğe verdiğin `--yil` bununla aynı
+değilse betik durur (yanlış kolona yazmayı önler).
+
+## 2. Kopya al — asıl dosyaya dokunma (1 dk)
+
+Dev sunucusu açıksa kapat. Sonra:
+
+```bash
+mkdir -p /tmp/veri-2027
+sqlite3 data/yokatlas.db ".backup '/tmp/veri-2027/once.db'" # güncelleme ÖNCESİ hâl (geri dönüş + karşılaştırma)
+cp /tmp/veri-2027/once.db /tmp/veri-2027/yeni.db # üzerinde çalışılacak kopya
+```
+
+Bundan sonraki her adım **`/tmp/veri-2027/yeni.db`** üzerinde koşar. `data/yokatlas.db`
+ancak 8. adımda, her şey yeşilken değişir.
+
+## 3. Yeni yılı çek (3 dk)
+
+```bash
+pnpm refresh --yil 2027 --db /tmp/veri-2027/yeni.db
+```
+
+Ne yapar: şemaya `sira2027/puan2027/kontenjan2027/yerlesen2027` kolonlarını ekler,
+**yıl kilidini** sınar, tüm kılavuzu çeker, ham hâlini `data/ham/2027-.jsonl.gz`
+olarak saklar (git'e girmez), sonra tek seferde DB'ye yazar.
+
+Görmen gerekenler:
+
+```
+Yıl kilidi: basariSirasi1 = sira2026 → 480/480 (%100.0); eksiz basariSirasi = sira2026 → 0/480 (%0.0)
+Çekilen: 21… kayıt, benzersiz kod: 21…, kodsuz: 0, API toplamı: 21… ← üç sayı aynı
+Güncellenen: 19…, eklenen: 1…, atlanan: 0
+```
+
+- **Yıl kilidi** şunu kanıtlar: servisin "bir önceki yıl" diye verdiği sıralar bizim
+ 2026 kolonumuzla birebir aynı **ve** "bu yıl" diye verdikleri ondan farklı. Yani
+ yazacağımız veri gerçekten 2027'dir. (Servisteki `yil` alanına güvenilmez; 2026'da
+ bu alan yüzünden 2025 verisi bozulmak üzereydi.)
+- "atlanan" 0 değilse satırlar altında listelenir: kaynağın puan türü vermediği yeni
+ programlardır; uydurulmaz. Sayıyı not et.
+- Bu betik mevcut programların **eski yıl kolonlarına dokunmaz**; dokunursa kendi
+ sağlaması bunu yakalar ve her şeyi geri alır.
+
+Aynı işlemi servise gitmeden tekrarlamak için (ör. 8. adımda):
+`pnpm refresh --yil 2027 --db --ham data/ham/2027-.jsonl.gz`
+
+## 4. Geçmiş boşluklarını doldur (1 dk)
+
+```bash
+pnpm gecmis-doldur --yil 2027 --db /tmp/veri-2027/yeni.db
+```
+
+Kılavuz kodu değişen ya da yeni koda taşınan programların eski yıllarını tamamlar.
+Yalnız **boş** alanı doldurur; dolu değeri asla ezmez, tahmin yapmaz. Kaynak sırası:
+servisin kendi geçmiş alanları → servisin bildirdiği eski kod → aynı üniversitede
+bire bir aynı adlı ve en az bir yılı kanıtla örtüşen tek satır.
+
+Çıktının sonundaki "**Dolu olup kaynağın FARKLI söylediği alanlar**" listesine bak.
+Birkaç onluk fark olağandır (kaynak eski sıraları ufak düzeltmelerle yeniden yayımlıyor;
+2026'da `sira2024` farklarının %95'i on binde birin altındaydı). **Binlerce satırda büyük
+fark** görürsen dur ve veri mühendisine sor.
+
+## 5. Kılavuzdan çıkan programlar: önce sadece raporla (1 dk)
+
+```bash
+pnpm eski-arsivle --yil 2027 --db /tmp/veri-2027/yeni.db --kuru
+```
+
+Hiçbir şey yazmaz. Şunu söyler: bu yılın kılavuzunda **olmayan** kaç satır var ve
+bunlar siteden kalkarsa hangi `/bolum/...` ve `/universite/...` sayfaları tamamen
+kapanır (adlarıyla). Bu liste **[BİLAL] kararıdır** — sayfa kapanışı SEO'yu etkiler,
+gerekirse yönlendirme (redirect) eklenir.
+
+Onaydan sonra gerçek taşıma (silmez; satırlar `programs_arsiv` tablosuna geçer):
+
+```bash
+pnpm eski-arsivle --yil 2027 --db /tmp/veri-2027/yeni.db
+```
+
+Neden gerekli: arşivlenmezse eski kodlu satırlar listelerde bir yıl önceki sırayla
+görünmeye devam eder; öğrenci o kodu tercih ekranında bulamaz, kodu değişen program
+iki kez listelenir.
+
+## 6. İndeks + dosyayı toparla (1 dk)
+
+```bash
+pnpm db:index --db /tmp/veri-2027/yeni.db --vacuum
+```
+
+Son satırda `USING INDEX idx_programs_tur_onlisans_efektif` yazmalı. Yazmıyorsa betik
+zaten hata verir: sitedeki sıralama aralığı sorguları yavaşlar.
+
+## 7. Kalite kapısı (1 dk)
+
+```bash
+pnpm veri-kalite --yil 2027 --db /tmp/veri-2027/yeni.db --onceki /tmp/veri-2027/once.db --arsivli
+echo "çıkış kodu: $?" # 0 olmalı
+```
+
+(`--arsivli`'yi yalnız 5. adımda gerçek arşivlemeyi yaptıysan ekle.)
+
+- Her satır `GEÇTİ`, `KALDI`, `UYARI` ya da `bilgi` ile başlar. **Tek bir `KALDI` varsa
+ yayına çıkılmaz**; çıkış kodu 1 olur.
+- En önemli iki satır:
+ - `önceki DB: geçmiş yıllar korunmuş — … değişen/silinen satır: 0`
+ - `sira2027/sira2026 ∈ [0,5; 2] — … (%9x)` — programların büyük çoğunluğunun sırası
+ bir yılda yarıya inmez, iki katına çıkmaz. Oran düşükse yıllar kaymıştır.
+- `UYARI`'lar bilgilendirir (ör. ili boş 110 program, yetim net satırları); yeni bir
+ uyarı türü ya da sayıda sıçrama varsa veri mühendisine göster.
+
+Elle 3 program seç (biri çok bilinen: ör. Boğaziçi Bilgisayar Mühendisliği), YÖK Atlas
+sitesindeki 2027 taban sırasıyla karşılaştır:
+
+```bash
+sqlite3 /tmp/veri-2027/yeni.db "SELECT id, universite, isim, sira2026, sira2027, puan2027 FROM programs WHERE isim LIKE 'Bilgisayar Mühendisliği%' AND universite LIKE 'BOĞAZİÇİ%';"
+```
+
+## 8. Yerine koy ve siteyi kontrol et (5 dk)
+
+```bash
+cp /tmp/veri-2027/yeni.db data/yokatlas.db
+rm -f data/yokatlas.db-wal data/yokatlas.db-shm # eski dosyanın artıkları; yenisi temiz (checkpoint yapılmış)
+pnpm build # katalog sayfaları yeni veriden üretilir; hata vermemeli
+```
+
+Netler (yerleşen son kişinin netleri) ayrı bir servisten gelir ve genelde daha geç
+güncellenir; hazır olduğunda:
+
+```bash
+pnpm detay --yil 2027 --db data/yokatlas.db # ~127 istek, ~2 dk; yıl sağlaması tutmazsa durur
+```
+
+`--kirilim` bayrağını **kullanma**: kontenjan kırılımını (okul birincisi, şehit-gazi…)
+sitede okuyan kod yok ve servis bu alanları Eylül 2026'dan beri yayımlamıyor. Bayrak
+verilse bile alanlar yoksa adım kendini durdurur, mevcut değerlerin üstüne boş yazmaz.
+
+Yıl değişince koddaki metinler (`"son 6 yıl"`, başlıklardaki yıl vb.) `veri-yillari.ts`
+sabitlerinden türediği için kendiliğinden güncellenir. Elle yıl yazılmış yer kaldıysa bul:
+
+```bash
+grep -rnE "sira20[0-9]{2}|20(2[1-9])[–-]20" src scripts --include='*.ts' --include='*.tsx' | grep -v veri-yillari
+```
+
+Tadımlık havuzu ve kayıtlı raporlar eski sıralarla üretilmiştir → yayından sonra prod'da
+`pnpm tadimlik --force` (Bilal koşar; ayrıntı `scripts/tadimlik-uret.ts` başlığında).
+
+## 9. Commit ve yayın
+
+```bash
+git add src/lib/veri-yillari.ts data/yokatlas.db
+git commit -m "veri: 2027 yerleştirme verisi"
+```
+
+`data/ham/` git'e girmez (yerelde sakla; bir sonraki yıl karşılaştırma için işe yarar).
+Push öncesi `AGENTS.md`'deki "Push öncesi build zorunlu" kuralı geçerli. Yayın kararı Bilal'in.
+
+## Geri alma
+
+| Ne zaman | Nasıl | Süre |
+|---|---|---|
+| 8. adımdan önce | Hiçbir şey yapma; `data/yokatlas.db` hiç değişmedi. `/tmp/veri-2027/` silinebilir. | 0 |
+| 8. adımdan sonra, commit'ten önce | `git checkout -- data/yokatlas.db data/yokatlas.db-wal data/yokatlas.db-shm` | 1 dk |
+| Commit/yayından sonra | `git revert ` + yeniden yayın. `SON_YIL` da aynı commit'te geri döner; site bir önceki yılın verisiyle tutarlı çalışır. | 10 dk |
+| Yalnız arşivlemeyi geri almak | `INSERT INTO programs SELECT * FROM programs_arsiv; DELETE FROM programs_arsiv;` (kopyada dene, sonra `pnpm db:index`). Netler yeni kodda kalır; zararsız. | 5 dk |
+
+`/tmp/veri-2027/once.db` dosyasını yayından bir hafta sonrasına kadar silme.
+
+## Bir şey durursa
+
+| Mesaj | Anlamı | Ne yap |
+|---|---|---|
+| `--yil … ≠ SON_YIL` | 1. adım atlanmış | `veri-yillari.ts`'i güncelle |
+| `eksiz alanları hâlâ verisi` | YÖK Atlas henüz yeni yılı yayımlamamış | Bekle, bir hafta sonra tekrar dene |
+| `"…1" alanları DB'deki … verisiyle eşleşmiyor` | Servis bir yıl daha ilerlemiş (bir yılı atladık) **ya da** servis biçim değiştirmiş **ya da** yanlış DB | Yazma. Veri mühendisine göster |
+| `Benzersiz kod sayısı … ≠ API toplamı` | Çekim sırasında servis sayfaları kaydırdı | Birkaç dakika sonra 3. adımı tekrarla (DB'ye bir şey yazılmadı) |
+| `Aynı üniversitede birden fazla unitur` | Bir kurumun türü iki farklı yazılmış | Listelenen kurumları veri mühendisine göster; `scripts/unitur-onar.ts` |
+| `Güncel satır sayısı … refresh kaydıyla uyuşmuyor` (arşivle) | refresh yarım kalmış | 3. adımı tekrarla; arşivleme bilerek çalışmıyor |
+| `veri-kalite` içinde `KALDI` | O kontrol tutmadı | Yayına çıkma; satırdaki sayıyı ve örnek kodları veri mühendisine ilet |
+
+## Betikler ve dokundukları
+
+| Betik | Yazar mı? | Ne yazar |
+|---|---|---|
+| `scripts/yokatlas-goc.ts` (`pnpm yokatlas:goc`) | evet | Yalnız şema: yıl kolonları, `son_kilavuz_yili`, `eski_kod`, `programs_arsiv`, `veri_meta`. refresh bunu kendisi çağırır. |
+| `scripts/refresh.ts` | evet | `sira/puan/kontenjan/yerlesen`, `son_kilavuz_yili`, `eski_kod`; yeni programlar; ham dosya |
+| `scripts/gecmis-doldur.ts` | evet | Yalnız **NULL** geçmiş yıl alanları + `eski_kod` |
+| `scripts/eski-arsivle.ts` | `--kuru` ile hayır | Satır taşıma `programs` → `programs_arsiv`; `netler.program_id` yeni koda |
+| `scripts/db-index.ts` | evet | İndeks, `--vacuum`, WAL checkpoint |
+| `scripts/veri-kalite.ts` | **hayır** | Salt okunur; çıkış kodu 0/1 |
+| `scripts/detay.ts` | evet | `netler`; `--kirilim` ile (alanlar varsa) bir önceki yılın kırılım kolonları |
+
+Kolon sözlüğü: `son_kilavuz_yili` = programın görüldüğü son tercih kılavuzu (NULL = 2024
+ve öncesi, bilinmiyor). `eski_kod` = kılavuz kodu değiştiyse bir önceki kod — kayıtlı
+listelerdeki eski kodları yeni koda çevirmek için `SELECT id FROM programs WHERE eski_kod = ?`.
diff --git a/package.json b/package.json
index 61c60e9..a015fa9 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,11 @@
"ingest": "tsx scripts/ingest.ts",
"refresh": "tsx scripts/refresh.ts",
"detay": "tsx scripts/detay.ts",
+ "yokatlas:goc": "tsx scripts/yokatlas-goc.ts",
+ "gecmis-doldur": "tsx scripts/gecmis-doldur.ts",
+ "eski-arsivle": "tsx scripts/eski-arsivle.ts",
+ "veri-kalite": "tsx scripts/veri-kalite.ts",
+ "db:index": "tsx scripts/db-index.ts",
"logolar": "tsx scripts/logo-indir.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
diff --git a/scripts/db-index.ts b/scripts/db-index.ts
index 0fd6c43..58259f6 100644
--- a/scripts/db-index.ts
+++ b/scripts/db-index.ts
@@ -1,44 +1,83 @@
-// Sorgu yolundaki ifade indexi + WAL bakımı. Bir kez çalıştırılır
-// (pnpm tsx scripts/db-index.ts); ingest/refresh sonrası da güvenle
-// tekrar çalıştırılabilir (IF NOT EXISTS).
+// Sorgu yolundaki ifade indexi + WAL bakımı. refresh sonrası (ve istenince tek
+// başına) çalıştırılır; tekrar çalıştırmak güvenlidir.
//
-// src/lib/db.ts tüm aralık sorgularını COALESCE(sira2025, sira2024)
-// üzerinden yapar; SQLite ifade indexleri METİNSEL eşleştiği için buradaki
-// ifade oradaki EFEKTIF_SIRA sabitiyle birebir aynı olmalıdır. Bu index
-// olmadan her sorgu tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
+// src/lib/db.ts tüm aralık sorgularını EFEKTIF_SIRA_SQL üzerinden yapar;
+// SQLite ifade indexleri METİNSEL eşleştiği için indeks ifadesi
+// src/lib/veri-yillari.ts'teki sabitle birebir aynı olmalıdır — bu yüzden
+// buraya elle yazılmaz, import edilir. Bu index olmadan her sorgu
+// tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
+//
+// Kullanım: pnpm db:index --db data/yokatlas.db [--vacuum]
+// --vacuum: arşivleme gibi büyük silmelerden sonra dosyayı küçültür (site kapalıyken).
-import path from "node:path";
-import Database from "better-sqlite3";
+import { EFEKTIF_SIRA_SQL } from "../src/lib/veri-yillari";
+import { bayrakVar, calistir, dbAc, dbArgumani, dur, type Db } from "./yokatlas-goc";
-const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
+export const EFEKTIF_INDEKS = "idx_programs_tur_onlisans_efektif";
-db.exec(`
- CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
- ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
-`);
+/**
+ * Efektif sıra indeksini güncel ifadeyle kurar. Eski yılın ifadesiyle kurulmuş
+ * aynı adlı indeks varsa DROP edip yeniden kurar (yıl değişince ifade değişir).
+ */
+export function efektifIndeksiKur(db: Db): "kuruldu" | "yenilendi" | "guncel" {
+ const mevcut = db
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
+ .get(EFEKTIF_INDEKS) as { sql: string } | undefined;
+ let sonuc: "kuruldu" | "yenilendi" | "guncel" = "kuruldu";
+ if (mevcut) {
+ if (mevcut.sql.includes(EFEKTIF_SIRA_SQL)) return "guncel";
+ db.exec(`DROP INDEX ${EFEKTIF_INDEKS}`);
+ sonuc = "yenilendi";
+ }
+ db.exec(
+ `CREATE INDEX ${EFEKTIF_INDEKS} ON programs (tur, onlisans, ${EFEKTIF_SIRA_SQL})`
+ );
+ return sonuc;
+}
-// Programatik SEO sayfaları (src/lib/katalog.ts) bölüm/üniversite detaylarını
-// ham ada birebir IN (...) eşleşmesiyle çeker; bu iki index olmadan her
-// sayfa tam tablo taraması yapar.
-db.exec(`
- CREATE INDEX IF NOT EXISTS idx_programs_isim ON programs (isim);
- CREATE INDEX IF NOT EXISTS idx_programs_universite ON programs (universite);
-`);
+/** Sihirbaz/liste aralık sorgusunun planı (src/lib/db.ts dilimAra ile aynı biçim). */
+export function efektifSorguPlani(db: Db): string[] {
+ const plan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT id FROM programs
+ WHERE tur = ? AND onlisans = ?
+ AND ${EFEKTIF_SIRA_SQL} IS NOT NULL
+ AND ${EFEKTIF_SIRA_SQL} BETWEEN ? AND ?
+ ORDER BY ${EFEKTIF_SIRA_SQL} ASC LIMIT 10`
+ )
+ .all("SAYISAL", 0, 1000, 50000) as { detail: string }[];
+ return plan.map((p) => p.detail);
+}
-// Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
-// (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
-db.pragma("wal_checkpoint(TRUNCATE)");
+if (process.argv[1]?.endsWith("db-index.ts")) {
+ calistir(() => {
+ const db = dbAc(dbArgumani());
+ console.log(`Efektif indeks (${EFEKTIF_SIRA_SQL}): ${efektifIndeksiKur(db)}`);
-const plan = db
- .prepare(
- `EXPLAIN QUERY PLAN
- SELECT id FROM programs
- WHERE tur = ? AND onlisans = ?
- AND COALESCE(sira2025, sira2024) IS NOT NULL
- AND COALESCE(sira2025, sira2024) BETWEEN ? AND ?
- ORDER BY COALESCE(sira2025, sira2024) ASC LIMIT 10`,
- )
- .all("SAYISAL", 0, 1000, 50000);
+ // Programatik SEO sayfaları (src/lib/katalog.ts) bölüm/üniversite
+ // detaylarını ham ada birebir IN (...) eşleşmesiyle çeker; bu iki index
+ // olmadan her sayfa tam tablo taraması yapar.
+ db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_programs_isim ON programs (isim);
+ CREATE INDEX IF NOT EXISTS idx_programs_universite ON programs (universite);
+ `);
-console.log("Sorgu planı:", JSON.stringify(plan, null, 2));
-db.close();
+ if (bayrakVar("vacuum")) {
+ db.exec("VACUUM");
+ console.log("VACUUM tamam.");
+ }
+
+ // Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
+ // (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
+ db.pragma("wal_checkpoint(TRUNCATE)");
+
+ const plan = efektifSorguPlani(db);
+ console.log("Sorgu planı:");
+ for (const satir of plan) console.log(` ${satir}`);
+ db.close();
+ if (!plan.some((s) => s.includes(EFEKTIF_INDEKS))) {
+ dur(`Sorgu planı ${EFEKTIF_INDEKS} indeksini kullanmıyor.`);
+ }
+ });
+}
diff --git a/scripts/detay.ts b/scripts/detay.ts
index 502b3c0..6db1b69 100644
--- a/scripts/detay.ts
+++ b/scripts/detay.ts
@@ -1,45 +1,71 @@
/**
- * Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler:
+ * Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler.
+ * İki BAĞIMSIZ adım:
*
- * 1. Kontenjan türü kırılımı (son yerleştirme yılı, ör. 2025):
- * Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın
- * için kontenjan + yerleşen. Kaynak: POST /api/tercih-kilavuz/search
- * (gk1/gkY1, obk1/obkY1, sgy1/sgyY1, dprm1/dprmY1, y34_1/y34Y1 —
- * "1" son yerleştirme yılı demek; kayıttaki `yil` kılavuz yılıdır,
- * veri yılı = yil - 1).
+ * A) Yerleşen son kişinin netleri (varsayılan; `--netler-atla` ile kapatılır)
+ * Kaynak: POST /api/netler/search → `netler` tablosu. Bu API her satırda
+ * verinin ait olduğu yılı `yil` alanında verir; betik bunu körlemesine
+ * kabul etmez: ilk sayfada `tabanPuan`, DB'deki puan ile ≥%95
+ * eşleşmiyorsa DURUR (yıl etiketi kaymış demektir).
*
- * 2. Yerleşen son kişinin netleri (yıl bazında, tüm puan türleri):
- * Kaynak: POST /api/netler/search → `netler` tablosu.
+ * B) Kontenjan türü kırılımı — YALNIZ `--kirilim` bayrağıyla (varsayılan KAPALI)
+ * Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın için
+ * kontenjan + yerleşen; API'nin `…1` alanları → kolonları.
+ * `--yil`'in kendi kırılım kolonları BİLEREK yok (src/ içinde okuyan kod
+ * yok; KARARLAR 2026-09-21).
+ * Emniyet: (1) refresh ile aynı YIL KİLİDİ; (2) beklenen on alanın HER
+ * BİRİ ilk sayfada en az bir kayıtta bulunmalı — API null alanları hiç
+ * göndermediği için "ilk kayıt" tek başına yanıltır; bir alan ilk sayfanın
+ * tamamında `undefined` ise API o alanı artık yayımlamıyordur ve adım
+ * HİÇBİR ŞEY yazmadan DURUR; (3) yazım `COALESCE(yeni, mevcut)` —
+ * mevcut değerin üstüne asla NULL yazılmaz.
+ * (Eylül 2026: API `gkY1/obk1/…` alanlarını yayımlamıyor → bu adım durur;
+ * 2025 kırılımı DB'de Ağustos 2026 çekiminden duruyor, korunur.)
+ *
+ * Yıl HİÇBİR adımda kılavuz kaydındaki `yil` alanından türetilmez.
*
* Not: YÖK Atlas 2026'da SPA'ya geçti; eski content/lisans-dynamic/*.php
* panelleri (ve onlarla birlikte kadın-erkek dağılımı) yayından kalktı.
- * Cinsiyet verisi yeni API'lerin hiçbirinde yok.
*
- * Kullanım: npx tsx scripts/detay.ts
+ * Kullanım:
+ * pnpm detay --yil 2026 --db /tmp/deneme.db
+ * pnpm detay --yil 2026 --db /tmp/deneme.db --kirilim --netler-atla
*/
-import path from "node:path";
-import Database from "better-sqlite3";
+import type Database from "better-sqlite3";
+import { ILK_YIL } from "../src/lib/veri-yillari";
+import { yilKilidi, type KilavuzKaydi } from "./refresh";
+import {
+ argOku,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ yilArgumani,
+ type Db,
+} from "./yokatlas-goc";
const KILAVUZ_API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const NETLER_API = "https://yokatlas.yok.gov.tr/api/netler/search";
const PAGE_SIZE = 500;
-const DELAY_MS = 300;
+const DELAY_MS = 500;
-type KilavuzRecord = {
- kilavuzKodu: number;
- eskiKilavuzKodu?: number | null;
- yil: number;
- gk1?: number | null;
- gkY1?: number | null;
- obk1?: number | null;
- obkY1?: number | null;
- sgy1?: number | null;
- sgyY1?: number | null;
- dprm1?: number | null;
- dprmY1?: number | null;
- y34_1?: number | null;
- y34Y1?: number | null;
-};
+/** API alanı → DB kolon öneki. `…1` = bir önceki yerleştirme yılı. */
+const KIRILIM_ALANLARI = [
+ ["gk1", "gk"],
+ ["gkY1", "gkY"],
+ ["obk1", "obk"],
+ ["obkY1", "obkY"],
+ ["sgy1", "sgy"],
+ ["sgyY1", "sgyY"],
+ ["dprm1", "dprm"],
+ ["dprmY1", "dprmY"],
+ ["y34_1", "y34k"],
+ ["y34Y1", "y34kY"],
+] as const;
+
+type KirilimKaydi = KilavuzKaydi & Record;
type NetlerRecord = {
kilavuzKodu: number;
@@ -113,96 +139,9 @@ const NET_COLS = [
"ydtYdilNet",
] as const;
-async function main() {
- const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
- db.pragma("journal_mode = WAL");
+// ── A) Netler ────────────────────────────────────────────────────────────
- // ── 1. Kontenjan türü kırılımı ─────────────────────────────────────────
- const first = await fetchPage(KILAVUZ_API, 0);
- // API kaydındaki yil kılavuz yılı; "1" ekli alanlar bir önceki
- // yerleştirme dönemine ait (2026 kılavuzu → 2025 verisi)
- const veriYili = first.content[0].yil - 1;
-
- const kontCols = [
- `gk${veriYili}`,
- `gkY${veriYili}`,
- `obk${veriYili}`,
- `obkY${veriYili}`,
- `sgy${veriYili}`,
- `sgyY${veriYili}`,
- `dprm${veriYili}`,
- `dprmY${veriYili}`,
- `y34k${veriYili}`,
- `y34kY${veriYili}`,
- ];
- const mevcut = new Set(
- (db.prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
- (c) => c.name
- )
- );
- for (const col of kontCols) {
- if (!mevcut.has(col)) {
- db.exec(`ALTER TABLE programs ADD COLUMN ${col} INTEGER`);
- }
- }
-
- const updateKont = db.prepare(`
- UPDATE programs SET ${kontCols.map((c) => `${c} = ?`).join(", ")}
- WHERE id = ?
- `);
- const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
-
- // Kılavuz kodu yıllar arasında değişebiliyor (eskiKilavuzKodu); netler
- // API'si yeni kodla döndüğü için eşleşen DB id'sini haritada tutuyoruz.
- const dbId = new Map();
- let kontUpdated = 0;
- let kontMissing = 0;
-
- const processKilavuz = db.transaction((records: KilavuzRecord[]) => {
- for (const r of records) {
- if (!r.kilavuzKodu) continue;
- let id = String(r.kilavuzKodu);
- if (!exists.get(id)) {
- const eski = r.eskiKilavuzKodu ? String(r.eskiKilavuzKodu) : null;
- if (eski && exists.get(eski)) {
- id = eski;
- } else {
- kontMissing++;
- continue;
- }
- }
- dbId.set(r.kilavuzKodu, id);
- updateKont.run(
- num(r.gk1),
- num(r.gkY1),
- num(r.obk1),
- num(r.obkY1),
- num(r.sgy1),
- num(r.sgyY1),
- num(r.dprm1),
- num(r.dprmY1),
- num(r.y34_1),
- num(r.y34Y1),
- id
- );
- kontUpdated++;
- }
- });
-
- console.log(
- `Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${first.totalPages} sayfa…`
- );
- processKilavuz(first.content);
- for (let p = 1; p < first.totalPages; p++) {
- await sleep(DELAY_MS);
- processKilavuz((await fetchPage(KILAVUZ_API, p)).content);
- process.stdout.write(`\rkontenjan sayfa ${p + 1}/${first.totalPages}`);
- }
- console.log(
- `\nKontenjan kırılımı: ${kontUpdated} güncellendi, ${kontMissing} DB'de yok`
- );
-
- // ── 2. Yerleşen son kişinin netleri ────────────────────────────────────
+async function netlerAdimi(db: Db, yil: number, azamiSayfa: number): Promise {
db.exec(`
CREATE TABLE IF NOT EXISTS netler (
program_id TEXT NOT NULL,
@@ -214,54 +153,165 @@ async function main() {
);
`);
- const insertNet = db.prepare(`
- INSERT OR REPLACE INTO netler (program_id, yil, ${NET_COLS.join(", ")})
- VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")})
- `);
+ const first = await fetchPage(NETLER_API, 0);
- let netRows = 0;
+ // Yıl etiketi sağlaması: netler.tabanPuan(yil=Y) ≈ programs.puanY
+ const programKolonlari = new Set(kolonAdlari(db, "programs"));
+ const puanGetir = new Map>();
+ let kiyas = 0;
+ let esit = 0;
+ for (const r of first.content) {
+ if (!r.kilavuzKodu || !r.yil || num(r.tabanPuan) == null) continue;
+ if (r.yil < ILK_YIL || r.yil > yil || !programKolonlari.has(`puan${r.yil}`)) continue;
+ let st = puanGetir.get(r.yil);
+ if (!st) {
+ st = db.prepare(`SELECT puan${r.yil} AS p FROM programs WHERE id = ?`);
+ puanGetir.set(r.yil, st);
+ }
+ const satir = st.get(String(r.kilavuzKodu)) as { p: number | null } | undefined;
+ if (!satir || satir.p == null) continue;
+ kiyas++;
+ if (Math.abs(satir.p - (r.tabanPuan as number)) <= 0.01) esit++;
+ }
+ const oran = kiyas ? esit / kiyas : 0;
+ console.log(`Netler yıl sağlaması: tabanPuan = puan → ${esit}/${kiyas} (%${(oran * 100).toFixed(1)})`);
+ if (kiyas < 50 || oran < 0.95) {
+ dur(
+ "Netler API'sindeki `yil` etiketi DB'deki yıl kolonlarıyla örtüşmüyor " +
+ "(ya da karşılaştıracak veri yok). Netler yazılmadı."
+ );
+ }
+
+ // Mevcut değerin üstüne NULL yazılmaz: çakışmada COALESCE(yeni, eski).
+ const upsert = db.prepare(`
+ INSERT INTO netler (program_id, yil, ${NET_COLS.join(", ")})
+ VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")})
+ ON CONFLICT(program_id, yil) DO UPDATE SET
+ ${NET_COLS.map((c) => `${c} = COALESCE(excluded.${c}, ${c})`).join(",\n ")}
+ `);
+ const programVar = db.prepare("SELECT 1 FROM programs WHERE id = ?");
+
+ let yazilan = 0;
+ let programYok = 0;
+ let yilDisi = 0;
const processNetler = db.transaction((records: NetlerRecord[]) => {
for (const r of records) {
if (!r.kilavuzKodu || !r.yil) continue;
- const id = dbId.get(r.kilavuzKodu) ?? String(r.kilavuzKodu);
- insertNet.run(
+ if (r.yil < ILK_YIL || r.yil > yil) {
+ yilDisi++;
+ continue;
+ }
+ const id = String(r.kilavuzKodu);
+ // Yetim satır üretme: program DB'de yoksa (refresh koşulmamış / atlanmış) sayıp geç.
+ if (!programVar.get(id)) {
+ programYok++;
+ continue;
+ }
+ upsert.run(
id,
r.yil,
r.puanTuru?.trim() ?? null,
...NET_COLS.slice(1).map((c) => num(r[c as keyof NetlerRecord]))
);
- netRows++;
+ yazilan++;
}
});
- const netFirst = await fetchPage(NETLER_API, 0);
- console.log(
- `Netler: ${netFirst.totalElements} kayıt, ${netFirst.totalPages} sayfa…`
- );
- processNetler(netFirst.content);
- for (let p = 1; p < netFirst.totalPages; p++) {
+ const sayfa = Math.min(first.totalPages, azamiSayfa);
+ console.log(`Netler: ${first.totalElements} kayıt, ${first.totalPages} sayfa${sayfa < first.totalPages ? ` (yalnız ilk ${sayfa})` : ""}…`);
+ processNetler(first.content);
+ for (let p = 1; p < sayfa; p++) {
await sleep(DELAY_MS);
processNetler((await fetchPage(NETLER_API, p)).content);
- process.stdout.write(`\rnetler sayfa ${p + 1}/${netFirst.totalPages}`);
+ process.stdout.write(`\rnetler sayfa ${p + 1}/${sayfa}`);
}
- console.log(`\nNetler: ${netRows} satır yazıldı`);
-
- const ozet = db
- .prepare(
- `SELECT
- (SELECT COUNT(*) FROM programs WHERE gkY${veriYili} IS NOT NULL) AS kontlu,
- (SELECT COUNT(*) FROM netler) AS net,
- (SELECT COUNT(DISTINCT program_id) FROM netler) AS netli_program`
- )
- .get() as { kontlu: number; net: number; netli_program: number };
+ const dagilim = db
+ .prepare("SELECT yil, COUNT(*) AS n FROM netler GROUP BY yil ORDER BY yil")
+ .all() as { yil: number; n: number }[];
console.log(
- `Özet → kontenjan kırılımı olan program: ${ozet.kontlu}, ` +
- `net satırı: ${ozet.net} (${ozet.netli_program} program)`
+ `\nNetler: ${yazilan} satır yazıldı/güncellendi; programı DB'de olmayan: ${programYok}; yıl aralığı dışı: ${yilDisi}`
);
- db.close();
+ console.log(`Netler yıl dağılımı: ${dagilim.map((d) => `${d.yil}=${d.n}`).join(" ")}`);
}
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
+// ── B) Kontenjan kırılımı ────────────────────────────────────────────────
+
+async function kirilimAdimi(db: Db, yil: number, azamiSayfa: number): Promise {
+ const veriYili = yil - 1; // `…1` alanları; yıl kilidiyle doğrulanır, r.yil'e bakılmaz
+ const first = await fetchPage(KILAVUZ_API, 0);
+ yilKilidi(db, yil, first.content);
+
+ const eksik = KIRILIM_ALANLARI.filter(
+ ([alan]) => !first.content.some((r) => r[alan] !== undefined)
+ ).map(([alan]) => alan);
+ if (eksik.length) {
+ dur(
+ `Kırılım alanları API'de yok (ilk sayfanın tamamında undefined): ${eksik.join(", ")}. ` +
+ `${veriYili} kırılım kolonlarına DOKUNULMADI (mevcut değerler korunuyor).`
+ );
+ }
+
+ const kontCols = KIRILIM_ALANLARI.map(([, onek]) => `${onek}${veriYili}`);
+ const mevcut = new Set(kolonAdlari(db, "programs"));
+ for (const col of kontCols) {
+ if (!mevcut.has(col)) db.exec(`ALTER TABLE programs ADD COLUMN ${col} INTEGER`);
+ }
+ const updateKont = db.prepare(`
+ UPDATE programs SET ${kontCols.map((c) => `${c} = COALESCE(?, ${c})`).join(", ")}
+ WHERE id = ?
+ `);
+ const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
+
+ let guncellenen = 0;
+ let dbdeYok = 0;
+ const isle = db.transaction((records: KirilimKaydi[]) => {
+ for (const r of records) {
+ if (!r.kilavuzKodu) continue;
+ const id = String(r.kilavuzKodu);
+ if (!exists.get(id)) {
+ dbdeYok++;
+ continue;
+ }
+ updateKont.run(...KIRILIM_ALANLARI.map(([alan]) => num(r[alan])), id);
+ guncellenen++;
+ }
+ });
+
+ const sayfa = Math.min(first.totalPages, azamiSayfa);
+ console.log(`Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${sayfa} sayfa…`);
+ isle(first.content);
+ for (let p = 1; p < sayfa; p++) {
+ await sleep(DELAY_MS);
+ isle((await fetchPage(KILAVUZ_API, p)).content);
+ process.stdout.write(`\rkırılım sayfa ${p + 1}/${sayfa}`);
+ }
+ console.log(`\nKontenjan kırılımı: ${guncellenen} güncellendi, ${dbdeYok} DB'de yok`);
+}
+
+async function main() {
+ const yil = yilArgumani();
+ const db = dbAc(dbArgumani());
+ // Yalnız deneme için: kaynağı yormadan ilk N sayfayla dur.
+ const azamiSayfa = Number.parseInt(argOku("azami-sayfa") ?? "", 10) || Number.POSITIVE_INFINITY;
+
+ let hata: unknown = null;
+ if (!bayrakVar("netler-atla")) {
+ try {
+ await netlerAdimi(db, yil, azamiSayfa);
+ } catch (err) {
+ hata = err; // adımlar bağımsız: netler dursa da kırılım (istenmişse) denenir
+ console.error(`Netler adımı durdu: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+ if (bayrakVar("kirilim")) {
+ await kirilimAdimi(db, yil, azamiSayfa);
+ } else {
+ console.log("Kontenjan kırılımı atlandı (yalnız --kirilim ile koşar).");
+ }
+
+ db.pragma("wal_checkpoint(TRUNCATE)");
+ db.close();
+ if (hata) throw hata;
+}
+
+if (process.argv[1]?.endsWith("detay.ts")) calistir(main);
diff --git a/scripts/eski-arsivle.ts b/scripts/eski-arsivle.ts
new file mode 100644
index 0000000..fe0d4eb
--- /dev/null
+++ b/scripts/eski-arsivle.ts
@@ -0,0 +1,228 @@
+/**
+ * Güncel tercih kılavuzunda OLMAYAN programları `programs` → `programs_arsiv`
+ * tablosuna taşır (silmez; aynı şema, aynı değerler). Kodu değişen programların
+ * çift satırı (eski kod + yeni kod) böylece sorgu koşuluyla değil DB inşasında
+ * çözülür: site yalnız bu yıl tercih edilebilen programları görür.
+ *
+ * `netler.program_id`: arşive giden eski kodun TEK bir güncel karşılığı varsa
+ * (programs.eski_kod) yeni koda çevrilir; yeni kodda o yıl zaten net satırı
+ * varsa eski satır olduğu gibi bırakılır (veri silinmez).
+ *
+ * ÖNCE `--kuru` ile koş: hiçbir şey yazmaz; kaç satır gideceğini ve hangi bölüm
+ * / üniversite sayfalarının (slug) tamamen kapanacağını adlarıyla listeler.
+ * Gerçek koşu [BİLAL] onayı ister — sayfalar siteden kalkar.
+ *
+ * Kullanım:
+ * pnpm eski-arsivle --yil 2026 --db /tmp/deneme.db --kuru
+ * pnpm eski-arsivle --yil 2026 --db /tmp/deneme.db
+ */
+import { bolumBazAdi, turkishSlugify, uniSayfaSlug } from "../src/lib/slug";
+import {
+ arsivTablosunuGarantiEt,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ metaOku,
+ metaYaz,
+ tabloVar,
+ yilArgumani,
+} from "./yokatlas-goc";
+
+type Ad = { ad: string; n: number };
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const kuru = bayrakVar("kuru");
+ const db = dbAc(dbYolu, { saltOkunur: kuru });
+ console.log(`DB: ${dbYolu}${kuru ? "\nKURU KİP: hiçbir şey yazılmayacak" : ""}`);
+
+ if (!kolonAdlari(db, "programs").includes("son_kilavuz_yili")) {
+ dur("son_kilavuz_yili kolonu yok: önce refresh koşulmalı.");
+ }
+
+ // ── Emniyet: refresh bu yıl için tam koşmuş olmalı ─────────────────────
+ const apiToplam = Number(metaOku(db, `refresh_${yil}_api_toplam`) ?? NaN);
+ const atlanan = Number(metaOku(db, `refresh_${yil}_atlanan`) ?? NaN);
+ const guncel = (
+ db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
+ ).n;
+ if (!Number.isFinite(apiToplam) || guncel !== apiToplam - atlanan) {
+ dur(
+ `Güncel satır sayısı (${guncel}) refresh kaydıyla (API ${apiToplam} − atlanan ${atlanan}) uyuşmuyor. ` +
+ "Yarım bir refresh'in üstüne arşivleme yapılmaz — güncel programlar da gidebilir."
+ );
+ }
+
+ // NOT: `<>` NULL'ları kaçırır; son_kilavuz_yili bilinmeyen (NULL) satırlar da eskidir.
+ const ESKI = "son_kilavuz_yili IS NOT ?";
+ const eskiSayi = (
+ db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${ESKI}`).get(yil) as { n: number }
+ ).n;
+ console.log(`\nGüncel (${yil} kılavuzunda): ${guncel} Arşivlenecek: ${eskiSayi}`);
+ if (eskiSayi === 0) {
+ console.log("Arşivlenecek satır yok.");
+ db.close();
+ return;
+ }
+
+ const kirilim = db
+ .prepare(
+ `SELECT COALESCE(CAST(son_kilavuz_yili AS TEXT), 'bilinmiyor (≤2024)') AS y, COUNT(*) AS n,
+ COUNT(sira${yil - 1}) AS onceki_sirali
+ FROM programs WHERE ${ESKI} GROUP BY son_kilavuz_yili ORDER BY son_kilavuz_yili`
+ )
+ .all(yil) as { y: string; n: number; onceki_sirali: number }[];
+ for (const k of kirilim) {
+ console.log(` son kılavuz yılı ${k.y}: ${k.n} satır (${k.onceki_sirali} tanesinde sira${yil - 1} dolu)`);
+ }
+
+ const halefli = (
+ db
+ .prepare(
+ `SELECT COUNT(*) AS n FROM programs e WHERE e.${ESKI}
+ AND EXISTS (SELECT 1 FROM programs g WHERE g.eski_kod = e.id AND g.son_kilavuz_yili = ?)`
+ )
+ .get(yil, yil) as { n: number }
+ ).n;
+ console.log(
+ ` yeni kodla devam eden (eski_kod eşleşmesi): ${halefli}; karşılığı olmayan (kapanmış/ad değişmiş): ${eskiSayi - halefli}`
+ );
+
+ // ── SEO: tamamen kaybolan bölüm / üniversite sayfaları ─────────────────
+ const eskiIsimler = db
+ .prepare(`SELECT isim AS ad, COUNT(*) AS n FROM programs WHERE ${ESKI} GROUP BY isim`)
+ .all(yil) as Ad[];
+ const guncelIsimler = db
+ .prepare("SELECT DISTINCT isim AS ad FROM programs WHERE son_kilavuz_yili = ?")
+ .all(yil) as { ad: string }[];
+ const guncelHamIsim = new Set(guncelIsimler.map((r) => r.ad));
+ const guncelBolumSlug = new Set(guncelIsimler.map((r) => turkishSlugify(bolumBazAdi(r.ad))));
+ const kaybolanHamIsim = eskiIsimler.filter((r) => !guncelHamIsim.has(r.ad));
+ const kaybolanBolum = new Map();
+ for (const r of eskiIsimler) {
+ const baz = bolumBazAdi(r.ad);
+ const slug = turkishSlugify(baz);
+ if (!slug || guncelBolumSlug.has(slug)) continue;
+ const k = kaybolanBolum.get(slug) ?? { ad: baz, n: 0 };
+ k.n += r.n;
+ kaybolanBolum.set(slug, k);
+ }
+
+ const eskiUniler = db
+ .prepare(`SELECT universite AS ad, COUNT(*) AS n FROM programs WHERE ${ESKI} GROUP BY universite`)
+ .all(yil) as Ad[];
+ const guncelUniler = db
+ .prepare("SELECT DISTINCT universite AS ad FROM programs WHERE son_kilavuz_yili = ?")
+ .all(yil) as { ad: string }[];
+ const guncelHamUni = new Set(guncelUniler.map((r) => r.ad));
+ const guncelUniSlug = new Set(guncelUniler.map((r) => uniSayfaSlug(r.ad)));
+ const kaybolanHamUni = eskiUniler.filter((r) => !guncelHamUni.has(r.ad));
+ const kaybolanUni = new Map();
+ for (const r of eskiUniler) {
+ const slug = uniSayfaSlug(r.ad);
+ if (!slug || guncelUniSlug.has(slug)) continue;
+ const k = kaybolanUni.get(slug) ?? { ad: r.ad, n: 0 };
+ k.n += r.n;
+ kaybolanUni.set(slug, k);
+ }
+
+ console.log(
+ `\nTamamen kaybolan ham 'isim' değeri: ${kaybolanHamIsim.length} ` +
+ `(çoğu burs/dil parantezi varyantı; sayfa kaybı aşağıdaki slug listesidir)`
+ );
+ console.log(`KAPANACAK /bolum/ sayfası: ${kaybolanBolum.size}`);
+ for (const [slug, k] of [...kaybolanBolum].sort((a, b) => a[0].localeCompare(b[0]))) {
+ console.log(` /bolum/${slug} — ${k.ad} (${k.n} satır)`);
+ }
+ console.log(`Tamamen kaybolan ham 'universite' değeri: ${kaybolanHamUni.length}`);
+ for (const r of kaybolanHamUni) console.log(` ${r.ad} (${r.n} satır)`);
+ console.log(`KAPANACAK /universite/ sayfası: ${kaybolanUni.size}`);
+ for (const [slug, k] of [...kaybolanUni].sort((a, b) => a[0].localeCompare(b[0]))) {
+ console.log(` /universite/${slug} — ${k.ad} (${k.n} satır)`);
+ }
+
+ // ── netler ─────────────────────────────────────────────────────────────
+ const netlerVar = tabloVar(db, "netler");
+ if (netlerVar) {
+ const net = db
+ .prepare(
+ `SELECT COUNT(*) AS satir, COUNT(DISTINCT n.program_id) AS program
+ FROM netler n JOIN programs e ON e.id = n.program_id WHERE e.${ESKI}`
+ )
+ .get(yil) as { satir: number; program: number };
+ console.log(`\nnetler: arşive gidecek programlara bağlı ${net.satir} satır (${net.program} program)`);
+ }
+
+ if (kuru) {
+ console.log("\nKURU KİP bitti — DB değişmedi.");
+ db.close();
+ return;
+ }
+
+ // ── Gerçek taşıma: tek transaction ─────────────────────────────────────
+ db.transaction(() => {
+ arsivTablosunuGarantiEt(db);
+ const kolonlar = kolonAdlari(db, "programs");
+ const saglama = () =>
+ (
+ db
+ .prepare(
+ `SELECT COUNT(*) || '|' || TOTAL(sira${yil - 1}) || '|' || TOTAL(puan${yil - 1}) AS s
+ FROM (SELECT * FROM programs UNION ALL SELECT ${kolonlar.join(", ")} FROM programs_arsiv)`
+ )
+ .get() as { s: string }
+ ).s;
+ const once = saglama();
+
+ if (netlerVar) {
+ // Tek halefli eski kodlar → yeni koda. OR IGNORE: yeni kodda o yıl varsa dokunma.
+ const r = db
+ .prepare(
+ `UPDATE OR IGNORE netler SET program_id = (
+ SELECT g.id FROM programs g WHERE g.eski_kod = netler.program_id AND g.son_kilavuz_yili = @yil
+ )
+ WHERE program_id IN (SELECT id FROM programs WHERE son_kilavuz_yili IS NOT @yil)
+ AND (SELECT COUNT(*) FROM programs g
+ WHERE g.eski_kod = netler.program_id AND g.son_kilavuz_yili = @yil) = 1`
+ )
+ .run({ yil });
+ console.log(`\nnetler: ${r.changes} satır yeni koda çevrildi`);
+ }
+
+ const tasinan = db
+ .prepare(
+ `INSERT INTO programs_arsiv (${kolonlar.join(", ")})
+ SELECT ${kolonlar.join(", ")} FROM programs WHERE ${ESKI}`
+ )
+ .run(yil).changes;
+ const cikan = db.prepare(`DELETE FROM programs WHERE ${ESKI}`).run(yil).changes;
+ if (tasinan !== eskiSayi || cikan !== eskiSayi) {
+ dur(`Taşınan (${tasinan}) / çıkarılan (${cikan}) ≠ beklenen (${eskiSayi}). Geri alındı.`);
+ }
+ const sonra = saglama();
+ if (once !== sonra) {
+ dur(`programs ∪ programs_arsiv sağlaması değişti. Geri alındı.\nönce : ${once}\nsonra: ${sonra}`);
+ }
+ metaYaz(db, `arsiv_${yil}_tarih`, new Date().toISOString());
+ metaYaz(db, `arsiv_${yil}_satir`, tasinan);
+ console.log(`Arşivlendi: ${tasinan} satır → programs_arsiv. Sağlama aynı: ${sonra}`);
+ })();
+
+ if (netlerVar) {
+ const yetim = (
+ db
+ .prepare("SELECT COUNT(*) AS n FROM netler WHERE program_id NOT IN (SELECT id FROM programs)")
+ .get() as { n: number }
+ ).n;
+ console.log(`netler: programs'ta karşılığı olmayan satır (arşivdeki programlara ait): ${yetim}`);
+ }
+ db.pragma("wal_checkpoint(TRUNCATE)");
+ db.close();
+ console.log("\nSıradaki adım: pnpm db:index --db --vacuum sonra pnpm veri-kalite …");
+}
+
+if (process.argv[1]?.endsWith("eski-arsivle.ts")) calistir(main);
diff --git a/scripts/gecmis-doldur.ts b/scripts/gecmis-doldur.ts
new file mode 100644
index 0000000..ca84bca
--- /dev/null
+++ b/scripts/gecmis-doldur.ts
@@ -0,0 +1,302 @@
+/**
+ * Güncel kılavuzdaki programların GEÇMİŞ yıl kolonlarındaki boşlukları doldurur.
+ *
+ * Kurallar (pazarlıksız):
+ * - Yalnız NULL alan doldurulur. Dolu değer ASLA ezilmez; kaynak farklı bir
+ * değer söylüyorsa sayılır ve raporlanır (karar insanındır).
+ * - Tahmin yok, komşu yıldan kopya yok. Kaynak yoksa alan NULL kalır.
+ * - Belirsiz eşleşmede birleştirme yok (sayılır, raporlanır).
+ *
+ * Kaynak önceliği:
+ * 1. API `…1/…2/…3` alanları (ham dosyadan; API'ye gidilmez) → yıl-1/-2/-3
+ * için sıra, puan, genel kontenjan.
+ * 2. `eski_kod` (API `eskiKilavuzKodu`): kodu değişen programın eski satırı
+ * (programs ya da programs_arsiv) → tüm geçmiş yıllar, dört ölçü.
+ * Eski kod hâlâ güncel kılavuzdaysa ya da birden çok yeni program aynı
+ * eski koda işaret ediyorsa (bölünme) → belirsiz, atlanır.
+ * 3. Aynı (üniversite baz adı, isim, tur, onlisans) için TAM 1 güncel + TAM 1
+ * eski satır varsa; ortak yıllarda puan/kontenjan/yerleşen ÇELİŞMİYORSA ve
+ * en az bir ortak değer AYNIYSA (kanıt) → eski satırdan doldurulur, güncel
+ * satıra `eski_kod` yazılır. Kanıtsız ya da çelişkili eşleşme birleştirilmez.
+ *
+ * Kullanım:
+ * pnpm gecmis-doldur --yil 2026 --db /tmp/deneme.db [--ham data/ham/2026-….jsonl.gz] [--kuru]
+ */
+import path from "node:path";
+import type Database from "better-sqlite3";
+import { ILK_YIL } from "../src/lib/veri-yillari";
+import { gecmisAlanlari, hamOku, sonHamDosya } from "./refresh";
+import {
+ argOku,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ tabloVar,
+ yilArgumani,
+ YIL_OLCULERI,
+} from "./yokatlas-goc";
+
+type Satir = Record & { id: string };
+
+const PUAN_TOLERANS = 0.01;
+
+class KuruGeriAl extends Error {}
+
+function ayniMi(olcu: string, a: number, b: number): boolean {
+ return olcu === "puan" ? Math.abs(a - b) <= PUAN_TOLERANS : a === b;
+}
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const kuru = bayrakVar("kuru");
+ const hamArg = argOku("ham");
+ const hamDosya = hamArg
+ ? path.resolve(process.cwd(), hamArg)
+ : sonHamDosya(yil, path.resolve(process.cwd(), "data", "ham"));
+ if (!hamDosya) {
+ dur(`--ham verilmedi ve data/ham altında ${yil}-*.jsonl.gz yok. Önce refresh koş.`);
+ }
+
+ const db = dbAc(dbYolu);
+ const kolonlar = new Set(kolonAdlari(db, "programs"));
+ if (!kolonlar.has("son_kilavuz_yili") || !kolonlar.has(`sira${yil}`)) {
+ dur("Şema eski: önce `pnpm refresh` (ya da `pnpm yokatlas:goc`) koşulmalı.");
+ }
+ const guncelSayisi = (
+ db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
+ ).n;
+ if (guncelSayisi === 0) dur(`son_kilavuz_yili=${yil} satır yok: önce refresh koşulmalı.`);
+
+ const gecmisYillar: number[] = [];
+ for (let y = ILK_YIL; y < yil; y++) {
+ if (kolonlar.has(`sira${y}`)) gecmisYillar.push(y);
+ }
+ const olculer = YIL_OLCULERI.map(([o]) => o);
+
+ console.log(`DB: ${dbYolu}\nHam: ${hamDosya}${kuru ? "\nKURU KİP: hiçbir şey yazılmayacak" : ""}`);
+ const { kayitlar } = await hamOku(hamDosya, yil);
+
+ const doldurulan = new Map(); // "kaynak|kolon" → adet
+ const farkli = new Map();
+ const say = (m: Map, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
+ const farkYaz = (kaynak: string, kolon: string, id: string, db_: number, kaynakDeger: number) => {
+ const k = `${kaynak}|${kolon}`;
+ const f = farkli.get(k) ?? { adet: 0, ornek: [] };
+ f.adet++;
+ if (f.ornek.length < 3) f.ornek.push(`${id}: DB=${db_} kaynak=${kaynakDeger}`);
+ farkli.set(k, f);
+ };
+
+ const satirGetir = db.prepare("SELECT * FROM programs WHERE id = ?");
+ const arsivGetir = tabloVar(db, "programs_arsiv")
+ ? db.prepare("SELECT * FROM programs_arsiv WHERE id = ?")
+ : null;
+ const setCache = new Map>();
+ const alanYaz = (id: string, kolon: string, deger: number) => {
+ let st = setCache.get(kolon);
+ if (!st) {
+ // Çifte emniyet: WHERE … IS NULL — dolu değerin üstüne yazılamaz.
+ st = db.prepare(`UPDATE programs SET ${kolon} = ? WHERE id = ? AND ${kolon} IS NULL`);
+ setCache.set(kolon, st);
+ }
+ st.run(deger, id);
+ };
+
+ /** Hedefin NULL alanlarını kaynaktan doldurur; dolu+farklıyı sayar. */
+ const satirdanDoldur = (kaynakAdi: string, hedef: Satir, kaynak: Satir) => {
+ for (const y of gecmisYillar) {
+ for (const olcu of olculer) {
+ const kolon = `${olcu}${y}`;
+ const k = kaynak[kolon] as number | null;
+ if (k == null) continue;
+ const h = hedef[kolon] as number | null;
+ if (h == null) {
+ alanYaz(hedef.id, kolon, k);
+ hedef[kolon] = k;
+ say(doldurulan, `${kaynakAdi}|${kolon}`);
+ } else if (!ayniMi(olcu, h, k)) {
+ farkYaz(kaynakAdi, kolon, hedef.id, h, k);
+ }
+ }
+ }
+ };
+
+ const ozet = {
+ apiEslesen: 0,
+ eskiKodlu: 0,
+ eskiKodBulunamadi: 0,
+ eskiKodHalaGuncel: 0,
+ eskiKodBolunme: 0,
+ adEslesen: 0,
+ adBelirsiz: 0,
+ adPuanCelisik: 0,
+ adKanitsiz: 0,
+ };
+ const ornekler: Record = { bolunme: [], halaGuncel: [], adEslesen: [], adCelisik: [], adKanitsiz: [] };
+ const ornekEkle = (k: string, s: string) => {
+ if (ornekler[k].length < 3) ornekler[k].push(s);
+ };
+
+ try {
+ db.transaction(() => {
+ // ── 1. API …1/…2/…3 ────────────────────────────────────────────────
+ for (const r of kayitlar) {
+ if (!r.kilavuzKodu) continue;
+ const hedef = satirGetir.get(String(r.kilavuzKodu)) as Satir | undefined;
+ if (!hedef || hedef.son_kilavuz_yili !== yil) continue;
+ ozet.apiEslesen++;
+ for (const g of gecmisAlanlari(r)) {
+ const y = yil - g.fark;
+ if (!gecmisYillar.includes(y)) continue;
+ for (const olcu of ["sira", "puan", "kontenjan"] as const) {
+ const k = g[olcu];
+ if (k == null) continue;
+ const kolon = `${olcu}${y}`;
+ const h = hedef[kolon] as number | null;
+ if (h == null) {
+ alanYaz(hedef.id, kolon, k);
+ hedef[kolon] = k;
+ say(doldurulan, `1-api|${kolon}`);
+ } else if (!ayniMi(olcu, h, k)) {
+ farkYaz("1-api", kolon, hedef.id, h, k);
+ }
+ }
+ }
+ }
+
+ // ── 2. eski_kod ────────────────────────────────────────────────────
+ const eskiKodlular = db
+ .prepare("SELECT * FROM programs WHERE son_kilavuz_yili = ? AND eski_kod IS NOT NULL")
+ .all(yil) as Satir[];
+ const eskiKullanim = new Map();
+ for (const s of eskiKodlular) say(eskiKullanim, s.eski_kod as string);
+ for (const hedef of eskiKodlular) {
+ const eskiId = hedef.eski_kod as string;
+ if ((eskiKullanim.get(eskiId) ?? 0) > 1) {
+ ozet.eskiKodBolunme++;
+ ornekEkle("bolunme", `${hedef.id} ← ${eskiId} (${hedef.isim})`);
+ continue;
+ }
+ const kaynak =
+ (satirGetir.get(eskiId) as Satir | undefined) ??
+ (arsivGetir?.get(eskiId) as Satir | undefined);
+ if (!kaynak) {
+ ozet.eskiKodBulunamadi++;
+ continue;
+ }
+ if (kaynak.son_kilavuz_yili === yil) {
+ ozet.eskiKodHalaGuncel++;
+ ornekEkle("halaGuncel", `${hedef.id} ← ${eskiId} (${kaynak.isim})`);
+ continue;
+ }
+ ozet.eskiKodlu++;
+ satirdanDoldur("2-eski_kod", hedef, kaynak);
+ }
+
+ // ── 3. Benzersiz (universite, isim, tur, onlisans) ─────────────────
+ const sahiplenilmis = new Set(eskiKullanim.keys());
+ const gruplar = new Map();
+ for (const s of db.prepare("SELECT * FROM programs").all() as Satir[]) {
+ // API 2026'da üniversite adına "(İL)" eki koyuyor; eski satırlar eksiz.
+ const uniBaz = String(s.universite).replace(/\s*\([^)]*\)\s*$/, "").trim();
+ const anahtar = JSON.stringify([uniBaz, s.isim, s.tur, s.onlisans]);
+ const g = gruplar.get(anahtar) ?? { guncel: [], eski: [] };
+ if (s.son_kilavuz_yili === yil) g.guncel.push(s);
+ else if (!sahiplenilmis.has(s.id)) g.eski.push(s);
+ gruplar.set(anahtar, g);
+ }
+ const eskiKodYaz = db.prepare(
+ "UPDATE programs SET eski_kod = ? WHERE id = ? AND eski_kod IS NULL"
+ );
+ for (const g of gruplar.values()) {
+ if (g.eski.length === 0 || g.guncel.length === 0) continue;
+ if (g.eski.length !== 1 || g.guncel.length !== 1) {
+ ozet.adBelirsiz++;
+ continue;
+ }
+ const [hedef] = g.guncel;
+ const [kaynak] = g.eski;
+ if (hedef.eski_kod != null) {
+ // API başka bir eski kod söylüyor; ad eşleşmesi onun önüne geçmez.
+ ozet.adBelirsiz++;
+ continue;
+ }
+ // Aynı yılda iki satırda da veri varsa değerler AYNI olmalı (çift satır);
+ // farklıysa bunlar aynı anda var olmuş iki ayrı programdır (ör. 1 kişilik
+ // özel kontenjanlı adaş program). Sıra bilerek dışarıda: 2024 sırası iki
+ // kaynakta farklı tanımlı. Ortak yılı hiç olmayan eşleşme KANITSIZDIR →
+ // birleştirilmez, yalnız sayılır.
+ let kanit = 0;
+ let celisik = false;
+ for (const y of gecmisYillar) {
+ for (const olcu of ["puan", "kontenjan", "yerlesen"]) {
+ const a = hedef[`${olcu}${y}`] as number | null;
+ const b = kaynak[`${olcu}${y}`] as number | null;
+ if (a == null || b == null) continue;
+ if (ayniMi(olcu, a, b)) kanit++;
+ else celisik = true;
+ }
+ }
+ if (celisik) {
+ ozet.adPuanCelisik++;
+ ornekEkle("adCelisik", `${hedef.id} ~ ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
+ continue;
+ }
+ if (kanit === 0) {
+ ozet.adKanitsiz++;
+ ornekEkle("adKanitsiz", `${hedef.id} ? ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
+ continue;
+ }
+ ozet.adEslesen++;
+ ornekEkle("adEslesen", `${hedef.id} ← ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
+ satirdanDoldur("3-ad", hedef, kaynak);
+ eskiKodYaz.run(kaynak.id, hedef.id);
+ }
+
+ if (kuru) throw new KuruGeriAl();
+ })();
+ } catch (err) {
+ if (!(err instanceof KuruGeriAl)) throw err;
+ }
+
+ // ── Rapor ──────────────────────────────────────────────────────────────
+ console.log(`\nGüncel kılavuzdaki program: ${guncelSayisi}; API kaydıyla eşleşen: ${ozet.apiEslesen}`);
+ console.log(
+ `eski_kod: ${ozet.eskiKodlu} programın eski satırı bulundu ve boşlukları ondan dolduruldu; ` +
+ `eski satır yok: ${ozet.eskiKodBulunamadi}; eski kod hâlâ güncel (atlandı): ${ozet.eskiKodHalaGuncel}; ` +
+ `bölünme/çoklu (atlandı): ${ozet.eskiKodBolunme}`
+ );
+ console.log(
+ `Ad eşleşmesi: ${ozet.adEslesen} birebir; belirsiz (atlandı): ${ozet.adBelirsiz}; ` +
+ `aynı yılda farklı değer = ayrı program (atlandı): ${ozet.adPuanCelisik}; ` +
+ `ortak yılı olmayan, kanıtsız (atlandı): ${ozet.adKanitsiz}`
+ );
+ for (const [k, v] of Object.entries(ornekler)) {
+ if (v.length) console.log(` örnek ${k}: ${v.join(" ; ")}`);
+ }
+
+ console.log(`\nDoldurulan NULL alanlar${kuru ? " (kuru — yazılmadı)" : ""}:`);
+ const toplamlar = new Map();
+ for (const [k, n] of [...doldurulan].sort()) {
+ const [kaynak] = k.split("|");
+ toplamlar.set(kaynak, (toplamlar.get(kaynak) ?? 0) + n);
+ console.log(` ${k.replace("|", " ")}: ${n}`);
+ }
+ for (const [kaynak, n] of toplamlar) console.log(` TOPLAM ${kaynak}: ${n}`);
+ if (doldurulan.size === 0) console.log(" (yok)");
+
+ console.log("\nDolu olup kaynağın FARKLI söylediği alanlar (EZİLMEDİ):");
+ for (const [k, f] of [...farkli].sort()) {
+ console.log(` ${k.replace("|", " ")}: ${f.adet} ör. ${f.ornek.join(" ; ")}`);
+ }
+ if (farkli.size === 0) console.log(" (yok)");
+
+ if (!kuru) db.pragma("wal_checkpoint(TRUNCATE)");
+ db.close();
+}
+
+if (process.argv[1]?.endsWith("gecmis-doldur.ts")) calistir(main);
diff --git a/scripts/refresh.ts b/scripts/refresh.ts
index ec9cbc0..ac891ea 100644
--- a/scripts/refresh.ts
+++ b/scripts/refresh.ts
@@ -1,24 +1,66 @@
/**
- * Canlı YÖK Atlas API'sinden 2025 yerleştirme verisini çekip DB'yi tazeler.
+ * Canlı YÖK Atlas API'sinden SON yerleştirme yılının verisini çekip DB'ye işler.
*
* Endpoint: POST https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search
- * (yokatlas-py'nin kullandığı resmî tercih-kılavuz JSON API'si)
*
- * - Mevcut programlarda sira/puan/kontenjan/yerlesen 2025 kolonlarını günceller
- * - DB'de olmayan (yeni açılan) programları ekler
- * - Az yerleşeni olan programlarda API basariSirasi döndürmez → NULL kalır
+ * API'nin yıl düzeni (Eylül 2026'da doğrulandı): EKSİZ alanlar (basariSirasi,
+ * minPuan, kontenjan, gkY…) en son yerleştirme yılıdır; `…1` bir önceki,
+ * `…2` iki önceki, `…3` üç önceki yıldır. Kayıttaki `yil` alanı GÜVENİLMEZ
+ * (kılavuz yılı / veri yılı anlamı yıl içinde değişiyor) — bu betik yılı
+ * HİÇBİR ZAMAN oradan türetmez. Yıl `--yil` ile verilir ve YIL KİLİDİ ile
+ * DB'ye karşı doğrulanır.
*
- * Kullanım: npx tsx scripts/refresh.ts
+ * Güvenlik sırası:
+ * 1. `--yil` = SON_YIL, `--db` zorunlu (varsayılan yok).
+ * 2. YIL KİLİDİ: ilk sayfadaki `basariSirasi1` değerleri DB'deki
+ * sira ile ≥%95 eşleşmeli VE eksiz `basariSirasi` ondan farklı
+ * olmalı. Değilse API henüz yeni yıla dönmemiştir (ya da bir yıl daha
+ * ilerlemiştir) → HİÇBİR ŞEY yazılmadan durur.
+ * 3. Tüm sayfalar önce belleğe + ham dosyaya alınır; benzersiz kod sayısı =
+ * `totalElements` değilse (sayfalama kayması) durur.
+ * 4. DB yazımı TEK transaction; önceki yılların kolon sağlaması transaction
+ * içinde karşılaştırılır, fark varsa geri alınır.
+ *
+ * Yazdıkları: sira/puan/kontenjan/yerlesen, son_kilavuz_yili, eski_kod.
+ * Mevcut satırların ÖNCEKİ yıl kolonlarına dokunmaz (onları yalnız NULL ise
+ * gecmis-doldur.ts doldurur). Yeni program eklerken `…1/…2/…3` alanlarından
+ * önceki üç yılı da yazar. API "veri yok" için 0 döndürür → NULL.
+ *
+ * Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı.
+ * `--ham ` verilirse API'ye HİÇ gidilmez; daha önce kaydedilmiş ham
+ * dosyadan aynı işlem tekrarlanır (kopyada deneme → gerçek DB'ye uygulama).
+ *
+ * Kullanım:
+ * pnpm refresh --yil 2026 --db /tmp/deneme.db
+ * pnpm refresh --yil 2026 --db data/yokatlas.db --ham data/ham/2026-2026-09-21.jsonl.gz
*/
+import fs from "node:fs";
import path from "node:path";
-import Database from "better-sqlite3";
+import readline from "node:readline";
+import zlib from "node:zlib";
+import { ILK_YIL } from "../src/lib/veri-yillari";
+import { efektifIndeksiKur } from "./db-index";
import { uniAdiNormalize, uniturOnar } from "./unitur-onar";
+import {
+ argOku,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ metaYaz,
+ yilArgumani,
+ yilKolonlariniGarantiEt,
+ yokatlasGoc,
+ type Db,
+} from "./yokatlas-goc";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500;
-const DELAY_MS = 300;
+const DELAY_MS = 500;
+const KILIT_ESIK = 0.95;
+const KILIT_ASGARI_ORNEK = 50;
-const TUR_MAP: Record = {
+export const TUR_MAP: Record = {
SAY: "SAYISAL",
EA: "EŞİT AĞIRLIK",
SÖZ: "SÖZEL",
@@ -26,27 +68,75 @@ const TUR_MAP: Record = {
TYT: "TYT",
};
-type ApiRecord = {
+/** API kaydından betiklerin kullandığı alanlar (ham dosyada kaydın tamamı durur). */
+export type KilavuzKaydi = {
kilavuzKodu: number;
+ eskiKilavuzKodu?: number | null;
birimAdi: string;
universiteAdi: string;
- universiteTuru: string | null;
- ilAdi: string | null;
- fymkAdi: string | null;
+ universiteTuru?: string | null;
+ ilAdi?: string | null;
+ fymkAdi?: string | null;
puanTuru: string;
- ogrenimSuresi: number | null;
+ ogrenimSuresi?: number | null;
birimTuruId: number;
- basariSirasi?: number | null;
- minPuan?: number | null;
+ basariSirasi?: number | string | null;
+ minPuan?: number | string | null;
kontenjan?: number | null;
gkY?: number | null;
sgyY?: number | null;
dprmY?: number | null;
obkY?: number | null;
+ basariSirasi1?: number | string | null;
+ basariSirasi2?: number | string | null;
+ basariSirasi3?: number | string | null;
+ minPuan1?: number | string | null;
+ minPuan2?: number | string | null;
+ minPuan3?: number | string | null;
+ gk1?: number | null;
+ gk2?: number | null;
+ gk3?: number | null;
};
+const TUTULAN_ALANLAR: (keyof KilavuzKaydi)[] = [
+ "kilavuzKodu",
+ "eskiKilavuzKodu",
+ "birimAdi",
+ "universiteAdi",
+ "universiteTuru",
+ "ilAdi",
+ "fymkAdi",
+ "puanTuru",
+ "ogrenimSuresi",
+ "birimTuruId",
+ "basariSirasi",
+ "minPuan",
+ "kontenjan",
+ "gkY",
+ "sgyY",
+ "dprmY",
+ "obkY",
+ "basariSirasi1",
+ "basariSirasi2",
+ "basariSirasi3",
+ "minPuan1",
+ "minPuan2",
+ "minPuan3",
+ "gk1",
+ "gk2",
+ "gk3",
+];
+
+function inceKayit(ham: Record): KilavuzKaydi {
+ const ince: Record = {};
+ for (const alan of TUTULAN_ALANLAR) {
+ if (ham[alan] !== undefined) ince[alan] = ham[alan];
+ }
+ return ince as KilavuzKaydi;
+}
+
type ApiPage = {
- content: ApiRecord[];
+ content: Record[];
totalPages: number;
totalElements: number;
};
@@ -83,140 +173,435 @@ async function fetchPage(page: number): Promise {
return (await res.json()) as ApiPage;
}
-function yerlesen(r: ApiRecord): number | null {
- const parts = [r.gkY, r.sgyY, r.dprmY, r.obkY].filter(
- (x): x is number => typeof x === "number"
- );
- return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
-}
-
-function intOrNull(v: unknown): number | null {
+/** Pozitif tam sayı; 0 / boş / sayı değil → NULL (API "veri yok"u 0 ile verir). */
+export function intOrNull(v: unknown): number | null {
const n = typeof v === "string" ? Number.parseFloat(v) : (v as number);
return typeof n === "number" && Number.isFinite(n) && n > 0
? Math.round(n)
: null;
}
-function realOrNull(v: unknown): number | null {
+export function realOrNull(v: unknown): number | null {
const n = typeof v === "string" ? Number.parseFloat(v) : (v as number);
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : null;
}
-async function main() {
- const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
- db.pragma("journal_mode = WAL");
+function yerlesen(r: KilavuzKaydi): number | null {
+ const parts = [r.gkY, r.sgyY, r.dprmY, r.obkY].filter(
+ (x): x is number => typeof x === "number"
+ );
+ return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
+}
- const cols = db.prepare("PRAGMA table_info(programs)").all() as {
- name: string;
- }[];
- const has2025 = cols.some((c) => c.name === "sira2025");
- if (!has2025) {
- db.exec(`
- ALTER TABLE programs ADD COLUMN sira2025 INTEGER;
- ALTER TABLE programs ADD COLUMN puan2025 REAL;
- ALTER TABLE programs ADD COLUMN kontenjan2025 INTEGER;
- ALTER TABLE programs ADD COLUMN yerlesen2025 INTEGER;
- CREATE INDEX IF NOT EXISTS idx_programs_tur_sira25 ON programs (tur, sira2025);
- `);
+/**
+ * API `eskiKilavuzKodu` iki anlamda geliyor: gerçek eski program kodu (9 hane)
+ * ya da yeni açılan programda üniversite öneki (4 hane, ör. 2031). Yalnız
+ * gerçek program kodu biçimindeki ve yeni koddan farklı olanı döndürür.
+ */
+export function eskiKod(r: KilavuzKaydi): string | null {
+ if (!r.eskiKilavuzKodu) return null;
+ const eski = String(r.eskiKilavuzKodu);
+ const yeni = String(r.kilavuzKodu);
+ return eski.length === yeni.length && eski !== yeni ? eski : null;
+}
+
+/** Yeni programda önceki üç yılın API karşılığı: [yıl farkı, sıra, puan, genel kontenjan]. */
+export function gecmisAlanlari(
+ r: KilavuzKaydi
+): { fark: number; sira: number | null; puan: number | null; kontenjan: number | null }[] {
+ return [
+ { fark: 1, sira: intOrNull(r.basariSirasi1), puan: realOrNull(r.minPuan1), kontenjan: intOrNull(r.gk1) },
+ { fark: 2, sira: intOrNull(r.basariSirasi2), puan: realOrNull(r.minPuan2), kontenjan: intOrNull(r.gk2) },
+ { fark: 3, sira: intOrNull(r.basariSirasi3), puan: realOrNull(r.minPuan3), kontenjan: intOrNull(r.gk3) },
+ ];
+}
+
+// ── Ham dosya ────────────────────────────────────────────────────────────
+
+/** `--ham` verilmemişse o yılın en yeni ham dosyası (ad sırası = tarih sırası). */
+export function sonHamDosya(yil: number, dizin: string): string | null {
+ if (!fs.existsSync(dizin)) return null;
+ const adaylar = fs
+ .readdirSync(dizin)
+ .filter((f) => f.startsWith(`${yil}-`) && f.endsWith(".jsonl.gz"))
+ .sort();
+ return adaylar.length ? path.join(dizin, adaylar[adaylar.length - 1]) : null;
+}
+
+export type HamOkuma = { toplam: number; kayitlar: KilavuzKaydi[] };
+
+/** Ham .jsonl.gz → ince kayıtlar. İlk satır `_meta` (yıl doğrulanır). */
+export async function hamOku(dosya: string, yil: number): Promise {
+ if (!fs.existsSync(dosya)) dur(`Ham dosya yok: ${dosya}`);
+ const rl = readline.createInterface({
+ input: fs.createReadStream(dosya).pipe(zlib.createGunzip()),
+ crlfDelay: Infinity,
+ });
+ let toplam = -1;
+ const kayitlar: KilavuzKaydi[] = [];
+ for await (const satir of rl) {
+ if (!satir) continue;
+ const obj = JSON.parse(satir) as Record;
+ if (obj._meta) {
+ const meta = obj._meta as { yil: number; totalElements: number };
+ if (meta.yil !== yil) {
+ dur(`Ham dosya ${meta.yil} yılı için çekilmiş, --yil ${yil} verildi.`);
+ }
+ toplam = meta.totalElements;
+ continue;
+ }
+ kayitlar.push(inceKayit(obj));
+ }
+ if (toplam < 0) dur(`Ham dosyada _meta satırı yok: ${dosya}`);
+ return { toplam, kayitlar };
+}
+
+// ── Yıl kilidi ───────────────────────────────────────────────────────────
+
+/**
+ * `basariSirasi1` = DB sira (≥%95) VE eksiz `basariSirasi` ≠ DB
+ * sira. İkisi birlikte "eksiz alanlar gerçekten verisi" demektir.
+ */
+export function yilKilidi(db: Db, yil: number, ornek: KilavuzKaydi[]): void {
+ const onceki = yil - 1;
+ const bul = db.prepare(`SELECT sira${onceki} AS s FROM programs WHERE id = ?`);
+ let kiyas1 = 0;
+ let esit1 = 0;
+ let kiyas0 = 0;
+ let esit0 = 0;
+ for (const r of ornek) {
+ if (!r.kilavuzKodu) continue;
+ let satir = bul.get(String(r.kilavuzKodu)) as { s: number | null } | undefined;
+ const eski = eskiKod(r);
+ if (!satir && eski) satir = bul.get(eski) as typeof satir;
+ if (!satir || satir.s == null) continue;
+ const s1 = intOrNull(r.basariSirasi1);
+ if (s1 != null) {
+ kiyas1++;
+ if (s1 === satir.s) esit1++;
+ }
+ const s0 = intOrNull(r.basariSirasi);
+ if (s0 != null) {
+ kiyas0++;
+ if (s0 === satir.s) esit0++;
+ }
+ }
+ const oran1 = kiyas1 ? esit1 / kiyas1 : 0;
+ const oran0 = kiyas0 ? esit0 / kiyas0 : 0;
+ console.log(
+ `Yıl kilidi: basariSirasi1 = sira${onceki} → ${esit1}/${kiyas1} (%${(oran1 * 100).toFixed(1)}); ` +
+ `eksiz basariSirasi = sira${onceki} → ${esit0}/${kiyas0} (%${(oran0 * 100).toFixed(1)})`
+ );
+ if (kiyas1 < KILIT_ASGARI_ORNEK) {
+ dur(
+ `Yıl kilidi için yeterli karşılaştırma yok (${kiyas1} < ${KILIT_ASGARI_ORNEK}). ` +
+ `DB'de sira${onceki} dolu mu, doğru DB mi?`
+ );
+ }
+ if (oran0 >= 0.5) {
+ dur(
+ `API'nin eksiz alanları hâlâ ${onceki} verisi (DB ile %${(oran0 * 100).toFixed(1)} aynı). ` +
+ `${yil} yerleştirme sonuçları henüz yayımlanmamış; bugün koşmak ${yil} kolonuna ${onceki} verisi yazar.`
+ );
+ }
+ if (oran1 < KILIT_ESIK) {
+ dur(
+ `API'nin "…1" alanları DB'deki ${onceki} verisiyle eşleşmiyor (%${(oran1 * 100).toFixed(1)} < %95). ` +
+ `API yıl düzeni değişmiş olabilir; --yil ${yil} ile yazmak yanlış kolonu doldurur.`
+ );
+ }
+}
+
+// ── Ana akış ─────────────────────────────────────────────────────────────
+
+async function apidenCek(
+ yil: number,
+ hamDizin: string,
+ ilkSayfaKontrol: (ornek: KilavuzKaydi[]) => void
+): Promise {
+ fs.mkdirSync(hamDizin, { recursive: true });
+ const tarih = new Date().toISOString().slice(0, 10);
+ const hamDosya = path.join(hamDizin, `${yil}-${tarih}.jsonl.gz`);
+ const gecici = `${hamDosya}.tmp`;
+
+ const first = await fetchPage(0);
+ const kayitlar = first.content.map(inceKayit);
+ // Kilit tutmazsa hiçbir şey yazılmadan ve kaynağa 1 istekle durulur.
+ ilkSayfaKontrol(kayitlar);
+
+ console.log(
+ `Toplam ${first.totalElements} kayıt, ${first.totalPages} sayfa çekilecek ` +
+ `(~${Math.ceil((first.totalPages * (DELAY_MS + 1500)) / 1000)} sn)…`
+ );
+ const gzip = zlib.createGzip();
+ const cikti = fs.createWriteStream(gecici);
+ gzip.pipe(cikti);
+ const bitti = new Promise((resolve, reject) => {
+ cikti.on("finish", resolve);
+ cikti.on("error", reject);
+ gzip.on("error", reject);
+ });
+ const yaz = (obj: unknown) => gzip.write(`${JSON.stringify(obj)}\n`);
+
+ yaz({
+ _meta: {
+ yil,
+ cekimTarihi: new Date().toISOString(),
+ kaynak: API,
+ totalElements: first.totalElements,
+ totalPages: first.totalPages,
+ sayfaBoyu: PAGE_SIZE,
+ },
+ });
+ for (const r of first.content) yaz(r);
+ for (let p = 1; p < first.totalPages; p++) {
+ await sleep(DELAY_MS);
+ const page = await fetchPage(p);
+ for (const r of page.content) {
+ yaz(r);
+ kayitlar.push(inceKayit(r));
+ }
+ process.stdout.write(`\rsayfa ${p + 1}/${first.totalPages}`);
+ }
+ console.log();
+ gzip.end();
+ await bitti;
+ fs.renameSync(gecici, hamDosya);
+ console.log(`Ham veri: ${hamDosya}`);
+ return { toplam: first.totalElements, kayitlar, hamDosya };
+}
+
+function oncekiYilSaglamasi(db: Db, yil: number, azamiRowid: number): string {
+ const kolonlar: string[] = [];
+ for (let y = ILK_YIL; y < yil; y++) {
+ for (const olcu of ["sira", "puan", "kontenjan", "yerlesen"]) {
+ kolonlar.push(`COUNT(${olcu}${y})`, `TOTAL(${olcu}${y})`);
+ }
+ }
+ const r = db
+ .prepare(`SELECT COUNT(*), ${kolonlar.join(", ")} FROM programs WHERE rowid <= ?`)
+ .raw()
+ .get(azamiRowid) as number[];
+ return r.join("|");
+}
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const hamArg = argOku("ham");
+ const hamDizin = path.resolve(process.cwd(), argOku("ham-dizin") ?? path.join("data", "ham"));
+
+ const db = dbAc(dbYolu);
+ console.log(`DB: ${dbYolu}\nYıl: ${yil}`);
+
+ // Şema (idempotent). Yeni programlarda …1/…2/…3 yazılacağı için önceki üç
+ // yılın kolonları da garanti edilir (normalde zaten vardır).
+ const eklenen = yokatlasGoc(db, yil);
+ for (let fark = 1; fark <= 3; fark++) {
+ if (yil - fark >= ILK_YIL) eklenen.push(...yilKolonlariniGarantiEt(db, yil - fark));
+ }
+ if (eklenen.length) console.log(`Göç: ${eklenen.join(", ")}`);
+
+ // ── Veri: API ya da ham dosya ──────────────────────────────────────────
+ let toplam: number;
+ let kayitlar: KilavuzKaydi[];
+ let hamDosya: string;
+ if (hamArg) {
+ hamDosya = path.resolve(process.cwd(), hamArg);
+ console.log(`Kaynak: ham dosya (API'ye gidilmiyor) → ${hamDosya}`);
+ ({ toplam, kayitlar } = await hamOku(hamDosya, yil));
+ yilKilidi(db, yil, kayitlar.slice(0, PAGE_SIZE));
+ } else {
+ ({ toplam, kayitlar, hamDosya } = await apidenCek(yil, hamDizin, (ornek) =>
+ yilKilidi(db, yil, ornek)
+ ));
}
- // Sorgu yolunun kullandığı ifade indexi; ifade src/lib/db.ts EFEKTIF_SIRA
- // ile birebir aynı olmalı (bkz. scripts/db-index.ts). sira2025 kolonu
- // yukarıda garanti edildikten sonra kurulur.
- db.exec(`
- CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
- ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
- `);
+ // ── Bütünlük: benzersiz kod = totalElements ────────────────────────────
+ const benzersiz = new Map();
+ let kodsuz = 0;
+ for (const r of kayitlar) {
+ if (!r.kilavuzKodu) {
+ kodsuz++;
+ continue;
+ }
+ benzersiz.set(String(r.kilavuzKodu), r);
+ }
+ console.log(
+ `Çekilen: ${kayitlar.length} kayıt, benzersiz kod: ${benzersiz.size}, kodsuz: ${kodsuz}, API toplamı: ${toplam}`
+ );
+ if (benzersiz.size + kodsuz !== toplam || kayitlar.length !== toplam) {
+ dur(
+ `Benzersiz kod sayısı (${benzersiz.size}) + kodsuz (${kodsuz}) ≠ API toplamı (${toplam}). ` +
+ "Sayfalama sırasında kayıt kaymış/yinelenmiş; DB'ye HİÇBİR ŞEY yazılmadı. Tekrar dene."
+ );
+ }
+ // ── Yazım: tek transaction ─────────────────────────────────────────────
const update = db.prepare(`
- UPDATE programs SET sira2025 = ?, puan2025 = ?, kontenjan2025 = ?, yerlesen2025 = ?
+ UPDATE programs
+ SET sira${yil} = ?, puan${yil} = ?, kontenjan${yil} = ?, yerlesen${yil} = ?,
+ son_kilavuz_yili = ?, eski_kod = COALESCE(?, eski_kod)
WHERE id = ?
`);
+ const gecmisYillar = [1, 2, 3].filter((f) => yil - f >= ILK_YIL);
const insert = db.prepare(`
INSERT INTO programs (id, isim, universite, unitur, il, fakulte, tur, sure, onlisans,
- sira2025, puan2025, kontenjan2025, yerlesen2025)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ sira${yil}, puan${yil}, kontenjan${yil}, yerlesen${yil},
+ son_kilavuz_yili, eski_kod
+ ${gecmisYillar.map((f) => `, sira${yil - f}, puan${yil - f}, kontenjan${yil - f}`).join("")})
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${gecmisYillar.map(() => ", ?, ?, ?").join("")})
`);
const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
+ // Yeni satırda yazım/sözlük tutarlılığı — TAHMİN YOK, yalnız DB'de zaten var
+ // olan yazımı yeniden kullanma:
+ // - unitur: API sözlüğü (DEVLET/VAKIF/KKTC/YURTDIŞI) DB sözlüğünden (VAKIF MYO,
+ // YURTDISI KAMU…) farklı. Aynı üniversitenin mevcut satırlarında TEK bir
+ // unitur varsa o kullanılır; yoksa API değeri olduğu gibi yazılır. Aksi
+ // hâlde aynı kurumda iki unitur oluşur ve uniturOnar haklı olarak durur.
+ // - fakulte: API 2026'da BÜYÜK HARF veriyor; aynı üniversitede büyük harfe
+ // çevrilince aynı olan mevcut yazım varsa o kullanılır.
+ const bazAd = (ad: string) => ad.replace(/\s*\([^)]*\)\s*$/, "").trim();
+ const uniturlar = new Map>();
+ const fakulteler = new Map>();
+ for (const s of db
+ .prepare("SELECT DISTINCT universite, unitur, fakulte FROM programs")
+ .all() as { universite: string; unitur: string | null; fakulte: string | null }[]) {
+ const baz = bazAd(s.universite);
+ if (s.unitur) {
+ for (const k of [s.universite, baz]) {
+ const set = uniturlar.get(k) ?? new Set();
+ set.add(s.unitur);
+ uniturlar.set(k, set);
+ }
+ }
+ if (s.fakulte) {
+ const m = fakulteler.get(baz) ?? new Map();
+ const anahtar = s.fakulte.toLocaleUpperCase("tr-TR");
+ // Aynı anahtara iki farklı yazım düşerse belirsiz → yeniden kullanma
+ m.set(anahtar, m.has(anahtar) && m.get(anahtar) !== s.fakulte ? "" : s.fakulte);
+ fakulteler.set(baz, m);
+ }
+ }
+ const uniturSec = (universite: string, api: string | null): string | null => {
+ for (const k of [universite, bazAd(universite)]) {
+ const set = uniturlar.get(k);
+ if (set?.size === 1) return [...set][0];
+ }
+ return api;
+ };
+ const fakulteSec = (universite: string, api: string | null): string | null => {
+ if (!api) return null;
+ const mevcut = fakulteler.get(bazAd(universite))?.get(api.toLocaleUpperCase("tr-TR"));
+ return mevcut || api;
+ };
+
let updated = 0;
let inserted = 0;
- let skipped = 0;
+ const atlananlar: string[] = [];
- const first = await fetchPage(0);
- const totalPages = first.totalPages;
- console.log(
- `Toplam ${first.totalElements} kayıt, ${totalPages} sayfa çekilecek…`
- );
+ db.transaction(() => {
+ const azamiRowid = (
+ db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM programs").get() as { m: number }
+ ).m;
+ const once = oncekiYilSaglamasi(db, yil, azamiRowid);
- const processPage = db.transaction((records: ApiRecord[]) => {
- for (const r of records) {
+ for (const [id, r] of benzersiz) {
const tur = TUR_MAP[r.puanTuru?.trim() ?? ""];
- if (!r.kilavuzKodu || !tur) {
- skipped++;
+ // Puan türü yalnız YENİ satır için gerekir; mevcut satırın türü DB'de var.
+ // Kaynak türü vermiyorsa yeni satır UYDURULMAZ, atlanır ve raporlanır.
+ if (!tur && !exists.get(id)) {
+ atlananlar.push(`${id} ${r.universiteAdi} / ${r.birimAdi} (puanTuru="${r.puanTuru}")`);
continue;
}
- const id = String(r.kilavuzKodu);
const vals = [
intOrNull(r.basariSirasi),
realOrNull(r.minPuan),
intOrNull(r.kontenjan),
yerlesen(r),
];
+ const eski = eskiKod(r);
if (exists.get(id)) {
- update.run(...vals, id);
+ update.run(...vals, yil, eski, id);
updated++;
} else {
+ const gecmis = gecmisAlanlari(r).filter((g) => yil - g.fark >= ILK_YIL);
+ // "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
+ // duplikat kurum adı üretmesin
+ const universite = uniAdiNormalize(r.universiteAdi);
insert.run(
id,
r.birimAdi,
- // "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
- // duplikat kurum adı üretmesin
- uniAdiNormalize(r.universiteAdi),
- r.universiteTuru ?? null,
+ universite,
+ uniturSec(universite, r.universiteTuru ?? null),
r.ilAdi ?? null,
- r.fymkAdi ?? null,
+ fakulteSec(universite, r.fymkAdi ?? null),
tur,
r.ogrenimSuresi ?? null,
r.birimTuruId === 47 ? 1 : 0,
- ...vals
+ ...vals,
+ yil,
+ eski,
+ ...gecmis.flatMap((g) => [g.sira, g.puan, g.kontenjan])
);
inserted++;
}
}
- });
- processPage(first.content);
- for (let p = 1; p < totalPages; p++) {
- await sleep(DELAY_MS);
- const page = await fetchPage(p);
- processPage(page.content);
- process.stdout.write(`\rsayfa ${p + 1}/${totalPages}`);
- }
- console.log();
+ const sonra = oncekiYilSaglamasi(db, yil, azamiRowid);
+ if (once !== sonra) {
+ dur(
+ "Mevcut satırların önceki yıl kolonları değişti — bu betik onlara dokunmamalı. Geri alındı.\n" +
+ `önce : ${once}\nsonra: ${sonra}`
+ );
+ }
+ const gorulen = (
+ db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
+ ).n;
+ const beklenen = toplam - kodsuz - atlananlar.length;
+ if (gorulen !== beklenen) {
+ dur(
+ `son_kilavuz_yili=${yil} satır sayısı ${gorulen}, beklenen ${beklenen} ` +
+ `(API ${toplam} − atlanan ${kodsuz + atlananlar.length}). Geri alındı.`
+ );
+ }
+
+ metaYaz(db, `refresh_${yil}_api_toplam`, toplam);
+ metaYaz(db, `refresh_${yil}_atlanan`, kodsuz + atlananlar.length);
+ metaYaz(db, `refresh_${yil}_tarih`, new Date().toISOString());
+ metaYaz(db, `refresh_${yil}_ham`, path.basename(hamDosya));
+ })();
- const stats = db
- .prepare(
- `SELECT COUNT(*) AS toplam,
- SUM(CASE WHEN sira2025 IS NOT NULL THEN 1 ELSE 0 END) AS sirali
- FROM programs`
- )
- .get() as { toplam: number; sirali: number };
console.log(
- `Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${skipped}`
- );
- console.log(
- `DB toplam: ${stats.toplam} program, 2025 sıralaması olan: ${stats.sirali}`
+ `Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${kodsuz + atlananlar.length}`
);
+ for (const a of atlananlar) console.log(` atlandı: ${a}`);
// API'nin universiteTuru döndürmediği kayıtlar devlet/vakıf filtrelerinden
// düşmesin: her tazelemeden sonra unitur onarımı koşulur (idempotent).
db.transaction(() => uniturOnar(db))();
+ // Sorgu yolunun ifade indeksi — ifade veri-yillari.ts'ten; eski yılınki DROP.
+ console.log(`Efektif indeks: ${efektifIndeksiKur(db)}`);
+
+ const stats = db
+ .prepare(
+ `SELECT COUNT(*) AS toplam,
+ SUM(son_kilavuz_yili = ${yil}) AS guncel,
+ COUNT(sira${yil}) AS sirali
+ FROM programs`
+ )
+ .get() as { toplam: number; guncel: number; sirali: number };
+ console.log(
+ `DB toplam: ${stats.toplam} program; ${yil} kılavuzunda: ${stats.guncel}; ` +
+ `${yil} sıralaması olan: ${stats.sirali}`
+ );
+ db.pragma("wal_checkpoint(TRUNCATE)");
db.close();
+ console.log(
+ `\nSıradaki adım: pnpm gecmis-doldur --yil ${yil} --db --ham ${path.relative(process.cwd(), hamDosya)}`
+ );
}
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
+if (process.argv[1]?.endsWith("refresh.ts")) calistir(main);
diff --git a/scripts/tadimlik-uret.ts b/scripts/tadimlik-uret.ts
index 697c6dd..f27ed2c 100644
--- a/scripts/tadimlik-uret.ts
+++ b/scripts/tadimlik-uret.ts
@@ -38,6 +38,7 @@ import {
} from "../src/lib/tadimlik-havuzu";
import { anahtarVar } from "../src/lib/ai/client";
import { yapilandirilmisUret } from "../src/lib/ai/cagri";
+import { ILK_YIL, VERI_YIL_SAYISI } from "../src/lib/veri-yillari";
// Next dışında koşuyoruz; .env dosyalarını elle yükle (mevcut env kazanır).
function envYukle(dosya: string): void {
@@ -72,8 +73,8 @@ KURALLAR (kesin):
riskli" etiketleriyle konuş.
- Programın "dilim" alanındaki "garanti" değeri teknik anahtardır; adaya dönük
hiçbir metinde bu kelimeyi kullanma, o dilimi "güvenli" diye an.
-- trendOzeti: son 5 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
- beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
+- trendOzeti: son ${VERI_YIL_SAYISI} yılın taban sıralaması hareketi, TEK cümle
+ (ör. "2022'den beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
- Adayın sıralaması yaklaşıktır; kesin sıra telaffuz etme, banda göre konuş.`;
function mockAktif(): boolean {
@@ -87,27 +88,30 @@ function mockMetin(p: Program, temsiliSira: number, kategori: string | null) {
riskNotu:
"(Dev mock) Son taban sıralaması sırana yakın; küçük bir kayma sonucu değiştirebilir.",
trendOzeti:
- "(Dev mock) Son 5 yılda taban sıralaması görece stabil seyretti.",
+ `(Dev mock) Son ${VERI_YIL_SAYISI} yılda taban sıralaması görece stabil seyretti.`,
};
}
/** rapor.ts programBilgisi ile aynı snapshot şekli. */
function programBilgisi(p: Program) {
- const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
+ const gecmis = p.siraGecmisi;
+ const gecmisVar = gecmis.some((s) => s != null);
return {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
- efektifSira: p.sira2025 ?? p.sira2024,
- siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
+ efektifSira: p.efektifSira,
+ siraGecmisi: gecmisVar ? gecmis : undefined,
+ ilkYil: gecmisVar ? ILK_YIL : undefined,
};
}
/**
* Deterministik program seçimi: kovanın temsili sırası için "dengeli" dilimden
- * (boşsa "garanti") kategoriye ve üniversite tipine uyan, 2025'te en çok
- * yerleşen alan program. Tanınır/dolu programlar tadımlıkta daha ikna edici.
+ * (boşsa "garanti") kategoriye ve üniversite tipine uyan, son yerleştirme
+ * yılında en çok yerleşen alan program. Tanınır/dolu programlar tadımlıkta
+ * daha ikna edici.
*/
function programSec(
temsiliSira: number,
@@ -125,7 +129,7 @@ function programSec(
: programlar;
if (uygunlar.length === 0) continue;
const secilen = [...uygunlar].sort(
- (a, b) => (b.yerlesen2025 ?? -1) - (a.yerlesen2025 ?? -1),
+ (a, b) => (b.yerlesenSon ?? -1) - (a.yerlesenSon ?? -1),
)[0];
return { program: secilen, dilim };
}
diff --git a/scripts/veri-kalite.ts b/scripts/veri-kalite.ts
new file mode 100644
index 0000000..6309f91
--- /dev/null
+++ b/scripts/veri-kalite.ts
@@ -0,0 +1,228 @@
+/**
+ * yokatlas.db veri kalitesi denetimi — SALT OKUNUR. Yayın öncesi kapı:
+ * bir ZORUNLU kontrol başarısızsa çıkış kodu 1 (CI/runbook durur).
+ * UYARI'lar çıkış kodunu etkilemez; insan gözüyle bakılır.
+ *
+ * Kullanım:
+ * pnpm veri-kalite --yil 2026 --db /tmp/deneme.db [--onceki data/yokatlas.db] [--arsivli]
+ *
+ * --onceki Güncelleme ÖNCESİ DB. Verilirse: iki DB'de de bulunan her
+ * programın önceki yıl kolonları (sira/puan/kontenjan/yerlesen)
+ * birebir aynı mı (dolu değer değişmiş/silinmiş olamaz).
+ * --arsivli programs içinde güncel kılavuzda olmayan satır kalmamış olmalı.
+ */
+import path from "node:path";
+import { EFEKTIF_SIRA_SQL, ILK_YIL } from "../src/lib/veri-yillari";
+import { EFEKTIF_INDEKS, efektifSorguPlani } from "./db-index";
+import {
+ argOku,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ metaOku,
+ tabloVar,
+ yilArgumani,
+} from "./yokatlas-goc";
+
+const TURLER = ["SAYISAL", "EŞİT AĞIRLIK", "SÖZEL", "DİL", "TYT"];
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const oncekiArg = argOku("onceki");
+ const arsivli = bayrakVar("arsivli");
+ const db = dbAc(dbYolu, { saltOkunur: true });
+ const onceki = yil - 1;
+
+ let basarisiz = 0;
+ let uyari = 0;
+ const zorunlu = (ad: string, tamam: boolean, detay: string) => {
+ console.log(`${tamam ? "GEÇTİ " : "KALDI "} ${ad} — ${detay}`);
+ if (!tamam) basarisiz++;
+ };
+ const bilgi = (ad: string, sorunlu: boolean, detay: string) => {
+ console.log(`${sorunlu ? "UYARI " : "bilgi "} ${ad} — ${detay}`);
+ if (sorunlu) uyari++;
+ };
+ const sayi = (sql: string, ...args: unknown[]) =>
+ (db.prepare(sql).get(...args) as { n: number }).n;
+ const ornek = (sql: string, ...args: unknown[]) =>
+ (db.prepare(sql).raw().all(...args) as unknown[][]).map((r) => r.join("/")).join(" ; ");
+
+ console.log(`DB: ${dbYolu}\nYıl: ${yil} Efektif sıra: ${EFEKTIF_SIRA_SQL}\n`);
+
+ // 1. Dosya bütünlüğü
+ const butunluk = (db.pragma("integrity_check") as { integrity_check: string }[])
+ .map((r) => r.integrity_check)
+ .join(",");
+ zorunlu("integrity_check", butunluk === "ok", butunluk);
+
+ // 2. Şema
+ const kolonlar = new Set(kolonAdlari(db, "programs"));
+ const gerekli = [`sira${yil}`, `puan${yil}`, `kontenjan${yil}`, `yerlesen${yil}`, "son_kilavuz_yili", "eski_kod"];
+ const eksikKolon = gerekli.filter((k) => !kolonlar.has(k));
+ zorunlu("şema", eksikKolon.length === 0, eksikKolon.length ? `eksik: ${eksikKolon.join(", ")}` : "yıl kolonları + son_kilavuz_yili + eski_kod var");
+ if (eksikKolon.length) {
+ dur("Şema eksik; kalan kontroller koşulamaz.");
+ }
+
+ // 3. Kapsam: güncel satır = API toplamı − atlanan
+ const apiToplam = Number(metaOku(db, `refresh_${yil}_api_toplam`) ?? NaN);
+ const atlanan = Number(metaOku(db, `refresh_${yil}_atlanan`) ?? NaN);
+ const guncel = sayi("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?", yil);
+ zorunlu(
+ "kapsam",
+ Number.isFinite(apiToplam) && guncel === apiToplam - atlanan,
+ `son_kilavuz_yili=${yil}: ${guncel}; API toplamı ${apiToplam} − atlanan ${atlanan} = ${apiToplam - atlanan}`
+ );
+
+ // 4. Doluluk
+ const sirali = sayi(`SELECT COUNT(sira${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ const oncekiSirali = sayi(`SELECT COUNT(sira${onceki}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ const doluOran = guncel ? sirali / guncel : 0;
+ zorunlu(
+ `sira${yil} doluluk`,
+ doluOran >= 0.7,
+ `${sirali}/${guncel} (%${(doluOran * 100).toFixed(1)}; eşik %70). Aynı satırlarda sira${onceki} dolu: ${oncekiSirali}`
+ );
+ const puanli = sayi(`SELECT COUNT(puan${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ const kontenjanli = sayi(`SELECT COUNT(kontenjan${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ bilgi(`${yil} diğer ölçüler`, kontenjanli < guncel * 0.95, `puan dolu ${puanli}, kontenjan dolu ${kontenjanli}, toplam ${guncel}`);
+
+ // 5. Yıllar arası tutarlılık: sira Y / sira Y-1 ∈ [0,5; 2]
+ const ikisiDolu = sayi(`SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${onceki} IS NOT NULL`);
+ const aralikta = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${onceki} IS NOT NULL
+ AND sira${yil} * 1.0 / sira${onceki} BETWEEN 0.5 AND 2`
+ );
+ const aralikOran = ikisiDolu ? aralikta / ikisiDolu : 0;
+ zorunlu(
+ `sira${yil}/sira${onceki} ∈ [0,5; 2]`,
+ ikisiDolu > 0 && aralikOran >= 0.9,
+ `${aralikta}/${ikisiDolu} (%${(aralikOran * 100).toFixed(1)}; eşik %90)`
+ );
+ const ayniSira = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${yil} = sira${onceki} AND puan${yil} = puan${onceki}`
+ );
+ zorunlu(
+ "yıl kayması (kopya yıl)",
+ ikisiDolu > 0 && ayniSira / ikisiDolu < 0.02,
+ `sıra VE puanı ${onceki} ile birebir aynı satır: ${ayniSira}/${ikisiDolu} (çoksa ${yil} kolonuna ${onceki} verisi yazılmıştır)`
+ );
+
+ // 6. Değer aralıkları (tüm yıllar)
+ const yillar: number[] = [];
+ for (let y = ILK_YIL; y <= yil; y++) if (kolonlar.has(`sira${y}`)) yillar.push(y);
+ const bozukKosul = yillar
+ .map((y) => `sira${y} <= 0 OR sira${y} > 5000000 OR puan${y} <= 0 OR puan${y} > 600 OR kontenjan${y} < 0 OR yerlesen${y} < 0`)
+ .join(" OR ");
+ const bozuk = sayi(`SELECT COUNT(*) AS n FROM programs WHERE ${bozukKosul}`);
+ zorunlu("değer aralığı", bozuk === 0, `0/negatif/aşırı sıra-puan-kontenjan: ${bozuk}${bozuk ? ` ör. ${ornek(`SELECT id FROM programs WHERE ${bozukKosul} LIMIT 3`)}` : ""}`);
+
+ const siraPuanTutarsiz = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND puan${yil} IS NULL`
+ );
+ zorunlu(`sira${yil} var, puan${yil} yok`, siraPuanTutarsiz === 0, `${siraPuanTutarsiz} satır`);
+
+ // 7. Kategorik alanlar
+ const bozukTur = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE tur NOT IN (${TURLER.map(() => "?").join(",")}) OR onlisans NOT IN (0, 1)`,
+ ...TURLER
+ );
+ zorunlu("tur/onlisans", bozukTur === 0, `geçersiz değer: ${bozukTur}`);
+ const tytTutarsiz = sayi("SELECT COUNT(*) AS n FROM programs WHERE (tur = 'TYT') <> (onlisans = 1) AND son_kilavuz_yili = ?", yil);
+ bilgi("TYT ↔ önlisans", tytTutarsiz > 0, `tur=TYT ile onlisans=1 örtüşmeyen güncel satır: ${tytTutarsiz}${tytTutarsiz ? ` ör. ${ornek("SELECT id, tur, onlisans FROM programs WHERE (tur = 'TYT') <> (onlisans = 1) AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
+ const uniturBos = sayi("SELECT COUNT(*) AS n FROM programs WHERE unitur IS NULL AND son_kilavuz_yili = ?", yil);
+ bilgi("unitur boş", uniturBos > 0, `güncel satırlarda ${uniturBos} (devlet/vakıf filtresinden düşer)${uniturBos ? ` ör. ${ornek("SELECT DISTINCT universite FROM programs WHERE unitur IS NULL AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
+ const uniturlar = ornek("SELECT COALESCE(unitur, '(NULL)'), COUNT(*) FROM programs GROUP BY unitur ORDER BY 2 DESC");
+ bilgi("unitur dağılımı", false, uniturlar);
+ const ilBos = sayi("SELECT COUNT(*) AS n FROM programs WHERE (il IS NULL OR TRIM(il) = '') AND son_kilavuz_yili = ?", yil);
+ bilgi("il boş", ilBos > 0, `güncel satırlarda ${ilBos}${ilBos ? ` ör. ${ornek("SELECT id, universite FROM programs WHERE (il IS NULL OR TRIM(il) = '') AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
+
+ // 8. Kontenjan–yerleşen
+ const tasan = sayi(`SELECT COUNT(*) AS n FROM programs WHERE yerlesen${yil} > kontenjan${yil} * 1.5 + 5`);
+ bilgi(`yerlesen${yil} ≫ kontenjan${yil}`, tasan > guncel * 0.02, `yerleşen > 1,5×genel kontenjan + 5: ${tasan} (yerleşen okul birincisi/şehit-gazi/deprem kontenjanlarını da içerir)${tasan ? ` ör. ${ornek(`SELECT id, kontenjan${yil}, yerlesen${yil} FROM programs WHERE yerlesen${yil} > kontenjan${yil} * 1.5 + 5 LIMIT 3`)}` : ""}`);
+ const doluAmaSirasiz = sayi(`SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ? AND sira${yil} IS NULL AND yerlesen${yil} > 0`, yil);
+ bilgi(`yerleşen var, sira${yil} yok`, false, `${doluAmaSirasiz} (API az yerleşenli programda sıra vermez; efektif sıra ${onceki}'e düşer)`);
+
+ // 9. Eski/çift satırlar
+ const eskiSatir = sayi("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili IS NOT ?", yil);
+ if (arsivli) zorunlu("arşiv", eskiSatir === 0, `programs içinde güncel kılavuzda olmayan satır: ${eskiSatir}`);
+ else bilgi("arşivlenmemiş eski satır", eskiSatir > 0, `${eskiSatir} satır güncel kılavuzda yok ama sitede listelenir (efektif sıra ${onceki}'e düşer)`);
+ const ciftAd = sayi(
+ `SELECT COUNT(*) AS n FROM (SELECT 1 FROM programs WHERE son_kilavuz_yili = ?
+ GROUP BY universite, fakulte, isim, tur HAVING COUNT(*) > 1)`,
+ yil
+ );
+ bilgi("aynı adlı güncel program", false, `(universite, fakulte, isim, tur) yinelenen grup: ${ciftAd} (özel kontenjanlı adaş programlar olağandır)${ciftAd ? ` ör. ${ornek("SELECT universite, isim, COUNT(*) FROM programs WHERE son_kilavuz_yili = ? GROUP BY universite, fakulte, isim, tur HAVING COUNT(*) > 1 LIMIT 3", yil)}` : ""}`);
+ const eskiKodCakisma = sayi(
+ "SELECT COUNT(*) AS n FROM (SELECT eski_kod FROM programs WHERE eski_kod IS NOT NULL AND son_kilavuz_yili = ? GROUP BY eski_kod HAVING COUNT(*) > 1)",
+ yil
+ );
+ bilgi("eski_kod paylaşımı (bölünme)", eskiKodCakisma > 0, `birden çok güncel programın işaret ettiği eski kod: ${eskiKodCakisma}`);
+
+ // 10. netler
+ if (tabloVar(db, "netler")) {
+ const arsivVar = tabloVar(db, "programs_arsiv");
+ const yetim = sayi(
+ `SELECT COUNT(*) AS n FROM netler WHERE program_id NOT IN (SELECT id FROM programs)
+ ${arsivVar ? "AND program_id NOT IN (SELECT id FROM programs_arsiv)" : ""}`
+ );
+ bilgi("yetim netler", yetim > 0, `ne programs ne arşivde karşılığı olan: ${yetim}`);
+ const netliGuncel = sayi(
+ "SELECT COUNT(DISTINCT n.program_id) AS n FROM netler n JOIN programs p ON p.id = n.program_id WHERE p.son_kilavuz_yili = ?",
+ yil
+ );
+ bilgi("netler kapsamı", false, `net satırı olan güncel program: ${netliGuncel}/${guncel}; yıl dağılımı: ${ornek("SELECT yil, COUNT(*) FROM netler GROUP BY yil ORDER BY yil")}`);
+ }
+
+ // 11. Sorgu planı
+ const plan = efektifSorguPlani(db);
+ zorunlu("sorgu planı", plan.some((s) => s.includes(EFEKTIF_INDEKS)), plan.join(" | "));
+
+ // 12. Önceki DB ile geçmiş yıl karşılaştırması
+ if (oncekiArg) {
+ const oncekiYol = path.resolve(process.cwd(), oncekiArg);
+ db.prepare("ATTACH DATABASE ? AS eski").run(oncekiYol);
+ const eskiKolonlar = new Set(
+ (db.prepare("PRAGMA eski.table_info(programs)").all() as { name: string }[]).map((c) => c.name)
+ );
+ const ortak: string[] = [];
+ for (let y = ILK_YIL; y < yil; y++) {
+ for (const o of ["sira", "puan", "kontenjan", "yerlesen"]) {
+ if (kolonlar.has(`${o}${y}`) && eskiKolonlar.has(`${o}${y}`)) ortak.push(`${o}${y}`);
+ }
+ }
+ const simdi = tabloVar(db, "programs_arsiv")
+ ? `(SELECT id, ${ortak.join(", ")} FROM main.programs UNION ALL SELECT id, ${ortak.join(", ")} FROM main.programs_arsiv)`
+ : "main.programs";
+ const kaybolan = sayi(`SELECT COUNT(*) AS n FROM eski.programs e WHERE e.id NOT IN (SELECT id FROM ${simdi})`);
+ zorunlu("önceki DB: satır kaybı", kaybolan === 0, `önceki DB'de olup şimdi ne programs ne arşivde olan: ${kaybolan}`);
+ // Dolu değer değişmiş ya da NULL'a dönmüş olamaz; NULL → dolu (gecmis-doldur) serbest.
+ const degisen = sayi(
+ `SELECT COUNT(*) AS n FROM eski.programs e JOIN ${simdi} s ON s.id = e.id
+ WHERE ${ortak.map((k) => `(e.${k} IS NOT NULL AND e.${k} IS NOT s.${k})`).join(" OR ")}`
+ );
+ zorunlu("önceki DB: geçmiş yıllar korunmuş", degisen === 0, `${ortak.length} kolonda dolu değeri değişen/silinen satır: ${degisen}`);
+ const toplamlar = (tablo: string) =>
+ (db
+ .prepare(
+ `SELECT COUNT(*), TOTAL(sira${onceki}), TOTAL(puan${onceki}), TOTAL(kontenjan${onceki}), TOTAL(yerlesen${onceki}) FROM ${tablo}`
+ )
+ .raw()
+ .get() as number[]).join(" | ");
+ console.log(` ${onceki} sağlaması (count | Σsira | Σpuan | Σkontenjan | Σyerlesen)`);
+ console.log(` önceki DB : ${toplamlar("eski.programs")}`);
+ console.log(` şimdi, önceki DB'deki aynı id'ler : ${toplamlar(`${simdi} WHERE id IN (SELECT id FROM eski.programs)`)}`);
+ console.log(` şimdi, tüm satırlar (yeni dahil) : ${toplamlar(simdi)}`);
+ }
+
+ db.close();
+ console.log(`\nSonuç: ${basarisiz} ZORUNLU kontrol kaldı, ${uyari} uyarı.`);
+ if (basarisiz > 0) process.exit(1);
+}
+
+if (process.argv[1]?.endsWith("veri-kalite.ts")) calistir(main);
diff --git a/scripts/yokatlas-goc.ts b/scripts/yokatlas-goc.ts
new file mode 100644
index 0000000..7f38e62
--- /dev/null
+++ b/scripts/yokatlas-goc.ts
@@ -0,0 +1,267 @@
+/**
+ * yokatlas.db şema göçü — idempotent. Yeni yerleştirme yılı için kolonları,
+ * `son_kilavuz_yili` / `eski_kod` kolonlarını, `programs_arsiv` ve `veri_meta`
+ * tablolarını garanti eder. Hiçbir mevcut değeri silmez/ezmez.
+ *
+ * Betiklerin ortak yardımcıları (argüman okuma, DB açma) da buradadır;
+ * refresh / gecmis-doldur / eski-arsivle / detay / veri-kalite buradan alır.
+ *
+ * Kullanım: pnpm yokatlas:goc --yil 2026 --db data/yokatlas.db
+ */
+import fs from "node:fs";
+import path from "node:path";
+import Database from "better-sqlite3";
+import { ILK_YIL, SON_YIL } from "../src/lib/veri-yillari";
+
+export type Db = Database.Database;
+
+/** Her yıl için tutulan dört ölçü ve SQLite tipi. */
+export const YIL_OLCULERI = [
+ ["sira", "INTEGER"],
+ ["puan", "REAL"],
+ ["kontenjan", "INTEGER"],
+ ["yerlesen", "INTEGER"],
+] as const;
+
+// ── Komut satırı ─────────────────────────────────────────────────────────
+
+/** `--ad deger` ya da `--ad=deger`. Yoksa undefined. */
+export function argOku(ad: string): string | undefined {
+ const argv = process.argv.slice(2);
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === `--${ad}`) {
+ const sonraki = argv[i + 1];
+ return sonraki && !sonraki.startsWith("--") ? sonraki : undefined;
+ }
+ if (argv[i].startsWith(`--${ad}=`)) return argv[i].slice(ad.length + 3);
+ }
+ return undefined;
+}
+
+export function bayrakVar(ad: string): boolean {
+ return process.argv.slice(2).includes(`--${ad}`);
+}
+
+export class DurHatasi extends Error {}
+
+/** Güvenlik kilidi: mesajı yazıp betiği durdurmak için fırlatılır. */
+export function dur(mesaj: string): never {
+ throw new DurHatasi(mesaj);
+}
+
+/** main() sarmalayıcı: DurHatasi → temiz mesaj + çıkış kodu 1. */
+export function calistir(main: () => Promise | void): void {
+ Promise.resolve()
+ .then(main)
+ .catch((err) => {
+ if (err instanceof DurHatasi) console.error(`\nDURDU: ${err.message}`);
+ else console.error(err);
+ process.exit(1);
+ });
+}
+
+/**
+ * `--yil` ZORUNLU ve `SON_YIL` ile aynı olmalı. Varsayılan yok: betik hangi
+ * yılı yazdığını API'den ya da takvimden TAHMİN ETMEZ.
+ */
+export function yilArgumani(): number {
+ const ham = argOku("yil");
+ if (!ham) {
+ dur(
+ `--yil zorunlu (ör. --yil ${SON_YIL}). Varsayılan yok; yıl API kaydındaki ` +
+ "`yil` alanından da türetilmez."
+ );
+ }
+ const yil = Number.parseInt(ham, 10);
+ if (!Number.isInteger(yil) || String(yil) !== ham.trim()) {
+ dur(`--yil sayı olmalı: "${ham}"`);
+ }
+ if (yil !== SON_YIL) {
+ dur(
+ `--yil ${yil} ≠ SON_YIL ${SON_YIL} (src/lib/veri-yillari.ts). Yeni yıl ` +
+ "yükleniyorsa önce SON_YIL'i güncelle; betikler ve site aynı sabiti okur."
+ );
+ }
+ return yil;
+}
+
+/** `--db` ZORUNLU; dosya var olmalı. Yanlışlıkla boş DB yaratılmaz. */
+export function dbArgumani(): string {
+ const ham = argOku("db");
+ if (!ham) {
+ dur(
+ "--db zorunlu (ör. --db data/yokatlas.db). Önce kopya üzerinde dene: " +
+ "cp data/yokatlas.db /tmp/deneme.db"
+ );
+ }
+ const yol = path.resolve(process.cwd(), ham);
+ if (!fs.existsSync(yol)) dur(`DB bulunamadı: ${yol}`);
+ return yol;
+}
+
+export function dbAc(yol: string, secenek: { saltOkunur?: boolean } = {}): Db {
+ const db = new Database(yol, {
+ readonly: secenek.saltOkunur ?? false,
+ fileMustExist: true,
+ });
+ if (!secenek.saltOkunur) db.pragma("journal_mode = WAL");
+ return db;
+}
+
+// ── Şema ─────────────────────────────────────────────────────────────────
+
+export function tabloVar(db: Db, tablo: string): boolean {
+ return (
+ db
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
+ .get(tablo) !== undefined
+ );
+}
+
+export function kolonAdlari(db: Db, tablo: string): string[] {
+ return (
+ db.prepare(`PRAGMA table_info(${tablo})`).all() as { name: string }[]
+ ).map((c) => c.name);
+}
+
+function yilDogrula(yil: number): void {
+ if (!Number.isInteger(yil) || yil < ILK_YIL || yil > 2100) {
+ dur(`Geçersiz yıl: ${yil}`);
+ }
+}
+
+/**
+ * sira/puan/kontenjan/yerlesen kolonlarını (yoksa) ekler. İdempotent.
+ * Kontenjan kırılımı (gk/obk/…) ve kontenjanMeb kolonları BİLEREK eklenmez:
+ * `src/` içinde okuyan kod yok (KARARLAR 2026-09-21).
+ */
+export function yilKolonlariniGarantiEt(
+ db: Db,
+ yil: number,
+ tablo = "programs"
+): string[] {
+ yilDogrula(yil);
+ const mevcut = new Set(kolonAdlari(db, tablo));
+ const eklenen: string[] = [];
+ for (const [olcu, tip] of YIL_OLCULERI) {
+ const kolon = `${olcu}${yil}`;
+ if (mevcut.has(kolon)) continue;
+ db.exec(`ALTER TABLE ${tablo} ADD COLUMN ${kolon} ${tip}`);
+ eklenen.push(kolon);
+ }
+ return eklenen;
+}
+
+/**
+ * `son_kilavuz_yili`: programın görüldüğü son tercih kılavuzu yılı.
+ * `eski_kod`: kılavuz kodu değiştiyse bir önceki kod (API `eskiKilavuzKodu`
+ * ya da gecmis-doldur'un benzersiz ad eşleşmesi).
+ *
+ * Geri doldurma TEK SEFERLİK — yalnız kolon ilk kez eklenirken koşar:
+ * kontenjan2025 dolu → 2025 kılavuzunda vardı. Gerisi NULL (bilinmiyor) kalır.
+ */
+export function kilavuzKolonlariniGarantiEt(db: Db): string[] {
+ const mevcut = new Set(kolonAdlari(db, "programs"));
+ const eklenen: string[] = [];
+ if (!mevcut.has("son_kilavuz_yili")) {
+ db.exec("ALTER TABLE programs ADD COLUMN son_kilavuz_yili INTEGER");
+ eklenen.push("son_kilavuz_yili");
+ if (mevcut.has("kontenjan2025")) {
+ const r = db
+ .prepare(
+ "UPDATE programs SET son_kilavuz_yili = 2025 WHERE kontenjan2025 IS NOT NULL"
+ )
+ .run();
+ console.log(` son_kilavuz_yili geri doldurma (2025): ${r.changes} satır`);
+ }
+ }
+ if (!mevcut.has("eski_kod")) {
+ db.exec("ALTER TABLE programs ADD COLUMN eski_kod TEXT");
+ eklenen.push("eski_kod");
+ }
+ return eklenen;
+}
+
+/**
+ * `programs_arsiv`: programs ile AYNI kolonlar (ad + tip + sıra). Yoksa kurar;
+ * varsa programs'a sonradan eklenmiş kolonları ona da ekler. Böylece
+ * `INSERT INTO programs_arsiv SELECT * FROM programs` her zaman geçerlidir…
+ * yine de taşıma kolon adlarıyla yapılır (bkz. eski-arsivle.ts).
+ */
+export function arsivTablosunuGarantiEt(db: Db): string[] {
+ const kolonlar = db.prepare("PRAGMA table_info(programs)").all() as {
+ name: string;
+ type: string;
+ }[];
+ if (!tabloVar(db, "programs_arsiv")) {
+ const tanim = kolonlar
+ .map((c) =>
+ c.name === "id"
+ ? "id TEXT PRIMARY KEY"
+ : `${c.name} ${c.type || "TEXT"}`
+ )
+ .join(",\n ");
+ db.exec(`CREATE TABLE programs_arsiv (\n ${tanim}\n)`);
+ return ["programs_arsiv"];
+ }
+ const arsivde = new Set(kolonAdlari(db, "programs_arsiv"));
+ const eklenen: string[] = [];
+ for (const c of kolonlar) {
+ if (arsivde.has(c.name)) continue;
+ db.exec(
+ `ALTER TABLE programs_arsiv ADD COLUMN ${c.name} ${c.type || "TEXT"}`
+ );
+ eklenen.push(`programs_arsiv.${c.name}`);
+ }
+ return eklenen;
+}
+
+/** Koşu kayıtları (API toplamı, atlanan, ham dosya…): veri-kalite okur. */
+export function metaTablosunuGarantiEt(db: Db): void {
+ db.exec(
+ "CREATE TABLE IF NOT EXISTS veri_meta (anahtar TEXT PRIMARY KEY, deger TEXT NOT NULL)"
+ );
+}
+
+export function metaYaz(db: Db, anahtar: string, deger: string | number): void {
+ metaTablosunuGarantiEt(db);
+ db.prepare(
+ "INSERT INTO veri_meta (anahtar, deger) VALUES (?, ?) " +
+ "ON CONFLICT(anahtar) DO UPDATE SET deger = excluded.deger"
+ ).run(anahtar, String(deger));
+}
+
+export function metaOku(db: Db, anahtar: string): string | undefined {
+ if (!tabloVar(db, "veri_meta")) return undefined;
+ const r = db
+ .prepare("SELECT deger FROM veri_meta WHERE anahtar = ?")
+ .get(anahtar) as { deger: string } | undefined;
+ return r?.deger;
+}
+
+/** Tüm göç: yıl kolonları + kılavuz kolonları + arşiv + meta. İdempotent. */
+export function yokatlasGoc(db: Db, yil: number): string[] {
+ const eklenen: string[] = [];
+ db.transaction(() => {
+ eklenen.push(...yilKolonlariniGarantiEt(db, yil));
+ eklenen.push(...kilavuzKolonlariniGarantiEt(db));
+ eklenen.push(...arsivTablosunuGarantiEt(db));
+ metaTablosunuGarantiEt(db);
+ })();
+ return eklenen;
+}
+
+// Doğrudan koşulduğunda
+if (process.argv[1]?.endsWith("yokatlas-goc.ts")) {
+ calistir(() => {
+ const yil = yilArgumani();
+ const db = dbAc(dbArgumani());
+ const eklenen = yokatlasGoc(db, yil);
+ console.log(
+ eklenen.length
+ ? `Göç tamam. Eklenen: ${eklenen.join(", ")}`
+ : "Göç tamam. Şema zaten güncel (değişiklik yok)."
+ );
+ db.close();
+ });
+}
diff --git a/src/app/bolum/[slug]/opengraph-image.tsx b/src/app/bolum/[slug]/opengraph-image.tsx
index 1703927..71f2945 100644
--- a/src/app/bolum/[slug]/opengraph-image.tsx
+++ b/src/app/bolum/[slug]/opengraph-image.tsx
@@ -4,6 +4,7 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
export const alt = "Bölüm taban puanları ve sıralamaları — KolayTercih";
export const size = OG_SIZE;
@@ -26,6 +27,6 @@ export default async function Image({
return createOgSayfaImage({
eyebrow: "Bölüm",
title: `${ad} taban puanları`,
- description: `${sayi}2025 yerleştirme verileri · 2026 tercih rehberi`,
+ description: `${sayi}${SON_YIL} yerleştirme verileri · ${TERCIH_YILI} tercih rehberi`,
});
}
diff --git a/src/app/bolumler/opengraph-image.tsx b/src/app/bolumler/opengraph-image.tsx
index 9df4d77..0ae7dbb 100644
--- a/src/app/bolumler/opengraph-image.tsx
+++ b/src/app/bolumler/opengraph-image.tsx
@@ -3,9 +3,9 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { BASLIK_YILI, SON_YIL } from "@/lib/veri-yillari";
-export const alt =
- "Tüm bölümlerin taban puanları ve sıralamaları 2026 — KolayTercih";
+export const alt = `Tüm bölümlerin taban puanları ve sıralamaları ${BASLIK_YILI} — KolayTercih`;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
@@ -13,6 +13,6 @@ export default async function Image() {
return createOgSayfaImage({
eyebrow: "Katalog",
title: "Tüm bölümlerin taban puanları ve sıralamaları",
- description: "Lisans ve önlisans programları · 2025 yerleştirme verileri",
+ description: `Lisans ve önlisans programları · ${SON_YIL} yerleştirme verileri`,
});
}
diff --git a/src/app/bolumler/page.tsx b/src/app/bolumler/page.tsx
index 9f4e660..16990c6 100644
--- a/src/app/bolumler/page.tsx
+++ b/src/app/bolumler/page.tsx
@@ -1,16 +1,19 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
+import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { SiteFooter } from "@/components/site-footer";
import {
BolumKatalogu,
BolumKataloguSkeleton,
} from "@/features/katalog/components/bolum-listesi";
+// Başlıktaki yıl tek kaynaktan (veri-yillari.ts); metadata ve h1 aynı metni okur.
+const BASLIK = `Tüm Bölümlerin Taban Puanları ve Sıralamaları ${BASLIK_YILI}`;
+
export const metadata: Metadata = sayfaMetadata({
- title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
- description:
- "Üniversite bölümlerinin 2025 yerleştirme taban puanları ve başarı sıralamaları: lisans ve önlisans programları kategori kategori, 2026 tercihleri için.",
+ title: BASLIK,
+ description: `Üniversite bölümlerinin ${SON_YIL} yerleştirme taban puanları ve başarı sıralamaları: lisans ve önlisans programları kategori kategori, ${TERCIH_YILI} tercihleri için.`,
path: "/bolumler",
});
@@ -26,7 +29,7 @@ export default function BolumlerPage() {
/>
- Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
+ {BASLIK}
}>
diff --git a/src/app/universite/[slug]/opengraph-image.tsx b/src/app/universite/[slug]/opengraph-image.tsx
index 4772c5b..f756bad 100644
--- a/src/app/universite/[slug]/opengraph-image.tsx
+++ b/src/app/universite/[slug]/opengraph-image.tsx
@@ -8,6 +8,7 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { SON_YIL } from "@/lib/veri-yillari";
export const alt = "Üniversite taban puanları ve sıralamaları — KolayTercih";
export const size = OG_SIZE;
@@ -26,7 +27,7 @@ export default async function Image({
if (uni?.programSayisi != null) {
parcalar.push(`${uni.programSayisi.toLocaleString("tr-TR")} program`);
}
- parcalar.push("2025 yerleştirme verileri");
+ parcalar.push(`${SON_YIL} yerleştirme verileri`);
// Logo dosyası varsa OG kartına data URI olarak gömülür; okuma hatası
// kartı düşürmesin diye sessizce logosuz karta düşülür.
diff --git a/src/app/universiteler/opengraph-image.tsx b/src/app/universiteler/opengraph-image.tsx
index 07fb32c..ba84552 100644
--- a/src/app/universiteler/opengraph-image.tsx
+++ b/src/app/universiteler/opengraph-image.tsx
@@ -3,9 +3,9 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { BASLIK_YILI, SON_YIL } from "@/lib/veri-yillari";
-export const alt =
- "Üniversitelerin taban puanları ve sıralamaları 2026 — KolayTercih";
+export const alt = `Üniversitelerin taban puanları ve sıralamaları ${BASLIK_YILI} — KolayTercih`;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
@@ -13,6 +13,6 @@ export default async function Image() {
return createOgSayfaImage({
eyebrow: "Katalog",
title: "Üniversitelerin taban puanları ve sıralamaları",
- description: "Devlet ve vakıf üniversiteleri · 2025 yerleştirme verileri",
+ description: `Devlet ve vakıf üniversiteleri · ${SON_YIL} yerleştirme verileri`,
});
}
diff --git a/src/app/universiteler/page.tsx b/src/app/universiteler/page.tsx
index 5c07342..082f844 100644
--- a/src/app/universiteler/page.tsx
+++ b/src/app/universiteler/page.tsx
@@ -1,16 +1,19 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
+import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { SiteFooter } from "@/components/site-footer";
import {
UniversiteKatalogu,
UniversiteKataloguSkeleton,
} from "@/features/katalog/components/universite-listesi";
+// Başlıktaki yıl tek kaynaktan (veri-yillari.ts); metadata ve h1 aynı metni okur.
+const BASLIK = `Üniversitelerin Taban Puanları ve Sıralamaları ${BASLIK_YILI}`;
+
export const metadata: Metadata = sayfaMetadata({
- title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
- description:
- "Devlet ve vakıf üniversitelerinin 2025 yerleştirme taban puanları, başarı sıralamaları ve kontenjanları — üniversite üniversite, 2026 tercihleri için.",
+ title: BASLIK,
+ description: `Devlet ve vakıf üniversitelerinin ${SON_YIL} yerleştirme taban puanları, başarı sıralamaları ve kontenjanları — üniversite üniversite, ${TERCIH_YILI} tercihleri için.`,
path: "/universiteler",
});
@@ -26,7 +29,7 @@ export default function UniversitelerPage() {
/>
- Üniversitelerin Taban Puanları ve Sıralamaları 2026
+ {BASLIK}
}>
diff --git a/src/components/program-liste-verileri.tsx b/src/components/program-liste-verileri.tsx
index a91029e..a4dde16 100644
--- a/src/components/program-liste-verileri.tsx
+++ b/src/components/program-liste-verileri.tsx
@@ -1,30 +1,37 @@
import { TrendingDown, TrendingUp } from "lucide-react";
-import type { Program } from "@/types/yokatlas";
+import { ILK_YIL } from "@/lib/veri-yillari";
-/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
- * rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
-export type SiraSerisi = Pick<
- Program,
- "sira2021" | "sira2022" | "sira2023" | "sira2024" | "sira2025"
->;
+/** Taban sıra geçmişinin yapısal alt kümesi: hem DB programı hem de rapordaki
+ * kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. Dizi `ilkYil`'a
+ * (yoksa veri penceresinin ilk yılına) demirlidir: eleman yılı
+ * `ilkYil + indeks`. Uzunluk sabit DEĞİL — eski kayıtlı raporlar daha kısa
+ * dizi taşır; yardımcılar dizinin kendi son iki elemanıyla çalışır. */
+export type SiraSerisi = {
+ siraGecmisi: readonly (number | null)[];
+ ilkYil?: number;
+};
-/** Rapordaki [2021..2025] dizisini sütunlu seriye çevirir. */
-export function raporSiraSerisi(gecmis?: (number | null)[]): SiraSerisi {
- const g = gecmis ?? [];
- return {
- sira2021: g[0] ?? null,
- sira2022: g[1] ?? null,
- sira2023: g[2] ?? null,
- sira2024: g[3] ?? null,
- sira2025: g[4] ?? null,
- };
+/** Rapordaki kayıtlı diziyi seriye çevirir. Dizi DOLDURULMAZ: 5 elemanlı eski
+ * kayıt 5 elemanlı kalır, "son iki yıl" kaydın kendi son iki yılıdır. */
+export function raporSiraSerisi(
+ gecmis?: (number | null)[],
+ ilkYil?: number,
+): SiraSerisi {
+ return { siraGecmisi: gecmis ?? [], ilkYil };
+}
+
+/** Serinin son iki yılı: [bir önceki, son]. */
+function sonIkiSira(program: SiraSerisi): [number | null, number | null] {
+ const g = program.siraGecmisi;
+ return [g[g.length - 2] ?? null, g[g.length - 1] ?? null];
}
export function programEtkinSira(program: SiraSerisi): number | null {
- return program.sira2025 ?? program.sira2024;
+ const [onceki, son] = sonIkiSira(program);
+ return son ?? onceki;
}
-/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
+/** Taban sıralaması geçmişini sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
// Satır başına render edilen bileşen: filter/map/min/max/map/map'in 6 ayrı
// geçişi tek döngüde toplanır (js-combine-iterations, js-min-max-loop)
@@ -32,14 +39,10 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
let min = Infinity;
let max = -Infinity;
const ozetParcalari: string[] = [];
- for (const [yil, sira] of [
- [2021, program.sira2021],
- [2022, program.sira2022],
- [2023, program.sira2023],
- [2024, program.sira2024],
- [2025, program.sira2025],
- ] as const) {
+ const ilkYil = program.ilkYil ?? ILK_YIL;
+ for (const [indeks, sira] of program.siraGecmisi.entries()) {
if (sira == null) continue;
+ const yil = ilkYil + indeks;
yillar.push([yil, sira]);
if (sira < min) min = sira;
if (sira > max) max = sira;
@@ -89,12 +92,13 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
export function ProgramTabanTrendi({ program }: { program: SiraSerisi }) {
- if (program.sira2025 == null || program.sira2024 == null) return null;
+ const [onceki, son] = sonIkiSira(program);
+ if (son == null || onceki == null) return null;
- const oran = (program.sira2024 - program.sira2025) / program.sira2024;
+ const oran = (onceki - son) / onceki;
if (Math.abs(oran) < 0.1) return null;
- const gecis = `${program.sira2024.toLocaleString("tr-TR")} → ${program.sira2025.toLocaleString("tr-TR")}`;
+ const gecis = `${onceki.toLocaleString("tr-TR")} → ${son.toLocaleString("tr-TR")}`;
if (oran > 0) {
return (
n.toLocaleString("tr-TR");
@@ -65,20 +71,20 @@ function bolumSayfaDilimi(bolum: BolumDetay, sayfa: number) {
let minSira: number | null = null;
let maxSira: number | null = null;
let verisiz = 0;
- // Sırası 2025'te olmayıp 2024'ten gelen satırlar: aralık metni yılı dürüstçe
- // söylesin (tablonun Sıralama sütunu bu satırlarda "—" gösterir).
- let yalniz2024 = 0;
+ // Sırası son yılda olmayıp bir önceki yıldan gelen satırlar: aralık metni
+ // yılı dürüstçe söylesin (tablonun Sıralama sütunu bu satırlarda "—" gösterir).
+ let yalnizOnceki = 0;
for (const p of dilim) {
- const efektif = p.sira2025 ?? p.sira2024;
+ const efektif = p.efektifSira;
if (efektif == null) {
verisiz += 1;
continue;
}
- if (p.sira2025 == null) yalniz2024 += 1;
+ if (p.siraSon == null) yalnizOnceki += 1;
if (minSira == null || efektif < minSira) minSira = efektif;
if (maxSira == null || efektif > maxSira) maxSira = efektif;
}
- return { tumProgramlar, dilim, minSira, maxSira, verisiz, yalniz2024 };
+ return { tumProgramlar, dilim, minSira, maxSira, verisiz, yalnizOnceki };
}
/** için kısa sıra: 2.425.061 → "2,43 mn", 95.400 → "95 bin". */
@@ -99,13 +105,13 @@ function siraAraligiMetni({
minSira,
maxSira,
verisiz,
- yalniz2024,
+ yalnizOnceki,
}: ReturnType): string | null {
if (dilim.length === 0) return null;
if (minSira == null || maxSira == null) {
return "taban verisi olmayan programlar";
}
- const yil = siraYiliEtiketi(yalniz2024, dilim.length - verisiz);
+ const yil = siraYiliEtiketi(yalnizOnceki, dilim.length - verisiz);
const aralik =
minSira === maxSira
? `${yil} taban sırası ${sayi(minSira)} olan programlar`
@@ -134,8 +140,8 @@ export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
const meta = sayfaMetadata({
title: baslik,
description: aralik
- ? `${bolum.ad} taban puanları, sayfa ${sayfa}: ${aralik}. Toplam ${stats.toplam} programın son yerleştirme verileri (2025; 2025'te verisi olmayanlarda 2024) — taban puanı, başarı sıralaması ve kontenjan.`
- : `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın 2025 yerleştirme verileri.`,
+ ? `${bolum.ad} taban puanları, sayfa ${sayfa}: ${aralik}. Toplam ${stats.toplam} programın son yerleştirme verileri (${SON_YIL}; o yıl verisi olmayanlarda ${ONCEKI_YIL}) — taban puanı, başarı sıralaması ve kontenjan.`
+ : `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın ${SON_YIL} yerleştirme verileri.`,
path: `/bolum/${slug}/sayfa/${sayfa}`,
openGraph: {
images: ustSegmentOgGorseli(
@@ -151,8 +157,8 @@ export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
? ` En iyi taban sıralaması ${sayi(stats.minSira)}.`
: "";
return sayfaMetadata({
- title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
- description: `${bolum.ad} 2025 yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} 2026 tercih rehberi.`,
+ title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları ${BASLIK_YILI}`,
+ description: `${bolum.ad} ${SON_YIL} yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} ${TERCIH_YILI} tercih rehberi.`,
path: `/bolum/${slug}`,
});
}
@@ -230,7 +236,7 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{ilkSayfa ? (
- {sayi(tumProgramlar.length)} üniversite programının 2025 taban
+ {sayi(tumProgramlar.length)} üniversite programının {SON_YIL} taban
puanlarını, başarı sıralamalarını ve kontenjanlarını incele.
) : (
diff --git a/src/features/katalog/components/bolum-listesi.tsx b/src/features/katalog/components/bolum-listesi.tsx
index 3a49747..bb6af6f 100644
--- a/src/features/katalog/components/bolum-listesi.tsx
+++ b/src/features/katalog/components/bolum-listesi.tsx
@@ -4,6 +4,7 @@ import { KATEGORILER } from "@/lib/kategoriler";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
+import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -81,11 +82,11 @@ export function BolumKatalogu() {
return (
<>
- {sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
+ {sayi(hepsi.length)} bölümün {SON_YIL} YKS yerleştirme verileri: taban
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu görürsün.
- Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri
- için rehber niteliğindedir.
+ Veriler YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır;{" "}
+ {TERCIH_YILI} tercihleri için rehber niteliğindedir.
diff --git a/src/features/katalog/components/bolum-program-listesi.tsx b/src/features/katalog/components/bolum-program-listesi.tsx
index 2051159..f95057a 100644
--- a/src/features/katalog/components/bolum-program-listesi.tsx
+++ b/src/features/katalog/components/bolum-program-listesi.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/types/yokatlas";
import { trBaslikDuzeni } from "@/lib/slug";
+import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
import {
ProgramSiraGecmisi,
@@ -24,14 +25,14 @@ import {
const sayi = (n: number) => n.toLocaleString("tr-TR");
-type SiralamaAlani = "sira2025" | "puan2025" | "kontenjan2025";
+type SiralamaAlani = "siraSon" | "puanSon" | "kontenjanSon";
type SiralamaYonu = "artan" | "azalan";
export type BolumProgrami = Program & { universiteSlug: string | null };
const ALAN_ETIKETLERI: Record = {
- sira2025: "Sıralama",
- puan2025: "Puan",
- kontenjan2025: "Kontenjan",
+ siraSon: "Sıralama",
+ puanSon: "Puan",
+ kontenjanSon: "Kontenjan",
};
function SiralamaDugmesi({
@@ -157,11 +158,11 @@ export function BolumProgramListesi({
Üniversite
- Son 5 yıl
+ Son {VERI_YIL_SAYISI} yıl
- {p.puan2025 != null
- ? p.puan2025.toFixed(2).replace(".", ",")
+ {p.puanSon != null
+ ? p.puanSon.toFixed(2).replace(".", ",")
: "—"}
- {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
+ {p.kontenjanSon != null ? `${sayi(p.kontenjanSon)} kişi` : "—"}
- {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
+ {p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—"}
diff --git a/src/features/katalog/components/sayfalama.tsx b/src/features/katalog/components/sayfalama.tsx
index a7e56d3..bf2c546 100644
--- a/src/features/katalog/components/sayfalama.tsx
+++ b/src/features/katalog/components/sayfalama.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
+import { ONCEKI_YIL, SON_YIL } from "@/lib/veri-yillari";
/** Programatik tablo sayfaları için satır dilimi boyutu. */
export const SAYFA_BOYU = 50;
@@ -13,10 +14,19 @@ export function sayfaDilimi(liste: readonly T[], sayfa: number): T[] {
return liste.slice((sayfa - 1) * SAYFA_BOYU, sayfa * SAYFA_BOYU);
}
-/** Aralıktaki sıraların hangi yıldan geldiği: hepsi 2025 / hepsi 2024 / karışık. */
-export function siraYiliEtiketi(yalniz2024: number, siraliSayi: number): string {
- if (yalniz2024 === 0) return "2025";
- return yalniz2024 === siraliSayi ? "2024" : "2024–2025";
+/**
+ * Aralıktaki sıraların hangi yıldan geldiği: hepsi son yıl / hepsi bir önceki
+ * yıl / karışık. `yalnizOnceki`: sırası son yılda olmayıp bir önceki yıldan
+ * gelen satır sayısı; `siraliSayi`: sırası olan toplam satır.
+ */
+export function siraYiliEtiketi(
+ yalnizOnceki: number,
+ siraliSayi: number,
+): string {
+ if (yalnizOnceki === 0) return String(SON_YIL);
+ return yalnizOnceki === siraliSayi
+ ? String(ONCEKI_YIL)
+ : `${ONCEKI_YIL}–${SON_YIL}`;
}
/**
diff --git a/src/features/katalog/components/universite-icerik.tsx b/src/features/katalog/components/universite-icerik.tsx
index 50821ab..90da09d 100644
--- a/src/features/katalog/components/universite-icerik.tsx
+++ b/src/features/katalog/components/universite-icerik.tsx
@@ -34,6 +34,7 @@ import { UniLogo } from "@/components/uni-logo";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
import type { Program } from "@/types/yokatlas";
+import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -91,13 +92,13 @@ function fakulteOzeti({
if (adlar.length === 0) return null;
if (adlar.length === 1) {
const siralar = dilim
- .map((p) => p.sira2025 ?? p.sira2024)
+ .map((p) => p.efektifSira)
.filter((n): n is number => n != null);
if (siralar.length === 0) return adlar[0];
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const yil = siraYiliEtiketi(
- dilim.filter((p) => p.sira2025 == null && p.sira2024 != null).length,
+ dilim.filter((p) => p.siraSon == null && p.efektifSira != null).length,
siralar.length,
);
return min === max
@@ -116,10 +117,10 @@ export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
// farklılaştırıcı (o sayfadaki birimler) açıklamaya ve gövdeye gider.
const birimler = fakulteOzeti(uniSayfaDilimi(uni, sayfa));
return sayfaMetadata({
- title: `${uni.ad} Taban Puanları 2026 (Sayfa ${sayfa})`,
+ title: `${uni.ad} Taban Puanları ${BASLIK_YILI} (Sayfa ${sayfa})`,
description: birimler
- ? `${uni.ad} taban puanları, sayfa ${sayfa}: ${birimler}. Toplam ${uni.programSayisi} programın 2025 yerleştirme verileri — taban puanı, başarı sıralaması ve kontenjan.`
- : `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın 2025 yerleştirme verileri.`,
+ ? `${uni.ad} taban puanları, sayfa ${sayfa}: ${birimler}. Toplam ${uni.programSayisi} programın ${SON_YIL} yerleştirme verileri — taban puanı, başarı sıralaması ve kontenjan.`
+ : `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın ${SON_YIL} yerleştirme verileri.`,
path: `/universite/${slug}/sayfa/${sayfa}`,
openGraph: {
images: ustSegmentOgGorseli(
@@ -130,8 +131,8 @@ export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
});
}
return sayfaMetadata({
- title: `${uni.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
- description: `${uni.ad} 2025 yerleştirme verileri: ${uni.programSayisi} programın taban puanları, başarı sıralamaları ve kontenjanları${uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}. 2026 tercih rehberi.`,
+ title: `${uni.ad} Taban Puanları ve Başarı Sıralamaları ${BASLIK_YILI}`,
+ description: `${uni.ad} ${SON_YIL} yerleştirme verileri: ${uni.programSayisi} programın taban puanları, başarı sıralamaları ve kontenjanları${uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}. ${TERCIH_YILI} tercih rehberi.`,
path: `/universite/${slug}`,
});
}
@@ -241,7 +242,7 @@ export function UniversiteIcerik({
diff --git a/src/features/katalog/components/universite-program-tablosu.tsx b/src/features/katalog/components/universite-program-tablosu.tsx
index 34f35b3..eb7408d 100644
--- a/src/features/katalog/components/universite-program-tablosu.tsx
+++ b/src/features/katalog/components/universite-program-tablosu.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
import type { Program } from "@/types/yokatlas";
+import { SON_YIL, VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -48,10 +49,10 @@ export function UniversiteProgramTablosu({
Program
- Son 5 yıl
+ Son {VERI_YIL_SAYISI} yıl
- 2025 puan
+ {SON_YIL} puan
Kontenjan
@@ -93,13 +94,13 @@ export function UniversiteProgramTablosu({
- {p.puan2025 != null
- ? p.puan2025.toFixed(2).replace(".", ",")
+ {p.puanSon != null
+ ? p.puanSon.toFixed(2).replace(".", ",")
: "—"}
- {p.kontenjan2025 != null
- ? `${sayi(p.kontenjan2025)} kişi`
+ {p.kontenjanSon != null
+ ? `${sayi(p.kontenjanSon)} kişi`
: "—"}
diff --git a/src/features/katalog/components/veri-kaynagi.tsx b/src/features/katalog/components/veri-kaynagi.tsx
index d1485e5..9407c23 100644
--- a/src/features/katalog/components/veri-kaynagi.tsx
+++ b/src/features/katalog/components/veri-kaynagi.tsx
@@ -1,7 +1,8 @@
import { KATALOG_VERI_TARIHI } from "@/lib/seo";
import { cn } from "@/lib/utils";
+import { SON_YIL } from "@/lib/veri-yillari";
-// Veri tarihinin görünür biçimi ("9 Ağustos 2026") — tek biçim kaynağı; ana
+// Veri tarihinin görünür biçimi ("21 Eylül 2026") — tek biçim kaynağı; ana
// sayfa hero'sundaki veri satırı da bunu kullanır.
export const KATALOG_TARIH_METNI = new Date(
`${KATALOG_VERI_TARIHI}T00:00:00Z`,
@@ -24,7 +25,7 @@ export function VeriKaynagi({
return (
);
diff --git a/src/features/liste/components/program-ekle-butonu.tsx b/src/features/liste/components/program-ekle-butonu.tsx
index 95613cc..7cf0bd1 100644
--- a/src/features/liste/components/program-ekle-butonu.tsx
+++ b/src/features/liste/components/program-ekle-butonu.tsx
@@ -24,8 +24,7 @@ type ProgramOzet = Pick<
| "il"
| "unitur"
| "tur"
- | "sira2025"
- | "sira2024"
+ | "efektifSira"
>;
const PROFILSIZ_EKLEME_TOAST_ID = "profilsiz-ekleme";
diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx
index e71c009..c889950 100644
--- a/src/features/liste/components/program-tablosu.tsx
+++ b/src/features/liste/components/program-tablosu.tsx
@@ -22,6 +22,7 @@ import type {
} from "@/types/yokatlas";
import { DILIM_ETIKET, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
+import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -239,7 +240,7 @@ export function ProgramTablosu({
Program
- Son 5 yıl
+ Son {VERI_YIL_SAYISI} yıl
Kontenjan
@@ -357,19 +358,19 @@ function ProgramSatiri({
- {p.kontenjan2025 != null ? (
+ {p.kontenjanSon != null ? (
- {p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? (
+ {p.yerlesenSon != null && p.yerlesenSon < p.kontenjanSon ? (
boş kaldı
) : null}
- {p.kontenjan2025.toLocaleString("tr-TR")} kişi
+ {p.kontenjanSon.toLocaleString("tr-TR")} kişi
) : (
"—"
diff --git a/src/features/liste/hooks/use-tercih-profili.ts b/src/features/liste/hooks/use-tercih-profili.ts
index f76fb90..3771ac7 100644
--- a/src/features/liste/hooks/use-tercih-profili.ts
+++ b/src/features/liste/hooks/use-tercih-profili.ts
@@ -28,8 +28,7 @@ export type BekleyenProgram = {
| "il"
| "unitur"
| "tur"
- | "sira2025"
- | "sira2024"
+ | "efektifSira"
>;
kaynak: ManuelKaynak;
adaySira?: number;
diff --git a/src/features/liste/liste-queries.ts b/src/features/liste/liste-queries.ts
index 569f366..d716a2e 100644
--- a/src/features/liste/liste-queries.ts
+++ b/src/features/liste/liste-queries.ts
@@ -8,7 +8,8 @@ import type {
UniturGrubu,
} from "@/types/yokatlas";
-// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
+// Tabloda kullanılmayan alanlar (fakulte, puanSon, siraSon) client'a
+// serileşmesin.
// Alanlar tek tek seçilir: Program'a alan eklenirse ProgramListeSatiri
// üzerinden derleyici burada da karar vermeye zorlar.
export function programOzetle(p: Program): ProgramListeSatiri {
@@ -20,13 +21,10 @@ export function programOzetle(p: Program): ProgramListeSatiri {
il: p.il,
tur: p.tur,
sure: p.sure,
- sira2025: p.sira2025,
- sira2024: p.sira2024,
- sira2023: p.sira2023,
- sira2022: p.sira2022,
- sira2021: p.sira2021,
- kontenjan2025: p.kontenjan2025,
- yerlesen2025: p.yerlesen2025,
+ siraGecmisi: p.siraGecmisi,
+ efektifSira: p.efektifSira,
+ kontenjanSon: p.kontenjanSon,
+ yerlesenSon: p.yerlesenSon,
};
}
diff --git a/src/features/liste/liste-store.ts b/src/features/liste/liste-store.ts
index 0216cd3..0b30f43 100644
--- a/src/features/liste/liste-store.ts
+++ b/src/features/liste/liste-store.ts
@@ -158,10 +158,8 @@ export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[]
return liste;
}
-export function efektifSiraAl(
- p: Pick,
-): number | null {
- return p.sira2025 ?? p.sira2024 ?? null;
+export function efektifSiraAl(p: Pick): number | null {
+ return p.efektifSira ?? null;
}
/** Program satırını manuel liste kaydına çevirir. */
@@ -174,8 +172,7 @@ export function programdanManuelTercih(
| "il"
| "unitur"
| "tur"
- | "sira2025"
- | "sira2024"
+ | "efektifSira"
>,
adaySira?: number | null,
kaynak: ManuelKaynak = "sonuc",
diff --git a/src/features/pazarlama/components/huni-semasi.tsx b/src/features/pazarlama/components/huni-semasi.tsx
index 2224b19..4286fbb 100644
--- a/src/features/pazarlama/components/huni-semasi.tsx
+++ b/src/features/pazarlama/components/huni-semasi.tsx
@@ -16,7 +16,7 @@ flowchart TD
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, yalnızca ulaşabildiklerin listelenir")
A --> B("2. adım · İl seçimi programı olan tüm iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
B --> C("3. adım · Devlet / Vakıf sayılar hem alanı hem ili hesaba katar")
- C --> H("Aday havuzu elde kalan gerçek programlar")
+ C --> H("Aday havuzu elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez")
H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir önce il, sonra alan — listede açıkça belirtilir")
G --> H
@@ -44,10 +44,12 @@ export function HuniSemasi() {
return (
- Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
+ Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer;
+ kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez. Her
+ adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
dışına asla çıkmadan
{" "}
diff --git a/src/features/pazarlama/components/meraklisina-icerik.tsx b/src/features/pazarlama/components/meraklisina-icerik.tsx
index 12815f5..cd70ddb 100644
--- a/src/features/pazarlama/components/meraklisina-icerik.tsx
+++ b/src/features/pazarlama/components/meraklisina-icerik.tsx
@@ -47,8 +47,8 @@ export function MeraklisinaIcerik() {
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
yerleşebilirdin demektir. Sistemde her programın en güncel taban
- sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
- bir önceki yılınki kullanılır.
+ sıralaması kayıtlıdır; bir bölüm için en son yılın verisi henüz
+ oluşmamışsa bir önceki yılınki kullanılır.
@@ -116,10 +116,13 @@ export function MeraklisinaIcerik() {
tabloya da uygulanır. Adımlar bitince elde kalan gerçek
programlar bir aday havuzu{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
- verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
- 24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
- yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
- nasıl kurulduğu da dahil.
+ verilir. Havuza yalnızca{" "}
+ en son tercih kılavuzunda yer alan{" "}
+ programlar girer: kapanmış ya da kodu değiştiği için eski kaydı
+ kalmış bir program sana önerilmez. Aşağıdaki şema akışın tamamını
+ gösterir — havuz 24'ün altına düşerse filtrelerin nasıl
+ gevşetildiği, yine de yetmezse listenin eldeki programlarla, sana
+ açıkça belirtilerek nasıl kurulduğu da dahil.
diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx
index b8af439..0a59dc7 100644
--- a/src/features/rapor/components/rapor-listesi.tsx
+++ b/src/features/rapor/components/rapor-listesi.tsx
@@ -2,7 +2,7 @@
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
-// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
+// aynı satır düzeni, aynı "Son N yıl" / "Taban sıra" sütunları. Buradaki fark
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
@@ -27,6 +27,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
+import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import {
DILIM_ETIKET,
@@ -47,7 +48,7 @@ const RISK_STIL: Record =
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
};
-// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
+// Sütun sayısı: # · Program · Son N yıl · Risk · Taban sıra · detay oku
const SUTUN_SAYISI = 6;
/**
@@ -161,6 +162,19 @@ export function RaporListesi({
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
+ // Sütun başlığı kaydın KENDİ penceresini söyler: eski raporların sıra
+ // geçmişi üretildiği günün yıl sayısını taşır (dizi doldurulmaz), başlık
+ // çizgide olmayan bir yılı vaat etmesin. Geçmişi hiç olmayan kayıtta
+ // bugünkü pencereye düşer.
+ const yilSayisi = useMemo(() => {
+ let enUzun = 0;
+ for (const t of rapor.tercihler) {
+ const n = rapor.programlar[t.programId]?.siraGecmisi?.length ?? 0;
+ if (n > enUzun) enUzun = n;
+ }
+ return enUzun > 0 ? enUzun : VERI_YIL_SAYISI;
+ }, [rapor]);
+
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
@@ -168,7 +182,7 @@ export function RaporListesi({
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
- const seri = raporSiraSerisi(p?.siraGecmisi);
+ const seri = raporSiraSerisi(p?.siraGecmisi, p?.ilkYil);
const taban = p?.efektifSira ?? programEtkinSira(seri);
const acikMi = acik.has(t.sira);
const devlet = p?.unitur === "DEVLET";
@@ -366,7 +380,7 @@ export function RaporListesi({
#Program
- Son 5 yıl
+ Son {yilSayisi} yıl
Yapay Zeka Risk
diff --git a/src/features/rapor/components/rapor-yazdir.tsx b/src/features/rapor/components/rapor-yazdir.tsx
index 20cf054..10fc8c0 100644
--- a/src/features/rapor/components/rapor-yazdir.tsx
+++ b/src/features/rapor/components/rapor-yazdir.tsx
@@ -7,6 +7,7 @@ import { DILIM_ETIKET, RISK_ETIKET, dilimdenRisk } from "@/lib/risk";
import type { RaporParams, RaporSonuc } from "../types/rapor";
import { Skeleton } from "@/components/ui/skeleton";
import { YazdirButonu } from "./yazdir-butonu";
+import { ILK_YIL, VERI_ARALIGI } from "@/lib/veri-yillari";
// Dilim sütunu: dilim adı + varsayılan riski. Üçüncü dilimde ikisi aynı
// kelime ("Güvenli") olduğu için parantez düşer — "Güvenli (Güvenli)" yazmaz.
@@ -29,6 +30,23 @@ export async function RaporYazdir() {
const rapor = satir.result as RaporSonuc;
const params = satir.params as RaporParams;
+ // Dipnottaki yıl aralığı raporun ÜRETİLDİĞİ veri penceresidir: eski kayıtlı
+ // rapor (5 yıllık geçmiş) güncel "2021–2026"yı iddia etmesin.
+ const gecmisler = Object.values(rapor.programlar).filter(
+ (p) => p.siraGecmisi && p.siraGecmisi.length > 0,
+ );
+ const veriAraligi =
+ gecmisler.length > 0
+ ? (() => {
+ const ilk = Math.min(...gecmisler.map((p) => p.ilkYil ?? ILK_YIL));
+ const son = Math.max(
+ ...gecmisler.map(
+ (p) => (p.ilkYil ?? ILK_YIL) + p.siraGecmisi!.length - 1,
+ ),
+ );
+ return `${ilk}–${son}`;
+ })()
+ : VERI_ARALIGI;
const tarih = new Date(satir.updatedAt).toLocaleDateString("tr-TR", {
day: "numeric",
month: "long",
@@ -106,7 +124,7 @@ export async function RaporYazdir() {