プログラミング学習メモ

RubyとRuby on Rails等

rubyでslackの天気botを作った

投稿間隔が空いてしまいましたが、slackのbotを作ったのでその事について描こうと思います。

汚いコードはこちら

github.com

環境

  • macOS Catalina 10.15.2

  • ruby 2.6.4p104 (2019-08-28 revision 67798) [x86_64-darwin18]

利用したサービス

お天気Webサービス仕様 - Weather Hacks - livedoor 天気情報weather.livedoor.com

api.slack.com

やったこと

slackでチャンネルにボットを置いて、天気都道府県名が同時に投稿されたとき、該当する都道府県の天気をAPIで取得して、整形した文字列をslackのbotが投稿する。

使ったサービスの紹介+α程度の記事なので、詳しくは作ったbotのコードをご覧ください。

内容

天気APIの取得と解析

今回使ったWeather HacksというAPIは、今日明日の天気をJSON形式で提供しているサービスです。認証も必要なく、URLはベースのpathと末尾の都市IDだけ、HTTPリクエストはgetのみと、わりと簡単に情報を得ることができます。

#群馬県前橋市のJSONを取得する例

require "uri"
require 'net/http'
require "json"

MAEBASHI = "http://weather.livedoor.com/forecast/webservice/json/v1?city=100010"

#URLをパースしてURIインスタンスを返す
uri = URI.parse(MAEBASHI)

#引数のURLへのgetリクエストのレスポンスを文字列で返す(引数はURIインスタンスのみ)
res =  Net::HTTP.get(uri)

#文字列をJSON形式でパースして、ハッシュ(と配列)で返す
weather = Json.parse(res)

#仕様に書いてあるプロパティ名がハッシュのキーとなる
#ハッシュの途中で今日か明日か選択する配列が出てくるので注意
#例
weather["forecasts"][0]["date"] #=>"2019-12-20"
weather["forecasts"][0]["telop"] #=>"晴のち曇"

加えて都市IDと都道府県名でCSVを作成して、全ての都道府県に対応する処理を作ってみました。

...

  BASE = 'http://weather.livedoor.com/forecast/webservice/json/v1?city='.freeze
...

    # 都道府県に応じたAPIのURLを作って配列で返す
    def generate(text)
      hash.each_with_object([]) {|(k, v), ary| ary << (BASE + v) if text.include? k.chop }
    end

    # csvを読み込んで{'都道府県' => '都道府県ID'}ハッシュで返す
    def hash
      @hash ||= CSV.read(listfile).each_with_object({}) {|ary, h| h[ary[0]] = ary[1] }
    end

    # 絶対パス生成
    def listfile
      @listfile ||= File.expand_path("api_list.csv", __dir__)
    end
  end

...

上のコードは文字列に都道府県名が含まれるなら、その都道府県IDとベースのURLを連結したURLを配列で返します。slackで投稿された文字列を引数にします。

slackAPI

slackのAPIは難しく、まだ理解できていない状態です。

RTM APIEvents APIがあり、今回使用したのはRTM APIです。

このRTM APIWebSocketを用いてリアルタイム通信を行いますが、WebSocketや非同期処理で用いるeventmachineは全く理解ができていないので、理解できる範囲で紹介しようと思います。


slack APIを使うには新しいアプリを作る必要があります。

  1. slack apiCreate New Appからアプリを作成します。
  2. 作ったAppのBot Userbotを作成します。
  3. OAuth & PermissionsInstall App to Workspaceワークスペースにアプリをインストールします。
  4. botが追加されたワークスペース内で、任意のチャンネルにbotを追加します

これでOAuth & PermissionsAPIを使える様になるBot User OAuth Access Tokenトークンが取得できます。

トークンはコードに平文で置かずに、環境変数等に入れて外部に漏れないようにしてください。平文に置いて、手違いでgithubにpushしたらすぐにトークンが無効となりました。

以下がWebSocketでリアルタイム通信を行う手順です。

