【SamuraiStats】2. サーバーを建ててLINEメッセージを送る

前回記事

https://qiita.com/kohki_takatama/items/463007a57d26e116c20f

今回は**Messaging API**（LINEメッセージを送るAPI）と**Glitch**（無料サーバー）を使って、「『最新』というキーワードに対し最新の試合成績を返す」というコードを書きました。

＜実際の動作＞
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3457770/c04a1ef3-2e2d-44c7-c274-5770034688a7.png)


## コード
### Messaging APIで「メッセージを送る」
「前回の出力結果（下記）を、VSCodeを開かなくても見れるようにしたい」と思いました。
```shell
(1) ['吉田 正尚']
(11)  ['日付：5月25日', '対戦チーム：エンゼルス', '打数：4', '安打：1', '本塁打：0', '打点：0', '得点：1', '三振：0', '四球：0', '死球：0', '打席結果：一併打、中２、二ゴロ、一ゴロ']
```
はじめはTwitter APIを使ってツイート&通知しようと思いましたが、全くうまくいかなかったのでMessaging API（LINE）を使うことにしました。

以下がMessaging APIでブロードキャストメッセージ（友達登録されている全員に送るメッセージ）を送信するコードです。

```js
import line from "@line/bot-sdk";
import * as dotenv from "dotenv";
dotenv.config();

const sendMessageToLINE = (messageText) => {
  const config = { channelAccessToken: process.env.ACCESS_TOKEN };
  const client = new line.Client(config);

  client
    .broadcast({ type: "text", text: messageText }) //NOTE: 個別で送る場合はpushMessageメソッドを使う。引数は(userID, message)
    .then(() => {
      console.log("Message was sent successfully");
    })
    .catch((err) => {
      console.error("An error occurred while sending message:", err);
    });
};
```
部分ごとに分解します。
①必要なものをインポート
```js
import line from "@line/bot-sdk";
import * as dotenv from "dotenv";
dotenv.config();
```
②チャンネルアクセストークン（.envファイルに保存）をもとにインスタンスを生成、
アクセストークンは[LINE Developers](https://developers.line.biz/ja/services/messaging-api/)から確認
```js
const config = { channelAccessToken: process.env.ACCESS_TOKEN };
const client = new line.Client(config);
```
③broadcastメソッドを使い、登録されている全員にメッセージを送信。
```js
client.broadcast({ type: "text", text: messageText }) 
//messageTextは送りたいメッセージの内容

//個別に送りたい場合は↓
client.pushMessage(userId, {type: "text", text: messageText})
//自分のuserIdは"LINE Developers > チャネル基本設定 > あなたのユーザーID"で確認
//メッセージ送信元のuserIdはevent
```
ただし、これらは一方的な「送信」であって、「メッセージの送信を検知し返信する」といったことにはサーバーが必要です。
### サーバーをたてる（Glitch）
https://glitch.com/

無料サーバーGlitchを使用しました。見よう見まねで、解説できることがないのでコードだけ置いておきます。
```server.js
// 必要なモジュールをインポートします
const fastify = require("fastify")({ logger: false });
const line = require("@line/bot-sdk");

const samuraiList = require("./dbForScrape.js");
const scrapeToArray = require("./scrape.js")
const testLog = require("./test.js");
const sendToLineAsReply = require("./main.js")

// LINEの設定情報をロードします
const CONFIG = {
  channelAccessToken: process.env.ACCESS_TOKEN,
  channelSecret: process.env.SECRET_KEY,
};

// application/jsonのリクエストボディを文字列として扱うように設定します
fastify.addContentTypeParser(
  "application/json",
  { parseAs: "string" },
  function (req, body, done) {
    done(null, body);
  }
);

// /webhook へのPOSTリクエストを処理します
fastify.post("/webhook", async function (request, reply) {
  // リクエストがLINEから来たものか確認します
  const rawBody = request.body; // 文字列としてのリクエストボディ
  const signature = request.headers["x-line-signature"]; // LINEからの署名
  if (!line.validateSignature(rawBody, CONFIG.channelSecret, signature)) {
    reply.code(403).send("Unauthorized"); // 署名が一致しない場合、403エラーを返します
    return;
  }

  // リクエストボディをJavaScriptオブジェクトに変換します
  const parsedBody = JSON.parse(rawBody);

  // 各イベントを処理します
  parsedBody.events.map((event) => {
    //返信する関数にreplyTokenとmessage.text(送られてきたメッセージ文)を送る
    replyToLine(event.replyToken, event.message.text)
  });

  // 200 OKを返します
  reply.code(200).send("OK");
});

// サーバーを起動します
fastify.listen(
  { port: process.env.PORT, host: "0.0.0.0" },
  function (err, address) {
    if (err) {
      console.error(err);
      process.exit(1);
    }
    console.log(`Your app is listening on ${address}`);
  }
);

fastify.get("/", async (request, reply) => {
  return { hello: "world" };
});
```

### Messaging APIで「キーワードを検出し返信する」
汚いコードですがこれもとりあえず置いておきます（雑
sortArray / convertToDate 云々と書いているのは、最新の試合順に並び替えてリプライを送るためです。
```js
const line = require("@line/bot-sdk");

const samuraiList = require("./dbForScrape.js");
const scrapeToArray = require("./scrape.js");
const formatToReply = require("./formatToReply.js");
const convertToDate = require("./convertToDate.js");

const CONFIG = {
  channelAccessToken: process.env.ACCESS_TOKEN,
  channelSecret: process.env.SECRET_KEY,
};

const client = new line.Client(CONFIG);

const scrapeAndFormat = async (url, cssSelector) => {
  const scrapedData = await scrapeToArray(url, cssSelector);
  const formatedData = formatToReply(scrapedData);
  return formatedData;
};
const replyToLine = async (token, message) => {
  let replyMessage = "";
  if (message.includes("最新")) {
    let sortArray = [];
    for (let i in samuraiList) {
      sortArray.push(
        await scrapeToArray(samuraiList[i].url, samuraiList[i].selector)
        
      );
    }
    sortArray.sort(
      (a, b) =>
        convertToDate(a.recentStats.日付) - convertToDate(b.recentStats.日付)
    );
    for (let i in sortArray) {
      const formatedData = formatToReply(sortArray[i]);
      console.log(i);
      console.log(formatedData)
      client.replyMessage(token, { type: "text", text: formatedData });
    }
  } else {
    client.replyMessage(token, { type: "text", text: "don't match any case" });
  }
};
```


## 課題：replyTokenの失効問題
replyTokenは30秒ほどで失効するらしく、スクレイピングが間に合わずreplyが途切れてしまうという問題が発生しました。
replyMessageメソッドではなくpushMessageメソッドを使えばreplyTokenを使わないので、良いのでは？と思ったのですが、以下の表にあるようにpushMessageを使うと一定数までのメッセージしか送信できません。
（しかも**昨日改定**されて、送信上限が1000通→200通に激減）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3457770/ff95d94a-fc45-8de7-182a-4d8e1d83977f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3457770/7d21ac29-59fa-9403-01a6-c311c0641c5c.png)
そこで、「データベースを建てて、定期的にスクレイピングしたデータを保存し、replyはデータベースを参照して行う」という試みをしてみようと思います。データベースの構築と運用は初めてです。次回の記事で書こうと思います。

雑な記事ですみません。ただ、次回もこんな感じで書くかもしれません。ご容赦を。
ほな、また。

記録：初挑戦：API, Messaging API, require, サーバーの利用, replyMessage