5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【LINE bot】記事にいいねをもらったら LINE に通知してささやかな喜びをつぶれるほど抱きしめる 🍒

Last updated at Posted at 2025-05-14

はじめに

(いいねをくれた)君を忘れない

一生懸命書いた記事にいいねがもらえると嬉しいですよね ☺️
しかし、Qiita を見にこないと、いいねがもらえたことに気づけません。
それでは寂しいので LINE に通知します 🔔

「Web プッシュ通知なら受け取れるよ」

何か声が聞こえますが、無視します。
作りたいから作るんです。

きっと 想像した以上に 騒がしい未来が僕を待ってる

使った技術

  • Google Apps Script(以下 GAS)
    • コンピューティング、定期実行トリガー
  • Google Sheets
    • データベースの代わり
  • Qiita API v2
    • 記事一覧&いいね一覧を取得
  • LINE Messaging API
    • LINE にメッセージ送信、Webhook

成果物

1 日 2 回、新着いいねがないかチェックして、あったら LINE に通知する bot です 🤖
QiitaLikeNotifier と LineUserIdLogger の 2 つのアプリケーションからなります。

QiitaLikeNotifier

メインロジックです。

構成図

QiitaLikeNotifier.drawio.png

スクリプト

const QIITA_TOKEN =
  PropertiesService.getScriptProperties().getProperty("QIITA_TOKEN");
const QIITA_ARTICLES_SPREADSHEET_ID =
  PropertiesService.getScriptProperties().getProperty(
    "QIITA_ARTICLES_SPREADSHEET_ID"
  );
const LINE_CHANNEL_ACCESS_TOKEN =
  PropertiesService.getScriptProperties().getProperty(
    "LINE_CHANNEL_ACCESS_TOKEN"
  );
const LINE_USER_ID_SPREADSHEET_ID =
  PropertiesService.getScriptProperties().getProperty(
    "LINE_USER_ID_SPREADSHEET_ID"
  );

/**
 * Qiitaから記事一覧を取得する
 * @param {number} page 取得ページ番号
 * @param {number} perPage 1ページあたりの取得件数
 * @return {{ 'id': string, 'title': string, 'url': string, 'likes_count': number }[]} 記事一覧
 */
function getArticlesFromQiita(page, perPage) {
  // https://qiita.com/api/v2/docs#get-apiv2authenticated_useritems
  const response = UrlFetchApp.fetch(
    `https://qiita.com/api/v2/authenticated_user/items?page=${page}&per_page=${perPage}`,
    {
      method: "get",
      headers: {
        Authorization: `Bearer ${QIITA_TOKEN}`,
      },
    }
  );

  const body = JSON.parse(response.getContentText());
  const articles = body.map((item) => ({
    id: item["id"],
    title: item["title"],
    url: item["url"],
    likes_count: item["likes_count"],
  }));

  return articles;
}

/**
 * 記事IDから記事情報へのマッピングを取得する
 * @param {{ 'id': string, 'title': string, 'url': string, 'likes_count': number }[]} 記事一覧
 * @return {{ [articleId: string]: { 'title': string, 'url': string, 'likes_count': number }[] }} 記事IDから記事情報へのマッピング
 */
function getArticleId2Article(articles) {
  const articleId2Article = {};
  articles.forEach((article) => {
    const id = article["id"];
    articleId2Article[id] = {
      title: article["title"],
      url: article["url"],
      likes_count: article["likes_count"],
    };
  });

  return articleId2Article;
}

/**
 * スプレッドシートからデータを取得する
 * @param {Sheet} sheet スプレッドシート
 * @return {{ headers: string[], rows: (string | number)[][] }}
 */
function getDataFromSpreadsheet(sheet) {
  const startRow = 1;
  const startColumn = 1;
  const numRows = sheet.getLastRow();
  const numColumns = sheet.getLastColumn();
  // https://developers.google.com/apps-script/reference/spreadsheet/sheet?hl=ja#getRange(Integer,Integer,Integer,Integer)
  // https://developers.google.com/apps-script/reference/spreadsheet/range?hl=ja#getValues()
  const data = sheet
    .getRange(startRow, startColumn, numRows, numColumns)
    .getValues();

  const headers = data[0];
  const rows = data.length > 1 ? data.slice(1) : [];

  return { headers, rows };
}

/**
 * スプレッドシートのデータを更新する
 * @param {Sheet} sheet スプレッドシート
 * @param {(string | number)[][]} rows 行データ
 */
