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

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);
});