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
+235
View File
@@ -0,0 +1,235 @@
/**
* EKAP v2 API istemcisi — ihale arama + sonuç ilanı (kazanan firma, sözleşme bedeli).
* Zero-dependency: node:crypto + built-in fetch.
*
* Güvenlik başlıkları AES-256-CBC ile üretilir. Anahtar EKAP tarafından
* dönemsel olarak değiştirilir; değişirse EKAP_R8FACT_KEY env'i ile geçilebilir.
* (Yeni anahtar ekapv2.kik.gov.tr/ekap/search konsolunda environment.r8fact içinde.)
*
* Sunucu hata kodları: 1200 = başlık eksik, 1202 = timestamp penceresi dışı (±30sn),
* 1204 = guid uyuşmuyor.
*/
import crypto from 'node:crypto';
const BASE = 'https://ekapv2.kik.gov.tr';
const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
function aesKey() {
return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8');
}
function aesB64(plain, iv) {
const c = crypto.createCipheriv('aes-256-cbc', aesKey(), iv);
return Buffer.concat([c.update(plain, 'utf8'), c.final()]).toString('base64');
}
// Her istekte yeniden üretilmeli (timestamp penceresi ±30 sn)
export function securityHeaders() {
const guid = crypto.randomUUID();
const iv = crypto.randomBytes(16); // Sec-1 ve Sec-4 AYNI IV'yi kullanmalı
const ts = String(Date.now()); // milisaniye zorunlu
return {
'X-Ekap-Sec-1': aesB64(guid, iv),
'X-Ekap-Sec-2': iv.toString('base64'),
'X-Ekap-Sec-3': guid,
'X-Ekap-Sec-4': aesB64(ts, iv),
};
}
const BASE_HEADERS = {
Accept: 'application/json',
'Accept-Language': 'tr',
'Content-Type': 'application/json',
Origin: BASE,
Referer: `${BASE}/ekap/search`,
'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',
'api-version': 'v1',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { ...BASE_HEADERS, ...securityHeaders() },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
const raw = await res.text();
if (!res.ok) {
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene
if ((res.status === 401 || res.status >= 500) && i < retries) {
await sleep(500 * (i + 1));
continue;
}
throw new Error(`EKAP ${res.status}: ${raw.slice(0, 200)}`);
}
return JSON.parse(raw);
} catch (e) {
lastErr = e;
if (i >= retries) break;
await sleep(500 * (i + 1));
}
}
throw lastErr;
}
// --- İhale arama ---------------------------------------------------------
// EKAP ihale durumu: 15 = Sonuç İlanı Yayımlanmış
export const DURUM_SONUC_ILANI = 15;
// İlan tipi: 1=Ön İlan 2=İhale İlanı 3=İptal 4=Sonuç İlanı 5=Ön Yeterlik 6=Düzeltme
export const ILAN_TIP_SONUC = '4';
function searchBody(opts = {}) {
return {
searchText: opts.searchText || '',
filterType: null,
ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true,
teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true,
isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true,
ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true,
searchType: 'GirdigimGibi',
iknYili: opts.iknYili ?? null,
iknSayi: opts.iknSayi ?? null,
ihaleTarihSaatBaslangic: opts.ihaleTarihBaslangic ?? null, // "YYYY-MM-DD"
ihaleTarihSaatBitis: opts.ihaleTarihBitis ?? null,
ilanTarihSaatBaslangic: opts.ilanTarihBaslangic ?? null,
ilanTarihSaatBitis: opts.ilanTarihBitis ?? null,
yasaKapsami4734List: [],
ihaleTuruIdList: opts.ihaleTuruIdList || [],
ihaleUsulIdList: [], ihaleUsulAltIdList: [],
ihaleIlIdList: opts.ihaleIlIdList || [], // EKAP il id (245-325) — PLAKA DEĞİL
ihaleDurumIdList: opts.ihaleDurumIdList || [],
idareIdList: opts.idareIdList || [],
ihaleIlanTuruIdList: opts.ihaleIlanTuruIdList || [],
teklifTuruIdList: [], asiriDusukTeklifIdList: [], istisnaMaddeIdList: [],
okasBransKodList: opts.okasBransKodList || [],
okasBransAdiList: [], titubbKodList: [], gmdnKodList: [],
eIhale: null, eEksiltmeYapilacakMi: null, ortakAlimMi: null, kismiTeklifMi: null,
fiyatDisiUnsurVarmi: null,
ekonomikVeMaliYeterlilikBelgeleriIsteniyorMu: null,
meslekiTeknikYeterlilikBelgeleriIsteniyorMu: null,
isDeneyimiGosterenBelgelerIsteniyorMu: null,
yerliIstekliyeFiyatAvantajiUgulaniyorMu: null,
yabanciIsteklilereIzinVeriliyorMu: null,
alternatifTeklifVerilebilirMi: null, konsorsiyumKatilabilirMi: null,
altYukleniciCalistirilabilirMi: null, fiyatFarkiVerilecekMi: null,
avansVerilecekMi: null, cerceveAnlasmaMi: null,
personelCalistirilmasinaDayaliMi: null,
orderBy: opts.orderBy || 'ihaleTarihi',
siralamaTipi: opts.siralamaTipi || 'desc',
paginationSkip: opts.skip ?? 0,
paginationTake: opts.take ?? 20,
};
}
export async function searchTenders(opts = {}) {
const data = await ekapPost('/b_ihalearama/api/Ihale/GetListByParameters', searchBody(opts));
return { list: data.list || [], totalCount: data.totalCount ?? 0 };
}
// NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan.
export async function getIlanlar(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId });
return data.list || [];
}
export async function getIhaleDetay(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/IhaleDetay/GetByIhaleIdIhaleDetay', { ihaleId });
return data.item || data;
}
// --- Sonuç ilanı parse ---------------------------------------------------
const ENTITIES = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" };
// EKAP'ın veriHtml tablosunu, Python tarafındaki markdown ile aynı "| hücre |"
// biçimine indirger; böylece aynı regex'ler çalışır.
export function htmlToPipeText(html) {
if (!html) return '';
return String(html)
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ')
.replace(/<\s*\/\s*tr\s*>/gi, ' |\n')
.replace(/<\s*br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ')
.replace(/[ \t]+/g, ' ');
}
function cleanCell(v) {
if (v == null) return null;
const t = String(v).replace(/-->/g, ' ').replace(/\*\*/g, ' ').replace(/\s+/g, ' ').trim();
return t || null;
}
function findField(text, labelPattern) {
if (!text) return null;
const re = new RegExp(`${labelPattern}\\s*\\**\\s*\\|\\s*\\**:?\\**\\s*\\|([^|\\n]*)`, 'i');
const m = re.exec(text);
return m ? cleanCell(m[1]) : null;
}
function parseAmount(v) {
if (!v) return null;
const m = /([\d.]+,\d+|\d+)/.exec(v);
if (!m) return null;
const n = parseFloat(m[1].replace(/\./g, '').replace(',', '.'));
return Number.isNaN(n) ? null : n;
}
function parseIntSafe(v) {
if (!v) return null;
const m = /\d+/.exec(v);
return m ? parseInt(m[0], 10) : null;
}
/** Sonuç ilanı HTML'inden kazanan firma / bedel / teklif sayısı çıkarır. */
export function parseResultAnnouncement(veriHtml) {
const text = htmlToPipeText(veriHtml);
const amountRaw = findField(text, 'Bedeli');
const cur = amountRaw ? (/\b([A-Z]{3})\b/.exec(amountRaw)?.[1] ?? null) : null;
return {
ikn: findField(text, 'İhale kayıt numarası'),
kazananFirma: findField(text, 'Yüklenici(?:si)?'),
kazananUyruk: findField(text, 'Yüklenicinin uyruğu'),
kazananAdres: findField(text, 'Yüklenicinin adresi'),
sozlesmeBedeli: parseAmount(amountRaw),
paraBirimi: cur,
sozlesmeTarihi: findField(text, '4- Sözleşmenin[\\s\\S]{0,200}?Tarihi'),
sozlesmeSuresi: findField(text, '4- Sözleşmenin[\\s\\S]{0,400}?Süresi'),
yaklasikMaliyet: parseAmount(findField(text, '(?<!Kısımlarının )Yaklaşık Maliyeti')),
teklifSayisi: parseIntSafe(findField(text, 'Toplam Teklif Sayısı')),
gecerliTeklifSayisi: parseIntSafe(findField(text, 'Toplam Geçerli Teklif Sayısı')),
};
}
// --- Firma adı normalize (kazanan eşleştirme için) -----------------------
const TR_FOLD = { 'Ç': 'C', 'Ğ': 'G', 'İ': 'I', 'I': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U', 'ç': 'C', 'ğ': 'G', 'ı': 'I', 'i': 'I', 'ö': 'O', 'ş': 'S', 'ü': 'U' };
const SUFFIX = new Set(['LIMITED', 'LTD', 'SIRKETI', 'SIRKET', 'STI', 'ANONIM', 'AS', 'A', 'S', 'SANAYI', 'SAN', 'TICARET', 'TIC', 'VE', 'KOLLEKTIF', 'KOMANDIT']);
export function normalizeCompanyName(n) {
if (!n) return '';
return String(n)
.replace(/[ÇĞİIÖŞÜçğıiöşü]/g, (c) => TR_FOLD[c] || c)
.toUpperCase()
.replace(/[^0-9A-Z]+/g, ' ')
.split(/\s+/)
.filter((t) => t && !SUFFIX.has(t))
.join(' ');
}
/** Bir ihalenin sonuç ilan(lar)ını çeker ve parse eder (kısmi teklifte birden çok olur). */
export async function fetchResults(ihaleId) {
const ilanlar = await getIlanlar(ihaleId);
return ilanlar
.filter((i) => String(i.ilanTip) === ILAN_TIP_SONUC)
.map((i, idx) => ({ ...parseResultAnnouncement(i.veriHtml), ilanTarihi: i.ilanTarihi, kisimIndex: idx }))
.filter((r) => r.kazananFirma || r.sozlesmeBedeli != null);
}