BeautifulSoupでのつまりどころをちょっとだけまとめてみた

BeautifulSoupでスクレイピングしていると、よくわからないパースエラーでコケる事があるかと思います。


今日も"junk characters in start tag"みたいなエラーに悩まされました。

Google先生に尋ねてみるも、同じような事象でなやんでる外人さんたちはみつけられても、「これだ」という解決方法はみつからず。

仕方ないので、自力でちょっと調べてみた成果を以下に記載してみる。同じようなことで悩んでる人が、もっと良い解決方法を見つけるためのたたき台にでもなれば本望である。

〜〜〜〜〜

HTMLParser.pyで定義されているエラーが出ているようなので、該当モジュールのソースを読んでみたところ、どうやら「">"または"/>"でタグがおわってないと駄目よ」みたいなエラーらしい。

HTMLソースの中から、エラーを引き起こしてる部分を探してみると、どうやら以下のようなパターンのときに「">"または"/>"で終わっていない」と判断されている様子。

例:


試しに、以下のような形に直してみたら、エラーがでなくなった


pythonインタラクティブシェルやipythonに以下のコードを流してみると、実際に試せると思います。
(UTF-8の環境でしか試せてませんが)

import sys
from BeautifulSoup import BeautifulSoup

test_str_ng = '<hoge alt=ほげ>'.decode( sys.stdin.encoding )
test_str_ok = '<hoge alt="ほげ">'.decode( sys.stdin.encoding )

# エラーが発生する
soup = BeautifulSoup( test_str_ng )

# OKなはず
soup = BeautifulSoup( test_str_ok )

以下に実行してみた例を張ります。

$ ipython -cl
>>> import sys
>>> from BeautifulSoup import BeautifulSoup
    
>>> test_str_ng = ''.decode( sys.stdin.encoding ) >>> test_str_ok = ''.decode( sys.stdin.encoding )
>>> # エラーが発生する >>> soup = BeautifulSoup( test_str_ng )
                                                                                                                      • -
Traceback (most recent call last): File "", line 1, in File "/var/lib/python-support/python2.5/BeautifulSoup.py", line 1499, in __init__ BeautifulStoneSoup.__init__(self, *args, **kwargs) File "/var/lib/python-support/python2.5/BeautifulSoup.py", line 1230, in __init__ self._feed(isHTML=isHTML) File "/var/lib/python-support/python2.5/BeautifulSoup.py", line 1263, in _feed self.builder.feed(markup) File "/usr/lib/python2.5/HTMLParser.py", line 108, in feed self.goahead(0) File "/usr/lib/python2.5/HTMLParser.py", line 148, in goahead k = self.parse_starttag(i) File "/usr/lib/python2.5/HTMLParser.py", line 263, in parse_starttag % (rawdata[k:endpos][:20],)) File "/usr/lib/python2.5/HTMLParser.py", line 115, in error raise HTMLParseError(message, self.getpos()) HTMLParseError: junk characters in start tag: u'\u307b\u3052>', at line 1, column 1
>>> # OKなはず >>> soup = BeautifulSoup( test_str_ok )
というわけで、上述のエラーを処理しながら、htmlをBeutifulSoupに投げてインスタンスを得るような関数を書いてみた。
from BeautifulSoup import BeautifulSoup
from HTMLParser import HTMLParseError
import re, string

def get_soup( html ) :
    try:
        # HTMLソースをBeautifulSoupに渡す
        soup = BeautifulSoup( html )
        return soup

    except HTMLParseError, e :
        # エラーメッセージに"junk characters in start tag: "が含まれる場合
        e_words = "junk characters in start tag: "
        if e.msg.find( e_words ) != -1 :

            u"""
            エラーメッセージから"junk characters in start tag: "を取りのぞくと、
            エラーを引き起こしている文字列部分のみが取り出せます。
            BeautifulSoupの場合、受け取った文字列を内部でUnicode型に変換するため、
            取り出した文字列は u'xxxx' のような形になるので、一旦evalで評価して
            Unicode型のオブジェクトを生成し、改めて文字列型に直します。
            """
            err_str = eval( string.replace( e.msg, e_words ,"" )).encode("utf-8")

            # 不完全なクオーテーションを削ります
            rpl_str = string.replace( err_str , "\"" , "" )
            rpl_str = string.replace( rpt_str , "\'" , "" )
            # '"ほげほげ">'のような形へ直します
            rpl_str = "\"%s\">" % string.replace( rpt_str, ">", "" )
            html = string.replace( html , err_str , rpl_str )

            # get_soup関数を再帰的に呼び出します
            get_soup( html )
        else :
            # その他のエラーが出た場合の対処を記述してください。
            raise e
    except Exception, e:
        # 想定外の例外の場合は再送出
        raise e
