doodle-on-web

自分で調べたことや、仕事の中で質問されたことなどをまとめています。

GitHub Actionsで「週1自動投稿」を実現する方法(はてなブログ対応)

スポンサーリンク

この記事でわかること

  • GitHub Actions の schedule トリガーで毎週自動投稿する方法
  • 投稿待ちキューを管理して順番に記事を公開するフロー
  • 実運用で詰まりやすいタイムゾーン・二重投稿・競合対策

この記事のゴール:articles/ に記事を置くだけで、毎週決まった時間にブログが自動更新される状態を作ること。


こんな人向け: - はてなブログを GitHub で管理している人 - 記事をストックして定期公開したい人 - 「push し忘れ」で更新が止まった経験がある人 - 記事は書けるけど「継続投稿」が苦手な人

前回の記事はこちら:Claude Codeでブログ自動化システムを構築した話


「push したときだけ投稿」では週1更新が続かない

前回の記事では articles/ へのファイル push をトリガーにはてなブログへ自動投稿する仕組みを紹介しました。

ただ実運用してみると、一つ問題がありました。

「週1で記事を書こうとしても、push を忘れると更新が止まる」

push のたびにブログが更新されるのは便利ですが、「定期的に公開」したいなら スケジュール実行 の方が向いています。

そこで次のステップとして、毎週月曜朝9時に articles/ の未投稿ファイルを自動でピックアップして公開する ワークフローを追加しました。

月曜朝に何もしなくても記事が公開されているのは、地味に気持ちがいいです。


全体の動き

記事を articles/ にストック(ファイル名順に投稿される)
        ↓
毎週月曜 9:00(JST)
        ↓
GitHub Actions が cron で起動
        ↓
articles/ の未投稿ファイルを 1 件取得
(posted/history.json に記録がないもの)
        ↓
はてなブログに投稿
        ↓
history.json に記録してコミット・push

記事を articles/ に置いておくだけで、あとは自動で週1公開される仕組みです。

この構成のポイントは 「記事を書くタイミング」と「公開タイミング」を完全に分離している 点です。まとめて書いてストックしておけば、その後は何もしなくても週1で更新が続きます。

ディレクトリ構成

blog-posts/
├── .github/workflows/
│   ├── post-on-push.yml      # push トリガー(前回記事)
│   └── weekly-post.yml       # ← 今回追加(週次スケジュール)
├── articles/
│   ├── 01_first-post.md      # 投稿済み
│   ├── 02_second-post.md     # ← 次回投稿対象(ファイル名順)
│   └── 03_third-post.md      # 再来週以降
├── posted/
│   └── history.json
└── post_to_hatena.py

※投稿順は ファイル名の辞書順(例:01_xxx.md, 02_xxx.md)になります。公開したい順番でファイル名を付けると管理しやすいです。


クイックスタート(10分で動かす)

最低限の設定は2ステップ、コードはそのままコピペで動きます。

  1. .github/workflows/weekly-post.yml を作成(Step 1 のコードを貼る)
  2. post_to_hatena.py--auto モードを追加(Step 2 のコードを追記)
  3. articles/ にテスト用 Markdown を1ファイル置く
  4. Actions タブ →「Run workflow」で即時テスト実行

詳細は以降の Step で説明します。


Step 1: schedule トリガーの設定

.github/workflows/weekly-post.yml を作成します。

name: Weekly Auto Post

on:
  schedule:
    - cron: '0 0 * * 1'   # UTC 0:00 月曜 = JST 9:00 月曜
  workflow_dispatch:        # 手動実行ボタンも残す

# 手動実行と cron が同時に走っても二重投稿しない
concurrency:
  group: weekly-post
  cancel-in-progress: false

jobs:
  post:
    runs-on: ubuntu-latest
    permissions:
      contents: write       # history.json のコミットに必要

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - run: pip install requests python-frontmatter

      - name: 未投稿ファイルを 1 件投稿
        env:
          HATENA_ID: ${{ secrets.HATENA_ID }}
          HATENA_API_KEY: ${{ secrets.HATENA_API_KEY }}
          HATENA_BLOG_DOMAIN: ${{ secrets.HATENA_BLOG_DOMAIN }}
        run: python post_to_hatena.py --auto

      - name: history.json をコミット
        run: |
          git config user.name  "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add posted/history.json
          git diff --cached --quiet || {
            git pull --rebase
            git commit -m "chore: auto post $(date +'%Y-%m-%d')"
            git push
          }

cron の書き方メモ

書き方 意味
0 0 * * 1 毎週月曜 UTC 0:00(JST 9:00)
0 1 * * 1,4 月・木 UTC 1:00(JST 10:00)
0 22 * * 0 毎週日曜 UTC 22:00(JST 翌7:00)

GitHub Actions の cron は UTC 基準 です。JST に変換して設定を間違えないよう注意してください。変換は crontab.guru が便利です。

注意: GitHub Actions の schedule は混雑時に数分〜数十分遅延することがあります。「9:00ぴったりに公開」が必要な場合は外部スケジューラ(GitHub Apps や AWS EventBridge など)の利用を検討してください。


Step 2: --auto モードをスクリプトに追加

既存の post_to_hatena.py--auto フラグを追加します。articles/ の中から未投稿の Markdown ファイルを1件見つけて投稿するだけです。

