Si vous avez déjà publié un article puis réalisé, deux semaines plus tard, que la meta description est restée vide (ou pire : un extrait automatique coupé au mauvais endroit), vous avez déjà touché le vrai problème : c’est répétitif, facile à oublier, et ça se voit dans les résultats Google.
Le besoin / Le cas d’usage
Une meta description est le petit texte (souvent 150–160 caractères) que les moteurs de recherche peuvent afficher sous le titre. WordPress 6.9.4 ne génère pas de meta description “SEO” native : selon votre thème, vous aurez soit rien, soit un extrait basé sur le contenu. Les plugins SEO (Yoast, Rank Math, SEOPress…) ajoutent un champ, mais ils ne le remplissent pas pour vous.
L’IA est utile ici pour produire rapidement une description :
- cohérente avec le contenu réel de l’article,
- dans une longueur maîtrisée,
- avec un ton constant (informatif, commercial, neutre…),
- sans devoir relire 2 000 mots à chaque publication.
À la fin, vous saurez implémenter un petit plugin WordPress (compatible WP 6.9.4 / PHP 8.1+) qui :
- ajoute un bouton “Générer avec l’IA” dans l’éditeur (Gutenberg),
- appelle une API IA via
wp_remote_post(), - met en cache les résultats avec les Transients,
- enregistre la meta description en post meta (et peut aussi remplir le champ d’un plugin SEO si vous le souhaitez).
Je vois souvent ce workflow sur des sites avec plusieurs auteurs : personne ne “possède” la tâche meta description, et le backlog SEO s’accumule. Automatiser la génération au moment de l’édition règle ce point sans vous enfermer dans un outil.
Résumé rapide
- Vous stockez la clé API dans
wp-config.php(jamais en dur dans un plugin). - Vous installez un mu-plugin (chargé automatiquement) qui expose un endpoint REST sécurisé.
- L’éditeur appelle cet endpoint, qui appelle l’API IA avec
wp_remote_post(). - Réponse nettoyée (sanitization), tronquée, puis sauvegardée en post meta.
- Cache via
get_transient()/set_transient()pour limiter les coûts.
Quand utiliser l’IA pour ça
Utilisez l’IA pour générer des meta descriptions si :
- vous publiez beaucoup (ou à plusieurs) et vous voulez un “minimum SEO” systématique,
- vos articles ont une structure régulière (tutoriels, tests produit, recettes, fiches…),
- vous avez déjà un ton éditorial défini et vous pouvez le décrire en 2–3 phrases dans un prompt,
- vous voulez accélérer la mise à jour d’anciens contenus (audit + génération en série).
Dans mon expérience, le gain est maximal quand vous combinez IA + règles simples : longueur, interdiction des guillemets, pas de promesses non vérifiables, et pas de “Cliquez ici”.
Quand ne PAS utiliser l’IA
Évitez (ou limitez) l’IA si :
- vos contenus touchent à des sujets sensibles (santé, juridique, finance) et vous n’avez pas de relecture stricte,
- vous avez des contraintes légales fortes (RGPD, données personnelles dans le contenu),
- vous pouvez faire mieux avec une règle déterministe : par exemple, si votre intro contient toujours une phrase parfaite, un simple extrait + nettoyage suffit.
Une solution classique (sans IA) peut être plus simple et gratuite : prendre l’extrait WordPress, retirer les shortcodes, limiter à 155 caractères, et sauvegarder. C’est souvent “assez bien” pour un petit blog.
Autre cas : si vous utilisez déjà un plugin SEO avec des templates dynamiques (variables), vous pouvez parfois générer une description correcte sans IA. L’IA devient alors un outil de relecture/optimisation, pas la source.
Prérequis
Vous allez faire un appel HTTP depuis WordPress vers un service externe. Une API (Application Programming Interface) est une interface qui permet à un logiciel d’en interroger un autre. Concrètement : vous envoyez une requête HTTP (souvent en JSON) à une URL, et vous recevez une réponse JSON.
WordPress fournit wp_remote_post() pour faire ces requêtes serveur à serveur. Documentation officielle : wp_remote_post().
Versions et environnement
- WordPress : 6.9.4 (ou plus)
- PHP : 8.1 minimum (8.2/8.3 recommandé si votre hébergeur le permet)
- HTTPS actif sur l’admin (fortement recommandé)
- Accès administrateur pour installer un mu-plugin
Clé API et coûts
Vous pouvez utiliser OpenAI, Anthropic, Mistral, Google… Le code ci-dessous cible OpenAI (Responses API) car c’est stable et bien documenté. Adaptez ensuite si vous préférez un autre fournisseur.
- Créez une clé API sur : platform.openai.com
- Documentation API : OpenAI Responses API
Avertissement coûts : chaque génération consomme des tokens. Si vous générez 500 descriptions d’un coup, la facture peut grimper. Le cache (transients) et des modèles “mini” réduisent fortement la note.
Où stocker la clé (obligatoire : wp-config.php)
Ajoutez ceci dans wp-config.php, idéalement juste au-dessus de la ligne “That’s all, stop editing!” :
define( 'BPCAB_OPENAI_API_KEY', 'VOTRE_CLE_API_ICI' );
Ne mettez jamais cette clé :
- dans un thème (risque de perdre la clé lors d’une mise à jour),
- dans un dépôt Git public,
- dans du JavaScript (elle serait visible par tous).
Où coller le code du plugin
Pour un débutant, le plus fiable est un mu-plugin (Must-Use). Il se charge automatiquement, même si vous changez de thème.
- Créez le dossier :
wp-content/mu-plugins/(s’il n’existe pas) - Créez le fichier :
wp-content/mu-plugins/ai-meta-description.php
Référence : Must-Use Plugins (mu-plugins).
Architecture de la solution
Flux (schéma textuel) :
Éditeur WordPress (bouton) → appel REST interne (admin) → endpoint WordPress →
wp_remote_post()→ API IA → réponse JSON → nettoyage + contrôle longueur → sauvegarde en post meta → retour à l’éditeur
Détails des étapes
- UI côté éditeur : un petit script JS ajoute un bouton dans la barre du document (Gutenberg) et appelle l’API REST.
- Endpoint REST : une route
/bpcab/v1/meta-descriptionreçoitpost_idet un mode (“draft” ou “save”). - Sécurité : permissions WordPress (capability
edit_post) + nonce REST. - Appel IA : serveur → serveur via
wp_remote_post(), timeout court, gestion d’erreurs. - Cache : un transient basé sur le hash du contenu pour éviter de payer deux fois la même génération.
- Stockage : sauvegarde en
post meta(clé_bpcab_meta_description). Vous pouvez ensuite l’exposer au front ou la mapper vers un plugin SEO.
Le code complet — étape par étape
Un mot sur les “hooks” avant de coder. Un hook est un point d’extension de WordPress. Il en existe deux types :
- Action : déclenche un code à un moment donné (ex :
rest_api_init). - Filtre : modifie une valeur (ex : filtrer un contenu).
Référence : Hooks (actions & filters).
Étape 1 — créer une post meta dédiée
On enregistre une meta “propre” et visible via REST (utile pour Gutenberg). Référence : register_post_meta().
<?php
/**
* Plugin Name: BPCAB - IA Meta Description
* Description: Génère des meta descriptions via IA depuis l'éditeur WordPress (WP 6.9.4+).
* Author: BPCAB
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'init', function () {
register_post_meta(
'',
'_bpcab_meta_description',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
} );
Étape 2 — endpoint REST sécurisé
On expose une route REST interne. Gutenberg sait appeler l’API REST de WordPress facilement, et vous gardez la clé API côté serveur.
Documentation REST : WordPress REST API.
add_action( 'rest_api_init', function () {
register_rest_route(
'bpcab/v1',
'/meta-description',
array(
'methods' => 'POST',
'permission_callback' => function ( WP_REST_Request $request ) {
$post_id = absint( $request->get_param( 'post_id' ) );
if ( ! $post_id ) {
return new WP_Error( 'bpcab_missing_post_id', 'post_id manquant.', array( 'status' => 400 ) );
}
return current_user_can( 'edit_post', $post_id );
},
'callback' => 'bpcab_rest_generate_meta_description',
'args' => array(
'post_id' => array(
'type' => 'integer',
'required' => true,
),
'mode' => array(
'type' => 'string',
'default' => 'draft',
'enum' => array( 'draft', 'save' ),
),
),
)
);
} );
function bpcab_rest_generate_meta_description( WP_REST_Request $request ) : WP_REST_Response {
$post_id = absint( $request->get_param( 'post_id' ) );
$mode = (string) $request->get_param( 'mode' );
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_REST_Response(
array( 'error' => 'Article introuvable.' ),
404
);
}
// On se base sur le contenu + titre. Vous pouvez adapter selon votre ligne éditoriale.
$title = (string) get_the_title( $post );
$content = (string) $post->post_content;
// Nettoyage basique : on retire les shortcodes et les balises.
$content_plain = wp_strip_all_tags( strip_shortcodes( $content ) );
$content_plain = trim( preg_replace( '/s+/', ' ', $content_plain ) );
// Si l'article est vide (cas fréquent sur brouillon), on évite de payer un appel IA inutile.
if ( mb_strlen( $content_plain ) < 80 ) {
return new WP_REST_Response(
array(
'error' => 'Contenu trop court. Ajoutez un peu de texte avant de générer.',
),
400
);
}
$result = bpcab_generate_meta_description_via_openai(
array(
'post_id' => $post_id,
'title' => $title,
'content' => $content_plain,
'language' => 'fr',
)
);
if ( is_wp_error( $result ) ) {
return new WP_REST_Response(
array(
'error' => $result->get_error_message(),
'details' => $result->get_error_data(),
),
500
);
}
$meta_description = $result['meta_description'];
if ( 'save' === $mode ) {
update_post_meta( $post_id, '_bpcab_meta_description', $meta_description );
}
return new WP_REST_Response(
array(
'meta_description' => $meta_description,
'saved' => ( 'save' === $mode ),
),
200
);
}
Étape 3 — appel IA via wp_remote_post() + cache transient
On met en cache par “empreinte” (hash) du titre + contenu. Si vous regénérez sans changer l’article, vous récupérez le cache.
Référence Transients : Transients API.
function bpcab_generate_meta_description_via_openai( array $data ) {
if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || ! BPCAB_OPENAI_API_KEY ) {
return new WP_Error( 'bpcab_missing_api_key', 'Clé API manquante. Ajoutez BPCAB_OPENAI_API_KEY dans wp-config.php.' );
}
$post_id = absint( $data['post_id'] ?? 0 );
$title = (string) ( $data['title'] ?? '' );
$content = (string) ( $data['content'] ?? '' );
// Empreinte pour le cache : si le contenu change, on regénère.
$cache_key_raw = $title . '|' . $content;
$cache_key = 'bpcab_md_' . md5( $cache_key_raw );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && ! empty( $cached['meta_description'] ) ) {
return $cached;
}
// Prompt “contraignant” : court, sans guillemets, sans emojis, sans promesses.
$system_instructions = 'Vous êtes un assistant SEO. Vous écrivez une meta description en français, factuelle, sans superlatifs inutiles.';
$user_prompt = "Générez une meta description SEO pour l'article suivant.n"
. "- Longueur: 140 à 160 caractères (espaces inclus)n"
. "- Une seule phrasen"
. "- Pas de guillemetsn"
. "- Pas de call-to-action du type "Cliquez" ou "Découvrez"n"
. "- Doit résumer fidèlement le contenunn"
. "Titre: {$title}nn"
. "Contenu:n{$content}n";
$body = array(
'model' => 'gpt-4.1-mini',
'input' => array(
array(
'role' => 'system',
'content' => $system_instructions,
),
array(
'role' => 'user',
'content' => $user_prompt,
),
),
// On limite la sortie : une meta description ne doit pas devenir un paragraphe.
'max_output_tokens' => 120,
// Température basse = plus stable.
'temperature' => 0.4,
);
$args = array(
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
),
'body' => wp_json_encode( $body ),
// Timeouts : si votre hébergeur est lent, augmentez à 20.
'timeout' => 15,
);
$response = wp_remote_post( 'https://api.openai.com/v1/responses', $args );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'bpcab_http_error',
'Erreur HTTP lors de l’appel à l’API IA.',
array( 'wp_error' => $response->get_error_message() )
);
}
$code = wp_remote_retrieve_response_code( $response );
$raw = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error(
'bpcab_api_non_200',
'Réponse non valide de l’API IA.',
array(
'status_code' => $code,
'body' => $raw,
)
);
}
$json = json_decode( $raw, true );
if ( ! is_array( $json ) ) {
return new WP_Error(
'bpcab_json_decode',
'Impossible de décoder la réponse JSON de l’API.',
array( 'body' => $raw )
);
}
// Extraction robuste : selon les formats, le texte peut se trouver à plusieurs endroits.
$text = bpcab_extract_text_from_openai_responses_api( $json );
$text = is_string( $text ) ? $text : '';
// Nettoyage : on veut une string sûre, sans HTML.
$text = wp_strip_all_tags( $text );
$text = sanitize_text_field( $text );
$text = trim( preg_replace( '/s+/', ' ', $text ) );
// Garde-fous : longueur + suppression guillemets (j’ai vu l’IA en ajouter malgré l’instruction).
$text = str_replace( array( '"', '“', '”', '’' ), array( '', '', '', "'" ), $text );
// Tronquage “propre” si ça dépasse (évite de casser un caractère multibyte).
if ( mb_strlen( $text ) > 160 ) {
$text = mb_substr( $text, 0, 160 );
$text = rtrim( $text, " tnrx0B-–—,;:" );
}
// Si trop court, on préfère renvoyer quand même, mais vous pouvez forcer une régénération.
if ( mb_strlen( $text ) < 80 ) {
// Pas une erreur fatale : certains articles très simples donnent des descriptions courtes.
}
$result = array(
'meta_description' => $text,
'model' => $json['model'] ?? 'unknown',
);
// Cache 30 jours. Ajustez : si vous modifiez souvent les articles, réduisez à 7 jours.
set_transient( $cache_key, $result, 30 * DAY_IN_SECONDS );
return $result;
}
/**
* Extraction du texte depuis la Responses API.
* On évite les dépendances : extraction “best effort”.
*/
function bpcab_extract_text_from_openai_responses_api( array $json ) : string {
// Format courant : output[0].content[0].text
if ( isset( $json['output'][0]['content'][0]['text'] ) && is_string( $json['output'][0]['content'][0]['text'] ) ) {
return $json['output'][0]['content'][0]['text'];
}
// Fallback : concaténer tous les segments texte trouvés.
$texts = array();
if ( isset( $json['output'] ) && is_array( $json['output'] ) ) {
foreach ( $json['output'] as $out ) {
if ( empty( $out['content'] ) || ! is_array( $out['content'] ) ) {
continue;
}
foreach ( $out['content'] as $c ) {
if ( isset( $c['text'] ) && is_string( $c['text'] ) ) {
$texts[] = $c['text'];
}
}
}
}
return trim( implode( ' ', $texts ) );
}
Étape 4 — ajouter un bouton dans Gutenberg (JS) + nonce REST
On ajoute un script uniquement dans l’éditeur. On utilise wp.apiFetch et on passe le nonce REST via wp_localize_script.
Piège courant : coller ce JS en front (ou dans un builder) et exposer la route sans permissions. Ici, on reste dans l’admin, et l’endpoint vérifie edit_post.
add_action( 'enqueue_block_editor_assets', function () {
$asset_handle = 'bpcab-ai-meta-description-editor';
wp_enqueue_script(
$asset_handle,
plugins_url( 'bpcab-ai-meta-description-editor.js', __FILE__ ),
array( 'wp-data', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-plugins' ),
'1.0.0',
true
);
wp_localize_script(
$asset_handle,
'BPCAB_AIMD',
array(
'restUrl' => esc_url_raw( rest_url( 'bpcab/v1/meta-description' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
)
);
} );
Créez ensuite le fichier JS à côté du mu-plugin. Problème : un mu-plugin n’a pas “naturellement” un dossier d’assets. Deux options :
- Option débutant : mettez le JS dans
wp-content/plugins/bpcab-ai-meta-description/(plugin classique) au lieu de mu-plugin. - Option mu-plugin : placez le JS dans un chemin fixe (ex :
/wp-content/mu-plugins/assets/) et utilisez une URL construite à la main.
Pour éviter de vous faire trébucher sur les chemins, la section “Code assemblé complet” ci-dessous vous donne une version plugin classique (plus simple pour charger un JS). Le mu-plugin reste très bien pour la partie serveur, mais pour un débutant, plugin classique = moins de surprises.
Voici le fichier bpcab-ai-meta-description-editor.js :
( function ( wp ) {
const { registerPlugin } = wp.plugins;
const { PluginDocumentSettingPanel } = wp.editPost;
const { TextControl, Button, Notice } = wp.components;
const { useSelect, useDispatch } = wp.data;
const { useState } = wp.element;
function AIMetaDescriptionPanel() {
const postId = useSelect( ( select ) => select( 'core/editor' ).getCurrentPostId(), [] );
const currentMeta = useSelect( ( select ) => {
const meta = select( 'core/editor' ).getEditedPostAttribute( 'meta' );
return meta ? meta._bpcab_meta_description : '';
}, [] );
const { editPost } = useDispatch( 'core/editor' );
const [ value, setValue ] = useState( currentMeta || '' );
const [ isLoading, setIsLoading ] = useState( false );
const [ error, setError ] = useState( '' );
const [ info, setInfo ] = useState( '' );
async function generate( mode ) {
setError( '' );
setInfo( '' );
setIsLoading( true );
try {
const res = await wp.apiFetch( {
url: BPCAB_AIMD.restUrl,
method: 'POST',
headers: {
'X-WP-Nonce': BPCAB_AIMD.nonce,
},
data: {
post_id: postId,
mode: mode, // 'draft' ou 'save'
},
} );
if ( res && res.meta_description ) {
setValue( res.meta_description );
editPost( { meta: { _bpcab_meta_description: res.meta_description } } );
if ( res.saved ) {
setInfo( 'Meta description générée et enregistrée.' );
} else {
setInfo( 'Meta description générée (non enregistrée). Cliquez sur “Enregistrer” si elle vous convient.' );
}
} else {
setError( 'Réponse inattendue du serveur.' );
}
} catch ( e ) {
// Message souvent vu : "You are probably offline." ou erreur REST.
setError( e.message ? e.message : 'Erreur lors de l’appel REST.' );
} finally {
setIsLoading( false );
}
}
return wp.element.createElement(
PluginDocumentSettingPanel,
{
name: 'bpcab-ai-meta-description',
title: 'Meta description (IA)',
className: 'bpcab-ai-meta-description-panel',
},
error ? wp.element.createElement( Notice, { status: 'error', isDismissible: true }, error ) : null,
info ? wp.element.createElement( Notice, { status: 'info', isDismissible: true }, info ) : null,
wp.element.createElement( TextControl, {
label: 'Meta description',
help: 'Stockée dans la meta _bpcab_meta_description. Vous pouvez la copier dans votre plugin SEO si besoin.',
value: value,
onChange: ( v ) => {
setValue( v );
editPost( { meta: { _bpcab_meta_description: v } } );
},
} ),
wp.element.createElement(
'div',
{ style: { display: 'flex', gap: '8px' } },
wp.element.createElement( Button, { variant: 'secondary', isBusy: isLoading, disabled: isLoading, onClick: () => generate( 'draft' ) }, 'Générer (aperçu)' ),
wp.element.createElement( Button, { variant: 'primary', isBusy: isLoading, disabled: isLoading, onClick: () => generate( 'save' ) }, 'Générer & enregistrer' )
)
);
}
registerPlugin( 'bpcab-ai-meta-description', {
render: AIMetaDescriptionPanel,
} );
} )( window.wp );
Piège classique : oublier la dépendance wp-plugins ou wp-edit-post dans wp_enqueue_script(). Résultat : écran blanc dans l’éditeur et une erreur console du type “Cannot read properties of undefined”.
Le code assemblé complet
Version “prête à copier-coller” en plugin classique (recommandé ici pour charger facilement le JS).
Arborescence
wp-content/plugins/bpcab-ai-meta-description/bpcab-ai-meta-description.phpwp-content/plugins/bpcab-ai-meta-description/bpcab-ai-meta-description-editor.js
Fichier PHP complet
<?php
/**
* Plugin Name: BPCAB - IA Meta Description
* Description: Génération de meta descriptions via IA depuis l’éditeur (WP 6.9.4+, PHP 8.1+).
* Version: 1.0.0
* Author: BPCAB
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Enregistre la post meta qui stocke la meta description générée.
*/
add_action( 'init', function () {
register_post_meta(
'',
'_bpcab_meta_description',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
} );
/**
* Route REST pour générer / enregistrer la meta description.
*/
add_action( 'rest_api_init', function () {
register_rest_route(
'bpcab/v1',
'/meta-description',
array(
'methods' => 'POST',
'permission_callback' => function ( WP_REST_Request $request ) {
$post_id = absint( $request->get_param( 'post_id' ) );
if ( ! $post_id ) {
return new WP_Error( 'bpcab_missing_post_id', 'post_id manquant.', array( 'status' => 400 ) );
}
return current_user_can( 'edit_post', $post_id );
},
'callback' => 'bpcab_rest_generate_meta_description',
'args' => array(
'post_id' => array(
'type' => 'integer',
'required' => true,
),
'mode' => array(
'type' => 'string',
'default' => 'draft',
'enum' => array( 'draft', 'save' ),
),
),
)
);
} );
function bpcab_rest_generate_meta_description( WP_REST_Request $request ) : WP_REST_Response {
$post_id = absint( $request->get_param( 'post_id' ) );
$mode = (string) $request->get_param( 'mode' );
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_REST_Response( array( 'error' => 'Article introuvable.' ), 404 );
}
$title = (string) get_the_title( $post );
$content = (string) $post->post_content;
$content_plain = wp_strip_all_tags( strip_shortcodes( $content ) );
$content_plain = trim( preg_replace( '/s+/', ' ', $content_plain ) );
if ( mb_strlen( $content_plain ) < 80 ) {
return new WP_REST_Response(
array( 'error' => 'Contenu trop court. Ajoutez du texte avant de générer.' ),
400
);
}
$result = bpcab_generate_meta_description_via_openai(
array(
'post_id' => $post_id,
'title' => $title,
'content' => $content_plain,
'language' => 'fr',
)
);
if ( is_wp_error( $result ) ) {
return new WP_REST_Response(
array(
'error' => $result->get_error_message(),
'details' => $result->get_error_data(),
),
500
);
}
$meta_description = $result['meta_description'];
if ( 'save' === $mode ) {
update_post_meta( $post_id, '_bpcab_meta_description', $meta_description );
/**
* Point d’extension : si vous voulez synchroniser vers un plugin SEO,
* vous pourrez accrocher un hook ici.
*/
do_action( 'bpcab_meta_description_saved', $post_id, $meta_description, $result );
}
return new WP_REST_Response(
array(
'meta_description' => $meta_description,
'saved' => ( 'save' === $mode ),
),
200
);
}
function bpcab_generate_meta_description_via_openai( array $data ) {
if ( ! defined( 'BPCAB_OPENAI_API_KEY' ) || ! BPCAB_OPENAI_API_KEY ) {
return new WP_Error( 'bpcab_missing_api_key', 'Clé API manquante. Ajoutez BPCAB_OPENAI_API_KEY dans wp-config.php.' );
}
$title = (string) ( $data['title'] ?? '' );
$content = (string) ( $data['content'] ?? '' );
$cache_key_raw = $title . '|' . $content;
$cache_key = 'bpcab_md_' . md5( $cache_key_raw );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) && ! empty( $cached['meta_description'] ) ) {
return $cached;
}
$system_instructions = 'Vous êtes un assistant SEO. Vous écrivez une meta description en français, factuelle, sans superlatifs inutiles.';
$user_prompt = "Générez une meta description SEO pour l'article suivant.n"
. "- Longueur: 140 à 160 caractères (espaces inclus)n"
. "- Une seule phrasen"
. "- Pas de guillemetsn"
. "- Pas de call-to-action du type "Cliquez" ou "Découvrez"n"
. "- Doit résumer fidèlement le contenunn"
. "Titre: {$title}nn"
. "Contenu:n{$content}n";
$body = array(
'model' => 'gpt-4.1-mini',
'input' => array(
array(
'role' => 'system',
'content' => $system_instructions,
),
array(
'role' => 'user',
'content' => $user_prompt,
),
),
'max_output_tokens' => 120,
'temperature' => 0.4,
);
$response = wp_remote_post(
'https://api.openai.com/v1/responses',
array(
'headers' => array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
),
'body' => wp_json_encode( $body ),
'timeout' => 15,
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error(
'bpcab_http_error',
'Erreur HTTP lors de l’appel à l’API IA.',
array( 'wp_error' => $response->get_error_message() )
);
}
$code = wp_remote_retrieve_response_code( $response );
$raw = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error(
'bpcab_api_non_200',
'Réponse non valide de l’API IA.',
array(
'status_code' => $code,
'body' => $raw,
)
);
}
$json = json_decode( $raw, true );
if ( ! is_array( $json ) ) {
return new WP_Error(
'bpcab_json_decode',
'Impossible de décoder la réponse JSON de l’API.',
array( 'body' => $raw )
);
}
$text = bpcab_extract_text_from_openai_responses_api( $json );
$text = wp_strip_all_tags( (string) $text );
$text = sanitize_text_field( $text );
$text = trim( preg_replace( '/s+/', ' ', $text ) );
$text = str_replace( array( '"', '“', '”', '’' ), array( '', '', '', "'" ), $text );
if ( mb_strlen( $text ) > 160 ) {
$text = mb_substr( $text, 0, 160 );
$text = rtrim( $text, " tnrx0B-–—,;:" );
}
$result = array(
'meta_description' => $text,
'model' => $json['model'] ?? 'unknown',
);
set_transient( $cache_key, $result, 30 * DAY_IN_SECONDS );
return $result;
}
function bpcab_extract_text_from_openai_responses_api( array $json ) : string {
if ( isset( $json['output'][0]['content'][0]['text'] ) && is_string( $json['output'][0]['content'][0]['text'] ) ) {
return $json['output'][0]['content'][0]['text'];
}
$texts = array();
if ( isset( $json['output'] ) && is_array( $json['output'] ) ) {
foreach ( $json['output'] as $out ) {
if ( empty( $out['content'] ) || ! is_array( $out['content'] ) ) {
continue;
}
foreach ( $out['content'] as $c ) {
if ( isset( $c['text'] ) && is_string( $c['text'] ) ) {
$texts[] = $c['text'];
}
}
}
}
return trim( implode( ' ', $texts ) );
}
/**
* Script éditeur Gutenberg.
*/
add_action( 'enqueue_block_editor_assets', function () {
$handle = 'bpcab-ai-meta-description-editor';
wp_enqueue_script(
$handle,
plugins_url( 'bpcab-ai-meta-description-editor.js', __FILE__ ),
array( 'wp-data', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-plugins' ),
'1.0.0',
true
);
wp_localize_script(
$handle,
'BPCAB_AIMD',
array(
'restUrl' => esc_url_raw( rest_url( 'bpcab/v1/meta-description' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
)
);
} );
Fichier JS complet
( function ( wp ) {
const { registerPlugin } = wp.plugins;
const { PluginDocumentSettingPanel } = wp.editPost;
const { TextControl, Button, Notice } = wp.components;
const { useSelect, useDispatch } = wp.data;
const { useState } = wp.element;
function AIMetaDescriptionPanel() {
const postId = useSelect( ( select ) => select( 'core/editor' ).getCurrentPostId(), [] );
const currentMeta = useSelect( ( select ) => {
const meta = select( 'core/editor' ).getEditedPostAttribute( 'meta' );
return meta ? meta._bpcab_meta_description : '';
}, [] );
const { editPost } = useDispatch( 'core/editor' );
const [ value, setValue ] = useState( currentMeta || '' );
const [ isLoading, setIsLoading ] = useState( false );
const [ error, setError ] = useState( '' );
const [ info, setInfo ] = useState( '' );
async function generate( mode ) {
setError( '' );
setInfo( '' );
setIsLoading( true );
try {
const res = await wp.apiFetch( {
url: BPCAB_AIMD.restUrl,
method: 'POST',
headers: {
'X-WP-Nonce': BPCAB_AIMD.nonce,
},
data: {
post_id: postId,
mode: mode,
},
} );
if ( res && res.meta_description ) {
setValue( res.meta_description );
editPost( { meta: { _bpcab_meta_description: res.meta_description } } );
setInfo( res.saved ? 'Meta description générée et enregistrée.' : 'Meta description générée (non enregistrée).' );
} else {
setError( 'Réponse inattendue du serveur.' );
}
} catch ( e ) {
setError( e.message ? e.message : 'Erreur lors de l’appel REST.' );
} finally {
setIsLoading( false );
}
}
return wp.element.createElement(
PluginDocumentSettingPanel,
{
name: 'bpcab-ai-meta-description',
title: 'Meta description (IA)',
className: 'bpcab-ai-meta-description-panel',
},
error ? wp.element.createElement( Notice, { status: 'error', isDismissible: true }, error ) : null,
info ? wp.element.createElement( Notice, { status: 'info', isDismissible: true }, info ) : null,
wp.element.createElement( TextControl, {
label: 'Meta description',
help: 'Astuce : relisez et ajustez. L’IA est un point de départ, pas une vérité.',
value: value,
onChange: ( v ) => {
setValue( v );
editPost( { meta: { _bpcab_meta_description: v } } );
},
} ),
wp.element.createElement(
'div',
{ style: { display: 'flex', gap: '8px' } },
wp.element.createElement( Button, { variant: 'secondary', isBusy: isLoading, disabled: isLoading, onClick: () => generate( 'draft' ) }, 'Générer (aperçu)' ),
wp.element.createElement( Button, { variant: 'primary', isBusy: isLoading, disabled: isLoading, onClick: () => generate( 'save' ) }, 'Générer & enregistrer' )
)
);
}
registerPlugin( 'bpcab-ai-meta-description', {
render: AIMetaDescriptionPanel,
} );
} )( window.wp );
Explication du code
Pourquoi une post meta plutôt qu’un champ “SEO plugin” direct
Chaque plugin SEO a ses propres clés meta. En stockant d’abord dans _bpcab_meta_description, vous :
- restez indépendant,
- pouvez changer de plugin SEO sans perdre vos textes,
- pouvez mapper ensuite vers Yoast/Rank Math/SEOPress via un hook.
Pourquoi REST + Gutenberg plutôt qu’un “bouton PHP”
Gutenberg est une application JS dans l’admin. Si vous voulez un bouton réactif, le REST est la voie la plus stable. Et surtout, la clé API reste côté serveur.
Pourquoi le transient cache est indispensable
Sans cache, vous payez :
- à chaque clic,
- à chaque rafraîchissement,
- à chaque conflit de brouillon (deux auteurs qui testent).
Avec un cache basé sur le hash du contenu, vous ne repayer pas tant que le texte n’a pas changé.
Pourquoi on “sanitise” la réponse IA
Une réponse IA est un contenu externe. Même si le risque d’injection HTML est faible dans ce contexte, je l’ai déjà vue renvoyer des guillemets typographiques, des retours ligne, parfois des balises si le prompt est mal écrit. Ici, on force :
wp_strip_all_tags(): supprime toute balise,sanitize_text_field(): nettoie la string,- contrôle de longueur.
Coûts API et optimisation
Les coûts dépendent du modèle et du volume de tokens (entrée + sortie). Pour une meta description :
- entrée : titre + 1 000 à 3 000 caractères de contenu nettoyé,
- sortie : ~160 caractères.
En pratique, c’est une requête “petite”. Le vrai risque de coût vient surtout :
- de la régénération multiple (d’où le cache),
- de l’envoi de l’article complet (d’où le nettoyage et l’idée de limiter le contenu envoyé).
Stratégies concrètes
- Envoyer seulement l’intro (ex : premiers 1 500 caractères) : souvent suffisant pour résumer.
- Modèle “mini” : bon rapport qualité/prix pour un texte court.
- Cache long (7 à 30 jours) + purge uniquement si le contenu change.
- Batch hors admin : pour une migration, faites un WP-CLI (variante ci-dessous) et lancez la nuit.
Variantes et cas d’usage avancés
Variante 1 — n’envoyer que l’introduction
J’ai souvent de meilleurs résultats en envoyant l’intro + les titres H2, plutôt que tout l’article. Pour un débutant, le plus simple : tronquer le contenu envoyé.
// Remplacez $content_plain par une version limitée.
$content_plain = mb_substr( $content_plain, 0, 1500 );
Variante 2 — synchroniser vers un plugin SEO
Exemple : vous gardez votre meta interne, mais vous copiez aussi vers un plugin SEO via le hook bpcab_meta_description_saved.
Attention : les clés meta varient selon les plugins et leurs versions. Vérifiez dans la doc du plugin, ou inspectez la table wp_postmeta sur un article où vous avez rempli le champ à la main.
add_action( 'bpcab_meta_description_saved', function ( int $post_id, string $meta_description ) {
// Exemple fictif : adaptez à votre plugin SEO après vérification.
// update_post_meta( $post_id, '_yoast_wpseo_metadesc', $meta_description );
// update_post_meta( $post_id, 'rank_math_description', $meta_description );
}, 10, 2 );
Variante 3 — compatibilité Divi 5 / Elementor / Avada
Bonne nouvelle : la génération se fait côté WordPress (REST + post meta). Donc :
- Divi 5 : si vous éditez vos articles/pages dans Gutenberg (ou même en Divi), la meta est stockée au niveau du post. Divi n’empêche pas l’enregistrement de post meta.
- Elementor : idem. Elementor édite le contenu, mais la post meta reste accessible. Vous pouvez déclencher la génération depuis Gutenberg, puis conserver la meta pour l’affichage SEO.
- Avada : même logique. Le builder n’interfère pas avec
update_post_meta().
Si vous utilisez exclusivement l’éditeur d’un builder et jamais Gutenberg, vous pouvez quand même utiliser ce plugin : la génération se déclenche via REST, donc vous pourriez ajouter une petite page d’outil dans l’admin (non incluse ici) ou lancer une génération en masse via WP-CLI.
Sécurité et bonnes pratiques
- Ne jamais exposer la clé API côté client : pas de requête directe depuis le navigateur vers OpenAI. Toujours via votre serveur WordPress.
- Permissions REST : on vérifie
current_user_can('edit_post', $post_id). Sans ça, n’importe quel utilisateur connecté pourrait générer (et vous coûter de l’argent). - Rate limiting : si vous avez beaucoup d’auteurs, ajoutez une limite par utilisateur (ex : 20 générations/heure). Sans limite, un bouton spammé peut vous coûter cher.
- Sanitization : on nettoie la réponse IA avant sauvegarde. Même si c’est “juste du texte”, traitez-le comme une entrée externe.
- RGPD : vous envoyez du contenu d’article à un tiers. Si vos contenus contiennent des données personnelles (noms, emails, témoignages), vous devez cadrer ça (base légale, information, DPA selon le fournisseur). Ne générez pas à l’aveugle sur des contenus utilisateurs.
Référence sécurité REST : REST API Authentication.
Rate limiting simple (exemple)
Voici une approche simple par transient utilisateur. Ça ne remplace pas un vrai WAF, mais ça évite les abus involontaires.
function bpcab_rate_limit_ok( int $user_id, int $limit = 20, int $window_seconds = 3600 ) {
$key = 'bpcab_rl_' . $user_id;
$count = (int) get_transient( $key );
if ( $count >= $limit ) {
return false;
}
set_transient( $key, $count + 1, $window_seconds );
return true;
}
Vous l’appelez au début de bpcab_rest_generate_meta_description() et vous renvoyez une 429 si nécessaire.
Comment tester et déboguer
1) Activer les logs WordPress
Dans wp-config.php (sur un environnement de test, pas en prod), activez :
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
Les erreurs iront dans wp-content/debug.log.
2) Tester l’endpoint REST sans Gutenberg
Avant de suspecter le JS, testez l’endpoint avec curl. Récupérez un nonce depuis l’admin (ou utilisez un outil REST authentifié), puis :
curl -X POST "https://votre-site.tld/wp-json/bpcab/v1/meta-description"
-H "Content-Type: application/json"
-H "X-WP-Nonce: VOTRE_NONCE"
-d '{"post_id":123,"mode":"draft"}'
3) Inspecter la réponse OpenAI en cas d’erreur
Quand l’API renvoie un code non-2xx, le plugin renvoie body dans les détails. Sur un site réel, évitez d’afficher ces détails aux auteurs (ça peut contenir des infos techniques). Gardez-les dans les logs.
4) Pièges que je vois souvent
- Le code est collé dans
functions.phpdu thème parent : mise à jour du thème = code perdu. - Une parenthèse oubliée : erreur fatale, écran blanc dans l’admin.
- PHP trop ancien (7.4/8.0) : le typage strict et certaines fonctions se comportent différemment.
- Conflit avec un plugin de snippets : un snippet désactivé partiellement peut casser l’éditeur.
- Cache d’admin : vous modifiez le JS mais le navigateur garde l’ancienne version. Faites un “hard reload”.
Si ça ne marche pas
Voici un tableau de diagnostic basé sur des cas réels de support.
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Le bouton n’apparaît pas dans l’éditeur | Script non chargé ou mauvaises dépendances | Console du navigateur (F12) + onglet Réseau | Vérifiez enqueue_block_editor_assets, les dépendances (wp-plugins, wp-edit-post) et le chemin du fichier JS |
| Erreur “Clé API manquante” | Constante absente ou typo dans wp-config.php |
Rechercher BPCAB_OPENAI_API_KEY dans wp-config.php |
Ajoutez define() au bon endroit, sans espaces invisibles |
| Erreur 401/403 côté API IA | Clé invalide, expirée, ou permissions compte | Réponse details.body dans la réponse REST |
Regénérez la clé, vérifiez la facturation/quotas sur le compte API |
| Erreur 500 “Réponse non valide de l’API IA” | Timeout, quota, ou réponse non-JSON | Consultez wp-content/debug.log |
Augmentez timeout, réduisez la taille du contenu envoyé, vérifiez le quota |
| La meta est générée mais ne se sauvegarde pas | Mode “draft” utilisé, ou droits insuffisants | Vérifiez mode et la capability edit_post |
Cliquez “Générer & enregistrer”, vérifiez le rôle utilisateur |
| Texte bizarre (guillemets, deux phrases, trop long) | Prompt trop permissif ou modèle trop créatif | Comparez la sortie à vos contraintes | Baissez temperature, forcez “une phrase”, tronquez à 160, retirez guillemets |
Erreurs “WordPress” typiques et corrections
- Hook inadapté : si vous mettez
register_rest_routesurinitau lieu derest_api_init, la route peut ne pas être disponible au bon moment. - Confusion action/filtre :
rest_api_initest une action (vous “faites” quelque chose), pas un filtre. - Permaliens : la REST API ne dépend pas des permaliens, mais certains plugins de sécurité bloquent
/wp-json/. Testez l’URL directement. - Test en production sans sauvegarde : un plugin mal copié peut casser l’admin. Faites toujours une sauvegarde et testez sur staging.
Ressources
- WordPress: wp_remote_post()
- WordPress: REST API Handbook
- WordPress: Transients API
- WordPress: register_post_meta()
- WordPress: Must-Use Plugins
- OpenAI: Responses API
- PHP: json_decode()
- GitHub: WordPress core (wordpress-develop)
FAQ
Est-ce que Google utilise toujours la meta description ?
Pas toujours. Google peut réécrire l’extrait. Mais une bonne meta description reste utile : elle influence souvent le snippet quand elle correspond bien à la requête, et elle sert aussi à d’autres plateformes (partage, aperçus).
Pourquoi ne pas générer automatiquement à chaque sauvegarde d’article ?
Parce que vous allez payer des appels IA en boucle (autosaves, révisions, corrections). Je préfère un bouton explicite. Si vous voulez de l’automatique, faites-le uniquement au premier passage “Publié”, et avec cache + rate limit.
Puis-je utiliser Anthropic, Mistral ou Google à la place ?
Oui. Gardez la même architecture : REST interne + wp_remote_post() + cache + sanitization. Seul le format de la requête/réponse change.
Pourquoi stocker en post meta plutôt que dans un champ ACF ?
La post meta est la couche native. ACF stocke aussi en post meta, mais ajoute une couche de configuration. Pour une meta description, la version native est plus simple et plus portable.
Est-ce compatible avec Divi 5 / Elementor / Avada ?
Oui pour le stockage. Le panneau de génération est dans Gutenberg. Si vous n’utilisez jamais Gutenberg, ajoutez une page outil (ou un WP-CLI) pour déclencher la génération.
Le bouton tourne en boucle (“isLoading”) et ne renvoie rien
Typiquement : endpoint bloqué par un plugin de sécurité, nonce invalide, ou erreur 500 côté serveur. Regardez la console réseau (requête vers /wp-json/bpcab/v1/meta-description) et le fichier debug.log.
Je reçois “You are probably offline.” dans Gutenberg
C’est un message générique de wp.apiFetch. La cause réelle est souvent une réponse REST bloquée (403), un JSON invalide, ou un timeout. Inspectez la réponse dans l’onglet Réseau.
Comment vider le cache si je veux forcer une nouvelle génération ?
Le cache est un transient basé sur le hash titre+contenu. Si vous modifiez le contenu, le hash change et le cache ne s’applique plus. Sinon, vous pouvez réduire la durée (7 jours) ou ajouter un paramètre “force” (non inclus ici).
Est-ce que je peux générer pour 1 000 anciens articles ?
Oui, mais pas depuis l’admin à la main. Faites un script WP-CLI ou un cron, avec rate limiting, et surveillez les coûts. Je recommande de commencer par 20 articles, vérifier la qualité, puis étendre.
Pourquoi ma meta description contient des caractères bizarres (—, …) ?
C’est normal : l’IA utilise parfois de la ponctuation typographique. Le code retire les guillemets, mais garde les tirets. Si vous voulez du 100% ASCII, ajoutez une normalisation (à faire avec prudence en français).
Puis-je afficher cette meta description dans le front si je n’ai pas de plugin SEO ?
Oui. Votre thème peut lire get_post_meta( get_the_ID(), '_bpcab_meta_description', true ) et l’injecter dans <meta name="description">. Faites-le proprement via wp_head et en échappant avec esc_attr(). Si vous voulez, je peux vous donner le snippet exact selon votre thème.