【実践編3】GASとGemini API連携:ニュース自動要約ツールのコアロジック実装

GAS

事前準備(APIキーの取得およびGoogleドライブのフォルダ作成)が完了したため、Google Apps Script(GAS)を用いたツールのコアロジック実装を行う。

本記事では、単純なRSS(タイトルとURL)の取得にとどまらず、リンク先のHTML本文までスクレイピング(ディープフェッチ)してAIに解析させることで、より高精度な要約を生成する。さらに、Gemini APIのリトライ処理や出力済みファイルのアーカイブ機能を含む、実戦的なスクリプトを実装する。

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

APIキーやフォルダIDをソースコードにハードコーディングすることは、セキュリティおよび保守性の観点から避ける。GASの「スクリプトプロパティ」を利用し、環境変数として設定する。

  1. GASエディタの左側メニューの歯車アイコン(プロジェクトの設定)をクリックする。
  2. 「スクリプト プロパティを追加」をクリックし、以下の4つのキーと値を登録する。
    • GEMINI_API_KEY: 取得済みのAPIキー
    • DRIVE_FOLDER_ID: 最新のMarkdown保存用フォルダID
    • ARCHIVE_FOLDER_ID: 過去ファイルの退避用フォルダID
    • MODEL_NAME: gemini-2.5-flash (使用するモデル名)

Step 2: コアロジックの実装

指定したRSSフィードから最新記事を取得し、リンク先のHTML本文およびコードブロックを抽出した上で、Gemini APIで要約してドライブへ保存するスクリプトを記述する。

左側メニューから「エディタ(< >)」に戻り、コード.gs に以下のコードを実装する。 ※ソースコード内の TOPICS オブジェクトを編集することで、情報源やプロンプトのカスタマイズが可能。

/**
 * 共通設定(スクリプトプロパティから取得)
 */
const getAppConfig = () => {
  const props = PropertiesService.getScriptProperties();
  return {
    GEMINI_API_KEY: props.getProperty('GEMINI_API_KEY'),
    DRIVE_FOLDER_ID: props.getProperty('DRIVE_FOLDER_ID'),
    ARCHIVE_FOLDER_ID: props.getProperty('ARCHIVE_FOLDER_ID'),
    MODEL_NAME: props.getProperty('MODEL_NAME') 
  };
};

/**
 * トピック定義(取得元RSSと個別プロンプトの管理)
 */
const TOPICS = {
  TechNews: {
    label: 'テクノロジー最新動向',
    deepFetch: true, // リンク先の本文HTMLまで取得(スクレイピング)するかどうか
    promptExtra: `一般的な技術トレンドとして、開発者目線で分かりやすく解説してください。`,
    feeds: [
      // サンプルとして一般的なGoogleニュースのRSSを指定
      { name: "Google News: Technology", url: "https://news.google.com/rss/search?q=technology&hl=ja&gl=JP&ceid=JP:ja" }
    ]
  }
};

// トリガー実行用のエントリーポイント
function runTechNewsTopic() { runTopic('TechNews'); }

/**
 * メイン処理
 */
function runTopic(topicKey) {
  const config = getAppConfig();
  const topic = TOPICS[topicKey];

  if (!config.GEMINI_API_KEY || !config.DRIVE_FOLDER_ID) {
    console.error("エラー: 必須プロパティが設定されていません。");
    return;
  }

  const now = new Date();
  const yesterday = new Date(now.getTime() - (24 * 60 * 60 * 1000));
  
  // 1. RSSから過去24時間の記事を取得
  const newsItems = fetchRecentNews(topic.feeds, yesterday);
  if (newsItems.length === 0) return;

  // 2. 記事HTMLから本文とコードブロックを抽出(スクレイピング)
  newsItems.forEach(item => {
    const { text, codeBlocks, linkedText } = fetchArticleDeep(item.link, topic.deepFetch);
    if (text.length > item.description.length) item.description = text;
    item.codeBlocks = codeBlocks;
    item.linkedText = linkedText;
  });

  const rawContent = newsItems.map(item => `- [${item.title}](${item.link}) (${item.source})`).join('\n');

  // 3. Gemini APIで要約生成
  const summary = generateGeminiSummary(config, topic, newsItems);

  // 4. Markdownとして成形し保存
  const dateStr = Utilities.formatDate(now, "JST", "yyyy-MM-dd");
  const markdownBody = [
    `# ${topic.label} Daily Update (${dateStr})`,
    `\n## 📝 分析レポート`,
    summary,
    `\n## 🔗 参照元の記事一覧`,
    rawContent
  ].join('\n');

  saveToDrive(config, topicKey, markdownBody, dateStr);
}

