Si vous avez déjà voulu afficher un “TL;DR” propre en haut de vos articles sans y passer 10 minutes à chaque publication, l’IA peut le faire à votre place — et WordPress 6.9.4 vous donne déjà tout ce qu’il faut pour l’intégrer proprement.

Le piège, c’est que 90% des snippets qu’on trouve en ligne font n’importe quoi en 2026 : clé API en dur, appels côté navigateur (clé exposée), pas de cache, et des hooks mal choisis qui déclenchent 20 requêtes IA par page. Ici, on fait l’inverse : un résumé généré côté serveur, mis en cache, stocké en meta, et affiché où vous voulez.

Le besoin / Le cas d’usage

Un résumé automatique sert à deux choses très concrètes : améliorer la lecture (les visiteurs comprennent tout de suite de quoi parle l’article) et augmenter la rétention (un bon “aperçu” réduit le taux de rebond). J’ai souvent vu ça fonctionner sur des blogs très longs (tutoriels, recettes détaillées, comparatifs) où l’intro est forcément dense.

Cas typiques :

  • Blog SEO : un encart “Résumé en 4 points” en haut de l’article.
  • Site média : une version courte pour les pages catégories ou les cartes d’articles.
  • Documentation / knowledge base : un résumé + points clés pour aider à scanner.
  • Newsletter : réutiliser le résumé comme accroche email.

À la fin, vous saurez :

  • déclencher une génération de résumé (manuellement depuis l’admin ou automatiquement à l’enregistrement),
  • appeler une API IA via wp_remote_post(),
  • mettre en cache (Transients) et stocker le résultat (post meta),
  • afficher le résumé via shortcode (compatible Gutenberg, Divi 5, Elementor, Avada),
  • déboguer les erreurs classiques (timeout, quota, JSON invalide, hook trop tôt).

Résumé rapide

  • Le résumé est généré côté serveur (PHP) via wp_remote_post() : la clé API n’est jamais exposée.
  • Le résultat est stocké en post meta et mis en cache avec les Transients pour éviter de payer deux fois.
  • Un bouton dans l’éditeur permet de générer / régénérer le résumé, sans automatisme dangereux.
  • Un shortcode [ai_summary] affiche le résumé dans Gutenberg, Divi 5, Elementor ou Avada.
  • Le code gère les erreurs (timeout, HTTP 401/429/500) et nettoie la sortie avec wp_kses_post().

Quand utiliser l’IA pour ça

Utilisez l’IA si votre contenu est long, répétitif à résumer, ou si vous avez plusieurs auteurs et que vous voulez une cohérence. C’est aussi utile si vous republiez des articles et souhaitez un résumé “à jour” sans tout relire.

J’ai eu de bons résultats quand :

  • les articles font plus de 800–1200 mots,
  • vous avez une structure claire (titres, sections),
  • vous imposez une forme de résumé (ex. 3–5 puces, ton neutre, pas de promesses).

Quand ne PAS utiliser l’IA

Ne payez pas une API IA pour des cas où WordPress/PHP fait mieux et gratuitement.

  • Extrait simple : si vous voulez juste les 20 premières lignes, utilisez l’extrait WordPress (champ Extrait) ou wp_trim_words().
  • Contenu très court : un post de 200 mots n’a pas besoin d’un résumé IA.
  • Contraintes légales/éditoriales fortes : si le résumé doit être juridiquement exact (médical, finance), mieux vaut un humain.
  • Sites très sensibles aux coûts : si vous avez 50 000 articles et que vous régénérez tout, la facture arrive vite.

Alternative “classique” souvent suffisante :

<?php
// Exemple : extrait gratuit sans IA (utile si vous débutez)
$excerpt = has_excerpt() ? get_the_excerpt() : wp_trim_words( wp_strip_all_tags( get_the_content() ), 40 );
echo esc_html( $excerpt );
?>

Prérequis

Vous allez appeler un service externe via une API (Application Programming Interface). Concrètement, c’est une URL à laquelle votre site envoie une requête HTTP (souvent en JSON) et reçoit une réponse (souvent en JSON). Dans WordPress, on fait ça avec l’API HTTP : wp_remote_post() et wp_remote_get().

Prérequis techniques (avril 2026) :

  • WordPress : 6.9.4 (ou plus récent).
  • PHP : 8.1+ recommandé (et en pratique, évitez 8.0 si vous pouvez).
  • HTTPS actif sur le site (sinon, vous aurez tôt ou tard des soucis d’appels sortants).
  • Accès à votre wp-config.php pour stocker la clé API.

Choix du fournisseur IA : je montre OpenAI dans le code (API stable et très documentée), mais l’architecture est la même pour Anthropic, Google AI, Mistral, etc.

Coûts : chaque génération consomme des tokens (texte envoyé + texte reçu). Si vous déclenchez la génération à chaque affichage de page, vous allez payer très cher. Ici, on génère une fois, on stocke, et on régénère uniquement sur action.

Stocker la clé API (où et comment)

Ne mettez jamais votre clé dans un shortcode, un bloc HTML, un JS, ni dans la base de données en clair si vous pouvez l’éviter. Le plus simple pour débuter : une constante dans wp-config.php.

Ajoutez ceci dans wp-config.php, juste avant la ligne “That’s all, stop editing!” :

<?php
// Clé API OpenAI (ne jamais committer ce fichier dans un dépôt public)
define( 'BPCAB_OPENAI_API_KEY', 'sk-votre-cle-ici' );

