🌊

【自動化】GASとAIを使ってニュース自動要約メール配信システムを実装

に公開

はじめに

「気になるニュースサイトが多すぎて毎日チェックしきれない」という方に向けて、RSSフィードの新着記事を毎朝自動で収集し、OpenAI APIで要約してGmailに送信する仕組みをGASで実装しました。

実装にあたって以下の点を意識しました。

  • RSSフィードのURLをスプレッドシートに登録するだけで取得先を管理できる
  • 一度送信した記事は再送しない重複防止の仕組みを入れる
  • OpenAI APIの呼び出しコストを最小限に抑える設計にする

完成イメージ

購読リスト

配信履歴

受信メール

スプレッドシート(購読リスト)

A列 B列 C列
サイト名 RSS URL 有効フラグ
ITmedia AI+ https://rss.itmedia.co.jp/rss/2.0/aiplus.xml TRUE
Gigazine https://gigazine.net/news/rss_2.0/ TRUE

全体の流れ

【設定(スプレッドシート)】
購読リストシートにRSS URLを登録しておく
 ↓
【収集(GAS × 時間トリガー)】
毎朝8時に自動起動し、RSSフィードを取得・XMLをパース
 ↓
【重複チェック(スプレッドシート)】
配信履歴シートと照合して未送信の記事だけを抽出
 ↓
【要約(GAS × OpenAI API)】
各記事のタイトル+本文冒頭をOpenAIに渡して3行要約を生成
 ↓
【配信(GAS × Gmail)】
要約一覧をHTMLメールにまとめてGmailで送信
 ↓
【記録(スプレッドシート)】
送信した記事のURLを配信履歴シートに記録

スプレッドシートのシート構成

シート名 用途
購読リスト RSS URLとサイト名・有効フラグを管理
配信履歴 送信済み記事のURLを記録(重複防止)

事前準備

OpenAI APIキーの確認

OpenAIのアカウントにログインし、APIキーを取得しておきます。
https://platform.openai.com/api-keys

今回使用するモデルは gpt-4o-mini です。要約用途なら十分な精度があり、コストも非常に安価です。


実装手順

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

新しいスプレッドシートを作成し、以下の2つのシートを用意します。

「購読リスト」シートの列構成

項目 入力例
A列 サイト名 ITmedia AI+
B列 RSS URL https://rss.itmedia.co.jp/rss/2.0/aiplus.xml
C列 有効フラグ TRUE

C列はチェックボックスにしておくと管理しやすいです。「挿入」→「チェックボックス」から設定してください。

参考として使いやすいRSSフィードを以下に示します。

サイト名 RSS URL
ITmedia AI+ https://rss.itmedia.co.jp/rss/2.0/aiplus.xml
ITmedia NEWS https://rss.itmedia.co.jp/rss/2.0/news_bursts.xml
Gigazine https://gigazine.net/news/rss_2.0/

「配信履歴」シートの列構成

項目
A列 記事URL
B列 記録日時

こちらは最初は空のままで構いません。GASが自動で書き込みます。

2. GASスクリプトの実装

スプレッドシートのメニューから「拡張機能」→「Apps Script」を開き、以下のコードを貼り付けます。

// =====================
// 設定値
// =====================
const OPENAI_API_KEY        = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
const MAIL_TO               = PropertiesService.getScriptProperties().getProperty("MAIL_TO");
const SHEET_FEEDS           = "購読リスト";
const SHEET_HISTORY         = "配信履歴";
const MAX_ARTICLES_PER_FEED = 5;  // 1フィードあたりの最大取得記事数

// =====================
// メイン関数(トリガーから呼び出す)
// =====================
function sendNewsSummary() {
  if (!OPENAI_API_KEY) {
    Logger.log("エラー:スクリプトプロパティに OPENAI_API_KEY が設定されていません。");
    return;
  }

  const ss      = SpreadsheetApp.getActiveSpreadsheet();
  const feeds   = getActiveFeeds(ss);

  if (feeds.length === 0) {
    Logger.log("有効なフィードがありません。");
    return;
  }

  const history     = loadHistory(ss);
  const newArticles = [];

  // 各フィードから新着記事を収集
  for (const feed of feeds) {
    const articles = fetchArticles(feed.url, feed.name, history);
    newArticles.push(...articles);
  }

  if (newArticles.length === 0) {
    Logger.log("新着記事はありませんでした。");
    return;
  }

  // 各記事をOpenAI APIで要約
  for (const article of newArticles) {
    article.summary = summarizeWithOpenAI(article.title, article.description);
    Utilities.sleep(500); // APIレート制限対策
  }

  // 全体の所感を生成
  const overallComment = generateOverallComment(newArticles);

  // メール送信
  sendMail(newArticles, overallComment);

  // 配信履歴に記録
  saveHistory(ss, newArticles);

  Logger.log(`${newArticles.length}件の記事を送信しました。`);
}

