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

Featured image of the post

はじめに

Vercel や Cloudflare Pagesでは、ビルドは「45分まで」などの時間制限がある。

ただしNotion APIで多数のデータを取得すると45分を超えてビルドできないことがある💦

この記事では、キャッシュを使ってビルド時間を短縮する方法を紹介する😊

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

📎
【関連記事(ローカルでビルドする場合はこちら)】

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

ビルドに時間がかかる原因

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

Image in a image block

解決方法

①あらかじめローカルでNotion APIのレスポンスをキャッシュ(JSONファイル)として保存しておく。

Image in a image block

→時間がかかるのであらかじめローカルで実行する。

②クラウドでビルドするときは保存しておいたキャッシュ(JSONファイル)を使う。

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の実行数を制御するために使う。

キャッシュの保存先を用意する

キャッシュをS3などのクラウド上に保存したいので、保存先を用意する。

Image in a image block

保存先はクラウド上なら何でもいい。

例:Googleドライブ、Dropbox、S3、CloudflareR2など

💡
今回はCloudflare R2(S3互換)の無料枠を使う方法を解説する!

①Cloudflare R2のアカウントを作成

公式サイトから無料でアカウントを作成する。

(無料枠でもクレジットカードの登録は必要)

Image in a image block

②バケットを作成する

アカウントの作成が完了したら、バケットを作成する。

Image in a image block

適当な名前を入力して、バケットを作成する。

Image in a image block

③APIキーを発行する

[概要 − R2 APIトークンの管理]をクリック。

Image in a image block

[APIトークンを作成する]をクリック。

Image in a image block

権限は[オブジェクトの読み取りと書き込み]にする。

バケットの指定は[特定のバケットのみに適用]で先程作ったバケットを選択する。

Image in a image block

各値をメモしておく。

(二度と表示されないので忘れずメモする)

Image in a image block

④「@aws-sdk/client-s3」をインストールする

「@aws-sdk/client-s3」をインストールする。

(他のプログラミング言語の場合は同等のS3ライブラリをインストールする)

npm install @aws-sdk/client-s3

💡
Cloudflare R2はS3互換なのでS3のパッケージをインストールする!

⑤APIキー等を環境変数に設定

③でメモした値と、②で作ったバケット名を環境変数をセットしておく。

export R2_ACCESS_KEY_ID="xxxxxxxxxxxxxxxxxxx"
export R2_SECRET_ACCESS_KEY="xxxxxxxxxxxxxxxxxxx"
export ENDPOINT="https://xxxxxxxxxxxxxxxxxxx.r2.cloudflarestorage.com"
export BUCKET_NAME="バケット名"

💡
【注意】

2箇所で環境変数の設定を行う!

  • ローカル(自分のマシン)

    →.envやコマンドで環境変数を設定できる。

  • サーバー(Vercelなど)

    →管理画面などで環境変数の設定ができる。

実装

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

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

今から作成するプログラムのイメージ

「データが新しければキャッシュを生成する」プログラムを作る✅

