スキャナーでスキャンしたPDFをDiscordで通知するエージェントをAIで自作してみた!

Cover Image for スキャナーでスキャンしたPDFをDiscordで通知するエージェントをAIで自作してみた!
AICU media
AICU media

Claude4でつくる家庭用スキャナーエージェント「AgentScanner」解説

「つくるひとをつくる」を開発する、AICU編集部・しらいはかせです。今回は「窓の杜」での連載「生成AIストリーム」で紹介した自作ソフトのインストール解説です。自作ソフト、といってもScanSnapで保存したPDFを整理してDiscordで連絡する、というめちゃくちゃ便利なアプリをClaude Sonnet4とGoogle Apps Scriptで使ってあっという間に自分で開発しました、という話です。ソースコードも公開です。だいたい1時間ぐらいでできる作業なので、ぜひ日曜大工風味なDIYとして、AIとやってみてください!

https://amzn.to/4lpUXqk

[設計]保存したPDFファイルをDiscordに通知する

Google DriveへのPDF保存は各社のスキャナでもできそうなので、今回はScanSnapでGoogle DriveにPDFを保存させるところはメーカー提供のソリューションにお任せして、「そのスキャンしたPDFファイルの整理と通知」に注目して開発することにしました。

ちなみに、スキャナ各社のソフトウェアはGoogle Driveへのファイル保存はできても、SlackやDiscordへの通知機能は実装されていません。もちろんLINEもありません。ないならつくるしかない!

出来上がったもの。

先に完成形から紹介しておきます。

こんなふうに、書類をスキャンすると家族で共有しているGoogle Driveに保存され、Discrodでファイル名やサイズの通知が来ます。ファイル名の文字起こしはScansnapによるものです。
設定が間違っていた場合のエラー通知などもきちんと表示されます。

「AgentScanner」家族の感想

出来上がったツール「AgentScanner」の体験を家族にきいてみました。

・なにこれ超便利じゃん
・お父さんが占有していたスキャナーをみんなで使えるのいいね
・リンクをクリックするとPDFが見れるの助かる
・郵便受けで受け取ったDMをとりあえずスキャンして通知できるのいいね
・スキャンしてすぐに確認してリサイクルにできるのいいね
・Discordのチャンネルで後から見ればいいのいいね
・DiscordでもGoogle Driveでも検索できるのうれしい
・過去のPDFも含めてGoogle Drive上でフォルダで整理する前段として嬉しい

といった評価をいただいております。
これでお父さんの株は爆上がりですね!

Claude4に渡したプロンプト

実は今回のスクリプトは Claude4 無料アカウントで作ったものです。

以下のようなプロンプトで「ほぼ一発」で作りました。そのまま貼り付けてみていただいても構いません。

・Scansnapで特定のディレクトリに新規PDFが保存されるので
・時間トリガーで起動した関数WatchNewFile()を起動して
・新しいファイルIDがきたら(Spreadsheet/Scansnap/シート[PDF]に記載)
・そのファイル名とGoogleDriveのURLを、DiscordのWebhookで伝えるGoogle Apps Script

シート名は[PDF](存在しない場合は新規作成)、WEBHOOK, FOLDER_IDはスクリプトプロパティで取得(ない場合は取得方法をダイアログ表示)、 SpreadsheetIDは自動取得でお願いします。

Scansnapの生成するPDFは「YYYYMMDD_(タイトル).pdf」という形式になります

シート[PDF]のヘッダは

書類日付,作成日時,追加日時,ファイル名,GoogleDriveURL,ファイルID

という順で、書類日付はファイル名の冒頭YYYYMMDD_から取得して「2025/05/31」形式でお願いします。GoogleDriveURLはテキスト形式。

・ユーザーが操作する関数はスプレッドシートのメニュー「Scanner管理」から利用可能にします。

・Discord通知はよりスタイリッシュにemoji満載で
・ユーザがシートを改造・拡張しやすくするため、できるだけセルの列番号をハードコードしない(ヘッダ名称で扱う)。

Claude4の回答

毎回微妙に異なりますがスクリーンショットとしてはこんな感じです。

