🔁

Zenn 記事を投稿したら X に自動通知する仕組みを作る【GitHub Actions編】

に公開

はじめに

本記事では、GitHub Actions を使用して、Zenn のブログ記事を更新したことを、X (Twitter) で自動告知する構成の構築方法についてご紹介いたします。

注意点

GitHub Actions と X API の Free プランでは、それぞれ制限事項があります。
本構成は Free プランにて実現可能かと思いますが、制限事項は変更される可能性があるため、以下の Web ページより最新情報をご確認ください。

https://docs.github.com/ja/billing/managing-billing-for-your-products/about-billing-for-github-actions
https://docs.x.com/x-api/getting-started/about-x-api

必要な準備

まず大前提として、Zenn と GitHub を連携します。
連携手順は、以下の記事をご確認ください。

https://zenn.dev/irongeneral21/articles/zenn-github-setup

以下のページから、X の Developer Platform に登録して、アプリを作成する。
https://developer.x.com/en

作成したアプリから、以下のキーを取得して、GitHub Secrets に登録する。

API Key # 環境変数 TWITTER_API_KEY として登録
API Secret Key # 環境変数 TWITTER_API_SECRET として登録
Access Token # 環境変数 TWITTER_ACCESS_TOKEN として登録
Access Token Secret # 環境変数 TWITTER_ACCESS_SECRET として登録

GitHub Secrets としてキーを登録する方法は、以下のページから確認できます。
https://docs.github.com/ja/actions/how-tos/security-for-github-actions/security-guides/using-secrets-in-github-actions

ディレクトリ構造の整理

以下のようなディレクトリ構造となるように、フォルダやファイルを作成します。

Zenn
└── articles # Zenn の記事を保存している場所
└── books
└── images
└── .github
   └── scripts
      └── tweet.py # X にポストする Python スクリプト
   └── workflows
      └── tweet.yaml # GitHub Actions のワークフロー YAML
└── pending.json # 予約投稿待ち記録
└── tweeted.json # ポスト済み記録

自動投稿用のスクリプト作成

自動投稿に必要なスクリプトは、全て ChatGPT を使用して生成しました。
特に問題ないかと思いますが、もし、おかしな点があれば、必要に応じて処理をご変更いただければと思います。

X にポストする Python スクリプト (ここをクリック)
tweet.py
import os
import sys
import json
import subprocess
from datetime import datetime, timezone, timedelta
import tweepy

# 設定
USERNAME = 'testuser'  # Zennユーザー名を設定
TWEETED_FILE = '.tweeted.json'
PENDING_FILE = '.pending.json'

JST = timezone(timedelta(hours=+9))  # 日本標準時タイムゾーン

def load_json(path):
    if os.path.exists(path):
        with open(path, 'r', encoding='utf-8') as f:
            return json.load(f)
    return {}

