Files
ihaleist/server/site-scan.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

136 lines
4.9 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.
/**
* Gerçek firma web sitesi tarama + AI ile yetkinlik çıkarımı.
*
* Eski webScraperService.js sabit bir anahtar-kelime sözlüğüydü (domainde
* "medikal" geçince tıbbi ürün listesi uyduruyordu). Bu modül siteyi
* GERÇEKTEN çeker, metnini çıkarır ve AI ile ne tedarik edebildiğini belirler.
*
* Bağımlılık yok (Node 18+ fetch).
*/
import { analyzeSpec, aiConfigured } from './ai.mjs';
const UA = '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';
const MAX_BYTE = 1.5 * 1024 * 1024;
const ENT = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
function htmlToText(html) {
return String(html || '')
.replace(/<\s*(script|style|noscript|svg)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<\s*\/\s*(p|div|li|h[1-6]|tr|section)\s*>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&[a-z#0-9]+;/gi, (m) => ENT[m.toLowerCase()] ?? ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function normalizeUrl(input) {
let u = String(input || '').trim();
if (!u) throw new Error('Adres gerekli');
if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
const url = new URL(u);
if (!/^https?:$/.test(url.protocol)) throw new Error('Yalnızca http/https desteklenir');
return url;
}
async function fetchPage(url, timeout = 20000) {
const res = await fetch(url, {
headers: { 'User-Agent': UA, 'Accept-Language': 'tr,en;q=0.8', Accept: 'text/html,application/xhtml+xml' },
redirect: 'follow',
signal: AbortSignal.timeout(timeout),
});
if (!res.ok) throw new Error(`Sayfa alınamadı (HTTP ${res.status})`);
const tip = res.headers.get('content-type') || '';
if (!/text\/html|application\/xhtml/i.test(tip)) throw new Error('Adres bir web sayfası değil');
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_BYTE) throw new Error('Sayfa çok büyük');
return new TextDecoder('utf-8').decode(buf);
}
// Ana sayfadan iç sayfa adaylarını çıkar (hakkımızda, ürünler, hizmetler…)
const IC_SAYFA = /(urun|ürün|hizmet|product|service|hakk|about|katalog|cozum|çözüm|referans|belge|sertifika)/i;
function icLinkler(html, base) {
const out = new Set();
const re = /<a[^>]+href=["']([^"'#]+)["']/gi;
let m;
while ((m = re.exec(html)) && out.size < 40) {
try {
const u = new URL(m[1], base);
if (u.hostname !== new URL(base).hostname) continue;
if (!IC_SAYFA.test(u.pathname)) continue;
u.hash = '';
out.add(u.href);
} catch { /* geçersiz link */ }
}
return [...out].slice(0, 4);
}
const SISTEM = `Sen Türkiye kamu ihalelerine katılan firmaların yetkinliklerini belirleyen bir analistsin.
Sana bir firmanın web sitesinden çıkarılmış metin verilecek.
YALNIZCA geçerli JSON döndür, başka metin yazma. Şema:
{
"firmaAdi": "sitede geçen firma adı veya null",
"ozet": "firmanın ne yaptığına dair 1-2 cümle",
"sektorler": ["firmanın faaliyet gösterdiği sektörler"],
"urunler": ["tedarik edebildiği somut ürün/hizmetler"],
"sertifikalar": ["sitede belirtilen belge/sertifikalar"],
"ihaleKonulari": ["bu firmanın teklif verebileceği ihale konu başlıkları — kısa, aranabilir terimler"]
}
KURALLAR:
- Yalnızca metinde GERÇEKTEN geçen bilgileri yaz. Uydurma.
- Metinde yoksa boş dizi veya null bırak.
- ihaleKonulari en fazla 12 madde, her biri 1-4 kelime, ihale ilanlarında aranabilecek terimler olsun
(ör. "tıbbi sarf malzeme", "iş güvenliği ekipmanı", "temizlik kimyasalı").`;
/**
* Siteyi çeker ve AI ile yetkinlik çıkarır.
* @returns {{url, tarananSayfa, karakter, sonuc}}
*/
export async function scanSite(inputUrl) {
if (!aiConfigured()) {
throw new Error('Site analizi için AI yapılandırılmamış (OPENROUTER_API_KEY gerekli).');
}
const url = normalizeUrl(inputUrl);
const ana = await fetchPage(url.href);
const sayfalar = [{ url: url.href, metin: htmlToText(ana) }];
// Birkaç iç sayfayı da tara (ürün/hizmet sayfaları en bilgilendirici)
for (const link of icLinkler(ana, url.href)) {
try {
const html = await fetchPage(link, 15000);
const metin = htmlToText(html);
if (metin.length > 200) sayfalar.push({ url: link, metin });
} catch { /* iç sayfa atlanabilir */ }
}
const birlesik = sayfalar
.map((s) => `--- ${s.url} ---\n${s.metin.slice(0, 12000)}`)
.join('\n\n')
.slice(0, 45000);
if (birlesik.replace(/\s/g, '').length < 200) {
throw new Error('Siteden yeterli metin çıkarılamadı (JavaScript ile yüklenen bir site olabilir).');
}
const out = await analyzeSpec({
text: birlesik,
fileName: url.hostname,
tender: null,
chain: null,
systemOverride: SISTEM,
});
return {
url: url.href,
tarananSayfa: sayfalar.length,
sayfalar: sayfalar.map((s) => s.url),
karakter: birlesik.length,
model: out.model,
sonuc: out.result,
};
}