Le besoin / Le cas d’usage

Si vous avez déjà passé 30 minutes à chercher “le bon titre” ou à résumer un article en 2 phrases, vous avez déjà rencontré un cas d’usage parfait pour une API d’IA dans WordPress.

Le besoin concret, côté WordPress, c’est souvent l’un de ceux-ci :

  • Générer un brouillon à partir d’un sujet (utile pour les blogs, pages de services, fiches produit).
  • Créer des titres et meta descriptions (SEO “basique”, sans plugin lourd, ou en complément).
  • Résumer un contenu existant pour une newsletter, un extrait, ou un encart “TL;DR”.
  • Produire des FAQ à partir d’un article (fréquent sur des sites d’affiliation ou de support).

À la fin, vous saurez implémenter une intégration simple, robuste et sécurisée avec WordPress 6.9.4 et PHP 8.1+, en utilisant wp_remote_post() (sans SDK), avec :

  • un shortcode pour afficher une réponse IA dans une page ou un article,
  • un endpoint AJAX côté admin pour générer du texte depuis l’éditeur (sans exposer la clé API),
  • du cache via les Transients,
  • une gestion d’erreurs réaliste (timeouts, quota, clé invalide).

Résumé rapide

  • Vous stockez la clé OpenAI dans wp-config.php via une constante (jamais en dur dans un plugin).
  • Vous appelez l’API via wp_remote_post() avec un timeout et une gestion des erreurs WP_Error.
  • Vous cachez les réponses IA avec get_transient()/set_transient() pour réduire les coûts.
  • Vous sanitisez la sortie (ex: wp_kses_post()) avant affichage.
  • Vous protégez les actions sensibles avec nonce + capabilities et un rate limit.

Quand utiliser l’IA pour ça

Utilisez l’IA quand la valeur vient de la rédaction ou de la transformation de texte, et que le résultat n’a pas besoin d’être “parfait” du premier coup.

  • Blogs : idées d’articles, plans, introductions, résumés, FAQ.
  • Sites vitrines : reformulation de sections (“À propos”, “Services”), variations de slogans.
  • E-commerce : brouillons de descriptions produit (à relire), listes de bénéfices, tableaux comparatifs.
  • Support : réponses types, reformulation d’une base de connaissance.

Dans mon expérience, l’intégration la plus rentable est celle qui réduit une tâche répétitive (résumés, meta descriptions, FAQ), pas celle qui “écrit tout le site”.

Quand ne PAS utiliser l’IA

Évitez l’IA si une solution WordPress/PHP classique est :

  • déterministe (même entrée → même sortie attendue),
  • moins chère,
  • plus rapide,
  • ou plus fiable.

Exemples concrets :

  • Formater un texte, tronquer, extraire un extrait : faites-le en PHP (ex: wp_trim_words()).
  • Rechercher des contenus : utilisez WP_Query + indexation, pas un prompt.
  • Traduction “production” à grande échelle : mieux vaut un pipeline dédié (et une validation), sinon la facture monte vite.

Autre cas fréquent : si vous envoyez des données personnelles (messages de contact, données clients), vous devez cadrer RGPD/consentement. Dans le doute, n’envoyez pas.

Prérequis

WordPress ciblé : 6.9.4 (avril 2026). PHP : 8.1+.

Ce qu’est une API : une interface qui permet à deux services de communiquer. Ici, WordPress enverra une requête HTTP (POST) à OpenAI, et OpenAI renverra du JSON (du texte structuré).

Ce qu’est wp_remote_post() : la fonction WordPress qui envoie une requête HTTP sortante. Elle gère les transports (cURL/streams) et renvoie soit un tableau de réponse, soit une erreur de type WP_Error. Doc officielle : wp_remote_post().

1) Créer une clé API OpenAI

Créez votre clé depuis la console OpenAI. Doc : OpenAI API Quickstart.

Avertissement coûts : chaque appel est facturé. Même un “petit” prompt multiplié par un formulaire public peut devenir un piège (bots, spam, rafales). On ajoutera un cache + rate limiting.

2) Stocker la clé au bon endroit (wp-config.php)

Collez ceci dans wp-config.php, idéalement juste au-dessus de la ligne “That’s all, stop editing!”. Ne mettez jamais la clé dans un thème, un plugin public, ou du JavaScript.

define( 'OPENAI_API_KEY', 'sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxx' );

Si vous utilisez Git, vérifiez que wp-config.php n’est pas commité. J’ai souvent vu des clés exposées juste parce qu’un staging était public.

3) Où coller le code WordPress

Pour un débutant, je recommande un mu-plugin : un plugin “must-use” chargé automatiquement, qui évite les surprises quand on change de thème.

  • Créez le dossier : wp-content/mu-plugins/ (s’il n’existe pas)
  • Créez un fichier : wp-content/mu-plugins/openai-wp-remote-post.php

Référence : Must-Use Plugins.

4) Extensions / serveur

  • PHP avec HTTPS sortant autorisé (cURL recommandé).
  • Un hébergeur qui ne bloque pas les requêtes sortantes (certains WAF le font).
  • Accès aux logs (ou au moins WP_DEBUG_LOG).

Doc PHP (JSON, erreurs) : php.net JSON.

Architecture de la solution

Voici le flux, tel qu’on va l’implémenter :

