なまこマンの毎日ゲーム宣言!

趣味のガジェット弄りとゲームとプログラミングの備忘録

NotionのAPIを叩いて食材管理デジタルサイネージを作ったけれどしっくりこない

先日、Notionで食材のストックを管理するためのデータベースを作成しました。

このデータベースによる食材管理の効果は覿面で、補充すべき食材や、賞味期限の迫っている食材が可視化されたことにより、買い物や自炊の計画を立てやすくなりました。あとは、食材の賞味期限の感覚も今まで以上に身に着き、作り置きする量や常にストックしておく食材について考えるようになりました。

ですが、食材管理データベースには唯一の欠点がありました。それは、わざわざPCやスマホでNotionを開かなければ参照できないことです。まあ、必要な時にスマホから開くのでも良いのですが、どうせなら冷蔵庫のそばに常に表示できたら便利です。

そこで、Notion API経由で食材管理データベースから必要な情報を取得し、補充すべき食材や、賞味期限の迫っている食材の一覧を表示するデジタルサイネージを実装してみたのですが…結論から言うとしっくりくるものはできませんでした。

今回の記事ではPythonでNotion APIを叩く際の注意点と、(現時点ではしっくりきていませんが)デジタルサイネージスクリプトの一例を紹介します。

実装のモチベーション

食材の賞味期限を冷蔵庫の中で切らしてしまったり、既に自宅にストックがあるのに新しく買ってきてダブらせてしまったりと、食材や消耗品の管理があまりに下手だったので、Notionのデータベースで管理することにしました。

namakoman.hatenablog.jp

その結果出来上がったのが上の画像のようなデータベースです。「在庫管理」のトグルを開いて、材料および自炊した料理の個数や賞味期限を入力すると、食材データベース(材料を管理)および自炊データベース(作った料理を管理)のそれぞれについて、賞味期限が近付いてきたものおよび、補充が必要なもののみが「賞味期限が近いもの」「少ないもの」の見出しの下側に強調表示されます。

このデータベースを見ることで、消費すべき食材の優先順位や、買い物の際に補充すべき食材が一目でわかるのですが、確認のためにわざわざスマホやPCからNotionを開かなければならないのは、少々手間です。

namakoman.hatenablog.jp

そこで、先日作成し、今日に至るまで想定通りの挙動をしてくれているスケジュール管理用デジタルサイネージと同じ要領で、食材管理データベースのデータを表示するデジタルサイネージPythonpygameで実装することにしたのでした。

なお、今回のデジタルサイネージを実行する環境はLinuxをインストールしたタブレットを想定しています。

準備

背景画像の準備

デジタルサイネージの背景に表示する画像を準備します。今回は端末の解像度に合わせ、1800×1200の画像を準備しました。

今回準備した画像は、海苔弁いちのやの「特製海苔弁」の写真です。1つ1400円する高級のり弁なのですが、本当に美味しいんですよ…。

そんな特製海苔弁の写真を、↑のように加工します。

黒い網掛け部分に、Notionデータベースから取得したストック食材の名前、個数、賞味期限を表示するイメージです。

フォントのインストール

デジタルサイネージに表示する文字として、日本語にも対応したドット風のフォント「DotGothic16」を採用しました。

ダウンロードしたフォントは、ターミナルから以下のコマンドを実行することで使えるようになります。

sudo mkdir -p /usr/share/fonts/truetype/custom
sudo cp ~/Downloads/digitalism.ttf /usr/share/fonts/truetype/custom/
sudo cp ~/Downloads/DotGothic16-Regular.ttf /usr/share/fonts/truetype/custom/
sudo fc-cache -fv

必要パッケージのインストール

Python(3.12.3)に以下のパッケージをインストールします。

anyio==4.9.0
certifi==2025.4.26
charset-normalizer==3.4.1
DateTime==5.5
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.10
notion-client==2.3.0
numpy==2.2.5
pandas==2.2.3
pillow==11.2.1
pygame==2.6.1
python-dateutil==2.9.0.post0
pytz==2025.2
requests==2.32.3
setuptools==80.0.1
six==1.17.0
sniffio==1.3.1
typing_extensions==4.13.2
tzdata==2025.2
urllib3==2.4.0
zope.interface==7.2

