👀

GASを使うと社内だけで使えるWebサイトが簡単に作れるの知ってました?

に公開

はじめに

2025年11月現在、Geminiを使用してGoogle Slidesを作成する「まじん式」プロンプトが話題です。
https://note.com/majin_108/n/n11fc2f2190e9
こちらの記事で初めて知りましたが、どうやらGoogle Apps Script(GAS)を使うと、社内限定で使えるWebアプリケーションを簡単に構築できるそうです。
GAS自体は使ったことはありますが、Webアプリケーションを作ったことはありませんでした。
本記事では、実践的なサンプルプロジェクトとしてGoogleDriveにあるファイルを検索するアプリケーション(Drive検索ビューア)を構築していきます。
開発にはclaspとTypeScriptを使用します。claspはCommand Line Apps Script Projectsの略称で、Apps Script プロジェクトをローカルで開発することができるOSSです。
https://github.com/google/clasp
また、従来のGASエディタではなく、使い慣れたVS Codeで開発を進めていきます。
GASエディタでは困難だったClaude CodeなどのAIエージェントを組み合わせることで、モダンな開発環境でGAS製のWebアプリケーションを作成していきましょう。

この記事の対象読者

  • GASは使ったことがあるが、claspは未経験の方
  • 社内ツール開発の選択肢を広げたい方

構築するアプリの概要

今回構築する「Drive検索ビューア」は、Google Drive内のファイルをキーワード検索し、結果を一覧表示するシンプルなWebアプリです。
このアプリの特徴は、社内ドメイン限定で公開できる点です。Google Workspaceを利用している企業であれば、追加のインフラ構築なしで社内向けのWebツールを提供できます。

本プロジェクトでは以下のサンプルリポジトリを元に作成しています。
https://github.com/cristobalgvera/ez-clasp

サンプルプロジェクトを作成後、少し設定をいじった状態のbranchがこちらです。
https://github.com/Suntory-Y-Water/clasp-gas-webapp-demo/tree/init

作成したすべてのコードはこちらです。
https://github.com/Suntory-Y-Water/clasp-gas-webapp-demo

なぜclaspを使うのか

従来のGASエディタでも開発は可能です。しかし、claspを使うことで以下のメリットがあります。

1つ目は使い慣れた開発環境で開発ができるという点です。いつも使っているVS Codeなどのエディタで開発できるため、拡張機能やキーボードショートカットをそのまま活用できます。
GASは手軽に開発が可能なのが長所だと思っていますが、逆に言えばエディタが普段使わないもので開発するため、普段から開発しているエンジニアにとっては少しとっつきづらい印象です…
(余談ですが、この現象はExcel VBAなどにもいえるかと思います)
特に2025年11月現在ではClaude CodeなどのAIエージェントを用いた開発が主流となっているため、生成AIと共に開発を行うことは生産性向上には欠かせないです。

2つ目はTypeScriptによる型安全性が担保されている点です。@types/google-apps-scriptにより、DriveAppHtmlServiceなどのAPIに対して型補完が効きます。APIの使い方を調べる時間が削減され、コンパイル時に型エラーを検出できます。
JavaScript(ないしはGAS)でも開発は可能ですが、やはり型があることによる恩恵は言うまでもないでしょう。

開発環境のセットアップ

テンプレートプロジェクトの準備

先述した通り、EZ CLASPというテンプレートを使用します。このテンプレートは、TypeScriptの型補完、自動テスト、コードフォーマットなど、モダンな開発環境が整っています。

# テンプレートを使用してプロジェクト作成
pnpx tiged cristobalgvera/ez-clasp clasp-gas-webapp-demo
cd clasp-gas-webapp-demo

# Git初期化とパッケージインストール
git init
pnpm install

# claspにログイン
pnpm clasp:login

pnpm clasp:loginを実行すると、ブラウザが開いてGoogleアカウントの認証を求められます。認証を完了すると、ローカル環境からGASプロジェクトを操作できるようになります。
認証後の情報は~/.clasprc.jsonに保存されます(誤ってもgit管理はしないように)。

GASプロジェクトの作成

claspコマンドでWebアプリ用のGASプロジェクトを作成します。

pnpm clasp:create

スクリプトの種類を選択するプロンプトが表示されるため、「webapp」を選択してください。
ここで表示されているURLは基本的に作成したユーザーしかコードの編集ができないため、公開しても問題ありません。

pnpm clasp:create

> clasp-gas-webapp-demo@0.0.5 clasp:create /Users/{user_name}/dev/clasp-gas-webapp-demo
> rimraf .clasp.json && clasp create --rootDir . --title clasp-gas-webapp-demo && pnpm lint --write .clasp.json

? Create which script? webapp
Created new webapp script: https://script.google.com/d/1HmYpY4QQuW2zyVvM9VpgduyQqZVTkQ4WmkLWF1oiEHrxg91NO-LgCE2Q/edit

