Back to Blog
Claude APISupabaseGitHub Actions自動化エージェントNext.jsブログ運営

ブログ自動投稿エージェントを作った話:Claude API+Supabase+GitHub Actionsで実現した仕組み

このサイト(chiapuru.com)はNext.js+Markdownで動いている静的サイトだ。記事はリポジトリのposts/ディレクトリにMarkdownファイルを置くだけで公開できる仕組みになっている。

シンプルな構成なのは良いのだが、「記事を書く」という行為そのものがボトルネックになっていた。ネタはある。でも文章を書き始めるまでの腰が重い。

そこで**「テーマを登録しておけば、勝手に記事を書いてGitHubにプッシュしてくれるエージェント」**を作った。今回はその設計と実装を丸ごと公開する。


全体アーキテクチャ

[Supabase DB]
  ↓ テーマキューを取得
[GitHub Actions (Scheduled)]
  ↓ Pythonスクリプト起動
[Claude API]
  ↓ Markdown記事を生成
[GitHub API]
  ↓ posts/に直接コミット
[Vercel]
  → 自動デプロイ → 公開

登場人物は4つ。

役割 使用サービス
テーマ・スケジュール管理 Supabase (PostgreSQL)
記事生成 Claude API (claude-opus-4-5)
定期実行・自動コミット GitHub Actions
ホスティング Vercel

Supabaseのテーブル設計

まずテーマを溜めておくキューをSupabaseに作る。

create table blog_topics (
  id uuid default gen_random_uuid() primary key,
  title text not null,
  memo text,                        -- 書いてほしいポイントのメモ
  status text default 'pending',    -- pending / generating / done / error
  scheduled_at timestamptz,         -- この日付以降に生成する
  generated_at timestamptz,
  slug text,                        -- 生成後のファイル名
  created_at timestamptz default now()
);

statusカラムが重要で、pendingのものだけをエージェントが拾う。生成中はgeneratingに変えることで二重実行を防いでいる。

Supabaseダッシュボードからテーブルエディタで直接テーマを追加できるのが地味に便利で、「あ、これ書きたい」と思ったときにさっとスマホから登録できる。


記事生成スクリプト(Python)

.github/scripts/generate_post.pyに置いているスクリプトの全体像。

import os
import json
import re
from datetime import datetime, timezone
import anthropic
from supabase import create_client, Client

SUPABASE_URL = os.environ["SUPABASE_URL"]
SUPABASE_KEY = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]

supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)


def fetch_next_topic():
    """pendingかつscheduled_atが現在以前のトピックを1件取得"""
    now = datetime.now(timezone.utc).isoformat()
    result = (
        supabase.table("blog_topics")
        .select("*")
        .eq("status", "pending")
        .lte("scheduled_at", now)
        .order("scheduled_at")
        .limit(1)
        .execute()
    )
    if result.data:
        return result.data[0]
    return None


def mark_status(topic_id: str, status: str, slug: str = None):
    update = {"status": status}
    if status == "done":
        update["generated_at"] = datetime.now(timezone.utc).isoformat()
    if slug:
        update["slug"] = slug
    supabase.table("blog_topics").update(update).eq("id", topic_id).execute()


def build_prompt(topic: dict) -> str:
    memo_section = f"\n追加メモ: {topic['memo']}" if topic.get("memo") else ""
    return f"""あなたはchiapuru.comの記事ライターです。
- サイトコンセプト: 大学・省庁・医療業界での実務経験を持つ個人が発信する業務効率化・AI活用情報
- 読者: ITエンジニア、情シス担当者、大学職員、スプレッドシートを使う業務担当者
- トーン: 実践的・一人称・「自分が試した」感を大切に
- 必ずコード例またはステップバイステップの手順を含める
- 分量: 2000〜4000字
- フォーマット: Markdownで、frontmatterを含める

frontmatterの形式:
---
title: ""
date: "{datetime.now().strftime('%Y-%m-%d')}"
tags: []
excerpt: ""
---

以下のテーマで記事を書いてください。今日の日付は {datetime.now().strftime('%Y-%m-%d')} です。

テーマ: {topic['title']}{memo_section}"""


def generate_article(topic: dict) -> str:
    prompt = build_prompt(topic)
    message = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=8192,
        messages=[{"role": "user", "content": prompt}],
    )
    return message.content[0].text


def extract_slug(article: str, title: str) -> str:
    """frontmatterのtitleからslugを生成、失敗したらtitleから"""
    # frontmatterのtitleを抽出
    match = re.search(r'^title:\s*"(.+?)"', article, re.MULTILINE)
    if match:
        raw = match.group(1)
    else:
        raw = title

    # 日本語→ローマ字変換は省略し、英数字とハイフンのみ残す
    slug = re.sub(r"[^\w\-]", "-", raw.lower())
    slug = re.sub(r"-+", "-", slug).strip("-")
    return slug[:60]  # 長すぎる場合は切る