// Optionnel : modèle (vous pourrez ajuster sans toucher au plugin)
define( 'BPCAB_OPENAI_MODEL', 'gpt-4.1-mini' );
?>

Si vous n’avez pas accès à wp-config.php, utilisez plutôt une variable d’environnement côté serveur. Mais pour un blogueur débutant, la constante dans wp-config.php reste la voie la plus fiable.

Où coller le code

Évitez functions.php si vous débutez : une erreur de point-virgule et votre site tombe. Je recommande un mu-plugin (plugin “must-use”) : WordPress le charge automatiquement, et c’est plus stable qu’un snippet dans un thème.

  • Créez le dossier : wp-content/mu-plugins/ (s’il n’existe pas).
  • Créez un fichier : wp-content/mu-plugins/ai-summary.php
  • Collez le “Code assemblé complet” plus bas.

Architecture de la solution

Voici ce qui se passe en coulisses (schéma textuel) :

Éditeur WordPress → clic “Générer le résumé” → requête admin (nonce) → PHP récupère le contenu → cache transient (si déjà généré) → sinon wp_remote_post()API IA → réponse JSON → nettoyage (wp_kses_post()) → stockage en post meta → affichage via shortcode

Pourquoi ce design

  • Pas de requête IA au chargement de la page : sinon vous payez à chaque visite (et vous risquez un 429 “rate limit”).
  • Stockage en post meta : rapide, simple, exportable, et vous pouvez l’afficher n’importe où.
  • Transient : évite de relancer l’IA si vous cliquez deux fois, ou si un autosave déclenche un second passage.
  • Shortcode : compatible avec Gutenberg, Divi 5, Elementor et Avada sans code front.

Le code complet — étape par étape

On va construire 5 briques :

  • une fonction pour extraire un texte “propre” depuis le contenu WordPress,
  • une fonction qui appelle l’API IA avec wp_remote_post(),
  • un cache transient,
  • un stockage en post meta,
  • un bouton dans l’admin + un shortcode d’affichage.

1) Extraire un contenu propre à résumer

Envoyer le HTML brut (avec shortcodes, blocs, scripts) donne souvent des résumés bizarres. On va :

  • récupérer le contenu,
  • exécuter les shortcodes (optionnel),
  • retirer les balises,
  • réduire la longueur pour limiter les coûts.
<?php
/**
 * Prépare le texte à envoyer à l'IA.
 * Objectif : éviter d'envoyer du HTML/bruit et limiter les tokens.
 */
function bpcab_ai_summary_prepare_text( int $post_id, int $max_chars = 12000 ): string {
	$post = get_post( $post_id );
	if ( ! $post instanceof WP_Post ) {
		return '';
	}

	$content = $post->post_content;

	// Optionnel : exécuter les shortcodes. Utile sur certains sites, mais parfois ça ajoute du bruit.
	// Si vous avez des shortcodes lourds (formulaires, produits), commentez la ligne suivante.
	$content = do_shortcode( $content );

	// Applique les filtres de contenu (blocs, embeds, etc.).
	$content = apply_filters( 'the_content', $content );

	// Retire toutes les balises HTML.
	$text = wp_strip_all_tags( $content, true );

	// Normalise les espaces.
	$text = preg_replace( '/s+/u', ' ', $text );
	$text = trim( (string) $text );

	// Coupe pour limiter les coûts (et éviter des timeouts si l'article est énorme).
	if ( function_exists( 'mb_substr' ) ) {
		$text = mb_substr( $text, 0, $max_chars );
	} else {
		$text = substr( $text, 0, $max_chars );
	}

	return $text;
}
?>

2) Appeler l’API IA avec wp_remote_post()

WordPress fournit une API HTTP officielle. C’est la méthode à privilégier : elle gère cURL/streams, proxies, SSL, et s’intègre avec les outils WP.

Sources officielles utiles :

<?php
/**
 * Appelle l'API OpenAI pour générer un résumé.
 * Retourne soit une chaîne (résumé), soit WP_Error.
 */
