MORITOMOMENT

登山好きエンジニアのテックブログ

プログラミング・アウトドア関連を中心に発信

Rubyで天気予報のプログラムを作成

こんにちは。

今回はRubyで天気予報のプログラムを作成してみました。

この記事を読む前に

もし読者の方が、次の節で述べている背景に合致しているのであれば、ネタバレになってしまいます。

ですので、もし自力で挑戦したい場合は、プログラム全体をさっと飛ばして、アルゴリズムを参考にするなどしてください。

背景 なぜ天気予報?

僕がTwitterでフォローしているゲスエンジニアとださんがこのようなツイートをしていました。

noteは問題とヒントのみ無料公開されています。

ちょうど最近チェリー本(プロを目指す人のためのRuby入門)を読んだばかりなので、 腕試しに挑戦してみました。

実現したい事

天気予報なので、

〇〇日、どこどこは晴れでしょう。

のように直近の日にち(今日、明日、明後日)の任意の場所の天気を知る事ができたら嬉しいです。

なので実装したいポイントとしては、

  • 任意の地名の天気がわかる。
  • 今日、明日、明後日の天気がわかる。

作成プログラム

作成したプログラムを次に示します。

require 'uri'
require 'open-uri'
require 'net/http'
require 'json'
require 'kconv'
require "rexml/document"
require 'active_support'
require 'active_support/core_ext'


# key:地名, value:city idのハッシュを作成
# http://kzfm-s.hateblo.jp/entry/2015/06/07/014735の記事を参考
xml_url = open("http://weather.livedoor.com/forecast/rss/primary_area.xml").read.toutf8
xml_doc = REXML::Document.new(xml_url)
xml_hash = Hash.from_xml(xml_doc.to_s)
city_id_hash = {}
xml_hash["rss"]["channel"]["source"]["pref"].each do |key|
  count = 0
  #ハッシュ作成
  while key["city"][count] != nil do
    city_id_hash[key["city"][count]["title"]] = key["city"][count]["id"] 
    count += 1
  end
end

#任意の地名をコマンドライン入力より受け取る
while true
  p "日本のどこの天気が知りたいですか?"
  city_name = gets.chomp! #getsで含まれた\nは削除して扱う
  break if city_id_hash.has_key?(city_name) #実際にハッシュに登録されてたらループを抜ける
  puts "正しい地名を述べてください"
end
city_id = city_id_hash[city_name]

p "いつの天気が知りたいですか?今日なら0、明日なら1、明後日なら2を入力してください"
day_number = gets.to_i

#取得したcity idで見たい天気の情報を取得する
url = "http://weather.livedoor.com/forecast/webservice/json/v1?city=#{city_id}"
uri = URI.parse(url)
res = Net::HTTP.get(uri)
res_json = JSON.parse(res)

# res_jsonから必要な情報
# 日にち
# 場所
# 天気  を抜き出す
day = res_json["forecasts"][day_number]["date"].split("-")[-1]
location = res_json["location"]["city"]
telop    = res_json["forecasts"][day_number]["telop"]

puts "#{day}日、#{location}#{telop}でしょう"

実行結果例 岐阜の明日の天気 

※今日が2/28であることを仮定

"日本のどこの天気が知りたいですか?" 
岐阜 ←コマンドライン入力
"いつの天気が知りたいですか?今日なら0、明日なら1、明後日なら2を入力してください"
1 ←コマンドライン入力
01日、岐阜は晴れでしょう

実行結果例 東京の明日の天気 

"日本のどこの天気が知りたいですか?"
東京
"いつの天気が知りたいですか?今日なら0、明日なら1、明後日なら2を入力してください"
1
01日、東京は曇のち晴でしょう

上二つの実行例は http://weather.livedoor.com/ で正しい結果であると確認できます。

プログラムの解説

今回の天気予報プログラムのアルゴリズム

  1. 天気の最新情報(xml形式)を http://weather.livedoor.com/forecast/rss/primary_area.xml から受け取る。
  2. 受け取ったxmlから”地名”=>"city id"のハッシュを作成。
  3. コマンドライン入力から任意の地名を受け取る。もし地名がハッシュのキーとして存在しなければやり直す。
  4. コマンドライン入力によりいつの天気を知りたいか受け取る。
  5. ライブドアAPIを用いて天気情報を取得する(note参考)。
  6. 結果出力

ステップ1, 2について

該当プログラムは次の部分です。

