LoginSignup
8
5

More than 1 year has passed since last update.

【GAS】特定の場所の天気予報を指定の日まで毎日お知らせしてくれるLineBot(前編)

Last updated at Posted at 2021-09-21

旅行や遠出する際に
当日の天気が気になっちゃう!  だけど毎日天気予報を調べるのめんどくさい!
という堕落した発想から、毎朝目的の場所の目的の日の天気を教えてくれるBot作ったらいいんじゃない!?ということで作成しました。

はじめに

当記事はGASからOpenWeatherMapを用いて、お天気LineBotを作成した記事の前編です。
後編→こちら(まだ)

各所に変なコードの記述や分かりにくい部分があるかもしれませんが、優しくご指摘いただけると幸いです。
また、GitHubにもコードをあげてみたので万が一物好きな方がいれば見てみてください。

どういうもの?

こんな感じです。

IMB_vHcKzO.gif

簡単に説明すると、
1. 地名を入力する。
2. 入力した地名の住所が表示され、合っているか聞かれる。
3. はいを選択した場合は日付ピッカーが表示され、選択して送信するとスプレッドシートに登録され、現在発表されているその日その地点の天気を教えてくれる。
4. いいえを選択した場合は泣いて許しを請います😭
5. 「確認」と入力すると、現在登録されている日時・地点の天気予報を教えてくれます。
6. 朝7時ぐらいに自動的に現在登録されている日時・地点の天気予報を教えてくれます。

作成

作成手順を記します。
大まかな流れは以下の通りです。

タスク 記事
①各種APIキーを集める。 前編
②めっちゃコード書く。 前編
③LINE Developersに登録する。 後編
④定期作業のトリガーを設定する。 後編

①各種APIキー集める。

以下の2つのAPIを利用します。
* LINE Developer
* OpenWeatherMap

LINE Developer

LINE Developerの公式サイトはこちら
LINE Developerへの登録方法は割愛します。
参考:公式サイト

  1. 自分のアカウントのページからチャンネルを作成します。
    スクリーンショット 2021-09-21 16.46.56.png

  2. Message APIを選択します。
    スクリーンショット 2021-09-21 16.48.55.png

  3. チャンネルアイコンやチャンネル名、チャンネル説明、大業種、小業種等を入力後、規約に同意して作成ボタンを押下し、案内にしたがって作成します。
    ※今回は大業種は「ウェブサービス」、小業種は「ウェブサービス(天気)」を選択しています。

  4. チャンネルが作成されたら「Message API設定」を押下します。
    スクリーンショット 2021-09-21 17.10.24.png

  5. 下の方にスクロールし、チャンネルアクセストークンの「発行」ボタンを押下します。
    スクリーンショット 2021-09-21 17.13.05.png

  6. チャンネルアクセストークンが発行されるのでメモしておいてください。

OpenWeatherMap

OpenWeatherMap公式サイトはこちら
こちらも登録済みのため方法は割愛させていただきます。
参考:OpenWeather の API を使ってみた
APIキーの取得方法も書かれているのでメモしておいてください。

②めっちゃコード書く

当記事に記載されているコードは投稿時のもので、バグや不具合当があるかもしれません。
最新のコードはGitHubにありますのでそちらも見ていただけると幸いです。

スプレッドシートも利用するので、スプレッドシートからスクリプトエディタを開きます。

当Botでは10ファイル分作成しましています。
そのうち2ファイルはテストやログを残すためのコードしか書かれていないため、実際に必要なのは8ファイル分になります。
テストといってもGASの挙動を見たり上手くファイルが呼び出されるかを見たりするためだけなので体系的なテスト用のファイルではありません。
スクリーンショット 2021-09-21 17.54.40.png

main.gs

これはLINEでアクションがあった際に呼び出されるファイルです。
全体の流れを示しています。

main.gs
// LINE developersのメッセージ送受信設定に記載のアクセストークン
const ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_ACCESS_TOKEN");

// 返信用のURL
const REPLY_URL = "https://api.line.me/v2/bot/message/reply";
const BROADCAST_URL = "https://api.line.me/v2/bot/message/broadcast";

// スプレッドシート情報
const SPREAD_SHEET_ID = PropertiesService.getScriptProperties().getProperty("SPREAD_SHEET_ID");
const SHEET_NAME = PropertiesService.getScriptProperties().getProperty("SHEET_NAME");
const SHEET = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(SHEET_NAME);

/**
 LINEからのPOST受け取り
 */