function bpcab_ai_summary_call_openai( string $input_text, array $args = [] ) {
	if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || BPCAB_OPENAI_API_KEY === '' ) {
		return new WP_Error( 'bpcab_missing_api_key', 'Clé API manquante : définissez BPCAB_OPENAI_API_KEY dans wp-config.php.' );
	}

	$model = defined( 'BPCAB_OPENAI_MODEL' ) ? BPCAB_OPENAI_MODEL : 'gpt-4.1-mini';

	$defaults = [
		'max_bullets' => 4,
		'language'    => 'fr',
		'timeout'     => 25,
	];
	$args = wp_parse_args( $args, $defaults );

	// Prompt volontairement strict : réduit les hallucinations et impose une forme stable.
	$system = 'Vous êtes un assistant éditorial. Vous résumez fidèlement le texte fourni. Vous n'inventez rien.';
	$user   = "Résume le texte ci-dessous en {$args['max_bullets']} puces maximum, en {$args['language']}. "
	        . "Chaque puce doit être courte (max 18 mots). Pas de marketing, pas de phrases vagues.nn"
	        . $input_text;

	$body = [
		'model' => $model,
		'input' => [
			[
				'role'    => 'system',
				'content' => $system,
			],
			[
				'role'    => 'user',
				'content' => $user,
			],
		],
		// On évite une température trop haute pour un résumé.
		'temperature' => 0.2,
	];

	$request_args = [
		'headers' => [
			'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
			'Content-Type'  => 'application/json',
		],
		'timeout' => (int) $args['timeout'],
		'body'    => wp_json_encode( $body ),
	];

	$response = wp_remote_post( 'https://api.openai.com/v1/responses', $request_args );

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

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

	if ( $code < 200 || $code >= 300 ) {
		// On garde un extrait du body pour aider au debug, sans tout logguer.
		$excerpt = function_exists( 'mb_substr' ) ? mb_substr( $raw, 0, 300 ) : substr( $raw, 0, 300 );
		return new WP_Error( 'bpcab_openai_http_error', 'Erreur HTTP OpenAI (' . $code . ') : ' . $excerpt, [ 'status' => $code ] );
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		return new WP_Error( 'bpcab_openai_json_error', 'Réponse JSON invalide depuis OpenAI.' );
	}

	// Extraction robuste : la structure peut évoluer, on vise le texte final.
	$text = '';

	// Format attendu : output_text (souvent présent), sinon on parcourt output.
	if ( isset( $data['output_text'] ) && is_string( $data['output_text'] ) ) {
		$text = $data['output_text'];
	} elseif ( isset( $data['output'] ) && is_array( $data['output'] ) ) {
		foreach ( $data['output'] as $item ) {
			if ( isset( $item['content'] ) && is_array( $item['content'] ) ) {
				foreach ( $item['content'] as $c ) {
					if ( isset( $c['type'] ) && $c['type'] === 'output_text' && isset( $c['text'] ) ) {
						$text .= (string) $c['text'];
					}
				}
			}
		}
	}

	$text = trim( (string) $text );

	if ( $text === '' ) {
		return new WP_Error( 'bpcab_openai_empty', 'OpenAI a répondu, mais aucun texte de résumé n’a été trouvé.' );
	}

	return $text;
}
?>

3) Cache transient + stockage en post meta

Le transient sert de cache court terme (minutes/heures). Le post meta sert de stockage long terme (tant que l’article existe). On fait les deux : vous évitez de payer lors d’une régénération accidentelle, et vous gardez le résumé même si le transient expire.

Références :

<?php
/**
 * Génère (ou récupère) le résumé IA d'un article.
 * - Si déjà stocké en meta et pas de force, on le renvoie.
 * - Sinon on regarde le transient (évite double facture).
 * - Sinon on appelle l'IA, on nettoie, on stocke, puis on renvoie.
 */
function bpcab_ai_summary_get_or_generate( int $post_id, bool $force_regenerate = false ) {
	$meta_key = '_bpcab_ai_summary';

	if ( ! $force_regenerate ) {
		$existing = (string) get_post_meta( $post_id, $meta_key, true );
		if ( $existing !== '' ) {
			return $existing;
		}
	}

	$transient_key = 'bpcab_ai_sum_' . $post_id;
	if ( ! $force_regenerate ) {
		$cached = get_transient( $transient_key );
		if ( is_string( $cached ) && $cached !== '' ) {
			return $cached;
		}
	}

	$input = bpcab_ai_summary_prepare_text( $post_id );
	if ( $input === '' ) {
		return new WP_Error( 'bpcab_no_content', 'Contenu vide : impossible de résumer.' );
	}

	$result = bpcab_ai_summary_call_openai( $input, [
		'max_bullets' => 4,
		'timeout'     => 25,
	] );

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

	// Nettoyage : on autorise uniquement un HTML simple (listes, gras, liens si jamais).
	$clean = wp_kses_post( $result );

	// Stockage long terme.
	update_post_meta( $post_id, $meta_key, $clean );

	// Cache court terme (ex. 12h). Ajustez selon votre flux éditorial.
	set_transient( $transient_key, $clean, 12 * HOUR_IN_SECONDS );

	return $clean;
}
?>

4) Bouton “Générer le résumé” dans l’admin (sécurisé)

On ajoute une metabox dans l’écran d’édition des articles. Le bouton déclenche une action admin avec un nonce (jeton anti-CSRF). Si vous débutez : un nonce empêche un site externe de faire cliquer votre navigateur “à votre insu” pour générer des résumés (et donc consommer votre quota).

Références :

<?php
/**
 * Ajoute une metabox sur les posts (vous pouvez ajouter 'page' si vous voulez).
 */
add_action( 'add_meta_boxes', function () {
	add_meta_box(
		'bpcab-ai-summary-box',
		'Résumé IA',
		'bpcab_ai_summary_metabox_render',
		'post',
		'side',
		'high'
	);
} );

function bpcab_ai_summary_metabox_render( WP_Post $post ): void {
	$meta_key = '_bpcab_ai_summary';
	$summary  = (string) get_post_meta( $post->ID, $meta_key, true );

	$generate_url = wp_nonce_url(
		add_query_arg(
			[
				'action'  => 'bpcab_generate_ai_summary',
				'post_id' => $post->ID,
			],
			admin_url( 'admin-post.php' )
		),
		'bpcab_generate_ai_summary_' . $post->ID
	);

	$regen_url = wp_nonce_url(
		add_query_arg(
			[
				'action'  => 'bpcab_generate_ai_summary',
				'post_id' => $post->ID,
				'force'   => 1,
			],
			admin_url( 'admin-post.php' )
		),
		'bpcab_generate_ai_summary_' . $post->ID
	);

	echo '<p>Générez un résumé en 4 puces. Le résultat est stocké dans la fiche de l’article.</p>';

	if ( $summary !== '' ) {
		echo '<div style="padding:8px;border:1px solid #ccd0d4;background:#fff;max-height:180px;overflow:auto">';
		echo wp_kses_post( nl2br( $summary ) );
		echo '</div>';
	} else {
		echo '<p><em>Aucun résumé généré pour le moment.</em></p>';
	}

	echo '<p><a class="button button-primary" href="' . esc_url( $generate_url ) . '">Générer</a></p>';
	echo '<p><a class="button" href="' . esc_url( $regen_url ) . '">Régénérer (force)</a></p>';

	echo '<p>Shortcode : <code>[ai_summary]</code></p>';
}