Linuxの場合、pipからパッケージをインストールするにはbase環境ではなく、venvで作った仮想環境をアクティベートする必要があります。

python3 -m venv signage #signageは仮想環境名
source ~/signage/bin/activate

Notion APIがデータベースにアクセスするためのインテグレーションを作成

Notion APIはこちらから利用可能です。

developers.notion.com

「View my integrations」から、新しいインテグレーションを作成します。まずは適当にインテグレーションの名前を入力します。

「関連ワークスペース」のトグルには、自分のアカウントが使用可能なワークスペースの一覧が格納されています。目的のデータベースがあるワークスペースを選択します。

種類は「内部」で問題ありません。

「保存」をクリックすると、作成したインテグレーションの細かな設定ができます。

機能のチェックボックスは最低限「コンテンツを読み取る」に入っていれば問題ないはずです。(おそらく…)

「内部インテグレーションシークレット」はAPIキーです。後で必要になります。

データを取得したいデータベースのあるNotionのページにアクセスし、右上の「・・・」をクリックして「接続>接続を追加」から、先ほど作成したインテグレーションをページに接続します。これでAPI経由でページのコンテンツを読み込むことが可能となります。

データベースはメニューからリンクURLを取得することが可能です。

データベースのリンクURLのうち、反転部分はデータベースIDです。(8桁-4桁-4桁-4桁-12桁の英数字。ハイフンは省略可能。)こちらも後で必要になります。

Notionデータベースから得られるJSONの中身は?

API経由で、目的のデータベースから得られるJSONの内容は以下のようになっています。(非常に長いので途中で省略しています。)

{
	'object': 'list',
	 'results': [
		{
			'object': 'page',
			 'id': '(略)',
			 'created_time': '2025-05-31T14 : 57 : 00.000Z',
			 'last_edited_time': '2025-06-02T02 : 59 : 00.000Z',
			 'created_by': {
				'object': 'user',
				 'id': '(略)'
			},
			 'last_edited_by': {
				'object': 'user',
				 'id': '(略)'
			},
			 'cover': None,
			 'icon': None,
			 'parent': {
				'type': 'database_id',
				 'database_id': '(略)'
			},
			 'archived': False,
			 'in_trash': False,
			 'properties': {
				'少ない': {
					'id': 'C%7C%5Cm',
					 'type': 'checkbox',
					 'checkbox': False
				},
				 'ステータス1': {
					'id': 'F%3FY%40',
					 'type': 'formula',
					 'formula': {
						'type': 'string',
						 'string': ''
					}
				},
				 '買わない': {
					'id': 'Fj%60L',
					 'type': 'checkbox',
					 'checkbox': False
				},
				 'ステータス2': {
					'id': 'IdR%5E',
					 'type': 'formula',
					 'formula': {
						'type': 'string',
						 'string': ''
					}
				},
				 '理由': {
					'id': '%5EOs%5C',
					 'type': 'formula',
					 'formula': {
						'type': 'string',
						 'string': ''
					}
				},
				 '賞味期限': {
					'id': 'lTOr',
					 'type': 'date',
					 'date': None
				},
				 '個数': {
					'id': 'zvNI',
					 'type': 'number',
					 'number': 1
				},
				 '名前': {
					'id': 'title',
					 'type': 'title',
					 'title': [
						{
							'type': 'text',
							 'text': {
								'content': 'お茶パック',
								 'link': None
							},
							 'annotations': {
								'bold': False,
								 'italic': False,
								 'strikethrough': False,
								 'underline': False,
								 'code': False,
								 'color': 'default'
							},
							 'plain_text': 'お茶パック',
							 'href': None
						}
					]
				}
			},
			 'url': 'https : //www.notion.so/(略)',
			 'public_url': None
		},

(中略)

],
	 'next_cursor': None,
	 'has_more': False,
	 'type': 'page_or_database',
	 'page_or_database': {
		
	},
	 'request_id': '(略)'
}

results以下に、データベース内の項目の一つ一つが、作成時間や編集時間などのメタデータや、付与されたプロパティ情報とセットで格納されています。

