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