Files
ihaleist/server/feeds-mcp.mjs
T

203 lines
12 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.
// 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 { acikIhale } from '../src/services/ihaleTarih.js';
import { deadlineInfo, tenderDateKey } from '../src/services/tenderCalendar.js';
import { applyTenderStates, tenderStateKey } from '../src/services/tenderState.js';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 ?? '').trim());
const key = m && tenderDateKey({ tarih: `${m[1].padStart(2, '0')}.${m[2].padStart(2, '0')}.${m[3]}` });
return key ? new Date(key + 'T00:00:00+03:00') : null;
}
/** Liste filtresi — RSS ve MCP ortak. */
export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}, now = new Date()) {
const qq = norm(q); const ill = norm(il); const sek = norm(sektor);
return tenders
.filter((t) => !sadeceAcik || acikIhale(t, now))
.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, now = new Date()) {
const states = store.getTenderStates(email);
const takip = new Set(store.getTrackedTenderKeys(email, states));
const days = Math.min(90, Math.max(1, Math.floor(Number(gun) || 14)));
return applyTenderStates(tenders, states)
.filter(t => takip.has(tenderStateKey(t)) && acikIhale(t, now))
.map(t => ({ t, info: deadlineInfo(t, now) }))
.filter(({ info }) => info.days !== null && info.days >= 0 && info.days <= days && info.expired !== true)
.sort((a, b) => a.info.days - b.info.days ||
(a.info.deadline?.getTime() ?? Infinity) - (b.info.deadline?.getTime() ?? Infinity))
.map(({ t, info }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir,
tarih: t.tarih, saat: t.saat, kalan_gun: info.days,
son_teklif_zamani: info.deadline?.toISOString() ?? null,
saat_dogrulanmali: !info.deadline, 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);
}