function doPost(e) {
  // 受け取ったデータの整理
  let contents = e.postData.contents;
  let obj = JSON.parse(contents);
  let events = obj["events"];

  let payload = "";
  // データのタイプから処理方法を分け、返信用のpayloadを作成する。
  if (events[0].type == "message") {
    payload = replyMessage(events[0]);
  } else if (events[0].type == "postback") {
    payload = postBack(events[0]);
  }

  // フェッチ処理
  fetchData([REPLY_URL,payload]);
}
  • ACCESS_TOKENはプロパティから#LINE Developerでメモしたアクセストークンを呼び出しています。

    • プロパティストアの使い方はこちらに詳しく書かれています。
    • めんどくさい人は直書きで問題ありません。
  • スプレッドシート情報は目的日と場所を登録するスプレッドシートの情報を設定しています。

    • こちらもめんどくさい人は直書きで問題ありません。
    • SPREAD_SHEET_IDはスプレッドシートのURLを見れば分かります。
    • SHEETはシート名を記入してください。

https://docs.google.com/spreadsheets/d/[スプレッドシートID]/edit#gid=0

replyMessage.gs

LINEからのリクエストがメッセージだった場合このメソッドで処理されます。
このメソッドに入ってくるメッセージは以下の3つに分類して処理され、メッセージを作成して返却します。

  • 確認
    • 現在登録されている目的の場所と日付の天気を返信します。
  • >いいえ
    • このメッセージは地名を検索する際に間違っていた時自動的に送信されるメッセージに対して返信します。泣いて詫びてます。
  • その他
    • その他のメッセージは地名検索するメソッドに送られます。
replyMessage.gs
/**
 * メッセージを受け取った時の処理。
 */
function replyMessage(e) {
  let input_text = e.message.text;
  let replayText = "";

  if (input_text == "確認") {
    regularWork();

  } else if (input_text == ">いいえ") {
    replayText = [{
      "type": "text",
      "text": "申し訳ありません😭\n違う言葉を入力してみてください。"
    }];  

  // メッセージの場合、入力された文字から場所を検索し、確認を画面を表示する。
  } else {
    // 入力されたメッセージから場所を検索する。
    let placeInfo = getPlaceInfo(input_text);
    let addr = placeInfo[0]; // 住所
    let lon = placeInfo[1]; // 経度
    let lat = placeInfo[2]; // 緯度

    // はいを押された場合のpostbackを作成する。
    let yesResTextStr = "datetimepicker,"+ input_text + "," + lon + "," + lat;
    // はいを押された場合の日付の初期値と最低値(本日)のフォーマットを作成する。
    let todayFormat = Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd");

    // メッセージデータを作成する。
    replayText =  [{
      "type": "template",
      "altText": "select",
      "template": {
        "type": "buttons",
        "title": "この場所で正しいですか?",
        "text": addr,
        "actions": [{
            "type": "datetimepicker",
            "label": "はい(日付の入力へ)",
            "data": yesResTextStr,
            "mode": "date",
            "initial": todayFormat,
            "min": todayFormat
          },
          {
            "type": "message",
            "label": "いいえ",
            "text": ">いいえ"
          }
        ]
      }
    }];
  }

  let payload = {
    "replyToken": e.replyToken,
    "messages": replayText
  };

  return payload;
}

postBack.gs

LINEからのリクエストがPost Backだった場合、このメソッドで処理されます。
このメソッドに入ってくるパターンはタイムピッカーで日付を選択した場合のみです。
送られてきたデータをスプレッドシートに登録し、目的の場所・日付の天気予報を取得、予報文の整形をしてメッセージを返却します。

postBack.gs
/**
 * Post Backを受け取った際の処理
 */
