Web-Demo: 2 Gratis-Analysen direkt auf der Webseite

Worker (/demo auf behoerdenklar-proxy):
- Turnstile-Verifikation serverseitig (Bot-Mauer)
- Analyse 1 anonym (1x pro IP/30 Tage), Analyse 2 nur mit E-Mail
  (SHA-256-Hash mit Pepper, dauerhaft — nie Klartext gespeichert)
- IP-Gesamtlimit 3/30 Tage, globales Tagesbudget 100 (~2,50 EUR Deckel)
- Wegwerf-Mail-Blockliste, 5-MB-Limit, CORS nur eigene Domain
- Prompt/Schema identisch zur App (ohne Antwort-Generator)

Webseite:
- demo.html im Papier-Design: Einwilligung, Upload mit Client-Downscaling
  (2000px/JPEG wie App), Ampel-Logik, Ergebnis-Karten, Warteliste-CTA
  mit vorbelegter E-Mail, gestaffelter Ablauf 1. anonym / 2. mit E-Mail
- Startseite verlinkt Demo im Hero
- Datenschutz: neuer Abschnitt 8 Gratis-Demo (Einwilligung, Turnstile,
  E-Mail-Hash), Folgeabschnitte neu nummeriert; Sitemap + /demo

Ende-zu-Ende getestet: echte Analyse (Jobcenter-Testbrief korrekt erklärt,
Ampel erkennt fruehere Absagefrist), Schummler-Szenarien (Browser-Loeschen,
doppelte E-Mail) serverseitig abgefangen. Dabei entdeckt: Anthropic-Key
war ungueltig -> erneuert (App-Analyse funktionierte wieder).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 13:06:03 +02:00
parent 02523b0467
commit ec6ec22421
8 changed files with 899 additions and 3 deletions

View File

@@ -0,0 +1,9 @@
---
name: .claude
description: Describe what this custom agent does and when to use it.
tools: Read, Grep, Glob, Bash # specify the tools this agent can use. If not set, all enabled tools are allowed.
---
<!-- Tip: Use /create-agent in chat to generate content with agent assistance -->
Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.

View File

@@ -0,0 +1,9 @@
---
name: claude
description: Describe what this custom agent does and when to use it.
tools: Read, Grep, Glob, Bash # specify the tools this agent can use. If not set, all enabled tools are allowed.
---
<!-- Tip: Use /create-agent in chat to generate content with agent assistance -->
Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.

View File

