すくらっぷ あんど びるどー(したい)

AI・Obsidian・個人開発の試行錯誤ログ。 作業メモを、あとで使える知識に変えるための記録。

Obsidianメモ索引自動生成スクリプト詳細版。コメント付き全文と設計意図

はじめに

この記事は、Obsidianのメモ索引自動生成について、実装だけを詳しく残す詳細版である。

目的、完成イメージ、前提環境、Before / After、実行方法の概要は要約版にまとめた。この記事ではそれらを繰り返さず、次の内容だけを扱う。

syurainu.com

作ったスクリプトの考え方

スクリプトでは、まず対象フォルダを固定した。

対象:
00_人間用/メモ/

出力:
00_人間用/メモ/索引/

Vault全体を検索対象にしない。
今回の目的は「人間用メモを探しやすくすること」なので、対象を広げすぎないようにした。
また、自動生成ファイルをさらに索引対象に入れると、索引が自分自身を拾ってしまう。
そのため、次のものは除外した。

00_人間用/メモ/索引/
メモ直下にある自動生成用README.md
メモ索引.md
最近更新.md
フォルダ別.md
五十音グループの索引ページ

タイトルは、本文内の最初の # 見出し を優先する。

見出しがなければ、ファイル名をタイトルとして使う。

1. Markdown本文を読む
2. 最初の # 見出し を探す
3. 見つかればそれをタイトルにする
4. なければファイル名を使う

これにより、ファイル名が雑でも、本文の先頭見出しがあれば比較的読みやすい索引になる。

実際に使ったスクリプト

実際に使った build_memo_index.py は次である。MemoEntry には dataclasses を使い、索引生成に必要な値をまとめて扱う1

このスクリプトは、実PCのフルパスを直接書かず、スクリプト自身の場所からVaultルートを求めている2。そのため、同じように 90_System/scripts/ 配下に置けば、Vault内の相対構成で動かせる。

掲載コード内では、Markdownのコードフェンス文字列もPythonの文字列として扱っている。そのため、ブログ本文では外側のコードブロックを4連バッククォートにしている。

他の人がこのまま使う場合は、次の配置にする。

<VAULT_PATH>/
├─ 00_人間用/
│  └─ メモ/
│     └─ ここに索引化したいMarkdownを置く
└─ 90_System/
   └─ scripts/
      └─ build_memo_index.py

直接実行する場合は、Vaultルートで次を実行する。

cd <VAULT_PATH>
py -3 -X utf8 90_System\scripts\build_memo_index.py

00_人間用/メモ/ 以外を対象にしたい場合は、スクリプト先頭の MEMO_ROOTINDEX_ROOT を自分のVault構成に合わせて変更する。

また、索引に出したくないフォルダ名は EXCLUDED_DIR_NAMES に追加する。索引には本文全文は出さないが、ファイル名、見出し、フォルダ名は出るため、秘密情報を含むメモは対象フォルダに入れない。

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from pathlib import Path


# このスクリプトを 90_System/scripts/ に置く前提で、Vaultルートを逆算する。
VAULT_ROOT = Path(__file__).resolve().parents[2]
MEMO_ROOT = VAULT_ROOT / "00_人間用" / "メモ"
INDEX_ROOT = MEMO_ROOT / "索引"

# タイトル抽出だけに使うため、巨大なメモを全文読みしない。
MAX_READ_CHARS = 20000

# ここに入れたフォルダ名は、索引へファイル名や見出しを出さない。
EXCLUDED_DIR_NAMES = {
    "AI立入禁止",
    "private",
    "secret",
    ".git",
    ".obsidian",
    "__pycache__",
}

# 索引の出力ファイルを、次回の索引対象に含めないための一覧。
GENERATED_FILES = {
    "README.md",
    "メモ索引.md",
    "最近更新.md",
    "フォルダ別.md",
    "英数記号.md",
    "あ行.md",
    "か行.md",
    "さ行.md",
    "た行.md",
    "な行.md",
    "は行.md",
    "ま行.md",
    "や行.md",
    "ら行.md",
    "わ行.md",
    "その他.md",
}

GROUP_ORDER = [
    "英数記号",
    "あ行",
    "か行",
    "さ行",
    "た行",
    "な行",
    "は行",
    "ま行",
    "や行",
    "ら行",
    "わ行",
    "その他",
]


@dataclass(frozen=True)
class MemoEntry:
    path: Path
    title: str
    group: str
    folder: str
    updated: datetime


def read_text(path: Path, max_chars: int = MAX_READ_CHARS) -> str:
    # 文字コードの揺れや壊れたファイルで、索引生成全体を止めない。
    try:
        text = path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        text = path.read_text(encoding="utf-8-sig", errors="ignore")
    except OSError:
        return ""
    return text[:max_chars]


