Files
mcg-website/tools/wxr.py
T
luxick bef5182545 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>
2026-07-28 19:20:11 +02:00

127 lines
4.1 KiB
Python

"""Shared helpers for reading the WordPress WXR export."""
from __future__ import annotations
import re
import unicodedata
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
NS = {
"wp": "http://wordpress.org/export/1.2/",
"content": "http://purl.org/rss/1.0/modules/content/",
"excerpt": "http://wordpress.org/export/1.2/excerpt/",
"dc": "http://purl.org/dc/elements/1.1/",
}
REPO_ROOT = Path(__file__).resolve().parent.parent
EXPORT = REPO_ROOT / "wordpress-export" / "motorradclubgiebelwaldev.WordPress.2026-07-28.xml"
CACHE = REPO_ROOT / ".cache" / "uploads"
# German transliteration first, so "Jubiläum" becomes "jubilaeum" and not "jubilaum".
UMLAUTS = {
"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss",
"Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
}
def slugify(value: str, *, keep_dots: bool = False) -> str:
for src, dst in UMLAUTS.items():
value = value.replace(src, dst)
value = unicodedata.normalize("NFKD", value)
value = "".join(c for c in value if not unicodedata.combining(c))
allowed = r"[^a-z0-9._-]+" if keep_dots else r"[^a-z0-9-]+"
value = re.sub(allowed, "-", value.lower())
return re.sub(r"-{2,}", "-", value).strip("-")
def slugify_filename(name: str) -> str:
stem, _, ext = name.rpartition(".")
return f"{slugify(stem, keep_dots=False)}.{ext.lower()}"
def clean_media_name(name: str) -> str:
"""Strip the bookkeeping suffixes WordPress bakes into upload filenames.
``Harry_2-e1654849333614.jpg`` -> ``harry-2.jpg``
``IMG_20220527_121440-small-scaled.jpg`` -> ``img-20220527-121440-small.jpg``
"""
stem, _, ext = slugify_filename(name).rpartition(".")
stem = re.sub(r"-e\d{9,}$", "", stem) # post-crop revision id
stem = re.sub(r"-\d+x\d+$", "", stem) # generated size variant
stem = re.sub(r"-scaled$", "", stem) # WP "big image" downscale
return f"{stem or 'bild'}.{ext}"
def text(node: ET.Element | None, path: str, default: str = "") -> str:
if node is None:
return default
found = node.find(path, NS)
if found is None or found.text is None:
return default
return found.text
@dataclass
class Item:
element: ET.Element
post_id: str
post_type: str
status: str
title: str
slug: str
link: str
parent: str
date_gmt: str
modified_gmt: str
content: str
excerpt: str
attachment_url: str
meta: dict[str, str] = field(default_factory=dict)
categories: list[tuple[str, str]] = field(default_factory=list)
def load_items() -> dict[str, Item]:
channel = ET.parse(EXPORT).getroot().find("channel")
items: dict[str, Item] = {}
for el in channel.findall("item"):
meta = {}
for m in el.findall("wp:postmeta", NS):
key = text(m, "wp:meta_key")
meta[key] = text(m, "wp:meta_value")
cats = [
(c.get("domain", ""), c.get("nicename", ""))
for c in el.findall("category")
]
item = Item(
element=el,
post_id=text(el, "wp:post_id"),
post_type=text(el, "wp:post_type"),
status=text(el, "wp:status"),
title=(el.findtext("title") or "").strip(),
slug=text(el, "wp:post_name"),
link=(el.findtext("link") or "").strip(),
parent=text(el, "wp:post_parent"),
date_gmt=text(el, "wp:post_date_gmt"),
modified_gmt=text(el, "wp:post_modified_gmt"),
content=text(el, "content:encoded"),
excerpt=text(el, "excerpt:encoded"),
attachment_url=text(el, "wp:attachment_url"),
meta=meta,
categories=cats,
)
items[item.post_id] = item
return items
def attachments(items: dict[str, Item]) -> dict[str, Item]:
return {k: v for k, v in items.items() if v.post_type == "attachment"}
def cache_path(url: str) -> Path:
"""Local cache location mirroring the wp-content/uploads/YYYY/MM layout."""
rel = url.split("/wp-content/uploads/", 1)[-1]
parts = rel.split("/")
return CACHE.joinpath(*parts[:-1], slugify_filename(parts[-1]))