[Claudeの回答]

スプレッドシートのメニューから操作できるようにしたScanSnap PDF管理システムを作成しました!

🎯 主な機能

📋 スプレッドシートメニュー

スプレッドシートを開くと「ScanSnap管理」メニューが自動で追加されます:

  • 📋 設定: Discord WebhookとGoogle DriveフォルダIDを設定

  • ⚙️ トリガー設定: WatchNewFile()の自動実行を5分間隔で設定

  • 🔍 手動チェック: 即座にファイルチェックを実行

  • 🧪 設定テスト: 設定確認とテスト通知を送信

  • 📊 統計情報: 登録ファイル数や月別統計を表示

  • ❌ トリガー削除: 自動実行を停止

🔧 自動機能

  • 自動監視: 5分間隔でPDFファイルを監視

  • 重複回避: ファイルIDによる重複チェック

  • エラー通知: エラーもDiscordに自動通知

  • 統計管理: 月別の登録統計を自動計算

📊 PDFシート機能

  • 自動作成: 「PDF」シートが存在しない場合は自動作成

  • 整理された記録: 書類日付、作成日時、追加日時など詳細情報を記録

  • リンク機能: GoogleDriveURLをクリック可能なリンクに設定

  • 視覚的な整理: ヘッダー行の色分けと列幅の自動調整

🚀 使用開始手順

  • スプレッドシートにスクリプトを設定

  • Google Apps Scriptエディタに上記コードを貼り付け

  • 保存して実行権限を付与

  • 初期設定

  • スプレッドシートの「ScanSnap管理」→「設定」を選択

  • Discord Webhook URLとGoogle DriveフォルダIDを入力

  • 動作確認

  • 「設定テスト」でDiscord通知を確認

  • 「手動チェック」でファイル検出を確認

  • 自動実行開始

  • 「トリガー設定」で5分間隔の自動監視を開始

これで、ScanSnapで新しいPDFが保存されるたびに、自動的にスプレッドシートに記録され、Discordに通知されます!

インストール方法

さて実際のところ、Google Apps Script (GAS) の開発経験がない方にとっては、Claudeが言っていることもなんだかよくわからないかもしれません。以下、アプリのインストール方法を解説していきます。

①ScanSnapで保存先を設定する

ScanSnapCloudで設定します。
ここでは家族で共有しているGoogle Driveの ScanSnap フォルダです。
このURLに含まれるIDが後程重要になるのでブラウザで開いておいてください。

②通知用のDiscord Webhookを作る