def extract_title(path: Path) -> str:
    # 本文の最初のH1を優先し、なければファイル名を表示名にする。
    text = read_text(path)
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("# "):
            return stripped[2:].strip() or path.stem
    return path.stem


def classify_group(title: str) -> str:
    # 読み推定までは行わず、タイトルの先頭文字だけで軽く分ける。
    if not title:
        return "その他"
    ch = title[0]
    if ch.isascii() and ch.isalnum():
        return "英数記号"
    if ch.isascii() or ch in "._-!!??##@[]【】「」『』()()・…=**":
        return "英数記号"
    if ch in "あいうえおぁぃぅぇぉアイウエオァィゥェォ":
        return "あ行"
    if ch in "かきくけこがぎぐげごカキクケコガギグゲゴ":
        return "か行"
    if ch in "さしすせそざじずぜぞサシスセソザジズゼゾ":
        return "さ行"
    if ch in "たちつてとだぢづでどタチツテトダヂヅデド":
        return "た行"
    if ch in "なにぬねのナニヌネノ":
        return "な行"
    if ch in "はひふへほばびぶべぼぱぴぷぺぽハヒフヘホバビブベボパピプペポ":
        return "は行"
    if ch in "まみむめもマミムメモ":
        return "ま行"
    if ch in "やゆよゃゅょヤユヨャュョ":
        return "や行"
    if ch in "らりるれろラリルレロ":
        return "ら行"
    if ch in "わをんワヲン":
        return "わ行"
    return "その他"


def make_link(path: Path, title: str | None = None, table_cell: bool = False) -> str:
    # Vault内の相対パスから、Obsidianで開けるWikiリンクを作る。
    # Markdown表の中では、Wikiリンク別名の | を \| にしないと列が崩れる。
    rel = path.relative_to(VAULT_ROOT).as_posix()
    if rel.endswith(".md"):
        rel = rel[:-3]
    label = title or path.stem
    separator = r"\|" if table_cell else "|"
    return f"[[{rel}{separator}{label}]]"


def should_skip_path(path: Path) -> bool:
    # 自動生成物と秘密系フォルダを索引対象から外す。
    if INDEX_ROOT in path.parents:
        return True
    try:
        rel_parts = path.relative_to(MEMO_ROOT).parts
    except ValueError:
        return True
    if any(part in EXCLUDED_DIR_NAMES for part in rel_parts):
        return True
    if path.name in GENERATED_FILES and path.parent == MEMO_ROOT:
        return True
    return False


def iter_memos() -> list[MemoEntry]:
    # メモ本文は変更せず、索引に必要な軽量メタデータだけ集める。
    entries: list[MemoEntry] = []
    if not MEMO_ROOT.exists():
        return entries
    for path in MEMO_ROOT.rglob("*.md"):
        if should_skip_path(path):
            continue
        try:
            updated = datetime.fromtimestamp(path.stat().st_mtime)
        except OSError:
            continue
        title = extract_title(path)
        group = classify_group(title)
        folder = path.parent.relative_to(MEMO_ROOT).as_posix()
        if folder == ".":
            folder = "メモ直下"
        entries.append(
            MemoEntry(
                path=path,
                title=title,
                group=group,
                folder=folder,
                updated=updated,
            )
        )
    return sorted(entries, key=lambda e: (e.group, e.title.lower(), e.path.as_posix().lower()))


def page_header(title: str) -> list[str]:
    # 生成ページには共通のfrontmatterと注意書きを入れる。
    today = datetime.now().strftime("%Y-%m-%d")
    return [
        "---",
        "type: memo_index",
        "status: active",
        f"updated: {today}",
        "---",
        "",
        f"# {title}",
        "",
        "このページは自動生成です。",
        "メモ本文は移動・編集しません。",
        "",
    ]


def write_text(path: Path, lines: list[str]) -> None:
    # 出力先フォルダがなければ作り、UTF-8でMarkdownを書き出す。
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")


def write_hub(entries: list[MemoEntry]) -> None:
    # 全体の入口になるページ。各索引ページへのリンクと件数だけを置く。
    counts = {group: 0 for group in GROUP_ORDER}
    for entry in entries:
        counts[entry.group] = counts.get(entry.group, 0) + 1

    lines = page_header("メモ索引")
    lines.extend(
        [
            f"総メモ数: {len(entries)}",
            "",
            "## 入口",
            "",
            "| 見たいもの | 開くページ | 件数 |",
            "| --- | --- | ---: |",
            f"| 最近更新 | {make_link(INDEX_ROOT / '最近更新.md', '最近更新', table_cell=True)} | 50 |",
            f"| フォルダ別 | {make_link(INDEX_ROOT / 'フォルダ別.md', 'フォルダ別', table_cell=True)} | {len(entries)} |",
        ]
    )
    for group in GROUP_ORDER:
        lines.append(f"| {group} | {make_link(INDEX_ROOT / f'{group}.md', group, table_cell=True)} | {counts.get(group, 0)} |")
    lines.extend(
        [
            "",
            "## 運用",
            "",
            "```text",
            "更新コマンド:",
            "90_System\\scripts\\run_ai_memory.cmd memo_index",
            "```",
            "",
            "タグやプロパティではなく、ファイル名・見出しをもとにリンク一覧を作ります。",
        ]
    )
    write_text(INDEX_ROOT / "メモ索引.md", lines)


