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.
  • SimpleXML ist 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, ...).