GASでRSSの新着記事をAIで
要約しSlackに毎朝配信する方法

複数サイトのRSSフィードをXmlServiceで取得し、新着記事だけをClaude APIで1〜2行に要約して、毎朝Slackにまとめて配信する仕組みを、動くコードで解説します。 情報収集の「見に行く」を「届く」に変える、実務向けの自動化です。

|対象: GAS / XmlService / Claude API / Slack

Table of Contents

情報収集を「毎朝届く」に変える

業界のニュースや競合のブログを毎日チェックするのは、意外と続きません。 サイトを1つずつ開いて回るうちに、見落としたり、後回しにして溜め込んだりします。

そこで、各サイトが配信するRSS(更新情報を機械が読める形で配るフィード)を使います。 GASで複数フィードの新着だけを集め、Claude APIで1〜2行に要約し、毎朝Slackにまとめて届ける仕組みを作ります。 「見に行く」から「届く」に変わり、タイトルと要約を見て開くべき記事だけを選べるようになります。

巡回の手間ゼロ

複数サイトのRSSをGASが自動で回るので、1つずつ開かなくてよい

要約で取捨選択

タイトル+1行要約で、開く価値がある記事だけを素早く判断できる

チームで共有

Slackに流すので、担当者みんなが同じ情報を朝に受け取れる

重複なし

配信済みの記事を記録し、同じニュースを二度流さない

APIキーとWebhookを安全に保管する

使うのはClaude APIキーと、SlackのIncoming Webhook URL(指定チャンネルに投稿するための送信先URL)の2つです。 どちらもコードに直接書くと共有時に漏れる恐れがあるため、PropertiesService(スクリプトごとの設定値を安全に保管する仕組み)のスクリプトプロパティに保存します。

// スクリプトプロパティにAPIキーとSlack Webhookを保存(初回1回だけ実行)
function saveSecrets() {
  PropertiesService.getScriptProperties().setProperties({
    CLAUDE_API_KEY: "sk-ant-xxxxxxxx",
    SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/xxxx/xxxx/xxxx",
  });
}

function getProp(name) {
  const v = PropertiesService.getScriptProperties().getProperty(name);
  if (!v) throw new Error(name + " が未設定です。saveSecrets を実行してください。");
  return v;
}

SlackのWebhook URLの取得方法は、GASからSlackに自動通知する方法で詳しく解説しています。

購読するRSSフィードを登録する

チェックしたいサイトのRSS/AtomフィードのURLを配列に並べます。 多くのサイトは/feed/rss/atom.xmlをURL末尾に付けるとフィードにアクセスできます。

// 購読したいRSS/AtomフィードのURLを並べる
const FEEDS = [
  "https://example.com/blog/feed",     // RSS形式でもAtom形式でもOK
  "https://another-site.example/atom.xml",
  "https://news.example/rss",
];

XmlServiceでRSS・Atomを解析する

フィードはUrlFetchAppで取得し、XmlService(GAS標準のXML解析ライブラリ)で読み解きます。 フィードにはRSS形式(rssタグ)とAtom形式(feedタグ)の2種類があり、記事の入れ物や要素名が違います。 ルート要素の名前で判別し、どちらでも同じ形の記事オブジェクトに揃えます。

// 1つのフィードを取得し、記事の配列にして返す(RSS・Atom両対応)
function fetchFeed(url) {
  const res = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
  if (res.getResponseCode() !== 200) {
    Logger.log("取得失敗: " + url);
    return [];
  }

  // XmlServiceでXMLを解析する(GAS標準のXMLパーサー)
  const root = XmlService.parse(res.getContentText()).getRootElement();
  const atom = XmlService.getNamespace("http://www.w3.org/2005/Atom");

  // <rss> なら channel > item、<feed>(Atom)なら entry を取り出す
  if (root.getName() === "rss") {
    const channel = root.getChild("channel");
    return channel.getChildren("item").map((item) => ({
      id: text(item, "guid") || text(item, "link"),
      title: text(item, "title"),
      link: text(item, "link"),
      body: stripTags(text(item, "description")),
    }));
  } else {
    // Atom形式
    return root.getChildren("entry", atom).map((entry) => ({
      id: text(entry, "id", atom) || getAtomLink(entry, atom),
      title: text(entry, "title", atom),
      link: getAtomLink(entry, atom),
      body: stripTags(text(entry, "summary", atom) || text(entry, "content", atom)),
    }));
  }
}

// 子要素のテキストを安全に取り出す(無ければ空文字)
function text(parent, name, ns) {
  const child = ns ? parent.getChild(name, ns) : parent.getChild(name);
  return child ? child.getText().trim() : "";
}

// Atomの <link href="..."> からURLを取り出す
function getAtomLink(entry, ns) {
  const link = entry.getChild("link", ns);
  return link ? link.getAttribute("href").getValue() : "";
}

// 本文からHTMLタグを除いて短くする
function stripTags(html) {
  return html.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim().slice(0, 400);
}

Atom形式は名前空間(namespace)が付くため、getChildに名前空間を渡すのがポイントです。 本文はHTMLタグを除いて先頭だけを残し、要約に渡すトークンを節約します。

