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