// =====================
// 有効なフィード一覧を取得
// =====================
function getActiveFeeds(ss) {
  const sheet = ss.getSheetByName(SHEET_FEEDS);

  if (!sheet) {
    Logger.log(`シートが見つかりません:${SHEET_FEEDS}`);
    return [];
  }

  const data  = sheet.getDataRange().getValues();
  const feeds = [];

  for (let i = 1; i < data.length; i++) { // 1行目はヘッダーのためスキップ
    const name    = data[i][0];
    const url     = data[i][1];
    const enabled = data[i][2];

    if (name && url && enabled === true) {
      feeds.push({ name, url });
    }
  }

  return feeds;
}

// =====================
// 配信履歴(送信済みURL)をセットで読み込む
// =====================
function loadHistory(ss) {
  const sheet = ss.getSheetByName(SHEET_HISTORY);

  if (!sheet) {
    Logger.log(`シートが見つかりません:${SHEET_HISTORY}`);
    return new Set();
  }

  const data = sheet.getDataRange().getValues();
  const urls = new Set();

  for (let i = 1; i < data.length; i++) {
    if (data[i][0]) urls.add(data[i][0]);
  }

  return urls;
}

// =====================
// RSSフィードから記事を取得・パース
// =====================
function fetchArticles(feedUrl, feedName, history) {
  let response;

  try {
    response = UrlFetchApp.fetch(feedUrl, { muteHttpExceptions: true });
  } catch (e) {
    Logger.log(`フィード取得エラー(${feedName}): ${e.message}`);
    return [];
  }

  if (response.getResponseCode() !== 200) {
    Logger.log(`HTTPエラー(${feedName}): ${response.getResponseCode()}`);
    return [];
  }

  const xml      = response.getContentText();
  const document = XmlService.parse(xml);
  const root     = document.getRootElement();

  // RSS 2.0 と Atom の両方に対応
  let items = [];
  const ns  = root.getNamespace();

  if (root.getName() === "rss") {
    // RSS 2.0 形式
    const channel = root.getChild("channel");
    items = channel.getChildren("item");
  } else if (root.getName() === "feed") {
    // Atom 形式
    items = root.getChildren("entry", ns);
  }

  const articles = [];

  for (const item of items.slice(0, MAX_ARTICLES_PER_FEED)) {
    const title = getElementText(item, "title", ns);
    const link  = getArticleLink(item, ns);
    const desc  = getElementText(item, "description", ns) ||
                  getElementText(item, "summary", ns) || "";

    if (!title || !link) continue;
    if (history.has(link)) continue; // 送信済みはスキップ

    articles.push({
      feedName:    feedName,
      title:       title.trim(),
      url:         link.trim(),
      description: stripHtml(desc).slice(0, 500), // 先頭500文字のみ使用
      summary:     ""
    });
  }

  return articles;
}

// =====================
// XMLの要素テキストを取得するヘルパー
// =====================
function getElementText(item, tagName, ns) {
  const child = item.getChild(tagName) || item.getChild(tagName, ns);
  return child ? child.getText() : "";
}

// =====================
// 記事URLを取得するヘルパー(RSS/Atom両対応)
// =====================
function getArticleLink(item, ns) {
  // RSS 2.0: <link>タグ
  const linkEl = item.getChild("link");
  if (linkEl) return linkEl.getText();

  // Atom: <link href="..." />属性
  const atomLink = item.getChild("link", ns);
  if (atomLink) return atomLink.getAttribute("href").getValue();

  return "";
}

// =====================
// HTMLタグを除去するヘルパー
// =====================
function stripHtml(html) {
  return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
}

