Files
ihaleist/server/index.js
T

1002 lines
42 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 crypto from 'node:crypto';
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, emailFrom } from './email.mjs';
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs';
import * as store from './user-store.mjs';
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
import { searchTed } from './ted.mjs';
import { readHistory, historyStats, havuzOlc, havuzGecmisi } from './history-store.mjs';
import { startScheduler, schedulerStatus } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs';
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
import { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.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`);
havuzKaydet(numFound, cache.tenders.length);
} catch (e) {
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
} finally {
arkaPlanDoluyor = false;
}
}
// Havuz büyüklüğünü kaydeder; ani düşüş / eksik yükleme varsa yöneticiyi uyarır.
let sonHavuzUyarisi = 0;
function dtSayisiGuvenli() {
try { return readDtTenders(1)?.totalCount ?? null; } catch { return null; }
}
function havuzKaydet(kaynakToplam, yuklenen) {
try {
const ist = historyStats?.() || {};
const sonuc = havuzOlc({
kaynakToplam,
yuklenen,
dt: dtSayisiGuvenli(),
arsiv: ist.toplam ?? null,
});
if (!sonuc?.uyari) return;
console.warn(`[havuz-izleme] ${sonuc.uyari}`);
// Aynı uyarıyı sürekli göndermemek için 6 saatte bir e-posta.
if (Date.now() - sonHavuzUyarisi < 6 * 3600e3) return;
sonHavuzUyarisi = Date.now();
const alicilar = adminEmails();
if (!alicilar.length || !emailConfigured()) return;
sendEmail({
to: alicilar,
subject: sonuc.tur === 'eksik_yukleme'
? 'İhaleist: havuz eksik yüklendi'
: 'İhaleist: canlı ihale havuzu ani düştü',
text: `${sonuc.uyari}\n\nÖnceki ölçüm: ${sonuc.onceki ?? '-'}\nŞimdi: ${sonuc.simdi ?? '-'}\n\n` +
`Ayrıntı: ${process.env.APP_URL || 'https://ihaleist.seymata.com'}/api/health/havuz`,
}).catch((e) => console.warn('[havuz-izleme] uyarı e-postası gönderilemedi:', e.message));
} catch (e) {
console.warn('[havuz-izleme] ölçüm alınamadı:', e.message);
}
}
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 readRaw(req, limit = 1024 * 1024) {
return new Promise((resolve, reject) => {
let buf = '';
req.on('data', (c) => {
buf += c;
if (buf.length > limit) { reject(new Error('Gövde çok büyük')); req.destroy(); }
});
req.on('end', () => resolve(buf));
req.on('error', reject);
});
}
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);
});
}
// /api/tenders gövdesini üretir; bildirim işi de aynı veriyi kullanır.
async function tendersPayload(force = false) {
const c = await getTenders(force);
let dt = { tenders: [], totalCount: 0, available: false };
try { dt = readDtTenders(DT_LIMIT); }
catch (e) { console.warn('[ihaleist-api] DT okuma hatası:', e.message); }
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 {
success: true,
source: sources.join(' + '),
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
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],
};
}
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 });
}
}
// --- Belge deposu (GERÇEK dosya, GERÇEK boyut, kota uygulanır) ---
if (url.pathname === '/api/me/documents') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
const opts = { isAdmin: user.isAdmin };
if (req.method === 'POST') {
try {
const body = await readBody(req, 12 * 1024 * 1024); // base64 şişmesi payı
if (!body.ad || !body.icerikBase64) {
return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' });
}
const buf = Buffer.from(body.icerikBase64, 'base64');
const kullanimDurumu = store.documentUsage(user.email);
kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts);
kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts);
const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`;
fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf);
const id = store.addDocument(user.email, {
ad: String(body.ad).slice(0, 200),
kategori: body.kategori || null,
boyut: buf.length, // GERÇEK bayt — rastgele değil
mime: body.mime || null,
dosya: dosyaAdi,
sonKullanma: body.sonKullanma || null,
});
return json(res, 200, { success: true, id, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
if (req.method === 'DELETE') {
const ok = store.deleteDocument(user.email, url.searchParams.get('id'));
return json(res, ok ? 200 : 404, { success: ok, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
}
return json(res, 200, {
success: true,
belgeler: store.listDocuments(user.email),
kullanim: store.documentUsage(user.email),
haklar: { adet: haklar(user.email, opts).belgeAdet, bayt: haklar(user.email, opts).belgeBayt },
});
}
// Belge indirme
if (url.pathname === '/api/me/documents/download') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const rec = store.getDocument(user.email, url.searchParams.get('id'));
if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' });
try {
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
res.writeHead(200, {
'Content-Type': rec.mime || 'application/octet-stream',
'Content-Length': buf.length,
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(rec.ad)}`,
});
return res.end(buf);
} catch (e) {
return json(res, 500, { success: false, error: 'dosya okunamadı' });
}
}
// --- Kullanıcı verisi (localStorage yerine sunucu tarafı) ---
if (url.pathname.startsWith('/api/me/data')) {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
const anahtar = url.searchParams.get('anahtar');
if (req.method === 'GET') {
if (anahtar) return json(res, 200, { success: true, anahtar, deger: store.getData(user.email, anahtar) });
return json(res, 200, { success: true, veri: store.getAllData(user.email) });
}
if (req.method === 'PUT' || req.method === 'POST') {
try {
const body = await readBody(req, 512 * 1024);
const k = body.anahtar || anahtar;
if (!k) return json(res, 400, { success: false, error: 'anahtar gerekli' });
// Ekip kotası sunucu tarafında uygulanır
if (k === 'ekip' && Array.isArray(body.deger)) {
kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin });
}
store.setData(user.email, k, body.deger);
return json(res, 200, { success: true });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
return json(res, 405, { success: false, error: 'desteklenmeyen metot' });
}
// --- API anahtarları (gerçek, hash'li, sunucu doğrulamalı) ---
if (url.pathname === '/api/me/apikeys') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
if (req.method === 'POST') {
try {
const mevcut = store.listApiKeys(user.email).length;
kotaKontrol(user.email, 'apiAnahtari', mevcut, 1, { isAdmin: user.isAdmin });
const body = await readBody(req).catch(() => ({}));
const k = store.createApiKey(user.email, body.ad);
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
return json(res, 200, { success: true, anahtar: k });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
if (req.method === 'DELETE') {
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
return json(res, ok ? 200 : 404, { success: ok, anahtarlar: store.listApiKeys(user.email) });
}
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
}
// --- Stripe webhook (imza doğrulamalı) ---
// NOT: Bu uç kimlik doğrulaması İSTEMEZ; güvenlik imza doğrulamasıyla sağlanır.
if (url.pathname === '/api/billing/cancel' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const kayit = store.getUser(user.email);
const subId = kayit?.stripe_sub_id;
if (!subId) return json(res, 400, { success: false, error: 'Aktif bir aboneliğiniz görünmüyor.' });
const body = await readJson(req);
try {
const out = await cancelSubscription({ subId, neden: body.neden, aciklama: body.aciklama });
// Neden kaydı: ürün kararları için kendi tarafımızda da tutulur.
store.setData(user.email, 'iptal_geri_bildirim', {
neden: body.neden || null,
aciklama: String(body.aciklama || '').slice(0, 500),
tarih: new Date().toISOString(),
});
console.log(`[billing] ${user.email} aboneliğini iptal etti (neden: ${body.neden || 'belirtilmedi'})`);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/billing/webhook' && req.method === 'POST') {
if (!webhookConfigured()) {
return json(res, 503, { success: false, error: 'STRIPE_WEBHOOK_SECRET tanımlı değil' });
}
let olay;
try {
const ham = await readRaw(req);
olay = verifyWebhook(ham, req.headers['stripe-signature']);
} catch (e) {
console.warn('[stripe-webhook] imza reddedildi:', e.message);
return json(res, 400, { success: false, error: e.message });
}
try {
const o = olay.data?.object || {};
switch (olay.type) {
case 'checkout.session.completed': {
const email = (o.client_reference_id || o.customer_email || o.metadata?.email || '').toLowerCase();
const planId = o.metadata?.planId || 'pro';
if (email) {
store.setPlan(email, {
plan: planId, kaynak: 'stripe',
customerId: o.customer || null, subId: o.subscription || null,
});
console.log(`[stripe-webhook] ${email} → ${planId} planına geçti`);
}
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const priceId = o.items?.data?.[0]?.price?.id;
const planId = planForPrice(priceId) || 'pro';
const aktif = ['active', 'trialing'].includes(o.status);
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
if (k) {
store.setPlan(k.email, {
plan: aktif ? planId : 'ucretsiz', kaynak: 'stripe',
bitis: o.current_period_end ? new Date(o.current_period_end * 1000).toISOString() : null,
customerId: o.customer, subId: o.id,
});
console.log(`[stripe-webhook] ${k.email} aboneliği ${o.status} → ${aktif ? planId : 'ucretsiz'}`);
}
break;
}
case 'customer.subscription.deleted': {
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
if (k) {
store.setPlan(k.email, { plan: 'ucretsiz', kaynak: 'stripe', customerId: o.customer, subId: o.id });
console.log(`[stripe-webhook] ${k.email} aboneliği iptal edildi`);
}
break;
}
default:
break; // ilgilenmediğimiz olaylar sessizce onaylanır
}
return json(res, 200, { received: true });
} catch (e) {
console.error('[stripe-webhook] işleme hatası:', e.message);
return json(res, 500, { success: false, error: e.message });
}
}
// --- Kullanıcının planı ve kullanımı ---
if (url.pathname === '/api/me/plan') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
return json(res, 200, { success: true, ...kullanim(user.email, { isAdmin: user.isAdmin }) });
}
// --- Abonelik / ödeme ---
if (url.pathname === '/api/billing/plans') {
return json(res, 200, { configured: billingConfigured(), webhookHazir: webhookConfigured(), plans: planCatalog(), iptalNedenleri: IPTAL_NEDENLERI });
}
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, periyot: body.periyot === 'aylik' ? 'aylik' : 'yillik' });
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 });
}
}
// --- Firma web sitesi tarama (gerçek fetch + AI) ---
if (url.pathname === '/api/company/scan' && 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 scanSite(body.url);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Otomatik bildirim abonelikleri ---
if (url.pathname === '/api/notify/subscriptions') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!notifyAvailable()) return json(res, 503, { success: false, error: 'Bildirim deposu kullanılamıyor' });
if (req.method === 'POST') {
try {
const body = await readBody(req);
const abone = subscribe(user.email, body.kriter || {}, user.name);
return json(res, 200, { success: true, abonelikler: abone });
} catch (e) {
return json(res, 400, { success: false, error: e.message });
}
}
if (req.method === 'DELETE') {
const id = Number(url.searchParams.get('id'));
const ok = removeSub(id, user.email);
return json(res, ok ? 200 : 404, { success: ok, abonelikler: listSubs(user.email) });
}
return json(res, 200, { success: true, abonelikler: listSubs(user.email) });
}
// Aboneliklerin hemen işlenmesi (admin/test)
if (url.pathname === '/api/notify/run' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici yetkisi gerekli' });
try {
const sonuc = await bildirimleriIsle(() => tendersPayload(), { zorla: url.searchParams.get('zorla') === '1' });
return json(res, 200, { success: true, ...sonuc });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') {
// 'from' gizli bilgi değil; hangi adresin kullanıldığını görünür kılar
return json(res, 200, {
configured: emailConfigured(),
from: emailFrom(),
envAyarli: Boolean((process.env.EMAIL_FROM || '').trim()),
});
}
// 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 out = await tendersPayload(url.searchParams.get('refresh') === '1');
return json(res, 200, out);
}
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/havuz') {
const gecmis = havuzGecmisi(Number(url.searchParams.get('limit')) || 60);
const son = gecmis[0] || null;
// Son 7 günün en yükseği — bugünkü sayı buna göre kıyaslanır.
const haftaBas = new Date(Date.now() - 7 * 864e5).toISOString();
const hafta = gecmis.filter((r) => r.zaman >= haftaBas);
const zirve = hafta.reduce((m, r) => Math.max(m, r.kaynak_toplam || 0), 0);
return json(res, 200, {
success: true,
son,
haftalikZirve: zirve || null,
zirveyeOran: son?.kaynak_toplam && zirve ? Number((son.kaynak_toplam / zirve).toFixed(3)) : null,
olcumSayisi: gecmis.length,
gecmis,
});
}
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));
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
startScheduler({ tenderGetir: () => tendersPayload() });
});