時間がかかるNotion APIの実行を高速化!キャッシュ方法を丁寧に解説!

Featured image of the post

はじめに

Notion APIで「ページ全体」を取得するのは時間がかかりがち💦

よくある原因

→ブロック数が多く、何度もリクエストが必要なため時間がかかってしまう。

Image in a image block

→ページ数が増えると数十分以上かかることも💦

毎回新しいデータを取得するのであれば、時間がかかるのは仕方ない🥲

しかし!!

ビルドの度に同じデータを取得している場合などは時短する余地がある!!

(例:SSGで毎回同じHTMLを生成している場合)

そこでこの記事では、ビルドの度に同じデータを取得するのをやめてビルド時間を短縮する方法を紹介する😊

(筆者はビルド時間が40分→6分と大幅に短縮できた!)

📎
【関連記事(Vercel や Cloudflare Pagesでビルドする場合はこちら)】

📄VercelやCloudflare Pagesのビルドタイムアウトを回避!Notion APIのキャッシュを丁寧に解説!

やりたいこと

2回目以降は再度同じデータを取得しないようしたい!

💡
実行する度に毎回同じデータを取得するのは時間の無駄!

完成イメージ

Notion APIで取得したデータをキャッシュ(JSONファイル)として保存しておき、 次回以降はAPIを呼ぶ代わりにキャッシュ(JSONファイル)を読み込む。

Image in a image block

💡
2回目以降は不要なAPIを叩かない!

実現方法

前提
時間がかかるのは「ブロック」の取得

Notionのデータは主に「ページ」と「ブロック」の2種類に分けられる。

特に「ブロック」の数が多く取得に時間がかかる💦

Image in a image block

💡
今回はブロックの取得回数を減らす方針!

【基本方針】Notion APIで取得したデータはキャッシュする

一度Notion APIで取得したブロックはキャッシュ(JSONファイル)として保存しておく。

Image in a image block

→2回目以降はNotion APIの代わりにキャッシュを使うことでAPIの実行回数を減って時短になる。

💡
Notion APIのレスポンスをJSONファイルに書き込むだけ!

【ポイント】データが新しくなっていたら取得しなおす

基本的に2回目以降はNotion APIを使わない。(代わりにキャッシュを使う)

しかしページが更新されている場合など、Notion APIで最新のデータを取得したいケースもある💦

結論、以下の2パターンのときにNotion APIで最新のデータを取得すれば上手くいく!

  • 新規ページ(まだキャッシュがないページ)のとき
  • ページに更新があるとき

Image in a image block

💡
これにより必要最低限のNotion APIで済ませることができる!

準備

使用技術の紹介
プログラミング言語

「キャッシュの扱い方」の話なので、プログラミング言語にかかわらず使える。

(今回はJavaScriptを使って説明する)

事前に用意しておくもの
  • Node.js
  • npm
  • @notionhq/client(Notion APIのSDK)

💡
今回はキャッシュの話なのでNotion APIの準備は省略する。

今からインストールするもの
  • p-queue

p-queueをインストールする

p-queueをインストールする。

npm install p-queue

今回のキャッシュとは直接関係ない。

Notion APIのレート制限にかからないよう、APIの実行数を制御するために使う。

実装

①キャッシュを生成するプログラムを作成

Notion APIを実行してキャッシュ(JSONファイル)を生成する。

Image in a image block

【手順1】キャッシュを生成するコード

