私たちの立場を率直に言えば、私たちは Geonode であり、スクレイピングを行う人々にプロキシを販売しているため、XPath は私たちのビジネスと密接に関連しています。なお、セレクタの不具合は決してプロキシの問題ではない、という点に留意すべきです。 もしデータ抽出が機能しなくなった場合、その原因はサイトのマークアップが変更されたためであり、帯域幅を増やしたりIPアドレスをローテーションさせたりしても解決しません。両者は「スクレイパーがデータを返さなくなった」という同じ症状を示すため混同されがちですが、プロキシの障害ではレスポンスがブロックされたりエラーページが表示されたりするのに対し、セレクタの不具合ではレスポンス自体は成功しているものの、結果として何も得られないという状態になります。 他のことを確認する前に、生のHTMLを確認してください。ページが存在しているにもかかわらず、XPathが空のノードセットを返す場合は、この記事が該当し、プロキシに問題はありません。
「preceding-sibling」が実際に選択する対象
XPath 1.0仕様書では、これを1行で次のように定義しています。preceding-sibling軸は「コンテキストノードのすべての先行する兄弟ノードを含む。コンテキストノードが属性ノードまたは名前空間ノードである場合、preceding-sibling軸は空となる」。
この意味を伝えるのは2つの言葉です。兄弟ノードとは、同じ親を持つノードを意味します。いとこや祖先、異なる階層にあるノードは含まれません。先行とは、ドキュメントの順序においてより前の位置にあることを意味します。
<div>
<p>First</p>
<p>Second</p>
<span id="here">Context</span>
<p>Third</p>
</div>
コンテキストノードとして span を例にとると:
preceding-sibling::p → First, Second
following-sibling::p → Third
preceding-sibling::* → both p elements
属性ノードに関するこの注意点(caveat)は、結果が空になるケースを説明する上で重要であるため、覚えておく価値がある。属性(//@class)に移動した場合、そこから preceding-sibling を実行すると、その属性が属する要素を囲む要素が何であれ、定義上、結果は空になる。属性は、いかなるノードとも兄弟ノードではないからである。
逆軸の落とし穴:「[1]
」は「最初」を意味しない これは、誤った結果が生じる最も一般的な原因であり、仕様書にはその理由が正確に説明されています。
XPathでは、ancestor
、ancestor-or-self
、preceding
、およびpreceding-sibling
を逆軸として分類しています。逆軸の場合、近接位置は、ノードをドキュメント順の逆順に並べた際に決定されます。
したがって、preceding-sibling
において、位置 1 は、ドキュメント内の最初のノードではなく、最も近い先行する兄弟ノード(コンテキストノードの直前にあるノード)を指します。
<div>
<p>Alpha</p>
<p>Beta</p>
<p>Gamma</p>
<span id="here">Context</span>
</div>
preceding-sibling::p[1] → Gamma (nearest)
preceding-sibling::p[2] → Beta
preceding-sibling::p[3] → Alpha (furthest)
(preceding-sibling::p)[1] → Alpha (first in document order)
括弧の有無で意味が根本的に変わります。括弧がない場合、[1]
は逆軸に沿って適用される述語であり、「最も近い」を意味します。 括弧を付けると、軸の結果はまずドキュメント順のノードセットに集められ、[1]
はその中でインデックスを指定することになり、「最初」を意味します。
これに対し、following-sibling
は順方向の軸であり、両者が一致します:
following-sibling::p[1] → the next one
(following-sibling::p)[1] → also the next one
この非対称性こそが、following-sibling
で学んだ人々が preceding-sibling
に引っかかってしまう理由です。習慣は引き継がれても、セマンティクスは引き継がれないのです。
実際には、括弧を付けない方がほぼ常に望ましいです。 「この値の直前にあるラベル」というのが一般的な要件であり、それは preceding-sibling::label[1]
です。括弧を使うのは、「ドキュメント内の最初のもの」を本当に意味する場合に限るべきですが、これは思ったより稀なケースです。
preceding-sibling 対 preceding
名前が紛らわしいほど似ている2つの異なる軸ですが、その違いは重要です。
仕様書では、precedingを「コンテキストノードと同じドキュメント内にあり、ドキュメント順でコンテキストノードより前に位置するすべてのノード(祖先ノード、属性ノード、名前空間ノードを除く)」と定義しています。
つまり、precedingは、ドキュメント内のどの深さであってもコンテキストノードより前にあるすべてのノードから、祖先ノードを除いたものです。一方、preceding-siblingは、親ノードを共有するノードのみを指します。
<body>
<header><h1>Title</h1></header>
<div>
<p>One</p>
<span id="here">Context</span>
</div>
</body>
span より:
preceding-sibling::* → the p only
preceding::* → the p, the h1, and the header
先祖ノードが除外されるという点こそが、人々を驚かせる部分です。span を含む div は、その開始タグがソース内でより前に現れていても、preceding には含まれません。この軸は「自分より前にあるもの」を対象としており、「自分を包含するもの」を対象としていないため、定義上、先祖ノードは除外されるのです。
どのオプションをいつ使うか。 構造化された関係(ラベルとその値、見出しとその下の段落、行内のセルなど)には preceding-sibling を使用します。「ネスト構造に関係なく、この要素より上のどこにあるかに関わらず最も近い見出し」といった、真に緩やかな関係には preceding を使用します。preceding は適用範囲が広く、処理がかなり遅く、意図しない要素と一致する可能性がはるかに高くなります。
ラベル・値パターン
これが、スクレイピング作業において「preceding-sibling
」が存在する理由であり、実際のマークアップのほとんどがこのパターンの何らかのバリエーションであるため、しっかりと理解しておく価値があります。
問題点:隣にあるラベルによってのみ識別できる値を取得したい場合です。値自体には、有用なクラスもIDもなく、区別できる要素が何もありません。
定義リスト:
<dl>
<dt>Price</dt>
<dd>£42.00</dd>
<dt>Stock</dt>
<dd>In stock</dd>
</dl>
価格を選択するということは、dd
を見つけ、その直前にある dt
が「Price」と記述されているものを特定することを意味します:
//dd[preceding-sibling::dt[1] = 'Price']
これを逆から読み解くと、各 dd
について、その直前にある dt
を取得し、そのテキストが「Price」であれば、その dd
を保持するということです。 [1]
が不可欠であることに注意してください。これがなければ、任意の先行する dt
が一致する場合でも preceding-sibling::dt = 'Price'
が真となり、2番目の dd
も条件を満たしてしまうことになります。これは実際によく見られるバグです。
表のセル:
<tr>
<td>SKU</td>
<td>ABC-123</td>
</tr>
//td[preceding-sibling::td[1] = 'SKU']
あるいは、2列の仕様表のヘッダー列から行全体にわたる場合:
//th[normalize-space() = 'Weight']/following-sibling::td[1]
ラベルがth
である場合、2番目の形式の方が通常は好ましいです。これは、読み進める方向と一致し、テーブルの構造に合致するからです。
堅牢性の向上により、実際のマークアップでも正しく動作するようになります:
//dd[preceding-sibling::dt[1][normalize-space() = 'Price']]
normalize-space()
は、内部の空白を圧縮し、末尾をトリミングします。これにより、そうでなければ正確な文字列比較を失敗させてしまう、整形式のHTMLも適切に処理されます。 これは、XPathスクレイピングにおいて最も価値の高い関数です。
部分一致については、contains()
の方が許容範囲が広く、それに応じて精度が低くなります:
//dd[preceding-sibling::dt[1][contains(., 'Price')]]
注意:contains(., 'Price')
は「Price excluding VAT」や「Historic Price」にも一致します。ページにそのようなラベルが複数ある場合、複数の結果が返され、コードは黙って最初の結果を採用してしまいます。
見出しとその後のコンテンツは、逆方向の同じパターンです:
//h2[normalize-space() = 'Specifications']/following-sibling::table[1]
その見出しの直後の最初の表。これはドキュメントや製品ページで非常に一般的な要件ですが、「この特定の見出しの直後の最初の表」を表すCSSの同等表現は存在しません。
述語や条件との組み合わせ `
preceding-sibling
` は XPath の他の部分と組み合わせて使用でき、知っておくと便利な組み合わせがいくつかあります。
兄弟要素の計数 — 最初の要素や最後の要素を検索したり、位置を確認したりするのに役立ちます:
//li[count(preceding-sibling::li) = 0] first li
//li[count(preceding-sibling::li) < 3] first three
存在の判定 — ブールコンテキストにおけるノードセットは、空でない場合に真となります:
//p[preceding-sibling::h2] paragraphs with an h2 somewhere before
//p[not(preceding-sibling::p)] first paragraph among its siblings
軸の連鎖:
//span[@class='value']/preceding-sibling::*[1]/text()
タグに関係なく直前の要素とそのテキスト。
複数の条件:
//td[preceding-sibling::td[1] = 'Status'][normalize-space() != '']
2つの述語を順次適用: "Status" ラベルの後のセルであり、かつ空でないこと。
..
というショートカットについての一言。これは軸(axis)よりも簡潔な場合が多い:
//dt[.='Price']/following-sibling::dd[1]
は、同じ結果を得るための preceding-sibling
という形式よりも通常読みやすく、ドキュメントが記述されている方向に沿って読み取れる。アンカーがラベルである場合は、こちらを優先する。
CSSセレクタで置き換え可能な場合と不可能な場合
「CSSは後方参照ができない」という従来の通説は、見直すべきです。
CSSでは現在、:has() を使用して兄弟要素の条件指定が可能になりました。 最新のブラウザで広くサポートされており、セレクタを兄弟関係に基づいて条件付けすることができます:
dt:has(+ dd) a dt immediately followed by a dd
li:has(~ li.active) an li with a later sibling that is active
CSSでは依然としてできないこと:
テキストコンテンツによる選択。 [text() = 'Price'] に相当する CSS 構文は存在しません。ラベルと値の照合はテキストの照合であるため、この点だけでも、ラベルと値のパターンは依然として XPath の領域にとどまっています。
任意の祖先要素への移動。 :has() は親要素の選択を可能にしますが、一般的な祖先軸は存在しません。
逆方向の軸に沿ってインデックスを指定すること。 「このタイプの最も近い直前の要素」を意味するCSSの構文は存在しません。
そして、CSSの方が優れている点: 単純な処理のすべてです。クラスやIDによる選択、子孫関係、属性の一致などです。CSSセレクタは可読性が高く、ツールによるサポートも充実しており、ほとんどのエンジンで処理が高速です。
賢明な方針としては、デフォルトではCSSを使用し、テキストの一致や逆方向のナビゲーションが必要な特定の場面でのみXPathを利用することです。1つのコードベースで両者を混在させることは問題ありません。セレクタの90%をCSSで、残りの難しい10%をXPathで処理するスクレイパーは、どちらか一方に固執するスクレイパーよりもメンテナンスが容易です。
パフォーマンスと脆弱性
2つの実用上の制約。
パフォーマンス。 preceding-sibling は兄弟ノードの数によって制限されますが、その数は通常少ないため、問題はありません。一方、preceding はドキュメント内のコンテキストノードより前のすべてのノードをスキャンするため、ページが大きい場合はコストが高く、ループ内では計算量が2乗になります。 preceding を使用したセレクタの処理が遅い場合、その原因はほぼ間違いなくこれです。より近いコンテキストノードから preceding-sibling を用いて書き直すのが、一般的な対処法です。
脆弱性。 兄弟要素に基づくセレクタはドキュメントの構造に依存しますが、再設計によってまさにその構造が変更されてしまいます。preceding-sibling::td[1] は、誰かが列を挿入した瞬間に、何の警告もなく動作しなくなります。エラーは発生しませんが、セレクタが別のセルに一致してしまい、データが知らぬ間に間違ったものになってしまいます。
実際に役立つ 3 つの対策:
可能な場合は、位置ではなくテキストをアンカーにする。 //dt[.='Price']/following-sibling::dd[1] はリストの順序変更後も機能し続けますが、(//dd)[3] は機能しなくなります。
抽出する内容を検証する。 価格が通貨のパターンに一致すべき場合は、それを確認してください。価格の代わりに株価のステータスを返すようになったセレクタは、その形式を検証する仕組みがない限り、気づかれません。
構造化データが存在する場合は、埋め込み形式を優先してください。 ページに <script type="application/ld+json"> ブロック内の JSON-LD が含まれている場合は、代わりにそれを解析してください。これは機械が読み取れるように設計されており、再設計時にもはるかに安定しており、セレクタの脆弱性という問題そのものを解消します。XPath を記述する前にこれを確認する30秒の時間は十分に価値があります。
目に見えない構造上の不具合は、プロキシのテストが重要な理由で説明した問題と同じ類のものだ――リクエストは成功し、解析も成功するが、データが間違っている。
使用すべきでない場合
要素に利用可能な識別子がある場合。 id、class、または data 属性がある場合は、それらを使用してください。構造に依存するセレクタは、開発者が意図的に付けた名前に依存するセレクタよりも、明らかに脆弱です。
構造化データが利用可能な場合。 JSON-LD、マイクロデータ、XHRによるJSONペイロードなど。これらはいずれも、レンダリングされたHTMLを解析するよりも優れています。
関係性が本質的に緩い場合。 preceding::*[5] のような記述をしている場合は、構造が実際には何も伝えておらず、次のデプロイでセレクタが機能しなくなる可能性があります。アプローチを見直してください。
CSSで十分な場合。 単純な選択であれば、CSSの方が可読性が高く、サポートも充実しています。XPathはテキストの一致確認やバックワードナビゲーションに留めておきましょう。
ブラウザのXPath 2.0機能に依存している場合。 ブラウザはdocument.evaluateを通じてXPath 1.0を実装しており、Seleniumはブラウザに準拠しています。 したがって、matches()、正規表現、upper-case()、シーケンス型は使用できません。lxml などのサーバーサイドライブラリも、共通 API に関しては XPath 1.0 に準拠しています。オンラインで見つけたコードスニペットが動作しない場合は、それより新しいバージョンにのみ存在する関数が使用されていないか確認してください。
よくある質問
XPathにおける「preceding-sibling」とは何ですか?
コンテキストノードと同じ親を持ち、ドキュメント順でその前に現れるすべてのノードを選択します。祖先、子孫、またはツリーの他のレベルにあるノードは含まれず、兄弟ノードのみが対象となります。コンテキストノードが属性ノードまたは名前空間ノードの場合は、結果は空になります。
なぜ preceding-sibling[1] は間違った要素を返すのですか?
preceding-siblingは逆軸であり、逆軸での位置番号付けはドキュメント順の逆順で行われるためです。したがって、[1]は、ドキュメント内の最初の兄弟ノードではなく、最も近い先行する兄弟ノードを意味します。ドキュメント順の最初のノードを取得するには、軸を括弧で囲みます:(preceding-sibling::p)[1]。
preceding と preceding-sibling の違いは何ですか?
preceding-sibling は、同じ親を持つノードのみを対象とします。preceding は、祖先ノードや属性ノードを除き、ドキュメント内のどの深さにあるものでも、それより前のすべてのノードを対象とします。preceding は対象範囲がはるかに広く、処理速度もかなり遅く、意図しないものが一致する可能性がはるかに高くなります。
XPathでラベルに基づいて値を選択するにはどうすればよいですか?
テキストでラベルを一致させ、隣接する要素を取得します://dt[normalize-space()='Price']/following-sibling::dd[1]、または値側から取得する場合は //dd[preceding-sibling::dt[1]='Price']。[1] は重要です。これがないと、先行するラベルのいずれかが一致しただけで述語が真になってしまいます。
CSSセレクタで preceding-sibling と同じことができますか?
ある程度は可能です。:has()は、最新のブラウザで兄弟要素の条件付き選択を実現します。しかし、CSSでは依然として、テキスト内容による選択や逆方向の軸に沿ったインデックスによる選択はできません。そして、ラベルと値のパターンでは、まさにこのテキストの一致が不可欠です。単純な選択にはCSSを、テキストや逆方向のナビゲーションが必要な場合はXPathを使用してください。
preceding-siblingはSeleniumやブラウザで動作しますか?
はい。ブラウザはdocument.evaluateを通じてXPath 1.0を実装しており、Seleniumはブラウザのエンジンを利用しています。この軸はXPath 1.0に準拠しており、普遍的に利用可能です。 利用できないのは、XPath 2.0以降の機能です。正規表現、matches()、upper-case()などは利用できません。
preceding-siblingは処理が遅いですか?
通常は遅くありません。処理速度は兄弟要素の数によって制限されますが、その数は通常少ないものです。preceding軸は処理が遅くなります。これは、ドキュメント内のそれより前の部分をすべてスキャンするためであり、ループ内では計算量が2乗になるからです。兄弟要素に基づくセレクタの処理が遅い場合は、実際にprecedingを使用していないか確認してください。
XPathセレクタの脆弱性を軽減するにはどうすればよいですか?
可能な限り位置ではなくテキストをアンカーとして使用し、normalize-space() を使用して空白の違いに対応し、構造よりも識別子やデータ属性を優先し、HTML を解析する前に埋め込まれた JSON-LD がないか確認し、抽出するデータの形式を検証して、誤った要素に一致したセレクタが静かに失敗するのではなく、はっきりとエラーとなるようにしてください。
まとめ `
`preceding-sibling は、同じレベルのノードの間を「後ろ方向」にたどるという、単一の役割を持つ限定的なツールです。重要な点は、これが「逆方向」の軸であるということです。つまり、[1]`` は「最初のノード」ではなく「最も近いノード」を指し、括弧を付けるとその意味が完全に逆転します。この一点の違いこそが、この関数を使用する際に多くの人が誤った結果を得てしまう主な原因となっています。
その真価は「ラベル-値」パターンにあります。つまり、隣にあるテキストによってのみ識別可能なフィールドを抽出できる点です。CSSは:has()によってそのギャップの一部を埋めていますが、依然としてテキストコンテンツによる選択はできず、ラベルとはまさにテキストそのものです。こうしたケースにおいて、XPathは「慣れ親しんだツール」というよりは「適切なツール」であり続けます。
注意すべき点は、構造セレクタは黙って動作しなくなることがあるということです。 新しい列が追加されたり、リストの順序が変更されたり、ラッパーのdivが追加されたりすると、見た目は何の問題もなさそうであっても、セレクタが別の要素に一致してしまうことがあります。可能な限りテキストを基準にし、セレクタを記述する前に埋め込まれた構造化データをチェックし、結果の妥当性を検証してください。目に見える失敗は、決してコストのかかる失敗ではないからです。