def write_to_github(slug: str, content: str):
    """GitHub APIでposts/ディレクトリにファイルを作成"""
    import base64
    import urllib.request

    token = os.environ["GITHUB_TOKEN"]
    repo = os.environ["GITHUB_REPOSITORY"]  # "owner/repo"形式
    date_prefix = datetime.now().strftime("%Y-%m-%d")
    filename = f"{date_prefix}-{slug}.md"
    path = f"posts/{filename}"
    url = f"https://api.github.com/repos/{repo}/contents/{path}"

    encoded = base64.b64encode(content.encode("utf-8")).decode("utf-8")
    payload = json.dumps({
        "message": f"feat: add auto-generated post {filename}",
        "content": encoded,
        "branch": "main",
    }).encode("utf-8")

    req = urllib.request.Request(
        url,
        data=payload,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "X-GitHub-Api-Version": "2022-11-28",
        },
        method="PUT",
    )
    with urllib.request.urlopen(req) as res:
        if res.status not in (200, 201):
            raise RuntimeError(f"GitHub API error: {res.status}")
    return filename


def main():
    topic = fetch_next_topic()
    if not topic:
        print("生成対象のトピックはありません。")
        return

    print(f"生成開始: {topic['title']}")
    mark_status(topic["id"], "generating")

    try:
        article = generate_article(topic)
        slug = extract_slug(article, topic["title"])
        filename = write_to_github(slug, article)
        mark_status(topic["id"], "done", slug=filename)
        print(f"完了: {filename}")
    except Exception as e:
        mark_status(topic["id"], "error")
        print(f"エラー: {e}")
        raise


if __name__ == "__main__":
    main()

ポイントをいくつか補足しておく。

二重実行防止mark_status(topic["id"], "generating")をAPIコール前に叩いている。GitHub Actionsのジョブが並列で走ってしまっても、最初のジョブがgeneratingに変えた後は他のジョブがそのレコードを拾わない。

GitHub APIへの直接コミットgit cloneせずにContents APIを使って直接ファイルを作成している。標準ライブラリのurllib.requestだけで完結するので依存を増やさずに済む。


GitHub Actionsワークフロー

.github/workflows/auto-post.yml

name: Auto Blog Post

on:
  schedule:
    # 毎朝8時(JST)に実行 = UTC 23:00
    - cron: "0 23 * * *"
  workflow_dispatch:  # 手動実行も可能にしておく

jobs:
  generate:
    runs-on: ubuntu-latest
    permissions:
      contents: write  # リポジトリへのコミットに必要

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          pip install anthropic supabase

      - name: Run generate script
        env:
          SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
        run: python .github/scripts/generate_post.py

GITHUB_TOKENはActionsが自動で用意してくれるので、シークレットへの登録は不要。Supabase関連とAnthropicのキーだけSettings → Secretsに追加すればOK。


実際に動かしてみての所感

良かった点

  • テーマを「溜めておく」だけでいい。アイデアが浮かんだ瞬間にSupabaseに放り込んでおけば、翌朝には記事になっている
  • memoフィールドに「〇〇の点を強調して」「コードはTypeScriptで」などの指示を書いておくと、かなり自分の意図に沿った記事が出てくる
  • 失敗してもステータスがerrorになるだけで、次回実行時には再試行される(errorのレコードは手動でpendingに戻す運用)

課題と今後

  • 日本語タイトルからのslug生成が雑。現状は英数字とハイフンだけ残す力技なので、ほとんどのケースでスラッグが-だらけになる。pykakasiでローマ字変換するか、frontmatterにslugフィールドを追加してClaudeに英語slugも出力させるかを検討中
  • 1日1記事ペースで回しているが、たまにScheduled triggerがスキップされるGitHub Actions特有の問題がある。重要なスケジュールはcron.jobなどの外部サービスで叩いた方が安定する
  • 画像はまだ手動。OGP画像の自動生成(DALL-E or Stable Diffusion)まで繋げるのが次のマイルストーン

まとめ

Claude API+Supabase+GitHub Actionsの組み合わせで、テーマ登録から記事公開まで完全自動化できた。インフラコストはほぼゼロ(SupabaseとActionsは無料枠内、Claude APIは1記事あたり数円程度)。

「書く」という行為を「テーマを登録する」という軽い行為に置き換えただけで、更新頻度が劇的に上がった。ブログを持っているけど更新が続かない、という人にはかなりおすすめのアプローチだ。

コード全体はGitHubリポジトリに置いてある(予定)。質問や改善提案はXまで。...

Share:
View all posts