Claude CodeでObservable Frameworkのデータローダーを検証する — 出力をhookで固める
Observable Frameworkのデータローダーを書かせるときに、標準出力とキャッシュの仕様を踏まえ、保存のたびにhookでローダーを実行して失敗を返す設定をまとめます。
Observable Frameworkのダッシュボードは、データローダーが出す静的なスナップショットの上に載ります。ローダーを書く作業はClaude Codeに任せやすい一方、失敗の仕方に癖があります。標準出力がそのまま成果物になり、成功した出力はキャッシュされ、ビルドは更新時刻を見ません。この記事では、その仕様を先に押さえたうえで、ローダーを保存するたびにhookで実行し、壊れた出力をClaudeに返す構成を組みます。
データローダーが何を出力に固めるか
データローダーは、ビルド時にデータの静的なスナップショットを作るプログラムです。データベースに問い合わせてCSVを出す、チャートを描画してPNGを出す、といった用途が公式の例に挙がっています。ローダーはJavaScript、TypeScript、Python、Rのほか、シェルスクリプトや任意の実行ファイルでも書けます。
関連づけはファイル名で決まります。ページからFileAttachment("quakes.json")で参照したファイルが存在しないとき、Frameworkはquakes.json.shやquakes.json.jsのような二重拡張子のファイルを探します。二つ目の拡張子で言語が決まり、.jsならnode、.pyならpython3、.shならshで実行されます。最初の拡張子(.jsonや.csv)は出力の形を表すだけで、中身の整形はローダー自身の責任です。
つまり、拡張子が.csvでも、ローダーがJSONを吐けばそのまま.csvとして配られます。Claudeに書かせるときも、出力形式の保証は書いた側が持つことになります。
標準出力とキャッシュの仕様が失敗の入口になる
ローダーまわりの事故は、次の3点でほぼ説明がつきます。
ローダーが静かに壊れる3つの仕様
標準出力がそのまま成果物
ローダーの出力は標準出力に書きます。ログを標準出力に混ぜると、そのログも出力ファイルに入り、クライアントへ送られます。ログは標準エラーに書く決まりです。
終了コードだけが成否を決める
失敗するローダーは0以外で終了する必要があります。0で終わると、Frameworkは成功とみなし、出力をキャッシュして配ります。空の出力は単独ではエラーにならず、警告が出るだけです。
ビルドは更新時刻を見ない
プレビューでは、ローダーのソースがキャッシュより新しければ再実行されます。ビルドでは更新時刻を無視し、出力がキャッシュにないときだけローダーを走らせます。
成功した出力は、ソースルート(通常はsrc)の.observablehq/cacheに保存されます。ここに古い出力が残っていると、ローダーを直してもビルドは古い結果を使い続けます。強制的に再実行させるには、次のようにキャッシュを消します。
# 全ローダーを次のビルドで再実行する
rm -rf src/.observablehq/cache
# quakes.csv のローダーだけ再実行する
rm -f src/.observablehq/cache/quakes.csvどれか1つのローダーが失敗すると、ビルド全体が失敗します。ダッシュボードの一部だけ空になる、という壊れ方はしません。この点は、失敗を早く見つけたい側にとっては好都合です。
Claudeに書かせたローダーが落ちる典型パターン
仕様を踏まえると、Claudeが書いたローダーの不具合は、次の型に分けられます。
| 症状 | 原因 | 検出方法 |
|---|---|---|
| JSONとして読めない | 原因console.logのデバッグ出力が標準出力に混ざった | 検出方法出力をJSONとして解釈する |
| 空のCSVが配られる | 原因取得に失敗しても終了コードが0 | 検出方法出力が空でないことを確かめる |
| 修正後も数字が変わらない | 原因キャッシュに旧出力が残っている | 検出方法キャッシュを消してから再実行する |
| ビルドが落ちる | 原因外部APIの失敗で非0終了 | 検出方法ローダーを単体で実行する |
curlでデータを取るローダーなら、-f(--fail)を付けておくと、ダウンロード失敗時にローダーが非0で終わります。公式もこのフラグを勧めています。Claudeにシェルのローダーを書かせるなら、この一点をCLAUDE.mdに入れておく価値があります。
CLAUDE.mdにローダーの規約を書く
hookで機械的に検証する前に、書き方の規約を渡しておくと、手戻りが減ります。次のような断片を想定します。
## データローダーの規約(src/ 配下の *.json.* と *.csv.*)
- 出力は標準出力だけ。ログとデバッグ出力は標準エラーに書く
- 取得に失敗したら必ず非0で終了する。curl は -f を付ける
- 空の出力で終了しない
- ローダーを直したら、対応するキャッシュを削除してから再実行する
(src/.observablehq/cache/ 配下の同名ファイル)
- 検証はローダーを単体で実行して行う。npm run build は最後に1回規約はあくまで依頼の前提です。守られたかどうかは、次の節のhookで毎回確かめます。
保存のたびにローダーを実行するhook
ここからが本題です。PostToolUseは、ツールの実行が成功した直後に動くイベントです。入力のJSONにはtool_inputが入っていて、EditやWriteなら対象のfile_pathが取れます。このhookが終了コード2で終わると、標準エラーの内容がClaudeに渡されます。ツールはすでに実行済みなので、止めることはできませんが、直すべき理由は伝わります。
設定は次のとおりです。.claude/settings.jsonに置きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-loader.sh",
"timeout": 90
}
]
}
]
}
}timeoutは秒単位です。コマンドフックの既定は600秒ですが、ローダーが外部APIを叩く場合に備え、短めに切っておきます。
スクリプトは、編集されたファイルがローダーかどうかを判定し、該当するときだけ実行します。
#!/bin/bash
# .claude/hooks/check-loader.sh
f=$(jq -r '.tool_input.file_path // empty')
# src 直下の *.json.{sh,js,py} と *.csv.{sh,js,py} だけを対象にする
case "$f" in
*/src/*.json.sh|*/src/*.json.js|*/src/*.json.py) kind=json ;;
*/src/*.csv.sh|*/src/*.csv.js|*/src/*.csv.py) kind=csv ;;
*) exit 0 ;;
esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
case "$f" in
*.sh) run="sh" ;;
*.js) run="node" ;;
*.py) run="python3" ;;
esac
out=$(mktemp); err=$(mktemp)
# 標準出力と標準エラーを分けて受ける
$run "$f" >"$out" 2>"$err"
code=$?
if [ $code -ne 0 ]; then
echo "ローダーが終了コード $code で失敗しました: $f" >&2
head -n 20 "$err" >&2
exit 2
fi
if [ ! -s "$out" ]; then
echo "ローダーの出力が空です。取得失敗を握りつぶしていないか確認してください: $f" >&2
exit 2
fi
if [ "$kind" = "json" ] && ! jq empty "$out" 2>/dev/null; then
echo "出力がJSONとして読めません。標準出力にログが混ざっていないか確認してください: $f" >&2
head -c 300 "$out" >&2
exit 2
fi
if [ "$kind" = "csv" ] && [ "$(wc -l <"$out")" -lt 2 ]; then
echo "CSVにヘッダー行しかありません。データ行が0件です: $f" >&2
exit 2
fi
exit 0ポイントは、標準出力と標準エラーを分けて受けていることです。Frameworkが標準出力をそのまま成果物にする以上、検証も標準出力だけを対象にしないと、本番のビルドと違う結果になります。ログの混入は、JSONの構文チェックで機械的に拾えます。
実行権限を付けるのを忘れないでください。
chmod +x .claude/hooks/check-loader.shこのhookで拾えないもの
hookの検証は、ローダーが単体で動き、形の正しい出力を出すところまでです。次の項目は別の手段で確かめます。
- ページ側の
FileAttachmentが正しいファイル名を指しているか。ローダーはページから参照されたときだけビルドで実行されるため、参照が抜けるとローダーは一度も走りません - キャッシュに古い出力が残っていないか。hookはキャッシュを経由せずローダーを直接動かします。キャッシュを消す操作は、CLAUDE.mdの規約でClaudeに任せるか、自分で
rmします - 数字の中身が正しいか。件数や合計値の妥当性は、ローダーの出力形式ではなく業務側の期待値で判断します
ローダーがネットワークやデータベースに接続する場合、保存のたびに本番のデータソースへ問い合わせが飛びます。頻繁に編集させる作業では、開発用のデータソースを指すよう環境変数を分けるか、hookの対象を絞っておくほうが安全です。
プレビュー中とビルドで挙動が違う点
ローダーの検証で混乱しやすいのは、プレビューとビルドでキャッシュの扱いが違うことです。
プレビューサーバーは、ローダーの出力が初めて必要になったときにローダーを実行し、結果をキャッシュします。ローダーのソースを編集すると、更新時刻がキャッシュより新しくなるので、自動で再実行されます。ブラウザを再読み込みしなくても、新しいデータが画面に反映されます。Claudeがローダーを書き換えたあとにプレビューで確かめる限り、古いキャッシュには当たりません。
ビルドは逆で、更新時刻を無視します。出力がキャッシュにあれば、ローダーのソースが新しくても実行しません。「プレビューでは直ったのにビルドの成果物は古い」という食い違いは、この差から生まれます。CIでキャッシュを復元している場合は特に、更新時刻が保たれないため、どのローダーが走るかはキャッシュの中身だけで決まります。
同じローダーへの要求が同時に来た場合は、ローダーは1回しか実行されず、各要求に同じ結果が返ります。複数のページが同じファイルを参照していても、重いクエリが二重に走る心配はありません。
CIのキャッシュでデータを更新し続けるとき
デプロイを自動化するなら、重いローダーの結果をCI側でキャッシュする設定が公式のガイドにあります。GitHub Actionsでは、src/.observablehq/cacheをactions/cacheで保存し、ビルドの前に復元します。公式の例では、キャッシュのキーにsrc/dataのファイルのハッシュ(hashFiles('src/data/*'))と日付の両方を含め、タイムゾーンをAmerica/Los_Angelesにして、1日1回だけローダーが再実行される形です。ローダーを書き換えればハッシュが変わり、キャッシュは自動的に切り替わります。
この例でキャッシュされるのは、src/dataに置いたローダーの出力だけです。ソースルートをsrc以外にした場合や、ローダーを別の場所に置いた場合は、パスを書き換える必要があります。キーの日付形式をdate +'%Y-%U'にすれば週に1回、date +'%Y-%m-%dT%H'にすれば1時間ごとになります。
Claudeにローダーを直させたあと、CIに反映されないなら、まずキャッシュキーを疑います。ローダーをsrc/dataの外に置いている、またはハッシュの対象に含めていないと、キーが同じ日のままで、直したローダーの出力はキャッシュに負けます。その場合は、ローダーの置き場所に合わせてhashFilesのパスを広げてください。
ログの混入を避けたPythonのローダー例
標準出力と標準エラーの分離は、言語ごとに書き方が違います。Pythonのローダーなら、次のように書きます。公式の例にあるJavaScriptのconsole.warnと同じ役割を、print(..., file=sys.stderr)が担います。
import csv
import sys
import urllib.request
url = "https://example.com/api/sales.csv"
print(f"fetching {url}", file=sys.stderr) # ログは標準エラーへ
with urllib.request.urlopen(url, timeout=30) as res:
if res.status != 200:
print(f"unexpected status: {res.status}", file=sys.stderr)
sys.exit(1) # 失敗は非0で終了する
body = res.read().decode("utf-8")
rows = list(csv.reader(body.splitlines()))
if len(rows) < 2:
print("no data rows", file=sys.stderr)
sys.exit(1)
sys.stdout.write(body) # 成果物だけを標準出力へこのファイルをsrc/sales.csv.pyとして置くと、FileAttachment("sales.csv")の参照でこのローダーが使われます。URLは例示用のダミーです。hook側では、先ほどのスクリプトが.pyをpython3で実行し、終了コードと行数を見ます。
最後はビルドで通しで確かめる
ローダー単体の検証が済んだら、ビルドを1回回します。npm run build(observable buildと同じ)はdistディレクトリを作ります。出力ルートの_fileディレクトリには、ローダーの出力がハッシュ付きのファイル名で並びます。
npm run build
ls dist/_filedist/_fileにローダーの出力が並んでいれば、そのローダーはページから参照され、ビルド時に実行されています。並んでいないなら、FileAttachmentの参照が抜けています。どれか1つでもローダーが失敗すればビルド全体が止まるので、終了コードの扱いをhook側と揃えておくと、手元とCIで結果が食い違いません。
同じ考え方は、ほかのビルド時検証にも使えます。dbtの失敗行を読んで直す流れはClaude Codeでdbtのデータテストを足すに、ネイティブビルドの失敗をhookで返す構成はClaude CodeでGodotのGDExtensionを作るにまとめています。キャッシュが古いデータを返し続ける問題の切り分けは、Next.jsのキャッシュを見直すと構造が似ています。
まとめ
Observable Frameworkのローダーは、標準出力がそのまま成果物になり、終了コード0なら成功とみなされ、ビルドはキャッシュの更新時刻を見ません。この3つが、Claudeの書いたローダーを静かに壊します。保存のたびにローダーを単体で実行し、終了コード・空出力・形式を調べるhookを置けば、壊れた出力がビルドやデプロイまで届く前にClaudeへ返せます。キャッシュの削除だけは、hookに持たせず、規約か手作業に残すのが分かりやすい分担です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。