家族もしくは個人で作った管理権限があるDiscordサーバーを準備してください。つぎに通知用のチャンネルを作ります。
(#generalチャンネルで構いません)
ここにWebhookという、Webアクセスの仕組みを使った通知投稿の入り口を作ります。Discord左側の Text Channelsから、該当のチャンネルで「チャンネルの編集」→「連携サービス」でウェブフックを追加します。

すでにお使いのウェブフックがある場合はそちらをお使いいただいて構いませんし、「新しいウェブフック」を設定いただいても構いません。

「ウェブフックURLをコピー」というボタンを押すと、クリップボードに「https://discord.com/api/webhooks/…」という文字列がコピーできます。こちらは後ほど使いますので、どこかに貼り付けておいてください。なお、これはセキュリティ情報ですので、GitHubのソースやWebサイト、掲示板など公開の場所には書かないでください(流出すると、そのチャンネルが書き放題、見放題になります)。

③管理用Google Spreadsheetを作る

ここで作成するスプレッドシートは新規作成でOK。名前も自由です。既存の「家計簿」とか「確定申告」といったシートでも構いません。

④メニューバーの「拡張機能」→「App Script」でスクリプトを編集

拡張機能→「Apps Script」でエディタを開いてスクリプトを貼り付けていきます。

スクリプトはGitHubに用意しておきました
https://j.aicu.ai/AgentScanner

こちらの右にある「Copy raw file」をクリックするとスクリプトがコピーできますのでコピーをして貼り付けて保存します。

⑤ onOpenの実行と初回の実行権限設定

Google Apps Scriptになれた人であればいつもの手順ですが、念の為丁寧に解説しておきます。

無題のプロジェクト 」は自由に変更して保存していただいて構いません。

保存したら、その右隣のアイコンで「デバッグ」を行います。右の開始関数「onOpen」を選んでいたら「デバッグ」ボタンを押して初回実行です。

この時点で何か間違いがあれば表示されます。

初回実行には権限承認が必要です。

まず実行するアカウントを設定します。この場合はスプレッドシートを作った家族のGmailアカウントで構いません。

このあと「このアプリは Google で確認されていません」と表示されますが左下の「詳細」から先へ進みます。

「詳細」を押すと「安全でないページに移動」という雰囲気の良くないメッセージが表示されますが、その先にすすまないと設定できないので確認してください。

このプロジェクトに「全て選択」で権限を設定します。

⑥各種設定と動作確認

ここから先はスプレッドシートの上に「Scanner管理」というメニューが現れます。

まずは「設定」です。

DiscordのWebhookのURLを設定します。

さらに、保存先フォルダのIDを設定します。

PDFを保存しているGoogle Driveのフォルダにアクセスする必要がありますので、お使いのGoogle Driveを表示している状態で、URLをみて「folders/」のあと「1y…」で始まるIDをコピーして貼り付けてください。

次に「トリガー設定」です。

新規のPDFの存在をチェックする「WatchNewFile」という関数が5分間隔で実行されるように設定してあります。

「設定テスト」を行うと「テスト完了」と表示されます。

ここで「手動チェック」を実行しても構いません。

こんな感じに監視フォルダとフォルダIDがDiscordに投稿されれば成功です!

⑦設定の確認

細かい設定を確認したい場合はスクリプトエディタで変更できます。

左側のギアアイコンでプロジェクトの設定を確認し、スクリプトプロパティに「FOLDER_ID」「WEBHOOK」を設定します。

時間トリガーを5分に設定してありますが、こちらは「トリガー」の「時間の間隔を選択」から1分おきなどに変更可能です。

興味のある人は、表示形式やアイコンを変えたり、表示内容を変えたりしてみてみてください。

アイコンを変えたい人は

メッセージの表示を変えたい人は

ChatGPT等のAPIと連動して、中身の分析やフォルダの分類、ファイル名の変更通知などを自動化してみてもいいと思います。

その場合はこのコードを拡張するというよりは、新規でGoogleAppsScript (.gs)を作って、該当のフォルダIDを監視して処理をするようなスクリプトにするといいと思います。Google Apps Scriptには一度に処理できる時間の上限がありますので、AIエージェントとしていい感じに動作するような仕掛けを考えてみてください。
フィードバックは しらいはかせ X@o_ob か、Githubリポジトリまでどうぞ。

https://github.com/aicuai/GenAI-Steam/discussions

まあこのあたりもClaudeに聞くといいとおもいます。
もちろんChatGPTやGemini、Copilotでもいいとおもいます。
開発が一通りできあがったら、運用マニュアルやREADMEなんかも生成して、Driveにおいておくといいですね。レッツ・バイブコーディング!

おまけ:カバーアートのプロンプト

a cheerful family of four (father, mother, son, daughter) happily gathered around a compact mobile scanner (ScanSnap ix100 style) on a table, warm and bright lighting, clean line art, semi-cartoonish style, smiling faces, cozy indoor environment, 16:9 aspect ratio, Japanese illustration style, colored pencil texture, retro-modern feeling

Flux.1[Dev]で数十枚、生成しました。

おまけ2: Geminiによる生成

こちらもほぼ完璧なコードを一発で出してきました。もしかするとClaude4よりもいいかもしれません。さすがGoogle Apps ScriptとGeminiのタッグです。どうして公式に合体しないんだろう。


指定された要件に基づき、ScanSnapでGoogle Driveの特定フォルダに保存された新しいPDFを検知し、スプレッドシートに記録してDiscordに通知するGoogle Apps Scriptを作成します。

シートの列をヘッダー名で動的に扱うため、メンテナンス性が高いコードになっています。

完成したコード

以下のコードをGoogle Apps Scriptエディタにコピー&ペーストして使用してください。

// ===============================================================
// 定数設定
// ===============================================================
const SHEET_NAME = '[PDF]';
const HEADERS = ['書類日付', '作成日時', '追加日時', 'ファイル名', 'GoogleDriveURL', 'ファイルID'];

// ===============================================================
// スプレッドシートのメニューを追加
// ===============================================================
/**
 * スプレッドシートを開いたときにカスタムメニューを追加します。
 */
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('📂 Scanner管理')
    .addItem('⚙️ 初期設定 & 手動実行', 'manualCheckAndSetup')
    .addSeparator()
    .addItem('🕒 トリガーを設定', 'showTriggerSetupInstructions')
    .addToUi();
}

