2020年8月31日月曜日

気象データをGASのWebスクレイピングで自動取得する。

-----
・追記(2022-9-14)

同様のデータを JSON 形式で取得する Web API を作成しました。まるごと GAS で取得するより Web API を楽だと思います。 → 「過去の気象データを JSON 形式で取得する Web API を作りました。


・追記(2024-3-4)

いつの間にか GAS から気象庁のサイトへのアクセスが制限されたようです。データの取得にあたっては Web API をご利用ください。
-----

農林水産省のページではスマート農業の効果として次の3つが挙げられています。

① 作業の自動化
② 情報共有の簡易化
③ データの活用

事例も様々紹介されていますが、小規模農家としては最新技術・機器に対して大きな初期投資をすることは難しいです。実際のところ、ある程度一般的な技術として確立し、実用化した段階で導入を検討しても遅くはないと思います。

では逆に、今すぐ始められる事、始めた方が良さそうな事は何だろうかと考えてみると、③のデータ活用ではなかろうかと思うのです。データ活用であればパソコン1台から小さく始められます。流れとして今後使えるデータも整備され増えていきそうですしね。

これまで蓄積した経験や勘も含めて定量化し、そのデータを活かすことで農業における「判断精度の向上」ができたら小規模農家にとってもスマート化の意義が出てくると思うのです。

そんなわけで、まずは基礎の基礎として気象データの取得をGAS(Google Apps Script)によるWebスクレイピングで試みてみました。

Webスクレイピングというのはウェブサイトから情報を抽出する技術です。公式で適切なAPIが提供されていれば不要ですが、そういうところばかりとは限りませんのでやはり使えると便利です。ただし、サイトによっては規約等で禁止されていることもあるので注意が必要です。

コードに関しては毎度のことながら動けばいいやの精神で作っておりますのでどうぞよろしくお願いします。


***

今回は気象庁から過去の気象データを取得します。(気象庁サイトで公開されている情報の利用についてはこちらを参照)

コードは以下のページを参考にさせていただきました。



機能はシンプルに、

・指定した地点の任意の過去時点から前日までの気象データを取得する。
・毎日前日の気象データを取得する。

の2つとなります。


① スプレッドシートの準備

まずはスプレッドシートを用意します。

設定がやや細かいので作成済みのものをダウンロードできるようにしておきました。


・追記(2021-7-9)
時々、アクセス権のリクエストをいただきますが、↓の方法で編集可能なコピーを作成してご使用ください。

PCでGoogleアカウントにログインした状態で、リンク先にて「ファイル」から「コピーを作成」で編集可能なファイルが作成されます。


「master」「data」「prec_no」「block_no」「reference」の4つのシートがあります。

「master」シートでは気象データの取得開始年月日、地点を設定をします。

データ取得にあたって入力が必要となるのは「取得年月日(取得開始年月日)」の3か所、「都道府県」「地点」の計5か所(黄色に塗りつぶしてあるセル)です。その他のセルは自動で入力されるようになっています。

「data」シートは取得した気象データが入力されます。地点によって取得できるデータに違いがあるので空白になる列もあります。

「prec_no」「block_no」「reference」シートは「master」シートで都道府県と地点を入力した際に番号を取得するためのシートです。

自動入力される地点番号は国際地点番号が割り振られた地点のものしか用意していないので、他の地点のデータがほしい場合は「master」シートで手動入力が必要です。気象庁の過去の気象データ検索のページで都道府県と地点を選択すると表示されるURLの「block_no=」後の数字です。


「master」シートは作り込みが甘く都道府県と地点が連動していないので正しく入力してください。都道府県が「東京都」で地点が「小名浜」のような入力が可能ですが、当然データの取得はできません。

※ 都道府県と地点のプルダウンリストが連動するようにスプレッドシートを修正しました。(2020-9-9)


② コードの作成

上記スプレッドシートの「ツール」→「スクリプト エディタ」からスクリプトファイルを作成します。コードは以下の通りです。