# key:地名, value:city idのハッシュを作成
# http://kzfm-s.hateblo.jp/entry/2015/06/07/014735の記事を参考
xml_url = open("http://weather.livedoor.com/forecast/rss/primary_area.xml").read.toutf8
xml_doc = REXML::Document.new(xml_url)
xml_hash = Hash.from_xml(xml_doc.to_s)
city_id_hash = {}
xml_hash["rss"]["channel"]["source"]["pref"].each do |key|
  count = 0
  #ハッシュ作成
  while key["city"][count] != nil do
    city_id_hash[key["city"][count]["title"]] = key["city"][count]["id"] 
    count += 1
  end
end

考える際にはこちらの記事を参考にさせていただきました。ありがとうございました。

kzfm-s.hateblo.jp

この部分の解説については、Qiitaに投稿していますのでそちらをご参照ください。

qiita.com

 ステップ3, 4について

該当プログラムは次の部分です。

#任意の地名をコマンドライン入力より受け取る
while true
  p "日本のどこの天気が知りたいですか?"
  city_name = gets.chomp! #getsで含まれた\nは削除して扱う
  break if city_id_hash.has_key?(city_name) #実際にハッシュに登録されてたらループを抜ける
  puts "正しい地名を述べてください"
end
city_id = city_id_hash[city_name]

p "いつの天気が知りたいですか?今日なら0、明日なら1、明後日なら2を入力してください"
day_number = gets.to_i

while文はエラー回避のために用いています。

例えば、岐阜の天気が知りたいから、岐阜県と入力したとします。 しかしながら岐阜県の天気のキーは”岐阜”と”高山”のみ存在しており、 ”岐阜県”というキーはハッシュには存在してません。

city_name="岐阜県" のまま実行を継続するとエラーとなってしまいます。

そのため任意の地名がハッシュのキーとして存在する場合のみ、ループを抜けれるようにすることでエラーを回避しています。

次に、いつの天気を知りたいか入力してもらうとき、 今日なら0を、明日なら1を、明後日なら2を入力させてますが、この理由については次に説明します。

ステップ4, 5について

該当プログラムは次の部分です。

#取得したcity idで見たい天気の情報を取得する
url = "http://weather.livedoor.com/forecast/webservice/json/v1?city=#{city_id}"
uri = URI.parse(url)
res = Net::HTTP.get(uri)
res_json = JSON.parse(res)

# res_jsonから必要な情報
# 日にち
# 場所
# 天気  を抜き出す
day = res_json["forecasts"][day_number]["date"].split("-")[-1]
location = res_json["location"]["city"]
telop    = res_json["forecasts"][day_number]["telop"]

puts "#{day}日、#{location}#{telop}でしょう"

まずステップ5についてですが、APIの使い方をnoteのヒントを参考にしたので説明は必要ないと思います。

式展開を使うことで、任意のcity idを用いて天気の情報を取得できます。 (これがしたいがために、ステップ1, 2でめんどくさいことをしました笑)

最後に天気予報をputsで出力するのですが、必要な情報をres_jsonから抜き出します。

必要な情報を抜き出すためにはres_jsonがどのようなデータ構造になっているか調べる必要があります。

ということで、岐阜(city_id=210010)を参照した場合のres_jsonのデータ構造を示します。 データ構造は次のようなハッシュになっていました。わかりやすいよう出力フォーマットを変更しています。

※2月26日のデータになってます。日付が変わっても同様に動作します。

