【読書記録】ブクログではなくNotionで。楽天ブックスAPIで書影を自動取得

読書記録の王道サービスは、
おそらくブクログです。

以前、使っていたのですが、私にはなぜか合わず…。

いまはNotionに移行しています。

今回は、その理由と、楽天ブックスAPIを使って書影を自動取得する方法までお伝えします。

Notionで書影を取得するイメージ
Contents

読書記録の3つの方法

読書記録をとる方法として、3つ検討しました。

1つ目は冒頭にあげた「ブクログ」
無料で使えますし、基本的にはおすすめのサービスではあります。


2つ目は「Notion+Amazon」を軸にした方法。
Amazonの商品ページをNotionへ共有したり、Web Clipperで保存したりする方法、公開テンプレなどがあります。

ただ、私が天邪鬼なのか、
「人が作ったテンプレはそのまま使いたくない」という思いがふつふつと湧き…。


3つ目の「Notion+楽天ブックスAPI」という方法に辿り着きました。

「素直にブクログ使っておけばいいのに…」と言われる気もするのですが、
どうしても使いたくなく。

ざっくりですが、3つの記録方法を比較するとこんな感じです。

項目ブクログNotion+AmazonNotion+楽天API
(今回の方法)
初期設定◎ ほぼ不要◎ 簡単△ API・Pythonの設定が必要
本の登録◎ 楽。
バーコードもあり
○ Amazon商品ページから
共有・Web Clipper
○ タイトル+著者を入力
登録時の
外部サイトアクセス
△ ブクログにアクセス必要△ 商品ページを開く必要あり◎ 不要
書影◎ 自動△ Web Clipper等の
解析結果に依存
◎ APIから自動取得
取得の安定性△ 環境・Webページに依存○ API検索・タイトル表記に依存
情報の集約△ 読書だけ
別サービス
◎ Notionに集約◎ Notionに集約
自動化の保守◎ 不要◎ ほぼ不要△ API認証等の保守あり
仕組みの拡張性△〜○◎ Pythonで拡張可能

ブクログではなくNotionを選んだ理由

ブクログではなくNotionを選んだ理由を書いてみます。

①Notionに情報を集約したい

タスクのメモやネタ帳、お客様カルテなど、
基本的に情報をすべてNotionにまとめる形で使っています。

そのため、ブクログを使うと情報が一元管理できないという点がネックになっていました。

「ブクログで登録した情報をNotionに連携させる」ことも検討したのですが、
現時点では、ブクログとNotionを直接つなぐ公式API連携はなさそうで…。

CSVでエクスポートしてつなぐことはできるのかもしれないですが、
手間を増やしたくなく、シンプルでもなくなるので不採用に。

②自分用にカスタマイズしたい

ブクログに限らず外部の専用サービスを使うと、
自分好みにカスタマイズがしづらくなります。

Notionであれば「プロパティ」という機能で必要な情報を整理しやすくなりますし、
1冊ごとにメモを細かく残すことも可能です。
(私は細かいメモまではとりませんが)

こういった理由もあり、Notionで管理することにしています。

③アクセスの手間・UIとの相性

毎回ブラウザでアクセスする、スマホでアプリを開くというのが好きではなく。

Notionはいつも開いているので、比較すると少しだけ手軽です。

またこれは完全に好みですが、UIもあまり…という形で。

ブクログを使っている人を非難したいわけではなく、
私には合わなかったという話です。

Notionで読書記録を管理する

さて、ここからは具体的な記録方法などについて書いていきます。

なお、「読書DBそのものの作り方・考え方」は以前YouTubeで紹介しています。

今回は「書影をどう自動取得したか」に絞って、お伝えしていきます。

全体像としては、こんなイメージです。
無料の範囲で構築できます。

全体の流れ

Notionに本のタイトル・著者を入力

WindowsでPythonを定期実行

楽天ブックスAPIで検索

書影URLを取得

Notion APIでページカバーに設定

本棚ビューに表示

以下、楽天ブックスから書影を自動取得する6つの手順を書いてみます。
Windowsを前提にしています。

①楽天Web Serviceから認証情報を取得する

楽天Web Serviceにアクセスして、楽天ブックスと連携できるようにしていきます。

楽天のアカウントでログインをしたら、「New App」をクリックします。

「新規アプリ登録」の画面が出てきます。

以下のイメージで設定していきましょう。