デジタルサイネージ実装Pythonスクリプト

以下のPythonスクリプトnotion_gpt.pyを実行すると、デジタルサイネージが起動します。(お察しの通り、スクリプトの整理にChatGPTの力を借りています。)実行の前に、タブレットの作業ディレクトリに前項で生成した背景画像を移動しておきます。

NOTION_API_KEY = "your_API_key"  # あなたのAPIキー
DATABASE_ID = "your_database_ID_1"      # データベースID(食材管理データベース)
url = f"https://api.notion.com/v1/databases/{DATABASE_ID}/query"
DATABASE_ID2 = "your_database_ID_2"      # あなたのデータベースID(料理管理データベース)
url2 = f"https://api.notion.com/v1/databases/{DATABASE_ID2}/query"

headers = {
    "Authorization": f"Bearer {NOTION_API_KEY}",
    "Notion-Version": "2022-06-28",  # ここはAPIバージョンによる
    "Content-Type": "application/json"
}

まずはrequestsでNotionデータベースからJSONを取得するために、APIキーとデータベースIDを指定します。

食材データベースのIDを「DATABASE_ID」、自炊データベースのIDを「DATABASE_ID2」に指定し、それぞれのデータベースからJSONを取得するためのURL「url」と「url2」を作成します。

def fetch_notion_data():
    global df, df_head, df_tail, has_tail

    def parse_notion_data(url):
        response = requests.post(url, headers=headers)
        text = response.json()
        doc = text["results"]
        chinchin = [d["properties"]["理由"]["formula"]["string"] for d in doc]
        filtered = [doc[i] for i, x in enumerate(chinchin) if x != ""]
        parsed = []
        for item in filtered:
            try:
                parsed.append((
                    item["properties"]["名前"]["title"][0]["text"]["content"],
                    item["properties"]["個数"]["number"],
                    item["properties"]["賞味期限"]["date"]["start"]
                ))
            except TypeError:
                parsed.append((
                    item["properties"]["名前"]["title"][0]["text"]["content"],
                    item["properties"]["個数"]["number"],
                    ""
                ))
        return parsed

    data1 = parse_notion_data(url)
    data2 = parse_notion_data(url2)

    df = pd.DataFrame(data1 + data2, columns=["名前", "個数", "賞味期限"])

    # 名前が長すぎるものを省略
    df["名前"] = df["名前"].apply(lambda x: x[:7] + "…" if len(x) > 7 else x)

    df = df.sort_values(by="賞味期限", ascending=True)
    df.reset_index(drop=True, inplace=True)

    df_head = df.iloc[:14].reset_index(drop=True)
    df_tail = df.iloc[14:].reset_index(drop=True)
    if len(df_tail) > 13:
        df_tail = df_tail.iloc[:14]

    has_tail = not df_tail.empty

fetch_notion_data()で、データベースからのJSON取得、JSONからの必要なデータ(名前、個数、賞味期限、理由)の取得、食材を賞味期限昇順にソートといった操作を実施します。

具体的には「賞味期限が近いもの」「少ないもの」に表示される食材のみをデジタルサイネージに表示すべき情報としてフィルタリングし、そこから名前、個数、賞味期限を取得の後、それらをデータフレームとして出力します。

さらに、出力したデータフレームを賞味期限昇順にソートしたうえで、名前の長すぎる食材は7文字目以降を「…」に置き換えて描画範囲内に収まるようにします。

