【Python】HTMLからリンク一覧を取り出す:HTMLParserの小さな使い方

PythonのTopに戻る

属性の順番に依存せずリンクを読む

保存済みHTMLからリンク先とリンク文字列を一覧にしたいとき、aタグを単純な正規表現で探すと、属性の順番や引用符、内側のタグの違いに影響されやすい。標準ライブラリのHTMLParserなら、開始タグ・終了タグ・文字データを別々のイベントとして受け取れる。ここでは短いHTML文字列からa要素のhrefと表示用の文字列を集める。通信やブラウザー操作は行わない。

対象は入れ子のaタグがなく、開始・終了タグが対応するHTMLの断片である。DOMを丸ごと作る道具ではなく、イベントを受けながら必要な情報だけを保持する。巨大なHTMLを渡す場合にはfeed()を複数回に分けられるが、この例は仕組みを追いやすい小さな入力で確認する。

aタグの内側だけ文字列を蓄える

example.py

from html.parser import HTMLParser


class LinkCollector(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.links = []
        self.active = False
        self.href = None
        self.parts = []

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            if self.active:
                raise ValueError("nested anchor is not supported")
            self.active = True
            self.href = dict(attrs).get("href")
            self.parts = []

    def handle_data(self, data):
        if self.active:
            self.parts.append(data)

    def handle_endtag(self, tag):
        if tag == "a" and self.active:
            if self.href is not None:
                text = " ".join("".join(self.parts).split())
                self.links.append((self.href, text))
            self.active = False
            self.href = None
            self.parts = []


source = '''<p>
<a class="button" href="/data?a=1&amp;b=2">Data <strong>A</strong></a>
<a href='report.html' title='report'>A &amp; B</a>
<a id="section">Section</a>
</p>'''
parser = LinkCollector()
parser.feed(source)
parser.close()
assert not parser.active
assert parser.links == [("/data?a=1&b=2", "Data A"), ("report.html", "A & B")]
for href, text in parser.links:
    print(href, "|", text)

実行結果

/data?a=1&b=2 | Data A
report.html | A & B

状態を持って、内側のタグをまたぐ

handle_starttag()へ渡されるattrsは属性名と値の組のリストである。辞書へ変換しhrefを取得するので、classやtitleが先に書かれていても影響しない。aタグを見つけるとactiveをTrueにし、その後のhandle_data()で文字を集める。strongタグが挟まってもactiveは変わらないため、DataとAを同じリンク文字列として回収できる。

hrefがないaタグはページ内の位置を示す目的などで現れる。例ではリンク先一覧に不要なので除外し、空文字のhrefとは区別している。空文字のhrefを除外するか、相対URLを絶対URLへ直すかは次の処理の方針になる。HTML中にbase要素がある場合など、相対URLの基準を勝手に決めないことが大切である。

convert_charrefs=Trueにより、&amp;は&として受け取れる。表示用の文字列は、連結後にsplit()とjoin()で連続する空白を一つへまとめた。これはHTMLの見た目を完全再現する操作ではなく、この一覧で読みやすくするための規則である。pre要素など空白に意味がある部分を含む場合には、こうした正規化を別途検討する。

ブラウザーの見え方と同じではない

CSSで非表示になった文字、JavaScriptが後から追加するリンク、画像のalt属性などは、この小さな実装では見た目どおりに扱わない。brタグがあっても自動で改行は追加されない。scriptやstyleの内容も文字データとして届きうるので、一般のHTMLから表示テキストを再現したい場合は除外処理や専用の解析方法が必要になる。

HTMLParserは壊れたタグ構造をブラウザーと同じように補修するDOMエンジンではない。この例では入れ子のaタグを見つけたらValueErrorにし、最後にactiveが残っていないことを確認している。ただし、この二つの検査だけでHTML全体の妥当性が保証されるわけではない。終了タグの省略などを多く含む実データでは、対応範囲を決めてから処理を広げる。

同じhrefが複数回出た場合はすべて残す。ページの上部と下部でリンク文字列が違うこともあるため、抽出と重複除去を一緒にしない方が情報を保ちやすい。重複を消す場合も、hrefだけで判定するのか、文字列との組で判定するのかを決める。

取得したhrefをそのまま信頼しない

hrefにはhttps以外の形式や、ページ内参照、相対パスも入る。抽出しただけではアクセスしてよいURLかは判断できない。一覧から自動取得へ進める場合は、許可するschemeやホストを検証する。HTMLへ一覧を再出力するときも、抽出した文字列をそのままタグへ連結せず、適切にエスケープして出力する。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:検索語に記号があっても壊れない:re.escapeで複数語を検索する / 引用符の中の空白を残して分割する:shlexでトークン化

PythonのTopに戻る