ブログ更新を X に自動投稿する - リンク付きのリプライを投下

このブログを更新したら、その告知を X(旧 Twitter)に自動で投稿する。そんな仕組みを作りました。
きっかけは単純で、毎回手でやるのが面倒だったからです。本記事は、その実装の記録です。あわせて、作っていく中で「配信に関する情報をどこに置くか」を考えた結果、フロントマターにすべて寄せる設計になった話も書きます。
手動投稿をやめたかった
これまで、ブログ記事を公開したら X にも手で投稿していました。記事のリンクを貼って、ひとこと添えて、ポストする。作業自体は1分もかかりません。
ただ、1分の作業でも「やる」「やらない」の判断が毎回挟まると、そこそこの確率で抜けます。実際、公開したのに X に流し忘れた記事が何本かありました。書いた本人が告知を忘れる、というのは地味に間抜けです。
書くことと配信することは、別の作業です。書くことには集中したい。でも配信は、決まった手順をなぞるだけの作業で、人間がやる必然性がありません。これは機械にやらせる。そう決めて作り始めました。
3つの設計判断
仕組みを作るにあたって、最初に決めたことが3つあります。
投稿文は自分で書く
まず、X に流す投稿文を AI に生成させるか、手で書くか。手で書くことにしました。
記事本文から要約を生成するのは技術的には簡単です。でも、X の投稿文は記事の要約ではありません。「これを読んでほしい」という、その記事に対する自分の温度がそのまま出る場所です。そこを機械の生成文にすると、すべての告知が同じ顔になります。
自動化したいのは「配信の手順」であって「何を言うか」ではない。ここは分けました。投稿文だけは手書きで、あとは全部自動。
リンクはリプライに分ける
次に、記事リンクの置き方。投稿本文に URL を入れず、本文を投稿したあと、そのリプライとして URL を別ツイートで付ける形にしました。
X は、外部リンクを含むポストの表示が伸びにくい、という傾向が知られています。真偽はさておき、リンクを本文から外してリプライに逃がすのは、X 上でよく使われる定番の運用です。それに倣いました。
本文ツイートは言いたいことだけ、リンクはその下にぶら下げる。スレッドの形としても素直です。
配信設定はフロントマターに集める
3つ目。これが一番こだわったところです。
この仕組みでは、X 投稿に必要な情報を、記事の Markdown ファイル先頭にあるフロントマター(YAML のメタ情報)に書きます。投稿文も、リンクをリプライに付けるかどうかのフラグも、すべてそこです。
x:
main: |
ブログ更新を X に自動投稿する - リンク付きのリプライを投下
(中略)
reply_url: true
なぜ別ファイルや外部サービスにしなかったか。記事という1つの対象について、本文・配信設定・配信状態がバラバラの場所にあると、どれが最新か分からなくなるからです。
1記事1ファイル。本文も、X にどう流すかも、同じ index.md の中にある。これを記事に関する Single Source of Truth(信頼できる唯一の情報源)として扱う、というのが設計の軸です。
しかも投稿が完了すると、スクリプトが同じフロントマターに投稿日時とツイート ID を書き戻します。
x:
posted_at: "2026-05-18T10:30:00Z"
tweet_id: "1234567890"
main: |
(略)
reply_url: true
投稿済みかどうかも、このファイルを見れば分かる。そしてファイルは Git で管理されているので、いつ・何が・どう配信されたかは、すべてコミット履歴に残ります。配信ログのための仕組みを別に用意しなくても、リポジトリがそのまま記録になっています。
実装
全体の流れ
仕組みはこう動きます。
flowchart LR
author(["記事を書く人"])
subgraph repo ["本体リポジトリ(GitHub)"]
md["index.md(本文 + x.main)"]
end
subgraph ga ["GitHub Actions"]
direction TB
scan["記事を走査"]
post["X に投稿"]
back["posted_at を書き戻してコミット"]
scan --> post --> back
end
x(["X"])
author -->|push| md
md -->|main の更新を検知| scan
post -->|x.main / リプライ| x
back -.->|"[skip ci] コミット"| md
記事を書いて本体リポジトリの main ブランチに push する。すると GitHub Actions が動き、記事を走査して、未投稿のものを X に投稿する。投稿が終わったら、フロントマターに投稿状態を書き足してコミットする。これだけです。
X への投稿には twitter-api-v2 という Node.js 向けのライブラリを使いました。X API を直接叩くより、認証まわりが楽です。
投稿スクリプト
投稿処理の本体は scripts/publish-to-x.mjs という1ファイルです。やっていることは、
src/content/blog/配下の記事を全部見るx.mainがあって、まだposted_atが無い記事を選ぶx.mainをそのまま本文として投稿するreply_urlがtrueなら、記事 URL をリプライで投稿する- 投稿できたら、フロントマターに
posted_atとtweet_idを書き戻す
コードにすると、投稿の中心はこのくらいの分量です。
const main = await client.v2.tweet(x.main);
if (x.reply_url) {
await client.v2.tweet(`記事はこちら\n${articleUrl}`, {
reply: { in_reply_to_tweet_id: main.data.id },
});
}
posted_at を見て未投稿の記事だけ選ぶので、同じ記事が二重投稿されることはありません。一度投稿された記事は、次から自動的にスキップされます。
ローカルで試すとき用に --dry-run オプションも付けました。これを付けると、実際には投稿せず「何を投稿しようとしているか」だけを表示します。……付けたのに、後で自分が忘れるのですが、その話は後述します。
GitHub Actions と、Workers Builds との同居
配信のトリガーは GitHub Actions です。main ブランチへの push のうち、ブログ記事(src/content/blog/)が変わったときだけワークフローを起動します。
ここで1つ気にしたのが、このサイトのデプロイ方法との干渉です。本サイトは Cloudflare の Workers Builds でホストしていて、main に push されるたびに自動でビルド・デプロイが走ります(前回の移管記事に書いたとおりです)。
問題は、X 投稿のワークフロー自身もコミットを作る点です。投稿後にフロントマターへ posted_at を書き戻し、それをコミット & push する。するとその push がまた main への変更になり、ワークフローがもう一度起動し……と、放っておくと無限ループになりかねません。
対策は定番で、ワークフローが作るコミットのメッセージに [skip ci] を入れます。GitHub Actions はこの文字列を含む push を無視するので、自分のコミットで自分が再起動することはなくなります。
chore: update post status [skip ci]
なお [skip ci] が効くのは GitHub Actions に対してだけです。Cloudflare 側のビルドや、別途連携している Zenn への同期は、この文字列とは無関係に動きます。「CI のループだけ止めて、ほかの連携には手を出さない」――この切り分けを意識して設計しました。
ハマったところ
今回ハマったところはそんなにありませんでしたが、ひとつだけ ――
誤投稿事件
ローカルで動作確認をしていたときのことです。
前述のとおり、安全のために --dry-run オプションを用意してありました。投稿せずに内容だけ確認するためのものです。それを付け忘れて、本番の投稿コマンドをそのまま実行しました。
X に、テスト用の文言がそのまま流れました。しかもリプライ付きの設計なので、本文とリプライで2ツイート。慌てて両方削除しました。
ただ、結果として、これは一番確実な動作確認になりました。本文の投稿、リプライのぶら下げ、投稿後のフロントマターへの posted_at 書き戻し――一連の流れが本番環境で通しで動くことが、否応なく確認できたわけです。フロントマターにちゃんと tweet_id が書き戻されているのを見て、「あ、ちゃんと動いてる」と。
教訓は2つ。危険な操作には dry-run を用意すること。そして、用意しただけでは忘れる、ということです。
この記事自体が、この仕組みで配信されている
ここまで読んでいただいて、たぶんお気づきのとおりです。
この記事は、今まさに説明してきた仕組みで X に配信されています。この index.md のフロントマターには x.main が書いてあり、その文章がそのまま X に流れました。リンクは、その下のリプライに付いているはずです。
仕組みを説明する記事が、その仕組みで配信される。書いていて少し不思議な感覚でしたが、これ以上ない動作確認でもあります。もしあなたが X 経由でこの記事に来ているなら、それが証拠です。
次は、はてなブログ
X への自動投稿ができたので、次ははてなブログです。
このブログの記事を、はてなブログにも自動で展開したい。Zenn へのクロスポストはすでに動いているので、これができれば、1回 push するだけで本体サイト・Zenn・はてな・X の4箇所に配信される状態になります。
書く場所は1つ、届く場所は複数。配信は機械に任せて、人間は書くことに専念する。その形に、少しずつ近づけていきます。
当社では、こうした「手作業で回している定型業務を、仕組みで自動化する」開発をお手伝いしています。今回のような小さな自動化から、業務システムの開発・保守まで、長期運用を前提とした設計でご支援します。お問い合わせフォーム、または LINE 公式アカウントからお気軽にどうぞ。