リモート開発メインのソフトウェア開発企業のエンジニアブログです

Notionで書き溜めた約1,000件のCDレビューをWebサーバーで安全に公開する

目次

はじめに

現在個人サイトでCDレビューを公開しています。公開しているCDレビュー(約1,000件)は、Notionのデータベースで書いています。公開ページはNotionを直接読まず、ビルド時にNotionから取り出したデータを検証してSQLiteにまとめ、共用レンタルサーバーのPHPで描画しています。

この記事の主題は、Notionで書いた内容を、壊れていないことを確かめてから公開用のデータに変える仕組みです。配信側(SQLite・PHP・デプロイ)は、その結果をどう置くかとして短く紹介します。セットアップ手順ではなく設計の紹介記事なので、すべての手順は扱いません。

タイトルの「安全に」は、次の3つを指しています。

  • 公開するデータの安全:壊れたデータ、古いデータ、掲載の確認が済んでいない画像を公開しない
  • 原本の安全:Notionのデータを誤って書き換えない
  • 公開サーバーの安全:公開側からデータを書き換えられる入口を作らない

サイトに対するあらゆる攻撃や障害を防げる、という意味ではありません。

想定読者

  • Notionで管理している情報をWebサイトに出したい人
  • Notion APIの基本(インテグレーション、データベースの取得)を知っている人

結論

  • Notionは書く場所として使い、公開ページはビルド時に作ったSQLiteから描画する
  • 安全に公開するため、同期では、公開URLの維持、確認済みデータの欠落検知、本番での厳格な同期失敗の扱い、の3つを特に重視した
  • 結果として、Notionや音楽サービスのAPIへサーバーから接続せずに、レビュー本文と一覧を表示できる

全体像

【公開までの経路】
 Notionで編集
   │  ビルド時にAPIで全件取得
   ▼
 同期と検証 ── 問題があればここで停止
   │
   ▼
 生成JSON(Gitで管理)
   │
   ▼
 SQLite・検索用JSON・release.php を生成
   │
   ▼
 サーバーへ転送(release.php を最後に転送)
   │
   ▼
 PHPがSQLiteを読んで描画

【必要なときだけ使う経路】
 運用スクリプト ──API──▶ Notionへ書き戻し(確認済みリンクの登録など)Code language: CSS (css)

公開までの経路では、Notionを読むだけで書き込みはしません。Notionへの書き込みは、別に用意した運用スクリプトを手動で実行したときだけ行います。

原本を安全に保つため、一括で書き込むスクリプトは、書き込み用のフラグと対象件数の両方を指定しないと実行されません。指定した件数と実際の対象件数が違えば、何も書き込まずに止まります。

制約とこの構成を選んだ理由

守りたかったこと

種類内容
編集レビューの追加・修正はこれまでどおりNotionで行う
表示Notionや音楽サービスのAPIが落ちていても、レビュー本文と一覧は表示できる
URL公開済みの詳細ページのURL、canonical、OGP、構造化データ(JSON-LD)を変えない
権利ジャケット画像は、掲載してよいと確認できたものだけ出す
サーバーNode.jsの常駐やデータベースサーバーは使えない

以前の構成で困っていたこと

以前はAstroで全レビューの詳細ページを静的HTMLとして生成し、ポータルや他のアプリと一緒にサイト全体を一括でデプロイしていました。この「全件生成・一括デプロイ」の構成では、レビュー1件を直すだけでも全ページの生成とサイト全体の転送が必要になり、レビューが増えるほど作業が重くなっていました。

検討した選択肢

案良い点見送った理由
当時の静的生成構成を続ける構成が単純全件生成・一括デプロイの重さが残る
ページ表示のたびにNotion APIを読む書いた内容がすぐ反映されるレート制限があり、Notionの障害がそのまま表示の障害になる
WordPressなどのCMSに移す管理画面がそろっている約1,000件の移行が必要。公開サーバーに管理画面を置くことになる
ビルド時にNotionからSQLiteを作る(採用)Notionで書けて、表示時にNotionへ接続しない反映にはビルドとデプロイが必要(後述)

Notionを原本に残す理由