Notion APIのレスポンスをキャッシュ(JSONファイル)として保存する処理を書く。

  1. scriptsフォルダを作成し、その中に空のblog-contents-cache.cjsを作成する。
    Image in a image block

  2. scripts/blog-contents-cache.cjs(コピペでOK)
    const fs = require('fs');
    const { Client } = require('@notionhq/client');
    
    // ✅ご自身のNotion APIシークレットキーに書き換えてください
    const notion = new Client({ auth: "secret_xxxx" });
    
    /**
     * キャッシュを読み込む
     * 
     * @returns {Promise<Array>} - 読み込んだデータの配列
     */
    const loadCache = async () => {
      // tmpフォルダのファイル名を取得
      const contents = await fs.promises.readdir('tmp');
    
      const loadData = [];
      let loadCount = 0;
    
      // キャッシュを読み込む
      for (const content of contents) {
        // 拡張子がjsonでない場合はスキップ
        if (!content.endsWith('.json')) {
          return;
        }
        const filePath = `tmp/${content}`;
    
        const fileData = await fs.promises.readFile(filePath, 'utf-8');
        const jsonData = JSON.parse(fileData);
    
        loadData.push(jsonData);
        loadCount++;
      };
    
      console.log("[キャッシュ読み込み]", loadCount, "件読み込みました");
      return loadData;
    }
    
    /**
     * キャッシュを保存
     * 
     * @param {string} fileName - ファイル名
     * @param {Object} data - キャッシュするデータ(JSON)
     */
    const saveCacheLocal = async (fileName, data) => {
        // ファイルを保存
        fs.writeFileSync(`tmp/${fileName}.json`, JSON.stringify(data));
    }  
    
    /**
     * ページ全体をキャッシュ
     * 
     * @param {string} fileName - ファイル名
     * @param {string} pageId - ページのID
     * @param {string} last_edited_time - ページの最終更新日時
     * @param {Object} queue - リクエストを制限するためのキューオブジェクト
     */
    const savePageCache = async (fileName, pageId, last_edited_time, queue) => {
      const allBlocks = await retrieveAndWriteBlock(pageId, queue);
      if (allBlocks) {
        // ページ全体を1つのファイルにまとめて保存
        const pageData = {
          pageId: pageId,
          last_edited_time: last_edited_time,
          blocks: allBlocks,
        };
        await saveCacheLocal(fileName, pageData);
      }
    };
    
    /**
     * Notion からページを取得
     * 
     * @param {Object} queue - リクエストを制限するためのキューオブジェクト
     * @returns {Promise<Array>} - 取得したページの配列
     */
    const getAllPages = async (queue) => {
      const params = {
    	  // ✅ご自身のNotionデータベースのIDに書き換えてください
        database_id: "cb080ff46bbc430bb12184eb58b857f9",
        // ※必要であれば適宜他のパラメータも追加してOK
        // filter: { ... },
      };
    
      let results = [];
      while (true) {
        const res = await queue.add(() => notion.databases.query(params));
    
        results = results.concat(res.results);
    
        if (!res.has_more) {
          break;
        }
    
        params['start_cursor'] = res.next_cursor;
      }
    
      const pages = results.map((result) => {
        return {
          id: result.id,
          last_edited_time: result.last_edited_time,
          title: result.properties.Title.rich_text
            ? result.properties.Title.rich_text[0].plain_text
            : '',
        };
      });
    
      return pages;
    };
    
    /**
     * リトライ処理
     * 
     * @param {number} maxRetries - 最大リトライ回数
     * @param {Function} fn - リトライする関数
     * @returns {Promise} - リトライ結果のプロミス
     */
    const retry = (maxRetries, fn) => {
      return fn().catch(function (err) {
        if (maxRetries <= 0) {
          console.error("最大リトライ回数を超えました:", err);
          return null;
        }
        return retry(maxRetries - 1, fn);
      });
    };
    
    /**
     * Notionブロックを再帰的に取得し、ローカルtmpに保存
     * 
     * @param {string} blockId - ブロックのID
     * @param {Object} queue - リクエストを制限するためのキューオブジェクト
     * @returns {Promise} - ブロックの再帰的取得結果のプロミス
     */
    const retrieveAndWriteBlockChildren = async (blockId, queue, allBlocks = []) => {
      const params = { block_id: blockId };
    
      let results = [];
    
      while (true) {
        // ブロックの子要素を取得
        const res = await retry(3, () => queue.add(() => notion.blocks.children.list(params)));
        if (!res) {
          console.error("リトライ回数を超えました");
          return;
        }
    
        results = results.concat(res.results);
    
        // ブロックの子要素がない場合は終了
        if (!res.has_more) {
          break;
        }
    
        // ページネーションのトークンを更新(一度に100ブロックまでしか取得できないので続きを取得するために必要)
        params['start_cursor'] = res.next_cursor;
      }
    
      // キャッシュを保存
      saveCacheLocal(blockId, results);
    
      // すべてのブロックをまとめる
      allBlocks.push({
        id: blockId,
        blocks: results
      });
    
      // ブロックの子要素を再帰的に取得
      for (const block of results) {
        if (
          block.type === 'synced_block' && // 同期されたブロック
          block.synced_block.synced_from && // 同期元のブロックが存在する
          block.synced_block.synced_from.block_id // 同期元のブロックIDが存在する
        ) {
          try {
            // 同期元のブロックを再帰的に取得
            await retrieveAndWriteBlock(block.synced_block.synced_from.block_id, queue);
          } catch (err) {
            console.log(
              `Could not retrieve the original synced_block. error: ${err}`
            );
            throw err;
          }
        } else if (block.has_children) {
          // ブロックの子要素がある場合は再帰的に取得
          await retrieveAndWriteBlockChildren(block.id, queue, allBlocks);
        }
      }
    
      return allBlocks;
    };
    
    /**
     * ブロックを再帰的に取得
     * 
     * @param {string} blockId - ブロックのID
     * @param {Object} queue - リクエストを制限するためのキューオブジェクト
     */
    const retrieveAndWriteBlock = async (blockId, queue) => {
      const params = { block_id: blockId };
    
      // ブロックを取得
      const block = await retry(3, () => queue.add(() => notion.blocks.retrieve(params)));
    
      if (!block) {
        console.error("リトライ回数を超えました");
        return;
      }
    
      // キャッシュを保存
      saveCacheLocal(blockId, block);
    
      // すべてのブロックをまとめる
      const allBlocks = [{
        id: blockId,
        blocks: [block]
      }];
    
      // ブロックの子要素がある場合は再帰的に取得
      if (block.has_children) {
        await retrieveAndWriteBlockChildren(block.id, queue, allBlocks);
      }
    
      return allBlocks;
    };
    
    (async () => {
    	// Notion APIを1秒に3回までに制限
      const queue = new (await import('p-queue')).default({ interval: 1000, intervalCap: 3 });
    
      // -----------------------------------------------------
      // 1. Notion からページを取得(更新日を取得したい)
      // -----------------------------------------------------
      const pages = await getAllPages(queue);  
    
      // -----------------------------------------------------
      // 2. キャッシュの更新日と比較(更新があるページ、新規のページのみ抽出)
      // -----------------------------------------------------
      const cachePages = await loadCache();
      const updatedPages = pages.filter((page) => {
        const cachePage = cachePages.length > 0 ? cachePages.find((cachePage) => {
          return page.id === cachePage.pageId;
        }) : null;
    
        // キャッシュがない
        if (!cachePage) {
          return true;
        }
    
        return page.last_edited_time !== cachePage.last_edited_time;
      });
    
      // -----------------------------------------------------
      // 3. データ取得(更新があるページ、新規のページのみ)
      // -----------------------------------------------------
      if (updatedPages.length === 0) {
        console.log("記事の更新はありません");
      } else {
        for (const page of updatedPages) {
          console.log("[ページのキャッシュを開始]:", page.title);
          // -----------------------------------------------------
          // 4. キャッシュを保存
          // -----------------------------------------------------
          await savePageCache(`${page.title}.json`, page.id, page.last_edited_time, queue); // ページ全体をキャッシュ
        };
      }
    })();

    →全ブロックデータをキャッシュできる!

    (初回はキャッシュがないので全データを取得する)