#トークンを環境変数に入れておく(ここではSLACK_API_TOKEN)
require 'http'
require 'json'
require 'eventmachine'
require 'faye/websocket'

RTM_API = "https://slack.com/api/rtm.start"

#RTM APIのURLにトークンをつけてpostリクエストを送る
#戻り値はHTTP::Responseインスタンスを返す
response = HTTP.post(RTM_API, params: {token: ENV['SLACK_API_TOKEN']})

#HTTP::ResponseインスタンスのbodyのJSONをパースする
rc = JSON.parse(response.body)

#JSONからWebSocketで用いるwss通信のアドレスを得る。
url = rc['url']

#非同期処理を行うためにeventmachineの起動
EM.run do

  # Web Socketインスタンスの立ち上げ
  
  ws = Faye::WebSocket::Client.new(url)
  
  #onメソッドは引数に一致するイベントによって呼ばれる。
  #ブロックパラメータはレスポンスのJSONを含むFaye::WebSocket::API::**インスタンスが代入される

  #  接続が確立した時の処理
  ws.on :open do
    #処理
  end

  # RTM APIから情報を受け取った時の処理
  ws.on :message do |event|
    #レスポンスのJSONを得る
    data = JSON.parse(event.data)
    #チャンネルに投稿する
    ws.send({type: 'message',text: "メッセージ",channel: data['channel']}.to_json)
    #等の処理
  end

  # 接続が切断した時の処理
  ws.on :close do
    EM.stop
    #eventmachineの終了等の処理
  end
end

参考

【第一回】超簡単!RubyでSlack Botを作る方法 - Studio Andy

Rakeタスクをレビューしてもらった

前回の記事でまとめた情報を元にRakeタスクを作りました。

起動すると、はてなブックマークのランキングをスクレイピングし、タイトルを出力し、記事を選択してブラウザで開くという物です

github.com

これに対し

  • nokogiriは外部ライブラリなのでGemfileで管理
  • open-uriでエラーが出た際の例外処理がない
  • 処理を分割して実装する
  • STDIN.gets.chompはCLIのツールを用いる?
  • classを使う

というissueをもらったので

それを踏まえて書き直したのが、こちら

github.com

これをビデオチャットでレビューしてもらいました。 個人的にはそこそこできたと思っていたのですが、かなり深刻な問題点を指摘・修正してもらいました。

レビューまとめ

実際はもっと細かく指摘されましたが、自分なりにまとめてみました。振り返られるのがpull requestのコードと記憶のみなので無意識に素通りしている所はあるかも知れません。

クラスの区分を考える

私が作ったタスクはclassがRankingScrapingしかなく、スクレイピング・タイトルとURL格納・タイトル出力・記事の選択とブラウザ移行が一つのクラスに記述されていました。
このクラス名はScraperの様な名詞に、行う処理はスクレイピングと出力のみにし、要素(タイトル・url)の格納とコレクション等は別のクラスを作った方が良いです。

紐付いた値はまとめて管理する

タイトルはtitle_ary、URLはurl_aryという配列に格納していましたが、これらは本来セットとなる要素です。取り回し辛い上に、そもそもRubyは配列の順番を保証していません。
これらには新しいクラスを作り、そのインスタンスインスタンス変数にタイトルとURLとランキングを格納という方法が良いです。
これらのコレクションクラスも必要です。

固定の値はクラス内部に隠す

今回はスクレイピングしたいURLやxpathcssセレクタを外部からもらう様なことはせず常に固定です。にも関わらず、固定のURLやxpathを変数としてRakefike内に記述していました。これらは値を扱うクラスに定数として置くのが好ましいです。

メソッド名は簡潔に

get_elementやchoice_articleなど冗長なメソッド名が多いです。elementやselectなど可能なら一語で書くのがrubyの慣習らしいです。rubyの慣習にそったコーディングか判断してくれるRubocopというgemもあるので今度使ってみようと思います。

例外処理は外側に書く

