1521 lines
69 KiB
JavaScript
1521 lines
69 KiB
JavaScript
import { deadlineInfo } from '../src/services/tenderCalendar.js';
|
||
import { exchangeRates } from './exchange-rates.mjs';
|
||
import { uniqueTenders, announcementFields } from './tender-quality.mjs';
|
||
/**
|
||
* 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';
|
||
import { telegramConfigured, botAdi, kodIleBagla, sendTelegram } from './telegram.mjs';
|
||
import { bildirimleriOku, okunduIsaretle, zeyilnameDurumu, zeyilnameTara, sonucTara } from './zeyilname.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 { tedCevir } from './ted-ceviri.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 { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs';
|
||
import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs';
|
||
import { belgeyeSor, teklifDenetle, teklifTaslak } from './ai-teklif.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;
|
||
};
|
||
|
||
// 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') || '';
|
||
// İKN'yi boşluksuz sakla: ilan.gov.tr bazı kayıtlarda başa/sona boşluk ve
|
||
// kesme boşluğu (nbsp) koyuyor; " 2026/1622878" EKAP formatı sayılmıyor ve
|
||
// doküman/analiz düğmeleri kapanıyordu (havuzda 50 kayıt).
|
||
const ikn = String(getFilter(ad, 'İhale Kayıt No') || ad.adNo || '').replace(/[\s\u00a0]+/g, '');
|
||
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 = deadlineInfo({ tarih }).days;
|
||
|
||
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(/ /g, ' ')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/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 = { ...announcementFields(text), 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);
|
||
}
|
||
|
||
const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]);
|
||
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: bugunEklenenSayisi(combined),
|
||
loadedUniqueCount: combined.length,
|
||
dtCount: dt.totalCount,
|
||
historyCount: hist.totalCount,
|
||
tenders: combined,
|
||
};
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||
try {
|
||
if (url.pathname === '/api/exchange-rates') {
|
||
try { return json(res,200,{success:true,...await exchangeRates()}); }
|
||
catch(e) { return json(res,503,{success:false,error:e.message}); }
|
||
}
|
||
// --- 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() });
|
||
}
|
||
// --- Telegram bağlantısı (kullanıcı kodu bota yazar, biz getUpdates'te görürüz) ---
|
||
if (url.pathname.startsWith('/api/me/telegram')) {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||
const tg = store.getData(user.email, 'telegram');
|
||
if (req.method === 'GET') {
|
||
return json(res, 200, { success: true, yapilandirilmis: telegramConfigured(), bot: await botAdi(), bagli: !!tg?.chatId, ad: tg?.ad || null, baglanma: tg?.baglanma || null });
|
||
}
|
||
if (req.method === 'DELETE') { store.setData(user.email, 'telegram', null); return json(res, 200, { success: true }); }
|
||
if (req.method === 'POST') {
|
||
try {
|
||
const body = await readBody(req);
|
||
if (body.islem === 'test') {
|
||
if (!tg?.chatId) return json(res, 400, { success: false, error: 'Telegram bağlı değil' });
|
||
await sendTelegram(tg.chatId, '✅ İhaleist bağlantı testi — bildirimler bu sohbete gelecek.');
|
||
return json(res, 200, { success: true });
|
||
}
|
||
const kod = String(body.kod || '').replace(/\D/g, '');
|
||
if (kod.length !== 6) return json(res, 400, { success: false, error: '6 haneli kod gerekli' });
|
||
const bulunan = await kodIleBagla(kod);
|
||
if (!bulunan) return json(res, 404, { success: false, error: 'Kod bota gönderilmiş görünmüyor. Önce bota yazın, sonra doğrulayın.' });
|
||
store.setData(user.email, 'telegram', { ...bulunan, baglanma: new Date().toISOString() });
|
||
await sendTelegram(bulunan.chatId, `✅ İhaleist hesabınız (${user.email}) bu sohbete bağlandı.`).catch(() => {});
|
||
return json(res, 200, { success: true, ad: bulunan.ad });
|
||
} catch (e) { return json(res, 502, { success: false, error: e.message }); }
|
||
}
|
||
}
|
||
// --- Takip bildirimleri (zeyilname / sonuç) ---
|
||
if (url.pathname === '/api/me/bildirimler') {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||
if (req.method === 'POST') {
|
||
const body = await readBody(req);
|
||
return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) });
|
||
}
|
||
return json(res, 200, { success: true, bildirimler: bildirimleriOku(user.email) });
|
||
}
|
||
if (url.pathname === '/api/health/takip') {
|
||
return json(res, 200, { success: true, ...zeyilnameDurumu(), telegram: telegramConfigured() });
|
||
}
|
||
if (url.pathname === '/api/admin/takip/tara' && req.method === 'POST') {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici gerekli' });
|
||
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
|
||
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
|
||
}
|
||
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,
|
||
});
|
||
// Başlık/açıklama Türkçesi: TED'in kendi TUR metni → önbellek → model
|
||
const bekleyenCeviri = await tedCevir(out.tenders).catch(() => 0);
|
||
return json(res, 200, { success: true, ...out, ceviriBekleniyor: bekleyenCeviri > 0 });
|
||
} 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 üst verisi: son kullanma tarihi (süre takibi) / kategori
|
||
if (url.pathname === '/api/me/documents/meta' && req.method === 'PATCH') {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||
try {
|
||
const body = await readBody(req);
|
||
if (!body.id) return json(res, 400, { success: false, error: 'id gerekli' });
|
||
const sonKullanma = body.sonKullanma ? String(body.sonKullanma).slice(0, 10) : null;
|
||
if (sonKullanma && !/^\d{4}-\d{2}-\d{2}$/.test(sonKullanma)) return json(res, 400, { success: false, error: 'tarih YYYY-AA-GG olmalı' });
|
||
const ok = store.updateDocument(user.email, String(body.id), { sonKullanma, kategori: body.kategori ? String(body.kategori).slice(0, 60) : null });
|
||
if (!ok) return json(res, 404, { success: false, error: 'belge bulunamadı' });
|
||
return json(res, 200, { success: true, belgeler: store.listDocuments(user.email) });
|
||
} catch (e) { return json(res, 400, { success: false, error: e.message }); }
|
||
}
|
||
// 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 });
|
||
}
|
||
}
|
||
|
||
if (url.pathname === '/api/me/tender-state') {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||
if (req.method === 'GET') return json(res, 200, {success:true, records:store.getTenderStates(user.email)});
|
||
if (req.method !== 'PUT') return json(res, 405, {success:false,error:'desteklenmeyen metot'});
|
||
try {
|
||
const body = await readBody(req, 512 * 1024);
|
||
const record = store.saveTenderState(user.email, body.key, body.patch, body.snapshot, body.version);
|
||
return json(res, 200, {success:true, record});
|
||
} catch (e) {
|
||
return json(res, e.kod === 'VERSION_CONFLICT' ? 409 : 400, {success:false,error:e.message,kod:e.kod});
|
||
}
|
||
}
|
||
|
||
// --- 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 });
|
||
}
|
||
}
|
||
|
||
// ---- RSS beslemeleri + MCP sunucusu + haftalık brief + teklif asistanı (İhaleAB'den taşındı) ----
|
||
if (url.pathname === '/api/feeds/tenders.rss') {
|
||
const key = url.searchParams.get('key') || '';
|
||
const sahip = key.startsWith('ih_live_') ? store.verifyApiKey(key) : null;
|
||
if (!sahip) { res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('API anahtarı gerekli: ?key=ih_live_… (API & Webhooks)'); }
|
||
const out = await tendersPayload();
|
||
const xml = ihaleRss(out.tenders, { q: url.searchParams.get('q'), il: url.searchParams.get('il'), sektor: url.searchParams.get('sektor'), sadeceAcik: url.searchParams.get('acik') !== '0', limit: url.searchParams.get('limit') || 50 });
|
||
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=600' });
|
||
return res.end(xml);
|
||
}
|
||
if (url.pathname === '/api/feeds/sonuc.rss') {
|
||
const xml = sonucRss({ firma: url.searchParams.get('firma'), idare: url.searchParams.get('idare'), il: url.searchParams.get('il') });
|
||
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' });
|
||
return res.end(xml);
|
||
}
|
||
if (url.pathname === '/mcp') {
|
||
return mcpIstek(req, res, { readBody, json, tenderGetir: async () => (await tendersPayload()).tenders });
|
||
}
|
||
if (url.pathname === '/api/me/yaklasan') {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||
const out = await tendersPayload();
|
||
return json(res, 200, { success: true, tenders: yaklasanIhaleler(user.email, out.tenders, Number(url.searchParams.get('gun')) || 14) });
|
||
}
|
||
if (url.pathname === '/api/me/brief') {
|
||
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') {
|
||
const body = await readBody(req).catch(() => ({}));
|
||
store.setData(user.email, 'brief', { kapali: body.kapali === true });
|
||
return json(res, 200, { success: true, kapali: body.kapali === true });
|
||
}
|
||
const out = await tendersPayload();
|
||
const b = briefOlustur(user.email, out.tenders);
|
||
return json(res, 200, { success: true, kapali: !!(store.getData(user.email, 'brief')?.kapali), brief: b, metin: briefMetni(b) });
|
||
}
|
||
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
|
||
const user = userFromAuthHeader(req.headers['authorization']);
|
||
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
|
||
const r = await haftalikBriefGonder(async () => (await tendersPayload()).tenders, { zorla: url.searchParams.get('zorla') === '1' });
|
||
return json(res, 200, { success: true, ...r });
|
||
}
|
||
if ((url.pathname === '/api/ai/belge-sor' || url.pathname === '/api/ai/teklif-denetim' || url.pathname === '/api/ai/teklif-taslak') && 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 bir sağlayıcı anahtarı ekleyin.' });
|
||
try {
|
||
const body = await readBody(req, 2 * 1024 * 1024);
|
||
const ortak = { ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text };
|
||
const out = url.pathname === '/api/ai/belge-sor'
|
||
? await belgeyeSor({ ...ortak, soru: body.soru })
|
||
: url.pathname === '/api/ai/teklif-denetim'
|
||
? await teklifDenetle({ ...ortak, taslak: body.taslak, gereklilikler: body.gereklilikler })
|
||
: await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler });
|
||
return json(res, 200, { success: true, ...out });
|
||
} catch (e) {
|
||
return json(res, e.attempts ? 502 : 400, { 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, process.env.NODE_ENV === 'test' ? '127.0.0.1' : undefined, () => {
|
||
if (process.env.NODE_ENV === 'test') return;
|
||
// 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() });
|
||
});
|