10 Commits

Author SHA1 Message Date
cc99fbe5de feat: Add README
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m11s
Build Docker / BuildImage (push) Successful in 1m15s
2026-03-11 14:26:03 +11:00
89a7a745a8 fix: Typo when covernant ends with 'E'
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 2m36s
Build Docker / BuildImage (push) Successful in 3m43s
2026-03-11 14:15:20 +11:00
06280bc806 Merge pull request 'Add new OG images' (#2) from feat/ogimages into main
All checks were successful
Build Docker / BuildImage (push) Successful in 45s
Check Code Quality / RuffCheck (push) Successful in 50s
Reviewed-on: #2
2026-02-25 23:20:02 +11:00
867df2f8c9 feat: Add cost to name
All checks were successful
Build Docker / BuildImage (push) Successful in 42s
Check Code Quality / RuffCheck (push) Successful in 57s
2026-02-25 23:16:44 +11:00
9beb3f2918 feat: Add info for address
All checks were successful
Build Docker / BuildImage (push) Successful in 49s
Check Code Quality / RuffCheck (push) Successful in 54s
2026-02-25 23:12:48 +11:00
47e8c24219 feat: Cleanup some text
All checks were successful
Build Docker / BuildImage (push) Successful in 48s
Check Code Quality / RuffCheck (push) Successful in 51s
2026-02-25 23:07:53 +11:00
54ce15baa8 feat: Add custom fonts
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 1m0s
Build Docker / BuildImage (push) Successful in 1m14s
2026-02-25 23:02:49 +11:00
612ead6e63 feat: Move from svg to png
All checks were successful
Check Code Quality / RuffCheck (push) Successful in 45s
Build Docker / BuildImage (push) Successful in 49s
2026-02-25 22:55:41 +11:00
25506da02c feat: Cleanup transaction image
All checks were successful
Build Docker / BuildImage (push) Successful in 38s
Check Code Quality / RuffCheck (push) Successful in 45s
2026-02-25 22:41:21 +11:00
2df0001352 feat: Add commas to block number OG image 2026-02-25 22:31:33 +11:00
9 changed files with 689 additions and 64 deletions

163
README.md
View File

@@ -1,19 +1,154 @@
# Python Flask Webserver Template # Fire Explorer
Python3 website template including git actions Fire Explorer is a Flask-based Handshake blockchain explorer UI and API.
It provides searchable views for blocks, transactions, addresses, and names, plus utility endpoints for namehash resolution, HIP-02 lookup, and covenant display rendering.
# Development ## Features
1. Install requirements
```bash - Search Handshake data by block, header, transaction, address, and name.
python3 -m pip install -r requirements.txt - Route-based deep links such as `/tx/<hash>` and `/block/<height-or-hash>`.
``` - Dynamic Open Graph card generation at `/og-image` and `/og-image.png`.
2. Run the dev server - HIP-02 and WALLET DNS record resolution endpoint.
```bash - SQLite namehash cache to reduce repeated upstream lookups.
python3 server.py - Single-container deployment flow with Docker.
## Tech Stack
- Python 3.13+
- Flask
- Gunicorn (started programmatically via `main.py`)
- SQLite (local cache DB)
- Pillow (OG image rendering)
- Requests and DNS tooling for Handshake integrations
## Project Layout
```text
.
|- main.py # Gunicorn entrypoint
|- server.py # Flask app, routes, API, OG generation
|- tools.py # HIP-02 and DNS helper functions
|- templates/ # HTML templates + static assets
|- pyproject.toml # Project metadata and dependencies (uv)
|- requirements.txt # Exported lock-style requirements
|- Dockerfile # Container build and runtime setup
``` ```
# Production ## Prerequisites
Run using the main.py file
- Python 3.13 or newer
- `uv` (recommended) or `pip`
- OpenSSL CLI available in PATH (required for HIP-02 certificate checks in `tools.py`)
## Local Development
### Option 1: `uv` (recommended)
```bash ```bash
python3 main.py uv sync
``` uv run main.py
```
The app binds to `0.0.0.0:5000` when started through `main.py`.
### Option 2: `pip`
```bash
python -m venv .venv
# Linux/macOS
source .venv/bin/activate
# Windows (PowerShell)
# .venv\Scripts\Activate.ps1
pip install -r requirements.txt
python main.py
```
### Flask debug mode
```bash
python server.py
```
This runs Flask's built-in development server at `127.0.0.1:5000`.
## Environment Variables
Configure via shell variables or a `.env` file.
- `WORKERS`
: Gunicorn worker count. Default: `1`.
- `THREADS`
: Gunicorn thread count per worker. Default: `2`.
- `DATABASE_PATH`
: SQLite file path for the namehash cache. Default: `fireexplorer.db`.
- `HSD_API_BASE`
: Upstream Handshake API base URL. Default: `https://hsd.hns.au/api/v1`.
- `PUBLIC_BASE_URL`
: Optional canonical public URL used for OG metadata generation.
## Docker
Build:
```bash
docker build -t fireexplorer .
```
Run:
```bash
docker run --rm -p 5000:5000 -v fireexplorer-data:/data fireexplorer
```
Container notes:
- The image runs `uv run main.py`.
- The default database path is set to `/data/fireexplorer.db`.
- A volume is declared at `/data` for persistence.
## HTTP Routes
### UI routes
- `/`
- `/block/<block_id>`
- `/header/<block_id>`
- `/tx/<tx_hash>`
- `/address/<address>`
- `/name/<name>`
- `/coin/<coin_hash>/<index>`
- `/og-image`
- `/og-image.png`
### API routes
- `GET /api/v1/status`
: Service health and local cache stats.
- `GET /api/v1/namehash/<namehash>`
: Resolves namehash to name (cached in SQLite).
- `GET /api/v1/hip02/<domain>`
: Resolves HIP-02, falls back to WALLET TXT-style record lookup.
- `POST /api/v1/covenant`
: Accepts one covenant object or an array and returns display-ready covenant labels, including cached name resolution.
## Development Tooling
Pre-commit config includes:
- `uv-lock`
- `uv-export`
- `ruff-check`
- `ruff-format`
Run hooks locally:
```bash
pre-commit run --all-files
```
## Notes
- Frontend requests blockchain data from `https://hsd.hns.au/api/v1/`.
- Namehash mappings are cached locally in SQLite to improve repeated lookups.
- HIP-02 resolution requires DNS-over-HTTPS access and OpenSSL availability.

View File

@@ -8,6 +8,7 @@ dependencies = [
"cryptography>=46.0.3", "cryptography>=46.0.3",
"flask>=3.1.2", "flask>=3.1.2",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"pillow>=11.3.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"requests-doh>=1.0.0", "requests-doh>=1.0.0",
] ]

