NotionAPIを用いて情報を一括で取得する方法

NotionAPIを使ってNotion全体から情報を取ってくる方法について模索した結果を書いていきます。
2023.12.22

ことのはじまり

皆さん、Notionをお使いでしょうか?我が社では、Notionを社内のナレッジ集約ツールとして利用しています。社内で使っている複数のワークスペースを統合したところ、元のワークスペースに対するリンクがNotion内に多数存在する状況となってしまいました。そこで、Notion APIを利用してリンクの一覧を取得することを試みることにしました。 この記事では、リンクに限らず様々な情報をNotion全体から取ってくる方法を説明します。(「全情報と言っておいて、プロパティとかコメントとかユーザーとかは取ってこないなんて…」とか言ってはいけません。

この記事について

対象読者

  • Notion APIを用いて、一括で取ってきたい情報があり、その手段が知りたいという方
  • なんとなくNotion APIについて知りたい方

前提条件

  • この記事ではpythonを使います。

全てのコードは少し環境を整えたら実行できるような形にしてあるので、pythonの環境で実際に実行してみることをお勧めします。

Notion APIの使い方

この記事を見ている方の中には、Notion APIを使ったことのない方もいると思います。そこで、まずはNotion APIの使い方の簡単な説明を行います。

1. インテグレーションの作成(APIの有効化)

詳しく説明している記事が沢山あるので、説明はそちらに譲ります。 以下に参考となるブログを貼っておくので、参考にしてインテグレーションを作成してください。

適当なページにコネクトを追加したら、次のステップに進んでください。 (実際に情報を取ってきたいページがある方は情報を取得したいページを全て含んだ上位ページもしくは最上位のページにコネクトを追加して下さい)

2. Tokenを使ってAPIを叩く

インテグレーションが作成できたら、後はAPIを叩くだけです。 実際に叩いてみましょう!

.envファイルを作成してNOTION_API_KEYの設定を行い、同じディレクトリ内に実行ファイルを作成して、実行してください。 実行の際にはpython-dotenvrequestsが必要になります。入っていない人は以下を実行しましょう。

$ pip install requests python-dotenv

python-dotenvについては、一応参考サイトを載せておきます。

NOTION_API_KEY="secret_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
import os
import requests
from dotenv import load_dotenv
from pprint import pprint

# 環境変数をロード
load_dotenv()

# Notion APIキーの取得
api_key = os.getenv("NOTION_API_KEY")

# Notion APIのエンドポイント
url = "https://api.notion.com/v1/search"

# リクエストヘッダー
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Notion-Version": "2022-06-28" # APIのバージョン
}

# APIリクエストの実行
response = requests.post(url, headers=headers)

# レスポンスの表示(pprintを用いて綺麗に表示)
pprint(response.json())

ページの情報が出力されたらバッチリです。 おめでとうございます!!これであなたもNotion APIマスターです!!

Notion APIで何ができるのか

それでは、使い方がなんとなく分かったところで、具体的にNotion APIでは何ができるのかを見ていきます。 Notion APIの公式ドキュメントを参照してみましょう。画面左側のサイドバーのENDPOINTSを見ると、何ができるのかが分かります。

ここで、Notionのブロックやページ、データベースなどの概念、関係性が分からない方は以下のページを一読してきて下さい。なんとなくわかるようになるのではないかと思います。

では、今回の情報の取得に必要そうなエンドポイントの説明をしていきます。

今回使うエンドポイントの説明

多くの情報から特定の情報を取ってくる時には、やはり検索を使いたいですよね。 というわけで、ENDPOINTSを見ると、なんと!あるではありませんかSearchの文言が。これを使えば万事解決!!!!!! やりましたね。このブログはここでお終いです。あとは公式サイトで使い方を見て下さい。

とはなりません。

仕様を見てみると、ページタイトルとデータベースのタイトルしか検索できないではありませんか!! というわけで、大人しくBlocksのエンドポイントを使いましょう。楽は出来なさそうです。 (データベースのエンドポイントにはqueryがあり、データベースは検索できそうに見えますが、プロパティに基づいてフィルタリングができるだけなので、肝心のデータベースの中身の検索はできません。)

HTTP method Endpoint 使い方
GET Retrieve block children ブロックの全ての子要素をリスト形式で取得する
POST Query a database データベースからページ一覧を取得する

