From 2bbdef77a372aff180095469eeee5932574bd45d Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Mon, 4 Jan 2021 21:32:03 +0100 Subject: [PATCH] Add goatcounter as analytics source --- articles/context_processors.py | 7 +- articles/static/edit-keymap.js | 8 +- articles/static/vendor/goatcounter.js | 233 ++++++++++++++++++ articles/templates/articles/base.html | 2 +- .../articles/snippets/analytics.html | 19 +- blog/settings.py | 7 +- poetry.lock | 30 ++- pyproject.toml | 1 + 8 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 articles/static/vendor/goatcounter.js diff --git a/articles/context_processors.py b/articles/context_processors.py index 44a72e8..6a99ca8 100644 --- a/articles/context_processors.py +++ b/articles/context_processors.py @@ -34,8 +34,11 @@ def git_version(request): return {"git_version": version, "git_version_url": url} -def plausible(request): - return {"plausible_domain": settings.PLAUSIBLE_DOMAIN} +def analytics(request): + return { + "plausible_domain": settings.PLAUSIBLE_DOMAIN, + "goatcounter_domain": settings.GOATCOUNTER_DOMAIN, + } def open_graph_image_url(request): diff --git a/articles/static/edit-keymap.js b/articles/static/edit-keymap.js index 2e74e61..b0fbd21 100644 --- a/articles/static/edit-keymap.js +++ b/articles/static/edit-keymap.js @@ -1,10 +1,12 @@ +'use strict'; +// Explicitely not using ES 6 features because the compressor doesn't support them. window.onload = function () { - const adminLinkElement = document.querySelector("a#admin-link"); + var adminLinkElement = document.querySelector("a#admin-link"); if (adminLinkElement === undefined || adminLinkElement === null) { return; } - const adminLocation = adminLinkElement.href; - document.addEventListener("keydown", event => { + var adminLocation = adminLinkElement.href; + document.addEventListener("keydown", function(event) { if (event.code === "KeyE") { window.location = adminLocation; } diff --git a/articles/static/vendor/goatcounter.js b/articles/static/vendor/goatcounter.js new file mode 100644 index 0000000..4dc7659 --- /dev/null +++ b/articles/static/vendor/goatcounter.js @@ -0,0 +1,233 @@ +// GoatCounter: https://www.goatcounter.com +// This file (and *only* this file) is released under the ISC license: +// https://opensource.org/licenses/ISC +(function() { + 'use strict'; + + if (window.goatcounter && window.goatcounter.vars) // Compatibility + window.goatcounter = window.goatcounter.vars + else + window.goatcounter = window.goatcounter || {} + + // Get all data we're going to send off to the counter endpoint. + var get_data = function(vars) { + var data = { + p: (vars.path === undefined ? goatcounter.path : vars.path), + r: (vars.referrer === undefined ? goatcounter.referrer : vars.referrer), + t: (vars.title === undefined ? goatcounter.title : vars.title), + e: !!(vars.event || goatcounter.event), + s: [window.screen.width, window.screen.height, (window.devicePixelRatio || 1)], + b: is_bot(), + q: location.search, + } + + var rcb, pcb, tcb // Save callbacks to apply later. + if (typeof(data.r) === 'function') rcb = data.r + if (typeof(data.t) === 'function') tcb = data.t + if (typeof(data.p) === 'function') pcb = data.p + + if (is_empty(data.r)) data.r = document.referrer + if (is_empty(data.t)) data.t = document.title + if (is_empty(data.p)) data.p = get_path() + + if (rcb) data.r = rcb(data.r) + if (tcb) data.t = tcb(data.t) + if (pcb) data.p = pcb(data.p) + return data + } + + // Check if a value is "empty" for the purpose of get_data(). + var is_empty = function(v) { return v === null || v === undefined || typeof(v) === 'function' } + + // See if this looks like a bot; there is some additional filtering on the + // backend, but these properties can't be fetched from there. + var is_bot = function() { + // Headless browsers are probably a bot. + var w = window, d = document + if (w.callPhantom || w._phantom || w.phantom) + return 150 + if (w.__nightmare) + return 151 + if (d.__selenium_unwrapped || d.__webdriver_evaluate || d.__driver_evaluate) + return 152 + if (navigator.webdriver) + return 153 + return 0 + } + + // Object to urlencoded string, starting with a ?. + var urlencode = function(obj) { + var p = [] + for (var k in obj) + if (obj[k] !== '' && obj[k] !== null && obj[k] !== undefined && obj[k] !== false) + p.push(encodeURIComponent(k) + '=' + encodeURIComponent(obj[k])) + return '?' + p.join('&') + } + + // Show a warning in the console. + var warn = function(msg) { + if (console && 'warn' in console) + console.warn('goatcounter: ' + msg) + } + + // Get the endpoint to send requests to. + var get_endpoint = function() { + var s = document.querySelector('script[data-goatcounter]'); + if (s && s.dataset.goatcounter) + return s.dataset.goatcounter + return (goatcounter.endpoint || window.counter) // counter is for compat; don't use. + } + + // Get current path. + var get_path = function() { + var loc = location, + c = document.querySelector('link[rel="canonical"][href]') + if (c) { // May be relative or point to different domain. + var a = document.createElement('a') + a.href = c.href + if (a.hostname.replace(/^www\./, '') === location.hostname.replace(/^www\./, '')) + loc = a + } + return (loc.pathname + loc.search) || '/' + } + + // Filter some requests that we (probably) don't want to count. + goatcounter.filter = function() { + if ('visibilityState' in document && (document.visibilityState === 'prerender' || document.visibilityState === 'hidden')) + return 'visibilityState' + if (!goatcounter.allow_frame && location !== parent.location) + return 'frame' + if (!goatcounter.allow_local && location.hostname.match(/(localhost$|^127\.|^10\.|^172\.(1[6-9]|2[0-9]|3[0-1])\.|^192\.168\.)/)) + return 'localhost' + if (!goatcounter.allow_local && location.protocol === 'file:') + return 'localfile' + if (localStorage && localStorage.getItem('skipgc') === 't') + return 'disabled with #toggle-goatcounter' + return false + } + + // Get URL to send to GoatCounter. + window.goatcounter.url = function(vars) { + var data = get_data(vars || {}) + if (data.p === null) // null from user callback. + return + data.rnd = Math.random().toString(36).substr(2, 5) // Browsers don't always listen to Cache-Control. + + var endpoint = get_endpoint() + if (!endpoint) + return warn('no endpoint found') + + return endpoint + urlencode(data) + } + + // Count a hit. + window.goatcounter.count = function(vars) { + var f = goatcounter.filter() + if (f) + return warn('not counting because of: ' + f) + + var url = goatcounter.url(vars) + if (!url) + return warn('not counting because path callback returned null') + + var img = document.createElement('img') + img.src = url + img.style.position = 'absolute' // Affect layout less. + img.setAttribute('alt', '') + img.setAttribute('aria-hidden', 'true') + + var rm = function() { if (img && img.parentNode) img.parentNode.removeChild(img) } + setTimeout(rm, 3000) // In case the onload isn't triggered. + img.addEventListener('load', rm, false) + document.body.appendChild(img) + } + + // Get a query parameter. + window.goatcounter.get_query = function(name) { + var s = location.search.substr(1).split('&') + for (var i = 0; i < s.length; i++) + if (s[i].toLowerCase().indexOf(name.toLowerCase() + '=') === 0) + return s[i].substr(name.length + 1) + } + + // Track click events. + window.goatcounter.bind_events = function() { + if (!document.querySelectorAll) // Just in case someone uses an ancient browser. + return + + var send = function(elem) { + return function() { + goatcounter.count({ + event: true, + path: (elem.dataset.goatcounterClick || elem.name || elem.id || ''), + title: (elem.dataset.goatcounterTitle || elem.title || (elem.innerHTML || '').substr(0, 200) || ''), + referrer: (elem.dataset.goatcounterReferrer || elem.dataset.goatcounterReferral || ''), + }) + } + } + + Array.prototype.slice.call(document.querySelectorAll("*[data-goatcounter-click]")).forEach(function(elem) { + if (elem.dataset.goatcounterBound) + return + var f = send(elem) + elem.addEventListener('click', f, false) + elem.addEventListener('auxclick', f, false) // Middle click. + elem.dataset.goatcounterBound = 'true' + }) + } + + // Add a "visitor counter" frame or image. + window.goatcounter.visit_count = function(opt) { + opt = opt || {} + opt.type = opt.type || 'html' + opt.append = opt.append || 'body' + opt.path = opt.path || get_path() + opt.attr = opt.attr || {width: '200', height: (opt.no_branding ? '60' : '80')} + + opt.attr['src'] = get_endpoint() + 'er/' + encodeURIComponent(opt.path) + '.' + opt.type + '?' + if (opt.no_branding) opt.attr['src'] += '&no_branding=1' + if (opt.style) opt.attr['src'] += '&style=' + encodeURIComponent(opt.style) + + var tag = {png: 'img', svg: 'img', html: 'iframe'}[opt.type] + if (!tag) + return warn('visit_count: unknown type: ' + opt.type) + + if (opt.type === 'html') { + opt.attr['frameborder'] = '0' + opt.attr['scrolling'] = 'no' + } + + var d = document.createElement(tag) + for (var k in opt.attr) + d.setAttribute(k, opt.attr[k]) + + var p = document.querySelector(opt.append) + if (!p) + return warn('visit_count: append not found: ' + opt.append) + p.appendChild(d) + } + + // Make it easy to skip your own views. + if (location.hash === '#toggle-goatcounter') + if (localStorage.getItem('skipgc') === 't') { + localStorage.removeItem('skipgc', 't') + alert('GoatCounter tracking is now ENABLED in this browser.') + } + else { + localStorage.setItem('skipgc', 't') + alert('GoatCounter tracking is now DISABLED in this browser until ' + location + ' is loaded again.') + } + + if (!goatcounter.no_onload) { + var go = function() { + goatcounter.count() + if (!goatcounter.no_events) + goatcounter.bind_events() + } + + if (document.body === null) + document.addEventListener('DOMContentLoaded', function() { go() }, false) + else + go() + } +})(); diff --git a/articles/templates/articles/base.html b/articles/templates/articles/base.html index f18f82f..2a9ab06 100644 --- a/articles/templates/articles/base.html +++ b/articles/templates/articles/base.html @@ -32,7 +32,6 @@ {% endcompress %} {% include "articles/snippets/favicon.html" %} - {% include "articles/snippets/analytics.html" %}
@@ -60,6 +59,7 @@ for ongoing builds here.

