- yokatlas-goc: idempotent şema göçü (yıl kolonları, son_kilavuz_yili, eski_kod, programs_arsiv, veri_meta) + betiklerin ortak yardımcıları - refresh: --yil/--db zorunlu, YIL KİLİDİ (…1 = DB sira<yil-1> ≥%95 ve eksiz ≠), yıl asla r.yil'den türetilmez; ham veri data/ham/*.jsonl.gz, --ham ile API'siz tekrar; benzersiz kod = totalElements; tek transaction + önceki yıl sağlaması; yeni programda …1/2/3 → üç önceki yıl; efektif indeks EFEKTIF_SIRA_SQL ile - gecmis-doldur: yalnız NULL alan; API …1/2/3 → eski_kod → kanıtlı 1:1 ad eşleşmesi - eski-arsivle: güncel kılavuzda olmayanlar → programs_arsiv, netler yeni koda; --kuru - detay: netler bağımsız + yıl sağlaması; kırılım yalnız --kirilim, alan yoksa durur, mevcut değerin üstüne NULL yazılmaz; `yil - 1` varsayımı kalktı - veri-kalite: salt okunur kalite kapısı, zorunlu kontrol kalırsa çıkış kodu 1 - docs/veri/yillik-guncelleme.md: adım adım runbook + geri alma DB commit'lenmedi (arşivleme kararı [Bilal]). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
||
* 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<yil> 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ı → <yil-1> 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<string, unknown>;
|
||
|
||
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<T> = { content: T[]; totalPages: number; totalElements: number };
|
||
|
||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
async function fetchPage<T>(api: string, page: number): Promise<ApiPage<T>> {
|
||
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<T>;
|
||
}
|
||
|
||
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<void> {
|
||
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<NetlerRecord>(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<number, Database.Statement<unknown[]>>();
|
||
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<yil> → ${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<NetlerRecord>(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<void> {
|
||
const veriYili = yil - 1; // `…1` alanları; yıl kilidiyle doğrulanır, r.yil'e bakılmaz
|
||
const first = await fetchPage<KirilimKaydi>(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<KirilimKaydi>(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);
|