この記事は何?
- 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);
}
}
}