Migrate WordPress site to Hugo

Replaces the WordPress 7.0.2 / HitMag site with a static Hugo build,
deployed by Gitea Actions over rsync/SSH.

Content: 26 files (9 pages + 17 posts) as Markdown page bundles, plus
127 of 165 media attachments. The remaining 38 are media-library
leftovers that appear nowhere on the live site; they are listed in
MIGRATION.md.

The WXR export contains neither media binaries nor widgets, so both were
recovered from the live host before it is retired:
  - tools/fetch_media.py downloads all uploads, capping them at 2000px
  - tools/build_data.py scrapes the "Unsere Mitglieder" and
    "In Erinnerung" widgets into data/*.yaml

TablePress' six Termine tables became data/termine.yaml, rendered with
the newest year expanded and earlier years collapsed. Old permalinks are
not preserved (clean slugs, no redirects, as agreed).

Custom layouts, no third-party theme. Plain CSS, since the pinned Hugo
0.164.0 is the non-extended build and cannot compile Sass.

Verified: clean build with zero warnings, 718 internal links checked and
none broken, no surviving wp-content URLs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:20:11 +02:00
parent eccf30584a
commit bef5182545
188 changed files with 2770 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
"""Download every attachment referenced by the WXR export into .cache/uploads/.
The export only carries URLs, not binaries, so the files have to come off the live
WordPress host before it is retired. Images are capped at MAX_EDGE px on the long
side and re-encoded; PDFs are stored verbatim.
Re-running is cheap: anything already in the cache is skipped.
"""
from __future__ import annotations
import sys
import urllib.error
import urllib.parse
import urllib.request
from io import BytesIO
from PIL import Image
from wxr import CACHE, attachments, cache_path, load_items
MAX_EDGE = 2000
JPEG_QUALITY = 82
USER_AGENT = "mcg-website-migration/1.0 (+https://mcg.luxick.de)"
def fetch(url: str) -> bytes:
# A few uploads have umlauts in the filename (e.g. Hövelhof.jpg); the HTTP
# request line must be ASCII, so percent-encode the path before sending.
parts = urllib.parse.urlsplit(url)
safe = urllib.parse.urlunsplit(
parts._replace(path=urllib.parse.quote(parts.path, safe="/%"))
)
req = urllib.request.Request(safe, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read()
def store_image(raw: bytes, dest) -> None:
img = Image.open(BytesIO(raw))
img.load()
# Honour the EXIF orientation now; the tag is dropped on save.
try:
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
except Exception:
pass
if max(img.size) > MAX_EDGE:
img.thumbnail((MAX_EDGE, MAX_EDGE), Image.LANCZOS)
suffix = dest.suffix.lower()
if suffix in (".jpg", ".jpeg"):
img.convert("RGB").save(dest, "JPEG", quality=JPEG_QUALITY, optimize=True,
progressive=True)
elif suffix == ".png":
img.save(dest, "PNG", optimize=True)
else:
img.save(dest)
def main() -> int:
items = load_items()
atts = attachments(items)
skipped = downloaded = failed = 0
for post_id, item in sorted(atts.items(), key=lambda kv: int(kv[0])):
url = item.attachment_url
if not url:
print(f"!! {post_id}: no attachment_url", file=sys.stderr)
failed += 1
continue
dest = cache_path(url)
if dest.exists() and dest.stat().st_size > 0:
skipped += 1
continue
dest.parent.mkdir(parents=True, exist_ok=True)
try:
raw = fetch(url)
except Exception as exc: # one bad upload must not abort the whole run
print(f"!! {post_id}: {url} -> {exc!r}", file=sys.stderr)
failed += 1
continue
try:
if dest.suffix.lower() == ".pdf":
dest.write_bytes(raw)
else:
store_image(raw, dest)
except Exception as exc: # corrupt/unsupported source, keep the original bytes
print(f"!! {post_id}: could not process {url} ({exc}); storing raw",
file=sys.stderr)
dest.write_bytes(raw)
downloaded += 1
print(f" {dest.relative_to(CACHE)} ({len(raw) // 1024} KiB -> "
f"{dest.stat().st_size // 1024} KiB)")
print(f"\ndownloaded={downloaded} cached={skipped} failed={failed} "
f"total={len(atts)}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())