ChatGPTの要約を付けた自動ニュース投稿Slack BotをGoogle Apps Scriptを使って作成してみた

# はじめに

こんにちは。@yyyimai です。
今回は、Google Apps Script(以下、GAS)を使って自動的にニュースを取得し、Slackに投稿するSlack Botを構築する方法についてご紹介します。

このSlack Botは、定期的にニュースを収集し、特定のキーワードに関連するものを自動的にSlackに投稿することができます。これにより、ニュースに常にアクセスできるだけでなく、手動で投稿する手間を省くことができます。

この記事では、GASを使ってニュースを自動取得する方法、取得したニュースを処理する方法、そしてSlack APIを使用してSlackに投稿する方法について説明します。また、この記事を通じて、GASやSlack APIの基礎知識を身につけることができます。

さらにちょっとだけOpen AIのAPIを使ってChatGPT風味でニュースの要約を行うという追加機能も付けています。

それでは、さっそく始めていきましょう。

# 概要

GASは、Googleが提供するJavaScriptっぽい言語の実行環境であり、GWS（Googleドキュメント、スプレッドシート、Gmailなど）と連携することができます。この記事では、GASを使って自動ニュース投稿Slack Botを構築します。

このSlack Botの機能は、自動的にニュースを取得し、Slackに投稿することです。GASは、HTTPリクエストを送信して外部APIからデータを取得し、データを処理してSlackに投稿することができます。具体的には、GASでRSSフィードを取得し、取得したデータをフィルタリングしてSlackに投稿することができます。

今回準備するものをとしては以下のものとなります。

- Googleスプレッドシート(取得した記事を残すために利用します)
- GAS
- Slack Bot

そして、ニュースを投稿するまでの流れは以下のような形になります。

![ブログ用1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/ad381323-fa5c-600f-8e94-7419c8964beb.png)

では、作成する処理について説明していきたいと思いますが、今回の処理の中でスクレイピングに近い処理を実装しています。できる限り頻度は減らしていますが**スクレイピングは、Webサイトの利用規約に違反する場合があり、法的な問題を引き起こす可能性があります**。また、**スクレイピングを行うとWebサイトの負荷が増加し、サービスの品質に悪影響を与えることがあります**。節度を持って利用していただくようお願いいたします。

# 作成したそれぞれの処理について

## 前提条件

GASではappsscript.jsonという設定ファイルが利用できます。設定メニューの中の「「appsscript.json」マニフェスト ファイルをエディタで表示する」のチェックを有効にしてください。

![ブログ用8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/266a60db-4130-20d1-82ae-21672047741a.png)

そして今回のGASで利用する外部ライブラリや権限については以下のように設定を編集するようにしてください。

```json:appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "dependencies": {
    "libraries": [
      {
        "userSymbol": "Cheerio",
        "libraryId": "1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0",
        "version": "14"
      }
    ]
  },
  "oauthScopes": [
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/script.external_request"
  ]
}
```

今回利用しているOpen AIのAPIキーの情報についてはスクリプトプロパティに登録するようにしています。もし今回のコードをそのまま流用する方がいればお気を付けください。ご自身でAPIキーを取得する必要があります。

![ブログ用9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/0ae21b7f-2084-67a0-f5c1-db69274ffc07.png)


## ニュース取得処理

ニュース取得処理に関しては各サイトが提供しているRSSを利用します。このRSSは種類としてRSS1.0とRSS2.0、ATOMの３種類あるためそれぞれのタイプに合わせて動くようにしました。

そして、スプレッドシートに取得先のサイト一覧を作成しておいて、その一覧に載っているサイトを4時間ごとにクロールする形としました。今回はRSSというシート名で一覧を作成しています。

![ブログ用2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/4ed3c042-86a9-17f0-2edd-d9c909f2602e.png)

そして、取得したニュースを残す先としてはArticleとFeedという2つのシートを使いました。

Articleシートは取得したニュースをすべて残すために利用していて、RSSから取得したURLと比較してすでに取得していたニュースはシートには残さないという機能のために利用しています。

![ブログ用3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/8e722a6c-8ffc-cb13-b5ba-28edf48ada45.png)

