【備忘録】Beautiful Soup 4の.stringの挙動を正しく理解していなかった話

# 要約

`.string`は挙動をしっかり把握して使わなくてはいけない。

子タグを残して文字列を取得したいときはBeautiful Soup 4を利用せずに`re.sub()`などを用いることを検討するべきかもしれない。

## 2022年2月27日追記

コメントで情報をいただきました`.contents`を利用すれば子タグを残して文字列が取得できるそうです。

公式ドキュメント（日本語訳版）へのリンク⇒http://kondou.com/BS4/#contents-children



# 環境

* python : 3.10.1
* BeautifulSoup4 : 4.10.0
* Windows11

# 経緯

https://github.com/msattova/trpgLOG2md

先日、[こちらの記事](https://qiita.com/msattova/items/8120b23f2470667af359)で宣伝したこのプログラムに「`<br/>`を含む`<span>`タグの内側の文字列がなぜかちゃんと出力されない」問題が見つかりました。

問題のコードがこちら（2022年2月27日追記：コメントを受けてコードを整形しました）：

``` python:app.py
    def __search_and_get(self):
        messages = self.soup.find_all('p')
        get_inner = slice(1, -1)
        for msg in messages:
            inlist = []
            for span in msg.find_all('span'):
                st = span.string
                if str is not None:
                    str = str.strip()
                else:
                    str = ''
                if '#' in str:
                    str = str.replace('#', '\\#')
                inlist.append(str)
            inlist[0] = inlist[0][get_inner]  # タブ名の[]の内側の文字列を取得
            self.meslist.append(inlist)
```

`（タグ名）.string`で取得できる値は`str`型だと思い込んでいた（もしくは`str`型ではないにせよ`str`型と同じように扱って良いものと考えていた）ころに書いたものです。

たとえば、次のようなinput.htmlがあったとします

```html
<!DOCTYPE html>
    <head>
        <!--関係ないので省略-->
    </head>
    <body>
        <p>
            <span>[hoge]</span>
            <span>fuga</span>:
            <span>改行がない文章です</span>
        </p>
        <p>
            <span>[hoge]</span>
            <span>fuga</span>:
            <span>改行がある<br/>文章です</span>
        </p>
    </body>
</html>
```

問題のコードを書いたとき、私は次のようなリストが生成されることを期待していました。

```:想定
[['hoge', 'fuga', '改行がない文章です'], ['hoge', 'fuga', '改行が<br/>ある文章です']]
```

しかし実際は、こうなります。

```:現実
[['hoge', 'fuga', '改行がない文章です'], ['hoge', 'fuga', '']]
```

`str`が`None`だったら`str`に`''`を代入する処理を入れていたので、実際に`span.string`から返ってきた値は`None`ということになりますね。


## なぜ`None`が返されたのか？

[BeautifulSoupでstringとtextの挙動の明確な違い – Python](https://lets-hack.tech/programming/languages/python/bs4-text-or-string/)によると、

> ".string"は指定した要素の、子孫要素に渡って"NavigableString"クラスが一つしか存在しない時にstringとして返却されます。

とのことです。

リンク先に詳しい解説が掲載されていますので、ちゃんとした説明はそちらに任せます。

ざっくりと、私の理解の範囲で言えば、指定要素内の文字列は**タグの存在した位置で分割された状態で保持されており、`.string`は文字列が複数に分割されている場合は`None`を返すようになっている**……ということのようです。

なので、文字列がタグで分断さえされていなければ——たとえば、`<span><pre>別のタグで囲まれた文章です</pre></span>`などは——`None`は返ってきません。

## `<span>改行がある文章です<br/></span>`ならNoneは返ってこない？

文字列の途中にタグが入っていなければいいのかと言うと、どうやらそういうわけでもないようです。

`<span>改行がある文章です<br/></span>`の場合は、`None`が返ってきました。

これは`['改行がある文章です', '']`として処理されているということなのかと思いましたが、

```python
print(list(span.strings))
```

すると、

```
['改行がある文章です']
```

と表示されてしまいました。

ほかにもいくつか実験してみたのでその結果を今までのものと一緒に、以下の表にまとめました。

| 番号 | 対象文字列 | 結果 |
|---|---|---|
|1.| `<span>文章です<br/></span>`  |  None |
|2.| `<span>文章です<hr></span>`  | None  |
|3.| `<span>文章です<pre></pre></span>` | None |
|4.| `<span><pre>文章です</pre></span>` | `'文章です'` |


Beautiful Soup 4のソースコードをちゃんと読んでいないのであくまで仮説ですが、テキストの後ろに開始タグが存在したため、**長さ4の文字列と長さ0の文字列**に分割されてしまったのでしょう。

# 結局どうしたのか

話を私が作っているプログラムの方に戻します。

結局、`<br/>`で示された改行の情報は残しておきたかったので`get_text()`や`.text`は使わず、`re.sub()`を利用することにしました。

（2022年2月27日追記：コメントを受けてコードを整形及び一部変更しました）

```python
    def _search_and_get(self):
        messages = self.soup.find_all('p')
        get_inner_slice = slice(1, -1)
        for msg in messages:
            tmplist = list()
            for span in msg.find_all('span'):
                if span is None:
                    s = ''
                else:
                    s = str(span)
                s = re.sub(r'</?span>', '',
                           s.replace('<br/>', '\n')).strip()
                if '#' in s:
                    s = s.replace('#', '\\#')
                tmplist.append(s)
            tmplist[0] = tmplist[0][get_inner_slice]  # タブ名の[]の内側の文字列を取得
            self.meslist.append(tmplist)
```

以上です。

# 参考

https://lets-hack.tech/programming/languages/python/bs4-text-or-string/

https://yu-nix.com/blog/2020/11/24/bs4-text/#text%E3%81%A8string%E3%81%AE%E9%81%95%E3%81%84

2022年2月27日追記：

http://kondou.com/BS4/#contents-children