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?

#189 Workspace Studio + GASでブログ記事をGoogle Chatに自動通知してみた

0
Posted at

はじめに

2025年12月にGoogle Workspace Studioの正式バージョンがリリースされました。
様々な業務フローを自動化することを目的に作られた機能ですが、中でもGeminiをフローに組み込むことでより幅広い自動化が可能になっているようです。
そこで今回は、機能把握のために弊社ブログ記事をランダムに取得し、グループチャットに通知してくれる一連のフローを作成してみましたので共有します。

全体像

今回作成するフローの全体は以下のようになります。

-- Workspace Studio --
定期実行
↓
Geminiへランダム取得の指示
↓
スプレッドシートへ出力

-- GAS --
定期実行
↓
Workspace Studio で書き込まれた内容をWebhookで通知

個人への通知のみで完結させる場合にはGASの設定は不要になりますが、今回は既存グループへの通知を行いたかったため、スプレッドシートをDBのように使用し、Webhookで通知するような構成になっています。

フローの構築

新規フローの作成

最初に Workspace Studio へアクセスし、以下画像の赤枠を押下します。

ワークフローの作成指示画像

Starterの設定

作成されると、 StarterActions からなる画面になるので、 Starter を設定します。
起動トリガーはチャットをトリガーにしたりなど様々ありますが、今回は以下の画像のようにスケジュールで設定します。

Starterの設定

赤枠の部分を選択すると、いつ起動し繰り返すかなどの設定項目が出てくるのでお好みの時間に設定します。

Geminiの設定

次に今回の肝であるGeminiの設定を行います。
先ほどは Starter を設定しましたが、 Actions 内のStepを押下し、以下画像赤枠のGeminiを選択します。

Geminiの選択

選択するとプロンプトの設定などが可能な画面が表示されます。


今回、プロンプトは以下のものを使用します。

記事を5件取得してください。

【取得元】
- https://www.nxted.co.jp/blog/blog_list に掲載されている記事からランダムに選択すること

【対象】
- ソフトウェアエンジニア向けの技術ブログ記事のみ

【選定基準】
- 技術的な内容(実装・解説・検証)
- エンジニアにとって有益な内容

【除外】
- 生産性向上
- 仕事術
- ビジネス系
- 自己啓発

【取得ルール】
- 上記URL内の一覧ページから記事をランダムに選択すること
- 必ず実在する記事のみ使用すること(推測・架空は禁止)
- URLは必ず記事詳細ページ(/blog/blog_detail?id=xxx 形式)を使用すること

【出力形式】
以下のフォーマットを厳密に守ること:

■記事
タイトル:
URL:
カテゴリ:
概要:

【フォーマット厳守ルール】
- 「■記事N」から開始すること
- 各項目は必ず1行で書くこと
- ラベル(タイトル: など)は完全一致させること
- 項目の順番を変更しないこと
- 空行は記事ごとに1行のみ許可
- 文章の途中で改行しないこと

この後GASを使用する都合上、本来であれば出力フォーマットはJSONなどのParseしやすい形にしたいところですが、JSONなどの出力にフォーマットさせてしまうと、URLが削除されてしまうという現象が頻発しました。

よって、最低限Parseできる構造を保ったまま、自然な形で出力させるようなプロンプト構造にしています。


次に、以下画像の赤枠のように「All Source・検索を許可」という形で設定します。

Geminiの設定

※ 今回は検索が主なので Workspace は使いませんが、用途によって使い分けてみてください。

スプレッドシートへの出力

ここまで設定したら、スプレッドシートへの出力をしてWorkspace Studio側の設定は終了です。

Add Step をクリックし、以下画像の赤枠を選択します。

Sheetsを選択

すると、対象のスプレッドシートを選択する項目があるので結果を書き込む対象のスプレッドシートを作成・選択してください。

※ 作成したスプレッドシートの A1 にヘッダーとなる値を入力しておいてください


上記が正しく設定できていれば、「何を書き込むか」という設定ができるようになるので、以下画像のように Step 2: Ask Gemini を選択し、Step2で取得した結果を書き込むように設定してください。

Sheetsの設定

フローの動作確認

以上がWorkspace Studio側の設定です。

画面下部の Test run > Start の順でフローのテスト実行が可能なので、実行結果が選択したスプレッドシートへ出力されているかを確認します。

フローの動作確認結果

GASの構築

Google Apps Script にアクセスし、 新しいプロジェクト を作成します。

GASの新規プロジェクト作成

プロジェクトが作成されたら、デフォルトで用意されている コード.gs を削除し、以下の5ファイルを作成してコードをそれぞれ貼り付けます。

config.js

接続先WebhookのURLと、スプレッドシートの設定をまとめたファイルです。