そして、新規で取得したニュースに対しては特定のキーワードをタイトルに含んでいるかという判定を実施します。この時のキーワードに関してはKeywordというシート上に記載しました。

![ブログ用4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/9893dd1a-9f2f-5619-228f-c4b45fb3946b.png)

このキーワードに引っ掛かったニュースだけをFeedシートに追加する形となります。

![ブログ用5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/3e61c797-887c-a838-7b92-20f61ad42edc.png)

実際にGASで記載したコードは以下となります。

```javascript:rss.gs
// スプレッドシートのID
var sheetId = 'GoogleスプレッドシートのID'
// シートの名前一覧
var rssSheetName = "RSS";
var feedSheetName = "Feed";
var articleSheetName = 'Article';
var keywordSheetName = 'Keyword';

function mainRss() {
  let spreadSheet = SpreadsheetApp.openById(sheetId);
  let rssSheet = spreadSheet.getSheetByName(rssSheetName);
  let feedSheet = spreadSheet.getSheetByName(feedSheetName);
  let articleSheet = spreadSheet.getSheetByName(articleSheetName);
  let keywordSheet = spreadSheet.getSheetByName(keywordSheetName);

  var rssLastRowNum = rssSheet.getLastRow() - 1;
  var rssArray = rssSheet.getRange(2, 1 , rssLastRowNum, 2).getValues();
  console.log(rssArray)

  var articleLastRowNum = articleSheet.getLastRow() - 1;
  var articleArray = articleSheet.getRange(2, 3 , articleLastRowNum).getValues();
  console.log(articleArray)

  var keywordLastRowNum = keywordSheet.getLastRow() - 1;
  var keywordArray = keywordSheet.getRange(2, 1 , keywordLastRowNum).getValues().flat();
  console.log(keywordArray)

  for (var i = 0; i < rssArray.length; i++) {
    console.log(rssArray[i][1])
    switch (rssArray[i][0]) {
      case 'RSS1.0':
        postReleaseNotificationRSS1(rssArray[i][1],articleArray,feedSheet,articleSheet,keywordArray);
        break;

      case 'RSS2.0':
        postReleaseNotificationRSS2(rssArray[i][1],articleArray,feedSheet,articleSheet,keywordArray);
        break;

      case 'ATOM':
        postReleaseNotificationAtom(rssArray[i][1],articleArray,feedSheet,articleSheet,keywordArray);
        break;
    }
  }
}

function postReleaseNotificationRSS1(url,articleArray,feedSheet,articleSheet,keywordArray) {
  // RSSの読み込み
  let xml = UrlFetchApp.fetch(url).getContentText()
  let document = XmlService.parse(xml)
  let root = document.getRootElement()
  var rss = XmlService.getNamespace('http://purl.org/rss/1.0/');
  var dc = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/');
  let entries = root.getChildren('item', rss);


  // 古いものから比較するため
  entries.reverse()

  // RSSから取得したデータと比較と保存
  for (var i = 0; i < entries.length; i++) {
    var title = entries[i].getChild('title', rss).getText()
    var link = entries[i].getChild('link', rss).getText()
    var updated = entries[i].getChild('date', dc).getText()
    var flag = false;

    // URLが一致しないときは新しいデータ
    if (articleArray.some(url => url[0] === link)) {
      console.log("continueしたよ")
      continue
    }

    console.log(link)

    for (var j = 0; j < keywordArray.length; j++) {
      if (title.includes(keywordArray[j]) == true){
        feedSheet.appendRow([updated, title, link])
        flag = true
        break
      }
    }
    // スプレッドシートへの保存
    articleSheet.appendRow([updated, title, link, flag])
  }
}

function postReleaseNotificationRSS2(url,articleArray,feedSheet,articleSheet,keywordArray) {
  // RSSの読み込み
  let xml = UrlFetchApp.fetch(url).getContentText()
  let document = XmlService.parse(xml)
  let root = document.getRootElement()
  let entries = root.getChild('channel').getChildren('item');


  // 古いものから比較するため
  entries.reverse()

  // RSSから取得したデータと比較と保存
  for (var i = 0; i < entries.length; i++) {
    var title = entries[i].getChild('title').getText()
    var link = entries[i].getChild('link').getText()
    var updated = Utilities.formatDate(new Date(entries[i].getChild('pubDate').getText()), 'JST', "yyyy-MM-dd'T'HH:mm:ssXXX")
    var flag = false;

    // URLが一致しないときは新しいデータ
    if (articleArray.some(url => url[0] === link)) {
      console.log("continueしたよ")
      continue
    }

    console.log(link)

    for (var j = 0; j < keywordArray.length; j++) {
      if (title.includes(keywordArray[j]) == true){
        feedSheet.appendRow([updated, title, link])
        flag = true
        break
      }
    }
    // スプレッドシートへの保存
    articleSheet.appendRow([updated, title, link, flag])
  }
}

function postReleaseNotificationAtom(url,articleArray,feedSheet,articleSheet,keywordArray) {
  // RSSの読み込み
  let xml = UrlFetchApp.fetch(url).getContentText()
  let document = XmlService.parse(xml)
  let root = document.getRootElement()
  let atom = XmlService.getNamespace('http://www.w3.org/2005/Atom')
  let entries = root.getChildren('entry', atom)


  // 古いものから比較するため
  entries.reverse()

  // RSSから取得したデータと比較と保存
  for (var i = 0; i < entries.length; i++) {
    var title = entries[i].getChild('title', atom).getText()
    var link = entries[i].getChild('link', atom).getAttribute('href').getValue()
    var updated = Utilities.formatDate(new Date(entries[i].getChild('updated', atom).getText()), 'JST', "yyyy-MM-dd'T'HH:mm:ssXXX")
    var flag = false;

    // URLが一致しないときは新しいデータ
    if (articleArray.some(url => url[0] === link)) {
      console.log("continueしたよ")
      continue
    }

    console.log(link)

    for (var j = 0; j < keywordArray.length; j++) {
      if (title.includes(keywordArray[j]) == true){
        feedSheet.appendRow([updated, title, link])
        flag = true
        break
      }
    }
    // スプレッドシートへの保存
    articleSheet.appendRow([updated, title, link, flag])
  }
}
```

