Build TercihAI MVP: landing page, YÖK Atlas data pipeline, rank-based results

- Add product vision/market research doc (VISION.md)
- Set up shadcn/ui (radix + nova preset) with blue/orange theme, Outfit + Work Sans fonts
- Landing page: hero with rank input, problem/steps sections, comparison table, FAQ
- Data pipeline: CSV archive ingest (2021-2024) + live YÖK Atlas API refresh (2025)
  into SQLite (npm run ingest / refresh); zeros normalized to NULL
- /sonuc page: hayal/dengeli/garanti buckets by COALESCE(sira2025, sira2024),
  score-type switcher, 2024→2025 trend indicators
- Add ui-ux-pro-max and shadcn agent skills, shadcn MCP config, launch.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-20 07:46:41 +03:00
parent 4c76bd9724
commit 87f265ec15
84 changed files with 16020 additions and 89 deletions

190
scripts/ingest.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* YÖK Atlas verisini SQLite'a yükler.
*
* Kaynak: yokatlas-dataset-2025 (github.com/MorphaxTheDeveloper/yokatlas-dataset-2025)
* — YÖK Atlas'ın halka açık verisinin CSV dökümü. Canlı API'den tazeleme
* (yokatlas-py'nin kullandığı JSON API) v2'de eklenecek.
*
* Kullanım: npx tsx scripts/ingest.ts <tum_bolumler.csv yolu>
*/
import { createReadStream, existsSync, mkdirSync, rmSync } from "node:fs";
import { createInterface } from "node:readline";
import path from "node:path";
import Database from "better-sqlite3";
const YEARS = [2021, 2022, 2023, 2024] as const;
const csvPath = process.argv[2];
if (!csvPath || !existsSync(csvPath)) {
console.error("Kullanım: npx tsx scripts/ingest.ts <tum_bolumler.csv yolu>");
process.exit(1);
}
const dbDir = path.join(process.cwd(), "data");
mkdirSync(dbDir, { recursive: true });
const dbPath = path.join(dbDir, "yokatlas.db");
rmSync(dbPath, { force: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
const yearCols = YEARS.flatMap((y) => [
`sira${y} INTEGER`,
`puan${y} REAL`,
`kontenjan${y} INTEGER`,
`yerlesen${y} INTEGER`,
]);
db.exec(`
CREATE TABLE programs (
id TEXT PRIMARY KEY,
isim TEXT NOT NULL,
universite TEXT NOT NULL,
unitur TEXT,
il TEXT,
fakulte TEXT,
tur TEXT NOT NULL,
sure INTEGER,
onlisans INTEGER NOT NULL DEFAULT 0,
${yearCols.join(",\n ")}
);
CREATE INDEX idx_programs_tur_sira ON programs (tur, sira2024);
CREATE INDEX idx_programs_il ON programs (il);
`);
// CSV satırlarını RFC-4180 tırnak kurallarıyla böler (alan içi virgül/tırnak destekli)
function splitCsvLine(line: string): string[] {
const out: string[] = [];
let cur = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuotes = false;
}
} else {
cur += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
out.push(cur);
cur = "";
} else {
cur += ch;
}
}
out.push(cur);
return out;
}
function toInt(v: string | undefined): number | null {
if (!v) return null;
const n = Number.parseFloat(v);
return Number.isFinite(n) ? Math.round(n) : null;
}
function toReal(v: string | undefined): number | null {
if (!v) return null;
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
}
// Kaynak CSV'de 0, "veri yok" anlamına gelir (sıralama/puan 0 olamaz)
function positiveOrNull(n: number | null): number | null {
return n != null && n > 0 ? n : null;
}
async function main() {
const columns = [
"id",
"isim",
"universite",
"unitur",
"il",
"fakulte",
"tur",
"sure",
"onlisans",
...YEARS.flatMap((y) => [
`sira${y}`,
`puan${y}`,
`kontenjan${y}`,
`yerlesen${y}`,
]),
];
const insert = db.prepare(
`INSERT OR REPLACE INTO programs (${columns.join(",")})
VALUES (${columns.map(() => "?").join(",")})`
);
const rl = createInterface({
input: createReadStream(csvPath, "utf8"),
crlfDelay: Infinity,
});
let header: string[] | null = null;
let idx: Record<string, number> = {};
const rows: unknown[][] = [];
let skipped = 0;
for await (const line of rl) {
if (!header) {
header = splitCsvLine(line);
idx = Object.fromEntries(header.map((h, i) => [h, i]));
continue;
}
if (!line.trim()) continue;
const f = splitCsvLine(line);
const get = (col: string) => f[idx[col]]?.trim() ?? "";
const id = get("id");
const isim = get("isim");
const universite = get("universite");
const tur = get("tur");
if (!id || !isim || !universite || !tur) {
skipped++;
continue;
}
rows.push([
id,
isim,
universite,
get("unitur") || null,
get("il") || null,
get("fakulte") || null,
tur,
toInt(get("sure")),
toInt(get("onlisans")) ?? 0,
...YEARS.flatMap((y) => [
positiveOrNull(toInt(get(`sira${y}`))),
positiveOrNull(toReal(get(`puan${y}`))),
toInt(get(`kontenjan${y}`)),
toInt(get(`yerlesen${y}`)),
]),
]);
}
const insertAll = db.transaction((all: unknown[][]) => {
for (const r of all) insert.run(...r);
});
insertAll(rows);
const count = db
.prepare("SELECT COUNT(*) AS c FROM programs")
.get() as { c: number };
const withSira = db
.prepare("SELECT COUNT(*) AS c FROM programs WHERE sira2024 IS NOT NULL")
.get() as { c: number };
console.log(`Yüklendi: ${count.c} program (${skipped} satır atlandı)`);
console.log(`2024 sıralaması olan: ${withSira.c}`);
console.log(`DB: ${dbPath}`);
}
main().then(() => db.close());

