PHP: Wie parst man HTML richtig?
Man will eine Information aus einer HTML-Seite herausziehen und greift zum ersten Werkzeug das einem einfällt: einem regulären Ausdruck. Das geht meistens schief. PHP bringt für diese Aufgabe alles mit, was man braucht.
Alle Ausgaben stammen aus einem Skript, das ich gegen PHP 8.5.9 mit libxml 2.9.14 laufen lassen habe.
Das Werkzeug: DOMDocument
Du brauchst nichts zu installieren. Die Erweiterungen dom, libxml und
simplexml sind Teil von PHP. Für HTML nimmst du DOMDocument:
<?php
$dom = new DOMDocument();
$dom->loadHTML($html);
?>
Der Punkt, den viele nicht wissen: loadHTML() kommt mit kaputtem HTML
zurecht. Dahinter steckt der HTML-Parser von libxml, und der ist genau dafür
gebaut. Nehmen wir dieses absichtlich fehlerhafte Markup — nicht geschlossenes
<p>, ein Attribut ohne Anführungszeichen, Großschreibung, ein nacktes &:
<html><body>
<DIV class=teaser>
<p>Erstes Produkt & Zubehör
<a href="/a.html">Produkt A</a>
<p>Zweites Produkt
<a href='/b.html'>Produkt B</a>
</DIV>
</body></html>
Das lädt problemlos.
libxml_use_internal_errors nicht vergessen
Bei kaputtem HTML schreibt libxml Warnungen ins Log bzw. in die Ausgabe. Das
willst du in der Regel nicht. Mit libxml_use_internal_errors(true) sammelst du
die Meldungen ein, statt sie ausgeben zu lassen:
<?php
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML($html);
$fehler = libxml_get_errors(); // falls du sie doch ansehen willst
libxml_clear_errors();
?>
Bei unserem Beispiel oben meldet libxml genau ein Problem
(htmlParseEntityRef: no name, wegen des nackten &) — und parst trotzdem
weiter.
Daten auslesen mit XPath
Zum Suchen nimmst du DOMXPath. Damit beschreibst du, was du haben willst,
statt zu beschreiben, wie die Zeichen drumherum aussehen:
<?php
$xpath = new DOMXPath($dom);
foreach ($xpath->query('//div[@class="teaser"]//a') as $a) {
echo $a->textContent . ' -> ' . $a->getAttribute('href') . "\n";
}
?>
Ausgabe:
Produkt A -> /a.html
Produkt B -> /b.html
Attribute liest du mit getAttribute(), den Text mit textContent. Ein
praktisches Beispiel, Preise aus einer Liste aufsummieren:
RS 1000 10.40 EUR
CCX11 23.68 EUR
sum: 34.08 EUR
Brauchst du nicht den Text, sondern das Markup eines Teilbaums, nimmst du
saveHTML() mit einem Knoten als Argument:
<?php
echo $dom->saveHTML($knoten);
?>
Die Falle mit der Zeichenkodierung
Und jetzt der Fehler, an dem man einen Nachmittag verlieren kann. Ohne einen
Hinweis auf die Kodierung nimmt loadHTML() ISO-8859-1 an. Bei UTF-8
kommt dann das hier heraus:
ohne charset : Preis in Euro: 10,40 ⬠für Zubehör
Es gibt zwei Wege das zu beheben. Entweder das Dokument enthält ein
<meta charset="utf-8">, dann erkennt libxml die Kodierung selbst. Oder du
stellst dem Markup eine XML-Deklaration voran:
<?php
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
?>
Beides führt zum richtigen Ergebnis:
mit XML-Hinweis : Preis in Euro: 10,40 € für Zubehör
mit meta charset : Preis in Euro: 10,40 € für Zubehör
Wenn dir also Umlaute zerschossen werden: Das ist der Grund.
SimpleXML ist kein HTML-Parser
SimpleXML ist bequem, aber es will wohlgeformtes XML. Auf unser kaputtes
HTML angesetzt:
simplexml_load_string($html) => false
Für XML (etwa einen RSS-Feed) ist SimpleXML eine gute Wahl. Für HTML aus freier Wildbahn nicht.
Warum nicht einfach ein regulärer Ausdruck?
Weil er beim ersten Windstoß umfällt. Nehmen wir einen durchaus vernünftig aussehenden Ausdruck, um Links zu finden:
<?php
preg_match_all('#<a href="([^"]+)">([^<]+)</a>#', $html, $treffer);
?>
Das Ergebnis bei unserem Beispiel:
regulärer Ausdruck : 1 von 2 Links
DOM : 2 von 2 Links
Der zweite Link steht in einfachen statt doppelten Anführungszeichen. Ein einziges Zeichen Unterschied im Markup, und der Ausdruck übersieht die Hälfte. Genau das ist der Punkt: Ein HTML-Parser kennt die Regeln von HTML bereits. Einem regulären Ausdruck musst du sie jedes Mal neu beibringen — und du wirst immer einen Fall vergessen.
Das heißt nicht, dass reguläre Ausdrücke verboten sind. Für sehr einfache Extraktionen aus Markup dessen Form du kennst, sind sie schnell und in Ordnung. Sobald es aber um Verschachtelung, Attribute in beliebiger Reihenfolge oder fremdes Markup geht, nimm einen Parser.
Und die ganzen Bibliotheken?
Auf Stack Overflow findest du eine lange Liste an Bibliotheken für diese Aufgabe. Die Liste ist allerdings in die Jahre gekommen: Einige der dort genannten Projekte werden nicht mehr gepflegt, andere befinden sich nur noch im Sicherheitsmodus. Und die beiden bekanntesten, die nicht auf libxml aufsetzen, werden in derselben Antwort ausdrücklich nicht empfohlen, weil sie langsam und speicherhungrig sind.
Mein Rat: Fang mit DOMDocument und DOMXPath an. Das ist eingebaut, schnell
und du kannst alles damit machen. Wenn du lieber CSS-Selektoren statt XPath
schreibst, ist der
DomCrawler aus
den Symfony-Komponenten eine gute und aktiv gepflegte Ergänzung. Er lässt sich
auch ohne den Rest von Symfony verwenden.
Zusammenfassung
DOMDocument::loadHTML()verkraftet kaputtes HTML, das ist kein Mythos.libxml_use_internal_errors(true)schaltet den Warnungsregen ab.- Zum Suchen nimmst du
DOMXPath. - Ohne
<meta charset>bzw.<?xml encoding="UTF-8">zerschießt es dir die Umlaute. SimpleXMList für XML, nicht für HTML.- Reguläre Ausdrücke brechen, sobald sich das Markup minimal ändert.
Hinweis zu Netcup (Werbung)
Der deutsche Hoster Netcup bietet unter anderem günstige und zugleich leistungsstarke Webhosting Pakete, KVM-basierte Root Server und dezidierte Server an. Mit unseren Gutscheincodes kannst du noch mehr Geld sparen (6€ bei deiner ersten Bestellung, 30% Rabatt auf alle KVM-basierten Root Server, ...).