はじめに
情報過多の時代において、関心のあるニュースをタイムリーに把握することは重要ですが、すべての記事を読むのは時間的に困難です。本記事では、Google Apps Script、生成AI(Gemini)、Slackを組み合わせて、RSSフィードから最新のテック記事を自動的に要約し、Slackに通知するシステムを構築する方法を紹介します。
概要
このシステムでは以下の機能を実現しています:
- 複数のテックニュースサイトのRSSフィードを定期的に監視
 - 新着記事の本文を取得
 - Gemini APIを使って記事内容を要約
 - 要約結果をSlackに通知
 - 通知済み記事を管理して重複を防止
 

このような形で通知されます!
必要なもの
- Googleアカウント(Google Apps Scriptを使用するため)
 - Spreadsheet(設定や履歴管理用)
 - Gemini API Key(Google AI Studioから取得)
 - Slack Webhook URL
 
Slack Webhook URLやGemini API Keyの取得については以下記事を参考にしてください。
実装手順
1. Google Spreadsheetの準備
まず、Googleスプレッドシートを作成し、以下の2つのシートを追加します:
設定: システムの各種設定値を保存Posted: 通知済みのURL履歴を保存
設定シートには以下の項目を設定します:
| キー | 値 | 
|---|---|
| API_KEY | Gemini API Key | 
| Model_Name | gemini-pro | 
| SLACK_WEBHOOK_URL | Slack通知用WebhookのURL | 
| PROMPT_TEMPLATE | 要約用のプロンプト | 
2. Google Apps Scriptの作成
スプレッドシートからツール→スクリプトエディタを開き、以下のコードを実装します。
const FEEDS = [
  { url: 'https://gigazine.net/news/rss_2.0/', id: 'gigazine' },
  { url: 'https://www.publickey1.jp/atom.xml', id: 'publickey' },
  { url: 'https://news.yahoo.co.jp/rss/topics/it.xml', id: 'yahoo_it' }  
];
const CONTENT_SELECTOR_MAP = {
  'gigazine.net': '#article .cntimage',
  'www.publickey1.jp': '#maincol',
  'news.yahoo.co.jp': '#yjnMain'  
};
const POST_LIMIT = 5;
const URL_HISTORY_KEY = 'NOTIFIED_URLS';
/**
 * メイン処理
 */
function main() {
  const notifiedUrls = getPostedUrls();  
  let newNotified = [];
  FEEDS.forEach(feed => {
    const lastPublishedDateKey = 'lastPublishedDate_' + feed.id;
    const lastPublishedEpoch = PropertiesService.getScriptProperties().getProperty(lastPublishedDateKey);
    const lastPublishedDate = lastPublishedEpoch ? new Date(Number(lastPublishedEpoch)) : new Date(0);
    let latestDate = lastPublishedDate;
    let count = 0;
    const rssItems = parseFeed(feed.url);
    for (const item of rssItems) {
      if (item.pubDate > lastPublishedDate && !notifiedUrls.includes(item.link)) {
        Logger.log(item);
        const content = getContent(item.link);
        if (!content) continue;
        const prompt = getSettingValue('PROMPT_TEMPLATE');
        const summary = runGemini(prompt, content);
        sendMessageToSlackByWebhook(item.title, summary, item.link);
        newNotified.push(item.link);
        if (item.pubDate > latestDate) latestDate = item.pubDate;
        count++;
        if (count >= POST_LIMIT) break;
        Utilities.sleep(1500);
      }
    }
    PropertiesService.getScriptProperties().setProperty(lastPublishedDateKey, latestDate.getTime().toString());
  });
  // 通知済みURLを「Posted」シートに個別登録
  newNotified.forEach(url => savePostedUrl(url));
}
/**
 * 通知済みURL一覧をスプレッドシートから取得
 * @returns {string[]} URLリスト
 */
function getPostedUrls() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Posted');
  if (!sheet) return [];
  const lastRow = sheet.getLastRow();
  if (lastRow === 0) return [];
  return sheet.getRange(1, 1, lastRow, 1).getValues().flat();
}
/**
 * 通知済みURLをスプレッドシートに追加(重複チェックあり)
 * @param {string} url 追加するURL
 */
function savePostedUrl(url) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Posted');
  if (!sheet) return;
  const postedUrls = getPostedUrls();
  if (!postedUrls.includes(url)) {
    sheet.appendRow([url]);
  }
}
/**
 * Geminiによる要約
 */