// ===============================================================
// ユーザー操作用の関数
// ===============================================================
/**
 * 手動で実行し、設定が不十分な場合は案内を表示します。
 */
function manualCheckAndSetup() {
  if (checkProperties()) {
    WatchNewFile();
    SpreadsheetApp.getUi().alert('✅ 処理が完了しました。');
  }
}

/**
 * トリガー設定方法の案内を表示します。
 */
function showTriggerSetupInstructions() {
  const message = `
    【時間ベースのトリガー設定方法】
    1. スクリプトエディタの左側にある時計アイコン(トリガー)をクリックします。
    2. 右下の「トリガーを追加」ボタンをクリックします。
    3. 「実行する関数を選択」で「WatchNewFile」を選びます。
    4. 「イベントのソースを選択」で「時間主導型」を選びます。
    5. 「時間ベースのトリガーのタイプを選択」で「分タイマー」や「1時間おき」など、お好みの実行頻度を選びます。
    6. 「保存」をクリックして設定を完了します。
  `;
  SpreadsheetApp.getUi().alert(message);
}


// ===============================================================
// メイン処理(この関数をトリガーで実行)
// ===============================================================
/**
 * 新しいファイルを監視し、シートとDiscordに通知します。
 */
function WatchNewFile() {
  try {
    const properties = PropertiesService.getScriptProperties();
    const FOLDER_ID = properties.getProperty('FOLDER_ID');
    const WEBHOOK_URL = properties.getProperty('WEBHOOK');

    if (!FOLDER_ID || !WEBHOOK_URL) {
      Logger.log('FOLDER_IDまたはWEBHOOKが設定されていません。');
      // トリガー実行時はUI操作できないため、ログ出力に留める
      return;
    }

    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = getOrCreateSheet(ss);
    const headerIndexMap = getHeaderIndexMap(sheet);
    const existingFileIds = getExistingFileIds(sheet, headerIndexMap['ファイルID']);
    const folder = DriveApp.getFolderById(FOLDER_ID);
    const files = folder.getFilesByType(MimeType.PDF);
    
    const newRows = [];
    const newNotifications = [];

    while (files.hasNext()) {
      const file = files.next();
      const fileId = file.getId();

      if (existingFileIds.has(fileId)) {
        continue; // 既に処理済みのファイルはスキップ
      }

      const fileName = file.getName();
      const fileUrl = file.getUrl();
      const createdAt = file.getDateCreated();
      const addedAt = new Date();
      const docDate = parseDocumentDate(fileName);
      
      const rowData = {
        '書類日付': docDate,
        '作成日時': createdAt,
        '追加日時': addedAt,
        'ファイル名': fileName,
        'GoogleDriveURL': fileUrl,
        'ファイルID': fileId
      };
      
      // ヘッダーの順序に従って配列を作成
      const newRow = HEADERS.map(header => rowData[header] || '');
      newRows.push(newRow);

      newNotifications.push({ fileName, fileUrl, docDate });
    }

    if (newRows.length > 0) {
      // データをシートに追加
      sheet.getRange(sheet.getLastRow() + 1, 1, newRows.length, HEADERS.length).setValues(newRows);
      
      // Discordに通知
      newNotifications.forEach(data => {
        sendToDiscord(WEBHOOK_URL, data.fileName, data.fileUrl, data.docDate);
        Utilities.sleep(500); // Discordへの連続投稿のための短い待機
      });
    }

  } catch (e) {
    Logger.log(`エラーが発生しました: ${e.message}\n${e.stack}`);
    // エラー発生をDiscordに通知することも可能
    // sendErrorToDiscord(WEBHOOK_URL, e.message);
  }
}

