問題: 「読んだ記事をチェックリスト化したが、原典に戻れない」
手元に「マインドセット系の記事」を MD で蓄積する vault がある。X の長文ポスト、note 記事、YouTube 動画の書き起こしなど。各 MD には frontmatter で出典 URL が記録されている。
--- 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 検索の手順は重い。
求めるゴール:
- 各 MD の出典 URL を、Notion の Master Items DB(全項目の図書館)と Daily Log DB(毎日のチェックリスト)両方の URL プロパティに自動反映
- 過去 27 件の項目にも遡及反映(新規だけだと整合性が崩れる)
- Notion 上で URL 型プロパティとして扱い、タップで原典が開けるようにする
- Notion DB のスキーマ変更はスクリプト側で自動化(手作業を増やさない)
設計の核: 3 つの冪等パターン
実装の中心は「毎回起動時に冪等に状態を整える」3 つのレイヤー。スキーマ・データ・列構造を、それぞれが「あれば何もしない、なければ整える」関数で運用する。
図 1. 3 レイヤーの冪等構造
Layer 1 URL 抽出: 3 パターンを 1 つの正規表現で
frontmatter の source フィールドには 3 つのパターンが混在していた:
"https://note.com/sora/..."
YouTube動画「...」 https://youtu.be/xxx
["https://a.com", "https://b.com"]
_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 で存在チェックする。
_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.py と 02_send_nightly_checklist.py の冒頭で各 DB に対して 1 行呼ぶだけで、初回起動時にスキーマが整い、以降はノーオペで通り過ぎる。
Layer 3 SQLite 列追加マイグレーションの軽量化
SQLite は ALTER TABLE ADD COLUMN IF NOT EXISTS を持たない。重い migration ライブラリを入れるほどの規模でもないので、「ペア (table, column, ddl) のリスト」+ PRAGMA table_info による存在チェックだけで冪等にする。
_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 機構」が組める。
落とし穴: 「空の孤立 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 の二重構造
_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 パスとは無関係なので、こちらは正常に動いてしまう ← 検知が遅れる
解決: __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"
~/brainコミット/.../scripts/_lib/db.py
~/brainコミット/.../data/checks.db
原則: 「ユーザーの HOME を起点にする」設計より、「ファイル自身の位置を起点にする」設計の方が、プロジェクトの移動・別マシン展開・vault 構造の変更に強い。Path.home() は OS ユーザーの環境に依存するので、複数プロジェクトを管理するときは特に注意。
解決 2: --backfill-urls モードで過去項目を救う
新規追加された項目は次回 23:00 cron で自動的に Source URL が入るが、既に Notion に登録済みの 27 件は遡及更新が必要。Master と Daily Log の両方を後から埋めるための独立モードを切り出した。
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 件残存 |
教訓
スキーマ自動追加は「起動時の 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__起点の相対解決の方が移植性が高い