WordPress記事をThreadsに自動投稿する方法【コード版】|functions.phpで作る仕組みと、ハマった落とし穴

WordPress記事をThreadsに自動投稿する方法【コード版】|functions.phpで作る仕組みと、ハマった落とし穴
まめすけ

「WordPressの記事、公開したのにThreadsで一言も紹介できてない…」そんな状態になってない?

k-suke

私の場合、プラグインがSNSとの連携エラーで止まっていたんだけどね・・・

まめすけ

今日は一緒に、プラグインなしで自動投稿を組み直した記録を見ていこう。

ちなみにこの話は前編(コード版)。
コードを書かずに導入したい人向けの後編(プラグイン版)はこちら。

前編(本記事):コード版 — 子テーマのfunctions.phpに直接組み込む方法。仕組みを理解しながら自由にカスタマイズしたい人向け。
後編:プラグイン版 — ファイル1つを有効化するだけで動く方法。コードを触りたくない人向け(近日公開)。

WordPressサイトの保守を専門にしているk-suke(当サイト運営者)です。自分のブログ「ウェブlog」でも、記事公開と同時にThreadsへプラグインで自動投稿する仕組みを運用していました。が、ある日投稿されていないことに気が付き、原因を調べて作り直した過程を、そのまま記事にしました。

目次

あなたのサイト、こんなことになっていませんか?

  • 記事は公開しているのに、SNS連携プラグインが動いているか確認したことがない
  • プラグインの管理画面で「連携エラー」の表示に気づかず放置している
  • SNS自動投稿を「設定したら終わり」だと思っている
  • エラー通知の仕組みがなく、止まっていても気づけない
  • プラグインはできるだけ減らしたい

1つでも当てはまるなら、この記事は役に立つはずです。

WordPress記事をThreadsに自動投稿するにはどうすればいい?

結論から言うと、プラグインに頼らず、WordPress自体に「記事公開時にThreads APIを直接呼び出す」処理を持たせるのが一番安定します。

具体的には、子テーマのfunctions.php(またはプラグイン化したファイル)に、記事公開のタイミングで動くフックを1つ仕込むだけです。プラグイン更新のたびに接続が切れる心配がなく、仕組みが単純なので障害箇所も追いやすくなります。以下、実際にどう組んだか順を追って説明します。

必要なthreadsトークンなどの取得方法も説明していますので心配はいりません。

プラグインを使った自動投稿は、なぜ気づかないうちに壊れるのか?

私自身、以前はUncanny Automatorというプラグインで「記事公開→Threads投稿」を自動化していました。ところが6月頃、プラグインとThreads側の連携にエラーが発生。当時は忙しく、いったんプラグインを削除してそのままにしていました。

結果、2ヶ月近くThreadsへの投稿が完全に止まっていたのに、誰にも(自分にも)気づかれない状態が続いていたんです。(お恥ずかしい・・・)

この経験からわかったのは、プラグイン任せの自動化には次のような弱点があるということです。

  • 連携先API(Meta / Threads側)の仕様変更にプラグイン側の対応が追いつかないことがある
  • エラーが起きても、プラグインによっては通知機能がなく気づけない
  • プラグインの更新・停止・削除のたびに再設定が必要になる

「便利だから」と外部プラグインに投稿トリガーを預けると、壊れたときに気づく手段まで一緒に失うリスクがあります。

※ここで問題にしているのは、汎用の自動化プラグイン(Uncanny Automatorなど、多機能ゆえに依存先が多いタイプ)です。今回のように「Threads投稿専用」に絞って自分で作った軽量プラグインなら話は別で、この構造上の弱点は当てはまりません。後編ではその作り方を紹介します。

プラグインなしでThreads自動投稿を作るには、具体的に何をすればいい?

始める前に用意するもの

このコードをそのまま動かすには、事前に次の3つが必要です。順番に用意してください。

STEP
Threads APIのアクセストークン・ユーザーID
STEP
トークン自動更新の仕組み(GASウェブアプリURL)
STEP
合言葉(Webhookシークレット)

GAS編の中で自分で決める、ランダムな文字列です

この3つが揃ったら、以下の手順で組み込みます。

Step1:wp-config.phpに4つの値を追加

WordPressのルート直下にあるwp-config.phpを開き、$table_prefixの行より前に以下を追加してください。

