読者です 読者をやめる 読者になる 読者になる

FutureInsight.info

AI、ビッグデータ、ライフサイエンス、テクノロジービッグプレイヤーの動向、これからの働き方などの「未来」に注目して考察するブログです。

本文抽出ライブラリWebstemmerのblog本文抽出用特化スクリプト「blogstemmer」を書いてみた

以前のエントリーで本文抽出ライブラリWebstemmerを使ってみました。

Webstemmerは非常に興味深い本文抽出ライブラリなのですが、ニュースサイトなどの複雑な階層構造を持っているサイトの本文抽出に特化しているため、逆にblogのようなシンプルなケースでの本文抽出に用いるには、ちょっとオーバースペックです。

Webstemmer はニュースサイトから記事本文と記事のタイトルをプレインテキスト形式で自動的に抽出するソフトウェアです。サイトのトップページの URL さえ与えれば全自動で解析するため、人手の介入はほとんど必要ありません。

そのあたりのことを考慮して、本文抽出ライブラリWebstemmerのblog本文抽出用特化スクリプト「blogstemmer」を作成してみました。相変わらず長いので興味のある方だけ、ご覧下さい。あと、あくまでエントリーの本文をRSSにある他のエントリーとレイアウトを比較して推定するためのラッパーであり、特に機能追加などはしておりません。(はやくgitの使い方覚えて、githubに入れていこう)

Webstemmerがblogの本文抽出にはオーバースペックなポイント

Webstemmerを一通り触ってみて、オーバースペックだと感じた点は以下の3点です。

  • blogの本文抽出だと各エントリーのレイアウトはほぼ同じと仮定してよいので、各ページのレイアウトを分類する精度はそこまで必要ない。広告エントリー、一行だけ、写真だけなどあきらかなごみエントリーをはじけばよい。
  • blogはRSSがあるのでタイトルの推定は基本的に必要ない。
  • blogはRSSがあるのでリンク構造を解析するタイプのクローラは必要ない。
  • 逆に文字エンコード情報が含まれていないページが多いので、文字エンコード推定機能は必須。

