CONTACT

【AI×自動化】SES業界の課題を解決する「SES自動マッチングシステム」を自社開発しました

SES業界が抱える「人材マッチング」の課題

SES(システムエンジニアリングサービス)業界では、
案件と人材のマッチング業務が極めて時間と手間のかかる作業です。

営業担当者は日々大量のメールを確認しながら、
「このエンジニアのスキルはどの案件に合うのか?」を判断する必要があります。

しかしこの作業には、次のような課題がありました。

  • 手作業による照合作業の非効率さ
  • 見落としリスクの高さ
  • 担当者の経験や勘に依存した属人的判断
  • メール処理に費やされる膨大な時間コスト

こうした課題は、SES事業のスピードと精度の両立を難しくしていました。
この非効率な仕組みを変えるために、当社はAIを活用した自動マッチングシステムの開発に着手しました。

開発の背景 – なぜAIによる自動化が必要だったのか

SES業界では、案件情報やスキル情報がメールベースで日々やり取りされます。
そのため、情報の構造化や一元管理が難しく、属人的な判断に依存する傾向がありました。

当社は、「このプロセスこそ自動化できるのではないか」と考え、
Google Apps ScriptとGPT-4を活用した完全自動マッチングシステムの開発を進めました。

目的は明確

  • 営業担当者の日常業務を自動化し、生産性を向上させる
  • AIによる客観的判断で、マッチング精度を安定化させる
  • 業務標準化によって属人化を解消する

このシステムは「AIを業務の一部として自然に組み込む」ことを目指して設計されています。

「SES自動マッチングシステム」とは

自動で人材と案件をマッチングする仕組み

本システムは、Gmailに届くメールを自動で分析・分類し、人材情報と案件情報を抽出、スキルマッチングを行う仕組みです。

AIがそれぞれのメールを判定し
「候補者メール」と「案件メール」を自動分類。
抽出したスキルや条件をもとに
AIが類似度スコアを算出して最適なマッチング結果を出力します。

結果はGoogleスプレッドシートに自動保存され
営業担当者は毎朝、最新のマッチング結果をすぐに確認できます。

Google Apps Script × GPT-4による低コスト運用

本システムはGoogleのクラウド環境上で動作し
専用サーバー不要・無料枠で運用可能です。

AI部分にはOpenAIのGPT-4 APIを活用し
自然言語処理とスキル抽出を高精度に実現。

この構成により、高精度かつ低コストな自動化システムを実現しています。

導入効果 – 営業業務の90%を自動化

導入後の効果は非常に大きく、
これまで営業担当者が1日2〜3時間費やしていたメール確認とマッチング作業が、
ほぼゼロに削減されました。

工数削減と標準化による生産性向上

  • メール処理・照合・スプレッドシート更新を完全自動化
  • 業務を標準化することで、担当者によるばらつきを排除
  • 処理件数が増えても、工数は一定のまま

営業担当者が戦略業務に集中できる環境へ

日常的なルーティン作業が削減されたことで、
営業担当者はより戦略的な案件開拓や顧客対応に時間を使えるようになりました。

また、AIによるマッチング結果の「スコア化」により、
判断の透明性と再現性も確保されています。

技術面での特長とこだわり

経営層の方にも安心して導入をご検討いただけるよう、
技術面でも運用性と信頼性を重視しています。

AIによるスキル類似度計算で高精度マッチング

GPT-4のEmbedding技術を活用し、
スキル情報を数値化して類似度を計算。
感覚や経験に依存しない客観的なマッチング基準を実現しています。

安定稼働・自動復旧による運用効率

メール取得エラーやAPI応答遅延が発生しても、
自動リトライ・ログ管理機能により安定稼働を維持
日々の運用において、担当者がシステムの存在を意識する必要はありません。

今後の展望 – 学習機能・CRM連携でさらに進化

当社では今後、以下のような機能拡張を予定しています。

  • 学習機能:過去のマッチング結果をもとにAIが精度を向上
  • 通知機能:最適なマッチング結果をSlackなどに自動通知
  • CRM連携:顧客データベースと自動で連携し、契約管理まで一元化

このように、AIによる業務支援の幅を広げることで、
SES業務全体のDX(デジタルトランスフォーメーション)を加速していきます。

SES業務の未来を変える – AI活用の可能性

このシステムの開発を通じて、私たちは改めて実感しました。
「AIは一部の高度な開発ではなく、現場業務のパートナーとして機能する」と。