define('THREADS_ACCESS_TOKEN', 'xxxxxxxxxx');   // Meta申請編で取得したトークン
define('THREADS_USER_ID', 'xxxxxxxxxx');         // Meta申請編で確認したユーザーID
define('THREADS_WEBHOOK_SECRET', 'ランダムな長い文字列');       // GAS編で決めた合言葉
define('THREADS_GAS_WEBAPP_URL', 'https://script.google.com/macros/s/xxxxx/exec'); // GAS編で発行したURL

Step2:子テーマのfunctions.phpにコードを追加

以下がコード全文です。子テーマのfunctions.phpの末尾に、そのまま追記してください(すでに<?phpがファイル冒頭にある場合は、それと重複させないよう本文だけを追加します)。

// ---- 1. 公開イベントを検知して非同期投稿を予約 ----
add_action('transition_post_status', 'weblog_schedule_threads_post', 10, 3);

function weblog_schedule_threads_post($new_status, $old_status, $post) {
    if ($new_status !== 'publish' || $old_status === 'publish') {
        return;
    }
    if ($post->post_type !== 'post') {
        return;
    }
    if (get_post_meta($post->ID, '_threads_posted', true)) {
        return;
    }

    wp_schedule_single_event(time() + 5, 'weblog_threads_post_event', array($post->ID));
}

// ---- 2. 実際にThreadsへ投稿する処理 ----
add_action('weblog_threads_post_event', 'weblog_execute_threads_post');

function weblog_execute_threads_post($post_id) {
    if (get_post_meta($post_id, '_threads_posted', true)) {
        return;
    }

    $token = weblog_get_threads_access_token();
    if (empty($token) || !defined('THREADS_USER_ID')) {
        error_log('[Threads自動投稿] トークン未設定のため投稿をスキップしました。post_id=' . $post_id);
        return;
    }

    $post = get_post($post_id);
    if (!$post || $post->post_status !== 'publish') {
        return;
    }

    $text = weblog_build_threads_text($post);
    $result = weblog_post_to_threads($text, $token);

    $record = array(
        'time'   => current_time('mysql'),
        'title'  => get_the_title($post_id),
        'url'    => get_permalink($post_id),
        'status' => $result['success'] ? '成功' : '失敗',
        'detail' => $result['success'] ? ('thread_id: ' . $result['id']) : $result['error'],
    );
    update_option('threads_last_result', $record);

    if ($result['success']) {
        update_post_meta($post_id, '_threads_posted', current_time('mysql'));
        error_log('[Threads自動投稿] 投稿成功 post_id=' . $post_id . ' thread_id=' . $result['id']);
    } else {
        error_log('[Threads自動投稿] 投稿失敗 post_id=' . $post_id . ' エラー=' . $result['error']);
        wp_mail(
            get_option('admin_email'),
            '[' . get_bloginfo('name') . '] Threads自動投稿に失敗しました',
            "記事「" . $record['title'] . "」のThreads自動投稿に失敗しました。\n\n" .
            "エラー内容: " . $result['error'] . "\n\n" .
            "記事URL: " . $record['url'] . "\n\n" .
            "※手動でThreadsに投稿するか、管理画面「設定 > Threads自動投稿」で状況を確認してください。"
        );
    }
}

// ---- 3. 投稿文の組み立て ----
// ※ Threadsは1投稿につき「トピックタグ」を1つしか認識しない仕様のため
//   (2つ目以降は#記号付きのただの文字列として残るだけで機能しない)、
//   タグは1つだけに絞る。カテゴリがあればカテゴリ名を、無ければ既定値を使う。
function weblog_build_threads_text($post) {
    $title = get_the_title($post);
    $url = get_permalink($post);

    $tag = '#WordPress';
    $categories = get_the_category($post->ID);
    if (!empty($categories)) {
        $clean_name = preg_replace('/[\s ・\//\-]/u', '', $categories[0]->name);
        if ($clean_name !== '') {
            $tag = '#' . $clean_name;
        }
    }

    return "{$title}\n\n{$url}\n\n{$tag}";
}

// ---- 4. アクセストークンの取得(wp_options優先、なければwp-config.phpの初期値) ----
function weblog_get_threads_access_token() {
    $option_token = get_option('threads_access_token');
    if (!empty($option_token)) {
        return $option_token;
    }
    return defined('THREADS_ACCESS_TOKEN') ? THREADS_ACCESS_TOKEN : '';
}

