Files
ihaleist/server/index.js
T
hikmettanandClaude Fable 5.1 1716b6365c feat: hibrit uyum skoru (sozluksel + anlamsal bonus) ve firma esigi
Anlamsal bonus: profil metni sunucuda gomulur (multilingual-e5-small, ONNX
q8, @huggingface/transformers — Python/GPU yok), havuzdaki her ihaleyle
kosinus alinir, havuz ici z-skor → +0..20 puan. Sozluksel skor ana eksen
kalir; bonus eklenir. Sozluksel 0 iken bonus yalniz ≥10'sa gorunur.

NEDEN BONUS, ESIK DEGIL (olculdu): e5 kosinusleri 0,79-0,86 bandina
sikisiyor (havuz ort 0,814 sd 0,026); kirtasiye ile rontgen arasi 0,06.
kaanvp/kobi-ihale-eslestirme'nin 0,65 esigiyle trafo bakimi da "eslesti"
sayilirdi. Ama siralama dogru ve sozlukselin kacirdigini yakaliyor: "GENEL
TIBBI SARF" profilde birebir terim yokken kirtasiyenin ustune cikiyor.

- server/uyum-gomme.mjs: acilista arka planda isitma (~45 sn soguk), ihale
  gommeleri bellekte (metin imzasi degisince yenilenir), model data/modeller
  volume'unda bir kez indirilir; paket/yukleme yoksa sessizce kapali
- POST /api/uyum/gomme, GET /api/health/uyum
- Dockerfile: calisma imajina @huggingface/transformers@3 (package.json'a
  degil — sunucu sifir-bagimlilik, calisma katmani ayri npm i)
- Olculen: 3.2 bin ihale ilk cagri 7,6 sn (tumu gomuluyor), ikinci 17 ms;
  +20 alan 170, ≥10 alan 610 ihale

Firma esigi: profilde uyumEsigi (varsayilan 60, kalici); "Uyumlu" hizli
filtresi bu esigi kullanir; profil modalinda kaydirici + bonus durumu.
Modaldaki "%98+ dogruluk" cumlesi kaldirildi — olculmemis iddiaydi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:32:23 +03:00

