はじめに
Vercel や Cloudflare Pagesでは、ビルドは「45分まで」などの時間制限がある。
ただしNotion APIで多数のデータを取得すると45分を超えてビルドできないことがある💦
この記事では、キャッシュを使ってビルド時間を短縮する方法を紹介する😊
ビルドに時間がかかる原因
ブロック数が多く、何度もNotion APIのリクエストが必要なため時間がかかってしまう。
解決方法
①あらかじめローカルでNotion APIのレスポンスをキャッシュ(JSONファイル)として保存しておく。
→時間がかかるのであらかじめローカルで実行する。
②クラウドでビルドするときは保存しておいたキャッシュ(JSONファイル)を使う。
→大量のNotion APIのリクエストが不要!ビルドが高速に!
上図は簡易なイメージ。
実際はこれに加えて、キャッシュを更新する処理も実装する。
準備
使用技術の紹介
プログラミング言語
「キャッシュの扱い方」の話なので、プログラミング言語にかかわらず使える。
(今回はJavaScriptを使って説明する)
事前に用意しておくもの
- Node.js
- npm
- @notionhq/client(Notion APIのSDK)
今からインストールするもの
- p-queue
今から用意するもの
- キャッシュの保存先
p-queueをインストールする
p-queueをインストールする。
npm install p-queue
今回のキャッシュとは直接関係ない。
Notion APIのレート制限にかからないよう、APIの実行数を制御するために使う。
キャッシュの保存先を用意する
キャッシュをS3などのクラウド上に保存したいので、保存先を用意する。
保存先はクラウド上なら何でもいい。
例:Googleドライブ、Dropbox、S3、CloudflareR2など
①Cloudflare R2のアカウントを作成
公式サイトから無料でアカウントを作成する。
(無料枠でもクレジットカードの登録は必要)
②バケットを作成する
アカウントの作成が完了したら、バケットを作成する。
適当な名前を入力して、バケットを作成する。
③APIキーを発行する
[概要 − R2 APIトークンの管理]をクリック。
[APIトークンを作成する]をクリック。
権限は[オブジェクトの読み取りと書き込み]にする。
バケットの指定は[特定のバケットのみに適用]で先程作ったバケットを選択する。
各値をメモしておく。
(二度と表示されないので忘れずメモする)
④「@aws-sdk/client-s3」をインストールする
「@aws-sdk/client-s3」をインストールする。
(他のプログラミング言語の場合は同等のS3ライブラリをインストールする)
npm install @aws-sdk/client-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)
- 初回
このプログラムをローカル(自分のPC)で実行する。
キャッシュがないため以下の動作になる。
④で全データを取得
→⑤全データのキャッシュを生成
→⑥全データのキャッシュをアップロード!
💡初回は時間がかかるのでローカルで実行する必要がある!(Vercelなどで実行するとタイムアウトしてしまう可能性がある)
- 2回目以降
このプログラムをVercelやCloudflare Pagesで実行する。
キャッシュがあるため以下の動作になる。
④で差分だけ取得
→⑤差分だけキャッシュを生成
→⑥差分のキャッシュをアップロード!
💡差分だけリクエストするので時間がかからない!(Vercelなどで実行してもタイムアウトにならない)
【手順1】キャッシュを生成するコード
早速、先ほど紹介したイメージ画像のプログラムを作成する。
(1ファイルをコピペするだけでキャッシュ生成や保存がすべてできる)
scripts
フォルダを作成し、その中に空のblog-contents-cache.cjs
を作成する。- 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); // ページ全体をキャッシュ } } })();
→実行すると先ほどのイメージ図のとおり動作する!
【注意】NotionのAPIキー
“secret_xxxx”
は必要であれば環境変数に置き換える。
【手順2】tmpフォルダを作る
- 手動でプロジェクトフォルダの直下に空のtmpフォルダを作る。
(tmpはキャッシュ(JSONファイル)の保存先として使う)
Gitを使っている場合は追加で以下の2つもしておく!
- tmpフォルダの中に
.gitkeep
という空ファイルを作る。 - .gitignoreに
tmp
フォルダの除外設定を追加する。tmp/
【手順3】キャッシュを生成する
初回だけローカルで実行する✅
(2回目以降はVercelやCloudflare Pagesのビルド時に実行する → 後で設定する)
コマンドを実行するだけでOK。
node scripts/blog-contents-cache.cjs
→初回はキャッシュがなく全データを取得するので実行に時間がかかる。
【手順4】キャッシュが生成されたか確認する
2箇所(ローカルとCloudflare R2)にキャッシュが生成されている確認する。
- ローカルのtmpフォルダ
大量のキャッシュ(JSONファイル)が生成されていれば成功🎉
→ブロックごとにJSONファイルが生成されている。
- Cloudflare R2
ページの数だけキャッシュ(JSONファイル)が生成されていれば成功🎉
→ページごとに1ファイルにまとめて保存している。
(ファイル数が多いとダウンロードに時間がかかるため、まとめて保存している。)
②キャッシュを使うようにプログラムを修正
キャッシュを使うよう、自身のプログラムを修正する!
修正イメージ
キャッシュがある場合は、キャッシュを使う。
キャッシュがない場合は、従来どおりNotion APIでデータを取得する。
つまり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 dev
、npm run build
など)、Notion APIの代わりにキャッシュが使われて高速化されているはず!
③ビルド前に自動でキャッシュを更新する設定
あとはビルド前に(差分だけ)キャッシュを更新するようにすれば完成!
【手順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ファイル)を保存して、クラウドでのビルド時間を短縮する方法を紹介した。
手順は少し多かったが、仕組みはシンプル✨
- 初回だけローカルでキャッシュを生成(アップロード)する。
→「全ページ」のキャッシュが生成される。
(実行に時間がかかるのでローカルで実行する)
- 2回目以降はビルド前に自動でキャッシュを更新(アップロード)する。
→「新規ページ」 または 「更新があるページ」のキャッシュが更新される。
この仕組みを導入すれば2回目以降のビルドはキャッシュを使って大幅にビルド時間を短縮できるはず✅