項目今回の入力内容補足
アプリケーション名Notion読書DB書影取得自分で用途が分かる名前に設定。
アプリケーションURLhttps://free-to-blog.com/今回のアプリに紐づける自分のWebサイトを設定。
アプリケーションタイプAPI/バックエンドサービスWeb画面から直接使うのではなく、Windows上のPythonからAPIへアクセスするため。
許可されたWebサイトfree-to-blog.comアプリケーションURLと同じ自分のドメインを設定。
許可されたIPアドレス自宅回線のグローバルIPアドレスコマンドプロンプトで curl https://api4.ipify.org と入力して確認。今回はWindows上のPythonからAPIを実行するため、このIPからアクセス。
アプリケーションの説明Notionの個人用読書データベースに書籍の表紙画像を設定するため用途が分かる程度に簡潔に記載。
データ利用目的楽天ブックスAPIを利用して、書籍タイトルおよび著者名から書籍情報を検索し、取得した書影URLを本人専用のNotion読書データベースのページカバーとして表示するために使用します。取得したデータの第三者への販売・再配布は行いません。「何のデータを、何のために使うか」が分かるように記載。
予想QPS11秒あたりの想定APIリクエスト数。今回の用途なら1で十分。
APIアクセススコープ楽天ブックスAPIのみ他の楽天市場API・楽天KoboAPIなどは使わないため未選択でOK。

無事、登録できるとこのような情報が取得できます。

②NotionのAPI情報を取得する

Notionでは「Personal access token」を作成し、「読書DBのDatabase ID」を確認していきます。

任意のページから、
以下の手順で「開発者ポータル」を開きます。

ポータルにアクセスできたら、
「Personal access tokens」から「New token」をクリックします。

すると作成情報の入力画面が表示されますので、以下のように入力していきましょう。

補足すると入力内容は下記のイメージです。

項目今回の入力内容補足
Token nameNotion読書DB書影取得後から用途が分かる名前に設定。
Workspace読書DBがある自分のワークスペースこのトークンからアクセスするNotionワークスペースを選択。
Expiration2027年8月8日今回は1年を選択。期限前に更新・再発行が必要。
Notion APIオンNotionのデータベースを読み取り、ページカバーを書き換えるために必要。
Workersオフ今回はWindows上のPythonから処理するため使用しない。

作成するとPersonal access tokenが発行されます。

このトークンは後ほど.envファイルのNOTION_TOKENとして保存します。
パスワードと同様の情報なので、公開しないように注意しましょう。


続いて、読書DBのDatabase IDを確認します。

Notionで読書DBのリンクをコピーすると、URL内にデータベースを識別するIDが含まれています。
(ページの右上)

このリンクをベースにDatabase IDを取得します。

コピーしたURLのうち、?v=の前にある32文字がDatabase IDです。

たとえばコピーしたURLが、

https://www.notion.so/248104cd477e80fdb757e945d38000bd?v=148104cd477e80bb928f000ce197ddf2

なら

248104cd477e80fdb757e945d38000bd

がDatabase IDです。

③.envに認証情報を保存し、Pythonを組む

まず、楽天Web ServiceとNotionで取得した認証情報を、Pythonファイルに直接書かず.envファイルに保存します。

メモ帳を開いて、以下の記載をしていきましょう。

NOTION_TOKEN=NotionのPersonal access token
NOTION_DATABASE_ID=Notion読書DBのDatabase ID
RAKUTEN_APP_ID=楽天Web ServiceのApplication ID
RAKUTEN_ACCESS_KEY=楽天Web ServiceのAccess Key

なお、.envは認証情報をまとめておく設定ファイルです。
トークンやAccess KeyをPythonコードに直接書かずに済むので、コードを修正・共有するときにも扱いやすくなります。

記載できたら、ファイル名を「.env」、ファイルの種類を「すべてのファイル」として保存します。


そのうえでPythonでは、次の流れを組みました。

Pythonの流れ

Notionの読書DBを取得

ページカバーがない本を確認

「本のタイトル+著者」で楽天ブックスAPIを検索

書影URLを取得

Notion APIでページカバーに設定

※Pythonをインストールしていない場合は、先にPythonを利用できる状態にしておきましょう。詳細はこちら

必要なライブラリ(Pythonの機能みたいなもの)は、次の2つです。
PowerShellを開き、以下のコマンドを実行すると追加できます。

py -m pip install requests python-dotenv

全文は以下に載せておきます。

Pythonのコード全文
from __future__ import annotations

import argparse
import os
import re
import time
import unicodedata
from pathlib import Path
from typing import Any

import requests
from dotenv import load_dotenv