この構成の選択に関わる点に絞ると、次のとおりです。

  • 管理画面を作らなくてよい:書く画面、一覧、検索、絞り込みがNotionに最初からあります。公開サーバーには管理画面もログイン画面も置かずに済みます。
  • 型のあるプロパティで入力をそろえられる:点数は数値、日付は日付として入るので、同期側で文字列を解析しなくて済みます。セレクトを使うと、自由入力に比べて表記ゆれを抑えられます。
  • リレーションで整理できる: ジャンルは別のデータベースにまとめ、リレーションで参照しています。ジャンル名を直せば、次回の同期・ビルド・デプロイの後に全レビューへ反映されます。
  • APIで読み書きできる:人が画面で書き、確認済みのリンクの登録や空欄の一括補完はスクリプトがAPIで行う、という分担ができます。

主なプロパティは次のとおりです。

プロパティ型役割
アルバム名タイトル作品名
アーティストテキストアーティスト名
一言コメントテキスト公開本文
点数数値評価
リリース日/レビュー公開日日付並び順・表示
ジャンルリレーションジャンル用データベースを参照
公開状態数式「公開」のものだけサイトに出す
ジャケット掲載区分セレクト画像の出し方
Apple Musicリンク/SpotifyリンクURL配信サービスへのリンク
スラッグテキスト任意のURL

一方で、公開サイトからNotionを直接読むには扱いにくい点があります。

このため、Notionを読むのはビルド時だけにしました。

同期で工夫したこと

この章の工夫は、どれも「公開するデータの安全」と「原本の安全」のためのものです。問題を見つけたら、公開用のデータを作らずに止めることを基本にしています。掲載するコードは実装からの抜粋で、説明に関係しない部分は省略しています。

取得はシンプルに、エラーには慎重に

取得自体は、データソースのクエリを100件ずつ、next_cursor がなくなるまで繰り返すだけです。Notion APIは2025-09-03版で「データソース」という単位が導入されたため[Upgrade guide]、ここでは Notion-Version: 2026-03-11 を指定して /v1/data_sources/{id}/query を使っています。

do {
  const body = { page_size: 100 };
  if (cursor) body.start_cursor = cursor;
  const response = await notionRequest(`/v1/data_sources/${dataSourceId}/query`, {
    method: 'POST',
    body: JSON.stringify(body),
  });
  results.push(...response.results.filter((item) => item.object === 'page'));
  cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);

一時的なエラーは、HTTPメソッドによって再試行するかどうかを変えています。

const RETRYABLE_SERVER_STATUSES = new Set([500, 502, 503, 504]);
const ALWAYS_RETRYABLE_STATUSES = new Set([429, 529]);

const retryServerErrors = method === 'GET'
  || (method === 'POST' && /\/data_sources\/[^/]+\/query$/.test(path));

const retryable = ALWAYS_RETRYABLE_STATUSES.has(response.status)
  || (retryServerErrors && RETRYABLE_SERVER_STATUSES.has(response.status));
応答読み取り(GET・データソースのクエリ)書き込み(PATCHなど)
429・529(混雑・レート制限)再試行する再試行する
500・502・503・504再試行する再試行しない
タイムアウト再試行する再試行しない

429と529は、リクエストが処理されずに返ってきたことが分かるので、書き込みでも送り直します。5xxやタイムアウトでは、書き込みが反映されたかどうかが応答から分かりません。この実装では、そうした場合に自動では送り直さず、エラーとして止めて人が確認する方針にしています。原本に同じ更新を重ねて送らないための、安全側の選択です。

待ち時間は Retry-After ヘッダーがあればそれに従い、なければ 2^attempt 秒(最大30秒)に少しのランダムな揺らぎを足します。最大6回まで試します。

ジャンルのようなリレーションは、ページの応答にすべての参照が含まれない場合があります。has_more が true のときは、プロパティ単体を取得するAPI[Retrieve a page property item]で残りを読みます。

公開URLを変えない

詳細ページのURLは /cd-reviews/<slug>/ です。スラッグはNotionで手入力したものがあればそれを使い、なければ「アーティスト名-アルバム名-ページID末尾8桁」から作ります。

