Update routing in next.config.ts to redirect "/tercih-robotu" to the hero form, add simple-icons dependency in package.json, and enhance database indexing for improved query performance. Remove unused SVG files and update metadata across various pages for better SEO and clarity.
All checks were successful
Deploy / deploy (push) Successful in 14m14s

This commit is contained in:
bilalgursen
2026-08-01 17:28:38 +03:00
parent 569b9e5da2
commit 8cf0c3267d
76 changed files with 5050 additions and 255 deletions

View File

@@ -31,16 +31,20 @@ export const PUAN_TURLERI = {
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 };
function getDb(): Database.Database {
export function getDb(): Database.Database {
if (!globalForDb.yokatlasDb) {
globalForDb.yokatlasDb = new Database(
path.join(process.cwd(), "data", "yokatlas.db"),
@@ -50,12 +54,12 @@ function getDb(): Database.Database {
return globalForDb.yokatlasDb;
}
const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
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
const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
/**
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
@@ -74,20 +78,45 @@ function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
return { sql: "", args: [] };
}
export function searchByRank(
/**
* 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;
limitPerBucket?: number;
limit?: number;
offset?: number;
} = {}
): RankResults {
): { programlar: Program[]; toplam: number } {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const limitPerBucket = opts.limitPerBucket ?? 12;
const limit = opts.limit ?? 12;
const offset = opts.offset ?? 0;
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
const illerListe = [
@@ -99,55 +128,64 @@ export function searchByRank(
? `AND il IN (${illerListe.map(() => "?").join(",")})`
: "";
const unitur = uniturFiltreSql(opts.uniturGrubu);
const ilArgs = [...illerListe, ...unitur.args];
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 ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
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";
// hayal: kullanıcıdan daha iyi taban sıralaması (sınıra en yakın olanlar önce)
const hayal = db
const programlar = db
.prepare(
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} DESC LIMIT ?`
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
)
.all(
tur,
onlisans,
...ilArgs,
Math.round(sira * 0.5),
sira - 1,
limitPerBucket
);
.all(...args, limit, offset) as Program[];
const toplam = (
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
adet: number;
}
).adet;
// dengeli: tabanı kullanıcının sıralaması civarında
const dengeli = db
.prepare(`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ASC LIMIT ?`)
.all(
tur,
onlisans,
...ilArgs,
sira,
Math.round(sira * 1.4),
limitPerBucket
);
return { programlar, toplam };
}
// garanti: tabanı belirgin şekilde altında
const garanti = db
.prepare(`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ASC LIMIT ?`)
.all(
tur,
onlisans,
...ilArgs,
Math.round(sira * 1.4) + 1,
Math.round(sira * 3),
limitPerBucket
);
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 as Program[],
dengeli: dengeli as Program[],
garanti: garanti as Program[],
hayal: hayal.programlar,
dengeli: dengeli.programlar,
garanti: garanti.programlar,
toplam: {
hayal: hayal.toplam,
dengeli: dengeli.toplam,
garanti: garanti.toplam,
},
};
}
@@ -155,39 +193,79 @@ 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 alt sınırından
// (sira*0.5) garanti üst sınırına (sira*3) kadar. Böylece 500B sıradaki aday Tıp
// bubble'ı görmez; 5B sıradaki görür.
// 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
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 ustSinir = Math.round(sira * 3);
const base = `FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
// isim bazında say → JS'te kategoriye çevir (bir isim çok kategoriye girebilir)
const isimSayilari = db
.prepare(`SELECT isim, COUNT(*) AS adet ${base} GROUP BY isim`)
.all(tur, onlisans, altSinir, ustSinir) as {
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;
adet: number;
il: string | null;
unitur: string | null;
}[];
const kategoriSay = new Map<string, number>();
for (const { isim, adet } of isimSayilari) {
for (const k of kategoriBul(isim)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + adet);
// 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,
@@ -195,33 +273,14 @@ export function rankWindowFacets(
.filter((k) => k.adet >= 3) // gürültüyü ele
.sort((a, b) => b.adet - a.adet);
const iller = (
db
.prepare(
`SELECT il, COUNT(*) AS adet ${base} AND il IS NOT NULL AND il <> ''
GROUP BY il ORDER BY adet DESC LIMIT 15`
)
.all(tur, onlisans, altSinir, ustSinir) as {
il: string;
adet: number;
}[]
).filter((r) => r.adet > 0);
const iller = [...ilSay.entries()]
.map(([il, adet]) => ({ il, adet }))
.sort((a, b) => b.adet - a.adet)
.slice(0, 15);
const uniturSatirlari = db
.prepare(`SELECT unitur, COUNT(*) AS adet ${base} GROUP BY unitur`)
.all(tur, onlisans, altSinir, ustSinir) as {
unitur: string | null;
adet: number;
}[];
let devlet = 0;
let vakif = 0;
for (const { unitur, adet } of uniturSatirlari) {
if (unitur === "DEVLET") devlet += adet;
else if (unitur) vakif += 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 };
return { kategoriler, iller, uniturler, toplam: satirlar.length };
}

384
src/lib/katalog.ts Normal file
View File

@@ -0,0 +1,384 @@
import {
getDb,
SELECT_COLS,
EFEKTIF_SIRA,
type Program,
} from "./db";
import { kategoriBul, type Kategori } from "./kategoriler";
import { uniAdiNormalize } from "./harita";
import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug";
/**
* 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(COALESCE(sira2025, sira2024)) — 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[];
};
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 (Program & { onlisans?: number })[];
// 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.sira2025 ?? p.sira2024;
if (efektif != null) {
if (minSira == null || efektif < minSira) minSira = efektif;
if (maxSira == null || efektif > maxSira) maxSira = efektif;
}
if (p.puan2025 != null) {
if (minPuan == null || p.puan2025 < minPuan) minPuan = p.puan2025;
if (maxPuan == null || p.puan2025 > maxPuan) maxPuan = p.puan2025;
}
}
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 & {
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(il) AS il, MAX(unitur) AS unitur,
COUNT(*) AS adet
FROM programs GROUP BY universite`,
)
.all() as {
universite: string;
il: string | null;
unitur: string | null;
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.il && row.il) u.il = row.il;
if (!u.unitur && row.unitur) u.unitur = row.unitur;
}
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()];
}
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 Program[];
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.sira2025 ?? p.sira2024;
if (efektif != null && (minSira == null || efektif < minSira)) {
minSira = efektif;
}
toplamKontenjan += p.kontenjan2025 ?? 0;
}
const ozet = getAllUniversiteler().find((u) => u.slug === slug)!;
return {
...ozet,
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;
}

90
src/lib/rehber.ts Normal file
View File

@@ -0,0 +1,90 @@
import fs from "node:fs";
import path from "node:path";
import { marked } from "marked";
/**
* Rehber makaleleri: content/rehber/*.md dosyaları build sırasında okunur ve
* statik HTML'e gömülür (sayfalar generateStaticParams + dynamicParams=false
* ile tamamen prerender edilir; runtime'da fs erişimi olmaz).
*
* Frontmatter elle parse edilir (yalnızca düz `anahtar: değer` satırları) —
* 10 makale için gray-matter bağımlılığına gerek yok.
*/
export type RehberOzet = {
slug: string;
baslik: string;
aciklama: string;
/** ISO tarih — Article JSON-LD datePublished */
tarih: string;
};
export type RehberYazi = RehberOzet & { html: string };
const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber");
function parseFrontmatter(raw: string): {
meta: Record<string, string>;
body: string;
} {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { meta: {}, body: raw };
const meta: Record<string, string> = {};
for (const satir of match[1].split(/\r?\n/)) {
const ayrac = satir.indexOf(":");
if (ayrac === -1) continue;
const anahtar = satir.slice(0, ayrac).trim();
const deger = satir
.slice(ayrac + 1)
.trim()
.replace(/^["']|["']$/g, "");
if (anahtar) meta[anahtar] = deger;
}
return { meta, body: raw.slice(match[0].length) };
}
function oku(slug: string): RehberYazi | null {
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
if (!fs.existsSync(dosya)) return null;
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
if (!meta.baslik || !meta.aciklama || !meta.tarih) {
throw new Error(
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
);
}
return {
slug,
baslik: meta.baslik,
aciklama: meta.aciklama,
tarih: meta.tarih,
html: marked.parse(body, { gfm: true, async: false }),
};
}
export function getAllRehberSlugs(): string[] {
if (!fs.existsSync(REHBER_DIZIN)) return [];
return fs
.readdirSync(REHBER_DIZIN)
.filter((f) => f.endsWith(".md"))
.map((f) => f.replace(/\.md$/, ""))
.sort();
}
export function getRehber(slug: string): RehberYazi | null {
// path traversal koruması: slug yalnızca dosya adı olabilir
if (!/^[a-z0-9-]+$/.test(slug)) return null;
return oku(slug);
}
export function getAllRehberler(): RehberOzet[] {
return getAllRehberSlugs()
.map((slug) => oku(slug))
.filter((y): y is RehberYazi => y !== null)
.map(({ slug, baslik, aciklama, tarih }) => ({
slug,
baslik,
aciklama,
tarih,
}))
.sort((a, b) => b.tarih.localeCompare(a.tarih));
}

View File

@@ -15,7 +15,7 @@ export const RISK_ETIKET: Record<RiskSeviyesi, string> = {
/**
* Deterministik risk: programın efektif taban sıralaması (COALESCE 2025→2024)
* adayın sıralamasıyla kıyaslanır. Eşikler db.ts'teki dilim aralıklarıyla
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, 3×sira]).
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, ∞)).
*/
export function riskHesapla(
efektifSira: number | null | undefined,

124
src/lib/seo.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { URUNLER } from "./credits";
/**
* Kanonik site adresi. NEXT_PUBLIC_APP_URL bilinçli olarak KULLANILMAZ —
* o değişken ödeme callback akışına ait ve .env.local'de localhost'a işaret
* ediyor. .dockerignore .env* dosyalarını dışladığı için prod build burada
* deterministik olarak kolaytercih.com'a düşer.
*/
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ??
(process.env.NODE_ENV === "production"
? "https://kolaytercih.com"
: "http://localhost:3000");
export const SITE_NAME = "KolayTercih";
type JsonLdData = Record<string, unknown>;
export function organizationJsonLd(): JsonLdData {
return {
"@context": "https://schema.org",
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
logo: `${SITE_URL}/kolay-tercih-mark.svg`,
contactPoint: {
"@type": "ContactPoint",
email: "destek@kolaytercih.com",
contactType: "customer support",
availableLanguage: "Turkish",
},
};
}
export function webSiteJsonLd(): JsonLdData {
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: SITE_NAME,
url: SITE_URL,
inLanguage: "tr",
};
}
export function softwareApplicationJsonLd(): JsonLdData {
return {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: SITE_NAME,
applicationCategory: "EducationalApplication",
operatingSystem: "Web",
url: SITE_URL,
inLanguage: "tr",
offers: {
"@type": "Offer",
price: (URUNLER.paket.amountKurus / 100).toFixed(0),
priceCurrency: "TRY",
description: URUNLER.paket.label,
},
};
}
export function faqPageJsonLd(
faqs: readonly { question: string; answer: string }[],
): JsonLdData {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((f) => ({
"@type": "Question",
name: f.question,
acceptedAnswer: { "@type": "Answer", text: f.answer },
})),
};
}
export function breadcrumbJsonLd(
items: readonly { name: string; path: string }[],
): JsonLdData {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: `${SITE_URL}${item.path}`,
})),
};
}
export function articleJsonLd(opts: {
title: string;
description: string;
path: string;
datePublished: string;
}): JsonLdData {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: opts.title,
description: opts.description,
url: `${SITE_URL}${opts.path}`,
datePublished: opts.datePublished,
inLanguage: "tr",
publisher: {
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
},
};
}
/** JSON-LD'yi güvenli gömer: `<` kaçışı script-injection'ı engeller. */
export function JsonLd({ data }: { data: JsonLdData }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}

45
src/lib/slug.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Türkçe güvenli slug. Sıra kritik: önce tr-TR lowercase — düz toLowerCase()
* 'İ'yi 'i' + U+0307 (combining dot) yapar ve slug bozulur — sonra ASCII
* katlama, en son NFKD ile kalan aksan artıkları temizlenir.
*/
export function turkishSlugify(s: string): string {
return s
.toLocaleLowerCase("tr-TR")
.replace(/ı/g, "i")
.replace(/ş/g, "s")
.replace(/ğ/g, "g")
.replace(/ü/g, "u")
.replace(/ö/g, "o")
.replace(/ç/g, "c")
.replace(/â/g, "a")
.replace(/î/g, "i")
.replace(/û/g, "u")
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* "Psikoloji (%50 İndirimli) " → "Psikoloji". Burs/dil/kampüs gibi tüm
* parantez varyantlarını atar; arama niyeti taban ada gelir, varyantlar
* bölüm sayfasında satır olarak listelenir.
*/
export function bolumBazAdi(isim: string): string {
return isim
.replace(/\s*\([^)]*\)/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** ALL-CAPS veritabanı adlarını tr-TR başlık düzenine çevirir (İSTANBUL → İstanbul). */
export function trBaslikDuzeni(s: string): string {
return s
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((w) =>
w.length > 0 ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w,
)
.join(" ");
}