/** * 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'; import { anahtarKesfet } from './ekap-key.mjs'; const BASE = 'https://ekapv2.kik.gov.tr'; const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g'; // EKAP anahtarı döndürdüğünde portalın kendi paketinden keşfedilen değer. // Yalnızca kimlik doğrulama hatasından sonra dolar; normalde null kalır. let kesfedilenAnahtar = null; function aktifAnahtar() { return kesfedilenAnahtar || process.env.EKAP_R8FACT_KEY || DEFAULT_KEY; } function aesKey() { return Buffer.from(aktifAnahtar(), '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; let anahtarDenendi = false; 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. // Anahtar rotasyonu da buradan geçiyor ve EKAP bunu bazen 401, // bazen 500 ile bildiriyor (imza çözülemeyince şifre çözme katmanı // patlıyor) — bu yüzden 5xx de anahtar yenilemeyi tetikliyor. if ((res.status === 401 || res.status === 403 || res.status >= 500) && i < retries) { if (!anahtarDenendi) { anahtarDenendi = true; // istek başına bir kez: gerçek sunucu // hatasında her denemede portalden üç dosya indirmeyelim const yeni = await anahtarKesfet(); if (yeni && yeni !== aktifAnahtar()) { console.warn('[ekap] imzalama anahtarı değişmiş — portalden yeni anahtar alındı'); kesfedilenAnahtar = yeni; } } 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. */ /** * OKAS kataloğunda ad ile arama (TR/EN). Kaynak: EKAP'ın kendi kalem servisi, * DevExtreme loadOptions gövdesiyle. "ortoped" → 33141700 Ortopedik malzemeler. */ export async function okasAra(q, { take = 30 } = {}) { const terim = String(q || '').trim(); const filter = terim ? [['kalemAdi', 'contains', terim], 'or', ['kalemAdiEng', 'contains', terim]] : []; const d = await ekapPost('/b_ihalearama/api/IhtiyacKalemleri/GetAll', { loadOptions: { filter: { sort: [], group: [], filter, totalSummary: [], groupSummary: [], select: [], preSelect: [], primaryKey: [] }, take, }, }); const liste = d?.loadResult?.data || []; return liste.map((k) => ({ kod: k.kod, ad: (k.kalemAdi || '').trim(), adEn: (k.kalemAdiEng || '').trim(), seviye: k.kodLevel, altSayisi: k.childCount ?? 0, })); } function normalizeKayit(t) { return { 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, }; } /** OKAS kodlarıyla EKAP araması — idarenin kendi sınıflandırmasına göre. */ export async function searchByOkas(kodlar, { skip = 0, take = 20 } = {}) { const liste = (Array.isArray(kodlar) ? kodlar : [kodlar]).map(String).filter(Boolean); if (!liste.length) return { totalCount: 0, list: [] }; const data = await ekapPost( '/b_ihalearama/api/Ihale/GetListByParameters', searchBody({ okasBransKodList: liste, skip, take }), ); return { totalCount: data.totalCount ?? 0, list: (data.list || []).map(normalizeKayit) }; } 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(normalizeKayit) }; } // 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, '(? 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); }