heartwood every commit a ring

Replace Playwright with subprocess chromium and move Dockerfile to Alpine

22cdc829 by Isaac Bythewood · 24 days ago

Replace Playwright with subprocess chromium and move Dockerfile to Alpine

Analytics now uses the same hardened subprocess-chromium wrapper as the status
project for screenshots and PDF reports, dropping the Playwright dependency
(and its bundled glibc-built driver, which blocked the move to Alpine). The
image is now alpine:3.21 with system chromium, shedding ~40 apt packages of
Playwright browser deps and the gdal-bin relic that was never actually used
(django.contrib.gis.geoip2 is pure-Python).
modified Dockerfile
@@ -1,59 +1,30 @@FROM ubuntu:24.04ENV DEBIAN_FRONTEND=noninteractive \    LANG=C.UTF-8 \    UV_PROJECT_ENVIRONMENT=/app/.venv \    PLAYWRIGHT_BROWSERS_PATH=/ms-playwrightCOPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uvCOPY --from=oven/bun:latest /usr/local/bin/bun /usr/local/bin/bun# Use Linode's package mirror (same network as the host). Resilient to# archive.ubuntu.com / security.ubuntu.com outages.RUN sed -i \    -e 's|http://archive.ubuntu.com/ubuntu|http://mirrors.linode.com/ubuntu|g' \    -e 's|http://security.ubuntu.com/ubuntu|http://mirrors.linode.com/ubuntu|g' \    /etc/apt/sources.list.d/ubuntu.sourcesRUN apt-get update && \    apt-get install -y --no-install-recommends \    ca-certificates curl unzip gdal-bin \    python3 python3-venv \        # Playwright dependencies        gstreamer1.0-libav gstreamer1.0-plugins-bad gstreamer1.0-plugins-base \        gstreamer1.0-plugins-good libasound2t64 libatk-bridge2.0-0t64 \        libatk1.0-0t64 libatspi2.0-0t64 libatomic1 libavif16 libcairo-gobject2 \        libcairo2 libcups2t64 libdbus-1-3 libdrm2 libenchant-2-2 libepoxy0 \        libevent-2.1-7t64 libflite1 libfontconfig1 libfreetype6 \        libgdk-pixbuf-2.0-0 libgbm1 libgles2 libglib2.0-0t64 \        libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0 \        libgstreamer-plugins-base1.0-0 libgstreamer1.0-0 libgtk-3-0t64 \        libgtk-4-1 libharfbuzz-icu0 libharfbuzz0b libhyphen0 libicu74 \        libjpeg-turbo8 liblcms2-2 libmanette-0.2-0 libnspr4 libnss3 libopus0 \        libpango-1.0-0 libpangocairo-1.0-0 libpng16-16t64 libsecret-1-0 libvpx9 \        libwayland-client0 libwayland-egl1 libwayland-server0 libwebp7 \        libwebpdemux2 libwoff1 libx11-6 libx11-xcb1 libx264-164 libxcb-shm0 \        libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxdmcp6 libxext6 \        libxfixes3 libxinerama1 libxkbcommon0 libxml2 libxrandr2 libxrender1 \        libxslt1.1 libxss1 libxtst6 libxi6 libxshmfence1 xvfb \        fonts-freefont-ttf fonts-liberation fonts-noto fonts-noto-color-emoji && \    rm -rf /var/lib/apt/lists/*FROM alpine:3.21ENV LANG="C.UTF-8" \    PYTHONUNBUFFERED=1COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/COPY --from=oven/bun:alpine /usr/local/bin/bun /usr/local/bin/bunRUN apk add --update --no-cache \      python3 py3-pip \      chromium libstdc++ nss harfbuzz freetype \      font-noto font-noto-extra font-noto-emojiWORKDIR /appCOPY pyproject.toml uv.lock package.json bun.lock /app/RUN bun install --frozen-lockfile && \    uv sync --frozen && \    mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" && \    uv run playwright install chromium    uv sync --frozen --no-devCOPY . .ENV PATH="/app/.venv/bin:/app/node_modules/.bin:$PATH"RUN bun run build && \    python manage.py collectstatic --noinputRUN chown -R ubuntu:ubuntu /app && \    chown -R ubuntu:ubuntu "$PLAYWRIGHT_BROWSERS_PATH"    uv run python manage.py collectstatic --noinputUSER ubuntuRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    chown -R app:app /appUSER app:app
added analytics/chromium.py
@@ -0,0 +1,176 @@"""Headless Chromium wrapper for generating screenshots and PDFs from URLs or HTML.Shells out to the system chromium binary (no Playwright, no Selenium, no bundledbrowser). Works on Alpine, Ubuntu, and macOS as long as a chromium-family binaryis discoverable on PATH."""from __future__ import annotationsimport osimport shutilimport subprocessimport tempfilefrom contextlib import contextmanagerfrom pathlib import Pathfrom typing import Iterator, Optional, Tuplefrom django.core.files import Filefrom django.core.files.storage import default_storageDEFAULT_VIEWPORT: Tuple[int, int] = (1280, 720)DEFAULT_VIRTUAL_TIME_BUDGET_MS = 5_000DEFAULT_SUBPROCESS_TIMEOUT_S = 60BASE_FLAGS = (    "--headless=new",    "--no-sandbox",    "--no-zygote",    "--disable-gpu",    "--disable-dev-shm-usage",    "--disable-software-rasterizer",    "--disable-extensions",    "--disable-background-networking",    "--disable-crash-reporter",    "--disable-logging",    "--hide-scrollbars",)def _find_chromium() -> Optional[str]:    for binary in ("chromium", "chromium-browser", "google-chrome"):        path = shutil.which(binary)        if path:            return path    return NoneCHROMIUM_BINARY = _find_chromium()class ChromiumError(RuntimeError):    pass@contextmanagerdef _tempfile(suffix: str) -> Iterator[Path]:    fd, raw = tempfile.mkstemp(suffix=suffix, dir="/tmp")    os.close(fd)    path = Path(raw)    try:        yield path    finally:        path.unlink(missing_ok=True)@contextmanagerdef _html_tempfile(html: str) -> Iterator[str]:    fd, raw = tempfile.mkstemp(suffix=".html", dir="/tmp")    path = Path(raw)    try:        with os.fdopen(fd, "w", encoding="utf-8") as fp:            fp.write(html)        yield f"file://{path}"    finally:        path.unlink(missing_ok=True)def _run(args: list[str], timeout: int) -> None:    if not CHROMIUM_BINARY:        raise ChromiumError(            "No chromium binary found on PATH (tried: chromium, "            "chromium-browser, google-chrome)"        )    cmd = [CHROMIUM_BINARY, *BASE_FLAGS, *args]    try:        subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)    except subprocess.TimeoutExpired as exc:        raise ChromiumError(f"chromium timed out after {timeout}s") from exc    except subprocess.CalledProcessError as exc:        stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip()        raise ChromiumError(            f"chromium exited {exc.returncode}: {stderr or '(no stderr)'}"        ) from excdef _save(source: Path, filename: str) -> str:    if default_storage.exists(filename):        default_storage.delete(filename)    with source.open("rb") as fp:        default_storage.save(filename, File(fp))    return default_storage.url(filename)def generate_screenshot_from_url(    url: str,    filename: str,    *,    viewport: Tuple[int, int] = DEFAULT_VIEWPORT,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _tempfile(".png") as out:        _run(            [                f"--screenshot={out}",                f"--window-size={viewport[0]},{viewport[1]}",                f"--virtual-time-budget={virtual_time_budget_ms}",                url,            ],            timeout,        )        return _save(out, filename)def generate_screenshot_from_html(    html: str,    filename: str,    *,    viewport: Tuple[int, int] = DEFAULT_VIEWPORT,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _html_tempfile(html) as url:        return generate_screenshot_from_url(            url,            filename,            viewport=viewport,            virtual_time_budget_ms=virtual_time_budget_ms,            timeout=timeout,        )def generate_pdf_from_url(    url: str,    filename: str,    *,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _tempfile(".pdf") as out:        _run(            [                f"--print-to-pdf={out}",                "--no-pdf-header-footer",                f"--virtual-time-budget={virtual_time_budget_ms}",                url,            ],            timeout,        )        return _save(out, filename)def generate_pdf_from_html(    html: str,    filename: str,    *,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _html_tempfile(html) as url:        return generate_pdf_from_url(            url,            filename,            virtual_time_budget_ms=virtual_time_budget_ms,            timeout=timeout,        )
deleted analytics/playwright.py
@@ -1,170 +0,0 @@"""Utilities for generating screenshots and PDFs using Playwright."""from __future__ import annotationsimport osimport tempfilefrom contextlib import contextmanagerfrom typing import Generator, Iterable, Optionalfrom django.core.files import Filefrom django.core.files.storage import default_storagefrom playwright.sync_api import Page, sync_playwrightDEFAULT_VIEWPORT = {"width": 1280, "height": 720}DEFAULT_LAUNCH_ARGS = (    "--no-sandbox",    "--disable-dev-shm-usage",    "--disable-gpu",    "--disable-setuid-sandbox",    "--disable-software-rasterizer",    "--disable-extensions",    "--disable-background-networking",)def _env_flag(name: str, default: bool = True) -> bool:    value = os.environ.get(name)    if value is None:        return default    return value.strip().lower() not in {"0", "false", "no"}def _env_int(name: str, default: int) -> int:    value = os.environ.get(name)    if value is None:        return default    try:        return int(value)    except ValueError:        return defaultdef _env_float(name: str, default: float) -> float:    value = os.environ.get(name)    if value is None:        return default    try:        return float(value)    except ValueError:        return defaultdef _collect_launch_args() -> Iterable[str]:    extra = os.environ.get("PLAYWRIGHT_LAUNCH_ARGS", "")    return list(DEFAULT_LAUNCH_ARGS) + [arg for arg in extra.split() if arg]@contextmanagerdef _page_context(viewport: Optional[dict] = None) -> Generator[Page, None, None]:    viewport = viewport or DEFAULT_VIEWPORT    navigation_timeout = _env_int("PLAYWRIGHT_NAVIGATION_TIMEOUT", 45_000)    action_timeout = _env_int("PLAYWRIGHT_ACTION_TIMEOUT", navigation_timeout)    launch_kwargs = {        "headless": _env_flag("PLAYWRIGHT_HEADLESS", True),        "args": list(_collect_launch_args()),    }    executable = os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE")    if executable:        launch_kwargs["executable_path"] = executable    channel = os.environ.get("PLAYWRIGHT_CHROMIUM_CHANNEL")    if channel:        launch_kwargs["channel"] = channel    with sync_playwright() as playwright:        browser = playwright.chromium.launch(**launch_kwargs)        context = browser.new_context(            viewport=viewport,            device_scale_factor=_env_float("PLAYWRIGHT_DEVICE_SCALE_FACTOR", 1.0),        )        page = context.new_page()        page.set_default_navigation_timeout(navigation_timeout)        page.set_default_timeout(action_timeout)        try:            yield page        finally:            context.close()            browser.close()def _temporary_file(suffix: str) -> str:    handle, path = tempfile.mkstemp(suffix=suffix)    os.close(handle)    return pathdef _save_and_cleanup(tempfilename: str, filename: str) -> str:    if default_storage.exists(filename):        default_storage.delete(filename)    with open(tempfilename, "rb") as file_pointer:        default_storage.save(filename, File(file_pointer))    os.remove(tempfilename)    return default_storage.url(filename)def save_tempfile_to_storage(tempfilename: str, filename: str) -> str:    """    Backwards compatibility wrapper for the old chromium module API.    """    return _save_and_cleanup(tempfilename, filename)def _load_url(page: Page, url: str) -> None:    page.goto(url, wait_until="networkidle")def _load_html(page: Page, html: str) -> None:    temp_html_path = _temporary_file(".html")    try:        with open(temp_html_path, "w", encoding="utf-8") as temp_file:            temp_file.write(html)        page.goto(f"file://{temp_html_path}", wait_until="networkidle")    finally:        if os.path.exists(temp_html_path):            os.remove(temp_html_path)def generate_screenshot_from_url(url: str, filename: str) -> str:    tempfilename = _temporary_file(".png")    with _page_context() as page:        _load_url(page, url)        page.screenshot(path=tempfilename, full_page=True)    return _save_and_cleanup(tempfilename, filename)def generate_screenshot_from_html(html: str, filename: str) -> str:    tempfilename = _temporary_file(".png")    with _page_context() as page:        _load_html(page, html)        page.screenshot(path=tempfilename, full_page=True)    return _save_and_cleanup(tempfilename, filename)def generate_pdf_from_url(url: str, filename: str) -> str:    tempfilename = _temporary_file(".pdf")    with _page_context() as page:        _load_url(page, url)        page.pdf(            path=tempfilename,            format=os.environ.get("PLAYWRIGHT_PDF_FORMAT", "A4"),            print_background=_env_flag("PLAYWRIGHT_PDF_PRINT_BACKGROUND", True),            prefer_css_page_size=_env_flag("PLAYWRIGHT_PDF_PREFER_CSS_PAGE_SIZE", True),        )    return _save_and_cleanup(tempfilename, filename)def generate_pdf_from_html(html: str, filename: str) -> str:    tempfilename = _temporary_file(".pdf")    with _page_context() as page:        _load_html(page, html)        page.pdf(            path=tempfilename,            format=os.environ.get("PLAYWRIGHT_PDF_FORMAT", "A4"),            print_background=_env_flag("PLAYWRIGHT_PDF_PRINT_BACKGROUND", True),            prefer_css_page_size=_env_flag("PLAYWRIGHT_PDF_PREFER_CSS_PAGE_SIZE", True),        )    return _save_and_cleanup(tempfilename, filename)
modified pages/templatetags/social.py
@@ -10,7 +10,7 @@ from django.core.files.storage import default_storagefrom django.utils.html import format_htmlfrom django.utils import timezonefrom analytics.playwright import generate_screenshot_from_urlfrom analytics.chromium import generate_screenshot_from_urlregister = template.Library()
modified properties/views.py
@@ -10,7 +10,7 @@ from django.shortcuts import redirect, renderfrom django.template.loader import render_to_stringfrom django.utils import timezonefrom analytics.playwright import generate_pdf_from_htmlfrom analytics.chromium import generate_pdf_from_htmlfrom . import queries as qfrom .forms import PropertyForm
modified pyproject.toml
@@ -12,5 +12,4 @@ dependencies = [    "uvicorn",    "whitenoise",    "geoip2",    "playwright",]
modified uv.lock
@@ -118,7 +118,6 @@ dependencies = [    { name = "dnspython" },    { name = "geoip2" },    { name = "gunicorn" },    { name = "playwright" },    { name = "requests" },    { name = "tzdata" },    { name = "user-agents" },
@@ -132,7 +131,6 @@ requires-dist = [    { name = "dnspython" },    { name = "geoip2" },    { name = "gunicorn" },    { name = "playwright" },    { name = "requests" },    { name = "tzdata" },    { name = "user-agents" },
@@ -387,53 +385,6 @@ wheels = [    { url = "https://files.pythonhosted.org/packages/dd/d2/d55df737199a52b9d06e742ed2a608c525f0677e40375951372e65714fbd/geoip2-5.2.0-py3-none-any.whl", hash = "sha256:3d1546fd4eb7cad20445d027d2d9e81d3a71c074e019383f30db5d45e2c23320", size = 28991, upload-time = "2025-11-20T18:21:07.178Z" },][[package]]name = "greenlet"version = "3.4.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },    { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },    { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },    { url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" },    { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },    { url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" },    { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },    { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },    { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },    { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },    { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },    { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },    { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },    { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" },    { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },    { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" },    { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },    { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },    { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },    { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },    { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },    { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },    { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },    { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },    { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },    { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },    { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },    { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },    { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },    { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },    { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },    { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },    { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },    { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },    { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },    { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },    { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },    { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },    { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },][[package]]name = "gunicorn"version = "25.3.0"
@@ -628,25 +579,6 @@ wheels = [    { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },][[package]]name = "playwright"version = "1.58.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "greenlet" },    { name = "pyee" },]wheels = [    { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },    { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },    { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },    { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },    { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },    { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },    { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },    { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },][[package]]name = "propcache"version = "0.4.1"
@@ -731,18 +663,6 @@ wheels = [    { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },][[package]]name = "pyee"version = "13.0.1"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "typing-extensions" },]sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },][[package]]name = "requests"version = "2.33.1"