418 lines
18 KiB
JavaScript
418 lines
18 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;
|
|
const BANNER_VERSION = '2026-10-02';
|
|
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
|
|
const BANNER_LINK_LABEL = 'Datenschutz';
|
|
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
|
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
|
|
|
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;
|
|
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
|
|
const age = now - c.t;
|
|
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
|
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
|
} 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. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
|
|
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
|
|
|
|
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);
|
|
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;
|
|
|
|
// ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
|
|
let storage = null;
|
|
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
|
const dnt = (v) => v === '1' || v === 'yes';
|
|
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
|
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
|
|
const stored = readConsent(storage, Date.now());
|
|
let memChoice = null;
|
|
let tracking = false;
|
|
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
|
function choiceNow() {
|
|
const c = readConsent(storage, Date.now());
|
|
return c ? c.a : (memChoice ? memChoice.a : null);
|
|
}
|
|
const counting = () => choiceNow() !== 'reject';
|
|
|
|
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
|
|
let lastPath = null;
|
|
let pvStart = Date.now();
|
|
let maxScroll = 0;
|
|
let enSent = false;
|
|
let pvSent = false;
|
|
let cxSent = false;
|
|
function sendPv(first) {
|
|
if (!counting()) return;
|
|
lastPath = cleanPath(WIN.location.pathname);
|
|
pvStart = Date.now(); maxScroll = 0; enSent = false;
|
|
const ev = { t: 'pv', site, path: lastPath };
|
|
if (choiceNow() === 'accept') {
|
|
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
|
|
ev.vp = vp();
|
|
}
|
|
pvSent = true;
|
|
send(ev);
|
|
}
|
|
let navTimer = null;
|
|
function scheduleNav() {
|
|
if (navTimer || !counting()) return;
|
|
navTimer = setTimeout(safe(function () {
|
|
navTimer = null;
|
|
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
|
}), 300);
|
|
}
|
|
let hooksOn = false;
|
|
function ensureHooks() {
|
|
if (hooksOn) return;
|
|
hooksOn = 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 */ }
|
|
}
|
|
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
|
|
function onAccept() {
|
|
ensureHooks();
|
|
if (!pvSent) { sendPv(true); return; }
|
|
if (cxSent) return;
|
|
cxSent = true;
|
|
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
|
|
}
|
|
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
|
|
|
|
// ---- Tracking (nur nach Annehmen erreichbar) ----
|
|
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()); });
|
|
function sendEn() {
|
|
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 onLeave = safe(sendEn);
|
|
// Interner Seitenwechsel per Link: en sofort senden, der Beacon beim pagehide geht in Chromium oft verloren.
|
|
// Bubbling-Listener am Dokument: laeuft nach den Handlern der Seite, defaultPrevented ist dann bekannt.
|
|
const onLinkClick = safe(function (e) {
|
|
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || (typeof e.button === 'number' && e.button !== 0)) return;
|
|
let a = e.target;
|
|
while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
|
|
if (!a || hosts.indexOf(e.target) !== -1) return;
|
|
const tg = String(a.getAttribute('target') || '').toLowerCase();
|
|
if ((tg && tg !== '_self') || a.getAttribute('download') !== null) return;
|
|
const u = new URL(String(a.href || a.getAttribute('href') || ''), WIN.location.href);
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
|
|
if (u.origin !== WIN.location.origin || u.pathname === WIN.location.pathname) return;
|
|
sendEn();
|
|
});
|
|
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 ok = choiceNow() === 'accept';
|
|
if (!ok && tracking) stopTracking();
|
|
return ok;
|
|
}
|
|
|
|
function startTracking() {
|
|
if (tracking) return;
|
|
tracking = true;
|
|
DOC.addEventListener('click', onClick, { capture: true, passive: true });
|
|
DOC.addEventListener('click', onLinkClick, { 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('click', onLinkClick);
|
|
DOC.removeEventListener('visibilitychange', onVis);
|
|
WIN.removeEventListener('pagehide', onLeave);
|
|
WIN.removeEventListener('scroll', onScroll);
|
|
}
|
|
|
|
// ---- Einwilligung ----
|
|
let bannerHost = null;
|
|
let linkHostEl = null;
|
|
|
|
function choose(a) {
|
|
memChoice = null;
|
|
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
|
send({ t: 'bn', site, a });
|
|
if (a === 'accept') { startTracking(); onAccept(); } 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;width:150px;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%}}';
|
|
|
|
let prevPad = '';
|
|
function applyPad() {
|
|
if (!bannerHost) return;
|
|
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
|
|
DOC.body.style.paddingBottom = h + 'px';
|
|
}
|
|
const onResize = safe(applyPad);
|
|
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': BANNER_ARIA });
|
|
const p = el('p', { class: 't' }, BANNER_TEXT);
|
|
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
|
|
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);
|
|
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
|
|
prevPad = DOC.body.style.paddingBottom;
|
|
applyPad();
|
|
WIN.addEventListener('resize', onResize);
|
|
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;
|
|
WIN.removeEventListener('resize', onResize);
|
|
DOC.body.style.paddingBottom = prevPad;
|
|
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-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;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' }, SETTINGS_LABEL);
|
|
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 (choiceNow() === 'accept') { startTracking(); onAccept(); } 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 */ }
|
|
|
|
})();
|