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