> clasp-gas-webapp-demo@0.0.5 lint /Users/{user_name}/dev/clasp-gas-webapp-demo
> biome check --write .clasp.json

Checked 1 file in 947µs. Fixed 1 file.

Webアプリの実装

Drive検索サービスの実装

まず、Drive検索機能をsrc/features/drive-search/ディレクトリに作成します。
必要な型定義や実装を追加していきます。
今回の実装はテンプレートリポジトリの実装手法を真似て作成しています。

src/features/drive-search/drive-search.type.ts
/**
 * Drive検索結果の単一ファイル情報
 */
export type DriveFileInfo = {
  /** ファイル名 */
  name: string;
  /** 最終更新日時(ISO 8601形式) */
  lastUpdated: string;
};

/**
 * Drive検索結果のリスト
 */
export type DriveSearchResult = DriveFileInfo[];
src/features/drive-search/drive-search.service.ts
import type { DriveSearchResult } from './drive-search.type';

/**
 * Drive検索サービス
 *
 * Google DriveApp APIを使用してファイルを検索する機能を提供します。
 */
export const DriveSearchService = {
  /**
   * キーワードでDriveファイルを検索します。
   *
   * @param params - 検索パラメータ
   * @param params.keyword - 検索キーワード(ファイル名の部分一致)
   * @returns ファイル情報の配列(最大50件)
   */
  searchFiles: ({ keyword }: { keyword: string }): DriveSearchResult => {
    // シングルクォートをエスケープしてDrive APIクエリを構築
    const query = `title contains '${keyword.replace(/'/g, "\\'")}'`;
    const files = DriveApp.searchFiles(query);
    const results: DriveSearchResult = [];
    const maxResults = 50;

    for (let i = 0; i < maxResults && files.hasNext(); i++) {
      const file = files.next();
      results.push({
        name: file.getName(),
        lastUpdated: file.getLastUpdated().toISOString(),
      });
    }

    return results;
  },
} as const;

