🔍

WebスクレイピングのためのXPath学習ノート

2022/01/13に公開

初めてWebスクレイピングとXPathを触った。XPathを使うにあたって日本語資料が少ないので、自分用のXPathの学習メモを作ることにした。日本語で読める参照先が偉大過ぎる(英語だらけで目眩がしたのは自分だけではないはずだ)。よって、本記事は勉強ノートという名の紹介記事となります。
アコーディオンで畳まれた部分と脚注に用がなければ4分で読めます。全部読むと9分程度です。

XPath学習の動機(例)

CSS XPath
検索の方向 親要素から兄弟や子孫要素へと進む 親から子孫方向に加えて子要素から親要素に向けても検索できる。
テキストノードの扱い 検索対象や条件に含められない 検索対象や条件に含められる
コメントノードの扱い 検索対象や条件に含められない 検索対象や条件に含められる
  • htmlのツリー構造を決める人がちょいちょい構造を変えることが分かり、子要素から親要素へ向かっての検索を行いたかった。
  • 使用したライブラリがコメントアウトされたhtmlタグに反応するので、最初に取り除きたかった。

よって資料が少なかったXPathを選択。classやidの名前や名づけルールを変えられたら困るけど、それはまあ仕方ないかなと思っている。

XPath学習資料

自分が探した範囲では以下のURLが参考になった。

文字ベースの資料

初めて入門するのに読みやすい記事

(*2つはほぼ同一内容)

分量はあるが良い入門

XPath | TECHSCORE(テックスコア)
ぱっと見た印象だが仕様書通りの内容がそろっており、系統だっていて読みやすかった。同じサイトに周辺知識もまとまっていてとてもありがたい。

仕様書

XPath 1.0

Google Chromeやlxmlはこのバージョンに対応。@2022/01/11
本家英語版はこちら(XML Path Language (XPath))。XPath 1.0の日本語訳もある。

XPath 1.0で仕様がよく分かりづらい書き方をされている場合、紙幅を割いて文章が追加されていることがあるので後続のバージョンを読んでもいいかもしれない。
特にWebスクレイピングで関係する範囲は(これを書いている2021年1月13日時点で最新の)XPath 3.1の場合「3.3 Path Expression」あたりがメインになるのではないかと思う。
3.3 Path Expression - XPath 3.1(本家,英語)
3.3 パス式 - XML Path Language (XPath) 3.1(日本語訳)

とにかく短いページ数で一覧したい

XPath チートシート

インタラクティブ仕様のplayground

http://xpather.com/
理解を深めるにあたって開いておくと疑問があったときに確認しやすい。公開されているのはベータ版で、Google Chrome最新版またはFireFox最新版でのアクセス推奨。
一番上の記入欄にXPathを入れると、どの要素が選び出されるのかが視覚的に確認できる。
検索対象の文書を自分でカスタムしたり、htmlをそのままコピペしたりできるので「これで動くか?」を気軽に試せる(動作が重くなるため、コピペするサイズは500KBまでが推奨)。

スクレイピング対象のページを開いてConsoleを見ながら下記のようなものを書いて、実物を見ながら実験してみてもいい(失敗してもページをリロードすればOK)。

function test_xpath_selector(xpath_selector){
const r = document.evaluate(xpath_selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const result = []; 
for(i = 0; i < r.snapshotLength; i++){
	e = r.snapshotItem(i)
	result.push(e)
}
return result
}

なおGoogle Chromeの場合、Consoleで使える簡易コマンド(参照元)があって、$x("xpath_selector");がある。返ってくる内容は上記と一緒。

XPath概要メモ

  • 検索の起点はルートノード(html文書であれば、子ノードにhtml要素が入っている地点)。
  • 現在地から見えるものは、素の状態だと自身の属性のみ + (デフォルトの軸=検索方向・範囲である)直接の子ノード(要素名のみ)。
  • 「軸」は検索する対象にする要素の方向と範囲を示す。軸を明示的に指定すると検索できる対象の方向と範囲を指定できる[1]軸::要素名(またはワイルドカード)という形で使う。デフォルトでは直接の子ノードが見える状態で、文章の先頭から現れる順に並んでいる。

仕様書による定義(英語)
直後の要素の数える順が「文書冒頭から登場する順番」を正順、「文書末尾からたどって行って要素の開始タグが現れる順」を逆順とする[2]

指定 直訳 正順/逆順
parent 親(要素)。 逆順
child 子(要素)。 正順
self 自身。要素でも属性でもOK (略)
ancestor 祖先(要素)。 逆順
ancestor-or-self (略) 逆順
descendant 子孫(要素)。 正順
descendant-or-self (略) 正順
preceding-sibling 先行する兄弟(要素)。 逆順
following-sibling 後に続く兄弟(要素)。 正順
preceding 先行する(要素)。 逆順
following 後に続く(要素)。 正順
  • 条件が当てはまるものを残す形で対象を絞り込む。
  • 条件が当てはまった先へ1)移動する(デフォルト動作)、2)移動しない、の2種類の動作ができる。基本的には当てはまったノードに視点が移動する。移動したくない場合は[ここに条件を記述する]という形の条件を書くと現在地から移動しない[3]

