Claude Media
Claude CodeでObservable Frameworkのデータローダーを検証する — 出力をhookで固める

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/_file

dist/_fileにローダーの出力が並んでいれば、そのローダーはページから参照され、ビルド時に実行されています。並んでいないなら、FileAttachmentの参照が抜けています。どれか1つでもローダーが失敗すればビルド全体が止まるので、終了コードの扱いをhook側と揃えておくと、手元とCIで結果が食い違いません。

同じ考え方は、ほかのビルド時検証にも使えます。dbtの失敗行を読んで直す流れはClaude Codeでdbtのデータテストを足すに、ネイティブビルドの失敗をhookで返す構成はClaude CodeでGodotのGDExtensionを作るにまとめています。キャッシュが古いデータを返し続ける問題の切り分けは、Next.jsのキャッシュを見直すと構造が似ています。

まとめ

Observable Frameworkのローダーは、標準出力がそのまま成果物になり、終了コード0なら成功とみなされ、ビルドはキャッシュの更新時刻を見ません。この3つが、Claudeの書いたローダーを静かに壊します。保存のたびにローダーを単体で実行し、終了コード・空出力・形式を調べるhookを置けば、壊れた出力がビルドやデプロイまで届く前にClaudeへ返せます。キャッシュの削除だけは、hookに持たせず、規約か手作業に残すのが分かりやすい分担です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. github.com/observablehq/framework
この記事を共有:XはてブLinkedIn