2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NotionAPI x GAS x OCRで画像内の文字列検索できるようにする

Posted at

この記事は何?

  • Notionに貼った画像内の文字列で検索ヒットできるようにしたい!
  • Notionに貼った画像から文字列をOCR抽出して、プロパティに入れたらええやんけ
  • GASとNotionAPIとGoogle Drive APIで作ったぞ。日次実行で無料だぞ!

作ったもの

Google Apps Scriptで、Notion APIでNotionのデータベースからOCRテキストが未設定のページを取得し、それらのページ内の画像をGoogle Drive APIを使ってOCR処理し、結果をNotionのプロパティに保存します。日本語OCRに対応します。

処理順序

実行コード

// NotionのAPI認証トークンとデータベースID
const notionToken = "secret_XXXX";
const notionDbId = "cafYYYYYYYYYYYYYYZZZZZZZ";
const ocrProperty = "OCR Text ";

// 設定値
const CONFIG = {
  maxPages: 50,            // 一度に処理する最大ページ数
  maxRetries: 3,           // API呼び出し失敗時の最大リトライ回数
  retryDelay: 1000,        // リトライ間隔(ミリ秒)
  maxOcrLength: 2000,      // NotionのRich Textプロパティの最大文字数
  requestTimeout: 30000,   // APIリクエストのタイムアウト時間(ミリ秒)
  rateLimitDelay: 334,     // API呼び出し間の遅延(Notionの制限に対応:3リクエスト/秒)
  logLevel: 'INFO'         // ログレベル: 'DEBUG', 'INFO', 'WARN', 'ERROR'
};

// ログレベルの定義
const LOG_LEVELS = {
  'DEBUG': 0,
  'INFO': 1,
  'WARN': 2,
  'ERROR': 3
};

/**
 * ログ出力関数
 * @param {string} level - ログレベル
 * @param {string} message - ログメッセージ
 * @param {Object} data - 追加データ(オプション)
 */
function log(level, message, data) {
  if (LOG_LEVELS[level] >= LOG_LEVELS[CONFIG.logLevel]) {
    const timestamp = new Date().toISOString();
    let logMessage = `[${timestamp}] [${level}] ${message}`;
    
    if (level === 'ERROR') {
      console.error(logMessage);
      if (data) console.error(JSON.stringify(data, null, 2));
    } else {
      console.log(logMessage);
      if (data && LOG_LEVELS[level] <= LOG_LEVELS['DEBUG']) {
        console.log(JSON.stringify(data, null, 2));
      }
    }
  }
}

/**
 * スリープ関数
 * @param {number} ms - スリープ時間(ミリ秒)
 * @returns {Promise} - 指定時間後に解決するPromise
 */
function sleep(ms) {
  return new Promise(resolve => Utilities.sleep(ms));
}

/**
 * メイン関数 - OCR処理の全体の流れを制御
 */
function main() {
  try {
    log('INFO', '処理を開始します');
    
    let startTime = new Date().getTime();
    let updateCount = 0;
    let errorCount = 0;
    let skippedCount = 0;
    
    // OCRプロパティが空のページを取得
    let pages = getPages();
    log('INFO', `処理対象ページ数: ${pages.length}`);
    
    if (pages.length === 0) {
      log('INFO', 'OCR処理が必要なページはありません');
      return;
    }
    
    // 各ページを処理
    pages.forEach((page, index) => {
      try {
        log('INFO', `ページ処理中 (${index + 1}/${pages.length}): ${page.id}`);
        
        // 各ページのブロックを取得
        let blocks = getBlocks(page);
        
        if (blocks.length === 0) {
          log('INFO', `ページ ${page.id} にはブロックがありません`);
          let isUpdate = updateOcrProperty(page, ["no-content"]);
          if (isUpdate) {
            updateCount++;
          } else {
            errorCount++;
          }
          return;
        }
        
        // ブロック内の画像からOCRテキストを抽出
        let contents = getOcrText(blocks);
        
        // OCRテキストをNotionプロパティに更新
        let isUpdate = updateOcrProperty(page, contents);
        if (isUpdate) {
          updateCount++;
          log('INFO', `ページ ${page.id} のOCRテキストを更新しました`);
        } else {
          errorCount++;
          log('ERROR', `ページ ${page.id} のOCRテキスト更新に失敗しました`);
        }
        
        // レート制限対策の遅延
        if (index < pages.length - 1) {
          Utilities.sleep(CONFIG.rateLimitDelay);
        }
      } catch (e) {
        errorCount++;
        log('ERROR', `ページ ${page.id} の処理中にエラーが発生しました`, e);
      }
    });
    
    let endTime = new Date().getTime();
    let executionTime = (endTime - startTime) / 1000;
    
    log('INFO', `処理完了: 更新=${updateCount}, エラー=${errorCount}, スキップ=${skippedCount}, 実行時間=${executionTime}秒`);
  } catch (e) {
    log('ERROR', '処理全体でエラーが発生しました', e);
    throw e;
  }
}

