Compare commits

...
3 Commits
Author SHA1 Message Date
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
ClaudeandClaude Opus 5.5 07a4e153bf Datenschutzerklärung Stand 17.10.2026: Abschnitt 4a Reichweitenmessung auf webklar.com, PDF neu
- Abschnitt 4: eigene Website-Analyse auf webklar.com statt "keine Analyse-Werkzeuge", Eintrag wk-consent.
- Abschnitt 4a: Rolle (WEBklar selbst Verantwortlicher), Daten ohne und mit Einwilligung, Rechtsgrundlagen,
  Widerruf, Fristen, Verweis auf webklar.com/datenschutz-analyse.html.
- Abschnitt 5: Kontaktformular-Anfragen gehen seit 17.10.2026 auch per Mail an das eigene Postfach.
- Abschnitt 13: Einwilligung nur für die erweiterte Reichweitenmessung.
- Archiv: Banner-Fassung eigen 2026-10-17 und Kopie der Zusatzseite von webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:34:06 +00:00
ClaudeandClaude Opus 5.5 9ed295946c Website-Analyse für webklar.com: Sie-Fassung des Banners, fester Messdienst-Eintrag, Auswertung fürs WOMS
- Messskript: data-variant="eigen" zeigt eine eigene Banner-Fassung (Sie-Form, WEBklar misst selbst,
  BANNER_VERSION_EIGEN 2026-10-17); Kunden-Websites bleiben bei der Fassung 2026-10-02.
- config.analyse.eigeneWebsite: fester Schlüssel und Domains von webklar.com; ensureEigeneWebsite zieht den
  Eintrag im Messdienst täglich nach (aktiv: false schaltet ab). Kein Kunde, kein Abo, keine Tickets.
- /api/admin/analyse-eigen (jeder Mitarbeiter): Kennzahlen 7/30/90 Tage und Heatmap-Link auf webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:34:06 +00:00
20 changed files with 495 additions and 39 deletions

No files matched your search