+{% include "articles/snippets/analytics.html" %} {% endspaceless %} diff --git a/articles/templates/articles/snippets/analytics.html b/articles/templates/articles/snippets/analytics.html index ac6e328..2b163b4 100644 --- a/articles/templates/articles/snippets/analytics.html +++ b/articles/templates/articles/snippets/analytics.html @@ -1,4 +1,19 @@ +{% load static compress %} + {% if plausible_domain is not None and not user.is_authenticated %} - - + {% endif %} + +{% compress js inline %} + {% if goatcounter_domain is not None and not user.is_authenticated %} + + + {% endif %} +{% endcompress %} diff --git a/blog/settings.py b/blog/settings.py index 6df4580..4b8cac6 100644 --- a/blog/settings.py +++ b/blog/settings.py @@ -99,7 +99,7 @@ TEMPLATES = [ "articles.context_processors.drafts_count", "articles.context_processors.date_format", "articles.context_processors.git_version", - "articles.context_processors.plausible", + "articles.context_processors.analytics", "articles.context_processors.open_graph_image_url", "articles.context_processors.blog_metadata", ], @@ -191,6 +191,7 @@ SHORTPIXEL_RESIZE_WIDTH = int(os.getenv("SHORTPIXEL_RESIZE_WIDTH", 750)) SHORTPIXEL_RESIZE_HEIGHT = int(os.getenv("SHORTPIXEL_RESIZE_HEIGHT", 10000)) PLAUSIBLE_DOMAIN = os.getenv("PLAUSIBLE_DOMAIN") +GOATCOUNTER_DOMAIN = os.getenv("GOATCOUNTER_DOMAIN") LOGIN_URL = "admin:login" @@ -203,7 +204,9 @@ COMPRESS_FILTERS = { "compressor.filters.css_default.CssAbsoluteFilter", "compressor.filters.cssmin.rCSSMinFilter", ], - "js": ["compressor.filters.jsmin.JSMinFilter"], + "js": [ + "compressor.filters.jsmin.CalmjsFilter", + ], } if DEBUG: COMPRESS_DEBUG_TOGGLE = "nocompress" diff --git a/poetry.lock b/poetry.lock index e616f9d..c35fa15 100644 --- a/poetry.lock +++ b/poetry.lock @@ -54,6 +54,17 @@ soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} html5lib = ["html5lib"] lxml = ["lxml"] +[[package]] +name = "calmjs.parse" +version = "1.2.5" +description = "Various parsers for ECMA standards." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ply = ">=3.6" + [[package]] name = "certifi" version = "2020.12.5" @@ -327,6 +338,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] dev = ["pre-commit", "tox"] +[[package]] +name = "ply" +version = "3.11" +description = "Python Lex & Yacc" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "pre-commit" version = "2.9.3" @@ -606,7 +625,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "0b9ac9fa575f31f30485f510b5da016453e0387bc8805e56ea52f5dc09de095d" +content-hash = "e55cff591236bac6ee7f57a2da6cb48fcf0484f5a1d17a26949c3c724dad0087" [metadata.files] appdirs = [ @@ -630,6 +649,11 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] +"calmjs.parse" = [ + {file = "calmjs.parse-1.2.5-py2-none-any.whl", hash = "sha256:dd2c576169e683f0a06b5e8908d7b9d134e756ae75763c21b721d9937b0b422a"}, + {file = "calmjs.parse-1.2.5-py3-none-any.whl", hash = "sha256:163ae575c478944c3a54c8e26d5cca794a977c5bf160f6aaa3731b51ae88ab99"}, + {file = "calmjs.parse-1.2.5.zip", hash = "sha256:693d18bcfdb14f2a33f64288680adc138275d39de5980fb46f5b882df6e1dbde"}, +] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, @@ -881,6 +905,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +ply = [ + {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, + {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, +] pre-commit = [ {file = "pre_commit-2.9.3-py2.py3-none-any.whl", hash = "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0"}, {file = "pre_commit-2.9.3.tar.gz", hash = "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"}, diff --git a/pyproject.toml b/pyproject.toml index 1644542..9955af1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ requests = "^2.24" html2text = "^2020.1.16" readtime = "^1.1.1" django-compressor = "^2.4" +"calmjs.parse" = "^1.2.5" [tool.poetry.dev-dependencies] pre-commit = "^2.7"