function updateDataToSpreadsheet(sheet, rows) {
  const startDataRow = 2;
  const startColumns = 1;
  const numDataRows = rows.length;
  const numColumns = sheet.getLastColumn();
  // https://developers.google.com/apps-script/reference/spreadsheet/range?hl=ja#setValues(Object)
  sheet
    .getRange(startDataRow, startColumns, numDataRows, numColumns)
    .setValues(rows);
}

/**
 * 記事IDからいいね件数へのマッピングを取得する
 * @param {(string | number)[][]} rows スプレッドシートの行データ
 * @return {{ [articleId: string]: number }} 記事IDからいいね件数へのマッピング
 */
function getArticleId2LikesCountFromSheetData(rows) {
  const articleId2LikesCount = {};
  rows.forEach((row) => {
    const [id, likesCount] = row;
    articleId2LikesCount[id] = likesCount;
  });

  return articleId2LikesCount;
}

/**
 * 記事IDからスプレッドシート上のインデックスを取得する
 * @param {(string | number)[][]} rows スプレッドシートの行データ
 * @param {string} targetArticleId 記事ID
 * @return {number} スプレッドシート上のインデックス
 */
function findRowIndexByArticleId(rows, targetArticleId) {
  const foundIndex = rows.findIndex((row) => row[0] === targetArticleId);
  return foundIndex === -1 ? null : foundIndex;
}

/**
 * 記事に新しくいいねがついたか判定する
 * @param {{ 'id': string, 'title': string, 'url': string, 'likes_count': number }[]} 記事一覧
 * @return {{ [articleId: string]: number }} 新しくいいねがついた記事IDからいいね件数の差分へのマッピング
 */
function checkIfArticlesGotNewLikes(articlesFromQiita) {
  // https://developers.google.com/apps-script/reference/spreadsheet/spreadsheet-app?hl=ja#openById(String)
  // https://developers.google.com/apps-script/reference/spreadsheet/spreadsheet?hl=ja#getSheets()
  const qiitaArticlesSheet = SpreadsheetApp.openById(
    QIITA_ARTICLES_SPREADSHEET_ID
  ).getSheets()[0];
  const { rows } = getDataFromSpreadsheet(qiitaArticlesSheet);
  const articleId2PrevLikesCount = getArticleId2LikesCountFromSheetData(rows);

  const articleId2LikesDelta = {};
  articlesFromQiita.forEach((currArticle) => {
    const id = currArticle["id"];
    const currLikesCount = currArticle["likes_count"];

    const prevLikesCount = articleId2PrevLikesCount[id];
    // 新しく追加された記事の場合、レコードを追加する
    if (prevLikesCount == null) {
      rows.push([id, currLikesCount]);
      // いいねが1件以上あった場合、新しくいいねがついたと判定する
      if (currLikesCount > 0) {
        articleId2LikesDelta[id] = currLikesCount;
      }
      return;
    }

    // 前回の判定からいいねが増えた場合、いいね件数を更新する
    if (currLikesCount > prevLikesCount) {
      const rowIndex = findRowIndexByArticleId(rows, id);
      if (rowIndex != null) {
        rows[rowIndex][1] = currLikesCount;
        articleId2LikesDelta[id] = currLikesCount - prevLikesCount;
      }
    }
  });

  // スプレッドシートを一括更新する
  updateDataToSpreadsheet(qiitaArticlesSheet, rows);

  return articleId2LikesDelta;
}

/**
 * Qiitaから記事IDに紐づく新しくついたいいね一覧を取得する
 * @param {string} 記事ID
 * @param {number} あたらしくついたいいね件数
 * @return {{ 'created_at': string, 'user_id': string, 'article_id': string }[]} 指定した記事IDの新しくついたいいね一覧
 */
function getLikesFromQiitaByArticleId(articleId, delta) {
  // https://qiita.com/api/v2/docs#get-apiv2itemsitem_idlikes
  const response = UrlFetchApp.fetch(
    `https://qiita.com/api/v2/items/${articleId}/likes`,
    {
      method: "get",
      headers: {
        Authorization: `Bearer ${QIITA_TOKEN}`,
      },
    }
  );

  const body = JSON.parse(response.getContentText());
  const likes = body.map((item) => ({
    created_at: item["created_at"],
    user_id: item["user"]["id"],
    article_id: articleId,
  }));

  return likes.slice(0, delta);
}

