BeautifulSoupで<br>タグを含む文字列の扱い(前半)

# BeautifulSoup

BeautifulSoupはHTMLやXMLといった構造体からデータを取り出すためのライブラリです。
Webサイトから情報をスクレイピングしてくるのによく使われます。
https://www.crummy.com/software/BeautifulSoup/bs4/doc/

私はできる限りスクレイピングは使いたくないし使うべきではないと考えているのですが、残念ながらやらねばならないときもあるものです。
また、スクレイピングは節度を持って、ルールを守ってやりましょう。

# この記事の内容

以下の2つの話題のうち、前者について挙動を確認します。
後半は[こちら](https://qiita.com/amuyikam/items/b7acf88abb7512d58ed8)

1. Tag.textとTag.stringの違い。特に文字列が`<br>`タグを含む場合はTag.stringはNoneになる。
2. Tag.find(string='hoge')とTag.find(text='hoge')の違い。両者は同じになる。

# 環境

```bash
$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ python --version
Python 3.7.2
$ pip show bs4
Name: bs4
Version: 0.0.1
```

# Tag.textとTag.stringの話

## ユースケース1

requestsやseleniumなどで取得したhtml文字列を解析して中のデータを取り出したい。特に次のようなhtmlの一部から、テキスト部分 `hogefuga` という文字列を取得したいとします。

```python:対象html
html = '''
<p>hogefuga</p>
'''
```

これは次のようなpythonスクリプトで取得できます。(実施前にpipでbs4をインストールしてください)

```python:sample_code1.py
from bs4 import BeautifulSoup

html = '''
<p>hogefuga</p>
'''

soup = BeautifulSoup(html, 'html.parser')
text = soup.find('p').string
print(text)
```

実際に実行してみます。

```bash:実行結果
$ python sample_code1.py
hogefuga
```

できました。
これが基本的なBeautifulSoupの使い方です。

## ユースケース2

それでは、抽出する文字列の中に`<br>`タグが入っている場合はどうでしょうか。
今回の対象htmlはこれです。

```python:対象html
html = '''
<p>hoge<br>fuga</p>
'''
```

先程と同じように書くと、失敗してしまいます。

```python:sample_code2.py
from bs4 import BeautifulSoup

html = '''
<p>hoge<br>fuga</p>
'''

soup = BeautifulSoup(html, 'html.parser')
text = soup.find('p').string
print(text)
```

```bash:実行結果
$ python sample_code2.py
None
```

### 抽出文字列に`<br>`が含まれる場合は Tag.stringではなくTag.textを利用する

[こちら](https://qiita.com/booleanoid/items/211820516eb7a2191b32)で言及されているように、 Tag.textプロパティを利用すると取得できるようです。
なにはともあれやってみます。

```python:sample_code3.py
from bs4 import BeautifulSoup

html = '''
<p>hoge<br>fuga</p>
'''

soup = BeautifulSoup(html, 'html.parser')
text = soup.find('p').text
print(text)
```

```bash:実行結果
$ python sample_code3.py
hogefuga
```

改行タグを除いたテキストが取得できました。

### Tag.stringとTag.textは何が違うのか?

[ソースコード](https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/bs4/element.py)を読んでみると、挙動の違いがわかります。

#### Tag.string

Tag.stringはこのように定義されています。

```python
    @property
    def string(self):
        """Convenience property to get the single string within this tag.

        :Return: If this tag has a single string child, return value
         is that string. If this tag has no children, or more than one
         child, return value is None. If this tag has one child tag,
         return value is the 'string' attribute of the child tag,
         recursively.
        """
        if len(self.contents) != 1:
            return None
        child = self.contents[0]
        if isinstance(child, NavigableString):
            return child
        return child.string
```

コメントにもあるように、次のルールで値を返します。

- もしタグの中身が単一の文字列(例: `<p>hogefuga</p>`)の場合、その文字列を返す
- もしタグの中身が無い(例: `<p></p>`)か、中身が複数の要素から成る(例: `<p>hoge<span>fuga</span></p>`)場合、Noneを返す
- もしタグの中身が単一のタグ(例: `<p><span>hogefuga</span></p>`)の場合、子要素のタグの文字列をそのタグの文字列として返す。`return child.string`部分によって再帰的に実行される。例えば子要素が1つのタグのみ持っている場合は孫要素の文字列が返される。

日本語よりコードの方がわかりやすいと思うので、コードで例を挙げます。

```python:sample_code4.py
from bs4 import BeautifulSoup

html = '''
<p>hogefuga</p>
<p></p>
<p>hoge<span>fuga</span></p>
<p><span>hogefuga</span></p>
<p><span><b>hogefuga</b></span></p>
'''

soup = BeautifulSoup(html, 'html.parser')
print('中身が単一文字列: ', soup.find_all('p')[0].string)
print('中身が無い: ', soup.find_all('p')[1].string)
print('中身が複数要素: ', soup.find_all('p')[2].string)
print('中身が単一タグ: ', soup.find_all('p')[3].string)
print('孫要素: ', soup.find_all('p')[4].string)
```

```bash:実行結果
$ python sample_code4.py
中身が単一文字列:  hogefuga
中身が無い:  None
中身が複数要素:  None
中身が単一タグ:  hogefuga
孫要素:  hogefuga
```

それでは、改行タグを含む文字列 `<p>hoge<br>fuga</p>` はなぜNoneが返されたのでしょうか?

Tagの子要素を表示するTag.childrenプロパティで問題のhtmlの中身を調べてみます。childrenはgeneratorを返すので、listにして表示します。

```python:sample_code5.py
from bs4 import BeautifulSoup

html = '''
<p>hoge<br>fuga</p>
'''

soup = BeautifulSoup(html, 'html.parser')
print(list(soup.find_all('p')[0].children))
```

```bash:実行結果
$ python sample_code5.py
['hoge', <br/>, 'fuga']
```

上記より、 `<p>hoge<br>fuga</p>` は3つの要素が含まれるタグなので、Tag.stringがNoneが返すという挙動をしたことがわかりました。

#### Tag.text

前節では、Tag.stringプロパティが`<br>`タグを含む文字列をうまく扱えないことを確認しました。
それに対してTag.textでは文字列中にタグがあっても文字列を抽出することができます。この挙動を調べます。

ソースコードではこう書かれています

```python
    def get_text(self, separator=u"", strip=False,
                 types=(NavigableString, CData)):
        """
        Get all child strings, concatenated using the given separator.
        """
        return separator.join([s for s in self._all_strings(
                    strip, types=types)])
    getText = get_text
    text = property(get_text)

    def _all_strings(self, strip=False, types=(NavigableString, CData)):
        """Yield all strings of certain classes, possibly stripping them.

        By default, yields only NavigableString and CData objects. So
        no comments, processing instructions, etc.
        """
        for descendant in self.descendants:
            if (
                (types is None and not isinstance(descendant, NavigableString))
                or
                (types is not None and type(descendant) not in types)):
                continue
            if strip:
                descendant = descendant.strip()
                if len(descendant) == 0:
                    continue
            yield descendant

    @property
    def descendants(self):
        if not len(self.contents):
            return
        stopNode = self._last_descendant().next_element
        current = self.contents[0]
        while current is not stopNode:
            yield current
            current = current.next_element
```

Tag.textを呼ぶとget_text関数が実行され、Tag以下の全ての要素の文字列が順番に文字列連結されているのがわかります。

例えば、`<p>hoge<br>fuga</p>`に対してTag.textを実行するとどうなるかというと、 3つの要素 `['hoge', <br>, 'fuga']`のうち`<br>`タグには文字列が無いので、`hogefuga`が返されるという挙動になります。

# 結論

Tag.stringとTag.textは全く別の処理を行っていることがわかりました。
作者はTag.textは再帰的に処理するため可能な場合はstringの方を使って欲しいようですが、文字列に他のhtmlタグが入る可能性がある場合はtextを利用する方が無難でしょう。

今回は `<br>`タグを槍玉に上げましたが、`<b></b>`タグなど他にも文字列中に入ってくるinlineなタグがあるので気をつけましょう。

# 参考

- 公式ドキュメント https://www.crummy.com/software/BeautifulSoup/bs4/doc/
- ソースコード https://code.launchpad.net/~leonardr/beautifulsoup/bs4
- https://qiita.com/booleanoid/items/211820516eb7a2191b32