feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar

Roadmap'in 6 maddesi de gerçek çalışır halde:

1) Geçmiş veri motoru (EKAP v2)
   - server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
     ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
     yaklaşık maliyet, teklif sayısı), TR firma adı normalize
   - server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
   - server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
   - server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
   - /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
     rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor

2) Gerçek AI şartname analizi
   - server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
     Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
   - pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
   - AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata

3) KİK kurul kararları
   - server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
     düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
   - KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri

4) Fiyatlandırma + ödeme
   - server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
   - Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı

5) TED (AB ihaleleri)
   - server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
   - rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü

6) Kırmızı bayrak & şeffaflık
   - analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
     aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
   - RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
This commit is contained in:
2026-08-28 10:37:50 +03:00
parent 3e129c2988
commit 168052c267
26 changed files with 3121 additions and 219 deletions
+122 -1
View File
@@ -18,6 +18,12 @@ import { fileURLToPath } from 'node:url';
import { readDtTenders } from './dt-store.mjs';
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { billingConfigured, planCatalog, createCheckoutSession } from './billing.mjs';
import { searchTed } from './ted.mjs';
import { readHistory, historyStats } from './history-store.mjs';
import { startScheduler } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -337,6 +343,111 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { success: true, user });
}
// --- KİK kurul kararları ---
if (url.pathname === '/api/kik/search') {
try {
const q = url.searchParams.get('q') || '';
const out = await searchKik(
{
metin: url.searchParams.get('metin') || (url.searchParams.get('alan') ? null : q),
idare: url.searchParams.get('idare'),
basvuran: url.searchParams.get('basvuran'),
kararNo: url.searchParams.get('kararNo'),
yil: url.searchParams.get('yil'),
},
url.searchParams.get('tur') || 'uyusmazlik',
Number(url.searchParams.get('limit')) || 25
);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/kik/karar') {
try {
const id = url.searchParams.get('id');
if (!id) return json(res, 400, { success: false, error: 'id gerekli' });
const metin = await kararMetni(id);
return json(res, 200, { success: true, id, metin });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Geçmiş sonuç ilanları (kazanan firma arşivi) ---
if (url.pathname === '/api/history') {
const out = readHistory(Number(url.searchParams.get('limit')) || 300, {
firma: url.searchParams.get('firma'),
idare: url.searchParams.get('idare'),
il: url.searchParams.get('il'),
});
return json(res, 200, { success: true, ...out });
}
if (url.pathname === '/api/history/stats') {
return json(res, 200, { success: true, ...historyStats() });
}
// --- TED (AB ihaleleri) ---
if (url.pathname === '/api/ted') {
try {
const out = await searchTed({
mod: url.searchParams.get('mod') || 'turkiye',
since: url.searchParams.get('since') || '20240101',
page: Number(url.searchParams.get('page')) || 1,
limit: Number(url.searchParams.get('limit')) || 25,
});
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Abonelik / ödeme ---
if (url.pathname === '/api/billing/plans') {
return json(res, 200, { configured: billingConfigured(), plans: planCatalog() });
}
if (url.pathname === '/api/billing/checkout' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req);
const out = await createCheckoutSession({ planId: body.planId, email: user.email });
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, billingConfigured() ? 400 : 503, { success: false, error: e.message });
}
}
// --- Gerçek AI şartname analizi ---
if (url.pathname === '/api/ai/config') {
return json(res, 200, { configured: aiConfigured(), providers: availableProviders() });
}
if (url.pathname === '/api/ai/analyze' && 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 en az bir sağlayıcı anahtarı ekleyin (ZAI_API_KEY / DEEPSEEK_API_KEY / GEMINI_API_KEY …)',
});
}
try {
const body = await readBody(req, 2 * 1024 * 1024);
if (!body.text || String(body.text).trim().length < 40) {
return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' });
}
const out = await analyzeSpec({
text: body.text,
fileName: body.fileName,
tender: body.tender,
chain: body.chain,
});
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
} catch (e) {
return json(res, 502, { success: false, error: e.message, attempts: e.attempts || null });
}
}
// --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') {
return json(res, 200, { configured: emailConfigured() });
@@ -370,8 +481,16 @@ const server = http.createServer(async (req, res) => {
} catch (e) {
console.warn('[ihaleist-api] DT okuma hatası:', e.message);
}
// Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı)
let hist = { tenders: [], totalCount: 0, available: false };
try {
hist = readHistory(600);
} catch (e) {
console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message);
}
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
return json(res, 200, {
success: true,
source: sources.join(' + '),
@@ -379,7 +498,8 @@ const server = http.createServer(async (req, res) => {
totalCount: c.totalCount + dt.totalCount,
todayCount: c.todayCount,
dtCount: dt.totalCount,
tenders: [...dt.tenders, ...c.tenders],
historyCount: hist.totalCount,
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
});
}
@@ -428,4 +548,5 @@ const server = http.createServer(async (req, res) => {
server.listen(PORT, () => {
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
startScheduler();
});