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:
bilalgursen
2026-09-21 17:03:01 +03:00
parent 38e02e0ed9
commit a773ac7beb
10 changed files with 2016 additions and 279 deletions

228
scripts/eski-arsivle.ts Normal file
View File

@@ -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<string, { ad: string; n: number }>();
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<string, { ad: string; n: number }>();
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/<slug> 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/<slug> 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 <db> --vacuum sonra pnpm veri-kalite …");
}
if (process.argv[1]?.endsWith("eski-arsivle.ts")) calistir(main);