NOTION_API_BASE = "https://api.notion.com/v1"
NOTION_VERSION = "2026-03-11"
RAKUTEN_API_URL = (
    "https://openapi.rakuten.co.jp/services/api/BooksBook/Search/20170404"
)

TITLE_PROPERTY = "本のタイトル"
AUTHOR_PROPERTY = "著者"


def load_settings() -> dict[str, str]:
    env_path = Path(__file__).resolve().parent / ".env"
    load_dotenv(env_path)

    keys = [
        "NOTION_TOKEN",
        "NOTION_DATABASE_ID",
        "RAKUTEN_APP_ID",
        "RAKUTEN_ACCESS_KEY",
    ]
    settings = {key: os.getenv(key, "").strip() for key in keys}
    missing = [key for key, value in settings.items() if not value]

    if missing:
        raise RuntimeError(
            ".env に次の設定がありません: " + ", ".join(missing)
        )

    return settings


def notion_headers(token: str) -> dict[str, str]:
    return {
        "Authorization": f"Bearer {token}",
        "Notion-Version": NOTION_VERSION,
        "Content-Type": "application/json",
    }


def request_json(
    method: str,
    url: str,
    *,
    headers: dict[str, str],
    params: dict[str, Any] | None = None,
    json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
    response = requests.request(
        method,
        url,
        headers=headers,
        params=params,
        json=json_body,
        timeout=30,
    )

    if response.ok:
        return response.json()

    try:
        detail = response.json()
    except ValueError:
        detail = response.text

    raise RuntimeError(
        f"APIエラー: {method} {url}\n"
        f"HTTP {response.status_code}\n{detail}"
    )


def get_data_source_id(
    database_id: str,
    headers: dict[str, str],
) -> str:
    database = request_json(
        "GET",
        f"{NOTION_API_BASE}/databases/{database_id}",
        headers=headers,
    )

    data_sources = database.get("data_sources", [])
    if not data_sources:
        raise RuntimeError("Notionデータベース内にデータソースがありません。")

    # 今回のDBは1データソースなので先頭を使います。
    return data_sources[0]["id"]


def query_pages(
    data_source_id: str,
    headers: dict[str, str],
) -> list[dict[str, Any]]:
    pages: list[dict[str, Any]] = []
    cursor: str | None = None

    while True:
        body: dict[str, Any] = {"page_size": 100}
        if cursor:
            body["start_cursor"] = cursor

        result = request_json(
            "POST",
            f"{NOTION_API_BASE}/data_sources/{data_source_id}/query",
            headers=headers,
            json_body=body,
        )

        pages.extend(result.get("results", []))

        if not result.get("has_more"):
            break

        cursor = result.get("next_cursor")
        if not cursor:
            break

    return pages


def rich_text_to_plain(items: list[dict[str, Any]]) -> str:
    return "".join(item.get("plain_text", "") for item in items).strip()


def get_page_text(page: dict[str, Any], property_name: str) -> str:
    prop = page.get("properties", {}).get(property_name, {})
    prop_type = prop.get("type")

    if prop_type == "title":
        return rich_text_to_plain(prop.get("title", []))

    if prop_type == "rich_text":
        return rich_text_to_plain(prop.get("rich_text", []))

    return ""


def normalize_text(value: str) -> str:
    value = unicodedata.normalize("NFKC", value).lower()
    value = re.sub(r"\s+", "", value)
    value = re.sub(r"[「」『』()()\[\]【】・::,,.。!!??\-ー_]", "", value)
    return value


def normalize_items(data: dict[str, Any]) -> list[dict[str, Any]]:
    raw_items = data.get("Items")
    if raw_items is None:
        raw_items = data.get("items", [])

    items: list[dict[str, Any]] = []
    for raw in raw_items or []:
        if not isinstance(raw, dict):
            continue

        item = raw.get("Item")
        if item is None:
            item = raw.get("item")
        if item is None:
            item = raw

        if isinstance(item, dict):
            items.append(item)

    return items


def rakuten_search(
    *,
    title: str,
    author: str,
    app_id: str,
    access_key: str,
) -> list[dict[str, Any]]:
    params: dict[str, str | int] = {
        "applicationId": app_id,
        "format": "json",
        "formatVersion": 2,
        "title": title,
        "hits": 10,
        "outOfStockFlag": 1,
    }

    if author:
        params["author"] = author

    headers = {
        "accessKey": access_key,
        "User-Agent": "NotionBookCoverSync/1.0",
    }

    data = request_json(
        "GET",
        RAKUTEN_API_URL,
        headers=headers,
        params=params,
    )
    return normalize_items(data)


def score_book(
    item: dict[str, Any],
    wanted_title: str,
    wanted_author: str,
) -> int:
    item_title = normalize_text(str(item.get("title", "")))
    item_author = normalize_text(str(item.get("author", "")))
    wanted_title_n = normalize_text(wanted_title)
    wanted_author_n = normalize_text(wanted_author)

    score = 0

    if item_title == wanted_title_n:
        score += 100
    elif wanted_title_n and wanted_title_n in item_title:
        score += 70
    elif item_title and item_title in wanted_title_n:
        score += 50

    if wanted_author_n:
        if item_author == wanted_author_n:
            score += 40
        elif wanted_author_n in item_author:
            score += 30
        elif item_author and item_author in wanted_author_n:
            score += 20

    if item.get("largeImageUrl"):
        score += 5

    return score


def find_best_book(
    *,
    title: str,
    author: str,
    app_id: str,
    access_key: str,
) -> dict[str, Any] | None:
    # まずタイトル+著者で検索。
    items = rakuten_search(
        title=title,
        author=author,
        app_id=app_id,
        access_key=access_key,
    )

    # 見つからない場合のみタイトル単独へ緩める。
    if not items and author:
        items = rakuten_search(
            title=title,
            author="",
            app_id=app_id,
            access_key=access_key,
        )

    candidates = [
        item for item in items
        if item.get("largeImageUrl")
    ]
    if not candidates:
        return None

    return max(
        candidates,
        key=lambda item: score_book(item, title, author),
    )


def set_page_cover(
    page_id: str,
    image_url: str,
    headers: dict[str, str],
) -> None:
    request_json(
        "PATCH",
        f"{NOTION_API_BASE}/pages/{page_id}",
        headers=headers,
        json_body={
            "cover": {
                "type": "external",
                "external": {"url": image_url},
            }
        },
    )


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Notion読書DBのカバー未設定ページに楽天ブックスの書影を設定します。"
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="実際にNotionへ書影を設定します。指定しない場合は確認だけです。",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=1,
        help="処理する最大件数。初期値は1件です。",
    )
    parser.add_argument(
        "--title",
        help="この文字を含む本だけを対象にします。",
    )
    args = parser.parse_args()

    if args.limit < 1:
        raise RuntimeError("--limit は1以上で指定してください。")

    settings = load_settings()
    n_headers = notion_headers(settings["NOTION_TOKEN"])

    print("Notion読書DBを確認しています...")
    data_source_id = get_data_source_id(
        settings["NOTION_DATABASE_ID"],
        n_headers,
    )
    pages = query_pages(data_source_id, n_headers)

    targets: list[dict[str, Any]] = []
    for page in pages:
        title = get_page_text(page, TITLE_PROPERTY)
        author = get_page_text(page, AUTHOR_PROPERTY)

        if not title:
            continue
        if page.get("cover") is not None:
            continue
        if args.title and args.title not in title:
            continue

        targets.append(
            {
                "page_id": page["id"],
                "title": title,
                "author": author,
            }
        )

    print(f"カバー未設定の対象: {len(targets)}件")

    if not targets:
        print("処理対象はありません。")
        return 0

    mode = "書影を設定" if args.apply else "確認のみ"
    print(f"実行モード: {mode}")
    print(f"今回の上限: {args.limit}件")

    processed = 0
    updated = 0
    not_found = 0

    for target in targets[: args.limit]:
        processed += 1
        title = target["title"]
        author = target["author"]

        print("\n" + "=" * 70)
        print(f"[{processed}] {title}")
        print(f"Notion著者: {author or '(空欄)'}")

        book = find_best_book(
            title=title,
            author=author,
            app_id=settings["RAKUTEN_APP_ID"],
            access_key=settings["RAKUTEN_ACCESS_KEY"],
        )

        if not book:
            not_found += 1
            print("楽天ブックスで書影付きの本が見つかりませんでした。")
            continue

        image_url = str(book.get("largeImageUrl", ""))
        print(f"候補タイトル: {book.get('title', '')}")
        print(f"候補著者: {book.get('author', '')}")
        print(f"ISBN: {book.get('isbn', '')}")
        print(f"書影URL: {image_url}")

        if args.apply:
            set_page_cover(
                target["page_id"],
                image_url,
                n_headers,
            )
            updated += 1
            print("→ Notionのページカバーを設定しました。")
        else:
            print("→ 確認のみのため、Notionは変更していません。")

        time.sleep(1)

    print("\n" + "=" * 70)
    print("処理結果")
    print(f"確認した件数: {processed}")
    print(f"設定した件数: {updated}")
    print(f"見つからなかった件数: {not_found}")

    if not args.apply:
        print(
            "\n候補が正しければ、次のコマンドで1件だけ実際に設定します。"
            "\npy .\\notion_book_cover_sync.py --apply --limit 1"
        )

    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except requests.RequestException as exc:
        print(f"通信エラー: {exc}")
        raise SystemExit(1)
    except Exception as exc:
        print(exc)
        raise SystemExit(1)

