[GAS/Gemini] 無料で運用! ニュース&要約をPostしてくれるSlackBotをコピペで作ろう

## 情報収集どのようにされていますか？
QiitaやZenn、Publickey、GIGAZINEなど、常に情報をチェックしたいけど情報収集に時間を割いていると仕事が進まないし、情報を取り入れないと置いていかれるし・・・。
なかなか難しいですよね。

## Gemini Proを利用した要約Bot
現在Gemini Proは無料で利用できます。
これを利用しない手はないということで、Gemini Proを利用した要約SlackBotを2ヶ月ほど運用しています。
個人的にはコレがなかなか良いので紹介しようと思います。

## 全体図

![Slackニュース.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/6ab966e8-f04f-04b4-dea4-9e3a95fa982b.png)

1. Google Apps Scriptのトリガーで10分ごとに実行
1. 任意のRSSからニュースを取得
1. 新着ニュースをSlackにPost
1. Gemini Proを使用してニュースを要約
1. 要約内容をニュースのスレッドにPost

という感じです
デフォルトでは、土日祝日は動きません。平日の9:40〜18:00のみ動きます。

**お金をかけずに運用できます**

## 動いている様子

![スクリーンショット 2024-04-24 21.35.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/6234f687-0e88-afff-6f8a-5e9109e0f550.png)

要約のプロンプトは、GAS内で以下のように定義しており、ちょっと面白い感じの要約もできます
上のスクリーンショットの要約は`絵文字を混ぜながら要約して`ですね
追加したい人格とかあれば任意に追加できます

`ギャルの口調で要約して`は意外にわかりやすくてオススメ
```
const SUMMALIZE_PROMPT = ["コテコテの関西弁で要約して", "ギャルの口調で要約して", "ドラゴンボールの孫悟空の口調で要約して", "ドラゴンボールのベジータの口調で要約して", "絵文字を混ぜながら要約して", "ハイテンションで要約して"];

/**
 * 要約のプロンプトを取得する
 * @returns
 */
function getRandomPrompt() {
  const randomNum = Math.random();

  // 要約してが50%の確率で選ばれるようにする
  if (randomNum < 0.5) {
    return "要約して";
  } else {
    // ランダムに選択
    const randomIndex = Math.floor(Math.random() * SUMMALIZE_PROMPT.length);
    return SUMMALIZE_PROMPT[randomIndex];
  }
}
```

## Bot作成

### ニュースを投稿するSlackチャンネルの作成

ニュースの投稿は専用のチャンネルを作ったほうが良いです

1. チャンネルを作ったら、チャンネル名(ここではowayo-test)をクリック
![スクリーンショット 2024-04-24 18.55.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/9f08c79d-69e6-01ac-f856-e350a548735f.png)
2. ダイアログが開くので下にスクロールする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/d52ca334-7b54-9c4f-275b-f0dcfaf14fd2.png)
チャンネルIDが表示されるのでコピーして控えておきます

### Slackアプリの作成

1. 以下にアクセスしてSlackアカウントでログインします
  https://slack.com/signin
2. 以下にアクセス
  https://api.slack.com/apps
3. **Create New App** をクリック
4. **From an app manifest** をクリック
5. インストールするワークスペースを選択し **Next**
6. 以下の内容を貼り付けて **Next**
```json
{
    "display_information": {
        "name": "ニュース要約君",
        "description": "ニュースを要約してPostします",
        "background_color": "#333333"
    },
    "features": {
        "bot_user": {
            "display_name": "news_summarizer",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "channels:history",
                "chat:write",
                "groups:history"
            ]
        }
    },
    "settings": {
        "org_deploy_enabled": false,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false
    }
}
```

7. **Create** をクリック
8. **Basic Information**の**Install to Workspace**をクリック
8. **許可する**をクリック
8. **Basic Information**の**Verification Token**を控えておきます
8. **OAuth & Permissions**の、**OAuth Tokens for Your Workspace**に表示されている**Bot User OAuth Token**を控えておきます

### SlackチャンネルにBotの追加

APIからチャンネルを見つけられるように、SlackチャンネルにBotを追加してあげます
1. Slackのチャンネル名(ここではowayo-test)をクリック
![スクリーンショット 2024-04-24 18.55.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/9f08c79d-69e6-01ac-f856-e350a548735f.png)
1. ダイアログが開くので、**インテグレーション**をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/3d063884-12ce-245e-ec33-4ae7fac07d96.png)
1. **アプリを追加する**をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/55f2790c-de99-4f69-a008-3f9332046d59.png)
1. アプリの一覧から、作成したアプリを見つけて**追加**をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/364699f9-015c-9aea-68be-4988d828d68e.png)

