郵便物や買い物のレシート、お店や病院などで貰った資料やパンフレットなどを、スキャナで読み取って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 でそらみみをフォローしよう!
Follow @sora_mimi