repos
/ analytics-django master

analytics-django

mirror archived upstream

Self-hostable website analytics on Django: a straightforward collector API, dashboards, a world map, and PDF reports.

analyticsdjangodockerhandcodedpythonself-hostedsqliteviteweb-analytics

4.7 KB · 175 lines · Python Raw History
  1"""
  2Headless Chromium wrapper for generating screenshots and PDFs from URLs or HTML.
  3
  4Shells out to the system chromium binary (no Playwright, no Selenium, no bundled
  5browser). Works on Alpine, Ubuntu, and macOS as long as a chromium-family binary
  6is discoverable on PATH.
  7"""
  8from __future__ import annotations
  9
 10import os
 11import shutil
 12import subprocess
 13import tempfile
 14from contextlib import contextmanager
 15from pathlib import Path
 16from typing import Iterator, Optional, Tuple
 17
 18from django.core.files import File
 19from django.core.files.storage import default_storage
 20
 21DEFAULT_VIEWPORT: Tuple[int, int] = (1280, 720)
 22DEFAULT_VIRTUAL_TIME_BUDGET_MS = 5_000
 23DEFAULT_SUBPROCESS_TIMEOUT_S = 60
 24
 25BASE_FLAGS = (
 26    "--headless=new",
 27    "--no-sandbox",
 28    "--no-zygote",
 29    "--disable-gpu",
 30    "--disable-dev-shm-usage",
 31    "--disable-software-rasterizer",
 32    "--disable-extensions",
 33    "--disable-background-networking",
 34    "--disable-crash-reporter",
 35    "--disable-logging",
 36    "--hide-scrollbars",
 37)
 38
 39
 40def _find_chromium() -> Optional[str]:
 41    for binary in ("chromium", "chromium-browser", "google-chrome"):
 42        path = shutil.which(binary)
 43        if path:
 44            return path
 45    return None
 46
 47
 48class ChromiumError(RuntimeError):
 49    pass
 50
 51
 52@contextmanager
 53def _tempfile(suffix: str) -> Iterator[Path]:
 54    fd, raw = tempfile.mkstemp(suffix=suffix, dir="/tmp")
 55    os.close(fd)
 56    path = Path(raw)
 57    try:
 58        yield path
 59    finally:
 60        path.unlink(missing_ok=True)
 61
 62
 63@contextmanager
 64def _html_tempfile(html: str) -> Iterator[str]:
 65    fd, raw = tempfile.mkstemp(suffix=".html", dir="/tmp")
 66    path = Path(raw)
 67    try:
 68        with os.fdopen(fd, "w", encoding="utf-8") as fp:
 69            fp.write(html)
 70        yield f"file://{path}"
 71    finally:
 72        path.unlink(missing_ok=True)
 73
 74
 75def _run(args: list[str], timeout: int) -> None:
 76    binary = _find_chromium()
 77    if not binary:
 78        raise ChromiumError(
 79            "No chromium binary found on PATH (tried: chromium, "
 80            "chromium-browser, google-chrome)"
 81        )
 82    cmd = [binary, *BASE_FLAGS, *args]
 83    try:
 84        subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)
 85    except subprocess.TimeoutExpired as exc:
 86        raise ChromiumError(f"chromium timed out after {timeout}s") from exc
 87    except subprocess.CalledProcessError as exc:
 88        stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
 89        raise ChromiumError(
 90            f"chromium exited {exc.returncode}: {stderr or '(no stderr)'}"
 91        ) from exc
 92
 93
 94def _save(source: Path, filename: str) -> str:
 95    if default_storage.exists(filename):
 96        default_storage.delete(filename)
 97    with source.open("rb") as fp:
 98        default_storage.save(filename, File(fp))
 99    return default_storage.url(filename)
100
101
102def generate_screenshot_from_url(
103    url: str,
104    filename: str,
105    *,
106    viewport: Tuple[int, int] = DEFAULT_VIEWPORT,
107    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,
108    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,
109) -> str:
110    with _tempfile(".png") as out:
111        _run(
112            [
113                f"--screenshot={out}",
114                f"--window-size={viewport[0]},{viewport[1]}",
115                f"--virtual-time-budget={virtual_time_budget_ms}",
116                url,
117            ],
118            timeout,
119        )
120        return _save(out, filename)
121
122
123def generate_screenshot_from_html(
124    html: str,
125    filename: str,
126    *,
127    viewport: Tuple[int, int] = DEFAULT_VIEWPORT,
128    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,
129    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,
130) -> str:
131    with _html_tempfile(html) as url:
132        return generate_screenshot_from_url(
133            url,
134            filename,
135            viewport=viewport,
136            virtual_time_budget_ms=virtual_time_budget_ms,
137            timeout=timeout,
138        )
139
140
141def generate_pdf_from_url(
142    url: str,
143    filename: str,
144    *,
145    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,
146    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,
147) -> str:
148    with _tempfile(".pdf") as out:
149        _run(
150            [
151                f"--print-to-pdf={out}",
152                "--no-pdf-header-footer",
153                f"--virtual-time-budget={virtual_time_budget_ms}",
154                url,
155            ],
156            timeout,
157        )
158        return _save(out, filename)
159
160
161def generate_pdf_from_html(
162    html: str,
163    filename: str,
164    *,
165    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,
166    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,
167) -> str:
168    with _html_tempfile(html) as url:
169        return generate_pdf_from_url(
170            url,
171            filename,
172            virtual_time_budget_ms=virtual_time_budget_ms,
173            timeout=timeout,
174        )