def write_recent(entries: list[MemoEntry]) -> None:
    # 更新日順の入口。最近触ったメモを探すために50件までに絞る。
    recent = sorted(entries, key=lambda e: e.updated, reverse=True)[:50]
    lines = page_header("最近更新")
    lines.extend(["最近更新されたメモを50件まで表示します。", ""])
    for entry in recent:
        lines.append(f"- {entry.updated.strftime('%Y-%m-%d')} {make_link(entry.path, entry.title)}")
    write_text(INDEX_ROOT / "最近更新.md", lines)


def write_folder_index(entries: list[MemoEntry]) -> None:
    # フォルダ構成を頼りに探したい場合の索引。
    lines = page_header("フォルダ別")
    folders: dict[str, list[MemoEntry]] = {}
    for entry in entries:
        folders.setdefault(entry.folder, []).append(entry)
    for folder in sorted(folders):
        folder_entries = sorted(folders[folder], key=lambda e: e.title.lower())
        lines.extend([f"## {folder}", ""])
        for entry in folder_entries:
            lines.append(f"- {make_link(entry.path, entry.title)}")
        lines.append("")
    write_text(INDEX_ROOT / "フォルダ別.md", lines)


def write_group_pages(entries: list[MemoEntry]) -> None:
    # タイトル先頭文字ごとの索引。厳密な五十音順ではなく軽い入口として使う。
    by_group: dict[str, list[MemoEntry]] = {group: [] for group in GROUP_ORDER}
    for entry in entries:
        by_group.setdefault(entry.group, []).append(entry)
    for group in GROUP_ORDER:
        lines = page_header(group)
        group_entries = sorted(by_group.get(group, []), key=lambda e: e.title.lower())
        if not group_entries:
            lines.append("該当メモはありません。")
        for entry in group_entries:
            rel_folder = entry.folder
            lines.append(f"- {make_link(entry.path, entry.title)}")
            lines.append(f"  - 場所: `{rel_folder}`")
        write_text(INDEX_ROOT / f"{group}.md", lines)


def write_readme(entries: list[MemoEntry]) -> None:
    # メモフォルダ直下にも、人間が最初に開く入口を置く。
    lines = page_header("メモ")
    lines.extend(
        [
            "このフォルダは、人間が自由に書いたメモを置く場所です。",
            "",
            "## 探す",
            "",
            "| 見たいもの | 開く場所 |",
            "| --- | --- |",
            f"| メモ全体の索引 | {make_link(INDEX_ROOT / 'メモ索引.md', 'メモ索引', table_cell=True)} |",
            f"| 最近更新 | {make_link(INDEX_ROOT / '最近更新.md', '最近更新', table_cell=True)} |",
            f"| フォルダ別 | {make_link(INDEX_ROOT / 'フォルダ別.md', 'フォルダ別', table_cell=True)} |",
            "",
            "## 更新",
            "",
            "```text",
            "90_System\\scripts\\run_ai_memory.cmd memo_index",
            "```",
            "",
            f"現在の索引対象メモ数: {len(entries)}",
        ]
    )
    write_text(MEMO_ROOT / "README.md", lines)