// ===============================================================
// ヘルパー関数群
// ===============================================================

/**
 * シートが存在しない場合は作成してヘッダーをセットします。
 * @param {Spreadsheet} ss - 対象のスプレッドシート
 * @return {Sheet} 処理対象のシート
 */
function getOrCreateSheet(ss) {
  let sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) {
    sheet = ss.insertSheet(SHEET_NAME);
    sheet.appendRow(HEADERS);
    sheet.setFrozenRows(1);
    // A列を日付形式、E列をテキスト形式に設定
    sheet.getRange('A:A').setNumberFormat('yyyy/mm/dd');
    sheet.getRange('E:E').setNumberFormat('@'); 
  }
  return sheet;
}

/**
 * ヘッダー名と列番号のマップを作成します。
 * @param {Sheet} sheet - 対象のシート
 * @return {Object} {'ヘッダー名': 列番号(1-based), ...}
 */
function getHeaderIndexMap(sheet) {
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  const map = {};
  headers.forEach((header, i) => {
    map[header] = i + 1;
  });
  return map;
}

/**
 * シートに記録済みのファイルIDをSetとして取得します。
 * @param {Sheet} sheet - 対象のシート
 * @param {number} fileIdColumn - ファイルIDが格納されている列番号
 * @return {Set<string>} ファイルIDのセット
 */
function getExistingFileIds(sheet, fileIdColumn) {
  if (sheet.getLastRow() < 2 || !fileIdColumn) {
    return new Set();
  }
  const ids = sheet.getRange(2, fileIdColumn, sheet.getLastRow() - 1, 1).getValues().flat();
  return new Set(ids);
}

/**
 * ファイル名から日付文字列 (YYYY/MM/DD) を抽出します。
 * @param {string} fileName - ファイル名
 * @return {string} "YYYY/MM/DD" 形式の日付文字列。失敗した場合は空文字。
 */
function parseDocumentDate(fileName) {
  const match = fileName.match(/^(\d{8})_/);
  if (match && match[1]) {
    const yyyy = match[1].substring(0, 4);
    const mm = match[1].substring(4, 6);
    const dd = match[1].substring(6, 8);
    return `${yyyy}/${mm}/${dd}`;
  }
  return '';
}

/**
 * Discordにスタイリッシュな通知を送信します。
 * @param {string} webhookUrl - DiscordのWebhook URL
 * @param {string} fileName - ファイル名
 * @param {string} fileUrl - Google DriveのURL
 * @param {string} docDate - 書類の日付
 */
function sendToDiscord(webhookUrl, fileName, fileUrl, docDate) {
  const payload = {
    embeds: [{
      title: "📄✨ 新しいドキュメントが届きました!",
      description: `**[${fileName}](${fileUrl})**`,
      color: 3447003, // Blue
      fields: [
        { name: "🗓️ 書類日付", value: docDate || '(不明)', inline: true },
        { name: "🚀 アクション", value: `[ファイルを開く](${fileUrl})`, inline: true }
      ],
      footer: {
        text: "Scansnap 自動通知",
        icon_url: "https://i.imgur.com/b6Qy6v1.png" // Google Drive icon
      },
      timestamp: new Date().toISOString()
    }]
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload)
  };

  UrlFetchApp.fetch(webhookUrl, options);
}

/**
 * スクリプトプロパティが設定されているか確認し、なければダイアログで案内します。
 * @return {boolean} プロパティが正常に設定されていれば true
 */