これもメモ帳で記載して、ファイル名の末尾を「.py」にし、ファイルの種類は「すべてのファイル」として保存します。

名前は「notion_book_cover_sync.py」といったイメージです。

なお、「.env」と「notion_book_cover_sync.py」は同じフォルダに保存します。

※今回のコードでは、Notionのタイトル用プロパティを「本のタイトル」、著者用プロパティを「著者」としています。自分のデータベースでプロパティ名が異なる場合は、コード内のTITLE_PROPERTYとAUTHOR_PROPERTYを変更してください。

コード自体の起動は、⑤BATファイルで解説します。

④Notionのギャラリービューを設定する

データベース上部にある「+」をクリックして、「ギャラリービュー」を追加します。

ビューの名前を設定し、カードプレビューは「ページカバー画像」を選択します。

そして、「メディアを表示に合わせる」を「オン」にすると、下記の左側のように表示されます。

手動で実行するだけであれば、ここまででも一通り使える状態です。

⑤BATファイルを作成する

Pythonファイルを毎回コマンドで実行するのは少し手間なので、
実行用のBATファイルを作成します。

メモ帳で以下のコードを記載して、「.bat」ファイルとして保存しましょう。

たとえば「run_book_cover_sync.bat」といったイメージです。

@echo off

cd /d "●●(自分のPythonファイル保存先に更新)"