そんなわけで、上記のオーバースペックな点をカバーするblogの本文抽出専用のラッパースクリプトを作成してみました。含まれている機能は、

  • RSSを指定すればエントリーを取得し、後は勝手にパターンファイルを作成してくれる。
  • レイアウト解析においてレイアウトがマッチしていると判断するスレッショルドをデフォルトで下げておく。0.97から0.7とかなり低くしておきました。これは、各エントリーの構造は基本的に同じであるという前提を利用したものです。いろいろ試した感じ、0.7あれば、広告エントリーははじくことができつつ、基本的に各エントリーは同じレイアウトを持つと判断されるようです。
  • エントリーの文字コードは自動判断してくれる(Universal Encoding Detectorを使用:http://chardet.feedparser.org/)

Webstemmerのblog本文抽出用特化スクリプト「Blogstemmer」

そんなわけで以下がソースコードです。Universal Encoding Detectorをあらかじめインストールしておいてください。Webstemmer-0.6.1内に以下のスクリプトを設置してご利用下さい。Webstemmerに合わせて、Python2.4までの機能しか使ってません。

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

import sys
import os
import feedparser
import urllib2
import urllib
import zipfile
import datetime
import md5
import time
import popen2
import chardet
import extract

def getBlogEntry(rss):
    d = feedparser.parse(rss)
    entries = {}
    index = 0
        
    for item in d["items"]:
        print >>sys.stderr, "get:"+item["link"]
        response = urllib2.urlopen(item["link"])
        entries[item["link"]] = response.read()
        index = index + 1
        if index == 8:
            break
        time.sleep(1)
    return entries

def createPackageDir(dirname, entries, rss):
    try:
        os.makedirs(dirname)
    except:
        pass
    filelist = []
    for key in entries.keys():
        filename = str(md5.new(key).hexdigest())+".html"
        fileobj = open(os.path.join(dirname, filename), "w")
        filelist.append(os.path.join(dirname, filename))
        fileobj.write(entries[key])
        fileobj.close()
    return filelist

def createZipFile(filename, filelist):
    if os.path.exists(filename):
        os.remove(filename)
    zip = zipfile.ZipFile(filename, "w")
    for path in filelist:
        print >>sys.stderr, "zip:"+path
        zip.write(path)
    zip.close()

def analize(rss):
    entries = getBlogEntry(rss)
    d = datetime.datetime.today()
    
    datename = "%04d%02d%02d%02d%02d"%(d.year, d.month, d.day, d.hour, d.minute)
    basename = urllib.quote("_".join( (rss.replace("http://", "")).split("/")))
    filelist = createPackageDir(os.path.join(datename, basename), entries, rss)
    createZipFile(basename+"."+datename+".zip", filelist)
    char = chardet.detect(entries[entries.keys()[0]])
    print >>sys.stderr, "python analyze.py -c %s -t 0.5 %s"%(char["encoding"] ,basename+"."+datename+".zip")
    (stdout, stdin, stderr) = popen2.popen3("python analyze.py -c %s -t 0.7 %s"%
                                            (char["encoding"] ,basename+"."+datename+".zip"))
    lines = []
    for line in stdout:
        lines.append(line)
    try:
        os.remove(zipfilename)
    except:
        pass
    return lines

def extract(rss, entryurl, pattern):
    response = urllib2.urlopen(entryurl)
    entry = response.read()
    d = datetime.datetime.today()
    datename = "%04d%02d%02d%02d%02d"%(d.year, d.month, d.day, d.hour, d.minute)
    basename = urllib.quote("_".join( (rss.replace("http://", "")).split("/"))) 
    filelist = createPackageDir(os.path.join(datename, basename), {entryurl:entry}, rss)
    zipfilename = createZipFile(basename+"."+datename+".zip", filelist)
    charenc = chardet.detect(entry)
    default_charset=charenc["encoding"]

    print >>sys.stderr, "python extract.py -c %s -t 0.5 %s %s"%(charenc["encoding"], pattern, basename+"."+datename+".zip")
    (stdout, stdin, stderr) = popen2.popen3("python extract.py -c %s -t 0.7 %s %s"%(charenc["encoding"], pattern, basename+"."+datename+".zip"))
    lines = []
    for line in stdout:
        lines.append(line)
    try:
        os.remove(zipfilename)
    except:
        pass

    title = []
    maintext = []
    subtext = []
    for line in lines:
        if line.startswith("TITLE"):
            title.append(":".join(line.split(":")[1:]))
        elif line.startswith("MAIN"):
            maintext.append(":".join(line.split(":")[1:]))
        elif line.startswith("SUB"):
            subtext.append(":".join(line.split(":")[1:]))
        else:
            print >>sys.stderr, line
    return (title, maintext, subtext)

def main():
  import getopt
  def usage():
      print '''usage: blogstemmer.py [-t ][-p pattern_file] [-r rss_url] [-e entry_url]
      if pattern output is needed, please set only -r option.
      if entry extract result is needed, please set all options(-p, -r, -e).'''
      sys.exit(2)
  try:
      (opts, args) = getopt.getopt(sys.argv[1:], 'p:r:e:')
  except getopt.GetoptError:
      usage()
    
  (patternfile, rss, entry) = ("", "", "")
  
  for (k, v) in opts:
      if k == '-p': patternfile = v
      elif k == '-r': rss = v
      elif k == '-e': entry = v

  if rss != "" and entry == "" and patternfile == "":
      print "".join(analize(rss))
  elif rss != "" and entry != "" and patternfile != "":
      (title, maintext, subtext ) = extract(rss, entry, patternfile)
      print "TITLE:" + "".join(title)
      print "MAIN:" + "".join(maintext)
      print "SUB:" + "".join(subtext)      
  else:
      usage()

if __name__=="__main__":
    main()

ファイルはこちら

使い方は以下の通りです。

  • レイアウトパターンファイルをRSSから生成する場合
python blogstemmer.py -r http://d.hatena.ne.jp/gamella/rss > gamella.pat
python blogstemmer.py -r http://d.hatena.ne.jp/gamella/rss -p gamella.pat -e http://d.hatena.ne.jp/gamella/20090324/1237826978

また、上のスクリプトを見ていただけるとわかりますが、main関数で呼んでいるanalyze()とextract()にすべて集約されており、この2関数を呼べばあとは、本文抽出が行えるようになっているので、ライブラリとしても利用可能なはずです。
実際に使うとなると修正が必要だろうなと思う点は以下の通りです。

  • エントリー取得部分にタイムアウト処理を設定
  • レイアウト解析処理(analyze()内)にタイムアウト処理を設定
  • 解析失敗などのエラー処理(設定を変えてもういちど試すか、RSS側に含まれるデータを本文にするとか)
  • そのほかのエラー処理たくさん追加
  • ディレクトリのお掃除処理を追加
  • RSSに本文が全て含まれているケースも多々あるので、そもそもそんなに必要ない?

まぁ、しかし上のスクリプトをベースにいろいろいじれば良いかと思いますので、もしよろしければお使い下さい。手元では、ライブドアブログ、はてな、MTなどの10個くらいのサイトで本文抽出に成功することを確認しています。