@@ -16,12 +16,18 @@
interface Env { interface Env {
/** Geheimnis: `wrangler secret put ANTHROPIC_API_KEY` */ /** Geheimnis: `wrangler secret put ANTHROPIC_API_KEY` */
ANTHROPIC_API_KEY: string; ANTHROPIC_API_KEY: string;
/** Geheimnis: `wrangler secret put TURNSTILE_SECRET_KEY` (Web-Demo) */
TURNSTILE_SECRET_KEY: string;
/** KV-Namespace für das Rate-Limit (Binding in wrangler.toml) */ /** KV-Namespace für das Rate-Limit (Binding in wrangler.toml) */
RATE_LIMIT: KVNamespace; RATE_LIMIT: KVNamespace;
/** Erlaubte Anfragen pro Gerät und Tag (in wrangler.toml unter [vars]) */ /** Erlaubte Anfragen pro Gerät und Tag (in wrangler.toml unter [vars]) */
TAGES_LIMIT: string; TAGES_LIMIT: string;
/** Erlaubte Anfragen pro IP-Adresse und Tag (in wrangler.toml unter [vars]) */ /** Erlaubte Anfragen pro IP-Adresse und Tag (in wrangler.toml unter [vars]) */
TAGES_LIMIT_IP: string; TAGES_LIMIT_IP: string;
/** Web-Demo: hartes Tagesbudget über alle Nutzer (Kosten-Deckel) */
DEMO_TAGES_BUDGET: string;
/** Web-Demo: max. Analysen pro IP innerhalb von 30 Tagen */
DEMO_LIMIT_IP: string;
} }
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
@@ -44,8 +50,290 @@ function fehler(status: number, typ: string, meldung: string): Response {
); );
} }
// ============================================================
// Web-Demo (/demo): 2 Gratis-Analysen direkt auf der Webseite
// ============================================================
//
// Schutzschichten (jede allein wäre umgehbar, zusammen dicht genug):
// 1. Turnstile-Token (Bot-Mauer, wird serverseitig verifiziert)
// 2. Analyse 1 anonym: max. 1 pro IP / 30 Tage
// 3. Analyse 2 nur mit E-Mail: pro E-Mail (Hash) genau 1 — dauerhaft
// 4. IP-Gesamtlimit über 30 Tage (bremst Verlauf-Löscher)
// 5. Globales Tagesbudget (harter Kosten-Deckel für den Betreiber)
/** Nur die eigene Webseite darf den Demo-Endpunkt aufrufen (CORS). */
const DEMO_ORIGIN_MUSTER = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.pages\.dev$/;
/** ~5 MB Datei entsprechen ~6,7 Mio. Base64-Zeichen. */
const DEMO_MAX_BASE64 = 7_000_000;
/** Bekannte Wegwerf-E-Mail-Domains (kleine, pragmatische Liste). */
const WEGWERF_DOMAINS = new Set([
'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'temp-mail.org',
'tempmail.com', 'trashmail.com', 'yopmail.com', 'sharklasers.com',
'getnada.com', 'dispostable.com', 'maildrop.cc', 'throwawaymail.com',
]);
/** Identisch zur App (src/services/analyse.ts) — gleiche Qualität in der Demo. */
const DEMO_SYSTEM_PROMPT = `Du bist ein Assistent, der deutschen Behördenbriefe für Privatpersonen verständlich macht. Die Nutzer sind Deutsche, die Amtsdeutsch schwer verstehen, oder Menschen mit Deutsch als Fremdsprache.
Deine Aufgabe:
1. Lies den fotografierten/hochgeladenen Brief vollständig.
2. Erkläre ihn in einfacher Alltagssprache (Sprachniveau A2/B1): kurze Sätze, keine Schachtelsätze, keine unerklärten Fachbegriffe.
3. Extrahiere Fristen und Termine exakt. Datumsangaben immer als ISO-Format (JJJJ-MM-TT). Wenn du ein Datum nicht sicher lesen kannst, lass das Feld null — erfinde niemals Daten.
4. Erstelle eine konkrete Checkliste, was der Nutzer tun muss.
5. Sei sachlich und beruhigend, nicht alarmierend.
Wichtig: Wenn das Bild kein Behördenbrief ist oder unlesbar ist, schreibe das klar in kernaussage und erklaerung_einfach und lasse frist/termin null.`;
/** Schlanke Demo-Variante des Analyse-Schemas (ohne Antwort-Optionen —
* der Antwort-Generator bleibt der App vorbehalten). */
const DEMO_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['brieftyp', 'absender', 'kernaussage', 'erklaerung_einfach', 'fachbegriffe', 'frist', 'termin', 'checkliste'],
properties: {
brieftyp: { type: 'string', description: 'Kurze Kategorie des Briefs, z. B. "Einladung Jobcenter".' },
absender: { type: 'string', description: 'Die Behörde, die den Brief geschickt hat.' },
kernaussage: { type: 'string', description: 'Antwort auf "Was will das Amt von mir?" in 2-3 kurzen Sätzen, Sprachniveau A2.' },
erklaerung_einfach: { type: 'string', description: 'Erklärung des gesamten Briefs in einfacher Alltagssprache (A2/B1). Kurze Sätze.' },
fachbegriffe: {
type: 'array',
description: 'Fachbegriffe aus dem Brief mit einfacher Erklärung.',
items: {
type: 'object', additionalProperties: false, required: ['begriff', 'erklaerung'],
properties: { begriff: { type: 'string' }, erklaerung: { type: 'string' } },
},
},
frist: {
description: 'Frist, bis wann reagiert werden muss. null wenn keine Frist im Brief steht.',
anyOf: [
{
type: 'object', additionalProperties: false, required: ['datum', 'aktion'],
properties: { datum: { type: 'string', description: 'ISO JJJJ-MM-TT' }, aktion: { type: 'string' } },
},
{ type: 'null' },
],
},
termin: {
description: 'Persönlicher Termin. null wenn keiner im Brief steht.',
anyOf: [
{
type: 'object', additionalProperties: false, required: ['datum', 'uhrzeit', 'ort'],
properties: {
datum: { type: 'string', description: 'ISO JJJJ-MM-TT' },
uhrzeit: { anyOf: [{ type: 'string' }, { type: 'null' }] },
ort: { anyOf: [{ type: 'string' }, { type: 'null' }] },
},
},
{ type: 'null' },
],
},
checkliste: { type: 'array', description: 'To-do-Liste. Leer wenn nichts zu tun ist.', items: { type: 'string' } },
},
};
function demoCorsHeaders(origin: string | null): Record<string, string> {
const erlaubt = origin && DEMO_ORIGIN_MUSTER.test(origin) ? origin : 'https://behoerdenklar.pages.dev';
return {
'access-control-allow-origin': erlaubt,
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
};
}
/** Fehler für die Demo-Seite: { code, meldung } + CORS. */
function demoFehler(status: number, code: string, meldung: string, origin: string | null): Response {
return Response.json({ code, meldung }, { status, headers: demoCorsHeaders(origin) });
}
/** SHA-256-Hash der E-Mail (mit Pepper) — es wird nie Klartext gespeichert. */
async function emailHash(email: string, pepper: string): Promise<string> {
const daten = new TextEncoder().encode(`bk-demo|${pepper}|${email}`);
const digest = await crypto.subtle.digest('SHA-256', daten);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
async function demoHandler(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('origin');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
}
if (request.method !== 'POST') {
return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
}
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) {
return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
}
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string };
try {
body = await request.json();
} catch {
return demoFehler(400, 'json', 'Ungültige Anfrage.', origin);
}
const { bild, mimeType, turnstileToken } = body;
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : null;
if (typeof bild !== 'string' || bild.length === 0 || bild.length > DEMO_MAX_BASE64) {
return demoFehler(400, 'datei', 'Die Datei fehlt oder ist zu groß (max. 5 MB).', origin);
}
if (mimeType !== 'image/jpeg' && mimeType !== 'image/png' && mimeType !== 'application/pdf') {
return demoFehler(400, 'datei', 'Nur Fotos (JPG/PNG) oder PDF sind möglich.', origin);
}
if (typeof turnstileToken !== 'string' || !turnstileToken) {
return demoFehler(403, 'turnstile', 'Sicherheitsprüfung fehlt. Bitte Seite neu laden.', origin);
}
// Schicht 1: Turnstile serverseitig verifizieren (niemals nur im Browser!)
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
const pruefung = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ secret: env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: ip }),
});
const pruefungJson = (await pruefung.json()) as { success?: boolean };
if (!pruefungJson.success) {
return demoFehler(403, 'turnstile', 'Sicherheitsprüfung fehlgeschlagen. Bitte Seite neu laden und erneut versuchen.', origin);
}
// E-Mail prüfen (nur für die 2. Analyse nötig)
let mailKey: string | null = null;
if (email) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
return demoFehler(400, 'email', 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', origin);
}
const domain = email.split('@')[1];
if (WEGWERF_DOMAINS.has(domain)) {
return demoFehler(400, 'email', 'Wegwerf-E-Mail-Adressen sind nicht möglich. Bitte nutzen Sie Ihre normale Adresse.', origin);
}
mailKey = `demo:mail:${await emailHash(email, env.TURNSTILE_SECRET_KEY)}`;
}
// Schichten 2-5: Limits lesen
const heute = new Date().toISOString().slice(0, 10);
const tagKey = `demo:tag:${heute}`;
const ipKey = `demo:ip:${ip}`;
const anonKey = `demo:anon:${ip}`;
const budget = parseInt(env.DEMO_TAGES_BUDGET || '100', 10);
const ipLimit = parseInt(env.DEMO_LIMIT_IP || '3', 10);
const [tagWert, ipWert, anonWert, mailWert] = await Promise.all([
env.RATE_LIMIT.get(tagKey),
env.RATE_LIMIT.get(ipKey),
env.RATE_LIMIT.get(anonKey),
mailKey ? env.RATE_LIMIT.get(mailKey) : Promise.resolve(null),
]);
const tagZahl = parseInt(tagWert ?? '0', 10);
const ipZahl = parseInt(ipWert ?? '0', 10);
const anonZahl = parseInt(anonWert ?? '0', 10);
// Schicht 5: globales Tagesbudget (Kosten-Deckel)
if (tagZahl >= budget) {
return demoFehler(429, 'budget', 'Die Gratis-Demo ist für heute ausgebucht. Kommen Sie morgen wieder — oder tragen Sie sich in die Warteliste ein.', origin);
}
// Schicht 4: IP-Gesamtlimit (30 Tage)
if (ipZahl >= ipLimit) {
return demoFehler(429, 'aufgebraucht', 'Die Gratis-Analysen für diesen Anschluss sind aufgebraucht. In der App gibt es 3 weitere gratis.', origin);
}
// Schicht 2/3: ohne E-Mail nur 1x, pro E-Mail nur 1x (dauerhaft)
if (!email && anonZahl >= 1) {
return demoFehler(403, 'email_noetig', 'Ihre erste Gratis-Analyse ist verbraucht. Für die zweite geben Sie bitte Ihre E-Mail-Adresse ein.', origin);
}
if (mailKey && mailWert !== null) {
return demoFehler(429, 'aufgebraucht', 'Mit dieser E-Mail-Adresse wurde die Gratis-Analyse schon genutzt. In der App gibt es 3 weitere gratis.', origin);
}
// KI-Analyse — identischer Aufbau wie in der App
const dateiBlock =
mimeType === 'application/pdf'
? { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: bild } }
: { type: 'image', source: { type: 'base64', media_type: mimeType, data: bild } };
const antwort = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': ANTHROPIC_VERSION,
},
body: JSON.stringify({
model: 'claude-sonnet-5',
max_tokens: MAX_TOKENS_OBERGRENZE,
system: DEMO_SYSTEM_PROMPT,
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
messages: [
{
role: 'user',
content: [
dateiBlock,
{ type: 'text', text: `Analysiere diesen Behördenbrief. Heute ist der ${heute} (wichtig für relative Datumsangaben wie "innerhalb von 14 Tagen").` },
],
},
],
}),
});
if (!antwort.ok) {
const status = antwort.status;
// Fehlerdetails ins Worker-Log (wrangler tail) — hilft bei der Diagnose
console.log('Demo-KI-Fehler', status, (await antwort.text()).slice(0, 500));
if (status === 429 || status >= 500) {
return demoFehler(503, 'ki', 'Der KI-Dienst ist gerade ausgelastet. Bitte versuchen Sie es in einer Minute erneut.', origin);
}
return demoFehler(502, 'ki', 'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es mit einem neuen, gut beleuchteten Foto.', origin);
}
const daten = (await antwort.json()) as {
stop_reason?: string;
content?: { type: string; text?: string }[];
};
// Erst NACH erfolgreichem KI-Aufruf zählen (Fehler kosten kein Kontingent)
const MONAT_TTL = 60 * 60 * 24 * 30;
const schreiben: Promise<void>[] = [
env.RATE_LIMIT.put(tagKey, String(tagZahl + 1), { expirationTtl: 60 * 60 * 48 }),
env.RATE_LIMIT.put(ipKey, String(ipZahl + 1), { expirationTtl: MONAT_TTL }),
];
if (!email) {
schreiben.push(env.RATE_LIMIT.put(anonKey, String(anonZahl + 1), { expirationTtl: MONAT_TTL }));
}
if (mailKey) {
// bewusst OHNE TTL: „pro E-Mail für immer nur 1" — es liegt nur der Hash
schreiben.push(env.RATE_LIMIT.put(mailKey, heute));
}
await Promise.all(schreiben);
if (daten.stop_reason === 'refusal') {
return demoFehler(422, 'inhalt', 'Die KI konnte diesen Inhalt nicht verarbeiten. Bitte prüfen Sie, ob das Foto wirklich einen Behördenbrief zeigt.', origin);
}
const textBlock = [...(daten.content ?? [])].reverse().find((b) => b.type === 'text');
if (!textBlock?.text) {
return demoFehler(502, 'ki', 'Die KI hat keine verwertbare Antwort geliefert. Bitte erneut versuchen.', origin);
}
let analyse: unknown;
try {
analyse = JSON.parse(textBlock.text);
} catch {
return demoFehler(502, 'ki', 'Die Antwort war unlesbar. Bitte erneut versuchen.', origin);
}
return Response.json(
{ analyse, versuch: email ? 2 : 1 },
{ headers: demoCorsHeaders(origin) }
);
}
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
// Web-Demo hat einen eigenen Pfad mit eigenen Regeln
if (new URL(request.url).pathname === '/demo') {
return demoHandler(request, env);
}
if (request.method !== 'POST') { if (request.method !== 'POST') {
return fehler(405, 'invalid_request_error', 'Nur POST erlaubt.'); return fehler(405, 'invalid_request_error', 'Nur POST erlaubt.');
} }