## ChatGPTの要約処理

次にChatGPTの要約を付けるため、Open AIのAPIを使った処理を実装します。

Feedシートに一つ前のニュース取得処理で新規ニュースを取得したので、要約されていないニュースが存在しているはずです。そして、Feedシートには要約されているニュースも存在していることでしょう。ですので、プログラムの作りとしては要約済みのニュースは要約しないという作りとしました。

実際にGASで記載したコードは以下となります。

```javascript:gpt.gs
function mainGPT() {
  var spreadSheet = SpreadsheetApp.openById(sheetId);
  var feedSheet = spreadSheet.getSheetByName(feedSheetName);

  var feedLastRowNum = feedSheet.getLastRow() - 1;
  var feedRange = feedSheet.getRange(2, 3 , feedLastRowNum, 3)
  var feedList = feedRange.getValues()

  for (var i = 0; i < feedList.length; i++) {
    console.log(feedList[i][0])
    console.log(feedList[i][2])
    if (feedList[i][2] != '済') {
      try {
        var resultGPT = postChatGPT(feedList[i][0])
        feedList[i][1] = resultGPT
        feedList[i][2] = '済'
      } catch(e) {
        feedList[i][1] = 'なんらかの理由で要約できませんでした。記事の内容が気になる方は直接ご確認ください。'
        feedList[i][2] = '済'
      }
    }
  }
  
  feedRange.setValues(feedList)
}

function postChatGPT(articleURL){
  // URLからHTMLを取得
  var response = UrlFetchApp.fetch(articleURL);
  var html = response.getContentText();
  
  // Cheerioを使用してHTMLを解析
  var $ = Cheerio.load(html);
  
  // ブログ記事の本文を含むpタグを取得
  var paragraphs = $("p");
  
  // 本文からテキストを抽出
  var text = "";
  paragraphs.each(function() {
    var paragraphText = $(this).text();
    text += paragraphText + "\n";
  });
  console.log(text.length)


  //API Keyを取得する
  var prop = PropertiesService.getScriptProperties();
  var apikey = prop.getProperty("APIKEY")
  //chatGPTのエンドポイントURL
  var url = "https://api.openai.com/v1/chat/completions";

  //リクエストヘッダ
  var header = {
    "Authorization":"Bearer "+ apikey,
    "Content-type": "application/json",
  }
  
  var systemParameter = '入力された文章を要約してください。\n\
\n\
  制約条件\n\
  ・文章は簡潔にわかりやすく。\n\
  ・箇条書きで3行で出力。\n\
  ・1行あたりの文字数は80文字程度。\n\
  ・重要なキーワードは取り逃がさない。\n\
  ・要約した文章は日本語で出力。\n\
\n\
  '

  //リクエストボディを作成
  var payload = {
    "model": "gpt-3.5-turbo",
    "max_tokens" : 2048,  //最大値は2048まで
    "temperature" : 1.0,  //最大値は2.0
    "messages": [
      {"role": "system", "content": systemParameter},
      {"role": "user", "content": text}
      ]
  }

  //リクエストオプション
  var options = {
    "muteHttpExceptions" : true,
    "headers": header, 
    "method": "POST",
    "payload": JSON.stringify(payload)
  }

  //Open AIにリクエスト実行
  var res = UrlFetchApp.fetch(url, options);

  //レスポンスデータを取り出す
  var result = JSON.parse(res.getContentText());
  console.log(result)
  
  var gptresponse = result.choices[0].message.content;

  //OpenAIのAPIレスポンスを戻り値で返却
  return gptresponse;
}
```

