見出し画像

[コード公開] GAS x Zapier x LLMで今日のニュースを自動でサマリーする

こんにちは、note AI creativeの田中です。

今日話題になったAI系のニュースを自動でサマリーしてお知らせする仕組み」を作ったので作り方を解説します。Zapierの設定やGASのコードも可能な範囲で公開しています。

チャンネルへの投稿
スレッド

全体の構成

全体の流れは以下の図のとおりです。

全体構成図
  1. ①〜③:RSSでニュースを取得して、各ニュースの要約を作成

  2. :GASで今日のニュース要約を作成

  3. :Zapierで今日のニュース要約をSlackにポスト

1. ZapierでRSS購読して要約&スプシに記載

全体像の①〜③

以下はZapierの設定内容です。

Zapier全体像
  • ステップ1のRSS by Zapierは https://b.hatena.ne.jp/hotentry/it.rss を購読している。

  • ステップ2は、「Raw Subject」にAI関連のキーワードが入っているかで判定している。

AIニュース判定
  • ステップ4の「Webhook by Zapier」は社内で使っているAzureのエンドポイントにPOSTリクエストしている。OpenAIのAPIを使っても同じことができる。ただし、Zapierはタイムアウトが30秒程度と厳しいため、GPT-4などは使えない。GPT-4oやGPT-4o-miniなど推奨。

    • 以下は使用しているプロンプト

以下の記事のContentを日本語でFormatに従い要約してください。
要約は要点を5点に整理して下さい。文書は日本語で要約して下さい。
<Format>
タイトル:{日本語にした記事のタイトル}
1. *要約見出し1*: 要約のポイント1つ目。
2. *要約見出し2*: 要約のポイント2つ目。
3. *要約見出し3*: 要約のポイント3つ目。
4. *要約見出し4*: 要約のポイント4つ目。
5. *要約見出し5*: 要約のポイント5つ目。
</Format>

<Content>
タイトル:{title}
{content}
</Content>
  • ステップ7の「今日の日付を取得」はJavaScriptのコードで今日の日付を取得している(スプシ書き込み用)

// 現在の日付を取得
var date = new Date();

// 年を取得
var year = date.getFullYear();

// 月を取得(0から11の値を返すため、1を足す)
var month = date.getMonth() + 1;

// 日を取得
var day = date.getDate();

// 月が1桁の場合は先頭に0を追加
if (month < 10) {
  month = "0" + month;
}

// 日が1桁の場合は先頭に0を追加
if (day < 10) {
  day = "0" + day;
}

// 出力する日付の形式を指定(yyyy/mm/dd)
var outputDate = year + "/" + month + "/" + day;

// 出力結果をオブジェクトとしてまとめる
output = { Date: outputDate };
  • ステップ8では、結果をスプレッドシートに書き込んでいる。

ステップ8の設定詳細

このZapierを稼働させていると、スプレッドシート上に毎日複数件のニュース記事が貯まるようになってくる。

スプレッドシート上に溜まった記事群

2. GASで今日のニュース要約を作成

全体像の④

ここでは、スプレッドシートに溜まった記事群を1日単位でまとめて、今日話題になったトピックやそれらのサマリーをLLMで要約します。

以下は実際に動かしているコードです。

/**
 * メイン関数。スプレッドシートからデータを取得し、加工して結果をシートに書き込む。
 */
function processAndCallClaude() {
  var spreadsheetId = '<ここにシートID>';
  var sheetName = 'シート1';

  var data = getSpreadsheetData(spreadsheetId, sheetName);
  var headers = getHeaders(data);
  var today = getCurrentDate();
  
  var { summaries, urls, titles } = extractData(data, headers, today);
  
  var summaryText = formatSummaryText(summaries);
  var urlTitleText = formatUrlTitleText(urls, titles);
  
  var abstractPromptText = getAbstractPrompt();
  var finalAbstractText = replacePlaceholders(abstractPromptText, { summary: summaryText, today: today });
  
  var abstractResult = callClaude(finalAbstractText, "claude-3-5-sonnet-20240620");
  abstractResult = formatListText(abstractResult);
  
  var detailPromptText = getDetailPrompt();
  var finalDetailText = replacePlaceholders(detailPromptText, { news_list: summaryText, abstract: abstractResult });
  
  var summaryResult = callClaude(finalDetailText, "claude-3-5-sonnet-20240620");
  summaryResult = formatListText(summaryResult);
  
  writeResultToDailySummary(today, abstractResult, urlTitleText, summaryResult);
}


/**
 * スプレッドシートからデータを取得する。
 * @param {string} spreadsheetId - スプレッドシートのID
 * @param {string} sheetName - シートの名前
 * @return {Array<Array<string>>} スプレッドシートのデータ
 */
function getSpreadsheetData(spreadsheetId, sheetName) {
  var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(sheetName);
  return sheet.getDataRange().getValues();
}

/**
 * スプレッドシートのヘッダー行を取得する。
 * @param {Array<Array<string>>} data - スプレッドシートのデータ
 * @return {Array<string>} ヘッダー行
 */
function getHeaders(data) {
  return data[0];
}

