はじめに
ここのアドベントカレンダーのやつです。
マストドン上で稼働している自作botの機能に”お天気情報取得”があります。”<地名>の天気を教えて!”とお願いすると、その地域の天気情報を返信してくれるというもので、従来”livedoor天気api”を使用していました。
ところが、2020年7月31日を以ってこの”livedoor天気api”はサービス終了となり、天気情報の機能が使えなくなってしまいました。
ということで、再び天気情報機能を使えるようになんとかしようと思います。
要件
- 指定された地名の天気情報を取得できること(既存要件)
- Pythonで実装すること(既存要件)※botをPythonで作成しているため
- 天気情報を表やグラフにして画像ファイル(PNG)にすること(追加要件)
※表・グラフの画像ファイル化は前からやってみたかったので、今回合わせてやってみたいと思います。
天気情報をどこから取得するか
調べてみると、OpenWeatherMapというのが良さそうな感じです。これを使わせてもらいましょう。(ユーザ登録要、無料プランあり)
無料プランで、「60回/分・100万回/月」という制限がありますが、小規模なら十分すぎる感じです。取得できる情報も「現在天気」「1分毎天気」「1時間毎天気」「日毎天気」などこちらも十分そうです。
APIにはいくつか種類がありますが、”One Call API"を使えば欲しい情報は持ってきてくれそうです。
実際のAPI使用例を見てみると、"lat"、"lon"に経度・緯度指定が必要そうです。("apiid"はユーザ登録すればもらえます。)
グローバルなAPIなので、日本語地名による検索はダメそうです。(アルファベットでの地名検索はできるっぽいですが、主要都市だけかも?色々試しましたが諦めた気がします)
取得結果は以下のような情報が含まれるようです。(詳細は公式サイトのドキュメント参照)
地名と緯度・経度の一覧を作る
前述の通り、天気情報の取得には経度・緯度が必要そうなので、地名を経度・緯度に変換する方法を考えます。
調べてみると国土交通省のHPより「位置参照情報 ダウンロードサービス」というものがありました。都道府県毎にダウンロードする必要があるものの、全市区町村単位の位置情報は網羅されていそうです。”地名”はとりあえずは”市区町村名”とすることとします。(街区レベルのものもありますが、ここまで細かいのは今回は不必要と思います)
実際のデータの一部はこんな感じです。住所毎に緯度・経度情報があります。
今回はここまで細かくなくていいかなと思うので、市区町村名でグルーピングして緯度・経度のそれぞれの平均値を求め、その値を市区町村の緯度・経度とする感じで、一覧を作成しました。(使用しない情報は除外)
{
"徳島県": {
"徳島市": [
34.0694450186722,
134.5442937966806
],
"鳴門市": [
34.182439379310345,
134.5660390172414
],
"小松島市": [
33.994815439999996,
134.59324916
],
"<省略>"
}
}
以上から、天気情報取得の流れは、
-
市区町村名が指定される
-
一覧より該当の市区町村の経度・緯度を取得
-
OpenWeatherMap APIに経度・緯度を渡してコールし、天気情報を受け取る
-
天気情報を表・グラフなどに加工(※次項で説明)して返す
こんな感じで行けそうです。
Pythonでデータを表・グラフにして、画像ファイルにする
Pythonでグラフ作成ライブラリといえば、matplotlibとかpandasが挙がりますが(多分)、”表の画像ファイル化”、”画像の挿入(お天気マークを画像に入れたい)"がどうやっても難しそうで難航していました。
そこで、plotly という作図ライブラリを使わせてもらいました。これなら、表の画像ファイル化が綺麗にできました。図の挿入もバッチリです。詳細な使い方は他の記事に譲るとして、今回の作例とポイントだけ説明したいと思います。
作例1 表の画像ファイル化
作例2 棒グラフの画像ファイル化
# -*- coding: utf-8 -*-
import os
from pprint import pprint as pp
import requests
import json
from bs4 import BeautifulSoup
from time import sleep
from collections import defaultdict
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
import urllib.request
from pytz import timezone
from datetime import datetime, timedelta
from PIL import Image
import locale
locale.setlocale(locale.LC_TIME, 'ja_JP.UTF-8')
CITY_LATLOC_PATH = "dic/city_latloc.json"
ICON_DIR = "tenki_icon"
IMAGE_H = 1260
IMAGE_W = 800
BG_COLOR = "#100500"
LINE_COLOR = "#5f5050"
FONT_COLOR = "#fff5f3"
WEATHER_IMAGE_PATH = "./media"
# 天気メイン
def get_tenki(quary, appid):
with open(CITY_LATLOC_PATH, 'r') as fr:
city_latloc_dict = json.load(fr)
os.makedirs(WEATHER_IMAGE_PATH, exist_ok=True)
os.makedirs(os.path.join(WEATHER_IMAGE_PATH, ICON_DIR), exist_ok=True)
hit_tdfk = [tdfk for tdfk in city_latloc_dict.keys() if tdfk in quary]
hit_skcs = []
hit_lat = 0.0
hit_loc = 0.0
if len(hit_tdfk) == 1:
# 都道府県指定あり
for k_skcs, latloc in city_latloc_dict[hit_tdfk[0]].items():
if quary.split(hit_tdfk[0])[-1] in [k_skcs, k_skcs[:-1]]:
hit_skcs.append(hit_tdfk[0] + k_skcs)
hit_lat = latloc[0]
hit_loc = latloc[1]
else:
# 都道府県指定なし
for k_tdfk, v in city_latloc_dict.items():
for k_skcs, latloc in v.items():
if quary in [k_skcs, k_skcs[:-1]]:
hit_skcs.append(k_tdfk + k_skcs)
hit_lat = latloc[0]
hit_loc = latloc[1]
if len(hit_skcs) == 0:
return 900, None, None, None # 見つからなかった場合
elif len(hit_skcs) > 1:
return 901, None, "、".join(hit_skcs), None # 複数見つかった場合
# 天気情報取得
url = "http://api.openweathermap.org/data/2.5/onecall"
payload = {
"lat": hit_lat, "lon": hit_loc,
"lang": "ja",
"units": "metric",
"APPID": appid}
tenki_data = requests.get(url, params=payload).json()
tz = timezone(tenki_data['timezone'])
skcs_name = hit_skcs[0]
return 0, hit_skcs[0]+"の天気", \
make_weather_image_current(tenki_data['current'], skcs_name, tz),\
[make_weather_image_daily(tenki_data['daily'], skcs_name, tz),
make_weather_image_hourly(tenki_data['hourly'], skcs_name, tz),
make_weather_image_minutely(tenki_data['minutely'], skcs_name, tz)]
# UV指数
def get_uvi_info(uvi):
if uvi < 3.0:
return f"弱い", "rgb(204,242,255)"
elif uvi < 6.0:
return f"中程度", "rgb(255,255,204)"
elif uvi < 8.0:
return f"強い", "rgb(255,204,153)"
elif uvi < 11.0:
return f"非常に強い", "rgb(255,101,101)"
else:
return f"極端に強い", "rgb(255,101,255)"
# 16方位名
def get_wind_deg_name(deg):
import math
dname = ["北", "北北東", "北東", "東北東", "東", "東南東", "南東", "南南東",
"南", "南南西", "南西", "西南西", "西", "西北西", "北西", "北北西", "北"]
return dname[int((deg + 11.25)/22.5)]
# 現在の天気
def make_weather_image_current(wd, skcs_name, tz):
tmp_dict = {}
tmp_dict['曇り%'] = wd['clouds']
tmp_dict['日時'] = datetime.fromtimestamp(
wd['dt'], tz=tz).strftime("%m/%d %H:%M")
tmp_dict['体感気温℃'] = f"{float(wd['feels_like']):.1f}"
tmp_dict['湿度%'] = wd['humidity']
tmp_dict['気圧hPa'] = wd['pressure']
tmp_dict['日の出'] = datetime.fromtimestamp(
wd['sunrise'], tz=tz).strftime("%H:%M:%S")
tmp_dict['日の入'] = datetime.fromtimestamp(
wd['sunset'], tz=tz).strftime("%H:%M:%S")
tmp_dict['気温℃'] = f"{float(wd['temp']):.1f}"
if 'uvi' in wd:
uv_text, _ = get_uvi_info(wd['uvi'])
tmp_dict['UV指数'] = f"{uv_text}({float(wd['uvi']):.1f})"
else:
tmp_dict['UV指数'] = "−"
tmp_dict['天気'] = wd['weather'][0]['description']
ret_text = f"現在({tmp_dict['日時']}時点)の{skcs_name}の天気\n"
ret_text += f"{tmp_dict['天気']}:"
ret_text += f"気温{tmp_dict['気温℃']}℃/湿度{tmp_dict['湿度%']}%/体感気温{tmp_dict['体感気温℃']}℃\n"
ret_text += f"気圧{tmp_dict['気圧hPa']}hPa/UV指数「{tmp_dict['UV指数']}」/雲率{tmp_dict['曇り%']}%\n"
ret_text += f"日の出時刻は{tmp_dict['日の出']}、日の入時刻は{tmp_dict['日の入']}\n"
ret_text += f" by OpenWeatherMap API https://openweathermap.org/"
return ret_text
# 1週間天気
def make_weather_image_daily(wd, skcs_name, tz):
tenki_data_list = []
for l1 in wd:
tmp_dict = {}
tmp_dict['日付'] = datetime.fromtimestamp(
l1['dt'], tz=tz).strftime("%m/%d(%a)")
tmp_dict['☀☁'] = "" # お天気アイコン表示用
tmp_dict['icon'] = l1['weather'][0]['icon']
tmp_dict['天気'] = l1['weather'][0]['description']
tmp_dict['最高気温℃'] = f"{float(l1['temp']['max']):.1f}"
tmp_dict['最低気温℃'] = f"{float(l1['temp']['min']):.1f}"
tmp_dict['降水確率%'] = int(float(l1['pop'])*100)
tmp_dict['UV指数'], tmp_dict['uv_color'] = get_uvi_info(l1['uvi'])
tmp_dict['font_color'] = FONT_COLOR
tmp_dict['風速m/s'] = f"{float(l1['wind_speed']):.1f}"
tmp_dict['風向'] = get_wind_deg_name(l1['wind_deg'])
tmp_dict['気圧hPa'] = f"{int(l1['pressure']):,}"
tenki_data_list.append(tmp_dict)
df_temp = pd.json_normalize(tenki_data_list)
df = df_temp[['日付', '☀☁', '天気', '最高気温℃',
'最低気温℃', '降水確率%', '風向', '風速m/s', '気圧hPa', 'UV指数']] # テーブルの作成
fig = go.Figure(data=[go.Table(
columnwidth=[25, 10, 25, 20, 20, 20, 20, 20, 20, 20], # カラム幅の変更
header=dict(values=df.columns, align='center', font=dict(color=FONT_COLOR, size=18), height=30,
line_color=LINE_COLOR, fill_color=BG_COLOR),
cells=dict(values=df.values.T, align='center', font=dict(color=[df_temp.font_color]*9 + [df_temp.uv_color], size=18), height=30, # ポイント:UV指数カラムの色指定
line_color=LINE_COLOR, fill_color=BG_COLOR),
)],
layout=dict(margin=dict(l=0, r=0, t=30, b=0), paper_bgcolor=BG_COLOR,
title=dict(
text=skcs_name+"の1週間天気", x=0.5, y=1.0, font=dict(color=FONT_COLOR, size=24), xanchor='center', yanchor='top', pad=dict(l=0, r=0, t=5, b=0))
)
)
# ポイント:お天気アイコン貼り付け。後から画像を乗せるイメージ。位置調整は手動。
for i in range(1, len(df)+1, 1):
# 天気アイコン取得
icon_name = df_temp['icon'][i-1]
icon_image = get_weather_icon(icon_name)
fig.add_layout_image(
dict(source=icon_image, x=0.125, y=(1.0-1.0/(len(df)+1)*(i+0.5))))
fig.update_layout_images(dict(
xref="paper", yref="paper", sizex=0.22, sizey=0.21, xanchor="left", yanchor="middle"))
imagepath = os.path.join(WEATHER_IMAGE_PATH, "tmp_weather_d.png")
fig.write_image(imagepath, height=30*(len(df)+2), width=1100, scale=1)
return imagepath
# 48時間天気
def make_weather_image_hourly(wd, skcs_name, tz):
tenki_data_list = []
for l1 in wd:
tmp_dict = {}
tmp_dict['日時'] = datetime.fromtimestamp(
l1['dt'], tz=tz).strftime("%m/%d %H時")
tmp_dict['☀☁'] = "" # お天気アイコン表示用
tmp_dict['icon'] = l1['weather'][0]['icon']
tmp_dict['天気'] = l1['weather'][0]['description']
tmp_dict['気温℃'] = f"{float(l1['temp']): .1f}"
tmp_dict['湿度%'] = l1['humidity']
tmp_dict['体感気温℃'] = f"{float(l1['feels_like']):.1f}"
tmp_dict['降水確率%'] = int(float(l1['pop'])*100)
tmp_dict['風向'] = get_wind_deg_name(l1['wind_deg'])
tmp_dict['風速m/s'] = f"{float(l1['wind_speed']):.1f}"
tmp_dict['気圧hPa'] = f"{int(l1['pressure']):,}"
tenki_data_list.append(tmp_dict)
df_temp = pd.json_normalize(tenki_data_list)
df = df_temp[['日時', '☀☁', '天気', '気温℃', '湿度%',
'体感気温℃', '降水確率%', '風向', '風速m/s', '気圧hPa']] # テーブルの作成
fig = go.Figure(data=[go.Table(
# columnorder=[10, 20, 30, 40, 50, 25, 70],
columnwidth=[25, 10, 25, 20, 20, 20, 20, 20, 20, 20], # カラム幅の変更
header=dict(values=df.columns, align='center', font=dict(color=FONT_COLOR, size=18), height=30,
line_color=LINE_COLOR, fill_color=BG_COLOR),
cells=dict(values=df.values.T, align='center', font=dict(color=FONT_COLOR, size=18), height=30,
line_color=LINE_COLOR, fill_color=BG_COLOR),
)],
layout=dict(margin=dict(l=0, r=0, t=30, b=0), paper_bgcolor=BG_COLOR,
title=dict(
text=skcs_name+"の48時間天気", x=0.5, y=1.0, font=dict(color=FONT_COLOR, size=24), xanchor='center', yanchor='top', pad=dict(l=0, r=0, t=5, b=0))
),
)
# お天気アイコン貼り付け
for i in range(1, len(df)+1, 1):
# 天気アイコン取得
icon_name = df_temp['icon'][i-1]
icon_image = get_weather_icon(icon_name)
fig.add_layout_image(
dict(source=icon_image, x=0.125, y=(1.0-1.0/49.0*(i+0.5))))
fig.update_layout_images(dict(
xref="paper", yref="paper", sizex=0.05, sizey=0.05, xanchor="left", yanchor="middle"))
imagepath = os.path.join(WEATHER_IMAGE_PATH, "tmp_weather_h.png")
fig.write_image(imagepath, height=30*(48+2), width=1100, scale=1)
return imagepath
# 1時間降水量
def make_weather_image_minutely(wd, skcs_name, tz):
tenki_data_list = []
for l1 in wd:
tmp_dict = {}
tmp_dict['時刻'] = datetime.fromtimestamp(
l1['dt'], tz=tz).strftime("%H:%M")
tmp_dict['降水量mm'] = l1['precipitation']
tenki_data_list.append(tmp_dict)
df_temp = pd.json_normalize(tenki_data_list)
df = df_temp[['時刻', '降水量mm']].round({'降水量mm': 2}) # テーブルの作成
fig = go.Figure([go.Bar(x=df['時刻'], y=df['降水量mm'], text=df['降水量mm'], textposition='auto',
marker=dict(color='rgba(150,150,255,0.8)'),
y0=0
)],
layout=dict(margin=dict(l=0, r=0, t=30, b=0), paper_bgcolor=BG_COLOR, plot_bgcolor=BG_COLOR,
title=dict(
text=skcs_name+"の1時間の降水量", x=0.5, y=1.0, font=dict(color=FONT_COLOR, size=24), xanchor='center', yanchor='top', pad=dict(l=0, r=0, t=5, b=0)),
font=dict(color=FONT_COLOR, size=18),
xaxis=dict(title='時刻', showgrid=False),
yaxis=dict(title='降水量mm', showgrid=False,
rangemode='nonnegative') # ポイント:負の値は取らないグラフなので0起点にする
)
)
imagepath = os.path.join(WEATHER_IMAGE_PATH, "tmp_weather_m.png")
fig.write_image(imagepath, height=400, width=1600, scale=1)
return imagepath
# 天気アイコン取得
def get_weather_icon(icon_name):
icon_image_path = os.path.join(
WEATHER_IMAGE_PATH, ICON_DIR, icon_name + ".png")
if os.path.exists(icon_image_path):
pass
else:
url = f"http://openweathermap.org/img/wn/{icon_name}@4x.png"
with urllib.request.urlopen(url) as web_file:
data = web_file.read()
with open(icon_image_path, mode='wb') as local_file:
local_file.write(data)
return Image.open(icon_image_path)
if __name__ == '__main__':
pp(get_tenki("札幌市中央区", "<<api_key>>"))
おわりに
とりあえず、イメージした表・グラフの形になりました。
(アドベントカレンダー感はありませんが、まあよいでしょう。)
夏に実装したのですが、いつの間にか冬になってました。夏にAPIを叩いたときは降雪量情報は返ってこなかったのですが、今なら返ってくるかもしれないので試してみたいと思います。