feat(seo): GEO paketi — yapılandırılmış metadata, AI bot erişimi, veri tazelik sinyali

- sayfaMetadata(): indekslenebilir sayfalar için tek kaynaklı canonical +
  eksiksiz openGraph üretimi; sayfalar buna geçirildi
- robots.ts: yalnızca /api/ engellenir; noindex'li sayfalar robots engeli
  yerine meta ile yönetilir, AI botları niyeti belgeleyen açık grupla izinli
- next.config: htmlLimitedBots'a AI botları eklendi (JS çalıştırmayan
  botlara metadata head içinde bloklanarak verilir)
- sitemap lastmod + katalogda görünür 'Kaynak/son güncelleme' satırı
  (VeriKaynagi) KATALOG_VERI_TARIHI tek kaynağından
- docs/geo/geo-raporu.md: çalışmanın raporu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 00:51:16 +03:00
parent d2651eadb9
commit 53eb07bb34
18 changed files with 718 additions and 73 deletions

View File

@@ -1,18 +1,18 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
import { SiteFooter } from "@/components/site-footer";
import {
BolumKatalogu,
BolumKataloguSkeleton,
} from "@/features/katalog/components/bolum-listesi";
export const metadata: Metadata = {
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.",
alternates: { canonical: "/bolumler" },
};
path: "/bolumler",
});
export default function BolumlerPage() {
return (

View File

@@ -3,13 +3,14 @@ import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = {
export const metadata: Metadata = sayfaMetadata({
title: "Gizlilik ve KVKK",
description:
"KolayTercih gizlilik politikası ve KVKK aydınlatma metni: hangi veriler toplanır, nasıl işlenir ve haklarınız nelerdir.",
alternates: { canonical: "/gizlilik" },
};
path: "/gizlilik",
});
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function GizlilikPage() {

View File

@@ -3,13 +3,14 @@ import { Mail } from "lucide-react";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = {
export const metadata: Metadata = sayfaMetadata({
title: "İletişim",
description:
"KolayTercih destek ekibine ulaş: sorular, geri bildirim ve iade talepleri için iletişim kanalları.",
alternates: { canonical: "/iletisim" },
};
path: "/iletisim",
});
export default function IletisimPage() {
return (

View File

@@ -3,13 +3,14 @@ import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = {
export const metadata: Metadata = sayfaMetadata({
title: "Kullanım ve Satış Koşulları",
description:
"KolayTercih kullanım ve satış koşulları: hizmet kapsamı, ödeme, iade politikası ve sorumluluk sınırları.",
alternates: { canonical: "/kosullar" },
};
path: "/kosullar",
});
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function KosullarPage() {

View File

@@ -7,13 +7,14 @@ import {
MeraklisinaIcerik,
MeraklisinaIcerikSkeleton,
} from "@/features/pazarlama/components/meraklisina-icerik";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = {
export const metadata: Metadata = sayfaMetadata({
title: "Meraklısına: KolayTercih nasıl çalışır?",
alternates: { canonical: "/meraklisina" },
path: "/meraklisina",
description:
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
};
});
export default function MeraklisinaPage() {
return (

View File

@@ -8,13 +8,14 @@ import {
PaketSatinAl,
PaketSatinAlSkeleton,
} from "@/features/odeme/components/paket-satinal";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = {
export const metadata: Metadata = sayfaMetadata({
title: "Tercih Dönemi Paketi",
description:
"Tek seferlik Tercih Dönemi Paketi: yapay zekâ destekli kişisel 24 tercihlik liste, risk analizi, liste revizyonları ve soru hakkı. Abonelik yok.",
alternates: { canonical: "/paket" },
};
path: "/paket",
});
export default function PaketPage({
searchParams,

View File

@@ -1,6 +1,6 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { PagePixelDivider } from "@/components/pixel-decor";
@@ -9,12 +9,12 @@ import {
RehberListesiSkeleton,
} from "@/features/rehber/components/rehber-listesi";
export const metadata: Metadata = {
export const metadata: Metadata = sayfaMetadata({
title: "Tercih Rehberi 2026 — YKS Tercih Dönemi Yazıları",
description:
"YKS tercih dönemi rehberi: tercih listesi nasıl yapılır, ölü tercih nedir, ek yerleştirme nasıl işler, sıralama mı puan mı — hepsi öğrenci diliyle.",
alternates: { canonical: "/rehber" },
};
path: "/rehber",
});
export default function RehberIndexPage() {
return (

View File

@@ -1,15 +1,44 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
// Yalnızca /api/ engellenir. /sonuc, /giris, /listem, /odeme/*, /rapor/*
// bilinçli olarak ENGELLENMEDİ: bu sayfalar noindex meta taşıyor ve robots.txt
// engeli o meta'nın görülmesini imkânsız kılardı (dış link alırlarsa
// "robots.txt tarafından engellendi ama dizine eklendi" durumuna düşerlerdi).
const DISALLOW = ["/api/"];
// Arama/cevap motorlarının tarayıcıları. `*` kuralı bunları zaten kapsıyor;
// grup, niyeti belgelemek için açıkça yazıldı: AI aramalarında kaynak
// gösterilmek istiyoruz. DİKKAT: bir bot kendi adına yazılmış grubu bulursa `*`
// grubunu yok sayar — buraya eklenen her bot için DISALLOW tekrar edilmeli.
const AI_BOTLARI = [
// Arama indeksi + kullanıcı tetiklemeli fetch
"OAI-SearchBot",
"ChatGPT-User",
"Claude-SearchBot",
"Claude-User",
"PerplexityBot",
"Perplexity-User",
"DuckAssistBot",
"MistralAI-User",
"meta-webindexer",
"Amzn-SearchBot",
// Model eğitimi: aramasız yanıtlarda markanın bilinmesi buradan geçer
"GPTBot",
"ClaudeBot",
"Google-Extended",
"Applebot-Extended",
"meta-externalagent",
"Amazonbot",
"CCBot",
];
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
// /sonuc bilinçli olarak ENGELLENMEDİ: sayfa noindex meta taşıyor ve
// robots.txt engeli o meta'nın görülmesini imkânsız kılardı.
disallow: ["/api/", "/listem", "/odeme/", "/rapor/", "/giris"],
},
rules: [
{ userAgent: "*", allow: "/", disallow: DISALLOW },
{ userAgent: AI_BOTLARI, allow: "/", disallow: DISALLOW },
],
sitemap: `${SITE_URL}/sitemap.xml`,
};
}

View File

@@ -1,5 +1,5 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
import { KATALOG_VERI_TARIHI, SITE_URL } from "@/lib/seo";
import { getAllBolumSlugs, getAllUniversiteSlugs } from "@/lib/katalog";
import { getAllRehberler } from "@/lib/rehber";
@@ -7,8 +7,18 @@ import { getAllRehberler } from "@/lib/rehber";
// /odeme/sonuc, /rapor/yazdir (auth/utility sayfaları).
const staticEntries: MetadataRoute.Sitemap = [
{ url: `${SITE_URL}/`, priority: 1, changeFrequency: "weekly" },
{ url: `${SITE_URL}/bolumler`, priority: 0.8, changeFrequency: "weekly" },
{ url: `${SITE_URL}/universiteler`, priority: 0.8, changeFrequency: "weekly" },
{
url: `${SITE_URL}/bolumler`,
lastModified: KATALOG_VERI_TARIHI,
priority: 0.8,
changeFrequency: "weekly",
},
{
url: `${SITE_URL}/universiteler`,
lastModified: KATALOG_VERI_TARIHI,
priority: 0.8,
changeFrequency: "weekly",
},
{ url: `${SITE_URL}/rehber`, priority: 0.8, changeFrequency: "weekly" },
{ url: `${SITE_URL}/paket`, priority: 0.7, changeFrequency: "monthly" },
{ url: `${SITE_URL}/meraklisina`, priority: 0.6, changeFrequency: "monthly" },
@@ -20,12 +30,14 @@ const staticEntries: MetadataRoute.Sitemap = [
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const bolumler: MetadataRoute.Sitemap = getAllBolumSlugs().map((slug) => ({
url: `${SITE_URL}/bolum/${slug}`,
lastModified: KATALOG_VERI_TARIHI,
priority: 0.7,
changeFrequency: "monthly",
}));
const universiteler: MetadataRoute.Sitemap = getAllUniversiteSlugs().map(
(slug) => ({
url: `${SITE_URL}/universite/${slug}`,
lastModified: KATALOG_VERI_TARIHI,
priority: 0.6,
changeFrequency: "monthly",
}),

View File

@@ -1,18 +1,18 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { JsonLd, breadcrumbJsonLd, sayfaMetadata } from "@/lib/seo";
import { SiteFooter } from "@/components/site-footer";
import {
UniversiteKatalogu,
UniversiteKataloguSkeleton,
} from "@/features/katalog/components/universite-listesi";
export const metadata: Metadata = {
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.",
alternates: { canonical: "/universiteler" },
};
path: "/universiteler",
});
export default function UniversitelerPage() {
return (

View File

@@ -60,19 +60,31 @@ function AccordionContent({
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
// forceMount: kapalı içerik de ilk HTML'de kalır — JS çalıştırmayan
// tarayıcılar (arama/AI botları) cevapları okuyabilsin. Radix kapalıyken
// içeriği hiç render etmez; forceMount ile de yükseklik ölçümü kapalıyken 0
// çıktığı için accordion-up/down keyframe'leri çalışmaz. Aç/kapa bu yüzden
// grid-rows geçişiyle yapılır; geçiş Content'in KENDİSİNDE değil çocuğunda
// durur, çünkü Radix ölçüm sırasında Content'in transition-duration'ını
// sıfırlar. visibility geçişi kapalı içeriği odak/ekran okuyucudan çıkarır.
return (
<AccordionPrimitive.Content
forceMount
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
className="group/accordion-content text-sm"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
<div className="grid grid-rows-[1fr] transition-[grid-template-rows,visibility] duration-200 ease-out group-data-[state=closed]/accordion-content:invisible group-data-[state=closed]/accordion-content:grid-rows-[0fr] motion-reduce:transition-none">
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</div>
</div>
</AccordionPrimitive.Content>
)

View File

@@ -8,9 +8,15 @@ import {
getBolumBySlug,
uniSlugFromAd,
} from "../katalog-queries";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import {
JsonLd,
breadcrumbJsonLd,
sayfaMetadata,
ustSegmentOgGorseli,
} from "@/lib/seo";
import { SAYFA_BOYU } from "./sayfalama";
import { BolumProgramListesi } from "./bolum-program-listesi";
import { VeriKaynagi } from "./veri-kaynagi";
import { SiteFooter } from "@/components/site-footer";
import { Skeleton } from "@/components/ui/skeleton";
@@ -41,21 +47,27 @@ export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
if (!bolum) return {};
const { stats } = bolum;
if (sayfa > 1) {
return {
return sayfaMetadata({
title: `${bolum.ad} Taban Puanları 2026 (Sayfa ${sayfa})`,
description: `${bolum.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${stats.toplam} programın 2025 yerleştirme verileri.`,
alternates: { canonical: `/bolum/${slug}/sayfa/${sayfa}` },
};
path: `/bolum/${slug}/sayfa/${sayfa}`,
openGraph: {
images: ustSegmentOgGorseli(
`/bolum/${slug}`,
"Bölüm taban puanları ve sıralamaları — KolayTercih",
),
},
});
}
const enIyi =
stats.minSira != null
? ` En iyi taban sıralaması ${sayi(stats.minSira)}.`
: "";
return {
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.`,
alternates: { canonical: `/bolum/${slug}` },
};
path: `/bolum/${slug}`,
});
}
export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
@@ -139,6 +151,7 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
tabanYol={`/bolum/${slug}`}
sayfa={sayfa}
/>
<VeriKaynagi className="mt-6" />
</section>
</main>
<SiteFooter />

View File

@@ -9,7 +9,13 @@ import {
bolumSlugFromIsim,
} from "../katalog-queries";
import { trBaslikDuzeni } from "@/lib/slug";
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
import {
JsonLd,
breadcrumbJsonLd,
sayfaMetadata,
SITE_URL,
ustSegmentOgGorseli,
} from "@/lib/seo";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
@@ -17,6 +23,7 @@ import { PagePixelDivider } from "@/components/pixel-decor";
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
import { uniUlkeKonum } from "@/lib/harita-ulkeler";
import { UniversiteProgramTablosu } from "./universite-program-tablosu";
import { VeriKaynagi } from "./veri-kaynagi";
import { UniLogo } from "@/components/uni-logo";
import { uniLogoYolu } from "@/lib/uni-logolar";
import { Skeleton } from "@/components/ui/skeleton";
@@ -48,17 +55,23 @@ export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
const uni = getUniversiteBySlug(slug);
if (!uni) return {};
if (sayfa > 1) {
return {
return sayfaMetadata({
title: `${uni.ad} Taban Puanları 2026 (Sayfa ${sayfa})`,
description: `${uni.ad} taban puanları ve başarı sıralamaları tablosunun ${sayfa}. sayfası — toplam ${uni.programSayisi} programın 2025 yerleştirme verileri.`,
alternates: { canonical: `/universite/${slug}/sayfa/${sayfa}` },
};
path: `/universite/${slug}/sayfa/${sayfa}`,
openGraph: {
images: ustSegmentOgGorseli(
`/universite/${slug}`,
"Üniversite taban puanları ve sıralamaları — KolayTercih",
),
},
});
}
return {
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.`,
alternates: { canonical: `/universite/${slug}` },
};
path: `/universite/${slug}`,
});
}
export function UniversiteIcerik({
@@ -274,11 +287,10 @@ export function UniversiteIcerik({
<UniversiteProgramTablosu gruplar={gruplar} />
<p className="mt-3 text-xs leading-5 text-slate-500">
<VeriKaynagi>
&quot;—&quot;: o yıl için yerleştirme verisi bulunmuyor (yeni açılan
veya o yıl yerleşen çıkmayan program). Kaynak: YÖK Atlas 2025
yerleştirme sonuçları.
</p>
veya o yıl yerleşen çıkmayan program).{" "}
</VeriKaynagi>
<Sayfalama
tabanYol={`/universite/${slug}`}

View File

@@ -0,0 +1,29 @@
import { KATALOG_VERI_TARIHI } from "@/lib/seo";
import { cn } from "@/lib/utils";
const TARIH_METNI = new Date(
`${KATALOG_VERI_TARIHI}T00:00:00Z`,
).toLocaleDateString("tr-TR", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
// Katalog tablolarının altındaki kaynak + tazelik satırı. Görünür metin olması
// bilinçli: arama/AI botları JSON-LD'ye değil sayfadaki metne güvenir.
export function VeriKaynagi({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<p className={cn("mt-3 text-xs leading-5 text-slate-500", className)}>
{children}
Kaynak: YÖK Atlas 2025 yerleştirme sonuçları. Son güncelleme:{" "}
<time dateTime={KATALOG_VERI_TARIHI}>{TARIH_METNI}</time>.
</p>
);
}

View File

@@ -2,7 +2,12 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllRehberler, getAllRehberSlugs, getRehber } from "../rehber-queries";
import { JsonLd, articleJsonLd, breadcrumbJsonLd } from "@/lib/seo";
import {
JsonLd,
articleJsonLd,
breadcrumbJsonLd,
sayfaMetadata,
} from "@/lib/seo";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { ElCizimiSema } from "./el-cizimi-sema";
import { RehberKapak } from "./rehber-kapak";
@@ -20,17 +25,12 @@ export function rehberStaticSlugs(): string[] {
export function rehberYaziMetadata(slug: string): Metadata {
const yazi = getRehber(slug);
if (!yazi) return {};
return {
return sayfaMetadata({
title: yazi.baslik,
description: yazi.aciklama,
alternates: { canonical: `/rehber/${slug}` },
openGraph: {
type: "article",
title: yazi.baslik,
description: yazi.aciklama,
publishedTime: yazi.tarih,
},
};
path: `/rehber/${slug}`,
openGraph: { type: "article", publishedTime: yazi.tarih },
});
}
export function RehberMakale({ slug }: { slug: string }) {

View File

@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { URUNLER } from "./credits";
/**
@@ -14,6 +15,62 @@ export const SITE_URL =
export const SITE_NAME = "KolayTercih";
/**
* Katalog verisinin (data/yokatlas.db) en son yenilendiği gün. Sitemap lastmod,
* katalog sayfalarındaki görünür "son güncelleme" satırı buradan okunur.
* 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";
type SayfaMetadataGirdi = {
title: string;
description: string;
/** Kanonik yol ("/bolumler"); metadataBase ile mutlak adrese çözülür. */
path: string;
/** Varsayılan "website"; rehber yazıları article alanlarını buradan verir. */
openGraph?: NonNullable<Metadata["openGraph"]>;
};
/**
* İndekslenebilir sayfaların metadata'sı için tek kaynak. Next, segmentler
* arası metadata'yı SIĞ birleştirir: sayfa `openGraph` vermezse kök layout'unki
* (ana sayfanın başlığı + og:url'i) olduğu gibi miras kalır; verirse nesnenin
* tamamı ezilir (siteName/locale kaybolur). Bu yüzden openGraph burada eksiksiz
* üretilir. twitter:title/description/image, openGraph'tan otomatik dolar.
*/
export function sayfaMetadata({
title,
description,
path,
openGraph,
}: SayfaMetadataGirdi): Metadata {
return {
title,
description,
alternates: { canonical: path },
openGraph: {
type: "website",
locale: "tr_TR",
siteName: SITE_NAME,
url: path,
title,
description,
...openGraph,
},
};
}
/**
* Üst segmentin dosya-tabanlı OG görselini (opengraph-image.tsx) alt rotaya
* elle bağlar. Gerekçe: alt rota `openGraph` tanımlayınca sığ birleştirme üst
* segmentten gelen `images`'ı da düşürür (ör. /bolum/[slug]/sayfa/[no]).
* Boyut, src/lib/og-sayfa.tsx içindeki OG_SIZE ile aynı olmalı.
*/
export function ustSegmentOgGorseli(path: string, alt: string) {
return [{ url: `${path}/opengraph-image`, width: 1200, height: 630, alt }];
}
type JsonLdData = Record<string, unknown>;
export function organizationJsonLd(): JsonLdData {