SES業界に限らず、多くの業種で同様の仕組みを応用できます。
当社では、今後もAI・自動化技術を活用した業務改善ソリューションの開発を積極的に進めていきます。

よくある質問(FAQ)

Q1. このシステムはどのくらいのコストで運用できますか?
A2. 基本構成の導入であれば約1〜2週間程度です。カスタマイズ内容により変動します。

Q2. 導入にはどのくらいの期間がかかりますか?
A2. 基本構成の導入であれば約1〜2週間程度です。カスタマイズ内容により変動します。

Q3. セキュリティ面は安全ですか?
A3. Googleクラウド上で動作し、外部データベースを使用しないため、情報漏洩リスクは極めて低いです。

Q4. 他業種でも活用できますか?
A4. はい。メールベースの情報抽出・分類が必要な業務なら、同様の仕組みを応用可能です。

Q5. どのAIモデルを使用していますか?
A5. OpenAIのGPT-4モデルを利用し、スキル抽出とマッチング判定を行っています。

Q6. 今後のアップデート予定はありますか?
A6. はい。CRM連携や学習機能など、さらに高機能なバージョンを開発中です。

まとめ – AIと自動化でSES業務を次のステージへ

「SES自動マッチングシステム」は、
AI技術による業務効率化と標準化を実現する第一歩です。

私たちは今後も、AI・自動化技術を通じて
お客様の業務課題を解決し、より付加価値の高いサービスを提供してまいります。

✅ この記事のポイント

  • 営業業務を90%自動化
  • GPT-4を活用した高精度マッチング
  • Google環境で低コスト運用
  • 今後は学習機能・CRM連携を予定

このシステムは、「AIを業務に組み込む時代」の到来を実感させるプロジェクトとなりました。
当社はこれからも、AI技術を活用したソリューション開発を通じて、
お客様のビジネス変革を支援してまいります。

【ソースコードあり】ベンチャーなので名刺管理ツールをGASで作ってみた【作り方も載せます】

TL;DR:完成した名刺管理ツールの紹介

特定フォルダに社員ごとのフォルダを作成し、そこに名刺のjpg画像を格納することで自動的にスプレッドシートに名刺の情報が格納されるツールを作りました。

仕組みとしては「時間経過」をトリガーにGASのスクリプトが実行され、特定フォルダ以下の画像ファイルを「クローリング」し、未処理の画像があれば「OCR」→「ChatGPTで解析」をする、というものです。

はじめに

名刺管理ツールは市販のSaaSが数多く存在します。しかし、スタートアップやベンチャーの現場では「費用対効果が合わない」「機能が大きすぎる」「まずは最低限でいい」といった理由から、導入に踏み切れないことも少なくありません。

私たちのチームでも同じ課題を抱えていました。営業や社外折衝で毎日のように名刺が増えていく一方で、情報の整理は各自に任せきり。結果、

  • どの名刺がどこにあるのか分からない
  • Excelや手書きの管理で属人化してしまう
  • データ化が追いつかず、営業リストに活用できない

といった状態が起きていました。

そこで「市販ツールをいきなり導入する前に、自分たちで小さく作ってみよう」と考えました。幸いGoogle Workspaceを全社で利用していたため、Drive・スプレッドシート・Apps Scriptを組み合わせれば、最低限の名刺管理システムがすぐに作れそうです。さらに最近はChatGPTの活用で、OCRの結果をきれいに構造化することも可能になっています。

この記事では、「Driveのフォルダに名刺画像を置くだけで、自動でOCR → ChatGPTで整形 → スプレッドシートに反映する仕組み」を作ったプロセスを、ソースコード付きで解説します。小さく始めたいベンチャーやスタートアップにとって、役立つ参考になるはずです。

プロジェクトの全体像

以下がプロジェクトの全体像です。

  1. GASのスクリプトから、フォルダ内の画像ファイルを取得
  2. 未処理の画像ならGCP内の「Google Drive API」に含まれるOCR機能を活用して、名刺の画像を文字列化
  3. 帰ってきた文字列をChatGPTに構造化(JSON)させる
  4. JSONをパース(分解)し、スプレッドシートに記録

準備編:GCPとGASの設定

名刺管理ツールをGASで動かすためには、Google Cloud Platform(GCP)でAPIを有効化し、Google Apps Script(GAS)と連携させる必要があります。ここでは必要な準備をステップごとに解説します。

