AI が書いた X 投稿を人間が承認してから出す仕組みを GitHub Actions で作った

AI が書いた X 投稿を人間が承認してから出す仕組みを GitHub Actions で作った コラム
AI が書いた X 投稿を人間が承認してから出す仕組みを GitHub Actions で作ったのイメージ

SNS 運用を自動化したいが、AI が勝手に投稿するのは怖い——そのちょうど中間の落とし所として「生成は自動、投稿は手動承認」という仕組みを作った。

ブログに記事を公開するたびに X(旧 Twitter)にも告知投稿したいという動機はずっとあった。記事を書いたあとに手動でツイートするのは地味に手間で、投稿を忘れることも多かった。ただし完全自動化には踏み切れなかった。AI が生成した文章をそのまま投稿すると、意図しないニュアンスや誤情報が入ることがある。特に技術記事の紹介ツイートで「嘘」を言ってしまうと信頼を損なう。

「完全自動化は怖い、でも手動投稿は忘れる」——この問題を解決するために考えたのが、生成は自動で、投稿の最終判断だけ人間が持つ設計だ。

結論から書くと、GitHub Actions の workflow_dispatch を使えば「ボタンを押したときだけ動くワークフロー」が作れる。これが「手動承認」の実装として非常に相性が良かった。ボタンを押す前にプレビューを確認できるので、「投稿内容を確認してから実行する」という自然なワークフローが実現できる。

全体の仕組みと設計思想

全体の仕組みと設計思想のイメージ

まず全体のフローを整理する。


記事ファイル(.md)
  ↓ frontmatter から title / description / tags を読む
  ↓ Claude / スクリプトが 280 文字以内のツイート文を自動生成
  ↓ GitHub Actions ログにプレビュー出力

人間が確認フロー:
GitHub Actions の画面
  → 対象ワークフローを選択
  → "Run workflow" ボタンをクリック
  → 記事パスを入力(または custom_tweet でカスタム文を入力)
  → 実行前にプレビューを確認して「これなら投稿できる」と判断
  → ボタンを押す = 承認 = 投稿

workflow_dispatch のトリガー自体が「人間が GitHub の UI でボタンを押す」行為なので、ボタンを押す前にプレビューを見て判断するというフローになっている。完全自動化とは違い、必ず人間の目を通してから投稿される。

この設計を選んだ理由は3つある。まず、投稿前にツイート文を確認できるため、AI が生成したおかしな表現を見つけて止められる。次に、どの記事をいつ投稿したかが GitHub のワークフロー実行履歴として残るため管理がしやすい。最後に、custom_tweet 入力フィールドで生成文を上書きできるため、AI の文章が気に入らなければ実行前に書き直せる。

実際に使い始めてみると、予想以上に「承認」の感覚が自然だった。記事を公開したら GitHub の Actions タブを開き、ワークフローを実行する——この2ステップが習慣になった。

post-to-x.mjs の中身

post-to-x.mjs の中身のイメージ

スクリプトは 88 行。twitter-api-v2 パッケージ 1 つで X API v2 の認証と投稿を処理する。依存パッケージを最小限に絞ったのは、GitHub Actions のランナーで npm install --no-save を使って一時的にインストールするため、package.json を管理するプロジェクトに組み込まずに単体で動かせるようにしたかったからだ。


// フロントマターをパース(yaml ライブラリ不使用、正規表現で済ます)
function parseFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---/);
  if (!match) return {};
  const yaml = match[1];
  const get = (key) => {
    const m = yaml.match(new RegExp(`^${key}:\\s*["']?(.+?)["']?\\s*$`, "m"));
    return m ? m[1] : "";
  };
  return { title: get("title"), description: get("description"), tags };
}

frontmatter のパースに yaml ライブラリを使わず正規表現で済ませたのは、npm install --no-save で動かすため依存を最小限にしたかったからだ。title・description・tags だけ取れれば十分で、フル機能の YAML パーサーを入れるほどのことはない。ただし、この方法は frontmatter の値にコロン(:)が含まれる場合にパースがずれることがある。タイトルに「ChatGPT vs Claude: どちらが良いか」のような表現を使う場合はダブルクォートで囲む必要がある。

ツイート文の生成ロジックはシンプルだ。


function buildTweet(title, description, url, tags) {
  const hashtags = tags.slice(0, 3)
    .map((t) => `#${t.replace(/\s+/g, "")}`)
    .join(" ");
  const suffix = `\n\n👉 ${url}${hashtags ? "\n" + hashtags : ""}`;
  const maxBody = 200; // CJK 2文字換算を考慮して余裕を持たせる
  const body = description.length > maxBody
    ? description.slice(0, maxBody - 1) + "…"
    : description;
  return `${title}\n\n${body}${suffix}`;
}