### Google Cloud Projectの作成

Geminiの利用には、Google Cloud Projectの作成が必要なので作成しておきます

1. 以下のページを開き、適当なプロジェクト名を入力し**作成**をクリック 
https://console.cloud.google.com/projectcreate
2. 課金が無効になっていることを確認
  以下のURLで**このプロジェクトには請求先アカウントがありません**と表示されることを確認
https://console.cloud.google.com/billing/linkedaccount

**このプロジェクトには請求先アカウントがありません**となっていない場合は、2024/5/2移行課金が行われます。  
**このプロジェクトには請求先アカウントがありません**となっている場合は次の項目に進んでください。

### Gemini Pro API Keyの発行

1. 以下のページを開き、**無料**の**今すぐ試す**をクリック
  https://ai.google.dev/pricing?hl=ja
2. 右のメニューから鍵のアイコンをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/e4d2de7b-1bf4-4eac-c53c-f332d6b8f0e8.png)

2. **Create API key**をクリック
2. 先ほど作成した、Google Cloud Projectを選択し、**Create API key in existing project**をクリック
2. API Keyが生成されるので**Copy**をクリックし控えておく

### Google Apps Scriptの作成

1. GASの画面を開いて、**新しいプロジェクト**をクリックします
  https://script.google.com/home
1. ライブラリの **+** アイコンをクリックし、スクリプトIDに `1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0` を入れて **検索** をクリックします
1. Cheerioのバージョン14が選択されていることを確認し、**追加**をクリックします
  このライブラリはDOM Parserとして使用します
