記事を毎朝 1 本 X に投稿する:コマンドラインの PHP と cron で動かす

広告WordPress・PHP を学べる本

Amazonのアソシエイトとして、WordPress functions.phpは適格販売により収入を得ています。

記事を 1 日 1 本、X で紹介したい

公開している記事を X(旧 Twitter)で毎日紹介する仕組みを、テーマの中に作りました。投稿のためのプラグインもありますが、記事の選び方や文の形を自由に決めたかったので、コマンドラインで動かす PHP をテーマに置き、サーバーの cron で毎朝 1 回動かす形にしています。

流れは、まだ出していない記事を 1 本選び、X に入る長さの文を作って、X API v2 に送るというものです。出した記事は記録しておき、同じ記事を続けて出さないようにしています。

全体の流れ

次のコードは、実際のファイルを短くして、名前を例の名前にした骨組みです。記事の選び方や文の作り方はサイトによって変わるので、ここでは形だけを示しています。

/wp-content/themes/(テーマのフォルダ)/cli/my-x-daily.php(骨組み)
<?php
// コマンドラインのときだけ動かす(ブラウザから開かれたら 404 で終わる)
if ( PHP_SAPI !== 'cli' ) {
    http_response_code( 404 );
    exit;
}

// WordPress を読み込む
$_SERVER['HTTP_HOST']   = 'example.com';
$_SERVER['REQUEST_URI'] = '/';
define( 'WP_USE_THEMES', false );
require dirname( __DIR__, 4 ) . '/wp-load.php';

define( 'MY_X_OPT_POSTED', 'my_x_posted_ids' );
define( 'MY_X_OPT_LAST', 'my_x_last' );
define( 'MY_X_MAX_WEIGHT', 280 );

// 同じ日に 2 本出さない
$today = wp_date( 'Y-m-d' );
$last  = get_option( MY_X_OPT_LAST, array() );
if ( is_array( $last ) && ( $last['date'] ?? '' ) === $today ) {
    my_x_out( 'skip: already posted today' );
    exit( 0 );
}

// リンクは日曜だけ付ける
$with_link = ( '7' === wp_date( 'N' ) );

// まだ出していない記事を 1 本選んで、文を作る
$posted  = array_map( 'intval', (array) get_option( MY_X_OPT_POSTED, array() ) );
$post_id = my_x_pick( $posted );
$text    = my_x_compose( $post_id, $with_link );
if ( '' === $text ) {
    my_x_out( 'error: no text' );
    exit( 1 );
}

// 鍵は公開フォルダの外のファイルから読む
$keys = my_x_keys();
$res  = my_x_post( $text, $keys );
if ( ! $res['ok'] ) {
    my_x_out( 'error: HTTP ' . $res['code'] );
    exit( 1 );
}

// 出した記事と日付を記録する
$posted[] = $post_id;
update_option( MY_X_OPT_POSTED, array_values( array_unique( $posted ) ), false );
update_option( MY_X_OPT_LAST, array( 'date' => $today, 'post_id' => $post_id ), false );
my_x_out( 'ok: post_id ' . $post_id );
書き方 役目
PHP_SAPI !== 'cli' テーマの中のファイルは URL でも開けてしまうので、ブラウザから開かれたときは何もしないで 404 を返す
define( 'WP_USE_THEMES', false ) WordPress の関数だけを使い、ページの表示はしない
dirname( __DIR__, 4 ) テーマの cli フォルダから 4 つ上の、WordPress のフォルダの wp-load.php を読む
MY_X_OPT_LAST 最後に出した日付を option に入れておき、cron が 2 回動いても同じ日に 2 本出さない
MY_X_OPT_POSTED 出した記事の ID を option に入れておき、選ぶときに外す
update_option( …, false ) 最後の false で、ページを開くたびに読み込む option(autoload)にしない

鍵(API Key・API Key Secret・Access Token・Access Token Secret)は、コードには書かず、WordPress のフォルダより上の、公開されないフォルダに置いたファイルから読んでいます。ファイルには X_API_KEY=… のような行を並べ、足りないものがあれば送らずに終わるようにしました。git で管理しているテーマの中に鍵を置くと、うっかり公開してしまうおそれがあるためです。

X API v2 に OAuth 1.0a で送る

X に投稿するには、POST https://api.x.com/2/tweets に、投稿する文を JSON で送ります。今回は 1 つのアカウントから出すだけなので、ユーザーごとの許可の画面がいらない OAuth 1.0a を使い、開発者のページで発行した 4 つの鍵で署名を作っています。

