Drag to explore
Blog 一覧へ
WordPressの投稿時に自動でX(Twitter)に投稿できるようにしてみた
作ってみた

WordPressの投稿時に自動でX(Twitter)に投稿できるようにしてみた

WordPress で記事を公開するたびに X (Twitter) へ手動で投稿するのは地味に面倒です(多分きっとそう)。プラグインを使う手もありますが、テーマ固有の投稿タイプやタクソノミーに対応させたい場合、自前で実装するほうが柔軟性が高くなります(多分きっとそうに違いない)。

本記事では、本サイトテーマに実装した X への自動投稿機能の構成と、開発中にハマったポイントを紹介します。

機能概要

  • WordPress の投稿公開時に X へ自動投稿
  • 対象投稿タイプ: `post`(通常投稿)、`page`(固定ページ)、`creative`(カスタム投稿)
  • X API v2 + OAuth 1.0a で認証
  • 複数アカウントへの同時投稿に対応
  • 管理画面からアカウントの追加・削除が可能
  • Gutenberg(ブロックエディタ)とクラシックエディタの両方に対応

X Developer Console と課金体系(2026年3月時点)

X API を利用するには、まず X Developer Console でアプリを作成する必要があります。旧 developer.x.com は 2026 年 2 月に console.x.com へ完全移行しました。

料金体系

2026 年 2 月に Pay-Per-Use(従量課金)モデルが正式ローンチされ、これが新規利用者の標準となっています。

操作単価
ポスト作成(POST /2/tweets)$0.01/件
ポスト読み取り$0.005/件
ユーザールックアップ$0.01/件

旧 Free プランは実質廃止されており、新規登録者は最低 $5 のクレジット購入が必要です(たぶん)。Basic ($200/月)、Pro ($5,000/月)、Enterprise ($42,000+/月) は引き続き利用可能です。

初期設定の手順

  1. https://console.x.com/ にログイン
  2. アプリを作成
  3. Settings > User authentication settings > Edit で OAuth 1.0a を ON
  4. App permissions を 「Read and write」 に設定(デフォルトは Read のみ)
  5. Keys and tokens タブで Access Token と Access Token Secret を生成
  6. Pay-Per-Use を有効化 し、$5 以上のクレジットを購入

重要: 権限を「Read」から「Read and write」に変更した場合、Access Token の再生成が必須でした。トークンは生成時の権限スコープで発行されるため、古いトークンでは投稿に失敗します(HTTP 403)。

ファイル構成

3つのクラスに責務を分離しています。

wp-theme/
├── functions.php                          # require_once + init() 呼び出し
└── includes/
    ├── class-x-api-client.php            # X API v2 クライアント(OAuth 1.0a 署名 + HTTP リクエスト)
    ├── class-x-auto-post.php             # コア処理(メタボックス、遷移検知、テキスト構築)
    └── class-x-admin-settings.php        # 管理画面設定(アカウント CRUD、暗号化保存)
クラス責務
`Fantastiq_X_Api_Client`OAuth 1.0a 署名の生成と X API v2 への HTTP リクエスト
`Fantastiq_X_Auto_Post`投稿ステータス遷移の検知、メタボックス UI、投稿テキストの構築
`Fantastiq_X_Admin_Settings`管理画面でのアカウント CRUD、API シークレットの暗号化・復号

投稿テキストのフォーマット

X に投稿されるテキストは以下の形式です。

