🐦

esa 記事をスマートチップで補完する Google Docs アドオンの作り方

2023/11/24に公開

はじめに

Google ドキュメント や Google スプレッドシート の機能の一つであるスマートチップをご存知でしょうか?
@ の入力からユーザー、ファイル、日付、予定などをサジェスト&入力する機能です。
Google ドキュメントにスマートチップと構成要素を挿入する - Google ドキュメント エディタ ヘルプ

このスマートチップは原則Google Workspace系のデータしか扱えないのですが、GASのアドオンとして実装することで、デフォルトで用意されたユーザーやファイル以外にも任意のものを対象としてスマートチップに変換することが可能になります。
※ 23年11月時点ではGoogel ドキュメントだけが利用可能ですが、デベロッパープログラムに参加することスプレッドシートやスライド上でリンクテキストにはできますが、スマートチップにはできません

本記事ではドキュメントサービスの https://esa.io/ の記事をスマートチップ化するGoogle Docsのアドオンを作ったのでそれを解説します。

前提知識

GASの基本的な書き方や仕組みは理解しているのを前提としています。

また、アドオンの配布の仕方については下記記事を参考にしてください。
本記事の内容を実践する場合は、まずこちらから試してからが良いかと思います。

https://zenn.dev/howdy39/articles/e3bbf1c3e363a0

実際に作るもの

スマートチップへの変換方法は2パターンあり、それぞれ実行したのが上記のgifです。

1つめの変換が

  1. esaのリンクを貼る
  2. TABキーでスマートチップに変換

2つめの変換が

  1. 既存のesaリンクにカーソルを当てる
  2. チップ を押してリンクをスマートチップに変換

システム解説

コード自体は設定ファイルを含めても200行程度なので大したことはないのですが、システム自体は少し特殊なので解説しておきます。

全体の流れ

スマートチップを呼び出すパターンとしては、最初の実行時と記事のキャッシュ有無で3つの呼び出し方があります。
それぞれ解説していきます。

初回のみ一度

初回実行時はGASが認可を求めてきます。GASを利用したことがある人なら違和感はないかと思います。
これはGASがスマートチップへアクセスするためだったり、スプレッドシートへの書き込みだったり、外部のAPIを叩いたりするために権限が必要だからです。

GASが認可を求めている、ということはスマートチップのGASを実行するアカウントはアドオンの作成者のアカウントではなく、スマートチップを呼び出しているアカウントになります。
そのためアドオン作成者が別途サーバーを用意したり、GASの日毎の制限を気にしたりする必要は基本的にありません。

キャッシュに存在しない

なぜキャッシュが必要なのかについては次のセクションで解説します。
スマートチップ化したいURLの情報(記事番号)を元にesaの記事取得APIを叩いて記事タイトルや内容を取得します。APIで取得した記事内容をもとにスマートチップ化する感じですね。

https://docs.esa.io/posts/102#GET /v1/teams/:team_name/posts/:post_number

キャッシュに存在する

キャッシュが必要な理由ですが、esaのAPIは ユーザ毎に15分間に75リクエストの制限 があるためです。
ここで言うユーザはアクセストークンを発行したユーザーのため、システムアカウント用のユーザのトークンを使って記事情報を取得する形になります。
つまり、アドオン化したとしても同じユーザでAPIを叩くため、アドオンを使って都度APIを叩いていると制限に掛かる可能性が高いのです。

キャッシュはスプレッドシートに下記の形で1記事1行で保存しています。
取得日時が◯日前(本記事執筆時点では7日前)より後のものをキャッシュありデータと判定しているので、同じ記事でも複数行存在します。

GASのキャッシュは Cache Serviceを使うことをまず考えますが、キャッシュ時間が6時間が最大なので、本用途には少し短いなと思いスプレッドシートを使ったキャッシュを選択しました。
※ 記事のタイトルはそうそう変わるものではないので6時間は短いと判断

実装

