【コピペで始める】GASでつくるconnpass新着イベント通知LINEBOT

## はじめに

この記事は、「とりあえず外部API叩いてLINEBOTに通知することをGASでやりたい人」向けの知見提供を目的としています。

LINEBOTの開発準備や、GASの使い方は沢山記事があったり、公式のドキュメントも充実しているので、そちらに委譲します。

LINEBOT作成に必要なことのうち、本記事で書くこと、書かないことは以下です。

- 書くこと
  - GASと連携させるスプレッドシートの前準備
  - GASのコード
  - 本botを作るときのちょっとしたtips

- 書かないこと
  - [LINEBOTの開発準備](https://developers.line.biz/ja/docs/messaging-api/getting-started/#step-two-log-in-to-line-developers-console)
  - [GASの使い方](https://developers.google.com/apps-script/overview?hl=ja)
  - [GASの環境変数（システムプロパティ）設定方法](https://developers.google.com/apps-script/guides/properties?hl=ja)
    - LINEBOTのアクセストークンを設定するために使う
  - GASの定期実行設定

## スプレットシートの準備

GASで開発を行うとき、スプレッドシートをDBのように使うことができます。
コードの話をする前に、簡単にスプレッドシートの前準備について説明します。
（ヘッダーを記述しておくだけなので、コードを変更すれば、このステップも必要ないのですが、、、）


以下のように、A1セルに「event_id」、A2セルに「fetch_date」と入力します。
「event_id」はconnpassのイベントIDで、通知済みの新着イベントのIDを記録しておきます。
「fetch_date」は必須でないですが、一応timestampを記録しておきます。

![スクリーンショット 2024-03-16 22.50.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1796961/5b5de134-6ec6-6567-1bc1-fb97859be856.png)

## コード解説

本題です。とりあえずコードの全量を載せます。
テストコードも書いてないし、リファクタリングもしてないのでお粗末ですが、ご容赦ください。（作成後1ヶ月問題なく動いてるので問題ない、はず）
GitHubにpushしてるので[こちら](https://github.com/masaki10/connpass-new-events-notifier/tree/main)もぜひ。（テストコードのPR待ってます！！！！！）

```javascript
const FETCHED_EVENT_TABLE = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("fetched_event");
const CONNPASS_API_ENDPOINT = "https://connpass.com/api/v1/event/?order=3&count=100"
const LINE_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_TOKEN");
const LINE_BROADCAST_ENDPOINT = "https://api.line.me/v2/bot/message/broadcast";

// main関数
const pushNewConnpassEvent = () => {
  const now = new Date();

  // connpass APIで新着ベントを取得
  const result = fetchEvents();

  // 通知済みのイベントを取得
  const pushedEventIds = findPushedEventIds();

  // 通知済みのイベントを除外
  const originalNewEvents = removePushedEvents(result.events, pushedEventIds);

  // 「もくもく」が含まれるイベントを除外
  const newEvents = eliminateEventsIncludingWord(originalNewEvents, "もくもく");

  // LINEのMessaging APIは同時に5つまでメッセージを送れるため、配列を5個ずつに分割
  const slicedNewEvents = sliceByNumber(newEvents, 5);

  slicedNewEvents.forEach((events) => {
    // push用のフォーマットを作成
    const pushedContents = events.map((e) => createPushFormat(e));

    // 未通知のイベントをLINEに通知
    const isSuccess = pushToLine(pushedContents);

    if (isSuccess) {
      // 通知済みのイベントを保存
      events.forEach((e) => {
        savePushedEventId(e.event_id, now);
      });
    }

  });

}

const fetchEvents = () => {
  const response = UrlFetchApp.fetch(CONNPASS_API_ENDPOINT);
  return JSON.parse(response.getContentText());
}

const readAllRecord = (sheet) => {
  if (sheet.getLastRow() < 2) {
    return [];
  }

  // const headers = getHeaders(sheet);
  const range = sheet.getRange(2, 1, sheet.getLastRow(), sheet.getLastColumn());
  return range.getValues();
}

const findPushedEventIds = () => {
  const records = readAllRecord(FETCHED_EVENT_TABLE);
  return records.map((r) => r[0]);
}

const savePushedEventId = (id, date) => {
  const newRow = FETCHED_EVENT_TABLE.getLastRow() + 1;

  const rangeForId = FETCHED_EVENT_TABLE.getRange(`A${newRow}`);
  rangeForId.setValue(id);

  const rangeForDate = FETCHED_EVENT_TABLE.getRange(`B${newRow}`);
  rangeForDate.setValue(date);
}

const removePushedEvents = (events, pushedEventIds) => {
  return events.filter((e) => {
    return !pushedEventIds.includes(e.event_id);
  });
}

const eliminateEventsIncludingWord = (events, word) => {
  return events.filter((e) => {
    return !e.title.includes(word);
  });
}

const createPushFormat = (event) => {
  const title = event.title;
  // const description = event.description;
  const eventUrl = event.event_url;
  const startedAt = formatDate(isoToDate(event.started_at));
  const endedAt = formatDate(isoToDate(event.ended_at));
  const place = event.place;

  return `${title}\n${startedAt} ~ ${endedAt} at ${place}\n${eventUrl}`;
}

const formatDate = (date) => {
  return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
}

const isoToDate = (timestampStr) => {
  const timestampMs = Date.parse(timestampStr);
  return new Date(timestampMs);  
}

const pushToLine = (contents) => {
  var isSuccess = true;

  const payload = {
    messages: contents.map((c) => {
      return {
        type: 'text', 
        text: c
      }
    })
  };

  const param = {
    method: "post",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${LINE_TOKEN}`,
    },
    payload: JSON.stringify(payload),
  };

  try {
    UrlFetchApp.fetch(LINE_BROADCAST_ENDPOINT, param);
  } catch (e) {
    console.log(e);
    isSuccess = false;
  }

  return isSuccess;
};

const sliceByNumber = (array, number) => {
  const length = Math.ceil(array.length / number)
  return new Array(length).fill().map((_, i) =>
    array.slice(i * number, (i + 1) * number)
  )
}
```

### 定数の説明

最初に定数について説明します。
「FETCHED_EVENT_TABLE」はDB（テーブル）として使用するスプレッドシートのシートです。

「CONNPASS_API_ENDPOINT」は[connpass API](https://connpass.com/about/api/)のエンドポイントです。
新着順に並べ替えて（order=3）、100件取得してます（count=100）。

「LINE_TOKEN」はLINEBOTのアクセストークンです。
GASの環境変数（システムプロパティ）から取得しています。設定方法は[こちら](https://developers.google.com/apps-script/guides/properties?hl=ja)。

「LINE_BROADCAST_ENDPOINT」はLINEBOTに配信するためのAPIエンドポイントです。

```javascript
const FETCHED_EVENT_TABLE = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("fetched_event");
const CONNPASS_API_ENDPOINT = "https://connpass.com/api/v1/event/?order=3&count=100"
const LINE_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_TOKEN");
const LINE_BROADCAST_ENDPOINT = "https://api.line.me/v2/bot/message/broadcast";
```

### GASで実行されるメイン関数の説明

ここから、関数単位で解説していきます。（全てはしないです。もし不明な箇所があればお気軽に聞いてください！）
まずは、メイン関数（pushNewConnpassEvent）の説明です。

コメントを読めば何をしているのかすぐにわかりますね、素晴らしい。（自画自賛、勿論冗談です。）

処理の流れはコメントを見ればわかると思うので割愛します。
「もくもく」が含まれるイベントを除外しているのは、新着イベントが多くて優先度が低いイベント（私の場合もくもく会）を除外したかったからです。
お好みでコメントアウトしたり、変更したりしてください。

```javascript
// main関数
const pushNewConnpassEvent = () => {
  const now = new Date();

  // connpass APIで新着ベントを取得
  const result = fetchEvents();

  // 通知済みのイベントを取得
  const pushedEventIds = findPushedEventIds();

  // 通知済みのイベントを除外
  const originalNewEvents = removePushedEvents(result.events, pushedEventIds);

  // 「もくもく」が含まれるイベントを除外
  const newEvents = eliminateEventsIncludingWord(originalNewEvents, "もくもく");

  // LINEのMessaging APIは同時に5つまでメッセージを送れるため、配列を5個ずつに分割
  const slicedNewEvents = sliceByNumber(newEvents, 5);

  slicedNewEvents.forEach((events) => {
    // push用のフォーマットを作成
    const pushedContents = events.map((e) => createPushFormat(e));

    // 未通知のイベントをLINEに通知
    const isSuccess = pushToLine(pushedContents);

    if (isSuccess) {
      // 通知済みのイベントを保存
      events.forEach((e) => {
        savePushedEventId(e.event_id, now);
      });
    }

  });

}
```

### DB代わりのスプレッドシートから通知済みイベントを取得する関数の説明

1行目にヘッダーがあり、カラムが2であることを前提としたコードになっています。
`sheet.getRange()`で、2行1列目から値が入力されている最後の行、列までの範囲を指定して、`range.getValues()`で値を取得しています。
event_idとfetched_dateを取得しているので、`records.map((r) => r[0])`でevent_idのみにしています。

（今見ると、ツッコミどころ満載のコードですが、知らないふりをします。）

```javascript
const readAllRecord = (sheet) => {
  if (sheet.getLastRow() < 2) {
    return [];
  }

  // const headers = getHeaders(sheet);
  const range = sheet.getRange(2, 1, sheet.getLastRow(), sheet.getLastColumn());
  return range.getValues();
}

const findPushedEventIds = () => {
  const records = readAllRecord(FETCHED_EVENT_TABLE);
  return records.map((r) => r[0]);
}
```

### 配信用のフォーマット作成関数の説明

コードとしては説明は不要だと思いますが、実際どういうメッセージになっているか載せておきます。

![スクリーンショット 2024-03-16 23.38.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1796961/11c10374-0574-7377-77a9-e01cda7b0feb.png)


```javascript
const createPushFormat = (event) => {
  const title = event.title;
  // const description = event.description;
  const eventUrl = event.event_url;
  const startedAt = formatDate(isoToDate(event.started_at));
  const endedAt = formatDate(isoToDate(event.ended_at));
  const place = event.place;

  return `${title}\n${startedAt} ~ ${endedAt} at ${place}\n${eventUrl}`;
}
```

### LINEBOTに配信する関数の説明

payloadとheaderを押さえておけば問題ないと思います。
とは言ってもそれらもそんなに複雑なものではないです。

今回配信するのはテキストなので、`type`をtestとし、`text`に配信したい内容を設定しています。
そして、その配列を`messages`に設定しています。（1〜5個まで設定できます。）

headerは、`Content-Type`と`Authorization`を設定してあげれば良いです。一般的な認証つきAPIと同様です。

（配信失敗判定が終わっていますが、このとき仕事で例外設計にうんざりしていた時期なので勘弁してくださいw）

```javascript
const pushToLine = (contents) => {
  var isSuccess = true;

  const payload = {
    messages: contents.map((c) => {
      return {
        type: 'text', 
        text: c
      }
    })
  };

  const param = {
    method: "post",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${LINE_TOKEN}`,
    },
    payload: JSON.stringify(payload),
  };

  try {
    UrlFetchApp.fetch(LINE_BROADCAST_ENDPOINT, param);
  } catch (e) {
    console.log(e);
    isSuccess = false;
  }

  return isSuccess;
};

```

### 配信済みイベントをスプレッドシートに保存する関数の説明

スプレッドシートの入力済みの最終行を取得して、その次の行にイベントIDとタイムスタンプを入力しています。

```javascript
const savePushedEventId = (id, date) => {
  const newRow = FETCHED_EVENT_TABLE.getLastRow() + 1;

  const rangeForId = FETCHED_EVENT_TABLE.getRange(`A${newRow}`);
  rangeForId.setValue(id);

  const rangeForDate = FETCHED_EVENT_TABLE.getRange(`B${newRow}`);
  rangeForDate.setValue(date);
}
```

## ちょっとしたTips

大したものではないですが、今回のbotを作る上でのtipsを何点か共有します。

- LINEのMessaging APIは、無料プランだと月に200通までしか送れないので、節約のために5個ずつまとめて配信する
- connpassのイベントは思いの外多いので、見やすさ・送信量節約のために、除外キーワードなどでフィルターをかける
- （私は好みの問題でやってないですが、）複数のイベントを1つのメッセージとして送ることで、送信量を節約する

## おわりに

外部APiから情報を取得して、LINEBOTに配信したい需要はあると思っているのですが、作成するのに時間をかけたくない部分でもあると思うので、この記事を書きました。（zennやqiitaのトレンド記事を送るなどにもコストかけずに応用できると思います。）

誰かの役に立てば嬉しく思います。

是非！リファクタやテストコードのPR待ってます！！