Add clustered bot visitor filter
This commit is contained in:
@@ -184,6 +184,7 @@
|
|||||||
<legend>Signalizace navstevniku</legend>
|
<legend>Signalizace navstevniku</legend>
|
||||||
<p class="ai-help">Zelena znamena aktivni. Po necinosti se prepne na oranzovou a po dalsim limitu na sedou.</p>
|
<p class="ai-help">Zelena znamena aktivni. Po necinosti se prepne na oranzovou a po dalsim limitu na sedou.</p>
|
||||||
<label class="setting-switch full-row">Skryt boty <input id="hideBotsToggle" type="checkbox"></label>
|
<label class="setting-switch full-row">Skryt boty <input id="hideBotsToggle" type="checkbox"></label>
|
||||||
|
<label class="setting-switch full-row">Hromadny bot filtr <input id="clusterBotFilterEnabled" type="checkbox"></label>
|
||||||
<div class="settings-top-row">
|
<div class="settings-top-row">
|
||||||
<label>Zelena do
|
<label>Zelena do
|
||||||
<span class="seconds-field"><input id="presenceActiveSeconds" type="number" min="10" max="3600" step="5"><span>s</span></span>
|
<span class="seconds-field"><input id="presenceActiveSeconds" type="number" min="10" max="3600" step="5"><span>s</span></span>
|
||||||
@@ -192,6 +193,14 @@
|
|||||||
<span class="seconds-field"><input id="presenceOfflineSeconds" type="number" min="20" max="7200" step="5"><span>s</span></span>
|
<span class="seconds-field"><input id="presenceOfflineSeconds" type="number" min="20" max="7200" step="5"><span>s</span></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-top-row">
|
||||||
|
<label>Pocet navstev
|
||||||
|
<input id="clusterBotCount" type="number" min="3" max="500" step="1">
|
||||||
|
</label>
|
||||||
|
<label>Casove okno
|
||||||
|
<span class="seconds-field"><input id="clusterBotWindowSeconds" type="number" min="10" max="3600" step="10"><span>s</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<small id="presenceStatus" class="full-row">Oranzova je cas mezi zelenou a sedou.</small>
|
<small id="presenceStatus" class="full-row">Oranzova je cas mezi zelenou a sedou.</small>
|
||||||
<button id="savePresenceSettings" class="full-row" type="button">Ulozit signalizaci</button>
|
<button id="savePresenceSettings" class="full-row" type="button">Ulozit signalizaci</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
+36
-12
@@ -74,6 +74,9 @@ $("#settingsButton").addEventListener("click", () => $("#settingsPanel").classLi
|
|||||||
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
|
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
|
||||||
$("#presenceActiveSeconds").addEventListener("input", markPresenceSettingsDirty);
|
$("#presenceActiveSeconds").addEventListener("input", markPresenceSettingsDirty);
|
||||||
$("#presenceOfflineSeconds").addEventListener("input", markPresenceSettingsDirty);
|
$("#presenceOfflineSeconds").addEventListener("input", markPresenceSettingsDirty);
|
||||||
|
$("#clusterBotFilterEnabled").addEventListener("change", markPresenceSettingsDirty);
|
||||||
|
$("#clusterBotCount").addEventListener("input", markPresenceSettingsDirty);
|
||||||
|
$("#clusterBotWindowSeconds").addEventListener("input", markPresenceSettingsDirty);
|
||||||
$("#savePresenceSettings").addEventListener("click", savePresenceSettings);
|
$("#savePresenceSettings").addEventListener("click", savePresenceSettings);
|
||||||
$("#adminSoundToggle").addEventListener("click", () => {
|
$("#adminSoundToggle").addEventListener("click", () => {
|
||||||
setAdminSoundMuted(!state.soundMuted);
|
setAdminSoundMuted(!state.soundMuted);
|
||||||
@@ -766,36 +769,57 @@ function renderPresenceSettings(settings) {
|
|||||||
const normalized = normalizePresenceSettings(settings);
|
const normalized = normalizePresenceSettings(settings);
|
||||||
$("#presenceActiveSeconds").value = normalized.activeSeconds;
|
$("#presenceActiveSeconds").value = normalized.activeSeconds;
|
||||||
$("#presenceOfflineSeconds").value = normalized.offlineSeconds;
|
$("#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() {
|
function markPresenceSettingsDirty() {
|
||||||
const normalized = normalizePresenceSettings({
|
const normalized = currentPresenceSettingsFromForm();
|
||||||
activeSeconds: $("#presenceActiveSeconds").value,
|
setPresenceStatus(presenceStatusText(normalized, "Neulozeno. Oranzova"));
|
||||||
offlineSeconds: $("#presenceOfflineSeconds").value
|
|
||||||
});
|
|
||||||
setPresenceStatus(`Neulozeno. Oranzova bude ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePresenceSettings() {
|
async function savePresenceSettings() {
|
||||||
if (!state.site) return;
|
if (!state.site) return;
|
||||||
const normalized = normalizePresenceSettings({
|
const normalized = currentPresenceSettingsFromForm();
|
||||||
activeSeconds: $("#presenceActiveSeconds").value,
|
|
||||||
offlineSeconds: $("#presenceOfflineSeconds").value
|
|
||||||
});
|
|
||||||
setPresenceStatus("Ukladam signalizaci...");
|
setPresenceStatus("Ukladam signalizaci...");
|
||||||
const settings = structuredClone(state.site.settings);
|
const settings = structuredClone(state.site.settings);
|
||||||
settings.adminPresence = normalized;
|
settings.adminPresence = normalized;
|
||||||
await saveSite({ settings, isOnline: state.site.isOnline });
|
await saveSite({ settings, isOnline: state.site.isOnline });
|
||||||
renderPresenceSettings(state.site.settings.adminPresence || normalized);
|
renderPresenceSettings(state.site.settings.adminPresence || normalized);
|
||||||
|
state.visitorRenderKey = "";
|
||||||
await Promise.all([loadConversations(), loadVisitors()]);
|
await Promise.all([loadConversations(), loadVisitors()]);
|
||||||
setPresenceStatus(`Ulozeno. Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`);
|
setPresenceStatus(presenceStatusText(normalized, "Ulozeno. Oranzova"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePresenceSettings(value = {}) {
|
function normalizePresenceSettings(value = {}) {
|
||||||
const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30);
|
const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30);
|
||||||
const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10);
|
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) {
|
function clampNumber(value, min, max, fallback) {
|
||||||
|
|||||||
@@ -363,7 +363,10 @@ function defaultSettings() {
|
|||||||
},
|
},
|
||||||
adminPresence: {
|
adminPresence: {
|
||||||
activeSeconds: 30,
|
activeSeconds: 30,
|
||||||
offlineSeconds: 120
|
offlineSeconds: 120,
|
||||||
|
clusterBotFilterEnabled: true,
|
||||||
|
clusterBotCount: 20,
|
||||||
|
clusterBotWindowSeconds: 60
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1482,7 +1485,13 @@ function mergeSiteSettings(currentSettings, incomingSettings) {
|
|||||||
function normalizeAdminPresenceSettings(value = {}) {
|
function normalizeAdminPresenceSettings(value = {}) {
|
||||||
const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30);
|
const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30);
|
||||||
const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10);
|
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() {
|
function adminPresenceSettings() {
|
||||||
@@ -1829,10 +1838,11 @@ function currentVisitors() {
|
|||||||
ORDER BY datetime(vs.first_seen_at) ASC, vs.id ASC
|
ORDER BY datetime(vs.first_seen_at) ASC, vs.id ASC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
`).all();
|
`).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 history = parseJson(row.browsing_history_json, []);
|
||||||
const device = parseJson(row.device_json, {});
|
const device = parseJson(row.device_json, {});
|
||||||
const pageCountValue = new Set(history.map((item) => item.url).filter(Boolean)).size;
|
const pageCountValue = new Set(history.map((item) => item.url).filter(Boolean)).size;
|
||||||
@@ -1845,6 +1855,8 @@ function formatVisitorSession(row, thresholds = adminPresenceSettings()) {
|
|||||||
sessionSeconds: sessionSecondsValue,
|
sessionSeconds: sessionSecondsValue,
|
||||||
referrer: row.referrer
|
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 countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
|
||||||
const presence = visitorPresence(row, thresholds);
|
const presence = visitorPresence(row, thresholds);
|
||||||
return {
|
return {
|
||||||
@@ -1862,8 +1874,8 @@ function formatVisitorSession(row, thresholds = adminPresenceSettings()) {
|
|||||||
visitCount: Math.max(1, history.length),
|
visitCount: Math.max(1, history.length),
|
||||||
conversationCount: conversationCountValue,
|
conversationCount: conversationCountValue,
|
||||||
lastConversationId: row.last_conversation_id,
|
lastConversationId: row.last_conversation_id,
|
||||||
isBot: bot.isBot,
|
isBot: finalBot.isBot,
|
||||||
botReason: bot.reason,
|
botReason: finalBot.reason,
|
||||||
device,
|
device,
|
||||||
language: row.language,
|
language: row.language,
|
||||||
timezone: row.timezone,
|
timezone: row.timezone,
|
||||||
@@ -1884,6 +1896,50 @@ function visitorPresence(row, thresholds = adminPresenceSettings()) {
|
|||||||
return Number.isFinite(lastActivity) && Date.now() - lastActivity <= thresholds.activeSeconds * 1000 ? "active" : "idle";
|
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) {
|
function conversationDetails(conversation) {
|
||||||
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
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);
|
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user