/**
 * 「daily summary」シートに結果を書き込む
 * @param {string} date - 今日の日付
 * @param {string} abstract - abstractの内容
 * @param {string} links - linksの内容
 * @param {string} summary - summaryの内容
 */
function writeResultToDailySummary(date, abstract, links, summary) {
  var spreadsheetId = '<ここにシートID>';
  var sheetName = 'daily summary';
  var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(sheetName);
  
  if (sheet.getRange('A2').getValue() || sheet.getRange('B2').getValue()) {
    sheet.insertRowsBefore(2, 1);
  }
  
  sheet.getRange('A2').setValue(date);
  var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  
  var abstractColumnIndex = headers.indexOf('abstract') + 1;
  sheet.getRange(2, abstractColumnIndex).setValue(abstract);
  
  var linksColumnIndex = headers.indexOf('links') + 1;
  sheet.getRange(2, linksColumnIndex).setValue(links);
  
  var summaryColumnIndex = headers.indexOf('summary') + 1;
  sheet.getRange(2, summaryColumnIndex).setValue(summary);
}

/**
 * 今日の日付を取得する。
 * @return {string} 今日の日付(yyyy/MM/dd形式)
 */
function getCurrentDate() {
  var today = new Date();
  var year = today.getFullYear();
  var month = ('0' + (today.getMonth() + 1)).slice(-2);
  var day = ('0' + today.getDate()).slice(-2);
  return year + '/' + month + '/' + day;
}

/**
 * データを抽出する。
 * @param {Array<Array<string>>} data - スプレッドシートのデータ
 * @param {Array<string>} headers - ヘッダー行
 * @param {string} today - 今日の日付
 * @return {Object} 抽出されたデータ(summaries、urls、titles)
 */
function extractData(data, headers, today) {
  var dateColumnIndex = headers.indexOf('date');
  var summaryColumnIndex = headers.indexOf('summary');
  var sourceColumnIndex = headers.indexOf('source');
  var urlColumnIndex = headers.indexOf('URL');
  var titleColumnIndex = headers.indexOf('title');
  
  if (dateColumnIndex === -1 || summaryColumnIndex === -1 || sourceColumnIndex === -1 || urlColumnIndex === -1 || titleColumnIndex === -1) {
    Logger.log('必要なヘッダーが見つかりません。');
    return;
  }
  
  var summaries = [];
  var urls = [];
  var titles = [];
  
  for (var i = 1; i < data.length; i++) {
    var dateCell = data[i][dateColumnIndex];
    var formattedDate = Utilities.formatDate(new Date(dateCell), Session.getScriptTimeZone(), 'yyyy/MM/dd');
    var sourceCell = data[i][sourceColumnIndex];
    if (formattedDate === today && sourceCell === "hatena bookmark") {
      summaries.push(data[i][summaryColumnIndex]);
      urls.push(data[i][urlColumnIndex]);
      titles.push(data[i][titleColumnIndex]);
    }
  }
  
  return { summaries, urls, titles };
}

/**
 * summary列の値をフォーマットする。
 * @param {Array<string>} summaries - summary列の値
 * @return {string} フォーマットされたsummaryテキスト
 */
function formatSummaryText(summaries) {
  return summaries.join('\n\n---\n\n');
}

/**
 * URLとtitleをフォーマットする。
 * @param {Array<string>} urls - URLのリスト
 * @param {Array<string>} titles - タイトルのリスト
 * @return {string} フォーマットされたURLとタイトルのテキスト
 */
function formatUrlTitleText(urls, titles) {
  return '*:link: URLリスト*\n' + urls.map((url, index) => '・<' + url + '|' + titles[index] + '>').join('\n');
}

/**
 * 改行を調整してリスト間の改行を詰める関数
 * @param {string} text - フォーマット前のテキスト
 * @return {string} フォーマット後のテキスト
 */
function formatListText(text) {
  return text.replace(/(\n\s*・)/g, "\n・").replace(/^\s*・/, "・");
}

/**
 * テキストのプレースホルダーを置換する。
 * @param {string} text - テキスト
 * @param {Object} replacements - プレースホルダーと置換値のペア
 * @return {string} プレースホルダーが置換されたテキスト
 */
function replacePlaceholders(text, replacements) {
  for (var placeholder in replacements) {
    var value = replacements[placeholder];
    var regex = new RegExp('{' + placeholder + '}', 'g');
    text = text.replace(regex, value);
  }
  return text;
}

function getAbstractPrompt() {
  return `
複数のAIニュース記事の要約から、話題になっったトピックを箇条書きで書き出して下さい。
<ai_news_summaries>
{summary}
</ai_news_summaries>
### 手順
これらの要約を注意深く読み、以下の手順に従って分析してください:
1. 各要約に含まれる主要なトピックや出来事を特定します。
2. 影響力のあるトピックを優先順位付けします。
### 出力形式
出力は箇条書きのみで、前後に文章は含めないで下さい。
・[トピック1]
・[トピック2]
・[トピック3]
・[トピック4]
・[トピック5]
...
### 出力例
・「LinguaLink」AI、100言語リアルタイム同時通訳を実現
・AIスタートアップ Neurotech、脳波解析技術で1億ドルの資金調達に成功
・日本政府、AI開発に関する新規制法案を可決
・「ClimateOracle」AI、長期気候変動予測の精度を飛躍的に向上
・米国で自動運転AI搭載車の公道走行規制が緩和
`
}