以下のような不完全なクウォーテーションが入ってくる可能性も考慮して書いてみた
<hoge alt="ほげ>
<hoge alt=ほげ">
ほんとはもっと細かくエラー処理を書かなきゃダメなんだろうし、ケースの洗い出しとして甚だ不十分だと思うけど、とりあえず間に合わせで。 BeautifulSoupを継承する子クラスを定義するほうが、オブジェクト指向的にはスマートなのかな? オブジェクト指向なんてすっかり忘れちまったので、それはいつかまた機会を見てしらべてみよう。

その他のつまりどころについて

その他、BeautifulSoupを使っていて詰まったところを書きます。 以下全て、元のHTMLソースをhtml変数に格納しているという前提で記述しています。
  • コメント行が悪さをする
⇒BeautifulSoupに投げる前にコメント行を削除してしまいましょう。 例:
# re.DOTALLを指定しないと、行をまたぐコメントに対処できないので注意
# .*の部分は.*?として、必ず最短マッチにしてあげましょう
re_word = re.compile( r"<!--.*?-->",re.DOTALL )
html = re_word.sub ( "", html )
  • "<scr" + "ipt" みたいな記述が悪さをして、パースでコケる
正規表現で引っ掛けて、削除してからBeautifulSoupに投げましょう (正規表現に自信が無いので例は割愛) 文字コードをUTFあたりに変換してから投げるのをお勧めします。 例:
# Universal Encoding Detecterをインポート
import chardet
# HTMLソースから文字コードを判定する。
charset = chardet.detect( html )["encoding"]
# Parseする際にコケないようにするために、この段階でUTF-8に統一しておく
html = html.decode( charset , "ignore").encode( "utf-8")
文字参照Unicode型に直す関数を書きましょう 例:
# htmlentitydefsはインポートしといてください

def getWebsiteTitle( soup ):
    u"""
    ウェブサイト名を取得するメソッド
    """
    # findでコケる可能性があるので、try文で囲う
    try :
        # titleタグで囲われた部分のみを抽出。
        # BeutifulSoupが自動でUnicode型に変換して返してくれる。
        websitetitle = soup.find( "title" ).string
        # 後にファイル名として使用するため、文字参照をUnicodeに変換しておく
        websitetitle = getUnicodeByHtmlEntity( websitetitle )
        # ウェブサイト名を返却
        return websitetitle
    except :
        # エラーが出た場合、固定値"Unknown"を返却
        return "Unknown"

def getUnicodeByHtmlEntity( txt ):
    u"""
    文字参照(文字実体参照・数値文字参照)をunicode型に変換するメソッド
    """
    # ループで繰り返し使用するので正規表現をあらかじめコンパイルしておく
    reference_regex = re.compile(u'&(#x?[0-9a-f]+|[a-z]+);', re.I)
    num16_regex = re.compile(u'#x\d+', re.I)
    num10_regex = re.compile(u'#\d+', re.I)

    # 返却文字列を初期化
    replaced_txt = u""
    # 文字位置を初期化
    character_position = 0
    # txtの長さを取得
    txt_length = len( txt )

    # 検索開始位置をずらしながら、文字列を検索する
    while character_position < txt_length :
        match_obj = reference_regex.search( txt, character_position )
        # 検索開始位置以降の文字列に、文字参照が見つかった場合
        if match_obj :
            replaced_txt += txt[character_position:match_obj.start()]

            # 検索開始位置を後ろにずらす
            character_position = match_obj.end()
            # 正規表現にマッチする部分のみを取り出す
            matched_string = match_obj.group(1)

            # 文字実体参照(実体参照)をunicodeに置き換える
            if matched_string in htmlentitydefs.name2codepoint.keys():
                replaced_txt += unichr( htmlentitydefs.name2codepoint[ matched_string ])

            # 数値文字参照(文字参照)処理部
            elif num16_regex.match( matched_string ):
                # 16進数により指定されている数値文字参照(文字参照)をunicodeに置き換える
                replaced_txt += unichr( int( u"0"+matched_string[1:], 16 ))
            elif num10_regex.match( matched_string ) :
                # 10進数により指定されている数値文字参照(文字参照)をunicodeに置き換える
                replaced_txt += unichr( int( matched_string[1:] ))
        # 検索開始位置以降の文字列に、文字参照が見つからなかった場合
        else :
            # 検索開始位置以降の文字列を返却文字列の後ろに連結
            replaced_txt += txt[character_position:]
            # while文から抜けるよう、検索開始位置を指定
            character_position = txt_length
            # breakの方がベター?

    # 文字参照をUnicodeに変換した結果を返却
    return replaced_txt
他にも色々とあると思うけど、思いついたらまた書こうと思う。