BeautifulSoup
BeautifulSoupはHTMLやXMLといった構造体からデータを取り出すためのライブラリです。
Webサイトから情報をスクレイピングしてくるのによく使われます。
https://www.crummy.com/software/BeautifulSoup/bs4/doc/
私はできる限りスクレイピングは使いたくないし使うべきではないと考えているのですが、残念ながらやらねばならないときもあるものです。
また、スクレイピングは節度を持って、ルールを守ってやりましょう。
この記事の内容
以下の2つの話題のうち、後者について挙動を確認します。
前者はこちら。
- Tag.textとTag.stringの違い。特に文字列が
<br>
タグを含む場合はTag.stringはNoneになる。 - Tag.find(string='hoge')とTag.find(text='hoge')の違い。両者は同じになる。
環境
$ 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関数について調べます。
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番目にあるとは限らない。
<a>hoge</a>
<a>hoge<br>fuga</a>
<a>fuga</a>
うまくいかない方法
次のようにしてもうまくいきません。
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>
タグを含む文字列の扱い(前半)に書いたように、BeautifulSoupを使う上でstringとtextは区別されます。そこでstringではなくtextを使うと良いのかと思いましたが、これもうまくいきません。
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関数のドキュメントはこちら
これを読んでもわからないので、例によってソースを読みます。
まず、text引数とstring引数は、Tag._find_all関数の中で次のようにまとめられるためどちらを指定しても同じです。
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にかかれています。
Tag.find関数のstring引数の挙動
また、Tag.find関数やTag.find_all関数はTagに含まれる要素に対してnameやstringなどの条件を与えてfilter条件に合うかどうかを確認しています。
先程の例でうまく行かなかった原因はSoupStrainer.search_tag関数の次の3行が肝になります。
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条件を比較しています。
こちらで確認した通り、Tag.textはTagの中の文字列を再帰ですべて抽出しようとしますが、Tag.stringは中身の要素が2つ以上の場合にNoneを返します。
その結果、 Noneに対してfilter条件を適用することになってしまい、結果的にNoneがreturnされてしまいます。
上記挙動はどうも直感的ではなく、self._matches(found.text, self.text)
にした方が良いのではないかとissueが上がっていますが、作者はいくつかの理由から変更する気はないようです。
うまくいく方法
issueの作者のコメントにもあるように、Tag.find関数のname引数やstring引数には独自関数を渡してfilter関数とすることができます。
ドキュメントだとここ
次のようにTag型の引数を1つとるfilter関数 filter_a_hogefuga
を作ってやるとうまく抽出できます。
重要なのは、tag.stringではなくtag.textを利用しているところです。
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))
$ python sample.py
<a>hoge<br/>fuga</a>
lambdaでやるとよりスマートかもしれません。
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'))
$ 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