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 @@
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);