自分のなり損ないを作ったというお話

記事タイトル(cv:森本レオ)

TL;DR

自分のツイートを取得して、そこから作った文章をツイートするBotを作った

これはなに?なんで作ったの?

身の回りで「マルコフ連鎖で生成した文章をツイートするBotを作る」というのが流行って、自分のも見たかったから作った。

いや分からんが

噛み砕いて言うと

噛み砕いて言うとって、一旦咀嚼したものを出すってことだよね…うぇぇ… まず自分のツイートをとってきて、それを形態素っていう単位に分解する。その形態素を組み替えることによって僕みたいな文章を作ろう! そしてツイートしよう! ってことをやってる。マルコフ連鎖については詳しく言わないので適当に調べて。僕も実際には分かってないのかもしれない。

どうやって作るの?

ツイート部分とマルコフ連鎖はKotlinをかきかき、形態素解析MeCabにぶん投げるってことをする。

詳細な構成

開発OS:Windows7,Windows10,Ubuntu 18.04 on WSL on Win10
IDE:IntelliJ IDEA
開発言語:Kotlin 1.3.11
JDK:環境がバラバラでよくわかんない☆
使ったもの:MeCab,Penicillin

実行環境:Ubuntu 18.04(開発のとは違う)

詳しい流れ

といっても流れだけで本当に詳細までは話さないよ。

技術選定

