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