Utilisateur (admin) → clic “Générer” → AJAX WordPress (admin-ajax.php) → validation (nonce/capability/rate limit) → cache transient (hit/miss) → wp_remote_post()API OpenAI → réponse JSON → sanitation → renvoi au navigateur / affichage shortcode

Étapes techniques (ce qui se passe en coulisses)

  • Entrée : un prompt (texte) fourni par l’admin, ou un “sujet” passé au shortcode.
  • Normalisation : on nettoie l’entrée (sanitize_textarea_field()), on limite la taille.
  • Cache : on calcule une clé (hash) et on tente get_transient().
  • HTTP : si cache manqué, on envoie une requête POST JSON avec en-têtes Authorization: Bearer ....
  • Erreurs : on gère timeouts, codes HTTP, JSON invalide, quota.
  • Sortie : on renvoie du texte safe (HTML filtré) ou du texte brut selon le contexte.

Le code complet — étape par étape

On va construire une mini “couche OpenAI” réutilisable, puis l’exposer via :

  • un shortcode [openai_text prompt="..."] (pratique avec Divi/Elementor/Avada),
  • une action AJAX réservée aux admins pour tester depuis l’admin.

Étape 1 — Vérifier la présence de la clé et définir quelques constantes

Copiez ce bloc au début de votre fichier mu-plugin. Il évite un classique : activer le code, oublier la clé, et se retrouver avec des erreurs silencieuses.

<?php
/**
 * Plugin Name: OpenAI via wp_remote_post (mu-plugin)
 * Description: Intégration OpenAI simple (WordPress 6.9.4+, PHP 8.1+) avec cache, erreurs, shortcode et AJAX admin.
 * Author: Votre Nom
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Réglages de base.
 * Astuce: gardez des timeouts courts, sinon vous bloquez PHP-FPM sur les hébergements partagés.
 */
define( 'BPCAB_OPENAI_TIMEOUT', 20 ); // secondes
define( 'BPCAB_OPENAI_CACHE_TTL', 6 * HOUR_IN_SECONDS );

Étape 2 — Comprendre “action” et “filtre” (juste ce qu’il faut)

Un hook est un point d’extension WordPress. Il en existe deux types :

  • Action : déclenche une fonction à un moment donné (ex: init, admin_enqueue_scripts).
  • Filtre : reçoit une valeur, la modifie, puis la renvoie.

Docs : Hooks (actions & filters).

Étape 3 — Fonction d’appel OpenAI avec wp_remote_post()

Ce bloc fait le cœur du travail : construire le JSON, envoyer la requête, parser la réponse, gérer les erreurs, et mettre en cache.

Note : l’API OpenAI évolue. Le principe reste stable (POST JSON + Bearer token). Si OpenAI change un champ, vous adapterez un seul endroit, ici.

/**
 * Appelle OpenAI via wp_remote_post() et renvoie une chaîne (texte) ou WP_Error.
 *
 * @param string $prompt Le prompt utilisateur (déjà nettoyé idéalement).
 * @param array  $args   Options: model, temperature, max_output_tokens, cache_ttl.
 * @return string|WP_Error
 */
