Files
ihaleist/server/kik.mjs
T
hikmettan 168052c267 feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar
Roadmap'in 6 maddesi de gerçek çalışır halde:

1) Geçmiş veri motoru (EKAP v2)
   - server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
     ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
     yaklaşık maliyet, teklif sayısı), TR firma adı normalize
   - server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
   - server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
   - server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
   - /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
     rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor

2) Gerçek AI şartname analizi
   - server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
     Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
   - pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
   - AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata

3) KİK kurul kararları
   - server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
     düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
   - KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri

4) Fiyatlandırma + ödeme
   - server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
   - Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı

5) TED (AB ihaleleri)
   - server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
   - rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü

6) Kırmızı bayrak & şeffaflık
   - analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
     aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
   - RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
2026-08-28 10:37:50 +03:00

207 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&amp;/g, '&')
.replace(/&times;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/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;
}