下記4ファイルをスタントアロンスクリプトで実装します。
esaアイコンのURLとesaのURLのドメイン部などは自分の環境に合ったものに適宜書き換えてください。

  • appsscript.json
    GASの設定ファイル
  • code.gs
    メインのコードです、ここから各種別ファイルのコードを呼び出しています
  • EsaService.gs
    EsaのAPIを叩くサービスクラス
  • SheetsService.js
    スプレッドシートとのやりとりを行うサービスクラス

appsscript.json

appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "exceptionLogging": "STACKDRIVER",
  "oauthScopes": ["https://www.googleapis.com/auth/workspace.linkpreview", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/spreadsheets"],
  "urlFetchWhitelist": ["https://api.esa.io/"],
  "runtimeVersion": "V8",
  "addOns": {
    "common": {
      "name": "esa-smartchips",
      "logoUrl": "https://esaのicon.jpg",
      "layoutProperties": {
        "primaryColor": "#469993"
      }
    },
    "docs": {
      "linkPreviewTriggers": [{
        "patterns": [{
          "hostPattern": "hoge.esa.io",
          "pathPrefix": "posts"
        }],
        "runFunction": "caseLinkPreview",
        "labelText": "記事タイトル",
        "logoUrl": "https://esaのicon.jpg"
      }]
    }
  }
}

スマートチップ関連のポイントとしては下記の3点かなと思います。

  • oauthScopes"https://www.googleapis.com/auth/workspace.linkpreview" をつける
  • patterns にスマートチップ対象とするURLのパターンを入れる
  • runFunction にスマートチップ処理で実行するFunction名を設定する

addOns の書き方は下記ドキュメントを参照して入力しましょう。
https://developers.google.com/workspace/add-ons/guides/preview-links-smart-chips?hl=ja#set-up-link-previews

code.gs

code.gs
function caseLinkPreview(event) {
  try {
    console.log({ event });

    const url = event.docs.matchedUrl.url || event.sheets.matchedUrl.url;

    if (url) {
      const post_number = Number(url.split(/\D+/)[1]);
      console.log({ post_number });

      let post = SheetsService.findPost({ post_number });
      console.log({ post });

      if (!post) {
        post = EsaService.getEsaPost({ post_number });
        console.log({ post });

        SheetsService.addPost({
          number: post_number,
          title: post.タイトル,
          full_title: post.完全タイトル,
          text: post.本文,
        });
      }

      return CardService.newCardBuilder()
        .setHeader(
          CardService.newCardHeader()
            .setTitle(post.タイトル)
            .setImageUrl(
              'https://esaのicon.jpg'
            )
        )
        .addSection(
          CardService.newCardSection()
            .setHeader('CATEGORY')
            .addWidget(
              CardService.newDecoratedText()
                .setStartIcon(CardService.newIconImage().setIcon(CardService.Icon.BOOKMARK))
                .setText(post.完全タイトル)
                .setWrapText(true)
            )
        )
        .addSection(
          CardService.newCardSection()
            .setHeader('BODY')
            .addWidget(
              CardService.newDecoratedText()
                .setStartIcon(CardService.newIconImage().setIcon(CardService.Icon.DESCRIPTION))
                .setText(post.本文)
                .setWrapText(true)
            )
        )
        .build();
    }
  } catch (e) {
    console.error({ e });

    let message = e.message;
    if (message.includes('404')) {
      message = '記事が見つかりませんでした、URLを確認してください';
    }

    return CardService.newCardBuilder()
      .addSection(
        CardService.newCardSection().addWidget(CardService.newTextParagraph().setText(message))
      )
      .build();
  }
}

スマートチップ関連のポイントとしては CardService.newCardBuilder になるかなと思います。

setHeaderaddSection を駆使してカードのUIを作っていく感じですね。

CardService の書き方は下記ドキュメントを参照して入力しましょう。
https://developers.google.com/apps-script/reference/card-service?hl=ja

EsaService.gs

