Files
kolaytercih/scripts/refresh.ts
bilalgursen 2ff8fe584b fix(veri): kimlik kilidi — kodu başka programa devredilmiş satırlar yakalanır ve onarılır
Kılavuz kodu başka programa devredildiğinde (ör. 209210292: Diş Hekimliği SAY
lisans → İlk ve Acil Yardım TYT önlisans) refresh yalnız koda bakıp yıl
kolonlarını yazıyordu; satır eski adıyla yeni programın sırasını gösteriyordu.

- scripts/kimlik.ts (yeni): kimlik = puan türü + önlisans + baz ad slug'ı.
  Devir yılı kanıt zinciriyle bulunur (API …1/…2/…3 sıra/puanı DB ile eşleşen
  ardışık yıllar + netler.puanTuru); API geçmişi kod bazlı ve kirli olduğu
  için (gk2, bazı basariSirasi3) zincirsiz güvenilmez. Yalnız adı değişen ve
  API penceresinin tamamında sürekliliği olan satır "ad değişimi"dir, geçmişi
  korunur.
- refresh: uyumsuz satır varsa listeler ve HİÇBİR ŞEY yazmadan durur;
  --kimlik-onar ile isim/tur/onlisans/fakulte/sure API'den yazılır, devir
  öncesi tüm yıl kolonları NULL'lanır, eski kimlik + geçmişi programs_arsiv'e
  <kod>~<yıl> id'siyle kopyalanır, eski yılların netleri oraya taşınır,
  veri_meta'ya kimlik_onar:<kod> kaydı düşer. Önceki-yıl sağlaması onarılanlar
  dışındaki satırlar için aynen geçerli. --isim-esitle (varsayılan kapalı):
  kimliği aynı, yalnız burs/dil eki farklı satırlarda isim API'den.
