Files
ihaleist/server/index.js
T
hikmettan 89cfd03d92 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
2026-08-26 20:45:02 +03:00

370 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
});