/** * 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'; // 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 const MAX_PAGES = 8; // 8 sayfa × 20 = 160 ilan 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() }; async function getTenders(force = false) { const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS; if (fresh && !force) return cache; const allAds = []; let numFound = 0; for (let page = 0; page < MAX_PAGES; page++) { const result = await ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE); const ads = result.ads || []; numFound = result.numFound || numFound; if (ads.length === 0) break; allAds.push(...ads); if (allAds.length >= numFound) break; } const seen = new Set(); cache.tenders = allAds .filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id))) .map(mapAd); cache.totalCount = numFound || allAds.length; cache.todayCount = cache.tenders.filter((t) => t.isYeni).length; cache.fetchedAt = Date.now(); console.log(`[ihaleist-api] ${cache.tenders.length} ihale yüklendi (toplam havuz: ${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)); } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); try { 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(300); } catch (e) { console.warn('[ihaleist-api] DT 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'); return json(res, 200, { success: true, source: sources.join(' + '), fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'), totalCount: c.totalCount + dt.totalCount, todayCount: c.todayCount, dtCount: dt.totalCount, tenders: [...dt.tenders, ...c.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 }); 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)); });