EsaService.gs
// eslint-disable-next-line no-redeclare
const ESA_TOKEN = PropertiesService.getScriptProperties().getProperty('ESA_TOKEN');

// eslint-disable-next-line
class EsaService {
  static getEsaPost({ post_number }) {
    // @see https://docs.esa.io/posts/102#GET%20/v1/teams/:team_name/posts
    const url = `https://api.esa.io/v1/teams/hoge/posts/${post_number}`;

    const options = {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${ESA_TOKEN}`,
        'Content-type': 'application/json',
      },
    };

    const response = UrlFetchApp.fetch(url, options);
    const content = JSON.parse(response.getContentText('UTF-8'));

    let title = `#${post_number}: ${content.name}`;
    if (content.name === 'README') {
      title = `#${post_number}: ${content.full_name}`;
    }

    const result = {
      番号: post_number,
      タイトル: title,
      完全タイトル: content.full_name,
      本文: content.body_md
        .replace(/^\r\n/, '')
        .replace(/[\r\n]+/g, '\n')
        .slice(0, 80),
    };

    return result;
  }
}

スマートチップ関連コードだと本文を変換している部分ぐらいでしょうか。
マークダウンで改行だけのコードは潰したり文字数を削ったりしています。

SheetsService.js

SheetsService.js
const CACHE_SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('CACHE_SPREADSHEET_ID');
const ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID);
const sheet = ss.getSheetByName('preview_logs');

class SheetsService {
  static findPost({ post_number }) {
    let values = sheet.getDataRange().getValues();
    const allPosts = this.convertSpreadSheetTable(values);

    const index = allPosts.findIndex((post) => {
      if (post.番号 !== post_number) {
        return false;
      }

      const oldCacheDate = new Date();
      oldCacheDate.setDate(oldCacheDate.getDate() - 7);
      return post.取得日時 > oldCacheDate;
    });
    if (!index) {
      return null;
    } else {
      return allPosts[index];
    }
  }

  static addPost({ number, title, full_title, text }) {
    const writeValues = [new Date(), number, title, full_title, text];
    const range = sheet.getDataRange();
    const startRow = range.getLastRow();
    sheet.getRange(startRow + 1, 1, 1, writeValues.length).setValues([writeValues]);
  }

  static convertSpreadSheetTable(ssValues) {
    const results = [];
    const [headers, ...rows] = ssValues;

    rows.forEach((row) => {
      const obj = {};
      headers.forEach((header, i) => {
        obj[header] = row[i];
      });
      results.push(obj);
    });
    return results;
  }
}

スマートチップ関連のコードは特にありません。
convertSpreadSheetTable 関数はヘッダー行の名称でObjectのプロパティに変換する関数です。
この変換関数をかませることでスプレッドシート上で列の順番を変えてもそのまま動くようにしています。

終わりに

長らくGoogleドキュメントは大きな変更がなかったのですが、スマートチップが出てきたことでNotionのようなリッチなエディタとしての機能が強化されているように思えます。

デベロッパープログラムでの提供となりますが、@メニューから任意の処理を起動できるような動きもあります。
Duet AI for Google Workspaceによる要約機能など、今後のGoogle ドキュメントの進化が楽しみですね。

https://twitter.com/howdy39/status/1727119056371933547

参考記事

スマートチップ開発についての解説記事
https://developers.google.com/workspace/add-ons/guides/preview-links-smart-chips?hl=ja#apps-script

GASを実行環境としてGoogle Workspace アドオンを作る際の解説記事
https://developers.google.com/apps-script/add-ons/cats-quickstart?hl=ja

GAS以外を実行環境としてGoogle Workspace アドオンを作る際の解説記事
https://developers.google.com/workspace/add-ons/quickstart/alternate-runtimes?hl=ja

CasheServiceの書き方
https://developers.google.com/apps-script/reference/card-service?hl=ja

GASをGoogleWorkspaceアドオンにして配布する方法
https://zenn.dev/howdy39/articles/e3bbf1c3e363a0

Discussion