GASでなろうの更新通知ボットを作った話

Ｒｅ：ゼロから始める異世界生活のアニメ2期絶賛放映中ですね。4章は長いですがその分色々な総決算をする章だと思っているので個人的にはすごく好きです。

そんなリゼロですが、小説家になろうというWeb小説のサイトで更新されていることはご存知でしょうか？
この小説家になろうというサイト（通称「なろう」）では実にさまざまな小説が公開されており、自分はそのいくつかの作品を愛読しています。(無職転生とかも今アニメでやってますよね)

そんななろうですが、一つ問題点があります。
それが「**いつ更新されたのか分からん問題**」です。

自分は好きな作家さんはTwitterなどでフォローする派なので、まだ更新されたかどうかわかりやすいですが、Twitterをやってない方や更新したことを呟かない方なんかももちろん存在します。

そういった時には、なろうのサイトをいちいち開いて確認しに行かねばならないのです。これは実に面倒くさい。どうにかして更新されたことを知れないものかと思っていたところ「[なろう小説API](https://dev.syosetu.com/man/api/)」があることを知りました。

これを使えば更新された時に通知してくれるLINEボットを作れるのでは？と思ったので今回はGASを使って小説家になろうで好きな作品が更新されたらLINEに通知してくれるボット君を作りました。

## 大まかな構成
今回使用したのはGASとClaspというnpmパッケージを使用して開発しました。
GAS(Google Apps Scriptの略)とはGoogle系のサービス(Gmailやスプレッドシート)などをいい感じに扱えるようにしてくれたJavaScriptっぽい言語です。

また、ClaspはGASをローカルかつTypescriptで開発できるようにしてくれるものです。
より詳しく知りたい方は以下の記事を読むといいと思います。
[GAS のGoogle謹製CLIツール clasp](https://qiita.com/HeRo/items/4e65dcc82783b2766c03)

構成としてはGoogleSpredSheetをデータベースと見立てて好きな作品の「ID」「最終更新日」「話数」を記録してます。
これを元に1時間ごとになろうAPIにリクエストを投げ、最終更新日や話数が更新されていればLINEに通知という感じの構成になっています。

## なろうAPI
[なろうAPI](https://dev.syosetu.com/man/api)ですが、一つの作品の情報を取得しようと思って時は以下のようなURLにGetリクエストを送るとJSON形式でレスポンスが返ってきます。

```terminal
$ curl "https://api.syosetu.com/novelapi/api/?out=json&ncode=[小説のID]"
```

小説のIDとは実際のなろうのサイトで作品をみた時のURLの`https://ncode.syosetu.com/xxxxxxx`のxxxxxxxの部分になります。

```json
[
  {
    "allcount": 1
  },
  {
    "title": "Ｒｅ：ゼロから始める異世界生活",
    "ncode": "N2267BE",
    "userid": 235132,
    "writer": "鼠色猫/長月達平",
    "story": "突如、コンビニ帰りに異世界へ召喚されたひきこもり学生の菜月昴。知識も技術も武力もコミュ能力もない、ないない尽くしの凡人が、チートボーナスを与えられることもなく放り込まれた異世界で必死こいて生き抜く。彼に与えられたたった一個の祝福は、『死んだら巻き戻ります』という痛みを伴う『死に戻り』のみ！　頼れるもののいない異世界で、いったい彼は何度死に、なにを掴み取るのか。　　※血も死体も出る予定ですが、そんな派手なことにはなりません。\n\n※当作品は2014年1月24日、ＭＦ文庫Ｊ様の方から同タイトルで書籍化させていただいております。皆様の応援のおかげです、ありがとうございます。また、ＷＥＢ版の削除・ダイジェスト化・更新停止といったことは今後も予定されておりません。書籍・ＷＥＢ共によろしくお願いします。\n\n【祝！　アニメ化決定いたしました！】\n皆様の応援のおかげです！　今後もよろしくお願いします！\n\n≪アニメ公式サイト≫\nhttp://re-zero-anime.jp/",
    "biggenre": 2,
    "genre": 201,
    "gensaku": "",
    "keyword": "R15 残酷な描写あり 異世界転移 異世界 ファンタジー 銀髪ヒロイン 感想乞食 バトル シリアス ほのぼの 時間遡行 死に戻り",
    "general_firstup": "2012-04-20 21:58:11",
    "general_lastup": "2021-02-13 01:00:00",
    "novel_type": 1,
    "end": 1,
    "general_all_no": 514,
    "length": 5979377,
    "time": 11959,
    "isstop": 0,
    "isr15": 1,
    "isbl": 0,
    "isgl": 0,
    "iszankoku": 1,
    "istensei": 0,
    "istenni": 1,
    "pc_or_k": 2,
    "global_point": 552267,
    "daily_point": 224,
    "weekly_point": 1392,
    "monthly_point": 11946,
    "quarter_point": 27696,
    "yearly_point": 115516,
    "fav_novel_cnt": 200523,
    "impression_cnt": 19748,
    "review_cnt": 174,
    "all_point": 151221,
    "all_hyoka_cnt": 15536,
    "sasie_cnt": 1,
    "kaiwaritu": 41,
    "novelupdated_at": "2021-02-14 19:53:40",
    "updated_at": "2021-03-07 10:15:49"
  }
]
```

色々な情報がきていますが、ここで必要になるのは
話数の`general_all_no`と最終更新日の`general_lastup`になります。

## 対象作品の管理
通知して欲しい作品の管理ですが、SpreadSheetで以下のように管理しています。
新しい作品を追加する際に必要になるのはタイトルとIDのみで、後のカラムは適当に0でも入れとけば勝手に更新してくれます。

![スクリーンショット 2021-03-07 10.30.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363545/72b2a4d7-a683-62f8-a8f2-847ed8db1549.png)

## Source Code

実際のコードを先に載せておきます。
機能が簡素なだけあって大体60行くらいの処理ですみます。（コスパいいね）

```typescript:index.ts
import Spreadsheet = GoogleAppsScript.Spreadsheet.Spreadsheet;

const BASE_URL = "https://api.syosetu.com/novelapi/api/?out=json&ncode=";
const LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push";

const notifyUpdate = () => {
  const accessToken = PropertiesService.getScriptProperties().getProperty(
    "ACCESS_TOKEN"
  );
  const spredsheetId = PropertiesService.getScriptProperties().getProperty(
    "SPREDSHEET_ID"
  );
  const roomId = PropertiesService.getScriptProperties().getProperty(
    "ROOM_ID"
  );

  // 既存のspreadシートを取得する
  const spreadSheet = SpreadsheetApp.openById(spredsheetId || "");

  const sheet = spreadSheet.getSheetByName("シート1");
  if (!sheet) {
    // TODO エラーを通知
    return;
  }

  const sources = sheet.getDataRange().getValues();
  sources.shift();
  sources.forEach((source, index) => {
    const data = JSON.parse(
      UrlFetchApp.fetch(BASE_URL + source[1]).getContentText()
    );

    if (
      data[1]["general_lastup"] !== source[2] &&
      parseInt(data[1]["general_all_no"]) > parseInt(source[3])
    ) {
      // update spredsheet
      sheet.getRange(index + 2, 3).setValue(data[1]["general_lastup"]);
      sheet.getRange(index + 2, 4).setValue(data[1]["general_all_no"]);

      UrlFetchApp.fetch(LINE_PUSH_URL, {
        headers: {
          "Content-Type": "application/json; charset=UTF-8",
          Authorization: "Bearer " + accessToken,
        },
        method: "post",
        payload: JSON.stringify({
          to: roomId,
          messages: [
            {
              type: "text",
              text: `${data[1]["title"]}が更新されました！！\n https://ncode.syosetu.com/${data[1]["ncode"]}/${data[1]["general_all_no"]}`,
            },
          ],
        }),
      });
    }
  });
};

