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
236 lines
9.2 KiB
JavaScript
236 lines
9.2 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|