📄

ScanSnap と AI で家庭内プリント地獄をサバイブする

に公開

皆さん、書類・プリントと戦っていますか?私は戦っています。特に生活環境が色々と変わってきて対応する書類・プリント・配布物が圧倒的に増えました。データソースもフォーマットも様々で、これらを統合的に管理するのは無理と思っていましたがいくつかのツールを組み合わせることでどうにか乗り切ることができました。

最終的に例えば「週明けに必要な持ち物ってなんだっけ?」って思ったらチャット欄でそれをAIに聞くことで「週明けはいつものセットに加えて、締め切りが近い書類に記入するのと、〇〇円の現金が必要です」と言った情報を教えてくれます。たくさんのプリントを書類ケースに入れておいて、それをひっくり返して探す手間がなくなります。

この記事ではその方法を紹介します。

登場人物たち

ドキュメントスキャナ

配布された書類をデータ化するハードウェアが必要です。いくつか選択肢があります。

ScanSnap

家庭用ドキュメントスキャナとしてはデファクトスタンダードだと思います。私はix1300を購入しました。正直、ちょっと高い。でも圧倒的な書類スキャン速度と精度を体験しました。

ScanSnap ix1300

iPhone, Android の Google Driveアプリ

Google Driveアプリでもファイルスキャンが可能です。スマホのカメラから書類を検出して保存します。ドキュメントスキャナを買うほどでもない・・・でもワークフローを試してみたい場合に使えます。私も以前はこれを使っていましたがファイルの数が増えると作業量が大変でした。

コンビニのスキャナ

コンビニにあるマルチコピー機でもスキャン可能です。書類の数が多いけどスマホでやるには大変、でもScanSnapは高い・・・というケースで使いました。コンビニのスキャナも便利ですが、数十枚の書類を持って店舗に行き、1枚数十円のコストをかけ、スキャン後に原本を忘れずに持ち帰る……といった運用コストと情報漏洩リスクを考えると、日常的なワークフローには不向きでした。

クラウドストレージ(Google Drive)

選択肢が色々とあることは知っていますが、Notebook LMとの連携を考えるとGoogle Drive一択です。今回、最終的にGoogle Docsに変換するため認証や実行環境、保存場所を一括して管理できます。

ナレッジベース(Notebook LM)

これが利用できることがワークフローの肝と言えます。大容量のデータソースから必要な情報を抽出、集約、テキストで出力してくれます。ありとあらゆる文章を単一の場所で情報化できます。

自動化処理の実行環境(Google Apps Script)

こちらもGoogle Driveを使う関係上ほぼ一択です。もちろん知識があればGoogle Cloud上にCloud Run functionsを構築するなども可能ですが、そこまでは不要と判断しました。ドキュメントスキャナーでロードしたPDFをテキスト化、Notebook LMのデータソースに注入するための処理を統括します。

AIモデル(Gemini 3.1 Flash Lite Preview)

こちらに関しては何でもいいです。Google Apps Scriptを使っていますがAPIも統合されていないのでRest APIを呼び出しています。ただ、無料枠で十分使えるので今回はGemini 3.1 Flash Lite Previewを使いました。

ワークフローの説明

geminiにビジュアライゼーションしてもらいました。

ドキュメントスキャン

ドキュメントスキャナを使ってスキャンします。ScanSnapを使っていますが色は白黒、画質は自動のPDFにしています。

クラウドアップ保存

Google Driveにアップロードします。ScanSnapの場合、事前に設定しておくと本体の電源を入れてスキャンボタンを押してスキャンするだけでGoogle Driveの所定のフォルダに格納されます。

AI 処理

Google Apps Script を使ってファイルのロード、AIで処理、マスタデータに記録、ファイルを移動します。これについてはソースコードも交えて後で解説します。

マスタ記録

単一のGoogle Docsに記録します。こちらも後述しますがNotebook LMの制約上単一のGoogle Docsに記録をしていくことで作業が楽になります。

知識同期

Notebook LM上でUIを操作してGoogle Docsの内容をリロードします。ちょっとした作業ですがNotebook LMがAPIを用意していないのでこの手順は手動です。

GASによるワークフローのオーケストレーション

GASの仕事を整理すると以下です。

アップロードされたファイルのリストアップ

事前に設定していたフォルダ内のpdfファイルをリストアップします。

1ファイルずつGemini APIを呼び出してMarkdown化、タイトル付け

アップロードされたファイルはそのままだとScanSnap内蔵のOCRでファイル名が付けられています。Gemini APIでファイルを読み込みMarkdownに変換するタイミングでファイル内容に合ったタイトルを適切に設定させます。

このとき、Structured Outputを使ってタイトルと本文だけをjson文字列でレスポンスさせます。これによって、Geminiが「以下がファイルを読み込んで生成したMarkdownです」のような余計な一文を表示しなくなります。