1. コード.gsの内容を以下で置き換えます
```ts
// チャンネルID
const CHANNEL_ID = PropertiesService.getScriptProperties().getProperty('SLACK_CHANNEL_ID')

// サイトごとのコンテンツのセレクタ
const CONTENT_SELECTOR_MAP = {
  'gigazine.net': "#article .cntimage",
  'www.publickey1.jp': "#maincol",
  'qiita.com': ".p-items_main",
  'zenn.dev': "article section[class^=View_content]"
};

// 要約のプロンプト
const SUMMALIZE_PROMPT = ["コテコテの関西弁で要約して", "ギャルの口調で要約して", "ドラゴンボールの孫悟空の口調で要約して", "ドラゴンボールのベジータの口調で要約して", "絵文字を混ぜながら要約して", "ハイテンションで要約して"];

// 通知する記事の最大数
const POST_LIMIT = 1;

function main() {
  if (shouldExecute()) {
    const notifiedUrls = getNotifiedUrls()
    notifyFeedByDate(notifiedUrls)
    notifyFeedByNonSequential(notifiedUrls)
  }
}

function debug() {
  const notifiedUrls = getNotifiedUrls()
  notifyFeedByDate(notifiedUrls)
  notifyFeedByNonSequential(notifiedUrls)
}

/**
 * 実行してもよいか判定する
 * @returns true: 実行する, false: 実行しない
 */
function shouldExecute() {
  const now = new Date();
  const day = now.getDay();
  const hour = now.getHours();
  const minute = now.getMinutes();

  // 土日の判定
  if (day === 0 || day === 6) return false;

  // 祝日の判定
  if (isHoliday(now)) return false;

  // 時間帯の判定（9:40〜18:00の間でない場合は実行しない）
  if (hour < 9 || (hour === 9 && minute < 40) || hour >= 18) return false;

  // 上記の条件をすべて満たさない場合は実行する
  return true;
}

/**
 * RSSフィードを日付順に通知する
 * @param notifiedUrls
 */
function notifyFeedByDate(notifiedUrls) {
  const feeds = [
    { url: 'https://gigazine.net/news/rss_2.0/', id: 'gigazine' },
    { url: 'https://www.publickey1.jp/atom.xml', id: 'publickey' },
  ];

  feeds.forEach(function (feed) {
    const lastPublishedDateKey = 'lastPublishedDate_' + feed.id;
    // プロパティからエポックタイムを取得し、それを基にDateオブジェクトを生成
    const lastPublishedDateEpoch = PropertiesService.getScriptProperties().getProperty(lastPublishedDateKey);
    const lastPublishedDate = lastPublishedDateEpoch ? new Date(Number(lastPublishedDateEpoch)) : new Date();
    let latestDate = lastPublishedDate;

    let rssData = parseFeed(feed.url);

    let notifyCount = 0;
    rssData.forEach(rss => {
      if (notifyCount >= POST_LIMIT) {
        return;
      }
      if (rss.pubDate > lastPublishedDate && !notifiedUrls.includes(rss.link)) {
        if (rss.pubDate > latestDate) {
          latestDate = rss.pubDate;
        }

        const ts = sendMessageToSlack(CHANNEL_ID, `<${rss.link}|${rss.title}>`);
        const content = getContent(rss.link);
        if (content) {
          const summalize = runGemini(getRandomPrompt(), content);
          sendMessageToSlack(CHANNEL_ID, summalize, ts);
          notifyCount++;
        }
      }
    })

    if (latestDate > lastPublishedDate) {
      // 最新の日付をエポックタイムとして保存
      PropertiesService.getScriptProperties().setProperty(lastPublishedDateKey, latestDate.getTime().toString());
    }
  });
}

/**
 * 日付順ではないRSSフィードを通知する
 * @param notifiedUrls
 */
function notifyFeedByNonSequential(notifiedUrls) {
  const feeds = [
    { url: 'https://qiita.com/popular-items/feed', id: 'qiita', prefix: 'https://qiita.com/' },
    { url: 'https://zenn.dev/feed', id: 'zenn', prefix: 'https://zenn.dev/' }
  ];

  feeds.forEach(function (feed) {
    const notifiedUrlsKey = 'notifiedUrls_' + feed.id;
    // PropertiesServiceから通知済みURLを取得
    const notifiedUrlsString = PropertiesService.getScriptProperties().getProperty(notifiedUrlsKey);
    const notifiedUrlsArray = notifiedUrlsString ? notifiedUrlsString.split(' ') : [];

    const rssData = parseFeed(feed.url);
    let notifyCount = 0;

    rssData.forEach(rss => {
      if (notifyCount >= POST_LIMIT) {
        return;
      }

      const relativeUrl = rss.link.replace(feed.prefix, '');
      if (!notifiedUrlsArray.includes(relativeUrl) && !notifiedUrls.includes(rss.link)) {
        const message = `{
          "blocks": [
            {
              "type": "section",
              "text": {
                "type": "mrkdwn",
                "text": "<${rss.link}|${rss.title}>"
              }
            }
          ]
        }`;
        console.info(message);

        const ts = sendMessageToSlack(CHANNEL_ID, `<${rss.link}|${rss.title}>`);
        const content = getContent(rss.link);
        if (content) {
          const summalize = runGemini(getRandomPrompt(), content);
          sendMessageToSlack(CHANNEL_ID, summalize, ts);
          notifiedUrlsArray.push(relativeUrl);
          notifyCount++;
        }
      }
    });

    // 通知済みURLを更新（最新の50個を保持）
    if (notifiedUrlsArray.length > 50) {
      notifiedUrlsArray.splice(0, notifiedUrlsArray.length - 50); // 古いURLを削除
    }
    PropertiesService.getScriptProperties().setProperty(notifiedUrlsKey, notifiedUrlsArray.join(' '));
  });
}

/**
 * 要約のプロンプトを取得する
 * @returns
 */
function getRandomPrompt() {
  const randomNum = Math.random();

  // 要約してが50%の確率で選ばれるようにする
  if (randomNum < 0.5) {
    return "要約して";
  } else {
    // ランダムに選択
    const randomIndex = Math.floor(Math.random() * SUMMALIZE_PROMPT.length);
    return SUMMALIZE_PROMPT[randomIndex];
  }
}

/**
 * JSONをパースする
 * @param text
 * @returns
 */
function jsonParse(text) {
  try {
    return JSON.parse(text);
  } catch (e) {
    console.error('Error parsing JSON:', e);
    return {};
  }
}

/**
 * ルートメッセージを取得し、URLを抽出してコンテンツを取得する
 * @param channelId
 * @param threadTs
 * @returns
 */
function fetchRootMessageAndUrls(channelId, threadTs) {
  if (threadTs == undefined) return null;
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_OAUTH_TOKEN');
  const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}&limit=1`;
  const options = {
    'method': 'get',
    'headers': {
      'Authorization': 'Bearer ' + token
    },
    'muteHttpExceptions': true
  };
  const response = UrlFetchApp.fetch(url, options);
  const result = jsonParse(response.getContentText());
  console.info(result);
  if (result.ok && result.messages.length > 0) {
    const rootMessage = result.messages[0].text;
    console.info(rootMessage)
    const url = extractUrls(rootMessage);

    const content = getContent(url);
    return content;
  }
  return null
}

