import {validateReminderPreferences} from '../src/services/reminderPreferences.js'; import {validateTeam} from '../src/services/teamData.js'; import { executeAnalysisJob } from './analysis-job.mjs'; import { resolveAiJobInput } from './ai-job-source.mjs'; import * as aiJobs from './ai-job-store.mjs'; import { startAiJobRunner } from './ai-job-runner.mjs'; import { observeTenders, tenderHistory } from './tender-history.mjs'; import { requestLifetime } from './request-lifetime.mjs'; import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs'; import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs'; 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 { createTelegramLinker } from './telegram-link.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, briefOutbox } from './brief.mjs'; import { belgeyeSor, teklifDenetle, teklifTaslak, ihaleMetni, gereklilikMetni } from './ai-teklif.mjs'; import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolveDelivery, 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(//gi, ' ') .replace(//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 }; const sourceTender=cache.tenders.find(t=>t.id===`ilan-${ilanId}`); if(sourceTender) { try { observeTenders([{...sourceTender,...detail,id:`ilan-${ilanId}:detail`}]); } catch { detail.sourceWarning='İlan alındı ancak kaynak geçmişi kaydedilemedi.'; } } 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', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', }); res.end(JSON.stringify(body)); } function readRaw(req, limit = 1024 * 1024) { return new Promise((resolve, reject) => { let buf = ''; let settled = false; const fail = (error) => { if (!settled) { settled = true; reject(error); } }; req.on('data', (c) => { if (settled) return; buf += c; if (Buffer.byteLength(buf, 'utf8') > limit) { fail(new Error('Gövde çok büyük')); req.destroy(); } }); req.on('end', () => { if (!settled) { settled = true; resolve(buf); } }); req.on('error', fail); }); } function readBody(req, limit = 100 * 1024) { return new Promise((resolve, reject) => { let data = ''; let settled = false; const fail = (error) => { if (!settled) { settled = true; reject(error); } }; req.on('data', (c) => { if (settled) return; data += c; if (Buffer.byteLength(data, 'utf8') > limit) { fail(new Error('gövde çok büyük')); req.destroy(); } }); req.on('end', () => { if (settled) return; try { const body = data ? JSON.parse(data) : {}; settled = true; resolve(body); } catch { fail(new Error('geçersiz JSON gövdesi')); } }); req.on('error', fail); }); } // /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]); let historyWarning=null; try { observeTenders(combined); } catch { historyWarning='Kaynak geçmişi kaydedilemedi; değişiklik arşivi eksik olabilir.'; }; 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, historyWarning, dtCount: dt.totalCount, historyCount: hist.totalCount, tenders: combined, }; } const aiJobRunner = startAiJobRunner(async (job,{signal}) => { if(!aiConfigured())throw new Error('AI hizmeti kullanılamıyor. Ayarlar düzeltildikten sonra yeniden başlatın.'); if(job.operation==='analiz')return executeAnalysisJob(job,{analyze:analyzeSpec,reviewSource:reviewedAnalysisSource,pin:aiJobs.pinAiJobInput,signal}); const body=await resolveAiJobInput(job,ihaleMetni,aiJobs.pinAiJobInput,signal,input=>gereklilikMetni(input.tender,input.gereklilikler,'',true)); const shared={signal,ikn:body.ikn,tender:body.tender,chain:body.chain,text:body.text,resolvedSource:body.resolvedSource}; const result=job.operation==='belge-sor' ? await belgeyeSor({...shared,soru:body.soru}) : job.operation==='teklif-denetim' ? await teklifDenetle({...shared,taslak:body.taslak,gereklilikler:body.gereklilikler}) : await teklifTaslak({...shared,tur:body.tur,firma:body.firma,belgeler:body.belgeler,fiyat:body.fiyat,gereklilikler:body.gereklilikler}); return {success:true,...result}; }); const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Authorization,Content-Type', 'Access-Control-Max-Age': '600', }); res.end(); return; } try { if (url.pathname === '/api/me/ai-jobs') { const user=userFromAuthHeader(req.headers.authorization); if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'}); const id=url.searchParams.get('id'); if(req.method==='GET') { if(id){const job=aiJobs.getAiJob(user.email,id);return json(res,job?200:404,{success:!!job,job,error:job?undefined:'İş bulunamadı.'});} return json(res,200,{success:true,jobs:aiJobs.listAiJobs(user.email,url.searchParams.get('ikn')).map(({result,...job})=>job)}); } if(req.method==='DELETE') { const ok=aiJobs.cancelAiJob(user.email,id);if(ok)aiJobRunner.cancel(id); return json(res,ok?200:404,{success:ok,error:ok?undefined:'İş bulunamadı veya artık iptal edilemiyor.'}); } if(req.method==='POST') { try { if(!aiConfigured())return json(res,503,{success:false,error:'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin.'}); const body=await readBody(req,2*1024*1024); if(id) {if(body.action!=='retry'||!aiJobs.retryAiJob(user.email,id))return json(res,404,{success:false,error:'Yeniden başlatılabilir iş bulunamadı.'});} const job=id?aiJobs.getAiJob(user.email,id):aiJobs.createAiJob(user.email,body.requestId,body.operation,body.input); aiJobRunner.wake();return json(res,202,{success:true,job}); }catch(error){return json(res,400,{success:false,error:error.message});} } return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); } 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, ...announcementFields(out.metin) }); } 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' }); let validatedDocument = null; try { const body = await readBody(req, 4 * 1024 * 1024); if (!body.ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); const sourceDocument = body.documentId && store.getDocument(user.email,String(body.documentId)); if (!sourceDocument || sourceDocument.tur !== 'ihale' || sourceDocument.ikn !== String(body.ikn)) return json(res,403,{success:false,error:'Kalem aktarımı için bu ihaleye bağlı, size ait ihale dokümanı gerekli. Firma belgeleri ortak havuza aktarılamaz.'}); validatedDocument = sourceDocument; if (typeof body.metin !== 'string') throw new Error('Doküman metni gerekli.'); const { kalemler, yontem } = kalemleriCikar(body.metin || ''); if (!kalemler.length) { store.setDocumentProcessing(user.email,sourceDocument.id,{status:'no_items',count:0}); 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, }); store.setDocumentProcessing(user.email,sourceDocument.id,{status:'processed',count:kalemler.length}); return json(res, 200, { success: true, kalemler, yontem, sayi: kalemler.length }); } catch (e) { if (validatedDocument) { try { store.setDocumentProcessing(user.email,validatedDocument.id,{status:'error',error:'Kalem çıkarımı tamamlanamadı. Doküman metnini kontrol edip yeniden deneyin.'}); } catch (statusError) { console.error('[document-processing] Durum kaydedilemedi:', statusError.message); } } 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' }); try { return json(res, 200, { success: true, ...kalemStore.kalemOku(ikn) }); } catch { return json(res,503,{success:false,error:'Kalem deposuna erişilemiyor. Daha sonra yeniden deneyin.'}); } } 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' }); try { const sonuc = kalemStore.kalemAra(q); return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc }); } catch { return json(res,503,{success:false,error:'Kalem araması şu anda kullanılamıyor. Yeniden deneyin.'}); } } // --- Ü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 === '/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' }); if(!telegramConfigured())return json(res,503,{success:false,error:'Telegram hizmeti etkin değil.'}); const receipt=await sendTelegram(tg.chatId,'✅ İhaleist bağlantı testi.',{parseMode:null,returnReceipt:true}); if(!receipt?.id)throw new Error('Telegram gönderim kabulü doğrulanamadı.'); return json(res,200,{success:true,id:receipt.id}); } const linker=createTelegramLinker(store); if(body.islem==='kod'){ if(!telegramConfigured())return json(res,503,{success:false,error:'Telegram hizmeti etkin değil.'}); return json(res,200,{success:true,...linker.issue(user.email)}); } const bulunan=await linker.link(user.email,body.kod,kodIleBagla); return json(res, 200, { success: true, ad: bulunan.ad }); } catch (e) { return json(res,e.kod==='VERSION_CONFLICT'?409:e.status||502,{success:false,error:e.message,kod:e.kod}); } } return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); } // --- 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') { try { const body = await readBody(req); return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) }); } catch (error) { return json(res, 400, { success: false, error: error.message }); } } if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); 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 documentType = body.documentType ?? 'firma'; if (!['firma','ihale'].includes(documentType)) throw new Error('Geçersiz belge türü.'); const metadata = documentMetadataPatch(body); const buf = decodeDocumentUpload(body.icerikBase64); 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; let readError = null; if (sunucuOkuyabilir(body.ad)) { try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { readError = 'Dosya metni okunamadı. Dosya biçimini ve bütünlüğünü kontrol edin.'; } if (documentType === 'ihale' && !ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni); } const id = store.saveDocumentFile(user.email, { ad: String(body.ad).slice(0, 200), kategori: metadata.kategori || null, mime: body.mime || null, sonKullanma: metadata.sonKullanma || null, tur: documentType, ikn, // ihaleye bağlı belge }, buf); // 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; let processing = documentType !== 'ihale' ? {status:'not_applicable'} : readError ? {status:'error',error:readError} : /\.(pdf|txt|md)$/i.test(body.ad) ? {status:'awaiting_text'} : !sunucuOkuyabilir(body.ad) ? {status:'unsupported'} : !ikn ? {status:'needs_tender'} : {status:'no_items',count:0}; if (documentType === 'ihale' && 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 }; processing = {status:'processed',count:kalemler.length}; console.log(`[kalem] ${ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`); } } catch (e) { processing = {status:'error',error:'Kalem çıkarımı tamamlanamadı; yeniden deneyin.'}; console.warn('[kalem] çıkarılamadı:', e.message); } } store.setDocumentProcessing(user.email,id,processing); return json(res, 200, { success: true, id, ikn, processing, 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 ok = store.updateDocument(user.email, String(body.id), documentMetadataPatch(body)); 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 }); } } if (url.pathname === '/api/me/documents/text-review/validate' && 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,8192); const resolved=reviewedAnalysisSource(user.email,body.review,body.ikn); if(body.review.fileHash!==resolved.humanReview.fileHash || body.review.textHash!==resolved.humanReview.textHash) return json(res,409,{success:false,error:'Analizin dayandığı metin incelemesi güncel değil. Analizi yenileyin.'}); return json(res,200,{success:true,review:resolved.humanReview}); } catch(error) {return json(res,error.status || 500,{success:false,error:error.message});} } if (url.pathname === '/api/me/documents/text-review') { const user=userFromAuthHeader(req.headers['authorization']); if(!user) return json(res,401,{success:false,error:'oturum yok'}); try { if(req.method === 'GET') return json(res,200,{success:true,...documentReview(user.email,url.searchParams.get('id'))}); if(req.method !== 'POST') return json(res,405,{success:false,error:'desteklenmeyen metot'}); const body=await readBody(req,1100*1024); if(typeof body.id !== 'string') return json(res,400,{success:false,error:'belge id gerekli'}); return json(res,200,{success:true,...saveDocumentReview(user.email,body.id,body)}); } catch(error) {return json(res,error.status || 500,{success:false,error:error.message});} } if (url.pathname === '/api/me/documents/read-error' && 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,8192); const rec = body.id && store.getDocument(user.email,String(body.id)); if (!rec || rec.tur !== 'ihale') return json(res,404,{success:false,error:'ihale dokümanı bulunamadı'}); if (!Object.hasOwn(body,'expectedProcessing')) return json(res,400,{success:false,error:'işleme sürümü gerekli'}); if (!store.reportDocumentReadError(user.email,rec.id,body.expectedProcessing)) return json(res,409,{success:false,error:'Belge işleme durumu değişti. Listeyi yeniden yükleyin.'}); return json(res,200,{success:true}); } 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, ...store.getDataSnapshot(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') { validateTeam(body.deger); kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin }); } if(k==='favori_kurumlar' && (!Array.isArray(body.deger) || body.deger.some(name=>typeof name!=='string'||!name.trim()||name.length>1000) || new Set(body.deger.map(name=>name.trim())).size!==body.deger.length)) return json(res,400,{success:false,error:'Favori kurum listesi geçersiz veya yinelenen kurum içeriyor.'}); if(k==='rakip_izleme' && (!Array.isArray(body.deger) || body.deger.some(item=>!item || typeof item.id!=='string'||!item.id||typeof item.name!=='string'||!item.name.trim()||item.name.length>1000||(item.sector!=null&&typeof item.sector!=='string')) || new Set(body.deger.map(item=>item.id)).size!==body.deger.length || new Set(body.deger.map(item=>item.name.trim().toLocaleLowerCase('tr-TR'))).size!==body.deger.length)) return json(res,400,{success:false,error:'Rakip listesi geçersiz veya yinelenen firma içeriyor.'}); if(k==='reminder')validateReminderPreferences(body.deger); if(k==='brief'&&typeof body.deger?.kapali!=='boolean')return json(res,400,{success:false,error:'Haftalık özet seçimi geçersiz.'}); const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme','reminder','brief'].includes(k); if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision))))) return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'}); if(k==='telegram')return json(res,403,{success:false,error:'Telegram bağlantısını doğrulama ekranından yönetin.'}); const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined); return json(res, 200, { success: true, ...(protectedKey ? {revision} : {}) }); } catch (e) { return json(res, e.kod === 'KOTA_ASILDI' ? 402 : e.kod === 'VERSION_CONFLICT' ? 409 : 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 body = await readBody(req); const k = store.createApiKey(user.email, body.ad, { limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari }); // `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, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari }); } if (req.method !== 'GET') return json(res, 405, { success: false, error: 'Desteklenmeyen metot.' }); 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.' }); let body; try { body = await readBody(req); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Geçersiz abonelik iptal gövdesi.'); if (body.neden != null && (typeof body.neden !== 'string' || body.neden.length > 100)) throw new Error('İptal nedeni geçersiz.'); if (body.aciklama != null && (typeof body.aciklama !== 'string' || body.aciklama.length > 500)) throw new Error('İptal açıklaması geçersiz.'); } catch (error) { return json(res, 400, { success: false, error: error.message }); } 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 rawEmail = o.client_reference_id || o.customer_email || o.metadata?.email || ''; const email = typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : ''; const rawPlanId = o.metadata?.planId; const candidatePlan = typeof rawPlanId === 'string' ? rawPlanId.trim() : ''; const planId = ['pro', 'kurumsal'].includes(candidatePlan) ? candidatePlan : 'ucretsiz'; 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) { if (o.current_period_end != null && (!Number.isSafeInteger(o.current_period_end) || o.current_period_end <= 0)) throw new Error('Stripe dönem sonu tarihi geçersiz.'); 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 hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.', }); } try { const body = await readBody(req, 2 * 1024 * 1024); const reviewed=body.reviewedSource ? reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn) : null; const sourceText=reviewed ? reviewed.text : body.text; if (!sourceText || String(sourceText).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: sourceText, fileName: reviewed?.fileName || body.fileName, extraction: reviewed ? {fileName:reviewed.fileName,complete:true,ocr:false} : body.extraction, tender: body.tender, chain: body.chain, mode: body.mode === 'derin' ? 'derin' : undefined, }); if (reviewed) { const latest=reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn); if(latest.humanReview.textHash!==reviewed.humanReview.textHash) throw Object.assign(new Error('İnceleme analiz sırasında değişti.'),{status:409}); out.result.sourceReview={...out.result.sourceReview,humanReview:reviewed.humanReview}; } return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() }); } catch (e) { return json(res, e.status || 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 }); } } if(url.pathname==='/api/admin/notify/outbox') { const user=userFromAuthHeader(req.headers.authorization); if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'}); if(!user.isAdmin)return json(res,403,{success:false,error:'Yönetici yetkisi gerekli.'}); try { if(req.method==='GET')return json(res,200,{success:true,deliveries:deliveryQueue()}); if(req.method==='POST'){const body=await readBody(req);return json(res,200,{success:true,delivery:resolveDelivery(body.id,user.email,body)});} return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); }catch(error){return json(res,error.status||400,{success:false,error:error.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')); if(!Number.isSafeInteger(id)||id<1)return json(res,400,{success:false,error:'Geçersiz abonelik kimliği.'}); const ok = removeSub(id, user.email); return json(res, ok ? 200 : 404, { success: ok, abonelikler: listSubs(user.email) }); } if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); 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') { const user = userFromAuthHeader(req.headers['authorization']); if (!user) return json(res, 401, { success: false, error: 'oturum gerekli' }); // 'from' gizli bilgi değil; hangi adresin kullanıldığını görünür kılar return json(res, 200, { success: true, 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); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Geçersiz e-posta test gövdesi.'); const tenders = body.tenders == null ? [] : body.tenders; if (!Array.isArray(tenders) || tenders.length > 100 || tenders.some(tender => !tender || typeof tender !== 'object' || Array.isArray(tender))) throw new Error('İhale listesi geçersiz veya çok büyük.'); const subject = body.subject == null ? 'İhaleist — bildirim testi' : body.subject; if (typeof subject !== 'string' || !subject.trim() || subject.length > 200) throw new Error('E-posta konusu geçersiz.'); const html = tenderDigestHtml({ name: user.name || user.email, tenders }); const { id } = await sendEmail({ to: user.email, subject: subject.trim(), 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); try { if(req.method==='POST'){ const body=await readBody(req); if(typeof body.kapali!=='boolean')return json(res,400,{success:false,error:'Haftalık özet seçimi geçersiz.'}); if(!Object.hasOwn(body,'revision')||(body.revision!==null&&(typeof body.revision!=='string'||!/^[a-f0-9]{64}$/.test(body.revision))))return json(res,428,{success:false,error:'Güncel özet ayarını yükleyin.'}); const revision=store.setData(user.email,'brief',{kapali:body.kapali},body.revision); return json(res,200,{success:true,kapali:body.kapali,revision}); } if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); let preview={}; if(url.searchParams.get('preview')==='1'){ const out=await tendersPayload(),b=briefOlustur(user.email,out.tenders); preview={brief:b,metin:briefMetni(b)}; } const snapshot=store.getDataSnapshot(user.email); return json(res,200,{success:true,kapali:!!snapshot.veri.brief?.kapali,revision:snapshot.revisions.brief ?? null,...preview}); }catch(e){return json(res,e.kod==='VERSION_CONFLICT'?409:400,{success:false,error:e.message,kod:e.kod});} } if(url.pathname==='/api/admin/brief/outbox') { const user=userFromAuthHeader(req.headers.authorization); if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'}); if(!user.isAdmin)return json(res,403,{success:false,error:'Yönetici yetkisi gerekli.'}); try { if(req.method==='GET')return json(res,200,{success:true,deliveries:briefOutbox.queue()}); if(req.method==='POST'){const body=await readBody(req);return json(res,200,{success:true,delivery:briefOutbox.resolve(body.id,user.email,body)});} return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); }catch(error){return json(res,error.status||400,{success:false,error:error.message});} } 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); return json(res, 200, { success: r.hata === 0, partial: r.hata > 0 && r.gonderilen > 0, ...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 hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.' }); const lifetime = requestLifetime(req,res); try { const body = await readBody(req, 2 * 1024 * 1024); const ortak = { signal:lifetime.signal, 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) { if (lifetime.signal.aborted) return; return json(res, e.attempts ? 502 : 400, { success: false, error: e.message }); } finally { lifetime.dispose(); } } if (url.pathname === '/api/tenders/source-history') { if (!userFromAuthHeader(req.headers.authorization)) return json(res,401,{success:false,error:'Oturum gerekli.'}); if (req.method !== 'GET') return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); const before=url.searchParams.get('before'); if(before && (!/^\d+$/.test(before) || !Number.isSafeInteger(Number(before)))) return json(res,400,{success:false,error:'Geçersiz sayfa.'}); try { return json(res,200,{success:true,...tenderHistory({ikn:url.searchParams.get('ikn'),kurum:url.searchParams.get('kurum')},{before:before?Number(before):null})}); } catch { return json(res,503,{success:false,error:'Kaynak geçmişi alınamadı. Yeniden deneyin.'}); } } 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() }); }); server.on('close',()=>aiJobRunner.stop());