/**
 * すべての新しくついたいいね一覧を取得する
 * @param {{ [articleId: string]: number }} 新しくいいねがついた記事IDからいいね件数の差分へのマッピング
 * @return {{ 'created_at': string, 'user_id': string, 'article_id': string }[]} すべての新しくついたいいね一覧
 */
function getNewLikes(articleId2LikesDelta) {
  const newLikes = Object.entries(articleId2LikesDelta).flatMap(([id, delta]) =>
    getLikesFromQiitaByArticleId(id, delta)
  );

  const compareByCreatedAtDesc = (a, b) =>
    new Date(b["created_at"]) - new Date(a["created_at"]);
  const sortedNewLikes = newLikes.sort(compareByCreatedAtDesc);

  return sortedNewLikes;
}

/**
 * いいねメッセージを作成する
 * @param {{ 'created_at': string, 'user_id': string, 'article_id': string }[]} いいね一覧
 * @param {{ 'id': string, 'title': string, 'url': string, 'likes_count': number }[]} 記事一覧
 * @return {string[]} いいねメッセージ一覧
 */
function createLikeMessages(newLikes, articlesFromQiita) {
  const articleId2Article = getArticleId2Article(articlesFromQiita);

  const likeMessages = newLikes.map((like) => {
    const userId = like["user_id"];
    const articleId = like["article_id"];

    const article = articleId2Article[articleId];
    const articleTitle = article["title"];
    const articleUrl = article["url"];

    const createdAt = like["created_at"];
    const createdAtDate = new Date(createdAt);
    const timeZone = "Asia/Tokyo";
    // http://developers.google.com/apps-script/reference/utilities/utilities?hl=ja#formatDate(Date,String,String)
    const formattedCreatedAt = Utilities.formatDate(
      createdAtDate,
      timeZone,
      "yyyy/MM/dd HH:mm"
    );

    return `@${userId}があなたの記事${articleTitle}にいいねしました。\n${articleUrl}\n${formattedCreatedAt}`;
  });

  return likeMessages;
}

/**
 * LINEユーザーにいいねメッセージをプッシュ通知する
 * @param {string[]} いいねメッセージ一覧
 * @return {TextOutput} TextOutputオブジェクト
 */
function pushLikeMessagesToLineUsers(likeMessages) {
  // 新しくいいねがない場合、通知しない
  if (likeMessages.length === 0) {
    return ContentService.createTextOutput("No new likes");
  }

  const lineUserIdSheet = SpreadsheetApp.openById(
    LINE_USER_ID_SPREADSHEET_ID
  ).getSheets()[0];
  const { rows } = getDataFromSpreadsheet(lineUserIdSheet);
  if (rows.length === 0) {
    return ContentService.createTextOutput("Failed to find user id");
  }
  const userId = rows[0][0];

  // 同時に送信できるメッセージは最大5件
  const MAX_MESSAGE_COUNT = 5;
  const messages = likeMessages.slice(0, MAX_MESSAGE_COUNT).map((message) => ({
    type: "text",
    text: message,
  }));

  // https://developers.line.biz/ja/docs/messaging-api/sending-messages/#send-messages-at-any-time
  // https://developers.line.biz/ja/reference/messaging-api/#send-push-message
  const url = "https://api.line.me/v2/bot/message/push";
  const payload = {
    to: userId,
    messages,
  };
  const options = {
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: `Bearer ${LINE_CHANNEL_ACCESS_TOKEN}`,
    },
    payload: JSON.stringify(payload),
  };
  UrlFetchApp.fetch(url, options);

  // https://developers.google.com/apps-script/reference/content/content-service?hl=ja#createtextoutput
  return ContentService.createTextOutput("OK");
}

function main() {
  const page = 1;
  const perPage = 20;
  const articlesFromQiita = getArticlesFromQiita(page, perPage);

  const articleId2LikesDelta = checkIfArticlesGotNewLikes(articlesFromQiita);
  const newLikes = getNewLikes(articleId2LikesDelta);

  const likeMessages = createLikeMessages(newLikes, articlesFromQiita);
  return pushLikeMessagesToLineUsers(likeMessages);
}

LineUserIdLogger

プッシュ通知をするのに userId が必要です。
友だち追加 or 適当なメッセージ送信したときに取得してスプレッドシートに保存しておきます。
そのためだけの GAS です。

構成図

LineUserIdLogger.drawio.png

スクリプト

const SPREADSHEET_ID =
  PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID");

/**
 * スプレッドシートからデータを取得する
 * @param {Sheet} sheet スプレッドシート
 * @return {{ headers: string[], rows: (string | number)[][] }}
 */