// ---- 5. Threads API呼び出し(コンテナ作成 → 公開の2ステップ) ----
function weblog_post_to_threads($text, $token) {
    $base = 'https://graph.threads.net/v1.0';
    $user_id = THREADS_USER_ID;

    $create_response = wp_remote_post("{$base}/{$user_id}/threads", array(
        'timeout' => 15,
        'body' => array(
            'media_type' => 'TEXT',
            'text' => $text,
            'access_token' => $token,
        ),
    ));

    if (is_wp_error($create_response)) {
        return array('success' => false, 'error' => $create_response->get_error_message());
    }

    $create_body = json_decode(wp_remote_retrieve_body($create_response), true);
    if (empty($create_body['id'])) {
        return array('success' => false, 'error' => wp_remote_retrieve_body($create_response));
    }
    $creation_id = $create_body['id'];

    sleep(3); // Meta推奨:コンテナ作成後、公開前に少し待機

    $publish_response = wp_remote_post("{$base}/{$user_id}/threads_publish", array(
        'timeout' => 15,
        'body' => array(
            'creation_id' => $creation_id,
            'access_token' => $token,
        ),
    ));

    if (is_wp_error($publish_response)) {
        return array('success' => false, 'error' => $publish_response->get_error_message());
    }

    $publish_body = json_decode(wp_remote_retrieve_body($publish_response), true);
    if (empty($publish_body['id'])) {
        return array('success' => false, 'error' => wp_remote_retrieve_body($publish_response));
    }

    return array('success' => true, 'id' => $publish_body['id']);
}

// ---- 6. WP-CronでGASのWebアプリへ定期的に問い合わせ、トークンを取得する(PULL方式) ----
register_activation_hook(__FILE__, 'weblog_activate_poll_schedule');
function weblog_activate_poll_schedule() {
    if (!wp_next_scheduled('weblog_poll_token_event')) {
        wp_schedule_event(time(), 'daily', 'weblog_poll_token_event');
    }
}

register_deactivation_hook(__FILE__, 'weblog_deactivate_poll_schedule');
function weblog_deactivate_poll_schedule() {
    wp_clear_scheduled_hook('weblog_poll_token_event');
}

add_action('init', function () {
    if (!wp_next_scheduled('weblog_poll_token_event')) {
        wp_schedule_event(time(), 'daily', 'weblog_poll_token_event');
    }
});

add_action('weblog_poll_token_event', 'weblog_poll_gas_token');

function weblog_poll_gas_token() {
    if (!defined('THREADS_GAS_WEBAPP_URL') || !defined('THREADS_WEBHOOK_SECRET')) {
        error_log('[Threads自動投稿] THREADS_GAS_WEBAPP_URL または THREADS_WEBHOOK_SECRET が未設定です。');
        return;
    }

    $url = add_query_arg('secret', rawurlencode(THREADS_WEBHOOK_SECRET), THREADS_GAS_WEBAPP_URL);
    $response = wp_remote_get($url, array('timeout' => 15));

    if (is_wp_error($response)) {
        weblog_notify_poll_failure($response->get_error_message());
        return;
    }

    $code = wp_remote_retrieve_response_code($response);
    $body = json_decode(wp_remote_retrieve_body($response), true);

    if ($code !== 200 || empty($body['access_token'])) {
        weblog_notify_poll_failure('HTTPステータス: ' . $code . ' / 応答: ' . wp_remote_retrieve_body($response));
        return;
    }

    $current = get_option('threads_access_token');
    if ($current !== $body['access_token']) {
        update_option('threads_access_token', $body['access_token']);
        update_option('threads_token_updated_at', current_time('mysql'));
        update_option('threads_poll_last_checked_at', current_time('mysql'));

        wp_mail(
            get_option('admin_email'),
            '[' . get_bloginfo('name') . '] Threadsトークンを自動反映しました',
            "GASで更新された新しいアクセストークンをWordPressに反映しました。\n日時: " . current_time('mysql')
        );
    } else {
        update_option('threads_poll_last_checked_at', current_time('mysql'));
    }
}

function weblog_notify_poll_failure($detail) {
    $last_notified = get_option('threads_poll_failure_notified_at');
    if ($last_notified && (time() - strtotime($last_notified)) < DAY_IN_SECONDS) {
        error_log('[Threads自動投稿] GASへの問い合わせ失敗(通知は24時間以内のためスキップ): ' . $detail);
        return;
    }
    update_option('threads_poll_failure_notified_at', current_time('mysql'));
    error_log('[Threads自動投稿] GASへの問い合わせ失敗: ' . $detail);

    wp_mail(
        get_option('admin_email'),
        '[' . get_bloginfo('name') . '] Threadsトークン取得(GAS連携)に失敗しました',
        "WordPressからGASのWebアプリへのトークン取得に失敗しました。\n\n詳細: " . $detail .
        "\n\nTHREADS_GAS_WEBAPP_URL の設定や、GAS側のデプロイ状態を確認してください。"
    );
}