/**
 * APIリクエストをリトライ機能付きで実行
 * @param {Function} apiCall - 実行する関数
 * @param {Array} args - 関数の引数
 * @returns {Object} API呼び出し結果
 */
async function withRetry(apiCall, ...args) {
  let lastError;
  
  for (let attempt = 1; attempt <= CONFIG.maxRetries; attempt++) {
    try {
      return await apiCall(...args);
    } catch (e) {
      lastError = e;
      log('WARN', `API呼び出しに失敗しました (${attempt}/${CONFIG.maxRetries})`, e);
      
      if (attempt < CONFIG.maxRetries) {
        // 指数バックオフでリトライ
        const delay = CONFIG.retryDelay * Math.pow(2, attempt - 1);
        log('INFO', `${delay}ミリ秒後にリトライします`);
        await sleep(delay);
      }
    }
  }
  
  throw new Error(`最大リトライ回数(${CONFIG.maxRetries})を超えました: ${lastError.message}`);
}

/**
 * OCRテキストプロパティが空のNotionページを取得
 * @returns {Array} ページの配列
 */
function getPages() {
  try {
    log('INFO', 'OCR未処理のページを検索中...');
    
    let endpoint = `/databases/${notionDbId}/query`;
    let payload = {
      filter: {
        or: [
          { property: ocrProperty, rich_text: { is_empty: true } }
        ]
      },
      page_size: CONFIG.maxPages
    };
    
    let res = withRetry(() => notionAPI(endpoint, "POST", payload));
    
    if (isError(res)) {
      log('ERROR', 'ページ取得に失敗しました', res);
      return [];
    }
    
    let pages = res.results || [];
    log('INFO', `${pages.length}件のOCR未処理ページを取得しました`);
    return pages;
  } catch (e) {
    log('ERROR', 'ページ取得処理でエラーが発生しました', e);
    return [];
  }
}

/**
 * OCRテキストをNotionページのプロパティに更新
 * @param {Object} page - Notionページオブジェクト
 * @param {Array} contents - OCRテキスト配列
 * @returns {boolean} 更新成功したかどうか
 */
function updateOcrProperty(page, contents) {
  try {
    if (!page || !page.id) {
      log('ERROR', '無効なページオブジェクトです');
      return false;
    }
    
    if (!Array.isArray(contents)) {
      log('WARN', '無効なコンテンツ配列です、空配列に変換します');
      contents = [];
    }
    
    if (contents.length === 0) {
      contents.push("no-image");
      log('INFO', `ページ ${page.id} には画像がありませんでした`);
    }
    
    // 改行コードを統一
    const normalizedText = contents.join("\n").replace(/\r\n/g, "\n");
    
    // 文字数制限に対応
    const truncatedText = normalizedText.substring(0, CONFIG.maxOcrLength);
    if (normalizedText.length > CONFIG.maxOcrLength) {
      log('WARN', `テキストが${CONFIG.maxOcrLength}文字を超えるため切り詰めました (${normalizedText.length} -> ${truncatedText.length})`);
    }
    
    let endpoint = `/pages/${page.id}`;
    let properties = {};
    properties[ocrProperty] = { 
      rich_text: [{ 
        text: { content: truncatedText } 
      }] 
    };
    let payload = { properties: properties };
    
    log('DEBUG', `ページ ${page.id} のOCRプロパティを更新します`);
    let res = withRetry(() => notionAPI(endpoint, "PATCH", payload));
    
    if (isError(res)) {
      log('ERROR', `ページ ${page.id} のプロパティ更新に失敗しました`, res);
      return false;
    }
    
    return true;
  } catch (e) {
    log('ERROR', `プロパティ更新処理でエラーが発生しました: ${e.message}`, e);
    return false;
  }
}

/**
 * ブロック内の画像からOCRテキストを抽出
 * @param {Array} blocks - ブロック配列
 * @returns {Array} OCRテキスト配列
 */
