Part 3 〜 17 にかけて、47 都道府県データを D3.js で可視化する 15 種のチャートを作ってきました。棒グラフ、ヒートマップ、コロプレス、散布図、Bar Chart Race、レーダー、箱ひげ、積み上げ面、Small Multiples、バブル、スロープ ── どれもそれなりに気合いの入った作例です。
ただ、ここで一つ正直に告白しておきます。全部を毎週手で更新するのは、地獄です。
/fetch-estat-data を 15 回叩き、整形スクリプトを 15 本走らせ、Remotion でレンダリングし、R2 に push して、Cloudflare のキャッシュをパージする。1 サイクル 90 分。週末の半日が消えます。やる気が落ちて 2 週飛ばすと、サイトのデータが「最新」を名乗れなくなる。
本記事(Part 19)では、この一連の作業を Claude Code の Skill チェーンと GitHub Actions cron で完全自動化 する手順を書きます。毎週日曜 JST 20:00 に勝手にデータが更新され、失敗したら Issue が立ち、必要なら部分実行で復旧できる ── そういうパイプラインです。
筆者は元県庁職員で、現在は stats47.jp を 1 人で運営しています。サイトには 530 を超えるランキングがあり、その大半は本記事のパターンで自動更新されています。Claude Code 未経験のソフトウェアエンジニア向けに、再利用可能な「Skill × Actions」パイプラインの組み方を共有します。
毎週 20 本のチャートを手で更新する地獄
まずは「なぜ自動化するのか」を整理しておきます。
stats47.jp で公開しているチャートは 2026 年 6 月時点で 20 種弱。/ranking/ 配下と /themes/ ダッシュボードに散らばっており、それぞれが e-Stat から取得した最新値で描画されます。e-Stat は月次・四半期・年次で更新タイミングがバラバラなので、「全部を最新に保つ」には少なくとも週次の取り込みが必要です。
手動運用フローはざっとこんな手順でした。
- ターミナルを開き、
/search-estatで更新対象 ID を再確認 /fetch-estat-data <id>を 15 回繰り返す- 取得した JSON を D3.js プロジェクトに配置
npm run build:chartsでチャート 15 種を再生成- Remotion で SNS 用静止画を 5 種レンダリング
npx wrangler r2 object putで R2 に push- Cloudflare Cache を
/purge-cdnでパージ - Slack に「更新完了」をペースト
所要時間 60 〜 90 分。途中で API がレート制限に当たると 30 分追加。「やればやるほど嫌になる作業」の典型です。
しかも、手動運用には別の隠れたコストがあります。
| 隠れたコスト | 具体例 |
|---|---|
| 抜け漏れ | 15 種のうち 1 つだけ更新を忘れて、サイトに混在期がある統計が出る |
| 揺れ | 取得日時がバラバラだと「同じ年度の値」の前提が崩れる |
| 引き継ぎ不能 | 手順が頭の中にしかなく、他の人や未来の自分が再現できない |
| やる気消費 | 「今週はサボろう」が積み重なってデータが半年古くなる |
このすべてを Claude Code の Skill 機能と GitHub Actions cron で潰します。
アーキテクチャ全体図|4 段パイプライン
最終形を先に出します。データの流れは fetch → transform → render → push の 4 段で、各段が独立した Skill になっています。
文章で書くとこうです。
[ GitHub Actions cron (毎週日曜 JST 20:00) ]
│
▼
[ Claude Code headless mode ]
│
▼
[ /weekly-refresh (上位スキル) ]
│
├──▶ /fetch-estat-data (15 種類の statsDataId を順次取得)
│
├──▶ /transform-snapshots (JSON → CSV / SQLite シード形式に整形)
│
├──▶ /render-charts (D3.js で SVG/PNG 生成、Remotion で静止画)
│
└──▶ /push-r2 (R2 にスナップショット push + Cloudflare purge)
│
▼
[ 失敗時: gh issue create で起票 ]
[ 成功時: 完了レポートを docs/ に append ]
各段が独立した Skill なので、「render だけ再実行」「push だけ再実行」が可能 です。これがパイプラインの設計上のキモになります。モノリシックな 1 本シェルスクリプトにすると、途中失敗時に最初からやり直しになって罠を踏みます。
4 段それぞれの責務はこんな分担です。
| 段 | Skill 名 | 入力 | 出力 | 所要時間目安 |
|---|---|---|---|---|
| 1 | /fetch-estat-data | statsDataId のリスト | 生 JSON(tmp/raw/*.json) | 5 〜 15 分(API レート次第) |
| 2 | /transform-snapshots | 生 JSON | CSV / SQLite シード | 1 〜 3 分 |
| 3 | /render-charts | CSV / SQLite | SVG / PNG / mp4 | 10 〜 30 分(Remotion 込み) |
| 4 | /push-r2 | 出力ファイル群 | R2 オブジェクト + キャッシュパージ | 2 〜 5 分 |
合計 20 〜 50 分。GitHub Actions の無料枠(月 2,000 分)で楽に収まります。
Step 1: 各段を Skill 化する
Claude Code の Skill は、.claude/skills/<name>/SKILL.md という Markdown ファイル 1 枚で定義します。詳細は Part 2 で書いたので、ここでは「パイプライン構成要素として Skill を書くときの注意点」だけ抑えます。
単一責務にする
1 つの Skill に「データ取得 + 整形 + push」を全部詰め込むと、再実行性が壊れます。Skill 1 つ = 1 段 = 1 つの責務を徹底します。
mkdir -p .claude/skills/fetch-estat-data
mkdir -p .claude/skills/transform-snapshots
mkdir -p .claude/skills/render-charts
mkdir -p .claude/skills/push-r2
mkdir -p .claude/skills/weekly-refresh # ← 上位スキル
frontmatter の description は呼び出し動機を明示
---
name: transform-snapshots
description: tmp/raw/*.json を読み込み、CSV と SQLite シード形式に変換する。fetch-estat-data の後段で使う。
---
description が曖昧だと、Claude が「いま transform を呼ぶべきか」の判断に迷います。「fetch-estat-data の後段で使う」のように 前後関係まで書いておく と、上位スキルから自動的に呼び出されやすくなります。
出力契約をはっきりさせる
各 Skill は次段が読める形式で必ず出力します。例えば /fetch-estat-data の SKILL.md にはこう書いておきます。
## 出力契約
- 出力先: `tmp/raw/<statsDataId>-<YYYY-MM-DD>.json`
- 1 ファイル 1 statsDataId
- 取得失敗した場合は `tmp/raw/_errors.log` に追記し、終了コード 0 で次段に渡す
「失敗してもパイプラインを止めない」「次段が拾えるエラーログを残す」という設計にしておくと、/transform-snapshots 側で「これは存在しないからスキップ」と判断できます。全体停止より部分降格の方が、週次バッチでは実用的です。
Step 2: Skill チェーンを 1 つの上位 Skill から呼ぶ
4 つの Skill を順次呼ぶだけの上位 Skill /weekly-refresh を作ります。これがあると「Actions からは weekly-refresh だけ叩けばよい」状態になり、ワークフローが極端にシンプルになります。
---
name: weekly-refresh
description: 毎週日曜に走らせる総合バッチ。fetch → transform → render → push を順次実行し、失敗時は Issue を起票する。
---
# /weekly-refresh
## 目的
stats47.jp の全チャート(15 種)を最新の e-Stat データで再生成し、R2 に push して Cloudflare のキャッシュをパージする。
## 前提
- 環境変数: `ESTAT_APP_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `CLOUDFLARE_API_TOKEN`
- 対象 statsDataId のリスト: `.claude/state/weekly-refresh-targets.json`
## 手順
1. `.claude/state/weekly-refresh-targets.json` から statsDataId を全件読み込む
2. 各 ID について `/fetch-estat-data <id>` を順次呼び出す(並列度 3、API レート対策)
3. 取得完了後、`/transform-snapshots` を呼び出して CSV/SQLite を生成
4. `/render-charts` を呼び出してチャート 15 種をレンダリング
5. `/push-r2` を呼び出して R2 push + キャッシュパージ
6. 各段の出力サマリを `docs/03_週次運用/メトリクス/YYYY-Www.md` に append
7. いずれかの段で失敗(exit code != 0 or _errors.log に記載あり)した場合は次節「失敗時処理」に従う
## 失敗時処理
- `gh issue create` で起票
- title: `[weekly-refresh] YYYY-MM-DD 失敗: <段名>`
- body: 失敗した段、エラーログ末尾 20 行、再実行コマンド例
- label: `auto-generated,weekly-refresh-failure`
- 残りの段はスキップする(前段の出力が信頼できない以上、強行しても意味がない)
## 出力契約
- 標準出力: 各段の所要時間と件数を JSON で 1 行ずつ出す
- ファイル: `docs/03_週次運用/メトリクス/YYYY-Www.md` に Markdown 表を append
上位 Skill から下位 Skill を呼ぶときは、Claude Code に「順次呼び出してね」と書いておけば素直に従ってくれます。中で Bash ツールを使ってもよいし、各下位 Skill の手順を呼び出す形でもよいです。
実行イメージはこう。
# ローカル動作確認
claude --skill weekly-refresh
これだけで 4 段が走ります。手動で 90 分かかっていたものが、Skill 化した瞬間に「コマンド 1 行」になります。
Step 3: GitHub Actions の YAML|毎週日曜 JST 20:00
ここから自動化の本丸です。GitHub Actions の cron で /weekly-refresh を叩きます。.github/workflows/weekly-refresh.yml を以下の内容で作成します。
name: Weekly Refresh
on:
schedule:
# 毎週日曜 JST 20:00 = UTC 11:00
- cron: "0 11 * * 0"
workflow_dispatch:
inputs:
stage:
description: "再実行する段(fetch / transform / render / push / all)"
required: false
default: "all"
type: choice
options: ["all", "fetch", "transform", "render", "push"]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Run /weekly-refresh
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ESTAT_APP_ID: ${{ secrets.ESTAT_APP_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
STAGE: ${{ github.event.inputs.stage || 'all' }}
run: |
claude --skill weekly-refresh --headless \
--prompt "stage=$STAGE で実行してください"
- name: Commit metrics
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/03_週次運用/メトリクス/
git diff --staged --quiet || \
git commit -m "chore(metrics): weekly-refresh $(date -u +%Y-%m-%d)"
git push
ポイントは 3 つです。
| ポイント | 解説 |
|---|---|
cron: "0 11 * * 0" | UTC 11:00 = JST 20:00 日曜。Actions の cron は必ず UTC なのでタイムゾーン注意 |
workflow_dispatch.inputs.stage | 手動再実行時に「render だけ」のような部分実行ができる |
permissions.issues: write | 失敗時 Issue 起票に必要。デフォルト read だけだと gh issue create が 403 で死ぬ |
claude --skill weekly-refresh --headless の --headless フラグで、対話なしで Skill を実行します。CI 環境向けの実行モードです。
cron 文字列の読み方も整理しておきます。
| 位置 | 意味 | 例の値 | 意味 |
|---|---|---|---|
| 1 | 分 | 0 | 0 分 |
| 2 | 時(UTC) | 11 | UTC 11 時 = JST 20 時 |
| 3 | 日 | * | 毎日 |
| 4 | 月 | * | 毎月 |
| 5 | 曜日 | 0 | 日曜(0 と 7 はどちらも日曜) |
「毎週月曜 朝 6 時 JST」にしたければ "0 21 * * 0"(UTC 日曜 21:00 = JST 月曜 06:00)になります。地味に間違えやすいので、cron 文字列のコメントに JST 換算 を必ず書くクセを付けておきます。
Step 4: Secrets 管理|appId と R2_API_TOKEN
GitHub Actions に渡す秘密情報は GitHub Secrets で管理します。リポジトリの Settings → Secrets and variables → Actions から登録します。
最低限必要なのは以下の 4 つです。
| Secret 名 | 取得元 | 用途 |
|---|---|---|
ANTHROPIC_API_KEY | https://console.anthropic.com/ | Claude Code を headless で動かすために必須 |
ESTAT_APP_ID | https://www.e-stat.go.jp/api/ | e-Stat API キー |
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY | Cloudflare Dashboard → R2 → Manage API Tokens | R2 への書き込み権限 |
CLOUDFLARE_API_TOKEN | Cloudflare Dashboard → My Profile → API Tokens | Cache Purge と Pages デプロイに必要 |
CLOUDFLARE_API_TOKEN は権限を最小化するのがおすすめです。stats47 では「D1 Edit + R2 Storage Edit + Pages Edit + Account Settings Read」を 1 つのトークンに集約しています。
# gh CLI で Secrets を一括登録する例
gh secret set ANTHROPIC_API_KEY --body "sk-ant-xxx"
gh secret set ESTAT_APP_ID --body "xxxx"
gh secret set R2_ACCESS_KEY_ID --body "xxxx"
gh secret set R2_SECRET_ACCESS_KEY --body "xxxx"
gh secret set CLOUDFLARE_API_TOKEN --body "xxxx"
ローカルの .env ファイルと同じ値を GitHub Secrets に入れるだけです。ただし以下は 絶対にやらない こと。
.envをコミットする(gitignore 必須)- ログに
echo $ANTHROPIC_API_KEYを残す(Actions のログは公開リポジトリだと誰でも見られる) - PR の diff に Secret を貼って動作確認する
ローテーション周期は最低でも年 1 回にしましょう。ANTHROPIC_API_KEY は支払いに直結するので、漏洩すると一夜で月額数万円分使い込まれるリスクがあります。
Step 5: 失敗時通知|Issue 起票で運用に乗せる
Actions cron は「成功したか失敗したか」が見えにくいです。デフォルトだと「失敗したら Email が来る」程度で、忙しいと気付かずに放置されます。これを防ぐため、失敗時に GitHub Issue を起票する仕組みを入れます。
/weekly-refresh Skill の中で gh コマンドを叩く形でも実装できますが、Actions の if: failure() で書いておく方が確実です。
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
const today = new Date().toISOString().slice(0, 10);
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[weekly-refresh] ${today} 失敗`,
body: [
`## 失敗概要`,
``,
`- Run URL: ${runUrl}`,
`- 実行日時 (UTC): ${new Date().toISOString()}`,
`- 入力 stage: ${{ github.event.inputs.stage || 'all' }}`,
``,
`## 再実行コマンド`,
``,
"```bash",
"gh workflow run weekly-refresh.yml -f stage=all",
"```",
``,
`失敗した段だけ再実行する場合は \`stage=fetch\` などを指定。`,
].join("\n"),
labels: ["auto-generated", "weekly-refresh-failure"],
});
これで失敗のたびに Issue が立ち、GitHub のホーム画面に通知が来ます。Slack 連携が欲しい場合は、Issue 起票をトリガーに別の Workflow で Slack に飛ばす二段構成にすると保守しやすいです(直接 Slack に飛ばす構成は webhook URL の管理が増えてダルい)。
stats47 では、auto-generated ラベルが付いた Issue を 24 時間以内に対応するルールにしています。3 日以上溜まったら手動運用に戻すか、対象 statsDataId を pause する判断をします。**「失敗を可視化して、放置を許さない」**のがパイプライン保守の肝です。
Step 6: 部分実行|特定スキルだけ再実行する流れ
パイプラインを長く運用していると、「render だけ失敗した」「fetch は成功したけど push がタイムアウト」という事故が必ず起きます。このとき毎回 fetch から全部やり直すと、e-Stat の API レートを無駄に消費しますし、所要時間も膨らみます。
そこで workflow_dispatch.inputs.stage を使って 段単位で再実行できるようにしておきます。
# render だけ再実行
gh workflow run weekly-refresh.yml -f stage=render
# push だけ再実行
gh workflow run weekly-refresh.yml -f stage=push
/weekly-refresh Skill 側では、受け取った stage パラメータに応じて呼び出す下位 Skill を切り替えます。
## stage パラメータの処理
- `stage=all`(デフォルト): fetch → transform → render → push を順次実行
- `stage=fetch`: fetch だけ実行。出力は `tmp/raw/` に残す
- `stage=transform`: transform だけ実行。前回の `tmp/raw/` を入力にする
- `stage=render`: render だけ実行。前回の CSV/SQLite を入力にする
- `stage=push`: push だけ実行。前回の出力ファイルを入力にする
これで「render が失敗した → コード直して push せず render だけ再実行 → 結果確認 → 問題なければ push だけ追加実行」という流れがスムーズに回ります。
特に大事なのは 中間成果物を tmp/ に保持する こと。tmp/raw/, tmp/transformed/, tmp/rendered/ のように段ごとにディレクトリを切り、Actions の Artifacts として upload しておくと、再実行時に download して使えます。
- name: Upload intermediate artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: weekly-refresh-${{ github.run_id }}
path: |
tmp/raw/
tmp/transformed/
tmp/rendered/
retention-days: 14
if: always() で失敗時も成果物を残しておくのがコツです。あとから「何が起きていたのか」を再現できるかどうかが、運用継続性を大きく変えます。
つまずきポイント|タイムゾーン、API レート、依存ファイル
最後に、筆者が踏んだ地雷を 3 つだけ共有しておきます。
1. タイムゾーン: GitHub Actions の cron は UTC
これは何回踏んでも忘れます。cron: "0 20 * * 0" と書くと「JST 20 時」のつもりで「UTC 20 時 = JST 翌日 05 時」になります。
対策は単純で、cron 行の上にコメントで JST 換算を必ず書く。
on:
schedule:
# JST 日曜 20:00 = UTC 日曜 11:00
- cron: "0 11 * * 0"
もう一つの落とし穴は、Actions runner の date コマンドは UTC で返ってきます。docs/ に追記する日付やファイル名に使う場合は、TZ=Asia/Tokyo date +%Y-%m-%d で明示しましょう。
2. e-Stat API レート: 並列度は 3 が安全圏
/fetch-estat-data を 15 種類分まとめて Promise.all で叩くと、e-Stat 側で 429(Too Many Requests)を返してきます。公式に明示されたレート制限は緩いのですが、実測では 同時 5 接続を超えると不安定になります。
並列度 3 で p-limit を使うのが安全です。
import pLimit from "p-limit";
const limit = pLimit(3);
const ids = await loadTargetIds();
const results = await Promise.all(
ids.map((id) =>
limit(async () => {
const json = await fetchStatsData(id);
await fs.writeFile(`tmp/raw/${id}-${today}.json`, JSON.stringify(json));
return { id, status: "ok" };
}),
),
);
15 件を並列度 3 で回しても 5 〜 10 分で終わります。「並列度を上げて速くしよう」の誘惑には負けないこと。
3. 依存ファイルの commit: state ディレクトリの扱い
「対象 statsDataId のリスト」「成功ログ」「Skill が読み書きする state」をどこに置くかは毎回悩むポイントです。stats47 では下記の規約に統一しました。
| ファイル種別 | 場所 | commit する? |
|---|---|---|
| 設定(statsDataId リスト、閾値) | .claude/config/ | する |
| state(最終実行時刻、累積カウンタ) | .claude/state/ | する(履歴を残す) |
| 中間生成物(tmp/raw/, tmp/rendered/) | tmp/ | しない(gitignore) |
| 人間向け週次レポート | docs/03_週次運用/メトリクス/YYYY-Www.md | する |
tmp/ を間違えて commit すると node_modules 級の肥大化が起きるので、.gitignore に必ず tmp/ を入れておきます。逆に .claude/state/ の commit を忘れると、Actions が「前回の続き」を判断できなくなります。コミット粒度は「人間が読み返す週次レポート + state JSON」を 1 コミットにまとめるのがおすすめです。
動作確認|初回実行はローカルで
いきなり cron に乗せると、失敗時のデバッグが GitHub の Web UI 越しになって辛いです。初回はローカルで act または claude --skill weekly-refresh --headless を叩いて、4 段が通ることを確認してから cron を有効化します。
# ローカルで Skill 単体実行
claude --skill fetch-estat-data --headless \
--prompt "statsDataId=0003448237 で実行"
# 上位 Skill 実行
claude --skill weekly-refresh --headless \
--prompt "stage=all で実行"
# act で Actions ワークフローをローカル実行
act schedule -W .github/workflows/weekly-refresh.yml
act は GitHub Actions を Docker 上でローカル実行するツールです。Secrets は .secrets ファイルに別途記述する必要がありますが、CI 上で初めて失敗するパターンを 1 つ前に潰せます。
ローカル確認 → workflow_dispatch で手動実行 → cron 有効化、の 3 段階で慣らしていくと事故が減ります。
次回予告|Cloudflare Pages にデプロイする
ここまでで、データの自動更新パイプラインは完成です。最終回となる Part 20 では、生成されたチャートを Cloudflare Pages にデプロイし、世界中に配信する流れを書きます。
next buildの Output 設定(Static Export vs SSR)- Cloudflare Pages の wrangler.toml と環境変数
- カスタムドメインと CDN キャッシュ戦略
- Lighthouse スコアを 95+ で維持するチェックリスト
- 全 20 本のシリーズ振り返り(自動化前後で運用時間がどう変わったか)
Skill チェーン × Actions × Pages の三点セットで、**「個人が 47 都道府県統計サイトを 1 人で回せる時代」**の作り方をシリーズ全体で示します。最終回までお付き合いください。
関連ランキング・記事
- スキル化する /search-estat|統計表 ID を AI に探させる — 本シリーズ Part 2。Skill の基本的な書き方
- Claude Code で人口データを棒グラフ化|D3.js × Skill の合わせ技 — 本シリーズ Part 3。チャート生成パイプラインの起点
- Claude Code で47都道府県分析を自動化|公務員のための AI × 統計 7 ステップ — シリーズ全体の俯瞰記事
- 完成したチャートを Next.js + Cloudflare Pages で公開|連載シリーズ最終回 — 本シリーズ Part 20。最終デプロイ編
- 都道府県別 ICT 関連統計ランキング一覧 — 本記事カテゴリの統計ランキング集