function postBack(e) {
  // 受け取った情報を整理する。([datetimepicker,目的地,経度,緯度])
  let data = e.postback.data.split(",");

  let replayText = "✅登録しました!\n";
  if (data[0] == "datetimepicker") {

    let targetDay = e.postback.params['date'];

    // スプレッドシートに書き込むための引数。最初の文字を削除し、最後に目的日を追加する。([入力された地名,経度,緯度,目的日])
    let wSSArg = data.slice(1,data.length);
    wSSArg.push(targetDay);

    // スプレッドシートに書き込む
    writeSpreadSheet(wSSArg);

    let today = new Date();
    let targetDayForComp = new Date(targetDay + " 00:00:00");
    let dayDiff = (Math.floor((targetDayForComp - today)/1000));

    // 日付の差が8日以上大きい場合はまだ予報が出ていないためreturn。
    if (dayDiff >= 691200) {
      replayText = replayText + targetDay.replace(/-/g,"/") + ""  + data[1] + 
        "の天気予報はまだ発表されていませんので、もうしばらくお待ちください☀️☁️☂️"
    } else {
      // 天気予報を取得するための引数を作成([経度,緯度,目的日])
      let gWArg = [data[2],data[3],targetDay];

      // 天気予報を取得
      let weatherInfo = getWeather(gWArg);

      // 取得した情報から処理を決める。
      switch(weatherInfo[0]) {

        // エラーの場合
        case 9:
          replayText = "⚠️エラーが発生しました。\nしばらくお待ちいただくか、管理者にお尋ねください。\n" + weatherInfo[1];
          break;

        // まだ予報が出ていない場合
        case 1:
          replayText = replayText + targetDay.replace(/-/g,"/") + "" + data[1] + 
            "の天気予報はまだ発表されていませんので、もうしばらくお待ちください☀️☁️☂️"
          break;

        // 正常な場合
        case 0:
          // アイコンを設定する。
          let icon = "";
          switch (weatherInfo[2]) {
            case "01d" : icon = "☀️"; 
              break;
            case "02d" : icon = "🌤";
              break;
            case "03d" : icon = "☁️";
              break;
            case "04d" : icon = "☁️";
              break;
            case "09d" : icon = "☂️";
              break;
            case "10d" : icon = "☔️";
              break;
            case "11d" : icon = "";
              break;
            case "13d" : icon = "☃️";
              break;
            case "50d" : icon = "🌫";
              break;
          }

          replayText = replayText + targetDay.replace(/-/g,"/") + "" + data[1] +"の予報は、" + weatherInfo[1] + icon
            + "、最高気温は" + weatherInfo[3] + "℃、最低気温は" + weatherInfo[4] + "℃です✨";
          break;
      } 
    }
  };

  let payload = {
    "replyToken": e.replyToken,
    "messages": [{
      "type": "text",
      "text": replayText
    }]
  };

  return payload;
}

getPlaceInfo.gs

このメソッドでは地名を引数として、戻り値として郵便番号と住所、経度、緯度を返却します。
戻り値は配列で返却されます。

getPlaceInfo.gs
/**
 * 場所情報から軽度緯度を取得するクラス。
 * 引数:検索したい場所
 */
function getPlaceInfo(place) {

  var
    geocoder = Maps.newGeocoder(), // Creates a new Geocoder object.
    geocoder = geocoder.setLanguage('ja'),  // Use Japanese
    response = geocoder.geocode(place);

  response = response.results[0];

  let addr = response["formatted_address"] // 郵便番号・住所

  let lng = response["geometry"]["location"]["lng"]; // 経度
  let lat = response["geometry"]["location"]["lat"]; // 緯度
  let placeInfo = [addr, lng, lat];

  return placeInfo;
}

地名から位置情報を得る方法についてはGoogleMaps APIが有名ですが、色々探していたらMaps.newGeocoderクラスというものがあるらしく使ってみることにしました。
こちらの記事を参考にしました。
噂によると利用制限があるとかないとか?(個人利用の範囲なのでそこまで気にしていなかったので気になる人は調べてみてください)

getWeather.gs

天気予報情報を取得するクラス。
主にOpneWeatherMapと通信し、目的の日の必要な情報のみを返却します。
返却するデータは以下の通りです。(湿度も返却したけど実際には使わなかった。それより降水確率が欲しかった)

  • 天気
  • 気象アイコンID
  • 最高気温
  • 最低気温
  • 湿度
getWeather.gs
const OPEN_WEATHER_API_KEY = PropertiesService.getScriptProperties().getProperty("OPEN_WEATHER_API_KEY");

/**
 * 軽度緯度から天気予報を取得する。
 * 引数 : 対象の地点の軽度緯度情報と目的の日付までの日数。[経度,緯度,目的日]
 * 戻り値 : 気象予報情報。 
 *  正常:[0,天気,アイコンID,最高気温,最低気温,湿度]
 *  正常(7日以上先の目的日の場合):[1,7日前になるまでお待ちください。]
 *  異常:[9,エラーメッセージ]
 * 
 * 天気アイコンIDの詳細は以下を参照。
 * https://openweathermap.org/weather-conditions#How-to-get-icon-URL
 */