/**
 * Handler admin : génère le résumé et redirige vers l'éditeur.
 */
add_action( 'admin_post_bpcab_generate_ai_summary', function () {
	if ( ! current_user_can( 'edit_posts' ) ) {
		wp_die( 'Droits insuffisants.' );
	}

	$post_id = isset( $_GET['post_id'] ) ? (int) $_GET['post_id'] : 0;
	if ( $post_id <= 0 ) {
		wp_die( 'Post ID invalide.' );
	}

	check_admin_referer( 'bpcab_generate_ai_summary_' . $post_id );

	$force = isset( $_GET['force'] ) && (int) $_GET['force'] === 1;

	$result = bpcab_ai_summary_get_or_generate( $post_id, $force );

	// Prépare un message admin simple.
	$redirect = get_edit_post_link( $post_id, 'raw' );
	if ( ! $redirect ) {
		$redirect = admin_url( 'edit.php' );
	}

	if ( is_wp_error( $result ) ) {
		$redirect = add_query_arg(
			[
				'bpcab_ai_summary' => 'error',
				'msg'              => rawurlencode( $result->get_error_message() ),
			],
			$redirect
		);
	} else {
		$redirect = add_query_arg(
			[
				'bpcab_ai_summary' => 'success',
			],
			$redirect
		);
	}

	wp_safe_redirect( $redirect );
	exit;
} );

/**
 * Affiche un message dans l'admin après génération.
 */
add_action( 'admin_notices', function () {
	if ( ! is_admin() ) {
		return;
	}
	if ( ! isset( $_GET['bpcab_ai_summary'] ) ) {
		return;
	}

	$status = sanitize_text_field( (string) $_GET['bpcab_ai_summary'] );

	if ( $status === 'success' ) {
		echo '<div class="notice notice-success is-dismissible"><p>Résumé IA généré.</p></div>';
	} elseif ( $status === 'error' ) {
		$msg = isset( $_GET['msg'] ) ? sanitize_text_field( (string) $_GET['msg'] ) : 'Erreur inconnue.';
		echo '<div class="notice notice-error is-dismissible"><p>Résumé IA : ' . esc_html( $msg ) . '</p></div>';
	}
} );
?>

5) Shortcode d’affichage (Gutenberg, Divi, Elementor, Avada)

Un shortcode est une petite balise texte (ex. [ai_summary]) que WordPress remplace par du contenu. C’est le chemin le plus simple pour être compatible avec la majorité des builders.

Référence :

<?php
/**
 * Shortcode : [ai_summary]
 * Attributs :
 * - post_id (optionnel) : par défaut, le post courant
 * - fallback="excerpt" : si pas de résumé, affiche un extrait classique
 */
add_shortcode( 'ai_summary', function ( $atts ) {
	$atts = shortcode_atts(
		[
			'post_id'  => 0,
			'fallback' => 'none',
		],
		(array) $atts,
		'ai_summary'
	);

	$post_id = (int) $atts['post_id'];
	if ( $post_id <= 0 ) {
		$post_id = get_the_ID();
	}
	if ( ! $post_id ) {
		return '';
	}

	$summary = (string) get_post_meta( $post_id, '_bpcab_ai_summary', true );

	if ( $summary === '' ) {
		if ( $atts['fallback'] === 'excerpt' ) {
			$excerpt = has_excerpt( $post_id ) ? get_the_excerpt( $post_id ) : wp_trim_words( wp_strip_all_tags( get_post_field( 'post_content', $post_id ) ), 40 );
			return '<div class="bpcab-ai-summary"><p>' . esc_html( $excerpt ) . '</p></div>';
		}
		return '';
	}

	// On autorise un HTML simple (le meta contient déjà du wp_kses_post, mais on re-filtre à l'affichage).
	$html = wp_kses_post( nl2br( $summary ) );

	return '<div class="bpcab-ai-summary"><strong>Résumé</strong><br>' . $html . '</div>';
} );
?>

Le code assemblé complet

Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/ai-summary.php. Ensuite, vérifiez que votre clé est bien définie dans wp-config.php.

Risque réel : si vous collez ce code dans le mauvais fichier (ex. dans un plugin de snippets qui ajoute des balises <?php automatiquement), vous pouvez déclencher une erreur fatale. Si vous utilisez un plugin de snippets, collez sans les balises PHP si le plugin les ajoute déjà.

<?php
/**
 * Plugin Name: BPCAB — Résumé IA des articles
 * Description: Génère et affiche un résumé IA (stocké en meta) via OpenAI. Compatible WordPress 6.9.4+ / PHP 8.1+.
 * Author: BPCAB
 * Version: 1.0.0
 */

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

/**
 * Prépare le texte à envoyer à l'IA.
 */
