/** * KİK (Kamu İhale Kurumu) kurul kararları — EKAP v2 JSON API. * Zero-dependency: node:crypto + built-in fetch. * * Endpoint'ler (anonim, login yok): * POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlari (uyuşmazlık) * POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk (düzenleyici) * POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk (mahkeme) * * Güvenlik: X-Ekap-Sec-1..6 (AES-256-CBC). Sec-5 = HTTP metodu, Sec-6 = URL path. * Başlıklar HER istekte yeniden üretilmeli (GUID nonce + timestamp penceresi). * * Karar tam metni: gundemMaddesiId AYRI bir AES anahtarıyla şifrelenip * KararGoster.aspx'e query param olarak verilir (HTML döner). * * ⚠ Her iki anahtar da KİK frontend'inde sabittir; değişirse 401/HataKodu 1200 * alınır. Env ile geçilebilir: EKAP_R8FACT_KEY, EKAP_RAW_KEY (hex). */ import crypto from 'node:crypto'; const ORIGIN = 'https://ekapv2.kik.gov.tr'; const SIGN_KEY_DEFAULT = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g'; const ID_KEY_DEFAULT = Buffer.from([ 236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174, 174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176, ]); const signKey = () => Buffer.from(process.env.EKAP_R8FACT_KEY || SIGN_KEY_DEFAULT, 'utf8'); const idKey = () => (process.env.EKAP_RAW_KEY ? Buffer.from(process.env.EKAP_RAW_KEY, 'hex') : ID_KEY_DEFAULT); function secHeaders(method, pathname) { const iv = crypto.randomBytes(16); const key = signKey(); const e = (t) => { const c = crypto.createCipheriv('aes-256-cbc', key, iv); return Buffer.concat([c.update(String(t), 'utf8'), c.final()]).toString('base64'); }; const g = crypto.randomUUID(); return { 'X-Ekap-Sec-1': e(g), 'X-Ekap-Sec-2': iv.toString('base64'), 'X-Ekap-Sec-3': g, 'X-Ekap-Sec-4': e(Date.now()), 'X-Ekap-Sec-5': e(method.toUpperCase()), 'X-Ekap-Sec-6': e(pathname), }; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function kikPost(pathname, body, { retries = 2 } = {}) { let last; for (let i = 0; i <= retries; i++) { try { const r = await fetch(ORIGIN + pathname, { method: 'POST', headers: { 'api-version': 'v1', 'Content-Type': 'application/json', Accept: 'application/json', 'Accept-Language': 'tr', Origin: ORIGIN, Referer: `${ORIGIN}/sorgulamalar/kurul-kararlari`, 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36', ...secHeaders('POST', pathname), }, body: JSON.stringify(body), signal: AbortSignal.timeout(40000), }); const raw = await r.text(); if (!r.ok) { if (i < retries && (r.status === 401 || r.status >= 500)) { await sleep(600 * (i + 1)); continue; } throw new Error(`KİK sunucusu isteği reddetti (HTTP ${r.status}).`); } return JSON.parse(raw); } catch (e) { last = e; if (i >= retries) break; await sleep(600 * (i + 1)); } } throw last; } // Yanıt anahtarları uyuşmazlıkta camelCase, DK/MK'de PascalCase gelebilir const pick = (o, ...names) => { for (const n of names) if (o && o[n] != null) return o[n]; return undefined; }; const TURLER = { uyusmazlik: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari', etiket: 'Uyuşmazlık' }, duzenleyici: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk', etiket: 'Düzenleyici' }, mahkeme: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk', etiket: 'Mahkeme' }, }; /** Kararın tam metnini gösteren kalıcı URL (gundemMaddesiId şifrelenir). */ export function kararUrl(gundemMaddesiId) { const iv = crypto.randomBytes(16); const c = crypto.createCipheriv('aes-256-cbc', idKey(), iv); const id = iv.toString('hex') + Buffer.concat([c.update(String(gundemMaddesiId), 'utf8'), c.final()]).toString('hex'); return `https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId=${id}`; } /** Kararın tam metnini düz metin olarak getirir. */ export async function kararMetni(gundemMaddesiId) { const res = await fetch(kararUrl(gundemMaddesiId), { headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'tr' }, signal: AbortSignal.timeout(40000), }); if (!res.ok) throw new Error(`Karar metni alınamadı (HTTP ${res.status})`); const html = await res.text(); return html .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ').replace(/"/g, '"').replace(/&/g, '&') .replace(/×/g, ' ').replace(/</g, '<').replace(/>/g, '>') .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) .replace(/&[a-z]+;/gi, ' ') .replace(/[ \t]+/g, ' ') .replace(/\s*\n\s*/g, '\n') .trim(); } const cache = new Map(); const TTL = 20 * 60 * 1000; /** * Kurul kararı araması. * @param {object} f { metin, kararNo, idare, basvuran, konu, yil, tarih1, tarih2, sonKararlar } * @param {'uyusmazlik'|'duzenleyici'|'mahkeme'} tur */ export async function searchKik(f = {}, tur = 'uyusmazlik', limit = 25) { const cfg = TURLER[tur] || TURLER.uyusmazlik; const filters = {}; if (f.metin) filters.KararMetni = f.metin; if (f.kararNo) filters.KararNo = f.kararNo; if (f.idare) filters.IhaleyiYapanIdare = f.idare; if (f.basvuran) filters.Sikayetci = f.basvuran; if (f.konu) filters.BasvuruKonusuIhale = f.konu; if (f.yil) filters.Yil = f.yil; if (f.tarih1) filters.KararTarihi1 = f.tarih1; // "dd.MM.yyyy HH:mm:ss" if (f.tarih2) filters.KararTarihi2 = f.tarih2; if (!Object.keys(filters).length) filters.SonKararlar = ''; const key = `${cfg.path}|${JSON.stringify(filters)}|${limit}`; const hit = cache.get(key); if (hit && Date.now() - hit.at < TTL) return hit.data; const j = await kikPost(cfg.path, { sorgulaKurulKararlari: { keyValuePairs: { keyValueOfstringanyType: Object.entries(filters).map(([k, v]) => ({ key: k, value: String(v) })), }, }, }); const wrap = pick(j, 'SorgulaKurulKararlariResponse', 'sorgulaKurulKararlariResponse') || {}; const res = pick(wrap, 'SorgulaKurulKararlariResult', 'sorgulaKurulKararlariResult') || {}; const hataKodu = String(pick(res, 'hataKodu', 'HataKodu') ?? '0'); // 4401 = kayıt bulunamadı (hata değil) if (hataKodu === '4401') { const bos = { results: [], totalCount: 0, tur: cfg.etiket, kaynak: 'KİK — EKAP Kurul Kararları', fetchedAt: new Date().toISOString() }; cache.set(key, { at: Date.now(), data: bos }); return bos; } if (hataKodu !== '0') { throw new Error(`KİK: ${pick(res, 'hataMesaji', 'HataMesaji') || `hata kodu ${hataKodu}`}`); } const liste = pick(res, 'KurulKararTutanakDetayListesi', 'kurulKararTutanakDetayListesi') || []; const kayitlar = pick(liste[0] || {}, 'kurulKararTutanakDetayi', 'KurulKararTutanakDetayi') || []; const results = (Array.isArray(kayitlar) ? kayitlar : [kayitlar]).slice(0, limit).map((k, i) => { const gid = pick(k, 'gundemMaddesiId', 'GundemMaddesiId'); const kararNo = pick(k, 'kararNo', 'KararNo'); const tarih = pick(k, 'kararTarihi', 'KararTarihi'); return { id: String(gid ?? kararNo ?? i), gundemMaddesiId: gid ?? null, kararNo: kararNo ?? null, kararTarihi: tarih ? String(tarih).slice(0, 10).split('-').reverse().join('.') : null, idare: pick(k, 'idareAdi', 'IdareAdi') ?? null, basvuran: pick(k, 'basvuran', 'Basvuran') ?? null, baslik: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null, konu: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null, ozet: pick(k, 'kararNitelik', 'KararNitelik') ?? null, resmiGazete: pick(k, 'resmiGazeteSayisi', 'ResmiGazeteSayisi') ?? null, tur: cfg.etiket, link: gid != null ? kararUrl(gid) : null, }; }); const data = { results, totalCount: results.length, tur: cfg.etiket, kaynak: 'KİK — EKAP Kurul Kararları', fetchedAt: new Date().toISOString(), }; cache.set(key, { at: Date.now(), data }); return data; }