なお、Gemini APIの無料枠で収まるようにするため1回のGASトリガーで処理するファイルの数を制限していますし、また次のファイルを処理する前にクールダウンを入れています。GASの1トリガー当たりの実行可能時間は6分に到達しないためにも処理するファイルの上限は大切です。

また、Gemini APIは課金を有効化してロードされたファイルの内容を学習しないようにしています。

Google Docsに追記する

Notebook LMが参照しているGoogle Docsに追記します。単一のGoogle Docsに書き出していますが年度ごとにファイルを更新する予定です。それには以下の理由があります。

NotebookLMの「ソース上限」の回避

NotebookLMには「1ノートブックにつき最大50ソースまで」という制限があります。プリントごとに1ファイルを作成してしまうと、数週間で上限に達して破綻しますが、単一ドキュメントに集約することでこの制限を完全に無効化できます。

Google Docsの「102万文字制限」の回避

Googleドキュメントには1ファイルあたり約102万文字(またはファイルサイズ)の物理的な上限があります。永続的に1つのファイルに追記し続けるといつか必ず書き込みエラーでシステムが停止するため、年度という論理的な区切りでファイルをローテーションする安全設計です。

オリジナルファイルの待避

スキャンした書類には図表が含まれている場合があります。残念ながらテキストベースのMarkdownに変換する都合上これらの情報は欠落します。オリジナルのファイルを見る必要が出たときのためにGoogle Docsにはファイルへのリンクを挿入。ファイル自体も所定のGoogle Drive内のフォルダに移動させます。

これらの機能を実装したGASのコードが以下です。

/**
 * NotebookLM向け 外部脳構築スクリプト (GAS) - v4 Geminiネイティブ版
 * - 複数ページPDFをそのままGeminiに解析させます。
 * - ページ跨ぎの表組みのコンテキストを維持します。
 * - 1分間のトリガーで「最大5ファイル」を処理し、15 RPM制限を安全に回避します。
 */

const CONFIG = {
  INBOX_FOLDER_ID: 'YOUR_INBOX_FOLDER_ID',
  ARCHIVE_FOLDER_ID: 'YOUR_ARCHIVE_FOLDER_ID',
  MASTER_DOC_DIR_ID: 'YOUR_MASTER_DOC_DIR_ID',
  GEMINI_API_KEY: 'YOUR_API_KEY'
};