function runGemini(prompt, content) {
  const apiKey = getSettingValue('API_KEY');
  const model = getSettingValue('Model_Name');
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
  const promptText = `${prompt}\n\n${content}`;
  const payload = {
    contents: [{ parts: [{ text: promptText }] }]
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  try {
    const response = UrlFetchApp.fetch(url, options);
    const json = JSON.parse(response.getContentText());
    return json.candidates?.[0]?.content?.parts?.[0]?.text || '(要約失敗)';
  } catch (e) {
    Logger.log('Gemini Error: ' + e.message);
    return '(要約エラー)';
  }
}
/**
 * Slackにメッセージ送信
 */
function sendMessageToSlackByWebhook(title, summary, link) {
  const webhookUrl = getSettingValue('SLACK_WEBHOOK_URL');
  const message = `📰 *${title}*\n\n📄 ${summary}\n\n🔗 <${link}>`;
  const payload = {
    text: message
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  const response = UrlFetchApp.fetch(webhookUrl, options);
  Logger.log(response.getContentText());
}
/**
 * URLの本文を抽出
 */
function getContent(url) {
  const response = UrlFetchApp.fetch(url);
  const html = response.getContentText();
  const $ = Cheerio.load(html);
  const hostname = getHostname(url);
  const selector = CONTENT_SELECTOR_MAP[hostname];
  if (!selector) return null;
  return $(selector).text().replace(/[ \t\u3000]+/g, ' ')
    .replace(/ +$/g, '')
    .replace(/[\r\n]+/g, "\n")
    .trim();
}
/**
 * RSSフィード解析
 */
function parseFeed(url) {
  try {
    const response = UrlFetchApp.fetch(url);
    const xml = response.getContentText();
    const document = XmlService.parse(xml);
    const root = document.getRootElement();
    const isAtom = root.getName() === 'feed';
    const namespace = isAtom ? XmlService.getNamespace('http://www.w3.org/2005/Atom') : root.getNamespace();
    const entries = isAtom
      ? root.getChildren('entry', namespace)
      : root.getChild('channel', namespace).getChildren('item', namespace);
    return entries.map(entry => {
      const title = entry.getChildText('title', namespace);
      const pubDate = isAtom
        ? new Date(entry.getChildText('published', namespace))
        : new Date(entry.getChildText('pubDate', namespace));
      const link = isAtom
        ? entry.getChildren('link', namespace).find(l => !l.getAttribute('rel') || l.getAttribute('rel').getValue() === 'alternate').getAttribute('href').getValue()
        : entry.getChildText('link', namespace);
      return { title, pubDate, link: link.replace(/\?.*$/, '') };
    });
  } catch (e) {
    Logger.log('RSS parse error: ' + e.message);
    return [];
  }
}
/**
 * 設定シートから値を取得
 */
function getSettingValue(key) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('設定');
  const data = sheet.getDataRange().getValues();
  for (const row of data) {
    if (row[0] === key) return row[1];
  }
  throw new Error(`設定「${key}」が見つかりません。`);
}
/**
 * URLからホスト名を抽出
 */
function getHostname(url) {
  return url.match(/^https?:\/\/([^\/]+)/)?.[1];
}
3.ライブラリの追加
RSS記事のパースに使うライブラリを追加します。
1. 左のメニューから「ライブラリ」アイコン(本のような形)をクリック
2. 「ライブラリを追加」ボタンをクリック
3. 次のスクリプトIDを貼り付けて「検索」:
1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0
- Cheerio という名前のライブラリが表示されたら、バージョン(16)を選択し、「追加」をクリック
 
コードの解説
RSSフィード要約通知システムの詳細解説
1. メイン処理
const FEEDS = [
  { url: 'https://gigazine.net/news/rss_2.0/', id: 'gigazine' },
  { url: 'https://www.publickey1.jp/atom.xml', id: 'publickey' },
  { url: 'https://news.yahoo.co.jp/rss/topics/it.xml', id: 'yahoo_it' }  
];
ここでは監視対象の3つのRSSフィードを定義しています。
– url: RSSフィードのURL
– id: 各フィードを識別するための一意のID
const CONTENT_SELECTOR_MAP = {
  'gigazine.net': '#article .cntimage',
  'www.publickey1.jp': '#maincol',
  'news.yahoo.co.jp': '#yjnMain'  
};
これは各ウェブサイトから記事本文を抽出するためのCSSセレクタを定義しています。ドメインごとに異なるHTMLセレクタを指定することで、サイトごとに適切な本文抽出ができるようになっています。
const POST_LIMIT = 5;
一度の実行で処理する記事の最大数を5に制限しています。
function main() {
  const notifiedUrls = getPostedUrls();  
  let newNotified = [];
getPostedUrls(): 既に通知済みのURL一覧を取得します(過去の実行で処理済みのURLを重複処理しないため)newNotified: 今回新たに通知したURLを保存するための配列
  FEEDS.forEach(feed => {
    const lastPublishedDateKey = 'lastPublishedDate_' + feed.id;
    const lastPublishedEpoch = PropertiesService.getScriptProperties().getProperty(lastPublishedDateKey);
    const lastPublishedDate = lastPublishedEpoch ? new Date(Number(lastPublishedEpoch)) : new Date(0);
    let latestDate = lastPublishedDate;
    let count = 0;
ここは各フィードごとの処理です:
– フィードIDに基づいて最終処理日時のキーを生成
– Google Apps ScriptのPropertiesServiceを使用して、前回の実行時に保存した最後の記事公開日時を取得
– 値がなければ初期値として1970年1月1日(new Date(0))を設定
– 今回の実行で見つかる最新の記事日時を更新するための変数を初期化
– 処理済み記事のカウンターを初期化
    const rssItems = parseFeed(feed.url);
    for (const item of rssItems) {
      if (item.pubDate > lastPublishedDate && !notifiedUrls.includes(item.link)) {
- フィードURLから記事一覧を取得
 - 各記事について、前回の処理以降に公開されたもので、かつ未通知のものだけを処理
 
        Logger.log(item);
        const content = getContent(item.link);
        if (!content) continue;
        const prompt = getSettingValue('PROMPT_TEMPLATE');
        const summary = runGemini(prompt, content);
- 記事の詳細をログに出力
 - 記事URLから本文を取得(失敗した場合は次の記事へスキップ)
 - スクリプトのプロパティから要約用のプロンプトテンプレートを取得
 - Gemini APIを使用して記事本文を要約
 
        sendMessageToSlackByWebhook(item.title, summary, item.link);
        newNotified.push(item.link);
        if (item.pubDate > latestDate) latestDate = item.pubDate;
        count++;
        if (count >= POST_LIMIT) break;
        Utilities.sleep(1500);
- 記事タイトル、要約、リンクをSlackに通知
 - 通知済みURL配列に追加
 - 最新記事日時を更新
 - 処理済み記事数をカウントアップし、上限に達したら終了
 - API呼び出しの間隔を調整するために1.5秒待機
 
    PropertiesService.getScriptProperties().setProperty(lastPublishedDateKey, latestDate.getTime().toString());
  });
  // 通知済みURLを登録
  newNotified.forEach(url => savePostedUrl(url));
}
- 最新の公開日時をPropertiesServiceに保存(次回実行時の基準に)
 - 今回処理したURLを通知済みリストに登録
 
2. 記事取得と要約処理
function getContent(url) {
  const response = UrlFetchApp.fetch(url);
  const html = response.getContentText();
  const $ = Cheerio.load(html);
  const hostname = getHostname(url);
  const selector = CONTENT_SELECTOR_MAP[hostname];
  if (!selector) return null;
UrlFetchAppを使って記事ページのHTMLを取得- CheerioライブラリでHTMLを解析(jQueryライクなセレクタを使用可能に)
 - URLからホスト名を抽出
 - 該当ホスト名のCSSセレクタを取得(未定義の場合は処理中止)
 
  return $(selector).text().replace(/[ \t\u3000]+/g, ' ')
    .replace(/ +$/g, '')
    .replace(/[\r\n]+/g, "\n")
    .trim();
}
- セレクタに該当する要素からテキストを抽出
 - 空白文字(半角、全角、タブ)を単一の半角スペースに置換
 - 行末の空白を削除
 - 連続した改行を単一の改行に置換
 - 前後の空白を削除して返却
 
function runGemini(prompt, content) {
  const apiKey = getSettingValue('API_KEY');
  const model = getSettingValue('Model_Name');
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
  const promptText = `${prompt}\n\n${content}`;
- スクリプトのプロパティからGemini APIのキーとモデル名を取得
 - Gemini API呼び出し用のURLを組み立て
 - プロンプトテンプレートと記事本文を結合
 
  const payload = {
    contents: [{ parts: [{ text: promptText }] }]
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
- Gemini APIに送信するリクエストボディを構築
 - HTTP POSTオプションを設定(エラー時に例外を抑制するmuteHttpExceptions)
 
  try {
    const response = UrlFetchApp.fetch(url, options);
    const json = JSON.parse(response.getContentText());
    return json.candidates?.[0]?.content?.parts?.[0]?.text || '(要約失敗)';
  } catch (e) {
    Logger.log('Gemini Error: ' + e.message);
    return '(要約エラー)';
  }
}
- APIリクエストを実行し、レスポンスをJSONとしてパース
 - オプショナルチェーン(
?.)を使用してAPIレスポンスから安全に要約テキストを抽出 - エラー発生時はログに記録し、エラーメッセージを返却
 
3. Slack通知処理
function sendMessageToSlackByWebhook(title, summary, link) {
  const webhookUrl = getSettingValue('SLACK_WEBHOOK_URL');
  const message = `📰 *${title}*\n\n📄 ${summary}\n\n🔗 <${link}>`;
- スクリプトのプロパティからSlack WebhookのURLを取得
 - 通知メッセージを構築(絵文字と書式を使用)
 - 📰:記事タイトル(太字)
 - 📄:要約内容
 - 🔗:記事リンク
 
  const payload = {
    text: message
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  const response = UrlFetchApp.fetch(webhookUrl, options);
  Logger.log(response.getContentText());
}
- Slack Webhookに送信するデータを構築
 - HTTP POSTオプションを設定
 - WebhookにPOSTリクエストを送信
 - レスポンスをログに記録
 
4. RSS関連の処理
function getContent(url) {
  // 前述の内容と重複するため解説省略
}
/**
 * RSSフィード解析
 */
function parseFeed(url) {
  try {
    const response = UrlFetchApp.fetch(url);
    const xml = response.getContentText();
    const document = XmlService.parse(xml);
    const root = document.getRootElement();
- RSSフィードのXMLを取得し、Google Apps ScriptのXmlServiceでパース
 - ルート要素を取得
 
    const isAtom = root.getName() === 'feed';
    const namespace = isAtom ? XmlService.getNamespace('http://www.w3.org/2005/Atom') : root.getNamespace();
    const entries = isAtom
      ? root.getChildren('entry', namespace)
      : root.getChild('channel', namespace).getChildren('item', namespace);
- フィードタイプの判別(AtomかRSSか)
 - 適切な名前空間を取得
 - フィードタイプに応じて記事(entry/item)要素を取得
 
    return entries.map(entry => {
      const title = entry.getChildText('title', namespace);
      const pubDate = isAtom
        ? new Date(entry.getChildText('published', namespace))
        : new Date(entry.getChildText('pubDate', namespace));
      const link = isAtom
        ? entry.getChildren('link', namespace).find(l => !l.getAttribute('rel') || l.getAttribute('rel').getValue() === 'alternate').getAttribute('href').getValue()
        : entry.getChildText('link', namespace);
      return { title, pubDate, link: link.replace(/\?.*$/, '') };
    });
- 各記事から以下の情報を抽出:
 - タイトル
 - 公開日時(フィードタイプによって要素名が異なる)
 - リンクURL(Atomの場合は属性から、RSSの場合はテキストから取得)
 - クエリパラメータを除去したURLを返却
 
  } catch (e) {
    Logger.log('RSS parse error: ' + e.message);
    return [];
  }
}
/**
 * URLからホスト名を抽出
 */
function getHostname(url) {
  return url.match(/^https?:\/\/([^\/]+)/)?.[1];
}
- エラー時には空配列を返却
 getHostname関数は正規表現を使用してURLからホスト名部分を抽出
その他の関数
コード内で呼び出されているが、サンプルに含まれていない関数について推測される役割:
getSettingValue(key): スクリプトのプロパティサービスから設定値を取得する関数getPostedUrls(): すでに通知済みのURL一覧を取得する関数savePostedUrl(url): 通知済みURLを保存する関数
定期実行の設定
Google Apps Scriptのトリガーを設定して、定期的に実行するようにします:
- スクリプトエディタの「トリガー」をクリック
 - 「トリガーを追加」をクリック
 - 実行する関数に「main」を選択
 - イベントのソースを「時間主導型」に設定
 - 時間の種類を「時間」に設定し、間隔を選択(例:1時間ごと)
 - 保存をクリック
 
プロンプトエンジニアリング
Geminiに要約させる際のプロンプトは、例えば:
以下のテック記事の内容を3〜5行で要約してください。
などです
カスタマイズのポイント
監視するRSSフィードの追加
FEEDS配列に新しいフィードを追加することで、監視対象を増やせます:
const FEEDS = [
  // 既存のフィード
  { url: '新しいRSSフィードのURL', id: '識別子' }  
];
本文セレクタの調整
新しいサイトを追加する場合、記事本文を抽出するためのCSSセレクタを追加します:
const CONTENT_SELECTOR_MAP = {
  // 既存のセレクタ
  'example.com': '.article-content'  
};
検証ツールで記事本文が存在するdiv要素のセレクタを探します。

まとめ
このシステムにより、以下のメリットが得られます:
- 複数のニュースソースを一元管理
 - AIによる要約で情報の把握が迅速に
 - Slack通知でチーム内での情報共有が容易に
 - Google Apps Scriptによる無料で簡単な実装
 
GASスプレッドシートはこちら
今回使用したスプレッドシートはこちら

  
  
  
  