+13 -2
View File
@@ -5,6 +5,10 @@ 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: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
export const BANNER_VERSION_EIGEN = '2026-10-17';
export const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie 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';
@@ -88,13 +92,20 @@ export function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out';
}
export function readConsent(storage, now) {
// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
export function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
export function readConsent(storage, now, version = BANNER_VERSION) {
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
if (c.bv !== 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 };
+13 -2
View File
@@ -8,6 +8,10 @@ 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: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
const BANNER_VERSION_EIGEN = '2026-10-17';
const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie 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';
@@ -91,13 +95,20 @@ function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out';
}
function readConsent(storage, now) {
// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
function readConsent(storage, now, version = BANNER_VERSION) {
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
if (c.bv !== 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 };
+24 -8
View File
@@ -8,6 +8,10 @@ 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: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
const BANNER_VERSION_EIGEN = '2026-10-17';
const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie 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';
@@ -91,13 +95,20 @@ function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out';
}
function readConsent(storage, now) {
// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
function readConsent(storage, now, version = BANNER_VERSION) {
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
if (c.bv !== 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 };
@@ -134,6 +145,8 @@ function main() {
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';
@@ -173,12 +186,12 @@ function main() {
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());
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());
const c = readConsent(storage, Date.now(), banner.version);
return c ? c.a : (memChoice ? memChoice.a : null);
}
const counting = () => choiceNow() !== 'reject';
@@ -207,7 +220,10 @@ function main() {
if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () {
navTimer = null;
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
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;
@@ -312,7 +328,7 @@ function main() {
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 */ }
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();
@@ -352,7 +368,7 @@ function main() {
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);
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');
@@ -433,7 +449,7 @@ function main() {
// 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());
const c = readConsent(storage, Date.now(), banner.version);
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner();
}));
+12 -7
View File
@@ -1,7 +1,7 @@
// 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, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
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;
@@ -15,6 +15,8 @@ function main() {
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';
@@ -54,12 +56,12 @@ function main() {
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());
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());
const c = readConsent(storage, Date.now(), banner.version);
return c ? c.a : (memChoice ? memChoice.a : null);
}
const counting = () => choiceNow() !== 'reject';
@@ -88,7 +90,10 @@ function main() {
if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () {
navTimer = null;
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
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;
@@ -193,7 +198,7 @@ function main() {
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 */ }
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();
@@ -233,7 +238,7 @@ function main() {
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);
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');
@@ -314,7 +319,7 @@ function main() {
// 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());
const c = readConsent(storage, Date.now(), banner.version);
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner();
}));
+70 -4
View File
@@ -7,7 +7,7 @@ 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 { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL, BANNER_VERSION_EIGEN, BANNER_TEXT_EIGEN, bannerFor } from '../client/core.js';
import { buildAll } from '../client/build.js';
const BV = '2026-10-02';
@@ -170,12 +170,12 @@ function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n))
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
const settingsBtn = (h) => findAll(h.body, (n) => n.tagName === 'BUTTON' && n.attrs['aria-label'] === SETTINGS_LABEL)[0];
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) {
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad, variant } = {}) {
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'); 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 script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); if (variant !== undefined) script.setAttribute('data-variant', variant); script.src = src;
const docL = {}; const winL = {};
const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
const injected = [];
@@ -565,7 +565,9 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
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']);
// en der vorigen Seite vor dem pv der neuen (Router-Links verhindern den Standard-Klick, onLinkClick greift nicht)
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
assert.equal(h.sent[1].d.scroll, 55);
h.ck(target());
assert.equal(h.sent.at(-1).d.path, '/zwei');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
@@ -641,3 +643,67 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
g.ck(lnk('/kontakt'));
assert.equal(enCount(g), 0);
});
// ---------- Variante "eigen" (WEBklars eigene Websites, z. B. webklar.com) ----------
test('core: bannerFor und readConsent je Fassung', () => {
assert.deepEqual(bannerFor('eigen'), { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN });
for (const v of ['', 'kunde', 'EIGEN', 'x', undefined]) assert.deepEqual(bannerFor(v), { version: BANNER_VERSION, text: BANNER_TEXT });
assert.equal(BANNER_VERSION_EIGEN, '2026-10-17');
assert.notEqual(BANNER_VERSION_EIGEN, BANNER_VERSION);
const now = 1_700_000_000_000;
const st = (o) => ({ getItem: () => JSON.stringify(o) });
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BV, t: now }), now, BANNER_VERSION_EIGEN), null, 'Kunden-Fassung gilt nicht fuer eigen');
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now, BANNER_VERSION_EIGEN).a, 'accept');
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now), null, 'ohne Fassung gilt die Kunden-Fassung');
});
test('core: Banner-Text eigen (Sie-Form, ohne Auftragsverarbeiter)', () => {
assert.match(BANNER_TEXT_EIGEN, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
assert.match(BANNER_TEXT_EIGEN, /Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung\./);
assert.match(BANNER_TEXT_EIGEN, /Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben\./);
assert.match(BANNER_TEXT_EIGEN, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
assert.doesNotMatch(BANNER_TEXT_EIGEN, /\b(Du|Dein|Deine|Deiner|Dich|Dir)\b/);
assert.doesNotMatch(BANNER_TEXT_EIGEN, /betreibt für uns/);
});
test('w.js: data-variant="eigen" => Sie-Text, eigene Fassung im Speicher', () => {
const h = harness({ variant: 'eigen' });
const host = hostEl(h);
const p = findAll(host.shadow, (n) => n.tagName === 'P')[0];
assert.ok(p.textContent.startsWith(BANNER_TEXT_EIGEN));
assert.ok(!p.textContent.startsWith(BANNER_TEXT));
btn(host.shadow, 'Annehmen').click();
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 });
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
});
test('w.js: eigen fragt neu bei Wahl aus der Kunden-Fassung, gilt mit eigener Fassung', () => {
const alt = harness({ variant: 'eigen', stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
assert.ok(hostEl(alt), 'Banner erscheint erneut');
const ok = harness({ variant: 'eigen', stored: { v: 1, a: 'reject', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 - 1000 } });
assert.equal(hostEl(ok), undefined);
assert.deepEqual(ok.types(), []);
});
test('w.js: unbekannte Variante => Kunden-Fassung', () => {
const h = harness({ variant: 'sonstwas' });
const p = findAll(hostEl(h).shadow, (n) => n.tagName === 'P')[0];
assert.ok(p.textContent.startsWith(BANNER_TEXT));
});
test('w.js: SPA-Wechsel ohne Einwilligung sendet nur pv, kein en', () => {
const h = harness();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'pv']);
});
test('w.js: en nach SPA-Wechsel nur einmal je Seite (Link-Klick und Router)', () => {
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.leave();
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.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
h.leave();
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`).slice(-1), ['en:/zwei']);
});
Binary file not shown.
@@ -1 +1 @@
8facae22cea59e23f6a9350374e03f0c42d0cba2e3adfda6062034d47081617a
d9e43fa83fa9cb6fd40937df034068891409dbdaa98061b5dd3b0c1e51cca1ea
+8
View File
@@ -8,6 +8,14 @@ Jede ausgelieferte Fassung bleibt hier nachvollziehbar. `BANNER_VERSION` im Clie
Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt.
## Eigene Website webklar.com
Auf webklar.com läuft dieselbe Messung mit WEBklar als Verantwortlichem (keine AVV). Das Skript-Tag trägt `data-variant="eigen"`; der Banner hat dann eine eigene Sie-Fassung (`BANNER_VERSION_EIGEN`), die Zusatzseite liegt im Repository der Website (`public/datenschutz-analyse.html`), die Kopie hier ist byte-gleich zu der ausgelieferten Fassung. Der Abschnitt 4a der Datenschutzerklärung fasst sie zusammen.
| Fassung | gültig ab | Banner-Text | Zusatzseite |
|---|---|---|---|
| 2026-10-17 | offen bis Livegang | [banner-eigen-2026-10-17.md](banner-eigen-2026-10-17.md) (`BANNER_VERSION_EIGEN` 2026-10-17) | [datenschutz-analyse-webklar-com-2026-10-17.html](datenschutz-analyse-webklar-com-2026-10-17.html) |
AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen.
## Terminplaner
@@ -0,0 +1,13 @@
# Banner-Text Fassung 2026-10-17 (eigene Websites der WEBklar GbR)
Quelle: `analytics/client/core.js` (Konstanten `BANNER_TEXT_EIGEN`, `BANNER_VERSION_EIGEN`). Gilt nur, wenn das Skript-Tag `data-variant="eigen"` trägt, derzeit nur auf webklar.com. Kunden-Websites behalten die Fassung 2026-10-02 ([banner-2026-10-02.md](banner-2026-10-02.md)).
- `BANNER_VERSION_EIGEN`: 2026-10-17
## Fließtext (BANNER_TEXT_EIGEN)
Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: [Datenschutz](/datenschutz-analyse.html)
## aria-label, Schaltflächen, Einstellungs-Link
Wie in der Kunden-Fassung: „Einwilligung zur Reichweitenmessung“; „Ablehnen“ · „Annehmen“ (gleich gestaltet, Ablehnen zuerst); Keks-Symbol „Datenschutz-Einstellungen“ unten rechts.
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutz bei der Reichweitenmessung – WEBklar</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 12rem; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 2px solid #6d1fd1; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
</style>
</head>
<body>
<main>
<nav class="top"><a href="/">← Zur Startseite</a> · <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a> · <a href="/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Datenschutz bei der Reichweitenmessung</h1>
<p class="meta">Ergänzung zu unserer Datenschutzerklärung (Abschnitt 4a) · gilt für webklar.com · Stand 17.10.2026</p>
<p>Diese Seite beschreibt nur die Reichweitenmessung auf webklar.com. Alle übrigen Hinweise finden Sie in unserer <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a>.</p>
<h2>Verantwortlicher</h2>
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler<br>
E-Mail: <a href="mailto:support@webklar.com">support@webklar.com</a></p>
<p>Einen Datenschutzbeauftragten haben wir nicht benannt, weil wir dazu gesetzlich nicht verpflichtet sind.</p>
<h2>Worum es geht</h2>
<p>Wir möchten verstehen, wie unsere Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir unsere eigene Website-Analyse, dieselbe, die wir auch unseren Kunden anbieten. Sie kommt ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken aus. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf unserem Server in Deutschland.</p>
<p>Damit die Messung arbeiten kann, lädt Ihr Browser bei jedem Seitenaufruf ein Skript von unserem Server (project.webklar.com). Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Ihre IP-Adresse und Ihr Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Sie ablehnen oder „Global Privacy Control“ aktiviert haben. Beim Abruf des Skripts und bei der Messung speichern und protokollieren wir beides nicht.</p>
<h2>Solange Sie im Banner nichts ausgewählt haben</h2>
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
<ul>
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
<li>das Land, das wir aus Ihrer IP-Adresse ableiten,</li>
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
</ul>
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Ihrer IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Sie wiedererkennen könnten, speichern wir ohne Ihre Einwilligung nicht.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
<h2>Wenn Sie „Ablehnen“ wählen</h2>
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Ihr Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
<h2>Wenn Sie „Annehmen“ wählen</h2>
<p>Dann messen wir zusätzlich:</p>
<ul>
<li>von welcher Website Sie gekommen sind (nur der Domainname),</li>
<li>die Größenklasse Ihres Browserfensters,</li>
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Ihrer IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
<li>wie lange Sie auf einer Seite bleiben (ab dem Laden der Seite) und wie weit Sie scrollen,</li>
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
<li>ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
<li>einen Zähler dafür, dass Sie „Annehmen“ gewählt haben.</li>
</ul>
<p>Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
<h2>Was in Ihrem Browser gespeichert wird</h2>
<p>Wir speichern Ihre Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Ihres Browsers (Eintrag „wk-consent“), damit wir Sie nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Sie können den Eintrag jederzeit selbst löschen, indem Sie die Website-Daten in Ihrem Browser löschen.</p>
<h2>Speicherdauer</h2>
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten. Beenden wir die Messung, löschen wir alle Messdaten spätestens rund 60 Tage danach. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
<h2>Empfänger</h2>
<p>Die Messung betreiben wir selbst. Die Daten sehen nur unsere Mitarbeiter. Der Server steht bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland; Hetzner ist für uns als Auftragsverarbeiter tätig. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
<h2>Freiwilligkeit</h2>
<p>Sie sind weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Sie ablehnen oder Ihre Einwilligung widerrufen, können Sie unsere Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
<div class="box">
<h2 style="border:0;margin-top:0;padding-top:0">Einwilligung widerrufen</h2>
<p>Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicken Sie auf „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts auf jeder Seite) und wählen Sie „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
<h2 style="border:0;padding-top:0">Widerspruchsrecht</h2>
<p>Sie können der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Ihrer besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählen Sie im Banner oder über „Datenschutz-Einstellungen“ (das Keks-Symbol) „Ablehnen“ oder aktivieren in Ihrem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Sie können Ihren Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
</div>
<h2>Ihre Rechte</h2>
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Ihnen zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle unseres Webservers noch vorhanden sind (Abschnitt 3 der Datenschutzerklärung). Wenn Sie uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Sie möchten, Ihre IP-Adresse nennen, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Ihnen das mit.</p>
<p>Mit Ihren Anliegen können Sie sich jederzeit an die oben genannte Adresse wenden. Außerdem haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO), für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz.</p>
</main>
</body>
</html>
@@ -6,18 +6,18 @@ Diese Liste ändert die Datenschutzerklärung nicht. `public/datenschutz.html` b
Je Prüfpunkt: **Fundstelle**, was dort steht, was sich ändert, und die Frage an den Inhaber oder die Kanzlei.
**D1** · Fundstelle `public/datenschutz.html:120`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt?
**D1** · Fundstelle `public/datenschutz.html:127`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt?
**D2** · Fundstelle `public/datenschutz.html:119`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis?
**D2** · Fundstelle `public/datenschutz.html:126`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis?
**D3** · Fundstelle `public/datenschutz.html:81`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7?
**D3** · Fundstelle `public/datenschutz.html:88`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7?
**D4** · Fundstelle `public/datenschutz.html:121`, ergänzend `public/datenschutz.html:108` und `public/datenschutz.html:114`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden?
**D4** · Fundstelle `public/datenschutz.html:128`, ergänzend `public/datenschutz.html:115` und `public/datenschutz.html:121`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden?
**D5** · Fundstelle `public/datenschutz.html:95`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung?
**D5** · Fundstelle `public/datenschutz.html:102`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung?
**D6** · Fundstellen `public/datenschutz.html:98` (Terminplaner), `public/datenschutz.html:106` (Webshop), `public/datenschutz.html:112` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen?
**D6** · Fundstellen `public/datenschutz.html:105` (Terminplaner), `public/datenschutz.html:113` (Webshop), `public/datenschutz.html:119` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen?
**D7** · Fundstelle `public/datenschutz.html:131`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz?
**D7** · Fundstelle `public/datenschutz.html:138`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz?
**D8** · Fundstelle `public/datenschutz.html:123`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden?
**D8** · Fundstelle `public/datenschutz.html:130`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden?
+12 -5
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 07.10.2026</p>
<p class="meta">Stand 17.10.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
@@ -65,17 +65,24 @@
<p>Mit UptimeRobot haben wir einen Vertrag über die Auftragsverarbeitung geschlossen; er ist Teil der Nutzungsbedingungen von UptimeRobot. UptimeRobot verarbeitet diese Daten auch außerhalb der EU, insbesondere in den USA. Grundlage dafür sind die Standardvertragsklauseln der EU-Kommission (Art. 46 Abs. 2 lit. c DSGVO). Sie sind Teil des Auftragsverarbeitungsvertrags von UptimeRobot (uptimerobot.com/dpa); eine Kopie erhalten Sie auch bei uns. Rechtsgrundlage ist für die Websites unserer Abo-Kunden Art. 6 Abs. 1 lit. b DSGVO, weil die Überwachung zu den Leistungen des Abos gehört, im Übrigen Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt auch hier im sicheren und funktionsfähigen Betrieb. Die Adresse einer Website entfernen wir bei UptimeRobot, sobald wir die Website nicht mehr betreiben.</p>
<h2>4. Cookies und Speicher im Browser</h2>
<p>Auf unseren eigenen Seiten (webklar.com und dem Kundenportal) setzen wir keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p>
<p>Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten (webklar.com und dem Kundenportal) keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p>
<table>
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th></tr>
<tr><td>webklar_portal_session (Cookie)</td><td>hält Sie im Kundenportal angemeldet</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
<tr><td>preview_session (Cookie)</td><td>Zugang zu Ihren Vorschauseiten unter *.project.webklar.com</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
<tr><td>theme (lokaler Speicher, webklar.com)</td><td>merkt sich die von Ihnen gewählte helle oder dunkle Darstellung</td><td>bis Sie ihn im Browser löschen</td></tr>
<tr><td>wk-consent (lokaler Speicher, webklar.com)</td><td>merkt sich Ihre Auswahl im Banner der Reichweitenmessung („Annehmen“ oder „Ablehnen“) mit Datum und Fassung des Banner-Textes (Abschnitt 4a)</td><td>183 Tage, danach fragen wir erneut</td></tr>
</table>
<p>Diese Speicherungen sind unbedingt erforderlich, damit wir die von Ihnen gewünschten Funktionen bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG). Eine Einwilligung ist dafür nicht nötig. Die Verarbeitung stützt sich auf Art. 6 Abs. 1 lit. b DSGVO (Portal) bzw. lit. f DSGVO (Darstellung).</p>
<p>Diese Speicherungen sind unbedingt erforderlich, damit wir die von Ihnen gewünschten Funktionen bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG). Eine Einwilligung ist dafür nicht nötig. Die Verarbeitung stützt sich auf Art. 6 Abs. 1 lit. b DSGVO (Portal) bzw. lit. f DSGVO (Darstellung, Auswahl im Banner).</p>
<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>
<p>Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie unsere Website genutzt wird, um sie zu verbessern. Es ist dieselbe Messung, die wir unseren Kunden anbieten (Abschnitt 6b); für webklar.com sind wir selbst Verantwortlicher. Die Messung läuft auf unserem Server bei der Hetzner Online GmbH in Deutschland (Abschnitt 3), ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Beim ersten Aufruf fragt ein Banner nach Ihrer Zustimmung.</p>
<p>Solange Sie im Banner nichts ausgewählt haben, zählen wir nur den Seitenaufruf: Gespeichert werden der Seitenpfad, das Land, das wir aus Ihrer IP-Adresse ableiten, die Geräteklasse, die wir aus dem Browser-Kennzeichen ableiten, der Zeitpunkt, auf die volle Stunde gerundet, und ein Zähler dafür, dass das Banner angezeigt wurde. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen. Wählen Sie „Ablehnen“, zählen wir einmal die Ablehnung und danach nichts mehr. Sendet Ihr Browser „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner nicht an und zählen nichts.</p>
<p>Wählen Sie „Annehmen“, messen wir zusätzlich, von welcher Website Sie gekommen sind (nur der Domainname), die Größenklasse Ihres Browserfensters, wie lange Sie auf einer Seite bleiben und wie weit Sie scrollen, wo Sie klicken (Klickposition und Aufbau des angeklickten Elements, ohne Texte und Formulareingaben, für eine „Heatmap“), ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann nur mit deren Domainname), und eine Besucher-Kennung, die täglich wechselt. Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen: über „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts) und „Ablehnen“.</p>
<p>IP-Adressen und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher und speichern sie in den Messdaten nicht. Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen, zusammengefasste Tageswerte ohne Besucher-Kennung nach 25 Monaten. Alle Einzelheiten, auch zum Widerspruch und zu Ihren Rechten, finden Sie auf unserer Zusatzseite <a href="https://webklar.com/datenschutz-analyse.html">webklar.com/datenschutz-analyse.html</a>.</p>
<h2>5. Kontaktaufnahme</h2>
<p>Über das Kontaktformular auf webklar.com erheben wir Name, E-Mail-Adresse, Firma (freiwillig) und Ihre Nachricht. Die Angaben speichern wir in unserer eigenen Datenbank auf unserem Server. Schreiben Sie uns per E-Mail oder rufen Sie an, verarbeiten wir die Angaben, die Sie uns dabei mitteilen.</p>
<p>Über das Kontaktformular auf webklar.com erheben wir Name, E-Mail-Adresse, Firma (freiwillig) und Ihre Nachricht. Die Angaben speichern wir in unserer eigenen Datenbank auf unserem Server und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8), damit wir von Ihrer Anfrage erfahren. Schreiben Sie uns per E-Mail oder rufen Sie an, verarbeiten wir die Angaben, die Sie uns dabei mitteilen.</p>
<p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p>
<h2>6. Kundenportal</h2>
@@ -149,7 +156,7 @@
<p>Wir verkaufen keine Daten und geben sie nicht zu Werbezwecken weiter.</p>
<h2>13. Ihre Rechte</h2>
<p>Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) und Datenübertragbarkeit (Art. 20). Eine Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3). Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.</p>
<p>Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) und Datenübertragbarkeit (Art. 20). Eine Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3). Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).</p>
<div class="box widerspruch">
<strong>Widerspruchsrecht (Art. 21 DSGVO)</strong>
<p>Verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO, können Sie aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit widersprechen. Verarbeiten wir Ihre Daten zur Direktwerbung, können Sie dem jederzeit ohne Angabe von Gründen widersprechen. Dann nutzen wir Ihre Daten nicht mehr für diesen Zweck. Ein formloser Hinweis an die Kontaktdaten in Abschnitt 1 genügt.</p>
+10
View File
@@ -1058,6 +1058,16 @@ export const config = {
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
avvVersion: '2026-10-02',
// WEBklars eigene Website (kein Kundenprojekt, kein Abo): fester Eintrag im Messdienst, Auswertung im WOMS
// (server/services/analyseEigen.js). Der Schluessel ist nicht geheim, er steht im Skript-Tag von webklar.com.
// aktiv: false schaltet die Messung ab (Messdienst liefert dann das leere Skript und verwirft Ereignisse).
eigeneWebsite: {
aktiv: true,
siteKey: 'yiy992rvq7vchfd7bqtqivah',
projectId: 'webklar-eigene-website',
domains: ['webklar.com', 'www.webklar.com'],
host: 'webklar.com',
},
},
// Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
+7
View File
@@ -33,6 +33,7 @@ import { purgeViewLog } from './services/customerViewLog.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { ensureEigeneWebsite } from './services/analyseEigen.js'
import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js'
@@ -54,6 +55,7 @@ import publicShopRoutes from './routes/publicShop.js'
import publicUnterkunftRoutes from './routes/publicUnterkunft.js'
import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js'
import adminAnalyseEigenRoutes from './routes/admin/analyseEigen.js'
import { legalRouter } from './services/legalDocs.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js'
import adminProjektkontenRoutes from './routes/admin/projektkonten.js'
@@ -121,6 +123,8 @@ app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes)
// Website-Analyse der eigenen Website webklar.com (WOMS-Seite Website-Statistik)
app.use('/api/admin/analyse-eigen', adminAnalyseEigenRoutes)
// Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft
app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)
app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
@@ -241,6 +245,9 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.then(() => runAnalyseReconcile({ now: new Date() }))
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
// eigene Website webklar.com: Eintrag im Messdienst nachziehen (ohne Kunde und Abo, config.analyse.eigeneWebsite)
.then(() => ensureEigeneWebsite())
.catch((err) => console.warn('[analyse] Eintrag für webklar.com fehlgeschlagen:', err.message))
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
+34
View File
@@ -0,0 +1,34 @@
// Admin-API (WOMS): Auswertung der Website-Analyse fuer WEBklars eigene Website (webklar.com). Jeder Mitarbeiter darf lesen.
import { Router } from 'express'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
import { eigeneAuswertung, eigenerHeatmapLink, gueltigerPfad } from '../../services/analyseEigen.js'
const router = Router()
router.use(requireAppwriteTeam)
router.get('/', async (req, res) => {
const range = String(req.query.range || '30')
if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum (7, 30 oder 90 Tage)' })
try {
res.json(await eigeneAuswertung(Number(range)))
} catch (err) {
// 404: Messdienst kennt die Website noch nicht (Eintrag entsteht beim Start bzw. im Tagesjob)
if (err.status === 404) return res.status(409).json({ error: 'Die Messung für webklar.com ist noch nicht eingerichtet.' })
console.error('[admin-analyse-eigen] Auswertung:', err.status || '', err.message)
res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' })
}
})
router.post('/heatmap-link', (req, res) => {
const { path, vp } = req.body || {}
if (!gueltigerPfad(path)) return res.status(400).json({ error: 'Ungültiger Pfad' })
if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' })
try {
res.json({ url: eigenerHeatmapLink({ path, vp }) })
} catch (err) {
console.error('[admin-analyse-eigen] Heatmap-Link:', err.status || '', err.message)
res.status(503).json({ error: 'Der Heatmap-Link konnte gerade nicht erstellt werden.' })
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
// Website-Analyse fuer WEBklars eigene Website (webklar.com): ohne Kunde, Abo, Funktionsdokument und Tickets.
// Der Messdienst kennt die Website ueber den festen Eintrag config.analyse.eigeneWebsite; das WOMS liest die
// Auswertung ueber /api/admin/analyse-eigen. Rechtlich ist WEBklar hier selbst Verantwortlicher (keine AVV).
import { config } from '../config.js'
import { analyticsClient } from './analyticsClient.js'
// Tests ersetzen den Client
export const eigenDeps = { client: analyticsClient }
const website = () => config.analyse.eigeneWebsite
/** Gleiche Pruefung wie beim Heatmap-Link der Kunden (routes/funktionen.js): Pfad ohne Abfrage, Anker und Steuerzeichen. */
export function gueltigerPfad(path) {
return typeof path === 'string' && path.startsWith('/') && path.length <= 300 && !/[?#\\\s\u0000-\u001f\u007f]/.test(path)
}
/**
* Eintrag im Messdienst anlegen bzw. nachziehen (idempotent), bei aktiv: false abschalten.
* Laeuft taeglich und beim Start; ein Fehler wird geworfen und vom Aufrufer geloggt.
*/
export async function ensureEigeneWebsite() {
const w = website()
if (!w.aktiv) {
await eigenDeps.client.stopSite(w.siteKey)
return { aktiv: false }
}
await eigenDeps.client.putSite(w.siteKey, { projectId: w.projectId, domains: w.domains, active: true })
return { aktiv: true }
}
/** Kennzahlen fuer 7, 30 oder 90 Tage (Form wie analytics/src/stats.js), dazu Adresse und Zustand fuer die Anzeige. */
export async function eigeneAuswertung(range) {
const w = website()
const stats = await eigenDeps.client.getStats(w.siteKey, range)
return { website: { host: w.host, aktiv: w.aktiv }, stats }
}
/** Heatmap-Link fuer eine Seite von webklar.com (neuer Tab; webklar.com laesst sich nicht in fremde Seiten einbetten). */
export function eigenerHeatmapLink({ path, vp }) {
const w = website()
const token = eigenDeps.client.heatToken({ siteKey: w.siteKey, path, vp })
return `https://${w.host}${path}?__wk_heat=${encodeURIComponent(token)}`
}
+96
View File
@@ -0,0 +1,96 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Website-Analyse der eigenen Website webklar.com: fester Messdienst-Eintrag und Auswertung fuers WOMS (/api/admin/analyse-eigen).
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { default: routes } = await import('../server/routes/admin/analyseEigen.js')
const eigen = await import('../server/services/analyseEigen.js')
const app = await startTestApp({ '/api/admin/analyse-eigen': routes })
const vorher = { ...config.analyse.eigeneWebsite }
after(() => {
Object.assign(config.analyse.eigeneWebsite, vorher)
return Promise.all([app.close(), aw.close(), st.close()])
})
const aufrufe = []
let statsFehler = null
eigen.eigenDeps.client = {
putSite: async (siteKey, body) => aufrufe.push({ op: 'put', siteKey, ...body }),
stopSite: async (siteKey) => aufrufe.push({ op: 'stop', siteKey }),
getStats: async (siteKey, range) => {
if (statsFehler) throw statsFehler
aufrufe.push({ op: 'stats', siteKey, range })
return { range, totals: { pv: 12 } }
},
heatToken: ({ siteKey, path, vp }) => `tok:${siteKey}:${path}:${vp}`,
}
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
const W = config.analyse.eigeneWebsite
beforeEach(() => {
aufrufe.length = 0
statsFehler = null
Object.assign(config.analyse.eigeneWebsite, vorher)
})
test('Konfiguration: gueltiger Schluessel und Domains, wie sie der Messdienst annimmt', () => {
assert.match(W.siteKey, /^[a-z0-9]{16,40}$/)
assert.equal(W.aktiv, true)
assert.equal(W.host, 'webklar.com')
assert.deepEqual(W.domains, ['webklar.com', 'www.webklar.com'])
assert.ok(W.projectId.length > 0 && W.projectId.length <= 100)
})
test('ensureEigeneWebsite: legt den Eintrag an bzw. zieht ihn nach; aktiv false schaltet ab', async () => {
assert.deepEqual(await eigen.ensureEigeneWebsite(), { aktiv: true })
assert.deepEqual(aufrufe, [{ op: 'put', siteKey: W.siteKey, projectId: W.projectId, domains: W.domains, active: true }])
aufrufe.length = 0
config.analyse.eigeneWebsite.aktiv = false
assert.deepEqual(await eigen.ensureEigeneWebsite(), { aktiv: false })
assert.deepEqual(aufrufe, [{ op: 'stop', siteKey: W.siteKey }])
})
test('GET: nur fuer Mitarbeiter', async () => {
assert.equal((await rawGet('/api/admin/analyse-eigen')).status, 401)
assert.equal((await rawGet('/api/admin/analyse-eigen', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403)
assert.equal((await rawGet('/api/admin/analyse-eigen', { headers: { Authorization: 'Bearer jwt-admin' } })).status, 200)
assert.deepEqual(aufrufe.map((a) => a.op), ['stats'])
})
test('GET: Auswertung mit Zeitraum, Adresse und Zustand', async () => {
const r = await rawGet('/api/admin/analyse-eigen?range=7', staff)
assert.equal(r.status, 200)
assert.deepEqual(r.body, { website: { host: 'webklar.com', aktiv: true }, stats: { range: 7, totals: { pv: 12 } } })
assert.deepEqual(aufrufe, [{ op: 'stats', siteKey: W.siteKey, range: 7 }])
assert.equal((await rawGet('/api/admin/analyse-eigen', staff)).body.stats.range, 30, 'Vorgabe 30 Tage')
for (const bad of ['1', '31', 'abc', '30x']) assert.equal((await rawGet(`/api/admin/analyse-eigen?range=${bad}`, staff)).status, 400)
})
test('GET: Messdienst ohne Eintrag => 409, nicht erreichbar => 503 ohne Details', async () => {
statsFehler = Object.assign(new Error('Auswertung antwortet mit Fehler 404'), { status: 404 })
const a = await rawGet('/api/admin/analyse-eigen', staff)
assert.equal(a.status, 409)
assert.match(a.body.error, /noch nicht eingerichtet/)
statsFehler = Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 })
const b = await rawGet('/api/admin/analyse-eigen', staff)
assert.deepEqual([b.status, b.body.error], [503, 'Auswertung gerade nicht erreichbar'])
})
test('POST heatmap-link: Link auf webklar.com mit Token; Pfad und Ansicht werden geprueft', async () => {
const r = await post('/api/admin/analyse-eigen/heatmap-link', { path: '/wartungs-abo', vp: 'm' }, staff)
assert.equal(r.status, 200)
assert.equal(r.body.url, `https://webklar.com/wartungs-abo?__wk_heat=${encodeURIComponent(`tok:${W.siteKey}:/wartungs-abo:m`)}`)
for (const path of ['wartungs-abo', '/a?b=1', '/a#x', '/a b', '/a\\b', `/${'x'.repeat(300)}`, 5, null]) {
assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path, vp: 'd' }, staff)).status, 400, String(path))
}
assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path: '/', vp: 'x' }, staff)).status, 400)
assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path: '/', vp: 'd' })).status, 401)
})
+11
View File
@@ -76,3 +76,14 @@ test('Banner-Archiv: Text und aria-label wortgleich zu core.js, README nennt BAN
readFileSync(root('runner/src/templates/datenschutz-analyse.html'))
)
})
test('Banner-Archiv eigene Website: Sie-Fassung wortgleich zu core.js, README nennt BANNER_VERSION_EIGEN', () => {
const v = core.BANNER_VERSION_EIGEN
const md = readFileSync(root(`docs/rechtliches/archiv/banner-eigen-${v}.md`), 'utf8')
assert.ok(md.includes(core.BANNER_TEXT_EIGEN))
assert.ok(md.includes(`BANNER_VERSION_EIGEN\`: ${v}`))
const readme = readFileSync(root('docs/rechtliches/archiv/README.md'), 'utf8')
assert.ok(readme.includes('BANNER_VERSION_EIGEN') && readme.includes(`datenschutz-analyse-webklar-com-${v}.html`))
const seite = readFileSync(root(`docs/rechtliches/archiv/datenschutz-analyse-webklar-com-${v}.html`), 'utf8')
assert.ok(seite.includes(`Stand ${v.slice(8, 10)}.${v.slice(5, 7)}.${v.slice(0, 4)}`), 'Stand der Zusatzseite = Fassung')
})
+1 -1
View File
@@ -39,5 +39,5 @@ test('Datenschutz-Prüfpunkte D1 bis D8 mit gültiger Fundstelle in public/daten
const zeilen = lies('public/datenschutz.html').split('\n')
const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1]))
assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length))
assert.ok(zeilen[119].includes('Zahlungspflichtig bestellen') && zeilen[118].includes('Leistungsbeginn') && zeilen[94].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
assert.ok(zeilen[126].includes('Zahlungspflichtig bestellen') && zeilen[125].includes('Leistungsbeginn') && zeilen[101].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
})
+25 -1
View File
@@ -558,7 +558,7 @@ test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren,
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
assert.ok(terms)
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
assert.ok(html.includes('<p class="meta">Stand 07.10.2026</p>'), 'Stand = Tag der Änderung (Abschnitt 6f Buchungskalender)')
assert.ok(html.includes('<p class="meta">Stand 17.10.2026</p>'), 'Stand = Tag der Änderung (Abschnitt 4a Reichweitenmessung auf webklar.com)')
})
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
@@ -708,3 +708,27 @@ test('datenschutz_pdf: das PDF der Datenschutzerklärung ist eingecheckt (> 20 K
const skript = read('scripts/build-legal-pdfs.sh')
assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/)
})
// ---------------------------------------------------------------- Reichweitenmessung auf webklar.com (eigene Website-Analyse)
test('legal_datenschutz_4a: eigene Website-Analyse auf webklar.com mit Rolle, Rechtsgrundlagen, Fristen und Zusatzseite', () => {
const html = read(DS)
const ds = strip(html)
// Der frühere Satz "keine Analyse-, Tracking- oder Marketing-Werkzeuge" gilt nur noch mit Ausnahme der eigenen Messung
assert.ok(ds.includes('Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten'))
assert.ok(!ds.includes('Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.'))
assert.ok(ds.includes('Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).'))
assert.ok(html.includes('<tr><td>wk-consent (lokaler Speicher, webklar.com)</td>'))
// 4a steht zwischen 4 und 5
const i4 = html.indexOf('<h2>4. Cookies und Speicher im Browser</h2>')
const i4a = html.indexOf('<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>')
const i5 = html.indexOf('<h2>5. Kontaktaufnahme</h2>')
assert.ok(i4 > 0 && i4 < i4a && i4a < i5, 'Reihenfolge 4, 4a, 5')
const block = strip(html.slice(i4a, i5))
for (const s of ['für webklar.com sind wir selbst Verantwortlicher', 'Hetzner Online GmbH in Deutschland', 'ohne Cookies',
'Art. 6 Abs. 1 lit. f DSGVO', '§ 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO', 'Global Privacy Control', 'Keks-Symbol',
'nach 30 Tagen', 'nach 25 Monaten', 'täglich wechselt', 'webklar.com/datenschutz-analyse.html']) assert.ok(block.includes(s), s)
assert.ok(html.includes('<a href="https://webklar.com/datenschutz-analyse.html">'))
// Kontaktformular: seit 17.10.2026 auch per Mail an das eigene Postfach
assert.ok(ds.includes('und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8)'))
})