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