目次
この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。
こんにちは、いくたです。
みなさん、人生について考えていますか?
毎日忙しいとなかなか考える暇がありませんよね。
そこで普段仕事中に使っているチャットツールである Slack に名言を教えてくれるbotを作ってみました。忙しい中でもふとした瞬間に人生について考えるきっかけになるかもしれませんね。
準備するもの
- Ruby(ver 2.1.0)
- gem: open-uri → URL先のデータを普通のファイルと同様に扱えます
- gem: Nokogiri → HTMLやXMLの構造を解析して特定の要素を抽出できます
- gem: MeCab → 日本語の文章を品詞単位で解析してくれます
- SlackBot → 導入方法は こちらの記事 がわかりやすかったです
- 名言 → こちらの 偉人の名言100 から名言をとってきます
MeCab とは日本語の文章を品詞単位で解析してくれるオープンソース形態素解析エンジンです。
試しに二葉亭四迷の名言を解析してみました。
品詞ごとに分けて品詞の種類を教えてくれます。
$ mecab
いや、人生は気合だね。二葉亭四迷
いや 接続詞,*,*,*,*,*,いや,イヤ,イヤ
、 記号,読点,*,*,*,*,、,、,、
人生 名詞,一般,*,*,*,*,人生,ジンセイ,ジンセイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
気合 名詞,一般,*,*,*,*,気合,キアイ,キアイ
だ 助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ
ね 助詞,終助詞,*,*,*,*,ね,ネ,ネ
。 記号,句点,*,*,*,*,。,。,。
二葉亭四迷 名詞,固有名詞,人名,一般,*,*,二葉亭四迷,フタバテイシメイ,フタバテイシメイ
EOS
気合いですか…先生…
実装の概要
今回作るのはユーザーが投稿した内容に反応して名言を返してくれるbotです。
下の画像の saying_bot が今回作ったbotです。
直前の「ただ」という単語に反応してダグ・ハマーショルドさんの名言を投稿しています。
図1 投稿した内容の単語に反応して名言を返すsaying_bot
ざっくりとした仕組みとしては以下のような感じです。
- 名言データをサイトからとってくる
- MeCabで名言データを解析して”名詞”だけを抜き出す(名詞リスト)
- 投稿された内容に名詞リストと一致する部分があるか調べる
- 一致するものがあれば対応する名言を投稿する
1.については私が前回書いた記事でWebスクレイピングについて書いたので割愛します。open-uri や Nokogiri の使い方と合わせて書いてあるので、よろしければこちらも合わせてご覧ください。
【前回の記事】アニメ名探偵コナンの新一が出てる回だけを見たい
今回の記事では 2〜4 についてどうやって実装したか具体的に見ていきます。
また、裏テーマとして「クラスを役割ごとに分ける」ということも意識しているので、そのあたりも一緒に追っていきます。
MeCabで名詞だけを抽出
Stringに新たにparse_nounメソッドを追加します。
文字列から名詞だけを取り出して、配列に格納してくれます。
こちら↓を参考にして書きました。
Rubyの形態素解析「MeCab」で文章から名詞を抽出してみる
抜き出す名詞の種類を「一般名詞」と「形容動詞語幹」に絞っています。
名詞の全てを抜き出してしまうと「それ」「あれ」といった指示代名詞等も入ってしまうので細分化して絞り込みました。
一般名詞は「本」とか「人間」といった普通の名詞です。
形容動詞語幹とは「親切」とか「変」などのことをいいます。
形容動詞の「親切だ」「変だ」という語から変形して名詞のように使われています。
# mecab_string.rb
require 'MeCab'
# Stringクラスに名詞抽出メソッドを追加
class String
def parse_noun
model = MeCab::Model.new(ARGV.join(' '))
tagger = model.createTagger
node = tagger.parseToNode(self)
nouns = []
while node
target_node = node.feature.force_encoding('UTF-8')
# 名詞(一般・形容動詞語幹)を抽出する
if /^名詞,一般/ =~ target_node || /^名詞,形容動詞語幹/ =~ target_node
nouns.push(node.surface.force_encoding('UTF-8'))
end
node = node.next
end
nouns
end
end
名言を保持するクラス
名言のデータを保持するクラスを考えます。
インスタンスからid(名言のID)、dialog(発言)とgreatman(偉人)というメソッドで名言のデータ取ってこれるように、attr_accessorをつかっています。
# saying.rb
# Sayingクラスは名言データを持つためのクラス
class Saying
attr_accessor :id, :dialog, :greatman
def initialize(id, dialog, greatman)
@id = id
@dialog = dialog
@greatman = greatman
end
end
名言データと名詞リストを扱うクラス
つぎにSayingDaoクラスを作成します。
DaoはData Access Objectの略でデータを扱うオブジェクトです。
今回はサイトからとってきた「名言データ」と名言から名詞を抽出した「名詞リスト」のふたつを同時に扱います。
SayingDaoクラスの役割
initialize
- 名言の内容をサイトから取得
- 使いやすいようにハッシュ化して「名言データ」をつくる
- 名言から名詞を抽出して「名詞リスト」をつくる
find_and_sample_saying メソッド
- 任意の文字列(ユーザーの投稿内容)を引数にとる
- initialize内で作成した名詞リストと照らし合わせる
- 一致したものがあれば名言データをひとつだけ返す
- 一致しなければnilを返す
繰り返しになりますが、Open-uriやNokogiriの使い方は前回の記事をどうぞ。
# saying_dao.rb
require 'open-uri'
require 'nokogiri'
require_relative 'saying.rb'
require_relative 'mecab_string.rb'
# 名言リストと名詞テストを作成する
class SayingDao
attr_accessor :sayings_data, :nouns_list
# 名言をとってくるURL
SAYING_URL = 'http://atsume.goo.ne.jp/HxLFhNn4N7Zb'.freeze
XPATH = "//*[@id=\"atsumeWrapper\"]/div[3]/div".freeze
def initialize
# 名言をとってくるURLからNokogiriとXPathでスクレイピング
html = open(SAYING_URL).read
doc = Nokogiri::HTML.parse(html)
div = doc.xpath(XPATH)
# 名言のデータはsayings_dataに格納する
@sayings_data = []
# 名言に含まれる名詞のデータはnouns_listに格納する
@nouns_list = Hash.new { |hash, key| hash[key] = [] }
# HTMLの解析結果をdiv要素ごとに処理
div.each_with_index do |div, saying_id|
dialog = div.xpath('./p').text
greatman = div.xpath('./h2').text
saying = Saying.new(saying_id, dialog, greatman)
@sayings_data << saying
# 名言から名詞を抽出
saying_nouns = saying.dialog.parse_noun
# 名詞データがないときには新しく作る
# 既にkeyが一致するデータがあればindexの情報を追加
saying_nouns.each { |noun| @nouns_list[noun].push(saying_id) }
end
end
# 名詞リストと照合して一致した名詞と名言を返す
def find_and_sample_saying(text)
# 名詞リストのうち引数textに含まれるものを取ってくる
# sayin_idは使わないので引数を捨てるためにアンダースコアをつける
related_nonus = @nouns_list.select { |noun, _saying_ids| text.include?(noun) }
# 名詞リストに引っかからなかったらnilを返す
return if related_nonus.empty?
# 複数の名詞リストに引っかかることがあるのでsampleで一つだけ取ってくる
noun = related_nonus.keys.sample
# 一つの名詞に複数の名言が紐付いていることがあるのでsampleで一つだけ取ってくる
saying_id = related_nonus[noun].sample
saying = @sayings_data.find { |saying| saying.id == saying_id }
{ noun: noun, saying: saying }
end
end
ユーザーの投稿と名詞リストを照らしあわせる
次にslack側から渡されたユーザーの投稿に合わせて名言のデータを返すReplyMessageクラスを作ります。
ここで先ほどSayinDaoで生成した名言データおよび名詞リストをつかいます。
投稿内容に名詞リストと一致するものがあれば、該当するインデックス番号に紐付く名言データをひとつ取ってきます。
SayingReplyクラスの役割
initialize
SayingDaoをnewする
saying_replyメソッド
- ユーザーの投稿内容を受け取る
- 投稿内容と名言リストを突き合わせる(SayingDaoのfind_and_sample_sayingメソッド)
- find_and_sample_sayingの返り値(名言データor nil)に合わせて返信メッセージ or nilを返す
# reply_message.rb
require_relative 'saying_dao.rb'
# メッセージを成形
class ReplyMessage
def initialize
@saying_dao = SayingDao.new
end
# ユーザーの投稿内容にあった返信内容を返す
def saying_reply(message)
match_data = @saying_dao.find_and_sample_saying(message)
# match_dataがnilだったらnilを返す
return if match_data.nil?
# match_dataに値があれば返信内容を返す
<<~TEXT
「#{match_data[:noun]}」と言えば、こちらの名言をご覧ください。
>#{match_data[:saying].dialog}
>
>- #{match_data[:saying].greatman} -
TEXT
end
end
名言を投稿するSlackBot
最後に実際にslackの制御を行うbotを作成します。
基本の挙動は こちらの記事 を参考に書きました。
botの挙動
- slack上のアクション(誰がどんな投稿をしたか、誰が記入中ステータスか等)をリアルタイムで補足
- ユーザーが何かしら投稿した時にReplyMessageのsaying_replyメソッドに渡す
- saying_replyメソッドから返ってきた値をslackに投稿する
# bot.rb
require 'eventmachine'
require 'faye/websocket'
require 'http'
require 'json'
require_relative 'reply_message.rb'
SLACK_API_URL = 'https://slack.com/api/rtm.start'.freeze
response = HTTP.post(SLACK_API_URL, params: { token: ENV['SLACK_API_TOKEN'] })
rc = JSON.parse(response.body)
url = rc['url']
EM.run do
# Web Socketインスタンスの立ち上げ
ws = Faye::WebSocket::Client.new(url)
# ReplyMessageのインスタンスを生成
rm = ReplyMessage.new
# 接続が確立した時の処理
ws.on :open do
p [:open]
end
# RTM APIから情報を受け取った時の処理
ws.on :message do |event|
data = JSON.parse(event.data)
p [:message, data]
if data['type'] == 'message' && data['user']
# ユーザーの投稿data['text']をReplyMessageのsaying_replyメソッドに渡す
reply_message = rm.saying_reply(data['text'])
if reply_message
# 返信内容を投稿する
ws.send({
type: 'message',
text: reply_message,
channel: data['channel']
}.to_json)
end
end
end
# 接続が切断した時の処理
ws.on :close do
p [:close]
ws = nil
EM.stop
end
end
まとめ
今回このbotを作る上で一番大変だったのは、クラスを役割ごとに分ける作業でした。
普段だと、書きやすいコードからなんとなく書き始めてしまうことが多いので、作り始めてから「この機能は別のクラスにしよう…」とか「このクラス全く要らないのでは…」となってしまいます。
今度からは必要となるクラスや機能を挙げてから、実際のコードを書いていこうと思いました。
最後にお気に入りの名言をひとつ。
「それも、いいじゃないか」は、おもしろい人生のスローガン。
– メーソン・クーリー –
それでは、今日はこの辺で。
読んでいただき、ありがとうございました。