GoogleDriveの特定のフォルダに保存された画像やPDFをAI解析してファイル名を設定するGoogleAppsScript

郵便物や買い物のレシート、お店や病院などで貰った資料やパンフレットなどを、スキャナで読み取ってGoogleDriveに保存するようにしているが、スキャナメーカーの自動連携ではファイル名がゴミ(OCRの限界)なので、Geminiに解析してファイル名を付けてもらおう!という思いつきで作成したGoogleAppsScript。

必要なのは GoogleDrive の対象フォルダのID、GeminiAPIのAPIキー。
処理成功したファイル名には[DONE]を付与し、エラーが起きたファイル名には[ERR]を付与。[DONE]も[ERR]も付いていないファイルを処理対象とする。
対象フォルダ以下を再帰的に捜索するので、サブフォルダを掘ってあってもファイルを処理可能。

コード後半のGemini宛てのプロンプトや受け渡しのJSONフォーマットをカスタマイズすれば、もっと複雑な処理や情報の抽出も可能。

GoogleAppsScript本体

// 固定値の定義
const scriptProperties = PropertiesService.getScriptProperties();
const MODEL_NAME = "gemini-2.5-flash-lite"; // 使用するモデル
const MAX_BASE_NAME_LENGTH = 80; // ファイル名(日付と拡張子除く)の最大文字数
const MAX_PROCCESS_FILES = 30; // 一回のバッチで処理するファイル数(スキップファイルは除く)

// GASプロジェクト設定のスクリプトプロパティから情報を取得(APIキー類のハードコーディングは禁止)
const GEMINI_API_KEY = scriptProperties.getProperty('GeminiKey'); // Geminiで発行したAPIキー
const TARGET_FOLDER_ID = scriptProperties.getProperty('TargetFolderID'); // 処理対象のGoogleDriveフォルダのID

// ==========================================================
// 1. メイン関数(トリガーとして実行される)
// ==========================================================
function checkAndRenameFiles() {
  const folder = DriveApp.getFolderById(TARGET_FOLDER_ID);

  // 再帰的に全ファイルを取得
  let allFiles = [];
  getAllFilesRecursively(folder, allFiles);
  Logger.log(`Total files found in all folders: ${allFiles.length}`);

  let processedCount = 0;
  for (let i = 0; i < allFiles.length; i++) {
    if (processedCount >= MAX_PROCCESS_FILES) {
      Logger.log("バッチ処理件数の上限に達したため終了します。");
      break;
    }
    const file = allFiles[i];
    const fileName = file.getName();

    // Processed や Error のタグが付いていないファイル(未処理ファイル)を探す
    if (!fileName.includes("[DONE]") && !fileName.includes("[ERR]")) {
      processedCount++;
      Logger.log(`Processing file: ${fileName} (${processedCount} / ${MAX_PROCCESS_FILES})`);

      try {
        // 個別ファイル処理の呼び出し
        processFile(file);
        // 1ファイル終わるごとに2秒待機(429エラー対策)
        Logger.log("API制限回避のため2秒待機します...");
        Utilities.sleep(2000);
      } catch (e) {
        // エラー時はログに情報を吐き、ファイル名に Error タグを付ける
        Logger.log(`Global Error processing ${fileName}: ${e.toString()}`);
        file.setName(`[ERR]${fileName}`);
      }
    } else {
      // 存在する Processed と Error のファイルはすべて無視する
    }
  }
}

// ==========================================================
// 2. 個別ファイル処理(日付フォールバック、AI解析、リネーム実行)
// ==========================================================
function processFile(file) {
  const fileType = file.getMimeType();
  const fileId = file.getId();
  let newNameData = { date: null, title: null, supplement: null };

  // ----------------------------------------
  // ファイルの種類によらず一括してGeminiに解析させ、
  // 候補ファイル名のJSON情報を取得
  newNameData = analyzeImageWithGemini(file);
  // ----------------------------------------

  // ----------------------------------------
  // 3. 日付のフォールバック処理
  let finalDate = newNameData.date; // 取得した日付
  
  if (!finalDate || finalDate.length !== 8) {
    // Geminiが日付を返さなかった、または形式が不正な場合
    // 代替情報としてそのファイルの作成日を取得
    const createdDate = file.getDateCreated();
    // YYYYMMDD形式に変換
    finalDate = Utilities.formatDate(createdDate, Session.getScriptTimeZone(), "yyyyMMdd");
    Logger.log(`Using file creation date as fallback: ${finalDate}`);
  }
  // ----------------------------------------
  
  // ----------------------------------------
  // 4. ファイル名の組み立てと更新
  if (newNameData.title && 
      newNameData.title!='Error') {
    // Geminiの処理でエラーが起きていなければ・・・

    // 形式: yyyy-mm-dd_タイトル_補足.拡張子
    const datePart = `${finalDate.substring(0, 4)}-${finalDate.substring(4, 6)}-${finalDate.substring(6, 8)}`;
    const extension = file.getName().split('.').pop();
    
    // タイトルと補足を結合
    let baseName = `${datePart}_${newNameData.title}`;
    if (newNameData.supplement) {
      baseName += `_${newNameData.supplement}`;
    }
    
    // 最大文字数で切り詰め
    if (baseName.length > MAX_BASE_NAME_LENGTH) {
        baseName = baseName.substring(0, MAX_BASE_NAME_LENGTH);
    }
    
    // AI提案のファイル名を生成し、サニタイズする
    const newFileName = sanitizeFileName(`[DONE]${baseName}.${extension}`);

    // ファイル名変更
    file.setName(newFileName);
    Logger.log(`Renamed to: ${newFileName}`);
    
  } else {

    // タイトルが生成できなかった場合はエラーマーク
    file.setName(`[ERR][${newNameData.supplement}]${file.getName()}`);
    Logger.log(`Failed to generate title. Marked as error.`);

  }
  // ----------------------------------------

}

