💭

jsなどで〇〇な子を持つ親を取ってくる 〜 XPathのススメ

2021/05/23に公開約7,400字

Qiitaにも投稿してます→【2021年5月版】jsなどで〇〇な子を持つ親を取ってくる 〜 XPathのススメ

答え

//親[子供[子供の条件]]

サンプル

Chromeのコンソールで$x()から簡単に試せる。

$x('//h3[span[@id="〇〇"]]')

とか、

$x('//h3[.//*[text()="〇〇"]]')

とか、

$x('//h3[.//span[contains(@class ,"〇〇")]]')

などとやれば、それぞれ、

  • idが〇〇なspanが直下にあるh3
  • テキストが〇〇な任意のエレメントを含むh3
  • Cssクラスに〇〇を含むspanを含む h3

を取得できる。

解説

子を条件にその親エレメントを決めたい場合は多い。が、今のところCSSセレクタでは階層の上から辿るエレメント指定しかできない。。。
一応、CSS 4の草案では擬似要素 :has()はあるけどまだどのブラウザも効いてない

そんなわけでお手軽なCSSセレクタで"〇〇な子を持つ親"を取ってくることはできない。
というわけでブラウザでサポートされるもう一つのセレクタ、XPathの登場になります。

JSで使うだけでなく、Pythonでクローラー作って特定の子を持つブロックだけスクレイピングしたい、という時も威力を発揮します。Selenuimでゴニョゴニョしたい、なんて場合はもはやマストです。

XPath、覚えておいて損はないです

簡単な構文の紹介