def draw_table():
    screen.blit(bgimage, (0, 0))
    
    # 横位置の初期値
    x = start_x

    # タイトル
    title_surf = font_title.render("冷蔵庫 在庫・賞味期限チェック", True, text_color)
    rect_title = pygame.Rect(x, start_y2, 750, 120)
    screen.blit(title_surf, (rect_title.x + 7.5, rect_title.y + 7.5))

    # 列名を描画
    for col_name in df_head.columns:
        width = column_widths[col_name]
        rect = pygame.Rect(x, start_y, width, cell_height)
        text_surf = fontb.render(str(col_name), True, text_color)
        screen.blit(text_surf, (rect.x + 7.5, rect.y + 7.5))
        x += width

    # データ行の描画
    for row_index, row in df_head.iterrows():
        x = start_x
        for col_name in df_head.columns:
            width = column_widths[col_name]
            value = str(row[col_name])
            rect = pygame.Rect(x, start_y + (row_index + 1) * cell_height, width, cell_height)
            text_surf = font.render(value, True, text_color)
            screen.blit(text_surf, (rect.x + 7.5, rect.y + 7.5))
            x += width
    
    if has_tail == True:
            x2 = start_x2

            # 列名を描画
            for col_name in df_tail.columns:
                width = column_widths[col_name]
                rect = pygame.Rect(x2, start_y, width, cell_height)
                text_surf = fontb.render(str(col_name), True, text_color)
                screen.blit(text_surf, (rect.x + 7.5, rect.y + 7.5))
                x2 += width

            # データ行の描画
            for row_index, row in df_tail.iterrows():
                x2 = start_x2
                for col_name in df_tail.columns:
                    width = column_widths[col_name]
                    value = str(row[col_name])
                    rect = pygame.Rect(x2, start_y + (row_index + 1) * cell_height, width, cell_height)
                    text_surf = font.render(value, True, text_color)
                    screen.blit(text_surf, (rect.x + 7.5, rect.y + 7.5))
                    x2 += width

draw_table()で、テーブル型の描画スペースに、fetch_notion_data()で取得したデータを描画します。

def schedule_data_refresh():
    while True:
        now = datetime.now(pytz.timezone('Asia/Tokyo'))
        next_fetch = min([(now.replace(hour=h, minute=0, second=0) + timedelta(days=(now.hour >= h)))
                          for h in range(0,24)])
        time.sleep((next_fetch - now).total_seconds())
        fetch_notion_data()

schedule_data_refresh()は、毎時0分にfetch_notion_data()を実行する関数です。すなわちデジタルサイネージの表示内容は毎時0分に更新されます。

fetch_notion_data()
th = threading.Thread(target=schedule_data_refresh, daemon=True)
th.start()

# メインループ
running = True
while running:
    draw_table()
    pygame.display.flip()

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

pygame.quit()

Threadingでschedule_data_refresh()を回し続け、さらにdraw_tableを実行し続けます。

トラブルシューティング

APIキーと、データを取得したいデータベースのIDが正確なのに、なぜか、

response = requests.post(url, headers=headers)
text = response.json()

を実行し、オブジェクト「text」の内容を確認しても、

{'object': 'error', 'status': 404, 'code': 'object_not_found', 'message': 'Could not find database with ID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee. Make sure the relevant pages and databases are shared with your integration.', 'request_id': 'ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj'}

となり、データベース内のデータを何も取得できていないという症状に悩まされました。

これはデータベースIDを取得する際に、リンクドデータベースのURLからIDを取得していたのが原因でした。

リンクドデータベースが参照している大元のデータベースのURLからIDを取得することで、データを取得することができます。

あとがき

意図した挙動はしてくれたのですが…実際に使ってみて、以下の点が不便でした。

・個数0の食材(≒現在冷蔵庫に入っていない食材)の表示が思いのほか邪魔だった

・冷蔵庫内の、賞味期限にまだ余裕のある食材が表示されない

・点けっぱなしだと夜にまぶしい

・日付と現在時刻が表示されない(キッチンがカレンダーや時計のある場所から遠いので、冷蔵庫のすぐそばに現在時刻を表示できるものがあるとありがたいのです)

要は、冷蔵庫の傍に設置するデジタルサイネージには、単に補充したい食材や賞味期限の近い食材だけを表示するのではなく、冷蔵庫の中に入っている食材を全て表示したほうが便利ではないかと思ったんですよ。そうすれば、いちいち冷蔵庫を開くことなく冷蔵庫内の現状を把握できて、献立をより考えやすくなります。これに関してはNotionのデータベース側のプロパティを調整すればいけそうな気がするので、今後も手を加え、しっくりくる形に仕上げていきます。

とはいえ、補充したい食材の一覧も表示できるなら表示しておくにこしたことはないと思うので…もう一つタブレット探すかぁ(え)