def main() -> int:
    # 収集から書き出しまでを一括実行する。
    entries = iter_memos()
    write_hub(entries)
    write_recent(entries)
    write_folder_index(entries)
    write_group_pages(entries)
    write_readme(entries)
    print(f"Memo entries: {len(entries)}")
    print(f"Output: {INDEX_ROOT.relative_to(VAULT_ROOT).as_posix()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

分類はあえて雑にした

索引は、厳密な分類表ではない。

今回は次のグループに分けた。

英数記号
あ行
か行
さ行
た行
な行
は行
ま行
や行
ら行
わ行
その他

この分類は、タイトルの先頭文字だけで決めている。
たとえば、タイトルが英数字で始まれば 英数記号、あ行で始まれば あ行 に入る。
漢字で始まるタイトルや判定しにくいものは、無理に読みを推測せず その他 に入れる。 日本語の読みを正確に推定しようとすると、形態素解析や辞書が必要になり、仕組みが重くなる。 今回必要だったのは「完璧な図書館分類」ではなく、「500件超のメモをまず人間が見渡せる入口」である。

実行結果

実行後、メモ索引の入口には次のような結果が出た。 公開前には、一時ディレクトリ上に小さなテスト用Vaultを作り、実スクリプトの主要関数を単体テストした。

PASS 7 tests:
extract_title
classify_group
make_link
should_skip_path
iter_memos
write_hub_table_links
write_readme_table_links
総メモ数: 534

最近更新: 50
フォルダ別: 534
英数記号: 255
あ行: 21
か行: 19
さ行: 11
た行: 15
な行: 2
は行: 15
ま行: 13
や行: 2
ら行: 3
わ行: 7
その他: 171

00_人間用/メモ/README.md にも、メモ索引へのリンクを置いた。

00_人間用/メモ/
├─ README.md
└─ 索引/
   └─ メモ索引.md

さらに、Vault全体の「探す入口」である 00_人間用/00_探す.md からも、メモ索引へ進めるようにした。 これで、Obsidianを開いてからの流れは次になる。

00_人間用/00_探す.md
↓
00_人間用/メモ/索引/メモ索引.md
↓
最近更新 / フォルダ別 / 五十音グループ
↓
目的のメモ

詰まったところ

今回のポイントは、技術的な難しさよりも、設計の割り切りだった。

詰まったこと 原因 対処
チャット欄だけでは候補が安定しない AIが検索そのものより相談に寄ることがある 先に人間が見られる索引を作った
タグやプロパティを主役にすると重くなる 入力漏れ、表記ゆれ、過去メモへの付け直しが発生する ファイル名と見出しからリンク一覧を作った
五十音分類を正確にやろうとすると複雑になる 漢字の読み推定が必要になる 先頭文字だけで分類し、迷うものはその他に入れた
索引が自分自身を拾う可能性がある 生成ファイルもMarkdownであるため 索引/ 配下と生成ファイルを除外した
メモ本文を整理したくなる 索引作成と分類整理を同時にやると壊れやすい 本文は移動・編集せず、索引だけ作った

最初から完璧な分類を目指すと、たぶん続かない。

まずは壊さずに見えるようにする。それだけでも、かなり探しやすくなる。

セキュリティ上の注意

今回の索引は、人間用メモへのリンク一覧である。

そのため、何でも索引に出せるわけではない。

このスクリプトが索引に出す情報は、主に次である。

ファイルの相対パス
本文先頭の # 見出し、またはファイル名
所属フォルダ
更新日

本文全文は索引に書き出さない。

一方で、ファイル名や見出しに秘密情報を書いている場合は、それ自体が索引に出る。たとえば、ファイル名にサービス名、個人名、契約名、秘密のプロジェクト名を入れている場合は注意が必要である。

スクリプト側では、次のような処理はしていない。

ネットワーク通信
外部API呼び出し
任意コマンド実行
ファイル削除
ファイル移動
Vault全体の読み取り

読み取り対象は MEMO_ROOT で指定したメモフォルダ配下に限定している。さらに、EXCLUDED_DIR_NAMES に入れたフォルダ名は索引対象から外す3

少なくとも、次のような場所は索引対象から外す。

AI立入禁止フォルダ
.obsidian/
Archive
認証情報やトークンを含む設定ファイル
個人情報や契約情報を含むメモ

索引は便利だが、便利なものほど「何を出さないか」を先に決めておく必要がある。

まとめ

この詳細版で残した実装上の要点は、次の通りである。

  • 対象フォルダを MEMO_ROOT に限定する
  • 生成先の INDEX_ROOT を再索引しない
  • タイトルは最初の # 見出し を優先する
  • Wikiリンクを表に入れるときだけ \| にする4
  • 秘密系フォルダは EXCLUDED_DIR_NAMES で除外する
  • 一時ディレクトリ上のテスト用Vaultで主要関数を確認する

要約版を読めば全体像が分かり、この詳細版を読めば実装を追える、という役割分担にした。

参考文献


  1. dataclasses は、型注釈付きのクラスから初期化メソッドなどを生成するPython標準ライブラリである。このスクリプトでは、pathtitlegroupfolderupdated を1つのレコードとして扱うために使っている。
  2. Path(__file__).resolve().parents[2] は、スクリプトを 90_System/scripts/ に置く構成に依存する。配置を変える場合は、親ディレクトリを辿る数も変更する。
  3. このスクリプトは本文全文を索引へ出さない。ただし、ファイル名、見出し、フォルダ名、更新日は出力される。秘密情報をそれらに含めると、索引側にも現れる。
  4. Markdown表のセル内に | を入れる場合はエスケープが必要である。ObsidianのWikilink別名記法 [[path|label]] も、表の中では同じ制約を受ける。