📚

現在のNotion API(2021/7/13)で何がどこまでできるか

2021/07/13に公開
4

個人サイト作成のためにNotion APIを使いました。
その際に知ったことを書きます。

Notion API

Notion APIは、2021/5/14にパブリックベータとして公開されました。

https://developers.notion.com/

ベータなので色々微妙だったりもするのですが、個人的には思ったよりも使えるなと思いました。

ドキュメントもまだ情報量は少ないですが、ツボはおさえてる感じです。
僕は公式ドキュメント見ながら進めましたが、途中で詰むことはありませんでした。
DocとAPI Referenceの2ページはめちゃくちゃ見ました。

敢えて苦言を呈するなら、今提供しているAPIの全体像とか、利用サンプルとかの説明は弱いかなと感じました。

非公式API(読み飛ばし可)

Notionは長らくAPI公開をしてくれませんでしたが、そうはいってもAPIでNotionのデータを取得したい需要は根強くありました。
以前notion-blogの紹介記事も書きましたが、

https://zenn.dev/st43/articles/9a714916c50b80

こちらで使っているのが非公式APIです。
https://www.notion.so/api/v3」 というエンドポイントを叩いています。

https://github.com/ijjk/notion-blog/blob/main/src/lib/notion/server-constants.js

↑notion-blogのこの部分ですね。
ブラウザの開発者コンソールで内部APIの挙動を推測して叩いてた、という事情のようです。

Notionがこの内部API叩かれてることをどう考えているのかは不明ですが、
将来的にNotion APIが完成したら、外部からのアクセスを封じる方向に動くのではないかと思います。
(今はまだAPIがベータ版という弱みがあるので、黙認してるだけではないかと僕は思っています)

本記事ではNotion APIに加えて、この非公式APIの内容も紹介できたらと思います。
将来的にはたぶん知る必要のない情報になるのだと思いますが、
現状だとNotion APIでは取れないけど、非公式APIだと取れる要素がいくつか存在します。
なので、要件次第では、敢えて非公式APIを使うという選択肢もあるのかなと思います。

Notion APIの設定

https://www.notion.so/my-integrations

↑ここにNotionアカウントを使ってログインします。
「Integration」というのが、Notion上のあるDBをAPIから操作可能にする単位になるので、新規作成します。
そしてNotionアプリから、Share > Invite > Select Intergrationで追加します。

ここでアクセス権与えるところはなんか変な感じがしますが、面白いなとも思いました。
Notionユーザーに権限与えるのと同じように、APIからのアクセスも管理するんだなーと。

以上、2つの作業が終わったら、APIが叩ける状態になります。
My Integrationsからシークレットキーが見られるので、curlコマンド使って色々叩いてみましょう。

手順の詳細は公式ドキュメントをご確認ください。

https://developers.notion.com/docs

認証まわり

僕自身はNotionを個人で使っており、チームで使ったことがないので読み飛ばしていましたが、最近はNotionを会社で使っているところもあると聞きます。
publicなIntegrationをつくると、OAuthで外部からのアクセスを受け入れることもできるようです。

僕は試していないところなので、もしご興味があれば下記を参照してください。

https://developers.notion.com/docs/authorization

Notion APIの全体像

僕はAPIを個人ブログのために使おうと思っていたので、まずAPIでNotionのページに書いてある文章をとりあえず取ってみたい、と思いました。
ただ初見だと、そこまでたどり着くのがちょっと大変でした。
手書きで恐縮ですが、あるアカウントのNotionのデータ構造はざっくり下記のようになっています。

この図が示しているのは、典型的なNotionの利用例です。
データベースがあって、そこに複数のページが紐づいています。
1つのページはヘッダー部とコンテンツ部にわかれていて、コンテンツはブロックの配列として管理されています。

僕の場合、「Blog」みたいな名前のデータベースをつくって、その要素として「記事A」「記事B」みたいにページをつくりました。

ルート要素としてつくったページは、親となるデータベースは持ちません。
また、親子関係は柔軟に作成できるので、データベースの子要素としてデータベースもつくれますし、そこら辺に制約はありません。

上記の構成なので、APIもデータベース・ページ・ブロックでそれぞれわかれています。
Retrieve a pageは、ブロックを返してくれません。

※ Notionのドキュメントでは、GETのことをRetrieveと書いています。

Retrieve block childrenを使いましょう。

ブロックを取得するサンプル

APIから返却されるデータのイメージをつかために、curlコマンド叩いたときのレスポンスを載せます。
Notionのページに「test」とだけ書いて、そのページに対してブロックの取得を試みました。