my-x-daily.php(署名を作って送る関数)
function my_x_post( $text, array $keys ) {
    $url   = 'https://api.x.com/2/tweets';
    $oauth = array(
        'oauth_consumer_key'     => $keys['X_API_KEY'],
        'oauth_nonce'            => bin2hex( random_bytes( 16 ) ),
        'oauth_signature_method' => 'HMAC-SHA1',
        'oauth_timestamp'        => (string) time(),
        'oauth_token'            => $keys['X_ACCESS_TOKEN'],
        'oauth_version'          => '1.0',
    );
    ksort( $oauth );
    $param = array();
    foreach ( $oauth as $k => $v ) {
        $param[] = rawurlencode( $k ) . '=' . rawurlencode( $v );
    }
    $base     = 'POST&' . rawurlencode( $url ) . '&' . rawurlencode( implode( '&', $param ) );
    $sign_key = rawurlencode( $keys['X_API_SECRET'] ) . '&' . rawurlencode( $keys['X_ACCESS_SECRET'] );
    $oauth['oauth_signature'] = base64_encode( hash_hmac( 'sha1', $base, $sign_key, true ) );

    $header = array();
    foreach ( $oauth as $k => $v ) {
        $header[] = rawurlencode( $k ) . '="' . rawurlencode( $v ) . '"';
    }

    $res = wp_remote_post(
        $url,
        array(
            'timeout' => 20,
            'headers' => array(
                'Authorization' => 'OAuth ' . implode( ', ', $header ),
                'Content-Type'  => 'application/json',
            ),
            'body'    => wp_json_encode( array( 'text' => $text ) ),
        )
    );
    if ( is_wp_error( $res ) ) {
        return array( 'ok' => false, 'code' => 0, 'id' => '' );
    }
    $code = (int) wp_remote_retrieve_response_code( $res );
    $json = json_decode( (string) wp_remote_retrieve_body( $res ), true );
    $id   = isset( $json['data']['id'] ) ? (string) $json['data']['id'] : '';
    return array( 'ok' => ( 201 === $code && '' !== $id ), 'code' => $code, 'id' => $id );
}
書き方 役目
oauth_nonce 送るたびに変わる値。同じ署名を使い回されないようにする
oauth_timestamp 送る時刻。OAuth の決まりで、UNIX 時刻(秒)をそのまま入れる
ksort( $oauth ) 署名の元にする値は、名前の順に並べる決まり
$base 「メソッド & URL & 値の並び」を、それぞれ URL エンコードしてつないだ署名の元
$sign_key API Key Secret と Access Token Secret を & でつないだ署名の鍵
hash_hmac( 'sha1', …, true ) HMAC-SHA1 で署名を作り、Base64 にしてヘッダーに入れる
201 === $code 投稿ができると 201 と、投稿の ID が返ってくる

本文は JSON で送るので、署名の元には oauth_ で始まる値だけを入れています。OAuth 1.0a では、フォームの形(application/x-www-form-urlencoded)で送る値は署名の元に入れますが、JSON の本文は入れません。

文の長さは「重み」で数える

X の投稿は 280 までですが、これは文字の数ではなく重みの合計です。英字や数字、記号の多くは 1、日本語や絵文字は 2 と数え、URL は長さに関係なく 23 と数えます。日本語だけなら 140 字までということになります。

my-x-daily.php(重みを数える関数)
function my_x_weight( $text ) {
    $w    = 0;
    $text = preg_replace_callback(
        '#https?://\S+#',
        function () use ( &$w ) {
            $w += 23;
            return '';
        },
        $text
    );
    foreach ( preg_split( '//u', $text, -1, PREG_SPLIT_NO_EMPTY ) as $ch ) {
        $cp  = mb_ord( $ch, 'UTF-8' );
        $one = ( $cp <= 0x10FF )
            || ( $cp >= 0x2000 && $cp <= 0x200D )
            || ( $cp >= 0x2010 && $cp <= 0x201F )
            || ( $cp >= 0x2032 && $cp <= 0x2037 );
        $w += $one ? 1 : 2;
    }
    return $w;
}
書き方 役目
preg_replace_callback( '#https?://\S+#', … ) URL を見つけたら 23 を足して、文から外す
preg_split( '//u', … ) 残りの文を 1 字ずつに分ける(u で UTF-8 として扱う)
mb_ord() 1 字の文字コードを数にする
$cp <= 0x10FF など 英字や記号など、X が 1 と数える範囲。それ以外は 2 にする

文を作るときは、まず記事のタイトル・要点・補足などの後ろの部分・案内の文をすべて入れて重みを数えます。280 をこえたら、後ろの部分を外してもう一度数え、それでも入らない記事は飛ばして次の記事にしています。途中で文を切ると読みにくくなるので、切らずに部分ごと外す形にしました。

作業環境の PHP でこの関数を動かし、いくつかの文を数えた結果が次のとおりです。

