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>
This commit is contained in:
+4
-1
@@ -37,6 +37,7 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio
|
|||||||
import * as store from './user-store.mjs';
|
import * as store from './user-store.mjs';
|
||||||
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
||||||
import { searchTed } from './ted.mjs';
|
import { searchTed } from './ted.mjs';
|
||||||
|
import { tedCevir } from './ted-ceviri.mjs';
|
||||||
import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
|
import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
|
||||||
sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi,
|
sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi,
|
||||||
cpvCacheOku, cpvCacheYaz } from './history-store.mjs';
|
cpvCacheOku, cpvCacheYaz } from './history-store.mjs';
|
||||||
@@ -926,7 +927,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
page: Number(url.searchParams.get('page')) || 1,
|
page: Number(url.searchParams.get('page')) || 1,
|
||||||
limit: Number(url.searchParams.get('limit')) || 25,
|
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) {
|
} catch (e) {
|
||||||
return json(res, 502, { success: false, error: e.message });
|
return json(res, 502, { success: false, error: e.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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ı' };
|
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) {
|
function mapNotice(n) {
|
||||||
const pn = n['publication-number'];
|
const pn = n['publication-number'];
|
||||||
const bedel = n['result-value-notice'] ?? first(n['estimated-value-lot']);
|
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'),
|
sonucIlani: String(n['notice-type'] || '').startsWith('can'),
|
||||||
cpv: uniq(n['classification-cpv']),
|
cpv: uniq(n['classification-cpv']),
|
||||||
aciklama: txt(n['description-lot']),
|
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),
|
link: n.links?.html?.ENG || (pn ? `https://ted.europa.eu/en/notice/-/detail/${pn}` : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.' },
|
{ 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 = () => {
|
export const TedTenders = () => {
|
||||||
const [mod, setMod] = useState('acik');
|
const [mod, setMod] = useState('acik');
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const yukle = (m) => {
|
// Çeviriler sunucuda arka planda tamamlanır; "bekleniyor" geldiği sürece
|
||||||
setLoading(true);
|
// sessizce (yükleniyor göstergesi olmadan) birkaç kez yeniden çekilir.
|
||||||
setError(null);
|
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`)
|
fetch(`/api/ted?mod=${m}&limit=30`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { if (d.success) setData(d); else setError(d.error); })
|
.then((d) => {
|
||||||
.catch((e) => setError(e.message))
|
if (!d.success) { if (!sessiz) setError(d.error); return; }
|
||||||
.finally(() => setLoading(false));
|
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]);
|
useEffect(() => { yukle(mod); }, [mod]);
|
||||||
@@ -92,6 +110,9 @@ export const TedTenders = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{data?.ceviriBekleniyor && (
|
||||||
|
<p className="text-[11px] text-slate-500 flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> Başlıklar Türkçeye çevriliyor, birkaç saniye içinde güncellenir…</p>
|
||||||
|
)}
|
||||||
{data && (
|
{data && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.tenders.length === 0 && (
|
{data.tenders.length === 0 && (
|
||||||
@@ -102,7 +123,12 @@ export const TedTenders = () => {
|
|||||||
{data.tenders.map((t) => (
|
{data.tenders.map((t) => (
|
||||||
<div key={t.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-shadow space-y-2">
|
<div key={t.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs hover:shadow-md transition-shadow space-y-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="font-bold text-sm text-slate-900 leading-snug">{t.baslik}</h3>
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-bold text-sm text-slate-900 leading-snug">{t.baslikTr || t.baslik}</h3>
|
||||||
|
{t.baslikTr && t.baslikTr !== t.baslik && (
|
||||||
|
<p className="text-[11px] italic text-slate-400 leading-snug mt-0.5">{t.baslik}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{t.link && (
|
{t.link && (
|
||||||
<a href={t.link} target="_blank" rel="noopener noreferrer"
|
<a href={t.link} target="_blank" rel="noopener noreferrer"
|
||||||
className="text-sky-600 hover:text-sky-800 shrink-0" title="TED'de aç">
|
className="text-sky-600 hover:text-sky-800 shrink-0" title="TED'de aç">
|
||||||
@@ -116,7 +142,7 @@ export const TedTenders = () => {
|
|||||||
<Building2 className="w-3 h-3 text-slate-400" /> {t.kurum}
|
<Building2 className="w-3 h-3 text-slate-400" /> {t.kurum}
|
||||||
</span>
|
</span>
|
||||||
{t.sehir && <span>{t.sehir}</span>}
|
{t.sehir && <span>{t.sehir}</span>}
|
||||||
{t.ulke && <span className="bg-slate-100 px-1.5 py-0.5 rounded font-bold">{t.ulke}</span>}
|
{t.ulke && <span className="bg-slate-100 px-1.5 py-0.5 rounded font-bold">{ULKE_TR[t.ulke] || t.ulke}</span>}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3 text-blue-500" />
|
<Calendar className="w-3 h-3 text-blue-500" />
|
||||||
{t.sonTeklifTarihi ? `Son teklif: ${t.sonTeklifTarihi}` : t.yayimTarihi}
|
{t.sonTeklifTarihi ? `Son teklif: ${t.sonTeklifTarihi}` : t.yayimTarihi}
|
||||||
@@ -125,12 +151,16 @@ export const TedTenders = () => {
|
|||||||
{t.sonucIlani && <span className="bg-emerald-50 text-emerald-800 border border-emerald-200 px-1.5 py-0.5 rounded font-bold">Sonuç İlanı</span>}
|
{t.sonucIlani && <span className="bg-emerald-50 text-emerald-800 border border-emerald-200 px-1.5 py-0.5 rounded font-bold">Sonuç İlanı</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(t.aciklamaTr || t.aciklama) && (
|
||||||
|
<p className="text-xs text-slate-600 leading-relaxed line-clamp-3">{t.aciklamaTr || t.aciklama}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{(t.kazananFirma || t.yaklasikMaliyet != null) && (
|
{(t.kazananFirma || t.yaklasikMaliyet != null) && (
|
||||||
<div className="flex items-center gap-4 flex-wrap pt-2 border-t border-slate-100 text-[11px]">
|
<div className="flex items-center gap-4 flex-wrap pt-2 border-t border-slate-100 text-[11px]">
|
||||||
{t.kazananFirma && (
|
{t.kazananFirma && (
|
||||||
<span className="flex items-center gap-1 font-bold text-emerald-800">
|
<span className="flex items-center gap-1 font-bold text-emerald-800">
|
||||||
<Trophy className="w-3 h-3" /> {t.kazananFirma}
|
<Trophy className="w-3 h-3" /> {t.kazananFirma}
|
||||||
{t.kazananUlke && <span className="text-slate-400 font-normal">({t.kazananUlke})</span>}
|
{t.kazananUlke && <span className="text-slate-400 font-normal">({ULKE_TR[t.kazananUlke] || t.kazananUlke})</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{t.yaklasikMaliyet != null && (
|
{t.yaklasikMaliyet != null && (
|
||||||
|
|||||||
Reference in New Issue
Block a user