この記事を書いた理由について、簡単にご説明します。私たちは Geonode という企業で、データ抽出を行う方々にプロキシを販売しているため、セレクタに関する質問が絶えず寄せられます。これに関する免責事項は簡潔です:セレクタのバグとプロキシの問題は、まったく異なる症状を引き起こすため、これらを混同すると何時間も無駄にしてしまいます。 リクエストがブロックされると、認証ページまたはエラーステータスが返されます。一方、contains()が機能していない場合は、正常なページが表示され、結果は空になります。HTMLが表示されており、式で何も検出されない場合は、この記事が参考になり、ネットワークに問題はありません。
関数そのもの
XPath 1.0仕様書には、次のように簡潔に記述されています。
contains 関数は、第1引数の文字列に第2引数の文字列が含まれている場合に true を返し、そうでない場合は false を返します。
両方の引数は文字列です。引数は2つ、結果はブール値、大文字と小文字を区別し、ワイルドカードも正規表現も使用できません。
//a[contains(@href, 'download')]
//div[contains(@class, 'product')]
//p[contains(text(), 'Price')]
興味深い挙動は、指定した引数が文字列でない場合(ほとんどの場合がこれにあたります)に何が起こるかにすべてあります。
ノードセットの落とし穴:contains()
は最初のノードしか認識しない これは、「XPathが一部のページでは動作するが、他のページでは動作しない」という現象を引き起こすバグであり、仕様書には次のように明確に説明されています:
ノードセットは、ドキュメント順で先頭にあるノードの文字列値を返すことで、文字列に変換されます。ノードセットが空の場合、空の文字列が返されます。
したがって、ノードセットを生成するコードを記述し、それを ``contains() に渡すと、XPath は最初のノード以外のすべてを黙って破棄してしまいます。
<div>
<p>Introduction</p>
<p>Price: £42</p>
<p>Availability</p>
</div>
contains(//p, 'Price') → false
これは誤りです。なぜなら、//p
は 3 つの要素からなるノードセットであり、文字列への変換では最初の要素である「Introduction」が採用されるため、「Price」は含まれていないからです。他の 2 つの段落は一切考慮されませんでした。
修正するには、集合を変換するのではなく、述語をノードごとに適用するようにします:
//p[contains(., 'Price')] → the second paragraph
ここでは、contains()
は p
ごとに一度評価され、.
がその個々のノードとなります。これは、「この集合にこれが入っているか?」と「この集合のどの要素にこれが入っているか?」を問うことの違いであり、通常は後者だけが意図されるものです。
同様の落とし穴は、text()
にも見られます。これもノードセットの一種です:
//div[contains(text(), 'Price')]
text()
は すべての 直接の子テキストノードを返しますが、文字列への変換では最初のものだけが使用されます。要素のテキストが複数のノードにまたがっている場合(ネストされたマークアップがあるときは常に発生します)、テストされるのは最初の断片のみとなります。
.
対 text()
:同じ問題のもう一方の側面 仕様では、要素の文字列値を次のように定義しています:
要素ノードのすべての子孫テキストノードの文字列値を、ドキュメント順に連結したもの
これが、この2つの形式の決定的な違いです。
<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: ")
、
.
は、ネストされた要素まで探索します。text()
は、直接の子要素のみを認識し、その中でも最初の子要素のみを対象とします。
**デフォルトでは.
を使用してください。** これは、読者が「この要素のテキスト」とみなす内容に一致し、値が<span>
で囲まれるなどのマークアップの変更があっても影響を受けません。
**text()
は意図的に使用してください**。これは、ネストされたコンテンツを意図的に除外したい場合(例えば、子要素であるバッジやツールチップ内のテキストを一致させずにラベルのみを一致させたい場合など)に用います。
「テキストノードのいずれかにこれが含まれる」という条件の場合、正しい形式は、述語をテキストノード自体に適用するものです:
//div[text()[contains(., 'Price')]]
冗長ですが、正しい記述です。
空白文字:「normalize-space()」は省略不可
実際のHTMLは整形式で記述されており、空白文字は文字列値に含まれます。
<td>
In stock
</td>
このセルの文字列値は「"\n In stock\n"」であるため、「'In stock'」との完全一致比較は失敗します。
仕様書には、その修正方法が次のように定義されています:
normalize-space 関数は、先頭および末尾の空白を削除し、連続する空白文字を単一の空白に置き換えることで、空白を正規化した引数の文字列を返します。
//td[normalize-space() = 'In stock']
//td[contains(normalize-space(), 'In stock')]
引数を指定しない normalize-space() はコンテキストノードに対して動作することに注意してください。これは、述語内で必要な動作です。
特に contains() の場合、末尾の空白はそれほど重要ではありませんが、中央の空白は非常に重要です。'In stock' に対する検索は、事前に正規化を行わない限り、"In stock" との比較で失敗します。セレクタに1つの習慣だけを取り入れるなら、テキストの比較を normalize-space() で囲むようにしてください。
クラスの適切なマッチング
contains()
の最も一般的な誤用であり、気づかれないまま間違っている可能性が最も高いものです。
//div[contains(@class, 'btn')]
これは class="btn"
に一致します。また、class="btn-primary"
、class="unbtn"
、class="sidebar-btn-group"
にも一致します。@class
はスペースで区切られた単一の文字列であり、contains()
は単純な部分文字列の比較であるため、単語の境界については一切考慮されません。
正しい書き方では、属性とターゲットの両方にスペースを埋め込み、トークン単位でのみ一致するようにします:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
これを次のように読み解きます:クラス属性を取得し、その空白を正規化し、すべてのトークンが両側でスペースで区切られるようにスペースで囲み、スペースで囲まれたターゲットを検索します。class="btn-primary"
は " btn-primary "
となり、これには " btn "
は含まれません。class="icon btn large"
は " icon btn large "
となり、これには含まれます。
見栄えは悪いですが、これは正しい方法であり、成熟したスクレイピングのコードベースでは最終的にこうなるものです。ヘルパー関数で囲んでみましょう:
def has_class(name):
return (f"contains(concat(' ', normalize-space(@class), ' '), ' {name} ')")
2つのクラスが必要な場合は:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')
and contains(concat(' ', normalize-space(@class), ' '), ' primary ')]
この時点で、CSSセレクタ — div.btn.primary
— の方がはるかに読みやすく、まさに正しい動作をします。 クラスだけでマッチングを行う場合は、CSSを使用してください。 XPathが真価を発揮するのは、テキストのマッチングや逆方向のナビゲーションが必要な場合であり、CSSがすでにうまく処理できることのためではありません。この2つを比較した記事が XPath preceding-sibling にあります。
大文字小文字の区別と「translate()」による回避策
「contains()」は大文字小文字を区別しますが、XPath 1.0 には「lower-case()」関数がありません。ブラウザや Selenium は XPath 1.0 を実装しているため、この制限は最も重要な場面で常に影響を及ぼします。
この回避策では、translate() を使用します。これは、「第2引数の文字列に含まれる文字が、第3引数の文字列の対応する位置にある文字に置き換えられた、最初の引数の文字列」を返すように定義されています:
//p[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz'), 'price')]
文字単位の変換であり、ASCII のみに対応しています。 アクセント付き文字を小文字に変換することはありません。これらをカバーするために両方の文字列を拡張しない限りは、ですが、そうするとすぐに扱いにくくなってしまいます。
利用可能な場合、より良い選択肢が2つあります:
大文字小文字を区別しない部分文字列に一致させる。 ページに「Price」や「PRICE」はあっても「price」は一切ない場合、'rice' に一致させるのは見栄えは悪いですが、機能します。 多くの場合、これが現実的な解決策となります。
より豊富なXPath機能を備えたライブラリを使用する。 lxmlなどのサーバーサイドパーサーは、正規表現用のre:test()を含むEXSLT拡張機能をサポートしており、大文字小文字の区別やその他多くの処理に対応しています。ブラウザではサポートされていないため、lxml用に発見したスニペットが、まさにこの理由でSeleniumでは動作しない可能性があります。
もしtranslate()のような長いコードを書いてしまっているなら、それはこのタスクにおいてXPath 1.0の限界を超えているというサインです。
関連する文字列関数 `
contains()
` はこの小さな関数群の一つに過ぎず、他の関数の方がより正確であることが多い。
** ``starts-with()`
** — 「最初の引数の文字列が、2番目の引数の文字列で始まる場合に true を返す」。``contains()
` よりも条件が厳密であるため、過剰な一致が起こりにくい:
//a[starts-with(@href, 'https://')]
XPath 1.0 には ``ends-with()`
は存在しない。 この回避策ではsubstring()`
と string-length()
を使用しますが、扱いが煩わしいため、通常は別のアプローチの方が望ましいです。
**substring-before()
および substring-after()
** — 前者は「第1引数の文字列の中で、第2引数の文字列が最初に現れる位置より前の部分文字列を返す……第1引数の文字列に第2引数の文字列が含まれていない場合は空文字列を返す」ものです。 式内の値を分割するのに便利です:
substring-after(//span[@class='price'], '£')
**normalize-space()
** — 上記で説明したもので、最も頻繁に使用するべきものです。
**translate()
** — 大文字小文字を区別せず、また文字を何もなしにマッピングすることで文字を削除します:
translate(., ',', '')
**string-length()
** — 空または切り詰められた値をフィルタリングします:
//td[string-length(normalize-space()) > 0]
これらを組み合わせることで真価が発揮されます:
//tr[contains(normalize-space(td[1]), 'Weight')]/td[2]
どの行でも、最初のセルに「Weight」と記載されている行の 2 番目のセルを、空白を区別せずに抽出します。
頻繁に現れるパターン
以下の式は、実際のデータ抽出作業の大部分をカバーしており、手元に置いておく価値があります。
ラベルの隣にある値を取得する。 構造化されたページをスクレイピングする際、最も一般的な要件です:
//dt[contains(normalize-space(), 'Price')]/following-sibling::dd[1]
//th[contains(normalize-space(), 'Weight')]/following-sibling::td[1]
[1]
に注意してください。これがなければ、following-sibling::td
はその行の以降のすべてのセルを返してしまいます。その結果、コードは最初のセルだけを静かに取得してしまう一方、開発者はそれが唯一のセルだと誤解してしまう可能性があります。
href ではなく、表示テキストでリンクを検索する:
//a[contains(normalize-space(), 'Download')]
URL がハッシュ化された識別子である場合、URL との照合よりも堅牢ですが、サイトが翻訳されている場合は不安定になります。 どちらがより頻繁に変化するかによって選択してください。
内部の要素に基づいてコンテナを検索する:
//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')][.//span[contains(., 'Sold out')]]
2つの述語を連続して指定します。「Sold out」と記載されたspanを含むカード。これを1つの条件で表現しようとするよりも、このように組み合わせた方が構成しやすく、読みやすくなります。
包含するよりも除外する。 多くの場合、より明確な表現になります:
//tr[not(contains(@class, 'header'))]
//li[not(contains(normalize-space(), 'Advertisement'))]
**ボタンが button
であっても a
であっても一致させる:**
//*[self::button or self::a][contains(normalize-space(), 'Continue')]
値を含む行を見つけ、別の列を取得する:
//tr[td[contains(normalize-space(), 'SKU-1234')]]/td[3]
外側から読み取ります:SKU が記載されたセルを含む行、そしてその行の 3 番目のセル。 これはテーブル検索のパターンであり、テーブル全体に対する絶対インデックスよりも、列の順序変更に対してはるかに耐性があります。
空のマッチに注意してください。 空白をフィルタリングする述語はコストがかからず、下流での混乱を防ぐことができます:
//td[string-length(normalize-space()) > 0][contains(., 'Ltd')]
「contains()」が不適切な場合
等価性を指定したい場合。 contains(., 'Price') は「Historic Price」や「Price excluding VAT」にも一致してしまいます。まさにそのラベルを指定したい場合は、normalize-space() = 'Price' を使用してください。一致しすぎた場合、エラーは発生しません。コードは最初の一致結果のみを取得し、3件あったことには気づきません。
クラスにのみマッチさせたい場合。 CSSなら適切かつ読みやすく処理できます。上記を参照してください。
正規表現が必要な場合。 XPath 1.0には正規表現機能がありません。どうしても必要な場合は、contains()で抽出してから、ホスト言語で正規表現を適用してください。その際、何が一致したかも確認できます。
利用可能な識別子がある場合。 ID、data属性、または埋め込まれたJSON-LDは、テキストによるマッチングよりも安定しています。テキストはコンテンツであり、コンテンツは変化します。再設計、翻訳、または校正によって、テキストでマッチングしたセレクタが機能しなくなっても、警告は表示されません。
文字列がユーザー入力から得られる場合。 信頼できないテキストをXPath式に挿入することは、XPathインジェクションとなります。ライブラリに変数バインディング機能がある場合はそれを利用し、ない場合は適切にエスケープしてください。特に引用符については注意が必要です。XPath 1.0には文字列リテラル内の引用符に対するエスケープシーケンスがないため、concat() を使用して作成する必要があります。
よくある質問
XPathにおけるcontains()の機能とは?
最初の文字列引数に、2番目の文字列が部分文字列として含まれている場合に true を返します。両方の引数は文字列であり、比較は大文字と小文字を区別し、ワイルドカードや正規表現は使用されません。抽出における一般的な用途は、要素のテキストや属性値の一部と一致させることです。
なぜ私のXPathのcontains()は何も見つからないのですか?
ほとんどの場合、ノードセットを引数として渡していることが原因です。XPathは、ドキュメント順の最初のノードを取り、残りを無視することでノードセットを文字列に変換するため、contains(//p, 'x')では常に最初の段落のみが検査されます。代わりに、ノードごとに述語を適用してください://p[contains(., 'x')]。
contains(.) と contains(text()) の違いは何ですか?
. は要素の文字列値を使用します。仕様では、これはすべての子孫テキストノードの連結として定義されているため、ネストされたマークアップにもアクセスします。text() は直接の子テキストノードを返し、文字列変換では最初のノードのみが使用されます。 ネストされたコンテンツを意図的に除外したい場合を除き、. を使用してください。
XPath でクラスを一致させるにはどうすればよいですか?
contains(concat(' ', normalize-space(@class), ' '), ' name ') を使用してください。これにより、属性にパディングが追加され、完全なトークンのみが一致するようになります。単純な contains(@class, 'btn') でも、btn-primary や unbtn に一致します。クラスでのみ一致させる場合は、CSS セレクタの方が明確であり、デフォルトでは正しい結果が得られます。
XPathのcontains()は大文字と小文字を区別しますか?
はい、XPath 1.0にはlower-case()関数はありません。標準的な回避策は、大文字と小文字を明示的に指定したtranslate()を使用することですが、これはASCII文字のみを扱います。lxmlなどのサーバーサイドライブラリはEXSLT正規表現をサポートしていますが、ブラウザやSeleniumはサポートしていません。
contains() を複数の条件で使うにはどうすればよいですか?
and および or を使用して述語を組み合わせます://div[contains(@class, 'card') and contains(., 'In stock')]。各 contains() は、同じコンテキストノードに対して評価される個別のブール値のテストです。
XPathには先頭一致(starts-with)や末尾一致(ends-with)の関数はありますか?
starts-with() が存在し、適用可能な場合は contains() よりもこちらを優先する価値があります。なぜなら、過剰一致が少ないからです。XPath 1.0には ends-with() はありません。回避策として substring() と string-length() を組み合わせる方法がありますが、扱いにくいため、通常は別のアプローチを採用した方が良いでしょう。
なぜ contains() は予想よりも多くの要素に一致してしまうのか?
これは、単語の境界を認識しない部分文字列の比較であるためです。contains(., 'Price') は、「Historic Price」や「Price excluding VAT」にも一致してしまいます。等価性を確認するには normalize-space() = 'Price' を使用するか、クラストークンにはパディング付き連結(padded-concat)のイディオムを使用してください。
まとめ `
contains()`` は仕様上は単純ですが、実際の使用においては落とし穴が数多く存在します。そのほとんどは、ある一点に起因しています。XPathは、ノードセットを文字列に変換する際、最初のノードを取り出し、残りを破棄してしまうからです。 この単一のルールこそが、contains(//p, 'x') が自信満々に間違った答えを返す理由、contains(text(), 'x')` がノード間にまたがるテキストを見落とす理由、そして同じ式があるページでは動作するのに次のページでは失敗する理由を説明しています。
これを回避する方法はいくつかあります。contains() を述語内で適用し、ノードごとに評価されるようにします。特別な理由がない限り、text() ではなく . を使用します。 テキストの比較は、実際のHTMLが整形式で出力されるため、normalize-space()で囲んでください。また、クラスのマッチングについては、パッド付き連結(padded-concat)のイディオムを使うか、あるいは(より良い方法として)まさにその目的のために設計されたCSSセレクタを使用してください。
XPathは、テキストの照合や逆方向へのナビゲーションといった、XPathならではの機能に限定して使用してください。これらはCSSにはない真の機能であり、その構文を使う価値があります。div.card を選択するためにXPathを使用するのは、メリットを得ることなくコストを支払うだけになります。