206
scripts/refresh.ts Normal file
View File

@@ -0,0 +1,206 @@
/**
* Canlı YÖK Atlas API'sinden 2025 yerleştirme verisini çekip DB'yi tazeler.
*
* 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
*
* Kullanım: npx tsx scripts/refresh.ts
*/
import path from "node:path";
import Database from "better-sqlite3";
const API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const PAGE_SIZE = 500;
const DELAY_MS = 300;
const TUR_MAP: Record<string, string> = {
SAY: "SAYISAL",
EA: "EŞİT AĞIRLIK",
SÖZ: "SÖZEL",
DİL: "DİL",
TYT: "TYT",
};
type ApiRecord = {
kilavuzKodu: number;
birimAdi: string;
universiteAdi: string;
universiteTuru: string | null;
ilAdi: string | null;
fymkAdi: string | null;
puanTuru: string;
ogrenimSuresi: number | null;
birimTuruId: number;
basariSirasi?: number | null;
minPuan?: number | null;
kontenjan?: number | null;
gkY?: number | null;
sgyY?: number | null;
dprmY?: number | null;
obkY?: number | null;
};
type ApiPage = {
content: ApiRecord[];
totalPages: number;
totalElements: number;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fetchPage(page: number): Promise<ApiPage> {
const res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters: {
puanTuru: null,
universiteId: [],
birimGrupId: [],
ilKodu: [],
birimTuruId: null,
universiteTuru: null,
bursOraniId: null,
ogrenimTuruId: null,
kilavuzKodu: null,
minBasariSirasi: null,
maxBasariSirasi: null,
},
page,
size: PAGE_SIZE,
sortBy: "kilavuzKodu",
direction: "ASC",
}),
});
if (!res.ok) {
throw new Error(`API ${res.status} (sayfa ${page})`);
}
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 {
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 {
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");
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);
`);
}
const update = db.prepare(`
UPDATE programs SET sira2025 = ?, puan2025 = ?, kontenjan2025 = ?, yerlesen2025 = ?
WHERE id = ?
`);
const insert = db.prepare(`
INSERT INTO programs (id, isim, universite, unitur, il, fakulte, tur, sure, onlisans,
sira2025, puan2025, kontenjan2025, yerlesen2025)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
let updated = 0;
let inserted = 0;
let skipped = 0;
const first = await fetchPage(0);
const totalPages = first.totalPages;
console.log(
`Toplam ${first.totalElements} kayıt, ${totalPages} sayfa çekilecek…`
);
const processPage = db.transaction((records: ApiRecord[]) => {
for (const r of records) {
const tur = TUR_MAP[r.puanTuru?.trim() ?? ""];
if (!r.kilavuzKodu || !tur) {
skipped++;
continue;
}
const id = String(r.kilavuzKodu);
const vals = [
intOrNull(r.basariSirasi),
realOrNull(r.minPuan),
intOrNull(r.kontenjan),
yerlesen(r),
];
if (exists.get(id)) {
update.run(...vals, id);
updated++;
} else {
insert.run(
id,
r.birimAdi,
r.universiteAdi,
r.universiteTuru ?? null,
r.ilAdi ?? null,
r.fymkAdi ?? null,
tur,
r.ogrenimSuresi ?? null,
r.birimTuruId === 47 ? 1 : 0,
...vals
);
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 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}`
);
db.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});