15 Jahre WordPress, 15 Jahre Müll: So räumst du Altlasten in der Datenbank auf, ohne etwas kaputtzumachen

15 Jahre WordPress, 15 Jahre Müll: So räumst du Altlasten in der Datenbank auf, ohne etwas kaputtzumachen

Ein Blog, der seit über zehn Jahren läuft, schleppt Reste von Themes, Übersetzungs-Plugins und Umzügen mit sich herum. Ich zeige dir, wie ich solche Altlasten mit WP-CLI finde und entferne – immer mit Backup und Probelauf vorher.

Du kennst das: Ein Blog läuft seit 2010. Drei Themes, zwei Domainwechsel, ein Übersetzungs-Plugin, ein Umfrage-Plugin, ein Twitter-Plugin – alles längst gelöscht. Nur die Datenbank hat nichts vergessen. Und irgendwann steht in einem Beitrag „fast 90{29e1…} der Wähler“ statt „fast 90 %“, und du fragst dich, seit wann das so ist.

In meinem Fall ging es um einen gewachsenen Gesellschafts-Blog mit mehreren hundert Beiträgen und vielen Gastautoren. Eigentlich wollte ich nur die Suchmaschinenoptimierung angehen. Unterwegs bin ich über so viele Altlasten gestolpert, dass das WordPress Datenbank aufräumen zum eigenen Projekt wurde. Hier zeige ich dir, wie ich die fünf häufigsten Fundstücke gefunden und entfernt habe – jeweils mit Probelauf, damit du (oder dein zukünftiges Ich) nicht aus Versehen den halben Blog umschreibt.

Ziel

  • Kaputte Prozentzeichen ({64 Hex-Zeichen} statt %) reparieren.
  • Alte Domains und http://-Links auf die aktuelle Adresse umstellen.
  • Reste eines gelöschten Übersetzungs-Plugins (<!--:de-->) entfernen.
  • Tote Shortcodes wie [twitter_hashtag …] oder [poll id="2"] aus den Texten holen.
  • Seiten reparieren, die an einer gelöschten Elternseite hängen.

Voraussetzungen

  • SSH-Zugang zum Server und WP-CLI. Unter Plesk heißt der Aufruf zum Beispiel plesk ext wp-toolkit --wp-cli -instance-id 3 -- …; in den Beispielen schreibe ich der Kürze halber nur wp.
  • Genug Platz für ein Datenbank-Backup.
  • Etwas Geduld beim Probelauf. Der ist der eigentliche Trick an der Sache.

Hinweis: Das Tabellen-Präfix ist bei alten Installationen selten wp_. Du findest es mit wp db prefix.

Schritt 1: Backup, und zwar vor jedem einzelnen Schritt

