354 lines
15 KiB
JavaScript
354 lines
15 KiB
JavaScript
/* WEBklar Analyse w.js - generiert aus client/core.js + client/w.js (node client/build.js), nicht von Hand aendern */
|
|
(function () {
|
|
'use strict';
|
|
// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
|
|
// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
|
|
|
|
const CONSENT_KEY = 'wk-consent';
|
|
const CONSENT_MAX_AGE = 183 * 86400000;
|
|
|
|
function vpClass(width) {
|
|
const w = Number(width);
|
|
if (w < 768) return 'm';
|
|
if (w < 1024) return 't';
|
|
return 'd';
|
|
}
|
|
|
|
function cleanPath(href) {
|
|
let p = '/';
|
|
const h = String(href == null ? '' : href);
|
|
if (h.charAt(0) === '/') {
|
|
p = h.replace(/[?#].*$/, '') || '/'; // wie location.pathname, auch '//foo'
|
|
} else {
|
|
try { p = new URL(h, 'http://x.invalid/').pathname || '/'; } catch (e) { /* bleibt '/' */ }
|
|
}
|
|
try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
|
|
if (p.charAt(0) !== '/') p = '/' + p;
|
|
return p.slice(0, 300);
|
|
}
|
|
|
|
const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
|
|
|
|
function refHost(referrer, ownHost) {
|
|
if (!referrer) return '';
|
|
try {
|
|
const u = new URL(String(referrer));
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
|
const h = u.hostname.toLowerCase();
|
|
if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
|
|
return h;
|
|
} catch (e) { return ''; }
|
|
}
|
|
|
|
// Nur Tag-Namen mit :nth-of-type, keine IDs/Klassen (koennten personenbezogene Texte enthalten).
|
|
function cssPath(el) {
|
|
const parts = [];
|
|
let n = el;
|
|
while (n && n.tagName && parts.length < 6) {
|
|
const tag = String(n.tagName).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
if (!tag) break;
|
|
let idx = 1;
|
|
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
|
if (String(s.tagName).toLowerCase() === String(n.tagName).toLowerCase()) idx++;
|
|
}
|
|
parts.unshift(tag + ':nth-of-type(' + idx + ')');
|
|
if (tag === 'body' || tag === 'html') break;
|
|
n = n.parentNode;
|
|
}
|
|
let out = parts.join(' > ');
|
|
while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
|
|
return out.slice(0, 200);
|
|
}
|
|
|
|
function parseHref(a) {
|
|
const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
|
|
return String(href).trim();
|
|
}
|
|
|
|
function linkHost(a) {
|
|
try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
|
|
}
|
|
|
|
function contactKind(a, ownHost) {
|
|
const href = parseHref(a);
|
|
if (!href) return null;
|
|
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
|
|
if (!scheme) return null; // relativ, Anker
|
|
const s = scheme[1].toLowerCase();
|
|
if (s === 'tel') return 'tel';
|
|
if (s === 'mailto') return 'mail';
|
|
if (s === 'whatsapp') return 'wa';
|
|
if (s !== 'http' && s !== 'https') return null;
|
|
const host = linkHost(a);
|
|
if (!host) return null;
|
|
if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
|
|
const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
|
|
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
|
}
|
|
|
|
function readConsent(storage, now) {
|
|
try {
|
|
const raw = storage.getItem(CONSENT_KEY);
|
|
if (!raw) return null;
|
|
const c = JSON.parse(raw);
|
|
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
|
const age = now - c.t;
|
|
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
|
return { v: 1, a: c.a, t: c.t };
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
function contrastOk(hex) {
|
|
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
|
|
const lin = (i) => {
|
|
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
|
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
};
|
|
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
|
|
return 1.05 / (L + 0.05) >= 4.5;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
|
|
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
|
|
|
|
function main() {
|
|
const DOC = document;
|
|
const WIN = window;
|
|
if (WIN.__wkAnalytics) return; // doppelt eingebunden
|
|
WIN.__wkAnalytics = true;
|
|
const SELF = DOC.currentScript && DOC.currentScript.getAttribute && DOC.currentScript.getAttribute('data-site')
|
|
? DOC.currentScript : DOC.querySelector('script[src*="/a/w.js"]');
|
|
if (!SELF) return;
|
|
const site = String(SELF.getAttribute('data-site') || '');
|
|
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
|
|
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
|
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
|
|
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
|
|
const base = src.replace(/[^/]*$/, '');
|
|
const endpoint = base + 'e';
|
|
|
|
// ---- Heatmap-Modus: nichts senden, kein Banner ----
|
|
const qs = new URLSearchParams(WIN.location.search);
|
|
const heatToken = qs.get('__wk_heat');
|
|
if (heatToken) {
|
|
qs.delete('__wk_heat');
|
|
const rest = qs.toString();
|
|
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
|
|
const s = DOC.createElement('script');
|
|
s.setAttribute('src', base + 'h.js');
|
|
s.setAttribute('data-token', heatToken);
|
|
(DOC.head || DOC.documentElement).appendChild(s);
|
|
return;
|
|
}
|
|
|
|
// ---- Senden: Fehler werden verschluckt ----
|
|
function send(obj) {
|
|
try {
|
|
const body = JSON.stringify(obj);
|
|
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
|
|
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
|
|
} catch (e) { /* egal */ }
|
|
}
|
|
const safe = (fn) => function () { try { return fn.apply(this, arguments); } catch (e) { /* nie die Seite stoeren */ } };
|
|
const vp = () => vpClass(WIN.innerWidth);
|
|
const ownHost = () => WIN.location.hostname;
|
|
|
|
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
|
|
let lastPath = null;
|
|
let pvStart = Date.now();
|
|
let maxScroll = 0;
|
|
let enSent = false;
|
|
function sendPv(first) {
|
|
lastPath = cleanPath(WIN.location.pathname);
|
|
pvStart = Date.now(); maxScroll = 0; enSent = false;
|
|
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
|
|
}
|
|
let navTimer = null;
|
|
function scheduleNav() {
|
|
if (navTimer) return;
|
|
navTimer = setTimeout(safe(function () {
|
|
navTimer = null;
|
|
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
|
}), 300);
|
|
}
|
|
sendPv(true);
|
|
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
|
try {
|
|
const orig = WIN.history.pushState;
|
|
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
|
WIN.addEventListener('popstate', safe(scheduleNav));
|
|
} catch (e) { /* egal */ }
|
|
|
|
// ---- Tracking (nur nach Annehmen erreichbar) ----
|
|
let storage = null;
|
|
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
|
let memChoice = null;
|
|
let tracking = false;
|
|
const hosts = [];
|
|
const scrollPct = () => {
|
|
const h = DOC.documentElement.scrollHeight || 1;
|
|
return Math.max(0, Math.min(100, Math.round(((WIN.scrollY || 0) + WIN.innerHeight) / h * 100)));
|
|
};
|
|
const rel = (v, total) => Math.max(0, Math.min(1, Math.round(v / (total || 1) * 10000) / 10000));
|
|
|
|
const onClick = safe(function (e) {
|
|
if (!allowed()) return;
|
|
const t = e.target;
|
|
if (!t || hosts.indexOf(t) !== -1) return;
|
|
const dw = DOC.documentElement.scrollWidth, dh = DOC.documentElement.scrollHeight;
|
|
const px = typeof e.pageX === 'number' ? e.pageX : (e.clientX || 0) + (WIN.scrollX || 0);
|
|
const py = typeof e.pageY === 'number' ? e.pageY : (e.clientY || 0) + (WIN.scrollY || 0);
|
|
const path = cleanPath(WIN.location.pathname);
|
|
send({ t: 'ck', site, path, vp: vp(), x: rel(px, dw), y: rel(py, dh), sel: cssPath(t) });
|
|
let a = t;
|
|
while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
|
|
const kind = a ? contactKind(a, ownHost()) : null;
|
|
if (kind) {
|
|
const ev = { t: 'ct', site, path, kind };
|
|
if (kind === 'out') ev.host = linkHost(a);
|
|
send(ev);
|
|
}
|
|
});
|
|
const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
|
|
const onLeave = safe(function () {
|
|
if (enSent || !allowed()) return;
|
|
enSent = true;
|
|
send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
|
|
sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
|
|
});
|
|
const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
|
|
|
|
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
|
|
function allowed() {
|
|
const c = readConsent(storage, Date.now());
|
|
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
|
|
if (!ok && tracking) stopTracking();
|
|
return ok;
|
|
}
|
|
|
|
function startTracking() {
|
|
if (tracking) return;
|
|
tracking = true;
|
|
DOC.addEventListener('click', onClick, { capture: true, passive: true });
|
|
DOC.addEventListener('visibilitychange', onVis);
|
|
WIN.addEventListener('pagehide', onLeave);
|
|
WIN.addEventListener('scroll', onScroll, { passive: true });
|
|
}
|
|
function stopTracking() {
|
|
tracking = false;
|
|
DOC.removeEventListener('click', onClick, { capture: true });
|
|
DOC.removeEventListener('visibilitychange', onVis);
|
|
WIN.removeEventListener('pagehide', onLeave);
|
|
WIN.removeEventListener('scroll', onScroll);
|
|
}
|
|
|
|
// ---- Einwilligung ----
|
|
const dnt = (v) => v === '1' || v === 'yes';
|
|
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
|
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
|
|
|
|
const stored = readConsent(storage, Date.now());
|
|
let bannerHost = null;
|
|
let linkHostEl = null;
|
|
|
|
function choose(a) {
|
|
memChoice = null;
|
|
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
|
send({ t: 'bn', site, a });
|
|
if (a === 'accept') startTracking(); else stopTracking();
|
|
closeBanner();
|
|
}
|
|
|
|
function el(tag, attrs, text) {
|
|
const n = DOC.createElement(tag);
|
|
for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
|
|
if (text != null) n.textContent = text;
|
|
return n;
|
|
}
|
|
|
|
const CSS = ':host{all:initial}' +
|
|
'*{box-sizing:border-box}' +
|
|
'.bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:14px 20px;background:#fff;color:#111;border-top:1px solid #d0d0d0;box-shadow:0 -2px 12px rgba(0,0,0,.12);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}' +
|
|
'.t{margin:0;max-width:720px}' +
|
|
'.t a{color:#111;text-decoration:underline}' +
|
|
'.r{display:flex;gap:10px;flex:none}' +
|
|
'button{font:600 15px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-width:120px;height:44px;padding:0 20px;border:2px solid ' + accent + ';border-radius:8px;cursor:pointer;background:' + accent + ';color:#fff}' +
|
|
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
|
|
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
|
|
|
|
function openBanner(focus) {
|
|
if (bannerHost) return;
|
|
const prev = DOC.activeElement;
|
|
bannerHost = DOC.createElement('div');
|
|
const st = bannerHost.style;
|
|
st.position = 'fixed'; st.left = '0'; st.right = '0'; st.bottom = '0'; st.zIndex = '2147483647';
|
|
hosts.push(bannerHost);
|
|
const root = bannerHost.attachShadow({ mode: 'open' });
|
|
root.appendChild(el('style', {}, CSS));
|
|
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
|
|
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
|
|
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
|
|
const row = el('div', { class: 'r' });
|
|
const no = el('button', { type: 'button' }, 'Ablehnen');
|
|
const yes = el('button', { type: 'button' }, 'Annehmen');
|
|
no.addEventListener('click', safe(function () { choose('reject'); if (prev && prev.focus) prev.focus(); }));
|
|
yes.addEventListener('click', safe(function () { choose('accept'); if (prev && prev.focus) prev.focus(); }));
|
|
row.appendChild(no); row.appendChild(yes);
|
|
bar.appendChild(p); bar.appendChild(row);
|
|
root.appendChild(bar);
|
|
DOC.body.appendChild(bannerHost);
|
|
if (linkHostEl) linkHostEl.style.display = 'none';
|
|
if (focus === true) no.focus();
|
|
}
|
|
function closeBanner() {
|
|
if (!bannerHost) return;
|
|
const i = hosts.indexOf(bannerHost);
|
|
if (i !== -1) hosts.splice(i, 1);
|
|
bannerHost.remove();
|
|
bannerHost = null;
|
|
if (linkHostEl) linkHostEl.style.display = '';
|
|
}
|
|
|
|
function addSettingsLink() {
|
|
linkHostEl = DOC.createElement('div');
|
|
const st = linkHostEl.style;
|
|
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
|
|
hosts.push(linkHostEl);
|
|
const root = linkHostEl.attachShadow({ mode: 'open' });
|
|
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
|
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
|
|
b.addEventListener('click', safe(function () { openBanner(true); }));
|
|
root.appendChild(b);
|
|
DOC.body.appendChild(linkHostEl);
|
|
}
|
|
|
|
DOC.addEventListener('click', safe(function (e) {
|
|
const t = e.target;
|
|
if (t && t.closest && t.closest('[data-webklar-consent]')) { if (e.preventDefault) e.preventDefault(); openBanner(true); }
|
|
}));
|
|
if (!DOC.querySelector('[data-webklar-consent]')) addSettingsLink();
|
|
|
|
// Einwilligung in anderem Tab geaendert
|
|
WIN.addEventListener('storage', safe(function (e) {
|
|
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
|
const c = readConsent(storage, Date.now());
|
|
if (c && c.a === 'accept') startTracking(); else stopTracking();
|
|
if (c) closeBanner();
|
|
}));
|
|
|
|
if (stored) {
|
|
if (stored.a === 'accept') startTracking();
|
|
} else {
|
|
openBanner();
|
|
send({ t: 'bn', site, a: 'shown' });
|
|
}
|
|
}
|
|
|
|
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
|
|
|
})();
|