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

前回(BeautifulSoup で HTML 文書からタグを取り除く(Python) - やた@はてな日記)の内容でも,ある程度は問題なく処理できていました.しかし,大量の HTML 文書を渡してみると,新たに 2 つの問題が見つかりました.それぞれの内容と今回の対処は以下のとおりです.

  1. 深すぎる再帰呼び出しによる RuntimeError 例外
    • <p> を改行(<br>)の代わりに使っている HTML 文書や,バグ入りの自動生成プログラムにより作成された HTML 文書などが原因だろうと思います(未確認).
  2. 不正な数値文字参照による ValueError と OverflowError
    • ValueError が送出される例:&#100000000;
    • OverflowError が送出される例:&#10000000000000000;
      • BeautifulSoup に少しだけ手を加えることで対処しました.

また,PRE 要素の外部にある改行は空白に置き換えるようにしました.ただし,PRE 要素の入れ子には対処していません.

※ 私が普段使っているブラウザ(Firefox)では PRE 要素の入れ子が有効でした.

以上の修正を加えた結果,以下のようなコードになりました.元のコードと比べると,エレガントさに欠けます….

def getNavigableStrings(soup):
  # PRE 要素の階層を記録します.
  preCount = 0

  # ループ 1 回で,葉ノードまでの探索をおこない,次の探索開始位置を求めます.
  while soup:
    # 葉ノードに到達するか,テキストノードに到達するまで探索します.
    while not isinstance(soup, BeautifulSoup.NavigableString):
      if soup.name in ("script", "style", "s"):
        break

      # 実際のところ,BeautifulSoup は PRE 要素の入れ子を許さないため,
      # True/False のフラグでも構わないかもしれません.
      if soup.name == "pre":
        preCount = preCount + 1
      if soup.name in _blockTags:
        yield u"\n"
      if soup.contents:
        soup = soup.contents[0]
      else:
        break

    if isinstance(soup, BeautifulSoup.NavigableString):
      if type(soup) not in (BeautifulSoup.Comment,
        BeautifulSoup.Declaration):
        # PRE 要素の外側では改行を空白に置き換えます.
        if preCount:
          yield soup
        else:
          yield u" ".join(soup.splitlines())
      if soup.nextSibling:
        soup = soup.nextSibling
        continue
      soup = soup.parent

    # 兄弟ノードを探しつつ,ルート方向へと移動します.
    while soup:
      if soup.name == "pre":
        preCount = preCount - 1
      if soup.name in _blockTags:
        yield u"\n"
      if soup.nextSibling:
        soup = soup.nextSibling
        break
      soup = soup.parent

## BeautifulSoup.BeautifulStoneSoup の修正
  def handle_charref(self, ref):
    "Handle character references as data."
    if self.convertEntities:
      # 修正内容は try, except の追加です.
      # 例外が送出された場合,10 進数の数値文字参照に戻します.
      try:
        data = unichr(int(ref))
      except (ValueError, OverflowError):
        data = '&#%s;' % ref
    else:
      data = '&#%s;' % ref
    self.handle_data(data)

大量の HTML 文書からテキストを切り出してみて,「致命的な問題はなさそうな気がする」状態になりました.気のせいかもしれませんが….

現状で,Core2 Duo U9600 (1.6GHz) での処理速度は,8 文書/秒くらいです.2 並列にしても 16 文書/秒くらいですから,大量の HTML 文書からテキストを切り出すという用途では,かなりのリソースが必要になります.

追記(2010-06-27):BeautifulSoup の修正について,Tag._convertEntities() 内部の unichr() で例外(ValueError)が投げられる可能性を見逃していました.こちらも try, except を加えておいた方がよさそうです.

## 以下の部分を try, except で囲むと大丈夫なはず….
# Handle numeric entities
if len(x) > 1 and x[1] == 'x':
  return unichr(int(x[2:], 16))
else:
  return unichr(int(x[1:]))