← 기사 목록
日本語https://qiita.com/tags/ai/feed

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

추출된 키워드

38
WordPress プラグイン·5WP.org·5問い合わせフォーム営業·5Contact Form 7·4Claude·4GPT·4Gemini·4YomuForm·4Google Gemini·3Anthropic·3OpenAI·3Google·3README·3AES-256·3interface·3JSON·3OpenAI GPT·3Anthropic Claude·3wpcf7_before_send_mail·3LLM·3Plugin Check·3BYOK·3AES-256-CBC·2Google Analytics·2Akismet·2gettext-parser·2Node.js·2i18n·2base32·2HMAC-SHA256·2Caddy·2Next.js·2Cloudflare Workers·2Plugin Header·2load_plugin_textdomain·2ASCII·2wp_salt('auth·2FileMaker·1

원문

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

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

ポイント:

  • で完全に包む。判定でどんな例外が出ても CF7 のメール送信自体を絶対に止めない。フォーム動作が壊れるとサイト側の損失が大きすぎる
    try/catch ( \Throwable $e )
  • 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/

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