「出かけようとしたら雨が降ってた……」を防ぐため、「1時間毎の天気予報」を1時間毎にSlack通知してみた

気象情報APIの「OpenWeatherMap」を使って1時間毎の天気予報を取得し、その内容を1時間毎にSlack通知する仕組みをサーバーレスで作ってみました。
2020.06.15

天気予報を毎日見ていますか? 私は見ていません。そのせいで「夕方になったら雨が降ってきた……。スーパーは昼に行けばよかった……。」といった後悔は1度や2度ではありません。そこで考えました。天気予報をSlackに通知すれば否が応でも見るだろう、と。

通知内容と頻度

  • 通知内容
    • 今日の天気:1時間ごと(12時間分)
    • 週間の天気:1日ごと(8日分)
  • 通知頻度
    • 毎日0時から1時間毎

実際の様子がこちらです。

天気予報がSlackに通知されている様子

システム概要

超シンプルなサーバーレス構成です。

概要図

通知先の準備

Slackチャンネルを作成

notify-weather-tokyoチャンネルを作成しました。

通知先のSlackチャンネルを作成する

Slackアプリを追加

https://api.slack.com/apps にアクセスし、Slackアプリを作成します。

Slackアプリを作成する

Incoming Webhooksを有効化

Incoming Webhooks設定をONにします。

Incoming Webhookを有効にする

Incoming Webhookの作成 & 通知先チャンネル設定

先ほど作成したチャンネルを通知先に設定します。

Incoming Webhookを追加する

通知先チャンネルとIncoming Webhookを紐付ける

設定完了です。URLはあとでパラメータストアに登録します。

Incoming Webhookを追加した

OpenWeatherのAPIキーを取得

OpenWeatherMapにサインアップし、APIキーを取得します。

サーバーレスアプリの作成

パラメータストアに値を追加

AWS Systems Managerのパラメータストアに次の値を追加します。

  • 緯度
  • 経度
  • Slackの通知先URL
  • OpenWeatherMapのAPIキー

緯度経度は東京を設定しています。

緯度

aws ssm put-parameter \
    --type 'String' \
    --name '/Notify-Weather-To-Slack-App/tokyo/Latitude' \
    --value '35.681236'

経度

aws ssm put-parameter \
    --type 'String' \
    --name '/Notify-Weather-To-Slack-App/tokyo/Longitude' \
    --value '139.767125'

Slackの通知先URL

URLの先頭にhttps://があると、コマンド実行が失敗するため取り除いています。

aws ssm put-parameter \
    --type 'String' \
    --name '/Notify-Weather-To-Slack-App/tokyo/slack_url' \
    --value 'hooks.slack.com/services/xxxxx/yyyyy/zzzzz'

OpenWeatherのAPIキー

aws ssm put-parameter \
    --type 'String' \
    --name '/Notify-Weather-To-Slack-App/apikey' \
    --value 'xxxx'

AWS SAMプロジェクトの作成

sam init --runtime python3.7 --name Notify-Weather-To-Slack-App

テンプレートファイル

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Notify-Weather-To-Slack-App

Parameters:
  TargetName:
    Type: String

  Latitude:
    Type: AWS::SSM::Parameter::Value<String>

  Longitude:
    Type: AWS::SSM::Parameter::Value<String>

  ApiKey:
    Type: AWS::SSM::Parameter::Value<String>

  SlackUrl:
    Type: AWS::SSM::Parameter::Value<String>

Resources:
  NotifyWeatherFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub notify-weather-to-slack-${TargetName}-function
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.7
      Timeout: 10
      Environment:
        Variables:
          LATITUDE: !Ref Latitude
          LONGITUDE: !Ref Longitude
          API_KEY: !Ref ApiKey
          SLACK_URL: !Ref SlackUrl
      Events:
        NotifySlack:
          Type: Schedule
          Properties:
            Schedule: cron(0 0/1 * * ? *) # 日本時間で毎日0時から1時間毎

  NotifyWeatherFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${NotifyWeatherFunction}"

Lambdaコード

app.py

import json
import locale
import os
import requests
from datetime import datetime, timezone, timedelta

LATITUDE = os.environ['LATITUDE']
LONGITUDE = os.environ['LONGITUDE']
API_KEY = os.environ['API_KEY']
SLACk_URL = os.environ['SLACK_URL']

def lambda_handler(event, context):
    main()

def main():
    # 曜日表記を日本語にする
    locale.setlocale(locale.LC_TIME, 'ja_JP.UTF-8')

    # 気象情報を取得する
    weather_endpoint = get_weather_endpoint(LATITUDE, LONGITUDE, API_KEY)
    weather_data = get_weather_data(weather_endpoint)

    # Slack通知用のメッセージを作成する
    message = create_message(weather_data)
    print(json.dumps(message))

    # Slackに通知する
    post_slack(message)

