/** * 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, '(? 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); }