import "server-only"; import { getDb, programSatiri, SELECT_COLS, EFEKTIF_SIRA, type ProgramHamSatiri, } from "./db"; import type { Program } from "@/types/yokatlas"; import { kategoriBul, type Kategori } from "./kategoriler"; import { uniAdiNormalize } from "./harita"; import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug"; import { urapSirasiBul, type UrapSira } from "./urap-siralama"; import { uniIstatistikBul, type UniIstatistik } from "./yok-istatistik"; /** * Programatik SEO sayfalarının veri katmanı (/bolum, /universite, hub'lar). * * Slug haritaları modül-scope'ta lazy kurulur: DB readonly açıldığı için * slugs tablosu yazılamaz; SELECT DISTINCT (1.5K satır) süreç başına bir kez * <10ms sürer. Harita değerleri HAM isim/universite dizeleridir — sorgular * `WHERE isim IN (?, ...)` ile birebir eşleşip index'e vurur (WHERE içinde * TRIM/fonksiyon kullanmak index'i öldürür). */ // ---------- Bölüm tarafı ---------- export type BolumOzet = { slug: string; ad: string; programSayisi: number; kategoriler: Kategori[]; lisans: boolean; onlisans: boolean; /** MIN(efektif sıra) — en iyi (en küçük) taban sıralaması */ enIyiSira: number | null; }; export type BolumStats = { toplam: number; devlet: number; vakif: number; /** unitur boş/NULL olan satırlar — vakıf SAYILMAZ, ayrı gösterilir */ bilinmeyenTur: number; minSira: number | null; maxSira: number | null; minPuan: number | null; maxPuan: number | null; ilSayisi: number; }; export type BolumDetay = BolumOzet & { lisansProgramlari: Program[]; onlisansProgramlari: Program[]; stats: BolumStats; }; type BolumKayit = { slug: string; ad: string; hamIsimler: string[] }; const globalKatalog = globalThis as unknown as { bolumMap?: Map; bolumOzetler?: BolumOzet[]; uniMap?: Map; uniOzetler?: UniOzet[]; iller?: IlOzet[]; ilMap?: Map; }; function bolumMapGetir(): Map { if (!globalKatalog.bolumMap) { const rows = getDb() .prepare("SELECT DISTINCT isim FROM programs") .all() as { isim: string }[]; const map = new Map(); for (const { isim } of rows) { const baz = bolumBazAdi(isim); if (!baz) continue; const slug = turkishSlugify(baz); if (!slug) continue; const kayit = map.get(slug); if (kayit) kayit.hamIsimler.push(isim); else map.set(slug, { slug, ad: baz, hamIsimler: [isim] }); } if (map.size === 0) { // generateStaticParams boş dizi dönerse build hatası — boş DB'yi erken yakala throw new Error("katalog: programs tablosu boş görünüyor"); } globalKatalog.bolumMap = map; } return globalKatalog.bolumMap; } export function getAllBolumler(): BolumOzet[] { if (!globalKatalog.bolumOzetler) { const map = bolumMapGetir(); // Tek taramada tüm bölümlerin özeti: 23K satır, süreç başına bir kez const rows = getDb() .prepare( `SELECT isim, onlisans, ${EFEKTIF_SIRA} AS efektif FROM programs`, ) .all() as { isim: string; onlisans: number; efektif: number | null }[]; const ozet = new Map(); const slugByIsim = new Map(); for (const kayit of map.values()) { for (const ham of kayit.hamIsimler) slugByIsim.set(ham, kayit.slug); ozet.set(kayit.slug, { slug: kayit.slug, ad: kayit.ad, programSayisi: 0, kategoriler: kategoriBul(kayit.ad), lisans: false, onlisans: false, enIyiSira: null, }); } for (const row of rows) { const slug = slugByIsim.get(row.isim); if (!slug) continue; const b = ozet.get(slug)!; b.programSayisi += 1; if (row.onlisans === 1) b.onlisans = true; else b.lisans = true; if (row.efektif != null && (b.enIyiSira == null || row.efektif < b.enIyiSira)) { b.enIyiSira = row.efektif; } } globalKatalog.bolumOzetler = [...ozet.values()].sort((a, b) => a.ad.localeCompare(b.ad, "tr-TR"), ); } return globalKatalog.bolumOzetler; } export function getAllBolumSlugs(): string[] { return [...bolumMapGetir().keys()]; } export function getBolumBySlug(slug: string): BolumDetay | null { const kayit = bolumMapGetir().get(slug); if (!kayit) return null; const placeholders = kayit.hamIsimler.map(() => "?").join(","); const programlar = ( getDb() .prepare( `SELECT ${SELECT_COLS} FROM programs WHERE isim IN (${placeholders}) ORDER BY (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`, ) .all(...kayit.hamIsimler) as ProgramHamSatiri[] ).map(programSatiri); // onlisans SELECT_COLS içinde yok; tur üzerinden ayır (TYT = önlisans) const lisansProgramlari = programlar.filter((p) => p.tur !== "TYT"); const onlisansProgramlari = programlar.filter((p) => p.tur === "TYT"); const iller = new Set(); let devlet = 0; let vakif = 0; let bilinmeyenTur = 0; let minSira: number | null = null; let maxSira: number | null = null; let minPuan: number | null = null; let maxPuan: number | null = null; for (const p of programlar) { if (p.il) iller.add(p.il); if (p.unitur === "DEVLET") devlet += 1; else if (p.unitur) vakif += 1; else bilinmeyenTur += 1; const efektif = p.efektifSira; if (efektif != null) { if (minSira == null || efektif < minSira) minSira = efektif; if (maxSira == null || efektif > maxSira) maxSira = efektif; } if (p.puanSon != null) { if (minPuan == null || p.puanSon < minPuan) minPuan = p.puanSon; if (maxPuan == null || p.puanSon > maxPuan) maxPuan = p.puanSon; } } const ozet = getAllBolumler().find((b) => b.slug === slug)!; return { ...ozet, lisansProgramlari, onlisansProgramlari, stats: { toplam: programlar.length, devlet, vakif, bilinmeyenTur, minSira, maxSira, minPuan, maxPuan, ilSayisi: iller.size, }, }; } /** Aynı kategorideki diğer bölümler, program sayısına göre (kendisi hariç). */ export function ilgiliBolumler(slug: string, limit = 8): BolumOzet[] { const hepsi = getAllBolumler(); const kendi = hepsi.find((b) => b.slug === slug); if (!kendi) return []; const kendiKategoriler = new Set(kendi.kategoriler); return hepsi .filter( (b) => b.slug !== slug && (kendiKategoriler.size === 0 || b.kategoriler.some((k) => kendiKategoriler.has(k))), ) .sort((a, b) => b.programSayisi - a.programSayisi) .slice(0, limit); } // ---------- Üniversite tarafı ---------- export type UniOzet = { slug: string; /** tr-TR başlık düzeninde görünen ad (İstanbul Teknik Üniversitesi) */ ad: string; il: string | null; unitur: string | null; programSayisi: number; fakulteSayisi: number; }; export type UniDetay = UniOzet & { /** * URAP Türkiye genel sıralaması; listede olmayan üniversitede null. * Gösterildiği her yüzeyde URAP_KAYNAK_URL'ye aktif atıf zorunludur. */ urap: UrapSira | null; /** YÖK İstatistik örgün öğrenci / öğretim üyesi sayısı; KKTC ve yurt dışında null. */ istatistik: UniIstatistik | null; fakulteler: { fakulte: string; programlar: Program[] }[]; stats: { minSira: number | null; toplamKontenjan: number; devletMi: boolean; }; }; type UniKayit = { slug: string; ad: string; hamAdlar: string[] }; function uniMapGetir(): Map { if (!globalKatalog.uniMap) { const rows = getDb() .prepare("SELECT DISTINCT universite FROM programs") .all() as { universite: string }[]; const map = new Map(); for (const { universite } of rows) { const normal = uniAdiNormalize(universite); if (!normal) continue; const slug = turkishSlugify(normal); if (!slug) continue; const kayit = map.get(slug); if (kayit) kayit.hamAdlar.push(universite); else map.set(slug, { slug, ad: trBaslikDuzeni(normal), hamAdlar: [universite], }); } if (map.size === 0) { throw new Error("katalog: programs tablosunda üniversite yok"); } globalKatalog.uniMap = map; } return globalKatalog.uniMap; } export function getAllUniversiteler(): UniOzet[] { if (!globalKatalog.uniOzetler) { const map = uniMapGetir(); const rows = getDb() .prepare( `SELECT universite, MAX(unitur) AS unitur, COUNT(*) AS adet FROM programs GROUP BY universite`, ) .all() as { universite: string; unitur: string | null; adet: number; }[]; // il: MAX(il) alfabetik en büyüğü seçip yanıltıyordu (ör. Ankara Sosyal // Bilimler'in 12 KKTC satırı 47 Ankara satırını eziyordu); üniversitenin // en çok programının bulunduğu il esas alınır. const ilRows = getDb() .prepare( `SELECT universite, il, COUNT(*) AS adet FROM programs WHERE il IS NOT NULL GROUP BY universite, il`, ) .all() as { universite: string; il: string; adet: number }[]; // Fakülte sayısı: aynı üniversitenin iki yazımı ("X ÜNİV." / "X ÜNİV. (İL)") // birleştiği için GROUP BY universite üzerinden saymak çift sayardı; // (slug, fakulte) çifti üzerinden kesin sayılır. const fakulteRows = getDb() .prepare( "SELECT DISTINCT universite, fakulte FROM programs WHERE fakulte IS NOT NULL", ) .all() as { universite: string; fakulte: string }[]; const bySlug = new Map(); const slugByHam = new Map(); for (const kayit of map.values()) { for (const ham of kayit.hamAdlar) slugByHam.set(ham, kayit.slug); bySlug.set(kayit.slug, { slug: kayit.slug, ad: kayit.ad, il: null, unitur: null, programSayisi: 0, fakulteSayisi: 0, }); } for (const row of rows) { const slug = slugByHam.get(row.universite); if (!slug) continue; const u = bySlug.get(slug)!; u.programSayisi += row.adet; if (!u.unitur && row.unitur) u.unitur = row.unitur; } const ilAdetleri = new Map>(); for (const row of ilRows) { const slug = slugByHam.get(row.universite); if (!slug) continue; let sayac = ilAdetleri.get(slug); if (!sayac) { sayac = new Map(); ilAdetleri.set(slug, sayac); } sayac.set(row.il, (sayac.get(row.il) ?? 0) + row.adet); } for (const [slug, sayac] of ilAdetleri) { let enIyi: string | null = null; let enCok = 0; for (const [il, adet] of sayac) { if (adet > enCok) { enIyi = il; enCok = adet; } } bySlug.get(slug)!.il = enIyi; } const fakulteSetleri = new Map>(); for (const row of fakulteRows) { const slug = slugByHam.get(row.universite); if (!slug) continue; let set = fakulteSetleri.get(slug); if (!set) { set = new Set(); fakulteSetleri.set(slug, set); } set.add(row.fakulte); } for (const [slug, set] of fakulteSetleri) { bySlug.get(slug)!.fakulteSayisi = set.size; } globalKatalog.uniOzetler = [...bySlug.values()].sort((a, b) => a.ad.localeCompare(b.ad, "tr-TR"), ); } return globalKatalog.uniOzetler; } export function getAllUniversiteSlugs(): string[] { return [...uniMapGetir().keys()]; } // ---------- İl (şehir) tarafı: /universiteler/[il] ---------- export type IlOzet = { /** turkishSlugify(il): "istanbul", "saraybosna-bosna-hersek" */ slug: string; /** Görünen ad: İstanbul, Bakü-Azerbaycan */ ad: string; /** Bu ilde yer alan üniversiteler (UniOzet.il esas; ad sırasıyla) */ uniler: UniOzet[]; programSayisi: number; }; /** * "BAKÜ-AZERBAYCAN" → "Bakü-Azerbaycan": trBaslikDuzeni yalnız boşlukta * böler; yurt dışı şehirlerin tireli ülke eki de baş harfli yazılır. */ function ilGorunenAd(il: string): string { return trBaslikDuzeni(il).replace(/-(\p{L})/gu, (_, harf: string) => `-${harf.toLocaleUpperCase("tr-TR")}`, ); } /** * Üniversiteleri iline göre kümeler; sıralama üniversite sayısına göre * azalan, eşitlikte ad. Her üniversite tek ilde sayılır (UniOzet.il: en çok * programının bulunduğu il). İli olmayan (NULL/boş) üniversite hiçbir ile * girmez. KKTC ve yurt dışı şehirler de birer ildir (LEFKOŞA, BAKÜ-…). */ export function getAllIller(): IlOzet[] { if (!globalKatalog.iller) { const bySlug = new Map(); for (const u of getAllUniversiteler()) { const il = u.il?.trim(); if (!il) continue; const slug = turkishSlugify(il); if (!slug) continue; let kayit = bySlug.get(slug); if (!kayit) { kayit = { slug, ad: ilGorunenAd(il), uniler: [], programSayisi: 0 }; bySlug.set(slug, kayit); } kayit.uniler.push(u); kayit.programSayisi += u.programSayisi; } globalKatalog.ilMap = bySlug; globalKatalog.iller = [...bySlug.values()].sort( (a, b) => b.uniler.length - a.uniler.length || a.ad.localeCompare(b.ad, "tr-TR"), ); } return globalKatalog.iller; } export function getAllIlSlugs(): string[] { return getAllIller().map((il) => il.slug); } export function getIlBySlug(slug: string): IlOzet | null { getAllIller(); return globalKatalog.ilMap!.get(slug) ?? null; } export function getUniversiteBySlug(slug: string): UniDetay | null { const kayit = uniMapGetir().get(slug); if (!kayit) return null; const placeholders = kayit.hamAdlar.map(() => "?").join(","); const programlar = ( getDb() .prepare( `SELECT ${SELECT_COLS} FROM programs WHERE universite IN (${placeholders}) ORDER BY fakulte, (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`, ) .all(...kayit.hamAdlar) as ProgramHamSatiri[] ).map(programSatiri); const gruplar = new Map(); let minSira: number | null = null; let toplamKontenjan = 0; for (const p of programlar) { const anahtar = p.fakulte ?? "Diğer"; const liste = gruplar.get(anahtar); if (liste) liste.push(p); else gruplar.set(anahtar, [p]); const efektif = p.efektifSira; if (efektif != null && (minSira == null || efektif < minSira)) { minSira = efektif; } toplamKontenjan += p.kontenjanSon ?? 0; } const ozet = getAllUniversiteler().find((u) => u.slug === slug)!; return { ...ozet, urap: urapSirasiBul(slug), istatistik: uniIstatistikBul(slug), fakulteler: [...gruplar.entries()] .map(([fakulte, programlar]) => ({ fakulte, programlar })) .sort((a, b) => a.fakulte.localeCompare(b.fakulte, "tr-TR")), stats: { minSira, toplamKontenjan, devletMi: ozet.unitur === "DEVLET", }, }; } // ---------- Yardımcılar ---------- /** Ham program isminden /bolum sayfası slug'ı (tablolarda çapraz link için). */ export function bolumSlugFromIsim(isim: string): string | null { const slug = turkishSlugify(bolumBazAdi(isim)); return bolumMapGetir().has(slug) ? slug : null; } /** Ham üniversite adından /universite sayfası slug'ı. */ export function uniSlugFromAd(universite: string): string | null { const slug = turkishSlugify(uniAdiNormalize(universite)); return uniMapGetir().has(slug) ? slug : null; }