function bpcab_openai_generate_text( string $prompt, array $args = [] ) {
	if ( ! defined( 'OPENAI_API_KEY' ) || ! OPENAI_API_KEY ) {
		return new WP_Error( 'openai_missing_key', 'Clé API OpenAI manquante. Définissez OPENAI_API_KEY dans wp-config.php.' );
	}

	$prompt = trim( $prompt );
	if ( $prompt === '' ) {
		return new WP_Error( 'openai_empty_prompt', 'Prompt vide.' );
	}

	// Limite simple pour éviter les prompts énormes (et la facture).
	// Ajustez selon votre usage.
	if ( strlen( $prompt ) > 4000 ) {
		return new WP_Error( 'openai_prompt_too_long', 'Prompt trop long (limite 4000 caractères dans cet exemple).' );
	}

	$defaults = [
		// Choisissez un modèle adapté à votre budget. Gardez-le configurable.
		'model'             => 'gpt-4.1-mini',
		'temperature'       => 0.4,
		'max_output_tokens' => 350,
		'cache_ttl'         => BPCAB_OPENAI_CACHE_TTL,
	];
	$args = wp_parse_args( $args, $defaults );

	// Clé de cache: dépend du modèle + prompt + paramètres.
	$cache_key_raw = wp_json_encode( [
		'model'       => (string) $args['model'],
		'temperature' => (float) $args['temperature'],
		'max_tokens'  => (int) $args['max_output_tokens'],
		'prompt'      => $prompt,
	] );
	$cache_key = 'bpcab_openai_' . md5( $cache_key_raw );

	$cached = get_transient( $cache_key );
	if ( is_string( $cached ) && $cached !== '' ) {
		return $cached;
	}

	$endpoint = 'https://api.openai.com/v1/responses';

	/**
	 * Corps de requête (API Responses).
	 * Référence: https://platform.openai.com/docs/api-reference/responses
	 *
	 * On demande une sortie texte. Si vous voulez du JSON structuré, vous pouvez le demander,
	 * mais il faut alors valider/sécuriser plus strictement.
	 */
	$body = [
		'model' => (string) $args['model'],
		'input' => [
			[
				'role'    => 'user',
				'content' => [
					[
						'type' => 'input_text',
						'text' => $prompt,
					],
				],
			],
		],
		'temperature'       => (float) $args['temperature'],
		'max_output_tokens' => (int) $args['max_output_tokens'],
	];

	$request_args = [
		'timeout' => BPCAB_OPENAI_TIMEOUT,
		'headers' => [
			'Authorization' => 'Bearer ' . OPENAI_API_KEY,
			'Content-Type'  => 'application/json; charset=utf-8',
		],
		'body'    => wp_json_encode( $body ),
	];

	$response = wp_remote_post( $endpoint, $request_args );

	if ( is_wp_error( $response ) ) {
		// Erreur réseau / DNS / SSL / timeout.
		return new WP_Error(
			'openai_http_error',
			'Erreur HTTP lors de l’appel OpenAI: ' . $response->get_error_message(),
			[ 'error' => $response ]
		);
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	$raw  = (string) wp_remote_retrieve_body( $response );

	if ( $code < 200 || $code >= 300 ) {
		// Souvent: 401 (clé invalide), 429 (quota/rate limit), 400 (payload).
		return new WP_Error(
			'openai_bad_status',
			'OpenAI a répondu avec un code HTTP ' . $code,
			[ 'status' => $code, 'body' => $raw ]
		);
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		return new WP_Error(
			'openai_bad_json',
			'Réponse OpenAI illisible (JSON invalide).',
			[ 'body' => $raw ]
		);
	}

	/**
	 * Extraction texte:
	 * La structure exacte peut varier selon les options. On cherche un texte “output_text”.
	 * Si vous constatez un format différent, logguez $data et adaptez ici.
	 */
	$text = '';

	// Cas fréquent: output_text au niveau racine (selon certaines réponses).
	if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) {
		$text = $data['output_text'];
	}

	// Sinon, on tente de parcourir output[].
	if ( $text === '' && isset( $data['output'] ) && is_array( $data['output'] ) ) {
		foreach ( $data['output'] as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			if ( isset( $item['content'] ) && is_array( $item['content'] ) ) {
				foreach ( $item['content'] as $content ) {
					if ( isset( $content['type'], $content['text'] ) && $content['type'] === 'output_text' && is_string( $content['text'] ) ) {
						$text .= $content['text'];
					}
				}
			}
		}
	}

	$text = trim( $text );

	if ( $text === '' ) {
		return new WP_Error(
			'openai_empty_output',
			'OpenAI a répondu, mais aucun texte exploitable n’a été trouvé.',
			[ 'parsed' => $data ]
		);
	}

	// Cache: on stocke le texte tel quel (non HTML), la sanitation se fera à l’affichage.
	set_transient( $cache_key, $text, (int) $args['cache_ttl'] );

	return $text;
}

Étape 4 — Shortcode pour afficher une réponse IA

Un shortcode permet d’insérer un résultat dans le contenu WordPress. C’est pratique avec Divi 5, Elementor ou Avada : vous ajoutez un module “Code” / “Shortcode” et vous collez [openai_text ...].

Doc : Shortcodes.

/**
 * Shortcode: [openai_text prompt="Résume ce texte..." model="gpt-4.1-mini"]
 *
 * Attention: n’utilisez pas ce shortcode sur une page publique avec des attributs dynamiques
 * venant d’utilisateurs non authentifiés. Sinon, vous ouvrez la porte au spam + coûts.
 */
function bpcab_openai_text_shortcode( $atts ) {
	$atts = shortcode_atts(
		[
			'prompt' => '',
			'model'  => 'gpt-4.1-mini',
		],
		$atts,
		'openai_text'
	);

	$prompt = sanitize_textarea_field( (string) $atts['prompt'] );
	$model  = sanitize_text_field( (string) $atts['model'] );

	if ( $prompt === '' ) {
		return '';
	}

	$result = bpcab_openai_generate_text( $prompt, [ 'model' => $model ] );

	if ( is_wp_error( $result ) ) {
		// En front, évitez d’exposer trop de détails. Logguez plutôt.
		error_log( '[OpenAI shortcode] ' . $result->get_error_code() . ': ' . $result->get_error_message() );
		return '<div class="openai-error">Texte indisponible pour le moment.</div>';
	}

	// Sanitation: on autorise un HTML très limité.
	// Si vous voulez uniquement du texte, utilisez esc_html().
	$safe_html = wp_kses_post( nl2br( $result ) );

	return '<div class="openai-text">' . $safe_html . '</div>';
}
add_shortcode( 'openai_text', 'bpcab_openai_text_shortcode' );

Étape 5 — Bouton de test dans l’admin (AJAX sécurisé)

Le piège classique : appeler l’API depuis JavaScript côté front. Ça expose la clé (ou un proxy trop permissif) et vous finissez avec une facture surprise. Ici, on fait un appel AJAX vers WordPress, et WordPress appelle OpenAI côté serveur.

5.1 Ajouter une petite page d’admin

/**
 * Ajoute une page d’admin simple pour tester.
 */
function bpcab_openai_admin_menu() {
	add_management_page(
		'Test OpenAI',
		'Test OpenAI',
		'manage_options',
		'bpcab-openai-test',
		'bpcab_openai_admin_page_render'
	);
}
add_action( 'admin_menu', 'bpcab_openai_admin_menu' );

