Skip to content

Instantly share code, notes, and snippets.

@Saki-htr
Last active November 29, 2025 20:08
Show Gist options
  • Select an option

  • Save Saki-htr/bc819faba0183ff099c13e4703684b4c to your computer and use it in GitHub Desktop.

Select an option

Save Saki-htr/bc819faba0183ff099c13e4703684b4c to your computer and use it in GitHub Desktop.
契約書自動入力のGAS
function extractTextFromPdfs() {
// スプレッドシートIDとGoogleドライブのフォルダIDを設定
const spreadsheetId = '<スプレッドシートID>';
const parentFolderId = '<GoogleドライブのフォルダID>';
const sheetName = '<シート名>';
// スプレッドシートと親フォルダを取得
const spreadsheet = SpreadsheetApp.openById(spreadsheetId);
const sheet = spreadsheet.getSheetByName(sheetName);
// スプレッドシートのヘッダー行を2行目から取得
const headerRow = sheet.getRange(2, 1, 1, sheet.getLastColumn()).getValues()[0];
// 既に処理済みのファイルURLを取得(重複処理を避けるため)
const lastRow = sheet.getLastRow();
const processedUrls = new Set();
if (lastRow > 2) {
const existingData = sheet.getRange(3, 1, lastRow - 2, sheet.getLastColumn()).getValues();
const linkColumnIndex = headerRow.indexOf('契約書のリンク');
if (linkColumnIndex !== -1) {
existingData.forEach(row => {
if (row[linkColumnIndex]) {
processedUrls.add(row[linkColumnIndex]);
}
});
}
}
Logger.log(`📋 既に処理済み: ${processedUrls.size}件のファイル`);
// Googleドライブのフォルダを取得
const parentFolder = DriveApp.getFolderById(parentFolderId);
const subFolders = parentFolder.getFolders();
// Gemini APIキー
const apiKey = PropertiesService.getScriptProperties().getProperty('API_KEY');
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
// データの書き込み開始行を既存データの次の行に設定
let nextRow = sheet.getLastRow() + 1;
if (nextRow < 3) nextRow = 3; // 最低でも3行目から開始
let processedCount = 0; // 処理件数をカウント
let folderCount = 0; // フォルダカウント
let skippedCount = 0; // スキップ件数をカウント
const maxProcessingTime = 25 * 60 * 1000; // 25分(ミリ秒)
const startTime = new Date().getTime();
while (subFolders.hasNext()) {
// 処理時間チェック(25分でタイムアウト回避)
const currentTime = new Date().getTime();
if (currentTime - startTime > maxProcessingTime) {
Logger.log(`⏰ 処理時間制限により中断: ${folderCount}フォルダ、${processedCount}件処理済み`);
Logger.log(`🔄 続きを処理するには、再度実行してください`);
break;
}
const subFolder = subFolders.next();
const subFolderName = subFolder.getName();
folderCount++;
Logger.log(`📁 処理中のフォルダ (${folderCount}): ${subFolderName}`);
const files = subFolder.getFilesByType(MimeType.PDF);
// フォルダ内のファイル処理開始
while (files.hasNext()) {
const file = files.next();
const fileName = file.getName();
const fileUrl = file.getUrl();
// 既に処理済みのファイルかチェック
if (processedUrls.has(fileUrl)) {
Logger.log(` ⏭️ スキップ(処理済み): ${fileName}`);
skippedCount++;
continue;
}
Logger.log(` 📄 処理中: ${fileName}`);
const fileBlob = file.getBlob();
const prompt = createPrompt(fileBlob);
const geminiResponse = callGeminiApi(endpoint, prompt);
if (geminiResponse && geminiResponse.is_contract) {
const rowData = getRowData(geminiResponse, fileUrl, headerRow);
sheet.getRange(nextRow, 1, 1, rowData.length).setValues([rowData]);
nextRow++;
processedCount++; // 契約書として処理した場合のみカウントアップ
Logger.log(` ✅ 契約書として登録: ${geminiResponse.契約書表題 || '表題不明'}`);
// 10件ごとに強制反映(パフォーマンスとリアルタイム性のバランス)
if (processedCount % 10 === 0) {
SpreadsheetApp.flush();
Logger.log(`📊 ${processedCount}件処理完了 - スプレッドシートに反映済み`);
}
} else if (geminiResponse && !geminiResponse.is_contract) {
Logger.log(` ⏭️ 契約書以外のため除外: ${fileName}`);
} else {
Logger.log(` ❌ 処理エラー: ${fileName}`);
}
// API負荷軽減のため、各ファイル処理後に1秒待機(処理時間を最適化)
Utilities.sleep(1000);
}
Logger.log(`📁 フォルダ「${subFolderName}」の処理完了\n`);
}
// 最終的な反映を確実に実行
SpreadsheetApp.flush();
const endTime = new Date().getTime();
const processingTime = Math.round((endTime - startTime) / 1000);
Logger.log(`🎉 処理完了: ${folderCount}フォルダ、${processedCount}件の契約書を登録、${skippedCount}件をスキップ(処理時間: ${processingTime}秒)`);
}
// ---------------------------------------------------------------
// Gemini APIに送るプロンプトを生成する関数
// ---------------------------------------------------------------
function createPrompt(fileBlob) {
const promptText = `
以下のPDFファイルは「契約書」「約定書」「合意書」「承諾書」「保険や証券の申込書」のいずれかに該当しますか?
これらの文書ではない場合(特に「覚書」「見積書」の場合)、\`{"is_contract": false}\`というJSONを返してください。
もし「契約書」「約定書」「合意書」であれば、以下のJSON形式で情報を抽出してください。
- 契約書表題
- 契約相手方(<自社名>は除外し、契約相手となる他社の会社名のみを抽出)
- 契約締結日 (YYYY/MM/DD)
- 契約開始日 (YYYY/MM/DD):記載がなければ契約締結日の日付。
- 契約終了日 (YYYY/MM/DD) :自動更新が有りの場合は最新の契約終了日。(例えば1年毎に自動更新される場合は、今年or来年の日付を教えて)。自動更新がなければ空欄。
- 自動更新の有無 (自動更新有り/自動更新無し)
- 自動更新予定日 (最新の契約終了日から1年後など、自動更新される日付。なければ空欄。YYYY/MM/DD)
- 契約状態 (有効・継続中/無効・終了)
- 契約終了後の秘密保持等制約の有無 (制約有り/制約無し)
- 秘密保持等制約がある場合、契約終了後の制約の終了日 (YYYY/MM/DD):なければ空欄
- is_contract: true
`;
return [
{ "text": promptText },
{
"inline_data": {
"mime_type": "application/pdf",
"data": Utilities.base64Encode(fileBlob.getBytes())
}
}
];
}
// ---------------------------------------------------------------
// Gemini APIを呼び出す関数(リトライ機能付き)
// ---------------------------------------------------------------
function callGeminiApi(endpoint, prompt) {
const requestBody = {
"contents": [
{
"parts": prompt
}
],
"generation_config": {
"temperature": 0.1
}
};
const options = {
"method": "post",
"contentType": "application/json",
"payload": JSON.stringify(requestBody),
"muteHttpExceptions": true
};
const maxRetries = 3; // 最大リトライ回数
let retryCount = 0;
while (retryCount < maxRetries) {
try {
const response = UrlFetchApp.fetch(endpoint, options);
const result = JSON.parse(response.getContentText());
if (result.error) {
// 503エラー(過負荷)または429エラー(レート制限)の場合はリトライ
if (result.error.code === 503 || result.error.code === 429) {
retryCount++;
const waitTime = Math.pow(2, retryCount) * 1000; // 指数バックオフ(2秒、4秒、8秒)
Logger.log(`Gemini API一時的エラー (${result.error.code}): ${retryCount}回目のリトライを${waitTime/1000}秒後に実行`);
if (retryCount < maxRetries) {
Utilities.sleep(waitTime);
continue;
}
}
Logger.log('Gemini APIエラー: ' + JSON.stringify(result.error));
return null;
}
const textContent = result.candidates[0].content.parts[0].text;
const cleanedText = textContent.replace(/```json/g, '').replace(/```/g, '').trim();
return JSON.parse(cleanedText);
} catch (e) {
retryCount++;
Logger.log(`Gemini API呼び出しでエラーが発生 (${retryCount}/${maxRetries}): ${e.toString()}`);
if (retryCount < maxRetries) {
const waitTime = Math.pow(2, retryCount) * 1000;
Logger.log(`${waitTime/1000}秒後にリトライします`);
Utilities.sleep(waitTime);
} else {
Logger.log('最大リトライ回数に達しました。処理を終了します。');
return null;
}
}
}
return null;
}
// ---------------------------------------------------------------
// Geminiの応答をスプレッドシートの行データに変換する関数
// ---------------------------------------------------------------
function getRowData(response, fileUrl, headerRow) {
const dataMap = {
'管理番号': '',
'契約書表題': response.契約書表題 || '',
'契約相手方': response.契約相手方 || '',
'当社契約担当部門・MT': '',
'当社契約担当者': '',
'契約締結日': response.契約締結日 || '',
'契約開始日': response.契約開始日 || '',
'契約終了日': response.契約終了日 || '',
'契約金額': '',
'自動更新の有無': response.自動更新の有無 || '',
'自動更新予定日': response.自動更新予定日 || '',
'契約状態': response.契約状態 || '',
'契約書のリンク': fileUrl || '',
'覚書、変更契約の有無': '',
'覚書、変更契約のリンク': '',
'契約終了後の秘密保持等制約の有無': response.契約終了後の秘密保持等制約の有無 || '',
'契約終了後の制約の終了日': response.契約終了後の制約の終了日 || '',
'備考': ''
};
return headerRow.map(header => dataMap[header.trim()] || '');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment