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

誰にもばれない日報自動生成ツール作成奮闘記

みなさん初めまして。4月に新卒入社した DTM小僧 と申します。
高専卒20歳 、現在 最年少 として一番下から他のエンジニアを追いかけている最中です。
業務ではFuelPHPをベースにした社内向けシステムの開発を行っています。
以後宜しくお願い致します!

avatar20

Speeeでは毎日メールで日報を提出しています。日報からはその人の仕事ぶりやプライベートの一部が垣間見れて読んでいてとても楽しいです。
とはいえ毎日毎日そんなに面白い出来事に遭遇しないので、最近は書く内容をひねり出すのが本当に大変でして、

他の人の日報をゴニョゴニョして自分の分を生成してしまおう

という手法にチャレンジしてみたいと思います。

今回使ったもの

MecabMacPortsでインストールしました。辞書はIPAでもいいんですが僕の環境だと文字化けが面倒臭かったのでUnidic辞書を使いました。

やり方

  1. メールサーバーから日報メールを取ってくる
  2. 本文から所感(今回生成する部分)を抽出する
  3. MeCab形態素解析してマルコフ連鎖用のテーブルを作る
  4. 出だしの単語を指定してマルコフ連鎖で文章を生成

マルコフ連鎖とは

自然言語の文章を"単語"のつながりと考えると、「この単語の次にはこの単語が来やすい」という情報をモデル化することができます。このようなモデルをマルコフ連鎖と呼びます。

例えば「しぬほどアイスが食べたい」「駅前のコンビニで新しいアイスが売ってたよ〜」という文を形態素解析すると

$ mecab
しぬほどアイスが食べたい
しぬ  シヌ  シヌ  しぬ  動詞-一般   五段-ナ行   連体形-一般    0
ほど  ホド  ホド  ほど  助詞-副助詞
アイス   アイス   アイス   アイス-ice   名詞-普通名詞-一般          1
が ガ ガ が 助詞-格助詞
食べ  タベ  タベル   食べる   動詞-一般   下一段-バ行    連用形-一般    2
たい  タイ  タイ  たい  助動詞   助動詞-タイ    終止形-一般
EOS
$ mecab
新しいアイスが売ってたよ〜
新しい   アタラシー アタラシイ 新しい   形容詞-一般    形容詞   連体形-一般    4
アイス   アイス   アイス   アイス-ice   名詞-普通名詞-一般          1
が ガ ガ が 助詞-格助詞
売っ  ウッ  ウル  売る  動詞-一般   五段-ラ行   連用形-促音便 0
て テ テル  てる  助動詞   下一段-タ行    連用形-一般
た タ タ た 助動詞   助動詞-タ   終止形-一般
よ〜  ヨー  ヨ よ 助詞-終助詞
EOS

このように文章が分割されました。これを元に、こんな感じのマルコフ連鎖用のテーブルを作ります。

{
    'しぬ' : {
        'ほど' : ['アイス']
    },
    'ほど' : {
        'アイス' : ['が']
    },
    'アイス' : {
        'が' : ['食べたい' , '売って']
    },
    'が' : {
        '食べたい' : [''],
        '売って' : ['た']
    },
    '食べたい' : {
        '' : null
    },
    '新しい' : {
        'アイス' : ['が']
    },
    '売って' : {
        'た' : ['よ']
    },
    'た' : {
        'よ' : ['〜']
    },
    'よ' : {
        '〜' : null
    }
}

2単語プレフィクスのマルコフ連鎖では、A→B→Cという単語のつながりに注目し、A→Bの後に続き得る候補の中からランダムでCを決定します。同様にB→Cの後にくるDを決め、さらにこれを続けることで文章を生成します。
例えば、'アイス'→'が'に続く候補として'食べたい'と'売って'があるので、ランダムでどちらか選んで文章が終わるまで連鎖します。下の画像は単語のつながりからランダムで文章を生成する仕組みを表したものです。

先ほどの例だと、「しぬほどアイスが売ってたよ〜」「新しいアイスが食べたい」といった文が生成できることがお分かりでしょうか。

文章を多く(あるいは長く)することで生成される文章のパターンが増えます。

やってみよう

まずはサーバーから日報メールを引っ張ってきましょう。

#!/usr/bin/perl

use strict;
use Text::MeCab;
use Encode;
use Net::IMAP::Client;
use MIME::Parser;
use utf8;
binmode(STDOUT , ':utf8');

my $client = Net::IMAP::Client->new(
    server => '受信サーバーのホスト',
    user   => 'ユーザー名',
    pass   => 'パスワード',
    ssl    => 1,
    port   => 993,
);

# メールサーバーにログイン
$client->login
    or die $client->last_error;

# フォルダを選択(Gmailではラベル)
$client->select('DailyReport')
    or die $!;

# 検索実行
my $messages = $client->search({
    SINCE => '15-June-2014',
});

# 取得できたかチェック
unless($messages){
    die "Mail not found.";
}

これでメールの配列のリファレンスが取得出来ました。デリファレンスして1通ずつ処理していきます。

MIME::Parserをつかって本文だけを抜き出します。

