Veröffentlicht am 1 Kommentar

DOMDocument statt regulärer Ausdrücke für HTML-Operationen

Wenn es daran geht, innerhalb seines HTMLs Elemente mit PHP abzuändern, landet man schnell bei regulären Ausdrücken und preg_replace bzw. preg_match. Doch reguläre Ausdrücke sind zum Parsen von HTML eigentlich weder geeignet noch dafür gedacht. Hier kommt DOMDocument ins Spiel.

Warum keine regulären Ausdrücke?

Reguläre Ausdrücke dienen dazu, „[…] zur Beschreibung von Sprachen, also Mengen von bestimmten Wörtern, verwendet […]“[1] zu werden. HTML selbst ist jedoch keine reguläre Sprache, sondern für reguläre Ausdrücke zu komplex.

Auch wenn es endlos viele Beispiele gibt, mit regulären Ausdrücken HTML zu parsen, so ist das immer die Suche nach einer Problemlösung, für die es bereits eine fertige Lösung gibt.

DOMDocument

Mit DOMDocument hast du mit PHP selbst die Möglichkeit, ähnliche Operationen am Document Object Model (DOM) beliebigen HTMLs durchzuführen, wie mit JavaScript. Für eine möglichst steile Lernkurve wurden sogar viele Methoden von DOMDocument identisch genannt wie ihre Entsprechung in JavaScript, z. B. in Form von getElementById(). Damit brachen die Entwickler von PHP sogar bewusst ihre eigene Namenskonvention.

Während man bei regulären Ausdrücken Parameter und ähnliches erst umständlich aus dem Code parsen muss, geht das mit DOMDocument dagegen praktisch wie von selbst. In diesem Falle dank der Methode getAttribute().

Gleichzeitig bietet in meinen Benchmark-Tests DOMDocument keinerlei Performance-Nachteile im Vergleich zu regulären Ausdrücken und hat daher vor allem Vorteile. Auch ist anhand des Codes wesentlich einfacher zu erkennen, welche Bereiche eines Elements aktuell verarbeitet/verändert werden, während bei regulären Ausdrücken zum einen erst der reguläre Ausdruck verstanden werden will und bei der Verarbeitung in der Regel mit $matches[x] und ähnlichem gearbeitet wird. Ziemlich nichtssagend.

Aller Anfang ist schwer

Auch ich habe mich lange davor gesträubt, DOMDocument wirklich produktiv einzusetzen. Ein paar mal bin ich schon vor mehreren Jahren darüber gestolpert, weil ich mit regulären Ausdrücken einfach nicht ans Ziel kam. Aber die geringe Dokumentation und daher mein geringeres Verständnis haben mich stets davon abgehalten, mir das Ganze näher anzuschauen.

Sehen wir uns beispielsweise einmal an, wie wir mit DOMDocument überhaupt erst einmal HTML verarbeiten können:

libxml_use_internal_errors( true );
$dom = new DOMDocument();
$dom->loadHTML(
	mb_convert_encoding(
		'<html>' . $content . '</html>',
		'HTML-ENTITIES',
		'UTF-8'
	),
	LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);

Solange man kein vollständiges HTML-Dokument bearbeitet, das den erforderlichen Doctype besitzt, muss man libxml_use_internal_errors( true ) setzen. Damit werden Fehler der Bibliothek libxml nicht ausgegeben und man muss sich selbst um etwaige Fehler kümmern. (Möchte man die Fehler verarbeiten, erhält man diese über libxml_get_errors()).

Danach initialisieren wir in Zeile 2 die Klasse DOMDocument und laden in Zeile 3–10 den HTML-Code, der in diesem Beispiel innerhalb von $content sitzt. Ich weiß an dieser Stelle, dass $content nie ein vollständiges HTML-Dokument ist. Um es aber am Ende immer sauber verarbeiten zu können, benötigt es diese Elemente, weshalb ich diese in Zeile 5 hinzufüge. Außerdem stelle ich sicher, dass wir mit einer UTF-8-Zeichenkette arbeiten, da loadHTML standardmäßig mit ISO-8859-1 arbeitet.

Die beiden Flags LIBXML_HTML_NOIMPLIED und LIBXML_HTML_NODEFDTD geben noch an, dass nicht automatisch <html>– und <body>-Elemente bzw. ein Doctype automatisch hinzugefügt werden.

Das Speichern ist dagegen verhältnismäßig simpel, da hierbei das gesamte Dokument nur gespeichert werden muss. Danach sollte dann noch das umschließende <html> entfernt werden. Dieses stellt sicher, dass bei der Verarbeitung des innerliegenden HTMLs keine Probleme auftreten, was ansonsten in manchen Konstellationen der Fall sein kann. Im Code sieht das dann folgendermaßen aus:

str_replace( [ '<html>', '</html>' ], '', $dom->saveHTML( $dom->documentElement ) );

