Webページの要約(前処理)

# まえがき

本記事は[機械学習関連情報の収集と分類(構想)](http://qiita.com/suchowan/items/459062590f7134dfc138)の❷を背景としています。

クロウルして収集したWebページを自動分類するには[scikit-learn でランダムフォレストによる多ラベル分類](http://qiita.com/suchowan/items/58760cedb1b062182d92)で事前調査したように、ランダムフォレストや決定木などのアルゴリズムでオンプレミスな環境で分類することもできますが、一方では[Ruby からの Watson Natural Language Classifier 利用例](http://qiita.com/suchowan/items/4bb4db40c4ff434ee847)などのようにクラウド上のサービスを利用することも考えられます。

[自然言語処理における前処理の種類とその威力](http://qiita.com/Hironsan/items/2466fe0f344115aff177)に書かれているような前処理は非常に重要です。

ただ、Webページを自動分類するためクラウド上のサービスを利用する場合、この記事では言及されていない前処理が必要となります。

それが「Webページの要約」です。

一般にクラウド上のサービスには１レコードあたりのデータ量に制限があります。[Ruby からの Watson Natural Language Classifier 利用例](http://qiita.com/suchowan/items/4bb4db40c4ff434ee847)では分類する１記事あたり1024文字という制約のため、ブログ記事の前から1024バイトまでを切り出してサービスに渡すようにしました。これも初歩的な“要約”です。

Webページの場合、もともと分量制限はありませんから、いかに分類に必要な情報を落とさずにクラウド上のサービスに渡すデータ量を減らすかという工夫が必要になります。

このためのもっとも正統的なテクニックが「Webページの要約」ではないかと思うのです。

# 一般的な要約技術

要約技術の最近の動向というのはあまりまとまったものが見つかりませんでしたが、３年ほど前の[自動要約技術の研究動向](https://www.slideshare.net/hitoshin/automatic-summarization)が参考になりました。

また、具体的にコードを実装したものとして LexRank を用いた[自動要約アルゴリズムLexRankでドナルド・トランプ氏の演説を3行に要約してみる](http://qiita.com/riverwell/items/310e0a31f2e5097df7ff)や[自動要約アルゴリズムLexRankを用いたECサイトの商品価値の要約](http://qiita.com/takumi_TKHS/items/4a56ac151c60da8bde4b)がQiita に上がっています。

これらの実装は、文章を“文”の集合とみなして、“文”に重要度を割り当て、重要度の高い“文”から順に抽出するという方針で文章を要約するものです。

ここで、問題になるのが Webページをどのように“文”に分割するかです。

これに関してはネットを検索しても実装コードが見つからず、唯一実装できそうなロジックを解説していたのが、[『重要文抽出によるWebページ要約のためのHTMLテキスト分割』](http://harp.lib.hiroshima-u.ac.jp/hiroshima-cu/metadata/5532)という論文でした[^1]。

結局実装コードが見つからなかったので、今回はこの論文のロジックでコードを実装[^2]してみることにしました。

# 具体的なロジック

具体的なロジックは下記の３段階からなります。

(1)全体をテキストユニット(文候補)に分割
　　区切りは下記
　　- ブロックレベル要素
　　- リンクタグ(複数連続する場合)
　　- 句点(“。”)

(2)テキストユニットを文ユニットと非文ユニットに分類
　　文は下記条件のうち３つ以上を満たすもの
　　- C1. 自立語の数が7個以上
　　- C2. 自立語数の全単語数に対する割合が0.64以下
　　- C3. 付属語数の自立語数に対する割合が0.22以上[^3]
　　- C4. 助詞の数の自立語数に対する割合が0.26以上
　　- C5. 助動詞数の自立語数に対する割合が0.06以上

(3)非文ユニットを自立語の数を指針に結合・分割する

今回は簡単のため、非文ユニットは結合のみ行い、分割は実装していません。

また下記コードは HTMLのうち body 部分のみ抽出する実装となっていますが、これはダウンロードした HTML ファイルのファイル名に title 要素の内容を利用する前提であるためです。一般的には title 要素の内容も要約で利用するのがよいでしょう。

# HTMLのテキストユニットへの分割とプレーンテキスト化

```html2plaintext_part_1.py
import codecs
import re

class Article:

    # この順に文字コードを試みる
    encodings = [
        "utf-8",
        "cp932",
        "euc-jp",
        "iso-2022-jp",
        "latin_1"
    ]

    # ブロックレベル要素抽出正規表現
    block_level_tags = re.compile("(?i)</?(" + "|".join([
        "address", "blockquote", "center", "dir", "div", "dl",
        "fieldset", "form", "h[1-6]", "hr", "isindex", "menu",
        "noframes", "noscript", "ol", "pre", "p", "table", "ul",
        "dd", "dt", "frameset", "li", "tbody", "td", "tfoot",
        "th", "thead", "tr"
        ]) + ")(>|[^a-z].*?>)")

    def __init__(self,path):
        print(path)
        self.path = path
        self.contents = self.get_contents()

    def get_contents(self):
        for encoding in self.encodings:
            try:
                lines = ' '.join([line.rstrip('\r\n') for line in codecs.open(self.path, 'r', encoding)])
                parts = re.split("(?i)<(?:body|frame).*?>", lines, 1)
                if len(parts) == 2:
                    head, body = parts
                else:
                    print('Cannot split ' + self.path)
                    body = lines
                body = re.sub(r"(?i)<(script|style|select).*?>.*?</\1\s*>"," ", body)
                body = re.sub(self.block_level_tags, ' _BLOCK_LEVEL_TAG_ ', body)
                body = re.sub(r"(?i)<a\s.+?>",' _ANCHOR_LEFT_TAG_ ', body)
                body = re.sub("(?i)</a>",' _ANCHOR_RIGHT_TAG_ ', body)
                body = re.sub("(?i)<[/a-z].*?>", " ", body)
                blocks = []
                for block in body.split("_BLOCK_LEVEL_TAG_"):
                    units = []
                    for unit in block.split("。"):
                        unit = re.sub("_ANCHOR_LEFT_TAG_ +_ANCHOR_RIGHT_TAG_", " ", unit) # イメージへのリンクを除外
                        if not re.match(r"^ *$", unit):
                            for fragment in re.split("((?:_ANCHOR_LEFT_TAG_ .+?_ANCHOR_LEFT_TAG_ ){2,})", unit):
                                fragment = re.sub("_ANCHOR_(LEFT|RIGHT)_TAG_", ' ', fragment)
                                if not re.match(r"^ *$", fragment):
                                    if TextUnit(fragment).is_sentence():
                                        # 文ユニットは“ 。”で終わる
                                        if len(units) > 0 and units[-1] == '―':
                                            units.append('。\n')
                                        units.append(fragment)
                                        units.append(' 。\n')
                                    else:
                                        # 非文ユニットは“―。”で終わる
                                        # (制約) 論文と相違し非文ユニットは結合のみ行い分割していない
                                        units.append(fragment)
                                        units.append('―')
                    if len(units) > 0 and units[-1] == '―':
                       units.append('。\n')
                    blocks += units
                return re.sub(" +", " ", "".join(blocks))
            except UnicodeDecodeError:
                continue
        print('Cannot detect encoding of ' + self.path)
        return None
```

# 文ユニットと非文ユニットとを判別
```html2plaintext_part_2.py
from janome.tokenizer import Tokenizer
from collections import defaultdict
import mojimoji
#import re

class TextUnit:

    tokenizer = Tokenizer("user_dic.csv", udic_type="simpledic", udic_enc="utf8")

    def __init__(self,fragment):
        self.fragment   = fragment
        self.categories = defaultdict(int)
        for token in self.tokenizer.tokenize(self.preprocess(self.fragment)):
            self.categories[self.categorize(token.part_of_speech)] += 1

    def categorize(self,part_of_speech):
        if re.match("^名詞,(一般|代名詞|固有名詞|サ変接続|[^,]+語幹)", part_of_speech):
            return '自立'
        if re.match("^動詞", part_of_speech) and not re.match("サ変", part_of_speech):
            return '自立'
        if re.match("^形容詞,自立", part_of_speech):
            return '自立'
        if re.match("^助詞", part_of_speech):
            return '助詞'
        if re.match("^助動詞", part_of_speech):
            return '助動詞'
        return 'その他'

    def is_sentence(self):
        if self.categories['自立'] == 0:
            return False
        match = 0
        if self.categories['自立'] >= 7:
            match += 1
        if 100 * self.categories['自立'] / sum(self.categories.values()) <= 64:
            match += 1
        if 100 * (self.categories['助詞'] + self.categories['助動詞']) / self.categories['自立'] >= 22:
            # 論文通り「付属語　＝ 助詞　⋃　助動詞」と解釈 (通常の定義と異なる)
            match += 1
        if 100 * self.categories['助詞'] / self.categories['自立'] >= 26:
            match += 1
        if 100 * self.categories['助動詞'] / self.categories['自立'] >= 6:
            match += 1
        return match >= 3

    def preprocess(self, text):
        text = re.sub("&[^;]+;",  " ", text)
        text = mojimoji.han_to_zen(text, digit=False)
        text = re.sub('(\t |　)+', " ", text)
        return text
```

# HTMLファイルをプレーンテキストファイルに変換
```html2plaintext_part_3.py
if __name__ == '__main__':
    import glob
    import os

    path_pattern = ''/home/samba/example/links/bookmarks.crawled/**/*.html'
    # The converted plaintext is put as '/home/samba/example/links/bookmarks.plaintext/**/*.txt'
    for path in glob.glob(path_pattern, recursive=True):
        article = Article(path)
        plaintext_path = re.sub("(?i)html?$", "txt", path.replace('.crawled', '.plaintext'))
        plaintext_dir  = re.sub("/[^/]+$", "", plaintext_path)
        if not os.path.exists(plaintext_dir):
            os.makedirs(plaintext_dir)
        with open(plaintext_path, 'w') as f:
            f.write(article.contents)
```

Python には慣れていないので気の利かないコードになっていると思いますが、改良点をご指摘いただければありがたいです。

# プレーンテキストの要約

このようにして生成したプレーンテキストは [LexRank](http://qiita.com/search?q=LexRank)などを使って要約できるはずです。

今回の例では簡単のため形態素解析に janome を使いましたが、「HTML→プレーンテキスト」と「プレーンテキスト→要約」はステップを分けましたので、「プレーンテキスト→要約」の際にはまったく別の形態素解析ツールを使用してもよいでしょう。

[^1]: もとの研究は2002～2004年ころのもので、最近ではもっと効率のよいロジックが提案されているかもしれません。
[^2]: → https://github.com/suchowan/bookmarks/blob/master/scripts/python/html2plaintext.py
      [shiracamus](http://qiita.com/shiracamus)さんのご提案を反映して、より効率の良いジェネレータ内包表記に見直したバージョンです。
[^3]: C2 と C3 を独立に扱っていることから、この論文での自立語・付属語などの用語は一般的な用例とずれている可能性が高いです。今回は実装しやすいように適当に解釈しました。