条件の指定

1)要素、2)要素の属性 の2つを使って検索できる。
要素の検索方法は次の2通り:

  • 要素(element)名。いわゆるhtmlタグと言われるものの名前を指定する。
  • テストのうち、ノードテスト(頻出はtext()comment())[4]

当てはまる・当てはまらない=真偽を判断するための道具は

  • 比較演算子(=, !=, <, <=, >, >=)[5]

  • 論理演算(and, or, not())[6]

  • 比較対象になる値としてリテラルなど特定の値が必要であればnumber[7], 文字列, booleanが使える[8]

  • 計算(+, -[9], *, div(小数点以下も算出するタイプの割り算)[10], mod)と文字列操作[11]が一定程度できる。
  • 物足りない/少なさに思わず心配になる分量の組み込み関数もある。カテゴリは大きく分けてノードセット、文字列、boolean、numberの4種。いずれも[]の内部での条件判断に使うように見える。
    関数の参照元:
    4 Core Function Library(英語)
    4 コア関数ライブラリ
ノードセット関数(ざっくり)

直前までの絞り込みで残っている候補の数を「コンテキストサイズ」、その時の軸に合わせた並び順で数えて何番目にあたるかの数字を「コンテキストポジション」と呼ぶ。コンテキストポジションは1から数える。[12]
参照元: 4.1 ノード集合関数, 5. コア関数ライブラリ 1 | TECHSCORE(テックスコア), 4.1 Node Set Functions

last()

コンテキストサイズ(現在当てはまる候補がいくつあるか)を示す数値を返す。//*[id="container"]/*[last()]であれば、idがコンテキストである要素の(デフォルト検索範囲である)子要素候補の個数を数えた結果を返している。

position()

コンテキストポジション(現在当てはまる候補のうち、自身がいくつめにあたるか)を返す。[position() > 3][position() mod 2 = 1]などの使い方がある。[position() = 1]であれば、短縮形の[1]という書き方をよく見る[13]

count(node-set)

ノードセットの中にノードがいくつあるかを返す。配列を条件で絞り込んでcountする例などもある。

name(node-set?)

現在値の要素または属性の名前を返す。例えば//*/@*[contains(., "data-")]と書くと、data-という文字列を含む属性が取得できる。引数が0または1つしか入れられない。

以下、今のところあまり使い方が思いつかないもの:

Function: node-set id(object)
 Function: string local-name(node-set?)
 Function: string namespace-uri(node-set?)

文字列関数

参照元: 4.2 文字列関数, 5. コア関数ライブラリ 1 | TECHSCORE(テックスコア), 4.2 String Functions

string(引数)

型変換[14]

concat(string, string, string*)

引数に文字列を取って文字列を結合する。"str1" || "str2"と書いてもつなげられる。

starts-with(string, substr)

substrで始まっている場合true。それ以外はfalse。

contains(string, substr)

substrを含む場合true。それ以外はfalse。

substring-before(string, substr)

substrより前を抜き出す。

substring-after(string, substr)

substrより後を抜き出す。

substring(string, start_index, substr_length?)

start_indexは1文字目を1としたときの開始位置、substr_lengthの指定がなければ末尾まで。

string-length(string?)

引数がなければ現在地の要素(コンテキストノード)を文字列に変換してから長さを数える。サロゲートペアの文字でも1文字と数える[15]

normalize-space(string?)

文字列の前後のCR, LF, 水平タブ, 半角スペースを除去し、残った中で「CR, LF, 水平タブ, 半角スペース」の連続した部分を半角スペース1つに置き換える。

translate(string, old, new)

stringの中のoldの各文字をnewの各文字に1対1対応させる形で置き換え。

boolean関数

真偽値に関する演算。
参照元:4.3 ブール関数, 6. コア関数ライブラリ (2) | TECHSCORE(テックスコア), 4.3 Boolean Functions

boolean(object)

型変換

not(boolean)

true()

false()

lang(string)

コンテクストの言語が引数と一致しているか調べる。

数値関数

参照元: 4.4 数値関数, 6. コア関数ライブラリ (2) | TECHSCORE(テックスコア), 4.4 Number Functions

number(object?)

型変換。色々書いてあるけど分かってない。

sum(node-set)

各ノードを数値に変換して合計するらしい。動作がいまいちよく分からない。

floor(number)

整数値に切り下げ。

ceiling(number)

整数値に切り上げ。

round(number)

近い数字に丸める。小数部分が0.5の場合は大きい方の数値に向かって丸める。

