/** * Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler. * İki BAĞIMSIZ adım: * * A) Yerleşen son kişinin netleri (varsayılan; `--netler-atla` ile kapatılır) * Kaynak: POST /api/netler/search → `netler` tablosu. Bu API her satırda * verinin ait olduğu yılı `yil` alanında verir; betik bunu körlemesine * kabul etmez: ilk sayfada `tabanPuan`, DB'deki puan ile ≥%95 * eşleşmiyorsa DURUR (yıl etiketi kaymış demektir). * * B) Kontenjan türü kırılımı — YALNIZ `--kirilim` bayrağıyla (varsayılan KAPALI) * Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın için * kontenjan + yerleşen; API'nin `…1` alanları → kolonları. * `--yil`'in kendi kırılım kolonları BİLEREK yok (src/ içinde okuyan kod * yok; KARARLAR 2026-09-21). * Emniyet: (1) refresh ile aynı YIL KİLİDİ; (2) beklenen on alanın HER * BİRİ ilk sayfada en az bir kayıtta bulunmalı — API null alanları hiç * göndermediği için "ilk kayıt" tek başına yanıltır; bir alan ilk sayfanın * tamamında `undefined` ise API o alanı artık yayımlamıyordur ve adım * HİÇBİR ŞEY yazmadan DURUR; (3) yazım `COALESCE(yeni, mevcut)` — * mevcut değerin üstüne asla NULL yazılmaz. * (Eylül 2026: API `gkY1/obk1/…` alanlarını yayımlamıyor → bu adım durur; * 2025 kırılımı DB'de Ağustos 2026 çekiminden duruyor, korunur.) * * Yıl HİÇBİR adımda kılavuz kaydındaki `yil` alanından türetilmez. * * Not: YÖK Atlas 2026'da SPA'ya geçti; eski content/lisans-dynamic/*.php * panelleri (ve onlarla birlikte kadın-erkek dağılımı) yayından kalktı. * * Kullanım: * pnpm detay --yil 2026 --db /tmp/deneme.db * pnpm detay --yil 2026 --db /tmp/deneme.db --kirilim --netler-atla */ 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 = 500; /** API alanı → DB kolon öneki. `…1` = bir önceki yerleştirme yılı. */ const KIRILIM_ALANLARI = [ ["gk1", "gk"], ["gkY1", "gkY"], ["obk1", "obk"], ["obkY1", "obkY"], ["sgy1", "sgy"], ["sgyY1", "sgyY"], ["dprm1", "dprm"], ["dprmY1", "dprmY"], ["y34_1", "y34k"], ["y34Y1", "y34kY"], ] as const; type KirilimKaydi = KilavuzKaydi & Record; type NetlerRecord = { kilavuzKodu: number; yil: number; puanTuru: string | null; tabanPuan?: number | null; obp?: number | null; katsayi?: number | null; tytTrkNet?: number | null; tytSosNet?: number | null; tytMatNet?: number | null; tytFenNet?: number | null; aytMatNet?: number | null; aytFizNet?: number | null; aytKimNet?: number | null; aytBioNet?: number | null; aytTdeNet?: number | null; aytTrh1Net?: number | null; aytCog1Net?: number | null; aytTrh2Net?: number | null; aytCog2Net?: number | null; aytDinNet?: number | null; aytFelNet?: number | null; ydtYdilNet?: number | null; }; type ApiPage = { content: T[]; totalPages: number; totalElements: number }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function fetchPage(api: string, page: number): Promise> { const res = await fetch(api, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filters: {}, 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; } function num(v: unknown): number | null { return typeof v === "number" && Number.isFinite(v) ? v : null; } const NET_COLS = [ "puanTuru", "tabanPuan", "obp", "katsayi", "tytTrkNet", "tytSosNet", "tytMatNet", "tytFenNet", "aytMatNet", "aytFizNet", "aytKimNet", "aytBioNet", "aytTdeNet", "aytTrh1Net", "aytCog1Net", "aytTrh2Net", "aytCog2Net", "aytDinNet", "aytFelNet", "ydtYdilNet", ] as const; // ── A) Netler ──────────────────────────────────────────────────────────── async function netlerAdimi(db: Db, yil: number, azamiSayfa: number): Promise { db.exec(` CREATE TABLE IF NOT EXISTS netler ( program_id TEXT NOT NULL, yil INTEGER NOT NULL, ${NET_COLS.map((c) => `${c} ${c === "puanTuru" ? "TEXT" : "REAL"}`).join( ",\n " )}, PRIMARY KEY (program_id, yil) ); `); const first = await fetchPage(NETLER_API, 0); // Yıl etiketi sağlaması: netler.tabanPuan(yil=Y) ≈ programs.puanY const programKolonlari = new Set(kolonAdlari(db, "programs")); const puanGetir = new Map>(); let kiyas = 0; let esit = 0; for (const r of first.content) { if (!r.kilavuzKodu || !r.yil || num(r.tabanPuan) == null) continue; if (r.yil < ILK_YIL || r.yil > yil || !programKolonlari.has(`puan${r.yil}`)) continue; let st = puanGetir.get(r.yil); if (!st) { st = db.prepare(`SELECT puan${r.yil} AS p FROM programs WHERE id = ?`); puanGetir.set(r.yil, st); } const satir = st.get(String(r.kilavuzKodu)) as { p: number | null } | undefined; if (!satir || satir.p == null) continue; kiyas++; if (Math.abs(satir.p - (r.tabanPuan as number)) <= 0.01) esit++; } const oran = kiyas ? esit / kiyas : 0; console.log(`Netler yıl sağlaması: tabanPuan = puan → ${esit}/${kiyas} (%${(oran * 100).toFixed(1)})`); if (kiyas < 50 || oran < 0.95) { dur( "Netler API'sindeki `yil` etiketi DB'deki yıl kolonlarıyla örtüşmüyor " + "(ya da karşılaştıracak veri yok). Netler yazılmadı." ); } // Mevcut değerin üstüne NULL yazılmaz: çakışmada COALESCE(yeni, eski). const upsert = db.prepare(` INSERT INTO netler (program_id, yil, ${NET_COLS.join(", ")}) VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")}) ON CONFLICT(program_id, yil) DO UPDATE SET ${NET_COLS.map((c) => `${c} = COALESCE(excluded.${c}, ${c})`).join(",\n ")} `); const programVar = db.prepare("SELECT 1 FROM programs WHERE id = ?"); let yazilan = 0; let programYok = 0; let yilDisi = 0; const processNetler = db.transaction((records: NetlerRecord[]) => { for (const r of records) { if (!r.kilavuzKodu || !r.yil) continue; 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])) ); yazilan++; } }); const sayfa = Math.min(first.totalPages, azamiSayfa); console.log(`Netler: ${first.totalElements} kayıt, ${first.totalPages} sayfa${sayfa < first.totalPages ? ` (yalnız ilk ${sayfa})` : ""}…`); processNetler(first.content); for (let p = 1; p < sayfa; p++) { await sleep(DELAY_MS); processNetler((await fetchPage(NETLER_API, p)).content); process.stdout.write(`\rnetler sayfa ${p + 1}/${sayfa}`); } 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( `\nNetler: ${yazilan} satır yazıldı/güncellendi; programı DB'de olmayan: ${programYok}; yıl aralığı dışı: ${yilDisi}` ); console.log(`Netler yıl dağılımı: ${dagilim.map((d) => `${d.yil}=${d.n}`).join(" ")}`); } // ── B) Kontenjan kırılımı ──────────────────────────────────────────────── async function kirilimAdimi(db: Db, yil: number, azamiSayfa: number): Promise { const veriYili = yil - 1; // `…1` alanları; yıl kilidiyle doğrulanır, r.yil'e bakılmaz const first = await fetchPage(KILAVUZ_API, 0); yilKilidi(db, yil, first.content); const eksik = KIRILIM_ALANLARI.filter( ([alan]) => !first.content.some((r) => r[alan] !== undefined) ).map(([alan]) => alan); if (eksik.length) { dur( `Kırılım alanları API'de yok (ilk sayfanın tamamında undefined): ${eksik.join(", ")}. ` + `${veriYili} kırılım kolonlarına DOKUNULMADI (mevcut değerler korunuyor).` ); } const kontCols = KIRILIM_ALANLARI.map(([, onek]) => `${onek}${veriYili}`); const mevcut = new Set(kolonAdlari(db, "programs")); for (const col of kontCols) { if (!mevcut.has(col)) db.exec(`ALTER TABLE programs ADD COLUMN ${col} INTEGER`); } const updateKont = db.prepare(` UPDATE programs SET ${kontCols.map((c) => `${c} = COALESCE(?, ${c})`).join(", ")} WHERE id = ? `); const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?"); let guncellenen = 0; let dbdeYok = 0; const isle = db.transaction((records: KirilimKaydi[]) => { for (const r of records) { if (!r.kilavuzKodu) continue; const id = String(r.kilavuzKodu); if (!exists.get(id)) { dbdeYok++; continue; } updateKont.run(...KIRILIM_ALANLARI.map(([alan]) => num(r[alan])), id); guncellenen++; } }); const sayfa = Math.min(first.totalPages, azamiSayfa); console.log(`Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${sayfa} sayfa…`); isle(first.content); for (let p = 1; p < sayfa; p++) { await sleep(DELAY_MS); isle((await fetchPage(KILAVUZ_API, p)).content); process.stdout.write(`\rkırılım sayfa ${p + 1}/${sayfa}`); } console.log(`\nKontenjan kırılımı: ${guncellenen} güncellendi, ${dbdeYok} DB'de yok`); } async function main() { const yil = yilArgumani(); const db = dbAc(dbArgumani()); // Yalnız deneme için: kaynağı yormadan ilk N sayfayla dur. const azamiSayfa = Number.parseInt(argOku("azami-sayfa") ?? "", 10) || Number.POSITIVE_INFINITY; let hata: unknown = null; if (!bayrakVar("netler-atla")) { try { await netlerAdimi(db, yil, azamiSayfa); } catch (err) { hata = err; // adımlar bağımsız: netler dursa da kırılım (istenmişse) denenir console.error(`Netler adımı durdu: ${err instanceof Error ? err.message : err}`); } } if (bayrakVar("kirilim")) { await kirilimAdimi(db, yil, azamiSayfa); } else { console.log("Kontenjan kırılımı atlandı (yalnız --kirilim ile koşar)."); } db.pragma("wal_checkpoint(TRUNCATE)"); db.close(); if (hata) throw hata; } if (process.argv[1]?.endsWith("detay.ts")) calistir(main);