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:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"")
|
||||
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()
|
||||
@@ -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())
|
||||
@@ -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
@@ -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]))
|
||||
Reference in New Issue
Block a user