クラスのメソッド内で例外処理を書いていました。結果的に握り潰してしまうことになるので、実行ファイルであるRakefileに記述しました。また、例外処理でプログラムの実行を終了する場合のexitの戻り値はエラーを意味する1にします。エラーでない場合は0ですが、それがデフォルトなので引数なしでも正常終了扱いとなります。

既存の機能を活用する

先ほど言及したコレクションクラスを定義する時にはEnumerableを使います。 Enumerableはmix-inしeachを定義するだけでコレクションクラスが簡単に作れる、Arrayクラスもincludeしている繰り返しの汎用モジュールです。rubyはこういった便利な汎用モジュールがあるのでクラス作成にそれを使わない手はないです。

感想

基本的なプログラミングと設計の力がないこと、クラスを使ってオブジェクト指向的にプログラミングする難しさを再認識しました。クラスに関しては数をこなさないと身につかないと言っていたので、これからはもっとコードを書いて少しづつでも身に付けられたらと思っています。

nokogiriでスクレイピング

今回はいつものドキュメントの焼き直しみたいなのではなく、実際に作ってみたことを書いてみようと思います。

目標

はてなブックマークのテクノロジー部門ランキングをスクレイピングし、タイトルとURLを拾う

html取得

まずhtmlを取得してみます。

require 'nokogiri' #スクレイピングのライブラリ
require 'open-uri' #urlを開くライブラリ

charset = nil
#htmlと文字コードを取得
html = open(url) do |f| 
  charset = f.charset
  f.read 
end

#取得したhtmlを操作しやすいNokogiriオブジェクトにパース
 doc = Nokogiri::HTML.parse(html, nil, charset)


#=> 出力結果は量が多いためカット

取得し、パースもできました。 しかし、目的のタグのtitlehref(URL)といった属性を抽出する方法がわかりません。

NokogiriオブジェクトはXpathcssセレクタで要素を指定して抽出できるそうなのでその要素を探します。

chrome で解析

スクレイピングを行うために、Google Chromeデベロッパーツールを使い、抽出の際目印になりそうな要素を探していきます。

chrome上でF12若しくは⌘+option+iで起動します。

左のマウスカーソルのようなアイコンをクリックすると、マウスカーソルの下にあるテキストや画像がhtmlのどの部分に該当するかわかります。 f:id:zoo_web:20191129005547p:plain タイトルとURLが抽出できそうなclassが見つかりました。

スクレイピング

html取得の項目のコードの続き

先ほど得た要素をXpathcssセレクタで記述します。

#entrylist-contents-titleというclassを持つh3タグの中のaタグという意味
x = '//h3 [@class="entrylist-contents-title"]/a'
c = 'h3.entrylist-contents-title a'

これを用いて抽出したaタグを一つずつ処理します。

#Xpath
doc.xpath(x).each do |node|
  puts node[:title]
  puts node[:href]
end

#cssセレクタ
doc.css(c).each do |node|
  puts node[:title]
  puts node[:href]
end

#出力は同じ。繰り返しの1番目のみ。
#=>韓国トップの囲碁棋士引退「努力してもAIには勝てない」 | NHKニュース
#=>https://www3.nhk.or.jp/news/html/20191129/k10012195501000.html

Xpathcssセレクタはとてもはまりました。
調べてもクリティカルな情報が見つからず、他のブログのサンプルコード結果から予測して試行錯誤しながらXpathcssセレクタを書いていました。
特にハッシュのように要素を抽出する方法に巡り合えなくて、代替案として文字列に変換して正規表現で抽出するコードを書いたりもしました。

参考
スクレイピングエンジニアなら知っておきたいNokogiriの使い方8選

同値性と同一性

Rubyでは等しいか比較をする時
基本的にレシーバとなるオブジェクトのクラスにオーバーライド(再定義)された

Object#==  
Object#===
Object#equal?

を用います。

それぞれ等しいならtrue、異なるならfalseとなる点は同じですが、判断基準が異なります。

