TL;DR
- Notion のプロパティ ID は予告なく変わるため、ハードコードは厳禁
retrieveDatabaseを使い、タイトル(Name)から ID を動的に解決する- 存在しないプロパティに対するガード節を徹底し、エラー耐性を高める
The Pain: スクリプトが突然死ぬ理由
「ローカルで動いていたスクリプトが、本番環境で突然エラーを吐いた」。 その原因の多くは、NotionのプロパティIDの変更や予期せぬスキーマ不一致です。
// BAD: プロパティIDや名前をハードコードしている
const response = await notion.pages.create({
parent: { database_id: "..." },
properties: {
"Title": { title: ... }, // "Name" かもしれない
"Tags": { multi_select: ... } // "Tag" かもしれない
}
});
データベースのカラム名を少し変えただけで、自動化フロー全体が停止する。これでは「堅牢」とは言えません。
The Solution: 動的スキーマ取得 (Dynamic Schema Fetching)
堅牢なスクリプトを書くための鉄則は、**「送信前にデータベースの定義(Schema)を聞く」**ことです。
retrieveDatabaseで現在のプロパティ定義を取得する。- 辞書型(Map)を作成し、「欲しいプロパティ」が存在するか確認する。
- 名前が多少揺れていても吸収できるロジックを入れる(例:
slugorSlug)。
The Implementation: 実際のコード
以下は、本ブログの自動投稿スクリプトで使用しているロジックの抜粋です。
// scripts/post_notion.mjs より抜粋・簡略化
async function getDatabaseSchema(databaseId) {
const dbRes = await notion.databases.retrieve({ database_id: databaseId });
const properties = dbRes.properties;
// 利用可能なプロパティ名をリスト化
const availableProps = Object.keys(properties);
console.log("Available properties:", availableProps);
return { properties, availableProps };
}
async function safePost(article, databaseId) {
const { properties: schema, availableProps } =
await getDatabaseSchema(databaseId);
// フォールバック付きでプロパティ名を特定
const titleProp = availableProps.find(
(p) =>
["Title", "Name", "タイトル", "名前"].includes(p) ||
schema[p].type === "title",
);
const slugProp = availableProps.find((p) =>
["Slug", "slug", "スラッグ"].includes(p),
);
if (!titleProp) {
throw new Error("Critical: Title property not found in database.");
}
// ペイロード構築
const payload = {};
payload[titleProp] = { title: [{ text: { content: article.title } }] };
if (slugProp && schema[slugProp].type === "rich_text") {
payload[slugProp] = { rich_text: [{ text: { content: article.slug } }] };
}
return await notion.pages.create({
parent: { database_id: databaseId },
properties: payload,
// ...
});
}
ポイント
- Type Check: 名前だけでなく
type === 'title'など型でも検索する。 - Graceful Degradation: 必須でないプロパティ(Slugなど)は見つからなければ送信しない(エラーにしない)。
- Logging: 何が見つかって何が見つからなかったかをログに残す。
The Takeaway: 運用の安定化
この「動的スキーマ取得」を導入してから、Notion側でプロパティ名を「Slug」から「URL Slug」に変えたときも、スクリプトは止まることなく動作し続けました。
自動化スクリプトは「作った瞬間」が完成ではありません。「運用環境の変化にどれだけ耐えられるか」がエンジニアの腕の見せ所です。
次回は、生成AIの「JSON崩れ」を許さないパース戦略について解説します。
Implementation: Dynamic Resolver
Notion のプロパティ ID は、データベースの構成を変更するたびに再生成される可能性があります。これを防ぐために、実行時にスキーマを読み込み、名前と ID のマッピングテーブルをオンメモリで作るのが最適解です。
const response = await notion.databases.retrieve({ database_id: DATABASE_ID });
const propertyIdMap = Object.fromEntries(
Object.entries(response.properties).map(([name, val]) => [name, val.id]),
);
// 例: "Status" プロパティの ID を名前から取得
const statusId = propertyIdMap["Status"];
⏳ 比類なき「リトライ戦略」の重要性
API 連携においてスキーマ崩れと同じくらい致命的なのが、429 Too Many Requests(レートリミット)です。
Notion API は比較的厳格な制限があります。単純なループで投稿を行うと、すぐに制限に達します。
私たちは p-retry などのライブラリを使い、指数バックオフを伴う自動リトライを実装しています。これにより、一時的なネットワークエラーやリミット超過であっても、システム全体が停止することなく、数秒待機して処理を完遂させることができます。
graph TD
A[Start] --> B{Retrieve Database Schema};
B --> C{Extract Property Names and IDs};
C --> D{Create Name-to-ID Map};
D --> E{Look up Property ID by Name};
E --> F[Use ID in API Call];
F --> G[End];
2026-05 時点の最新仕様(Notion API + LLM 連携)
Notion API の主要制約(公式値)
- Rate limit: 平均 3 リクエスト/秒(突発的なバーストには HTTP 429 が返り
Retry-Afterヘッダで再試行間隔が指定される)[公式値](Notion API Rate Limits) - ペイロードサイズ: 1 リクエストあたり最大 1000 ブロック、URL 2000 文字、リッチテキスト 2000 文字[公式値](Notion API Limits)
- Property type: 26 種類(title / rich_text / number / select / multi_select / date / relation / formula 等)[公式値](Notion API Database properties)
LLM 出力 → Notion DB 連携のベストプラクティス
LLM の JSON 出力を Notion DB に動的にマッピングする際のフローは、本記事の動的スキーマ取得と Gemini API の JSON パース戦略 の structured output / response_schema を組み合わせると安定します(経験則)。
// 1. LLM で structured output を生成(responseSchema で型強制)
const structured = await gemini.generateContent({
responseSchema: { type: "object", properties: { ... } },
});
// 2. Notion DB の現行スキーマを動的取得
const db = await notion.databases.retrieve({ database_id });
// 3. 各 property を動的解決して投入
const properties = mapToNotionProperties(structured, db.properties);
await notion.pages.create({ parent: { database_id }, properties });
OpenAI Responses API の text.format でも同様[公式値](OpenAI Structured Output 公式)、Anthropic Tool Use の strict: true でも同等[公式値](Anthropic Tool Use 公式)の構造化出力強制が可能。
失敗パターン Top5(経験則・運用ログベース)
- スキーマ変更でクラッシュ: ID ハードコード → 動的解決で回避(本記事の主題)
- Rate limit 直撃: 並列投入で 429 連発 → exponential backoff + jitter
- Rich text 2000 字超過: LLM 出力をそのまま投入 → 自動チャンク分割
- Relation の片方向不整合: 双方向 relation を片方からだけ更新 → 整合性チェック必須
- Property type 不一致: structured output の type と Notion property type のずれ → mapping 関数で吸収
詳細な retry / circuit breaker パターンは エラーハンドリング設計ガイド を参照。
FAQ
Q1. Notion API の rate limit は具体的に何 req/s ですか?
公式値で 平均 3 req/s[公式値](Notion API Rate Limits)。突発的バーストには 429 が返り Retry-After で再試行間隔が指定されるため、429 を見たら指定された秒数待つのが確実です。並列度を抑える設計が現実解。
Q2. structured output(responseSchema)と Notion API の連携でハマる箇所は?
property type の対応関係でハマります(経験則)。LLM 側の JSON Schema string が Notion 側で title / rich_text / select のいずれにマッピングされるか曖昧だと、片方が壊れます。mapping 関数を中間層に置き、Notion の動的スキーマと照合してから投入するのが現実解。
Q3. LLM 出力が Rich text の 2000 字制限を超える場合は?
自動チャンク分割が定石(経験則)。Notion の rich_text array は複数要素を許容するため、2000 字単位で分割して array 要素として渡せば内容を保てます。コードベースの長文挿入が頻発する場合は、別 page を作って relation で参照する設計も検討。
Q4. 双方向 relation の整合性をどう担保しますか?
「片方を更新したら反対側も検証」のパターンを採用(経験則)。Notion の relation は backlink 自動生成が効く場合と効かない場合があるため、API 経由で両方向を明示的に確認する mapping 関数を持つのが安全。詳細は Notion ブログ自動化の教科書 で扱う運用パターン参照。
Q5. Notion API と他の LLM プロバイダ(OpenAI / Anthropic)の連携はどう設計しますか?
3 プロバイダとも structured output / response_schema / strict tool 機能をサポートしています[公式値]。Notion DB を共通のデータ層として置き、上流の LLM プロバイダを抽象化するのが現実解。詳細は Responses API 時代のツール呼び出し設計 を参照。
