merge: veri/2026 — P-0 yıl sözleşmesi + P-A veri betikleri

This commit is contained in:
bilalgursen
2026-09-21 17:04:12 +03:00
11 changed files with 2054 additions and 279 deletions

3
.gitignore vendored
View File

@@ -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/

View File

@@ -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-<tarih>.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 <db> --ham data/ham/2027-<tarih>.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 <commit>` + 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â <geçen yı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<yıl>`, `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 = ?`.

View File

@@ -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",

View File

@@ -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));
`);
// 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);
`);
// 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)");
/**
* 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;
}
/** 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 COALESCE(sira2025, sira2024) IS NOT NULL
AND COALESCE(sira2025, sira2024) BETWEEN ? AND ?
ORDER BY COALESCE(sira2025, sira2024) ASC LIMIT 10`,
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);
.all("SAYISAL", 0, 1000, 50000) as { detail: string }[];
return plan.map((p) => p.detail);
}
console.log("Sorgu planı:", JSON.stringify(plan, null, 2));
if (process.argv[1]?.endsWith("db-index.ts")) {
calistir(() => {
const db = dbAc(dbArgumani());
console.log(`Efektif indeks (${EFEKTIF_SIRA_SQL}): ${efektifIndeksiKur(db)}`);
// 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);
`);
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.`);
}
});
}

View File

@@ -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<yil> 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ı → <yil-1> 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<string, unknown>;
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<KilavuzRecord>(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<number, string>();
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<KilavuzRecord>(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<void> {
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<NetlerRecord>(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<number, Database.Statement<unknown[]>>();
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<yil> → ${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<NetlerRecord>(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<NetlerRecord>(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<void> {
const veriYili = yil - 1; // `…1` alanları; yıl kilidiyle doğrulanır, r.yil'e bakılmaz
const first = await fetchPage<KirilimKaydi>(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<KirilimKaydi>(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);

228
scripts/eski-arsivle.ts Normal file
View File

@@ -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<string, { ad: string; n: number }>();
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<string, { ad: string; n: number }>();
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/<slug> 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/<slug> 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 <db> --vacuum sonra pnpm veri-kalite …");
}
if (process.argv[1]?.endsWith("eski-arsivle.ts")) calistir(main);

302
scripts/gecmis-doldur.ts Normal file
View File

@@ -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<string, string | number | null> & { 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<string, number>(); // "kaynak|kolon" → adet
const farkli = new Map<string, { adet: number; ornek: string[] }>();
const say = (m: Map<string, number>, 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<string, Database.Statement<unknown[]>>();
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<string, string[]> = { 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<string, number>();
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<string, { guncel: Satir[]; eski: Satir[] }>();
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<string, number>();
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);

View File

@@ -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<yil-1> 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<yil>, 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 <dosya>` 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<string, string> = {
export const TUR_MAP: Record<string, string> = {
SAY: "SAYISAL",
EA: "EŞİT AĞIRLIK",
SÖZ: "SÖZEL",
@@ -26,27 +68,75 @@ const TUR_MAP: Record<string, string> = {
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<string, unknown>): KilavuzKaydi {
const ince: Record<string, unknown> = {};
for (const alan of TUTULAN_ALANLAR) {
if (ham[alan] !== undefined) ince[alan] = ham[alan];
}
return ince as KilavuzKaydi;
}
type ApiPage = {
content: ApiRecord[];
content: Record<string, unknown>[];
totalPages: number;
totalElements: number;
};
@@ -83,140 +173,435 @@ async function fetchPage(page: number): Promise<ApiPage> {
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");
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);
`);
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;
}
// 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));
`);
/**
* 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<HamOkuma> {
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<string, unknown>;
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<yil-1> (≥%95) VE eksiz `basariSirasi` ≠ DB
* sira<yil-1>. İkisi birlikte "eksiz alanlar gerçekten <yil> 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<HamOkuma & { hamDosya: string }> {
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<void>((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)
));
}
// ── Bütünlük: benzersiz kod = totalElements ────────────────────────────
const benzersiz = new Map<string, KilavuzKaydi>();
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<string, Set<string>>();
const fakulteler = new Map<string, Map<string, string>>();
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<string>();
set.add(s.unitur);
uniturlar.set(k, set);
}
}
if (s.fakulte) {
const m = fakulteler.get(baz) ?? new Map<string, string>();
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}`);
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ı.`
);
}
console.log();
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 };
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));
})();
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 <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);

228
scripts/veri-kalite.ts Normal file
View File

@@ -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 <db> 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);

267
scripts/yokatlas-goc.ts Normal file
View File

@@ -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): 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<yil> 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();
});
}

38
src/lib/veri-yillari.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Yerleştirme verisinin yıl kapsamı — TEK KAYNAK. Saf ve import'suz: client,
* server ve `scripts/` aynı dosyayı okur. Yeni yıl verisi yüklenince yalnız
* `SON_YIL` değişir (+ `pnpm refresh -- --yil <yıl>` ile DB inşası).
*/
/** Veri penceresinin ilk yılı. Kayıtlı rapor/tadımlık `siraGecmisi` dizileri
* bu yıla demirlidir; pencere kaydırılmaz, uzar. */
export const ILK_YIL = 2021;
/** En son yerleştirme yılı (DB'de `sira${SON_YIL}` kolonları dolu). */
export const SON_YIL = 2026;
export const ONCEKI_YIL = SON_YIL - 1;
/** [2021, …, SON_YIL] — `siraGecmisi` dizileriyle indeks hizalı. */
export const YILLAR: readonly number[] = Array.from(
{ length: SON_YIL - ILK_YIL + 1 },
(_, i) => ILK_YIL + i,
);
/** Bu veriyle tercih yapacak öğrencilerin yılı. */
export const TERCIH_YILI = SON_YIL + 1;
/** Sayfa başlıklarında görünen yıl ("… Taban Puanları 2026"). */
export const BASLIK_YILI = SON_YIL;
export const VERI_YIL_SAYISI = YILLAR.length;
/** "2021–2026" */
export const VERI_ARALIGI = `${ILK_YIL}–${SON_YIL}`;
/**
* Efektif sıra: son yılın sırası, yoksa bir önceki yılınki. İNDEKS SÖZLEŞMESİ —
* SQLite ifade indeksi metinsel eşleşmeyle çalışır; sorgular ve indeks
* betikleri bu dizgiyi birebir (virgülden sonra tek boşluk) kullanmalıdır.
*/
export const EFEKTIF_SIRA_SQL = `COALESCE(sira${SON_YIL}, sira${ONCEKI_YIL})`;