PHP

WordPressプラグインからAI APIを呼ぶ方法|PHPのwp_remote_postで管理画面に実装

PHPだけでWordPress管理画面からAI APIを呼ぶ最小プラグイン。APIキーの保持、権限・nonce・入力検査、wp_remote_post、Responses APIの本文取得、タイムアウトの扱いをコードで説明します。

この記事の目次
  1. 通信は「ブラウザー→WordPress→AI API」の順にする
  2. サーバー側へAPIキーとモデルIDを設定する
  3. 1ファイルの管理画面プラグインを作る
  4. 生のJSONではoutput配列を読んで本文を取り出す
  5. 通信失敗・HTTPエラー・生成未完了を分ける
  6. nonceとトークン上限だけでは、利用回数を制限できない
  7. 実APIを使う前に、失敗する応答で画面を確かめる
  8. 次に知りたいこと

WordPressの管理画面に「この短文をAIで整える」ボタンを付けたい。Node.jsの別サーバーを用意しなくても、PHPのwp_remote_post()からAI APIへJSONを送信できます。キーはサーバー側に置き、管理者の権限とnonceを確認してから呼び出します。

ここでは、管理者が入力した短文をOpenAIのResponses APIへ送り、返った文章を画面へ表示するプラグインを1ファイルで作ります。同期処理なので、送信後は結果が返るまで待ちます。少人数の試作から始め、待ち時間や利用回数が増えたらジョブ化を検討する構成です。

通信は「ブラウザー→WordPress→AI API」の順にする

ブラウザーが送るのは、本文とWordPressのnonceです。APIキーをHTML、JavaScript、hiddenフィールドへ置きません。WordPress側だけが認証ヘッダーを作り、固定したAPIのURLへ送信します。

  1. 管理者が「ツール → AI整文」を開く。
  2. 本文をPOSTする。PHPでmanage_optionsとnonceを確認する。
  3. 入力の型・長さを検査し、wp_remote_post()でAI APIを呼ぶ。
  4. HTTPステータス、JSON、生成の完了状態、本文を順に確認する。
  5. 結果をHTMLとして実行せず、エスケープして表示する。

WordPressのnonceはCSRF対策に使えますが、認証・権限確認や1回限りの送信保証にはなりません。コードでは current_user_can('manage_options') を別に確認します。ログインさえすれば誰でも使える作りにはしません。

スポンサーリンク

サーバー側へAPIキーとモデルIDを設定する

PHPから読める環境変数にDS_AI_API_KEYとDS_AI_MODELを用意し、wp-config.phpのWordPress読み込みより前に次の定義を追加します。既存ファイルのPHP開始タグは重ねません。

<?php
// wp-config.phpのWordPress読込より前へ追加(既存のPHP開始タグは重ねない)。
define('DS_AI_API_KEY', getenv('DS_AI_API_KEY') ?: '');
define('DS_AI_MODEL', getenv('DS_AI_MODEL') ?: '');

DS_AI_MODELには、利用するAPIプロジェクトで使用可能なResponses対応モデルIDを設定してください。モデルの利用可否はアカウント側で確認します。シェルで設定した環境変数が、そのままPHP-FPMやApacheへ渡るとは限らないため、利用環境の設定方法に合わせます。

APIキーの値を確認するためにechoやvar_dumpで表示しないでください。設定がない場合は、このプラグインが値を出さずに設定不足を知らせます。wp-config.phpやサーバー設定、バックアップの閲覧権限も限定します。

1ファイルの管理画面プラグインを作る

wp-content/plugins/ds-ai-note/ds-ai-note.phpを作り、以下を保存します。ステージングの管理画面で有効化すると、「ツール」に「AI整文」が追加されます。PHP 8.1以上を前提にした例です。

<?php
/**
 * Plugin Name: DS AI Note Example
 * Description: 管理者が短い文章を整える同期型の教材。
 * Version: 1.0.0
 * Requires PHP: 8.1
 */
if (!defined('ABSPATH')) { exit; }

add_action('admin_menu', function () {
    add_management_page('AI整文', 'AI整文', 'manage_options', 'ds-ai-note', 'ds_ai_note_page');
});