# キー、 バリュー
pinpointLocations, [{"link"=>"http://weather.livedoor.com/area/forecast/2120100", "name"=>"岐阜市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120200", "name"=>"大垣市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120400", "name"=>"多治見市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120500", "name"=>"関市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120600", "name"=>"中津川市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120700", "name"=>"美濃市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120800", "name"=>"瑞浪市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2120900", "name"=>"羽島市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121000", "name"=>"恵那市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121100", "name"=>"美濃加茂市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121200", "name"=>"土岐市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121300", "name"=>"各務原市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121400", "name"=>"可児市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121500", "name"=>"山県市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121600", "name"=>"瑞穂市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121800", "name"=>"本巣市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2121900", "name"=>"郡上市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2122100", "name"=>"海津市"}, {"link"=>"http://weather.livedoor.com/area/forecast/2130200", "name"=>"岐南町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2130300", "name"=>"笠松町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2134100", "name"=>"養老町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2136100", "name"=>"垂井町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2136200", "name"=>"関ケ原町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2138100", "name"=>"神戸町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2138200", "name"=>"輪之内町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2138300", "name"=>"安八町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2140100", "name"=>"揖斐川町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2140300", "name"=>"大野町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2140400", "name"=>"池田町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2142100", "name"=>"北方町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150100", "name"=>"坂祝町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150200", "name"=>"富加町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150300", "name"=>"川辺町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150400", "name"=>"七宗町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150500", "name"=>"八百津町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150600", "name"=>"白川町"}, {"link"=>"http://weather.livedoor.com/area/forecast/2150700", "name"=>"東白川村"}, {"link"=>"http://weather.livedoor.com/area/forecast/2152100", "name"=>"御嵩町"}]
link, http://weather.livedoor.com/area/forecast/210010
forecasts, [{"dateLabel"=>"今日", "telop"=>"晴れ", "date"=>"2019-02-26", "temperature"=>{"min"=>nil, "max"=>nil}, "image"=>{"width"=>50, "url"=>"http://weather.livedoor.com/img/icon/1.gif", "title"=>"晴れ", "height"=>31}}, {"dateLabel"=>"明日", "telop"=>"曇り", "date"=>"2019-02-27", "temperature"=>{"min"=>{"celsius"=>"4", "fahrenheit"=>"39.2"}, "max"=>{"celsius"=>"13", "fahrenheit"=>"55.4"}}, "image"=>{"width"=>50, "url"=>"http://weather.livedoor.com/img/icon/8.gif", "title"=>"曇り", "height"=>31}}, {"dateLabel"=>"明後日", "telop"=>"曇のち雨", "date"=>"2019-02-28", "temperature"=>{"min"=>nil, "max"=>nil}, "image"=>{"width"=>50, "url"=>"http://weather.livedoor.com/img/icon/13.gif", "title"=>"曇のち雨", "height"=>31}}]
location, {"city"=>"岐阜", "area"=>"東海", "prefecture"=>"岐阜県"}
publicTime, 2019-02-26T17:00:00+0900
copyright, {"provider"=>[{"link"=>"http://tenki.jp/", "name"=>"日本気象協会"}], "link"=>"http://weather.livedoor.com/", "title"=>"(C) LINE Corporation", "image"=>{"width"=>118, "link"=>"http://weather.livedoor.com/", "url"=>"http://weather.livedoor.com/img/cmn/livedoor.gif", "title"=>"livedoor 天気情報", "height"=>26}}
title, 岐阜県 岐阜 の天気
description, {"text"=>" 本州付近は、大陸に中心を持つ高気圧に緩やかに覆われています。\n\n 岐阜県は、おおむね晴れています。\n\n 今夜は、高気圧に覆われて、晴れるでしょう。\n\n 明日は、はじめ高気圧に覆われて晴れますが、気圧の谷や南から湿った空\n気の影響で朝から曇りとなるでしょう。", "publicTime"=>"2019-02-26T16:37:00+0900"}

まずdayとtelopのデータはどこに埋もれているか説明します。 上記データ構造を見ると、res_jsonの"forecasts"キーの値に[ "今日の天気のハッシュ", "明日の天気のハッシュ", "明後日の天気のハッシュ"]があります。

そのため今日の天気が欲しい場合は、res_json["forecasts"][0]を参照すれば良いとわかります。 そしてres_json["forecasts"][0]自体がハッシュとなっているため、dayとtelopを該当するキーを用いることで取り出せます。

ここまで聞くと、今日、明日、明後日の情報をコマンドライン入力で、0, 1, 2で指定してた意味が理解していただけると思います。 

dayの取り出し方はday = res_json["forecasts"][day_number]["date"].split("-")[-1]となっており、少し複雑になっているので簡単に説明しておきます。

res_json["forecasts"][0]["date"]の値は2019-02-26です。 欲しい情報は26のみです。

嬉しいことに"date"の値のフォーマットが"yyyy-mm-dd"となっていますので、splitメソッドを使うことで配列[yyyy, mm, dd]が作成できます。そして"dd"は配列末尾にあるため、-1番目の要素を参照することでdayの情報を取ることができます。

実装の問題点

ステップ1, 2の部分の実行速度が遅い。

解答と自分のプログラムを比較してみた感想

書き方は十人十色ですね。

特にday情報取得の実装方法は個人差がでるかもしれません。

気になる方はぜひ挑戦してみましょう。

参考書籍

あと挑戦してみて自分がRubyを使えていることに嬉しく思っています。

Ruby の知識は、伊藤 淳一さんのプロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法までで勉強したのですが、非常に力がついているような気がします。

プログラミング経験者で新たにRubyを始めようと思っている方はぜひお手にとってみてはいかがでしょうか。