```

ちょっとずつ説明していきます。
まずはプロパティの取得部分です。もちろんLINEのアクセストークンやSpreadSheetのIDなんかは秘匿したい情報ですのでコードには直接載せたくないです。ということでプロパティを外部で定義します。

GASのWebエディタを開き、以前のエディタにします。（多分右上ら辺にあるはず）

以前のエディタに戻したら、右上が添付の画像みたいになると思いますので、「ファイル」を選択し、一番下の「プロジェクトのプロパティ」を選択します。

![スクリーンショット 2021-03-07 10.41.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363545/a095718a-da6b-a6d6-d6f4-5c158229a13e.png)

選択したらこれまた添付のような画面が出てくると思いますので、上のタブから「スクリプトのプロパティ」を選択。
「行を追加」で追加したい変数を定義していきます。

![スクリーンショット 2021-03-07 10.42.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363545/e7fc1cdc-5a23-bd1c-0051-ed4cbced7730.png)


上記で設定したプロパティを取得するのが以下の部分になります。

```javascript
  const accessToken = PropertiesService.getScriptProperties().getProperty(
    "ACCESS_TOKEN"
  );
  const spredsheetId = PropertiesService.getScriptProperties().getProperty(
    "SPREDSHEET_ID"
  );
  const roomId = PropertiesService.getScriptProperties().getProperty(
    "ROOM_ID"
  );
```

次はスプレッドシートから情報を抜き取る作業です。

```javascript

  // 既存のspreadシートを取得する
  const spreadSheet = SpreadsheetApp.openById(spredsheetId || "");

  const sheet = spreadSheet.getSheetByName("シート1");
  if (!sheet) {
    // TODO エラーを通知
    return;
  }

  const sources = sheet.getDataRange().getValues();
  sources.shift();
  sources.forEach((source, index) => {
    ...
  })
