feat: İhaleAB'den taşınan özellikler — MCP sunucusu, RSS, haftalık brief, teklif asistanı, yeterlilik rozeti, 5 yeni kırmızı bayrak
- /mcp (JSON-RPC, ih_live_ anahtarı): search_tenders, get_tender, search_results, search_items, upcoming_deadlines - /api/feeds/tenders.rss (anahtarlı) ve /api/feeds/sonuc.rss (açık) - Haftalık brief (Pazartesi 08:00, Telegram + e-posta), /api/me/brief tercihi + önizleme, sağ panelde yaklaşan ihale tarihleri - Detayda Teklif Asistanı sekmesi: belgeye sor (atıflı), taslak denetimi, taslak üretimi (analyzeSpec zinciri) - Derin analiz belge listesi × Doküman Deposu eşleştirme rozeti - Kırmızı bayrak: kısa süre, hafta sonu tarih, geç sözleşme, kazananı gizli, DT parçalama şüphesi - Kullanım kılavuzu güncellendi Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut
|
||||
// analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde:
|
||||
// 1) belgeyeSor: ihale dokümanı/ilan metninde soru → atıflı yanıt
|
||||
// 2) teklifDenetle: kullanıcının taslağı × ihale gereklilikleri → karşılanıyor/eksik/belirsiz
|
||||
// 3) teklifTaslak: kapak mektubu / teknik / ticari / tam dosya bölümleri
|
||||
// Sayılar (fiyat, süre, adet) yalnızca verilen veriden; model rakam uydurmaz,
|
||||
// bilinmeyene [DOLDURUN] koyar.
|
||||
import { analyzeSpec } from './ai.mjs';
|
||||
import { ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
||||
import * as kalemStore from './kalem-store.mjs';
|
||||
|
||||
const DURAK = new Set(['ve', 'veya', 'ile', 'için', 'bir', 'bu', 'ne', 'nasıl', 'kaç', 'kadar', 'mi', 'mı', 'mu', 'mü', 'var', 'yok', 'olan', 'olarak', 'gibi', 'ihale', 'ihalede', 'şartname', 'şartnamede', 'nedir', 'midir', 'hangi']);
|
||||
const norm = (s) => String(s ?? '').toLocaleLowerCase('tr-TR').replace(/[^\p{L}\p{N}\s%]/gu, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
export function terimler(soru) {
|
||||
return [...new Set(norm(soru).split(' ').filter((k) => k && !DURAK.has(k) && (k.length >= 3 || /^\d+$/.test(k))))].slice(0, 12);
|
||||
}
|
||||
|
||||
/** Metni ~1200 karakterlik parçalara böler (paragraf sınırlarında). */
|
||||
export function parcala(metin, hedef = 1200) {
|
||||
const p = []; let tampon = '';
|
||||
for (const satir of String(metin ?? '').replace(/\r/g, '').split('\n')) {
|
||||
if (tampon.length + satir.length > hedef * 1.5 && tampon.trim()) { p.push(tampon.trim()); tampon = ''; }
|
||||
tampon += satir + '\n';
|
||||
if (tampon.length >= hedef && satir.trim() === '') { p.push(tampon.trim()); tampon = ''; }
|
||||
}
|
||||
if (tampon.trim().length >= 40) p.push(tampon.trim());
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Terim kapsamasına göre en iyi k parça. */
|
||||
export function getir(parcalar, soru, k = 8) {
|
||||
const t = terimler(soru);
|
||||
if (!t.length) return [];
|
||||
const ifade = norm(soru);
|
||||
return parcalar
|
||||
.map((icerik, i) => {
|
||||
const m = norm(icerik);
|
||||
let hit = 0;
|
||||
for (const x of t) if (m.includes(x)) hit += /^\d+$/.test(x) ? 1.5 : 1;
|
||||
const puan = hit / t.length + (ifade.length >= 8 && m.includes(ifade) ? 0.5 : 0);
|
||||
return { n: i + 1, icerik, puan };
|
||||
})
|
||||
.filter((x) => x.puan > 0)
|
||||
.sort((a, b) => b.puan - a.puan)
|
||||
.slice(0, k);
|
||||
}
|
||||
|
||||
/** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */
|
||||
export async function ihaleMetni(ikn, verilen) {
|
||||
if (verilen && String(verilen).trim().length > 200) return { text: String(verilen), kaynak: 'yüklenen' };
|
||||
if (ikn && (await browserAvailable().catch(() => false))) {
|
||||
try { const r = await documentText(ikn); if (r?.text && r.text.length > 200) return { text: r.text, kaynak: 'EKAP dokümanı' }; } catch { /* ilan metnine düş */ }
|
||||
}
|
||||
if (ikn) {
|
||||
try { const r = await ilanMetni(ikn); const text = typeof r === 'string' ? r : r?.text; if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ }
|
||||
}
|
||||
return { text: '', kaynak: null };
|
||||
}
|
||||
|
||||
export async function belgeyeSor({ ikn, soru, text, tender, chain }) {
|
||||
const q = String(soru ?? '').trim().slice(0, 500);
|
||||
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.');
|
||||
const { text: metin, kaynak } = await ihaleMetni(ikn, text);
|
||||
if (!metin) throw new Error('Bu ihale için doküman metni bulunamadı — PDF\'i "Yapay Zeka Asistanı"ndan yükleyip tekrar sorun.');
|
||||
const parcalar = parcala(metin);
|
||||
const kaynaklar = getir(parcalar, q, 8);
|
||||
if (!kaynaklar.length) return { cevap: 'Belgede bu soruyla eşleşen bir bölüm bulunamadı; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak };
|
||||
const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik.slice(0, 1400)}`).join('\n\n');
|
||||
const sistem = `Sen Türkiye kamu ihalesi uzmanısın. Sana bir ihale dokümanının numaralı parçaları verilecek. YALNIZCA bu parçalardaki bilgiyle yanıt ver; parçalarda yoksa "Belgede bu bilgi bulunamadı" de, tahmin etme. Her iddianın sonuna dayandığı parça numarasını köşeli parantezle yaz: [1], [3]. Sayı, tarih, tutar, süre değerlerini aynen aktar. Kısa ve net Türkçe. Yalnızca JSON döndür: {"cevap":"..."}`;
|
||||
const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, systemOverride: sistem });
|
||||
const cevap = String(out?.result?.cevap ?? '').trim() || 'Yanıt üretilemedi.';
|
||||
const kullanilan = new Set([...cevap.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])));
|
||||
return { cevap, kaynaklar: (kullanilan.size ? kaynaklar.filter((k) => kullanilan.has(k.n)) : kaynaklar.slice(0, 3)).map((k) => ({ n: k.n, alinti: k.icerik.slice(0, 1400) })), kaynak, model: out.model?.label };
|
||||
}
|
||||
|
||||
function gereklilikMetni(tender, gereklilikler, metin) {
|
||||
const g = [];
|
||||
for (const x of Array.isArray(gereklilikler) ? gereklilikler : []) if (x) g.push(typeof x === 'string' ? x : x.belge || x.item || x.ad || JSON.stringify(x));
|
||||
for (const y of Array.isArray(tender?.yeterlikKriterleri) ? tender.yeterlikKriterleri : []) if (y) g.push(String(y));
|
||||
for (const i of Array.isArray(tender?.items) ? tender.items.slice(0, 30) : []) g.push(`Kalem: ${i.ad} ${i.miktar ?? ''} ${i.birim ?? ''}`.trim());
|
||||
if (tender?.ikn && kalemStore.available()) for (const k of (kalemStore.kalemOku(tender.ikn) || []).slice(0, 30)) g.push(`Kalem: ${k.ad} ${k.miktar ?? ''} ${k.birim ?? ''}`.trim());
|
||||
if (metin) for (const s of ['teminat', 'teslim süresi', 'geçerlilik', 'ceza', 'numune', 'belge']) { const p = getir(parcala(metin), s, 1)[0]; if (p) g.push(`${s}: ${p.icerik.slice(0, 300).replace(/\s+/g, ' ')}`); }
|
||||
return [...new Set(g)].slice(0, 60);
|
||||
}
|
||||
|
||||
export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) {
|
||||
const t = String(taslak ?? '').trim().slice(0, 60000);
|
||||
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.');
|
||||
const { text: metin } = await ihaleMetni(ikn, text).catch(() => ({ text: '' }));
|
||||
const liste = gereklilikMetni(tender, gereklilikler, metin);
|
||||
if (!liste.length) throw new Error('Gereklilik listesi yok — önce derin analiz çalıştırın veya dokümanı yükleyin.');
|
||||
const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür.`;
|
||||
const out = await analyzeSpec({ text: `GEREKLİLİKLER:\n${liste.map((g, i) => `${i + 1}. ${g}`).join('\n')}\n\nTEKLİF TASLAĞI:\n${t}\n\nYalnızca JSON döndür.`, tender, chain, systemOverride: sistem, mode: 'derin' });
|
||||
const ham = Array.isArray(out?.result?.maddeler) ? out.result.maddeler : [];
|
||||
const DUR = new Set(['karsilaniyor', 'eksik', 'belirsiz']);
|
||||
const maddeler = liste.map((g, i) => {
|
||||
const m = ham[i] ?? {};
|
||||
return { gereklilik: g, durum: DUR.has(m.durum) ? m.durum : 'belirsiz', kanit: m.kanit ? String(m.kanit).slice(0, 400) : null, oneri: m.oneri ? String(m.oneri).slice(0, 400) : null };
|
||||
});
|
||||
const ok = maddeler.filter((m) => m.durum === 'karsilaniyor').length;
|
||||
const yarim = maddeler.filter((m) => m.durum === 'belirsiz').length;
|
||||
return { ozet: String(out?.result?.ozet ?? '').slice(0, 800), maddeler, uyum_yuzde: Math.round(((ok + yarim * 0.5) / maddeler.length) * 100), eksikler: maddeler.filter((m) => m.durum === 'eksik').map((m) => m.gereklilik), model: out.model?.label };
|
||||
}
|
||||
|
||||
const BOLUMLER = {
|
||||
kapak: ['Teklif Mektubu (Kapak)'],
|
||||
teknik: ['Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ekler'],
|
||||
ticari: ['Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
|
||||
tam: ['Teklif Mektubu (Kapak)', 'Firma Tanıtımı ve Yeterlilik', 'Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
|
||||
};
|
||||
|
||||
export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) {
|
||||
const istenen = BOLUMLER[tur] || BOLUMLER.tam;
|
||||
const { text: metin } = await ihaleMetni(ikn, text).catch(() => ({ text: '' }));
|
||||
const liste = gereklilikMetni(tender, gereklilikler, metin);
|
||||
const baglam = [
|
||||
`İHALE: ${tender?.baslik ?? '?'} · İdare: ${tender?.kurum ?? '?'} · İl: ${tender?.sehir ?? '?'} · Tarih: ${tender?.tarih ?? '?'} · Usul: ${tender?.usul ?? tender?.alimTuru ?? '?'}${tender?.yaklasikMaliyet ? ' · Yaklaşık maliyet: ' + tender.yaklasikMaliyet : ''}${ikn ? ' · İKN ' + ikn : ''}`,
|
||||
firma ? `FİRMA: ${typeof firma === 'string' ? firma : JSON.stringify(firma).slice(0, 1500)}` : null,
|
||||
liste.length ? 'GEREKLİLİKLER / KALEMLER:\n' + liste.map((g) => '- ' + g).join('\n') : null,
|
||||
Array.isArray(belgeler) && belgeler.length ? 'DEPODAKİ BELGELER:\n' + belgeler.slice(0, 40).map((b) => `- ${b.ad || b}${b.kategori ? ' [' + b.kategori + ']' : ''}`).join('\n') : null,
|
||||
fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null,
|
||||
].filter(Boolean).join('\n\n');
|
||||
const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver.`;
|
||||
const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, systemOverride: sistem, mode: 'derin' });
|
||||
const bolumler = (Array.isArray(out?.result?.bolumler) ? out.result.bolumler : [])
|
||||
.map((b) => ({ baslik: String(b?.baslik ?? '').slice(0, 200), icerik: String(b?.icerik ?? '').slice(0, 12000) }))
|
||||
.filter((b) => b.baslik && b.icerik);
|
||||
if (!bolumler.length) throw new Error('Model bölüm üretmedi; tekrar deneyin.');
|
||||
return { bolumler, model: out.model?.label };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar,
|
||||
// geçen hafta sonuçlanan takipliler, takiplilere gelen zeyilname sayısı.
|
||||
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
|
||||
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as store from './user-store.mjs';
|
||||
import { resultsByIkn } from './history-store.mjs';
|
||||
import { emailConfigured, sendEmail } from './email.mjs';
|
||||
import { sendTelegram, telegramConfigured } from './telegram.mjs';
|
||||
import { yaklasanIhaleler, trTarih } from './feeds-mcp.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DURUM_DOSYASI = path.join(__dirname, 'data', 'brief-son.json');
|
||||
|
||||
function sonGonderim() {
|
||||
try { return JSON.parse(fs.readFileSync(DURUM_DOSYASI, 'utf8')); } catch { return {}; }
|
||||
}
|
||||
function sonGonderimYaz(d) {
|
||||
try { fs.mkdirSync(path.dirname(DURUM_DOSYASI), { recursive: true }); fs.writeFileSync(DURUM_DOSYASI, JSON.stringify(d)); } catch { /* yok */ }
|
||||
}
|
||||
|
||||
const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`);
|
||||
|
||||
/** Bir kullanıcı için brief içeriği. */
|
||||
export function briefOlustur(email, tenders) {
|
||||
const takip = (store.getData(email, 'takip') || []).map(String);
|
||||
const yaklasan = yaklasanIhaleler(email, tenders, 7);
|
||||
const birHafta = Date.now() - 7 * 86400000;
|
||||
const sonuclar = [];
|
||||
for (const ikn of takip.slice(0, 200)) {
|
||||
const r = resultsByIkn(ikn) || [];
|
||||
for (const s of r) {
|
||||
const d = trTarih(s.sozlesmeTarihi) || trTarih(s.tarih);
|
||||
if (d && d.getTime() >= birHafta) sonuclar.push({ ikn, baslik: s.baslik, kazanan: s.kazananFirma, bedel: s.sozlesmeBedeli, birim: s.paraBirimi, teklif: s.teklifSayisi });
|
||||
}
|
||||
}
|
||||
const hafta = new Date().toLocaleDateString('tr-TR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length };
|
||||
}
|
||||
|
||||
export function briefMetni(b) {
|
||||
const s = [`📋 Haftalık İhale Brief — ${b.hafta}`];
|
||||
if (b.yaklasan.length) {
|
||||
s.push('', `⏰ Bu hafta yapılacak takipli ihaleler (${b.yaklasan.length}):`);
|
||||
for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih}${t.saat ? ' ' + t.saat : ''})`);
|
||||
}
|
||||
if (b.sonuclar.length) {
|
||||
s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`);
|
||||
for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`);
|
||||
}
|
||||
s.push('', 'ihaleist.com');
|
||||
return s.join('\n');
|
||||
}
|
||||
|
||||
export function briefHtml(b) {
|
||||
const esc = (x) => String(x ?? '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||
const bolum = (baslik, satirlar) => satirlar.length
|
||||
? `<h3 style="margin:18px 0 6px;font-size:15px;color:#0f172a">${baslik}</h3><ul style="margin:0;padding-left:18px;color:#334155;font-size:14px">${satirlar.map((x) => `<li style="margin:4px 0">${x}</li>`).join('')}</ul>` : '';
|
||||
return `<div style="font-family:Inter,Arial,sans-serif;max-width:640px;margin:auto;padding:20px;color:#0f172a">
|
||||
<h2 style="margin:0 0 4px;font-size:20px">📋 Haftalık İhale Brief</h2><div style="color:#64748b;font-size:13px">${esc(b.hafta)}</div>
|
||||
${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `<b>${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'}</b> — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)})`))}
|
||||
${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}
|
||||
<p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`;
|
||||
}
|
||||
|
||||
export async function haftalikBriefGonder(tenderGetir, { zorla = false } = {}) {
|
||||
const tenders = await tenderGetir();
|
||||
const r = { kullanici: 0, gonderilen: 0, bos: 0, hata: 0 };
|
||||
for (const u of store.listUsers()) {
|
||||
r.kullanici++;
|
||||
try {
|
||||
const tercih = store.getData(u.email, 'brief');
|
||||
if (tercih && tercih.kapali && !zorla) continue;
|
||||
const b = briefOlustur(u.email, tenders);
|
||||
if (b.bos) { r.bos++; continue; }
|
||||
const tg = store.getData(u.email, 'telegram');
|
||||
let gitti = false;
|
||||
if (telegramConfigured() && tg?.chatId) gitti = await sendTelegram(tg.chatId, briefMetni(b).slice(0, 3900)).then(() => true).catch(() => false);
|
||||
if (emailConfigured()) gitti = (await sendEmail({ to: u.email, subject: `İhaleist — Haftalık Brief (${b.hafta})`, html: briefHtml(b), text: briefMetni(b) }).then(() => true).catch(() => false)) || gitti;
|
||||
if (gitti) r.gonderilen++;
|
||||
} catch (e) {
|
||||
r.hata++;
|
||||
console.warn('[brief]', u.email, e.message);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Saatlik kontrol: Pazartesi ≥08:00 ve bu hafta gönderilmediyse gönder. */
|
||||
export function briefZamanlayici(tenderGetir) {
|
||||
if (process.env.BRIEF_ENABLED === '0') { console.log('[brief] haftalık brief devre dışı (BRIEF_ENABLED=0)'); return; }
|
||||
const kontrol = async () => {
|
||||
const simdi = new Date();
|
||||
if (simdi.getDay() !== 1 || simdi.getHours() < 8) return;
|
||||
const anahtar = `${simdi.getFullYear()}-${simdi.getMonth()}-${simdi.getDate()}`;
|
||||
const d = sonGonderim();
|
||||
if (d.son === anahtar) return;
|
||||
sonGonderimYaz({ son: anahtar });
|
||||
const r = await haftalikBriefGonder(tenderGetir).catch((e) => ({ hata: e.message }));
|
||||
console.log('[brief] haftalık brief:', JSON.stringify(r));
|
||||
};
|
||||
setTimeout(kontrol, 3 * 60 * 1000).unref?.();
|
||||
setInterval(kontrol, 60 * 60 * 1000).unref?.();
|
||||
console.log('[brief] haftalık brief zamanlandı (Pazartesi 08:00, saatlik kontrol)');
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// RSS beslemeleri + MCP sunucusu (İhaleAB'den taşındı, ihaleist verisine uyarlandı).
|
||||
//
|
||||
// RSS: /api/feeds/tenders.rss?key=ih_live_…&q=&il=&sektor= (okuyucular başlık
|
||||
// gönderemez → anahtar sorguda), /api/feeds/sonuc.rss (açık; EKAP sonuçları).
|
||||
// MCP: POST /mcp — JSON-RPC 2.0 Streamable HTTP; Claude Code / Cursor /
|
||||
// Claude Desktop (mcp-remote) araçları: search_tenders, get_tender,
|
||||
// search_results, search_items, upcoming_deadlines. Bağımlılık yok.
|
||||
import * as store from './user-store.mjs';
|
||||
import { readHistory, resultsByIkn } from './history-store.mjs';
|
||||
import * as kalemStore from './kalem-store.mjs';
|
||||
|
||||
const SITE = 'https://ihaleist.com';
|
||||
const xml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const norm = (s) => String(s ?? '').toLocaleLowerCase('tr-TR');
|
||||
|
||||
export function rssUret({ title, link, description, items }) {
|
||||
const tarih = (v) => (v ? new Date(v).toUTCString() : new Date().toUTCString());
|
||||
const ogeler = items.map((i) =>
|
||||
`<item><title>${xml(i.title)}</title><link>${xml(i.link)}</link><guid isPermaLink="false">${xml(i.guid ?? i.link)}</guid>` +
|
||||
(i.description ? `<description>${xml(i.description)}</description>` : '') +
|
||||
(i.category ?? []).map((c) => `<category>${xml(c)}</category>`).join('') +
|
||||
`<pubDate>${tarih(i.pubDate)}</pubDate></item>`).join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>${xml(title)}</title><link>${xml(link)}</link><description>${xml(description)}</description><language>tr</language><lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${ogeler}</channel></rss>`;
|
||||
}
|
||||
|
||||
/** dd.mm.yyyy → Date (ihaleist tarih biçimi). */
|
||||
export function trTarih(s) {
|
||||
const m = /(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(String(s ?? ''));
|
||||
return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null;
|
||||
}
|
||||
|
||||
/** Liste filtresi — RSS ve MCP ortak. */
|
||||
export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}) {
|
||||
const qq = norm(q); const ill = norm(il); const sek = norm(sektor);
|
||||
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
|
||||
return tenders
|
||||
.filter((t) => !sadeceAcik || !t.kazananFirma)
|
||||
.filter((t) => !sadeceAcik || !trTarih(t.tarih) || trTarih(t.tarih) >= bugun)
|
||||
.filter((t) => !ill || norm(t.sehir).includes(ill))
|
||||
.filter((t) => !sek || norm(t.sektor).includes(sek))
|
||||
.filter((t) => !qq || norm(t.baslik).includes(qq) || norm(t.kurum).includes(qq) || String(t.ikn ?? '').includes(qq) ||
|
||||
(Array.isArray(t.kalemler) && t.kalemler.some((k) => norm(k.ad ?? k).includes(qq))))
|
||||
.slice(0, Math.min(Math.max(Number(limit) || 50, 1), 200));
|
||||
}
|
||||
|
||||
export function ihaleRss(tenders, filtre) {
|
||||
const liste = ihaleFiltrele(tenders, filtre);
|
||||
const f = [filtre.il && `il ${filtre.il}`, filtre.sektor && filtre.sektor, filtre.q && `"${filtre.q}"`].filter(Boolean).join(', ');
|
||||
return rssUret({
|
||||
title: `İhaleist — ihaleler${f ? ' (' + f + ')' : ''}`,
|
||||
link: SITE,
|
||||
description: 'Türkiye kamu ihaleleri (ilan.gov.tr + EKAP). Filtreler URL\'de.',
|
||||
items: liste.map((t) => ({
|
||||
title: `${t.sehir ? t.sehir + ' · ' : ''}${t.baslik}`,
|
||||
link: t.kaynakUrl || `${SITE}/?ikn=${encodeURIComponent(t.ikn || '')}`,
|
||||
guid: t.id,
|
||||
description: [t.kurum, t.tarih ? 'ihale ' + t.tarih + (t.saat ? ' ' + t.saat : '') : null, t.alimTuru, t.ikn ? 'İKN ' + t.ikn : null].filter(Boolean).join(' · '),
|
||||
pubDate: t.yayinZamani || trTarih(t.ilanTarihi),
|
||||
category: [t.sektor, t.alimTuru].filter(Boolean),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function sonucRss({ firma, idare, il } = {}) {
|
||||
const { tenders } = readHistory(50, { firma, idare, il });
|
||||
return rssUret({
|
||||
title: 'İhaleist — EKAP sonuç ilanları',
|
||||
link: SITE,
|
||||
description: 'Sonuçlanan ihaleler: kazanan firma, sözleşme bedeli, teklif sayısı.',
|
||||
items: tenders.map((t) => ({
|
||||
title: `${t.kazananFirma ?? 'kazanan açıklanmadı'} ← ${t.baslik}`,
|
||||
link: `${SITE}/?ikn=${encodeURIComponent(t.ikn || '')}`,
|
||||
guid: t.id,
|
||||
description: [t.kurum, t.sozlesmeBedeli != null ? `${t.sozlesmeBedeli} ${t.paraBirimi ?? 'TL'}` : null, t.teklifSayisi != null ? `${t.teklifSayisi} teklif` : null, t.sozlesmeTarihi ? 'sözleşme ' + t.sozlesmeTarihi : null].filter(Boolean).join(' · '),
|
||||
pubDate: trTarih(t.sozlesmeTarihi) || trTarih(t.tarih),
|
||||
category: [t.sektor].filter(Boolean),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- MCP -----------------------------------------------------------------
|
||||
|
||||
export const PROTOKOL = '2025-03-26';
|
||||
export const SUNUCU = { name: 'ihaleist', version: '1.0.0' };
|
||||
|
||||
export const TOOLS = [
|
||||
{ name: 'search_tenders', description: 'Türkiye kamu ihalelerinde arama (ilan.gov.tr + EKAP + doğrudan temin). q başlık/kurum/İKN/teklif cetveli kaleminde arar.',
|
||||
inputSchema: { type: 'object', properties: { q: { type: 'string' }, il: { type: 'string', description: 'İl adı' }, sektor: { type: 'string' }, sadece_acik: { type: 'boolean', description: 'Varsayılan true' }, limit: { type: 'integer', minimum: 1, maximum: 100 } } } },
|
||||
{ name: 'get_tender', description: 'İKN ile ihale ayrıntısı; varsa teklif cetveli kalemleri ve sonuç ilanı.',
|
||||
inputSchema: { type: 'object', properties: { ikn: { type: 'string' } }, required: ['ikn'] } },
|
||||
{ name: 'search_results', description: 'EKAP sonuç ilanlarında kazanan firma / idare / il ile arama: sözleşme bedeli, teklif sayısı, kırım.',
|
||||
inputSchema: { type: 'object', properties: { firma: { type: 'string' }, idare: { type: 'string' }, il: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } },
|
||||
{ name: 'search_items', description: 'Teklif cetveli kalemlerinde (OKAS/ürün adı) arama — hangi ihalede hangi ürün istenmiş.',
|
||||
inputSchema: { type: 'object', properties: { q: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 400 } }, required: ['q'] } },
|
||||
{ name: 'upcoming_deadlines', description: 'API anahtarı sahibinin takip ettiği ihalelerden N gün içinde yapılacak olanlar.',
|
||||
inputSchema: { type: 'object', properties: { gun: { type: 'integer', minimum: 1, maximum: 90 } } } },
|
||||
];
|
||||
|
||||
const hata = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
||||
const sonuc = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result });
|
||||
|
||||
export function yaklasanIhaleler(email, tenders, gun = 14) {
|
||||
const takip = new Set((store.getData(email, 'takip') || []).map(String));
|
||||
const bugun = new Date(); bugun.setHours(0, 0, 0, 0);
|
||||
const sinir = new Date(bugun.getTime() + gun * 86400000);
|
||||
return tenders
|
||||
.filter((t) => t.ikn && takip.has(String(t.ikn)))
|
||||
.map((t) => ({ t, d: trTarih(t.tarih) }))
|
||||
.filter(({ d }) => d && d >= bugun && d <= sinir)
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.map(({ t, d }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir, tarih: t.tarih, saat: t.saat, kalan_gun: Math.round((d - bugun) / 86400000), kaynakUrl: t.kaynakUrl }));
|
||||
}
|
||||
|
||||
export async function aracCalistir(name, args, user, ctx) {
|
||||
const tenders = await ctx.tenderGetir();
|
||||
switch (name) {
|
||||
case 'search_tenders': {
|
||||
const liste = ihaleFiltrele(tenders, { q: args?.q, il: args?.il, sektor: args?.sektor, sadeceAcik: args?.sadece_acik !== false, limit: args?.limit ?? 20 });
|
||||
return { count: liste.length, tenders: liste.map(({ items, aciklama, ...t }) => t) };
|
||||
}
|
||||
case 'get_tender': {
|
||||
const ikn = String(args?.ikn ?? '').trim();
|
||||
const t = tenders.find((x) => String(x.ikn) === ikn);
|
||||
const kalemler = kalemStore.available() ? kalemStore.kalemOku(ikn) : null;
|
||||
const sonuc = resultsByIkn(ikn);
|
||||
if (!t && !sonuc?.length) throw new Error('İhale bulunamadı: ' + ikn);
|
||||
return { tender: t ?? null, kalemler: kalemler ?? [], sonuc: sonuc ?? [] };
|
||||
}
|
||||
case 'search_results': {
|
||||
const r = readHistory(Math.min(Number(args?.limit ?? 50) || 50, 200), { firma: args?.firma, idare: args?.idare, il: args?.il });
|
||||
return { total: r.totalCount, results: r.tenders.map(({ items, ...t }) => t) };
|
||||
}
|
||||
case 'search_items':
|
||||
return { items: kalemStore.available() ? kalemStore.kalemAra(String(args?.q ?? ''), { limit: Math.min(Number(args?.limit ?? 100) || 100, 400) }) : [] };
|
||||
case 'upcoming_deadlines':
|
||||
return { tenders: yaklasanIhaleler(user.email, tenders, Number(args?.gun ?? 14) || 14) };
|
||||
default:
|
||||
throw new Error('Bilinmeyen araç: ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rpcIsle(req, user, ctx) {
|
||||
const { id, method, params } = req ?? {};
|
||||
if (req?.jsonrpc !== '2.0' || typeof method !== 'string') return hata(id, -32600, 'Geçersiz istek');
|
||||
const bildirim = id === undefined;
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
return sonuc(id, { protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOKOL, capabilities: { tools: { listChanged: false } }, serverInfo: SUNUCU,
|
||||
instructions: 'İhaleist: Türkiye kamu ihaleleri. search_tenders → get_tender(ikn) → search_results(firma) tipik akıştır; Türkçe sorgu kullanın.' });
|
||||
case 'notifications/initialized':
|
||||
case 'notifications/cancelled':
|
||||
return null;
|
||||
case 'ping':
|
||||
return sonuc(id, {});
|
||||
case 'tools/list':
|
||||
return sonuc(id, { tools: TOOLS });
|
||||
case 'tools/call': {
|
||||
const name = params?.name;
|
||||
if (!TOOLS.some((t) => t.name === name)) return hata(id, -32602, 'Bilinmeyen araç: ' + name);
|
||||
try {
|
||||
const r = await aracCalistir(name, params?.arguments ?? {}, user, ctx);
|
||||
return sonuc(id, { content: [{ type: 'text', text: JSON.stringify(r, null, 1) }], isError: false });
|
||||
} catch (e) {
|
||||
return sonuc(id, { content: [{ type: 'text', text: e.message }], isError: true });
|
||||
}
|
||||
}
|
||||
default:
|
||||
return bildirim ? null : hata(id, -32601, 'Yöntem bulunamadı: ' + method);
|
||||
}
|
||||
}
|
||||
|
||||
export function anahtarAyikla(authorization, xApiKey, sorguKey) {
|
||||
const bearer = /^Bearer\s+(\S+)$/i.exec(authorization || '')?.[1];
|
||||
const aday = (bearer || xApiKey || sorguKey || '').trim();
|
||||
return aday.startsWith('ih_live_') ? aday : null;
|
||||
}
|
||||
|
||||
/** HTTP giriş noktası — index.js'ten çağrılır. */
|
||||
export async function mcpIstek(req, res, { readBody, json, tenderGetir }) {
|
||||
if (req.method === 'GET') return json(res, 405, hata(null, -32000, 'SSE akışı desteklenmiyor; POST kullanın'));
|
||||
if (req.method === 'DELETE') { res.writeHead(200); return res.end(); }
|
||||
const key = anahtarAyikla(req.headers['authorization'], req.headers['x-api-key'], null);
|
||||
const user = key ? store.verifyApiKey(key) : null;
|
||||
if (!user) return json(res, 401, hata(null, -32001, 'Kimlik doğrulanamadı: Authorization: Bearer ih_live_… gerekli (API & Webhooks)'));
|
||||
let govde;
|
||||
try { govde = await readBody(req, 512 * 1024); } catch { return json(res, 400, hata(null, -32700, 'JSON ayrıştırılamadı')); }
|
||||
const ctx = { tenderGetir };
|
||||
if (Array.isArray(govde)) {
|
||||
const out = (await Promise.all(govde.map((r) => rpcIsle(r, user, ctx)))).filter(Boolean);
|
||||
if (!out.length) { res.writeHead(202); return res.end(); }
|
||||
return json(res, 200, out);
|
||||
}
|
||||
const r = await rpcIsle(govde, user, ctx);
|
||||
if (!r) { res.writeHead(202); return res.end(); }
|
||||
return json(res, 200, r);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
|
||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||
import { searchKik, kararMetni } from './kik.mjs';
|
||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
||||
import { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs';
|
||||
import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs';
|
||||
import { belgeyeSor, teklifDenetle, teklifTaslak } from './ai-teklif.mjs';
|
||||
import { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs';
|
||||
import { bildirimleriIsle } from './notifier.mjs';
|
||||
import { scanSite } from './site-scan.mjs';
|
||||
@@ -1321,6 +1324,67 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RSS beslemeleri + MCP sunucusu + haftalık brief + teklif asistanı (İhaleAB'den taşındı) ----
|
||||
if (url.pathname === '/api/feeds/tenders.rss') {
|
||||
const key = url.searchParams.get('key') || '';
|
||||
const sahip = key.startsWith('ih_live_') ? store.verifyApiKey(key) : null;
|
||||
if (!sahip) { res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('API anahtarı gerekli: ?key=ih_live_… (API & Webhooks)'); }
|
||||
const out = await tendersPayload();
|
||||
const xml = ihaleRss(out.tenders, { q: url.searchParams.get('q'), il: url.searchParams.get('il'), sektor: url.searchParams.get('sektor'), sadeceAcik: url.searchParams.get('acik') !== '0', limit: url.searchParams.get('limit') || 50 });
|
||||
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=600' });
|
||||
return res.end(xml);
|
||||
}
|
||||
if (url.pathname === '/api/feeds/sonuc.rss') {
|
||||
const xml = sonucRss({ firma: url.searchParams.get('firma'), idare: url.searchParams.get('idare'), il: url.searchParams.get('il') });
|
||||
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' });
|
||||
return res.end(xml);
|
||||
}
|
||||
if (url.pathname === '/mcp') {
|
||||
return mcpIstek(req, res, { readBody, json, tenderGetir: async () => (await tendersPayload()).tenders });
|
||||
}
|
||||
if (url.pathname === '/api/me/yaklasan') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
const out = await tendersPayload();
|
||||
return json(res, 200, { success: true, tenders: yaklasanIhaleler(user.email, out.tenders, Number(url.searchParams.get('gun')) || 14) });
|
||||
}
|
||||
if (url.pathname === '/api/me/brief') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req).catch(() => ({}));
|
||||
store.setData(user.email, 'brief', { kapali: body.kapali === true });
|
||||
return json(res, 200, { success: true, kapali: body.kapali === true });
|
||||
}
|
||||
const out = await tendersPayload();
|
||||
const b = briefOlustur(user.email, out.tenders);
|
||||
return json(res, 200, { success: true, kapali: !!(store.getData(user.email, 'brief')?.kapali), brief: b, metin: briefMetni(b) });
|
||||
}
|
||||
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
|
||||
const r = await haftalikBriefGonder(async () => (await tendersPayload()).tenders, { zorla: url.searchParams.get('zorla') === '1' });
|
||||
return json(res, 200, { success: true, ...r });
|
||||
}
|
||||
if ((url.pathname === '/api/ai/belge-sor' || url.pathname === '/api/ai/teklif-denetim' || url.pathname === '/api/ai/teklif-taslak') && req.method === 'POST') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
if (!aiConfigured()) return json(res, 503, { success: false, error: 'AI yapılandırılmamış — sunucuya bir sağlayıcı anahtarı ekleyin.' });
|
||||
try {
|
||||
const body = await readBody(req, 2 * 1024 * 1024);
|
||||
const ortak = { ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text };
|
||||
const out = url.pathname === '/api/ai/belge-sor'
|
||||
? await belgeyeSor({ ...ortak, soru: body.soru })
|
||||
: url.pathname === '/api/ai/teklif-denetim'
|
||||
? await teklifDenetle({ ...ortak, taslak: body.taslak, gereklilikler: body.gereklilikler })
|
||||
: await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler });
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, e.attempts ? 502 : 400, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tenders') {
|
||||
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
|
||||
return json(res, 200, out);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { historyStats } from './history-store.mjs';
|
||||
import { bildirimleriIsle } from './notifier.mjs';
|
||||
import { zeyilnameTara, sonucTara } from './zeyilname.mjs';
|
||||
import { briefZamanlayici } from './brief.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -190,6 +191,9 @@ export function startScheduler({ tenderGetir } = {}) {
|
||||
setTimeout(takipTuru, 8 * 60 * 1000).unref?.();
|
||||
setInterval(takipTuru, Math.max(1, tSaat) * 3600 * 1000).unref?.();
|
||||
}
|
||||
// Haftalık brief (Pazartesi 08:00): takipli ihalelerin tarihleri + sonuçlar
|
||||
if (tenderGetir) briefZamanlayici(tenderGetir);
|
||||
|
||||
// Otomatik e-posta bildirimi
|
||||
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
|
||||
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
|
||||
|
||||
Reference in New Issue
Block a user