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
+169
View File
@@ -0,0 +1,169 @@
"""Generate data/*.yaml.
Two sources:
* ``data/termine.yaml`` comes from the six TablePress tables in the WXR export.
* ``data/members.yaml`` and ``data/memoriam.yaml`` come from the *live* homepage.
Those two blocks were WordPress widgets, and WXR does not export widgets at all,
so scraping the rendered page is the only way to recover them.
Run once during the migration. After that the YAML files are the source of truth.
"""
from __future__ import annotations
import html
import json
import re
import sys
import urllib.request
from pathlib import Path
from wxr import REPO_ROOT, clean_media_name, load_items
LIVE_HOME = "https://motorradclub-giebelwald.de/"
USER_AGENT = "mcg-website-migration/1.0 (+https://mcg.luxick.de)"
DATA = REPO_ROOT / "data"
def yaml_str(value: str) -> str:
"""Always double-quote; the cells contain colons, umlauts and inline HTML."""
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
# --------------------------------------------------------------------------- termine
def build_termine() -> None:
items = load_items()
tables = [i for i in items.values() if i.post_type == "tablepress_table"]
years: dict[int, list[dict[str, str]]] = {}
for table in tables:
match = re.search(r"(\d{4})", table.title)
if not match:
print(f"!! skipping table without a year in the title: {table.title}",
file=sys.stderr)
continue
year = int(match.group(1))
rows = []
for row in json.loads(table.content):
date = (row[0] if len(row) > 0 else "").strip()
desc = (row[1] if len(row) > 1 else "").strip()
if not date and not desc:
continue # TablePress spacer rows
rows.append({"date": date, "description": desc})
years[year] = rows
lines = [
"# Vereinstermine, ursprünglich TablePress-Tabellen aus WordPress.",
"# Neue Termine: einfach unter dem passenden Jahr ergänzen.",
"",
]
for year in sorted(years, reverse=True):
lines.append(f'"{year}":')
if not years[year]:
lines[-1] += " []"
continue
for row in years[year]:
lines.append(f" - date: {yaml_str(row['date'])}")
lines.append(f" description: {yaml_str(row['description'])}")
lines.append("")
(DATA / "termine.yaml").write_text("\n".join(lines).rstrip() + "\n",
encoding="utf-8")
total = sum(len(v) for v in years.values())
print(f"termine.yaml: {len(years)} Jahre, {total} Termine")
# ------------------------------------------------------------------- widget scraping
def live_home() -> str:
req = urllib.request.Request(LIVE_HOME, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read().decode("utf-8", errors="replace")
GALLERY_ITEM = re.compile(
r"<figure class='gallery-item'>.*?"
r"<a href='(?P<href>[^']+)'>.*?"
r"(?:<figcaption[^>]*>(?P<caption>.*?)</figcaption>)?"
r"</figure>",
re.S,
)
def gallery_entries(fragment: str) -> list[tuple[str, str]]:
out = []
for m in GALLERY_ITEM.finditer(fragment):
href = m.group("href")
caption = html.unescape(re.sub(r"<[^>]+>", "", m.group("caption") or "")).strip()
out.append((href, caption))
return out
def image_ref(url: str) -> str:
"""Map an uploads URL to the committed path under assets/img/."""
return "img/" + clean_media_name(url.rsplit("/", 1)[-1])
def build_widgets() -> None:
page = live_home()
# Each widget renders as <h4 class="widget-title">NAME</h4> followed by a WP
# gallery. Keep the trailing </figure> inside the group, otherwise the last
# entry of every gallery is silently dropped.
members_html = re.search(
r"Unsere Mitglieder</h4>(.*?</figure>)\s*</div>", page, re.S
)
memoriam_html = re.search(
r"In Erinnerung</h4>(.*?</figure>)\s*</div>", page, re.S
)
if not members_html or not memoriam_html:
raise SystemExit("could not locate the member/memoriam widgets on the live page")
members = gallery_entries(members_html.group(1))
memoriam = gallery_entries(memoriam_html.group(1))
# The WordPress gallery widget shuffles its order on every request, so the
# source order carries no meaning. Sort by name for a stable, readable list.
members.sort(key=lambda entry: entry[1].casefold())
lines = [
"# Mitglieder-Galerie der Startseite.",
"# Aus dem WordPress-Widget übernommen (Widgets sind nicht im XML-Export enthalten).",
"",
]
for url, name in members:
lines.append(f"- name: {yaml_str(name)}")
lines.append(f" image: {yaml_str(image_ref(url))}")
(DATA / "members.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"members.yaml: {len(members)} Mitglieder")
lines = [
"# \"In Erinnerung\" ebenfalls aus einem WordPress-Widget übernommen.",
"",
]
for url, caption in memoriam:
# Captions look like "Roland Groos ✝ 27.5.2020".
name, _, died = caption.partition("")
lines.append(f"- name: {yaml_str(name.strip())}")
lines.append(f" died: {yaml_str(died.strip())}")
lines.append(f" image: {yaml_str(image_ref(url))}")
(DATA / "memoriam.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"memoriam.yaml: {len(memoriam)} Einträge")
return [image_ref(u) for u, _ in members] + [image_ref(u) for u, _ in memoriam]
def main() -> int:
DATA.mkdir(parents=True, exist_ok=True)
build_termine()
build_widgets()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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())
+171
View File
@@ -0,0 +1,171 @@
"""Minimal HTML -> Markdown converter for the imported WordPress content.
Deliberately narrow: it only handles the tags that actually occur in this export
(paragraphs, headings, lists, emphasis, links, images, breaks, rules). Anything
unrecognised is passed through as raw HTML, which Goldmark renders because
``markup.goldmark.renderer.unsafe`` is on.
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
INLINE = {"strong", "b", "em", "i", "a", "br", "span", "code", "u", "s"}
BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "hr",
"blockquote", "figure", "figcaption", "div"}
ESCAPE = re.compile(r"([\\`*_\[\]])")
class Converter(HTMLParser):
def __init__(self, resolve_image=None):
super().__init__(convert_charrefs=True)
self.out: list[tuple[str, str]] = []
self.resolve_image = resolve_image or (lambda src, cls: src)
self._stack: list[str] = []
self._list: list[str] = [] # 'ul' / 'ol' nesting
self._li_index: list[int] = []
self._link: str | None = None
self._buf: list[str] = []
# -- helpers ---------------------------------------------------------
def _emit(self, text: str) -> None:
self._buf.append(text)
def _flush_block(self, prefix: str = "", kind: str = "block") -> None:
text = "".join(self._buf).strip()
self._buf = []
if text:
self.out.append((kind, prefix + text))
def _open_emphasis(self, marker: str) -> None:
# Emitted lazily: WordPress often writes "<strong> 57258 ...</strong>",
# and "** 57258**" is not valid emphasis in CommonMark. The marker is
# placed after any leading whitespace instead (see handle_data).
self._pending_emphasis = marker
def _close_emphasis(self, marker: str) -> None:
if getattr(self, "_pending_emphasis", None) == marker:
self._pending_emphasis = None # empty <strong></strong>
return
# Likewise move trailing whitespace out: "**Samstag **" -> "**Samstag** ".
trailing = ""
while self._buf and self._buf[-1] and self._buf[-1][-1] in " \t\n":
trailing = self._buf[-1][-1] + trailing
self._buf[-1] = self._buf[-1][:-1]
if not self._buf[-1]:
self._buf.pop()
if not self._buf:
return
self._emit(marker)
if trailing:
self._emit(trailing)
# -- tags ------------------------------------------------------------
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "p":
self._flush_block()
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
self._flush_block()
self._pending_prefix = "#" * int(tag[1]) + " "
elif tag in ("ul", "ol"):
self._flush_block()
self._list.append(tag)
self._li_index.append(0)
elif tag == "li":
self._flush_block()
if self._list:
self._li_index[-1] += 1
indent = " " * (len(self._list) - 1)
marker = ("- " if self._list[-1] == "ul"
else f"{self._li_index[-1]}. ")
self._pending_prefix = indent + marker
elif tag == "hr":
self._flush_block()
self.out.append(("block", "---"))
elif tag == "br":
self._emit(" \n")
elif tag in ("strong", "b"):
self._open_emphasis("**")
elif tag in ("em", "i"):
self._open_emphasis("*")
elif tag == "a":
self._link = a.get("href", "")
self._emit("[")
elif tag == "img":
src = self.resolve_image(a.get("src", ""), a.get("class", ""))
alt = (a.get("alt") or "").replace("]", "")
self._emit(f"![{alt}]({src})")
self._stack.append(tag)
def handle_endtag(self, tag):
while self._stack and self._stack.pop() != tag:
pass
if tag == "p":
self._flush_block()
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6", "li"):
prefix = getattr(self, "_pending_prefix", "")
self._pending_prefix = ""
self._flush_block(prefix, kind="li" if tag == "li" else "block")
elif tag in ("ul", "ol"):
self._flush_block()
if self._list:
self._list.pop()
self._li_index.pop()
elif tag in ("strong", "b"):
self._close_emphasis("**")
elif tag in ("em", "i"):
self._close_emphasis("*")
elif tag == "a":
self._emit(f"]({self._link or ''})")
self._link = None
def handle_data(self, data):
if not data:
return
# Collapse WordPress' aggressive whitespace, but keep explicit breaks.
text = data.replace(" ", " ")
text = re.sub(r"[ \t]*\n[ \t]*", "\n", text)
pending = getattr(self, "_pending_emphasis", None)
if pending:
if not text.strip():
return # whitespace only; keep waiting for real content
lead = text[:len(text) - len(text.lstrip())]
if lead:
self._emit(lead)
text = text.lstrip()
self._emit(pending)
self._pending_emphasis = None
elif not self._buf and not text.strip():
return
self._emit(ESCAPE.sub(r"\\\1", text) if self._link is None
else text.replace("[", r"\[").replace("]", r"\]"))
def result(self) -> str:
self._flush_block(getattr(self, "_pending_prefix", ""))
blocks = [(kind, text.strip()) for kind, text in self.out if text.strip()]
out = ""
for index, (kind, text) in enumerate(blocks):
if index == 0:
out = text
continue
# Keep consecutive list items tight; a blank line between them makes
# Goldmark render a "loose" list, wrapping every item in <p>.
sep = "\n" if kind == "li" and blocks[index - 1][0] == "li" else "\n\n"
out += sep + text
return out
def html_to_markdown(html_text: str, resolve_image=None) -> str:
conv = Converter(resolve_image=resolve_image)
conv.feed(html_text)
conv.close()
text = conv.result()
# An escaped em-dash entity or stray double-escape looks worse than the source.
text = text.replace("\\_\\_", "__")
return re.sub(r"\n{3,}", "\n\n", text).strip()
+133
View File
@@ -0,0 +1,133 @@
"""Post-migration checks. Run after `hugo --gc --minify`.
1. Inventory - every published WordPress page/post has a content file.
2. Media - every attachment is either placed in the site or explicitly
accounted for as an unused media-library leftover.
3. Links - no internal href/src in public/ points at a missing file, and no
absolute wp-content URL survived the import.
Exits non-zero if any check fails.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit
from wp2hugo import PAGE_MAP, SKIP, SLUG_FIXES
from wxr import REPO_ROOT, clean_media_name, load_items, slugify
PUBLIC = REPO_ROOT / "public"
CONTENT = REPO_ROOT / "content"
failures: list[str] = []
notes: list[str] = []
def check_inventory(items) -> None:
expected = 0
for item in items.values():
if item.post_id in SKIP or item.status != "publish":
continue
if item.post_type == "page":
mapping = PAGE_MAP.get(item.post_id)
if not mapping:
failures.append(f"page {item.post_id} ({item.slug}) is unmapped")
continue
path = CONTENT / mapping[0]
elif item.post_type == "post":
path = CONTENT / "aktuelles" / SLUG_FIXES.get(
item.slug, slugify(item.slug)) / "index.md"
else:
continue
expected += 1
if not path.exists():
failures.append(f"missing content file for {item.post_type} "
f"{item.post_id} ({item.slug}): {path}")
notes.append(f"inventory: {expected} published pages/posts accounted for")
for post_id, reason in SKIP.items():
notes.append(f" skipped {post_id}: {reason}")
def check_media(items) -> None:
placed = {p.name for p in CONTENT.rglob("*") if p.is_file()
and p.suffix.lower() != ".md"}
placed |= {p.name for p in (REPO_ROOT / "assets" / "img").rglob("*")
if p.is_file()}
placed |= {p.name for p in (REPO_ROOT / "static").rglob("*") if p.is_file()}
# assets/img/logo.png and banner.jpg are renamed copies.
placed |= {"Emblem_transparent.png", "cropped-Banner_small_web.jpg"}
unused = []
for item in items.values():
if item.post_type != "attachment" or not item.attachment_url:
continue
original = item.attachment_url.rsplit("/", 1)[-1]
if original in placed or clean_media_name(original) in placed:
continue
unused.append(item.attachment_url)
total = sum(1 for i in items.values() if i.post_type == "attachment")
notes.append(f"media: {total - len(unused)}/{total} attachments placed")
if unused:
notes.append(f" {len(unused)} media-library leftovers not shown on the "
f"live site (see MIGRATION.md)")
HREF = re.compile(r'(?:href|src)=["\']?([^"\'\s>]+)', re.I)
def check_links() -> None:
if not PUBLIC.exists():
failures.append("public/ does not exist - run `hugo` first")
return
checked = broken = 0
for html in PUBLIC.rglob("*.html"):
text = html.read_text(encoding="utf-8", errors="replace")
for stale in re.findall(r'https?://motorradclub-giebelwald\.de/wp-content/\S*',
text):
failures.append(f"{html.relative_to(PUBLIC)}: stale WordPress URL {stale}")
for raw in HREF.findall(text):
url = urlsplit(raw)
if url.scheme or url.netloc or not url.path:
continue # external, mailto:, protocol-relative
if raw.startswith("#"):
continue
checked += 1
path = unquote(url.path)
target = (PUBLIC / path.lstrip("/") if path.startswith("/")
else html.parent / path)
if target.is_dir():
target = target / "index.html"
if not target.exists():
broken += 1
failures.append(f"{html.relative_to(PUBLIC)}: broken link -> {raw}")
notes.append(f"links: {checked} internal references checked, {broken} broken")
def main() -> int:
items = load_items()
check_inventory(items)
check_media(items)
check_links()
for note in notes:
print(note)
if failures:
print(f"\n{len(failures)} FAILURE(S):", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print("\nall checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+340
View File
@@ -0,0 +1,340 @@
"""One-shot WordPress WXR -> Hugo importer.
Run once during the migration:
python tools/fetch_media.py # pull the binaries off the live host
python tools/build_data.py # termine / members / memoriam
python tools/wp2hugo.py # content bundles + assets
Afterwards the generated Markdown in content/ is the source of truth and this
script is kept only so the import is reproducible and reviewable.
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from html2md import html_to_markdown
from wxr import (REPO_ROOT, cache_path, clean_media_name, load_items, slugify)
CONTENT = REPO_ROOT / "content"
ASSETS_IMG = REPO_ROOT / "assets" / "img"
STATIC_PDF = REPO_ROOT / "static" / "dokumente"
# WordPress page id -> destination. Anything not listed is skipped.
PAGE_MAP = {
"13": ("aktuelles/_index.md", None),
"72": ("termine.md", "termine"),
"128": ("downloads.md", None),
"138": ("impressum.md", None),
"98": ("archiv/vergangene-o-fahrten.md", None),
"194": ("archiv/40-jahre-mcg/index.md", None),
"217": ("archiv/familienausfahrt-2019/index.md", None),
"287": ("archiv/bilder-familienausfahrt/index.md", None),
"473": ("archiv/familienfahrt-2022/index.md", None),
}
# Deliberately dropped, with the reason recorded for the migration report.
SKIP = {
"3": "stock WordPress privacy-policy draft (English, references an old IP)",
"12": "WP home page body was the placeholder 'Dies ist nur eine Demo Seite'",
"410": "empty draft post",
}
# Site chrome pulled out of the media library into assets/img/.
SITE_IMAGES = {
"Emblem_transparent.png": "logo.png",
"cropped-Banner_small_web.jpg": "banner.jpg",
}
SLUG_FIXES = {
"29-o-fahrt-zum-50-jubilaum": "29-o-fahrt-zum-50-jubilaeum",
"regelmasger-stammtisch": "regelmaessiger-stammtisch",
"vorankundigung-mcg-wochenende-2024": "vorankuendigung-mcg-wochenende-2024",
"o-fahrt-2019": "25-o-fahrt-2022", # title says 25. O-Fahrt 2022; slug was stale
"termin": "28-o-fahrt-2025", # title says 28. O-Fahrt 2025
}
@dataclass
class Target:
item: object
path: Path
bundle: Path | None
layout: str | None = None
images: dict[str, str] = field(default_factory=dict) # attachment id -> filename
# --------------------------------------------------------------------- helpers
def yaml_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def iso(date_gmt: str) -> str:
if not date_gmt or date_gmt.startswith("0000"):
return ""
return date_gmt.replace(" ", "T") + "Z"
def referenced_ids(item, by_filename: dict[str, str] | None = None) -> list[str]:
"""Every attachment id an item points at, in document order.
Some posts carry stale ``wp-image-NNN`` ids whose attachment record was
deleted from WordPress even though the upload itself survived (post 81 points
at id 83, which no longer exists). When ``by_filename`` is supplied, such
references are recovered from the ``src`` URL instead.
"""
ids: list[str] = []
def add(value):
if value and value not in ids:
ids.append(value)
thumb = item.meta.get("_thumbnail_id")
add(thumb)
for m in re.finditer(r'<img[^>]*>', item.content):
tag = m.group(0)
cls = re.search(r'wp-image-(\d+)', tag)
if cls:
add(cls.group(1))
if by_filename is not None:
src = re.search(r'src="([^"]+)"', tag)
if src:
add(by_filename.get(clean_media_name(src.group(1).rsplit("/", 1)[-1])))
for m in re.finditer(r'<!--\s*wp:gallery\s*(\{.*?\})\s*-->', item.content, re.S):
try:
for gid in json.loads(m.group(1)).get("ids", []):
add(str(gid))
except json.JSONDecodeError:
pass
for m in re.finditer(r'<!--\s*wp:file\s*(\{.*?\})\s*-->', item.content, re.S):
try:
add(str(json.loads(m.group(1)).get("id", "")))
except json.JSONDecodeError:
pass
return [i for i in ids if i]
# ------------------------------------------------------------------ conversion
def convert_body(item, target: Target, attachments: dict, pdf_names: dict) -> str:
body = item.content
# Galleries: replace the whole block with a shortcode listing bundle files.
def gallery_repl(match: re.Match) -> str:
try:
ids = [str(i) for i in json.loads(match.group(1)).get("ids", [])]
except json.JSONDecodeError:
return ""
files = [target.images[i] for i in ids if i in target.images]
if not files:
return ""
args = " ".join(f'"{f}"' for f in files)
return f"\n\n{{{{< gallery {args} >}}}}\n\n"
body = re.sub(
r'<!--\s*wp:gallery\s*(\{.*?\})\s*-->.*?<!--\s*/wp:gallery\s*-->',
gallery_repl, body, flags=re.S)
# File blocks: PDFs live in static/dokumente/, linked via the pdf shortcode.
def file_repl(match: re.Match) -> str:
block = match.group(0)
try:
attrs = json.loads(match.group(1))
except json.JSONDecodeError:
return ""
att_id = str(attrs.get("id", ""))
href = attrs.get("href", "")
name = pdf_names.get(att_id) or clean_media_name(href.rsplit("/", 1)[-1])
label = re.search(r'>([^<]+)</a>', block)
label = label.group(1).strip() if label else name
return f'\n\n{{{{< pdf "{name}" "{label}" >}}}}\n\n'
body = re.sub(
r'<!--\s*wp:file\s*(\{.*?\})\s*-->.*?<!--\s*/wp:file\s*-->',
file_repl, body, flags=re.S)
# Shortcodes that have no Hugo equivalent.
body = re.sub(r'<!--\s*wp:shortcode\s*-->.*?<!--\s*/wp:shortcode\s*-->', "",
body, flags=re.S)
body = re.sub(r'\[table id=\d+\s*/?\]', "", body)
body = re.sub(r'\[widgets_on_pages[^\]]*\]', "", body)
# Remaining Gutenberg comments carry no information once converted.
body = re.sub(r'<!--\s*/?wp:[^>]*?-->', "", body)
# WordPress' editor leaves these around every translated paragraph.
body = re.sub(r'<span style="vertical-align: inherit;">\s*', "", body)
body = re.sub(r'</span>', "", body)
def resolve_image(src: str, cls: str) -> str:
m = re.search(r'wp-image-(\d+)', cls or "")
if m and m.group(1) in target.images:
return target.images[m.group(1)]
# Fall back to matching on the original upload filename.
base = clean_media_name(src.rsplit("/", 1)[-1])
for name in target.images.values():
if name == base:
return name
return src
return html_to_markdown(body, resolve_image=resolve_image)
def front_matter(item, target: Target, extra_tags: list[str]) -> str:
lines = ["---", f"title: {yaml_quote(item.title)}"]
if iso(item.date_gmt):
lines.append(f"date: {iso(item.date_gmt)}")
if iso(item.modified_gmt) and item.modified_gmt != item.date_gmt:
lines.append(f"lastmod: {iso(item.modified_gmt)}")
if extra_tags:
lines.append("tags: [" + ", ".join(yaml_quote(t) for t in extra_tags) + "]")
thumb = item.meta.get("_thumbnail_id")
if thumb and thumb in target.images:
lines.append(f"featured: {yaml_quote(target.images[thumb])}")
if target.layout:
lines.append(f"layout: {yaml_quote(target.layout)}")
lines.append("---")
return "\n".join(lines)
# ------------------------------------------------------------------------ main
def main() -> int:
items = load_items()
attachments = {k: v for k, v in items.items() if v.post_type == "attachment"}
# Cleaned upload filename -> attachment id, for recovering stale wp-image ids.
by_filename: dict[str, str] = {}
for att_id, att in attachments.items():
if att.attachment_url:
by_filename.setdefault(
clean_media_name(att.attachment_url.rsplit("/", 1)[-1]), att_id)
targets: list[Target] = []
for item in items.values():
if item.post_id in SKIP or item.status != "publish":
continue
if item.post_type == "page":
mapping = PAGE_MAP.get(item.post_id)
if not mapping:
print(f"!! unmapped page {item.post_id} ({item.slug})", file=sys.stderr)
continue
rel, layout = mapping
path = CONTENT / rel
elif item.post_type == "post":
slug = SLUG_FIXES.get(item.slug, slugify(item.slug))
path = CONTENT / "aktuelles" / slug / "index.md"
layout = None
else:
continue
bundle = path.parent if path.name == "index.md" else None
targets.append(Target(item=item, path=path, bundle=bundle, layout=layout))
# ---- assign media -------------------------------------------------
used: set[str] = set()
pdf_names: dict[str, str] = {}
for target in targets:
for att_id in referenced_ids(target.item, by_filename):
att = attachments.get(att_id)
if not att or not att.attachment_url:
continue
src = cache_path(att.attachment_url)
if not src.exists():
print(f"!! missing cached media for {att_id}: {att.attachment_url}",
file=sys.stderr)
continue
name = clean_media_name(att.attachment_url.rsplit("/", 1)[-1])
if src.suffix.lower() == ".pdf":
STATIC_PDF.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, STATIC_PDF / name)
pdf_names[att_id] = name
target.images[att_id] = name
else:
if target.bundle is None:
# Non-bundle page (termine.md, impressum.md, ...) -> shared assets.
ASSETS_IMG.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, ASSETS_IMG / name)
target.images[att_id] = f"/img/{name}"
else:
target.bundle.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target.bundle / name)
target.images[att_id] = name
used.add(att_id)
# Any PDF in the library that no page links to is still worth keeping around
# for the Downloads page; copy them all so nothing is lost.
for att_id, att in attachments.items():
if not att.attachment_url.lower().endswith(".pdf"):
continue
src = cache_path(att.attachment_url)
if src.exists():
STATIC_PDF.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, STATIC_PDF / clean_media_name(
att.attachment_url.rsplit("/", 1)[-1]))
used.add(att_id)
# Site chrome + widget images (members / memoriam) into assets/img/.
ASSETS_IMG.mkdir(parents=True, exist_ok=True)
widget_images = set()
for yaml_file in ("members.yaml", "memoriam.yaml"):
text = (REPO_ROOT / "data" / yaml_file).read_text(encoding="utf-8")
widget_images.update(re.findall(r'image:\s*"img/([^"]+)"', text))
for att_id, att in attachments.items():
original = att.attachment_url.rsplit("/", 1)[-1]
name = clean_media_name(original)
src = cache_path(att.attachment_url)
if not src.exists():
continue
if original in SITE_IMAGES:
shutil.copy2(src, ASSETS_IMG / SITE_IMAGES[original])
used.add(att_id)
elif name in widget_images:
shutil.copy2(src, ASSETS_IMG / name)
used.add(att_id)
# ---- write content ------------------------------------------------
# content/ is the source of truth once imported, so an accidental re-run must
# not throw away hand edits. Pass --force to regenerate anyway.
force = "--force" in sys.argv
written = kept = 0
for target in targets:
item = target.item
target.path.parent.mkdir(parents=True, exist_ok=True)
if target.path.exists() and not force:
kept += 1
continue
tags = ["O-Fahrt"] if any(nice == "ofahrt" for _, nice in item.categories) else []
body = convert_body(item, target, attachments, pdf_names)
target.path.write_text(
front_matter(item, target, tags) + "\n\n" + body + "\n",
encoding="utf-8")
written += 1
print(f"wrote {written} content files"
+ (f", kept {kept} existing (use --force to overwrite)" if kept else ""))
print(f"media used: {len(used)}/{len(attachments)} attachments")
unused = sorted(set(attachments) - used, key=int)
print(f"unreferenced attachments: {len(unused)}")
for att_id in unused:
print(f" - {attachments[att_id].attachment_url}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+126
View File
@@ -0,0 +1,126 @@
"""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]))