DESIGN NOTE 2026-05-19 Python / Notion API / SQLite

マインド MD の出典 URL を Notion 両 DB に自動反映する設計と落とし穴

「読んだ記事をチェックリスト化したが、原典に戻れない」というよくある問題を、Notion DB スキーマの冪等な自動追加__file__ ベースの DB パス解決で解いた話。途中で踏んだ「空の孤立 DB」問題の解説付き。

対象 DB
Notion ×2 + SQLite
変更ファイル
5 + 設定/Doc
遡及反映
22 / 27 件
所要時間
約 2 時間
01

問題: 「読んだ記事をチェックリスト化したが、原典に戻れない」

手元に「マインドセット系の記事」を MD で蓄積する vault がある。X の長文ポスト、note 記事、YouTube 動画の書き起こしなど。各 MD には frontmatter で出典 URL が記録されている。

1_インプット/マインドセット/AIを武器に....md の frontmatter
---
title: "AIを武器に、やりたいことを自分でデザインして生きるということ|そら"
source: "https://note.com/sora19ai/n/n7598faf0b2a0"
published: 2026-05-02
tags: [clippings]
check_items:
  - id: cd3089987
    text: "「どうすれば楽に稼げる?」ではなく ..."
    ...
---

これを毎晩 23:00 に Claude が読み、Yes/No 形式の「問いかけ」を 3〜7 個抽出して Notion DB に流し込む。寝る前にスマホで Notion を開き、できた項目にチェックを入れる運用が回っている。

問題

Notion 上のチェック項目には「Source MD: AIを武器に....md」とファイル名だけが記録されていて、元 URL は捨てられていた。「この問いの原典をもう一度読みたい」と思ってもタップで飛べない。MD を開けば URL はあるが、スマホで Notion → ファイラー → MD 検索の手順は重い。

求めるゴール:

02

設計の核: 3 つの冪等パターン

実装の中心は「毎回起動時に冪等に状態を整える」3 つのレイヤー。スキーマ・データ・列構造を、それぞれが「あれば何もしない、なければ整える」関数で運用する。

図 1. 3 レイヤーの冪等構造

MD frontmatter source: "https://..." 真実の源 Layer 1: 抽出 extract_source_url() 正規表現で 3 パターン対応 Layer 2: スキーマ追加 ensure_database_property() retrieve → 無ければ PATCH url / rich_text / checkbox / number / date Layer 3: SQLite 列追加 _COLUMN_MIGRATIONS PRAGMA table_info → ADD COLUMN リストに 1 行足すだけで冪等 Notion Master Items Source URL (URL 型) Notion Daily Log Source URL (URL 型) SQLite master_items.source_url daily_log.source_url

Layer 1 URL 抽出: 3 パターンを 1 つの正規表現で

frontmatter の source フィールドには 3 つのパターンが混在していた:

パターン A: URL 直書き
"https://note.com/sora/..."
パターン B: 説明 + URL
YouTube動画「...」 https://youtu.be/xxx
パターン C: list 形式
["https://a.com", "https://b.com"]
_lib/frontmatter.py
_URL_RE = re.compile(r"https?://[^\s\"'<>)\]]+")

def extract_source_url(source_value) -> str | None:
    """URL 直書き / "テキスト + URL" / list 形式の 3 パターンに対応。"""
    if source_value is None: return None
    if isinstance(source_value, list):
        for v in source_value:
            url = extract_source_url(v)
            if url: return url
        return None
    s = str(source_value).strip()
    if not s: return None
    m = _URL_RE.search(s)
    return m.group(0).rstrip(".,)\"';") if m else None

末尾の .,)"'; 除去は地味だが重要。「see https://example.com/page).」のような文末で URL が壊れるのを防ぐ。

Layer 2 Notion スキーマの冪等な自動追加

Notion DB に新しいプロパティを足したいとき、UI で手作業するのが普通だが「ユーザーが忘れたら止まる」。スクリプト起動時に「あれば何もしない、なければ追加する」関数を毎回呼ぶ設計に。Notion API の PATCH /databases/{id}プロパティ追加に対して冪等(既存名で同じ型なら no-op)だが、念のため事前 retrieve_database で存在チェックする。

