From 38e02e0ed98394aef30d181c6d9e585ae6bffdf1 Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 16:35:24 +0300
Subject: [PATCH 1/9] =?UTF-8?q?feat(veri):=20y=C4=B1l=20kapsam=C4=B1=20tek?=
=?UTF-8?q?=20kayna=C4=9F=C4=B1=20(veri-yillari.ts)=20=E2=80=94=202026=20y?=
=?UTF-8?q?=C3=BCklemesi=20P-0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5.1
---
src/lib/veri-yillari.ts | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 src/lib/veri-yillari.ts
diff --git a/src/lib/veri-yillari.ts b/src/lib/veri-yillari.ts
new file mode 100644
index 0000000..2e0b014
--- /dev/null
+++ b/src/lib/veri-yillari.ts
@@ -0,0 +1,38 @@
+/**
+ * Yerleştirme verisinin yıl kapsamı — TEK KAYNAK. Saf ve import'suz: client,
+ * server ve `scripts/` aynı dosyayı okur. Yeni yıl verisi yüklenince yalnız
+ * `SON_YIL` değişir (+ `pnpm refresh -- --yil ` ile DB inşası).
+ */
+
+/** Veri penceresinin ilk yılı. Kayıtlı rapor/tadımlık `siraGecmisi` dizileri
+ * bu yıla demirlidir; pencere kaydırılmaz, uzar. */
+export const ILK_YIL = 2021;
+
+/** En son yerleştirme yılı (DB'de `sira${SON_YIL}` kolonları dolu). */
+export const SON_YIL = 2026;
+
+export const ONCEKI_YIL = SON_YIL - 1;
+
+/** [2021, …, SON_YIL] — `siraGecmisi` dizileriyle indeks hizalı. */
+export const YILLAR: readonly number[] = Array.from(
+ { length: SON_YIL - ILK_YIL + 1 },
+ (_, i) => ILK_YIL + i,
+);
+
+/** Bu veriyle tercih yapacak öğrencilerin yılı. */
+export const TERCIH_YILI = SON_YIL + 1;
+
+/** Sayfa başlıklarında görünen yıl ("… Taban Puanları 2026"). */
+export const BASLIK_YILI = SON_YIL;
+
+export const VERI_YIL_SAYISI = YILLAR.length;
+
+/** "2021–2026" */
+export const VERI_ARALIGI = `${ILK_YIL}–${SON_YIL}`;
+
+/**
+ * Efektif sıra: son yılın sırası, yoksa bir önceki yılınki. İNDEKS SÖZLEŞMESİ —
+ * SQLite ifade indeksi metinsel eşleşmeyle çalışır; sorgular ve indeks
+ * betikleri bu dizgiyi birebir (virgülden sonra tek boşluk) kullanmalıdır.
+ */
+export const EFEKTIF_SIRA_SQL = `COALESCE(sira${SON_YIL}, sira${ONCEKI_YIL})`;
From 3fa5cab9066fc4b042c0cadb6fa35fbfff88617e Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 16:53:42 +0300
Subject: [PATCH 2/9] =?UTF-8?q?feat(veri):=20katalog/OG/rapor=20metinleri?=
=?UTF-8?q?=20y=C4=B1l=C4=B1=20veri-yillari.ts'ten=20okur=20=E2=80=94=2020?=
=?UTF-8?q?26=20y=C3=BCklemesi=20P-C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- "2025 yerleştirme" → SON_YIL, "2026 tercihleri" → TERCIH_YILI, başlık yılı → BASLIK_YILI
- yazdırılabilir rapor dipnotu: "2021–2025" → VERI_ARALIGI
- meraklısına 1. bölüm: efektif sıra açıklaması yılsız
- KATALOG_VERI_TARIHI: 2026-09-21 (2026 verisinin çekim tarihi)
Co-Authored-By: Claude Fable 5.1
---
src/app/bolum/[slug]/opengraph-image.tsx | 3 ++-
src/app/bolumler/opengraph-image.tsx | 6 +++---
src/app/bolumler/page.tsx | 11 +++++++----
src/app/universite/[slug]/opengraph-image.tsx | 3 ++-
src/app/universiteler/opengraph-image.tsx | 6 +++---
src/app/universiteler/page.tsx | 11 +++++++----
src/features/katalog/components/bolum-listesi.tsx | 7 ++++---
.../katalog/components/universite-listesi.tsx | 7 ++++---
src/features/katalog/components/veri-kaynagi.tsx | 5 +++--
.../pazarlama/components/meraklisina-icerik.tsx | 4 ++--
src/features/rapor/components/rapor-yazdir.tsx | 3 ++-
src/lib/seo.tsx | 2 +-
12 files changed, 40 insertions(+), 28 deletions(-)
diff --git a/src/app/bolum/[slug]/opengraph-image.tsx b/src/app/bolum/[slug]/opengraph-image.tsx
index 1703927..71f2945 100644
--- a/src/app/bolum/[slug]/opengraph-image.tsx
+++ b/src/app/bolum/[slug]/opengraph-image.tsx
@@ -4,6 +4,7 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
export const alt = "Bölüm taban puanları ve sıralamaları — KolayTercih";
export const size = OG_SIZE;
@@ -26,6 +27,6 @@ export default async function Image({
return createOgSayfaImage({
eyebrow: "Bölüm",
title: `${ad} taban puanları`,
- description: `${sayi}2025 yerleştirme verileri · 2026 tercih rehberi`,
+ description: `${sayi}${SON_YIL} yerleştirme verileri · ${TERCIH_YILI} tercih rehberi`,
});
}
diff --git a/src/app/bolumler/opengraph-image.tsx b/src/app/bolumler/opengraph-image.tsx
index 9df4d77..0ae7dbb 100644
--- a/src/app/bolumler/opengraph-image.tsx
+++ b/src/app/bolumler/opengraph-image.tsx
@@ -3,9 +3,9 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { BASLIK_YILI, SON_YIL } from "@/lib/veri-yillari";
-export const alt =
- "Tüm bölümlerin taban puanları ve sıralamaları 2026 — KolayTercih";
+export const alt = `Tüm bölümlerin taban puanları ve sıralamaları ${BASLIK_YILI} — KolayTercih`;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
@@ -13,6 +13,6 @@ export default async function Image() {
return createOgSayfaImage({
eyebrow: "Katalog",
title: "Tüm bölümlerin taban puanları ve sıralamaları",
- description: "Lisans ve önlisans programları · 2025 yerleştirme verileri",
+ description: `Lisans ve önlisans programları · ${SON_YIL} yerleştirme verileri`,
});
}
diff --git a/src/app/bolumler/page.tsx b/src/app/bolumler/page.tsx
index 9f4e660..16990c6 100644
--- a/src/app/bolumler/page.tsx
+++ b/src/app/bolumler/page.tsx
@@ -1,16 +1,19 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
+import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { SiteFooter } from "@/components/site-footer";
import {
BolumKatalogu,
BolumKataloguSkeleton,
} from "@/features/katalog/components/bolum-listesi";
+// Başlıktaki yıl tek kaynaktan (veri-yillari.ts); metadata ve h1 aynı metni okur.
+const BASLIK = `Tüm Bölümlerin Taban Puanları ve Sıralamaları ${BASLIK_YILI}`;
+
export const metadata: Metadata = sayfaMetadata({
- title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
- description:
- "Üniversite bölümlerinin 2025 yerleştirme taban puanları ve başarı sıralamaları: lisans ve önlisans programları kategori kategori, 2026 tercihleri için.",
+ title: BASLIK,
+ description: `Üniversite bölümlerinin ${SON_YIL} yerleştirme taban puanları ve başarı sıralamaları: lisans ve önlisans programları kategori kategori, ${TERCIH_YILI} tercihleri için.`,
path: "/bolumler",
});
@@ -26,7 +29,7 @@ export default function BolumlerPage() {
/>
- Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
+ {BASLIK}
}>
diff --git a/src/app/universite/[slug]/opengraph-image.tsx b/src/app/universite/[slug]/opengraph-image.tsx
index 4772c5b..f756bad 100644
--- a/src/app/universite/[slug]/opengraph-image.tsx
+++ b/src/app/universite/[slug]/opengraph-image.tsx
@@ -8,6 +8,7 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { SON_YIL } from "@/lib/veri-yillari";
export const alt = "Üniversite taban puanları ve sıralamaları — KolayTercih";
export const size = OG_SIZE;
@@ -26,7 +27,7 @@ export default async function Image({
if (uni?.programSayisi != null) {
parcalar.push(`${uni.programSayisi.toLocaleString("tr-TR")} program`);
}
- parcalar.push("2025 yerleştirme verileri");
+ parcalar.push(`${SON_YIL} yerleştirme verileri`);
// Logo dosyası varsa OG kartına data URI olarak gömülür; okuma hatası
// kartı düşürmesin diye sessizce logosuz karta düşülür.
diff --git a/src/app/universiteler/opengraph-image.tsx b/src/app/universiteler/opengraph-image.tsx
index 07fb32c..ba84552 100644
--- a/src/app/universiteler/opengraph-image.tsx
+++ b/src/app/universiteler/opengraph-image.tsx
@@ -3,9 +3,9 @@ import {
OG_CONTENT_TYPE,
OG_SIZE,
} from "@/lib/og-sayfa";
+import { BASLIK_YILI, SON_YIL } from "@/lib/veri-yillari";
-export const alt =
- "Üniversitelerin taban puanları ve sıralamaları 2026 — KolayTercih";
+export const alt = `Üniversitelerin taban puanları ve sıralamaları ${BASLIK_YILI} — KolayTercih`;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
@@ -13,6 +13,6 @@ export default async function Image() {
return createOgSayfaImage({
eyebrow: "Katalog",
title: "Üniversitelerin taban puanları ve sıralamaları",
- description: "Devlet ve vakıf üniversiteleri · 2025 yerleştirme verileri",
+ description: `Devlet ve vakıf üniversiteleri · ${SON_YIL} yerleştirme verileri`,
});
}
diff --git a/src/app/universiteler/page.tsx b/src/app/universiteler/page.tsx
index 5c07342..082f844 100644
--- a/src/app/universiteler/page.tsx
+++ b/src/app/universiteler/page.tsx
@@ -1,16 +1,19 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
+import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { SiteFooter } from "@/components/site-footer";
import {
UniversiteKatalogu,
UniversiteKataloguSkeleton,
} from "@/features/katalog/components/universite-listesi";
+// Başlıktaki yıl tek kaynaktan (veri-yillari.ts); metadata ve h1 aynı metni okur.
+const BASLIK = `Üniversitelerin Taban Puanları ve Sıralamaları ${BASLIK_YILI}`;
+
export const metadata: Metadata = sayfaMetadata({
- title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
- description:
- "Devlet ve vakıf üniversitelerinin 2025 yerleştirme taban puanları, başarı sıralamaları ve kontenjanları — üniversite üniversite, 2026 tercihleri için.",
+ title: BASLIK,
+ description: `Devlet ve vakıf üniversitelerinin ${SON_YIL} yerleştirme taban puanları, başarı sıralamaları ve kontenjanları — üniversite üniversite, ${TERCIH_YILI} tercihleri için.`,
path: "/universiteler",
});
@@ -26,7 +29,7 @@ export default function UniversitelerPage() {
/>
- Üniversitelerin Taban Puanları ve Sıralamaları 2026
+ {BASLIK}
}>
diff --git a/src/features/katalog/components/bolum-listesi.tsx b/src/features/katalog/components/bolum-listesi.tsx
index 3a49747..bb6af6f 100644
--- a/src/features/katalog/components/bolum-listesi.tsx
+++ b/src/features/katalog/components/bolum-listesi.tsx
@@ -4,6 +4,7 @@ import { KATEGORILER } from "@/lib/kategoriler";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
+import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -81,11 +82,11 @@ export function BolumKatalogu() {
return (
<>
- {sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
+ {sayi(hepsi.length)} bölümün {SON_YIL} YKS yerleştirme verileri: taban
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu görürsün.
- Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri
- için rehber niteliğindedir.
+ Veriler YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır;{" "}
+ {TERCIH_YILI} tercihleri için rehber niteliğindedir.
diff --git a/src/features/katalog/components/universite-listesi.tsx b/src/features/katalog/components/universite-listesi.tsx
index 1fd4459..13c521f 100644
--- a/src/features/katalog/components/universite-listesi.tsx
+++ b/src/features/katalog/components/universite-listesi.tsx
@@ -5,6 +5,7 @@ import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { UniLogo } from "@/components/uni-logo";
import { Skeleton } from "@/components/ui/skeleton";
+import { SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -49,11 +50,11 @@ export function UniversiteKatalogu() {
return (
<>
- {sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
+ {sayi(hepsi.length)} üniversitenin {SON_YIL} YKS yerleştirme verileri.
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler YÖK
- Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için rehber
- niteliğindedir.
+ Atlas {SON_YIL} yerleştirme sonuçlarına dayanır; {TERCIH_YILI} tercihleri
+ için rehber niteliğindedir.
diff --git a/src/features/katalog/components/veri-kaynagi.tsx b/src/features/katalog/components/veri-kaynagi.tsx
index d1485e5..9407c23 100644
--- a/src/features/katalog/components/veri-kaynagi.tsx
+++ b/src/features/katalog/components/veri-kaynagi.tsx
@@ -1,7 +1,8 @@
import { KATALOG_VERI_TARIHI } from "@/lib/seo";
import { cn } from "@/lib/utils";
+import { SON_YIL } from "@/lib/veri-yillari";
-// Veri tarihinin görünür biçimi ("9 Ağustos 2026") — tek biçim kaynağı; ana
+// Veri tarihinin görünür biçimi ("21 Eylül 2026") — tek biçim kaynağı; ana
// sayfa hero'sundaki veri satırı da bunu kullanır.
export const KATALOG_TARIH_METNI = new Date(
`${KATALOG_VERI_TARIHI}T00:00:00Z`,
@@ -24,7 +25,7 @@ export function VeriKaynagi({
return (
{children}
- Kaynak: YÖK Atlas 2025 yerleştirme sonuçları. Son güncelleme:{" "}
+ Kaynak: YÖK Atlas {SON_YIL} yerleştirme sonuçları. Son güncelleme:{" "}
{KATALOG_TARIH_METNI} .
);
diff --git a/src/features/pazarlama/components/meraklisina-icerik.tsx b/src/features/pazarlama/components/meraklisina-icerik.tsx
index 12815f5..d5c1c9c 100644
--- a/src/features/pazarlama/components/meraklisina-icerik.tsx
+++ b/src/features/pazarlama/components/meraklisina-icerik.tsx
@@ -47,8 +47,8 @@ export function MeraklisinaIcerik() {
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
yerleşebilirdin demektir. Sistemde her programın en güncel taban
- sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
- bir önceki yılınki kullanılır.
+ sıralaması kayıtlıdır; bir bölüm için en son yılın verisi henüz
+ oluşmamışsa bir önceki yılınki kullanılır.
diff --git a/src/features/rapor/components/rapor-yazdir.tsx b/src/features/rapor/components/rapor-yazdir.tsx
index 20cf054..3618f6c 100644
--- a/src/features/rapor/components/rapor-yazdir.tsx
+++ b/src/features/rapor/components/rapor-yazdir.tsx
@@ -7,6 +7,7 @@ import { DILIM_ETIKET, RISK_ETIKET, dilimdenRisk } from "@/lib/risk";
import type { RaporParams, RaporSonuc } from "../types/rapor";
import { Skeleton } from "@/components/ui/skeleton";
import { YazdirButonu } from "./yazdir-butonu";
+import { VERI_ARALIGI } from "@/lib/veri-yillari";
// Dilim sütunu: dilim adı + varsayılan riski. Üçüncü dilimde ikisi aynı
// kelime ("Güvenli") olduğu için parantez düşer — "Güvenli (Güvenli)" yazmaz.
@@ -106,7 +107,7 @@ export async function RaporYazdir() {
- Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (2021–2025
+ Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi ({VERI_ARALIGI}{" "}
taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak
üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme
garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai
diff --git a/src/lib/seo.tsx b/src/lib/seo.tsx
index 52b4149..6d784cd 100644
--- a/src/lib/seo.tsx
+++ b/src/lib/seo.tsx
@@ -21,7 +21,7 @@ export const SITE_NAME = "KolayTercih";
* Yalnızca veri GERÇEKTEN değiştiğinde (ingest/refresh sonrası) elle güncelle;
* build zamanına bağlama — her deploy'da değişen lastmod sinyali değersizleşir.
*/
-export const KATALOG_VERI_TARIHI = "2026-08-09";
+export const KATALOG_VERI_TARIHI = "2026-09-21";
type SayfaMetadataGirdi = {
title: string;
From 590fea749d80002baa9a5d6edd217b8b9c1e9bd1 Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 16:54:45 +0300
Subject: [PATCH 3/9] =?UTF-8?q?docs(meraklisina):=20aday=20havuzu=20kural?=
=?UTF-8?q?=C4=B1=20=E2=80=94=20yaln=C4=B1z=20en=20son=20tercih=20k=C4=B1l?=
=?UTF-8?q?avuzundaki=20programlar?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bilal kararı (21 Eylül 2026): kılavuzda artık olmayan eski program kayıtları
siteden kalkar; havuza yalnız son kılavuzda yer alan programlar girer.
Şema düğümü (H), aria-label, figcaption ve 4. bölüm paragrafı birlikte
güncellendi (AGENTS.md şema senkronu).
Co-Authored-By: Claude Fable 5.1
---
src/features/pazarlama/components/huni-semasi.tsx | 8 +++++---
.../pazarlama/components/meraklisina-icerik.tsx | 11 +++++++----
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/src/features/pazarlama/components/huni-semasi.tsx b/src/features/pazarlama/components/huni-semasi.tsx
index 2224b19..4286fbb 100644
--- a/src/features/pazarlama/components/huni-semasi.tsx
+++ b/src/features/pazarlama/components/huni-semasi.tsx
@@ -16,7 +16,7 @@ flowchart TD
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi alanlar bölümün adına ve bağlı olduğu fakülteye göre belirlenir, yalnızca ulaşabildiklerin listelenir")
A --> B("2. adım · İl seçimi programı olan tüm iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
B --> C("3. adım · Devlet / Vakıf sayılar hem alanı hem ili hesaba katar")
- C --> H("Aday havuzu elde kalan gerçek programlar")
+ C --> H("Aday havuzu elde kalan gerçek programlar — yalnızca en son tercih kılavuzunda yer alanlar kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez")
H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir önce il, sonra alan — listede açıkça belirtilir")
G --> H
@@ -44,10 +44,12 @@ export function HuniSemasi() {
return (
- Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
+ Havuza yalnızca en son tercih kılavuzunda yer alan programlar girer;
+ kapanan ya da kodu değişip eski kaydı kalan programlar önerilmez. Her
+ adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
dışına asla çıkmadan
{" "}
diff --git a/src/features/pazarlama/components/meraklisina-icerik.tsx b/src/features/pazarlama/components/meraklisina-icerik.tsx
index d5c1c9c..cd70ddb 100644
--- a/src/features/pazarlama/components/meraklisina-icerik.tsx
+++ b/src/features/pazarlama/components/meraklisina-icerik.tsx
@@ -116,10 +116,13 @@ export function MeraklisinaIcerik() {
tabloya da uygulanır. Adımlar bitince elde kalan gerçek
programlar bir aday havuzu {" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
- verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
- 24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
- yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
- nasıl kurulduğu da dahil.
+ verilir. Havuza yalnızca{" "}
+ en son tercih kılavuzunda yer alan {" "}
+ programlar girer: kapanmış ya da kodu değiştiği için eski kaydı
+ kalmış bir program sana önerilmez. Aşağıdaki şema akışın tamamını
+ gösterir — havuz 24'ün altına düşerse filtrelerin nasıl
+ gevşetildiği, yine de yetmezse listenin eldeki programlarla, sana
+ açıkça belirtilerek nasıl kurulduğu da dahil.
From ce40b7c5b3158cdb105dce7ea25518fb195aaebf Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 17:00:39 +0300
Subject: [PATCH 4/9] =?UTF-8?q?feat(veri):=20Program=20tipi=20y=C4=B1ls?=
=?UTF-8?q?=C4=B1z=20=E2=80=94=20db=20e=C5=9Fleyicisi,=20lib=20ve=20snapsh?=
=?UTF-8?q?ot=20ilkYil=20(P-B=20a)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Program artık siraGecmisi (YILLAR ile hizalı) + efektifSira + siraSon/puanSon/
kontenjanSon/yerlesenSon taşır. db.ts kolonları YILLAR'dan üretip takma adla
seçer, programSatiri eşleyicisi ham satırı Program'a çevirir; SELECT_COLS'un
üç tüketicisi de eşleyiciden geçer. EFEKTIF_SIRA veri-yillari'dan gelir.
- trend: ILK_YIL tek kaynaktan, opsiyonel ilkYil parametresi
- rapor snapshot'ına opsiyonel ilkYil yazılır (eski kayıtlar: okurken ILK_YIL)
- rapor promptu son 4 yılı göndermeye devam eder, anahtarlar dinamik
- tadımlık "son N yıl" filtresi genel: N != VERI_YIL_SAYISI ise servis edilmez
- veri-kapsami VERI_YIL_SAYISI'nı veri-yillari'dan türetir
Bileşenler bir sonraki commit'te yeni tipe geçer (ikisi birlikte derlenir).
Co-Authored-By: Claude Fable 5.1
---
.../liste/hooks/use-tercih-profili.ts | 3 +-
src/features/liste/liste-queries.ts | 14 +++--
src/features/liste/liste-store.ts | 9 ++--
src/features/rapor/types/rapor.ts | 10 +++-
src/lib/ai/rapor.ts | 25 +++++----
src/lib/db.ts | 49 ++++++++++++++---
src/lib/katalog.ts | 54 +++++++++++--------
src/lib/rapor-havuzu.ts | 26 ++++++---
src/lib/rapor-maske.ts | 2 +-
src/lib/risk.ts | 10 ++--
src/lib/tadimlik-havuzu.ts | 42 ++++++++++++---
src/lib/trend.ts | 23 ++++----
src/lib/veri-kapsami.ts | 8 +--
src/types/yokatlas.ts | 26 +++++----
14 files changed, 195 insertions(+), 106 deletions(-)
diff --git a/src/features/liste/hooks/use-tercih-profili.ts b/src/features/liste/hooks/use-tercih-profili.ts
index f76fb90..3771ac7 100644
--- a/src/features/liste/hooks/use-tercih-profili.ts
+++ b/src/features/liste/hooks/use-tercih-profili.ts
@@ -28,8 +28,7 @@ export type BekleyenProgram = {
| "il"
| "unitur"
| "tur"
- | "sira2025"
- | "sira2024"
+ | "efektifSira"
>;
kaynak: ManuelKaynak;
adaySira?: number;
diff --git a/src/features/liste/liste-queries.ts b/src/features/liste/liste-queries.ts
index 569f366..d716a2e 100644
--- a/src/features/liste/liste-queries.ts
+++ b/src/features/liste/liste-queries.ts
@@ -8,7 +8,8 @@ import type {
UniturGrubu,
} from "@/types/yokatlas";
-// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
+// Tabloda kullanılmayan alanlar (fakulte, puanSon, siraSon) client'a
+// serileşmesin.
// Alanlar tek tek seçilir: Program'a alan eklenirse ProgramListeSatiri
// üzerinden derleyici burada da karar vermeye zorlar.
export function programOzetle(p: Program): ProgramListeSatiri {
@@ -20,13 +21,10 @@ export function programOzetle(p: Program): ProgramListeSatiri {
il: p.il,
tur: p.tur,
sure: p.sure,
- sira2025: p.sira2025,
- sira2024: p.sira2024,
- sira2023: p.sira2023,
- sira2022: p.sira2022,
- sira2021: p.sira2021,
- kontenjan2025: p.kontenjan2025,
- yerlesen2025: p.yerlesen2025,
+ siraGecmisi: p.siraGecmisi,
+ efektifSira: p.efektifSira,
+ kontenjanSon: p.kontenjanSon,
+ yerlesenSon: p.yerlesenSon,
};
}
diff --git a/src/features/liste/liste-store.ts b/src/features/liste/liste-store.ts
index 0216cd3..0b30f43 100644
--- a/src/features/liste/liste-store.ts
+++ b/src/features/liste/liste-store.ts
@@ -158,10 +158,8 @@ export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[]
return liste;
}
-export function efektifSiraAl(
- p: Pick,
-): number | null {
- return p.sira2025 ?? p.sira2024 ?? null;
+export function efektifSiraAl(p: Pick): number | null {
+ return p.efektifSira ?? null;
}
/** Program satırını manuel liste kaydına çevirir. */
@@ -174,8 +172,7 @@ export function programdanManuelTercih(
| "il"
| "unitur"
| "tur"
- | "sira2025"
- | "sira2024"
+ | "efektifSira"
>,
adaySira?: number | null,
kaynak: ManuelKaynak = "sonuc",
diff --git a/src/features/rapor/types/rapor.ts b/src/features/rapor/types/rapor.ts
index 85045e3..385348d 100644
--- a/src/features/rapor/types/rapor.ts
+++ b/src/features/rapor/types/rapor.ts
@@ -34,8 +34,13 @@ export type RaporSonuc = {
// ayıklamak için (bkz. features/rapor/rapor-queries.ts)
devMock?: boolean;
// Render için havuzdan zenginleştirilmiş program bilgisi
- // efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
- // siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
+ // efektifSira: üretim anındaki son yılın sırası, yoksa bir önceki yılınki —
+ // eski kayıtlı raporlarda yok
+ // siraGecmisi: [ilkYil..] taban sıralamaları — deterministik trend oku.
+ // Uzunluk üretim anındaki veri penceresidir (eski kayıtlarda 5 eleman);
+ // okuyan taraf diziyi doldurmaz, yılı `ilkYil + indeks` ile bulur.
+ // ilkYil: dizinin ilk elemanının yılı — eski kayıtlarda yok (okurken
+ // lib/veri-yillari ILK_YIL)
programlar: Record<
string,
{
@@ -45,6 +50,7 @@ export type RaporSonuc = {
unitur: string | null;
efektifSira?: number | null;
siraGecmisi?: (number | null)[];
+ ilkYil?: number;
}
>;
};
diff --git a/src/lib/ai/rapor.ts b/src/lib/ai/rapor.ts
index 708fbc9..13b6adf 100644
--- a/src/lib/ai/rapor.ts
+++ b/src/lib/ai/rapor.ts
@@ -26,6 +26,7 @@ import {
riskNotuMetni,
} from "../risk";
import { trendOzetiMetni } from "../trend";
+import { ILK_YIL } from "../veri-yillari";
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
@@ -141,29 +142,27 @@ function normalizeEt(
function trendOzetiUret(p: AdayProgram): string {
// Cümle tadımlıkla ortak saf fonksiyondan gelir (lib/trend.ts)
- return trendOzetiMetni([
- p.sira2021,
- p.sira2022,
- p.sira2023,
- p.sira2024,
- p.sira2025,
- ]);
+ return trendOzetiMetni(p.siraGecmisi);
}
function riskNotuUret(p: AdayProgram, adaySira: number): string {
- return riskNotuMetni(riskHesapla(p.sira2025 ?? p.sira2024, adaySira));
+ return riskNotuMetni(riskHesapla(p.efektifSira, adaySira));
}
/** Havuz programını render için gereken bilgiye indirger. */
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
- const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
+ const gecmis = p.siraGecmisi;
+ const gecmisVar = gecmis.some((s) => s != null);
return {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
- efektifSira: p.sira2025 ?? p.sira2024,
- siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
+ efektifSira: p.efektifSira,
+ siraGecmisi: gecmisVar ? gecmis : undefined,
+ // Dizinin çapa yılı snapshot'a yazılır: kayıt, veri penceresi sonradan
+ // uzasa da kendi yıllarını bilir (eski kayıtlarda yok → okurken ILK_YIL).
+ ilkYil: gecmisVar ? ILK_YIL : undefined,
};
}
@@ -183,7 +182,7 @@ function mockRapor(
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
const hedef = dilimHedefiHesapla(havuz);
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
- const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
+ const efektif = (p: AdayProgram) => p.efektifSira ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
havuz
.filter((p) => p.dilim === dilim)
@@ -293,7 +292,7 @@ export async function raporUret(
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
const efektif = (id: string) => {
const p = havuzMap.get(id)!;
- return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
+ return p.efektifSira ?? Number.MAX_SAFE_INTEGER;
};
let secim: Record | null = null;
diff --git a/src/lib/db.ts b/src/lib/db.ts
index b6195d1..704b7c0 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -3,6 +3,7 @@
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
+import { EFEKTIF_SIRA_SQL, SON_YIL, YILLAR } from "./veri-yillari";
import {
PUAN_TURLERI,
type DilimKey,
@@ -27,12 +28,46 @@ export function getDb(): Database.Database {
return globalForDb.yokatlasDb;
}
+// Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB
+// dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır
+// `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir).
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
- sira2025, sira2024, sira2023, sira2022, sira2021,
- puan2025, kontenjan2025, yerlesen2025`;
+ ${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")},
+ puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon`;
-// 2025 sıralaması yoksa (az yerleşen/yeni program) 2024'e düşer
-export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
+// Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer.
+// İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts.
+export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL;
+
+/** SELECT_COLS ile okunan ham satır: sıra kolonları `s` takma adıyla gelir. */
+export type ProgramHamSatiri = Omit<
+ Program,
+ "siraGecmisi" | "efektifSira" | "siraSon"
+> &
+ Record<`s${number}`, number | null>;
+
+/** Ham DB satırını yılsız `Program`'a çevirir. */
+export function programSatiri(row: ProgramHamSatiri): Program {
+ const siraGecmisi = YILLAR.map((yil) => row[`s${yil}`] ?? null);
+ const siraSon = siraGecmisi[siraGecmisi.length - 1] ?? null;
+ return {
+ id: row.id,
+ isim: row.isim,
+ universite: row.universite,
+ unitur: row.unitur,
+ il: row.il,
+ fakulte: row.fakulte,
+ tur: row.tur,
+ sure: row.sure,
+ siraGecmisi,
+ // EFEKTIF_SIRA ile aynı kural: son yıl, yoksa bir önceki yıl
+ efektifSira: siraSon ?? siraGecmisi[siraGecmisi.length - 2] ?? null,
+ siraSon,
+ puanSon: row.puanSon,
+ kontenjanSon: row.kontenjanSon,
+ yerlesenSon: row.yerlesenSon,
+ };
+}
/**
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
@@ -112,18 +147,18 @@ export function dilimAra(
// 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
+ const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
)
- .all(...args, limit, offset) as Program[];
+ .all(...args, limit, offset) as ProgramHamSatiri[];
const toplam = (
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
adet: number;
}
).adet;
- return { programlar, toplam };
+ return { programlar: satirlar.map(programSatiri), toplam };
}
export function searchByRank(
diff --git a/src/lib/katalog.ts b/src/lib/katalog.ts
index a2ebbf8..ae4339c 100644
--- a/src/lib/katalog.ts
+++ b/src/lib/katalog.ts
@@ -1,5 +1,11 @@
import "server-only";
-import { getDb, SELECT_COLS, EFEKTIF_SIRA } from "./db";
+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";
@@ -24,7 +30,7 @@ export type BolumOzet = {
kategoriler: Kategori[];
lisans: boolean;
onlisans: boolean;
- /** MIN(COALESCE(sira2025, sira2024)) — en iyi (en küçük) taban sıralaması */
+ /** MIN(efektif sıra) — en iyi (en küçük) taban sıralaması */
enIyiSira: number | null;
};
@@ -131,13 +137,15 @@ export function getBolumBySlug(slug: string): BolumDetay | null {
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 })[];
+ 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");
@@ -156,14 +164,14 @@ export function getBolumBySlug(slug: string): BolumDetay | null {
if (p.unitur === "DEVLET") devlet += 1;
else if (p.unitur) vakif += 1;
else bilinmeyenTur += 1;
- const efektif = p.sira2025 ?? p.sira2024;
+ const efektif = p.efektifSira;
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;
+ if (p.puanSon != null) {
+ if (minPuan == null || p.puanSon < minPuan) minPuan = p.puanSon;
+ if (maxPuan == null || p.puanSon > maxPuan) maxPuan = p.puanSon;
}
}
@@ -357,13 +365,15 @@ export function getUniversiteBySlug(slug: string): UniDetay | null {
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 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;
@@ -373,11 +383,11 @@ export function getUniversiteBySlug(slug: string): UniDetay | null {
const liste = gruplar.get(anahtar);
if (liste) liste.push(p);
else gruplar.set(anahtar, [p]);
- const efektif = p.sira2025 ?? p.sira2024;
+ const efektif = p.efektifSira;
if (efektif != null && (minSira == null || efektif < minSira)) {
minSira = efektif;
}
- toplamKontenjan += p.kontenjan2025 ?? 0;
+ toplamKontenjan += p.kontenjanSon ?? 0;
}
const ozet = getAllUniversiteler().find((u) => u.slug === slug)!;
diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts
index 5453485..7efe92f 100644
--- a/src/lib/rapor-havuzu.ts
+++ b/src/lib/rapor-havuzu.ts
@@ -8,6 +8,7 @@ import {
} from "@/types/yokatlas";
import type { RaporParams } from "@/features/rapor/types/rapor";
import { kategoriEslesir } from "./kategoriler";
+import { SON_YIL, YILLAR } from "./veri-yillari";
export type Dilim = "hayal" | "dengeli" | "garanti";
@@ -155,6 +156,13 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
return { havuz: dilimBasinaKirp(ilsizHam, PROMPT_LIMIT_DILIM), gevsetildi };
}
+// Prompt'a veri penceresinin tamamı değil son 4 yılı gider (yeniden eskiye):
+// pencere uzadıkça satır başına token sayısı sabit kalır.
+const PROMPT_YIL_SAYISI = 4;
+const PROMPT_YIL_INDEKSLERI = YILLAR.map((_, i) => i)
+ .slice(-PROMPT_YIL_SAYISI)
+ .reverse();
+
/** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */
export function havuzuKompaktJson(havuz: AdayProgram[]): string {
return JSON.stringify(
@@ -165,14 +173,16 @@ export function havuzuKompaktJson(havuz: AdayProgram[]): string {
unitur: p.unitur,
il: p.il,
dilim: p.dilim,
- sira: {
- y2025: p.sira2025,
- y2024: p.sira2024,
- y2023: p.sira2023,
- y2022: p.sira2022,
- },
- kontenjan2025: p.kontenjan2025,
- yerlesen2025: p.yerlesen2025,
+ // Anahtarlar yıl taşır (y, kontenjan): model rakamın hangi
+ // yıla ait olduğunu anahtardan okur.
+ sira: Object.fromEntries(
+ PROMPT_YIL_INDEKSLERI.map((i) => [
+ `y${YILLAR[i]}`,
+ p.siraGecmisi[i] ?? null,
+ ]),
+ ),
+ [`kontenjan${SON_YIL}`]: p.kontenjanSon,
+ [`yerlesen${SON_YIL}`]: p.yerlesenSon,
})),
);
}
diff --git a/src/lib/rapor-maske.ts b/src/lib/rapor-maske.ts
index ffb70ad..8842b6a 100644
--- a/src/lib/rapor-maske.ts
+++ b/src/lib/rapor-maske.ts
@@ -44,7 +44,7 @@ export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
// kilitli metinler istemciye hiç ulaşmaz.
uyarilar: rapor.uyarilar?.slice(0, 1) ?? [],
kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1),
- // Program adı, üniversite ve 2021–2025 taban sıralamaları ham veridir.
+ // Program adı, üniversite ve geçmiş yılların taban sıralamaları ham veridir.
programlar: rapor.programlar,
// Kapsam (kaç program bulundu / dağılım uyarlandı mı) dürüstlük bilgisi:
// paketsiz kullanıcı da görür, maskelenmez.
diff --git a/src/lib/risk.ts b/src/lib/risk.ts
index 132e176..e025ac0 100644
--- a/src/lib/risk.ts
+++ b/src/lib/risk.ts
@@ -32,9 +32,10 @@ export const RISK_PIN_RENK: Record = {
};
/**
- * 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, ∞)).
+ * Deterministik risk: programın efektif taban sıralaması (son yılın sırası,
+ * yoksa bir önceki yılınki) 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, ∞)).
*/
export function riskHesapla(
efektifSira: number | null | undefined,
@@ -72,7 +73,8 @@ export function dilimdenFarkliRiskEtiketi(
* Risk seviyesinin tek cümlelik açıklaması (rapor satırları + tadımlık).
* Ürün yerleşme olasılığı HESAPLAMAZ; yalnızca son taban sıralamasını adayın
* sırasıyla kıyaslar. Bu yüzden metin olasılık/ihtimal/"yerleşirsin" demez.
- * "Geçen yıl" da denmez: efektif sıra 2025 yoksa 2024'e düşüyor.
+ * "Geçen yıl" da denmez: efektif sıra, son yılın verisi yoksa bir önceki
+ * yılınkine düşüyor.
*/
export function riskNotuMetni(risk: RiskSeviyesi | null): string {
switch (risk) {
diff --git a/src/lib/tadimlik-havuzu.ts b/src/lib/tadimlik-havuzu.ts
index 7b01b7b..90071fb 100644
--- a/src/lib/tadimlik-havuzu.ts
+++ b/src/lib/tadimlik-havuzu.ts
@@ -10,6 +10,7 @@ import type { Dilim } from "./rapor-havuzu";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import { dilimdenRisk, riskHesapla, riskNotuMetni } from "./risk";
import { trendOzetiMetni } from "./trend";
+import { VERI_YIL_SAYISI } from "./veri-yillari";
/** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */
export const GENEL_KATEGORI = "genel";
@@ -54,16 +55,41 @@ export type TadimlikTip = "genel" | "devlet" | "vakif";
* gerekce, satırın servis edilen TEK LLM metnidir (batch'te temsili sıraya
* göre yazıldı). Ürün yerleşme olasılığı hesaplamaz; bu yüzden olasılık /
* ihtimal / şans / garanti / kesin / yüzde / "yerleşirsin" diyen gerekçe
- * servis edilmez. Eski batch'in "son 4 yıl" kalıbı da takılır (veri 5 yılı
- * kapsıyor, bkz. veri-kapsami.ts). Kalıp küçük harfledir: metin önce Türkçe
- * kurallarıyla küçültülür ("İhtimal" → "ihtimal"; düz /i bayrağı İ/ı'yı
- * eşlemez).
+ * servis edilmez. Kalıp küçük harfledir: metin önce Türkçe kurallarıyla
+ * küçültülür ("İhtimal" → "ihtimal"; düz /i bayrağı İ/ı'yı eşlemez).
*/
const YASAK_GEREKCE_DILI =
- /olasılı|ihtimal|şans|garanti|kesin|yüzde|yerleşirsin|yerleşebil|son (4|dört) yıl/;
+ /olasılı|ihtimal|şans|garanti|kesin|yüzde|yerleşirsin|yerleşebil/;
+
+// Bayat batch kalıbı: gerekçe "son N yıl" diyorsa N, verinin bugün kapsadığı
+// yıl sayısı (VERI_YIL_SAYISI) olmalı. Veri penceresi uzayınca eski batch'in
+// "son 5 yıl"ı da, daha eskisinin "son 4 yıl"ı da kendiliğinden takılır.
+const SON_N_YIL = /son (\d+|[a-zçğıöşü]+) yıl/g;
+const SAYI_SOZCUKLERI: Record = {
+ bir: 1,
+ iki: 2,
+ üç: 3,
+ dört: 4,
+ beş: 5,
+ altı: 6,
+ yedi: 7,
+ sekiz: 8,
+ dokuz: 9,
+ on: 10,
+};
+
+function bayatYilSayisiVar(kucukMetin: string): boolean {
+ for (const [, ham] of kucukMetin.matchAll(SON_N_YIL)) {
+ const n = /^\d+$/.test(ham) ? Number(ham) : SAYI_SOZCUKLERI[ham];
+ // Sayı olmayan sözcük ("son birkaç yıl") yıl sayısı iddiası değildir
+ if (n != null && n !== VERI_YIL_SAYISI) return true;
+ }
+ return false;
+}
export function gerekceServisEdilebilir(gerekce: string): boolean {
- return !YASAK_GEREKCE_DILI.test(gerekce.toLocaleLowerCase("tr-TR"));
+ const kucuk = gerekce.toLocaleLowerCase("tr-TR");
+ return !YASAK_GEREKCE_DILI.test(kucuk) && !bayatYilSayisiVar(kucuk);
}
/**
@@ -108,10 +134,12 @@ export async function tadimlikSec(
// aynı formülden adayın gerçek sırasına göre; trend, rapordakiyle aynı
// saf fonksiyondan programın sıra geçmişinden okuma anında üretilir —
// metin rozetle çelişemez, "son 4 yıl" gibi bayat kalıp taşınmaz.
+ // Snapshot'ın sıra dizisi üretildiği günün penceresidir (eski
+ // satırlarda 5 eleman); yıllar dizinin kendi çapasından okunur.
riskNotu: riskNotuMetni(
riskHesapla(program.efektifSira, sira) ?? dilimdenRisk(satir.dilim),
),
- trendOzeti: trendOzetiMetni(program.siraGecmisi),
+ trendOzeti: trendOzetiMetni(program.siraGecmisi, program.ilkYil),
program,
};
}
diff --git a/src/lib/trend.ts b/src/lib/trend.ts
index 4e3c8bd..8221cda 100644
--- a/src/lib/trend.ts
+++ b/src/lib/trend.ts
@@ -4,12 +4,7 @@
// düzeyine indi"); yıl aralığı sabit bir "son N yıl" kalıbından değil,
// serideki dolu yıllardan gelir.
-/**
- * Sıra geçmişi dizisinin ilk elemanının yılı. Dizi her yerde 2021'den başlar
- * (bkz. programBilgisi, raporSiraSerisi); yeni veri yılı sona eklenir, bu
- * çapa değişmez.
- */
-const ILK_VERI_YILI = 2021;
+import { ILK_YIL } from "./veri-yillari";
/** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */
function siraKisa(n: number): string {
@@ -21,7 +16,10 @@ function siraKisa(n: number): string {
}
/**
- * [2021..] taban sıralaması dizisinden tek cümlelik trend özeti.
+ * [ilkYil..] taban sıralaması dizisinden tek cümlelik trend özeti. Dizi
+ * `ilkYil`'a demirlidir (varsayılan: veri penceresinin ilk yılı); yeni veri
+ * yılı sona eklenir, bu yüzden eski kayıtların daha kısa dizileri de doğru
+ * yılları verir. `ilkYil`, snapshot kendi çapasını taşıyorsa ondan gelir.
*
* Cümle yalnızca OLANI betimler: ilk ve son dolu yılın tabanını kıyaslar
* (±%10 eşiği). "Zorlaşıyor / kolaylaşıyor" gibi geleceğe dönük yargı
@@ -30,22 +28,23 @@ function siraKisa(n: number): string {
*/
export function trendOzetiMetni(
gecmis: readonly (number | null | undefined)[] | undefined,
+ ilkYil: number = ILK_YIL,
): string {
const seri: Array<[number, number]> = [];
(gecmis ?? []).forEach((sira, i) => {
- if (sira != null) seri.push([ILK_VERI_YILI + i, sira]);
+ if (sira != null) seri.push([ilkYil + i, sira]);
});
if (seri.length < 2) {
return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku.";
}
- const [ilkYil, ilkSira] = seri[0];
+ const [basYil, ilkSira] = seri[0];
const [sonYil, sonSira] = seri[seri.length - 1];
const oran = (sonSira - ilkSira) / ilkSira;
if (oran <= -0.1) {
- return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; taban sıkılaştı.`;
+ return `${basYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; taban sıkılaştı.`;
}
if (oran >= 0.1) {
- return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; taban gevşedi.`;
+ return `${basYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; taban gevşedi.`;
}
- return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`;
+ return `${basYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`;
}
diff --git a/src/lib/veri-kapsami.ts b/src/lib/veri-kapsami.ts
index 937f77a..f3a7f47 100644
--- a/src/lib/veri-kapsami.ts
+++ b/src/lib/veri-kapsami.ts
@@ -1,6 +1,6 @@
/**
- * Taban sıralaması geçmişinin kapsadığı yıl sayısı. Veritabanında
- * sira2021–sira2025 kolonları var (5 yıl); "son N yıl" diyen tüm metinler bu
- * sabitten okur. Yeni veri yılı eklenince (ör. sira2026) burayı güncelle.
+ * Taban sıralaması geçmişinin kapsadığı yıl sayısı; "son N yıl" diyen tüm
+ * metinler bu sabitten okur. Değer yıl kapsamının tek kaynağından
+ * (lib/veri-yillari.ts) türetilir — yeni veri yılı eklenince burası değişmez.
*/
-export const VERI_YIL_SAYISI = 5;
+export { VERI_YIL_SAYISI } from "./veri-yillari";
diff --git a/src/types/yokatlas.ts b/src/types/yokatlas.ts
index 55b1c9e..6e7d61b 100644
--- a/src/types/yokatlas.ts
+++ b/src/types/yokatlas.ts
@@ -11,14 +11,16 @@ export type Program = {
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;
+ /** Taban sıralamaları; `YILLAR` (lib/veri-yillari) ile indeks hizalı. */
+ siraGecmisi: (number | null)[];
+ /** Son yılın sırası; o yıl verisi yoksa bir önceki yılınki. */
+ efektifSira: number | null;
+ // "Son" = en son yerleştirme yılı (SON_YIL). Alan adları bilinçli yılsız:
+ // yeni veri yılı yüklenince tip ve tüketiciler değişmez.
+ siraSon: number | null;
+ puanSon: number | null;
+ kontenjanSon: number | null;
+ yerlesenSon: number | null;
};
export const PUAN_TURLERI = {
@@ -42,11 +44,15 @@ export type RankResults = {
};
/**
- * /sonuc ham tablosunun satırı: tablo `fakulte` ve `puan2025`'i hiç
+ * /sonuc ham tablosunun satırı: tablo `fakulte`, `puanSon` ve `siraSon`'u
+ * (dizinin son elemanı; tablo `efektifSira` + `siraGecmisi` ile çalışır) hiç
* kullanmadığı için bu alanlar client'a serileştirilmez (satır başına en
* uzun string alanlardan biri fakulte). Katalog sayfaları tam Program kullanır.
*/
-export type ProgramListeSatiri = Omit;
+export type ProgramListeSatiri = Omit<
+ Program,
+ "fakulte" | "puanSon" | "siraSon"
+>;
export type RankResultsListe = {
hayal: ProgramListeSatiri[];
From ceeb70375d14bfbb6476f147db699f5da58f2882 Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 17:00:39 +0300
Subject: [PATCH 5/9] =?UTF-8?q?feat(veri):=20bile=C5=9Fenler=20y=C4=B1ls?=
=?UTF-8?q?=C4=B1z=20Program'a=20ve=20veri-yillari=20s=C3=B6zl=C3=BC=C4=9F?=
=?UTF-8?q?=C3=BCne=20ge=C3=A7ti=20(P-B=20b)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SiraSerisi = { siraGecmisi, ilkYil? }; yardımcılar dizinin kendi son iki
elemanıyla çalışır, yıl ilkYil + indeks. raporSiraSerisi diziyi doldurmaz:
5 elemanlı eski raporlar 2024→2025 trendini göstermeye devam eder.
- Tablo başlıkları "Son ${VERI_YIL_SAYISI} yıl"; rapor listesi başlığı kaydın
kendi pencere uzunluğunu söyler (eski rapor: 5).
- siraYiliEtiketi SON_YIL/ONCEKI_YIL'dan; bölüm/üniversite sayfa metinleri ve
metadata SON_YIL, TERCIH_YILI, BASLIK_YILI sözlüğünden.
Co-Authored-By: Claude Fable 5.1
---
src/components/program-liste-verileri.tsx | 62 ++++++++++---------
.../katalog/components/bolum-icerik.tsx | 32 ++++++----
.../components/bolum-program-listesi.tsx | 25 ++++----
src/features/katalog/components/sayfalama.tsx | 18 ++++--
.../katalog/components/universite-icerik.tsx | 26 ++++----
.../components/universite-program-tablosu.tsx | 13 ++--
.../liste/components/program-ekle-butonu.tsx | 3 +-
.../liste/components/program-tablosu.tsx | 13 ++--
.../rapor/components/rapor-listesi.tsx | 22 +++++--
.../rapor/components/tadimlik-satiri.tsx | 2 +-
10 files changed, 127 insertions(+), 89 deletions(-)
diff --git a/src/components/program-liste-verileri.tsx b/src/components/program-liste-verileri.tsx
index a91029e..a4dde16 100644
--- a/src/components/program-liste-verileri.tsx
+++ b/src/components/program-liste-verileri.tsx
@@ -1,30 +1,37 @@
import { TrendingDown, TrendingUp } from "lucide-react";
-import type { Program } from "@/types/yokatlas";
+import { ILK_YIL } from "@/lib/veri-yillari";
-/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
- * rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
-export type SiraSerisi = Pick<
- Program,
- "sira2021" | "sira2022" | "sira2023" | "sira2024" | "sira2025"
->;
+/** Taban sıra geçmişinin yapısal alt kümesi: hem DB programı hem de rapordaki
+ * kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. Dizi `ilkYil`'a
+ * (yoksa veri penceresinin ilk yılına) demirlidir: eleman yılı
+ * `ilkYil + indeks`. Uzunluk sabit DEĞİL — eski kayıtlı raporlar daha kısa
+ * dizi taşır; yardımcılar dizinin kendi son iki elemanıyla çalışır. */
+export type SiraSerisi = {
+ siraGecmisi: readonly (number | null)[];
+ ilkYil?: number;
+};
-/** Rapordaki [2021..2025] dizisini sütunlu seriye çevirir. */
-export function raporSiraSerisi(gecmis?: (number | null)[]): SiraSerisi {
- const g = gecmis ?? [];
- return {
- sira2021: g[0] ?? null,
- sira2022: g[1] ?? null,
- sira2023: g[2] ?? null,
- sira2024: g[3] ?? null,
- sira2025: g[4] ?? null,
- };
+/** Rapordaki kayıtlı diziyi seriye çevirir. Dizi DOLDURULMAZ: 5 elemanlı eski
+ * kayıt 5 elemanlı kalır, "son iki yıl" kaydın kendi son iki yılıdır. */
+export function raporSiraSerisi(
+ gecmis?: (number | null)[],
+ ilkYil?: number,
+): SiraSerisi {
+ return { siraGecmisi: gecmis ?? [], ilkYil };
+}
+
+/** Serinin son iki yılı: [bir önceki, son]. */
+function sonIkiSira(program: SiraSerisi): [number | null, number | null] {
+ const g = program.siraGecmisi;
+ return [g[g.length - 2] ?? null, g[g.length - 1] ?? null];
}
export function programEtkinSira(program: SiraSerisi): number | null {
- return program.sira2025 ?? program.sira2024;
+ const [onceki, son] = sonIkiSira(program);
+ return son ?? onceki;
}
-/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
+/** Taban sıralaması geçmişini sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
// Satır başına render edilen bileşen: filter/map/min/max/map/map'in 6 ayrı
// geçişi tek döngüde toplanır (js-combine-iterations, js-min-max-loop)
@@ -32,14 +39,10 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
let min = Infinity;
let max = -Infinity;
const ozetParcalari: string[] = [];
- for (const [yil, sira] of [
- [2021, program.sira2021],
- [2022, program.sira2022],
- [2023, program.sira2023],
- [2024, program.sira2024],
- [2025, program.sira2025],
- ] as const) {
+ const ilkYil = program.ilkYil ?? ILK_YIL;
+ for (const [indeks, sira] of program.siraGecmisi.entries()) {
if (sira == null) continue;
+ const yil = ilkYil + indeks;
yillar.push([yil, sira]);
if (sira < min) min = sira;
if (sira > max) max = sira;
@@ -89,12 +92,13 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
export function ProgramTabanTrendi({ program }: { program: SiraSerisi }) {
- if (program.sira2025 == null || program.sira2024 == null) return null;
+ const [onceki, son] = sonIkiSira(program);
+ if (son == null || onceki == null) return null;
- const oran = (program.sira2024 - program.sira2025) / program.sira2024;
+ const oran = (onceki - son) / onceki;
if (Math.abs(oran) < 0.1) return null;
- const gecis = `${program.sira2024.toLocaleString("tr-TR")} → ${program.sira2025.toLocaleString("tr-TR")}`;
+ const gecis = `${onceki.toLocaleString("tr-TR")} → ${son.toLocaleString("tr-TR")}`;
if (oran > 0) {
return (
n.toLocaleString("tr-TR");
@@ -65,20 +71,20 @@ function bolumSayfaDilimi(bolum: BolumDetay, sayfa: number) {
let minSira: number | null = null;
let maxSira: number | null = null;
let verisiz = 0;
- // Sırası 2025'te olmayıp 2024'ten gelen satırlar: aralık metni yılı dürüstçe
- // söylesin (tablonun Sıralama sütunu bu satırlarda "—" gösterir).
- let yalniz2024 = 0;
+ // Sırası son yılda olmayıp bir önceki yıldan gelen satırlar: aralık metni
+ // yılı dürüstçe söylesin (tablonun Sıralama sütunu bu satırlarda "—" gösterir).
+ let yalnizOnceki = 0;
for (const p of dilim) {
- const efektif = p.sira2025 ?? p.sira2024;
+ const efektif = p.efektifSira;
if (efektif == null) {
verisiz += 1;
continue;
}
- if (p.sira2025 == null) yalniz2024 += 1;
+ if (p.siraSon == null) yalnizOnceki += 1;
if (minSira == null || efektif < minSira) minSira = efektif;
if (maxSira == null || efektif > maxSira) maxSira = efektif;
}
- return { tumProgramlar, dilim, minSira, maxSira, verisiz, yalniz2024 };
+ return { tumProgramlar, dilim, minSira, maxSira, verisiz, yalnizOnceki };
}
/** için kısa sıra: 2.425.061 → "2,43 mn", 95.400 → "95 bin". */
@@ -99,13 +105,13 @@ function siraAraligiMetni({
minSira,
maxSira,
verisiz,
- yalniz2024,
+ yalnizOnceki,
}: ReturnType): string | null {
if (dilim.length === 0) return null;
if (minSira == null || maxSira == null) {
return "taban verisi olmayan programlar";
}
- const yil = siraYiliEtiketi(yalniz2024, dilim.length - verisiz);
+ const yil = siraYiliEtiketi(yalnizOnceki, dilim.length - verisiz);
const aralik =
minSira === maxSira
? `${yil} taban sırası ${sayi(minSira)} olan programlar`
@@ -134,8 +140,8 @@ export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
const meta = sayfaMetadata({
title: baslik,
description: aralik
- ? `${bolum.ad} taban puanları, sayfa ${sayfa}: ${aralik}. Toplam ${stats.toplam} programın son yerleştirme verileri (2025; 2025'te verisi olmayanlarda 2024) — taban puanı, başarı sıralaması ve kontenjan.`
- : `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın 2025 yerleştirme verileri.`,
+ ? `${bolum.ad} taban puanları, sayfa ${sayfa}: ${aralik}. Toplam ${stats.toplam} programın son yerleştirme verileri (${SON_YIL}; o yıl verisi olmayanlarda ${ONCEKI_YIL}) — taban puanı, başarı sıralaması ve kontenjan.`
+ : `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın ${SON_YIL} yerleştirme verileri.`,
path: `/bolum/${slug}/sayfa/${sayfa}`,
openGraph: {
images: ustSegmentOgGorseli(
@@ -151,8 +157,8 @@ export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
? ` En iyi taban sıralaması ${sayi(stats.minSira)}.`
: "";
return sayfaMetadata({
- title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
- description: `${bolum.ad} 2025 yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} 2026 tercih rehberi.`,
+ title: `${bolum.ad} Taban Puanları ve Başarı Sıralamaları ${BASLIK_YILI}`,
+ description: `${bolum.ad} ${SON_YIL} yerleştirme verileri: ${stats.toplam} programın taban puanı ve sıralaması, ${stats.devlet} devlet / ${stats.vakif} vakıf.${enIyi} ${TERCIH_YILI} tercih rehberi.`,
path: `/bolum/${slug}`,
});
}
@@ -230,7 +236,7 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
{ilkSayfa ? (
- {sayi(tumProgramlar.length)} üniversite programının 2025 taban
+ {sayi(tumProgramlar.length)} üniversite programının {SON_YIL} taban
puanlarını, başarı sıralamalarını ve kontenjanlarını incele.
) : (
diff --git a/src/features/katalog/components/bolum-program-listesi.tsx b/src/features/katalog/components/bolum-program-listesi.tsx
index 2051159..f95057a 100644
--- a/src/features/katalog/components/bolum-program-listesi.tsx
+++ b/src/features/katalog/components/bolum-program-listesi.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/types/yokatlas";
import { trBaslikDuzeni } from "@/lib/slug";
+import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
import {
ProgramSiraGecmisi,
@@ -24,14 +25,14 @@ import {
const sayi = (n: number) => n.toLocaleString("tr-TR");
-type SiralamaAlani = "sira2025" | "puan2025" | "kontenjan2025";
+type SiralamaAlani = "siraSon" | "puanSon" | "kontenjanSon";
type SiralamaYonu = "artan" | "azalan";
export type BolumProgrami = Program & { universiteSlug: string | null };
const ALAN_ETIKETLERI: Record = {
- sira2025: "Sıralama",
- puan2025: "Puan",
- kontenjan2025: "Kontenjan",
+ siraSon: "Sıralama",
+ puanSon: "Puan",
+ kontenjanSon: "Kontenjan",
};
function SiralamaDugmesi({
@@ -157,11 +158,11 @@ export function BolumProgramListesi({
Üniversite
- Son 5 yıl
+ Son {VERI_YIL_SAYISI} yıl
- {p.puan2025 != null
- ? p.puan2025.toFixed(2).replace(".", ",")
+ {p.puanSon != null
+ ? p.puanSon.toFixed(2).replace(".", ",")
: "—"}
- {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
+ {p.kontenjanSon != null ? `${sayi(p.kontenjanSon)} kişi` : "—"}
- {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
+ {p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—"}
diff --git a/src/features/katalog/components/sayfalama.tsx b/src/features/katalog/components/sayfalama.tsx
index a7e56d3..bf2c546 100644
--- a/src/features/katalog/components/sayfalama.tsx
+++ b/src/features/katalog/components/sayfalama.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
+import { ONCEKI_YIL, SON_YIL } from "@/lib/veri-yillari";
/** Programatik tablo sayfaları için satır dilimi boyutu. */
export const SAYFA_BOYU = 50;
@@ -13,10 +14,19 @@ export function sayfaDilimi(liste: readonly T[], sayfa: number): T[] {
return liste.slice((sayfa - 1) * SAYFA_BOYU, sayfa * SAYFA_BOYU);
}
-/** Aralıktaki sıraların hangi yıldan geldiği: hepsi 2025 / hepsi 2024 / karışık. */
-export function siraYiliEtiketi(yalniz2024: number, siraliSayi: number): string {
- if (yalniz2024 === 0) return "2025";
- return yalniz2024 === siraliSayi ? "2024" : "2024–2025";
+/**
+ * Aralıktaki sıraların hangi yıldan geldiği: hepsi son yıl / hepsi bir önceki
+ * yıl / karışık. `yalnizOnceki`: sırası son yılda olmayıp bir önceki yıldan
+ * gelen satır sayısı; `siraliSayi`: sırası olan toplam satır.
+ */
+export function siraYiliEtiketi(
+ yalnizOnceki: number,
+ siraliSayi: number,
+): string {
+ if (yalnizOnceki === 0) return String(SON_YIL);
+ return yalnizOnceki === siraliSayi
+ ? String(ONCEKI_YIL)
+ : `${ONCEKI_YIL}–${SON_YIL}`;
}
/**
diff --git a/src/features/katalog/components/universite-icerik.tsx b/src/features/katalog/components/universite-icerik.tsx
index 50821ab..90da09d 100644
--- a/src/features/katalog/components/universite-icerik.tsx
+++ b/src/features/katalog/components/universite-icerik.tsx
@@ -34,6 +34,7 @@ import { UniLogo } from "@/components/uni-logo";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
import type { Program } from "@/types/yokatlas";
+import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -91,13 +92,13 @@ function fakulteOzeti({
if (adlar.length === 0) return null;
if (adlar.length === 1) {
const siralar = dilim
- .map((p) => p.sira2025 ?? p.sira2024)
+ .map((p) => p.efektifSira)
.filter((n): n is number => n != null);
if (siralar.length === 0) return adlar[0];
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const yil = siraYiliEtiketi(
- dilim.filter((p) => p.sira2025 == null && p.sira2024 != null).length,
+ dilim.filter((p) => p.siraSon == null && p.efektifSira != null).length,
siralar.length,
);
return min === max
@@ -116,10 +117,10 @@ export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
// farklılaştırıcı (o sayfadaki birimler) açıklamaya ve gövdeye gider.
const birimler = fakulteOzeti(uniSayfaDilimi(uni, sayfa));
return sayfaMetadata({
- title: `${uni.ad} Taban Puanları 2026 (Sayfa ${sayfa})`,
+ title: `${uni.ad} Taban Puanları ${BASLIK_YILI} (Sayfa ${sayfa})`,
description: birimler
- ? `${uni.ad} taban puanları, sayfa ${sayfa}: ${birimler}. Toplam ${uni.programSayisi} programın 2025 yerleştirme verileri — taban puanı, başarı sıralaması ve kontenjan.`
- : `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın 2025 yerleştirme verileri.`,
+ ? `${uni.ad} taban puanları, sayfa ${sayfa}: ${birimler}. Toplam ${uni.programSayisi} programın ${SON_YIL} yerleştirme verileri — taban puanı, başarı sıralaması ve kontenjan.`
+ : `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın ${SON_YIL} yerleştirme verileri.`,
path: `/universite/${slug}/sayfa/${sayfa}`,
openGraph: {
images: ustSegmentOgGorseli(
@@ -130,8 +131,8 @@ export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
});
}
return sayfaMetadata({
- title: `${uni.ad} Taban Puanları ve Başarı Sıralamaları 2026`,
- description: `${uni.ad} 2025 yerleştirme verileri: ${uni.programSayisi} programın taban puanları, başarı sıralamaları ve kontenjanları${uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}. 2026 tercih rehberi.`,
+ title: `${uni.ad} Taban Puanları ve Başarı Sıralamaları ${BASLIK_YILI}`,
+ description: `${uni.ad} ${SON_YIL} yerleştirme verileri: ${uni.programSayisi} programın taban puanları, başarı sıralamaları ve kontenjanları${uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}. ${TERCIH_YILI} tercih rehberi.`,
path: `/universite/${slug}`,
});
}
@@ -241,7 +242,7 @@ export function UniversiteIcerik({
- {uni.ad} Taban Puanları 2026
+ {uni.ad} Taban Puanları {BASLIK_YILI}
{ilkSayfa ? "" : ` — Sayfa ${sayfa}`}
@@ -250,7 +251,7 @@ export function UniversiteIcerik({
<>
{uni.ad}
- {uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}, 2025 YKS
+ {uni.il ? ` (${trBaslikDuzeni(uni.il)})` : ""}, {SON_YIL} YKS
yerleştirme sonuçlarına göre{" "}
{sayi(uni.programSayisi)} programda öğrenci aldı
{uni.fakulteSayisi > 1
@@ -267,10 +268,11 @@ export function UniversiteIcerik({
{uni.stats.toplamKontenjan > 0 ? (
<> Toplam kontenjan {sayi(uni.stats.toplamKontenjan)}.>
) : null}{" "}
- Aşağıdaki tablolar 2026 tercihleri için rehber niteliğindedir;{" "}
+ Aşağıdaki tablolar {TERCIH_YILI} tercihleri için rehber
+ niteliğindedir;{" "}
- 2026 taban puanları ve sıralamaları, yerleştirme sonuçları
- açıklandığında netleşir.
+ {TERCIH_YILI} taban puanları ve sıralamaları, yerleştirme
+ sonuçları açıklandığında netleşir.
diff --git a/src/features/katalog/components/universite-program-tablosu.tsx b/src/features/katalog/components/universite-program-tablosu.tsx
index 34f35b3..eb7408d 100644
--- a/src/features/katalog/components/universite-program-tablosu.tsx
+++ b/src/features/katalog/components/universite-program-tablosu.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
import type { Program } from "@/types/yokatlas";
+import { SON_YIL, VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -48,10 +49,10 @@ export function UniversiteProgramTablosu({
Program
- Son 5 yıl
+ Son {VERI_YIL_SAYISI} yıl
- 2025 puan
+ {SON_YIL} puan
Kontenjan
@@ -93,13 +94,13 @@ export function UniversiteProgramTablosu({
- {p.puan2025 != null
- ? p.puan2025.toFixed(2).replace(".", ",")
+ {p.puanSon != null
+ ? p.puanSon.toFixed(2).replace(".", ",")
: "—"}
- {p.kontenjan2025 != null
- ? `${sayi(p.kontenjan2025)} kişi`
+ {p.kontenjanSon != null
+ ? `${sayi(p.kontenjanSon)} kişi`
: "—"}
diff --git a/src/features/liste/components/program-ekle-butonu.tsx b/src/features/liste/components/program-ekle-butonu.tsx
index 95613cc..7cf0bd1 100644
--- a/src/features/liste/components/program-ekle-butonu.tsx
+++ b/src/features/liste/components/program-ekle-butonu.tsx
@@ -24,8 +24,7 @@ type ProgramOzet = Pick<
| "il"
| "unitur"
| "tur"
- | "sira2025"
- | "sira2024"
+ | "efektifSira"
>;
const PROFILSIZ_EKLEME_TOAST_ID = "profilsiz-ekleme";
diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx
index e71c009..c889950 100644
--- a/src/features/liste/components/program-tablosu.tsx
+++ b/src/features/liste/components/program-tablosu.tsx
@@ -22,6 +22,7 @@ import type {
} from "@/types/yokatlas";
import { DILIM_ETIKET, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
+import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -239,7 +240,7 @@ export function ProgramTablosu({
Program
- Son 5 yıl
+ Son {VERI_YIL_SAYISI} yıl
Kontenjan
@@ -357,19 +358,19 @@ function ProgramSatiri({
- {p.kontenjan2025 != null ? (
+ {p.kontenjanSon != null ? (
- {p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? (
+ {p.yerlesenSon != null && p.yerlesenSon < p.kontenjanSon ? (
boş kaldı
) : null}
- {p.kontenjan2025.toLocaleString("tr-TR")} kişi
+ {p.kontenjanSon.toLocaleString("tr-TR")} kişi
) : (
"—"
diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx
index b8af439..0a59dc7 100644
--- a/src/features/rapor/components/rapor-listesi.tsx
+++ b/src/features/rapor/components/rapor-listesi.tsx
@@ -2,7 +2,7 @@
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
-// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
+// aynı satır düzeni, aynı "Son N yıl" / "Taban sıra" sütunları. Buradaki fark
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
@@ -27,6 +27,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
+import { VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import {
DILIM_ETIKET,
@@ -47,7 +48,7 @@ const RISK_STIL: Record =
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
};
-// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
+// Sütun sayısı: # · Program · Son N yıl · Risk · Taban sıra · detay oku
const SUTUN_SAYISI = 6;
/**
@@ -161,6 +162,19 @@ export function RaporListesi({
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
+ // Sütun başlığı kaydın KENDİ penceresini söyler: eski raporların sıra
+ // geçmişi üretildiği günün yıl sayısını taşır (dizi doldurulmaz), başlık
+ // çizgide olmayan bir yılı vaat etmesin. Geçmişi hiç olmayan kayıtta
+ // bugünkü pencereye düşer.
+ const yilSayisi = useMemo(() => {
+ let enUzun = 0;
+ for (const t of rapor.tercihler) {
+ const n = rapor.programlar[t.programId]?.siraGecmisi?.length ?? 0;
+ if (n > enUzun) enUzun = n;
+ }
+ return enUzun > 0 ? enUzun : VERI_YIL_SAYISI;
+ }, [rapor]);
+
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
@@ -168,7 +182,7 @@ export function RaporListesi({
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
- const seri = raporSiraSerisi(p?.siraGecmisi);
+ const seri = raporSiraSerisi(p?.siraGecmisi, p?.ilkYil);
const taban = p?.efektifSira ?? programEtkinSira(seri);
const acikMi = acik.has(t.sira);
const devlet = p?.unitur === "DEVLET";
@@ -366,7 +380,7 @@ export function RaporListesi({
#
Program
- Son 5 yıl
+ Son {yilSayisi} yıl
Yapay Zeka Risk
diff --git a/src/features/rapor/components/tadimlik-satiri.tsx b/src/features/rapor/components/tadimlik-satiri.tsx
index 592984a..bc72ea8 100644
--- a/src/features/rapor/components/tadimlik-satiri.tsx
+++ b/src/features/rapor/components/tadimlik-satiri.tsx
@@ -105,7 +105,7 @@ export function TadimlikSatiri({
const p = satir.program;
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
- const seri = raporSiraSerisi(p.siraGecmisi);
+ const seri = raporSiraSerisi(p.siraGecmisi, p.ilkYil);
const taban = p.efektifSira ?? programEtkinSira(seri);
const devlet = p.unitur === "DEVLET";
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref())}`;
From 85ed972c68d37f72e01f2d2ddfd3d8b5024410fd Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 17:00:39 +0300
Subject: [PATCH 6/9] =?UTF-8?q?feat(tadimlik):=20batch=20=C3=BCretimi=20y?=
=?UTF-8?q?=C4=B1ls=C4=B1z=20alanlarla;=20snapshot'a=20ilkYil=20(P-B=20c)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Prompt ve mock metni "son ${VERI_YIL_SAYISI} yıl" der; program seçimi
yerlesenSon'a göre; snapshot rapor.ts programBilgisi ile aynı şekilde
siraGecmisi + ilkYil yazar.
Co-Authored-By: Claude Fable 5.1
---
scripts/tadimlik-uret.ts | 22 +++++++++++++---------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/scripts/tadimlik-uret.ts b/scripts/tadimlik-uret.ts
index 697c6dd..f27ed2c 100644
--- a/scripts/tadimlik-uret.ts
+++ b/scripts/tadimlik-uret.ts
@@ -38,6 +38,7 @@ import {
} from "../src/lib/tadimlik-havuzu";
import { anahtarVar } from "../src/lib/ai/client";
import { yapilandirilmisUret } from "../src/lib/ai/cagri";
+import { ILK_YIL, VERI_YIL_SAYISI } from "../src/lib/veri-yillari";
// Next dışında koşuyoruz; .env dosyalarını elle yükle (mevcut env kazanır).
function envYukle(dosya: string): void {
@@ -72,8 +73,8 @@ KURALLAR (kesin):
riskli" etiketleriyle konuş.
- Programın "dilim" alanındaki "garanti" değeri teknik anahtardır; adaya dönük
hiçbir metinde bu kelimeyi kullanma, o dilimi "güvenli" diye an.
-- trendOzeti: son 5 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
- beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
+- trendOzeti: son ${VERI_YIL_SAYISI} yılın taban sıralaması hareketi, TEK cümle
+ (ör. "2022'den beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
- Adayın sıralaması yaklaşıktır; kesin sıra telaffuz etme, banda göre konuş.`;
function mockAktif(): boolean {
@@ -87,27 +88,30 @@ function mockMetin(p: Program, temsiliSira: number, kategori: string | null) {
riskNotu:
"(Dev mock) Son taban sıralaması sırana yakın; küçük bir kayma sonucu değiştirebilir.",
trendOzeti:
- "(Dev mock) Son 5 yılda taban sıralaması görece stabil seyretti.",
+ `(Dev mock) Son ${VERI_YIL_SAYISI} yılda taban sıralaması görece stabil seyretti.`,
};
}
/** rapor.ts programBilgisi ile aynı snapshot şekli. */
function programBilgisi(p: Program) {
- const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
+ const gecmis = p.siraGecmisi;
+ const gecmisVar = gecmis.some((s) => s != null);
return {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
- efektifSira: p.sira2025 ?? p.sira2024,
- siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
+ efektifSira: p.efektifSira,
+ siraGecmisi: gecmisVar ? gecmis : undefined,
+ ilkYil: gecmisVar ? ILK_YIL : undefined,
};
}
/**
* Deterministik program seçimi: kovanın temsili sırası için "dengeli" dilimden
- * (boşsa "garanti") kategoriye ve üniversite tipine uyan, 2025'te en çok
- * yerleşen alan program. Tanınır/dolu programlar tadımlıkta daha ikna edici.
+ * (boşsa "garanti") kategoriye ve üniversite tipine uyan, son yerleştirme
+ * yılında en çok yerleşen alan program. Tanınır/dolu programlar tadımlıkta
+ * daha ikna edici.
*/
function programSec(
temsiliSira: number,
@@ -125,7 +129,7 @@ function programSec(
: programlar;
if (uygunlar.length === 0) continue;
const secilen = [...uygunlar].sort(
- (a, b) => (b.yerlesen2025 ?? -1) - (a.yerlesen2025 ?? -1),
+ (a, b) => (b.yerlesenSon ?? -1) - (a.yerlesenSon ?? -1),
)[0];
return { program: secilen, dilim };
}
From a773ac7beb9cd77fddde3e8a696c02bd547c0c8a Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 17:03:01 +0300
Subject: [PATCH 7/9] =?UTF-8?q?feat(veri):=202026=20y=C3=BCkleme=20hatt?=
=?UTF-8?q?=C4=B1=20=E2=80=94=20y=C4=B1l=20kilitli=20refresh,=20ge=C3=A7mi?=
=?UTF-8?q?=C5=9F=20doldurma,=20ar=C5=9Fiv,=20kalite=20kap=C4=B1s=C4=B1=20?=
=?UTF-8?q?(P-A)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- yokatlas-goc: idempotent şema göçü (yıl kolonları, son_kilavuz_yili, eski_kod,
programs_arsiv, veri_meta) + betiklerin ortak yardımcıları
- refresh: --yil/--db zorunlu, YIL KİLİDİ (…1 = DB sira ≥%95 ve eksiz ≠),
yıl asla r.yil'den türetilmez; ham veri data/ham/*.jsonl.gz, --ham ile API'siz
tekrar; benzersiz kod = totalElements; tek transaction + önceki yıl sağlaması;
yeni programda …1/2/3 → üç önceki yıl; efektif indeks EFEKTIF_SIRA_SQL ile
- gecmis-doldur: yalnız NULL alan; API …1/2/3 → eski_kod → kanıtlı 1:1 ad eşleşmesi
- eski-arsivle: güncel kılavuzda olmayanlar → programs_arsiv, netler yeni koda; --kuru
- detay: netler bağımsız + yıl sağlaması; kırılım yalnız --kirilim, alan yoksa durur,
mevcut değerin üstüne NULL yazılmaz; `yil - 1` varsayımı kalktı
- veri-kalite: salt okunur kalite kapısı, zorunlu kontrol kalırsa çıkış kodu 1
- docs/veri/yillik-guncelleme.md: adım adım runbook + geri alma
DB commit'lenmedi (arşivleme kararı [Bilal]).
Co-Authored-By: Claude Fable 5.1
---
.gitignore | 3 +
docs/veri/yillik-guncelleme.md | 230 +++++++++++++
package.json | 5 +
scripts/db-index.ts | 111 ++++---
scripts/detay.ts | 350 +++++++++++---------
scripts/eski-arsivle.ts | 228 +++++++++++++
scripts/gecmis-doldur.ts | 302 +++++++++++++++++
scripts/refresh.ts | 571 +++++++++++++++++++++++++++------
scripts/veri-kalite.ts | 228 +++++++++++++
scripts/yokatlas-goc.ts | 267 +++++++++++++++
10 files changed, 2016 insertions(+), 279 deletions(-)
create mode 100644 docs/veri/yillik-guncelleme.md
create mode 100644 scripts/eski-arsivle.ts
create mode 100644 scripts/gecmis-doldur.ts
create mode 100644 scripts/veri-kalite.ts
create mode 100644 scripts/yokatlas-goc.ts
diff --git a/.gitignore b/.gitignore
index 09ea8f9..eed9051 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,6 @@ next-env.d.ts
# Python
__pycache__/
*.pyc
+
+# YÖK Atlas ham API çekimleri (scripts/refresh.ts; ~yıllık, büyük, yeniden üretilebilir)
+data/ham/
diff --git a/docs/veri/yillik-guncelleme.md b/docs/veri/yillik-guncelleme.md
new file mode 100644
index 0000000..eab6772
--- /dev/null
+++ b/docs/veri/yillik-guncelleme.md
@@ -0,0 +1,230 @@
+# Yıllık veri güncellemesi (YÖK Atlas → `data/yokatlas.db`)
+
+Her yıl yerleştirme sonuçları açıklandıktan sonra **bir kez** yapılır. Bu sayfayı
+yukarıdan aşağıya izleyen biri, betiklerin içini bilmeden güncellemeyi
+tamamlayabilir. Toplam süre: yaklaşık **30 dakika** (çoğu bekleme ve gözle kontrol).
+
+> Ürünün bütün değeri bu verinin doğruluğuna dayanır. Yanlış bir taban sıralaması,
+> bir öğrencinin yanlış tercih yapması demektir. Bu yüzden betikler şüphede
+> **durur**; "DURDU:" ile başlayan bir mesaj hata değil, emniyet kilididir.
+> Kilidi aşmaya çalışma — mesajı oku, aşağıdaki "Bir şey durursa" tablosuna bak.
+
+Örneklerde yeni yıl `2027` diye yazıldı; o yıl hangisiyse onu yaz.
+
+## 0. Ne zaman?
+
+- ÖSYM yerleştirme sonuçlarını açıklar (genelde **Ağustos sonu**), YÖK Atlas verisini
+ birkaç gün–birkaç hafta sonra günceller. Tarih her yıl değişir → **doğrulanmalı**.
+- Erken koşmanın zararı yok: YÖK Atlas henüz güncellenmediyse 3. adım
+ "`… hâlâ 2026 verisi`" diyerek **hiçbir şey yazmadan** durur. Bir hafta sonra tekrar dene.
+- Dosya indirmen gerekmez. Veri, YÖK Atlas'ın herkese açık tercih kılavuzu
+ servisinden (`https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search`) betikle çekilir:
+ 43 istek, aralarında yarım saniye — kaynağı yormaz. Betiği art arda defalarca koşma;
+ ilk çekimden sonra hep kaydedilmiş ham dosyayı (`--ham`) kullan.
+
+## 1. Hazırlık (5 dk)
+
+```bash
+git switch -c veri/2027 # main'de çalışma
+```
+
+`src/lib/veri-yillari.ts` dosyasında **tek satırı** değiştir:
+
+```ts
+export const SON_YIL = 2027;
+```
+
+Betikler ve site yılı yalnız buradan okur. Betiğe verdiğin `--yil` bununla aynı
+değilse betik durur (yanlış kolona yazmayı önler).
+
+## 2. Kopya al — asıl dosyaya dokunma (1 dk)
+
+Dev sunucusu açıksa kapat. Sonra:
+
+```bash
+mkdir -p /tmp/veri-2027
+sqlite3 data/yokatlas.db ".backup '/tmp/veri-2027/once.db'" # güncelleme ÖNCESİ hâl (geri dönüş + karşılaştırma)
+cp /tmp/veri-2027/once.db /tmp/veri-2027/yeni.db # üzerinde çalışılacak kopya
+```
+
+Bundan sonraki her adım **`/tmp/veri-2027/yeni.db`** üzerinde koşar. `data/yokatlas.db`
+ancak 8. adımda, her şey yeşilken değişir.
+
+## 3. Yeni yılı çek (3 dk)
+
+```bash
+pnpm refresh --yil 2027 --db /tmp/veri-2027/yeni.db
+```
+
+Ne yapar: şemaya `sira2027/puan2027/kontenjan2027/yerlesen2027` kolonlarını ekler,
+**yıl kilidini** sınar, tüm kılavuzu çeker, ham hâlini `data/ham/2027-.jsonl.gz`
+olarak saklar (git'e girmez), sonra tek seferde DB'ye yazar.
+
+Görmen gerekenler:
+
+```
+Yıl kilidi: basariSirasi1 = sira2026 → 480/480 (%100.0); eksiz basariSirasi = sira2026 → 0/480 (%0.0)
+Çekilen: 21… kayıt, benzersiz kod: 21…, kodsuz: 0, API toplamı: 21… ← üç sayı aynı
+Güncellenen: 19…, eklenen: 1…, atlanan: 0
+```
+
+- **Yıl kilidi** şunu kanıtlar: servisin "bir önceki yıl" diye verdiği sıralar bizim
+ 2026 kolonumuzla birebir aynı **ve** "bu yıl" diye verdikleri ondan farklı. Yani
+ yazacağımız veri gerçekten 2027'dir. (Servisteki `yil` alanına güvenilmez; 2026'da
+ bu alan yüzünden 2025 verisi bozulmak üzereydi.)
+- "atlanan" 0 değilse satırlar altında listelenir: kaynağın puan türü vermediği yeni
+ programlardır; uydurulmaz. Sayıyı not et.
+- Bu betik mevcut programların **eski yıl kolonlarına dokunmaz**; dokunursa kendi
+ sağlaması bunu yakalar ve her şeyi geri alır.
+
+Aynı işlemi servise gitmeden tekrarlamak için (ör. 8. adımda):
+`pnpm refresh --yil 2027 --db --ham data/ham/2027-.jsonl.gz`
+
+## 4. Geçmiş boşluklarını doldur (1 dk)
+
+```bash
+pnpm gecmis-doldur --yil 2027 --db /tmp/veri-2027/yeni.db
+```
+
+Kılavuz kodu değişen ya da yeni koda taşınan programların eski yıllarını tamamlar.
+Yalnız **boş** alanı doldurur; dolu değeri asla ezmez, tahmin yapmaz. Kaynak sırası:
+servisin kendi geçmiş alanları → servisin bildirdiği eski kod → aynı üniversitede
+bire bir aynı adlı ve en az bir yılı kanıtla örtüşen tek satır.
+
+Çıktının sonundaki "**Dolu olup kaynağın FARKLI söylediği alanlar**" listesine bak.
+Birkaç onluk fark olağandır (kaynak eski sıraları ufak düzeltmelerle yeniden yayımlıyor;
+2026'da `sira2024` farklarının %95'i on binde birin altındaydı). **Binlerce satırda büyük
+fark** görürsen dur ve veri mühendisine sor.
+
+## 5. Kılavuzdan çıkan programlar: önce sadece raporla (1 dk)
+
+```bash
+pnpm eski-arsivle --yil 2027 --db /tmp/veri-2027/yeni.db --kuru
+```
+
+Hiçbir şey yazmaz. Şunu söyler: bu yılın kılavuzunda **olmayan** kaç satır var ve
+bunlar siteden kalkarsa hangi `/bolum/...` ve `/universite/...` sayfaları tamamen
+kapanır (adlarıyla). Bu liste **[BİLAL] kararıdır** — sayfa kapanışı SEO'yu etkiler,
+gerekirse yönlendirme (redirect) eklenir.
+
+Onaydan sonra gerçek taşıma (silmez; satırlar `programs_arsiv` tablosuna geçer):
+
+```bash
+pnpm eski-arsivle --yil 2027 --db /tmp/veri-2027/yeni.db
+```
+
+Neden gerekli: arşivlenmezse eski kodlu satırlar listelerde bir yıl önceki sırayla
+görünmeye devam eder; öğrenci o kodu tercih ekranında bulamaz, kodu değişen program
+iki kez listelenir.
+
+## 6. İndeks + dosyayı toparla (1 dk)
+
+```bash
+pnpm db:index --db /tmp/veri-2027/yeni.db --vacuum
+```
+
+Son satırda `USING INDEX idx_programs_tur_onlisans_efektif` yazmalı. Yazmıyorsa betik
+zaten hata verir: sitedeki sıralama aralığı sorguları yavaşlar.
+
+## 7. Kalite kapısı (1 dk)
+
+```bash
+pnpm veri-kalite --yil 2027 --db /tmp/veri-2027/yeni.db --onceki /tmp/veri-2027/once.db --arsivli
+echo "çıkış kodu: $?" # 0 olmalı
+```
+
+(`--arsivli`'yi yalnız 5. adımda gerçek arşivlemeyi yaptıysan ekle.)
+
+- Her satır `GEÇTİ`, `KALDI`, `UYARI` ya da `bilgi` ile başlar. **Tek bir `KALDI` varsa
+ yayına çıkılmaz**; çıkış kodu 1 olur.
+- En önemli iki satır:
+ - `önceki DB: geçmiş yıllar korunmuş — … değişen/silinen satır: 0`
+ - `sira2027/sira2026 ∈ [0,5; 2] — … (%9x)` — programların büyük çoğunluğunun sırası
+ bir yılda yarıya inmez, iki katına çıkmaz. Oran düşükse yıllar kaymıştır.
+- `UYARI`'lar bilgilendirir (ör. ili boş 110 program, yetim net satırları); yeni bir
+ uyarı türü ya da sayıda sıçrama varsa veri mühendisine göster.
+
+Elle 3 program seç (biri çok bilinen: ör. Boğaziçi Bilgisayar Mühendisliği), YÖK Atlas
+sitesindeki 2027 taban sırasıyla karşılaştır:
+
+```bash
+sqlite3 /tmp/veri-2027/yeni.db "SELECT id, universite, isim, sira2026, sira2027, puan2027 FROM programs WHERE isim LIKE 'Bilgisayar Mühendisliği%' AND universite LIKE 'BOĞAZİÇİ%';"
+```
+
+## 8. Yerine koy ve siteyi kontrol et (5 dk)
+
+```bash
+cp /tmp/veri-2027/yeni.db data/yokatlas.db
+rm -f data/yokatlas.db-wal data/yokatlas.db-shm # eski dosyanın artıkları; yenisi temiz (checkpoint yapılmış)
+pnpm build # katalog sayfaları yeni veriden üretilir; hata vermemeli
+```
+
+Netler (yerleşen son kişinin netleri) ayrı bir servisten gelir ve genelde daha geç
+güncellenir; hazır olduğunda:
+
+```bash
+pnpm detay --yil 2027 --db data/yokatlas.db # ~127 istek, ~2 dk; yıl sağlaması tutmazsa durur
+```
+
+`--kirilim` bayrağını **kullanma**: kontenjan kırılımını (okul birincisi, şehit-gazi…)
+sitede okuyan kod yok ve servis bu alanları Eylül 2026'dan beri yayımlamıyor. Bayrak
+verilse bile alanlar yoksa adım kendini durdurur, mevcut değerlerin üstüne boş yazmaz.
+
+Yıl değişince koddaki metinler (`"son 6 yıl"`, başlıklardaki yıl vb.) `veri-yillari.ts`
+sabitlerinden türediği için kendiliğinden güncellenir. Elle yıl yazılmış yer kaldıysa bul:
+
+```bash
+grep -rnE "sira20[0-9]{2}|20(2[1-9])[–-]20" src scripts --include='*.ts' --include='*.tsx' | grep -v veri-yillari
+```
+
+Tadımlık havuzu ve kayıtlı raporlar eski sıralarla üretilmiştir → yayından sonra prod'da
+`pnpm tadimlik --force` (Bilal koşar; ayrıntı `scripts/tadimlik-uret.ts` başlığında).
+
+## 9. Commit ve yayın
+
+```bash
+git add src/lib/veri-yillari.ts data/yokatlas.db
+git commit -m "veri: 2027 yerleştirme verisi"
+```
+
+`data/ham/` git'e girmez (yerelde sakla; bir sonraki yıl karşılaştırma için işe yarar).
+Push öncesi `AGENTS.md`'deki "Push öncesi build zorunlu" kuralı geçerli. Yayın kararı Bilal'in.
+
+## Geri alma
+
+| Ne zaman | Nasıl | Süre |
+|---|---|---|
+| 8. adımdan önce | Hiçbir şey yapma; `data/yokatlas.db` hiç değişmedi. `/tmp/veri-2027/` silinebilir. | 0 |
+| 8. adımdan sonra, commit'ten önce | `git checkout -- data/yokatlas.db data/yokatlas.db-wal data/yokatlas.db-shm` | 1 dk |
+| Commit/yayından sonra | `git revert ` + yeniden yayın. `SON_YIL` da aynı commit'te geri döner; site bir önceki yılın verisiyle tutarlı çalışır. | 10 dk |
+| Yalnız arşivlemeyi geri almak | `INSERT INTO programs SELECT * FROM programs_arsiv; DELETE FROM programs_arsiv;` (kopyada dene, sonra `pnpm db:index`). Netler yeni kodda kalır; zararsız. | 5 dk |
+
+`/tmp/veri-2027/once.db` dosyasını yayından bir hafta sonrasına kadar silme.
+
+## Bir şey durursa
+
+| Mesaj | Anlamı | Ne yap |
+|---|---|---|
+| `--yil … ≠ SON_YIL` | 1. adım atlanmış | `veri-yillari.ts`'i güncelle |
+| `eksiz alanları hâlâ verisi` | YÖK Atlas henüz yeni yılı yayımlamamış | Bekle, bir hafta sonra tekrar dene |
+| `"…1" alanları DB'deki … verisiyle eşleşmiyor` | Servis bir yıl daha ilerlemiş (bir yılı atladık) **ya da** servis biçim değiştirmiş **ya da** yanlış DB | Yazma. Veri mühendisine göster |
+| `Benzersiz kod sayısı … ≠ API toplamı` | Çekim sırasında servis sayfaları kaydırdı | Birkaç dakika sonra 3. adımı tekrarla (DB'ye bir şey yazılmadı) |
+| `Aynı üniversitede birden fazla unitur` | Bir kurumun türü iki farklı yazılmış | Listelenen kurumları veri mühendisine göster; `scripts/unitur-onar.ts` |
+| `Güncel satır sayısı … refresh kaydıyla uyuşmuyor` (arşivle) | refresh yarım kalmış | 3. adımı tekrarla; arşivleme bilerek çalışmıyor |
+| `veri-kalite` içinde `KALDI` | O kontrol tutmadı | Yayına çıkma; satırdaki sayıyı ve örnek kodları veri mühendisine ilet |
+
+## Betikler ve dokundukları
+
+| Betik | Yazar mı? | Ne yazar |
+|---|---|---|
+| `scripts/yokatlas-goc.ts` (`pnpm yokatlas:goc`) | evet | Yalnız şema: yıl kolonları, `son_kilavuz_yili`, `eski_kod`, `programs_arsiv`, `veri_meta`. refresh bunu kendisi çağırır. |
+| `scripts/refresh.ts` | evet | `sira/puan/kontenjan/yerlesen`, `son_kilavuz_yili`, `eski_kod`; yeni programlar; ham dosya |
+| `scripts/gecmis-doldur.ts` | evet | Yalnız **NULL** geçmiş yıl alanları + `eski_kod` |
+| `scripts/eski-arsivle.ts` | `--kuru` ile hayır | Satır taşıma `programs` → `programs_arsiv`; `netler.program_id` yeni koda |
+| `scripts/db-index.ts` | evet | İndeks, `--vacuum`, WAL checkpoint |
+| `scripts/veri-kalite.ts` | **hayır** | Salt okunur; çıkış kodu 0/1 |
+| `scripts/detay.ts` | evet | `netler`; `--kirilim` ile (alanlar varsa) bir önceki yılın kırılım kolonları |
+
+Kolon sözlüğü: `son_kilavuz_yili` = programın görüldüğü son tercih kılavuzu (NULL = 2024
+ve öncesi, bilinmiyor). `eski_kod` = kılavuz kodu değiştiyse bir önceki kod — kayıtlı
+listelerdeki eski kodları yeni koda çevirmek için `SELECT id FROM programs WHERE eski_kod = ?`.
diff --git a/package.json b/package.json
index 61c60e9..a015fa9 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,11 @@
"ingest": "tsx scripts/ingest.ts",
"refresh": "tsx scripts/refresh.ts",
"detay": "tsx scripts/detay.ts",
+ "yokatlas:goc": "tsx scripts/yokatlas-goc.ts",
+ "gecmis-doldur": "tsx scripts/gecmis-doldur.ts",
+ "eski-arsivle": "tsx scripts/eski-arsivle.ts",
+ "veri-kalite": "tsx scripts/veri-kalite.ts",
+ "db:index": "tsx scripts/db-index.ts",
"logolar": "tsx scripts/logo-indir.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
diff --git a/scripts/db-index.ts b/scripts/db-index.ts
index 0fd6c43..58259f6 100644
--- a/scripts/db-index.ts
+++ b/scripts/db-index.ts
@@ -1,44 +1,83 @@
-// Sorgu yolundaki ifade indexi + WAL bakımı. Bir kez çalıştırılır
-// (pnpm tsx scripts/db-index.ts); ingest/refresh sonrası da güvenle
-// tekrar çalıştırılabilir (IF NOT EXISTS).
+// Sorgu yolundaki ifade indexi + WAL bakımı. refresh sonrası (ve istenince tek
+// başına) çalıştırılır; tekrar çalıştırmak güvenlidir.
//
-// src/lib/db.ts tüm aralık sorgularını COALESCE(sira2025, sira2024)
-// üzerinden yapar; SQLite ifade indexleri METİNSEL eşleştiği için buradaki
-// ifade oradaki EFEKTIF_SIRA sabitiyle birebir aynı olmalıdır. Bu index
-// olmadan her sorgu tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
+// src/lib/db.ts tüm aralık sorgularını EFEKTIF_SIRA_SQL üzerinden yapar;
+// SQLite ifade indexleri METİNSEL eşleştiği için indeks ifadesi
+// src/lib/veri-yillari.ts'teki sabitle birebir aynı olmalıdır — bu yüzden
+// buraya elle yazılmaz, import edilir. Bu index olmadan her sorgu
+// tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
+//
+// Kullanım: pnpm db:index --db data/yokatlas.db [--vacuum]
+// --vacuum: arşivleme gibi büyük silmelerden sonra dosyayı küçültür (site kapalıyken).
-import path from "node:path";
-import Database from "better-sqlite3";
+import { EFEKTIF_SIRA_SQL } from "../src/lib/veri-yillari";
+import { bayrakVar, calistir, dbAc, dbArgumani, dur, type Db } from "./yokatlas-goc";
-const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
+export const EFEKTIF_INDEKS = "idx_programs_tur_onlisans_efektif";
-db.exec(`
- CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
- ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
-`);
+/**
+ * Efektif sıra indeksini güncel ifadeyle kurar. Eski yılın ifadesiyle kurulmuş
+ * aynı adlı indeks varsa DROP edip yeniden kurar (yıl değişince ifade değişir).
+ */
+export function efektifIndeksiKur(db: Db): "kuruldu" | "yenilendi" | "guncel" {
+ const mevcut = db
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
+ .get(EFEKTIF_INDEKS) as { sql: string } | undefined;
+ let sonuc: "kuruldu" | "yenilendi" | "guncel" = "kuruldu";
+ if (mevcut) {
+ if (mevcut.sql.includes(EFEKTIF_SIRA_SQL)) return "guncel";
+ db.exec(`DROP INDEX ${EFEKTIF_INDEKS}`);
+ sonuc = "yenilendi";
+ }
+ db.exec(
+ `CREATE INDEX ${EFEKTIF_INDEKS} ON programs (tur, onlisans, ${EFEKTIF_SIRA_SQL})`
+ );
+ return sonuc;
+}
-// Programatik SEO sayfaları (src/lib/katalog.ts) bölüm/üniversite detaylarını
-// ham ada birebir IN (...) eşleşmesiyle çeker; bu iki index olmadan her
-// sayfa tam tablo taraması yapar.
-db.exec(`
- CREATE INDEX IF NOT EXISTS idx_programs_isim ON programs (isim);
- CREATE INDEX IF NOT EXISTS idx_programs_universite ON programs (universite);
-`);
+/** Sihirbaz/liste aralık sorgusunun planı (src/lib/db.ts dilimAra ile aynı biçim). */
+export function efektifSorguPlani(db: Db): string[] {
+ const plan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT id FROM programs
+ WHERE tur = ? AND onlisans = ?
+ AND ${EFEKTIF_SIRA_SQL} IS NOT NULL
+ AND ${EFEKTIF_SIRA_SQL} BETWEEN ? AND ?
+ ORDER BY ${EFEKTIF_SIRA_SQL} ASC LIMIT 10`
+ )
+ .all("SAYISAL", 0, 1000, 50000) as { detail: string }[];
+ return plan.map((p) => p.detail);
+}
-// Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
-// (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
-db.pragma("wal_checkpoint(TRUNCATE)");
+if (process.argv[1]?.endsWith("db-index.ts")) {
+ calistir(() => {
+ const db = dbAc(dbArgumani());
+ console.log(`Efektif indeks (${EFEKTIF_SIRA_SQL}): ${efektifIndeksiKur(db)}`);
-const plan = db
- .prepare(
- `EXPLAIN QUERY PLAN
- SELECT id FROM programs
- WHERE tur = ? AND onlisans = ?
- AND COALESCE(sira2025, sira2024) IS NOT NULL
- AND COALESCE(sira2025, sira2024) BETWEEN ? AND ?
- ORDER BY COALESCE(sira2025, sira2024) ASC LIMIT 10`,
- )
- .all("SAYISAL", 0, 1000, 50000);
+ // Programatik SEO sayfaları (src/lib/katalog.ts) bölüm/üniversite
+ // detaylarını ham ada birebir IN (...) eşleşmesiyle çeker; bu iki index
+ // olmadan her sayfa tam tablo taraması yapar.
+ db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_programs_isim ON programs (isim);
+ CREATE INDEX IF NOT EXISTS idx_programs_universite ON programs (universite);
+ `);
-console.log("Sorgu planı:", JSON.stringify(plan, null, 2));
-db.close();
+ if (bayrakVar("vacuum")) {
+ db.exec("VACUUM");
+ console.log("VACUUM tamam.");
+ }
+
+ // Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
+ // (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
+ db.pragma("wal_checkpoint(TRUNCATE)");
+
+ const plan = efektifSorguPlani(db);
+ console.log("Sorgu planı:");
+ for (const satir of plan) console.log(` ${satir}`);
+ db.close();
+ if (!plan.some((s) => s.includes(EFEKTIF_INDEKS))) {
+ dur(`Sorgu planı ${EFEKTIF_INDEKS} indeksini kullanmıyor.`);
+ }
+ });
+}
diff --git a/scripts/detay.ts b/scripts/detay.ts
index 502b3c0..6db1b69 100644
--- a/scripts/detay.ts
+++ b/scripts/detay.ts
@@ -1,45 +1,71 @@
/**
- * Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler:
+ * Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler.
+ * İki BAĞIMSIZ adım:
*
- * 1. Kontenjan türü kırılımı (son yerleştirme yılı, ör. 2025):
- * Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın
- * için kontenjan + yerleşen. Kaynak: POST /api/tercih-kilavuz/search
- * (gk1/gkY1, obk1/obkY1, sgy1/sgyY1, dprm1/dprmY1, y34_1/y34Y1 —
- * "1" son yerleştirme yılı demek; kayıttaki `yil` kılavuz yılıdır,
- * veri yılı = yil - 1).
+ * A) Yerleşen son kişinin netleri (varsayılan; `--netler-atla` ile kapatılır)
+ * Kaynak: POST /api/netler/search → `netler` tablosu. Bu API her satırda
+ * verinin ait olduğu yılı `yil` alanında verir; betik bunu körlemesine
+ * kabul etmez: ilk sayfada `tabanPuan`, DB'deki puan ile ≥%95
+ * eşleşmiyorsa DURUR (yıl etiketi kaymış demektir).
*
- * 2. Yerleşen son kişinin netleri (yıl bazında, tüm puan türleri):
- * Kaynak: POST /api/netler/search → `netler` tablosu.
+ * B) Kontenjan türü kırılımı — YALNIZ `--kirilim` bayrağıyla (varsayılan KAPALI)
+ * Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın için
+ * kontenjan + yerleşen; API'nin `…1` alanları → kolonları.
+ * `--yil`'in kendi kırılım kolonları BİLEREK yok (src/ içinde okuyan kod
+ * yok; KARARLAR 2026-09-21).
+ * Emniyet: (1) refresh ile aynı YIL KİLİDİ; (2) beklenen on alanın HER
+ * BİRİ ilk sayfada en az bir kayıtta bulunmalı — API null alanları hiç
+ * göndermediği için "ilk kayıt" tek başına yanıltır; bir alan ilk sayfanın
+ * tamamında `undefined` ise API o alanı artık yayımlamıyordur ve adım
+ * HİÇBİR ŞEY yazmadan DURUR; (3) yazım `COALESCE(yeni, mevcut)` —
+ * mevcut değerin üstüne asla NULL yazılmaz.
+ * (Eylül 2026: API `gkY1/obk1/…` alanlarını yayımlamıyor → bu adım durur;
+ * 2025 kırılımı DB'de Ağustos 2026 çekiminden duruyor, korunur.)
+ *
+ * Yıl HİÇBİR adımda kılavuz kaydındaki `yil` alanından türetilmez.
*
* Not: YÖK Atlas 2026'da SPA'ya geçti; eski content/lisans-dynamic/*.php
* panelleri (ve onlarla birlikte kadın-erkek dağılımı) yayından kalktı.
- * Cinsiyet verisi yeni API'lerin hiçbirinde yok.
*
- * Kullanım: npx tsx scripts/detay.ts
+ * Kullanım:
+ * pnpm detay --yil 2026 --db /tmp/deneme.db
+ * pnpm detay --yil 2026 --db /tmp/deneme.db --kirilim --netler-atla
*/
-import path from "node:path";
-import Database from "better-sqlite3";
+import type Database from "better-sqlite3";
+import { ILK_YIL } from "../src/lib/veri-yillari";
+import { yilKilidi, type KilavuzKaydi } from "./refresh";
+import {
+ argOku,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ yilArgumani,
+ type Db,
+} from "./yokatlas-goc";
const KILAVUZ_API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const NETLER_API = "https://yokatlas.yok.gov.tr/api/netler/search";
const PAGE_SIZE = 500;
-const DELAY_MS = 300;
+const DELAY_MS = 500;
-type KilavuzRecord = {
- kilavuzKodu: number;
- eskiKilavuzKodu?: number | null;
- yil: number;
- gk1?: number | null;
- gkY1?: number | null;
- obk1?: number | null;
- obkY1?: number | null;
- sgy1?: number | null;
- sgyY1?: number | null;
- dprm1?: number | null;
- dprmY1?: number | null;
- y34_1?: number | null;
- y34Y1?: number | null;
-};
+/** API alanı → DB kolon öneki. `…1` = bir önceki yerleştirme yılı. */
+const KIRILIM_ALANLARI = [
+ ["gk1", "gk"],
+ ["gkY1", "gkY"],
+ ["obk1", "obk"],
+ ["obkY1", "obkY"],
+ ["sgy1", "sgy"],
+ ["sgyY1", "sgyY"],
+ ["dprm1", "dprm"],
+ ["dprmY1", "dprmY"],
+ ["y34_1", "y34k"],
+ ["y34Y1", "y34kY"],
+] as const;
+
+type KirilimKaydi = KilavuzKaydi & Record;
type NetlerRecord = {
kilavuzKodu: number;
@@ -113,96 +139,9 @@ const NET_COLS = [
"ydtYdilNet",
] as const;
-async function main() {
- const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
- db.pragma("journal_mode = WAL");
+// ── A) Netler ────────────────────────────────────────────────────────────
- // ── 1. Kontenjan türü kırılımı ─────────────────────────────────────────
- const first = await fetchPage(KILAVUZ_API, 0);
- // API kaydındaki yil kılavuz yılı; "1" ekli alanlar bir önceki
- // yerleştirme dönemine ait (2026 kılavuzu → 2025 verisi)
- const veriYili = first.content[0].yil - 1;
-
- const kontCols = [
- `gk${veriYili}`,
- `gkY${veriYili}`,
- `obk${veriYili}`,
- `obkY${veriYili}`,
- `sgy${veriYili}`,
- `sgyY${veriYili}`,
- `dprm${veriYili}`,
- `dprmY${veriYili}`,
- `y34k${veriYili}`,
- `y34kY${veriYili}`,
- ];
- const mevcut = new Set(
- (db.prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
- (c) => c.name
- )
- );
- for (const col of kontCols) {
- if (!mevcut.has(col)) {
- db.exec(`ALTER TABLE programs ADD COLUMN ${col} INTEGER`);
- }
- }
-
- const updateKont = db.prepare(`
- UPDATE programs SET ${kontCols.map((c) => `${c} = ?`).join(", ")}
- WHERE id = ?
- `);
- const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
-
- // Kılavuz kodu yıllar arasında değişebiliyor (eskiKilavuzKodu); netler
- // API'si yeni kodla döndüğü için eşleşen DB id'sini haritada tutuyoruz.
- const dbId = new Map();
- let kontUpdated = 0;
- let kontMissing = 0;
-
- const processKilavuz = db.transaction((records: KilavuzRecord[]) => {
- for (const r of records) {
- if (!r.kilavuzKodu) continue;
- let id = String(r.kilavuzKodu);
- if (!exists.get(id)) {
- const eski = r.eskiKilavuzKodu ? String(r.eskiKilavuzKodu) : null;
- if (eski && exists.get(eski)) {
- id = eski;
- } else {
- kontMissing++;
- continue;
- }
- }
- dbId.set(r.kilavuzKodu, id);
- updateKont.run(
- num(r.gk1),
- num(r.gkY1),
- num(r.obk1),
- num(r.obkY1),
- num(r.sgy1),
- num(r.sgyY1),
- num(r.dprm1),
- num(r.dprmY1),
- num(r.y34_1),
- num(r.y34Y1),
- id
- );
- kontUpdated++;
- }
- });
-
- console.log(
- `Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${first.totalPages} sayfa…`
- );
- processKilavuz(first.content);
- for (let p = 1; p < first.totalPages; p++) {
- await sleep(DELAY_MS);
- processKilavuz((await fetchPage(KILAVUZ_API, p)).content);
- process.stdout.write(`\rkontenjan sayfa ${p + 1}/${first.totalPages}`);
- }
- console.log(
- `\nKontenjan kırılımı: ${kontUpdated} güncellendi, ${kontMissing} DB'de yok`
- );
-
- // ── 2. Yerleşen son kişinin netleri ────────────────────────────────────
+async function netlerAdimi(db: Db, yil: number, azamiSayfa: number): Promise {
db.exec(`
CREATE TABLE IF NOT EXISTS netler (
program_id TEXT NOT NULL,
@@ -214,54 +153,165 @@ async function main() {
);
`);
- const insertNet = db.prepare(`
- INSERT OR REPLACE INTO netler (program_id, yil, ${NET_COLS.join(", ")})
- VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")})
- `);
+ const first = await fetchPage(NETLER_API, 0);
- let netRows = 0;
+ // Yıl etiketi sağlaması: netler.tabanPuan(yil=Y) ≈ programs.puanY
+ const programKolonlari = new Set(kolonAdlari(db, "programs"));
+ const puanGetir = new Map>();
+ let kiyas = 0;
+ let esit = 0;
+ for (const r of first.content) {
+ if (!r.kilavuzKodu || !r.yil || num(r.tabanPuan) == null) continue;
+ if (r.yil < ILK_YIL || r.yil > yil || !programKolonlari.has(`puan${r.yil}`)) continue;
+ let st = puanGetir.get(r.yil);
+ if (!st) {
+ st = db.prepare(`SELECT puan${r.yil} AS p FROM programs WHERE id = ?`);
+ puanGetir.set(r.yil, st);
+ }
+ const satir = st.get(String(r.kilavuzKodu)) as { p: number | null } | undefined;
+ if (!satir || satir.p == null) continue;
+ kiyas++;
+ if (Math.abs(satir.p - (r.tabanPuan as number)) <= 0.01) esit++;
+ }
+ const oran = kiyas ? esit / kiyas : 0;
+ console.log(`Netler yıl sağlaması: tabanPuan = puan → ${esit}/${kiyas} (%${(oran * 100).toFixed(1)})`);
+ if (kiyas < 50 || oran < 0.95) {
+ dur(
+ "Netler API'sindeki `yil` etiketi DB'deki yıl kolonlarıyla örtüşmüyor " +
+ "(ya da karşılaştıracak veri yok). Netler yazılmadı."
+ );
+ }
+
+ // Mevcut değerin üstüne NULL yazılmaz: çakışmada COALESCE(yeni, eski).
+ const upsert = db.prepare(`
+ INSERT INTO netler (program_id, yil, ${NET_COLS.join(", ")})
+ VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")})
+ ON CONFLICT(program_id, yil) DO UPDATE SET
+ ${NET_COLS.map((c) => `${c} = COALESCE(excluded.${c}, ${c})`).join(",\n ")}
+ `);
+ const programVar = db.prepare("SELECT 1 FROM programs WHERE id = ?");
+
+ let yazilan = 0;
+ let programYok = 0;
+ let yilDisi = 0;
const processNetler = db.transaction((records: NetlerRecord[]) => {
for (const r of records) {
if (!r.kilavuzKodu || !r.yil) continue;
- const id = dbId.get(r.kilavuzKodu) ?? String(r.kilavuzKodu);
- insertNet.run(
+ if (r.yil < ILK_YIL || r.yil > yil) {
+ yilDisi++;
+ continue;
+ }
+ const id = String(r.kilavuzKodu);
+ // Yetim satır üretme: program DB'de yoksa (refresh koşulmamış / atlanmış) sayıp geç.
+ if (!programVar.get(id)) {
+ programYok++;
+ continue;
+ }
+ upsert.run(
id,
r.yil,
r.puanTuru?.trim() ?? null,
...NET_COLS.slice(1).map((c) => num(r[c as keyof NetlerRecord]))
);
- netRows++;
+ yazilan++;
}
});
- const netFirst = await fetchPage(NETLER_API, 0);
- console.log(
- `Netler: ${netFirst.totalElements} kayıt, ${netFirst.totalPages} sayfa…`
- );
- processNetler(netFirst.content);
- for (let p = 1; p < netFirst.totalPages; p++) {
+ const sayfa = Math.min(first.totalPages, azamiSayfa);
+ console.log(`Netler: ${first.totalElements} kayıt, ${first.totalPages} sayfa${sayfa < first.totalPages ? ` (yalnız ilk ${sayfa})` : ""}…`);
+ processNetler(first.content);
+ for (let p = 1; p < sayfa; p++) {
await sleep(DELAY_MS);
processNetler((await fetchPage(NETLER_API, p)).content);
- process.stdout.write(`\rnetler sayfa ${p + 1}/${netFirst.totalPages}`);
+ process.stdout.write(`\rnetler sayfa ${p + 1}/${sayfa}`);
}
- console.log(`\nNetler: ${netRows} satır yazıldı`);
-
- const ozet = db
- .prepare(
- `SELECT
- (SELECT COUNT(*) FROM programs WHERE gkY${veriYili} IS NOT NULL) AS kontlu,
- (SELECT COUNT(*) FROM netler) AS net,
- (SELECT COUNT(DISTINCT program_id) FROM netler) AS netli_program`
- )
- .get() as { kontlu: number; net: number; netli_program: number };
+ const dagilim = db
+ .prepare("SELECT yil, COUNT(*) AS n FROM netler GROUP BY yil ORDER BY yil")
+ .all() as { yil: number; n: number }[];
console.log(
- `Özet → kontenjan kırılımı olan program: ${ozet.kontlu}, ` +
- `net satırı: ${ozet.net} (${ozet.netli_program} program)`
+ `\nNetler: ${yazilan} satır yazıldı/güncellendi; programı DB'de olmayan: ${programYok}; yıl aralığı dışı: ${yilDisi}`
);
- db.close();
+ console.log(`Netler yıl dağılımı: ${dagilim.map((d) => `${d.yil}=${d.n}`).join(" ")}`);
}
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
+// ── B) Kontenjan kırılımı ────────────────────────────────────────────────
+
+async function kirilimAdimi(db: Db, yil: number, azamiSayfa: number): Promise {
+ const veriYili = yil - 1; // `…1` alanları; yıl kilidiyle doğrulanır, r.yil'e bakılmaz
+ const first = await fetchPage(KILAVUZ_API, 0);
+ yilKilidi(db, yil, first.content);
+
+ const eksik = KIRILIM_ALANLARI.filter(
+ ([alan]) => !first.content.some((r) => r[alan] !== undefined)
+ ).map(([alan]) => alan);
+ if (eksik.length) {
+ dur(
+ `Kırılım alanları API'de yok (ilk sayfanın tamamında undefined): ${eksik.join(", ")}. ` +
+ `${veriYili} kırılım kolonlarına DOKUNULMADI (mevcut değerler korunuyor).`
+ );
+ }
+
+ const kontCols = KIRILIM_ALANLARI.map(([, onek]) => `${onek}${veriYili}`);
+ const mevcut = new Set(kolonAdlari(db, "programs"));
+ for (const col of kontCols) {
+ if (!mevcut.has(col)) db.exec(`ALTER TABLE programs ADD COLUMN ${col} INTEGER`);
+ }
+ const updateKont = db.prepare(`
+ UPDATE programs SET ${kontCols.map((c) => `${c} = COALESCE(?, ${c})`).join(", ")}
+ WHERE id = ?
+ `);
+ const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
+
+ let guncellenen = 0;
+ let dbdeYok = 0;
+ const isle = db.transaction((records: KirilimKaydi[]) => {
+ for (const r of records) {
+ if (!r.kilavuzKodu) continue;
+ const id = String(r.kilavuzKodu);
+ if (!exists.get(id)) {
+ dbdeYok++;
+ continue;
+ }
+ updateKont.run(...KIRILIM_ALANLARI.map(([alan]) => num(r[alan])), id);
+ guncellenen++;
+ }
+ });
+
+ const sayfa = Math.min(first.totalPages, azamiSayfa);
+ console.log(`Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${sayfa} sayfa…`);
+ isle(first.content);
+ for (let p = 1; p < sayfa; p++) {
+ await sleep(DELAY_MS);
+ isle((await fetchPage(KILAVUZ_API, p)).content);
+ process.stdout.write(`\rkırılım sayfa ${p + 1}/${sayfa}`);
+ }
+ console.log(`\nKontenjan kırılımı: ${guncellenen} güncellendi, ${dbdeYok} DB'de yok`);
+}
+
+async function main() {
+ const yil = yilArgumani();
+ const db = dbAc(dbArgumani());
+ // Yalnız deneme için: kaynağı yormadan ilk N sayfayla dur.
+ const azamiSayfa = Number.parseInt(argOku("azami-sayfa") ?? "", 10) || Number.POSITIVE_INFINITY;
+
+ let hata: unknown = null;
+ if (!bayrakVar("netler-atla")) {
+ try {
+ await netlerAdimi(db, yil, azamiSayfa);
+ } catch (err) {
+ hata = err; // adımlar bağımsız: netler dursa da kırılım (istenmişse) denenir
+ console.error(`Netler adımı durdu: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+ if (bayrakVar("kirilim")) {
+ await kirilimAdimi(db, yil, azamiSayfa);
+ } else {
+ console.log("Kontenjan kırılımı atlandı (yalnız --kirilim ile koşar).");
+ }
+
+ db.pragma("wal_checkpoint(TRUNCATE)");
+ db.close();
+ if (hata) throw hata;
+}
+
+if (process.argv[1]?.endsWith("detay.ts")) calistir(main);
diff --git a/scripts/eski-arsivle.ts b/scripts/eski-arsivle.ts
new file mode 100644
index 0000000..fe0d4eb
--- /dev/null
+++ b/scripts/eski-arsivle.ts
@@ -0,0 +1,228 @@
+/**
+ * Güncel tercih kılavuzunda OLMAYAN programları `programs` → `programs_arsiv`
+ * tablosuna taşır (silmez; aynı şema, aynı değerler). Kodu değişen programların
+ * çift satırı (eski kod + yeni kod) böylece sorgu koşuluyla değil DB inşasında
+ * çözülür: site yalnız bu yıl tercih edilebilen programları görür.
+ *
+ * `netler.program_id`: arşive giden eski kodun TEK bir güncel karşılığı varsa
+ * (programs.eski_kod) yeni koda çevrilir; yeni kodda o yıl zaten net satırı
+ * varsa eski satır olduğu gibi bırakılır (veri silinmez).
+ *
+ * ÖNCE `--kuru` ile koş: hiçbir şey yazmaz; kaç satır gideceğini ve hangi bölüm
+ * / üniversite sayfalarının (slug) tamamen kapanacağını adlarıyla listeler.
+ * Gerçek koşu [BİLAL] onayı ister — sayfalar siteden kalkar.
+ *
+ * Kullanım:
+ * pnpm eski-arsivle --yil 2026 --db /tmp/deneme.db --kuru
+ * pnpm eski-arsivle --yil 2026 --db /tmp/deneme.db
+ */
+import { bolumBazAdi, turkishSlugify, uniSayfaSlug } from "../src/lib/slug";
+import {
+ arsivTablosunuGarantiEt,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ metaOku,
+ metaYaz,
+ tabloVar,
+ yilArgumani,
+} from "./yokatlas-goc";
+
+type Ad = { ad: string; n: number };
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const kuru = bayrakVar("kuru");
+ const db = dbAc(dbYolu, { saltOkunur: kuru });
+ console.log(`DB: ${dbYolu}${kuru ? "\nKURU KİP: hiçbir şey yazılmayacak" : ""}`);
+
+ if (!kolonAdlari(db, "programs").includes("son_kilavuz_yili")) {
+ dur("son_kilavuz_yili kolonu yok: önce refresh koşulmalı.");
+ }
+
+ // ── Emniyet: refresh bu yıl için tam koşmuş olmalı ─────────────────────
+ const apiToplam = Number(metaOku(db, `refresh_${yil}_api_toplam`) ?? NaN);
+ const atlanan = Number(metaOku(db, `refresh_${yil}_atlanan`) ?? NaN);
+ const guncel = (
+ db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
+ ).n;
+ if (!Number.isFinite(apiToplam) || guncel !== apiToplam - atlanan) {
+ dur(
+ `Güncel satır sayısı (${guncel}) refresh kaydıyla (API ${apiToplam} − atlanan ${atlanan}) uyuşmuyor. ` +
+ "Yarım bir refresh'in üstüne arşivleme yapılmaz — güncel programlar da gidebilir."
+ );
+ }
+
+ // NOT: `<>` NULL'ları kaçırır; son_kilavuz_yili bilinmeyen (NULL) satırlar da eskidir.
+ const ESKI = "son_kilavuz_yili IS NOT ?";
+ const eskiSayi = (
+ db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${ESKI}`).get(yil) as { n: number }
+ ).n;
+ console.log(`\nGüncel (${yil} kılavuzunda): ${guncel} Arşivlenecek: ${eskiSayi}`);
+ if (eskiSayi === 0) {
+ console.log("Arşivlenecek satır yok.");
+ db.close();
+ return;
+ }
+
+ const kirilim = db
+ .prepare(
+ `SELECT COALESCE(CAST(son_kilavuz_yili AS TEXT), 'bilinmiyor (≤2024)') AS y, COUNT(*) AS n,
+ COUNT(sira${yil - 1}) AS onceki_sirali
+ FROM programs WHERE ${ESKI} GROUP BY son_kilavuz_yili ORDER BY son_kilavuz_yili`
+ )
+ .all(yil) as { y: string; n: number; onceki_sirali: number }[];
+ for (const k of kirilim) {
+ console.log(` son kılavuz yılı ${k.y}: ${k.n} satır (${k.onceki_sirali} tanesinde sira${yil - 1} dolu)`);
+ }
+
+ const halefli = (
+ db
+ .prepare(
+ `SELECT COUNT(*) AS n FROM programs e WHERE e.${ESKI}
+ AND EXISTS (SELECT 1 FROM programs g WHERE g.eski_kod = e.id AND g.son_kilavuz_yili = ?)`
+ )
+ .get(yil, yil) as { n: number }
+ ).n;
+ console.log(
+ ` yeni kodla devam eden (eski_kod eşleşmesi): ${halefli}; karşılığı olmayan (kapanmış/ad değişmiş): ${eskiSayi - halefli}`
+ );
+
+ // ── SEO: tamamen kaybolan bölüm / üniversite sayfaları ─────────────────
+ const eskiIsimler = db
+ .prepare(`SELECT isim AS ad, COUNT(*) AS n FROM programs WHERE ${ESKI} GROUP BY isim`)
+ .all(yil) as Ad[];
+ const guncelIsimler = db
+ .prepare("SELECT DISTINCT isim AS ad FROM programs WHERE son_kilavuz_yili = ?")
+ .all(yil) as { ad: string }[];
+ const guncelHamIsim = new Set(guncelIsimler.map((r) => r.ad));
+ const guncelBolumSlug = new Set(guncelIsimler.map((r) => turkishSlugify(bolumBazAdi(r.ad))));
+ const kaybolanHamIsim = eskiIsimler.filter((r) => !guncelHamIsim.has(r.ad));
+ const kaybolanBolum = new Map();
+ for (const r of eskiIsimler) {
+ const baz = bolumBazAdi(r.ad);
+ const slug = turkishSlugify(baz);
+ if (!slug || guncelBolumSlug.has(slug)) continue;
+ const k = kaybolanBolum.get(slug) ?? { ad: baz, n: 0 };
+ k.n += r.n;
+ kaybolanBolum.set(slug, k);
+ }
+
+ const eskiUniler = db
+ .prepare(`SELECT universite AS ad, COUNT(*) AS n FROM programs WHERE ${ESKI} GROUP BY universite`)
+ .all(yil) as Ad[];
+ const guncelUniler = db
+ .prepare("SELECT DISTINCT universite AS ad FROM programs WHERE son_kilavuz_yili = ?")
+ .all(yil) as { ad: string }[];
+ const guncelHamUni = new Set(guncelUniler.map((r) => r.ad));
+ const guncelUniSlug = new Set(guncelUniler.map((r) => uniSayfaSlug(r.ad)));
+ const kaybolanHamUni = eskiUniler.filter((r) => !guncelHamUni.has(r.ad));
+ const kaybolanUni = new Map();
+ for (const r of eskiUniler) {
+ const slug = uniSayfaSlug(r.ad);
+ if (!slug || guncelUniSlug.has(slug)) continue;
+ const k = kaybolanUni.get(slug) ?? { ad: r.ad, n: 0 };
+ k.n += r.n;
+ kaybolanUni.set(slug, k);
+ }
+
+ console.log(
+ `\nTamamen kaybolan ham 'isim' değeri: ${kaybolanHamIsim.length} ` +
+ `(çoğu burs/dil parantezi varyantı; sayfa kaybı aşağıdaki slug listesidir)`
+ );
+ console.log(`KAPANACAK /bolum/ sayfası: ${kaybolanBolum.size}`);
+ for (const [slug, k] of [...kaybolanBolum].sort((a, b) => a[0].localeCompare(b[0]))) {
+ console.log(` /bolum/${slug} — ${k.ad} (${k.n} satır)`);
+ }
+ console.log(`Tamamen kaybolan ham 'universite' değeri: ${kaybolanHamUni.length}`);
+ for (const r of kaybolanHamUni) console.log(` ${r.ad} (${r.n} satır)`);
+ console.log(`KAPANACAK /universite/ sayfası: ${kaybolanUni.size}`);
+ for (const [slug, k] of [...kaybolanUni].sort((a, b) => a[0].localeCompare(b[0]))) {
+ console.log(` /universite/${slug} — ${k.ad} (${k.n} satır)`);
+ }
+
+ // ── netler ─────────────────────────────────────────────────────────────
+ const netlerVar = tabloVar(db, "netler");
+ if (netlerVar) {
+ const net = db
+ .prepare(
+ `SELECT COUNT(*) AS satir, COUNT(DISTINCT n.program_id) AS program
+ FROM netler n JOIN programs e ON e.id = n.program_id WHERE e.${ESKI}`
+ )
+ .get(yil) as { satir: number; program: number };
+ console.log(`\nnetler: arşive gidecek programlara bağlı ${net.satir} satır (${net.program} program)`);
+ }
+
+ if (kuru) {
+ console.log("\nKURU KİP bitti — DB değişmedi.");
+ db.close();
+ return;
+ }
+
+ // ── Gerçek taşıma: tek transaction ─────────────────────────────────────
+ db.transaction(() => {
+ arsivTablosunuGarantiEt(db);
+ const kolonlar = kolonAdlari(db, "programs");
+ const saglama = () =>
+ (
+ db
+ .prepare(
+ `SELECT COUNT(*) || '|' || TOTAL(sira${yil - 1}) || '|' || TOTAL(puan${yil - 1}) AS s
+ FROM (SELECT * FROM programs UNION ALL SELECT ${kolonlar.join(", ")} FROM programs_arsiv)`
+ )
+ .get() as { s: string }
+ ).s;
+ const once = saglama();
+
+ if (netlerVar) {
+ // Tek halefli eski kodlar → yeni koda. OR IGNORE: yeni kodda o yıl varsa dokunma.
+ const r = db
+ .prepare(
+ `UPDATE OR IGNORE netler SET program_id = (
+ SELECT g.id FROM programs g WHERE g.eski_kod = netler.program_id AND g.son_kilavuz_yili = @yil
+ )
+ WHERE program_id IN (SELECT id FROM programs WHERE son_kilavuz_yili IS NOT @yil)
+ AND (SELECT COUNT(*) FROM programs g
+ WHERE g.eski_kod = netler.program_id AND g.son_kilavuz_yili = @yil) = 1`
+ )
+ .run({ yil });
+ console.log(`\nnetler: ${r.changes} satır yeni koda çevrildi`);
+ }
+
+ const tasinan = db
+ .prepare(
+ `INSERT INTO programs_arsiv (${kolonlar.join(", ")})
+ SELECT ${kolonlar.join(", ")} FROM programs WHERE ${ESKI}`
+ )
+ .run(yil).changes;
+ const cikan = db.prepare(`DELETE FROM programs WHERE ${ESKI}`).run(yil).changes;
+ if (tasinan !== eskiSayi || cikan !== eskiSayi) {
+ dur(`Taşınan (${tasinan}) / çıkarılan (${cikan}) ≠ beklenen (${eskiSayi}). Geri alındı.`);
+ }
+ const sonra = saglama();
+ if (once !== sonra) {
+ dur(`programs ∪ programs_arsiv sağlaması değişti. Geri alındı.\nönce : ${once}\nsonra: ${sonra}`);
+ }
+ metaYaz(db, `arsiv_${yil}_tarih`, new Date().toISOString());
+ metaYaz(db, `arsiv_${yil}_satir`, tasinan);
+ console.log(`Arşivlendi: ${tasinan} satır → programs_arsiv. Sağlama aynı: ${sonra}`);
+ })();
+
+ if (netlerVar) {
+ const yetim = (
+ db
+ .prepare("SELECT COUNT(*) AS n FROM netler WHERE program_id NOT IN (SELECT id FROM programs)")
+ .get() as { n: number }
+ ).n;
+ console.log(`netler: programs'ta karşılığı olmayan satır (arşivdeki programlara ait): ${yetim}`);
+ }
+ db.pragma("wal_checkpoint(TRUNCATE)");
+ db.close();
+ console.log("\nSıradaki adım: pnpm db:index --db --vacuum sonra pnpm veri-kalite …");
+}
+
+if (process.argv[1]?.endsWith("eski-arsivle.ts")) calistir(main);
diff --git a/scripts/gecmis-doldur.ts b/scripts/gecmis-doldur.ts
new file mode 100644
index 0000000..ca84bca
--- /dev/null
+++ b/scripts/gecmis-doldur.ts
@@ -0,0 +1,302 @@
+/**
+ * Güncel kılavuzdaki programların GEÇMİŞ yıl kolonlarındaki boşlukları doldurur.
+ *
+ * Kurallar (pazarlıksız):
+ * - Yalnız NULL alan doldurulur. Dolu değer ASLA ezilmez; kaynak farklı bir
+ * değer söylüyorsa sayılır ve raporlanır (karar insanındır).
+ * - Tahmin yok, komşu yıldan kopya yok. Kaynak yoksa alan NULL kalır.
+ * - Belirsiz eşleşmede birleştirme yok (sayılır, raporlanır).
+ *
+ * Kaynak önceliği:
+ * 1. API `…1/…2/…3` alanları (ham dosyadan; API'ye gidilmez) → yıl-1/-2/-3
+ * için sıra, puan, genel kontenjan.
+ * 2. `eski_kod` (API `eskiKilavuzKodu`): kodu değişen programın eski satırı
+ * (programs ya da programs_arsiv) → tüm geçmiş yıllar, dört ölçü.
+ * Eski kod hâlâ güncel kılavuzdaysa ya da birden çok yeni program aynı
+ * eski koda işaret ediyorsa (bölünme) → belirsiz, atlanır.
+ * 3. Aynı (üniversite baz adı, isim, tur, onlisans) için TAM 1 güncel + TAM 1
+ * eski satır varsa; ortak yıllarda puan/kontenjan/yerleşen ÇELİŞMİYORSA ve
+ * en az bir ortak değer AYNIYSA (kanıt) → eski satırdan doldurulur, güncel
+ * satıra `eski_kod` yazılır. Kanıtsız ya da çelişkili eşleşme birleştirilmez.
+ *
+ * Kullanım:
+ * pnpm gecmis-doldur --yil 2026 --db /tmp/deneme.db [--ham data/ham/2026-….jsonl.gz] [--kuru]
+ */
+import path from "node:path";
+import type Database from "better-sqlite3";
+import { ILK_YIL } from "../src/lib/veri-yillari";
+import { gecmisAlanlari, hamOku, sonHamDosya } from "./refresh";
+import {
+ argOku,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ tabloVar,
+ yilArgumani,
+ YIL_OLCULERI,
+} from "./yokatlas-goc";
+
+type Satir = Record & { id: string };
+
+const PUAN_TOLERANS = 0.01;
+
+class KuruGeriAl extends Error {}
+
+function ayniMi(olcu: string, a: number, b: number): boolean {
+ return olcu === "puan" ? Math.abs(a - b) <= PUAN_TOLERANS : a === b;
+}
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const kuru = bayrakVar("kuru");
+ const hamArg = argOku("ham");
+ const hamDosya = hamArg
+ ? path.resolve(process.cwd(), hamArg)
+ : sonHamDosya(yil, path.resolve(process.cwd(), "data", "ham"));
+ if (!hamDosya) {
+ dur(`--ham verilmedi ve data/ham altında ${yil}-*.jsonl.gz yok. Önce refresh koş.`);
+ }
+
+ const db = dbAc(dbYolu);
+ const kolonlar = new Set(kolonAdlari(db, "programs"));
+ if (!kolonlar.has("son_kilavuz_yili") || !kolonlar.has(`sira${yil}`)) {
+ dur("Şema eski: önce `pnpm refresh` (ya da `pnpm yokatlas:goc`) koşulmalı.");
+ }
+ const guncelSayisi = (
+ db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
+ ).n;
+ if (guncelSayisi === 0) dur(`son_kilavuz_yili=${yil} satır yok: önce refresh koşulmalı.`);
+
+ const gecmisYillar: number[] = [];
+ for (let y = ILK_YIL; y < yil; y++) {
+ if (kolonlar.has(`sira${y}`)) gecmisYillar.push(y);
+ }
+ const olculer = YIL_OLCULERI.map(([o]) => o);
+
+ console.log(`DB: ${dbYolu}\nHam: ${hamDosya}${kuru ? "\nKURU KİP: hiçbir şey yazılmayacak" : ""}`);
+ const { kayitlar } = await hamOku(hamDosya, yil);
+
+ const doldurulan = new Map(); // "kaynak|kolon" → adet
+ const farkli = new Map();
+ const say = (m: Map, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
+ const farkYaz = (kaynak: string, kolon: string, id: string, db_: number, kaynakDeger: number) => {
+ const k = `${kaynak}|${kolon}`;
+ const f = farkli.get(k) ?? { adet: 0, ornek: [] };
+ f.adet++;
+ if (f.ornek.length < 3) f.ornek.push(`${id}: DB=${db_} kaynak=${kaynakDeger}`);
+ farkli.set(k, f);
+ };
+
+ const satirGetir = db.prepare("SELECT * FROM programs WHERE id = ?");
+ const arsivGetir = tabloVar(db, "programs_arsiv")
+ ? db.prepare("SELECT * FROM programs_arsiv WHERE id = ?")
+ : null;
+ const setCache = new Map>();
+ const alanYaz = (id: string, kolon: string, deger: number) => {
+ let st = setCache.get(kolon);
+ if (!st) {
+ // Çifte emniyet: WHERE … IS NULL — dolu değerin üstüne yazılamaz.
+ st = db.prepare(`UPDATE programs SET ${kolon} = ? WHERE id = ? AND ${kolon} IS NULL`);
+ setCache.set(kolon, st);
+ }
+ st.run(deger, id);
+ };
+
+ /** Hedefin NULL alanlarını kaynaktan doldurur; dolu+farklıyı sayar. */
+ const satirdanDoldur = (kaynakAdi: string, hedef: Satir, kaynak: Satir) => {
+ for (const y of gecmisYillar) {
+ for (const olcu of olculer) {
+ const kolon = `${olcu}${y}`;
+ const k = kaynak[kolon] as number | null;
+ if (k == null) continue;
+ const h = hedef[kolon] as number | null;
+ if (h == null) {
+ alanYaz(hedef.id, kolon, k);
+ hedef[kolon] = k;
+ say(doldurulan, `${kaynakAdi}|${kolon}`);
+ } else if (!ayniMi(olcu, h, k)) {
+ farkYaz(kaynakAdi, kolon, hedef.id, h, k);
+ }
+ }
+ }
+ };
+
+ const ozet = {
+ apiEslesen: 0,
+ eskiKodlu: 0,
+ eskiKodBulunamadi: 0,
+ eskiKodHalaGuncel: 0,
+ eskiKodBolunme: 0,
+ adEslesen: 0,
+ adBelirsiz: 0,
+ adPuanCelisik: 0,
+ adKanitsiz: 0,
+ };
+ const ornekler: Record = { bolunme: [], halaGuncel: [], adEslesen: [], adCelisik: [], adKanitsiz: [] };
+ const ornekEkle = (k: string, s: string) => {
+ if (ornekler[k].length < 3) ornekler[k].push(s);
+ };
+
+ try {
+ db.transaction(() => {
+ // ── 1. API …1/…2/…3 ────────────────────────────────────────────────
+ for (const r of kayitlar) {
+ if (!r.kilavuzKodu) continue;
+ const hedef = satirGetir.get(String(r.kilavuzKodu)) as Satir | undefined;
+ if (!hedef || hedef.son_kilavuz_yili !== yil) continue;
+ ozet.apiEslesen++;
+ for (const g of gecmisAlanlari(r)) {
+ const y = yil - g.fark;
+ if (!gecmisYillar.includes(y)) continue;
+ for (const olcu of ["sira", "puan", "kontenjan"] as const) {
+ const k = g[olcu];
+ if (k == null) continue;
+ const kolon = `${olcu}${y}`;
+ const h = hedef[kolon] as number | null;
+ if (h == null) {
+ alanYaz(hedef.id, kolon, k);
+ hedef[kolon] = k;
+ say(doldurulan, `1-api|${kolon}`);
+ } else if (!ayniMi(olcu, h, k)) {
+ farkYaz("1-api", kolon, hedef.id, h, k);
+ }
+ }
+ }
+ }
+
+ // ── 2. eski_kod ────────────────────────────────────────────────────
+ const eskiKodlular = db
+ .prepare("SELECT * FROM programs WHERE son_kilavuz_yili = ? AND eski_kod IS NOT NULL")
+ .all(yil) as Satir[];
+ const eskiKullanim = new Map();
+ for (const s of eskiKodlular) say(eskiKullanim, s.eski_kod as string);
+ for (const hedef of eskiKodlular) {
+ const eskiId = hedef.eski_kod as string;
+ if ((eskiKullanim.get(eskiId) ?? 0) > 1) {
+ ozet.eskiKodBolunme++;
+ ornekEkle("bolunme", `${hedef.id} ← ${eskiId} (${hedef.isim})`);
+ continue;
+ }
+ const kaynak =
+ (satirGetir.get(eskiId) as Satir | undefined) ??
+ (arsivGetir?.get(eskiId) as Satir | undefined);
+ if (!kaynak) {
+ ozet.eskiKodBulunamadi++;
+ continue;
+ }
+ if (kaynak.son_kilavuz_yili === yil) {
+ ozet.eskiKodHalaGuncel++;
+ ornekEkle("halaGuncel", `${hedef.id} ← ${eskiId} (${kaynak.isim})`);
+ continue;
+ }
+ ozet.eskiKodlu++;
+ satirdanDoldur("2-eski_kod", hedef, kaynak);
+ }
+
+ // ── 3. Benzersiz (universite, isim, tur, onlisans) ─────────────────
+ const sahiplenilmis = new Set(eskiKullanim.keys());
+ const gruplar = new Map();
+ for (const s of db.prepare("SELECT * FROM programs").all() as Satir[]) {
+ // API 2026'da üniversite adına "(İL)" eki koyuyor; eski satırlar eksiz.
+ const uniBaz = String(s.universite).replace(/\s*\([^)]*\)\s*$/, "").trim();
+ const anahtar = JSON.stringify([uniBaz, s.isim, s.tur, s.onlisans]);
+ const g = gruplar.get(anahtar) ?? { guncel: [], eski: [] };
+ if (s.son_kilavuz_yili === yil) g.guncel.push(s);
+ else if (!sahiplenilmis.has(s.id)) g.eski.push(s);
+ gruplar.set(anahtar, g);
+ }
+ const eskiKodYaz = db.prepare(
+ "UPDATE programs SET eski_kod = ? WHERE id = ? AND eski_kod IS NULL"
+ );
+ for (const g of gruplar.values()) {
+ if (g.eski.length === 0 || g.guncel.length === 0) continue;
+ if (g.eski.length !== 1 || g.guncel.length !== 1) {
+ ozet.adBelirsiz++;
+ continue;
+ }
+ const [hedef] = g.guncel;
+ const [kaynak] = g.eski;
+ if (hedef.eski_kod != null) {
+ // API başka bir eski kod söylüyor; ad eşleşmesi onun önüne geçmez.
+ ozet.adBelirsiz++;
+ continue;
+ }
+ // Aynı yılda iki satırda da veri varsa değerler AYNI olmalı (çift satır);
+ // farklıysa bunlar aynı anda var olmuş iki ayrı programdır (ör. 1 kişilik
+ // özel kontenjanlı adaş program). Sıra bilerek dışarıda: 2024 sırası iki
+ // kaynakta farklı tanımlı. Ortak yılı hiç olmayan eşleşme KANITSIZDIR →
+ // birleştirilmez, yalnız sayılır.
+ let kanit = 0;
+ let celisik = false;
+ for (const y of gecmisYillar) {
+ for (const olcu of ["puan", "kontenjan", "yerlesen"]) {
+ const a = hedef[`${olcu}${y}`] as number | null;
+ const b = kaynak[`${olcu}${y}`] as number | null;
+ if (a == null || b == null) continue;
+ if (ayniMi(olcu, a, b)) kanit++;
+ else celisik = true;
+ }
+ }
+ if (celisik) {
+ ozet.adPuanCelisik++;
+ ornekEkle("adCelisik", `${hedef.id} ~ ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
+ continue;
+ }
+ if (kanit === 0) {
+ ozet.adKanitsiz++;
+ ornekEkle("adKanitsiz", `${hedef.id} ? ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
+ continue;
+ }
+ ozet.adEslesen++;
+ ornekEkle("adEslesen", `${hedef.id} ← ${kaynak.id} (${hedef.universite} / ${hedef.isim})`);
+ satirdanDoldur("3-ad", hedef, kaynak);
+ eskiKodYaz.run(kaynak.id, hedef.id);
+ }
+
+ if (kuru) throw new KuruGeriAl();
+ })();
+ } catch (err) {
+ if (!(err instanceof KuruGeriAl)) throw err;
+ }
+
+ // ── Rapor ──────────────────────────────────────────────────────────────
+ console.log(`\nGüncel kılavuzdaki program: ${guncelSayisi}; API kaydıyla eşleşen: ${ozet.apiEslesen}`);
+ console.log(
+ `eski_kod: ${ozet.eskiKodlu} programın eski satırı bulundu ve boşlukları ondan dolduruldu; ` +
+ `eski satır yok: ${ozet.eskiKodBulunamadi}; eski kod hâlâ güncel (atlandı): ${ozet.eskiKodHalaGuncel}; ` +
+ `bölünme/çoklu (atlandı): ${ozet.eskiKodBolunme}`
+ );
+ console.log(
+ `Ad eşleşmesi: ${ozet.adEslesen} birebir; belirsiz (atlandı): ${ozet.adBelirsiz}; ` +
+ `aynı yılda farklı değer = ayrı program (atlandı): ${ozet.adPuanCelisik}; ` +
+ `ortak yılı olmayan, kanıtsız (atlandı): ${ozet.adKanitsiz}`
+ );
+ for (const [k, v] of Object.entries(ornekler)) {
+ if (v.length) console.log(` örnek ${k}: ${v.join(" ; ")}`);
+ }
+
+ console.log(`\nDoldurulan NULL alanlar${kuru ? " (kuru — yazılmadı)" : ""}:`);
+ const toplamlar = new Map();
+ for (const [k, n] of [...doldurulan].sort()) {
+ const [kaynak] = k.split("|");
+ toplamlar.set(kaynak, (toplamlar.get(kaynak) ?? 0) + n);
+ console.log(` ${k.replace("|", " ")}: ${n}`);
+ }
+ for (const [kaynak, n] of toplamlar) console.log(` TOPLAM ${kaynak}: ${n}`);
+ if (doldurulan.size === 0) console.log(" (yok)");
+
+ console.log("\nDolu olup kaynağın FARKLI söylediği alanlar (EZİLMEDİ):");
+ for (const [k, f] of [...farkli].sort()) {
+ console.log(` ${k.replace("|", " ")}: ${f.adet} ör. ${f.ornek.join(" ; ")}`);
+ }
+ if (farkli.size === 0) console.log(" (yok)");
+
+ if (!kuru) db.pragma("wal_checkpoint(TRUNCATE)");
+ db.close();
+}
+
+if (process.argv[1]?.endsWith("gecmis-doldur.ts")) calistir(main);
diff --git a/scripts/refresh.ts b/scripts/refresh.ts
index ec9cbc0..ac891ea 100644
--- a/scripts/refresh.ts
+++ b/scripts/refresh.ts
@@ -1,24 +1,66 @@
/**
- * Canlı YÖK Atlas API'sinden 2025 yerleştirme verisini çekip DB'yi tazeler.
+ * Canlı YÖK Atlas API'sinden SON yerleştirme yılının verisini çekip DB'ye işler.
*
* Endpoint: POST https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search
- * (yokatlas-py'nin kullandığı resmî tercih-kılavuz JSON API'si)
*
- * - Mevcut programlarda sira/puan/kontenjan/yerlesen 2025 kolonlarını günceller
- * - DB'de olmayan (yeni açılan) programları ekler
- * - Az yerleşeni olan programlarda API basariSirasi döndürmez → NULL kalır
+ * API'nin yıl düzeni (Eylül 2026'da doğrulandı): EKSİZ alanlar (basariSirasi,
+ * minPuan, kontenjan, gkY…) en son yerleştirme yılıdır; `…1` bir önceki,
+ * `…2` iki önceki, `…3` üç önceki yıldır. Kayıttaki `yil` alanı GÜVENİLMEZ
+ * (kılavuz yılı / veri yılı anlamı yıl içinde değişiyor) — bu betik yılı
+ * HİÇBİR ZAMAN oradan türetmez. Yıl `--yil` ile verilir ve YIL KİLİDİ ile
+ * DB'ye karşı doğrulanır.
*
- * Kullanım: npx tsx scripts/refresh.ts
+ * Güvenlik sırası:
+ * 1. `--yil` = SON_YIL, `--db` zorunlu (varsayılan yok).
+ * 2. YIL KİLİDİ: ilk sayfadaki `basariSirasi1` değerleri DB'deki
+ * sira ile ≥%95 eşleşmeli VE eksiz `basariSirasi` ondan farklı
+ * olmalı. Değilse API henüz yeni yıla dönmemiştir (ya da bir yıl daha
+ * ilerlemiştir) → HİÇBİR ŞEY yazılmadan durur.
+ * 3. Tüm sayfalar önce belleğe + ham dosyaya alınır; benzersiz kod sayısı =
+ * `totalElements` değilse (sayfalama kayması) durur.
+ * 4. DB yazımı TEK transaction; önceki yılların kolon sağlaması transaction
+ * içinde karşılaştırılır, fark varsa geri alınır.
+ *
+ * Yazdıkları: sira/puan/kontenjan/yerlesen, son_kilavuz_yili, eski_kod.
+ * Mevcut satırların ÖNCEKİ yıl kolonlarına dokunmaz (onları yalnız NULL ise
+ * gecmis-doldur.ts doldurur). Yeni program eklerken `…1/…2/…3` alanlarından
+ * önceki üç yılı da yazar. API "veri yok" için 0 döndürür → NULL.
+ *
+ * Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı.
+ * `--ham ` verilirse API'ye HİÇ gidilmez; daha önce kaydedilmiş ham
+ * dosyadan aynı işlem tekrarlanır (kopyada deneme → gerçek DB'ye uygulama).
+ *
+ * Kullanım:
+ * pnpm refresh --yil 2026 --db /tmp/deneme.db
+ * pnpm refresh --yil 2026 --db data/yokatlas.db --ham data/ham/2026-2026-09-21.jsonl.gz
*/
+import fs from "node:fs";
import path from "node:path";
-import Database from "better-sqlite3";
+import readline from "node:readline";
+import zlib from "node:zlib";
+import { ILK_YIL } from "../src/lib/veri-yillari";
+import { efektifIndeksiKur } from "./db-index";
import { uniAdiNormalize, uniturOnar } from "./unitur-onar";
+import {
+ argOku,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ metaYaz,
+ yilArgumani,
+ yilKolonlariniGarantiEt,
+ yokatlasGoc,
+ type Db,
+} from "./yokatlas-goc";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500;
-const DELAY_MS = 300;
+const DELAY_MS = 500;
+const KILIT_ESIK = 0.95;
+const KILIT_ASGARI_ORNEK = 50;
-const TUR_MAP: Record = {
+export const TUR_MAP: Record = {
SAY: "SAYISAL",
EA: "EŞİT AĞIRLIK",
SÖZ: "SÖZEL",
@@ -26,27 +68,75 @@ const TUR_MAP: Record = {
TYT: "TYT",
};
-type ApiRecord = {
+/** API kaydından betiklerin kullandığı alanlar (ham dosyada kaydın tamamı durur). */
+export type KilavuzKaydi = {
kilavuzKodu: number;
+ eskiKilavuzKodu?: number | null;
birimAdi: string;
universiteAdi: string;
- universiteTuru: string | null;
- ilAdi: string | null;
- fymkAdi: string | null;
+ universiteTuru?: string | null;
+ ilAdi?: string | null;
+ fymkAdi?: string | null;
puanTuru: string;
- ogrenimSuresi: number | null;
+ ogrenimSuresi?: number | null;
birimTuruId: number;
- basariSirasi?: number | null;
- minPuan?: number | null;
+ basariSirasi?: number | string | null;
+ minPuan?: number | string | null;
kontenjan?: number | null;
gkY?: number | null;
sgyY?: number | null;
dprmY?: number | null;
obkY?: number | null;
+ basariSirasi1?: number | string | null;
+ basariSirasi2?: number | string | null;
+ basariSirasi3?: number | string | null;
+ minPuan1?: number | string | null;
+ minPuan2?: number | string | null;
+ minPuan3?: number | string | null;
+ gk1?: number | null;
+ gk2?: number | null;
+ gk3?: number | null;
};
+const TUTULAN_ALANLAR: (keyof KilavuzKaydi)[] = [
+ "kilavuzKodu",
+ "eskiKilavuzKodu",
+ "birimAdi",
+ "universiteAdi",
+ "universiteTuru",
+ "ilAdi",
+ "fymkAdi",
+ "puanTuru",
+ "ogrenimSuresi",
+ "birimTuruId",
+ "basariSirasi",
+ "minPuan",
+ "kontenjan",
+ "gkY",
+ "sgyY",
+ "dprmY",
+ "obkY",
+ "basariSirasi1",
+ "basariSirasi2",
+ "basariSirasi3",
+ "minPuan1",
+ "minPuan2",
+ "minPuan3",
+ "gk1",
+ "gk2",
+ "gk3",
+];
+
+function inceKayit(ham: Record): KilavuzKaydi {
+ const ince: Record = {};
+ for (const alan of TUTULAN_ALANLAR) {
+ if (ham[alan] !== undefined) ince[alan] = ham[alan];
+ }
+ return ince as KilavuzKaydi;
+}
+
type ApiPage = {
- content: ApiRecord[];
+ content: Record[];
totalPages: number;
totalElements: number;
};
@@ -83,140 +173,435 @@ async function fetchPage(page: number): Promise {
return (await res.json()) as ApiPage;
}
-function yerlesen(r: ApiRecord): number | null {
- const parts = [r.gkY, r.sgyY, r.dprmY, r.obkY].filter(
- (x): x is number => typeof x === "number"
- );
- return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
-}
-
-function intOrNull(v: unknown): number | null {
+/** Pozitif tam sayı; 0 / boş / sayı değil → NULL (API "veri yok"u 0 ile verir). */
+export function intOrNull(v: unknown): number | null {
const n = typeof v === "string" ? Number.parseFloat(v) : (v as number);
return typeof n === "number" && Number.isFinite(n) && n > 0
? Math.round(n)
: null;
}
-function realOrNull(v: unknown): number | null {
+export function realOrNull(v: unknown): number | null {
const n = typeof v === "string" ? Number.parseFloat(v) : (v as number);
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : null;
}
-async function main() {
- const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
- db.pragma("journal_mode = WAL");
+function yerlesen(r: KilavuzKaydi): number | null {
+ const parts = [r.gkY, r.sgyY, r.dprmY, r.obkY].filter(
+ (x): x is number => typeof x === "number"
+ );
+ return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
+}
- const cols = db.prepare("PRAGMA table_info(programs)").all() as {
- name: string;
- }[];
- const has2025 = cols.some((c) => c.name === "sira2025");
- if (!has2025) {
- db.exec(`
- ALTER TABLE programs ADD COLUMN sira2025 INTEGER;
- ALTER TABLE programs ADD COLUMN puan2025 REAL;
- ALTER TABLE programs ADD COLUMN kontenjan2025 INTEGER;
- ALTER TABLE programs ADD COLUMN yerlesen2025 INTEGER;
- CREATE INDEX IF NOT EXISTS idx_programs_tur_sira25 ON programs (tur, sira2025);
- `);
+/**
+ * API `eskiKilavuzKodu` iki anlamda geliyor: gerçek eski program kodu (9 hane)
+ * ya da yeni açılan programda üniversite öneki (4 hane, ör. 2031). Yalnız
+ * gerçek program kodu biçimindeki ve yeni koddan farklı olanı döndürür.
+ */
+export function eskiKod(r: KilavuzKaydi): string | null {
+ if (!r.eskiKilavuzKodu) return null;
+ const eski = String(r.eskiKilavuzKodu);
+ const yeni = String(r.kilavuzKodu);
+ return eski.length === yeni.length && eski !== yeni ? eski : null;
+}
+
+/** Yeni programda önceki üç yılın API karşılığı: [yıl farkı, sıra, puan, genel kontenjan]. */
+export function gecmisAlanlari(
+ r: KilavuzKaydi
+): { fark: number; sira: number | null; puan: number | null; kontenjan: number | null }[] {
+ return [
+ { fark: 1, sira: intOrNull(r.basariSirasi1), puan: realOrNull(r.minPuan1), kontenjan: intOrNull(r.gk1) },
+ { fark: 2, sira: intOrNull(r.basariSirasi2), puan: realOrNull(r.minPuan2), kontenjan: intOrNull(r.gk2) },
+ { fark: 3, sira: intOrNull(r.basariSirasi3), puan: realOrNull(r.minPuan3), kontenjan: intOrNull(r.gk3) },
+ ];
+}
+
+// ── Ham dosya ────────────────────────────────────────────────────────────
+
+/** `--ham` verilmemişse o yılın en yeni ham dosyası (ad sırası = tarih sırası). */
+export function sonHamDosya(yil: number, dizin: string): string | null {
+ if (!fs.existsSync(dizin)) return null;
+ const adaylar = fs
+ .readdirSync(dizin)
+ .filter((f) => f.startsWith(`${yil}-`) && f.endsWith(".jsonl.gz"))
+ .sort();
+ return adaylar.length ? path.join(dizin, adaylar[adaylar.length - 1]) : null;
+}
+
+export type HamOkuma = { toplam: number; kayitlar: KilavuzKaydi[] };
+
+/** Ham .jsonl.gz → ince kayıtlar. İlk satır `_meta` (yıl doğrulanır). */
+export async function hamOku(dosya: string, yil: number): Promise {
+ if (!fs.existsSync(dosya)) dur(`Ham dosya yok: ${dosya}`);
+ const rl = readline.createInterface({
+ input: fs.createReadStream(dosya).pipe(zlib.createGunzip()),
+ crlfDelay: Infinity,
+ });
+ let toplam = -1;
+ const kayitlar: KilavuzKaydi[] = [];
+ for await (const satir of rl) {
+ if (!satir) continue;
+ const obj = JSON.parse(satir) as Record;
+ if (obj._meta) {
+ const meta = obj._meta as { yil: number; totalElements: number };
+ if (meta.yil !== yil) {
+ dur(`Ham dosya ${meta.yil} yılı için çekilmiş, --yil ${yil} verildi.`);
+ }
+ toplam = meta.totalElements;
+ continue;
+ }
+ kayitlar.push(inceKayit(obj));
+ }
+ if (toplam < 0) dur(`Ham dosyada _meta satırı yok: ${dosya}`);
+ return { toplam, kayitlar };
+}
+
+// ── Yıl kilidi ───────────────────────────────────────────────────────────
+
+/**
+ * `basariSirasi1` = DB sira (≥%95) VE eksiz `basariSirasi` ≠ DB
+ * sira. İkisi birlikte "eksiz alanlar gerçekten verisi" demektir.
+ */
+export function yilKilidi(db: Db, yil: number, ornek: KilavuzKaydi[]): void {
+ const onceki = yil - 1;
+ const bul = db.prepare(`SELECT sira${onceki} AS s FROM programs WHERE id = ?`);
+ let kiyas1 = 0;
+ let esit1 = 0;
+ let kiyas0 = 0;
+ let esit0 = 0;
+ for (const r of ornek) {
+ if (!r.kilavuzKodu) continue;
+ let satir = bul.get(String(r.kilavuzKodu)) as { s: number | null } | undefined;
+ const eski = eskiKod(r);
+ if (!satir && eski) satir = bul.get(eski) as typeof satir;
+ if (!satir || satir.s == null) continue;
+ const s1 = intOrNull(r.basariSirasi1);
+ if (s1 != null) {
+ kiyas1++;
+ if (s1 === satir.s) esit1++;
+ }
+ const s0 = intOrNull(r.basariSirasi);
+ if (s0 != null) {
+ kiyas0++;
+ if (s0 === satir.s) esit0++;
+ }
+ }
+ const oran1 = kiyas1 ? esit1 / kiyas1 : 0;
+ const oran0 = kiyas0 ? esit0 / kiyas0 : 0;
+ console.log(
+ `Yıl kilidi: basariSirasi1 = sira${onceki} → ${esit1}/${kiyas1} (%${(oran1 * 100).toFixed(1)}); ` +
+ `eksiz basariSirasi = sira${onceki} → ${esit0}/${kiyas0} (%${(oran0 * 100).toFixed(1)})`
+ );
+ if (kiyas1 < KILIT_ASGARI_ORNEK) {
+ dur(
+ `Yıl kilidi için yeterli karşılaştırma yok (${kiyas1} < ${KILIT_ASGARI_ORNEK}). ` +
+ `DB'de sira${onceki} dolu mu, doğru DB mi?`
+ );
+ }
+ if (oran0 >= 0.5) {
+ dur(
+ `API'nin eksiz alanları hâlâ ${onceki} verisi (DB ile %${(oran0 * 100).toFixed(1)} aynı). ` +
+ `${yil} yerleştirme sonuçları henüz yayımlanmamış; bugün koşmak ${yil} kolonuna ${onceki} verisi yazar.`
+ );
+ }
+ if (oran1 < KILIT_ESIK) {
+ dur(
+ `API'nin "…1" alanları DB'deki ${onceki} verisiyle eşleşmiyor (%${(oran1 * 100).toFixed(1)} < %95). ` +
+ `API yıl düzeni değişmiş olabilir; --yil ${yil} ile yazmak yanlış kolonu doldurur.`
+ );
+ }
+}
+
+// ── Ana akış ─────────────────────────────────────────────────────────────
+
+async function apidenCek(
+ yil: number,
+ hamDizin: string,
+ ilkSayfaKontrol: (ornek: KilavuzKaydi[]) => void
+): Promise {
+ fs.mkdirSync(hamDizin, { recursive: true });
+ const tarih = new Date().toISOString().slice(0, 10);
+ const hamDosya = path.join(hamDizin, `${yil}-${tarih}.jsonl.gz`);
+ const gecici = `${hamDosya}.tmp`;
+
+ const first = await fetchPage(0);
+ const kayitlar = first.content.map(inceKayit);
+ // Kilit tutmazsa hiçbir şey yazılmadan ve kaynağa 1 istekle durulur.
+ ilkSayfaKontrol(kayitlar);
+
+ console.log(
+ `Toplam ${first.totalElements} kayıt, ${first.totalPages} sayfa çekilecek ` +
+ `(~${Math.ceil((first.totalPages * (DELAY_MS + 1500)) / 1000)} sn)…`
+ );
+ const gzip = zlib.createGzip();
+ const cikti = fs.createWriteStream(gecici);
+ gzip.pipe(cikti);
+ const bitti = new Promise((resolve, reject) => {
+ cikti.on("finish", resolve);
+ cikti.on("error", reject);
+ gzip.on("error", reject);
+ });
+ const yaz = (obj: unknown) => gzip.write(`${JSON.stringify(obj)}\n`);
+
+ yaz({
+ _meta: {
+ yil,
+ cekimTarihi: new Date().toISOString(),
+ kaynak: API,
+ totalElements: first.totalElements,
+ totalPages: first.totalPages,
+ sayfaBoyu: PAGE_SIZE,
+ },
+ });
+ for (const r of first.content) yaz(r);
+ for (let p = 1; p < first.totalPages; p++) {
+ await sleep(DELAY_MS);
+ const page = await fetchPage(p);
+ for (const r of page.content) {
+ yaz(r);
+ kayitlar.push(inceKayit(r));
+ }
+ process.stdout.write(`\rsayfa ${p + 1}/${first.totalPages}`);
+ }
+ console.log();
+ gzip.end();
+ await bitti;
+ fs.renameSync(gecici, hamDosya);
+ console.log(`Ham veri: ${hamDosya}`);
+ return { toplam: first.totalElements, kayitlar, hamDosya };
+}
+
+function oncekiYilSaglamasi(db: Db, yil: number, azamiRowid: number): string {
+ const kolonlar: string[] = [];
+ for (let y = ILK_YIL; y < yil; y++) {
+ for (const olcu of ["sira", "puan", "kontenjan", "yerlesen"]) {
+ kolonlar.push(`COUNT(${olcu}${y})`, `TOTAL(${olcu}${y})`);
+ }
+ }
+ const r = db
+ .prepare(`SELECT COUNT(*), ${kolonlar.join(", ")} FROM programs WHERE rowid <= ?`)
+ .raw()
+ .get(azamiRowid) as number[];
+ return r.join("|");
+}
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const hamArg = argOku("ham");
+ const hamDizin = path.resolve(process.cwd(), argOku("ham-dizin") ?? path.join("data", "ham"));
+
+ const db = dbAc(dbYolu);
+ console.log(`DB: ${dbYolu}\nYıl: ${yil}`);
+
+ // Şema (idempotent). Yeni programlarda …1/…2/…3 yazılacağı için önceki üç
+ // yılın kolonları da garanti edilir (normalde zaten vardır).
+ const eklenen = yokatlasGoc(db, yil);
+ for (let fark = 1; fark <= 3; fark++) {
+ if (yil - fark >= ILK_YIL) eklenen.push(...yilKolonlariniGarantiEt(db, yil - fark));
+ }
+ if (eklenen.length) console.log(`Göç: ${eklenen.join(", ")}`);
+
+ // ── Veri: API ya da ham dosya ──────────────────────────────────────────
+ let toplam: number;
+ let kayitlar: KilavuzKaydi[];
+ let hamDosya: string;
+ if (hamArg) {
+ hamDosya = path.resolve(process.cwd(), hamArg);
+ console.log(`Kaynak: ham dosya (API'ye gidilmiyor) → ${hamDosya}`);
+ ({ toplam, kayitlar } = await hamOku(hamDosya, yil));
+ yilKilidi(db, yil, kayitlar.slice(0, PAGE_SIZE));
+ } else {
+ ({ toplam, kayitlar, hamDosya } = await apidenCek(yil, hamDizin, (ornek) =>
+ yilKilidi(db, yil, ornek)
+ ));
}
- // Sorgu yolunun kullandığı ifade indexi; ifade src/lib/db.ts EFEKTIF_SIRA
- // ile birebir aynı olmalı (bkz. scripts/db-index.ts). sira2025 kolonu
- // yukarıda garanti edildikten sonra kurulur.
- db.exec(`
- CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
- ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
- `);
+ // ── Bütünlük: benzersiz kod = totalElements ────────────────────────────
+ const benzersiz = new Map();
+ let kodsuz = 0;
+ for (const r of kayitlar) {
+ if (!r.kilavuzKodu) {
+ kodsuz++;
+ continue;
+ }
+ benzersiz.set(String(r.kilavuzKodu), r);
+ }
+ console.log(
+ `Çekilen: ${kayitlar.length} kayıt, benzersiz kod: ${benzersiz.size}, kodsuz: ${kodsuz}, API toplamı: ${toplam}`
+ );
+ if (benzersiz.size + kodsuz !== toplam || kayitlar.length !== toplam) {
+ dur(
+ `Benzersiz kod sayısı (${benzersiz.size}) + kodsuz (${kodsuz}) ≠ API toplamı (${toplam}). ` +
+ "Sayfalama sırasında kayıt kaymış/yinelenmiş; DB'ye HİÇBİR ŞEY yazılmadı. Tekrar dene."
+ );
+ }
+ // ── Yazım: tek transaction ─────────────────────────────────────────────
const update = db.prepare(`
- UPDATE programs SET sira2025 = ?, puan2025 = ?, kontenjan2025 = ?, yerlesen2025 = ?
+ UPDATE programs
+ SET sira${yil} = ?, puan${yil} = ?, kontenjan${yil} = ?, yerlesen${yil} = ?,
+ son_kilavuz_yili = ?, eski_kod = COALESCE(?, eski_kod)
WHERE id = ?
`);
+ const gecmisYillar = [1, 2, 3].filter((f) => yil - f >= ILK_YIL);
const insert = db.prepare(`
INSERT INTO programs (id, isim, universite, unitur, il, fakulte, tur, sure, onlisans,
- sira2025, puan2025, kontenjan2025, yerlesen2025)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ sira${yil}, puan${yil}, kontenjan${yil}, yerlesen${yil},
+ son_kilavuz_yili, eski_kod
+ ${gecmisYillar.map((f) => `, sira${yil - f}, puan${yil - f}, kontenjan${yil - f}`).join("")})
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${gecmisYillar.map(() => ", ?, ?, ?").join("")})
`);
const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
+ // Yeni satırda yazım/sözlük tutarlılığı — TAHMİN YOK, yalnız DB'de zaten var
+ // olan yazımı yeniden kullanma:
+ // - unitur: API sözlüğü (DEVLET/VAKIF/KKTC/YURTDIŞI) DB sözlüğünden (VAKIF MYO,
+ // YURTDISI KAMU…) farklı. Aynı üniversitenin mevcut satırlarında TEK bir
+ // unitur varsa o kullanılır; yoksa API değeri olduğu gibi yazılır. Aksi
+ // hâlde aynı kurumda iki unitur oluşur ve uniturOnar haklı olarak durur.
+ // - fakulte: API 2026'da BÜYÜK HARF veriyor; aynı üniversitede büyük harfe
+ // çevrilince aynı olan mevcut yazım varsa o kullanılır.
+ const bazAd = (ad: string) => ad.replace(/\s*\([^)]*\)\s*$/, "").trim();
+ const uniturlar = new Map>();
+ const fakulteler = new Map>();
+ for (const s of db
+ .prepare("SELECT DISTINCT universite, unitur, fakulte FROM programs")
+ .all() as { universite: string; unitur: string | null; fakulte: string | null }[]) {
+ const baz = bazAd(s.universite);
+ if (s.unitur) {
+ for (const k of [s.universite, baz]) {
+ const set = uniturlar.get(k) ?? new Set();
+ set.add(s.unitur);
+ uniturlar.set(k, set);
+ }
+ }
+ if (s.fakulte) {
+ const m = fakulteler.get(baz) ?? new Map();
+ const anahtar = s.fakulte.toLocaleUpperCase("tr-TR");
+ // Aynı anahtara iki farklı yazım düşerse belirsiz → yeniden kullanma
+ m.set(anahtar, m.has(anahtar) && m.get(anahtar) !== s.fakulte ? "" : s.fakulte);
+ fakulteler.set(baz, m);
+ }
+ }
+ const uniturSec = (universite: string, api: string | null): string | null => {
+ for (const k of [universite, bazAd(universite)]) {
+ const set = uniturlar.get(k);
+ if (set?.size === 1) return [...set][0];
+ }
+ return api;
+ };
+ const fakulteSec = (universite: string, api: string | null): string | null => {
+ if (!api) return null;
+ const mevcut = fakulteler.get(bazAd(universite))?.get(api.toLocaleUpperCase("tr-TR"));
+ return mevcut || api;
+ };
+
let updated = 0;
let inserted = 0;
- let skipped = 0;
+ const atlananlar: string[] = [];
- const first = await fetchPage(0);
- const totalPages = first.totalPages;
- console.log(
- `Toplam ${first.totalElements} kayıt, ${totalPages} sayfa çekilecek…`
- );
+ db.transaction(() => {
+ const azamiRowid = (
+ db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM programs").get() as { m: number }
+ ).m;
+ const once = oncekiYilSaglamasi(db, yil, azamiRowid);
- const processPage = db.transaction((records: ApiRecord[]) => {
- for (const r of records) {
+ for (const [id, r] of benzersiz) {
const tur = TUR_MAP[r.puanTuru?.trim() ?? ""];
- if (!r.kilavuzKodu || !tur) {
- skipped++;
+ // Puan türü yalnız YENİ satır için gerekir; mevcut satırın türü DB'de var.
+ // Kaynak türü vermiyorsa yeni satır UYDURULMAZ, atlanır ve raporlanır.
+ if (!tur && !exists.get(id)) {
+ atlananlar.push(`${id} ${r.universiteAdi} / ${r.birimAdi} (puanTuru="${r.puanTuru}")`);
continue;
}
- const id = String(r.kilavuzKodu);
const vals = [
intOrNull(r.basariSirasi),
realOrNull(r.minPuan),
intOrNull(r.kontenjan),
yerlesen(r),
];
+ const eski = eskiKod(r);
if (exists.get(id)) {
- update.run(...vals, id);
+ update.run(...vals, yil, eski, id);
updated++;
} else {
+ const gecmis = gecmisAlanlari(r).filter((g) => yil - g.fark >= ILK_YIL);
+ // "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
+ // duplikat kurum adı üretmesin
+ const universite = uniAdiNormalize(r.universiteAdi);
insert.run(
id,
r.birimAdi,
- // "ÜNİVERSİTESİ- CERRAHPAŞA" gibi tire etrafı boşluklu varyantlar
- // duplikat kurum adı üretmesin
- uniAdiNormalize(r.universiteAdi),
- r.universiteTuru ?? null,
+ universite,
+ uniturSec(universite, r.universiteTuru ?? null),
r.ilAdi ?? null,
- r.fymkAdi ?? null,
+ fakulteSec(universite, r.fymkAdi ?? null),
tur,
r.ogrenimSuresi ?? null,
r.birimTuruId === 47 ? 1 : 0,
- ...vals
+ ...vals,
+ yil,
+ eski,
+ ...gecmis.flatMap((g) => [g.sira, g.puan, g.kontenjan])
);
inserted++;
}
}
- });
- processPage(first.content);
- for (let p = 1; p < totalPages; p++) {
- await sleep(DELAY_MS);
- const page = await fetchPage(p);
- processPage(page.content);
- process.stdout.write(`\rsayfa ${p + 1}/${totalPages}`);
- }
- console.log();
+ const sonra = oncekiYilSaglamasi(db, yil, azamiRowid);
+ if (once !== sonra) {
+ dur(
+ "Mevcut satırların önceki yıl kolonları değişti — bu betik onlara dokunmamalı. Geri alındı.\n" +
+ `önce : ${once}\nsonra: ${sonra}`
+ );
+ }
+ const gorulen = (
+ db.prepare("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?").get(yil) as { n: number }
+ ).n;
+ const beklenen = toplam - kodsuz - atlananlar.length;
+ if (gorulen !== beklenen) {
+ dur(
+ `son_kilavuz_yili=${yil} satır sayısı ${gorulen}, beklenen ${beklenen} ` +
+ `(API ${toplam} − atlanan ${kodsuz + atlananlar.length}). Geri alındı.`
+ );
+ }
+
+ metaYaz(db, `refresh_${yil}_api_toplam`, toplam);
+ metaYaz(db, `refresh_${yil}_atlanan`, kodsuz + atlananlar.length);
+ metaYaz(db, `refresh_${yil}_tarih`, new Date().toISOString());
+ metaYaz(db, `refresh_${yil}_ham`, path.basename(hamDosya));
+ })();
- const stats = db
- .prepare(
- `SELECT COUNT(*) AS toplam,
- SUM(CASE WHEN sira2025 IS NOT NULL THEN 1 ELSE 0 END) AS sirali
- FROM programs`
- )
- .get() as { toplam: number; sirali: number };
console.log(
- `Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${skipped}`
- );
- console.log(
- `DB toplam: ${stats.toplam} program, 2025 sıralaması olan: ${stats.sirali}`
+ `Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${kodsuz + atlananlar.length}`
);
+ for (const a of atlananlar) console.log(` atlandı: ${a}`);
// API'nin universiteTuru döndürmediği kayıtlar devlet/vakıf filtrelerinden
// düşmesin: her tazelemeden sonra unitur onarımı koşulur (idempotent).
db.transaction(() => uniturOnar(db))();
+ // Sorgu yolunun ifade indeksi — ifade veri-yillari.ts'ten; eski yılınki DROP.
+ console.log(`Efektif indeks: ${efektifIndeksiKur(db)}`);
+
+ const stats = db
+ .prepare(
+ `SELECT COUNT(*) AS toplam,
+ SUM(son_kilavuz_yili = ${yil}) AS guncel,
+ COUNT(sira${yil}) AS sirali
+ FROM programs`
+ )
+ .get() as { toplam: number; guncel: number; sirali: number };
+ console.log(
+ `DB toplam: ${stats.toplam} program; ${yil} kılavuzunda: ${stats.guncel}; ` +
+ `${yil} sıralaması olan: ${stats.sirali}`
+ );
+ db.pragma("wal_checkpoint(TRUNCATE)");
db.close();
+ console.log(
+ `\nSıradaki adım: pnpm gecmis-doldur --yil ${yil} --db --ham ${path.relative(process.cwd(), hamDosya)}`
+ );
}
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
+if (process.argv[1]?.endsWith("refresh.ts")) calistir(main);
diff --git a/scripts/veri-kalite.ts b/scripts/veri-kalite.ts
new file mode 100644
index 0000000..6309f91
--- /dev/null
+++ b/scripts/veri-kalite.ts
@@ -0,0 +1,228 @@
+/**
+ * yokatlas.db veri kalitesi denetimi — SALT OKUNUR. Yayın öncesi kapı:
+ * bir ZORUNLU kontrol başarısızsa çıkış kodu 1 (CI/runbook durur).
+ * UYARI'lar çıkış kodunu etkilemez; insan gözüyle bakılır.
+ *
+ * Kullanım:
+ * pnpm veri-kalite --yil 2026 --db /tmp/deneme.db [--onceki data/yokatlas.db] [--arsivli]
+ *
+ * --onceki Güncelleme ÖNCESİ DB. Verilirse: iki DB'de de bulunan her
+ * programın önceki yıl kolonları (sira/puan/kontenjan/yerlesen)
+ * birebir aynı mı (dolu değer değişmiş/silinmiş olamaz).
+ * --arsivli programs içinde güncel kılavuzda olmayan satır kalmamış olmalı.
+ */
+import path from "node:path";
+import { EFEKTIF_SIRA_SQL, ILK_YIL } from "../src/lib/veri-yillari";
+import { EFEKTIF_INDEKS, efektifSorguPlani } from "./db-index";
+import {
+ argOku,
+ bayrakVar,
+ calistir,
+ dbAc,
+ dbArgumani,
+ dur,
+ kolonAdlari,
+ metaOku,
+ tabloVar,
+ yilArgumani,
+} from "./yokatlas-goc";
+
+const TURLER = ["SAYISAL", "EŞİT AĞIRLIK", "SÖZEL", "DİL", "TYT"];
+
+async function main() {
+ const yil = yilArgumani();
+ const dbYolu = dbArgumani();
+ const oncekiArg = argOku("onceki");
+ const arsivli = bayrakVar("arsivli");
+ const db = dbAc(dbYolu, { saltOkunur: true });
+ const onceki = yil - 1;
+
+ let basarisiz = 0;
+ let uyari = 0;
+ const zorunlu = (ad: string, tamam: boolean, detay: string) => {
+ console.log(`${tamam ? "GEÇTİ " : "KALDI "} ${ad} — ${detay}`);
+ if (!tamam) basarisiz++;
+ };
+ const bilgi = (ad: string, sorunlu: boolean, detay: string) => {
+ console.log(`${sorunlu ? "UYARI " : "bilgi "} ${ad} — ${detay}`);
+ if (sorunlu) uyari++;
+ };
+ const sayi = (sql: string, ...args: unknown[]) =>
+ (db.prepare(sql).get(...args) as { n: number }).n;
+ const ornek = (sql: string, ...args: unknown[]) =>
+ (db.prepare(sql).raw().all(...args) as unknown[][]).map((r) => r.join("/")).join(" ; ");
+
+ console.log(`DB: ${dbYolu}\nYıl: ${yil} Efektif sıra: ${EFEKTIF_SIRA_SQL}\n`);
+
+ // 1. Dosya bütünlüğü
+ const butunluk = (db.pragma("integrity_check") as { integrity_check: string }[])
+ .map((r) => r.integrity_check)
+ .join(",");
+ zorunlu("integrity_check", butunluk === "ok", butunluk);
+
+ // 2. Şema
+ const kolonlar = new Set(kolonAdlari(db, "programs"));
+ const gerekli = [`sira${yil}`, `puan${yil}`, `kontenjan${yil}`, `yerlesen${yil}`, "son_kilavuz_yili", "eski_kod"];
+ const eksikKolon = gerekli.filter((k) => !kolonlar.has(k));
+ zorunlu("şema", eksikKolon.length === 0, eksikKolon.length ? `eksik: ${eksikKolon.join(", ")}` : "yıl kolonları + son_kilavuz_yili + eski_kod var");
+ if (eksikKolon.length) {
+ dur("Şema eksik; kalan kontroller koşulamaz.");
+ }
+
+ // 3. Kapsam: güncel satır = API toplamı − atlanan
+ const apiToplam = Number(metaOku(db, `refresh_${yil}_api_toplam`) ?? NaN);
+ const atlanan = Number(metaOku(db, `refresh_${yil}_atlanan`) ?? NaN);
+ const guncel = sayi("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?", yil);
+ zorunlu(
+ "kapsam",
+ Number.isFinite(apiToplam) && guncel === apiToplam - atlanan,
+ `son_kilavuz_yili=${yil}: ${guncel}; API toplamı ${apiToplam} − atlanan ${atlanan} = ${apiToplam - atlanan}`
+ );
+
+ // 4. Doluluk
+ const sirali = sayi(`SELECT COUNT(sira${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ const oncekiSirali = sayi(`SELECT COUNT(sira${onceki}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ const doluOran = guncel ? sirali / guncel : 0;
+ zorunlu(
+ `sira${yil} doluluk`,
+ doluOran >= 0.7,
+ `${sirali}/${guncel} (%${(doluOran * 100).toFixed(1)}; eşik %70). Aynı satırlarda sira${onceki} dolu: ${oncekiSirali}`
+ );
+ const puanli = sayi(`SELECT COUNT(puan${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ const kontenjanli = sayi(`SELECT COUNT(kontenjan${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
+ bilgi(`${yil} diğer ölçüler`, kontenjanli < guncel * 0.95, `puan dolu ${puanli}, kontenjan dolu ${kontenjanli}, toplam ${guncel}`);
+
+ // 5. Yıllar arası tutarlılık: sira Y / sira Y-1 ∈ [0,5; 2]
+ const ikisiDolu = sayi(`SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${onceki} IS NOT NULL`);
+ const aralikta = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${onceki} IS NOT NULL
+ AND sira${yil} * 1.0 / sira${onceki} BETWEEN 0.5 AND 2`
+ );
+ const aralikOran = ikisiDolu ? aralikta / ikisiDolu : 0;
+ zorunlu(
+ `sira${yil}/sira${onceki} ∈ [0,5; 2]`,
+ ikisiDolu > 0 && aralikOran >= 0.9,
+ `${aralikta}/${ikisiDolu} (%${(aralikOran * 100).toFixed(1)}; eşik %90)`
+ );
+ const ayniSira = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${yil} = sira${onceki} AND puan${yil} = puan${onceki}`
+ );
+ zorunlu(
+ "yıl kayması (kopya yıl)",
+ ikisiDolu > 0 && ayniSira / ikisiDolu < 0.02,
+ `sıra VE puanı ${onceki} ile birebir aynı satır: ${ayniSira}/${ikisiDolu} (çoksa ${yil} kolonuna ${onceki} verisi yazılmıştır)`
+ );
+
+ // 6. Değer aralıkları (tüm yıllar)
+ const yillar: number[] = [];
+ for (let y = ILK_YIL; y <= yil; y++) if (kolonlar.has(`sira${y}`)) yillar.push(y);
+ const bozukKosul = yillar
+ .map((y) => `sira${y} <= 0 OR sira${y} > 5000000 OR puan${y} <= 0 OR puan${y} > 600 OR kontenjan${y} < 0 OR yerlesen${y} < 0`)
+ .join(" OR ");
+ const bozuk = sayi(`SELECT COUNT(*) AS n FROM programs WHERE ${bozukKosul}`);
+ zorunlu("değer aralığı", bozuk === 0, `0/negatif/aşırı sıra-puan-kontenjan: ${bozuk}${bozuk ? ` ör. ${ornek(`SELECT id FROM programs WHERE ${bozukKosul} LIMIT 3`)}` : ""}`);
+
+ const siraPuanTutarsiz = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND puan${yil} IS NULL`
+ );
+ zorunlu(`sira${yil} var, puan${yil} yok`, siraPuanTutarsiz === 0, `${siraPuanTutarsiz} satır`);
+
+ // 7. Kategorik alanlar
+ const bozukTur = sayi(
+ `SELECT COUNT(*) AS n FROM programs WHERE tur NOT IN (${TURLER.map(() => "?").join(",")}) OR onlisans NOT IN (0, 1)`,
+ ...TURLER
+ );
+ zorunlu("tur/onlisans", bozukTur === 0, `geçersiz değer: ${bozukTur}`);
+ const tytTutarsiz = sayi("SELECT COUNT(*) AS n FROM programs WHERE (tur = 'TYT') <> (onlisans = 1) AND son_kilavuz_yili = ?", yil);
+ bilgi("TYT ↔ önlisans", tytTutarsiz > 0, `tur=TYT ile onlisans=1 örtüşmeyen güncel satır: ${tytTutarsiz}${tytTutarsiz ? ` ör. ${ornek("SELECT id, tur, onlisans FROM programs WHERE (tur = 'TYT') <> (onlisans = 1) AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
+ const uniturBos = sayi("SELECT COUNT(*) AS n FROM programs WHERE unitur IS NULL AND son_kilavuz_yili = ?", yil);
+ bilgi("unitur boş", uniturBos > 0, `güncel satırlarda ${uniturBos} (devlet/vakıf filtresinden düşer)${uniturBos ? ` ör. ${ornek("SELECT DISTINCT universite FROM programs WHERE unitur IS NULL AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
+ const uniturlar = ornek("SELECT COALESCE(unitur, '(NULL)'), COUNT(*) FROM programs GROUP BY unitur ORDER BY 2 DESC");
+ bilgi("unitur dağılımı", false, uniturlar);
+ const ilBos = sayi("SELECT COUNT(*) AS n FROM programs WHERE (il IS NULL OR TRIM(il) = '') AND son_kilavuz_yili = ?", yil);
+ bilgi("il boş", ilBos > 0, `güncel satırlarda ${ilBos}${ilBos ? ` ör. ${ornek("SELECT id, universite FROM programs WHERE (il IS NULL OR TRIM(il) = '') AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
+
+ // 8. Kontenjan–yerleşen
+ const tasan = sayi(`SELECT COUNT(*) AS n FROM programs WHERE yerlesen${yil} > kontenjan${yil} * 1.5 + 5`);
+ bilgi(`yerlesen${yil} ≫ kontenjan${yil}`, tasan > guncel * 0.02, `yerleşen > 1,5×genel kontenjan + 5: ${tasan} (yerleşen okul birincisi/şehit-gazi/deprem kontenjanlarını da içerir)${tasan ? ` ör. ${ornek(`SELECT id, kontenjan${yil}, yerlesen${yil} FROM programs WHERE yerlesen${yil} > kontenjan${yil} * 1.5 + 5 LIMIT 3`)}` : ""}`);
+ const doluAmaSirasiz = sayi(`SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ? AND sira${yil} IS NULL AND yerlesen${yil} > 0`, yil);
+ bilgi(`yerleşen var, sira${yil} yok`, false, `${doluAmaSirasiz} (API az yerleşenli programda sıra vermez; efektif sıra ${onceki}'e düşer)`);
+
+ // 9. Eski/çift satırlar
+ const eskiSatir = sayi("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili IS NOT ?", yil);
+ if (arsivli) zorunlu("arşiv", eskiSatir === 0, `programs içinde güncel kılavuzda olmayan satır: ${eskiSatir}`);
+ else bilgi("arşivlenmemiş eski satır", eskiSatir > 0, `${eskiSatir} satır güncel kılavuzda yok ama sitede listelenir (efektif sıra ${onceki}'e düşer)`);
+ const ciftAd = sayi(
+ `SELECT COUNT(*) AS n FROM (SELECT 1 FROM programs WHERE son_kilavuz_yili = ?
+ GROUP BY universite, fakulte, isim, tur HAVING COUNT(*) > 1)`,
+ yil
+ );
+ bilgi("aynı adlı güncel program", false, `(universite, fakulte, isim, tur) yinelenen grup: ${ciftAd} (özel kontenjanlı adaş programlar olağandır)${ciftAd ? ` ör. ${ornek("SELECT universite, isim, COUNT(*) FROM programs WHERE son_kilavuz_yili = ? GROUP BY universite, fakulte, isim, tur HAVING COUNT(*) > 1 LIMIT 3", yil)}` : ""}`);
+ const eskiKodCakisma = sayi(
+ "SELECT COUNT(*) AS n FROM (SELECT eski_kod FROM programs WHERE eski_kod IS NOT NULL AND son_kilavuz_yili = ? GROUP BY eski_kod HAVING COUNT(*) > 1)",
+ yil
+ );
+ bilgi("eski_kod paylaşımı (bölünme)", eskiKodCakisma > 0, `birden çok güncel programın işaret ettiği eski kod: ${eskiKodCakisma}`);
+
+ // 10. netler
+ if (tabloVar(db, "netler")) {
+ const arsivVar = tabloVar(db, "programs_arsiv");
+ const yetim = sayi(
+ `SELECT COUNT(*) AS n FROM netler WHERE program_id NOT IN (SELECT id FROM programs)
+ ${arsivVar ? "AND program_id NOT IN (SELECT id FROM programs_arsiv)" : ""}`
+ );
+ bilgi("yetim netler", yetim > 0, `ne programs ne arşivde karşılığı olan: ${yetim}`);
+ const netliGuncel = sayi(
+ "SELECT COUNT(DISTINCT n.program_id) AS n FROM netler n JOIN programs p ON p.id = n.program_id WHERE p.son_kilavuz_yili = ?",
+ yil
+ );
+ bilgi("netler kapsamı", false, `net satırı olan güncel program: ${netliGuncel}/${guncel}; yıl dağılımı: ${ornek("SELECT yil, COUNT(*) FROM netler GROUP BY yil ORDER BY yil")}`);
+ }
+
+ // 11. Sorgu planı
+ const plan = efektifSorguPlani(db);
+ zorunlu("sorgu planı", plan.some((s) => s.includes(EFEKTIF_INDEKS)), plan.join(" | "));
+
+ // 12. Önceki DB ile geçmiş yıl karşılaştırması
+ if (oncekiArg) {
+ const oncekiYol = path.resolve(process.cwd(), oncekiArg);
+ db.prepare("ATTACH DATABASE ? AS eski").run(oncekiYol);
+ const eskiKolonlar = new Set(
+ (db.prepare("PRAGMA eski.table_info(programs)").all() as { name: string }[]).map((c) => c.name)
+ );
+ const ortak: string[] = [];
+ for (let y = ILK_YIL; y < yil; y++) {
+ for (const o of ["sira", "puan", "kontenjan", "yerlesen"]) {
+ if (kolonlar.has(`${o}${y}`) && eskiKolonlar.has(`${o}${y}`)) ortak.push(`${o}${y}`);
+ }
+ }
+ const simdi = tabloVar(db, "programs_arsiv")
+ ? `(SELECT id, ${ortak.join(", ")} FROM main.programs UNION ALL SELECT id, ${ortak.join(", ")} FROM main.programs_arsiv)`
+ : "main.programs";
+ const kaybolan = sayi(`SELECT COUNT(*) AS n FROM eski.programs e WHERE e.id NOT IN (SELECT id FROM ${simdi})`);
+ zorunlu("önceki DB: satır kaybı", kaybolan === 0, `önceki DB'de olup şimdi ne programs ne arşivde olan: ${kaybolan}`);
+ // Dolu değer değişmiş ya da NULL'a dönmüş olamaz; NULL → dolu (gecmis-doldur) serbest.
+ const degisen = sayi(
+ `SELECT COUNT(*) AS n FROM eski.programs e JOIN ${simdi} s ON s.id = e.id
+ WHERE ${ortak.map((k) => `(e.${k} IS NOT NULL AND e.${k} IS NOT s.${k})`).join(" OR ")}`
+ );
+ zorunlu("önceki DB: geçmiş yıllar korunmuş", degisen === 0, `${ortak.length} kolonda dolu değeri değişen/silinen satır: ${degisen}`);
+ const toplamlar = (tablo: string) =>
+ (db
+ .prepare(
+ `SELECT COUNT(*), TOTAL(sira${onceki}), TOTAL(puan${onceki}), TOTAL(kontenjan${onceki}), TOTAL(yerlesen${onceki}) FROM ${tablo}`
+ )
+ .raw()
+ .get() as number[]).join(" | ");
+ console.log(` ${onceki} sağlaması (count | Σsira | Σpuan | Σkontenjan | Σyerlesen)`);
+ console.log(` önceki DB : ${toplamlar("eski.programs")}`);
+ console.log(` şimdi, önceki DB'deki aynı id'ler : ${toplamlar(`${simdi} WHERE id IN (SELECT id FROM eski.programs)`)}`);
+ console.log(` şimdi, tüm satırlar (yeni dahil) : ${toplamlar(simdi)}`);
+ }
+
+ db.close();
+ console.log(`\nSonuç: ${basarisiz} ZORUNLU kontrol kaldı, ${uyari} uyarı.`);
+ if (basarisiz > 0) process.exit(1);
+}
+
+if (process.argv[1]?.endsWith("veri-kalite.ts")) calistir(main);
diff --git a/scripts/yokatlas-goc.ts b/scripts/yokatlas-goc.ts
new file mode 100644
index 0000000..7f38e62
--- /dev/null
+++ b/scripts/yokatlas-goc.ts
@@ -0,0 +1,267 @@
+/**
+ * yokatlas.db şema göçü — idempotent. Yeni yerleştirme yılı için kolonları,
+ * `son_kilavuz_yili` / `eski_kod` kolonlarını, `programs_arsiv` ve `veri_meta`
+ * tablolarını garanti eder. Hiçbir mevcut değeri silmez/ezmez.
+ *
+ * Betiklerin ortak yardımcıları (argüman okuma, DB açma) da buradadır;
+ * refresh / gecmis-doldur / eski-arsivle / detay / veri-kalite buradan alır.
+ *
+ * Kullanım: pnpm yokatlas:goc --yil 2026 --db data/yokatlas.db
+ */
+import fs from "node:fs";
+import path from "node:path";
+import Database from "better-sqlite3";
+import { ILK_YIL, SON_YIL } from "../src/lib/veri-yillari";
+
+export type Db = Database.Database;
+
+/** Her yıl için tutulan dört ölçü ve SQLite tipi. */
+export const YIL_OLCULERI = [
+ ["sira", "INTEGER"],
+ ["puan", "REAL"],
+ ["kontenjan", "INTEGER"],
+ ["yerlesen", "INTEGER"],
+] as const;
+
+// ── Komut satırı ─────────────────────────────────────────────────────────
+
+/** `--ad deger` ya da `--ad=deger`. Yoksa undefined. */
+export function argOku(ad: string): string | undefined {
+ const argv = process.argv.slice(2);
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === `--${ad}`) {
+ const sonraki = argv[i + 1];
+ return sonraki && !sonraki.startsWith("--") ? sonraki : undefined;
+ }
+ if (argv[i].startsWith(`--${ad}=`)) return argv[i].slice(ad.length + 3);
+ }
+ return undefined;
+}
+
+export function bayrakVar(ad: string): boolean {
+ return process.argv.slice(2).includes(`--${ad}`);
+}
+
+export class DurHatasi extends Error {}
+
+/** Güvenlik kilidi: mesajı yazıp betiği durdurmak için fırlatılır. */
+export function dur(mesaj: string): never {
+ throw new DurHatasi(mesaj);
+}
+
+/** main() sarmalayıcı: DurHatasi → temiz mesaj + çıkış kodu 1. */
+export function calistir(main: () => Promise | void): void {
+ Promise.resolve()
+ .then(main)
+ .catch((err) => {
+ if (err instanceof DurHatasi) console.error(`\nDURDU: ${err.message}`);
+ else console.error(err);
+ process.exit(1);
+ });
+}
+
+/**
+ * `--yil` ZORUNLU ve `SON_YIL` ile aynı olmalı. Varsayılan yok: betik hangi
+ * yılı yazdığını API'den ya da takvimden TAHMİN ETMEZ.
+ */
+export function yilArgumani(): number {
+ const ham = argOku("yil");
+ if (!ham) {
+ dur(
+ `--yil zorunlu (ör. --yil ${SON_YIL}). Varsayılan yok; yıl API kaydındaki ` +
+ "`yil` alanından da türetilmez."
+ );
+ }
+ const yil = Number.parseInt(ham, 10);
+ if (!Number.isInteger(yil) || String(yil) !== ham.trim()) {
+ dur(`--yil sayı olmalı: "${ham}"`);
+ }
+ if (yil !== SON_YIL) {
+ dur(
+ `--yil ${yil} ≠ SON_YIL ${SON_YIL} (src/lib/veri-yillari.ts). Yeni yıl ` +
+ "yükleniyorsa önce SON_YIL'i güncelle; betikler ve site aynı sabiti okur."
+ );
+ }
+ return yil;
+}
+
+/** `--db` ZORUNLU; dosya var olmalı. Yanlışlıkla boş DB yaratılmaz. */
+export function dbArgumani(): string {
+ const ham = argOku("db");
+ if (!ham) {
+ dur(
+ "--db zorunlu (ör. --db data/yokatlas.db). Önce kopya üzerinde dene: " +
+ "cp data/yokatlas.db /tmp/deneme.db"
+ );
+ }
+ const yol = path.resolve(process.cwd(), ham);
+ if (!fs.existsSync(yol)) dur(`DB bulunamadı: ${yol}`);
+ return yol;
+}
+
+export function dbAc(yol: string, secenek: { saltOkunur?: boolean } = {}): Db {
+ const db = new Database(yol, {
+ readonly: secenek.saltOkunur ?? false,
+ fileMustExist: true,
+ });
+ if (!secenek.saltOkunur) db.pragma("journal_mode = WAL");
+ return db;
+}
+
+// ── Şema ─────────────────────────────────────────────────────────────────
+
+export function tabloVar(db: Db, tablo: string): boolean {
+ return (
+ db
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
+ .get(tablo) !== undefined
+ );
+}
+
+export function kolonAdlari(db: Db, tablo: string): string[] {
+ return (
+ db.prepare(`PRAGMA table_info(${tablo})`).all() as { name: string }[]
+ ).map((c) => c.name);
+}
+
+function yilDogrula(yil: number): void {
+ if (!Number.isInteger(yil) || yil < ILK_YIL || yil > 2100) {
+ dur(`Geçersiz yıl: ${yil}`);
+ }
+}
+
+/**
+ * sira/puan/kontenjan/yerlesen kolonlarını (yoksa) ekler. İdempotent.
+ * Kontenjan kırılımı (gk/obk/…) ve kontenjanMeb kolonları BİLEREK eklenmez:
+ * `src/` içinde okuyan kod yok (KARARLAR 2026-09-21).
+ */
+export function yilKolonlariniGarantiEt(
+ db: Db,
+ yil: number,
+ tablo = "programs"
+): string[] {
+ yilDogrula(yil);
+ const mevcut = new Set(kolonAdlari(db, tablo));
+ const eklenen: string[] = [];
+ for (const [olcu, tip] of YIL_OLCULERI) {
+ const kolon = `${olcu}${yil}`;
+ if (mevcut.has(kolon)) continue;
+ db.exec(`ALTER TABLE ${tablo} ADD COLUMN ${kolon} ${tip}`);
+ eklenen.push(kolon);
+ }
+ return eklenen;
+}
+
+/**
+ * `son_kilavuz_yili`: programın görüldüğü son tercih kılavuzu yılı.
+ * `eski_kod`: kılavuz kodu değiştiyse bir önceki kod (API `eskiKilavuzKodu`
+ * ya da gecmis-doldur'un benzersiz ad eşleşmesi).
+ *
+ * Geri doldurma TEK SEFERLİK — yalnız kolon ilk kez eklenirken koşar:
+ * kontenjan2025 dolu → 2025 kılavuzunda vardı. Gerisi NULL (bilinmiyor) kalır.
+ */
+export function kilavuzKolonlariniGarantiEt(db: Db): string[] {
+ const mevcut = new Set(kolonAdlari(db, "programs"));
+ const eklenen: string[] = [];
+ if (!mevcut.has("son_kilavuz_yili")) {
+ db.exec("ALTER TABLE programs ADD COLUMN son_kilavuz_yili INTEGER");
+ eklenen.push("son_kilavuz_yili");
+ if (mevcut.has("kontenjan2025")) {
+ const r = db
+ .prepare(
+ "UPDATE programs SET son_kilavuz_yili = 2025 WHERE kontenjan2025 IS NOT NULL"
+ )
+ .run();
+ console.log(` son_kilavuz_yili geri doldurma (2025): ${r.changes} satır`);
+ }
+ }
+ if (!mevcut.has("eski_kod")) {
+ db.exec("ALTER TABLE programs ADD COLUMN eski_kod TEXT");
+ eklenen.push("eski_kod");
+ }
+ return eklenen;
+}
+
+/**
+ * `programs_arsiv`: programs ile AYNI kolonlar (ad + tip + sıra). Yoksa kurar;
+ * varsa programs'a sonradan eklenmiş kolonları ona da ekler. Böylece
+ * `INSERT INTO programs_arsiv SELECT * FROM programs` her zaman geçerlidir…
+ * yine de taşıma kolon adlarıyla yapılır (bkz. eski-arsivle.ts).
+ */
+export function arsivTablosunuGarantiEt(db: Db): string[] {
+ const kolonlar = db.prepare("PRAGMA table_info(programs)").all() as {
+ name: string;
+ type: string;
+ }[];
+ if (!tabloVar(db, "programs_arsiv")) {
+ const tanim = kolonlar
+ .map((c) =>
+ c.name === "id"
+ ? "id TEXT PRIMARY KEY"
+ : `${c.name} ${c.type || "TEXT"}`
+ )
+ .join(",\n ");
+ db.exec(`CREATE TABLE programs_arsiv (\n ${tanim}\n)`);
+ return ["programs_arsiv"];
+ }
+ const arsivde = new Set(kolonAdlari(db, "programs_arsiv"));
+ const eklenen: string[] = [];
+ for (const c of kolonlar) {
+ if (arsivde.has(c.name)) continue;
+ db.exec(
+ `ALTER TABLE programs_arsiv ADD COLUMN ${c.name} ${c.type || "TEXT"}`
+ );
+ eklenen.push(`programs_arsiv.${c.name}`);
+ }
+ return eklenen;
+}
+
+/** Koşu kayıtları (API toplamı, atlanan, ham dosya…): veri-kalite okur. */
+export function metaTablosunuGarantiEt(db: Db): void {
+ db.exec(
+ "CREATE TABLE IF NOT EXISTS veri_meta (anahtar TEXT PRIMARY KEY, deger TEXT NOT NULL)"
+ );
+}
+
+export function metaYaz(db: Db, anahtar: string, deger: string | number): void {
+ metaTablosunuGarantiEt(db);
+ db.prepare(
+ "INSERT INTO veri_meta (anahtar, deger) VALUES (?, ?) " +
+ "ON CONFLICT(anahtar) DO UPDATE SET deger = excluded.deger"
+ ).run(anahtar, String(deger));
+}
+
+export function metaOku(db: Db, anahtar: string): string | undefined {
+ if (!tabloVar(db, "veri_meta")) return undefined;
+ const r = db
+ .prepare("SELECT deger FROM veri_meta WHERE anahtar = ?")
+ .get(anahtar) as { deger: string } | undefined;
+ return r?.deger;
+}
+
+/** Tüm göç: yıl kolonları + kılavuz kolonları + arşiv + meta. İdempotent. */
+export function yokatlasGoc(db: Db, yil: number): string[] {
+ const eklenen: string[] = [];
+ db.transaction(() => {
+ eklenen.push(...yilKolonlariniGarantiEt(db, yil));
+ eklenen.push(...kilavuzKolonlariniGarantiEt(db));
+ eklenen.push(...arsivTablosunuGarantiEt(db));
+ metaTablosunuGarantiEt(db);
+ })();
+ return eklenen;
+}
+
+// Doğrudan koşulduğunda
+if (process.argv[1]?.endsWith("yokatlas-goc.ts")) {
+ calistir(() => {
+ const yil = yilArgumani();
+ const db = dbAc(dbArgumani());
+ const eklenen = yokatlasGoc(db, yil);
+ console.log(
+ eklenen.length
+ ? `Göç tamam. Eklenen: ${eklenen.join(", ")}`
+ : "Göç tamam. Şema zaten güncel (değişiklik yok)."
+ );
+ db.close();
+ });
+}
From cd26b1fd56d7db75bf38f3a666ba0c89c4723e64 Mon Sep 17 00:00:00 2001
From: bilalgursen
Date: Mon, 21 Sep 2026 17:04:25 +0300
Subject: [PATCH 8/9] =?UTF-8?q?data:=20yokatlas.db=202026=20yerle=C5=9Ftir?=
=?UTF-8?q?me=20verisi=20(ar=C5=9Fivli)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
YÖK Atlas tercih-kilavuz API'si, çekim 2026-09-21: 21.493 program (18.251'inde
2026 sırası). Son kılavuzda olmayan 3.706 satır programs_arsiv'e taşındı.
2025 ve önceki dolu değerler değişmedi (veri-kalite: 0 fark).
Co-Authored-By: Claude Fable 5.1
---
data/yokatlas.db | Bin 17911808 -> 17502208 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
diff --git a/data/yokatlas.db b/data/yokatlas.db
index 1258876532561a2fb3df8bf19e005a85792fb898..bd2ed1f4f99f27c632a968373cffc5a29914a6aa 100644
GIT binary patch
delta 2439976
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 UAv16%n64fRV$
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