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}