function getDataFromSpreadsheet(sheet) {
  const startRow = 1;
  const startColumn = 1;
  const numRows = sheet.getLastRow();
  const numColumns = sheet.getLastColumn();
  // https://developers.google.com/apps-script/reference/spreadsheet/sheet?hl=ja#getRange(Integer,Integer,Integer,Integer)
  // https://developers.google.com/apps-script/reference/spreadsheet/range?hl=ja#getValues()
  const data = sheet
    .getRange(startRow, startColumn, numRows, numColumns)
    .getValues();

  const headers = data[0];
  const rows = data.length > 1 ? data.slice(1) : [];

  return { headers, rows };
}

/**
 * スプレッドシートのデータを更新する
 * @param {Sheet} sheet スプレッドシート
 * @param {(string | number)[][]} rows 行データ
 */
function updateDataToSpreadsheet(sheet, rows) {
  const startDataRow = 2;
  const startColumns = 1;
  const numDataRows = rows.length;
  const numColumns = sheet.getLastColumn();
  // https://developers.google.com/apps-script/reference/spreadsheet/range?hl=ja#setValues(Object)
  sheet
    .getRange(startDataRow, startColumns, numDataRows, numColumns)
    .setValues(rows);
}

function doPost(e) {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheets()[0];
  const { rows } = getDataFromSpreadsheet(sheet);
  // userIdを取得済みの場合、後続の処理をスキップする
  if (rows.length > 0) {
    return ContentService.createTextOutput("UserId already exists.");
  }

  // WebhookイベントオブジェクトからuserIdを取り出す
  const json = JSON.parse(e.postData.contents);
  const events = json.events;
  const targetEvent = events.find(
    (event) => event.type === "follow" || event.type === "message"
  );
  const userId = targetEvent.source.userId;

  // userIdをスプレッドシートに保存する
  rows.push([userId]);
  updateDataToSpreadsheet(sheet, rows);

  return ContentService.createTextOutput("OK");
}

手順

簡単に手順を説明します 🧑‍🏫
コードについては結構はしょります。

1. Qiita API のトークンを取得する

画面右上の自分のアイコンからメニューを開いて「設定」に行って

qiita-setting.png

「アプリケーション」のここから取得できます。

qiita-api-token.png

取得したトークンは、忘れずにメモしておきましょう ✍️

2. LINE Messaging API のトークンを取得する

以下を参考に LINE 公式アカウントを作ってください。

LINE Messaging API の利用を有効にする方法

少しわかりづらいので補足します 💁‍♂️

LINE Official Account Manager で作ったアカウントの「設定」に行って

account-setting.png

「Messaging API」のここから利用を有効化できます。

enable-messaging-api.png

トークンを取得する

LINE Developers コンソールでチャネルの「Messaging API 設定」に行って

messaging-api-setting.png

下の方のここから取得できます。

messaging-api-token.png

3. スプレッドシートを作成する

いいね件数のログを保存するシート QiitaArticles とプッシュ通知先の ID を保存するシート LineUsetIdLogger を作成します。

それぞれ以下のようになる想定です。

qiita-articles-sheet.png

line-user-id-sheet.png

GAS からスプレッドシートを参照するために ID を控えておきましょう。
URL の{sheet_id}です。

https://docs.google.com/spreadsheets/d/{sheet_id}/edit?gid=0#gid=0

4. API トークンとシート ID を GAS のスクリプトプロパティに保存する

GAS ではスクリプトプロパティという環境変数っぽく扱える変数を設定できます。
API トークンや ID などはスクリプトプロパティとして保存しておくと便利です 🏪

「プロジェクトの設定」に行って

gas-project-setting.png

下の方のここで編集できます。

gas-script-property.png

スクリプトからこのように参照します。

const QIITA_TOKEN =
  PropertiesService.getScriptProperties().getProperty("QIITA_TOKEN");
const QIITA_ARTICLES_SPREADSHEET_ID =
  PropertiesService.getScriptProperties().getProperty(
    "QIITA_ARTICLES_SPREADSHEET_ID"
  );
const LINE_CHANNEL_ACCESS_TOKEN =
  PropertiesService.getScriptProperties().getProperty(
    "LINE_CHANNEL_ACCESS_TOKEN"
  );
const LINE_USER_ID_SPREADSHEET_ID =
  PropertiesService.getScriptProperties().getProperty(
    "LINE_USER_ID_SPREADSHEET_ID"
  );