function ds_ai_note_request(array $form) {
    if (!current_user_can('manage_options')) {
        return new WP_Error('forbidden', 'この操作を実行する権限がありません。');
    }
    $nonce = $form['_wpnonce'] ?? null;
    if (!is_string($nonce) || !wp_verify_nonce($nonce, 'ds_ai_note')) {
        return new WP_Error('nonce', '画面を開き直してから送信してください。');
    }
    $text = $form['text'] ?? null;
    if (!is_string($text) || wp_check_invalid_utf8($text) !== $text) {
        return new WP_Error('input', '本文はUTF-8の文字列で入力してください。');
    }
    $text = trim($text);
    if ($text === '' || strlen($text) > 3000) {
        return new WP_Error('input', '本文は空でない3000バイト以内の文字列にしてください。');
    }
    if (!defined('DS_AI_API_KEY') || !is_string(DS_AI_API_KEY) || DS_AI_API_KEY === ''
        || !defined('DS_AI_MODEL') || !is_string(DS_AI_MODEL) || DS_AI_MODEL === '') {
        return new WP_Error('config', 'サーバー側のAPIキーとモデルIDを設定してください。');
    }
    $response = wp_remote_post('https://api.openai.com/v1/responses', [
        'timeout' => 30,
        'redirection' => 0,
        'sslverify' => true,
        'limit_response_size' => 256000,
        'headers' => [
            'Authorization' => 'Bearer ' . DS_AI_API_KEY,
            'Content-Type' => 'application/json',
        ],
        'body' => wp_json_encode([
            'model' => DS_AI_MODEL,
            'instructions' => '入力文を読みやすい日本語に整えてください。事実を足さず、整えた文章だけを返してください。',
            'input' => $text,
            'max_output_tokens' => 512,
            'store' => false,
        ]),
    ]);
    if (is_wp_error($response)) {
        return new WP_Error('transport', '通信結果を確認できません。自動再送はしていません。');
    }
    $status = wp_remote_retrieve_response_code($response);
    if ($status !== 200) {
        return new WP_Error('http', 'AI APIがHTTP ' . (int) $status . 'を返しました。');
    }
    $data = json_decode(wp_remote_retrieve_body($response), true);
    if (!is_array($data) || json_last_error() !== JSON_ERROR_NONE) {
        return new WP_Error('json', 'AI APIのJSONを読み取れませんでした。');
    }
    if (($data['status'] ?? '') !== 'completed') {
        return new WP_Error('incomplete', '生成が完了していません。部分的な出力は表示しません。');
    }
    if (!isset($data['output']) || !is_array($data['output'])) {
        return new WP_Error('shape', 'AI APIの出力形式を確認できませんでした。');
    }
    $texts = [];
    foreach ($data['output'] as $item) {
        if (!is_array($item) || ($item['type'] ?? '') !== 'message') { continue; }
        if (($item['role'] ?? '') !== 'assistant' || !is_array($item['content'] ?? null)) { continue; }
        foreach ($item['content'] as $part) {
            if (!is_array($part)) { continue; }
            if (($part['type'] ?? '') === 'refusal') {
                return new WP_Error('refusal', 'この入力への文章生成は行われませんでした。');
            }
            if (($part['type'] ?? '') === 'output_text' && is_string($part['text'] ?? null)) {
                $texts[] = $part['text'];
            }
        }
    }
    $answer = trim(implode("\n", $texts));
    if ($answer === '' || strlen($answer) > 12000 || wp_check_invalid_utf8($answer) !== $answer) {
        return new WP_Error('output', '表示できる長さ・形式の回答がありませんでした。');
    }
    return $answer;
}

function ds_ai_note_page() {
    if (!current_user_can('manage_options')) { wp_die('権限がありません。', '', ['response' => 403]); }
    $result = null;
    if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
        $result = ds_ai_note_request(wp_unslash($_POST));
    }
    echo '<div class="wrap"><h1>AI整文</h1>';
    echo '<p>入力文をOpenAIへ送信します。送信してよい文章だけを入力してください。</p>';
    echo '<p>1回ごとにAPI呼び出しが発生します。結果画面を再読み込みしてPOSTを再送しないでください。</p>';
    echo '<form method="post" action="' . esc_url(admin_url('tools.php?page=ds-ai-note')) . '">';
    wp_nonce_field('ds_ai_note');
    echo '<label for="ds-ai-text">整えたい短文(3000バイト以内)</label><br>';
    echo '<textarea id="ds-ai-text" name="text" rows="6" class="large-text" required></textarea>';
    submit_button('OpenAIへ送信して整文する');
    echo '</form>';
    if (is_wp_error($result)) {
        echo '<p role="alert">' . esc_html($result->get_error_message()) . '</p>';
    } elseif (is_string($result)) {
        echo '<h2>整文結果</h2><pre style="white-space:pre-wrap;overflow-wrap:anywhere">' . esc_html($result) . '</pre>';
    }
    echo '</div>';
}

フォームからはキーやモデルIDを受け取らず、URLも固定しています。redirection=0でリダイレクトを追わず、TLS証明書の検証を有効のままにしています。接続失敗を直すためにsslverifyをfalseへ変えるのではなく、サーバーのCA証明書や外向き通信設定を確認してください。

入力上限は3000文字ではなく3000バイトです。日本語は1文字が複数バイトになるため、英数字と同じ文字数は入りません。PHP側で判定し、空欄、配列、不正なUTF-8も送信前に拒否します。

