feat(veri): 2026 yükleme hattı — yıl kilitli refresh, geçmiş doldurma, arşiv, kalite kapısı (P-A)
- 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<yil-1> ≥%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 <noreply@anthropic.com>
This commit is contained in:
350
scripts/detay.ts
350
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<yil> 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ı → <yil-1> 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<string, unknown>;
|
||||
|
||||
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<KilavuzRecord>(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<number, string>();
|
||||
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<KilavuzRecord>(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<void> {
|
||||
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<NetlerRecord>(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<number, Database.Statement<unknown[]>>();
|
||||
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<yil> → ${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<NetlerRecord>(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<NetlerRecord>(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<void> {
|
||||
const veriYili = yil - 1; // `…1` alanları; yıl kilidiyle doğrulanır, r.yil'e bakılmaz
|
||||
const first = await fetchPage<KirilimKaydi>(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<KirilimKaydi>(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);
|
||||
|
||||
Reference in New Issue
Block a user