## ニュース投稿

ここまでで、要約されたニュース一覧が作成されているものと思います。このニュースを実際にSlack Botの権限を使って投稿します。Slack Botの作成についてはあまり詳細な方法をこのブログでは説明しませんが、ご了承ください。

まず、Slack Botを準備します。このSlack Botには以下の画像のように権限を付与してください。

![ブログ用6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/83fbd79e-170b-e4d4-c7b0-4d83f08f8ffb.png)

そして、ワークスペースにインストールしていただければ、OAuthトークンが発行されるはずです。

![ブログ用7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/dc045f59-8b42-a45a-9665-05231e416963.png)

このOAuthトークンは以下のGASで記載したコードで利用するため、コピーして貼り付けるようにしてください。

```javascript:slack.gs
function mainSlack() {
  var today = new Date ();
  // 営業日であれば実行
  if (isWorkday(today) == false) {
    console.log("今日は休日です")
    return "今日は休日です"
  }

  var slackText = '今日のGoogle関係のニュースです。'

  let spreadSheet = SpreadsheetApp.openById(sheetId);
  let feedSheet = spreadSheet.getSheetByName(feedSheetName);

  var feedLastRowNum = feedSheet.getLastRow() - 1;
  var feedRange = feedSheet.getRange(2, 2 , feedLastRowNum, 5)
  var feedList = feedRange.getValues()


  for (var i = 0; i < feedList.length; i++) {
    console.log(feedList[i][4])
    if (feedList[i][3] == '済' && feedList[i][4] != '済') {
      postFirstText(slackText)
      break
    }
  }

  for (var i = 0; i < feedList.length; i++) {
    console.log(feedList[i][4])
    if (feedList[i][3] == '済' && feedList[i][4] != '済') {
      postSlackbot(feedList[i])
      feedList[i][4] = '済'
    }
  }

  feedRange.setValues(feedList)  
}

// 指定された日が営業日か（営業日 = 「土日でない」「祝日カレンダーに予定がない」）
// 営業日 = true
function isWorkday (targetDate) {

  // targetDate の曜日を確認、週末は休む (false)
  var rest_or_work = ["REST","mon","tue","wed","thu","fri","REST"]; // 日〜土
  if ( rest_or_work [targetDate.getDay ()] == "REST" ) {
    return false;
  }; 

  // 祝日カレンダーを確認する
  var calJpHolidayUrl = "ja.japanese#holiday@group.v.calendar.google.com";
  var calJpHoliday = CalendarApp.getCalendarById (calJpHolidayUrl);
  if (calJpHoliday.getEventsForDay (targetDate).length != 0) {
    // その日に予定がなにか入っている = 祝祭日 = 営業日じゃない (false)
    return false;
  } ;

  // 全て当てはまらなければ営業日 (True)
  return true;
}

function postFirstText(slackText) {
  //SlackAPIで登録したボットのトークンを設定する
  var token = "xoxb-ボットのトークン";
  //Slackボットがメッセージを投稿するチャンネルを定義する
  var channelId = "#general";

  var message = [{
    pretext: slackText,
  }]

  UrlFetchApp.fetch("https://api.slack.com/api/chat.postMessage", {
    method: "post",
    payload: {
      token: token,
      channel: channelId,
      attachments: JSON.stringify(message)
    }
  })
  return;
}

function postSlackbot(feedArray) {
  //SlackAPIで登録したボットのトークンを設定する
  var token = "xoxb-ボットのトークン";
  //Slackボットがメッセージを投稿するチャンネルを定義する
  var channelId = "#general";

  var message = [{
    color: "#33cccc", // 縦線の色
    title: feedArray[0],
    title_link: feedArray[1],
    fields: [{
      title: "chatGPTによる要約(間違ってる可能性あり)",
      value: feedArray[2],
      short: false,
    },],
  }]

  UrlFetchApp.fetch("https://api.slack.com/api/chat.postMessage", {
    method: "post",
    payload: {
      token: token,
      channel: channelId,
      attachments: JSON.stringify(message)
    }
  })
  return;
}
```