==

==は同値性を判断しています。

a = 'abc'
b = 'abc'
a == b #=>true

同値性とは文字の通り、値が等しいとtrueとなります。
上記は値は等しいですが、別のオブジェクトです

a.object_id #=>70337627390140
b.object_id #=>70337627471920

また、Integerクラス(整数)やFloatクラス(浮動小数点数)等の数値、シンボルは値が等しいと、同じオブジェクトとなります。

int = 1
int2 = 1
int.object_id #=> 3
int2.object_id #=> 3

Object#equal?

Object#equal?は同一性を判断しています。

==の例の文字列も同一性はありません

a.equal? b #=>false

同一のオブジェクトを参照している時のみtrueとなります。

c = a
a.equal? c #=>true

rubyにおいて複製などをする際、オブジェクトの要素はコピーせずにそのまま参照することがあります。

ary = %w[1 2 3] #=>["1", "2", "3"]
#cloneはレシーバのオブジェクトのコピーを出力
ary2 = ary.clone
ary[0].equal? ary2[0] #=> true
ary3 = %w[a b]       #=> ["a", "b"]
#Array#*は要素を引数の数だけ繰り返す
re = ary3 * 2        #=> ["a", "b", "a", "b"]
re[0].equal? re[2]  #=>true
ary3[0].equal? re[0] #=>true

同じオブジェクトを破壊的メソッドを使用した時、参照している全てに影響してしまいます。

#上の続き
ary[0]       #=>"1"
#succは次の数字や文字を出力。succ!は破壊的メソッド
ary[0].succ! #=>"2"
ary          #=>["2", "2", "3"]
ary2         #=>["2", "2", "3"]

===

===は基本的に同値性で判断しますが、case文のバックで用いられるため、クラスによって==とは異なる内容の再定義がされている場合があります。

等式は可換(左右を入れ替えても結果が同じ)ですが、上記の理由で===では非可換となる場合があります。

/[a-z]+/ === 'string'  #=>true
 'string' === /[a-z]+/ #=>false

これはRegexpクラス(正規表現)ではRegexp#===として正規表現にマッチすればtrueとなる再定義されている。しかしStringクラスはString#==と同じ再定義しているため可換性がなくなっています。

参考

Ruby 逆引きハンドブック

論理演算子のショートサーキット

Rubyには以下の論理演算子があります

&&, ||, and, or, not, !

&&andは二項とも真なら真の論理積||orは二項の片方が真なら真の論理和と呼ばれます。 not,!の論理否定は今回は扱いません。
andor&&||と比べて処理の優先度が低いという違いがあります。

論理演算子Rubyでの動作は以下のようになります。

#Rubyではfalse,nil以外のオブジェクトが全てtrue(真)と扱われる
0 && 1 #=> 1

0 || 1 #=> 0

false && true #=> false

#(0 && 1) or 2 と同義
0 && 1 or 2 #=> 1

なぜ以上のような戻り値になるかというと、

1.最後に評価された値が戻り値になる
2.評価が確定したら右辺を評価しない、ショートサーキットという評価法を用いている

この二点によるものです。

ショートサーキットについて具体例を示すと
&&の場合は

false && true #=>false

左辺のfalseによって真になることはないため、右辺は評価せずfalseが最後に評価された値となります。

||の場合は

true || false #=> true

左辺のtrueで真が確定するため、右辺は評価せずtrueが最後に評価された値となります。

制御フロー

論理演算子はこの性質を利用して制御フローに使用することもできます。

#Aが真ならBを実行する
A && B

#Aが偽ならBを実行する
A || B

例(send_emailというメソッドKernelには存在しません)

#ユーザーが存在するならメールを送る
@user && send_mail(@user)

#ユーザーが存在しないなら処理を抜ける
@user || return(@user)

左辺がnil,falseなら右辺を代入する自己代入演算子||=も、この性質を利用したものです。
上の式は下の式のシンタックスシュガーとなります。