function bpcab_ai_summary_prepare_text( int $post_id, int $max_chars = 12000 ): string {
	$post = get_post( $post_id );
	if ( ! $post instanceof WP_Post ) {
		return '';
	}

	$content = $post->post_content;

	// Optionnel : exécuter les shortcodes (peut ajouter du bruit selon votre site).
	$content = do_shortcode( $content );

	// Applique les filtres du contenu (blocs, embeds, etc.).
	$content = apply_filters( 'the_content', $content );

	$text = wp_strip_all_tags( $content, true );
	$text = preg_replace( '/s+/u', ' ', $text );
	$text = trim( (string) $text );

	if ( function_exists( 'mb_substr' ) ) {
		$text = mb_substr( $text, 0, $max_chars );
	} else {
		$text = substr( $text, 0, $max_chars );
	}

	return $text;
}

/**
 * Appelle l'API OpenAI pour générer un résumé.
 * Retourne soit une chaîne (résumé), soit WP_Error.
 */
function bpcab_ai_summary_call_openai( string $input_text, array $args = [] ) {
	if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || BPCAB_OPENAI_API_KEY === '' ) {
		return new WP_Error( 'bpcab_missing_api_key', 'Clé API manquante : définissez BPCAB_OPENAI_API_KEY dans wp-config.php.' );
	}

	$model = defined( 'BPCAB_OPENAI_MODEL' ) ? BPCAB_OPENAI_MODEL : 'gpt-4.1-mini';

	$defaults = [
		'max_bullets' => 4,
		'language'    => 'fr',
		'timeout'     => 25,
	];
	$args = wp_parse_args( $args, $defaults );

	$system = 'Vous êtes un assistant éditorial. Vous résumez fidèlement le texte fourni. Vous n'inventez rien.';
	$user   = "Résume le texte ci-dessous en {$args['max_bullets']} puces maximum, en {$args['language']}. "
	        . "Chaque puce doit être courte (max 18 mots). Pas de marketing, pas de phrases vagues.nn"
	        . $input_text;

	$body = [
		'model' => $model,
		'input' => [
			[
				'role'    => 'system',
				'content' => $system,
			],
			[
				'role'    => 'user',
				'content' => $user,
			],
		],
		'temperature' => 0.2,
	];

	$request_args = [
		'headers' => [
			'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
			'Content-Type'  => 'application/json',
		],
		'timeout' => (int) $args['timeout'],
		'body'    => wp_json_encode( $body ),
	];

	$response = wp_remote_post( 'https://api.openai.com/v1/responses', $request_args );

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

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

	if ( $code < 200 || $code >= 300 ) {
		$excerpt = function_exists( 'mb_substr' ) ? mb_substr( $raw, 0, 300 ) : substr( $raw, 0, 300 );
		return new WP_Error( 'bpcab_openai_http_error', 'Erreur HTTP OpenAI (' . $code . ') : ' . $excerpt, [ 'status' => $code ] );
	}

	$data = json_decode( $raw, true );
	if ( ! is_array( $data ) ) {
		return new WP_Error( 'bpcab_openai_json_error', 'Réponse JSON invalide depuis OpenAI.' );
	}

	$text = '';

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

	$text = trim( (string) $text );

	if ( $text === '' ) {
		return new WP_Error( 'bpcab_openai_empty', 'OpenAI a répondu, mais aucun texte de résumé n’a été trouvé.' );
	}

	return $text;
}

/**
 * Génère (ou récupère) le résumé IA d'un article.
 */
function bpcab_ai_summary_get_or_generate( int $post_id, bool $force_regenerate = false ) {
	$meta_key = '_bpcab_ai_summary';

	if ( ! $force_regenerate ) {
		$existing = (string) get_post_meta( $post_id, $meta_key, true );
		if ( $existing !== '' ) {
			return $existing;
		}
	}

	$transient_key = 'bpcab_ai_sum_' . $post_id;
	if ( ! $force_regenerate ) {
		$cached = get_transient( $transient_key );
		if ( is_string( $cached ) && $cached !== '' ) {
			return $cached;
		}
	}

	$input = bpcab_ai_summary_prepare_text( $post_id );
	if ( $input === '' ) {
		return new WP_Error( 'bpcab_no_content', 'Contenu vide : impossible de résumer.' );
	}

	$result = bpcab_ai_summary_call_openai( $input, [
		'max_bullets' => 4,
		'timeout'     => 25,
	] );

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

	$clean = wp_kses_post( $result );

	update_post_meta( $post_id, $meta_key, $clean );
	set_transient( $transient_key, $clean, 12 * HOUR_IN_SECONDS );

	return $clean;
}

/**
 * Metabox dans l'éditeur.
 */
add_action( 'add_meta_boxes', function () {
	add_meta_box(
		'bpcab-ai-summary-box',
		'Résumé IA',
		'bpcab_ai_summary_metabox_render',
		'post',
		'side',
		'high'
	);
} );