function bpcab_openai_admin_page_render() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	$nonce = wp_create_nonce( 'bpcab_openai_generate' );
	?>
	<div class="wrap">
		<h1>Test OpenAI (via wp_remote_post)</h1>
		<p>Ce test appelle OpenAI côté serveur. La clé API ne sort jamais du serveur.</p>

		<div>
			<label for="bpcab-openai-prompt"><strong>Prompt</strong></label><br>
			<textarea id="bpcab-openai-prompt" style="width:100%;max-width:900px;height:140px;">Écrivez 5 titres possibles pour un article WordPress sur la sécurité des formulaires.</textarea>
		</div>

		<p>
			<button class="button button-primary" id="bpcab-openai-generate">Générer</button>
		</p>

		<div id="bpcab-openai-result" style="white-space:pre-wrap;background:#fff;border:1px solid #ccd0d4;padding:12px;max-width:900px;"></div>

		<script>
		(function() {
			const btn = document.getElementById('bpcab-openai-generate');
			const promptEl = document.getElementById('bpcab-openai-prompt');
			const out = document.getElementById('bpcab-openai-result');

			btn.addEventListener('click', async function() {
				out.textContent = 'Appel en cours...';

				const form = new FormData();
				form.append('action', 'bpcab_openai_generate');
				form.append('nonce', '<?php echo esc_js( $nonce ); ?>');
				form.append('prompt', promptEl.value);

				try {
					const res = await fetch(ajaxurl, { method: 'POST', body: form });
					const json = await res.json();
					if (!json || !json.success) {
						out.textContent = (json && json.data && json.data.message) ? json.data.message : 'Erreur inconnue.';
						return;
					}
					out.textContent = json.data.text;
				} catch (e) {
					out.textContent = 'Erreur réseau: ' + e.message;
				}
			});
		})();
		</script>
	</div>
	<?php
}

5.2 Handler AJAX (nonce + capability + rate limit)

On protège l’action avec :

  • Nonce : token anti-CSRF
  • Capability : seuls les admins (ici) peuvent déclencher
  • Rate limiting : évite les clics rapides (ou scripts) qui martèlent l’API

Doc nonce : WordPress Nonces.

/**
 * Limitation simple par utilisateur (anti-rafale).
 */
function bpcab_openai_rate_limit_check( int $user_id, int $seconds = 10 ) {
	$key = 'bpcab_openai_rl_' . $user_id;
	if ( get_transient( $key ) ) {
		return false;
	}
	set_transient( $key, '1', $seconds );
	return true;
}

/**
 * AJAX: génération de texte (admin uniquement).
 */
function bpcab_openai_ajax_generate() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( [ 'message' => 'Permissions insuffisantes.' ], 403 );
	}

	$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( (string) $_POST['nonce'] ) : '';
	if ( ! wp_verify_nonce( $nonce, 'bpcab_openai_generate' ) ) {
		wp_send_json_error( [ 'message' => 'Nonce invalide.' ], 400 );
	}

	$user_id = get_current_user_id();
	if ( ! bpcab_openai_rate_limit_check( $user_id, 8 ) ) {
		wp_send_json_error( [ 'message' => 'Trop de requêtes. Attendez quelques secondes.' ], 429 );
	}

	$prompt = isset( $_POST['prompt'] ) ? sanitize_textarea_field( (string) $_POST['prompt'] ) : '';
	if ( $prompt === '' ) {
		wp_send_json_error( [ 'message' => 'Prompt vide.' ], 400 );
	}

	$result = bpcab_openai_generate_text( $prompt, [
		'model'             => 'gpt-4.1-mini',
		'temperature'       => 0.5,
		'max_output_tokens' => 300,
		'cache_ttl'         => 2 * HOUR_IN_SECONDS,
	] );

	if ( is_wp_error( $result ) ) {
		// Log utile côté serveur, sans exposer les détails à l’admin (à vous de choisir).
		error_log( '[OpenAI AJAX] ' . $result->get_error_code() . ' - ' . $result->get_error_message() );

		$data = $result->get_error_data();
		// Message simple + code.
		wp_send_json_error(
			[
				'message' => 'Erreur OpenAI: ' . $result->get_error_message(),
				'code'    => $result->get_error_code(),
				'status'  => is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : null,
			],
			500
		);
	}

	wp_send_json_success(
		[
			'text' => $result, // Texte brut; l’affichage admin est en textContent (safe).
		]
	);
}
add_action( 'wp_ajax_bpcab_openai_generate', 'bpcab_openai_ajax_generate' );

Le code assemblé complet

Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/openai-wp-remote-post.php. Ensuite, définissez la constante OPENAI_API_KEY dans wp-config.php.

Si vous copiez le code au mauvais endroit (ex: dans un article, dans un module Divi, ou dans un plugin de snippets mal configuré), vous aurez soit un écran blanc, soit rien ne se passera. Un mu-plugin évite beaucoup de ces erreurs.

<?php
/**
 * Plugin Name: OpenAI via wp_remote_post (mu-plugin)
 * Description: Intégration OpenAI simple (WordPress 6.9.4+, PHP 8.1+) avec cache, erreurs, shortcode et AJAX admin.
 * Author: Votre Nom
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'BPCAB_OPENAI_TIMEOUT', 20 ); // secondes
define( 'BPCAB_OPENAI_CACHE_TTL', 6 * HOUR_IN_SECONDS );

/**
 * Appelle OpenAI via wp_remote_post() et renvoie une chaîne (texte) ou WP_Error.
 *
 * @param string $prompt Le prompt utilisateur (déjà nettoyé idéalement).
 * @param array  $args   Options: model, temperature, max_output_tokens, cache_ttl.
 * @return string|WP_Error
 */