function getWeather(info) {
  // 引数の整理
  let lon = info[0]; // 経度
  let lat = info[1]; // 緯度
  let targetDay = changeJSTToUnixTime(info[2]); // 目的日をUTCのUNIXTIMEに変換する。

  try {
    // 引数をもとにURLを作成する。
    let url = "https://api.openweathermap.org/data/2.5/onecall?exclude=current,minutely,hourly&lang=ja&units=metic&appid="
      + OPEN_WEATHER_API_KEY + "&lon=" + lon + "&lat=" + lat;  

    // URLからフェッチし、JSONデータに変換
    let response = UrlFetchApp.fetch(url);
    let jsonData = JSON.parse(response.getContentText());

    // 目的日
    let targetDailyDate = "";

    // 目的日からJSONのどのデータを参照するかを調べる。
    for (let i = 0; i < jsonData.daily.length; i++) {
      if (jsonData.daily[i].dt > targetDay) {
        targetDailyDate = i;
        break;
      }
    }

    // 目的日が7日以上先の場合は返却する。(エラーコードは1)
    if (targetDailyDate.length == 0) {
      return [1,"7日前になるまでお待ちください。"];
    }

    // 必要なデータを取得していく。
    // 天気とアイコン
    let weather = jsonData.daily[targetDailyDate].weather[0].description;
    let icon = jsonData.daily[targetDailyDate].weather[0].icon;
    // 最高・最低気温
    let maxTemp = Math.floor(jsonData.daily[targetDailyDate].temp.max - 273.15);
    let minTemp = Math.floor(jsonData.daily[targetDailyDate].temp.min - 273.15);
    // 湿度
    let humidity = jsonData.daily[targetDailyDate].humidity;

    // 返却する。(エラーコードは0)
    return [0,weather,icon,maxTemp,minTemp,humidity];

    } catch (e) {
        // エラーメッセージを返却する。(エラーコードは9)
        return [9,"【error】 [" + new Date() +  "] " + e];
    }
}

/**
 * JST日付からUNIX時間を返却する。
 * 引数:JSTで示された日付のみのデータ。
 * 戻り値:引数の日付の00:00:00.000のUNIX時間(UTC) 単位は秒
 */
function changeJSTToUnixTime(jst) {
  // そのままnewDate()すると勝手に9時になってしまうため、00:00:00.000を予め付与する。
  let target = new Date(jst + " 00:00:00");
  // 返却したい値はJSTの0時なので最後にJSTとUTCとの時差である9時間を引いている。
  return Math.floor(Date.parse(Utilities.formatDate(target, 'GMT', 'dd MMM yyyy HH:mm:ss z'))/1000)  - 32400;
}

OPEN_WEATHER_API_KEYOpenWeatherMapで取得したAPIキーをプロパティから取得していますが、こちらも直書きでも問題ありません。

changeJSTToUnixTimeメソッドは、OpenWeatherMapで利用される時間はUNOX時間でさらに協定世界時(UTC)なので時間の変換や、日本標準時(JST)との時差を合わせるためのものです。

writeSpreadSheet.gs

目的の地名と日時をスプレッドシートに書き込むためのメソッドです。

writeSpreadSheet.gs
/**
 * 軽度緯度と登録地名と目的の日付をスプレッドシートに登録するクラス。
 * 引数:配列の引数。配列は以下の通り。
 *     [入力された地名,経度,緯度,目的の日付]
 */
function writeSpreadSheet(info) {
  SHEET.appendRow([info[0], info[1], info[2], info[3], new Date()]);
}

SHEETmain.gsで定義しています。

regularWork.gs

regularWork.gsは現在登録されている全ての目的の地点、日時の天気予報を取得し、メッセージを作成するメソッドです。
定時作業と、LINEで「確認」とメッセージを送ることでも呼び出すことができます。

regularWork.gs
/**
 * 定期作業のためのfunction
 * スプレッドシートに記述されているデータを読み込んで天気予報をLineBotへ流す。
 */