今さっき書いたように楽は出来ないので、上記二つのエンドポイントを駆使してデータを取り出していきます。

実際にコードを書いてみる

とりあえず私が実際に書いたコードを見てみましょう。

実際のコード

import os
import time
import functools
import pprint
from dotenv import load_dotenv
from notion_client import Client

def retry(exceptions, tries=6, delay=10, backoff=2):
"""
指定された例外が発生した場合に、指定された回数だけ関数をリトライするデコレータです。

Parameters:
exceptions (Exception or tuple): リトライする例外の種類または例外のタプル。
tries (int, optional): リトライの試行回数。デフォルトは6回です。
delay (int, optional): リトライの間隔(秒)。デフォルトは10秒です。
backoff (int, optional): リトライの間隔を増やす倍率。デフォルトは2倍です。

Returns:
デコレートされた関数を実行するデコレータ関数。

Example:
@retry(ValueError, tries=3, delay=5)
def divide(a, b):
return a / b

divide(10, 0) # ゼロ除算の例外が発生しても、3回までリトライします。
"""
def deco_retry(func):
@functools.wraps(func)
def f_retry(*args, **kwargs):
mtries, mdelay = tries, delay
while mtries > 1:
try:
return func(*args, **kwargs)
except exceptions as e:
print(f"{str(e)}, {mdelay} 秒後にリトライします...")
time.sleep(mdelay)
mtries -= 1
mdelay *= backoff
return func(*args, **kwargs)
return f_retry
return deco_retry

def get_all_pages_in_database(notion: Client, database_id: str) -> list | None:
"""
指定されたデータベース内のすべてのページを取得する

Parameters
----------
database_id : str
データベースのID

Returns
-------
list or None
データベース内のすべてのページのリスト。リンクドビューの場合はNone。
"""
try:
pages = []
start_cursor = None
while True:
response = notion.databases.query(database_id=database_id, start_cursor=start_cursor)
pages.extend(response.get("results", []))
if response.get("has_more") == False:
break
start_cursor = response.get("next_cursor")

except Exception as e:
print("↓がdatabaseのエラーならリンクドビュー由来のエラーです。問題ありません。")
print(f"An error occurred: {e}")
# リンクドビューと通常のDBを区別する方法はありませんでした。APIを叩いた時に返ってくる情報が全く同じです。
# リンクドビューからDBの情報は取得できないので、404エラーを無視して次のブロックを処理します。
return None
return pages

@retry(Exception)
def get_all_info_under_the_page(notion: Client, root_page_id: str) -> set:
"""
指定されたページ以下のすべての情報を取得します。

Parameters
----------
notion : Client
Notionクライアントオブジェクト
root_page_id : str
ルートページのID

Returns
-------
set
データベースのIDを格納するセット(例としてデータベースのIDを一括で取ってくるコードとした)
"""
stack = [(root_page_id, None)] # スタックに初期ページIDとスタートカーソル(None)を追加
# ================sample================
database_ids = set() # データベースのIDを格納するセット
# ================sample================

while stack:
current_page_id, start_cursor = stack.pop()
response = notion.blocks.children.list(block_id=current_page_id, start_cursor=start_cursor)
children = response.get("results", [])

# childrenに対して情報を取ってくる処理を行う(以下は例として、データベースのIDを取ってくる)
# ==============sample===============
for block in children:
if block["type"] == "child_database":
database_ids.add(block["id"].replace("-", ""))
# =============sample================

# childrenの構造を確認してみたい場合は以下のコメントアウトを外してください(大量の情報が出力されます)
# pp = pprint.PrettyPrinter(indent=1)
# pp.pprint(children)

if response.get("has_more"):
next_cursor = response.get("next_cursor")
stack.append((current_page_id, next_cursor)) # スタックに現在のページIDと次のカーソルを追加

# タイプ別に子ページを確認して、子ページがあればスタックに追加する
for block in children:
child_id = block["id"].replace("-", "")
# データベースの場合
if block["type"] == "child_database":
all_pages_in_db = get_all_pages_in_database(notion, child_id)
# リンクドビューの場合はスキップする
if all_pages_in_db is None:
continue
# データベース内のすべてのページをスタックに追加
for block in all_pages_in_db:
stack.append((block["id"].replace("-", ""), None))
# データベース以外のブロックで子ブロックを持つ場合スタックに追加
elif block["has_children"] == True:
stack.append((child_id, None))
return database_ids

