feat(veri): 2026 yükleme hattı — yıl kilitli refresh, geçmiş doldurma, arşiv, kalite kapısı (P-A)

- 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>
This commit is contained in:
bilalgursen
2026-09-21 17:03:01 +03:00
parent 38e02e0ed9
commit a773ac7beb
10 changed files with 2016 additions and 279 deletions

View File

@@ -1,45 +1,71 @@
/**
* Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler:
* Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler.
* İki BAĞIMSIZ adım:
*
* 1. Kontenjan türü kırılımı (son yerleştirme yılı, ör. 2025):
* Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın
* için kontenjan + yerleşen. Kaynak: POST /api/tercih-kilavuz/search
* (gk1/gkY1, obk1/obkY1, sgy1/sgyY1, dprm1/dprmY1, y34_1/y34Y1 —
* "1" son yerleştirme yılı demek; kayıttaki `yil` kılavuz yılıdır,
* veri yılı = yil - 1).
* 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).
*
* 2. Yerleşen son kişinin netleri (yıl bazında, tüm puan türleri):
* Kaynak: POST /api/netler/search → `netler` tablosu.
* 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ı.
* Cinsiyet verisi yeni API'lerin hiçbirinde yok.
*
* Kullanım: npx tsx scripts/detay.ts
* Kullanım:
* pnpm detay --yil 2026 --db /tmp/deneme.db
* pnpm detay --yil 2026 --db /tmp/deneme.db --kirilim --netler-atla
*/
import path from "node:path";
import Database from "better-sqlite3";
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 = 300;
const DELAY_MS = 500;
type KilavuzRecord = {
kilavuzKodu: number;
eskiKilavuzKodu?: number | null;
yil: number;
gk1?: number | null;
gkY1?: number | null;
obk1?: number | null;
obkY1?: number | null;
sgy1?: number | null;
sgyY1?: number | null;
dprm1?: number | null;
dprmY1?: number | null;
y34_1?: number | null;
y34Y1?: number | null;
};
/** 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;
@@ -113,96 +139,9 @@ const NET_COLS = [
"ydtYdilNet",
] as const;
async function main() {
const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
db.pragma("journal_mode = WAL");
// ── A) Netler ────────────────────────────────────────────────────────────
// ── 1. Kontenjan türü kırılımı ─────────────────────────────────────────
const first = await fetchPage<KilavuzRecord>(KILAVUZ_API, 0);
// API kaydındaki yil kılavuz yılı; "1" ekli alanlar bir önceki
// yerleştirme dönemine ait (2026 kılavuzu → 2025 verisi)
const veriYili = first.content[0].yil - 1;
const kontCols = [
`gk${veriYili}`,
`gkY${veriYili}`,
`obk${veriYili}`,
`obkY${veriYili}`,
`sgy${veriYili}`,
`sgyY${veriYili}`,
`dprm${veriYili}`,
`dprmY${veriYili}`,
`y34k${veriYili}`,
`y34kY${veriYili}`,
];
const mevcut = new Set(
(db.prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
(c) => c.name
)
);
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} = ?`).join(", ")}
WHERE id = ?
`);
const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
// Kılavuz kodu yıllar arasında değişebiliyor (eskiKilavuzKodu); netler
// API'si yeni kodla döndüğü için eşleşen DB id'sini haritada tutuyoruz.
const dbId = new Map<number, string>();
let kontUpdated = 0;
let kontMissing = 0;
const processKilavuz = db.transaction((records: KilavuzRecord[]) => {
for (const r of records) {
if (!r.kilavuzKodu) continue;
let id = String(r.kilavuzKodu);
if (!exists.get(id)) {
const eski = r.eskiKilavuzKodu ? String(r.eskiKilavuzKodu) : null;
if (eski && exists.get(eski)) {
id = eski;
} else {
kontMissing++;
continue;
}
}
dbId.set(r.kilavuzKodu, id);
updateKont.run(
num(r.gk1),
num(r.gkY1),
num(r.obk1),
num(r.obkY1),
num(r.sgy1),
num(r.sgyY1),
num(r.dprm1),
num(r.dprmY1),
num(r.y34_1),
num(r.y34Y1),
id
);
kontUpdated++;
}
});
console.log(
`Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${first.totalPages} sayfa…`
);
processKilavuz(first.content);
for (let p = 1; p < first.totalPages; p++) {
await sleep(DELAY_MS);
processKilavuz((await fetchPage<KilavuzRecord>(KILAVUZ_API, p)).content);
process.stdout.write(`\rkontenjan sayfa ${p + 1}/${first.totalPages}`);
}
console.log(
`\nKontenjan kırılımı: ${kontUpdated} güncellendi, ${kontMissing} DB'de yok`
);
// ── 2. Yerleşen son kişinin netleri ────────────────────────────────────
async function netlerAdimi(db: Db, yil: number, azamiSayfa: number): Promise<void> {
db.exec(`
CREATE TABLE IF NOT EXISTS netler (
program_id TEXT NOT NULL,
@@ -214,54 +153,165 @@ async function main() {
);
`);
const insertNet = db.prepare(`
INSERT OR REPLACE INTO netler (program_id, yil, ${NET_COLS.join(", ")})
VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")})
`);
const first = await fetchPage<NetlerRecord>(NETLER_API, 0);
let netRows = 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;
const id = dbId.get(r.kilavuzKodu) ?? String(r.kilavuzKodu);
insertNet.run(
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]))
);
netRows++;
yazilan++;
}
});
const netFirst = await fetchPage<NetlerRecord>(NETLER_API, 0);
console.log(
`Netler: ${netFirst.totalElements} kayıt, ${netFirst.totalPages} sayfa…`
);
processNetler(netFirst.content);
for (let p = 1; p < netFirst.totalPages; p++) {
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}/${netFirst.totalPages}`);
process.stdout.write(`\rnetler sayfa ${p + 1}/${sayfa}`);
}
console.log(`\nNetler: ${netRows} satır yazıldı`);
const ozet = db
.prepare(
`SELECT
(SELECT COUNT(*) FROM programs WHERE gkY${veriYili} IS NOT NULL) AS kontlu,
(SELECT COUNT(*) FROM netler) AS net,
(SELECT COUNT(DISTINCT program_id) FROM netler) AS netli_program`
)
.get() as { kontlu: number; net: number; netli_program: number };
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(
`Özet → kontenjan kırılımı olan program: ${ozet.kontlu}, ` +
`net satırı: ${ozet.net} (${ozet.netli_program} program)`
`\nNetler: ${yazilan} satır yazıldı/güncellendi; programı DB'de olmayan: ${programYok}; yıl aralığı dışı: ${yilDisi}`
);
db.close();
console.log(`Netler yıl dağılımı: ${dagilim.map((d) => `${d.yil}=${d.n}`).join(" ")}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
// ── 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);