// ---- 7. 手動で「今すぐGASに問い合わせる」を実行するハンドラ ----
add_action('admin_post_weblog_manual_poll_token', 'weblog_handle_manual_poll_token');

function weblog_handle_manual_poll_token() {
    if (!current_user_can('manage_options')) {
        wp_die('権限がありません。');
    }
    check_admin_referer('weblog_manual_poll_token_action');

    weblog_poll_gas_token();

    wp_safe_redirect(add_query_arg('polled', '1', admin_url('options-general.php?page=weblog-threads-status')));
    exit;
}

// ---- 8. 管理画面にステータス表示ページを追加(設定 > Threads自動投稿) ----
add_action('admin_menu', function () {
    add_options_page(
        'Threads自動投稿',
        'Threads自動投稿',
        'manage_options',
        'weblog-threads-status',
        'weblog_render_threads_status_page'
    );
});

function weblog_render_threads_status_page() {
    $token_updated_at = get_option('threads_token_updated_at', '未実行(wp-config.phpの初期トークンを使用中)');
    $expires_in = get_option('threads_token_expires_in');
    $last_result = get_option('threads_last_result', array());
    ?>
    <div class="wrap">
        <h1>Threads自動投稿 ステータス</h1>

        <?php if (isset($_GET['polled'])): ?>
        <div class="notice notice-info is-dismissible"><p>GASへの問い合わせを実行しました。</p></div>
        <?php endif; ?>

        <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin:16px 0;">
            <?php wp_nonce_field('weblog_manual_poll_token_action'); ?>
            <input type="hidden" name="action" value="weblog_manual_poll_token">
            <?php submit_button('今すぐGASに問い合わせる', 'secondary', 'submit', false); ?>
        </form>

        <h2>トークン</h2>
        <table class="widefat striped" style="max-width:700px;">
            <tr><th style="width:200px;">最終更新</th><td><?php echo esc_html($token_updated_at); ?></td></tr>
            <tr><th>最終チェック(GASへの問い合わせ)</th><td><?php echo esc_html(get_option('threads_poll_last_checked_at', '-')); ?></td></tr>
            <?php if ($expires_in): ?>
            <tr><th>有効期限(更新時点から)</th><td><?php echo esc_html(round($expires_in / 86400)); ?>日</td></tr>
            <?php endif; ?>
        </table>

        <h2 style="margin-top:24px;">直近の投稿</h2>
        <table class="widefat striped" style="max-width:700px;">
            <tr><th style="width:200px;">記事タイトル</th><td><?php echo esc_html($last_result['title'] ?? '-'); ?></td></tr>
            <tr><th>結果</th><td><?php echo esc_html($last_result['status'] ?? '-'); ?></td></tr>
            <tr><th>詳細</th><td><?php echo esc_html($last_result['detail'] ?? '-'); ?></td></tr>
            <tr><th>日時</th><td><?php echo esc_html($last_result['time'] ?? '-'); ?></td></tr>
        </table>
    </div>
    <?php
}

// ---- 9. 直近の投稿が失敗している場合、管理画面上部に注意バナーを表示 ----
add_action('admin_notices', function () {
    $last_result = get_option('threads_last_result', array());
    if (!empty($last_result['status']) && $last_result['status'] === '失敗') {
        echo '<div class="notice notice-error"><p><strong>Threads自動投稿が失敗しました:</strong>「'
            . esc_html($last_result['title']) . '」 - ' . esc_html($last_result['detail'])
            . ' <a href="' . esc_url(admin_url('options-general.php?page=weblog-threads-status')) . '">詳細を見る</a></p></div>';
    }
});

Step3:動作確認

コードを追加したら、管理画面に「設定 > Threads自動投稿」というメニューが新しく表示されます。まず「今すぐGASに問い合わせる」ボタンを押し、トークンが正しく取得できるか確認してください。そのうえで、テスト用の記事を1本公開し、実際にThreadsへ投稿されるか確認しましょう。

「海外からのアクセス制限」を有効にしていると、何が起きる?

