Eine kurze Anmerkung dazu, warum wir diesen Beitrag schreiben. Wir sind Geonode; wir verkaufen Proxys an Personen, die Daten extrahieren, daher erreichen uns ständig Fragen zu Selektoren. Der relevante Hinweis ist kurz: Fehler bei Selektoren und Probleme mit Proxys führen zu völlig unterschiedlichen Symptomen, und sie zu verwechseln kostet viel Zeit. Eine blockierte Anfrage führt zu einer Challenge-Seite oder einem Fehlerstatus. Ein fehlerhafter contains() liefert eine einwandfreie Seite und ein leeres Ergebnis. Wenn der HTML-Code vorhanden ist und Ihr Ausdruck nichts findet, sind Sie hier genau richtig und das Netzwerk funktioniert einwandfrei.
Die Funktion selbst
Die XPath 1.0-Spezifikation ist knapp gehalten:
Die Funktion contains gibt „true“ zurück, wenn die Zeichenkette des ersten Arguments die Zeichenkette des zweiten Arguments enthält, andernfalls gibt sie „false“ zurück.
Beide Argumente sind Zeichenketten. Zwei Argumente, ein boolesches Ergebnis, Groß-/Kleinschreibung wird beachtet, keine Platzhalter, keine regulären Ausdrücke.
//a[contains(@href, 'download')]
//div[contains(@class, 'product')]
//p[contains(text(), 'Price')]
Das interessante Verhalten zeigt sich ausschließlich darin, was passiert, wenn das von Ihnen übergebene Argument keine Zeichenkette ist – was meistens der Fall ist.
Die „Node-Set“-Falle: „contains()
“ erkennt nur den ersten Knoten Dies ist der Fehler, der dazu führt, dass „mein XPath auf manchen Seiten funktioniert und auf anderen nicht“, und die Spezifikation erklärt ihn ganz genau:
Ein Knotensatz wird in eine Zeichenkette umgewandelt, indem der Zeichenkettenwert des Knotens im Knotensatz zurückgegeben wird, der in der Dokumentreihenfolge an erster Stelle steht. Ist der Knotensatz leer, wird eine leere Zeichenkette zurückgegeben.
Wenn Sie also etwas schreiben, das eine Knotenmenge erzeugt, und diese an ``contains() übergeben, verwirft XPath stillschweigend alles außer dem ersten Knoten.
<div>
<p>Introduction</p>
<p>Price: £42</p>
<p>Availability</p>
</div>
contains(//p, 'Price') → false
Falsch, denn //p
ist eine Knotenmenge aus drei Elementen; bei der Zeichenfolgenkonvertierung wird das erste Element – „Introduction“ – herangezogen, und dieses enthält nicht „Price“. Die anderen beiden Absätze wurden gar nicht berücksichtigt.
Die Lösung besteht darin, das Prädikat pro Knoten anzuwenden, anstatt eine Menge zu konvertieren:
//p[contains(., 'Price')] → the second paragraph
Hier wird contains()
einmal für jeden p
ausgewertet, wobei .
der jeweilige einzelne Knoten ist. Das ist der Unterschied zwischen der Frage „Enthält die Menge dies?“ und „Welche Elemente der Menge enthalten dies?“, und in der Regel ist nur das Zweite gemeint.
Die gleiche Falle tritt bei ``text() auf, das ebenfalls eine Knotenmenge ist: `
//div[contains(text(), 'Price')]
text()
` gibt alle direkten Textknoten-Kinder zurück, und die Zeichenfolgenkonvertierung berücksichtigt nur das erste. Wenn der Text des Elements über mehrere Knoten verteilt ist – was immer dann der Fall ist, wenn verschachtelte Markups vorliegen –, prüfen Sie nur das erste Fragment.
„.
“ versus „text()
“: Die andere Hälfte desselben Problems Die Spezifikation definiert den String-Wert eines Elements wie folgt:
die Verkettung der String-Werte aller Textknoten, die Nachkommen des Elementknotens sind, in Dokumentreihenfolge
Das ist der entscheidende Unterschied zwischen den beiden Formen.
<p>Total: <strong>£42.00</strong> including VAT</p>
contains(., '£42.00') → true (all descendant text, concatenated)
contains(text(), '£42.00') → false (first direct text node only: "Total: ")
.
greift in verschachtelte Elemente hinein. text()
berücksichtigt nur direkte Kinder und davon nur das erste.
**Verwenden Sie standardmäßig .
.** Es entspricht dem, was ein Leser als „den Text dieses Elements“ betrachten würde, und es bleibt auch bei Änderungen am Markup erhalten, beispielsweise wenn jemand einen Wert in ein ``<span>`
` einbindet.
**Verwenden Sie „text()
“ bewusst**, wenn Sie verschachtelte Inhalte gezielt ausschließen möchten – beispielsweise um eine Beschriftung zu finden, ohne den Text innerhalb eines untergeordneten Badges oder Tooltips zu berücksichtigen.
Für „einer der Textknoten enthält dies“ wendet die korrekte Form das Prädikat auf die Textknoten selbst an:
//div[text()[contains(., 'Price')]]
Ausführlich und korrekt.
Leerzeichen: „normalize-space()“ ist nicht optional
Echtes HTML wird formatiert dargestellt, und die Leerzeichen werden in den String-Wert übernommen.
<td>
In stock
</td>
Der String-Wert dieser Zelle lautet „"\n In stock\n"“, sodass ein exakter Vergleich mit „'In stock'“ fehlschlägt.
Die Spezifikation definiert die Korrektur:
Die Funktion normalize-space gibt die Argumentzeichenfolge zurück, wobei Leerzeichen normalisiert werden, indem führende und nachgestellte Leerzeichen entfernt und Folgen von Leerzeichen durch ein einzelnes Leerzeichen ersetzt werden.
//td[normalize-space() = 'In stock']
//td[contains(normalize-space(), 'In stock')]
Beachten Sie, dass „normalize-space()“ ohne Argument auf den Kontextknoten angewendet wird – genau das, was Sie innerhalb eines Prädikats benötigen.
Speziell bei „contains()“ spielen Leerzeichen am Anfang und am Ende eine geringere Rolle, in der Mitte hingegen eine große. Eine Suche nach „'In stock'“ schlägt bei „"In stock"“ fehl, sofern Sie den String nicht zuvor normalisieren. Wenn Sie sich nur eine Gewohnheit für Ihre Selektoren aneignen, dann sollte es das Umschließen von Textvergleichen mit „normalize-space()“ sein.
Klassen richtig abgleichen
Der häufigste Missbrauch von „contains()
“ und derjenige, der am ehesten unbemerkt falsch ist.
//div[contains(@class, 'btn')]
Das passt zu class="btn"
. Es passt auch zu class="btn-primary"
, class="unbtn"
und class="sidebar-btn-group"
. Da @class
eine durch Leerzeichen getrennte Zeichenkette ist und contains()
ein einfacher Teilzeichenfolgen-Test ist, werden Wortgrenzen nicht berücksichtigt.
Die korrekte Vorgehensweise besteht darin, sowohl das Attribut als auch das Ziel mit Leerzeichen aufzufüllen, sodass nur ganze Token übereinstimmen:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
Man kann es so verstehen: Man nehme das Klassenattribut, normalisiere die Leerzeichen, umschließe es mit Leerzeichen, sodass jedes Token auf beiden Seiten durch Leerzeichen getrennt ist, und suche nach dem Ziel, das von Leerzeichen umgeben ist. Aus „class="btn-primary"
“ wird „" btn-primary "
“, was „" btn "
“ nicht enthält. Aus „class="icon btn large"
“ wird „" icon btn large "
“, was es sehr wohl enthält.
Das ist unschön. Es ist aber korrekt, und genau so sieht es letztendlich in jedem ausgereiften Scraping-Code aus. Packen Sie es in einen Helper:
def has_class(name):
return (f"contains(concat(' ', normalize-space(@class), ' '), ' {name} ')")
Wenn Sie zwei Klassen benötigen:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')
and contains(concat(' ', normalize-space(@class), ' '), ' primary ')]
An dieser Stelle ist ein CSS-Selektor – div.btn.primary
– deutlich lesbarer und macht genau das Richtige. Wenn Sie ausschließlich nach Klassen suchen und nach nichts anderem, verwenden Sie CSS. XPath bewährt sich, wenn Sie Textabgleiche oder rückwärtsgerichtete Navigation benötigen – nicht für Dinge, die CSS bereits gut beherrscht. Wir haben die beiden in XPath preceding-sibling verglichen.
Groß-/Kleinschreibung und die „translate()“-Umgehungslösung
Bei „contains()“ wird zwischen Groß- und Kleinschreibung unterschieden, und XPath 1.0 verfügt über keine „lower-case()“-Funktion. Da Browser und Selenium XPath 1.0 implementieren, tritt diese Einschränkung genau dort auf, wo sie am meisten ins Gewicht fällt.
Die Abhilfe nutzt „translate()“, definiert als Rückgabe „der Zeichenkette des ersten Arguments, wobei Vorkommen von Zeichen aus der Zeichenkette des zweiten Arguments durch das Zeichen an der entsprechenden Position in der Zeichenkette des dritten Arguments ersetzt werden“:
//p[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz'), 'price')]
Zeichenweise Transliteration, nur ASCII. Zeichen mit Akzenten werden nicht in Kleinbuchstaben umgewandelt, es sei denn, man erweitert beide Zeichenfolgen so, dass sie diese abdecken, was jedoch schnell unhandlich wird.
Zwei bessere Optionen, sofern verfügbar:
Eine groß-/kleinschreibungsunabhängige Teilzeichenfolge abgleichen. Wenn auf der Seite „Price“ oder „PRICE“, aber niemals „price“ steht, ist der Abgleich mit „'rice'“ zwar unschön, funktioniert aber. Oftmals die pragmatische Lösung.
Verwende eine Bibliothek mit einem umfangreicheren XPath. Serverseitige Parser wie lxml unterstützen EXSLT-Erweiterungen, darunter „re:test()“ für reguläre Ausdrücke, die Groß- und Kleinschreibung sowie vieles mehr berücksichtigen. Browser tun dies nicht, daher kann ein Code-Schnipsel, den du für lxml gefunden hast, in Selenium genau aus diesem Grund fehlschlagen.
Wenn Sie feststellen, dass Sie einen langen „translate()“ schreiben, ist das ein Zeichen dafür, dass Sie für diese Aufgabe über XPath 1.0 hinausgewachsen sind.
Die zugehörigen Zeichenfolgenfunktionen „
contains()
“ gehören zu einer kleinen Familie, und die anderen sind oft präziser.
** „starts-with()
“** — „gibt ‚true‘ zurück, wenn die Zeichenfolge des ersten Arguments mit der Zeichenfolge des zweiten Arguments beginnt“. Spezifischer als „contains()
“ und dementsprechend weniger anfällig für übermäßige Übereinstimmungen:
//a[starts-with(@href, 'https://')]
In XPath 1.0 gibt es keine Funktion „ends-with()
“. Die Umgehungslösung nutzt substring()
und string-length()
, und sie ist so umständlich, dass ein anderer Ansatz in der Regel besser ist.
**substring-before()
und substring-after()
** — das erste „gibt die Teilzeichenfolge der ersten Zeichenkette zurück, die dem ersten Vorkommen der zweiten Zeichenkette vorausgeht … oder die leere Zeichenkette, wenn die erste Zeichenkette die zweite nicht enthält“. Nützlich zum Aufteilen eines Werts innerhalb des Ausdrucks:
substring-after(//span[@class='price'], '£')
**normalize-space()
** — wurde oben bereits behandelt und ist die Funktion, die Sie am häufigsten verwenden sollten.
**translate()
** — Groß-/Kleinschreibung ignorieren sowie Zeichen entfernen, indem sie auf „leer“ gesetzt werden:
translate(., ',', '')
**string-length()
** — leere oder abgeschnittene Werte herausfiltern:
//td[string-length(normalize-space()) > 0]
Der eigentliche Vorteil liegt in der Kombination dieser Funktionen:
//tr[contains(normalize-space(td[1]), 'Weight')]/td[2]
Die zweite Zelle jeder Zeile, in deren erster Zelle „Weight“ steht, ohne Berücksichtigung von Leerzeichen.
Häufig vorkommende Muster
Die folgenden Ausdrücke decken den Großteil der tatsächlichen Extraktionsarbeit ab und sollten griffbereit gehalten werden.
Den Wert neben einer Beschriftung ermitteln. Die mit Abstand häufigste Anforderung beim Scraping strukturierter Seiten:
//dt[contains(normalize-space(), 'Price')]/following-sibling::dd[1]
//th[contains(normalize-space(), 'Weight')]/following-sibling::td[1]
Beachten Sie das „[1]
“ – ohne dieses gibt „following-sibling::td
“ jede nachfolgende Zelle in der Zeile zurück, und Ihr Code nimmt stillschweigend die erste, während Sie davon ausgehen, dass es die einzige war.
Einen Link anhand seines sichtbaren Texts statt anhand seines „href“-Attributs finden:
//a[contains(normalize-space(), 'Download')]
Robuster als der Abgleich mit der URL, wenn es sich bei der URL um einen gehashten Bezeichner handelt, und anfälliger, wenn die Website übersetzt wird. Wählen Sie die Variante, die sich häufiger ändert.
Einen Container anhand seines Inhalts finden:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')][.//span[contains(., 'Sold out')]]
Zwei Prädikate hintereinander: eine Karte, die ein span-Element enthält, in dem „Ausverkauft“ steht. Das lässt sich gut kombinieren und liest sich besser, als wenn man versuchen würde, dies in einer einzigen Bedingung auszudrücken.
Eher ausschließen als einschließen. Oft die klarere Formulierung:
//tr[not(contains(@class, 'header'))]
//li[not(contains(normalize-space(), 'Advertisement'))]
**Eine Schaltfläche finden, unabhängig davon, ob es sich um „button
“ oder „a
“ handelt:**
//*[self::button or self::a][contains(normalize-space(), 'Continue')]
Die Zeile finden, die einen bestimmten Wert enthält, und eine andere Spalte auswählen:
//tr[td[contains(normalize-space(), 'SKU-1234')]]/td[3]
Von innen nach außen lesen: Zeilen, die eine Zelle mit der SKU enthalten, dann die dritte Zelle dieser Zeile. Dies ist das Muster der Tabellensuche, das eine Neuanordnung der Spalten weitaus besser übersteht als ein absoluter Index auf die gesamte Tabelle.
Schützen Sie sich vor leeren Treffern. Ein Prädikat, das leere Zellen herausfiltert, kostet nichts und verhindert eine ganze Reihe von Verwechslungen in nachgelagerten Schritten:
//td[string-length(normalize-space()) > 0][contains(., 'Ltd')]
Wann „contains()“ das falsche Werkzeug ist
Wenn Sie Gleichheit meinen. „contains(., 'Price')“ findet auch Treffer bei „Historic Price“ und „Price excluding VAT“. Wenn Sie genau diese Bezeichnung suchen, verwenden Sie „normalize-space() = 'Price'“. Bei zu vielen Treffern erfolgt keine Meldung – Ihr Code nimmt das erste Ergebnis und erfährt nie, dass es drei gab.
Wenn Sie nach Klassen suchen und nach nichts anderem. CSS erledigt das korrekt und übersichtlich. Siehe oben.
Wenn Sie einen regulären Ausdruck benötigen. XPath 1.0 bietet keinen. Extrahieren Sie notfalls mit contains() und wenden Sie anschließend einen regulären Ausdruck in Ihrer Programmiersprache an, wo Sie auch sehen können, was übereingestimmt hat.
Wenn es einen verlässlichen Bezeichner gibt. Eine ID, ein „data“-Attribut oder eingebettetes JSON-LD ist stabiler als jeder Textabgleich. Text ist Inhalt, und Inhalt ändert sich – ein Redesign, eine Übersetzung oder eine redaktionelle Bearbeitung macht einen auf Text basierenden Selektor unbrauchbar, und nichts warnt Sie davor.
Wenn die Zeichenkette aus einer Benutzereingabe stammt. Das Einfügen von nicht vertrauenswürdigem Text in einen XPath-Ausdruck ist eine XPath-Injektion. Verwenden Sie die Variablenbindung Ihrer Bibliothek, sofern vorhanden, und führen Sie eine ordnungsgemäße Escape-Behandlung durch, wenn dies nicht der Fall ist – insbesondere bei Anführungszeichen, da XPath 1.0 keine Escape-Sequenz für ein Anführungszeichen innerhalb eines String-Literals vorsieht und Sie „concat()“ verwenden müssen, um eine solche zu erstellen.
Häufig gestellte Fragen
Was bewirkt die Funktion „contains()“ in XPath?
Die Funktion gibt „true“ zurück, wenn das erste String-Argument das zweite als Teilzeichenfolge enthält. Beide Argumente sind Strings, bei dem Vergleich wird die Groß-/Kleinschreibung beachtet, und es werden keine Platzhalter oder reguläre Ausdrücke verwendet. Bei der Extraktion dient die Funktion in der Regel dazu, ein Element anhand eines Teils seines Textes oder Attributwerts zu finden.
Warum findet mein XPath-Ausdruck „contains()“ nichts?
Meistens liegt das daran, dass Sie ihm eine Knotenmenge übergeben haben. XPath wandelt eine Knotenmenge in eine Zeichenkette um, indem es den ersten Knoten in der Dokumentreihenfolge berücksichtigt und den Rest ignoriert; daher untersucht „contains(//p, 'x')“ immer nur den ersten Absatz. Wenden Sie das Prädikat stattdessen pro Knoten an: „//p[contains(., 'x')]“.
Was ist der Unterschied zwischen contains(.) und contains(text())? `
.`` verwendet den String-Wert des Elements, den die Spezifikation als Verkettung aller untergeordneten Textknoten definiert – es greift also in verschachtelte Markups ein. ``text()`` gibt direkte Textknoten-Kinder zurück, und bei der String-Konvertierung wird nur der erste berücksichtigt. Verwenden Sie „.`“, es sei denn, Sie möchten verschachtelte Inhalte gezielt ausschließen.
Wie finde ich mit XPath eine Übereinstimmung mit einer Klasse?
Verwenden Sie „contains(concat(' ', normalize-space(@class), ' '), ' name ')“, wodurch das Attribut aufgefüllt wird, sodass nur ganze Token übereinstimmen. Ein einfaches „contains(@class, 'btn')“ passt auch auf „btn-primary“ und „unbtn“. Wenn Sie ausschließlich nach Klassen suchen, ist ein CSS-Selektor übersichtlicher und standardmäßig korrekt.
Ist „contains()“ in XPath groß-/kleinschreibungsabhängig?
Ja, und XPath 1.0 verfügt über keine „lower-case()“-Funktion. Die übliche Abhilfe ist „translate()“ mit expliziter Angabe von Groß- und Kleinbuchstaben, was jedoch nur ASCII-Zeichen unterstützt. Serverseitige Bibliotheken wie lxml unterstützen EXSLT-reguläre Ausdrücke; Browser und Selenium tun dies nicht.
Wie verwende ich contains() mit mehreren Bedingungen?
Kombinieren Sie Prädikate mit and und or: //div[contains(@class, 'card') and contains(., 'In stock')]. Jedes contains() ist ein separater boolescher Test, der anhand desselben Kontextknotens ausgewertet wird.
Verfügt XPath über eine Funktion für „beginnt mit“ oder „endet mit“?
„starts-with()“ existiert und ist, wo es passt, „contains()“ vorzuziehen, da es weniger Übertreffungen liefert. In XPath 1.0 gibt es kein „ends-with()“ – die Umgehungslösung verwendet „substring()“ mit „string-length()“ und ist so umständlich, dass ein anderer Ansatz in der Regel besser ist.
Warum findet contains() mehr Elemente als erwartet?
Weil es sich um einen Teilzeichenfolgen-Test ohne Berücksichtigung von Wortgrenzen handelt. contains(., 'Price') findet auch „Historic Price“ und „Price excluding VAT“. Verwenden Sie normalize-space() = 'Price' für die Gleichheit oder das „padded-concat“-Idiom für Klassen-Token.
Fazit: „
contains()“ ist in der Theorie einfach, birgt in der Praxis jedoch zahlreiche Fallstricke, die fast alle auf eine einzige Ursache zurückzuführen sind: XPath wandelt eine Knotenmenge in eine Zeichenkette um, indem es den ersten Knoten übernimmt und den Rest verworfen wird. Diese eine Regel erklärt, warum contains(//p, 'x') ein scheinbar sicheres, aber falsches Ergebnis liefert, warum contains(text(), 'x') Text überspringt, der sich über mehrere Knoten erstreckt, und warum derselbe Ausdruck auf einer Seite funktioniert, auf der nächsten jedoch fehlschlägt.
Es gibt nur wenige Vorgehensweisen, mit denen sich dies vermeiden lässt. Wenden Sie contains() innerhalb eines Prädikats an, damit es pro Knoten ausgewertet wird. Verwenden Sie . anstelle von text(), es sei denn, Sie haben einen Grund dafür. Schließen Sie Textvergleiche in normalize-space() ein, da echtes HTML formatiert ist. Und für den Abgleich von Klassen verwenden Sie entweder das „padded-concat“-Idiom oder – besser noch – einen CSS-Selektor, der genau für diese Aufgabe entwickelt wurde.
Reservieren Sie XPath für das, was es einzigartig kann: das Abgleichen von Text und das Rückwärtsnavigieren. Das sind echte Fähigkeiten, für die es kein CSS-Äquivalent gibt, und sie sind die Syntax wert. Die Verwendung von XPath zur Auswahl von div.card bedeutet, den Aufwand zu betreiben, ohne den Nutzen zu erzielen.