数えた文 文字の数 重み
Hello 5 5
こんにちは 5 10
WordPress のカスタマイズ 17 24
https://example.com/ 20 23
https://example.com/archives/12345/?utm_source=x&utm_medium=social&utm_campaign=daily 85 23
📖(絵文字 1 つ) 1 2
「改行」+改行+「あり」 5 9
「あ」を 140 字 140 280
「あ」を 141 字 141 282

URL は長くても 23 のままなので、記事の URL に計測のための値を付けても、文に入る長さは変わりません。改行は 1 と数えます。

料金に合わせて、リンクは週 1 回にした

X API の料金は、2026 年 10 月に料金のページを見たときは、無料の枠がなく、使った分だけ払う形でした。投稿を作る料金は、リンクなしが 1 本 $0.015、リンクありが 1 本 $0.20 と、リンクの有無で大きく違います。

毎日リンクつきで出すと、1 週間で $1.40 になりますが、日曜だけリンクつきにして、ほかの日はリンクなしにすると、1 週間で $0.29($0.015 × 6 本 + $0.20 × 1 本)に下がります。そのため、リンクは週 1 回(日曜)だけにして、ほかの日は「詳しくはプロフィールのリンクから」のような文にしています。料金は変わることがあるので、作るときは料金のページで今の値を確認してください。

リンクを付ける日は、記事の URL に utm_source=x&utm_medium=social&utm_campaign=… を付けています。こうしておくと、GA4 で X の投稿から来た人を、ほかのアクセスと分けて数えられます。

my-x-daily.php(リンクに計測の値を付ける)
$url = add_query_arg(
    array(
        'utm_source'   => 'x',
        'utm_medium'   => 'social',
        'utm_campaign' => 'my_daily',
    ),
    get_permalink( $post_id )
);

サーバーの cron で毎朝動かす

サーバーの cron に、このファイルをコマンドラインの PHP で動かす設定を入れます。エックスサーバーでは、サーバーパネルの「Cron 設定」で、PHP のバージョンを決めて /usr/bin/php8.3 のように呼びます。バージョンを書かずに php とだけ書くと、サイトで使っているものと違うバージョンの PHP で動くことがあるので、バージョンつきで書きます。

cron のコマンド(毎朝 8 時の例)
0 8 * * * /usr/bin/php8.3 (WordPress のフォルダ)/wp-content/themes/(テーマのフォルダ)/cli/my-x-daily.php

動かした結果は、標準出力に 1 行で出しておくと、cron のメールで受け取れます。失敗したときは PHP のエラーログにも同じ行を書いておくと、あとから探しやすくなります。

つまずき:echo の出力が一部消えた

このファイルをコマンドラインで動かして試していたところ、echo で出した文の一部が消えていました。調べてみると、ページの出力を最後にまとめて書き換えるプラグイン(空の行をまとめる処理)が、WordPress を読み込んだこのスクリプトの出力にも効いていたのが原因でした。

WordPress を読み込むと、プラグインも読み込まれ、出力をためて書き換える仕組みも動きます。そこで、出力は echo ではなく fwrite( STDOUT, … ) で標準出力に直接書くようにしました。こうすると、出力をためる仕組みを通らないので、書いたとおりに出ます。

my-x-daily.php(出力の関数)
function my_x_out( $msg, $is_error = false ) {
    $line = '[' . wp_date( 'Y-m-d H:i:s' ) . '] my-x-daily ' . $msg;
    fwrite( STDOUT, $line . "\n" );
    if ( $is_error ) {
        error_log( $line );
    }
}
書き方 役目
fwrite( STDOUT, … ) 出力をためる仕組みを通さず、標準出力に直接書く
wp_date() サイトのタイムゾーンで時刻を付ける
error_log() 失敗したときは、PHP のエラーログにも残す

気をつけること

  • X への送信は 1 回ごとにお金がかかるので、作るときは投稿せずに文だけを出す形で何度も確認し、送るのは最後にします
  • この重みを数える関数は、作りの上で、絵文字をいくつかつなげて 1 つに見せているもの(家族の絵文字など)を多めに数えます。多めに数えるぶんには、X で長すぎてはじかれることはありません
  • 鍵は公開されないフォルダに置き、ログやエラーの文にも値を出さないようにします
  • 料金や数え方は変わることがあります。作るときは、X の開発者のページで今の決まりを確認してください

まとめ

動かし始めたら、GA4 で utm_source=x の付いたアクセスを週ごとに見ます。リンクの日に来る人の数を見て、リンクを週 1 回のままにするかを決めます。

参考

関連機能

  1. WordPress予約フォームをSelectTypeで作成してページに埋め込む ( 無料版でお試し )

  2. 【終了】WP LINE Notify は使えません:WordPress のお問い合わせを LINE に知らせる今の方法(Messaging API)

  3. WordPress XPosterプラグインとカスタム投稿を使用してX(twitter)を予約投稿で自動連携してみる

サイドバー

よく使うカスタマイズ

最近の記事

Profile

PAGE TOP