xref: /plugin/botmon/admin.js (revision bc52cf5354998ddadd21f3ebe32fef84ca579334) !
1"use strict";
2/* DokuWiki BotMon Plugin Script file */
3/* 14.10.2025 - 0.5.0 - pre-release */
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	'PROBABLY_HUMAN': 'human',
11	'LIKELY_BOT': 'likely_bot',
12	'KNOWN_BOT': 'known_bot'
13});
14
15// enumeration of log types:
16const BM_LOGTYPE = Object.freeze({
17	'SERVER': 'srv',
18	'CLIENT': 'log',
19	'TICKER': 'tck'
20});
21
22// enumeration of IP versions:
23const BM_IPVERSION = Object.freeze({
24	'IPv4': 4,
25	'IPv6': 6
26});
27
28/* BotMon root object */
29const BotMon = {
30
31	init: function() {
32		//console.info('BotMon.init()');
33
34		// find the plugin basedir:
35		this._baseDir = document.currentScript.src.substring(0, document.currentScript.src.lastIndexOf('/')+1);
36
37		// read the page language from the DOM:
38		this._lang = document.getRootNode().documentElement.lang || this._lang;
39
40		// get the time offset:
41		this._timeDiff = BotMon.t._getTimeOffset();
42
43		// get yesterday's date:
44		let d = new Date();
45		if (BMSettings.showday == 'yesterday') d.setDate(d.getDate() - 1);
46		this._datestr = d.toISOString().slice(0, 10);
47
48		// init the sub-objects:
49		BotMon.t._callInit(this);
50	},
51
52	_baseDir: null,
53	_lang: 'en',
54	_datestr: '',
55	_timeDiff: '',
56
57	/* internal tools */
58	t: {
59		/* helper function to call inits of sub-objects */
60		_callInit: function(obj) {
61			//console.info('BotMon.t._callInit(obj=',obj,')');
62
63			/* call init / _init on each sub-object: */
64			Object.keys(obj).forEach( (key,i) => {
65				const sub = obj[key];
66				let init = null;
67				if (typeof sub === 'object' && sub.init) {
68					init = sub.init;
69				}
70
71				// bind to object
72				if (typeof init == 'function') {
73					const init2 = init.bind(sub);
74					init2(obj);
75				}
76			});
77		},
78
79		/* helper function to calculate the time difference to UTC: */
80		_getTimeOffset: function() {
81			const now = new Date();
82			let offset = now.getTimezoneOffset(); // in minutes
83			const sign = Math.sign(offset); // +1 or -1
84			offset = Math.abs(offset); // always positive
85
86			let hours = 0;
87			while (offset >= 60) {
88				hours += 1;
89				offset -= 60;
90			}
91			return ( hours > 0 ? sign * hours + ' h' : '') + (offset > 0 ? ` ${offset} min` : '');
92		},
93
94		/* helper function to create a new element with all attributes and text content */
95		_makeElement: function(name, atlist = undefined, text = undefined) {
96			var r = null;
97			try {
98				r = document.createElement(name);
99				if (atlist) {
100					for (let attr in atlist) {
101						r.setAttribute(attr, atlist[attr]);
102					}
103				}
104				if (text) {
105					r.textContent = text.toString();
106				}
107			} catch(e) {
108				console.error(e);
109			}
110			return r;
111		},
112
113		/* helper to convert an ip address string to a normalised format: */
114		_ip2Num: function(ip) {
115			if (!ip) {
116				return 'null';
117			} else if (ip.indexOf(':') > 0) { /* IP6 */
118				return (ip.split(':').map(d => ('0000'+d).slice(-4) ).join(':'));
119			} else { /* IP4 */
120				return ip.split('.').map(d => ('000'+d).slice(-3) ).join('.');
121			}
122		},
123
124		/* helper function to format a Date object to show only the time. */
125		/* returns String */
126		_formatTime: function(date) {
127
128			if (date) {
129				return date.getHours() + ':' + ('0'+date.getMinutes()).slice(-2) + ':' + ('0'+date.getSeconds()).slice(-2);
130			} else {
131				return null;
132			}
133
134		},
135
136		/* helper function to show a time difference in seconds or minutes */
137		/* returns String */
138		_formatTimeDiff: function(dateA, dateB) {
139
140			// if the second date is ealier, swap them:
141			if (dateA > dateB) dateB = [dateA, dateA = dateB][0];
142
143			// get the difference in milliseconds:
144			let ms = dateB - dateA;
145
146			if (ms > 50) { /* ignore small time spans */
147				const h = Math.floor((ms / (1000 * 60 * 60)) % 24);
148				const m = Math.floor((ms / (1000 * 60)) % 60);
149				const s = Math.floor((ms / 1000) % 60);
150
151				return ( h>0 ? h + 'h ': '') + ( m>0 ? m + 'm ': '') + ( s>0 ? s + 's': '');
152			}
153
154			return null;
155
156		},
157
158		// calcualte a reduced ration between two numbers
159		// adapted from https://stackoverflow.com/questions/3946373/math-in-js-how-do-i-get-a-ratio-from-a-percentage
160		_getRatio: function(a, b, tolerance) {
161
162			var bg = b;
163			var sm = a;
164			if (a > b) {
165				var bg = a;
166				var sm = b;
167			}
168
169			for (var i = 1; i < 1000000; i++) {
170				var d = sm / i;
171				var res = bg / d;
172				var howClose = Math.abs(res - res.toFixed(0));
173				if (howClose < tolerance) {
174					if (a > b) {
175						return res.toFixed(0) + ':' + i;
176					} else {
177						return i + ':' + res.toFixed(0);
178					}
179				}
180			}
181		}
182	}
183};
184
185/* everything specific to the 'Latest' tab is self-contained in the 'live' object: */
186BotMon.live = {
187	init: function() {
188		//console.info('BotMon.live.init()');
189
190		// set the title:
191		const tDiff = '<abbr title="Coordinated Universal Time">UTC</abbr> ' + (BotMon._timeDiff != '' ? ` (offset: ${BotMon._timeDiff}` : '' ) + ')';
192		BotMon.live.gui.status.setTitle(`Data for <time datetime="${BotMon._datestr}">${BotMon._datestr}</time> ${tDiff}`);
193
194		// init sub-objects:
195		BotMon.t._callInit(this);
196	},
197
198	data: {
199		init: function() {
200			//console.info('BotMon.live.data.init()');
201
202			// call sub-inits:
203			BotMon.t._callInit(this);
204		},
205
206		// this will be called when the known json files are done loading:
207		_dispatch: function(file) {
208			//console.info('BotMon.live.data._dispatch(,',file,')');
209
210			// shortcut to make code more readable:
211			const data = BotMon.live.data;
212
213			// set the flags:
214			switch(file) {
215				case 'rules':
216					data._dispatchRulesLoaded = true;
217					break;
218				case 'ipranges':
219					data._dispatchIPRangesLoaded = true;
220					break;
221				case 'bots':
222					data._dispatchBotsLoaded = true;
223					break;
224				case 'clients':
225					data._dispatchClientsLoaded = true;
226					break;
227				case 'platforms':
228					data._dispatchPlatformsLoaded = true;
229					break;
230				default:
231					// ignore
232			}
233
234			// are all the flags set?
235			if (data._dispatchBotsLoaded && data._dispatchClientsLoaded && data._dispatchPlatformsLoaded && data._dispatchRulesLoaded && data._dispatchIPRangesLoaded) {
236				// chain the log files loading:
237				BotMon.live.data.loadLogFile(BM_LOGTYPE.SERVER, BotMon.live.data._onServerLogLoaded);
238			}
239		},
240		// flags to track which data files have been loaded:
241		_dispatchBotsLoaded: false,
242		_dispatchClientsLoaded: false,
243		_dispatchPlatformsLoaded: false,
244		_dispatchIPRangesLoaded: false,
245		_dispatchRulesLoaded: false,
246
247		// event callback, after the server log has been loaded:
248		_onServerLogLoaded: function() {
249			//console.info('BotMon.live.data._onServerLogLoaded()');
250
251			// chain the client log file to load:
252			BotMon.live.data.loadLogFile(BM_LOGTYPE.CLIENT, BotMon.live.data._onClientLogLoaded);
253		},
254
255		// event callback, after the client log has been loaded:
256		_onClientLogLoaded: function() {
257			//console.info('BotMon.live.data._onClientLogLoaded()');
258
259			// chain the ticks file to load:
260			BotMon.live.data.loadLogFile(BM_LOGTYPE.TICKER, BotMon.live.data._onTicksLogLoaded);
261
262		},
263
264		// event callback, after the tiker log has been loaded:
265		_onTicksLogLoaded: function() {
266			//console.info('BotMon.live.data._onTicksLogLoaded()');
267
268			// analyse the data:
269			BotMon.live.data.analytics.analyseAll();
270
271			// sort the data:
272			// #TODO
273
274			// display the data:
275			BotMon.live.gui.overview.make();
276
277			//console.log(BotMon.live.data.model._visitors);
278
279		},
280
281		// the data model:
282		model: {
283			// visitors storage:
284			_visitors: [],
285
286			// find an already existing visitor record:
287			findVisitor: function(visitor, type) {
288				//console.info('BotMon.live.data.model.findVisitor()', type);
289				//console.log(visitor);
290
291				// shortcut to make code more readable:
292				const model = BotMon.live.data.model;
293
294				//const timeout = 60 * 60 * 1000; // session timeout: One hour
295
296				if (visitor._type == BM_USERTYPE.KNOWN_BOT) { // known bots match by their bot ID:
297
298					for (let i=0; i<model._visitors.length; i++) {
299						const v = model._visitors[i];
300
301						// bots match when their ID matches:
302						if (v._bot && v._bot.id == visitor._bot.id) {
303							return v;
304						}
305					}
306
307				} else { // other types match by their DW/PHPIDs:
308
309					// loop over all visitors already registered and check for ID matches:
310					for (let i=0; i<model._visitors.length; i++) {
311						const v = model._visitors[i];
312
313						if ( v.id.trim() !== '' && v.id == visitor.id) { // match the DW/PHP IDs
314							return v;
315						}
316					}
317
318					// if not found, try to match IP address and user agent:
319					for (let i=0; i<model._visitors.length; i++) {
320						const v = model._visitors[i];
321						if (  v.ip == visitor.ip && v.agent == visitor.agent) {
322							return v;
323						}
324					}
325				}
326
327				return null; // nothing found
328			},
329
330			/* if there is already this visit registered, return the page view item */
331			_getPageView: function(visit, view) {
332
333				// shortcut to make code more readable:
334				const model = BotMon.live.data.model;
335
336				for (let i=0; i<visit._pageViews.length; i++) {
337					const pv = visit._pageViews[i];
338					if (pv.pg == view.pg) {
339						return pv;
340					}
341				}
342				return null; // not found
343			},
344
345			// register a new visitor (or update if already exists)
346			registerVisit: function(nv, type) {
347				//console.info('registerVisit', nv, type);
348
349				// shortcut to make code more readable:
350				const model = BotMon.live.data.model;
351
352				// is it a known bot?
353				const bot = BotMon.live.data.bots.match(nv.agent);
354
355				// enrich new visitor with relevant data:
356				if (!nv._bot) nv._bot = bot ?? null; // bot info
357				nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr && nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) ); // user type
358				if (bot && bot.geo) {
359					if (!nv.geo || nv.geo == '' || nv.geo == 'ZZ') nv.geo = bot.geo;
360				} else if (!nv.geo ||nv.geo == '') {
361					nv.geo = 'ZZ';
362				}
363
364				// update first and last seen:
365				if (!nv._firstSeen) nv._firstSeen = nv.ts; // first-seen
366				nv._lastSeen = nv.ts; // last-seen
367
368				// country name:
369				try {
370					nv._country = ( nv.geo == 'local' ? "localhost" : "Unknown" );
371					if (nv.geo && nv.geo !== '' && nv.geo !== 'ZZ' && nv.geo !== 'local') {
372						const countryName = new Intl.DisplayNames(['en', BotMon._lang], {type: 'region'});
373						nv._country = countryName.of(nv.geo.substring(0,2)) ?? nv.geo;
374					}
375				} catch (err) {
376					console.error(err);
377					nv._country = 'Error';
378				}
379
380				// check if it already exists:
381				let visitor = model.findVisitor(nv, type);
382				if (!visitor) {
383					visitor = {...nv, ...{
384						_seenBy: [type],
385						_viewCount: 0, // number of page views
386						_loadCount: 0, // number of page loads (not necessarily views!)
387						_pageViews: [], // array of page views
388						_hasReferrer: false, // has at least one referrer
389						_jsClient: false, // visitor has been seen logged by client js as well
390						_client: BotMon.live.data.clients.match(nv.agent) ?? null, // client info
391						_platform: BotMon.live.data.platforms.match(nv.agent), // platform info
392						_captcha: {'X': 0, 'Y': 0, 'N': 0, 'W':0, 'H': 0,
393							_str: function() { return (this.X > 0 ? 'X' : '') + (this.Y > 0 ? 'Y' : '') + (this.N > 0 ? 'N' : '') + (this.W > 0 ? 'W' : '') + (this.H > 0 ? 'H' : ''); }
394						} // captcha counter
395					}};
396					model._visitors.push(visitor);
397				};
398
399				// update first and last seen:
400				if (visitor._firstSeen > nv.ts) {
401					visitor._firstSeen = nv.ts;
402				}
403				if (visitor._lastSeen < nv.ts) {
404					visitor._lastSeen = nv.ts;
405				}
406
407				// update total loads and views (not the same!):
408				visitor._loadCount += 1;
409				visitor._viewCount += (nv.captcha == 'Y' ? 0 : 1);
410
411				// ...because also a captcha is a "load", but not a "view".
412				// let's count the captcha statuses as well:
413				if (nv.captcha) visitor._captcha[nv.captcha] += 1;
414
415				// is this visit already registered?
416				let prereg = model._getPageView(visitor, nv);
417				if (!prereg) {
418					// add new page view:
419					prereg = model._makePageView(nv, type);
420					visitor._pageViews.push(prereg);
421				}
422
423				// update last seen date
424				prereg._lastSeen = nv.ts;
425
426				// increase view count:
427				prereg._loadCount += (visitor.captcha == 'Y' ? 0 : 1);
428				//prereg._tickCount += 1;
429
430				// update referrer state:
431				visitor._hasReferrer = visitor._hasReferrer ||
432					(prereg.ref !== undefined && prereg.ref !== '');
433
434				// update time stamp for last-seen:
435				if (visitor._lastSeen < nv.ts) {
436					visitor._lastSeen = nv.ts;
437				}
438
439				// if needed:
440				return visitor;
441			},
442
443			// updating visit data from the client-side log:
444			updateVisit: function(dat) {
445				//console.info('updateVisit', dat);
446
447				// shortcut to make code more readable:
448				const model = BotMon.live.data.model;
449
450				const type = BM_LOGTYPE.CLIENT;
451
452				let visitor = BotMon.live.data.model.findVisitor(dat, type);
453				if (!visitor) {
454					visitor = model.registerVisit(dat, type);
455				}
456				if (visitor) {
457
458					if (visitor._lastSeen < dat.ts) {
459						visitor._lastSeen = dat.ts;
460					}
461					if (!visitor._seenBy.includes(type)) {
462						visitor._seenBy.push(type);
463					}
464					visitor._jsClient = true; // seen by client js
465				}
466
467				// find the page view:
468				let prereg = BotMon.live.data.model._getPageView(visitor, dat);
469				if (prereg) {
470					// update the page view:
471					prereg._lastSeen = dat.ts;
472					if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
473					prereg._jsClient = true; // seen by client js
474				} else {
475					// add the page view to the visitor:
476					prereg = model._makePageView(dat, type);
477					visitor._pageViews.push(prereg);
478				}
479				prereg._tickCount += 1;
480			},
481
482			// updating visit data from the ticker log:
483			updateTicks: function(dat) {
484				//console.info('updateTicks', dat);
485
486				// shortcut to make code more readable:
487				const model = BotMon.live.data.model;
488
489				const type = BM_LOGTYPE.TICKER;
490
491				// find the visit info:
492				let visitor = model.findVisitor(dat, type);
493				if (!visitor) {
494					console.info(`No visitor with ID “${dat.id}” found, registering as a new one.`);
495					visitor = model.registerVisit(dat, type);
496				}
497				if (visitor) {
498					// update visitor:
499					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
500					if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
501
502					// get the page view info:
503					let pv = model._getPageView(visitor, dat);
504					if (!pv) {
505						console.info(`No page view for visit ID “${dat.id}”, page “${dat.pg}”, registering a new one.`);
506						pv = model._makePageView(dat, type);
507						visitor._pageViews.push(pv);
508					}
509
510					// update the page view info:
511					if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
512					if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
513					pv._tickCount += 1;
514
515				}
516			},
517
518			// helper function to create a new "page view" item:
519			_makePageView: function(data, type) {
520				// console.info('_makePageView', data);
521
522				// try to parse the referrer:
523				let rUrl = null;
524				try {
525					rUrl = ( data.ref && data.ref !== '' ? new URL(data.ref) : null );
526				} catch (e) {
527					console.warn(`Invalid referer: “${data.ref}”.`);
528					console.info(data);
529				}
530
531				return {
532					_by: type,
533					ip: data.ip,
534					pg: data.pg,
535					lang: data.lang || '??',
536					_ref: rUrl,
537					_firstSeen: data.ts,
538					_lastSeen: data.ts,
539					_seenBy: [type],
540					_jsClient: ( type !== BM_LOGTYPE.SERVER),
541					_viewCount: 0,
542					_loadCount: 0,
543					_tickCount: 0
544				};
545			},
546
547			// helper function to make a human-readable title from the Captcha statuses:
548			_makeCaptchaTitle: function(cObj) {
549				const cStr = cObj._str();
550				switch (cStr) {
551					case 'Y':
552					case 'NY': return "Blocked by captcha";
553					case 'YN': return "Captcha solved";
554					case 'W': return "IP Address whitelisted";
555					case 'H': return "HEAD request, no captcha";
556					default: return "Undefined: " + cStr;
557				}
558			}
559		},
560
561		// functions to analyse the data:
562		analytics: {
563
564			/**
565			 * Initializes the analytics data storage object:
566			 */
567			init: function() {
568				//console.info('BotMon.live.data.analytics.init()');
569			},
570
571			// data storage:
572			data: {
573				totalVisits: 0,
574				totalPageViews: 0,
575				humanPageViews: 0,
576				bots: {
577					known: 0,
578					suspected: 0,
579					human: 0,
580					users: 0
581				}
582			},
583
584			// sort the visits by type:
585			groups: {
586				knownBots: [],
587				suspectedBots: [],
588				humans: [],
589				users: []
590			},
591
592			// all analytics
593			analyseAll: function() {
594				//console.info('BotMon.live.data.analytics.analyseAll()');
595
596				// shortcut to make code more readable:
597				const model = BotMon.live.data.model;
598				const me = BotMon.live.data.analytics;
599
600				BotMon.live.gui.status.showBusy("Analysing data …");
601
602				// loop over all visitors:
603				model._visitors.forEach( (v) => {
604
605					// count visits and page views:
606					this.data.totalVisits += 1;
607					this.data.totalPageViews += v._viewCount;
608
609					// check for typical bot aspects:
610					let botScore = 0;
611
612					if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots
613
614						this.data.bots.known += v._viewCount;
615						this.groups.knownBots.push(v);
616
617					} else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */
618
619						this.data.bots.users += v._viewCount;
620						this.groups.users.push(v);
621
622					} else {
623
624						// get evaluation:
625						const e = BotMon.live.data.rules.evaluate(v);
626						v._eval = e.rules;
627						v._botVal = e.val;
628
629						if (e.isBot) { // likely bots
630							v._type = BM_USERTYPE.LIKELY_BOT;
631							this.data.bots.suspected += v._viewCount;
632							this.groups.suspectedBots.push(v);
633						} else { // probably humans
634							v._type = BM_USERTYPE.PROBABLY_HUMAN;
635							this.data.bots.human += v._viewCount;
636							this.groups.humans.push(v);
637						}
638					}
639
640					// perform actions depending on the visitor type:
641					if (v._type == BM_USERTYPE.KNOWN_BOT ) { /* known bots only */
642
643					} else if (v._type == BM_USERTYPE.LIKELY_BOT) { /* probable bots only */
644
645						// add bot views to IP range information:
646						me.addToIpRanges(v);
647
648					} else { /* humans only */
649
650						// add browser and platform statistics:
651						me.addBrowserPlatform(v);
652
653						// add
654						v._pageViews.forEach( pv => {
655							me.addToRefererList(pv._ref);
656							me.addToPagesList(pv.pg);
657						});
658					}
659
660					// add to the country lists:
661					me.addToCountries(v.geo, v._country, v._type);
662
663				});
664
665				BotMon.live.gui.status.hideBusy('Done.');
666			},
667
668			// get a list of known bots:
669			getTopBots: function(max) {
670				//console.info('BotMon.live.data.analytics.getTopBots('+max+')');
671
672				//console.log(BotMon.live.data.analytics.groups.knownBots);
673
674				let botsList = BotMon.live.data.analytics.groups.knownBots.toSorted( (a, b) => {
675					return b._viewCount - a._viewCount;
676				});
677
678				const other = {
679					'id': 'other',
680					'name': "Others",
681					'count': 0
682				};
683
684				const rList = [];
685				const max2 = ( botsList.length > max ? max-1 : botsList.length );
686				let total = 0; // adding up the items
687				for (let i=0; i<botsList.length; i++) {
688					const it = botsList[i];
689					if (it && it._bot) {
690						if (i < max2) {
691							rList.push({
692								id: it._bot.id,
693								name: (it._bot.n ? it._bot.n : it._bot.id),
694								count: it._viewCount
695							});
696						} else {
697							other.count += it._pageViews.length;
698						};
699						total += it._viewCount;
700					}
701				};
702
703				// add the "other" item, if needed:
704				if (botsList.length > max2) {
705					rList.push(other);
706				};
707
708				rList.forEach( it => {
709					it.pct = (it.count * 100 / total);
710				});
711
712				return rList;
713			},
714
715			// most visited pages list:
716			_pagesList: [],
717
718			/**
719			 * Add a page view to the list of most visited pages.
720			 * @param {string} pageId - The page ID to add to the list.
721			 * @example
722			 * BotMon.live.data.analytics.addToPagesList('1234567890');
723			 */
724			addToPagesList: function(pageId) {
725				//console.log('BotMon.live.data.analytics.addToPagesList', pageId);
726
727				const me = BotMon.live.data.analytics;
728
729				// already exists?
730				let pgObj = null;
731				for (let i = 0; i < me._pagesList.length; i++) {
732					if (me._pagesList[i].id == pageId) {
733						pgObj = me._pagesList[i];
734						break;
735					}
736				}
737
738				// if not exists, create it:
739				if (!pgObj) {
740					pgObj = {
741						id: pageId,
742						count: 1
743					};
744					me._pagesList.push(pgObj);
745				} else {
746					pgObj.count += 1;
747				}
748			},
749
750			getTopPages: function(max) {
751				//console.info('BotMon.live.data.analytics.getTopPages('+max+')');
752				const me = BotMon.live.data.analytics;
753				return me._pagesList.toSorted( (a, b) => {
754					return b.count - a.count;
755				}).slice(0,max);
756			},
757
758			// Referer List:
759			_refererList: [],
760
761			addToRefererList: function(ref) {
762				//console.log('BotMon.live.data.analytics.addToRefererList',ref);
763
764				const me = BotMon.live.data.analytics;
765
766				// ignore internal references:
767				if (ref && ref.host == window.location.host) {
768					return;
769				}
770
771				const refInfo = me.getRefererInfo(ref);
772
773				// already exists?
774				let refObj = null;
775				for (let i = 0; i < me._refererList.length; i++) {
776					if (me._refererList[i].id == refInfo.id) {
777						refObj = me._refererList[i];
778						break;
779					}
780				}
781
782				// if not exists, create it:
783				if (!refObj) {
784					refObj = refInfo;
785					refObj.count = 1;
786					me._refererList.push(refObj);
787				} else {
788					refObj.count += 1;
789				}
790			},
791
792			getRefererInfo: function(url) {
793				//console.log('BotMon.live.data.analytics.getRefererInfo',url);
794				try {
795					url = new URL(url);
796				} catch (e) {
797					return {
798						'id': 'null',
799						'n': 'Invalid Referer'
800					};
801				}
802
803				// find the referer ID:
804				let refId = 'null';
805				let refName = 'No Referer';
806				if (url && url.host) {
807					const hArr = url.host.split('.');
808					const tld = hArr[hArr.length-1];
809					refId = ( tld == 'localhost' ? tld : hArr[hArr.length-2]);
810					refName = hArr[hArr.length-2] + '.' + tld;
811				}
812
813				return {
814					'id': refId,
815					'n': refName
816				};
817			},
818
819			/**
820			 * Get a sorted list of the top referers.
821			 * The list is sorted in descending order of count.
822			 * If the array has more items than the given maximum, the rest of the items are added to an "other" item.
823			 * Each item in the list has a "pct" property, which is the percentage of the total count.
824			 * @param {number} max - The maximum number of items to return.
825			 * @return {Array} The sorted list of top referers.
826			 */
827			getTopReferers: function(max) {
828				//console.info(('BotMon.live.data.analytics.getTopReferers(' + max + ')'));
829
830				const me = BotMon.live.data.analytics;
831
832				return me._makeTopList(me._refererList, max);
833			},
834
835			/**
836			 * Create a sorted list of top items from a given array.
837			 * The list is sorted in descending order of count.
838			 * If the array has more items than the given maximum, the rest of the items are added to an "other" item.
839			 * Each item in the list has a "pct" property, which is the percentage of the total count.
840			 * @param {Array} arr - The array to sort and truncate.
841			 * @param {number} max - The maximum number of items to return.
842			 * @return {Array} The sorted list of top items.
843			 */
844			_makeTopList: function(arr, max) {
845				//console.info(('BotMon.live.data.analytics._makeTopList(arr,' + max + ')'));
846
847				const me = BotMon.live.data.analytics;
848
849				// sort the list:
850				arr.sort( (a,b) => {
851					return b.count - a.count;
852				});
853
854				const rList = []; // return array
855				const max2 = ( arr.length >= max ? max-1 : arr.length );
856				const other = {
857					'id': 'other',
858					'name': "Others",
859					'typ': 'other',
860					'count': 0
861				};
862				let total = 0; // adding up the items
863				for (let i=0; Math.min(max, arr.length) > i; i++) {
864					const it = arr[i];
865					if (it) {
866						if (i < max2) {
867							const rIt = {
868								id: it.id,
869								name: (it.n ? it.n : it.id),
870								typ: it.typ || it.id,
871								count: it.count
872							};
873							rList.push(rIt);
874						} else {
875							other.count += it.count;
876						}
877						total += it.count;
878					}
879				}
880
881				// add the "other" item, if needed:
882				if (arr.length > max2) {
883					rList.push(other);
884				};
885
886				rList.forEach( it => {
887					it.pct = (it.count * 100 / total);
888				});
889
890				return rList;
891			},
892
893			/* countries of visits */
894			_countries: {
895				'human': [],
896				'bot': []
897			},
898			/**
899			 * Adds a country code to the statistics.
900			 *
901			 * @param {string} iso The ISO 3166-1 alpha-2 country code.
902			 */
903			addToCountries: function(iso, name, type) {
904
905				const me = BotMon.live.data.analytics;
906
907				// find the correct array:
908				let arr = null;
909				switch (type) {
910
911					case BM_USERTYPE.KNOWN_USER:
912					case BM_USERTYPE.PROBABLY_HUMAN:
913						arr = me._countries.human;
914						break;
915					case BM_USERTYPE.LIKELY_BOT:
916					case BM_USERTYPE.KNOWN_BOT:
917						arr = me._countries.bot;
918						break;
919					default:
920						console.warn(`Unknown user type ${type} in function addToCountries.`);
921				}
922
923				if (arr) {
924					let cRec = arr.find( it => it.id == iso);
925					if (!cRec) {
926						cRec = {
927							'id': iso,
928							'n': name,
929							'count': 1
930						};
931						arr.push(cRec);
932					} else {
933						cRec.count += 1;
934					}
935				}
936			},
937
938			/**
939			 * Returns a list of countries with visit counts, sorted by visit count in descending order.
940			 *
941			 * @param {BM_USERTYPE} type array of types type of visitors to return.
942			 * @param {number} max The maximum number of entries to return.
943			 * @return {Array} A list of objects with properties 'iso' (ISO 3166-1 alpha-2 country code) and 'count' (visit count).
944			 */
945			getCountryList: function(type, max) {
946
947				const me = BotMon.live.data.analytics;
948
949				// find the correct array:
950				let arr = null;
951				switch (type) {
952
953					case 'human':
954						arr = me._countries.human;
955						break;
956					case 'bot':
957						arr = me._countries.bot;
958						break;
959					default:
960						console.warn(`Unknown user type ${type} in function getCountryList.`);
961						return;
962				}
963
964				return me._makeTopList(arr, max);
965			},
966
967			/* browser and platform of human visitors */
968			_browsers: [],
969			_platforms: [],
970
971			addBrowserPlatform: function(visitor) {
972				//console.info('addBrowserPlatform', visitor);
973
974				const me = BotMon.live.data.analytics;
975
976				// add to browsers list:
977				let browserRec = ( visitor._client ? visitor._client : {'id': 'unknown'});
978				if (visitor._client) {
979					let bRec = me._browsers.find( it => it.id == browserRec.id);
980					if (!bRec) {
981						bRec = {
982							id: browserRec.id,
983							n: browserRec.n,
984							count: 1
985						};
986						me._browsers.push(bRec);
987					} else {
988						bRec.count += 1;
989					}
990				}
991
992				// add to platforms list:
993				let platformRec = ( visitor._platform ? visitor._platform : {'id': 'unknown'});
994				if (visitor._platform) {
995					let pRec = me._platforms.find( it => it.id == platformRec.id);
996					if (!pRec) {
997						pRec = {
998							id: platformRec.id,
999							n: platformRec.n,
1000							count: 1
1001						};
1002						me._platforms.push(pRec);
1003					} else {
1004						pRec.count += 1;
1005					}
1006				}
1007
1008			},
1009
1010			getTopBrowsers: function(max) {
1011
1012				const me = BotMon.live.data.analytics;
1013
1014				return me._makeTopList(me._browsers, max);
1015			},
1016
1017			getTopPlatforms: function(max) {
1018
1019				const me = BotMon.live.data.analytics;
1020
1021				return me._makeTopList(me._platforms, max);
1022			},
1023
1024			/* bounces are counted, not calculates: */
1025			getBounceCount: function(type) {
1026
1027				const me = BotMon.live.data.analytics;
1028				var bounces = 0;
1029				const list = me.groups[type];
1030
1031				list.forEach(it => {
1032					bounces += (it._viewCount <= 1 ? 1 : 0);
1033				});
1034
1035				return bounces;
1036			},
1037
1038			_ipRanges: [],
1039
1040			/* adds a visit to the ip ranges arrays */
1041			addToIpRanges: function(v) {
1042				//console.info('addToIpRanges', v.ip);
1043
1044				const me = BotMon.live.data.analytics;
1045				const ipRanges = BotMon.live.data.ipRanges;
1046
1047				// Number of IP address segments to look at:
1048				const kIP4Segments = 1;
1049				const kIP6Segments = 3;
1050
1051				let ipGroup = ''; // group name
1052				let ipSeg = []; // IP segment array
1053				let rawIP = ''; // raw ip prefix
1054				let ipName = ''; // IP group display name
1055
1056				const ipType = v.ip.indexOf(':') > 0 ? BM_IPVERSION.IPv6 : BM_IPVERSION.IPv4;
1057				const ipAddr = BotMon.t._ip2Num(v.ip);
1058
1059				// is there already a known IP range assigned?
1060				if (v._ipRange) {
1061
1062					ipGroup = v._ipRange.g; // group name
1063					ipName = ipRanges.getOwner( v._ipRange.g ) || "Unknown";
1064
1065				} else { // no known IP range, let's collect necessary information:
1066
1067
1068					// collect basic IP address info:
1069					if (ipType == BM_IPVERSION.IPv6) {
1070						ipSeg = ipAddr.split(':');
1071						const prefix = v.ip.split(':').slice(0, kIP6Segments).join(':');
1072						rawIP = ipSeg.slice(0, kIP6Segments).join(':');
1073						ipGroup = 'ip6-' + rawIP.replaceAll(':', '-');
1074						ipName = prefix + '::'; // + '/' + (16 * kIP6Segments);
1075					} else {
1076						ipSeg = ipAddr.split('.');
1077						const prefix = v.ip.split('.').slice(0, kIP4Segments).join('.');
1078						rawIP = ipSeg.slice(0, kIP4Segments).join('.') ;
1079						ipGroup = 'ip4-' + rawIP.replaceAll('.', '-');
1080						ipName = prefix + '.x.x.x'.substring(0, 1+(4-kIP4Segments)*2); // + '/' + (8 * kIP4Segments);
1081					}
1082				}
1083
1084				// check if record already exists:
1085				let ipRec = me._ipRanges.find( it => it.g == ipGroup);
1086				if (!ipRec) {
1087
1088					// ip info record initialised:
1089					ipRec = {
1090						g: ipGroup,
1091						n: ipName,
1092						count: 0
1093					}
1094
1095					// existing record?
1096					if (v._ipRange) {
1097
1098						ipRec.from = v._ipRange.from;
1099						ipRec.to = v._ipRange.to;
1100						ipRec.typ = 'net';
1101
1102					} else { // no known IP range, let's collect necessary information:
1103
1104						// complete the ip info record:
1105						if (ipType == BM_IPVERSION.IPv6) {
1106							ipRec.from = rawIP + ':0000:0000:0000:0000:0000:0000:0000'.substring(0, (8-kIP6Segments)*5);
1107							ipRec.to = rawIP + ':FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF'.substring(0, (8-kIP6Segments)*5);
1108							ipRec.typ = '6';
1109						} else {
1110							ipRec.from = rawIP + '.000.000.000.000'.substring(0, (4-kIP4Segments)*4);
1111							ipRec.to = rawIP + '.255.255.255.254'.substring(0, (4-kIP4Segments)*4);
1112							ipRec.typ = '4';
1113						}
1114					}
1115
1116					me._ipRanges.push(ipRec);
1117				}
1118
1119				// add to counter:
1120				ipRec.count += v._viewCount;
1121
1122			},
1123
1124			getTopBotISPs: function(max) {
1125
1126				const me = BotMon.live.data.analytics;
1127
1128				return me._makeTopList(me._ipRanges, max);
1129			},
1130		},
1131
1132		// information on "known bots":
1133		bots: {
1134			// loads the list of known bots from a JSON file:
1135			init: async function() {
1136				//console.info('BotMon.live.data.bots.init()');
1137
1138				// Load the list of known bots:
1139				BotMon.live.gui.status.showBusy("Loading known bots …");
1140				const url = BotMon._baseDir + 'config/known-bots.json';
1141				try {
1142					const response = await fetch(url);
1143					if (!response.ok) {
1144						throw new Error(`${response.status} ${response.statusText}`);
1145					}
1146
1147					this._list = await response.json();
1148					this._ready = true;
1149
1150				} catch (error) {
1151					BotMon.live.gui.status.setError("Error while loading the known bots file:", error.message);
1152				} finally {
1153					BotMon.live.gui.status.hideBusy("Status: Done.");
1154					BotMon.live.data._dispatch('bots')
1155				}
1156			},
1157
1158			// returns bot info if the clientId matches a known bot, null otherwise:
1159			match: function(agent) {
1160				//console.info('BotMon.live.data.bots.match(',agent,')');
1161
1162				const BotList = BotMon.live.data.bots._list;
1163
1164				// default is: not found!
1165				let botInfo = null;
1166
1167				if (!agent) return null;
1168
1169				// check for known bots:
1170				BotList.find(bot => {
1171					let r = false;
1172					for (let j=0; j<bot.rx.length; j++) {
1173						const rxr = agent.match(new RegExp(bot.rx[j]));
1174						if (rxr) {
1175							botInfo = {
1176								n : bot.n,
1177								id: bot.id,
1178								geo: (bot.geo ? bot.geo : null),
1179								url: bot.url,
1180								v: (rxr.length > 1 ? rxr[1] : -1)
1181							};
1182							r = true;
1183							break;
1184						};
1185					};
1186					return r;
1187				});
1188
1189				// check for unknown bots:
1190				if (!botInfo) {
1191					const botmatch = agent.match(/([\s\d\w\-]*bot|[\s\d\w\-]*crawler|[\s\d\w\-]*spider)[\/\s\w\-;\),\\.$]/i);
1192					if(botmatch) {
1193						botInfo = {'id': ( botmatch[1] || "other_" ), 'n': "Other" + ( botmatch[1] ? " (" + botmatch[1] + ")" : "" ) , "bot": botmatch[1] };
1194					}
1195				}
1196
1197				//console.log("botInfo:", botInfo);
1198				return botInfo;
1199			},
1200
1201
1202			// indicates if the list is loaded and ready to use:
1203			_ready: false,
1204
1205			// the actual bot list is stored here:
1206			_list: []
1207		},
1208
1209		// information on known clients (browsers):
1210		clients: {
1211			// loads the list of known clients from a JSON file:
1212			init: async function() {
1213				//console.info('BotMon.live.data.clients.init()');
1214
1215				// Load the list of known bots:
1216				BotMon.live.gui.status.showBusy("Loading known clients");
1217				const url = BotMon._baseDir + 'config/known-clients.json';
1218				try {
1219					const response = await fetch(url);
1220					if (!response.ok) {
1221						throw new Error(`${response.status} ${response.statusText}`);
1222					}
1223
1224					BotMon.live.data.clients._list = await response.json();
1225					BotMon.live.data.clients._ready = true;
1226
1227				} catch (error) {
1228					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
1229				} finally {
1230					BotMon.live.gui.status.hideBusy("Status: Done.");
1231					BotMon.live.data._dispatch('clients')
1232				}
1233			},
1234
1235			// returns bot info if the user-agent matches a known bot, null otherwise:
1236			match: function(agent) {
1237				//console.info('BotMon.live.data.clients.match(',agent,')');
1238
1239				let match = {"n": "Unknown", "v": -1, "id": 'null'};
1240
1241				if (agent) {
1242					BotMon.live.data.clients._list.find(client => {
1243						let r = false;
1244						for (let j=0; j<client.rx.length; j++) {
1245							const rxr = agent.match(new RegExp(client.rx[j]));
1246							if (rxr) {
1247								match.n = client.n;
1248								match.v = (rxr.length > 1 ? rxr[1] : -1);
1249								match.id = client.id || null;
1250								r = true;
1251								break;
1252							}
1253						}
1254						return r;
1255					});
1256				}
1257
1258				//console.log(match)
1259				return match;
1260			},
1261
1262			// return the browser name for a browser ID:
1263			getName: function(id) {
1264				const it = BotMon.live.data.clients._list.find(client => client.id == id);
1265				return ( it && it.n ? it.n : "Unknown"); //it.n;
1266			},
1267
1268			// indicates if the list is loaded and ready to use:
1269			_ready: false,
1270
1271			// the actual bot list is stored here:
1272			_list: []
1273
1274		},
1275
1276		// information on known platforms (operating systems):
1277		platforms: {
1278			// loads the list of known platforms from a JSON file:
1279			init: async function() {
1280				//console.info('BotMon.live.data.platforms.init()');
1281
1282				// Load the list of known bots:
1283				BotMon.live.gui.status.showBusy("Loading known platforms");
1284				const url = BotMon._baseDir + 'config/known-platforms.json';
1285				try {
1286					const response = await fetch(url);
1287					if (!response.ok) {
1288						throw new Error(`${response.status} ${response.statusText}`);
1289					}
1290
1291					BotMon.live.data.platforms._list = await response.json();
1292					BotMon.live.data.platforms._ready = true;
1293
1294				} catch (error) {
1295					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
1296				} finally {
1297					BotMon.live.gui.status.hideBusy("Status: Done.");
1298					BotMon.live.data._dispatch('platforms')
1299				}
1300			},
1301
1302			// returns bot info if the browser id matches a known platform:
1303			match: function(cid) {
1304				//console.info('BotMon.live.data.platforms.match(',cid,')');
1305
1306				let match = {"n": "Unknown", "id": 'null'};
1307
1308				if (cid) {
1309					BotMon.live.data.platforms._list.find(platform => {
1310						let r = false;
1311						for (let j=0; j<platform.rx.length; j++) {
1312							const rxr = cid.match(new RegExp(platform.rx[j]));
1313							if (rxr) {
1314								match.n = platform.n;
1315								match.v = (rxr.length > 1 ? rxr[1] : -1);
1316								match.id = platform.id || null;
1317								r = true;
1318								break;
1319							}
1320						}
1321						return r;
1322					});
1323				}
1324
1325				return match;
1326			},
1327
1328			// return the platform name for a given ID:
1329			getName: function(id) {
1330				const it = BotMon.live.data.platforms._list.find( pf => pf.id == id);
1331				return ( it ? it.n : 'Unknown' );
1332			},
1333
1334
1335			// indicates if the list is loaded and ready to use:
1336			_ready: false,
1337
1338			// the actual bot list is stored here:
1339			_list: []
1340
1341		},
1342
1343		// storage and functions for the known bot IP-Ranges:
1344		ipRanges: {
1345
1346			init: function() {
1347				//console.log('BotMon.live.data.ipRanges.init()');
1348				// #TODO: Load from separate IP-Ranges file
1349				// load the rules file:
1350				const me = BotMon.live.data;
1351
1352				try {
1353					BotMon.live.data._loadSettingsFile(['user-ipranges', 'known-ipranges'],
1354						(json) => {
1355
1356						// groups can be just saved in the data structure:
1357						if (json.groups && json.groups.constructor.name == 'Array') {
1358							me.ipRanges._groups = json.groups;
1359						}
1360
1361						// groups can be just saved in the data structure:
1362						if (json.ranges && json.ranges.constructor.name == 'Array') {
1363							json.ranges.forEach(range => {
1364								me.ipRanges.add(range);
1365							})
1366						}
1367
1368						// finished loading
1369						BotMon.live.gui.status.hideBusy("Status: Done.");
1370						BotMon.live.data._dispatch('ipranges')
1371					});
1372				} catch (error) {
1373					BotMon.live.gui.status.setError("Error while loading the config file: " + error.message);
1374				}
1375			},
1376
1377			// the actual bot list is stored here:
1378			_list: [],
1379			_groups: [],
1380
1381			add: function(data) {
1382				//console.log('BotMon.live.data.ipRanges.add(',data,')');
1383
1384				const me = BotMon.live.data.ipRanges;
1385
1386				// convert IP address to easier comparable form:
1387				const ip2Num = BotMon.t._ip2Num;
1388
1389				let item = {
1390					'cidr': data.from.replaceAll(/::+/g, '::') + '/' + ( data.m ? data.m : '??' ),
1391					'from': ip2Num(data.from),
1392					'to': ip2Num(data.to),
1393					'm': data.m,
1394					'g': data.g
1395				};
1396				me._list.push(item);
1397
1398			},
1399
1400			getOwner: function(gid) {
1401
1402				const me = BotMon.live.data.ipRanges;
1403
1404				for (let i=0; i < me._groups.length; i++) {
1405					const it = me._groups[i];
1406					if (it.id == gid) {
1407						return it.name;
1408					}
1409				}
1410				return null;
1411			},
1412
1413			match: function(ip) {
1414				//console.log('BotMon.live.data.ipRanges.match(',ip,')');
1415
1416				const me = BotMon.live.data.ipRanges;
1417
1418				// convert IP address to easier comparable form:
1419				const ipNum = BotMon.t._ip2Num(ip);
1420
1421				for (let i=0; i < me._list.length; i++) {
1422					const ipRange = me._list[i];
1423
1424					if (ipNum >= ipRange.from && ipNum <= ipRange.to) {
1425						return ipRange;
1426					}
1427
1428				};
1429				return null;
1430			}
1431		},
1432
1433		// storage for the rules and related functions
1434		rules: {
1435
1436			/**
1437			 * Initializes the rules data.
1438			 *
1439			 * Loads the default config file and the user config file (if present).
1440			 * The default config file is used if the user config file does not have a certain setting.
1441			 * The user config file can override settings from the default config file.
1442			 *
1443			 * The rules are loaded from the `rules` property of the config files.
1444			 * The IP ranges are loaded from the `ipRanges` property of the config files.
1445			 *
1446			 * If an error occurs while loading the config file, it is displayed in the status bar.
1447			 * After the config file is loaded, the status bar is hidden.
1448			 */
1449			init: async function() {
1450				//console.info('BotMon.live.data.rules.init()');
1451
1452				// Load the list of known bots:
1453				BotMon.live.gui.status.showBusy("Loading list of rules …");
1454
1455				// load the rules file:
1456				const me = BotMon.live.data;
1457
1458				try {
1459					BotMon.live.data._loadSettingsFile(['user-config', 'default-config'],
1460						(json) => {
1461
1462							// override the threshold?
1463							if (json.threshold) me._threshold = json.threshold;
1464
1465							// set the rules list:
1466							if (json.rules && json.rules.constructor.name == 'Array') {
1467								me.rules._rulesList = json.rules;
1468							}
1469
1470							BotMon.live.gui.status.hideBusy("Status: Done.");
1471							BotMon.live.data._dispatch('rules')
1472						}
1473					);
1474				} catch (error) {
1475					BotMon.live.gui.status.setError("Error while loading the config file: " + error.message);
1476				}
1477			},
1478
1479			_rulesList: [], // list of rules to find out if a visitor is a bot
1480			_threshold: 100, // above this, it is considered a bot.
1481
1482			// returns a descriptive text for a rule id
1483			getRuleInfo: function(ruleId) {
1484				// console.info('getRuleInfo', ruleId);
1485
1486				// shortcut for neater code:
1487				const me = BotMon.live.data.rules;
1488
1489				for (let i=0; i<me._rulesList.length; i++) {
1490					const rule = me._rulesList[i];
1491					if (rule.id == ruleId) {
1492						return rule;
1493					}
1494				}
1495				return null;
1496
1497			},
1498
1499			// evaluate a visitor for lkikelihood of being a bot
1500			evaluate: function(visitor) {
1501
1502				// shortcut for neater code:
1503				const me = BotMon.live.data.rules;
1504
1505				let r =  {	// evaluation result
1506					'val': 0,
1507					'rules': [],
1508					'isBot': false
1509				};
1510
1511				for (let i=0; i<me._rulesList.length; i++) {
1512					const rule = me._rulesList[i];
1513					const params = ( rule.params ? rule.params : [] );
1514
1515					if (rule.func) { // rule is calling a function
1516						if (me.func[rule.func]) {
1517							if(me.func[rule.func](visitor, ...params)) {
1518								r.val += rule.bot;
1519								r.rules.push(rule.id)
1520							}
1521						} else {
1522							//console.warn("Unknown rule function: “${rule.func}”. Ignoring rule.")
1523						}
1524					}
1525				}
1526
1527				// is a bot?
1528				r.isBot = (r.val >= me._threshold);
1529
1530				return r;
1531			},
1532
1533			// list of functions that can be called by the rules list to evaluate a visitor:
1534			func: {
1535
1536				// check if client is on the list passed as parameter:
1537				matchesClient: function(visitor, ...clients) {
1538
1539					const clientId = ( visitor._client ? visitor._client.id : '');
1540					return clients.includes(clientId);
1541				},
1542
1543				// check if OS/Platform is one of the obsolete ones:
1544				matchesPlatform: function(visitor, ...platforms) {
1545
1546					const pId = ( visitor._platform ? visitor._platform.id : '');
1547
1548					if (visitor._platform.id == null) console.log(visitor._platform);
1549
1550					return platforms.includes(pId);
1551				},
1552
1553				// are there at lest num pages loaded?
1554				smallPageCount: function(visitor, num) {
1555					return (visitor._viewCount <= Number(num));
1556				},
1557
1558				// There was no entry in a specific log file for this visitor:
1559				// note that this will also trigger the "noJavaScript" rule:
1560				noRecord: function(visitor, type) {
1561					if (!visitor._seenBy.includes('srv')) return false; // only if 'srv' is also specified!
1562					return !visitor._seenBy.includes(type);
1563				},
1564
1565				// there are no referrers in any of the page visits:
1566				noReferrer: function(visitor) {
1567
1568					let r = false; // return value
1569					for (let i = 0; i < visitor._pageViews.length; i++) {
1570						if (!visitor._pageViews[i]._ref) {
1571							r = true;
1572							break;
1573						}
1574					}
1575					return r;
1576				},
1577
1578				// test for specific client identifiers:
1579				matchesUserAgent: function(visitor, ...list) {
1580
1581					for (let i=0; i<list.length; i++) {
1582						if (visitor.agent == list[i]) {
1583							return true
1584						}
1585					};
1586					return false;
1587				},
1588
1589				// unusual combinations of Platform and Client:
1590				combinationTest: function(visitor, ...combinations) {
1591
1592					for (let i=0; i<combinations.length; i++) {
1593
1594						if (visitor._platform.id == combinations[i][0]
1595							&& visitor._client.id == combinations[i][1]) {
1596								return true
1597						}
1598					};
1599
1600					return false;
1601				},
1602
1603				// is the IP address from a known bot network?
1604				fromKnownBotIP: function(visitor) {
1605					//console.info('fromKnownBotIP()', visitor.ip);
1606
1607					const ipInfo = BotMon.live.data.ipRanges.match(visitor.ip);
1608
1609					if (ipInfo) {
1610						visitor._ipInKnownBotRange = true;
1611						visitor._ipRange = ipInfo;
1612					}
1613
1614					return (ipInfo !== null);
1615				},
1616
1617				// is the page language mentioned in the client's accepted languages?
1618				// the parameter holds an array of exceptions, i.e. page languages that should be ignored.
1619				matchLang: function(visitor, ...exceptions) {
1620
1621					if (visitor.lang && visitor.accept && exceptions.indexOf(visitor.lang) < 0) {
1622						return (visitor.accept.split(',').indexOf(visitor.lang) < 0);
1623					}
1624					return false;
1625				},
1626
1627				// the "Accept language" header contains certain entries:
1628				clientAccepts: function(visitor, ...languages) {
1629					//console.info('clientAccepts', visitor.accept, languages);
1630
1631					if (visitor.accept && languages) {;
1632						return ( visitor.accept.split(',').filter(lang => languages.includes(lang)).length > 0 );
1633					}
1634					return false;
1635				},
1636
1637				// Is there an accept-language field defined at all?
1638				noAcceptLang: function(visitor) {
1639
1640					if (!visitor.accept || visitor.accept.length <= 0) { // no accept-languages header
1641						return true;
1642					}
1643					// TODO: parametrize this!
1644					return false;
1645				},
1646				// At least x page views were recorded, but they come within less than y seconds
1647				loadSpeed: function(visitor, minItems, maxTime) {
1648
1649					if (visitor._viewCount >= minItems) {
1650						//console.log('loadSpeed', visitor._pageViews.length, minItems, maxTime);
1651
1652						const pvArr = visitor._pageViews.map(pv => pv._lastSeen).sort();
1653
1654						let totalTime = 0;
1655						for (let i=1; i < pvArr.length; i++) {
1656							totalTime += (pvArr[i] - pvArr[i-1]);
1657						}
1658
1659						//console.log('     ', totalTime , Math.round(totalTime / (pvArr.length * 1000)), (( totalTime / pvArr.length ) <= maxTime * 1000), visitor.ip);
1660
1661						return (( totalTime / pvArr.length ) <= maxTime * 1000);
1662					}
1663				},
1664
1665				// Country code matches one of those in the list:
1666				matchesCountry: function(visitor, ...countries) {
1667
1668					// ingore if geoloc is not set or unknown:
1669					if (visitor.geo) {
1670						return (countries.indexOf(visitor.geo) >= 0);
1671					}
1672					return false;
1673				},
1674
1675				// Country does not match one of the given codes.
1676				notFromCountry: function(visitor, ...countries) {
1677
1678					// ingore if geoloc is not set or unknown:
1679					if (visitor.geo && visitor.geo !== 'ZZ') {
1680						return (countries.indexOf(visitor.geo) < 0);
1681					}
1682					return false;
1683				}
1684			}
1685		},
1686
1687		/**
1688		 * Loads a settings file from the specified list of filenames.
1689		 * If the file is successfully loaded, it will call the callback function
1690		 * with the loaded JSON data.
1691		 * If no file can be loaded, it will display an error message.
1692		 *
1693		 * @param {string[]} fns - list of filenames to load
1694		 * @param {function} callback - function to call with the loaded JSON data
1695		 */
1696		_loadSettingsFile: async function(fns, callback) {
1697			//console.info('BotMon.live.data._loadSettingsFile()', fns);
1698
1699			const kJsonExt = '.json';
1700			let loaded = false; // if successfully loaded file
1701
1702			for (let i=0; i<fns.length; i++) {
1703				const filename = fns[i] +kJsonExt;
1704				try {
1705					const response = await fetch(DOKU_BASE + 'lib/plugins/botmon/config/' + filename);
1706					if (!response.ok) {
1707						continue;
1708					} else {
1709						loaded = true;
1710					}
1711					const json = await response.json();
1712					if (callback && typeof callback === 'function') {
1713						callback(json);
1714					}
1715					break;
1716				} catch (e) {
1717					BotMon.live.gui.status.setError("Error while loading the config file: " + filename);
1718				}
1719			}
1720
1721			if (!loaded) {
1722				BotMon.live.gui.status.setError("Could not load a config file.");
1723			}
1724		},
1725
1726		/**
1727		 * Loads a log file (server, page load, or ticker) and parses it.
1728		 * @param {String} type - the type of the log file to load (srv, log, or tck)
1729		 * @param {Function} [onLoaded] - an optional callback function to call after loading is finished.
1730		 */
1731		loadLogFile: async function(type, onLoaded = undefined) {
1732			//console.info('BotMon.live.data.loadLogFile(',type,')');
1733
1734			let typeName = '';
1735			let columns = [];
1736
1737			switch (type) {
1738				case "srv":
1739					typeName = "Server";
1740					columns = ['ts','ip','pg','id','typ','usr','agent','ref','lang','accept','geo','captcha'];
1741					break;
1742				case "log":
1743					typeName = "Page load";
1744					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
1745					break;
1746				case "tck":
1747					typeName = "Ticker";
1748					columns = ['ts','ip','pg','id','agent'];
1749					break;
1750				default:
1751					console.warn(`Unknown log type ${type}.`);
1752					return;
1753			}
1754
1755			// Show the busy indicator and set the visible status:
1756			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
1757
1758			// compose the URL from which to load:
1759			const url = BotMon._baseDir + `logs/${BotMon._datestr}.${type}.txt`;
1760			//console.log("Loading:",url);
1761
1762			// fetch the data:
1763			try {
1764				const response = await fetch(url);
1765				if (!response.ok) {
1766
1767					throw new Error(`${response.status} ${response.statusText}`);
1768
1769				} else {
1770
1771					// parse the data:
1772					const logtxt = await response.text();
1773					if (logtxt.length <= 0) {
1774						throw new Error(`Empty log file ${url}.`);
1775					}
1776
1777					logtxt.split('\n').forEach((line) => {
1778						if (line.trim() === '') return; // skip empty lines
1779						const cols = line.split('\t');
1780
1781						// assign the columns to an object:
1782						const data = {};
1783						cols.forEach( (colVal,i) => {
1784							const colName = columns[i] || `col${i}`;
1785							const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
1786							data[colName] = colValue;
1787						});
1788
1789						// register the visit in the model:
1790						switch(type) {
1791							case BM_LOGTYPE.SERVER:
1792								BotMon.live.data.model.registerVisit(data, type);
1793								break;
1794							case BM_LOGTYPE.CLIENT:
1795								data.typ = 'js';
1796								BotMon.live.data.model.updateVisit(data);
1797								break;
1798							case BM_LOGTYPE.TICKER:
1799								data.typ = 'js';
1800								BotMon.live.data.model.updateTicks(data);
1801								break;
1802							default:
1803								console.warn(`Unknown log type ${type}.`);
1804								return;
1805						}
1806					});
1807				}
1808
1809			} catch (error) {
1810				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message} – data may be incomplete.`);
1811			} finally {
1812				BotMon.live.gui.status.hideBusy("Status: Done.");
1813				if (onLoaded) {
1814					onLoaded(); // callback after loading is finished.
1815				}
1816			}
1817		}
1818	},
1819
1820	gui: {
1821		init: function() {
1822			//console.log('BotMon.live.gui.init()');
1823
1824			// init sub-objects:
1825			BotMon.t._callInit(this);
1826		},
1827
1828		tabs: {
1829			init: function() {
1830				//console.log('BotMon.live.gui.tabs.init()');
1831
1832				/* find and add all existing tabs */
1833				document.querySelectorAll('#botmon__admin *[role=tablist]')
1834					.forEach((tablist) => {
1835						tablist.querySelectorAll('*[role=tab]')
1836					.forEach( t => t.addEventListener('click', this._onTabClick) )
1837					});
1838			},
1839
1840			/* callback for tab click */
1841			_onTabClick: function(e) {
1842				//console.log('BotMon.live.gui.tabs._onTabClick()');
1843
1844				/* reusable constants: */
1845				const kAriaSelected = 'aria-selected';
1846				const kAriaControls = 'aria-controls';
1847				const kTrue = 'true';
1848				const kFalse = 'false';
1849				const kHidden = 'hidden';
1850
1851				/* cancel default action */
1852				e.preventDefault();
1853
1854				/* if the active tab is clicked, do nothing: */
1855				let selState = this.getAttribute(kAriaSelected);
1856				if ( selState && selState == kTrue ) {
1857					return;
1858				}
1859
1860				/* find the active tab element: */
1861				var aItem = null;
1862				let tablist = this.parentNode;
1863				while (tablist.getAttribute('role') !== 'tablist') {
1864					tablist = tablist.parentNode;
1865				}
1866
1867				if (tablist.getAttribute('role') == 'tablist') {
1868					let lis = tablist.querySelectorAll('*[role=tab]');
1869					lis.forEach( (it) => {
1870						let selected = it.getAttribute(kAriaSelected);
1871						if ( selected && selected == kTrue ) {
1872							aItem = it;
1873						}
1874					});
1875				}
1876
1877				/* swap the active states: */
1878				this.setAttribute(kAriaSelected, kTrue);
1879				if (aItem) {
1880					aItem.setAttribute(kAriaSelected, kFalse);
1881					let aId = aItem.getAttribute(kAriaControls);
1882					let aObj = document.getElementById(aId);
1883					if (aObj) aObj.hidden = true;
1884				}
1885
1886				/* show the new panel: */
1887				let nId = this.getAttribute(kAriaControls);
1888				let nObj = document.getElementById(nId);
1889				if (nObj) nObj.hidden = false;
1890			}
1891		},
1892
1893		/* The Overview / web metrics section of the live tab */
1894		overview: {
1895			/**
1896			 * Populates the overview part of the today tab with the analytics data.
1897			 *
1898			 * @method make
1899			 * @memberof BotMon.live.gui.overview
1900			 */
1901			make: function() {
1902
1903				const data = BotMon.live.data.analytics.data;
1904
1905				const maxItemsPerList = 5; // how many list items to show?
1906
1907				const kNoData = '–'; // shown when data is missing
1908
1909				// shortcut for neater code:
1910				const makeElement = BotMon.t._makeElement;
1911
1912				const botsVsHumans = document.getElementById('botmon__today__botsvshumans');
1913				if (botsVsHumans) {
1914					botsVsHumans.appendChild(makeElement('dt', {}, "Page views"));
1915
1916					for (let i = 0; i <= 5; i++) {
1917						const dd = makeElement('dd');
1918						let title = '';
1919						let value = '';
1920						switch(i) {
1921							case 0:
1922								title = "Known bots:";
1923								value = data.bots.known || kNoData;
1924								break;
1925							case 1:
1926								title = "Suspected bots:";
1927								value = data.bots.suspected || kNoData;
1928								break;
1929							case 2:
1930								title = "Probably humans:";
1931								value = data.bots.human || kNoData;
1932								break;
1933							case 3:
1934								title = "Registered users:";
1935								value = data.bots.users || kNoData;
1936								break;
1937							case 4:
1938								title = "Total:";
1939								value = data.totalPageViews || kNoData;
1940								break;
1941							case 5:
1942								title = "Bots-humans ratio:";
1943								value = BotMon.t._getRatio(data.bots.suspected + data.bots.known, data.bots.users + data.bots.human, 100);
1944								break;
1945							default:
1946								console.warn(`Unknown list type ${i}.`);
1947						}
1948						dd.appendChild(makeElement('span', {}, title));
1949						dd.appendChild(makeElement('strong', {}, value));
1950						botsVsHumans.appendChild(dd);
1951					}
1952				}
1953
1954				// update known bots list:
1955				const botElement = document.getElementById('botmon__botslist'); /* Known bots */
1956				if (botElement) {
1957					botElement.appendChild(makeElement('dt', {}, `Top known bots`));
1958
1959					let botList = BotMon.live.data.analytics.getTopBots(maxItemsPerList);
1960					botList.forEach( (botInfo) => {
1961						const bli = makeElement('dd');
1962						bli.appendChild(makeElement('span', {'class': 'has_icon bot bot_' + botInfo.id }, botInfo.name));
1963						bli.appendChild(makeElement('span', {'class': 'count' }, botInfo.count));
1964						botElement.append(bli)
1965					});
1966				}
1967
1968				// update the suspected bot IP ranges list:
1969				const botIps = document.getElementById('botmon__botips');
1970				if (botIps) {
1971					botIps.appendChild(makeElement('dt', {}, "Top bot Networks"));
1972
1973					const ispList = BotMon.live.data.analytics.getTopBotISPs(5);
1974					//console.log(ispList);
1975					ispList.forEach( (netInfo) => {
1976						const li = makeElement('dd');
1977						li.appendChild(makeElement('span', {'class': 'has_icon ipaddr ip' + netInfo.typ }, netInfo.name));
1978						li.appendChild(makeElement('span', {'class': 'count' }, netInfo.count));
1979						botIps.append(li)
1980					});
1981				}
1982
1983				// update the top bot countries list:
1984				const botCountries = document.getElementById('botmon__botcountries');
1985				if (botCountries) {
1986					botCountries.appendChild(makeElement('dt', {}, `Top bot Countries`));
1987					const countryList = BotMon.live.data.analytics.getCountryList('bot', 5);
1988					countryList.forEach( (cInfo) => {
1989						const cLi = makeElement('dd');
1990						cLi.appendChild(makeElement('span', {'class': 'has_icon country ctry_' + cInfo.id.toLowerCase() }, cInfo.name));
1991						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count));
1992						botCountries.appendChild(cLi);
1993					});
1994				}
1995
1996				// update the webmetrics overview:
1997				const wmoverview = document.getElementById('botmon__today__wm_overview');
1998				if (wmoverview) {
1999
2000					const humanVisits = BotMon.live.data.analytics.groups.users.length + BotMon.live.data.analytics.groups.humans.length;
2001					const bounceRate = Math.round(100 * (BotMon.live.data.analytics.getBounceCount('users') + BotMon.live.data.analytics.getBounceCount('humans')) / humanVisits);
2002
2003					wmoverview.appendChild(makeElement('dt', {}, "Humans’ metrics"));
2004					for (let i = 0; i <= 4; i++) {
2005						const dd = makeElement('dd');
2006						let title = '';
2007						let value = '';
2008						switch(i) {
2009							case 0:
2010								title = "Registered users’ page views:";
2011								value = data.bots.users || kNoData;
2012								break;
2013							case 1:
2014								title = "“Probably humans” page views:";
2015								value = data.bots.human || kNoData;
2016								break;
2017							case 2:
2018								title = "Total human page views:";
2019								value = (data.bots.users + data.bots.human) || kNoData;
2020								break;
2021							case 3:
2022								title = "Total human visits:";
2023								value = humanVisits || kNoData;
2024								break;
2025							case 4:
2026								title = "Humans’ bounce rate:";
2027								value = bounceRate + '%';
2028								break;
2029							default:
2030								console.warn(`Unknown list type ${i}.`);
2031						}
2032						dd.appendChild(makeElement('span', {}, title));
2033						dd.appendChild(makeElement('strong', {}, value));
2034						wmoverview.appendChild(dd);
2035					}
2036				}
2037
2038				// update the webmetrics clients list:
2039				const wmclients = document.getElementById('botmon__today__wm_clients');
2040				if (wmclients) {
2041
2042					wmclients.appendChild(makeElement('dt', {}, "Browsers"));
2043
2044					const clientList = BotMon.live.data.analytics.getTopBrowsers(maxItemsPerList);
2045					if (clientList) {
2046						clientList.forEach( (cInfo) => {
2047							const cDd = makeElement('dd');
2048							cDd.appendChild(makeElement('span', {'class': 'has_icon client cl_' + cInfo.id }, ( cInfo.name ? cInfo.name : cInfo.id)));
2049							cDd.appendChild(makeElement('span', {
2050								'class': 'count',
2051								'title': cInfo.count + " page views"
2052							}, cInfo.pct.toFixed(1) + '%'));
2053							wmclients.appendChild(cDd);
2054						});
2055					}
2056				}
2057
2058				// update the webmetrics platforms list:
2059				const wmplatforms = document.getElementById('botmon__today__wm_platforms');
2060				if (wmplatforms) {
2061
2062					wmplatforms.appendChild(makeElement('dt', {}, "Platforms"));
2063
2064					const pfList = BotMon.live.data.analytics.getTopPlatforms(maxItemsPerList);
2065					if (pfList) {
2066						pfList.forEach( (pInfo) => {
2067							const pDd = makeElement('dd');
2068							pDd.appendChild(makeElement('span', {'class': 'has_icon platform pf_' + pInfo.id }, ( pInfo.name ? pInfo.name : pInfo.id)));
2069							pDd.appendChild(makeElement('span', {
2070								'class': 'count',
2071								'title': pInfo.count + " page views"
2072							}, pInfo.pct.toFixed(1) + '%'));
2073							wmplatforms.appendChild(pDd);
2074						});
2075					}
2076				}
2077
2078				// update the top bot countries list:
2079				const usrCountries = document.getElementById('botmon__today__wm_countries');
2080				if (usrCountries) {
2081					usrCountries.appendChild(makeElement('dt', {}, `Top visitor Countries:`));
2082					const usrCtryList = BotMon.live.data.analytics.getCountryList('human', 5);
2083					usrCtryList.forEach( (cInfo) => {
2084						const cLi = makeElement('dd');
2085						cLi.appendChild(makeElement('span', {'class': 'has_icon country ctry_' + cInfo.id.toLowerCase() }, cInfo.name));
2086						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count));
2087						usrCountries.appendChild(cLi);
2088					});
2089				}
2090
2091				// update the top pages;
2092				const wmpages = document.getElementById('botmon__today__wm_pages');
2093				if (wmpages) {
2094
2095					wmpages.appendChild(makeElement('dt', {}, "Top pages"));
2096
2097					const pgList = BotMon.live.data.analytics.getTopPages(maxItemsPerList);
2098					if (pgList) {
2099						pgList.forEach( (pgInfo) => {
2100							const pgDd = makeElement('dd');
2101							pgDd.appendChild(makeElement('a', {
2102								'class': 'page_icon',
2103								'href': DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pgInfo.id),
2104								'target': 'preview',
2105								'title': "PageID: " + pgInfo.id
2106							}, pgInfo.id));
2107							pgDd.appendChild(makeElement('span', {
2108								'class': 'count',
2109								'title': pgInfo.count + " page views"
2110							}, pgInfo.count));
2111							wmpages.appendChild(pgDd);
2112						});
2113					}
2114				}
2115
2116				// update the top referrers;
2117				const wmreferers = document.getElementById('botmon__today__wm_referers');
2118				if (wmreferers) {
2119
2120					wmreferers.appendChild(makeElement('dt', {}, "Referers"));
2121
2122					const refList = BotMon.live.data.analytics.getTopReferers(maxItemsPerList);
2123					if (refList) {
2124						refList.forEach( (rInfo) => {
2125							const rDd = makeElement('dd');
2126							rDd.appendChild(makeElement('span', {'class': 'has_icon referer ref_' + rInfo.id }, rInfo.name));
2127							rDd.appendChild(makeElement('span', {
2128								'class': 'count',
2129								'title': rInfo.count + " references"
2130							}, rInfo.pct.toFixed(1) + '%'));
2131							wmreferers.appendChild(rDd);
2132						});
2133					}
2134				}
2135			}
2136		},
2137
2138		status: {
2139			setText: function(txt) {
2140				const el = document.getElementById('botmon__today__status');
2141				if (el && BotMon.live.gui.status._errorCount <= 0) {
2142					el.innerText = txt;
2143				}
2144			},
2145
2146			setTitle: function(html) {
2147				const el = document.getElementById('botmon__today__title');
2148				if (el) {
2149					el.innerHTML = html;
2150				}
2151			},
2152
2153			setError: function(txt) {
2154				console.error(txt);
2155				BotMon.live.gui.status._errorCount += 1;
2156				const el = document.getElementById('botmon__today__status');
2157				if (el) {
2158					el.innerText = "Data may be incomplete.";
2159					el.classList.add('error');
2160				}
2161			},
2162			_errorCount: 0,
2163
2164			showBusy: function(txt = null) {
2165				BotMon.live.gui.status._busyCount += 1;
2166				const el = document.getElementById('botmon__today__busy');
2167				if (el) {
2168					el.style.display = 'inline-block';
2169				}
2170				if (txt) BotMon.live.gui.status.setText(txt);
2171			},
2172			_busyCount: 0,
2173
2174			hideBusy: function(txt = null) {
2175				const el = document.getElementById('botmon__today__busy');
2176				BotMon.live.gui.status._busyCount -= 1;
2177				if (BotMon.live.gui.status._busyCount <= 0) {
2178					if (el) el.style.display = 'none';
2179					if (txt) BotMon.live.gui.status.setText(txt);
2180				}
2181			}
2182		},
2183
2184		lists: {
2185			init: function() {
2186
2187				// function shortcut:
2188				const makeElement = BotMon.t._makeElement;
2189
2190				const parent = document.getElementById('botmon__today__visitorlists');
2191				if (parent) {
2192
2193					for (let i=0; i < 4; i++) {
2194
2195						// change the id and title by number:
2196						let listTitle = '';
2197						let listId = '';
2198						let infolink = null;
2199						switch (i) {
2200							case 0:
2201								listTitle = "Registered users";
2202								listId = 'users';
2203								break;
2204							case 1:
2205								listTitle = "Probably humans";
2206								listId = 'humans';
2207								break;
2208							case 2:
2209								listTitle = "Suspected bots";
2210								listId = 'suspectedBots';
2211								infolink = 'https://leib.be/sascha/projects/dokuwiki/botmon/info/suspected_bots';
2212								break;
2213							case 3:
2214								listTitle = "Known bots";
2215								listId = 'knownBots';
2216								infolink = 'https://leib.be/sascha/projects/dokuwiki/botmon/info/known_bots';
2217								break;
2218							default:
2219								console.warn('Unknown list number.');
2220						}
2221
2222						const details = makeElement('details', {
2223							'data-group': listId,
2224							'data-loaded': false
2225						});
2226						const title = details.appendChild(makeElement('summary'));
2227						title.appendChild(makeElement('span', {'class': 'title'}, listTitle));
2228						if (infolink) {
2229							title.appendChild(makeElement('a', {
2230								'class': 'ext_info',
2231								'target': '_blank',
2232								'href': infolink,
2233								'title': "More information"
2234							}, "Info"));
2235						}
2236						details.addEventListener("toggle", this._onDetailsToggle);
2237
2238						parent.appendChild(details);
2239
2240					}
2241				}
2242			},
2243
2244			_onDetailsToggle: function(e) {
2245				//console.info('BotMon.live.gui.lists._onDetailsToggle()');
2246
2247				const target = e.target;
2248
2249				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
2250					target.setAttribute('data-loaded', 'loading');
2251
2252					const fillType = target.getAttribute('data-group');
2253					const fillList = BotMon.live.data.analytics.groups[fillType];
2254					if (fillList && fillList.length > 0) {
2255
2256						const ul = BotMon.t._makeElement('ul');
2257
2258						fillList.forEach( (it) => {
2259							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
2260						});
2261
2262						target.appendChild(ul);
2263						target.setAttribute('data-loaded', 'true');
2264					} else {
2265						target.setAttribute('data-loaded', 'false');
2266					}
2267
2268				}
2269			},
2270
2271			_makeVisitorItem: function(data, type) {
2272
2273				// shortcuts for neater code:
2274				const make = BotMon.t._makeElement;
2275				const model = BotMon.live.data.model;
2276
2277				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
2278				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
2279
2280				const platformName = (data._platform ? data._platform.n : 'Unknown');
2281				const clientName = (data._client ? data._client.n: 'Unknown');
2282
2283				const sumClass = ( !data._seenBy || data._seenBy.indexOf(BM_LOGTYPE.SERVER) < 0 ? 'noServer' : 'hasServer');
2284
2285				const li = make('li'); // root list item
2286				const details = make('details');
2287				const summary = make('summary', {
2288					'class': sumClass
2289				});
2290				details.appendChild(summary);
2291
2292				const span1 = make('span'); /* left-hand group */
2293
2294				if (data._type !== BM_USERTYPE.KNOWN_BOT) { /* No platform/client for bots */
2295					span1.appendChild(make('span', { /* Platform */
2296						'class': 'icon_only platform pf_' + (data._platform ? data._platform.id : 'unknown'),
2297						'title': "Platform: " + platformName
2298					}, platformName));
2299
2300					span1.appendChild(make('span', { /* Client */
2301						'class': 'icon_only client client cl_' + (data._client ? data._client.id : 'unknown'),
2302						'title': "Client: " + clientName
2303					}, clientName));
2304				}
2305
2306				// identifier:
2307				if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
2308
2309					const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
2310					span1.appendChild(make('span', { /* Bot */
2311						'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown'),
2312						'title': "Bot: " + botName
2313					}, botName));
2314
2315				} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
2316
2317					span1.appendChild(make('span', { /* User */
2318						'class': 'has_icon user_known',
2319						'title': "User: " + data.usr
2320					}, data.usr));
2321
2322				} else { /* others */
2323
2324
2325					span1.appendChild(make('span', { // IP-Address
2326						'class': 'has_icon ipaddr ip' + ipType,
2327						'title': "IP-Address: " + data.ip
2328					}, data.ip));
2329
2330					/*span1.appendChild(make('span', { // Internal ID
2331						'class': 'has_icon session typ_' + data.typ,
2332						'title': "ID: " + data.id
2333					}, data.id));*/
2334				}
2335
2336				span1.appendChild(make('span', { /* page views */
2337					'class': 'has_icon pageseen',
2338					'title': data._pageViews.length + " page load(s)"
2339				}, data._pageViews.length));
2340
2341
2342				// referer icons:
2343				if ((data._type == BM_USERTYPE.PROBABLY_HUMAN || data._type == BM_USERTYPE.LIKELY_BOT) && data.ref) {
2344					const refInfo = BotMon.live.data.analytics.getRefererInfo(data.ref);
2345					span1.appendChild(make('span', {
2346						'class': 'icon_only referer ref_' + refInfo.id,
2347						'title': "Referer: " + data.ref
2348					}, refInfo.n));
2349				}
2350
2351				summary.appendChild(span1);
2352				const span2 = make('span'); /* right-hand group */
2353
2354				// country flag:
2355					if (data.geo && data.geo !== 'ZZ') {
2356						span2.appendChild(make('span', {
2357							'class': 'icon_only country ctry_' + data.geo.toLowerCase(),
2358							'data-ctry': data.geo,
2359							'title': "Country: " + ( data._country || "Unknown")
2360						}, ( data._country || "Unknown") ));
2361					}
2362
2363					span2.appendChild(make('span', { // seen-by icon:
2364						'class': 'icon_only seenby sb_' + data._seenBy.join(''),
2365						'title': "Seen by: " + data._seenBy.join('+')
2366					}, data._seenBy.join(', ')));
2367
2368					// captcha status:
2369					const cCode = ( data._captcha ? data._captcha._str() : '');
2370					if (cCode !== '') {
2371						const cTitle = model._makeCaptchaTitle(data._captcha)
2372						span2.appendChild(make('span', { // captcha status
2373							'class': 'icon_only captcha cap_' + cCode,
2374							'title': "Captcha-status: " + cTitle
2375						}, cTitle));
2376					}
2377
2378				summary.appendChild(span2);
2379
2380				// add details expandable section:
2381				details.appendChild(BotMon.live.gui.lists._makeVisitorDetails(data, type));
2382
2383				li.appendChild(details);
2384				return li;
2385			},
2386
2387			_makeVisitorDetails: function(data, type) {
2388
2389				// shortcuts for neater code:
2390				const make = BotMon.t._makeElement;
2391				const model = BotMon.live.data.model;
2392
2393				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
2394				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
2395				const platformName = (data._platform ? data._platform.n : 'Unknown');
2396				const clientName = (data._client ? data._client.n: 'Unknown');
2397
2398				const dl = make('dl', {'class': 'visitor_details'});
2399
2400				if (data._type == BM_USERTYPE.KNOWN_BOT) {
2401
2402					dl.appendChild(make('dt', {}, "Bot name:")); /* bot info */
2403					dl.appendChild(make('dd', {'class': 'icon_only bot bot_' + (data._bot ? data._bot.id : 'unknown')},
2404						(data._bot ? data._bot.n : 'Unknown')));
2405
2406					if (data._bot && data._bot.url) {
2407						dl.appendChild(make('dt', {}, "Bot info:")); /* bot info */
2408						const botInfoDd = dl.appendChild(make('dd'));
2409						botInfoDd.appendChild(make('a', {
2410							'href': data._bot.url,
2411							'target': '_blank'
2412						}, data._bot.url)); /* bot info link*/
2413
2414					}
2415
2416				} else { /* not for bots */
2417
2418					dl.appendChild(make('dt', {}, "Client:")); /* client */
2419					dl.appendChild(make('dd', {'class': 'has_icon client cl_' + (data._client ? data._client.id : 'unknown')},
2420						clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
2421
2422					dl.appendChild(make('dt', {}, "Platform:")); /* platform */
2423					dl.appendChild(make('dd', {'class': 'has_icon platform pf_' + (data._platform ? data._platform.id : 'unknown')},
2424						platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
2425
2426					/*dl.appendChild(make('dt', {}, "ID:"));
2427					dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));*/
2428				}
2429
2430				dl.appendChild(make('dt', {}, "IP-Address:"));
2431				const ipItem = make('dd', {'class': 'has_icon ipaddr ip' + ipType});
2432					ipItem.appendChild(make('span', {'class': 'address'} , data.ip));
2433					ipItem.appendChild(make('a', {
2434						'class': 'icon_only extlink ipinfo',
2435						'href': `https://ipinfo.io/${encodeURIComponent(data.ip)}`,
2436						'target': 'ipinfo',
2437						'title': "View this address on IPInfo.io"
2438					} , "DNS Info"));
2439					ipItem.appendChild(make('a', {
2440						'class': 'icon_only extlink abuseipdb',
2441						'href': `https://www.abuseipdb.com/check/${encodeURIComponent(data.ip)}`,
2442						'target': 'abuseipdb',
2443						'title': "Check this address on AbuseIPDB.com"
2444					} , "Check on AbuseIPDB"));
2445				dl.appendChild(ipItem);
2446
2447				if (Math.abs(data._lastSeen - data._firstSeen) < 100) {
2448					dl.appendChild(make('dt', {}, "Seen:"));
2449					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
2450				} else {
2451					dl.appendChild(make('dt', {}, "First seen:"));
2452					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
2453					dl.appendChild(make('dt', {}, "Last seen:"));
2454					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
2455				}
2456
2457				dl.appendChild(make('dt', {}, "Actions:"));
2458				dl.appendChild(make('dd', {'class': 'views'},
2459					"Page loads: " + data._loadCount.toString() +
2460					( data._captcha['Y'] > 0 || data._captcha['W'] || data._captcha['-'] > 0 ? ", captchas: " + (data._captcha['Y']+data._captcha['W']+data._captcha['-']).toString() : '') +
2461					", views: " + data._viewCount.toString()
2462				));
2463
2464				dl.appendChild(make('dt', {}, "User-Agent:"));
2465				dl.appendChild(make('dd', {'class': 'agent'}, data.agent));
2466
2467				if (data.ref && data.ref !== '') {
2468					dl.appendChild(make('dt', {}, "Referrer:"));
2469
2470					const refInfo = BotMon.live.data.analytics.getRefererInfo(data.ref);
2471					const refDd = dl.appendChild(make('dd', {
2472						'class': 'has_icon referer ref_' + refInfo.id
2473					}));
2474					refDd.appendChild(make('a', {
2475						'href': data.ref,
2476						'target': 'refTarget'
2477					}, data.ref));
2478				}
2479
2480				dl.appendChild(make('dt', {}, "Languages:"));
2481				dl.appendChild(make('dd', {'class': 'langs'}, ` [${data.accept}]`));
2482
2483				if (data.geo && data.geo !=='') {
2484					dl.appendChild(make('dt', {}, "Location:"));
2485					dl.appendChild(make('dd', {
2486						'class': 'has_icon country ctry_' + data.geo.toLowerCase(),
2487						'data-ctry': data.geo,
2488						'title': "Country: " + data._country
2489					}, data._country + ' (' + data.geo + ')'));
2490				}
2491
2492				if (data.captcha && data.captcha !=='') {
2493					dl.appendChild(make('dt', {}, "Captcha-status:"));
2494					dl.appendChild(make('dd', {
2495						'class': 'captcha'
2496					}, model._makeCaptchaTitle(data._captcha)));
2497				}
2498
2499				dl.appendChild(make('dt', {}, "Session ID:"));
2500				dl.appendChild(make('dd', {'class': 'has_icon session typ_' + data.typ}, data.id));
2501
2502				dl.appendChild(make('dt', {}, "Seen by:"));
2503				dl.appendChild(make('dd', {'class': 'has_icon seenby sb_' + data._seenBy.join('')}, data._seenBy.join(', ') ));
2504
2505				dl.appendChild(make('dt', {}, "Visited pages:"));
2506				const pagesDd = make('dd', {'class': 'pages'});
2507				const pageList = make('ul');
2508
2509				/* list all page views */
2510				data._pageViews.sort( (a, b) => a._firstSeen - b._firstSeen );
2511				data._pageViews.forEach( (page) => {
2512					pageList.appendChild(BotMon.live.gui.lists._makePageViewItem(page));
2513				});
2514				pagesDd.appendChild(pageList);
2515				dl.appendChild(pagesDd);
2516
2517				/* bot evaluation rating */
2518				if (data._type !== BM_USERTYPE.KNOWN_BOT && data._type !== BM_USERTYPE.KNOWN_USER) {
2519					dl.appendChild(make('dt', undefined, "Bot rating:"));
2520					dl.appendChild(make('dd', {'class': 'bot-rating'}, ( data._botVal ? data._botVal : '–' ) + ' (of ' + BotMon.live.data.rules._threshold + ')'));
2521
2522					/* add bot evaluation details: */
2523					if (data._eval) {
2524						dl.appendChild(make('dt', {}, "Bot evaluation:"));
2525						const evalDd = make('dd', {'class': 'eval'});
2526						const testList = make('ul');
2527						data._eval.forEach( test => {
2528
2529							const tObj = BotMon.live.data.rules.getRuleInfo(test);
2530							let tDesc = tObj ? tObj.desc : test;
2531
2532							// special case for Bot IP range test:
2533							if (tObj.func == 'fromKnownBotIP') {
2534								const rangeInfo = BotMon.live.data.ipRanges.match(data.ip);
2535								if (rangeInfo) {
2536									const owner = BotMon.live.data.ipRanges.getOwner(rangeInfo.g);
2537									tDesc += ' (range: “' + rangeInfo.cidr + '”, ' + owner + ')';
2538								}
2539							}
2540
2541							// create the entry field
2542							const tstLi = make('li');
2543							tstLi.appendChild(make('span', {
2544								'data-testid': test
2545							}, tDesc));
2546							tstLi.appendChild(make('span', {}, ( tObj ? tObj.bot : '—') ));
2547							testList.appendChild(tstLi);
2548						});
2549
2550						// add total row
2551						const tst2Li = make('li', {
2552							'class': 'total'
2553						});
2554						/*tst2Li.appendChild(make('span', {}, "Total:"));
2555						tst2Li.appendChild(make('span', {}, data._botVal));
2556						testList.appendChild(tst2Li);*/
2557
2558						evalDd.appendChild(testList);
2559						dl.appendChild(evalDd);
2560					}
2561				}
2562				// return the element to add to the UI:
2563				return dl;
2564			},
2565
2566			// make a page view item:
2567			_makePageViewItem: function(page) {
2568				//console.log("makePageViewItem:",page);
2569
2570				// shortcut for neater code:
2571				const make = BotMon.t._makeElement;
2572
2573				// the actual list item:
2574				const pgLi = make('li');
2575
2576				const row1 = make('div', {'class': 'row'});
2577
2578					row1.appendChild(make('a', { // page id is the left group
2579						'href': DOKU_BASE + 'doku.php?id=' + encodeURIComponent(page.pg),
2580						'target': 'preview',
2581						'hreflang': page.lang,
2582						'title': "PageID: " + page.pg
2583					}, page.pg)); /* DW Page ID */
2584
2585					const rightGroup = row1.appendChild(make('div')); // right-hand group
2586
2587						// get the time difference:
2588						rightGroup.appendChild(make('span', {
2589							'class': 'first-seen',
2590							'title': "First visited: " + page._firstSeen.toLocaleString() + " UTC"
2591						}, BotMon.t._formatTime(page._firstSeen)));
2592
2593						rightGroup.appendChild(make('span', { /* page loads */
2594							'class': 'has_icon pageviews',
2595							'title': page._viewCount.toString() + " page load(s)"
2596						}, page._viewCount.toString()));
2597
2598				pgLi.appendChild(row1);
2599
2600				/* LINE 2 */
2601
2602				const row2 = make('div', {'class': 'row'});
2603
2604					// page referrer:
2605					if (page._ref) {
2606						row2.appendChild(make('span', {
2607							'class': 'referer',
2608							'title': "Referrer: " + page._ref.href
2609						}, page._ref.hostname));
2610					} else {
2611						row2.appendChild(make('span', {
2612							'class': 'referer'
2613						}, "No referer"));
2614					}
2615
2616					// visit duration:
2617					let visitTimeStr = "Bounce";
2618					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
2619					if (visitDuration > 0) {
2620						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
2621					}
2622					const tDiff = BotMon.t._formatTimeDiff(page._firstSeen, page._lastSeen);
2623					if (tDiff) {
2624						row2.appendChild(make('span', {'class': 'visit-length', 'title': 'Last seen: ' + page._lastSeen.toLocaleString()}, tDiff));
2625					} else {
2626						row2.appendChild(make('span', {
2627							'class': 'bounce',
2628							'title': "Visitor bounced"}, "Bounce"));
2629					}
2630
2631				pgLi.appendChild(row2);
2632
2633				return pgLi;
2634			}
2635		}
2636	}
2637};
2638
2639/* launch only if the BotMon admin panel is open: */
2640if (document.getElementById('botmon__admin')) {
2641	BotMon.init();
2642}