配信済みをスキップして新着だけ集める

毎日実行しても同じ記事を流さないよう、配信済みの記事ID(guidまたはURL)をスクリプトプロパティに記録します。 全フィードを回して、まだ記録にない記事だけを新着として集めます。 1回の配信が多すぎないよう、件数に上限も設けます。

// 全フィードを回して、まだ配信していない新着記事だけを集める
function collectNewItems() {
  const props = PropertiesService.getScriptProperties();
  const sentIds = JSON.parse(props.getProperty("SENT_IDS") || "[]");
  const sentSet = {};
  sentIds.forEach((id) => (sentSet[id] = true));

  const fresh = [];
  FEEDS.forEach((url) => {
    fetchFeed(url).forEach((item) => {
      if (!item.id || sentSet[item.id]) return; // 配信済みはスキップ
      if (!item.title) return;
      fresh.push(item);
      sentSet[item.id] = true; // 同じ実行内での重複も防ぐ
    });
  });

  return fresh.slice(0, 15); // 1回の配信は多くても15件までに絞る
}

Claude APIで新着をまとめて要約する

集めた新着記事を1回のリクエストでまとめてClaude APIに渡し、それぞれのsummary(1文の要約)をJSONで受け取ります。 1件ずつ呼ぶより通信回数が減り、速く安く動きます。 RSSの本文は抜粋だけのことが多いため、「推測は書かない」と指示して要約の暴走を防ぎます。

// 新着記事の配列を渡し、1〜2行の日本語要約をまとめて受け取る
function summarizeItems(items) {
  if (items.length === 0) return [];

  const prompt =
    "あなたはIT担当者向けのニュースキュレーターです。\n" +
    "次の記事それぞれを、日本語で40字以内・1文に要約してください。\n" +
    "本文が抜粋のみの場合は、分かる範囲で要約し、推測は書かないでください。\n" +
    "余計な説明は出力せず、JSON配列だけを返してください。\n" +
    "形式: [{\"index\":0,\"summary\":\"新機能Xが発表され無料枠が拡大\"}]\n\n" +
    "記事(番号: タイトル / 本文抜粋):\n" +
    items.map((it, i) => i + ": " + it.title + " / " + it.body).join("\n");

  const payload = {
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    messages: [{ role: "user", content: prompt }],
  };

  const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
    method: "post",
    contentType: "application/json",
    headers: {
      "x-api-key": getProp("CLAUDE_API_KEY"),
      "anthropic-version": "2023-06-01",
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  if (res.getResponseCode() !== 200) {
    throw new Error("API error: " + res.getContentText());
  }

  const responseText = JSON.parse(res.getContentText()).content[0].text;
  const parsed = JSON.parse(extractJson(responseText));

  // 要約を元の記事にひも付ける
  return items.map((it, i) => {
    const found = parsed.find((p) => p.index === i);
    return { title: it.title, link: it.link, summary: found ? found.summary : "" };
  });
}

// レスポンスからJSON配列部分だけを取り出す(前後の余計な文字対策)
function extractJson(text) {
  const start = text.indexOf("[");
  const end = text.lastIndexOf("]");
  return text.slice(start, end + 1);
}

AIの返答には前後に説明文が付くことがあるため、[から]までを切り出してからJSON.parseしています。 返ってきた要約はindexを頼りに元記事へひも付けます。

Block KitでSlackに見やすく投稿する

要約付きの記事一覧を、SlackのBlock Kit(メッセージを部品で組み立てる仕組み)で投稿します。 見出し・区切り線・記事ごとのセクションで構成すると、朝の一覧として読みやすくなります。 タイトルはリンクにして、要約を見て気になった記事をすぐ開けるようにします。

// 要約付きの記事一覧をBlock KitでSlackに投稿する
function postToSlack(summaries) {
  const blocks = [
    {
      type: "header",
      text: { type: "plain_text", text: "☀️ 今朝の新着記事(" + summaries.length + "件)" },
    },
    { type: "divider" },
  ];

  summaries.forEach((s) => {
    blocks.push({
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*<" + s.link + "|" + escapeMrkdwn(s.title) + ">*\n" + s.summary,
      },
    });
  });

  UrlFetchApp.fetch(getProp("SLACK_WEBHOOK_URL"), {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify({ blocks: blocks }),
    muteHttpExceptions: true,
  });
}