wp db export - > /root/backups/blog/db-$(date +%Y%m%d-%H%M)-vor-aufraeumen.sql
chmod 600 /root/backups/blog/*.sql
tail -c 60 /root/backups/blog/db-*-vor-aufraeumen.sql

Die letzte Zeile muss Dump completed on … lauten. Fehlt sie, ist das Backup unvollständig.

Danach wieder prüfen: Ich lege vor jedem der folgenden Schritte ein neues Backup mit sprechendem Namen an (…-vor-prozent-fix.sql, …-vor-shortcodes.sql). Klingt übertrieben, spart aber im Ernstfall die Frage, welcher Stand welcher war.

Schritt 2: Bestandsaufnahme

Bevor du etwas änderst, zähl nach. Ein paar Abfragen, die bei mir fündig wurden:

wp eval '
global $wpdb; $p = $wpdb->posts;
$q = [
  "Prozent-Platzhalter" => "SELECT COUNT(*) FROM $p WHERE post_status=\"publish\" AND post_content REGEXP \"\\\\{[0-9a-f]{64}\\\\}\"",
  "alte http-Links"     => "SELECT COUNT(*) FROM $p WHERE post_status=\"publish\" AND post_content LIKE \"%http://example.com%\"",
  "qTranslate-Reste"    => "SELECT COUNT(*) FROM $p WHERE post_status=\"publish\" AND post_content LIKE \"%<!--:%\"",
  "Shortcode poll"      => "SELECT COUNT(*) FROM $p WHERE post_status=\"publish\" AND post_content LIKE \"%[poll%\"",
];
foreach ($q as $k => $s) echo "$k: " . $wpdb->get_var($s) . PHP_EOL;'

Hinweis: Über den Plesk-Wrapper liefert wp db query bei mir keine Ausgabe. wp eval mit $wpdb funktioniert dagegen zuverlässig.

Schritt 3: Der Prozent-Fehler

Was dahintersteckt: Seit WordPress 4.8.3 ersetzt $wpdb->prepare() jedes % in einer Abfrage durch einen Platzhalter aus 64 Hex-Zeichen in geschweiften Klammern und tauscht ihn erst direkt vor dem Ausführen wieder zurück. Plugins, die das Ergebnis von prepare() speichern, statt es auszuführen, schreiben den Platzhalter dauerhaft in die Datenbank. Der Code ist pro Installation immer derselbe.

Den Code findest du so (er steht nach dem Backup im Klartext in der SQL-Datei):

grep -o -E "\{[0-9a-f]{64}\}" /root/backups/blog/db-*-vor-prozent-fix.sql | sort | uniq -c

Bei mir war es ein einziger Code mit gut 5.000 Vorkommen. Probelauf:

CODE='{…die 64 Zeichen aus der Ausgabe…}'
wp search-replace "$CODE" '%' --all-tables-with-prefix --skip-columns=guid --dry-run --report-changed-only

Die Liste zeigt, welche Tabellen und Spalten betroffen sind. Ich habe danach gezielt nur Inhalte, Titel, Auszüge, Metadaten und Optionen repariert:

wp search-replace "$CODE" '%' wp_posts wp_postmeta wp_options \
  --include-columns=post_content,post_title,post_excerpt,meta_value,option_value \
  --report-changed-only

Bewusst nicht angefasst: post_name (sonst ändern sich Beitrags-URLs) und die Tabellen von Link-Checker und Newsletter.

Netter Nebeneffekt: Serialisierte Plugin-Einstellungen, deren Längenangaben durch den Code nicht mehr stimmten, sind danach wieder lesbar.

Nach zwei Domainwechseln stecken in Hunderten Beiträgen noch http://example.com/… und http://www.example.org/…. Die Links funktionieren über Weiterleitungen, kosten aber bei jedem Klick ein bis zwei Umwege.

for ALT in "http://www.example.com" "http://example.com" "http://www.example.org" "http://example.org"; do
  wp search-replace "$ALT" "https://example.org" wp_posts wp_postmeta \
    --include-columns=post_content,post_excerpt,meta_value --dry-run --report-changed-only
done

Passt die Liste, das Gleiche ohne --dry-run.

Hinweis: --format=count liefert über den Plesk-Wrapper bei mir eine leere Ausgabe. --report-changed-only zeigt dagegen eine Tabelle, die ohnehin aussagekräftiger ist.

Schritt 5: Reste eines Übersetzungs-Plugins

qTranslate hat Texte in Kommentar-Markierungen verpackt: <!--:de-->Text<!--:-->. Auf der Seite sieht man davon nichts, aber in Feeds, Exporten und manchen Vorschauen tauchen sie auf.

Wichtig vor dem Löschen: Prüf, ob in den Blöcken noch andere Sprachen mit Inhalt stecken. Ich habe das Skript so gebaut, dass es solche Beiträge überspringt und meldet:

<?php
// aufraeumen.php – Aufruf: wp eval-file aufraeumen.php [wirklich]
global $wpdb;
$echt = ($args[0] ?? '') === 'wirklich';
$rows = $wpdb->get_results("SELECT ID, post_title, post_content FROM {$wpdb->posts}
  WHERE post_status='publish' AND CONCAT(post_title, post_content) LIKE '%<!--:%'");
foreach ($rows as $r) {
    preg_match_all('/<!--:([a-z]{2,3})-->(.*?)<!--:-->/is', $r->post_title . $r->post_content, $m, PREG_SET_ORDER);
    $fremd = array_filter($m, fn($b) => strtolower($b[1]) !== 'de' && trim(wp_strip_all_tags($b[2])) !== '');
    if ($fremd) { echo "fremdsprachig, übersprungen: {$r->ID}\n"; continue; }
    $neu = [
        'post_title'   => preg_replace('/<!--:[a-z]{0,3}-->/i', '', $r->post_title),
        'post_content' => preg_replace('/<!--:[a-z]{0,3}-->/i', '', $r->post_content),
    ];
    if ($echt) { $wpdb->update($wpdb->posts, $neu, ['ID' => $r->ID]); clean_post_cache($r->ID); }
}
echo $echt ? "fertig\n" : "Probelauf\n";

Die übersprungenen Beiträge habe ich mir einzeln angesehen und dann entschieden: einsprachig – Markierungen raus; zweisprachig – beide Sprachen stehen lassen, nur die Markierungen raus.

Hinweis: Ich ändere hier direkt per $wpdb->update() statt mit wp_update_post(). So entstehen keine Revisionen, und das Änderungsdatum der Beiträge bleibt, wie es war.

Schritt 6: Tote Shortcodes

Ist ein Plugin gelöscht, bleibt sein Shortcode als nackter Text im Beitrag stehen – sichtbar für jeden Leser. Bei mir: [twitter_hashtag hash="…" number="3" …] in 112 Beiträgen, [poll id="4"] in 35. Im gleichen Skript wie oben, nur mit anderem Muster:

$muster = '/\s*(?:<p[^>]*>\s*)?\[(?:twitter_hashtag|poll)\b[^\]]*\](?:\s*<\/p>)?/';
$neu = preg_replace($muster, '', $r->post_content);

Der Teil mit <p…> sorgt dafür, dass kein leerer Absatz übrig bleibt, wenn der Shortcode allein in einer Zeile stand.

Hinweis: shortcode_exists() hilft hier nicht weiter, weil WP-CLI je nach Umgebung nicht alle Plugins lädt. Ob ein Shortcode tot ist, siehst du zuverlässiger auf der Live-Seite: Steht er dort als Text, ist er tot.

Schritt 7: Seiten mit gelöschter Elternseite

Eine Seite lieferte 404, obwohl sie veröffentlicht war. Ursache: Sie hing unter einer Elternseite, die es nicht mehr gab. WordPress baut die Adresse dann trotzdem, findet die Seite aber nicht.

wp eval 'global $wpdb; print_r($wpdb->get_results("SELECT c.ID, c.post_name, c.post_parent
  FROM {$wpdb->posts} c LEFT JOIN {$wpdb->posts} p ON p.ID = c.post_parent
  WHERE c.post_type=\"page\" AND c.post_status=\"publish\" AND c.post_parent > 0 AND p.ID IS NULL"));'

Repariert ist das mit einer Zeile: post_parent auf 0 setzen. Danach ist die Seite wieder unter ihrer Adresse erreichbar und taucht auch in der Sitemap auf.

Wenn Yoast SEO im Spiel ist

Yoast speichert Titel, Beschreibungen und Vorschaubilder zusätzlich in der Tabelle …_yoast_indexable. Direkte Änderungen an der Datenbank bekommt Yoast nicht mit. Entweder du ziehst die betroffenen Spalten dort mit, oder du baust die Indexables neu auf:

wp yoast index --reindex

Bei großen Blogs dauert das eine Weile. Ich habe die betroffenen Zeilen direkt mitgezogen.

So prüfst du es

  • Die Zählabfragen aus Schritt 2 noch einmal laufen lassen: Alles sollte auf 0 stehen.
  • Ein paar betroffene Beiträge im Browser öffnen, gezielt die mit Prozentzahlen, Tabellen (width: 100%) und den entfernten Shortcodes.
  • Startseite, eine Kategorie, den Feed und wp-login.php aufrufen: alles 200.
  • Seiten-Cache leeren (bei Plesk mit nginx-Cache die Dateien unter /var/cache/nginx/<domain>_proxy/ löschen), sonst siehst du den alten Stand.

Rückweg

wp db import /root/backups/blog/db-…-vor-aufraeumen.sql

Das setzt die komplette Datenbank auf den Stand vor dem jeweiligen Schritt zurück – deshalb die sprechenden Dateinamen aus Schritt 1.

Stolperstellen aus der Praxis

  • Großbuchstaben: Neben <!--:de--> gab es auch <!--:TR-->. Ohne das i im Muster bleiben die stehen.
  • Fremdsprachige Blöcke: Einfach alle Markierungen löschen ist harmlos für die Anzeige, aber du verlierst die Information, welcher Text welche Sprache war. Erst prüfen, dann entscheiden.
  • post_name und guid nie blind ersetzen. Das eine ändert URLs, das andere ist die interne Kennung von Beiträgen und Anhängen.
  • Alte Einstellungen von gelöschten Themes und Plugins enthalten oft auch alte Domains. Die habe ich bewusst gelassen: Sie wirken nirgends mehr, und jede Änderung dort ist ein unnötiges Risiko.
  • Yoast-Optionsnamen raten geht schief. Der Schalter für „Schlagwort-Archive nicht indexieren“ heißt noindex-tax-post_tag, nicht noindex-post_tag. Ein falscher Schlüssel wird klaglos gespeichert und tut nichts. Danach also immer auf der Live-Seite nachsehen.
  • Der Fehler kann wiederkommen. Welches Plugin den Prozent-Platzhalter damals geschrieben hat, ließ sich nicht mehr klären. Die Zählabfrage aus Schritt 2 gelegentlich laufen lassen schadet nicht.

Fazit

Ein alter Blog ist wie ein Dachboden: Von außen sieht alles ordentlich aus, und erst beim Aufräumen merkst du, was da alles liegt. Mit WP-CLI, einem Backup vor jedem Schritt und einem ehrlichen Probelauf ist das Entrümpeln aber kein Glücksspiel – und am Ende stehen in deinen Beiträgen wieder Prozentzeichen statt Hex-Salat.

Total
0
Shares
Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Previous Post
Fünf Jahre kein Update: So habe ich Kali auf dem Raspberry Pi wiederbelebt, ohne neu zu installieren

Fünf Jahre kein Update: So habe ich Kali auf dem Raspberry Pi wiederbelebt, ohne neu zu installieren

Next Post
Apache 2.4.58 und trotzdem sicher: Warum dich die Versionsnummer bei der mod_http2-Lücke in die Irre führt

Apache 2.4.58 und trotzdem sicher: Warum dich die Versionsnummer bei der mod_http2-Lücke in die Irre führt

Related Posts
Total
0
Share