diff --git a/.gitignore b/.gitignore index 7f59ca4..2dc4bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ __pycache__/ # YÖK Atlas ham API çekimleri (scripts/refresh.ts; ~yıllık, büyük, yeniden üretilebilir) data/ham/ + +# YÖK Atlas API ham dökümleri (refresh.ts, program-ozellik.ts) +/data/ham/ diff --git a/data/yokatlas.db b/data/yokatlas.db index 3926e72..2642738 100644 Binary files a/data/yokatlas.db and b/data/yokatlas.db differ diff --git a/package.json b/package.json index 8f8865a..5dea669 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "eslint && pnpm tasarim:denetim", "ingest": "tsx scripts/ingest.ts", "refresh": "tsx scripts/refresh.ts", + "ozellik": "tsx scripts/program-ozellik.ts", "detay": "tsx scripts/detay.ts", "yokatlas:goc": "tsx scripts/yokatlas-goc.ts", "gecmis-doldur": "tsx scripts/gecmis-doldur.ts", diff --git a/scripts/program-ozellik.ts b/scripts/program-ozellik.ts new file mode 100644 index 0000000..13fbae8 --- /dev/null +++ b/scripts/program-ozellik.ts @@ -0,0 +1,282 @@ +/** + * YÖK Atlas API'sindeki program ÖZELLİKLERİNİ `program_ozellik` tablosuna yazar: + * yıllık ücret, burs oranı, öğretim dili, öğretim türü, program ve üniversite + * akreditasyonu, kadro (prof/doç/dr. öğr. üyesi/arş. gör.), tercih baraj + * sırası, kampüs ilçesi ve yerleşme koşulu kodları. Koşul metinleri koda göre + * `kosul` tablosunda tek kopya durur. + * + * refresh.ts'ten AYRI betik: bu alanların yıl anlamı yok (kılavuzun o anki + * hâli), yıl kilidi gerekmez; ama --yil ve --db yine zorunlu ve tablo satırı + * hangi yılın kılavuzundan geldiğini `yil` kolonunda taşır. Yalnız `programs` + * tablosunda olan id'lere yazılır; eşleşmeyen kayıt sayısı raporlanır. + * + * Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı. + * Çekilen ham kayıtlar data/ham/ozellik--.jsonl.gz'e yazılır; + * `--ham ` verilirse API'ye gidilmez, o dosya okunur. + * + * Kullanım: + * pnpm ozellik --yil 2026 --db /tmp/deneme.db + * pnpm ozellik --yil 2026 --db data/yokatlas.db --ham data/ham/ozellik-2026-2026-09-25.jsonl.gz + */ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; +import zlib from "node:zlib"; +import { + argOku, + calistir, + dbAc, + dbArgumani, + dur, + metaYaz, + yilArgumani, + type Db, +} from "./yokatlas-goc"; + +const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search"; +const PAGE_SIZE = 500; +const DELAY_MS = 500; + +type HamKayit = Record; + +type ApiPage = { + content: HamKayit[]; + 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). */ +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 metinOrNull(v: unknown): string | null { + if (typeof v !== "string") return null; + const t = v.trim(); + return t ? t : null; +} + +// ── Şema ───────────────────────────────────────────────────────────────── + +export function ozellikTablolariniGarantiEt(db: Db): void { + db.exec(` + CREATE TABLE IF NOT EXISTS program_ozellik ( + id TEXT PRIMARY KEY REFERENCES programs(id), + ucret INTEGER, + burs_orani TEXT, + ogrenim_dili TEXT, + ogrenim_turu TEXT, + akreditasyon TEXT, + uni_akreditasyon TEXT, + prof INTEGER, + doc INTEGER, + dou INTEGER, + argor INTEGER, + min_basari_sirasi INTEGER, + ilce TEXT, + kosul_kodlari TEXT, + yil INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS kosul ( + kod TEXT PRIMARY KEY, + metin TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_program_ozellik_dili ON program_ozellik (ogrenim_dili); + CREATE INDEX IF NOT EXISTS idx_program_ozellik_turu ON program_ozellik (ogrenim_turu); + `); +} + +// ── Ham dosya ──────────────────────────────────────────────────────────── + +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()), + }); + const kayitlar: HamKayit[] = []; + let metaVar = false; + for await (const satir of rl) { + if (!satir) continue; + const obj = JSON.parse(satir) as HamKayit; + if (obj._meta) { + const meta = obj._meta as { yil: number }; + if (meta.yil !== yil) dur(`Ham dosya ${meta.yil} yılı için, --yil ${yil} verildi.`); + metaVar = true; + continue; + } + kayitlar.push(obj); + } + if (!metaVar) dur(`Ham dosyada _meta satırı yok: ${dosya}`); + return kayitlar; +} + +async function apidenCek(yil: number): Promise { + const ilk = await fetchPage(0); + const toplamSayfa = ilk.totalPages; + const kayitlar = [...ilk.content]; + console.log(`API: ${ilk.totalElements} kayıt, ${toplamSayfa} sayfa`); + for (let p = 1; p < toplamSayfa; p++) { + await sleep(DELAY_MS); + const sayfa = await fetchPage(p); + kayitlar.push(...sayfa.content); + if (p % 10 === 0) console.log(` sayfa ${p}/${toplamSayfa}`); + } + const benzersiz = new Set(kayitlar.map((r) => String(r.kilavuzKodu))).size; + if (benzersiz !== ilk.totalElements) { + dur(`Benzersiz kod ${benzersiz} ≠ totalElements ${ilk.totalElements} (sayfalama kaydı)`); + } + const dizin = path.join(process.cwd(), "data", "ham"); + fs.mkdirSync(dizin, { recursive: true }); + const tarih = new Date().toISOString().slice(0, 10); + const dosya = path.join(dizin, `ozellik-${yil}-${tarih}.jsonl.gz`); + const gz = zlib.createGzip(); + const out = fs.createWriteStream(dosya); + gz.pipe(out); + gz.write(JSON.stringify({ _meta: { yil, totalElements: ilk.totalElements, tarih, kaynak: "program-ozellik" } }) + "\n"); + for (const r of kayitlar) gz.write(JSON.stringify(r) + "\n"); + await new Promise((res, rej) => { + out.on("finish", () => res()); + out.on("error", rej); + gz.end(); + }); + console.log(`Ham dosya: ${dosya}`); + return kayitlar; +} + +// ── Yazım ──────────────────────────────────────────────────────────────── + +function kosulKodlari(r: HamKayit): { kodlar: string[]; metinler: Map } { + const metinler = new Map(); + const liste = Array.isArray(r.kosulList) ? (r.kosulList as Record[]) : []; + for (const k of liste) { + for (const [kod, metin] of Object.entries(k)) { + if (typeof metin === "string" && metin.trim()) metinler.set(kod, metin.trim()); + } + } + // `kosul` alanı "155,18,21" biçiminde; listedeki kodlarla birleşir. + const hamKodlar = typeof r.kosul === "string" ? r.kosul.split(",") : []; + const kodlar = Array.from( + new Set([...hamKodlar.map((k) => k.trim()).filter(Boolean), ...metinler.keys()]) + ).sort((a, b) => Number(a) - Number(b)); + return { kodlar, metinler }; +} + +calistir(async () => { + const yil = yilArgumani(); + const dbYolu = dbArgumani(); + const hamArg = argOku("ham"); + + const kayitlar = hamArg + ? await hamOku(path.resolve(process.cwd(), hamArg), yil) + : await apidenCek(yil); + + const db = dbAc(dbYolu); + ozellikTablolariniGarantiEt(db); + + const mevcut = new Set( + (db.prepare("SELECT id FROM programs").all() as { id: string }[]).map((r) => r.id) + ); + + const yaz = db.prepare(` + INSERT INTO program_ozellik (id, ucret, burs_orani, ogrenim_dili, ogrenim_turu, + akreditasyon, uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi, + ilce, kosul_kodlari, yil) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ucret = excluded.ucret, burs_orani = excluded.burs_orani, + ogrenim_dili = excluded.ogrenim_dili, ogrenim_turu = excluded.ogrenim_turu, + akreditasyon = excluded.akreditasyon, uni_akreditasyon = excluded.uni_akreditasyon, + prof = excluded.prof, doc = excluded.doc, dou = excluded.dou, argor = excluded.argor, + min_basari_sirasi = excluded.min_basari_sirasi, ilce = excluded.ilce, + kosul_kodlari = excluded.kosul_kodlari, yil = excluded.yil + `); + const kosulYaz = db.prepare( + "INSERT INTO kosul (kod, metin) VALUES (?, ?) ON CONFLICT(kod) DO UPDATE SET metin = excluded.metin" + ); + + let yazilan = 0; + let eslesmeyen = 0; + const tumKosullar = new Map(); + db.transaction(() => { + for (const r of kayitlar) { + const id = String(r.kilavuzKodu ?? ""); + if (!mevcut.has(id)) { + eslesmeyen++; + continue; + } + const { kodlar, metinler } = kosulKodlari(r); + for (const [k, m] of metinler) tumKosullar.set(k, m); + yaz.run( + id, + intOrNull(r.ucret), + metinOrNull(r.bursOraniAdi), + metinOrNull(r.ogrenimDiliAdi), + metinOrNull(r.ogrenimTuruAdi), + metinOrNull(r.akreditasyon), + metinOrNull(r.uniAkreditasyon), + intOrNull(r.prof), + intOrNull(r.doc), + intOrNull(r.dou), + intOrNull(r.arGor), + intOrNull(r.minBasariSirasi), + metinOrNull(r.ilceAdi), + kodlar.length ? kodlar.join(",") : null, + yil + ); + yazilan++; + } + for (const [k, m] of tumKosullar) kosulYaz.run(k, m); + metaYaz(db, "program_ozellik_yil", yil); + metaYaz(db, "program_ozellik_tarih", new Date().toISOString().slice(0, 10)); + })(); + + const ozet = db + .prepare( + `SELECT COUNT(*) AS n, + SUM(ucret IS NOT NULL) AS ucret, SUM(burs_orani IS NOT NULL) AS burs, + SUM(ogrenim_dili IS NOT NULL) AS dil, SUM(akreditasyon IS NOT NULL) AS akr, + SUM(prof IS NOT NULL) AS prof, SUM(min_basari_sirasi IS NOT NULL) AS baraj, + SUM(kosul_kodlari IS NOT NULL) AS kosul + FROM program_ozellik` + ) + .get() as Record; + db.pragma("wal_checkpoint(TRUNCATE)"); + db.close(); + console.log( + `Yazıldı ${yazilan}, programs'ta olmayan ${eslesmeyen}, koşul metni ${tumKosullar.size}.\n` + + `Doluluk: ${JSON.stringify(ozet)}` + ); +}); diff --git a/src/lib/program-ozellik.ts b/src/lib/program-ozellik.ts new file mode 100644 index 0000000..05ee622 --- /dev/null +++ b/src/lib/program-ozellik.ts @@ -0,0 +1,137 @@ +// Program özellikleri: YÖK Atlas API'sinden scripts/program-ozellik.ts ile +// `program_ozellik` ve `kosul` tablolarına yazılan, yıl anlamı olmayan alanlar +// (ücret, burs, dil, öğretim türü, akreditasyon, kadro, baraj, ilçe, koşullar). +// Sıra/puan/kontenjan gibi yıllık alanlar burada DEĞİL (bkz. db.ts). +import { getDb } from "./db"; + +export type ProgramOzellik = { + id: string; + /** Vakıf/KKTC yıllık öğrenim ücreti (TL, KDV dahil, kılavuz yılı); devlette null */ + ucret: number | null; + /** "Burslu" | "%50 İndirimli" | "%25 İndirimli" | "Ücretli"; devlette null */ + bursOrani: string | null; + /** "Türkçe" | "İngilizce" | "İngilizce (%30)" | "Almanca" … */ + ogrenimDili: string | null; + /** "Örgün Öğretim" | "Uzaktan Öğretim" | "Açık Öğretim" | "UOLP" */ + ogrenimTuru: string | null; + /** Program akreditasyonu (MÜDEK, TEPDAD, FEDEK …) */ + akreditasyon: string | null; + /** Üniversite akreditasyonu (YÖKAK …) */ + uniAkreditasyon: string | null; + prof: number | null; + doc: number | null; + dou: number | null; + argor: number | null; + /** Tercih edebilmek için gereken en düşük başarı sırası (baraj) */ + minBasariSirasi: number | null; + /** Kampüs ilçesi (büyük harf, API'den) */ + ilce: string | null; + /** Yerleşme koşulu kodları (kosul tablosundan metne çevrilir) */ + kosulKodlari: string[]; + /** Kılavuz yılı */ + yil: number; +}; + +type HamSatir = { + id: string; + ucret: number | null; + burs_orani: string | null; + ogrenim_dili: string | null; + ogrenim_turu: string | null; + akreditasyon: string | null; + uni_akreditasyon: string | null; + prof: number | null; + doc: number | null; + dou: number | null; + argor: number | null; + min_basari_sirasi: number | null; + ilce: string | null; + kosul_kodlari: string | null; + yil: number; +}; + +const OZELLIK_COLS = `id, ucret, burs_orani, ogrenim_dili, ogrenim_turu, akreditasyon, + uni_akreditasyon, prof, doc, dou, argor, min_basari_sirasi, ilce, kosul_kodlari, yil`; + +function satir(r: HamSatir): ProgramOzellik { + return { + id: r.id, + ucret: r.ucret, + bursOrani: r.burs_orani, + ogrenimDili: r.ogrenim_dili, + ogrenimTuru: r.ogrenim_turu, + akreditasyon: r.akreditasyon, + uniAkreditasyon: r.uni_akreditasyon, + prof: r.prof, + doc: r.doc, + dou: r.dou, + argor: r.argor, + minBasariSirasi: r.min_basari_sirasi, + ilce: r.ilce, + kosulKodlari: r.kosul_kodlari ? r.kosul_kodlari.split(",") : [], + yil: r.yil, + }; +} + +/** Tablo yoksa (eski DB) her okuma null/boş döner; sayfa kırılmaz. */ +function tabloVar(): boolean { + return ( + getDb() + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'") + .get() !== undefined + ); +} + +export function programOzellikGetir(id: string): ProgramOzellik | null { + if (!tabloVar()) return null; + const r = getDb() + .prepare(`SELECT ${OZELLIK_COLS} FROM program_ozellik WHERE id = ?`) + .get(id) as HamSatir | undefined; + return r ? satir(r) : null; +} + +export function programOzellikleriGetir( + ids: readonly string[] +): Record { + const sonuc: Record = {}; + if (ids.length === 0 || !tabloVar()) return sonuc; + const satirlar = getDb() + .prepare( + `SELECT ${OZELLIK_COLS} FROM program_ozellik WHERE id IN (${ids.map(() => "?").join(",")})` + ) + .all(...ids) as HamSatir[]; + for (const r of satirlar) sonuc[r.id] = satir(r); + return sonuc; +} + +/** Koşul kodlarının kılavuz metinleri, kod sırasıyla. Bilinmeyen kod atlanır. */ +export function kosulMetinleri(kodlar: readonly string[]): { kod: string; metin: string }[] { + if (kodlar.length === 0 || !tabloVar()) return []; + const satirlar = getDb() + .prepare(`SELECT kod, metin FROM kosul WHERE kod IN (${kodlar.map(() => "?").join(",")})`) + .all(...kodlar) as { kod: string; metin: string }[]; + const harita = new Map(satirlar.map((s) => [s.kod, s.metin])); + return kodlar.flatMap((k) => (harita.has(k) ? [{ kod: k, metin: harita.get(k)! }] : [])); +} + +/** Kadro toplamı (prof + doç + dr. öğr. üyesi); hiçbiri yoksa null. */ +export function ogretimUyesiToplami(o: ProgramOzellik): number | null { + const parcalar = [o.prof, o.doc, o.dou].filter((x): x is number => x != null); + return parcalar.length ? parcalar.reduce((a, b) => a + b, 0) : null; +} + +/** Öğrenciye okunan kısa özet: "İngilizce · Burslu · MÜDEK akreditasyonlu". */ +export function ozellikOzeti(o: ProgramOzellik): string[] { + const parcalar: string[] = []; + if (o.ogrenimDili && o.ogrenimDili !== "Türkçe") parcalar.push(o.ogrenimDili); + if (o.ogrenimTuru && o.ogrenimTuru !== "Örgün Öğretim") parcalar.push(o.ogrenimTuru); + if (o.bursOrani) parcalar.push(o.bursOrani); + if (o.ucret != null && o.bursOrani !== "Burslu") { + parcalar.push(`yıllık ${o.ucret.toLocaleString("tr-TR")} TL`); + } + if (o.akreditasyon) parcalar.push(`${o.akreditasyon} akreditasyonlu`); + if (o.minBasariSirasi != null) { + parcalar.push(`baraj ${o.minBasariSirasi.toLocaleString("tr-TR")}. sıra`); + } + return parcalar; +}