1. GCPプロジェクトの作成

まずはGCPで新しいプロジェクトを作成します。

  1. Google Cloud Console にアクセスし、右上のプロジェクト選択メニューから「新しいプロジェクト」をクリック
  2. プロジェクト名を入力(例:BusinessCardOCR)
  3. 作成を押す(「組織」と「場所」はデフォルトで大丈夫です)

2. Drive APIの有効化

次に、このプロジェクトで Google Drive API を有効化します。

  1. 画像赤枠部分で「Google Drive API」を検索
  2. 検索結果から「Google Drive API」を開く
  3. 「有効にする」をクリック

3. OAuth同意画面の設定

Google Drive APIを利用するためにOAuth同意画面の設定が必要です。

  1. 「APIとサービス」→「OAuth同意画面」を開く
  2. ユーザータイプを選択(社内利用であれば「内部」、外部公開なら「外部」)
  3. アプリ名、サポートメールアドレスを入力
  4. 以下の画像のボタンからスコープに「…/auth/drive」を追加
  5. 保存して完了

4. スプレッドシートとの紐付け(コンテナバインド型)

今回のプロジェクトは「スプレッドシートと連動する前提」なので、対象のスプレッドシートから直接GASを開きます。

  1. 名刺情報を保存するスプレッドシートを用意(シート名は data 推奨)
  2. スプレッドシートのメニューから「拡張機能」→「Apps Script」をクリック
  3. これでスプレッドシートと紐づいた(コンテナバインド型)GASが作成されます

5. 高度なGoogleサービスの有効化

最後に、GAS側でDrive APIを使えるように設定します。

  1. GASエディタを開き、メニューから「サービス」→「高度なGoogleサービス」へ
  2. Drive API v2 をONにする
  3. これで Drive.Files.copy(…) が使えるようになります

6. APIキーの設定

ChatGPTのAPIキーの取得方法などの記事はたくさんあるので、そちらを参考にしてください!

  • プロジェクトのプロパティを開く
    メニュー「プロジェクトの設定(歯車アイコン)」をクリック
    「スクリプトのプロパティ」欄を探します
  • スクリプトプロパティを追加
    「+追加」を押して新しいキーを追加
    名前: OPENAI_API_KEY
    値: OpenAIのAPIキー(例: sk-xxxx…

これで準備は完了です!
次章からは、実際にOCRとChatGPTを組み合わせて名刺を自動で構造化していく実装に入ります。

実装編:ソースコード

以下をGASのスクリプトエディタにコピペしてください。
PARENT_FOLDER_IDには名刺を格納するフォルダのURLの〇〇部分を入れてください
「https://drive.google.com/drive/u/1/folders/〇〇」

var PARENT_FOLDER_ID = "XXXXXXXXXXXXXXXXXX";  // 社員フォルダをまとめるフォルダID

function crawlAndProcess() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("data");
  var lastRow = sheet.getLastRow();

  var processedFiles = [];
  if (lastRow > 1) {
    var values = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
    processedFiles = values.map(function(row) {
      return row[0];  // 1列目の値(ファイルID)
    });
  }

  var parent = DriveApp.getFolderById(PARENT_FOLDER_ID);
  var subFolders = parent.getFolders();

  while (subFolders.hasNext()) {
    var folder = subFolders.next();
    var employeeName = folder.getName();
    var files = folder.getFiles();
    

    while (files.hasNext()) {
      var file = files.next();
      if (processedFiles.indexOf(file.getId()) !== -1) {
        continue; // 既に処理済み
      }
      Logger.log(file);
      // --- OCR実行 (Advanced Drive Service 必要) ---
      var resource = {
        title: file.getName(),
        mimeType: MimeType.GOOGLE_DOCS
      };
      var ocrFile = Drive.Files.copy(resource, file.getId(), {ocr: true});
      var doc = DocumentApp.openById(ocrFile.id);
      var text = doc.getBody().getText();

      Logger.log("OCR完了");

      // ChatGPTで構造化
      var structured = callOpenAI(text);

      Logger.log(structured);
      // スプレッドシートに追記
      sheet.appendRow([
        file.getId(),
        employeeName,
        file.getName(),
        new Date(),
        structured.name || "",
        structured.company || "",
        structured.department || "",
        structured.position || "",
        structured.address || "",
        structured.phone || "",
        structured.email || "",
        structured.website || ""
      ]);

      // 中間ファイル(Google Doc)は削除
      DriveApp.getFileById(ocrFile.id).setTrashed(true);
    }
  }
}

