"""B1 Mobile Reachability — echter Playwright-Scan auf iPhone-Emulation. Ersetzt den statischen HTTP-Fetch im Backend-B1-Wiring durch eine echte WebKit-Browser-Session mit `devices['iPhone 15']`-Preset. Misst: - hat Footer einen Reopen-Anchor (Text/aria-label/onclick)? - Tap-Target-Größe (boundingBox in px) — Apple HIG 44pt = ≥44 px - Click-Behavior: öffnet sich der CMP direkt? (DOM-Mutation + Modal-Detection nach 2s) Output schema (für Backend-B1 ersetzbar mit statischer Logik): { "url": str, "has_anchor": bool, "anchor_text": str, "tap_target_px": {"w": int, "h": int} | None, "click_opens_cmp": bool, "modal_selector": str | None, "screenshot_b64": str (initial Footer-Crop), "engine_meta": {"engine": "webkit", "device": "iPhone 15", "user_agent": str, "viewport": str}, } """ from __future__ import annotations import base64 import logging from typing import Any logger = logging.getLogger(__name__) # Phrasen für Footer-Anchor-Suche (mirror des Backend-Service) _REOPEN_PHRASES = ( "cookie-einstellungen", "cookie einstellungen", "cookie-präferenzen", "cookie-praeferenzen", "cookie-einwilligung", "einwilligung verwalten", "consent manager", "consent settings", "consent-einstellungen", "datenschutz-einstellungen", "datenschutzeinstellungen", "cookies verwalten", "manage cookies", "manage preferences", "privacy settings", "privacy preferences", "tracking-einstellungen", ) async def scan_mobile_reachability(url: str) -> dict[str, Any]: """Run Mobile-Safari emulation + footer reachability check.""" try: from playwright.async_api import async_playwright except Exception as e: logger.warning("playwright not available: %s", e) return {"url": url, "error": "playwright missing"} async with async_playwright() as p: device_preset = p.devices.get("iPhone 15") or {} browser = await p.webkit.launch(headless=True) try: context = await browser.new_context( **device_preset, locale="de-DE", timezone_id="Europe/Berlin", ) page = await context.new_page() try: await page.goto(url, wait_until="domcontentloaded", timeout=30000) except Exception as e: return {"url": url, "error": f"goto failed: {e}"[:200]} try: await page.wait_for_timeout(1500) except Exception: pass ua = await page.evaluate("() => navigator.userAgent") viewport = page.viewport_size or {} engine_meta = { "engine": "webkit", "device": "iPhone 15", "user_agent": ua, "viewport": f"{viewport.get('width','?')}x{viewport.get('height','?')}", } # Find footer reopen anchor by text matching anchor_loc = None for phrase in _REOPEN_PHRASES: try: candidate = page.locator( f"footer >> text=/{phrase}/i" ).first if await candidate.count() > 0: anchor_loc = candidate anchor_text = phrase break except Exception: continue result: dict[str, Any] = { "url": url, "has_anchor": False, "anchor_text": "", "tap_target_px": None, "click_opens_cmp": False, "modal_selector": None, "engine_meta": engine_meta, } if anchor_loc is None: # Capture footer crop try: footer = page.locator("footer").first if await footer.count() > 0: png = await footer.screenshot() result["screenshot_b64"] = base64.b64encode( png, ).decode("ascii")[:120000] except Exception: pass return result result["has_anchor"] = True result["anchor_text"] = anchor_text try: box = await anchor_loc.bounding_box() if box: result["tap_target_px"] = { "w": int(box["width"]), "h": int(box["height"]), } except Exception: pass # DOM-Modal-Snapshot vorher try: before_modals = await page.evaluate( "() => Array.from(document.querySelectorAll(" "'[role=dialog],[aria-modal=true],.cmp-modal," ".ot-sdk-container,#usercentrics-cmp')).length" ) except Exception: before_modals = 0 # Klick + warten try: await anchor_loc.click(timeout=5000) await page.wait_for_timeout(2000) after_modals = await page.evaluate( "() => Array.from(document.querySelectorAll(" "'[role=dialog],[aria-modal=true],.cmp-modal," ".ot-sdk-container,#usercentrics-cmp')).length" ) if after_modals > before_modals: result["click_opens_cmp"] = True result["modal_selector"] = ( "[role=dialog] | [aria-modal=true] | cmp-modal" ) except Exception as e: logger.info("anchor click skipped: %s", e) return result finally: await browser.close()