"""Minimal Chrome DevTools Protocol client for the local kiosk browser. Chromium listens on 127.0.0.1 only (``--remote-debugging-address``); this client refuses any other host. Each call opens a short-lived WebSocket to the page target, which keeps the client stateless and robust against browser restarts. """ from __future__ import annotations import base64 import itertools import json import urllib.request from typing import Any DEFAULT_PORT = 9222 _LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"} class CDPError(Exception): pass class CDP: def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT, timeout: float = 5.0): if host not in _LOCAL_HOSTS: raise ValueError("DevTools is alleen lokaal bereikbaar") self.host = host self.port = port self.timeout = timeout self._ids = itertools.count(1) # -- HTTP endpoints ------------------------------------------------- def _http_json(self, path: str, method: str = "GET") -> Any: req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", method=method) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: return json.loads(resp.read().decode("utf-8")) except (OSError, ValueError) as exc: raise CDPError(f"DevTools niet bereikbaar: {exc}") from exc def version(self) -> dict[str, Any]: return self._http_json("/json/version") def pages(self) -> list[dict[str, Any]]: return [t for t in self._http_json("/json/list") if t.get("type") == "page"] def main_page(self) -> dict[str, Any] | None: pages = self.pages() return pages[0] if pages else None def new_page(self, url: str) -> dict[str, Any]: from urllib.parse import quote return self._http_json("/json/new?" + quote(url, safe=""), method="PUT") # -- WebSocket commands --------------------------------------------- def call(self, ws_url: str, method: str, params: dict[str, Any] | None = None, timeout: float | None = None) -> dict[str, Any]: try: import websocket # websocket-client (python3-websocket) except ImportError as exc: # pragma: no cover - dependency missing raise CDPError("python3-websocket is niet geïnstalleerd") from exc msg_id = next(self._ids) try: # Chromium rejects DevTools WebSockets that carry a foreign Origin; # we send none at all. ws = websocket.create_connection(ws_url, timeout=timeout or self.timeout, suppress_origin=True) except Exception as exc: raise CDPError(f"Verbinding met DevTools mislukt: {exc}") from exc try: ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}})) while True: reply = json.loads(ws.recv()) if reply.get("id") == msg_id: break except Exception as exc: raise CDPError(f"DevTools-opdracht {method} mislukt: {exc}") from exc finally: try: ws.close() except Exception: pass if "error" in reply: raise CDPError(f"{method}: {reply['error'].get('message', reply['error'])}") return reply.get("result", {}) def _page_ws(self) -> str: page = self.main_page() if not page or not page.get("webSocketDebuggerUrl"): raise CDPError("Geen browserpagina gevonden") return page["webSocketDebuggerUrl"] def current_url(self) -> str | None: page = self.main_page() return page.get("url") if page else None def navigate(self, url: str) -> None: self.call(self._page_ws(), "Page.navigate", {"url": url}) def reload(self, ignore_cache: bool = False) -> None: self.call(self._page_ws(), "Page.reload", {"ignoreCache": ignore_cache}) def responsive(self, ws_url: str | None = None, timeout: float = 5.0) -> bool: """True when the page's renderer still executes JavaScript.""" try: result = self.call(ws_url or self._page_ws(), "Runtime.evaluate", {"expression": "1+1", "returnByValue": True}, timeout=timeout) except CDPError: return False return result.get("result", {}).get("value") == 2 def screenshot(self, quality: int = 70) -> bytes: result = self.call(self._page_ws(), "Page.captureScreenshot", {"format": "jpeg", "quality": quality}, timeout=15) try: return base64.b64decode(result["data"]) except (KeyError, ValueError) as exc: raise CDPError("Ongeldige screenshot ontvangen") from exc def close_browser(self) -> None: ws_url = self.version().get("webSocketDebuggerUrl") if not ws_url: raise CDPError("Geen browser-endpoint gevonden") self.call(ws_url, "Browser.close")