Files
ihaleist/server/notifier.mjs
T
hikmettan f73c7bd6cb feat: konu bazlı bildirim + gerçek site tarama
1) Bildirim artık KONU odaklı (il değil)
   - Kullanıcı hangi konularda ihale alabildiğini seçer; varsayılan tüm Türkiye
   - İl/alım türü/favori kurum 'daha da daralt' altında isteğe bağlı
   - notify-store: konular kolonu + otomatik şema geçişi
   - notifier: konu başlıkları ihale başlığı/sektör/branş/kalemlerde aranır

2) Gerçek firma sitesi tarama (webScraperService mock'unun yerine)
   - server/site-scan.mjs: siteyi GERÇEKTEN çeker, ana sayfa + ürün/hizmet/
     hakkımızda iç sayfalarını tarar, HTML'i metne çevirir, AI ile firmanın
     tedarik edebildiği ihale konularını çıkarır ('uydurma' yasaklı prompt)
   - /api/company/scan (auth'lu)
   - ai.mjs: systemOverride desteği (farklı görevler için ayrı sistem promptu)
   - Arayüz: 'Sitenizi tarayıp konuları otomatik önerelim' → tıklayarak ekleme

Not: eski webScraperService.js sabit anahtar-kelime sözlüğüydü; domain adında
'tanriverdi' geçince tıbbi ürün listesi uyduruyordu.
2026-08-29 10:19:44 +03:00

109 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur
* ve özet e-posta gönderir.
*
* Env:
* NOTIFY_ENABLED=0 → kapat
* NOTIFY_INTERVAL_SAAT=6 → kontrol sıklığı
* NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale
*/
import { list, markSent, available } from './notify-store.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
/** Bir ihale, aboneliğin kriterlerine uyuyor mu? */
export function eslesiyorMu(t, a) {
if (a.sehir && norm(t.sehir) !== norm(a.sehir)) return false;
if (a.sektor && norm(t.sektor) !== norm(a.sektor)) return false;
if (a.alimTuru && !norm(t.alimTuru).includes(norm(a.alimTuru))) return false;
if (a.anahtar) {
const q = norm(a.anahtar);
const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
if (!alan.includes(q)) return false;
}
// Konu başlıkları: herhangi biri ihale metninde geçiyorsa eşleşir
if (a.konular?.length) {
const alan = norm(`${t.baslik || ''} ${t.sektor || ''} ${t.brans || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
if (!a.konular.some((k) => alan.includes(norm(k)))) return false;
}
if (a.kurumlar?.length) {
const kurum = norm(t.kurum);
if (!a.kurumlar.some((k) => norm(k) === kurum)) return false;
}
return true;
}
/** Sonuçlanmış/arşiv kayıtları bildirime girmez — yalnızca teklif verilebilir ilanlar. */
function bildirilebilir(t) {
if (t.durum === 'Sonuçlandı') return false;
if (String(t.id || '').startsWith('hist-')) return false;
return true;
}
/**
* Tüm aktif abonelikleri işler.
* @param {Function} tenderGetir () => Promise<{tenders: []}>
*/
export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) {
if (process.env.NOTIFY_ENABLED === '0') return { atlandi: 'kapalı' };
if (!available()) return { atlandi: 'depo yok' };
if (!emailConfigured()) return { atlandi: 'e-posta yapılandırılmamış' };
const abonelikler = list();
if (!abonelikler.length) return { abonelik: 0 };
const veri = await tenderGetir();
const tumu = (veri.tenders || []).filter(bildirilebilir);
const maxAdet = Number(process.env.NOTIFY_MAX || 25);
let gonderilen = 0;
let hata = 0;
for (const a of abonelikler) {
try {
const gorulen = new Set(a.sonGorulenler || []);
const eslesen = tumu.filter((t) => eslesiyorMu(t, a));
const yeniler = eslesen.filter((t) => !gorulen.has(t.id));
// İlk kurulumda geçmişi spam'lemeyelim: sadece işaretle, gönderme.
// zorla=true ise (yönetici "şimdi gönder" dediğinde) bu adım atlanır.
if (!a.sonGonderim && !zorla) {
markSent(a.id, eslesen.map((t) => t.id));
continue;
}
const gonderilecek = zorla && !yeniler.length ? eslesen.slice(0, maxAdet) : yeniler;
if (!gonderilecek.length) continue;
const kriterMetni = [
a.sehir && `İl: ${a.sehir}`,
a.sektor && `Sektör: ${a.sektor}`,
a.alimTuru && `Tür: ${a.alimTuru}`,
a.anahtar && `Anahtar: ${a.anahtar}`,
a.konular?.length && `Konular: ${a.konular.slice(0, 4).join(', ')}${a.konular.length > 4 ? '…' : ''}`,
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
const html = tenderDigestHtml({ name: a.ad || a.email, tenders: gonderilecek.slice(0, maxAdet) })
+ `<p style="font-family:Arial,sans-serif;font-size:11px;color:#8b93a4;max-width:640px;margin:12px auto 0">
Kriteriniz: ${kriterMetni} — toplam ${gonderilecek.length} ihale.
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
</p>`;
await sendEmail({
to: a.email,
subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`,
html,
});
markSent(a.id, eslesen.map((t) => t.id));
gonderilen++;
console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`);
} catch (e) {
hata++;
console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`);
}
}
return { abonelik: abonelikler.length, gonderilen, hata };
}