ここで私たちが関わる部分はごくわずかですが、一応明記しておきます。私たちは Geonode であり、データ抽出作業を行う方々にプロキシを販売しているため、サポートのやり取りでは常にセレクタに関する話題が出てきます。比較に入る前に言っておくべきことは、どちらの選択肢を選んでも、ブロックされるかどうかに影響はないということです。 セレクタに関する問題とネットワークに関する問題は、一見すると同じように見えます(どちらも「スクレイパーがデータを返さなくなった」という結果になります)が、実際には何の共通点もありません。ページが正常に表示され、式に一致する要素が何もなかった場合、これはセレクタの問題であり、インフラストラクチャの選択をどのように変更しても解決しません。
概要
| CSS | XPath | |
|---|---|---|
| クラス、ID、属性による選択 | 可能(簡潔) | 可能(扱いにくい) |
| 子孫および子要素の関係 | 可能 | 可能 |
| テキスト内容による選択 | 不可 | 可能 |
| 親または先祖への移動 | 一部可能(:has() 経由) | 可能(直接) |
| 前の兄弟要素への移動 | 一部可能(:has() 経由) | 可能(直接) |
| 位置指定による選択 | :nth-child() ファミリー | position()、last()、述語 |
| 文字列関数 | いいえ | はい |
| 名前空間を含む XML のクエリ | いいえ | はい |
| 可読性 | 優れている | 劣っている |
| ツールおよびブラウザのサポート | 普遍的 | 1.0 では普遍的 |
2つの行が最も重要なポイントとなっています。CSSではテキストコンテンツによる選択がまったくできないため、最も一般的な抽出パターンである「隣接するラベルから値を特定する」ことができません。また、XPathは可読性が低いのですが、1年後に同僚があなたのコードをメンテナンスすることになった際、この点は人々が認める以上に重要な問題となります。
CSSが優れている点
クラスおよび属性の選択において、その差は歴然としています。
div.product-card
a[href^="https://"]
input[type="checkbox"]:checked
ul > li:first-child
section.content p:not(.footnote)
これに対し、XPathでの同等の表現はより長くなり、特にクラスの場合は実に扱いにくいものです:
//div[contains(concat(' ', normalize-space(@class), ' '), ' product-card ')]
//a[starts-with(@href, 'https://')]
//ul/li[1]
最初の例はクラスマッチングの定型表現ですが、これが存在する理由は、@class
がスペースで区切られた単一の文字列であり、XPathにはその内部にトークンという概念がないためです。 単純な ``contains(@class, 'product-card')`
は ``product-card-large
や ``old-product-card
にも一致してしまうため、スペースを埋め込んだバージョンが正しいものとなります。また、これは ``div.product-card
` の4倍の長さがあり、スキャンするのもかなり困難です。
選択条件がクラス、ID、属性、および構造的な関係である場合は、CSSを使用してください。 実際の選択作業の大部分はこれらでカバーされており、そこにXPathを選択することは、実際には使用していない機能のために可読性を犠牲にすることになります。
また、CSSの方がツールの面でも優れています。どのブラウザの要素インスペクタもネイティブにCSSセレクタを生成し、ほとんどのテストフレームワークではデフォルトでCSSセレクタが使用され、document.querySelectorAll
はヘルパーなしでどこでも利用可能です。
XPathの優れている点
CSSではまったく不可能なテキストの一致検索:
//button[normalize-space()='Continue']
//a[contains(., 'Download')]
//dt[normalize-space()='Price']/following-sibling::dd[1]
最後のパターン――ラベルを検索し、隣接する値を取得する――は、構造化されたページからの情報抽出において中核となる手法ですが、CSSにはテキストコンテンツにアクセスする機能がないため、これに対応するCSS式は存在しません。 この単一の機能こそが、本来はCSSを全面的に使用しているコードベースにおいても、XPathがスクレイピングに引き続き利用されている理由です。
祖先ナビゲーション:
//span[@class='price']/ancestor::div[contains(@class,'card')][1]
値から、それを格納するコンテナへと遡って移動します。:has()
はCSSにこれに近い機能を提供しますが、その制限については後述します。
コンテンツに対する相対的な位置論理:
//h2[normalize-space()='Specifications']/following-sibling::table[1]
特定の見出しの直後の最初のテーブル。:nth-child()
は兄弟要素間の位置を数えるものであり、「テキストが X である要素の直後」という条件を表現することはできません。
文字列関数。 normalize-space()
、substring-before()
、translate()
などを使用すると、式内で処理を行うことができます。特に normalize-space()
はほぼ必須と言えます。実際の HTML は整形されているため、"\n In stock\n"
との正確なテキスト比較は失敗するからです。
名前空間を含むXML。 HTMLではなくXML(サイトマップ、RSSフィード、SOAPレスポンスなど)をクエリする場合、CSSは適切なツールではありません。XPathはこうした用途のために設計されており、名前空間の処理もその設計の一部となっています。
『:has()』がもたらした変化
この比較において最も重要な進展であり、それ以前に書かれた記事も数多くあります。
MDNのドキュメントによると、:has()は、「引数として渡された相対セレクタのいずれかが、この要素を基準として少なくとも1つの要素と一致する場合、その要素を表す」ものであり、「参照要素を基準として、親要素または前の兄弟要素を選択する方法」を提供するものと説明されています。 そのベースラインステータスは「広く利用可能」であり、2023年12月以降、各ブラウザでサポートされています。
これにより、CSSでは以前表現できなかったことが表現できるようになりました:
div.card:has(span.sold-out) /* a card containing a sold-out marker */
h1:has(+ p) /* an h1 immediately followed by a p */
li:has(~ li.active) /* an li with a later active sibling */
tr:has(td.error) /* a row containing an error cell */
これは、以前XPathが必要とされていた用途の相当な部分をカバーしています。つまり、親要素の選択や、兄弟要素を条件とした選択です。
3つの制限が文書化されており、知っておく価値があります。:has()は「別の:has()内にネストすることはできません」。疑似要素は「:has()内では有効なセレクタとはみなされず」、そのアンカーとしても有効ではありません。また、その特異度は「引数の中で最も特異度の高いセレクタの特異度」であり、:is()や:not()の挙動と一致しています。
抽出において最も重要な制限は、そのリストには含まれていません::has() は依然としてテキストにマッチできません。 div:has(span) は機能しますが、div:has(span:contains('Price')) は存在しません。なぜなら、:contains() は標準的な CSS セレクタではないからです。これは提案されたものの取り下げられており、ブラウザも実装していません。したがって、:has() にかかわらず、ラベル-値のパターンは引き続き XPath の領域となります。
並列翻訳
このトレードオフの感覚をつかむ最も手っ取り早い方法は、同じ意図が両方の方法で表現されている例を見ることです。
| 意図 | CSS | XPath |
|---|---|---|
| クラスを持つ要素 | div.card | //div[contains(concat(' ',normalize-space(@class),' '),' card ')] |
| IDを持つ要素 | #main | //*[@id='main'] |
| 属性が存在する | a[href] | //a[@href] |
| 属性がで始まる | a[href^="/docs"] | //a[starts-with(@href,'/docs')] |
| 属性を含む | a[href*="pdf"] | //a[contains(@href,'pdf')] |
| 直接の子要素 | ul > li | //ul/li |
| 任意の子孫 | div p | //div//p |
| 最初の子要素 | li:first-child | //li[1] |
| 最後の子要素 | li:last-child | //li[last()] |
| n番目の子要素 | li:nth-child(3) | //li[3] |
| 次の兄弟要素 | h2 + p | //h2/following-sibling::p[1] |
| それ以降の任意の兄弟要素 | h2 ~ p | //h2/following-sibling::p |
| 否定 | p:not(.footnote) | //p[not(contains(@class,'footnote'))] |
| 一致する要素の親 | div:has(> span.price) | //span[contains(@class,'price')]/.. |
| テキストを含む | 不可 | //p[contains(., 'Price')] |
| 完全一致のテキスト | 不可 | //p[normalize-space()='Price'] |
| ラベルの隣の値 | 不可 | //dt[normalize-space()='Price']/following-sibling::dd[1] |
この表を下に読み進めると、その傾向は明らかです。親行より上の項目についてはすべて、CSSの方が短く明確であり、XPathを選ぶことは何のメリットもない冗長さを選ぶことに他なりません。下部の3行については、CSSの列がまったく存在しません。
2つの変換例については、改めて検討する価値があります。「class」の行は、この比較全体の中で最も顕著な対照を示しています――9文字対70文字強――。また、XPath版が単に冗長になっているわけではなく、短縮形の contains(@class,'card') は、card-large や discard よりも実際に範囲が広すぎるのです。 また、「最初の子」の行には落とし穴が隠されています。li:first-child と //li[1] はここでは一致しますが、//li[1] は親ごとに適用される述語であるため、ドキュメント内の すべての リストの下にある最初の li を選択してしまいます。CSS は曖昧さがありませんが、XPath では、1つだけを指す場合は (//li)[1] が必要です。
実際の混合例
実際に商品ページを抽出する場合の例です。
# CSS for the structural work — clear and adequate
cards = tree.cssselect('div.product-grid > article.product-card')
for card in cards:
name = card.cssselect('h3.product-name')[0].text_content().strip()
image = card.cssselect('img.product-image')[0].get('src')
# XPath for the label-value pairs, which CSS cannot express
price = card.xpath(
".//dt[normalize-space()='Price']/following-sibling::dd[1]"
)[0].text_content().strip()
stock = card.xpath(
".//span[contains(., 'in stock') or contains(., 'out of stock')]"
)
この中で、特に参考になる点が3つあります。
各XPath式の前につく「.」です。 .//dt は現在のカード内を検索しますが、//dt はルートからドキュメント全体を検索し、ページ上のすべてのカードについて、最初に一致したラベルを返してしまいます。これは混合抽出コードで最もよくあるバグの一つであり、すべてのレコードで同じ値を生成してしまいます――一見妥当で一貫性があるように見えますが、間違っています。
CSSで済む部分はCSSで。 グリッドやカードの選択、見出し、画像などです。これらをXPathで記述すると、コードが長くなり、明瞭さが損なわれます。
XPathは必要な箇所でのみ使用。 価格はラベルで識別され、在庫状況はテキストで識別されます。 どちらもCSSでは表現できず、これらこそが、そもそもこのファイルにXPathが含まれている理由です。
その結果、各式が可能な限り短くまとめられたスクレイパーが完成し、読者はどの部分が構造に依存し、どの部分がコンテンツに依存しているかを一目で把握できます。これは同時に、サイトが変更された際にどの部分が機能しなくなるかを示すマップでもあります。
パフォーマンス
通常は関係がないが、時として決定的な要素となる。
CSSは一般的にブラウザ上で高速に動作する。これは、エンジンがセレクタのマッチングを徹底的に最適化しているためであり、レンダリングのクリティカルパス上にあるからだ。一方、XPathはより一般的な評価モデルを経由する。
この違いが問題になることはほとんどない。ほとんどの人が扱う規模では、1ページから数百回選択を行う程度の処理では、どちらの場合でも測定可能な差は生じない。ネットワークリクエストの方が桁違いに大きな負荷となる。
問題となる場合:
preceding および following 軸は、実際に処理コストが高い。 これらは、コンテキストノードの前後にあるドキュメント全体をスキャンする。多数のノードを巡回するループ内では、その処理量は二次関数的に増加する。XPath式が著しく遅い場合は、これらを使用していないか確認すること。preceding-sibling および following-sibling は、兄弟ノードの数によって上限が決まるため、問題はない。
** ネストされた式の先頭にある // は、ルートから再検索を行います。** //div//span は見た目以上に処理負荷が高く、div を巡回するループ内での .//span が、通常意図している動作です。
:has() は、 大規模なドキュメントではコストがかかる可能性があります。これは、エンジンが各候補について内部セレクタを評価する必要があるためです。抽出には問題ありませんが、大規模なページに適用されるスタイルシートでは注意が必要な点です。
lxml などのサーバーサイド解析では、その差はごくわずかであるため、可読性を優先して決定すべきです。
利用可能性とバージョンの落とし穴
「オンラインテスターでは動くのに、自分のコードでは動かない」という現象を引き起こす落とし穴。
ブラウザは、document.evaluateを通じてXPath 1.0を実装しており、Seleniumはブラウザのエンジンを利用しています。XPath 2.0および3.1の機能(matches()、replace()、lower-case()、ends-with()、シーケンスなど)は、そこでは利用できません。これらのいずれかを使用したコードスニペットは失敗します。
lxmlは、共通APIとしてXPath 1.0を実装しており、さらに正規表現用のre:test()を含むEXSLT拡張機能もサポートしています。したがって、Pythonで動作する正規表現ベースの式は、Seleniumでは動作しません。
パーシングライブラリにおけるCSSのサポートは、通常、内部でCSSをXPathに変換する「変換レイヤー」を介して行われます。これは一般的なセレクタではうまく機能しますが、新しいセレクタではあまりうまくいきません。サーバーサイドのパーサーにおける:has()のサポート状況は大きく異なり、ブラウザで動作するセレクタでも、スクレイパーでは動作しない可能性があります。
実用的なルール: セレクタは、ブラウザのコンソールやオンラインツールではなく、実際に実行される環境でテストしてください。最もよくある2つの予期せぬ問題として、SeleniumでXPath 2.0関数が動作しないことや、Pythonパーサーで:has()が動作しないことが挙げられます。
実践的な選択
ほぼすべてのケースに対応できる決定手順。
まずはCSSから始めましょう。 CSSは可読性が高く、ツールも充実しており、クラス、ID、属性、構造による選択――つまり選択の大部分――には十分対応できます。
テキストを条件に一致させる必要がある場合は、XPathに切り替えてください。 これが主な理由であり、決定的な理由です。CSSの構文ではテキストコンテンツを読み取ることはできません。
:has()でカバーできない範囲の祖先ナビゲーションにはXPathに切り替えてください。特に、既知の親要素に対する条件付きマッチングではなく、数レベル上の特定の祖先要素が必要な場合に有効です。
XMLの処理にはXPathに切り替えてください。 名前空間やドキュメント順序に基づく操作こそが、XPathが設計された本来の目的です。
1つのコードベースで両方を併用しましょう。 これは妥協ではなく、ごく普通で賢明な選択です。単純な90%の部分にはCSSを、難しい10%の部分にはXPathを使用するスクレイパーは、どちらか一方に固執するスクレイパーよりもメンテナンスが容易です。
そして、どちらの選択肢よりも重要な習慣があります。セレクタを記述する前に、埋め込まれた構造化データがあるかどうかを確認することです。 多くのページでは、検索機能を駆動するために<script type="application/ld+json">ブロック内にJSON-LDが含まれており、その解析はレンダリングされたマークアップの解析よりもはるかに安定しています。ページ上のすべてのセレクタが機能しなくなるようなデザイン変更があっても、このデータは残ります。
どちらも解決できない問題
どちらも同じ点で脆弱であり、この点が実際の時間を浪費する原因となっています。
構造セレクタは、何の前触れもなく動作しなくなることがあります。 誰かが列を挿入すると、td[3]が別のセルをマッチさせてしまいます。エラーは発生せず、データは気づかれないうちに間違った状態になってしまいます。可能な限りテキストや識別子を基準にし、抽出するデータの形式を検証してください。価格が価格らしく見えるべき場合は、それを確認してください。
どちらもクライアント側でレンダリングされたコンテンツには対応していません。 読み込み後に JavaScript によってマークアップが組み立てられる場合、どちらも初期の HTML からは何も見つかりません。これは、セレクタの問題ではなく、ブラウザや基盤となる API が必要な取得に関する問題です。
どちらも、それだけでは再設計に耐えられません。 これを回避するには、セレクタを一箇所にまとめておくことです。そうすれば、変更後の更新に1日ではなく1時間で済みます。また、解析したHTMLを保存しておけば、何かが壊れた際に新旧を比較できます。
また、どちらの方法もページの取得方法の影響を受けません。 これが冒頭で述べた点です。ドキュメントが完全な状態で、式が何も一致しない場合、インフラストラクチャをいくら変更しても答えは変わりません。
よくある質問
XPathはCSSセレクタよりも優れていますか?
一般的に、どちらが優れているというわけではありません。XPathは機能性が豊富で、テキストのマッチングや親要素への移動、文字列関数の使用が可能です。一方、CSSは可読性が高く、ツールによるサポートも充実しています。基本的にはCSSを使用し、CSSでは表現できない特定のケースに限りXPathを活用しましょう。
CSSセレクタでテキストを指定できますか?
いいえ。テキストコンテンツを指定するための標準的なCSSセレクタは存在しません。:contains()が提案されましたが、採用されることはなく、ブラウザでも実装されていません。これが最大の機能格差であり、データ抽出作業においてXPathが依然として使われ続けている主な理由です。
:has() は XPath に取って代わるのでしょうか?
一部はそうです。これによって、以前は XPath でのみ可能だった CSS の親要素選択や兄弟要素の条件付きマッチングが可能になりました。ただし、テキストマッチング機能は追加されていないため、ラベルと値のパターンには依然として XPath が必要です。また、ネストすることはできず、サーバーサイドの解析ライブラリによるサポート状況もまちまちです。
XPathとCSS、どちらが高速か?
ブラウザでは、セレクタのマッチングがレンダリング向けに最適化されているため、一般的にCSSの方が高速です。ただし、ネットワーク通信にかかる時間に比べれば、その差は通常無視できる程度です。XPathが真に遅くなるのは、ドキュメント全体をスキャンするprecedingおよびfollowing軸の場合です。
SeleniumでXPath 2.0は使用できますか?
いいえ。ブラウザはdocument.evaluateを通じてXPath 1.0を実装しており、Seleniumはブラウザのエンジンを使用します。matches()、lower-case()、ends-with()などの関数は利用できません。これが、スニペットがオンラインテスターでは動作するのに、テストスイートでは失敗する一般的な理由です。
親要素を選択するにはどうすればよいですか?
XPathでは、parent:: または .. を使用します。CSSでは、:has() が条件付き形式を提供します。例えば、div:has(> span.price) は span ではなく div を選択します。数レベル上の特定の祖先要素を選択するには、XPathの ancestor:: 軸の方が直接的です。
ウェブスクレイピングにはCSSとXPathのどちらを使うべきか?
同じコードベース内で両方を使うべきです。クラス、ID、属性の選択にはCSSを使います。これらが大部分を占めます。テキストに一致させる必要がある場合はXPathを使います。ラベルから値を特定するのは典型的なケースであり、CSSにはこれに対応する機能がありません。
サイトが変更された場合、どちらがより安定していますか?
本質的にはどちらも安定しているわけではありません。安定性は、使用する言語ではなく、何をアンカーにするかによって決まります。テキストやdata-*属性をアンカーにすれば、サイトのリデザイン後も機能しますが、位置をアンカーにすると、リデザインで機能しなくなります。どちらの言語でも、どちらの方法も使用可能です。
まとめ
この比較は、2つの非対称性に帰着します。CSSはテキストを読み取ることができず、XPathは読みづらいということです。それ以外はすべて細部です。
したがって、実用上のルールは単純明快だ。デフォルトではCSSを使う。なぜなら、選択のほとんどはクラス、ID、または属性によるものであり、CSSはそれらを明確に表現できるからだ。XPathに頼るのは、XPathならではの機能が必要となる特定の場面に限るべきだ。何よりもまずテキストの一致検索、次に祖先ナビゲーション、そして文字列関数である。 両者を併用するのはごく普通のことであり、それぞれの得意分野に応じて使い分けたコードベースは、どちらか一方に固執したコードベースよりもメンテナンスが容易です。
:has()は両者の差を確実に縮めており、適した場面では採用する価値があります。具体的には、2023年後半から広く利用可能になった、プレーンCSSでの親要素の選択や兄弟要素の条件指定などです。 ただし、テキストには対応しておらず、サーバーサイドのパーサーによるサポートはブラウザのサポートほど統一されていない点に注意してください。
したがって、式を信頼する前に、必ず実行環境を確認してください。ブラウザやSeleniumにおけるXPath 1.0、lxmlのEXSLT拡張機能、解析ライブラリにおける:has()のサポート状況など――セレクタに関する最も一般的な「予期せぬ問題」は、構文エラーではなく、実行している環境とは異なる場所でしか利用できない機能によるものです。