_lib/notion_client.py
_SUPPORTED_PROP_TYPES = {
    "url": {"url": {}},
    "rich_text": {"rich_text": {}},
    "checkbox": {"checkbox": {}},
    "number": {"number": {"format": "number"}},
    "date": {"date": {}},
}

def ensure_database_property(token, db_id, prop_name, prop_type="url") -> bool:
    """DB に指定プロパティが無ければ追加。冪等。"""
    meta = retrieve_database(token, db_id)
    if prop_name in (meta.get("properties") or {}):
        return False  # 既存、何もしない
    _request(token, "PATCH", f"/databases/{db_id}", {
        "properties": {prop_name: _SUPPORTED_PROP_TYPES[prop_type]}
    })
    return True  # 今回追加

利点: 「新プロパティを足すたびに README に手作業を書き足す」運用から解放される。01_scan_and_extract.py02_send_nightly_checklist.py の冒頭で各 DB に対して 1 行呼ぶだけで、初回起動時にスキーマが整い、以降はノーオペで通り過ぎる。

Layer 3 SQLite 列追加マイグレーションの軽量化

SQLite は ALTER TABLE ADD COLUMN IF NOT EXISTS を持たない。重い migration ライブラリを入れるほどの規模でもないので、「ペア (table, column, ddl) のリスト」+ PRAGMA table_info による存在チェックだけで冪等にする。

_lib/db.py
_COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
    ("master_items", "source_url",
     "ALTER TABLE master_items ADD COLUMN source_url TEXT"),
    ("daily_log", "source_url",
     "ALTER TABLE daily_log ADD COLUMN source_url TEXT"),
]

def _column_exists(conn, table, column):
    cur = conn.execute(f"PRAGMA table_info({table})")
    return any(row[1] == column for row in cur.fetchall())

def _apply_column_migrations(conn):
    for table, column, ddl in _COLUMN_MIGRATIONS:
        if not _column_exists(conn, table, column):
            conn.execute(ddl)
    conn.commit()

def connect(db_path=None):
    conn = sqlite3.connect(str(db_path or DB_PATH))
    conn.executescript(_SCHEMA_SQL)
    _apply_column_migrations(conn)  # ← 接続のたびに毎回チェック
    return conn

将来別の列を足したくなったら、リストに 1 行追記するだけ。30 行未満で「最低限の migration 機構」が組める。

03

落とし穴: 「空の孤立 DB」が現れた

Notion 側への反映が完了して「updated: 22」と出たので、念のため SQLite を確認したら——

=== daily_log 2026-05-16 (source_url 集計) ===
  total=0, with_url=None
=== master_items サンプル ===
  (empty)

Notion は埋まっているのに、SQLite は 。同じスクリプトが両方を更新したはずなのに整合性が崩れている。原因を追っていくと、こうだった:

図 2. DB_PATH の落とし穴 — 実体と空 DB の二重構造

実体 (vault 配下) ~/brainコミット/.../daily-mindset/ scripts/_lib/db.py DB_PATH=Path.home() / projects / ... data/checks.db ★ 真の DB (32 件) この場所を見るべきだった 期待 実際は 空の孤立 DB ~/projects/daily-mindset/ data/checks.db (空) total=0 / 過去テスト時に誤生成 DB_PATH がここを指していた → SQLite UPDATE は空打ち

_lib/db.py はこう書かれていた:

# 旧: 別の場所を見てしまう
DB_PATH = Path.home() / "projects" / "daily-mindset-checklist" / "data" / "checks.db"

プロジェクト実体は vault 配下 (~/brainコミット/...) に置いていたが、Path.home() / "projects" / ... で開いた先には過去のテスト時に作られた空の DBが残っていた。スクリプトはそこに UPDATE を打ち、何もマッチしないまま帰ってくる。

これが起きやすい構造

  • 過去にプロジェクトを ~/projects/ に置いて開発を始めた
  • 途中で vault (~/brainコミット/) 配下に移動した
  • でも DB_PATH = Path.home() / "projects" / ... はそのままだった
  • 過去の ~/projects/ の残骸(空ディレクトリ + 空 DB ファイル)が残っていた
  • Notion API は OS パスとは無関係なので、こちらは正常に動いてしまう ← 検知が遅れる
04

解決: __file__ ベースの DB_PATH + 遡及 backfill

解決 1: Path(__file__) ベースに切替