const compactId = String(id || '').replace(/[^a-f0-9]/gi, '').toLowerCase();
// Notion IDの先頭側は作成時刻が近いページで共通しやすいため、ランダム性の高い末尾を使う。
const suffix = compactId.slice(-GENERATED_SLUG_ID_LENGTH);
const base = slugify(`${artist || ''}-${album || ''}`).slice(0, maxBaseLength).replace(/-+$/g, '')
  || 'cd-review';
return `${base}-${suffix}`;

これだけでは、後からNotionでアルバム名の表記を直したとき、スラッグも変わってしまいます。公開済みのURLが変わると、外部からのリンクや検索エンジンの評価が失われます。

そこで、自動生成のスラッグは前回の生成JSONにあるものを使い続けます。

const existingRoute = existingRoutes.get(id);
const generatedSlug = !manualSlug && existingRoute?.slugSource === 'generated'
  ? existingRoute.slug
  : createReviewSlug({ artist, album, id, manualSlug });

existingRoutes は前回の生成JSONから読んだ、ページIDとスラッグの対応表です。表記を直してもURLは変わらず、新しく追加したレビューだけに新しいスラッグが付きます。

加えて、all や page など固定ページのパスと重なるスラッグや、重複したスラッグがあればビルドを止めます。

確認済みのデータが消えたら止める

同期でいちばん防ぎたいのは、確認済みのジャケットや配信リンクが、エラーにならないまま空欄になって公開されることです。例外が出ないので、気づかないまま公開されてしまいます。安全に公開するための仕組みの中で、最も重視しているのがこの検査です。

難しいのは、値がなくなったときに、それが意図した変更なのか事故なのかを見分けることです。次の2つの例を比べると違いが分かります。

例Notionの入力生成結果扱い
Notionで「Apple Musicリンク」を意図して空欄にした前回から変わったappleMusicUrl が空になる意図した変更として通す
画像の照合キャッシュが壊れ、作品IDが一致しなくなった前回と同じcoverUrl が空になる事故として止める

判断の基準は「入力が前回と同じなのに、結果だけが消えたかどうか」です。前回の生成JSONと今回の結果を比べ、入力が同じなのに値が空になった項目があれば、生成JSONを書き換えずに停止します。

const PROVIDER_FIELDS = ['coverUrl', 'coverLinkUrl', 'appleMusicUrl', 'spotifyUrl'];

for (const review of nextReviews) {
  const previous = previousById.get(review.id);
  if (!previous) continue;
  for (const field of PROVIDER_FIELDS) {
    if (!sameProviderInputs(previous, review)) continue;
    if (previous[field] && !review[field]) {
      regressions.push({ id: review.id, artist: review.artist, album: review.album, field });
    }
  }
}
function sameProviderInputs(previous, next) {
  return previous.artist === next.artist
    && previous.album === next.album
    && (previous.coverPolicy || null) === (next.coverPolicy || null)
    && (previous.coverSourceUrl || null) === (next.coverSourceUrl || null);
}

入力として見ているのは、アーティスト名、アルバム名、ジャケット掲載区分、ジャケットの出典URLです。実際のコードでは、配信リンクについてはNotionのリンク欄そのものが変わったかどうかも比べています。

止まったときは、どのレビューのどの項目が消えるかを表示します。

検証済みの画像・配信先が理由不明のまま2件失われます。
  - アーティスト名 / アルバム名: coverUrl
  ...
生成JSONを書き換えずに停止しました。出典・キャッシュ・手動設定を確認してください。

ジャケット画像そのものも、Notionの「ジャケット掲載区分」で出し方を決めています。迷ったときは出さない方に倒す方針で、区分が空欄のものは「未確認」として扱い、画像を出しません。

掲載区分動作
API提供Apple Music/Spotifyのアートワークを使う。出典URLと照合結果の作品IDが一致したときだけ掲載
公式・権利者提供/自作・許諾済みNotionに添付した画像をダウンロードして自分のサーバーに置く(添付のURLが1時間で失効するため)
掲載しない/未確認画像を出さない

本番では古いデータで続けない

同期の結果は1つのJSONファイルに書き出し、Gitにコミットしています。このファイルは、変更履歴、Notionに接続できないときの予備、前回との比較元、の3つの役割を持ちます。