function callOpenAI(text) {
  const API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
  const url = "https://api.openai.com/v1/chat/completions";
  const payload = {
    model: "gpt-4o-mini",
    messages: [
      {
        role: "system",
        content: "あなたはOCRされた名刺情報を必ず以下のキーでJSON化するアシスタントですJson以外のコンテンツは生成しないでください。: name, company, department, position, address, phone, email, website"
      },
      {
        role: "user",
        content: "以下のOCRテキストから名刺情報を抽出してください:" + text
      }
    ]
  };

  const response = UrlFetchApp.fetch(url, {
    method: "post",
    headers: {
      Authorization: "Bearer " + API_KEY,
      "Content-Type": "application/json"
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  const json = JSON.parse(response.getContentText());
  try {
    return JSON.parse(json.choices[0].message.content);
  } catch (e) {
    return {};
  }
}

運用編:自動化

名刺管理を日常的に使うには、「新しい名刺画像がアップロードされたら自動で処理される」ことが大切です。Google Apps Script では、時間主導型トリガーを設定することで、関数を一定間隔で自動実行できます。

1. トリガー画面を開く

  1. Apps Script エディタを開く
  2. 左側の時計アイコン「トリガー」をクリック

2. 新しいトリガーを追加

  1. 右下の「トリガーを追加」をクリック
  2. 実行する関数を crawlAndProcess に設定
  3. 実行するデプロイ → Head
  4. 実行ユーザー → 自分(アカウント)

3. イベントの種類を選択

  1. イベントのソース:時間主導型
  2. 時間ベースのトリガーのタイプを選択
    分ベース → 5分ごと、10分ごとなど
    時間ベース → 1時間ごと、2時間ごと
    日ベース → 毎日決まった時刻

例えば「1時間ごと」にすれば、Driveに追加された名刺画像を自動的にOCR & 保存してくれます。

実際に使ってみた感想

実際に社内でこの仕組みを運用してみると、いくつかの気づきがありました。

まず、技術的な側面では ChatGPTが返すJSONのフォーマットが必ずしも正しいとは限らない という課題がありました。時折、余計なテキストが混ざったり、JSONの波括弧が欠けていたりするケースがあり、その場合はスプレッドシートに正しく反映されません。プロンプトを工夫することで精度は改善しますが、「AIからの出力を前提にシステムを組む場合はエラーハンドリングが必須」という学びがありました。

次に、仕組みそのものを作るのは思った以上に簡単で便利でしたが、「名刺画像をDriveにアップロードする」という運用ルールを根付かせる方がはるかに難しい という現実に直面しました。どれだけ便利な仕組みでも、社員全員が日常的に使う習慣を持たなければ、データは蓄積されません。技術の課題よりも文化を浸透させる難しさの方が大きいと感じています。

そして最後に、実際に使ってみると 人間がこれまで目で確認していた情報整理を、かなりの部分AIに任せられるようになってきた という実感がありました。OCRとAIの組み合わせは、単なる自動化を超えて、知的労働の一部を置き換え始めています。こうした仕組みを日常的に取り入れることで、今後は名刺管理に限らずさまざまな業務で、AIによる労働力の代替が加速していくと強く感じました。

まとめ

今回紹介した名刺管理ツールは、Google Drive API の OCRChatGPT による構造化、そして GAS + スプレッドシート を組み合わせることで、比較的簡単に実現できました。
システム自体は短時間で構築できますが、実際に使い続けるには「名刺画像をアップロードする文化を根付かせること」が大きな課題です。

一方で、AIを組み合わせることで、これまで人間が手作業で行っていた判断や整理の一部を自動化できることを強く実感しました。こうした仕組みを小さく作って運用してみることが、今後の業務効率化やAI活用の第一歩になると考えています。

【GAS】GoogleスプレッドシートとApps Scriptで予定管理ツールを自作!(複数招待者の空き状況自動取得編)

本記事の概要

Googleカレンダーに予定を手動で登録するのが面倒くさい…
そんな悩みを解決するために、GoogleスプレッドシートとApps Scriptを使った「予定管理ツール」を自作しました!
本記事では、複数招待者の空き状況を自動で取得してくる機能について解説します。

ターゲット

  • Google Workspaceを活用している企業の管理者・エンジニア
  • 社内予定調整・会議予約の工数を削減したい方
  • Google App Script(GAS)で業務効率化をしたい方

Googleカレンダー連携予定管理ツールとは?

この「予定管理ツール」は、GoogleスプレッドシートとApps Script(GAS)を組み合わせて作った業務自動化ツールです。

予定の入力はスプレッドシート、
登録・通知はGoogleカレンダー、
全てをワンクリックで完結できます。

シート構成と役割

シート名役割
予定管理登録予定の一覧(手入力 or 空き時間から自動反映)
登録一覧カレンダーに登録済みの予定履歴
招待者マスタ社員名とメールアドレスの対応表(自動取得)
会議室マスタ会議室名とGoogleカレンダーIDの一覧(自動取得)
空き状況招待者の空き時間を自動出力するためのワークシート

機能要件

①スプレッドシートUI機能

機能内容
カスタムメニュー追加シート起動時にメニューバーへ「空き状況確認」メニューを追加
空き状況取得招待者の予定を確認し、空いている時間を30分単位で可視化
予定反映空き時間の候補を選択して、予定管理シートへ自動入力

②Googleカレンダー連携機能

機能内容
カレンダー登録シート上の予定情報からGoogleカレンダーにイベント登録(Meetリンク付き)
招待者追加招待者マスタを参照してメールアドレスに変換し、ゲストとして自動追加
会議室予約会議室マスタのメールを使用してイベントに会議室を招待
イベントID保存作成されたイベントのIDを履歴としてスプレッドシートに記録
入力行削除カレンダー登録完了後、予定管理シートから該当行を削除

③履歴メンテナンス機能

機能内容
終了イベント削除終了日時が過去のイベントは履歴シートから自動削除
カレンダー削除検知カレンダー上から削除されたイベントも履歴から削除
毎日トリガー実行上記処理を毎日午前2時に実行するトリガーを自動作成

④マスタメンテナンス機能

機能内容
招待者マスタ更新Google Workspace のユーザー一覧を取得してシートに自動反映
会議室マスタ更新会議室リソースを一覧取得し、メールアドレスとともにシートに反映
自動トリガー作成毎日午前3時にマスタを更新するトリガーを自動生成

実装コード解説

①カスタムメニューの追加(onOpen())

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('空き状況確認')
    .addItem('選択行の空き状況取得', 'showFreeBusyForRow')
    .addItem('選択スロットを予定管理に反映', 'applyFreeSlotsToInput')
    .addToUi();
}

解説

  • onOpen()はGASにおけるスプレッドシートの「カスタムメニュー追加」などに使う代表的なトリガー関数。ユーザーがスプレッドシートを開いたときに自動的に実行される
  • SpreadsheetApp.getUi()→スプレッドシートのUIにアクセス
  • createMenu→ユーザー定義のメニューに追加
  • GAS独自のカスタムメニュー機能により、クリックで関数実行が可能になる

実装後イメージ

②空きスロットの取得(Googleカレンダー連携)

function showFreeBusyForRow() {
  const ss    = SpreadsheetApp.getActiveSpreadsheet();
  const input = ss.getSheetByName(SHEET_INPUT);
  const ui    = SpreadsheetApp.getUi();
  const sel   = input.getActiveRange();
  if (!sel || sel.getSheet().getName() !== SHEET_INPUT) {
    ui.alert('予定管理シートでデータ行を選択してください');
  return;
  }
  const row = sel.getRow();
  if (row < 2) {
    ui.alert('データ行を選択してください');
    return;
  }

  // デフォルト設定
  const defaultStart = new Date(); defaultStart.setHours(0,0,0,0);
  const defaultDays  = 7;
  const defaultFromH = 9, defaultFromM = 0;
  const defaultToH   = 20, defaultToM   = 0;

  // 開始日時(I3) と 期間日数(J3)
  let rawStart = input.getRange('I3').getValue();
  let timeMin  = rawStart instanceof Date? new Date(rawStart): rawStart? new Date(rawStart): defaultStart;
  let rawDays  = input.getRange('J3').getValue();
  let days     = rawDays? Number(rawDays): defaultDays;
  let timeMax  = new Date(timeMin); timeMax.setDate(timeMin.getDate()+days);

  // 時間帯(K3,L3)
  const parseHM = (v,defH,defM)=>{
    if (v instanceof Date) return [v.getHours(),v.getMinutes()];
    if (typeof v==='string' && v.includes(':')) {
      const [h,m]=v.split(':').map(x=>parseInt(x,10)||0);
      return [h,m];
    }
    return [defH,defM];
  };
  let [fh,fm] = parseHM(input.getRange('K3').getValue(), defaultFromH, defaultFromM);
  let [th,tm] = parseHM(input.getRange('L3').getValue(), defaultToH,   defaultToM  );
  const fromTotalMin = fh * 60 + fm;
  const toTotalMin   = th * 60 + tm;

  // 招待者取得 (G列)
  const inviteeStr = input.getRange(row,7).getValue();
  if (!inviteeStr) { ui.alert('招待者セルが空です'); return; }
  const names      = inviteeStr.toString().split(',').map(s=>s.trim()).filter(s=>s);
  const inviteeMap = buildMap(ss, INVITEE_MASTER,1,2);
  const attendees  = names.map(n=>inviteeMap[n]).filter(e=>e);
  if (!attendees.length) { ui.alert('招待者マスタに該当がありません'); return; }

  // FreeBusy.query
  const fbReq  = { timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), items: attendees.map(e=>({id:e})) };
  const fbResp = Calendar.Freebusy.query(fbReq);

  // busy→free slots
  const busy      = attendees.flatMap(e=>fbResp.calendars[e].busy||[]).map(p=>({start:new Date(p.start),end:new Date(p.end)}));
  const slotIntervalCell = input.getRange('M3').getValue();
  const slotMin = slotIntervalCell
    ? Number(slotIntervalCell)
    : 30;
  const freeSlots = calcFreeSlots(busy, timeMin, timeMax, slotMin)
  .filter(s=>{
  const m  = s.start.getHours()*60 + s.start.getMinutes();
  return m>=fromTotalMin && m+slotMin<=toTotalMin;
  });

  // 出力
  let out = ss.getSheetByName('空き状況');
  if (!out) out = ss.insertSheet('空き状況');
  // ヘッダー以外の値をまるっとクリア
  const maxRow = out.getMaxRows();
  if (maxRow > 1) {
    // A2:C<最終行> の内容を消す
    out.getRange(2, 1, maxRow - 1, 3).clearContent();
    // A2:A<最終行> のチェックボックス設定を消す
    out.getRange(2, 1, maxRow - 1, 1).clearDataValidations();
  }

  // フリー枠を書き込む
  if (freeSlots.length > 0) {
    const outputValues = freeSlots.map(s => [
      false,
      s.start,
      s.end
    ]);
    out.getRange(2, 1, outputValues.length, 3).setValues(outputValues);
    // ③ 書き込んだ行だけチェックボックスを再設定
    out.getRange(2, 1, outputValues.length, 1).insertCheckboxes();
  }
  ui.alert(`空きスロットを${freeSlots.length}件出力しました`);
}

解説1:基本定数の定義と選択行のチェック

const ss    = SpreadsheetApp.getActiveSpreadsheet();
const input = ss.getSheetByName(SHEET_INPUT);
const ui    = SpreadsheetApp.getUi();
const sel   = input.getActiveRange();
  • ss:現在アクティブなスプレッドシートを取得
  • input:予定管理用のシート(例:‘予定管理’)を取得
  • ui:UIダイアログ用のオブジェクト
  • sel:現在選択されているセルの範囲
if (!sel || sel.getSheet().getName() !== SHEET_INPUT) {
  ui.alert('予定管理シートでデータ行を選択してください');
  return;
}
const row = sel.getRow();
if (row < 2) {
  ui.alert('データ行を選択してください');
  return;
}
  • 選択セルが正しいシート・データ行であることをチェック。間違っていると警告を出して処理終了。

解説2:検索範囲の開始日時・終了日時の取得(I3,J3)

const defaultStart = new Date(); defaultStart.setHours(0,0,0,0);
const defaultDays  = 7;
  • デフォルトは「今日0:00」から7日間。
let rawStart = input.getRange('I3').getValue();
let timeMin  = rawStart instanceof Date ? new Date(rawStart) : defaultStart;

let rawDays  = input.getRange('J3').getValue();
let days     = rawDays ? Number(rawDays) : defaultDays;
let timeMax  = new Date(timeMin); timeMax.setDate(timeMin.getDate() + days);
  • I3 に開始日、J3 に日数が入力されていれば使用し、未入力ならデフォルト使用。

解説3:時間帯の取得(K3:開始時刻、L3:終了時刻)

const parseHM = (v,defH,defM)=>{
    if (v instanceof Date) return [v.getHours(),v.getMinutes()];
    if (typeof v==='string' && v.includes(':')) {
      const [h,m]=v.split(':').map(x=>parseInt(x,10)||0);
      return [h,m];
    }
    return [defH,defM];
  };

引数の意味

  • v… 時刻情報(Date型または”10:30″などの文字列、または未入力)
  • defH… デフォルトの「時」(vが無効だった時に使用)
  • defM… デフォルトの「分」(vが無効だった時に使用)

処理の流れ

1. v instanceof Date

  • 入力 v が Date オブジェクトなら、getHours() と getMinutes() で [時, 分] を返します。

例:
parseHM(new Date(“2025-08-05T14:45:00”), 9, 0)  // → [14, 45]

2. typeof v === ‘string’ && v.includes(“:”)

  • 入力 v が “14:30” のような文字列なら、それを : で分割して、parseInt で整数に変換します。
  • 不正な文字列でも parseInt(x, 10) || 0 によって0に変換されるため、安全です。

例:
parseHM(“10:45”, 9, 0) // → [10, 45]
parseHM(“abc:99″, 9, 0) // → [0, 99](”abc” は parseIntでNaN → 0 に)

3. それ以外はデフォルト値を返す

return [defH, defM];

  • 日時が未入力だったり、文字列が “abc” のように完全に無効だった場合など。

例:
parseHM(“”, 9, 0) // → [9, 0]

解説4:招待者(G列)を取得し、メールアドレスに変換

const inviteeStr = input.getRange(row, 7).getValue();
if (!inviteeStr) { ui.alert('招待者セルが空です'); return; }

const names = inviteeStr.toString().split(',').map(s => s.trim()).filter(s => s);
const inviteeMap = buildMap(ss, INVITEE_MASTER, 1, 2);
const attendees = names.map(n => inviteeMap[n]).filter(e => e);
if (!attendees.length) { ui.alert('招待者マスタに該当がありません'); return; }
  • G列には 山田太郎, 佐藤花子 のように招待者名が入っている想定。
  • buildMap() で「名前 → メールアドレス」マップを作り、各招待者のアドレスを抽出。
  • マスタに存在しない名前は無視。

解説5:GoogleカレンダーAPIでFreeBusy(予定あり)取得

const fbReq  = {
  timeMin: timeMin.toISOString(),
  timeMax: timeMax.toISOString(),
  items: attendees.map(e => ({ id: e }))
};
const fbResp = Calendar.Freebusy.query(fbReq);
  • 複数人のカレンダーの「予定あり時間(busy)」を取得。

解説6:空きスロットの計算

const busy = attendees.flatMap(e => fbResp.calendars[e].busy || [])
  .map(p => ({ start: new Date(p.start), end: new Date(p.end) }));

const slotIntervalCell = input.getRange('M3').getValue();
const slotMin = slotIntervalCell ? Number(slotIntervalCell) : 30;
  • 予定のある時間(busy)をまとめて、30分単位などで区切った空き時間(free)を計算。
const freeSlots = calcFreeSlots(busy, timeMin, timeMax, slotMin).filter(s => {
  const m = s.start.getHours() * 60 + s.start.getMinutes();
  return m >= fromTotalMin && m + slotMin <= toTotalMin;
});
  • 指定時間帯の中に収まるスロットだけ抽出。

解説7:結果を「空き状況」シートに出力

let out = ss.getSheetByName('空き状況');
if (!out) out = ss.insertSheet('空き状況');
  • 出力先の 空き状況 シートが存在しない場合は新規作成。
if (freeSlots.length > 0) {
  const outputValues = freeSlots.map(s => [false, s.start, s.end]);
  out.getRange(2, 1, outputValues.length, 3).setValues(outputValues);
  out.getRange(2, 1, outputValues.length, 1).insertCheckboxes();
}
// A2:C<最終行>の値とチェックボックスをクリア
const maxRow = out.getMaxRows();
if (maxRow > 1) {
  out.getRange(2, 1, maxRow - 1, 3).clearContent();
  out.getRange(2, 1, maxRow - 1, 1).clearDataValidations();
}
  • 空きスロットを A列:チェックボックス、B列:開始時刻、C列:終了時刻として出力。
  • チェックボックスは再度挿入されるので再利用可能。

解説8:完了ダイアログ

ui.alert(`空きスロットを${freeSlots.length}件出力しました`);