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
+206
View File
@@ -0,0 +1,206 @@
/**
* Gerçek AI şartname analizi — çoklu sağlayıcı + fallback zinciri.
* Bağımlılık yok (Node 18+ built-in fetch).
*
* Env (hangisi varsa o sağlayıcı kullanılabilir olur):
* ZAI_API_KEY → GLM (Z.ai)
* DEEPSEEK_API_KEY → DeepSeek
* GEMINI_API_KEY → Gemini (Google)
* XAI_API_KEY → Grok (xAI)
* OPENAI_API_KEY → GPT (OpenAI)
* ANTHROPIC_API_KEY → Claude (Anthropic)
*
* Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL=glm-5.3).
*/
export const PROVIDERS = {
glm: {
label: 'GLM', provider: 'Z.ai', envKey: 'ZAI_API_KEY',
model: () => process.env.GLM_MODEL || 'glm-5.3',
url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai',
},
deepseek: {
label: 'DeepSeek', provider: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY',
model: () => process.env.DEEPSEEK_MODEL || 'deepseek-chat',
url: 'https://api.deepseek.com/chat/completions', style: 'openai',
},
gemini: {
label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY',
model: () => process.env.GEMINI_MODEL || 'gemini-flash-latest',
style: 'gemini',
},
grok: {
label: 'Grok', provider: 'xAI', envKey: 'XAI_API_KEY',
model: () => process.env.XAI_MODEL || 'grok-4',
url: 'https://api.x.ai/v1/chat/completions', style: 'openai',
},
gpt: {
label: 'GPT', provider: 'OpenAI', envKey: 'OPENAI_API_KEY',
model: () => process.env.OPENAI_MODEL || 'gpt-5.2',
url: 'https://api.openai.com/v1/chat/completions', style: 'openai',
},
claude: {
label: 'Claude', provider: 'Anthropic', envKey: 'ANTHROPIC_API_KEY',
model: () => process.env.ANTHROPIC_MODEL || 'claude-sonnet-5',
url: 'https://api.anthropic.com/v1/messages', style: 'anthropic',
},
};
export function availableProviders() {
return Object.entries(PROVIDERS)
.filter(([, p]) => Boolean(process.env[p.envKey]))
.map(([id, p]) => ({ id, label: p.label, provider: p.provider, model: p.model() }));
}
export function aiConfigured() {
return availableProviders().length > 0;
}
const SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734/4735 sayılı kanunlar) konusunda uzman bir ihale danışmanısın.
Sana verilen teknik şartname / ihale ilanı metnini analiz et.
YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
{
"summary": "şartnamenin 2-3 cümlelik özeti",
"qualificationCriteria": ["istenen yeterlik belgeleri, madde madde"],
"criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar"],
"risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}],
"bidAnalysis": {
"estimatedCost": sayı_veya_null,
"recommendedBid": sayı_veya_null,
"minBidLimit": sayı_veya_null,
"maxBidLimit": sayı_veya_null,
"winProbability": 0-100_arası_sayı_veya_null,
"competitionLevel": "rekabet seviyesi yorumu",
"notes": "teklif stratejisi notu"
}
}
Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`;
function buildUserPrompt({ text, fileName, tender }) {
const parts = [];
if (fileName) parts.push(`Dosya: ${fileName}`);
if (tender) {
parts.push(`İhale başlığı: ${tender.baslik || '-'}`);
parts.push(`İdare: ${tender.kurum || '-'}`);
parts.push(`Şehir: ${tender.sehir || '-'}`);
parts.push(`Usul: ${tender.usul || tender.alimTuru || '-'}`);
if (tender.yaklasikMaliyet) parts.push(`Yaklaşık maliyet: ${tender.yaklasikMaliyet}`);
if (Array.isArray(tender.items) && tender.items.length) {
parts.push('Kalemler: ' + tender.items.map((i) => `${i.ad} (${i.miktar} ${i.birim})`).join('; '));
}
}
parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, 60000));
return parts.join('\n');
}
async function callOpenAiStyle(p, key, userPrompt, signal) {
const res = await fetch(p.url, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: p.model(),
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
temperature: 0.2,
max_tokens: 4000,
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.choices?.[0]?.message?.content || '';
}
async function callGemini(p, key, userPrompt, signal) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${p.model()}:generateContent?key=${encodeURIComponent(key)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' },
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || '';
}
async function callAnthropic(p, key, userPrompt, signal) {
const res = await fetch(p.url, {
method: 'POST',
headers: {
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: p.model(),
max_tokens: 4000,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.content?.map((c) => c.text || '').join('') || '';
}
function extractJson(raw) {
if (!raw) throw new Error('Model boş yanıt döndürdü');
let s = String(raw).trim();
// ```json ... ``` sarmalını temizle
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) s = fence[1].trim();
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start === -1 || end === -1) throw new Error('Yanıtta JSON bulunamadı');
return JSON.parse(s.slice(start, end + 1));
}
/**
* Zinciri sırayla dener; ilk başarılı sonucu döndürür.
* @returns {{result:object, model:object, attempts:Array}}
*/
export async function analyzeSpec({ text, fileName, tender, chain }) {
const order = (Array.isArray(chain) && chain.length ? chain : ['glm', 'deepseek', 'gemini', 'grok', 'gpt', 'claude'])
.filter((id) => PROVIDERS[id]);
const userPrompt = buildUserPrompt({ text, fileName, tender });
const attempts = [];
for (const id of order) {
const p = PROVIDERS[id];
const key = process.env[p.envKey];
if (!key) {
attempts.push({ id, ok: false, error: `${p.envKey} tanımlı değil` });
continue;
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 90000);
try {
let raw;
if (p.style === 'gemini') raw = await callGemini(p, key, userPrompt, ac.signal);
else if (p.style === 'anthropic') raw = await callAnthropic(p, key, userPrompt, ac.signal);
else raw = await callOpenAiStyle(p, key, userPrompt, ac.signal);
const result = extractJson(raw);
attempts.push({ id, ok: true });
return {
result,
model: { id, label: p.label, provider: p.provider, model: p.model() },
attempts,
};
} catch (e) {
attempts.push({ id, ok: false, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message });
} finally {
clearTimeout(timer);
}
}
const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', '));
err.attempts = attempts;
throw err;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Abonelik / ödeme — Stripe Checkout (zero-dependency, form-encoded REST).
*
* Env:
* STRIPE_SECRET_KEY — sk_live_... / sk_test_...
* STRIPE_PRICE_PRO — Pro planın Stripe Price ID'si (price_...)
* STRIPE_PRICE_KURUMSAL — Kurumsal planın Price ID'si
* APP_URL — başarı/iptal dönüş adresi (örn. https://ihaleist.seymata.com)
*
* Not: Abonelik hakkının kalıcı tanınması için Stripe webhook'u + kullanıcı
* kaydı gerekir (bkz. /api/billing/webhook). Anahtar yoksa uçlar "yapılandırılmadı"
* döner; uygulama ücretsiz katmanda çalışmaya devam eder.
*/
export const PLANS = [
{
id: 'ucretsiz',
ad: 'Ücretsiz',
fiyat: 0,
periyot: 'ay',
ozet: 'İhaleleri keşfedin, takibe alın.',
ozellikler: [
'Canlı ihale + doğrudan temin listesi',
'Gelişmiş arama ve filtreler',
'Favori kurumlar',
'10 ihale takibi',
'Tarayıcı bildirimleri',
],
sinirlar: { takip: 10, ekip: 1 },
},
{
id: 'pro',
ad: 'Pro',
fiyat: 899,
periyot: 'ay',
vurgu: true,
rozet: 'En popüler',
ozet: 'Teklif hazırlayan firmalar için tam analiz.',
ozellikler: [
'Ücretsiz plandaki her şey',
'Sınırsız ihale takibi',
'Kanban süreç yönetimi',
'Kazanan firma & rakip analizi',
'Kalem bazlı fiyat analizi',
'AI şartname analizi',
'PDF teklif cetveli (kaşe/imza)',
'E-posta bildirimleri',
'5 ekip üyesi',
],
sinirlar: { takip: null, ekip: 5 },
priceEnv: 'STRIPE_PRICE_PRO',
},
{
id: 'kurumsal',
ad: 'Kurumsal',
fiyat: 2490,
periyot: 'ay',
ozet: 'Çok kullanıcılı ekipler ve entegrasyon.',
ozellikler: [
'Pro plandaki her şey',
'Sınırsız ekip üyesi',
'Kırmızı bayrak & şeffaflık analizi',
'KİK kurul kararı eşleştirme',
'AB ihaleleri (TED) katmanı',
'API erişimi & webhook',
'Öncelikli destek',
],
sinirlar: { takip: null, ekip: null },
priceEnv: 'STRIPE_PRICE_KURUMSAL',
},
];
export function billingConfigured() {
return Boolean(process.env.STRIPE_SECRET_KEY);
}
export function planCatalog() {
return PLANS.map((p) => ({
...p,
satinAlinabilir: Boolean(p.priceEnv && process.env[p.priceEnv] && billingConfigured()),
}));
}
function formEncode(obj) {
const parts = [];
const walk = (prefix, val) => {
if (val === undefined || val === null) return;
if (Array.isArray(val)) val.forEach((v, i) => walk(`${prefix}[${i}]`, v));
else if (typeof val === 'object') Object.entries(val).forEach(([k, v]) => walk(`${prefix}[${k}]`, v));
else parts.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(val))}`);
};
Object.entries(obj).forEach(([k, v]) => walk(k, v));
return parts.join('&');
}
export async function createCheckoutSession({ planId, email }) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)');
const plan = PLANS.find((p) => p.id === planId);
if (!plan || !plan.priceEnv) throw new Error('Geçersiz plan');
const price = process.env[plan.priceEnv];
if (!price) throw new Error(`${plan.ad} planı için ${plan.priceEnv} tanımlı değil`);
const appUrl = process.env.APP_URL || 'https://ihaleist.seymata.com';
const body = formEncode({
mode: 'subscription',
'line_items[0][price]': price,
'line_items[0][quantity]': 1,
success_url: `${appUrl}/?odeme=basarili&plan=${planId}`,
cancel_url: `${appUrl}/?odeme=iptal`,
customer_email: email,
allow_promotion_codes: 'true',
'metadata[planId]': planId,
'metadata[email]': email || '',
});
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `Stripe hatası: HTTP ${res.status}`);
return { url: data.url, id: data.id };
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* EKAP geçmiş sonuç tarayıcısı — kazanan firma + sözleşme bedeli + teklif sayısı biriktirir.
*
* Kullanım:
* node server/ekap-scan.mjs --gun 7 # son 7 günün sonuçlananları
* node server/ekap-scan.mjs --bas 2026-01-01 --son 2026-06-30
* node server/ekap-scan.mjs --gun 1 --limit 200 # cron için (günlük artımlı)
*
* Notlar:
* - EKAP arama indeksi sonuç ilanlarını kapsamaz; bu yüzden sonuçlanmış ihaleler
* sayfalanır ve HER ihalenin ilanları ayrıca çekilir (N+1 kaçınılmaz).
* - Eşzamanlılık 6 (repo referansı 8) — EKAP'ı yormamak için.
* - Zaten kayıtlı IKN'ler atlanır, böylece tekrar çalıştırmak ucuzdur.
*/
import {
searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI,
} from './ekap-v2.mjs';
import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs';
const args = process.argv.slice(2);
const arg = (n, d = null) => {
const i = args.indexOf(`--${n}`);
return i >= 0 && args[i + 1] ? args[i + 1] : d;
};
const has = (n) => args.includes(`--${n}`);
const iso = (d) => d.toISOString().slice(0, 10);
const gun = Number(arg('gun', 0));
let bas = arg('bas');
let son = arg('son');
if (!bas || !son) {
const bugun = new Date();
const geri = new Date(bugun.getTime() - (gun || 7) * 86400000);
bas = bas || iso(geri);
son = son || iso(bugun);
}
const LIMIT = Number(arg('limit', 500));
const SAYFA = 50;
const ESZAMANLI = Number(arg('paralel', 6));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function havuz(items, worker, n) {
const sonuc = [];
let i = 0;
const calisanlar = Array.from({ length: Math.min(n, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
try { sonuc.push(await worker(items[idx])); }
catch (e) { sonuc.push({ hata: e.message }); }
}
});
await Promise.all(calisanlar);
return sonuc;
}
async function main() {
if (!available()) {
console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.');
process.exit(1);
}
const db = openDb({ create: true });
const baslangic = new Date().toISOString();
console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`);
let skip = 0;
let taranan = 0;
let eklenen = 0;
let hata = 0;
let toplamHavuz = null;
while (taranan < LIMIT) {
let sayfa;
try {
sayfa = await searchTenders({
ihaleDurumIdList: [DURUM_SONUC_ILANI],
ihaleTarihBaslangic: bas,
ihaleTarihBitis: son,
orderBy: 'ihaleTarihi',
siralamaTipi: 'desc',
skip,
take: Math.min(SAYFA, LIMIT - taranan),
});
} catch (e) {
console.error('[ekap-scan] arama hatası:', e.message);
hata++;
break;
}
if (toplamHavuz == null) {
toplamHavuz = sayfa.totalCount;
console.log(`[ekap-scan] aralıkta ${toplamHavuz} sonuçlanmış ihale bulundu`);
}
if (!sayfa.list.length) break;
// Zaten kayıtlı olanları atla (IKN kalıcı anahtar)
const yeni = sayfa.list.filter((t) => !hasIkn(db, t.ikn));
const atlanan = sayfa.list.length - yeni.length;
const sonuclar = await havuz(yeni, async (t) => {
const rs = await fetchResults(t.id);
if (!rs.length) return { bos: true };
for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma);
const n = saveResults(db, t, rs);
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma };
}, ESZAMANLI);
for (const s of sonuclar) {
if (s?.hata) hata++;
else if (s?.kayit) eklenen += s.kayit;
}
taranan += sayfa.list.length;
skip += sayfa.list.length;
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${atlanan} atlandı · ${hata} hata`);
if (skip >= toplamHavuz) break;
await sleep(300); // EKAP'ı yormamak için nazik ara
}
logScan(db, {
baslangic, bitis: new Date().toISOString(),
aralikBas: bas, aralikSon: son, taranan, eklenen, hata,
not: `havuz=${toplamHavuz}`,
});
db.close();
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı yazıldı, ${hata} hata.`);
}
main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); });
+235
View File
@@ -0,0 +1,235 @@
/**
* EKAP v2 API istemcisi — ihale arama + sonuç ilanı (kazanan firma, sözleşme bedeli).
* Zero-dependency: node:crypto + built-in fetch.
*
* Güvenlik başlıkları AES-256-CBC ile üretilir. Anahtar EKAP tarafından
* dönemsel olarak değiştirilir; değişirse EKAP_R8FACT_KEY env'i ile geçilebilir.
* (Yeni anahtar ekapv2.kik.gov.tr/ekap/search konsolunda environment.r8fact içinde.)
*
* Sunucu hata kodları: 1200 = başlık eksik, 1202 = timestamp penceresi dışı (±30sn),
* 1204 = guid uyuşmuyor.
*/
import crypto from 'node:crypto';
const BASE = 'https://ekapv2.kik.gov.tr';
const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
function aesKey() {
return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8');
}
function aesB64(plain, iv) {
const c = crypto.createCipheriv('aes-256-cbc', aesKey(), iv);
return Buffer.concat([c.update(plain, 'utf8'), c.final()]).toString('base64');
}
// Her istekte yeniden üretilmeli (timestamp penceresi ±30 sn)
export function securityHeaders() {
const guid = crypto.randomUUID();
const iv = crypto.randomBytes(16); // Sec-1 ve Sec-4 AYNI IV'yi kullanmalı
const ts = String(Date.now()); // milisaniye zorunlu
return {
'X-Ekap-Sec-1': aesB64(guid, iv),
'X-Ekap-Sec-2': iv.toString('base64'),
'X-Ekap-Sec-3': guid,
'X-Ekap-Sec-4': aesB64(ts, iv),
};
}
const BASE_HEADERS = {
Accept: 'application/json',
'Accept-Language': 'tr',
'Content-Type': 'application/json',
Origin: BASE,
Referer: `${BASE}/ekap/search`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
'api-version': 'v1',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(BASE + path, {
method: 'POST',
headers: { ...BASE_HEADERS, ...securityHeaders() },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
const raw = await res.text();
if (!res.ok) {
// 401/1202 = timestamp kayması → yeni başlıkla tekrar dene
if ((res.status === 401 || res.status >= 500) && i < retries) {
await sleep(500 * (i + 1));
continue;
}
throw new Error(`EKAP ${res.status}: ${raw.slice(0, 200)}`);
}
return JSON.parse(raw);
} catch (e) {
lastErr = e;
if (i >= retries) break;
await sleep(500 * (i + 1));
}
}
throw lastErr;
}
// --- İhale arama ---------------------------------------------------------
// EKAP ihale durumu: 15 = Sonuç İlanı Yayımlanmış
export const DURUM_SONUC_ILANI = 15;
// İlan tipi: 1=Ön İlan 2=İhale İlanı 3=İptal 4=Sonuç İlanı 5=Ön Yeterlik 6=Düzeltme
export const ILAN_TIP_SONUC = '4';
function searchBody(opts = {}) {
return {
searchText: opts.searchText || '',
filterType: null,
ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true,
teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true,
isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true,
ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true,
searchType: 'GirdigimGibi',
iknYili: opts.iknYili ?? null,
iknSayi: opts.iknSayi ?? null,
ihaleTarihSaatBaslangic: opts.ihaleTarihBaslangic ?? null, // "YYYY-MM-DD"
ihaleTarihSaatBitis: opts.ihaleTarihBitis ?? null,
ilanTarihSaatBaslangic: opts.ilanTarihBaslangic ?? null,
ilanTarihSaatBitis: opts.ilanTarihBitis ?? null,
yasaKapsami4734List: [],
ihaleTuruIdList: opts.ihaleTuruIdList || [],
ihaleUsulIdList: [], ihaleUsulAltIdList: [],
ihaleIlIdList: opts.ihaleIlIdList || [], // EKAP il id (245-325) — PLAKA DEĞİL
ihaleDurumIdList: opts.ihaleDurumIdList || [],
idareIdList: opts.idareIdList || [],
ihaleIlanTuruIdList: opts.ihaleIlanTuruIdList || [],
teklifTuruIdList: [], asiriDusukTeklifIdList: [], istisnaMaddeIdList: [],
okasBransKodList: opts.okasBransKodList || [],
okasBransAdiList: [], titubbKodList: [], gmdnKodList: [],
eIhale: null, eEksiltmeYapilacakMi: null, ortakAlimMi: null, kismiTeklifMi: null,
fiyatDisiUnsurVarmi: null,
ekonomikVeMaliYeterlilikBelgeleriIsteniyorMu: null,
meslekiTeknikYeterlilikBelgeleriIsteniyorMu: null,
isDeneyimiGosterenBelgelerIsteniyorMu: null,
yerliIstekliyeFiyatAvantajiUgulaniyorMu: null,
yabanciIsteklilereIzinVeriliyorMu: null,
alternatifTeklifVerilebilirMi: null, konsorsiyumKatilabilirMi: null,
altYukleniciCalistirilabilirMi: null, fiyatFarkiVerilecekMi: null,
avansVerilecekMi: null, cerceveAnlasmaMi: null,
personelCalistirilmasinaDayaliMi: null,
orderBy: opts.orderBy || 'ihaleTarihi',
siralamaTipi: opts.siralamaTipi || 'desc',
paginationSkip: opts.skip ?? 0,
paginationTake: opts.take ?? 20,
};
}
export async function searchTenders(opts = {}) {
const data = await ekapPost('/b_ihalearama/api/Ihale/GetListByParameters', searchBody(opts));
return { list: data.list || [], totalCount: data.totalCount ?? 0 };
}
// NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan.
export async function getIlanlar(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId });
return data.list || [];
}
export async function getIhaleDetay(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/IhaleDetay/GetByIhaleIdIhaleDetay', { ihaleId });
return data.item || data;
}
// --- Sonuç ilanı parse ---------------------------------------------------
const ENTITIES = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" };
// EKAP'ın veriHtml tablosunu, Python tarafındaki markdown ile aynı "| hücre |"
// biçimine indirger; böylece aynı regex'ler çalışır.
export function htmlToPipeText(html) {
if (!html) return '';
return String(html)
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ')
.replace(/<\s*\/\s*tr\s*>/gi, ' |\n')
.replace(/<\s*br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ')
.replace(/[ \t]+/g, ' ');
}
function cleanCell(v) {
if (v == null) return null;
const t = String(v).replace(/-->/g, ' ').replace(/\*\*/g, ' ').replace(/\s+/g, ' ').trim();
return t || null;
}
function findField(text, labelPattern) {
if (!text) return null;
const re = new RegExp(`${labelPattern}\\s*\\**\\s*\\|\\s*\\**:?\\**\\s*\\|([^|\\n]*)`, 'i');
const m = re.exec(text);
return m ? cleanCell(m[1]) : null;
}
function parseAmount(v) {
if (!v) return null;
const m = /([\d.]+,\d+|\d+)/.exec(v);
if (!m) return null;
const n = parseFloat(m[1].replace(/\./g, '').replace(',', '.'));
return Number.isNaN(n) ? null : n;
}
function parseIntSafe(v) {
if (!v) return null;
const m = /\d+/.exec(v);
return m ? parseInt(m[0], 10) : null;
}
/** Sonuç ilanı HTML'inden kazanan firma / bedel / teklif sayısı çıkarır. */
export function parseResultAnnouncement(veriHtml) {
const text = htmlToPipeText(veriHtml);
const amountRaw = findField(text, 'Bedeli');
const cur = amountRaw ? (/\b([A-Z]{3})\b/.exec(amountRaw)?.[1] ?? null) : null;
return {
ikn: findField(text, 'İhale kayıt numarası'),
kazananFirma: findField(text, 'Yüklenici(?:si)?'),
kazananUyruk: findField(text, 'Yüklenicinin uyruğu'),
kazananAdres: findField(text, 'Yüklenicinin adresi'),
sozlesmeBedeli: parseAmount(amountRaw),
paraBirimi: cur,
sozlesmeTarihi: findField(text, '4- Sözleşmenin[\\s\\S]{0,200}?Tarihi'),
sozlesmeSuresi: findField(text, '4- Sözleşmenin[\\s\\S]{0,400}?Süresi'),
yaklasikMaliyet: parseAmount(findField(text, '(?<!Kısımlarının )Yaklaşık Maliyeti')),
teklifSayisi: parseIntSafe(findField(text, 'Toplam Teklif Sayısı')),
gecerliTeklifSayisi: parseIntSafe(findField(text, 'Toplam Geçerli Teklif Sayısı')),
};
}
// --- Firma adı normalize (kazanan eşleştirme için) -----------------------
const TR_FOLD = { 'Ç': 'C', 'Ğ': 'G', 'İ': 'I', 'I': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U', 'ç': 'C', 'ğ': 'G', 'ı': 'I', 'i': 'I', 'ö': 'O', 'ş': 'S', 'ü': 'U' };
const SUFFIX = new Set(['LIMITED', 'LTD', 'SIRKETI', 'SIRKET', 'STI', 'ANONIM', 'AS', 'A', 'S', 'SANAYI', 'SAN', 'TICARET', 'TIC', 'VE', 'KOLLEKTIF', 'KOMANDIT']);
export function normalizeCompanyName(n) {
if (!n) return '';
return String(n)
.replace(/[ÇĞİIÖŞÜçğıiöşü]/g, (c) => TR_FOLD[c] || c)
.toUpperCase()
.replace(/[^0-9A-Z]+/g, ' ')
.split(/\s+/)
.filter((t) => t && !SUFFIX.has(t))
.join(' ');
}
/** Bir ihalenin sonuç ilan(lar)ını çeker ve parse eder (kısmi teklifte birden çok olur). */
export async function fetchResults(ihaleId) {
const ilanlar = await getIlanlar(ihaleId);
return ilanlar
.filter((i) => String(i.ilanTip) === ILAN_TIP_SONUC)
.map((i, idx) => ({ ...parseResultAnnouncement(i.veriHtml), ilanTarihi: i.ilanTarihi, kisimIndex: idx }))
.filter((r) => r.kazananFirma || r.sozlesmeBedeli != null);
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Geçmiş ihale sonuçları deposu (SQLite).
* ekap-scan.mjs doldurur; server/index.js okur.
*
* Kalıcı anahtar: IKN (EKAP'ın döndürdüğü `id` per-response token'dır, saklanmaz).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
let DatabaseSync = null;
try {
({ DatabaseSync } = await import('node:sqlite'));
} catch {
console.warn('[history-store] node:sqlite yok — geçmiş veri deposu devre dışı');
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, 'data');
const DB_PATH = path.join(DATA_DIR, 'history.sqlite');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sonuclar (
ikn TEXT NOT NULL,
kisim INTEGER NOT NULL DEFAULT 0,
ihale_adi TEXT,
idare_adi TEXT,
il TEXT,
ihale_tipi TEXT,
ihale_tarihi TEXT,
ilan_tarihi TEXT,
kazanan_firma TEXT,
kazanan_norm TEXT,
kazanan_adres TEXT,
sozlesme_bedeli REAL,
para_birimi TEXT,
sozlesme_tarihi TEXT,
yaklasik_maliyet REAL,
teklif_sayisi INTEGER,
gecerli_teklif_sayisi INTEGER,
cekilme_zamani TEXT,
PRIMARY KEY (ikn, kisim)
);
CREATE INDEX IF NOT EXISTS idx_kazanan ON sonuclar(kazanan_norm);
CREATE INDEX IF NOT EXISTS idx_idare ON sonuclar(idare_adi);
CREATE INDEX IF NOT EXISTS idx_il ON sonuclar(il);
CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi);
CREATE TABLE IF NOT EXISTS tarama_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
baslangic TEXT, bitis TEXT,
aralik_bas TEXT, aralik_son TEXT,
taranan INTEGER, eklenen INTEGER, hata INTEGER, not_ TEXT
);
`;
export function available() {
return Boolean(DatabaseSync);
}
export function openDb({ create = false } = {}) {
if (!DatabaseSync) return null;
if (!fs.existsSync(DB_PATH)) {
if (!create) return null;
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const db = new DatabaseSync(DB_PATH);
db.exec(SCHEMA);
return db;
}
export function saveResults(db, tender, results) {
const stmt = db.prepare(`
INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi,
kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi,
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,cekilme_zamani)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ikn,kisim) DO UPDATE SET
kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm,
sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet,
teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi,
cekilme_zamani=excluded.cekilme_zamani
`);
const now = new Date().toISOString();
let n = 0;
for (const r of results) {
stmt.run(
r.ikn || tender.ikn, r.kisimIndex ?? 0,
tender.ihaleAdi || null, tender.idareAdi || null, tender.ihaleIlAdi || null,
tender.ihaleTipAciklama || null, tender.ihaleTarihSaat || null, r.ilanTarihi || null,
r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null,
r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null,
r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null,
now
);
n++;
}
return n;
}
export function hasIkn(db, ikn) {
const row = db.prepare('SELECT 1 FROM sonuclar WHERE ikn=? LIMIT 1').get(ikn);
return Boolean(row);
}
// --- Okuma tarafı (API için) --------------------------------------------
function rowToTender(r) {
return {
id: `hist-${r.ikn}-${r.kisim}`,
kaynak: 'EKAP Sonuç İlanı',
ikn: r.ikn,
baslik: r.ihale_adi || 'İhale',
kurum: r.idare_adi || '—',
sehir: r.il || '',
tarih: r.ihale_tarihi || '',
alimTuru: r.ihale_tipi || 'İhale',
sektor: r.ihale_tipi || 'Genel',
kazananFirma: r.kazanan_firma,
sozlesmeBedeli: r.sozlesme_bedeli,
paraBirimi: r.para_birimi,
sozlesmeTarihi: r.sozlesme_tarihi,
yaklasikMaliyet: r.yaklasik_maliyet,
teklifSayisi: r.teklif_sayisi,
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
durum: 'Sonuçlandı',
items: [],
};
}
export function readHistory(limit = 500, { firma, idare, il } = {}) {
const db = openDb();
if (!db) return { tenders: [], totalCount: 0, available: false };
try {
const where = [];
const args = [];
if (firma) { where.push('kazanan_norm LIKE ?'); args.push(`%${firma}%`); }
if (idare) { where.push('idare_adi LIKE ?'); args.push(`%${idare}%`); }
if (il) { where.push('il LIKE ?'); args.push(`%${il}%`); }
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
const total = db.prepare(`SELECT COUNT(*) c FROM sonuclar ${w}`).all(...args)[0]?.c ?? 0;
const rows = db.prepare(`SELECT * FROM sonuclar ${w} ORDER BY cekilme_zamani DESC LIMIT ?`).all(...args, limit);
return { tenders: rows.map(rowToTender), totalCount: total, available: true };
} catch (e) {
console.warn('[history-store] okuma hatası:', e.message);
return { tenders: [], totalCount: 0, available: false };
} finally {
db.close();
}
}
export function historyStats() {
const db = openDb();
if (!db) return { available: false };
try {
const c = (q) => db.prepare(q).all()[0] || {};
const toplam = c('SELECT COUNT(*) v FROM sonuclar').v ?? 0;
const firma = c('SELECT COUNT(DISTINCT kazanan_norm) v FROM sonuclar WHERE kazanan_norm IS NOT NULL').v ?? 0;
const idare = c('SELECT COUNT(DISTINCT idare_adi) v FROM sonuclar').v ?? 0;
const hacim = c('SELECT SUM(sozlesme_bedeli) v FROM sonuclar').v ?? 0;
const tekTeklif = c('SELECT COUNT(*) v FROM sonuclar WHERE gecerli_teklif_sayisi=1').v ?? 0;
const sonTarama = db.prepare('SELECT * FROM tarama_log ORDER BY id DESC LIMIT 1').all()[0] || null;
return { available: true, toplam, firma, idare, hacim, tekTeklif, sonTarama };
} catch {
return { available: false };
} finally {
db.close();
}
}
export function logScan(db, row) {
db.prepare(`INSERT INTO tarama_log (baslangic,bitis,aralik_bas,aralik_son,taranan,eklenen,hata,not_)
VALUES (?,?,?,?,?,?,?,?)`)
.run(row.baslangic, row.bitis, row.aralikBas, row.aralikSon, row.taranan, row.eklenen, row.hata, row.not || null);
}
+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();
});
+206
View File
@@ -0,0 +1,206 @@
/**
* KİK (Kamu İhale Kurumu) kurul kararları — EKAP v2 JSON API.
* Zero-dependency: node:crypto + built-in fetch.
*
* Endpoint'ler (anonim, login yok):
* POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlari (uyuşmazlık)
* POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk (düzenleyici)
* POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk (mahkeme)
*
* Güvenlik: X-Ekap-Sec-1..6 (AES-256-CBC). Sec-5 = HTTP metodu, Sec-6 = URL path.
* Başlıklar HER istekte yeniden üretilmeli (GUID nonce + timestamp penceresi).
*
* Karar tam metni: gundemMaddesiId AYRI bir AES anahtarıyla şifrelenip
* KararGoster.aspx'e query param olarak verilir (HTML döner).
*
* ⚠ Her iki anahtar da KİK frontend'inde sabittir; değişirse 401/HataKodu 1200
* alınır. Env ile geçilebilir: EKAP_R8FACT_KEY, EKAP_RAW_KEY (hex).
*/
import crypto from 'node:crypto';
const ORIGIN = 'https://ekapv2.kik.gov.tr';
const SIGN_KEY_DEFAULT = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g';
const ID_KEY_DEFAULT = Buffer.from([
236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174,
174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176,
]);
const signKey = () => Buffer.from(process.env.EKAP_R8FACT_KEY || SIGN_KEY_DEFAULT, 'utf8');
const idKey = () => (process.env.EKAP_RAW_KEY ? Buffer.from(process.env.EKAP_RAW_KEY, 'hex') : ID_KEY_DEFAULT);
function secHeaders(method, pathname) {
const iv = crypto.randomBytes(16);
const key = signKey();
const e = (t) => {
const c = crypto.createCipheriv('aes-256-cbc', key, iv);
return Buffer.concat([c.update(String(t), 'utf8'), c.final()]).toString('base64');
};
const g = crypto.randomUUID();
return {
'X-Ekap-Sec-1': e(g),
'X-Ekap-Sec-2': iv.toString('base64'),
'X-Ekap-Sec-3': g,
'X-Ekap-Sec-4': e(Date.now()),
'X-Ekap-Sec-5': e(method.toUpperCase()),
'X-Ekap-Sec-6': e(pathname),
};
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function kikPost(pathname, body, { retries = 2 } = {}) {
let last;
for (let i = 0; i <= retries; i++) {
try {
const r = await fetch(ORIGIN + pathname, {
method: 'POST',
headers: {
'api-version': 'v1',
'Content-Type': 'application/json',
Accept: 'application/json',
'Accept-Language': 'tr',
Origin: ORIGIN,
Referer: `${ORIGIN}/sorgulamalar/kurul-kararlari`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
...secHeaders('POST', pathname),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(40000),
});
const raw = await r.text();
if (!r.ok) {
if (i < retries && (r.status === 401 || r.status >= 500)) { await sleep(600 * (i + 1)); continue; }
throw new Error(`KİK sunucusu isteği reddetti (HTTP ${r.status}).`);
}
return JSON.parse(raw);
} catch (e) {
last = e;
if (i >= retries) break;
await sleep(600 * (i + 1));
}
}
throw last;
}
// Yanıt anahtarları uyuşmazlıkta camelCase, DK/MK'de PascalCase gelebilir
const pick = (o, ...names) => {
for (const n of names) if (o && o[n] != null) return o[n];
return undefined;
};
const TURLER = {
uyusmazlik: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari', etiket: 'Uyuşmazlık' },
duzenleyici: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk', etiket: 'Düzenleyici' },
mahkeme: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk', etiket: 'Mahkeme' },
};
/** Kararın tam metnini gösteren kalıcı URL (gundemMaddesiId şifrelenir). */
export function kararUrl(gundemMaddesiId) {
const iv = crypto.randomBytes(16);
const c = crypto.createCipheriv('aes-256-cbc', idKey(), iv);
const id = iv.toString('hex') + Buffer.concat([c.update(String(gundemMaddesiId), 'utf8'), c.final()]).toString('hex');
return `https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId=${id}`;
}
/** Kararın tam metnini düz metin olarak getirir. */
export async function kararMetni(gundemMaddesiId) {
const res = await fetch(kararUrl(gundemMaddesiId), {
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'tr' },
signal: AbortSignal.timeout(40000),
});
if (!res.ok) throw new Error(`Karar metni alınamadı (HTTP ${res.status})`);
const html = await res.text();
return html
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&quot;/g, '"').replace(/&amp;/g, '&')
.replace(/&times;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&[a-z]+;/gi, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\s*\n\s*/g, '\n')
.trim();
}
const cache = new Map();
const TTL = 20 * 60 * 1000;
/**
* Kurul kararı araması.
* @param {object} f { metin, kararNo, idare, basvuran, konu, yil, tarih1, tarih2, sonKararlar }
* @param {'uyusmazlik'|'duzenleyici'|'mahkeme'} tur
*/
export async function searchKik(f = {}, tur = 'uyusmazlik', limit = 25) {
const cfg = TURLER[tur] || TURLER.uyusmazlik;
const filters = {};
if (f.metin) filters.KararMetni = f.metin;
if (f.kararNo) filters.KararNo = f.kararNo;
if (f.idare) filters.IhaleyiYapanIdare = f.idare;
if (f.basvuran) filters.Sikayetci = f.basvuran;
if (f.konu) filters.BasvuruKonusuIhale = f.konu;
if (f.yil) filters.Yil = f.yil;
if (f.tarih1) filters.KararTarihi1 = f.tarih1; // "dd.MM.yyyy HH:mm:ss"
if (f.tarih2) filters.KararTarihi2 = f.tarih2;
if (!Object.keys(filters).length) filters.SonKararlar = '';
const key = `${cfg.path}|${JSON.stringify(filters)}|${limit}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL) return hit.data;
const j = await kikPost(cfg.path, {
sorgulaKurulKararlari: {
keyValuePairs: {
keyValueOfstringanyType: Object.entries(filters).map(([k, v]) => ({ key: k, value: String(v) })),
},
},
});
const wrap = pick(j, 'SorgulaKurulKararlariResponse', 'sorgulaKurulKararlariResponse') || {};
const res = pick(wrap, 'SorgulaKurulKararlariResult', 'sorgulaKurulKararlariResult') || {};
const hataKodu = String(pick(res, 'hataKodu', 'HataKodu') ?? '0');
// 4401 = kayıt bulunamadı (hata değil)
if (hataKodu === '4401') {
const bos = { results: [], totalCount: 0, tur: cfg.etiket, kaynak: 'KİK — EKAP Kurul Kararları', fetchedAt: new Date().toISOString() };
cache.set(key, { at: Date.now(), data: bos });
return bos;
}
if (hataKodu !== '0') {
throw new Error(`KİK: ${pick(res, 'hataMesaji', 'HataMesaji') || `hata kodu ${hataKodu}`}`);
}
const liste = pick(res, 'KurulKararTutanakDetayListesi', 'kurulKararTutanakDetayListesi') || [];
const kayitlar = pick(liste[0] || {}, 'kurulKararTutanakDetayi', 'KurulKararTutanakDetayi') || [];
const results = (Array.isArray(kayitlar) ? kayitlar : [kayitlar]).slice(0, limit).map((k, i) => {
const gid = pick(k, 'gundemMaddesiId', 'GundemMaddesiId');
const kararNo = pick(k, 'kararNo', 'KararNo');
const tarih = pick(k, 'kararTarihi', 'KararTarihi');
return {
id: String(gid ?? kararNo ?? i),
gundemMaddesiId: gid ?? null,
kararNo: kararNo ?? null,
kararTarihi: tarih ? String(tarih).slice(0, 10).split('-').reverse().join('.') : null,
idare: pick(k, 'idareAdi', 'IdareAdi') ?? null,
basvuran: pick(k, 'basvuran', 'Basvuran') ?? null,
baslik: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null,
konu: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null,
ozet: pick(k, 'kararNitelik', 'KararNitelik') ?? null,
resmiGazete: pick(k, 'resmiGazeteSayisi', 'ResmiGazeteSayisi') ?? null,
tur: cfg.etiket,
link: gid != null ? kararUrl(gid) : null,
};
});
const data = {
results,
totalCount: results.length,
tur: cfg.etiket,
kaynak: 'KİK — EKAP Kurul Kararları',
fetchedAt: new Date().toISOString(),
};
cache.set(key, { at: Date.now(), data });
return data;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Sunucu içi zamanlayıcı — geçmiş sonuç ilanlarını periyodik olarak biriktirir.
* Container'da ayrı cron kurmaya gerek kalmaz; server/index.js başlatır.
*
* Env:
* SCAN_ENABLED=0 → kapat (varsayılan açık)
* SCAN_INTERVAL_SAAT=6 → tarama sıklığı (saat)
* SCAN_GUN=3 → her turda geriye kaç günü tara
* SCAN_LIMIT=300 → tur başına maksimum ihale
*/
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let calisiyor = false;
function tara() {
if (calisiyor) {
console.log('[scheduler] önceki tarama sürüyor, bu tur atlandı');
return;
}
calisiyor = true;
const gun = process.env.SCAN_GUN || '3';
const limit = process.env.SCAN_LIMIT || '300';
console.log(`[scheduler] EKAP sonuç taraması başlıyor (son ${gun} gün, limit ${limit})`);
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--gun', gun, '--limit', limit], {
stdio: ['ignore', 'pipe', 'pipe'],
});
p.stdout.on('data', (d) => process.stdout.write(String(d)));
p.stderr.on('data', (d) => process.stderr.write(String(d)));
p.on('close', (code) => {
calisiyor = false;
console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`);
});
p.on('error', (e) => {
calisiyor = false;
console.warn('[scheduler] tarama başlatılamadı:', e.message);
});
}
export function startScheduler() {
if (process.env.SCAN_ENABLED === '0') {
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
return;
}
const saat = Number(process.env.SCAN_INTERVAL_SAAT || 6);
const ms = Math.max(1, saat) * 3600 * 1000;
console.log(`[scheduler] geçmiş sonuç taraması her ${saat} saatte bir çalışacak`);
// Açılıştan 2 dk sonra ilk tur (sunucu ısınsın), sonra periyodik
setTimeout(tara, 2 * 60 * 1000).unref?.();
setInterval(tara, ms).unref?.();
}
+154
View File
@@ -0,0 +1,154 @@
/**
* TED (Tenders Electronic Daily) — AB kamu ihale ilanları, Türkiye odaklı.
*
* Endpoint: POST https://api.ted.europa.eu/v3/notices/search (anonim, API key YOK)
* Doğrulanmış davranış:
* - `fields` boş olamaz (400 verir)
* - limit max 250; page*limit max 15000; alan/sayfa max 10000 ("links" gizli alan da sayılır)
* - Rate limit agresif ve belgelenmemiş: ~1 istek/sn güvenli. 429 gövdesi JSON DEĞİL (nginx HTML)
* - Ülke kodları ISO alpha-3 (TUR); tarih formatı YYYYMMDD
* - Metin alanları çok dilli obje: { eng: [...], tur: [...] }
*
* Bağımlılık yok (Node 18+ fetch).
*/
const TED_URL = 'https://api.ted.europa.eu/v3/notices/search';
const FIELDS = [
'publication-number', 'notice-title', 'notice-type', 'publication-date',
'deadline-receipt-tender-date-lot', 'organisation-name-buyer', 'organisation-city-buyer',
'buyer-country', 'winner-name', 'winner-country',
'estimated-value-lot', 'estimated-value-cur-lot',
'result-value-notice', 'result-value-cur-notice',
'classification-cpv', 'contract-nature', 'description-lot',
];
// Türkiye evreni: TR idarelerin AB ihaleleri + AB'de kazanan TR firmaları + teklif veren TR firmaları
const Q_TURKIYE = '(buyer-country IN (TUR) OR winner-country IN (TUR) OR organisation-country-tenderer IN (TUR))';
// TR firmalarının AB'de kazandıkları (rakip radar için)
const Q_KAZANAN = 'winner-country IN (TUR)';
const cache = new Map(); // key -> {at, data}
const TTL = 30 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Çok dilli alan → tek string
function txt(v) {
if (v == null) return null;
if (typeof v === 'string') return v;
if (Array.isArray(v)) return txt(v[0]);
if (typeof v === 'object') return txt(v.eng ?? v.tur ?? Object.values(v)[0]);
return String(v);
}
const first = (v) => (Array.isArray(v) ? v[0] ?? null : v ?? null);
const uniq = (v) => (Array.isArray(v) ? [...new Set(v)] : v == null ? [] : [v]);
const day = (v) => { const s = first(v); return s ? String(s).slice(0, 10) : null; };
const NATURE_TR = { works: 'Yapım İşi', supplies: 'Mal Alımı', services: 'Hizmet Alımı' };
function mapNotice(n) {
const pn = n['publication-number'];
const bedel = n['result-value-notice'] ?? first(n['estimated-value-lot']);
const cur = n['result-value-cur-notice'] ?? first(n['estimated-value-cur-lot']);
const nature = first(n['contract-nature']);
return {
id: `ted-${pn}`,
kaynak: 'TED',
ikn: String(pn || ''),
baslik: txt(n['notice-title']) || 'Başlıksız ilan',
kurum: txt(n['organisation-name-buyer']) || '—',
sehir: first(n['organisation-city-buyer'])?.trim() || null,
ulke: first(n['buyer-country']) || null,
tarih: day(n['deadline-receipt-tender-date-lot']) || day(n['publication-date']),
yayimTarihi: day(n['publication-date']),
sonTeklifTarihi: day(n['deadline-receipt-tender-date-lot']),
yaklasikMaliyet: bedel != null && bedel !== '' ? Number(bedel) : null,
paraBirimi: cur || null,
kazananFirma: txt(n['winner-name']),
kazananUlke: first(n['winner-country']),
alimTuru: NATURE_TR[nature] || nature || 'AB İhalesi',
ihaleTuru: n['notice-type'],
sonucIlani: String(n['notice-type'] || '').startsWith('can'),
cpv: uniq(n['classification-cpv']),
aciklama: txt(n['description-lot']),
link: n.links?.html?.ENG || (pn ? `https://ted.europa.eu/en/notice/-/detail/${pn}` : null),
};
}
async function tedSearch(body, { retries = 3 } = {}) {
for (let i = 0; ; i++) {
let res;
try {
res = await fetch(TED_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(45000),
});
} catch (e) {
if (i >= retries) throw new Error(`TED bağlantı hatası: ${e.message}`);
await sleep(1000 * 2 ** i);
continue;
}
// 429 gövdesi HTML gelir; her durumda önce text al
const raw = await res.text();
if (res.status === 429 || res.status >= 500) {
if (i >= retries) throw new Error(`TED geçici olarak yanıt vermiyor (HTTP ${res.status}).`);
await sleep(1200 * 2 ** i);
continue;
}
if (!res.ok) {
let msg = raw.slice(0, 300);
try { const j = JSON.parse(raw); msg = j.message || msg; } catch { /* HTML yanıt */ }
throw new Error(`TED API: ${msg}`);
}
return JSON.parse(raw);
}
}
/**
* @param {object} opts
* @param {'turkiye'|'kazanan'|'acik'} [opts.mod]
* @param {string} [opts.query] Ham expert query (verilirse mod yok sayılır)
* @param {string} [opts.since] YYYYMMDD
*/
export async function searchTed({ mod = 'turkiye', query, since = '20240101', page = 1, limit = 25 } = {}) {
let q;
let scope = 'ALL';
if (query && query.trim()) {
q = query.trim();
} else if (mod === 'kazanan') {
q = `${Q_KAZANAN} AND publication-date >= ${since} SORT BY publication-date DESC`;
} else if (mod === 'acik') {
q = `buyer-country IN (TUR) SORT BY publication-date DESC`;
scope = 'ACTIVE';
} else {
q = `${Q_TURKIYE} AND publication-date >= ${since} SORT BY publication-date DESC`;
}
const key = `${q}|${scope}|${limit}|${page}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL) return hit.data;
const data = await tedSearch({
query: q,
fields: FIELDS,
page,
limit: Math.min(limit, 250),
scope,
onlyLatestVersions: true,
paginationMode: 'PAGE_NUMBER',
});
const out = {
tenders: (data.notices || []).map(mapNotice),
totalCount: data.totalNoticeCount ?? (data.notices || []).length,
mod,
query: q,
timedOut: Boolean(data.timedOut),
fetchedAt: new Date().toISOString(),
};
cache.set(key, { at: Date.now(), data: out });
return out;
}