X は CJK 文字(日本語・中国語・韓国語)を 2 文字としてカウントする。280 文字制限は「280 コードポイント」ではなく「X の重み付き文字数」なので、日本語記事のタイトル + 説明文で簡単に 280 を超える。maxBody = 200 と余裕を持たせることで、ハッシュタグと URL を含めても安全に収まるようにしている。実際にテストした際、200 文字を超えた description のツイートが投稿エラーになることがあったため、このバッファは必要だった。

GitHub Actions ワークフローの設計

GitHub Actions ワークフローの設計のイメージ

on:
  workflow_dispatch:
    inputs:
      article_path:
        description: "記事ファイルパス"
        required: false
      custom_tweet:
        description: "カスタムツイート文(空なら記事から自動生成)"
        required: false

workflow_dispatchinputs を定義すると、GitHub の UI に入力フォームが表示される。記事パスを入れると frontmatter から自動生成、custom_tweet に文章を入れるとそちらを優先する。両方空だとエラーになる。

この設計の利点は「カスタムツイート文」フィールドがある点だ。AI が生成した説明文が気に入らなければ、実行前にここを書き直せる。記事の frontmatter を変えなくても、投稿内容だけを上書きできる。例えば「description は SEO 向けに書かれているが、X 向けにもう少しカジュアルに言い換えたい」というときに重宝する。

ワークフローの他の部分は以下のような構成にした。


jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Install dependencies
        run: npm install --no-save twitter-api-v2
      - name: Post to X
        env:
          X_API_KEY: ${{ secrets.X_API_KEY }}
          X_API_SECRET: ${{ secrets.X_API_SECRET }}
          X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
          X_ACCESS_SECRET: ${{ secrets.X_ACCESS_SECRET }}
        run: node tools/sns/post-to-x.mjs "${{ inputs.article_path }}" "${{ inputs.custom_tweet }}"

npm install --no-save でランナーに一時的にパッケージを入れるため、package.json を汚さない。Actions の実行ごとに新鮮な環境でインストールされるため、バージョン固定の問題も起きにくい。

X Developer Portal でのアプリ設定手順

X Developer Portal でのアプリ設定手順のイメージ

スクリプトを動かすには X API の認証情報が必要だ。4 つのトークンを取得するための手順を整理する。

ステップ 1: X Developer Portal でアプリを作成

