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>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
/**
|
||
* 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)}`
|
||
);
|
||
});
|