diff --git a/server/index.js b/server/index.js index 732598c..f8d9171 100644 --- a/server/index.js +++ b/server/index.js @@ -37,6 +37,7 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio import * as store from './user-store.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { searchTed } from './ted.mjs'; +import { tedCevir } from './ted-ceviri.mjs'; import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet, sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi, cpvCacheOku, cpvCacheYaz } from './history-store.mjs'; @@ -926,7 +927,9 @@ const server = http.createServer(async (req, res) => { page: Number(url.searchParams.get('page')) || 1, limit: Number(url.searchParams.get('limit')) || 25, }); - return json(res, 200, { success: true, ...out }); + // Başlık/açıklama Türkçesi: TED'in kendi TUR metni → önbellek → model + const bekleyenCeviri = await tedCevir(out.tenders).catch(() => 0); + return json(res, 200, { success: true, ...out, ceviriBekleniyor: bekleyenCeviri > 0 }); } catch (e) { return json(res, 502, { success: false, error: e.message }); } diff --git a/server/ted-ceviri.mjs b/server/ted-ceviri.mjs new file mode 100644 index 0000000..71d43b1 --- /dev/null +++ b/server/ted-ceviri.mjs @@ -0,0 +1,126 @@ +/** + * TED ilan başlık/açıklamalarının Türkçesi. + * 1) TED çok dilli alanında Türkçe varsa (tur) doğrudan o kullanılır — model gerekmez. + * 2) Yoksa OpenRouter ile toplu çevrilir (bir sayfa = tek istek), sonuç + * server/data/ted.sqlite'ta ilan numarasına göre saklanır; ikinci görüntüleme ücretsizdir. + * Çeviri başarısız olursa özgün metin döner; liste asla bekletilmez (üst sınır ~40 sn). + */ +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { openRouterKey } from './ai.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'ted.sqlite'); +let db = null; + +function open() { + if (db) return db; + try { + const { DatabaseSync } = require_sqlite(); + fs.mkdirSync(DATA_DIR, { recursive: true }); + db = new DatabaseSync(DB_PATH); + db.exec(`CREATE TABLE IF NOT EXISTS ceviri ( + ikn TEXT PRIMARY KEY, baslik_tr TEXT, aciklama_tr TEXT, kaynak TEXT, zaman TEXT NOT NULL + )`); + } catch (e) { + console.warn('[ted-ceviri] sqlite açılamadı, bellek içi çalışılıyor:', e.message); + db = null; + } + return db; +} +function require_sqlite() { return process.getBuiltinModule('node:sqlite'); } + +const bellek = new Map(); // sqlite yoksa +function oku(ikn) { + const d = open(); + if (d) return d.prepare('SELECT baslik_tr, aciklama_tr FROM ceviri WHERE ikn = ?').get(ikn) || null; + return bellek.get(ikn) || null; +} +function yaz(ikn, baslik_tr, aciklama_tr, kaynak) { + const d = open(); + if (d) d.prepare('INSERT OR REPLACE INTO ceviri (ikn, baslik_tr, aciklama_tr, kaynak, zaman) VALUES (?,?,?,?,?)').run(ikn, baslik_tr, aciklama_tr, kaynak, new Date().toISOString()); + else bellek.set(ikn, { baslik_tr, aciklama_tr }); +} + +// Ölçüm (6 ilan): deepseek-v4-flash 10 sn/851 token; glm-5.3-flash 71 sn boyunca +// 3000 "düşünme" tokenı tüketip boş dönüyor; gemini-flash-latest OpenRouter'da +// geçersiz kimlik. Bu yüzden sıra: deepseek → gemini 3.7 flash → glm. +const MODELLER = ['deepseek/deepseek-v4-flash', 'google/gemini-3.7-flash', 'z-ai/glm-5.3-flash']; +const SISTEM = 'Sen kamu ihale terminolojisine hâkim bir çevirmensin. Verilen AB (TED) ihale başlık ve açıklamalarını doğal, resmî Türkçeye çevir. Başlık "Ülke – Konu – İlan adı" biçimindedir: ülke adını da Türkçeleştir (Austria→Avusturya). Kurum adlarını çevirme, sayıları/tarihleri aynen koru. Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.'; + +async function modeldenCevir(kalemler, signal) { + const key = openRouterKey(); + if (!key) throw new Error('OPENROUTER_API_KEY yok'); + const girdi = JSON.stringify({ ilanlar: kalemler.map((k) => ({ id: k.id, baslik: k.baslik, aciklama: (k.aciklama || '').slice(0, 600) })) }); + let sonHata = null; + for (const model of MODELLER) { + try { + const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', signal, + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://ihaleist.com', 'X-Title': 'ihaleist' }, + body: JSON.stringify({ model, temperature: 0.1, max_tokens: 2500, response_format: { type: 'json_object' }, + messages: [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }] }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const icerik = data?.choices?.[0]?.message?.content || ''; + const s = icerik.slice(icerik.indexOf('{'), icerik.lastIndexOf('}') + 1); + const out = JSON.parse(s); + if (!Array.isArray(out?.ceviriler)) throw new Error('beklenen JSON değil'); + return out.ceviriler; + } catch (e) { sonHata = e; } + } + throw sonHata || new Error('çeviri başarısız'); +} + +const devamEden = new Set(); // arka planda çevrilmekte olan ikn'ler + +async function parcayiCevir(parca) { + const ctrl = new AbortController(); + const zamanlayici = setTimeout(() => ctrl.abort(), 90000); + try { + const sonuc = await modeldenCevir(parca, ctrl.signal); + const map = new Map(sonuc.map((x) => [String(x.id), x])); + for (const t of parca) { + const x = map.get(String(t.id)); + if (!x || !x.baslik) continue; + t.baslikTr = String(x.baslik).trim(); t.aciklamaTr = x.aciklama ? String(x.aciklama).trim() : null; t.ceviriKaynagi = 'model'; + if (t.ikn) yaz(t.ikn, t.baslikTr, t.aciklamaTr, 'model'); + } + } finally { + clearTimeout(zamanlayici); + for (const t of parca) devamEden.delete(t.ikn); + } +} + +/** + * Listeyi yerinde zenginleştirir: baslikTr, aciklamaTr, ceviriKaynagi. + * Önbellekte olmayanlar için model çağrısı en fazla `blockMs` beklenir; kalan + * parçalar arka planda sürer (liste nesneleri arama önbelleğinde durduğu için + * bir sonraki istek çevirileri hazır bulur). Dönüş: kaç kaydın hâlâ beklediği. + */ +export async function tedCevir(tenders, { blockMs = 12000 } = {}) { + if (!Array.isArray(tenders) || tenders.length === 0) return 0; + const bekleyen = []; + for (const t of tenders) { + if (t.baslikTr) continue; + if (t.baslikTur) { t.baslikTr = t.baslikTur; t.aciklamaTr = t.aciklamaTur || null; t.ceviriKaynagi = 'ted'; continue; } + const c = t.ikn ? oku(t.ikn) : null; + if (c && c.baslik_tr) { t.baslikTr = c.baslik_tr; t.aciklamaTr = c.aciklama_tr || null; t.ceviriKaynagi = 'onbellek'; continue; } + if (!devamEden.has(t.ikn)) bekleyen.push(t); + } + if (!openRouterKey()) return 0; + if (!bekleyen.length) return tenders.filter((t) => !t.baslikTr).length; + for (const t of bekleyen) devamEden.add(t.ikn); + const parcalar = []; + for (let i = 0; i < bekleyen.length; i += 6) parcalar.push(bekleyen.slice(i, i + 6)); + const is = (async () => { + for (const parca of parcalar) { + try { await parcayiCevir(parca); } catch (e) { console.warn('[ted-ceviri] parça atlandı:', e.message); } + } + })(); + await Promise.race([is, new Promise((r) => setTimeout(r, blockMs))]); + return tenders.filter((t) => !t.baslikTr).length; +} diff --git a/server/ted.mjs b/server/ted.mjs index 54a3010..de07f6f 100644 --- a/server/ted.mjs +++ b/server/ted.mjs @@ -47,6 +47,12 @@ const day = (v) => { const s = first(v); return s ? String(s).slice(0, 10) : nul 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']); @@ -72,6 +78,8 @@ function mapNotice(n) { 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), }; } diff --git a/src/components/tools/TedTenders.jsx b/src/components/tools/TedTenders.jsx index 6e29214..0ea8fb4 100644 --- a/src/components/tools/TedTenders.jsx +++ b/src/components/tools/TedTenders.jsx @@ -7,20 +7,38 @@ const MODLAR = [ { id: 'kazanan', label: 'AB’de Kazanan TR Firmaları', aciklama: 'Türk firmalarının AB ihalelerinde kazandığı sözleşmeler.' }, ]; +const ULKE_TR = { + TUR: 'Türkiye', DEU: 'Almanya', FRA: 'Fransa', ITA: 'İtalya', ESP: 'İspanya', NLD: 'Hollanda', BEL: 'Belçika', + AUT: 'Avusturya', POL: 'Polonya', ROU: 'Romanya', BGR: 'Bulgaristan', GRC: 'Yunanistan', HRV: 'Hırvatistan', + HUN: 'Macaristan', CZE: 'Çekya', SVK: 'Slovakya', SVN: 'Slovenya', PRT: 'Portekiz', SWE: 'İsveç', DNK: 'Danimarka', + FIN: 'Finlandiya', NOR: 'Norveç', IRL: 'İrlanda', LTU: 'Litvanya', LVA: 'Letonya', EST: 'Estonya', CYP: 'Kıbrıs', + MLT: 'Malta', LUX: 'Lüksemburg', CHE: 'İsviçre', GBR: 'İngiltere', SRB: 'Sırbistan', MKD: 'K. Makedonya', + ALB: 'Arnavutluk', BIH: 'Bosna-Hersek', MNE: 'Karadağ', XKX: 'Kosova', UKR: 'Ukrayna', MDA: 'Moldova', ISL: 'İzlanda', +}; + export const TedTenders = () => { const [mod, setMod] = useState('acik'); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const yukle = (m) => { - setLoading(true); - setError(null); + // Çeviriler sunucuda arka planda tamamlanır; "bekleniyor" geldiği sürece + // sessizce (yükleniyor göstergesi olmadan) birkaç kez yeniden çekilir. + const yenidenDeneme = React.useRef(0); + const yukle = (m, sessiz = false) => { + if (!sessiz) { setLoading(true); setError(null); yenidenDeneme.current = 0; } fetch(`/api/ted?mod=${m}&limit=30`) .then((r) => r.json()) - .then((d) => { if (d.success) setData(d); else setError(d.error); }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + .then((d) => { + if (!d.success) { if (!sessiz) setError(d.error); return; } + setData(d); + if (d.ceviriBekleniyor && yenidenDeneme.current < 12) { + yenidenDeneme.current += 1; + setTimeout(() => yukle(m, true), 7000); + } + }) + .catch((e) => { if (!sessiz) setError(e.message); }) + .finally(() => { if (!sessiz) setLoading(false); }); }; useEffect(() => { yukle(mod); }, [mod]); @@ -92,6 +110,9 @@ export const TedTenders = () => { )} + {data?.ceviriBekleniyor && ( +