仕様書によれば、比較演算子と論理演算の順は優先度の高い順に

  1. >=, >, <=, <(この4種同士であれば優先度はいずれも同一)
  2. =, !=(この2種の優先度は同一)
  3. and
  4. or
    ・・・で、()を付けると優先度を指定できる。
表記 意味
. 現在地(現在地がノード、属性のいずれでも使える)[16]。 コンテキストアイテムという名前。
.. 現在地からみて親ノード
/ 現在地、または(デフォルト動作で)現在地の直下の子ノード。
// 現在地、または現在地から見て子孫ノード[17]
* ワイルドカード。要素にも属性にも使える
@ 現在地からみて自身の属性(どの属性かは指定が必要。決まらなかったら@*でよい。)
(elementA|elementB) 子要素のうちelementA要素またはelementB要素。ノードだとこの書き方。
[条件] 条件。現在地から移動しないが、カッコ内の条件を判断するときの視点は移動先候補のノードからの視点での判断になる。[]内の条件を確かめて当てはまったノードが残る。中で比較演算子、or, and, not()条件が使える[18]。and条件の場合はカッコを繰り返してa[@class="reference"][@class="clearfix"]のように書くこともできる[19]
text() 軸の要素に対するテスト。あてはまるものが残る(左の例であればテキストノード)。[20]
comment() 軸の要素に対するテスト。あてはまるものが残る(左の例であればコメントノード)。

仕様書に書いてある実例を読む

XPath 1.0の場合の参照先

ロケーション・パス(英語) - (日本語)
Syntax(省略形あり)(英語) - (日本語)

移動経路を確かめたもの一覧
XPath 直前までのXPath 現在地 軸(検索の範囲・方向) 要素 属性 条件 次の移動先
html (なし) ルートノード 子要素(デフォルト)・正順 html (子要素の)html要素
//* // ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) 子要素
//div // ルートまたはその子孫ノード 子要素(デフォルト)・正順 div (子要素の)div要素
//ancestor::article // ルートまたはその子孫ノード 祖先要素・逆順 article 祖先ノードのうちarticle要素
//comment() // ルートまたはその子孫ノード 子要素(デフォルト)・正順 テスト: comment() (子要素の)コメント要素[21]
//header/a //header 文書内すべてのheader要素 子要素(デフォルト)・正順 a header要素直下のa要素
//header//a //header 文書内すべてのheader要素 子孫要素・正順 a header要素の子孫ノードのうちa要素
//header//a[3] //header 文書内すべてのheader要素 子孫要素・正順 a [3] header要素の子孫ノードのa要素の3つめ(*0始まりではなく1始まり)
//header//a[@href] //header 文書内すべてのheader要素 子孫要素・正順 a [@href] header要素の子孫ノードのうち、href属性のあるa要素
//header//a/@href //a 文書内すべてのa要素 子要素(デフォルト)・正順 href header要素の子孫ノードのa要素のうち、href属性(値が手に入る)
//*[@id] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) [@id] (子要素で)id属性を持つもの
//*[contains(@class, 'detail')] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) [contains(@class, 'detail')] 子要素のうち、class属性にdetailを含むもの
//a[text() = "詳細はこちら"] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 a [text() = "詳細はこちら"] 子要素のうちa要素で、そのテキストノードの値が「詳細はこちら」であるもの
//a/text()[contains(., "詳細")] //a ルートまたはその子孫ノードのうち、a要素 子要素(デフォルト)・正順 テスト: text() [contains(., "詳細")] 子要素がテキスト要素で、その中に"詳細"が含まれるもの
//*[ancestor-or-self::*[@id="content"]] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) [ancestor-or-self::*[@id="content"]] 子要素のうち、自身または祖先ノードに「idがcontentのノード」に当てはまるものがある要素
//*[not(ancestor-or-self::*[@id="content"] or descendant-or-self::*[@id="content"])] // ルートまたはその子孫ノード 子孫要素・正順 *(ワイルドカート) [not(ancestor-or-self::*[@id="content"] or descendant-or-self::*[@id="content"])] 子要素のうち、祖先・自身・子孫に「idがcontentである要素」に当てはまるものがない[22]
//note[not(self::*[@name][@id])] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 note [not(self::*[@name][@id])] name属性もid属性も設定されていないnote要素
//br/parent::*/preceding-sibling::*/*[1] //br/parent::*/preceding-sibling::* ルートまたはその子孫ノードのうち、br要素の親要素より前にある兄弟要素 子要素(デフォルト)・正順 *(ワイルドカード) [1] 最初の子要素
//*[contains(name(.), "s")] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) [contains(name(.), "s")] 要素名にsを含む属性
//*[contains(name(.), "s")] // ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) [contains(name(.), "s")] 要素名にsを含む属性
///@[contains(name(.), "data-")] //* ルートまたはその子孫ノード 子要素(デフォルト)・正順 *(ワイルドカート) [contains(name(.), "data-")] 要素名にdata-を含む属性

