Mit Python und der WordPress REST API eine komplette Sprachversion automatisieren: WPML, ACF und Yoast SEO

Wer auf einer mehrsprachigen WordPress-Site eine neue Sprachversion anlegen muss – mit WPML, ACF Custom Blocks und Yoast-SEO-Metadaten – steht vor einem manuellen Albtraum. Dieser Post zeigt, wie ein Python-Script via REST API den gesamten Import automatisiert: von statischen HTML-Dateien bis zur fertig verlinkten Übersetzung in WordPress.

Ein Kunde betreibt eine umfangreiche mehrsprachige WordPress-Website — Englisch ist die Hauptsprache, weitere Sprachversionen sind bereits vorhanden. Für einen neuen Zielmarkt sollte eine vollständige neue Sprachversion entstehen: mehrere Dutzend Seiten, komplex strukturiert mit ACF Custom Blocks, vollständig verlinkt untereinander, mit gepflegten Yoast-SEO-Metadaten.

Die Übersetzungen lagen bereits vor — als statische HTML-Dateien, geliefert von einem externen Dienstleister. Jetzt mussten diese Inhalte in WordPress: als Gutenberg-Blöcke, als WPML-verknüpfte Übersetzungen der englischen Ursprungsseiten, mit korrekt übertragenen SEO-Metadaten.

Manuell hätte das Stunden gedauert, wäre fehleranfällig gewesen und nicht reproduzierbar. Also habe ich einen Python-basierten Importer geschrieben.

Ausgangslage im Detail