# 環境変数をロード
load_dotenv()
api_key = os.getenv("NOTION_API_KEY")
notion = Client(auth=api_key)
page_id = "0eba0c1f98484decbf56ee79758acc41"
get_all_info_under_the_page(notion, page_id)

はい。どうでしょうか?どう思いましたか? 「長くて読むのめんどくさいなぁ」と思いましたか?正直な方ですね。ついでに私も正直な方です。自分でも読むの面倒です。

とはいえ、折角ここまで読んでくださったのなら、最後まで読んでいって下さい。 もったいないですよ。なるべく分かりやすく解説しますから。Notion APIを使っていたら引っかかる謎仕様も説明しますから。 どうか読んでくださいお願いします。お願いします。お願いします。orz(これだけお願いすれば読んでくれるやろ

というわけで解説していきます。

コードの簡単な解説

このコードは三つの関数から成り立っています。

  • retry
  • get_all_pages_in_database
  • get_all_info_under_the_page

メインの処理は三つ目の関数で、上二つは補助的な関数です。

一つ目の関数は名前の通りリトライ処理を担当しています。エラーが起こった時にリトライ処理をしてくれます。 二つ目の関数はデータベースからページ一覧を取ってくる処理を担当しています。 最後の関数はスタックを用いてNotionのページを潜っていって情報を取ってくる処理を担当しています。メインの処理ですね。

このコードは実際に私が業務で使ったコードを少し改変して他の処理にも使えるようにしてあります。 よく見なくてもコード上に超分かりやすくsampleと書いてある場所があると思うのですが、ここを自分のやりたい処理に置き換えてもらえばきっと使えるでしょう。一応、後の方に使い方の章もあります。

コードのポイント(躓きポイント)

1. メインの処理にスタックを用いた

元々は再帰的な関数を書いて処理を行なっていたのですが、実際に動かしてみたら関数のネストが深くなりすぎてしまい、メモリ上限を超えてしまいました。というわけで、コードの動作安定化のためにスタックを用いることにしました。

2. リトライ処理の追加

元々はリトライ処理などなかったのですが、実行してから5,600分ほど経過した時にエラーが出ることがあって、調べてみたら単にリクエスト上限に引っかかっているだけのようでしたので、リトライ処理を入れました。

えっ?このコードの実行時間ってそんなに時間かかるのかって? はい、そうです。もちろん調べるNotionのページの大きさにもよりますが、私の場合700分とかを超える時間がかかっていました。 まあ、全探索しているので、仕方ありません。現状(2023年末)より良い方法はなさそうなので諦めて下さい。

(以下 注) このコードでは全てのエラーに対してリトライ処理が起こるので注意して下さい。 本来、エラーの種類により処理を分けた方が良いのですが、リトライするべき可能性のあるエラーの種類は複数あり、全てを把握し切れておらず、上手くいかなかった際に失う時間があまりにも多いので、安全を取って全てにリトライ処理をかけています。デバッグの際にはコメントアウトして下さい。

3. リンクドビューの処理

まず、リンクドビューについて知らない人はこのページを見てみて下さい。

さて、このリンクドビューの情報をNotion APIで取得する上で、Notion API特有の謎仕様により起こる問題がありまして、私が初見でこの問題に出くわした時は頭の中が?????????となりました。その問題について説明します。

簡単にその問題について言ってしまうと、 「NotionAPIで取ってきたデータからは普通のデータベースとリンクドビューの見分けがつかない」 という仕様と 「リンクドビューのIDからデータベースのqueryをするとエラーを返される」 という仕様が組み合わさってNotion API上でリンクドビューがとても扱いづらいという問題です。

さて、とりあえず、本当に見分けがつかないのか NotionAPIを用いてデータベースとリンクドビューの情報を取ってきたものを見比べてみましょう。(IDなどは適当に変えてあります。) さて、どちらがデータベースの情報でどちらがリンクドビューの情報かわかるでしょうか?

{
'archived': False,
'child_database': {'title': ''},
'created_by': {'id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'object': 'user'},
'created_time': '2023-08-01T08:08:00.000Z',
'has_children': False,
'id': '11111111-2222-3333-4444-555555555555',
'last_edited_by': {'id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
'object': 'user'},
'last_edited_time': '2023-08-24T08:08:00.000Z',
'object': 'block',
'parent': {'page_id': '66666666-7777-8888-9999-121212121212',
'type': 'page_id'},
'request_id': '12341234-1234-1234-1234-123412341234',
'type': 'child_database'
}

{
'archived': False,
'child_database': {'title': ''},
'created_by': {'id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'object': 'user'},
'created_time': '2023-07-01T07:44:00.000Z',
'has_children': False,
'id': '22222222-3333-4444-5555-666666666666',
'last_edited_by': {'id': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
'object': 'user'},
'last_edited_time': '2023-07-20T00:34:00.000Z',
'object': 'block',
'parent': {'page_id': '14141414-4444-5555-1111-123412341234',
'type': 'page_id'},
'request_id': '4312-3414-1234-1234-431212341234',
'type': 'child_database'
}

少し見づらいですが、分からないですよね。私も全くわかりません。正解は上がリンクドビューです。たしか。おそらく。

とにかくデータ上では区別がつかないのでとりあえずdetabaseとみなしてqueryに突っ込むのですが、そこで、突っ込んだものがリンクドビューだと「そんなデータベースないよ」って言われてエラーを吐かれます。。。。

どうしろと?

というわけで、今回のコードではエラーが出たらきっとリンクドビュー由来のものだということにして処理を続行するようにしています。 かなり無理矢理な方法ですが、他に方法が見つからなかったので、仕方なくこうしています。恐らくこうする他ないと思います。

4. そもそもなぜブロックをタイプごとに処理を分けずに、データベースとそれ以外といった大雑把な分け方にしているのか?

突然ですが質問です。あなたはNotionのどの種類のブロックにページを含めることができるかご存知でしょうか?

ここにブロック一覧のページを貼っておきますので、少し考えてみて下さい。

 

まあ、ページ内にページは置けますよね。トグルリスト内にも置けますよね。 では、見出し1,2,3には置けるでしょうか?バレットリスト内には?コールアウト内には?Todoリスト内には?

流石にTodoリスト内にページが置かれているのは見たことないし、置けないのでしょうか? 見出しブロックにページが置かれてる?意味不明ですかね。

 

正解は、今私が挙げた種類のブロック内にはもれなくページを置くことが出来ます。 せっかくなので、どうやったらこの状況にできるか試行錯誤してみて下さい。実際置いてみたらどのようになるのかの参考画像をこのページの下の方に貼っておきます。 熟達したNotion使いでないと知らないことかと思いますので、ぜひ習得してみて下さい。

閑話休題

何の話をしたかったのかというと、どのタイプのブロックにページが入り得るのかを判断するのは非常に難しそうだということです。 もちろん探したら探索しなくて良いブロックのタイプがあるかもしれません。それによって関数の実行時間が多少削れるかもしれません。

ですが、コストとメリットが釣り合いません。 そもそも、さっきの余談で分かるように、日常的にNotionで用いるほとんどのブロック内にはページが存在出来ます。なので、ブロックタイプごとに分けて処理してもほとんど実行時間は減らないでしょう。

使い方

まず、 notion-sdk-py を用いているので、pip install しましょう。

$ pip install notion_client

あとは、.envファイルが置いてあるディレクトリに置けば実行できるはずです。

実行はそれだけで出来るのですが、上記のコードを改変して使うには、NotionAPIがどのようなレスポンスを返してくるのかを知らなければなりません。とはいえ、取得したい情報によってレスポンスのどこに注目すべきかは変わるでしょうし、特に解説はしないでおこうと思います。(解説めんどくさいやぁ

 

...とはいえ全く解説しないのもなんなので、コードを改変する時に役立つ情報を書いておこうと思います。

  • コード上の「childrenの構造を確認してみたい場合は以下のコメントアウトを外してください」の部分のコメントアウトを外せばレスポンスが見られます。上手いこと役立てて下さい。
  • retry処理は任意のエラーを吸い取ってしまうので、デバッグの際には@retry(Exception)の部分をコメントアウトすることをお勧めします。

では、なんやかんや上手いこと頑張って下さい!!

 

とは言ったものの、まだやり投げ感が否めないので、参考になりそうなサイトを載せておきます。これで許して下さいお願いします。

まとめ

Notion APIで全体から情報を取ってくる方法は泥臭い方法しかないぞ!時間はかかるが頑張れ! あと、リンクドビューが苦しい。

参考URL

アノテーション株式会社

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社WEBサイトをご覧ください。