Files
ihaleist/server/ted.mjs
T
hikmettanandClaude Fable 5.1 c32446db81 AB İhaleleri (TED): başlık ve açıklamalar Türkçe
- server/ted-ceviri.mjs: TED'in kendi Türkçe alanı varsa onu kullanır; yoksa
  OpenRouter ile toplu çevirir (deepseek-v4-flash → gemini 3.7 flash → glm),
  sonucu server/data/ted.sqlite'ta ilan numarasına göre saklar
- İstek en fazla 12 sn bekler, kalan çeviriler arka planda sürer;
  yanıt ceviriBekleniyor bayrağı taşır, ön yüz sessizce yeniden çeker
- Kartta Türkçe başlık + küçük özgün başlık, Türkçe açıklama (3 satır),
  ülke kodları Türkçe ad (TUR → Türkiye, AUT → Avusturya…)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:58:28 +03:00

163 lines
6.0 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ı' };
// Çok dilli alanda Türkçe varyant (varsa)
function turTxt(v) {
if (v && typeof v === 'object' && !Array.isArray(v) && typeof v.tur === 'string' && v.tur.trim()) return v.tur.trim();
return null;
}
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']),
baslikTur: turTxt(n['notice-title']),
aciklamaTur: turTxt(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;
}