法改正情報を早く確実にキャッチするためにGASで自動化するツールを作って運用してみた。

## 概要
法改正情報を早く、逃さずにキャッチできる状態を作るために、GSSとGoogleAppsScriptでツールを作って運用してみた話です。
スクレイピングってなに？の状態から実装を始め、今年の3月から9ヶ月ほど、機能強化しながら運用してみたので、まとめてみます。

## きっかけ
普段は社会保険のチームで開発業務をしています。
社会保険の機能は法改正に沿って、施行開始日に間に合うように開発を行う必要があります。例えば今年の4月に大法人に対して、[e-Govを利用した電子申請が義務化されました](https://www.mhlw.go.jp/content/000511981.pdf)が、それに向けた開発業務を行なっていく上で、義務化の対象となっている届出のレイアウト等の更新情報やAPIの仕様変更をなるべく早く、逃さずにキャッチする必要があります。
これまでは人力に頼っている部分が大きかったのですが、毎日見に行くのめんどいしたぶんそのうち見逃したりするよね？見に行くページが決まっているんだったら自動化できないかな？と思ったのがきっかけです。

## htmlの変更でチェック
まずは単純にHTMLのテキストの変更をチェックすることで法改正情報のページ更新を検知することにしました。
ツールはGSSとGoogleAppsScriptで実装しています。
スプレッドシートにチェックするページのURLを書き、そのURLのhtmlを毎日取得し、差分が有れば通知する仕組みです。GSS上でURLを管理することで、私以外のメンバーでもソース修正することなくURLの追加・削除ができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/883734/8aaa6bf4-d4a5-d8ab-5aca-d9bf92b7c28a.png)

GASに用意されている、`UrlFetchApp.fetch`を使って、URL先の情報を取得し、`getContentText`でhtmlを取得します。
GSSのセルには文字数制限があり、取得したhtmlをGSS上に残すことはできないので、htmlをハッシュ値に変換して、ハッシュ値が変わっていればページに変更があったと判定します。

```java
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("urlリスト");
var rows = sheet.getDataRange();
var numRows = rows.getNumRows();
var values = rows.getValues();
// 一行ずつチェック
var results = [];
for (var i = 1; i <= numRows - 1; i++) {
  var row = values[i];
  // GSSから対象URLの値を取得
  var name = row[0];
  var url = row[1];
  var hash = row[2];// 前回のハッシュ値
  // htmlを取得
  var html = UrlFetchApp.fetch(url);
  var text = html.getContentText();
  // ハッシュ値を計算
  var md5bin = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text);
  var md5 = Utilities.base64Encode(md5bin);
  if (hash == md5) continue; // 前回と同じならスキップ
  results.push([name, url]);
  sheet.getRange(1 + i , 3).setValue(md5);
}
if (results.length == 0) {
  Logger.log("更新はありませんでした。");
  return;
}
```

