20本の図を毎週自動更新|Skill チェーンと GitHub Actions Claude Code

ClaudeCode
GitHubActions
Skill
自動化
cron

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 は月次・四半期・年次で更新タイミングがバラバラなので、「全部を最新に保つ」には少なくとも週次の取り込みが必要です。

手動運用フローはざっとこんな手順でした。

  1. ターミナルを開き、/search-estat で更新対象 ID を再確認
  2. /fetch-estat-data <id> を 15 回繰り返す
  3. 取得した JSON を D3.js プロジェクトに配置
  4. npm run build:charts でチャート 15 種を再生成
  5. Remotion で SNS 用静止画を 5 種レンダリング
  6. npx wrangler r2 object put で R2 に push
  7. Cloudflare Cache を /purge-cdn でパージ
  8. 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-datastatsDataId のリスト生 JSON(tmp/raw/*.json5 〜 15 分(API レート次第)
2/transform-snapshots生 JSONCSV / SQLite シード1 〜 3 分
3/render-chartsCSV / SQLiteSVG / PNG / mp410 〜 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 文字列の読み方も整理しておきます。

位置意味例の値意味
100 分
2時(UTC)11UTC 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_KEYhttps://console.anthropic.com/Claude Code を headless で動かすために必須
ESTAT_APP_IDhttps://www.e-stat.go.jp/api/e-Stat API キー
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEYCloudflare Dashboard → R2 → Manage API TokensR2 への書き込み権限
CLOUDFLARE_API_TOKENCloudflare Dashboard → My Profile → API TokensCache 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 人で回せる時代」**の作り方をシリーズ全体で示します。最終回までお付き合いください。

関連ランキング・記事