Files
kolaytercih/src/lib/db.ts
bilalgursen 34c8f90e26 feat: alan eşlemesine fakülte sinyali + il adımında tüm iller
Sihirbazın alan çipleri artık YÖK Atlas'ın kendi verisiyle süzülüyor:
kategoriBul program adı kurallarını fakülte kurallarıyla kesiştiriyor
(Siber Güvenlik Müh. artık Hukuk değil, Siyaset Bilimi artık Fen değil).
Aşırı geniş isim regexleri sıkılaştırıldı (bilim/güvenlik/programcı/çevre
+ gastronomi'nin içindeki "astronomi" tuzağı) — sayısalda Hukuk & Siyasal,
sözelde Mühendislik çipi tamamen kalktı.

İl adımındaki .slice(0,15) kırpması kaldırıldı: penceresinde programı olan
TÜM iller listelenir; KKTC/yurtdışı kampüsler ayrı çip grubunda. Haritanın
15-limit yüzünden yanlış "program yok" grilemesi kendiliğinden düzeldi.

Öncelik çiplerine şeffaflık notu eklendi (listeyi daraltmaz, AI'ya yön
verir). /meraklisina şeması ve anlatımı aynı davranışla senkronlandı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 00:11:36 +03:00

273 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.
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
import {
PUAN_TURLERI,
type DilimKey,
type Program,
type ProgramNetSatiri,
type PuanTuruKey,
type RankResults,
type SihirbazFacetleri,
type UniturGrubu,
} from "@/types/yokatlas";
// Dev'de hot-reload başına yeni bağlantı açı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).
*/
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,
},
};
}
/**
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
*/
export function netlerGetir(programId: string): ProgramNetSatiri[] {
return getDb()
.prepare(
`SELECT yil, puanTuru, tabanPuan, obp,
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
aytDinNet, aytFelNet, ydtYdilNet
FROM netler WHERE program_id = ? ORDER BY yil DESC`
)
.all(programId) as ProgramNetSatiri[];
}
// 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, fakulte, il, unitur FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} >= ?`
)
.all(tur, onlisans, altSinir) as {
isim: string;
fakulte: string | null;
il: string | null;
unitur: string | null;
}[];
// isim+fakülte → kategori eşlemesi pahalı (regexler); tekrar edenler için önbellek
const kategoriCache = new Map<string, string[]>();
const kategorileriGetir = (
isim: string,
fakulte: string | null
): string[] => {
const anahtar = `${isim}|${fakulte ?? ""}`;
let k = kategoriCache.get(anahtar);
if (!k) {
k = kategoriBul(isim, fakulte);
kategoriCache.set(anahtar, k);
}
return k;
};
const seciliKategoriler = secim.kategoriler ?? [];
const kategoriUyar = (isim: string, fakulte: string | null) =>
seciliKategoriler.length === 0 ||
kategorileriGetir(isim, fakulte).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, fakulte, il, unitur } of satirlar) {
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
for (const k of kategorileriGetir(isim, fakulte)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
}
if (!kategoriUyar(isim, fakulte)) 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);
// Penceredeki programı olan TÜM iller döner (81 il + KKTC/yurtdışı);
// sıralama/gruplama UI'da yapılır. Kırpma yok: kullanıcı her ili görebilmeli.
const iller = [...ilSay.entries()]
.map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet);
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 };
}