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