diff --git a/server/history-store.mjs b/server/history-store.mjs index 6d288cd..fad951e 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -50,6 +50,13 @@ CREATE INDEX IF NOT EXISTS idx_idare ON sonuclar(idare_adi); CREATE INDEX IF NOT EXISTS idx_il ON sonuclar(il); CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi); +CREATE TABLE IF NOT EXISTS sektor_zengin ( + ilan_id TEXT PRIMARY KEY, -- ör. ilan-12345 + sektor TEXT NOT NULL, -- doküman metninden çıkarılan sektör + kaynak TEXT, -- 'dokuman' | 'okas' + zaman TEXT +); + CREATE TABLE IF NOT EXISTS havuz_olcum ( id INTEGER PRIMARY KEY AUTOINCREMENT, zaman TEXT NOT NULL, @@ -416,3 +423,80 @@ export function havuzOzet() { db.close(); } } + + +// --- Sektör zenginleştirme (doküman metninden) ----------------------------- +// Başlığı sinyalsiz ihaleler ("MUHTELİF MALZEME ALIMI") ilan metninden +// sınıflandırılır. Sonuç kalıcı saklanır: aynı ilan bir daha indirilmez. + +/** id → sektör haritası. Yalnızca istenen id'ler okunur. */ +export function sektorZenginOku(idler) { + const db = openDb(); + if (!db || !idler?.length) return new Map(); + try { + const harita = new Map(); + // SQLite parametre sınırına takılmamak için parçalara böl. + for (let i = 0; i < idler.length; i += 400) { + const parca = idler.slice(i, i + 400); + const yer = parca.map(() => '?').join(','); + const satirlar = db + .prepare(`SELECT ilan_id, sektor FROM sektor_zengin WHERE ilan_id IN (${yer})`) + .all(...parca); + for (const r of satirlar) harita.set(r.ilan_id, r.sektor); + } + return harita; + } finally { + db.close(); + } +} + +export function sektorZenginYaz(kayitlar) { + const db = openDb({ create: true }); + if (!db || !kayitlar?.length) return 0; + try { + const st = db.prepare( + `INSERT INTO sektor_zengin (ilan_id, sektor, kaynak, zaman) VALUES (?,?,?,?) + ON CONFLICT(ilan_id) DO UPDATE SET sektor=excluded.sektor, kaynak=excluded.kaynak, zaman=excluded.zaman` + ); + const now = new Date().toISOString(); + let n = 0; + for (const k of kayitlar) { + if (!k?.id || !k?.sektor) continue; + st.run(k.id, k.sektor, k.kaynak || 'dokuman', now); + n++; + } + return n; + } finally { + db.close(); + } +} + +/** Hangi id'ler daha önce denenmiş? ('Genel' kaydı da denenmiş sayılır) */ +export function sektorZenginDenenmis(idler) { + const db = openDb(); + if (!db || !idler?.length) return new Set(); + try { + const küme = new Set(); + for (let i = 0; i < idler.length; i += 400) { + const parca = idler.slice(i, i + 400); + const yer = parca.map(() => '?').join(','); + for (const r of db.prepare(`SELECT ilan_id FROM sektor_zengin WHERE ilan_id IN (${yer})`).all(...parca)) + küme.add(r.ilan_id); + } + return küme; + } finally { + db.close(); + } +} + +export function sektorZenginSayi() { + const db = openDb(); + if (!db) return { toplam: 0, cozulen: 0 }; + try { + const t = db.prepare("SELECT COUNT(*) c FROM sektor_zengin").get().c; + const c = db.prepare("SELECT COUNT(*) c FROM sektor_zengin WHERE sektor <> 'Genel'").get().c; + return { toplam: t, cozulen: c }; + } finally { + db.close(); + } +} diff --git a/server/index.js b/server/index.js index c09b198..c881b47 100644 --- a/server/index.js +++ b/server/index.js @@ -25,7 +25,8 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio import * as store from './user-store.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { searchTed } from './ted.mjs'; -import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet } from './history-store.mjs'; +import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet, + sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi } from './history-store.mjs'; import { startScheduler, schedulerStatus } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; @@ -299,6 +300,8 @@ async function arkaPlandaTamamla(numFound, ilkListe) { } 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 } catch (e) { console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message); } finally { @@ -306,6 +309,67 @@ async function arkaPlandaTamamla(numFound, ilkListe) { } } +// --- 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-/, '')); + const sek = sektorBul(t.baslik || '', det.aciklama || ''); + sonuc.push({ id: t.id, sektor: sek }); + } catch (e) { + // Metin alınamadıysa 'Genel' yaz — sonsuz yeniden deneme olmasın. + sonuc.push({ id: t.id, sektor: 'Genel' }); + } + })); + // 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() { @@ -939,6 +1003,21 @@ const server = http.createServer(async (req, res) => { } 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,