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:
+206
@@ -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(/ /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;
|
||||
}
|
||||
Reference in New Issue
Block a user