feat: doküman metninden sektör zenginleştirme — başlığı sinyalsiz ilanlar ilan metninden sınıflandırılıyor (kalıcı önbellekli)

This commit is contained in:
2026-08-30 01:39:46 +03:00
parent a6a421d745
commit 495118ef9e
2 changed files with 164 additions and 1 deletions
+84
View File
@@ -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_il ON sonuclar(il);
CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi); 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 ( CREATE TABLE IF NOT EXISTS havuz_olcum (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
zaman TEXT NOT NULL, zaman TEXT NOT NULL,
@@ -416,3 +423,80 @@ export function havuzOzet() {
db.close(); 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();
}
}
+80 -1
View File
@@ -25,7 +25,8 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio
import * as store from './user-store.mjs'; import * as store from './user-store.mjs';
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
import { searchTed } from './ted.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 { startScheduler, schedulerStatus } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs'; import { searchKik, kararMetni } from './kik.mjs';
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.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`); console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`);
havuzKaydet(numFound, cache.tenders.length); havuzKaydet(numFound, cache.tenders.length);
zenginUygula(); // önceden çözülmüşleri hemen uygula
setTimeout(() => sektorZenginlestir().catch(() => {}), 5000); // kalanları arka planda
} catch (e) { } catch (e) {
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message); console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
} finally { } 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. // Havuz büyüklüğünü kaydeder; ani düşüş / eksik yükleme varsa yöneticiyi uyarır.
let sonHavuzUyarisi = 0; let sonHavuzUyarisi = 0;
function dtSayisiGuvenli() { 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') 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') { if (url.pathname === '/api/meta/filtreler') {
return json(res, 200, { return json(res, 200, {
success: true, success: true,