200 lines
7.4 KiB
Python
Executable File
200 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Fetch real source assets for desfoto.de.
|
|
|
|
Sources
|
|
-------
|
|
* Own image pool (Orgatool volume, served through the photographer's own API).
|
|
We pull the pre-derived `web` variant (long edge 2560 px) instead of the
|
|
20-60 MB camera originals. Only assets we are allowed to publish as the
|
|
rights holder are listed in src/images.json.
|
|
* Own portfolio files of the existing site (www.dennyschulz.de/legacy/<file>).
|
|
These carry the concert, band and free-work photographs that are missing from
|
|
the pool; they are fetched as-is and downscaled by the build. Also used for
|
|
the operator's original logo (www.dennyschulz.de/logo.png).
|
|
* Google Fonts (open licensed, OFL) - downloaded once, then self-hosted.
|
|
Licences are written to docs/.
|
|
* YouTube poster frame for the own music video "Thjodroerir - Skogamor".
|
|
|
|
Everything lands in assets-src/ which is *not* shipped. scripts/build-site.py
|
|
turns assets-src/ into the committed site/ output.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "assets-src"
|
|
IMG_OUT = SRC / "images"
|
|
BRAND_OUT = SRC / "brand"
|
|
FONT_OUT = SRC / "fonts"
|
|
TTF_OUT = SRC / "fonts-ttf"
|
|
DOCS = ROOT / "docs"
|
|
|
|
LOGO_URL = "https://www.dennyschulz.de/logo.png"
|
|
|
|
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
|
# Old Safari advertises truetype-only support, so Google serves real .ttf
|
|
# files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read).
|
|
UA_TTF = (
|
|
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-US) "
|
|
"AppleWebKit/533.18.1 (KHTML, like Gecko) Version/5.0.2 Safari/533.18.1"
|
|
)
|
|
|
|
# family -> (google css2 query, wanted unicode-range comments)
|
|
FAMILIES = {
|
|
"fraunces": {
|
|
"query": "family=Fraunces:opsz,wght@9..144,300..700",
|
|
"keep": ("latin", "latin-ext"),
|
|
"license": "https://raw.githubusercontent.com/google/fonts/main/ofl/fraunces/OFL.txt",
|
|
},
|
|
"manrope": {
|
|
"query": "family=Manrope:wght@200..800",
|
|
"keep": ("latin", "latin-ext"),
|
|
"license": "https://raw.githubusercontent.com/google/fonts/main/ofl/manrope/OFL.txt",
|
|
},
|
|
}
|
|
|
|
TTF_QUERIES = (
|
|
"family=Fraunces:opsz,wght@9..144,400",
|
|
"family=Fraunces:opsz,wght@9..144,600",
|
|
"family=Manrope:wght@400",
|
|
"family=Manrope:wght@600",
|
|
)
|
|
VIDEO_ID = "NWWFTf7l8g0"
|
|
|
|
|
|
def fetch(url: str, *, ua: str = UA, timeout: int = 120) -> bytes:
|
|
req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "*/*"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.read()
|
|
|
|
|
|
def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
|
|
try:
|
|
return fetch(url, ua=ua)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code in (403, 404, 410):
|
|
return None
|
|
raise
|
|
except urllib.error.URLError:
|
|
return None
|
|
|
|
|
|
def fetch_images() -> None:
|
|
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
|
|
base = manifest["pool_base"].rstrip("/")
|
|
legacy_base = manifest["legacy_base"].rstrip("/")
|
|
suffixes = (".webp", ".src", ".jpg", ".jpeg", ".png")
|
|
ok = skipped = 0
|
|
for entry in manifest["images"]:
|
|
slug, raw = entry["slug"], entry["raw"]
|
|
rel = raw.replace("\\", "/")
|
|
if any((IMG_OUT / f"{slug}{suffix}").exists() for suffix in suffixes):
|
|
skipped += 1
|
|
continue
|
|
if entry.get("source") == "legacy":
|
|
data = fetch(f"{legacy_base}/{rel}")
|
|
(IMG_OUT / f"{slug}.jpg").write_bytes(data)
|
|
else:
|
|
data = try_fetch(f"{base}/_derived/web/{rel}.webp")
|
|
if data is None:
|
|
print(f" [!] web variant missing, falling back to original: {rel}")
|
|
data = fetch(f"{base}/{rel}")
|
|
(IMG_OUT / f"{slug}.src").write_bytes(data)
|
|
else:
|
|
(IMG_OUT / f"{slug}.webp").write_bytes(data)
|
|
ok += 1
|
|
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
|
|
print(f"images: {ok} fetched, {skipped} already present")
|
|
|
|
poster = IMG_OUT / "video-skogamor-poster.jpg"
|
|
if not poster.exists():
|
|
data = try_fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/maxresdefault.jpg")
|
|
if data is None:
|
|
data = fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/hqdefault.jpg")
|
|
poster.write_bytes(data)
|
|
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
|
|
|
|
|
|
def fetch_brand() -> None:
|
|
"""The operator's original logo (used for the brand mark and all icons)."""
|
|
BRAND_OUT.mkdir(parents=True, exist_ok=True)
|
|
target = BRAND_OUT / "logo.png"
|
|
if target.exists():
|
|
print(f"brand: logo already present ({target.stat().st_size / 1024:.0f} KiB)")
|
|
return
|
|
data = fetch(LOGO_URL)
|
|
target.write_bytes(data)
|
|
print(f" [ok] logo ({len(data) / 1024:.0f} KiB)")
|
|
|
|
|
|
def fetch_fonts() -> None:
|
|
FONT_OUT.mkdir(parents=True, exist_ok=True)
|
|
for family, spec in FAMILIES.items():
|
|
css = fetch(f"https://fonts.googleapis.com/css2?{spec['query']}&display=swap").decode()
|
|
blocks = re.findall(
|
|
r"/\* ([a-z0-9-]+) \*/\s*@font-face \{(.*?)\}", css, flags=re.S
|
|
)
|
|
index = []
|
|
for subset, body in blocks:
|
|
if subset not in spec["keep"]:
|
|
continue
|
|
url = re.search(r"url\((https://[^)]+\.woff2)\)", body)
|
|
rng = re.search(r"unicode-range:\s*([^;]+);", body)
|
|
if not url:
|
|
continue
|
|
name = f"{family}-{subset}.woff2"
|
|
target = FONT_OUT / name
|
|
if not target.exists():
|
|
target.write_bytes(fetch(url.group(1)))
|
|
index.append({"file": name, "range": rng.group(1).strip() if rng else ""})
|
|
print(f" [ok] font {name} ({target.stat().st_size / 1024:.0f} KiB)")
|
|
(SRC / f"font-index-{family}.json").write_text(
|
|
json.dumps(index, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
|
|
license_target = DOCS / f"font-{family}-OFL.txt"
|
|
if not license_target.exists():
|
|
license_target.write_bytes(fetch(spec["license"]))
|
|
print(f" [ok] licence {license_target.name}")
|
|
|
|
# Build-time only TTFs (used to render the OG image and brand PNGs).
|
|
TTF_OUT.mkdir(parents=True, exist_ok=True)
|
|
for query in TTF_QUERIES:
|
|
css = fetch(f"https://fonts.googleapis.com/css2?{query}", ua=UA_TTF).decode()
|
|
blocks = re.findall(r"@font-face \{(.*?)\}", css, flags=re.S)
|
|
for block in blocks:
|
|
url_match = re.search(r"url\((https://[^)]+)\)", block)
|
|
if not url_match:
|
|
continue
|
|
family = (re.search(r"font-family: '([^']+)'", block) or [None, "font"])[1]
|
|
weight = (re.search(r"font-weight: (\d+)", block) or [None, "400"])[1]
|
|
name = f"{family.lower()}-{weight}.ttf"
|
|
target = TTF_OUT / name
|
|
if target.exists():
|
|
continue
|
|
target.write_bytes(fetch(url_match.group(1), ua=UA_TTF))
|
|
print(f" [ok] build font {name} ({target.stat().st_size / 1024:.0f} KiB)")
|
|
|
|
|
|
def main() -> int:
|
|
for path in (IMG_OUT, BRAND_OUT, FONT_OUT, DOCS):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
print("== brand ==")
|
|
fetch_brand()
|
|
print("== images ==")
|
|
fetch_images()
|
|
print("== fonts ==")
|
|
fetch_fonts()
|
|
print("done")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|