5. もりもりコードを書く

詳しくは成果物のところに添付したコードを読んでください 🙇‍♂️

5.1 外部サービスへのリクエスト回数を減らす

一般に、Web アプリでは外部サービスとの通信がボトルネックになりがちです。(アプリケーションからデータベースへのクエリ実行など)
そのため、API コールの回数をできるだけ少なくする工夫をしてみました。

5.1.1 Qiita API へのリクエスト

Qiita API を使っていいねを取得する流れは以下です。
データベースの世界で N+1 クエリと呼ばれるリクエストになっています。

  1. GET /api/v2/authenticated_user/items?page=1&per_page=20 で認証中ユーザーの記事一覧を取得する(リクエスト 1 回)
  2. GET /api/v2/items/:item_id/likes で記事 ID に紐づくいいね一覧を取得する(リクエスト N 回)

本当は JOIN して一括で取得したいですが、Qiita API を使って実現する方法はありません。

古い記事にいいねをもらう可能性はありますが、「最新 20 件の記事のみ、新着いいねがあるか判定する」という仕様にしました。
N+1 の N を小さくする作戦です 🤓

5.1.2 LINE Messaging API へのリクエスト

LINE へのプッシュ通知には POST https://api.line.me/v2/bot/message/push を使います。
この API では最大 5 件までメッセージを送信できます。

新着いいねが 5 件以上ある可能性はありますが、リクエストが 1 回になるよう、「最新 5 件以内のいいねを通知する」という仕様にしました。

// 同時に送信できるメッセージは最大5件
const MAX_MESSAGE_COUNT = 5;
const messages = likeMessages.slice(0, MAX_MESSAGE_COUNT).map((message) => ({
  type: "text",
  text: message,
}));

5.2 LINE のプッシュ通知先の ID を取得する方法

POST https://api.line.me/v2/bot/message/push は、payload に送信先の ID(to)を要求します。

curl -v -X POST https://api.line.me/v2/bot/message/push \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {channel access token}' \
-H 'X-Line-Retry-Key: {UUID}' \
-d '{
    "to": "U4af4980629...",
    "messages":[
        {
            "type":"text",
            "text":"Hello, world1"
        },
        {
            "type":"text",
            "text":"Hello, world2"
        }
    ]
}'

送信先の ID は、Webhook イベントオブジェクト で返される userId です。

このuserId を取得&保存するために LineUserIdLogger が必要です。
公式アカウントを友だち追加 or 適当なメッセージを送信したときに、userId を取得&保存するスクリプトになっています。

function doPost(e) {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheets()[0];
  const { rows } = getDataFromSpreadsheet(sheet);
  // userIdを取得済みの場合、後続の処理をスキップする
  if (rows.length > 0) {
    return ContentService.createTextOutput("UserId already exists.");
  }

  // WebhookイベントオブジェクトからuserIdを取り出す
  const json = JSON.parse(e.postData.contents);
  const events = json.events;
  const targetEvent = events.find(
    (event) => event.type === "follow" || event.type === "message"
  );
  const userId = targetEvent.source.userId;

  // userIdをスプレッドシートに保存する
  rows.push([userId]);
  updateDataToSpreadsheet(sheet, rows);

  return ContentService.createTextOutput("OK");
}

6. LineUserIdLogger をデプロイする

LineUserIdLogger は、LINE ユーザーの操作起点のイベントによって処理を実行したいです。
LINE Messaging API の Webhook に設定する Web アプリとしてデプロイします 🪝

GAS の画面左上から「新しいデプロイ」を選択して

deploy-menu.png

以下のように設定してデプロイ!

gas-new-deploy.png

いろいろ承認を求められますが、全部承認するとデプロイが完了します。

この URL をコピーして

gas-web-app-url.png

以下を参考に Webhook の設定をします。

7. QiitaLikeNotifier に定期実行トリガーを設定する

GAS の左メニューバーから「トリガー」に行って

gas-trigger.png

トリガーを追加 🔫

trigger-list-empty.png

こんな感じで設定します。

trigger-setting.png

私は「午後 12〜1 時」と「午後 6 時〜7 時」の 2 つトリガーを設定しました。

trigger-list.png

8. 通知が来る(はず)

やったぜ ✌️🌇

おわりに

いつかまた この場所(Qiita)で 君とめぐり会いたい

参考文献

  • スピッツ.(1996). チェリー. インディゴ地平線. ポリドール.
5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?