[X Developer Portal](https://developer.twitter.com/en/portal/dashboard) にアクセスし、アカウントを持っていなければ開発者アカウントを申請する。無料の Basic アクセスでも今回の投稿用途には十分だ。申請フォームでは「目的」を正直に記入する(「個人ブログの記事告知自動化」など)。

アプリを作成したら「App Settings」→「Keys and Tokens」から以下を取得する。

  • X_API_KEY:Consumer Key(API Key)
  • X_API_SECRET:Consumer Secret(API Key Secret)

ステップ 2: アプリの権限設定

デフォルトでは Read 権限しかない。投稿(Tweet)するには「Read and Write」権限が必要なので、「App Settings」→「User authentication settings」で権限を変更する。変更後は既存のアクセストークンが無効になるため、必ずトークンを再発行する。

ステップ 3: アクセストークンの取得

開発者アカウントと投稿するアカウントが同じ場合は、Developer Portal から「Access Token and Secret」を直接生成できる。開発者アカウント @kagurasoma_gc、投稿先 @Lifeeventhub のように異なるアカウントに投稿する場合は、PIN ベースの OAuth 1.0a 認証でアクセストークンを取得する必要がある。


// authorize-x.mjs
const { url, oauth_token, oauth_token_secret } =
  await client.generateAuthLink("oob"); // "oob" = out-of-band = PIN認証

console.log("以下の URL を対象アカウントでログインした状態で開いてください:");
console.log(url);

// PIN を入力させる
const pin = await new Promise((resolve) => {
  rl.question("PIN を入力: ", resolve);
});

const { accessToken, accessSecret } =
  await loginClient.login(pin);

"oob" が PIN 認証のキーワードだ。コールバック URL の代わりに PIN を使うモード。対象アカウントで X の認証ページにアクセスして PIN を取得し、ターミナルに入力するとトークンが発行される。

ステップ 4: GitHub Secrets に登録

取得した 4 つのトークンを GitHub リポジトリの「Settings」→「Secrets and variables」→「Actions」から登録する。

  • X_API_KEY
  • X_API_SECRET
  • X_ACCESS_TOKEN
  • X_ACCESS_SECRET

Secrets に登録するとワークフローのログにマスクされて表示されるため、誤ってログが公開されてもトークンが漏れない。

実際に使ってみた感触と気づき

実際に使ってみた感触と気づきのイメージ

仕組みを作ってから 1 ヶ月運用した結果、いくつか気づきがあった。

1. 投稿前にプレビューが確認できる安心感

ワークフローのログの最初に「=== ツイートプレビュー ===」が出力されるようにしている。実行して最初のログを見た段階でツイート文を確認できる。「あ、この文章はちょっと変だな」と思ったら次の記事投稿時に frontmatter の description を書き直す材料にもなる。実際に何度か「これは description が長すぎて意味が切れている」と気づいて修正した。

2. 記事と投稿の対応が GitHub の履歴に残る

どの記事をいつ投稿したかが、ワークフローの実行履歴として GitHub に残る。手動で X を開いて投稿するより管理しやすい。「先月この記事を投稿したっけ?」という疑問も履歴を見ればすぐ解決する。

3. description の書き方が変わった

X 投稿文を自動生成するようになってから、frontmatter の description を「SEO だけでなく X で読んでも自然な文章」として書くようになった。description の品質が上がると、検索結果のメタディスクリプションとしても自然な表現になるという副次効果があった。

4. 完全自動化との比較

「毎回ボタンを押すのが面倒になってきたら push トリガーに変えればよい」と思っていたが、実際には「ボタンを押す」というステップが心理的なブレーキになっていてよかった。記事を公開した直後に気づいた誤字や内容の修正をして、修正版が公開されてから X 投稿する、という判断が自然にできるようになった。完全自動化だと修正前の記事を投稿してしまうリスクがあった。

X 投稿自動化の方式比較

同じ目的を達成する方法はいくつかある。それぞれの特徴を整理する。

方式 承認フロー 履歴管理 セットアップ難易度 コスト
GitHub Actions + workflow_dispatch(本記事) ボタン押下で承認 Actions 履歴に残る 中(GitHub 知識必要) 無料
Buffer / Hootsuite 等のツール ダッシュボードで承認 ツール内に残る 低(GUIのみ) 有料($6〜)
Zapier / Make で完全自動化 承認なし(全自動) ワークフローログ 低〜中 有料
cron + curl で完全自動化 承認なし(全自動) サーバーログのみ 高(サーバー管理必要) 無料〜
手動投稿 100% 手動 なし なし 無料

「承認フローあり × 無料 × 履歴管理」の3条件をすべて満たすのは GitHub Actions の組み合わせが現実的だ。特にエンジニアがすでに GitHub を日常的に使っているなら、追加ツールなしで完結するメリットが大きい。

よくある設定ミスと対処法

実際に設定する際によくある問題をまとめておく。

「Authorization failed」エラーが出る

最も多い原因はアプリの権限が「Read Only」のままになっていることだ。Developer Portal でアプリの権限を「Read and Write」に変更してから、アクセストークンを再発行する必要がある。既存のトークンは権限変更後に自動で無効になるため、必ず再発行すること。

「Value too long」エラーが出る

日本語の description が 280 文字制限を超えているケース。buildTweetmaxBody を 150 まで下げてみると解消することが多い。CJK 文字は 2 文字カウントのため、体感的な文字数より制限に当たりやすい。

投稿されたが文字化けしている

Node.js のスクリプトファイルを UTF-8 以外で保存していた場合に起こる。ファイルが UTF-8 BOM ありで保存されているとパースが崩れることがある。エディタで BOM なし UTF-8 で保存し直すと解消する。

✅ まとめ

  • workflow_dispatch = 人間がボタンを押したときだけ動くトリガー。これが「手動承認」の実装として機能する
  • スクリプトは twitter-api-v2 の Node.js MJS で完結。npm install --no-save でワークフロー内にインストールするだけ
  • frontmatter から自動生成しつつ、custom_tweet 入力フィールドで上書きもできる柔軟な設計
  • 開発者アカウントと投稿アカウントが異なる場合は PIN 認証("oob" モード)でアクセストークンを取得する
  • アプリの権限を「Read and Write」に設定してからトークンを発行することを忘れずに

完全自動化は「AI を信用しすぎる」感があって怖い。「生成は自動・承認は人間」の中間地点として、このワークフローはちょうど良い落とし所だと思っている。1 ヶ月運用した実感として、ボタンを押すという小さな手間が「投稿内容を一度確認する」という良い習慣になっている。次のアクションとして、GitHub Actions の設定手順は tools/sns/README.md に残しておいたので、同じ構成を試したい方はそちらも参照してください。

コメント