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
This commit is contained in:
2026-08-28 10:37:50 +03:00
parent 3e129c2988
commit 168052c267
26 changed files with 3121 additions and 219 deletions
+206
View File
@@ -0,0 +1,206 @@
/**
* 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;
}