🎃

XPath1.0でクラスセレクター相当の選択をする

2025/01/29に公開

皆さんXPathは書いていますか?

私は、主にGoogleスプレッドシートのIMPORTXML関数でHTMLからデータを抽出するときに書いています。
と言っても、年に2回くらいしかXPathはいじらないのでなかなか文法が覚えられず、毎回「xpath 書き方」で検索しているのですが…

XPathでHTMLと格闘していると頻繁に浮かぶのが、 「クラスセレクター使いたい」 という願望です。
CSSのクラスセレクターやDOM APIのgetElementsByClassName()メソッドを使うと、class属性のリストに特定の値を含む要素を抜き出すことができます。
XPathはCSSセレクターよりも柔軟で複雑な指定ができ、要素以外のノードも取得できる便利な構文ですが、単純な書き方ではクラスセレクター相当の挙動はなかなか実現できません。
私が頻繁にお世話になるXPathとCSSセレクター対応表でも、クラスセレクターは微妙な扱いです。

概略

結論を言えば、class属性にclsという値を含む要素を取得したいとすると、
次のようなXPathを書けばよいです。

//*[contains(concat(' ',@class,' '),' cls ')]

厳密にHTMLの仕様に従うなら、@classnormalize-space()で囲います。

//*[contains(concat(' ',normalize-space(@class),' '),' cls ')]

複数のclass(例えばcls1cls2)を指定するなら、このようなXPathが書けます。

//*[contains(concat(' ',@class,' '),' cls1 ')][contains(concat(' ',@class,' '),' cls2 ')]

HTMLの仕様に厳密に従う必要がある場合は、二番目の例と同じ要領でnormalize-space()を組み合わせます。

//*[contains(concat(' ',normalize-space(@class),' '),' cls1 ')][contains(concat(' ',normalize-space(@class),' '),' cls2 ')]

以下、これらのXPath式について説明します。

「よくあるクラスセレクター相当のXPath」の問題点

class属性をXPathでの指定に使う例として、よくあるのは次のようなものです。

//*[@class='cls']

しかし、この素朴な書き方ではclass属性に複数の値が設定されているケースに対応できません。

<div class="abc cls def">text</div>

なぜならば、このXPathは「class属性の文字列値が文字列'cls'に等しいか」を判断することになるため、他のclass属性値が同時に指定されている要素を抽出できないからです。

文字列'cls'を含むかどうかを判断すればよいだろうということで、次のような書き方も提案されています。

//*[contains(@class,'cls')]

しかし、XPathとCSSセレクター対応表でも指摘されているように、この書き方では

<div class="cclss">text</div>

のように属性値がたまたま部分文字列'cls'を含んでいる場合でもマッチしてしまいます。

おおよそ完全な解決策

class="abc cls def"のように、HTML要素のclass属性は通常半角スペース区切りのリストになっています。

であれば、class属性が'cls'の前後に半角スペースを含めた文字列' cls 'を含んでいるか判断すればいいということになります。

//*[contains(@class,' cls ')]

ただ、clsがclass属性内で最初に指定されている場合や最後に指定されている場合は、このままではうまくいきません。
普通は、最初の属性値の前や、最後の属性値の後ろにスペースを置いたりしませんよね。
属性値の両端にスペースが無いとうまく動かないということです。

でも、無いなら追加してしまえばよいのです。
concat()を使って@classの両端に半角スペースを結合した文字列を生成し、この新しい文字列を使うことにします。

//*[contains(concat(' ',@class,' '),' cls ')]

ひとまずこれで完成です。
正直やりたいことに対して式が複雑すぎるし直感的でもないですが、仕組み自体はそこまで難しくありません。

このわかってしまえば単純(?)な式はかなり強力で、ほぼ全てのケースに対応できます。
class属性が単独か、もしくは半角スペース区切りで書かれてさえいればこの式は有効だからです。

class属性が半角スペース以外のホワイトスペース区切りの場合

仕様の上では、class属性に複数の値を設定する場合はホワイトスペース区切りのリストにすることになっています。

「ホワイトスペース」です。「半角スペース」とは限定していません。

どういうものがホワイトスペースなのか?という話ですが、正確な一覧は仕様書を見ていただくとして、ざっくり言えば半角スペースのほかタブ文字や改行がホワイトスペースです。

つまり、

<!-- タブ区切りのつもり -->
<div class="abc	cls	def">text</div>
<!-- 改行区切り -->
<div class="abc
cls
def">text</div>
<!-- タブが連続&改行と混ざっている -->
<div class="abc
			cls
			def">text</div>

こういうHTMLは全て有効なのですが、前項で考えたXPathはこういったパターンには対応できません。

仮に現在の「前後に半角スペースを含む」という条件式を「前後にホワイトスペースを含む」に変更可能ならば対応できそうですが、XPath1.0では正規表現を扱えないのがネックです。

しかし、XPathにはホワイトスペースを攻略する便利な関数があります。
それがnormalize-space()です。
この関数は、引数にとった文字列から前後のホワイトスペースを取り除き、文字列中のホワイトスペースを半角スペースに変換します。
ホワイトスペースが複数連続している場合は、全部まとめて半角スペース1文字に変換します。

これはなかなか強力です。
元のclass属性がどのような形で複数指定されていようと、normalize-space()してしまえば半角スペース区切りのリストになるからです。

では、normalize-space()を先ほど考えたXPath式に導入します。
normalize-space()は前後のホワイトスペースを取り除いてしまうので、normalize-space()したあとに半角スペースを前後にconcat()する、という順番にします。

//*[contains(concat(' ',normalize-space(@class),' '),' cls ')]

普通はclass属性のリストは半角スペース区切りだと思いますが、一応ホワイトスペース全般に対応しようとするとこうなります。

classを複数指定する場合

まだクラスセレクターやgetElementsByClassName()と等価にはなっていません。
クラスセレクターやgetElementsByClassName()では複数のclass属性値が指定できるからです。
つまり、CSSセレクターなら.cls1.cls2、DOM APIならdocument.getElementsByClassName('cls1 cls2')と、複数のclassが指定できます。

XPath1.0でも

//*[contains(concat(' ',@class,' '),' cls1 cls2 ')]

みたいにすれば複数指定できるんじゃないの?と思ってしまいそうですが、これはcls1cls2が連続してこの順番に書かれていないと機能しません。

例えば、

<div class="cls2 cls1">text</div>
<div class="cls1 cls_m cls2">text</div>

こういうHTMLになるともう対応できません。

これは、式の条件部分(XPath用語だと「述部」)をそのまま複数並べることで対処可能です。

//*[contains(concat(' ',@class,' '),' cls1 ')][contains(concat(' ',@class,' '),' cls2 ')]

normalize-space()を使って全ホワイトスペース対応バージョンに書き直すと次のようになります。

//*[contains(concat(' ',normalize-space(@class),' '),' cls1 ')][contains(concat(' ',normalize-space(@class),' '),' cls2 ')]

随分と厳つい式になってしまいましたが、これでクラスセレクターと等価です。

余談:XPath1.0じゃなければもっと楽

IMPORTXML関数やJavaScriptがサポートするのはXPath1.0だけですが、XPath2.0以降であればもっと楽で自然な方法があります。
classを1つだけ指定する場合だと、下のような書き方ができます。

XPath2.0以降

//*[tokenize(@class,'\s+')='cls']

さらに、XPath3.1ではこんな書き方ができるようになりました。

//*[contains-token(@class,'cls')]

特にcontains-token()はやりたいことがそのまま書けて素晴らしいですね。

TryAngle@大阪公立大学

Discussion