Files
ihaleist/server/ekap-v2.mjs
T
hikmettanandClaude Fable 5.1 1fce9df06f feat: OKAS kod aramasi, toplu ihale dokumani yukleme, kazanan istihbarati
Uc ozellik, 27+1 depo taramasinda gorulen fikirlerden; kod kopyalanmadi
(ilgili depolar lisanssiz), uygulamalar sifirdan yazildi.

1) OKAS koduyla arama (fikir: firatbadur/ihaletakip-api'nin kullandigi
   IhtiyacKalemleri/GetAll ucu). EKAP'in kendi OKAS katalogunda ad ile
   (TR/EN) arama: "ortoped" → 33141700 Ortopedik malzemeler, 33183000
   Ortopedik destek cihazlari. Secilen kodla EKAP'ta ihale aramasi
   (okasBransKodList). Olculen: 33141700 altinda 335 ihale, basliklari
   "VETERINER ILAC VE SARF MALZEME" — basliktan gorunmeyen turden.
   - ekap-v2.mjs: okasAra(), searchByOkas(); sonuc normalize ortak
   - GET /api/okas/ara?q= (1 sa onbellek); /api/urun-ara?okas=kod,kod
   - Urun Arama modalinda "OKAS koduyla" modu (katalog onerisi + cok kod)
   - Sol menude CPV bolumunun ustunde OKAS kutusu: kod secilince modal
     o kodla acilip hemen arar

2) Toplu ihale dokumani yukleme (fikir: barkem-tayyar/ihale'nin izlenen
   klasor + extractIkn deseni). Belge Kasasi'na coklu dosya secimi: her
   dosyanin IKN'si METNINDEN bulunur ("Ihale Kayit Numarasi (IKN):
   2026/1166496"), kullaniciya sorulmaz; ihaleye baglanir ve teklif
   cetveli kalemleri otomatik havuza yazilir. Onlarca sartnameyi tek
   seferde atarak kalem havuzunu hizla doldurmak icin.
   - kalem-cikar.mjs#iknCikar + istemci esi (PDF tarayicida okunuyor)
   - Yukleme ucu: otoIkn → ofis metninden IKN; donuste ikn alani
   - Dosya basina sonuc satiri: IKN / kalem sayisi / "IKN bulunamadi"
   Dogrulandi: ikn parametresiz docx → 2026/1588948 bulundu, 2 kalem.

3) Kazanan istihbarati (fikir: gulmezeren2-byte/ihalent, MIT). Rakip
   Radari'nda firma basina kirim orani (1 − sozlesme/yaklasik maliyet,
   ortalama), ortalama gecerli teklif sayisi (rekabet derinligi) ve tek
   teklifle kazanma orani. Veri yoksa null — 0 ile karismasin; bedel >
   maliyet×1,5 olan bozuk satirlar orana katilmiyor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:16:53 +03:00

324 lines
13 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';
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 = { '&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);
}