GAS活用：小説家になろう更新通知を受け取る

## 最初にまとめ
- 「小説家になろう」に掲載されている小説の更新通知をLineに送ります。
- 同じテーマはQiita、他サイトで見かけられはしたものの、実現手段が同じでソースコードを公開しているものが無かったため記事にしました。
- 散々言われていますが、「自分個人用に何かを開発したい際にGoogle Apps Script(GAS)はかなり手軽に使えて良い」ということを実感しました。

## はじめに
- 現在放送中のアニメ「蜘蛛ですが、なにか？」、かなり人を選ぶと思いますが面白いですよね :spider: 
- 原作は「小説家になろう」で連載中！　（おそらく）最終局面の真っ只中なので、**次回更新が待ち遠しくて仕方ない**
- ただ、更新通知をメール等で送ってくれる機能がないので、サイトを覗きに行っては、まだ更新されて無かった…と落ち込む毎日…。
- そこで更新を自動チェックする手段を構築します。
    - 通知インタフェースとしてはLineを
    - 更新確認および通知送信にはGAS + Line Nofityを使います
    - 選定理由：お手軽かつ無料 :moneybag: 

## アウトプットイメージ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621288/deb2ebdb-952a-c9a9-5fde-1b7fec6e7033.png" width="320px" alt="Line画面イメージ">


## 制作
### 事前準備
- Line Notifyのアクセストークンを取得します。検索すればいくらでも出てくるので割愛します。
- Google Spread SheetをDB代わりに使うので、下記のようなフォーマットを作成しておきます。
  - 通知対象：購読したい小説のNコード（URLの末尾です）を記載
  - 最終更新日、最新話数はブランクで大丈夫です
  - 複数の小説を購読したい場合には、どんどん下に追加していけばOKです
- GASのタイムゾーンがデフォルトだと北米になっているので、以下手順を参考に日本時間にしておくと良いです。[リンク](https://qiita.com/park3taro/items/a4c61305386e151a6529)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621288/5c377f73-99ac-ddcb-1ed4-401efb37ba13.png)

### 実装

1. 事前準備で用意したスプレッドシートの「ツール」→「スクリプトエディタ」と進んで、以下のコードを貼り付けます。
2. 事前準備で取得したLine Notifyのトークンを`YOUR_TOKEN`の部分に貼り付けます。
3. その後GAS上のトリガー機能で、実行する関数「main」、イベント「時間ベース」として、お好みの時間間隔を設定すれば完成。お手軽ですね。

```javascript
const NAROU_ENDPOINT = "https://api.syosetu.com/novelapi/api/?out=json&ncode=";
const LINE_ENDPOINT = "https://notify-api.line.me/api/notify";
const LINE_TOKEN = "YOUR_TOKEN";

// スプレッドシートの設定
const COL_ID = 1;
const COL_UPDATE = 2;
const COL_TOTAL = 3;
const ROW_FIRST_CONTENT = 2;

const sheet = SpreadsheetApp.getActiveSheet();

function getNovelInfo(id) {
  const resp = UrlFetchApp.fetch(NAROU_ENDPOINT + id);
  const parsed_resp = JSON.parse(resp.getContentText());
  // [0]には取得した総ノベル数、[1]以降に各ノベル情報が返ってくる。必要なのは[1]だけ。
  return parseNovel(parsed_resp[1])
}

function parseNovel(novel) {
  const title = novel.title;
  const lastUpdate = new Date(novel.general_lastup.replace("-", "/"));
  const total = novel.general_all_no;
  return { "title": title, "lastUpdate": lastUpdate, "total": total }
}

function overwriteSheet(idx, novel) {
  sheet.getRange(idx, COL_UPDATE).setValue(novel.lastUpdate);
  sheet.getRange(idx, COL_TOTAL).setValue(novel.total);
}

function getUnread(idx, novel) {
  // 最終既読～最新話までの話数リストを返す。
  // ただし最終既読の記録が無かったり、記録値が不正だった場合のエラー処理を含む。
  try {
    const end = novel.total;

    // 最終既読が記録されていなければ、最新話だけを通知する
    if (sheet.getRange(idx, COL_TOTAL).isBlank()) {
      return [end]
    }
    const start = sheet.getRange(idx, COL_TOTAL).getValue() + 1;

    // 最終既読記録値が不正なら、最新話だけを通知する
    if (start >= end) {
      console.warn("Saved total would not be correct. Overwrote it by the new value.");
      return [end]
    }

    return [...Array((end - start + 1))].map(function (_, i) {
      return start + i
    })
  }
  catch (error) {
    console.error(error);
  }
}

function makePlainMailBody(idx, novel) {
  const id = sheet.getRange(idx, COL_ID).getValue();
  const url = "https://ncode.syosetu.com/" + id + "/"
  const unread = getUnread(idx, novel);

  let mailBody = `\n${novel.title} が更新されました。\n\n`;
  for (let i of unread) {
    mailBody += `第${i}話\n${url}${String(i)}\n`;
  }
  return mailBody
}

function lineNotify(message) {
  const req = {
    "method": "post",
    "headers": { "Authorization": "Bearer " + LINE_TOKEN },
    "payload": { "message": message }
  };
  UrlFetchApp.fetch(LINE_ENDPOINT, req);
}

function notifyUpdate(idx, novel) {
  lineNotify(makePlainMailBody(idx, novel));
  overwriteSheet(idx, novel);
}

function main() {
  const lastRow = sheet.getLastRow();

  for (let i = ROW_FIRST_CONTENT; i <= lastRow; i++) {
    const id = sheet.getRange(i, COL_ID).getValue()

    try {
      const novel = getNovelInfo(id);
      const isFirstCheck = sheet.getRange(i, COL_UPDATE).isBlank();

      if (isFirstCheck) {
        console.log("First check");
        notifyUpdate(i, novel);
        continue
      }

      const prevUpdate = new Date(sheet.getRange(i, COL_UPDATE).getValue());
      const isUpdated = (prevUpdate.getTime() - novel.lastUpdate.getTime() < 0 ? true : false);

      if (isUpdated) {
        console.log("found update");
        notifyUpdate(i, novel);
      } else {
        console.log("no update")
      }

    }
    catch (error) {
      console.error(error);
    }
  }
}
```

### 簡単に解説

1. 公式の「なろう小説API」で読みたい小説の情報（最終更新日・話数）を取得
2. スプレッドシート上に最終更新日が記録されていない or 記録されているが日付が古い場合、更新されたと判断する
3. 更新ありの場合、最終既読～最新話までの話数リストを生成して、各話数までのURLを添えてLineに通知（例えば前回更新で1話まで更新されていて、今回のチェックで3話が見つかったら、2話・3話のURLを送る）
4. スプレッドシート上の情報を更新

## おわりに
GASを触ってみたいと思っていたので、趣味と実益を兼ねた良い経験になりました。
ちなみに、これが完成してからまだ一度も更新通知は届いていません :innocent: 

## 参考
### 同じような機能で稼働中のサービス
[ネット小説更新チェック](http://newnvs.ddo.jp/newnvs/) 様：
メールアドレスを登録すると更新通知を送ってくれます

### 先達の試み
一番最後の記事がやりたいことそのままだったのですが、残念ながらソースコードを見つけられず…。

https://qiita.com/k_keisuke/items/54767d7298e0f658f96d

https://qiita.com/amabow/items/2c7eca5e51f3bcf14bb7

https://jijyoron.hatenablog.com/entry/2019/02/02/163925