# GASのトリガーについて

今回作成したGASのコードはすべてスプレッドシートに紐づけて作成していますので、以下のようなGASのコンソール画面に3つのgsファイルとappsscript.jsonファイルが存在しているはずです。

![ブログ用10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/9399edbc-359a-a46d-492e-4bcd1cb29fee.png)

これらをそれぞれの時間ごとに実行されるようにトリガーを設定する必要があります。
今回は以下の表のようにトリガーを設定しました。

| 実行する関数 | イベントソース | トリガータイプ | 時間の間隔   |
|:------------|:-------------|:--------------|:-------------|
| mainRss     | 時間主導型    | 時間ベース     | 4時間おき    |
| mainGPT     | 時間主導型    | 時間ベース     | 2時間おき    |
| mainSlack   | 時間主導型    | 日付ベース     | 午後12時~1時 |

# Slack Botが動くとどんな感じか

実際に動くと以下のような感じになります。試験的に動作させたときのものになることに注意ください。

![ブログ用11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2324252/83a52a15-4d20-4525-c9af-8a97374a9b6e.png)

# 今後の展望

今回のSlack Botでのニュースを投稿する際にSlackのattachment機能を利用しています。この機能で表現できることは他にもあるので、例えばニュースの投稿日時やニュースソースによって縦棒の色を変更などが次に追加したいところになります。ニュースを投稿するBotとしては皆さんにクリックしてもらうことが大事になるので、クリックしたくなる付加価値をつけなくてはなりません。今後もいろいろ変更したいと思っています。

# おわりに

Google Apps Scriptを使用して自動ニュース投稿Slack Botを構築する方法について説明しました。自動投稿Slack Botを使用することで、社内の情報共有や業務の効率化に役立てることができます。GASの基礎を理解し、Slack APIの使い方を学ぶことで、自分だけの自動投稿Slack Botを作成することができます。

今回の記事が、GASやSlack APIを使った自動化ツールの開発に興味を持っていただけたら幸いです。今後も、より高度な自動化ツールの開発に取り組んでいき、業務効率化を実現することができるようになることを願っています。

一番大事なこととして、記事の中ではスクレイピングのリスクについてもお話ししました。スクレイピングは法的問題を引き起こすことがあります。**今回の記事はスクレイピングを推奨するものではありません**。利用する場合は自己責任でお願いいたします。

# 参考文献

[【GAS】Google Apps ScriptでRSSリーダーを作成する方法を解説](https://mo-gu-mo-gu.com/gas-rss-reader/)
[Google Apps ScriptでRSS1.0・RSS2.0・ATOMを取得（フィルタ付き）](https://imabari.hateblo.jp/entry/2015/06/18/114843)
[GASとChatGPTを使ってブログ記事をスクレイピングしつつ要約をまとめてもらう。](https://www.pnkts.net/2023/03/21/summarize-using-gas-and-chatgpt)
[Slackbotからの通知メッセージをattachmentsでリッチにする](https://iliiliiiliili.hatenablog.jp/entry/2022/07/07/225923)