大抵の問題は以下のポイントだけ覚えておけば解決できます。

  • XPathはXMLを検索する専用のクエリ言語なのでXMLっぽいドキュメントには大体(?)使える。
  • ルートが明確にありドキュメントの起点は/である。
  • ディレクトリのように"パスの位置、階層"の概念もある。相対パス"./"、"../"も使える。
  • ルート飛ばしたり、ここから以下全部などしたい場合は//とやる。子階層をすべて検索してくれる。
  • 基本はタグエレメント名で指定、ワイルドカード*も使える。ex) //h3//*など
  • 条件は[...]で指定する。ex) //*[text()="タイトル"]
  • エレメントの属性は@で参照する。 ex) //*[@id="タイトル"]
  • エレメントの中の文字列はtext()で取得する。 ex) //h1[text()="タイトル"]
  • "含む"はcontains()で指定する。 ex) //h1[contains(text(),"タイトル")]
  • "本文が〇〇であるエレメント"は[elm="本文"]でショートカットできる。 ex) //h3[*="本文"]
  • 検索結果は"セット"で持っている。 ex) //h1[...] は検索条件にヒットするすべてのh1エレメントを表す
  • エレメント名そのものを条件で指定するにはname()。 ex) //*[contains(name(),"head"] でタグ名に"head"を含むエレメントすべて取得
  • 特定のいくつかのエレメントを取得したい場合はname()の条件を結合してガリガリ書くより、(//h2 | //h3) で2つのエレメントセットを無理やり結合したほうが楽できる。(パフォーマンスは落ちるだろうけど。。。)

こんな感じでエレメントを"検索"していきます。

またブラウザでは、コンソールから$x()でお手軽にxpathでの検索が行えます。(Chrome、FFはいける。Internet Explorerは知らん。)

条件のちょっとした応用

ここでミソなのは[...]の条件の中身も"XPath"になっているということです。つまり、[...]の条件内でも上記の構文がそのまま使える、ということです。
普通のプログラムの"条件式"の感覚ではちょっと"アレ?"って感じですが、クエリ言語のSQLで、割とどこでも(SELECT 〜)できるのと同じような感覚?です。

つまり、//要素[.//要素[.//要素[...]]]というように現在位置を起点にさらに子階層を再帰で条件を絞っていけるのです。

また、//要素/要素/../要素みたいな階層を下から上に遡ることもできるので、"〇〇な子要素を持つブロックのタイトルだけをまとめて"とってくる、というエスパーなことも可能です。

具体例

例えばWikipediaの"タンポポ"のページで試してみましょう。

サブタイトルすべて取ってくる

サブタイトルはmw-headlineというclassをもつspanのテキスト本文で定義されています。
これを取ってくるXpathを確認するにはブラウザのコンソールから以下のようにします。

$x('//span[@class="mw-headline"]/text()')

これで"名称、特徴、花の特徴、日本における在来種と外来種、交雑、利用、遊び、薬用、食用、工業製品の原料、主な種、タンポポの画像、文化、脚注、注釈、出典、参考文献、外部リンク"の18のテキスト(テキストノード)が取得できます。

"漢方"を含む"リスト"を取ってくる

本文の中にいくつかあるulのリストの中でリスト内の本文に"漢方"という文言を含むulのブロックを取得してみましょう。

$x('//ul[.//*[contains(text(),"漢方")]]')

これで本文に"漢方"が含まれる何かしらの子要素を持っているulタグが一つ取得できます。

"交雑"セクションの本文を取ってくる。

"交雑"セクションの本文はpultableタグの3種で構成されています。この3種のタグで絞り込んでも汎用性ないので、"交雑"テキストを持つh3から次のh3orh2までのエレメント、としましょうか。
さらに、h3pは親子ではないので取得にはちょっとした工夫が必要です。

起点/following-sibling::*[count(. | 終点/preceding-sibling::*)=count(終点/preceding-sibling::*)]

というcountを使って起点以下のエレメントから終点以上のエレメントを引き算?してfollowing-siblingを取得する、というまどろっこしいやり方をします。(なぜ機能するのか自分もわかっていません。。。)

具体的には以下のようになります。

$x('(//h3 | //h2)[*="交雑"]/following-sibling::*[count(. | (//h2 | //h3)[*="利用"]/preceding-sibling::*)=count((//h2 | //h3)[*="利用"]/preceding-sibling::*)]')

これで "交雑" セクションの本文である、p,ul,tableと最後のdivが取得できます。

最後のdivタグは除去したい、という場合は、

$x('(//h3 | //h2)[*="交雑"]/following-sibling::*[count(. | (//h2 | //h3)[*="利用"]/preceding-sibling::*)=count((//h2 | //h3)[*="利用"]/preceding-sibling::*)][not(name()="div")]')

と最後に[not(name()="div")]すれば、div以外のタグが取得できます。

本文に"在来種"が含まれるセクションのタイトルをすべて取得する。

Wikipediaではセクションのヘッダーは "h3>span"構造で固定ですが、本文はul使ったりpだったりまちまちです。"在来種"が含まれる"任意のエレメント"の以前にある最初のh2もしくはh3を取得してみましょう。
さらに、本文そのものが<br/><sup>などを含む複雑な構成をしている場合はシンプルなcontains(text(), "在来種")では太刀打ちできません。
[text()[contains(. "在来種")]]とやってさらに細かくマッチさせていく必要があります。
それでは、まとめると以下のようなパスになります。

$x('//*[text()[contains(., "在来種")]]/preceding-sibling::*[name()="h3" or name()="h2"][1]/span/text()')

です。

これで、"日本における在来種と外来種"、"交雑"、"主な種"の3つのテキスト(テキストノード)が取得できます。

ちょっと複雑なので分解していくと・・・

  1. //*[text()[contains(., "在来種")]]で"在来種"を含むエレメントすべて
  2. /preceding-sibling::*[name()="h3" or name()="h2"]で上記のエレメントの前にある h3h2 タグすべて
  3. [1]で上記の最初(1番目)のもの
  4. /span/text() で上記に続く spanタグの本文

を取得してます。

本文に "在来種" を含む最初のセクションの本文を取得する

さて、若干複雑ですが上記の組み合わせで以下のように考えましょう。

  1. 本文に "在来種" を含むエレメントの前にあるh2h3の最初のものを検索
  2. 本文に "在来種" を含むエレメントの次にあるh2h3の最初のものを検索
  3. 1.と2.の間のエレメントを取得

上記の起点〜終点の間にあるエレメントを取得するクエリの応用です。
で、上記をxpathで書くと以下のようになります。

  1. (//*[text()[contains(., "在来種")]]/preceding-sibling::*[name()="h3" or name()="h2"][1])[1]
  2. (//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]
  3. (//*[text()[contains(., "在来種")]]/preceding-sibling::*[name()="h3" or name()="h2"][1])[1]/following-sibling::*[count(. | (//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]/preceding-sibling::*)=count((//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]/preceding-sibling::*)]

最後のクエリがキテレツですがコピペでなんとかしましょう。
さて、テキスト抽出をするところまで繋げると以下のようになります。

$x('(//*[text()[contains(., "在来種")]]/preceding-sibling::*[name()="h3" or name()="h2"][1])[1]/following-sibling::*[count(. | (//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]/preceding-sibling::*)=count((//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]/preceding-sibling::*)]/text()')

これで26個のテキストノードが取得できました。
やれやれです。。。

テキストそのものを抽出

さて、これまでもテキストノードが取得できてましたが、この"本文だけ"を抽出するにはXpathだけではどうにもなりません。
テキストノードから本文だけを抽出するには他言語のパワーが必要です。
jsでやると以下のようになります。

$x('...').map(t=>t.data).join("")

Pythonでクローラー書いたりする場合にはextract()とかで良かったような・・・

上記のxpathから本文だけ抽出すると以下のようになります。

> $x('(//*[text()[contains(., "在来種")]]/preceding-sibling::*[name()="h3" or name()="h2"][1])[1]/following-sibling::*[count(. | (//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]/preceding-sibling::*)=count((//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]/preceding-sibling::*)]/text()').map(t=>t.data).join("")

"日本でよく知られるタンポポには、古来から自生していた在来種(日本タンポポ)と、以降に外国から持ち込まれた外来種がある(現在は帰化種といわれている)。在来種は外来種に比べ、開花時期が春の短い期間に限られ、種の数も少ない。また、在来種が種子をつくるためには、他の株から花粉を運んでもらって実を結び子孫を増やす必要から、同じ仲間と群生している。一方で外来種は、一年中いつでも花を咲かせ、かつ一個体のみで種子をつくることができるため、在来種に比べて小さな種子をたくさん生産する。夏場でも見られるタンポポは概ね外来種のである。\n見分け方としては、花の基部を包んでいる緑の部分である片を見てみると、反り返っているものが外来種(図1)で、反り返っていないものが在来種(図2)である。在来種は総苞の大きさや形で区別できる。しかし交雑(後述)の結果、単純に外見から判断できない個体が存在することが確認されている。\n日本における分布は、人間が土地開発を行った地域に外来種が広がり、在来種は年々郊外に追いやられて減少しつつある。より個体数が多く目に付きやすいことから、「セイヨウタンポポが日本古来のタンポポを駆逐してしまった」という印象を持たれるが、実際には誤りであることは、在来種の生き方から理解されている。\nセイヨウタンポポは在来種よりも生育可能場所が多く、かつ他の個体と花粉を交雑しなくても種子をつくることができる能力を持っているため繁殖力は高いが、相対的に種子が小さくて芽生えのサイズも小さくなるため、他の植物との競争に不利という弱点を持っている。そのため、他の植物が生えないような都市化した環境では生育できるものの、豊かな自然環境が残るところでは生存が難しくなる。\n在来種はセイヨウタンポポよりも種子をつける数が少なくなっても、大きめの種子をつくる戦略を選んでいる。また、風に乗って飛ばされた種子は、地上に落下しても秋になるまで発芽しない性質を持っている。在来種が春しか花を咲かせない理由は、夏草が生い茂る前に花を咲かせて種子を飛ばしてしまい、夏場は自らの葉を枯らして根だけを残した休眠状態(夏眠)になって、秋に再び葉を広げて冬越しするという、日本の自然環境に合わせた生存戦略を持っているからである。\n\n\t\n\t\t\n\t\t\n"

はい、無事に文字列として取得できました。

まとめ

この"特定条件の子要素から親要素を特定してさらにその配下を取ってくる"という処理は、実際にプログラムでやってみてもなかなかめんどくさいものです。
Xpathは文字列で定義できるのでどこかのファイルに外出ししておくとプログラムもスッキリします。
本質的でないところで頑張りすぎないように、Xpathでどうにかできないか、アイデアとして持っておくのも良いのではないでしょうか。

また、いろいろ凝ったことをしようとすると直感とxpathの構文が乖離してくるのでこまめに$x()でチェックすることをお勧めします。

以上です!

Discussion

ログインするとコメントできます