このサイト(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まで。...