萬九郎の硬い船

プログラミング学習記録など

BeautifulSoup4のfind()とfind_all()について理解を深める

BeautifulSoup4のメソッドfind()とfind_all()について、ちゃんとごまかさずに理解しておかないと後々困りそうな気がした。 せっかくなので、ドキュメントの該当部分をちょっと自力で訳してみて知識を定着させようと思う(以下訳文)。

ツリーの検索

BeautifulSoupにはパースツリーを検索するメソッドがたくさん定義されていますが、どれも大変よく似ています。最もよく使うfind()とfind_all()の2つについて、詳しく説明していきます。その他のメソッドについては、ほぼ同じ引数を取りますので、簡単な説明だけにしておきます。 再び、「3姉妹」のhtmlドキュメントを例にします。

html_doc = """
<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title"><b>The Dormouse's story</b></p>

<p class="story">Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>

<p class="story">...</p>
"""

from bs4 import BeautifulSoup
soup = BeautifulSoup(html_doc, 'html.parser')

find_all()などのフィルタに引数を渡して、htmlドキュメントの気になる部分に注目します。

フィルタの種類

find_all()メソッドなどについての細かい話をする前に、これらのメソッドに渡せるフィルタの例をご覧に入れましょう。フィルタは検索APIを通して繰り返し何度も出てきます。タグ名や属性、文字列、またその組み合わせによってフィルタをかけることができます。

文字列

最もシンプルなフィルタは文字列です。検索メソッドに文字列を渡すと、BeautifulSoupはその文字列に厳密に一致するものを返します。次のコードはドキュメント内の全てのbタグを探します。

soup.find_all('b')
# [<b>The Dormouse's story</b>]

バイト文字列を渡した場合、BeautifulSoupは文字列がUTF-8エンコードされていると仮定します。これを防ぐには、代わりにUnicode文字列を渡します。

正規表現

正規表現オブジェクトを渡した場合、BeautifulSoupはmatch()メソッドを使ってその正規表現にマッチするものを返します。次のコードは、タグ名がbから始まる全てのタグを探します。この場合はbodyタグとbタグになります。

import re
for tag in soup.find_all(re.compile("^b")):
    print(tag.name)
# body
# b

次のコードは、タグ名にtが含まれる全てのタグを探します。

for tag in soup.find_all(re.compile("t")):
    print(tag.name)
# html
# title

リスト

リストを渡した場合、BeautifulSoupはリスト内のどれか1つにでもマッチすればそれを返します。次のコードは、全てのaタグとbタグを探します。

soup.find_all(["a", "b"])
# [<b>The Dormouse's story</b>,
#  <a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

True

True値は、可能な限り全てにマッチします。次のコードは、ドキュメント内の全てのタグ(テキスト文字列を除く)を探します。

for tag in soup.find_all(True):
    print(tag.name)
# html
# head
# title
# body
# p
# b
# p
# a
# a
# a
# p

関数

上記のようなものでは不十分な場合、要素を唯一の引数に取る関数を定義してください。引数がマッチした場合にはTrueを、そうでなければFalseを返す必要があります。 次の関数は、タグがclass属性を持つ一方でid属性を持たない場合にTrueを返します。

def has_class_but_no_id(tag):
    return tag.has_attr('class') and not tag.has_attr('id')

この関数をfind_all()に渡すことで、全てのpタグをピックアップできます。

soup.find_all(has_class_but_no_id)
# [<p class="title"><b>The Dormouse's story</b></p>,
#  <p class="story">Once upon a time there were...</p>,
#  <p class="story">...</p>]

この関数はpタグのみをピックアップし、classとidの両方を持つaタグ、およびclassを持たないhtmlとtitleタグは無視します。 hrefのような特定の属性を用いて関数をフィルタに渡す場合、関数に渡される引数はタグではなく属性値となります。次の関数は、href属性が正規表現にマッチしない全てのaタグを探します。

def not_lacie(href):
    return href and not re.compile("lacie").search(href)
soup.find_all(href=not_lacie)
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

関数は必要に応じて複雑にすることができます。次の関数は、aタグが文字列に囲まれている場合にTrueを返します。

from bs4 import NavigableString
def surrounded_by_strings(tag):
    return (isinstance(tag.next_element, NavigableString)
            and isinstance(tag.previous_element, NavigableString))

for tag in soup.find_all(surrounded_by_strings):
    print tag.name
# p
# a
# a
# a
# p

さあ、これで検索メソッドを細かく見ていく準備ができました。

find_all()