Başlıklar Türkçeye çevriliyor, birkaç saniye içinde güncellenir…

+ )} {data && (
{data.tenders.length === 0 && ( @@ -102,7 +123,12 @@ export const TedTenders = () => { {data.tenders.map((t) => (
-

{t.baslik}

+
+

{t.baslikTr || t.baslik}

+ {t.baslikTr && t.baslikTr !== t.baslik && ( +

{t.baslik}

+ )} +
{t.link && ( @@ -116,7 +142,7 @@ export const TedTenders = () => { {t.kurum} {t.sehir && {t.sehir}} - {t.ulke && {t.ulke}} + {t.ulke && {ULKE_TR[t.ulke] || t.ulke}} {t.sonTeklifTarihi ? `Son teklif: ${t.sonTeklifTarihi}` : t.yayimTarihi} @@ -125,12 +151,16 @@ export const TedTenders = () => { {t.sonucIlani && Sonuç İlanı}
+ {(t.aciklamaTr || t.aciklama) && ( +

{t.aciklamaTr || t.aciklama}

+ )} + {(t.kazananFirma || t.yaklasikMaliyet != null) && (
{t.kazananFirma && ( {t.kazananFirma} - {t.kazananUlke && ({t.kazananUlke})} + {t.kazananUlke && ({ULKE_TR[t.kazananUlke] || t.kazananUlke})} )} {t.yaklasikMaliyet != null && (