function getPrevData(){
  var spreadsheetUrl = "https://docs.google.com/spreadsheets/d/スプレッドシートID/";
  var spreadsheet = SpreadsheetApp.openByUrl(spreadsheetUrl);
  var masterSheet = spreadsheet.getSheetByName("master");
  var dataSheet = spreadsheet.getSheetByName("data");
  var opt = {"contentType":"text/html;","method":"get"};
  var dataJma = "";
  var contentJma = "";
  var postText = "";
  var urlJma = "https://www.data.jma.go.jp/obd/stats/etrn/view/daily_s1.php?prec_no=";
  var prefNo = masterSheet.getRange(2,12).getValue();
  var blockNo = masterSheet.getRange(2,14).getValue();
  var months = masterSheet.getRange(2,7).getValue();
  
  for(var i = 0; i < months; i++){
    var startRow = dataSheet.getLastRow()+1;
    var masterYear = masterSheet.getRange(2,1).getValue();
    var masterMonth = masterSheet.getRange(2,2).getValue();
    var masterDay = masterSheet.getRange(2,3).getValue();
    var days = masterSheet.getRange(2,4).getValue();
    var nextYear = masterSheet.getRange(2,5).getValue();
    var nextMonth = masterSheet.getRange(2,6).getValue();
    var nextDay = 1;
    var curtYear = masterSheet.getRange(2,9).getValue();
    var curtMonth = masterSheet.getRange(2,10).getValue();
    var today = masterSheet.getRange(2,8).getValue();
    var lastday = new Date(masterYear, masterMonth, 0);
    var middleArr = [];
    var tableArr = [];
    var endRow = "";
    var cols = 22;
    var dataYearMonth = "";
    var dataDate = [];

    urlJma = urlJma + prefNo + "&block_no=" + blockNo + "&year=" + masterYear + "&month="+ masterMonth + "&day=&view=";
    dataJma = UrlFetchApp.fetch(urlJma ,opt);
    contentJma = dataJma.getContentText();
    postText = getStringSlice(contentJma, '<th scope="col">値</th>','<div class="print" style="margin-top:1em">');
    postText = postText.replace(/<\/th>/g, '</th>,');
    postText = postText.replace(/<\/td>/g, '</td>,');
    postText = postText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'');
    postText = postText.replace( /\r\n/g , "\n" );
    postText = postText.replace( /^(\n+)|(\n+)$/g , "" );
    postText = postText.replace(/\/\/\//g, '');
    postText = postText.replace(/\--/g, '0'); //該当現象なし
    postText = postText.replace(/\s\)/g, ''); //準正常値
    postText = postText.replace(/\s\]/g, ''); //資料不足値
    middleArr = postText.split( /\n/g );
    tableArr = [];
    days = days + masterDay - 1;
    for( var j = 0 , m = days ; j < m ; j++ ){
      tableArr[j] = middleArr[j].split(",");
      for( var k = 0 , n = tableArr[j].length ; k < n ; k++ ){
        tableArr[j][k] = tableArr[j][k].replace( /(^\s+)|(\s+$)/g , ""  );
      }
    }

    tableArr.splice(0,0);
    endRow = tableArr.length;
    dataSheet.getRange(startRow,3,endRow,cols).setValues(tableArr);
    dataYearMonth = "[[" + masterYear + "," + masterMonth + "]";
    for(var l = 0; l < days-1; l++) {
      dataYearMonth = dataYearMonth + ",[" + masterYear + "," + masterMonth + "]" ;
    }
    dataDate =  JSON.parse(dataYearMonth + "]");
    dataSheet.getRange(startRow,1,endRow,2).setValues(dataDate);
    
    if(masterDay != 1){
      masterDay = masterDay - 1;
      
      dataSheet.deleteRows(2,masterDay);
    }
    
    today = Utilities.formatDate(today, "JST", "yyyy/MM/dd");
    lastday = Utilities.formatDate(lastday, "JST", "yyyy/MM/dd");
    if(masterYear != curtYear){
      masterSheet.getRange(2,1).setValue(nextYear);
      masterSheet.getRange(2,2).setValue(nextMonth);
      masterSheet.getRange(2,3).setValue(nextDay);
    } else if(masterMonth != curtMonth){
      masterSheet.getRange(2,1).setValue(nextYear);
      masterSheet.getRange(2,2).setValue(nextMonth);
      masterSheet.getRange(2,3).setValue(nextDay);
    } else if(today == lastday){
      masterSheet.getRange(2,1).setValue(nextYear);
      masterSheet.getRange(2,2).setValue(nextMonth);
      masterSheet.getRange(2,3).setValue(nextDay);
    }
  }  
}