function bpcab_ai_summary_metabox_render( WP_Post $post ): void {
	$summary  = (string) get_post_meta( $post->ID, '_bpcab_ai_summary', true );

	$generate_url = wp_nonce_url(
		add_query_arg(
			[
				'action'  => 'bpcab_generate_ai_summary',
				'post_id' => $post->ID,
			],
			admin_url( 'admin-post.php' )
		),
		'bpcab_generate_ai_summary_' . $post->ID
	);

	$regen_url = wp_nonce_url(
		add_query_arg(
			[
				'action'  => 'bpcab_generate_ai_summary',
				'post_id' => $post->ID,
				'force'   => 1,
			],
			admin_url( 'admin-post.php' )
		),
		'bpcab_generate_ai_summary_' . $post->ID
	);

	echo '<p>Générez un résumé en 4 puces. Le résultat est stocké dans la fiche de l’article.</p>';

	if ( $summary !== '' ) {
		echo '<div style="padding:8px;border:1px solid #ccd0d4;background:#fff;max-height:180px;overflow:auto">';
		echo wp_kses_post( nl2br( $summary ) );
		echo '</div>';
	} else {
		echo '<p><em>Aucun résumé généré pour le moment.</em></p>';
	}

	echo '<p><a class="button button-primary" href="' . esc_url( $generate_url ) . '">Générer</a></p>';
	echo '<p><a class="button" href="' . esc_url( $regen_url ) . '">Régénérer (force)</a></p>';
	echo '<p>Shortcode : <code>[ai_summary]</code></p>';
}

/**
 * Handler admin sécurisé.
 */
add_action( 'admin_post_bpcab_generate_ai_summary', function () {
	if ( ! current_user_can( 'edit_posts' ) ) {
		wp_die( 'Droits insuffisants.' );
	}

	$post_id = isset( $_GET['post_id'] ) ? (int) $_GET['post_id'] : 0;
	if ( $post_id <= 0 ) {
		wp_die( 'Post ID invalide.' );
	}

	check_admin_referer( 'bpcab_generate_ai_summary_' . $post_id );

	$force  = isset( $_GET['force'] ) && (int) $_GET['force'] === 1;
	$result = bpcab_ai_summary_get_or_generate( $post_id, $force );

	$redirect = get_edit_post_link( $post_id, 'raw' );
	if ( ! $redirect ) {
		$redirect = admin_url( 'edit.php' );
	}

	if ( is_wp_error( $result ) ) {
		$redirect = add_query_arg(
			[
				'bpcab_ai_summary' => 'error',
				'msg'              => rawurlencode( $result->get_error_message() ),
			],
			$redirect
		);
	} else {
		$redirect = add_query_arg(
			[
				'bpcab_ai_summary' => 'success',
			],
			$redirect
		);
	}

	wp_safe_redirect( $redirect );
	exit;
} );

/**
 * Notices admin.
 */
add_action( 'admin_notices', function () {
	if ( ! is_admin() ) {
		return;
	}
	if ( ! isset( $_GET['bpcab_ai_summary'] ) ) {
		return;
	}

	$status = sanitize_text_field( (string) $_GET['bpcab_ai_summary'] );

	if ( $status === 'success' ) {
		echo '<div class="notice notice-success is-dismissible"><p>Résumé IA généré.</p></div>';
	} elseif ( $status === 'error' ) {
		$msg = isset( $_GET['msg'] ) ? sanitize_text_field( (string) $_GET['msg'] ) : 'Erreur inconnue.';
		echo '<div class="notice notice-error is-dismissible"><p>Résumé IA : ' . esc_html( $msg ) . '</p></div>';
	}
} );

/**
 * Shortcode : [ai_summary]
 */
add_shortcode( 'ai_summary', function ( $atts ) {
	$atts = shortcode_atts(
		[
			'post_id'  => 0,
			'fallback' => 'none',
		],
		(array) $atts,
		'ai_summary'
	);

	$post_id = (int) $atts['post_id'];
	if ( $post_id <= 0 ) {
		$post_id = get_the_ID();
	}
	if ( ! $post_id ) {
		return '';
	}

	$summary = (string) get_post_meta( $post_id, '_bpcab_ai_summary', true );

	if ( $summary === '' ) {
		if ( $atts['fallback'] === 'excerpt' ) {
			$excerpt = has_excerpt( $post_id ) ? get_the_excerpt( $post_id ) : wp_trim_words( wp_strip_all_tags( get_post_field( 'post_content', $post_id ) ), 40 );
			return '<div class="bpcab-ai-summary"><p>' . esc_html( $excerpt ) . '</p></div>';
		}
		return '';
	}

	$html = wp_kses_post( nl2br( $summary ) );

	return '<div class="bpcab-ai-summary"><strong>Résumé</strong><br>' . $html . '</div>';
} );
?>

Explication du code

Préparation du texte

bpcab_ai_summary_prepare_text() fait le ménage avant l’envoi :

  • apply_filters('the_content', ...) reconstruit un rendu cohérent (blocs, embeds).
  • wp_strip_all_tags() retire le HTML pour éviter que l’IA “résume” votre mise en page.
  • la coupe à 12 000 caractères est un garde-fou contre les articles énormes (et les factures).

Piège courant : certains collent directement get_the_content() dans l’API. Résultat : vous envoyez des shortcodes non interprétés, des commentaires HTML, et parfois des scripts inclus par un builder.

Appel HTTP via wp_remote_post()

wp_remote_post() envoie la requête. On fixe :

  • timeout à 25 secondes : au-delà, beaucoup d’hébergements mutualisés coupent.
  • Content-Type JSON + header Authorization.
  • un prompt strict pour obtenir des puces courtes.

Piège courant : utiliser un vieux tutoriel qui appelle https://api.openai.com/v1/chat/completions avec un format obsolète, puis croire que “l’API a cassé”. En 2026, l’endpoint /v1/responses est le plus pratique pour ce cas.

Cache + meta

On stocke dans :

  • post meta _bpcab_ai_summary : persistant.
  • transient bpcab_ai_sum_{$post_id} : évite les doubles appels rapprochés.

