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