function getTodayData(){
  var spreadsheetUrl = "https://docs.google.com/spreadsheets/d/スプレッドシートID/";
  var spreadsheet = SpreadsheetApp.openByUrl(spreadsheetUrl);
  var masterSheet = spreadsheet.getSheetByName("master");
  var dataSheet = spreadsheet.getSheetByName("data");
  var opt = {"contentType":"text/html;","method":"get"};
  var dataJma = "";
  var contentJma = "";
  var postText = "";
  var urlJma = "https://www.data.jma.go.jp/obd/stats/etrn/view/daily_s1.php?prec_no=";
  var prefNo = masterSheet.getRange(2,12).getValue();
  var blockNo = masterSheet.getRange(2,14).getValue();
  var startRow = dataSheet.getLastRow()+1;
  var masterYear = masterSheet.getRange(2,1).getValue();
  var masterMonth = masterSheet.getRange(2,2).getValue();
  var masterDay = masterSheet.getRange(2,3).getValue();
  var days = masterSheet.getRange(2,4).getValue();
  var nextYear = masterSheet.getRange(2,5).getValue();
  var nextMonth = masterSheet.getRange(2,6).getValue();
  var nextDay = 1;
  var today = masterSheet.getRange(2,8).getValue();
  var lastday = new Date(masterYear, masterMonth, 0);
  var middleArr = [];
  var tableArr = [];
  var endRow = "";
  var cols = 22;
  var dataDate = [];

  urlJma = urlJma + prefNo + "&block_no=" + blockNo + "&year=" + masterYear + "&month="+ masterMonth + "&day=&view=";
  dataJma = UrlFetchApp.fetch(urlJma ,opt);
  contentJma = dataJma.getContentText();
  postText = getStringSlice(contentJma, '<th scope="col">値</th>','<div class="print" style="margin-top:1em">');
  postText = postText.replace(/<\/th>/g, '</th>,');
  postText = postText.replace(/<\/td>/g, '</td>,');
  postText = postText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'');
  postText = postText.replace( /\r\n/g , "\n" );
  postText = postText.replace( /^(\n+)|(\n+)$/g , "" );
  postText = postText.replace(/\/\/\//g, '');
  postText = postText.replace(/\--/g, '0'); //該当現象なし
  postText = postText.replace(/\s\)/g, ''); //準正常値
  postText = postText.replace(/\s\]/g, ''); //資料不足値
  middleArr = postText.split( /\n/g );
  tableArr = [];
  days = days + masterDay - 1;
  for( var j = 0 , m = days ; j < m ; j++){
    tableArr[0] = middleArr[j].split(",");
    for( var k = 0 , n = tableArr[0].length ; k < n ; k++ ){
      tableArr[0][k] = tableArr[0][k].replace( /(^\s+)|(\s+$)/g , ""  );
    }
  }
  
  tableArr.splice(0,0);
  endRow = tableArr.length;
  dataSheet.getRange(startRow,3,endRow,cols).setValues(tableArr);
  dataSheet.getRange(startRow,1).setValue(masterYear);
  dataSheet.getRange(startRow,2).setValue(masterMonth);

  today = Utilities.formatDate(today, "JST", "yyyy/MM/dd");
  lastday = Utilities.formatDate(lastday, "JST", "yyyy/MM/dd");
  if(today == lastday){
    masterSheet.getRange(2,1).setValue(nextYear);
    masterSheet.getRange(2,2).setValue(nextMonth);
    masterSheet.getRange(2,3).setValue(nextDay);
  }
}
 
function getStringSlice(content, startStr, endStr){
  var indexStart = content.indexOf(startStr);
  if(indexStart == -1){
    return "";
  } else {
    indexStart += startStr.length
    return content.slice(indexStart, content.indexOf(endStr, indexStart));
  }
}
※ コードを一部修正をしました。(2020-9-2)
※ コードを一部修正をしました。(2020-10-4)

2行目、93行目の「スプレッドシートID」は固有になりますので書き換えてください。スプレッドシートを開いた状態でURLの下記部分に表示されます。ちょっと長めです。

https://docs.google.com/spreadsheets/d/ここの値をコピペ/edit#gid=0


GASの初回実行時に承認が必要となります。無料のGoogleアカウントの場合「このアプリは確認されていません」とちょっとこわい?ページが表示されますが大丈夫です。ここで「安全なページに戻る」をクリックすると実行できませんので、「詳細」→「プロジェクト名(安全ではないページ)に移動」から承認します。

getPrevData関数を実行すると「master」シートにしたがって気象データを取得し、「data」シートに書き込まれます。

データ取得先のサーバー負荷を考えて1回の実行で取得できる最大値を12か月分にしてあります(「master」シートの「取得月数」で指定)。それ以上必要な場合は複数回実行してください。

これで過去分のデータ取得は完了です。


※ 取得したデータを処理しやすいように準正常値、資料不足値は正常値に、該当現象なしは「0」に変換しています。


③ トリガーの設定

getTodayData関数により毎日前日のデータを取得するためにトリガーの設定をします。

「トリガーを追加」より、

・「実行する関数を選択」→ getTodayData
・「実行するデプロイを選択」→ Head
・「イベントのソースを選択」→ 時間主導型
・「時間ベースのトリガーのタイプを選択」→ 日付ベースのタイマー
・「時刻を選択」→ 午前3時〜4時
・「エラー通知設定」→ 毎日通知を受け取る

気象データの更新が毎日1時頃とのことなので、実行時間は余裕をみて3時~4時で設定しています。

これで毎日前日のデータが取得されます。


***

ちゃんと動きましたでしょうか。

ちなみに、実は過去の気象データは気象庁サイトにてCSV形式でのダウンロードが可能です。ただ、毎日前日分のデータを取得するとなるとやはり手間なのでWebスクレイピングが活きてきます。


気象データはいろいろ使えます。

私もまずは原木椎茸に関してあれこれ栽培の指標になるようなものを作れないかとポチポチやっております。

もちろんそのためには地域の気象データだけではなく施設内のデータも必須なので測定機器の選定等、準備をしているところです。

小さくてもできるスマート農業を探っていきたいと思います。






・関連投稿
GASで簡単な予定通知LINE botを作ってみる。