少ない学びをせめて記録する

技術記録、競プロメモ、その他調べたことを書く @京都, twitter : @nehan_der_thal

python, Beautifulsoupでサイトの構造変化に強いscraping

よくWEBから情報抽出するのですがHTMLをパースする時にはpython のBeautifulsoupライブラリ(BS4)を利用しています。 BS4は公式の解説がわかりやすいですし、有名ライブラリなので日本語記事もたくさん見つかります。

www.crummy.com qiita.com

BS4ではfind('div', id="main")などとして簡単にhtml上の必要なDOM情報にアクセスでき、簡単な処理ならこれで十分ですがもう少し複雑にscraipingしたいことがあります。 例えばHタグの後ろにあるテキスト情報のみを取りたい。特定のdiv要素の子情報を取りたい。その際DOMtreeの木構造は保持したい。などです。 そのような時にはBS4のnext_element(html上でタグ開始位置を基準に現在見ているタグの次のタグを見つける)parent, children, descendants (現在見ているタグの親・子・子孫要素を返す。) などが便利です。全て豊富な例と一緒に公式ドキュメントに説明があります。

便利と感じている書き方

私の場合、next_elementなどを使ってHTML内をさまようと読みにくいコードになる傾向にあったのですが、最近以下のような書き方をすることで改善されたような気がしています。

html_doc = """
<html><head><title>The Dormouse's story</title></head>
<body>
<h1> Title</h1>
<div id="main">
<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
<h3>names</h3>
<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>

<h3>other names</h3>
<a href="http://example.com/others" id="link4">AAA</a>;
<a href="http://example.com/others2" id="link5">BBB</a>;
<p class="story">...</p>
</div>
<div id="footer">
<p>aaa</p>
</div>
</body>
"""
from bs4 import BeautifulSoup
from bs4.element import NavigableString, Tag

def gen_next_element(process):
    def _next_element(current_element, status):
        next_element = current_element.next_element
        if isinstance(current_element, NavigableString):
            return next_element, status
        return process(current_element, status)
    return _next_element

def read_page(text, status, function):
    soup = BeautifulSoup(text, 'html.parser')
    current_dom = soup.find("div", id="main")
    if not current_dom:
        return status
    current_dom = current_dom.extract()
    next_element = gen_next_element(function)
    while True:
        if not current_dom:
            break
        current_dom, status = next_element(current_dom, status)
    return status

def extract_links(current_element, status):
    next_element = current_element.next_element
    if current_element.name == "a":
        href = current_element.get("href")
        status["links"].append((current_element.text, href, status["previous_header"]))
    elif current_element.name == "h3":
        status["previous_header"] = current_element.text
    return next_element, status


if __name__ == '__main__':
    status = read_page(html_doc, {'links' : [], "previous_header":""}, extract_links)
    for url, text, title in status["links"]:
        print("url : {}, text : {}, title: {}".format(url, text, title))

この例ではHTML内のリンク情報を見出しとともに列挙しています。このコードにはgen_next_element, read_page, extract_linksと3つの関数がありますが、前半の2つは前から順にタグを見ていくという処理を表しています。extract_linksは具体的な処理を表す関数です。この関数は現在のタグをみて何か処理(今回はリンク収集)を行い情報を更新し、次のタグと更新された情報を返します。 このような構成にしてから巡回のことは意識せずに1つのタグについての取り扱いだけを考えるのが容易になった気がしています。 それでもやはり解りにくいコードになりやすいという感覚があるため、ScrapingのGood Practice があれば知りたいところです。