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