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へ送信します。
- 管理者が「ツール → AI整文」を開く。
- 本文をPOSTする。PHPでmanage_optionsとnonceを確認する。
- 入力の型・長さを検査し、wp_remote_post()でAI APIを呼ぶ。
- HTTPステータス、JSON、生成の完了状態、本文を順に確認する。
- 結果を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の下書きへ登録する方法に、投稿登録側の考え方をまとめています。