feat: canlı veri — ilan.gov.tr açık ihale + EKAP doğrudan temin (22/d)

- server/index.js: ilan.gov.tr proxy (açık ihale + kira/satış), TLS zincir düzeltmesi
- server/dt-worker.mjs: EKAP doğrudan temin toplayıcı (Playwright, imzasız oturum içi fetch)
- server/dt-store.mjs + il-plaka.mjs: DT verisini tender modeline çevirme
- src/services/tenderApi.js + AppContext: mock veri yerine canlı API
- Doğrudan Temin sekmesi 256 gerçek 22/d kaydıyla dolu
This commit is contained in:
2026-08-26 20:45:02 +03:00
parent d9570fe422
commit 89cfd03d92
14 changed files with 1000 additions and 66 deletions
+369
View File
@@ -0,0 +1,369 @@
/**
* 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(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<(?:br|\/tr|\/p|\/h\d|\/li)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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));
});