J’ai souvent croisé un bug sur des sites avec un plugin de cache agressif : les gens testent, cliquent deux fois, et déclenchent deux appels IA. Le transient coupe ça net.

Metabox + nonce

La metabox ajoute un bouton. Le handler est sur admin-post.php, ce qui évite d’écrire un endpoint REST complet pour un premier usage.

Le nonce est non négociable : sans lui, n’importe quelle page externe pourrait déclencher une génération si vous êtes connecté à l’admin.

Affichage via shortcode

Le shortcode renvoie un HTML simple. Si vous voulez un rendu en liste, vous pouvez demander à l’IA de renvoyer un <ul>, mais je préfère du texte + nl2br() : c’est plus robuste sur les thèmes/builder.

Coûts API et optimisation

Le coût dépend du modèle et du volume de texte envoyé. Pour un article de 1500 mots, vous envoyez souvent l’équivalent de quelques milliers de tokens. Avec un modèle “mini”, ça reste généralement faible à l’unité, mais ça grimpe vite si vous régénérez en masse.

Estimation simple (à adapter)

Exemple réaliste :

  • 100 articles/mois
  • 1 génération par article (pas à chaque mise à jour)
  • résumé court (4 puces)

Vous paierez typiquement quelques euros à quelques dizaines d’euros par mois selon le modèle et la longueur moyenne. La vraie dérive vient de deux erreurs :

  • générer à chaque affichage de page,
  • envoyer le contenu complet sans limite (articles + commentaires + blocs lourds).

Optimisations concrètes

  • Coupez le texte (déjà fait) : 8k–12k caractères suffit souvent.
  • Générez sur action (bouton) plutôt qu’en automatique sur save_post.
  • Réutilisez le résumé (newsletter, OG description, cartes) pour rentabiliser l’appel.
  • Choisissez un modèle plus petit pour du résumé (souvent aucun intérêt à prendre le plus gros).

Variantes et cas d’usage avancés

1) Générer automatiquement à la publication (avec garde-fous)

Si vous voulez automatiser, faites-le uniquement quand un article passe en “publish”, et avec un contrôle pour éviter de relancer à chaque autosave. Sinon, vous allez payer pour rien.

<?php
// Variante : génération automatique lors de la publication (optionnel).
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
	if ( ! $post instanceof WP_Post ) {
		return;
	}
	if ( $post->post_type !== 'post' ) {
		return;
	}

	// On génère seulement quand on publie pour la première fois.
	if ( $old_status !== 'publish' && $new_status === 'publish' ) {
		// Évite de faire ça sur des révisions/autosaves.
		if ( wp_is_post_revision( $post->ID ) || wp_is_post_autosave( $post->ID ) ) {
			return;
		}

		// Si déjà présent, on ne touche pas.
		$existing = (string) get_post_meta( $post->ID, '_bpcab_ai_summary', true );
		if ( $existing !== '' ) {
			return;
		}

		// Génération (sans forcer).
		$result = bpcab_ai_summary_get_or_generate( $post->ID, false );

		// Si erreur, on ne bloque pas la publication.
		if ( is_wp_error( $result ) ) {
			error_log( 'Résumé IA : ' . $result->get_error_message() );
		}
	}
}, 10, 3 );
?>

2) Sortie “SEO-friendly” pour meta description

Vous pouvez générer une version 155–160 caractères et la stocker dans une autre meta. Attention : si vous utilisez Yoast/RankMath, ils ont leurs propres champs. L’idée ici est de vous montrer la mécanique, pas d’écraser des réglages SEO.

3) Intégration Divi 5 / Elementor / Avada

  • Gutenberg : utilisez un bloc “Code court” et mettez [ai_summary].
  • Divi 5 : module “Code” ou “Text”, insérez [ai_summary]. Si vous ne voyez pas le rendu dans le builder, testez côté front (Divi peut différer en mode visual).
  • Elementor : widget “Shortcode”, collez [ai_summary]. Si vous utilisez un cache, videz-le après génération.
  • Avada : élément “Shortcode” dans Fusion Builder, ou directement dans le contenu.

Sécurité et bonnes pratiques

Les 4 règles que je répète sur chaque site :

  • Jamais de clé API côté client : pas de JS qui appelle OpenAI depuis le navigateur. Sinon, la clé est récupérable et réutilisable.
  • Nonces partout pour les actions admin (déjà fait).
  • Nettoyez la sortie : on applique wp_kses_post() avant stockage et avant affichage. Même si l’IA n’est pas “malveillante”, vous ne voulez pas d’HTML inattendu.
  • Rate limiting : si vous ouvrez la génération au front (mauvaise idée), imposez une limite par IP/utilisateur. Ici, c’est admin-only, donc déjà réduit.

RGPD / confidentialité

Si vous envoyez le contenu d’un article à un service externe, vous transférez des données à un sous-traitant. Pour un blog public, c’est souvent acceptable, mais :

  • évitez d’envoyer des données personnelles (noms, emails) si vous résumez des contenus privés,
  • documentez le sous-traitant dans votre politique de confidentialité,
  • si vous résumez des contenus soumis à des obligations (santé, juridique), validez votre cadre légal.

Comment tester et déboguer

Testez toujours sur un environnement de staging si possible. Au minimum : sauvegarde avant d’ajouter du PHP.

Activer les logs WordPress

Dans wp-config.php :

<?php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
?>

Ensuite :

  • cliquez “Générer” sur un article court,
  • si erreur : regardez wp-content/debug.log.

Tester l’appel HTTP sans IA (diagnostic réseau)

