- /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>
197 lines
11 KiB
JavaScript
197 lines
11 KiB
JavaScript
// 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);
|
||
}
|