0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GAS × Gemini API × Discord】無料で実現するRSSフィードのAI自動要約&通知システム構築ガイド

Last updated at Posted at 2025-10-15

概要

Googleスプレッドシートに登録したRSSフィードから記事情報を取得し、Gemini APIを利用して各記事の要約を生成します。生成された要約はDiscordの指定チャンネルにWebhook経由で自動的に通知されます。Gemini APIの無料枠とGASを活用して無料で構築することができます。

通知先についてはLINEも検討しましたが、Messaging APIの無料枠だと月200通しか送れないので本記事では通知先としてDiscordを選択しています。
要約する件数を制限すればLINE通知でも十分だと思います。

完成イメージ

Qiitaの人気記事のRSSフィードを登録してみました。


以下の記事の要約が表示されています。

セットアップ手順

1. スプレッドシートの準備

まず、取得対象のRSSフィードを管理するGoogleスプレッドシートを作成します。

  1. 新規にGoogleスプレッドシートを作成する
  2. A列にサイト名、B列にRSSフィードのURLを2行目以降に入力する。1行目はヘッダーとする。シート名はデフォルトのシート1のままでOK

2. APIキーとWebhook URLの取得

Gemini APIキー

  1. Google AI Studioにアクセスする
  2. Get API keyを選択し、APIキーを生成・コピーする

【API料金について】
Google AI StudioからのAPIキー取得の詳細手順は割愛しますが、以下画像のように割り当てティアが無料枠となっていれば課金されないはずです。

Discord Webhook URL

  1. 通知を送信したいDiscordサーバーのチャンネル設定を開く
  2. 「連携サービス」タブから「ウェブフックを作成」を選択する
  3. Webhookの名前等を設定し、「ウェブフックURLをコピー」する

3. Google Apps Scriptの設定

スクリプトエディタの起動

スプレッドシートのメニューから 拡張機能 > Apps Script を選択し、スクリプトエディタを開く。

スクリプトプロパティの設定

取得したAPIキーとURLをスクリプトプロパティに保存する。

  1. スクリプトエディタの左メニューからプロジェクトの設定を開く
  2. 「スクリプト プロパティ」セクションで、以下の2つのプロパティを追加・保存する
プロパティ
GEMINI_API_KEY 取得したGemini APIキー
DISCORD_WEBHOOK_URL 取得したDiscord Webhook URL

スクリプトコードの編集

エディタ内の既存コードをすべて削除し、以下のコードを貼り付ける。

const SHEET_NAME = 'シート1';

