- 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>
163 lines
6.0 KiB
JavaScript
163 lines
6.0 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|