Files
ihaleist/server/feeds-mcp.mjs
T
hikmettanandClaude Fable 5.1 48f0c48732 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>
2026-09-04 02:23:41 +03:00

197 lines
11 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 { 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 ?? ''));
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);
}