function getOcrText(blocks) {
  try {
    if (!Array.isArray(blocks) || blocks.length === 0) {
      log('INFO', 'ブロックが存在しないか無効です');
      return [];
    }
    
    log('INFO', `${blocks.length}個のブロックをスキャン中...`);
    
    let imageBlocks = blocks.filter(block => block && block.type === 'image' && block.image);
    log('INFO', `${imageBlocks.length}個の画像ブロックを検出しました`);
    
    if (imageBlocks.length === 0) {
      return [];
    }
    
    let contents = [];
    let successCount = 0;
    let errorCount = 0;
    
    imageBlocks.forEach((block, index) => {
      try {
        if (!block.image || !block.image[block.image.type] || !block.image[block.image.type].url) {
          log('WARN', `画像ブロック #${index} に有効なURLがありません`, block);
          errorCount++;
          return;
        }
        
        const imageUrl = block.image[block.image.type].url;
        log('DEBUG', `画像 #${index} のOCR処理中: ${imageUrl.substring(0, 50)}...`);
        
        let text = getText(imageUrl);
        
        if (!text || text.trim() === '') {
          log('INFO', `画像 #${index} からテキストを抽出できませんでした`);
          text = "[no-text-extracted]";
        } else {
          log('DEBUG', `画像 #${index} から ${text.length} 文字を抽出しました`);
        }
        
        contents.push(text);
        successCount++;
        
        // 複数画像処理間の遅延
        if (index < imageBlocks.length - 1) {
          Utilities.sleep(CONFIG.rateLimitDelay);
        }
      } catch (e) {
        log('ERROR', `画像 #${index} の処理中にエラーが発生しました`, e);
        contents.push(`[error: ${e.message}]`);
        errorCount++;
      }
    });
    
    log('INFO', `OCR処理完了: 成功=${successCount}, エラー=${errorCount}`);
    return contents;
  } catch (e) {
    log('ERROR', 'OCRテキスト抽出処理でエラーが発生しました', e);
    return [`[processing-error: ${e.message}]`];
  }
}

/**
 * ページ内のブロックを取得
 * @param {Object} page - Notionページオブジェクト
 * @returns {Array} ブロック配列
 */
function getBlocks(page) {
  try {
    if (!page || !page.id) {
      log('ERROR', '無効なページオブジェクトです');
      return [];
    }
    
    log('INFO', `ページ ${page.id} のブロックを取得中...`);
    let endpoint = `/blocks/${page.id}/children`;
    
    let res = withRetry(() => notionAPI(endpoint));
    
    if (isError(res)) {
      log('ERROR', `ページ ${page.id} のブロック取得に失敗しました`, res);
      return [];
    }
    
    let blocks = res.results || [];
    log('INFO', `ページ ${page.id} から ${blocks.length}個のブロックを取得しました`);
    return blocks;
  } catch (e) {
    log('ERROR', `ブロック取得処理でエラーが発生しました: ${e.message}`, e);
    return [];
  }
}

/**
 * 画像URLからOCRテキストを抽出
 * @param {string} url - 画像URL
 * @returns {string} OCRテキスト
 */
function getText(url) {
  let srcFile = null;
  let distFile = null;
  
  try {
    if (!url) {
      throw new Error('無効な画像URLです');
    }
    
    log('DEBUG', '画像のダウンロード中...');
    
    // 画像URLからファイルをダウンロード(タイムアウト設定付き)
    let fetchOptions = {
      muteHttpExceptions: true,
      validateHttpsCertificates: true,
      followRedirects: true,
      timeout: CONFIG.requestTimeout
    };
    
    let res = UrlFetchApp.fetch(url, fetchOptions);
    
    if (res.getResponseCode() !== 200) {
      throw new Error(`画像の取得に失敗しました: HTTP ${res.getResponseCode()}`);
    }
    
    let fileBlob = res.getBlob();
    if (!fileBlob || fileBlob.getBytes().length === 0) {
      throw new Error('空のファイルがダウンロードされました');
    }
    
    const fileSize = fileBlob.getBytes().length;
    log('DEBUG', `画像ファイルサイズ: ${fileSize} バイト`);
    
    const fileType = fileBlob.getContentType();
    if (!fileType.startsWith('image/')) {
      throw new Error(`サポートされていないファイル形式です: ${fileType}`);
    }
    
    const uniqueId = Utilities.getUuid();
    fileBlob.setName(`notion-image-${uniqueId}`);
    
    // Googleドライブに一時ファイルとして保存
    srcFile = DriveApp.createFile(fileBlob);
    log('DEBUG', `一時ファイルを作成しました: ${srcFile.getId()}`);
    
    // OCR処理のオプション(日本語)
    let options = { 
      ocr: true, 
      ocrLanguage: "ja" 
    };
    
    // OCR処理を適用したファイルをコピー作成
    log('INFO', 'OCR処理を実行中...');
    distFile = Drive.Files.copy({ title: `ocr-image-${uniqueId}` }, srcFile.getId(), options);
    
    if (!distFile || !distFile.id) {
      throw new Error('OCR処理されたファイルの作成に失敗しました');
    }
    
    // OCR処理されたドキュメントからテキストを抽出
    const doc = DocumentApp.openById(distFile.id);
    if (!doc) {
      throw new Error('OCR処理後のドキュメントを開けませんでした');
    }
    
    const body = doc.getBody();
    const text = body ? body.getText() : '';
    
    log('INFO', `OCR処理完了: ${text.length}文字を抽出しました`);
    return text;
  } catch (e) {
    log('ERROR', `OCR処理中にエラーが発生しました: ${e.message}`, e);
    return `[ocr-error: ${e.message}]`;
  } finally {
    // 一時ファイルを削除(エラーが発生しても確実に削除)
    try {
      if (srcFile) {
        Drive.Files.remove(srcFile.getId());
        log('DEBUG', '一時ソースファイルを削除しました');
      }
    } catch (e) {
      log('WARN', '一時ソースファイルの削除に失敗しました', e);
    }
    
    try {
      if (distFile) {
        Drive.Files.remove(distFile.id);
        log('DEBUG', '一時OCRファイルを削除しました');
      }
    } catch (e) {
      log('WARN', '一時OCRファイルの削除に失敗しました', e);
    }
  }
}