function checkProperties() {
  const properties = PropertiesService.getScriptProperties();
  const FOLDER_ID = properties.getProperty('FOLDER_ID');
  const WEBHOOK_URL = properties.getProperty('WEBHOOK');

  if (!FOLDER_ID || !WEBHOOK_URL) {
    const ui = SpreadsheetApp.getUi();
    const message = `
      スクリプトのプロパティが設定されていません。
      
      【設定方法】
      1. スクリプトエディタの左メニューから歯車アイコン(プロジェクトの設定)をクリックします。
      2. 「スクリプト プロパティ」のセクションで「スクリプト プロパティを編集」をクリックします。
      3. 以下の2つのプロパティを追加してください。

      ・プロパティ名: FOLDER_ID
        値: ScanSnapのPDFが保存されるGoogle DriveフォルダのID
        (フォルダURLの .../folders/ に続く部分)

      ・プロパティ名: WEBHOOK
        値: 通知を送りたいDiscordチャンネルのWebhook URL

      設定後に再度メニューから実行してください。
    `;
    ui.alert('⚠️ 初期設定が必要です', message, ui.ButtonSet.OK);
    return false;
  }
  return true;
}

導入と設定の手順

1. スクリプトの準備

  1. このスクリプトを使用したいスプレッドシートを開き ます。

  2. 上部メニューの「拡張機能」>「Apps Script」を選択して、スクリプトエディタを開きます。

  3. エディタに表示されている既存のコードをすべて削除し、上記のコードを貼り付けて保存します(💾アイコンをクリック)。

2. スクリプトプロパティの設定

コード内で直接IDやURLを記述するのではなく、安全な「スクリプトプロパティ」機能を使います。

  1. スクリプトエディタの左側メニューから歯車アイコン(プロジェクトの設定)をクリックします。

  2. 一番下にある「スクリプト プロパティ」というセクションで、「スクリプト プロパティを編集」ボタンをクリックします。

  3. 「プロパティを追加」 を2回クリックし、以下の2つの項目を設定します。

    • プロパティ 1

      • プロパティ : FOLDER_ID

      • : ScanSnapのPDFが保存されるGoogle DriveフォルダのIDを入力します。

        • IDの確認方法 : 該当フォルダをブラウザで開いたときのURL https://drive.google.com/drive/folders/xxxxxxxxxxxxx の xxxxxxxxxxxxx の部分です。
    • プロパティ 2

      • プロパティ : WEBHOOK

      • : 通知を送信したいDiscordチャンネルのWebhook URLを貼り付けます。

        • Webhook URLの取得方法 : Discordのチャンネル設定 >「連携サービス」>「ウェブフックを作成」から取得できます。
  4. 「スクリプト プロパティを保存」 をクリックします。

3. 初回実行と権限の承認

  1. スプレッドシートに戻り、ページを再読み込みすると「📂 Scanner管理」というカスタムメニューが表示されます。

  2. 📂 Scanner管理 > ⚙️ 初期設定 & 手動実行 をクリックします。

  3. 「承認が必要です」というダイアログが表示されるので、「続行」 をクリックします。

  4. 自分のGoogleアカウントを選択し、「詳細」>「(安全でないページ)に移動」をクリックして、表示される権限(Googleスプレッドシート、Google Drive、外部サービスへの接続)を「許可」します。

  5. これで初回セットアップは完了です。[PDF]というシートが自動で作成され、ヘッダーが入力されます。

4. 時間ベースのトリガーを設定

スクリプトを定期的に自動実行するための設定です。

  1. スクリプトエディタの左側メニューから時計アイコン(トリガー)をクリックします。

  2. 右下の「トリガーを追加」ボタンをクリックします。

  3. 以下の通りに設定します。

    • 実行する関数を選択 : WatchNewFile

    • 実行するデプロイを選択 : Head

    • イベントのソースを選択 : 時間主導型

    • 時間ベースのトリガーのタイプを選択 : 分タイマー や 1時間おき など

    • 時間の間隔を選択 : 10分おき や 1時間おき など、お好みの頻度を選択します。

  4. 「保存」をクリックします。

以上で全ての設定は完了です。以降、指定したフォルダに新しいPDFが保存されると、設定した間隔でスクリプトが自動実行され、スプレッドシートへの記録とDiscordへの通知が行われます。


Originally published at note.com/aicu on July 8, 2025.