// Slackのmrkdwnで意味を持つ記号をエスケープする
function escapeMrkdwn(t) {
  return t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

Slackのmrkdwn(Slack独自の記法)では<>が特別な意味を持つため、タイトルに含まれると表示が崩れます。エスケープしておくと安全です。

毎朝の定期トリガーで自動配信する

収集・要約・投稿・記録を1つの関数にまとめ、毎朝自動で動かします。 新着がゼロなら投稿をスキップし、無駄な通知を出しません。 一時的なAPIエラーに備えて、失敗したら少し待って再試行するリトライも挟みます。

// 収集 → 要約 → Slack投稿 → 配信済み記録 を一気に実行する
function runDailyDigest() {
  const items = collectNewItems();
  if (items.length === 0) {
    Logger.log("新着記事はありませんでした。");
    return;
  }

  const summaries = summarizeWithRetry(items);
  postToSlack(summaries);

  // 配信済みIDを記録(直近300件だけ保持して肥大化を防ぐ)
  const props = PropertiesService.getScriptProperties();
  const sentIds = JSON.parse(props.getProperty("SENT_IDS") || "[]");
  const merged = sentIds.concat(items.map((it) => it.id)).slice(-300);
  props.setProperty("SENT_IDS", JSON.stringify(merged));
}

// 一時的なAPIエラー時に最大3回まで再試行する
function summarizeWithRetry(items, maxRetry) {
  const limit = maxRetry || 3;
  for (let attempt = 1; attempt <= limit; attempt++) {
    try {
      return summarizeItems(items);
    } catch (e) {
      Logger.log("試行" + attempt + "回目で失敗: " + e.message);
      if (attempt === limit) throw e;
      Utilities.sleep(1000 * attempt); // 待ち時間を伸ばして再試行
    }
  }
}

あとは時間主導トリガー(時刻や間隔で自動実行する仕組み)で、毎朝の時間帯に実行するよう設定します。atHour(8)なら8〜9時の間に配信されます。

// 毎朝8時台に自動実行するトリガーを作る(初回1回だけ実行)
function createDailyTrigger() {
  ScriptApp.newTrigger("runDailyDigest")
    .timeBased()
    .atHour(8)       // 8〜9時の間に実行される
    .everyDays(1)    // 毎日
    .create();
}

実務での注意点

1. 要約は「開くかどうか」の判断材料にする

RSSの本文は冒頭の抜粋だけのことが多く、要約が全文の内容と一致しない場合があります。 数字や固有名詞など重要な情報は、必ず元記事のリンクを開いて確認してください。 要約はあくまで取捨選択のための下書きです。

2. フィードの取得マナーを守る

短い間隔で何度も取得すると、相手サーバーに負荷をかけます。配信は1日1〜数回にとどめ、常識的な頻度で利用してください。 取得したフィードの内容の利用が、各サイトの利用規約に反しないかも確認しておくと安心です。

3. 取得できないフィードもある

RSSを提供していないサイトや、独自形式・認証が必要なフィードは取得できません。 取得失敗はログに残してスキップし、他のフィードの配信は止めないようにしています。

4. 実行時間の上限に注意する

GASには1回あたり6分の実行時間制限があります。フィード数や新着件数が多い場合は、要約のバッチ処理と配信件数の上限で1回の処理量を抑えると安定します。

まとめ

RSSフィードの巡回は、XmlServiceでの解析、配信済みのスキップ、Claude APIでのバッチ要約、Block KitでのSlack投稿を組み合わせれば、毎朝自動で届く仕組みにできます。 RSSとAtomの両方に対応させ、要約と一緒に必ず元記事リンクを載せておけば、チーム全員が朝に情報を受け取り、開くべき記事だけを選べます。 要約は取捨選択の下書きとして使い、詳細は元記事で確認する、という前提だけ守って活用してください。

よくある質問

多くのニュースサイトやブログには、購読用のRSS(更新情報を配信する仕組み)が用意されています。ページ内の「RSS」「フィード」リンクや、ブラウザのアドレスバーに /feed や /rss、/atom.xml を付けたURLで見つかることが多いです。この記事のコードはRSS(rssタグ)とAtom(feedタグ)の両方に対応しているので、どちらの形式でも同じように扱えます。取得できないサイトは、配信自体を提供していない場合があります。

配信済みの記事IDを PropertiesService(スクリプトごとの設定値を保管する仕組み)に記録し、次回以降はスキップするようにしています。記事のURL(またはguid)をキーにするので、同じ記事が再配信されることはありません。記録が増えすぎないよう、この記事では直近の一定件数だけを保持する形にしています。

要約はあくまで「その記事を開くべきか」を判断するための下書きとして使ってください。AIはRSSに含まれる本文(多くは冒頭の抜粋)から要約するため、全文を読んだ場合と細部が異なることがあります。数字や固有名詞など重要な情報は、必ず元記事のリンクを開いて確認する前提で運用してください。この記事でも要約と一緒に必ず元記事URLを載せています。

GASには1回あたり6分の実行時間制限があります。フィードの取得と要約をまとめて行うため、10〜20サイト程度なら問題なく動きます。サイト数や新着件数が多い場合は、要約を複数記事まとめて1回のAPIリクエストで送るバッチ処理にすると、通信回数が減って速くなります。この記事のコードもバッチ要約に対応しています。

直接書くのは避けてください。スクリプトを共有した際に漏れる恐れがあります。Claude APIキーもSlackのWebhook URLも、PropertiesService のスクリプトプロパティに保存し、コードからは getProperty() で読み込む方法が安全です。

情報収集の
自動化を相談する。

RSS配信以外にも、Webページやメールの要約・定期レポート・Slack通知など、情報収集と共有を効率化するGAS×AI開発をご相談いただけます。

AI×GAS自動化を見る