用法:find_all(name, attrs, recursive, string, limit, **kwargs) find_all()メソッドは、タグの子孫要素を調べてフィルタにマッチする全ての子孫要素を返します。これまでいくつかフィルタを例に挙げましたが、もう少し加えておきます。

soup.find_all("title")
# [<title>The Dormouse's story</title>]

soup.find_all("p", "title")
# [<p class="title"><b>The Dormouse's story</b></p>]

soup.find_all("a")
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

soup.find_all(id="link2")
# [<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>]

import re
soup.find(string=re.compile("sisters"))
# u'Once upon a time there were three little sisters; and their names were\n'

知っているものもありますが、新しいものもいくつか出てきましたね。文字列やidに値を渡しているのはどういう意味でしょうか?なぜfind_all(“p”, “title”)はtitleというCSSのclassを持つpタグを返すのでしょうか?find_all()の引数をよく見てみましょう。

name引数

name引数に値を渡して、特定の名前のタグだけを対象にすることができます。名前がマッチしないタグと同じく、テキスト文字列も無視されます。 これが、最もシンプルな使い方です。

soup.find_all("title")
# [<title>The Dormouse's story</title>]

フィルタの種類で挙げたように、name引数の値には文字列、正規表現、リスト、関数、True値が使えます。

keyword引数

どんな非公認の引数も、タグの属性フィルタに変更されます。idに値を渡すと、BeautifulSoupは各タグのid属性に対してフィルタをかけます。

soup.find_all(id='link2')
# [<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>]

hrefに値を渡すと、BeautifulSoupは各タグのhref属性に対してフィルタをかけます。

soup.find_all(href=re.compile("elsie"))
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>]

文字列、正規表現、リスト、関数、True値によって属性をフィルタできます。 次のコードは、id属性が何かしらの値を持つ全てのタグを探します。

soup.find_all(id=True)
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

複数のkeyword引数を渡すことで、一度に複数の属性に対してフィルタをかけることができます。

soup.find_all(href=re.compile("elsie"), id='link1')
# [<a class="sister" href="http://example.com/elsie" id="link1">three</a>]

HTML5のdata-*属性など、keyword引数の属性名としては使えないものもあります。

data_soup = BeautifulSoup('<div data-foo="value">foo!</div>')
data_soup.find_all(data-foo="value")
# SyntaxError: keyword can't be an expression

これらの属性については、一度ディクショナリ型にしてからattrs引数として渡すことで使えるようになります。

data_soup.find_all(attrs={"data-foo": "value"})
# [<div data-foo="value">foo!</div>]

CSSのclassで検索する

特定のCSSのclassを持つタグを検索するのはとても効果的ですが、classというのはPython予約語でもあるため、classをkeyword引数にすると文法エラーになってしまいます。BeautifulSoupのバージョン4.1.2では、class_というkeyword引数を使えるようになっています。

soup.find_all("a", class_="sister")
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

他のkeyword引数と同じように、class_には文字列、正規表現、関数、True値を渡すことができます。

soup.find_all(class_=re.compile("itl"))
# [<p class="title"><b>The Dormouse's story</b></p>]

def has_six_characters(css_class):
    return css_class is not None and len(css_class) == 6

soup.find_all(class_=has_six_characters)
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

1つのタグが複数のclass属性値を持つこともある、というのを思い出しましょう。特定のCSSのclassにマッチするタグを探す場合、指定されているclassのうちのどれかにマッチさせているということになります。

css_soup = BeautifulSoup('<p class="body strikeout"></p>')
css_soup.find_all("p", class_="strikeout")
# [<p class="body strikeout"></p>]

css_soup.find_all("p", class_="body")
# [<p class="body strikeout"></p>]

また、厳密な文字列でclass属性を検索することもできます。

css_soup.find_all("p", class_="body strikeout")
# [<p class="body strikeout"></p>]

文字列を組み替えると、うまくいきません。

css_soup.find_all("p", class_="strikeout body")
# []

2つ以上のCSSのclassにマッチするタグを検索したい場合、CSSセレクタを使うのがよいでしょう。

css_soup.select("p.strikeout.body")
# [<p class="body strikeout"></p>]

旧バージョンのBeautifulSoupにはclass_のショートカットがないので、前にも触れたattrsのトリッキーな方法を使います。classキーの値を探したい文字列(や正規表現など)にしたディクショナリを作ればよいのです。

soup.find_all("a", attrs={"class": "sister"})
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>,
#  <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>]

string引数

string引数で、タグの代わりに文字列を探すこともできます。nameやkeyword引数と同じように、文字列、正規表現、リスト、関数、True値を渡すことができます。例を見てみましょう。