// ==========================================================
// 3. Gemini連携関数(画像ベースでプロンプトに詳細ルールを指示)
// ==========================================================
function analyzeImageWithGemini(file) {
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL_NAME}:generateContent?key=${GEMINI_API_KEY}`;

  // 1. ファイルをBase64形式に変換
  const blob = file.getBlob();
  const base64Data = Utilities.base64Encode(blob.getBytes());
  const mimeType = file.getMimeType();

  // 2. プロンプト定義
  const prompt = `
  このファイルの内容を詳細に分析し、ファイル名生成のために最適な情報(日付、タイトル、補足情報)を抽出・生成してください。
    
    【日付の決定ルール】
    1. ファイルの内容から「請求日」「撮影日」「作成日」「発行日」など、そのデータにとって最も適切な日付を優先して抽出してください。
    2. 日付は**必ず**YYYYMMDD形式(例:20250320)で抽出してください。
    3. **抽出した日付が非現実的(遠い過去、または未来、または不明)な場合**は、日付の値を**空欄 ("")** にしてください。
    
    【タイトルの決定ルール】
    1. タイトル(最大20文字程度)には、以下の情報から最も重要なものを選んで含めてください。
        - 文書の概要、文書や資料の発行者の組織や人物の名前、レシートの店舗・施設名、写真の場所や被写体の特徴。
    2. タイトルに含めるべき具体的な情報がない場合は、そのデータの種類(例:通知文書、請求書、風景写真、レシート)を入れてください。
    
    【補足情報の決定ルール】
    1. 補足情報には、タイトルに入れきれなかった重要な情報(例:プロジェクト名、合計金額、主要な製品やサービスの名前、画像の中の人物や景色の説明、名刺の会社名)を短くまとめて含めてください。
    
    【回答形式】
    回答は、以下のJSON形式**のみ**で回答してください。余分な説明やコメントは一切含めないでください。
    
    {
      "date": "YYYYMMDDまたは空欄",
      "title": "タイトル(20文字程度)",
      "supplement": "補足情報(50文字以内)"
    }
  `;

  // 3. ペイロードの構成
  const payload = {
    "contents": [{
      "parts": [
        { "text": prompt },
        {
          "inline_data": {
            "mime_type": mimeType,
            "data": base64Data
          }
        }
      ]
    }],
    "generationConfig": {
      "responseMimeType": "application/json",
      "responseSchema": {
        "type": "object",
        "properties": {
          "date": { "type": "string" },
          "title": { "type": "string" },
          "supplement": { "type": "string" }
        },
        "required": ["date", "title", "supplement"]
      }
    }
  };

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

  // 3. Geminiに解析させる
  try {
    // Geminiへリクエストを送り、レスポンス(JSON)を受け取る
    const response = UrlFetchApp.fetch(url, options);
    Logger.log("Gemini Response(JSON): " + response.getContentText());
    const jsonResponse = JSON.parse(response.getContentText());
    
    // レスポンスに情報があったら、それを返り値としてReturn
    if (jsonResponse.candidates && jsonResponse.candidates[0].content) {
      const generatedText = jsonResponse.candidates[0].content.parts[0].text;
      Logger.log("Gemini Response(TEXT): " + generatedText);
      return JSON.parse(generatedText);
    }
    // レスポンスに情報が無かったら、エラー値としてReturn
    return { date: "", title: "Error", supplement: "解析不能" };
  } catch (e) {
    // エラー時は内容をログに吐いてnullでReturn
    Logger.log("Image Analysis Error: " + e.toString());
    return null;
  }
}

/**
 * サブフォルダを含め再帰的にファイルを取得する関数
 * @param {GoogleAppsScript.Drive.Folder} folder 現在のフォルダ
 * @param {Array} allFiles ファイルを蓄積する配列
 */
function getAllFilesRecursively(folder, allFiles) {
  // 1. 現在のフォルダのファイルを取得して配列に入れる
  const files = folder.getFiles();
  while (files.hasNext()) {
    allFiles.push(files.next());
  }

  // 2. サブフォルダを順番に探索(ここで自分自身を呼び出す=再帰)
  const subFolders = folder.getFolders();
  while (subFolders.hasNext()) {
    const nextFolder = subFolders.next();
    getAllFilesRecursively(nextFolder, allFiles);
  }
}

/**
 * ファイル名として不適切な文字(制御文字・禁止記号)を除去・置換する
 * @param {string} filename - 候補となる文字列
 * @param {string} replaceChar - 置換後の文字(デフォルトは空文字)
 * @return {string} サニタイズされた文字列
 */
function sanitizeFileName(filename, replaceChar = '') {
  if (!filename) return '';

  // 1. 制御文字(改行 \n \r、タブ \t など:ASCII 0-31 および 127)
  // 2. OSで禁止されている記号 / \ ? % * : | " < >
  // これらを一括で置換
  const invalidChars = /[\x00-\x1f\x7f\\\/?%*:|"<>]/g;
  
  let sanitized = filename.replace(invalidChars, replaceChar);

  // 3. 前後の空白をトリムし、念のためピリオドのみのファイル名などを防ぐ
  sanitized = sanitized.trim();

  // ファイル名が空になった場合のフォールバック(任意)
  return sanitized || 'untitled_file';
}

GoogleAppsScriptプロジェクトのプロパティ設定サンプル

 

この記事が気に入ったら
いいね!しよう

最新情報をお届けします

Twitter でそらみみをフォローしよう!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です