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
他にも色々とあると思うけど、思いついたらまた書こうと思う。

BeautifulSoupとGoogleで遊んでみた

Google検索での結果数はなにかと指標として使えそうな気がしたので、検索結果数を取得する簡単なプログラムを書いてみた。

#!/usr/bin/env python
# coding: UTF-8
import urllib, urllib2, sys
from BeautifulSoup import BeautifulSoup


OPENER = urllib2.build_opener()
OPENER.addheaders = [("User-Agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)")]
# hl=en⇒英語, num=1⇒表示する検索結果は1件, q=⇒サーチクエリ
BASE_URL = "http://www.google.co.jp/search?hl=en&num=1&q="

def get_num_of_results( que ) :
    u"""
    Googleでの検索結果数をintで返すメソッド
    """
    # イディオムで調べたりするのを考慮してクオーテーションで囲ってからURLエンコード
    url = BASE_URL +  urllib.quote( "\"%s\"" % que )
    html = OPENER.open( url ).read()
    # 念のため
    html = html.decode( "utf-8", "ignore" ).encode("utf-8") 
    soup = BeautifulSoup( html )
    
    # 検索結果数に該当する部分をスクレイピング。
    num_str = soup.findAll("p")[1].findAll("b")[2].string
    
    # 3ケタ区切りのカンマを削る
    num_str = "".join( [ str for str in num_str.split(",") ])

    return int( num_str )

def print_searchterm_and_num( search_term ) :
    num = get_num_of_results( search_term )
    print "'%s' => %s" % ( search_term, num )

def main() :
    if len( sys.argv ) == 1 :
        print u"検索語を指定して下さい"
        sys.exit(1)
    else :
        for search_term in sys.argv[1:] :
            print_searchterm_and_num( search_term )

if __name__=="__main__":
    main()
    sys.exit(0)

lxmlでパースしようと最初考えてたんだけど、どうもうまくいかなかったのでBeautifulSoupを使った。

検索結果数を取得するところはもっと賢い書き方が多分あると思うんだけど、他に思いつかなかった。

例えば

Results 1 - 10 of about 214,000,000 for abc

で言う所の 214,000,000 の部分を引っ張ってくる仕様になってる。

ためしに、以下のような使い方を考えてみた。

※ 上述のスクリプトを"test.py"だと仮定して

  • 前置詞の使い方わかんなくなった時。(コーパス的なアプローチ)
$ python test.py "bored of" "bored with" "bored by"
'bored of' => 2530000
'bored with' => 3480000
'bored by' => 791000
  • どっちの漢字が正しいんだっけ?
$ python test.py 南明菜 南明奈
'南明菜' => 45500
'南明奈' => 1510000
  • 検索結果数でシェアとか見れないかな
$ python test.py FreeBSD NetBSD OpenBSD
'FreeBSD' => 26200000
'NetBSD' => 7900000
'OpenBSD' => 5890000

GoogleAPIを使ったほうが楽だったんだろうなと、今さらながら反省している。

NTPの外形監視をjavaで

やんごとなき事情により、「NTPサーバの外形監視プログラム」を作る機会を得たので、それについて。

事の始まりは、NTPサーバが正常に機能しているかを監視する必要が出てきたこと。

普通こういう場合は、統合監視ツールのエージェントとかをサーバにインストールすることが多いんだろうけど、今回監視するNTPサーバはアプライアンス製品(LinuxベースのOSが載っている様子)のため、エージェントのインストールはできなかった。

今回いじってOKな端末にはテラタームが入っていたので、

  • TelnetなりSSHなりでログインして、psとかntpqを叩くテラタームマクロを書いて、結果をパースする

ってのを最初に考えたんだけど、端末とNTPサーバの間のどっかでパケットフィルタリングがかけられているらしい事がわかり、その案は没に。

否応無しに、監視用のNTPクライアントプログラムを書かなくちゃいけなくなったわけだが、プログラムを書く上での制約も多かった。

端末はWindows機で、PerlPython等の実行環境はインストールされていなかった。

  • PerlでNet::NTPつかって書く

ってのも考えていたのだが、速攻没に。
Net::NTP - Perl extension for decoding NTP server responses - metacpan.org
あきらめ切れなくて、perl2exeも試してみたが、うまくexe化できなかった。

仕方ないのでVBScriptで書こうと試みるも、VBの方言が全くわからない俺にソケットアプリなんか書けるわけもなく、10分も立たずにあきらめた。

javaなら入ってるんじゃね?」という最後の望みをたくしてjava -version を叩いたら1.4がインストールされていることが判明。およそ2年ぶりにjavaアプリを書くことが決定した。

なんか良いクラスライブラリは無いものかと思って探してみたところ、Jakarta Commons Netがどうやら使えそうなことが判明。
dfltweb1.onamae.com – このドメインはお名前.comで取得されています。

こいつを使って無事、監視プログラムが書けた。
もちろんNTP以外にも、ネットワークアプリを作るにあたって便利なモジュールが集まっているので、他のサービスの外形監視プログラムなんかもコレで書けそうだ。

普段はあまりプログラムを書く機会が無く、家でちょこちょこPythonを触る程度なので、2年ぶりのjavaは良い経験になった。もっとも2年前にJavaを会社の研修でやるまで、プログラミングなぞやったことが無かったので、プログラミング言語を見る上での自分の中での基準がJavaにあるのかもしれないという気がした。