/**
 * Geminiを実行する
 * @param message
 * @param content
 * @returns
 */
function runGemini(message, content) {
  try {
    const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY')
    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${apiKey}`
    const payload = {
      'contents': [
        {
          'role': 'user',
          'parts': [{
            'text': message + "\n\n-----\n\n" + content
          }]
        }
      ]
    }
    const options = {
      'method': 'post',
      'contentType': 'application/json',
      'payload': JSON.stringify(payload)
    };
    const response = UrlFetchApp.fetch(url, options)
    const json = jsonParse(response.getContentText());
    console.info('Gemini response')
    console.info(json)
    if (json) {
      if (json.candidates && json.candidates.length > 0) {
        console.info(`finishReason: ${json.candidates[0].finishReason}`)
        console.info(json.candidates[0].safetyRatings)
        if (json.candidates[0].content) {
          console.info(json.candidates[0])
          console.info(json.candidates[0].content)
          console.info(json.candidates[0].content.parts[0])
          console.info(json.candidates[0].content.parts[0].text)
          return json.candidates[0].content.parts[0].text;
        } else {
          console.error('Gemini no content')
          return `Gemini error
finishReason: ${json.candidates[0].finishReason}
${JSON.stringify(json.candidates[0].safetyRatings, null, "\t")}
        `
        }
      } else if (json.error) {
        console.error(json.error)
        return `Gemini error
Error code: ${json.error.code}
${json.error.message}`
      }
    }
    return null
  } catch (e) {
    console.error('Gemini error: ', e);
    return e.message
  }
}

/**
 * メッセージからURLを抽出する
 * @param text
 * @returns
 */
function extractUrls(text) {
  let matches = text.match(/<([^|]+)(?:\||>)/);
  if (matches && matches.length > 1) {
    return matches[1];
  }
  matches = text.match(/(https?:\/\/[^\s]+)/);
  if (matches && matches.length > 1) {
    return matches[1];
  }
  return null;
}

/**
 * Slackにメッセージを送信する
 * @param channel
 * @param text
 * @param threadTs
 * @returns
 */
function sendMessageToSlack(channel, text, threadTs = null) {
  if (text == null) {
    return
  }
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_OAUTH_TOKEN');
  const url = 'https://slack.com/api/chat.postMessage';
  const blocks = [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": text
      }
    }
  ];
  console.info("Send Slack")
  console.info(blocks)
  const payload = {
    "channel": channel,
    "blocks": blocks,
    "thread_ts": threadTs
  };
  const options = {
    'method': 'post',
    'contentType': 'application/json',
    'headers': {
      'Authorization': 'Bearer ' + token
    },
    'payload': JSON.stringify(payload),
    'muteHttpExceptions': true
  };
  const response = UrlFetchApp.fetch(url, options);
  const result = jsonParse(response.getContentText());
  console.info(result);
  if (result.ok) {
    return result.ts; // 成功した場合、レスポンスからtsを返す
  } else {
    throw new Error('Failed to send message to Slack: ' + result.error);
  }
}

/**
 * ホスト名を取得する
 * @param url
 * @returns
 */
function getHostname(url) {
  const hostPattern = /https?:\/\/([^\/]+)/;
  const matches = url.match(hostPattern);
  if (matches && matches.length > 1) {
    return matches[1];
  }
  return null;
}

/**
 * URLからコンテンツを取得する
 * @param url
 * @returns
 */
function getContent(url) {
  const response = UrlFetchApp.fetch(url);
  const html = response.getContentText();

  // CheerioでHTMLを解析
  const $ = Cheerio.load(html);

  // URLのホスト名に基づいて適切なセレクタを選択
  const hostname = getHostname(url);
  const selector = CONTENT_SELECTOR_MAP[hostname];

  if (selector) {
    // セレクタにマッチする要素のテキストを抽出
    const matchedText = $(selector).text().replace(/[ \t\u3000]+/g, ' ').replace(/ +$/g, '').replace(/[\r\n]+/g, "\n");
    console.info(matchedText);
    return matchedText;
  } else {
    console.info(`No selector found for hostname: ${hostname}`);
  }
  return null
}

/**
 * RSSをパースする
 * @param url URL
 * @returns
 */
function parseFeed(url) {
  console.info(url)
  try {
    const response = UrlFetchApp.fetch(url);
    const xml = response.getContentText();
    const document = XmlService.parse(xml);
    const root = document.getRootElement();

    // フィードの形式（RSSまたはAtom）を判定
    const isAtom = root.getName() === 'feed';
    const namespace = isAtom ? XmlService.getNamespace('http://www.w3.org/2005/Atom') : root.getNamespace();
    const dcNamespace = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/')
    console.info(`isAtom: ${isAtom}`)
    let entries;
    if (isAtom) {
      // Atomフィードの処理: 名前空間を考慮して子要素を取得
      entries = root.getChildren('entry', namespace);
    } else {
      // RSSフィードの処理
      entries = root.getChildren('channel', namespace)[0].getChildren('item', namespace);
    }
    console.info(`count: ${entries.length}`)
    const rssData = [];
    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i];
      let title, pubDate, link;
      if (isAtom) {
        // Atomフィードの要素取得
        title = entry.getChild('title', namespace).getText();
        pubDate = new Date(entry.getChild('published', namespace).getText());
        link = entry.getChildren('link', namespace).find(link => link.getAttribute('rel') === null || link.getAttribute('rel').getValue() === 'alternate').getAttribute('href').getValue();
      } else {
        // RSSフィードの要素取得
        title = entry.getChild('title', namespace).getText();
        pubDate = new Date(entry.getChild('pubDate', namespace).getText());
        link = entry.getChild('link', namespace).getText();
      }

      rssData.push({
        title: title,
        pubDate: pubDate,
        link: link.replace(/\?.*$/, '')
      });
    }
    rssData.sort(function (a, b) {
      return a.pubDate - b.pubDate;
    });
    console.info(rssData);
    return rssData;
  } catch (e) {
    console.error('parseFeed error: ', e);
    return [];
  }
}

/**
 * 祝日か判定する
 * @param date 判定対象の日付
 * @returns true: 祝日, false: 平日
 */
function isHoliday(date) {
  const calendars = CalendarApp.getCalendarsByName('日本の祝日');
  if (calendars.length > 0) {
    const events = calendars[0].getEventsForDay(date);
    return events.length > 0;
  }
  return false;
}

/**
 * メッセージからURLを抽出する
 * @param messages
 * @returns URLのリスト
 */
function extractUrlsFromMessages(messages) {
  const urlPattern = /<([^|]+)\|/; // URLを抽出するための正規表現パターン

  const urls = messages.map(message => {
    const matches = message.text.match(urlPattern);
    if (matches && matches.length > 1) {
      // 正規表現の第一マッチンググループがURL
      return matches[1];
    } else {
      return null; // URLが見つからない場合はnullを返す
    }
  }).filter(url => url !== null); // nullでない値のみをフィルタリング

  return urls;
}

function extractTitleUrlsFromMessages(messages) {
  const pattern = /<([^|]+)\|([^>]+)>/; // URLを抽出するための正規表現パターン

  return messages.map(message => {
    const matches = message.text.match(pattern);
    if (matches && matches.length > 1) {
      // 正規表現の第一マッチンググループがURL
      return { title: matches[2], url: matches[1] };
    } else {
      return null; // URLが見つからない場合はnullを返す
    }
  }).filter(url => url !== null); // nullでない値のみをフィルタリング
}

/**
 * すでにSlackに通知済みのURLを取得する
 * @returns URLのリスト
 */
function getNotifiedUrls() {
  const url = 'https://slack.com/api/conversations.history';
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_OAUTH_TOKEN');
  const params = {
    'channel': CHANNEL_ID,
    'limit': 500
  };

  const options = {
    'method': 'get',
    'headers': {
      'Authorization': `Bearer ${token}`
    },
    'muteHttpExceptions': true
  };

  const queryString = Object.keys(params).map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
  const response = UrlFetchApp.fetch(`${url}?${queryString}`, options);
  const result = jsonParse(response.getContentText());
  let urls = []
  if (result.ok) {
    urls = extractUrlsFromMessages(result.messages);
  } else {
    console.error('Failed to fetch messages:', result.error);
  }
  return urls
}

```
5. フロッピーアイコンをクリックし保存します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/b6a4dfc3-7fa6-3c57-dd5c-fe7f0b6e1af9.png)
5. 左のメニューから歯車アイコンをクリックします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/38ccea5f-1411-8158-1f82-31e52011b1e6.png)
5. 画面下の、**スクリプト プロパティ**にて**スクリプト プロパティを追加**をクリックします
5. 以下を追加します
  最初は入力欄が1つしか表示されないので、**スクリプト プロパティを追加**を3回クリックすることで追加で3つ入力欄が表示されます