なぜ history.json で管理するのか? GitHub Actions はジョブが終わるたびに環境が破棄される「ステートレス」な設計です。「どこまで投稿したか」という状態をジョブ間で引き継ぐには、Git リポジトリにファイルとして保存するのが最もシンプルな方法です。

import argparse, json, glob, sys
from pathlib import Path

HISTORY_FILE = Path("posted/history.json")

def load_history():
    if not HISTORY_FILE.exists():
        return {"posted": []}
    try:
        return json.loads(HISTORY_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        # JSON が壊れていた場合は空履歴として復旧
        return {"posted": []}

def find_next_unposted():
    history = load_history()
    posted_files = {e["file"] for e in history["posted"]}
    candidates = sorted(glob.glob("articles/*.md"))   # ファイル名辞書順
    for f in candidates:
        if Path(f).name not in posted_files:
            return f
    return None   # 投稿待ちなし

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("file", nargs="?")
    parser.add_argument("--update", action="store_true")
    parser.add_argument("--auto",   action="store_true")
    args = parser.parse_args()

    if args.auto:
        target = find_next_unposted()
        if target is None:
            print("投稿待ちの記事がありません。スキップします。")
            # exit(0) で正常終了 → ワークフローが失敗(赤)にならない
            sys.exit(0)
        print(f"自動投稿: {target}")
        post_file(target)          # 既存の投稿処理を呼ぶ
    elif args.file:
        post_file(args.file, update=args.update)

sys.exit(0) で正常終了することで、投稿待ちがない週でもワークフローが失敗(赤)にならないのがポイントです。

応用: Slack の Incoming Webhook と組み合わせると「今週は投稿なし」をチームに通知することもできます。


Step 3: 手動トリガーで即時テスト

workflow_dispatch を追加しておくと、GitHub の Actions タブ →「Run workflow」ボタンで即時実行できます。

cron は最初の発火まで待てないので、追加直後はこのボタンで動作確認するのがおすすめです。


詰まりやすいポイントと対策

問題1: history.json のコミット・push が失敗する

permissions: contents: write が抜けていると push でエラーになります。また、git diff --cached --quiet || { ... } のパターンで「変更がない場合はコミットしない」ようにしないと、空コミットエラーが出ることがあります。

問題2: 手動実行と cron が同時に走って二重投稿される

concurrency ブロックを設定することで、同名グループのジョブは同時に1つしか走りません。cancel-in-progress: false にすることで実行中のジョブをキャンセルせず、後から来たジョブを待機させます。

問題3: cron が動かない(スケジュールがスキップされる)

GitHub のドキュメントには「リポジトリのアクティビティがない状態が続くと cron ジョブが無効化される場合がある」と記載があります。月1回程度 workflow_dispatch で手動実行しておくと無効化されにくいです。

問題4: タイムゾーンのズレ

UTC と JST は 9 時間差。「月曜朝に投稿したい」のに UTC をそのまま書くと日曜夜に実行されてしまいます。

よくある失敗:投稿順が意図通りにならない

ファイル名を my-article.md, another-post.md のように適当につけると、辞書順で am より前になり、意図しない順番で公開されます。01_, 02_ のようなプレフィックスで順番を明示するのがおすすめです。


運用イメージ

週次ワークフローを導入してからの流れはこうなりました。

  1. 月〜金のすき間時間: Claude Code に記事を生成させ、articles/ にレビュー済みの MD を蓄積
  2. 毎週月曜 9:00: GitHub Actions が自動でピックアップして公開
  3. 公開後: history.json が更新され、次回は次のファイルが対象になる

月曜朝に何もしなくても記事が公開されているのは、地味に気持ちがいいです。 ストックが増えれば増えるほど「しばらく放置しても更新が続く」状態になります。

この仕組みを入れると、「記事を書く」ことだけに集中できるようになります。公開作業やタイミングを気にする必要がなくなるので、ブログ自動化・定期更新の心理的ハードルがかなり下がります。


応用:もう一段階自動化する

今回の構成を土台にすると、さらに拡張できます。

  • 複数記事を一度に投稿: find_next_unposted() をリスト返しにして for ループで回す
  • 予約投稿(日時指定): frontmatter に publish_at: 2026-04-07 を持たせ、当日以降のファイルだけを対象にする
  • 投稿通知: Slack Webhook で「今週の記事を投稿しました」を自動送信する

Zapier などの外部ツールを使わず、GitHub Actions だけで完結するのがこの構成のメリットです。この仕組みは GitHub Pages や Notion API など、「ファイル変更をトリガーに外部サービスへ投稿する」構成全般に応用できます。


まとめ

GitHub Actions の schedule トリガーを使えば、「push し忘れ」を気にせず定期公開できます。要点は4つです。

  1. cron は UTC 基準 — JST に換算して設定する(9時間差に注意)
  2. concurrency で二重投稿を防ぐ — 手動実行と cron の同時起動を制御
  3. --auto モードで未投稿ファイルを自動選択 — ファイル名順でキューを消化
  4. 投稿なしのときは exit(0) で正常終了 — ワークフローが失敗(赤)にならない

前回の push トリガーと組み合わせれば「書いたらすぐ公開」「ためておいて毎週公開」の両方を使い分けられます。ぜひ自分のペースに合ったスケジュールで試してみてください。


次回は「Claude API とはてなブログを連携させてみた」を解説予定です。