- gecmis-doldur: onarılmış satırda devir öncesi yılları hiçbir kaynaktan
  doldurmaz (yoksa API'nin kod bazlı …N alanları eski programı geri yazar).
- veri-kalite: --ham ile ZORUNLU kimlik kontrolü (0 uyumsuz beklenir);
  --onceki karşılaştırmasında onarılanlar ayrı doğrulanır.
- docs/veri/yillik-guncelleme.md: kimlik kilidi adımı.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:24:29 +03:00

763 lines
28 KiB
TypeScript
Raw Permalink 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 SON yerleştirme yılının verisini çekip DB'ye işler.
*
* Endpoint: POST https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search
*
* API'nin yıl düzeni (Eylül 2026'da doğrulandı): EKSİZ alanlar (basariSirasi,
* minPuan, kontenjan, gkY…) en son yerleştirme yılıdır; `…1` bir önceki,
* `…2` iki önceki, `…3` üç önceki yıldır. Kayıttaki `yil` alanı GÜVENİLMEZ
* (kılavuz yılı / veri yılı anlamı yıl içinde değişiyor) — bu betik yılı
* HİÇBİR ZAMAN oradan türetmez. Yıl `--yil` ile verilir ve YIL KİLİDİ ile
* DB'ye karşı doğrulanır.
*
* Güvenlik sırası:
* 1. `--yil` = SON_YIL, `--db` zorunlu (varsayılan yok).
* 2. YIL KİLİDİ: ilk sayfadaki `basariSirasi1` değerleri DB'deki
* sira<yil-1> ile ≥%95 eşleşmeli VE eksiz `basariSirasi` ondan farklı
* olmalı. Değilse API henüz yeni yıla dönmemiştir (ya da bir yıl daha
* ilerlemiştir) → HİÇBİR ŞEY yazılmadan durur.
* 3. Tüm sayfalar önce belleğe + ham dosyaya alınır; benzersiz kod sayısı =
* `totalElements` değilse (sayfalama kayması) durur.
* 4. DB yazımı TEK transaction; önceki yılların kolon sağlaması transaction
* içinde karşılaştırılır, fark varsa geri alınır.
*
* 5. KİMLİK KİLİDİ: mevcut satırın puan türü / önlisans bayrağı / baz ad
* slug'ı API kaydıyla uyuşmuyorsa kılavuz kodu BAŞKA programa devredilmiştir
* (bkz. kimlik.ts). Liste basılır ve HİÇBİR ŞEY yazılmadan durulur;
* liste gözle incelendikten sonra `--kimlik-onar` ile yeniden koşulur.
*
* Yazdıkları: sira/puan/kontenjan/yerlesen<yil>, son_kilavuz_yili, eski_kod.
* Mevcut satırların ÖNCEKİ yıl kolonlarına dokunmaz (onları yalnız NULL ise
* gecmis-doldur.ts doldurur). Yeni program eklerken `…1/…2/…3` alanlarından
* önceki üç yılı da yazar. API "veri yok" için 0 döndürür → NULL.
*
* `--kimlik-onar`: kimliği kaymış satırlarda isim/tur/onlisans/fakulte/sure
* API'den yazılır; devirden ÖNCEKİ yılların tüm kolonları NULL'lanır (o geçmiş
* başka programa ait), eski kimlik + geçmişi `programs_arsiv`'e kopyalanır.
* `--isim-esitle`: kimliği AYNI olup yalnız adı farklı satırlarda (API burs/dil
* eki veriyor: "Tıp (İngilizce) (Burslu)") isim API'den yazılır. Varsayılan
* KAPALI: isim, sihirbaz kategorilerini ve bölüm sayfası başlığını besler.
*
* Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı.
* `--ham <dosya>` verilirse API'ye HİÇ gidilmez; daha önce kaydedilmiş ham
* dosyadan aynı işlem tekrarlanır (kopyada deneme → gerçek DB'ye uygulama).
*
* Kullanım:
* pnpm refresh --yil 2026 --db /tmp/deneme.db
* pnpm refresh --yil 2026 --db data/yokatlas.db --ham data/ham/2026-2026-09-21.jsonl.gz
*/
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import zlib from "node:zlib";
import { ILK_YIL } from "../src/lib/veri-yillari";
import { efektifIndeksiKur } from "./db-index";
import {
apiOnlisans,
devirYiliBul,
kimlikFarklari,
kimlikOnar,
netTurleriGetir,
type DbKimlik,
type KimlikFarki,
type OnarimKaydi,
} from "./kimlik";
import { uniAdiNormalize, uniturOnar } from "./unitur-onar";
import {
argOku,
bayrakVar,
calistir,
dbAc,
dbArgumani,
dur,
metaYaz,
yilArgumani,
yilKolonlariniGarantiEt,
yokatlasGoc,
type Db,
} from "./yokatlas-goc";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500;
const DELAY_MS = 500;
const KILIT_ESIK = 0.95;
const KILIT_ASGARI_ORNEK = 50;
export const TUR_MAP: Record<string, string> = {
SAY: "SAYISAL",
EA: "EŞİT AĞIRLIK",
SÖZ: "SÖZEL",
DİL: "DİL",
TYT: "TYT",
};
/** API kaydından betiklerin kullandığı alanlar (ham dosyada kaydın tamamı durur). */
export type KilavuzKaydi = {
kilavuzKodu: number;
eskiKilavuzKodu?: number | null;
birimAdi: string;
universiteAdi: string;
universiteTuru?: string | null;
ilAdi?: string | null;
fymkAdi?: string | null;
puanTuru: string;
ogrenimSuresi?: number | null;
birimTuruId: number;
basariSirasi?: number | string | null;
minPuan?: number | string | null;
kontenjan?: number | null;
gkY?: number | null;
sgyY?: number | null;
dprmY?: number | null;
obkY?: number | null;
basariSirasi1?: number | string | null;
basariSirasi2?: number | string | null;
basariSirasi3?: number | string | null;
minPuan1?: number | string | null;
minPuan2?: number | string | null;
minPuan3?: number | string | null;
gk1?: number | null;
gk2?: number | null;
gk3?: number | null;
};
const TUTULAN_ALANLAR: (keyof KilavuzKaydi)[] = [
"kilavuzKodu",
"eskiKilavuzKodu",
"birimAdi",
"universiteAdi",
"universiteTuru",
"ilAdi",
"fymkAdi",
"puanTuru",
"ogrenimSuresi",
"birimTuruId",
"basariSirasi",
"minPuan",
"kontenjan",
"gkY",
"sgyY",
"dprmY",
"obkY",
"basariSirasi1",
"basariSirasi2",
"basariSirasi3",
"minPuan1",
"minPuan2",
"minPuan3",
"gk1",
"gk2",
"gk3",
];
function inceKayit(ham: Record<string, unknown>): KilavuzKaydi {
const ince: Record<string, unknown> = {};
for (const alan of TUTULAN_ALANLAR) {
if (ham[alan] !== undefined) ince[alan] = ham[alan];
}
return ince as KilavuzKaydi;
}
type ApiPage = {
content: Record<string, unknown>[];
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). */
export 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;
}
export function realOrNull(v: unknown): number | null {
const n = typeof v === "string" ? Number.parseFloat(v) : (v as number);
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : null;
}
function yerlesen(r: KilavuzKaydi): number | null {
const parts = [r.gkY, r.sgyY, r.dprmY, r.obkY].filter(
(x): x is number => typeof x === "number"
);
return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
}
/**
* API `eskiKilavuzKodu` iki anlamda geliyor: gerçek eski program kodu (9 hane)
* ya da yeni açılan programda üniversite öneki (4 hane, ör. 2031). Yalnız
* gerçek program kodu biçimindeki ve yeni koddan farklı olanı döndürür.
*/
export function eskiKod(r: KilavuzKaydi): string | null {
if (!r.eskiKilavuzKodu) return null;
const eski = String(r.eskiKilavuzKodu);
const yeni = String(r.kilavuzKodu);
return eski.length === yeni.length && eski !== yeni ? eski : null;
}
/** Yeni programda önceki üç yılın API karşılığı: [yıl farkı, sıra, puan, genel kontenjan]. */
export function gecmisAlanlari(
r: KilavuzKaydi
): { fark: number; sira: number | null; puan: number | null; kontenjan: number | null }[] {
return [
{ fark: 1, sira: intOrNull(r.basariSirasi1), puan: realOrNull(r.minPuan1), kontenjan: intOrNull(r.gk1) },
{ fark: 2, sira: intOrNull(r.basariSirasi2), puan: realOrNull(r.minPuan2), kontenjan: intOrNull(r.gk2) },
{ fark: 3, sira: intOrNull(r.basariSirasi3), puan: realOrNull(r.minPuan3), kontenjan: intOrNull(r.gk3) },
];
}
// ── Ham dosya ────────────────────────────────────────────────────────────
/** `--ham` verilmemişse o yılın en yeni ham dosyası (ad sırası = tarih sırası). */
export function sonHamDosya(yil: number, dizin: string): string | null {
if (!fs.existsSync(dizin)) return null;
const adaylar = fs
.readdirSync(dizin)
.filter((f) => f.startsWith(`${yil}-`) && f.endsWith(".jsonl.gz"))
.sort();
return adaylar.length ? path.join(dizin, adaylar[adaylar.length - 1]) : null;
}
export type HamOkuma = { toplam: number; kayitlar: KilavuzKaydi[] };
/** Ham .jsonl.gz → ince kayıtlar. İlk satır `_meta` (yıl doğrulanır). */
export async function hamOku(dosya: string, yil: number): Promise<HamOkuma> {
if (!fs.existsSync(dosya)) dur(`Ham dosya yok: ${dosya}`);
const rl = readline.createInterface({
input: fs.createReadStream(dosya).pipe(zlib.createGunzip()),
crlfDelay: Infinity,
});
let toplam = -1;
const kayitlar: KilavuzKaydi[] = [];
for await (const satir of rl) {
if (!satir) continue;
const obj = JSON.parse(satir) as Record<string, unknown>;
if (obj._meta) {
const meta = obj._meta as { yil: number; totalElements: number };
if (meta.yil !== yil) {
dur(`Ham dosya ${meta.yil} yılı için çekilmiş, --yil ${yil} verildi.`);
}
toplam = meta.totalElements;
continue;
}
kayitlar.push(inceKayit(obj));
}
if (toplam < 0) dur(`Ham dosyada _meta satırı yok: ${dosya}`);
return { toplam, kayitlar };
}
// ── Yıl kilidi ───────────────────────────────────────────────────────────
/**
* `basariSirasi1` = DB sira<yil-1> (≥%95) VE eksiz `basariSirasi` ≠ DB
* sira<yil-1>. İkisi birlikte "eksiz alanlar gerçekten <yil> verisi" demektir.
*/
export function yilKilidi(db: Db, yil: number, ornek: KilavuzKaydi[]): void {
const onceki = yil - 1;
const bul = db.prepare(`SELECT sira${onceki} AS s FROM programs WHERE id = ?`);
let kiyas1 = 0;
let esit1 = 0;
let kiyas0 = 0;
let esit0 = 0;
for (const r of ornek) {
if (!r.kilavuzKodu) continue;
let satir = bul.get(String(r.kilavuzKodu)) as { s: number | null } | undefined;
const eski = eskiKod(r);
if (!satir && eski) satir = bul.get(eski) as typeof satir;
if (!satir || satir.s == null) continue;
const s1 = intOrNull(r.basariSirasi1);
if (s1 != null) {
kiyas1++;
if (s1 === satir.s) esit1++;
}
const s0 = intOrNull(r.basariSirasi);
if (s0 != null) {
kiyas0++;
if (s0 === satir.s) esit0++;
}
}
const oran1 = kiyas1 ? esit1 / kiyas1 : 0;
const oran0 = kiyas0 ? esit0 / kiyas0 : 0;
console.log(
`Yıl kilidi: basariSirasi1 = sira${onceki} → ${esit1}/${kiyas1} (%${(oran1 * 100).toFixed(1)}); ` +
`eksiz basariSirasi = sira${onceki} → ${esit0}/${kiyas0} (%${(oran0 * 100).toFixed(1)})`
);
if (kiyas1 < KILIT_ASGARI_ORNEK) {
dur(
`Yıl kilidi için yeterli karşılaştırma yok (${kiyas1} < ${KILIT_ASGARI_ORNEK}). ` +
`DB'de sira${onceki} dolu mu, doğru DB mi?`
);
}
if (oran0 >= 0.5) {
dur(
`API'nin eksiz alanları hâlâ ${onceki} verisi (DB ile %${(oran0 * 100).toFixed(1)} aynı). ` +
`${yil} yerleştirme sonuçları henüz yayımlanmamış; bugün koşmak ${yil} kolonuna ${onceki} verisi yazar.`
);
}
if (oran1 < KILIT_ESIK) {
dur(
`API'nin "…1" alanları DB'deki ${onceki} verisiyle eşleşmiyor (%${(oran1 * 100).toFixed(1)} < %95). ` +
`API yıl düzeni değişmiş olabilir; --yil ${yil} ile yazmak yanlış kolonu doldurur.`
);
}
}
// ── Ana akış ─────────────────────────────────────────────────────────────
async function apidenCek(
yil: number,
hamDizin: string,
ilkSayfaKontrol: (ornek: KilavuzKaydi[]) => void
): Promise<HamOkuma & { hamDosya: string }> {
fs.mkdirSync(hamDizin, { recursive: true });
const tarih = new Date().toISOString().slice(0, 10);
const hamDosya = path.join(hamDizin, `${yil}-${tarih}.jsonl.gz`);
const gecici = `${hamDosya}.tmp`;
const first = await fetchPage(0);
const kayitlar = first.content.map(inceKayit);
// Kilit tutmazsa hiçbir şey yazılmadan ve kaynağa 1 istekle durulur.
ilkSayfaKontrol(kayitlar);
console.log(
`Toplam ${first.totalElements} kayıt, ${first.totalPages} sayfa çekilecek ` +
`(~${Math.ceil((first.totalPages * (DELAY_MS + 1500)) / 1000)} sn)…`
);
const gzip = zlib.createGzip();
const cikti = fs.createWriteStream(gecici);
gzip.pipe(cikti);
const bitti = new Promise<void>((resolve, reject) => {
cikti.on("finish", resolve);
cikti.on("error", reject);
gzip.on("error", reject);
});
const yaz = (obj: unknown) => gzip.write(`${JSON.stringify(obj)}\n`);
yaz({
_meta: {
yil,
cekimTarihi: new Date().toISOString(),
kaynak: API,
totalElements: first.totalElements,
totalPages: first.totalPages,
sayfaBoyu: PAGE_SIZE,
},
});
for (const r of first.content) yaz(r);
for (let p = 1; p < first.totalPages; p++) {
await sleep(DELAY_MS);
const page = await fetchPage(p);
for (const r of page.content) {
yaz(r);
kayitlar.push(inceKayit(r));
}
process.stdout.write(`\rsayfa ${p + 1}/${first.totalPages}`);
}
console.log();
gzip.end();
await bitti;
fs.renameSync(gecici, hamDosya);
console.log(`Ham veri: ${hamDosya}`);
return { toplam: first.totalElements, kayitlar, hamDosya };
}
/** Önceki yıl kolonlarının sağlaması; `haricIdler` (kimliği onarılanlar) dışarıda. */
function oncekiYilSaglamasi(
db: Db,
yil: number,
azamiRowid: number,
haricIdler: string[]
): string {
const kolonlar: string[] = [];
for (let y = ILK_YIL; y < yil; y++) {
for (const olcu of ["sira", "puan", "kontenjan", "yerlesen"]) {
kolonlar.push(`COUNT(${olcu}${y})`, `TOTAL(${olcu}${y})`);
}
}
const r = db
.prepare(
`SELECT COUNT(*), ${kolonlar.join(", ")} FROM programs
WHERE rowid <= ? AND id NOT IN (SELECT value FROM json_each(?))`
)
.raw()
.get(azamiRowid, JSON.stringify(haricIdler)) as number[];
return r.join("|");
}
async function main() {
const yil = yilArgumani();
const dbYolu = dbArgumani();
const hamArg = argOku("ham");
const hamDizin = path.resolve(process.cwd(), argOku("ham-dizin") ?? path.join("data", "ham"));
const db = dbAc(dbYolu);
console.log(`DB: ${dbYolu}\nYıl: ${yil}`);
// Şema (idempotent). Yeni programlarda …1/…2/…3 yazılacağı için önceki üç
// yılın kolonları da garanti edilir (normalde zaten vardır).
const eklenen = yokatlasGoc(db, yil);
for (let fark = 1; fark <= 3; fark++) {
if (yil - fark >= ILK_YIL) eklenen.push(...yilKolonlariniGarantiEt(db, yil - fark));
}
if (eklenen.length) console.log(`Göç: ${eklenen.join(", ")}`);
// ── Veri: API ya da ham dosya ──────────────────────────────────────────
let toplam: number;
let kayitlar: KilavuzKaydi[];
let hamDosya: string;
if (hamArg) {
hamDosya = path.resolve(process.cwd(), hamArg);
console.log(`Kaynak: ham dosya (API'ye gidilmiyor) → ${hamDosya}`);
({ toplam, kayitlar } = await hamOku(hamDosya, yil));
yilKilidi(db, yil, kayitlar.slice(0, PAGE_SIZE));
} else {
({ toplam, kayitlar, hamDosya } = await apidenCek(yil, hamDizin, (ornek) =>
yilKilidi(db, yil, ornek)
));
}
// ── Bütünlük: benzersiz kod = totalElements ────────────────────────────
const benzersiz = new Map<string, KilavuzKaydi>();
let kodsuz = 0;
for (const r of kayitlar) {
if (!r.kilavuzKodu) {
kodsuz++;
continue;
}
benzersiz.set(String(r.kilavuzKodu), r);
}
console.log(
`Çekilen: ${kayitlar.length} kayıt, benzersiz kod: ${benzersiz.size}, kodsuz: ${kodsuz}, API toplamı: ${toplam}`
);
if (benzersiz.size + kodsuz !== toplam || kayitlar.length !== toplam) {
dur(
`Benzersiz kod sayısı (${benzersiz.size}) + kodsuz (${kodsuz}) ≠ API toplamı (${toplam}). ` +
"Sayfalama sırasında kayıt kaymış/yinelenmiş; DB'ye HİÇBİR ŞEY yazılmadı. Tekrar dene."
);
}
// ── Yazım: tek transaction ─────────────────────────────────────────────
const update = db.prepare(`
UPDATE programs
SET sira${yil} = ?, puan${yil} = ?, kontenjan${yil} = ?, yerlesen${yil} = ?,
son_kilavuz_yili = ?, eski_kod = COALESCE(?, eski_kod)
WHERE id = ?
`);
const gecmisYillar = [1, 2, 3].filter((f) => yil - f >= ILK_YIL);
const insert = db.prepare(`
INSERT INTO programs (id, isim, universite, unitur, il, fakulte, tur, sure, onlisans,
sira${yil}, puan${yil}, kontenjan${yil}, yerlesen${yil},
son_kilavuz_yili, eski_kod
${gecmisYillar.map((f) => `, sira${yil - f}, puan${yil - f}, kontenjan${yil - f}`).join("")})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${gecmisYillar.map(() => ", ?, ?, ?").join("")})
`);
const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
// Yeni satırda yazım/sözlük tutarlılığı — TAHMİN YOK, yalnız DB'de zaten var
// olan yazımı yeniden kullanma:
// - unitur: API sözlüğü (DEVLET/VAKIF/KKTC/YURTDIŞI) DB sözlüğünden (VAKIF MYO,
// YURTDISI KAMU…) farklı. Aynı üniversitenin mevcut satırlarında TEK bir
// unitur varsa o kullanılır; yoksa API değeri olduğu gibi yazılır. Aksi
// hâlde aynı kurumda iki unitur oluşur ve uniturOnar haklı olarak durur.
// - fakulte: API 2026'da BÜYÜK HARF veriyor; aynı üniversitede büyük harfe
// çevrilince aynı olan mevcut yazım varsa o kullanılır.
const bazAd = (ad: string) => ad.replace(/\s*\([^)]*\)\s*$/, "").trim();
const uniturlar = new Map<string, Set<string>>();
const fakulteler = new Map<string, Map<string, string>>();
for (const s of db
.prepare("SELECT DISTINCT universite, unitur, fakulte FROM programs")
.all() as { universite: string; unitur: string | null; fakulte: string | null }[]) {
const baz = bazAd(s.universite);
if (s.unitur) {
for (const k of [s.universite, baz]) {
const set = uniturlar.get(k) ?? new Set<string>();
set.add(s.unitur);
uniturlar.set(k, set);
}
}
if (s.fakulte) {
const m = fakulteler.get(baz) ?? new Map<string, string>();
const anahtar = s.fakulte.toLocaleUpperCase("tr-TR");
// Aynı anahtara iki farklı yazım düşerse belirsiz → yeniden kullanma
m.set(anahtar, m.has(anahtar) && m.get(anahtar) !== s.fakulte ? "" : s.fakulte);
fakulteler.set(baz, m);
}
}
const uniturSec = (universite: string, api: string | null): string | null => {
for (const k of [universite, bazAd(universite)]) {
const set = uniturlar.get(k);
if (set?.size === 1) return [...set][0];
}
return api;
};
const fakulteSec = (universite: string, api: string | null): string | null => {
if (!api) return null;
const mevcut = fakulteler.get(bazAd(universite))?.get(api.toLocaleUpperCase("tr-TR"));
return mevcut || api;
};
// ── Kimlik kilidi: kod aynı, program aynı mı? ──────────────────────────
const kimlikOnarIste = bayrakVar("kimlik-onar");
const isimEsitleIste = bayrakVar("isim-esitle");
const kimlikGetir = db.prepare("SELECT * FROM programs WHERE id = ?");
type Uyumsuz = {
id: string;
r: KilavuzKaydi;
satir: DbKimlik & Record<string, unknown>;
farklar: KimlikFarki[];
apiTur: string | undefined;
devirYili: number;
};
const uyumsuzlar: Uyumsuz[] = [];
const ekFarkliIdler: string[] = [];
for (const [id, r] of benzersiz) {
const satir = kimlikGetir.get(id) as Uyumsuz["satir"] | undefined;
if (!satir) continue;
const apiTur = TUR_MAP[r.puanTuru?.trim() ?? ""];
const farklar = kimlikFarklari(satir, r, apiTur);
if (farklar.length) {
uyumsuzlar.push({
id,
r,
satir,
farklar,
apiTur,
devirYili: devirYiliBul(
satir,
{
gecmis: gecmisAlanlari(r),
netTurleri: netTurleriGetir(db, id, TUR_MAP),
eskiTur: satir.tur,
yeniTur: apiTur ?? satir.tur,
farklar,
},
yil
),
});
} else if (satir.isim !== r.birimAdi) {
ekFarkliIdler.push(id);
}
}
if (uyumsuzlar.length) {
console.log(`\nKİMLİĞİ KAYMIŞ ${uyumsuzlar.length} satır (kod aynı, program farklı):`);
for (const u of uyumsuzlar) {
const gecmis = [];
for (let y = ILK_YIL; y <= yil; y++) gecmis.push(u.satir[`sira${y}`] ?? "-");
const netOzet = [...netTurleriGetir(db, u.id, TUR_MAP)]
.sort((a, b) => a[0] - b[0])
.map(([y, t]) => `${y}=${t}`)
.join(" ");
console.log(
` ${u.id} [${u.farklar.join("+")}] ${u.satir.universite}\n` +
` DB : ${u.satir.isim} | ${u.satir.tur} | onlisans=${u.satir.onlisans} | ${u.satir.fakulte}\n` +
` API: ${u.r.birimAdi} | ${u.apiTur ?? `?(${u.r.puanTuru})`} | onlisans=${apiOnlisans(u.r)} | ${u.r.fymkAdi}\n` +
` sira ${ILK_YIL}…${yil}: ${gecmis.join(", ")} | net türleri: ${netOzet || "-"} → ` +
(u.devirYili === ILK_YIL
? "AD DEĞİŞİMİ (API penceresinin tamamı aynı program; geçmiş korunur)"
: `devir yılı ${u.devirYili} (öncesi silinir)`)
);
}
const tursuz = uyumsuzlar.filter((u) => !u.apiTur);
if (!kimlikOnarIste) {
dur(
`${uyumsuzlar.length} satırın kimliği API ile uyuşmuyor; DB'ye HİÇBİR ŞEY yazılmadı. ` +
"Listeyi incele; devirse aynı komutu --kimlik-onar ile yeniden koş " +
"(isim/tur/onlisans/fakulte/sure API'den yazılır, devir öncesi yıllar NULL'lanır, eski kimlik arşive kopyalanır)."
);
}
if (tursuz.length) {
dur(
`${tursuz.length} uyumsuz satırda API puan türü vermiyor (${tursuz.map((u) => u.id).join(", ")}); ` +
"tür UYDURULMAZ, onarım yapılamaz."
);
}
}
console.log(
`Kimlik: ${uyumsuzlar.length} uyumsuz; yalnız adı (burs/dil eki) farklı ${ekFarkliIdler.length} satır` +
(isimEsitleIste ? " → isim API'den yazılacak (--isim-esitle)" : " → isim yazılmıyor (--isim-esitle verilmedi)")
);
let updated = 0;
let inserted = 0;
const atlananlar: string[] = [];
const onarimlar: { id: string; universite: string; kayit: OnarimKaydi }[] = [];
let isimEsitlenen = 0;
db.transaction(() => {
const azamiRowid = (
db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM programs").get() as { m: number }
).m;
const onarilanIdler = uyumsuzlar.map((u) => u.id);
const once = oncekiYilSaglamasi(db, yil, azamiRowid, onarilanIdler);
// Kimlik onarımı yıl yazımından ÖNCE: arşiv kopyası eski hâliyle alınır.
for (const u of uyumsuzlar) {
const universite = String(u.satir.universite);
const kayit = kimlikOnar(db, {
id: u.id,
yil,
devirYili: u.devirYili,
yeni: {
isim: u.r.birimAdi,
tur: u.apiTur as string,
onlisans: apiOnlisans(u.r),
fakulte: fakulteSec(universite, u.r.fymkAdi ?? null),
sure: u.r.ogrenimSuresi ?? null,
},
});
onarimlar.push({ id: u.id, universite, kayit });
}
if (isimEsitleIste) {
const isimYaz = db.prepare("UPDATE programs SET isim = ? WHERE id = ?");
for (const id of ekFarkliIdler) {
isimYaz.run((benzersiz.get(id) as KilavuzKaydi).birimAdi, id);
isimEsitlenen++;
}
}
for (const [id, r] of benzersiz) {
const tur = TUR_MAP[r.puanTuru?.trim() ?? ""];
// Puan türü yalnız YENİ satır için gerekir; mevcut satırın türü DB'de var.
// Kaynak türü vermiyorsa yeni satır UYDURULMAZ, atlanır ve raporlanır.
if (!tur && !exists.get(id)) {
atlananlar.push(`${id} ${r.universiteAdi} / ${r.birimAdi} (puanTuru="${r.puanTuru}")`);
continue;
}
const vals = [
intOrNull(r.basariSirasi),
realOrNull(r.minPuan),
intOrNull(r.kontenjan),
yerlesen(r),
];
const eski = eskiKod(r);
if (exists.get(id)) {
update.run(...vals, yil, eski, id);
updated++;
} else {
const gecmis = gecmisAlanlari(r).filter((g) => yil - g.fark >= ILK_YIL);
// "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
// duplikat kurum adı üretmesin
const universite = uniAdiNormalize(r.universiteAdi);
insert.run(
id,
r.birimAdi,
universite,
uniturSec(universite, r.universiteTuru ?? null),
r.ilAdi ?? null,
fakulteSec(universite, r.fymkAdi ?? null),
tur,
r.ogrenimSuresi ?? null,
r.birimTuruId === 47 ? 1 : 0,
...vals,
yil,
eski,
...gecmis.flatMap((g) => [g.sira, g.puan, g.kontenjan])
);
inserted++;
}
}
const sonra = oncekiYilSaglamasi(db, yil, azamiRowid, onarilanIdler);
if (once !== sonra) {
dur(
"Kimliği onarılanlar DIŞINDAKİ satırların önceki yıl kolonları değişti — bu betik onlara dokunmamalı. Geri alındı.\n" +
`önce : ${once}\nsonra: ${sonra}`
);
}
const gorulen = (
db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
).n;
const beklenen = toplam - kodsuz - atlananlar.length;
if (gorulen !== beklenen) {
dur(
`son_kilavuz_yili=${yil} satır sayısı ${gorulen}, beklenen ${beklenen} ` +
`(API ${toplam} − atlanan ${kodsuz + atlananlar.length}). Geri alındı.`
);
}
metaYaz(db, `refresh_${yil}_api_toplam`, toplam);
metaYaz(db, `refresh_${yil}_atlanan`, kodsuz + atlananlar.length);
metaYaz(db, `refresh_${yil}_tarih`, new Date().toISOString());
metaYaz(db, `refresh_${yil}_ham`, path.basename(hamDosya));
})();
console.log(
`Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${kodsuz + atlananlar.length}`
);
for (const a of atlananlar) console.log(` atlandı: ${a}`);
if (isimEsitlenen) console.log(`İsmi API'den eşitlenen (yalnız ek farkı): ${isimEsitlenen}`);
if (onarimlar.length) {
console.log(`\nKİMLİĞİ ONARILAN ${onarimlar.length} satır:`);
console.log(" id | üniversite | eski ad [tür/önlisans] → yeni ad [tür/önlisans] | devir | NULL'lanan yıllar | arşiv | taşınan net yılları");
for (const o of onarimlar) {
const k = o.kayit;
console.log(
` ${o.id} | ${o.universite} | ${k.eski.isim} [${k.eski.tur}/${k.eski.onlisans}] → ` +
`${k.yeni.isim} [${k.yeni.tur}/${k.yeni.onlisans}] | ${k.devirYili} | ` +
`${k.silinenYillar.join(",") || "-"} | ${k.arsivId ?? "-"} | ${k.tasinanNetYillari.join(",") || "-"}`
);
}
metaYaz(db, `kimlik_onar_${yil}_satir`, onarimlar.length);
}
// API'nin universiteTuru döndürmediği kayıtlar devlet/vakıf filtrelerinden
// düşmesin: her tazelemeden sonra unitur onarımı koşulur (idempotent).
db.transaction(() => uniturOnar(db))();
// Sorgu yolunun ifade indeksi — ifade veri-yillari.ts'ten; eski yılınki DROP.
console.log(`Efektif indeks: ${efektifIndeksiKur(db)}`);
const stats = db
.prepare(
`SELECT COUNT(*) AS toplam,
SUM(son_kilavuz_yili = ${yil}) AS guncel,
COUNT(sira${yil}) AS sirali
FROM programs`
)
.get() as { toplam: number; guncel: number; sirali: number };
console.log(
`DB toplam: ${stats.toplam} program; ${yil} kılavuzunda: ${stats.guncel}; ` +
`${yil} sıralaması olan: ${stats.sirali}`
);
db.pragma("wal_checkpoint(TRUNCATE)");
db.close();
console.log(
`\nSıradaki adım: pnpm gecmis-doldur --yil ${yil} --db <db> --ham ${path.relative(process.cwd(), hamDosya)}`
);
}
if (process.argv[1]?.endsWith("refresh.ts")) calistir(main);