【Notion】読了時間と「本文の更新日」を CI で Notion に書き戻すようにした

この記事でわかること
- 一覧カードに読了時間が出ていなかった理由(一覧では本文を取っていない)
- 別のデータストアを足さずに、Notion のプロパティへ書き戻すことにした理由
- 本文のハッシュで「本文が変わったときだけ更新日を進める」仕組み
- ContentChecked に開始時刻を入れていて、105本中94本を毎回読み直していた不具合
- デプロイのたびに Notion キャッシュを読み直させるためにキーにビルド ID を混ぜた話
Notion を CMS にして Next.js のブログを動かしている方向けです。CI は GitHub Actions、配信は Cloudflare Pages の前提で書いています。
概要
この記事では、このブログの一覧カードに出るはずだった「N分で読める」が、実は一度も表示されていなかった問題を直したときのことを書いていきます。
直し方としては、デプロイの前に GitHub Actions で本文を読んで、読了時間を Notion のプロパティに書き戻すようにしました。ついでに、ずっと気になっていた「タグを付け替えただけで記事が『更新』扱いになる」問題も同じ仕組みで直しています。
書き戻すだけなら割とすぐ終わると思っていたのですが、自分の書き込みで last_edited_time が動く、というところで一度ハマったので、その話も残しておきます。
対象者
- Notion を CMS にしてブログを運用している方
- 一覧ページで本文由来の値(読了時間など)を出したいけど、一覧で本文を取りに行きたくない方
動作環境
| 項目 | 内容 |
|---|---|
| フレームワーク | Next.js 15(App Router、Edge Runtime) |
| 配信 | Cloudflare Pages |
| CMS | Notion(REST API を直接 fetch) |
| CI | GitHub Actions |
| 書き戻しスクリプト | Node.js 22 |
なぜ読了時間が出ていなかったのか
記事詳細のページでは、本文を組み立てたあとに文字数から読了時間を出しています。600字で1分という基準です。
一方、一覧カードは Notion のデータベースクエリの結果(プロパティ)だけで描いていて、本文のブロックは取りに行っていません。記事ごとに本文を取ると、1本あたり数往復かかります。Notion の API は平均3リクエスト/秒に制限されているので、一覧のたびにそれをやるのは無理でした。
結果として、一覧のカード側には読了時間の値が無く、表示の条件を満たさないので何も出ていなかった、という感じです。コードはあるのに一度も出ていなかったので、正直気づくのが遅れました...
Notion に書き戻すことにした
選択肢としては、KV や D1 に読了時間を持たせる方法もありました。ただ、記事の正のデータは Notion にあって、一覧はすでに Notion のプロパティを読んでいます。プロパティを1つ増やして、そこに値が入っていれば、一覧側は今のクエリのまま読めます。
なので、新しいデータストアは足さずに、Notion に下記の4つのプロパティを作って CI から書き戻すことにしました。
| プロパティ | 型 | 中身 |
|---|---|---|
ReadingTime |
Number | 読了の目安(分) |
BodyHash |
Text | 本文のハッシュ |
ContentUpdated |
Date | 本文が最後に変わった日時 |
ContentChecked |
Date | スクリプトが最後に確認した日時 |
deploy.yml では、ビルドの前に scripts/sync-article-meta.mjs を動かしています。失敗してもデプロイは止めないようにしました。止まっても「読了時間が出ない」「更新日が従来どおりになる」だけなので、デプロイを巻き込むほどではないかなと思っています。
- name: Sync article metadata to Notion
env:
NOTION_API_KEY: ${{ secrets.NOTION_SYNC_API_KEY || secrets.NOTION_API_KEY }}
NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
run: node scripts/sync-article-meta.mjs
timeout-minutes: 10
continue-on-error: true(NOTION_SYNC_API_KEY のほうは、書き込み用のトークンを分けた話なので別の記事で書きます)
本文が変わったときだけ更新日を進める
サイトの「最終更新日」は、これまで Notion の last_edited_time をそのまま出していました。これだとタグを足しただけでも「更新」になりますし、今回のスクリプトがプロパティを書いた時点でも動いてしまいます。
そこで、本文のブロックから指紋を作ってハッシュを取り、前回の BodyHash と違うときだけ ContentUpdated を進めるようにしました。サイト側は ContentUpdated を優先して表示します。
指紋を作るときに1つ気をつけたのが画像です。Notion にアップロードした画像の URL は取得のたびに署名が変わるので、URL をそのままハッシュに入れると、何も変えていないのに毎回「本文が変わった」になります。アップロード画像はブロック ID で代用しました。
if (['image', 'video', 'file', 'pdf', 'audio'].includes(type)) {
mark = data.type === 'external' ? data.external?.url ?? '' : block.id;
}また、初回(BodyHash がまだ空)は、書き込んだ瞬間を「更新」にしないように、今の更新日を ContentUpdated に引き継いでいます。これをやらないと、初回のデプロイで全記事の更新日が今日になってしまいます。
毎回ほぼ全記事を読み直していた
本文を毎回全部読むと、記事が約100本あるので数分かかります。なので「last_edited_time が ContentChecked より新しい記事だけ読む」という条件を付けていました。
ところが、入れた直後のデプロイのログを見ると、本文を変えていない記事を105本中94本読み直していました。
原因は ContentChecked に入れていた時刻です。スクリプトの開始時刻を全記事に入れていたのですが、スクリプトは1本ずつ順番に書き込むので、後半の記事を書き込むのは開始から数分後になります。その書き込みで last_edited_time が動くので、開始から1分以上たってから書いた記事は「last_edited_time > ContentChecked」になり、次の回で「編集された」と判定されていました。
直し方としては、書き込む直前の時刻を入れるようにしただけです。
// 書き込む直前の時刻にする。開始時刻だと、1分以上たってから書いた記事は書き込みで動いた
// last_edited_time のほうが新しくなり、次の回に「編集された」と読み直してしまう
ContentChecked: { date: { start: new Date().toISOString() } },last_edited_time は分単位に丸められるので、書き込み直前の時刻を入れておけば、自分の書き込みで動いた分は ContentChecked 以下に収まります。同じ分のうちに人が編集した分は取りこぼしますが、次に編集したときに拾われるので、そこは割り切っています。
ちなみに、本文の変更判定(BodyHash)のほうはこの間も正しく動いていて、読み直した94本はすべて「本文の変更なし」でした。無駄に読んでいただけで、更新日がずれたりはしていません。
書き戻した値がすぐサイトに出ない問題
もう1つ、書き戻した値がなかなかサイトに出ない問題がありました。
このブログは Notion への往復を減らすために、Cloudflare の Cache API に Notion の取得結果を持っています。デプロイでは CDN を全パージしているのですが、Cache API は CDN のパージでは消えません。なので、デプロイ直後のウォームがこの層に残った古い値で CDN を埋め直してしまい、書き戻した読了時間が出ない、ということが起きます。
以前から「Notion を直したら15分待ってからデプロイ」という運用で避けていたのですが、毎回 CI が書き込むとなるとそれでは無理です。そこで、キャッシュのキーにビルド ID を混ぜて、デプロイのたびに Notion を読み直させるようにしました。ビルド ID は GitHub Actions の run ID を next.config.js で埋めています。
NOTION_CACHE_BUILD_ID: process.env.GITHUB_RUN_ID
? `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT || 1}`
: String(Date.now()),これで15分待つ運用も要らなくなりました。ただ、キーが変わるとデプロイ直後は「Notion が 429 を返したときの代役(古い値)」が1つも無い状態になります。そこはあとで、前のビルドまでに取れた値も代役として使えるように足しています。
感想
Notion を CMS にしていると、「本文から計算できる値」をどこに持つかは割と悩むところだと思います。今回は Notion 自体に書き戻す形にしましたが、データの置き場所が増えないので、個人的にはかなり運用が楽だなと思いました。
一方で、書き込むと last_edited_time が動くので、「自分の書き込み」と「人の編集」をどう見分けるかは最初にちゃんと考えておくべきでした。開始時刻を入れていたのは完全に自分のミスで、ログを見なかったら毎回全記事を読み直していたと思います(多分気づかないまま)。
おわりに
一覧に読了時間が出るようになり、連載ページでは全回そろっているときだけ合計の読了時間も出しています。更新日もタグの付け替えでは動かなくなりました。
同じように Notion を CMS にしている方の参考になれば嬉しいです。