/** 接続先・スプレッドシートの設定 */
const CONFIG = {
  WEBHOOK_URL:
    "https://chat.googleapis.com/v1/spaces/XXXXXXXXXX/messages?key=XXXXXXXXXX&token=XXXXXXXXXX",
  SHEET: {
    ID: "スプレッドシートのID",
    NAME: "シート1",
    COLUMN: "A", // 記事テキストが格納されている列
  },
};

WEBHOOK_URL はGoogle ChatのスペースでWebhookを設定すると発行されます。
また、SHEET.ID はスプレッドシートのURLの /d//edit の間の文字列です。

それぞれ取得して設定してください。

sheet.js

スプレッドシートから記事テキストを取得し、パースして記事オブジェクトの配列を返すファイルです。

/**
 * スプレッドシートの指定列の最終行からテキストを取得し、記事配列にパースして返す。
 * データは常に最終行の1セルにまとめて格納されている前提。
 */
function loadArticlesFromSheet() {
  const spreadSheet = SpreadsheetApp.openById(CONFIG.SHEET.ID);
  const sheet = spreadSheet.getSheetByName(CONFIG.SHEET.NAME);

  const column = CONFIG.SHEET.COLUMN;
  // 空白でないセルの数 = 最終行のインデックス
  const lastRow = sheet
    .getRange(`${column}1:${column}`)
    .getValues()
    .filter(String).length;

  const rawText = sheet.getRange(`${column}${lastRow}`).getValue();
  return parseArticles(String(rawText));
}

/**
 * 「■記事N」区切りの複数記事テキストを記事オブジェクトの配列に変換する。
 * タイトルが取得できなかったブロックは除外する。
 */
function parseArticles(text) {
  const blocks = text.split(/■記事\d+/).filter((b) => b.trim().length > 0);
  return blocks.map(parseArticleBlock).filter((a) => a.title);
}

/**
 * 1記事分のテキストブロックをパースして { title, category, summary, url } を返す。
 * 各行は「キー: 値」形式。URL はコロンを含むため先頭一致で特別処理する。
 */
function parseArticleBlock(block) {
  const article = {};

  for (const line of block.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed) continue;

    // URL行は特別処理(値にコロンが含まれるため最初のコロンで分割できない)
    if (trimmed.startsWith("URL:")) {
      article.url = trimmed.substring(4).trim();
      continue;
    }

    const colonIdx = trimmed.indexOf(":");
    if (colonIdx === -1) continue;

    const key = trimmed.substring(0, colonIdx).trim();
    const value = trimmed.substring(colonIdx + 1).trim();

    switch (key) {
      case "タイトル":
        article.title = value;
        break;
      case "カテゴリ":
        article.category = value;
        break;
      case "概要":
        article.summary = value;
        break;
    }
  }

  // GoogleリダイレクトURLが含まれる場合は実URLを抽出する
  if (article.url) {
    article.url = extractRealUrl(article.url);
  }

  return article;
}

/**
 * GoogleリダイレクトURL (https://www.google.com/url?q=...) から
 * 実際のURLを抽出する。通常のURLはそのまま返す。
 */
function extractRealUrl(url) {
  const match = url.match(/[?&]q=([^&]+)/);
  if (!match) return url;
  try {
    return decodeURIComponent(match[1]);
  } catch (e) {
    return match[1];
  }
}

最終行の取得について

loadArticlesFromSheet では、A列全体の値を取得し、最終行のインデックスを求めています。

Workspace Studio がスプレッドシートへ書き込む際は常に新しい行に追記する形式のため、「最終行 = 最新の実行結果」として扱っています。


パース処理について

Geminiの出力テキストは以下のような構造になっています。

■記事1
タイトル: ...
URL: ...
カテゴリ: ...
概要: ...

■記事2
...

parseArticles ではこの ■記事N を正規表現で記事ごとのテキストブロックに分割します。

その後 parseArticleBlock で各行を キー: 値 形式として読み込み、オブジェクトに変換しています。


URLだけは https://... のようにコロンを値の中に含むため、先頭が URL: で始まる行は substring(4) で特別処理しています。他の項目は最初の : の位置をインデックスで探して分割しています。


また、parseArticles の末尾の .filter((a) => a.title) によって、タイトルが取得できなかった不完全なブロック(パース失敗やGeminiの出力ゆれによるもの)を除外しています。


GoogleリダイレクトURLの処理について

Geminiが検索結果を出力する際、https://www.google.com/url?q=実際のURL&... というリダイレクト形式のURLになることがあります。

このままではカードのリンクが意図した記事へ遷移しないため、extractRealUrl でクエリパラメータ q= の値を正規表現で取り出し、decodeURIComponent でパーセントエンコードをデコードして実際のURLを復元しています。

webhook.js

エントリーポイントとなる関数と、Google Chat WebhookへのPOSTを担うファイルです。