function main() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    console.error(`シート「${SHEET_NAME}」が見つかりません。`);
    return;
  }
  
  const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).getValues();
  
  const thresholdDate = new Date();
  thresholdDate.setDate(thresholdDate.getDate() - 1);

  for (const row of data) {
    const siteName = row[0];
    const rssUrl = row[1];
    
    if (!rssUrl) continue;
    
    try {
      const articles = fetchAndParseRSS(rssUrl);
      if (!articles || articles.length === 0) continue;

      for (const article of articles) {
        const articleDate = new Date(article.pubDate);
        if (articleDate < thresholdDate) {
          continue;
        }
        
        console.log(`[${siteName}] 記事取得: ${article.title}`);
        
        const articleText = getArticleText(article.link);
        const summary = summarizeTextWithGemini(articleText);
        if (!summary) {
          console.log(`[${siteName}] 要約取得に失敗したためスキップ`);
          continue;
        }
        
        console.log(`[${siteName}] 要約完了`);


        const [summaryBlock, keywordsBlock] = summary.split('---KEYWORDS---');
        const [conclusionBlock, pointsBlock] = summaryBlock.split('---POINTS---');

        const conclusion = conclusionBlock ? conclusionBlock.replace('### この記事の結論\n', '').trim() : '結論の取得に失敗しました。';
        const pointsContent = pointsBlock ? pointsBlock.replace(/### \S+\n/, '').trim() : '';
        const keywordsContent = keywordsBlock ? keywordsBlock.replace(/### \S+\n/, '').trim() : '';


        let fields = [];
        if (pointsContent) {
          let content = pointsContent;
          if (content.length > 1020) {
            content = content.substring(0, 1020) + '...';
          }
          fields.push({
            name: `**重要なポイント**`, 
            value: content     
          });
        }
        if (keywordsContent) {
          fields.push({
            name: `**キーワード**`,
            value: keywordsContent
          });
        }
        

        const discordPayload = {
          username: siteName,
          embeds: [{
            title: article.title,
            url: article.link,
            description: conclusion,
            fields: fields,
            color: 3447003,
            footer: {
              text: "AI Summary Bot"
            }
          }]
        };

        sendDiscordMessage(discordPayload);
        Utilities.sleep(2000);
      }
    } catch (e) {
      console.error(`[${siteName}] の処理中に予期せぬエラー: ${e.toString()}`);
    }
  }
}

function summarizeTextWithGemini(text) {
  const GEMINI_API_KEY = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!GEMINI_API_KEY) {
    console.error('GEMINI_API_KEYが設定されていません。');
    return null;
  }
  
  const GEMINI_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`;
  
  const prompt = `この記事から「この記事を一言で言うと何か?(結論・主張)」と「主要なポイント(箇条書き)」および「キーワード」を抽出してください。必ず以下の形式で出力してください。

### この記事の結論
ここに記事の核心的な主張や結論を1〜2文で記述。
---POINTS---
### 重要なポイント
- ポイント1
- ポイント2
---KEYWORDS---
### キーワード
キーワード1, キーワード2, キーワード3

---
${text.substring(0, 8000)}`;
  const payload = {
    "contents": [{"parts": [{"text": prompt}]}],
    "generationConfig": {"temperature": 0.3, "topP": 0.95, "maxOutputTokens": 2048}
  };

  const options = {
    'method': 'post', 'contentType': 'application/json',
    'payload': JSON.stringify(payload), 'muteHttpExceptions': true
  };

  const response = UrlFetchApp.fetch(GEMINI_API_URL, options);
  const jsonResponse = JSON.parse(response.getContentText());
  
  try {
    if (jsonResponse.candidates && jsonResponse.candidates[0].content && jsonResponse.candidates[0].content.parts && jsonResponse.candidates[0].content.parts[0].text) {
      return jsonResponse.candidates[0].content.parts[0].text;
    } else {
      const reason = jsonResponse.candidates ? jsonResponse.candidates[0].finishReason : 'N/A';
      console.error(`Geminiからのレスポンスが不正です。理由: ${reason} レスポンス: ${JSON.stringify(jsonResponse)}`);
      return null;
    }
  } catch(e) {
    console.error(`Gemini APIレスポンスの解析エラー: ${e.toString()}`);
    return null;
  }
}

function sendDiscordMessage(payload) {
  const WEBHOOK_URL = PropertiesService.getScriptProperties().getProperty('DISCORD_WEBHOOK_URL');
  if (!WEBHOOK_URL) {
    console.error('DISCORD_WEBHOOK_URLが設定されていません。');
    return;
  }
  const options = {
    'method': 'post',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload)
  };
  UrlFetchApp.fetch(WEBHOOK_URL, options);
}

function fetchAndParseRSS(url) {
  try {
    const xml = UrlFetchApp.fetch(url, { muteHttpExceptions: true }).getContentText();
    const document = XmlService.parse(xml);
    const root = document.getRootElement();
    const rootName = root.getName();
    const articles = [];
    let items = [];
    if (rootName === 'rss') {
      const channel = root.getChild('channel');
      if (channel) items = channel.getChildren('item');
      for (const item of items) {
        const title = item.getChild('title').getText();
        const link = item.getChild('link').getText();
        const pubDate = item.getChild('pubDate') ? item.getChild('pubDate').getText() : new Date().toUTCString();
        articles.push({ title, link, pubDate });
      }
    } else if (rootName === 'feed') {
      const atom = XmlService.getNamespace('http://www.w3.org/2005/Atom');
      items = root.getChildren('entry', atom);
      for (const item of items) {
        const title = item.getChild('title', atom).getText();
        const link = item.getChild('link', atom).getAttribute('href').getValue();
        const pubDate = item.getChild('published', atom) ? item.getChild('published', atom).getText() : new Date().toISOString();
        articles.push({ title, link, pubDate });
      }
    }
    return articles;
  } catch (e) {
    console.error(`RSSの取得または解析に失敗 (${url}): ${e.toString()}`); return [];
  }
}

function getArticleText(url) {
  try {
    const html = UrlFetchApp.fetch(url, { muteHttpExceptions: true }).getContentText();
    const text = html.replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '\n').replace(/(\n\s*){3,}/g, '\n\n');
    return text.trim();
  } catch (e) {
    console.error(`記事本文の取得に失敗 (${url}): ${e.toString()}`); return '';
  }
}

動作確認と自動実行設定

手動実行

スクリプトの動作確認のため、エディタ上部でmain関数を選択し、「実行」ボタンをクリックする。初回実行時には、Googleアカウントに対する承認が必要となる。実行後、Discordチャンネルに通知が送信されることを確認する。

自動実行(トリガー設定)

スクリプトを定期的に自動実行するため、トリガーを設定する。

  1. スクリプトエディタの左メニューからトリガーを開く
  2. 「トリガーを追加」を選択し、以下のように設定する
    • 実行する関数: main
    • イベントのソース: 時間主導型
    • トリガーのタイプ: 日タイマー
    • 時刻: 任意の実行時間帯(例: 午前8時~9時
  3. 設定を保存する

参考

Discord Webhook Resource
LINE Messaging APIの料金

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?