感想

  • なんとなく「関数型で見そうな書き方?」「SQLのexistsとかと似た感じ?(述語論理分野で見たような?)」という類似を感じる。

  • 組み込み関数の少なさが心配になったが、意外にこれだけで目的を果たせそうで嬉しい。

  • 言語仕様の読み取りができると便利なことが分かった。BNFまたはEBNFは構文解析本で詳しく書かれているので、興味があればそちらをかじっても良さそうだ。正規表現とか文字コードの世界のものすごいディープな所まで入らなくても読めそう。

  • 概要部分以外をアコーディオン形式で畳んでみたら、ぱっと見の文字数があっさりな感じになった。メモのつもりが、書いているうちに気になって調べてみたらえらい文字数になった。こんなに書くと思ってなかった・・・。

  • TECHSCOREのXPath資料が偉大だった。周辺分野も含めての網羅性がすごくて、入門記事を見た後に何度も見直した。

  • 同じく、仕様書の日本語訳が公開されていて本当にありがたかった。英語でめげそうになっても「いざとなったら日本語訳も見よう」と思えて心強かった。
    XPath 1.0(日本語訳)
    XPath 3.1(日本語訳)

脚注
  1. この時、見つかった要素の並び順も軸の指定ごとに変わっている。path演算子(/)で区切ったり、カッコで囲むことで並び順をリセットできる。 ↩︎

  2. 正順・逆順の出典はここ↩︎

  3. 個人的にSQLのサブクエリ的な雰囲気を感じた ↩︎

  4. XPath 3.1仕様書を見たらノード以外のテスト(例: attribute())がある。2022.01.12現在Google Chromeやlxmlでは動かないが、正確性を期してこの書き方にした ↩︎

  5. XPath1.0では暗黙の型変換があり、左右の比較対象が特定の型であればもう片方もそれに合わせて変換される。優先度が高い順にboolean, number(float), stringとなる。 ↩︎

  6. andとorは演算子、notは関数。仕様書で書かれている場所が違う。 ↩︎

  7. XPath 1.0の仕様書によれば、floatで、NaN, Infinity-Infinity、正のゼロ、負のゼロが使える。なお、XPath3.1を確認したところ、Integer, double, decimalとの文字があったのでバージョンによる模様。InfinityとNaNは対応だが、正負のゼロはよく分からない。 ↩︎

  8. true(), false()で直接比較可能な値を呼び出せる。(関数型みたいな呼び方だなあという印象を持った) ↩︎

  9. 仕様書によると引き算の場合には「-の前には必ずスペースをつけること」と注意書きがある ↩︎

  10. 将来のバージョンには解が整数になる割り算idivが使える。 ↩︎

  11. "str1" || "str2"で文字列が結合できる。残りは組み込み関数参照。 ↩︎

  12. XPath 1.0の定義内でいきなり出てくるので出典を探したところ、XSLTの仕様書に定義があった。本家参照箇所 日本語訳参照箇所 ↩︎

  13. CSSの:nth-child(2n+3)についてはand条件として分解し、[position() >= 3][position() mod 2 = 1]と書くと再現できる。 ↩︎

  14. ここのNOTEを見るに、数字から文字列への変換にこれを使うのは推奨ではないらしい。XSLTのfotmat-number関数を使ってほしいとのことだが、Google Chromeで動かないのでstring関数で変換するしかない気がする。 ↩︎

  15. サロゲートペアの場合の文字数の扱いは仕様書本家(英語)または日本語版参照。 ↩︎

  16. コンテキストノードの定義はXPath1.0ではこの節の末尾ちかく, 3.1だとここ↩︎

  17. ///descendant-or-self::node()/の略。出典はここの3番 ↩︎

  18. Webスクレイピングの場合にand, or, not()を条件による絞り込み以外で使う用事が思いつかない。*[not(self::div)]のような書き方で何とかなりそうな気がする。 ↩︎

  19. andで繋げると読みづらいから分けられないかなと思ったら、仕様でOKと書いてあった。参照元はリンク先の節の末尾。 ↩︎

  20. text()comment()は仕様書に書かれている場所(ノードテスト)からして関数ではないようだ。個人的には暗黙の引数にコンテキストノードを取る関数に見える ↩︎

  21. Scrapy内蔵のlxmlがコメントアウトされたタグにも反応してしまうため、コメントは最初に除去することにした。 ↩︎

  22. 切り抜きたい部分を整理しつつXPathを書くため、分量の多いhtmlから関係ない部分を一度取り除くのに便利だった。条件が煩雑になってきたのでド・モルガンの法則を用いて若干整理したが、長くて読みづらい↩︎

Discussion