Files
ihaleist/server/index.js
T
hikmettan 667b335ea7 fix: 10 dakikada bir havuz 120 kayda düşüyordu
Sorun: önbellek süresi dolunca getTenders cache.tenders'ı ilk partiyle (120 ilan)
DEĞİŞTİRİYOR, kalanı arka planda dolduruyordu. Yani her 10 dakikada bir kullanıcı
birkaç dakika boyunca eksik liste görüyordu (canlıda 8.258 havuza karşı 5.319
yüklü olarak yakalandı).

Çözüm: yenilemede mevcut dolu liste sunulmaya devam ediyor; yeni liste ayrı
kurulup TAMAMLANINCA atomik olarak devreye alınıyor. Yalnızca ilk açılışta
(elde hiç veri yokken) ilk parti anında gösteriliyor.

Doğrulama: zorla yenileme sırasında liste 3.795'te sabit kaldı (öncesinde 120).
2026-08-29 03:03:03 +03:00

671 lines
26 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';
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(/<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() };
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. Yeni liste ayrı kurulur ve TAMAMLANINCA
// atomik olarak devreye alınır; böylece yenileme sırasında kullanıcı hiçbir
// zaman eksik listeye düşmez (önceden her yenilemede 120 kayda iniyordu).
async function arkaPlandaTamamla(numFound, ilkListe) {
if (arkaPlanDoluyor) return;
arkaPlanDoluyor = true;
try {
const yeni = [...ilkListe];
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(yeni, ads);
if (eklenen.length) yeni.push(...eklenen);
page += grup;
await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için
}
// Yalnızca daha zengin bir liste elde ettiysek devreye al
if (yeni.length >= cache.tenders.length) {
cache.tenders = yeni;
cache.todayCount = yeni.filter((t) => t.isYeni).length;
}
console.log(`[ihaleist-api] havuz hazır: ${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;
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();
const ilkListe = ads
.filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id)))
.map(mapAd);
cache.totalCount = numFound || ilkListe.length;
cache.fetchedAt = Date.now();
// İlk yükleme: elde hiçbir şey yoksa ilk partiyi hemen göster.
// Yenileme: eldeki dolu listeyi KORU, yenisi arka planda hazırlansın.
if (!cache.tenders || cache.tenders.length === 0) {
cache.tenders = ilkListe;
cache.todayCount = ilkListe.filter((t) => t.isYeni).length;
console.log(`[ihaleist-api] ilk yükleme: ${ilkListe.length} ilan (havuz ${cache.totalCount}) — kalanı arka planda`);
} else {
console.log(`[ihaleist-api] yenileme başladı (mevcut ${cache.tenders.length} ilan sunulmaya devam ediyor)`);
}
if (cache.totalCount > ilkListe.length) arkaPlandaTamamla(cache.totalCount, ilkListe);
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();
});