/** * ihaleist API Proxy — ilan.gov.tr canlı ihale verisi * * Kaynak: Basın İlan Kurumu resmi ilan portalı (www.ilan.gov.tr) açık JSON API'si. * Kapsam: 4734 açık ihaleler + 2886 kira/satış ihaleleri. (Doğrudan temin 22/d bu * kaynakta yayınlanmaz — Faz 2'de EKAP worker'ı ile eklenecek.) * * Bağımlılık yok: Node 18+ built-in http + fetch. * Çalıştır: node server/index.js (PORT env ile port değişir, varsayılan 3001) */ import http from 'node:http'; import https from 'node:https'; import tls from 'node:tls'; import fs from 'node:fs'; import path from 'node:path'; 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, schedulerStatus } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const EXTRA_CA = fs.readFileSync(path.join(__dirname, 'geotrust-tls-rsa-ca-g1.pem'), 'utf8'); const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA]; const PORT = process.env.PORT || 3001; const ILAN_BASE = 'https://www.ilan.gov.tr'; const CACHE_TTL_MS = 10 * 60 * 1000; // 10 dk const LIST_SIZE = 20; // ilan.gov.tr sayfa başına 20 kayıtla sınırlıyor // ilan.gov.tr sayfa başına 20 kayıt veriyor; havuzun tamamı için çok sayfa gerekir. // Sayfalar paralel çekilir, ilk yanıt beklemesin diye kalanı arka planda dolar. const MAX_PAGES = Number(process.env.ILAN_MAX_PAGES || 300); // 300 × 20 = 6000 ilan const ILK_PARTI = Number(process.env.ILAN_ILK_PARTI || 6); // ilk yanıt için yeterli sayfa const ES_ZAMANLI = Number(process.env.ILAN_ESZAMANLI || 6); // paralel istek sayısı const DT_LIMIT = Number(process.env.DT_LIMIT || 5000); const HISTORY_LIMIT = Number(process.env.HISTORY_LIMIT || 5000); const ILAN_HEADERS = { accept: 'application/json', 'content-type': 'application/json', origin: ILAN_BASE, referer: `${ILAN_BASE}/ilan/tum-ilanlar`, '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', }; // --------------------------------------------------------------------------- // İl → Bölge eşlemesi // --------------------------------------------------------------------------- const REGIONS = { 'MARMARA BÖLGESİ': ['İstanbul', 'Bursa', 'Kocaeli', 'Balıkesir', 'Tekirdağ', 'Sakarya', 'Çanakkale', 'Edirne', 'Kırklareli', 'Yalova', 'Bilecik'], 'İÇ ANADOLU BÖLGESİ': ['Ankara', 'Konya', 'Kayseri', 'Eskişehir', 'Sivas', 'Aksaray', 'Karaman', 'Kırıkkale', 'Kırşehir', 'Nevşehir', 'Niğde', 'Yozgat', 'Çankırı'], 'EGE BÖLGESİ': ['İzmir', 'Manisa', 'Aydın', 'Denizli', 'Muğla', 'Afyonkarahisar', 'Kütahya', 'Uşak'], 'AKDENİZ BÖLGESİ': ['Antalya', 'Adana', 'Mersin', 'Hatay', 'Kahramanmaraş', 'Osmaniye', 'Isparta', 'Burdur'], 'KARADENİZ BÖLGESİ': ['Samsun', 'Trabzon', 'Ordu', 'Rize', 'Giresun', 'Zonguldak', 'Tokat', 'Çorum', 'Amasya', 'Sinop', 'Kastamonu', 'Bartın', 'Karabük', 'Düzce', 'Bolu', 'Artvin', 'Gümüşhane', 'Bayburt'], 'DOĞU ANADOLU BÖLGESİ': ['Erzurum', 'Van', 'Malatya', 'Elazığ', 'Erzincan', 'Ağrı', 'Kars', 'Ardahan', 'Iğdır', 'Muş', 'Bitlis', 'Bingöl', 'Tunceli', 'Hakkari'], 'GÜNEYDOĞU ANADOLU BÖLGESİ': ['Gaziantep', 'Şanlıurfa', 'Diyarbakır', 'Mardin', 'Batman', 'Siirt', 'Şırnak', 'Adıyaman', 'Kilis'], }; const CITY_TO_REGION = {}; for (const [region, cities] of Object.entries(REGIONS)) { for (const c of cities) CITY_TO_REGION[c.toLocaleUpperCase('tr-TR')] = region; } // --------------------------------------------------------------------------- // Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre) // --------------------------------------------------------------------------- const SECTOR_RULES = [ [/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş/i, 'Sağlık / Tıbbi Malzeme'], [/yazılım|bilişim|sunucu|donanım|kamera|network|bilgisayar/i, 'Bilişim & Yazılım'], [/gıda|yemek|catering|ekmek|et |sebze|meyve|kumanya/i, 'Gıda & Catering'], [/temizlik|hijyen|dezenfek|deterjan/i, 'Temizlik & Hijyen'], [/inşaat|yapım|onarım|tadilat|bakım|asfalt|beton|sulama|yol /i, 'İnşaat & Bakım'], [/kırtasiye|matbaa|baskı|kağıt|toner/i, 'Kırtasiye & Büro'], [/giyim|tekstil|elbise|üniforma|kıyafet/i, 'Tekstil & Giyim'], [/araç|otomotiv|lastik|akaryakıt|yakıt|kiralama/i, 'Otomotiv & Yedek Parça'], [/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş/i, 'Elektrik & Enerji'], ]; const INSTITUTION_RULES = [ [/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'], [/belediye/i, 'Belediyeler'], [/üniversite/i, 'Üniversiteler'], [/hastane|sağlık müdürlüğü|başhekim/i, 'Eğitim ve Araştırma Hastaneleri'], [/ağız ve diş/i, 'Ağız ve Diş Sağlığı Merkezleri'], [/ceza infaz|cezaevi|tevkifevi/i, 'Ceza İnfaz Kurumları'], [/bakanlığı|bakanlık|valiliği|müdürlüğü/i, 'Bakanlıklar'], [/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'], ]; function classify(rules, text, fallback) { for (const [re, label] of rules) if (re.test(text)) return label; return fallback; } function titleCaseTr(s) { if (!s) return ''; return s .toLocaleLowerCase('tr-TR') .split(/\s+/) .map((w) => (w ? w[0].toLocaleUpperCase('tr-TR') + w.slice(1) : w)) .join(' '); } function getFilter(ad, key) { const f = (ad.adTypeFilters || []).find((x) => x.key === key); return f ? f.value : null; } function relativeTimeTr(iso) { if (!iso) return ''; const diffMs = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diffMs / 60000); if (mins < 60) return `${Math.max(mins, 1)} dk önce eklendi`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours} saat önce eklendi`; return `${Math.floor(hours / 24)} gün önce eklendi`; } function daysUntil(ddmmyyyy) { if (!ddmmyyyy) return null; const [d, m, y] = ddmmyyyy.split('.').map(Number); if (!d || !m || !y) return null; return Math.ceil((new Date(y, m - 1, d) - new Date()) / 86400000); } // ilan.gov.tr kaydını ihaleist tender modeline çevir function mapAd(ad) { const cityUpper = (ad.addressCityName || '').toLocaleUpperCase('tr-TR'); const sehir = titleCaseTr(ad.addressCityName || ''); const tarih = getFilter(ad, 'İhale ve Teklif Açma Tarihi') || ''; const ikn = getFilter(ad, 'İhale Kayıt No') || ad.adNo || ''; const usul = getFilter(ad, 'İhale Usulü') || 'Belirtilmemiş'; const ihaleTuru = getFilter(ad, 'İhale Türü') || ''; const searchBase = `${ad.title || ''} ${ad.advertiserName || ''}`; const kalanGun = daysUntil(tarih); let aciliyetBadge = null; if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN'; const publishedToday = ad.publishStartDate && new Date(ad.publishStartDate).toDateString() === new Date().toDateString(); return { id: `ilan-${ad.id}`, kaynak: 'ilan.gov.tr', kaynakUrl: `${ILAN_BASE}${ad.urlStr || ''}`, ikn, baslik: ad.title || '', kurum: ad.advertiserName || '', sehir, bolge: CITY_TO_REGION[cityUpper] || '', tarih, saat: '', alimTuru: ihaleTuru || 'İhale', usul, sektor: classify(SECTOR_RULES, searchBase, 'Genel'), brans: ihaleTuru || 'Genel', kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'), eklenmeZamani: relativeTimeTr(ad.publishStartDate), isYeni: Boolean(publishedToday), isLiveFeed: true, aciliyetBadge, items: [], aciklama: null, // detay çağrısıyla lazy doldurulur yaklasikMaliyet: null, teslimatSuresi: null, teslimYeri: null, yeterlikKriterleri: [], aiMatchScore: null, aiRiskScore: null, aiBidRange: null, kanbanStatus: 'incelenecek', atananPersonel: null, durum: 'Aktif', incelendi: false, takipte: false, sozlesmeBedeli: null, kazananFirma: null, }; } // --------------------------------------------------------------------------- // ilan.gov.tr istemcisi + cache // --------------------------------------------------------------------------- function httpsJson(urlStr, { method = 'GET', body = null } = {}) { return new Promise((resolve, reject) => { const u = new URL(urlStr); const payload = body ? JSON.stringify(body) : null; const req = https.request( { hostname: u.hostname, path: u.pathname + u.search, method, ca: CA_BUNDLE, headers: { ...ILAN_HEADERS, ...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}), }, timeout: 30000, }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); if (res.statusCode < 200 || res.statusCode >= 300) { return reject(new Error(`ilan.gov.tr HTTP ${res.statusCode}: ${text.slice(0, 200)}`)); } try { resolve(JSON.parse(text)); } catch { reject(new Error('ilan.gov.tr geçersiz JSON döndürdü')); } }); }, ); req.on('timeout', () => req.destroy(new Error('ilan.gov.tr zaman aşımı'))); req.on('error', reject); if (payload) req.write(payload); req.end(); }); } async function ilanSearch(keys, skipCount = 0, maxResultCount = LIST_SIZE) { const data = await httpsJson(`${ILAN_BASE}/api/api/services/app/Ad/AdsByFilter`, { method: 'POST', body: { keys, skipCount, maxResultCount }, }); return data.result || {}; } async function ilanDetail(id) { const data = await httpsJson( `${ILAN_BASE}/api/api/services/app/AdDetail/GetAdDetail?id=${encodeURIComponent(id)}`, ); return data.result || {}; } function htmlToText(htmlStr) { if (!htmlStr) return ''; return htmlStr .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<(?:br|\/tr|\/p|\/h\d|\/li)>/gi, '\n') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/[ \t]+/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); } const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: new Map() }; let arkaPlanDoluyor = false; // Bir sayfa grubunu paralel çeker async function ilanSayfalari(baslangic, adet) { const istekler = []; for (let i = 0; i < adet; i++) { const page = baslangic + i; istekler.push( ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE).catch((e) => { console.warn(`[ihaleist-api] sayfa ${page} alınamadı: ${e.message}`); return { ads: [] }; }) ); } return Promise.all(istekler); } function ilanlariBirlestir(mevcut, yeniAds) { const seen = new Set(mevcut.map((t) => t.id)); const eklenen = []; for (const ad of yeniAds) { const t = mapAd(ad); if (seen.has(t.id)) continue; seen.add(t.id); eklenen.push(t); } return eklenen; } // Kalan sayfaları arka planda çeker; kullanıcı beklemez, havuz zamanla dolar. async function arkaPlandaTamamla(numFound) { if (arkaPlanDoluyor) return; arkaPlanDoluyor = true; try { const gerekliSayfa = Math.min(MAX_PAGES, Math.ceil(numFound / LIST_SIZE)); let page = ILK_PARTI; while (page < gerekliSayfa) { const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page); const sonuclar = await ilanSayfalari(page, grup); const ads = sonuclar.flatMap((r) => r.ads || []); if (!ads.length) break; const eklenen = ilanlariBirlestir(cache.tenders, ads); if (eklenen.length) cache.tenders = [...cache.tenders, ...eklenen]; page += grup; if (page % 60 === 0) { console.log(`[ihaleist-api] arka plan: ${cache.tenders.length}/${numFound} ilan yüklendi`); } await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için } console.log(`[ihaleist-api] havuz dolduruldu: ${cache.tenders.length}/${numFound} ilan`); } catch (e) { console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message); } finally { arkaPlanDoluyor = false; } } async function getTenders(force = false) { const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS; if (fresh && !force) return cache; // İlk parti: paralel, hızlı — kullanıcı bunu bekler const ilk = await ilanSayfalari(0, ILK_PARTI); const numFound = ilk.find((r) => r.numFound)?.numFound || 0; const ads = ilk.flatMap((r) => r.ads || []); const seen = new Set(); cache.tenders = ads .filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id))) .map(mapAd); cache.totalCount = numFound || cache.tenders.length; cache.todayCount = cache.tenders.filter((t) => t.isYeni).length; cache.fetchedAt = Date.now(); console.log(`[ihaleist-api] ${cache.tenders.length} ihale yüklendi (havuz: ${cache.totalCount}) — kalanı arka planda`); // Kalanı arka planda doldur (yanıtı bloklamaz) if (cache.totalCount > cache.tenders.length) { arkaPlandaTamamla(cache.totalCount); } return cache; } async function getDetail(ilanId) { if (cache.detail.has(ilanId)) return cache.detail.get(ilanId); const raw = await ilanDetail(ilanId); const text = htmlToText(raw.content || ''); const detail = { aciklama: text.slice(0, 4000), adNo: raw.adNo || null }; cache.detail.set(ilanId, detail); if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value); return detail; } // --------------------------------------------------------------------------- // HTTP sunucu // --------------------------------------------------------------------------- function json(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', }); res.end(JSON.stringify(body)); } function readBody(req, limit = 100 * 1024) { return new Promise((resolve, reject) => { let data = ''; req.on('data', (c) => { data += c; if (data.length > limit) reject(new Error('gövde çok büyük')); }); req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { reject(new Error('geçersiz JSON gövdesi')); } }); req.on('error', reject); }); } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); try { // --- Google ile Giriş --- if (url.pathname === '/api/auth/google/config') { return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null }); } if (url.pathname === '/api/auth/google' && req.method === 'POST') { try { const body = await readBody(req); const { token, user } = await loginWithGoogle(body.credential); return json(res, 200, { success: true, token, user }); } catch (e) { return json(res, 401, { success: false, error: e.message }); } } if (url.pathname === '/api/auth/me') { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); return json(res, 200, { success: true, user }); } // --- İhale dokümanları --- if (url.pathname === '/api/documents/url') { try { const ikn = url.searchParams.get('ikn'); if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); const out = await documentUrl(ikn); return json(res, 200, { success: true, ...out }); } catch (e) { return json(res, 502, { success: false, error: e.message }); } } // İlan metni + kalemler — analiz için (tarayıcı gerektirmez) if (url.pathname === '/api/documents/ilan') { try { const ikn = url.searchParams.get('ikn'); if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); const out = await ilanMetni(ikn); return json(res, 200, { success: true, ...out }); } catch (e) { return json(res, 502, { success: false, error: e.message }); } } // Doküman metni — sunucu tarafı tarayıcı gerektirir if (url.pathname === '/api/documents/text') { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); try { const ikn = url.searchParams.get('ikn'); if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); const out = await documentText(ikn); return json(res, 200, { success: true, ...out }); } catch (e) { return json(res, 502, { success: false, error: e.message }); } } if (url.pathname === '/api/documents/config') { return json(res, 200, { tarayiciVar: await browserAvailable() }); } // --- 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() }); } // Giriş yapan kullanıcının KENDİ e-postasına test/özet gönderir (kötüye kullanımı önler). if (url.pathname === '/api/notify/email/test' && req.method === 'POST') { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); if (!emailConfigured()) return json(res, 503, { success: false, error: 'E-posta yapılandırılmamış (RESEND_API_KEY yok)' }); try { const body = await readBody(req).catch(() => ({})); const html = tenderDigestHtml({ name: user.name || user.email, tenders: body.tenders || [] }); const { id } = await sendEmail({ to: user.email, subject: body.subject || 'İhaleist — bildirim testi', html, }); return json(res, 200, { success: true, id, to: user.email }); } catch (e) { return json(res, 502, { success: false, error: e.message }); } } if (url.pathname === '/api/tenders') { const force = url.searchParams.get('refresh') === '1'; const c = await getTenders(force); // Doğrudan temin (EKAP worker) verisini varsa birleştir let dt = { tenders: [], totalCount: 0, available: false }; try { dt = readDtTenders(DT_LIMIT); } 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(HISTORY_LIMIT); } 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(' + '), fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'), // Havuz = canlı ilan + doğrudan temin + sonuç arşivi (yüklü sayısıyla tutarlı olsun) totalCount: c.totalCount + dt.totalCount + hist.totalCount, ilanCount: c.totalCount, todayCount: c.todayCount, dtCount: dt.totalCount, historyCount: hist.totalCount, tenders: [...dt.tenders, ...c.tenders, ...hist.tenders], }); } if (url.pathname === '/api/dt') { let dt = { tenders: [], totalCount: 0, available: false }; try { dt = readDtTenders(Number(url.searchParams.get('limit')) || 300); } catch (e) { return json(res, 200, { success: true, available: false, error: e.message, tenders: [] }); } return json(res, 200, { success: true, available: dt.available, source: 'EKAP Doğrudan Temin (22/d)', totalCount: dt.totalCount, tenders: dt.tenders, }); } const detailMatch = url.pathname.match(/^\/api\/tenders\/ilan-(\d+)\/detail$/); if (detailMatch) { const detail = await getDetail(detailMatch[1]); return json(res, 200, { success: true, ...detail }); } if (url.pathname === '/api/stats') { const c = await getTenders(); return json(res, 200, { success: true, toplamIlan: c.totalCount, bugunEklenen: c.todayCount, yuklenenIlan: c.tenders.length, kaynak: 'ilan.gov.tr', }); } if (url.pathname === '/api/health') return json(res, 200, { ok: true }); if (url.pathname === '/api/health/scheduler') { return json(res, 200, { success: true, tarayiciVar: await browserAvailable(), ...schedulerStatus() }); } return json(res, 404, { success: false, error: 'not found' }); } catch (err) { console.error('[ihaleist-api] hata:', err.message); return json(res, 502, { success: false, error: err.message }); } }); 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(); });