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<typeof bolumSayfaDilimi>): 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 }) { </h1> {ilkSayfa ? ( <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base"> - {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. </p> ) : ( 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 ( <> <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600"> - {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. </p> <PagePixelDivider seed={29} className="mt-8" /> 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<SiralamaAlani, string> = { - sira2025: "Sıralama", - puan2025: "Puan", - kontenjan2025: "Kontenjan", + siraSon: "Sıralama", + puanSon: "Puan", + kontenjanSon: "Kontenjan", }; function SiralamaDugmesi({ @@ -157,11 +158,11 @@ export function BolumProgramListesi({ <TableRow> <TableHead>Üniversite</TableHead> <TableHead className="hidden text-right md:table-cell"> - Son 5 yıl + Son {VERI_YIL_SAYISI} yıl </TableHead> <TableHead className="hidden text-right lg:table-cell"> <SiralamaDugmesi - alan="puan2025" + alan="puanSon" aktifAlan={aktifAlan} yon={yon} onSirala={sirala} @@ -169,7 +170,7 @@ export function BolumProgramListesi({ </TableHead> <TableHead className="hidden text-right sm:table-cell"> <SiralamaDugmesi - alan="kontenjan2025" + alan="kontenjanSon" aktifAlan={aktifAlan} yon={yon} onSirala={sirala} @@ -177,7 +178,7 @@ export function BolumProgramListesi({ </TableHead> <TableHead className="text-right"> <SiralamaDugmesi - alan="sira2025" + alan="siraSon" aktifAlan={aktifAlan} yon={yon} onSirala={sirala} @@ -243,17 +244,17 @@ export function BolumProgramListesi({ <ProgramSiraGecmisi program={p} /> </TableCell> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell"> - {p.puan2025 != null - ? p.puan2025.toFixed(2).replace(".", ",") + {p.puanSon != null + ? p.puanSon.toFixed(2).replace(".", ",") : "—"} </TableCell> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"> - {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"} + {p.kontenjanSon != null ? `${sayi(p.kontenjanSon)} kişi` : "—"} </TableCell> <TableCell className="text-right text-xs tabular-nums text-slate-500"> <span className="inline-flex items-center gap-1"> <ProgramTabanTrendi program={p} /> - {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"} + {p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—"} </span> </TableCell> <TableCell className="pr-3 text-right"> 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<T>(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({ <div className="mt-4 flex items-center gap-4"> <UniLogo slug={slug} ad={uni.ad} boy="lg" /> <h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl"> - {uni.ad} Taban Puanları 2026 + {uni.ad} Taban Puanları {BASLIK_YILI} {ilkSayfa ? "" : ` — Sayfa ${sayfa}`} </h1> </div> @@ -250,7 +251,7 @@ export function UniversiteIcerik({ <> <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600"> {uni.ad} - {uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}, 2025 YKS + {uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}, {SON_YIL} YKS yerleştirme sonuçlarına göre{" "} <strong>{sayi(uni.programSayisi)} programda</strong> öğrenci aldı {uni.fakulteSayisi > 1 @@ -267,10 +268,11 @@ export function UniversiteIcerik({ {uni.stats.toplamKontenjan > 0 ? ( <> Toplam kontenjan {sayi(uni.stats.toplamKontenjan)}.</> ) : null}{" "} - Aşağıdaki tablolar 2026 tercihleri için rehber niteliğindedir;{" "} + Aşağıdaki tablolar {TERCIH_YILI} tercihleri için rehber + niteliğindedir;{" "} <em> - 2026 taban puanları ve sıralamaları, yerleştirme sonuçları - açıklandığında netleşir. + {TERCIH_YILI} taban puanları ve sıralamaları, yerleştirme + sonuçları açıklandığında netleşir. </em> </p> diff --git a/src/features/katalog/components/universite-listesi.tsx b/src/features/katalog/components/universite-listesi.tsx index 1fd4459..13c521f 100644 --- a/src/features/katalog/components/universite-listesi.tsx +++ b/src/features/katalog/components/universite-listesi.tsx @@ -5,6 +5,7 @@ import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form"; import { PagePixelDivider } from "@/components/pixel-decor"; import { UniLogo } from "@/components/uni-logo"; import { Skeleton } from "@/components/ui/skeleton"; +import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari"; const sayi = (n: number) => n.toLocaleString("tr-TR"); @@ -49,11 +50,11 @@ export function UniversiteKatalogu() { return ( <> <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600"> - {sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri. + {sayi(hepsi.length)} üniversitenin {SON_YIL} YKS yerleştirme verileri. Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler YÖK - Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için rehber - niteliğindedir. + Atlas {SON_YIL} yerleştirme sonuçlarına dayanır; {TERCIH_YILI} tercihleri + için rehber niteliğindedir. </p> <PagePixelDivider seed={63} className="mt-8" /> 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({ <TableRow> <TableHead>Program</TableHead> <TableHead className="hidden text-right md:table-cell"> - Son 5 yıl + Son {VERI_YIL_SAYISI} yıl </TableHead> <TableHead className="hidden text-right lg:table-cell"> - 2025 puan + {SON_YIL} puan </TableHead> <TableHead className="hidden text-right sm:table-cell"> Kontenjan @@ -93,13 +94,13 @@ export function UniversiteProgramTablosu({ <ProgramSiraGecmisi program={p} /> </TableCell> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell"> - {p.puan2025 != null - ? p.puan2025.toFixed(2).replace(".", ",") + {p.puanSon != null + ? p.puanSon.toFixed(2).replace(".", ",") : "—"} </TableCell> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"> - {p.kontenjan2025 != null - ? `${sayi(p.kontenjan2025)} kişi` + {p.kontenjanSon != null + ? `${sayi(p.kontenjanSon)} kişi` : "—"} </TableCell> <TableCell className="text-right text-xs tabular-nums text-slate-500"> 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 ( <p className={cn("mt-3 text-xs leading-5 text-slate-500", className)}> {children} - Kaynak: YÖK Atlas 2025 yerleştirme sonuçları. Son güncelleme:{" "} + Kaynak: YÖK Atlas {SON_YIL} yerleştirme sonuçları. Son güncelleme:{" "} <time dateTime={KATALOG_VERI_TARIHI}>{KATALOG_TARIH_METNI}</time>. </p> ); 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({ <TableHead className="w-8" /> <TableHead>Program</TableHead> <TableHead className="hidden text-right md:table-cell"> - Son 5 yıl + Son {VERI_YIL_SAYISI} yıl </TableHead> <TableHead className="hidden text-right sm:table-cell"> Kontenjan @@ -357,19 +358,19 @@ function ProgramSatiri({ <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell" title={ - p.kontenjan2025 != null && p.yerlesen2025 != null - ? `${p.kontenjan2025} kontenjan, ${p.yerlesen2025} yerleşen` + p.kontenjanSon != null && p.yerlesenSon != null + ? `${p.kontenjanSon} kontenjan, ${p.yerlesenSon} yerleşen` : undefined } > - {p.kontenjan2025 != null ? ( + {p.kontenjanSon != null ? ( <span className="inline-flex items-center gap-1.5"> - {p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? ( + {p.yerlesenSon != null && p.yerlesenSon < p.kontenjanSon ? ( <span className="rounded bg-amber-50 px-1 py-px text-[10px] font-medium leading-4 text-amber-600"> boş kaldı </span> ) : null} - {p.kontenjan2025.toLocaleString("tr-TR")} kişi + {p.kontenjanSon.toLocaleString("tr-TR")} kişi </span> ) : ( "—" 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<Program, "sira2025" | "sira2024">, -): number | null { - return p.sira2025 ?? p.sira2024 ?? null; +export function efektifSiraAl(p: Pick<Program, "efektifSira">): 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<br/>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<br/>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<br/>sayılar hem alanı hem ili hesaba katar") - C --> H("Aday havuzu<br/>elde kalan gerçek programlar") + C --> H("Aday havuzu<br/>elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar<br/>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<br/>önce il, sonra alan — listede açıkça belirtilir") G --> H @@ -44,10 +44,12 @@ export function HuniSemasi() { return ( <ElCizimiSema tanim={SEMA} - ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, il adımında programı olan tüm iller listelenir; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda güvenli olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır." + ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuza yalnızca en son tercih kılavuzunda yer alan programlar girer, kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez; alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, il adımında programı olan tüm iller listelenir; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda güvenli olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır." figcaption={ <> - 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{" "} <span className="font-medium text-slate-700"> dışına asla çıkmadan </span>{" "} 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. </p> </section> @@ -116,10 +116,13 @@ export function MeraklisinaIcerik() { tabloya da uygulanır. Adımlar bitince elde kalan gerçek programlar bir <strong>aday havuzu</strong>{" "} 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{" "} + <strong>en son tercih kılavuzunda yer alan</strong>{" "} + 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. </p> <HuniSemasi /> </section> 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<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = 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({ <TableHead className="w-8 pl-4 pr-0">#</TableHead> <TableHead>Program</TableHead> <TableHead className="hidden text-right md:table-cell"> - Son 5 yıl + Son {yilSayisi} yıl </TableHead> <TableHead> <span className="hidden sm:inline">Yapay Zeka </span>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() { <footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500"> <p> - Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (2021–2025 + Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi ({veriAraligi}{" "} taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai diff --git a/src/features/rapor/components/tadimlik-satiri.tsx b/src/features/rapor/components/tadimlik-satiri.tsx index 592984a..bc72ea8 100644 --- a/src/features/rapor/components/tadimlik-satiri.tsx +++ b/src/features/rapor/components/tadimlik-satiri.tsx @@ -105,7 +105,7 @@ export function TadimlikSatiri({ const p = satir.program; const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim); - const seri = raporSiraSerisi(p.siraGecmisi); + const seri = raporSiraSerisi(p.siraGecmisi, p.ilkYil); const taban = p.efektifSira ?? programEtkinSira(seri); const devlet = p.unitur === "DEVLET"; const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref())}`; diff --git a/src/features/rapor/types/rapor.ts b/src/features/rapor/types/rapor.ts index 85045e3..385348d 100644 --- a/src/features/rapor/types/rapor.ts +++ b/src/features/rapor/types/rapor.ts @@ -34,8 +34,13 @@ export type RaporSonuc = { // ayıklamak için (bkz. features/rapor/rapor-queries.ts) devMock?: boolean; // Render için havuzdan zenginleştirilmiş program bilgisi - // efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok - // siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku + // efektifSira: üretim anındaki son yılın sırası, yoksa bir önceki yılınki — + // eski kayıtlı raporlarda yok + // siraGecmisi: [ilkYil..] taban sıralamaları — deterministik trend oku. + // Uzunluk üretim anındaki veri penceresidir (eski kayıtlarda 5 eleman); + // okuyan taraf diziyi doldurmaz, yılı `ilkYil + indeks` ile bulur. + // ilkYil: dizinin ilk elemanının yılı — eski kayıtlarda yok (okurken + // lib/veri-yillari ILK_YIL) programlar: Record< string, { @@ -45,6 +50,7 @@ export type RaporSonuc = { unitur: string | null; efektifSira?: number | null; siraGecmisi?: (number | null)[]; + ilkYil?: number; } >; }; diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts index 708fbc9..13b6adf 100644 --- a/src/lib/ai/rapor.ts +++ b/src/lib/ai/rapor.ts @@ -26,6 +26,7 @@ import { riskNotuMetni, } from "../risk"; import { trendOzetiMetni } from "../trend"; +import { ILK_YIL } from "../veri-yillari"; // Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu // ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti @@ -141,29 +142,27 @@ function normalizeEt( function trendOzetiUret(p: AdayProgram): string { // Cümle tadımlıkla ortak saf fonksiyondan gelir (lib/trend.ts) - return trendOzetiMetni([ - p.sira2021, - p.sira2022, - p.sira2023, - p.sira2024, - p.sira2025, - ]); + return trendOzetiMetni(p.siraGecmisi); } function riskNotuUret(p: AdayProgram, adaySira: number): string { - return riskNotuMetni(riskHesapla(p.sira2025 ?? p.sira2024, adaySira)); + return riskNotuMetni(riskHesapla(p.efektifSira, adaySira)); } /** Havuz programını render için gereken bilgiye indirger. */ function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] { - 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, + // Dizinin çapa yılı snapshot'a yazılır: kayıt, veri penceresi sonradan + // uzasa da kendi yıllarını bilir (eski kayıtlarda yok → okurken ILK_YIL). + ilkYil: gecmisVar ? ILK_YIL : undefined, }; } @@ -183,7 +182,7 @@ function mockRapor( // Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder const hedef = dilimHedefiHesapla(havuz); const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti; - const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0; + const efektif = (p: AdayProgram) => p.efektifSira ?? 0; const dilimden = (dilim: AdayProgram["dilim"], adet: number) => havuz .filter((p) => p.dilim === dilim) @@ -293,7 +292,7 @@ export async function raporUret( const havuzMap = new Map(havuz.map((p) => [p.id, p])); const efektif = (id: string) => { const p = havuzMap.get(id)!; - return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER; + return p.efektifSira ?? Number.MAX_SAFE_INTEGER; }; let secim: Record<Dilim, AiSecim[]> | null = null; diff --git a/src/lib/db.ts b/src/lib/db.ts index b6195d1..704b7c0 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -3,6 +3,7 @@ import path from "node:path"; import Database from "better-sqlite3"; import { KATEGORILER, kategoriBul } from "./kategoriler"; +import { EFEKTIF_SIRA_SQL, SON_YIL, YILLAR } from "./veri-yillari"; import { PUAN_TURLERI, type DilimKey, @@ -27,12 +28,46 @@ export function getDb(): Database.Database { return globalForDb.yokatlasDb; } +// Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB +// dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır +// `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir). export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure, - sira2025, sira2024, sira2023, sira2022, sira2021, - puan2025, kontenjan2025, yerlesen2025`; + ${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")}, + puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon`; -// 2025 sıralaması yoksa (az yerleşen/yeni program) 2024'e düşer -export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)"; +// Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer. +// İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts. +export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL; + +/** SELECT_COLS ile okunan ham satır: sıra kolonları `s<yıl>` takma adıyla gelir. */ +export type ProgramHamSatiri = Omit< + Program, + "siraGecmisi" | "efektifSira" | "siraSon" +> & + Record<`s${number}`, number | null>; + +/** Ham DB satırını yılsız `Program`'a çevirir. */ +export function programSatiri(row: ProgramHamSatiri): Program { + const siraGecmisi = YILLAR.map((yil) => row[`s${yil}`] ?? null); + const siraSon = siraGecmisi[siraGecmisi.length - 1] ?? null; + return { + id: row.id, + isim: row.isim, + universite: row.universite, + unitur: row.unitur, + il: row.il, + fakulte: row.fakulte, + tur: row.tur, + sure: row.sure, + siraGecmisi, + // EFEKTIF_SIRA ile aynı kural: son yıl, yoksa bir önceki yıl + efektifSira: siraSon ?? siraGecmisi[siraGecmisi.length - 2] ?? null, + siraSon, + puanSon: row.puanSon, + kontenjanSon: row.kontenjanSon, + yerlesenSon: row.yerlesenSon, + }; +} /** * Kullanıcının başarı sıralamasına göre programları üç dilime ayırır. @@ -112,18 +147,18 @@ export function dilimAra( // hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa const yon = dilim === "hayal" ? "DESC" : "ASC"; - const programlar = db + const satirlar = db .prepare( `SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?` ) - .all(...args, limit, offset) as Program[]; + .all(...args, limit, offset) as ProgramHamSatiri[]; const toplam = ( db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as { adet: number; } ).adet; - return { programlar, toplam }; + return { programlar: satirlar.map(programSatiri), toplam }; } export function searchByRank( diff --git a/src/lib/katalog.ts b/src/lib/katalog.ts index a2ebbf8..ae4339c 100644 --- a/src/lib/katalog.ts +++ b/src/lib/katalog.ts @@ -1,5 +1,11 @@ import "server-only"; -import { getDb, SELECT_COLS, EFEKTIF_SIRA } from "./db"; +import { + getDb, + programSatiri, + SELECT_COLS, + EFEKTIF_SIRA, + type ProgramHamSatiri, +} from "./db"; import type { Program } from "@/types/yokatlas"; import { kategoriBul, type Kategori } from "./kategoriler"; import { uniAdiNormalize } from "./harita"; @@ -24,7 +30,7 @@ export type BolumOzet = { kategoriler: Kategori[]; lisans: boolean; onlisans: boolean; - /** MIN(COALESCE(sira2025, sira2024)) — en iyi (en küçük) taban sıralaması */ + /** MIN(efektif sıra) — en iyi (en küçük) taban sıralaması */ enIyiSira: number | null; }; @@ -131,13 +137,15 @@ export function getBolumBySlug(slug: string): BolumDetay | null { if (!kayit) return null; const placeholders = kayit.hamIsimler.map(() => "?").join(","); - const programlar = getDb() - .prepare( - `SELECT ${SELECT_COLS} FROM programs - WHERE isim IN (${placeholders}) - ORDER BY (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`, - ) - .all(...kayit.hamIsimler) as (Program & { onlisans?: number })[]; + const programlar = ( + getDb() + .prepare( + `SELECT ${SELECT_COLS} FROM programs + WHERE isim IN (${placeholders}) + ORDER BY (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`, + ) + .all(...kayit.hamIsimler) as ProgramHamSatiri[] + ).map(programSatiri); // onlisans SELECT_COLS içinde yok; tur üzerinden ayır (TYT = önlisans) const lisansProgramlari = programlar.filter((p) => p.tur !== "TYT"); @@ -156,14 +164,14 @@ export function getBolumBySlug(slug: string): BolumDetay | null { if (p.unitur === "DEVLET") devlet += 1; else if (p.unitur) vakif += 1; else bilinmeyenTur += 1; - const efektif = p.sira2025 ?? p.sira2024; + const efektif = p.efektifSira; if (efektif != null) { if (minSira == null || efektif < minSira) minSira = efektif; if (maxSira == null || efektif > maxSira) maxSira = efektif; } - if (p.puan2025 != null) { - if (minPuan == null || p.puan2025 < minPuan) minPuan = p.puan2025; - if (maxPuan == null || p.puan2025 > maxPuan) maxPuan = p.puan2025; + if (p.puanSon != null) { + if (minPuan == null || p.puanSon < minPuan) minPuan = p.puanSon; + if (maxPuan == null || p.puanSon > maxPuan) maxPuan = p.puanSon; } } @@ -357,13 +365,15 @@ export function getUniversiteBySlug(slug: string): UniDetay | null { if (!kayit) return null; const placeholders = kayit.hamAdlar.map(() => "?").join(","); - const programlar = getDb() - .prepare( - `SELECT ${SELECT_COLS} FROM programs - WHERE universite IN (${placeholders}) - ORDER BY fakulte, (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`, - ) - .all(...kayit.hamAdlar) as Program[]; + const programlar = ( + getDb() + .prepare( + `SELECT ${SELECT_COLS} FROM programs + WHERE universite IN (${placeholders}) + ORDER BY fakulte, (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`, + ) + .all(...kayit.hamAdlar) as ProgramHamSatiri[] + ).map(programSatiri); const gruplar = new Map<string, Program[]>(); let minSira: number | null = null; @@ -373,11 +383,11 @@ export function getUniversiteBySlug(slug: string): UniDetay | null { const liste = gruplar.get(anahtar); if (liste) liste.push(p); else gruplar.set(anahtar, [p]); - const efektif = p.sira2025 ?? p.sira2024; + const efektif = p.efektifSira; if (efektif != null && (minSira == null || efektif < minSira)) { minSira = efektif; } - toplamKontenjan += p.kontenjan2025 ?? 0; + toplamKontenjan += p.kontenjanSon ?? 0; } const ozet = getAllUniversiteler().find((u) => u.slug === slug)!; diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts index 5453485..7efe92f 100644 --- a/src/lib/rapor-havuzu.ts +++ b/src/lib/rapor-havuzu.ts @@ -8,6 +8,7 @@ import { } from "@/types/yokatlas"; import type { RaporParams } from "@/features/rapor/types/rapor"; import { kategoriEslesir } from "./kategoriler"; +import { SON_YIL, YILLAR } from "./veri-yillari"; export type Dilim = "hayal" | "dengeli" | "garanti"; @@ -155,6 +156,13 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc { return { havuz: dilimBasinaKirp(ilsizHam, PROMPT_LIMIT_DILIM), gevsetildi }; } +// Prompt'a veri penceresinin tamamı değil son 4 yılı gider (yeniden eskiye): +// pencere uzadıkça satır başına token sayısı sabit kalır. +const PROMPT_YIL_SAYISI = 4; +const PROMPT_YIL_INDEKSLERI = YILLAR.map((_, i) => i) + .slice(-PROMPT_YIL_SAYISI) + .reverse(); + /** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */ export function havuzuKompaktJson(havuz: AdayProgram[]): string { return JSON.stringify( @@ -165,14 +173,16 @@ export function havuzuKompaktJson(havuz: AdayProgram[]): string { unitur: p.unitur, il: p.il, dilim: p.dilim, - sira: { - y2025: p.sira2025, - y2024: p.sira2024, - y2023: p.sira2023, - y2022: p.sira2022, - }, - kontenjan2025: p.kontenjan2025, - yerlesen2025: p.yerlesen2025, + // Anahtarlar yıl taşır (y<yıl>, kontenjan<yıl>): model rakamın hangi + // yıla ait olduğunu anahtardan okur. + sira: Object.fromEntries( + PROMPT_YIL_INDEKSLERI.map((i) => [ + `y${YILLAR[i]}`, + p.siraGecmisi[i] ?? null, + ]), + ), + [`kontenjan${SON_YIL}`]: p.kontenjanSon, + [`yerlesen${SON_YIL}`]: p.yerlesenSon, })), ); } diff --git a/src/lib/rapor-maske.ts b/src/lib/rapor-maske.ts index ffb70ad..8842b6a 100644 --- a/src/lib/rapor-maske.ts +++ b/src/lib/rapor-maske.ts @@ -44,7 +44,7 @@ export function raporMaskele(rapor: RaporSonuc): MaskeliRapor { // kilitli metinler istemciye hiç ulaşmaz. uyarilar: rapor.uyarilar?.slice(0, 1) ?? [], kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1), - // Program adı, üniversite ve 2021–2025 taban sıralamaları ham veridir. + // Program adı, üniversite ve geçmiş yılların taban sıralamaları ham veridir. programlar: rapor.programlar, // Kapsam (kaç program bulundu / dağılım uyarlandı mı) dürüstlük bilgisi: // paketsiz kullanıcı da görür, maskelenmez. diff --git a/src/lib/risk.ts b/src/lib/risk.ts index 132e176..e025ac0 100644 --- a/src/lib/risk.ts +++ b/src/lib/risk.ts @@ -32,9 +32,10 @@ export const RISK_PIN_RENK: Record<RiskSeviyesi, string> = { }; /** - * Deterministik risk: programın efektif taban sıralaması (COALESCE 2025→2024) - * adayın sıralamasıyla kıyaslanır. Eşikler db.ts'teki dilim aralıklarıyla - * birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, ∞)). + * Deterministik risk: programın efektif taban sıralaması (son yılın sırası, + * yoksa bir önceki yılınki) adayın sıralamasıyla kıyaslanır. Eşikler + * db.ts'teki dilim aralıklarıyla birebir aynı (dengeli: [sira, 1.4×sira], + * garanti: (1.4×sira, ∞)). */ export function riskHesapla( efektifSira: number | null | undefined, @@ -72,7 +73,8 @@ export function dilimdenFarkliRiskEtiketi( * Risk seviyesinin tek cümlelik açıklaması (rapor satırları + tadımlık). * Ürün yerleşme olasılığı HESAPLAMAZ; yalnızca son taban sıralamasını adayın * sırasıyla kıyaslar. Bu yüzden metin olasılık/ihtimal/"yerleşirsin" demez. - * "Geçen yıl" da denmez: efektif sıra 2025 yoksa 2024'e düşüyor. + * "Geçen yıl" da denmez: efektif sıra, son yılın verisi yoksa bir önceki + * yılınkine düşüyor. */ export function riskNotuMetni(risk: RiskSeviyesi | null): string { switch (risk) { diff --git a/src/lib/seo.tsx b/src/lib/seo.tsx index 52b4149..6d784cd 100644 --- a/src/lib/seo.tsx +++ b/src/lib/seo.tsx @@ -21,7 +21,7 @@ export const SITE_NAME = "KolayTercih"; * Yalnızca veri GERÇEKTEN değiştiğinde (ingest/refresh sonrası) elle güncelle; * build zamanına bağlama — her deploy'da değişen lastmod sinyali değersizleşir. */ -export const KATALOG_VERI_TARIHI = "2026-08-09"; +export const KATALOG_VERI_TARIHI = "2026-09-21"; type SayfaMetadataGirdi = { title: string; diff --git a/src/lib/tadimlik-havuzu.ts b/src/lib/tadimlik-havuzu.ts index 7b01b7b..90071fb 100644 --- a/src/lib/tadimlik-havuzu.ts +++ b/src/lib/tadimlik-havuzu.ts @@ -10,6 +10,7 @@ import type { Dilim } from "./rapor-havuzu"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import { dilimdenRisk, riskHesapla, riskNotuMetni } from "./risk"; import { trendOzetiMetni } from "./trend"; +import { VERI_YIL_SAYISI } from "./veri-yillari"; /** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */ export const GENEL_KATEGORI = "genel"; @@ -54,16 +55,41 @@ export type TadimlikTip = "genel" | "devlet" | "vakif"; * gerekce, satırın servis edilen TEK LLM metnidir (batch'te temsili sıraya * göre yazıldı). Ürün yerleşme olasılığı hesaplamaz; bu yüzden olasılık / * ihtimal / şans / garanti / kesin / yüzde / "yerleşirsin" diyen gerekçe - * servis edilmez. Eski batch'in "son 4 yıl" kalıbı da takılır (veri 5 yılı - * kapsıyor, bkz. veri-kapsami.ts). Kalıp küçük harfledir: metin önce Türkçe - * kurallarıyla küçültülür ("İhtimal" → "ihtimal"; düz /i bayrağı İ/ı'yı - * eşlemez). + * servis edilmez. Kalıp küçük harfledir: metin önce Türkçe kurallarıyla + * küçültülür ("İhtimal" → "ihtimal"; düz /i bayrağı İ/ı'yı eşlemez). */ const YASAK_GEREKCE_DILI = - /olasılı|ihtimal|şans|garanti|kesin|yüzde|yerleşirsin|yerleşebil|son (4|dört) yıl/; + /olasılı|ihtimal|şans|garanti|kesin|yüzde|yerleşirsin|yerleşebil/; + +// Bayat batch kalıbı: gerekçe "son N yıl" diyorsa N, verinin bugün kapsadığı +// yıl sayısı (VERI_YIL_SAYISI) olmalı. Veri penceresi uzayınca eski batch'in +// "son 5 yıl"ı da, daha eskisinin "son 4 yıl"ı da kendiliğinden takılır. +const SON_N_YIL = /son (\d+|[a-zçğıöşü]+) yıl/g; +const SAYI_SOZCUKLERI: Record<string, number> = { + bir: 1, + iki: 2, + üç: 3, + dört: 4, + beş: 5, + altı: 6, + yedi: 7, + sekiz: 8, + dokuz: 9, + on: 10, +}; + +function bayatYilSayisiVar(kucukMetin: string): boolean { + for (const [, ham] of kucukMetin.matchAll(SON_N_YIL)) { + const n = /^\d+$/.test(ham) ? Number(ham) : SAYI_SOZCUKLERI[ham]; + // Sayı olmayan sözcük ("son birkaç yıl") yıl sayısı iddiası değildir + if (n != null && n !== VERI_YIL_SAYISI) return true; + } + return false; +} export function gerekceServisEdilebilir(gerekce: string): boolean { - return !YASAK_GEREKCE_DILI.test(gerekce.toLocaleLowerCase("tr-TR")); + const kucuk = gerekce.toLocaleLowerCase("tr-TR"); + return !YASAK_GEREKCE_DILI.test(kucuk) && !bayatYilSayisiVar(kucuk); } /** @@ -108,10 +134,12 @@ export async function tadimlikSec( // aynı formülden adayın gerçek sırasına göre; trend, rapordakiyle aynı // saf fonksiyondan programın sıra geçmişinden okuma anında üretilir — // metin rozetle çelişemez, "son 4 yıl" gibi bayat kalıp taşınmaz. + // Snapshot'ın sıra dizisi üretildiği günün penceresidir (eski + // satırlarda 5 eleman); yıllar dizinin kendi çapasından okunur. riskNotu: riskNotuMetni( riskHesapla(program.efektifSira, sira) ?? dilimdenRisk(satir.dilim), ), - trendOzeti: trendOzetiMetni(program.siraGecmisi), + trendOzeti: trendOzetiMetni(program.siraGecmisi, program.ilkYil), program, }; } diff --git a/src/lib/trend.ts b/src/lib/trend.ts index 4e3c8bd..8221cda 100644 --- a/src/lib/trend.ts +++ b/src/lib/trend.ts @@ -4,12 +4,7 @@ // düzeyine indi"); yıl aralığı sabit bir "son N yıl" kalıbından değil, // serideki dolu yıllardan gelir. -/** - * Sıra geçmişi dizisinin ilk elemanının yılı. Dizi her yerde 2021'den başlar - * (bkz. programBilgisi, raporSiraSerisi); yeni veri yılı sona eklenir, bu - * çapa değişmez. - */ -const ILK_VERI_YILI = 2021; +import { ILK_YIL } from "./veri-yillari"; /** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */ function siraKisa(n: number): string { @@ -21,7 +16,10 @@ function siraKisa(n: number): string { } /** - * [2021..] taban sıralaması dizisinden tek cümlelik trend özeti. + * [ilkYil..] taban sıralaması dizisinden tek cümlelik trend özeti. Dizi + * `ilkYil`'a demirlidir (varsayılan: veri penceresinin ilk yılı); yeni veri + * yılı sona eklenir, bu yüzden eski kayıtların daha kısa dizileri de doğru + * yılları verir. `ilkYil`, snapshot kendi çapasını taşıyorsa ondan gelir. * * Cümle yalnızca OLANI betimler: ilk ve son dolu yılın tabanını kıyaslar * (±%10 eşiği). "Zorlaşıyor / kolaylaşıyor" gibi geleceğe dönük yargı @@ -30,22 +28,23 @@ function siraKisa(n: number): string { */ export function trendOzetiMetni( gecmis: readonly (number | null | undefined)[] | undefined, + ilkYil: number = ILK_YIL, ): string { const seri: Array<[number, number]> = []; (gecmis ?? []).forEach((sira, i) => { - if (sira != null) seri.push([ILK_VERI_YILI + i, sira]); + if (sira != null) seri.push([ilkYil + i, sira]); }); if (seri.length < 2) { return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku."; } - const [ilkYil, ilkSira] = seri[0]; + const [basYil, ilkSira] = seri[0]; const [sonYil, sonSira] = seri[seri.length - 1]; const oran = (sonSira - ilkSira) / ilkSira; if (oran <= -0.1) { - return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; taban sıkılaştı.`; + return `${basYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; taban sıkılaştı.`; } if (oran >= 0.1) { - return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; taban gevşedi.`; + return `${basYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; taban gevşedi.`; } - return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`; + return `${basYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`; } diff --git a/src/lib/veri-kapsami.ts b/src/lib/veri-kapsami.ts index 937f77a..f3a7f47 100644 --- a/src/lib/veri-kapsami.ts +++ b/src/lib/veri-kapsami.ts @@ -1,6 +1,6 @@ /** - * Taban sıralaması geçmişinin kapsadığı yıl sayısı. Veritabanında - * sira2021–sira2025 kolonları var (5 yıl); "son N yıl" diyen tüm metinler bu - * sabitten okur. Yeni veri yılı eklenince (ör. sira2026) burayı güncelle. + * Taban sıralaması geçmişinin kapsadığı yıl sayısı; "son N yıl" diyen tüm + * metinler bu sabitten okur. Değer yıl kapsamının tek kaynağından + * (lib/veri-yillari.ts) türetilir — yeni veri yılı eklenince burası değişmez. */ -export const VERI_YIL_SAYISI = 5; +export { VERI_YIL_SAYISI } from "./veri-yillari"; diff --git a/src/lib/veri-yillari.ts b/src/lib/veri-yillari.ts new file mode 100644 index 0000000..2e0b014 --- /dev/null +++ b/src/lib/veri-yillari.ts @@ -0,0 +1,38 @@ +/** + * Yerleştirme verisinin yıl kapsamı — TEK KAYNAK. Saf ve import'suz: client, + * server ve `scripts/` aynı dosyayı okur. Yeni yıl verisi yüklenince yalnız + * `SON_YIL` değişir (+ `pnpm refresh -- --yil <yıl>` ile DB inşası). + */ + +/** Veri penceresinin ilk yılı. Kayıtlı rapor/tadımlık `siraGecmisi` dizileri + * bu yıla demirlidir; pencere kaydırılmaz, uzar. */ +export const ILK_YIL = 2021; + +/** En son yerleştirme yılı (DB'de `sira${SON_YIL}` kolonları dolu). */ +export const SON_YIL = 2026; + +export const ONCEKI_YIL = SON_YIL - 1; + +/** [2021, …, SON_YIL] — `siraGecmisi` dizileriyle indeks hizalı. */ +export const YILLAR: readonly number[] = Array.from( + { length: SON_YIL - ILK_YIL + 1 }, + (_, i) => ILK_YIL + i, +); + +/** Bu veriyle tercih yapacak öğrencilerin yılı. */ +export const TERCIH_YILI = SON_YIL + 1; + +/** Sayfa başlıklarında görünen yıl ("… Taban Puanları 2026"). */ +export const BASLIK_YILI = SON_YIL; + +export const VERI_YIL_SAYISI = YILLAR.length; + +/** "2021–2026" */ +export const VERI_ARALIGI = `${ILK_YIL}–${SON_YIL}`; + +/** + * Efektif sıra: son yılın sırası, yoksa bir önceki yılınki. İNDEKS SÖZLEŞMESİ — + * SQLite ifade indeksi metinsel eşleşmeyle çalışır; sorgular ve indeks + * betikleri bu dizgiyi birebir (virgülden sonra tek boşluk) kullanmalıdır. + */ +export const EFEKTIF_SIRA_SQL = `COALESCE(sira${SON_YIL}, sira${ONCEKI_YIL})`; diff --git a/src/types/yokatlas.ts b/src/types/yokatlas.ts index 55b1c9e..6e7d61b 100644 --- a/src/types/yokatlas.ts +++ b/src/types/yokatlas.ts @@ -11,14 +11,16 @@ export type Program = { fakulte: string | null; tur: string; sure: number | null; - sira2025: number | null; - sira2024: number | null; - sira2023: number | null; - sira2022: number | null; - sira2021: number | null; - puan2025: number | null; - kontenjan2025: number | null; - yerlesen2025: number | null; + /** Taban sıralamaları; `YILLAR` (lib/veri-yillari) ile indeks hizalı. */ + siraGecmisi: (number | null)[]; + /** Son yılın sırası; o yıl verisi yoksa bir önceki yılınki. */ + efektifSira: number | null; + // "Son" = en son yerleştirme yılı (SON_YIL). Alan adları bilinçli yılsız: + // yeni veri yılı yüklenince tip ve tüketiciler değişmez. + siraSon: number | null; + puanSon: number | null; + kontenjanSon: number | null; + yerlesenSon: number | null; }; export const PUAN_TURLERI = { @@ -42,11 +44,15 @@ export type RankResults = { }; /** - * /sonuc ham tablosunun satırı: tablo `fakulte` ve `puan2025`'i hiç + * /sonuc ham tablosunun satırı: tablo `fakulte`, `puanSon` ve `siraSon`'u + * (dizinin son elemanı; tablo `efektifSira` + `siraGecmisi` ile çalışır) hiç * kullanmadığı için bu alanlar client'a serileştirilmez (satır başına en * uzun string alanlardan biri fakulte). Katalog sayfaları tam Program kullanır. */ -export type ProgramListeSatiri = Omit<Program, "fakulte" | "puan2025">; +export type ProgramListeSatiri = Omit< + Program, + "fakulte" | "puanSon" | "siraSon" +>; export type RankResultsListe = { hayal: ProgramListeSatiri[];