整理整頓大好き人間(だけど片付けは苦手)

子供が小学校に入ると、小学校、PTA、学童、習い事など、各方面からどんどんプリントがやってくる。電子化大好きなので、「スキャンして電子化したらいいやん!」って思ってScanSnapを買ったものの、運用を続けるうちに日々の忙しさにかまけてモチベはどんどん下がり、気がついたら、「Scan」フォルダには分類されていないプリントの山が出来上がっていた。

しかし、世は大AI時代。助けてぇ、Gemini〜!って泣きついたら、何とかなった。

出来上がった仕組みはこういう感じ。紙のプリントをスキャンしたり、スマホで撮ったりして、指定フォルダにアップロードすると、Geminiが内容を解析し、適切な名前をつけて、自動で振り分けてくれる。AIの辞書に三日坊主はないので、設定さえすれば、ずっとずっと、自分の代わりに書類を整理してくれるってわけ。最高。

ちなみに他のAIではなくGeminiに相談した理由は、GoogleドライブやGoogle Apps Scriptとの相性が良さそうだったから。GoogleのことはGeminiに聞けって、じいちゃんが言ってたからね。

はじめに

現在の運用は、ScanSnapでプリントをスキャンすると、PDF化されてGoogleドライブの「Scan」フォルダに自動でアップロードされる。それを、定期的に目視で確認し、内容によって「小学校」「PTA」「学童」などのフォルダに手動で区分していく。という感じ。なのだけど・・・