function bpcab_openai_generate_text( string $prompt, array $args = [] ) {
	if ( ! defined( 'OPENAI_API_KEY' ) || ! OPENAI_API_KEY ) {
		return new WP_Error( 'openai_missing_key', 'Clé API OpenAI manquante. Définissez OPENAI_API_KEY dans wp-config.php.' );
	}

	$prompt = trim( $prompt );
	if ( $prompt === '' ) {
		return new WP_Error( 'openai_empty_prompt', 'Prompt vide.' );
	}

	if ( strlen( $prompt ) > 4000 ) {
		return new WP_Error( 'openai_prompt_too_long', 'Prompt trop long (limite 4000 caractères dans cet exemple).' );
	}

	$defaults = [
		'model'             => 'gpt-4.1-mini',
		'temperature'       => 0.4,
		'max_output_tokens' => 350,
		'cache_ttl'         => BPCAB_OPENAI_CACHE_TTL,
	];
	$args = wp_parse_args( $args, $defaults );

	$cache_key_raw = wp_json_encode( [
		'model'       => (string) $args['model'],
		'temperature' => (float) $args['temperature'],
		'max_tokens'  => (int) $args['max_output_tokens'],
		'prompt'      => $prompt,
	] );
	$cache_key = 'bpcab_openai_' . md5( $cache_key_raw );

	$cached = get_transient( $cache_key );
	if ( is_string( $cached ) && $cached !== '' ) {
		return $cached;
	}

	$endpoint = 'https://api.openai.com/v1/responses';

	$body = [
		'model' => (string) $args['model'],
		'input' => [
			[
				'role'    => 'user',
				'content' => [
					[
						'type' => 'input_text',
						'text' => $prompt,
					],
				],
			],
		],
		'temperature'       => (float) $args['temperature'],
		'max_output_tokens' => (int) $args['max_output_tokens'],
	];

	$request_args = [
		'timeout' => BPCAB_OPENAI_TIMEOUT,
		'headers' => [
			'Authorization' => 'Bearer ' . OPENAI_API_KEY,
			'Content-Type'  => 'application/json; charset=utf-8',
		],
		'body'    => wp_json_encode( $body ),
	];

	$response = wp_remote_post( $endpoint, $request_args );

	if ( is_wp_error( $response ) ) {
		return new WP_Error(
			'openai_http_error',
			'Erreur HTTP lors de l’appel OpenAI: ' . $response->get_error_message(),
			[ 'error' => $response ]
		);
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	$raw  = (string) wp_remote_retrieve_body( $response );

	if ( $code < 200 || $code >= 300 ) {
		return new WP_Error(
			'openai_bad_status',
			'OpenAI a répondu avec un code HTTP ' . $code,
			[ 'status' => $code, 'body' => $raw ]
		);
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		return new WP_Error(
			'openai_bad_json',
			'Réponse OpenAI illisible (JSON invalide).',
			[ 'body' => $raw ]
		);
	}

	$text = '';

	if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) {
		$text = $data['output_text'];
	}

	if ( $text === '' && isset( $data['output'] ) && is_array( $data['output'] ) ) {
		foreach ( $data['output'] as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			if ( isset( $item['content'] ) && is_array( $item['content'] ) ) {
				foreach ( $item['content'] as $content ) {
					if ( isset( $content['type'], $content['text'] ) && $content['type'] === 'output_text' && is_string( $content['text'] ) ) {
						$text .= $content['text'];
					}
				}
			}
		}
	}

	$text = trim( $text );

	if ( $text === '' ) {
		return new WP_Error(
			'openai_empty_output',
			'OpenAI a répondu, mais aucun texte exploitable n’a été trouvé.',
			[ 'parsed' => $data ]
		);
	}

	set_transient( $cache_key, $text, (int) $args['cache_ttl'] );

	return $text;
}

/**
 * Shortcode: [openai_text prompt="..." model="gpt-4.1-mini"]
 */
function bpcab_openai_text_shortcode( $atts ) {
	$atts = shortcode_atts(
		[
			'prompt' => '',
			'model'  => 'gpt-4.1-mini',
		],
		$atts,
		'openai_text'
	);

	$prompt = sanitize_textarea_field( (string) $atts['prompt'] );
	$model  = sanitize_text_field( (string) $atts['model'] );

	if ( $prompt === '' ) {
		return '';
	}

	$result = bpcab_openai_generate_text( $prompt, [ 'model' => $model ] );

	if ( is_wp_error( $result ) ) {
		error_log( '[OpenAI shortcode] ' . $result->get_error_code() . ': ' . $result->get_error_message() );
		return '<div class="openai-error">Texte indisponible pour le moment.</div>';
	}

	$safe_html = wp_kses_post( nl2br( $result ) );
	return '<div class="openai-text">' . $safe_html . '</div>';
}
add_shortcode( 'openai_text', 'bpcab_openai_text_shortcode' );

/**
 * Page d’admin de test.
 */
function bpcab_openai_admin_menu() {
	add_management_page(
		'Test OpenAI',
		'Test OpenAI',
		'manage_options',
		'bpcab-openai-test',
		'bpcab_openai_admin_page_render'
	);
}
add_action( 'admin_menu', 'bpcab_openai_admin_menu' );

