Files
ihaleist/server/ekap-v2.mjs
T
hikmettanandClaude Opus 5 ff797f5a91 feat: urun arama — urun adini ihale DOKUMANLARININ icinde aratir
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>
2026-09-03 12:55:36 +03:00

276 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = { '&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);
}