function getDetailPrompt() {
  return `
あなたは今日ネットで話題になったニュース記事を要約する任務を担当します。以下の指示に従って、各ニュース記事の内容を簡潔にまとめてください。
まず、今日話題になったニュース記事の一覧を箇条書きで示します:
<news_list>
{news_list}
</news_list>
次に、これらのニュース記事に関する全体的なトピックリストを提供します:
<topics>
{abstract}
</topics>
### 手順
各ニュース記事について、以下の手順で要約してください:
1. トピックの構成に沿って要約を作成してください。
2. トピックよりも詳細なレベルで情報を提供してください。
3. 各要約は約100文字程度になるようにしてください。
4. 重要な事実、数字、引用などを含めて、記事の本質を捉えてください。
### 出力形式
出力は箇条書きのみで、前後に文章は含めないで下さい。
・[トピック1詳細説明]
・[トピック2詳細説明]
・[トピック3詳細説明]
・[トピック4詳細説明]
・[トピック5詳細説明]
...
### 出力例
1. 多言語コミュニケーション革命の幕開けか。AI企業TechLingua社が開発した「LinguaLink」AIが、100言語のリアルタイム同時通訳に成功。音声認識、自然言語処理、音声合成を統合し、0.5秒以下の遅延で高精度な通訳を実現。国際会議やグローバルビジネスでの活用が期待される一方、通訳者の雇用への影響も懸念されている。
2. 脳科学とAIの融合で次世代インターフェースの開発へ。AIスタートアップNeurotech社が脳波解析技術により1億ドルの資金調達を達成。同社の技術は、思考だけで機器を操作する「ブレイン・コンピューター・インターフェース」の実用化を目指す。医療やエンターテインメント分野での応用が期待されるが、プライバシーの問題も指摘されている。
3. AI開発の適正化へ向けて一歩前進。日本政府がAI開発に関する新規制法案を可決。AIの透明性、説明責任、公平性を確保するための指針を策定。個人情報保護やAI利用の倫理的側面に焦点を当て、産業の健全な発展と個人の権利保護の両立を目指す。法案は来年4月より施行予定だが、技術進歩に対する柔軟な対応が課題。
4. 気候変動対策に新たな光明。環境テック企業GreenAI社の「ClimateOracle」AIが、長期気候変動予測の精度を従来比50%向上させることに成功。膨大な気象データと地球科学モデルを統合し、複雑な気候システムをより正確に予測。政策立案者や企業の長期的な環境戦略に貢献すると期待されるが、予測結果の解釈には慎重な姿勢も求められる。
5. 自動運転技術の実用化が加速。米運輸省が自動運転AI搭載車の公道走行規制を緩和。一定の安全基準を満たせば、ステアリングやペダルのない完全自動運転車の公道走行が可能に。交通事故削減や移動弱者支援への期待が高まる一方、サイバーセキュリティや責任問題など、新たな課題への対応も必要とされている。
`
}


function callClaude(prompt, model) {
  var scriptProperties = PropertiesService.getScriptProperties();
  var apiKey = scriptProperties.getProperty('CLAUDE_API_KEY');
  var apiUrl = "https://api.anthropic.com/v1/messages";
  
  var headers = {
    "x-api-key": apiKey,
    "anthropic-version": "2023-06-01",
    "content-type": "application/json"
  };
  
  var data = {
    "model": model,
    "max_tokens": 4000,
    "messages": [
      {"role": "user", "content": prompt}
    ]
  };
  
  var options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(data)
  };
  
  var response = UrlFetchApp.fetch(apiUrl, options);
  var responseText = response.getContentText();
  
  // レスポンスをパースして content 配列内の text 部分を取得
  var responseJson = JSON.parse(responseText);
  var content = responseJson.content;
  var text = content[0].text;
  
  return text;
}

これをトリガーで毎日午後4-5時のあいだに実行するようにしています。

時間ベースのトリガー設定

実行されると以下のように「daily summary」シートにサマリーが出力されます。「date」は今日の日付、「abstract」はトピックの箇条書きで、「summary」はより詳細な要約が入っています。「links」はSlack用書式にフォーマットされたリンク集が入っています。

daily summaryシート

3. 今日のサマリーをSlackにポストする

全体像の⑤

ここでは、スプレッドシート上から今日のサマリーを抽出してSlackにポストします。チャンネルにまずトピックだけ投下して、スレに畳む形で詳細とリンク集を流しています。

チャンネルにはトピックを流す
スレッドには詳細を入れる

以下はZapierの設定です。

Zapier全体像
チャンネルトピックを投稿
スレッドにニュースの要約詳細版を書き込み
スレッドに記事リンク集を書き込み



▼noteの技術記事が読みたい方はこちら








この記事が気に入ったらサポートをしてみませんか?