Last active
November 29, 2025 20:08
-
-
Save Saki-htr/bc819faba0183ff099c13e4703684b4c to your computer and use it in GitHub Desktop.
契約書自動入力のGAS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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