Files
kolaytercih/scripts/detay.ts
bilalgursen a773ac7beb 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>
2026-09-21 17:03:01 +03:00

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);