/**
 * RSS/Atomフィードのパース処理
 */
function fetchRecentNews(feeds, sinceDate) {
  let allItems = [];
  feeds.forEach(feed => {
    try {
      const response = UrlFetchApp.fetch(feed.url);
      const xml = XmlService.parse(response.getContentText());
      const root = xml.getRootElement();
      const channel = root.getChild("channel");

      if (channel) { // RSS 2.0
        const items = channel.getChildren("item");
        items.forEach(item => {
          const pubDateStr = item.getChildText("pubDate");
          const pubDate = pubDateStr ? new Date(pubDateStr) : new Date();
          if (pubDate > sinceDate) {
            allItems.push({
              title: item.getChildText("title"),
              link: item.getChildText("link"),
              date: pubDate,
              source: feed.name,
              description: stripHtml(item.getChildText("description") || "")
            });
          }
        });
      }
    } catch (e) {
      console.error(`フィード取得エラー: ${e.toString()}`);
    }
  });
  return allItems;
}

/**
 * HTML本文抽出とディープフェッチ
 * ※サーバー負荷に配慮し、取得階層と件数を制限する紳士的な設計
 */
function fetchArticleDeep(url, deepFetch) {
  const result = { text: "", codeBlocks: [], linkedText: "" };
  try {
    const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    if (response.getResponseCode() !== 200) return result;
    const html = response.getContentText();

    // コードブロック(<pre><code>)の抽出
    const codePattern = /<pre[^>]*>\s*<code[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi;
    let m;
    while ((m = codePattern.exec(html)) !== null && result.codeBlocks.length < 5) {
      const code = m[1].replace(/<[^>]+>/g, '').trim();
      if (code.length > 30) result.codeBlocks.push(code);
    }

    // 本文の抽出
    const bodyMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i) || html.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
    const bodyHtml = bodyMatch ? bodyMatch[1] : html;
    result.text = stripHtml(bodyHtml).substring(0, 2000);

    // ディープフェッチ(詳細記事リンクの追跡)
    if (deepFetch) {
      const linkPattern = /href="(https?:\/\/[^"]+)"/gi;
      const followedTexts = [];
      let linkMatch;
      let followed = 0;

      while ((linkMatch = linkPattern.exec(bodyHtml)) !== null && followed < 2) {
        const absUrl = linkMatch[1];
        if (absUrl === url || !/(blog|article|news|post)/i.test(absUrl)) continue;
        
        try {
          const linked = UrlFetchApp.fetch(absUrl, { muteHttpExceptions: true });
          if (linked.getResponseCode() === 200) {
            const lMatch = linked.getContentText().match(/<article[^>]*>([\s\S]*?)<\/article>/i);
            if (lMatch) followedTexts.push(`[${absUrl}]\n` + stripHtml(lMatch[1]).substring(0, 800));
            followed++;
          }
        } catch (e) { /* 無視 */ }
      }
      result.linkedText = followedTexts.join('\n\n---\n\n');
    }
  } catch (e) {
    console.error(`記事取得エラー: ${e.toString()}`);
  }
  return result;
}

/**
 * HTMLタグ除去
 */
function stripHtml(html) {
  return html.replace(/<style[\s\S]*?<\/style>/gi, '')
             .replace(/<script[\s\S]*?<\/script>/gi, '')
             .replace(/<[^>]+>/g, ' ')
             .replace(/\s+/g, ' ').trim();
}

/**
 * Gemini APIへのリクエスト送信(リトライ処理込み)
 */