// =====================
// OpenAI APIで記事を要約
// =====================
function summarizeWithOpenAI(title, description) {
  const prompt = `以下のニュース記事を日本語で3行以内に要約してください。
要約は体言止めや箇条書きを使わず、自然な文章で書いてください。

タイトル: ${title}
本文冒頭: ${description}`;

  const payload = {
    model: "gpt-4o-mini",
    messages: [
      {
        role: "system",
        content: "あなたはIT・テクノロジーニュースの要約を得意とするアシスタントです。"
      },
      {
        role: "user",
        content: prompt
      }
    ],
    max_tokens: 200,
    temperature: 0.3
  };

  let response;
  try {
    response = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", {
      method: "post",
      contentType: "application/json",
      headers: { "Authorization": `Bearer ${OPENAI_API_KEY}` },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    });
  } catch (e) {
    Logger.log(`OpenAI APIエラー: ${e.message}`);
    return "(要約の取得に失敗しました)";
  }

  if (response.getResponseCode() !== 200) {
    Logger.log(`OpenAI HTTPエラー: ${response.getResponseCode()}`);
    return "(要約の取得に失敗しました)";
  }

  const json = JSON.parse(response.getContentText());
  return json.choices[0].message.content.trim();
}

// =====================
// 全記事を通しての所感を生成
// =====================
function generateOverallComment(articles) {
  const articleList = articles.map((a, i) =>
    `${i + 1}. 【${a.feedName}】${a.title}\n   ${a.summary}`
  ).join("\n\n");

  const prompt = `以下は本日の新着テクノロジーニュースの一覧です。
これらの記事全体を読んで、世の中の動きや潮流について所感を200字程度で述べてください。

「●●という流れが世界では起きている」「日本では●●が課題になっている」「海外と日本で●●という温度差がある」といった視点を含めると良いです。
特定の記事の説明ではなく、全体を通して感じるトレンドや時代の流れを自然な文章で書いてください。
内容が変わるタイミングで段落を分け、段落の間には改行を入れてください。

${articleList}`;

  const payload = {
    model: "gpt-4o-mini",
    messages: [
      {
        role: "system",
        content: "あなたはIT・テクノロジー業界のトレンドを分析するアナリストです。"
      },
      {
        role: "user",
        content: prompt
      }
    ],
    max_tokens: 400,
    temperature: 0.7
  };

  let response;
  try {
    response = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", {
      method: "post",
      contentType: "application/json",
      headers: { "Authorization": `Bearer ${OPENAI_API_KEY}` },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    });
  } catch (e) {
    Logger.log(`全体所感の生成エラー: ${e.message}`);
    return "(全体所感の取得に失敗しました)";
  }

  if (response.getResponseCode() !== 200) {
    Logger.log(`全体所感 OpenAI HTTPエラー: ${response.getResponseCode()}`);
    return "(全体所感の取得に失敗しました)";
  }

  const json = JSON.parse(response.getContentText());
  return json.choices[0].message.content.trim();
}

// =====================
// メール送信
// =====================
function sendMail(articles, overallComment) {
  const today   = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd");
  const subject = `【AI News】本日の新着記事まとめ(${today})`;

  // フィードごとにグルーピング
  const grouped = {};
  for (const article of articles) {
    if (!grouped[article.feedName]) grouped[article.feedName] = [];
    grouped[article.feedName].push(article);
  }

  // テキストメール本文の組み立て
  let body = `本日(${today})の新着記事をお届けします。\n\n`;

  // 全体の所感
  body += "◆ 本日のトレンド所感\n";
  body += "━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
  body += `${overallComment}\n`;
  body += "━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n";

  for (const [feedName, feedArticles] of Object.entries(grouped)) {
    body += `■ ${feedName}\n`;
    body += "━━━━━━━━━━━━━━━━━━━━━━━━━━\n";

    for (const article of feedArticles) {
      body += `▼ ${article.title}\n`;
      body += ` ${article.url}\n`;
      body += ` → ${article.summary.replace(/\n/g, "\n  ")}\n\n`;
    }

    body += "\n";
  }

  GmailApp.sendEmail(MAIL_TO, subject, body);
}

// =====================
// 配信履歴に記録
// =====================
function saveHistory(ss, articles) {
  const sheet = ss.getSheetByName(SHEET_HISTORY);
  const now   = new Date();

  for (const article of articles) {
    sheet.appendRow([article.url, now]);
  }
}

3. APIキーとメールアドレスをスクリプトプロパティに設定する

