キーワードを1つ入れると、タイトルからリード文、見出し、本文、そして画像までを作って投稿にする。そういうWordPressプラグインを、自社サービスにするつもりで試作しました。名前はAI SEO POST、バージョンは1.0.0、vendorを除いた自作部分は約480行です。先に書いておくと、この実装は生成のフローが最後まで通っていません。動作確認より手前で止まったプロトタイプです。それでも、設計として残ったもの、通らなかった原因、仮に動いてもそのままでは運用に乗らなかった部分は、書いておく価値があると考えています。生成AIを使った受託の実績は、この1本だけです。
何を作ろうとしたのか
作ろうとしたのは、投稿の編集画面にキーワードを1語入れて保存しておくと、翌日には記事の形になっている、という仕組みです。カスタム投稿タイプは作らず、標準のpostをそのまま対象にしました。既存の編集フローを壊さずに載せたかったからです。
入口は投稿編集画面のサイドに置いたメタボックスで、ここに入れた値をaag_keywordというpost_metaに保存します。この保存処理だけは、nonceの検証、DOING_AUTOSAVEの判定、current_user_can('edit_post')の確認、sanitize_text_fieldという順で、WordPressの定石どおりに書けています。あとから読み返して、まともに動く保証があるのはここくらいでした。
設定は「設定」メニューの下に1ページ作り、Settings APIに乗せています。保存先はaag_optionsという1つのキーで、1日の生成本数、temperature(step 0.01の数値入力)、タイトルの種類(断言型・質問型・同調型・Let’s型の4択)、タイトル・サブタイトル・リード文それぞれの目標文字数、APIキー2つを配列でまとめて入れます。キーの入力欄はpasswordタイプにしました。オプションを1キーに集約したのは、設定が増えてもupdate_option1回で済むためです。
処理の起点はWP-Cronの日次イベントaag_generate_articles_eventで、キーワードの入った投稿をdaily_article_limit件だけ拾って回す設計にしました。クラスは4層に分けています。エントリファイルは定数ASAG_PATH/ASAG_URLの定義とrequireだけの21行、その下にフックとオプションとスケジュールを束ねるメインクラス、記事を組み立てるArticle_Creator、外部APIを叩くChatGPT_APIと画像生成側のラッパー。APIのラッパーはコンストラクタで注入する形にして、あとから差し替えられる余地を残しました。骨格の方針としては、いまでも間違っていないと思っています。
一度に書かせず、14回に分けた
設計で最初に分かれたのは、1回の大きなプロンプトで記事を丸ごと出させるか、パーツに分けて順に出させるかでした。選んだのは後者です。
generate_blog_elements()が、「文字数={n} でキーワード「{kw}」に基づいた…を{type}的な表現で書いて」という定型文に値を差し込みながら、タイトル → リード文 → サブタイトル6件 → 各セクションの本文6件、の順で呼び出していきます。合計14回。前の段で出てきたタイトルやサブタイトルを次のプロンプトに渡すので、後半の本文が前半の見出しから離れていきにくくなります。1回で全部書かせると、後半で話が別のところへ行っても直す手がかりがありません。分けておけば、おかしくなった段だけ作り直せます。
文体の指定は、コードに埋め込まずに設定へ逃がしました。title_typeをassertive/question/sympathetic/letsの4択にして、その値をそのままプロンプトの「{type}的な表現で」に埋めます。文体を変えるときにコードを触らずに済み、エンジニアでない運用者でも調整できます。
逆に、明確に失敗だったのが文字数の扱いです。設定した目標文字数を、プロンプト内の「文字数={n}」と、APIに渡すmax_tokensの両方に使い回していました。日本語ではトークン数と文字数が一致しないので、たとえば800文字を狙ってmax_tokensに800を渡すと、実際には途中で切れたり、逆に無駄に余ったりします。目標文字数はプロンプト側の指示に留めて、max_tokensは余裕を持った別の値にすべきでした。
完走しなかった原因は、全部PHPの結線だった
コードを読み直して分かったのは、止まっている理由がAIの側に1つもないということです。プロンプトの質でも、モデルの選択でもなく、クラスとクラスの間の受け渡しが全部食い違っていました。
| 場所 | 何が起きているか | 実行するとどうなるか |
|---|---|---|
| APIクライアントの初期化 | コンストラクタで認証オブジェクトを作るだけで、$this->clientに何も代入していない。さらにgetenv()に環境変数名ではなくキーそのものを渡している | $this->client->createCompletion()がnullに対する呼び出しになり、Fatal error |
| 引数の数 | 呼ぶ側は5引数、定義側は6つの必須引数。第2引数に文体の文字列が文字数として渡る位置ズレもある | PHP 7.1以降はArgumentCountErrorで即停止 |
| 配列のキー名 | 生成側はlead_textとcontentで返し、組み立て側はleadとsectionsを参照している | PHP 8では未定義キーの参照がWarningになり、本文が空のまま進む |
| 戻り値の型 | 記事作成側は投稿ID(整数)を返すのに、呼び出し側は配列として['title']や['content']を取り出している | 生成結果が投稿に反映されない |
| cronの登録 | register_activation_hook(__FILE__, ...)をlib配下のファイルの中で呼んでいる | __FILE__がメインのプラグインファイルを指さず、日次スケジュールが登録されない |
| 対象投稿の絞り込み | get_posts()の引数に'post_content' => ''を渡している。これはWP_Queryの有効なパラメータではない | 黙って無視され、生成済みの投稿を毎日上書きし続ける |
| 投稿の作り方 | 下位クラスがpost_status => 'publish'で新規投稿を作り、上位クラスが元の投稿を更新している | 上の不具合を全部直しても、1キーワードにつき記事が2本残る |
並べてみると、生成AIを組み込む仕事で難しいのはプロンプトではなく、その周りの配管だと分かります。APIを叩いて文字列を受け取るところまでは、公開されているサンプルをなぞれば動きます。詰まるのは、受け取った結果をどの型で次の層へ渡し、どこで検証するかという普通のPHPの設計です。クラスをまたいで連想配列を素で受け渡すと、キー名の約束は簡単に壊れます。値オブジェクトを挟むかarray_key_existsで検証していれば、leadとlead_textの食い違いは当日に見つかっていました。
外部APIを自動で叩く仕組みで、先に踏んでいた罠
実案件で似た性質の判断をしたことがあります。日本語のサイトで、投稿タイトルからURLスラッグを自動生成したい、という依頼でした。WordPressは日本語のタイトルをそのままスラッグにするため、URLがパーセントエンコードされた長い文字列になり、共有するときも、アクセス解析で見るときも扱いにくくなります。
方式は2つありました。翻訳APIを呼んで意味の通る英語のスラッグにする方法と、外部を呼ばずにローマ字へ変換する方法です。前者は読みやすいURLになりますが、APIキーの管理と従量課金が発生し、投稿を保存するたびに外部への呼び出しが走ります。後者は無料で確実に動く代わりに、英語ではなくローマ字(中古マンションならchuko-mansionのような形)になります。この案件では、用途としてローマ字で足りたので後者を選びました。外部APIを呼ばずに済むなら呼ばない、という判断です。
実装はwp_insert_post_dataフックでpost_nameを差し替える形です。必ず入れるのが、すでにスラッグが手で設定されている場合は上書きしないという分岐で、これが無いと公開済みの記事のタイトルを直した瞬間にURLが変わり、それまでのリンクが404になります。加えて、オートセーブとリビジョンで走らないようにwp_is_post_revision()とDOING_AUTOSAVEのガードを入れます。翻訳API方式を選んでいたら、このガードが無いだけで編集画面を開くたびに課金と遅延が積み上がっていました。変換結果には記号やスペースが混じるのでsanitize_title()を通し、重複時の連番付与はwp_unique_post_slug()に任せます。
記事生成プラグインの側は、この教訓を活かせていません。1記事につきテキスト14回、画像7回の呼び出しを、cronの1リクエストの中で直列に流す作りです。OpenAI APIの費用はどう決まり、月いくらになるのかで書いているとおり、費用は呼び出し回数とトークン数の掛け算で効くので、本数を増やしたときに何が起きるかを設定画面から読み取れる形にしておくべきでした。
自動で差し込む設計は、例外のページで崩れる
組み立て側は、リード文の後ろに<h2>の見出しを並べ、その直下に1200×630の画像を挟んで本文を続ける、という決め打ちのHTMLを作ります。画像はメディアライブラリに登録して、wp_insert_attachmentからwp_generate_attachment_metadata、wp_update_attachment_metadata、set_post_thumbnailという正攻法の流れでアイキャッチにも設定します。この部分の手順自体は問題ありません。
問題は「全部のページに同じ形で入る」という前提のほうです。以前、記事に目次を自動で入れたいという依頼を受けたとき、全ページ一括表示にすると目次はコンテンツの先頭に固定されるため、タイトルを置かないランディングページでは最上部に目次が出てレイアウトが崩れることが分かりました。そこで一括表示をやめ、ショートコードでページごとに位置を指定する方式にしています。見出しの直下に必ず画像を入れるという作りも性質は同じで、短いお知らせにも、比較表が主役の記事にも、同じ間隔で画像が挟まります。アイキャッチを画像生成AIで用意する運用の作り方と、その限界に書いたとおり、画像は本文以上に例外が出る部分です。生成そのものより「どこに、どう差し込むか」を可変にしておくほうが、後から効いてきます。
仮に動いても、そのままでは運用に乗らなかった
結線の不具合を全部直したとしても、この設計のままでは実運用に耐えません。理由が3つあります。
1つ目は実行時間です。1記事あたり21回の同期呼び出しを、設定した本数だけcronの1リクエストで直列に処理します。生成の待ち時間は1回あたり数秒から十数秒になるので、数本回すだけでPHPの実行時間とメモリの上限に届きます。set_time_limitで引き延ばす方向ではなく、1件ずつキューに積んで、1回の実行で1件だけ進める形に変えるべきでした。あわせて、未処理・処理中・完了のフラグをpost_metaに持たせないと、実行が重なったときに同じ投稿を二重に処理します。
2つ目は公開のしかたです。この実装はpost_statusにpublishを渡していて、人の確認を挟まずに公開されます。事実関係の確認も、既存記事との重複確認も、検索エンジンのポリシーへの適合も、誰も見ないまま外に出る状態です。draftで止めて人が見る工程を必ず挟む形にすべきで、この点はAIが書いた記事をそのまま公開できない理由と、人が入るべき工程で詳しく書いています。
3つ目は、外部との接続の作り方です。使っているモデルはtext-davinci-002で、これは既に提供が終了しています。Completions系からChat Completions系への移行が前提になり、Composerで入れているライブラリのバージョンも現行のAPIに追随していない可能性が高いです。画像側はhttps://api.midjourney.com/v1/generateというURLに素のcurlでGETしていますが、これは公式に提供されているエンドポイントではありません。画像生成は別の系統に置き換える必要があります。
接続の書き方にも直すべき点があります。WordPressの中から外部を叩くときは、素のcurlではなくwp_remote_get()やwp_remote_post()を使います。プロキシ設定、SSLの検証、タイムアウト、フィルタでの差し替えが標準で効くからです。この実装はCURLOPT_RETURNTRANSFERしか設定しておらず、タイムアウトも証明書検証の明示もなく、返ってきたデータをContent-Typeの確認なしにfile_put_contentsで保存しています。画像である保証がありません。そしてAPIキーをクエリ文字列に載せてGETしているため、アクセスログやプロキシに鍵が平文で残ります。キーはAuthorizationヘッダで送るものです。aag_optionsに平文で保存している点も、データベースを書き出す前に値を抜く手順とセットにする必要があります。
よくある質問
キーワードを入れれば、そのまま公開できる記事ができますか
できません。段階生成で構成の通った文章までは作れますが、事実の確認、社内の実情との突き合わせ、既存記事との重複確認は人の工程として残ります。自動化して効果が出るのは、真っ白な状態から下書きを起こす部分と、見出し案を並べる部分です。公開の判断まで自動にすると、直すコストのほうが高くつきます。
費用はどのくらいかかりますか
呼び出し回数と、送受信するトークン数と、使うモデルの単価の掛け算で決まります。この実装は1記事あたりテキスト14回に画像7回で、段階生成にした分だけ呼び出し回数が増えています。1回の巨大なプロンプトにまとめれば回数は減りますが、そのぶん1回で送るトークンが増えるので、単純に安くはなりません。
いま同じものを作るなら、どこが変わりますか
モデルは現行のものに置き換え、画像生成は実在するAPIに差し替えます。外部への通信はwp_remote_*に統一してタイムアウトとレスポンスの検証を入れ、キーはヘッダで送ります。処理はcronで直列に回さずキューに積み、生成物はdraftで止める。骨格のクラス分割と、プロンプトの可変部を設定に逃がした部分は、そのまま使います。
既存のWordPressサイトに後から組み込めますか
組み込めます。標準のpostを対象にして、メタボックスとスケジュール実行を足すだけの構造なので、テーマにも他のプラグインにも依存しません。ただし、この試作をそのまま入れることは勧めません。どこを自動化してどこに人を残すかを先に決めたうえで、必要な部分だけ作るほうが、結果的に短く済みます。自動化の相談としてお受けする場合も、最初に決めるのはそこです。
生成AIを業務に組み込むときに難しいのは、プロンプトそのものよりも周辺の配管と運用の設計です。どこまでを自動化し、どこに人の確認を残すかの線引きから、実装と運用まで一緒に組み立てます。
