BeautifulSoup で HTML 文書からタグを取り除く(Python)

はじめに

HTML の解析に便利な BeautifulSoup(Python ライブラリ)を使って HTML 文書のテキスト部分の切り出しを試みましたというお話です.「間違えているところがある」とか「もっと良い方法がある」という場合,コメントをいただけると幸いです.

※ HTML 文書の焦点抽出(ニュースやブログからの記事抽出など)については考慮していません.

追記(2010-06-21):このお話には続き(続・BeautifulSoup で HTML 文書からタグを取り除く(Python) - やた@はてな日記)があり,追加の問題とその対処について説明しています.

BeautifulSoup とは

BeautifulSoup は,以下のサイトでダウンロードできる Python 用のライブラリです.インストールをしなくても,アーカイブの中にある BeautifulSoup.py をコピーするだけで簡単に使えます.

Ubuntu 10.4 には Python 2.6 がインストールされているので,BeautifulSoup 3.0.8.1 を選択しました.

Beautiful Soup: We called him Tortoise because he taught us.

例えば,BeautifulSoup の基本機能に含まれる「HTML 文書の整形(prettify)」であれば,コンストラクタに HTML 文書を渡して,それから prettify() を呼び出すという 2 ステップで終了します.

import BeautifulSoup
html = '<html><head><title>Title</title></head><body><p>Paragraph</p></body></html>'
soup = BeautifulSoup.BeautifulSoup(html)
print soup.prettify()
# <html>
#  <head>
#   <title>
#    Title
#   </title>
#  </head>
#  <body>
#   <p>
#    Paragraph
#   </p>
#  </body>
# </html>

日本語の文字コードについては,自動的に判別してくれるようになっています.しかし,nkf のようなツールが失敗するのと同様に,BeautifulSoup も文字コードの判別に失敗することがあります.そのような場合,コンストラクタに fromEncoding を指定してやる必要があります.

※ HTML 文書に不正な文字が含まれている場合,どうやってもエラーが発生します.さっさと諦めてしまいましょう.

BeautifulSoup とタグ除去

ウェブ検索ですぐに先達(BeautifulSoupでタグを除去してテキストだけ抜き出す - yanbe.py - pythonグループ)が見つかりました.ここは先人に感謝し,とりあえず真似させていただくことにしましょう.

def getNavigableStrings(soup):
  if isinstance(soup, BeautifulSoup.NavigableString):
    if type(soup) not in (BeautifulSoup.Comment,
      BeautifulSoup.Declaration) and soup.strip():
      yield soup
  elif soup.name not in ('script', 'style'):
    for c in soup.contents:
      for g in getNavigableStrings(c):
        yield g

soup = BeautifulSoup.BeautifulSoup(html)
text = '\n'.join(getNavigableStrings(soup))

試してみると,大体うまくいきます.でも,いくつか気になることがあり,以下のように対処しました.

  1. 文字参照を解決したい.
    • BeautifulSoup のコンストラクタで convertEntities を指定する.
  2. 正規化おこないつつ,不要な空白・改行を取り除きたい.
    • unicodedata.normalize() で正規化する.
    • splitlines(), split(), join() で不要な空白・改行を取り除く.
  3. インライン要素の前後に改行を入れたくない.
    • 改行で連結するのをやめて,ブロック要素の前後のみに改行を入れる.
## 1. 文字参照について

soup = BeautifulSoup.BeautifulSoup(html,
  convertEntities = BeautifulSoup.BeautifulStoneSoup.HTML_ENTITIES)
## 2. 正規化と空白について

import unicodedata

# 不要な空白を取り除き,空行以外を返す.
def nonEmptyLines(text):
  for line in text.splitlines():
    line = u' '.join(line.split())
    if line:
      yield line

# 正規化の後で不要な空白・改行を取り除く.
def normalizeText(text):
  text = unicodedata.normalize('NFKC', text)
  return u'\n'.join(nonEmptyLines(text))

text = u'\n'.join(getNavigableStrings(soup))
text = normalizeText(text)
## 3. インライン要素前後の改行について

