帰ってきたTweetBot


はじめに

この記事はミクシィグループ Advent Calendar 2018 22日の記事です。

1年前にも似たようなことやりましたが(経緯はこちら)、今年は自社のアニメをほんのちょっとだけ盛り上げるべく1年前にやったことを踏まえつつ改良してやっていこうと思います。

以前やったことは指定した時間の直前になったらTwitterに投稿するというものです。
システムの構成を簡単に説明すると、Webアプリの部分をKemal(Rubyで言うところのSinatraのようなフレームワーク)で作って(エンドポイントはないので単純にHerokuに乗せるためのハリボテ)Herokuに乗せて、HerokuスケジューラでTwiterのClientでTwitterに投稿する処理をタスク実行するというものです。
Rubyだと有り物で簡単にできてしまうので、ちょっと手間を掛けてCrystalでやってみたりしました。

この時は放映時間の情報は公式サイトに書いてあるものをそのまま流用したので、放映時間が予定とずれてた場合でも設定した時間の10分前になるとTwitterで通知するという残念な出来でして。
(Gコードとかから番組情報を取るAPIとかありそうですけども)

改良ポイント

というわけで動画の情報を定期的にYoutubeに取得しに行き、チャンネルに新しい動画が投稿されていた場合はその情報をTweetするという具合に修正していこうと思います。
幸いYoutubeにはWebAPIが公開されているのでそれを活用します。
Ruby版では公式のWebAPIClientがあるのですが、それを使うと一瞬でできてしまうので、今年もCrystalでAPIClientを(部分的に)作ってからやってみます。

APIKeyの取得

GoogleのAPIConsoleでAPIKeyを取得します。
取得するにはまずGoogle Developers Consoleにアクセスします。
その後、認証情報から「プロジェクトを作成」を選択し、適当な名前をつけます。
プロジェクト作成後、認証情報から認証情報を作成->APIキーを選択します。

今回はユーザ情報(ブックマークしている動画など)を特に見ないので、シンプルAPIKeyで問題ありません。
(言い換えるとユーザ情報を取得した場合はOAuth2での登録が必要になります)
ドメイン制約はこのAPIをキックするBotを設置するURLを設定しておきます。
最後にAPI一覧の中から「YouTube Data API」を選択し、API有効にすることで準備は完了です。

ApiClient

Googleが公開しているApiClientのRuby版の実装を参考にしつつ、動画情報取得の箇所を実装します。

https://github.com/googleapis/google-api-ruby-client
を参考にして作ったもの

https://github.com/msky026/google-api-client-cr
(Youtubeのチャンネルの情報を取る箇所だけ実装)

単純にエンドポイントにGetを飛ばすだけです。

Youtube情報を取得している箇所だけ抜粋)
require "google-api-client-cr"
youtube = Google::Apis::YoutubeV3::YouTubeService.new
result = youtube.list_searches("id,snippet", channel_id: "UCWzenZSy9GJBcPzdSm-UX5w", order: "date", max_results: 5)

list_searchesの中身はこんな感じです。(概要抜粋)

require "http/client"
require "openssl"

context = OpenSSL::SSL::Context::Client.new
client = HTTP::Client.new("www.googleapis.com", tls: context)
builder = HTTP::Params::Builder.new
builder.add("part", "id,snippet")
builder.add("channelId", channel_id) if channel_id
builder.add("order", order) if order
builder.add("maxResults", max_results.to_s) if max_results
builder.add("key", key)
response = client.get("/youtube/v3/search?#{builder.to_s}")
JSON.parse(response.body)

これでどんな情報が返ってくるかというと、ブラウザで表示したほうがわかりやすいので以下に図示します。

ここで欲しい情報がVideoIDである["items"][0]["id"]["videoId"]と、動画タイトルの["items"][0]["snippet"]["title"]です。
(タイトルはOGPで表示されるのでいらないといえばいらないですが)

DBへの登録

