私たちの立場は簡単に説明できます。私たちは Geonode であり、プロキシを販売していますが、これはクラス選択とは何の関係もありません。 ここで重要な点は、クラスベースのセレクタが最も不安定な種類であり、その不安定さは外部から見るとまさにネットワークの問題のように見えるということです――リクエストは成功し、ページも表示されるのに、データ抽出の結果が何も返ってこないか、間違った結果が返ってくるのです。 動作しなくなったスクレイパーをデバッグする場合は、ページの取得方法について調べる前に、クラス名が変更されていないか確認してください。最新のビルドパイプラインを使用しているサイトでは、デプロイのたびにクラス名が変更される可能性があります。
「class」属性の問題 `
`class`` にはスペース区切りのトークンのリストが含まれていますが、XPath にはそのようなリストの概念がありません。XPath からは、1 つの文字列として認識されます。
<div class="card featured large">...</div>
XPathにとって、@class は文字列 "card featured large" として扱われます。「card がトークンの一つであるか」を問い合わせる組み込みの方法は存在しませんが、これはまさに、CSSセレクタが .card によってネイティブに回答する質問そのものです。
このギャップが、2つの失敗モードを生み出しています。
完全一致は厳しすぎる:
//div[@class='card']
class="card" にのみ一致し、それ以外や余分な空白を含むものは一切一致しません。class="card featured"、class="featured card"、class=" card " には一致しません。実際のページでは、意図したもののほとんどを捕捉できません。
部分文字列の一致は緩すぎる:
//div[contains(@class, 'card')]
class="card" には正しく一致しますが、class="card-large"、class="postcard"、class="discard"、class="card-footer" にも一致してしまいます。関連する名称が含まれるページでは、要求した結果のスーパーセットが返され、コードは何も警告せずに最初の結果を採り入れてしまいます。
2つ目の不具合の方がより危険です。なぜなら、結果が返されてしまうからです。何かが返され、一見妥当に見えますが、実際には間違った要素であるのです。
正しいイディオム
標準的な解決策では、両端にスペースを補填し、トークン全体のみが一致するようにします:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]
手順を追って見ていきましょう:
**normalize-space(@class)
** は、先頭と末尾の空白を削除し、内部の連続した空白を単一のスペースに圧縮します。" card featured "
は "card featured"
になります。
**concat(' ', ..., ' ')
** は、結果の両端の空白を削除し、内部の連続した空白を単一のスペースに圧縮します。 は になります。これで、すべてのトークンが両端でスペースで区切られるようになります。
**contains(..., ' card ')
** は、スペースで囲まれたターゲットを検索します。
単純なバージョンで処理に失敗したケースで検証してみてください:
| クラス属性 | パディングされた文字列 | ' card '
を含むか? |
|---|---|---|
| card
| " card "
| はい |
| card featured
| " card featured "
| はい |
| featured card
| " featured card "
| はい |
| card-large
| " card-large "
| いいえ |
| discard
| " discard "
| いいえ |
| postcard footer
| " postcard footer "
| いいえ |
| card
| " card "
| はい |
いずれの場合も正しいです。「normalize-space()
」というステップは単なる飾りではありません。これがなければ、二重スペースを含む ``class="card featured"`
は ``" card featured "
となり、これには ``" card "
が含まれているためたまたま動作しますが、改行を含む ``class="card\nfeatured"
` は動作しなくなります。
これは冗長です。次のようにラップしましょう:
def has_class(name):
return f"contains(concat(' ', normalize-space(@class), ' '), ' {name} ')"
tree.xpath(f"//div[{has_class('card')}]")
const hasClass = name =>
`contains(concat(' ', normalize-space(@class), ' '), ' ${name} ')`;
XPath を使って本格的なデータ抽出を行っているコードベースなら、いずれはこのようなヘルパー関数の何らかの形を採用することになります。20個の式のうち1つでパディングを間違えるよりは、一度だけ書いておくほうが賢明です。
複数のクラス
and
と組み合わせる:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
and contains(concat(' ', normalize-space(@class), ' '), ' featured ')]
ここで、記述の冗長さが本当に厄介になってきます。CSS では div.card.featured
と書ける内容を、140 文字も使って表現しているのです。
「2 つのクラスのいずれか」の場合は、or
を使用します:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
or contains(concat(' ', normalize-space(@class), ' '), ' tile ')]
「このクラスは持つが、あのクラスは持たない」場合は:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')
and not(contains(concat(' ', normalize-space(@class), ' '), ' hidden '))]
クラス条件が2つ以上ある場合は、CSSセレクタで代用できないかを真剣に検討してください。div.card.featured:not(.hidden)
は、同じロジックを5分の1の文字数で記述できており、主要な解析ライブラリはすべて、XPathに加えてCSSセレクタもサポートしています。ファイル全体で1つの言語を選ばなければならないというルールはありません。
生成およびハッシュ化されたクラス名
これは現代的な課題であり、これによってアドバイスも変わります。
CSS Modules、styled-components、さまざまなCSS-in-JSライブラリなど、多くのフロントエンドビルドツールは、衝突を避けるためにスコープ付きのクラス名を生成します。 その結果は次のような形になります:
<div class="ProductCard_container__3xK9p">...</div>
<div class="css-1x9dj2k">...</div>
ハッシュ部分はコンポーネントのスタイルが変更されるたびに変化し、実際には多くのデプロイのたびに変化することになります。フルネームにアンカーされたセレクタは、警告なしに動作しなくなります。
これに対処する3つの方法を、推奨順に紹介します。
安定したプレフィックスにアンカーする(ツールがプレフィックスを生成する場合):
//div[starts-with(@class, 'ProductCard_container__')]
CSS Modules では通常、ComponentName_elementName__hash
のような形式が生成されるため、最後の二重アンダースコア以前の部分はビルド間で安定しています。これが適用可能な場合は、うまく機能します。
**代わりに ``data-*`
属性を探す。** 多くのアプリケーションでは、自動ツールが安定したフックとして利用できるよう、data-testid`
、data-test
などの属性が明示的に含まれています:
//div[@data-testid='product-card']
もし存在すれば、それを使用してください。生成されたものではなく意図的に選ばれたものであるため、どのクラス名よりも安定しています。
まったく別の要素を基準にする。 見出し、テキストコンテンツ、または要素タイプを基準にします。//h2[normalize-space()='Featured']/following-sibling::div[1]
は、クラスの名称が何であるかを気にしません。
そして、完全に不透明なケース(css-1x9dj2k
のように安定した要素がない場合)では、クラスベースの選択は単に実行不可能であり、そうではないふりをしても、毎週動作しなくなるスクレイパーしか生まれません。ページ上の構造化データ、あるいはページ自体が呼び出している API を探してください。
クラスの順序と空白
多くの人がつまずきがちで、正確に理解しておく価値のある2つの点です。
属性内のクラスの順序には意味がありません。 class="card featured"
と class="featured card"
は、ブラウザにとってもCSSにとっても同等です。「padded-concat」という表記法は両方を正しく処理しますが、完全一致ではどちらも確実に処理されません。 もし順序に依存する式を書いてしまっていることに気づいたら、それは何かが間違っているというサインです。
空白文字はどのようなものでも構いません。 タブや改行はクラス属性内の有効な区切り文字であり、手作業でフォーマットされたHTMLにはこれらが出現します:
<div class="card
featured">
normalize-space()
これらはすべて結合されます。そのため、このイディオムにはそれらが含まれているのです。 正規化を行わずに ``concat(' ', @class, ' ') を使用する式は、そのマークアップでは失敗しますが、レンダリングされたページ上ではその失敗は目に見えません。
大文字と小文字は区別されます。 XHTML として解析される HTML ドキュメントではクラス名は大文字と小文字が区別されますが、CSS マッチングのための標準モードの HTML では大文字と小文字が区別されません。しかし、XPath の文字列比較では常に大文字と小文字が区別されます。 ページ内で Card` ` と card が混在している場合、パディング付き構文ではこれらを異なるものとして扱います。XPath 1.0 には lower-case()` ` 関数が存在しないため、回避策として translate() を使用し、アルファベットを明示的に指定する必要があります。この場合、式は完全に判読不能となり、CSS セレクタの方が明らかに優れた選択肢となります。
クラスの祖先または子孫を取得する
クラスの選択は、通常、目的そのものではなく手段です。そのほとんどは、以下の2つのパターンでカバーされます。
クラスからその内部の要素へ:
//div[contains(concat(' ',normalize-space(@class),' '),' card ')]//span[@class='price']
あるいは、すでにcard要素を含むコードでは、相対表現を使用します。この場合、先頭のドットは必須です:
for card in tree.xpath(f"//div[{has_class('card')}]"):
price = card.xpath(".//span[@class='price']/text()")
`
.//span
はcard要素内を検索します。 ``//span
`はルートからドキュメント全体を検索し、各カードについてページ上の最初の価格を返します。 これにより、一見一貫性があり妥当に見えるが実際には誤った出力が生成され、これはデータ抽出コードにおいて最も一般的なバグの一つです。
要素からそのコンテナまで:
//span[@class='price']/ancestor::div[contains(concat(' ',normalize-space(@class),' '),' card ')][1]
[1]
の指定は重要です:ancestor::
は逆軸となるため、位置1は最外層の要素ではなく、最も近い一致する祖先要素を指します。これを指定しないと、すべての一致する祖先要素が取得されてしまいますが、ネストされたコンテナがある場合、それが意図した結果となることはめったにありません。
ライブラリごとの注意点
イディオムはどこでも同じですが、それを取り巻くAPIは異なり、ライブラリ間のわずかな違いが、本来なら避けられるはずの混乱を招いています。
lxml (Python)。 両方の言語をサポートしており、授業での課題には cssselect
が実用的な選択肢となります:
from lxml import html
tree = html.fromstring(source)
tree.cssselect('div.card.featured') # clear
tree.xpath(f"//div[{has_class('card')}]") # when inside a larger expression
cssselect
は内部でCSSをXPathに変換するため、サポートするセレクタに関しては両者の機能は同等です。なお、これはlxml本体とは別のパッケージであるため、別途インストールが必要です。
BeautifulSoup (Python)。 XPathをまったくサポートしていません。これは、他のエコシステムから移行してきた人々にとっては意外な事実です。CSSセレクタにはselect()
を提供し、独自のfind_all(class_='card')
APIを備えており、こちらはネイティブで正しいトークンマッチングを行います。特にXPathが必要な場合は、lxmlが必要です。
Selenium。 By.XPATH
とBy.CSS_SELECTOR
の両方を受け付け、それぞれについてブラウザのエンジンを使用します。つまり、XPath 1.0のみに対応しており、CSSのサポート範囲はブラウザがサポートする範囲に準じます(:has()
を含む)。
Playwright。 文字列からロケーターの種類を自動検出するため、page.locator('div.card')
やpage.locator('//div[@id="x"]')
は、接頭辞なしで動作します。 また、テキストベースのロケーター(page.getByText()
、page.getByRole()
)も提供しており、これらは以前XPathのテキストマッチングが必要とされていた用途の大部分をカバーし、どちらの言語よりも可読性が高いです。
Scrapy。 セレクタに対して .css()
および .xpath()
を提供しており、これらはチェーン可能で、非常に便利です:
for card in response.css('div.card'):
price = card.xpath(".//dt[normalize-space()='Price']/following-sibling::dd[1]/text()").get()
構造解析にはCSSを、ラベル検索にはXPathを、同じ式チェーン内で使用できます。XPathの先頭に.
を付ける点に注意してください。Scrapyの連鎖セレクタも、他のものと同様に「ルート相対」の落とし穴があります。
ブラウザのコンソール。 $x('//div[@class="card"]')
はChromeおよびFirefoxの開発者ツールでXPathを評価し、document.querySelectorAll('div.card')
はCSSを処理します。コードに組み込む前にここで式をテストするのは10秒の価値がありますが、ブラウザのDOMはJavaScript実行後の状態であるのに対し、パーサーのDOMはそうではないという点に注意が必要です。コンソールで動作する式でも、生のHTMLでは何も見つからない場合があります。
代わりにCSSを使うべき場合
端的に言えば、この特定の用途においては、答えは通常「はい」だからです。
条件がクラスである場合はCSSを使用してください。 div.card, div.card.featured, div.card:not(.hidden) — いずれもより明確で、短く、デフォルトで正しい結果が得られます。CSSはclass属性をトークンリストとして解釈しますが、これはまさにXPathに欠けている機能です。
クラスが付随的な要素であり、真の条件がCSSでは表現できないもの(とりわけテキストコンテンツ)である場合は、XPathを使用してください。//div[contains(@class,'card')][.//span[contains(., 'Sold out')]] では、テキストの条件にXPathが必要であり、クラス部分はそれに付随する形で含まれます。
両方を組み合わせて使いましょう。 主要な解析ライブラリはすべて、両方をサポートしています。lxmlにはcssselectとxpathの両方があり、Seleniumも両方のロケーター戦略を受け入れ、Playwrightも同様です。構造的な選択にはCSSを、テキスト条件にはXPathを使用することは妥協ではなく、最も短く読みやすいコードを生み出す手法なのです。
XPathクラス選択が適しているのは、より大規模なXPath式の中でそれを必要とし、式の中で言語を切り替えることが不可能な場合のみです。これは現実的な制約であり、このイディオムが存在する理由でもあります。しかし、実際の現場で見られるXPathクラスマッチングの用途に比べれば、その適用範囲は狭いものです。
よくある質問
XPathでクラス名に基づいて要素を選択するにはどうすればよいですか?
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')] を使用してください。パディングを付けることで、クラストークン全体が一致する場合のみが対象となります。単純な @class='card' では、追加のクラスを持つ要素が除外されてしまいます。また、contains(@class,'card') は card-large や discard にも一致してしまいます。
なぜ contains(@class, 'name') は誤った要素に一致してしまうのですか?
これは、単語の境界を認識しない単純な部分文字列の比較であるためです。class 属性はスペース区切りのリストですが、XPath は単一の文字列として認識するため、contains(@class,'card') は、どこかにこれら 4 文字を含む任意のクラス名に一致してしまいます。
XPathで2つのクラスを持つ要素を選択するにはどうすればよいですか?
and を使用して、2つのパデッド・コンキャット条件を組み合わせます。これで動作し、文字数は約140文字になります。クラス条件が2つ以上ある場合は、CSSセレクタ(div.card.featured)の方がはるかに分かりやすく、ほとんどのパーシングライブラリでこれを使用できます。
XPathではクラスの順序は重要ですか?
「padded-concat」というイディオムを使用する場合は重要ではありません。このイディオムでは、属性内のどこにトークンが現れても一致します。ただし、完全一致の比較を行う場合は順序が重要になります。これが、@class='card featured'が不適切な選択であるいくつかの理由の一つです。HTMLにおいてクラスの順序には意味がないため、それに依存するセレクタはバグの原因となりかねません。
ランダムに生成されたクラス名はどのように扱えばよいですか?
ツールによって生成された場合は starts-with() を使用して安定したプレフィックスを基準にしたり、アプリケーションが data-testid 属性を提供している場合はそれを使用したり、あるいはテキストや構造など、まったく別の要素を基準にしたりします。安定した部分を持たない完全に不透明なハッシュ名の場合、クラスに基づく選択は現実的ではありません。
クラスによる選択には、XPathとCSSのどちらが適していますか?
明らかにCSSです。CSSはclass属性をトークンリストとして扱います(実際その通りです)。そのため、div.cardはより短く、かつ正しい記述となります。XPathでは、同じことを表現するために70文字ものイディオムが必要になります。 テキストのマッチングなど、CSSではできない処理が必要な場合にのみ、XPathを使用してください。
XPathでクラスに基づいて親要素を見つけるにはどうすればよいですか?
//span[@class='price']/ancestor::div[contains(concat(' ',normalize-space(@class),' '),' card ')][1]。[1]は、最も近い一致する祖先要素を選択します。これは、ancestor::が逆軸であり、位置1が「最も外側」ではなく「最も近い」を意味するためです。
相対XPathがすべての要素で同じ値を返すのはなぜですか?
ループ内で .// ではなく // を使用しているためです。先頭に // が付いた場合は、コンテキストに関係なくドキュメントのルートから検索されるため、すべての反復でページ全体から最初の一致が見つかります。.// は現在の要素内を検索するため、これが意図した動作です。
まとめ
クラスの選択において、XPathの古さが顕著に表れます。class属性はトークンのリストですが、XPathにはトークン演算がないため、「このクラスを持つ」という条件を表現するには、文字列にスペースを埋め込んで、その埋め込まれたトークンを検索する必要があります。これは、CSSなら9文字で表現できる内容を、XPathでは70文字も要する慣用表現なのです。
このイディオムは、より大規模なXPath式の中で必要になるため、覚えておきましょう。また、20回も書くのではなく、一度だけ書けるようにヘルパー関数にまとめると良いでしょう。「normalize-space()」というステップは省略できません。実際のマークアップでは、クラス属性に改行やタブが含まれていることがあり、正規化されていない式では、それらが原因で目に見えないエラーが発生してしまうからです。
しかし、より有用な結論は、このイディオムを避けることです。選択基準がクラスだけであり、他に何も含まれない場合は、CSSセレクタを記述してください。主要なライブラリはすべて両方をサポートしており、1つのファイル内で混在させることも一般的です。また、式ごとに適切なツールを選択することで、同僚が読みやすい、より短いコードが生成されます。
そして、クラス名は利用可能なアンカーの中で最も安定性が低いものと見なしてください。 生成された名前やハッシュ化された名前はデプロイ時に変更されます。手書きの名前でさえ、誰かがコンポーネントのスタイルを変更するたびに変わります。「data-testid」や見出しのテキスト、あるいは特定可能な要素との関係性は、いずれもクラス名よりも長く存続します。そして、クラスが消えた場合の失敗モードはエラーではなく、目立たず、構文的に正しく、空の出力となるだけです。
