Analyse: Client prüft Einwilligung vor dem Senden, Fassung der Banner-Wahl, fetch ohne Cookies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 18:50:26 +00:00
1 parent c2cbc4156f
commit 8d64a18d35
5 files changed
+354 -131

No files matched your search

+7 -1
View File
@@ -3,6 +3,11 @@
export const CONSENT_KEY = 'wk-consent';
export const CONSENT_MAX_AGE = 183 * 86400000;
export const BANNER_VERSION = '2026-10-02';
export 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: ';
export const BANNER_LINK_LABEL = 'Datenschutz';
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
export function vpClass(width) {
const w = Number(width);
@@ -89,9 +94,10 @@ export function readConsent(storage, now) {
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 };
return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; }
}
+7 -1
View File
@@ -6,6 +6,11 @@
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);
@@ -92,9 +97,10 @@ function readConsent(storage, now) {
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 };
return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; }
}
+80 -33
View File
@@ -6,6 +6,11 @@
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);
@@ -92,9 +97,10 @@ function readConsent(storage, now) {
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 };
return { v: 1, a: c.a, t: c.t, bv: c.bv };
} catch (e) { return null; }
}
@@ -113,7 +119,8 @@ function escapeHtml(s) {
}
// 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.
// 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;
@@ -149,7 +156,6 @@ function main() {
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 */ }
}
@@ -157,37 +163,71 @@ function main() {
const vp = () => vpClass(WIN.innerWidth);
const ownHost = () => WIN.location.hostname;
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
// ---- 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;
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
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) return;
if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () {
navTimer = null;
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
if (counting() && 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 */ }
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) ----
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;
@@ -239,8 +279,7 @@ function main() {
// 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');
const ok = choiceNow() === 'accept';
if (!ok && tracking) stopTracking();
return ok;
}
@@ -264,19 +303,14 @@ function main() {
}
// ---- 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 */ }
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(); else stopTracking();
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner();
}
@@ -297,6 +331,13 @@ function main() {
'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;
@@ -306,9 +347,9 @@ function main() {
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 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');
@@ -318,6 +359,10 @@ function main() {
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();
}
@@ -327,6 +372,8 @@ function main() {
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 = '';
}
@@ -336,8 +383,8 @@ function main() {
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');
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);
@@ -353,7 +400,7 @@ function main() {
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 (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner();
}));
+74 -33
View File
@@ -1,6 +1,7 @@
// 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.
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js';
// 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, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
function main() {
const DOC = document;
@@ -36,7 +37,6 @@ function main() {
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 */ }
}
@@ -44,37 +44,71 @@ function main() {
const vp = () => vpClass(WIN.innerWidth);
const ownHost = () => WIN.location.hostname;
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
// ---- 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;
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
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) return;
if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () {
navTimer = null;
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
if (counting() && 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 */ }
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) ----
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;
@@ -126,8 +160,7 @@ function main() {
// 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');
const ok = choiceNow() === 'accept';
if (!ok && tracking) stopTracking();
return ok;
}
@@ -151,19 +184,14 @@ function main() {
}
// ---- 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 */ }
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(); else stopTracking();
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner();
}
@@ -184,6 +212,13 @@ function main() {
'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;
@@ -193,9 +228,9 @@ function main() {
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 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');
@@ -205,6 +240,10 @@ function main() {
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();
}
@@ -214,6 +253,8 @@ function main() {
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 = '';
}
@@ -223,8 +264,8 @@ function main() {
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');
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);
@@ -240,7 +281,7 @@ function main() {
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 (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner();
}));
+186 -63
View File
@@ -7,8 +7,10 @@ import { dirname, join } from 'node:path';
import {
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
} from '../client/core.js';
import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from '../client/core.js';
import { buildAll } from '../client/build.js';
const BV = '2026-10-02';
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
// ---------- core ----------
@@ -96,13 +98,17 @@ test('readConsent', () => {
const now = 1_700_000_000_000;
const day = 86400000;
const st = (v) => ({ getItem: () => v });
assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day });
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject');
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null);
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null);
const J = (o) => st(JSON.stringify(o));
assert.equal(BANNER_VERSION, '2026-10-02');
assert.deepEqual(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 10 * day }), now), { v: 1, a: 'accept', t: now - 10 * day, bv: BV });
assert.equal(readConsent(J({ v: 1, a: 'reject', bv: BV, t: now - 182 * day }), now).a, 'reject');
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 183 * day }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', t: now }), now), null, 'ohne bv');
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: '2026-01-01', t: now }), now), null, 'alte Fassung');
assert.equal(readConsent(J({ v: 2, a: 'accept', bv: BV, t: now }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'maybe', bv: BV, t: now }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: 'x' }), now), null);
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now + day }), now), null);
assert.equal(readConsent(st('kaputt{'), now), null);
assert.equal(readConsent(st(null), now), null);
assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null);
@@ -144,6 +150,7 @@ function makeEl(tag) {
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); },
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); },
focus() { this.focused = true; },
getBoundingClientRect() { return { height: this.fakeHeight || 0 }; },
remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); },
closest(sel) {
const attr = /^\[([\w-]+)\]$/.exec(sel);
@@ -162,10 +169,10 @@ function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if
function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) {
const sent = []; const store = {}; let now = 1_700_000_000_000;
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) {
const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000;
if (stored) store['wk-consent'] = JSON.stringify(stored);
const body = makeEl('body'); const html = makeEl('html');
const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
const timers = [];
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src;
const docL = {}; const winL = {};
@@ -173,28 +180,29 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
const injected = [];
if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); };
const document = {
currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible',
currentScript: script, get referrer() { reads.referrer++; return 'https://www.google.com/'; }, visibilityState: 'visible',
documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
createElement: makeEl, activeElement: null,
createElement(t) { const e = makeEl(t); if (bannerHeight) e.offsetHeight = bannerHeight; return e; }, activeElement: null,
querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
addEventListener(t, f) { (docL[t] ||= []).push(f); },
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
};
body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; };
document.head.appendChild = body.appendChild;
const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } };
const origPush = function () {};
const history = { pushState: origPush, replaceState(s, t, u) { this.replaced = u; } };
const win = {
innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
get innerWidth() { reads.innerWidth++; return 1200; }, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
addEventListener(t, f) { (winL[t] ||= []).push(f); },
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
};
const sandbox = {
window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt,
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } },
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { beacon.push({ u, d }); return true; } },
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {},
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(),
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: (u, o) => { fetches.push({ u, o }); sent.push({ u, d: JSON.parse(o.body) }); return Promise.resolve(); },
};
win.localStorage = sandbox.localStorage;
sandbox.globalThis = sandbox;
@@ -205,31 +213,46 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); };
const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' }));
return { rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
const nav = (p) => { win.location.pathname = p; history.pushState(null, '', p); timers.splice(0).forEach((f) => f()); };
return { nav, reads, beacon, fetches, origPush, rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
}
const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; };
const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length);
test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => {
const SITE = 'abcdef0123456789abcd';
const ACC0 = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
test('w.js: ohne Einwilligung nur pv {t,site,path} + bn shown, kein Lesen von referrer/innerWidth', () => {
const h = harness();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e');
assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' });
assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' });
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start' });
assert.deepEqual(Object.keys(h.sent[0].d).sort(), ['path', 'site', 't']);
assert.deepEqual(h.sent[1].d, { t: 'bn', site: SITE, a: 'shown' });
assert.equal(h.reads.referrer, 0);
assert.equal(h.reads.innerWidth, 0);
h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
});
test('core: Banner-Konstanten', () => {
assert.match(BANNER_TEXT, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
assert.match(BANNER_TEXT, /Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast\./);
assert.match(BANNER_TEXT, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
assert.equal(BANNER_LINK_LABEL, 'Datenschutz');
assert.equal(BANNER_ARIA, 'Einwilligung zur Reichweitenmessung');
assert.equal(SETTINGS_LABEL, 'Datenschutz-Einstellungen');
});
test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => {
const h = harness();
const host = hostEl(h);
assert.ok(host, 'Banner im Shadow Root');
const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0];
assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen');
assert.equal(dlg.attrs['aria-label'], BANNER_ARIA);
const all = findAll(host.shadow, () => true);
const text = all.map((n) => n.textContent).join(' ');
assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/);
assert.ok(all.find((n) => n.tagName === 'P').textContent.startsWith(BANNER_TEXT), 'Text beginnt mit BANNER_TEXT');
const a = all.find((n) => n.tagName === 'A');
assert.equal(a.attrs.href, '/datenschutz-analyse.html');
assert.equal(a.textContent, 'Datenschutz');
@@ -244,7 +267,6 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)'
assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/);
assert.match(css, /button\{[^}]*[; ]width:150px/);
assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil');
assert.ok(!/min-width/.test(css));
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8');
assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/);
assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
@@ -258,8 +280,8 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
const h = harness();
const bh = hostEl(h);
btn(bh.shadow, 'Annehmen').click();
assert.equal(h.types().at(-1), 'bn:accept');
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 });
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
assert.ok(bh.removed && !hostEl(h));
const t = target();
h.ck(t);
@@ -281,7 +303,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
assert.equal(en[0].d.scroll, 55); // (300+800)/2000
assert.equal(typeof en[0].d.sec, 'number');
// Widerruf ueber Einstellungs-Link
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0];
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
assert.ok(settings, 'fester Link');
settings.click();
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen');
@@ -296,7 +318,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
});
test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
assert.deepEqual(h.types(), ['pv']);
assert.equal(hostEl(h), undefined);
h.ck(target());
@@ -304,28 +326,132 @@ test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', ()
});
test('w.js: abgelaufene/abgelehnte Einwilligung', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } });
assert.deepEqual(h.types(), ['pv']);
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 - 5000 } });
assert.deepEqual(h.types(), []);
h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv']);
const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } });
assert.deepEqual(h.types(), []);
const h2 = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 200 * 86400000 } });
assert.deepEqual(h2.types(), ['pv', 'bn:shown']);
});
test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => {
for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) {
test('w.js: GPC und DNT => nichts senden, kein Banner, keine Hooks (T1)', () => {
for (const o of [{ gpc: true }, { dnt: '1' }, { dnt: 'yes' }, { gpc: true, stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 } }]) {
const h = harness(o);
assert.deepEqual(h.types(), ['pv']);
assert.equal(hostEl(h), undefined);
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
h.ck(target()); h.leave();
assert.deepEqual(h.types(), ['pv']);
assert.equal(h.sent.length, 0);
assert.equal(h.winL.pageshow, undefined);
assert.equal(h.winL.popstate, undefined);
assert.equal(h.history.pushState, h.origPush);
assert.equal(h.body.children.length, 0, 'kein Banner, kein Einstellungsknopf');
h.ck(target()); h.leave(); h.nav('/neu');
assert.equal(h.sent.length, 0);
assert.equal(h.reads.referrer + h.reads.innerWidth, 0);
}
});
test('w.js: gespeichertes Ablehnen => nichts senden, nur Einstellungsknopf; spaeteres Annehmen => bn:accept, pv mit ref+vp', () => {
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
assert.equal(h.sent.length, 0);
assert.equal(h.winL.pageshow, undefined);
assert.equal(h.winL.popstate, undefined);
assert.equal(h.history.pushState, h.origPush);
assert.equal(h.body.children.length, 1);
const open = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
assert.ok(open);
open.click();
assert.equal(h.sent.length, 0);
btn(hostEl(h).shadow, 'Annehmen').click();
assert.deepEqual(h.types(), ['bn:accept', 'pv']);
assert.deepEqual(h.sent[1].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
assert.ok(h.winL.pageshow && h.winL.popstate, 'Hooks nachtraeglich installiert');
h.nav('/zwei');
assert.equal(h.types().at(-1), 'pv');
assert.equal(h.sent.at(-1).d.path, '/zwei');
});
test('w.js: Ablehnen => genau ein bn:reject, danach nichts mehr (auch nicht Seitenwechsel)', () => {
const h2 = harness();
btn(hostEl(h2).shadow, 'Ablehnen').click();
assert.deepEqual(h2.types(), ['pv', 'bn:shown', 'bn:reject']);
const n = h2.sent.length;
h2.nav('/zwei'); h2.nav('/drei');
for (const f of h2.winL.pageshow || []) f({ persisted: true });
for (const f of h2.winL.popstate || []) f({});
h2.timers.splice(0).forEach((f) => f());
h2.ck(target()); h2.leave();
for (const f of h2.docL.scroll || []) f({});
assert.equal(h2.sent.length, n);
assert.equal(h2.types().filter((t) => t === 'bn:reject').length, 1);
});
test('w.js: Seitenwechsel-Timer vor dem Ablehnen sendet nichts', () => {
const h = harness();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); // Timer geplant
assert.equal(h.timers.length, 1);
btn(hostEl(h).shadow, 'Ablehnen').click();
const n = h.sent.length;
h.timers.splice(0).forEach((f) => f());
assert.equal(h.sent.length, n);
});
test('w.js: Ablehnen in anderem Tab (storage) => danach kein pv beim Seitenwechsel', () => {
const h = harness();
const n = h.sent.length;
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
h.nav('/zwei');
for (const f of h.winL.pageshow || []) f({ persisted: true });
assert.equal(h.sent.length, n);
});
test('w.js: Annehmen auf laufender Seite => bn:accept, genau ein cx mit ref+vp; kein zweites cx', () => {
const h = harness();
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
btn(hostEl(h).shadow, 'Annehmen').click();
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
assert.deepEqual(h.sent.at(-1).d, { t: 'cx', site: SITE, path: '/start', ref: refHost('https://www.google.com/', 'mein.de'), vp: 'd' });
// Widerruf, erneut annehmen
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
btn(hostEl(h).shadow, 'Ablehnen').click();
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
btn(hostEl(h).shadow, 'Annehmen').click();
assert.equal(h.sent.filter((s) => s.d.t === 'cx').length, 1);
});
test('w.js: gespeichertes Annehmen => erstes pv mit ref+vp, kein cx', () => {
const h = harness({ stored: ACC0 });
assert.deepEqual(h.types(), ['pv']);
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
});
test('w.js: T15 kein sendBeacon, fetch mit keepalive, omit, POST, text/plain', () => {
const h = harness();
assert.ok(h.fetches.length >= 1);
assert.equal(h.beacon.length, 0);
for (const f of h.fetches) {
assert.equal(f.o.keepalive, true);
assert.equal(f.o.credentials, 'omit');
assert.equal(f.o.method, 'POST');
assert.equal(f.o.headers['Content-Type'], 'text/plain');
}
});
test('w.js: T19 Platz unten solange Banner offen; Knopf mindestens 24px', () => {
const h = harness({ bannerHeight: 90, bodyPad: '5px' });
assert.equal(h.body.style.paddingBottom, '90px');
btn(hostEl(h).shadow, 'Ablehnen').click();
assert.equal(h.body.style.paddingBottom, '5px');
const g = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
const css = findAll(g.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
assert.match(css, /min-width:24px/);
assert.match(css, /min-height:32px/);
assert.match(css, /padding:8px 12px/);
assert.match(css, /font-size:13px/);
});
test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true });
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }, withConsentLink: true });
assert.equal(findAll(h.body, (x) => x.textContent === SETTINGS_LABEL).length, 0);
assert.equal(hostEl(h), undefined);
h.link.click();
assert.ok(hostEl(h));
@@ -343,14 +469,14 @@ test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () =>
});
test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
const h = harness({ gpc: true });
h.win.location.pathname = '/start';
h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f());
assert.equal(h.sent.length, 1);
const h = harness();
h.nav('/start');
assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1);
h.win.location.pathname = '/neu';
h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu');
h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']);
assert.deepEqual(h.sent.filter((s) => s.d.t === 'pv').map((s) => s.d.path), ['/start', '/neu']);
assert.deepEqual(h.sent.at(-1).d, { t: 'pv', site: SITE, path: '/neu' });
});
test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
@@ -361,36 +487,33 @@ test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
});
});
test('w.js: DNT yes / msDoNotTrack => nur pv', () => {
for (const o of [{ dnt: 'yes' }]) { const h = harness(o); assert.deepEqual(h.types(), ['pv']); }
});
test('w.js: doppelt eingebunden => nur ein pv', () => {
const h = harness({ gpc: true });
const h = harness();
h.rerun();
assert.deepEqual(h.types(), ['pv']);
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
});
test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.ck(target());
assert.equal(h.sent.at(-1).d.t, 'ck');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
const n = h.sent.length;
h.ck(target()); h.leave();
assert.equal(h.sent.length, n);
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
// Annehmen in anderem Tab startet Tracking
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', t: 1_700_000_000_000 });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
h.storage();
h.ck(target());
assert.equal(h.sent.length, n + 1);
assert.equal(h.sent.length, n + 1); // nur ck, kein cx (pv trug ref/vp schon)
assert.ok(!h.sent.some((x) => x.d.t === 'cx'));
});
test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
const n = h.sent.length;
h.ck(target()); h.leave();
assert.equal(h.sent.length, n);
@@ -398,13 +521,13 @@ test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereign
});
test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']);
h.ck(target());
assert.equal(h.sent.at(-1).d.path, '/zwei');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
h.storage();
const n = h.sent.length;
h.leave();
@@ -412,7 +535,7 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
});
test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.leave();
assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1);
for (const f of h.winL.pageshow || []) f({ persisted: false });
@@ -424,7 +547,7 @@ test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
});
test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => {
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 } });
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
// kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls
const late = makeEl('a'); late.setAttribute('data-webklar-consent', '');
for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} });
@@ -433,7 +556,7 @@ test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegat
});
// ---------- Verweildauer bei internem Seitenwechsel ----------
const ACC = { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 };
const ACC = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; };
const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length;
@@ -473,7 +596,7 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
h.ck(lnk('/kontakt'));
assert.equal(enCount(h), 0);
const g = harness({ stored: ACC });
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
g.ck(lnk('/kontakt'));
assert.equal(enCount(g), 0);
});