以前は取得したものをそのままTwitterに投げてしまえばよかったのですが、重複投稿を防ぐためにDBに登録します。
videosという名前でカラムはid(serial), channel_id(string), video_id(string)の3つ設定します。
(video_idでユニークなのでchannel_idは不要といえば不要ですが、後々別のチャンネルとかでも使う場合のために絞り込みを行うために入れときます)
シンプルなカラムなので普通に以下の内容でも問題ないとは思います。

require "db"
database_url = ENV["DATABASE_URL"]
db = DB.open(database_url)

params = [] of String
params << channel_id
params << video_id
db.exec("insert into videos(channel_id, video_id) values($1::text, $2::text)", params)
db.close

本稿ではORMの紹介を兼ねて技術書典の時にも使ったGraniteを使ってみます。

require "db"
require "pg"
require "granite/adapter/pg"

database_url = ENV["DATABASE_URL"]
Granite::Adapters << Granite::Adapter::Pg.new({name: "pg", url: database_url})

class Video < Granite::Base
  adapter pg
  field channel_id : String
  field video_id : String
end

投稿

後はもう基本的に前回作ったものと同じです。
以下の文面を作り、Twiterに投稿するだけです。

require "twitter-crystal"

class TwitterClient
  @client : Twitter::REST::Client
  def initialize
    consumer_key                 = ENV["CONSUMER_KEY"]
    consumer_secret              = ENV["CONSUMER_SECRET"]
    access_token                 = ENV["ACCESS_TOKEN"]
    access_token_secret          = ENV["ACCESS_TOKEN_SECRET"]
    @client                      = Twitter::REST::Client.new(consumer_key, consumer_secret, access_token, access_token_secret)
  end

  def tweet(message)
    @client.update(message)
  end
end

以上を纏めて、Youtubeからデータを取り、実際に投稿する処理は以下の形になります。

require "google-api-client-cr"
require "./twitter_client"
require "./models/video"

class Notifier
  @channels : Array(NamedTuple(channel_id: String, tags: String))
  @twitter_client : TwitterClient
  @youtube : Google::Apis::YoutubeV3::YouTubeService

  def initialize(channels)
    @channels = channels
    @twitter_client = TwitterClient.new
    @youtube = Google::Apis::YoutubeV3::YouTubeService.new
  end

  def youtube_channel_info(channel_id)
    @youtube.list_searches("id,snippet", channel_id: channel_id, order: "date", max_results: 5)
  end

  def notify_all
    @channels.each do |channel|
      notify(channel[:channel_id], channel[:tags])
    end
  end

  def notify(channel_id, tags)
    channel_info = youtube_channel_info(channel_id)
    return unless channel_info
    video_id = channel_info["items"][0]["id"]["videoId"]
    title = channel_info["items"][0]["snippet"]["title"]
    return unless video_id
    video = Video.find_by(video_id: video_id.to_s)
    return if video
    Video.create!(channel_id: channel_id, video_id: video_id.to_s)
    message = "#{title} https://www.youtube.com/watch?v=#{video_id.to_s} #{tags}"
    @twitter_client.tweet(message)
  end
end

channels = [{channel_id: "UCWzenZSy9GJBcPzdSm-UX5w", tags: "#モンスト #モンストアニメ"}]
notifier = Notifier.new(channels)
notifier.notify_all

チャンネルは複数の情報を渡せるようにしています。
例えば

channels = [{channel_id: "UCWzenZSy9GJBcPzdSm-UX5w", tags: "#モンスト #モンストアニメ"}]

のところに別のチャンネルIDを渡せばそのチャンネルの新規投稿情報もチェックし始めます。

上記をHerokuスケジューラで定期実行(あまり短い期間で動かす必要もないので1時間に1回程度、毎週土曜日の20時以降になるタイミングで周期実行されるように)すれば、動画更新後の実行タイミングでTweetされます。
投稿されるとこんな感じになります。

明日は 清水さん です。よろしくお願いします。