【手順2】tmpフォルダを作る

手動でプロジェクトフォルダの直下に空のtmpフォルダを作っておく。

💡
tmpはキャッシュ(JSONファイル)の保存先として使う!

【手順3】キャッシュを生成する

コマンドを実行するだけでOK。

node scripts/blog-contents-cache.cjs

→初回はキャッシュがなく全データを取得するので実行に時間がかかる。

※ビルド処理中にキャッシュ生成処理を入れるのもよさそうだが、今回は簡易化のため、上記コマンドで実行する。

【手順4】キャッシュが生成されたか確認する

tmpフォルダに、大量のキャッシュ(JSONファイル)が生成されていれば成功🎉

Image in a image block

→ブロックごとにJSONファイルが生成されている。

②キャッシュを使うようにプログラムを修正

後はキャッシュを使うよう、自身のプログラムを修正するだけ!

修正イメージ

キャッシュがある場合は、キャッシュを使う。

Image in a image block

キャッシュがない場合は、従来どおりNotion APIでデータを取得する。

Image in a image block

つまりif文を追加するだけ✨

// ✅if文を追加。キャッシュからブロックデータを読み込む!
if (fs.existsSync(`tmp/${blockId}.json`)) {
  results = JSON.parse(fs.readFileSync(`tmp/${blockId}.json`, 'utf-8'))
} else {
	// 従来のNotion APIを使った処理はそのままelseの中に入れておけばOK
}