予備があると便利な一方で、「Notionの最新を反映したつもりが、実は古いデータで公開していた」という事故が起こりえます。そこで、本番用のビルドでは環境変数 NOTION_SYNC_STRICT=true を付け、同期に失敗したら停止します。古いデータのまま気づかずに公開するより、止めて知らせる方が安全だと判断しました。

try {
  await syncReviews();
} catch (error) {
  if (error instanceof ProviderDataRegressionError || error instanceof NotionServiceLinkError) throw error;
  if (existsSync(OUTPUT_URL) && process.env.NOTION_SYNC_STRICT !== 'true') {
    await refreshCachedServiceLinks();
    console.warn(`[reviews] Notion同期に失敗したため既存キャッシュを使用します: ${error.message}`);
  } else {
    throw error;
  }
}
状況同期だけ実行(開発時)本番ビルド(厳格)
Notion APIの障害警告を出して既存JSONで続行停止
データの欠落を検知停止停止
リンク欄の名前・型が想定と違う停止停止

データの欠落とリンク欄の設定ミスは、予備のJSONで続けても解決しないので、どちらのモードでも止めます。

Moba Pro

SQLiteへの変換と公開

検証を通った生成JSONを、サーバーに置く形に変えます。

SQLiteにまとめる

レビュー1件をJSONのまま入れる、単純なテーブルにしています。

'CREATE TABLE reviews ('
. 'slug TEXT PRIMARY KEY NOT NULL, '
. 'publication_date TEXT NOT NULL, '
. 'payload_json TEXT NOT NULL'
. ') WITHOUT ROWID'

SQLで使うのはスラッグ(1件の取得)と公開日(並び順)だけなので、それ以外を列に分けていません。Notionにプロパティを足しても、テーブル定義を変えずに済みます。作成後は PRAGMA integrity_check で検査し、ok 以外ならビルドを止めます。

ファイル名には、データ内容から計算した16文字のハッシュを入れます。

storage/reviews.3f9a1c....sqlite
data/reviews.3f9a1c....json      ← ブラウザの検索・絞り込み用
release.php                      ← どの世代のファイルを使うかを書いた設定

公開サーバーの安全のため、公開側のPHPはこのSQLiteを読むだけで、データを更新する処理を持っていません。検索条件はプレースホルダーで渡しています。

$statement = database()->prepare('SELECT payload_json FROM reviews WHERE slug = :slug');
$statement->execute(['slug' => $slug]);

ほかにも、公開側では次の対策をしています。

  • SQLiteを置いたディレクトリと release.php へのアクセスは、403で拒否する
  • URLのスラッグは、英小文字・数字・ハイフンだけの100文字以内に限る
  • Notionから取り込んだレビューの文字列は、HTMLとしてエスケープしてから出力する

参照先を最後に更新する

デプロイでは、release.php 以外のファイルを先に転送し、最後に release.php を転送します。

$syncOptions = ' -filemask="| release.php"'
$lines += "synchronize remote$syncOptions `"$localTarget`" `"$remoteTarget`""
$lines += "put `"$(Join-Path $Dist 'release.php')`" `"${remoteTarget}release.php`""

新しいSQLiteと検索用JSONは別のファイル名で先に置かれるので、転送途中のデータベースをPHPが読んでしまう危険を減らせます。参照先は最後に `release.php` を書き換えたときに変わります。

ただし、これはサイト全体を一瞬で切り替える仕組みではありません。

  • release.php は一時ファイルからの名前変更ではなく、直接上書きしている
  • PHPのテンプレートやsitemapは、release.php より先に新しいものに置き換わる
  • 古いページを開いたまま切り替わると、そのページからの検索では新しい世代の検索用JSONが返る

いずれも、扱うデータが小さく更新も手動の個人サイトでは許容できると判断しています。古い世代のファイルは、手元の成果物では最新2世代分を残しています。サーバーへの同期は既定で手元にないファイルを削除するので、サーバーにも同じ2世代分が残ります。削除しないオプションを付けて同期した場合は、それより古いファイルも残ります。

測定結果と限界

測定結果

移行前の数値は記録していないため、ここでは改善率ではなく、移行後に確認した値だけを示します。

項目値
npm run build:no-sync の所要時間2.2〜2.3秒(3回測定)
生成されたファイル数(`dist/` 全体、2世代分を含む)30
SQLiteファイル約5.3MB
検索用JSON約1.7MB

