feat: hybrid website compliance checks (§312k BGB, §5 TMG, Art. 13 DSGVO)
- Scan public website for cancellation button, imprint, privacy link, cookie consent - Generate follow-up questions when checks can't be verified without login - User answers "no" → finding with legal basis is added to results - Frontend: FollowUpQuestions component with Ja/Nein buttons - Sidebar: "Compliance Agent" entry added under KI-Compliance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { FollowUpQuestion } from '../_hooks/useAgentAnalysis'
|
||||||
|
|
||||||
|
const SEVERITY_STYLE: Record<string, { border: string; bg: string; icon: string }> = {
|
||||||
|
high: { border: 'border-red-300', bg: 'bg-red-50', icon: '!!' },
|
||||||
|
medium: { border: 'border-yellow-300', bg: 'bg-yellow-50', icon: '!' },
|
||||||
|
low: { border: 'border-blue-300', bg: 'bg-blue-50', icon: 'i' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
questions: FollowUpQuestion[]
|
||||||
|
answers: Record<string, boolean>
|
||||||
|
onAnswer: (questionId: string, answer: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpQuestions({ questions, answers, onAnswer }: Props) {
|
||||||
|
const unanswered = questions.filter(q => answers[q.id] === undefined)
|
||||||
|
const answered = questions.filter(q => answers[q.id] !== undefined)
|
||||||
|
|
||||||
|
if (questions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Rueckfragen zur manuellen Pruefung ({unanswered.length} offen)
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Unanswered questions */}
|
||||||
|
{unanswered.map(q => {
|
||||||
|
const style = SEVERITY_STYLE[q.severity] || SEVERITY_STYLE.medium
|
||||||
|
return (
|
||||||
|
<div key={q.id} className={`border ${style.border} ${style.bg} rounded-lg p-4`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`mt-0.5 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||||
|
q.severity === 'high' ? 'bg-red-200 text-red-800' :
|
||||||
|
q.severity === 'medium' ? 'bg-yellow-200 text-yellow-800' :
|
||||||
|
'bg-blue-200 text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{SEVERITY_STYLE[q.severity]?.icon || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{q.question}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Rechtsgrundlage: {q.legal_basis}</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onAnswer(q.id, true)}
|
||||||
|
className="px-4 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Ja
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onAnswer(q.id, false)}
|
||||||
|
className="px-4 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Answered questions */}
|
||||||
|
{answered.map(q => {
|
||||||
|
const isYes = answers[q.id]
|
||||||
|
return (
|
||||||
|
<div key={q.id} className={`border rounded-lg p-3 ${isYes ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm ${isYes ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{isYes ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-700">{q.question}</span>
|
||||||
|
<span className={`ml-auto text-xs font-medium ${isYes ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{isYes ? 'Ja — OK' : 'Nein — Finding erstellt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isYes && (
|
||||||
|
<p className="text-xs text-red-600 mt-1 ml-6">{q.finding_if_no}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export interface FollowUpQuestion {
|
||||||
|
id: string
|
||||||
|
question: string
|
||||||
|
legal_basis: string
|
||||||
|
severity: 'high' | 'medium' | 'low'
|
||||||
|
finding_if_no: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
url: string
|
url: string
|
||||||
classification: string
|
classification: string
|
||||||
@@ -14,6 +22,8 @@ export interface AnalysisResult {
|
|||||||
summary: string
|
summary: string
|
||||||
email_status: string
|
email_status: string
|
||||||
analyzed_at: string
|
analyzed_at: string
|
||||||
|
follow_up_questions: FollowUpQuestion[]
|
||||||
|
follow_up_answers: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ESCALATION_ROLES: Record<string, string> = {
|
const ESCALATION_ROLES: Record<string, string> = {
|
||||||
@@ -23,12 +33,6 @@ const ESCALATION_ROLES: Record<string, string> = {
|
|||||||
E3: 'DSB + Rechtsabteilung',
|
E3: 'DSB + Rechtsabteilung',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SDK_HEADERS = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Tenant-ID': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
|
||||||
'X-User-ID': '00000000-0000-0000-0000-000000000001',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentAnalysis() {
|
export function useAgentAnalysis() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -41,7 +45,6 @@ export function useAgentAnalysis() {
|
|||||||
setResult(null)
|
setResult(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Fetch and classify
|
|
||||||
const fetchRes = await fetch('/api/sdk/v1/agent/analyze', {
|
const fetchRes = await fetch('/api/sdk/v1/agent/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -65,6 +68,8 @@ export function useAgentAnalysis() {
|
|||||||
summary: data.summary || '',
|
summary: data.summary || '',
|
||||||
email_status: data.email_status || 'pending',
|
email_status: data.email_status || 'pending',
|
||||||
analyzed_at: new Date().toISOString(),
|
analyzed_at: new Date().toISOString(),
|
||||||
|
follow_up_questions: data.follow_up_questions || [],
|
||||||
|
follow_up_answers: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
setResult(analysisResult)
|
setResult(analysisResult)
|
||||||
@@ -76,5 +81,26 @@ export function useAgentAnalysis() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { analyze, loading, error, result, history }
|
function answerFollowUp(questionId: string, answer: boolean) {
|
||||||
|
if (!result) return
|
||||||
|
const question = result.follow_up_questions.find(q => q.id === questionId)
|
||||||
|
const newAnswers = { ...result.follow_up_answers, [questionId]: answer }
|
||||||
|
const newFindings = [...result.findings]
|
||||||
|
|
||||||
|
// If user answered "no" → add the finding
|
||||||
|
if (!answer && question) {
|
||||||
|
newFindings.push(question.finding_if_no)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...result,
|
||||||
|
findings: newFindings,
|
||||||
|
follow_up_answers: newAnswers,
|
||||||
|
}
|
||||||
|
setResult(updated)
|
||||||
|
// Update history too
|
||||||
|
setHistory(prev => prev.map(h => h.analyzed_at === result.analyzed_at ? updated : h))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { analyze, answerFollowUp, loading, error, result, history }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import React, { useState } from 'react'
|
|||||||
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
|
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
|
||||||
import { AnalysisResult } from './_components/AnalysisResult'
|
import { AnalysisResult } from './_components/AnalysisResult'
|
||||||
import { AnalysisHistory } from './_components/AnalysisHistory'
|
import { AnalysisHistory } from './_components/AnalysisHistory'
|
||||||
|
import { FollowUpQuestions } from './_components/FollowUpQuestions'
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const { analyze, loading, error, result, history } = useAgentAnalysis()
|
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -65,8 +66,19 @@ export default function AgentPage() {
|
|||||||
|
|
||||||
{/* Result */}
|
{/* Result */}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
|
||||||
<AnalysisResult result={result} />
|
<AnalysisResult result={result} />
|
||||||
|
|
||||||
|
{/* Follow-Up Questions */}
|
||||||
|
{result.follow_up_questions.length > 0 && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<FollowUpQuestions
|
||||||
|
questions={result.follow_up_questions}
|
||||||
|
answers={result.follow_up_answers}
|
||||||
|
onAnswer={answerFollowUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
|||||||
<AdditionalModuleItem href="/sdk/ai-act" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>} label="AI Act" isActive={pathname?.startsWith('/sdk/ai-act') ?? false} collapsed={collapsed} projectId={projectId} />
|
<AdditionalModuleItem href="/sdk/ai-act" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>} label="AI Act" isActive={pathname?.startsWith('/sdk/ai-act') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment / Terminal */}
|
{/* Payment / Terminal */}
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ class AnalyzeRequest(BaseModel):
|
|||||||
recipient: str = "dsb@breakpilot.local"
|
recipient: str = "dsb@breakpilot.local"
|
||||||
|
|
||||||
|
|
||||||
|
class FollowUpQuestion(BaseModel):
|
||||||
|
id: str
|
||||||
|
question: str
|
||||||
|
legal_basis: str
|
||||||
|
severity: str # "high", "medium", "low"
|
||||||
|
finding_if_no: str # Finding text if user answers "no"
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeResponse(BaseModel):
|
class AnalyzeResponse(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
classification: str
|
classification: str
|
||||||
@@ -55,6 +63,7 @@ class AnalyzeResponse(BaseModel):
|
|||||||
summary: str
|
summary: str
|
||||||
email_status: str
|
email_status: str
|
||||||
analyzed_at: str
|
analyzed_at: str
|
||||||
|
follow_up_questions: list[FollowUpQuestion] = []
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze", response_model=AnalyzeResponse)
|
@router.post("/analyze", response_model=AnalyzeResponse)
|
||||||
@@ -62,7 +71,7 @@ async def analyze_url(req: AnalyzeRequest):
|
|||||||
"""Fetch URL, classify, assess compliance, and notify responsible role."""
|
"""Fetch URL, classify, assess compliance, and notify responsible role."""
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
# Step 1: Fetch and clean
|
# Step 1: Fetch and clean
|
||||||
text = await _fetch_and_clean(client, req.url)
|
text, raw_html = await _fetch_and_clean(client, req.url)
|
||||||
|
|
||||||
# Step 2: Classify via SDK LLM
|
# Step 2: Classify via SDK LLM
|
||||||
classification = await _classify(client, text)
|
classification = await _classify(client, text)
|
||||||
@@ -74,15 +83,23 @@ async def analyze_url(req: AnalyzeRequest):
|
|||||||
esc_level = assessment.get("escalation_level", "E0")
|
esc_level = assessment.get("escalation_level", "E0")
|
||||||
role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"])
|
role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"])
|
||||||
|
|
||||||
# Step 5: Build summary
|
# Step 5: Website compliance checks (§312k BGB etc.)
|
||||||
|
site_findings, follow_ups = await _check_website_compliance(client, req.url, raw_html)
|
||||||
|
|
||||||
|
# Step 6: Merge findings
|
||||||
findings = assessment.get("triggered_rules", [])
|
findings = assessment.get("triggered_rules", [])
|
||||||
controls = assessment.get("required_controls", [])
|
controls = assessment.get("required_controls", [])
|
||||||
# Convert for summary (use string lists, not raw dicts)
|
findings_str = _to_string_list(findings) + site_findings
|
||||||
findings_str = _to_string_list(findings)
|
|
||||||
controls_str = _to_string_list(controls)
|
controls_str = _to_string_list(controls)
|
||||||
|
|
||||||
|
# Escalate if website checks found issues
|
||||||
|
if site_findings and esc_level == "E0":
|
||||||
|
esc_level = "E1"
|
||||||
|
role = ESCALATION_ROLES["E1"]
|
||||||
|
|
||||||
summary = _build_summary(req.url, classification, assessment, role, findings_str, controls_str)
|
summary = _build_summary(req.url, classification, assessment, role, findings_str, controls_str)
|
||||||
|
|
||||||
# Step 6: Send notification
|
# Step 7: Send notification
|
||||||
email_result = send_email(
|
email_result = send_email(
|
||||||
recipient=req.recipient,
|
recipient=req.recipient,
|
||||||
subject=f"Compliance-Finding: {classification} — {req.url[:60]}",
|
subject=f"Compliance-Finding: {classification} — {req.url[:60]}",
|
||||||
@@ -96,16 +113,17 @@ async def analyze_url(req: AnalyzeRequest):
|
|||||||
risk_score=assessment.get("risk_score", 0),
|
risk_score=assessment.get("risk_score", 0),
|
||||||
escalation_level=esc_level,
|
escalation_level=esc_level,
|
||||||
responsible_role=role,
|
responsible_role=role,
|
||||||
findings=_to_string_list(findings),
|
findings=findings_str,
|
||||||
required_controls=_to_string_list(controls),
|
required_controls=controls_str,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
email_status=email_result.get("status", "failed"),
|
email_status=email_result.get("status", "failed"),
|
||||||
analyzed_at=datetime.now(timezone.utc).isoformat(),
|
analyzed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
follow_up_questions=follow_ups,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str:
|
async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> tuple[str, str]:
|
||||||
"""Fetch URL and strip HTML to plain text."""
|
"""Fetch URL. Returns (clean_text, raw_html)."""
|
||||||
resp = await client.get(url, follow_redirects=True, headers={
|
resp = await client.get(url, follow_redirects=True, headers={
|
||||||
"User-Agent": "BreakPilot-Compliance-Agent/1.0",
|
"User-Agent": "BreakPilot-Compliance-Agent/1.0",
|
||||||
})
|
})
|
||||||
@@ -115,7 +133,7 @@ async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str:
|
|||||||
clean = re.sub(r"<[^>]+>", " ", clean)
|
clean = re.sub(r"<[^>]+>", " ", clean)
|
||||||
clean = re.sub(r" ", " ", clean)
|
clean = re.sub(r" ", " ", clean)
|
||||||
clean = re.sub(r"\s+", " ", clean).strip()
|
clean = re.sub(r"\s+", " ", clean).strip()
|
||||||
return clean[:4000]
|
return clean[:4000], html
|
||||||
|
|
||||||
|
|
||||||
async def _classify(client: httpx.AsyncClient, text: str) -> str:
|
async def _classify(client: httpx.AsyncClient, text: str) -> str:
|
||||||
@@ -207,6 +225,103 @@ async def _assess(client: httpx.AsyncClient, text: str, classification: str) ->
|
|||||||
return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"}
|
return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_website_compliance(
|
||||||
|
client: httpx.AsyncClient, url: str, html: str,
|
||||||
|
) -> tuple[list[str], list[FollowUpQuestion]]:
|
||||||
|
"""Scan public website for consumer protection compliance (§312k BGB etc.)."""
|
||||||
|
findings: list[str] = []
|
||||||
|
follow_ups: list[FollowUpQuestion] = []
|
||||||
|
html_lower = html.lower()
|
||||||
|
base_domain = re.sub(r"https?://([^/]+).*", r"\1", url)
|
||||||
|
|
||||||
|
# --- §312k BGB: Kündigungsbutton ---
|
||||||
|
cancel_patterns = [
|
||||||
|
r'href="[^"]*(?:kuendig|kündig|cancel|vertrag.?beenden|abo.?beenden|mitgliedschaft.?beenden)[^"]*"',
|
||||||
|
r'(?:kündigen|kuendigen|vertrag beenden|abo beenden|mitgliedschaft kündigen)',
|
||||||
|
]
|
||||||
|
has_cancel_link = any(re.search(p, html_lower) for p in cancel_patterns)
|
||||||
|
|
||||||
|
# Also check common cancel URLs
|
||||||
|
cancel_urls_to_probe = [
|
||||||
|
f"https://{base_domain}/kuendigen",
|
||||||
|
f"https://{base_domain}/cancel",
|
||||||
|
f"https://{base_domain}/vertrag-kuendigen",
|
||||||
|
f"https://{base_domain}/abo-kuendigen",
|
||||||
|
f"https://{base_domain}/account/cancel",
|
||||||
|
]
|
||||||
|
if not has_cancel_link:
|
||||||
|
for probe_url in cancel_urls_to_probe:
|
||||||
|
try:
|
||||||
|
probe = await client.head(probe_url, follow_redirects=True, timeout=5.0)
|
||||||
|
if probe.status_code < 400:
|
||||||
|
has_cancel_link = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not has_cancel_link:
|
||||||
|
findings.append(
|
||||||
|
"[§312k BGB] Kein oeffentlich sichtbarer Kuendigungsbutton gefunden. "
|
||||||
|
"Seit 01.07.2022 muessen online geschlossene Vertraege mit max. 2 Klicks kuendbar sein."
|
||||||
|
)
|
||||||
|
follow_ups.append(FollowUpQuestion(
|
||||||
|
id="cancel_button_312k",
|
||||||
|
question="Koennen Sie nach Login im Kundenbereich innerhalb von 2 Klicks Ihren Vertrag kuendigen?",
|
||||||
|
legal_basis="§ 312k BGB (Kuendigungsbutton), Omnibus-Richtlinie (EU) 2019/2161",
|
||||||
|
severity="high",
|
||||||
|
finding_if_no=(
|
||||||
|
"[§312k BGB] VERSTOSS: Kein funktionaler Kuendigungsbutton vorhanden. "
|
||||||
|
"Der Anbieter ist verpflichtet, einen leicht auffindbaren Kuendigungsbutton "
|
||||||
|
"bereitzustellen (max. 2 Klicks). Ein Zwang zur telefonischen Kuendigung "
|
||||||
|
"oder Kuendigung per Brief ist rechtswidrig."
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- Impressumspflicht (§5 TMG / §18 MStV) ---
|
||||||
|
imprint_patterns = [
|
||||||
|
r'href="[^"]*(?:impressum|imprint|legal.?notice|about.?us/legal)[^"]*"',
|
||||||
|
r'>impressum<',
|
||||||
|
]
|
||||||
|
has_imprint = any(re.search(p, html_lower) for p in imprint_patterns)
|
||||||
|
if not has_imprint:
|
||||||
|
findings.append(
|
||||||
|
"[§5 TMG] Kein Impressum-Link auf der Seite gefunden. "
|
||||||
|
"Geschaeftsmaessige Online-Dienste muessen ein leicht erreichbares Impressum bereitstellen."
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Datenschutzerklaerung verlinkt? ---
|
||||||
|
privacy_patterns = [
|
||||||
|
r'href="[^"]*(?:datenschutz|privacy|dsgvo)[^"]*"',
|
||||||
|
r'>datenschutz<',
|
||||||
|
]
|
||||||
|
has_privacy = any(re.search(p, html_lower) for p in privacy_patterns)
|
||||||
|
if not has_privacy:
|
||||||
|
findings.append(
|
||||||
|
"[Art. 13 DSGVO] Kein Link zur Datenschutzerklaerung gefunden. "
|
||||||
|
"Nutzer muessen ueber die Verarbeitung personenbezogener Daten informiert werden."
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Cookie-Consent-Banner ---
|
||||||
|
cookie_patterns = [
|
||||||
|
r'(?:cookie.?consent|cookie.?banner|consent.?manager|didomi|cookiebot|onetrust|usercentrics)',
|
||||||
|
r'(?:gdpr|dsgvo).?(?:consent|einwilligung)',
|
||||||
|
]
|
||||||
|
has_cookie_consent = any(re.search(p, html_lower) for p in cookie_patterns)
|
||||||
|
if not has_cookie_consent:
|
||||||
|
follow_ups.append(FollowUpQuestion(
|
||||||
|
id="cookie_consent",
|
||||||
|
question="Wird beim ersten Besuch der Website ein Cookie-Consent-Banner angezeigt?",
|
||||||
|
legal_basis="§ 25 TDDDG (ehem. TTDSG), Art. 5(3) ePrivacy-Richtlinie",
|
||||||
|
severity="medium",
|
||||||
|
finding_if_no=(
|
||||||
|
"[§25 TDDDG] Kein Cookie-Consent-Banner erkannt. "
|
||||||
|
"Vor dem Setzen nicht-essentieller Cookies ist eine Einwilligung erforderlich."
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
return findings, follow_ups
|
||||||
|
|
||||||
|
|
||||||
def _to_string_list(items: list) -> list[str]:
|
def _to_string_list(items: list) -> list[str]:
|
||||||
"""Convert list of dicts or strings to list of strings."""
|
"""Convert list of dicts or strings to list of strings."""
|
||||||
result = []
|
result = []
|
||||||
|
|||||||
Reference in New Issue
Block a user