Files
ihaleist/server/ted.mjs
T
hikmettan 168052c267 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
2026-08-28 10:37:50 +03:00

155 lines
5.7 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.
/**
* 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;
}