ここが今回いちばんハマったポイントです。多くのレンタルサーバー(当サイトも含む)には、セキュリティ対策として「海外IPアクセス制限」という機能があります。

まめすけ

これを有効にしていると、海外のサーバーから自分のWordPressへアクセスする通信は、正規のリクエストであっても軒並みブロックされちゃうんだ。

Google Apps Script(GAS)はアメリカのサーバーから動くため、この制限にまともに引っかかりました。最初はRSSフィードをGASから定期取得する設計にしていたのですが、実行するたびに501 Not Implementedというエラーに。原因を切り分けるためにステータスコードと応答本文をログ出力してみると、レンタルサーバーのエラーページがそのまま返ってきていることが判明しました。

「じゃあ発信元を変えればいいのでは」と、今度はGASからWordPressへ直接データを送りつける(Webhook)方式に変更しました。ところがこれも同じ501エラー。よく考えれば当然で、方向を変えても「海外→自サーバーへの受信」という構造は変わっていなかったんです。

最終的にたどり着いた解決策はシンプルでした。

GAS(海外)→ WordPress(受信)… 海外アクセス制限で必ずブロックされる

WordPress(発信)→ GAS(海外含む)… 発信方向なので制限を受けない

「受信」ではなく「発信」にすればいい。WordPress側からWP-Cronで1日1回、GASに「トークン変わってる?」と問い合わせに行く方式(PULL型)に組み替えたところ、あっさり解決しました。セキュリティ設定を一切緩めずに済んだのも収穫です。

Threadsのアクセストークンは、どう自動更新すればいい?

Threadsの長期アクセストークンには60日の有効期限があります。

放置すればいずれ投稿が失敗するので、GAS側に月1回のトリガーを設定し、トークンをリフレッシュする関数を用意しました。更新後は、前述のPULL方式でWordPressが取得しに行く流れです。

加えて、次の仕組みも組み込みました。

  • トークン更新の成功/失敗をメールで通知(GAS側)
  • 記事投稿が失敗した場合も管理者へメール通知+管理画面に警告バナー表示
  • WordPress管理画面に「設定 > Threads自動投稿」ページを新設し、トークンの最終更新日時・直近の投稿結果を一覧表示
  • 1日待たずにその場で確認できる「今すぐGASに問い合わせる」ボタンも設置

これで「動いているはずなのに実は止まっていた」という、今回の発端になった事故は起きにくくなりました。

実装中に見つかった、地味だけど刺さる落とし穴

最後に、伝えておくべきだなと感じた小さなハマりポイントを共有します。

  • WordPressのadd_query_arg()は値を自動エンコードしない。合言葉(シークレット)に+記号を使っていたところ、URLに組み込む際に空白へ化けてしまい認証エラーに。rawurlencode()で明示的にエンコードする必要があった
  • WordPressのRSSフィードは、必ずしも正規のXMLとして解析できるとは限らない。本文中に閉じタグ不備のHTMLが混ざっていると、厳密なXMLパーサーはそこで例外を吐いて全体が読めなくなる。正規表現ベースの緩い抽出に切り替えると解決した
  • Threads APIは「コンテナ作成」と「公開」が別リクエスト。間に3秒ほど待機を挟まないと、公開ステップが失敗することがある(Meta公式の推奨事項)

まとめ|プラグイン頼みの自動化は、壊れたときに気づけるかが分かれ目

プラグインでの自動化自体が悪いわけではありません。

ただ、「止まっても気づけない」状態は避けるべきです。今回のようにフックとAPIを直接叩く方式にすれば、依存先が減り、通知の仕組みも自分で設計できます。もし同じように「SNS自動投稿、いつの間にか止まってた」という経験があれば、見直しのタイミングかもしれません。

同じ内容をもっと優しく可能にするプラグインも用意してみました。


同じようなお悩み、ありませんか?

この記事のような不具合・お困りごとは、実際に状況を見た上でご提案します。
まずは無料相談から、お気軽にどうぞ。

このサイトはテーマ「SWELL」を使っています

「SWELL」はWordpress初心者も慣れた人も使い続けられるテーマだと思います。

よかったらシェアお願いします!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

WordPressでサイトを作り、壊し、また作る。
フリーランスとしてココナラで実案件に向き合いながら、その経験をそのまま言葉にしています。
うまくいった設定も、試したら失敗した話も、ぜんぶここに書いています。
すでにWordPressを持っていて、もう一歩進めたい人に届けば。

コメント

コメントする

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)

目次