function regularWork() {
  // シートの情報
  const startRow = 2;
  const startCol = 1;
  const endRow = SHEET.getLastRow() - 1;
  const endCol = 4;

  // 対象の範囲を取得する。
  let registList = SHEET.getRange(startRow,startCol,endRow,endCol).getDisplayValues();

  let replayTextList = [];
  let deleteList = [];

  // 天気予報を取得して返信用の文章を作成する。
  for (let i in registList) {

    // 情報の整理
    let place = registList[i][0];
    let lon = registList[i][1];
    let lat = registList[i][2];
    let targetDay = registList[i][3];

    // 8日以上先の天気予報は発表されていないため日付の比較をする。
    let today = new Date();
    let targetDayForComp = new Date(targetDay + " 00:00:00");
    let dayDiff = (Math.floor((targetDayForComp - today)/1000));

    let txt = "";

    // 日付の差が8日以上大きい場合はまだ予報が出ていないためreturn。
    if (dayDiff >= 691200) {
      txt = "ℹ️" + targetDay.replace(/-/g,"/") + "" + place + 
            "の天気予報はまだ発表されていませんので、もうしばらくお待ちください☀️☁️☂️";
      replayTextList.push(txt);

    } else if (dayDiff <= 0) {
      // 日付の差が0以下の場合、過去のデータになるのでリストに格納し、for文の処理が終わった後にまとめて削除する。
      // スプレッドシートからの削除の都合上、unshiftで行番号を追加していく。
      deleteList.unshift(parseInt(i) + 2);
      continue;

    } else {
      // 天気予報を取得する。
      let weatherInfo = getWeather([lon,lat,targetDay]);

      // 取得した情報から処理を決める。
      switch(weatherInfo[0]) {

        // エラーの場合
        case 9:
          txt = "⚠️" + targetDay.replace(/-/g,"/") + "" + place + 
            "の天気予報は取得の際にエラーが発生しました。\nしばらくお待ちいただくか、管理者にお尋ねください。" + weatherInfo[1];
          replayTextList.push(txt);

          break;

        // まだ予報が出ていない場合
        case 1:
          txt = "ℹ️" + targetDay.replace(/-/g,"/") + "" + place + 
            "の天気予報はまだ発表されていませんので、もうしばらくお待ちください☀️☁️☂️";
          replayTextList.push(txt);
          break;

        // 正常な場合
        case 0:
          // アイコンを設定する。
          let icon = "";
          switch (weatherInfo[2]) {
            case "01d" : icon = "☀️"; 
              break;
            case "02d" : icon = "🌤";
              break;
            case "03d" : icon = "☁️";
              break;
            case "04d" : icon = "☁️";
              break;
            case "09d" : icon = "☂️";
              break;
            case "10d" : icon = "☔️";
              break;
            case "11d" : icon = "";
              break;
            case "13d" : icon = "☃️";
              break;
            case "50d" : icon = "🌫";
              break;
          }

          txt = "ℹ️" +  targetDay.replace(/-/g,"/") + "" + place +"の予報は、" + weatherInfo[1] + icon
            + "、最高気温は" + weatherInfo[3] + "℃、最低気温は" + weatherInfo[4] + "℃です✨";
          replayTextList.push(txt);
          break;
      }
    }
  }

  let replayText = replayTextList.join("\n");

  // 過去のデータを削除していく。
  for (let j in deleteList) {
    SHEET.deleteRow(deleteList[j]);
  }

  let payload = {
    "messages": [{
      "type": "text",
      "text": replayText
    }]
  };

  fetchData([BROADCAST_URL,payload]);
}

fetchData.gas

各メソッドで作成されたメッセージをLINEにfetchするメソッドです。

fetchData.gs
/**
 * Reply用のフェッチする処理を行う。
 * 引数:LineBotへ返信するための必要なデータ。
 *      [返信用URL,payload]
 */
function fetchData(arg) {

  let url = arg[0];
  let payload = arg[1];

  let options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + ACCESS_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  UrlFetchApp.fetch(url, options);
}

logger.gs

Line Developerはコンソールログを表示することができません。
そのため、ログを確認するためにはスプレッドシートに書き込む必要があります。
スプレッドシートにログ用のシートを作成し、そこに書き込むようにしています。

logger.gs
function logger(str) {
  const LogSheet = PropertiesService.getScriptProperties().getProperty("LogSheet");
  const Sh = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(LogSheet);

  Sh.appendRow([new Date(), str]);
}

LogSheetはログ用のシート名をプロパティから取得しています。

test.gs

このメソッドは主に制作中に検証するためのものです。
もし改修することがあった場合にまた必要になるかもしれないので消さずに残しているだけなので、実際の機能には不要なものです。

前編 おわりに

記事を書き始めると、思っていた以上のボリュームになってしまったので、前編と後編に区切ることにしました。
前編の記事はひたすらコードばかりになってしまいましたが、ここまでくればあともうすぐですので後編もお付き合いいただければ幸いです。

便利だったサイトやアドオン等

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5