View File

@@ -8,6 +8,10 @@ compatibility_date = "2026-07-01"
[vars] [vars]
TAGES_LIMIT = "20" TAGES_LIMIT = "20"
TAGES_LIMIT_IP = "100" TAGES_LIMIT_IP = "100"
# Web-Demo (/demo): hartes Tagesbudget über alle Nutzer (Kosten-Deckel,
# 100 Analysen ≈ 2,50 €/Tag Maximalschaden) + IP-Limit über 30 Tage
DEMO_TAGES_BUDGET = "100"
DEMO_LIMIT_IP = "3"
[[kv_namespaces]] [[kv_namespaces]]
binding = "RATE_LIMIT" binding = "RATE_LIMIT"

View File

@@ -145,7 +145,38 @@
Ihre Adresse wird dann gelöscht. Ihre Adresse wird dann gelöscht.
</p> </p>
<h2>8. Reichweitenmessung (Webseite)</h2> <h2>8. Gratis-Demo (Webseite)</h2>
<p>
Auf unserer Webseite können Sie zwei Brief-Analysen kostenlos
ausprobieren, ohne die App zu installieren. Dabei gilt:
</p>
<ul>
<li>
<strong>Ihr Brief-Foto</strong> wird — nur mit Ihrer Einwilligung
(Häkchen vor dem Hochladen, Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2
lit. a DSGVO) — über unseren Server (Cloudflare) an Anthropic (USA)
zur Analyse geschickt. Es wird auf unserem Server nicht gespeichert;
bei Anthropic wird es nach spätestens 30 Tagen gelöscht (wie in
Abschnitt 3a beschrieben). Das Ergebnis sehen nur Sie in Ihrem
Browser; wir speichern es nicht.
</li>
<li>
<strong>Zum Schutz vor Missbrauch</strong> (Art. 6 Abs. 1 lit. f
DSGVO) prüfen wir Anfragen mit Cloudflare Turnstile (Bot-Erkennung
durch Cloudflare, Inc., USA), zählen Analysen pro IP-Adresse
(Speicherung max. 30 Tage) und führen ein tägliches Gesamtlimit.
</li>
<li>
<strong>Für die zweite Gratis-Analyse</strong> geben Sie Ihre
E-Mail-Adresse an. Wir speichern sie ausschließlich als
nicht umkehrbare Prüfsumme (Hash), um das Kontingent von zwei
Analysen durchzusetzen — die Adresse selbst wird nicht gespeichert
und nicht für E-Mails verwendet. In die Warteliste (Abschnitt 7)
kommen Sie nur, wenn Sie sich dort separat eintragen.
</li>
</ul>
<h2>9. Reichweitenmessung (Webseite)</h2>
<p> <p>
Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare, Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare,
Inc., USA) — eine datenschutzfreundliche Besucherstatistik Inc., USA) — eine datenschutzfreundliche Besucherstatistik
@@ -157,14 +188,14 @@
DSGVO). DSGVO).
</p> </p>
<h2>9. Keine Rechtsberatung</h2> <h2>10. Keine Rechtsberatung</h2>
<p> <p>
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine BehördenKlar erklärt Briefe verständlich, ersetzt aber keine
Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich
an eine Beratungsstelle oder eine Anwältin/einen Anwalt. an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
</p> </p>
<h2>10. Änderungen</h2> <h2>11. Änderungen</h2>
<p> <p>
Wir passen diese Datenschutzerklärung an, wenn sich die App oder die Wir passen diese Datenschutzerklärung an, wenn sich die App oder die
Rechtslage ändert. Die aktuelle Fassung finden Sie immer auf dieser Rechtslage ändert. Die aktuelle Fassung finden Sie immer auf dieser