```

`SpreadsheetApp.openById(spredsheetId || "");`の関数を読み出すことでスプレッドシートを取得できます。
また、`spreadSheet.getSheetByName("シート名");`でスプレッドシート内のシートを取得することができます。

シート内のセルを取得するのが`sheet.getDataRange().getValues();`の部分になります。
`getDataRange()`はそのシート内で記載されている範囲を取得する関数で左上から右下のセルまで取得してくれます。

`sources`を`shift()`しているのは単にヘッダ部分を除外しているだけです。

次は対象の作品ごとに更新されているかどうかを確認し、実際にLINEにポストする部分になります。

```javascript
  sources.forEach((source, index) => {
    const data = JSON.parse(
      UrlFetchApp.fetch(BASE_URL + source[1]).getContentText()
    );

    if (
      data[1]["general_lastup"] !== source[2] &&
      parseInt(data[1]["general_all_no"]) > parseInt(source[3])
    ) {
      // update spredsheet
      sheet.getRange(index + 2, 3).setValue(data[1]["general_lastup"]);
      sheet.getRange(index + 2, 4).setValue(data[1]["general_all_no"]);

      UrlFetchApp.fetch(LINE_PUSH_URL, {
        headers: {
          "Content-Type": "application/json; charset=UTF-8",
          Authorization: "Bearer " + accessToken,
        },
        method: "post",
        payload: JSON.stringify({
          to: roomId,
          messages: [
            {
              type: "text",
              text: `${data[1]["title"]}が更新されました！！\n https://ncode.syosetu.com/${data[1]["ncode"]}/${data[1]["general_all_no"]}`,
            },
          ],
        }),
      });
    }
  });
```

ちょっと量が多いですが、一つずつみていきましょう。

```javascript
    const data = JSON.parse(
      UrlFetchApp.fetch(BASE_URL + source[1]).getContentText()
    );
```
この部分でなろうAPIをコールしています。それを変数に格納しています。


```javascript
    if (
      data[1]["general_lastup"] !== source[2] &&
      parseInt(data[1]["general_all_no"]) > parseInt(source[3])
    ) {
      // update spredsheet
      sheet.getRange(index + 2, 3).setValue(data[1]["general_lastup"]);
      sheet.getRange(index + 2, 4).setValue(data[1]["general_all_no"]);

      UrlFetchApp.fetch(LINE_PUSH_URL, {
        headers: {
          "Content-Type": "application/json; charset=UTF-8",
          Authorization: "Bearer " + accessToken,
        },
        method: "post",
        payload: JSON.stringify({
          to: roomId,
          messages: [
            {
              type: "text",
              text: `${data[1]["title"]}が更新されました！！\n https://ncode.syosetu.com/${data[1]["ncode"]}/${data[1]["general_all_no"]}`,
            },
          ],
        }),
      });
    }
```
ここでは「レスポンスの『最終更新日』がSpreadSheetと一致しない」かつ「話数がSpreadSheetの話数より多い」という条件式を書いています。
もしこれらの条件を達成していた時はSpreadSheetの内容を更新し、さらにLINEのグループチャンネルにメッセージをポストするようにしています。

## 定期処理の設定
これだけでは、トリガーがないので動作しません。なので、1時間ごとにプログラムが走ってくれるように設定します。
再度Webエディタを開いてもらい(今回は新しいエディタ)左側のメニューから目覚ましアイコンの「トリガー」を選択します。
右下に「トリガーを追加」というボタンがあるので、それを押します。
すると添付の画像のような画面が出ると思います。
![スクリーンショット 2021-03-07 11.03.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363545/11bdcc61-bf2f-1a99-685b-9588c3a7c629.png)

それぞれ以下のように設定していきましょう

- 実行する関数を選択  
    これは定義した関数名を選択すればOKです。
- イベントのソースを選択
    「時間主導型」を選択しましょう。
- 時間ベースのトリガーのタイプを選択
    「時間ベースのタイマー」を選択しましょう。
- 時間の間隔を選択 
    実行したい間隔をお好きに設定してください。

設定が終わったら右下の「保存」を押して終了します。
これで1時間ごとにプログラムが動くようになりました！

## 実際の動作
実際の動作はこんな感じになります。
いい感じですね。これなら更新を見逃すことがなさそうです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/363545/172d440d-32b8-f250-9de6-9beda462873d.jpeg" width=350>

## 最後に
今回はGASを使って簡単にLINEボットを作ってみましたが、GASの魅力はこういった「簡単に実現したいことを実現できる（しかも無料で）」というところにあると思います。SpreadSheetをデータベースに見立てられる分、連携することでさらにより多くのことを実現できるようになると思います。

今回の記事ではLINEボットの作り方自体にフォーカスしなかったのでアクセストークンの取得の仕方など分からない部分が多々あると思います。そういった方には以下の記事がおすすめですので補完しながら作ってみてください。
[Google Apps ScriptでLINE BOTつくったら30分で動かせた件](https://qiita.com/hakshu/items/55c2584cf82718f47464)

リゼロの7章すごく面白いですね。早く続きが読みたいです。