| プロパティ | 値 |
|:-:|:-:|
| SLACK_VERIFICATION_TOKEN | Verification Tokenで控えた値を入力 |
| SLACK_OAUTH_TOKEN | ot User OAuth Tokenで控えた値を入力 |
| SLACK_CHANNEL_ID | 控えたチャンネルIDを入力 |
| GEMINI_API_KEY | 控えたGemini Pro API Keyを入力 |

5. 入力が終わったら、**スクリプト プロパティを保存**をクリックします
5. 左のメニューからエディタのアイコンをクリックします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/e081f112-eff8-509c-92e3-250db0eec0f3.png)
5. 実行する関数に**main**が選択されていると思うので**debug**を選択します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/5bb803f6-c5aa-6df3-2f05-eebd62cc70cc.png)
5. 実行ボタンをクリックします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/47dcaef9-c21d-ac89-0784-0d3120b604e7.png)
5. このような確認ダイアログが表示されるので、**権限を確認**をクリックします
   インターネットからの情報取得やGoogleカレンダーを使用した祝日判定を行っているため権限を確認して許可する必要があります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/fd2a834d-df8a-ef63-2722-52016bd0b3e5.png)
5. Googleアカウントの選択画面が表示されるので選択します
6. **許可**をクリックします
6. Slackチャンネルにニュースが通知され、そのニュースにリプライする形で要約がPostされると思います
![スクリーンショット 2024-04-24 21.53.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/fabcf194-3eed-a642-f6f6-4b4b40f184eb.png)
1. ここまで気たらもう少し!
   定期的に実行されるようにしましょう
   左のメニューから時計のアイコンをクリックします
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/b131ac23-cc9b-7f69-2f0c-8e375f80bb75.png)
1. **トリガーを追加**をクリック
1. **時間ベースのトリガーのタイプを選択**を**分ベースのタイマー**、**時間の間隔を選択**を任意の値に設定
1. **保存**をクリック
1. これで指定した間隔ごとにニュースが通知されるようになりました!