まず第一として自分の使える言語であること。がっつり(と言っていいのか分からないが)何か作ったことがあるのは[C++,C#,Swift,Kotlin,java,(Vue.js)]の4つ。C#で作ろうとしたらなんか「周りが作った言語と被るな」とかいうわけのわからない理不尽な指摘を受けたのでKotlinで作ることにした。C++は使う気しなかったのと、Javaは嫌い生理的に無理、Swiftは自宅にMacがないという理由で消去法的に決定。正直Kotlin/Nativeで書きたかったけど多分JVMで書くことになる。なんか悔しい。
次に形態素解析。KotlinでMeCabって使えるのかな、と調べたらKuromojiとSudachiってのが出てきた。Javaで使えるらしいからJVMで動かせば問題ないね。Kuromojiで試してみよう。が、動かない。なにが悪いんですかね、これで1週間ほど潰しました。しかたがないのでMeCabJavaバインディングも試してみる。が、これも動かず1週間ほど潰す。最終的にライブラリをロードしてなかったことが判明しました。バカですね。ということでMeCabを使います。ちなみにIDEを使わずコマンドライン上でコマンド叩いてやってます。なのでエラーも実行時エラーが出るだけです。クラスパスいじったり~ってのを延々と繰り返してました。
そしてBotも作らないといけないのでそこらへんをごちゃごちゃします。まずTwitterのDeveloper申請。新しくなって400文字英作文と落とされがちってのを聞いてめんどくさがって全然やってなかったんだけど、申請したら12時間後には申請通ってた。ビビらせやがって…。というわけでトークンをGet! ただトークン持ってるだけじゃどうにもならないのでKotlinでTwitterAPIを叩きます。検索するとトップにKotlinで使えるAPIラッパーがヒット。Penicillinってやつです。 これですね。Twitterではお馴染みのみりあやんないよbot元締めのプロジェクトチームの方が書いたやつらしいです。みりやんないよbotがKotlinで動いてるの今回で初めて知った。とりあえずREADME.mdのサンプルソースをコピペして試してみるもまったく動かない。なんでやねんと思いつつGitHubのソースを読み解いておかしい部分に変更を加える。ここでもIDEを使わずにmaven centralから直接jarを落としてきてクラスパスに追加してコマンドラインで開発したりしてます。しかしタイミングが悪くて、上がってるjarはver上がりたて4.0.0-eapなのにGitHubのmasterブランチは3.x.xのまま(覚えてない)。違うブランチに気づかなければ泣き寝入りするところだった。幸い更新が毎日のようにあったのですぐmasterに上がってきましたけど。逆に言えば更新がめちゃくちゃ早くて開発途中にはもうどういう変更がされているか追いきれなった。まぁ軽く使うだけなので問題なし。でもこの行動のおかげでつよつよの人が実装してるソースをしっかりと読むことになったのでだいぶ理解も深まった。これは良い経験。ちなみにREADME.mdのサンプルソースは1/24のコミットでようやく修正されてました。何がダメで動かなかったのか書こうと思ってたのに。閑話休題。無事にソースも分かって書き直してテストツイートもできた。ここまでで5日間くらい。時間かけすぎ。ここらへんのタイミングで開発環境をIntelli Jに変える。やべぇ、IDEめちゃくちゃ便利…最初から使っておけばよかった…。結局途中からコマンドラインでGradle使ってたからマジでIntelli J使えよって話。しかしUbuntu on WSL on Win10からWin7IDEに実行環境を移した結果、Penicillinサンプルソースを変更したことをすっかり忘れてまたエラーに苦しむことになりました。ここで5日ほど潰します。まごうことなきバカ。さっさと自分のコミット確認すればここまで潰すことはなかったのに。はい、ここまできたらもうあとはゴリゴリ愚直に書いていくだけですね。できたらデプロイして(まぁビルドしたjarをサーバーに置くだけなんだけど)cronで定期的に実行させるだけ。

実装

Kotlin初学者なのでおかしい点もあるだろうけど多めに見てね。自分でももやっとするので書き直したいなぁとは思ってる。とりあえず完成させることを目標に。開発はWin7のIntelli Jでやってたんだけど、MeCabUbuntu on WSL on Win10の方にしか入ってないのでデバッグできずにいきなりjarを実行してます。めんどくさがりなので実行後のエラーを見て詳細なデバッグもしておらずそこはよしなに。

Tweet Class

class Tweet {
    val key = KeyData()
    val morphoAnalysis = MorphoAnalysis()
    val markovChain = MarkovChain()
    val client = PenicillinClient {
        account {
            application(key.ConsumerKey, key.ConsumerSecret)
            token(key.AccessToken, key.AccessTokenSecret)
        }
        emulationMode = EmulationMode.TwitterForiPhone

        maxRetries = 5
        retry(1, TimeUnit.SECONDS)
    }
    fun getTweet(user: String) {
        runBlocking  {
            client.timeline.user(screenName = user, count = 200,includeRTs = false,excludeReplies = true).await().forEach { status ->
                morphoAnalysis.makeBlock(morphoAnalysis.blockList,morphoAnalysis.text2morphene(status.text.replace("""http(s)?://([\w-]+\.)+[\w-]+(/[\w- ./?%&=]*)?|#(w*[一-龠_ぁ-ん_ァ-ヴーa-zA-Za-zA-Z0-9]+|[a-zA-Z0-9_]+|[a-zA-Z0-9_]w*)""".toRegex(),"")))
            }
        }
    }
    fun post(text: String = "テスト") {
        client.statuses.update(status = markovChain.genText(morphoAnalysis.blockList)).complete()
    }
}

getTweet()はその名の通りTweetを取得してくる。ついでにそのままtext2morphene()に渡して形態素解析を行ってmakeBlock()でブロックに変換する。URLとかハッシュタグはいらないので正規表現で先になくしておく。post()もちろんそのままでツイートをする。ツイート文章はgenTextで生成。

MorphoAnalysis Class

class MorphoAnalysis {
    init {
        System.loadLibrary("MeCab")
    }
    val tagger = Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")
    var blockList = mutableListOf<Triple<String, String, String>>()
    fun text2morphene(text: String): List<String> {
        tagger.parse(text)
        var node = tagger.parseToNode(text).next
        var morphene: MutableList<String> = mutableListOf()
        while(node.run { next != null }) {
            morphene.add(node.surface)
            node = node.next
        }
        return morphene
    }
    fun makeBlock(blockList: MutableList<Triple<String, String, String>>, morphene: List<String>) {
        blockList.add(if(morphene.run { count() == 1 }) {
            Triple("_START_", morphene[0], "_END_")
        } else {
            Triple("_START_", morphene[0], morphene[1])
        })
        if(morphene.run { count() != 1 }) {
            for (index in morphene.indices) {
                if (index < morphene.lastIndex - 1) {
                    blockList.add(Triple(morphene[index], morphene[index + 1], morphene[index + 2]))
                } else {
                    blockList.add(Triple(morphene[index], morphene[index + 1], "_END_"))
                    break
                }
            }
        }
    }
}

text2morphene()はさっきも言ったとおり形態素解析を行う。まぁMeCabが全部やってくれるんだけどね。なぜかnodeを作る前にtagger.parse()をしないとちゃんとやってくれないという謎挙動があって変なソースになってるけど許して。makeBlock()は形態素をブロックとしてListに保存する。例えば「私はかにです、嘘ですエビです」という文章が[私,は,かに,です,、,嘘,です,エビ,です]と形態素解析されたとする。それを[_START_,私,は],[私,は,かに]…[です,エビ,です][エビ,です,_END_]というブロックにする。適当に書いたんだけど形態素が2つだけのときに対応してない気がする。

MarkovChain Class

class MarkovChain {
    fun genText(blockList: List<Triple<String, String, String>>): String {
        var (text: String, block: Triple<String, String, String>) = findStartBlock(blockList)
        do {
            block = findBlock(blockList, block.third)
            text += block.first + block.second
        } while(text.length < 140 && block.third !=  "_END_")
        return text
    }
    fun findStartBlock(blockList: List<Triple<String, String, String>>): Pair<String, Triple<String, String, String>> {
        val startBlock = blockList.filter { it.first == "_START_" }[Random.nextInt(blockList.filter { it.first == "_START_" }.indices)]
        return Pair(startBlock.second, startBlock)
    }
    fun findBlock(blockList: List<Triple<String, String, String>>,firstWord: String): Triple<String, String, String> {
        val block = blockList.filter { it.first == firstWord }[Random.nextInt(blockList.filter { it.first == firstWord }.indices)]
        return block
    }
}

genText()は(多分)マルコフ連鎖をして文章を生成する。findBlock()はマルコフ連鎖するために必要なブロックを見つけるために使う。_START_から始まって_END_で終わると終了。while()の条件式が140文字以下となっているがもちろんこのソースだと140文字以上の文章が生成されることもあるのでたまにエラーがでる。とりあえずできればいいと思ってやったのであとで修正する。

まとめ

終わってみれば結構時間かかったなぁという感じ。正直ソースだけ見るとこんなもん1日で書き終わります。いかに自分に集中力がないかっていうのを実感させられる。実装自体は1週間くらいでやったので全部合わせると1ヶ月半くらい。3/4以上も環境構築だけで潰してるのはさすがに頭が悪すぎる。ちなみにKuromojiとかMeCabの導入あたりは去年の11月頃で、そこから1月の初めに開発を始める間にテストやらkosenハッカソン、名古屋カンファが入ってきます。時間を無駄に消費してるのが手に取るように分かる。本実装は本当に簡単で常人ならマジで1日で終わります。僕が異常なだけです、はい。もっとやる気出してやろうね(環境構築の時点でモチベを失っていたのは確か)。
完成品もいい感じに動いてくれて達成感もあるし、なおかつ面白いのでとりあえずやってみるのはオススメです。
展望としてだけど、現状都度最新200のツイートを引っ張ってきて生成してるのでできれば過去のツイートも合わせてDBに保存してそれから生成したいところ。あとはなんか他の機能もつけれたら面白そうかなって感じ。なんか気持ち悪いソースも少しは書き直します。文章生成の精度については、もっと良くしてもいいんだけど適当な方が面白い文章が生成されやすくて迷ってる。
そんなところですかね。ぜひみんなフォローして楽しんだり自分で作ったりしてみてね。ブログ書くの飽きたのでここらへんで適当に締めます。