echo ================================================== >> book_cover_sync.log
echo 実行日時: %date% %time% >> book_cover_sync.log

py notion_book_cover_sync.py --apply --limit 100 >> book_cover_sync.log 2>&1

このBATファイルをダブルクリックすると、Pythonを実行して書影を取得できます。

⑥Windowsのタスクスケジューラで定期実行する

上記でも十分な方もいるかもしれませんが、
さらに書影取得を自動化するのであれば、タスクスケジューラで1日1回起動する形を試してみましょう。

まず、Windowsの検索窓で「タスクスケジューラ」を検索して起動します。

次に、右側にある「基本タスクの作成」をクリックします。

名前をつけて、

トリガーは「毎日」にしておきます。

操作は「プログラムの開始」で、

「プログラム/スクリプト」には、次を入力します。

C:\Windows\System32\cmd.exe

「引数の追加」には、作成したBATファイルのパスを指定します。
(ファイルを右クリックして「パスのコピー」で取得できます)

/c ""●●""

作成できたら、該当タスクのプロパティを開いて、「スケジュールされた時刻にタスクを開始できなかった場合、すぐにタスクを実行する」にチェックを入れておきましょう。

これによって、設定時刻にPCの電源が入っていなくても、その後PCを起動したときに実行されます。

最後に作成したタスクを右クリックして「実行」し、正常に動くか確認しておきましょう。
ログも確認できれば設定完了です。

まとめ

今回は、Notionにタイトルと著者を入力しておけば、楽天ブックスAPIから書影を取得し、本棚ビューまで自動で整える仕組みをつくりました。

初期設定には少し手間がかかりますが、一度つくってしまえば、日々の読書記録では外部の商品ページを探す必要がありません。

とはいえ、ここまで書いたのは、すべてツールの話。
ツールづくりに終始して、肝心の読書が滞らないように気を付けたいものです。

本を読んだら行動に繋げることが最重要。

「読み終わってからがスタート」

このように捉えておきたいものです。

では、また次回。

編集後記

◇日記
 昨日はオフ。
 サンシャインシティに行き、ウルトラマンEXPO2026。
 子ども達は目を輝かせていました。握手もでき(私も)。
 昼食後はナンジャタウンへ。夏休みらしい1日を過ごせました。

◇ブログネタ経緯
 読書記録の作り方を書こうかなと思い。
 想定より長編になってしまいました…。

◇1日1新
 ウルトラマンEXPO2026

よかったらシェアしてね!
  • URLをコピーしました!
Contents