a ||= 1

a = (a || 1)

括弧がついている理由は、代入=より優先度の低い演算子の後に代入を行うためです。

演算子メソッド

以下のような演算子は他の言語でも同じような結果になると思います。

10 + 10 #=> 20
10 * 2  #=> 20

ではこれはどうでしょうか

'dog' + 'cat' #=> "dogcat"
'dog' * 3     #=> "dogdogdog"

上記のように同じ演算子なのに振る舞いが異なるのは、それぞれの演算子Integerクラス・Stringクラスのインスタンスメソッドのためです。 再定義が可能な演算子全てがメソッドで実装されています。

上記の構文は以下のようなメソッドのシンタックスシュガー(同じ動きでわかりやすい構文)です。

#違いをわかりやすくするために()を用いています
10.+(10)         #=> 20
10.*(2)          #=> 20
'dog'.+('cat')   #=> "dogcat"
'dog'.*(3)       #=> "dogdogdog"

演算子メソッドを再定義は以下のようにします。

#二項演算子
def 演算子(引数) #オブジェクト + 引数(+なら)
  #処理
end
#単項演算子
def 演算子@ #+オブジェクト(+なら)
  #処理
end

Rubyオープンクラスなので、組み込みのライブラリにメソッドの追加や変更が簡単に行えます。
既存のクラスを再定義するだけで、同名の部分は変更し、そうでない部分は追加されます。

#Stringクラスに-メソッドは存在しません

class String
  def -(string)
    unless string.class == String
      return self
    end
    self.gsub(string, '')
  end
end

#完全一致した文字列を取り除く
'string' - 'ring' #=>"st"

参考

Rubyリファレンス(演算子メソッドの定義)
Rubyリファレンス(演算子式)

attr_accessorとは

初めまして、zooと申します。

約2ヶ月前にRubyの勉強を始めた初学者ですが、週一回を目標にRubyを中心にアウトプットしていきたいと思います。

今回はattr_accessorについてです。

Module#attr_accessor

Rubyには生成したインスタンスから直接インスタンス変数を読み書きできません。

そんなprivateなインスタンス変数の読み書きするゲッターメソッド・セッターメソッドを自動で定義してくれるメソッドです。定義をするだけなので戻り値はnilとなります

定義は、インスタンス変数名と同名のシンボルを引数とします。複数の引数がある場合はカンマで繋げます。
基本はクラス直下ですが、メソッド内でも一回呼び出せばどこで定義しても反映されます。 クラス直下ではレシーバを明示的に書かなくても自動的にクラス自身になりますが、メソッド内では明示的にレシーバを書かないとインスタンス自身をレシーバにしてしまうので注意してください。

attr_accessor :インスタンス変数名

def メソッド名
  クラス名.attr_accessor :インスタンス変数名
end

具体的な使用方法

class User
  attr_accessor :name
  def initialize(name)
    @name = name
  end

user = User.new('dog')
user.name #=> "dog"
user.name = 'cat'    
user.name #=> "cat"

ゲッターメソッドのみを定義するattr_reader
セッターメソッドのみを定義するattr_writerも同じく存在します

また、attr_accessorは以下と同義です。

class User
  def initialize(name)
    @name =name
  end

#ゲッターメソッド
#Rubyのメソッドの戻り値は最後に評価された値なので、@nameが戻り値となります
  def name 
    @name
  end

#セッターメソッド
#Rubyでは=を最後につけてメソッドを定義すると、変数の代入のようにメソッドを呼び出せます。変わるのは見た目だけです。
  def name=(value) 
    @name = value
  end
end

#結果は同じ
user = User.new('dog')
user.name #=> "dog"
user.name = 'cat'    
user.name #=> "cat"

Moduleクラスのサブクラスの、Classクラスのオブジェクトである新規に作成する全てのクラスがこのメソッドを使えます。

...
User.class   #=>Class

参考

プロを目指す人のためのRuby入門
Rubyリファレンス(attr_accessor)