saidsurucu/ihale-mcp deposu incelenirken EKAP arama API'sinin doküman ici arama parametreleri oldugu goruldu: teklifCetvelindeAra, teknikSartnamedeAra, idariSartnamedeAra, sozlesmeTasarisindaAra. EKAP dokumani kendi tarafinda indeksliyor — yani teklif cetvelinde arama icin dokumani indirmeye (ve CAPTCHA'ya) gerek yok. Kullanicinin sikayetini olcen kanit: ELDIVEN basliкta 1.135 → teklif cetvelinde 29.057 ORTOPEDIK VIDA baslikta 0 → teklif cetvelinde 3 HEMOGRAM baslikta 283 → teklif cetvelinde 2.529 "ORTOPEDIK VIDA" hicbir ihale BASLIGINDA gecmiyor; gectigi ihalelerin adi "VETERINER ILAC ... SARF MALZEME". Basliga bakan firma bunlari bulamiyordu. - ekap-v2.mjs: searchBody artik opts.kapsam ile daraltilabiliyor; searchInDocuments() yalnizca teklif cetvelinde (istege bagli teknik sartnamede) arar ve sonucu normalize eder - GET /api/urun-ara?q=&skip=&take=&teknik= — 5 dk bellek onbellegi ile (EKAP'a uzak cagri, ayni sorgu kaynagi yormasin) - Header'da "URUN ARA" dugmesi + UrunAramaModal: sayfalama, "sadece teklif verilebilir" suzgeci, havuzda acik olan ihalede detayi acma kisayolu SINIR: EKAP hangi kalemin eslestigini dondurmuyor, yalnizca eslesen ihaleleri. Kalem dokumu icin dokuman yuklemeye dayali ortak havuz yolu gecerli kaliyor; arayuzde bu acikca yaziliyor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
11 KiB
JavaScript
276 lines
11 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,
|
||
// Arama kapsamı — EKAP metni ihale dokümanının İÇİNDE de arayabiliyor
|
||
// (teknik/idari şartname, sözleşme tasarısı, teklif cetveli). Varsayılan
|
||
// hepsi açık; opts.kapsam ile daraltılır (ör. yalnızca teklif cetveli).
|
||
ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true,
|
||
teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true,
|
||
isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true,
|
||
ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true,
|
||
...(opts.kapsam || {}),
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* Ürün/kalem araması — metni YALNIZCA ihale dokümanının içinde arar.
|
||
*
|
||
* Firmalar başlığa bakarak kendi ürünlerini bulamıyor: "ORTOPEDİK VİDA"
|
||
* hiçbir ihale BAŞLIĞINDA geçmiyor, ama üç ihalenin TEKLİF CETVELİNDE var —
|
||
* ve o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME". EKAP dokümanı
|
||
* kendi tarafında indekslediği için doküman indirmeye gerek yok.
|
||
*/
|
||
export async function searchInDocuments(metin, { teknikSartname = false, skip = 0, take = 20 } = {}) {
|
||
const kapsam = {
|
||
ikNdeAra: false, ihaleAdindaAra: false, ihaleIlanindaAra: false,
|
||
teknikSartnamedeAra: !!teknikSartname, idariSartnamedeAra: false,
|
||
benzerIsMaddesindeAra: false, isinYapilacagiYerMaddesindeAra: false,
|
||
nitelikTurMiktarMaddesindeAra: false, ihaleBilgilerindeAra: false,
|
||
sozlesmeTasarisindaAra: false, teklifCetvelindeAra: true,
|
||
};
|
||
const data = await ekapPost(
|
||
'/b_ihalearama/api/Ihale/GetListByParameters',
|
||
searchBody({ searchText: metin, kapsam, skip, take }),
|
||
);
|
||
return {
|
||
totalCount: data.totalCount ?? 0,
|
||
list: (data.list || []).map((t) => ({
|
||
ikn: t.ikn,
|
||
baslik: t.ihaleAdi,
|
||
kurum: t.idareAdi,
|
||
sehir: t.ihaleIlAdi,
|
||
tur: t.ihaleTipAciklama,
|
||
usul: t.ihaleUsulAciklama,
|
||
durum: t.ihaleDurumAciklama,
|
||
ihaleTarihSaat: t.ihaleTarihSaat,
|
||
dokumanSayisi: t.dokumanSayisi ?? 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);
|
||
}
|