550
webseite/demo.html Normal file
View File

@@ -0,0 +1,550 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gratis-Demo: Behördenbrief online erklären lassen | BehördenKlar</title>
<meta name="description" content="Testen Sie BehördenKlar direkt im Browser: Brief fotografieren, hochladen, in einfacher Sprache erklärt bekommen. 2 Analysen gratis, ohne Konto.">
<link rel="canonical" href="https://behoerdenklar.pages.dev/demo">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<meta property="og:site_name" content="BehördenKlar">
<meta property="og:type" content="website">
<meta property="og:locale" content="de_DE">
<meta property="og:url" content="https://behoerdenklar.pages.dev/demo">
<meta property="og:title" content="Probieren Sie es aus: Ihr Behördenbrief, einfach erklärt">
<meta property="og:description" content="Direkt im Browser testen — 2 Gratis-Analysen, ohne Konto. Brief fotografieren, Klartext lesen.">
<meta property="og:image" content="https://behoerdenklar.pages.dev/vorschau.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<style>
:root {
--papier: #faf7f0;
--papier-dunkel: #f1ecdf;
--tinte: #21211d;
--grau: #6b675e;
--amtsblau: #1d4e89;
--stempelrot: #b3372b;
--marker: #ffe66b;
--gruen: #2e7d4f;
--rot: #b3372b;
--gelb: #a06b00;
color-scheme: light;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: Georgia, 'Times New Roman', serif;
background:
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33, 33, 29, 0.025) 34px 35px),
var(--papier);
color: var(--tinte);
line-height: 1.7;
font-size: 17px;
}
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
.briefkopf {
display: flex; justify-content: space-between; align-items: baseline;
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte);
font-size: 14px;
}
.briefkopf .absender { font-weight: 700; font-size: 16px; }
.briefkopf .absender a { color: inherit; text-decoration: none; }
.briefkopf .az { color: var(--grau); font-size: 13px; }
header { padding: 40px 0 8px; }
.betreff-label {
font-size: 13px; color: var(--grau); text-transform: uppercase;
letter-spacing: 0.14em; margin-bottom: 10px;
}
h1 {
font-size: clamp(30px, 6vw, 44px);
line-height: 1.15; font-weight: 700; letter-spacing: -0.01em;
}
.markiert {
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
}
.anrede { margin-top: 20px; font-size: 18px; max-width: 580px; }
section { margin-top: 44px; }
h2 {
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--grau); font-weight: 400; margin-bottom: 20px;
display: flex; align-items: center; gap: 12px;
}
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
h2 .nr { font-family: 'Courier New', ui-monospace, monospace; }
.antrag {
background: #fff; border: 2px solid var(--tinte);
box-shadow: 7px 8px 0 rgba(33, 33, 29, 0.9);
padding: 28px 26px; position: relative;
}
.formular-kopf {
font-family: 'Courier New', ui-monospace, monospace;
font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--grau); margin-bottom: 10px;
}
.cta {
display: inline-block; width: 100%; padding: 17px 30px;
background: var(--amtsblau); color: #fff; text-decoration: none;
font-family: inherit; font-size: 18px; font-weight: 700; text-align: center;
border: 2px solid var(--tinte);
box-shadow: 5px 5px 0 var(--tinte);
transition: transform 0.12s ease, box-shadow 0.12s ease;
cursor: pointer;
}
.cta:hover:not(:disabled) { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
.cta:disabled { opacity: 0.45; cursor: not-allowed; }
.konsens {
display: flex; gap: 12px; align-items: flex-start;
font-size: 15px; color: var(--grau); margin: 14px 0 18px;
}
.konsens input { width: 22px; height: 22px; margin-top: 4px; flex-shrink: 0; accent-color: var(--amtsblau); }
.konsens a { color: var(--amtsblau); }
.klein { font-size: 13.5px; color: var(--grau); margin-top: 12px; }
.klein a { color: var(--grau); }
.status { text-align: center; padding: 26px 10px; display: none; }
.status .gross { font-size: 20px; font-weight: 700; }
.status .sub { color: var(--grau); margin-top: 6px; font-size: 15px; }
.sanduhr {
display: inline-block; font-size: 34px;
animation: drehen 2.4s linear infinite;
}
@keyframes drehen { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.fehlerbox {
display: none; margin-top: 16px;
border: 2px solid var(--stempelrot); background: #fdf1ef;
color: #7c2018; padding: 14px 16px; font-size: 16px;
}
.email-teil { display: none; margin-top: 16px; }
.wl-input {
width: 100%; padding: 15px 16px; font-size: 17px; font-family: inherit;
border: 2px solid var(--tinte); background: #fff; color: var(--tinte);
margin-bottom: 12px;
}
.wl-input::placeholder { color: #9a958a; }
/* Ergebnis */
#ergebnis { display: none; }
.brief-karte {
background: #fff; border: 1px solid rgba(33, 33, 29, 0.28);
padding: 22px 24px; position: relative; margin-top: 22px;
box-shadow: 4px 5px 0 rgba(33, 33, 29, 0.08);
}
.karten-label {
position: absolute; top: -12px; left: 16px;
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.1em;
background: var(--gruen); color: var(--papier); padding: 3px 10px;
}
.karten-label.neutral { background: var(--tinte); }
.ampelzeile {
margin-top: 14px; padding-top: 12px;
border-top: 1px dashed rgba(33, 33, 29, 0.3);
font-size: 16px; font-weight: 700;
}
.todo { padding: 8px 0 8px 2px; border-bottom: 1px dotted rgba(33,33,29,0.3); font-size: 16.5px; }
.todo::before { content: '☐'; margin-right: 10px; color: var(--amtsblau); font-size: 18px; }
details { border-bottom: 1px solid rgba(33, 33, 29, 0.2); padding: 12px 4px; }
summary { font-weight: 700; cursor: pointer; font-size: 16.5px; list-style: none; }
summary::-webkit-details-marker { display: none; }
details p { margin-top: 8px; color: var(--grau); font-size: 15.5px; }
.stempel {
display: inline-block; padding: 7px 14px;
border: 2.5px solid var(--stempelrot); color: var(--stempelrot);
font-family: 'Courier New', ui-monospace, monospace; font-weight: 700;
font-size: 14px; letter-spacing: 0.1em; text-transform: uppercase;
transform: rotate(-4deg); opacity: 0.9;
}
.hinweis-recht {
margin-top: 18px; font-size: 14px; color: var(--grau);
border-top: 1px dashed rgba(33,33,29,0.3); padding-top: 12px;
}
/* Warteliste-Formular (wie Startseite) */
.wl-form { display: flex; flex-direction: column; gap: 12px; margin-top: 4px; }
.bonus-zeile {
margin: 14px 0; padding: 12px 16px;
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
font-size: 16px;
}
footer { margin-top: 48px; font-size: 14.5px; color: var(--grau); }
footer a { color: var(--grau); }
</style>
</head>
<body>
<div class="wrap">
<div class="briefkopf">
<span class="absender"><a href="index.html">📬 BehördenKlar</a></span>
<span class="az mono">Formular D-01 · Gratis-Demo</span>
</div>
<header>
<p class="betreff-label">Betreff</p>
<h1>Probieren Sie es aus —<br><span class="markiert">mit Ihrem eigenen Brief.</span></h1>
<p class="anrede">
Kein Konto. Keine App-Installation. Laden Sie ein Foto Ihres
Behördenbriefs hoch — in etwa einer Minute lesen Sie ihn in einfacher
Sprache. <strong>2 Analysen gratis.</strong>
</p>
</header>
<section id="demo">
<h2><span class="nr">§ 1</span> Brief hochladen</h2>
<div class="antrag">
<p class="formular-kopf">Antrag auf Klartext · 2 Gratis-Analysen</p>
<div id="eingabe">
<label class="konsens">
<input type="checkbox" id="konsens">
<span>
Ich bin einverstanden, dass mein Brief-Foto zur Analyse an den
Server von BehördenKlar und an den KI-Dienst Anthropic (USA)
geschickt wird. Es wird dort nicht dauerhaft gespeichert.
Behördenbriefe enthalten persönliche Daten — laden Sie nur hoch,
was Sie analysieren lassen möchten.
<a href="datenschutz.html" target="_blank">Datenschutzerklärung</a>
</span>
</label>
<div id="emailTeil" class="email-teil">
<p style="margin-bottom: 10px;">
🎉 <strong>Ihre erste Analyse ist verbraucht.</strong> Für die
zweite geben Sie bitte Ihre E-Mail-Adresse ein — so stellen wir
sicher, dass jeder genau 2 Gratis-Analysen bekommt.
</p>
<input class="wl-input" type="email" id="email" placeholder="Ihre E-Mail-Adresse" autocomplete="email">
<p class="klein" style="margin: 0 0 12px;">
Die Adresse wird nur verschlüsselt (als Prüfsumme) gespeichert,
um mitzuzählen — Sie bekommen dadurch keine E-Mails von uns.
</p>
</div>
<input type="file" id="datei" accept="image/jpeg,image/png,application/pdf" style="display: none;">
<button class="cta" id="startKnopf" disabled>📷 Brief auswählen &amp; erklären lassen</button>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAD8vfEq7X2EX-Krk" data-action="turnstile-spin-v1" style="margin-top: 14px;"></div>
<p class="klein">
Geht mit Foto (JPG/PNG) oder PDF, max. 5 MB. Tipp: gutes Licht,
Brief von oben, ganzer Text im Bild.
</p>
</div>
<div class="status" id="status">
<span class="sanduhr"></span>
<p class="gross">Ihr Brief wird analysiert…</p>
<p class="sub">Das dauert bis zu einer Minute. Bitte lassen Sie die Seite geöffnet.</p>
</div>
<div class="fehlerbox" id="fehler"></div>
</div>
</section>
<section id="ergebnis">
<h2><span class="nr">§ 2</span> Ihr Brief in Klartext</h2>
<div class="brief-karte">
<span class="karten-label neutral" id="ergBrieftyp">Brief</span>
<p style="margin-top: 6px;"><strong id="ergKern"></strong></p>
<p class="ampelzeile" id="ergAmpel" style="display: none;"></p>
</div>
<div class="brief-karte">
<span class="karten-label">Einfach erklärt</span>
<p id="ergErklaerung" style="white-space: pre-line;"></p>
</div>
<div class="brief-karte" id="ergChecklisteKarte" style="display: none;">
<span class="karten-label neutral">Das müssen Sie tun</span>
<div id="ergCheckliste"></div>
</div>
<div class="brief-karte" id="ergBegriffeKarte" style="display: none;">
<span class="karten-label neutral">Schwere Wörter erklärt</span>
<div id="ergBegriffe"></div>
</div>
<p class="hinweis-recht">
⚖️ BehördenKlar erklärt Briefe, ersetzt aber keine Rechtsberatung. Bei
wichtigen Entscheidungen hilft eine Beratungsstelle oder ein Anwalt.
</p>
</section>
<section id="werbung" style="display: none;">
<h2><span class="nr">§ 3</span> Wie geht es weiter?</h2>
<div class="antrag">
<p class="formular-kopf" id="restZeile">Warteliste</p>
<h3 id="wlTitel" style="font-size: 24px; line-height: 1.3;">Das hat Ihnen geholfen?</h3>
<p style="margin-top: 10px;">
Die App kann noch mehr: Briefe <strong>übersetzen in 9 Sprachen</strong>,
Fristen mit <strong>Erinnerung</strong>, fertige
<strong>Antwort-Entwürfe</strong> — und Ihre Briefe bleiben
verschlüsselt auf Ihrem Handy gespeichert.
</p>
<p class="bonus-zeile">
🎁 Warteliste = <strong>5 Gratis-Analysen</strong> in der App statt 3.
</p>
<p id="nochmalZeile" style="display: none; margin-bottom: 14px;">
<a href="#demo" style="color: var(--amtsblau); font-weight: 700;">↑ Noch einen
Brief testen? Ihre zweite Gratis-Analyse wartet oben.</a>
</p>
<form class="wl-form" action="https://86550585.sibforms.com/serve/MUIFAAvOQCpFg148k-xD2Ze2PDVKKaFEIaLECCCMe9fGKd_uIurPljyn_IhTrkR0EkM_pQp_8VBzcHZ6N9sKDX5GBZi9elYKN6isXGPUUxcAgO37OKhTFZyMlJiC9ikRmPI8Q2tU2GvqP6esJ5ReGfv1htZV_OfXRIgW59P6pkeUsYKxqdXA7nWOaWxNTYz4MzzoGg7czJMJ2fxuEg==" method="POST">
<input class="wl-input" style="margin-bottom: 0;" type="email" name="EMAIL" id="wlEmail" placeholder="Ihre E-Mail-Adresse" required autocomplete="email">
<input type="text" name="email_address_check" value="" style="position:absolute; left:-5000px;" tabindex="-1" autocomplete="off" aria-hidden="true">
<input type="hidden" name="locale" value="de">
<input type="hidden" name="html_type" value="simple">
<button class="cta" type="submit">Auf die Warteliste eintragen →</button>
</form>
<p class="klein">
Eine E-Mail zum Start, keine Werbung, jederzeit abmeldbar —
<a href="datenschutz.html">Datenschutzerklärung</a>.
</p>
<div style="margin-top: 16px;"><span class="stempel">Bald im App Store</span></div>
</div>
</section>
<footer>
<a href="index.html">← Zur Startseite</a> ·
<a href="impressum.html">Impressum</a> ·
<a href="datenschutz.html">Datenschutzerklärung</a>
</footer>
</div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
(function () {
'use strict';
var ENDPUNKT = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev/demo';
var MAX_KANTE = 2000; // wie in der App: reicht für A4, spart Datenvolumen
var konsens = document.getElementById('konsens');
var startKnopf = document.getElementById('startKnopf');
var dateiFeld = document.getElementById('datei');
var emailTeil = document.getElementById('emailTeil');
var emailFeld = document.getElementById('email');
var status = document.getElementById('status');
var eingabe = document.getElementById('eingabe');
var fehlerBox = document.getElementById('fehler');
function benutzt() { return parseInt(localStorage.getItem('bk_demo_benutzt') || '0', 10); }
function setzeBenutzt(n) { localStorage.setItem('bk_demo_benutzt', String(n)); }
function zeigeStand() {
// 2. Versuch: E-Mail-Feld einblenden; alles verbraucht: Eingabe sperren
if (benutzt() >= 2) {
eingabe.style.display = 'none';
fehlerBox.style.display = 'block';
fehlerBox.innerHTML = 'Ihre 2 Gratis-Analysen sind verbraucht. 🎁 In der <strong>App</strong> gibt es 3 weitere gratis — mit Warteliste sogar 5. Tragen Sie sich unten ein!';
document.getElementById('werbung').style.display = 'block';
} else if (benutzt() === 1) {
emailTeil.style.display = 'block';
}
}
konsens.addEventListener('change', function () { startKnopf.disabled = !konsens.checked; });
startKnopf.addEventListener('click', function () {
fehlerBox.style.display = 'none';
if (benutzt() >= 1 && !emailFeld.value.trim()) {
emailTeil.style.display = 'block';
emailFeld.focus();
zeigeFehler('Bitte geben Sie zuerst Ihre E-Mail-Adresse ein — dann wählen Sie den Brief aus.');
return;
}
dateiFeld.click();
});
dateiFeld.addEventListener('change', function () {
if (dateiFeld.files && dateiFeld.files[0]) starteAnalyse(dateiFeld.files[0]);
});
function zeigeFehler(text) {
fehlerBox.style.display = 'block';
fehlerBox.textContent = text;
}
function turnstileToken() {
var feld = document.querySelector('[name="cf-turnstile-response"]');
return feld ? feld.value : '';
}
/** Foto verkleinern (max 2000px, JPEG 60%) — wie in der App. */
function bildVerkleinern(datei) {
return new Promise(function (resolve, reject) {
var img = new Image();
var url = URL.createObjectURL(datei);
img.onload = function () {
URL.revokeObjectURL(url);
var f = Math.min(1, MAX_KANTE / Math.max(img.width, img.height));
var c = document.createElement('canvas');
c.width = Math.round(img.width * f);
c.height = Math.round(img.height * f);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
resolve(c.toDataURL('image/jpeg', 0.6).split(',')[1]);
};
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('Bild unlesbar')); };
img.src = url;
});
}
function pdfAlsBase64(datei) {
return new Promise(function (resolve, reject) {
var leser = new FileReader();
leser.onload = function () { resolve(String(leser.result).split(',')[1]); };
leser.onerror = function () { reject(new Error('Datei unlesbar')); };
leser.readAsDataURL(datei);
});
}
async function starteAnalyse(datei) {
fehlerBox.style.display = 'none';
if (datei.size > 5 * 1024 * 1024 && datei.type === 'application/pdf') {
zeigeFehler('Die PDF-Datei ist zu groß (max. 5 MB). Tipp: Fotografieren Sie den Brief stattdessen.');
return;
}
var token = turnstileToken();
if (!token) {
zeigeFehler('Die Sicherheitsprüfung lädt noch — bitte warten Sie 2 Sekunden und versuchen Sie es erneut.');
return;
}
eingabe.style.display = 'none';
status.style.display = 'block';
try {
var istPdf = datei.type === 'application/pdf';
var base64 = istPdf ? await pdfAlsBase64(datei) : await bildVerkleinern(datei);
var body = {
bild: base64,
mimeType: istPdf ? 'application/pdf' : 'image/jpeg',
turnstileToken: token,
};
var email = emailFeld.value.trim();
if (benutzt() >= 1 && email) body.email = email;
var antwort = await fetch(ENDPUNKT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
var daten = await antwort.json();
if (!antwort.ok) {
status.style.display = 'none';
eingabe.style.display = 'block';
if (window.turnstile) window.turnstile.reset();
if (daten.code === 'email_noetig') {
setzeBenutzt(Math.max(benutzt(), 1));
emailTeil.style.display = 'block';
emailFeld.focus();
}
if (daten.code === 'aufgebraucht') {
setzeBenutzt(2);
zeigeStand();
return;
}
zeigeFehler(daten.meldung || 'Etwas ist schiefgelaufen. Bitte erneut versuchen.');
return;
}
setzeBenutzt(Math.max(benutzt(), daten.versuch));
if (email) document.getElementById('wlEmail').value = email;
// Token erneuern — sonst würde die 2. Analyse ein "Seite neu laden" verlangen
if (window.turnstile) window.turnstile.reset();
zeigeErgebnis(daten.analyse, daten.versuch);
} catch (e) {
status.style.display = 'none';
eingabe.style.display = 'block';
if (window.turnstile) window.turnstile.reset();
zeigeFehler('Verbindung fehlgeschlagen. Bitte prüfen Sie Ihr Internet und versuchen Sie es erneut.');
}
}
/** Ampel wie in der App: rot ≤ 7 Tage, gelb ≤ 21, sonst grün. */
function ampel(analyse) {
var daten = [];
if (analyse.frist && analyse.frist.datum) daten.push(analyse.frist.datum);
if (analyse.termin && analyse.termin.datum) daten.push(analyse.termin.datum);
if (!daten.length) return null;
daten.sort();
var tage = Math.ceil((new Date(daten[0] + 'T12:00:00') - new Date()) / 86400000);
if (isNaN(tage)) return null;
var farbe = tage <= 7 ? 'var(--rot)' : tage <= 21 ? 'var(--gelb)' : 'var(--gruen)';
var punkt = tage <= 7 ? '🔴' : tage <= 21 ? '🟡' : '🟢';
var text = tage < 0 ? 'Die Frist ist womöglich schon vorbei!' :
tage === 0 ? 'HEUTE ist der wichtige Tag!' :
'Noch ' + tage + ' Tage Zeit (' + daten[0].split('-').reverse().join('.') + ')';
return { farbe: farbe, text: punkt + ' ' + text };
}
function zeigeErgebnis(analyse, versuch) {
status.style.display = 'none';
document.getElementById('ergBrieftyp').textContent = analyse.brieftyp || 'Ihr Brief';
document.getElementById('ergKern').textContent = analyse.kernaussage || '';
document.getElementById('ergErklaerung').textContent = analyse.erklaerung_einfach || '';
var a = ampel(analyse);
var ampelEl = document.getElementById('ergAmpel');
if (a) {
ampelEl.style.display = 'block';
ampelEl.style.color = a.farbe;
var teile = [a.text];
if (analyse.frist && analyse.frist.aktion) teile.push(analyse.frist.aktion);
ampelEl.textContent = teile.join(' · ');
}
if (analyse.checkliste && analyse.checkliste.length) {
document.getElementById('ergChecklisteKarte').style.display = 'block';
document.getElementById('ergCheckliste').innerHTML = analyse.checkliste
.map(function (t) { var d = document.createElement('div'); d.className = 'todo'; d.textContent = t; return d.outerHTML; })
.join('');
}
if (analyse.fachbegriffe && analyse.fachbegriffe.length) {
document.getElementById('ergBegriffeKarte').style.display = 'block';
document.getElementById('ergBegriffe').innerHTML = analyse.fachbegriffe
.map(function (b) {
var det = document.createElement('details');
var sum = document.createElement('summary');
sum.textContent = b.begriff;
var p = document.createElement('p');
p.textContent = b.erklaerung;
det.appendChild(sum); det.appendChild(p);
return det.outerHTML;
})
.join('');
}
document.getElementById('ergebnis').style.display = 'block';
var rest = 2 - benutzt();
document.getElementById('restZeile').textContent =
rest > 0 ? 'Noch ' + rest + ' Gratis-Analyse übrig' : 'Ihre 2 Gratis-Analysen sind verbraucht';
document.getElementById('wlTitel').textContent =
rest > 0 ? 'Das hat Ihnen geholfen?' : 'Weiter geht es in der App';
document.getElementById('werbung').style.display = 'block';
if (rest > 0) {
// Upload-Bereich für die 2. Analyse wieder anbieten (mit E-Mail-Feld)
eingabe.style.display = 'block';
emailTeil.style.display = 'block';
document.getElementById('nochmalZeile').style.display = 'block';
}
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
}
zeigeStand();
})();
</script>
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "11f017e9a707466b9151921db6125dde"}'></script>
</body>
</html>

View File

@@ -322,6 +322,10 @@
<a class="cta" href="#anmelden">Jetzt anmelden&nbsp;</a> <a class="cta" href="#anmelden">Jetzt anmelden&nbsp;</a>
<span class="stempel">Bald im App&nbsp;Store</span> <span class="stempel">Bald im App&nbsp;Store</span>
</div> </div>
<p style="margin-top: 18px; font-size: 17px;">
Erst ausprobieren? <a href="demo.html" style="color: var(--amtsblau); font-weight: 700;">Testen
Sie es direkt im Browser</a> — mit Ihrem eigenen Brief, 2&nbsp;Analysen gratis.
</p>
</header> </header>
<section> <section>

View File

@@ -7,4 +7,5 @@
<url><loc>https://behoerdenklar.pages.dev/uk</loc><lastmod>2026-07-23</lastmod></url> <url><loc>https://behoerdenklar.pages.dev/uk</loc><lastmod>2026-07-23</lastmod></url>
<url><loc>https://behoerdenklar.pages.dev/ru</loc><lastmod>2026-07-23</lastmod></url> <url><loc>https://behoerdenklar.pages.dev/ru</loc><lastmod>2026-07-23</lastmod></url>
<url><loc>https://behoerdenklar.pages.dev/pl</loc><lastmod>2026-07-23</lastmod></url> <url><loc>https://behoerdenklar.pages.dev/pl</loc><lastmod>2026-07-23</lastmod></url>
<url><loc>https://behoerdenklar.pages.dev/demo</loc><lastmod>2026-07-24</lastmod></url>
</urlset> </urlset>