"""B1 wiring — Mobile Consent-Reachability check + HTML block. Fetches the homepage of the first submitted URL, runs the static `evaluate_reachability` analysis on the footer, and renders the result as an HTML block for the audit mail. Only renders a block when the check FAILS — a passing site doesn't need a block. The block is severity-colored and lists the specific notes that triggered the finding (missing reopen anchor, new-tab break, browser-deflection language). """ from __future__ import annotations import html import logging import httpx from compliance.services.consent_reachability_check import ( evaluate_reachability, ) from ._helpers import _update logger = logging.getLogger(__name__) async def run_b1(state: dict) -> None: """Run the reachability check + render HTML. Mutates state in place.""" req = state["req"] check_id = state["check_id"] homepage_url = "" for d in req.documents: if d.url: from urllib.parse import urlparse p = urlparse(d.url) if p.scheme and p.netloc: homepage_url = f"{p.scheme}://{p.netloc}/" break if not homepage_url: return _update(check_id, "Mobile Consent-Reachability prüfen...", 95) # Try the new Playwright WebKit + iPhone scan first (Task #7). # Falls back to static HTTP fetch on error. mobile = None try: from ._constants import CONSENT_TESTER_URL async with httpx.AsyncClient(timeout=60.0) as c: r = await c.post( f"{CONSENT_TESTER_URL}/scan-mobile-reachability", json={"url": homepage_url}, ) if r.status_code == 200: mobile = r.json() logger.info( "B1 Mobile-Playwright: has_anchor=%s tap=%s click_opens=%s", mobile.get("has_anchor"), mobile.get("tap_target_px"), mobile.get("click_opens_cmp"), ) except Exception as e: logger.info("B1 Mobile-Playwright fallback to static fetch: %s", e) page_html = None try: async with httpx.AsyncClient( timeout=20.0, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 " "like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) " "Version/17.5 Mobile/15E148 Safari/604.1"}, ) as c: r = await c.get(homepage_url) if r.status_code == 200: page_html = r.text except Exception as e: logger.warning("B1: homepage fetch failed: %s", e) if not page_html and not mobile: return finding = evaluate_reachability(page_html or "", homepage_url) # Enrich finding with mobile-playwright details when available if mobile and mobile.get("has_anchor"): finding["mobile_playwright"] = { "has_anchor": mobile.get("has_anchor"), "anchor_text": mobile.get("anchor_text"), "tap_target_px": mobile.get("tap_target_px"), "click_opens_cmp": mobile.get("click_opens_cmp"), "engine_meta": mobile.get("engine_meta"), } # Tap-target rule (Apple HIG / WCAG 2.5.5): ≥ 44 px each side tp = mobile.get("tap_target_px") or {} if tp and (tp.get("w", 0) < 44 or tp.get("h", 0) < 44): finding["notes"] = (finding.get("notes") or []) + [ f"tap-target nur {tp.get('w')}×{tp.get('h')}px " "(Apple HIG / WCAG verlangen ≥ 44×44)", ] if finding.get("passed"): finding["passed"] = False finding["severity"] = "MEDIUM" finding["severity_reason"] = "misclassified" # If anchor exists in DOM but click doesn't open CMP, bump severity if mobile.get("has_anchor") and not mobile.get("click_opens_cmp"): finding["notes"] = (finding.get("notes") or []) + [ "click auf Footer-Link öffnet CMP nicht direkt", ] if finding.get("severity_reason") != "factually_wrong": finding["severity"] = "MEDIUM" finding["severity_reason"] = "misclassified" finding["passed"] = False state["reachability_finding"] = finding state["reachability_html"] = _render_block(finding) logger.info( "B1 Reachability: passed=%s severity=%s reason=%s mobile=%s", finding["passed"], finding.get("severity"), finding.get("severity_reason"), bool(mobile), ) def _render_block(finding: dict) -> str: """Render the reachability finding as an audit-mail HTML block.""" if finding["passed"]: return "" sev = (finding.get("severity") or "").upper() color = "#dc2626" if sev == "HIGH" else "#f59e0b" notes_html = "".join( f"
"
"Gefundener Footer-Link: "
f"{html.escape((anchor.get('text') or '')[:80])} "
f"→ {html.escape((anchor.get('href') or '')[:120])} "
f"(target_class: {html.escape(anchor.get('target_class') or '—')})"
"
Severity: " f"{sev} ({html.escape(finding.get('severity_reason') or '')})
" "" "Art. 7 Abs. 3 DSGVO: Widerruf muss so einfach wie Erteilung sein. " "Auf Mobile-Safari konnten wir folgendes Problem feststellen:
" f"