From a0bef3baa323253702d718e24e0d2e01d6e1c296 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 30 Jul 2026 13:54:34 +0200 Subject: [PATCH] Add clustered bot visitor filter --- public/admin.html | 9 +++++++ public/admin.js | 48 ++++++++++++++++++++++++--------- server.js | 68 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 107 insertions(+), 18 deletions(-) diff --git a/public/admin.html b/public/admin.html index 2acc23e..2738339 100644 --- a/public/admin.html +++ b/public/admin.html @@ -184,6 +184,7 @@ Signalizace navstevniku

Zelena znamena aktivni. Po necinosti se prepne na oranzovou a po dalsim limitu na sedou.

+
+
+ + +
Oranzova je cas mezi zelenou a sedou. diff --git a/public/admin.js b/public/admin.js index a614072..02bc076 100644 --- a/public/admin.js +++ b/public/admin.js @@ -74,6 +74,9 @@ $("#settingsButton").addEventListener("click", () => $("#settingsPanel").classLi $("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden")); $("#presenceActiveSeconds").addEventListener("input", markPresenceSettingsDirty); $("#presenceOfflineSeconds").addEventListener("input", markPresenceSettingsDirty); +$("#clusterBotFilterEnabled").addEventListener("change", markPresenceSettingsDirty); +$("#clusterBotCount").addEventListener("input", markPresenceSettingsDirty); +$("#clusterBotWindowSeconds").addEventListener("input", markPresenceSettingsDirty); $("#savePresenceSettings").addEventListener("click", savePresenceSettings); $("#adminSoundToggle").addEventListener("click", () => { setAdminSoundMuted(!state.soundMuted); @@ -766,36 +769,57 @@ function renderPresenceSettings(settings) { const normalized = normalizePresenceSettings(settings); $("#presenceActiveSeconds").value = normalized.activeSeconds; $("#presenceOfflineSeconds").value = normalized.offlineSeconds; - setPresenceStatus(`Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s necinosti.`); + $("#clusterBotFilterEnabled").checked = normalized.clusterBotFilterEnabled; + $("#clusterBotCount").value = normalized.clusterBotCount; + $("#clusterBotWindowSeconds").value = normalized.clusterBotWindowSeconds; + setPresenceStatus(presenceStatusText(normalized, "Oranzova")); } function markPresenceSettingsDirty() { - const normalized = normalizePresenceSettings({ - activeSeconds: $("#presenceActiveSeconds").value, - offlineSeconds: $("#presenceOfflineSeconds").value - }); - setPresenceStatus(`Neulozeno. Oranzova bude ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`); + const normalized = currentPresenceSettingsFromForm(); + setPresenceStatus(presenceStatusText(normalized, "Neulozeno. Oranzova")); } async function savePresenceSettings() { if (!state.site) return; - const normalized = normalizePresenceSettings({ - activeSeconds: $("#presenceActiveSeconds").value, - offlineSeconds: $("#presenceOfflineSeconds").value - }); + const normalized = currentPresenceSettingsFromForm(); setPresenceStatus("Ukladam signalizaci..."); const settings = structuredClone(state.site.settings); settings.adminPresence = normalized; await saveSite({ settings, isOnline: state.site.isOnline }); renderPresenceSettings(state.site.settings.adminPresence || normalized); + state.visitorRenderKey = ""; await Promise.all([loadConversations(), loadVisitors()]); - setPresenceStatus(`Ulozeno. Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`); + setPresenceStatus(presenceStatusText(normalized, "Ulozeno. Oranzova")); } function normalizePresenceSettings(value = {}) { const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30); const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10); - return { activeSeconds, offlineSeconds }; + return { + activeSeconds, + offlineSeconds, + clusterBotFilterEnabled: value.clusterBotFilterEnabled !== false, + clusterBotCount: clampNumber(value.clusterBotCount, 3, 500, 20), + clusterBotWindowSeconds: clampNumber(value.clusterBotWindowSeconds, 10, 3600, 60) + }; +} + +function currentPresenceSettingsFromForm() { + return normalizePresenceSettings({ + activeSeconds: $("#presenceActiveSeconds").value, + offlineSeconds: $("#presenceOfflineSeconds").value, + clusterBotFilterEnabled: $("#clusterBotFilterEnabled").checked, + clusterBotCount: $("#clusterBotCount").value, + clusterBotWindowSeconds: $("#clusterBotWindowSeconds").value + }); +} + +function presenceStatusText(settings, prefix) { + const cluster = settings.clusterBotFilterEnabled + ? ` Hromadny filtr: ${settings.clusterBotCount} navstev / ${settings.clusterBotWindowSeconds} s.` + : " Hromadny filtr vypnuty."; + return `${prefix}: ${settings.activeSeconds}-${settings.offlineSeconds} s necinosti.${cluster}`; } function clampNumber(value, min, max, fallback) { diff --git a/server.js b/server.js index 5761863..dc02b7b 100644 --- a/server.js +++ b/server.js @@ -363,7 +363,10 @@ function defaultSettings() { }, adminPresence: { activeSeconds: 30, - offlineSeconds: 120 + offlineSeconds: 120, + clusterBotFilterEnabled: true, + clusterBotCount: 20, + clusterBotWindowSeconds: 60 } }; } @@ -1482,7 +1485,13 @@ function mergeSiteSettings(currentSettings, incomingSettings) { function normalizeAdminPresenceSettings(value = {}) { const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30); const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10); - return { activeSeconds, offlineSeconds }; + return { + activeSeconds, + offlineSeconds, + clusterBotFilterEnabled: value.clusterBotFilterEnabled !== false, + clusterBotCount: clampNumber(value.clusterBotCount, 3, 500, 20), + clusterBotWindowSeconds: clampNumber(value.clusterBotWindowSeconds, 10, 3600, 60) + }; } function adminPresenceSettings() { @@ -1829,10 +1838,11 @@ function currentVisitors() { ORDER BY datetime(vs.first_seen_at) ASC, vs.id ASC LIMIT 100 `).all(); - return rows.map((row) => formatVisitorSession(row, presence)); + const clusters = suspiciousVisitorClusters(rows, presence); + return rows.map((row) => formatVisitorSession(row, presence, clusters)); } -function formatVisitorSession(row, thresholds = adminPresenceSettings()) { +function formatVisitorSession(row, thresholds = adminPresenceSettings(), clusters = new Map()) { const history = parseJson(row.browsing_history_json, []); const device = parseJson(row.device_json, {}); const pageCountValue = new Set(history.map((item) => item.url).filter(Boolean)).size; @@ -1845,6 +1855,8 @@ function formatVisitorSession(row, thresholds = adminPresenceSettings()) { sessionSeconds: sessionSecondsValue, referrer: row.referrer }); + const clusterReason = clusters.get(visitorClusterKey(row, device)); + const finalBot = clusterReason && conversationCountValue === 0 ? { isBot: true, reason: clusterReason } : bot; const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language); const presence = visitorPresence(row, thresholds); return { @@ -1862,8 +1874,8 @@ function formatVisitorSession(row, thresholds = adminPresenceSettings()) { visitCount: Math.max(1, history.length), conversationCount: conversationCountValue, lastConversationId: row.last_conversation_id, - isBot: bot.isBot, - botReason: bot.reason, + isBot: finalBot.isBot, + botReason: finalBot.reason, device, language: row.language, timezone: row.timezone, @@ -1884,6 +1896,50 @@ function visitorPresence(row, thresholds = adminPresenceSettings()) { return Number.isFinite(lastActivity) && Date.now() - lastActivity <= thresholds.activeSeconds * 1000 ? "active" : "idle"; } +function suspiciousVisitorClusters(rows, settings) { + if (!settings.clusterBotFilterEnabled) return new Map(); + const groups = new Map(); + for (const row of rows) { + if (Number(row.visitor_conversation_count || 0) > 0) continue; + if (String(row.referrer || "").trim()) continue; + const device = parseJson(row.device_json, {}); + const key = visitorClusterKey(row, device); + if (!key) continue; + const firstSeen = parseTimestampMs(row.first_seen_at); + if (!Number.isFinite(firstSeen)) continue; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(firstSeen); + } + const suspicious = new Map(); + const windowMs = settings.clusterBotWindowSeconds * 1000; + for (const [key, times] of groups.entries()) { + times.sort((a, b) => a - b); + let start = 0; + for (let end = 0; end < times.length; end += 1) { + while (times[end] - times[start] > windowMs) start += 1; + if (end - start + 1 >= settings.clusterBotCount) { + suspicious.set(key, `cluster ${end - start + 1}/${settings.clusterBotWindowSeconds}s`); + break; + } + } + } + return suspicious; +} + +function visitorClusterKey(row, device = {}) { + const ip = String(row.ip || "").trim(); + if (!ip) return ""; + const country = String(row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language) || "").toUpperCase(); + const platform = String(device.platform || "").trim(); + const ua = String(device.userAgent || "").trim(); + const browser = ua.includes("Edg/") ? "Edge" + : ua.includes("Chrome/") ? "Chrome" + : ua.includes("Firefox/") ? "Firefox" + : ua.includes("Safari/") ? "Safari" + : ua.slice(0, 80); + return [ip, country, platform, browser].join("|"); +} + function conversationDetails(conversation) { const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id); const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);