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:
@@ -1,24 +1,66 @@
|
||||
/**
|
||||
* Canlı YÖK Atlas API'sinden 2025 yerleştirme verisini çekip DB'yi tazeler.
|
||||
* 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
|
||||
* (yokatlas-py'nin kullandığı resmî tercih-kılavuz JSON API'si)
|
||||
*
|
||||
* - Mevcut programlarda sira/puan/kontenjan/yerlesen 2025 kolonlarını günceller
|
||||
* - DB'de olmayan (yeni açılan) programları ekler
|
||||
* - Az yerleşeni olan programlarda API basariSirasi döndürmez → NULL kalır
|
||||
* 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.
|
||||
*
|
||||
* Kullanım: npx tsx scripts/refresh.ts
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 Database from "better-sqlite3";
|
||||
import readline from "node:readline";
|
||||
import zlib from "node:zlib";
|
||||
import { ILK_YIL } from "../src/lib/veri-yillari";
|
||||
import { efektifIndeksiKur } from "./db-index";
|
||||
import { uniAdiNormalize, uniturOnar } from "./unitur-onar";
|
||||
import {
|
||||
argOku,
|
||||
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 = 300;
|
||||
const DELAY_MS = 500;
|
||||
const KILIT_ESIK = 0.95;
|
||||
const KILIT_ASGARI_ORNEK = 50;
|
||||
|
||||
const TUR_MAP: Record<string, string> = {
|
||||
export const TUR_MAP: Record<string, string> = {
|
||||
SAY: "SAYISAL",
|
||||
EA: "EŞİT AĞIRLIK",
|
||||
SÖZ: "SÖZEL",
|
||||
@@ -26,27 +68,75 @@ const TUR_MAP: Record<string, string> = {
|
||||
TYT: "TYT",
|
||||
};
|
||||
|
||||
type ApiRecord = {
|
||||
/** 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;
|
||||
universiteTuru?: string | null;
|
||||
ilAdi?: string | null;
|
||||
fymkAdi?: string | null;
|
||||
puanTuru: string;
|
||||
ogrenimSuresi: number | null;
|
||||
ogrenimSuresi?: number | null;
|
||||
birimTuruId: number;
|
||||
basariSirasi?: number | null;
|
||||
minPuan?: number | null;
|
||||
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: ApiRecord[];
|
||||
content: Record<string, unknown>[];
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
};
|
||||
@@ -83,140 +173,435 @@ async function fetchPage(page: number): Promise<ApiPage> {
|
||||
return (await res.json()) as ApiPage;
|
||||
}
|
||||
|
||||
function yerlesen(r: ApiRecord): 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;
|
||||
}
|
||||
|
||||
function intOrNull(v: unknown): number | null {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
function realOrNull(v: unknown): number | 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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
|
||||
db.pragma("journal_mode = WAL");
|
||||
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;
|
||||
}
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(programs)").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
const has2025 = cols.some((c) => c.name === "sira2025");
|
||||
if (!has2025) {
|
||||
db.exec(`
|
||||
ALTER TABLE programs ADD COLUMN sira2025 INTEGER;
|
||||
ALTER TABLE programs ADD COLUMN puan2025 REAL;
|
||||
ALTER TABLE programs ADD COLUMN kontenjan2025 INTEGER;
|
||||
ALTER TABLE programs ADD COLUMN yerlesen2025 INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_programs_tur_sira25 ON programs (tur, sira2025);
|
||||
`);
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
function oncekiYilSaglamasi(db: Db, yil: number, azamiRowid: number): 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 <= ?`)
|
||||
.raw()
|
||||
.get(azamiRowid) 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)
|
||||
));
|
||||
}
|
||||
|
||||
// Sorgu yolunun kullandığı ifade indexi; ifade src/lib/db.ts EFEKTIF_SIRA
|
||||
// ile birebir aynı olmalı (bkz. scripts/db-index.ts). sira2025 kolonu
|
||||
// yukarıda garanti edildikten sonra kurulur.
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
|
||||
ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
|
||||
`);
|
||||
// ── 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 sira2025 = ?, puan2025 = ?, kontenjan2025 = ?, yerlesen2025 = ?
|
||||
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,
|
||||
sira2025, puan2025, kontenjan2025, yerlesen2025)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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;
|
||||
};
|
||||
|
||||
let updated = 0;
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
const atlananlar: string[] = [];
|
||||
|
||||
const first = await fetchPage(0);
|
||||
const totalPages = first.totalPages;
|
||||
console.log(
|
||||
`Toplam ${first.totalElements} kayıt, ${totalPages} sayfa çekilecek…`
|
||||
);
|
||||
db.transaction(() => {
|
||||
const azamiRowid = (
|
||||
db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM programs").get() as { m: number }
|
||||
).m;
|
||||
const once = oncekiYilSaglamasi(db, yil, azamiRowid);
|
||||
|
||||
const processPage = db.transaction((records: ApiRecord[]) => {
|
||||
for (const r of records) {
|
||||
for (const [id, r] of benzersiz) {
|
||||
const tur = TUR_MAP[r.puanTuru?.trim() ?? ""];
|
||||
if (!r.kilavuzKodu || !tur) {
|
||||
skipped++;
|
||||
// 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 id = String(r.kilavuzKodu);
|
||||
const vals = [
|
||||
intOrNull(r.basariSirasi),
|
||||
realOrNull(r.minPuan),
|
||||
intOrNull(r.kontenjan),
|
||||
yerlesen(r),
|
||||
];
|
||||
const eski = eskiKod(r);
|
||||
if (exists.get(id)) {
|
||||
update.run(...vals, 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,
|
||||
// "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
|
||||
// duplikat kurum adı üretmesin
|
||||
uniAdiNormalize(r.universiteAdi),
|
||||
r.universiteTuru ?? null,
|
||||
universite,
|
||||
uniturSec(universite, r.universiteTuru ?? null),
|
||||
r.ilAdi ?? null,
|
||||
r.fymkAdi ?? null,
|
||||
fakulteSec(universite, r.fymkAdi ?? null),
|
||||
tur,
|
||||
r.ogrenimSuresi ?? null,
|
||||
r.birimTuruId === 47 ? 1 : 0,
|
||||
...vals
|
||||
...vals,
|
||||
yil,
|
||||
eski,
|
||||
...gecmis.flatMap((g) => [g.sira, g.puan, g.kontenjan])
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
processPage(first.content);
|
||||
for (let p = 1; p < totalPages; p++) {
|
||||
await sleep(DELAY_MS);
|
||||
const page = await fetchPage(p);
|
||||
processPage(page.content);
|
||||
process.stdout.write(`\rsayfa ${p + 1}/${totalPages}`);
|
||||
}
|
||||
console.log();
|
||||
const sonra = oncekiYilSaglamasi(db, yil, azamiRowid);
|
||||
if (once !== sonra) {
|
||||
dur(
|
||||
"Mevcut 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));
|
||||
})();
|
||||
|
||||
const stats = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS toplam,
|
||||
SUM(CASE WHEN sira2025 IS NOT NULL THEN 1 ELSE 0 END) AS sirali
|
||||
FROM programs`
|
||||
)
|
||||
.get() as { toplam: number; sirali: number };
|
||||
console.log(
|
||||
`Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${skipped}`
|
||||
);
|
||||
console.log(
|
||||
`DB toplam: ${stats.toplam} program, 2025 sıralaması olan: ${stats.sirali}`
|
||||
`Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${kodsuz + atlananlar.length}`
|
||||
);
|
||||
for (const a of atlananlar) console.log(` atlandı: ${a}`);
|
||||
|
||||
// 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)}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1]?.endsWith("refresh.ts")) calistir(main);
|
||||
|
||||
Reference in New Issue
Block a user