この記事の著者について簡単に説明します。私たちは Geonode であり、データ抽出を行うユーザーにプロキシを販売しています。したがって、XPath は私たちの事業の一部というよりは、事業に密接に関連する分野と言えます。 重要な注意点として、セレクタの問題とプロキシの問題は全く異なるものであり、これらを混同すると何時間も無駄にしてしまいます — リクエストがブロックされるとチャレンジページやエラーステータスが返されますが、式が間違っていると、ページ自体は正常に表示されるものの、結果は空になります。 HTMLが存在しているにもかかわらずXPathで何も見つからない場合、ネットワークに問題はなく、このページが正しい場所です。
基本的な構文
| 式 | 選択対象 |
|---|---|
/html/body/div | ルートからの絶対パス |
//div | ドキュメント内の任意の場所にあるdiv |
//div/p | divの直接の子要素であるp要素 |
//div//p | div内の任意の深さにあるp要素 |
. | 現在のコンテキストノード |
.. | コンテキストノードの親 |
* | 任意の要素 |
@href | href属性 |
//@href | ドキュメント内のすべてのhref属性 |
text() | コンテキストノードの子テキストノード |
node() | テキストやコメントを含む任意のノード |
//a | //link | 和集合 — いずれかの式に一致するすべての要素 |
/ と // の違いは、しっかりと理解しておく必要があります。スラッシュが1本の場合は「直接の子」、2本の場合は「任意の深さの子孫」を意味します。//div/p では、section で囲まれた段落は検出されませんが、//div//p では検出されます。
絶対パス(/html/body/div[2]/div[1]/span)は、ブラウザの「XPathをコピー」機能で生成されるものであり、次回の再設計時に機能しなくなるものです。特定可能な場所から始めて、相対パスで移動するようにしてください。
述語
角括弧はノードセットをフィルタリングします。実用的な処理のほとんどはここで行われます。
| 式 | 選択対象 |
|---|---|
//div[1] | 親ノードごとに、その兄弟ノードのうち最初の div |
(//div)[1] | ドキュメント全体で最初の div |
//div[last()] | その兄弟ノードのうち最後の div |
//div[position() < 4] | 最初の3つ |
//a[@href] | href属性を持つリンク |
//a[@href='/about'] | その正確なhrefを持つリンク |
//div[@class and @id] | 両方の属性を持つ要素 |
//p[text()] | 少なくとも1つのtext-node子要素を持つ段落 |
//div[p] | 少なくとも1つのp子要素を含むdiv |
//div[not(@hidden)] | hidden属性を持たないdiv |
//td[.='42'][@class='qty'] | 2つの述語を順次適用 |
** //div[1] と (//div)[1] の違いは、XPathにおいて最もよく見られる誤解です。** 前者は親要素ごとに適用される述語であるため、div を含む すべての 親要素の下にある最初の `` を選択します。これにより、潜在的に多数のノードが選択される可能性があります。 後者は、すべての div をドキュメント順にノードセットとして集め、その中から最初のもの(正確に1つのノード)を取得します。どちらも有用ですが、互いに置き換え可能というわけではありません。
述語は連鎖し、それぞれが前の述語の結果に対して適用されます。//td[@class='price'][1]は、左から右に読むと「classがpriceであるセルの中で最初のもの」を意味します。
軸
軸はコンテキストノードを基準として移動します。多くの場合は3つの軸を使用しますが、時折他の軸が必要になることもあります。
| 軸 | 選択対象 |
|---|---|
child:: | 直接の子ノード — デフォルト。通常は省略される |
descendant:: | どの深さにある子孫ノードもすべて |
parent:: | 親ノード |
ancestor:: | ルートまでのすべての上位ノード |
ancestor-or-self:: | 上位ノードとノード自体 |
following-sibling:: | 後の兄弟ノード |
preceding-sibling:: | 前の兄弟ノード |
following:: | ドキュメント順でその後に続くすべてのノード(子孫を除く) |
preceding:: | 先祖を除く、それ以前のすべて |
attribute:: | 属性 — 略記は @ |
self:: | ノード自体 |
4つは逆軸です — ancestor、ancestor-or-self、preceding、preceding-sibling — これらでは、位置の番号付けが逆順になります。preceding-sibling::p[1] は、ドキュメント内の最初の段落ではなく、最も近い直前の段落を指します。 代わりに、ドキュメント順を取得するには括弧で囲んでください。これについては、XPath preceding-siblingで詳しく解説しました。
following および preceding は、それぞれの対応する sibling メソッドよりも適用範囲が広く、処理速度もはるかに遅く、それぞれ祖先と子孫を除外します。関係が本当に緩い場合にのみ、これらを使用してください。
文字列関数
主力となる関数群。
| 関数 | 機能 |
|---|---|
contains(a, b) | a に b が部分文字列として含まれている場合に True を返す |
starts-with(a, b) | a が b で始まる場合に True を返す |
normalize-space(s) | 内部の空白を削除し、結合する |
string-length(s) | 文字数をカウントする |
substring(s, start, len) | 部分文字列、1から始まるインデックス |
substring-before(a, b) | b が最初に現れるまでの部分 |
substring-after(a, b) | b が最初に現れる後の部分 |
translate(s, from, to) | 文字単位の置換 |
concat(a, b, ...) | 文字列の結合 |
string(node-set) | 最初のノードのみの文字列値 |
実際のバグを防ぐための3つの注意点。
** substring() は1からインデックスが開始されます。** substring('hello', 1, 3) は hel を返します。誰もが一度はこれを間違えます。
** normalize-space() は、あらゆるテキスト比較におけるデフォルトのラッパーとして使用すべきです。** 実際のHTMLは整形されているため、セルの文字列値は "In stock" ではなく "\n In stock\n" であることがよくあります。引数を指定しない場合、コンテキストノードに対して動作します。
XPath 1.0 には、ends-with()、lower-case()、および正規表現は存在しません。 大文字小文字を区別しない処理を行うには、アルファベットを明示的に指定した translate() を使用するのが標準的な回避策です。lxml などのサーバーサイドライブラリは、re:test() を含む EXSLT 拡張機能をサポートしていますが、ブラウザや Selenium はサポートしていません。
数値関数とブール関数
| 関数 | 機能 |
|---|---|
count(node-set) | ノードの数 |
position() | コンテキストノードの位置 |
last() | コンテキストノードセットのサイズ |
number(s) | 数値に変換 |
sum(node-set) | 数値の合計 |
round(), floor(), ceiling() | 名称通り |
not(expr) | ブール値の否定 |
boolean(expr) | ブール値への変換 |
true(), false() | リテラルブール値 |
比較演算子は、=、!=、<、>、<=、>=であり、組み合わせにはandおよびorが使用されます。XMLの文脈では、<は<としてエスケープする必要があることに注意してください。これが、position() < 4と記述された式を見かけることがある理由です。
知っておくべき微妙な点として、ノードセットと値を比較することは存在テストとなります。//p = 'Price' は、いずれかの段落が "Price" と等しい場合に真となります。これは述語内ではしばしば望ましい動作ですが、最上位レベルではほとんどの場合望ましくありません。
頻繁に現れるパターン
実際に役立つ式。
クラスを正しく一致させる — 単純な ``contains(@class, 'btn')`
は、``btn-primary
や ``unbtn
` にも一致します:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
ラベルから値を抽出する — 最も一般的な抽出要件です:
//dt[normalize-space()='Price']/following-sibling::dd[1]
//th[normalize-space()='Weight']/following-sibling::td[1]
//td[preceding-sibling::td[1]='SKU']
セルから行を見つけ、別の列を取得する:
//tr[td[normalize-space()='SKU-1234']]/td[3]
**コンテナ内の内容からコンテナを検索する:
//div[contains(@class,'card')][.//span[contains(., 'Sold out')]]
見出しの直後の最初の要素を検索する:
//h2[normalize-space()='Specifications']/following-sibling::table[1]
大文字小文字を区別しないテキスト一致:
//p[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz'), 'price')]
「含む」ではなく「含まない」 — 多くの場合、より明確です:
//tr[not(contains(@class,'header'))]
//li[not(contains(normalize-space(),'Advertisement'))]
2つの要素タイプのいずれかと一致させる:
//*[self::button or self::a][normalize-space()='Continue']
空の値をスキップ:
//td[string-length(normalize-space()) > 0]
一致した要素から属性を抽出:
//a[normalize-space()='Download']/@href
落とし穴
人々の時間を奪う頻度順にランク付けしました。
ノードセットから文字列への変換では、最初のノードのみが対象となります。 contains(//p, 'Price') は、//p のノードセット全体を文字列に変換する際、最初の段落のみを取り出し、残りを無視してしまいます。代わりに、ノードごとに述語を適用してください://p[contains(., 'Price')]。これは、XPath における最も一般的なバグです。
. と text() は異なります。 要素の文字列値は、そのすべての子孫テキストノードを連結したものです。text() は直接の子テキストノードのみを返し、文字列変換ではそのうちの最初のものだけが採用されます。ネストされたコンテンツを意図的に除外したい場合を除き、. を使用してください。
逆軸の番号付け。 preceding-sibling::td[1] は「最も近い」ものであり、「最初」のものではありません。
空白。 //td[.='In stock'] は、整形式に整形された HTML に対しては失敗します。normalize-space() を使用すると修正されます。
大文字小文字の区別。 XPath 1.0 では、XML ドキュメント内の要素名を含め、すべてが大文字小文字を区別します。
デフォルトの名前空間。 デフォルトの名前空間を持つ XML では、//item は何も一致しません。プレフィックスを登録して使用する必要があります。HTML パーサーは通常この手間を省いてくれますが、XML パーサーはそうではありません。
ブラウザの開発者ツールからの絶対パス。 これらは特定の時点での正確な構造をエンコードしたものであり、変更があると機能しなくなります。
contains() による過剰な一致。 「Price」に一致すると、「Historic Price」にも一致してしまいます。等価性を指定する場合は、normalize-space() = 'Price' を使用してください。
信頼できない文字列の挿入。 ユーザー入力を式に挿入することは、XPathインジェクションにあたります。ライブラリで提供されている場合は、変数バインディングを使用してください。XPath 1.0では、文字列リテラル内の引用符に対するエスケープ文字がないため、両方の引用符を含む値にはconcat()を使用する必要があります。
XPath 1.0 とそれ以降のバージョン
ネットで見つけたコードスニペットが、実際に必要な場面で動作しない可能性があるため、知っておく価値があります。
XPath 1.0 は、ブラウザが document.evaluate を通じて実装しているものであり、Selenium が使用しているもので、lxml の共通 API が提供するものです。 これには、上記に挙げた関数のみが含まれており、それ以上の機能はありません。
XPath 2.0 および 3.1 では、正規表現(matches()、replace())、ケース関数(upper-case()、lower-case())、ends-with()、シーケンス型、for 式などが追加されています。 これらはXSLT 2.0以降のプロセッサや一部のXMLツールで利用可能ですが、ブラウザでは利用できません。
実用的なルール:式で上記の表にない関数を使用している場合、なぜ失敗するのかをデバッグする前に、お使いの環境がそれをサポートしているかどうかを確認してください。 「オンラインのXPathテスターでは動作するが、Seleniumでは動作しない」というケースは、ほぼ間違いなくこれが原因です。
XPath 1.0では対応していない機能については、通常、ホスト言語を活用するのが解決策となります。XPathでデータを抽出した後、PythonやJavaScriptで正規表現を適用し、何が一致したかを確認できるようにします。
リデザイン後も機能し続けるセレクタの書き方
チートシートには「何が可能か」が記載されていますが、このセクションでは、その選択肢の中からどれを選ぶべきかについて解説します。なぜなら、1年間機能し続けるセレクタと、来週の火曜日には機能しなくなるセレクタの違いは、完全に「何を基準にするか」にかかっているからです。
位置ではなく、意味を基準にしましょう。 (//table)[3]/tr[2]/td[4] は、ある瞬間のページの正確なレイアウトを表現しています。誰かがその上にテーブルを追加すると、すべての数値が間違ったものになってしまいます――しかし、式は依然として何かに一致しているため、エラーは表示されません。//th[normalize-space()='Weight']/following-sibling::td[1] は、ラベルが値と共に移動するため、順序変更後も維持される関係を表現しています。
生成された属性よりも、安定した属性を優先してください。 開発者が選択した ID や data-* 属性は、誰かがスタイルを変更するたびに変わってしまうクラス名よりもはるかに耐久性があります。多くの最新のフロントエンドフレームワークは、css-1x9dj2k のようなハッシュ化されたクラス名を生成しますが、これらはビルドのたびに変化します。これらを基準にすると、確実に動作しなくなります。
セレクタを記述する前に、埋め込まれた構造化データを確認する。 検索機能を促進するため、非常に多くのページが<script type="application/ld+json">ブロック内にJSON-LDを含んでいます。これは機械が読み取れるように設計されており、視覚的な再設計の影響を全く受けないため、レンダリングされたHTMLを解析するよりもはるかに安定しています。 これを確認するのに30秒かけるだけで、午後丸々を費やすようなセレクタのメンテナンス作業を回避できます。
抽出するデータの構造を検証してください。 これが、目立って失敗するパイプラインと、静かに失敗するパイプラインを分ける習慣です。価格が特定の通貨パターンに合致すべき場合は、それを確認してください。 カテゴリページに20件未満のアイテムが表示されたことがない場合は、20件未満を結果としてではなくエラーとして扱ってください。誤った要素にマッチし始めたセレクタは、一見妥当で構文的に正しいが誤ったデータを生成します――そして、どの段階でも例外は発生しません。
セレクタは一箇所にまとめておく。 コードベースのあちこちに散らばった40個のXPath文字列は、40個の個別のメンテナンス上の負担となる。名前を付けて単一のモジュールにまとめれば、依存関係のマップとなり、再設計後の更新も1日ではなく1時間で済む。
そして、保存したHTMLに対してテストを行う。 解析した各ページのコピーを保存しておけば、セレクタが機能しなくなった際に、古いマークアップと新しいマークアップを比較して、何が変更されたかを正確に把握できる。デバッグのために再取得を行うと、処理が遅くなり、帯域幅を消費する上、エラーが発生したページとは異なるページが表示される可能性もある。
代わりにCSSを使うべき場合
XPathはより強力ですが、可読性は低くなります。選択処理の大部分においては、CSSが適切なデフォルトの選択肢です。
CSSを使うべき場合: クラス、ID、属性、または子孫関係に基づいて選択を行う場合。div.card > p.price は、同等のXPathよりも明確で、ツールによるサポートも充実しており、一般的に処理も高速です。
XPath を使用すべき場合: テキストの内容で一致させる必要がある場合(CSS ではこれが全くできません);親要素や祖先要素へ移動する必要がある場合;:nth-child では表現できないような、兄弟要素に対する位置関係に基づくロジックが必要な場合;または HTML ではなく XML をクエリする場合。
なお、CSSはこの差の一部を埋めています。:has()により、最新のブラウザでは兄弟要素や子孫要素を条件とした選択が可能になったため、dt:has(+ dd) のような表現もできるようになりました。しかし、CSSでは依然としてテキストによる選択ができず、テキストの一致判定こそが、ラベルと値のパターンで必要とされるものです。
1つのコードベースで両方を併用することは問題なく、理にかなっています。単純な90%にはCSSを、難しい10%にはXPathを用いるのです。
よくある質問
XPathは何に使われますか?
XMLやHTMLドキュメント内のノードをナビゲートしたり選択したりするために使われます。実際には、ウェブスクレイピング、ブラウザテストの自動化、XML設定ファイルやデータファイルへのクエリなどに利用されています。CSSセレクタでは表現できない関係性(親、兄弟、テキストコンテンツなど)を表現することができます。
XPathにおける「/」と「//」の違いは何ですか?
スラッシュ(/)1つでは直接の子要素が選択され、スラッシュ(/)2つでは任意の深さの子孫要素が選択されます。//div/pは、直系の親要素がdivである段落に一致しますが、//div//pは、div内のどこにある段落にも一致します。
なぜ私のXPath「contains()」は何も返さないのですか?
ほとんどの場合、ノードセットを渡しているからです。XPathは、ドキュメント順の最初のノードを取り、残りを破棄することでノードセットを文字列に変換するため、「contains(//p, 'x')」は常に最初の段落のみを検査します。代わりに「//p[contains(., 'x')]」と記述してください。
XPathでクラスに基づいて要素を選択するにはどうすればよいですか?
//div[contains(concat(' ', normalize-space(@class), ' '), ' name ')] を使用してください。これにより、属性にパディングが追加され、完全なトークンのみが一致するようになります。単純な contains(@class, 'btn') でも、btn-primary に一致します。クラスだけで選択する場合は、CSSセレクタの方が明確であり、デフォルトでは正しい動作をします。
XPathは正規表現をサポートしていますか?
ブラウザやSeleniumが実装しているXPath 1.0ではサポートされていません。XPath 2.0以降ではmatches()およびreplace()が追加されており、lxmlなどのサーバーサイドライブラリではEXSLTのre:test()がサポートされています。ブラウザの自動化を行う場合は、XPathでデータを抽出した後、ホスト言語で正規表現を適用してください。
//div[1] と (//div)[1] の違いは何ですか? `
//div[1]`` は親要素ごとに述語を適用し、各親要素の下にある最初の div要素(1つ、あるいは複数のノードが存在する可能性があります)を選択します。一方、``(//div)[1]`` はドキュメント順にすべてのdiv` 要素を収集し、その中から最初のもの(正確に1つのノード)を選択します。
XPathは大文字と小文字を区別しますか?
はい、すべてにおいて区別します。要素名、属性名、および文字列の比較においてです。XPath 1.0にはlower-case()関数がないため、大文字と小文字を区別しないマッチングを行うには、translate()を使用し、大文字と小文字を明示的に指定する必要があります。
XPathとCSSセレクタ、どちらを使うべきですか?
クラス、ID、属性、および子孫関係についてはCSSを使用してください。CSSの方が明確で、サポートも充実しています。テキストコンテンツとの一致、祖先への移動、あるいはCSSでは表現できない位置に基づくロジックを表現する必要がある場合は、XPathを使用してください。1つのコードベースで両方を併用することは一般的です。
まとめ
XPathの有用な中核部分はごくわずかです。任意の場所を検索するための「//」、フィルタリングのための角括弧で囲まれた述語、属性を指定するための「@」、いくつかの文字列関数、そして特定できる対象を基準に相対的に移動するための兄弟軸などです。
3つの習慣を守るだけで、トラブルのほとんどは防げます。テキストの比較にはnormalize-space()で囲んでください。実際のHTMLは整形されているため、完全一致では失敗してしまうからです。また、contains()は、プレディケート内で適用してノードごとに評価されるようにしてください。ノードセットの文字列変換では、最初のノードだけが黙って採用されてしまうからです。 また、位置によるアンカー設定よりも、テキストや識別子によるアンカー設定を優先してください。構造は変化しますが、テキストは通常変化しないからです。
そして、どのバージョン向けに記述しているかを常に念頭に置いてください。 ブラウザやSeleniumではXPath 1.0が提供されます。つまり、正規表現も、lower-case()も、ends-with()も使用できません。オンラインテスターで動作する式であっても、これらを一切使用していなくても、このリストの後半に記載されている理由により失敗する可能性があります。1.0に欠けている機能が必要な場合は、XPathで抽出を行い、残りの処理はホスト言語で行ってください。
