1. amuyikam

    Posted

    amuyikam
Changes in title
+BeautifulSoupで<br>タグを含む文字列の扱い(後半)
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,201 @@
+# BeautifulSoup
+
+BeautifulSoupはHTMLやXMLといった構造体からデータを取り出すためのライブラリです。
+Webサイトから情報をスクレイピングしてくるのによく使われます。
+https://www.crummy.com/software/BeautifulSoup/bs4/doc/
+
+私はできる限りスクレイピングは使いたくないし使うべきではないと考えているのですが、残念ながらやらねばならないときもあるものです。
+また、スクレイピングは節度を持って、ルールを守ってやりましょう。
+
+# この記事の内容
+
+以下の2つの話題のうち、後者について挙動を確認します。
+前者は[こちら](https://qiita.com/amuyikam/items/c9d703b7aee807a16aae)。
+
+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.find関数
+
+前半でhtmlの一部から文字列を抽出する次のようなスクリプトを書きました。
+この中で利用しているTag.find関数について調べます。
+
+```python:sample_code1.py
+from bs4 import BeautifulSoup
+
+html = '''
+<p>hogefuga</p>
+'''
+
+soup = BeautifulSoup(html, 'html.parser')
+text = soup.find('p').string
+print(text)
+```
+
+## ケース
+
+次のhtmlの一部から文字列が`hoge<br>fuga`の行を抽出したい。
+必ず2番目にあるとは限らない。
+
+```html
+<a>hoge</a>
+<a>hoge<br>fuga</a>
+<a>fuga</a>
+```
+
+### うまくいかない方法
+
+次のようにしてもうまくいきません。
+
+```python:うまくいかない
+from bs4 import BeautifulSoup
+import re
+
+html = '''
+<a>hoge</a>
+<a>hoge<br>fuga</a>
+<a>fuga</a>
+'''
+
+soup = BeautifulSoup(html, 'html.parser')
+print(soup.find('a', string='hoge<br>fuga')) # None
+print(soup.find('a', string='hogefuga')) # None
+print(soup.find('a', string=re.compile('hoge.*fuga'))) # None
+```
+
+[BeautifulSoupで`<br>`タグを含む文字列の扱い(前半)](https://qiita.com/amuyikam/items/c9d703b7aee807a16aae)に書いたように、BeautifulSoupを使う上でstringとtextは区別されます。そこでstringではなくtextを使うと良いのかと思いましたが、これもうまくいきません。
+
+```python:全然ダメ
+from bs4 import BeautifulSoup
+import re
+
+html = '''
+<a>hoge</a>
+<a>hoge<br>fuga</a>
+<a>fuga</a>
+'''
+
+soup = BeautifulSoup(html, 'html.parser')
+print(soup.find('a', text='hoge<br>fuga')) # None
+print(soup.find('a', text='hogefuga')) # None
+print(soup.find('a', text=re.compile('hoge.*fuga'))) # None
+```
+
+## Tag.findのstringキーワード引数とtextキーワード引数
+
+Tag.find関数のドキュメントは[こちら](https://www.crummy.com/software/BeautifulSoup/bs4/doc/#find)
+これを読んでもわからないので、例によってソースを読みます。
+
+まず、text引数とstring引数は、Tag._find_all関数の中で次のようにまとめられるためどちらを指定しても同じです。
+
+```python
+ if text is None and 'string' in kwargs:
+ text = kwargs['string']
+ del kwargs['string']
+```
+
+もともとtextだったのですが、仕様が.stringと.textの関係と混ざって紛らわしいということでfind関数の仕様としてはstring引数になりました。
+後方互換のためtext引数も利用できるようになっていますが、Tag.find関数やTag.find_all関数に関しては同じですので素直にstringを使いましょう。
+という話が[このissue](https://bugs.launchpad.net/beautifulsoup/+bug/1366856)にかかれています。
+
+## Tag.find関数のstring引数の挙動
+
+また、Tag.find関数やTag.find_all関数はTagに含まれる要素に対してnameやstringなどの条件を与えてfilter条件に合うかどうかを確認しています。
+先程の例でうまく行かなかった原因はSoupStrainer.search_tag関数の次の3行が肝になります。
+
+```python
+ if found and self.text and not self._matches(found.string, self.text):
+ found = None
+ return found
+```
+
+foundにはfilter条件に当てはまるTag(例: `<a>hoge<br>fuga</a>`)
+self.textにはfilter条件(例: string='hoge'のhoge, ここのselfはSoupStrainer型)
+が代入されています。
+また最後にreturnする値がTag.findやTag.find_allから返される値になります。
+
+このとき、 `self._matches(found.string, self.text)` でfoundの文字列とfilter条件を比較しています。
+[こちら](https://qiita.com/amuyikam/items/c9d703b7aee807a16aae)で確認した通り、Tag.textはTagの中の文字列を再帰ですべて抽出しようとしますが、Tag.stringは中身の要素が2つ以上の場合にNoneを返します。
+その結果、 Noneに対してfilter条件を適用することになってしまい、結果的にNoneがreturnされてしまいます。
+
+上記挙動はどうも直感的ではなく、`self._matches(found.text, self.text)`にした方が良いのではないかと[issueが上がっています](https://bugs.launchpad.net/beautifulsoup/+bug/1366856)が、作者はいくつかの理由から変更する気はないようです。
+
+## うまくいく方法
+
+[issue](https://bugs.launchpad.net/beautifulsoup/+bug/1366856)の作者のコメントにもあるように、Tag.find関数のname引数やstring引数には独自関数を渡してfilter関数とすることができます。
+ドキュメントだと[ここ](https://www.crummy.com/software/BeautifulSoup/bs4/doc/#a-function)
+
+次のようにTag型の引数を1つとるfilter関数 `filter_a_hogefuga`を作ってやるとうまく抽出できます。
+重要なのは、tag.stringではなくtag.textを利用しているところです。
+
+```python:関数を使ってうまくやる方法
+from bs4 import BeautifulSoup
+
+def filter_a_hogefuga(tag):
+ if tag.name == 'a' and tag.text == 'hogefuga':
+ return True
+ return False
+
+html = '''
+<a>hoge</a>
+<a>hoge<br>fuga</a>
+<a>fuga</a>
+'''
+
+soup = BeautifulSoup(html, 'html.parser')
+print(soup.find(filter_a_hogefuga))
+```
+
+```bash:実行結果
+$ python sample.py
+<a>hoge<br/>fuga</a>
+```
+
+lambdaでやるとよりスマートかもしれません。
+
+
+```python:関数を使ってうまくいく方法
+from bs4 import BeautifulSoup
+
+html = '''
+<a>hoge</a>
+<a>hoge<br>fuga</a>
+<a>fuga</a>
+'''
+
+soup = BeautifulSoup(html, 'html.parser')
+print(soup.find(lambda x: 'hogefuga' in x.text and x.name == 'a'))
+```
+
+```bash:実行結果
+$ python sample.py
+<a>hoge<br/>fuga</a>
+```
+
+よさそうです。
+
+# 結論
+
+BeautifulSoupで、Tag.textとTag.stringの挙動は異なるが、Tag.find関数やTag.find_all関数のtext引数とstring引数は同じ挙動になる。
+また、filterに関数を利用することで`<br>`を含む文字列に対しても柔軟に目的のTagを抽出することができる。
+
+
+# 参考
+
+https://www.crummy.com/software/BeautifulSoup/bs4/doc/
+https://bugs.launchpad.net/beautifulsoup/+bug/1518409
+https://bugs.launchpad.net/beautifulsoup/+bug/1366856