soup.find_all(string="Elsie")
# [u'Elsie']

soup.find_all(string=["Tillie", "Elsie", "Lacie"])
# [u'Elsie', u'Lacie', u'Tillie']

soup.find_all(string=re.compile("Dormouse"))
[u"The Dormouse's story", u"The Dormouse's story"]

def is_the_only_string_within_a_tag(s):
    """Return True if this string is the only child of its parent tag."""
    return (s == s.parent.string)

soup.find_all(string=is_the_only_string_within_a_tag)
# [u"The Dormouse's story", u"The Dormouse's story", u'Elsie', u'Lacie', u'Tillie', u'...']

string引数は文字列を探しますが、タグを探す引数と組み合わせることもできます。BeautifulSoupは、中の文字列がstring引数の値にマッチする全てのタグを探します。次のコードは、中の文字列が「Elsie」であるaタグを返します。

soup.find_all("a", string="Elsie")
# [<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>]

string引数は、BeautifulSoupのバージョン4.4.0で新しく登場したもので、旧バージョンではtext引数と呼ばれていました。

soup.find_all("a", text="Elsie")
# [<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>]

limit引数

find_all()はフィルタにマッチした全てのタグや文字列を返しますが、対象とするドキュメントが大きい場合は時間がかかってしまいます。全ての結果が必要なわけではない場合、limit引数に数値を渡して数を制限することができます。ちょうど、SQLでいうLIMITキーワードのような感じです。limit引数によって、BeautifulSoupは特定の個数に達したところで結果の収集をやめます。 「3姉妹」ドキュメントには3つのリンクがありますが、次のコードは最初の2つだけを探します。

soup.find_all("a", limit=2)
# [<a class="sister" href="http://example.com/elsie" id="link1">Elsie</a>,
#  <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>]

recursive引数

mytag.find_all()を行なうと、BeautifulSoupはmytagの全ての子孫要素(子、子の子、そのまた子…)を調べます。直接の子要素のみを調べたい場合、recursive引数にFalseを渡します。違いを見てみましょう。

soup.html.find_all("title")
# [<title>The Dormouse's story</title>]

soup.html.find_all("title", recursive=False)
# []

ドキュメントのこの部分です。

<html>
 <head>
  <title>
   The Dormouse's story
  </title>
 </head>
...

titleタグはhtmlタグより下にありますが、htmlタグの直接の子要素ではありません(headタグがそれに当たります)。htmlタグの全子孫要素を探した場合にはtitleタグが見つかりますが、recursive引数がFalseの場合にはhtmlタグの直接の子要素に制限され、何も見つかりません。 BeautifulSoupはたくさんのツリー検索メソッド(後述)を備えており、それらはほとんどがfind_all()と同じ引数(name、attrs、string、limit、keyword)を取ります。しかし、recursive引数に関してはfind_all()とfind()だけがサポートしています。recursive引数をfind_parents()のようなメソッドに渡しても、あまり使えないでしょう。

find_all()を呼び出すようにタグを呼び出す

find_all()はBeautifulSoupの検索APIの中で最もよく使うので、ショートカットがあります。BeautifulSoupオブジェクトやタグオブジェクトを関数のように扱う場合、それはfind_all()をそのオブジェクトで呼び出すのと同じことになります。次の2行のコードは同じ意味です。

soup.find_all("a")
soup("a")

次の2行も同じ意味です。

soup.title.find_all(string=True)
soup.title(string=True)

find()

用法:find(name, attrs, recursive, string, **kwargs) find_all()メソッドはドキュメントを上から下まで検索しますが、結果が1つだけ欲しいこともあるでしょう。ドキュメントにあるbodyタグが1つだけだと知っているなら、さらなるbodyを探すのは時間の無駄です。find_all()を呼び出すときに毎回limit=1を引数に渡すよりも、find()メソッドを使いましょう。次の2行のコードはほぼ同じ意味になります。

soup.find_all('title', limit=1)
# [<title>The Dormouse's story</title>]

soup.find('title')
# <title>The Dormouse's story</title>

唯一の違いは、find_all()が結果を1つだけ含むリストを返すのに対して、find()はただ単に結果を返すという点です。 なにも見つからなかった場合、find_all()は空のリストを返し、find()はNoneを返します。

print(soup.find("nosuchtag"))
# None

タグ名でナビゲートする」で紹介した、soup.head.titleのトリッキーな方法を憶えていますか?find()を繰り返し呼び出せばうまくいきます。

soup.head.title
# <title>The Dormouse's story</title>

soup.find("head").find("title")
# <title>The Dormouse's story</title>