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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
BIN
data/yokatlas.db
BIN
data/yokatlas.db
Binary file not shown.
@@ -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
282
scripts/program-ozellik.ts
Normal 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
137
src/lib/program-ozellik.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user