あとはスクリプトをカスタマイズしたりして情報収集を効率化しましょう!

## オマケ
### アイコン変更&表示名&名前変更
1. 以下のページを開きます
   https://api.slack.com/apps
1. ニュース要約君を探してクリックします
1. **Basic Information**の下部の**Display Information**で表示名とアイコンを変更できます
1. **App Home**の**Your App’s Presence in Slack**で名前等できます

### 収集先を追加する方法

`notifyFeedByDate` の `feeds` にURLと任意の識別子を追加してください。
```ts
  const feeds = [
    { url: 'https://gigazine.net/news/rss_2.0/', id: 'gigazine' },
    { url: 'https://www.publickey1.jp/atom.xml', id: 'publickey' },
    { url: 'https://example.com/atom.xml', id: 'example' }, <<- 追加
  ];
```
その後、`CONTENT_SELECTOR_MAP` に、URLのホスト名をキーとして、該当のサイトの本文が入力されているセレクタを追加してください。
```ts
const CONTENT_SELECTOR_MAP = {
  'gigazine.net': "#article .cntimage",
  'www.publickey1.jp': "#maincol",
  'qiita.com': ".p-items_main",
  'zenn.dev': "article section[class^=View_content]",
  'example.com': "#main-content", <<- 追加
};
```
`debug` を実行して記事がポストされるか確認してください

## 注意
時々、プロンプトに不適切な内容が含まれているとGeminiに判定され、要約を作ってくれません。
ただ何もPostしないと何が起きているかわからないので、不適切と判定されたらその内容をPostするようにしています
![スクリーンショット 2024-04-24 22.22.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/87d46858-4fa0-3197-4973-7b2ba6a4f565.png)