Files
mcg-website/tools/wp2hugo.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

341 lines
13 KiB
Python

"""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())