8336c01c5c
Phase 6: PDF export via WeasyPrint — POST /agent/scans/pdf generates printable compliance report with findings table, service comparison, risk badge, and legal disclaimer. Phase 7: Recurring scans — POST /agent/monitored-urls to add URLs, POST /agent/run-scheduled triggers all enabled scans (cron/ZeroClaw). In-memory storage with DB upgrade path. Phase 8: Multi-website compare — POST /agent/compare with 2-5 URLs, parallel scanning, comparison table (risk, findings, services, compliance features per site). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
96 lines
4.8 KiB
Python
96 lines
4.8 KiB
Python
"""
|
|
Agent PDF Export — generates printable compliance scan reports.
|
|
|
|
Uses WeasyPrint to convert HTML report to PDF.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from io import BytesIO
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def generate_scan_pdf(scan_data: dict) -> bytes:
|
|
"""Generate a PDF report from scan results."""
|
|
from weasyprint import HTML
|
|
|
|
html = _build_report_html(scan_data)
|
|
pdf_buffer = BytesIO()
|
|
HTML(string=html).write_pdf(pdf_buffer)
|
|
return pdf_buffer.getvalue()
|
|
|
|
|
|
def _severity_color(sev: str) -> str:
|
|
return {"HIGH": "#dc2626", "CRITICAL": "#991b1b", "MEDIUM": "#ea580c", "LOW": "#2563eb"}.get(sev, "#6b7280")
|
|
|
|
|
|
def _build_report_html(data: dict) -> str:
|
|
"""Build HTML for the PDF report."""
|
|
url = data.get("url", "")
|
|
scan_type = data.get("scan_type", "scan")
|
|
mode = data.get("analysis_mode", "post_launch")
|
|
findings = data.get("findings", [])
|
|
services = data.get("services", [])
|
|
risk = data.get("risk_level", "")
|
|
score = data.get("risk_score", 0)
|
|
pages = data.get("pages_scanned", 0)
|
|
now = datetime.now(timezone.utc).strftime("%d.%m.%Y %H:%M UTC")
|
|
|
|
mode_label = "Live-Website Pruefung" if mode == "post_launch" else "Interne Pruefung"
|
|
type_label = {"quick": "Schnellanalyse", "scan": "Website-Scan", "consent_test": "Cookie-Test"}.get(scan_type, scan_type)
|
|
|
|
findings_rows = ""
|
|
for f in findings:
|
|
sev = f.get("severity", "MEDIUM") if isinstance(f, dict) else "MEDIUM"
|
|
text = f.get("text", str(f)) if isinstance(f, dict) else str(f)
|
|
color = _severity_color(sev)
|
|
findings_rows += f'<tr><td style="color:{color};font-weight:bold;padding:6px 8px;border-bottom:1px solid #e5e7eb;">{sev}</td><td style="padding:6px 8px;border-bottom:1px solid #e5e7eb;">{text}</td></tr>'
|
|
|
|
services_rows = ""
|
|
for s in services:
|
|
if isinstance(s, dict):
|
|
status_icon = "✓" if s.get("in_dse") or s.get("status") == "ok" else "✗"
|
|
status_color = "#16a34a" if status_icon == "✓" else "#dc2626"
|
|
services_rows += f'<tr><td style="color:{status_color};font-weight:bold;padding:4px 8px;border-bottom:1px solid #f3f4f6;">{status_icon}</td><td style="padding:4px 8px;border-bottom:1px solid #f3f4f6;">{s.get("name","")}</td><td style="padding:4px 8px;border-bottom:1px solid #f3f4f6;">{s.get("country","")}</td><td style="padding:4px 8px;border-bottom:1px solid #f3f4f6;">{s.get("category","")}</td></tr>'
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8">
|
|
<style>
|
|
body {{ font-family: -apple-system, Arial, sans-serif; font-size: 11px; color: #1e293b; margin: 40px; }}
|
|
h1 {{ font-size: 20px; color: #1e1b4b; margin-bottom: 4px; }}
|
|
h2 {{ font-size: 14px; color: #334155; border-bottom: 2px solid #e2e8f0; padding-bottom: 4px; margin-top: 24px; }}
|
|
.meta {{ color: #64748b; font-size: 10px; margin-bottom: 20px; }}
|
|
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; color: white; font-size: 10px; font-weight: bold; }}
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
th {{ text-align: left; padding: 6px 8px; background: #f8fafc; border-bottom: 2px solid #e2e8f0; font-size: 10px; color: #64748b; }}
|
|
.warning {{ background: #fef2f2; border-left: 4px solid #dc2626; padding: 10px 14px; margin: 16px 0; }}
|
|
.footer {{ margin-top: 30px; padding-top: 10px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 9px; }}
|
|
</style></head><body>
|
|
|
|
<h1>Compliance Agent Report</h1>
|
|
<p class="meta">{type_label} | {mode_label} | {now}</p>
|
|
|
|
<table style="margin-bottom:20px;">
|
|
<tr><td style="padding:4px 0;color:#64748b;width:150px;">URL</td><td style="padding:4px 0;"><strong>{url}</strong></td></tr>
|
|
<tr><td style="padding:4px 0;color:#64748b;">Risikobewertung</td><td style="padding:4px 0;"><span class="badge" style="background:{_severity_color(risk) if risk else '#6b7280'}">{risk} ({score}/100)</span></td></tr>
|
|
<tr><td style="padding:4px 0;color:#64748b;">Seiten gescannt</td><td style="padding:4px 0;">{pages}</td></tr>
|
|
<tr><td style="padding:4px 0;color:#64748b;">Findings</td><td style="padding:4px 0;"><strong>{len(findings)}</strong></td></tr>
|
|
</table>
|
|
|
|
{'<div class="warning"><strong>ACHTUNG:</strong> Maengel auf einer bereits veroeffentlichten Website. Sofortige Korrektur empfohlen.</div>' if mode == "post_launch" and findings else ''}
|
|
|
|
<h2>Findings ({len(findings)})</h2>
|
|
<table>
|
|
<tr><th>Schwere</th><th>Beschreibung</th></tr>
|
|
{findings_rows if findings_rows else '<tr><td colspan="2" style="padding:8px;color:#16a34a;">Keine Findings — alles OK</td></tr>'}
|
|
</table>
|
|
|
|
{'<h2>Dienstleister-Abgleich</h2><table><tr><th>Status</th><th>Dienst</th><th>Land</th><th>Kategorie</th></tr>' + services_rows + '</table>' if services_rows else ''}
|
|
|
|
<div class="footer">
|
|
Automatisch erstellt vom BreakPilot Compliance Agent | {now}<br>
|
|
Dieses Dokument ersetzt keine Rechtsberatung.
|
|
</div>
|
|
</body></html>"""
|