31行目のkeyword.replace(/'/g, "\\'")は、シングルクォートを含むファイル名を検索できるようにするためのエスケープ処理です。
たとえば「John's Report」というファイル名を検索する場合、クォートをエスケープしないとDrive APIのクエリ構文エラーになります。
検索結果は最大50件に制限しています。大量のファイルがヒットした場合のパフォーマンス劣化を防ぐためです。

エントリーポイントの実装

src/index.tsでWebアプリのエントリーポイントを定義します。

src/index.ts
import { DriveSearchService } from '@features/drive-search/drive-search.service';

// @ts-expect-error GASのグローバル関数
function doGet(): GoogleAppsScript.HTML.HtmlOutput {
  // 実際のHTMLがあるファイル名を指定
  return HtmlService.createHtmlOutputFromFile('app/index')
    .setTitle('Drive検索ビューア')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// @ts-expect-error GASのグローバル関数
function searchFiles(keyword: string) {
  return DriveSearchService.searchFiles({ keyword });
}

doGet()関数は、WebアプリのURLにアクセスした際に実行されます。

注意点として、HtmlService.createHtmlOutputFromFile('app/index')は、実際にHTMLファイルが存在するパスを指定する必要があります。今回はapp/ディレクトリに配置しているため、'app/index'と指定します。
パスを間違えると「File not found」エラーが発生するため、注意が必要です。

@ts-expect-errorは、GASのグローバル関数として定義するために必要です。設定しないとTypeScriptのコンパイラが怒ってくるので、このファイルに限りエラーを抑制します。

HTMLファイルの作成

app/index.htmlに検索画面のHTMLを作成します。
このあたりのHTMLはそれっぽい見た目だったらなんでもよいので、よしなに作成していきましょう。
今回はファイル名と更新日時のみを表示していますが、実際にはそのファイルに直接遷移したり、ファイルがあるDriveのフォルダ名を表示するなどすることでユーザー体験が良くなります。

実装はgoogle.script.runsrc/index.tsでWebアプリのエントリーポイントで定義したsearchFiles()を呼び出すことで、HTMLファイルからGoogleDrive検索の処理を実行することが可能です。

app/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Drive検索ビューア</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      background-color: #f5f5f5;
      padding: 20px;
    }

    .container {
      max-width: 800px;
      margin: 0 auto;
      background-color: white;
      padding: 30px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }

    h1 {
      color: #333;
      margin-bottom: 30px;
      font-size: 24px;
    }

    .search-form {
      display: flex;
      gap: 10px;
      margin-bottom: 30px;
    }

    #searchInput {
      flex: 1;
      padding: 10px 15px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 14px;
    }

    #searchInput:focus {
      outline: none;
      border-color: #4285f4;
    }

    #searchButton {
      padding: 10px 20px;
      background-color: #4285f4;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 500;
    }

    #searchButton:hover {
      background-color: #3367d6;
    }

    #searchButton:disabled {
      background-color: #ccc;
      cursor: not-allowed;
    }

    .message {
      padding: 15px;
      border-radius: 4px;
      margin-bottom: 20px;
      display: none;
    }

    .message.info {
      background-color: #e3f2fd;
      color: #1976d2;
      border: 1px solid #bbdefb;
    }

    .message.error {
      background-color: #ffebee;
      color: #c62828;
      border: 1px solid #ffcdd2;
    }

    .message.show {
      display: block;
    }

    .results-container {
      margin-top: 20px;
    }

    table {
      width: 100%;
      border-collapse: collapse;
      display: none;
    }

    table.show {
      display: table;
    }

    th {
      background-color: #f5f5f5;
      padding: 12px;
      text-align: left;
      font-weight: 600;
      color: #555;
      border-bottom: 2px solid #ddd;
    }

    td {
      padding: 12px;
      border-bottom: 1px solid #eee;
      color: #333;
    }

    tr:hover {
      background-color: #fafafa;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>Drive検索ビューア</h1>

    <div class="search-form">
      <input
        type="text"
        id="searchInput"
        placeholder="検索キーワードを入力してください"
        aria-label="検索キーワード"
      />
      <button id="searchButton" type="button">検索</button>
    </div>

    <div id="message" class="message"></div>

    <div class="results-container">
      <table id="resultsTable">
        <thead>
          <tr>
            <th>ファイル名</th>
            <th>最終更新日時</th>
          </tr>
        </thead>
        <tbody id="resultsBody">
        </tbody>
      </table>
    </div>
  </div>

  <script>
    const searchInput = document.getElementById('searchInput');
    const searchButton = document.getElementById('searchButton');
    const messageElement = document.getElementById('message');
    const resultsTable = document.getElementById('resultsTable');
    const resultsBody = document.getElementById('resultsBody');

    /**
     * メッセージを表示する
     *
     * @param {string} text - 表示するメッセージ
     * @param {'info' | 'error'} type - メッセージの種類
     */
    function showMessage(text, type) {
      messageElement.textContent = text;
      messageElement.className = `message ${type} show`;
    }

    /**
     * メッセージを非表示にする
     */
    function hideMessage() {
      messageElement.className = 'message';
    }

    /**
     * 検索結果をテーブルに表示する
     *
     * @param {Array<{name: string, lastUpdated: string}>} results - 検索結果の配列
     */
    function displayResults(results) {
      resultsBody.innerHTML = '';

      if (results.length === 0) {
        resultsTable.classList.remove('show');
        showMessage('該当するファイルがありません', 'info');
        return;
      }

      results.forEach(file => {
        const row = document.createElement('tr');
        row.innerHTML = `
          <td>${escapeHtml(file.name)}</td>
          <td>${escapeHtml(file.lastUpdated)}</td>
        `;
        resultsBody.appendChild(row);
      });

      resultsTable.classList.add('show');
      hideMessage();
    }

    /**
     * HTMLエスケープ処理
     *
     * @param {string} text - エスケープする文字列
     * @returns {string} エスケープされた文字列
     */
    function escapeHtml(text) {
      const div = document.createElement('div');
      div.textContent = text;
      return div.innerHTML;
    }

    /**
     * 検索実行時のエラーハンドラ
     *
     * @param {Error} error - エラーオブジェクト
     */
    function handleError(error) {
      console.error('検索エラー:', error);
      showMessage('検索中にエラーが発生しました', 'error');
      searchButton.disabled = false;
      searchInput.disabled = false;
    }

    /**
     * 検索を実行する
     */
    function performSearch() {
      const keyword = searchInput.value.trim();

      if (!keyword) {
        showMessage('検索キーワードを入力してください', 'info');
        return;
      }

      searchButton.disabled = true;
      searchInput.disabled = true;
      resultsTable.classList.remove('show');
      showMessage('検索中...', 'info');

      google.script.run
        .withSuccessHandler((results) => {
          displayResults(results);
          searchButton.disabled = false;
          searchInput.disabled = false;
        })
        .withFailureHandler((error) => {
          handleError(error);
        })
        .searchFiles(keyword);
    }

    // イベントリスナーの設定
    searchButton.addEventListener('click', performSearch);

    searchInput.addEventListener('keypress', (event) => {
      if (event.key === 'Enter') {
        performSearch();
      }
    });
  </script>
</body>
</html>

デプロイ

GASへのプッシュ

GASプロジェクトにコードをデプロイしていきましょう。

pnpm run deploy

初回実行時は、マニフェストファイル(appsscript.json)の上書き確認が表示されます。「Yes」を選択してください。

> clasp-gas-webapp-demo@0.0.5 push
> clasp push

? Manifest file has been updated. Do you want to push and overwrite? Yes
└─ app/index.html
└─ appsscript.json
└─ dist/index.js
Pushed 3 files.

Webアプリの公開設定

GASエディタ(https://script.google.com/)を開き、デプロイボタンから「新しいデプロイ」を選択します。
今回は個人開発のため自分のみを指定していますが、実際に社内で運用するときは@company.jpのようなドメインで絞ることが可能です。

デプロイ設定では、以下を指定します。

  • 種類: ウェブアプリ
  • 実行ユーザー: 自分
  • アクセスできるユーザー: 自分のみ(開発時)、またはドメイン内全員(本番時)

image

OAuth認証

デプロイ後、初回アクセス時にOAuth認証画面が表示されます。
本プロジェクトでは、appsscript.jsonに以下の権限を設定しています。

{
  "oauthScopes": ["https://www.googleapis.com/auth/drive.readonly"],
  "webapp": {
    "executeAs": "USER_ACCESSING",
    "access": "DOMAIN"
  }
}
  • https://www.googleapis.com/auth/drive.readonly: Drive APIの読み取り専用権限
  • executeAs: "USER_ACCESSING": アクセスしたユーザーの権限で実行
  • access: "DOMAIN": ドメイン内全員がアクセス可能

読み取り専用権限のため、ファイルの変更や削除はできません。最小権限の原則に従った設計です。

動作確認

デプロイ完了後、WebアプリのURLにアクセスします。
検索フォームにキーワード(例: 「報告書」)を入力して「検索」ボタンをクリックすると、Google Drive内のファイルが検索されます。
検索結果は、ファイル名と最終更新日時がテーブル形式で表示されます。該当するファイルがない場合は、「該当するファイルがありません」というメッセージが表示されます。
実際にGoogle Driveに仮ファイルを作成して「clasp-gas-webapp-demo」と検索したところ、無事検索ができ画面へ表示されました。
image

image

社内公開に向けて:実践的な考慮事項

このプロジェクトは、あくまでclaspを用いたWebアプリ開発の第一歩です。社内ツールだとしても、セキュリティへの配慮はより一層重要になります。
特に社内データを取り扱う場合は、以下の点に確認が必要です。

1つ目は情報セキュリティ部門との連携です。新しいツールを導入する際は、まず情報セキュリティ部門に相談することをお勧めします。
特に、OAuthで要求する権限の妥当性や、万が一のインシデントが発生した場合の対応など専門的な視点からの助言は非常に有益です。

2つ目は極力最小限の権限に設定することです。本アプリでは、oauthScopesdrive.readonlyという読み取り専用の権限を設定しました。
機能を追加する際も、安易に強力な権限(読み書き・削除が可能)を要求せず、本当に必要な最小限の権限は何かを常に意識して設定することが重要です。

3つ目はアクセス範囲を再確認することです。社内限定で公開する際は必ず"DOMAIN"を選択し、誤って"ANYONE"(全員に公開)に設定しないよう、デプロイ時にはダブルチェックを徹底しましょう。この一つの設定ミスが、意図しない情報公開に繋がるリスクを孕んでいます。

まとめ

本記事では、claspとTypeScriptを組み合わせることで、Google Apps Scriptのモダンな開発フローについて解説しました。

GASが元来持つ「サーバーインフラ不要で、Google Workspaceとシームレスに連携できる」という手軽さはそのままに、使い慣れたエディタ、強力な型補完、そしてGitによるバージョン管理といった、現代的なソフトウェア開発の恩恵を享受できます。この組み合わせは、特に社内の「ちょっとした不便」を解決するツールを迅速に開発する上で、非常に強力な選択肢となるでしょう。

今回構築した「Drive検索ビューア」は、その可能性を示すほんの始まりに過ぎません。Google Workspaceには、Gmail、カレンダー、スプレッドシートといった強力なAPI群が揃っています。これらと連携させることで、あなたのアイデア次第で活用の幅は無限に広がります。

例えば、以下のようなアプリなどが挙げられます。

  • Gmail APIと連携し、「特定のラベルが付いたメールの添付ファイルを一括でDriveに保存するツール」
  • Sheets APIを使い、「スプレッドシートを簡易データベースとした備品管理アプリ」
  • Calendar APIを活用し、「オフィスに出社している人の数を知るアプリ」

ぱっと思いついた範囲ですが、このような日々の業務に密着した課題を解決するアプリケーションを、自らの手で作り出すことが可能です。
この記事が、皆さんの「あったらいいな」を実現するための一歩を踏み出すきっかけとなれば幸いです。
以上になります✌️

参考リンク

https://github.com/google/clasp
https://developers.google.com/apps-script/guides/clasp
https://developers.google.com/identity/protocols/oauth2/scopes

Discussion