def get_weather_endpoint(latitude, longitude, api_key):
    base_url = 'https://api.openweathermap.org/data/2.5/onecall'
    return f'{base_url}?lat={latitude}&lon={longitude}&exclude=current&units=metric&lang=ja&appid={api_key}'

def get_weather_data(endpoint):
    res = requests.get(endpoint)
    return res.json()

def convert_unixtime_to_jst_datetime(unixtime):
    return datetime.fromtimestamp(unixtime, timezone(timedelta(hours=9)))

def get_icon_url(icon_name):
    return f'http://openweathermap.org/img/wn/{icon_name}@2x.png'

def create_message(weather_data):
    hourly = create_message_blocks_hourly(weather_data)
    daily = create_message_blocks_daily(weather_data)

    message_blocks = []
    message_blocks += hourly
    message_blocks.append({
        'type': 'divider'
    })
    message_blocks += daily
    return {
        'blocks': message_blocks
    }

def create_message_blocks_hourly(weather_data):
    message_blocks = []
    hourly = weather_data['hourly']

    # 見出しを作る
    first_datetime = convert_unixtime_to_jst_datetime(hourly[0]['dt'])
    message_blocks.append({
        'type': 'section',
        'text': {
            'type': 'plain_text',
            'text': first_datetime.strftime('%m/%d(%a)')
        }
    })

    # 1時間毎のメッセージを作る(12時間分)
    for i in range(12):
        item = hourly[i]
        target_datetime = convert_unixtime_to_jst_datetime(item['dt'])
        description = item['weather'][0]['description']
        icon_url = get_icon_url(item['weather'][0]['icon'])
        rain = item.get('rain', {'1h': 0}).get('1h')    # rainが無い場合は0mm/hとする
        temperature = item['temp']
        humidity = item['humidity']
        pressure = item['pressure']
        wind_speed = item['wind_speed']

        message_blocks.append({
            'type': 'context',
            'elements': [
                {
                    'type': 'mrkdwn',
                    'text': target_datetime.strftime('%H:%M')
                },
                {
                    'type': 'image',
                    'image_url': icon_url,
                    'alt_text': description
                },
                {
                    'type': 'mrkdwn',
                    'text': f'{description: <6} {rain:>5.1f}mm/h {temperature:>4.1f}℃ '
                            f'{humidity}% {pressure:>4}hPa {wind_speed:>5.1f}m/s'
                }
            ]
        })
    return message_blocks

def create_message_blocks_daily(weather_data):
    message_blocks = []
    daily = weather_data['daily']

    # 見出しを作る
    message_blocks.append({
        'type': 'section',
        'text': {
            'type': 'plain_text',
            'text': '週間天気'
        }
    })

    # 1日毎のメッセージを作る
    for item in daily:
        target_datetime = convert_unixtime_to_jst_datetime(item['dt'])
        description = item['weather'][0]['description']
        icon_url = get_icon_url(item['weather'][0]['icon'])
        rain = item.get('rain', 0)  # rainが無い場合は0mm/hとする
        temperature_min = item['temp']['min']
        temperature_max = item['temp']['max']
        humidity = item['humidity']
        pressure = item['pressure']
        wind_speed = item['wind_speed']

        message_blocks.append({
            'type': 'context',
            'elements': [
                {
                    'type': 'mrkdwn',
                    'text': target_datetime.strftime('%m/%d(%a)')
                },
                {
                    'type': 'image',
                    'image_url': icon_url,
                    'alt_text': description
                },
                {
                    'type': 'mrkdwn',
                    'text': f'{description: <6} {rain:>5.1f}mm/h {temperature_min:>4.1f} - {temperature_max:>4.1f}℃ '
                            f'{humidity}% {pressure:>4}hPa {wind_speed:>5.1f}m/s'
                }
            ]
        })
    return message_blocks

def post_slack(payload):
    url = f'https://{SLACk_URL}'
    try:
        response = requests.post(url, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        print(e)
    else:
        print(response.status_code)

デプロイ

parameter-overridesオプションでパラメータストアの情報を渡しています。

sam build

sam package \
    --output-template-file packaged.yaml \
    --s3-bucket your-bucket-name

sam deploy \
    --template-file packaged.yaml \
    --stack-name Notify-Weather-To-Slack-App-tokyo \
    --capabilities CAPABILITY_NAMED_IAM \
    --no-fail-on-empty-changeset \
    --parameter-overrides \
        TargetName=tokyo \
        Latitude=/Notify-Weather-To-Slack-App/tokyo/Latitude \
        Longitude=/Notify-Weather-To-Slack-App/tokyo/Longitude \
        ApiKey=/Notify-Weather-To-Slack-App/apikey \
        SlackUrl=/Notify-Weather-To-Slack-App/tokyo/slack_url

動作確認

しばらくすると……、無事に通知がきました!!

さいごに

天気予報をSlackに通知してみました。これで天気予報を見る習慣ができるでしょう。

参考