/**
 * エントリーポイント。
 * スプレッドシートから記事を読み込み、Google Chat に Card V2 形式で投稿する。
 */
function postArticlesToGoogleChat() {
  const articles = loadArticlesFromSheet();
  Logger.log(articles);

  const payload = buildCardPayload(articles);
  postToWebhook(payload);
}

/**
 * Google Chat Webhook に JSON ペイロードを POST する。
 * レスポンスコードが 200 以外の場合はエラー内容をログに記録する。
 */
function postToWebhook(payload) {
  const options = {
    method: "post",
    contentType: "application/json; charset=UTF-8",
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  const response = UrlFetchApp.fetch(CONFIG.WEBHOOK_URL, options);
  const code = response.getResponseCode();
  const body = response.getContentText();

  if (code === 200) {
    Logger.log("投稿成功!");
  } else {
    Logger.log(`投稿失敗 (HTTP ${code}): ${body}`);
  }
}

postArticlesToGoogleChat がトリガーに登録するエントリーポイントで、sheet.js・card.js の処理を呼び出してまとめています。

card.js

Google Chat の Card V2 形式のペイロードを組み立てるファイルです。

/**
 * 記事配列から Google Chat 用の cardsV2 ペイロードを組み立てる。
 * 各記事をセクションとして並べ、セクション間に区切り線を挿入する。
 */
function buildCardPayload(articles) {
  const sections = intersperseDividers(articles.map(buildArticleSection));

  return {
    cardsV2: [
      {
        cardId: "tech-articles-digest",
        card: {
          header: { title: "NXTED - 本日のランダムブログ" },
          sections,
        },
      },
    ],
  };
}

/**
 * 1記事分の Card セクション(ウィジェット群)を生成する。
 * URLが有効な場合のみ「記事を読む」ボタンを末尾に追加する。
 */
function buildArticleSection(article) {
  const widgets = [
    {
      decoratedText: {
        topLabel: article.category || "General",
        text: `<b>${escapeHtml(article.title)}</b>`,
        wrapText: true,
      },
    },
    {
      textParagraph: {
        text: escapeHtml(article.summary || ""),
      },
    },
  ];

  if (isValidUrl(article.url)) {
    widgets.push({
      buttonList: {
        buttons: [
          {
            text: "記事を読む",
            onClick: { openLink: { url: article.url } },
          },
        ],
      },
    });
  }

  return { widgets };
}

/** セクションの間に区切り線セクションを挿入する */
function intersperseDividers(sections) {
  const divider = { widgets: [{ divider: {} }] };
  return sections.flatMap((section, i) =>
    i < sections.length - 1 ? [section, divider] : [section],
  );
}

Google Chat の Card V2 はウィジェットをセクション単位で並べる構造です。今回は1記事を1セクションとし、以下の3種類のウィジェットで構成しています。

  • decoratedTexttopLabel にカテゴリ、text に太字のタイトルを表示
  • textParagraph:概要文を表示
  • buttonList:「記事を読む」ボタンを配置。URLが有効な場合のみ追加

また、記事間の区切り線は intersperseDividers で挿入しています。

utils.js

汎用ユーティリティ関数をまとめたファイルです。

/** HTML 特殊文字をエスケープする(XSS 対策) */
function escapeHtml(str) {
  if (!str) return "";
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

/** http(s) で始まる文字列のみ有効な URL とみなす */
function isValidUrl(url) {
  if (!url) return false;
  return /^https?:\/\//.test(url);
}

トリガーの設定

GASのエディタ画面左のメニューから トリガー を選択し、以下のように設定します。

  • 実行する関数:postArticlesToGoogleChat
  • イベントのソース:時間主導型
  • 時間ベースのトリガーのタイプ:日付ベースのタイマー
  • 時刻:お好みの時刻を設定

以上でGAS側の設定も完了です。

設定が正しくできていれば、指定した時刻にGoogle Chatへ以下のような通知が届きます。

Google Chatへの通知結果

おわりに

今回はWorkspace StudioとGASを組み合わせて、ブログ記事をランダムに取得しGoogle Chatへ通知するフローを構築しました。
GeminiのWeb検索機能をフローに組み込むことで、外部APIやスクレイピングを用意しなくても動的なデータ取得が実現できる点が印象的でした。


一方で、Geminiの出力は毎回完全に同じフォーマットになるとは限らないため、パース処理やプロンプト設計には工夫が必要なのと、Gemini自体の精度があまり良くなく複雑な検索などを行う場合には期待した結果にならないことが多かったイメージでした。


まだリリースされたばかりということもあり、機能やGeminiの回答精度に物足りなさは感じるところではありますが、これからのアップデート次第では様々なフローを構築することができそうで楽しみです。


長くなりましたが、ここまで読んでいただきありがとうございました。

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?