この頃はまだGASがV8に対応していなかったので、const使うとおかしな動きになって不便でした。。letも使えなかったり。
と思ったのですが、時期的にはやろうと思えばV8対応できたっぽいですね。[マニフェストファイル更新すればV8対応にできる](https://iwb.jp/google-apps-script-able-es6-arrow-function-let-const/)ことを知らなかったので、varを使っていました。（今新規でGASプロジェクト作る場合は、最初からV8対応になっているのでマニフェストファイルの更新は不要です。）
現在は全てconst, letで置き換えていますが、懐かしいので当時のコミットのまま載せておきます。

ハッシュ値を比較して、値が一致しない場合にはgmailにそのページのURLを送信します。
今ではツール作ったら何でもslackに通知飛ばしていますが、最初はメールで通知していましたね。。懐かしいです。

```javascript
var address = getMailAdress();
GmailApp.sendEmail(address, "【サイト更新情報 1日に1回のペースでの更新チェック中】\r\n 登録されているサイトに更新がありました。\r\n\r\n" + body
                    + "\r\n\r\n ※更新を監視したいサイトの追加はこちら→ *******";

function getMailAdress() {// メールアドレスシートからアドレスを取得
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("メールアドレス");
  var results = "";
  var rows = sheet.getDataRange();
  var numRows = rows.getNumRows();
  var values = rows.getValues();
  for (var i = 1; i <= numRows - 1; i++) {
    var row = values[i];
    var mailAdress = row[0];
//    var name = row[1];
    if(i != 1){
      results += ", ";
    }
    results += mailAdress;
  }
  return results;
}
```
こんな感じで書いたメソッドを1日1回実行のトリガーにセットして毎日自動で法改正情報をチェックできるようになりました。

## slackに通知
しばらく運用してみて、メールあんまり見ないよね問題が出てきたので、slackのincomming webhookを使ってslackに通知するようにしました。
この辺は[たくさん記事ある](https://qiita.com/search?sort=&q=gas+slack+webhook)ので、省略します。

メールではなくslackに通知することで、通知した後の柔軟性というか、スレッドでやり取りしたり内容の確認したりがしやすくなりますね。私自身メールはほぼ見ないので、通知をslackに集約していくと快適になりました。


## RSS対応
恥ずかしながら、このツールを実装するまでRSSを使ったことがありませんでした。
チェック対象の中でもいくつかのサイトにはRSSが用意されており、何それ便利そう、ということでRSSを使ってチェックをしてみることにしました。ソースはこんな感じ。

```javascript
const url = "*****";// rss url
const xml = UrlFetchApp.fetch(url).getContentText();
const document = XmlService.parse(xml);
const entries = document.getRootElement().getChildren("channel")[0].getChildren("item");

const title = entries[0].getChildText("title");
const link = entries[0].getChildText("link");
const description = entries[0].getChildText("description");
const pubDate = entries[0].getChildText("pubDate");

body = "*" + title + "*" + "\r\n" + link + "\r\n\r\n" + "```" + description + "```";// slack通知用に整形

```

htmlをハッシュ化して比較するのに対して、RSSを使って感じたメリットは以下です。

- 何の更新が入ったのかが通知だけでわかる。
- slack通知を分かりやすい見た目にできる。

実装したあとでslackにRSSリーダーのアプリがあることを知ったのですが、RSSがどういうものかを調べながら実装できたし通知の見た目も分かりやすくできたので、良しとします。RSSリーダーもたぶんこういうことやってるんだろうなということが知れたので。


## JavaScriptでレンダリングされる動的ページの対応
毎日のように更新が通知されるページがあり、なぜだろう？と調べたところ、JavaScriptでhtmlをレンダリングしているページであることが分かり、GASで取得されていたhtmlはjs実行前のものであることが原因でした。
jsでhtmlをレンダリングしているページの場合、GASの`UrlFetchApp.fetch`を実行すると、js実行前のhtmlがcontentとして返ってきてしまい、GAS単体では正しくページの更新チェックを実施することができないことが分かったため、ヘッドレスブラウザを使用してjs実行後のhtmlを取得することにしました。

使用したヘッドレスブラウザは、[phantomJS](https://phantomjscloud.com/)です。
アカウント作成後、ダッシュボード画面にApiKeyが表示されるのでコピーします。
![phantomjs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/883734/be9fc994-3e28-f8af-d0c9-25d6a98e84da.png)

GASからphantomJSにアクセスするには、`UrlFetchApp.fetch`に、`https://PhantomJsCloud.com/api/browser/v2/[API_KEY]/?request=[REQUEST_JSON]`を渡します。`API_KEY`にはコピーしてきたApiKey、`REQUEST_JSON`には、以下のような値を入力します。

```javascript
function getRenderedHTML (requestUrl) {
  const apiKey = "****";// コピーしてきたapiKey
  let requestJson= 
      {url:requestUrl,
       renderType:'HTML',
       outputAsJson:true};

  requestJson= JSON.stringify(requestJson);
  requestJson= encodeURIComponent(requestJson);

  //phantomJSにアクセスするURL
  let phantomJsUrl = 'https://phantomjscloud.com/api/browser/v2/'+ apiKey +'/?request=' + requestJson;

  //指定ページのHTMLを取得
  let response = UrlFetchApp.fetch(phantomJsUrl); //urlにアクセス
  let json = JSON.parse(response.getContentText());  //JSON⇒文字列に変換
  let html = json["content"]["data"];　//htmlを取得
  Logger.log(html);
  return html;
}

```

あとは返ってきたhtmlに対して、これまでと同じ方法で差分比較することで更新チェックができるようになりました。
法改正の情報が公開されるページは静的ページが多いのですが、今後変わっていくかもしれないですし、対応できるページが増えたことはよかったです。


## エラー時の処理
動的ページへの対応をやろうとしだした辺りから、更新チェックがエラーになることが時々出てくるようになりました。
この時、以下の問題が表面化しました。
1. エラーの原因が分からない。
2. エラーになってもslack通知だけ見ると、すぐにエラーに気付けない。

1については、比較に使っているのがハッシュ化した後の値であり、html自体はGSS上に持っていないため、どこに変更が入ったのかが分からないというものでした。
これによって、エラーの原因やエラーにはなってないけど何でこれ通知されたの？の様な問題が発生したときに、調査が難航しました。

それに対して解決策としては、GSS上に残せないのなら、ドライブにテキストで保存できればよいのでは？と考えました。

```javascript
function outputToFile(url, pageTitle, content) {
  let fileName = extractFileNameFromUrl(pageTitle + url);
  let contentType = 'text/plain';
  let charset = 'utf-8';

  // Blob を作成する
  let blob = Utilities.newBlob('', contentType, fileName).setDataFromString(content, charset);
  
  let folder = DriveApp.getFolderById('*******************');// HTML置き場 ドライブのフォルダID
  let newFolderName = getNow().split("/").join("_");// 名前が日付のフォルダを作成
  let folders = folder.getFoldersByName(newFolderName);//すでに同名のフォルダが存在するかチェック
  let newFolder;
  if (folders.hasNext()){
    newFolder = folders.next();
  } else {//フォルダがまだ存在しなければ作成
    newFolder = folder.createFolder(newFolderName);
  }
  // フォルダにファイル保存
  newFolder.createFile(blob);
}

//現在日時を取得
function getNow() {
  let d = new Date();
  let y = d.getFullYear();
  let mon = d.getMonth() + 1;
  let d2 = d.getDate();
  let now = y+"/"+mon+"/"+d2//+" "+h+":"+min+":"+s;
  return now;
}
```

ドライブのフォルダIDは、ドライブをブラウザで開き、保存したいフォルダまで移動して、`https://drive.google.com/drive/u/1/folders/{フォルダID}`のフォルダIDをコピーして貼り付ければOKです。
これにより、前日のhtmlと今日のhtmlを見比べることが可能となるので、1の問題が解決できます。

2については、後でGASからメールが来るので、エラーになっていたことは分かるのですが、メールが来るまではエラーが起きているかどうか分からず、修正が翌日以降になってしまっていました。
こちらは、すべての処理が終わった後に1行`チェック終了しました`のメッセージをslack通知することで解消しました。ここに書くか迷うくらい超省エネでの解決でしたが、目的は達成できました。同じ目的達成ができるなら、低コストな方でやりたいですね。ただ、あえて一度コストがかかることをやって学ぶこともあるので、そのときの気分次第なところもあります。

エラーに対する対策、発生したときに調査できる材料・手段を用意することは大切ですね。フォルダにhtmlを日次保存するようにしてから、何か起きたときの調査スピードが格段に速くなりました。また、私以外の人もhtmlを見ての確認・調査が可能になりました。

## スクレイピングする際の注意
ページへの負荷などから、[スクレイピングが禁止されているwebサイトもあります](https://qiita.com/n_oshiumi/items/b4efd1f40ec0a1b77376)。予め、サイトの規約等を確認しておくようにしましょう。

## 今後
- phantomJSは更新がストップしているサービスなので、Chrome Headless等他のヘッドレスブラウザで代替したい。
- ハッシュ値比較ではなく、ドライブに保存したhtml同士で差分比較して更新箇所まで通知したい。


## ツールを長期で運用してみて
今年3月からなので長期と言っていいのかは分からないですが、継続して運用している中で学ぶことは多いです。今書いてる内容って3月は知らなかったな、とか思いながら書いてました。
あとはチーム内で運用しているので、何か起きたときにフィードバック→修正のサイクルが早いこと、自分が作ったものが毎日動いているのが見れること、たまに事件が起きること、新しく技術を試すことができること、はシンプルに楽しいです。
普段の開発業務とは別の手法で開発をやることで学べることは多いので、今後も色々作っていこうと思います。