Files
kolaytercih/src/lib/katalog.ts
bilalgursen d8a28b7c3a feat(katalog): şehre göre üniversiteler — /universiteler/[il] sayfaları ve şehir çipleri
- Veri: getAllIller/getIlBySlug (üniversite en çok programının olduğu ile
  sayılır; ili boş olan hiçbir şehre girmez; tireli yurt dışı adlar baş harfli).
- /universiteler: "Şehre göre" çip satırı (2+ üniversiteli 28 şehir) +
  "Tüm şehirler" A–Z üç sütunlu liste (#tum-sehirler).
- /universiteler/[il]: 92 statik şehir sayfası — katalog detay düzeni, il ve
  üniversite sayısı hapları, kompakt sıra satırı, devlet/vakıf/KKTC-yurt dışı
  öbekleri, diğer şehir çipleri, CTA bandı; metadata + breadcrumb JSON-LD.
- SehirCipleri: sihirbaz il çipiyle aynı kalıp, aktif şehir mavi dolu.
- Sitemap'e şehir sayfaları eklendi.

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

495 lines
15 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 "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<string, BolumKayit>;
bolumOzetler?: BolumOzet[];
uniMap?: Map<string, UniKayit>;
uniOzetler?: UniOzet[];
iller?: IlOzet[];
ilMap?: Map<string, IlOzet>;
};
function bolumMapGetir(): Map<string, BolumKayit> {
if (!globalKatalog.bolumMap) {
const rows = getDb()
.prepare("SELECT DISTINCT isim FROM programs")
.all() as { isim: string }[];
const map = new Map<string, BolumKayit>();
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<string, BolumOzet>();
const slugByIsim = new Map<string, string>();
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<string>();
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<string>(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<string, UniKayit> {
if (!globalKatalog.uniMap) {
const rows = getDb()
.prepare("SELECT DISTINCT universite FROM programs")
.all() as { universite: string }[];
const map = new Map<string, UniKayit>();
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<string, UniOzet>();
const slugByHam = new Map<string, string>();
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<string, Map<string, number>>();
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<string, Set<string>>();
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<string, IlOzet>();
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<string, Program[]>();
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;
}