def save_json(path, data):
    with open(path, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def get_diff_files(before_sha, after_sha):
    if not before_sha or before_sha == '0000000000000000000000000000000000000000':
        return []
    try:
        result = subprocess.run(
            ['git', 'diff', '--name-status', before_sha, after_sha],
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, text=True)
        lines = result.stdout.strip().split('\n')
        # 'A' or 'M' のステータスで、articles/*.md ファイルのみ抽出
        files = [line.split('\t')[1] for line in lines
                 if line.startswith(('A', 'M')) and line.endswith('.md') and 'articles/' in line]
        return files
    except Exception as e:
        print(f"Git diff error: {e}")
        return []

def parse_article_metadata(path):
    # published: true と published_at を探す簡易パーサー
    published = False
    published_at = None
    title = None
    with open(path, 'r', encoding='utf-8') as f:
        for line in f:
            line_strip = line.strip()
            if line_strip.startswith('published:'):
                val = line_strip.split(':', 1)[1].strip().lower()
                published = val == 'true'
            elif line_strip.startswith('published_at:'):
                val = line_strip.split(':', 1)[1].strip()
                try:
                    # 日本時間 (JST) でパースし、tzinfoを設定
                    published_at = datetime.strptime(val, '%Y-%m-%d %H:%M').replace(tzinfo=JST)
                except Exception:
                    published_at = None
            elif line_strip.startswith('#'):
                title = line_strip.lstrip('#').strip()
            if published and title and (published_at is not None or published_at is None):
                # publishedとtitleは必須、published_atは無くても良いので先にbreak可能
                break
    return published, published_at, title

def post_tweet(title, url):
    tweet = f'新しい記事を公開しました!\n\n{title}\n\n{url} #Zenn'
    try:
        auth = tweepy.OAuth1UserHandler(
            os.environ['TWITTER_API_KEY'],
            os.environ['TWITTER_API_SECRET'],
            os.environ['TWITTER_ACCESS_TOKEN'],
            os.environ['TWITTER_ACCESS_SECRET']
        )
        api = tweepy.API(auth)
        api.update_status(tweet)
        print(f"Tweet posted: {title}")
        return True
    except Exception as e:
        print(f"Failed to post tweet: {e}")
        return False

def main():
    before_sha = os.environ.get('GITHUB_BEFORE')
    after_sha = os.environ.get('GITHUB_SHA')

    tweeted = load_json(TWEETED_FILE)
    pending = load_json(PENDING_FILE)

    # 差分で追加・更新された記事を取得
    diff_files = get_diff_files(before_sha, after_sha)
    print(f"Diff files: {diff_files}")

    now = datetime.now(JST)

    # 差分記事を処理
    for path in diff_files:
        if not os.path.exists(path):
            continue

        published, published_at, title = parse_article_metadata(path)
        if not published:
            print(f"Skipping unpublished article: {path}")
            continue

        slug = os.path.basename(path).replace('.md', '')
        url = f'https://zenn.dev/{USERNAME}/articles/{slug}'

        # 予約投稿の場合、未来ならpendingに記録してスキップ
        if published_at and published_at > now:
            print(f"Scheduling future tweet for {slug} at {published_at.isoformat()}")
            pending[slug] = {'path': path, 'published_at': published_at.isoformat()}
            # ツイート済みとしては扱わないので削除
            if slug in tweeted:
                del tweeted[slug]
            continue

        # 既にツイート済みならスキップ
        if slug in tweeted:
            print(f"Already tweeted: {slug}")
            continue

        # ツイート実行
        if not title:
            title = '新しい記事を公開しました!'
        success = post_tweet(title, url)
        if success:
            tweeted[slug] = {'tweeted_at': now.isoformat()}
            # もしpendingにあれば削除
            if slug in pending:
                del pending[slug]

    # pending記事の公開日時が来ていたらツイート
    for slug, info in list(pending.items()):
        path = info.get('path')
        pub_at_str = info.get('published_at')
        if not path or not pub_at_str or not os.path.exists(path):
            # ファイルがないならpendingから除去
            del pending[slug]
            continue

        pub_at = datetime.fromisoformat(pub_at_str)
        if pub_at <= now:
            # 予約投稿タイミング来たのでツイート
            _, _, title = parse_article_metadata(path)
            if not title:
                title = '新しい記事を公開しました!'
            url = f'https://zenn.dev/{USERNAME}/articles/{slug}'

            if slug not in tweeted:
                success = post_tweet(title, url)
                if success:
                    tweeted[slug] = {'tweeted_at': now.isoformat()}
                    del pending[slug]

    # 保存
    save_json(TWEETED_FILE, tweeted)
    save_json(PENDING_FILE, pending)

if __name__ == '__main__':
    main()

このスクリプトでは、以下のような処理が組み込まれています。

  • Zenn で記事が公開済みの場合は X でポストする。その後、tweeted.json にポスト済みとして記録する。
  • 予約投稿は、pending.json に投稿待ちとして記録する。その後、予約投稿が Zenn で公開された場合は、GitHub Actions が動作したタイミングで X にポストされます。
  • 既に公開した記事の文章等を後から修正しても、X にポストしない。
  • 実行時間が短くなるように、軽量化されています。
GitHub Actions のワークフロー YAML (ここをクリック)
tweet.yaml
name: Tweet on new or scheduled Zenn articles

on:
  push:
    paths:
      - 'articles/**.md'
    branches:
      - main

  schedule:
    - cron: '0 * * * *'  # 毎時00分に実行(UTC時間)

jobs:
  tweet:
    runs-on: ubuntu-latest
    env:
      GITHUB_BEFORE: ${{ github.event.before }}
      GITHUB_SHA: ${{ github.sha }}
      TWITTER_API_KEY: ${{ secrets.TWITTER_API_KEY }}
      TWITTER_API_SECRET: ${{ secrets.TWITTER_API_SECRET }}
      TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
      TWITTER_ACCESS_SECRET: ${{ secrets.TWITTER_ACCESS_SECRET }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # cron実行時にも履歴参照できるようにする

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.x'

      - name: Install dependencies
        run: pip install tweepy

      - name: Run tweet script
        run: python .github/scripts/tweet.py
pending.json
{}
tweeted.json
{}

まとめ

これで完成です。
後はいつも通り、Zenn 記事を GitHub にプッシュするだけです。
補足として、私は結局、GitHub Actions ではなく、IFTTT などのローコードツールを使って構築しましたため、動作は未確認であることご留意ください。

おわりに

もし、少しでもこの記事がお役に立てましたら、ぜひ "いいね" をお願いします。
また、今後もブログを更新して行きますので、Zenn と X のフォローをどうぞよろしくお願いいたします。

Discussion