Files
Webklar-Kundenbereich/analytics/client/w.js
T
ClaudeandClaude Opus 5.5 dd062b1f15 Analyse-Skript: bei SPA-Seitenwechseln die Verweildauer der vorigen Seite senden
Router-Links (z. B. React Router auf webklar.com) verhindern den Standard-Klick, dann griff onLinkClick nicht
und das en der vorigen Seite ging verloren. Jetzt sendet der Seitenwechsel-Timer zuerst en (nur mit
Einwilligung, einmal je Seite) und dann das pv der neuen Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:37:55 +00:00

336 lines
17 KiB
JavaScript

// 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.
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, bannerFor, CONSENT_KEY, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
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';
// data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter)
const banner = bannerFor(String(SELF.getAttribute('data-variant') || ''));
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) {
// __wk_embed=1: Ansicht im Geraeterahmen des Kundenportals (h.js ohne Leiste, Links gesperrt)
const embed = qs.get('__wk_embed') === '1';
qs.delete('__wk_heat');
qs.delete('__wk_embed');
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);
if (embed) s.setAttribute('data-embed', '1');
(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(), banner.version);
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(), banner.version);
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) return;
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
sendEn();
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 = '';
}
// Einstellungsknopf: angeknabberter Keks unten rechts, dreht sich langsam (nicht bei reduzierter Bewegung).
// Beim Darueberfahren haelt er an und zeigt "Datenschutz-Einstellungen"; das ist auch sein Name fuer Screenreader.
// Inline-SVG statt data:-Grafik, damit eine strenge CSP der Website (img-src) ihn nicht blockiert.
const SETTINGS_CSS = ':host{all:initial}' +
'.b{position:relative;display:block;width:44px;height:44px;padding:2px;margin:0;border:0;border-radius:50%;background:transparent;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
'.b:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
'.c{display:block;width:40px;height:40px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.35));animation:wk-spin 9s linear infinite}' +
'.b:hover .c,.b:focus-visible .c{animation-play-state:paused}' +
'.l{position:absolute;right:52px;top:50%;transform:translate(6px,-50%);padding:7px 12px;border-radius:999px;background:#fff;color:#222;box-shadow:0 2px 10px rgba(0,0,0,.18);font:600 13px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}' +
'.b:hover .l,.b:focus-visible .l{opacity:1;transform:translate(0,-50%)}' +
'@keyframes wk-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.c{animation:none}.l{transition:none}}';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const n = DOC.createElementNS(SVG_NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
function cookieSvg() {
const svg = svgEl('svg', { class: 'c', viewBox: '0 0 64 64', 'aria-hidden': 'true', focusable: 'false' });
// Umriss mit Bissspur oben rechts (zwei Bissboegen, dann der grosse Kreis zurueck)
svg.appendChild(svgEl('path', { d: 'M44.84 6A10 10 0 0 0 57.5 19.37A7.5 7.5 0 0 0 60.96 33.5A29 29 0 1 1 44.84 6Z', fill: '#dfa25b', stroke: '#a5652c', 'stroke-width': '3', 'stroke-linejoin': 'round' }));
const chips = [[19, 22, 4.2, 3.3, -20], [31, 14, 3.2, 2.6, 15], [33, 33, 4, 3.2, 30], [46, 41, 3.6, 2.8, -35], [21, 45, 4, 3, 10], [36, 52, 3, 2.4, -10], [11, 34, 2.6, 2.1, 40], [47, 31, 2.4, 2, 0]];
for (const c of chips) svg.appendChild(svgEl('ellipse', { cx: c[0], cy: c[1], rx: c[2], ry: c[3], transform: 'rotate(' + c[4] + ' ' + c[0] + ' ' + c[1] + ')', fill: '#5a3216' }));
const crumbs = [[26, 28, 1.1], [40, 22, 1], [28, 40, 1.2], [14, 26, 0.9], [40, 46, 1]];
for (const c of crumbs) svg.appendChild(svgEl('circle', { cx: c[0], cy: c[1], r: c[2], fill: '#f3c98c' }));
return svg;
}
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', {}, SETTINGS_CSS));
const b = el('button', { type: 'button', class: 'b', 'aria-label': SETTINGS_LABEL });
b.appendChild(el('span', { class: 'l', 'aria-hidden': 'true' }, SETTINGS_LABEL));
b.appendChild(cookieSvg());
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(), banner.version);
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 */ }