Files
kolaytercih/src/lib/db.ts

287 lines
9.3 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.
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
export type Program = {
id: string;
isim: string;
universite: string;
unitur: string | null;
il: string | null;
fakulte: string | null;
tur: string;
sure: number | null;
sira2025: number | null;
sira2024: number | null;
sira2023: number | null;
sira2022: number | null;
sira2021: number | null;
puan2025: number | null;
kontenjan2025: number | null;
yerlesen2025: number | null;
};
export const PUAN_TURLERI = {
say: "SAYISAL",
ea: "EŞİT AĞIRLIK",
soz: "SÖZEL",
dil: "DİL",
tyt: "TYT",
} as const;
export type PuanTuruKey = keyof typeof PUAN_TURLERI;
export type DilimKey = "hayal" | "dengeli" | "garanti";
export type RankResults = {
hayal: Program[];
dengeli: Program[];
garanti: Program[];
/** Dilim başına pencereye düşen TOPLAM program sayısı (limit'ten bağımsız) */
toplam: Record<DilimKey, number>;
};
// Dev'de hot-reload başına yeni bağlantıılmasın diye global cache
const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database };
export function getDb(): Database.Database {
if (!globalForDb.yokatlasDb) {
globalForDb.yokatlasDb = new Database(
path.join(process.cwd(), "data", "yokatlas.db"),
{ readonly: true, fileMustExist: true }
);
}
return globalForDb.yokatlasDb;
}
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
sira2025, sira2024, sira2023, sira2022, sira2021,
puan2025, kontenjan2025, yerlesen2025`;
// 2025 sıralaması yoksa (az yerleşen/yeni program) 2024'e düşer
export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
/**
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
* Sıralamada küçük sayı daha iyidir; program taban sıralaması kullanıcının
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
* demektir (hayal), büyükse daha güvenli demektir (garanti).
*/
export type UniturGrubu = "devlet" | "vakif";
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
if (grup === "vakif")
return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] };
return { sql: "", args: [] };
}
/**
* Dilim sınırları. Pencereler çarpan bazlı DEĞİL, açık uçlu: hayal adayın
* üstündeki her şeyi, garanti 1.4× eşiğinin altındaki her şeyi kapsar.
* "Yakından uzağa" sıralama + limit/offset sayesinde uçtaki gerçekçi olmayan
* programlar ancak sayfalandıkça görünür; eski ×0.5/×3 pencereleri küçük
* sıralamalarda (ör. 87) hiç sonuç bırakmıyordu.
*/
export function dilimAraligi(
sira: number,
dilim: DilimKey
): { alt: number; ust: number | null } {
const dengeliUst = Math.round(sira * 1.4);
if (dilim === "hayal") return { alt: 1, ust: sira - 1 };
if (dilim === "dengeli") return { alt: sira, ust: dengeliUst };
return { alt: dengeliUst + 1, ust: null };
}
/**
* Tek dilimi "yakından uzağa" sıralı ve sayfalı getirir: hayal adayın
* sıralamasından yukarı (taban DESC), dengeli/garanti aşağı (taban ASC).
* toplam, limit'ten bağımsız olarak dilimdeki tüm eşleşme sayısıdır.
*/
export function dilimAra(
sira: number,
turKey: PuanTuruKey,
dilim: DilimKey,
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
limit?: number;
offset?: number;
} = {}
): { programlar: Program[]; toplam: number } {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const limit = opts.limit ?? 12;
const offset = opts.offset ?? 0;
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
const illerListe = [
...(opts.il ? [opts.il] : []),
...(opts.iller ?? []),
].filter((v, i, a) => v && a.indexOf(v) === i);
const ilFiltre =
illerListe.length > 0
? `AND il IN (${illerListe.map(() => "?").join(",")})`
: "";
const unitur = uniturFiltreSql(opts.uniturGrubu);
const { alt, ust } = dilimAraligi(sira, dilim);
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
const siraKosulu =
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
const siraArgs = ust != null ? [alt, ust] : [alt];
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...siraArgs];
const base = `FROM programs
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql}
AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${siraKosulu}`;
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
const yon = dilim === "hayal" ? "DESC" : "ASC";
const programlar = db
.prepare(
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
)
.all(...args, limit, offset) as Program[];
const toplam = (
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
adet: number;
}
).adet;
return { programlar, toplam };
}
export function searchByRank(
sira: number,
turKey: PuanTuruKey,
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
limitPerBucket?: number;
} = {}
): RankResults {
const ortak = {
il: opts.il,
iller: opts.iller,
uniturGrubu: opts.uniturGrubu,
limit: opts.limitPerBucket ?? 12,
};
const hayal = dilimAra(sira, turKey, "hayal", ortak);
const dengeli = dilimAra(sira, turKey, "dengeli", ortak);
const garanti = dilimAra(sira, turKey, "garanti", ortak);
return {
hayal: hayal.programlar,
dengeli: dengeli.programlar,
garanti: garanti.programlar,
toplam: {
hayal: hayal.toplam,
dengeli: dengeli.toplam,
garanti: garanti.toplam,
},
};
}
export type SihirbazFacetleri = {
kategoriler: { ad: string; adet: number }[];
iller: { il: string; adet: number }[];
uniturler: { grup: UniturGrubu; adet: number }[];
/** Penceredeki (filtre uygulanmamış) toplam program sayısı */
toplam: number;
};
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için
// gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık
// sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı
// görmez; 5B sıradaki görür.
//
// Kademeli (cascade) filtre: adımlar birbirini süzer. Kategoriler yalnızca
// pencereden; iller seçili kategorilerden; üniversite tipi kategori+il'den
// hesaplanır. Böylece "Mühendislik + İzmir" seçen aday 3. adımda yalnızca
// İzmir'deki mühendislik programlarının devlet/vakıf dağılımını görür.
export function rankWindowFacets(
sira: number,
turKey: PuanTuruKey,
secim: { kategoriler?: string[]; iller?: string[] } = {}
): SihirbazFacetleri {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const altSinir = Math.round(sira * 0.5);
const satirlar = db
.prepare(
`SELECT isim, il, unitur FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} >= ?`
)
.all(tur, onlisans, altSinir) as {
isim: string;
il: string | null;
unitur: string | null;
}[];
// isim → kategori eşlemesi pahalı (12 regex); tekrar eden isimler için önbellek
const kategoriCache = new Map<string, string[]>();
const kategorileriGetir = (isim: string): string[] => {
let k = kategoriCache.get(isim);
if (!k) {
k = kategoriBul(isim);
kategoriCache.set(isim, k);
}
return k;
};
const seciliKategoriler = secim.kategoriler ?? [];
const kategoriUyar = (isim: string) =>
seciliKategoriler.length === 0 ||
kategorileriGetir(isim).some((k) => seciliKategoriler.includes(k));
const seciliIller = new Set(secim.iller ?? []);
const ilUyar = (il: string | null) =>
seciliIller.size === 0 || (il != null && seciliIller.has(il));
const kategoriSay = new Map<string, number>();
const ilSay = new Map<string, number>();
let devlet = 0;
let vakif = 0;
for (const { isim, il, unitur } of satirlar) {
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
for (const k of kategorileriGetir(isim)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
}
if (!kategoriUyar(isim)) continue;
// 2. adım: iller seçili kategorilerle süzülür
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
if (!ilUyar(il)) continue;
// 3. adım: üniversite tipi kategori + il ile süzülür
if (unitur === "DEVLET") devlet += 1;
else if (unitur) vakif += 1;
}
const kategoriler = KATEGORILER.map((ad) => ({
ad,
adet: kategoriSay.get(ad) ?? 0,
}))
.filter((k) => k.adet >= 3) // gürültüyü ele
.sort((a, b) => b.adet - a.adet);
const iller = [...ilSay.entries()]
.map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet)
.slice(0, 15);
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
return { kategoriler, iller, uniturler, toplam: satirlar.length };
}