(後で紹介するコードをコピペすれば動くので読み飛ばしてもOK

Image in a image block

  • 初回

    このプログラムをローカル(自分のPC)で実行する。

    キャッシュがないため以下の動作になる。

    ④で全データを取得

    →⑤全データのキャッシュを生成

    →⑥全データのキャッシュをアップロード!

    💡
    初回は時間がかかるのでローカルで実行する必要がある!

    (Vercelなどで実行するとタイムアウトしてしまう可能性がある)

  • 2回目以降

    このプログラムをVercelやCloudflare Pagesで実行する。

    キャッシュがあるため以下の動作になる。

    ④で差分だけ取得

    →⑤差分だけキャッシュを生成

    →⑥差分のキャッシュをアップロード!

    💡
    差分だけリクエストするので時間がかからない!

    (Vercelなどで実行してもタイムアウトにならない)

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

早速、先ほど紹介したイメージ画像のプログラムを作成する。

(1ファイルをコピペするだけでキャッシュ生成や保存がすべてできる)

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

  2. scripts/blog-contents-cache.cjs(コピペでOK)
    const { S3Client, PutObjectCommand, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3');
    const fs = require('fs');
    const { Client } = require('@notionhq/client');
    
    
    // ✅ご自身のNotion APIシークレットキーに書き換えてください
    const notion = new Client({ auth: "secret_xxxx" });
    
    // ✅Cloudflare R2以外のサービスを利用する場合は、適宜コードを変更してください
    const S3 = new S3Client({
      region: 'auto',
      endpoint: process.env.ENDPOINT,
      credentials: {
        accessKeyId: process.env.R2_ACCESS_KEY_ID,
        secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
      },
    });
    
    /**
     * Cloudflare R2にデータをアップロード
     * ✅他のサービスを利用する場合は、適宜コードを変更してください
     * 
     * @param {string} key - アップロードするファイルのキー(ファイル名)
     * @param {Buffer} data - アップロードするデータのバッファ
     */
    const uploadFileS3 = async (key, data) => {
      const command = new PutObjectCommand({
        Body: data,
        Bucket: process.env.BUCKET_NAME,
        Key: key,
        ContentType: 'application/json',
      })
    
      try {
        await S3.send(command);
        console.log("[データをアップロード]", key);
      } catch (err) {
        console.error(err);
      }
    };
    
    /**
     * Cloudflare R2にデータを取得(すべて)
     * ✅他のサービスを利用する場合は、適宜コードを変更してください
     * 
     * @returns {Promise<{Contents: Array}>} - 取得したデータの配列を含むオブジェクト
     */
    const getAllDataS3 = async () => {
      let isTruncated = true; // ページネーションの継続フラグ
      let continuationToken = null; // ページネーションのトークン
      const allContents = []; // 取得したデータ
    
      while (isTruncated) {
        // ページネーションのトークンを指定したリクエスト(一度の1000件までしか取得できないので続きを取得するために必要)
        const command = new ListObjectsV2Command({
          Bucket: process.env.BUCKET_NAME,
          Prefix: '',
          ContinuationToken: continuationToken,
        });
    
        try {
          // データを取得
          const response = await S3.send(command);
          if (!response.Contents) {
            break;
          }
          allContents.push(...response.Contents);
    
          // ページネーションの継続フラグを更新
          isTruncated = response.IsTruncated;
          continuationToken = response.NextContinuationToken;
        } catch (err) {
          console.error(err);
          break;
        }
      }
    
      return { Contents: allContents };
    }
    
    /**
     * Cloudflare R2からデータを取得
     * ✅他のサービスを利用する場合は、適宜コードを変更してください
     * 
     * @param {string} key - 取得するファイルのキー(ファイル名)
     * @returns {Promise<{Body: ReadableStream}>} - 取得したデータのレスポンス
     */
    const getDataS3 = async (key) => {
      const command = new GetObjectCommand({
        Bucket: process.env.BUCKET_NAME,
        Key: key,
      });
    
      try {
        const response = await S3.send(command);
        return response;
      } catch (err) {
        console.error(err);
      }
    }
    
    /**
     * Cloudflare R2からデータをダウンロード
     * ✅他のサービスを利用する場合は、適宜コードを変更してください
     * 
     * @param {string} key - ダウンロードするファイルのキー(ファイル名)
     * @param {string} path - ダウンロードしたファイルを保存するローカルパス
     */
    const downloadFileS3 = async (key, path) => {
      try {
        const response = await getDataS3(key);
        const data = await response.Body.transformToByteArray();
        
        fs.writeFileSync(path, data);
      } catch (err) {
        console.error(err);
      }
    }
    
    /**
     * キャッシュを読み込む
     * Cloudflare R2からすべてのデータをダウンロードして、ブロックごとに分割してローカルtmpに保存
     * 
     * @returns {Promise<Array>} - ダウンロードしたデータの配列
     */
    const loadCache = async () => {
      const objects = await getAllDataS3();
      const contents = objects.Contents;
    
      if (!contents || contents.length === 0) {
        console.log("[データをすべてダウンロード]0件ダウンロードしました");
        return [];
      }
    
      const downloadedData = [];
      let downloadCount = 0;
    
      for (const content of contents) {
        const key = content.Key;
        const filePath = `tmp/${key}.json`;
    
        // ファイルが既に存在するかチェック
        if (!fs.existsSync(filePath)) {
          await downloadFileS3(key, filePath);
          downloadCount++; // ダウンロードした件数をカウント
        }
    
        const fileData = await fs.promises.readFile(filePath, 'utf-8');
        const jsonData = JSON.parse(fileData);
    
        // ブロックごとに分解して保存
        for (const block of jsonData.blocks) {
          if (!block.id || !block.blocks) {
            continue;
          }
          // キー名をIDに変更
          const blockId = block.id;
          fs.writeFileSync(`tmp/${blockId}.json`, JSON.stringify(block.blocks));
        }
    
        downloadedData.push(jsonData);
      }
    
      console.log("[データをすべてダウンロード]", downloadCount, "件ダウンロードしました");
      return downloadedData;
    }
    
    /**
     * キャッシュを保存(ローカル)
     * 
     * @param {string} fileName - ファイル名
     * @param {Object} data - キャッシュするデータ(JSON)
     */
    const saveCacheLocal = async (fileName, data) => {
        // ファイルを保存
        fs.writeFileSync(`tmp/${fileName}.json`, JSON.stringify(data));
    }  
    
    /**
     * キャッシュを保存(リモート)
     * 
     * @param {string} fileName - ファイル名
     * @param {Object} block - キャッシュするデータ(JSON)
     */
    const saveCacheRemote = async (fileName, data) => {
      // Cloudflare R2にアップロード
      await uploadFileS3(`${fileName}`, JSON.stringify(data));
    }
    
    
    /**
     * 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 retry(3, () => 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に保存、リモートCloudflare R2にアップロード
     * 
     * @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;
    };
    
    /**
     * ページ全体をキャッシュ
     * 
     * @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);
        await saveCacheRemote(fileName, pageData);
      }
    };
    
    (async () => {
    	// Notion APIを1秒に3回までに制限
      const queue = new (await import('p-queue')).default({ interval: 1000, intervalCap: 3 }) // Notion APIを1秒に3回までに制限
    
      // -----------------------------------------------------
      // 1. Notion からページを取得(更新日を取得したい)
      // -----------------------------------------------------
      const pages = await getAllPages(queue);
    
      // -----------------------------------------------------
      // 2. Cloudflare R2 からキャッシュを取得
      // -----------------------------------------------------
      const cachePages = await loadCache();
    
      // -----------------------------------------------------
      // 3. キャッシュの更新日と比較(更新があるページ、新規のページのみ抽出)
      // -----------------------------------------------------
      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;
      });
    
      // -----------------------------------------------------
      // 4. データ取得(更新があるページ、新規のページのみ)
      // -----------------------------------------------------  
      if (updatedPages.length === 0) {
        console.log("記事の更新はありません");
      } else {
        for (const page of updatedPages) {
          console.log("[ページのキャッシュを開始]:", page.title);
          // -----------------------------------------------------
          // 5. キャッシュを生成
          // 6. キャッシュをアップロード
          // -----------------------------------------------------
          await savePageCache(`${page.title}.json`, page.id, page.last_edited_time, queue); // ページ全体をキャッシュ
        }
      }
    })();

    →実行すると先ほどのイメージ図のとおり動作する!

    Icon in a callout block
    【注意】

    NotionのAPIキー“secret_xxxx”必要であれば環境変数に置き換える。

【手順2】tmpフォルダを作る
  • 手動でプロジェクトフォルダの直下に空のtmpフォルダを作る。

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

Gitを使っている場合は追加で以下の2つもしておく!

  • tmpフォルダの中に.gitkeepという空ファイルを作る。
    Image in a image block

  • .gitignoreにtmpフォルダの除外設定を追加する。
    tmp/

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

初回だけローカルで実行する✅

(2回目以降はVercelやCloudflare Pagesのビルド時に実行する → 後で設定する)

Image in a image block

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

node scripts/blog-contents-cache.cjs

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

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

2箇所(ローカルとCloudflare R2)にキャッシュが生成されている確認する。

  • ローカルのtmpフォルダ

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

    Image in a image block

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

  • Cloudflare R2

    ページの数だけキャッシュ(JSONファイル)が生成されていれば成功🎉

    Image in a image block

    →ページごとに1ファイルにまとめて保存している。

    (ファイル数が多いとダウンロードに時間がかかるため、まとめて保存している。)

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

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

修正イメージ

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

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の代わりにキャッシュが使われて高速化されているはず!

③ビルド前に自動でキャッシュを更新する設定

あとはビルド前に(差分だけ)キャッシュを更新するようにすれば完成!

Image in a image block

【手順1】package.jsonを修正する

ビルドコマンドにnode scripts/blog-contents-cache.cjs &&を付けるだけ✨

(=ビルド前にscripts/blog-contents-cache.cjsを実行する)

例:astroの場合

package.json

{
  "scripts": {
	  // 修正前
    // "build": "astro build",
    // 修正後
    "build": "node scripts/blog-contents-cache.cjs && astro build",

    ...
  },
	...
}

npm run buildキャッシュを更新 && ビルドが実行される!

④ビルドする

これで実装は完了✨

これまでの変更をすべてVercel や Cloudflare Pagesにアップ(反映)して、ビルドするとNotion APIの代わりにキャッシュが使われて高速化されているはず!

まとめ

Notion APIのブロックデータのキャッシュ(JSONファイル)を保存して、クラウドでのビルド時間を短縮する方法を紹介した。

手順は少し多かったが、仕組みはシンプル✨

  1. 初回だけローカルでキャッシュを生成(アップロード)する。

    →「全ページ」のキャッシュが生成される。

    (実行に時間がかかるのでローカルで実行する)

  2. 2回目以降はビルド前に自動でキャッシュを更新(アップロード)する。

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

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

参考サイト