View File

@@ -271,6 +271,59 @@ packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via gunicorn # via gunicorn
pillow==12.1.1 \
--hash=sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9 \
--hash=sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da \
--hash=sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f \
--hash=sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642 \
--hash=sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850 \
--hash=sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9 \
--hash=sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8 \
--hash=sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd \
--hash=sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c \
--hash=sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1 \
--hash=sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af \
--hash=sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60 \
--hash=sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986 \
--hash=sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13 \
--hash=sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717 \
--hash=sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b \
--hash=sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15 \
--hash=sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a \
--hash=sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb \
--hash=sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e \
--hash=sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a \
--hash=sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f \
--hash=sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce \
--hash=sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc \
--hash=sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586 \
--hash=sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f \
--hash=sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8 \
--hash=sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60 \
--hash=sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334 \
--hash=sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524 \
--hash=sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf \
--hash=sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2 \
--hash=sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7 \
--hash=sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4 \
--hash=sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b \
--hash=sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c \
--hash=sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e \
--hash=sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029 \
--hash=sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f \
--hash=sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f \
--hash=sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8 \
--hash=sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3 \
--hash=sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e \
--hash=sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36 \
--hash=sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f \
--hash=sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f \
--hash=sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6 \
--hash=sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20 \
--hash=sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202 \
--hash=sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0 \
--hash=sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289
# via fireexplorer
platformdirs==4.5.0 \ platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \ --hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3 --hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3

475
server.py
View File