Si wp_remote_post() échoue partout, le problème est souvent réseau/SSL/proxy sur l’hébergement.

Vous pouvez tester un GET simple :

<?php
add_action( 'admin_init', function () {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	if ( ! isset( $_GET['bpcab_http_test'] ) ) {
		return;
	}

	$r = wp_remote_get( 'https://www.wordpress.org', [ 'timeout' => 10 ] );
	if ( is_wp_error( $r ) ) {
		wp_die( 'HTTP test error: ' . esc_html( $r->get_error_message() ) );
	}
	wp_die( 'HTTP test OK: ' . (int) wp_remote_retrieve_response_code( $r ) );
} );
?>

Si ça ne marche pas

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Erreur fatale (écran blanc) après collage Code collé au mauvais endroit / parenthèse ou point-virgule manquant Consultez wp-content/debug.log ou les logs serveur Utilisez un mu-plugin, revenez en arrière, corrigez la syntaxe
OpenAI HTTP 401 Clé invalide / mal copiée / constante non définie Vérifiez BPCAB_OPENAI_API_KEY dans wp-config.php Regénérez une clé, copiez sans espaces, vérifiez le bon fichier
OpenAI HTTP 429 Quota dépassé ou rate limit Regardez le body d’erreur (extrait dans le message) Attendez, réduisez les appels, activez cache, utilisez un modèle plus petit
Timeout / cURL error 28 Timeout trop faible ou hébergeur lent/bloqué Testez wp_remote_get() vers wordpress.org Augmentez timeout à 35, vérifiez firewall sortant
Le résumé ne s’affiche pas dans le builder Shortcode non rendu en mode éditeur / cache Testez côté front + videz cache plugin/CDN Utilisez le widget/module “Shortcode”, purge cache
Le bouton génère mais rien n’est stocké Conflit de permissions ou post_id incorrect Vérifiez que vous êtes admin/auteur avec droits d’édition Vérifiez current_user_can() et l’URL générée

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

  • Copier le code dans le thème parent : à la prochaine mise à jour du thème, vous perdez tout. Utilisez un mu-plugin ou un plugin custom.
  • Oublier de définir la constante : le code fonctionne, mais renvoie “Clé API manquante”. Vérifiez wp-config.php et les guillemets.
  • Hook inadapté : si vous mettez la génération sur the_content, vous déclenchez l’IA à chaque affichage. Gardez la génération sur action admin ou publication.
  • Tester en production sans sauvegarde : une virgule en trop et vous êtes en panne. Sauvegarde avant.
  • Ancien tuto incompatible : si vous adaptez un snippet 2023, il peut viser des endpoints/paramètres obsolètes. En 2026, préférez /v1/responses et une extraction robuste.

Ressources

FAQ

Est-ce que ce système fonctionne avec WordPress 6.9.4 et PHP 8.1 ?

Oui : tout repose sur l’API HTTP WordPress, les post meta et les transients, stables depuis longtemps. Le code évite les APIs “exotiques” et reste compatible PHP 8.1+.

Pourquoi ne pas générer le résumé à chaque affichage de l’article ?

Parce que vous payez à chaque visite, et vous risquez de saturer votre quota (429). Générer une fois et stocker est la stratégie la plus saine.

Où se trouve le résumé dans la base de données ?

Dans la table wp_postmeta, clé _bpcab_ai_summary. Vous pouvez le voir avec un plugin comme WP-CLI ou un outil DB, mais évitez d’éditer directement en SQL si vous débutez.

Puis-je afficher le résumé sur les pages catégories (archives) ?

Oui. Dans la boucle, utilisez [ai_summary post_id="123"] ou appelez directement get_post_meta(get_the_ID(), '_bpcab_ai_summary', true) dans un template.

Le résumé peut-il être faux ?

Oui. Même avec un prompt strict, l’IA peut simplifier trop fort ou rater un détail. Sur des sujets sensibles, gardez une validation humaine.

Est-ce que je peux changer le format (paragraphe au lieu de puces) ?

Oui : modifiez la consigne dans $user. Par exemple : “Résume en 2 phrases maximum (160 caractères)”. Gardez une température basse.

Pourquoi utiliser un mu-plugin plutôt qu’un plugin classique ?

Un mu-plugin est chargé automatiquement et ne peut pas être désactivé par erreur dans l’admin. C’est pratique pour des fonctionnalités “structurelles”. Si vous préférez pouvoir activer/désactiver, faites un plugin classique.

Elementor/Divi n’affiche pas le rendu dans l’éditeur, c’est normal ?

Souvent oui. Certains builders n’exécutent pas tous les shortcodes en mode édition visuelle. Vérifiez côté front, puis purgez le cache (plugin/CDN).

Comment éviter de renvoyer du contenu privé à l’API ?

N’activez pas la génération automatique sur des types de contenu privés, et filtrez le texte (retirez emails, numéros) si nécessaire. Pour des intranets, discutez du cadre légal et des DPA.

Je reçois “Réponse JSON invalide”, que faire ?

Ça arrive si un firewall injecte une page HTML, ou si l’hébergeur tronque la réponse. Vérifiez le code HTTP, logguez temporairement le body complet, et testez la connectivité sortante.

Puis-je remplacer OpenAI par Anthropic ou Mistral ?

Oui : gardez la même architecture (prepare → cache → appel HTTP → nettoyage → meta). Seule la fonction bpcab_ai_summary_call_openai() change (URL + format JSON + extraction du texte).