xref: /plugin/botmon/script.js (revision 451abfad91f2735c461ed4299575292c7c711359)
1"use strict";
2/* DokuWiki BotMon Plugin Script file */
3/* 06.09.2025 - 0.2.0 - beta */
4/* Author: Sascha Leib <ad@hominem.info> */
5
6// enumeration of user types:
7const BM_USERTYPE = Object.freeze({
8	'UNKNOWN': 'unknown',
9	'KNOWN_USER': 'user',
10	'HUMAN': 'human',
11	'LIKELY_BOT': 'likely_bot',
12	'KNOWN_BOT': 'known_bot'
13});
14
15/* BotMon root object */
16const BotMon = {
17
18	init: function() {
19		//console.info('BotMon.init()');
20
21		// find the plugin basedir:
22		this._baseDir = document.currentScript.src.substring(0, document.currentScript.src.indexOf('/exe/'))
23			+ '/plugins/botmon/';
24
25		// read the page language from the DOM:
26		this._lang = document.getRootNode().documentElement.lang || this._lang;
27
28		// get the time offset:
29		this._timeDiff = BotMon.t._getTimeOffset();
30
31		// init the sub-objects:
32		BotMon.t._callInit(this);
33	},
34
35	_baseDir: null,
36	_lang: 'en',
37	_today: (new Date()).toISOString().slice(0, 10),
38	_timeDiff: '',
39
40	/* internal tools */
41	t: {
42		/* helper function to call inits of sub-objects */
43		_callInit: function(obj) {
44			//console.info('BotMon.t._callInit(obj=',obj,')');
45
46			/* call init / _init on each sub-object: */
47			Object.keys(obj).forEach( (key,i) => {
48				const sub = obj[key];
49				let init = null;
50				if (typeof sub === 'object' && sub.init) {
51					init = sub.init;
52				}
53
54				// bind to object
55				if (typeof init == 'function') {
56					const init2 = init.bind(sub);
57					init2(obj);
58				}
59			});
60		},
61
62		/* helper function to calculate the time difference to UTC: */
63		_getTimeOffset: function() {
64			const now = new Date();
65			let offset = now.getTimezoneOffset(); // in minutes
66			const sign = Math.sign(offset); // +1 or -1
67			offset = Math.abs(offset); // always positive
68
69			let hours = 0;
70			while (offset >= 60) {
71				hours += 1;
72				offset -= 60;
73			}
74			return ( hours > 0 ? sign * hours + ' h' : '') + (offset > 0 ? ` ${offset} min` : '');
75		},
76
77		/* helper function to create a new element with all attributes and text content */
78		_makeElement: function(name, atlist = undefined, text = undefined) {
79			var r = null;
80			try {
81				r = document.createElement(name);
82				if (atlist) {
83					for (let attr in atlist) {
84						r.setAttribute(attr, atlist[attr]);
85					}
86				}
87				if (text) {
88					r.textContent = text.toString();
89				}
90			} catch(e) {
91				console.error(e);
92			}
93			return r;
94		},
95
96		/* helper to convert an ip address string to a normalised format: */
97		_ip2Num: function(ip) {
98			if (ip.indexOf(':') > 0) { /* IP6 */
99				return (ip.split(':').map(d => ('0000'+d).slice(-4) ).join(''));
100			} else { /* IP4 */
101				return Number(ip.split('.').map(d => ('000'+d).slice(-3) ).join(''));
102			}
103		}
104	}
105};
106
107/* everything specific to the "Today" tab is self-contained in the "live" object: */
108BotMon.live = {
109	init: function() {
110		//console.info('BotMon.live.init()');
111
112		// set the title:
113		const tDiff = '(<abbr title="Coordinated Universal Time">UTC</abbr>' + (BotMon._timeDiff != '' ? `, ${BotMon._timeDiff}` : '' ) + ')';
114		BotMon.live.gui.status.setTitle(`Data for <time datetime=${BotMon._today}>${BotMon._today}</time> ${tDiff}`);
115
116		// init sub-objects:
117		BotMon.t._callInit(this);
118	},
119
120	data: {
121		init: function() {
122			//console.info('BotMon.live.data.init()');
123
124			// call sub-inits:
125			BotMon.t._callInit(this);
126		},
127
128		// this will be called when the known json files are done loading:
129		_dispatch: function(file) {
130			//console.info('BotMon.live.data._dispatch(,',file,')');
131
132			// shortcut to make code more readable:
133			const data = BotMon.live.data;
134
135			// set the flags:
136			switch(file) {
137				case 'rules':
138					data._dispatchRulesLoaded = true;
139					break;
140				case 'bots':
141					data._dispatchBotsLoaded = true;
142					break;
143				case 'clients':
144					data._dispatchClientsLoaded = true;
145					break;
146				case 'platforms':
147					data._dispatchPlatformsLoaded = true;
148					break;
149				default:
150					// ignore
151			}
152
153			// are all the flags set?
154			if (data._dispatchBotsLoaded && data._dispatchClientsLoaded && data._dispatchPlatformsLoaded && data._dispatchRulesLoaded) {
155				// chain the log files loading:
156				BotMon.live.data.loadLogFile('srv', BotMon.live.data._onServerLogLoaded);
157			}
158		},
159		// flags to track which data files have been loaded:
160		_dispatchBotsLoaded: false,
161		_dispatchClientsLoaded: false,
162		_dispatchPlatformsLoaded: false,
163		_dispatchRulesLoaded: false,
164
165		// event callback, after the server log has been loaded:
166		_onServerLogLoaded: function() {
167			//console.info('BotMon.live.data._onServerLogLoaded()');
168
169			// chain the client log file to load:
170			BotMon.live.data.loadLogFile('log', BotMon.live.data._onClientLogLoaded);
171		},
172
173		// event callback, after the client log has been loaded:
174		_onClientLogLoaded: function() {
175			//console.info('BotMon.live.data._onClientLogLoaded()');
176
177			// chain the ticks file to load:
178			BotMon.live.data.loadLogFile('tck', BotMon.live.data._onTicksLogLoaded);
179
180		},
181
182		// event callback, after the tiker log has been loaded:
183		_onTicksLogLoaded: function() {
184			//console.info('BotMon.live.data._onTicksLogLoaded()');
185
186			// analyse the data:
187			BotMon.live.data.analytics.analyseAll();
188
189			// sort the data:
190			// #TODO
191
192			// display the data:
193			BotMon.live.gui.overview.make();
194
195			//console.log(BotMon.live.data.model._visitors);
196
197		},
198
199		model: {
200			// visitors storage:
201			_visitors: [],
202
203			// find an already existing visitor record:
204			findVisitor: function(visitor) {
205				//console.info('BotMon.live.data.model.findVisitor()');
206				//console.log(visitor);
207
208				// shortcut to make code more readable:
209				const model = BotMon.live.data.model;
210
211				// loop over all visitors already registered:
212				for (let i=0; i<model._visitors.length; i++) {
213					const v = model._visitors[i];
214
215					if (visitor._type == BM_USERTYPE.KNOWN_BOT) { /* known bots */
216
217						// bots match when their ID matches:
218						if (v._bot && v._bot.id == visitor._bot.id) {
219							return v;
220						}
221
222					} else if (visitor._type == BM_USERTYPE.KNOWN_USER) { /* registered users */
223
224						//if (visitor.id == 'fsmoe7lgqb89t92vt4ju8vdl0q') console.log(visitor);
225
226						// visitors match when their names match:
227						if ( v.usr == visitor.usr
228						 && v.ip == visitor.ip
229						 && v.agent == visitor.agent) {
230							return v;
231						}
232					} else { /* any other visitor */
233
234						if ( v.id == visitor.id) { /* match the pre-defined IDs */
235							return v;
236						} else if (v.ip == visitor.ip && v.agent == visitor.agent) {
237							if (v.typ !== 'ip') {
238								console.warn(`Visitor ID “${v.id}” not found, using matchin IP + User-Agent instead.`);
239							}
240							return v;
241						}
242
243					}
244				}
245				return null; // nothing found
246			},
247
248			/* if there is already this visit registered, return the page view item */
249			_getPageView: function(visit, view) {
250
251				// shortcut to make code more readable:
252				const model = BotMon.live.data.model;
253
254				for (let i=0; i<visit._pageViews.length; i++) {
255					const pv = visit._pageViews[i];
256					if (pv.pg == view.pg) {
257						return pv;
258					}
259				}
260				return null; // not found
261			},
262
263			// register a new visitor (or update if already exists)
264			registerVisit: function(nv, type) {
265				//console.info('registerVisit', nv, type);
266
267				// shortcut to make code more readable:
268				const model = BotMon.live.data.model;
269
270				// is it a known bot?
271				const bot = BotMon.live.data.bots.match(nv.agent);
272
273				// enrich new visitor with relevant data:
274				if (!nv._bot) nv._bot = bot ?? null; // bot info
275				nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr && nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) );
276				if (!nv._firstSeen) nv._firstSeen = nv.ts;
277				nv._lastSeen = nv.ts;
278
279				// check if it already exists:
280				let visitor = model.findVisitor(nv);
281				if (!visitor) {
282					visitor = nv;
283					visitor._seenBy = [type];
284					visitor._pageViews = []; // array of page views
285					visitor._hasReferrer = false; // has at least one referrer
286					visitor._jsClient = false; // visitor has been seen logged by client js as well
287					visitor._client = BotMon.live.data.clients.match(nv.agent) ?? null; // client info
288					visitor._platform = BotMon.live.data.platforms.match(nv.agent); // platform info
289					model._visitors.push(visitor);
290				} else { // update existing
291					if (visitor._firstSeen < nv.ts) {
292						visitor._firstSeen = nv.ts;
293					}
294				}
295
296				// find browser
297
298				// is this visit already registered?
299				let prereg = model._getPageView(visitor, nv);
300				if (!prereg) {
301					// add new page view:
302					prereg = model._makePageView(nv, type);
303					visitor._pageViews.push(prereg);
304				} else {
305					// update last seen date
306					prereg._lastSeen = nv.ts;
307					// increase view count:
308					prereg._viewCount += 1;
309				}
310
311				// update referrer state:
312				visitor._hasReferrer = visitor._hasReferrer ||
313					(prereg.ref !== undefined && prereg.ref !== '');
314
315				// update time stamp for last-seen:
316				if (visitor._lastSeen < nv.ts) {
317					visitor._lastSeen = nv.ts;
318				}
319
320				// if needed:
321				return visitor;
322			},
323
324			// updating visit data from the client-side log:
325			updateVisit: function(dat) {
326				//console.info('updateVisit', dat);
327
328				// shortcut to make code more readable:
329				const model = BotMon.live.data.model;
330
331				const type = 'log';
332
333				let visitor = BotMon.live.data.model.findVisitor(dat);
334				if (!visitor) {
335					visitor = model.registerVisit(dat, type);
336				}
337				if (visitor) {
338
339					visitor._lastSeen = dat.ts;
340					if (!visitor._seenBy.includes(type)) {
341						visitor._seenBy.push(type);
342					}
343					visitor._jsClient = true; // seen by client js
344				}
345
346				// find the page view:
347				let prereg = BotMon.live.data.model._getPageView(visitor, dat);
348				if (prereg) {
349					// update the page view:
350					prereg._lastSeen = dat.ts;
351					if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
352					prereg._jsClient = true; // seen by client js
353				} else {
354					// add the page view to the visitor:
355					prereg = model._makePageView(dat, type);
356					visitor._pageViews.push(prereg);
357				}
358			},
359
360			// updating visit data from the ticker log:
361			updateTicks: function(dat) {
362				//console.info('updateTicks', dat);
363
364				// shortcut to make code more readable:
365				const model = BotMon.live.data.model;
366
367				const type = 'tck';
368
369				// find the visit info:
370				let visitor = model.findVisitor(dat);
371				if (!visitor) {
372					console.info(`No visitor with ID “${dat.id}” found, registering as a new one.`);
373					visitor = model.registerVisit(dat, type);
374				}
375				if (visitor) {
376					// update visitor:
377					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
378					if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
379
380					// get the page view info:
381					let pv = model._getPageView(visitor, dat);
382					if (!pv) {
383						console.warn(`No page view for visit ID ${dat.id}, page ${dat.pg}, registering a new one.`);
384						pv = model._makePageView(dat, type);
385						visitor._pageViews.push(pv);
386					}
387
388					// update the page view info:
389					if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
390					if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
391					pv._tickCount += 1;
392
393				}
394			},
395
396			// helper function to create a new "page view" item:
397			_makePageView: function(data, type) {
398
399				// try to parse the referrer:
400				let rUrl = null;
401				try {
402					rUrl = ( data.ref && data.ref !== '' ? new URL(data.ref) : null );
403				} catch (e) {
404					console.info(`Invalid referer: “${data.ref}”.`);
405				}
406
407				return {
408					_by: type,
409					ip: data.ip,
410					pg: data.pg,
411					_ref: rUrl,
412					_firstSeen: data.ts,
413					_lastSeen: data.ts,
414					_seenBy: [type],
415					_jsClient: ( type !== 'srv'),
416					_viewCount: 1,
417					_tickCount: 0
418				};
419			}
420		},
421
422		analytics: {
423
424			init: function() {
425				//console.info('BotMon.live.data.analytics.init()');
426			},
427
428			// data storage:
429			data: {
430				totalVisits: 0,
431				totalPageViews: 0,
432				bots: {
433					known: 0,
434					suspected: 0,
435					human: 0,
436					users: 0
437				}
438			},
439
440			// sort the visits by type:
441			groups: {
442				knownBots: [],
443				suspectedBots: [],
444				humans: [],
445				users: []
446			},
447
448			// all analytics
449			analyseAll: function() {
450				//console.info('BotMon.live.data.analytics.analyseAll()');
451
452				// shortcut to make code more readable:
453				const model = BotMon.live.data.model;
454
455				BotMon.live.gui.status.showBusy("Analysing data …");
456
457				// loop over all visitors:
458				model._visitors.forEach( (v) => {
459
460					// count visits and page views:
461					this.data.totalVisits += 1;
462					this.data.totalPageViews += v._pageViews.length;
463
464					// check for typical bot aspects:
465					let botScore = 0;
466
467					if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots
468
469						this.data.bots.known += v._pageViews.length;
470						this.groups.knownBots.push(v);
471
472					} else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */
473
474						this.data.bots.users += v._pageViews.length;
475						this.groups.users.push(v);
476
477					} else {
478
479						// get evaluation:
480						const e = BotMon.live.data.rules.evaluate(v);
481						v._eval = e.rules;
482						v._botVal = e.val;
483
484						if (e.isBot) { // likely bots
485							v._type = BM_USERTYPE.LIKELY_BOT;
486							this.data.bots.suspected += v._pageViews.length;
487							this.groups.suspectedBots.push(v);
488						} else { // probably humans
489							v._type = BM_USERTYPE.HUMAN;
490							this.data.bots.human += v._pageViews.length;
491							this.groups.humans.push(v);
492						}
493						// TODO: find suspected bots
494
495					}
496				});
497
498				BotMon.live.gui.status.hideBusy('Done.');
499
500			}
501
502		},
503
504		bots: {
505			// loads the list of known bots from a JSON file:
506			init: async function() {
507				//console.info('BotMon.live.data.bots.init()');
508
509				// Load the list of known bots:
510				BotMon.live.gui.status.showBusy("Loading known bots …");
511				const url = BotMon._baseDir + 'data/known-bots.json';
512				try {
513					const response = await fetch(url);
514					if (!response.ok) {
515						throw new Error(`${response.status} ${response.statusText}`);
516					}
517
518					this._list = await response.json();
519					this._ready = true;
520
521				} catch (error) {
522					BotMon.live.gui.status.setError("Error while loading the ‘known bots’ file: " + error.message);
523				} finally {
524					BotMon.live.gui.status.hideBusy("Status: Done.");
525					BotMon.live.data._dispatch('bots')
526				}
527			},
528
529			// returns bot info if the clientId matches a known bot, null otherwise:
530			match: function(agent) {
531				//console.info('BotMon.live.data.bots.match(',agent,')');
532
533				const BotList = BotMon.live.data.bots._list;
534
535				// default is: not found!
536				let botInfo = null;
537
538				if (!agent) return null;
539
540				// check for known bots:
541				BotList.find(bot => {
542					let r = false;
543					for (let j=0; j<bot.rx.length; j++) {
544						const rxr = agent.match(new RegExp(bot.rx[j]));
545						if (rxr) {
546							botInfo = {
547								n : bot.n,
548								id: bot.id,
549								url: bot.url,
550								v: (rxr.length > 1 ? rxr[1] : -1)
551							};
552							r = true;
553							break;
554						}
555					};
556					return r;
557				});
558
559				// check for unknown bots:
560				if (!botInfo) {
561					const botmatch = agent.match(/[^\s](\w*bot)[\/\s;\),$]/i);
562					if(botmatch) {
563						botInfo = {'id': "other", 'n': "Other", "bot": botmatch[0] };
564					}
565				}
566
567				//console.log("botInfo:", botInfo);
568				return botInfo;
569			},
570
571
572			// indicates if the list is loaded and ready to use:
573			_ready: false,
574
575			// the actual bot list is stored here:
576			_list: []
577		},
578
579		clients: {
580			// loads the list of known clients from a JSON file:
581			init: async function() {
582				//console.info('BotMon.live.data.clients.init()');
583
584				// Load the list of known bots:
585				BotMon.live.gui.status.showBusy("Loading known clients");
586				const url = BotMon._baseDir + 'data/known-clients.json';
587				try {
588					const response = await fetch(url);
589					if (!response.ok) {
590						throw new Error(`${response.status} ${response.statusText}`);
591					}
592
593					BotMon.live.data.clients._list = await response.json();
594					BotMon.live.data.clients._ready = true;
595
596				} catch (error) {
597					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
598				} finally {
599					BotMon.live.gui.status.hideBusy("Status: Done.");
600					BotMon.live.data._dispatch('clients')
601				}
602			},
603
604			// returns bot info if the user-agent matches a known bot, null otherwise:
605			match: function(agent) {
606				//console.info('BotMon.live.data.clients.match(',agent,')');
607
608				let match = {"n": "Unknown", "v": -1, "id": null};
609
610				if (agent) {
611					BotMon.live.data.clients._list.find(client => {
612						let r = false;
613						for (let j=0; j<client.rx.length; j++) {
614							const rxr = agent.match(new RegExp(client.rx[j]));
615							if (rxr) {
616								match.n = client.n;
617								match.v = (rxr.length > 1 ? rxr[1] : -1);
618								match.id = client.id || null;
619								r = true;
620								break;
621							}
622						}
623						return r;
624					});
625				}
626
627				//console.log(match)
628				return match;
629			},
630
631			// indicates if the list is loaded and ready to use:
632			_ready: false,
633
634			// the actual bot list is stored here:
635			_list: []
636
637		},
638
639		platforms: {
640			// loads the list of known platforms from a JSON file:
641			init: async function() {
642				//console.info('BotMon.live.data.platforms.init()');
643
644				// Load the list of known bots:
645				BotMon.live.gui.status.showBusy("Loading known platforms");
646				const url = BotMon._baseDir + 'data/known-platforms.json';
647				try {
648					const response = await fetch(url);
649					if (!response.ok) {
650						throw new Error(`${response.status} ${response.statusText}`);
651					}
652
653					BotMon.live.data.platforms._list = await response.json();
654					BotMon.live.data.platforms._ready = true;
655
656				} catch (error) {
657					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
658				} finally {
659					BotMon.live.gui.status.hideBusy("Status: Done.");
660					BotMon.live.data._dispatch('platforms')
661				}
662			},
663
664			// returns bot info if the browser id matches a known platform:
665			match: function(cid) {
666				//console.info('BotMon.live.data.platforms.match(',cid,')');
667
668				let match = {"n": "Unknown", "id": null};
669
670				if (cid) {
671					BotMon.live.data.platforms._list.find(platform => {
672						let r = false;
673						for (let j=0; j<platform.rx.length; j++) {
674							const rxr = cid.match(new RegExp(platform.rx[j]));
675							if (rxr) {
676								match.n = platform.n;
677								match.v = (rxr.length > 1 ? rxr[1] : -1);
678								match.id = platform.id || null;
679								r = true;
680								break;
681							}
682						}
683						return r;
684					});
685				}
686
687				return match;
688			},
689
690			// indicates if the list is loaded and ready to use:
691			_ready: false,
692
693			// the actual bot list is stored here:
694			_list: []
695
696		},
697
698		rules: {
699			// loads the list of rules and settings from a JSON file:
700			init: async function() {
701				//console.info('BotMon.live.data.rules.init()');
702
703				// Load the list of known bots:
704				BotMon.live.gui.status.showBusy("Loading list of rules …");
705				const url = BotMon._baseDir + 'data/rules.json';
706				try {
707					const response = await fetch(url);
708					if (!response.ok) {
709						throw new Error(`${response.status} ${response.statusText}`);
710					}
711
712					const json = await response.json();
713
714					if (json.rules) {
715						this._rulesList = json.rules;
716					}
717
718					if (json.threshold) {
719						this._threshold = json.threshold;
720					}
721
722					if (json.ipRanges) {
723						// clean up the IPs first:
724						let list = [];
725						json.ipRanges.forEach( it => {
726							let item = {
727								'from': BotMon.t._ip2Num(it.from),
728								'to': BotMon.t._ip2Num(it.to),
729								'isp': it.isp,
730								'loc': it.loc
731							};
732							list.push(item);
733						});
734
735						this._botIPs = list;
736					}
737
738					this._ready = true;
739
740				} catch (error) {
741					BotMon.live.gui.status.setError("Error while loading the ‘rules’ file: " + error.message);
742				} finally {
743					BotMon.live.gui.status.hideBusy("Status: Done.");
744					BotMon.live.data._dispatch('rules')
745				}
746			},
747
748			_rulesList: [], // list of rules to find out if a visitor is a bot
749			_threshold: 100, // above this, it is considered a bot.
750
751			// returns a descriptive text for a rule id
752			getRuleInfo: function(ruleId) {
753				// console.info('getRuleInfo', ruleId);
754
755				// shortcut for neater code:
756				const me = BotMon.live.data.rules;
757
758				for (let i=0; i<me._rulesList.length; i++) {
759					const rule = me._rulesList[i];
760					if (rule.id == ruleId) {
761						return rule;
762					}
763				}
764				return null;
765
766			},
767
768			// evaluate a visitor for lkikelihood of being a bot
769			evaluate: function(visitor) {
770
771				// shortcut for neater code:
772				const me = BotMon.live.data.rules;
773
774				let r =  {	// evaluation result
775					'val': 0,
776					'rules': [],
777					'isBot': false
778				};
779
780				for (let i=0; i<me._rulesList.length; i++) {
781					const rule = me._rulesList[i];
782					const params = ( rule.params ? rule.params : [] );
783
784					if (rule.func) { // rule is calling a function
785						if (me.func[rule.func]) {
786							if(me.func[rule.func](visitor, ...params)) {
787								r.val += rule.bot;
788								r.rules.push(rule.id)
789							}
790						} else {
791							//console.warn("Unknown rule function: “${rule.func}”. Ignoring rule.")
792						}
793					}
794				}
795
796				// is a bot?
797				r.isBot = (r.val >= me._threshold);
798
799				return r;
800			},
801
802			// list of functions that can be called by the rules list to evaluate a visitor:
803			func: {
804
805				// check if client is one of the obsolete ones:
806				obsoleteClient: function(visitor, ...clients) {
807
808					const clientId = ( visitor._client ? visitor._client.id : '');
809					return clients.includes(clientId);
810				},
811
812				// check if OS/Platform is one of the obsolete ones:
813				obsoletePlatform: function(visitor, ...platforms) {
814
815					const pId = ( visitor._platform ? visitor._platform.id : '');
816					return platforms.includes(pId);
817				},
818
819				// client does not use JavaScript:
820				noJavaScript: function(visitor) {
821
822					return !(visitor._seenBy.includes('log') || visitor._seenBy.includes('tck'));
823
824				},
825
826				// are there at lest num pages loaded?
827				smallPageCount: function(visitor, num) {
828					return (visitor._pageViews.length <= Number(num));
829				},
830
831				// there are no ticks recorded for a visitor
832				// note that this will also trigger the "noJavaScript" rule:
833				noTicks: function(visitor) {
834					return !visitor._seenBy.includes('tck');
835				},
836
837				// there are no referrers in any of the page visits:
838				noReferrer: function(visitor) {
839
840					let r = false; // return value
841					for (let i = 0; i < visitor._pageViews.length; i++) {
842						if (!visitor._pageViews[i]._ref) {
843							r = true;
844							break;
845						}
846					}
847					return r;
848				},
849
850				// test for specific client identifiers:
851				clientTest: function(visitor, ...list) {
852
853					for (let i=0; i<list.length; i++) {
854						if (visitor._client.id == list[i]) {
855								return true
856						}
857					};
858					return false;
859				},
860
861				// unusual combinations of Platform and Client:
862				combTest: function(visitor, ...combinations) {
863
864					for (let i=0; i<combinations.length; i++) {
865
866						if (visitor._platform.id == combinations[i][0]
867							&& visitor._client.id == combinations[i][1]) {
868								return true
869						}
870					};
871
872					return false;
873				},
874
875				// is the IP address from a known bot network?
876				fromKnownBotIP: function(visitor) {
877
878					const ipInfo = BotMon.live.data.rules.getBotIPInfo(visitor.ip);
879
880					return (ipInfo !== null);
881				},
882
883				// is the page language mentioned in the client's accepted languages?
884				// the parameter holds an array of exceptions, i.e. page languages that should be ignored.
885				matchLang: function(visitor, ...exceptions) {
886
887					if (visitor.lang && visitor.accept && exceptions.indexOf(visitor.lang) < 0) {
888						return visitor.accept.split(',').indexOf(visitor.lang) < 0;
889					}
890					return false;
891				}
892			},
893
894			/* known bot IP ranges: */
895			_botIPs: [],
896
897			// return information on a bot IP range:
898			getBotIPInfo: function(ip) {
899
900				// shortcut to make code more readable:
901				const me = BotMon.live.data.rules;
902
903				// convert IP address to easier comparable form:
904				const ipNum = BotMon.t._ip2Num(ip);
905
906				for (let i=0; i < me._botIPs.length; i++) {
907					const ipRange = me._botIPs[i];
908
909					if (ipNum >= ipRange.from && ipNum <= ipRange.to) {
910						return ipRange;
911					}
912
913				};
914				return null;
915
916			}
917
918		},
919
920		loadLogFile: async function(type, onLoaded = undefined) {
921			//console.info('BotMon.live.data.loadLogFile(',type,')');
922
923			let typeName = '';
924			let columns = [];
925
926			switch (type) {
927				case "srv":
928					typeName = "Server";
929					columns = ['ts','ip','pg','id','typ','usr','agent','ref','lang','accept'];
930					break;
931				case "log":
932					typeName = "Page load";
933					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
934					break;
935				case "tck":
936					typeName = "Ticker";
937					columns = ['ts','ip','pg','id','agent'];
938					break;
939				default:
940					console.warn(`Unknown log type ${type}.`);
941					return;
942			}
943
944			// Show the busy indicator and set the visible status:
945			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
946
947			// compose the URL from which to load:
948			const url = BotMon._baseDir + `logs/${BotMon._today}.${type}.txt`;
949			//console.log("Loading:",url);
950
951			// fetch the data:
952			try {
953				const response = await fetch(url);
954				if (!response.ok) {
955					throw new Error(`${response.status} ${response.statusText}`);
956				}
957
958				const logtxt = await response.text();
959
960				logtxt.split('\n').forEach((line) => {
961					if (line.trim() === '') return; // skip empty lines
962					const cols = line.split('\t');
963
964					// assign the columns to an object:
965					const data = {};
966					cols.forEach( (colVal,i) => {
967						colName = columns[i] || `col${i}`;
968						const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
969						data[colName] = colValue;
970					});
971
972					// register the visit in the model:
973					switch(type) {
974						case 'srv':
975							BotMon.live.data.model.registerVisit(data, type);
976							break;
977						case 'log':
978							data.typ = 'js';
979							BotMon.live.data.model.updateVisit(data);
980							break;
981						case 'tck':
982							data.typ = 'js';
983							BotMon.live.data.model.updateTicks(data);
984							break;
985						default:
986							console.warn(`Unknown log type ${type}.`);
987							return;
988					}
989				});
990
991				if (onLoaded) {
992					onLoaded(); // callback after loading is finished.
993				}
994
995			} catch (error) {
996				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message}.`);
997			} finally {
998				BotMon.live.gui.status.hideBusy("Status: Done.");
999			}
1000		}
1001	},
1002
1003	gui: {
1004		init: function() {
1005			// init the lists view:
1006			this.lists.init();
1007		},
1008
1009		overview: {
1010			make: function() {
1011
1012				const data = BotMon.live.data.analytics.data;
1013				const parent = document.getElementById('botmon__today__content');
1014
1015				// shortcut for neater code:
1016				const makeElement = BotMon.t._makeElement;
1017
1018				if (parent) {
1019
1020					const bounceRate = Math.round(data.totalVisits / data.totalPageViews * 100);
1021
1022					jQuery(parent).prepend(jQuery(`
1023						<details id="botmon__today__overview" open>
1024							<summary>Overview</summary>
1025							<div class="grid-3-columns">
1026								<dl>
1027									<dt>Web metrics</dt>
1028									<dd><span>Total page views:</span><strong>${data.totalPageViews}</strong></dd>
1029									<dd><span>Total visitors (est.):</span><span>${data.totalVisits}</span></dd>
1030									<dd><span>Bounce rate (est.):</span><span>${bounceRate}%</span></dd>
1031								</dl>
1032								<dl>
1033									<dt>Bots vs. Humans (page views)</dt>
1034									<dd><span>Registered users:</span><strong>${data.bots.users}</strong></dd>
1035									<dd><span>Probably humans:</span><strong>${data.bots.human}</strong></dd>
1036									<dd><span>Suspected bots:</span><strong>${data.bots.suspected}</strong></dd>
1037									<dd><span>Known bots:</span><strong>${data.bots.known}</strong></dd>
1038								</dl>
1039								<dl id="botmon__botslist"></dl>
1040							</div>
1041						</details>
1042					`));
1043
1044					// update known bots list:
1045					const block = document.getElementById('botmon__botslist');
1046					block.innerHTML = "<dt>Top known bots (page views)</dt>";
1047
1048					let bots = BotMon.live.data.analytics.groups.knownBots.toSorted( (a, b) => {
1049						return b._pageViews.length - a._pageViews.length;
1050					});
1051
1052					for (let i=0; i < Math.min(bots.length, 4); i++) {
1053						const dd = makeElement('dd');
1054						dd.appendChild(makeElement('span', {'class': 'bot bot_' + bots[i]._bot.id}, bots[i]._bot.n));
1055						dd.appendChild(makeElement('strong', undefined, bots[i]._pageViews.length));
1056						block.appendChild(dd);
1057					}
1058				}
1059			}
1060		},
1061
1062		status: {
1063			setText: function(txt) {
1064				const el = document.getElementById('botmon__today__status');
1065				if (el && BotMon.live.gui.status._errorCount <= 0) {
1066					el.innerText = txt;
1067				}
1068			},
1069
1070			setTitle: function(html) {
1071				const el = document.getElementById('botmon__today__title');
1072				if (el) {
1073					el.innerHTML = html;
1074				}
1075			},
1076
1077			setError: function(txt) {
1078				console.error(txt);
1079				BotMon.live.gui.status._errorCount += 1;
1080				const el = document.getElementById('botmon__today__status');
1081				if (el) {
1082					el.innerText = "An error occured. See the browser log for details!";
1083					el.classList.add('error');
1084				}
1085			},
1086			_errorCount: 0,
1087
1088			showBusy: function(txt = null) {
1089				BotMon.live.gui.status._busyCount += 1;
1090				const el = document.getElementById('botmon__today__busy');
1091				if (el) {
1092					el.style.display = 'inline-block';
1093				}
1094				if (txt) BotMon.live.gui.status.setText(txt);
1095			},
1096			_busyCount: 0,
1097
1098			hideBusy: function(txt = null) {
1099				const el = document.getElementById('botmon__today__busy');
1100				BotMon.live.gui.status._busyCount -= 1;
1101				if (BotMon.live.gui.status._busyCount <= 0) {
1102					if (el) el.style.display = 'none';
1103					if (txt) BotMon.live.gui.status.setText(txt);
1104				}
1105			}
1106		},
1107
1108		lists: {
1109			init: function() {
1110
1111				// function shortcut:
1112				const makeElement = BotMon.t._makeElement;
1113
1114				const parent = document.getElementById('botmon__today__visitorlists');
1115				if (parent) {
1116
1117					for (let i=0; i < 4; i++) {
1118
1119						// change the id and title by number:
1120						let listTitle = '';
1121						let listId = '';
1122						switch (i) {
1123							case 0:
1124								listTitle = "Registered users";
1125								listId = 'users';
1126								break;
1127							case 1:
1128								listTitle = "Probably humans";
1129								listId = 'humans';
1130								break;
1131							case 2:
1132								listTitle = "Suspected bots";
1133								listId = 'suspectedBots';
1134								break;
1135							case 3:
1136								listTitle = "Known bots";
1137								listId = 'knownBots';
1138								break;
1139							default:
1140								console.warn('Unknown list number.');
1141						}
1142
1143						const details = makeElement('details', {
1144							'data-group': listId,
1145							'data-loaded': false
1146						});
1147						const title = details.appendChild(makeElement('summary'));
1148						title.appendChild(makeElement('span', {'class':'title'}, listTitle));
1149						title.appendChild(makeElement('span', {'class':'counter'}, '–'));
1150						details.addEventListener("toggle", this._onDetailsToggle);
1151
1152						parent.appendChild(details);
1153
1154					}
1155				}
1156			},
1157
1158			_onDetailsToggle: function(e) {
1159				//console.info('BotMon.live.gui.lists._onDetailsToggle()');
1160
1161				const target = e.target;
1162
1163				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
1164					target.setAttribute('data-loaded', 'loading');
1165
1166					const fillType = target.getAttribute('data-group');
1167					const fillList = BotMon.live.data.analytics.groups[fillType];
1168					if (fillList && fillList.length > 0) {
1169
1170						const ul = BotMon.t._makeElement('ul');
1171
1172						fillList.forEach( (it) => {
1173							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
1174						});
1175
1176						target.appendChild(ul);
1177						target.setAttribute('data-loaded', 'true');
1178					} else {
1179						target.setAttribute('data-loaded', 'false');
1180					}
1181
1182				}
1183			},
1184
1185			_makeVisitorItem: function(data, type) {
1186
1187				// shortcut for neater code:
1188				const make = BotMon.t._makeElement;
1189
1190				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
1191				const platformName = (data._platform ? data._platform.n : 'Unknown');
1192				const clientName = (data._client ? data._client.n: 'Unknown');
1193
1194				const li = make('li'); // root list item
1195				const details = make('details');
1196				const summary = make('summary');
1197				details.appendChild(summary);
1198
1199				const span1 = make('span'); /* left-hand group */
1200				if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
1201
1202					const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
1203					span1.appendChild(make('span', { /* Bot */
1204						'class': 'bot bot_' + (data._bot ? data._bot.id : 'unknown'),
1205						'title': "Bot: " + botName
1206					}, botName));
1207
1208				} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
1209
1210					span1.appendChild(make('span', { /* User */
1211						'class': 'user_known',
1212						'title': "User: " + data.usr
1213					}, data.usr));
1214
1215				} else { /* others */
1216
1217					if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
1218					span1.appendChild(make('span', { /* IP-Address */
1219						'class': 'ipaddr ip' + ipType,
1220						'title': "IP-Address: " + data.ip
1221					}, data.ip));
1222
1223				}
1224
1225				if (data._type !== BM_USERTYPE.KNOWN_BOT) { /* Not for bots */
1226					span1.appendChild(make('span', { /* Platform */
1227						'class': 'icon platform platform_' + (data._platform ? data._platform.id : 'unknown'),
1228						'title': "Platform: " + platformName
1229					}, platformName));
1230
1231					span1.appendChild(make('span', { /* Client */
1232						'class': 'icon client client_' + (data._client ? data._client.id : 'unknown'),
1233						'title': "Client: " + clientName
1234					}, clientName));
1235				}
1236
1237				summary.appendChild(span1);
1238				const span2 = make('span'); /* right-hand group */
1239
1240				span2.appendChild(make('span', { /* page views */
1241					'class': 'pageviews'
1242				}, data._pageViews.length));
1243
1244				summary.appendChild(span2);
1245
1246				// add details expandable section:
1247				details.appendChild(BotMon.live.gui.lists._makeVisitorDetails(data, type));
1248
1249				li.appendChild(details);
1250				return li;
1251			},
1252
1253			_makeVisitorDetails: function(data, type) {
1254
1255				// shortcut for neater code:
1256				const make = BotMon.t._makeElement;
1257
1258				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
1259				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
1260				const platformName = (data._platform ? data._platform.n : 'Unknown');
1261				const clientName = (data._client ? data._client.n: 'Unknown');
1262
1263				const dl = make('dl', {'class': 'visitor_details'});
1264
1265				if (data._type == BM_USERTYPE.KNOWN_BOT) {
1266
1267					dl.appendChild(make('dt', {}, "Bot name:")); /* bot info */
1268					dl.appendChild(make('dd', {'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown')},
1269						(data._bot ? data._bot.n : 'Unknown')));
1270
1271					if (data._bot && data._bot.url) {
1272						dl.appendChild(make('dt', {}, "Bot info:")); /* bot info */
1273						const botInfoDd = dl.appendChild(make('dd'));
1274						botInfoDd.appendChild(make('a', {
1275							'href': data._bot.url,
1276							'target': '_blank'
1277						}, data._bot.url)); /* bot info link*/
1278
1279					}
1280
1281				} else { /* not for bots */
1282
1283					dl.appendChild(make('dt', {}, "Client:")); /* client */
1284					dl.appendChild(make('dd', {'class': 'has_icon client_' + (data._client ? data._client.id : 'unknown')},
1285						clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
1286
1287					dl.appendChild(make('dt', {}, "Platform:")); /* platform */
1288					dl.appendChild(make('dd', {'class': 'has_icon platform_' + (data._platform ? data._platform.id : 'unknown')},
1289						platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
1290
1291					dl.appendChild(make('dt', {}, "IP-Address:"));
1292					dl.appendChild(make('dd', {'class': 'has_icon ip' + ipType}, data.ip));
1293
1294					dl.appendChild(make('dt', {}, "ID:"));
1295					dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));
1296				}
1297
1298				if ((data._lastSeen - data._firstSeen) < 1) {
1299					dl.appendChild(make('dt', {}, "Seen:"));
1300					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
1301				} else {
1302					dl.appendChild(make('dt', {}, "First seen:"));
1303					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
1304					dl.appendChild(make('dt', {}, "Last seen:"));
1305					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
1306				}
1307
1308				dl.appendChild(make('dt', {}, "User-Agent:"));
1309				dl.appendChild(make('dd', {'class': 'agent'}, data.agent));
1310
1311				dl.appendChild(make('dt', {}, "Visitor Type:"));
1312				dl.appendChild(make('dd', undefined, data._type ));
1313
1314				dl.appendChild(make('dt', {}, "Seen by:"));
1315				dl.appendChild(make('dd', undefined, data._seenBy.join(', ') ));
1316
1317				dl.appendChild(make('dt', {}, "Visited pages:"));
1318				const pagesDd = make('dd', {'class': 'pages'});
1319				const pageList = make('ul');
1320
1321				/* list all page views */
1322				data._pageViews.forEach( (page) => {
1323					const pgLi = make('li');
1324
1325					let visitTimeStr = "Bounce";
1326					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
1327					if (visitDuration > 0) {
1328						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
1329					}
1330
1331					pgLi.appendChild(make('span', {}, page.pg)); /* DW Page ID */
1332					if (page._ref) {
1333						pgLi.appendChild(make('span', {
1334							'data-ref': page._ref.host,
1335							'title': "Referrer: " + page._ref.full
1336						}, page._ref.site));
1337					} else {
1338						pgLi.appendChild(make('span', {
1339						}, "No referer"));
1340					}
1341					pgLi.appendChild(make('span', {}, ( page._seenBy ? page._seenBy.join(', ') : '—') + '; ' + page._tickCount));
1342					pgLi.appendChild(make('span', {}, page._firstSeen.toLocaleString()));
1343					pgLi.appendChild(make('span', {}, page._lastSeen.toLocaleString()));
1344					pageList.appendChild(pgLi);
1345				});
1346				pagesDd.appendChild(pageList);
1347				dl.appendChild(pagesDd);
1348
1349				/* add bot evaluation: */
1350				if (data._eval) {
1351					dl.appendChild(make('dt', {}, "Evaluation:"));
1352					const evalDd = make('dd');
1353					const testList = make('ul',{
1354						'class': 'eval'
1355					});
1356					data._eval.forEach( test => {
1357
1358						const tObj = BotMon.live.data.rules.getRuleInfo(test);
1359						let tDesc = tObj ? tObj.desc : test;
1360
1361						// special case for Bot IP range test:
1362						if (tObj.func == 'fromKnownBotIP') {
1363							const rangeInfo = BotMon.live.data.rules.getBotIPInfo(data.ip);
1364							if (rangeInfo) {
1365								tDesc += ` (${rangeInfo.isp}, ${rangeInfo.loc.toUpperCase()})`;
1366							}
1367						}
1368
1369						// create the entry field
1370						const tstLi = make('li');
1371						tstLi.appendChild(make('span', {
1372							'data-testid': test
1373						}, tDesc));
1374						tstLi.appendChild(make('span', {}, ( tObj ? tObj.bot : '—') ));
1375						testList.appendChild(tstLi);
1376					});
1377
1378					// add total row
1379					const tst2Li = make('li', {
1380						'class': 'total'
1381					});
1382					tst2Li.appendChild(make('span', {}, "Total:"));
1383					tst2Li.appendChild(make('span', {}, data._botVal));
1384					testList.appendChild(tst2Li);
1385
1386					evalDd.appendChild(testList);
1387					dl.appendChild(evalDd);
1388				}
1389				return dl;
1390			}
1391
1392		}
1393	}
1394};
1395
1396/* launch only if the BotMon admin panel is open: */
1397if (document.getElementById('botmon__admin')) {
1398	BotMon.init();
1399}