From 38e02e0ed98394aef30d181c6d9e585ae6bffdf1 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 16:35:24 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(veri):=20y=C4=B1l=20kapsam=C4=B1=20tek?= =?UTF-8?q?=20kayna=C4=9F=C4=B1=20(veri-yillari.ts)=20=E2=80=94=202026=20y?= =?UTF-8?q?=C3=BCklemesi=20P-0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/lib/veri-yillari.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/lib/veri-yillari.ts 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 ` 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})`; From a773ac7beb9cd77fddde3e8a696c02bd547c0c8a Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 17:03:01 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(veri):=202026=20y=C3=BCkleme=20hatt?= =?UTF-8?q?=C4=B1=20=E2=80=94=20y=C4=B1l=20kilitli=20refresh,=20ge=C3=A7mi?= =?UTF-8?q?=C5=9F=20doldurma,=20ar=C5=9Fiv,=20kalite=20kap=C4=B1s=C4=B1=20?= =?UTF-8?q?(P-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yokatlas-goc: idempotent şema göçü (yıl kolonları, son_kilavuz_yili, eski_kod, programs_arsiv, veri_meta) + betiklerin ortak yardımcıları - refresh: --yil/--db zorunlu, YIL KİLİDİ (…1 = DB sira ≥%95 ve eksiz ≠), yıl asla r.yil'den türetilmez; ham veri data/ham/*.jsonl.gz, --ham ile API'siz tekrar; benzersiz kod = totalElements; tek transaction + önceki yıl sağlaması; yeni programda …1/2/3 → üç önceki yıl; efektif indeks EFEKTIF_SIRA_SQL ile - gecmis-doldur: yalnız NULL alan; API …1/2/3 → eski_kod → kanıtlı 1:1 ad eşleşmesi - eski-arsivle: güncel kılavuzda olmayanlar → programs_arsiv, netler yeni koda; --kuru - detay: netler bağımsız + yıl sağlaması; kırılım yalnız --kirilim, alan yoksa durur, mevcut değerin üstüne NULL yazılmaz; `yil - 1` varsayımı kalktı - veri-kalite: salt okunur kalite kapısı, zorunlu kontrol kalırsa çıkış kodu 1 - docs/veri/yillik-guncelleme.md: adım adım runbook + geri alma DB commit'lenmedi (arşivleme kararı [Bilal]). Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 + docs/veri/yillik-guncelleme.md | 230 +++++++++++++ package.json | 5 + scripts/db-index.ts | 111 ++++--- scripts/detay.ts | 350 +++++++++++--------- scripts/eski-arsivle.ts | 228 +++++++++++++ scripts/gecmis-doldur.ts | 302 +++++++++++++++++ scripts/refresh.ts | 571 +++++++++++++++++++++++++++------ scripts/veri-kalite.ts | 228 +++++++++++++ scripts/yokatlas-goc.ts | 267 +++++++++++++++ 10 files changed, 2016 insertions(+), 279 deletions(-) create mode 100644 docs/veri/yillik-guncelleme.md create mode 100644 scripts/eski-arsivle.ts create mode 100644 scripts/gecmis-doldur.ts create mode 100644 scripts/veri-kalite.ts create mode 100644 scripts/yokatlas-goc.ts 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/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/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(); + }); +}