stock.dev
【Obsidian x Claude Code③】TimeTreeの予定をObsidianに取り込む

【Obsidian x Claude Code③】TimeTreeの予定をObsidianに取り込む

·7 min read

【Obsidian x Claude Code①】溜めた記事を1枚のDaily BriefにするのBriefにTimeTreeアプリから予定を取り込む機能を追加した。PythonのコードはClaude Codeに出力してもらいつつ、わからない部分は質問して理解するように心がけた。


TimeTree公式APIは事実上停止している

TimeTreeに公式の連携APIがなかった。TimeTreeの公開API機能は2023年12月22日をもって終了してしまっていた。。Web版/アプリ本体にiCal形式の購読URLを発行する機能もない。

TimeTree-Exporter (eoleedi/TimeTree-Exporter) を使用してみることにした。

これはTimeTreeのWeb版を逆エンジニアリングしてiCal形式に変換するMITライセンスのPythonツール。2026年5月8日にv0.7.2がリリースされている。

公式が仕様変更すれば壊れるけど、現状ではこれが唯一動く手段。今回は、プライベートのBriefなので採用することにした。


Python venvにインストール

venvを切ってtimetree-exporterとicalendarを入れる。

python3 -m venv ~/.claude/scripts/venv
~/.claude/scripts/venv/bin/pip install timetree-exporter icalendar

timetree-exporterは-e EMAIL-c CALENDAR_CODEを引数で受け、パスワードはTIMETREE_PASSWORD環境変数経由で渡せる。

TIMETREE_EMAIL="..." TIMETREE_PASSWORD="..." \
  ~/.claude/scripts/venv/bin/timetree-exporter \
  -c MfIg4uallkK1 -o /tmp/timetree.ics