{記事タイトル} | {サイトドメイン}
{記事URL}
{#タグ1 #タグ2 ...}

ドメインは `wp_parse_url(home_url(), PHP_URL_HOST)` で動的に取得するため、環境が変わっても正しいドメインが表示されます。

タグは投稿タイプごとに異なるタクソノミーから取得します。

private const TAG_TAXONOMY_MAP = [
    'post'     => 'post_tag',
    'creative' => 'creative_tag',
    'page'     => null,  // 固定ページにはタグなし
];

140 (半角: 280) 文字制限への対応

X の文字数制限は 140 (半角: 280) 文字ですが、URL は実際の長さに関係なく t.co 短縮で 23 文字換算されます。これを考慮して、タイトル + URL の基本部分の文字数を算出し、残りの余裕に収まるだけのハッシュタグを追加しています。

$base_length = mb_strlen($line1) + 1 + self::X_URL_LENGTH + 1; // URL は 23 文字換算

foreach ($hashtags as $hashtag) {
    $tag_length = mb_strlen($hashtag) + $separator_length;
    if ($current_length + $tag_length > self::X_MAX_LENGTH) {
        break; // 超過したらそこで打ち切り
    }
    $tag_parts[] = $hashtag;
    $current_length += $tag_length;
}

タグが多すぎる場合は末尾から削られるだけなので、タイトルや URL が欠けることはありません。

セキュリティ設計

API シークレットの暗号化保存

`api_secret` と `access_token_secret` は、平文で `wp_options` に保存すると DB が漏洩した際に即座にアカウントが乗っ取られます。そこで `sodium_crypto_secretbox` を使い、WordPress の `LOGGED_IN_KEY` から導出した鍵で暗号化しています。

private static function encrypt_value(string $value): string
{
    $key   = sodium_crypto_generichash(LOGGED_IN_KEY, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
    $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
    $cipher = sodium_crypto_secretbox($value, $nonce, $key);

    return 'enc:' . base64_encode($nonce . $cipher);
}

ポイントは以下の 3 つです。

  1. `enc:` プレフィックス: 暗号化済みデータと未暗号化データを区別します。これにより、既存の平文データがあっても復号処理がエラーにならず、段階的なマイグレーションが可能です。
  2. `autoload` を `false` に設定: `update_option()` の第3引数を `false` にすることで、WordPress が全ページで自動ロードするオプション群から除外し、機密情報のメモリ露出リスクを低減しています。
  3. `LOGGED_IN_KEY` を鍵導出のシードに使用: `wp-config.php` に定義された秘密鍵を利用するため、追加の鍵管理が不要です。
  4. 復号メソッドは `public static` で 1 箇所に集約: 暗号化して保存したデータを読み出すクラスが複数ある場合、復号ロジックが分散すると「片方にだけ復号処理がない」バグが発生します。実際にこのプロジェクトでも、管理画面(`Admin Settings`)では復号していたのに、自動投稿処理(`Auto Post`)では復号せずに暗号文のまま OAuth 署名を生成してしまう問題がありました。`decrypt_value()` を `public static` にして、すべての呼び出し元から共有する設計にしています。

フック実行順序の罠と Gutenberg 対応

WordPress の投稿保存時、フックは以下の順序で発火します。

transition_post_status  →  save_post

`transition_post_status` は `draft → publish` のような遷移を検知するのに便利ですが、2 つの問題があります。

  1. メタボックスの値が未保存: `transition_post_status` 時点では `save_post` がまだ発火しておらず、チェックボックスの値が DB に反映されていません。
  2. Gutenberg は REST API 経由で保存する: ブロックエディタでは `$_POST` に nonce が含まれないため、nonce 検証に依存した `save_meta()` が即座に `return` してしまいます。

解決策として、クラシックエディタと Gutenberg の2パス設計を採用しました。

public function handle_status_transition(string $new_status, string $old_status, WP_Post $post): void
{
    if ($new_status !== 'publish' || $old_status === 'publish') {
        return;
    }

    // クラシックエディタ($_POST に nonce がある)→ 記録のみ
    // save_meta 内でメタ保存後に maybe_post_to_x を呼ぶ
    if (isset($_POST[self::NONCE_NAME])) {
        $this->transitions[$post->ID] = ['old' => $old_status, 'new' => $new_status];
        return;
    }

    // Gutenberg/REST API 経由 → ここで直接投稿を実行
    $this->maybe_post_to_x($post);
}

クラシックエディタの場合は、`save_post` フック内でメタ保存後に遷移を拾います。

public function save_meta(int $post_id, WP_Post $post): void
{
    // ... nonce 検証、メタ保存 ...

    // メタ保存後に自動投稿を実行
    if (isset($this->transitions[$post_id])) {
        $this->maybe_post_to_x($post);
        unset($this->transitions[$post_id]);
    }
}

二重投稿防止

ネットワーク遅延やリダイレクトで `save_post` が複数回発火する可能性があります。`add_post_meta` の第4引数 `unique` を `true` にすることで、アトミックなロックとして機能させています。

$lock_acquired = add_post_meta($post->ID, self::META_POSTED, 'processing', true);
if (!$lock_acquired) {
    return; // 既にロック取得済み = 処理中 or 投稿済み
}

投稿が成功すれば `processing` を実際の `tweet_id` に更新し、全アカウントで失敗した場合はメタを削除してロックを解放します。

入力検証

WordPress の標準的なセキュリティプラクティスに従い、以下を徹底しています。

  • CSRF 防止: `wp_nonce_field` / `wp_verify_nonce` / `check_admin_referer`
  • 権限チェック: メタボックスは `edit_post`、設定画面は `manage_options`
  • 出力エスケープ: `esc_html`, `esc_attr`, `esc_js`
  • 入力サニタイズ: `sanitize_text_field`, `wp_unslash`, `absint`

OAuth 1.0a 署名の実装

X API v2 は OAuth 1.0a で認証します。実装で注意すべき点がいくつかあります。

JSON body は署名に含めない

`Content-Type: application/json` でリクエストする場合、JSON body は OAuth 署名のベース文字列に含めません。これは RFC 5849 Section 3.4.1.3 に定義されている仕様で、署名対象は URL のクエリパラメータと OAuth パラメータのみです。

$base_string = self::build_base_string(self::ENDPOINT, 'POST', $oauth);

nonce の生成

OAuth nonce は推測不可能である必要があります。`bin2hex(random_bytes(16))` で暗号学的に安全な 32 文字の nonce を生成しています。`uniqid()` や `mt_rand()` は予測可能なため使用しません。

HTTP クライアント

cURL を直接使う代わりに WordPress の `wp_remote_post` を採用しました。これにより、WordPress のプロキシ設定やSSL証明書のバンドルがそのまま適用され、サーバー環境への依存を減らせます。

管理画面 UI

上図のような感じでWordPressのテーマの「設定」に設置しました
  • 設定ページ: 「設定 > X 自動投稿」にアカウント一覧と追加フォームを配置
  • メタボックス: 投稿編集画面のサイドバーに「X 自動投稿」チェックボックスとアカウント選択を表示
  • API キーのマスク表示: 末尾 4 文字のみ表示し、残りは `*` で置換(例: `****abcd`)
  • フォーム入力欄: すべて `type=”password”` + `autocomplete=”off”` で、ショルダーハッキングやブラウザの自動補完を防止

まとめ

WordPress テーマ内で X 自動投稿を実装する際のポイントは、大きく4つです。

  • X Developer Console の設定を正しく行う(これですんごい手間取った): console.x.com で OAuth 1.0a を有効化し、App permissions を「Read and write」に設定したうえで Access Token を再生成する。Pay-Per-Use のクレジット購入も忘れずに
  • フック実行順序と Gutenberg の違いを理解する: クラシックエディタと Gutenberg で保存フローが異なる。`transition_post_status` 内で nonce の有無を判定し、2 パスで対応する設計が必要
  • シークレットを暗号化し、復号ロジックは 1 箇所に集約する: `sodium_crypto_secretbox` で暗号化保存し、`decrypt_value()` を `public static` にして全呼び出し元から共有する。復号漏れは OAuth 認証失敗の原因になる
  • OAuth 1.0a の仕様に忠実に実装する: JSON body を署名に含めない、nonce を暗号学的に安全な方法で生成するなど、RFC に従った実装を心がける

プラグインに頼らず自前で実装することで、カスタム投稿タイプやタクソノミーとの統合がシームレスになり、テーマの一部として一元管理できるようになりますよ、という話でした。

……とか言いつつ、そんな簡単な話でもないかも、と思う自分がいます。。。

More