function processScannedDocuments() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(60000)) return; // 排他制御

  try {
    const inboxFolder = DriveApp.getFolderById(CONFIG.INBOX_FOLDER_ID);
    const archiveFolder = DriveApp.getFolderById(CONFIG.ARCHIVE_FOLDER_ID);
    const files = inboxFolder.getFiles();

    if (!files.hasNext()) return;

    const masterDoc = getCurrentYearDocument();
    const body = masterDoc.getBody();
    
    // 変数名を明確化:ページ数ではなく「ファイル数」のカウンター
    let processedFileCount = 0;
    const MAX_FILES_PER_RUN = 5; // 1回で最大5ファイル (APIの15RPM制限に対し安全なバッファ)

    while (files.hasNext()) {
      if (processedFileCount >= MAX_FILES_PER_RUN) {
        console.log(`処理制限(${MAX_FILES_PER_RUN}ファイル)に達しました。残りは次回処理します。`);
        break;
      }

      const file = files.next();
      const mimeType = file.getMimeType();
      const fileName = file.getName();

      // GASのUrlFetchAppのペイロード制限(50MB)の安全策として30MB以上は弾く
      if (file.getSize() > 30 * 1024 * 1024) {
        console.error(`⚠️ サイズ超過: ${fileName} は30MBを超えているため処理できません。`);
        continue; 
      }

      if (mimeType !== MimeType.PDF && !mimeType.startsWith('image/')) continue;

      console.log(`処理開始: ${fileName}`);

      try {
        // PDFをGeminiに投げる(Markdownと新しいファイル名が返ってくる)
        const geminiResult = doOcrWithGemini(file.getBlob(), fileName);
        
        // 1. ファイルのリネーム
        const cleanFileName = geminiResult.suggested_filename.replace(/[\\/:*?"<>|]/g, '_'); // 禁則文字を念のため除去
        const newFileName = `${cleanFileName}.pdf`;
        file.setName(newFileName); // Googleドライブ上のファイル名を変更

        // 2. ファイルのURLを取得
        const fileUrl = file.getUrl();

        // 3. マスタードキュメントへ追記(直リンク付き)
        const timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm");
        body.appendParagraph(`\n\n=========================================`);
        body.appendParagraph(`📝 追加日時: ${timestamp} | 📄 ${newFileName}`);
        body.appendParagraph(`🔗 オリジナルファイルを開く: ${fileUrl}`); 
        body.appendParagraph(`=========================================`);
        body.appendParagraph(geminiResult.extracted_markdown);

        // ファイルをアーカイブへ移動
        file.moveTo(archiveFolder);
        processedFileCount++;
      } catch (e) {
        console.error(`⚠️ エラー [${fileName}]: ${e.message}`);
      }
    }
  } finally {
    lock.releaseLock();
  }
}

function doOcrWithGemini(blob, baseName) {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent?key=${CONFIG.GEMINI_API_KEY}`;
  
  const base64Data = Utilities.base64Encode(blob.getBytes());
  const mimeType = blob.getContentType() || 'application/pdf';

  // プロンプトを汎用化
  const promptText = `
  このファイルはスキャンされた書類です。数ページに及ぶ場合があります。
  1. 内容を読み取り、構造化されたMarkdownテキストに変換してください。
  2. このファイルの内容を端的に表す、後から検索しやすいファイル名(拡張子なし)を考案してください。(例:202604_月間プロジェクト報告書、2026年度_総会資料 など)
  `;

  const payload = {
    "contents": [{
      "parts": [
        { "text": promptText },
        { "inline_data": { "mime_type": mimeType, "data": base64Data } }
      ]
    }],
    "generationConfig": {
      "responseMimeType": "application/json",
      "responseSchema": {
        "type": "OBJECT",
        "properties": {
          "suggested_filename": {
            "type": "STRING",
            "description": "内容を端的に表すファイル名(拡張子は含めないこと)"
          },
          "extracted_markdown": {
            "type": "STRING",
            "description": "抽出・構造化されたMarkdownテキストのみを出力。"
          }
        },
        "required": ["suggested_filename", "extracted_markdown"]
      }
    }
  };

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

  const MAX_RETRIES = 5;

  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    const response = UrlFetchApp.fetch(url, options);
    const responseCode = response.getResponseCode();
    
    if (responseCode === 200) {
      const responseText = response.getContentText();
      const responseJson = JSON.parse(responseText);
      
      if (responseJson.usageMetadata) {
        const usage = responseJson.usageMetadata;
        console.log(`📊 トークン消費 [${baseName}]: 入力 ${usage.promptTokenCount} / 出力 ${usage.candidatesTokenCount} / 合計 ${usage.totalTokenCount}`);
      }

      const generatedContent = responseJson.candidates[0].content.parts[0].text;
      const structuredData = JSON.parse(generatedContent); 
      Utilities.sleep(4000); // 15 RPM制限対策
      return structuredData;
    }
    
    if (responseCode === 429 || responseCode >= 500) {
      console.warn(`API一時エラー(${responseCode})。サーバー混雑のため ${attempt}回目のリトライ待機中...`);
      Utilities.sleep(5000 * Math.pow(2, attempt - 1)); 
      continue;
    }

    throw new Error(`API Error: ${responseCode} - ${response.getContentText()}`);
  }
  throw new Error("APIリトライ上限超過。しばらく時間を置いてから再度お試しください。");
}

function getCurrentYearDocument() {
  const now = new Date();
  const year = now.getMonth() >= 3 ? now.getFullYear() : now.getFullYear() - 1;
  const docName = `外部脳_${year}年度`;
  const dir = DriveApp.getFolderById(CONFIG.MASTER_DOC_DIR_ID);
  const files = dir.getFilesByName(docName);

  if (files.hasNext()) return DocumentApp.openById(files.next().getId());
  
  const newDoc = DocumentApp.create(docName);
  DriveApp.getFileById(newDoc.getId()).moveTo(dir);
  const body = newDoc.getBody();
  body.appendParagraph(`🧠 ${year}年度 外部脳データ`);
  return newDoc;
}

まとめと課題

ドキュメントスキャナとクラウドストレージ、GAS、AIを使ってNotebook LM上に読み込んだファイルに基づくナレッジを構築する方法を紹介しました。

これによって解決した課題は

  • ファイルを整理する手間。どこにファイルを置くのか考えてそれを探す
  • ファイルの中に書かれた文章を探す手間。ドキュメントのどこに自分が知りたい情報があるのかをドキュメントを横断的に探すことができる。

ただ、以下の課題を感じています。

あくまでナレッジなので情報の収集は今のところ人間主体のパッシブなデータです。欲を言うなら「明日までに〇〇を準備しておいてください」のようにアクティブに準備物や行動予定を通知してくれると良いですがそれはNotebook LMでは実現できません。Geminiがファイルをロードするときにリマインダーを作るとかで解決できるかもしれませんがちょっと規模が大きくなりそうです。

そう言った課題も感じつつ、それでも状況の改善ができました。

Discussion