Die Website lief auf:

  • WordPress mit Gutenberg-Editor
  • WPML für Mehrsprachigkeit (/en//[neue-sprache]/)
  • ACF (Advanced Custom Fields) für mehrere Custom Block-Typen
  • Yoast SEO für Meta-Optimierung
  • Eigene Block-Plugins — darunter ein Anchor-Navigation-Block, ein interaktiver Inhaltsverknüpfungs-Block und ein Geolocation-Block

Die Quelldaten: HTML-Dateien, lokal gespeichert. Der HTML-Code stammte ursprünglich von WordPress — was die Konvertierung deutlich vereinfacht hat, aber nicht trivial gemacht hat.

Warum Python + REST API statt WP-CLI oder Plugin?

Die naheliegenden Alternativen schieden schnell aus:

WP-CLI hätte direkten Server-Zugriff erfordert und ist für komplexe WPML-Verlinkungslogik nicht ideal. XML-Import per Plugin (z. B. WP All Import) kann mit ACF Custom Blocks nicht zuverlässig umgehen, vor allem wenn Field Keys fest im Code verankert sind. Manueller Import war bei der Anzahl der Seiten keine ernsthafte Option.

Ein Python-Script über die REST API war die sauberste Lösung: Es ist lokal testbar, dry-run-fähig, vollständig reproduzierbar, und ohne Produktionsrisiko bis zur finalen Ausführung.

Architektur des Importers

language_importer_v2/
├── config.json          # API-Credentials, Base-URLs, Sprachcodes
├── importer.py          # Hauptskript
├── converter.py         # HTML → Gutenberg-Blöcke
├── seo_extractor.py     # Yoast-Metadaten aus HTML-<head>
├── wpml_linker.py       # WPML Translation Linking via REST API
└── README.md

Der Ablauf in groben Schritten:

  1. HTML-Datei einlesen
  2. Gutenberg-Markup generieren (Block-by-Block-Konvertierung)
  3. SEO-Metadaten aus dem <head> extrahieren
  4. Slug der englischen Quellseite ermitteln (automatisches Matching)
  5. Neue WordPress-Seite via REST API erstellen
  6. Yoast-Meta via REST API schreiben
  7. WPML-Verlinkung zur englischen Quellseite herstellen

HTML zu Gutenberg: Das Konvertierungsproblem

Das HTML stammte aus WordPress, enthielt aber keine Gutenberg-Kommentare mehr — diese werden beim Rendern herausgefiltert. Der Converter musste also aus dem gerenderten HTML die ursprüngliche Block-Struktur rekonstruieren.

Für Standard-Blöcke (Paragraphen, Headings, Listen, Bilder) ist das einfach:

def convert_paragraph(element):
    text = element.decode_contents()
    return f'<!-- wp:paragraph -->\n<p>{text}</p>\n<!-- /wp:paragraph -->'

def convert_heading(element):
    level = int(element.name[1])  # h2 → 2
    classes = element.get("class", [])
    text = element.decode_contents()
    return (
        f'<!-- wp:heading {{"level":{level}}} -->\n'
        f'<h{level} class="wp-block-heading {" ".join(classes)}">{text}</h{level}>\n'
        f'<!-- /wp:heading -->'
    )

Komplizierter waren verschachtelte Strukturen — vor allem Listen, wo wp:list-item seit Gutenberg-Version 6.x als eigenständige Blöcke innerhalb von wp:list erwartet werden:

def convert_list(element):
    ordered = element.name == "ol"
    block_type = "ordered" if ordered else "unordered"
    items_markup = "\n".join([
        f'<!-- wp:list-item --><li>{li.decode_contents()}</li><!-- /wp:list-item -->'
        for li in element.find_all("li", recursive=False)
    ])
    tag = "ol" if ordered else "ul"
    return (
        f'<!-- wp:list {{"ordered":{str(ordered).lower()}}} -->\n'
        f'<{tag} class="wp-block-list">\n{items_markup}\n</{tag}>\n'
        f'<!-- /wp:list -->'
    )

ACF Custom Blocks: Das Field-Key-Problem

Das war die eigentliche Komplexität des Projekts. Die Website verwendete mehrere eigene Block-Typen, die über ACF Pro registriert waren. Gutenberg speichert ACF-Block-Attribute nicht mit lesbaren Feldnamen, sondern mit internen Field Keys — eindeutigen IDs, die beim Erstellen des Feldes in ACF generiert wurden.

Diese Keys sind weder aus dem HTML noch aus der REST API automatisch ermittelbar. Sie mussten einmalig aus der Datenbank (oder über acf_get_field() im Code) ausgelesen und dann fest im Importer hinterlegt werden:

ACF_FIELD_KEYS = {
    "anchorlink": {
        "destination": "field_5ce787531418a",
    },
    "internallink": {
        "destination": "field_5cfa2b3e72c53",
    },
    "slider": {
        "post_id": "field_5ced17feae48d",
    },
    "ip_location": {
        "condition":  "field_6107d54dda6da",
        "countries":  "field_6107d5eeda6da",
    },
}

Ein ACF-Block im Gutenberg-Markup sieht dann so aus:

def convert_acf_internallink(destination_post_id):
    key = ACF_FIELD_KEYS["internallink"]["destination"]
    return (
        f'<!-- wp:acf/internallink {{"name":"acf/internallink","data":'
        f'{{"{key}":"{destination_post_id}"}}}} /-->'
    )

Wichtig: ACF Self-Closing Blocks enden mit /-->, nicht mit einem separaten Closing-Kommentar.

Für den Internallink-Block musste außerdem eine INTERNAL_LINK_MAP gepflegt werden — eine Zuordnung von lokalen HTML-Href-Zielen zu WordPress-Post-IDs der englischen Seiten, da die neuen Sprachversionen zur Linkzeit noch nicht existierten:

INTERNAL_LINK_MAP = {
    "/en/product/": 1234,
    "/en/about/":   5678,
    # ...
}

Slug-Matching: Automatisch statt manuell

Damit der Importer die passende englische Quellseite für jede neue Übersetzung findet, war ein automatisches Slug-Matching notwendig. Die lokalen HTML-Dateinamen folgten einer konsistenten Namenskonvention — index.htmlproduct.htmlabout-us.html — und konnten direkt auf englische WordPress-Slugs gemappt werden.

Der Importer fragt die englische Seite via REST API ab und speichert ihre Post-ID für den späteren WPML-Link:

def get_english_post_id(slug):
    response = requests.get(
        f"{WP_BASE_URL}/wp-json/wp/v2/pages",
        params={"slug": slug, "lang": "en"},
        auth=WP_AUTH
    )
    data = response.json()
    if not data:
        raise ValueError(f"Keine englische Seite für Slug '{slug}' gefunden.")
    return data[0]["id"]

WPML Translation Linking via REST API

WPML bietet zwei REST-Endpoints für das programmatische Verknüpfen von Übersetzungen. In der Praxis war keiner davon zuverlässig — je nach WPML-Version und Plugin-Konfiguration reagierte der eine oder der andere:

def link_wpml_translation(new_post_id, source_post_id, language_code):
    endpoints = [
        f"{WP_BASE_URL}/wp-json/wpml/v1/translation",
        f"{WP_BASE_URL}/wp-json/wpml/v1/posts/connect",
    ]
    payload = {
        "post_id":       new_post_id,
        "source_post_id": source_post_id,
        "language_code": language_code,
    }
    for endpoint in endpoints:
        response = requests.post(endpoint, json=payload, auth=WP_AUTH)
        if response.status_code == 200:
            print(f"  ✓ WPML-Link gesetzt via {endpoint}")
            return
    print(f"  ⚠ WPML-Link fehlgeschlagen — bitte manuell verknüpfen (Post {new_post_id})")

Der Fallback-Hinweis im Log war wichtig: In einigen Fällen musste das Linking manuell im WordPress-Backend nachgeholt werden. Das Script hat diese Fälle klar markiert.

Yoast SEO Metadaten aus dem HTML-<HEAD> extrahieren

Die statischen HTML-Dateien enthielten vollständige Yoast-Ausgaben im <head> — <title>, Meta-Description, OpenGraph-Tags, Twitter-Cards, Canonical-URL und Focus-Keyword. Diese Daten sollten 1:1 übernommen werden.

Der Extraktor liest alle relevanten Tags mit BeautifulSoup aus:

from bs4 import BeautifulSoup

def extract_yoast_meta(html_content):
    soup = BeautifulSoup(html_content, "html.parser")
    head = soup.head

    return {
        "_yoast_wpseo_title":              _get_meta(head, "title"),
        "_yoast_wpseo_metadesc":           _get_meta(head, "description"),
        "_yoast_wpseo_opengraph-title":    _get_og(head, "og:title"),
        "_yoast_wpseo_opengraph-description": _get_og(head, "og:description"),
        "_yoast_wpseo_opengraph-image":    _get_og(head, "og:image"),
        "_yoast_wpseo_canonical":          _get_canonical(head),
        "_yoast_wpseo_focuskw":            _get_yoast_focuskw(head),
        "_yoast_wpseo_twitter-title":      _get_meta(head, "twitter:title"),
        "_yoast_wpseo_twitter-description":_get_meta(head, "twitter:description"),
        "_yoast_wpseo_twitter-image":      _get_meta(head, "twitter:image"),
    }

Das Schreiben erfolgt dann beim Erstellen der Seite direkt im meta-Feld des REST-API-Requests — Yoast muss dafür in der rest_api_init-Konfiguration die entsprechenden Meta-Keys freigeben (was bei einer Standard-Yoast-Installation der Fall ist).

Dry-Run und Offline-Modus

Zwei Modi haben die Entwicklung und das Testing erheblich beschleunigt:

--dry-run: Das Script simuliert alle Schritte, schreibt aber nichts in WordPress. Ideal für einen ersten Durchlauf, um Fehler in der Konvertierung oder im Slug-Matching zu finden.

--gutenberg-only: Generiert das Gutenberg-Markup und speichert es lokal als .html-Datei, plus eine begleitende _seo.json-Datei mit den extrahierten Yoast-Metadaten — ohne REST-API-Verbindung. Sehr nützlich zur manuellen Prüfung vor dem Live-Import:

output/
├── about-us.html          # Gutenberg-Markup zur manuellen Prüfung
├── about-us_seo.json      # Yoast-Metadaten als JSON
├── product.html
├── product_seo.json
└── ...

Was noch von Hand gemacht werden musste

Nicht alles konnte automatisiert werden. Der Slider-Block referenziert einen Custom Post Type für Slider-Inhalte. Dieser musste zuerst manuell in der neuen Sprache erstellt werden, bevor seine Post-ID im Importer als Konstante eingetragen werden konnte. Das Script generierte in diesen Fällen einen Platzhalter:

<!-- wp:acf/slider {"name":"acf/slider","data":{"field_5ced17feae48d":"TODO_SLIDER_ID"}} /-->

Diese Stellen wurden nach dem Import per Suche im WordPress-Backend nachgezogen — ein vertretbarer Trade-off gegenüber einer komplexen Vorbedingungslogik im Script.

Erkenntnisse aus dem Projekt

  • ACF Field Keys sind nicht optional. Es gibt keinen sauberen Weg, sie dynamisch aus dem HTML zu ermitteln. Einmalig auslesen und im Code fixieren ist die pragmatische Lösung — und sie funktioniert stabil.
  • Die WPML REST API ist fragil. Der Fallback auf zwei Endpoints und das klare Logging fehlgeschlagener Links hat dem Projekt mehrere stille Fehler erspart.
  • Dry-Run immer zuerst. Der Gutenberg-only-Modus hat beim ersten vollständigen Durchlauf drei Konvertierungsfehler sichtbar gemacht, die im Live-Import schwer zu debuggen gewesen wären.
  • HTML aus WordPress ist ein guter Ausgangspunkt. Wenn der Quell-HTML von WordPress gerendert wurde, sind Klassen, IDs und Strukturen vorhersehbar — was die Konvertierungslogik deutlich einfacher macht als bei beliebigem HTML.
  • Page Template von der Quellseite erben. Im finalen Schritt übernahm der Importer automatisch das page_template-Feld der englischen Quellseite — damit wurde sichergestellt, dass neue Seiten nicht auf dem Default-Template landeten.

Fazit

Für mehrsprachige WordPress-Projekte mit ACF und WPML, bei denen Inhalte in Bulk aus externen Quellen übernommen werden müssen, ist ein Python-Importer über die REST API die robusteste Lösung. Sie ist testbar, reproduzierbar und erzwingt durch ihre Explizitheit ein gutes Verständnis der eigenen Datenstruktur — besonders was ACF Field Keys und WPML-Verlinkungslogik betrifft.

Der Code lässt sich mit geringem Aufwand auf andere Projekte übertragen, solange die ACF-Field-Keys und Slug-Conventions angepasst werden.

Sie benötigen Hilfe bei der Migration oder Integration einer komplexen und umfangreichen mehrsprachigen WordPress-Website?
Kontaktieren Sie mich für ein unverbindliches Gespräch.

Wenn Sie herausfinden möchten, ob ich auch Ihnen helfen kann, dann kontaktieren Sie mich – oder vereinbaren Sie sofort einen Kennenlerntermin.

Mehr über Eberhard Lauth

Seit mehr als 15 Jahren begleite ich Unternehmen und Organisationen bei Digitalisierung und Automatisierung ihres Geschäfts und ihrer Prozesse. Die Leidenschaft für Code sorgt für Ergebnisse aus einem Guss – egal, ob Websites, Onlineshops, Portale oder komplexere digitale Anwendungen.

Diese Lösungen sind derzeit im Einsatz bei KMUs, Medienunternehmen, IT-Firmen, Verbänden und Pharmaunternehmen. Der Fokus liegt dabei nicht nur auf der technischen Umsetzung, sondern auch auf der langfristigen Weiterentwicklung und der Optimierung für Marketing, Conversions und Lead-Generierung – sei es durch Landingpages oder mit skalierbaren und automatisierten Kampagnenlösungen.

Eberhard Lauth, Netzkundig