feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar
Roadmap'in 6 maddesi de gerçek çalışır halde:
1) Geçmiş veri motoru (EKAP v2)
- server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
yaklaşık maliyet, teklif sayısı), TR firma adı normalize
- server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
- server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
- server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
- /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor
2) Gerçek AI şartname analizi
- server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
- pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
- AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata
3) KİK kurul kararları
- server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
- KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri
4) Fiyatlandırma + ödeme
- server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
- Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı
5) TED (AB ihaleleri)
- server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
- rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü
6) Kırmızı bayrak & şeffaflık
- analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
- RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
This commit is contained in:
+154
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* TED (Tenders Electronic Daily) — AB kamu ihale ilanları, Türkiye odaklı.
|
||||
*
|
||||
* Endpoint: POST https://api.ted.europa.eu/v3/notices/search (anonim, API key YOK)
|
||||
* Doğrulanmış davranış:
|
||||
* - `fields` boş olamaz (400 verir)
|
||||
* - limit max 250; page*limit max 15000; alan/sayfa max 10000 ("links" gizli alan da sayılır)
|
||||
* - Rate limit agresif ve belgelenmemiş: ~1 istek/sn güvenli. 429 gövdesi JSON DEĞİL (nginx HTML)
|
||||
* - Ülke kodları ISO alpha-3 (TUR); tarih formatı YYYYMMDD
|
||||
* - Metin alanları çok dilli obje: { eng: [...], tur: [...] }
|
||||
*
|
||||
* Bağımlılık yok (Node 18+ fetch).
|
||||
*/
|
||||
|
||||
const TED_URL = 'https://api.ted.europa.eu/v3/notices/search';
|
||||
|
||||
const FIELDS = [
|
||||
'publication-number', 'notice-title', 'notice-type', 'publication-date',
|
||||
'deadline-receipt-tender-date-lot', 'organisation-name-buyer', 'organisation-city-buyer',
|
||||
'buyer-country', 'winner-name', 'winner-country',
|
||||
'estimated-value-lot', 'estimated-value-cur-lot',
|
||||
'result-value-notice', 'result-value-cur-notice',
|
||||
'classification-cpv', 'contract-nature', 'description-lot',
|
||||
];
|
||||
|
||||
// Türkiye evreni: TR idarelerin AB ihaleleri + AB'de kazanan TR firmaları + teklif veren TR firmaları
|
||||
const Q_TURKIYE = '(buyer-country IN (TUR) OR winner-country IN (TUR) OR organisation-country-tenderer IN (TUR))';
|
||||
// TR firmalarının AB'de kazandıkları (rakip radar için)
|
||||
const Q_KAZANAN = 'winner-country IN (TUR)';
|
||||
|
||||
const cache = new Map(); // key -> {at, data}
|
||||
const TTL = 30 * 60 * 1000;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Çok dilli alan → tek string
|
||||
function txt(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v)) return txt(v[0]);
|
||||
if (typeof v === 'object') return txt(v.eng ?? v.tur ?? Object.values(v)[0]);
|
||||
return String(v);
|
||||
}
|
||||
const first = (v) => (Array.isArray(v) ? v[0] ?? null : v ?? null);
|
||||
const uniq = (v) => (Array.isArray(v) ? [...new Set(v)] : v == null ? [] : [v]);
|
||||
const day = (v) => { const s = first(v); return s ? String(s).slice(0, 10) : null; };
|
||||
|
||||
const NATURE_TR = { works: 'Yapım İşi', supplies: 'Mal Alımı', services: 'Hizmet Alımı' };
|
||||
|
||||
function mapNotice(n) {
|
||||
const pn = n['publication-number'];
|
||||
const bedel = n['result-value-notice'] ?? first(n['estimated-value-lot']);
|
||||
const cur = n['result-value-cur-notice'] ?? first(n['estimated-value-cur-lot']);
|
||||
const nature = first(n['contract-nature']);
|
||||
return {
|
||||
id: `ted-${pn}`,
|
||||
kaynak: 'TED',
|
||||
ikn: String(pn || ''),
|
||||
baslik: txt(n['notice-title']) || 'Başlıksız ilan',
|
||||
kurum: txt(n['organisation-name-buyer']) || '—',
|
||||
sehir: first(n['organisation-city-buyer'])?.trim() || null,
|
||||
ulke: first(n['buyer-country']) || null,
|
||||
tarih: day(n['deadline-receipt-tender-date-lot']) || day(n['publication-date']),
|
||||
yayimTarihi: day(n['publication-date']),
|
||||
sonTeklifTarihi: day(n['deadline-receipt-tender-date-lot']),
|
||||
yaklasikMaliyet: bedel != null && bedel !== '' ? Number(bedel) : null,
|
||||
paraBirimi: cur || null,
|
||||
kazananFirma: txt(n['winner-name']),
|
||||
kazananUlke: first(n['winner-country']),
|
||||
alimTuru: NATURE_TR[nature] || nature || 'AB İhalesi',
|
||||
ihaleTuru: n['notice-type'],
|
||||
sonucIlani: String(n['notice-type'] || '').startsWith('can'),
|
||||
cpv: uniq(n['classification-cpv']),
|
||||
aciklama: txt(n['description-lot']),
|
||||
link: n.links?.html?.ENG || (pn ? `https://ted.europa.eu/en/notice/-/detail/${pn}` : null),
|
||||
};
|
||||
}
|
||||
|
||||
async function tedSearch(body, { retries = 3 } = {}) {
|
||||
for (let i = 0; ; i++) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(TED_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
} catch (e) {
|
||||
if (i >= retries) throw new Error(`TED bağlantı hatası: ${e.message}`);
|
||||
await sleep(1000 * 2 ** i);
|
||||
continue;
|
||||
}
|
||||
// 429 gövdesi HTML gelir; her durumda önce text al
|
||||
const raw = await res.text();
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
if (i >= retries) throw new Error(`TED geçici olarak yanıt vermiyor (HTTP ${res.status}).`);
|
||||
await sleep(1200 * 2 ** i);
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = raw.slice(0, 300);
|
||||
try { const j = JSON.parse(raw); msg = j.message || msg; } catch { /* HTML yanıt */ }
|
||||
throw new Error(`TED API: ${msg}`);
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {'turkiye'|'kazanan'|'acik'} [opts.mod]
|
||||
* @param {string} [opts.query] Ham expert query (verilirse mod yok sayılır)
|
||||
* @param {string} [opts.since] YYYYMMDD
|
||||
*/
|
||||
export async function searchTed({ mod = 'turkiye', query, since = '20240101', page = 1, limit = 25 } = {}) {
|
||||
let q;
|
||||
let scope = 'ALL';
|
||||
if (query && query.trim()) {
|
||||
q = query.trim();
|
||||
} else if (mod === 'kazanan') {
|
||||
q = `${Q_KAZANAN} AND publication-date >= ${since} SORT BY publication-date DESC`;
|
||||
} else if (mod === 'acik') {
|
||||
q = `buyer-country IN (TUR) SORT BY publication-date DESC`;
|
||||
scope = 'ACTIVE';
|
||||
} else {
|
||||
q = `${Q_TURKIYE} AND publication-date >= ${since} SORT BY publication-date DESC`;
|
||||
}
|
||||
|
||||
const key = `${q}|${scope}|${limit}|${page}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < TTL) return hit.data;
|
||||
|
||||
const data = await tedSearch({
|
||||
query: q,
|
||||
fields: FIELDS,
|
||||
page,
|
||||
limit: Math.min(limit, 250),
|
||||
scope,
|
||||
onlyLatestVersions: true,
|
||||
paginationMode: 'PAGE_NUMBER',
|
||||
});
|
||||
|
||||
const out = {
|
||||
tenders: (data.notices || []).map(mapNotice),
|
||||
totalCount: data.totalNoticeCount ?? (data.notices || []).length,
|
||||
mod,
|
||||
query: q,
|
||||
timedOut: Boolean(data.timedOut),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
cache.set(key, { at: Date.now(), data: out });
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user