scripts/data/ が同じ親ディレクトリにある」という物理構造を信用する形に変える。スクリプト自身の場所基準でパスを解決すれば、プロジェクトをどこに置いても狂わない。

# 新: scripts/_lib/db.py から見て project root の data/checks.db を必ず指す
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "checks.db"
resolve() の結果
~/brainコミット/.../scripts/_lib/db.py
parent.parent.parent / data / checks.db
~/brainコミット/.../data/checks.db

原則: 「ユーザーの HOME を起点にする」設計より、「ファイル自身の位置を起点にする」設計の方が、プロジェクトの移動・別マシン展開・vault 構造の変更に強い。Path.home() は OS ユーザーの環境に依存するので、複数プロジェクトを管理するときは特に注意。

解決 2: --backfill-urls モードで過去項目を救う

新規追加された項目は次回 23:00 cron で自動的に Source URL が入るが、既に Notion に登録済みの 27 件は遡及更新が必要。Master と Daily Log の両方を後から埋めるための独立モードを切り出した。

02_send_nightly_checklist.py --backfill-urls --date 2026-05-16
def _backfill_urls(token, master_db_id, daily_db_id, target_date):
    """指定日の既存 Daily Log 行に Master 由来の Source URL を後付け。"""
    active_items = _list_active_master_items(token, master_db_id)
    url_by_item_id = {it["item_id"]: it.get("source_url")
                      for it in active_items}

    rows = notion_client.query_database(token, daily_db_id,
        filter_={"property": "Date",
                 "date": {"equals": target_date}})

    for row in rows:
        item_id = notion_client.rich_text(row["properties"]["Master ID"])
        url = url_by_item_id.get(item_id)
        if url and url != notion_client.url_value(row["properties"].get("Source URL", {})):
            notion_client.update_page(token, row["id"],
                {"Source URL": notion_client.make_url(url)})
    # 加えて SQLite daily_log も同じ url で UPDATE
設計のポイント

「Master を真実の源」とする原則を維持したまま、Daily Log の過去レコードに片方向に同期する。Master → Daily に値を流すだけで、逆流はしない。

冪等性

既存 URL と同じなら skip、空欄なら埋める、Master 側に URL が無ければ skip。--backfill-urls を何度叩いても結果が同じ。

遡及反映の結果

対象 総数 URL 反映 URL 空欄 理由
Notion Master Items 32 22 10 5 件は MD に source 欠落 / 5 件は削除済み MD 由来
Notion Daily Log 27 22 5 Master の URL 空 5 件と同期
SQLite daily_log 32 22 10 Notion と同じ + 過去スナップショット 5 件残存
05

教訓

スキーマ自動追加は「起動時の 1 行」で十分

「Notion DB に新プロパティを足してください」と README に書く運用は破綻しやすい。ensure_database_property を毎回呼ぶ設計に変えれば、ユーザーは何もしなくて済む。冪等性が満たされていれば過剰呼び出しのリスクもない。

Path.home() ベースは罠

プロジェクトを移動した・別マシンに展開した・vault 構造を変えた、のいずれかで破綻する。Path(__file__).resolve() でファイル自身を起点にすれば、scripts と data の物理的な親子関係だけを信用すればよい。

遡及反映を最初から設計に含める

「今日以降は埋まる」だけでは過去データとの整合性が崩れる。--backfill-urls のような独立モードを最初から切り出しておけば、後付けで仕様変更しやすい。冪等性を保てば何回でも再実行できる。

汎用化できる設計パターン

  • スキーマ・データ・列構造の 3 レイヤーすべてを冪等に: 起動時に毎回「あるべき状態」に整える関数を呼ぶ。データソースが Notion / SQLite / API でも同じ思想で書ける
  • 「真実の源」を 1 つに固定する: 今回は Master が真実の源、Daily Log は派生スナップショット。書き戻しの方向を 1 方向に絞ることで遡及更新の設計が単純化
  • 失敗時の検証手段を埋め込む: scan_log テーブルに started_at / finished_at / status を残しておくと、Notion 反映と SQLite 反映の不一致が即座に検知できる
  • OS パスは Path ライブラリで「ファイル自身の位置」を起点に: Path.home() や環境変数より、__file__ 起点の相対解決の方が移植性が高い