function generateGeminiSummary(config, topic, newsItems) {
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${config.MODEL_NAME}:generateContent?key=${config.GEMINI_API_KEY}`;

  const newsText = newsItems.map(item => {
    let block = `### ${item.title}\nURL: ${item.link}\n\n${item.description.substring(0, 1000)}`;
    if (item.linkedText) block += `\n[関連ページ]\n${item.linkedText}`;
    if (item.codeBlocks && item.codeBlocks.length > 0) block += `\n[コードサンプル]\n${item.codeBlocks.join('\n')}`;
    return block;
  }).join('\n\n---\n\n');

  const prompt = `あなたは優秀なITアナリストです。以下の記事を読み、日次分析レポートをマークダウンで作成してください。
${topic.promptExtra || ''}

【出力構成】
## 🌟 今日のキーポイント
(1行で要約)

#### [各記事のタイトル]
**何が起きたか**(1〜2文)
**実務的インパクト**(箇条書き)

【ニュース記事】
${newsText}`;

  const payload = { contents: [{ parts: [{ text: prompt }] }] };
  const options = { method: "post", contentType: "application/json", payload: JSON.stringify(payload), muteHttpExceptions: true };

  // APIのレート制限等を考慮したリトライ処理(Exponential Backoff)
  let response;
  for (let i = 0; i < 3; i++) {
    response = UrlFetchApp.fetch(endpoint, options);
    if (response.getResponseCode() === 200) break;
    Utilities.sleep(Math.pow(2, i) * 1000);
  }

  if (response.getResponseCode() !== 200) return "要約の生成に失敗しました。";
  return JSON.parse(response.getContentText()).candidates[0].content.parts[0].text;
}

/**
 * Googleドライブへの保存とアーカイブ
 */
function saveToDrive(config, topicKey, content, dateStr) {
  try {
    const folder = DriveApp.getFolderById(config.DRIVE_FOLDER_ID);
    const filePrefix = `AI_Daily_Report_${topicKey}_`;

    // 同トピックの既存ファイルのみをアーカイブフォルダへ移動
    if (config.ARCHIVE_FOLDER_ID) {
      const archiveFolder = DriveApp.getFolderById(config.ARCHIVE_FOLDER_ID);
      const existingFiles = folder.getFilesByType(MimeType.PLAIN_TEXT);
      while (existingFiles.hasNext()) {
        const file = existingFiles.next();
        if (file.getName().startsWith(filePrefix)) file.moveTo(archiveFolder);
      }
    }

    // 新規作成
    folder.createFile(`${filePrefix}${dateStr}.md`, content, MimeType.PLAIN_TEXT);
  } catch (e) {
    console.error(`ドライブ保存エラー: ${e.toString()}`);
  }
}

Step 3: 実行と定期実行(トリガー)の設定

コードの実装後、動作確認と定期実行の設定を行う。

  1. エディタ上部の関数選択で runTechNewsTopic を選択し、「実行」をクリックする。
  2. 初回のみ承認ダイアログが表示されるため、権限を許可する。
  3. 実行完了後、GoogleドライブにMarkdownファイルが生成されていることを確認する。
  4. 自動化の設定: 左側メニューの時計アイコン(トリガー)をクリックし、「トリガーを追加」を選択する。
    • 実行する関数: runTechNewsTopic
    • イベントのソース: 時間主導型
    • トリガーのタイプ: 日付ベースのタイマー
    • 時刻: 任意の時間帯(例: 午前8時~9時)を指定して保存する。

技術メモ・今後の拡張について

今回の実装ではRSSフィードから「タイトル」と「URL」のみを取得してGeminiに渡している。より精度の高い要約が必要な場合は、UrlFetchApp と正規表現等を用いてリンク先のHTML本文をスクレイピングする処理の追加を検討する。 プロンプトチューニングについては、出力されたMarkdownの精度を確認しながら随時最適化を行う。

また、GASエディタの右上に「デプロイ」ボタンが存在するが、今回の自動要約システムの実装においてデプロイ作業は不要である。

デプロイ機能は、作成したスクリプトを不特定多数がアクセスできる「Webアプリ」として公開する場合や、外部システムから呼び出せる「実行可能API」として提供する場合に利用する。

本ツールのように、GASエディタ上からの手動実行、または「時間主導型のトリガー」によるバックグラウンド実行のみを目的とするスクリプトの場合、エディタ上でコードを保存(Ctrl+S または Cmd+S)するだけで最新の状態が反映され、正常に動作する

次回予告:clasp × VS Code × GitHub CopilotによるAI駆動コーディング

ここまでGASのブラウザエディタ上でコードを実装してきたが、今後の運用において「新しい情報源(RSS)を追加したい」「プロンプトを自分好みにチューニングしたい」といった改修が必ず発生する。

しかし、GASの標準エディタは生成AIのコーディング支援を直接受けることができず、保守性が高いとは言えない。

そこで次回の【実践編4】では、Google製のCLIツール「clasp」を導入する。GASのコードをローカル環境(VS Code)に引き込み、GitHub Copilotの強力なAI支援を受けながら、今回のコードを爆速でチューニング・拡張していく「モダンなGAS開発フロー」を解説する。

タイトルとURLをコピーしました