Zenn 記事を投稿したら X に自動通知する仕組みを作る【GitHub Actions編】
はじめに
本記事では、GitHub Actions を使用して、Zenn のブログ記事を更新したことを、X (Twitter) で自動告知する構成の構築方法についてご紹介いたします。
注意点
GitHub Actions と X API の Free プランでは、それぞれ制限事項があります。
本構成は Free プランにて実現可能かと思いますが、制限事項は変更される可能性があるため、以下の Web ページより最新情報をご確認ください。
必要な準備
まず大前提として、Zenn と GitHub を連携します。
連携手順は、以下の記事をご確認ください。
以下のページから、X の Developer Platform に登録して、アプリを作成する。
作成したアプリから、以下のキーを取得して、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 としてキーを登録する方法は、以下のページから確認できます。
ディレクトリ構造の整理
以下のようなディレクトリ構造となるように、フォルダやファイルを作成します。
Zenn
└── articles # Zenn の記事を保存している場所
└── books
└── images
└── .github
└── scripts
└── tweet.py # X にポストする Python スクリプト
└── workflows
└── tweet.yaml # GitHub Actions のワークフロー YAML
└── pending.json # 予約投稿待ち記録
└── tweeted.json # ポスト済み記録
自動投稿用のスクリプト作成
自動投稿に必要なスクリプトは、全て ChatGPT を使用して生成しました。
特に問題ないかと思いますが、もし、おかしな点があれば、必要に応じて処理をご変更いただければと思います。
X にポストする Python スクリプト (ここをクリック)
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 (ここをクリック)
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
{}
{}
まとめ
これで完成です。
後はいつも通り、Zenn 記事を GitHub にプッシュするだけです。
補足として、私は結局、GitHub Actions ではなく、IFTTT などのローコードツールを使って構築しましたため、動作は未確認であることご留意ください。
おわりに
もし、少しでもこの記事がお役に立てましたら、ぜひ "いいね" をお願いします。
また、今後もブログを更新して行きますので、Zenn と X のフォローをどうぞよろしくお願いいたします。
Discussion