function bpcab_openai_admin_page_render() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	$nonce = wp_create_nonce( 'bpcab_openai_generate' );
	?>
	<div class="wrap">
		<h1>Test OpenAI (via wp_remote_post)</h1>
		<p>Ce test appelle OpenAI côté serveur. La clé API ne sort jamais du serveur.</p>

		<div>
			<label for="bpcab-openai-prompt"><strong>Prompt</strong></label><br>
			<textarea id="bpcab-openai-prompt" style="width:100%;max-width:900px;height:140px;">Écrivez 5 titres possibles pour un article WordPress sur la sécurité des formulaires.</textarea>
		</div>

		<p>
			<button class="button button-primary" id="bpcab-openai-generate">Générer</button>
		</p>

		<div id="bpcab-openai-result" style="white-space:pre-wrap;background:#fff;border:1px solid #ccd0d4;padding:12px;max-width:900px;"></div>

		<script>
		(function() {
			const btn = document.getElementById('bpcab-openai-generate');
			const promptEl = document.getElementById('bpcab-openai-prompt');
			const out = document.getElementById('bpcab-openai-result');

			btn.addEventListener('click', async function() {
				out.textContent = 'Appel en cours...';

				const form = new FormData();
				form.append('action', 'bpcab_openai_generate');
				form.append('nonce', '<?php echo esc_js( $nonce ); ?>');
				form.append('prompt', promptEl.value);

				try {
					const res = await fetch(ajaxurl, { method: 'POST', body: form });
					const json = await res.json();
					if (!json || !json.success) {
						out.textContent = (json && json.data && json.data.message) ? json.data.message : 'Erreur inconnue.';
						return;
					}
					out.textContent = json.data.text;
				} catch (e) {
					out.textContent = 'Erreur réseau: ' + e.message;
				}
			});
		})();
		</script>
	</div>
	<?php
}

/**
 * Limitation simple par utilisateur (anti-rafale).
 */
function bpcab_openai_rate_limit_check( int $user_id, int $seconds = 10 ) {
	$key = 'bpcab_openai_rl_' . $user_id;
	if ( get_transient( $key ) ) {
		return false;
	}
	set_transient( $key, '1', $seconds );
	return true;
}

/**
 * AJAX: génération de texte (admin uniquement).
 */
function bpcab_openai_ajax_generate() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( [ 'message' => 'Permissions insuffisantes.' ], 403 );
	}

	$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( (string) $_POST['nonce'] ) : '';
	if ( ! wp_verify_nonce( $nonce, 'bpcab_openai_generate' ) ) {
		wp_send_json_error( [ 'message' => 'Nonce invalide.' ], 400 );
	}

	$user_id = get_current_user_id();
	if ( ! bpcab_openai_rate_limit_check( $user_id, 8 ) ) {
		wp_send_json_error( [ 'message' => 'Trop de requêtes. Attendez quelques secondes.' ], 429 );
	}

	$prompt = isset( $_POST['prompt'] ) ? sanitize_textarea_field( (string) $_POST['prompt'] ) : '';
	if ( $prompt === '' ) {
		wp_send_json_error( [ 'message' => 'Prompt vide.' ], 400 );
	}

	$result = bpcab_openai_generate_text( $prompt, [
		'model'             => 'gpt-4.1-mini',
		'temperature'       => 0.5,
		'max_output_tokens' => 300,
		'cache_ttl'         => 2 * HOUR_IN_SECONDS,
	] );

	if ( is_wp_error( $result ) ) {
		error_log( '[OpenAI AJAX] ' . $result->get_error_code() . ' - ' . $result->get_error_message() );

		$data = $result->get_error_data();
		wp_send_json_error(
			[
				'message' => 'Erreur OpenAI: ' . $result->get_error_message(),
				'code'    => $result->get_error_code(),
				'status'  => is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : null,
			],
			500
		);
	}

	wp_send_json_success( [ 'text' => $result ] );
}
add_action( 'wp_ajax_bpcab_openai_generate', 'bpcab_openai_ajax_generate' );

Explication du code

Pourquoi wp_remote_post() (et pas cURL direct)

wp_remote_post() passe par l’API HTTP de WordPress. Vous gagnez :

  • compatibilité hébergement (WordPress choisit le meilleur transport),
  • filtrage possible via hooks si besoin,
  • un format d’erreur standard (WP_Error).

Doc API HTTP : WordPress HTTP API.

Pourquoi un transient cache

Un transient est une valeur stockée avec une expiration. Sur un site avec cache objet (Redis/Memcached), c’est très efficace. Sans cache objet, WordPress le stocke en base.

Le gain est double : vitesse (pas d’appel externe) et coût (moins de requêtes facturées).

Doc : Transients API.

Pourquoi sanitize en entrée et wp_kses_post() en sortie

Entrée : vous évitez des caractères invisibles, des payloads énormes, et des surprises si vous réutilisez le prompt ailleurs.

Sortie : une IA peut générer du HTML. Si vous affichez tel quel, vous risquez d’introduire du contenu indésirable. wp_kses_post() autorise un sous-ensemble de balises “post” et supprime le reste.

Si vous voulez du texte strict : remplacez par esc_html( $result ).

Pourquoi nonce + capability + rate limit

Le trio que je vois manquer le plus souvent :

  • Nonce : empêche un site externe de déclencher votre action à votre place (CSRF).
  • Capability : empêche un utilisateur non autorisé (ou un compte compromis) de générer du contenu.
  • Rate limit : évite la rafale d’appels (clics répétés, scripts, bots).

Coûts API et optimisation

Les coûts dépendent du modèle et du volume de tokens (entrée + sortie). OpenAI facture au token, et les tarifs changent. Gardez toujours un œil sur la page officielle : OpenAI Pricing.