出力は esc_html() で表示します。AIがscriptタグを返してもコードとして実行しません。入力も出力も自動で記事へ保存する処理はなく、整文結果を読んで使う画面です。整文の指示を書いていても、事実が変わっていないかは原文と照合してください。

生のJSONではoutput配列を読んで本文を取り出す

OpenAIのSDK例にある response.output_text は、SDKが本文を集約する便利なプロパティです。PHPからHTTPで受けたJSONでは、同じ場所に本文があると想定しません。Text generationの説明のとおり、outputには推論などの項目が先に入る場合もあります。

コードではassistantのmessageを探し、そのcontentにあるoutput_textを集めます。refusalがあれば生成を断られた状態として扱い、completedでない結果や空の本文も成功表示にしません。

通信失敗・HTTPエラー・生成未完了を分ける

wp_remote_post()は、通信自体の失敗ではWP_Errorを返します。一方、HTTP 401や429が返った場合は、HTTP応答を受け取れているためステータスコードで判定します。

画面の状態 確認すること
権限がない 利用者がmanage_optionsを持つか。nonceの問題と分ける
画面を開き直す案内 nonceの期限、ログイン状態、古い画面からの送信
設定不足 PHPプロセスにキーとモデルIDが渡っているか
通信結果を確認できない 外向きHTTPS、DNS、CA証明書、PHP/プロキシの待ち時間
HTTP 401・403 キー、APIプロジェクトのアクセス権限などを提供元で確認
HTTP 429 利用枠やレート制限などの状況を提供元で確認。連打で再送しない
HTTP 400 モデルIDと送信パラメーターの対応を確認
生成未完了・本文なし 上限や応答形式を確認。途中の文章を完成稿とみなさない

この例では、提供元のエラー本文やAuthorizationヘッダーを画面・ログへ出しません。詳しい診断用の記録を追加する場合も、送信本文やキーを丸ごと記録せず、必要なステータスやリクエスト識別子に絞ります。

タイムアウトは「AI側で処理されていない」という意味ではありません。WordPressが待つのをやめても、提供元で処理が進んでいる可能性があります。この例は自動再送しません。失敗を見てすぐ何度も押すと、別の生成リクエストが増えることがあります。

nonceとトークン上限だけでは、利用回数を制限できない

max_output_tokensの512は出力の上限指定で、512文字という意味ではありません。推論を行うモデルでは、推論用のトークンも考慮する必要があります。小さすぎる上限では本文が得られないこともあるため、採用モデルと短いサンプルで確認します。

このフォームには、厳密な二重送信防止や利用回数の上限はありません。POST結果画面を再読み込みして再送した場合も、新たな呼び出しになります。複数人が継続利用する前に、サーバー側の一意なジョブID、同時実行の制御、利用者ごとの回数制限を追加します。

store=falseは生成レスポンスを後でAPIから取得するために保存するかどうかの指定です。通信先で一切のデータが保持されないことを保証する指定とは扱わず、送信対象は利用組織の方針と提供元のデータ管理条件に合わせます。

実APIを使う前に、失敗する応答で画面を確かめる

今回のコードは、WordPress 7.0・PHP 8.3.30でHTTP応答を差し替えて検証しました。実際のAI APIへは送信していません。管理画面の処理、送信するJSON、応答の読み方を確認するテストで、実モデルの生成品質や応答時間を測ったものではありません。

検証したケース 結果
未ログイン、不正nonce、空欄、配列、長すぎる本文、不正UTF-8、設定不足 対象に応じたエラーを返し、API呼び出し前に止まる
通信失敗、429、不正JSON、incomplete、出力形式不正、空出力、refusal 成功本文として表示しない
推論項目のあとに複数の本文がある assistantのoutput_textを取り出す
本文がscriptタグを含む 文字列としてエスケープ。キーは画面に含めない
GETで画面を開く 生成を実行しない

設定後は、送信してよい短いサンプルで1回の実通信を確認し、返った原文・整文、所要時間、管理画面の表示を照合します。タイムアウトが続く場合は、待ち時間を際限なく延ばす前に処理をジョブへ切り出します。

ジョブ化するなら、受付でジョブIDを発行し、待機・実行中・完了・失敗を永続化して、利用者が自分の結果だけ取得できるようにします。WP-Cronはジョブを動かす契機には使えても、リクエストを登録した瞬間に実行される保証や、一意な処理の保証を代わりに用意してくれるものではありません。長文生成や複数人での運用では、再試行と重複、権限、結果の保持期限をまとめて設計してください。

管理画面での1回の呼び出しが確認できたら、用途に合わせて次へ進めます。外部プログラムから原稿をWordPressへ登録する場合は、AI原稿をWordPressの下書きへ登録する方法に、投稿登録側の考え方をまとめています。

スポンサーリンク