feat(veri): program_ozellik tablosu — YÖK Atlas API'den ücret, burs, dil, öğretim türü, akreditasyon, kadro, baraj, ilçe, koşullar

scripts/program-ozellik.ts (pnpm ozellik --yil --db [--ham]) API'nin
kılavuz alanlarını program_ozellik ve kosul tablolarına yazar; 25 Eyl
2026 çekimi: 21.493 program, 194 koşul metni. src/lib/program-ozellik.ts
okuyucu + öğrenciye okunan kısa özet. Tablo yoksa okuma boş döner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-25 01:02:39 +03:00
parent f5e8e4f7c4
commit c23910cc79
5 changed files with 423 additions and 0 deletions

3
.gitignore vendored
View File

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

Binary file not shown.

View File

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

282
scripts/program-ozellik.ts Normal file
View File

@@ -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-<yil>-<tarih>.jsonl.gz'e yazılır;
* `--ham <dosya>` 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<string, unknown>;
type ApiPage = {
content: HamKayit[];
totalPages: number;
totalElements: number;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fetchPage(page: number): Promise<ApiPage> {
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<HamKayit[]> {
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<HamKayit[]> {
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<void>((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<string, string> } {
const metinler = new Map<string, string>();
const liste = Array.isArray(r.kosulList) ? (r.kosulList as Record<string, string>[]) : [];
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<string, string>();
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<string, number>;
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)}`
);
});

137
src/lib/program-ozellik.ts Normal file
View File

@@ -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<string, ProgramOzellik> {
const sonuc: Record<string, ProgramOzellik> = {};
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;
}