Estimation simple (à la louche, utile pour décider)

Pour un usage “blog” typique :

  • Prompt : 200 à 600 tokens
  • Réponse : 200 à 500 tokens
  • Total : 400 à 1100 tokens par requête

Si vous faites 20 générations/jour (rédaction, titres, FAQ), ça fait 600/mois. Le coût peut rester faible avec un modèle “mini”, mais grimpe vite si :

  • vous augmentez max_output_tokens,
  • vous envoyez des gros blocs (articles entiers),
  • vous ouvrez l’outil au public sans garde-fous.

Optimisations qui marchent vraiment

  • Cache agressif sur les prompts identiques (transients).
  • Limiter la longueur du prompt (on l’a fait).
  • Réduire max_output_tokens (souvent, 200–400 suffit pour titres/FAQ).
  • Modèle “mini” par défaut, et modèle plus cher uniquement sur demande.
  • Déclencher côté admin, pas côté front public.

Variantes et cas d’usage avancés

Variante 1 — Générer une FAQ à partir du contenu d’un article

Vous pouvez prendre le contenu d’un post et demander 5 questions/réponses. Attention : envoyez un extrait raisonnable (sinon tokens).

function bpcab_openai_generate_faq_for_post( int $post_id ) {
	$post = get_post( $post_id );
	if ( ! $post ) {
		return new WP_Error( 'not_found', 'Article introuvable.' );
	}

	// On récupère du texte sans shortcodes ni HTML lourd.
	$content = wp_strip_all_tags( (string) $post->post_content );
	$content = mb_substr( $content, 0, 4000 ); // coupe simple

	$prompt = "À partir du texte ci-dessous, générez une FAQ de 5 questions/réponses courtes.nnTEXTE:n" . $content;

	return bpcab_openai_generate_text( $prompt, [
		'max_output_tokens' => 450,
		'temperature'       => 0.3,
	] );
}

Variante 2 — Compatibilité Divi 5 / Elementor / Avada

Le shortcode est volontairement le point d’intégration le plus simple :

  • Divi 5 : module “Code” ou “Texte” (onglet texte) → collez [openai_text prompt="..."].
  • Elementor : widget “Shortcode” → collez le shortcode.
  • Avada (Fusion Builder) : élément “Code Block” ou “Shortcode” selon votre version → collez le shortcode.

Si vous voulez une intégration plus “native” (bouton dans le builder, champ dynamique), vous devrez passer par leurs APIs respectives. Pour débuter, le shortcode est robuste et portable.

Variante 3 — Stocker le résultat dans un champ personnalisé (post meta)

Utile si vous voulez générer une meta description et la conserver.

function bpcab_openai_generate_and_store_meta_description( int $post_id ) {
	$post = get_post( $post_id );
	if ( ! $post ) {
		return new WP_Error( 'not_found', 'Article introuvable.' );
	}

	$title   = get_the_title( $post_id );
	$content = wp_strip_all_tags( (string) $post->post_content );
	$content = mb_substr( $content, 0, 2500 );

	$prompt = "Écrivez une meta description SEO (max 155 caractères) pour un article WordPress.nTitre: {$title}nContenu: {$content}";

	$text = bpcab_openai_generate_text( $prompt, [
		'max_output_tokens' => 120,
		'temperature'       => 0.2,
	] );

	if ( is_wp_error( $text ) ) {
		return $text;
	}

	// On force une valeur courte.
	$text = trim( preg_replace( '/s+/', ' ', $text ) );
	$text = mb_substr( $text, 0, 170 );

	update_post_meta( $post_id, '_bpcab_meta_description_ai', sanitize_text_field( $text ) );

	return $text;
}

Sécurité et bonnes pratiques

Ne jamais exposer la clé API côté client

Règle simple : pas de clé dans JavaScript, pas de clé dans un shortcode visible publiquement, pas de clé dans un repo Git. La clé doit rester serveur, idéalement dans wp-config.php.

Valider toutes les entrées

Si un utilisateur peut influencer le prompt (même indirectement), vous devez :

  • sanitizer (sanitize_text_field(), sanitize_textarea_field()),
  • limiter la taille,
  • bloquer certains usages (ex: pas de génération sur un formulaire public).

Limiter le taux (rate limiting) et surveiller

Le rate limit par transient est basique mais efficace. Sur des sites plus gros, je passe souvent à :

  • rate limit par IP + user ID,
  • journalisation (log) des appels,
  • quota journalier côté admin.

RGPD : attention aux données envoyées

Si vous envoyez :

  • des emails, noms, messages de contact,
  • des données clients,
  • des données de santé, etc.

… vous devez cadrer votre conformité (base légale, information, conservation, sous-traitance). Dans le doute, envoyez uniquement du contenu éditorial déjà public, ou du texte “neutre”.

Ne modifiez jamais le core WordPress

Ça casse aux mises à jour. Utilisez un mu-plugin ou un plugin custom. Docs : Managing Plugins.

Comment tester et déboguer

1) Activez les logs WordPress

Dans wp-config.php (sur un environnement de dev/staging), activez :

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );

Référence : Debug WordPress.

2) Testez d’abord depuis l’admin

Allez dans Outils → Test OpenAI. C’est l’endroit le plus simple pour isoler :

  • un problème de clé,
  • un blocage réseau sortant,
  • un souci de JSON.

3) Logguez intelligemment