# ブロック要素の集合です.
blockTags = frozenset(['p', 'div', 'table', 'dl', 'ul',
  'ol', 'form', 'address', 'blockquote', 'h1', 'h2', 'h3',
  'h4', 'h5', 'h6', 'fieldset', 'hr', 'pre'
  'article', 'aside', 'dialog', 'figure', 'footer',
  'header', 'legend', 'nav', 'section'])

def getNavigableStrings(soup):
  if isinstance(soup, BeautifulSoup.NavigableString):
    # 空白だけでも返すように,``and soup.strip()'' を消しました.
    # 英数字前後の空白を誤って取り除くことを防ぎます.
    if type(soup) not in (BeautifulSoup.Comment,
      BeautifulSoup.Declaration):
      yield soup
  elif soup.name not in ('script', 'style'):
    # ブロック要素の前後に改行を入れるようにしました.
    isBlock = soup.name in blockTags
    if isBlock:
      yield u'\n'
    for c in soup.contents:
      for g in getNavigableStrings(c):
        yield g
    if isBlock:
      yield u'\n'

# 連結時に改行を挟むのをやめました.
text = u''.join(getNavigableStrings(soup))

このくらいで,ひとまず満足しました.でも,テーブルのセル周りはどうするのか(改行で区切るか?),取り消しされた部分(<s> と </s> で挟まれている部分)はどうするのか('script' や 'style' と同じように扱うか?),などの問題についても考慮する必要があるかもしれません.

BeautifulSoup と日本語文字コード

BeautifulSoup に日本語の HTML 文書を渡すと,10 〜 20% くらいの割合で TypeError や UnicodeError が送出されます.そして,どうやらエラーの原因は文字コードにあるようです.

さすがに 10% を超えてエラーになるのは困るので,上手くいくことを願いつつ,適当な文字コードを指定してみることにしました.

# お試し文字コードのリストです.
knownEncodings = ['CP932', 'SHIFT_JISX0213', 'EUC-JP-MS',
  'EUC-JISX0213', 'ISO-2022-JP-2', 'ISO-2022-JP-3', 'UTF-8']

# 有名どころの文字コードで BeautifulSoup を試します.
def testKnownEncodings(html):
  for e in knownEncodings:
    try:
      # 長くなるので convertEntities は省略しています.
      soup = BeautifulSoup.BeautifulSoup(html, fromEncoding = e)
      return soup
    except (TypeError, UnicodeError):
      pass

try:
  # 長くなるので convertEntities は省略しています.
  soup = BeautifulSoup.BeautifulSoup(html)
except (TypeError, UnicodeError):
  soup = testKnownEncodings(html)

以上の強引(手抜き)な方法により,25% くらい長く時間はかかるものの,エラーの割合を 1 〜 2% くらいに抑えることができました.めでたしめでたし….

追記(2010-06-21):大量の HTML 文書を処理してみたところ,エラーの割合は 4% 前後に落ち着きました.

追記(2010-06-27):testKnownEncodings() について,いくつか修正すべき箇所があります.

Python は "euc-jp-ms" をサポートしていないので,fromEncoding に "euc-jp-ms" を指定すると,必ず失敗します.iconv などを利用して変換する必要があります.ついでに,標準エンコーディングを見た感じ,文字コードのリストは以下のように修正した方がよさそうです.

※ cp932 と shift_jis_2004 や euc-jp-ms と euc_jis_2004 では,同じ符号に異なる文字が割り当てられています.そのため,文字化けの割合が少なくなるように,使われることの多い cp932 と euc-jp-ms を前に配置しています.また,絵文字

KNOWN_ENCODINGS = ["cp932", "shift_jis_2004", "euc-jp-ms",
  "euc_jis_2004", "iso2022-jp-2", "iso2022-jp-3", "iso2022-jp-2004"]

また,BeautifulSoup のコンストラクタが成功しても,文字化けしている可能性があります.これまでに確認した誤りとしては,euc-jp-ms らしき文書が windows-1252 扱いされて,記号だらけになるという事例がありました.原因は,meta タグで指定されている文字コードでの処理に失敗したとき,自動的に utf-8windows-1252 での処理を試みるためです.

windows-1252 ではないと断定できるのであれば,originalEncoding が windows-1252 になっている場合に失敗と判断すればよいでしょう.

soup = BeautifulSoup.BeautifulSoup(html)
if soup.originalEncoding == "windows-1252":
  # エラー処理