/** * 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 * * 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. * * 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 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 = 500; const KILIT_ESIK = 0.95; const KILIT_ASGARI_ORNEK = 50; export const TUR_MAP: Record = { SAY: "SAYISAL", EA: "EŞİT AĞIRLIK", SÖZ: "SÖZEL", DİL: "DİL", TYT: "TYT", }; /** 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; puanTuru: string; ogrenimSuresi?: number | null; birimTuruId: number; 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: Record[]; totalPages: number; totalElements: number; }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function fetchPage(page: number): Promise { const res = await fetch(API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filters: { puanTuru: null, universiteId: [], birimGrupId: [], ilKodu: [], birimTuruId: null, universiteTuru: null, bursOraniId: null, ogrenimTuruId: null, kilavuzKodu: null, minBasariSirasi: null, maxBasariSirasi: null, }, page, size: PAGE_SIZE, sortBy: "kilavuzKodu", direction: "ASC", }), }); if (!res.ok) { throw new Error(`API ${res.status} (sayfa ${page})`); } return (await res.json()) as ApiPage; } /** 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; } 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; } 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; } /** * 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) )); } // ── 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 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, 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; const atlananlar: string[] = []; 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); for (const [id, r] of benzersiz) { const tur = TUR_MAP[r.puanTuru?.trim() ?? ""]; // 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 vals = [ intOrNull(r.basariSirasi), realOrNull(r.minPuan), intOrNull(r.kontenjan), yerlesen(r), ]; const eski = eskiKod(r); if (exists.get(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, universite, uniturSec(universite, r.universiteTuru ?? null), r.ilAdi ?? null, fakulteSec(universite, r.fymkAdi ?? null), tur, r.ogrenimSuresi ?? null, r.birimTuruId === 47 ? 1 : 0, ...vals, yil, eski, ...gecmis.flatMap((g) => [g.sira, g.puan, g.kontenjan]) ); inserted++; } } 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)); })(); console.log( `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)}` ); } if (process.argv[1]?.endsWith("refresh.ts")) calistir(main);