0504_」二2年組名前(JII.pdf
20260506_なまえノ肱あ淌.pdf

これは何のプリントなんだ……。

ScanSnapでプリントをスキャンした時、内容に合わせてファイル名を設定してくれるのだけど、その名付けがうまくいかないことも多い。一見して何なのかわからないファイルでフォルダの中がぐちゃぐちゃになってしまうことも、整理のモチベを下げる大きな一因だった。

なので、Geminiで内容を解析して、分かりやすくリネームしてもらう。

0504_」二2年組名前(JII.pdf
 → 20260504_小学校_週時程_2年1組.pdf
20260506_なまえノ肱あ淌.pdf
 → 20260506_小学校_音読カード_2年_4月.pdf

そして、内容に応じて「小学校」や「PTA」などプレフィックスをつけて、どこからのプリントなのか把握しやすくする。処理が終わったプリントは、テーマごとにフォルダ分けしてもらって、「Scan」フォルダのクリーンな状態を保つ。そして、「あー、僕って整理整頓大好きな人なんだよね、実は。」みたいに、自尊心を取り戻す。そういう仕組みにしようと思った。

コストについて

GeminiのAPIを叩くので、従量課金でコストは発生する。処理する枚数によって変わるとは思うけど、僕の場合は、Gemini 2.5 Proで1ヶ月くらい試してみて、月500円も行かないくらいだった。Google AI Proには毎月$10分のGoogle Cloudクレジットが付いてくるので、その枠内で十分賄えそうだ。

作り方

ステップ1:フォルダと仕分けルールの準備(Googleドライブ)

まずは、Googleドライブ上に必要なフォルダと辞書ファイルを作る。

  1. フォルダを作る: 投げ込み用の「Scan」フォルダと、仕分け先となるフォルダ(「小学校」「PTA」など)を作る。
  2. 辞書ファイルを作る: 後述するスプレッドシートのひな型をコピーして、「このキーワードが書いてあったら、このフォルダに入れる」という仕分けルールのリスト(辞書)を作る。
  3. APIキーを取得する: Google AI Studio(またはGoogle Cloud)で「Gemini APIキー」を発行する。

ステップ2:コードをコピペ(Google Apps Script)

次に、システムを動かす頭脳を設定する。

  1. GASを開く: Googleドライブから「Google Apps Script(GAS)」のファイルを新規作成して開く。
  2. コードを貼る: 後述のGAS用コードを、そのまま全選択してコピペする。
  3. 設定用のIDを登録する: GAS画面の左側にある「歯車マーク(設定)」を開き、一番下の「スクリプトプロパティ」に、ステップ1で準備した「APIキー」や「フォルダID」などを入力して保存ボタンを押す。

入力例

プロパティ
GEMINI_API_KEY (ステップ1で取得したAPIキー)
DICTIONARY_FILE_ID (辞書ファイルのスプレッドシートID)
SCAN_FOLDER_ID (Step 1のScanフォルダID)
DEFAULT_DEST_ID (該当がない場合の移動先フォルダID )
GEMINI_MODEL_NAME (gemini-2.5-pro / gemini-3.5-flash など)
  • スプレッドシートID: URLの d//edit の間に挟まれた文字列
  • フォルダID: URLの folders/ の後ろにある文字列

ステップ3:スイッチ・オンと全自動化(初回承認とタイマー)

最後に、プログラムにアクセス許可を与えて、自動で動くようにする。

  1. 初回テスト: GASの画面で「実行」ボタンを押す。初回だけGoogleのセキュリティ警告(アクセス承認)が出るので、画面の指示に従って自分のアカウントでのアクセスを許可する。
  2. タイマーをセット: GASの左メニューにある時計マーク(トリガー設定)を開き、「1時間おきに動かす」というタイマーをセットする。もちろん、時間は自分の用途に応じて変更しても大丈夫だ。

完成!

あとは、日常で紙のプリントをもらったら、スキャンしたり、スマホで撮ったりして、「Scan」フォルダに放り込むだけ。しばらくすると、Geminiが勝手にファイル名をきれいに整え、指定のフォルダに片付けておいてくれる。

ちなみに、PDFでなくても、写真やスクショを「Scan」フォルダに入れても処理できる。学校からの連絡は、紙のプリント以外にも、アプリやメールなど、いろいろな経路でやってくるので。

おまけ

これは作ってみるまで想定していなかったのだけど、Googleドライブに置いてるってことは、Geminiが読めるってこと。試しに給食の献立を聞いてみたら、いい感じに教えてくれた。学校からの連絡を何でも覚えている人、みたいになってくれそう。表や図になっている情報は、たまに怪しい情報を返してきたりするけど、勘所を掴めば、なかなか便利に使えそうだ。

5/18の給食の献立は?

Googleドライブでプリントが格納されているフォルダごと「プロジェクト」にしておけばスムーズ。

付録

ファイル分類用辞書ファイル(スプレッドシート)

こちらをコピーして、内容を書き換えて使用すると良い。検索キーワードはカンマ区切りで入力。自分で入力しても良いけど、予めスキャンしておいたプリントを何枚かAIに読んでもらって、いい感じのキーワードを提案してもらうと簡単。

📥 スキャンファイル分類辞書(サンプル)

GAS(Google Apps Script)用コード

このままコピペして使用できる。

// ==========================================
// 1. 設定の読み込み処理
// 事前にGASの「スクリプトプロパティ」に設定してください
//
// 必須:
// - GEMINI_API_KEY
// - DICTIONARY_FILE_ID
// - SCAN_FOLDER_ID
// - DEFAULT_DEST_ID
//
// 任意:
// - GEMINI_MODEL_NAME
//
// 注意:
// OCR変換に Drive.Files を使うため、
// 「サービス」から Advanced Drive Service を有効化してください。
// ==========================================

function getProperties() {
  const props = PropertiesService.getScriptProperties();

  return {
    apiKey: props.getProperty('GEMINI_API_KEY'),
    dictId: props.getProperty('DICTIONARY_FILE_ID'),
    scanId: props.getProperty('SCAN_FOLDER_ID'),
    defaultId: props.getProperty('DEFAULT_DEST_ID'),
    modelName: props.getProperty('GEMINI_MODEL_NAME') || 'gemini-3.5-flash'
  };
}

// ==========================================
// 2. メイン処理
// ==========================================

function main() {
  const lock = LockService.getScriptLock();

  if (!lock.tryLock(10000)) {
    console.log('別の処理が実行中です。');
    return;
  }

  try {
    const props = getProperties();

    if (!props.apiKey || !props.dictId || !props.scanId || !props.defaultId) {
      console.error('【エラー】スクリプトプロパティが不足しています。');
      return;
    }

    const startTime = Date.now();
    const TIME_LIMIT = 5 * 60 * 1000;

    const scanFolder = DriveApp.getFolderById(props.scanId);
    const files = scanFolder.getFiles();
    const dictData = getDictionaryData(props.dictId);

    while (files.hasNext()) {
      if (Date.now() - startTime > TIME_LIMIT) {
        console.log('【一時停止】実行から5分経過。再度「実行」を押してください。');
        break;
      }

      const file = files.next();
      const type = file.getMimeType();

      if (!isSupportedFileType(type)) {
        continue;
      }

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

      try {
        processFile(file, dictData, props);
      } catch (e) {
        console.error(`エラー発生 (${file.getName()}): ${e.stack || e.message}`);
      }
    }
  } finally {
    lock.releaseLock();
  }
}

function isSupportedFileType(mimeType) {
  return (
    mimeType === MimeType.PDF ||
    mimeType === MimeType.JPEG ||
    mimeType === MimeType.PNG
  );
}

// ==========================================
// 3. ファイル処理
// ==========================================

function processFile(file, dictData, props) {
  const textContent = extractTextWithOcr(file);

  if (!textContent || !textContent.trim()) {
    throw new Error('OCR結果が空です。分類できないためスキップします。');
  }

  const MAX_TEXT_CHARS = 20000;
  const trimmedText = textContent.slice(0, MAX_TEXT_CHARS);

  const today = Utilities.formatDate(
    new Date(),
    Session.getScriptTimeZone(),
    'yyyyMMdd'
  );

  const originalFileName = file.getName();
  const originalExt = getFileExtension(originalFileName);

  const prompt = `あなたはプロの文書整理アシスタントです。
以下の「分類リスト」と「抽出テキスト」を分析して、最適なフォルダIDを選択し、新しいファイル名を決定してください。

# 分類リスト
JSON形式: [キーワード, プレフィックス, フォルダID]

${dictData.jsonString}

# 抽出テキスト
${trimmedText}

# 元のファイル名
${originalFileName}

# 指示
- 分類リストの各行から、内容に最も合致するものを1つ選んでください。
- リストに該当がない場合は、folderIdを "${props.defaultId}" にしてください。
- newNameは「YYYYMMDD_プレフィックス_内容_補足」の形式にしてください。
- newNameには拡張子を含めないでください。
- 日付が不明な場合は本日(${today})を使用してください。
- 使用できない文字や改行は含めないでください。`;

  const responseText = callGemini(prompt, props);
  const result = parseGeminiResult(responseText);

  let destFolderId = result.folderId;

  if (!dictData.validFolderIds.includes(destFolderId) && destFolderId !== props.defaultId) {
    console.warn(`不正なフォルダIDが返されました(${destFolderId})。デフォルトフォルダに移動します。`);
    destFolderId = props.defaultId;
  }

  const destFolder = DriveApp.getFolderById(destFolderId);

  const baseName = sanitizeFileName(result.newName);

  if (!baseName) {
    throw new Error('生成されたファイル名が空です。');
  }

  let safeName = baseName + originalExt;
  safeName = avoidDuplicateFileName(destFolder, safeName);

  if (result.newName + originalExt !== safeName) {
    console.warn(`ファイル名を調整しました: ${result.newName + originalExt} → ${safeName}`);
  }

  // 移動後にリネーム失敗する半端状態を避けるため、先に名前を変更
  file.setName(safeName);
  file.moveTo(destFolder);

  console.log(`移動完了: ${safeName}`);
}

// ==========================================
// 4. 辞書取得
// ==========================================

function getDictionaryData(dictId) {
  const sheet = SpreadsheetApp.openById(dictId).getSheets()[0];
  const values = sheet.getDataRange().getValues();

  const dataRows = values
    .slice(1)
    .filter(row => row[0] && row[1] && row[2])
    .map(row => [
      String(row[0]),
      String(row[1]),
      String(row[2])
    ]);

  const validFolderIds = dataRows.map(row => row[2]);

  return {
    jsonString: JSON.stringify(dataRows),
    validFolderIds
  };
}

// ==========================================
// 5. OCR
// ==========================================

function extractTextWithOcr(file) {
  const resource = {
    name: file.getName(),
    mimeType: MimeType.GOOGLE_DOCS
  };

  let docId = null;

  try {
    const doc = Drive.Files.create(resource, file.getBlob());
    docId = doc.id;

    const docFile = DocumentApp.openById(docId);
    return docFile.getBody().getText();
  } finally {
    if (docId) {
      Drive.Files.remove(docId);
    }
  }
}

// ==========================================
// 6. Gemini API
// ==========================================

function callGemini(prompt, props) {
  const url =
    `https://generativelanguage.googleapis.com/v1beta/models/${props.modelName}:generateContent?key=${props.apiKey}`;

  const payload = {
    contents: [
      {
        parts: [
          {
            text: prompt
          }
        ]
      }
    ],
    generationConfig: {
      responseMimeType: 'application/json',
      responseSchema: {
        type: 'OBJECT',
        properties: {
          folderId: {
            type: 'STRING'
          },
          newName: {
            type: 'STRING'
          }
        },
        required: ['folderId', 'newName']
      }
    }
  };

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

  const response = UrlFetchApp.fetch(url, options);
  const status = response.getResponseCode();
  const body = response.getContentText();

  if (status < 200 || status >= 300) {
    throw new Error(`Gemini APIエラー: ${status} ${body}`);
  }

  const json = JSON.parse(body);

  if (!json.candidates || json.candidates.length === 0) {
    throw new Error('Gemini候補が返されませんでした: ' + body);
  }

  const text = json.candidates?.[0]?.content?.parts?.[0]?.text;

  if (!text) {
    throw new Error('Geminiからの応答本文が空です: ' + body);
  }

  return text;
}

function parseGeminiResult(responseText) {
  let result;

  try {
    result = JSON.parse(responseText);
  } catch (e) {
    throw new Error('GeminiのJSON解析に失敗しました: ' + responseText);
  }

  if (!result.folderId || !result.newName) {
    throw new Error('GeminiのJSONに必要な項目がありません: ' + responseText);
  }

  return result;
}

// ==========================================
// 7. ファイル名処理
// ==========================================

function getFileExtension(fileName) {
  const match = String(fileName).match(/\.[^.]+$/);
  return match ? match[0].toLowerCase() : '';
}

function sanitizeFileName(name) {
  return String(name)
    .normalize('NFC')
    .replace(/[\\/:*?"<>|\r\n]/g, '_')
    .replace(/[\u0000-\u001F\u007F]/g, '')
    .replace(/\s+/g, ' ')
    .trim()
    .replace(/\.+$/g, '')
    .slice(0, 160);
}

function avoidDuplicateFileName(folder, fileName) {
  if (!folder.getFilesByName(fileName).hasNext()) {
    return fileName;
  }

  const ext = getFileExtension(fileName);
  const base = ext ? fileName.slice(0, -ext.length) : fileName;

  for (let i = 2; i <= 999; i++) {
    const candidate = `${base}_${i}${ext}`;

    if (!folder.getFilesByName(candidate).hasNext()) {
      return candidate;
    }
  }

  throw new Error('重複しないファイル名を作成できませんでした: ' + fileName);
}

ガイド用プロンプト

一人でやるのは不安という人は、以下のプロンプトを使えば、AIに設定をサポートしてもらえる。Gemini 用に作ったものだけど、Gemini以外でも使えるはず。多分。

この続きはcodocで購入