みなさん、お久しぶりです。
転職を機に、FileMakerから少し距離ができてしまい、バッタバタしていた(言い訳)のもあって記事更新が止まって早6年。。。ビジネスサイドに放り込まれたり、ゴリッゴリにコード書いたり、AIに積み上げたスキルセットぶち壊されたりで100転びぐらいしてました。育児と仕事の隙間を縫いながらやっと形になったので、久々に投稿します。
TL;DR
- Contact Form 7 に届く問い合わせを Claude / GPT / Gemini で「営業 / 本物の問い合わせ」に自動仕分けする WordPress プラグインを作った
- BYOK モデル(ユーザー自身の API キー)でフォーム内容はプラグイン作者を経由せず AI プロバイダーに直接送信
- WordPress.org 公式ディレクトリに申請したら Plugin Check で 12 エラー + 117 ワーニング食らったので潰した話
- プロンプト分割設計、AI プロバイダー抽象化、ライセンスサーバーまで含めた地味な実装メモ
動機: フォーム営業うざい問題
WordPress サイトを運営していると、Contact Form 7 に毎日のように営業メールが届きます。
- 受信箱が埋まって本物の問い合わせを見落とす
- Google Analytics の「フォーム送信 = コンバージョン」イベントが汚染される → マーケ判断が狂う
- 「営業対応を AI に丸投げしませんか」みたいな営業メールも届く(マッチポンプ)
- そしてなぜかひとり情シス&ビジネスサイド兼務の私が仕分け担当という。
スパムフィルタは「拒絶 / 受領」の二値判定で対応するけど、フォーム営業は 文章として正当な日本語 で送ってくるので Akismet 等では取りこぼします。「営業ではあるが日本語的にはまともな文章」を仕分けたい。
→ LLM に任せる以外なくない?
全体構成
[訪問者]
│ Contact Form 7 フォーム送信
▼
[WordPress: YomuForm プラグイン]
│ wpcf7_before_send_mail フックで割り込み
│
├─ AI プロバイダーに判定リクエスト (BYOK)
│ ├─ Anthropic Claude
│ ├─ OpenAI GPT
│ └─ Google Gemini
│
└─ 判定結果を CF7 メールテンプレに注入
├─ 件名にプレフィックス追加 [営業]
└─ 本文先頭に判定スタンプ
実装の核: wpcf7_before_send_mail フック
Contact Form 7 にはメール送信直前に割り込めるフックがあります。
add_action( 'wpcf7_before_send_mail', array( $this, 'on_before_send_mail' ), 10, 3 );
public function on_before_send_mail( $contact_form, &$abort, $submission ) {
try {
$this->process( $contact_form, $submission );
} catch ( \Throwable $e ) {
// フェイルセーフ: 判定パスでの例外はフォーム送信を止めない
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
error_log( 'YomuForm classification error: ' . $e->getMessage() );
}
}
}
ポイント:
-
try/catch ( \Throwable $e )で完全に包む。判定でどんな例外が出ても CF7 のメール送信自体を絶対に止めない。フォーム動作が壊れるとサイト側の損失が大きすぎる -
error_log()は WP_DEBUG_LOG 有効時のみ呼ぶ(WP.org 規約で discouraged)
判定後にメールテンプレを書き換える部分:
private function augment_mail( $contact_form, $classification ) {
$mail = $contact_form->prop( 'mail' );
$binary = Prompt_Manager::to_binary( $classification['category'] );
if ( 'spam' === $binary ) {
$mail['subject'] = '[営業判定] ' . $mail['subject'];
}
$stamp = "----- YomuForm AI 判定 -----\n";
$stamp .= sprintf( "flag: %d\n", 'spam' === $binary ? 0 : 1 );
$stamp .= sprintf( "confidence: %.2f\n", $classification['confidence'] );
$stamp .= "------------------------------\n";
$mail['body'] = $stamp . "\n" . $mail['body'];
$contact_form->set_properties( array( 'mail' => $mail ) );
}
メールクライアント側で [営業判定] をプレフィックスにフィルタ振り分け、flag:0 で自動アーカイブ等が組めます。
設計判断 1: プロンプトを「システム固定部」と「ユーザー編集部」に分離
最初の実装ではプロンプト全体をユーザーが編集可能にしていました。これが事故の元でした。
LLM への指示には必ず「JSON で返せ」と書く必要があります。ユーザーがプロンプトを編集する過程で出力形式の指示を壊すと、classifier がパースに失敗して全件 unknown 判定になる事故が想定されました。
そこで構造を変更:
[システム固定部] (PHP コードに埋め込み、編集不可)
- 役割定義
- カテゴリ定義
- 出力 JSON 形式の厳密指定
- フォーム内容挿入のプレースホルダー
[ユーザー編集部]
- 「判定の重点」のみ
- 業種固有のヒント等を自由記述
Prompt_Manager::compose() で 2 つを合成:
public static function compose( $additional_guidance, $categories, $form_content ) {
$categories_block = self::format_categories_for_prompt( $categories );
$judgment_focus = self::build_judgment_focus_from_categories( $categories );
if ( '' !== trim( $additional_guidance ) ) {
$judgment_focus .= "\n\n# 追加のガイダンス\n" . $additional_guidance;
}
return self::render( self::system_template(), array(
'judgment_focus' => $judgment_focus,
'categories' => $categories_block,
'form_content' => $form_content,
));
}
これでユーザーが何を書いても JSON 出力フォーマットは絶対壊れない構造に。
設計判断 2: AI プロバイダーは interface で抽象化
3 プロバイダー対応にする時に最初から interface を切ったのが正解でした。
interface Provider_Interface {
public function classify( $prompt, $options = array() );
}
final class Anthropic_Provider implements Provider_Interface {
public function classify( $prompt, $options = array() ) {
// x-api-key ヘッダ + Messages API
}
}
final class OpenAI_Provider implements Provider_Interface {
public function classify( $prompt, $options = array() ) {
// Authorization: Bearer + Chat Completions
}
}
final class Gemini_Provider implements Provider_Interface {
public function classify( $prompt, $options = array() ) {
// ?key=API_KEY in URL + generateContent
}
}
API 仕様の違い:
| プロバイダー | 認証方式 | エンドポイント |
|---|---|---|
| Anthropic |
x-api-key ヘッダ + anthropic-version
|
POST /v1/messages |
| OpenAI | Authorization: Bearer ${key} |
POST /v1/chat/completions |
| Gemini |
?key=${key} クエリ |
POST /v1beta/models/${model}:generateContent |
レスポンス形式も全部違うのでパーサーも分けて、共通の戻り値に整形:
return array(
'ok' => true,
'text' => $generated_text,
'tokens' => $total_tokens,
);
切り替えは Classifier 側で:
private function provider() {
$api_key = Settings::get_api_key();
switch ( Settings::get_provider() ) {
case 'openai': return new OpenAI_Provider( $api_key );
case 'gemini': return new Gemini_Provider( $api_key );
case 'anthropic':
default: return new Anthropic_Provider( $api_key );
}
}
新プロバイダー(ローカル LLM とか)追加する時はクラス 1 つ書くだけで済みます。
設計判断 3: LLM 応答の JSON パースは「最初の { から最後の }」を救出
LLM はたまに前置きを返します。
判定結果は以下の通りです:
{
"category": "sales_solicitation",
"confidence": 0.95,
"reason": "..."
}
そのまま json_decode すると失敗するので、フォールバック:
private function parse_json_response( $text ) {
$decoded = json_decode( trim( $text ), true );
if ( is_array( $decoded ) ) {
return $decoded;
}
// 前置きや code fence がある場合、最初の { から最後の } までを救出
$start = strpos( $text, '{' );
$end = strrpos( $text, '}' );
if ( false === $start || false === $end || $end <= $start ) {
return null;
}
return json_decode( substr( $text, $start, $end - $start + 1 ), true );
}
これでコードフェンス( json ... )でくるんで返してくる場合も含めて拾えます。Anthropic / OpenAI / Gemini いずれも同じパーサで動きました。
設計判断 4: API キーは AES-256 で暗号化保存
WordPress のオプションに API キーを平文で入れるのはまずいので、wp_salt('auth') を鍵材料に AES-256-CBC:
final class Encryption {
const METHOD = 'aes-256-cbc';
private static function key_material() {
return substr( hash( 'sha256', wp_salt( 'auth' ) . '|yomuform', true ), 0, 32 );
}
public static function encrypt( $plaintext ) {
$iv = openssl_random_pseudo_bytes( openssl_cipher_iv_length( self::METHOD ) );
$cipher = openssl_encrypt( $plaintext, self::METHOD, self::key_material(), OPENSSL_RAW_DATA, $iv );
return base64_encode( $iv . $cipher );
}
}
wp_salt('auth') はその WordPress 環境固有の値で、データベースダンプを別環境に持っていっても復号できません(鍵材料が変わるので)。
WP.org 申請でハマったポイント
ここからが本題というか苦行というか。Plugin Check ツールに掛けたら 12 エラー + 117 ワーニングでした。
エラー: README は完全英語必須(2025 年 7 月改定)
これ知らなくて、日本語ベース + 一部英語の混在で書いてました。Short description と Description セクションは全部標準英語にしないと弾かれます。
最終的にこんな感じで落ち着き:
Automatically classify Contact Form 7 submissions with AI to
separate real customer inquiries from cold sales emails.
罠 1: 最初 "AI-powered inquiry triage" って書いたら弾かれた。triage が標準英語じゃないと判定されたっぽい。
罠 2: Unicode em dash (—) が混じっていたら弾かれた(ASCII 範囲外文字で language detector が混乱する模様)。全部 ASCII の - に置換した。
ここに気づくのに数十時間無駄にした。おこ。
エラー: __() の placeholder には /* translators: */ コメント必須
// NG
__( '✅ 送信成功 (HTTP %s)', 'yomuform' )
// OK
/* translators: %s: HTTP status code */
__( '✅ 送信成功 (HTTP %s)', 'yomuform' )
%s %d 系の placeholder を含む __() 呼び出しには 必ず直前にコメントを入れないと WP.org の言語ファイル翻訳者がコンテキスト理解できないため、Plugin Check で ERROR 扱いになります。
ワーニング: View テンプレートの local 変数も「prefix されてない global」扱い
// admin/views/dashboard.php
$is_pro = Settings::is_pro(); // ← warning: 'PrefixAllGlobals.NonPrefixedVariableFound'
PHPCS WP standards は PHP の include スコープの違いを理解しないので、view ファイル内の local 変数も「グローバル汚染」と判定されます。仕方ないので各 view の先頭で:
// View template scope.
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
// phpcs:disable WordPress.Security.NonceVerification.Recommended
を入れて抑制。
ワーニング: load_plugin_textdomain() は WP 4.6+ で不要
// 削除した
load_plugin_textdomain( 'yomuform', false, dirname( ... ) . '/languages' );
WP 4.6 以降、テキストドメインがプラグインスラッグと一致してたら自動ロードされるそうで、明示呼び出しは discouraged 扱い。
Plugin Header の罠
Plugin URI: https://yomuform.com/
Author URI: https://yomuform.com/ ← NG (Plugin URI と同じ)
Plugin URI と Author URI は別の URL である必要があります。yomuscore.com(姉妹プロダクト)に変えて通った。
ライセンスシステム(番外編)
WordPress プラグインで Free + Plus + Pro 階層を出すために、シリアル認証サーバーを自前で建てました(Cloudflare Workers ではなく Next.js + Caddy):
POST /license/activate シリアル → Bearer トークン
GET /license/heartbeat トークン認証で生存確認
POST /license/deactivate ドメイン解放
POST /trial/register トライアル抜け穴対策(site_url hash 記録)
シリアル形式: YF-(PLUS|PRO)-XXXXXX-XXXXXX-CHECKSUM
CHECKSUM は HMAC-SHA256(SERVER_SECRET, "YF-{tier}-{r1}-{r2}") の先頭 8 文字を base32 化。プラグイン側は概形チェック(正規表現)のみで、実際の HMAC 検証はサーバー側で行う。秘密鍵を埋め込まないのでクラック耐性がある。
/trial/register はインストール時に呼び出して、site_url_hash → first_seen_at をサーバーに永続記録。再インストールしてもサーバー側の first_seen_at が変わらないので 30 日トライアルがリセットされない設計(要は WordPress プラグイン版アンチチート)。
学び
- WP.org の審査は思ったより厳しい: Plugin Check で 12 エラー潰すのに 数十時間。README 規約 2025-07 改定もそう
- プロンプト分割は最初に決める: 「ユーザー編集可能 / システム固定」の境界を後付けで切り直すのは結構な作業量
- interface 抽象化は早めに: 1 プロバイダーで動かしてから抽象化、ではなく最初から interface を切るのが結局速い
-
フェイルセーフ最優先: AI 通信は失敗する前提で
try/catch ( \Throwable )+ フォーム送信は絶対止めない設計 -
i18n は extract スクリプトを作る: 手動で .pot を維持するのは破綻する。Node.js + gettext-parser で PHP スキャンする
build-translations.jsを書いて全自動化
まとめ
- フォーム営業うざい問題は自分で解決できた
- WordPress + Contact Form 7 + Claude/GPT/Gemini の組み合わせは思ったより自然に統合できる
- WP.org 申請の罠は事前に Plugin Check ローカル実行で半分くらい潰せる
- リリース時は先着 100 名 50% OFF クーポン用意してます(クーポンコード
FRIENDS_50)
WP.org: 審査中、承認次第 https://wordpress.org/plugins/yomuform/
同じ問題で困ってる人の参考になれば。