curl 'https://api.notion.com/v1/blocks/{$block_id(※$page_id)}/children?page_size=100' \
  -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
  -H "Notion-Version: 2021-05-13"

リファレンスを見ると、block_idを指定しろと書かれていて面食らいましたが、ページIDでいいみたいです。

{
    "object": "list",
    "results": [
        {
            "object": "block",
            "id": "{$block_id}",
            "created_time": "2021-07-06T02:18:00.000Z",
            "last_edited_time": "2021-07-06T02:18:00.000Z",
            "has_children": false,
            "type": "paragraph",
            "paragraph": {
                "text": [
                    {
                        "type": "text",
                        "text": {
                            "content": "test",
                            "link": null
                        },
                        "annotations": {
                            "bold": false,
                            "italic": false,
                            "strikethrough": false,
                            "underline": false,
                            "code": false,
                            "color": "default"
                        },
                        "plain_text": "test",
                        "href": null
                    }
                ]
            }
        }
    ],
    "next_cursor": null,
    "has_more": false
}

なんかたかが一単語に対して、ずいぶん大袈裟にネストしているように見えますが、
これはNotionのブロックがプレインテキストだけでなく、テキストの書式を持っていたり、
Markdownチックな要素情報を持っていたり、場合によってはURLリンクだったりNotion内の別ページへのリンクだったり、様々なフォーマットを持っているためです。

今回は一ブロックのページに対してAPI叩きましたが、実際のページだとブロック数分来ます。
開発者側で上手いこと扱えば、現状でもとりあえずテキストベースなら(後述)なんとか使えると思いますが、
ブログのコンテンツホスティングとして使うユースケースなら、Markdown形式でまるっと返してくれる、とかが欲しいなと思ってしまいました。
(NotionアプリにはMarkdownの出力があるので、そのエクスポート結果をAPI経由で受け取るだけなので、技術的にはそんなに難しくないはずです)

Notion APIでできること一覧