/**
 * Notion APIへのリクエスト処理
 * @param {string} endpoint - APIエンドポイント
 * @param {string} method - HTTPメソッド(GET/POST/PATCH等)
 * @param {Object} payload - リクエストペイロード
 * @returns {Object} APIレスポンス
 */
function notionAPI(endpoint, method = "GET", payload = null) {
  try {
    const api = "https://api.notion.com/v1" + endpoint;
    const headers = {
      "Authorization": "Bearer " + notionToken,
      "Content-Type": method !== "GET" ? "application/json" : null,
      "Notion-Version": "2021-08-16"
    };
    
    const options = {
      headers: headers,
      method: method,
      payload: payload ? JSON.stringify(payload) : null,
      muteHttpExceptions: true,
      timeout: CONFIG.requestTimeout
    };
    
    log('DEBUG', `Notion API呼び出し: ${method} ${endpoint}`);
    const startTime = new Date().getTime();
    
    const res = UrlFetchApp.fetch(api, options);
    
    const endTime = new Date().getTime();
    const responseTime = endTime - startTime;
    
    const statusCode = res.getResponseCode();
    log('DEBUG', `API応答: HTTP ${statusCode}, 応答時間: ${responseTime}ms`);
    
    const responseText = res.getContentText();
    let json;
    
    try {
      json = JSON.parse(responseText);
    } catch (e) {
      log('ERROR', 'APIレスポンスのJSONパースに失敗しました', {
        statusCode: statusCode,
        responseText: responseText.substring(0, 200) + '...'
      });
      throw new Error(`APIレスポンスの解析に失敗しました: ${e.message}`);
    }
    
    // レート制限のチェック
    if (statusCode === 429) {
      const retryAfter = parseInt(res.getHeaders()['Retry-After'] || "5");
      log('WARN', `レート制限に達しました。${retryAfter}秒後に再試行します`);
      Utilities.sleep(retryAfter * 1000);
      throw new Error('レート制限に達しました');
    }
    
    // エラーレスポンスのチェック
    if (statusCode >= 400) {
      log('ERROR', `API呼び出しエラー: HTTP ${statusCode}`, json);
      throw new Error(`API呼び出しに失敗しました: HTTP ${statusCode}`);
    }
    
    return json;
  } catch (e) {
    log('ERROR', `Notion API呼び出し中にエラーが発生しました: ${e.message}`, e);
    throw e;
  }
}

/**
 * APIレスポンスがエラーかどうかを判定
 * @param {Object} res - APIレスポンス
 * @returns {boolean} エラーかどうか
 */
function isError(res) {
  if (!res) return true;
  if (res.object === "error") {
    log('ERROR', 'Notionからエラーレスポンスを受信しました', res);
    return true;
  }
  return false;
}

/**
 * 定期実行用の関数(Google Apps Scriptのトリガーで使用)
 */
function scheduledOcrProcessing() {
  try {
    log('INFO', '定期OCR処理を開始します');
    main();
    log('INFO', '定期OCR処理を完了しました');
  } catch (e) {
    log('ERROR', '定期OCR処理中に致命的なエラーが発生しました', e);
    
    // メール通知(オプション)
    try {
      const adminEmail = Session.getActiveUser().getEmail();
      if (adminEmail) {
        const subject = 'Notion OCR処理エラー通知';
        const body = `Notion OCR処理中にエラーが発生しました:\n\n${e.message}\n\n詳細はログを確認してください。`;
        MailApp.sendEmail(adminEmail, subject, body);
        log('INFO', `エラー通知メールを ${adminEmail} に送信しました`);
      }
    } catch (mailError) {
      log('ERROR', 'エラー通知メールの送信に失敗しました', mailError);
    }
  }
}
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?