/** * 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; }