Quand ça casse, logguez :

  • le code HTTP,
  • les 500 premiers caractères du body,
  • le temps de réponse (si vous instrumentez).

Évitez de logger la clé ou des données sensibles.

4) Vérifiez que le mu-plugin est bien chargé

Piège courant : créer mu-plugins mais se tromper de chemin. Le fichier doit être directement dans wp-content/mu-plugins/, pas dans un sous-dossier (sauf loader).

Si ça ne marche pas

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Page blanche / erreur PHP Point-virgule manquant, parenthèse oubliée Consultez wp-content/debug.log Corrigez la syntaxe, comparez avec le code assemblé
“Clé API manquante” Constante non définie Vérifiez wp-config.php Ajoutez define('OPENAI_API_KEY', '...')
HTTP 401 Clé invalide / révoquée Regardez le body dans les logs Regénérez une clé, vérifiez l’environnement (prod/staging)
HTTP 429 Quota dépassé ou rate limit OpenAI Body de réponse + console OpenAI Réduisez la fréquence, ajoutez cache, changez de modèle
Timeout Serveur lent, WAF, DNS, modèle plus long Erreur openai_http_error + message timeout Augmentez légèrement le timeout, simplifiez le prompt, testez réseau sortant
Le shortcode n’affiche rien Attribut prompt vide ou filtré Essayez un prompt statique Vérifiez la syntaxe du shortcode, évitez guillemets typographiques
Résultat “bizarre” ou HTML inattendu Sortie non contrôlée Consultez le texte brut Forcez esc_html() au lieu de wp_kses_post()
Rien ne se passe dans l’admin Hook AJAX incorrect ou conflit JS Console navigateur + onglet Réseau Vérifiez action=bpcab_openai_generate et le nonce

Erreurs réalistes que je vois souvent (et comment les éviter)

  • Code collé dans functions.php du thème parent : il disparaît à la mise à jour. Utilisez un thème enfant ou un mu-plugin.
  • Hook inadapté : appeler l’API sur init “à chaque page” par erreur. Résultat : facture + lenteur. Déclenchez sur action explicite (AJAX, bouton, cron).
  • Tester en production sans sauvegarde : faites au moins un staging, surtout si vous modifiez wp-config.php.
  • Snippet cassé par un plugin de snippets : certains plugins évaluent le code dans un contexte différent. Un mu-plugin est plus prévisible.
  • Ancien tutoriel incompatible : beaucoup d’exemples utilisent des endpoints ou formats obsolètes. Gardez l’appel isolé dans une fonction et logguez la réponse.

Ressources

FAQ

Est-ce que je peux appeler OpenAI directement depuis Elementor/Divi avec du JavaScript ?

Techniquement oui, mais vous ne devez pas. Vous exposeriez la clé API, ou vous devrez créer un proxy côté serveur de toute façon. La méthode propre : AJAX WordPress côté serveur, comme dans le code.

Pourquoi utiliser un mu-plugin plutôt que functions.php ?

Parce que le code ne dépend pas du thème. J’ai vu des sites casser après un changement de thème, simplement parce que l’intégration IA était dans functions.php.

Le shortcode va-t-il appeler l’API à chaque affichage de page ?

Oui, sauf si le cache répond. C’est pour ça qu’on met un transient. Sans cache, une page très visitée peut déclencher beaucoup d’appels (et coûter cher).

Puis-je désactiver complètement l’appel en front et garder seulement l’admin ?

Oui. Supprimez (ou commentez) add_shortcode() et gardez l’AJAX admin. C’est souvent le meilleur choix pour un débutant.

Comment forcer la régénération si le cache renvoie un vieux résultat ?

Changez légèrement le prompt, ou diminuez le TTL. Version plus propre : ajouter un paramètre no_cache=1 qui bypass le transient (à réserver aux admins).

Pourquoi mon hébergeur renvoie des erreurs SSL ou bloque la requête ?

Certains hébergements filtrent les connexions sortantes. Vérifiez si wp_remote_post() peut joindre https://api.openai.com. Les logs WordPress (WP_DEBUG_LOG) vous donneront souvent “cURL error 28” (timeout) ou une erreur SSL.

Je reçois HTTP 429 : c’est mon code ?

Pas forcément. HTTP 429 indique un rate limit ou un quota. Ajoutez du cache, baissez la fréquence, et vérifiez votre quota côté OpenAI.

Est-ce que je peux demander une réponse en HTML propre ?

Oui, mais traitez ça comme du contenu non fiable. Filtrez avec wp_kses_post() (ou un ensemble de balises autorisées encore plus strict) avant d’afficher.

Comment intégrer ça dans un workflow éditorial (brouillon, champs ACF, etc.) ?

La base est la même : vous appelez bpcab_openai_generate_text(), puis vous stockez le résultat dans post_content ou en post_meta. Pour ACF, vous mettez à jour la meta du champ.

Quel modèle choisir pour débuter ?

Commencez avec un modèle “mini” pour limiter la facture, puis montez en gamme uniquement si la qualité ne suffit pas. Gardez toujours le modèle configurable (comme dans le code).

Est-ce compatible WordPress 6.9.4 et PHP 8.1 ?

Oui. Le code utilise l’HTTP API, les transients et les nonces, qui sont stables. Si vous tombez sur un tutoriel plus ancien qui utilise un endpoint différent, gardez votre logique d’appel centralisée (une seule fonction), et adaptez l’extraction de texte.