foreach my $message_id (@$messages){
    my $from    = "";
    my $body    = "";

    # ヘッダー/本文/添付ファイルを含むMIMEを取得
    my $_msgref       = $client->get_rfc822_body($message_id);
    my $plain_message = $$_msgref;

    # 解析する
    my $entity = $parser->parse_data($plain_message);

    # 差出人を取得
    $from    = $entity->head->get('From');

    my $body_entity;
    my @parts = $entity->parts;

    if(@parts){
        my $plain_entity;
        my $html_entity;
        foreach my $part(@parts){
            if($part->mime_type eq 'text/plain'){
                $plain_entity = $part;
                last;
            }elsif($part->mime_type eq 'text/html'){
                $html_entity = $part;
            }
        }
        $body_entity = $plain_entity or $html_entity;
    }else{
        $body_entity = $entity;
    }

    # 本文の配列リファレンスを取得
    my $_body;
    if($body_entity){
        $_body = $body_entity->body;
    }
    else{
        print $from." could not catch body.\n";
        next;
    }

    $body = "";
    foreach my $line(@$_body){
        # 改行コードを変換
        $line =~ s/\r\n/\n/g;
        $body .= $line;
    }

    # 内部文字列へ変換
    $from    = decode('ISO-2022-JP' , $from);
    $body    = decode('ISO-2022-JP' , $body);

    if($body =~ /[雑所]感(.*?)(?:[…-]{5,}|.本日)/ms)
    {
        my $content = $1;
        $content =~ s/\n//mg;
        $content =~ s/http:\/\/(\S)*?//mg;
        # print $content."\n";
        push(@shokan , $content);
        &markovtable($content , \%markov);
        print $from;
    }
    else
    {
        print "could not catch content.\n";
    }
}

文字列と、ハッシュのリファレンスを渡すとMeCabで分割してマルコフテーブルをガンガン積んでいくサブルーチンmarkovtableをこんなかんじで。

sub markovtable {
    my $str     = $_[0];
    my $markov  = $_[1];
    my $mecab   = Text::MeCab->new;
    my $result  = $mecab->parse($str);
    my @words = ();
    while($result){
        push(@words , decode_utf8($result->surface));
        $result = $result->next;
    }

    # 分割数
    my $length = @words;

    for(my $i = 0 ; $i < $length-2 ; $i++){
        push(@{$markov->{$words[$i]}->{$words[$i+1]}} , $words[$i+2]);
    }
}

これで取得したすべての日報メールを元にマルコフテーブルが出来ました。ここからはマルコフ連鎖を用いて文章を生成する処理です。サブルーチンmarkovchainをこんな感じに。

sub markovchain {
    my $markov = $_[0];
    my $word1  = $_[1];
    my $word2;
    my $key ;
    my $text = "";

    $text .= $word1;
    my $number_of_key = keys(%{$markov->{$word1}});
    $key = int(rand($number_of_key));
    my @keys = keys(%{$markov->{$word1}});
    $word2 = $keys[$key];
    $text .= $word2;

    # 最初の2語が決まったのでゴリゴリ連鎖していく
    my $word3;
    my $length;
    while($word2 ne ''){
        $length = scalar(@{$markov->{$word1}->{$word2}});
        $key    = int(rand($length));
        $word3  = ${$markov->{$word1}->{$word2}}[$key];
        $text .= $word3;

        $word1 = $word2;
        $word2 = $word3;
        # print $text."\n";
    }
    return $text;
}

文章の最初の言葉を指定すると、マルコフテーブル内からその単語を見つけて文章を作り始めます。一発で綺麗な文章が作れるとは限らないのでループでいくつも文章を作れるようにしてみます。

# 文章生成の最初の言葉を入力
while(1){
    print "Start with > ";
    my $start = <STDIN>;
    chomp($start);
    $start = decode_utf8($start);

    if($start eq ''){
        print "Exit?[y/N] > ";
        $start = <STDIN>;
        chomp($start);
        if($start eq 'y'){
            last;
        }
        else{
            next;
        }
    }

    my $text = &markovchain(\%markov , $start);
    print $text."\n";
}

結果

「朝」「今日」「明日」「今週」から始まる文章を生成してみたらこんなかんじに。

(以下登場する人名はすべて架空のものに置き換えています)

Start with > 朝 朝早く来たら絶対、手洗い喉うがいを欠かさないことがあまりにも反映されてしまいました。 そのために一つづつ覚えていきたい。ただ、ゲロ絵文字(※)を理解し、それ無しの本質はあり得ない。矢澤さんランチ有難うございます。ただし、ストーリーの柔軟性(想定できる流れ)を持たせないと感じる機会を増やしたい。ただ、開き直ってやっと今までなかったんです」とすかさずツッコミを入れてきらきらメイクして自分で思うくらいなら、コミットするだけ。後悔しない、やりきる」って感じで、そこは必ず乗り越えてから炭酸水とココナッツウォーターを飲んでいます。6月なのはオツマミ系ばっかり。特にチューボーですよね。

だいぶ長めのめちゃくちゃな文になりました。チューボーですね。

Start with > 今日 今日はやること多すぎてぱつぱつでした。小泉さん、ご対応のほど、お疲れ様でした。スキューバからのテンションがおかしいですが、誠実な対応本当に良かった。いつも書いている友人がいなくなるのでもう少し集めます。とふと考えていました。ありがとうございました。ありがとうございました。

すごく腰が低い感じ。

Start with > 明日 明日から関西イベント!選考会の参加と、自分を理想に近づけていく。

すごく意識が高い感じ。

Start with > 今週 今週は、ママさんと南さんが向き合ってくれることによって組織力で勝つ。

ママさんとの連携で勝つ。

まとめ

今回はMeCab+マルコフ連鎖で既存の文章から新しい文章を生成してみました。意味の通る日本語にはなりませんが、スットコドッコイな日本語が湧いてきて結構おもしろかったです。

とはいえ、こんなスットコドッコイ文章を日報に書いた日には即バレ&上長激おこでしばき倒されてしまうのでもっと精度を上げなければなりません。 少し前にword2vecが話題になりましたが、アレを使えば文章全体の話題に合わせて単語を選ぶ、といったこともできるようになるのでしょうか。時間を見つけて挑戦してみたいです。