heartwood every commit a ring

Port Status from Django to a single-binary axum service

69207631 by Isaac Bythewood · 2 days ago

Port Status from Django to a single-binary axum service

Mirrors the analytics/blog/darkfurrow rust port: tiny main.rs entry,
src/app.rs builds AppState + Router, per-feature route modules under
src/routes/ each exposing fn router(), shared helpers in render.rs +
middleware.rs + templates.rs. PDF reports render in-process via embedded
Typst (typst + typst-pdf + typst-kit 0.14) using the same
minijinja-renders-typst pattern as analytics, with monochrome styling
and hairline-rule tables. Lighthouse still subprocesses to chromium for
its own audits and gains a /opt/playwright-browsers/ glob fallback.
Email alerts ported back to the warm-earth themed dark card; a new
preview-email subcommand renders both flavors to stdout for visual QA.
status migrate <django.sqlite3> imports the old DB preserving Property
UUIDs (smoke-tested against the production 217 MB Django DB: 47
properties + 67,634 checks come over clean). Django scaffolding
(accounts/, properties/, pages/, status/, crawler/, manage.py,
pyproject.toml, uv.lock, .venv) deleted.
modified .gitignore
@@ -1,11 +1,10 @@__pycache__//target//dist//data//node_modules//frontend/node_modules/.env.venvdb.sqlite3db.sqlite3-shmdb.sqlite3-waldb.sqlite3-journalmedianode_modulesstaticcrawler_output
modified CLAUDE.md
@@ -4,73 +4,112 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co## What This IsStatus is a self-hosted uptime monitoring and status page service. It monitors websites via HTTP checks (every 3 minutes), runs Lighthouse audits (daily), and crawls sites for SEO analysis (weekly). Alerts are sent via email and Discord webhooks on state transitions.Self-hosted uptime monitoring with status pages. HTTP checks every 3 minutes, Lighthouse audits daily, SEO crawler weekly. Alerts via direct-MX email + Discord webhooks on state transitions. Single-binary axum service backed by SQLite. Originally a Django + thread-pool scheduler service; data from that era can be migrated in via `./status migrate <django.sqlite3>` and the Django code has been retired.## Development Commands## Commands```bashmake                  # Install deps + run Django dev server + Vite watch (parallel)make scheduler        # Run the monitoring scheduler (separate terminal)make clean            # Remove node_modules, .venv, db, mediamake pull             # Rsync production database and media locallymake push             # Push to all git remotes```- **Dev server:** `make run` (Vite watch + cargo run on port 8000, plus background scheduler)- **Production build:** `make build` (Vite assets + release binary)- **Run release binary:** `make start`- **Pull production data:** `make pull` (rsync `db.sqlite3` from `git remote server`)- **Import a Django DB:** `make migrate FROM=<path-to-django.sqlite3> [FORCE=1]` (preserves Property UUIDs so existing public status URLs keep working). Same logic exposed on the binary as `./status migrate <path> [--force]`.- **Preview an alert email:** `./status preview-email <down|recovery>` renders the themed email HTML to stdout for visual QA.- **Docker build:** `sudo docker build .`Dependencies: Python (uv), JS (bun). Node is installed only so the `lighthouse` npm CLI can run — Bun doesn't execute Lighthouse correctly (bun issue #4958). Default dev credentials: admin/admin.There are no tests or linters configured.### Linting (no test runner)## Architecture```bashuv run black .uv run isort .uv run flake8```**Backend:** Single-binary axum app. `src/main.rs` is a tiny entry point; `src/app.rs` builds `AppState` and the `Router`. Per-feature route modules live in `src/routes/{home,auth,seo,properties,dashboard}.rs` and each exposes its own `Router::new()`. Shared helpers: `src/render.rs` (template render that injects user/request/messages context), `src/middleware.rs` (request log + 404), `src/templates.rs` (minijinja env + filters). sqlx + sqlite (WAL, `synchronous=NORMAL`, `busy_timeout=5s`, foreign keys on) for both reads and writes. Schema lives in `migrations/0001_initial.sql` and is applied automatically on boot. State is shared via `AppState` (template env, db pool, cookie key, config, embedded Typst PDF renderer).Black profile: 88 char lines. isort uses `profile=black`. Flake8 ignores E203. All Python tool configs are in `pyproject.toml`.**Auth:** Single password from `STATUS_PASSWORD` in `.env`. Login form posts to `/login`; on success a signed cookie (`SameSite=Strict`, 30-day max-age) is set. The cookie payload is just `1:<exp-timestamp>`, signed with `tower-cookies`. The cookie key is `STATUS_COOKIE_SECRET` if set, else derived from the password via SHA-512 (so changing the password invalidates sessions). No CSRF tokens; `SameSite=Strict` is the protection.## Architecture**Data model:** Two main tables. `properties` holds a tracked URL plus all monitoring state (last/next run for HTTP, lighthouse, crawler; lighthouse_scores, lighthouse_details, crawler_insights as JSON; alert state machine). `checks` stores individual HTTP probe results (status code, response_ms, headers JSON). `meta` is a key-value table for one-off settings.### Django Apps**Scheduler (`src/scheduler.rs`):** Tokio task that runs every 30 seconds with two semaphores so slow lighthouse/crawler work can't starve quick HTTP pings. On boot, wedged `running`/`queued` rows are reset. Each cycle enqueues HTTP checks (3-min intervals), lighthouse (daily), crawler (weekly), runs wedge-reset (5-min lighthouse cutoff, 15-min crawler cutoff), and once a day deletes checks older than 3 days.- **accounts** - Custom User model (UUID PK) with `discord_webhook_url` field. Standard auth views.- **properties** - Core app. `Property` model tracks a URL's monitoring state; `Check` model stores individual HTTP check results. Property behavior is split into mixin classes defined alongside the models in `properties/models.py`: `SecurityMixin` (header analysis), `AlertsMixin` (state machine for up/down notifications), `CrawlerMixin` (SEO data from the in-process crawler).- **pages** - Static marketing pages (home, changelog, robots.txt, sitemap).**Alert state machine (`src/checker.rs`):** Two states, `up` (default) and `down`. UP→DOWN requires 2 consecutive non-200 checks (avoids false positives); DOWN→UP is immediate on 200. State is committed inside a transaction before notifications fire, so a crash mid-alert can't cause duplicate sends.### Scheduler (`properties/management/commands/scheduler.py`)**SEO crawler (`src/crawler/`):** In-process spider built on reqwest + scraper, modeled directly on the original Python crawler. `mod.rs` runs the BFS up to PAGE_CAP (500) with CONCURRENCY (4) and a 9-minute deadline; `fetcher.rs` handles robots.txt/sitemap loading; `parser.rs` extracts title/meta/headings/links/images/forms/json-ld/text-hash; `checks.rs` is the same 38 checks as the Python version (SEO, links, sitemap, accessibility, content, performance, security).Single management command that runs in an infinite loop (30-second cycle) using two thread pools with graceful shutdown:- **Status checks**: Properties where `next_run_at` has passed (3-min intervals). HTTP timeout is 10s. SSL errors map to status 526, timeouts to 408.- **Lighthouse + Crawler**: Daily Lighthouse via Node CLI wrapper (`status/lighthouse.py`), weekly SEO crawl via the in-process crawler (`crawler/runner.py` — `requests` + `BeautifulSoup`, no subprocess).**Lighthouse (`src/lighthouse.rs`):** Subprocess to `node_modules/.bin/lighthouse` (npm package installed at the repo root). Returns the four category scores plus a "performance details" breakdown (top weighted metrics + top opportunities by savings). 180s outer timeout against chromium hangs. Chromium is located via `CHROMIUM_BIN`, then PATH search, then a `/opt/playwright-browsers/` glob fallback (so the webdev container Just Works without per-shell env vars).Cleans up checks older than 3 days each cycle.**Alerts (`src/alerts.rs`):** Email is sent direct-to-MX: hickory-resolver looks up the recipient domain's MX records, sorted by preference, and lettre opens an opportunistic STARTTLS SMTP connection on port 25. Discord webhooks are plain HTTP POST. Both are best-effort; failures are logged and don't break the check loop. Recipient is `ALERT_EMAIL`; webhook is `DISCORD_WEBHOOK_URL`.### Alert State Machine (`AlertsMixin` in `properties/models.py`)**PDF generation (`src/pdf.rs`):** `?report=pdf` on the property page renders through the embedded Typst compiler (no chromium subprocess, no temp files). `PdfRenderer::new` runs typst-kit's font searcher once at startup and shares the resulting library/book/font slots across renders. The dashboard route renders `templates/properties/property_report.typ` through minijinja first (using the `typst_md` and `typst_str` filters in `templates.rs` to escape user data into Typst-safe markup), then passes the resulting source to `PdfRenderer::render` inside `tokio::task::spawn_blocking` (Typst compilation is CPU-bound and synchronous). The visual style mirrors `analytics/templates/properties/property_report.typ`: monochrome, hairline rules, tracking-letterspaced uppercase section headers, JetBrains Mono for technical strings. `?report=md` returns the markdown variant directly.Two states: `up` (default) and `down`. Transitions:- **UP -> DOWN**: Requires 2 consecutive non-200 checks (avoids false positives).- **DOWN -> UP**: Immediate on 200 status code.**Templates (`templates/`):** Jinja2 templates rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `&#x2f;` (matches analytics/blog/darkfurrow). Custom functions: `vite_asset`, `url_for`, `naturaltime`, `urlencode`, `intcomma`, plus `typst_md` and `typst_str` for the PDF template. The `vite_asset` global resolves hashed asset names by reading `dist/.vite/manifest.json`: re-read per call in debug builds, cached at startup in release.Alerts (email + Discord webhook) only fire on state transitions, not on every check. State is committed before notifications fire so a crash mid-alert cannot cause duplicate sends.**Frontend pipeline (`frontend/`):** Vite (run with `bun`) builds three entry points (`base`, `pages`, `properties`) into `dist/`. Output filenames are content-hashed and served at `/static/`. Bootstrap 5 SCSS, Chart.js, monaspace font.### Frontend (Vite)**Request logging:** `src/main.rs::log_requests` middleware prints `time METHOD STATUS latency path` per request with ANSI-colored status codes.Three entry points in `vite.config.js`, all output to `status/static/` with stable filenames (no hashing) so templates reference them via plain `{% static %}`:- `status/static_src/index.js` -> base Bootstrap 5 bundle- `pages/static_src/index.js` -> homepage styles- `properties/static_src/index.js` -> Chart.js graphs, D3/Datamaps, print styles## Layout`bun run dev` runs `vite build --watch`; refresh the browser manually to see changes. The `fixDatamapsStrictMode` Vite plugin patches datamaps/d3v3 for ESM-strict mode at transform time.### External Tool Wrappers```status/├── Cargo.toml, Cargo.lock        # rust deps├── package.json                  # only for the lighthouse npm CLI at repo root├── Makefile, README.md           # top-level├── migrations/                   # sqlx migrations (0001_initial.sql)├── src/                          # rust source│   ├── main.rs        # tiny entry: env init, subcommand dispatch, server boot│   ├── app.rs         # AppState::from_env + router() (assembles per-feature routes)│   ├── render.rs      # render() / render_to_string() helpers (inject standard ctx)│   ├── middleware.rs  # request log + 404 handler│   ├── routes/        # per-feature route modules, each exposing fn router()│   │   ├── mod.rs│   │   ├── auth.rs        # /login, /logout, is_authenticated()│   │   ├── home.rs        # /, /changelog│   │   ├── seo.rs         # /favicon.ico, /robots.txt, /sitemap.xml│   │   ├── properties.rs  # /properties (list/create/delete/public toggle)│   │   └── dashboard.rs   # /<id> dashboard, /<id>/{status,recrawl,rerun-lighthouse}, build_property_context│   ├── models.rs      # PropertyRow + queries (list/get/create/delete/toggle_public)│   ├── checker.rs     # HTTP probe + alert state machine│   ├── lighthouse.rs  # subprocess to node_modules/.bin/lighthouse│   ├── crawler/       # SEO spider (mod, fetcher, parser, checks)│   ├── scheduler.rs   # 30s tokio loop with fast/slow semaphores│   ├── alerts.rs      # direct-MX email via lettre + Discord webhook (+ render_preview_html)│   ├── pdf.rs         # embedded Typst renderer + json-to-typst dict serializer│   ├── migrate.rs     # `./status migrate <django.sqlite3>`│   ├── db.rs          # sqlx pool init + migrate│   └── templates.rs   # minijinja env, vite_asset, url_for, jinja2-compat formatter├── templates/                    # minijinja-compatible jinja2 + typst│   ├── base.html              # layout│   ├── property_report.typ    # PDF template (typst markup, called from src/pdf.rs)│   ├── emails/                # property_email_base.html (themed dark card)│   ├── includes/              # messages│   ├── registration/          # login form│   ├── properties/            # properties list, dashboard, md report│   └── pages/                 # home, changelog├── frontend/                     # JS pipeline (package.json, vite.config.js, static_src/)│   ├── static_src/│   │   ├── base/       # bootstrap scss + base scripts│   │   ├── pages/      # marketing styles│   │   └── properties/ # dashboard chart + crawl-status polling JS├── dist/                         # vite build output (gitignored, served at /static/)├── data/                         # sqlite db at runtime (gitignored)├── target/                       # cargo build output (gitignored)├── Dockerfile, docker-compose.yml└── samplefiles/                  # Caddyfile.sample, env.sample, post-receive.sample```- `status/lighthouse.py` - Invokes `node_modules/.bin/lighthouse` with headless Chrome flags, parses JSON scores. Surfaces failure reasons in the UI.- `status/chromium.py` - Headless Chromium for PDF report generation. Auto-detects `chromium`, `chromium-browser`, or `google-chrome` binary. Hardened with timeouts, `/tmp` temp files, and `--headless=new`.- `crawler/` - In-process SEO crawler using `requests` + `BeautifulSoup` (no Scrapy). Split across `fetcher.py`, `parser.py`, `checks.py`, and `runner.py`. Extracts title, meta description, canonical URL, OG tags, and H1 per page.The binary reads `templates/`, `dist/`, and `migrations/` from cwd by default. Override the project root with `STATUS_ROOT=<path>` and the data directory with `STATUS_DATA_DIR=<path>` (production sets the latter to `/data`).### Settings## Key RoutesSplit settings: `status/settings/__init__.py` (shared), `development.py`, `production.py`. Django picks based on `DJANGO_SETTINGS_MODULE` env var (defaults to development). Production uses env vars for SECRET_KEY and BASE_URL, and stores SQLite at `/data/db/`. SQLite settings (WAL, PRAGMA tuning) are centralized in shared settings.- `/`: marketing home (redirects to `/properties` when authenticated)- `/login`, `/logout`: single-password auth- `/properties`: list + create + delete (auth required)- `/<property-id>`: dashboard (auth required unless property is public). Accepts `?report=pdf|md`.- `/properties/<id>/public`: POST toggles is_public- `/properties/<id>/status`: JSON crawler/lighthouse status (used by polling JS)- `/properties/<id>/recrawl`, `/properties/<id>/rerun-lighthouse`: POST to advance next-run-at- `/changelog`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml`- `/static/*`: Vite assets (1y cache header)### Production## ToolingDocker Compose runs a single `web` service (Alpine 3.21 base). `entrypoint.py` spawns Gunicorn (Uvicorn workers) and the `scheduler` management command side-by-side in that container — if either process exits, the container stops and Docker restarts it. `PYTHONUNBUFFERED=1` so scheduler output reaches `docker logs`. Deploys via `git push server master` triggering a post-receive hook.- **Rust deps:** managed with `cargo` (`Cargo.toml`, `Cargo.lock`)- **JS deps:** managed with `bun` from `frontend/`. The lighthouse npm CLI is installed at the repo root with plain `npm install` so its `node_modules/.bin/lighthouse` shim resolves correctly.- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime, `chromium` + `nodejs` apk packages: chromium is needed for lighthouse only, since PDF runs through embedded Typst). Runtime image also installs `font-jetbrains-mono`, `ttf-dejavu`, `ttf-liberation`, and `fontconfig` so the Typst renderer can find a body sans, mono, and fallback fonts. Deployed via `git push server master` triggering a post-receive hook that runs `docker compose up --build --detach`. Data persisted to `/srv/data/status/`.
added Cargo.lock
@@ -0,0 +1,5933 @@# This file is automatically @generated by Cargo.# It is not intended for manual editing.version = 4[[package]]name = "adler2"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"[[package]]name = "ahash"version = "0.8.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", "version_check", "zerocopy",][[package]]name = "aho-corasick"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"dependencies = [ "memchr",][[package]]name = "alloc-no-stdlib"version = "2.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"[[package]]name = "alloc-stdlib"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"dependencies = [ "alloc-no-stdlib",][[package]]name = "allocator-api2"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"[[package]]name = "android_system_properties"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"dependencies = [ "libc",][[package]]name = "anyhow"version = "1.0.102"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"[[package]]name = "approx"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"dependencies = [ "num-traits",][[package]]name = "ar_archive_writer"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"dependencies = [ "object",][[package]]name = "arrayref"version = "0.3.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"[[package]]name = "arrayvec"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"[[package]]name = "async-compression"version = "0.4.42"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"dependencies = [ "compression-codecs", "compression-core", "pin-project-lite", "tokio",][[package]]name = "async-trait"version = "0.1.89"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "atoi"version = "2.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"dependencies = [ "num-traits",][[package]]name = "atomic-waker"version = "1.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"[[package]]name = "autocfg"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"[[package]]name = "aws-lc-rs"version = "1.16.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"dependencies = [ "aws-lc-sys", "zeroize",][[package]]name = "aws-lc-sys"version = "0.40.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"dependencies = [ "cc", "cmake", "dunce", "fs_extra",][[package]]name = "axum"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"dependencies = [ "axum-core", "axum-macros", "bytes", "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-core"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"dependencies = [ "bytes", "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", "sync_wrapper", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-macros"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "az"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7"[[package]]name = "base64"version = "0.22.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"[[package]]name = "base64ct"version = "1.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"[[package]]name = "biblatex"version = "0.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "53d0c374feba1b9a59042a7c1cf00ce7c34b977b9134fe7c42b08e5183729f66"dependencies = [ "paste", "roman-numerals-rs", "strum", "unic-langid", "unicode-normalization", "unscanny",][[package]]name = "bincode"version = "1.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"dependencies = [ "serde",][[package]]name = "bit-set"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"dependencies = [ "bit-vec",][[package]]name = "bit-vec"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"[[package]]name = "bitflags"version = "1.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"[[package]]name = "bitflags"version = "2.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"dependencies = [ "serde_core",][[package]]name = "block-buffer"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"dependencies = [ "generic-array",][[package]]name = "brotli"version = "8.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "brotli-decompressor",][[package]]name = "brotli-decompressor"version = "5.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"dependencies = [ "alloc-no-stdlib", "alloc-stdlib",][[package]]name = "bumpalo"version = "3.20.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"[[package]]name = "by_address"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"[[package]]name = "bytemuck"version = "1.25.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"dependencies = [ "bytemuck_derive",][[package]]name = "bytemuck_derive"version = "1.10.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "byteorder"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"[[package]]name = "byteorder-lite"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"[[package]]name = "bytes"version = "1.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"[[package]]name = "cc"version = "1.2.61"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex",][[package]]name = "cfg-if"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"[[package]]name = "cfg_aliases"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"[[package]]name = "chinese-number"version = "0.7.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3e964125508474a83c95eb935697abbeb446ff4e9d62c71ce880e3986d1c606b"dependencies = [ "chinese-variant", "enum-ordinalize", "num-bigint", "num-traits",][[package]]name = "chinese-variant"version = "1.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "58b52a9840ffff5d4d0058ae529fa066a75e794e3125546acfc61c23ad755e49"[[package]]name = "chrono"version = "0.4.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", "windows-link",][[package]]name = "ciborium"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"dependencies = [ "ciborium-io", "ciborium-ll", "serde",][[package]]name = "ciborium-io"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"[[package]]name = "ciborium-ll"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"dependencies = [ "ciborium-io", "half",][[package]]name = "citationberg"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1f6597e8bdbca37f1f56e5a80d15857b0932aead21a78d20de49e99e74933046"dependencies = [ "quick-xml 0.38.4", "serde",][[package]]name = "cmake"version = "0.1.58"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"dependencies = [ "cc",][[package]]name = "cobs"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"dependencies = [ "thiserror 2.0.18",][[package]]name = "codex"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9589e1effc5cacbea347899645c654158b03b2053d24bb426fd3128ced6e423c"[[package]]name = "color_quant"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"[[package]]name = "comemo"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3c963350b2b08aa4b725d7802593245380ab53dacfedcaa971385fc33306c0d4"dependencies = [ "comemo-macros", "parking_lot", "rustc-hash", "siphasher 1.0.3", "slab",][[package]]name = "comemo-macros"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a3c400139ba1389ef9e20ad2d87cda68b437a66483aa0da616bdf2cea7413853"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "compression-codecs"version = "0.4.38"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"dependencies = [ "brotli", "compression-core", "flate2", "memchr",][[package]]name = "compression-core"version = "0.4.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"[[package]]name = "concurrent-queue"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"dependencies = [ "crossbeam-utils",][[package]]name = "const-oid"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"[[package]]name = "cookie"version = "0.18.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"dependencies = [ "base64", "hmac", "percent-encoding", "rand 0.8.6", "sha2", "subtle", "time", "version_check",][[package]]name = "core-foundation-sys"version = "0.8.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"[[package]]name = "core_maths"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"dependencies = [ "libm",][[package]]name = "cpufeatures"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"dependencies = [ "libc",][[package]]name = "crc"version = "3.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"dependencies = [ "crc-catalog",][[package]]name = "crc-catalog"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"[[package]]name = "crc32fast"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"dependencies = [ "cfg-if",][[package]]name = "crossbeam-deque"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"dependencies = [ "crossbeam-epoch", "crossbeam-utils",][[package]]name = "crossbeam-epoch"version = "0.9.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-queue"version = "0.3.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-utils"version = "0.8.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"[[package]]name = "crunchy"version = "0.2.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"[[package]]name = "crypto-common"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"dependencies = [ "generic-array", "typenum",][[package]]name = "cssparser"version = "0.31.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be"dependencies = [ "cssparser-macros", "dtoa-short", "itoa", "phf 0.11.3", "smallvec",][[package]]name = "cssparser-macros"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"dependencies = [ "quote", "syn",][[package]]name = "csv"version = "1.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"dependencies = [ "csv-core", "itoa", "ryu", "serde_core",][[package]]name = "csv-core"version = "0.1.13"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"dependencies = [ "memchr",][[package]]name = "data-encoding"version = "2.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"[[package]]name = "data-url"version = "0.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"[[package]]name = "der"version = "0.7.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"dependencies = [ "const-oid", "pem-rfc7468", "zeroize",][[package]]name = "deranged"version = "0.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"dependencies = [ "powerfmt",][[package]]name = "derive_more"version = "0.99.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "digest"version = "0.10.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"dependencies = [ "block-buffer", "const-oid", "crypto-common", "subtle",][[package]]name = "displaydoc"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "dotenvy"version = "0.15.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"[[package]]name = "dtoa"version = "1.0.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"[[package]]name = "dtoa-short"version = "0.3.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"dependencies = [ "dtoa",][[package]]name = "dunce"version = "1.0.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"[[package]]name = "ecow"version = "0.2.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02"dependencies = [ "serde",][[package]]name = "ego-tree"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"[[package]]name = "either"version = "1.15.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"dependencies = [ "serde",][[package]]name = "email-encoding"version = "0.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"dependencies = [ "base64", "memchr",][[package]]name = "email_address"version = "0.2.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"[[package]]name = "embedded-io"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"[[package]]name = "embedded-io"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"[[package]]name = "enum-as-inner"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"dependencies = [ "heck", "proc-macro2", "quote", "syn",][[package]]name = "enum-ordinalize"version = "4.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0"dependencies = [ "enum-ordinalize-derive",][[package]]name = "enum-ordinalize-derive"version = "4.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "equivalent"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"[[package]]name = "errno"version = "0.3.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "etcetera"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"dependencies = [ "cfg-if", "home", "windows-sys 0.48.0",][[package]]name = "euclid"version = "0.22.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"dependencies = [ "num-traits",][[package]]name = "event-listener"version = "5.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"dependencies = [ "concurrent-queue", "parking", "pin-project-lite",][[package]]name = "fancy-regex"version = "0.16.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"dependencies = [ "bit-set", "regex-automata", "regex-syntax",][[package]]name = "fast-srgb8"version = "1.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"[[package]]name = "fastrand"version = "2.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"[[package]]name = "fdeflate"version = "0.3.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"dependencies = [ "simd-adler32",][[package]]name = "find-msvc-tools"version = "0.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"[[package]]name = "flate2"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"dependencies = [ "crc32fast", "miniz_oxide", "zlib-rs",][[package]]name = "float-cmp"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"[[package]]name = "float-cmp"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"dependencies = [ "num-traits",][[package]]name = "flume"version = "0.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"dependencies = [ "futures-core", "futures-sink", "spin",][[package]]name = "fnv"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"[[package]]name = "foldhash"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"[[package]]name = "font-types"version = "0.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"dependencies = [ "bytemuck",][[package]]name = "fontconfig-parser"version = "0.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"dependencies = [ "roxmltree",][[package]]name = "fontdb"version = "0.23.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"dependencies = [ "fontconfig-parser", "log", "memmap2", "slotmap", "tinyvec", "ttf-parser",][[package]]name = "form_urlencoded"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"dependencies = [ "percent-encoding",][[package]]name = "fs_extra"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"[[package]]name = "futf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"dependencies = [ "mac", "new_debug_unreachable",][[package]]name = "futures-channel"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"dependencies = [ "futures-core", "futures-sink",][[package]]name = "futures-core"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"[[package]]name = "futures-executor"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"dependencies = [ "futures-core", "futures-task", "futures-util",][[package]]name = "futures-intrusive"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"dependencies = [ "futures-core", "lock_api", "parking_lot",][[package]]name = "futures-io"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"[[package]]name = "futures-macro"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "futures-sink"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"[[package]]name = "futures-task"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"[[package]]name = "futures-util"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"dependencies = [ "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "slab",][[package]]name = "fxhash"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"dependencies = [ "byteorder",][[package]]name = "generic-array"version = "0.14.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"dependencies = [ "typenum", "version_check",][[package]]name = "getopts"version = "0.2.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"dependencies = [ "unicode-width",][[package]]name = "getrandom"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen",][[package]]name = "getrandom"version = "0.3.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 5.3.0", "wasip2", "wasm-bindgen",][[package]]name = "getrandom"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "wasip2", "wasip3",][[package]]name = "gif"version = "0.13.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"dependencies = [ "color_quant", "weezl",][[package]]name = "gif"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"dependencies = [ "color_quant", "weezl",][[package]]name = "glidesort"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"[[package]]name = "half"version = "2.7.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"dependencies = [ "cfg-if", "crunchy", "zerocopy",][[package]]name = "hashbrown"version = "0.15.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"dependencies = [ "allocator-api2", "equivalent", "foldhash",][[package]]name = "hashbrown"version = "0.17.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"[[package]]name = "hashlink"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"dependencies = [ "hashbrown 0.15.5",][[package]]name = "hayagriva"version = "0.9.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1cb69425736f184173b3ca6e27fcba440a61492a790c786b1c6af7e06a03e575"dependencies = [ "biblatex", "ciborium", "citationberg", "indexmap", "paste", "roman-numerals-rs", "serde", "serde_yaml", "thiserror 2.0.18", "unic-langid", "unicode-segmentation", "unscanny", "url",][[package]]name = "hayro"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "048488ba88552bb0fb2a7e4001c64d5bed65d1a92167186a1bb9151571f32e60"dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo 0.12.0",][[package]]name = "hayro-font"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "10e7e97ce840a6a70e7901e240ec65ba61106b66b37a4a1b899a2ce484248463"dependencies = [ "log", "phf 0.13.1",][[package]]name = "hayro-interpret"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "56204c972d08e844f3db13b1e14be769f846e576699b46d4f4637cc4f8f70102"dependencies = [ "bitflags 2.11.1", "hayro-font", "hayro-syntax", "kurbo 0.12.0", "log", "moxcms 0.7.11", "phf 0.13.1", "rustc-hash", "siphasher 1.0.3", "skrifa", "smallvec", "yoke 0.8.2",][[package]]name = "hayro-svg"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e8c673304cec6e0dfd3b4f71fccecd45646899aa70279b62d3f933842abc4ac5"dependencies = [ "base64", "hayro-interpret", "image", "kurbo 0.12.0", "siphasher 1.0.3", "xmlwriter",][[package]]name = "hayro-syntax"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f9e5c7dbc0f11dc42775d1a6cc00f5f5137b90b6288dd7fe5f71d17b14d10be"dependencies = [ "flate2", "kurbo 0.12.0", "log", "rustc-hash", "smallvec", "zune-jpeg 0.4.21",][[package]]name = "hayro-write"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc05d8b4bc878b9aee48d980ecb25ed08f1dd9fad6da5ab4d9b7c56ec03a0cf6"dependencies = [ "flate2", "hayro-syntax", "log", "pdf-writer",][[package]]name = "heck"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"[[package]]name = "hex"version = "0.4.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"[[package]]name = "hickory-proto"version = "0.24.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248"dependencies = [ "async-trait", "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", "futures-io", "futures-util", "idna", "ipnet", "once_cell", "rand 0.8.6", "thiserror 1.0.69", "tinyvec", "tokio", "tracing", "url",][[package]]name = "hickory-resolver"version = "0.24.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e"dependencies = [ "cfg-if", "futures-util", "hickory-proto", "ipconfig", "lru-cache", "once_cell", "parking_lot", "rand 0.8.6", "resolv-conf", "smallvec", "thiserror 1.0.69", "tokio", "tracing",][[package]]name = "hkdf"version = "0.12.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"dependencies = [ "hmac",][[package]]name = "hmac"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"dependencies = [ "digest",][[package]]name = "home"version = "0.5.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"dependencies = [ "windows-sys 0.61.2",][[package]]name = "html5ever"version = "0.27.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"dependencies = [ "log", "mac", "markup5ever", "proc-macro2", "quote", "syn",][[package]]name = "http"version = "1.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"dependencies = [ "bytes", "itoa",][[package]]name = "http-body"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"dependencies = [ "bytes", "http",][[package]]name = "http-body-util"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite",][[package]]name = "http-range-header"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"[[package]]name = "httparse"version = "1.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"[[package]]name = "httpdate"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"[[package]]name = "hyper"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", "want",][[package]]name = "hyper-rustls"version = "0.27.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"dependencies = [ "http", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", "webpki-roots",][[package]]name = "hyper-util"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"dependencies = [ "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing",][[package]]name = "hypher"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bef68590049bab63a464eee1a1158ac04c6f6613a546d8d90f78636b8b94f171"[[package]]name = "iana-time-zone"version = "0.1.65"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core",][[package]]name = "iana-time-zone-haiku"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"dependencies = [ "cc",][[package]]name = "icu_collections"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"dependencies = [ "displaydoc", "serde", "yoke 0.7.5", "zerofrom", "zerovec 0.10.4",][[package]]name = "icu_collections"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke 0.8.2", "zerofrom", "zerovec 0.11.6",][[package]]name = "icu_locale_core"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"dependencies = [ "displaydoc", "litemap 0.8.2", "tinystr 0.8.3", "writeable 0.6.3", "zerovec 0.11.6",][[package]]name = "icu_locid"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"dependencies = [ "displaydoc", "litemap 0.7.5", "tinystr 0.7.6", "writeable 0.5.5", "zerovec 0.10.4",][[package]]name = "icu_locid_transform"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"dependencies = [ "displaydoc", "icu_locid", "icu_locid_transform_data", "icu_provider 1.5.0", "tinystr 0.7.6", "zerovec 0.10.4",][[package]]name = "icu_locid_transform_data"version = "1.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d"[[package]]name = "icu_normalizer"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"dependencies = [ "icu_collections 2.2.0", "icu_normalizer_data", "icu_properties 2.2.0", "icu_provider 2.2.0", "smallvec", "zerovec 0.11.6",][[package]]name = "icu_normalizer_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"[[package]]name = "icu_properties"version = "1.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"dependencies = [ "displaydoc", "icu_collections 1.5.0", "icu_locid_transform", "icu_properties_data 1.5.1", "icu_provider 1.5.0", "serde", "tinystr 0.7.6", "zerovec 0.10.4",][[package]]name = "icu_properties"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"dependencies = [ "icu_collections 2.2.0", "icu_locale_core", "icu_properties_data 2.2.0", "icu_provider 2.2.0", "zerotrie 0.2.4", "zerovec 0.11.6",][[package]]name = "icu_properties_data"version = "1.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2"[[package]]name = "icu_properties_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"[[package]]name = "icu_provider"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"dependencies = [ "displaydoc", "icu_locid", "icu_provider_macros", "postcard", "serde", "stable_deref_trait", "tinystr 0.7.6", "writeable 0.5.5", "yoke 0.7.5", "zerofrom", "zerovec 0.10.4",][[package]]name = "icu_provider"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"dependencies = [ "displaydoc", "icu_locale_core", "writeable 0.6.3", "yoke 0.8.2", "zerofrom", "zerotrie 0.2.4", "zerovec 0.11.6",][[package]]name = "icu_provider_adapters"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d6324dfd08348a8e0374a447ebd334044d766b1839bb8d5ccf2482a99a77c0bc"dependencies = [ "icu_locid", "icu_locid_transform", "icu_provider 1.5.0", "tinystr 0.7.6", "zerovec 0.10.4",][[package]]name = "icu_provider_blob"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c24b98d1365f55d78186c205817631a4acf08d7a45bdf5dc9dcf9c5d54dccf51"dependencies = [ "icu_provider 1.5.0", "postcard", "serde", "writeable 0.5.5", "zerotrie 0.1.3", "zerovec 0.10.4",][[package]]name = "icu_provider_macros"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "icu_segmenter"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a717725612346ffc2d7b42c94b820db6908048f39434504cb130e8b46256b0de"dependencies = [ "core_maths", "displaydoc", "icu_collections 1.5.0", "icu_locid", "icu_provider 1.5.0", "icu_segmenter_data", "serde", "utf8_iter", "zerovec 0.10.4",][[package]]name = "icu_segmenter_data"version = "1.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1e52775179941363cc594e49ce99284d13d6948928d8e72c755f55e98caa1eb"[[package]]name = "id-arena"version = "2.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"[[package]]name = "idna"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"dependencies = [ "idna_adapter", "smallvec", "utf8_iter",][[package]]name = "idna_adapter"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"dependencies = [ "icu_normalizer", "icu_properties 2.2.0",][[package]]name = "image"version = "0.25.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "gif 0.14.2", "image-webp", "moxcms 0.8.1", "num-traits", "png 0.18.1", "zune-core 0.5.1", "zune-jpeg 0.5.15",][[package]]name = "image-webp"version = "0.2.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"dependencies = [ "byteorder-lite", "quick-error",][[package]]name = "imagesize"version = "0.13.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"[[package]]name = "imagesize"version = "0.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"[[package]]name = "indexmap"version = "2.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"dependencies = [ "equivalent", "hashbrown 0.17.0", "serde", "serde_core",][[package]]name = "infer"version = "0.19.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"[[package]]name = "ipconfig"version = "0.3.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"dependencies = [ "socket2", "widestring", "windows-registry", "windows-result", "windows-sys 0.61.2",][[package]]name = "ipnet"version = "2.12.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"[[package]]name = "itoa"version = "1.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"[[package]]name = "jobserver"version = "0.1.34"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"dependencies = [ "getrandom 0.3.4", "libc",][[package]]name = "js-sys"version = "0.3.98"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen",][[package]]name = "kamadak-exif"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"dependencies = [ "mutate_once",][[package]]name = "krilla"version = "0.6.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a0ddfec86fec13d068075e14f22a7e217c281f3ed69ddcb427bf3f5d504fd674"dependencies = [ "base64", "bumpalo", "comemo", "flate2", "float-cmp 0.10.0", "gif 0.13.3", "hayro-write", "image-webp", "imagesize 0.14.0", "indexmap", "once_cell", "pdf-writer", "png 0.17.16", "rayon", "rustc-hash", "rustybuzz", "siphasher 1.0.3", "skrifa", "smallvec", "subsetter", "tiny-skia-path", "xmp-writer", "yoke 0.8.2", "zune-jpeg 0.5.15",][[package]]name = "krilla-svg"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f485e1a850201a01dcd8d73e7cf09f2cd4c4cc85c2cd296359094d49336d8ef7"dependencies = [ "flate2", "fontdb", "krilla", "png 0.17.16", "resvg", "tiny-skia", "usvg",][[package]]name = "kurbo"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"dependencies = [ "arrayvec", "euclid", "smallvec",][[package]]name = "kurbo"version = "0.12.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32"dependencies = [ "arrayvec", "euclid", "smallvec",][[package]]name = "lazy_static"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"dependencies = [ "spin",][[package]]name = "leb128fmt"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"[[package]]name = "lettre"version = "0.11.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"dependencies = [ "async-trait", "base64", "email-encoding", "email_address", "fastrand", "futures-io", "futures-util", "httpdate", "idna", "mime", "nom", "percent-encoding", "quoted_printable", "rustls", "socket2", "tokio", "tokio-rustls", "url", "webpki-roots",][[package]]name = "libc"version = "0.2.186"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"[[package]]name = "libm"version = "0.2.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"[[package]]name = "libredox"version = "0.1.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"dependencies = [ "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.5",][[package]]name = "libsqlite3-sys"version = "0.30.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"dependencies = [ "cc", "pkg-config", "vcpkg",][[package]]name = "linked-hash-map"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"[[package]]name = "linux-raw-sys"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"[[package]]name = "lipsum"version = "0.9.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "636860251af8963cc40f6b4baadee105f02e21b28131d76eba8e40ce84ab8064"dependencies = [ "rand 0.8.6", "rand_chacha 0.3.1",][[package]]name = "litemap"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"dependencies = [ "serde",][[package]]name = "litemap"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"[[package]]name = "lock_api"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"dependencies = [ "scopeguard",][[package]]name = "log"version = "0.4.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"[[package]]name = "lru-cache"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"dependencies = [ "linked-hash-map",][[package]]name = "lru-slab"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"[[package]]name = "mac"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"[[package]]name = "markup5ever"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril",][[package]]name = "matchers"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"dependencies = [ "regex-automata",][[package]]name = "matchit"version = "0.8.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"[[package]]name = "md-5"version = "0.10.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"dependencies = [ "cfg-if", "digest",][[package]]name = "memchr"version = "2.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"[[package]]name = "memmap2"version = "0.9.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"dependencies = [ "libc",][[package]]name = "memo-map"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"[[package]]name = "mime"version = "0.3.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"[[package]]name = "mime_guess"version = "2.0.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"dependencies = [ "mime", "unicase",][[package]]name = "minijinja"version = "2.19.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"dependencies = [ "memo-map", "serde", "serde_json",][[package]]name = "miniz_oxide"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"dependencies = [ "adler2", "simd-adler32",][[package]]name = "mio"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"dependencies = [ "libc", "wasi", "windows-sys 0.61.2",][[package]]name = "moxcms"version = "0.7.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"dependencies = [ "num-traits", "pxfm",][[package]]name = "moxcms"version = "0.8.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"dependencies = [ "num-traits", "pxfm",][[package]]name = "mutate_once"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"[[package]]name = "new_debug_unreachable"version = "1.0.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"[[package]]name = "nom"version = "8.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"dependencies = [ "memchr",][[package]]name = "nu-ansi-term"version = "0.50.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"dependencies = [ "windows-sys 0.61.2",][[package]]name = "num-bigint"version = "0.4.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"dependencies = [ "num-integer", "num-traits",][[package]]name = "num-bigint-dig"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"dependencies = [ "lazy_static", "libm", "num-integer", "num-iter", "num-traits", "rand 0.8.6", "smallvec", "zeroize",][[package]]name = "num-conv"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"[[package]]name = "num-integer"version = "0.1.46"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"dependencies = [ "num-traits",][[package]]name = "num-iter"version = "0.1.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"dependencies = [ "autocfg", "num-integer", "num-traits",][[package]]name = "num-traits"version = "0.2.19"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"dependencies = [ "autocfg", "libm",][[package]]name = "object"version = "0.37.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"dependencies = [ "memchr",][[package]]name = "once_cell"version = "1.21.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"[[package]]name = "palette"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"dependencies = [ "approx", "fast-srgb8", "libm", "palette_derive",][[package]]name = "palette_derive"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"dependencies = [ "by_address", "proc-macro2", "quote", "syn",][[package]]name = "parking"version = "2.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"[[package]]name = "parking_lot"version = "0.12.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"dependencies = [ "lock_api", "parking_lot_core",][[package]]name = "parking_lot_core"version = "0.9.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link",][[package]]name = "paste"version = "1.0.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"[[package]]name = "pdf-writer"version = "0.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92a79477295a713c2ed425aa82a8b5d20cec3fdee203706cbe6f3854880c1c81"dependencies = [ "bitflags 2.11.1", "itoa", "memchr", "ryu",][[package]]name = "pem-rfc7468"version = "0.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"dependencies = [ "base64ct",][[package]]name = "percent-encoding"version = "2.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"[[package]]name = "phf"version = "0.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"dependencies = [ "phf_shared 0.10.0",][[package]]name = "phf"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"dependencies = [ "phf_macros 0.11.3", "phf_shared 0.11.3",][[package]]name = "phf"version = "0.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", "serde",][[package]]name = "phf_codegen"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"dependencies = [ "phf_generator 0.10.0", "phf_shared 0.10.0",][[package]]name = "phf_codegen"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3",][[package]]name = "phf_generator"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"dependencies = [ "phf_shared 0.10.0", "rand 0.8.6",][[package]]name = "phf_generator"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"dependencies = [ "phf_shared 0.11.3", "rand 0.8.6",][[package]]name = "phf_generator"version = "0.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"dependencies = [ "fastrand", "phf_shared 0.13.1",][[package]]name = "phf_macros"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", "syn",][[package]]name = "phf_macros"version = "0.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"dependencies = [ "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", "syn",][[package]]name = "phf_shared"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"dependencies = [ "siphasher 0.3.11",][[package]]name = "phf_shared"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"dependencies = [ "siphasher 1.0.3",][[package]]name = "phf_shared"version = "0.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"dependencies = [ "siphasher 1.0.3",][[package]]name = "pico-args"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"[[package]]name = "pin-project-lite"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"[[package]]name = "pkcs1"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"dependencies = [ "der", "pkcs8", "spki",][[package]]name = "pkcs8"version = "0.10.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"dependencies = [ "der", "spki",][[package]]name = "pkg-config"version = "0.3.33"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"[[package]]name = "plain"version = "0.2.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"[[package]]name = "plist"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"dependencies = [ "base64", "indexmap", "quick-xml 0.39.4", "serde", "time",][[package]]name = "png"version = "0.17.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"dependencies = [ "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide",][[package]]name = "png"version = "0.18.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"dependencies = [ "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide",][[package]]name = "portable-atomic"version = "1.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"[[package]]name = "postcard"version = "1.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", "serde",][[package]]name = "potential_utf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"dependencies = [ "zerovec 0.11.6",][[package]]name = "powerfmt"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"[[package]]name = "ppv-lite86"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"dependencies = [ "zerocopy",][[package]]name = "precomputed-hash"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"[[package]]name = "prettyplease"version = "0.2.37"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"dependencies = [ "proc-macro2", "syn",][[package]]name = "proc-macro-hack"version = "0.5.20+deprecated"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"[[package]]name = "proc-macro2"version = "1.0.106"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"dependencies = [ "unicode-ident",][[package]]name = "psm"version = "0.1.31"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"dependencies = [ "ar_archive_writer", "cc",][[package]]name = "pxfm"version = "0.1.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"[[package]]name = "qcms"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "edecfcd5d755a5e5d98e24cf43113e7cdaec5a070edd0f6b250c03a573da30fa"[[package]]name = "quick-error"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"[[package]]name = "quick-xml"version = "0.38.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"dependencies = [ "memchr", "serde",][[package]]name = "quick-xml"version = "0.39.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"dependencies = [ "memchr",][[package]]name = "quinn"version = "0.11.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", "tokio", "tracing", "web-time",][[package]]name = "quinn-proto"version = "0.11.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", "tinyvec", "tracing", "web-time",][[package]]name = "quinn-udp"version = "0.5.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.60.2",][[package]]name = "quote"version = "1.0.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"dependencies = [ "proc-macro2",][[package]]name = "quoted_printable"version = "0.5.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"[[package]]name = "r-efi"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"[[package]]name = "r-efi"version = "6.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"[[package]]name = "rand"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4",][[package]]name = "rand"version = "0.9.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5",][[package]]name = "rand_chacha"version = "0.3.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"dependencies = [ "ppv-lite86", "rand_core 0.6.4",][[package]]name = "rand_chacha"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"dependencies = [ "ppv-lite86", "rand_core 0.9.5",][[package]]name = "rand_core"version = "0.6.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"dependencies = [ "getrandom 0.2.17",][[package]]name = "rand_core"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"dependencies = [ "getrandom 0.3.4",][[package]]name = "rayon"version = "1.12.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"dependencies = [ "either", "rayon-core",][[package]]name = "rayon-core"version = "1.13.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"dependencies = [ "crossbeam-deque", "crossbeam-utils",][[package]]name = "read-fonts"version = "0.35.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"dependencies = [ "bytemuck", "font-types",][[package]]name = "redox_syscall"version = "0.5.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"dependencies = [ "bitflags 2.11.1",][[package]]name = "redox_syscall"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"dependencies = [ "bitflags 2.11.1",][[package]]name = "regex"version = "1.12.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax",][[package]]name = "regex-automata"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"dependencies = [ "aho-corasick", "memchr", "regex-syntax",][[package]]name = "regex-syntax"version = "0.8.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"[[package]]name = "reqwest"version = "0.12.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"dependencies = [ "base64", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", "webpki-roots",][[package]]name = "resolv-conf"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"[[package]]name = "resvg"version = "0.45.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"dependencies = [ "gif 0.13.3", "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", "zune-jpeg 0.4.21",][[package]]name = "rgb"version = "0.8.53"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"dependencies = [ "bytemuck",][[package]]name = "ring"version = "0.17.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"dependencies = [ "cc", "cfg-if", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0",][[package]]name = "robotstxt"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbc52377db80e3fec3a2c748ca603b8b6cacdd34ff89ff4b742a635361d4b4a7"[[package]]name = "roman-numerals-rs"version = "3.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e"[[package]]name = "roxmltree"version = "0.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"[[package]]name = "rsa"version = "0.9.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"dependencies = [ "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize",][[package]]name = "rust_decimal"version = "1.42.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"dependencies = [ "arrayvec", "num-traits",][[package]]name = "rustc-hash"version = "2.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"[[package]]name = "rustix"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"dependencies = [ "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2",][[package]]name = "rustls"version = "0.23.40"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize",][[package]]name = "rustls-pki-types"version = "1.14.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"dependencies = [ "web-time", "zeroize",][[package]]name = "rustls-webpki"version = "0.103.13"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", "untrusted",][[package]]name = "rustversion"version = "1.0.22"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"[[package]]name = "rustybuzz"version = "0.20.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"dependencies = [ "bitflags 2.11.1", "bytemuck", "core_maths", "log", "smallvec", "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", "unicode-properties", "unicode-script",][[package]]name = "ryu"version = "1.0.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"[[package]]name = "same-file"version = "1.0.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"dependencies = [ "winapi-util",][[package]]name = "scopeguard"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"[[package]]name = "scraper"version = "0.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0"dependencies = [ "ahash", "cssparser", "ego-tree", "getopts", "html5ever", "once_cell", "selectors", "tendril",][[package]]name = "selectors"version = "0.25.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"dependencies = [ "bitflags 2.11.1", "cssparser", "derive_more", "fxhash", "log", "new_debug_unreachable", "phf 0.10.1", "phf_codegen 0.10.0", "precomputed-hash", "servo_arc", "smallvec",][[package]]name = "semver"version = "1.0.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"[[package]]name = "serde"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"dependencies = [ "serde_core", "serde_derive",][[package]]name = "serde_core"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"dependencies = [ "serde_derive",][[package]]name = "serde_derive"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "serde_json"version = "1.0.149"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij",][[package]]name = "serde_path_to_error"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"dependencies = [ "itoa", "serde", "serde_core",][[package]]name = "serde_spanned"version = "0.6.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"dependencies = [ "serde",][[package]]name = "serde_urlencoded"version = "0.7.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"dependencies = [ "form_urlencoded", "itoa", "ryu", "serde",][[package]]name = "serde_yaml"version = "0.9.34+deprecated"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"dependencies = [ "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml",][[package]]name = "servo_arc"version = "0.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44"dependencies = [ "stable_deref_trait",][[package]]name = "sha1"version = "0.10.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"dependencies = [ "cfg-if", "cpufeatures", "digest",][[package]]name = "sha2"version = "0.10.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"dependencies = [ "cfg-if", "cpufeatures", "digest",][[package]]name = "sharded-slab"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"dependencies = [ "lazy_static",][[package]]name = "shlex"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"[[package]]name = "signal-hook-registry"version = "1.4.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"dependencies = [ "errno", "libc",][[package]]name = "signature"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"dependencies = [ "digest", "rand_core 0.6.4",][[package]]name = "simd-adler32"version = "0.3.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"[[package]]name = "simplecss"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"dependencies = [ "log",][[package]]name = "siphasher"version = "0.3.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"[[package]]name = "siphasher"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"[[package]]name = "skrifa"version = "0.37.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841"dependencies = [ "bytemuck", "read-fonts",][[package]]name = "slab"version = "0.4.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"[[package]]name = "slotmap"version = "1.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"dependencies = [ "version_check",][[package]]name = "smallvec"version = "1.15.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"dependencies = [ "serde",][[package]]name = "socket2"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "spin"version = "0.9.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"dependencies = [ "lock_api",][[package]]name = "spki"version = "0.7.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"dependencies = [ "base64ct", "der",][[package]]name = "sqlx"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"dependencies = [ "sqlx-core", "sqlx-macros", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite",][[package]]name = "sqlx-core"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"dependencies = [ "base64", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", "futures-core", "futures-intrusive", "futures-io", "futures-util", "hashbrown 0.15.5", "hashlink", "indexmap", "log", "memchr", "once_cell", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", "url", "uuid",][[package]]name = "sqlx-macros"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", "syn",][[package]]name = "sqlx-macros-core"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"dependencies = [ "dotenvy", "either", "heck", "hex", "once_cell", "proc-macro2", "quote", "serde", "serde_json", "sha2", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn", "tokio", "url",][[package]]name = "sqlx-mysql"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"dependencies = [ "atoi", "base64", "bitflags 2.11.1", "byteorder", "bytes", "chrono", "crc", "digest", "dotenvy", "either", "futures-channel", "futures-core", "futures-io", "futures-util", "generic-array", "hex", "hkdf", "hmac", "itoa", "log", "md-5", "memchr", "once_cell", "percent-encoding", "rand 0.8.6", "rsa", "serde", "sha1", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", "uuid", "whoami",][[package]]name = "sqlx-postgres"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"dependencies = [ "atoi", "base64", "bitflags 2.11.1", "byteorder", "chrono", "crc", "dotenvy", "etcetera", "futures-channel", "futures-core", "futures-util", "hex", "hkdf", "hmac", "home", "itoa", "log", "md-5", "memchr", "once_cell", "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", "uuid", "whoami",][[package]]name = "sqlx-sqlite"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"dependencies = [ "atoi", "chrono", "flume", "futures-channel", "futures-core", "futures-executor", "futures-intrusive", "futures-util", "libsqlite3-sys", "log", "percent-encoding", "serde", "serde_urlencoded", "sqlx-core", "thiserror 2.0.18", "tracing", "url", "uuid",][[package]]name = "stable_deref_trait"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"[[package]]name = "stacker"version = "0.1.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"dependencies = [ "cc", "cfg-if", "libc", "psm", "windows-sys 0.61.2",][[package]]name = "status"version = "0.1.0"dependencies = [ "anyhow", "axum", "base64", "chrono", "dotenvy", "futures-util", "hickory-resolver", "hmac", "html5ever", "lettre", "mime_guess", "minijinja", "once_cell", "regex", "reqwest", "robotstxt", "rustls", "scraper", "serde", "serde_json", "serde_urlencoded", "sha2", "sqlx", "tempfile", "thiserror 2.0.18", "tokio", "tower", "tower-cookies", "tower-http", "tracing", "tracing-subscriber", "typst", "typst-kit", "typst-pdf", "url", "urlencoding", "uuid",][[package]]name = "strict-num"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"dependencies = [ "float-cmp 0.9.0",][[package]]name = "string_cache"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared 0.11.3", "precomputed-hash", "serde",][[package]]name = "string_cache_codegen"version = "0.5.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote",][[package]]name = "stringprep"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"dependencies = [ "unicode-bidi", "unicode-normalization", "unicode-properties",][[package]]name = "strum"version = "0.27.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"dependencies = [ "strum_macros",][[package]]name = "strum_macros"version = "0.27.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"dependencies = [ "heck", "proc-macro2", "quote", "syn",][[package]]name = "subsetter"version = "0.2.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb6895a12ac5599bb6057362f00e8a3cf1daab4df33f553a55690a44e4fed8d0"dependencies = [ "kurbo 0.12.0", "rustc-hash", "skrifa", "write-fonts",][[package]]name = "subtle"version = "2.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"[[package]]name = "svgtypes"version = "0.15.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"dependencies = [ "kurbo 0.11.3", "siphasher 1.0.3",][[package]]name = "syn"version = "2.0.117"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"dependencies = [ "proc-macro2", "quote", "unicode-ident",][[package]]name = "sync_wrapper"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"dependencies = [ "futures-core",][[package]]name = "synstructure"version = "0.13.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "syntect"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"dependencies = [ "bincode", "fancy-regex", "flate2", "fnv", "once_cell", "plist", "regex-syntax", "serde", "serde_derive", "serde_json", "thiserror 2.0.18", "walkdir", "yaml-rust",][[package]]name = "tempfile"version = "3.27.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2",][[package]]name = "tendril"version = "0.4.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"dependencies = [ "futf", "mac", "utf-8",][[package]]name = "thin-vec"version = "0.2.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"[[package]]name = "thiserror"version = "1.0.69"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"dependencies = [ "thiserror-impl 1.0.69",][[package]]name = "thiserror"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"dependencies = [ "thiserror-impl 2.0.18",][[package]]name = "thiserror-impl"version = "1.0.69"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thiserror-impl"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thread_local"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"dependencies = [ "cfg-if",][[package]]name = "time"version = "0.3.47"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde_core", "time-core", "time-macros",][[package]]name = "time-core"version = "0.1.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"[[package]]name = "time-macros"version = "0.2.27"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"dependencies = [ "num-conv", "time-core",][[package]]name = "tiny-skia"version = "0.11.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"dependencies = [ "arrayref", "arrayvec", "bytemuck", "cfg-if", "log", "png 0.17.16", "tiny-skia-path",][[package]]name = "tiny-skia-path"version = "0.11.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"dependencies = [ "arrayref", "bytemuck", "strict-num",][[package]]name = "tinystr"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"dependencies = [ "displaydoc", "serde", "zerovec 0.10.4",][[package]]name = "tinystr"version = "0.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"dependencies = [ "displaydoc", "serde_core", "zerovec 0.11.6",][[package]]name = "tinyvec"version = "1.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"dependencies = [ "tinyvec_macros",][[package]]name = "tinyvec_macros"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"[[package]]name = "tokio"version = "1.52.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2",][[package]]name = "tokio-macros"version = "2.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tokio-rustls"version = "0.26.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"dependencies = [ "rustls", "tokio",][[package]]name = "tokio-stream"version = "0.1.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"dependencies = [ "futures-core", "pin-project-lite", "tokio",][[package]]name = "tokio-util"version = "0.7.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio",][[package]]name = "toml"version = "0.8.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"dependencies = [ "serde", "serde_spanned", "toml_datetime", "toml_edit",][[package]]name = "toml_datetime"version = "0.6.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"dependencies = [ "serde",][[package]]name = "toml_edit"version = "0.22.27"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", "toml_write", "winnow",][[package]]name = "toml_write"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"[[package]]name = "tower"version = "0.5.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", "tracing",][[package]]name = "tower-cookies"version = "0.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"dependencies = [ "axum-core", "cookie", "futures-util", "http", "parking_lot", "pin-project-lite", "tower-layer", "tower-service",][[package]]name = "tower-http"version = "0.6.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"dependencies = [ "async-compression", "bitflags 2.11.1", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "http-range-header", "httpdate", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", "tracing", "url",][[package]]name = "tower-layer"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"[[package]]name = "tower-service"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"[[package]]name = "tracing"version = "0.1.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core",][[package]]name = "tracing-attributes"version = "0.1.31"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tracing-core"version = "0.1.36"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"dependencies = [ "once_cell", "valuable",][[package]]name = "tracing-log"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"dependencies = [ "log", "once_cell", "tracing-core",][[package]]name = "tracing-subscriber"version = "0.3.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log",][[package]]name = "try-lock"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"[[package]]name = "ttf-parser"version = "0.25.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"dependencies = [ "core_maths",][[package]]name = "two-face"version = "0.4.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39e51b6e60e545cfdae5a4639ff423818f52372211a8d9a3e892b4b0761f76b2"dependencies = [ "serde", "serde_derive", "syntect",][[package]]name = "typed-arena"version = "2.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"[[package]]name = "typenum"version = "1.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"[[package]]name = "typst"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1f6511ee598476f4f322b4d13891083d96dbacb8f9c2b908604c7094ba390653"dependencies = [ "comemo", "ecow", "rustc-hash", "typst-eval", "typst-html", "typst-layout", "typst-library", "typst-macros", "typst-realize", "typst-syntax", "typst-timing", "typst-utils",][[package]]name = "typst-assets"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5613cb719a6222fe9b74027c3625d107767ec187bff26b8fc931cf58942c834f"[[package]]name = "typst-eval"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "687757487dfc0c1e941344d5024cf7a28364e70c3e304faad89ac65597f62526"dependencies = [ "comemo", "ecow", "indexmap", "rustc-hash", "stacker", "toml", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils", "unicode-segmentation",][[package]]name = "typst-html"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e29f8da4f964d4c90739c3c1e0288b0ba1bccc3cc50623a6d558300b86ca8aad"dependencies = [ "bumpalo", "comemo", "ecow", "palette", "rustc-hash", "time", "typst-assets", "typst-library", "typst-macros", "typst-svg", "typst-syntax", "typst-timing", "typst-utils",][[package]]name = "typst-kit"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31476ec753e080ffdd543a0e74b6d319355449ff3eca3f216634f31cfd09a92a"dependencies = [ "ecow", "fontdb", "once_cell", "serde", "serde_json", "typst-assets", "typst-library", "typst-syntax", "typst-timing", "typst-utils",][[package]]name = "typst-layout"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4cab0200105831a9158e63718a0f6141c78cb2c1722ed17d19ad28941e3b8491"dependencies = [ "az", "bumpalo", "codex", "comemo", "ecow", "either", "hypher", "icu_properties 1.5.1", "icu_provider 1.5.0", "icu_provider_adapters", "icu_provider_blob", "icu_segmenter", "kurbo 0.12.0", "memchr", "rustc-hash", "rustybuzz", "smallvec", "ttf-parser", "typst-assets", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils", "unicode-bidi", "unicode-math-class", "unicode-script", "unicode-segmentation",][[package]]name = "typst-library"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e276a5de53020c43efe2111ec236252e54ea4480b5ac18063e663dfbe03d9d1b"dependencies = [ "az", "bitflags 2.11.1", "bumpalo", "chinese-number", "ciborium", "codex", "comemo", "csv", "ecow", "flate2", "fontdb", "glidesort", "hayagriva", "hayro-syntax", "icu_properties 1.5.1", "icu_provider 1.5.0", "icu_provider_blob", "image", "indexmap", "kamadak-exif", "kurbo 0.12.0", "lipsum", "memchr", "palette", "phf 0.13.1", "png 0.17.16", "qcms", "rayon", "regex", "regex-syntax", "roxmltree", "rust_decimal", "rustc-hash", "rustybuzz", "serde", "serde_json", "serde_yaml", "siphasher 1.0.3", "smallvec", "syntect", "time", "toml", "ttf-parser", "two-face", "typed-arena", "typst-assets", "typst-macros", "typst-syntax", "typst-timing", "typst-utils", "unicode-math-class", "unicode-normalization", "unicode-segmentation", "unscanny", "usvg", "utf8_iter", "wasmi", "xmlwriter",][[package]]name = "typst-macros"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "141cbd1027129fbf6bda1013f52a264df7befc7388cc8f47767d65e803fd3a59"dependencies = [ "heck", "proc-macro2", "quote", "syn",][[package]]name = "typst-pdf"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "37c8a4630754767cd10d48e8b8186e7dc784631a30a3a93521edf7d77aebd0c0"dependencies = [ "az", "bytemuck", "comemo", "ecow", "image", "indexmap", "infer", "krilla", "krilla-svg", "rustc-hash", "serde", "smallvec", "typst-assets", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils",][[package]]name = "typst-realize"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f7ffe964757fb93d2e98978aa2a74ee85b0f94c8643e8f3550737258b58f39d8"dependencies = [ "arrayvec", "bumpalo", "comemo", "ecow", "regex", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils",][[package]]name = "typst-svg"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e46b811837ade1f0243ef0d8bf3fb06d166443090eac22c28643f374c2ccdc9d"dependencies = [ "base64", "comemo", "ecow", "flate2", "hayro", "hayro-svg", "image", "rustc-hash", "ttf-parser", "typst-assets", "typst-library", "typst-macros", "typst-timing", "typst-utils", "xmlparser", "xmlwriter",][[package]]name = "typst-syntax"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a95d9192060e23b1e491b0b94dff676acddc92a4d672aeb8ca3890a5a734e879"dependencies = [ "ecow", "rustc-hash", "serde", "toml", "typst-timing", "typst-utils", "unicode-ident", "unicode-math-class", "unicode-script", "unicode-segmentation", "unscanny",][[package]]name = "typst-timing"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7be94f8faf19841b49574ef5c7fd7a12e2deb7c3d8deba5a596f35d2222024cd"dependencies = [ "parking_lot", "serde", "serde_json",][[package]]name = "typst-utils"version = "0.14.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a3966c92e8fa48c7ce898130d07000d985f18206d92b250f0f939287fbccdee3"dependencies = [ "once_cell", "portable-atomic", "rayon", "rustc-hash", "siphasher 1.0.3", "thin-vec", "unicode-math-class",][[package]]name = "unic-langid"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"dependencies = [ "unic-langid-impl", "unic-langid-macros",][[package]]name = "unic-langid-impl"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"dependencies = [ "serde", "tinystr 0.8.3",][[package]]name = "unic-langid-macros"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25"dependencies = [ "proc-macro-hack", "tinystr 0.8.3", "unic-langid-impl", "unic-langid-macros-impl",][[package]]name = "unic-langid-macros-impl"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5"dependencies = [ "proc-macro-hack", "quote", "syn", "unic-langid-impl",][[package]]name = "unicase"version = "2.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"[[package]]name = "unicode-bidi"version = "0.3.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"[[package]]name = "unicode-bidi-mirroring"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"[[package]]name = "unicode-ccc"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"[[package]]name = "unicode-ident"version = "1.0.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"[[package]]name = "unicode-math-class"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7d246cf599d5fae3c8d56e04b20eb519adb89a8af8d0b0fbcded369aa3647d65"[[package]]name = "unicode-normalization"version = "0.1.25"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"dependencies = [ "tinyvec",][[package]]name = "unicode-properties"version = "0.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"[[package]]name = "unicode-script"version = "0.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"[[package]]name = "unicode-segmentation"version = "1.13.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"[[package]]name = "unicode-vo"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"[[package]]name = "unicode-width"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"[[package]]name = "unicode-xid"version = "0.2.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"[[package]]name = "unsafe-libyaml"version = "0.2.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"[[package]]name = "unscanny"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47"[[package]]name = "untrusted"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"[[package]]name = "url"version = "2.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", "serde_derive",][[package]]name = "urlencoding"version = "2.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"[[package]]name = "usvg"version = "0.45.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"dependencies = [ "base64", "data-url", "flate2", "fontdb", "imagesize 0.13.0", "kurbo 0.11.3", "log", "pico-args", "roxmltree", "rustybuzz", "simplecss", "siphasher 1.0.3", "strict-num", "svgtypes", "tiny-skia-path", "unicode-bidi", "unicode-script", "unicode-vo", "xmlwriter",][[package]]name = "utf-8"version = "0.7.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"[[package]]name = "utf8_iter"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"[[package]]name = "uuid"version = "1.23.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen",][[package]]name = "valuable"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"[[package]]name = "vcpkg"version = "0.2.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"[[package]]name = "version_check"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"[[package]]name = "walkdir"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"dependencies = [ "same-file", "winapi-util",][[package]]name = "want"version = "0.3.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"dependencies = [ "try-lock",][[package]]name = "wasi"version = "0.11.1+wasi-snapshot-preview1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"[[package]]name = "wasip2"version = "1.0.3+wasi-0.2.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"dependencies = [ "wit-bindgen 0.57.1",][[package]]name = "wasip3"version = "0.4.0+wasi-0.3.0-rc-2026-01-06"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"dependencies = [ "wit-bindgen 0.51.0",][[package]]name = "wasite"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"[[package]]name = "wasm-bindgen"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-futures"version = "0.4.71"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "wasm-bindgen-macro"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"dependencies = [ "quote", "wasm-bindgen-macro-support",][[package]]name = "wasm-bindgen-macro-support"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-shared"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"dependencies = [ "unicode-ident",][[package]]name = "wasm-encoder"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"dependencies = [ "leb128fmt", "wasmparser 0.244.0",][[package]]name = "wasm-metadata"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"dependencies = [ "anyhow", "indexmap", "wasm-encoder", "wasmparser 0.244.0",][[package]]name = "wasm-streams"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"dependencies = [ "futures-util", "js-sys", "wasm-bindgen", "wasm-bindgen-futures", "web-sys",][[package]]name = "wasmi"version = "0.51.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff"dependencies = [ "spin", "wasmi_collections", "wasmi_core", "wasmi_ir", "wasmparser 0.228.0",][[package]]name = "wasmi_collections"version = "0.51.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e9b8e98e45a2a534489f8225e765cbf1cb9a3078072605e58158910cf4749172"[[package]]name = "wasmi_core"version = "0.51.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c25f375c0cdf14810eab07f532f61f14d4966f09c747a55067fdf3196e8512e6"dependencies = [ "libm",][[package]]name = "wasmi_ir"version = "0.51.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "624e2a68a4293ecb8f564260b68394b29cf3b3edba6bce35532889a2cb33c3d9"dependencies = [ "wasmi_core",][[package]]name = "wasmparser"version = "0.228.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"dependencies = [ "bitflags 2.11.1",][[package]]name = "wasmparser"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver",][[package]]name = "web-sys"version = "0.3.98"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "web-time"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "webpki-roots"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"dependencies = [ "rustls-pki-types",][[package]]name = "weezl"version = "0.1.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"[[package]]name = "whoami"version = "1.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"dependencies = [ "libredox", "wasite",][[package]]name = "widestring"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"[[package]]name = "winapi-util"version = "0.1.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"dependencies = [ "windows-sys 0.61.2",][[package]]name = "windows-core"version = "0.62.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings",][[package]]name = "windows-implement"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-interface"version = "0.59.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-link"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"[[package]]name = "windows-registry"version = "0.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"dependencies = [ "windows-link", "windows-result", "windows-strings",][[package]]name = "windows-result"version = "0.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"dependencies = [ "windows-link",][[package]]name = "windows-strings"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"dependencies = [ "windows-link",][[package]]name = "windows-sys"version = "0.48.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"dependencies = [ "windows-targets 0.48.5",][[package]]name = "windows-sys"version = "0.52.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"dependencies = [ "windows-targets 0.52.6",][[package]]name = "windows-sys"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"dependencies = [ "windows-targets 0.53.5",][[package]]name = "windows-sys"version = "0.61.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"dependencies = [ "windows-link",][[package]]name = "windows-targets"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5",][[package]]name = "windows-targets"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6",][[package]]name = "windows-targets"version = "0.53.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1",][[package]]name = "windows_aarch64_gnullvm"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"[[package]]name = "windows_aarch64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"[[package]]name = "windows_aarch64_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"[[package]]name = "windows_aarch64_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"[[package]]name = "windows_aarch64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"[[package]]name = "windows_aarch64_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"[[package]]name = "windows_i686_gnu"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"[[package]]name = "windows_i686_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"[[package]]name = "windows_i686_gnu"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"[[package]]name = "windows_i686_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"[[package]]name = "windows_i686_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"[[package]]name = "windows_i686_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"[[package]]name = "windows_i686_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"[[package]]name = "windows_i686_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"[[package]]name = "windows_x86_64_gnu"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"[[package]]name = "windows_x86_64_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"[[package]]name = "windows_x86_64_gnu"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"[[package]]name = "windows_x86_64_gnullvm"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"[[package]]name = "windows_x86_64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"[[package]]name = "windows_x86_64_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"[[package]]name = "windows_x86_64_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"[[package]]name = "windows_x86_64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"[[package]]name = "windows_x86_64_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"[[package]]name = "winnow"version = "0.7.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"dependencies = [ "memchr",][[package]]name = "wit-bindgen"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"dependencies = [ "wit-bindgen-rust-macro",][[package]]name = "wit-bindgen"version = "0.57.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"[[package]]name = "wit-bindgen-core"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"dependencies = [ "anyhow", "heck", "wit-parser",][[package]]name = "wit-bindgen-rust"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn", "wasm-metadata", "wit-bindgen-core", "wit-component",][[package]]name = "wit-bindgen-rust-macro"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn", "wit-bindgen-core", "wit-bindgen-rust",][[package]]name = "wit-component"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"dependencies = [ "anyhow", "bitflags 2.11.1", "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", "wasmparser 0.244.0", "wit-parser",][[package]]name = "wit-parser"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"dependencies = [ "anyhow", "id-arena", "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", "wasmparser 0.244.0",][[package]]name = "write-fonts"version = "0.43.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "886614b5ce857341226aa091f3c285e450683894acaaa7887f366c361efef79d"dependencies = [ "font-types", "indexmap", "kurbo 0.12.0", "log", "read-fonts",][[package]]name = "writeable"version = "0.5.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"[[package]]name = "writeable"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"[[package]]name = "xmlparser"version = "0.13.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"[[package]]name = "xmlwriter"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"[[package]]name = "xmp-writer"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"[[package]]name = "yaml-rust"version = "0.4.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"dependencies = [ "linked-hash-map",][[package]]name = "yoke"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"dependencies = [ "serde", "stable_deref_trait", "yoke-derive 0.7.5", "zerofrom",][[package]]name = "yoke"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"dependencies = [ "stable_deref_trait", "yoke-derive 0.8.2", "zerofrom",][[package]]name = "yoke-derive"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "yoke-derive"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zerocopy"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"dependencies = [ "zerocopy-derive",][[package]]name = "zerocopy-derive"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zerofrom"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"dependencies = [ "zerofrom-derive",][[package]]name = "zerofrom-derive"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zeroize"version = "1.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"[[package]]name = "zerotrie"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fb594dd55d87335c5f60177cee24f19457a5ec10a065e0a3014722ad252d0a1f"dependencies = [ "displaydoc", "litemap 0.7.5", "serde", "zerovec 0.10.4",][[package]]name = "zerotrie"version = "0.2.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"dependencies = [ "displaydoc", "yoke 0.8.2", "zerofrom",][[package]]name = "zerovec"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"dependencies = [ "serde", "yoke 0.7.5", "zerofrom", "zerovec-derive 0.10.3",][[package]]name = "zerovec"version = "0.11.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"dependencies = [ "serde", "yoke 0.8.2", "zerofrom", "zerovec-derive 0.11.3",][[package]]name = "zerovec-derive"version = "0.10.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zerovec-derive"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zlib-rs"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"[[package]]name = "zmij"version = "1.0.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"[[package]]name = "zune-core"version = "0.4.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"[[package]]name = "zune-core"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"[[package]]name = "zune-jpeg"version = "0.4.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"dependencies = [ "zune-core 0.4.12",][[package]]name = "zune-jpeg"version = "0.5.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"dependencies = [ "zune-core 0.5.1",]
added Cargo.toml
@@ -0,0 +1,49 @@[package]name = "status"version = "0.1.0"edition = "2021"default-run = "status"[dependencies]axum = { version = "0.8", features = ["macros"] }tokio = { version = "1", features = ["full"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] }tower-cookies = { version = "0.11", features = ["signed"] }minijinja = { version = "2", features = ["loader", "loop_controls", "json"] }serde = { version = "1", features = ["derive"] }serde_json = "1"serde_urlencoded = "0.7"sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "uuid"] }uuid = { version = "1", features = ["v4", "serde"] }chrono = { version = "0.4", features = ["serde"] }hmac = "0.12"sha2 = "0.10"base64 = "0.22"dotenvy = "0.15"anyhow = "1"thiserror = "2"tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "gzip", "brotli"] }rustls = "0.23"tempfile = "3"mime_guess = "2"urlencoding = "2"futures-util = "0.3"url = "2"scraper = "0.20"html5ever = "0.27"robotstxt = "0.3"regex = "1"once_cell = "1"hickory-resolver = "0.24"lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "rustls-tls", "tokio1-rustls-tls", "builder", "tokio1"] }typst = "0.14"typst-pdf = "0.14"typst-kit = { version = "0.14", default-features = false, features = ["fonts", "embed-fonts"] }[profile.release]# Skip LTO so release builds stay tractable on the 1-vCPU alpine VPS once# typst-library entered the dep tree. codegen-units defaults to 16.strip = true
modified Dockerfile
@@ -1,31 +1,56 @@FROM alpine:3.21# syntax=docker/dockerfile:1# ----- builder -----FROM rust:alpine AS builderENV LANG="C.UTF-8" \    PYTHONUNBUFFERED=1RUN apk add --no-cache musl-dev pkgconfig openssl-devCOPY --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 \      nodejs \      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 --no-devCOPY Cargo.toml Cargo.lock ./COPY src ./srcCOPY migrations ./migrationsCOPY frontend ./frontendRUN cd frontend && bun install --frozen-lockfile && bun run buildRUN --mount=type=cache,target=/usr/local/cargo/registry \    --mount=type=cache,target=/app/target \    cargo build --release && \    cp target/release/status /app/status# ----- runtime -----FROM alpine:3.23COPY . .# nodejs + chromium for the lighthouse npm CLI. PDF reports are rendered# in-process via embedded Typst, so the chromium dep is lighthouse-only.# Fonts are for the Typst renderer (it scans system fonts at boot and embeds# the listed families into output PDFs).RUN apk add --no-cache \    nodejs npm chromium ca-certificates \    font-jetbrains-mono ttf-dejavu ttf-liberation fontconfigENV PATH="/app/.venv/bin:/app/node_modules/.bin:$PATH"WORKDIR /appCOPY --from=builder /app/status ./statusCOPY --from=builder /app/dist ./distCOPY templates ./templatesCOPY migrations ./migrationsCOPY package.json ./RUN bun run build && \    uv run python manage.py collectstatic --noinput && \    chmod +x /app/entrypoint.py# Lighthouse npm package is required at runtime: the binary shells out to# /app/node_modules/.bin/lighthouse for site audits.RUN npm install --omit=dev --no-audit --no-fundRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    chown -R app:app /appUSER app:app    mkdir -p /data && chown -R app:app /app /dataUSER appENV PORT=8000ENV STATUS_DATA_DIR=/dataENV CHROMIUM_BIN=/usr/bin/chromium-browserEXPOSE 8000CMD ["./status"]
modified Makefile
@@ -1,70 +1,54 @@# Django + Vite MakefileCARGO ?= $(HOME)/.cargo/bin/cargoPORT  ?= 8000.DEFAULT_GOAL := run.PHONY: run build start clean push pull migrate.PHONY: run runserver vite clean push pull update scheduler.DEFAULT: run# Dev: Vite watch + cargo run concurrently. Both die on Ctrl+C.# Lighthouse runs in-process (subprocess to node_modules/.bin/lighthouse), so# the dev binary needs the npm install too.run: frontend/node_modules dist/.vite/manifest.json node_modules	@trap 'kill 0' EXIT INT TERM; \	(cd frontend && bun run dev) & \	PORT=$(PORT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules	cd frontend && bun run build	$(CARGO) build --releaseSERVER_URL = $(shell git config --get remote.server.url | cut -d ':' -f 1)PROJECT_NAME = $(shell basename $(PWD))run: install	@echo "run ----------------------------------------------------------------"	${MAKE} -j2 runserver viterunserver:	uv run python manage.py runserver 0.0.0.0:8000vite:	bun run devscheduler:	uv run python manage.py schedulerinstall: node_modules/touchfile .venv/touchfile db.sqlite3node_modules/touchfile: package.json	@echo "install node deps --------------------------------------------------"	bun install	touch $@	@echo "> all node deps installed".venv/touchfile: pyproject.toml	@echo "install python deps ------------------------------------------------"	uv sync	touch $@	@echo "> all python deps installed"db.sqlite3:	@echo "create database ----------------------------------------------------"	uv run python manage.py migrate	@echo "> database created"# Run the release binary (after `make build`)start:	PORT=$(PORT) ./target/release/statusclean:	$(CARGO) clean	rm -rf dist frontend/node_modules node_modules data/db.sqlite3push:	@echo "push ---------------------------------------------------------------"	git remote | xargs -I R git push R masterpull:	@echo "pull ---------------------------------------------------------------"	rsync -avz $(SERVER_URL):/srv/data/$(PROJECT_NAME)/db/db.sqlite3 db.sqlite3	rsync -avz $(SERVER_URL):/srv/data/$(PROJECT_NAME)/media/ media	@echo "> all files copied"# Import an existing Django status SQLite into the rust schema.# `make migrate FROM=../status-django/db.sqlite3` (add FORCE=1 to wipe first).migrate:	@if [ -z "$(FROM)" ]; then echo "usage: make migrate FROM=<path-to-django.sqlite3> [FORCE=1]"; exit 2; fi	$(CARGO) run -- migrate "$(FROM)" $(if $(FORCE),--force,)update: install	@echo "update -------------------------------------------------------------"	uv lock --upgrade	bun update --latest	@echo "> all deps updated"clean:	@echo "clean --------------------------------------------------------------"	rm -rf node_modules	rm -rf .venv	rm -rf db.sqlite3	rm -rf media	@echo "> all files removed"# Pull production data (db, media) for local devpull:	@SERVER=$$(git config --get remote.server.url | sed 's|ssh://||' | cut -d ':' -f 1 | cut -d '/' -f 1); \	NAME=$$(basename $$(pwd)); \	mkdir -p data; \	rsync -avz $$SERVER:/srv/data/$$NAME/db/db.sqlite3 data/db.sqlite3frontend/node_modules:	cd frontend && bun install# Lighthouse npm package, installed at the repo root because the lighthouse# binary lives at node_modules/.bin/lighthouse and reads node_modules/lighthouse# at runtime.node_modules: package.json	npm install --omit=dev --no-audit --no-funddist/.vite/manifest.json: frontend/node_modules	cd frontend && bun run build
modified README.md
@@ -1,13 +1,13 @@# StatusA self-hostable uptime monitor and status page. HTTP checks every 3 minutes,A self-hosted uptime monitor and status page. HTTP checks every 3 minutes,daily Lighthouse audits, weekly in-process SEO crawls, and alerts via emailand Discord webhook on state transitions.## MotivationI was bored and felt like writing my own uptime service over the weekend.Single-binary axum service backed by SQLite. Originally a Django service;that codebase has been retired and replaced with this rust port. The datafrom the Django era can be migrated in via `./status migrate <django.sqlite3>`(preserves Property UUIDs so existing public status URLs keep working).## Features
@@ -15,169 +15,90 @@ I was bored and felt like writing my own uptime service over the weekend.- HTTP uptime checks with rolling uptime percentages and recent-uptime bars- Lighthouse audits (performance, accessibility, best practices, SEO) with  weighted breakdown and top savings opportunities- In-process SEO crawler (requests + BeautifulSoup) — title, description,  canonical, OG tags, and H1 per page- Security header analysis (HSTS, CSP, X-Frame-Options, Referrer-Policy, etc.)- Alert state machine with debounce on flaps — two consecutive non-200s to  go down, immediate 200 to come back up- Email and Discord webhook alerts on state transitions only- PDF report export per property via a headless Chromium subprocess- Customizable UI with a warm-earth palette and Monaspace Argon- In-process SEO crawler (reqwest + scraper) extracting title, description,  canonical, OG tags, and H1 per page, plus 38 SEO/a11y/perf/security checks- Security header analysis (HTTPS, HSTS, HSTS preload, X-Frame-Options, etc.)- Alert state machine with debounce on flaps (two consecutive non-200s to  go down, immediate 200 to come back up)- Direct-to-MX email and Discord webhook alerts on state transitions only- PDF + markdown report export per property (PDF rendered in-process via  embedded Typst, no chromium subprocess; markdown rendered from a template)## RequirementsYou need docker + docker-compose installed for a quick production start or youcan figure out how we install and run things via the `Dockerfile` and set it upyourself.If you want to install things without docker then you'll need the followingdependencies:- python- uv- bun- node (required only for the `lighthouse` npm CLI — Bun doesn't run Lighthouse  correctly; see bun issue #4958)- chromium (used for PDF report generation via a subprocess wrapper)You need docker installed for a quick production start, or you can read the`Dockerfile` for the exact dependency list and adjust for your distro.You can also check the `Dockerfile` for an exact list of dependencies and adjustpackage names for your desired platform.For local development:This is a standard Django project. If you know how to run Django, or want tolook up any Django tutorial on how to run Django, you shouldn't have a problemgetting this project running on almost anything.- rust (cargo) for the backend- bun for the frontend bundler (Vite)- node + npm only for the `lighthouse` npm CLI; bun doesn't run Lighthouse  correctly (bun issue #4958)- chromium for Lighthouse's own audits (PDF reports do not need chromium;  they go through embedded Typst)## Running locallyIf you have all of the above dependencies installed you can use my Makefile torun and install python and node dependencies locally. Running `make` will checkthat you have the proper dependencies installed and if not it will try andinstall them for you. It will then create you a fresh database and runeverything.If you want to also run the scheduler you'll have to do so separately. Run`make` in one window/tab and run `make scheduler` in another.## Checking outdated dependenciesThis can be done in both bun and uv with the following two commands:    uv lock --upgrade --dry-run    bun outdatedYou can then upgrade all dependencies at once with:    make updateI recommend testing everything after this to make sure it's all working.## Optimizing images with webp    cp samplefiles/env.sample .env  # set STATUS_PASSWORD at minimum    make run                         # vite watch + cargo run on port 8000My development system runs Ubuntu so I installed the official webp utils fromGoogle with `apt install webp`.Server boots, applies migrations, and starts the scheduler in-process. Open<http://localhost:8000>, log in with the password from `.env`, add a propertyURL.    cwebp -q 90 -m 6 -o output.webp input.png## Importing an existing Django status DB## Using docker-composeIf you have a SQLite from the Django version of this project, you can keepyour existing properties + check history:The easiest way to run this project is to run it using`docker-compose up --build -d` if you have `docker-compose` and `docker`installed. This will start the server and have you running at port 8000. Thefirst time you do this make sure you run migrations with`docker-compose run web python manage.py migrate`. Make sure you setup the`.env` file before running, you can copy the sample from`samplefiles/env.sample` into the root of the project as `.env` and change thevariables.    make migrate FROM=/path/to/django/db.sqlite3Add `FORCE=1` to wipe an existing local rust DB first. The migration preservesProperty UUIDs so any public status URLs you've shared keep working.## Default userThe default user is `admin` with the password `admin`. Add your own propertiesfrom the dashboard after signing in.## Production deploy## AlertsEach property can be assigned a Discord webhook URL per user (see the accountsettings). Email alerts use the project's configured outbound mailer (thedirect-to-MX backend by default, see `status/mailer.py`). Alerts fire on statetransitions only — not on every failing check.## BackupsAll data is stored in `/srv/data/status/` and your repo is stored in`/srv/git/status.git/`. You can backup both of these folders and you'll havea 100% backup of everything except changes you may have made to the `Caddyfile`and the `.env` file which should be easy enough to recreate but you can backthose up too!## Server guideThis quickstart requires that you have an Alpine Linux server running with adomain name pointed to it. I'm currently using Linode as my host since theysupport Alpine Linux nicely. If you don't want to use Linode or Alpine Linuxyou can use these instructions and just change the apk commands at the start towhatever Linux distro you're using.**IMPORTANT NOTE**: Change `status.bythewood.me` to your domain name whererelevant in these instructions.**TIP**: During the ufw portion to enable the firewall I recommend only allowingyour IP address or your ISP's IP address range which you can find on whoislookups at the top. For example, replace `192.230.176.0/20` with your IP or yourISP's IP range.    ufw allow from 192.230.176.0/20 proto tcp to any port 22I allow my local ISP's range because I have a DHCP lease from them and I gettired of logging into my server from my hosting provider's UI to update it. It'sgood enough security and much better than nothing!The same `git push server master` post-receive hook flow used by the rest ofmy projects:Server:    apk update && apk upgrade && apk add docker docker-compose caddy git iptables ip6tables ufw    ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable    echo -e "#!/bin/sh\napk upgrade --update | sed \"s/^/[\`date\`] /\" >> /var/log/apk-autoupgrade.log" > /etc/periodic/daily/apk-autoupgrade && chmod 700 /etc/periodic/daily/apk-autoupgrade    rc-update add docker boot && service docker start    mkdir -p /srv/git/status.git && cd /srv/git/status.git && git init --bareLocal:    git clone git@github.com:overshard/status.git && cd status    git remote remove origin && git remote add origin root@status.bythewood.me:/srv/git/status.git    git push --set-upstream origin master    git remote add server root@status.example.com:/srv/git/status.git    git push --set-upstream server masterServer:    mkdir -p /srv/docker && cd /srv/docker && git clone /srv/git/status.git status && cd /srv/docker/status    cp samplefiles/Caddyfile.sample /etc/caddy/Caddyfile && sed -i 's/status.example.com/status.bythewood.me/g' /etc/caddy/Caddyfile    cp samplefiles/env.sample .env && sed -i 's/status.example.com/status.bythewood.me/g' .env    cp samplefiles/post-receive.sample /srv/git/status.git/hooks/post-receive    mkdir -p /srv/data/status/db && chown -R 1000:1000 /srv/data/status    docker-compose up --build --detach && docker-compose run web python3 manage.py migrate --noinput && docker-compose run web sqlite3 db.sqlite3 "PRAGMA journal_mode=WAL;" ".exit"    cp samplefiles/Caddyfile.sample /etc/caddy/Caddyfile    cp samplefiles/env.sample .env  # edit STATUS_PASSWORD, BASE_URL, ALERT_EMAIL, DISCORD_WEBHOOK_URL    cp samplefiles/post-receive.sample /srv/git/status.git/hooks/post-receive && chmod +x /srv/git/status.git/hooks/post-receive    mkdir -p /srv/data/status && chown -R 1000:1000 /srv/data/status    docker-compose up --build --detach    rc-update add caddy boot && service caddy start## Scaling## BackupsI choose to use an sqlite3 database since that handles all my usecases justfine. My first recommendation for scaling this project would be to use aPostgreSQL database. If you want to get fancy then a time-series database likeTimescale would make a lot of sense. The foundation of this project is pureDjango so it shouldn't be hard to swap in a different database.All data is stored in `/srv/data/status/` and your repo is in`/srv/git/status.git/`. Back up both of those folders and you have a completebackup. The `Caddyfile` and `.env` are easy enough to recreate but back themup too if you want to be thorough.## SupportI won't be providing any user support for this project. I'm more than happy toaccept good pull requests and fix bugs but I don't have the time to help peoplerun or use this project. I appologize in advance for this. Maintainingmutliple OSS projects has taught me that I need to step back from trying toprovide support to avoid burnout.I won't be providing user support for this project. I'm happy to accept goodpull requests and fix bugs but I don't have time to help people run or usethis project.
deleted TODO.md
@@ -1,37 +0,0 @@# TODO## Upgrade SQLite to ≥3.51.3 to close the WAL-reset corruption bugOn 2026-04-19 the production database corrupted with `database disk image ismalformed` (recovered via `sqlite3 .recover` on 2026-04-26). Root cause was atwo-part interaction:1. **SQLite WAL-reset bug** (introduced 3.7.0, fixed in **3.51.3** released   2026-03-13). Triggered when two or more connections on the same file   write/checkpoint simultaneously. The scheduler runs 4 worker threads with   their own Django connections, plus gunicorn — exactly the trigger pattern.2. **`PRAGMA mmap_size=128MB`** amplified a transient WAL inconsistency into   structural corruption (`Child page depth differs`, `2nd reference to page X`   — classic mmap-spread signatures). SQLite docs explicitly warn against mmap   with multi-process writers.Mitigations already applied (commit `dca4328`): dropped `mmap_size`, reducedgunicorn to 1 worker. mmap removal is the meaningful fix — it eliminates theamplifier so a transient WAL race is self-correcting. **The bug itself isstill latent** because we're stuck on SQLite 3.48.0 (Alpine 3.21).### Path forward, by preference- **Wait for Alpine 3.23** to ship SQLite ≥3.51.3 (likely May–June 2026), then  bump `FROM alpine:3.21` in the Dockerfile. Zero code change.- If it recurs before Alpine 3.23 lands: build SQLite from source in the  Dockerfile and `LD_PRELOAD` it, or switch the DB to Postgres (analytics  already runs on it).### Versions checked 2026-04-26- Alpine 3.21: sqlite-libs 3.48.0 (vulnerable, currently in use)- Alpine 3.22: sqlite-libs 3.49.2 (vulnerable)- Alpine edge: sqlite-libs 3.53.0 (fixed, but edge isn't appropriate for prod)- `pysqlite3-binary` 0.5.4.post2: bundles 3.51.1 (vulnerable; package's last  release predates the SQLite fix)
deleted accounts/__init__.py
deleted accounts/admin.py
@@ -1,11 +0,0 @@from django.contrib import adminfrom django.contrib.auth.models import Groupfrom django.contrib.auth.admin import UserAdminfrom .models import Useradmin.site.register(User, UserAdmin)admin.site.unregister(Group)
deleted accounts/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass AccountsConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'accounts'
deleted accounts/forms.py
@@ -1,9 +0,0 @@from django import formsfrom .models import Userclass UserForm(forms.ModelForm):    class Meta:        model = User        fields = ('username', 'email', 'discord_webhook_url')
deleted accounts/migrations/0001_initial.py
@@ -1,45 +0,0 @@# Generated by Django 4.0.4 on 2022-05-14 18:00import django.contrib.auth.modelsimport django.contrib.auth.validatorsfrom django.db import migrations, modelsimport django.utils.timezoneimport uuidclass Migration(migrations.Migration):    initial = True    dependencies = [        ('auth', '0012_alter_user_first_name_max_length'),    ]    operations = [        migrations.CreateModel(            name='User',            fields=[                ('password', models.CharField(max_length=128, verbose_name='password')),                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),            ],            options={                'verbose_name': 'user',                'verbose_name_plural': 'users',                'abstract': False,            },            managers=[                ('objects', django.contrib.auth.models.UserManager()),            ],        ),    ]
deleted accounts/migrations/0002_initial_data.py
@@ -1,21 +0,0 @@from django.db import migrationsdef create_superuser(apps, schema_editor):    """    Makes the initial admin superuser with the username admin and the password    admin.    """    User = apps.get_model('accounts', 'User')    User.objects.create_superuser(username='admin', password='admin')class Migration(migrations.Migration):    dependencies = [        ('accounts', '0001_initial'),    ]    operations = [        migrations.RunPython(create_superuser),    ]
deleted accounts/migrations/0003_user_discord_webhook_url.py
@@ -1,18 +0,0 @@# Generated by Django 4.0.5 on 2022-06-19 23:42from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('accounts', '0002_initial_data'),    ]    operations = [        migrations.AddField(            model_name='user',            name='discord_webhook_url',            field=models.URLField(blank=True, null=True),        ),    ]
deleted accounts/migrations/__init__.py
deleted accounts/models.py
@@ -1,31 +0,0 @@import uuidfrom django.db import modelsfrom django.contrib.auth.models import AbstractUserclass User(AbstractUser):    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)    discord_webhook_url = models.URLField(blank=True, null=True)    def __str__(self):        return self.username    @property    def total_properties(self):        return self.properties.count()    @property    def total_checks(self):        total_checks = 0        for property in self.properties.all():            total_checks += property.total_checks        return total_checks    @property    def total_properties_down(self):        total_properties_down = 0        for property in self.properties.all():            total_properties_down += property.current_status != 200        return total_properties_down
deleted accounts/static_src/index.js
deleted accounts/templates/accounts/profile.html
@@ -1,44 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">operator · {{ request.user.username }}</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Account identifiers and alert channels. Changes save on submit.</p>      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value }}" />          <label for="id_username">Username</label>        </div>        <div class="form-floating mb-3">          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value }}" />          <label for="id_email">Email</label>        </div>        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.discord_webhook_url.errors %}is-invalid{% endif %}" name="discord_webhook_url" id="id_discord_webhook_url" placeholder="Discord webhook URL" value="{{ form.discord_webhook_url.value|default:'' }}" />          <label for="id_discord_webhook_url">Discord webhook URL</label>        </div>        <div class="d-flex gap-2">          <button type="submit" class="btn btn-primary">Save →</button>          <a href="{% url 'password_change' %}" class="btn btn-outline-light">Change password</a>        </div>      </form>    </div>  </div></div>{% endblock %}
deleted accounts/urls.py
@@ -1,59 +0,0 @@from django.contrib.auth import views as auth_viewsfrom django.urls import pathfrom . import viewsurlpatterns = [    path(        "login/",        auth_views.LoginView.as_view(extra_context={"title": "Login"}),        name="login",    ),    path(        "logout/",        auth_views.LogoutView.as_view(extra_context={"title": "Logout"}),        name="logout",    ),    path(        "password-change/",        auth_views.PasswordChangeView.as_view(            extra_context={"title": "Password change"}        ),        name="password_change",    ),    path(        "password_change/done/",        auth_views.PasswordChangeDoneView.as_view(            extra_context={"title": "Password change done"}        ),        name="password_change_done",    ),    path(        "password-reset/",        auth_views.PasswordResetView.as_view(extra_context={"title": "Password reset"}),        name="password_reset",    ),    path(        "password-reset/done/",        auth_views.PasswordResetDoneView.as_view(            extra_context={"title": "Password reset done"}        ),        name="password_reset_done",    ),    path(        "reset/<uidb64>/<token>/",        auth_views.PasswordResetConfirmView.as_view(            extra_context={"title": "Password reset confirm"}        ),        name="password_reset_confirm",    ),    path(        "reset/done/",        auth_views.PasswordResetCompleteView.as_view(            extra_context={"title": "Password reset complete"}        ),        name="password_reset_complete",    ),    path("profile/", views.profile, name="profile"),]
deleted accounts/views.py
@@ -1,19 +0,0 @@from django.shortcuts import renderfrom django.contrib import messagesfrom .forms import UserFormdef profile(request):    if request.method == "POST":        form = UserForm(request.POST, instance=request.user)        if form.is_valid():            form.save()            messages.success(request, "Your profile was successfully updated!")    else:        form = UserForm(instance=request.user)    return render(        request,        "accounts/profile.html",        {"form": form, "title": "Profile", "description": "Update your profile."},    )
deleted bun.lock
@@ -1,702 +0,0 @@{  "lockfileVersion": 1,  "configVersion": 1,  "workspaces": {    "": {      "dependencies": {        "@fontsource/monaspace-argon": "^5.2.5",        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "chart.js": "^3.7.1",        "d3": "^7.4.5",        "datamaps": "^0.5.9",        "js-cookie": "^3.0.1",        "lighthouse": "^9.6.3",        "topojson": "^3.0.2",      },      "devDependencies": {        "sass": "^1.53.0",        "vite": "^6.3.1",      },    },  },  "packages": {    "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],    "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],    "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],    "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],    "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],    "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],    "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],    "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],    "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],    "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],    "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],    "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],    "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],    "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],    "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],    "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],    "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],    "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],    "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],    "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],    "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],    "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],    "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],    "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],    "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@fontsource/monaspace-argon": ["@fontsource/monaspace-argon@5.2.5", "", {}, "sha512-EJ+jq1Smm3BB+8RK/gwB1uzjrSKdycZkKm8OZGCYvqJiTChKcGe7b2lmj0PmQbb/+lAEdCBIAcFLlPaWhtRANA=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],    "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],    "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],    "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],    "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],    "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],    "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],    "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],    "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],    "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],    "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],    "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],    "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],    "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],    "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],    "@sentry/core": ["@sentry/core@6.19.7", "", { "dependencies": { "@sentry/hub": "6.19.7", "@sentry/minimal": "6.19.7", "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", "tslib": "^1.9.3" } }, "sha512-tOfZ/umqB2AcHPGbIrsFLcvApdTm9ggpi/kQZFkej7kMphjT+SGBiQfYtjyg9jcRW+ilAR4JXC9BGKsdEQ+8Vw=="],    "@sentry/hub": ["@sentry/hub@6.19.7", "", { "dependencies": { "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", "tslib": "^1.9.3" } }, "sha512-y3OtbYFAqKHCWezF0EGGr5lcyI2KbaXW2Ik7Xp8Mu9TxbSTuwTe4rTntwg8ngPjUQU3SUHzgjqVB8qjiGqFXCA=="],    "@sentry/minimal": ["@sentry/minimal@6.19.7", "", { "dependencies": { "@sentry/hub": "6.19.7", "@sentry/types": "6.19.7", "tslib": "^1.9.3" } }, "sha512-wcYmSJOdvk6VAPx8IcmZgN08XTXRwRtB1aOLZm+MVHjIZIhHoBGZJYTVQS/BWjldsamj2cX3YGbGXNunaCfYJQ=="],    "@sentry/node": ["@sentry/node@6.19.7", "", { "dependencies": { "@sentry/core": "6.19.7", "@sentry/hub": "6.19.7", "@sentry/types": "6.19.7", "@sentry/utils": "6.19.7", "cookie": "^0.4.1", "https-proxy-agent": "^5.0.0", "lru_map": "^0.3.3", "tslib": "^1.9.3" } }, "sha512-gtmRC4dAXKODMpHXKfrkfvyBL3cI8y64vEi3fDD046uqYcrWdgoQsffuBbxMAizc6Ez1ia+f0Flue6p15Qaltg=="],    "@sentry/types": ["@sentry/types@6.19.7", "", {}, "sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg=="],    "@sentry/utils": ["@sentry/utils@6.19.7", "", { "dependencies": { "@sentry/types": "6.19.7", "tslib": "^1.9.3" } }, "sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA=="],    "@types/d3": ["@types/d3@3.5.38", "", {}, "sha512-O/gRkjWULp3xVX8K85V0H3tsSGole0WYt77KVpGZO2xTGLuVFuvE6JIsIli3fvFHCYBhGFn/8OHEEyMYF+QehA=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],    "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],    "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],    "acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],    "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],    "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],    "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],    "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],    "axe-core": ["axe-core@4.4.1", "", {}, "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw=="],    "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],    "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],    "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],    "bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="],    "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],    "brfs": ["brfs@1.6.1", "", { "dependencies": { "quote-stream": "^1.0.1", "resolve": "^1.1.5", "static-module": "^2.2.0", "through2": "^2.0.0" }, "bin": { "brfs": "bin/cmd.js" } }, "sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ=="],    "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],    "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],    "buffer-equal": ["buffer-equal@0.0.1", "", {}, "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA=="],    "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],    "chart.js": ["chart.js@3.9.1", "", {}, "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w=="],    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],    "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],    "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],    "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],    "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],    "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],    "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],    "concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],    "configstore": ["configstore@5.0.1", "", { "dependencies": { "dot-prop": "^5.2.0", "graceful-fs": "^4.1.2", "make-dir": "^3.0.0", "unique-string": "^2.0.0", "write-file-atomic": "^3.0.0", "xdg-basedir": "^4.0.0" } }, "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA=="],    "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],    "cookie": ["cookie@0.4.2", "", {}, "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="],    "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],    "cross-fetch": ["cross-fetch@3.1.5", "", { "dependencies": { "node-fetch": "2.6.7" } }, "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw=="],    "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],    "csp_evaluator": ["csp_evaluator@1.1.1", "", {}, "sha512-N3ASg0C4kNPUaNxt1XAvzHIVuzdtr8KLgfk1O8WDyimp1GisPAHESupArO2ieHk9QWbrJ/WkQODyh21Ps/xhxw=="],    "cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="],    "cssstyle": ["cssstyle@1.2.1", "", { "dependencies": { "cssom": "0.3.x" } }, "sha512-7DYm8qe+gPx/h77QlCyFmX80+fGaE/6A/Ekl0zaszYOubvySO2saYFdQ78P29D0UsULxFKCetDGNaNRUdSF+2A=="],    "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],    "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],    "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],    "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],    "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],    "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],    "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],    "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],    "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],    "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],    "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],    "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],    "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],    "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],    "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],    "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],    "d3-geo-projection": ["d3-geo-projection@0.2.16", "", { "dependencies": { "brfs": "^1.3.0" } }, "sha512-NB4/NRMnfJnpodvRbNY/nOzuoU17P229ASYf2l1GwjZyfD7l5aIuMylDMbIBF4y42BGZZvGdUwFW8iFM/5UBzg=="],    "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],    "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],    "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],    "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],    "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],    "d3-queue": ["d3-queue@2.0.3", "", {}, "sha512-ejbdHqZYEmk9ns/ljSbEcD6VRiuNwAkZMdFf6rsUb3vHROK5iMFd8xewDQnUVr6m/ba2BG63KmR/LySfsluxbg=="],    "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],    "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],    "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],    "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],    "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],    "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],    "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],    "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],    "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],    "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],    "datamaps": ["datamaps@0.5.10", "", { "dependencies": { "@types/d3": "3.5.38", "d3": "^3.5.6", "topojson": "^1.6.19" } }, "sha512-FU9J8oXQlGuwALvf3LVno/jKSLiK4uTPcbrSfIRcVBwXCZ7I2KMSmj8kqW4rvBvSbd7Sk5oBxy4PTjSVq+XrMw=="],    "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],    "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],    "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],    "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "devtools-protocol": ["devtools-protocol@0.0.981744", "", {}, "sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg=="],    "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],    "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],    "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],    "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],    "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],    "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],    "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],    "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],    "escodegen": ["escodegen@1.9.1", "", { "dependencies": { "esprima": "^3.1.3", "estraverse": "^4.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "./bin/esgenerate.js", "escodegen": "./bin/escodegen.js" } }, "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q=="],    "esprima": ["esprima@3.1.3", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-AWwVMNxwhN8+NIPQzAQZCm7RkLC4RbM3B1OobMuyp3i+w73X57KCKaVIxaRZb+DYCojq7rspo+fmuQfAboyhFg=="],    "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],    "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],    "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],    "falafel": ["falafel@2.2.5", "", { "dependencies": { "acorn": "^7.1.1", "isarray": "^2.0.1" } }, "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ=="],    "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],    "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],    "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],    "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],    "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],    "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],    "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],    "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],    "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],    "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],    "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],    "has": ["has@1.0.4", "", {}, "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ=="],    "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],    "http-link-header": ["http-link-header@0.8.0", "", {}, "sha512-qsh/wKe1Mk1vtYEFr+LpQBFWTO1gxZQBdii2D0Umj+IUQ23r5sT088Rhpq4XzpSyIpaX7vwjB8Rrtx8u9JTg+Q=="],    "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],    "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],    "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],    "image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="],    "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],    "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],    "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],    "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],    "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],    "intl-messageformat": ["intl-messageformat@4.4.0", "", { "dependencies": { "intl-messageformat-parser": "^1.8.1" } }, "sha512-z+Bj2rS3LZSYU4+sNitdHrwnBhr0wO80ZJSW8EzKDBowwUe3Q/UsvgCGjrwa+HPzoGCLEb9HAjfJgo4j2Sac8w=="],    "intl-messageformat-parser": ["intl-messageformat-parser@1.8.1", "", {}, "sha512-IMSCKVf0USrM/959vj3xac7s8f87sc+80Y/ipBzdKy4ifBv5Gsj2tZ41EAaURVg01QU71fYr77uA8Meh6kELbg=="],    "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],    "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],    "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],    "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],    "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],    "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],    "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="],    "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],    "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],    "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],    "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],    "js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="],    "levn": ["levn@0.3.0", "", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="],    "lighthouse": ["lighthouse@9.6.8", "", { "dependencies": { "@sentry/node": "^6.17.4", "axe-core": "4.4.1", "chrome-launcher": "^0.15.0", "configstore": "^5.0.1", "csp_evaluator": "1.1.1", "cssstyle": "1.2.1", "enquirer": "^2.3.6", "http-link-header": "^0.8.0", "intl-messageformat": "^4.4.0", "jpeg-js": "^0.4.3", "js-library-detector": "^6.5.0", "lighthouse-logger": "^1.3.0", "lighthouse-stack-packs": "1.8.2", "lodash": "^4.17.21", "lookup-closest-locale": "6.2.0", "metaviewport-parser": "0.2.0", "open": "^8.4.0", "parse-cache-control": "1.0.1", "ps-list": "^8.0.0", "puppeteer-core": "^13.7.0", "robots-parser": "^3.0.0", "semver": "^5.3.0", "speedline-core": "^1.4.3", "third-party-web": "^0.17.1", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "lighthouse-cli/index.js", "smokehouse": "lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "lighthouse-core/scripts/manual-chrome-launcher.js" } }, "sha512-5aRSvnqazci8D2oE7GJM6C7IStvUuMVV+74cGyBuS4n4NCixsDd6+uJdX834XiInSfo+OuVbAJCX4Xu6d2+N9Q=="],    "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],    "lighthouse-stack-packs": ["lighthouse-stack-packs@1.8.2", "", {}, "sha512-vlCUxxQAB8Nu6LQHqPpDRiMi06Du593/my/6JbMttQeEfJ7pf4OS8obSTh5xSOS80U/O7fq59Q8rQGAUxQatUQ=="],    "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],    "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],    "lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="],    "lru_map": ["lru_map@0.3.3", "", {}, "sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ=="],    "magic-string": ["magic-string@0.22.5", "", { "dependencies": { "vlq": "^0.2.2" } }, "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w=="],    "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],    "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],    "merge-source-map": ["merge-source-map@1.0.4", "", { "dependencies": { "source-map": "^0.5.6" } }, "sha512-PGSmS0kfnTnMJCzJ16BLLCEe6oeYCamKFFdQKshi4BmM6FUwipjVOcBFGxqtQtirtAG4iZvHlqST9CpZKqlRjA=="],    "metaviewport-parser": ["metaviewport-parser@0.2.0", "", {}, "sha512-qL5NtY18LGs7lvZCkj3ep2H4Pes9rIiSLZRUyfDdvVw7pWFA0eLwmqaIxApD74RGvUrNEtk9e5Wt1rT+VlCvGw=="],    "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],    "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],    "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],    "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],    "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],    "node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],    "object-inspect": ["object-inspect@1.4.1", "", {}, "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw=="],    "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],    "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],    "optimist": ["optimist@0.3.7", "", { "dependencies": { "wordwrap": "~0.0.2" } }, "sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ=="],    "optionator": ["optionator@0.8.3", "", { "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", "levn": "~0.3.0", "prelude-ls": "~1.1.2", "type-check": "~0.3.2", "word-wrap": "~1.2.3" } }, "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA=="],    "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],    "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],    "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],    "parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],    "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],    "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],    "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],    "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],    "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],    "prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],    "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],    "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],    "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],    "ps-list": ["ps-list@8.1.1", "", {}, "sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ=="],    "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],    "puppeteer-core": ["puppeteer-core@13.7.0", "", { "dependencies": { "cross-fetch": "3.1.5", "debug": "4.3.4", "devtools-protocol": "0.0.981744", "extract-zip": "2.0.1", "https-proxy-agent": "5.0.1", "pkg-dir": "4.2.0", "progress": "2.0.3", "proxy-from-env": "1.1.0", "rimraf": "3.0.2", "tar-fs": "2.1.1", "unbzip2-stream": "1.4.3", "ws": "8.5.0" } }, "sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q=="],    "quote-stream": ["quote-stream@1.0.2", "", { "dependencies": { "buffer-equal": "0.0.1", "minimist": "^1.1.3", "through2": "^2.0.0" }, "bin": { "quote-stream": "bin/cmd.js" } }, "sha512-kKr2uQ2AokadPjvTyKJQad9xELbZwYzWlNfI3Uz2j/ib5u6H9lDP7fUUR//rMycd0gv4Z5P1qXMfXR8YpIxrjQ=="],    "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],    "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],    "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],    "robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],    "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],    "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],    "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],    "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],    "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],    "semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],    "shallow-copy": ["shallow-copy@0.0.1", "", {}, "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw=="],    "shapefile": ["shapefile@0.3.1", "", { "dependencies": { "d3-queue": "1", "iconv-lite": "0.2", "optimist": "0.3" }, "bin": { "dbfcat": "./bin/dbfcat", "shpcat": "./bin/shpcat", "shp2json": "./bin/shp2json" } }, "sha512-BZoPvnq4ULce0pyKiZUU4D8CdPl0Z1fpE73AeCkwyMbD2hpUeVA0s7jIE/wX8uWNruVeJV6e+rznPHBwuH5J6g=="],    "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],    "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],    "speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="],    "static-eval": ["static-eval@2.1.1", "", { "dependencies": { "escodegen": "^2.1.0" } }, "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA=="],    "static-module": ["static-module@2.2.5", "", { "dependencies": { "concat-stream": "~1.6.0", "convert-source-map": "^1.5.1", "duplexer2": "~0.1.4", "escodegen": "~1.9.0", "falafel": "^2.1.0", "has": "^1.0.1", "magic-string": "^0.22.4", "merge-source-map": "1.0.4", "object-inspect": "~1.4.0", "quote-stream": "~1.0.2", "readable-stream": "~2.3.3", "shallow-copy": "~0.0.1", "static-eval": "^2.0.0", "through2": "~2.0.3" } }, "sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ=="],    "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],    "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],    "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],    "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],    "tar-fs": ["tar-fs@2.1.1", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng=="],    "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],    "third-party-web": ["third-party-web@0.17.1", "", {}, "sha512-X9Mha8cVeBwakunlZXkXL6xRzw8VCcDGWqT59EzeTYAJIi8ien3CuufnEGEx4ZUFahumNQdoOwf4H2T9Ca6lBg=="],    "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="],    "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],    "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],    "topojson": ["topojson@3.0.2", "", { "dependencies": { "topojson-client": "3.0.0", "topojson-server": "3.0.0", "topojson-simplify": "3.0.2" }, "bin": { "geo2topo": "node_modules/topojson-server/bin/geo2topo", "toposimplify": "node_modules/topojson-simplify/bin/toposimplify", "topo2geo": "node_modules/topojson-client/bin/topo2geo", "topomerge": "node_modules/topojson-client/bin/topomerge", "topoquantize": "node_modules/topojson-client/bin/topoquantize" } }, "sha512-u3zeuL6WEVL0dmsRn7uHZKc4Ao4gpW3sORUv+N3ezLTvY3JdCuyg0hvpWiIfFw8p/JwVN++SvAsFgcFEeR15rQ=="],    "topojson-client": ["topojson-client@3.0.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-2phZ98wg/iKvsWxbB6JQcq0/N0f+sRx8ZogdvjCg+CjaJdmV0knP0OQwK5XbgnytAPx5lPZk41kiWpgH2w9FHg=="],    "topojson-server": ["topojson-server@3.0.0", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-UhhwQk4e2+lwhAVYkja3J5nQHQmKwORDuIQPkMnFFZFcLqWKLQWI3u7fZWtNIXTElBjTYdBUL1kzi1+oS/qDQw=="],    "topojson-simplify": ["topojson-simplify@3.0.2", "", { "dependencies": { "commander": "2", "topojson-client": "3" }, "bin": { "toposimplify": "bin/toposimplify" } }, "sha512-gyYSVRt4jO/0RJXKZQPzTDQRWV+D/nOfiljNUv0HBXslFLtq3yxRHrl7jbrjdbda5Ytdr7M8BZUI4OxU7tnbRQ=="],    "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],    "tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],    "type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],    "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],    "typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="],    "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="],    "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],    "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="],    "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],    "vlq": ["vlq@0.2.3", "", {}, "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow=="],    "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],    "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],    "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],    "wordwrap": ["wordwrap@0.0.3", "", {}, "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw=="],    "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],    "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],    "write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", "signal-exit": "^3.0.2", "typedarray-to-buffer": "^3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="],    "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],    "xdg-basedir": ["xdg-basedir@4.0.0", "", {}, "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q=="],    "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],    "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],    "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],    "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],    "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],    "agent-base/debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="],    "concat-stream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],    "datamaps/d3": ["d3@3.5.17", "", {}, "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg=="],    "datamaps/topojson": ["topojson@1.6.27", "", { "dependencies": { "d3": "3", "d3-geo-projection": "0.2", "d3-queue": "2", "optimist": "0.3", "rw": "1", "shapefile": "0.3" }, "bin": { "topojson": "./bin/topojson", "topojson-geojson": "./bin/topojson-geojson", "topojson-group": "./bin/topojson-group", "topojson-merge": "./bin/topojson-merge", "topojson-svg": "./bin/topojson-svg" } }, "sha512-JLFtrhClUH/k/yvsiCXqcWcXaOfO3DgFvHnYb+gS2xlDbjbvkKh6YB1CPilmEV++tH33xw6wCxoYA5g6YLZw/Q=="],    "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],    "extract-zip/debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="],    "https-proxy-agent/debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="],    "make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],    "merge-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],    "puppeteer-core/debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="],    "puppeteer-core/ws": ["ws@8.5.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg=="],    "shapefile/d3-queue": ["d3-queue@1.2.3", "", {}, "sha512-m6KtxX4V5pmVf1PqhH4SkQVMshSJfyCLM2vf2oFPi9FWFVT3+rtbCGerk766b/JXymHQDU3oqXHaZoiQ/e8yUQ=="],    "shapefile/iconv-lite": ["iconv-lite@0.2.11", "", {}, "sha512-KhmFWgaQZY83Cbhi+ADInoUQ8Etn6BG5fikM9syeOjQltvR45h7cRKJ/9uvQEuD61I3Uju77yYce0/LhKVClQw=="],    "static-eval/escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],    "static-module/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],    "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],    "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "topojson-simplify/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],    "agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],    "concat-stream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],    "duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],    "extract-zip/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],    "https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],    "puppeteer-core/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],    "static-eval/escodegen/esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],    "static-eval/escodegen/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],    "static-module/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],    "through2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],  }}
deleted crawler/__init__.py
deleted crawler/checks.py
@@ -1,760 +0,0 @@"""SEO / accessibility / performance / content / security checks.Every check takes a single `ctx` dict and returns a list of insight dicts.Insight shape: {url, issue, item, type, severity}"""from urllib.parse import urlparsefrom .fetcher import same_siteTYPE_SEO = "seo"TYPE_LINKS = "links"TYPE_ACCESSIBILITY = "accessibility"TYPE_CONTENT = "content"TYPE_PERFORMANCE = "performance"TYPE_SECURITY = "security"SEVERITY_ERROR = "error"SEVERITY_WARNING = "warning"SEVERITY_INFO = "info"REDIRECT_CODES = {301, 302, 303, 307, 308}def _insight(url, issue, type_, severity, item=""):    return {        "url": url,        "issue": issue,        "item": item,        "type": type_,        "severity": severity,    }def _normalize(s):    return " ".join(s.lower().split()) if s else ""def _group_by(pages, field):    seen = {}    for p in pages:        val = p.get(field, "")        if val:            seen.setdefault(_normalize(val), []).append(p)    return seen# ---------- core metadata ----------def check_title_missing(ctx):    return [        _insight(p["url"], "Page has no title", TYPE_SEO, SEVERITY_ERROR)        for p in ctx["html_pages"]        if not p.get("title")    ]def check_title_length(ctx):    out = []    for p in ctx["html_pages"]:        t = p.get("title", "")        if t and not (30 <= len(t) <= 60):            out.append(                _insight(                    p["url"],                    f"Title length is {len(t)} chars (recommended 30-60)",                    TYPE_SEO,                    SEVERITY_WARNING,                    item=t,                )            )    return outdef check_duplicate_titles(ctx):    out = []    for _, group in _group_by(ctx["html_pages"], "title").items():        if len(group) > 1:            for p in group:                out.append(                    _insight(                        p["url"],                        "Duplicate title",                        TYPE_SEO,                        SEVERITY_WARNING,                        item=p["title"],                    )                )    return outdef check_description_missing(ctx):    return [        _insight(p["url"], "Page has no meta description", TYPE_SEO, SEVERITY_ERROR)        for p in ctx["html_pages"]        if not p.get("description")    ]def check_description_length(ctx):    out = []    for p in ctx["html_pages"]:        d = p.get("description", "")        if d and not (70 <= len(d) <= 160):            out.append(                _insight(                    p["url"],                    f"Description length is {len(d)} chars (recommended 70-160)",                    TYPE_SEO,                    SEVERITY_WARNING,                    item=d,                )            )    return outdef check_duplicate_descriptions(ctx):    out = []    for _, group in _group_by(ctx["html_pages"], "description").items():        if len(group) > 1:            for p in group:                out.append(                    _insight(                        p["url"],                        "Duplicate meta description",                        TYPE_SEO,                        SEVERITY_WARNING,                        item=p["description"],                    )                )    return outdef check_h1_missing(ctx):    return [        _insight(p["url"], "Page has no h1", TYPE_SEO, SEVERITY_ERROR)        for p in ctx["html_pages"]        if not p.get("headings", {}).get("h1")    ]def check_h1_multiple(ctx):    out = []    for p in ctx["html_pages"]:        h1s = p.get("headings", {}).get("h1", [])        if len(h1s) > 1:            out.append(                _insight(                    p["url"],                    f"Page has {len(h1s)} h1 tags (expected 1)",                    TYPE_SEO,                    SEVERITY_WARNING,                    item=" | ".join(h1s[:3]),                )            )    return outdef check_h1_length(ctx):    out = []    for p in ctx["html_pages"]:        h1s = p.get("headings", {}).get("h1", [])        if h1s and not (20 <= len(h1s[0]) <= 70):            out.append(                _insight(                    p["url"],                    f"H1 length is {len(h1s[0])} chars (recommended 20-70)",                    TYPE_SEO,                    SEVERITY_WARNING,                    item=h1s[0],                )            )    return outdef check_duplicate_h1s(ctx):    out = []    buckets = {}    for p in ctx["html_pages"]:        h1s = p.get("headings", {}).get("h1", [])        if h1s:            buckets.setdefault(_normalize(h1s[0]), []).append((p["url"], h1s[0]))    for _, group in buckets.items():        if len(group) > 1:            for url, item in group:                out.append(_insight(url, "Duplicate h1", TYPE_SEO, SEVERITY_WARNING, item=item))    return outdef check_heading_hierarchy(ctx):    out = []    for p in ctx["html_pages"]:        h = p.get("headings", {})        levels = [lvl for lvl in range(1, 7) if h.get(f"h{lvl}")]        for i in range(1, len(levels)):            if levels[i] - levels[i - 1] > 1:                out.append(                    _insight(                        p["url"],                        f"Heading hierarchy skips from h{levels[i - 1]} to h{levels[i]}",                        TYPE_SEO,                        SEVERITY_INFO,                    )                )                break    return outdef check_canonical_missing(ctx):    return [        _insight(p["url"], "Page has no canonical URL", TYPE_SEO, SEVERITY_WARNING)        for p in ctx["html_pages"]        if not p.get("canonical")    ]def check_canonical_offdomain(ctx):    out = []    host = ctx["host"]    for p in ctx["html_pages"]:        c = p.get("canonical", "")        if c and not same_site(c, host):            out.append(                _insight(                    p["url"],                    "Canonical URL points off-domain",                    TYPE_SEO,                    SEVERITY_WARNING,                    item=c,                )            )    return outdef check_canonical_broken(ctx):    out = []    status_map = ctx["status_map"]    for p in ctx["html_pages"]:        c = p.get("canonical", "")        if c and c in status_map and status_map[c] != 200:            out.append(                _insight(                    p["url"],                    f"Canonical URL returns {status_map[c]}",                    TYPE_SEO,                    SEVERITY_ERROR,                    item=c,                )            )    return outdef check_robots_meta_noindex(ctx):    out = []    for p in ctx["html_pages"]:        rm = (p.get("robots_meta") or "").lower()        if "noindex" in rm:            out.append(                _insight(                    p["url"],                    "Page has noindex in meta robots tag",                    TYPE_SEO,                    SEVERITY_WARNING,                    item=p.get("robots_meta", ""),                )            )    return outdef check_lang_missing(ctx):    return [        _insight(p["url"], "HTML lang attribute missing", TYPE_SEO, SEVERITY_WARNING)        for p in ctx["html_pages"]        if not p.get("lang")    ]def check_viewport_missing(ctx):    return [        _insight(p["url"], "Viewport meta tag missing (mobile)", TYPE_SEO, SEVERITY_WARNING)        for p in ctx["html_pages"]        if not p.get("viewport")    ]def check_og_incomplete(ctx):    out = []    for p in ctx["html_pages"]:        og = p.get("og") or {}        missing = [k for k in ("title", "description", "image", "url") if not og.get(k)]        if missing:            out.append(                _insight(                    p["url"],                    f"Open Graph tags missing: {', '.join('og:' + m for m in missing)}",                    TYPE_SEO,                    SEVERITY_INFO,                )            )    return outdef check_twitter_card(ctx):    return [        _insight(p["url"], "Twitter card meta tag missing", TYPE_SEO, SEVERITY_INFO)        for p in ctx["html_pages"]        if not (p.get("twitter") or {}).get("card")    ]def check_favicon(ctx):    return [        _insight(p["url"], "Favicon link missing", TYPE_SEO, SEVERITY_INFO)        for p in ctx["html_pages"]        if not p.get("favicon")    ]def check_json_ld_parse_error(ctx):    out = []    for p in ctx["html_pages"]:        for item in p.get("json_ld", []):            if item is None:                out.append(                    _insight(                        p["url"],                        "JSON-LD structured data failed to parse",                        TYPE_SEO,                        SEVERITY_WARNING,                    )                )                break    return out# ---------- links ----------def check_broken_internal_links(ctx):    out = []    reported = set()    status_map = ctx["status_map"]    host = ctx["host"]    for p in ctx["html_pages"]:        for link in p.get("links", []):            lu = link["url"]            if not same_site(lu, host):                continue            status = status_map.get(lu)            if status is None:                continue            if status != 200 and status not in REDIRECT_CODES:                key = (p["url"], lu)                if key in reported:                    continue                reported.add(key)                label = f"status {status}" if status else "unreachable"                out.append(                    _insight(                        p["url"],                        f"Broken internal link ({label})",                        TYPE_LINKS,                        SEVERITY_ERROR,                        item=lu,                    )                )    return outdef check_broken_external_links(ctx):    out = []    reported = set()    host = ctx["host"]    ext = ctx["external_link_status"]    for p in ctx["html_pages"]:        for link in p.get("links", []):            lu = link["url"]            if same_site(lu, host):                continue            if lu not in ext:                continue            status = ext[lu]            if status == 0 or status >= 400:                key = (p["url"], lu)                if key in reported:                    continue                reported.add(key)                label = f"status {status}" if status else "unreachable"                out.append(                    _insight(                        p["url"],                        f"Broken external link ({label})",                        TYPE_LINKS,                        SEVERITY_WARNING,                        item=lu,                    )                )    return outdef check_redirect_chains(ctx):    out = []    for p in ctx["pages"]:        chain = p.get("redirect_chain") or []        if len(chain) > 2:  # initial + final is fine; more means multiple hops            hops = len(chain) - 1            out.append(                _insight(                    p["url"],                    f"Redirect chain has {hops} hops",                    TYPE_LINKS,                    SEVERITY_INFO,                    item=" -> ".join(str(code) for code, _ in chain),                )            )    return outdef check_nofollow_internal_links(ctx):    out = []    reported = set()    host = ctx["host"]    for p in ctx["html_pages"]:        for link in p.get("links", []):            lu = link["url"]            if not same_site(lu, host):                continue            if "nofollow" in (link.get("rel") or []):                key = (p["url"], lu)                if key in reported:                    continue                reported.add(key)                out.append(                    _insight(                        p["url"],                        "Internal link has rel=nofollow",                        TYPE_LINKS,                        SEVERITY_INFO,                        item=lu,                    )                )    return out# ---------- robots / sitemap ----------def check_robots_missing(ctx):    if not ctx["robots"]["exists"]:        return [            _insight(                ctx["start_url"],                "robots.txt missing",                TYPE_SEO,                SEVERITY_WARNING,                item=ctx["robots"]["url"],            )        ]    return []def check_sitemap_missing(ctx):    if not ctx["sitemap_urls"]:        return [            _insight(                ctx["start_url"],                "sitemap.xml missing or empty",                TYPE_SEO,                SEVERITY_WARNING,            )        ]    return []def check_sitemap_not_in_robots(ctx):    if (        ctx["robots"]["exists"]        and ctx["sitemap_urls"]        and not ctx["robots"].get("references_sitemap")    ):        return [            _insight(                ctx["start_url"],                "robots.txt does not reference a sitemap",                TYPE_SEO,                SEVERITY_INFO,            )        ]    return []def check_sitemap_broken_urls(ctx):    out = []    status_map = ctx["status_map"]    for url in ctx["sitemap_urls"]:        s = status_map.get(url)        if s is not None and s != 200 and s not in REDIRECT_CODES:            out.append(                _insight(                    url,                    f"URL listed in sitemap returns {s}",                    TYPE_SEO,                    SEVERITY_ERROR,                )            )    return outdef check_pages_missing_from_sitemap(ctx):    if not ctx["sitemap_urls"]:        return []    sitemap_set = set(ctx["sitemap_urls"])    out = []    for p in ctx["html_pages"]:        if p["url"] in sitemap_set:            continue        # Ignore pages excluded by meta robots        if "noindex" in (p.get("robots_meta") or "").lower():            continue        out.append(            _insight(                p["url"],                "Page not listed in sitemap",                TYPE_SEO,                SEVERITY_INFO,            )        )    return out# ---------- accessibility ----------def check_images_missing_alt(ctx):    out = []    for p in ctx["html_pages"]:        missing = [img for img in p.get("images", []) if img.get("alt") is None]        if missing:            out.append(                _insight(                    p["url"],                    f"{len(missing)} image(s) missing alt attribute",                    TYPE_ACCESSIBILITY,                    SEVERITY_WARNING,                    item=missing[0].get("src", "")[:160],                )            )    return outdef check_empty_anchor_text(ctx):    out = []    for p in ctx["html_pages"]:        empty = [link for link in p.get("links", []) if not link.get("text")]        if empty:            out.append(                _insight(                    p["url"],                    f"{len(empty)} link(s) have no visible text",                    TYPE_ACCESSIBILITY,                    SEVERITY_INFO,                    item=empty[0].get("url", "")[:160],                )            )    return outdef check_form_inputs_unlabeled(ctx):    out = []    ignore_types = {"hidden", "submit", "button", "reset", "image"}    for p in ctx["html_pages"]:        for form in p.get("forms", []):            label_fors = set(form.get("label_fors", []))            unlabeled = 0            for i in form.get("inputs", []):                if (i.get("type") or "text").lower() in ignore_types:                    continue                if i.get("aria_label"):                    continue                if i.get("id") and i.get("id") in label_fors:                    continue                unlabeled += 1            if unlabeled:                out.append(                    _insight(                        p["url"],                        f"{unlabeled} form input(s) without associated label",                        TYPE_ACCESSIBILITY,                        SEVERITY_WARNING,                        item=form.get("action", ""),                    )                )                break  # one insight per page    return out# ---------- content ----------def check_thin_content(ctx):    out = []    for p in ctx["html_pages"]:        wc = p.get("word_count", 0)        if wc < 300:            out.append(                _insight(                    p["url"],                    f"Thin content ({wc} words)",                    TYPE_CONTENT,                    SEVERITY_WARNING,                )            )    return outdef check_duplicate_content(ctx):    out = []    buckets = {}    for p in ctx["html_pages"]:        th = p.get("text_hash")        if th:            buckets.setdefault(th, []).append(p["url"])    for urls in buckets.values():        if len(urls) > 1:            for u in urls:                other = next((x for x in urls if x != u), urls[0])                out.append(                    _insight(                        u,                        "Page has duplicate visible content with another page",                        TYPE_CONTENT,                        SEVERITY_WARNING,                        item=other,                    )                )    return out# ---------- performance ----------def check_slow_pages(ctx):    out = []    for p in ctx["pages"]:        if not p.get("is_html"):            continue        ms = p.get("elapsed_ms", 0)        if ms > 1000:            out.append(                _insight(                    p["url"],                    f"Slow response ({ms} ms)",                    TYPE_PERFORMANCE,                    SEVERITY_WARNING,                )            )    return outdef check_missing_compression(ctx):    out = []    for p in ctx["html_pages"]:        headers = p.get("headers") or {}        enc = ""        for k, v in headers.items():            if k.lower() == "content-encoding":                enc = (v or "").lower()                break        if not enc:            out.append(                _insight(                    p["url"],                    "Response not compressed (no Content-Encoding header)",                    TYPE_PERFORMANCE,                    SEVERITY_INFO,                )            )    return outdef check_oversized_pages(ctx):    out = []    for p in ctx["pages"]:        size = p.get("bytes", 0)        if size > 500_000:            out.append(                _insight(                    p["url"],                    f"Oversized page ({size // 1024} KB)",                    TYPE_PERFORMANCE,                    SEVERITY_WARNING,                )            )    return out# ---------- security (per-page; SecurityMixin covers site-level) ----------def check_mixed_content(ctx):    out = []    for p in ctx["html_pages"]:        if not p["url"].startswith("https://"):            continue        http_resources = [r for r in p.get("resources", []) if r.startswith("http://")]        if http_resources:            out.append(                _insight(                    p["url"],                    f"Mixed content: {len(http_resources)} http:// resource(s) on https:// page",                    TYPE_SECURITY,                    SEVERITY_WARNING,                    item=http_resources[0],                )            )    return outALL_CHECKS = [    # Core metadata    check_title_missing,    check_title_length,    check_duplicate_titles,    check_description_missing,    check_description_length,    check_duplicate_descriptions,    check_h1_missing,    check_h1_multiple,    check_h1_length,    check_duplicate_h1s,    check_heading_hierarchy,    check_canonical_missing,    check_canonical_offdomain,    check_canonical_broken,    check_robots_meta_noindex,    check_lang_missing,    check_viewport_missing,    check_og_incomplete,    check_twitter_card,    check_favicon,    check_json_ld_parse_error,    # Links    check_broken_internal_links,    check_broken_external_links,    check_redirect_chains,    check_nofollow_internal_links,    # Robots / sitemap    check_robots_missing,    check_sitemap_missing,    check_sitemap_not_in_robots,    check_sitemap_broken_urls,    check_pages_missing_from_sitemap,    # Accessibility    check_images_missing_alt,    check_empty_anchor_text,    check_form_inputs_unlabeled,    # Content    check_thin_content,    check_duplicate_content,    # Performance    check_slow_pages,    check_missing_compression,    check_oversized_pages,    # Security    check_mixed_content,]
deleted crawler/fetcher.py
@@ -1,154 +0,0 @@"""HTTP fetching, robots.txt, and sitemap loading."""import loggingimport urllib.robotparserfrom dataclasses import dataclass, fieldfrom urllib.parse import urlparseimport requestsfrom bs4 import BeautifulSouplogger = logging.getLogger(__name__)USER_AGENT = "status (+https://status.bythewood.me)"PAGE_CAP = 500CONCURRENCY = 4REQUEST_TIMEOUT = (5, 15)EXTERNAL_LINK_TIMEOUT = (3, 8)# Hard deadline for a single site crawl. Scheduler JOIN_TIMEOUT must exceed this.CRAWL_DEADLINE_SECONDS = 540@dataclassclass FetchResult:    url: str    requested_url: str    status: int    headers: dict    body: bytes    content_type: str    elapsed_ms: int    redirect_chain: list = field(default_factory=list)    error: str = ""def make_session():    s = requests.Session()    s.headers.update({"User-Agent": USER_AGENT})    return sdef fetch(session, url):    """GET a URL and return a FetchResult. Body is only captured for HTML."""    try:        r = session.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=True)        chain = [(h.status_code, h.url) for h in r.history]        chain.append((r.status_code, r.url))        content_type = r.headers.get("content-type", "").lower()        body = r.content if "text/html" in content_type else b""        return FetchResult(            url=r.url,            requested_url=url,            status=r.status_code,            headers=dict(r.headers),            body=body,            content_type=content_type,            elapsed_ms=int(r.elapsed.total_seconds() * 1000),            redirect_chain=chain,        )    except requests.RequestException as e:        return FetchResult(            url=url,            requested_url=url,            status=0,            headers={},            body=b"",            content_type="",            elapsed_ms=0,            error=str(e),        )def head_status(session, url):    """Cheap check for external links. Returns the status code (0 on error)."""    try:        r = session.head(url, timeout=EXTERNAL_LINK_TIMEOUT, allow_redirects=True)        # Some servers reject HEAD with 405/403 but accept GET.        if r.status_code in (403, 405, 501):            r = session.get(                url, timeout=EXTERNAL_LINK_TIMEOUT, allow_redirects=True, stream=True            )            r.close()        return r.status_code    except requests.RequestException:        return 0def load_robots(session, base_origin):    """Fetch robots.txt. Returns (RobotFileParser, robots_url, raw_text_or_None)."""    robots_url = f"{base_origin}/robots.txt"    rp = urllib.robotparser.RobotFileParser()    text = None    try:        r = session.get(robots_url, timeout=REQUEST_TIMEOUT)        if r.status_code == 200:            text = r.text            rp.parse(text.splitlines())    except requests.RequestException:        pass    return rp, robots_url, textdef _parse_sitemap_xml(body):    """Return all <loc> values. Distinguishing index vs urlset is handled upstream."""    soup = BeautifulSoup(body, "xml")    return [loc.get_text(strip=True) for loc in soup.find_all("loc")]def load_sitemap(session, base_origin, robots_text):    """Return list of URLs from sitemap(s). Follows sitemap indexes one level.    Checks Sitemap: entries in robots.txt first, falls back to /sitemap.xml.    """    candidates = []    if robots_text:        for line in robots_text.splitlines():            if line.lower().startswith("sitemap:"):                candidates.append(line.split(":", 1)[1].strip())    if not candidates:        candidates.append(f"{base_origin}/sitemap.xml")    seen = set()    urls = []    to_fetch = list(candidates)    while to_fetch and len(seen) < 20:        smurl = to_fetch.pop()        if smurl in seen:            continue        seen.add(smurl)        try:            r = session.get(smurl, timeout=REQUEST_TIMEOUT)            if r.status_code != 200:                continue            for loc in _parse_sitemap_xml(r.content):                # Sub-sitemaps end in .xml; everything else is a page URL.                if loc.lower().endswith(".xml") or "sitemap" in loc.lower():                    to_fetch.append(loc)                else:                    urls.append(loc)        except requests.RequestException:            continue    return urlsdef same_site(url, host):    """True if `url` is on `host` or its www./apex counterpart."""    u = urlparse(url).netloc.lower()    h = host.lower()    if not u:        return False    if u == h:        return True    if u == "www." + h or h == "www." + u:        return True    return False
deleted crawler/parser.py
@@ -1,167 +0,0 @@"""HTML parsing: raw body -> structured page dict."""import hashlibimport jsonimport loggingfrom urllib.parse import urljoinfrom bs4 import BeautifulSouplogger = logging.getLogger(__name__)def parse_html(body, url):    """Extract everything the checks need from one HTML page.    Returns a dict with title, meta, headings, links, images, resources,    forms, json_ld, word count, text hash, favicon, lang, viewport, robots.    """    soup = BeautifulSoup(body, "lxml")    title = ""    if soup.title and soup.title.string:        title = soup.title.string.strip()    def meta_name(name):        tag = soup.find("meta", attrs={"name": name})        if tag and tag.get("content"):            return tag["content"].strip()        return ""    def meta_property(prop):        tag = soup.find("meta", attrs={"property": prop})        if tag and tag.get("content"):            return tag["content"].strip()        return ""    description = meta_name("description")    robots_meta = meta_name("robots")    viewport = meta_name("viewport")    canonical_tag = soup.find("link", rel="canonical")    canonical = ""    if canonical_tag and canonical_tag.get("href"):        canonical = urljoin(url, canonical_tag["href"].strip())    og = {        "title": meta_property("og:title"),        "description": meta_property("og:description"),        "image": meta_property("og:image"),        "url": meta_property("og:url"),    }    twitter = {        "card": meta_name("twitter:card"),        "title": meta_name("twitter:title"),        "description": meta_name("twitter:description"),    }    html_tag = soup.find("html")    lang = html_tag.get("lang", "").strip() if html_tag else ""    headings = {f"h{i}": [] for i in range(1, 7)}    for level in range(1, 7):        for h in soup.find_all(f"h{level}"):            headings[f"h{level}"].append(h.get_text(" ", strip=True))    links = []    for a in soup.find_all("a", href=True):        href = a["href"].strip()        if not href or href.startswith(("javascript:", "mailto:", "tel:", "#")):            continue        rel = a.get("rel") or []        if isinstance(rel, str):            rel = rel.split()        links.append(            {                "url": urljoin(url, href),                "text": a.get_text(" ", strip=True),                "rel": list(rel),            }        )    images = []    for img in soup.find_all("img"):        src = img.get("src", "").strip()        alt = img.get("alt")  # None = missing attribute, "" = explicitly empty        images.append(            {                "src": urljoin(url, src) if src else "",                "alt": alt,            }        )    resources = []    for tag in soup.find_all(["script", "link", "img", "iframe", "source"]):        src = tag.get("src") or tag.get("href")        if src and src.strip():            resources.append(urljoin(url, src.strip()))    json_ld = []    for s in soup.find_all("script", type="application/ld+json"):        raw = s.string or s.get_text() or ""        if not raw.strip():            continue        try:            json_ld.append(json.loads(raw))        except (ValueError, TypeError):            json_ld.append(None)  # parse error    favicon = ""    for link in soup.find_all("link", rel=True):        rels = link.get("rel", [])        if isinstance(rels, str):            rels = rels.split()        if any("icon" in r.lower() for r in rels):            href = link.get("href", "").strip()            if href:                favicon = urljoin(url, href)                break    forms = []    for form in soup.find_all("form"):        inputs = []        for i in form.find_all(["input", "textarea", "select"]):            inputs.append(                {                    "type": i.get("type", "text"),                    "name": i.get("name"),                    "id": i.get("id"),                    "aria_label": i.get("aria-label"),                }            )        label_fors = {lb.get("for") for lb in form.find_all("label") if lb.get("for")}        forms.append(            {                "action": urljoin(url, form.get("action", "")) if form.get("action") else url,                "inputs": inputs,                "label_fors": list(label_fors),            }        )    # Visible text for word count + duplicate detection.    for tag in soup(["script", "style", "noscript"]):        tag.decompose()    text = soup.get_text(" ", strip=True)    word_count = len(text.split())    text_hash = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()    return {        "title": title,        "description": description,        "canonical": canonical,        "robots_meta": robots_meta,        "viewport": viewport,        "lang": lang,        "og": og,        "twitter": twitter,        "headings": headings,        "links": links,        "images": images,        "resources": resources,        "json_ld": json_ld,        "favicon": favicon,        "forms": forms,        "word_count": word_count,        "text_hash": text_hash,    }
deleted crawler/runner.py
@@ -1,234 +0,0 @@"""Entry point for the SEO crawler.Crawls a site, runs checks, writes per-page debug output, and returnsa list of insights. Designed to run in-process; no subprocess required."""import jsonimport loggingimport osimport timefrom collections import dequefrom concurrent.futures import ThreadPoolExecutor, as_completedfrom urllib.parse import urlparsefrom django.conf import settingsfrom . import checksfrom .fetcher import (    CONCURRENCY,    CRAWL_DEADLINE_SECONDS,    PAGE_CAP,    fetch,    head_status,    load_robots,    load_sitemap,    make_session,    same_site,)from .parser import parse_htmllogger = logging.getLogger(__name__)def _output_path(host):    base = "crawler_output" if settings.DEBUG else "/data/crawler_output"    return os.path.join(base, f"{host}.json")def _normalize_url(url):    # Drop fragments; keep query strings since they often distinguish pages.    p = urlparse(url)    cleaned = p._replace(fragment="").geturl()    return cleaned.rstrip("/") or cleaneddef crawl(start_url, progress_cb=None):    """Fetch up to PAGE_CAP pages from the same host and collect metadata.    `progress_cb(pages_count)` is invoked after each batch so callers can    surface live progress without blocking the crawl.    """    session = make_session()    parsed = urlparse(start_url)    host = parsed.netloc    base_origin = f"{parsed.scheme}://{parsed.netloc}"    rp, robots_url, robots_text = load_robots(session, base_origin)    sitemap_urls = load_sitemap(session, base_origin, robots_text)    seen = set()    queue = deque()    pages = []    fetched = set()    deadline = time.time() + CRAWL_DEADLINE_SECONDS    def enqueue(url):        n = _normalize_url(url)        if n in seen:            return        seen.add(n)        queue.append(url)    enqueue(start_url)    # Seed with sitemap URLs so sitemap-only pages get crawled too.    for url in sitemap_urls[:PAGE_CAP]:        if same_site(url, host):            enqueue(url)    with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:        while queue and len(pages) < PAGE_CAP and time.time() < deadline:            batch = []            while queue and len(batch) < CONCURRENCY and len(pages) + len(batch) < PAGE_CAP:                url = queue.popleft()                if not rp.can_fetch("*", url):                    continue                batch.append(url)            if not batch:                break            futures = [ex.submit(fetch, session, u) for u in batch]            for f in as_completed(futures):                r = f.result()                # Different requested URLs can collapse to the same final URL                # after redirects. Drop duplicates so checks don't double-flag.                final_key = _normalize_url(r.url)                if final_key in fetched:                    seen.add(final_key)                    continue                fetched.add(final_key)                is_html = r.status == 200 and "text/html" in r.content_type                page = {                    "url": r.url,                    "requested_url": r.requested_url,                    "status": r.status,                    "content_type": r.content_type,                    "elapsed_ms": r.elapsed_ms,                    "bytes": len(r.body),                    "headers": r.headers,                    "redirect_chain": r.redirect_chain,                    "error": r.error,                    "is_html": is_html,                }                if is_html:                    try:                        page.update(parse_html(r.body, r.url))                    except Exception:                        logger.exception("[crawler] parse failed for %s", r.url)                        page["is_html"] = False                    else:                        for link in page.get("links", []):                            lu = link["url"]                            if same_site(lu, host):                                enqueue(lu)                # Ensure the final redirected URL is considered "seen" too, so                # we don't refetch it on another pass.                seen.add(_normalize_url(r.url))                pages.append(page)            if progress_cb is not None:                try:                    progress_cb(len(pages))                except Exception:                    logger.exception("[crawler] progress callback failed")    if time.time() >= deadline:        logger.warning(            "[crawler] hit deadline for %s after %d pages",            start_url,            len(pages),        )    # External link HEAD check. Only checks same unique URL once.    external_links = set()    for p in pages:        if not p.get("is_html"):            continue        for link in p.get("links", []):            if not same_site(link["url"], host):                external_links.add(link["url"])    external_link_status = {}    if external_links and time.time() < deadline:        with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:            futures = {ex.submit(head_status, session, u): u for u in external_links}            for f in as_completed(futures):                url = futures[f]                try:                    external_link_status[url] = f.result()                except Exception:                    external_link_status[url] = 0    return {        "start_url": start_url,        "host": host,        "pages": pages,        "external_link_status": external_link_status,        "sitemap_urls": sitemap_urls,        "robots": {            "url": robots_url,            "exists": robots_text is not None,            "raw": robots_text,            "references_sitemap": bool(                robots_text                and any(                    line.lower().startswith("sitemap:")                    for line in robots_text.splitlines()                )            ),        },    }def run_checks(crawl_result):    """Build a ctx dict and run every check. Returns the flat insight list."""    ctx = {        "start_url": crawl_result["start_url"],        "host": crawl_result["host"],        "pages": crawl_result["pages"],        "html_pages": [p for p in crawl_result["pages"] if p.get("is_html")],        "status_map": {p["url"]: p["status"] for p in crawl_result["pages"]},        "external_link_status": crawl_result["external_link_status"],        "sitemap_urls": crawl_result["sitemap_urls"],        "robots": crawl_result["robots"],    }    insights = []    for fn in checks.ALL_CHECKS:        try:            insights.extend(fn(ctx))        except Exception:            logger.exception("[crawler] check %s failed", fn.__name__)    return insightsdef _write_debug_output(crawl_result):    host = crawl_result["host"]    path = _output_path(host)    try:        os.makedirs(os.path.dirname(path), exist_ok=True)        with open(path, "w") as f:            for p in crawl_result["pages"]:                # Strip bulky fields from the debug file.                snapshot = {k: v for k, v in p.items() if k != "headers"}                f.write(json.dumps(snapshot, default=str) + "\n")    except OSError:        logger.exception("[crawler] failed writing debug output to %s", path)def run_seo_spider(url, progress_cb=None):    """Crawl `url`, write debug output, return list of insight dicts."""    start = time.time()    logger.info("[crawler] starting %s", url)    result = crawl(url, progress_cb=progress_cb)    insights = run_checks(result)    _write_debug_output(result)    logger.info(        "[crawler] done %s - %d pages, %d insights, %.1fs",        url,        len(result["pages"]),        len(insights),        time.time() - start,    )    return insights
modified docker-compose.yml
@@ -1,12 +1,14 @@services:  web:    container_name: status_web    container_name: status    build: .    init: true    env_file: .env    volumes:      - /srv/data/status/:/data/    ports:      - "127.0.0.1:${PORT}:${PORT}"    command: ./entrypoint.py    environment:      PORT: ${PORT}      STATUS_DATA_DIR: /data    volumes:      - /srv/data/status:/data    restart: unless-stopped    init: true
deleted entrypoint.py
@@ -1,47 +0,0 @@#!/usr/bin/env pythonimport osimport signalimport subprocessimport sysprocs = [    subprocess.Popen(["python", "manage.py", "scheduler"]),    subprocess.Popen(        [            "gunicorn",            "status.asgi:application",            "--workers",            "1",            "--max-requests",            "256",            "--timeout",            "30",            "--bind",            f":{os.environ['PORT']}",            "--worker-class",            "uvicorn.workers.UvicornWorker",            "--error-logfile",            "-",            "--access-logfile",            "-",        ]    ),]def shutdown(signum, frame):    for p in procs:        p.terminate()signal.signal(signal.SIGTERM, shutdown)signal.signal(signal.SIGINT, shutdown)pid, status = os.wait()for p in procs:    if p.pid != pid:        p.terminate()for p in procs:    p.wait()sys.exit(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1)
added frontend/bun.lock
@@ -0,0 +1,200 @@{  "lockfileVersion": 1,  "configVersion": 1,  "workspaces": {    "": {      "dependencies": {        "@fontsource/monaspace-argon": "^5.2.5",        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "chart.js": "^3.7.1",        "js-cookie": "^3.0.1",      },      "devDependencies": {        "sass": "^1.53.0",        "vite": "^6.3.1",      },    },  },  "packages": {    "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],    "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],    "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],    "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],    "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],    "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],    "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],    "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],    "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],    "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],    "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],    "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],    "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],    "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],    "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],    "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],    "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],    "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],    "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],    "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],    "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],    "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],    "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],    "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],    "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@fontsource/monaspace-argon": ["@fontsource/monaspace-argon@5.2.5", "", {}, "sha512-EJ+jq1Smm3BB+8RK/gwB1uzjrSKdycZkKm8OZGCYvqJiTChKcGe7b2lmj0PmQbb/+lAEdCBIAcFLlPaWhtRANA=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],    "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],    "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],    "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],    "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],    "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],    "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],    "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],    "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],    "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],    "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],    "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],    "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],    "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],    "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],    "bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="],    "chart.js": ["chart.js@3.9.1", "", {}, "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w=="],    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],    "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],    "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],    "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],    "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],    "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],    "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],    "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],  }}
added frontend/package.json
@@ -0,0 +1,19 @@{  "private": true,  "type": "module",  "scripts": {    "dev": "vite build --watch",    "build": "vite build"  },  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "chart.js": "^3.7.1",    "js-cookie": "^3.0.1"  },  "devDependencies": {    "sass": "^1.53.0",    "vite": "^6.3.1"  }}
renamed status/static_src/index.js → frontend/static_src/base/index.js
renamed status/static_src/scripts/bootstrap.js → frontend/static_src/base/scripts/bootstrap.js
renamed status/static_src/styles/_variables.scss → frontend/static_src/base/styles/_variables.scss
renamed status/static_src/styles/base.scss → frontend/static_src/base/styles/base.scss
@@ -730,6 +730,31 @@ footer {  }}// ── Monitor card buttons ──// Inline flex so the spinner sits centered next to the label instead of// floating off the baseline. Slightly thicker spinner border for visibility// against the muted button outline..monitor-btn {  display: inline-flex;  align-items: center;  gap: 0.4rem;  padding: 0.2rem 0.65rem;  line-height: 1.3;  .spinner-border-sm {    --bs-spinner-width: 0.85rem;    --bs-spinner-height: 0.85rem;    --bs-spinner-border-width: 0.2em;    color: $green-bright;    flex-shrink: 0;  }  &:disabled {    opacity: 0.85;  // Bootstrap's default 0.65 makes the spinner nearly invisible  }}// ── Insight row (lighthouse opportunities / crawler issues) ──.insight-header {
renamed status/static_src/styles/bootstrap.scss → frontend/static_src/base/styles/bootstrap.scss
@@ -135,6 +135,45 @@  color: $amber !important;}.text-terracotta {  color: #d88870 !important;}.property-url-link {  display: inline-flex;  align-items: center;  gap: 0.45rem;  max-width: 100%;  padding: 0.32rem 0.7rem;  background: rgba(107, 158, 120, 0.06);  border: 1px solid rgba(107, 158, 120, 0.18);  border-radius: 3px;  font-family: $font-family-monospace;  font-size: 0.88rem;  color: $green-bright !important;  text-decoration: none;  transition: border-color 200ms ease, background 200ms ease;  .property-url-text {    overflow: hidden;    text-overflow: ellipsis;    white-space: nowrap;    min-width: 0;  }  .property-url-arrow {    color: rgba(125, 184, 140, 0.55);    flex-shrink: 0;  }  &:hover {    border-color: rgba(107, 158, 120, 0.4);    background: rgba(107, 158, 120, 0.1);    .property-url-arrow { color: $green-bright; }  }}// Legacy utility aliases used by existing templates.bg-teal-900 { background-color: #090806 !important; }.bg-teal-800 { background-color: #0e0d0a !important; }
renamed pages/static_src/index.js → frontend/static_src/pages/index.js
renamed pages/static_src/styles/pages.scss → frontend/static_src/pages/styles/pages.scss
@@ -1,4 +1,4 @@@use "../../../status/static_src/styles/variables" as *;@use "../../base/styles/variables" as *;// ── Home hero ──
renamed properties/static_src/index.js → frontend/static_src/properties/index.js
renamed properties/static_src/scripts/property_crawl_status.js → frontend/static_src/properties/scripts/property_crawl_status.js
@@ -1,15 +1,12 @@// Polls the crawl/lighthouse status endpoint and updates the monitoring// panel in-place. When a crawl or lighthouse run is active, polling is// fast (2s); when idle, it's slow (30s) so older tabs don't hammer the// server.// Polls the crawl/lighthouse status endpoint and updates the monitoring panel// in-place. Fast (2s) while a job is active, slow (30s) while idle. Mirrors// the original Django version closely; the property id is read from the// #property-data JSON script (not a Django {% url %} data-attribute), and there// is no CSRF token since auth is a SameSite=Strict cookie.const FAST_POLL_MS = 2000;const SLOW_POLL_MS = 30000;function $(root, selector) {  return root.querySelector(selector);}function setText(root, field, value) {  const el = root.querySelector(`[data-field="${field}"]`);  if (el) el.textContent = value;
@@ -48,18 +45,17 @@ function relativeTime(iso, now) {function formatAbsolute(iso) {  if (!iso) return "";  const d = new Date(iso);  return d.toLocaleString();  return new Date(iso).toLocaleString();}function stateBadge(state) {  switch (state) {    case "running":      return { label: "Running", cls: "bg-primary" };      return { label: "running", cls: "chip chip-warn" };    case "queued":      return { label: "Queued", cls: "bg-info text-dark" };      return { label: "queued", cls: "chip chip-info" };    default:      return { label: "Idle", cls: "bg-secondary" };      return { label: "idle", cls: "chip chip-muted" };  }}
@@ -71,23 +67,26 @@ function renderWhen(root, field, iso, now) {    el.removeAttribute("title");    return;  }  const rel = relativeTime(iso, now);  el.textContent = rel;  el.textContent = relativeTime(iso, now);  el.title = formatAbsolute(iso);}function renderCrawler(root, crawler, serverNow) {  const badge = root.querySelector('[data-field="crawler.state_badge"]');  const s = stateBadge(crawler.state);  badge.className = `badge ${s.cls}`;  badge.textContent = s.label;  if (badge) {    const s = stateBadge(crawler.state);    badge.className = s.cls;    badge.textContent = s.label;  }  show(root, "crawler.progress_wrap", crawler.state === "running");  if (crawler.state === "running") {    const bar = root.querySelector('[data-field="crawler.progress_bar"]');    const pct = Math.round((crawler.progress || 0) * 100);    bar.style.width = `${pct}%`;    bar.setAttribute("aria-valuenow", pct);    if (bar) {      const pct = Math.round((crawler.progress || 0) * 100);      bar.style.width = `${pct}%`;      bar.setAttribute("aria-valuenow", String(pct));    }  }  show(root, "crawler.error_box", !!crawler.last_error);
@@ -99,45 +98,53 @@ function renderCrawler(root, crawler, serverNow) {  renderWhen(root, "crawler.last_attempt", crawler.last_attempt_at, serverNow);  const pagesEl = root.querySelector('[data-field="crawler.pages"]');  if (crawler.state === "running") {    pagesEl.textContent = `${crawler.pages_count || 0} so far…`;  } else if (crawler.pages_count != null) {    pagesEl.textContent = `${crawler.pages_count}`;  } else {    pagesEl.textContent = "—";  if (pagesEl) {    if (crawler.state === "running") {      pagesEl.textContent = `${crawler.pages_count || 0} so far…`;    } else if (crawler.pages_count != null) {      pagesEl.textContent = `${crawler.pages_count}`;    } else {      pagesEl.textContent = "—";    }  }  setText(root, "crawler.duration", humanDuration(crawler.last_duration_ms));  const ins = crawler.insights_by_severity || { error: 0, warning: 0, info: 0 };  const insEl = root.querySelector('[data-field="crawler.insights"]');  insEl.innerHTML = `    <span class="badge bg-danger me-1">${ins.error} err</span>    <span class="badge bg-warning text-dark me-1">${ins.warning} warn</span>    <span class="badge bg-info text-dark">${ins.info} info</span>  `;  if (insEl) {    insEl.innerHTML = `      <span class="chip chip-down me-1">${ins.error} err</span>      <span class="chip chip-warn me-1">${ins.warning} warn</span>      <span class="chip chip-info">${ins.info} info</span>    `;  }  const nextEl = root.querySelector('[data-field="crawler.next_run"]');  if (!crawler.next_run_at) {    nextEl.textContent = "—";    nextEl.removeAttribute("title");  } else if (crawler.state === "running" || crawler.state === "queued") {    nextEl.textContent = "— (running now)";    nextEl.title = formatAbsolute(crawler.next_run_at);  } else if (crawler.is_overdue) {    nextEl.innerHTML = `<span class="text-warning">due now</span>`;    nextEl.title = formatAbsolute(crawler.next_run_at);  } else {    nextEl.textContent = relativeTime(crawler.next_run_at, serverNow);    nextEl.title = formatAbsolute(crawler.next_run_at);  if (nextEl) {    if (!crawler.next_run_at) {      nextEl.textContent = "—";      nextEl.removeAttribute("title");    } else if (crawler.state === "running" || crawler.state === "queued") {      nextEl.textContent = "— (running now)";      nextEl.title = formatAbsolute(crawler.next_run_at);    } else if (crawler.is_overdue) {      nextEl.innerHTML = `<span class="text-amber">due now</span>`;      nextEl.title = formatAbsolute(crawler.next_run_at);    } else {      nextEl.textContent = relativeTime(crawler.next_run_at, serverNow);      nextEl.title = formatAbsolute(crawler.next_run_at);    }  }}function renderLighthouse(root, lh, serverNow) {  const badge = root.querySelector('[data-field="lighthouse.state_badge"]');  const s = stateBadge(lh.state);  badge.className = `badge ${s.cls}`;  badge.textContent = s.label;  if (badge) {    const s = stateBadge(lh.state);    badge.className = s.cls;    badge.textContent = s.label;  }  show(root, "lighthouse.error_box", !!lh.last_error);  if (lh.last_error) {
@@ -149,18 +156,20 @@ function renderLighthouse(root, lh, serverNow) {  setText(root, "lighthouse.duration", humanDuration(lh.last_duration_ms));  const nextEl = root.querySelector('[data-field="lighthouse.next_run"]');  if (!lh.next_run_at) {    nextEl.textContent = "—";    nextEl.removeAttribute("title");  } else if (lh.state === "running" || lh.state === "queued") {    nextEl.textContent = "— (running now)";    nextEl.title = formatAbsolute(lh.next_run_at);  } else if (lh.is_overdue) {    nextEl.innerHTML = `<span class="text-warning">due now</span>`;    nextEl.title = formatAbsolute(lh.next_run_at);  } else {    nextEl.textContent = relativeTime(lh.next_run_at, serverNow);    nextEl.title = formatAbsolute(lh.next_run_at);  if (nextEl) {    if (!lh.next_run_at) {      nextEl.textContent = "—";      nextEl.removeAttribute("title");    } else if (lh.state === "running" || lh.state === "queued") {      nextEl.textContent = "— (running now)";      nextEl.title = formatAbsolute(lh.next_run_at);    } else if (lh.is_overdue) {      nextEl.innerHTML = `<span class="text-amber">due now</span>`;      nextEl.title = formatAbsolute(lh.next_run_at);    } else {      nextEl.textContent = relativeTime(lh.next_run_at, serverNow);      nextEl.title = formatAbsolute(lh.next_run_at);    }  }}
@@ -168,11 +177,10 @@ function updateRecrawlButton(data) {  const btn = document.getElementById("recrawl-btn");  if (!btn) return;  const state = data.crawler.state;  // "overdue + idle" means the user already requested a recrawl but the  // scheduler hasn't picked it up yet (up to ~30s).  // "overdue + idle" means a recrawl was requested but the scheduler hasn't  // picked it up yet (up to ~30s).  const waitingForScheduler = state === "idle" && data.crawler.is_overdue;  const busy =    state === "queued" || state === "running" || waitingForScheduler;  const busy = state === "queued" || state === "running" || waitingForScheduler;  btn.disabled = busy;  const label = btn.querySelector(".recrawl-btn-label");  const spinner = btn.querySelector(".recrawl-btn-spinner");
@@ -184,7 +192,7 @@ function updateRecrawlButton(data) {    } else if (state === "queued") {      label.textContent = "Queued…";    } else {      label.textContent = "Waiting for scheduler…";      label.textContent = "Waiting…";    }  } else {    spinner.classList.add("d-none");
@@ -197,15 +205,14 @@ function updateRerunLighthouseButton(data) {  if (!btn) return;  const state = data.lighthouse.state;  const waitingForScheduler = state === "idle" && data.lighthouse.is_overdue;  const busy =    state === "queued" || state === "running" || waitingForScheduler;  const busy = state === "queued" || state === "running" || waitingForScheduler;  btn.disabled = busy;  const label = btn.querySelector(".rerun-lh-label");  const spinner = btn.querySelector(".rerun-lh-spinner");  if (busy) {    spinner.classList.remove("d-none");    if (state === "running") label.textContent = "Running";    else if (state === "queued") label.textContent = "Queued";    if (state === "running") label.textContent = "Running…";    else if (state === "queued") label.textContent = "Queued…";    else label.textContent = "Waiting…";  } else {    spinner.classList.add("d-none");
@@ -213,19 +220,11 @@ function updateRerunLighthouseButton(data) {  }}function getCsrfToken() {  const input = document.querySelector("input[name=csrfmiddlewaretoken]");  return input ? input.value : "";}async function triggerPost(url, onDone) {  try {    const res = await fetch(url, {      method: "POST",      headers: {        "X-CSRFToken": getCsrfToken(),        "Accept": "application/json",      },      headers: { Accept: "application/json" },      credentials: "same-origin",    });    if (!res.ok) {
@@ -242,10 +241,12 @@ async function triggerPost(url, onDone) {document.addEventListener("DOMContentLoaded", function () {  const root = document.getElementById("monitoring-status");  if (!root) return;  const propertyId = root.dataset.propertyId;  if (!propertyId) return;  const statusUrl = root.dataset.statusUrl;  const recrawlUrl = root.dataset.recrawlUrl;  const rerunLighthouseUrl = root.dataset.rerunLighthouseUrl;  const statusUrl = `/properties/${propertyId}/status`;  const recrawlUrl = `/properties/${propertyId}/recrawl`;  const rerunLighthouseUrl = `/properties/${propertyId}/rerun-lighthouse`;  let prevCrawlState = null;  let prevLhState = null;
@@ -263,14 +264,17 @@ document.addEventListener("DOMContentLoaded", function () {  }  function applyData(data) {    const serverNow = data.server_time ? new Date(data.server_time).getTime() : Date.now();    const serverNow = data.server_time      ? new Date(data.server_time).getTime()      : Date.now();    renderCrawler(root, data.crawler, serverNow);    renderLighthouse(root, data.lighthouse, serverNow);    updateRecrawlButton(data);    updateRerunLighthouseButton(data);    // If either subsystem just went idle after being active, refresh the    // page once so server-rendered charts/insights update.    // If either subsystem just went idle after being active, refresh the page    // so server-rendered tables (insights, lighthouse scores, opportunities)    // pick up the new data.    const crawlerFinished =      prevCrawlState && prevCrawlState !== "idle" && data.crawler.state === "idle";    const lhFinished =
@@ -303,22 +307,18 @@ document.addEventListener("DOMContentLoaded", function () {  }  const recrawlBtn = document.getElementById("recrawl-btn");  if (recrawlBtn && recrawlUrl) {    recrawlBtn.addEventListener("click", function () {  if (recrawlBtn) {    recrawlBtn.addEventListener("click", () => {      recrawlBtn.disabled = true;      triggerPost(recrawlUrl, function (data) {        applyData(data);      });      triggerPost(recrawlUrl, applyData);    });  }  const rerunLhBtn = document.getElementById("rerun-lighthouse-btn");  if (rerunLhBtn && rerunLighthouseUrl) {    rerunLhBtn.addEventListener("click", function () {  if (rerunLhBtn) {    rerunLhBtn.addEventListener("click", () => {      rerunLhBtn.disabled = true;      triggerPost(rerunLighthouseUrl, function (data) {        applyData(data);      });      triggerPost(rerunLighthouseUrl, applyData);    });  }
renamed properties/static_src/scripts/property_graphs.js → frontend/static_src/properties/scripts/property_graphs.js
added frontend/static_src/properties/scripts/property_is_public.js
@@ -0,0 +1,24 @@// Visibility toggle pill. Posts to /properties/<id>/public on every change,// then reloads so the rest of the dashboard re-renders against the new state.document.addEventListener("DOMContentLoaded", function () {  const wrap = document.getElementById("is-public-form");  if (!wrap) return;  const propertyId = wrap.dataset.propertyId;  if (!propertyId) return;  const checkbox = wrap.querySelector("#is-public-switch");  if (!checkbox) return;  checkbox.addEventListener("change", async () => {    checkbox.disabled = true;    try {      await fetch(`/properties/${propertyId}/public`, {        method: "POST",        credentials: "same-origin",      });      window.location.reload();    } catch (err) {      console.error("toggle public failed", err);      checkbox.disabled = false;    }  });});
renamed properties/static_src/styles/print.scss → frontend/static_src/properties/styles/print.scss
added frontend/vite.config.js
@@ -0,0 +1,27 @@import { resolve } from "path";import { defineConfig } from "vite";// Project structure mirrors analytics/blog/darkfurrow:// - All entry points live under static_src/{base,pages,properties}// - Vite outputs to ../dist/ with content-hashed filenames// - The Rust binary reads dist/.vite/manifest.json to resolve hashed namesexport default defineConfig({  base: "/static/",  build: {    outDir: resolve(__dirname, "../dist"),    emptyOutDir: true,    manifest: true,    rollupOptions: {      input: {        base: resolve(__dirname, "static_src/base/index.js"),        pages: resolve(__dirname, "static_src/pages/index.js"),        properties: resolve(__dirname, "static_src/properties/index.js"),      },    },  },  css: {    preprocessorOptions: {      scss: { quietDeps: true },    },  },});
deleted manage.py
@@ -1,22 +0,0 @@#!/usr/bin/env python"""Django's command-line utility for administrative tasks."""import osimport sysdef main():    """Run administrative tasks."""    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'status.settings.development')    try:        from django.core.management import execute_from_command_line    except ImportError as exc:        raise ImportError(            "Couldn't import Django. Are you sure it's installed and "            "available on your PYTHONPATH environment variable? Did you "            "forget to activate a virtual environment?"        ) from exc    execute_from_command_line(sys.argv)if __name__ == '__main__':    main()
added migrations/0001_initial.sql
@@ -0,0 +1,59 @@-- A "property" is a tracked URL: the unit of monitoring. The original Django-- schema had per-user properties; the rust port collapses to single-operator-- so user_id is gone, but the rest of the columns line up 1:1 (preserving-- UUIDs lets prior public status URLs keep working after migration).CREATE TABLE properties (    id                          BLOB PRIMARY KEY,    url                         TEXT NOT NULL,    is_public                   INTEGER NOT NULL DEFAULT 0,    is_protected                INTEGER NOT NULL DEFAULT 0,    last_run_at                 INTEGER,    next_run_at                 INTEGER,    last_run_at_crawler         INTEGER,    next_run_at_crawler         INTEGER,    crawler_insights            TEXT,    crawl_state                 TEXT NOT NULL DEFAULT 'idle',    crawl_started_at            INTEGER,    last_crawl_success_at       INTEGER,    last_crawl_error            TEXT,    last_crawl_duration_ms      INTEGER,    last_crawl_pages_count      INTEGER,    lighthouse_scores           TEXT,    lighthouse_details          TEXT,    last_lighthouse_run_at      INTEGER,    last_lighthouse_success_at  INTEGER,    last_lighthouse_error       TEXT,    last_lighthouse_duration_ms INTEGER,    next_lighthouse_run_at      INTEGER,    lighthouse_state            TEXT NOT NULL DEFAULT 'idle',    lighthouse_started_at       INTEGER,    -- alert state machine: "up" or "down". Transitions only fire alerts.    alert_state                 TEXT NOT NULL DEFAULT 'up',    last_alert_sent             INTEGER,    created_at                  INTEGER NOT NULL,    updated_at                  INTEGER NOT NULL);CREATE INDEX properties_url ON properties(url);-- Result of a single HTTP check. Cleaned up after 3 days by the scheduler.CREATE TABLE checks (    id           INTEGER PRIMARY KEY AUTOINCREMENT,    property_id  BLOB NOT NULL REFERENCES properties(id) ON DELETE CASCADE,    status_code  INTEGER NOT NULL,    response_ms  INTEGER NOT NULL DEFAULT 0,    headers      TEXT NOT NULL DEFAULT '{}',    created_at   INTEGER NOT NULL);CREATE INDEX checks_created_at         ON checks(created_at);CREATE INDEX checks_property_created   ON checks(property_id, created_at DESC);-- Key-value table for one-off settings (schema version, etc.).CREATE TABLE meta (    key   TEXT PRIMARY KEY,    value TEXT NOT NULL);
added package-lock.json
@@ -0,0 +1,2299 @@{  "name": "status-lighthouse",  "lockfileVersion": 3,  "requires": true,  "packages": {    "": {      "name": "status-lighthouse",      "dependencies": {        "lighthouse": "^12.0.0"      }    },    "node_modules/@formatjs/ecma402-abstract": {      "version": "2.3.6",      "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",      "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",      "license": "MIT",      "dependencies": {        "@formatjs/fast-memoize": "2.2.7",        "@formatjs/intl-localematcher": "0.6.2",        "decimal.js": "^10.4.3",        "tslib": "^2.8.0"      }    },    "node_modules/@formatjs/fast-memoize": {      "version": "2.2.7",      "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",      "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",      "license": "MIT",      "dependencies": {        "tslib": "^2.8.0"      }    },    "node_modules/@formatjs/icu-messageformat-parser": {      "version": "2.11.4",      "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",      "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",      "license": "MIT",      "dependencies": {        "@formatjs/ecma402-abstract": "2.3.6",        "@formatjs/icu-skeleton-parser": "1.8.16",        "tslib": "^2.8.0"      }    },    "node_modules/@formatjs/icu-skeleton-parser": {      "version": "1.8.16",      "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",      "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",      "license": "MIT",      "dependencies": {        "@formatjs/ecma402-abstract": "2.3.6",        "tslib": "^2.8.0"      }    },    "node_modules/@formatjs/intl-localematcher": {      "version": "0.6.2",      "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",      "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",      "license": "MIT",      "dependencies": {        "tslib": "^2.8.0"      }    },    "node_modules/@opentelemetry/api": {      "version": "1.9.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",      "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",      "license": "Apache-2.0",      "engines": {        "node": ">=8.0.0"      }    },    "node_modules/@opentelemetry/api-logs": {      "version": "0.57.2",      "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz",      "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/api": "^1.3.0"      },      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/context-async-hooks": {      "version": "1.30.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz",      "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": ">=1.0.0 <1.10.0"      }    },    "node_modules/@opentelemetry/core": {      "version": "1.30.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz",      "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/semantic-conventions": "1.28.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": ">=1.0.0 <1.10.0"      }    },    "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": {      "version": "1.28.0",      "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",      "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/instrumentation": {      "version": "0.57.2",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz",      "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/api-logs": "0.57.2",        "@types/shimmer": "^1.2.0",        "import-in-the-middle": "^1.8.1",        "require-in-the-middle": "^7.1.1",        "semver": "^7.5.2",        "shimmer": "^1.2.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-amqplib": {      "version": "0.46.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz",      "integrity": "sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-connect": {      "version": "0.43.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.1.tgz",      "integrity": "sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0",        "@types/connect": "3.4.38"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-dataloader": {      "version": "0.16.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.1.tgz",      "integrity": "sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-express": {      "version": "0.47.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz",      "integrity": "sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-fs": {      "version": "0.19.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.1.tgz",      "integrity": "sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-generic-pool": {      "version": "0.43.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.1.tgz",      "integrity": "sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-graphql": {      "version": "0.47.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz",      "integrity": "sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-hapi": {      "version": "0.45.2",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz",      "integrity": "sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-http": {      "version": "0.57.2",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz",      "integrity": "sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "1.30.1",        "@opentelemetry/instrumentation": "0.57.2",        "@opentelemetry/semantic-conventions": "1.28.0",        "forwarded-parse": "2.1.2",        "semver": "^7.5.2"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/semantic-conventions": {      "version": "1.28.0",      "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",      "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/instrumentation-ioredis": {      "version": "0.47.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.1.tgz",      "integrity": "sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/redis-common": "^0.36.2",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-kafkajs": {      "version": "0.7.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz",      "integrity": "sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-knex": {      "version": "0.44.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz",      "integrity": "sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-koa": {      "version": "0.47.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz",      "integrity": "sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-lru-memoizer": {      "version": "0.44.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.1.tgz",      "integrity": "sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-mongodb": {      "version": "0.52.0",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz",      "integrity": "sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-mongoose": {      "version": "0.46.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz",      "integrity": "sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-mysql": {      "version": "0.45.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.1.tgz",      "integrity": "sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0",        "@types/mysql": "2.15.26"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-mysql2": {      "version": "0.45.2",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz",      "integrity": "sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0",        "@opentelemetry/sql-common": "^0.40.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-pg": {      "version": "0.51.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.1.tgz",      "integrity": "sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.26.0",        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0",        "@opentelemetry/sql-common": "^0.40.1",        "@types/pg": "8.6.1",        "@types/pg-pool": "2.0.6"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-redis-4": {      "version": "0.46.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.1.tgz",      "integrity": "sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/redis-common": "^0.36.2",        "@opentelemetry/semantic-conventions": "^1.27.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-tedious": {      "version": "0.18.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz",      "integrity": "sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.57.1",        "@opentelemetry/semantic-conventions": "^1.27.0",        "@types/tedious": "^4.0.14"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.3.0"      }    },    "node_modules/@opentelemetry/instrumentation-undici": {      "version": "0.10.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz",      "integrity": "sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.8.0",        "@opentelemetry/instrumentation": "^0.57.1"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.7.0"      }    },    "node_modules/@opentelemetry/redis-common": {      "version": "0.36.2",      "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz",      "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/resources": {      "version": "1.30.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz",      "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "1.30.1",        "@opentelemetry/semantic-conventions": "1.28.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": ">=1.0.0 <1.10.0"      }    },    "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": {      "version": "1.28.0",      "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",      "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/sdk-trace-base": {      "version": "1.30.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz",      "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "1.30.1",        "@opentelemetry/resources": "1.30.1",        "@opentelemetry/semantic-conventions": "1.28.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": ">=1.0.0 <1.10.0"      }    },    "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": {      "version": "1.28.0",      "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz",      "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/semantic-conventions": {      "version": "1.40.0",      "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",      "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",      "license": "Apache-2.0",      "engines": {        "node": ">=14"      }    },    "node_modules/@opentelemetry/sql-common": {      "version": "0.40.1",      "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz",      "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/core": "^1.1.0"      },      "engines": {        "node": ">=14"      },      "peerDependencies": {        "@opentelemetry/api": "^1.1.0"      }    },    "node_modules/@paulirish/trace_engine": {      "version": "0.0.59",      "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.59.tgz",      "integrity": "sha512-439NUzQGmH+9Y017/xCchBP9571J4bzhpcNhrxorf7r37wcyJZkgUfrUsRL3xl+JDcZ6ORhoFCzCw98c6S3YHw==",      "license": "BSD-3-Clause",      "dependencies": {        "legacy-javascript": "latest",        "third-party-web": "latest"      }    },    "node_modules/@prisma/instrumentation": {      "version": "6.11.1",      "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.11.1.tgz",      "integrity": "sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA==",      "license": "Apache-2.0",      "dependencies": {        "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0"      },      "peerDependencies": {        "@opentelemetry/api": "^1.8"      }    },    "node_modules/@puppeteer/browsers": {      "version": "2.13.1",      "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.1.tgz",      "integrity": "sha512-zmS4RTK9fbrc++WlAJhxYbfz3IjDeOmkK/CwwbLmk7ydfS9e2CiEeRJHEPvjDVElO/bwXbidwGA37Bsm6LzCnQ==",      "license": "Apache-2.0",      "dependencies": {        "debug": "^4.4.3",        "extract-zip": "^2.0.1",        "progress": "^2.0.3",        "proxy-agent": "^6.5.0",        "semver": "^7.7.4",        "tar-fs": "^3.1.1",        "yargs": "^17.7.2"      },      "bin": {        "browsers": "lib/cjs/main-cli.js"      },      "engines": {        "node": ">=18"      }    },    "node_modules/@sentry/core": {      "version": "9.47.1",      "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz",      "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==",      "license": "MIT",      "engines": {        "node": ">=18"      }    },    "node_modules/@sentry/node": {      "version": "9.47.1",      "resolved": "https://registry.npmjs.org/@sentry/node/-/node-9.47.1.tgz",      "integrity": "sha512-CDbkasBz3fnWRKSFs6mmaRepM2pa+tbZkrqhPWifFfIkJDidtVW40p6OnquTvPXyPAszCnDZRnZT14xyvNmKPQ==",      "license": "MIT",      "dependencies": {        "@opentelemetry/api": "^1.9.0",        "@opentelemetry/context-async-hooks": "^1.30.1",        "@opentelemetry/core": "^1.30.1",        "@opentelemetry/instrumentation": "^0.57.2",        "@opentelemetry/instrumentation-amqplib": "^0.46.1",        "@opentelemetry/instrumentation-connect": "0.43.1",        "@opentelemetry/instrumentation-dataloader": "0.16.1",        "@opentelemetry/instrumentation-express": "0.47.1",        "@opentelemetry/instrumentation-fs": "0.19.1",        "@opentelemetry/instrumentation-generic-pool": "0.43.1",        "@opentelemetry/instrumentation-graphql": "0.47.1",        "@opentelemetry/instrumentation-hapi": "0.45.2",        "@opentelemetry/instrumentation-http": "0.57.2",        "@opentelemetry/instrumentation-ioredis": "0.47.1",        "@opentelemetry/instrumentation-kafkajs": "0.7.1",        "@opentelemetry/instrumentation-knex": "0.44.1",        "@opentelemetry/instrumentation-koa": "0.47.1",        "@opentelemetry/instrumentation-lru-memoizer": "0.44.1",        "@opentelemetry/instrumentation-mongodb": "0.52.0",        "@opentelemetry/instrumentation-mongoose": "0.46.1",        "@opentelemetry/instrumentation-mysql": "0.45.1",        "@opentelemetry/instrumentation-mysql2": "0.45.2",        "@opentelemetry/instrumentation-pg": "0.51.1",        "@opentelemetry/instrumentation-redis-4": "0.46.1",        "@opentelemetry/instrumentation-tedious": "0.18.1",        "@opentelemetry/instrumentation-undici": "0.10.1",        "@opentelemetry/resources": "^1.30.1",        "@opentelemetry/sdk-trace-base": "^1.30.1",        "@opentelemetry/semantic-conventions": "^1.34.0",        "@prisma/instrumentation": "6.11.1",        "@sentry/core": "9.47.1",        "@sentry/node-core": "9.47.1",        "@sentry/opentelemetry": "9.47.1",        "import-in-the-middle": "^1.14.2",        "minimatch": "^9.0.0"      },      "engines": {        "node": ">=18"      }    },    "node_modules/@sentry/node-core": {      "version": "9.47.1",      "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-9.47.1.tgz",      "integrity": "sha512-7TEOiCGkyShJ8CKtsri9lbgMCbB+qNts2Xq37itiMPN2m+lIukK3OX//L8DC5nfKYZlgikrefS63/vJtm669hQ==",      "license": "MIT",      "dependencies": {        "@sentry/core": "9.47.1",        "@sentry/opentelemetry": "9.47.1",        "import-in-the-middle": "^1.14.2"      },      "engines": {        "node": ">=18"      },      "peerDependencies": {        "@opentelemetry/api": "^1.9.0",        "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0",        "@opentelemetry/core": "^1.30.1 || ^2.0.0",        "@opentelemetry/instrumentation": ">=0.57.1 <1",        "@opentelemetry/resources": "^1.30.1 || ^2.0.0",        "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0",        "@opentelemetry/semantic-conventions": "^1.34.0"      }    },    "node_modules/@sentry/opentelemetry": {      "version": "9.47.1",      "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.1.tgz",      "integrity": "sha512-STtFpjF7lwzeoedDJV+5XA6P89BfmFwFftmHSGSe3UTI8z8IoiR5yB6X2vCjSPvXlfeOs13qCNNCEZyznxM8Xw==",      "license": "MIT",      "dependencies": {        "@sentry/core": "9.47.1"      },      "engines": {        "node": ">=18"      },      "peerDependencies": {        "@opentelemetry/api": "^1.9.0",        "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0",        "@opentelemetry/core": "^1.30.1 || ^2.0.0",        "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0",        "@opentelemetry/semantic-conventions": "^1.34.0"      }    },    "node_modules/@tootallnate/quickjs-emscripten": {      "version": "0.23.0",      "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",      "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",      "license": "MIT"    },    "node_modules/@types/connect": {      "version": "3.4.38",      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",      "license": "MIT",      "dependencies": {        "@types/node": "*"      }    },    "node_modules/@types/mysql": {      "version": "2.15.26",      "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",      "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==",      "license": "MIT",      "dependencies": {        "@types/node": "*"      }    },    "node_modules/@types/node": {      "version": "25.6.0",      "license": "MIT",      "dependencies": {        "undici-types": "~7.19.0"      }    },    "node_modules/@types/pg": {      "version": "8.6.1",      "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",      "integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==",      "license": "MIT",      "dependencies": {        "@types/node": "*",        "pg-protocol": "*",        "pg-types": "^2.2.0"      }    },    "node_modules/@types/pg-pool": {      "version": "2.0.6",      "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz",      "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==",      "license": "MIT",      "dependencies": {        "@types/pg": "*"      }    },    "node_modules/@types/shimmer": {      "version": "1.2.0",      "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz",      "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==",      "license": "MIT"    },    "node_modules/@types/tedious": {      "version": "4.0.14",      "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",      "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==",      "license": "MIT",      "dependencies": {        "@types/node": "*"      }    },    "node_modules/@types/yauzl": {      "version": "2.10.3",      "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",      "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",      "license": "MIT",      "optional": true,      "dependencies": {        "@types/node": "*"      }    },    "node_modules/agent-base": {      "version": "7.1.4",      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",      "license": "MIT",      "engines": {        "node": ">= 14"      }    },    "node_modules/ansi-colors": {      "version": "4.1.3",      "license": "MIT",      "engines": {        "node": ">=6"      }    },    "node_modules/ansi-regex": {      "version": "5.0.1",      "license": "MIT",      "engines": {        "node": ">=8"      }    },    "node_modules/ansi-styles": {      "version": "4.3.0",      "license": "MIT",      "dependencies": {        "color-convert": "^2.0.1"      },      "engines": {        "node": ">=8"      },      "funding": {        "url": "https://github.com/chalk/ansi-styles?sponsor=1"      }    },    "node_modules/ast-types": {      "version": "0.13.4",      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",      "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",      "license": "MIT",      "dependencies": {        "tslib": "^2.0.1"      },      "engines": {        "node": ">=4"      }    },    "node_modules/atomically": {      "version": "2.1.1",      "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz",      "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==",      "license": "MIT",      "dependencies": {        "stubborn-fs": "^2.0.0",        "when-exit": "^2.1.4"      }    },    "node_modules/axe-core": {      "version": "4.11.4",      "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",      "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==",      "license": "MPL-2.0",      "engines": {        "node": ">=4"      }    },    "node_modules/b4a": {      "version": "1.8.1",      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",      "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",      "license": "Apache-2.0",      "peerDependencies": {        "react-native-b4a": "*"      },      "peerDependenciesMeta": {        "react-native-b4a": {          "optional": true        }      }    },    "node_modules/balanced-match": {      "version": "1.0.2",      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",      "license": "MIT"    },    "node_modules/bare-events": {      "version": "2.8.2",      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",      "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",      "license": "Apache-2.0",      "peerDependencies": {        "bare-abort-controller": "*"      },      "peerDependenciesMeta": {        "bare-abort-controller": {          "optional": true        }      }    },    "node_modules/bare-fs": {      "version": "4.7.1",      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz",      "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==",      "license": "Apache-2.0",      "dependencies": {        "bare-events": "^2.5.4",        "bare-path": "^3.0.0",        "bare-stream": "^2.6.4",        "bare-url": "^2.2.2",        "fast-fifo": "^1.3.2"      },      "engines": {        "bare": ">=1.16.0"      },      "peerDependencies": {        "bare-buffer": "*"      },      "peerDependenciesMeta": {        "bare-buffer": {          "optional": true        }      }    },    "node_modules/bare-os": {      "version": "3.9.1",      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz",      "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",      "license": "Apache-2.0",      "engines": {        "bare": ">=1.14.0"      }    },    "node_modules/bare-path": {      "version": "3.0.0",      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",      "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",      "license": "Apache-2.0",      "dependencies": {        "bare-os": "^3.0.1"      }    },    "node_modules/bare-stream": {      "version": "2.13.1",      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz",      "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==",      "license": "Apache-2.0",      "dependencies": {        "streamx": "^2.25.0",        "teex": "^1.0.1"      },      "peerDependencies": {        "bare-abort-controller": "*",        "bare-buffer": "*",        "bare-events": "*"      },      "peerDependenciesMeta": {        "bare-abort-controller": {          "optional": true        },        "bare-buffer": {          "optional": true        },        "bare-events": {          "optional": true        }      }    },    "node_modules/bare-url": {      "version": "2.4.3",      "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz",      "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==",      "license": "Apache-2.0",      "dependencies": {        "bare-path": "^3.0.0"      }    },    "node_modules/basic-ftp": {      "version": "5.3.1",      "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",      "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",      "license": "MIT",      "engines": {        "node": ">=10.0.0"      }    },    "node_modules/brace-expansion": {      "version": "2.1.0",      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",      "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",      "license": "MIT",      "dependencies": {        "balanced-match": "^1.0.0"      }    },    "node_modules/buffer-crc32": {      "version": "0.2.13",      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",      "license": "MIT",      "engines": {        "node": "*"      }    },    "node_modules/chrome-launcher": {      "version": "1.2.1",      "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz",      "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==",      "license": "Apache-2.0",      "dependencies": {        "@types/node": "*",        "escape-string-regexp": "^4.0.0",        "is-wsl": "^2.2.0",        "lighthouse-logger": "^2.0.1"      },      "bin": {        "print-chrome-path": "bin/print-chrome-path.cjs"      },      "engines": {        "node": ">=12.13.0"      }    },    "node_modules/chromium-bidi": {      "version": "14.0.0",      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz",      "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==",      "license": "Apache-2.0",      "dependencies": {        "mitt": "^3.0.1",        "zod": "^3.24.1"      },      "peerDependencies": {        "devtools-protocol": "*"      }    },    "node_modules/cjs-module-lexer": {      "version": "1.4.3",      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",      "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",      "license": "MIT"    },    "node_modules/cliui": {      "version": "8.0.1",      "license": "ISC",      "dependencies": {        "string-width": "^4.2.0",        "strip-ansi": "^6.0.1",        "wrap-ansi": "^7.0.0"      },      "engines": {        "node": ">=12"      }    },    "node_modules/color-convert": {      "version": "2.0.1",      "license": "MIT",      "dependencies": {        "color-name": "~1.1.4"      },      "engines": {        "node": ">=7.0.0"      }    },    "node_modules/color-name": {      "version": "1.1.4",      "license": "MIT"    },    "node_modules/configstore": {      "version": "7.1.0",      "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz",      "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==",      "license": "BSD-2-Clause",      "dependencies": {        "atomically": "^2.0.3",        "dot-prop": "^9.0.0",        "graceful-fs": "^4.2.11",        "xdg-basedir": "^5.1.0"      },      "engines": {        "node": ">=18"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/csp_evaluator": {      "version": "1.1.5",      "resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.5.tgz",      "integrity": "sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==",      "license": "Apache-2.0"    },    "node_modules/data-uri-to-buffer": {      "version": "6.0.2",      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",      "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",      "license": "MIT",      "engines": {        "node": ">= 14"      }    },    "node_modules/debug": {      "version": "4.4.3",      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",      "license": "MIT",      "dependencies": {        "ms": "^2.1.3"      },      "engines": {        "node": ">=6.0"      },      "peerDependenciesMeta": {        "supports-color": {          "optional": true        }      }    },    "node_modules/decimal.js": {      "version": "10.6.0",      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",      "license": "MIT"    },    "node_modules/define-lazy-prop": {      "version": "2.0.0",      "license": "MIT",      "engines": {        "node": ">=8"      }    },    "node_modules/degenerator": {      "version": "5.0.1",      "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",      "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",      "license": "MIT",      "dependencies": {        "ast-types": "^0.13.4",        "escodegen": "^2.1.0",        "esprima": "^4.0.1"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/degenerator/node_modules/escodegen": {      "version": "2.1.0",      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",      "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",      "license": "BSD-2-Clause",      "dependencies": {        "esprima": "^4.0.1",        "estraverse": "^5.2.0",        "esutils": "^2.0.2"      },      "bin": {        "escodegen": "bin/escodegen.js",        "esgenerate": "bin/esgenerate.js"      },      "engines": {        "node": ">=6.0"      },      "optionalDependencies": {        "source-map": "~0.6.1"      }    },    "node_modules/degenerator/node_modules/esprima": {      "version": "4.0.1",      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",      "license": "BSD-2-Clause",      "bin": {        "esparse": "bin/esparse.js",        "esvalidate": "bin/esvalidate.js"      },      "engines": {        "node": ">=4"      }    },    "node_modules/degenerator/node_modules/estraverse": {      "version": "5.3.0",      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",      "license": "BSD-2-Clause",      "engines": {        "node": ">=4.0"      }    },    "node_modules/devtools-protocol": {      "version": "0.0.1507524",      "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1507524.tgz",      "integrity": "sha512-OjaNE7qpk6GRTXtqQjAE5bGx6+c4F1zZH0YXtpZQLM92HNXx4zMAaqlKhP4T52DosG6hDW8gPMNhGOF8xbwk/w==",      "license": "BSD-3-Clause"    },    "node_modules/dot-prop": {      "version": "9.0.0",      "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",      "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==",      "license": "MIT",      "dependencies": {        "type-fest": "^4.18.2"      },      "engines": {        "node": ">=18"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/emoji-regex": {      "version": "8.0.0",      "license": "MIT"    },    "node_modules/end-of-stream": {      "version": "1.4.5",      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",      "license": "MIT",      "dependencies": {        "once": "^1.4.0"      }    },    "node_modules/enquirer": {      "version": "2.4.1",      "license": "MIT",      "dependencies": {        "ansi-colors": "^4.1.1",        "strip-ansi": "^6.0.1"      },      "engines": {        "node": ">=8.6"      }    },    "node_modules/es-errors": {      "version": "1.3.0",      "license": "MIT",      "engines": {        "node": ">= 0.4"      }    },    "node_modules/escalade": {      "version": "3.2.0",      "license": "MIT",      "engines": {        "node": ">=6"      }    },    "node_modules/escape-string-regexp": {      "version": "4.0.0",      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",      "license": "MIT",      "engines": {        "node": ">=10"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/esutils": {      "version": "2.0.3",      "license": "BSD-2-Clause",      "engines": {        "node": ">=0.10.0"      }    },    "node_modules/events-universal": {      "version": "1.0.1",      "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",      "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",      "license": "Apache-2.0",      "dependencies": {        "bare-events": "^2.7.0"      }    },    "node_modules/extract-zip": {      "version": "2.0.1",      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",      "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",      "license": "BSD-2-Clause",      "dependencies": {        "debug": "^4.1.1",        "get-stream": "^5.1.0",        "yauzl": "^2.10.0"      },      "bin": {        "extract-zip": "cli.js"      },      "engines": {        "node": ">= 10.17.0"      },      "optionalDependencies": {        "@types/yauzl": "^2.9.1"      }    },    "node_modules/fast-fifo": {      "version": "1.3.2",      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",      "license": "MIT"    },    "node_modules/fd-slicer": {      "version": "1.1.0",      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",      "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",      "license": "MIT",      "dependencies": {        "pend": "~1.2.0"      }    },    "node_modules/forwarded-parse": {      "version": "2.1.2",      "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",      "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==",      "license": "MIT"    },    "node_modules/function-bind": {      "version": "1.1.2",      "license": "MIT",      "funding": {        "url": "https://github.com/sponsors/ljharb"      }    },    "node_modules/get-caller-file": {      "version": "2.0.5",      "license": "ISC",      "engines": {        "node": "6.* || 8.* || >= 10.*"      }    },    "node_modules/get-stream": {      "version": "5.2.0",      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",      "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",      "license": "MIT",      "dependencies": {        "pump": "^3.0.0"      },      "engines": {        "node": ">=8"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/get-uri": {      "version": "6.0.5",      "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",      "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",      "license": "MIT",      "dependencies": {        "basic-ftp": "^5.0.2",        "data-uri-to-buffer": "^6.0.2",        "debug": "^4.3.4"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/graceful-fs": {      "version": "4.2.11",      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",      "license": "ISC"    },    "node_modules/hasown": {      "version": "2.0.2",      "license": "MIT",      "dependencies": {        "function-bind": "^1.1.2"      },      "engines": {        "node": ">= 0.4"      }    },    "node_modules/http-link-header": {      "version": "1.1.3",      "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.3.tgz",      "integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==",      "license": "MIT",      "engines": {        "node": ">=6.0.0"      }    },    "node_modules/http-proxy-agent": {      "version": "7.0.2",      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",      "license": "MIT",      "dependencies": {        "agent-base": "^7.1.0",        "debug": "^4.3.4"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/https-proxy-agent": {      "version": "7.0.6",      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",      "license": "MIT",      "dependencies": {        "agent-base": "^7.1.2",        "debug": "4"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/image-ssim": {      "version": "0.2.0",      "license": "MIT"    },    "node_modules/import-in-the-middle": {      "version": "1.15.0",      "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz",      "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==",      "license": "Apache-2.0",      "dependencies": {        "acorn": "^8.14.0",        "acorn-import-attributes": "^1.9.5",        "cjs-module-lexer": "^1.2.2",        "module-details-from-path": "^1.0.3"      }    },    "node_modules/import-in-the-middle/node_modules/acorn": {      "version": "8.16.0",      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",      "license": "MIT",      "bin": {        "acorn": "bin/acorn"      },      "engines": {        "node": ">=0.4.0"      }    },    "node_modules/import-in-the-middle/node_modules/acorn-import-attributes": {      "version": "1.9.5",      "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",      "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",      "license": "MIT",      "peerDependencies": {        "acorn": "^8"      }    },    "node_modules/intl-messageformat": {      "version": "10.7.18",      "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",      "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",      "license": "BSD-3-Clause",      "dependencies": {        "@formatjs/ecma402-abstract": "2.3.6",        "@formatjs/fast-memoize": "2.2.7",        "@formatjs/icu-messageformat-parser": "2.11.4",        "tslib": "^2.8.0"      }    },    "node_modules/ip-address": {      "version": "10.2.0",      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",      "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",      "license": "MIT",      "engines": {        "node": ">= 12"      }    },    "node_modules/is-core-module": {      "version": "2.16.1",      "license": "MIT",      "dependencies": {        "hasown": "^2.0.2"      },      "engines": {        "node": ">= 0.4"      },      "funding": {        "url": "https://github.com/sponsors/ljharb"      }    },    "node_modules/is-docker": {      "version": "2.2.1",      "license": "MIT",      "bin": {        "is-docker": "cli.js"      },      "engines": {        "node": ">=8"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/is-fullwidth-code-point": {      "version": "3.0.0",      "license": "MIT",      "engines": {        "node": ">=8"      }    },    "node_modules/is-wsl": {      "version": "2.2.0",      "license": "MIT",      "dependencies": {        "is-docker": "^2.0.0"      },      "engines": {        "node": ">=8"      }    },    "node_modules/jpeg-js": {      "version": "0.4.4",      "license": "BSD-3-Clause"    },    "node_modules/js-library-detector": {      "version": "6.7.0",      "license": "MIT",      "engines": {        "node": ">=12"      }    },    "node_modules/legacy-javascript": {      "version": "0.0.1",      "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz",      "integrity": "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg==",      "license": "Apache-2.0"    },    "node_modules/lighthouse": {      "version": "12.8.2",      "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-12.8.2.tgz",      "integrity": "sha512-+5SKYzVaTFj22MgoYDPNrP9tlD2/Ay7j3SxPSFD9FpPyVxGr4UtOQGKyrdZ7wCmcnBaFk0mCkPfARU3CsE0nvA==",      "license": "Apache-2.0",      "dependencies": {        "@paulirish/trace_engine": "0.0.59",        "@sentry/node": "^9.28.1",        "axe-core": "^4.10.3",        "chrome-launcher": "^1.2.0",        "configstore": "^7.0.0",        "csp_evaluator": "1.1.5",        "devtools-protocol": "0.0.1507524",        "enquirer": "^2.3.6",        "http-link-header": "^1.1.1",        "intl-messageformat": "^10.5.3",        "jpeg-js": "^0.4.4",        "js-library-detector": "^6.7.0",        "lighthouse-logger": "^2.0.2",        "lighthouse-stack-packs": "1.12.2",        "lodash-es": "^4.17.21",        "lookup-closest-locale": "6.2.0",        "metaviewport-parser": "0.3.0",        "open": "^8.4.0",        "parse-cache-control": "1.0.1",        "puppeteer-core": "^24.17.1",        "robots-parser": "^3.0.1",        "speedline-core": "^1.4.3",        "third-party-web": "^0.27.0",        "tldts-icann": "^7.0.12",        "ws": "^7.0.0",        "yargs": "^17.3.1",        "yargs-parser": "^21.0.0"      },      "bin": {        "chrome-debug": "core/scripts/manual-chrome-launcher.js",        "lighthouse": "cli/index.js",        "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js"      },      "engines": {        "node": ">=18.16"      }    },    "node_modules/lighthouse-logger": {      "version": "2.0.2",      "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz",      "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==",      "license": "Apache-2.0",      "dependencies": {        "debug": "^4.4.1",        "marky": "^1.2.2"      }    },    "node_modules/lighthouse-stack-packs": {      "version": "1.12.2",      "resolved": "https://registry.npmjs.org/lighthouse-stack-packs/-/lighthouse-stack-packs-1.12.2.tgz",      "integrity": "sha512-Ug8feS/A+92TMTCK6yHYLwaFMuelK/hAKRMdldYkMNwv+d9PtWxjXEg6rwKtsUXTADajhdrhXyuNCJ5/sfmPFw==",      "license": "Apache-2.0"    },    "node_modules/lodash-es": {      "version": "4.18.1",      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",      "license": "MIT"    },    "node_modules/lookup-closest-locale": {      "version": "6.2.0",      "license": "MIT"    },    "node_modules/lru-cache": {      "version": "7.18.3",      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",      "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",      "license": "ISC",      "engines": {        "node": ">=12"      }    },    "node_modules/marky": {      "version": "1.3.0",      "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",      "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",      "license": "Apache-2.0"    },    "node_modules/metaviewport-parser": {      "version": "0.3.0",      "resolved": "https://registry.npmjs.org/metaviewport-parser/-/metaviewport-parser-0.3.0.tgz",      "integrity": "sha512-EoYJ8xfjQ6kpe9VbVHvZTZHiOl4HL1Z18CrZ+qahvLXT7ZO4YTC2JMyt5FaUp9JJp6J4Ybb/z7IsCXZt86/QkQ==",      "license": "MIT"    },    "node_modules/minimatch": {      "version": "9.0.9",      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",      "license": "ISC",      "dependencies": {        "brace-expansion": "^2.0.2"      },      "engines": {        "node": ">=16 || 14 >=14.17"      },      "funding": {        "url": "https://github.com/sponsors/isaacs"      }    },    "node_modules/mitt": {      "version": "3.0.1",      "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",      "license": "MIT"    },    "node_modules/module-details-from-path": {      "version": "1.0.4",      "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",      "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",      "license": "MIT"    },    "node_modules/ms": {      "version": "2.1.3",      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",      "license": "MIT"    },    "node_modules/netmask": {      "version": "2.1.1",      "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",      "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",      "license": "MIT",      "engines": {        "node": ">= 0.4.0"      }    },    "node_modules/once": {      "version": "1.4.0",      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",      "license": "ISC",      "dependencies": {        "wrappy": "1"      }    },    "node_modules/open": {      "version": "8.4.2",      "license": "MIT",      "dependencies": {        "define-lazy-prop": "^2.0.0",        "is-docker": "^2.1.1",        "is-wsl": "^2.2.0"      },      "engines": {        "node": ">=12"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/pac-proxy-agent": {      "version": "7.2.0",      "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",      "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",      "license": "MIT",      "dependencies": {        "@tootallnate/quickjs-emscripten": "^0.23.0",        "agent-base": "^7.1.2",        "debug": "^4.3.4",        "get-uri": "^6.0.1",        "http-proxy-agent": "^7.0.0",        "https-proxy-agent": "^7.0.6",        "pac-resolver": "^7.0.1",        "socks-proxy-agent": "^8.0.5"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/pac-resolver": {      "version": "7.0.1",      "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",      "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",      "license": "MIT",      "dependencies": {        "degenerator": "^5.0.0",        "netmask": "^2.0.2"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/parse-cache-control": {      "version": "1.0.1"    },    "node_modules/path-parse": {      "version": "1.0.7",      "license": "MIT"    },    "node_modules/pend": {      "version": "1.2.0",      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",      "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",      "license": "MIT"    },    "node_modules/pg-int8": {      "version": "1.0.1",      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",      "license": "ISC",      "engines": {        "node": ">=4.0.0"      }    },    "node_modules/pg-protocol": {      "version": "1.13.0",      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",      "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",      "license": "MIT"    },    "node_modules/pg-types": {      "version": "2.2.0",      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",      "license": "MIT",      "dependencies": {        "pg-int8": "1.0.1",        "postgres-array": "~2.0.0",        "postgres-bytea": "~1.0.0",        "postgres-date": "~1.0.4",        "postgres-interval": "^1.1.0"      },      "engines": {        "node": ">=4"      }    },    "node_modules/postgres-array": {      "version": "2.0.0",      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",      "license": "MIT",      "engines": {        "node": ">=4"      }    },    "node_modules/postgres-bytea": {      "version": "1.0.1",      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",      "license": "MIT",      "engines": {        "node": ">=0.10.0"      }    },    "node_modules/postgres-date": {      "version": "1.0.7",      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",      "license": "MIT",      "engines": {        "node": ">=0.10.0"      }    },    "node_modules/postgres-interval": {      "version": "1.2.0",      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",      "license": "MIT",      "dependencies": {        "xtend": "^4.0.0"      },      "engines": {        "node": ">=0.10.0"      }    },    "node_modules/progress": {      "version": "2.0.3",      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",      "license": "MIT",      "engines": {        "node": ">=0.4.0"      }    },    "node_modules/proxy-agent": {      "version": "6.5.0",      "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",      "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",      "license": "MIT",      "dependencies": {        "agent-base": "^7.1.2",        "debug": "^4.3.4",        "http-proxy-agent": "^7.0.1",        "https-proxy-agent": "^7.0.6",        "lru-cache": "^7.14.1",        "pac-proxy-agent": "^7.1.0",        "proxy-from-env": "^1.1.0",        "socks-proxy-agent": "^8.0.5"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/proxy-from-env": {      "version": "1.1.0",      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",      "license": "MIT"    },    "node_modules/pump": {      "version": "3.0.4",      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",      "license": "MIT",      "dependencies": {        "end-of-stream": "^1.1.0",        "once": "^1.3.1"      }    },    "node_modules/puppeteer-core": {      "version": "24.43.0",      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.0.tgz",      "integrity": "sha512-cCRNXsUlhyPoKDz6+TiSpfZpRS3mD6Y1YFKhkdr6ik6TMfuJb7fAtXq9ThUFc4sphxObDk3BuAvdxc1Y6YOnqQ==",      "license": "Apache-2.0",      "dependencies": {        "@puppeteer/browsers": "2.13.1",        "chromium-bidi": "14.0.0",        "debug": "^4.4.3",        "devtools-protocol": "0.0.1608973",        "typed-query-selector": "^2.12.2",        "webdriver-bidi-protocol": "0.4.1",        "ws": "^8.20.0"      },      "engines": {        "node": ">=18"      }    },    "node_modules/puppeteer-core/node_modules/devtools-protocol": {      "version": "0.0.1608973",      "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz",      "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",      "license": "BSD-3-Clause"    },    "node_modules/puppeteer-core/node_modules/ws": {      "version": "8.20.0",      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",      "license": "MIT",      "engines": {        "node": ">=10.0.0"      },      "peerDependencies": {        "bufferutil": "^4.0.1",        "utf-8-validate": ">=5.0.2"      },      "peerDependenciesMeta": {        "bufferutil": {          "optional": true        },        "utf-8-validate": {          "optional": true        }      }    },    "node_modules/require-directory": {      "version": "2.1.1",      "license": "MIT",      "engines": {        "node": ">=0.10.0"      }    },    "node_modules/require-in-the-middle": {      "version": "7.5.2",      "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",      "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",      "license": "MIT",      "dependencies": {        "debug": "^4.3.5",        "module-details-from-path": "^1.0.3",        "resolve": "^1.22.8"      },      "engines": {        "node": ">=8.6.0"      }    },    "node_modules/resolve": {      "version": "1.22.12",      "license": "MIT",      "dependencies": {        "es-errors": "^1.3.0",        "is-core-module": "^2.16.1",        "path-parse": "^1.0.7",        "supports-preserve-symlinks-flag": "^1.0.0"      },      "bin": {        "resolve": "bin/resolve"      },      "engines": {        "node": ">= 0.4"      },      "funding": {        "url": "https://github.com/sponsors/ljharb"      }    },    "node_modules/robots-parser": {      "version": "3.0.1",      "license": "MIT",      "engines": {        "node": ">=10.0.0"      }    },    "node_modules/semver": {      "version": "7.7.4",      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",      "license": "ISC",      "bin": {        "semver": "bin/semver.js"      },      "engines": {        "node": ">=10"      }    },    "node_modules/shimmer": {      "version": "1.2.1",      "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",      "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==",      "license": "BSD-2-Clause"    },    "node_modules/smart-buffer": {      "version": "4.2.0",      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",      "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",      "license": "MIT",      "engines": {        "node": ">= 6.0.0",        "npm": ">= 3.0.0"      }    },    "node_modules/socks": {      "version": "2.8.8",      "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",      "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",      "license": "MIT",      "dependencies": {        "ip-address": "^10.1.1",        "smart-buffer": "^4.2.0"      },      "engines": {        "node": ">= 10.0.0",        "npm": ">= 3.0.0"      }    },    "node_modules/socks-proxy-agent": {      "version": "8.0.5",      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",      "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",      "license": "MIT",      "dependencies": {        "agent-base": "^7.1.2",        "debug": "^4.3.4",        "socks": "^2.8.3"      },      "engines": {        "node": ">= 14"      }    },    "node_modules/source-map": {      "version": "0.6.1",      "license": "BSD-3-Clause",      "optional": true,      "engines": {        "node": ">=0.10.0"      }    },    "node_modules/speedline-core": {      "version": "1.4.3",      "license": "MIT",      "dependencies": {        "@types/node": "*",        "image-ssim": "^0.2.0",        "jpeg-js": "^0.4.1"      },      "engines": {        "node": ">=8.0"      }    },    "node_modules/streamx": {      "version": "2.25.0",      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",      "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",      "license": "MIT",      "dependencies": {        "events-universal": "^1.0.0",        "fast-fifo": "^1.3.2",        "text-decoder": "^1.1.0"      }    },    "node_modules/string-width": {      "version": "4.2.3",      "license": "MIT",      "dependencies": {        "emoji-regex": "^8.0.0",        "is-fullwidth-code-point": "^3.0.0",        "strip-ansi": "^6.0.1"      },      "engines": {        "node": ">=8"      }    },    "node_modules/strip-ansi": {      "version": "6.0.1",      "license": "MIT",      "dependencies": {        "ansi-regex": "^5.0.1"      },      "engines": {        "node": ">=8"      }    },    "node_modules/stubborn-fs": {      "version": "2.0.0",      "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",      "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==",      "license": "MIT",      "dependencies": {        "stubborn-utils": "^1.0.1"      }    },    "node_modules/stubborn-utils": {      "version": "1.0.2",      "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz",      "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==",      "license": "MIT"    },    "node_modules/supports-preserve-symlinks-flag": {      "version": "1.0.0",      "license": "MIT",      "engines": {        "node": ">= 0.4"      },      "funding": {        "url": "https://github.com/sponsors/ljharb"      }    },    "node_modules/tar-fs": {      "version": "3.1.2",      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",      "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",      "license": "MIT",      "dependencies": {        "pump": "^3.0.0",        "tar-stream": "^3.1.5"      },      "optionalDependencies": {        "bare-fs": "^4.0.1",        "bare-path": "^3.0.0"      }    },    "node_modules/tar-stream": {      "version": "3.2.0",      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",      "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",      "license": "MIT",      "dependencies": {        "b4a": "^1.6.4",        "bare-fs": "^4.5.5",        "fast-fifo": "^1.2.0",        "streamx": "^2.15.0"      }    },    "node_modules/teex": {      "version": "1.0.1",      "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",      "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",      "license": "MIT",      "dependencies": {        "streamx": "^2.12.5"      }    },    "node_modules/text-decoder": {      "version": "1.2.7",      "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",      "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",      "license": "Apache-2.0",      "dependencies": {        "b4a": "^1.6.4"      }    },    "node_modules/third-party-web": {      "version": "0.27.0",      "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.27.0.tgz",      "integrity": "sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==",      "license": "MIT"    },    "node_modules/tldts-core": {      "version": "7.0.30",      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",      "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",      "license": "MIT"    },    "node_modules/tldts-icann": {      "version": "7.0.30",      "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.0.30.tgz",      "integrity": "sha512-o+sKcCCZQOh78GHfpmlcKoef0c+UVfltkqmgKmUHWiMiUhSfer8k5mEkQL2RBqwuC90fluI7ZN50H7+I2bJ1jw==",      "license": "MIT",      "dependencies": {        "tldts-core": "^7.0.30"      }    },    "node_modules/tslib": {      "version": "2.8.1",      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",      "license": "0BSD"    },    "node_modules/type-fest": {      "version": "4.41.0",      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",      "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",      "license": "(MIT OR CC0-1.0)",      "engines": {        "node": ">=16"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/typed-query-selector": {      "version": "2.12.2",      "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",      "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",      "license": "MIT"    },    "node_modules/undici-types": {      "version": "7.19.2",      "license": "MIT"    },    "node_modules/webdriver-bidi-protocol": {      "version": "0.4.1",      "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",      "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",      "license": "Apache-2.0"    },    "node_modules/when-exit": {      "version": "2.1.5",      "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",      "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==",      "license": "MIT"    },    "node_modules/wrap-ansi": {      "version": "7.0.0",      "license": "MIT",      "dependencies": {        "ansi-styles": "^4.0.0",        "string-width": "^4.1.0",        "strip-ansi": "^6.0.0"      },      "engines": {        "node": ">=10"      },      "funding": {        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"      }    },    "node_modules/wrappy": {      "version": "1.0.2",      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",      "license": "ISC"    },    "node_modules/ws": {      "version": "7.5.10",      "license": "MIT",      "engines": {        "node": ">=8.3.0"      },      "peerDependencies": {        "bufferutil": "^4.0.1",        "utf-8-validate": "^5.0.2"      },      "peerDependenciesMeta": {        "bufferutil": {          "optional": true        },        "utf-8-validate": {          "optional": true        }      }    },    "node_modules/xdg-basedir": {      "version": "5.1.0",      "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",      "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",      "license": "MIT",      "engines": {        "node": ">=12"      },      "funding": {        "url": "https://github.com/sponsors/sindresorhus"      }    },    "node_modules/xtend": {      "version": "4.0.2",      "license": "MIT",      "engines": {        "node": ">=0.4"      }    },    "node_modules/y18n": {      "version": "5.0.8",      "license": "ISC",      "engines": {        "node": ">=10"      }    },    "node_modules/yargs": {      "version": "17.7.2",      "license": "MIT",      "dependencies": {        "cliui": "^8.0.1",        "escalade": "^3.1.1",        "get-caller-file": "^2.0.5",        "require-directory": "^2.1.1",        "string-width": "^4.2.3",        "y18n": "^5.0.5",        "yargs-parser": "^21.1.1"      },      "engines": {        "node": ">=12"      }    },    "node_modules/yargs-parser": {      "version": "21.1.1",      "license": "ISC",      "engines": {        "node": ">=12"      }    },    "node_modules/yauzl": {      "version": "2.10.0",      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",      "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",      "license": "MIT",      "dependencies": {        "buffer-crc32": "~0.2.3",        "fd-slicer": "~1.1.0"      }    },    "node_modules/zod": {      "version": "3.25.76",      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",      "license": "MIT",      "funding": {        "url": "https://github.com/sponsors/colinhacks"      }    }  }}
modified package.json
@@ -1,23 +1,9 @@{  "name": "status-lighthouse",  "private": true,  "type": "module",  "scripts": {    "dev": "vite build --watch",    "build": "vite build"  },  "comment": "Root-level package only exists so node_modules/.bin/lighthouse is on disk for the Rust binary to invoke. Frontend deps live in frontend/.",  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "chart.js": "^3.7.1",    "d3": "^7.4.5",    "datamaps": "^0.5.9",    "js-cookie": "^3.0.1",    "lighthouse": "^9.6.3",    "topojson": "^3.0.2"  },  "devDependencies": {    "sass": "^1.53.0",    "vite": "^6.3.1"    "lighthouse": "^12.0.0"  }}
deleted pages/__init__.py
deleted pages/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass PagesConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'pages'
deleted pages/models.py
@@ -1,3 +0,0 @@from django.db import models# Create your models here.
deleted pages/templates/pages/changelog.html
@@ -1,168 +0,0 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}{% include "includes/social.html" %}{% endblock %}{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet">{% endblock %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-8 offset-lg-2">      <div class="section-label mb-2">release log</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Shipped changes, incidents, and the occasional tease for something coming.</p>    </div>  </div>  <div class="row mt-4">    <div class="col-lg-8 offset-lg-2">      <div class="changelog-entry">        <div class="changelog-date">2026-04-18</div>        <ul>          <li>Centralize SQLite settings and expand PRAGMA tuning (WAL, synchronous, cache, mmap, temp_store)</li>          <li>Commit alert state before firing notifications so a crash mid-alert can't cause duplicate sends</li>          <li>Harden the chromium wrapper with timeouts, <code>/tmp</code> temp files, and <code>--headless=new</code></li>          <li>Redesign alert emails to match the warm-earth site palette</li>          <li>Stop wedge-reset from failing a healthy queued backlog of checks</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-17</div>        <ul>          <li>Redesign the whole dashboard with a warm-earth palette — mossy forest green and amber on deep charcoal, Monaspace Argon throughout</li>          <li>Rework the property page with live-signals tiles, Lighthouse score cards, styled Chart.js panels, and compact insight tables</li>          <li>Add recent-uptime bar strips and rolling uptime percentage to every property row on the dashboard</li>          <li>Surface the full Lighthouse performance breakdown — weighted metric tiles and top savings opportunities, ranked by impact</li>          <li>Swap the 🚀 favicon for a themed SVG tile matching the in-app logo</li>          <li>Update the chromium wrapper to also pick up <code>google-chrome</code> so the webdev container runs locally without extra setup</li>          <li>Fix logout 405 by switching to a POST form + CSRF (Django 5 <code>LogoutView</code> is POST-only)</li>          <li>Refactor the scheduler with thread pools and graceful shutdown</li>          <li>Collapse the web and scheduler services into a single container via a Python supervisor in <code>entrypoint.py</code></li>          <li>Replace the Exim relay with a Python direct-to-MX email backend</li>          <li>Set <code>PYTHONUNBUFFERED</code> so scheduler output reaches <code>docker logs</code></li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-16</div>        <ul>          <li>Add live crawl/lighthouse status panel with real-time recrawl and rerun buttons</li>          <li>Rewrite the SEO crawler in-process using <code>requests</code> + <code>BeautifulSoup</code> — no more Scrapy subprocess</li>          <li>Harden the scheduler, alert state machine, and crawl pipeline against races and partial failures</li>          <li>Harden the Lighthouse runner and surface failure reasons in the UI</li>          <li>Migrate the frontend build from Webpack/Yarn to Vite/Bun</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-09</div>        <ul>          <li>Migrate Python package management from Pipenv/pyenv to <code>uv</code></li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-03</div>        <ul>          <li>Bind the Docker port to localhost only — host should proxy in</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2025-06-01</div>        <ul>          <li>Refactor property email templates to share a base</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2025-05-31</div>        <ul>          <li>Improve alert system with state tracking and recovery notifications — no more alert spam on flapping sites</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-25</div>        <ul>          <li>Add recrawl button</li>          <li>Run crawler weekly on a schedule</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-23</div>        <ul>          <li>Add crawler insights — title, description, canonical, OG tags, H1 extraction per page</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-16</div>        <ul>          <li>Add docker init to handle Lighthouse Chrome zombies</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-15</div>        <ul>          <li>Add check queue to be more resource friendly</li>          <li>Add Lighthouse scores</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-04</div>        <ul>          <li>Add properties listing pagination</li>          <li>Add properties listing search</li>          <li>Improve properties listing UI</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-26</div>        <ul>          <li>Add HSTS monitoring</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-25</div>        <ul>          <li>Add uptime chart</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-19</div>        <ul>          <li>Create status project</li>        </ul>      </div>    </div>  </div></div>{% endblock %}
deleted pages/templates/robots.txt
@@ -1,4 +0,0 @@User-agent: *Allow: /Sitemap: {{BASE_URL}}/sitemap.xml
deleted pages/templates/sitemap.xml
@@ -1,11 +0,0 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">  <url>    <loc>{{BASE_URL}}/</loc>    <lastmod>2022-06-18</lastmod>  </url>  <url>    <loc>{{BASE_URL}}/changelog/</loc>    <lastmod>2022-06-18</lastmod>  </url></urlset>
deleted pages/templatetags/__init__.py
deleted pages/templatetags/social.py
@@ -1,38 +0,0 @@"""Template tags for social media and sharing in general."""import hashlibimport ioimport threadingfrom django import templatefrom django.core.files.storage import default_storagefrom django.utils.html import format_htmlfrom django.utils import timezonefrom status.chromium import generate_screenshot_from_urlregister = template.Library()@register.simple_tagdef og_image(url):    filename = hashlib.md5(url.encode("utf-8")).hexdigest()    filename = f"opengraph/{filename}.png"    if not default_storage.exists(filename):        # save a quick file to prevent infinite loop        default_storage.save(filename, io.BytesIO())        generate_screenshot_from_url(url, filename)    else:        # if the file exists, check it's timestamp, if it's older than a day        # regenerate it with threading since the old one is okay for now        one_day_ago = timezone.now() - timezone.timedelta(days=1)        if default_storage.get_modified_time(filename) < one_day_ago:            threading.Thread(                target=generate_screenshot_from_url, args=(url, filename)            ).start()    # get full url including domain    url = default_storage.url(filename)    return format_html('<meta property="og:image" content="{}">', url)
deleted pages/urls.py
@@ -1,12 +0,0 @@from django.urls import pathfrom . import viewsurlpatterns = [    path('changelog/', views.changelog, name='changelog'),    path('favicon.ico', views.favicon, name='favicon'),    path('robots.txt', views.robots, name='robots'),    path('sitemap.xml', views.sitemap, name='sitemap'),    path('', views.home, name='home'),]
deleted pages/views.py
@@ -1,60 +0,0 @@from django.shortcuts import render, redirectfrom django.http import HttpResponsefrom properties.models import Check, Propertyfrom accounts.models import Userdef home(request):    if request.user.is_authenticated:        return redirect('properties')    context = {}    context['title'] = 'Home'    context['description'] = 'Made by Isaac Bythewood, simple status for people who want to host their own and hack on it a bit.'    total_statuses = Check.objects.all().count()    context['total_statuses'] = total_statuses    total_properties = Property.objects.all().count()    context['total_properties'] = total_properties    total_users = User.objects.all().count()    context['total_users'] = total_users    try:        first_status_created_at = Check.objects.all().order_by('created_at').first().created_at        context['first_status_created_at'] = first_status_created_at    except AttributeError:        context['first_status_created_at'] = None    return render(request, 'pages/home.html', context)def changelog(request):    context = {}    context['title'] = 'Changelog'    context['description'] = 'An ongoing changelog and upcoming list of features for Status.'    return render(request, 'pages/changelog.html', context)def favicon(request):    # Heartbeat/pulse mark — a single EKG-style waveform sweep, reads as    # "vital signs / liveness" and is distinct from bars, rings, and shells.    svg = (        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'        '<polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34" '        'fill="none" stroke="#6b9e78" stroke-width="6" '        'stroke-linejoin="round" stroke-linecap="round"/>'        '<circle cx="30" cy="14" r="3.5" fill="#c9a84c"/>'        "</svg>"    )    return HttpResponse(svg, content_type="image/svg+xml")def robots(request):    return render(request, 'robots.txt', content_type='text/plain')def sitemap(request):    return render(request, 'sitemap.xml', content_type='text/xml')
deleted properties/__init__.py
deleted properties/admin.py
@@ -1,37 +0,0 @@from django.contrib import adminfrom .models import Property, Checkclass PropertyAdmin(admin.ModelAdmin):    list_display = (        "id",        "url",        "user",        "total_checks",        "last_run_at",        "next_run_at",        "should_check",    )    list_filter = ("user__username",)    search_fields = (        "url",        "user__username",    )    ordering = ("user",)admin.site.register(Property, PropertyAdmin)class CheckAdmin(admin.ModelAdmin):    list_display = (        "property",        "status_code",        "response_time",        "created_at",    )    list_filter = ("property__url", "created_at")admin.site.register(Check, CheckAdmin)
deleted properties/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass PropertiesConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'properties'
deleted properties/forms.py
@@ -1,22 +0,0 @@import requestsfrom django import formsfrom .models import Propertyclass PropertyForm(forms.ModelForm):    class Meta:        model = Property        fields = ('url',)    def clean_url(self):        url = self.cleaned_data['url']        if not url.startswith('http'):            url = 'http://' + url        try:            r = requests.get(url)            r.raise_for_status()        except requests.exceptions.RequestException:            raise forms.ValidationError('Invalid URL')        return r.url  # NOTE: requests' "Final URL location of Response."
deleted properties/management/__init__.py
deleted properties/management/commands/__init__.py
deleted properties/management/commands/scheduler.py
@@ -1,209 +0,0 @@import loggingimport signalimport threadingfrom concurrent.futures import ThreadPoolExecutorfrom django import dbfrom django.core.management.base import BaseCommandfrom django.db.models import Qfrom django.utils import timezonefrom properties.models import Check, Propertylogger = logging.getLogger(__name__)class Command(BaseCommand):    # Two pools so slow lighthouse/crawler work can't starve quick HTTP pings.    SLOW_WORKERS = 2    FAST_WORKERS = 2    CYCLE_SECONDS = 30    CLEANUP_INTERVAL_SECONDS = 86400    def __init__(self):        super().__init__()        self._stop = threading.Event()        self._last_cleanup = None    def handle(self, *args, **options):        self.stdout.write("[Scheduler] Starting scheduler...")        signal.signal(signal.SIGTERM, self._on_signal)        signal.signal(signal.SIGINT, self._on_signal)        # Clear any running/queued states left over from a prior crash so        # rows don't sit stuck and block new runs.        Property.objects.filter(crawl_state__in=["queued", "running"]).update(            crawl_state="idle"        )        Property.objects.filter(lighthouse_state__in=["queued", "running"]).update(            lighthouse_state="idle"        )        slow = ThreadPoolExecutor(            max_workers=self.SLOW_WORKERS, thread_name_prefix="slow"        )        fast = ThreadPoolExecutor(            max_workers=self.FAST_WORKERS, thread_name_prefix="fast"        )        try:            while not self._stop.is_set():                try:                    self._enqueue_status(fast)                    self._enqueue_lighthouse(slow)                    self._enqueue_crawler(slow)                    self.reset_wedged_states()                    self._maybe_cleanup()                except Exception:                    logger.exception("[Scheduler] cycle error")                self.stdout.write(                    f"[Scheduler] Sleeping scheduler for {self.CYCLE_SECONDS} seconds..."                )                self._stop.wait(self.CYCLE_SECONDS)        finally:            self.stdout.write("[Scheduler] Stopping scheduler...")            slow.shutdown(wait=False, cancel_futures=True)            fast.shutdown(wait=False, cancel_futures=True)    def _on_signal(self, signum, frame):        self.stdout.write(f"[Scheduler] Received signal {signum}, shutting down...")        self._stop.set()    def reset_wedged_states(self):        """Flip running rows back to idle once they've overrun their deadline.        Only "running" rows count as wedged. "queued" rows are waiting their        turn in the thread pool and will be picked up when a worker frees up;        flipping them here would mark healthy backlog as failed whenever the        user fans out manual re-triggers.        The startup path in handle() also wipes any leftover queued/running        state unconditionally to cover crashes.        """        now = timezone.now()        crawl_cutoff = now - timezone.timedelta(seconds=900)        lh_cutoff = now - timezone.timedelta(seconds=300)        Property.objects.filter(            crawl_state="running",            crawl_started_at__lt=crawl_cutoff,        ).update(            crawl_state="idle",            last_crawl_error="Crawl timed out or was interrupted",        )        Property.objects.filter(            lighthouse_state="running",            lighthouse_started_at__lt=lh_cutoff,        ).update(            lighthouse_state="idle",            last_lighthouse_error="Lighthouse run timed out or was interrupted",        )    def _maybe_cleanup(self):        now = timezone.now()        if (            self._last_cleanup            and (now - self._last_cleanup).total_seconds()            < self.CLEANUP_INTERVAL_SECONDS        ):            return        self.stdout.write("[Scheduler] Cleaning checks older than 3 days...")        Check.objects.filter(            created_at__lt=now - timezone.timedelta(days=3)        ).delete()        self._last_cleanup = now    def _enqueue_status(self, pool):        now = timezone.now()        due = list(            Property.objects.filter(                Q(last_run_at__isnull=True)                | Q(next_run_at__isnull=True)                | Q(next_run_at__lte=now)            )        )        for p in due:            p.next_run_at = p.get_next_run_at()            p.last_run_at = timezone.now()            p.save(update_fields=["next_run_at", "last_run_at"])            pool.submit(self._run_status, p.id)        db.connections.close_all()    def _enqueue_lighthouse(self, pool):        now = timezone.now()        due = list(            Property.objects.filter(                Q(last_lighthouse_run_at__isnull=True)                | Q(next_lighthouse_run_at__isnull=True)                | Q(next_lighthouse_run_at__lte=now)            ).exclude(lighthouse_state__in=["queued", "running"])        )        for p in due:            p.next_lighthouse_run_at = p.get_next_run_at_lighthouse()            p.last_lighthouse_run_at = timezone.now()            p.lighthouse_state = "queued"            p.save(                update_fields=[                    "next_lighthouse_run_at",                    "last_lighthouse_run_at",                    "lighthouse_state",                ]            )            pool.submit(self._run_lighthouse, p.id)        db.connections.close_all()    def _enqueue_crawler(self, pool):        now = timezone.now()        due = list(            Property.objects.filter(                Q(last_run_at_crawler__isnull=True)                | Q(next_run_at_crawler__isnull=True)                | Q(next_run_at_crawler__lte=now)            ).exclude(crawl_state__in=["queued", "running"])        )        for p in due:            p.next_run_at_crawler = p.get_next_run_at_crawl()            p.last_run_at_crawler = timezone.now()            p.crawl_state = "queued"            p.save(                update_fields=[                    "next_run_at_crawler",                    "last_run_at_crawler",                    "crawl_state",                ]            )            pool.submit(self._run_crawler, p.id)        db.connections.close_all()    def _run_status(self, property_id):        try:            prop = Property.objects.get(id=property_id)            self.stdout.write(f"[Scheduler] Checking status {prop.url}")            prop.process_check()        except Exception:            logger.exception("[Scheduler] status check failed for %s", property_id)        finally:            db.close_old_connections()    def _run_lighthouse(self, property_id):        try:            prop = Property.objects.get(id=property_id)            self.stdout.write(f"[Scheduler] Checking lighthouse {prop.url}")            prop.process_check_lighthouse()        except Exception:            logger.exception("[Scheduler] lighthouse failed for %s", property_id)        finally:            db.close_old_connections()    def _run_crawler(self, property_id):        try:            prop = Property.objects.get(id=property_id)            self.stdout.write(f"[Scheduler] Checking crawler {prop.url}")            prop.crawl_site()        except Exception:            logger.exception("[Scheduler] crawler failed for %s", property_id)        finally:            db.close_old_connections()
deleted properties/migrations/0001_initial.py
@@ -1,56 +0,0 @@# Generated by Django 4.0.5 on 2022-06-19 23:33from django.conf import settingsfrom django.db import migrations, modelsimport django.db.models.deletionimport uuidclass Migration(migrations.Migration):    initial = True    dependencies = [        migrations.swappable_dependency(settings.AUTH_USER_MODEL),    ]    operations = [        migrations.CreateModel(            name='Property',            fields=[                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),                ('url', models.CharField(max_length=255)),                ('is_public', models.BooleanField(default=False)),                ('run_interval', models.IntegerField(choices=[(60, 'Every 1 minute'), (180, 'Every 3 minutes'), (300, 'Every 5 minutes'), (900, 'Every 15 minutes'), (1800, 'Every 30 minutes'), (3600, 'Every hour')], default=180)),                ('last_run_at', models.DateTimeField(blank=True, null=True)),                ('next_run_at', models.DateTimeField(blank=True, null=True)),                ('created_at', models.DateTimeField(auto_now_add=True)),                ('updated_at', models.DateTimeField(auto_now=True)),                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='properties', to=settings.AUTH_USER_MODEL)),            ],            options={                'verbose_name': 'Property',                'verbose_name_plural': 'Properties',            },        ),        migrations.CreateModel(            name='Check',            fields=[                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),                ('status_code', models.IntegerField()),                ('response_time', models.IntegerField(default=0)),                ('headers', models.JSONField(default=dict)),                ('created_at', models.DateTimeField(auto_now_add=True)),                ('property', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='properties.property')),            ],            options={                'verbose_name': 'Check',                'verbose_name_plural': 'Checks',                'get_latest_by': 'created_at',            },        ),        migrations.AddIndex(            model_name='check',            index=models.Index(fields=['created_at'], name='properties__created_8f285e_idx'),        ),    ]
deleted properties/migrations/0002_property_last_lighthouse_run_at_and_more.py
@@ -1,28 +0,0 @@# Generated by Django 4.0.6 on 2022-07-15 17:23from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0001_initial'),    ]    operations = [        migrations.AddField(            model_name='property',            name='last_lighthouse_run_at',            field=models.DateTimeField(blank=True, null=True),        ),        migrations.AddField(            model_name='property',            name='lighthouse_scores',            field=models.JSONField(blank=True, null=True),        ),        migrations.AddField(            model_name='property',            name='next_lighthouse_run_at',            field=models.DateTimeField(blank=True, null=True),        ),    ]
deleted properties/migrations/0003_property_properties__url_7229e0_idx_and_more.py
@@ -1,21 +0,0 @@# Generated by Django 4.0.6 on 2022-07-17 03:05from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0002_property_last_lighthouse_run_at_and_more'),    ]    operations = [        migrations.AddIndex(            model_name='property',            index=models.Index(fields=['url'], name='properties__url_7229e0_idx'),        ),        migrations.AddIndex(            model_name='property',            index=models.Index(fields=['user'], name='properties__user_id_c32bda_idx'),        ),    ]
deleted properties/migrations/0004_remove_property_run_interval.py
@@ -1,17 +0,0 @@# Generated by Django 4.0.6 on 2022-07-23 00:33from django.db import migrationsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0003_property_properties__url_7229e0_idx_and_more'),    ]    operations = [        migrations.RemoveField(            model_name='property',            name='run_interval',        ),    ]
deleted properties/migrations/0005_property_last_run_at_crawler_and_more.py
@@ -1,23 +0,0 @@# Generated by Django 4.0.6 on 2022-07-23 01:50from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0004_remove_property_run_interval'),    ]    operations = [        migrations.AddField(            model_name='property',            name='last_run_at_crawler',            field=models.DateTimeField(blank=True, null=True),        ),        migrations.AddField(            model_name='property',            name='next_run_at_crawler',            field=models.DateTimeField(blank=True, null=True),        ),    ]
deleted properties/migrations/0006_property_crawler_insights.py
@@ -1,18 +0,0 @@# Generated by Django 4.0.6 on 2022-07-23 02:36from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0005_property_last_run_at_crawler_and_more'),    ]    operations = [        migrations.AddField(            model_name='property',            name='crawler_insights',            field=models.JSONField(blank=True, null=True),        ),    ]
deleted properties/migrations/0007_add_alert_state_tracking.py
@@ -1,25 +0,0 @@# Generated by Django 4.1 on 2025-05-31 20:08from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ("properties", "0006_property_crawler_insights"),    ]    operations = [        migrations.AddField(            model_name="property",            name="alert_state",            field=models.CharField(                choices=[("up", "Up"), ("down", "Down")], default="up", max_length=10            ),        ),        migrations.AddField(            model_name="property",            name="last_alert_sent",            field=models.DateTimeField(blank=True, null=True),        ),    ]
deleted properties/migrations/0008_lighthouse_error_tracking.py
@@ -1,21 +0,0 @@from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0007_add_alert_state_tracking'),    ]    operations = [        migrations.AddField(            model_name='property',            name='last_lighthouse_success_at',            field=models.DateTimeField(blank=True, null=True),        ),        migrations.AddField(            model_name='property',            name='last_lighthouse_error',            field=models.TextField(blank=True, null=True),        ),    ]
deleted properties/migrations/0009_check_properties__propert_b3e3ac_idx.py
@@ -1,20 +0,0 @@# Generated by Django 6.0.4 on 2026-04-16 23:01from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ("properties", "0008_lighthouse_error_tracking"),    ]    operations = [        migrations.AddIndex(            model_name="check",            index=models.Index(                fields=["property", "-created_at"],                name="properties__propert_b3e3ac_idx",            ),        ),    ]
deleted properties/migrations/0010_crawl_lighthouse_state_tracking.py
@@ -1,74 +0,0 @@# Generated by Django 6.0.4 on 2026-04-16 23:39from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ("properties", "0009_check_properties__propert_b3e3ac_idx"),    ]    operations = [        migrations.AddField(            model_name="property",            name="crawl_started_at",            field=models.DateTimeField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="crawl_state",            field=models.CharField(                choices=[                    ("idle", "Idle"),                    ("queued", "Queued"),                    ("running", "Running"),                ],                default="idle",                max_length=10,            ),        ),        migrations.AddField(            model_name="property",            name="last_crawl_duration_ms",            field=models.IntegerField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="last_crawl_error",            field=models.TextField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="last_crawl_pages_count",            field=models.IntegerField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="last_crawl_success_at",            field=models.DateTimeField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="last_lighthouse_duration_ms",            field=models.IntegerField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="lighthouse_started_at",            field=models.DateTimeField(blank=True, null=True),        ),        migrations.AddField(            model_name="property",            name="lighthouse_state",            field=models.CharField(                choices=[                    ("idle", "Idle"),                    ("queued", "Queued"),                    ("running", "Running"),                ],                default="idle",                max_length=10,            ),        ),    ]
deleted properties/migrations/0011_property_lighthouse_details.py
@@ -1,18 +0,0 @@# Generated by Django 6.0.4 on 2026-04-16 23:59from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ("properties", "0010_crawl_lighthouse_state_tracking"),    ]    operations = [        migrations.AddField(            model_name="property",            name="lighthouse_details",            field=models.JSONField(blank=True, null=True),        ),    ]
deleted properties/migrations/__init__.py
deleted properties/models.py
@@ -1,502 +0,0 @@import loggingimport reimport timeimport uuidimport requestsfrom django.conf import settingsfrom django.contrib.auth import get_user_modelfrom django.core.mail import EmailMessagefrom django.db import models, transactionfrom django.template.loader import render_to_stringfrom django.utils import timezonefrom django.utils.functional import cached_propertyfrom crawler.runner import run_seo_spiderfrom status.lighthouse import (    LighthouseError,    fetch_lighthouse_results,    parse_lighthouse_results,    parse_performance_details,)logger = logging.getLogger(__name__)User = get_user_model()class SecurityMixin:    @property    def invalid_cert(self):        return self.statuses.latest("created_at").status_code == 526    @property    def is_https(self):        return self.url.startswith("https://")    @property    def has_mime_type(self):        return self.latest_headers.get("content-type", None) is not None    @property    def has_content_sniffing_protection(self):        return self.latest_headers.get("x-content-type-options", None) == "nosniff"    @property    def has_xss_protection(self):        return self.latest_headers.get("x-xss-protection", None) == "1; mode=block"    @property    def has_clickjack_protection(self):        return self.latest_headers.get("x-frame-options", None) in [            "deny",            "sameorigin",            "allow-from",        ]    @property    def hides_server_version(self):        if (            self.latest_headers.get("server", None) is None            and self.latest_headers.get("x-server", None) is None            and self.latest_headers.get("powered-by", None) is None            and self.latest_headers.get("x-powered-by", None) is None        ):            return True        return False    @property    def has_hsts(self):        # hsts has at least one year set        hsts = self.latest_headers.get("strict-transport-security", None)        if hsts is None:            return False        # use re to get the max-age value        max_age = re.search(r"max-age=(\d+)", hsts)        if max_age is None:            return False        # convert to int and compare        max_age = int(max_age.group(1))        return max_age >= 31536000    @property    def has_hsts_preload(self):        hsts = self.latest_headers.get("strict-transport-security", None)        if hsts is None:            return False        return "preload" in hsts.lower()    @property    def has_security_issue(self):        if not self.is_https:            return True        if not self.has_mime_type:            return True        if not self.has_content_sniffing_protection:            return True        if not self.has_xss_protection:            return True        if not self.has_clickjack_protection:            return True        if not self.hides_server_version:            return True        if not self.has_hsts:            return True        if not self.has_hsts_preload:            return True        return Falseclass AlertsMixin:    def send_down_email(self):        subject = f"Status: {self.name} is down!"        context = {"property": self, "BASE_URL": settings.BASE_URL}        message = render_to_string("emails/property_down.html", context)        from_email = "noreply@bythewood.me"        to_emails = [self.user.email]        email = EmailMessage(subject, message, from_email, to_emails)        email.content_subtype = "html"        try:            email.send()        except Exception:            logger.exception("Failed to send down email for %s", self.url)    def send_recovery_email(self):        subject = f"Status: {self.name} is back up!"        context = {"property": self, "BASE_URL": settings.BASE_URL}        message = render_to_string("emails/property_recovery.html", context)        from_email = "noreply@bythewood.me"        to_emails = [self.user.email]        email = EmailMessage(subject, message, from_email, to_emails)        email.content_subtype = "html"        try:            email.send()        except Exception:            logger.exception("Failed to send recovery email for %s", self.url)    def send_down_discord_message(self):        if self.user.discord_webhook_url:            payload = {                "username": "Status",                "embeds": [                    {                        "title": "Status Alert",                        "description": f"{self.url} is down!",                        "color": 16711680,  # Red                        "timestamp": timezone.now().isoformat(),                    }                ],            }            try:                requests.post(self.user.discord_webhook_url, json=payload, timeout=5)            except requests.RequestException:                logger.exception("Discord down webhook failed for %s", self.url)    def send_recovery_discord_message(self):        if self.user.discord_webhook_url:            payload = {                "username": "Status",                "embeds": [                    {                        "title": "Status Recovery",                        "description": f"{self.url} is back up!",                        "color": 65280,  # Green                        "timestamp": timezone.now().isoformat(),                    }                ],            }            try:                requests.post(self.user.discord_webhook_url, json=payload, timeout=5)            except requests.RequestException:                logger.exception("Discord recovery webhook failed for %s", self.url)    def send_alerts(self, current_status_code):        """        Send alerts based on state transitions:        - Send 'down' alert when site goes from UP to DOWN        - Send 'recovery' alert when site goes from DOWN to UP        - No alerts for consecutive failures or consecutive successes        """        is_currently_up = current_status_code == 200        # Commit the state transition inside the atomic block BEFORE firing        # notifications. If the save raises (e.g. SQLite "database is locked"        # from a concurrent writer), the transaction rolls back and nothing        # is emailed — the next check will retry from the same state. Sending        # first would mean a failed save leaves us firing the same alert on        # every subsequent check.        transition = None        with transaction.atomic():            locked = Property.objects.select_for_update().get(pk=self.pk)            if is_currently_up and locked.alert_state == "down":                transition = "recovery"            elif not is_currently_up and locked.alert_state == "up":                checks = self.statuses.order_by("-created_at")[:2]                if (                    len(checks) >= 2                    and checks[0].status_code != 200                    and checks[1].status_code != 200                ):                    transition = "down"            if transition is not None:                locked.alert_state = "up" if transition == "recovery" else "down"                locked.last_alert_sent = timezone.now()                locked.save(update_fields=["alert_state", "last_alert_sent"])                self.alert_state = locked.alert_state                self.last_alert_sent = locked.last_alert_sent        if transition == "recovery":            self.send_recovery_email()            self.send_recovery_discord_message()        elif transition == "down":            self.send_down_email()            self.send_down_discord_message()class CrawlerMixin:    def get_next_run_at_crawl(self):        """Weekly crawl by default; users can trigger a recrawl anytime."""        return timezone.now() + timezone.timedelta(days=7)    def should_check_crawl(self):        now = timezone.now()        if self.last_run_at_crawler is None:            return True        if self.next_run_at_crawler is None:            return True        return self.next_run_at_crawler <= now    def _report_crawl_progress(self, pages_count):        Property.objects.filter(pk=self.pk).update(last_crawl_pages_count=pages_count)    def crawl_site(self):        Property.objects.filter(pk=self.pk).update(            crawl_state="running",            crawl_started_at=timezone.now(),            last_crawl_pages_count=0,        )        start = time.monotonic()        try:            insights = run_seo_spider(self.url, progress_cb=self._report_crawl_progress)        except Exception as e:            logger.exception("Crawl failed for %s", self.url)            Property.objects.filter(pk=self.pk).update(                crawl_state="idle",                last_crawl_error=f"{type(e).__name__}: {e}",                last_crawl_duration_ms=int((time.monotonic() - start) * 1000),            )            return        duration_ms = int((time.monotonic() - start) * 1000)        Property.objects.filter(pk=self.pk).update(            crawler_insights=insights,            crawl_state="idle",            last_crawl_success_at=timezone.now(),            last_crawl_error=None,            last_crawl_duration_ms=duration_ms,        )class Property(CrawlerMixin, AlertsMixin, SecurityMixin, models.Model):    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="properties")    url = models.CharField(max_length=255)    is_public = models.BooleanField(default=False)    last_run_at = models.DateTimeField(blank=True, null=True)    next_run_at = models.DateTimeField(blank=True, null=True)    last_run_at_crawler = models.DateTimeField(blank=True, null=True)    next_run_at_crawler = models.DateTimeField(blank=True, null=True)    crawler_insights = models.JSONField(blank=True, null=True)    crawl_state = models.CharField(        max_length=10,        choices=[("idle", "Idle"), ("queued", "Queued"), ("running", "Running")],        default="idle",    )    crawl_started_at = models.DateTimeField(blank=True, null=True)    last_crawl_success_at = models.DateTimeField(blank=True, null=True)    last_crawl_error = models.TextField(blank=True, null=True)    last_crawl_duration_ms = models.IntegerField(blank=True, null=True)    last_crawl_pages_count = models.IntegerField(blank=True, null=True)    lighthouse_scores = models.JSONField(blank=True, null=True)    lighthouse_details = models.JSONField(blank=True, null=True)    last_lighthouse_run_at = models.DateTimeField(blank=True, null=True)    last_lighthouse_success_at = models.DateTimeField(blank=True, null=True)    last_lighthouse_error = models.TextField(blank=True, null=True)    last_lighthouse_duration_ms = models.IntegerField(blank=True, null=True)    next_lighthouse_run_at = models.DateTimeField(blank=True, null=True)    lighthouse_state = models.CharField(        max_length=10,        choices=[("idle", "Idle"), ("queued", "Queued"), ("running", "Running")],        default="idle",    )    lighthouse_started_at = models.DateTimeField(blank=True, null=True)    # Alert state tracking    last_alert_sent = models.DateTimeField(blank=True, null=True)    alert_state = models.CharField(        max_length=10, choices=[("up", "Up"), ("down", "Down")], default="up"    )    created_at = models.DateTimeField(auto_now_add=True)    updated_at = models.DateTimeField(auto_now=True)    class Meta:        verbose_name = "Property"        verbose_name_plural = "Properties"        indexes = [            models.Index(fields=["url"]),            models.Index(fields=["user"]),        ]    def __str__(self):        return self.url    @property    def name(self):        return self.url.split("/")[2].replace("www.", "")    def get_next_run_at(self):        now = timezone.now()        return now.replace(            minute=(now.minute // 3) * 3, second=0, microsecond=0        ) + timezone.timedelta(minutes=3)    def should_check(self):        now = timezone.now()        if self.last_run_at is None:            return True        if self.next_run_at is None:            return True        return self.next_run_at <= now    def run_check(self):        try:            headers = {                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.115 Safari/537.36 Status/1.0.0"            }            response = requests.get(self.url, timeout=(3, 10), headers=headers)            response_time = response.elapsed.total_seconds() * 1000            status_code = response.status_code            headers = response.headers        except requests.exceptions.SSLError:            response_time = 10000            status_code = 526            headers = {}        except (requests.exceptions.RequestException, requests.exceptions.Timeout):            response_time = 10000            status_code = 408            headers = {}        return Check.objects.create(            property=self,            status_code=status_code,            response_time=response_time,            headers=dict(headers),        )    def process_check(self):        check = self.run_check()        # Always check for state changes, regardless of current status        self.send_alerts(check.status_code)    def get_next_run_at_lighthouse(self):        """        Should check daily.        """        return timezone.now() + timezone.timedelta(days=1)    def should_check_lighthouse(self):        now = timezone.now()        if self.last_lighthouse_run_at is None:            return True        if self.next_lighthouse_run_at is None:            return True        return self.next_lighthouse_run_at <= now    def process_check_lighthouse(self):        self.run_check_lighthouse()    def run_check_lighthouse(self):        Property.objects.filter(pk=self.pk).update(            lighthouse_state="running",            lighthouse_started_at=timezone.now(),        )        start = time.monotonic()        try:            results = fetch_lighthouse_results(self.url)            scores = parse_lighthouse_results(results)            details = parse_performance_details(results)        except LighthouseError as e:            logger.warning("Lighthouse failed for %s: %s", self.url, e)            Property.objects.filter(pk=self.pk).update(                lighthouse_state="idle",                last_lighthouse_error=str(e),                last_lighthouse_duration_ms=int((time.monotonic() - start) * 1000),            )            return        except Exception as e:            logger.exception("Unexpected lighthouse error for %s", self.url)            Property.objects.filter(pk=self.pk).update(                lighthouse_state="idle",                last_lighthouse_error=f"{type(e).__name__}: {e}",                last_lighthouse_duration_ms=int((time.monotonic() - start) * 1000),            )            return        Property.objects.filter(pk=self.pk).update(            lighthouse_scores=scores,            lighthouse_details=details,            last_lighthouse_success_at=timezone.now(),            last_lighthouse_error=None,            last_lighthouse_duration_ms=int((time.monotonic() - start) * 1000),            lighthouse_state="idle",        )    @property    def total_checks(self):        return self.statuses.count()    @cached_property    def current_status(self):        try:            return self.statuses.latest("created_at").status_code        except Check.DoesNotExist:            return 200    @property    def avg_response_time(self):        try:            return int(                self.statuses.all()[:31].aggregate(models.Avg("response_time"))[                    "response_time__avg"                ]            )        except TypeError:            return 0    @cached_property    def latest_headers(self):        try:            # return all headers lowercase to make them easier to use            return {                k.lower(): v.lower() for k, v in self.statuses.latest().headers.items()            }        except Check.DoesNotExist:            return {}    @cached_property    def avg_lighthouse_score(self):        if self.lighthouse_scores:            scores = [score for score in self.lighthouse_scores.values()]            return round(sum(scores) / len(scores))    def recent_tick_stream(self, limit=30):        """Most-recent-first list of 'up' / 'down' strings for the uptime strip."""        codes = list(            self.statuses.order_by("-created_at").values_list("status_code", flat=True)[:limit]        )        return ["up" if c == 200 else "down" for c in reversed(codes)]    @cached_property    def recent_uptime_pct(self):        """Uptime percentage over the most recent 100 checks, rounded to one decimal."""        codes = list(            self.statuses.order_by("-created_at").values_list("status_code", flat=True)[:100]        )        if not codes:            return None        up = sum(1 for c in codes if c == 200)        return round((up / len(codes)) * 100, 1)class Check(models.Model):    property = models.ForeignKey(        Property, on_delete=models.CASCADE, related_name="statuses", editable=False    )    status_code = models.IntegerField()    response_time = models.IntegerField(default=0)    headers = models.JSONField(default=dict)    created_at = models.DateTimeField(auto_now_add=True, editable=False)    class Meta:        verbose_name = "Check"        verbose_name_plural = "Checks"        indexes = [            models.Index(fields=["created_at"]),            models.Index(fields=["property", "-created_at"]),        ]        get_latest_by = "created_at"    def __str__(self):        return f"{self.property.url} - {self.created_at} - {self.status_code}"
deleted properties/static_src/scripts/property_is_public.js
@@ -1,17 +0,0 @@document.addEventListener("DOMContentLoaded", function () {  const form = document.getElementById("is-public-form");  if (!form) { return; }  form.addEventListener("change", function () {    let url = new URL(window.location.href);    url = url.origin + url.pathname + "is-public/";    fetch(url, {      method: "POST",      headers: {        "Content-Type": "application/json",        "X-CSRFToken": form.querySelector("input[name=csrfmiddlewaretoken]").value,      },    }).then(function () {      window.location.reload();    });  });});
deleted properties/templates/emails/property_down.html
@@ -1 +0,0 @@{% include "emails/property_email_base.html" with email_title="Property down" preheader=property.name|add:" is not responding" status_label="Down" event_title="Your property is down." event_copy="We've observed two consecutive failed checks in a row. Monitoring will continue in the background and you'll get another note the moment it recovers." accent="#c47055" accent_bright="#e38871" accent_tint="#201712" accent_border="#36231b" %}
deleted properties/templates/emails/property_recovery.html
@@ -1 +0,0 @@{% include "emails/property_email_base.html" with email_title="Property recovered" preheader=property.name|add:" is back online" status_label="Recovered" event_title="Your property is back online." event_copy="The latest check returned a healthy response. Everything looks normal again — we'll keep monitoring and alert you if anything changes." accent="#6b9e78" accent_bright="#7db88c" accent_tint="#191e17" accent_border="#222d22" %}
deleted properties/templates/properties/properties.html
@@ -1,218 +0,0 @@{% extends 'base.html' %}{% load static %}{% block extra_js %}<script type="module" src="{% static 'properties.js' %}"></script>{% endblock %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-4">  <div class="row align-items-center g-3 mb-4">    <div class="col-md-5">      <div class="section-label mb-1">operator · {{ request.user.username }}</div>      <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted mb-0 small mt-1">Every URL you've registered, current probe state, and audit signals.</p>    </div>    <div class="col-md-7">      <div class="dashboard-toolbar">        <form method="get" class="search-form flex-grow-1">          <span class="search-icon">            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">              <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>            </svg>          </span>          <input type="text" class="form-control search-input" name="q" id="id_search" placeholder="grep properties..." {% if q %}value="{{ q }}"{% endif %} />        </form>        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd">          + new        </button>      </div>    </div>  </div>  <div class="collapse mb-4" id="collapsePropertyAdd">    <div class="bg-surface border-subtle rounded border p-3">      <div class="section-label mb-2">register property</div>      <p class="text-muted small mb-3">Any public URL. Probe begins within 3 minutes.</p>      <form method="POST" class="dashboard-toolbar">        {% csrf_token %}        <input type="url" name="url" id="id_url" class="form-control flex-grow-1 {% if form.url.errors %}is-invalid{% endif %}" placeholder="https://example.com" required>        <button type="submit" class="btn btn-primary">Add →</button>      </form>      {% if form.errors %}<div class="text-danger small mt-2">{{ form.errors }}</div>{% endif %}    </div>  </div>  <div class="row g-3 mb-4">    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">total properties</div>        <div class="metric-value">{{ user.total_properties }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile {% if user.total_properties_down %}metric-accent-danger{% else %}metric-accent-green{% endif %}">        <div class="metric-label">currently down</div>        <div class="metric-value">{{ user.total_properties_down }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">checks logged</div>        <div class="metric-value">{{ user.total_checks }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-amber">        <div class="metric-label">probe cycle</div>        <div class="metric-value" style="font-size: 1.2rem;">3m · <span class="text-muted">live</span></div>        <div class="metric-sub">last sync: <span class="text-green">online</span></div>      </div>    </div>  </div>  <div class="section-label mb-2">registered properties</div>  {% for property in properties.object_list %}    <div class="property-row {% if property.current_status != 200 %}is-down{% endif %}">      <div class="pr-main">        <div class="pr-title">          <span class="status-dot {% if property.current_status == 200 %}is-up{% else %}is-down{% endif %}" aria-hidden="true"></span>          <a href="{% url 'property' property.id %}" class="text-truncate">{{ property.name }}</a>        </div>        <div class="pr-url">{{ property.url }}</div>        <div class="uptime-strip" aria-label="Recent checks">          {% for tick in property.recent_tick_stream %}            <span class="uptime-tick is-{{ tick }}"></span>          {% empty %}            {% for _ in "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" %}<span class="uptime-tick is-none"></span>{% endfor %}          {% endfor %}        </div>      </div>      <div class="pr-side">        <div class="pr-meta">          <div class="pr-meta-cell">            <span class="pr-meta-k">status</span>            {% if property.current_status == 200 %}              <span class="chip chip-ok">ok · 200</span>            {% else %}              <span class="chip chip-down">down · {{ property.current_status }}</span>            {% endif %}          </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">uptime</span>            {% if property.recent_uptime_pct is not None %}              {% if property.recent_uptime_pct >= 99 %}                <span class="chip chip-ok">{{ property.recent_uptime_pct }}%</span>              {% elif property.recent_uptime_pct < 95 %}                <span class="chip chip-down">{{ property.recent_uptime_pct }}%</span>              {% else %}                <span class="chip chip-warn">{{ property.recent_uptime_pct }}%</span>              {% endif %}            {% else %}              <span class="chip chip-muted">—</span>            {% endif %}          </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">sec</span>            {% if property.has_security_issue %}              <span class="chip chip-warn">issues</span>            {% else %}              <span class="chip chip-ok">ok</span>            {% endif %}          </div>          {% with insights=property.crawler_insights|length %}          <div class="pr-meta-cell">            <span class="pr-meta-k">seo</span>            {% if insights > 100 %}              <span class="chip chip-down">{{ insights }}</span>            {% elif insights > 25 %}              <span class="chip chip-warn">{{ insights }}</span>            {% else %}              <span class="chip chip-ok">{{ insights }}</span>            {% endif %}          </div>          {% endwith %}          <div class="pr-meta-cell">            <span class="pr-meta-k">lh</span>            {% if property.avg_lighthouse_score %}              {% if property.avg_lighthouse_score >= 90 %}                <span class="chip chip-ok">{{ property.avg_lighthouse_score }}%</span>              {% elif property.avg_lighthouse_score >= 80 %}                <span class="chip chip-warn">{{ property.avg_lighthouse_score }}%</span>              {% else %}                <span class="chip chip-down">{{ property.avg_lighthouse_score }}%</span>              {% endif %}            {% else %}              <span class="chip chip-muted">—</span>            {% endif %}          </div>        </div>        <div class="pr-actions">          {% if not property.is_protected %}          <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">            Delete          </button>          <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">            <div class="modal-dialog">              <div class="modal-content">                <div class="modal-header">                  <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>                </div>                <div class="modal-body">                  Are you sure you want to delete <strong class="text-white">{{ property.url }}</strong>? All checks and audit data will be removed.                </div>                <div class="modal-footer">                  <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                  <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>                </div>              </div>            </div>          </div>          {% endif %}          <a href="{% url 'property' property.id %}" class="btn btn-sm btn-outline-light">View →</a>        </div>      </div>    </div>  {% empty %}    <div class="text-center py-5 text-muted">      <div class="section-label mb-3" style="justify-content:center;">no properties yet</div>      <p class="mb-0 small">Click <strong>+ new</strong> above to register your first URL.</p>    </div>  {% endfor %}  {% if properties.paginator.num_pages > 1 %}  <div class="row mt-4">    <div class="col d-flex justify-content-center">      <nav aria-label="Pagination">        <ul class="pagination">          <li class="page-item {% if not properties.has_previous %}disabled{% endif %}">            <a class="page-link" {% if properties.has_previous %}href="?page={{ properties.previous_page_number }}"{% endif %}>← Prev</a>          </li>          {% for page_num in properties.paginator.page_range %}            <li class="page-item {% if properties.number == page_num %}active{% endif %}">              <a class="page-link" href="/properties/?page={{ page_num }}">{{ page_num }}</a>            </li>          {% endfor %}          <li class="page-item {% if not properties.has_next %}disabled{% endif %}">            <a class="page-link" {% if properties.has_next %}href="?page={{ properties.next_page_number }}"{% endif %}>Next →</a>          </li>        </ul>      </nav>    </div>  </div>  {% endif %}</div>{% endblock %}
deleted properties/templates/properties/property_report.html
@@ -1,341 +0,0 @@{% load static properties_tags %}<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <title>{{ property.name }} · Status Report</title>  <style>    @page { size: letter; margin: 0.6in; }    :root {      --ink: #1a1d1b;      --ink-soft: #4a4a44;      --ink-muted: #7a7a74;      --paper: #ffffff;      --paper-2: #f6f4ef;      --rule: #d8d4c8;      --accent: #4e7a5a;         /* deeper moss on white */      --accent-soft: #e8f0ea;      --amber: #9a7f2e;      --amber-soft: #f6efd7;      --danger: #9f3f2b;      --danger-soft: #f5e3dd;    }    * { box-sizing: border-box; }    html, body {      background: var(--paper);      color: var(--ink);      font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;      font-size: 10pt;      line-height: 1.5;      margin: 0;      padding: 0;    }    h1, h2, h3, h4 {      margin: 0 0 0.4em 0;      font-weight: 700;      color: var(--ink);      letter-spacing: -0.01em;    }    h1 { font-size: 22pt; line-height: 1.15; }    h2 { font-size: 12pt; margin-top: 1.4em; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); border-bottom: 1px solid var(--rule); padding-bottom: 0.25em; }    h3 { font-size: 10.5pt; margin-top: 1em; }    p { margin: 0 0 0.6em 0; }    a { color: var(--accent); text-decoration: none; }    code, .mono {      font-family: ui-monospace, Menlo, Consolas, 'Courier New', monospace;      font-size: 9pt;    }    .doc-header {      display: flex;      justify-content: space-between;      align-items: flex-end;      gap: 1em;      padding-bottom: 0.8em;      border-bottom: 2px solid var(--ink);      margin-bottom: 1em;    }    .doc-header .brand {      font-family: ui-monospace, Menlo, Consolas, monospace;      font-size: 9pt;      letter-spacing: 0.14em;      text-transform: uppercase;      color: var(--ink-muted);    }    .doc-header .brand strong { color: var(--ink); letter-spacing: 0.08em; }    .meta-url {      color: var(--ink-soft);      font-family: ui-monospace, Menlo, Consolas, monospace;      font-size: 9.5pt;    }    .meta-line {      color: var(--ink-muted);      font-size: 9pt;      font-family: ui-monospace, Menlo, Consolas, monospace;    }    /* Summary tiles */    .tile-grid {      display: grid;      grid-template-columns: repeat(4, 1fr);      gap: 0.5em;      margin: 1em 0;    }    .tile {      border: 1px solid var(--rule);      border-left: 3px solid var(--accent);      padding: 0.55em 0.7em;      background: var(--paper);    }    .tile.bad { border-left-color: var(--danger); }    .tile.warn { border-left-color: var(--amber); }    .tile-label {      font-size: 7.5pt;      letter-spacing: 0.1em;      text-transform: uppercase;      color: var(--ink-muted);      font-family: ui-monospace, Menlo, Consolas, monospace;    }    .tile-value {      font-size: 16pt;      font-weight: 700;      color: var(--ink);      line-height: 1.1;      margin-top: 0.15em;    }    .tile-sub { font-size: 8pt; color: var(--ink-muted); margin-top: 0.1em; }    /* Key/value block */    table.kv {      width: 100%;      border-collapse: collapse;      margin: 0.5em 0;    }    table.kv td {      padding: 0.3em 0.5em;      border-bottom: 1px solid var(--rule);      vertical-align: top;    }    table.kv td.k {      width: 35%;      color: var(--ink-muted);      font-size: 9pt;      letter-spacing: 0.04em;    }    /* Data tables */    table.data {      width: 100%;      border-collapse: collapse;      margin: 0.4em 0 1em;      font-size: 9pt;    }    table.data th, table.data td {      text-align: left;      padding: 0.35em 0.55em;      border-bottom: 1px solid var(--rule);      vertical-align: top;    }    table.data th {      background: var(--paper-2);      font-size: 8pt;      letter-spacing: 0.08em;      text-transform: uppercase;      color: var(--ink-muted);      font-weight: 600;      border-bottom: 1.5px solid var(--ink-muted);    }    table.data tbody tr:nth-child(even) td { background: var(--paper-2); }    /* Pass/fail marker used in security + checks */    .mark {      display: inline-block;      padding: 0.1em 0.5em;      font-size: 8pt;      font-family: ui-monospace, Menlo, Consolas, monospace;      letter-spacing: 0.06em;      text-transform: uppercase;      border-radius: 2px;      border: 1px solid transparent;    }    .mark.ok { background: var(--accent-soft); color: var(--accent); border-color: #b9d3c2; }    .mark.warn { background: var(--amber-soft); color: var(--amber); border-color: #e2d39b; }    .mark.fail { background: var(--danger-soft); color: var(--danger); border-color: #dfb2a7; }    .mark.muted { background: #f0ede5; color: var(--ink-muted); border-color: var(--rule); }    /* Security two-column grid */    .sec-grid {      display: grid;      grid-template-columns: 1fr 1fr;      gap: 0 1.2em;      margin: 0.3em 0 1em;    }    .sec-item {      display: flex;      justify-content: space-between;      padding: 0.3em 0;      border-bottom: 1px solid var(--rule);      font-size: 9pt;    }    .footer-note {      margin-top: 1.5em;      padding-top: 0.6em;      border-top: 1px solid var(--rule);      font-size: 8.5pt;      color: var(--ink-muted);    }    .insight-severity {      text-transform: uppercase;      font-size: 7.5pt;      letter-spacing: 0.08em;    }    .col-narrow { width: 90px; }    .col-short { width: 70px; text-align: right; }  </style></head><body>  <div class="doc-header">    <div>      <div class="brand"><strong>Status</strong> · property report</div>      <h1>{{ property.name }}</h1>      <a class="meta-url" href="{{ property.url }}">{{ property.url }}</a>    </div>    <div class="meta-line" style="text-align: right;">      generated · {% now "Y-m-d H:i" %}<br>      operator · {{ property.user.username }}    </div>  </div>  <h2>Live signals</h2>  <div class="tile-grid">    <div class="tile {% if property.current_status != 200 %}bad{% endif %}">      <div class="tile-label">HTTP Status</div>      <div class="tile-value">{{ property.current_status }}</div>      <div class="tile-sub">{% if property.current_status == 200 %}OK · all clear{% else %}failing{% endif %}</div>    </div>    <div class="tile {% if property.invalid_cert %}bad{% endif %}">      <div class="tile-label">TLS cert</div>      <div class="tile-value">{% if property.invalid_cert %}Invalid{% else %}Valid{% endif %}</div>      <div class="tile-sub">chain + trust</div>    </div>    <div class="tile {% if property.has_security_issue %}warn{% endif %}">      <div class="tile-label">Security</div>      <div class="tile-value">{% if property.has_security_issue %}Issues{% else %}Pass{% endif %}</div>      <div class="tile-sub">header scan</div>    </div>    <div class="tile {% if property.avg_response_time > 500 %}warn{% endif %}">      <div class="tile-label">Response</div>      <div class="tile-value">{{ property.avg_response_time }} <span style="font-size: 9pt; color: var(--ink-muted); font-weight: 500;">ms</span></div>      <div class="tile-sub">rolling avg · 31 checks</div>    </div>  </div>  <table class="kv">    <tr><td class="k">Alert state</td><td>{{ property.alert_state|title }}{% if property.last_alert_sent %} <span class="meta-line">· last alert {{ property.last_alert_sent|date:"Y-m-d H:i" }}</span>{% endif %}</td></tr>    <tr><td class="k">Visibility</td><td>{% if property.is_public %}Public{% else %}Private{% endif %}</td></tr>    <tr><td class="k">Registered</td><td>{{ property.created_at|date:"Y-m-d" }}</td></tr>    <tr><td class="k">Total checks</td><td>{{ property.total_checks }}</td></tr>    {% if property.recent_uptime_pct is not None %}    <tr><td class="k">Uptime (last 100)</td><td>{{ property.recent_uptime_pct }}%</td></tr>    {% endif %}  </table>  {% if property.lighthouse_scores %}  <h2>Lighthouse</h2>  <div class="tile-grid">    {% for category, score in property.lighthouse_scores.items %}    <div class="tile {% if score < 50 %}bad{% elif score < 90 %}warn{% endif %}">      <div class="tile-label">{{ category }}</div>      <div class="tile-value">{{ score }}<span style="font-size: 10pt; color: var(--ink-muted);">%</span></div>    </div>    {% endfor %}  </div>  {% endif %}  <h2>Security · header scan</h2>  <div class="sec-grid">    <div class="sec-item"><span>Use HTTPS</span><span class="mark {% if property.is_https %}ok{% else %}fail{% endif %}">{% if property.is_https %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Set MIME types</span><span class="mark {% if property.has_mime_type %}ok{% else %}fail{% endif %}">{% if property.has_mime_type %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Content sniff protection</span><span class="mark {% if property.has_content_sniffing_protection %}ok{% else %}fail{% endif %}">{% if property.has_content_sniffing_protection %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Clickjack protection</span><span class="mark {% if property.has_clickjack_protection %}ok{% else %}fail{% endif %}">{% if property.has_clickjack_protection %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>XSS protection</span><span class="mark {% if property.has_xss_protection %}ok{% else %}fail{% endif %}">{% if property.has_xss_protection %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Hide server version</span><span class="mark {% if property.hides_server_version %}ok{% else %}fail{% endif %}">{% if property.hides_server_version %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>HSTS enabled</span><span class="mark {% if property.has_hsts %}ok{% else %}fail{% endif %}">{% if property.has_hsts %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>HSTS preload</span><span class="mark {% if property.has_hsts_preload %}ok{% else %}fail{% endif %}">{% if property.has_hsts_preload %}pass{% else %}fail{% endif %}</span></div>  </div>  {% if property.lighthouse_details.metrics %}  <h2>Performance metrics</h2>  <table class="data">    <thead>      <tr><th>Acronym</th><th>Metric</th><th class="col-narrow">Value</th><th class="col-short">Weight</th><th class="col-short">Score</th></tr>    </thead>    <tbody>      {% for metric in property.lighthouse_details.metrics %}      <tr>        <td><strong>{{ metric.acronym }}</strong></td>        <td>{{ metric.title }}</td>        <td class="mono">{{ metric.display_value|default:"—" }}</td>        <td class="col-short">{{ metric.weight }}%</td>        <td class="col-short"><span class="mark {% if metric.score >= 0.9 %}ok{% elif metric.score >= 0.5 %}warn{% else %}fail{% endif %}">{{ metric.score|floatformat:"2" }}</span></td>      </tr>      {% endfor %}    </tbody>  </table>  {% endif %}  {% if property.lighthouse_details.opportunities %}  <h2>Top opportunities</h2>  <table class="data">    <thead>      <tr><th class="col-short">Score</th><th>Audit</th><th>Detail</th><th class="col-narrow">Est. savings</th></tr>    </thead>    <tbody>      {% for opp in property.lighthouse_details.opportunities %}      <tr>        <td class="col-short"><span class="mark {% if opp.score >= 0.9 %}ok{% elif opp.score >= 0.5 %}warn{% else %}fail{% endif %}">{{ opp.score|floatformat:"2" }}</span></td>        <td>{{ opp.title }}</td>        <td class="mono">{{ opp.display_value|default:"—" }}</td>        <td class="col-narrow">{% with saved=opp.savings_ms|format_ms_savings %}{% if saved %}<strong>{{ saved }}</strong>{% else %}—{% endif %}{% endwith %}</td>      </tr>      {% endfor %}    </tbody>  </table>  {% endif %}  {% if property.crawler_insights %}  <h2>Crawler insights</h2>  {% regroup property.crawler_insights|dictsort:"type" by type as insight_type_groups %}  {% for group in insight_type_groups %}  <h3>{{ group.grouper|capfirst }} <span class="meta-line">({{ group.list|length }})</span></h3>  <table class="data">    <thead>      <tr><th class="col-narrow">Severity</th><th>URL</th><th>Issue</th><th>Item</th></tr>    </thead>    <tbody>      {% for insight in group.list|dictsort:"severity" %}      <tr>        <td><span class="mark {% if insight.severity == 'error' %}fail{% elif insight.severity == 'warning' %}warn{% else %}muted{% endif %} insight-severity">{{ insight.severity }}</span></td>        <td class="mono">{{ insight.url|url_path }}</td>        <td>{{ insight.issue }}</td>        <td class="mono">{{ insight.item|default:"—" }}</td>      </tr>      {% endfor %}    </tbody>  </table>  {% endfor %}  {% endif %}  <div class="footer-note">    Status · self-hosted website monitoring. Report generated from live data at the moment above. Metrics refresh every 3 minutes (HTTP), daily (Lighthouse), weekly (crawler).  </div></body></html>
deleted properties/templates/properties/property_report.md
@@ -1,77 +0,0 @@{% load properties_tags %}# {{ property.name }}**URL:** {{ property.url }}**Generated:** {% now "Y-m-d H:i" %}**Operator:** {{ property.user.username }}## Live signals| Signal | Value | Status ||---|---|---|| HTTP status | `{{ property.current_status }}` | {% if property.current_status == 200 %}OK{% else %}failing{% endif %} || TLS cert | {% if property.invalid_cert %}invalid{% else %}valid{% endif %} | {% if property.invalid_cert %}fail{% else %}pass{% endif %} || Security headers | {% if property.has_security_issue %}issues{% else %}clean{% endif %} | {% if property.has_security_issue %}warn{% else %}pass{% endif %} || Avg response | `{{ property.avg_response_time }} ms` | {% if property.avg_response_time > 500 %}slow{% else %}ok{% endif %} || Alert state | `{{ property.alert_state }}` | {% if property.last_alert_sent %}last alert {{ property.last_alert_sent|date:"Y-m-d H:i" }}{% endif %} || Visibility | {% if property.is_public %}public{% else %}private{% endif %} | || Registered | {{ property.created_at|date:"Y-m-d" }} | || Total checks | {{ property.total_checks }} | |{% if property.recent_uptime_pct is not None %}| Uptime (last 100) | `{{ property.recent_uptime_pct }}%` | |{% endif %}{% if property.lighthouse_scores %}## Lighthouse scores| Category | Score ||---|---|{% for category, score in property.lighthouse_scores.items %}| {{ category|capfirst }} | `{{ score }}%` |{% endfor %}{% endif %}## Security · header scan| Check | Result ||---|---|| Use HTTPS | {% if property.is_https %}pass{% else %}fail{% endif %} || Set MIME types | {% if property.has_mime_type %}pass{% else %}fail{% endif %} || Content sniff protection | {% if property.has_content_sniffing_protection %}pass{% else %}fail{% endif %} || Clickjack protection | {% if property.has_clickjack_protection %}pass{% else %}fail{% endif %} || XSS protection | {% if property.has_xss_protection %}pass{% else %}fail{% endif %} || Hide server version | {% if property.hides_server_version %}pass{% else %}fail{% endif %} || HSTS enabled | {% if property.has_hsts %}pass{% else %}fail{% endif %} || HSTS preload | {% if property.has_hsts_preload %}pass{% else %}fail{% endif %} |{% if property.lighthouse_details.metrics %}## Performance metrics| Acronym | Metric | Value | Weight | Score ||---|---|---|---|---|{% for metric in property.lighthouse_details.metrics %}| **{{ metric.acronym }}** | {{ metric.title }} | `{{ metric.display_value|default:"—" }}` | {{ metric.weight }}% | {{ metric.score|floatformat:"2" }} |{% endfor %}{% endif %}{% if property.lighthouse_details.opportunities %}## Top opportunities| Score | Audit | Detail | Est. savings ||---|---|---|---|{% for opp in property.lighthouse_details.opportunities %}| {{ opp.score|floatformat:"2" }} | {{ opp.title }} | `{{ opp.display_value|default:"—" }}` | {% with saved=opp.savings_ms|format_ms_savings %}{% if saved %}**{{ saved }}**{% else %}—{% endif %}{% endwith %} |{% endfor %}{% endif %}{% if property.crawler_insights %}## Crawler insights{% regroup property.crawler_insights|dictsort:"type" by type as insight_type_groups %}{% for group in insight_type_groups %}### {{ group.grouper|capfirst }} ({{ group.list|length }})| Severity | URL | Issue | Item ||---|---|---|---|{% for insight in group.list|dictsort:"severity" %}| {{ insight.severity }} | `{{ insight.url|url_path }}` | {{ insight.issue }} | `{{ insight.item|default:"—" }}` |{% endfor %}{% endfor %}{% endif %}_Report generated by Status — self-hosted website monitoring. HTTP checks every 3 minutes, Lighthouse daily, crawler weekly._
deleted properties/templatetags/__init__.py
deleted properties/templatetags/properties_tags.py
@@ -1,37 +0,0 @@from django import templateregister = template.Library()@register.filterdef url_path(value):    """    Returns everything after the domain and possibly port in a URL. As an    example:    https://example.com:443/path/to/page.html?query=string#fragment -> /path/to/page.html?query=string#fragment    """    return "/" + value.split("/", 3)[-1]@register.filterdef lh_score_class(score):    """Bootstrap contextual class for a Lighthouse score (0-1)."""    if score is None:        return "secondary"    if score >= 0.9:        return "success"    if score >= 0.5:        return "warning"    return "danger"@register.filterdef format_ms_savings(ms):    """Render an ms value as '1.2 s' / '420 ms', or '' for zero/None."""    if not ms:        return ""    if ms >= 1000:        return f"{ms / 1000:.1f} s"    return f"{int(round(ms))} ms"
deleted properties/urls.py
@@ -1,14 +0,0 @@from django.urls import pathfrom . import viewsurlpatterns = [    path('<uuid:property_id>/', views.property, name='property'),    path('<uuid:property_id>/delete/', views.property_delete, name='property_delete'),    path('<uuid:property_id>/is-public/', views.adjust_is_public_property, name='adjust_is_public_property'),    path('<uuid:property_id>/status/', views.property_status, name='property_status'),    path('<uuid:property_id>/recrawl/', views.property_recrawl, name='property_recrawl'),    path('<uuid:property_id>/rerun-lighthouse/', views.property_rerun_lighthouse, name='property_rerun_lighthouse'),    path('', views.properties, name='properties'),]
deleted properties/views.py
@@ -1,296 +0,0 @@import uuidfrom django.conf import settingsfrom django.contrib import messagesfrom django.core.files.storage import default_storagefrom django.core.paginator import Paginatorfrom django.db import modelsfrom django.http import HttpResponse, JsonResponsefrom django.shortcuts import redirect, renderfrom django.template.loader import render_to_stringfrom django.utils import timezonefrom status.chromium import generate_pdf_from_htmlfrom .forms import PropertyFormfrom .models import Propertydef properties(request):    if not request.user.is_authenticated:        return redirect("/")    if request.method == "POST":        form = PropertyForm(request.POST)        if form.is_valid():            new_property = form.save(commit=False)            new_property.user = request.user            new_property.save()            messages.success(request, "Property added successfully.")            return redirect("properties")    else:        form = PropertyForm()    properties = request.user.properties.order_by("url")    q = request.GET.get("q")    if q:        properties = properties.filter(url__icontains=q)    page = request.GET.get("page")    properties = Paginator(properties, 25)    properties = properties.get_page(page)    return render(        request,        "properties/properties.html",        {            "form": form,            "title": "Properties",            "description": "Manage your properties.",            "q": q,            "properties": properties,        },    )def property_delete(request, property_id):    if not request.user.is_authenticated:        return redirect("/")    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return redirect("properties")    property_obj.delete()    messages.success(request, "Property deleted successfully.")    return redirect("properties")def adjust_is_public_property(request, property_id):    """    Sets the property to public or private    """    if not request.user.is_authenticated:        return redirect("/")    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return redirect("properties")    if request.method == "POST":        property_obj.is_public = property_obj.is_public is False        property_obj.save()        return JsonResponse({"success": True})    return JsonResponse({"success": False})def property(request, property_id):    context = {}    try:        property_obj = Property.objects.get(pk=property_id)        context["property"] = property_obj    except Property.DoesNotExist:        return redirect("properties")    if not property_obj.is_public and property_obj.user != request.user:        return redirect("properties")    # Set some basic page context variables    context["title"] = property_obj.name    context["description"] = "Status for " + property_obj.name    context["BASE_URL"] = settings.BASE_URL    status_response_times = []    for status in reversed(property_obj.statuses.order_by("-created_at")[:31]):        status_response_times.append(            {"label": status.created_at.isoformat(), "count": status.response_time}        )    context["status_response_times_graph"] = status_response_times    status_codes = property_obj.statuses.values("status_code").annotate(        count=models.Count("status_code")    )    context["status_codes_graph"] = [        {"label": x["status_code"], "count": x["count"]} for x in status_codes    ]    uptime = property_obj.statuses.filter(status_code=200).count()    downtime = property_obj.statuses.exclude(status_code=200).count()    total = uptime + downtime    try:        uptime_pct = round(uptime / total * 100, 2)    except ZeroDivisionError:        uptime_pct = 0    try:        downtime_pct = round(downtime / total * 100, 2)    except ZeroDivisionError:        downtime_pct = 0    context["uptime_graph"] = [        {"label": "Uptime", "count": uptime_pct},        {"label": "Downtime", "count": downtime_pct},    ]    # Report formats. `?report` (or `?report=pdf`) returns a printed PDF    # rendered from the light-themed report template; `?report=md` returns a    # plain-text Markdown report suited for piping into an LLM.    if "report" in request.GET:        fmt = request.GET.get("report") or "pdf"        if fmt == "md":            md = render_to_string("properties/property_report.md", context)            response = HttpResponse(md, content_type="text/markdown; charset=utf-8")            response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"'            return response        if fmt == "pdf":            html = render_to_string("properties/property_report.html", context)            filename = f"reports/{uuid.uuid4()}.pdf"            generate_pdf_from_html(html, filename)            with open(default_storage.path(filename), "rb") as pdf:                response = HttpResponse(pdf.read(), content_type="application/pdf")                response["Content-Disposition"] = "inline; filename=report.pdf"                return response    return render(request, "properties/property.html", context)def _crawl_progress(property_obj):    """Return the fraction (0-1) of the discovered work that's complete."""    from crawler.fetcher import PAGE_CAP    pages = property_obj.last_crawl_pages_count or 0    if pages <= 0:        return 0.05  # show *some* movement once we start    # We don't know the total ahead of time, so use a log-ish ratio capped at    # ~90% — the last 10% is reserved for post-crawl check processing.    return min(pages / PAGE_CAP, 0.9)def _serialize_status(property_obj):    now = timezone.now()    crawl_next = property_obj.next_run_at_crawler    lh_next = property_obj.next_lighthouse_run_at    insights = property_obj.crawler_insights or []    severity_counts = {"error": 0, "warning": 0, "info": 0}    for insight in insights:        sev = insight.get("severity", "info")        if sev in severity_counts:            severity_counts[sev] += 1    return {        "crawler": {            "state": property_obj.crawl_state,            "started_at": property_obj.crawl_started_at.isoformat()            if property_obj.crawl_started_at            else None,            "last_attempt_at": property_obj.last_run_at_crawler.isoformat()            if property_obj.last_run_at_crawler            else None,            "last_success_at": property_obj.last_crawl_success_at.isoformat()            if property_obj.last_crawl_success_at            else None,            "last_error": property_obj.last_crawl_error,            "last_duration_ms": property_obj.last_crawl_duration_ms,            "pages_count": property_obj.last_crawl_pages_count,            "next_run_at": crawl_next.isoformat() if crawl_next else None,            "is_overdue": bool(crawl_next and crawl_next <= now),            "insights_total": len(insights),            "insights_by_severity": severity_counts,            "progress": _crawl_progress(property_obj)            if property_obj.crawl_state == "running"            else None,        },        "lighthouse": {            "state": property_obj.lighthouse_state,            "started_at": property_obj.lighthouse_started_at.isoformat()            if property_obj.lighthouse_started_at            else None,            "last_attempt_at": property_obj.last_lighthouse_run_at.isoformat()            if property_obj.last_lighthouse_run_at            else None,            "last_success_at": property_obj.last_lighthouse_success_at.isoformat()            if property_obj.last_lighthouse_success_at            else None,            "last_error": property_obj.last_lighthouse_error,            "last_duration_ms": property_obj.last_lighthouse_duration_ms,            "next_run_at": lh_next.isoformat() if lh_next else None,            "is_overdue": bool(lh_next and lh_next <= now),            "scores": property_obj.lighthouse_scores,        },        "server_time": now.isoformat(),    }def property_status(request, property_id):    try:        property_obj = Property.objects.get(pk=property_id)    except Property.DoesNotExist:        return JsonResponse({"error": "not_found"}, status=404)    if not property_obj.is_public and property_obj.user != request.user:        return JsonResponse({"error": "forbidden"}, status=403)    return JsonResponse(_serialize_status(property_obj))def property_recrawl(request, property_id):    if not request.user.is_authenticated:        return JsonResponse({"error": "forbidden"}, status=403)    if request.method != "POST":        return JsonResponse({"error": "method_not_allowed"}, status=405)    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return JsonResponse({"error": "not_found"}, status=404)    if property_obj.crawl_state in ("queued", "running"):        return JsonResponse(            {                "ok": False,                "reason": "already_running",                **_serialize_status(property_obj),            }        )    property_obj.next_run_at_crawler = timezone.now()    property_obj.last_crawl_error = None    property_obj.save(update_fields=["next_run_at_crawler", "last_crawl_error"])    return JsonResponse({"ok": True, **_serialize_status(property_obj)})def property_rerun_lighthouse(request, property_id):    if not request.user.is_authenticated:        return JsonResponse({"error": "forbidden"}, status=403)    if request.method != "POST":        return JsonResponse({"error": "method_not_allowed"}, status=405)    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return JsonResponse({"error": "not_found"}, status=404)    if property_obj.lighthouse_state in ("queued", "running"):        return JsonResponse(            {                "ok": False,                "reason": "already_running",                **_serialize_status(property_obj),            }        )    property_obj.next_lighthouse_run_at = timezone.now()    property_obj.last_lighthouse_error = None    property_obj.save(        update_fields=["next_lighthouse_run_at", "last_lighthouse_error"]    )    return JsonResponse({"ok": True, **_serialize_status(property_obj)})
deleted pyproject.toml
@@ -1,32 +0,0 @@[project]name = "status"version = "0.0.1"requires-python = ">=3.10"dependencies = [    "django",    "dnspython",    "gunicorn",    "requests",    "tzdata",    "uvicorn",    "whitenoise",    "beautifulsoup4",    "lxml",][dependency-groups]dev = [    "black",    "flake8",    "isort",][tool.black]line-length = 88[tool.isort]profile = "black"[tool.flake8]max-line-length = 88extend-ignore = "E203"
modified samplefiles/Caddyfile.sample
@@ -1,9 +1,7 @@# Caddyfile for status## I like to use Caddy Server for simple reverse proxies since it has built in# automatic HTTPS support. It is probably worth serving static files with# Caddy too but I've honestly been too lazy to implement that. I just use# whitenoise on Django for that.# I like to use Caddy Server for simple reverse proxies since it has built-in# automatic HTTPS support.{  servers {
@@ -18,10 +16,6 @@    Cache-Control "public, max-age=315360000"  }  header /media/* {    Cache-Control "public, max-age=315360000"  }  header /* {    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"    X-XSS-Protection "1; mode=block"
@@ -35,13 +29,6 @@}status.example.com {  handle /media/* {    uri strip_prefix /media    file_server {      root /srv/data/status/media    }  }  reverse_proxy localhost:8000  import common
modified samplefiles/env.sample
@@ -1,9 +1,22 @@DJANGO_SETTINGS_MODULE=status.settings.productionDJANGO_SECRET_KEY=ADD_A_SUPER_SECRET_KEY_HEREDJANGO_BASE_URL=https://status.example.comPOSTGRES_USER=statusPOSTGRES_PASSWORD=statusPOSTGRES_DB=statusPOSTGRES_HOST=dbPOSTGRES_PORT=5432PORT=8000# Single-operator dashboard password. Required.STATUS_PASSWORD=change-me# Optional: 32+ random bytes used to sign the session cookie. If absent, the# cookie key is derived deterministically from STATUS_PASSWORD (so changing# the password invalidates all existing sessions). Setting this explicitly is# preferred (`openssl rand -base64 64` works).# STATUS_COOKIE_SECRET=# Used in templates for absolute URLs (sitemap, og tags, alert email links).# Keep it without a trailing slash.BASE_URL=https://status.example.com# Where outage / recovery emails are delivered. Email is sent direct-to-MX# from noreply@bythewood.me; you'll want a corresponding SPF entry on the# sending domain so it doesn't end up in spam. Leave unset to disable email.ALERT_EMAIL=alerts@example.com# Discord webhook to post outage / recovery embeds to. Leave unset to disable.# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
modified samplefiles/post-receive.sample
@@ -1,19 +1,17 @@#!/bin/sh## For easy server deploys you can create add this post-receive hook to your# server in a bare repo somewhere like /srv/git/status/hooks/post-receive# and make a git clone in /srv/docker/status. This script will then run on# pushing updates to the server to build and deploy new docker images.# For easy server deploys add this post-receive hook to your server in a bare# repo somewhere like /srv/git/status/hooks/post-receive and make a git clone# in /srv/docker/status. This script runs on push to rebuild and redeploy# the docker container.while read oldrev newrev ref; do  if [ "$ref" = "refs/heads/master" ]; then    unset GIT_DIR  # unset GIT_DIR so that git pull works correctly    unset GIT_DIR    START_TIME=`date +%s`    cd /srv/docker/status    git pull    docker-compose up --build --detach    docker-compose run web python3 manage.py migrate --noinput    docker system prune --force    END_TIME=`date +%s`    echo Total build time: `expr $END_TIME - $START_TIME`s
added src/alerts.rs
@@ -0,0 +1,241 @@use anyhow::Context;use hickory_resolver::TokioAsyncResolver;use lettre::message::header::ContentType;use lettre::transport::smtp::client::TlsParameters;use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};use minijinja::{AutoEscape, Environment};use serde_json::json;use uuid::Uuid;const FROM_ADDR: &str = "noreply@bythewood.me";const HELO: &str = "bythewood.me";const SMTP_TIMEOUT_SECS: u64 = 30;const EMAIL_BASE_TEMPLATE: &str =    include_str!("../templates/emails/property_email_base.html");/// Property snapshot used to fill in the email metadata table. Computed at the/// moment the alert fires so the values match what triggered it.pub struct EmailContext {    pub id: Uuid,    pub name: String,    pub url: String,    pub current_status: i64,    pub avg_response_time: i64,}struct Theme {    email_title: &'static str,    preheader: String,    status_label: &'static str,    event_title: &'static str,    event_copy: &'static str,    accent: &'static str,    accent_bright: &'static str,    accent_tint: &'static str,    accent_border: &'static str,}fn theme_for(kind: &str, name: &str) -> Option<Theme> {    match kind {        "down" => Some(Theme {            email_title: "Property down",            preheader: format!("{name} is not responding"),            status_label: "Down",            event_title: "Your property is down.",            event_copy: "We've observed two consecutive failed checks in a row. \                         Monitoring will continue in the background and you'll get \                         another note the moment it recovers.",            accent: "#c47055",            accent_bright: "#e38871",            accent_tint: "#201712",            accent_border: "#36231b",        }),        "recovery" => Some(Theme {            email_title: "Property recovered",            preheader: format!("{name} is back online"),            status_label: "Recovered",            event_title: "Your property is back online.",            event_copy: "The latest check returned a healthy response. Everything \                         looks normal again; we'll keep monitoring and alert you if \                         anything changes.",            accent: "#6b9e78",            accent_bright: "#7db88c",            accent_tint: "#191e17",            accent_border: "#222d22",        }),        _ => None,    }}pub(crate) fn render_preview_html(kind: &str, base_url: &str) -> anyhow::Result<String> {    let theme = theme_for(kind, "example.com")        .ok_or_else(|| anyhow::anyhow!("unknown kind: {kind} (use 'down' or 'recovery')"))?;    let ctx = EmailContext {        id: Uuid::nil(),        name: "example.com".to_string(),        url: "https://example.com".to_string(),        current_status: if kind == "down" { 503 } else { 200 },        avg_response_time: 184,    };    render_email_html(&theme, &ctx, base_url)}fn render_email_html(theme: &Theme, ctx: &EmailContext, base_url: &str) -> anyhow::Result<String> {    let mut env = Environment::new();    env.set_auto_escape_callback(|_| AutoEscape::Html);    env.add_template("email_base.html", EMAIL_BASE_TEMPLATE)        .context("compiling email template")?;    let tmpl = env.get_template("email_base.html")?;    let html = tmpl        .render(minijinja::context! {            email_title => theme.email_title,            preheader => &theme.preheader,            status_label => theme.status_label,            event_title => theme.event_title,            event_copy => theme.event_copy,            accent => theme.accent,            accent_bright => theme.accent_bright,            accent_tint => theme.accent_tint,            accent_border => theme.accent_border,            BASE_URL => base_url.trim_end_matches('/'),            property => minijinja::context! {                id => ctx.id.to_string(),                name => &ctx.name,                url => &ctx.url,                current_status => ctx.current_status,                avg_response_time => ctx.avg_response_time,            },        })        .context("rendering email template")?;    Ok(html)}/// Fire a state-transition notification. `kind` is "down" or "recovery".pub async fn fire(    kind: &str,    ctx: &EmailContext,    base_url: &str,    alert_email: Option<&str>,    discord_webhook: Option<&str>,) -> anyhow::Result<()> {    let theme = theme_for(kind, &ctx.name).ok_or_else(|| anyhow::anyhow!("unknown alert kind: {kind}"))?;    let subject = match kind {        "down" => format!("Status: {} is down!", ctx.name),        "recovery" => format!("Status: {} is back up!", ctx.name),        _ => unreachable!(),    };    let html = render_email_html(&theme, ctx, base_url)?;    let mut errors: Vec<anyhow::Error> = Vec::new();    if let Some(to) = alert_email {        if let Err(e) = send_email_via_mx(&subject, &html, to).await {            errors.push(e.context("email"));        }    }    if let Some(webhook) = discord_webhook {        if let Err(e) = send_discord(kind, &ctx.url, webhook).await {            errors.push(e.context("discord"));        }    }    if !errors.is_empty() {        let combined = errors.iter().map(|e| format!("{e:#}")).collect::<Vec<_>>().join("; ");        anyhow::bail!("{combined}");    }    Ok(())}/// Direct-MX delivery: resolve the recipient domain's MX records, sort by/// preference, try each with STARTTLS. No relay configured by default.async fn send_email_via_mx(subject: &str, html: &str, to: &str) -> anyhow::Result<()> {    let domain = to        .rsplit_once('@')        .map(|(_, d)| d.to_string())        .ok_or_else(|| anyhow::anyhow!("invalid recipient: {to}"))?;    let resolver = TokioAsyncResolver::tokio_from_system_conf()        .context("creating dns resolver")?;    let mx = resolver        .mx_lookup(domain.as_str())        .await        .context("mx lookup")?;    let mut records: Vec<_> = mx.iter().collect();    records.sort_by_key(|r| r.preference());    if records.is_empty() {        anyhow::bail!("no MX records for {domain}");    }    let email = Message::builder()        .from(FROM_ADDR.parse()?)        .to(to.parse()?)        .subject(subject)        .header(ContentType::TEXT_HTML)        .body(html.to_string())?;    let mut last_err: Option<anyhow::Error> = None;    for rec in records {        let host = rec.exchange().to_utf8();        let host = host.trim_end_matches('.').to_string();        match try_one_mx(&host, &email).await {            Ok(()) => return Ok(()),            Err(e) => {                tracing::warn!("MX {host} failed for {domain}: {e:#}");                last_err = Some(e);            }        }    }    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all MX hosts failed for {domain}")))}async fn try_one_mx(host: &str, email: &Message) -> anyhow::Result<()> {    let tls = TlsParameters::builder(host.to_string())        // Receiving MTAs sometimes use self-signed/expired certs; opportunistic        // STARTTLS with relaxed validation matches what the Python smtplib        // version did. End-to-end privacy is the recipient's MTA's job.        .dangerous_accept_invalid_certs(true)        .dangerous_accept_invalid_hostnames(true)        .build_rustls()?;    let transport: AsyncSmtpTransport<Tokio1Executor> =        AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)            .port(25)            .hello_name(lettre::transport::smtp::extension::ClientId::Domain(HELO.into()))            .timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))            .tls(lettre::transport::smtp::client::Tls::Opportunistic(tls))            .build();    transport.send(email.clone()).await.context("smtp send")?;    Ok(())}async fn send_discord(kind: &str, url: &str, webhook: &str) -> anyhow::Result<()> {    let (title, color, desc) = match kind {        "down" => ("Status Alert", 16711680u32, format!("{url} is down!")),        "recovery" => ("Status Recovery", 65280u32, format!("{url} is back up!")),        _ => anyhow::bail!("unknown kind: {kind}"),    };    let payload = json!({        "username": "Status",        "embeds": [{            "title": title,            "description": desc,            "color": color,            "timestamp": chrono::Utc::now().to_rfc3339(),        }],    });    let client = reqwest::Client::builder()        .timeout(std::time::Duration::from_secs(5))        .build()?;    let body = serde_json::to_string(&payload)?;    let resp = client        .post(webhook)        .header("content-type", "application/json")        .body(body)        .send()        .await?;    if !resp.status().is_success() {        anyhow::bail!("discord {} {}", resp.status(), resp.text().await.unwrap_or_default());    }    Ok(())}
added src/app.rs
@@ -0,0 +1,116 @@use axum::{http::HeaderValue, middleware as axum_middleware, Router};use minijinja::Environment;use sqlx::SqlitePool;use std::path::PathBuf;use std::sync::Arc;use tower_cookies::{CookieManagerLayer, Key};use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::pdf::PdfRenderer;use crate::routes;use crate::{db, middleware, templates};#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub pool: SqlitePool,    pub cookie_key: Key,    pub config: Arc<Config>,    pub pdf_renderer: Arc<PdfRenderer>,}#[derive(Debug, Clone)]pub struct Config {    pub root: PathBuf,    pub data_dir: PathBuf,    pub password: String,    pub base_url: String,    pub alert_email: Option<String>,    pub discord_webhook_url: Option<String>,}impl AppState {    pub async fn from_env() -> anyhow::Result<Self> {        let root: PathBuf = std::env::var("STATUS_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("."));        let data_dir = std::env::var("STATUS_DATA_DIR")            .map(PathBuf::from)            .unwrap_or_else(|_| root.join("data"));        std::fs::create_dir_all(&data_dir)?;        let password =            std::env::var("STATUS_PASSWORD").unwrap_or_else(|_| "admin".to_string());        let base_url = std::env::var("BASE_URL").unwrap_or_default();        let alert_email = std::env::var("ALERT_EMAIL").ok().filter(|s| !s.is_empty());        let discord_webhook_url = std::env::var("DISCORD_WEBHOOK_URL")            .ok()            .filter(|s| !s.is_empty());        let cookie_secret = std::env::var("STATUS_COOKIE_SECRET").unwrap_or_else(|_| {            // 64+ bytes derived from password if no secret provided. For a single-user            // self-hosted app this is fine; setting STATUS_COOKIE_SECRET is preferred.            use sha2::{Digest, Sha512};            let mut h = Sha512::new();            h.update(b"status-cookie:");            h.update(password.as_bytes());            let digest = h.finalize();            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)        });        let cookie_key = Key::from(cookie_secret.as_bytes());        let pool = db::init(&data_dir).await?;        let templates_dir = root.join("templates");        let manifest_path = root.join("dist/.vite/manifest.json");        let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));        let pdf_renderer = Arc::new(PdfRenderer::new(root.clone()));        let config = Arc::new(Config {            root,            data_dir,            password,            base_url,            alert_email,            discord_webhook_url,        });        Ok(Self {            env,            pool,            cookie_key,            config,            pdf_renderer,        })    }}pub fn router(state: AppState) -> Router {    let dist_dir = state.config.root.join("dist");    let static_cache = SetResponseHeaderLayer::if_not_present(        axum::http::header::CACHE_CONTROL,        HeaderValue::from_static("public, max-age=31536000"),    );    Router::new()        .merge(routes::home::router())        .merge(routes::auth::router())        .merge(routes::seo::router())        .merge(routes::properties::router())        // routes::dashboard holds the UUID `/{property_id}` catch-all; merge        // last so named routes win the match.        .merge(routes::dashboard::router())        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(static_cache)                .service(ServeDir::new(&dist_dir)),        )        .fallback(middleware::not_found)        .layer(CookieManagerLayer::new())        .layer(axum_middleware::from_fn(middleware::log_requests))        .with_state(state)}
added src/checker.rs
@@ -0,0 +1,200 @@use crate::alerts;use crate::db::now_ms;use crate::models::PropertyRow;use chrono::Timelike;use serde_json::json;use sqlx::SqlitePool;use std::time::{Duration, Instant};use uuid::Uuid;const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \                         (KHTML, like Gecko) Chrome/102.0.5005.115 Safari/537.36 Status/2.0.0";const HTTP_TIMEOUT_SECS: u64 = 10;/// Run a single HTTP check and persist the result. Maps SSL errors to 526/// (Cloudflare convention) and timeouts to 408 so the dashboard can show/// failure reasons without piping arbitrary error messages.pub async fn run_check(pool: &SqlitePool, prop: &PropertyRow) -> sqlx::Result<i64> {    let client = match reqwest::Client::builder()        .user_agent(USER_AGENT)        .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))        .redirect(reqwest::redirect::Policy::limited(5))        .build()    {        Ok(c) => c,        Err(e) => {            tracing::error!("reqwest client build: {e}");            return Ok(0);        }    };    let started = Instant::now();    let result = client.get(&prop.url).send().await;    let elapsed_ms = started.elapsed().as_millis().min(i64::MAX as u128) as i64;    let (status_code, response_ms, headers_json) = match result {        Ok(resp) => {            let status = resp.status().as_u16() as i64;            let mut hdrs = serde_json::Map::new();            for (k, v) in resp.headers().iter() {                if let Ok(s) = v.to_str() {                    hdrs.insert(k.as_str().to_string(), json!(s));                }            }            (status, elapsed_ms, serde_json::Value::Object(hdrs).to_string())        }        Err(err) if err.is_timeout() => (408, 10_000, "{}".to_string()),        Err(err) => {            // reqwest with rustls reports cert errors via the underlying            // error chain; fall back to substring match for portability.            let chain = format!("{err:?}");            let lower = chain.to_lowercase();            let is_ssl = lower.contains("certificate")                || lower.contains("invalidcertificate")                || lower.contains("tls")                || lower.contains("handshake");            let code = if is_ssl { 526 } else { 408 };            (code, 10_000, "{}".to_string())        }    };    let id = prop.id.clone();    sqlx::query(        "INSERT INTO checks (property_id, status_code, response_ms, headers, created_at) \         VALUES (?, ?, ?, ?, ?)",    )    .bind(&id)    .bind(status_code)    .bind(response_ms)    .bind(&headers_json)    .bind(now_ms())    .execute(pool)    .await?;    Ok(status_code)}/// Run a check, persist it, then advance the alert state machine and fire/// notifications on transitions.pub async fn process_check(    pool: &SqlitePool,    config: &crate::Config,    prop: &PropertyRow,) -> anyhow::Result<()> {    let status_code = run_check(pool, prop).await?;    advance_alert_state(pool, config, prop, status_code).await?;    Ok(())}/// State transitions:///   UP -> DOWN: requires 2 consecutive non-200 checks (avoids false positives)///   DOWN -> UP: immediate on 200/// Commits state inside a transaction *before* firing notifications so a/// crash mid-alert can't cause duplicate sends.async fn advance_alert_state(    pool: &SqlitePool,    config: &crate::Config,    prop: &PropertyRow,    status_code: i64,) -> anyhow::Result<()> {    let is_up = status_code == 200;    let mut tx = pool.begin().await?;    let row: Option<(String,)> = sqlx::query_as(        "SELECT alert_state FROM properties WHERE id = ?",    )    .bind(prop.id.clone())    .fetch_optional(&mut *tx)    .await?;    let Some((current_state,)) = row else {        return Ok(());    };    let mut transition: Option<&str> = None;    if is_up && current_state == "down" {        transition = Some("recovery");    } else if !is_up && current_state == "up" {        // Need 2 consecutive non-200s. We just inserted one; check the prior.        let recent: Vec<(i64,)> = sqlx::query_as(            "SELECT status_code FROM checks WHERE property_id = ? ORDER BY created_at DESC LIMIT 2",        )        .bind(prop.id.clone())        .fetch_all(&mut *tx)        .await?;        if recent.len() >= 2 && recent[0].0 != 200 && recent[1].0 != 200 {            transition = Some("down");        }    }    if let Some(kind) = transition {        let new_state = if kind == "recovery" { "up" } else { "down" };        sqlx::query(            "UPDATE properties SET alert_state = ?, last_alert_sent = ?, updated_at = ? WHERE id = ?",        )        .bind(new_state)        .bind(now_ms())        .bind(now_ms())        .bind(prop.id.clone())        .execute(&mut *tx)        .await?;    }    tx.commit().await?;    if let Some(kind) = transition {        let avg_response_time = recent_avg_response_ms(pool, &prop.id).await.unwrap_or(0);        let ctx = alerts::EmailContext {            id: prop.uuid(),            name: prop.name(),            url: prop.url.clone(),            current_status: status_code,            avg_response_time,        };        let alert_email = config.alert_email.clone();        let webhook = config.discord_webhook_url.clone();        let base_url = config.base_url.clone();        let url_for_log = prop.url.clone();        // Fire-and-forget: alerts don't block the scheduler tick.        tokio::spawn(async move {            if let Err(e) =                alerts::fire(kind, &ctx, &base_url, alert_email.as_deref(), webhook.as_deref()).await            {                tracing::warn!("alert dispatch failed for {url_for_log}: {e}");            }        });    }    Ok(())}/// Average response time across the most recent 31 checks. Mirrors the/// dashboard's "rolling avg" tile so the email matches what the user sees.async fn recent_avg_response_ms(pool: &SqlitePool, id: &[u8]) -> sqlx::Result<i64> {    let rows: Vec<(i64,)> = sqlx::query_as(        "SELECT response_ms FROM checks WHERE property_id = ? ORDER BY created_at DESC LIMIT 31",    )    .bind(id.to_vec())    .fetch_all(pool)    .await?;    if rows.is_empty() {        return Ok(0);    }    let sum: i64 = rows.iter().map(|r| r.0).sum();    Ok(sum / rows.len() as i64)}/// Compute the next due time aligned to a 3-minute boundary, matching Django.pub fn next_3min_boundary() -> i64 {    let now = chrono::Utc::now();    let minute = now.minute() as i64;    let aligned_min = (minute / 3) * 3;    let aligned = now        .with_minute(aligned_min as u32)        .and_then(|d| d.with_second(0))        .and_then(|d| d.with_nanosecond(0))        .unwrap_or(now);    (aligned + chrono::Duration::minutes(3)).timestamp_millis()}#[allow(dead_code)]pub async fn property_id_to_uuid(id_blob: &[u8]) -> Uuid {    Uuid::from_slice(id_blob).unwrap_or(Uuid::nil())}
added src/crawler/checks.rs
@@ -0,0 +1,833 @@//! SEO / accessibility / performance / content / security checks.//!//! Each check takes a `Ctx` and returns a list of insight values//! (`{url, issue, item, type, severity}` JSON objects). Direct port of//! `crawler/checks.py`.use super::{fetcher::same_site, Page, RobotsCtx};use serde_json::{json, Value};use std::collections::{HashMap, HashSet};const TYPE_SEO: &str = "seo";const TYPE_LINKS: &str = "links";const TYPE_A11Y: &str = "accessibility";const TYPE_CONTENT: &str = "content";const TYPE_PERF: &str = "performance";const TYPE_SEC: &str = "security";const SEV_ERROR: &str = "error";const SEV_WARN: &str = "warning";const SEV_INFO: &str = "info";pub struct Ctx<'a> {    pub start_url: &'a str,    pub host: &'a str,    pub pages: &'a [Page],    pub html_pages: &'a [&'a Page],    pub status_map: &'a HashMap<String, u16>,    pub external_link_status: &'a HashMap<String, u16>,    pub sitemap_urls: &'a [String],    pub robots: &'a RobotsCtx,}fn redirect_codes() -> HashSet<u16> {    [301, 302, 303, 307, 308].into_iter().collect()}fn insight(url: &str, issue: &str, type_: &str, severity: &str, item: &str) -> Value {    json!({        "url": url,        "issue": issue,        "item": item,        "type": type_,        "severity": severity,    })}fn normalize(s: &str) -> String {    s.to_lowercase().split_whitespace().collect::<Vec<_>>().join(" ")}fn group_by<'a, F: Fn(&Page) -> String>(    pages: &[&'a Page],    f: F,) -> HashMap<String, Vec<&'a Page>> {    let mut out: HashMap<String, Vec<&'a Page>> = HashMap::new();    for p in pages {        let v = f(p);        if !v.is_empty() {            out.entry(normalize(&v)).or_default().push(p);        }    }    out}fn html<'a>(p: &'a Page) -> &'a super::parser::ParsedHtml {    p.html.as_ref().expect("called on html page")}// ---------- core metadata ----------fn check_title_missing(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).title.is_empty())        .map(|p| insight(&p.url, "Page has no title", TYPE_SEO, SEV_ERROR, ""))        .collect()}fn check_title_length(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let t = &html(p).title;        let n = t.chars().count();        if !t.is_empty() && !(30..=60).contains(&n) {            out.push(insight(                &p.url,                &format!("Title length is {n} chars (recommended 30-60)"),                TYPE_SEO,                SEV_WARN,                t,            ));        }    }    out}fn check_duplicate_titles(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for (_, group) in group_by(ctx.html_pages, |p| html(p).title.clone()) {        if group.len() > 1 {            for p in group {                out.push(insight(&p.url, "Duplicate title", TYPE_SEO, SEV_WARN, &html(p).title));            }        }    }    out}fn check_description_missing(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).description.is_empty())        .map(|p| insight(&p.url, "Page has no meta description", TYPE_SEO, SEV_ERROR, ""))        .collect()}fn check_description_length(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let d = &html(p).description;        let n = d.chars().count();        if !d.is_empty() && !(70..=160).contains(&n) {            out.push(insight(                &p.url,                &format!("Description length is {n} chars (recommended 70-160)"),                TYPE_SEO,                SEV_WARN,                d,            ));        }    }    out}fn check_duplicate_descriptions(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for (_, group) in group_by(ctx.html_pages, |p| html(p).description.clone()) {        if group.len() > 1 {            for p in group {                out.push(insight(                    &p.url,                    "Duplicate meta description",                    TYPE_SEO,                    SEV_WARN,                    &html(p).description,                ));            }        }    }    out}fn h1s<'a>(p: &'a Page) -> &'a [String] {    html(p)        .headings        .get("h1")        .map(|v| v.as_slice())        .unwrap_or(&[])}fn check_h1_missing(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| h1s(p).is_empty())        .map(|p| insight(&p.url, "Page has no h1", TYPE_SEO, SEV_ERROR, ""))        .collect()}fn check_h1_multiple(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let v = h1s(p);        if v.len() > 1 {            let item = v.iter().take(3).cloned().collect::<Vec<_>>().join(" | ");            out.push(insight(                &p.url,                &format!("Page has {} h1 tags (expected 1)", v.len()),                TYPE_SEO,                SEV_WARN,                &item,            ));        }    }    out}fn check_h1_length(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let v = h1s(p);        if let Some(first) = v.first() {            let n = first.chars().count();            if !(20..=70).contains(&n) {                out.push(insight(                    &p.url,                    &format!("H1 length is {n} chars (recommended 20-70)"),                    TYPE_SEO,                    SEV_WARN,                    first,                ));            }        }    }    out}fn check_duplicate_h1s(ctx: &Ctx) -> Vec<Value> {    let mut buckets: HashMap<String, Vec<(String, String)>> = HashMap::new();    for p in ctx.html_pages {        if let Some(first) = h1s(p).first() {            buckets.entry(normalize(first)).or_default().push((p.url.clone(), first.clone()));        }    }    let mut out = Vec::new();    for (_, group) in buckets {        if group.len() > 1 {            for (url, item) in group {                out.push(insight(&url, "Duplicate h1", TYPE_SEO, SEV_WARN, &item));            }        }    }    out}fn check_heading_hierarchy(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let h = &html(p).headings;        let levels: Vec<u8> = (1..=6u8)            .filter(|lvl| {                h.get(&format!("h{lvl}"))                    .map(|v| !v.is_empty())                    .unwrap_or(false)            })            .collect();        for i in 1..levels.len() {            if levels[i] - levels[i - 1] > 1 {                out.push(insight(                    &p.url,                    &format!("Heading hierarchy skips from h{} to h{}", levels[i - 1], levels[i]),                    TYPE_SEO,                    SEV_INFO,                    "",                ));                break;            }        }    }    out}fn check_canonical_missing(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).canonical.is_empty())        .map(|p| insight(&p.url, "Page has no canonical URL", TYPE_SEO, SEV_WARN, ""))        .collect()}fn check_canonical_offdomain(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let c = &html(p).canonical;        if !c.is_empty() && !same_site(c, ctx.host) {            out.push(insight(&p.url, "Canonical URL points off-domain", TYPE_SEO, SEV_WARN, c));        }    }    out}fn check_canonical_broken(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let c = &html(p).canonical;        if !c.is_empty() {            if let Some(s) = ctx.status_map.get(c) {                if *s != 200 {                    out.push(insight(                        &p.url,                        &format!("Canonical URL returns {s}"),                        TYPE_SEO,                        SEV_ERROR,                        c,                    ));                }            }        }    }    out}fn check_robots_meta_noindex(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let rm = &html(p).robots_meta;        if rm.to_lowercase().contains("noindex") {            out.push(insight(                &p.url,                "Page has noindex in meta robots tag",                TYPE_SEO,                SEV_WARN,                rm,            ));        }    }    out}fn check_lang_missing(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).lang.is_empty())        .map(|p| insight(&p.url, "HTML lang attribute missing", TYPE_SEO, SEV_WARN, ""))        .collect()}fn check_viewport_missing(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).viewport.is_empty())        .map(|p| insight(&p.url, "Viewport meta tag missing (mobile)", TYPE_SEO, SEV_WARN, ""))        .collect()}fn check_og_incomplete(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let og = &html(p).og;        let mut missing: Vec<&str> = Vec::new();        if og.title.is_empty() { missing.push("og:title"); }        if og.description.is_empty() { missing.push("og:description"); }        if og.image.is_empty() { missing.push("og:image"); }        if og.url.is_empty() { missing.push("og:url"); }        if !missing.is_empty() {            out.push(insight(                &p.url,                &format!("Open Graph tags missing: {}", missing.join(", ")),                TYPE_SEO,                SEV_INFO,                "",            ));        }    }    out}fn check_twitter_card(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).twitter.card.is_empty())        .map(|p| insight(&p.url, "Twitter card meta tag missing", TYPE_SEO, SEV_INFO, ""))        .collect()}fn check_favicon(ctx: &Ctx) -> Vec<Value> {    ctx.html_pages        .iter()        .filter(|p| html(p).favicon.is_empty())        .map(|p| insight(&p.url, "Favicon link missing", TYPE_SEO, SEV_INFO, ""))        .collect()}fn check_json_ld_parse_error(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        for v in &html(p).json_ld {            if v.is_null() {                out.push(insight(                    &p.url,                    "JSON-LD structured data failed to parse",                    TYPE_SEO,                    SEV_WARN,                    "",                ));                break;            }        }    }    out}// ---------- links ----------fn check_broken_internal_links(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    let mut reported: HashSet<(String, String)> = HashSet::new();    let redirects = redirect_codes();    for p in ctx.html_pages {        for link in &html(p).links {            if !same_site(&link.url, ctx.host) {                continue;            }            let Some(s) = ctx.status_map.get(&link.url) else { continue };            if *s != 200 && !redirects.contains(s) {                let key = (p.url.clone(), link.url.clone());                if !reported.insert(key) {                    continue;                }                let label = if *s == 0 { "unreachable".to_string() } else { format!("status {s}") };                out.push(insight(                    &p.url,                    &format!("Broken internal link ({label})"),                    TYPE_LINKS,                    SEV_ERROR,                    &link.url,                ));            }        }    }    out}fn check_broken_external_links(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    let mut reported: HashSet<(String, String)> = HashSet::new();    for p in ctx.html_pages {        for link in &html(p).links {            if same_site(&link.url, ctx.host) {                continue;            }            let Some(s) = ctx.external_link_status.get(&link.url) else { continue };            if *s == 0 || *s >= 400 {                let key = (p.url.clone(), link.url.clone());                if !reported.insert(key) {                    continue;                }                let label = if *s == 0 { "unreachable".to_string() } else { format!("status {s}") };                out.push(insight(                    &p.url,                    &format!("Broken external link ({label})"),                    TYPE_LINKS,                    SEV_WARN,                    &link.url,                ));            }        }    }    out}fn check_redirect_chains(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.pages {        if p.redirect_chain.len() > 2 {            let hops = p.redirect_chain.len() - 1;            let codes = p                .redirect_chain                .iter()                .map(|(c, _)| c.to_string())                .collect::<Vec<_>>()                .join(" -> ");            out.push(insight(                &p.url,                &format!("Redirect chain has {hops} hops"),                TYPE_LINKS,                SEV_INFO,                &codes,            ));        }    }    out}fn check_nofollow_internal_links(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    let mut reported: HashSet<(String, String)> = HashSet::new();    for p in ctx.html_pages {        for link in &html(p).links {            if !same_site(&link.url, ctx.host) {                continue;            }            if link.rel.iter().any(|r| r == "nofollow") {                let key = (p.url.clone(), link.url.clone());                if !reported.insert(key) {                    continue;                }                out.push(insight(                    &p.url,                    "Internal link has rel=nofollow",                    TYPE_LINKS,                    SEV_INFO,                    &link.url,                ));            }        }    }    out}// ---------- robots / sitemap ----------fn check_robots_missing(ctx: &Ctx) -> Vec<Value> {    if !ctx.robots.exists {        vec![insight(            ctx.start_url,            "robots.txt missing",            TYPE_SEO,            SEV_WARN,            &ctx.robots.url,        )]    } else {        Vec::new()    }}fn check_sitemap_missing(ctx: &Ctx) -> Vec<Value> {    if ctx.sitemap_urls.is_empty() {        vec![insight(            ctx.start_url,            "sitemap.xml missing or empty",            TYPE_SEO,            SEV_WARN,            "",        )]    } else {        Vec::new()    }}fn check_sitemap_not_in_robots(ctx: &Ctx) -> Vec<Value> {    if ctx.robots.exists && !ctx.sitemap_urls.is_empty() && !ctx.robots.references_sitemap {        vec![insight(            ctx.start_url,            "robots.txt does not reference a sitemap",            TYPE_SEO,            SEV_INFO,            "",        )]    } else {        Vec::new()    }}fn check_sitemap_broken_urls(ctx: &Ctx) -> Vec<Value> {    let redirects = redirect_codes();    let mut out = Vec::new();    for url in ctx.sitemap_urls {        if let Some(s) = ctx.status_map.get(url) {            if *s != 200 && !redirects.contains(s) {                out.push(insight(                    url,                    &format!("URL listed in sitemap returns {s}"),                    TYPE_SEO,                    SEV_ERROR,                    "",                ));            }        }    }    out}fn check_pages_missing_from_sitemap(ctx: &Ctx) -> Vec<Value> {    if ctx.sitemap_urls.is_empty() {        return Vec::new();    }    let set: HashSet<&str> = ctx.sitemap_urls.iter().map(|s| s.as_str()).collect();    let mut out = Vec::new();    for p in ctx.html_pages {        if set.contains(p.url.as_str()) {            continue;        }        if html(p).robots_meta.to_lowercase().contains("noindex") {            continue;        }        out.push(insight(&p.url, "Page not listed in sitemap", TYPE_SEO, SEV_INFO, ""));    }    out}// ---------- accessibility ----------fn check_images_missing_alt(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let missing: Vec<_> = html(p).images.iter().filter(|i| i.alt.is_none()).collect();        if !missing.is_empty() {            let item: String = missing[0]                .src                .chars()                .take(160)                .collect();            out.push(insight(                &p.url,                &format!("{} image(s) missing alt attribute", missing.len()),                TYPE_A11Y,                SEV_WARN,                &item,            ));        }    }    out}fn check_empty_anchor_text(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let empty: Vec<_> = html(p).links.iter().filter(|l| l.text.is_empty()).collect();        if !empty.is_empty() {            let item: String = empty[0].url.chars().take(160).collect();            out.push(insight(                &p.url,                &format!("{} link(s) have no visible text", empty.len()),                TYPE_A11Y,                SEV_INFO,                &item,            ));        }    }    out}fn check_form_inputs_unlabeled(ctx: &Ctx) -> Vec<Value> {    let ignore: HashSet<&str> = ["hidden", "submit", "button", "reset", "image"].into_iter().collect();    let mut out = Vec::new();    'outer: for p in ctx.html_pages {        for form in &html(p).forms {            let label_set: HashSet<&str> = form.label_fors.iter().map(|s| s.as_str()).collect();            let mut unlabeled = 0;            for i in &form.inputs {                if ignore.contains(i.r#type.to_lowercase().as_str()) {                    continue;                }                if i.aria_label.is_some() {                    continue;                }                if let Some(id) = &i.id {                    if label_set.contains(id.as_str()) {                        continue;                    }                }                unlabeled += 1;            }            if unlabeled > 0 {                out.push(insight(                    &p.url,                    &format!("{unlabeled} form input(s) without associated label"),                    TYPE_A11Y,                    SEV_WARN,                    &form.action,                ));                continue 'outer;            }        }    }    out}// ---------- content ----------fn check_thin_content(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let wc = html(p).word_count;        if wc < 300 {            out.push(insight(                &p.url,                &format!("Thin content ({wc} words)"),                TYPE_CONTENT,                SEV_WARN,                "",            ));        }    }    out}fn check_duplicate_content(ctx: &Ctx) -> Vec<Value> {    let mut buckets: HashMap<String, Vec<String>> = HashMap::new();    for p in ctx.html_pages {        let th = &html(p).text_hash;        if !th.is_empty() {            buckets.entry(th.clone()).or_default().push(p.url.clone());        }    }    let mut out = Vec::new();    for urls in buckets.values() {        if urls.len() > 1 {            for u in urls {                let other = urls.iter().find(|x| *x != u).unwrap_or(&urls[0]);                out.push(insight(                    u,                    "Page has duplicate visible content with another page",                    TYPE_CONTENT,                    SEV_WARN,                    other,                ));            }        }    }    out}// ---------- performance ----------fn check_slow_pages(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.pages {        if !p.is_html {            continue;        }        if p.elapsed_ms > 1000 {            out.push(insight(                &p.url,                &format!("Slow response ({} ms)", p.elapsed_ms),                TYPE_PERF,                SEV_WARN,                "",            ));        }    }    out}fn check_missing_compression(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        let enc = p            .headers            .iter()            .find(|(k, _)| k.eq_ignore_ascii_case("content-encoding"))            .map(|(_, v)| v.to_lowercase())            .unwrap_or_default();        if enc.is_empty() {            out.push(insight(                &p.url,                "Response not compressed (no Content-Encoding header)",                TYPE_PERF,                SEV_INFO,                "",            ));        }    }    out}fn check_oversized_pages(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.pages {        if p.bytes > 500_000 {            out.push(insight(                &p.url,                &format!("Oversized page ({} KB)", p.bytes / 1024),                TYPE_PERF,                SEV_WARN,                "",            ));        }    }    out}// ---------- security ----------fn check_mixed_content(ctx: &Ctx) -> Vec<Value> {    let mut out = Vec::new();    for p in ctx.html_pages {        if !p.url.starts_with("https://") {            continue;        }        let http_resources: Vec<&String> = html(p)            .resources            .iter()            .filter(|r| r.starts_with("http://"))            .collect();        if !http_resources.is_empty() {            out.push(insight(                &p.url,                &format!(                    "Mixed content: {} http:// resource(s) on https:// page",                    http_resources.len()                ),                TYPE_SEC,                SEV_WARN,                http_resources[0],            ));        }    }    out}pub fn run_all(ctx: &Ctx) -> Vec<Value> {    let checks: &[(&'static str, fn(&Ctx) -> Vec<Value>)] = &[        ("title_missing", check_title_missing),        ("title_length", check_title_length),        ("duplicate_titles", check_duplicate_titles),        ("description_missing", check_description_missing),        ("description_length", check_description_length),        ("duplicate_descriptions", check_duplicate_descriptions),        ("h1_missing", check_h1_missing),        ("h1_multiple", check_h1_multiple),        ("h1_length", check_h1_length),        ("duplicate_h1s", check_duplicate_h1s),        ("heading_hierarchy", check_heading_hierarchy),        ("canonical_missing", check_canonical_missing),        ("canonical_offdomain", check_canonical_offdomain),        ("canonical_broken", check_canonical_broken),        ("robots_meta_noindex", check_robots_meta_noindex),        ("lang_missing", check_lang_missing),        ("viewport_missing", check_viewport_missing),        ("og_incomplete", check_og_incomplete),        ("twitter_card", check_twitter_card),        ("favicon", check_favicon),        ("json_ld_parse_error", check_json_ld_parse_error),        ("broken_internal_links", check_broken_internal_links),        ("broken_external_links", check_broken_external_links),        ("redirect_chains", check_redirect_chains),        ("nofollow_internal_links", check_nofollow_internal_links),        ("robots_missing", check_robots_missing),        ("sitemap_missing", check_sitemap_missing),        ("sitemap_not_in_robots", check_sitemap_not_in_robots),        ("sitemap_broken_urls", check_sitemap_broken_urls),        ("pages_missing_from_sitemap", check_pages_missing_from_sitemap),        ("images_missing_alt", check_images_missing_alt),        ("empty_anchor_text", check_empty_anchor_text),        ("form_inputs_unlabeled", check_form_inputs_unlabeled),        ("thin_content", check_thin_content),        ("duplicate_content", check_duplicate_content),        ("slow_pages", check_slow_pages),        ("missing_compression", check_missing_compression),        ("oversized_pages", check_oversized_pages),        ("mixed_content", check_mixed_content),    ];    let mut out = Vec::new();    for (name, fn_) in checks {        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fn_(ctx)));        match r {            Ok(v) => out.extend(v),            Err(_) => tracing::warn!("[crawler] check {name} panicked"),        }    }    out}
added src/crawler/fetcher.rs
@@ -0,0 +1,218 @@use reqwest::Client;use std::collections::HashMap;use std::time::{Duration, Instant};use url::Url;pub const PAGE_CAP: usize = 500;pub const CONCURRENCY: usize = 4;const REQUEST_TIMEOUT_SECS: u64 = 15;const EXTERNAL_LINK_TIMEOUT_SECS: u64 = 8;pub const CRAWL_DEADLINE_SECS: u64 = 540;const USER_AGENT: &str = "status (+https://status.bythewood.me)";#[derive(Debug, Clone)]pub struct FetchResult {    pub url: String,    pub requested_url: String,    pub status: u16,    pub headers: HashMap<String, String>,    pub body: Vec<u8>,    pub content_type: String,    pub elapsed_ms: i64,    pub redirect_chain: Vec<(u16, String)>,    pub error: String,}pub fn make_client() -> Client {    Client::builder()        .user_agent(USER_AGENT)        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))        .redirect(reqwest::redirect::Policy::limited(10))        .build()        .expect("client builds")}pub async fn fetch(client: &Client, url: &str) -> FetchResult {    let started = Instant::now();    match client.get(url).send().await {        Ok(resp) => {            let final_url = resp.url().to_string();            let status = resp.status().as_u16();            let mut headers = HashMap::new();            for (k, v) in resp.headers().iter() {                if let Ok(s) = v.to_str() {                    headers.insert(k.as_str().to_string(), s.to_string());                }            }            let content_type = headers                .iter()                .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))                .map(|(_, v)| v.to_lowercase())                .unwrap_or_default();            let body = if content_type.contains("text/html") {                resp.bytes().await.map(|b| b.to_vec()).unwrap_or_default()            } else {                Vec::new()            };            let elapsed_ms = started.elapsed().as_millis() as i64;            // Reqwest doesn't expose the redirect chain, so we approximate            // with [final_status, final_url]. Mid-chain hops are lost.            let redirect_chain = vec![(status, final_url.clone())];            FetchResult {                url: final_url,                requested_url: url.to_string(),                status,                headers,                body,                content_type,                elapsed_ms,                redirect_chain,                error: String::new(),            }        }        Err(e) => FetchResult {            url: url.to_string(),            requested_url: url.to_string(),            status: 0,            headers: HashMap::new(),            body: Vec::new(),            content_type: String::new(),            elapsed_ms: started.elapsed().as_millis() as i64,            redirect_chain: Vec::new(),            error: e.to_string(),        },    }}pub async fn head_status(client: &Client, url: &str) -> u16 {    let timeout = Duration::from_secs(EXTERNAL_LINK_TIMEOUT_SECS);    match client.head(url).timeout(timeout).send().await {        Ok(r) => {            let s = r.status().as_u16();            if matches!(s, 403 | 405 | 501) {                client                    .get(url)                    .timeout(timeout)                    .send()                    .await                    .map(|r| r.status().as_u16())                    .unwrap_or(0)            } else {                s            }        }        Err(_) => 0,    }}/// Robots.txt evaluator. Uses the `robotstxt` crate. Treats parse errors/// or missing files as "allow everything" so a broken robots.txt doesn't/// tank the crawl.pub struct Robots {    text: Option<String>,}impl Robots {    pub fn allowed(&self, url: &str) -> bool {        let Some(text) = &self.text else { return true };        let mut matcher = robotstxt::DefaultMatcher::default();        matcher.one_agent_allowed_by_robots(text, "*", url)    }    fn empty() -> Self {        Self { text: None }    }}pub async fn load_robots(client: &Client, base_origin: &str) -> (Robots, String, Option<String>) {    let robots_url = format!("{base_origin}/robots.txt");    let mut robots = Robots::empty();    let mut raw: Option<String> = None;    if let Ok(r) = client.get(&robots_url).send().await {        if r.status().as_u16() == 200 {            if let Ok(text) = r.text().await {                robots.text = Some(text.clone());                raw = Some(text);            }        }    }    (robots, robots_url, raw)}pub async fn load_sitemap(    client: &Client,    base_origin: &str,    robots_text: Option<&str>,) -> Vec<String> {    let mut candidates: Vec<String> = Vec::new();    if let Some(text) = robots_text {        for line in text.lines() {            let line = line.trim();            if let Some(rest) = line.to_lowercase().strip_prefix("sitemap:") {                // Reuse the original to preserve casing of the URL.                let original_after = &line[line.len() - rest.len()..];                candidates.push(original_after.trim().to_string());            }        }    }    if candidates.is_empty() {        candidates.push(format!("{base_origin}/sitemap.xml"));    }    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();    let mut urls: Vec<String> = Vec::new();    let mut to_fetch: Vec<String> = candidates;    while let Some(smurl) = to_fetch.pop() {        if seen.len() >= 20 {            break;        }        if seen.contains(&smurl) {            continue;        }        seen.insert(smurl.clone());        let r = match client.get(&smurl).send().await {            Ok(r) => r,            Err(_) => continue,        };        if r.status().as_u16() != 200 {            continue;        }        let body = match r.bytes().await {            Ok(b) => b,            Err(_) => continue,        };        for loc in parse_sitemap_xml(&body) {            let lower = loc.to_lowercase();            if lower.ends_with(".xml") || lower.contains("sitemap") {                to_fetch.push(loc);            } else {                urls.push(loc);            }        }    }    urls}/// Pull `<loc>` text from a sitemap XML body. Lightweight regex-based/// extraction; sitemaps are well-formed enough that we don't need a full/// XML parser, and adding one just for this would be overkill.fn parse_sitemap_xml(body: &[u8]) -> Vec<String> {    let s = String::from_utf8_lossy(body);    let re = regex::Regex::new(r"(?is)<loc>\s*([^<]+?)\s*</loc>").expect("regex");    re.captures_iter(&s)        .map(|c| c[1].trim().to_string())        .collect()}pub fn same_site(url: &str, host: &str) -> bool {    let Ok(u) = Url::parse(url) else { return false };    let h_lower = host.to_lowercase();    let Some(u_host) = u.host_str() else { return false };    let u_lower = u_host.to_lowercase();    if u_lower == h_lower {        return true;    }    if u_lower == format!("www.{h_lower}") || h_lower == format!("www.{u_lower}") {        return true;    }    false}
added src/crawler/mod.rs
@@ -0,0 +1,270 @@//! In-process SEO crawler. Fetches up to PAGE_CAP pages from the same host,//! collects metadata, and runs a fixed set of checks. Designed to be invoked//! from the scheduler with a progress callback.mod checks;mod fetcher;mod parser;use anyhow::Result;use serde_json::Value;use std::collections::{HashMap, HashSet, VecDeque};use std::sync::Arc;use std::time::Instant;use url::Url;pub use fetcher::PAGE_CAP;use fetcher::{    fetch, head_status, load_robots, load_sitemap, make_client, same_site, FetchResult,    CRAWL_DEADLINE_SECS, CONCURRENCY,};use parser::parse_html;#[derive(Debug, Clone, serde::Serialize)]pub struct Page {    pub url: String,    pub requested_url: String,    pub status: u16,    pub content_type: String,    pub elapsed_ms: i64,    pub bytes: usize,    pub headers: HashMap<String, String>,    pub redirect_chain: Vec<(u16, String)>,    pub error: String,    pub is_html: bool,    #[serde(flatten)]    pub html: Option<parser::ParsedHtml>,}#[derive(Debug, serde::Serialize)]pub struct CrawlResult {    pub start_url: String,    pub host: String,    pub pages: Vec<Page>,    pub external_link_status: HashMap<String, u16>,    pub sitemap_urls: Vec<String>,    pub robots: RobotsCtx,}#[derive(Debug, serde::Serialize)]pub struct RobotsCtx {    pub url: String,    pub exists: bool,    pub raw: Option<String>,    pub references_sitemap: bool,}fn normalize(url: &str) -> String {    if let Ok(mut u) = Url::parse(url) {        u.set_fragment(None);        let s = u.to_string();        let trimmed = s.trim_end_matches('/');        if trimmed.is_empty() { s } else { trimmed.to_string() }    } else {        url.to_string()    }}/// Crawl `start_url`, run all checks, return the flat insight list.pub async fn run_seo_spider<F>(start_url: &str, mut progress_cb: F) -> Result<Vec<Value>>where    F: FnMut(usize) + Send + 'static,{    let start = Instant::now();    tracing::info!("[crawler] starting {start_url}");    let result = crawl(start_url, &mut progress_cb).await?;    let insights = run_checks(&result);    tracing::info!(        "[crawler] done {start_url} - {} pages, {} insights, {:.1}s",        result.pages.len(),        insights.len(),        start.elapsed().as_secs_f64()    );    Ok(insights)}async fn crawl<F>(start_url: &str, progress_cb: &mut F) -> Result<CrawlResult>where    F: FnMut(usize) + Send,{    let parsed = Url::parse(start_url)?;    let host = parsed.host_str().unwrap_or("").to_string();    let base_origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));    let client = make_client();    let (robots, robots_url, robots_text) = load_robots(&client, &base_origin).await;    let robots = Arc::new(robots);    let sitemap_urls = load_sitemap(&client, &base_origin, robots_text.as_deref()).await;    let references_sitemap = robots_text        .as_deref()        .map(|t| {            t.lines()                .any(|l| l.trim().to_lowercase().starts_with("sitemap:"))        })        .unwrap_or(false);    let mut seen: HashSet<String> = HashSet::new();    let mut queue: VecDeque<String> = VecDeque::new();    let mut pages: Vec<Page> = Vec::new();    let mut fetched: HashSet<String> = HashSet::new();    let deadline = Instant::now() + std::time::Duration::from_secs(CRAWL_DEADLINE_SECS);    let enqueue = |url: String, seen: &mut HashSet<String>, queue: &mut VecDeque<String>| {        let n = normalize(&url);        if !seen.contains(&n) {            seen.insert(n);            queue.push_back(url);        }    };    enqueue(start_url.to_string(), &mut seen, &mut queue);    for url in sitemap_urls.iter().take(PAGE_CAP) {        if same_site(url, &host) {            enqueue(url.clone(), &mut seen, &mut queue);        }    }    while !queue.is_empty() && pages.len() < PAGE_CAP && Instant::now() < deadline {        // Pull a batch up to CONCURRENCY, respecting robots.        let mut batch: Vec<String> = Vec::new();        while let Some(url) = queue.pop_front() {            if !robots.allowed(&url) {                continue;            }            batch.push(url);            if batch.len() >= CONCURRENCY || pages.len() + batch.len() >= PAGE_CAP {                break;            }        }        if batch.is_empty() {            break;        }        let futs = batch            .into_iter()            .map(|u| {                let client = client.clone();                async move { fetch(&client, &u).await }            })            .collect::<Vec<_>>();        let results: Vec<FetchResult> = futures_util::future::join_all(futs).await;        for r in results {            let final_key = normalize(&r.url);            if fetched.contains(&final_key) {                seen.insert(final_key);                continue;            }            fetched.insert(final_key.clone());            let is_html = r.status == 200 && r.content_type.contains("text/html");            let mut page = Page {                url: r.url.clone(),                requested_url: r.requested_url.clone(),                status: r.status,                content_type: r.content_type.clone(),                elapsed_ms: r.elapsed_ms,                bytes: r.body.len(),                headers: r.headers.clone(),                redirect_chain: r.redirect_chain.clone(),                error: r.error.clone(),                is_html,                html: None,            };            if is_html {                match parse_html(&r.body, &r.url) {                    Ok(parsed) => {                        for link in &parsed.links {                            if same_site(&link.url, &host) {                                let n = normalize(&link.url);                                if !seen.contains(&n) {                                    seen.insert(n);                                    queue.push_back(link.url.clone());                                }                            }                        }                        page.html = Some(parsed);                    }                    Err(e) => {                        tracing::warn!("[crawler] parse failed for {}: {e}", r.url);                        page.is_html = false;                    }                }            }            seen.insert(normalize(&r.url));            pages.push(page);        }        progress_cb(pages.len());    }    if Instant::now() >= deadline {        tracing::warn!(            "[crawler] hit deadline for {start_url} after {} pages",            pages.len()        );    }    // External link HEAD check.    let mut external_links: HashSet<String> = HashSet::new();    for p in &pages {        if !p.is_html {            continue;        }        if let Some(html) = &p.html {            for link in &html.links {                if !same_site(&link.url, &host) {                    external_links.insert(link.url.clone());                }            }        }    }    let mut external_link_status: HashMap<String, u16> = HashMap::new();    if !external_links.is_empty() && Instant::now() < deadline {        let urls: Vec<String> = external_links.into_iter().collect();        for chunk in urls.chunks(CONCURRENCY) {            let futs = chunk.iter().cloned().map(|u| {                let client = client.clone();                async move {                    let s = head_status(&client, &u).await;                    (u, s)                }            });            let results = futures_util::future::join_all(futs).await;            for (u, s) in results {                external_link_status.insert(u, s);            }        }    }    Ok(CrawlResult {        start_url: start_url.to_string(),        host,        pages,        external_link_status,        sitemap_urls,        robots: RobotsCtx {            url: robots_url,            exists: robots_text.is_some(),            raw: robots_text,            references_sitemap,        },    })}fn run_checks(result: &CrawlResult) -> Vec<Value> {    let html_pages: Vec<&Page> = result.pages.iter().filter(|p| p.is_html).collect();    let mut status_map: HashMap<String, u16> = HashMap::new();    for p in &result.pages {        status_map.insert(p.url.clone(), p.status);    }    let ctx = checks::Ctx {        start_url: &result.start_url,        host: &result.host,        pages: &result.pages,        html_pages: &html_pages,        status_map: &status_map,        external_link_status: &result.external_link_status,        sitemap_urls: &result.sitemap_urls,        robots: &result.robots,    };    checks::run_all(&ctx)}
added src/crawler/parser.rs
@@ -0,0 +1,305 @@use anyhow::Result;use scraper::{Html, Selector};use serde::Serialize;use sha2::{Digest, Sha256};use std::collections::{BTreeMap, HashSet};use url::Url;#[derive(Debug, Clone, Serialize)]pub struct ParsedHtml {    pub title: String,    pub description: String,    pub canonical: String,    pub robots_meta: String,    pub viewport: String,    pub lang: String,    pub og: Og,    pub twitter: Twitter,    pub headings: BTreeMap<String, Vec<String>>,    pub links: Vec<Link>,    pub images: Vec<Image>,    pub resources: Vec<String>,    pub json_ld: Vec<serde_json::Value>,    pub favicon: String,    pub forms: Vec<Form>,    pub word_count: usize,    pub text_hash: String,}#[derive(Debug, Clone, Default, Serialize)]pub struct Og {    pub title: String,    pub description: String,    pub image: String,    pub url: String,}#[derive(Debug, Clone, Default, Serialize)]pub struct Twitter {    pub card: String,    pub title: String,    pub description: String,}#[derive(Debug, Clone, Serialize)]pub struct Link {    pub url: String,    pub text: String,    pub rel: Vec<String>,}#[derive(Debug, Clone, Serialize)]pub struct Image {    pub src: String,    /// `None` = attribute absent (a11y violation); `Some("")` = explicitly empty (decorative).    pub alt: Option<String>,}#[derive(Debug, Clone, Serialize)]pub struct Form {    pub action: String,    pub inputs: Vec<FormInput>,    pub label_fors: Vec<String>,}#[derive(Debug, Clone, Serialize)]pub struct FormInput {    pub r#type: String,    pub name: Option<String>,    pub id: Option<String>,    pub aria_label: Option<String>,}fn meta_by(doc: &Html, attr: &str, val: &str) -> String {    // CSS selectors don't support arbitrary attribute matching with spaces, so    // build the selector string by hand and let scraper parse it.    let sel = Selector::parse(&format!(r#"meta[{attr}="{val}"]"#)).ok();    if let Some(s) = sel {        if let Some(el) = doc.select(&s).next() {            return el.value().attr("content").unwrap_or("").trim().to_string();        }    }    String::new()}fn join(base: &Url, rel: &str) -> String {    base.join(rel)        .map(|u| u.to_string())        .unwrap_or_else(|_| rel.to_string())}pub fn parse_html(body: &[u8], page_url: &str) -> Result<ParsedHtml> {    let body_str = String::from_utf8_lossy(body);    let doc = Html::parse_document(&body_str);    let base = Url::parse(page_url)?;    let title = {        let s = Selector::parse("title").unwrap();        doc.select(&s)            .next()            .map(|t| t.text().collect::<String>().trim().to_string())            .unwrap_or_default()    };    let description = meta_by(&doc, "name", "description");    let robots_meta = meta_by(&doc, "name", "robots");    let viewport = meta_by(&doc, "name", "viewport");    let canonical = {        let s = Selector::parse(r#"link[rel="canonical"]"#).unwrap();        doc.select(&s)            .next()            .and_then(|el| el.value().attr("href"))            .map(|h| join(&base, h.trim()))            .unwrap_or_default()    };    let og = Og {        title: meta_by(&doc, "property", "og:title"),        description: meta_by(&doc, "property", "og:description"),        image: meta_by(&doc, "property", "og:image"),        url: meta_by(&doc, "property", "og:url"),    };    let twitter = Twitter {        card: meta_by(&doc, "name", "twitter:card"),        title: meta_by(&doc, "name", "twitter:title"),        description: meta_by(&doc, "name", "twitter:description"),    };    let lang = {        let s = Selector::parse("html").unwrap();        doc.select(&s)            .next()            .and_then(|el| el.value().attr("lang"))            .unwrap_or("")            .trim()            .to_string()    };    let mut headings: BTreeMap<String, Vec<String>> = BTreeMap::new();    for level in 1..=6u8 {        let key = format!("h{level}");        let s = Selector::parse(&key).unwrap();        let v: Vec<String> = doc            .select(&s)            .map(|el| el.text().collect::<Vec<_>>().join(" ").split_whitespace().collect::<Vec<_>>().join(" "))            .collect();        headings.insert(key, v);    }    let mut links: Vec<Link> = Vec::new();    let s = Selector::parse("a[href]").unwrap();    for a in doc.select(&s) {        let href = a.value().attr("href").unwrap_or("").trim();        if href.is_empty()            || href.starts_with("javascript:")            || href.starts_with("mailto:")            || href.starts_with("tel:")            || href.starts_with('#')        {            continue;        }        let text = a            .text()            .collect::<Vec<_>>()            .join(" ")            .split_whitespace()            .collect::<Vec<_>>()            .join(" ");        let rel: Vec<String> = a            .value()            .attr("rel")            .map(|s| s.split_whitespace().map(|t| t.to_string()).collect())            .unwrap_or_default();        links.push(Link {            url: join(&base, href),            text,            rel,        });    }    let mut images: Vec<Image> = Vec::new();    let s = Selector::parse("img").unwrap();    for img in doc.select(&s) {        let src = img.value().attr("src").unwrap_or("").trim();        let alt = img.value().attr("alt").map(|s| s.to_string());        images.push(Image {            src: if src.is_empty() { String::new() } else { join(&base, src) },            alt,        });    }    let mut resources: Vec<String> = Vec::new();    let s = Selector::parse("script, link, img, iframe, source").unwrap();    for el in doc.select(&s) {        let src = el            .value()            .attr("src")            .or_else(|| el.value().attr("href"))            .unwrap_or("")            .trim();        if !src.is_empty() {            resources.push(join(&base, src));        }    }    let mut json_ld: Vec<serde_json::Value> = Vec::new();    let s = Selector::parse(r#"script[type="application/ld+json"]"#).unwrap();    for sc in doc.select(&s) {        let raw = sc.text().collect::<String>();        if raw.trim().is_empty() {            continue;        }        match serde_json::from_str::<serde_json::Value>(&raw) {            Ok(v) => json_ld.push(v),            Err(_) => json_ld.push(serde_json::Value::Null),        }    }    let mut favicon = String::new();    let s = Selector::parse("link[rel]").unwrap();    for el in doc.select(&s) {        let rels = el.value().attr("rel").unwrap_or("");        if rels.split_whitespace().any(|r| r.to_lowercase().contains("icon")) {            let href = el.value().attr("href").unwrap_or("").trim();            if !href.is_empty() {                favicon = join(&base, href);                break;            }        }    }    let mut forms: Vec<Form> = Vec::new();    let s_form = Selector::parse("form").unwrap();    let s_input = Selector::parse("input, textarea, select").unwrap();    let s_label = Selector::parse("label[for]").unwrap();    for form in doc.select(&s_form) {        let mut inputs: Vec<FormInput> = Vec::new();        for i in form.select(&s_input) {            let v = i.value();            inputs.push(FormInput {                r#type: v.attr("type").unwrap_or("text").to_string(),                name: v.attr("name").map(String::from),                id: v.attr("id").map(String::from),                aria_label: v.attr("aria-label").map(String::from),            });        }        let mut label_fors: HashSet<String> = HashSet::new();        for lb in form.select(&s_label) {            if let Some(f) = lb.value().attr("for") {                label_fors.insert(f.to_string());            }        }        let action = form            .value()            .attr("action")            .map(|a| {                if a.is_empty() {                    page_url.to_string()                } else {                    join(&base, a)                }            })            .unwrap_or_else(|| page_url.to_string());        forms.push(Form {            action,            inputs,            label_fors: label_fors.into_iter().collect(),        });    }    // Visible text: drop script/style/noscript before extracting.    let strip_re = regex::Regex::new(        r"(?is)<(script|style|noscript)[^>]*>.*?</(script|style|noscript)>",    )    .unwrap();    let stripped = strip_re.replace_all(&body_str, "");    let tag_re = regex::Regex::new(r"(?is)<[^>]+>").unwrap();    let text_only = tag_re.replace_all(&stripped, " ");    let text = text_only.split_whitespace().collect::<Vec<_>>().join(" ");    let word_count = text.split_whitespace().count();    let mut h = Sha256::new();    h.update(text.as_bytes());    let text_hash = format!("{:x}", h.finalize());    Ok(ParsedHtml {        title,        description,        canonical,        robots_meta,        viewport,        lang,        og,        twitter,        headings,        links,        images,        resources,        json_ld,        favicon,        forms,        word_count,        text_hash,    })}
added src/db.rs
@@ -0,0 +1,33 @@use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};use sqlx::SqlitePool;use std::path::Path;use std::str::FromStr;use std::time::Duration;pub async fn init(data_dir: &Path) -> anyhow::Result<SqlitePool> {    let db_path = data_dir.join("db.sqlite3");    let url = format!("sqlite://{}", db_path.display());    if !db_path.exists() {        std::fs::File::create(&db_path)?;    }    let opts = SqliteConnectOptions::from_str(&url)?        .create_if_missing(true)        .journal_mode(SqliteJournalMode::Wal)        .synchronous(SqliteSynchronous::Normal)        .busy_timeout(Duration::from_secs(5))        .foreign_keys(true);    let pool = SqlitePoolOptions::new()        .max_connections(8)        .connect_with(opts)        .await?;    sqlx::migrate!("./migrations").run(&pool).await?;    Ok(pool)}pub fn now_ms() -> i64 {    chrono::Utc::now().timestamp_millis()}
added src/lighthouse.rs
@@ -0,0 +1,247 @@use serde_json::Value;use std::path::PathBuf;use std::process::Stdio;use std::time::Duration;use thiserror::Error;use tokio::process::Command;/// Locate a chromium binary for lighthouse to drive. Tries `CHROMIUM_BIN`,/// then a PATH search for the common binary names, then a glob over/// `/opt/playwright-browsers/` so the webdev container Just Works without/// per-shell env vars. Lighthouse passes the resulting path via the/// `CHROME_PATH` env var (the npm CLI looks for that).fn find_chromium() -> Option<String> {    if let Ok(p) = std::env::var("CHROMIUM_BIN") {        let path = PathBuf::from(&p);        if path.is_file() {            return Some(p);        }    }    let names = [        "chromium",        "chromium-browser",        "google-chrome",        "chrome",        "chrome-headless-shell",    ];    if let Some(path_var) = std::env::var_os("PATH") {        for dir in std::env::split_paths(&path_var) {            for name in &names {                let candidate = dir.join(name);                if candidate.is_file() {                    return Some(candidate.to_string_lossy().into_owned());                }            }        }    }    if let Ok(entries) = std::fs::read_dir("/opt/playwright-browsers") {        for entry in entries.flatten() {            let base = entry.path();            // Lighthouse needs a full chrome (it drives DevTools), not the            // headless-shell. Prefer chrome-linux64/chrome, fall back to the            // chromium build that ships under chromium-*/chrome-linux/chrome.            for rel in [                "chrome-linux64/chrome",                "chrome-linux/chrome",                "chrome-headless-shell-linux64/chrome-headless-shell",            ] {                let candidate = base.join(rel);                if candidate.is_file() {                    return Some(candidate.to_string_lossy().into_owned());                }            }        }    }    None}const SUBPROCESS_TIMEOUT_SECS: u64 = 180;const CHROME_FLAGS: &str = "--headless --no-sandbox --disable-dev-shm-usage --disable-gpu";#[derive(Debug, Error)]pub enum LighthouseError {    #[error("lighthouse binary missing at {0:?}")]    BinaryMissing(PathBuf),    #[error("lighthouse timed out after {0}s")]    Timeout(u64),    #[error("lighthouse exited {code}: {stderr}")]    ExitNonZero { code: i32, stderr: String },    #[error("could not parse lighthouse output: {0}")]    Parse(#[from] serde_json::Error),    #[error("missing category in lighthouse output: {0}")]    MissingCategory(&'static str),    #[error("null score(s) returned by lighthouse: {0:?}")]    NullScores(Vec<&'static str>),    #[error("subprocess io: {0}")]    Io(#[from] std::io::Error),}/// Run the lighthouse npm CLI and return the parsed JSON report.pub async fn fetch(root: &std::path::Path, url: &str) -> Result<Value, LighthouseError> {    let bin = root.join("node_modules/.bin/lighthouse");    if !bin.exists() {        return Err(LighthouseError::BinaryMissing(bin));    }    let chromium = find_chromium();    let mut cmd = Command::new(&bin);    cmd.arg(url)        .arg(format!("--chrome-flags={CHROME_FLAGS}"))        .arg("--output=json")        .arg("--output-path=stdout")        .arg("--quiet")        .env_clear()        .env("PATH", "/usr/bin:/bin:/usr/local/bin")        .stdout(Stdio::piped())        .stderr(Stdio::piped());    if let Some(c) = chromium {        cmd.env("CHROME_PATH", &c);    }    let child = cmd.spawn()?;    let output = match tokio::time::timeout(        Duration::from_secs(SUBPROCESS_TIMEOUT_SECS),        child.wait_with_output(),    )    .await    {        Ok(r) => r?,        Err(_) => return Err(LighthouseError::Timeout(SUBPROCESS_TIMEOUT_SECS)),    };    if !output.status.success() {        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();        let truncated = stderr            .chars()            .rev()            .take(500)            .collect::<String>()            .chars()            .rev()            .collect::<String>();        return Err(LighthouseError::ExitNonZero {            code: output.status.code().unwrap_or(-1),            stderr: truncated,        });    }    Ok(serde_json::from_slice(&output.stdout)?)}#[derive(Debug, serde::Serialize)]pub struct Scores {    #[serde(rename = "Performance")]    pub performance: i64,    #[serde(rename = "Accessibility")]    pub accessibility: i64,    #[serde(rename = "Best practices")]    pub best_practices: i64,    #[serde(rename = "SEO")]    pub seo: i64,}pub fn parse_scores(results: &Value) -> Result<Scores, LighthouseError> {    let cats = results        .get("categories")        .ok_or(LighthouseError::MissingCategory("categories"))?;    let pull = |k: &'static str| -> Result<Option<f64>, LighthouseError> {        let cat = cats.get(k).ok_or(LighthouseError::MissingCategory(k))?;        Ok(cat.get("score").and_then(|v| v.as_f64()))    };    let p = pull("performance")?;    let a = pull("accessibility")?;    let b = pull("best-practices")?;    let s = pull("seo")?;    let mut nulls = Vec::new();    if p.is_none() { nulls.push("Performance"); }    if a.is_none() { nulls.push("Accessibility"); }    if b.is_none() { nulls.push("Best practices"); }    if s.is_none() { nulls.push("SEO"); }    if !nulls.is_empty() {        return Err(LighthouseError::NullScores(nulls));    }    let to_pct = |v: f64| (v * 100.0).round() as i64;    Ok(Scores {        performance: to_pct(p.unwrap()),        accessibility: to_pct(a.unwrap()),        best_practices: to_pct(b.unwrap()),        seo: to_pct(s.unwrap()),    })}#[derive(Debug, serde::Serialize)]pub struct Details {    pub metrics: Vec<Value>,    pub opportunities: Vec<Value>,}pub fn parse_details(results: &Value) -> Option<Details> {    let category = results.get("categories")?.get("performance")?;    let audits = results.get("audits")?.as_object()?;    let mut metrics: Vec<Value> = Vec::new();    let mut opportunities: Vec<Value> = Vec::new();    if let Some(refs) = category.get("auditRefs").and_then(|v| v.as_array()) {        for r in refs {            let id = r.get("id").and_then(|v| v.as_str()).unwrap_or_default();            let Some(audit) = audits.get(id) else { continue };            let group = r.get("group").and_then(|v| v.as_str()).unwrap_or("");            let weight = r.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);            let score = audit.get("score").and_then(|v| v.as_f64());            if group == "metrics" && weight > 0.0 {                metrics.push(serde_json::json!({                    "id": id,                    "acronym": r.get("acronym").and_then(|v| v.as_str()).unwrap_or(id),                    "title": audit.get("title"),                    "display_value": audit.get("displayValue"),                    "score": score,                    "weight": weight,                }));                continue;            }            // Opportunities/diagnostics: skip passing/manual/not-applicable.            let mode = audit                .get("scoreDisplayMode")                .and_then(|v| v.as_str())                .unwrap_or("");            if matches!(mode, "manual" | "notApplicable" | "informative") {                continue;            }            let Some(s) = score else { continue };            if s >= 0.9 {                continue;            }            let savings_ms = audit                .get("details")                .and_then(|d| d.get("overallSavingsMs"))                .and_then(|v| v.as_f64())                .unwrap_or(0.0);            opportunities.push(serde_json::json!({                "id": id,                "title": audit.get("title"),                "display_value": audit.get("displayValue"),                "score": s,                "savings_ms": savings_ms,                "weight": weight,            }));        }    }    metrics.sort_by(|a, b| {        let aw = a.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);        let bw = b.get("weight").and_then(|v| v.as_f64()).unwrap_or(0.0);        bw.partial_cmp(&aw).unwrap_or(std::cmp::Ordering::Equal)    });    opportunities.sort_by(|a, b| {        let asav = a.get("savings_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);        let bsav = b.get("savings_ms").and_then(|v| v.as_f64()).unwrap_or(0.0);        bsav.partial_cmp(&asav).unwrap_or(std::cmp::Ordering::Equal)    });    opportunities.truncate(10);    Some(Details { metrics, opportunities })}
added src/main.rs
@@ -0,0 +1,117 @@mod alerts;mod app;mod checker;mod crawler;mod db;mod lighthouse;mod middleware;mod migrate;mod models;mod pdf;mod render;mod routes;mod scheduler;mod templates;pub use app::{AppState, Config};use std::net::SocketAddr;use std::path::PathBuf;#[tokio::main]async fn main() -> anyhow::Result<()> {    dotenvy::dotenv().ok();    tracing_subscriber::fmt()        .with_env_filter(            tracing_subscriber::EnvFilter::try_from_default_env()                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),        )        .init();    // Subcommand dispatch. Anything besides a known subcommand falls through to the server.    let mut argv = std::env::args().skip(1);    if let Some(first) = argv.next() {        match first.as_str() {            "migrate" => return run_migrate(argv.collect()).await,            "preview-email" => return run_preview_email(argv.collect()),            "--help" | "-h" => {                print_usage();                return Ok(());            }            other if !other.is_empty() => {                eprintln!("unknown subcommand: {other}");                print_usage();                std::process::exit(2);            }            _ => {}        }    }    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let state = AppState::from_env().await?;    // Reset wedged crawl/lighthouse rows from a prior crash, then spawn the    // scheduler loop alongside the HTTP server.    scheduler::reset_states_on_boot(&state.pool).await?;    let scheduler_handle = scheduler::spawn(state.pool.clone(), state.config.clone());    let router = app::router(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await?;    tracing::info!("status listening on http://{addr}");    axum::serve(listener, router).await?;    drop(scheduler_handle);    Ok(())}fn print_usage() {    eprintln!(        "status: single-binary axum uptime monitor\n\         \n\         Usage:\n  \           status                              run the HTTP server + scheduler\n  \           status migrate <path> [--force]     import a Django status SQLite\n  \           status preview-email <down|recovery> render an alert HTML to stdout\n"    );}fn run_preview_email(args: Vec<String>) -> anyhow::Result<()> {    let kind = args        .first()        .map(String::as_str)        .ok_or_else(|| anyhow::anyhow!("usage: status preview-email <down|recovery>"))?;    let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());    let html = alerts::render_preview_html(kind, &base_url)?;    print!("{html}");    Ok(())}async fn run_migrate(args: Vec<String>) -> anyhow::Result<()> {    let mut source: Option<PathBuf> = None;    let mut force = false;    for arg in args {        match arg.as_str() {            "--force" | "-f" => force = true,            "--help" | "-h" => {                eprintln!("Usage: status migrate <path-to-django-sqlite3> [--force]");                return Ok(());            }            v if v.starts_with("--") => anyhow::bail!("unknown migrate flag: {v}"),            v => {                if source.is_some() {                    anyhow::bail!("migrate takes a single source path");                }                source = Some(PathBuf::from(v));            }        }    }    let source = source.ok_or_else(|| {        anyhow::anyhow!("usage: status migrate <path-to-django-sqlite3> [--force]")    })?;    migrate::run(source, force).await}
added src/middleware.rs
@@ -0,0 +1,34 @@use axum::{    extract::Request,    http::StatusCode,    middleware::Next,    response::{IntoResponse, Response},};use chrono::Local;use std::time::Instant;pub async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}pub async fn not_found() -> Response {    (StatusCode::NOT_FOUND, "404 Not Found").into_response()}
added src/migrate.rs
@@ -0,0 +1,222 @@//! Import an existing Django status SQLite into the rust schema.//!//! Reads `properties_property` and `properties_check` from the Django DB//! and writes them into the new schema, preserving Property UUIDs so//! existing public status URLs keep working.use anyhow::{Context, Result};use sqlx::sqlite::SqliteConnectOptions;use sqlx::{Connection, SqliteConnection};use std::path::Path;use std::path::PathBuf;use std::str::FromStr;pub async fn run(source: PathBuf, force: bool) -> Result<()> {    if !source.exists() {        anyhow::bail!("source DB not found: {}", source.display());    }    let data_dir = std::env::var("STATUS_DATA_DIR")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("data"));    std::fs::create_dir_all(&data_dir)?;    let dest_path = data_dir.join("db.sqlite3");    if dest_path.exists() && !force {        anyhow::bail!(            "{} already exists. Pass --force to wipe it first.",            dest_path.display()        );    }    if dest_path.exists() {        std::fs::remove_file(&dest_path).context("removing existing dest db")?;    }    let pool = crate::db::init(&data_dir).await?;    let src_url = format!("sqlite://{}?mode=ro", source.display());    let opts = SqliteConnectOptions::from_str(&src_url)?        .create_if_missing(false)        .read_only(true);    let mut src = SqliteConnection::connect_with(&opts).await.context("opening source db")?;    // ---------- properties ----------    // Django columns: id (UUID hex string), url, is_public, last_run_at,    // next_run_at, last_run_at_crawler, next_run_at_crawler, crawler_insights    // (JSON or NULL), crawl_state, crawl_started_at, last_crawl_success_at,    // last_crawl_error, last_crawl_duration_ms, last_crawl_pages_count,    // lighthouse_scores, lighthouse_details, last_lighthouse_run_at,    // last_lighthouse_success_at, last_lighthouse_error,    // last_lighthouse_duration_ms, next_lighthouse_run_at, lighthouse_state,    // lighthouse_started_at, last_alert_sent, alert_state, created_at,    // updated_at, user_id.    let rows: Vec<DjangoProperty> = sqlx::query_as::<_, DjangoProperty>(        "SELECT id, url, is_public, \                last_run_at, next_run_at, \                last_run_at_crawler, next_run_at_crawler, crawler_insights, \                crawl_state, crawl_started_at, last_crawl_success_at, last_crawl_error, \                last_crawl_duration_ms, last_crawl_pages_count, \                lighthouse_scores, lighthouse_details, last_lighthouse_run_at, \                last_lighthouse_success_at, last_lighthouse_error, last_lighthouse_duration_ms, \                next_lighthouse_run_at, lighthouse_state, lighthouse_started_at, \                last_alert_sent, alert_state, created_at, updated_at \         FROM properties_property",    )    .fetch_all(&mut src)    .await    .context("reading properties from source db")?;    let mut prop_count = 0;    for row in &rows {        let uuid = uuid::Uuid::parse_str(&row.id)            .or_else(|_| uuid::Uuid::parse_str(&format!(                "{}-{}-{}-{}-{}",                &row.id[0..8], &row.id[8..12], &row.id[12..16], &row.id[16..20], &row.id[20..]            )))            .with_context(|| format!("parsing uuid: {}", row.id))?;        let blob = uuid.as_bytes().to_vec();        sqlx::query(            r#"INSERT INTO properties (                id, url, is_public, is_protected,                last_run_at, next_run_at,                last_run_at_crawler, next_run_at_crawler, crawler_insights,                crawl_state, crawl_started_at, last_crawl_success_at, last_crawl_error,                last_crawl_duration_ms, last_crawl_pages_count,                lighthouse_scores, lighthouse_details, last_lighthouse_run_at,                last_lighthouse_success_at, last_lighthouse_error, last_lighthouse_duration_ms,                next_lighthouse_run_at, lighthouse_state, lighthouse_started_at,                alert_state, last_alert_sent, created_at, updated_at            ) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,        )        .bind(&blob)        .bind(&row.url)        .bind(row.is_public)        .bind(parse_django_dt(&row.last_run_at))        .bind(parse_django_dt(&row.next_run_at))        .bind(parse_django_dt(&row.last_run_at_crawler))        .bind(parse_django_dt(&row.next_run_at_crawler))        .bind(&row.crawler_insights)        .bind(&row.crawl_state)        .bind(parse_django_dt(&row.crawl_started_at))        .bind(parse_django_dt(&row.last_crawl_success_at))        .bind(&row.last_crawl_error)        .bind(row.last_crawl_duration_ms)        .bind(row.last_crawl_pages_count)        .bind(&row.lighthouse_scores)        .bind(&row.lighthouse_details)        .bind(parse_django_dt(&row.last_lighthouse_run_at))        .bind(parse_django_dt(&row.last_lighthouse_success_at))        .bind(&row.last_lighthouse_error)        .bind(row.last_lighthouse_duration_ms)        .bind(parse_django_dt(&row.next_lighthouse_run_at))        .bind(&row.lighthouse_state)        .bind(parse_django_dt(&row.lighthouse_started_at))        .bind(&row.alert_state)        .bind(parse_django_dt(&row.last_alert_sent))        .bind(parse_django_dt(&row.created_at).unwrap_or(0))        .bind(parse_django_dt(&row.updated_at).unwrap_or(0))        .execute(&pool)        .await        .with_context(|| format!("inserting property {}", row.url))?;        prop_count += 1;    }    // ---------- checks ----------    let checks: Vec<DjangoCheck> = sqlx::query_as::<_, DjangoCheck>(        "SELECT property_id, status_code, response_time, headers, created_at FROM properties_check",    )    .fetch_all(&mut src)    .await    .context("reading checks from source db")?;    let mut check_count = 0;    for row in &checks {        let uuid = match uuid::Uuid::parse_str(&row.property_id) {            Ok(u) => u,            Err(_) => continue,        };        let blob = uuid.as_bytes().to_vec();        let created = parse_django_dt(&row.created_at).unwrap_or(0);        let response_ms = row.response_time;        let _ = sqlx::query(            "INSERT INTO checks (property_id, status_code, response_ms, headers, created_at) \             VALUES (?, ?, ?, ?, ?)",        )        .bind(&blob)        .bind(row.status_code)        .bind(response_ms)        .bind(&row.headers)        .bind(created)        .execute(&pool)        .await;        check_count += 1;    }    println!(        "[migrate] imported {prop_count} properties, {check_count} checks into {}",        dest_path.display()    );    let _ = src.close().await;    Ok(())}#[derive(sqlx::FromRow)]struct DjangoProperty {    id: String,    url: String,    is_public: i64,    last_run_at: Option<String>,    next_run_at: Option<String>,    last_run_at_crawler: Option<String>,    next_run_at_crawler: Option<String>,    crawler_insights: Option<String>,    crawl_state: String,    crawl_started_at: Option<String>,    last_crawl_success_at: Option<String>,    last_crawl_error: Option<String>,    last_crawl_duration_ms: Option<i64>,    last_crawl_pages_count: Option<i64>,    lighthouse_scores: Option<String>,    lighthouse_details: Option<String>,    last_lighthouse_run_at: Option<String>,    last_lighthouse_success_at: Option<String>,    last_lighthouse_error: Option<String>,    last_lighthouse_duration_ms: Option<i64>,    next_lighthouse_run_at: Option<String>,    lighthouse_state: String,    lighthouse_started_at: Option<String>,    last_alert_sent: Option<String>,    alert_state: String,    created_at: Option<String>,    updated_at: Option<String>,}#[derive(sqlx::FromRow)]struct DjangoCheck {    property_id: String,    status_code: i64,    // Django schema declared `INTEGER NOT NULL`; the Python ORM stored    // milliseconds as ints. (An earlier draft tried `f64` and crashed on    // strict-typed sqlx decoding.)    response_time: i64,    headers: String,    created_at: Option<String>,}/// Django stores datetimes as ISO8601 strings (`YYYY-MM-DD HH:MM:SS[.f]`)./// Parse to UTC ms since epoch; return None if absent or unparseable.fn parse_django_dt(s: &Option<String>) -> Option<i64> {    let raw = s.as_deref()?.trim();    if raw.is_empty() {        return None;    }    let normalized = raw.replace('T', " ");    let parsed = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S%.f")        .or_else(|_| chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S"))        .ok()?;    Some(chrono::Utc.from_utc_datetime(&parsed).timestamp_millis())}use chrono::TimeZone;#[allow(dead_code)]fn _unused(_: &Path) {}
added src/models.rs
@@ -0,0 +1,255 @@use chrono::{DateTime, TimeZone, Utc};use serde::{Deserialize, Serialize};use sqlx::SqlitePool;use uuid::Uuid;/// Row from the `properties` table. Timestamps are stored as integer ms since/// epoch; we keep them as `i64` here and convert at the rendering boundary.#[allow(dead_code)]#[derive(Debug, Clone, sqlx::FromRow)]pub struct PropertyRow {    pub id: Vec<u8>,    pub url: String,    pub is_public: i64,    pub is_protected: i64,    pub last_run_at: Option<i64>,    pub next_run_at: Option<i64>,    pub last_run_at_crawler: Option<i64>,    pub next_run_at_crawler: Option<i64>,    pub crawler_insights: Option<String>,    pub crawl_state: String,    pub crawl_started_at: Option<i64>,    pub last_crawl_success_at: Option<i64>,    pub last_crawl_error: Option<String>,    pub last_crawl_duration_ms: Option<i64>,    pub last_crawl_pages_count: Option<i64>,    pub lighthouse_scores: Option<String>,    pub lighthouse_details: Option<String>,    pub last_lighthouse_run_at: Option<i64>,    pub last_lighthouse_success_at: Option<i64>,    pub last_lighthouse_error: Option<String>,    pub last_lighthouse_duration_ms: Option<i64>,    pub next_lighthouse_run_at: Option<i64>,    pub lighthouse_state: String,    pub lighthouse_started_at: Option<i64>,    pub alert_state: String,    pub last_alert_sent: Option<i64>,    pub created_at: i64,    pub updated_at: i64,}impl PropertyRow {    pub fn uuid(&self) -> Uuid {        Uuid::from_slice(&self.id).unwrap_or(Uuid::nil())    }    /// "example.com" stripped of leading www. Used for display/title.    pub fn name(&self) -> String {        self.url            .split('/')            .nth(2)            .unwrap_or(&self.url)            .trim_start_matches("www.")            .to_string()    }}/// Minimal serializable shape used in templates. Contains everything every/// template touches; we lift expensive computations (latest_headers,/// recent_uptime_pct, etc.) into pre-computed fields fetched alongside.#[derive(Debug, Serialize)]pub struct PropertyContext {    pub id: String,    pub url: String,    pub name: String,    pub is_public: bool,    pub is_protected: bool,    pub current_status: i64,    pub avg_response_time: i64,    pub recent_uptime_pct: Option<f64>,    pub recent_tick_stream: Vec<&'static str>,    pub total_checks: i64,    pub crawl_state: String,    pub crawler_insights: serde_json::Value,    pub last_crawl_success_at: Option<String>,    pub last_crawl_error: Option<String>,    pub last_crawl_duration_ms: Option<i64>,    pub last_crawl_pages_count: Option<i64>,    pub next_run_at_crawler: Option<String>,    pub crawl_started_at: Option<String>,    pub lighthouse_state: String,    pub lighthouse_scores: serde_json::Value,    pub lighthouse_details: serde_json::Value,    pub last_lighthouse_success_at: Option<String>,    pub last_lighthouse_error: Option<String>,    pub last_lighthouse_duration_ms: Option<i64>,    pub next_lighthouse_run_at: Option<String>,    pub lighthouse_started_at: Option<String>,    pub avg_lighthouse_score: Option<i64>,    pub alert_state: String,    pub created_at: String,    pub updated_at: String,    // Security flags derived from the latest response headers.    pub is_https: bool,    pub invalid_cert: bool,    pub has_mime_type: bool,    pub has_content_sniffing_protection: bool,    pub has_xss_protection: bool,    pub has_clickjack_protection: bool,    pub hides_server_version: bool,    pub has_hsts: bool,    pub has_hsts_preload: bool,    pub has_security_issue: bool,}pub fn ms_to_iso(ms: i64) -> String {    Utc.timestamp_millis_opt(ms)        .single()        .map(|d| d.to_rfc3339())        .unwrap_or_default()}pub fn ms_to_iso_opt(ms: Option<i64>) -> Option<String> {    ms.and_then(|m| Utc.timestamp_millis_opt(m).single().map(|d| d.to_rfc3339()))}#[allow(dead_code)]pub fn ms_to_dt(ms: i64) -> DateTime<Utc> {    Utc.timestamp_millis_opt(ms).single().unwrap_or_else(Utc::now)}pub async fn list_properties(    pool: &SqlitePool,    search: Option<&str>,) -> sqlx::Result<Vec<PropertyRow>> {    if let Some(q) = search {        sqlx::query_as::<_, PropertyRow>(            "SELECT * FROM properties WHERE url LIKE ? ORDER BY url",        )        .bind(format!("%{q}%"))        .fetch_all(pool)        .await    } else {        sqlx::query_as::<_, PropertyRow>("SELECT * FROM properties ORDER BY url")            .fetch_all(pool)            .await    }}pub async fn get_property(pool: &SqlitePool, id: Uuid) -> sqlx::Result<Option<PropertyRow>> {    sqlx::query_as::<_, PropertyRow>("SELECT * FROM properties WHERE id = ?")        .bind(id.as_bytes().to_vec())        .fetch_optional(pool)        .await}pub async fn delete_property(pool: &SqlitePool, id: Uuid) -> sqlx::Result<()> {    sqlx::query("DELETE FROM properties WHERE id = ? AND is_protected = 0")        .bind(id.as_bytes().to_vec())        .execute(pool)        .await?;    Ok(())}pub async fn create_property(pool: &SqlitePool, url: &str) -> sqlx::Result<Uuid> {    let id = Uuid::new_v4();    let now = crate::db::now_ms();    sqlx::query(        r#"INSERT INTO properties (id, url, created_at, updated_at)           VALUES (?, ?, ?, ?)"#,    )    .bind(id.as_bytes().to_vec())    .bind(url)    .bind(now)    .bind(now)    .execute(pool)    .await?;    Ok(id)}pub async fn toggle_public(pool: &SqlitePool, id: Uuid) -> sqlx::Result<bool> {    let row: Option<(i64,)> = sqlx::query_as("SELECT is_public FROM properties WHERE id = ?")        .bind(id.as_bytes().to_vec())        .fetch_optional(pool)        .await?;    let Some((cur,)) = row else { return Ok(false) };    let new = if cur == 0 { 1 } else { 0 };    sqlx::query("UPDATE properties SET is_public = ?, updated_at = ? WHERE id = ?")        .bind(new)        .bind(crate::db::now_ms())        .bind(id.as_bytes().to_vec())        .execute(pool)        .await?;    Ok(new == 1)}#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]pub struct CheckRow {    pub id: i64,    pub property_id: Vec<u8>,    pub status_code: i64,    pub response_ms: i64,    pub headers: String,    pub created_at: i64,}pub async fn recent_checks(    pool: &SqlitePool,    property_id: Uuid,    limit: i64,) -> sqlx::Result<Vec<CheckRow>> {    sqlx::query_as::<_, CheckRow>(        "SELECT * FROM checks WHERE property_id = ? ORDER BY created_at DESC LIMIT ?",    )    .bind(property_id.as_bytes().to_vec())    .bind(limit)    .fetch_all(pool)    .await}pub async fn count_status_codes(    pool: &SqlitePool,    property_id: Uuid,) -> sqlx::Result<Vec<(i64, i64)>> {    sqlx::query_as::<_, (i64, i64)>(        "SELECT status_code, COUNT(*) FROM checks WHERE property_id = ? GROUP BY status_code",    )    .bind(property_id.as_bytes().to_vec())    .fetch_all(pool)    .await}pub async fn count_checks(pool: &SqlitePool, property_id: Uuid) -> sqlx::Result<i64> {    let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM checks WHERE property_id = ?")        .bind(property_id.as_bytes().to_vec())        .fetch_one(pool)        .await?;    Ok(n)}pub async fn count_uptime(    pool: &SqlitePool,    property_id: Uuid,) -> sqlx::Result<(i64, i64)> {    let (up, down): (i64, i64) = sqlx::query_as(        "SELECT \           SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END), \           SUM(CASE WHEN status_code <> 200 THEN 1 ELSE 0 END) \         FROM checks WHERE property_id = ?",    )    .bind(property_id.as_bytes().to_vec())    .fetch_one(pool)    .await    .unwrap_or((0, 0));    Ok((up, down))}
added src/pdf.rs
@@ -0,0 +1,150 @@//! PDF generation via the embedded Typst compiler. Mirrors blog/analytics://! the `.typ` template lives in `templates/properties/property_report.typ`//! and is rendered through minijinja first (using `typst_md` / `typst_str`//! filters to escape user data into Typst-safe markup), then compiled here.use std::path::{Path, PathBuf};use std::sync::Arc;use chrono::{Datelike, Local};use typst::{    diag::{FileError, FileResult, SourceDiagnostic},    foundations::{Bytes, Datetime},    layout::PagedDocument,    syntax::{FileId, Source, VirtualPath},    text::{Font, FontBook},    utils::LazyHash,    Library, LibraryExt, World,};use typst_kit::fonts::{FontSearcher, FontSlot, Fonts};/// Pre-built renderer state. Fonts and the standard library are loaded once/// at startup and shared across renders.pub struct PdfRenderer {    library: Arc<LazyHash<Library>>,    book: Arc<LazyHash<FontBook>>,    fonts: Arc<Vec<FontSlot>>,    root: PathBuf,}impl PdfRenderer {    pub fn new(root: PathBuf) -> Self {        let Fonts { book, fonts } =            FontSearcher::new().include_system_fonts(true).search();        Self {            library: Arc::new(LazyHash::new(Library::default())),            book: Arc::new(LazyHash::new(book)),            fonts: Arc::new(fonts),            root,        }    }    /// Compile a Typst source string into PDF bytes. Designed to be called    /// from `tokio::task::spawn_blocking` since Typst compilation is CPU-bound    /// and synchronous.    pub fn render(&self, source: String) -> anyhow::Result<Vec<u8>> {        let main_id = FileId::new(None, VirtualPath::new("/main.typ"));        let main = Source::new(main_id, source);        let world = PdfWorld {            library: self.library.clone(),            book: self.book.clone(),            fonts: self.fonts.clone(),            root: self.root.clone(),            main,        };        let warned = typst::compile::<PagedDocument>(&world);        let document = warned            .output            .map_err(|errs| format_diagnostics("compile", &errs))?;        let bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())            .map_err(|errs| format_diagnostics("pdf export", &errs))?;        Ok(bytes)    }}fn format_diagnostics(stage: &str, errs: &[SourceDiagnostic]) -> anyhow::Error {    let mut s = String::new();    for e in errs {        if !s.is_empty() {            s.push('\n');        }        s.push_str(&e.message);        for h in &e.hints {            s.push_str("\n  hint: ");            s.push_str(h);        }    }    anyhow::anyhow!("typst {stage}: {s}")}struct PdfWorld {    library: Arc<LazyHash<Library>>,    book: Arc<LazyHash<FontBook>>,    fonts: Arc<Vec<FontSlot>>,    root: PathBuf,    main: Source,}impl World for PdfWorld {    fn library(&self) -> &LazyHash<Library> {        &self.library    }    fn book(&self) -> &LazyHash<FontBook> {        &self.book    }    fn main(&self) -> FileId {        self.main.id()    }    fn source(&self, id: FileId) -> FileResult<Source> {        if id == self.main.id() {            return Ok(self.main.clone());        }        let path = self.resolve(id)?;        let text =            std::fs::read_to_string(&path).map_err(|err| FileError::from_io(err, &path))?;        Ok(Source::new(id, text))    }    fn file(&self, id: FileId) -> FileResult<Bytes> {        let path = self.resolve(id)?;        let bytes = std::fs::read(&path).map_err(|err| FileError::from_io(err, &path))?;        Ok(Bytes::new(bytes))    }    fn font(&self, index: usize) -> Option<Font> {        self.fonts.get(index)?.get()    }    fn today(&self, _offset: Option<i64>) -> Option<Datetime> {        let now = Local::now();        Datetime::from_ymd(now.year(), now.month() as u8, now.day() as u8)    }}impl PdfWorld {    fn resolve(&self, id: FileId) -> FileResult<PathBuf> {        if id.package().is_some() {            return Err(FileError::Other(Some(                "remote packages not supported".into(),            )));        }        id.vpath()            .resolve(&self.root)            .ok_or(FileError::AccessDenied)            .and_then(|p| {                if path_within(&p, &self.root) {                    Ok(p)                } else {                    Err(FileError::AccessDenied)                }            })    }}fn path_within(path: &Path, root: &Path) -> bool {    let canon = match path.canonicalize() {        Ok(p) => p,        Err(_) => return false,    };    let canon_root = match root.canonicalize() {        Ok(p) => p,        Err(_) => return false,    };    canon.starts_with(canon_root)}
added src/render.rs
@@ -0,0 +1,62 @@use axum::{    http::StatusCode,    response::{Html, IntoResponse, Response},};use chrono::Datelike;use crate::templates::{RequestCtx, UserCtx};use crate::AppState;/// Render a template to a String, with the standard page context injected.////// `extra` is merged on top of the standard context. Templates expect/// `user`, `request`, `now`, `BASE_URL`, `messages` to be present; callers/// supply page-specific fields like `page` via `extra`.////// Returns the rendered body on success, or a 500 Response with the error/// already logged.pub fn render_to_string(    state: &AppState,    template: &str,    path: &str,    authed: bool,    extra: minijinja::Value,) -> Result<String, Response> {    let tmpl = state.env.get_template(template).map_err(|e| {        tracing::error!("template '{}': {}", template, e);        (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response()    })?;    tmpl.render(minijinja::context! {        user => UserCtx { is_authenticated: authed },        request => RequestCtx {            url: String::new(),            url_root: "/".to_string(),            base_url: state.config.base_url.clone(),            path: path.to_string(),        },        now => minijinja::context! { year => chrono::Local::now().year() },        BASE_URL => &state.config.base_url,        messages => Vec::<()>::new(),        ..extra    })    .map_err(|e| {        tracing::error!("render '{}': {}", template, e);        (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()    })}/// Convenience wrapper around `render_to_string` for HTML responses. Most/// page handlers want this; `render_to_string` is for callers that need the/// raw body (e.g. markdown downloads, PDF print templates).pub fn render(    state: &AppState,    template: &str,    path: &str,    authed: bool,    extra: minijinja::Value,) -> Response {    match render_to_string(state, template, path, authed, extra) {        Ok(body) => Html(body).into_response(),        Err(resp) => resp,    }}
added src/routes/auth.rs
@@ -0,0 +1,106 @@use axum::{    extract::{Form, State},    http::StatusCode,    response::{IntoResponse, Redirect, Response},    routing::{get, post},    Router,};use chrono::Utc;use serde::Deserialize;use tower_cookies::{    cookie::{time::Duration, SameSite},    Cookie, Cookies,};use crate::render::render;use crate::AppState;const COOKIE_NAME: &str = "session";// 30 days. Matches the cookie max-age the browser stores.const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;pub fn router() -> Router<AppState> {    Router::new()        .route("/login", get(login_form).post(login_submit))        .route("/logout", post(logout))}#[derive(Debug, Deserialize)]pub struct LoginForm {    pub password: String,    #[serde(default)]    pub next: Option<String>,}/// Returns true if the request carries a valid, unexpired signed session/// cookie. Used by every auth-gated route module.pub fn is_authenticated(cookies: &Cookies, state: &AppState) -> bool {    let signed = cookies.signed(&state.cookie_key);    let Some(c) = signed.get(COOKIE_NAME) else { return false };    let value = c.value();    let Some((flag, exp_str)) = value.split_once(':') else { return false };    if flag != "1" {        return false;    }    let Ok(exp) = exp_str.parse::<i64>() else { return false };    Utc::now().timestamp() < exp}fn render_login(state: &AppState, error: Option<&str>) -> Response {    render(        state,        "registration/login.html",        "/login",        false,        minijinja::context! {            page => minijinja::context! {                title => "Log in",                description => "Log in to your dashboard.",            },            title => "Log in",            description => "Log in to your dashboard.",            error => error,            next => "/properties",        },    )}pub async fn login_form(State(state): State<AppState>, cookies: Cookies) -> Response {    if is_authenticated(&cookies, &state) {        return Redirect::to("/properties").into_response();    }    render_login(&state, None)}pub async fn login_submit(    State(state): State<AppState>,    cookies: Cookies,    Form(form): Form<LoginForm>,) -> Response {    if form.password != state.config.password {        let html = render_login(&state, Some("Invalid password."));        return (StatusCode::UNAUTHORIZED, html).into_response();    }    let exp = Utc::now().timestamp() + SESSION_TTL_SECS;    let value = format!("1:{exp}");    let cookie = Cookie::build((COOKIE_NAME, value))        .path("/")        .http_only(true)        .same_site(SameSite::Strict)        .max_age(Duration::seconds(SESSION_TTL_SECS))        .build();    cookies.signed(&state.cookie_key).add(cookie);    let next = form        .next        .filter(|n| n.starts_with('/') && !n.starts_with("//"))        .unwrap_or_else(|| "/properties".to_string());    Redirect::to(&next).into_response()}pub async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {    let signed = cookies.signed(&state.cookie_key);    if let Some(c) = signed.get(COOKIE_NAME) {        cookies.remove(c);    }    Redirect::to("/")}
added src/routes/dashboard.rs
@@ -0,0 +1,561 @@use axum::{    extract::{Path as AxumPath, Query, State},    http::{header, HeaderMap, StatusCode},    response::{IntoResponse, Json, Redirect, Response},    routing::{get, post},    Router,};use serde::Deserialize;use serde_json::{json, Value};use std::collections::HashMap;use tower_cookies::Cookies;use uuid::Uuid;use crate::models::{    self, count_checks, count_status_codes, count_uptime, ms_to_iso_opt, recent_checks,    PropertyContext, PropertyRow,};use crate::render::{render, render_to_string};use crate::routes::auth::is_authenticated;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/properties/{id}/status", get(property_status))        .route("/properties/{id}/recrawl", post(property_recrawl))        .route(            "/properties/{id}/rerun-lighthouse",            post(property_rerun_lighthouse),        )        // UUID catch-all is the property dashboard. Keep the merge order in        // app::router so the named routes above win the match.        .route("/{property_id}", get(property))}fn forbidden_json() -> Response {    (StatusCode::FORBIDDEN, Json(json!({"error": "forbidden"}))).into_response()}fn not_found_json() -> Response {    (StatusCode::NOT_FOUND, Json(json!({"error": "not_found"}))).into_response()}#[derive(Debug, Deserialize)]pub struct PropertyQuery {    pub report: Option<String>,}pub async fn property(    State(state): State<AppState>,    cookies: Cookies,    AxumPath(property_id): AxumPath<Uuid>,    Query(q): Query<PropertyQuery>,) -> Response {    let row = match models::get_property(&state.pool, property_id).await {        Ok(Some(r)) => r,        _ => return Redirect::to("/properties").into_response(),    };    let authed = is_authenticated(&cookies, &state);    let public = row.is_public != 0;    if !public && !authed {        return Redirect::to("/properties").into_response();    }    let ctx = match build_property_context(&state, &row).await {        Ok(c) => c,        Err(e) => {            tracing::error!("property context: {e:#}");            return (StatusCode::INTERNAL_SERVER_ERROR, "context error").into_response();        }    };    // Per-page graphs (response times, status codes, uptime).    let recent = recent_checks(&state.pool, property_id, 31).await.unwrap_or_default();    let status_response_times: Vec<Value> = recent        .iter()        .rev()        .map(|c| {            json!({                "label": chrono::DateTime::<chrono::Utc>::from_timestamp_millis(c.created_at)                    .map(|d| d.to_rfc3339())                    .unwrap_or_default(),                "count": c.response_ms,            })        })        .collect();    let codes = count_status_codes(&state.pool, property_id).await.unwrap_or_default();    let status_codes_graph: Vec<Value> = codes        .iter()        .map(|(code, count)| json!({"label": code, "count": count}))        .collect();    let (up, down) = count_uptime(&state.pool, property_id).await.unwrap_or((0, 0));    let total = up + down;    let pct = |n: i64| -> f64 {        if total == 0 {            0.0        } else {            (n as f64 / total as f64 * 10000.0).round() / 100.0        }    };    let uptime_graph: Vec<Value> = vec![        json!({"label": "Uptime",   "count": pct(up)}),        json!({"label": "Downtime", "count": pct(down)}),    ];    let title = ctx.name.clone();    let description = format!("Status for {}", ctx.name);    let property_value = serde_json::to_value(&ctx).unwrap_or(Value::Null);    let insights_groups = group_insights_by_type(&ctx.crawler_insights);    let path = format!("/{property_id}");    // Report formats.    if let Some(fmt) = q.report.as_deref() {        let fmt = if fmt.is_empty() { "pdf" } else { fmt };        if fmt == "md" {            let extra = minijinja::context! {                property => &property_value,                title => &title,                description => &description,            };            return match render_to_string(                &state,                "properties/property_report.md",                &path,                authed,                extra,            ) {                Ok(body) => {                    let mut h = HeaderMap::new();                    h.insert(                        header::CONTENT_TYPE,                        "text/markdown; charset=utf-8".parse().unwrap(),                    );                    h.insert(                        header::CONTENT_DISPOSITION,                        format!("inline; filename=\"{}.md\"", ctx.name).parse().unwrap(),                    );                    (StatusCode::OK, h, body).into_response()                }                Err(resp) => resp,            };        }        if fmt == "pdf" {            let extra = minijinja::context! {                property => &property_value,                insights_groups => &insights_groups,                title => &title,                description => &description,                base_url => &state.config.base_url,                generated_at => chrono::Local::now().format("%Y-%m-%d %H:%M %Z").to_string(),            };            let typst_source = match render_to_string(                &state,                "properties/property_report.typ",                &path,                authed,                extra,            ) {                Ok(s) => s,                Err(resp) => return resp,            };            let renderer = state.pdf_renderer.clone();            let result =                tokio::task::spawn_blocking(move || renderer.render(typst_source)).await;            return match result {                Ok(Ok(bytes)) => {                    let mut hh = HeaderMap::new();                    hh.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());                    hh.insert(                        header::CONTENT_DISPOSITION,                        format!("inline; filename=\"{}.pdf\"", ctx.name).parse().unwrap(),                    );                    (StatusCode::OK, hh, bytes).into_response()                }                Ok(Err(e)) => {                    tracing::error!("pdf render: {e:#}");                    (StatusCode::SERVICE_UNAVAILABLE, "pdf unavailable").into_response()                }                Err(e) => {                    tracing::error!("pdf join: {e}");                    (StatusCode::INTERNAL_SERVER_ERROR, "pdf join error").into_response()                }            };        }    }    let extra = minijinja::context! {        page => minijinja::context! { title => &title, description => &description },        property => &property_value,        status_response_times_graph => &status_response_times,        status_codes_graph => &status_codes_graph,        uptime_graph => &uptime_graph,        insights_groups => &insights_groups,        title => &title,        description => &description,    };    render(&state, "properties/property.html", &path, authed, extra)}pub async fn build_property_context(    state: &AppState,    row: &PropertyRow,) -> anyhow::Result<PropertyContext> {    let id = row.uuid();    let recent = recent_checks(&state.pool, id, 100).await?;    let total = count_checks(&state.pool, id).await?;    let current_status = recent.first().map(|c| c.status_code).unwrap_or(200);    let avg_response_time = if recent.is_empty() {        0    } else {        let n = recent.iter().take(31).count() as i64;        let sum: i64 = recent.iter().take(31).map(|c| c.response_ms).sum();        if n == 0 {            0        } else {            sum / n        }    };    let recent_uptime_pct = if recent.is_empty() {        None    } else {        let up = recent.iter().filter(|c| c.status_code == 200).count() as f64;        Some(((up / recent.len() as f64) * 1000.0).round() / 10.0)    };    let mut tick: Vec<&'static str> = recent        .iter()        .rev()        .map(|c| if c.status_code == 200 { "up" } else { "down" })        .collect();    tick.truncate(30);    let latest_headers: HashMap<String, String> = recent        .first()        .and_then(|c| serde_json::from_str::<HashMap<String, String>>(&c.headers).ok())        .unwrap_or_default();    let lower: HashMap<String, String> = latest_headers        .into_iter()        .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))        .collect();    let is_https = row.url.starts_with("https://");    let invalid_cert = current_status == 526;    let has_mime_type = lower.contains_key("content-type");    let has_content_sniffing_protection = lower        .get("x-content-type-options")        .map(|v| v == "nosniff")        .unwrap_or(false);    let has_xss_protection = lower        .get("x-xss-protection")        .map(|v| v == "1; mode=block")        .unwrap_or(false);    let has_clickjack_protection = lower        .get("x-frame-options")        .map(|v| matches!(v.as_str(), "deny" | "sameorigin" | "allow-from"))        .unwrap_or(false);    let hides_server_version = !lower.contains_key("server")        && !lower.contains_key("x-server")        && !lower.contains_key("powered-by")        && !lower.contains_key("x-powered-by");    let hsts = lower        .get("strict-transport-security")        .cloned()        .unwrap_or_default();    let has_hsts = {        if hsts.is_empty() {            false        } else if let Some(re) = regex::Regex::new(r"max-age=(\d+)").ok() {            re.captures(&hsts)                .and_then(|c| c.get(1))                .and_then(|m| m.as_str().parse::<i64>().ok())                .map(|m| m >= 31_536_000)                .unwrap_or(false)        } else {            false        }    };    let has_hsts_preload = hsts.to_lowercase().contains("preload");    let has_security_issue = !is_https        || !has_mime_type        || !has_content_sniffing_protection        || !has_xss_protection        || !has_clickjack_protection        || !hides_server_version        || !has_hsts        || !has_hsts_preload;    let lighthouse_scores: Value = row        .lighthouse_scores        .as_ref()        .and_then(|s| serde_json::from_str(s).ok())        .unwrap_or(Value::Null);    let lighthouse_details: Value = row        .lighthouse_details        .as_ref()        .and_then(|s| serde_json::from_str(s).ok())        .unwrap_or(Value::Null);    let crawler_insights: Value = row        .crawler_insights        .as_ref()        .and_then(|s| serde_json::from_str(s).ok())        .unwrap_or(Value::Array(Vec::new()));    let avg_lighthouse_score: Option<i64> = lighthouse_scores.as_object().and_then(|m| {        let scores: Vec<i64> = m.values().filter_map(|v| v.as_i64()).collect();        if scores.is_empty() {            None        } else {            Some((scores.iter().sum::<i64>() as f64 / scores.len() as f64).round() as i64)        }    });    Ok(PropertyContext {        id: row.uuid().to_string(),        url: row.url.clone(),        name: row.name(),        is_public: row.is_public != 0,        is_protected: row.is_protected != 0,        current_status,        avg_response_time,        recent_uptime_pct,        recent_tick_stream: tick,        total_checks: total,        crawl_state: row.crawl_state.clone(),        crawler_insights,        last_crawl_success_at: ms_to_iso_opt(row.last_crawl_success_at),        last_crawl_error: row.last_crawl_error.clone(),        last_crawl_duration_ms: row.last_crawl_duration_ms,        last_crawl_pages_count: row.last_crawl_pages_count,        next_run_at_crawler: ms_to_iso_opt(row.next_run_at_crawler),        crawl_started_at: ms_to_iso_opt(row.crawl_started_at),        lighthouse_state: row.lighthouse_state.clone(),        lighthouse_scores,        lighthouse_details,        last_lighthouse_success_at: ms_to_iso_opt(row.last_lighthouse_success_at),        last_lighthouse_error: row.last_lighthouse_error.clone(),        last_lighthouse_duration_ms: row.last_lighthouse_duration_ms,        next_lighthouse_run_at: ms_to_iso_opt(row.next_lighthouse_run_at),        lighthouse_started_at: ms_to_iso_opt(row.lighthouse_started_at),        avg_lighthouse_score,        alert_state: row.alert_state.clone(),        created_at: models::ms_to_iso(row.created_at),        updated_at: models::ms_to_iso(row.updated_at),        is_https,        invalid_cert,        has_mime_type,        has_content_sniffing_protection,        has_xss_protection,        has_clickjack_protection,        hides_server_version,        has_hsts,        has_hsts_preload,        has_security_issue,    })}fn group_insights_by_type(insights: &Value) -> Vec<Value> {    use std::collections::BTreeMap;    let arr = match insights.as_array() {        Some(a) => a,        None => return Vec::new(),    };    let mut buckets: BTreeMap<String, Vec<Value>> = BTreeMap::new();    for item in arr {        let t = item            .get("type")            .and_then(|v| v.as_str())            .unwrap_or("other")            .to_string();        buckets.entry(t).or_default().push(item.clone());    }    // Sort each bucket so errors come before warnings before info, matching the    // old Django dictsort:"severity".    let sev_rank = |s: &str| -> u8 {        match s {            "error" => 0,            "warning" => 1,            _ => 2,        }    };    for items in buckets.values_mut() {        items.sort_by_key(|i| sev_rank(i.get("severity").and_then(|v| v.as_str()).unwrap_or("info")));    }    buckets        .into_iter()        .map(|(name, items)| json!({"type": name, "items": items}))        .collect()}fn crawl_progress(prop: &PropertyRow) -> f64 {    let pages = prop.last_crawl_pages_count.unwrap_or(0);    if pages <= 0 {        return 0.05; // show *some* movement once we start    }    let cap = crate::crawler::PAGE_CAP as f64;    ((pages as f64) / cap).min(0.9)}fn serialize_status(prop: &PropertyRow) -> Value {    let now = chrono::Utc::now().timestamp_millis();    let insights: Value = prop        .crawler_insights        .as_ref()        .and_then(|s| serde_json::from_str(s).ok())        .unwrap_or(Value::Array(Vec::new()));    let mut sev = serde_json::Map::new();    sev.insert("error".into(), 0.into());    sev.insert("warning".into(), 0.into());    sev.insert("info".into(), 0.into());    let mut total = 0i64;    if let Some(arr) = insights.as_array() {        for i in arr {            total += 1;            let s = i.get("severity").and_then(|v| v.as_str()).unwrap_or("info");            if let Some(n) = sev.get_mut(s).and_then(|v| v.as_i64()) {                sev.insert(s.into(), Value::from(n + 1));            }        }    }    let crawl_next = prop.next_run_at_crawler;    let lh_next = prop.next_lighthouse_run_at;    json!({        "crawler": {            "state": prop.crawl_state,            "started_at": ms_to_iso_opt(prop.crawl_started_at),            "last_attempt_at": ms_to_iso_opt(prop.last_run_at_crawler),            "last_success_at": ms_to_iso_opt(prop.last_crawl_success_at),            "last_error": prop.last_crawl_error,            "last_duration_ms": prop.last_crawl_duration_ms,            "pages_count": prop.last_crawl_pages_count,            "next_run_at": ms_to_iso_opt(crawl_next),            "is_overdue": crawl_next.map(|n| n <= now).unwrap_or(false),            "insights_total": total,            "insights_by_severity": Value::Object(sev),            "progress": if prop.crawl_state == "running" {                Value::from(crawl_progress(prop))            } else {                Value::Null            },        },        "lighthouse": {            "state": prop.lighthouse_state,            "started_at": ms_to_iso_opt(prop.lighthouse_started_at),            "last_attempt_at": ms_to_iso_opt(prop.last_lighthouse_run_at),            "last_success_at": ms_to_iso_opt(prop.last_lighthouse_success_at),            "last_error": prop.last_lighthouse_error,            "last_duration_ms": prop.last_lighthouse_duration_ms,            "next_run_at": ms_to_iso_opt(lh_next),            "is_overdue": lh_next.map(|n| n <= now).unwrap_or(false),            "scores": prop.lighthouse_scores                .as_ref()                .and_then(|s| serde_json::from_str::<Value>(s).ok())                .unwrap_or(Value::Null),        },        "server_time": chrono::Utc::now().to_rfc3339(),    })}pub async fn property_status(    State(state): State<AppState>,    cookies: Cookies,    AxumPath(id): AxumPath<Uuid>,) -> Response {    let row = match models::get_property(&state.pool, id).await {        Ok(Some(r)) => r,        _ => return not_found_json(),    };    let authed = is_authenticated(&cookies, &state);    if row.is_public == 0 && !authed {        return forbidden_json();    }    Json(serialize_status(&row)).into_response()}pub async fn property_recrawl(    State(state): State<AppState>,    cookies: Cookies,    AxumPath(id): AxumPath<Uuid>,) -> Response {    if !is_authenticated(&cookies, &state) {        return forbidden_json();    }    let row = match models::get_property(&state.pool, id).await {        Ok(Some(r)) => r,        _ => return not_found_json(),    };    if matches!(row.crawl_state.as_str(), "queued" | "running") {        return Json(json!({            "ok": false,            "reason": "already_running",            "crawler": serialize_status(&row).get("crawler"),            "lighthouse": serialize_status(&row).get("lighthouse"),            "server_time": chrono::Utc::now().to_rfc3339(),        }))        .into_response();    }    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query(        "UPDATE properties SET next_run_at_crawler = ?, last_crawl_error = NULL, updated_at = ? WHERE id = ?",    )    .bind(now)    .bind(now)    .bind(row.id.clone())    .execute(&state.pool)    .await;    let updated = models::get_property(&state.pool, id)        .await        .ok()        .flatten()        .unwrap_or(row);    let mut payload = serialize_status(&updated);    if let Some(obj) = payload.as_object_mut() {        obj.insert("ok".into(), Value::Bool(true));    }    Json(payload).into_response()}pub async fn property_rerun_lighthouse(    State(state): State<AppState>,    cookies: Cookies,    AxumPath(id): AxumPath<Uuid>,) -> Response {    if !is_authenticated(&cookies, &state) {        return forbidden_json();    }    let row = match models::get_property(&state.pool, id).await {        Ok(Some(r)) => r,        _ => return not_found_json(),    };    if matches!(row.lighthouse_state.as_str(), "queued" | "running") {        return Json(json!({            "ok": false,            "reason": "already_running",            "crawler": serialize_status(&row).get("crawler"),            "lighthouse": serialize_status(&row).get("lighthouse"),            "server_time": chrono::Utc::now().to_rfc3339(),        }))        .into_response();    }    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query(        "UPDATE properties SET next_lighthouse_run_at = ?, last_lighthouse_error = NULL, updated_at = ? WHERE id = ?",    )    .bind(now)    .bind(now)    .bind(row.id.clone())    .execute(&state.pool)    .await;    let updated = models::get_property(&state.pool, id)        .await        .ok()        .flatten()        .unwrap_or(row);    let mut payload = serialize_status(&updated);    if let Some(obj) = payload.as_object_mut() {        obj.insert("ok".into(), Value::Bool(true));    }    Json(payload).into_response()}
added src/routes/home.rs
@@ -0,0 +1,64 @@use axum::{    extract::State,    response::{IntoResponse, Redirect, Response},    routing::get,    Router,};use tower_cookies::Cookies;use crate::render::render;use crate::routes::auth::is_authenticated;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/", get(home))        .route("/changelog", get(changelog))}pub async fn home(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    if authed {        return Redirect::to("/properties").into_response();    }    let totals: (i64, i64, Option<i64>) = sqlx::query_as(        "SELECT \           (SELECT COUNT(*) FROM checks), \           (SELECT COUNT(*) FROM properties), \           (SELECT MIN(created_at) FROM checks)",    )    .fetch_one(&state.pool)    .await    .unwrap_or((0, 0, None));    let first = totals.2.and_then(|ms| {        chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)            .map(|d| d.format("%b %-d, %Y").to_string())    });    let extra = minijinja::context! {        page => minijinja::context! {            title => "Home",            description => "Self-hosted uptime monitoring with status pages.",        },        title => "Home",        description => "Self-hosted uptime monitoring with status pages.",        total_statuses => totals.0,        total_properties => totals.1,        first_status_created_at => first,    };    render(&state, "pages/home.html", "/", authed, extra)}pub async fn changelog(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    let extra = minijinja::context! {        page => minijinja::context! {            title => "Changelog",            description => "An ongoing changelog and upcoming list of features for Status.",        },        title => "Changelog",        description => "An ongoing changelog and upcoming list of features for Status.",    };    render(&state, "pages/changelog.html", "/changelog", authed, extra)}
added src/routes/mod.rs
@@ -0,0 +1,5 @@pub mod auth;pub mod dashboard;pub mod home;pub mod properties;pub mod seo;
added src/routes/properties.rs
@@ -0,0 +1,109 @@use axum::{    extract::{Form, Path as AxumPath, Query, State},    response::{IntoResponse, Json, Redirect, Response},    routing::{get, post},    Router,};use serde::Deserialize;use serde_json::{json, Value};use tower_cookies::Cookies;use uuid::Uuid;use crate::models;use crate::render::render;use crate::routes::auth::is_authenticated;use crate::routes::dashboard::build_property_context;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/properties", get(properties).post(properties_create))        .route("/properties/{id}/delete", post(property_delete))        .route("/properties/{id}/public", post(property_public_toggle))}#[derive(Debug, Deserialize)]pub struct ListQuery {    pub q: Option<String>,}#[derive(Debug, Deserialize)]pub struct CreateForm {    pub url: String,}pub async fn properties(    State(state): State<AppState>,    cookies: Cookies,    Query(query): Query<ListQuery>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/").into_response();    }    let search = query.q.as_deref().filter(|s| !s.is_empty());    let rows = models::list_properties(&state.pool, search)        .await        .unwrap_or_default();    let mut props: Vec<Value> = Vec::with_capacity(rows.len());    for row in &rows {        match build_property_context(&state, row).await {            Ok(ctx) => props.push(serde_json::to_value(&ctx).unwrap_or(Value::Null)),            Err(e) => tracing::warn!("[properties] property context: {e:#}"),        }    }    let extra = minijinja::context! {        page => minijinja::context! {            title => "Properties",            description => "Manage your properties.",        },        title => "Properties",        description => "Manage your properties.",        properties => props,        q => query.q,    };    render(&state, "properties/properties.html", "/properties", true, extra)}pub async fn properties_create(    State(state): State<AppState>,    cookies: Cookies,    Form(form): Form<CreateForm>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/").into_response();    }    let url = form.url.trim();    if url.is_empty() || !(url.starts_with("http://") || url.starts_with("https://")) {        return Redirect::to("/properties").into_response();    }    if let Err(e) = models::create_property(&state.pool, url).await {        tracing::warn!("[properties] create: {e:#}");    }    Redirect::to("/properties").into_response()}pub async fn property_delete(    State(state): State<AppState>,    cookies: Cookies,    AxumPath(id): AxumPath<Uuid>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/").into_response();    }    if let Err(e) = models::delete_property(&state.pool, id).await {        tracing::warn!("[properties] delete: {e:#}");    }    Redirect::to("/properties").into_response()}pub async fn property_public_toggle(    State(state): State<AppState>,    cookies: Cookies,    AxumPath(id): AxumPath<Uuid>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/").into_response();    }    let res = models::toggle_public(&state.pool, id).await.unwrap_or(false);    Json(json!({"success": true, "is_public": res})).into_response()}
added src/routes/seo.rs
@@ -0,0 +1,52 @@use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/favicon.ico", get(favicon))        .route("/robots.txt", get(robots))        .route("/sitemap.xml", get(sitemap))}async fn favicon() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34" fill="none" stroke="#6b9e78" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"/><circle cx="30" cy="14" r="3.5" fill="#c9a84c"/></svg>"##;    (StatusCode::OK, h, svg).into_response()}async fn robots() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    (StatusCode::OK, h, "User-agent: *\nAllow: /\n").into_response()}async fn sitemap(State(state): State<AppState>) -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    let base = if state.config.base_url.is_empty() {        "/".to_string()    } else {        format!("{}/", state.config.base_url.trim_end_matches('/'))    };    let now = chrono::Utc::now().format("%Y-%m-%d");    let body = format!(        r##"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">  <url><loc>{base}</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}changelog</loc><lastmod>{now}</lastmod></url></urlset>"##    );    (StatusCode::OK, h, body).into_response()}
added src/scheduler.rs
@@ -0,0 +1,359 @@use crate::checker;use crate::crawler;use crate::db::now_ms;use crate::lighthouse;use crate::models::PropertyRow;use crate::Config;use sqlx::SqlitePool;use std::sync::Arc;use std::time::Duration;use tokio::sync::Semaphore;use tokio::task::JoinHandle;const CYCLE_SECS: u64 = 30;// Two pools so slow lighthouse/crawler work can't starve quick HTTP pings.const FAST_PERMITS: usize = 2;const SLOW_PERMITS: usize = 2;const CLEANUP_INTERVAL_SECS: i64 = 86_400;const CHECK_RETENTION_DAYS: i64 = 3;const CRAWL_WEDGE_SECS: i64 = 900;const LH_WEDGE_SECS: i64 = 300;const CRAWL_INTERVAL_DAYS: i64 = 7;const LH_INTERVAL_DAYS: i64 = 1;/// Wipe queued/running rows on startup. Anything in those states is/// leftover from a prior crash; tasks didn't survive the restart so the/// rows must not block new work.pub async fn reset_states_on_boot(pool: &SqlitePool) -> anyhow::Result<()> {    sqlx::query("UPDATE properties SET crawl_state = 'idle' WHERE crawl_state IN ('queued', 'running')")        .execute(pool)        .await?;    sqlx::query(        "UPDATE properties SET lighthouse_state = 'idle' WHERE lighthouse_state IN ('queued', 'running')",    )    .execute(pool)    .await?;    Ok(())}pub fn spawn(pool: SqlitePool, config: Arc<Config>) -> JoinHandle<()> {    let fast = Arc::new(Semaphore::new(FAST_PERMITS));    let slow = Arc::new(Semaphore::new(SLOW_PERMITS));    tokio::spawn(async move {        let mut last_cleanup: Option<i64> = None;        loop {            if let Err(e) = enqueue_status(&pool, &config, &fast).await {                tracing::warn!("[scheduler] enqueue_status: {e}");            }            if let Err(e) = enqueue_lighthouse(&pool, &config, &slow).await {                tracing::warn!("[scheduler] enqueue_lighthouse: {e}");            }            if let Err(e) = enqueue_crawler(&pool, &config, &slow).await {                tracing::warn!("[scheduler] enqueue_crawler: {e}");            }            if let Err(e) = reset_wedged_states(&pool).await {                tracing::warn!("[scheduler] reset_wedged_states: {e}");            }            if let Err(e) = maybe_cleanup(&pool, &mut last_cleanup).await {                tracing::warn!("[scheduler] cleanup: {e}");            }            tokio::time::sleep(Duration::from_secs(CYCLE_SECS)).await;        }    })}async fn reset_wedged_states(pool: &SqlitePool) -> sqlx::Result<()> {    let now = now_ms();    let crawl_cutoff = now - CRAWL_WEDGE_SECS * 1000;    let lh_cutoff = now - LH_WEDGE_SECS * 1000;    sqlx::query(        "UPDATE properties SET crawl_state = 'idle', last_crawl_error = 'Crawl timed out or was interrupted' \         WHERE crawl_state = 'running' AND crawl_started_at IS NOT NULL AND crawl_started_at < ?",    )    .bind(crawl_cutoff)    .execute(pool)    .await?;    sqlx::query(        "UPDATE properties SET lighthouse_state = 'idle', last_lighthouse_error = 'Lighthouse run timed out or was interrupted' \         WHERE lighthouse_state = 'running' AND lighthouse_started_at IS NOT NULL AND lighthouse_started_at < ?",    )    .bind(lh_cutoff)    .execute(pool)    .await?;    Ok(())}async fn maybe_cleanup(pool: &SqlitePool, last: &mut Option<i64>) -> sqlx::Result<()> {    let now = now_ms();    if let Some(t) = *last {        if (now - t) / 1000 < CLEANUP_INTERVAL_SECS {            return Ok(());        }    }    let cutoff = now - CHECK_RETENTION_DAYS * 24 * 3600 * 1000;    let res = sqlx::query("DELETE FROM checks WHERE created_at < ?")        .bind(cutoff)        .execute(pool)        .await?;    tracing::info!("[scheduler] cleaned {} checks older than {}d", res.rows_affected(), CHECK_RETENTION_DAYS);    *last = Some(now);    Ok(())}async fn enqueue_status(    pool: &SqlitePool,    config: &Arc<Config>,    sem: &Arc<Semaphore>,) -> sqlx::Result<()> {    let now = now_ms();    let due: Vec<PropertyRow> = sqlx::query_as(        "SELECT * FROM properties \         WHERE last_run_at IS NULL OR next_run_at IS NULL OR next_run_at <= ?",    )    .bind(now)    .fetch_all(pool)    .await?;    for prop in due {        let next = checker::next_3min_boundary();        sqlx::query(            "UPDATE properties SET next_run_at = ?, last_run_at = ?, updated_at = ? WHERE id = ?",        )        .bind(next)        .bind(now)        .bind(now)        .bind(prop.id.clone())        .execute(pool)        .await?;        let pool = pool.clone();        let sem = sem.clone();        let config = config.clone();        tokio::spawn(async move {            let _permit = match sem.acquire_owned().await {                Ok(p) => p,                Err(_) => return,            };            tracing::info!("[scheduler] checking status {}", prop.url);            if let Err(e) = checker::process_check(&pool, &config, &prop).await {                tracing::warn!("[scheduler] status check failed for {}: {e:#}", prop.url);            }        });    }    Ok(())}async fn enqueue_lighthouse(    pool: &SqlitePool,    config: &Arc<Config>,    sem: &Arc<Semaphore>,) -> sqlx::Result<()> {    let now = now_ms();    let due: Vec<PropertyRow> = sqlx::query_as(        "SELECT * FROM properties \         WHERE (last_lighthouse_run_at IS NULL OR next_lighthouse_run_at IS NULL OR next_lighthouse_run_at <= ?) \         AND lighthouse_state NOT IN ('queued', 'running')",    )    .bind(now)    .fetch_all(pool)    .await?;    for prop in due {        let next = now + LH_INTERVAL_DAYS * 24 * 3600 * 1000;        sqlx::query(            "UPDATE properties SET next_lighthouse_run_at = ?, last_lighthouse_run_at = ?, lighthouse_state = 'queued', updated_at = ? \             WHERE id = ?",        )        .bind(next)        .bind(now)        .bind(now)        .bind(prop.id.clone())        .execute(pool)        .await?;        let pool = pool.clone();        let sem = sem.clone();        let config = config.clone();        tokio::spawn(async move {            let _permit = match sem.acquire_owned().await {                Ok(p) => p,                Err(_) => return,            };            tracing::info!("[scheduler] lighthouse {}", prop.url);            run_lighthouse_for(&pool, &config, &prop).await;        });    }    Ok(())}async fn run_lighthouse_for(pool: &SqlitePool, config: &Arc<Config>, prop: &PropertyRow) {    let now = now_ms();    let _ = sqlx::query(        "UPDATE properties SET lighthouse_state = 'running', lighthouse_started_at = ?, updated_at = ? WHERE id = ?",    )    .bind(now)    .bind(now)    .bind(prop.id.clone())    .execute(pool)    .await;    let started = std::time::Instant::now();    match lighthouse::fetch(&config.root, &prop.url).await {        Ok(results) => {            match lighthouse::parse_scores(&results) {                Ok(scores) => {                    let details = lighthouse::parse_details(&results);                    let scores_json = serde_json::to_string(&scores).unwrap_or_else(|_| "{}".into());                    let details_json = details                        .as_ref()                        .and_then(|d| serde_json::to_string(d).ok())                        .unwrap_or_else(|| "null".into());                    let dur = started.elapsed().as_millis() as i64;                    let _ = sqlx::query(                        "UPDATE properties SET \                           lighthouse_scores = ?, lighthouse_details = ?, \                           last_lighthouse_success_at = ?, last_lighthouse_error = NULL, \                           last_lighthouse_duration_ms = ?, lighthouse_state = 'idle', updated_at = ? \                         WHERE id = ?",                    )                    .bind(scores_json)                    .bind(details_json)                    .bind(now_ms())                    .bind(dur)                    .bind(now_ms())                    .bind(prop.id.clone())                    .execute(pool)                    .await;                }                Err(e) => store_lh_error(pool, prop, &format!("{e}"), started).await,            }        }        Err(e) => store_lh_error(pool, prop, &format!("{e}"), started).await,    }}async fn store_lh_error(    pool: &SqlitePool,    prop: &PropertyRow,    msg: &str,    started: std::time::Instant,) {    tracing::warn!("[scheduler] lighthouse failed for {}: {msg}", prop.url);    let dur = started.elapsed().as_millis() as i64;    let _ = sqlx::query(        "UPDATE properties SET lighthouse_state = 'idle', last_lighthouse_error = ?, last_lighthouse_duration_ms = ?, updated_at = ? \         WHERE id = ?",    )    .bind(msg)    .bind(dur)    .bind(now_ms())    .bind(prop.id.clone())    .execute(pool)    .await;}async fn enqueue_crawler(    pool: &SqlitePool,    _config: &Arc<Config>,    sem: &Arc<Semaphore>,) -> sqlx::Result<()> {    let now = now_ms();    let due: Vec<PropertyRow> = sqlx::query_as(        "SELECT * FROM properties \         WHERE (last_run_at_crawler IS NULL OR next_run_at_crawler IS NULL OR next_run_at_crawler <= ?) \         AND crawl_state NOT IN ('queued', 'running')",    )    .bind(now)    .fetch_all(pool)    .await?;    for prop in due {        let next = now + CRAWL_INTERVAL_DAYS * 24 * 3600 * 1000;        sqlx::query(            "UPDATE properties SET next_run_at_crawler = ?, last_run_at_crawler = ?, crawl_state = 'queued', updated_at = ? \             WHERE id = ?",        )        .bind(next)        .bind(now)        .bind(now)        .bind(prop.id.clone())        .execute(pool)        .await?;        let pool = pool.clone();        let sem = sem.clone();        tokio::spawn(async move {            let _permit = match sem.acquire_owned().await {                Ok(p) => p,                Err(_) => return,            };            tracing::info!("[scheduler] crawler {}", prop.url);            run_crawler_for(&pool, &prop).await;        });    }    Ok(())}async fn run_crawler_for(pool: &SqlitePool, prop: &PropertyRow) {    let now = now_ms();    let _ = sqlx::query(        "UPDATE properties SET crawl_state = 'running', crawl_started_at = ?, last_crawl_pages_count = 0, updated_at = ? WHERE id = ?",    )    .bind(now)    .bind(now)    .bind(prop.id.clone())    .execute(pool)    .await;    let pool_for_progress = pool.clone();    let id_for_progress = prop.id.clone();    let progress_cb = move |pages: usize| {        let pool = pool_for_progress.clone();        let id = id_for_progress.clone();        tokio::spawn(async move {            let _ = sqlx::query(                "UPDATE properties SET last_crawl_pages_count = ? WHERE id = ?",            )            .bind(pages as i64)            .bind(id)            .execute(&pool)            .await;        });    };    let started = std::time::Instant::now();    let outcome = crawler::run_seo_spider(&prop.url, progress_cb).await;    let duration_ms = started.elapsed().as_millis() as i64;    match outcome {        Ok(insights) => {            let json = serde_json::to_string(&insights).unwrap_or_else(|_| "[]".into());            let _ = sqlx::query(                "UPDATE properties SET \                   crawler_insights = ?, crawl_state = 'idle', \                   last_crawl_success_at = ?, last_crawl_error = NULL, \                   last_crawl_duration_ms = ?, updated_at = ? \                 WHERE id = ?",            )            .bind(json)            .bind(now_ms())            .bind(duration_ms)            .bind(now_ms())            .bind(prop.id.clone())            .execute(pool)            .await;        }        Err(e) => {            tracing::warn!("[scheduler] crawl failed for {}: {e:#}", prop.url);            let _ = sqlx::query(                "UPDATE properties SET crawl_state = 'idle', last_crawl_error = ?, last_crawl_duration_ms = ?, updated_at = ? WHERE id = ?",            )            .bind(format!("{e:#}"))            .bind(duration_ms)            .bind(now_ms())            .bind(prop.id.clone())            .execute(pool)            .await;        }    }}
added src/templates.rs
@@ -0,0 +1,311 @@use minijinja::value::{Kwargs, Value};use minijinja::{path_loader, AutoEscape, Environment, Error, ErrorKind, Output, State};use serde::Serialize;use serde_json::Value as JsonValue;use std::path::Path;/// Jinja2-faithful HTML formatter. Does NOT escape `/`, so vite asset URLs/// like `/static/base-abc123.js` come through clean instead of `&#x2f;...`.fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {    if value.is_safe() {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    let auto_escape = match state.auto_escape() {        AutoEscape::Html => true,        AutoEscape::None => false,        _ => return minijinja::escape_formatter(out, state, value),    };    if !auto_escape {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    if let Some(s) = value.as_str() {        write_jinja2_html(out, s).map_err(Error::from)?;    } else if value.is_undefined() || value.is_none() {        // emit nothing    } else {        let stringified = value.to_string();        write_jinja2_html(out, &stringified).map_err(Error::from)?;    }    Ok(())}fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {    let mut last = 0;    for (i, b) in s.bytes().enumerate() {        let escape = match b {            b'&' => "&amp;",            b'<' => "&lt;",            b'>' => "&gt;",            b'"' => "&#34;",            b'\'' => "&#39;",            _ => continue,        };        if last < i {            out.write_str(&s[last..i])?;        }        out.write_str(escape)?;        last = i + 1;    }    if last < s.len() {        out.write_str(&s[last..])?;    }    Ok(())}#[derive(Debug, Clone, Serialize)]pub struct RequestCtx {    pub url: String,    pub url_root: String,    pub base_url: String,    pub path: String,}#[derive(Debug, Clone, Serialize, Default)]pub struct UserCtx {    pub is_authenticated: bool,}fn read_manifest(path: &Path) -> JsonValue {    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());    serde_json::from_str(&text).unwrap_or(JsonValue::Null)}fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {    if let Some(chunk) = manifest.get(entry) {        if kind == "css" {            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {                    return format!("/static/{first}");                }            }        }        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {            return format!("/static/{file}");        }    }    format!("/static/{entry}")}pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {    let mut env = Environment::new();    env.set_loader(path_loader(templates_dir));    env.set_formatter(jinja2_html_formatter);    #[cfg(debug_assertions)]    {        let path = manifest_path.to_path_buf();        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                let manifest = read_manifest(&path);                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    #[cfg(not(debug_assertions))]    {        let manifest = read_manifest(manifest_path);        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    env.add_function("url_for", url_for);    env.add_filter("naturaltime", naturaltime_filter);    env.add_filter("urlencode", urlencode_filter);    env.add_filter("typst_str", typst_str_filter);    env.add_filter("typst_md", typst_md_filter);    env.add_filter(        "url_path",        |v: Value| -> Result<String, Error> {            let s = v.as_str().map(|s| s.to_string()).unwrap_or_else(|| v.to_string());            // Returns the path+query+fragment portion of a URL. Mirrors the            // Django filter used by the old crawler-insights table.            let mut parts = s.splitn(4, '/');            let _scheme = parts.next();            let _empty = parts.next();            let _host = parts.next();            Ok(format!("/{}", parts.next().unwrap_or("")))        },    );    env.add_filter(        "format_ms_savings",        |v: Value| -> Result<String, Error> {            // Mirrors the Django filter: render an ms value as "1.2 s" or            // "420 ms". Empty string for zero / null so the template can show a            // "—" placeholder.            let ms = v                .as_i64()                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))                .unwrap_or(0);            if ms <= 0 {                Ok(String::new())            } else if ms >= 1000 {                Ok(format!("{:.1} s", ms as f64 / 1000.0))            } else {                Ok(format!("{ms} ms"))            }        },    );    env.add_filter(        "intcomma",        |v: Value| -> Result<String, Error> {            let n = v.as_i64().unwrap_or(0);            let s = n.abs().to_string();            let mut out = String::new();            for (i, ch) in s.chars().rev().enumerate() {                if i > 0 && i % 3 == 0 {                    out.insert(0, ',');                }                out.insert(0, ch);            }            if n < 0 {                out.insert(0, '-');            }            Ok(out)        },    );    env}fn urlencode_filter(value: Value) -> Result<String, Error> {    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());    Ok(urlencoding::encode(&s).into_owned())}/// Escape a value for inclusion inside a `"..."` Typst string literal.fn typst_str_filter(value: Value) -> Result<String, Error> {    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '\\' => out.push_str("\\\\"),            '"' => out.push_str("\\\""),            '\n' => out.push_str("\\n"),            '\r' => out.push_str("\\r"),            '\t' => out.push_str("\\t"),            _ => out.push(c),        }    }    Ok(out)}/// Escape a value for inclusion inside a Typst content block `[...]`./// Backslash-escapes the markup specials so a label like `*foo*` renders as/// literal text, not bolded.fn typst_md_filter(value: Value) -> Result<String, Error> {    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '\\' | '[' | ']' | '*' | '_' | '`' | '#' | '$' | '<' | '@' | '~' => {                out.push('\\');                out.push(c);            }            _ => out.push(c),        }    }    Ok(out)}/// Subset of Django's url_for/url tags. We only emit URL strings.fn url_for(_state: &State, endpoint: String, kwargs: Kwargs) -> Result<String, Error> {    let take_str = |k: &str| -> Result<Option<String>, Error> {        let v: Option<Value> = kwargs.get(k).ok();        match v {            None => Ok(None),            Some(val) => {                if val.is_undefined() || val.is_none() {                    Ok(None)                } else {                    Ok(Some(val.to_string()))                }            }        }    };    let path = match endpoint.as_str() {        "home" | "index" => "/".to_string(),        "login" => "/login".to_string(),        "logout" => "/logout".to_string(),        "properties" => "/properties".to_string(),        "property" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/{id}")        }        "property_delete" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/delete")        }        "property_public" | "adjust_is_public_property" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/public")        }        "property_status" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/status")        }        "property_recrawl" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/recrawl")        }        "property_rerun_lighthouse" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/rerun-lighthouse")        }        "changelog" => "/changelog".to_string(),        "favicon" => "/favicon.ico".to_string(),        "robots" => "/robots.txt".to_string(),        "sitemap" => "/sitemap.xml".to_string(),        "static" => {            let filename = take_str("filename")?.unwrap_or_default();            format!("/static/{filename}")        }        other => {            return Err(Error::new(                ErrorKind::InvalidOperation,                format!("unknown route in url_for: {other}"),            ));        }    };    kwargs.assert_all_used()?;    Ok(path)}/// Mimics Django's humanize "naturaltime" for createdAt timestamps.fn naturaltime_filter(value: Value) -> Result<String, Error> {    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());    let dt = chrono::DateTime::parse_from_rfc3339(&s)        .map(|d| d.with_timezone(&chrono::Utc))        .ok();    let Some(dt) = dt else { return Ok(s) };    let now = chrono::Utc::now();    let diff = now.signed_duration_since(dt);    let secs = diff.num_seconds();    Ok(if secs < 60 {        "just now".to_string()    } else if secs < 3600 {        let m = secs / 60;        format!("{m} minute{} ago", if m == 1 { "" } else { "s" })    } else if secs < 86_400 {        let h = secs / 3600;        format!("{h} hour{} ago", if h == 1 { "" } else { "s" })    } else if secs < 86_400 * 30 {        let d = secs / 86_400;        format!("{d} day{} ago", if d == 1 { "" } else { "s" })    } else if secs < 86_400 * 365 {        let m = secs / (86_400 * 30);        format!("{m} month{} ago", if m == 1 { "" } else { "s" })    } else {        let y = secs / (86_400 * 365);        format!("{y} year{} ago", if y == 1 { "" } else { "s" })    })}
deleted status/__init__.py
deleted status/asgi.py
@@ -1,16 +0,0 @@"""ASGI config for status project.It exposes the ASGI callable as a module-level variable named ``application``.For more information on this file, seehttps://docs.djangoproject.com/en/4.0/howto/deployment/asgi/"""import osfrom django.core.asgi import get_asgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'status.settings.development')application = get_asgi_application()
deleted status/chromium.py
@@ -1,176 +0,0 @@"""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 status/context_processors.py
@@ -1,15 +0,0 @@from django.conf import settingsdef canonical(request):    """    Gets the canonical URL for the current request.    """    return {'canonical': request.build_absolute_uri(request.path)}def base_url(request):    """    Provides the BASE_URL from settings.    """    return {'BASE_URL': settings.BASE_URL}
deleted status/lighthouse.py
@@ -1,163 +0,0 @@"""A wrapper around the lighthouse node CLI.Raises LighthouseError with a descriptive message on failure so callers canlog/persist the reason instead of silently dropping the result."""import jsonimport loggingimport shutilimport subprocessfrom django.conf import settingslogger = logging.getLogger(__name__)# Lighthouse's chrome-launcher searches for a browser on its own; we only# pin CHROME_PATH when we can resolve a known name, for determinism in# production (Alpine ships `chromium`). If nothing is found, fall through# and let chrome-launcher do its own lookup.CHROMIUM_BINARY = (    shutil.which("chromium")    or shutil.which("chromium-browser")    or shutil.which("google-chrome"))CHROME_FLAGS = "--headless --no-sandbox --disable-dev-shm-usage --disable-gpu"# Lighthouse itself can take 60-90s on a slow site; the outer timeout is a# backstop against Chromium hangs that would otherwise wedge the scheduler.SUBPROCESS_TIMEOUT_SECONDS = 180class LighthouseError(Exception):    passdef fetch_lighthouse_results(url):    command = [        f"{settings.BASE_DIR}/node_modules/.bin/lighthouse",        url,        f"--chrome-flags={CHROME_FLAGS}",        "--output=json",        "--output-path=stdout",        "--quiet",    ]    env = {"PATH": "/usr/bin:/bin:/usr/local/bin"}    if CHROMIUM_BINARY:        env["CHROME_PATH"] = CHROMIUM_BINARY    try:        process = subprocess.run(            command,            check=True,            stdout=subprocess.PIPE,            stderr=subprocess.PIPE,            timeout=SUBPROCESS_TIMEOUT_SECONDS,            env=env,        )    except subprocess.TimeoutExpired:        raise LighthouseError(            f"lighthouse timed out after {SUBPROCESS_TIMEOUT_SECONDS}s"        )    except subprocess.CalledProcessError as e:        stderr = (e.stderr or b"").decode("utf-8", errors="replace").strip()        raise LighthouseError(f"lighthouse exited {e.returncode}: {stderr[-500:]}")    except FileNotFoundError as e:        raise LighthouseError(f"lighthouse binary missing: {e}")    try:        return json.loads(process.stdout)    except json.JSONDecodeError as e:        raise LighthouseError(f"could not parse lighthouse output: {e}")def parse_lighthouse_results(results):    try:        scores = {            "Performance": results["categories"]["performance"]["score"],            "Accessibility": results["categories"]["accessibility"]["score"],            "Best practices": results["categories"]["best-practices"]["score"],            "SEO": results["categories"]["seo"]["score"],        }    except KeyError as e:        raise LighthouseError(f"missing category in lighthouse output: {e}")    if any(v is None for v in scores.values()):        missing = [k for k, v in scores.items() if v is None]        raise LighthouseError(f"null score(s) returned by lighthouse: {missing}")    return {k: round(v * 100) for k, v in scores.items()}def parse_performance_details(results):    """    Extract the weighted metrics and top opportunities behind the Performance    score. Returns None if the category is missing — callers should treat that    as "no breakdown available" rather than an error.    """    try:        category = results["categories"]["performance"]        audits = results["audits"]    except KeyError:        return None    metrics = []    opportunities = []    for ref in category.get("auditRefs", []):        audit = audits.get(ref.get("id"))        if not audit:            continue        group = ref.get("group")        score = audit.get("score")        weight = ref.get("weight", 0)        if group == "metrics" and weight > 0:            metrics.append(                {                    "id": audit.get("id"),                    "acronym": ref.get("acronym") or audit.get("id"),                    "title": audit.get("title"),                    "display_value": audit.get("displayValue"),                    "score": score,                    "weight": weight,                }            )            continue        # Opportunities/diagnostics: skip passing, manual, and not-applicable        # audits — we only want actionable findings.        mode = audit.get("scoreDisplayMode")        if mode in ("manual", "notApplicable", "informative"):            continue        if score is None or score >= 0.9:            continue        savings_ms = 0        details = audit.get("details") or {}        if isinstance(details, dict):            savings_ms = details.get("overallSavingsMs") or 0        opportunities.append(            {                "id": audit.get("id"),                "title": audit.get("title"),                "display_value": audit.get("displayValue"),                "score": score,                "savings_ms": savings_ms,                "weight": weight,            }        )    # Sort metrics by weight desc so the most impactful ones lead.    metrics.sort(key=lambda m: m["weight"], reverse=True)    # Sort opportunities by estimated savings, then by how badly they failed.    opportunities.sort(key=lambda o: (o["savings_ms"], -o["score"]), reverse=True)    return {        "metrics": metrics,        "opportunities": opportunities[:10],    }
deleted status/mailer.py
@@ -1,53 +0,0 @@import loggingimport smtplibfrom concurrent.futures import ThreadPoolExecutorimport dns.resolverfrom django.core.mail.backends.base import BaseEmailBackendlog = logging.getLogger(__name__)_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="mailer")def _deliver(message):    try:        by_domain = {}        for rcpt in message.to + message.cc + message.bcc:            by_domain.setdefault(rcpt.rsplit("@", 1)[1], []).append(rcpt)        payload = message.message().as_bytes()        sender = message.from_email        for domain, rcpts in by_domain.items():            mxs = sorted(                dns.resolver.resolve(domain, "MX"),                key=lambda r: r.preference,            )            for mx in mxs:                host = str(mx.exchange).rstrip(".")                try:                    with smtplib.SMTP(                        host, 25, local_hostname="bythewood.me", timeout=30                    ) as smtp:                        smtp.ehlo()                        try:                            smtp.starttls()                            smtp.ehlo()                        except smtplib.SMTPNotSupportedError:                            pass                        smtp.sendmail(sender, rcpts, payload)                    break                except Exception as exc:                    log.warning("MX %s failed for %s: %s", host, domain, exc)            else:                log.error("all MX hosts failed for %s", domain)    except Exception:        log.exception("mail delivery failed")class DirectMXBackend(BaseEmailBackend):    def send_messages(self, email_messages):        for message in email_messages:            _POOL.submit(_deliver, message)        return len(email_messages)
deleted status/settings/__init__.py
@@ -1,150 +0,0 @@import osfrom pathlib import Pathfrom django.contrib.messages import constants as messages# Build paths inside the project like this: BASE_DIR / 'subdir'.BASE_DIR = Path(__file__).resolve().parent.parent.parent# Application definitionINSTALLED_APPS = [    'django.contrib.admin',    'django.contrib.auth',    'django.contrib.contenttypes',    'django.contrib.sessions',    'django.contrib.messages',    'django.contrib.staticfiles',    'accounts',    'properties',    'pages',]MIDDLEWARE = [    'django.middleware.security.SecurityMiddleware',    'whitenoise.middleware.WhiteNoiseMiddleware',    'django.contrib.sessions.middleware.SessionMiddleware',    'django.middleware.common.CommonMiddleware',    'django.middleware.csrf.CsrfViewMiddleware',    'django.contrib.auth.middleware.AuthenticationMiddleware',    'django.contrib.messages.middleware.MessageMiddleware',    'django.middleware.clickjacking.XFrameOptionsMiddleware',]ROOT_URLCONF = 'status.urls'TEMPLATES = [    {        'BACKEND': 'django.template.backends.django.DjangoTemplates',        'DIRS': [BASE_DIR / 'status/templates'],        'APP_DIRS': True,        'OPTIONS': {            'context_processors': [                'django.template.context_processors.debug',                'django.template.context_processors.request',                'django.contrib.auth.context_processors.auth',                'django.contrib.messages.context_processors.messages',                'status.context_processors.canonical',                'status.context_processors.base_url',            ],        },    },]WSGI_APPLICATION = 'status.wsgi.application'# Messages# https://docs.djangoproject.com/en/3.0/ref/settings/#messagesMESSAGE_TAGS = {    messages.DEBUG: "alert-info",    messages.INFO: "alert-info",    messages.SUCCESS: "alert-success",    messages.WARNING: "alert-warning",    messages.ERROR: "alert-danger",}# Internationalization# https://docs.djangoproject.com/en/4.0/topics/i18n/LANGUAGE_CODE = 'en-us'TIME_ZONE = 'UTC'USE_I18N = TrueUSE_TZ = TrueUSE_THOUSAND_SEPARATOR = True# Static files (CSS, JavaScript, Images)# https://docs.djangoproject.com/en/4.0/howto/static-files/STATIC_URL = 'static/'STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"STATICFILES_DIRS = (BASE_DIR / "status/static",)STATIC_ROOT = BASE_DIR / "static"# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-rootMEDIA_URL = 'media/'MEDIA_ROOT = BASE_DIR / "media"# Database# https://docs.djangoproject.com/en/4.0/ref/settings/#databasesDATABASES = {    'default': {        'ENGINE': 'django.db.backends.sqlite3',        'NAME': BASE_DIR / 'db.sqlite3',        'OPTIONS': {            # WAL lets readers run concurrently with a writer; the scheduler            # has several worker threads, so the default rollback journal            # yields frequent "database is locked" errors. A 30s busy timeout            # gives contending writers a chance to serialize rather than            # fail, which previously stranded alert state mid-transition.            'timeout': 30,            'transaction_mode': 'IMMEDIATE',            # mmap_size is intentionally omitted: gunicorn workers and the            # scheduler each open their own connection, and SQLite's mmap is            # documented as unsafe for multi-process writers — it amplified            # an unrelated WAL inconsistency into full database corruption            # on 2026-04-19.            'init_command': (                'PRAGMA journal_mode=WAL;'                'PRAGMA synchronous=NORMAL;'                'PRAGMA foreign_keys=ON;'                'PRAGMA temp_store=MEMORY;'                'PRAGMA journal_size_limit=67108864;'                'PRAGMA cache_size=-20000;'            ),        },    }}# Default primary key field type# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-fieldDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'# Auth# https://docs.djangoproject.com/en/3.0/ref/settings/#authAUTH_USER_MODEL = "accounts.User"LOGIN_REDIRECT_URL = "properties"# Email# https://docs.djangoproject.com/en/4.0/topics/email/#console-backendEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
deleted status/settings/development.py
@@ -1,39 +0,0 @@from . import *  # noqa# Custom settingsBASE_URL = "http://localhost:8000"# Core settings# https://docs.djangoproject.com/en/4.0/ref/settings/#core-settingsSECRET_KEY = 'django-insecure-kbp9@ye$bf)$^l5-6q_4&2*ghbh29s*u)s2fy05xzj6gorvv!t'# Debuggging# https://docs.djangoproject.com/en/4.0/ref/settings/#debuggingDEBUG = True# Security# https://docs.djangoproject.com/en/4.0/ref/settings/#securityCSRF_TRUSTED_ORIGINS = [    'http://localhost:3000',    'http://localhost:8000',]# Email# https://docs.djangoproject.com/en/4.0/topics/email/#console-backendEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-rootMEDIA_URL = BASE_URL + '/media/'
deleted status/settings/production.py
@@ -1,80 +0,0 @@import osfrom . import *  # noqa# Custom settingsBASE_URL = os.environ.get("DJANGO_BASE_URL")# Core settings# https://docs.djangoproject.com/en/4.0/ref/settings/#core-settingsALLOWED_HOSTS = [os.environ.get("DJANGO_BASE_URL").split("//")[1]]# Debuggging# https://docs.djangoproject.com/en/4.0/ref/settings/#debuggingDEBUG = False# HTTP# https://docs.djangoproject.com/en/4.0/ref/settings/#httpSECURE_BROWSER_XSS_FILTER = TrueSECURE_HSTS_INCLUDE_SUBDOMAINS = TrueSECURE_HSTS_PRELOAD = TrueSECURE_HSTS_SECONDS = 31536000SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")SECURE_SSL_REDIRECT = TrueUSE_X_FORWARDED_HOST = True# Security# https://docs.djangoproject.com/en/4.0/ref/settings/#securityCSRF_COOKIE_SECURE = TrueCSRF_TRUSTED_ORIGINS = [os.environ.get("DJANGO_BASE_URL")]SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")SESSION_COOKIE_SECURE = True# Database# https://docs.djangoproject.com/en/4.0/ref/settings/#databasesDATABASES["default"]["NAME"] = "/data/db/db.sqlite3"# Email# https://docs.djangoproject.com/en/4.0/topics/email/#email-backendsEMAIL_BACKEND = "status.mailer.DirectMXBackend"DEFAULT_FROM_EMAIL = "noreply@bythewood.me"SERVER_EMAIL = "noreply@bythewood.me"# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-rootMEDIA_URL = BASE_URL + "/media/"MEDIA_ROOT = "/data/media"# Logging# https://docs.djangoproject.com/en/4.0/topics/logging/LOGGING = {    "version": 1,    "disable_existing_loggers": False,    "handlers": {        "console": {            "class": "logging.StreamHandler",        },    },    "root": {        "handlers": ["console"],        "level": "WARNING",    },}
deleted status/templates/403.html
@@ -1,25 +0,0 @@{% extends 'base.html' %}{% load static %}{% block title %}403{% endblock %}{% block description %}You are not allowed to access this page.{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container py-5">  <div class="row">    <div class="col-12 col-lg-8 offset-lg-2 text-center py-5">      <div class="section-label mb-3" style="display:inline-flex;">access denied</div>      <div class="display-1 fw-bolder text-white mb-3" style="font-size: clamp(5rem, 16vw, 10rem); letter-spacing: -0.04em; text-shadow: 0 0 30px rgba(239, 106, 91, 0.3);">        <span style="color: #ef6a5b;">4</span><span style="color: #e6b84c;">0</span><span style="color: #ef6a5b;">3</span>      </div>      <p class="text-muted mb-4">Credentials lack the clearance required for this endpoint.</p>      <a href="/" class="btn btn-primary">← Back to base</a>    </div>  </div></div>{% endblock %}
deleted status/templates/404.html
@@ -1,25 +0,0 @@{% extends 'base.html' %}{% load static %}{% block title %}404{% endblock %}{% block description %}That means the page you are looking for doesn't exist.{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container py-5">  <div class="row">    <div class="col-12 col-lg-8 offset-lg-2 text-center py-5">      <div class="section-label mb-3" style="display:inline-flex;">signal lost</div>      <div class="display-1 fw-bolder text-white mb-3" style="font-size: clamp(5rem, 16vw, 10rem); letter-spacing: -0.04em; text-shadow: 0 0 30px rgba(239, 106, 91, 0.3);">        <span style="color: #ef6a5b;">4</span><span style="color: #58d4e6;">0</span><span style="color: #ef6a5b;">4</span>      </div>      <p class="text-muted mb-4">The endpoint you pinged does not resolve. No bytes returned.</p>      <a href="/" class="btn btn-primary">← Back to base</a>    </div>  </div></div>{% endblock %}
deleted status/templates/500.html
@@ -1,25 +0,0 @@{% extends 'base.html' %}{% load static %}{% block title %}500{% endblock %}{% block description %}Something went wrong.{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container py-5">  <div class="row">    <div class="col-12 col-lg-8 offset-lg-2 text-center py-5">      <div class="section-label mb-3" style="display:inline-flex;">server fault</div>      <div class="display-1 fw-bolder text-white mb-3" style="font-size: clamp(5rem, 16vw, 10rem); letter-spacing: -0.04em; text-shadow: 0 0 30px rgba(239, 106, 91, 0.3);">        <span style="color: #ef6a5b;">5</span><span style="color: #ef6a5b;">0</span><span style="color: #ef6a5b;">0</span>      </div>      <p class="text-muted mb-4">Something threw on the way back. We've been notified.</p>      <a href="/" class="btn btn-primary">← Back to base</a>    </div>  </div></div>{% endblock %}
deleted status/templates/includes/messages.html
@@ -1,6 +0,0 @@{% for message in messages %}<div class="alert {{ message.tags }} alert-dismissible fade show mb-0" role="alert">  {{ message }}  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button></div>{% endfor %}
deleted status/templates/includes/social.html
@@ -1,9 +0,0 @@{% load social %}<meta property="og:type" content="website"><meta property="og:title" content="{{ title }} · Status"><meta property="og:description" content="{{ description }}"><meta property="og:url" content="{{ canonical }}">{% og_image canonical %}<meta name="twitter:card" content="summary_large_image">
deleted status/templates/registration/logged_out.html
@@ -1,25 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">session ended</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">You've been signed out. Hit the button below when you're ready to come back.</p>      <a href="{% url 'login' %}" class="btn btn-primary">Login →</a>    </div>  </div></div>{% endblock %}
deleted status/templates/registration/login.html
@@ -1,77 +0,0 @@{% extends "base.html" %}{% load i18n static %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="auth-shell">  <div class="auth-form-col">    <div class="auth-form-wrap">      <div class="section-label mb-2">authenticate</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em; font-size: 1.9rem;">{{ title }}</h1>      <p class="text-muted small mb-4">Access is invite-only. Contact the operator for credentials.</p>      {% if form.errors and not form.non_field_errors %}      <div class="alert alert-warning py-2 small">        {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}      </div>      {% endif %}      {% if form.non_field_errors %}      {% for error in form.non_field_errors %}      <div class="alert alert-danger py-2 small">{{ error }}</div>      {% endfor %}      {% endif %}      {% if user.is_authenticated %}      <div class="alert alert-warning py-2 small">        {% blocktranslate trimmed %}          You are authenticated as {{ username }}, but are not authorized to access this page.        {% endblocktranslate %}      </div>      {% endif %}      <form method="POST" action="{{ app_path }}">        {% csrf_token %}        <input type="hidden" name="next" value="{{ next }}" />        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value|default:'' }}" required autofocus />          <label for="id_username">Username</label>        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}" name="password" id="id_password" placeholder="Password" required />          <label for="id_password">Password</label>        </div>        <div class="d-flex justify-content-between align-items-center mt-4">          <button type="submit" class="btn btn-primary">Login →</button>          <a href="{% url 'password_reset' %}" class="btn btn-link small">Forgot password?</a>        </div>      </form>    </div>  </div>  <div class="auth-visual-col">    <div class="grid-plane" style="position:absolute; inset:0; height:100%;">      <div class="grid-plane-overlay"></div>      <div class="grid-plane-grid"></div>    </div>    <div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding: 2rem; z-index: 3;">      <div class="terminal-block" style="max-width: 380px; width: 100%;">        <span class="t-line"><span class="t-comment"># status · monitor</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">whoami</span></span>        <span class="t-line"><span class="t-val">anonymous</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">login --mode=interactive</span></span>        <span class="t-line"><span class="t-key">awaiting</span>=<span class="t-val">credentials</span> <span class="t-cursor"></span></span>      </div>    </div>  </div></div>{% endblock %}
deleted status/templates/registration/password_change_done.html
@@ -1,26 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">rotation complete</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Your password has been updated.</p>      <a href="{% url 'profile' %}" class="btn btn-primary">← Back to profile</a>    </div>  </div></div>{% endblock %}
deleted status/templates/registration/password_change_form.html
@@ -1,52 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">rotate credentials</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Confirm your existing password, then set a new one.</p>      {% if form.errors %}      <div class="alert alert-warning py-2 small">{% if form.errors.items|length == 1 %}Please correct the error below.{% else %}Please correct the errors below.{% endif %}</div>      {% endif %}      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}" name="old_password" id="id_old_password" placeholder="Old password" autocomplete="current-password" required autofocus />          <label for="id_old_password">Old password</label>          {{ form.old_password.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required />          <label for="id_new_password1">New password</label>          {% if form.new_password1.help_text %}          <div class="border border-subtle py-2 px-3 rounded mt-2 small text-muted">{{ form.new_password1.help_text|safe }}</div>          {% endif %}          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm new password" autocomplete="new-password" required />          <label for="id_new_password2">New password confirmation</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">Change password →</button>      </form>    </div>  </div></div>{% endblock %}
deleted status/templates/registration/password_reset_complete.html
@@ -1,26 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">reset complete</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Your password has been updated. Sign in whenever you're ready.</p>      <a href="{{ login_url }}" class="btn btn-primary">Login →</a>    </div>  </div></div>{% endblock %}
deleted status/templates/registration/password_reset_confirm.html
@@ -1,47 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">set new password</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      {% if validlink %}      <p class="text-muted small mb-4">Enter it twice so we can verify.</p>      <form method="POST">        {% csrf_token %}        <input type="hidden" autocomplete="username" value="{{ form.user.get_username }}" />        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required autofocus />          <label for="id_new_password1">New password</label>          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm password" autocomplete="new-password" required />          <label for="id_new_password2">Confirm password</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">Update password →</button>      </form>      {% else %}      <div class="alert alert-warning py-2 small">        The reset link was invalid, possibly because it's already been used. <a href="{% url 'password_reset' %}">Request a new one</a>.      </div>      {% endif %}    </div>  </div></div>{% endblock %}
deleted status/templates/registration/password_reset_done.html
@@ -1,26 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">dispatched</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">If an account with that email exists, a reset link is on its way. Check spam if it doesn't appear shortly.</p>      <a href="{% url 'login' %}" class="btn btn-outline-light">← Back to login</a>    </div>  </div></div>{% endblock %}
deleted status/templates/registration/password_reset_email.html
@@ -1,14 +0,0 @@{% load i18n %}{% autoescape off %}{% blocktranslate %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktranslate %}{% translate "Please go to the following page and choose a new password:" %}{% block reset_link %}{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}{% endblock %}{% translate 'Your username, in case you’ve forgotten:' %} {{ user.get_username }}{% translate "Thanks for using our site!" %}{% blocktranslate %}The {{ site_name }} team{% endblocktranslate %}{% endautoescape %}
deleted status/templates/registration/password_reset_form.html
@@ -1,34 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">recover access</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Enter your email — we'll dispatch a reset link.</p>      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="you@domain" value="{{ form.email.value|default:'' }}" required autofocus />          <label for="id_email">Email</label>        </div>        <button type="submit" class="btn btn-primary">Send reset link →</button>      </form>    </div>  </div></div>{% endblock %}
deleted status/urls.py
@@ -1,24 +0,0 @@from django.conf import settingsfrom django.conf.urls.static import staticfrom django.contrib import adminfrom django.urls import include, pathfrom django.views.generic import TemplateViewfrom accounts import urls as accounts_urlsfrom pages import urls as pages_urlsfrom properties import urls as properties_urlsurlpatterns = [    path("admin/", admin.site.urls),    path("accounts/", include(accounts_urls)),    path("properties/", include(properties_urls)),    path("", include(pages_urls)),]if settings.DEBUG:    urlpatterns.append(path("403/", TemplateView.as_view(template_name="403.html")))    urlpatterns.append(path("404/", TemplateView.as_view(template_name="404.html")))    urlpatterns.append(path("500/", TemplateView.as_view(template_name="500.html")))    urlpatterns += static("media/", document_root=settings.MEDIA_ROOT)
deleted status/wsgi.py
@@ -1,16 +0,0 @@"""WSGI config for status project.It exposes the WSGI callable as a module-level variable named ``application``.For more information on this file, seehttps://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/"""import osfrom django.core.wsgi import get_wsgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'status.settings.development')application = get_wsgi_application()
renamed status/templates/base.html → templates/base.html
@@ -1,27 +1,16 @@{% load static %}<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1">  <title>{% block title %}{{ title }}{% endblock %} · Status</title>  <meta name="description" content="{% block description %}{{ description }}{% endblock %}">  <link rel="canonical" href="{{ canonical }}">  {% if BASE_URL %}  <base href="{{ BASE_URL }}">  {% endif %}  <link rel="icon" type="image/svg+xml" href="{% url 'favicon' %}">  {% if BASE_URL %}<base href="{{ BASE_URL }}">{% endif %}  <link rel="icon" type="image/svg+xml" href="{{ url_for('favicon') }}">  {% block extra_head %}{% endblock %}  <link href="{% static 'base.css' %}" rel="stylesheet">  <link rel="stylesheet" href="{{ vite_asset('static_src/base/index.js', 'css') }}">  {% block extra_css %}{% endblock %}</head><body>  {% include 'includes/messages.html' %}
@@ -46,24 +35,22 @@      <div class="collapse navbar-collapse" id="navMain">        <ul class="navbar-nav ms-3">          {% if user.is_authenticated %}          <li class="nav-item"><a class="nav-link {% if request.path == '/properties/' %}active{% endif %}" href="/properties/">Properties</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/accounts/profile/' %}active{% endif %}" href="/accounts/profile/">Profile</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/properties' %}active{% endif %}" href="/properties">Properties</a></li>          {% else %}          <li class="nav-item"><a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">Home</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/changelog/' %}active{% endif %}" href="/changelog/">Changelog</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/changelog' %}active{% endif %}" href="/changelog">Changelog</a></li>          {% endif %}        </ul>        <ul class="navbar-nav ms-auto">          {% if user.is_authenticated %}          <li class="nav-item d-flex align-items-center">            <form method="post" action="{% url 'logout' %}" class="d-inline">              {% csrf_token %}            <form method="post" action="{{ url_for('logout') }}" class="d-inline">              <button type="submit" class="btn btn-sm btn-outline-light">Logout</button>            </form>          </li>          {% else %}          <li class="nav-item d-flex align-items-center">            <a href="/accounts/login/" class="btn btn-sm btn-outline-light">Login</a>            <a href="{{ url_for('login') }}" class="btn btn-sm btn-outline-light">Login</a>          </li>          {% endif %}        </ul>
@@ -98,25 +85,22 @@          <div class="h5 mb-3">// Pages</div>          <ul class="list-unstyled">            <li class="mb-2"><a href="/" class="link-footer">Home</a></li>            <li class="mb-2"><a href="/changelog/" class="link-footer">Changelog</a></li>            <li class="mb-2"><a href="/changelog" class="link-footer">Changelog</a></li>            <li class="mb-2"><a href="https://github.com/overshard/status" class="link-footer" target="_blank">Source</a></li>          </ul>        </div>        <div class="col-6 col-lg-2">          <div class="h5 mb-3">// Accounts</div>          <div class="h5 mb-3">// Operator</div>          <ul class="list-unstyled">            {% if user.is_authenticated %}            <li class="mb-2"><a href="/properties/" class="link-footer">Properties</a></li>            <li class="mb-2"><a href="/accounts/profile/" class="link-footer">Profile</a></li>            <li class="mb-2"><a href="/properties" class="link-footer">Properties</a></li>            <li class="mb-2">              <form method="post" action="{% url 'logout' %}" class="d-inline">                {% csrf_token %}              <form method="post" action="{{ url_for('logout') }}" class="d-inline">                <button type="submit" class="btn btn-link link-footer p-0 align-baseline text-start">Logout</button>              </form>            </li>            {% else %}            <li class="mb-2"><a href="/accounts/login/" class="link-footer">Login</a></li>            <li class="mb-2"><a href="/accounts/password-reset/" class="link-footer">Password reset</a></li>            <li class="mb-2"><a href="{{ url_for('login') }}" class="link-footer">Login</a></li>            {% endif %}          </ul>        </div>
@@ -128,7 +112,7 @@    <div class="container">      <div class="row align-items-center">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start order-1 order-sm-0">          <small>&copy; {% now 'Y' %} Isaac Bythewood · Some rights reserved</small>          <small>&copy; {{ now.year }} Isaac Bythewood · Some rights reserved</small>        </div>        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end py-3 py-sm-0">          <a href="https://github.com/overshard/status" target="_blank" class="footer-bar-link" aria-label="GitHub">
@@ -136,21 +120,13 @@              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>            </svg>          </a>          <a href="https://isaacbythewood.com/" target="_blank" class="logo logo-sm ms-3" aria-label="Isaac Bythewood">            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">              <polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34"                fill="none" stroke="#6b9e78" stroke-width="6"                stroke-linejoin="round" stroke-linecap="round"/>              <circle cx="30" cy="14" r="3.5" fill="#c9a84c"/>            </svg>          </a>        </div>      </div>    </div>  </div>  {% endblock %}  <script type="module" src="{% static 'base.js' %}"></script>  <script type="module" src="{{ vite_asset('static_src/base/index.js') }}"></script>  {% block extra_js %}{% endblock %}</body></html>
renamed properties/templates/emails/property_email_base.html → templates/emails/property_email_base.html
@@ -117,7 +117,7 @@                <table role="presentation" cellpadding="0" cellspacing="0" border="0">                  <tr>                    <td style="background-color: {{ accent_tint }}; border: 1px solid {{ accent_border }};">                      <a href="{{ BASE_URL }}/properties/{{ property.id }}/" style="display: inline-block; padding: 12px 22px; color: {{ accent_bright }}; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 700; text-decoration: none; font-family: ui-monospace, 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace;">                      <a href="{{ BASE_URL }}/{{ property.id }}" style="display: inline-block; padding: 12px 22px; color: {{ accent_bright }}; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 700; text-decoration: none; font-family: ui-monospace, 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace;">                        View property &nbsp;&rarr;                      </a>                    </td>
@@ -131,7 +131,7 @@            <tr>              <td class="px-pad" style="padding: 24px 0 0 0; color: #4a443c; font-size: 11px; line-height: 1.8; letter-spacing: 0.02em;">                Self-hosted monitoring &middot; HTTP checks every three minutes<br>                Alerts fire only on state transitions. Manage notifications from your <a href="{{ BASE_URL }}/accounts/profile/" style="color: #665f56; text-decoration: underline;">profile</a>.                Alerts fire only on state transitions. <a href="{{ BASE_URL }}/properties" style="color: #665f56; text-decoration: underline;">Manage properties</a>.              </td>            </tr>          </table>
added templates/includes/messages.html
@@ -0,0 +1,3 @@{# Flash messages aren't wired up in the rust port — the Django version used   request.messages, and we'd need a session-based equivalent. The base   template still includes this so the extension point is preserved. #}
added templates/pages/changelog.html
@@ -0,0 +1,33 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-4">  <div class="row mb-4">    <div class="col-12">      <div class="section-label mb-1">project</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">Changelog</h1>      <p class="text-muted mb-0 small">An ongoing record of what's shipped.</p>    </div>  </div>  <div class="bg-surface border-subtle rounded border p-4">    <h2 class="h5 text-white fw-bolder">v1.0 · Rust rewrite</h2>    <ul class="text-muted small">      <li>Single-binary axum service replaces the Django + scheduler pair.</li>      <li>Single-password operator auth replaces the multi-user accounts model.</li>      <li>SEO crawler ported to reqwest + scraper. Lighthouse still shells out to the npm CLI.</li>      <li>Email alerts use direct-MX delivery via lettre + hickory-resolver. Discord webhooks use plain HTTP POST.</li>      <li>Migration subcommand: <code>./status migrate &lt;django.sqlite3&gt;</code> preserves Property UUIDs.</li>    </ul>  </div></div>{% endblock %}
renamed pages/templates/pages/home.html → templates/pages/home.html
@@ -1,20 +1,11 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}{% include "includes/social.html" %}{% endblock %}{% extends "base.html" %}{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet"><link rel="stylesheet" href="{{ vite_asset('static_src/pages/index.js', 'css') }}">{% endblock %}{% block breadcrumb_wrapper %}{% endblock %}{% block main %}<section class="hero">  <div class="container">
@@ -22,7 +13,7 @@      <div class="col-12 col-lg-7">        <span class="hero-eyebrow">          <span class="eyebrow-dot" aria-hidden="true"></span>          v0.9 · alpha · self-hosted          v1.0 · self-hosted        </span>        <h1 class="hero-title">          Website status monitoring for operators who host their <span class="accent">own stack</span>.
@@ -33,7 +24,7 @@          never leaves your infrastructure.        </p>        <div class="hero-ctas">          <a href="/accounts/login/" class="btn btn-primary">Access dashboard →</a>          <a href="{{ url_for('login') }}" class="btn btn-primary">Access dashboard →</a>          <a href="https://github.com/overshard/status" target="_blank" class="btn btn-outline-light">View source</a>        </div>      </div>
@@ -53,29 +44,23 @@<section class="stat-strip">  <div class="container">    <div class="row g-4">      <div class="col-6 col-md-3">      <div class="col-6 col-md-4">        <div class="stat">          <div class="stat-label">properties</div>          <div class="stat-value">{{ total_properties }}</div>          <div class="stat-value">{{ total_properties|intcomma }}</div>        </div>      </div>      <div class="col-6 col-md-3">      <div class="col-6 col-md-4">        <div class="stat">          <div class="stat-label">checks logged</div>          <div class="stat-value">{{ total_statuses }}</div>          <div class="stat-value">{{ total_statuses|intcomma }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">operators</div>          <div class="stat-value">{{ total_users }}</div>        </div>      </div>      <div class="col-6 col-md-3">      <div class="col-12 col-md-4">        <div class="stat">          <div class="stat-label">online since</div>          <div class="stat-value" style="font-size: 1.05rem;">            {% if first_status_created_at %}{{ first_status_created_at|date:"M j, Y" }}{% else %}—{% endif %}            {% if first_status_created_at %}{{ first_status_created_at }}{% else %}—{% endif %}          </div>        </div>      </div>
@@ -97,62 +82,42 @@      <div class="feature">        <div class="feature-label">uptime</div>        <div class="feature-title">Live HTTP probes</div>        <p class="feature-desc">          Three-minute intervals, two-strike alert debouncing, SSL and timeout          mapping. Discord + SMTP notifications only fire on real state          transitions.        </p>        <p class="feature-desc">Three-minute intervals, two-strike alert debouncing, SSL and timeout mapping. Discord + SMTP notifications only fire on real state transitions.</p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">performance</div>        <div class="feature-title">Lighthouse audits</div>        <p class="feature-desc">          Daily headless runs surface performance, accessibility, best practices,          and SEO scores with weighted metric breakdowns and opportunity savings.        </p>        <p class="feature-desc">Daily headless runs surface performance, accessibility, best practices, and SEO scores with weighted metric breakdowns.</p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">security</div>        <div class="feature-title">Header analysis</div>        <p class="feature-desc">          HTTPS, HSTS, HSTS preload, XSS and content-sniffing protection,          clickjack defence, and server-version leak checks — graded on every          probe.        </p>        <p class="feature-desc">HTTPS, HSTS, HSTS preload, XSS and content-sniffing protection, clickjack defence, and server-version leak checks.</p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">crawler</div>        <div class="feature-title">Weekly SEO spider</div>        <p class="feature-desc">          Extracts title, description, canonical, OG tags, and H1 per page — every          mismatch becomes a severity-ranked insight you can drill into.        </p>        <p class="feature-desc">Extracts title, description, canonical, OG tags, and H1 per page — every mismatch becomes a severity-ranked insight.</p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">sharing</div>        <div class="feature-title">Public status pages</div>        <p class="feature-desc">          Toggle a property public and share the URL — no account required,          customers see live status, response graphs, and uptime at a glance.        </p>        <p class="feature-desc">Toggle a property public and share the URL — no account required, customers see live status, response graphs, and uptime at a glance.</p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">ownership</div>        <div class="feature-title">BSD, single container</div>        <p class="feature-desc">          Django + SQLite + a scheduler. Ships as a Docker Compose stack. Your          data, your ingress, your retention policy. No SaaS vendor lock-in.        </p>        <div class="feature-title">Single binary, single container</div>        <p class="feature-desc">Rust + axum + SQLite. Ships as a Docker Compose stack. Your data, your ingress, your retention policy. No SaaS vendor lock-in.</p>      </div>    </div>  </div>
added templates/properties/properties.html
@@ -0,0 +1,139 @@{% extends "base.html" %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/properties/index.js', 'css') }}">{% endblock %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-4">  <div class="row align-items-center g-3 mb-4">    <div class="col-md-5">      <div class="section-label mb-1">operator</div>      <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted mb-0 small mt-1">Every URL you've registered, current probe state, and audit signals.</p>    </div>    <div class="col-md-7">      <div class="dashboard-toolbar">        <form method="get" class="search-form flex-grow-1">          <input type="text" class="form-control search-input" name="q" id="id_search" placeholder="grep properties..."{% if q %} value="{{ q }}"{% endif %} />        </form>        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd">          + new        </button>      </div>    </div>  </div>  <div class="collapse mb-4" id="collapsePropertyAdd">    <div class="bg-surface border-subtle rounded border p-3">      <div class="section-label mb-2">register property</div>      <p class="text-muted small mb-3">Any public URL. Probe begins within 3 minutes.</p>      <form method="POST" action="{{ url_for('properties') }}" class="dashboard-toolbar">        <input type="url" name="url" id="id_url" class="form-control flex-grow-1" placeholder="https://example.com" required>        <button type="submit" class="btn btn-primary">Add →</button>      </form>    </div>  </div>  <div class="section-label mb-2">registered properties</div>  {% for property in properties %}    <div class="property-row {% if property.current_status != 200 %}is-down{% endif %}">      <div class="pr-main">        <div class="pr-title">          <span class="status-dot {% if property.current_status == 200 %}is-up{% else %}is-down{% endif %}" aria-hidden="true"></span>          <a href="/{{ property.id }}" class="text-truncate">{{ property.name }}</a>        </div>        <div class="pr-url">{{ property.url }}</div>        <div class="uptime-strip" aria-label="Recent checks">          {% for tick in property.recent_tick_stream %}            <span class="uptime-tick is-{{ tick }}"></span>          {% else %}            {% for i in range(30) %}<span class="uptime-tick is-none"></span>{% endfor %}          {% endfor %}        </div>      </div>      <div class="pr-side">        <div class="pr-meta">          <div class="pr-meta-cell">            <span class="pr-meta-k">status</span>            {% if property.current_status == 200 %}              <span class="chip chip-ok">ok · 200</span>            {% else %}              <span class="chip chip-down">down · {{ property.current_status }}</span>            {% endif %}          </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">uptime</span>            {% if property.recent_uptime_pct is not none %}              {% if property.recent_uptime_pct >= 99 %}                <span class="chip chip-ok">{{ property.recent_uptime_pct }}%</span>              {% elif property.recent_uptime_pct < 95 %}                <span class="chip chip-down">{{ property.recent_uptime_pct }}%</span>              {% else %}                <span class="chip chip-warn">{{ property.recent_uptime_pct }}%</span>              {% endif %}            {% else %}              <span class="chip chip-muted">—</span>            {% endif %}          </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">sec</span>            {% if property.has_security_issue %}              <span class="chip chip-warn">issues</span>            {% else %}              <span class="chip chip-ok">ok</span>            {% endif %}          </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">seo</span>            {% set insights = property.crawler_insights | length %}            {% if insights > 100 %}              <span class="chip chip-down">{{ insights }}</span>            {% elif insights > 25 %}              <span class="chip chip-warn">{{ insights }}</span>            {% else %}              <span class="chip chip-ok">{{ insights }}</span>            {% endif %}          </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">lh</span>            {% if property.avg_lighthouse_score %}              {% if property.avg_lighthouse_score >= 90 %}                <span class="chip chip-ok">{{ property.avg_lighthouse_score }}%</span>              {% elif property.avg_lighthouse_score >= 80 %}                <span class="chip chip-warn">{{ property.avg_lighthouse_score }}%</span>              {% else %}                <span class="chip chip-down">{{ property.avg_lighthouse_score }}%</span>              {% endif %}            {% else %}              <span class="chip chip-muted">—</span>            {% endif %}          </div>        </div>        <div class="pr-actions">          {% if not property.is_protected %}          <form method="POST" action="/properties/{{ property.id }}/delete" class="d-inline" onsubmit="return confirm('Delete {{ property.url }} and all its checks?');">            <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>          </form>          {% endif %}          <a href="/{{ property.id }}" class="btn btn-sm btn-outline-light">View →</a>        </div>      </div>    </div>  {% else %}    <div class="text-center py-5 text-muted">      <div class="section-label mb-3" style="justify-content:center;">no properties yet</div>      <p class="mb-0 small">Click <strong>+ new</strong> above to register your first URL.</p>    </div>  {% endfor %}</div>{% endblock %}
renamed properties/templates/properties/property.html → templates/properties/property.html
@@ -1,78 +1,59 @@{% extends 'base.html' %}{% load static properties_tags %}{% block extra_js %}{{ status_response_times_graph|json_script:"chart-status-response-times-data" }}{{ status_codes_graph|json_script:"chart-status-codes-data" }}{{ uptime_graph|json_script:"chart-uptime-data" }}<script type="module" src="{% static 'properties.js' %}"></script>{% endblock %}{% extends "base.html" %}{% block extra_css %}<link rel="stylesheet" href="{% static 'properties.css' %}"><link rel="stylesheet" href="{{ vite_asset('static_src/properties/index.js', 'css') }}">{% endblock %}{% block extra_js %}<script id="chart-status-response-times-data" type="application/json">{{ status_response_times_graph | tojson }}</script><script id="chart-status-codes-data" type="application/json">{{ status_codes_graph | tojson }}</script><script id="chart-uptime-data" type="application/json">{{ uptime_graph | tojson }}</script><script id="property-data" type="application/json">{{ property | tojson }}</script><script type="module" src="{{ vite_asset('static_src/properties/index.js') }}"></script>{% endblock %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="/properties/">Properties</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>    {% if user.is_authenticated %}<li class="breadcrumb-item"><a href="/properties">Properties</a></li>{% endif %}    <li class="breadcrumb-item active" aria-current="page">{{ property.name }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="bg-deep border-bottom border-subtle py-4">  <div class="container">    <div class="row align-items-center g-3">      <div class="col-12 col-lg-6">        <div class="section-label mb-2">property · <span class="text-green">{% if property.current_status == 200 %}online{% else %}offline{% endif %}</span></div>        <h1 class="mb-1 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">      <div class="col-12 col-lg-7">        <div class="section-label mb-2">property · <span class="{% if property.current_status == 200 %}text-green{% else %}text-terracotta{% endif %}">{% if property.current_status == 200 %}online{% else %}offline{% endif %}</span></div>        <h1 class="mb-2 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">          <span class="text-truncate">{{ property.name }}</span>          <span class="status-dot status-dot-lg {% if property.current_status == 200 %}is-up{% else %}is-down{% endif %}" aria-hidden="true"></span>        </h1>        <a href="{{ property.url }}" target="_blank" class="text-muted small text-decoration-none" style="font-family: var(--bs-font-monospace);">{{ property.url }} ↗</a>        <a href="{{ property.url }}" target="_blank" rel="noopener" class="property-url-link">          <span class="property-url-text">{{ property.url }}</span>          <span class="property-url-arrow" aria-hidden="true">↗</span>        </a>      </div>      <div class="col-12 col-lg-6 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">      <div class="col-12 col-lg-5 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">        {% if user.is_authenticated %}        <form id="is-public-form" method="POST" class="d-inline-flex align-items-center gap-2">          {% csrf_token %}          <span class="toggle-label" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Public properties are viewable by anyone with the URL — no login required.">Visibility</span>          <label class="toggle-pill">            <input type="checkbox" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>            <span class="toggle-pill-seg toggle-pill-private">Private</span>            <span class="toggle-pill-seg toggle-pill-public">Public</span>          </label>        </form>        <a href="{% url 'property' property.id %}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>        <a href="{% url 'property' property.id %}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>        {% if not property.is_protected %}        <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">          Delete        </button>        <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">          <div class="modal-dialog">            <div class="modal-content">              <div class="modal-header">                <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>              </div>              <div class="modal-body">                Delete <strong class="text-white">{{ property.name }}</strong>? All probe and audit data will be removed.              </div>              <div class="modal-footer">                <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>              </div>            </div>          </div>        </div>        {% endif %}          <span id="is-public-form" class="d-inline-flex align-items-center gap-2" data-property-id="{{ property.id }}">            <span class="toggle-label" title="Public properties are viewable by anyone with the URL; no login required.">Visibility</span>            <label class="toggle-pill">              <input type="checkbox" name="is_public" id="is-public-switch"{% if property.is_public %} checked{% endif %}>              <span class="toggle-pill-seg toggle-pill-private">Private</span>              <span class="toggle-pill-seg toggle-pill-public">Public</span>            </label>          </span>          <a href="/{{ property.id }}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>          <a href="/{{ property.id }}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>          {% if not property.is_protected %}          <form method="POST" action="/properties/{{ property.id }}/delete" class="d-inline" onsubmit="return confirm('Delete {{ property.url }}?');">            <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>          </form>          {% endif %}        {% endif %}      </div>    </div>
@@ -85,7 +66,7 @@    <div class="col-6 col-md-3">      <div class="metric-tile {% if property.current_status == 200 %}metric-accent-green{% else %}metric-accent-danger{% endif %}">        <div class="metric-label">http status</div>        <div class="metric-value">{% if property.current_status == 200 %}200{% else %}{{ property.current_status }}{% endif %}</div>        <div class="metric-value">{{ property.current_status }}</div>        <div class="metric-sub">{% if property.current_status == 200 %}OK · all clear{% else %}failing{% endif %}</div>      </div>    </div>
@@ -106,7 +87,7 @@    <div class="col-6 col-md-3">      <div class="metric-tile {% if property.avg_response_time > 500 %}metric-accent-danger{% else %}metric-accent-green{% endif %}">        <div class="metric-label">response</div>        <div class="metric-value">{{ property.avg_response_time }}<span style="font-size: 0.9rem; color: #89918c;">ms</span></div>        <div class="metric-value">{{ property.avg_response_time }}<span class="text-muted" style="font-size: 0.9rem;"> ms</span></div>        <div class="metric-sub">rolling avg</div>      </div>    </div>
@@ -115,11 +96,11 @@  {% if property.lighthouse_scores %}  <div class="section-label mb-2">lighthouse</div>  <div class="row g-3 mb-4">    {% for category, score in property.lighthouse_scores.items %}    {% for category, score in property.lighthouse_scores | items %}    <div class="col-6 col-md-3">      <div class="metric-tile {% if score >= 90 %}metric-accent-green{% elif score >= 50 %}metric-accent-amber{% else %}metric-accent-danger{% endif %}">        <div class="metric-label">{{ category }}</div>        <div class="metric-value">{{ score }}<span style="font-size: 0.9rem; color: #89918c;">%</span></div>        <div class="metric-value">{{ score }}<span class="text-muted" style="font-size: 0.9rem;">%</span></div>      </div>    </div>    {% endfor %}
@@ -127,82 +108,6 @@  {% endif %}</div>{% if user.is_authenticated %}<form style="display:none">{% csrf_token %}</form><div class="container my-4 d-print-none" id="monitoring-status" data-property-id="{{ property.id }}" data-status-url="{% url 'property_status' property.id %}" data-recrawl-url="{% url 'property_recrawl' property.id %}" data-rerun-lighthouse-url="{% url 'property_rerun_lighthouse' property.id %}">  <div class="section-label mb-2">scheduled audits</div>  <div class="row g-3">    <div class="col-12 col-md-6">      <div class="monitor-card h-100">        <div class="monitor-header">          <span class="monitor-title">Crawler</span>          <span class="d-flex align-items-center gap-2">            <span class="badge" data-field="crawler.state_badge">&nbsp;</span>            <button type="button" id="recrawl-btn" class="btn btn-sm btn-outline-light py-0" title="Recrawl now">              <span class="recrawl-btn-label">Recrawl</span>              <span class="recrawl-btn-spinner spinner-border spinner-border-sm ms-1 d-none" role="status" aria-hidden="true"></span>            </button>          </span>        </div>        <div class="monitor-body">          <div class="progress mb-3 d-none" data-field="crawler.progress_wrap" style="height: 4px;">            <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" data-field="crawler.progress_bar" style="width: 0%"></div>          </div>          <div class="alert alert-danger py-2 mb-3 d-none small" data-field="crawler.error_box" role="alert">            <strong>Last crawl failed:</strong>            <code data-field="crawler.error_text"></code>          </div>          <dl>            <dt>Last success</dt>            <dd data-field="crawler.last_success">—</dd>            <dt>Last attempt</dt>            <dd data-field="crawler.last_attempt">—</dd>            <dt>Pages crawled</dt>            <dd data-field="crawler.pages">—</dd>            <dt>Duration</dt>            <dd data-field="crawler.duration">—</dd>            <dt>Issues found</dt>            <dd data-field="crawler.insights">—</dd>            <dt>Next run</dt>            <dd data-field="crawler.next_run">—</dd>          </dl>        </div>      </div>    </div>    <div class="col-12 col-md-6">      <div class="monitor-card h-100">        <div class="monitor-header">          <span class="monitor-title">Lighthouse</span>          <span class="d-flex align-items-center gap-2">            <span class="badge" data-field="lighthouse.state_badge">&nbsp;</span>            <button type="button" id="rerun-lighthouse-btn" class="btn btn-sm btn-outline-light py-0" title="Rerun Lighthouse now">              <span class="rerun-lh-label">Rerun</span>              <span class="rerun-lh-spinner spinner-border spinner-border-sm ms-1 d-none" role="status" aria-hidden="true"></span>            </button>          </span>        </div>        <div class="monitor-body">          <div class="alert alert-warning py-2 mb-3 d-none small" data-field="lighthouse.error_box" role="alert">            <strong>Last Lighthouse run failed:</strong>            <code data-field="lighthouse.error_text"></code>          </div>          <dl>            <dt>Last success</dt>            <dd data-field="lighthouse.last_success">—</dd>            <dt>Last attempt</dt>            <dd data-field="lighthouse.last_attempt">—</dd>            <dt>Duration</dt>            <dd data-field="lighthouse.duration">—</dd>            <dt>Next run</dt>            <dd data-field="lighthouse.next_run">—</dd>          </dl>        </div>      </div>    </div>  </div></div>{% endif %}<div class="container my-4">  <div class="row g-3">    <div class="col-12 col-md-8">
@@ -273,11 +178,86 @@  </div></div>{% if user.is_authenticated %}<div class="container my-4 d-print-none" id="monitoring-status" data-property-id="{{ property.id }}">  <div class="section-label mb-2">scheduled audits</div>  <div class="row g-3">    <div class="col-12 col-md-6">      <div class="monitor-card h-100">        <div class="monitor-header">          <span class="monitor-title">Crawler</span>          <span class="d-flex align-items-center gap-2">            <span class="chip chip-muted" data-field="crawler.state_badge">&nbsp;</span>            <button type="button" id="recrawl-btn" class="btn btn-sm btn-outline-light monitor-btn" title="Recrawl now">              <span class="recrawl-btn-label">Recrawl</span>              <span class="recrawl-btn-spinner spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>            </button>          </span>        </div>        <div class="monitor-body">          <div class="progress mb-3 d-none" data-field="crawler.progress_wrap" style="height: 4px;">            <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" data-field="crawler.progress_bar" style="width: 0%"></div>          </div>          <div class="alert alert-danger py-2 mb-3 d-none small" data-field="crawler.error_box" role="alert">            <strong>Last crawl failed:</strong>            <code data-field="crawler.error_text"></code>          </div>          <dl>            <dt>Last success</dt>            <dd data-field="crawler.last_success">—</dd>            <dt>Last attempt</dt>            <dd data-field="crawler.last_attempt">—</dd>            <dt>Pages crawled</dt>            <dd data-field="crawler.pages">—</dd>            <dt>Duration</dt>            <dd data-field="crawler.duration">—</dd>            <dt>Issues found</dt>            <dd data-field="crawler.insights">—</dd>            <dt>Next run</dt>            <dd data-field="crawler.next_run">—</dd>          </dl>        </div>      </div>    </div>    <div class="col-12 col-md-6">      <div class="monitor-card h-100">        <div class="monitor-header">          <span class="monitor-title">Lighthouse</span>          <span class="d-flex align-items-center gap-2">            <span class="chip chip-muted" data-field="lighthouse.state_badge">&nbsp;</span>            <button type="button" id="rerun-lighthouse-btn" class="btn btn-sm btn-outline-light monitor-btn" title="Rerun Lighthouse now">              <span class="rerun-lh-label">Rerun</span>              <span class="rerun-lh-spinner spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>            </button>          </span>        </div>        <div class="monitor-body">          <div class="alert alert-warning py-2 mb-3 d-none small" data-field="lighthouse.error_box" role="alert">            <strong>Last Lighthouse run failed:</strong>            <code data-field="lighthouse.error_text"></code>          </div>          <dl>            <dt>Last success</dt>            <dd data-field="lighthouse.last_success">—</dd>            <dt>Last attempt</dt>            <dd data-field="lighthouse.last_attempt">—</dd>            <dt>Duration</dt>            <dd data-field="lighthouse.duration">—</dd>            <dt>Next run</dt>            <dd data-field="lighthouse.next_run">—</dd>          </dl>        </div>      </div>    </div>  </div></div>{% endif %}{% if property.lighthouse_details %}<div class="container my-4">  <div class="section-label mb-2">performance breakdown</div>  <p class="text-muted small mb-3">    Lighthouse combines these weighted metrics to produce the Performance score. Opportunities below are the biggest wins — savings are estimated against the audited URL only.    Lighthouse combines these weighted metrics to produce the Performance score. Opportunities below are the biggest wins; savings are estimated against the audited URL only.  </p>  {% if property.lighthouse_details.metrics %}
@@ -286,7 +266,7 @@    <div class="col-6 col-md d-flex">      <div class="metric-tile w-100 {% if metric.score >= 0.9 %}metric-accent-green{% elif metric.score >= 0.5 %}metric-accent-amber{% else %}metric-accent-danger{% endif %}">        <div class="metric-label">{{ metric.acronym }} · {{ metric.weight }}%</div>        <div class="metric-value" style="font-size: 1.2rem;">{{ metric.display_value|default:"—" }}</div>        <div class="metric-value" style="font-size: 1.2rem;">{{ metric.display_value or "—" }}</div>        <div class="metric-sub" title="{{ metric.title }}">{{ metric.title }}</div>      </div>    </div>
@@ -306,56 +286,54 @@  <div class="insight-row">    <span>      <span class="chip {% if opp.score >= 0.9 %}chip-ok{% elif opp.score >= 0.5 %}chip-warn{% else %}chip-down{% endif %}">        {{ opp.score|floatformat:"2" }}        {{ opp.score | round(2) }}      </span>    </span>    <span class="insight-col-trunc" title="{{ opp.title }}">{{ opp.title }}</span>    <span class="insight-col-trunc text-muted small" {% if opp.display_value %}data-bs-toggle="tooltip" data-bs-title="{{ opp.display_value }}"{% endif %}>      {{ opp.display_value|default:"—" }}    <span class="insight-col-trunc text-muted small" title="{{ opp.display_value or '' }}">      {{ opp.display_value or "—" }}    </span>    <span class="text-end small">      {% with saved=opp.savings_ms|format_ms_savings %}        {% if saved %}<strong class="text-amber">{{ saved }}</strong>{% else %}<span class="text-muted">—</span>{% endif %}      {% endwith %}      {% set saved = opp.savings_ms | format_ms_savings %}      {% if saved %}<strong class="text-amber">{{ saved }}</strong>{% else %}<span class="text-muted">—</span>{% endif %}    </span>  </div>  {% endfor %}  {% else %}  <div class="alert alert-info py-2 small">No actionable opportunities — everything passing at this URL.</div>  <div class="alert alert-info py-2 small">No actionable opportunities; everything passing at this URL.</div>  {% endif %}</div>{% endif %}{% if property.crawler_insights %}{% if insights_groups and insights_groups | length > 0 %}<div class="container my-4">  {% regroup property.crawler_insights|dictsort:"type" by type as insight_type_groups %}  {% for group in insight_type_groups %}  <div class="section-label mb-2">{{ group.grouper }} · <span class="text-muted">{{ group.list|length }}</span></div>  {% for group in insights_groups %}  <div class="section-label mb-2">{{ group.type }} · <span class="text-muted">{{ group.items | length }}</span></div>  <div class="insight-header">    <span>Severity</span>    <span>URL</span>    <span>Issue</span>    <span>Item</span>  </div>  {% for insight in group.list|dictsort:"severity" %}  {% for insight in group.items %}  <div class="insight-row">    <span>      <span class="chip {% if insight.severity == 'error' %}chip-down{% elif insight.severity == 'warning' %}chip-warn{% else %}chip-info{% endif %}">        {{ insight.severity|upper }}        {{ insight.severity | upper }}      </span>    </span>    <span class="insight-col-trunc">      <a href="{{ insight.url }}" target="_blank" class="text-muted">{{ insight.url|url_path }}</a>    </span>    <span class="insight-col-trunc" {% if insight.issue %}data-bs-toggle="tooltip" data-bs-title="{{ insight.issue }}"{% endif %}>      {{ insight.issue }}    </span>    <span class="insight-col-trunc text-muted small" {% if insight.item %}data-bs-toggle="tooltip" data-bs-title="{{ insight.item }}"{% endif %}>      {{ insight.item }}      <a href="{{ insight.url }}" target="_blank" rel="noopener" class="text-muted">{{ insight.url | url_path }}</a>    </span>    <span class="insight-col-trunc" title="{{ insight.issue }}">{{ insight.issue }}</span>    <span class="insight-col-trunc text-muted small" title="{{ insight.item or '' }}">{{ insight.item or "" }}</span>  </div>  {% endfor %}  {% endfor %}</div>{% elif property.last_crawl_error %}<div class="container my-4">  <div class="alert alert-danger py-2 small mb-0">Last crawl failed: <code>{{ property.last_crawl_error }}</code></div></div>{% endif %}{% endblock %}
added templates/properties/property_report.md
@@ -0,0 +1,39 @@# {{ property.name }}`{{ property.url }}`## Snapshot- Current status: **{{ property.current_status }}**- Average response: **{{ property.avg_response_time }} ms**- Recent uptime: {% if property.recent_uptime_pct is not none %}**{{ property.recent_uptime_pct }}%**{% else %}—{% endif %}- Total checks logged: **{{ property.total_checks }}**## Lighthouse{% if property.lighthouse_scores %}{% for k, v in property.lighthouse_scores | items %}- {{ k }}: **{{ v }}**{% endfor %}{% else %}_No lighthouse data._{% endif %}## Security headers| Header | State ||---|---|| HTTPS | {% if property.is_https %}yes{% else %}no{% endif %} || HSTS (1y+) | {% if property.has_hsts %}yes{% else %}no{% endif %} || HSTS preload | {% if property.has_hsts_preload %}yes{% else %}no{% endif %} || X-Frame-Options | {% if property.has_clickjack_protection %}set{% else %}missing{% endif %} || X-Content-Type-Options | {% if property.has_content_sniffing_protection %}nosniff{% else %}missing{% endif %} || Server header hidden | {% if property.hides_server_version %}yes{% else %}no{% endif %} |## SEO insights{% if property.crawler_insights and property.crawler_insights | length > 0 %}{% for i in property.crawler_insights %}- [{{ i.severity }}] [{{ i.type }}] {{ i.issue }} — `{{ i.url }}`{% endfor %}{% else %}_No crawl results yet._{% endif %}
added templates/properties/property_report.typ
@@ -0,0 +1,215 @@{# Property report rendered by minijinja into Typst markup, then compiled to   PDF by src/pdf.rs::PdfRenderer. Mirrors analytics/templates/properties/property_report.typ   in look-and-feel: monochrome, hairline rules, tracking-letterspaced uppercase   section headers, mono for technical strings. #}#let dim = rgb("#555")#let muted = rgb("#888")#let mono = ("JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono")#set page(  paper: "a4",  margin: (top: 14mm, bottom: 18mm, left: 14mm, right: 14mm),  footer: context {    set text(size: 7.5pt, fill: dim)    grid(      columns: (1fr, auto),      align: (left + horizon, right + horizon),      [Status · self-hosted{% if base_url %} · {{ base_url | typst_md }}{% endif %} · {{ property.name | typst_md }}],      [Page #counter(page).display() of #counter(page).final().first()],    )  },)#set text(  font: ("DejaVu Sans", "Liberation Sans", "Arial"),  size: 9.5pt,  fill: black,)#set par(leading: 0.5em, justify: false)#show heading.where(level: 1): set text(size: 22pt, weight: "bold")#show heading.where(level: 1): set block(above: 8pt, below: 4pt)#show heading.where(level: 2): it => block(  above: 16pt,  below: 6pt,  width: 100%,  stroke: (bottom: 0.6pt + black),  inset: (bottom: 3pt),)[#text(size: 11pt, weight: "bold", tracking: 0.6pt, upper(it.body))]#show heading.where(level: 3): it => block(  above: 8pt,  below: 3pt,)[#text(size: 8.5pt, weight: "bold", tracking: 0.5pt, fill: rgb("#333"), upper(it.body))]// Header strip#grid(  columns: (1fr, auto),  align: (left + top, right + top),  text(size: 8.5pt, tracking: 0.9pt, fill: dim, upper("// Status · property report")),  text(size: 8pt, fill: dim)[Generated {{ generated_at | typst_md }}],)= {{ property.name | typst_md }}// Meta dl: 2-col with thin rules above and below.#block(  above: 8pt,  below: 0pt,  width: 100%,  stroke: (top: 0.6pt + black, bottom: 0.6pt + black),  inset: (top: 6pt, bottom: 6pt),)[  #grid(    columns: (1fr, 1fr),    column-gutter: 16pt,    row-gutter: 4pt,    [      #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Property ID")) \      #text(font: mono, size: 8.5pt)[{{ property.id | typst_md }}]    ],    [      #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("URL")) \      #text(font: mono, size: 8.5pt)[{{ property.url | typst_md }}]    ],    [      #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Alert state")) \      #text(weight: "semibold")[{{ property.alert_state | typst_md }}]    ],    [      #text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Visibility")) \      #text(weight: "semibold")[{% if property.is_public %}public{% else %}private{% endif %}]    ],  )]== Live signals#grid(  columns: (1fr, 1fr, 1fr, 1fr),  gutter: 4pt,  rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[    #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[STATUS]    #v(2pt)    #text(size: 14pt, weight: "bold")[{{ property.current_status }}]  ],  rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[    #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[AVG RESPONSE]    #v(2pt)    #text(size: 14pt, weight: "bold")[{{ property.avg_response_time }} ms]  ],  rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[    #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[UPTIME]    #v(2pt)    #text(size: 14pt, weight: "bold")[{% if property.recent_uptime_pct is none %}—{% else %}{{ property.recent_uptime_pct }}%{% endif %}]  ],  rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[    #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[CHECKS]    #v(2pt)    #text(size: 14pt, weight: "bold")[{{ property.total_checks }}]  ],)== Lighthouse{% if property.lighthouse_scores %}#grid(  columns: (1fr, 1fr, 1fr, 1fr),  gutter: 4pt,  {% for k, v in property.lighthouse_scores | items %}  rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[    #text(size: 7.5pt, tracking: 0.4pt, fill: dim)[{{ k | upper | typst_md }}]    #v(2pt)    #text(size: 14pt, weight: "bold")[{{ v }}]  ],  {% endfor %}){% if property.lighthouse_details and property.lighthouse_details.metrics %}=== Performance metrics#block(breakable: false, table(  columns: (auto, 1fr, auto),  align: (left + top, left + top, right + top),  inset: (x: 3pt, y: 2pt),  stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },  table.header(    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Metric")),    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Title")),    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Value")),  ),  {% for m in property.lighthouse_details.metrics %}  text(font: mono, size: 7pt, weight: "bold")[{{ m.acronym | typst_md }}], text(size: 7.5pt)[{{ m.title | typst_md }}], text(size: 7.5pt)[{{ m.display_value | typst_md }}],  {% endfor %})){% endif %}{% if property.lighthouse_details and property.lighthouse_details.opportunities %}=== Top opportunities#block(breakable: false, table(  columns: (1fr, auto),  align: (left + top, right + top),  inset: (x: 3pt, y: 2pt),  stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },  table.header(    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Opportunity")),    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Savings")),  ),  {% for o in property.lighthouse_details.opportunities %}  text(size: 7.5pt)[{{ o.title | typst_md }}], text(size: 7.5pt)[{{ o.savings_ms | format_ms_savings | typst_md }}],  {% endfor %})){% endif %}{% else %}#text(size: 8pt, fill: muted, style: "italic")[No lighthouse data yet.]{% endif %}== Security#block(breakable: false, table(  columns: (1fr, auto),  align: (left + top, right + top),  inset: (x: 3pt, y: 2pt),  stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },  table.header(    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Check")),    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Result")),  ),  text(size: 7.5pt)[HTTPS], text(size: 7.5pt)[{% if property.is_https %}OK{% else %}Issue{% endif %}],  text(size: 7.5pt)[TLS certificate valid], text(size: 7.5pt)[{% if property.invalid_cert %}Issue{% else %}OK{% endif %}],  text(size: 7.5pt)[HSTS (≥1y max-age)], text(size: 7.5pt)[{% if property.has_hsts %}OK{% else %}Issue{% endif %}],  text(size: 7.5pt)[HSTS preload], text(size: 7.5pt)[{% if property.has_hsts_preload %}OK{% else %}Issue{% endif %}],  text(size: 7.5pt)[X-Frame-Options], text(size: 7.5pt)[{% if property.has_clickjack_protection %}OK{% else %}Issue{% endif %}],  text(size: 7.5pt)[X-Content-Type-Options], text(size: 7.5pt)[{% if property.has_content_sniffing_protection %}OK{% else %}Issue{% endif %}],  text(size: 7.5pt)[X-XSS-Protection], text(size: 7.5pt)[{% if property.has_xss_protection %}OK{% else %}Issue{% endif %}],  text(size: 7.5pt)[Server header hidden], text(size: 7.5pt)[{% if property.hides_server_version %}OK{% else %}Issue{% endif %}],))== SEO insights{% if insights_groups and insights_groups | length > 0 %}{% for group in insights_groups %}=== {{ group.type | typst_md }}#block(breakable: false, table(  columns: (auto, 1fr, 1fr),  align: (left + top, left + top, left + top),  inset: (x: 3pt, y: 2pt),  stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },  table.header(    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Severity")),    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Issue")),    text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("URL")),  ),  {% for i in group.items %}  text(size: 7.5pt, weight: "bold")[{{ i.severity | typst_md }}], text(size: 7.5pt)[{{ i.issue | typst_md }}], text(font: mono, size: 7pt, fill: dim)[{{ i.url | typst_md }}],  {% endfor %})){% endfor %}{% else %}#text(size: 8pt, fill: muted, style: "italic")[No crawl results yet.]{% endif %}
added templates/registration/login.html
@@ -0,0 +1,48 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="auth-shell">  <div class="auth-form-col">    <div class="auth-form-wrap">      <div class="section-label mb-2">authenticate</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em; font-size: 1.9rem;">{{ title }}</h1>      <p class="text-muted small mb-4">Access is single-operator. Use the password from your <code>.env</code>.</p>      {% if error %}      <div class="alert alert-danger py-2 small">{{ error }}</div>      {% endif %}      <form method="POST" action="{{ url_for('login') }}">        <input type="hidden" name="next" value="{{ next }}" />        <div class="form-floating mb-3">          <input type="password" class="form-control" name="password" id="id_password" placeholder="Password" required autofocus />          <label for="id_password">Password</label>        </div>        <div class="d-flex justify-content-end mt-4">          <button type="submit" class="btn btn-primary">Login →</button>        </div>      </form>    </div>  </div>  <div class="auth-visual-col">    <div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding: 2rem;">      <div class="terminal-block" style="max-width: 380px; width: 100%;">        <span class="t-line"><span class="t-comment"># status · monitor</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">whoami</span></span>        <span class="t-line"><span class="t-val">anonymous</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">login --mode=interactive</span></span>        <span class="t-line"><span class="t-key">awaiting</span>=<span class="t-val">credentials</span> <span class="t-cursor"></span></span>      </div>    </div>  </div></div>{% endblock %}
deleted uv.lock
@@ -1,708 +0,0 @@version = 1revision = 3requires-python = ">=3.10"resolution-markers = [    "python_full_version >= '3.12'",    "python_full_version < '3.12'",][[package]]name = "asgiref"version = "3.11.1"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "typing-extensions", marker = "python_full_version < '3.11'" },]sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },][[package]]name = "beautifulsoup4"version = "4.14.3"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "soupsieve" },    { name = "typing-extensions" },]sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },][[package]]name = "black"version = "26.3.1"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "click" },    { name = "mypy-extensions" },    { name = "packaging" },    { name = "pathspec" },    { name = "platformdirs" },    { name = "pytokens" },    { name = "tomli", marker = "python_full_version < '3.11'" },    { name = "typing-extensions", marker = "python_full_version < '3.11'" },]sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" },    { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" },    { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" },    { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" },    { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" },    { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" },    { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" },    { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" },    { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" },    { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" },    { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" },    { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" },    { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" },    { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" },    { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" },    { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" },    { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" },    { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" },    { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" },    { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" },    { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" },    { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" },    { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" },    { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" },    { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" },    { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },][[package]]name = "certifi"version = "2026.2.25"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },][[package]]name = "charset-normalizer"version = "3.4.7"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },    { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },    { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },    { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },    { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },    { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },    { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },    { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },    { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },    { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },    { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },    { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },    { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },    { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },    { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },    { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },    { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },    { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },    { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },    { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },    { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },    { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },    { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },    { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },    { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },    { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },    { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },    { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },    { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },    { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },    { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },    { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },][[package]]name = "click"version = "8.3.2"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "colorama", marker = "sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },][[package]]name = "colorama"version = "0.4.6"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },][[package]]name = "django"version = "5.2.13"source = { registry = "https://pypi.org/simple" }resolution-markers = [    "python_full_version < '3.12'",]dependencies = [    { name = "asgiref", marker = "python_full_version < '3.12'" },    { name = "sqlparse", marker = "python_full_version < '3.12'" },    { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },][[package]]name = "django"version = "6.0.4"source = { registry = "https://pypi.org/simple" }resolution-markers = [    "python_full_version >= '3.12'",]dependencies = [    { name = "asgiref", marker = "python_full_version >= '3.12'" },    { name = "sqlparse", marker = "python_full_version >= '3.12'" },    { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },][[package]]name = "dnspython"version = "2.8.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },][[package]]name = "flake8"version = "7.3.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "mccabe" },    { name = "pycodestyle" },    { name = "pyflakes" },]sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },][[package]]name = "gunicorn"version = "25.3.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "packaging" },]sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },][[package]]name = "h11"version = "0.16.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },][[package]]name = "idna"version = "3.11"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },][[package]]name = "isort"version = "8.0.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },][[package]]name = "lxml"version = "6.0.3"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/43/42/149c7747977db9d68faee960c1a3391eb25e94d4bb677f8e2df8328e4098/lxml-6.0.3.tar.gz", hash = "sha256:a1664c5139755df44cab3834f4400b331b02205d62d3fdcb1554f63439bf3372", size = 4237567, upload-time = "2026-04-09T14:39:09.664Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/c6/bd/8d24ca9079146eafc442e7fc33aa15b42d85fa88c02aac42dd80cee2f4af/lxml-6.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c524cf8c3b8d71dfc3de6cfb225138a876862a92d88bfa22eb9ff020729d45", size = 8540496, upload-time = "2026-04-09T14:33:38.86Z" },    { url = "https://files.pythonhosted.org/packages/15/72/b2e51e3cc0b808c030169581928802dd8802495445943ed610c21a244cde/lxml-6.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a10f9967859229cae38b1aa7a96eb655c96b8adc96989b52c5b1f77d963a77a4", size = 4601807, upload-time = "2026-04-09T14:33:41.532Z" },    { url = "https://files.pythonhosted.org/packages/9d/10/bd0a0447162f0895e118b5e38f62fbdcf9187a285c294e93de25ca2ea7cf/lxml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:edccf1157677db1da741d042601754b94af3926310c5763179200718ca738e70", size = 5000670, upload-time = "2026-04-09T14:33:43.934Z" },    { url = "https://files.pythonhosted.org/packages/de/e3/e2f36bac7eb86d2911da073a822e5351b8e3b48c3ed6016fc618db77ace0/lxml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20f8caa9beb61a688c4428631cb47fd6e0ba75ef30091cec5fee992138b2be77", size = 5154581, upload-time = "2026-04-09T14:33:46.317Z" },    { url = "https://files.pythonhosted.org/packages/b1/59/45a2aa9d0c0eb4365da07f388f4de4e19e3ce9644063302e8d713943ea58/lxml-6.0.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0c88ca5fb307f7e817fc427681126e4712d3452258577bcb4ca86594c934852", size = 5055184, upload-time = "2026-04-09T14:33:48.946Z" },    { url = "https://files.pythonhosted.org/packages/fa/3d/df07a4ac8d3beaef42e145436cd56e06a8f9e2f490de5b5692d9b1b8da59/lxml-6.0.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:72d108ef9e39f45c6865ea8a5ba6736c0b1a33ddd6343653775349869e58c30b", size = 5285602, upload-time = "2026-04-09T14:33:51.147Z" },    { url = "https://files.pythonhosted.org/packages/46/76/7565456ec16d875b01c7c85b881df0bd96c5d3cd1e0f154bf3a5f0383f9b/lxml-6.0.3-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:b044fe3bdb8b68efa33cb5917ae9379f07ec2e416ecd18cf5d333650d6d2fcbb", size = 5410242, upload-time = "2026-04-09T14:33:53.071Z" },    { url = "https://files.pythonhosted.org/packages/93/6e/388bd48adbc95f83fe1dab79bbac3f275c09dd33120ead403c6ae876e097/lxml-6.0.3-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:a4dc9f81707b9b56888fa7d3a889ac5219724cf0fbecab90ea5b197faf649534", size = 4769200, upload-time = "2026-04-09T14:33:55.166Z" },    { url = "https://files.pythonhosted.org/packages/5f/fa/c7dc493d5d99f9b32f507cca80ac32845f68cd6e6c77b8b328711e0fb176/lxml-6.0.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59ff244cee0270fc4cf5f2ee920d4493ee88d0bcbc6e8465e9ef01439f1785e7", size = 5358843, upload-time = "2026-04-09T14:33:57.481Z" },    { url = "https://files.pythonhosted.org/packages/d5/12/682e1f903784f4f6360ed66064e87f0fb824e9f05f304e8c8621405a2d63/lxml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2a49123cc3209ccad7c4c5a4133bcfcfd4875f30461ea4d0aaa84e6608f712c5", size = 5107019, upload-time = "2026-04-09T14:33:59.457Z" },    { url = "https://files.pythonhosted.org/packages/ff/49/eebd2774692723a54bdac5e3fb4b1ee8f28ec497a609b97eab0011b54fde/lxml-6.0.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:26cdd7c3f4c3b932b28859d0b70966c2ec8b67c106717d6320121002f8f99025", size = 4802429, upload-time = "2026-04-09T14:34:01.545Z" },    { url = "https://files.pythonhosted.org/packages/a3/16/edfbf5fdfaeff145b329e435ed744e9ea9c3b78993ddab1cd6dd2b703f73/lxml-6.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6556b3197bd8a237a16fcd7278d09f094c5777ae36a1435b5e8e488826386d96", size = 5348717, upload-time = "2026-04-09T14:34:03.508Z" },    { url = "https://files.pythonhosted.org/packages/58/e2/bf1277aaef92781b00b0bc0739cddde11da129405cbcb088b577cf2a8369/lxml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87bebd6836e88c0a007f66b89814daf5d7041430eb491c91d1851abc89aa6e93", size = 5307591, upload-time = "2026-04-09T14:34:05.647Z" },    { url = "https://files.pythonhosted.org/packages/b1/4b/ba1ef23b8881217a416250e8882c2630c63c18564ad65dcaa73cfbefaf7c/lxml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:0b012cf200736d90f828b3ab4206857772c61b710f0a98d3814c7994decb6652", size = 3597651, upload-time = "2026-04-09T14:34:07.668Z" },    { url = "https://files.pythonhosted.org/packages/44/10/a1f60ffc05595cfcc4dd5988bea98c44c6b78b89966b565087a4451395c7/lxml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:794b42f0112adfa3f23670aba5bc0ac9c9adfcee699c0df129b0186c812ac3ff", size = 4020048, upload-time = "2026-04-09T14:34:09.467Z" },    { url = "https://files.pythonhosted.org/packages/e8/3b/1333642a8c362e8e23b5f51affc69744db04c59f7ebe3c79fdbcd58c7b56/lxml-6.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:ecdded59dc50c0c28f652a98f69a7ada8bd2377248bf48c4a83c81204eb58b33", size = 3667324, upload-time = "2026-04-09T14:34:11.996Z" },    { url = "https://files.pythonhosted.org/packages/da/ce/8a0b4747bb5dd47fec3443f0506a2a2d4f58946d7176bc3fdcae781ac666/lxml-6.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c8184fdb2259bda1db2db9d6e25f667769afc2531830b4fa29f83f66a7872dea", size = 8524445, upload-time = "2026-04-09T14:34:14.244Z" },    { url = "https://files.pythonhosted.org/packages/e5/14/b74a06da69d212d1ac27e4bcf124e966d1d63c4d23522add86fbcf20324e/lxml-6.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b0f01fb8bdcaf4aa69cf55b2b2f8ef722e4423e1c020e7250dcb89a1d5db38e", size = 4594891, upload-time = "2026-04-09T14:34:17.123Z" },    { url = "https://files.pythonhosted.org/packages/39/9a/364392e9740ddcdba380c5dbb79464956aadf81135344d57153631c8e4a2/lxml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fab00cef83d4f9d76c5e0722346e84bc174b071d68b4f725aeb0bf3877b9e6a6", size = 4922596, upload-time = "2026-04-09T14:34:19.632Z" },    { url = "https://files.pythonhosted.org/packages/24/b6/6e4a53869a8e031dc5ea564a9857f6dd520a05412aea8d1b6565e8b2d43d/lxml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f753db5785ce019d7b25bb75638ef5a42a0e208aa9f19933262134e668ca6af", size = 5067033, upload-time = "2026-04-09T14:34:22.015Z" },    { url = "https://files.pythonhosted.org/packages/2d/cc/12035c0d104fbe64e56e7b2cd9d4942ffa2a1689f093f44de0eef73538ae/lxml-6.0.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27e317e554bc6086a082688ddf137437e5f7f20ffdd736a6f5b4e3ed1ecf1247", size = 5000434, upload-time = "2026-04-09T14:34:23.934Z" },    { url = "https://files.pythonhosted.org/packages/73/37/b9f9b28b542d0e62bb9353753cbec7e321f7394fea10470c6b8e5739b61d/lxml-6.0.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:feb5b9ed7d0510663a78b94f2b417a41c41b42a7bb157ef398ef9d78e6f0fd50", size = 5201705, upload-time = "2026-04-09T14:34:26.328Z" },    { url = "https://files.pythonhosted.org/packages/2d/26/9473de56eb74293c7061ff1a6ac352d5b89c83067f315accd73cf97a3b07/lxml-6.0.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:51014ee2ab2091dcd9cdef92532f0a1addb7c2cc52a2bd70682e441363de5c0d", size = 5329269, upload-time = "2026-04-09T14:34:29.563Z" },    { url = "https://files.pythonhosted.org/packages/a2/a3/502f97b6221e0958da94fde5eb17119f2104694a88126ef82fa189d5d7a4/lxml-6.0.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:abc39c4fb67f029400608f9a3a4a3f351ccb3c061b05fd3ad113e4cfbba8a8ee", size = 4658312, upload-time = "2026-04-09T14:34:31.62Z" },    { url = "https://files.pythonhosted.org/packages/d1/26/935d0297d1c272282e950986f14f044c8e4c34e60a8774bc993d26ddcf32/lxml-6.0.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:38652c280cf50cc5cf955e3d0b691fa6a97046d84407bbae340d8e353f9014ef", size = 5264811, upload-time = "2026-04-09T14:34:33.566Z" },    { url = "https://files.pythonhosted.org/packages/bb/a0/4755420775ded42b4cc9017357ce72ee7cd08fbfb72da3ac7e48fa2326bb/lxml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c3de55b53f69ffa2fcfd897bd8a7e62f0f88a40a8a0c544e171e813f9d4ddbf5", size = 5043997, upload-time = "2026-04-09T14:34:35.506Z" },    { url = "https://files.pythonhosted.org/packages/b7/54/61f21fcf0b5c0f30e58c369aacfa01f5a21ef0f8c9c773c413010c18a705/lxml-6.0.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd4f70e091f2df300396bc9ce36963f90b87611324c2ca750072a6e6375beba2", size = 4711595, upload-time = "2026-04-09T14:34:38.195Z" },    { url = "https://files.pythonhosted.org/packages/ca/9e/b9f73274a7e3819c821033ea9d8e777b297fcbe789765948d8c9d4fb9cfc/lxml-6.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c157bfef4e3b19688eb4da783c5bfabf5a3ac1ac8d317e0906f3feb18d4c89b7", size = 5251294, upload-time = "2026-04-09T14:34:40.461Z" },    { url = "https://files.pythonhosted.org/packages/38/8c/73e463041bad522c348c8a8c908a63c32f80215cff596210bbf24d69b3ee/lxml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8d10a75e4d0a6a9ac2fec2f7ade682f468b51935102c70dab638fa4e94ffcb04", size = 5224927, upload-time = "2026-04-09T14:34:42.986Z" },    { url = "https://files.pythonhosted.org/packages/3f/05/42820ad63897bfd35cb7e591d79e8d21524c9da1520fa156b71d32f6953b/lxml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:d573b81c29e20b1513afa386a544797a99cecde5497e6c77b6dfa4484112c819", size = 3593261, upload-time = "2026-04-09T14:34:44.804Z" },    { url = "https://files.pythonhosted.org/packages/e1/04/43c561e2293ede683f5259ceaccaf24ad6e830631123f197c1db483439ba/lxml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac63a1ef1899ccadace10ac937c41321672771378374c254e931d001448ae372", size = 4023698, upload-time = "2026-04-09T14:34:46.845Z" },    { url = "https://files.pythonhosted.org/packages/8e/5f/13fde57b45a0f88b8c4bb02156fb115e99ad48354029cb522b543f502563/lxml-6.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:10bc4f37c28b4e1b3e901dde66e3a096eb128acf388d5b2962dc2941284293bb", size = 3666947, upload-time = "2026-04-09T14:34:48.675Z" },    { url = "https://files.pythonhosted.org/packages/ac/4c/552571c619edd607432cbbf25e312a5d02859f2a7de421494a644b48451e/lxml-6.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ad6952810349cbfb843fe15e8afc580b2712359ae42b1d2b05d097bd48c4aea4", size = 8570109, upload-time = "2026-04-09T14:34:50.969Z" },    { url = "https://files.pythonhosted.org/packages/ac/49/cf08843a6a923cd1eef40797a31e61424ac257c43634b5c9cff3bee93696/lxml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b81ec1ecac3be8c1ff1e00ca1c1baf8122e87db9000cd2549963847bd5e3b41", size = 4623404, upload-time = "2026-04-09T14:34:53.79Z" },    { url = "https://files.pythonhosted.org/packages/b6/59/ffde0037a781b10c854abdf9e34fbf60d8f375ce8026551982b9f26695cc/lxml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:448e69211e59c39f398990753d15ba49f7218ec128f64ac8012ef16762e509a3", size = 4929662, upload-time = "2026-04-09T14:34:55.763Z" },    { url = "https://files.pythonhosted.org/packages/84/29/c468055e45954a93e1bc043a964d327d6784552d6551dc2364a1f83c53a1/lxml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6289cb9145fbbc5b0e159c9fcd7fc09446dadc6b60b72c4d1012e80c7c727970", size = 5092106, upload-time = "2026-04-09T14:34:58.522Z" },    { url = "https://files.pythonhosted.org/packages/59/a3/8400c79a6defe609e24ce7b580f48d53f08acbf4c998eede0083a89f16f0/lxml-6.0.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b68c29aac4788438b07d768057836de47589c7deaa3ad8dc4af488dfc27be388", size = 5004214, upload-time = "2026-04-09T14:35:00.531Z" },    { url = "https://files.pythonhosted.org/packages/57/b5/797246619cd0831c8d239f91fd4683683abbe7144854c6f33c68a6ea9f42/lxml-6.0.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50293e024afe5e2c25da2be68c8ceca8618912a0701a73f75b488317c8081aa6", size = 5630889, upload-time = "2026-04-09T14:35:02.89Z" },    { url = "https://files.pythonhosted.org/packages/a0/fa/b86302385dc896d02ebb2803e4522a923acaa30e6cb35223492257ee24ab/lxml-6.0.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac65c08ba1bd90f662cb1d5c79f7ae4c53b1c100f0bb6ec5df1f40ac29028a7e", size = 5237728, upload-time = "2026-04-09T14:35:05.827Z" },    { url = "https://files.pythonhosted.org/packages/9b/7d/812c054b7d15f4dfb3a6fc877c2936023fcd8ac8b53807f996c8c60c4f57/lxml-6.0.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:16fbcf06ae534b2fa5bcdc19fcf6abd9df2e74fe8563147d1c5a687a130efed4", size = 5349527, upload-time = "2026-04-09T14:35:08.121Z" },    { url = "https://files.pythonhosted.org/packages/b8/4a/33a572874924809928747cd156b172b04cd19c1ec1d10925fc77dfeb676d/lxml-6.0.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3a0484bd1e84f82766befcbd71cccd7307dacfe08071e4dbc1d9a9b498d321e8", size = 4693177, upload-time = "2026-04-09T14:35:10.4Z" },    { url = "https://files.pythonhosted.org/packages/36/d5/71842813ca0c43718f641e770195e278832f8c01870eaac857a3de34448a/lxml-6.0.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c137f8c8419c3de93e2998131d94628805f148e52b34da6d7533454e4d78bc2a", size = 5243928, upload-time = "2026-04-09T14:35:12.393Z" },    { url = "https://files.pythonhosted.org/packages/da/a7/330845ae467c6086ef35977c335bb252fa11490082335f9ccfd0465bdfb7/lxml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:775266571f7027b1d77f5fce18a247b24f51a4404bdc1b90ec56be9b1e3801b9", size = 5046937, upload-time = "2026-04-09T14:35:15.209Z" },    { url = "https://files.pythonhosted.org/packages/02/3d/b58b0aee0cf7e0b7eb5d24795a129c634c6d07f032d8b902bb0859319d13/lxml-6.0.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:aa18653b795d2c273b8676f7ad2ca916d846d15864e335f746658e4c28eb5168", size = 4776758, upload-time = "2026-04-09T14:35:17.758Z" },    { url = "https://files.pythonhosted.org/packages/8c/4c/f421b50f08c1b724a24c4a778db8888d0a2d948b4dd08b80f4f05a0804ff/lxml-6.0.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbffd22fc8e4d80454efa968b0c93440a00b8b8a817ce0c29d2c6cb5ad324362", size = 5644912, upload-time = "2026-04-09T14:35:20.438Z" },    { url = "https://files.pythonhosted.org/packages/a7/99/eabfedb111ca1f26c8fe890413eabc7e2b0010f075fdf5bceb42737c3894/lxml-6.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7373ede7ccb89e6f6e39c1423b3a4d4ee48035d3b4619a6addced5c8b48d0ecc", size = 5233509, upload-time = "2026-04-09T14:35:23.137Z" },    { url = "https://files.pythonhosted.org/packages/9f/17/050a105ca1154025b68c19901d45292cbdcee6f25bd056c178ad6b55e534/lxml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e759ff1b244725fef428c6b54f3dab4954c293b2d242a5f2e79db5cc3873de51", size = 5260150, upload-time = "2026-04-09T14:35:25.385Z" },    { url = "https://files.pythonhosted.org/packages/61/a0/ed83517d12e9fe00101a21fe08a168fd69f57875d9416353e2a38c401df7/lxml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:f179bae37ad673f57756b59f26833b7922230bef471fdb29492428f152bae8c6", size = 3595160, upload-time = "2026-04-09T14:35:27.519Z" },    { url = "https://files.pythonhosted.org/packages/55/d3/101726831f45951fe3ddd03cffbd2a4ac6261fc63ada399e6f7051d43af6/lxml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:8eeec925ad7f81886d413b3a1f8715551f75543519229a9b35e957771e1826d5", size = 3996108, upload-time = "2026-04-09T14:35:29.608Z" },    { url = "https://files.pythonhosted.org/packages/49/9f/ab1c58ad55bfcd4b55bafd98f19ff24f34315441f13aa787d5220def0702/lxml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:f96bba9a26a064ce9e11099bad12fb08384b64d3acc0acf94bf386ca5cf4f95f", size = 3658906, upload-time = "2026-04-09T14:35:32.451Z" },    { url = "https://files.pythonhosted.org/packages/86/a6/2cdc9c5a634b1b890927f968febc2474fa3eb6fed99db82ea3c008bbbda4/lxml-6.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:83c1d75e9d124ab82a4ddaf59135112f0dc49526b47355e5928ae6126a68e236", size = 8559579, upload-time = "2026-04-09T14:35:35.644Z" },    { url = "https://files.pythonhosted.org/packages/97/3c/adfbcdab17f89f72e069c5df5661c81e0511e3cdb353550f778e9ffaa08e/lxml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b683665d0287308adafc90a5617a51a508d8af8c7040693693bb333b5f4474fe", size = 4617332, upload-time = "2026-04-09T14:35:38.901Z" },    { url = "https://files.pythonhosted.org/packages/5e/d4/ee1a5c734a5ad79024fa85808f3efc18d5733813141e2bb2726a7d9d8bea/lxml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ed31e5852cd938704bc6c7a3822cbf84c7fa00ebfa914a1b4e2392d44f45bdfb", size = 4922821, upload-time = "2026-04-09T14:35:41.521Z" },    { url = "https://files.pythonhosted.org/packages/f1/1f/87efcc0b93ba4f95303ec8f80164f3c50db20a3a5612a285133f9ad6cb7e/lxml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8922a30704a4421d69a19e0499db5861da686c0bccc3a79cf3946e3155cf25f9", size = 5081226, upload-time = "2026-04-09T14:35:44.02Z" },    { url = "https://files.pythonhosted.org/packages/65/8b/fd0fadd9ec8a6ac9d694014ccdb9504e28705abb2e08c9ca23c609020325/lxml-6.0.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a1adb0e220cb8691202ba9d97646a06292657a122df4b92733861d42f7cf4d2", size = 4992884, upload-time = "2026-04-09T14:35:46.769Z" },    { url = "https://files.pythonhosted.org/packages/68/75/2fb0e534225214c6386496b7847195d7297b913cf563c5ccea394afc346b/lxml-6.0.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:821fd53699eb498990c915ba955a392d07246454c9405e6c1d0692362503013d", size = 5613383, upload-time = "2026-04-09T14:35:49.303Z" },    { url = "https://files.pythonhosted.org/packages/54/3a/8f560f8fb2f5f092e18ac7a13a94b77e0e5213fe7c424d12e98393dcc7d8/lxml-6.0.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04b7cedf52e125f86d0d426635e7fbe8e353d4cc272a1757888e3c072424381d", size = 5228398, upload-time = "2026-04-09T14:35:51.611Z" },    { url = "https://files.pythonhosted.org/packages/aa/d5/6bf993c02a0173eb5883ace61958c55c245d3daf7753fb5f931a9691b440/lxml-6.0.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9d98063e6ae0da5084ec46952bb0a5ccb5e2cad168e32b4d65d1ec84e4b4ebd4", size = 5342198, upload-time = "2026-04-09T14:35:54.311Z" },    { url = "https://files.pythonhosted.org/packages/bb/18/637130349ca6aa33b6dc4796732835ede5017a811c5f55763a1c468f7971/lxml-6.0.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:ce01ab3449015358f766a1950b3d818eedf9d4cdec3fa87e4eecaad10c0784db", size = 4699178, upload-time = "2026-04-09T14:35:56.647Z" },    { url = "https://files.pythonhosted.org/packages/bb/19/239daafcc1cfa42b8aa6384509a9fd2cb1aa281679c6e8395adf9ccbc189/lxml-6.0.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d38c25bad123d6ce30bb37931d90a4e8a167cd796eeae9cd16c2bfce52718f8e", size = 5231869, upload-time = "2026-04-09T14:36:00.41Z" },    { url = "https://files.pythonhosted.org/packages/0a/74/db7fcadc651b988502bed00d48acfd8b997ecb5dd52ebcc05f39bf946d9e/lxml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b8e0779780026979f217603385995202f364adc9807bd21210d81b9f562fc4e", size = 5043669, upload-time = "2026-04-09T14:36:02.463Z" },    { url = "https://files.pythonhosted.org/packages/55/99/af795b579182fa04aa87fcb0bd112e22705d982f71eb53874a8d356b4091/lxml-6.0.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8c082ad2398664213a4bb5d133e2eb8bf239220b7d6688f8c8ffa9050057501f", size = 4769745, upload-time = "2026-04-09T14:36:04.716Z" },    { url = "https://files.pythonhosted.org/packages/52/4d/10e652edc55d206188a1b738d1033aad3497886d34cb7f5fc753e67ecb49/lxml-6.0.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfc80c74233fe01157ab550fb12b9d07a2f1fa7c5900cefb484e3bf02e856fbc", size = 5635496, upload-time = "2026-04-09T14:36:06.815Z" },    { url = "https://files.pythonhosted.org/packages/ab/68/95371835ec15bb46feee27b090bcabbe579f4ad04efbef08e2713bcfea16/lxml-6.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c45bdcdc2ca6cf26fddff3faa5de7a2ed7c7f6016b3de80125313a37f972378", size = 5223564, upload-time = "2026-04-09T14:36:09.057Z" },    { url = "https://files.pythonhosted.org/packages/aa/a6/0a9e5b63e8959487551be5d5496bb758ed2424c77ed7b25a9b8aae3b60c6/lxml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99457524afd384c330dc51e527976653d543ccadfa815d9f2d92c5911626e536", size = 5250124, upload-time = "2026-04-09T14:36:11.337Z" },    { url = "https://files.pythonhosted.org/packages/d9/80/de3d3a790edf6d026c829fe8ccf54845058f57f8bb788e420c3b227eecef/lxml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:c8e3b8a54e65393ce1d5c7d9753fe756f0d96089e7163b20ddec3e5bb56a963e", size = 3596004, upload-time = "2026-04-09T14:36:13.446Z" },    { url = "https://files.pythonhosted.org/packages/9f/cf/43c9a5926060e39d99593921f37d7e88f129bc32ab6266b8460483abd613/lxml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:724b26a38cef98d6869d00a33cb66083bee967598e44f6a8e53f1dd283c851b0", size = 3994750, upload-time = "2026-04-09T14:36:15.686Z" },    { url = "https://files.pythonhosted.org/packages/e5/d3/b224dbc282bfef52d2e05645e405b5ed89c6391144dc09864229fe9ce88c/lxml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:f27373113fda6621e4201f529908a24c8a190c2af355aed4711dadca44db4673", size = 3657620, upload-time = "2026-04-09T14:36:17.952Z" },    { url = "https://files.pythonhosted.org/packages/d3/40/b637359bacf3813f1174d15b08516020ba5beb355e04377105d561e6e00a/lxml-6.0.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8c08926678852a233bf1ef645c4d683d56107f814482f8f41b21ef2c7659790e", size = 8575318, upload-time = "2026-04-09T14:36:20.608Z" },    { url = "https://files.pythonhosted.org/packages/7f/91/d5286a45202ed91f1e428e68c6e1c11bcb2b42715c48424871fc73485b05/lxml-6.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ce76d113a7c3bf42761ec1de7ca615b0cbf9d8ae478eb1d6c20111d9c9fc098", size = 4623084, upload-time = "2026-04-09T14:36:24.015Z" },    { url = "https://files.pythonhosted.org/packages/8a/5f/7ea1af571ee13ed1e5fba007fd83cd0794723ca76a51eed0ef9513363b1f/lxml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83eca62141314d641ebe8089ffa532bbf572ea07dd6255b58c40130d06bb2509", size = 4948797, upload-time = "2026-04-09T14:36:26.662Z" },    { url = "https://files.pythonhosted.org/packages/82/be/3a9b8d787d9877cbe17e02ef5af2523bd14ecc177ce308397c485c56fe18/lxml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8781d812bb8efd47c35651639da38980383ff0d0c1f3269ade23e3a90799079", size = 5085983, upload-time = "2026-04-09T14:36:29.486Z" },    { url = "https://files.pythonhosted.org/packages/c4/2b/645abaef837b11414c81513c31b308a001fb8cd370f665c3ebc854be5ba5/lxml-6.0.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b079e81aa3a31b523a224b0dd46da4f56e1b1e248eef9a599e5c885c788813", size = 5031039, upload-time = "2026-04-09T14:36:31.735Z" },    { url = "https://files.pythonhosted.org/packages/3b/4f/561f30b77e9edbb373e2b6b7203a7d6ab219c495abca219536c66f3a44b2/lxml-6.0.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c055bafdcb53e7f9f75e22c009cd183dd410475e21c296d599531d7f03d1bf5", size = 5646718, upload-time = "2026-04-09T14:36:34.127Z" },    { url = "https://files.pythonhosted.org/packages/d7/ba/2a72e673d109b563c2ab77097f2f4ca64e2927d2f04836ba07aaabe1da0e/lxml-6.0.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f1594a183cee73f9a1dbfd35871c4e04b461f47eeb9bcf80f7d7856b1b136d", size = 5239360, upload-time = "2026-04-09T14:36:37.195Z" },    { url = "https://files.pythonhosted.org/packages/52/98/4e5a4ef87d846af90cc9c1ee2f8af2af34c221e620aad317b3a535361b93/lxml-6.0.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:a6380c5035598e4665272ad3fc86c96ddb2a220d4059cce5ba4b660f78346ad9", size = 5351233, upload-time = "2026-04-09T14:36:39.634Z" },    { url = "https://files.pythonhosted.org/packages/cb/b8/cff0af5fe48ede6b1949dc2e14171470c0c68a15789037c1fed90602b89d/lxml-6.0.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:143ac903fb6c9be6da613390825c8e8bb8c8d71517d43882031f6b9bc89770ef", size = 4696677, upload-time = "2026-04-09T14:36:42.037Z" },    { url = "https://files.pythonhosted.org/packages/0c/6e/0b2a918fb15c30b00ff112df16c548df011db37b58d764bd17f47db74905/lxml-6.0.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4fff7d77f440378cd841e340398edf5dbefee334816efbf521bb6e31651e54e", size = 5250503, upload-time = "2026-04-09T14:36:44.417Z" },    { url = "https://files.pythonhosted.org/packages/57/1b/4697918f9d4c2e643e2c59cedb37c2f3a9f76fb1217d767f6dff476813d8/lxml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:631567ffc3ddb989ccdcd28f6b9fa5aab1ec7fc0e99fe65572b006a6aad347e2", size = 5084563, upload-time = "2026-04-09T14:36:46.762Z" },    { url = "https://files.pythonhosted.org/packages/7b/8c/d7ec96246f0632773912c6556288d3b6bb6580f3a967441ca4636ddc3f73/lxml-6.0.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:38acf7171535ffa7fff1fcec8b82ebd4e55cd02e581efe776928108421accaa1", size = 4737407, upload-time = "2026-04-09T14:36:49.826Z" },    { url = "https://files.pythonhosted.org/packages/d2/0c/603e35bf77aeb28c972f39eece35e7c0f6579ff33a7bed095cc2f7f942d9/lxml-6.0.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:06b9f3ac459b4565bbaa97aa5512aa7f9a1188c662f0108364f288f6daf35773", size = 5670919, upload-time = "2026-04-09T14:36:52.231Z" },    { url = "https://files.pythonhosted.org/packages/92/08/6d3f188e6705cf0bfd8b5788055c7381bb3ffa786dfba9fa0b0ed5778506/lxml-6.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2773dbe2cedee81f2769bd5d24ceb4037706cf032e1703513dd0e9476cd9375f", size = 5237771, upload-time = "2026-04-09T14:36:55.286Z" },    { url = "https://files.pythonhosted.org/packages/f1/4c/01639533b90e9ff622909c113df2ab2dbdd1d78540eb153d13b66a9c96ba/lxml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:30c437d8bb9a9a9edff27e85b694342e47a26a6abc249abe00584a4824f9d80d", size = 5263862, upload-time = "2026-04-09T14:36:58.247Z" },    { url = "https://files.pythonhosted.org/packages/06/0e/bd1157d7b09d1f5e1d580c124203cee656130a3f8908365760a593b21daf/lxml-6.0.3-cp314-cp314-win32.whl", hash = "sha256:1b60a3a1205f869bd47874787c792087174453b1a869db4837bf5b3ff92be017", size = 3656378, upload-time = "2026-04-09T14:37:47.74Z" },    { url = "https://files.pythonhosted.org/packages/c5/cc/d50cbce8cd5687670868bea33bbeefa0866c5e5d02c5e11c4a04c79fc45e/lxml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:5b6913a68d98c58c673667c864500ba31bc9b0f462effac98914e9a92ebacd2e", size = 4062518, upload-time = "2026-04-09T14:37:49.911Z" },    { url = "https://files.pythonhosted.org/packages/fd/c7/ece11a1e51390502894838aa384e9f98af7bef4d6806a927197153a16972/lxml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:1b36a3c73f2a6d9c2bfae78089ca7aedae5c2ee5fd5214a15f00b2f89e558ba7", size = 3741064, upload-time = "2026-04-09T14:37:52.185Z" },    { url = "https://files.pythonhosted.org/packages/2c/ae/918d7f89635fb6456cd732c12246c0e504dd9c49e8006f3593c9ecdb90ff/lxml-6.0.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:239e9a6be3a79c03ec200d26f7bb17a4414704a208059e20050bf161e2d8848a", size = 8826590, upload-time = "2026-04-09T14:37:00.862Z" },    { url = "https://files.pythonhosted.org/packages/07/cf/bda0ae583758704719976b9ea69c8b089fa5f92e49683e517386539b21cf/lxml-6.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16e5cbaa1a6351f2abefa4072e9aac1f09103b47fe7ab4496d54e5995b065162", size = 4735028, upload-time = "2026-04-09T14:37:03.602Z" },    { url = "https://files.pythonhosted.org/packages/2f/0e/3bfb18778c6f73c7ead2d49a256501fa3052888b899826f5d1df1fbdf83b/lxml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89f8746c206d8cf2c167221831645d6cc2b24464afd9c428a5eb3fd34c584eb1", size = 4969184, upload-time = "2026-04-09T14:37:05.914Z" },    { url = "https://files.pythonhosted.org/packages/29/e6/796c77751a682d6d1bb9aa3fe43851b41a21b0377100e246a4a83a81d668/lxml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d559a84b2fd583e5bcf8ec4af1ec895f98811684d5fbd6524ea31a04f92d4ad", size = 5103548, upload-time = "2026-04-09T14:37:08.605Z" },    { url = "https://files.pythonhosted.org/packages/f9/5e/a02aee214f657f29d4690d88161de8ffb8f1b5139e792bae313b9479e317/lxml-6.0.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7966fbce2d18fde579d5593933d36ad98cc7c8dc7f2b1916d127057ce0415062", size = 5027775, upload-time = "2026-04-09T14:37:11.283Z" },    { url = "https://files.pythonhosted.org/packages/20/e5/65dd25f2c366879d696d1c720af9a96fa0969d2d135a27b6140222fc6f68/lxml-6.0.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1f258e6aa0e6eda2c1199f5582c062c96c7d4a28d96d0c4daa79e39b3f2a764", size = 5595348, upload-time = "2026-04-09T14:37:13.618Z" },    { url = "https://files.pythonhosted.org/packages/f7/1f/2f0e80d7fd2ad9755d771af4ad46ea14bf871bc5a1d2d365a3f948940ddf/lxml-6.0.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:738aef404c862d2c3cd951364ee7175c9d50e8290f5726611c4208c0fba8d186", size = 5224217, upload-time = "2026-04-09T14:37:16.519Z" },    { url = "https://files.pythonhosted.org/packages/3b/28/e1aaeee7d6a4c9f24a3e4535a4e19ce64b99eefbe7437d325b61623b1817/lxml-6.0.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:5c35e5c3ed300990a46a144d3514465713f812b35dacfa83e928c60db7c90af7", size = 5312245, upload-time = "2026-04-09T14:37:19.387Z" },    { url = "https://files.pythonhosted.org/packages/0a/ac/9633cb919124473e03c62862b0494bf0e1705f902fbd9627be4f648bddfb/lxml-6.0.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:4ff774b43712b0cf40d9888a5494ca39aefe990c946511cc947b9fddcf74a29b", size = 4637952, upload-time = "2026-04-09T14:37:21.648Z" },    { url = "https://files.pythonhosted.org/packages/50/aa/135baeea457d41989bafa78e437fe3a370c793aab0d8fb3da73ccae10095/lxml-6.0.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d20af2784c763928d0d0879cbc5a3739e4d81eefa0d68962d3478bff4c13e644", size = 5232782, upload-time = "2026-04-09T14:37:24.6Z" },    { url = "https://files.pythonhosted.org/packages/0e/77/d05183ac8440cbc4c6fa386edb7ba9718bee4f097e58485b1cd1f9479d56/lxml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fdb7786ebefaa0dad0d399dfeaf146b370a14591af2f3aea59e06f931a426678", size = 5083889, upload-time = "2026-04-09T14:37:27.432Z" },    { url = "https://files.pythonhosted.org/packages/6d/58/e9fda8fb82775491ad0290c7b17252f944b6c3a6974cd820d65910690351/lxml-6.0.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c71a387ea133481e725079cff22de45593bf0b834824de22829365ab1d2386c9", size = 4758658, upload-time = "2026-04-09T14:37:29.81Z" },    { url = "https://files.pythonhosted.org/packages/8b/32/4aae9f004f79f9d200efd8343809cfe46077f8e5bd58f08708c320a20fcd/lxml-6.0.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:841b89fc3d910d61c7c267db6bb7dc3a8b3dac240edb66220fcdf96fe70a0552", size = 5619494, upload-time = "2026-04-09T14:37:33.482Z" },    { url = "https://files.pythonhosted.org/packages/f9/49/407fa9e3c91e7c6d0762eaeedd50d4695bcd26db817e933ca689eb1f3df4/lxml-6.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ac2d6cdafa29672d6a604c641bf67ace3fd0735ec6885501a94943379219ddbf", size = 5228386, upload-time = "2026-04-09T14:37:36.058Z" },    { url = "https://files.pythonhosted.org/packages/99/92/39982f818acbb1dd67dd5d20c2a06bcb9f1f3b9a8ff0021e367904f82417/lxml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:609bf136a7339aeca2bd4268c7cd190f33d13118975fe9964eda8e5138f42802", size = 5247973, upload-time = "2026-04-09T14:37:38.836Z" },    { url = "https://files.pythonhosted.org/packages/66/68/fcdbb78c8cda81a86e17b31abf103b7e474e474a09fb291a99e7a9b43eb8/lxml-6.0.3-cp314-cp314t-win32.whl", hash = "sha256:bf98f5f87f6484302e7cce4e2ca5af43562902852063d916c3e2f1c115fdce60", size = 3896249, upload-time = "2026-04-09T14:37:41.068Z" },    { url = "https://files.pythonhosted.org/packages/88/fb/6292681ac4a4223b700569ce98f71662cb07c5a3ade4f346f5f0d5c574cf/lxml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d3d65e511e4e656ec67b472110f7a72cbf8547ca15f76fe74cffa4e97412a064", size = 4391091, upload-time = "2026-04-09T14:37:43.357Z" },    { url = "https://files.pythonhosted.org/packages/99/39/a0f486360a6f1b36fd2f5eb62d037652bef503d82b6f853aee6664cdfcac/lxml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:cbc7ce67f85b92db97c92219985432be84dc1ba9a028e68c6933e89551234df2", size = 3816374, upload-time = "2026-04-09T14:37:45.532Z" },    { url = "https://files.pythonhosted.org/packages/a5/7c/3889981b55e83af1a710b2b54d40d5a9c7a2f7eab2e00cba6ba608fbdd22/lxml-6.0.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:093786037b934ef4747b0e8a0e1599fe7df7dd8246e7f07d43bba1c4c8bd7b84", size = 3929454, upload-time = "2026-04-09T14:38:54.873Z" },    { url = "https://files.pythonhosted.org/packages/0b/29/a88dfb805c882b4fc81ef35d342629715a482037a0acd78ea8114e115d76/lxml-6.0.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6364aa77b13e04459df6a9d2b806465287e7540955527e75ebd5fda48532913d", size = 4209854, upload-time = "2026-04-09T14:38:57.541Z" },    { url = "https://files.pythonhosted.org/packages/ca/01/44e71ace8c72bbb9aeb38551a4d314508133da88daf0dd9120a648af74ce/lxml-6.0.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:955550c78afb2be47755bd1b8153724292a5b539cf3f21665b310c145d08e6f8", size = 4317247, upload-time = "2026-04-09T14:38:59.977Z" },    { url = "https://files.pythonhosted.org/packages/18/1a/ec02aafa56ff7675873e8fd4b6c7747aceaae037767434359e75d0b1075b/lxml-6.0.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9a79144a8051bc5fbb223fac895b87eb67b361f27b00c2ed4a07ee34246b90", size = 4250372, upload-time = "2026-04-09T14:39:02.289Z" },    { url = "https://files.pythonhosted.org/packages/35/13/94acd22f85e34e22eb984b4ac3db4c1b0c1e3daa0433dac5053fd26954d8/lxml-6.0.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8243937d4673b46da90b4f5ea2627fd26842225e62e885828fdb8133aa1f7b32", size = 4401010, upload-time = "2026-04-09T14:39:04.598Z" },    { url = "https://files.pythonhosted.org/packages/28/7a/b3e8ed85413a4bd5c4850dfbd1eb18be7428127be0986f2a679d9d6098ad/lxml-6.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5892d2ef99449ebd8e30544af5bc61fd9c30e9e989093a10589766422f6c5e1a", size = 3507669, upload-time = "2026-04-09T14:39:06.873Z" },][[package]]name = "mccabe"version = "0.7.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },][[package]]name = "mypy-extensions"version = "1.1.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },][[package]]name = "packaging"version = "26.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }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 = "pathspec"version = "1.0.4"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },][[package]]name = "platformdirs"version = "4.9.6"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },][[package]]name = "pycodestyle"version = "2.14.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" },][[package]]name = "pyflakes"version = "3.4.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" },][[package]]name = "pytokens"version = "0.4.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" },    { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" },    { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" },    { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" },    { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" },    { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" },    { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" },    { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" },    { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" },    { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" },    { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },    { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },    { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },    { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },    { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },    { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },    { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },    { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },    { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },    { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },    { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },    { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },    { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },    { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },    { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },    { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },    { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },    { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },    { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },    { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },    { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },][[package]]name = "requests"version = "2.33.1"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "certifi" },    { name = "charset-normalizer" },    { name = "idna" },    { name = "urllib3" },]sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },][[package]]name = "soupsieve"version = "2.8.3"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },][[package]]name = "sqlparse"version = "0.5.5"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },][[package]]name = "status"version = "0.0.1"source = { virtual = "." }dependencies = [    { name = "beautifulsoup4" },    { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },    { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },    { name = "dnspython" },    { name = "gunicorn" },    { name = "lxml" },    { name = "requests" },    { name = "tzdata" },    { name = "uvicorn" },    { name = "whitenoise" },][package.dev-dependencies]dev = [    { name = "black" },    { name = "flake8" },    { name = "isort" },][package.metadata]requires-dist = [    { name = "beautifulsoup4" },    { name = "django" },    { name = "dnspython" },    { name = "gunicorn" },    { name = "lxml" },    { name = "requests" },    { name = "tzdata" },    { name = "uvicorn" },    { name = "whitenoise" },][package.metadata.requires-dev]dev = [    { name = "black" },    { name = "flake8" },    { name = "isort" },][[package]]name = "tomli"version = "2.4.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },    { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },    { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },    { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },    { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },    { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },    { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },    { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },    { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },    { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },    { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },    { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },    { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },    { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },    { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },    { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },    { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },    { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },    { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },    { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },    { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },    { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },    { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },    { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },    { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },    { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },    { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },    { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },    { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },    { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },    { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },    { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },    { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },    { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },    { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },    { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },    { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },    { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },    { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },    { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },    { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },    { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },    { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },    { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },    { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },    { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },][[package]]name = "typing-extensions"version = "4.15.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },][[package]]name = "tzdata"version = "2026.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },][[package]]name = "urllib3"version = "2.6.3"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },][[package]]name = "uvicorn"version = "0.44.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "click" },    { name = "h11" },    { name = "typing-extensions", marker = "python_full_version < '3.11'" },]sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },][[package]]name = "whitenoise"version = "6.12.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },]
deleted vite.config.js
@@ -1,54 +0,0 @@import { resolve } from "path";import { defineConfig } from "vite";// datamaps pulls in d3 v3, whose top-level IIFE reads globals off `this`// (this.document, this.navigator, etc.). Under ESM/strict `this` is undefined,// crashing at load. Bind the IIFE's `this` to globalThis so those reads work.//// Datamaps itself also has a sloppy-mode bug: `hoverover = ...` is written in// one function without ever being declared, relying on implicit global in// non-strict mode. Under ESM/strict that throws ReferenceError. Hoist a// declaration into the top-level IIFE so the bare assignment resolves.const fixDatamapsStrictMode = {  name: "fix-datamaps-strict-mode",  transform(code, id) {    if (id.includes("datamaps/node_modules/d3/d3.js")) {      return code.replace(/\}\(\);\s*$/, "}.call(globalThis);\n");    }    if (id.match(/datamaps\/dist\/datamaps\.[^/]+\.js$/)) {      return code.replace("var svg;", "var svg, hoverover;");    }  },};export default defineConfig({  plugins: [fixDatamapsStrictMode],  base: "/static/",  build: {    outDir: resolve(__dirname, "status/static"),    emptyOutDir: true,    rollupOptions: {      input: {        base: resolve(__dirname, "status/static_src/index.js"),        pages: resolve(__dirname, "pages/static_src/index.js"),        properties: resolve(__dirname, "properties/static_src/index.js"),      },      output: {        entryFileNames: "[name].js",        assetFileNames: (assetInfo) => {          if (/\.(png|jpg|gif|svg|webp)$/.test(assetInfo.name)) {            return "images/[name][extname]";          }          return "[name][extname]";        },      },    },  },  css: {    preprocessorOptions: {      scss: {        quietDeps: true,      },    },  },});