【手順1】ブロック一覧取得を修正

もしブロック一覧取得関数notion.blocks.children.list(…)を使った処理があれば、キャッシュを使うよう修正する。

例:修正前

const { Client } = require("@notionhq/client")

const notion = new Client({
    auth: ``,
})

const main = async () => {
    const blockId = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    
    // ブロック一覧取得処理
    const response = await notion.blocks.children.list({
        block_id: blockId,
    });
    const results = response.results;
}

例:修正後

const { Client } = require("@notionhq/client")

const notion = new Client({
    auth: ``,
})

const main = async () => {
    const blockId = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    let results;

    // ✅if文を追加:もしキャッシュがあれば、キャッシュを使う
	  if (fs.existsSync(`tmp/${blockId}.json`)) {
	    results = JSON.parse(fs.readFileSync(`tmp/${blockId}.json`, 'utf-8'))
	  } else {
	    // ブロック一覧取得処理
	    const response = await notion.blocks.children.list({
	        block_id: blockId,
	    });
	    results = response.results;
    }
}

【手順2】ブロック取得を修正

もしブロック取得関数notion.blocks.retrieve(…)を使った処理があれば、キャッシュを使うよう修正する。

例:修正前

const { Client } = require("@notionhq/client")

const notion = new Client({
    auth: ``,
})

const main = async () => {
    const blockId = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    
    // ブロック取得処理
    const results = await notion.blocks.retrieve({
        block_id: blockId,
    });
}

例:修正後

const { Client } = require("@notionhq/client")

const notion = new Client({
    auth: ``,
})

const main = async () => {
    const blockId = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    let results;

    // ✅if文を追加:もしキャッシュがあれば、キャッシュを使う
	  if (fs.existsSync(`tmp/${blockId}.json`)) {
	    results = JSON.parse(fs.readFileSync(`tmp/${blockId}.json`, 'utf-8'))
	  } else {
	    // ブロック取得処理
	    results = await notion.blocks.retrieve({
	        block_id: blockId,
	    });
    }
}

③今までどおりプログラムを実行する

これで実装は完了✨

自身のプログラムを実行すると(例:npm run devnpm run buildなど)、Notion APIの代わりにキャッシュが使われて高速化されているはず!

まとめ

Notion APIのブロックデータのキャッシュ(JSONファイル)を保存して、そのキャッシュを使う方法を紹介した。

全体の流れを振り返ると、たった2ステップでシンプルな仕組み✨

  1. ビルドの前に、キャッシュの生成スクリプトを実行しておく。

    →「新規ページ」 または 「更新があるページ」のキャッシュが生成される。

  2. ビルドを実行する。

    →キャッシュがあるデータについては、Notion APIを使わずにキャッシュを使う。

この仕組みを導入すれば2回目以降のビルドはキャッシュを使って大幅にビルド時間を短縮できるはず✅

参考サイト