【2021年5月版】jsなどで〇〇な子を持つ親を取ってくる 〜 XPathのススメ

Zennにも投稿してます→[jsなどで〇〇な子を持つ親を取ってくる 〜 XPathのススメ](https://zenn.dev/koinori/articles/869abf304a07b2662494)

# 答え

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

## サンプル

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()`はあるけど[まだどのブラウザも効いてない](https://developer.mozilla.org/ja/docs/Web/CSS/: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はいける。IEは知らん。)

## 条件のちょっとした応用

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

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

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

# 具体例

例えば Wikipediaの"[タンポポ](https://ja.wikipedia.org/wiki/%E3%82%BF%E3%83%B3%E3%83%9D%E3%83%9D)" のページで試してみましょう。

## サブタイトル全て取ってくる

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

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

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

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

```
$x('//ul[.//*[contains(text(),"漢方")]]')
```
これで本文に"漢方"が含まれる何かしらの子要素を持っている`ul`タグが一つ取得できます。

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

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

```
起点/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()')
```
です。

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

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

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

を取得してます。

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

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

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

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

1. `(//*[text()[contains(., "在来種")]]/preceding-sibling::*[name()="h3" or name()="h2"][1])[1]`
1. `(//*[text()[contains(., "在来種")]]/following-sibling::*[name()="h3" or name()="h2"][1])[1]`
1. `(//*[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()`でチェックすることをお勧めします。

以上です！