1372 lines
59 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 { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI, ilanKonusu, OKAS_BOLUM } from './siniflandir.mjs';
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
import { kalemleriCikar } from './kalem-cikar.mjs';
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
import { iknCikar } from './kalem-cikar.mjs';
import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs';
// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma.
const urunAramaCache = new Map();
import * as kalemStore from './kalem-store.mjs';
import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.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, havuzOzet,
sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi,
cpvCacheOku, cpvCacheYaz } 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)
// ---------------------------------------------------------------------------
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`;
}
// Türkiye günü ("yyyy-aa-gg"). Sunucu konteyneri UTC'de çalışıyor; ilan.gov.tr
// yayın saati TR gece yarısı = "…T21:00:00Z" olduğundan UTC'ye göre gün DÜN
// çıkıyor ve "bugün eklenen" hep 0 görünüyordu.
const TR_GUN = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Istanbul', year: 'numeric', month: '2-digit', day: '2-digit',
});
function trGun(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : TR_GUN.format(d);
}
const bugunTR = () => TR_GUN.format(new Date());
// isYeni / eklenmeZamani ilan havuza girerken hesaplanıp donuyordu; havuz
// günlerce yaşayınca "22 saat önce" damgası ve "bugün" bayrağı bayatlıyordu.
// Her yanıtta tazelenir (3 bin kayıt için birkaç ms).
function ilanTazele(t) {
if (!t.isLiveFeed || t.kaynak !== 'ilan.gov.tr') return t;
t.isYeni = Boolean(t.ilanTarihi) && t.ilanTarihi === bugunTR();
if (t.yayinZamani) t.eklenmeZamani = relativeTimeTr(t.yayinZamani);
return t;
}
const bugunEklenenSayisi = (liste) => {
const bugun = bugunTR();
return (liste || []).filter((t) => t.kaynak === 'ilan.gov.tr' && t.ilanTarihi === bugun).length;
};
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 || ''}`; // yalnızca arama için
const kalanGun = daysUntil(tarih);
let aciliyetBadge = null;
if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN';
const ilanTarihi = trGun(ad.publishStartDate);
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,
// Başlık önce, kurum adı yalnızca yedek (bkz. sektorBul)
sektor: sektorBul(ad.title || '', ad.advertiserName || ''),
brans: ihaleTuru || 'Genel',
kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'),
eklenmeZamani: relativeTimeTr(ad.publishStartDate),
yayinZamani: ad.publishStartDate || null,
ilanTarihi,
isYeni: Boolean(ilanTarihi) && ilanTarihi === bugunTR(),
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: [], hata: true };
})
);
}
return Promise.all(istekler);
}
// --- CPV/OKAS sınıflandırma GATE'i -----------------------------------------
// "sektör" alanının aksine (bkz. sektorZenginlestir — önce yayınla, sonra
// arka planda zenginleştir), CPV alanı bir ihale cache.tenders'a GİRMEDEN
// ÖNCE tamamlanmış olmalı. Bu yüzden bu fonksiyon her zaman ilanlariBirlestir
// / getTenders içinde, sonuç diziye eklenmeden HEMEN ÖNCE await'lenir.
const CPV_ESZAMANLI = Number(process.env.CPV_ESZAMANLI || 4); // AI'ı yormamak için sınırlı paralellik
async function cpvUygula(t) {
const ikn = t.ikn || '';
if (ikn) {
const onbellek = cpvCacheOku(ikn);
if (onbellek) {
Object.assign(t, onbellek);
return t;
}
}
let sonuc;
try {
sonuc = await classifyTenderCpv(t, { analyzeSpec });
} catch (e) {
console.warn(`[cpv-classify] ${t.id} sınıflandırılamadı:`, e.message);
sonuc = { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
}
Object.assign(t, sonuc);
if (ikn) cpvCacheYaz(ikn, sonuc);
return t;
}
/**
* HIZLI CPV geçişi — yalnızca önbellek + anahtar kelime, AI YOK.
*
* Eskiden havuz, her ilan için AI çağrısı bitene kadar yayınlanmıyordu:
* 3.000 ilan × AI = saatler, bu sürede kullanıcı yalnızca ilk 120 ilanı
* görüyordu. Artık havuz anında yayınlanır; anahtar kelimenin çözemediği
* ilanlar arka planda AI ile tamamlanır.
*/
function cpvHizli(t) {
const ikn = t.ikn || '';
if (ikn) {
const onbellek = cpvCacheOku(ikn);
if (onbellek) { Object.assign(t, onbellek); return true; }
}
const anahtar = classifyCpvByKeyword(t);
if (anahtar) {
Object.assign(t, anahtar);
if (ikn) cpvCacheYaz(ikn, anahtar);
return true;
}
Object.assign(t, { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'bekliyor' });
return false;
}
// Arka planda AI ile CPV tamamlama (havuzu bloklamaz)
const CPV_TUR_LIMIT = Number(process.env.CPV_TUR_LIMIT || 200);
let cpvCalisiyor = false;
async function cpvZenginlestir() {
if (cpvCalisiyor || !cache.tenders?.length) return;
cpvCalisiyor = true;
try {
const kuyruk = cache.tenders.filter((t) => t.cpvSource === 'bekliyor').slice(0, CPV_TUR_LIMIT);
if (!kuyruk.length) return;
const bekleyenToplam = cache.tenders.filter((t) => t.cpvSource === 'bekliyor').length;
console.log(`[cpv-zengin] ${kuyruk.length} ilan AI ile sınıflandırılacak (bekleyen ${bekleyenToplam})`);
for (let i = 0; i < kuyruk.length; i += CPV_ESZAMANLI) {
const grup = kuyruk.slice(i, i + CPV_ESZAMANLI);
await Promise.all(grup.map((t) => cpvUygula(t)));
}
console.log(`[cpv-zengin] ${kuyruk.length} ilan tamamlandı`);
} catch (e) {
console.warn('[cpv-zengin] hata:', e.message);
} finally {
cpvCalisiyor = false;
}
}
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);
}
// Hızlı sınıflandırma (önbellek + anahtar kelime). AI'a kalanlar arka planda
// tamamlanır; havuzun yayınlanması AI'ı beklemez.
for (const t of eklenen) cpvHizli(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;
let eksiksiz = true; // her sayfa hatasız indi mi?
while (page < gerekliSayfa) {
const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page);
const sonuclar = await ilanSayfalari(page, grup);
if (sonuclar.some((r) => r.hata)) eksiksiz = false;
const ads = sonuclar.flatMap((r) => r.ads || []);
if (!ads.length) { eksiksiz = false; 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
}
// EKSİKSİZ inen liste her zaman devreye girer — kaynaktaki ihale sayısı
// düşse bile. Eskiden "yeni liste eskisinden küçükse alma" kuralı vardı;
// kaynak 3.372'lik zirveden 3.100'e inince havuz o zirve anında DONDU:
// günlerce yeni ilan eklenmedi, "bugün eklenen" 0 kaldı. Yarım inen liste
// ise yalnızca eldekinden zenginse alınır (kullanıcı eksik listeye düşmesin).
if (eksiksiz || yeni.length >= cache.tenders.length) {
if (yeni.length < cache.tenders.length) {
console.log(`[ihaleist-api] havuz küçüldü: ${cache.tenders.length} → ${yeni.length} (kaynak ${numFound}); güncel liste devrede`);
}
cache.tenders = yeni;
cache.todayCount = bugunEklenenSayisi(yeni);
} else {
console.warn(`[ihaleist-api] yenileme eksik indi (${yeni.length}/${numFound}); eldeki ${cache.tenders.length} ilan korunuyor`);
}
console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`);
havuzKaydet(numFound, cache.tenders.length);
zenginUygula(); // önceden çözülmüşleri hemen uygula
setTimeout(() => sektorZenginlestir().catch(() => {}), 5000); // kalanları arka planda
setTimeout(() => cpvZenginlestir().catch(() => {}), 15000); // CPV'yi AI ile tamamla
} catch (e) {
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
} finally {
arkaPlanDoluyor = false;
}
}
// --- Sektör zenginleştirme -------------------------------------------------
// Başlığında sektör sinyali olmayan ilanların ("MUHTELİF MALZEME ALIMI")
// ilan METNİ indirilip sınıflandırılır. Sonuç kalıcı; aynı ilan bir daha
// indirilmez. Kaynağı yormamak için turda sınırlı sayıda ilan işlenir.
const ZENGIN_TUR_LIMIT = Number(process.env.SEKTOR_ZENGIN_LIMIT || 300);
const ZENGIN_PARALEL = Number(process.env.SEKTOR_ZENGIN_PARALEL || 4);
let zenginCalisiyor = false;
async function sektorZenginlestir() {
if (zenginCalisiyor || !cache.tenders?.length) return;
zenginCalisiyor = true;
try {
const adaylar = cache.tenders.filter((t) => t.sektor === 'Genel' && t.isLiveFeed && t.id?.startsWith('ilan-'));
if (!adaylar.length) return;
const denenmis = sektorZenginDenenmis(adaylar.map((t) => t.id));
const kuyruk = adaylar.filter((t) => !denenmis.has(t.id)).slice(0, ZENGIN_TUR_LIMIT);
if (!kuyruk.length) return;
console.log(`[sektor-zengin] ${kuyruk.length} ilan metni sınıflandırılacak (kalan aday ${adaylar.length - denenmis.size})`);
const sonuc = [];
for (let i = 0; i < kuyruk.length; i += ZENGIN_PARALEL) {
const grup = kuyruk.slice(i, i + ZENGIN_PARALEL);
await Promise.all(grup.map(async (t) => {
try {
const det = await getDetail(t.id.replace(/^ilan-/, ''));
// İlanın TAMAMI değil, yalnızca işin konusu taranır: standart
// hukuki metin ("e-imza ile imzalanarak", "numune sunulması")
// sınıflandırmayı zehirliyordu.
const sek = sektorBul(t.baslik || '', ilanKonusu(det.aciklama || ''));
sonuc.push({ id: t.id, sektor: sek, kaynak: 'dokuman-v3' });
} catch (e) {
// Metin alınamadıysa 'Genel' yaz — sonsuz yeniden deneme olmasın.
sonuc.push({ id: t.id, sektor: 'Genel', kaynak: 'dokuman-v3' });
}
}));
// Kaynağı yormamak için gruplar arasında kısa duraklama.
if (i + ZENGIN_PARALEL < kuyruk.length) await new Promise((r) => setTimeout(r, 250));
}
const yazilan = sektorZenginYaz(sonuc);
const cozulen = sonuc.filter((r) => r.sektor !== 'Genel').length;
console.log(`[sektor-zengin] ${yazilan} kayıt işlendi, ${cozulen} tanesi sınıflandırıldı`);
zenginUygula();
} catch (e) {
console.warn('[sektor-zengin] hata:', e.message);
} finally {
zenginCalisiyor = false;
}
}
/** Kayıtlı zenginleştirmeyi havuzdaki 'Genel' ilanlara uygular. */
function zenginUygula() {
if (!cache.tenders?.length) return 0;
const genel = cache.tenders.filter((t) => t.sektor === 'Genel');
if (!genel.length) return 0;
const harita = sektorZenginOku(genel.map((t) => t.id));
let n = 0;
for (const t of genel) {
const s = harita.get(t.id);
if (s && s !== 'Genel') { t.sektor = s; n++; }
}
if (n) console.log(`[sektor-zengin] ${n} ilan doküman metninden sınıflandırıldı`);
return n;
}
// 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);
// Hızlı sınıflandırma; AI'a kalanlar arka planda tamamlanır.
for (const t of ilkListe) cpvHizli(t);
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 = bugunEklenenSayisi(ilkListe);
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)');
for (const t of c.tenders) ilanTazele(t); // isYeni + "x saat önce" gün değişse de doğru
c.todayCount = bugunEklenenSayisi(c.tenders);
// Ortak kalem havuzu: bir kullanıcı dokümanı yüklediyse o ihalenin teklif
// cetveli kalemleri herkesin kartında görünsün.
try {
const ozet = kalemStore.kalemOzetleri();
if (ozet.size) {
for (const t of [...c.tenders, ...dt.tenders, ...hist.tenders]) {
const k = t.ikn && ozet.get(t.ikn);
if (k) { t.kalemSayisi = k.sayi; t.kalemler = k.ilkler; }
}
}
} catch (e) {
console.warn('[kalem] özet uygulanamadı:', e.message);
}
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() });
}
// --- Ortak kalem havuzu ---
// PDF metni tarayıcıda çıkarılıyor; istemci buraya gönderir.
if (url.pathname === '/api/kalem/cikar' && 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, 4 * 1024 * 1024);
if (!body.ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const { kalemler, yontem } = kalemleriCikar(body.metin || '');
if (!kalemler.length) {
return json(res, 200, { success: true, kalemler: [], yontem, not: 'Metinde teklif cetveli tablosu bulunamadı.' });
}
kalemStore.kalemYaz(String(body.ikn), kalemler, {
dosya: body.dosya ? String(body.dosya).slice(0, 200) : null, yontem, ekleyen: user.email,
});
return json(res, 200, { success: true, kalemler, yontem, sayi: kalemler.length });
} catch (e) {
return json(res, 400, { success: false, error: e.message });
}
}
if (url.pathname === '/api/kalem') {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
return json(res, 200, { success: true, ...kalemStore.kalemOku(ikn) });
}
if (url.pathname === '/api/kalem/ara') {
const q = url.searchParams.get('q') || '';
if (q.trim().length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' });
const sonuc = kalemStore.kalemAra(q);
return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc });
}
// --- Ürün araması: metni ihale DOKÜMANININ içinde arar ---
// EKAP teklif cetvelini kendi tarafında indeksliyor; doküman indirmeye
// gerek yok. "ORTOPEDİK VİDA" hiçbir başlıkta geçmiyor ama teklif
// cetvellerinde geçiyor — firmanın kendi ürününü bulma yolu bu.
if (url.pathname === '/api/urun-ara') {
const q = (url.searchParams.get('q') || '').trim();
const okas = (url.searchParams.get('okas') || '').split(',').map((k) => k.trim()).filter(Boolean);
if (!okas.length && q.length < 3) return json(res, 400, { success: false, error: 'en az 3 karakter' });
const skip = Math.max(0, Number(url.searchParams.get('skip')) || 0);
const take = Math.min(50, Math.max(1, Number(url.searchParams.get('take')) || 20));
const teknik = url.searchParams.get('teknik') === '1';
const anahtar = `${okas.length ? 'okas:' + okas.join(',') : q}|${skip}|${take}|${teknik}`;
const simdi = Date.now();
const onbellek = urunAramaCache.get(anahtar);
if (onbellek && simdi - onbellek.zaman < 5 * 60 * 1000) {
return json(res, 200, { success: true, onbellekten: true, ...onbellek.veri });
}
try {
const out = okas.length
? await searchByOkas(okas, { skip, take })
: await searchInDocuments(q, { teknikSartname: teknik, skip, take });
urunAramaCache.set(anahtar, { zaman: simdi, veri: out });
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
if (url.pathname === '/api/okas/ara') {
const q = (url.searchParams.get('q') || '').trim();
if (q.length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' });
const anahtar = `okas|${q}`;
const onb = urunAramaCache.get(anahtar);
if (onb && Date.now() - onb.zaman < 60 * 60 * 1000) return json(res, 200, { success: true, kalemler: onb.veri });
try {
const kalemler = await okasAra(q, { take: 30 });
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
return json(res, 200, { success: true, kalemler });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
if (url.pathname === '/api/uyum/gomme' && 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, 64 * 1024);
const metin = String(body.profilMetni || '').trim();
if (metin.length < 10) return json(res, 400, { success: false, error: 'profil metni yok' });
const c = await getTenders(false);
const out = await bonusHesapla(metin, c.tenders || []);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 500, { success: false, error: e.message });
}
}
if (url.pathname === '/api/health/uyum') {
return json(res, 200, { success: true, ...gommeDurumu() });
}
if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
}
// --- 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, 16 * 1024 * 1024); // 10 MB dosya × 1,34 base64 + JSON 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);
// Toplu yüklemede dosyanın hangi ihaleye ait olduğu metninden bulunur
// ("İhale Kayıt Numarası (İKN): 2026/1166496"); kullanıcıya sorulmaz.
// PDF tarayıcıda okunduğu için istemci ikn'yi kendisi gönderir.
let ikn = body.ikn ? String(body.ikn).slice(0, 40) : null;
let ofisMetni = null;
if (sunucuOkuyabilir(body.ad)) {
try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { /* metin çıkmazsa devam */ }
if (!ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni);
}
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,
ikn, // ihaleye bağlı belge
});
// Yükleme anında otomatik kalem çıkarımı — AI kullanmaz, kullanıcıdan
// tıklama beklemez. PDF'ler tarayıcıda çözüldüğü için istemci
// metni /api/kalem/cikar'a gönderir.
let kalemBilgi = null;
if (ikn && ofisMetni) {
try {
const { kalemler, yontem } = kalemleriCikar(ofisMetni);
if (kalemler.length) {
kalemStore.kalemYaz(ikn, kalemler, { dosya: String(body.ad), yontem, ekleyen: user.email });
kalemBilgi = { sayi: kalemler.length, yontem };
console.log(`[kalem] ${ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`);
}
} catch (e) {
console.warn('[kalem] çıkarılamadı:', e.message);
}
}
return json(res, 200, { success: true, id, ikn, kalem: kalemBilgi, 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) });
}
// ?ikn=... verilirse yalnızca o ihaleye yüklenen belgeler döner
const iknFiltre = url.searchParams.get('ikn') || undefined;
return json(res, 200, {
success: true,
belgeler: store.listDocuments(user.email, { ikn: iknFiltre }),
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ı' });
}
}
// Belge metni — docx/xlsx/zip için. PDF tarayıcıda (pdfjs) okunuyor;
// bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor.
if (url.pathname === '/api/me/documents/text') {
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ı' });
if (!sunucuOkuyabilir(rec.ad)) {
return json(res, 400, { success: false, error: 'Bu dosya türünden sunucuda metin çıkarılamıyor (docx, xlsx, zip destekleniyor).' });
}
try {
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
const metin = arsivMetni(rec.ad, buf);
return json(res, 200, { success: true, ad: rec.ad, metin, karakter: metin.length });
} catch (e) {
return json(res, 422, { success: false, error: e.message });
}
}
// --- 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,
mode: body.mode === 'derin' ? 'derin' : undefined,
});
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: bugunEklenenSayisi(c.tenders),
yuklenenIlan: c.tenders.length,
kaynak: 'ilan.gov.tr',
});
}
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
if (url.pathname === '/api/health/sektor') {
const say = sektorZenginSayi();
const genel = (cache.tenders || []).filter((t) => t.sektor === 'Genel').length;
const toplam = (cache.tenders || []).length;
return json(res, 200, {
success: true,
havuz: toplam,
genelKalan: genel,
genelOran: toplam ? Number((genel / toplam).toFixed(3)) : null,
dokumandanDenenen: say.toplam,
dokumandanCozulen: say.cozulen,
calisiyor: zenginCalisiyor,
});
}
if (url.pathname === '/api/meta/filtreler') {
return json(res, 200, {
success: true,
sektorler: SEKTOR_ETIKETLERI,
kurumTipleri: KURUM_ETIKETLERI,
cpvBolumler: Object.entries(OKAS_BOLUM).map(([kod, etiket]) => ({ kod, etiket })),
});
}
if (url.pathname === '/api/health/havuz') {
const ozet = havuzOzet();
if (!ozet) return json(res, 200, { success: true, son: null, olcumSayisi: 0, gecmis: [] });
const { son, hafta, ay } = ozet;
const oran = (ort, yeterli) =>
yeterli && son?.kaynak_toplam && ort ? Number((son.kaynak_toplam / ort).toFixed(3)) : null;
const yeterliVeri = hafta.adet >= 5;
const yeterliVeri30 = ay.adet >= 20;
return json(res, 200, {
success: true,
son,
// 7 gün: günlük dalgalanma
haftalikOrtalama: hafta.ortalama,
haftalikZirve: hafta.zirve,
olcumAdedi: hafta.adet,
yeterliVeri,
ortalamayaOran: oran(hafta.ortalama, yeterliVeri),
// 30 gün: kalıcı erime
aylikOrtalama: ay.ortalama,
aylikZirve: ay.zirve,
aylikDip: ay.dip,
olcumAdedi30: ay.adet,
yeterliVeri30,
aylikOrtalamayaOran: oran(ay.ortalama, yeterliVeri30),
gecmis: havuzGecmisi(Number(url.searchParams.get('limit')) || 60),
});
}
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, () => {
// Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt.
arkaPlandaIsit();
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() });
});