【Obsidian x Claude Code③】TimeTreeの予定をObsidianに取り込む
【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 icalendartimetree-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()詰まった点↓
- DTENDがexclusive: 終日複数日イベント (例: 5/16 00:00〜5/17 00:00) は5/16の予定。素朴に
start_date <= target <= end_dateと書くと翌日まで余計に表示される - 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>&1daily-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への入力の最初に「今日の予定」ブロックが入る。
参考リンク:
- TimeTree-Exporter (GitHub)
- timetree-exporter (PyPI)
- Termination of the Connect App (TimeTree API) — TimeTree Newsroom
- RFC 5545 §3.6.1 Event Component (DTENDの非包含性)
- RFC 5545 §3.8.2.2 Date-Time End
- icalendar (PyPI, v7.1.0)
- collective/icalendar issue #610 — Make vCategory iterable
- security add-generic-password / find-generic-password (ss64)
- Claude Code CLI reference (
--print/--dangerously-skip-permissions)