OpenWeatherMapから地名で天気情報を取得して画像ファイルに出力する

# はじめに
　ここの[アドベントカレンダー](https://adventar.org/calendars/5627)のやつです。

　マストドン上で稼働している[自作bot](https://kiritan.work/@kiri_bot01)の機能に”お天気情報取得”があります。”<地名>の天気を教えて！”とお願いすると、その地域の天気情報を返信してくれるというもので、従来”livedoor天気api”を使用していました。

　ところが、2020年7月31日を以ってこの”livedoor天気api”はサービス終了となり、天気情報の機能が使えなくなってしまいました。

　ということで、再び天気情報機能を使えるようになんとかしようと思います。


# 要件
- 指定された地名の天気情報を取得できること（既存要件）
- Pythonで実装すること（既存要件）※botをPythonで作成しているため
- 天気情報を表やグラフにして画像ファイル（PNG）にすること（追加要件）

※表・グラフの画像ファイル化は前からやってみたかったので、今回合わせてやってみたいと思います。


# 天気情報をどこから取得するか
　調べてみると、[OpenWeatherMap](https://openweathermap.org/)というのが良さそうな感じです。これを使わせてもらいましょう。（ユーザ登録要、無料プランあり）

無料プランで、「60回/分・100万回/月」という制限がありますが、小規模なら十分すぎる感じです。取得できる情報も「現在天気」「1分毎天気」「1時間毎天気」「日毎天気」などこちらも十分そうです。
![スクリーンショット 2020-12-08 11.28.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/985c0850-b8a3-0e32-d692-e57b98d9b37f.png)

APIにはいくつか種類がありますが、”One Call API"を使えば欲しい情報は持ってきてくれそうです。
![スクリーンショット 2020-12-08 11.33.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/d62f3c52-9361-de9d-08e2-264f92bf3a83.png)


実際のAPI使用例を見てみると、"lat"、"lon"に経度・緯度指定が必要そうです。（"apiid"はユーザ登録すればもらえます。）
グローバルなAPIなので、日本語地名による検索はダメそうです。（アルファベットでの地名検索はできるっぽいですが、主要都市だけかも？色々試しましたが諦めた気がします）
![スクリーンショット 2020-12-08 11.37.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/d0a239c7-112d-7b29-697c-94a40953456f.png)

取得結果は以下のような情報が含まれるようです。（詳細は[公式サイトのドキュメント](https://openweathermap.org/api/one-call-api)参照）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/21e3026e-be13-48c5-b1d3-4b2df842bddc.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/b7d73882-386f-4700-7fea-41b4aaa3d627.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/4bfebe08-009f-daf5-58a5-4892ab3765c5.png)


# 地名と緯度・経度の一覧を作る
　前述の通り、天気情報の取得には経度・緯度が必要そうなので、地名を経度・緯度に変換する方法を考えます。

　調べてみると[国土交通省のHP](https://nlftp.mlit.go.jp/isj/)より「位置参照情報 ダウンロードサービス」というものがありました。都道府県毎にダウンロードする必要があるものの、全市区町村単位の位置情報は網羅されていそうです。”地名”はとりあえずは”市区町村名”とすることとします。（街区レベルのものもありますが、ここまで細かいのは今回は不必要と思います）
![スクリーンショット 2020-12-08 13.10.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/18366773-052d-0b07-35c2-a337ab4c6b17.png)

　実際のデータの一部はこんな感じです。住所毎に緯度・経度情報があります。
![スクリーンショット 2020-12-08 13.14.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/6d13f720-b6bf-c1e8-1c70-7cefb02e7c8a.png)

　今回はここまで細かくなくていいかなと思うので、市区町村名でグルーピングして緯度・経度のそれぞれの平均値を求め、その値を市区町村の緯度・経度とする感じで、一覧を作成しました。（使用しない情報は除外）

``` latloc.json 
{
    "徳島県": {
        "徳島市": [
            34.0694450186722,
            134.5442937966806
        ],
        "鳴門市": [
            34.182439379310345,
            134.5660390172414
        ],
        "小松島市": [
            33.994815439999996,
            134.59324916
        ],
"＜省略＞"
    }
}
```


　以上から、天気情報取得の流れは、

1. 市区町村名が指定される

1. 一覧より該当の市区町村の経度・緯度を取得

1. OpenWeatherMap APIに経度・緯度を渡してコールし、天気情報を受け取る

1. 天気情報を表・グラフなどに加工（※次項で説明）して返す

こんな感じで行けそうです。

# Pythonでデータを表・グラフにして、画像ファイルにする
　Pythonでグラフ作成ライブラリといえば、matplotlibとかpandasが挙がりますが（多分）、”表の画像ファイル化”、”画像の挿入（お天気マークを画像に入れたい）"がどうやっても難しそうで難航していました。

　そこで、[plotly](https://plotly.com/python/) という作図ライブラリを使わせてもらいました。これなら、表の画像ファイル化が綺麗にできました。図の挿入もバッチリです。詳細な使い方は他の記事に譲るとして、今回の作例とポイントだけ説明したいと思います。

## 作例１ 表の画像ファイル化
- 図（お天気アイコン）の挿入
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/42ff9b1d-b532-f94e-0766-b735bccc1787.png)


- こちらの表ではUV指数の値によって文字色を変えています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/61cd87a5-0f9c-a9df-8b98-ef328a2ff394.png)


## 作例２ 棒グラフの画像ファイル化
- 棒グラフが取る値の範囲は自動ですが、負の値は取らないため０起点にしています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252429/21b81a66-9f81-08f6-ba95-870807bbbb78.png)


```otenki.py

# -*- 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

# １週間天気
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+"の１週間天気", 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

# ４８時間天気
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+"の４８時間天気", 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

# １時間降水量
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+"の１時間の降水量", 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を叩いたときは降雪量情報は返ってこなかったのですが、今なら返ってくるかもしれないので試してみたいと思います。