Die Verarbeitung

Nun wissen wir, wie man HTML-Code mit DOMDocument öffnen und wieder speichern und somit ausgeben kann. Doch wie verarbeite ich es überhaupt, damit ein neues Abspeichern erforderlich ist?

Wie bereits zu Beginn angedeutet, gibt es zu JavaScript ähnliche Möglichkeiten, das HTML zu verarbeiten. In diesem konkreten Fall werde ich alle Bild-Elemente innerhalb des HTML, die noch per http:// beginnen, auf https:// abändern. Alle Bild-Elemente erhalte ich per $dom->getElementsByTagName( 'img' ) und kann durch diese in einer foreach-Schleife durchgehen (Zeile 1). Über das entsprechende Attribut src prüfe ich dann, ob dieses http:// beinhaltet und ersetze es, falls das der Fall ist, durch https:// (Zeile 8).

Dabei verwende ich, ebenfalls identisch zu JavaScript, die Methoden getAttribute() und setAttribute().

foreach ( $dom->getElementsByTagName( 'img' ) as $image ) {
	// check if image already has HTTPS
	if ( strpos( $image->getAttribute( 'src' ), 'http://' ) === false ) {
		continue;
	}
	
	// replace all http:// with https:// in src attribute
	$image->setAttribute( 'src', str_replace( 'http://', 'https://', $image->getAttribute( 'src' ) ) );
}

Zusammengefasst

Insgesamt sieht meine Funktion dann folgendermaßen aus und kann beispielsweise über den Filter the_content in HTML verwendet werden.

function my_replace_http_with_https( $content ) {
	libxml_use_internal_errors( true );
	$dom = new DOMDocument();
	$dom->loadHTML(
		mb_convert_encoding(
			'<html>' . $content . '</html>',
			'HTML-ENTITIES',
			'UTF-8'
		),
		LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
	);
	
	// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
	foreach ( $dom->getElementsByTagName( 'img' ) as $image ) {
		// check if image already has HTTPS
		if ( strpos( $image->getAttribute( 'src' ), 'http://' ) === false ) {
			continue;
		}
		
		// replace all http:// with https:// in src attribute
		$image->setAttribute( 'src', str_replace( 'http://', 'https://', $image->getAttribute( 'src' ) ) );
	}
	
	return str_replace( [ '<html>', '</html>' ], '', $dom->saveHTML( $dom->documentElement ) );
	// phpcs:enable
}

add_filter( 'the_content', 'my_replace_http_with_https' );

Wer nun meint, das sei nur ein konstruiertes Problem und in der wirklichen Welt gibt es dafür keine Verwendung, der hat weit gefehlt: Wir verwenden genau diese Technik beispielsweise innerhalb von Embed Privacy erfolgreich (Code-Beispiel #1, Code-Beispiel #2).

Warum ist DOMDocument so unbekannt?

Ich kann nur mutmaßen. Für mich war DOMDocument lange Zeit ein Buch mit sieben Siegeln, da die Dokumentation leider sehr unzureichend ist, es gleichermaßen noch weitere Klassen gibt, die man kennen sollte (wie DOMElement) und es leider auch verhältnismäßig wenige Anleitungen über deren Nutzung gibt.

Nicht zuletzt ist das auch ein Grund und Motivation für mich gewesen, darüber zu schreiben, damit zumindest der Einstieg für andere einfacher ist.

Goodie: Vergleichsfunktion mit regulären Ausdrücken

Benchmark-Tests und Vergleiche habe ich mit dieser Funktion mit regulären Ausdrücken durchgeführt:

function my_regex_replace_http_with_https( $content ) {
    preg_match_all( '/<img ((?!src).)*src="([^"]+)"([^>])*>/', $content, $matches );
    
    if ( empty( $matches[2] ) ) {
		return $content;
	}
    
	foreach ( $matches[2] as $match ) {
        $https = str_replace( 'http://', 'https://', $match );
        $content = str_replace( 'src="' . $match . '"', 'src="' . $https . '"', $content );
    }
    
    return $content;
}

add_filter( 'the_content', 'my_regex_replace_http_with_https' );

Dieser Code ist auch ein gutes Beispiel dafür, dass ein kürzerer Code nicht zwingend besser ist. In diesem Fall ist er fast halb so lang wie die Variante mit DOMDocument, dafür aber wesentlich schlechter verständlich und fehleranfällig.

Ein Gedanke zu „DOMDocument statt regulärer Ausdrücke für HTML-Operationen

  1. Spannend! Hätte nicht gedacht, dass DOMDocument mit der Performance von Regex bei kleineren Änderungen konkurrieren kann. Ich werde es nächstes Mal versuchen statt Regex zu verwenden. Bisher habe ich DOMDocument nur verwendet, wenn ich Informationen scrapen wollte.

Schreibe einen Kommentar zu Johannes Antworten abbrechen

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