APIキーをコードに直接書くとセキュリティリスクがあるため、GASのスクリプトプロパティを使って安全に管理します。

  1. スクリプトエディタ上部のメニューから「プロジェクトの設定」を開く
  2. 「スクリプト プロパティ」セクションの「スクリプト プロパティを追加」をクリック
  3. 以下の2つを登録する
プロパティ名
OPENAI_API_KEY OpenAIで取得したAPIキー
MAIL_TO 送信先メールアドレス
  1. 「スクリプト プロパティを保存」をクリック

スクリプト側では PropertiesService.getScriptProperties().getProperty("キー名") で値を取得するため、コード上に機密情報が残りません。

4. 動作確認(手動実行)

トリガーを設定する前に、まず手動で動作を確認します。

  1. スクリプトエディタ上部の関数選択プルダウンで sendNewsSummary を選択する
  2. 「実行」ボタン(▶)を押す
  3. 初回は権限の確認ダイアログが出るので「許可」を選択する
  4. 実行後、指定したメールアドレスに要約メールが届くことを確認する
  5. 配信履歴シートにURLが記録されていることを確認する
  6. もう一度実行すると、同じ記事が届かない(重複防止が機能している)ことを確認する

5. 時間トリガーの設定

動作確認ができたら、毎朝自動実行するトリガーを設定します。

  1. スクリプトエディタ左側の時計アイコン「トリガー」をクリック
  2. 右下の「トリガーを追加」ボタンをクリック
  3. 以下のように設定する
項目 設定値
実行する関数 sendNewsSummary
イベントのソース 時間主導型
時間ベースのトリガーのタイプ 日付ベースのタイマー
時刻 午前8時〜9時
  1. 「保存」ボタンをクリック

工夫した点・つまずいた点

RSSのXML形式がサイトによって異なる

RSSには「RSS 2.0」と「Atom」の2種類の形式があります。ITmediaはRSS 2.0、一部のサイトはAtom形式を使っているため、両方に対応できるよう分岐処理を実装しました。

if (root.getName() === "rss") {
  // RSS 2.0 形式
  const channel = root.getChild("channel");
  items = channel.getChildren("item");
} else if (root.getName() === "feed") {
  // Atom 形式
  items = root.getChildren("entry", ns);
}

記事本文のHTMLタグを除去してからOpenAIに渡す

RSSの <description> タグにはHTMLタグが含まれていることが多いです。そのままOpenAIに渡すと要約の精度が下がるため、タグを除去する処理を入れました。

function stripHtml(html) {
  return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
}

OpenAI APIの連続呼び出しにウェイトを入れた

記事数が多いとAPIリクエストが連続して発生し、レートリミットに引っかかることがあります。Utilities.sleep(500) で1件ごとに0.5秒のウェイトを入れることで対処しました。

配信履歴の管理方法

送信済みURLをスプレッドシートに蓄積していくシンプルな設計にしました。読み込み時に Set に変換することで、記事数が増えても高速に重複チェックができます。

const urls = new Set();
for (let i = 1; i < data.length; i++) {
  if (data[i][0]) urls.add(data[i][0]);
}

コストの目安

今回使用している gpt-4o-mini の料金は、入力100万トークンあたり約$0.15、出力100万トークンあたり約$0.60です。

1記事あたりの処理に使うトークン数を概算すると以下の通りです。

項目 トークン数(概算)
システムプロンプト 約50トークン
記事タイトル+本文冒頭 約200トークン
要約(出力) 約150トークン

1記事あたりの合計が約400トークンとすると、1日10記事を処理しても約0.03円(1ドル150円換算)という非常に安価な運用が可能です。


まとめ

GASとOpenAI APIを使って、ニュースの自動要約メール配信システムを実装しました。今回の実装で以下が実現できました。

  • スプレッドシートへのURL登録だけで取得先を柔軟に管理
  • RSS 2.0 / Atom 両形式への対応
  • 重複送信を防ぐ配信履歴の仕組み
  • OpenAI APIによる自然な日本語3行要約
  • 毎朝8時の自動実行による完全な手放し運用

購読リストにURLを追加するだけで情報収集を自動化できるため、業界ニュースの監視や競合調査など幅広い用途に応用できます。

GASを使ったこうした自動化・効率化の仕組みを自社や業務に導入したい方は、お気軽にご相談ください。

Discussion