@@ -13,17 +13,23 @@ import requests
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from io import BytesIO
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
import importlib
import dotenv import dotenv
from tools import hip2, wallet_txt from tools import hip2, wallet_txt
from werkzeug.middleware.proxy_fix import ProxyFix
dotenv.load_dotenv() dotenv.load_dotenv()
app = Flask(__name__) app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
DATABASE = os.getenv("DATABASE_PATH", "fireexplorer.db") DATABASE = os.getenv("DATABASE_PATH", "fireexplorer.db")
HSD_API_BASE = os.getenv("HSD_API_BASE", "https://hsd.hns.au/api/v1") HSD_API_BASE = os.getenv("HSD_API_BASE", "https://hsd.hns.au/api/v1")
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/") PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OG_FONT_DIR = os.path.join(BASE_DIR, "templates", "assets", "fonts")
def get_db(): def get_db():
@@ -107,9 +113,110 @@ def _fetch_explorer_json(endpoint: str):
def _get_public_base_url() -> str: def _get_public_base_url() -> str:
if PUBLIC_BASE_URL: if PUBLIC_BASE_URL:
return PUBLIC_BASE_URL return PUBLIC_BASE_URL
forwarded_proto = request.headers.get("X-Forwarded-Proto")
forwarded_host = request.headers.get("X-Forwarded-Host")
if forwarded_proto and forwarded_host:
proto = forwarded_proto.split(",")[0].strip()
host = forwarded_host.split(",")[0].strip()
return f"{proto}://{host}"
return request.url_root.rstrip("/") return request.url_root.rstrip("/")
def _resolve_namehash(namehash: str) -> str | None:
try:
db = get_db()
cur = db.execute("SELECT name FROM names WHERE namehash = ?", (namehash,))
row = cur.fetchone()
if row and row["name"]:
return row["name"]
except Exception:
pass
try:
req = requests.get(f"{HSD_API_BASE}/namehash/{namehash}", timeout=3)
if req.status_code == 200:
name = req.json().get("result")
if name:
try:
db = get_db()
db.execute(
"INSERT OR REPLACE INTO names (namehash, name) VALUES (?, ?)",
(namehash, name),
)
db.commit()
except Exception:
pass
return name
except Exception:
pass
return None
def _summarize_transaction(tx: dict) -> str:
outputs = tx.get("outputs", []) if isinstance(tx, dict) else []
inputs = tx.get("inputs", []) if isinstance(tx, dict) else []
if not outputs:
return "Transaction details available"
action_counts: dict[str, int] = {}
for output in outputs:
covenant = output.get("covenant") or {}
action = (covenant.get("action") or "NONE").upper()
action_counts[action] = action_counts.get(action, 0) + 1
finalize_count = action_counts.get("FINALIZE", 0)
if finalize_count > 1:
return f"Finalized {finalize_count:,} domains"
# Covenant-aware summary for domain operations (e.g., BID)
for output in outputs:
covenant = output.get("covenant") or {}
action = (covenant.get("action") or "").upper()
if action and action != "NONE":
value = _safe_hns_value(output.get("value"))
items = covenant.get("items") or []
name = None
if items and isinstance(items[0], str):
name = _resolve_namehash(items[0])
if action == "BID":
if name:
return f"Bid {value} on {name}"
return f"Bid {value} on a domain"
if name:
if action.endswith("E"):
return f"{action.title()}d {name} • Value {value}"
else:
return f"{action.title()}ed {name} • Value {value}"
return f"{action.title()} covenant • Value {value}"
# Detect coinbase transaction
if inputs:
prevout = inputs[0].get("prevout") or {}
if (
prevout.get("hash")
== "0000000000000000000000000000000000000000000000000000000000000000"
and prevout.get("index") == 4294967295
):
reward = _safe_hns_value(sum((o.get("value") or 0) for o in outputs))
return f"Coinbase reward {reward}"
total_output_value = sum((o.get("value") or 0) for o in outputs)
total_output_hns = _safe_hns_value(total_output_value)
recipient_addresses = {
o.get("address") for o in outputs if o.get("address") and o.get("value", 0) > 0
}
recipient_count = len(recipient_addresses)
if recipient_count <= 1:
return f"Sent {total_output_hns}"
return f"Sent a total of {total_output_hns} to {recipient_count} addresses"
def _build_og_context( def _build_og_context(
search_type: str | None = None, search_value: str | None = None search_type: str | None = None, search_value: str | None = None
) -> dict: ) -> dict:
@@ -177,18 +284,32 @@ def _build_og_context(
elif search_type == "tx": elif search_type == "tx":
tx, err = _fetch_explorer_json(f"tx/{search_value}") tx, err = _fetch_explorer_json(f"tx/{search_value}")
if not err and tx: if not err and tx:
inputs = len(tx.get("inputs", [])) tx_summary = _summarize_transaction(tx)
outputs = len(tx.get("outputs", []))
fee = _safe_hns_value(tx.get("fee")) fee = _safe_hns_value(tx.get("fee"))
subtitle = f"{inputs} inputs • {outputs} outputs • Fee {fee}" subtitle = f"{tx_summary} • Fee {fee}"
og["title"] = _truncate("Transaction | Fire Explorer", 100) og["title"] = _truncate("Transaction | Fire Explorer", 100)
og["description"] = _truncate(subtitle, 200) og["description"] = _truncate(subtitle, 200)
og["image_query"]["subtitle"] = subtitle og["image_query"]["subtitle"] = subtitle
elif search_type == "address": elif search_type == "address":
txs, err = _fetch_explorer_json(f"tx/address/{search_value}") txs, tx_err = _fetch_explorer_json(f"tx/address/{search_value}")
if not err and isinstance(txs, list): coins, coin_err = _fetch_explorer_json(f"coin/address/{search_value}")
subtitle = f"{len(txs)} related transactions found"
tx_count = len(txs) if isinstance(txs, list) else None
balance_value = (
sum((coin.get("value") or 0) for coin in coins)
if isinstance(coins, list)
else None
)
if tx_count is not None or balance_value is not None:
parts = []
if balance_value is not None:
parts.append(f"Balance {_safe_hns_value(balance_value)}")
if tx_count is not None:
parts.append(f"{_format_int(tx_count, '0')} related transactions")
subtitle = "".join(parts)
og["title"] = _truncate("Address | Fire Explorer", 100) og["title"] = _truncate("Address | Fire Explorer", 100)
og["description"] = _truncate(subtitle, 200) og["description"] = _truncate(subtitle, 200)
og["image_query"]["subtitle"] = subtitle og["image_query"]["subtitle"] = subtitle
@@ -199,7 +320,11 @@ def _build_og_context(
info = name.get("info", name) info = name.get("info", name)
state = info.get("state", "Unknown") state = info.get("state", "Unknown")
owner = info.get("owner", {}).get("hash", "Unknown") owner = info.get("owner", {}).get("hash", "Unknown")
subtitle = f"State: {state} • Owner: {_truncate(owner, 26)}" value = info.get("value")
cost_text = _safe_hns_value(value) if value is not None else "Unknown"
subtitle = (
f"State: {state} • Cost: {cost_text} • Owner: {_truncate(owner, 18)}"
)
og["title"] = _truncate(f"Name {search_value} | Fire Explorer", 100) og["title"] = _truncate(f"Name {search_value} | Fire Explorer", 100)
og["description"] = _truncate(subtitle, 200) og["description"] = _truncate(subtitle, 200)
og["image_query"]["subtitle"] = subtitle og["image_query"]["subtitle"] = subtitle
@@ -213,7 +338,7 @@ def _render_index(search_type: str | None = None, search_value: str | None = Non
og_context = _build_og_context(search_type, search_value) og_context = _build_og_context(search_type, search_value)
image_query = og_context["image_query"] image_query = og_context["image_query"]
public_base_url = _get_public_base_url() public_base_url = _get_public_base_url()
og_image_url = public_base_url + "/og-image?" + urlencode(image_query) og_image_url = public_base_url + "/og-image.png?" + urlencode(image_query)
og_url = public_base_url + request.full_path og_url = public_base_url + request.full_path
if og_url.endswith("?"): if og_url.endswith("?"):
og_url = og_url[:-1] og_url = og_url[:-1]
@@ -233,6 +358,153 @@ def _render_index(search_type: str | None = None, search_value: str | None = Non
) )
def _get_og_image_context() -> dict:
title = _truncate(request.args.get("title", "Fire Explorer"), 90)
subtitle = _truncate(
request.args.get("subtitle", "A hot new Handshake Blockchain Explorer"),
140,
)
search_type = _truncate(request.args.get("type", "Explorer"), 24)
search_value = request.args.get("value", "")
normalized_type = search_type.strip().lower()
value_max_length = 44
if normalized_type in {"transaction", "tx"}:
value_max_length = 30
display_value = _ellipsize_middle(search_value, value_max_length)
if search_value.isdigit() and int(search_value) > 100:
display_value = f"{int(search_value):,}"
value_font_size = "30"
if len(display_value) > 34:
value_font_size = "26"
is_default_card = not search_value.strip() and normalized_type in {
"explorer",
"search",
"",
}
return {
"title": title,
"subtitle": subtitle,
"search_type": search_type,
"search_value": search_value,
"display_value": display_value,
"value_font_size": value_font_size,
"is_default_card": is_default_card,
}
def _load_og_font(size: int, bold: bool = False, mono: bool = False):
try:
image_font_module = importlib.import_module("PIL.ImageFont")
if mono:
candidates = [
os.path.join(OG_FONT_DIR, "DejaVuSansMono.ttf"),
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
"/usr/share/fonts/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationMono-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
"DejaVuSansMono.ttf",
]
elif bold:
candidates = [
os.path.join(OG_FONT_DIR, "DejaVuSans-Bold.ttf"),
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"DejaVuSans-Bold.ttf",
]
else:
candidates = [
os.path.join(OG_FONT_DIR, "DejaVuSans.ttf"),
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"DejaVuSans.ttf",
]
for candidate in candidates:
try:
return image_font_module.truetype(candidate, size=size)
except Exception:
continue
return image_font_module.load_default()
except Exception:
try:
image_font_module = importlib.import_module("PIL.ImageFont")
return image_font_module.load_default()
except Exception:
return None
def _fit_text(draw, text: str, font, max_width: int) -> str:
candidate = text
while candidate:
bbox = draw.textbbox((0, 0), candidate, font=font)
if bbox[2] - bbox[0] <= max_width:
return candidate
candidate = candidate[:-1]
if text:
return ""
return text
def _wrap_text(draw, text: str, font, max_width: int, max_lines: int = 2) -> list[str]:
if not text:
return [""]
words = text.split()
if not words:
return [_fit_text(draw, text, font, max_width)]
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
bbox = draw.textbbox((0, 0), candidate, font=font)
if bbox[2] - bbox[0] <= max_width:
current = candidate
continue
lines.append(_fit_text(draw, current, font, max_width))
current = word
if len(lines) >= max_lines - 1:
break
remaining_words = words[len(" ".join(lines + [current]).split()) :]
tail = current
if remaining_words:
tail = f"{current} {' '.join(remaining_words)}"
if len(lines) < max_lines:
lines.append(_fit_text(draw, tail, font, max_width))
return lines[:max_lines]
def _draw_text_shadow(
draw, position: tuple[int, int], text: str, font, fill, shadow=(0, 0, 0, 140)
):
if not text:
return
x, y = position
draw.text((x + 2, y + 2), text, fill=shadow, font=font)
draw.text((x, y), text, fill=fill, font=font)
# Assets routes # Assets routes
@app.route("/assets/<path:path>") @app.route("/assets/<path:path>")
def send_assets(path): def send_assets(path):
@@ -316,28 +588,13 @@ def coin_route(coin_hash, index):
@app.route("/og-image") @app.route("/og-image")
def og_image(): def og_image():
title = _truncate(request.args.get("title", "Fire Explorer"), 90) context = _get_og_image_context()
subtitle = _truncate( title = context["title"]
request.args.get("subtitle", "A hot new Handshake Blockchain Explorer"), subtitle = context["subtitle"]
140, search_type = context["search_type"]
) display_value = context["display_value"]
search_type = _truncate(request.args.get("type", "Explorer"), 24) value_font_size = context["value_font_size"]
search_value = request.args.get("value", "") is_default_card = context["is_default_card"]
normalized_type = search_type.strip().lower()
value_max_length = 44
if normalized_type in {"transaction", "tx"}:
value_max_length = 30
display_value = _ellipsize_middle(search_value, value_max_length)
value_font_size = "30"
if len(display_value) > 34:
value_font_size = "26"
is_default_card = not search_value.strip() and normalized_type in {
"explorer",
"search",
"",
}
type_text = escape(search_type) type_text = escape(search_type)
value_text = escape(display_value) value_text = escape(display_value)
@@ -348,52 +605,52 @@ def og_image():
svg = f"""<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\"> svg = f"""<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\">
<defs> <defs>
<linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"> <linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">
<stop offset=\"0%\" stop-color=\"#0b1220\" /> <stop offset=\"0%\" stop-color=\"#040b1a\" />
<stop offset=\"100%\" stop-color=\"#1f2937\" /> <stop offset=\"100%\" stop-color=\"#160a24\" />
</linearGradient> </linearGradient>
<linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\"> <linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">
<stop offset=\"0%\" stop-color=\"#ef4444\" /> <stop offset=\"0%\" stop-color=\"#cd408f\" />
<stop offset=\"100%\" stop-color=\"#f97316\" /> <stop offset=\"100%\" stop-color=\"#0c4fc2\" />
</linearGradient> </linearGradient>
</defs> </defs>
<rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" /> <rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" />
<circle cx=\"1100\" cy=\"80\" r=\"180\" fill=\"#ef4444\" opacity=\"0.16\" /> <circle cx=\"1100\" cy=\"80\" r=\"180\" fill=\"#08398c\" opacity=\"0.12\" />
<circle cx=\"120\" cy=\"610\" r=\"240\" fill=\"#f97316\" opacity=\"0.14\" /> <circle cx=\"120\" cy=\"610\" r=\"240\" fill=\"#8e2d63\" opacity=\"0.11\" />
<rect x=\"86\" y=\"74\" width=\"1028\" height=\"482\" rx=\"28\" fill=\"#0f172a\" stroke=\"#374151\" /> <rect x=\"86\" y=\"74\" width=\"1028\" height=\"482\" rx=\"28\" fill=\"#0b1426\" stroke=\"#64748b\" />
<rect x=\"130\" y=\"126\" width=\"176\" height=\"42\" rx=\"21\" fill=\"url(#accent)\" /> <rect x=\"130\" y=\"126\" width=\"176\" height=\"42\" rx=\"21\" fill=\"url(#accent)\" />
<text x=\"218\" y=\"153\" text-anchor=\"middle\" fill=\"#ffffff\" font-size=\"19\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">Handshake</text> <text x=\"218\" y=\"153\" text-anchor=\"middle\" fill=\"#ffffff\" font-size=\"19\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">Handshake</text>
<text x=\"130\" y=\"246\" fill=\"#ffffff\" font-size=\"82\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"800\">{title_text}</text> <text x=\"130\" y=\"246\" fill=\"#ffffff\" font-size=\"82\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"800\">{title_text}</text>
<text x=\"130\" y=\"302\" fill=\"#d1d5db\" font-size=\"32\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text> <text x=\"130\" y=\"302\" fill=\"#e5e7eb\" font-size=\"32\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text>
<line x1=\"130\" y1=\"338\" x2=\"1070\" y2=\"338\" stroke=\"#374151\" /> <line x1=\"130\" y1=\"338\" x2=\"1070\" y2=\"338\" stroke=\"#64748b\" />
<text x=\"130\" y=\"402\" fill=\"#f9fafb\" font-size=\"34\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"600\">Blocks • Transactions • Addresses • Names</text> <text x=\"130\" y=\"402\" fill=\"#f9fafb\" font-size=\"34\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"600\">Blocks • Transactions • Addresses • Names</text>
<text x=\"130\" y=\"452\" fill=\"#9ca3af\" font-size=\"28\" font-family=\"Inter, Arial, sans-serif\">Real-time status, searchable history, and rich on-chain data.</text> <text x=\"130\" y=\"452\" fill=\"#cbd5e1\" font-size=\"28\" font-family=\"Inter, Arial, sans-serif\">Real-time status, searchable history, and rich on-chain data.</text>
<text x=\"86\" y=\"608\" fill=\"#9ca3af\" font-size=\"24\" font-family=\"Inter, Arial, sans-serif\">explorer.hns.au</text> <text x=\"86\" y=\"608\" fill=\"#cbd5e1\" font-size=\"24\" font-family=\"Inter, Arial, sans-serif\">explorer.hns.au</text>
</svg>""" </svg>"""
else: else:
svg = f"""<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\"> svg = f"""<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\">
<defs> <defs>
<linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"> <linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">
<stop offset=\"0%\" stop-color=\"#111827\" /> <stop offset=\"0%\" stop-color=\"#040b1a\" />
<stop offset=\"100%\" stop-color=\"#1f2937\" /> <stop offset=\"100%\" stop-color=\"#160a24\" />
</linearGradient> </linearGradient>
<linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\"> <linearGradient id=\"accent\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">
<stop offset=\"0%\" stop-color=\"#ef4444\" /> <stop offset=\"0%\" stop-color=\"#cd408f\" />
<stop offset=\"100%\" stop-color=\"#f97316\" /> <stop offset=\"100%\" stop-color=\"#0c4fc2\" />
</linearGradient> </linearGradient>
<clipPath id=\"valueClip\"> <clipPath id=\"valueClip\">
<rect x=\"96\" y=\"496\" width=\"1008\" height=\"52\" rx=\"4\" /> <rect x=\"96\" y=\"496\" width=\"1008\" height=\"52\" rx=\"4\" />
</clipPath> </clipPath>
</defs> </defs>
<rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" /> <rect width=\"1200\" height=\"630\" fill=\"url(#bg)\" />
<circle cx=\"1040\" cy=\"120\" r=\"180\" fill=\"#ef4444\" opacity=\"0.14\" /> <circle cx=\"1040\" cy=\"120\" r=\"180\" fill=\"#08398c\" opacity=\"0.11\" />
<circle cx=\"160\" cy=\"580\" r=\"220\" fill=\"#f97316\" opacity=\"0.12\" /> <circle cx=\"160\" cy=\"580\" r=\"220\" fill=\"#8e2d63\" opacity=\"0.10\" />
<rect x=\"72\" y=\"64\" width=\"240\" height=\"44\" rx=\"22\" fill=\"url(#accent)\" /> <rect x=\"72\" y=\"64\" width=\"240\" height=\"44\" rx=\"22\" fill=\"url(#accent)\" />
<text x=\"192\" y=\"92\" text-anchor=\"middle\" fill=\"#ffffff\" font-size=\"20\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">{type_text}</text> <text x=\"192\" y=\"92\" text-anchor=\"middle\" fill=\"#ffffff\" font-size=\"20\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">{type_text}</text>
<text x=\"72\" y=\"188\" fill=\"#ffffff\" font-size=\"62\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">{title_text}</text> <text x=\"72\" y=\"188\" fill=\"#ffffff\" font-size=\"62\" font-family=\"Inter, Arial, sans-serif\" font-weight=\"700\">{title_text}</text>
<text x=\"72\" y=\"252\" fill=\"#d1d5db\" font-size=\"33\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text> <text x=\"72\" y=\"252\" fill=\"#e5e7eb\" font-size=\"33\" font-family=\"Inter, Arial, sans-serif\">{subtitle_text}</text>
<rect x=\"72\" y=\"472\" width=\"1056\" height=\"96\" rx=\"16\" fill=\"#0b1220\" stroke=\"#374151\" /> <rect x=\"72\" y=\"472\" width=\"1056\" height=\"96\" rx=\"16\" fill=\"#0b1426\" stroke=\"#64748b\" />
<text x=\"96\" y=\"530\" clip-path=\"url(#valueClip)\" fill=\"#f9fafb\" font-size=\"{value_font_size}\" font-family=\"'JetBrains Mono', 'Consolas', monospace\">{value_text}</text> <text x=\"96\" y=\"530\" clip-path=\"url(#valueClip)\" fill=\"#f9fafb\" font-size=\"{value_font_size}\" font-family=\"'JetBrains Mono', 'Consolas', monospace\">{value_text}</text>
<text x=\"72\" y=\"610\" fill=\"#9ca3af\" font-size=\"24\" font-family=\"Inter, Arial, sans-serif\">explorer.hns.au</text> <text x=\"72\" y=\"610\" fill=\"#cbd5e1\" font-size=\"24\" font-family=\"Inter, Arial, sans-serif\">explorer.hns.au</text>
</svg>""" </svg>"""
response = make_response(svg) response = make_response(svg)
@@ -402,6 +659,124 @@ def og_image():
return response return response
@app.route("/og-image.png")
def og_image_png():
try:
image_module = importlib.import_module("PIL.Image")
image_draw_module = importlib.import_module("PIL.ImageDraw")
except Exception:
if os.path.isfile("templates/assets/img/og.png"):
return send_from_directory("templates/assets/img", "og.png")
return og_image()
context = _get_og_image_context()
title = context["title"]
subtitle = context["subtitle"]
search_type = context["search_type"]
display_value = context["display_value"]
is_default_card = context["is_default_card"]
width, height = 1200, 630
image = image_module.new("RGBA", (width, height), "#040b1a")
draw = image_draw_module.Draw(image, "RGBA")
draw.rectangle((0, 0, width, height), fill="#040b1a")
draw.ellipse((920, -60, 1280, 300), fill=(8, 57, 140, 30))
draw.ellipse((-120, 360, 360, 840), fill=(142, 45, 99, 32))
title_font = _load_og_font(56, bold=True)
subtitle_font = _load_og_font(33, bold=False)
label_font = _load_og_font(20, bold=True)
mono_font = _load_og_font(30, bold=False, mono=True)
footer_font = _load_og_font(24, bold=False)
if is_default_card:
draw.rounded_rectangle(
(86, 74, 1114, 556), radius=28, fill="#0b1426", outline="#64748b", width=2
)
draw.rounded_rectangle((130, 126, 306, 168), radius=21, fill="#cd408f")
_draw_text_shadow(draw, (165, 133), "Handshake", label_font, "#ffffff")
nice_title_font = _load_og_font(74, bold=True)
nice_subtitle_font = _load_og_font(30, bold=False)
features_font = _load_og_font(34, bold=True)
subfeatures_font = _load_og_font(28, bold=False)
title_lines = _wrap_text(draw, title, nice_title_font, 940, max_lines=2)
subtitle_lines = _wrap_text(
draw, subtitle, nice_subtitle_font, 940, max_lines=2
)
title_y = 192
for line in title_lines:
_draw_text_shadow(draw, (130, title_y), line, nice_title_font, "#ffffff")
title_y += 76
subtitle_y = title_y + 8
for line in subtitle_lines:
_draw_text_shadow(
draw, (130, subtitle_y), line, nice_subtitle_font, "#e5e7eb"
)
subtitle_y += 38
divider_y = subtitle_y + 18
draw.line((130, divider_y, 1070, divider_y), fill="#64748b", width=2)
features_y = divider_y + 30
_draw_text_shadow(
draw,
(130, features_y),
"Blocks • Transactions • Addresses • Names",
features_font,
"#f9fafb",
)
_draw_text_shadow(
draw,
(130, features_y + 52),
"Real-time status, searchable history, and rich on-chain data.",
subfeatures_font,
"#cbd5e1",
)
_draw_text_shadow(draw, (86, 580), "explorer.hns.au", footer_font, "#cbd5e1")
else:
draw.rounded_rectangle((72, 64, 312, 108), radius=22, fill="#cd408f")
safe_type = _fit_text(draw, search_type, label_font, 220)
type_width = draw.textbbox((0, 0), safe_type, font=label_font)[2]
_draw_text_shadow(
draw, (192 - type_width // 2, 72), safe_type, label_font, "#ffffff"
)
title_lines = _wrap_text(draw, title, title_font, 1040, max_lines=2)
subtitle_lines = _wrap_text(draw, subtitle, subtitle_font, 1040, max_lines=2)
title_y = 132
for line in title_lines:
_draw_text_shadow(draw, (72, title_y), line, title_font, "#ffffff")
title_y += 66
subtitle_y = title_y + 8
for line in subtitle_lines:
_draw_text_shadow(draw, (72, subtitle_y), line, subtitle_font, "#e5e7eb")
subtitle_y += 38
draw.rounded_rectangle(
(72, 472, 1128, 568), radius=16, fill="#0b1426", outline="#64748b", width=2
)
value_max_px = 1008
safe_value = _fit_text(draw, display_value, mono_font, value_max_px)
_draw_text_shadow(draw, (96, 500), safe_value, mono_font, "#f9fafb")
_draw_text_shadow(draw, (72, 580), "explorer.hns.au", footer_font, "#cbd5e1")
output = BytesIO()
image.convert("RGB").save(output, format="PNG", optimize=True)
output.seek(0)
response = send_file(output, mimetype="image/png")
response.headers["Cache-Control"] = "public, max-age=300"
return response
@app.route("/<path:path>") @app.route("/<path:path>")
def catch_all(path: str): def catch_all(path: str):
if os.path.isfile("templates/" + path): if os.path.isfile("templates/" + path):

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@
<meta property="og:title" content="{{ meta_title or 'Fire Explorer' }}"> <meta property="og:title" content="{{ meta_title or 'Fire Explorer' }}">
<meta property="og:description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}"> <meta property="og:description" content="{{ meta_description or 'A hot new Handshake Blockchain Explorer' }}">
<meta property="og:image" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}"> <meta property="og:image" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
<meta property="og:image:secure_url" content="{{ og_image or 'https://explorer.hns.au/assets/img/og.png' }}">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">

60
uv.lock generated
View File

@@ -247,6 +247,7 @@ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "flask" }, { name = "flask" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "pillow" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "requests-doh" }, { name = "requests-doh" },
] ]
@@ -262,6 +263,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=46.0.3" }, { name = "cryptography", specifier = ">=46.0.3" },
{ name = "flask", specifier = ">=3.1.2" }, { name = "flask", specifier = ">=3.1.2" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=23.0.0" },
{ name = "pillow", specifier = ">=11.3.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "requests-doh", specifier = ">=1.0.0" }, { name = "requests-doh", specifier = ">=1.0.0" },
] ]
@@ -478,6 +480,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
] ]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.5.0" version = "4.5.0"