今回ちょこっとPerlJavaを触った上での感想や雑記、一部脱線:

  • Pythonばっかり書いてると、他の言語でソースを書くとき;を忘れる。
  • public / protected / privateみたいな使い分けは、PythonよりJavaの方が書き易いしわかり易いと感じた
  • Perlで書いたソースコードは、自分で書いたものさえ呪文に見える。
  • メンテする気がない使い捨てスクリプト以外でPerlは使いたくないなと思った。
  • が、use strictなんかは便利だと思う。typoが防げる。
  • ただ、「なんでmyなのか」はよくわからない。キチっとやるなら静的型付けの言語を使うほうがトータルコスト低そう。
  • vimの後方補完便利。CTRL+N
  • Pythonの場合、pydiction使うと後方補完でより幸せになれる。Pythonでソースを書く上でipythonとpydictionは手放せない。

余談だが、「商用の有料外形監視サービス」ってあんまり選択肢ないんだろうか?WebやDNSSMTPなんかを外形監視したいっていう要望はありそうなもんなんだけど、あまりサービスとして目にする機会ない気がする。たぶん自分が知らないだけなんだろう。

データベース雑記

もうすぐ春の情報処理試験ということで、データベーススペシャリストの勉強をしているのだが、受かる気がしない。

OracleをGoldまで小手先で切り抜けられたので、ノリで申し込んだのが間違いだったか。午後問題に歯が立たない。午後1はまだなんとかなるかもしれないが、午後2がダメだ。

自分の学習進捗がどうのというのはさておき、データベーススペシャリスト試験でとわれる内容はRDBにかたよっている気がする。現状あたりまえっちゃ当たり前なのかもしれないけど。
XMLDBみたいな階層型データベースの場合どのように設計していくべきなのかみたいなところも少しずつ範囲に含めて行って欲しいと思っている。

EDIなんかもレガシーなものからXMLベースのものへシフトして行く中で、流通・物流システムのデータベース設計手法もかわっていくのでは?という気がなんとなくしてる。

そんなこんなでXMLDBが気になっている今日この頃。パフォーマンスはどんなもんなのかとか、ロックの仕組みはどうなってるのかとか、そのあたりが気になる。

rssとスパム mixiとrss

ブログ初投稿。
読むばかりで書く文化が無いので、続くかわからないけど始めてみる。

RSSリーダというものを最近になって使い始めてみたんだが、便利だね。たまたま見つけたLifereaといいうのを使ってみてる。

便利なんだけど、広告はなんとかならんもんなんだろうか。ちゃんとした広告が表示されるうちは良いんだろうけど、「広告に表示されてるURLに飛んだらマルウェアに感染しました」なんて事がおこらんとも限らん。

なんらかのフィルターをかけられたらと思うんだけど、そういう機能をもったRSSリーダってあるのだろうか?探した限りでは見つけられなかった。似たようなことをサービスとして提供してるWebアプリみたいなのは見つけたんだけど、外だししなきゃ実現できないような機能じゃない気がする。

関係ないが「mixiの更新情報もrssリーダで管理できないか」と思って色々試してみた。

最初は「HTMLをスクレイピングして、rssフィードの形まで落とし込む」ってのを考えてて、「pythonでBeautifulSoupを使って書くかなぁ」と思ってたんだけど、「mixiステーション用にmixirssフィードを配信してる」って事に気がついたので、そちらを利用することにした。

まずはrssのURLをリーダに普通に登録してみたんだけど、認証まわりが原因でうまく行かず。認証用のダイアログは出るんだけど、そこに正しい情報を入力しても通らない。

次に、cookieを食わせるのを試してみようと思って、firefox3.0のcookies.sqliteからcookies.txtを作ってlifereaに渡してみたが、こちらもうまく行かず。

しかたないので、rssを取得するところまでをpythonスクリプトで書いて、そのスクリプトをlifereaから呼ぶようにしたらうまくいった。

#!/usr/bin/env python
#coding: utf-8

import sys, urllib, urllib2, cookielib

def main():
    u"""
    main method.
    """
    # variables
    username = 'hoge@fuga.foo' # input your username here.
    password = 'xxxxxx' # input your password here
    uid = 'xxxxxx' # input your uid here
    
    cookie_jar = cookielib.CookieJar()
    opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_jar))
    params = urllib.urlencode({'email': username, 'password': password, 'next_url': 'home.pl'})
    
    mixi_url = 'http://mixi.jp/'
    login_url = mixi_url + 'login.pl'
    new_friend_diary_url = mixi_url + 'atom/updates/r=1/member_id=' + uid + '/-/diary'

    try :
        opener.open( login_url , params)
        rss = opener.open( new_friend_diary_url ).read()
        rss = rss.decode("utf-8", "ignore").encode("utf-8")
        
        print rss

    except :
        pass

if __name__=='__main__':
    main()
    sys.exit(0)

mixiのセッション管理がどうなってるかは良く知らないけど、cookieの扱いなんかはもっとちゃんとした方がベターなのかもしれない。


lifereaにはこんな感じで登録してあげれば良いんじゃないかと。