測定条件は次のとおりです。

  • 2026年10月26日、レビュー989件
  • Windows 11、Intel Core i7-12700、メモリ32GB
  • npm run build:no-sync は、同期済みのJSONからSQLite・検索用JSON・アセットを作る処理だけを含む
  • Notionからの同期、画像の取得、サーバーへの転送は含まない

通常の更新で使う npm run build(Notionとの同期を含む)は、ネットワークやAPIの状況で時間が変わるため測っていません。

レビュー本文は1つのSQLiteにまとまるので、レビュー数に比例してHTMLファイルが増えることはなくなりました。

この構成の限界

表示はNotionに依存しないが、すべての外部サービスから独立しているわけではない: サーバー側でNotionや音楽サービスのAPIを呼ばずに、レビュー本文と一覧を表示できます。一方、Apple Music・Spotifyから提供されるジャケット画像や広告は、ブラウザが外部から直接読み込みます。2026年10月時点では、989件中981件のジャケットがApple Music・Spotifyの画像配信サーバーから読み込まれています。

すぐには反映されない: Notionで書いた内容を出すには、手元でビルドしてデプロイする必要があります。定期的に自動で回す仕組みは、まだ作っていません。

Notionのプロパティ名に依存している:コードがプロパティを日本語の名前で参照しているので、Notion側でカラム名を変えると同期が壊れます。配信リンクのカラムは名前と型を検査して止めますが、すべてのカラムで同じ検査をしているわけではありません。

検索用JSONが大きくなっていく: 全件をブラウザで検索するため、約1.7MBのJSONを読み込んでいます。件数がさらに増えたら、サーバー側で検索する方法を考える必要があります。

SQLでの点数・ジャンル検索は用意していない: 現在の実装では、絞り込みはブラウザ側で行っています。

手元で試すには

この記事の構成を試す場合の入口だけ示します。

1. Notionでインテグレーションを作り、対象のデータベースに接続する(接続しないとAPIから読めません)

2. 環境変数を設定する。値は公開しないこと

変数内容
NOTION_API_KEYインテグレーションのトークン
NOTION_DATA_SOURCE_ID対象データソースのID
NOTION_SYNC_STRICTtrue で同期失敗時に停止する

3. コマンドを使い分ける

コマンド用途
npm run build通常の更新。Notionと厳格に同期してから成果物を作る
npm run build:no-sync同期済みのデータだけで成果物を作り直す
npm run verifyテストとPHP・SQLiteの動作確認

同じ考え方を取り入れるなら、次の順がおすすめです。

1. Notionを読むのはビルド時だけにし、まずは全件をJSONに書き出す

2. そのJSONをGitに入れ、差分で変化を確かめられるようにする

3. 前回のJSONと比べて、入力が同じなのに値が消えたら止める検査を入れる

4. URLは最初に固定し、ページIDの一部を含めて変えない

5. 出力先はサーバーに合わせて選ぶ(静的ホスティングならHTML、PHPが使えるならSQLite)

まとめ

Notionは書く場所としては便利ですが、公開サイトから直接読むには制約があります。ビルド時にNotionから取り出し、URLの維持・データの欠落検知・厳格な同期で確かめてから公開用のデータにすることで、Notionの書きやすさを残したまま、Notionの障害に左右されずにレビューを表示できるようになりました。

ここでの「安全」は特別な技術ではなく、問題を見つけたら公開せずに止める、原本は確認してから書き換える、公開側に書き換えの入口を作らない、という判断の積み重ねです。

付録:動作環境

2026年10月26日に確認した環境です。

公開サーバー

項目内容
OSFreeBSD 13.0
WebサーバーApache 2.4
PHP8.2.35(CGI)
データベースSQLite(PHPのPDO SQLite拡張)

ビルド環境(手元のPC)

項目バージョン
OSWindows 11
Node.js22
PHP8.3.36
Vite / Tailwind CSS / TypeScript7 / 4 / 5.9
デプロイPowerShell 7+WinSCP

SQLiteのファイル形式はOSやCPUに関係なく共通なので、手元のWindowsで作ったファイルをFreeBSDのサーバーでそのまま開けます。

Tags

← 前の投稿

次の投稿 →

コメントを残す