0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

問い合わせフォーム営業に AI で立ち向かう WordPress プラグインを作って WP.org に申請するまで

0
Posted at

みなさん、お久しぶりです。
転職を機に、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 月改定)

Requiring the readme to be written in English

これ知らなくて、日本語ベース + 一部英語の混在で書いてました。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 プラグイン版アンチチート)。

学び

  1. WP.org の審査は思ったより厳しい: Plugin Check で 12 エラー潰すのに 数十時間。README 規約 2025-07 改定もそう
  2. プロンプト分割は最初に決める: 「ユーザー編集可能 / システム固定」の境界を後付けで切り直すのは結構な作業量
  3. interface 抽象化は早めに: 1 プロバイダーで動かしてから抽象化、ではなく最初から interface を切るのが結局速い
  4. フェイルセーフ最優先: AI 通信は失敗する前提で try/catch ( \Throwable ) + フォーム送信は絶対止めない設計
  5. 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/

同じ問題で困ってる人の参考になれば。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?