カレンダーコードはTimeTree Web版 (https://timetreeapp.com/calendars/<code>) のURLに含まれる10〜20文字の英数字。


認証情報はmacOS Keychainに置く

TIMETREE_PASSWORDをmacOS標準のKeychainに保存して、securityコマンドで読み出す方式にした。1passwordを使っていればそちらの方がいいかも。。

保存 (初回1回だけ、対話)

security add-generic-password -U -a "$USER" -s "timetree-daily-brief" -w

-wを値なしで渡すとパスワードの入力プロンプトが出る (画面エコーなし)。-Uは同じkey/serviceの既存項目を更新する。シェル履歴にパスワードが残らない。

読み出し (スクリプトから)

PASSWORD=$(security find-generic-password -s "timetree-daily-brief" -w 2>/dev/null)

-wで値だけ返す。標準出力に出るので変数に代入できる。


今日の予定だけ抽出するPython parser

timetree.icsは全イベントを含むので、今日の分だけ抜き出す必要がある。icalendarパッケージでパースする。

# ~/.claude/scripts/parse-today.py
import sys
from datetime import date, datetime, time
from icalendar import Calendar
from zoneinfo import ZoneInfo
 
JST = ZoneInfo("Asia/Tokyo")
 
 
def _as_date(value):
    if isinstance(value, datetime):
        if value.tzinfo is None:
            value = value.replace(tzinfo=JST)
        return value.astimezone(JST).date(), value.astimezone(JST).time()
    return value, None
 
 
def _occurs_today(start_date, end_date, target):
    if end_date is None:
        return start_date == target
    return start_date <= target < end_date  # DTEND は exclusive
 
 
def main():
    ics_path = sys.argv[1]
    target = date.fromisoformat(sys.argv[2]) if len(sys.argv) >= 3 \
        else datetime.now(JST).date()
 
    with open(ics_path, "rb") as f:
        cal = Calendar.from_ical(f.read())
 
    events = []
    for ev in cal.walk("VEVENT"):
        dtstart = ev.get("DTSTART").dt
        dtend = ev.get("DTEND").dt if ev.get("DTEND") else None
        start_date, start_time = _as_date(dtstart)
        end_date, _ = _as_date(dtend) if dtend else (None, None)
 
        if not _occurs_today(start_date, end_date, target):
            continue
 
        title = str(ev.get("SUMMARY", "(無題)")).strip()
        cats = ev.get("CATEGORIES")
        category_str = ""
        if cats is not None:
            try:
                names = [str(c) for c in cats.cats]
            except AttributeError:
                names = [str(cats)]
            names = [n for n in names if n]
            if names:
                category_str = f" [{','.join(names)}]"
 
        if start_time is None:
            events.append((time(0, 0), f"- 終日: {title}{category_str}"))
        else:
            label = start_time.strftime("%H:%M")
            events.append((start_time, f"- {label} {title}{category_str}"))
 
    events.sort(key=lambda x: x[0])
    print("\n".join(line for _, line in events) or "(今日の予定なし)")
 
 
if __name__ == "__main__":
    main()

詰まった点↓

  1. DTENDがexclusive: 終日複数日イベント (例: 5/16 00:00〜5/17 00:00) は5/16の予定。素朴にstart_date <= target <= end_dateと書くと翌日まで余計に表示される
  2. CATEGORIESのbytes展開: str(ev.get("CATEGORIES"))だとvCategory([vText(b'\xe3\x81\xbf...')])のような内部表現が文字列化される。icalendarのバージョンによって.cats属性が露出する場合とイテレート可能なlist風に振る舞う場合があるため、try/except AttributeErrorで両対応している

fetch-timetree.sh

securityでパスワードを取得 → timetree-exporter → parse-today.pyの流れをラップする。失敗してもexit 0で返すのが大事とのこと (Brief本体の生成を止めないため)。

#!/bin/bash
set -u
 
EMAIL="..."
CALENDAR_CODE="MfIg4uallkK1"
ICS_FILE="$HOME/.claude/scripts/timetree.ics"
TODAY_FILE="$HOME/.claude/scripts/timetree-today.txt"
VENV="$HOME/.claude/scripts/venv"
LOG_DIR="$HOME/.claude/logs"
 
export PATH="$HOME/.volta/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
export HOME="/Users/takedaharuna"
 
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/fetch-timetree-$(date +%Y-%m-%d).log"
 
{
  echo "=== TimeTree fetch started at $(date) ==="
 
  PASSWORD="$(security find-generic-password -s "timetree-daily-brief" -w 2>/dev/null || true)"
  if [ -z "$PASSWORD" ]; then
    echo "ERROR: Keychainからパスワード取れない"
    echo "(今日の予定取得失敗 — Keychainアクセス不可)" > "$TODAY_FILE"
    exit 0  # Briefは続行させる
  fi
 
  if ! TIMETREE_EMAIL="$EMAIL" TIMETREE_PASSWORD="$PASSWORD" \
       "$VENV/bin/timetree-exporter" -c "$CALENDAR_CODE" -o "$ICS_FILE"; then
    echo "(今日の予定取得失敗 — TimeTreeアクセス不可)" > "$TODAY_FILE"
    exit 0
  fi
 
  if ! "$VENV/bin/python" "$HOME/.claude/scripts/parse-today.py" "$ICS_FILE" > "$TODAY_FILE"; then
    echo "(今日の予定取得失敗 — パース失敗)" > "$TODAY_FILE"
    exit 0
  fi
 
  cat "$TODAY_FILE"
  echo "=== TimeTree fetch finished at $(date) ==="
} >> "$LOG_FILE" 2>&1

daily-brief.shでプロンプトに前置する

daily-brief.shの冒頭でfetch-timetree.shを呼び、結果をtimetree-today.txtから読んでプロンプトの先頭に差し込む。

# ~/.claude/scripts/daily-brief.sh の抜粋
bash "$HOME/.claude/scripts/fetch-timetree.sh" || true
 
if [ -f "$TIMETREE_TODAY_FILE" ]; then
  TIMETREE_TODAY="$(cat "$TIMETREE_TODAY_FILE")"
else
  TIMETREE_TODAY="(今日の予定取得失敗 — ファイルなし)"
fi
 
PROMPT="今日の予定 (TimeTreeより取得、$TODAY):
$TIMETREE_TODAY
 
---
 
$(cat "$PROMPT_FILE")"
 
"$CLAUDE_BIN" \
  --print \
  --dangerously-skip-permissions \
  "$PROMPT"

これでclaude --printへの入力の最初に「今日の予定」ブロックが入る。


参考リンク: