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:
@@ -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 = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };
|
||||
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user