XPath の関数を使った Web スクレイピング

## はじめに

Web ページから特定の情報を抽出する技術の総称 Web スクレイピングは、様々な方法で実現されます。手動で必要な部分をコピーする方法や、正規表現を使う方法、HTML と CSS の内容を元に独自のルールで意味的なまとまりを推定する方法 [VIPS: A VIsion based Page Segmentation Algorithm](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.94.1403&rep=rep1&type=pdf) など、一研究分野になるほど本当に様々です。

この記事では、そんな様々な方法の中から [XPath (XML Path Language)](http://www.w3.org/TR/xpath-30/) を取り上げます。XPath は、その名の通り XML から必要な箇所を探索・抽出する為に用いられる言語ですが、HTML にも利用することができます。自分が普段 Web スクレイピングする時は、Python の [lxml](http://lxml.de/) で XPath を使用しています。

Web スクレイピングで実際に想定される状況を例示しながら、[XPath の関数](http://www.w3.org/TR/xpath-functions/)の紹介を行います。XPath の基本については Satosi さんの [XPath チートシート](http://aoproj.web.fc2.com/xpath/XPath_cheatsheets_v2.pdf)を参照いただけば分かりやすいと思います。

## Web スクレイピングで役立つ XPath の関数

### 外部ページへのリンクを探索する

クローラー (スパイダー) を自作する場合を考えてみましょう。クローラーは、ページ中に含まれるリンクを辿りページを遷移していきますが、単純に a 要素の href 属性を探索してしまうと `#hoge` のようなページ内リンクや `javascript:void(0)` のような JavaScript を実行するリンクが含まれるかもしれません。`//@href` とした後にクローラー側でフィルタリングしても良いですが、必要とするリンクの探索が XPath とフィルタリングプログラムの二重管理になってしまって面倒です。

このような場合、`starts-with()` 関数を使用すると上手くいきます。href 属性が `https?://` で始まるリンクだけを取り出せばいいのです。

```xpath:starts-with()で外部ページへのリンクを探索する1
//a[starts-with(@href, "http://") or starts-with(@href, "https://")]/@href
```

ただ、これだけではまだ画像ファイルが含まれてしまうかもしれません。

その場合は、`contains()` 関数を使用しましょう。XPath は、 `ends-with()` がないので `contains()` を上手く使うしかありません。画像の拡張子が入らないという条件を `and` や `not` を使って表現してやれば上手く動きます。

```xpath:starts-with()で外部ページへのリンクを探索する2
//a[(starts-with(@href, "http://") or starts-with(@href, "https://")) and
    not(contains(@href, ".jpg") or contains(@href, ".png") or contains(@href, ".gif"))]/@href
```

完全ではないでしょうが、XPath だけで大部分の不要なリンクを取り除くことができました。

### 長文の段落を探索する

集めてきた様々な Web ページからコーパス (言語資料体) を作成する場合を考えてみましょう。Web ページには、ニュース記事のような意味的なまとまりを持つ段落もあれば、ナビゲーション用の短文だけの段落もあります。HTML の書き方は人それぞれなので単純に `//p` で段落を探索しても不必要な段落が大量に含まれてしまいます。

このような場合は、`string-length()` 関数を使用すると問題を緩和できます。400 字以上ある p 要素だけを探索したい場合は XPath だけで以下のように書けます。

```xpath:長文の段落を探索する
//p[string-length(normalize-space()) > 400]
```
ここで、肝になるのは `normalize-space()` です。HTML 文書内のテキストは、HTML 文書として見やすくなるように通常インデント付きであったり改行付きであったりと、不要な空白が付いています。`normalize-space()` は、このような空白を除去してくれます。詳しくは、次の節で紹介します。

### 不要な空白を除いたテキストを抽出する

Web ページから単純に特定のテキストを抽出したい場合を考えてみましょう。HTML からテキストを抽出する方法は様々ありますが、それぞれに特徴が異なります。以下の HTML 文書を例にその違いを見てみましょう。

```python:python:text(),string(),normalize-space()を使用したテキスト抽出
>>> html = '''
... <!DOCTYPE html>
... <html lang="ja">
...   <head>
...     <meta charset="UTF-8" />
...     <title>Alphabet</title>
...   </head>
...   <body>
...       <h1 id="title">Alphabet</h1>
...       <p class="alphabet">
...       ABCDEFG HIJKLMN<br>
...       OPQRSTU VW  XYZ
...       </p>
...   </body>
... </html>
... '''
>>> import lxml.html
>>> dom = lxml.html.fromstring(html)
>>> dom.xpath('//p[@class="alphabet"]/text()')
['\n      ABCDEFG HIJKLMN', '\n      OPQRSTU VW  XYZ\n      ']
>>> dom.xpath('string(//p[@class="alphabet"])')
'\n      ABCDEFG HIJKLMN\n      OPQRSTU VW  XYZ\n      '
>>> dom1.xpath('normalize-space(//p[@class="alphabet"])')
'ABCDEFG HIJKLMN OPQRSTU VW XYZ'
```

それぞれ以下の様な結果になりました。

- `text()`: 空要素毎に切り分けて空白や改行を含むテキスト (テキストノード) を返す
- `string()`: 空要素を無視し空白や改行を含むテキストを返す
- `normalize-space()`: 前後の空白を削除し内部の複数の連続する空白を一つの空白に置換したテキストを返す

つまり、ブラウザから Web ページを見て、テキストをコピーするのと同じようにテキストを取り出すには `normalize-space()` を使う必要があります。先にも紹介した `string-length()` は `normalize-space()` で得たテキストで数えなければ、空白を含んだ数になってしまうので、それも注意が必要です。勿論、勝手に HTML 文書の内容を整形されたものが困る場合もあるので、`string()` なども重要な関数です。

また、`normalize-space()` は _全角スペースに関しては連続していても一つの全角スペースにしてくれません_ 。その点は注意して下さい。

### 特定の形式のテキストを持つ要素を探索する

個人情報が table 要素で記述されたページで、電話番号の情報だけを抽出したい場合を考えてみましょう。table 要素の子要素 td 要素に名前や住所、電話番号など様々な情報が記述されていて、尚且つ人によって載っている情報の数 (td の要素数) もまばらだと電話番号の td 要素だけを上手く特定できません。

このような場合は、`translate()` 関数が効果を発揮します。全てのページで電話番号が `XXX-XXXX-XXXX` という形式で載っている場合は、以下のようにすれば電話番号の td 要素だけを取り出すことができます。

```xpath:特定の形式のテキストを持つ要素を探索する
//td[translate(normalize-space(), '1234567890', 'XXXXXXXXXX') = 'XXX-XXXX-XXXX']
```

`translate()` は、第二引数の n 文字目の文字を、第三引数の n 文字目の文字に置き換える関数です。もし、第三引数に置き換える文字がなかった場合は空文字 (`''`) に置き換えられます。

この関数を使うことによって、テキストの形式だけで比較を行ったり、抽出するテキストの整形を行うことができます。

## まとめ
XPath には上で紹介した以外にも様々な関数があります。関数によっては、まだ未実装なものもありますが、それでも強力なものばかりです。

XPath は CSS セレクタとよく対比されます。単純に DOM 木から位置を指定し要素を探索するだけであれば、CSS セレクタで事足りますし、学習コストが低いかもしれません。

しかしながら、Web スクレイピングの用途で見ると、上で紹介した通り、XPath は CSS セレクタでできないことがたくさんできる言語です。何より、XML から特定の要素を探索する場合は雲泥の差があります。

Web スクレイピング自体が下火ではありますが、たまにする必要がでた時に XPath が使えるということを覚えておくと、非常に役立つと思います。この記事は、私が昔書いた[関数を利用したXPath式](http://www.cafe-gentle.jp/challenge/tips/xpath_tips_001.html)を加筆修正したものですが、この内容がこれからも幾らかの人に役立てば幸いです。