2021/07/13現在の情報です。
(最新のチェンジログ

APIが提供している機能をまず書きます。
(API Referenceのサイドバーの並びに準拠して書いていくので、詳細は↑のリンクから参照してください)

  • データベース操作
    • データベースの取得
    • データベースへのページの追加(※ページの「Create a page」を使うことで実現)
    • データベース要素をfilter/sortした結果を取得する
  • ページ操作
    • ページの取得
    • ページの新規追加
    • ページ(のプロパティ)の更新(ページ削除もこのAPIで可能)
  • ブロック操作
    • ブロックの取得
    • ブロック要素の追加
  • ユーザー操作
    - ユーザー情報の取得(email/name/avatar_urlあたり)
    - ユーザーリストの取得
    - ページ/データベースの検索
    - クエリによる検索
    - 検索結果をfilter/sortした結果を取得する

データベースIDを確認する

だいたいのAPIの操作は、認証のためのNOTION_API_KEY、そして操作対象のIDを指定することになります。
NOTION_API_KEYというのは、NotionのIntegrationページから取得する、secret_xxxというキーです。

データベースIDは、なんとURLを見て取得することになります。

Where can I find my database's ID?

https://developers.notion.com/docs/working-with-databases#adding-pages-to-a-database

これなんかすごいワイルドですよね。
ちなみにIDは「2f26ee68-df30-4251-aad4-8ddc420cba3d」みたいな形式で来ますが、ハイフンがあってもなくてもイケました。

ページIDを確認する

ページIDもURLから取得できます。
ただルートページでなければ、ページの親となるデータベースの要素の中にIDが入っているので、そっちを使うことになると思います。

https://developers.notion.com/docs/working-with-page-content#creating-a-page-with-content

ドキュメントでは、「お試しでURLからページID取るのはいいけど、本番環境でユーザーに聞いたりするのはどうかと思うよ」と書かれています。

ブロックID

ブロックにもそれぞれIDが振られています。
ブロックIDはさすがにAPI経由じゃないと取得できませんが、ブロック操作のAPIにはブロックIDが必須となります。

ただご安心ください。ブロック操作のAPIはページIDでも叩けます。
なので、任意のページのブロック要素を取得したい際は、ページIDを使うことになります。

僕は、「最初のブロックID = ページIDになってるのかな?」と最初思ったんですが、ブロックIDはブロックIDで別に振られていました。
どうもドキュメントを読んだ限りでは、ページはブロックでもあるみたいです。
禅問答みたいですが、どうもそういう設計思想のようです。

はじめて触ったときはちょっと戸惑うと思います。

Pages are also blocks

https://developers.notion.com/docs/working-with-page-content#modeling-content-as-blocks

Notion APIのSDK

JavaScriptでSDKが提供されています。
他の言語はありません。
パッと試すならcurlコマンド、SDK使うならJSの二択のみです。

https://github.com/makenotion/notion-sdk-js

JSのSDKはOSSとして公開されています。

const { Client } = require("@notionhq/client")

// Initializing a client
const notion = new Client({
  auth: process.env.NOTION_TOKEN,
})

こんな感じで入れましょう。
API Referenceの右列にサンプルコードが載っているので、それが一番わかりいいと思います。

以下、制約を書いていきます。

更新系の処理ができない

できること一覧の中で、ページの新規追加/更新とブロック要素の追加と書きましたが、逆に言うとこれ以外の更新系の処理はできません。
基本的にCRUD操作の中の、CREATE/UPDATEがほとんどできない状況ですね。

データベースは新規作成できないので、Notionアプリからやる必要があります。
ページについてはそこそこCRUD操作がそろっている印象です。
ただ、ブロック操作ができないので、ページのプロパティだけいじれても……って感じはありますよね。

ブロックは新規要素を追加(append)することはできるんですが、既存のブロックを更新することはできません。
なので、今のままだと使い道は限られるかなーという感じです。

テキスト以外のブロックが取れない

この制約が一番キツいですね。
ここに目立つように書いてありますが、

https://developers.notion.com/reference/block

Only text-like blocks are currently available

ということで、テキスト以外のブロック要素がtype: "unsupported"で返ってきます。
サポートされてるタイプは下記の通りです。

  • "paragraph"
  • "heading_1"
  • "heading_2"
  • "heading_3"
  • "bulleted_list_item"
  • "numbered_list_item"
  • "to_do"
  • "toggle"
  • "child_page"

画像、コードブロックなどがunsupportedで返ってくるので、辛いですね。

テキストの書式は取れるので、Notion上でつけていたリッチテキストの装飾の情報は取得できます。
(個人的にはあんまりリッチテキスト使わないタイプなので、要らない情報ですが)

レートに秒間3回までの制限がある

こちらでリクエスト制限について議論がありました。

https://zenn.dev/catnose99/articles/ab3afcb4338cbe

現在は秒間3リクエストで制限があるみたいです。
ただこれはあくまでベータ版なので、正式公開のときは料金プランに応じて制限をかける予定とのこと。
超えるとhttp 429エラーが返ってくるそうです。

Rate limits will change during public beta

https://developers.notion.com/reference/errors#rate-limits

(2021/10/20追記)
「そんなにヘビーに連打しないんで食らわないだろう」と本音では思ってたんですが、食いました。
こんなエラーが返ってきます。

{
  code: 'rate_limited',
  status: 429,
  headers: Headers {
    [Symbol(map)]: [Object: null prototype] {
      date: [Array],
      'content-type': [Array],
      'content-length': [Array],
      connection: [Array],
      'set-cookie': [Array],
      'x-dns-prefetch-control': [Array],
      'x-frame-options': [Array],
      'strict-transport-security': [Array],
      'x-download-options': [Array],
      'x-content-type-options': [Array],
      'x-xss-protection': [Array],
      'referrer-policy': [Array],
      'content-security-policy': [Array],
      'x-content-security-policy': [Array],
      'x-webkit-csp': [Array],
      etag: [Array],
      vary: [Array],
      'cf-cache-status': [Array],
      'expect-ct': [Array],
      server: [Array],
      'cf-ray': [Array]
    }
  },
  body: '{"object":"error","status":429,"code":"rate_limited","message":"You have been rate limited. Please try again in a few minutes."}',
  page: '/articles'
}

ブロックにサイズ制限がある

そんなに引っかからないと思うんですが、ブロックのサイズ制限があります。

https://developers.notion.com/reference/errors#size-limits

2,000字/blockなので、よほどギチギチに詰めない限り引っかからないかな、と思います。
URLも1,000字/blockなので、長めに感じます。

Retrieveは100要素までの上限がある

データベース/ページ/ブロックのGET、ユーザー一覧やクエリ検索のAPI全部なんですが、一度のAPIリクエストで返す要素は100までです。
「え、じゃあ100超えたらどうすんの?」と思いましたが、has_moreというフラグがあるので、
これがtrueだったら、next_cursorに指定されたidをstart_cursorに詰めて叩いてください、という仕様になっています。

https://developers.notion.com/reference/pagination

こっちの要素は普通に使っていればそのうちどこかで超えると思うので、ページネーションを実装する必要があります。
特にブロック要素は超えやすいと思います。
僕の場合、昔書いたブログ記事をNotionに50個持ってきたんですが、そのうち3つで100ブロック以上の記事がありました。

データベースの要素は基本的にcreated_time順で返る

Notionアプリ上でブログ記事のデータベースがあるんですが、アプリ上で順番を入れかえても、APIからのレスポンスの順番は変わりません。
API叩いたところ、どうもcreated_timeが新しい順に返しているようです。
つまり最新の要素が最初に返ってくる、という仕様です。

Notionは仕様として、created_timeをユーザーが編集する機能がありません。

https://twitter.com/NotionHQ/status/1116453128428118016?s=20

したがって、ブログ記事のデータベースをきちんと運用すれば問題はありませんが、
たとえば2015年に書いた記事を突然個人ブログにインポートしたくなったときに困ります。
本当の意味での作成日は2015年ですが、Notion上のcreated_timeは2021年になってしまいます。

https://developers.notion.com/reference/post-database-query#post-database-query-sort

データベースのAPIでsort条件が指定できるので、こちらでやるのがいいんだと思います。
あるいは、created_time順で受けるだけ受けて、クライアントサイドでsortする方法もあります。

現状、僕のTS力の低さ故に↑が実装できなかったので、created_time順に並べたブログ記事一覧に対して、
Webページの日付情報だけをユーザープロパティとして入力可能にした「update time」を入れるようにしています。

https://github.com/0si43/shetommy.com/blob/main/src/pages/articles/index.tsx

非公式APIのレスポンス(読み飛ばし可)

以上でNotion APIの説明は終わりなんですが、現状だと、非公式APIを使うともっと情報が取れます。

curl -s -X POST -H 'Content-Type: application/json' -H "Cookie: token_v2=${NOTION_TOKEN}" \
    https://www.notion.so/api/v3/loadUserContent

トークンを取る手段は昔書いた記事を見ていただければと思います。
このAPI叩くと、ユーザーアカウントに紐づく情報が一覧で返ってきます。
整形してコピペしようと思いましたが、長すぎるのでやめました。

Notionアプリを開いたときのトップページで表示してるデータがたぶん全部取得できてるんだと思います。
お気に入りに入れてるページ/DBのリストや、ルートにあるページ/DBのリストも入っています。
またページ/DBのプロパティ情報も入っています。

ここからIDを参照するか、URLを見るとページIDが取れるので、下記のエンドポイントを叩きます。

curl -s -X POST -H 'Content-Type: application/json' -H "Cookie: token_v2=${NOTION_TOKEN}" \
    -d '{"pageId":${NOTION_PAGE_ID},"limit": 200, "cursor": { "stack": [] }, "chunkNumber": 0, "verticalColumns": false }' \
    https://www.notion.so/api/v3/loadPageChunk

これのレスポンスが、こんなデータ構造になっております。

{
    "recordMap": {
        "block": {
            "xxx": {
                "role": "editor",
                "value": {
                    "id": "xxx",
                    "version": 143,
                    "type": "page",
                    "properties": {
                        "xL^M": [
                            [
                                "‣",
                                [
                                    [
                                        "d",
                                        {
                                            "type": "date",
                                            "start_date": "2017-12-31"
                                        }
                                    ]
                                ]
                            ]
                        ],
                        "title": [
                            [
                                "ブログのタイトルです"
                            ]
                        ]
                    },
                    "content": [
                        "xxx",
                    ],
                    "created_time": 1625553266919,
                    "last_edited_time": 1625559720000,
                    "parent_id": "xxx",
                    "parent_table": "collection",
                    "alive": true,
                    "created_by_table": "notion_user",
                    "created_by_id": "xxx",
                    "last_edited_by_table": "notion_user",
                    "last_edited_by_id": "xxx",
                    "space_id": "xxx"
                }
            },
            "xxx": {
                "role": "editor",
                "value": {
                    "id": "xxx",
                    "version": 12,
                    "type": "collection_view_page",
                    "view_ids": [
                        "xxx"
                    ],
                    "collection_id": "xxx",
                    "format": {
                        "collection_pointer": {
                            "id": "xxx",
                            "table": "collection",
                            "spaceId": "xxx"
                        }
                    },
                    "permissions": [
                        {
                            "role": "editor",
                            "type": "user_permission",
                            "user_id": "xxx"
                        }
                    ],
                    "created_time": 1625553266919,
                    "last_edited_time": 1625559720000,
                    "parent_id": "xxx",
                    "parent_table": "space",
                    "alive": true,
                    "created_by_table": "notion_user",
                    "created_by_id": "xxx",
                    "last_edited_by_table": "notion_user",
                    "last_edited_by_id": "xxx",
                    "space_id": "xxx"
                }
            },
            "xxx": {
                "role": "editor",
                "value": {
                    "id": "xxx",
                    "version": 4,
                    "type": "text",
                    "properties": {
                        "title": [
                            [
                                "コンテンツの最初のブロックです"
                            ]
                        ]
                    },
                    "created_time": 1625559201869,
                    "last_edited_time": 1625559600000,
                    "parent_id": "xxx",
                    "parent_table": "block",
                    "alive": true,
                    "created_by_table": "notion_user",
                    "created_by_id": "xxx",
                    "last_edited_by_table": "notion_user",
                    "last_edited_by_id": "xxx",
                    "space_id": "xxx"
                }
            }
        },
        "collection": {
            "xxx": {
	  (※タイトル・アイコンなどの情報)  
        },
        "space": {
	  (※ユーザーの情報)
        },
        "collection_view": {
          (※Viewの情報)
        }
    },
    "cursor": {
        "stack": []
    }
}

色々調べると、非公式APIも上限100要素制限ができてるらしく、ページネーションが必要っぽいのは同じようです。
ただ現在のNotion APIのブロック要素がテキスト関連しか返せないのに対して、非公式APIは全種類(たぶん)返せます。

https://github.com/ijjk/notion-blog/blob/106d82ca3fe479e773fe8d6caa558c920e45915b/src/pages/blog/[slug].tsx

非公式APIなので、もちろんドキュメントはないのですが、notion-blogの実装を参考にしてブロックのtypeを察するに、

  • page
  • divider
  • text
  • image
  • video
  • embed
  • header
  • sub_header
  • sub_sub_header
  • bookmark
  • code
  • quote
  • callout
  • tweet
  • equation

がとれるはずです。
(H3要素がsub_sub_headerになってるのはベンチャー特有の雑な感じして好きです)

画像の置き場とかどうするんだろうなと思ってましたが、たぶん非公式APIはurlごともらってるみたいですね

まとめ

まとめます。

  • Notion APIはデータベース/ページ/ブロックで分離されている
  • ページのコンテンツが欲しい場合はブロックのAPIを叩く
  • 現状のNotion APIではGET処理は結構できる
    • ただブロックのテキストがらみの要素のみ
    • 更新処理はほとんどできない
  • テキスト要素以外のブロックのデータが急いで必要ならば、非公式APIを叩く選択肢もある
    • あんまり良くないと思いますが、技術的には可能
  • ちゃんと運用に乗せると、たぶん100要素上限はすぐ引っかかるので、ページネーションが必須
    • これが結構めんどくさい
  • ドキュメントは結構わかりやすい(英語しかないけども)
  • 「ベータだけどもう使えるかな?」という問いには、🤔という感じ
    • 「テキスト中心のブログサービスなら使えるよ!」が模範解答かな……

Discussion

蔀

本記事を書いた後に、Notion APIで画像がサポートされたり、ページの削除が可能になったりなどの変化がありました。
本記事は、継続的にアップデートしていくタイプの記事ではないので、最新情報は公式などを参照ください

kikusuikikusui

すごく興味深い内容でした。notionってurlのクエリパラメーターなどの値をページ内で展開して、ユーザーごとの個人ページを表示するようなことはできないのでしょうか?

上記の内容はこれらのAPIでも実現できないような気がしていて気になりました。
notionは機能を絞った分、そういう拡張性はないのかな?

蔀

notionってurlのクエリパラメーターなどの値をページ内で展開して、ユーザーごとの個人ページを表示するようなことはできないのでしょうか?

クエリ受けるのはムリだと思います。
やるとするなら、ページ作成は可能なので、

  • ユーザーのインプットを取得(クエリとして想定しているパラメーター)
  • 表示したいコンテンツを作成
  • 作成した個人ページをユーザーに返す

みたいな流れで実現するしかないですかね。
(ちょっとめんどくさそう)
(やりたいことはそういうことではないかもですが……)

kikusuikikusui

いやーやはりそうなりますよね。API叩いてユーザーごとに新規ページ作る方法。
ユーザーが増えるとそれに比例してページ数も増えてしまうのが難点ですね。