Si vous avez déjà importé 50 images dans la médiathèque et réalisé ensuite que le champ Texte alternatif est vide partout, vous connaissez la douleur : c’est long, répétitif, et on finit par bâcler.
Le besoin / Le cas d’usage
Le texte alternatif (alt) sert d’abord à l’accessibilité : un lecteur d’écran décrit l’image à une personne malvoyante. Il sert aussi quand l’image ne charge pas, et il peut aider au référencement image (sans être une baguette magique).
Le problème concret sur WordPress : la plupart des sites publient des images sans alt, ou avec des alt inutiles (“IMG_4388”). Sur des blogs photo, des sites de recettes, des boutiques, ou des portfolios, ça devient vite ingérable à la main.
À la fin, vous saurez déployer un mu-plugin (plugin “must-use”) qui :
- détecte quand une image est ajoutée à la médiathèque,
- génère une proposition d’alt via GPT (API OpenAI) en appel HTTP avec
wp_remote_post(), - stocke le résultat dans le champ alt WordPress,
- met en cache pour éviter de repayer la même chose,
- gère les erreurs (timeout, quota, clé invalide) sans casser l’admin.
J’ai souvent vu ce besoin sur des sites Elementor/Divi/Avada : les page builders n’empêchent pas les alt vides, ils ne font que consommer ce que la médiathèque fournit. Donc le bon endroit pour corriger, c’est la médiathèque.
Résumé rapide
- Vous mettez la clé API dans
wp-config.php(jamais dans le code du plugin). - Vous installez un mu-plugin dans
wp-content/mu-plugins/. - Le mu-plugin écoute l’ajout d’une image, récupère des infos (titre, légende, nom de fichier) et appelle GPT via
wp_remote_post(). - Le résultat est nettoyé (
sanitize_text_field()) puis enregistré comme alt. - Un cache
Transientévite de regénérer si l’image est déjà traitée. - Un “verrou” anti-doublon empêche deux requêtes simultanées sur la même image.
Quand utiliser l’IA pour ça
Utilisez l’IA pour générer des alt quand vous avez :
- un volume d’images important (blog actif, migration, import depuis un autre CMS),
- des auteurs multiples (qualité inégale des contenus),
- un besoin d’accessibilité cohérent sans y passer des heures,
- des images avec un contexte exploitable (titre d’article, légende, nom de fichier propre).
En pratique, GPT est très bon pour produire un alt “propre” si vous lui donnez un peu de contexte. Sans contexte, il invente parfois. Et si vous lui envoyez l’image elle-même (vision), le coût et la complexité montent.
Quand ne PAS utiliser l’IA
Ne l’utilisez pas (ou pas en automatique) dans ces cas :
- Images sensibles (photos de personnes, enfants, santé, documents) : vous envoyez des données à un service tiers. Même si vous n’envoyez pas l’image, le contexte texte peut suffire à identifier.
- Alt “normatifs” : pictogrammes, icônes UI, logos. Là, un alt standard (“Logo de …”) est plus fiable, gratuit, et cohérent.
- Sites très contraints en budget : si vous uploadez beaucoup, chaque appel API compte.
- Vous avez déjà un process éditorial solide : un champ alt rempli manuellement par l’auteur reste souvent meilleur.
Alternative classique : imposer le remplissage du champ alt à l’upload (validation côté admin), ou utiliser des règles simples basées sur le titre / nom de fichier. C’est moins “intelligent”, mais c’est zéro coût et zéro dépendance.
Prérequis
Versions : WordPress 6.9.4 (avril 2026) et PHP 8.1+.
Une clé API OpenAI (ou un autre fournisseur, mais ici je montre OpenAI car c’est le plus demandé). Les coûts varient selon le modèle.
Où stocker la clé API (obligatoire)
Une clé API est un “mot de passe” qui autorise votre site à appeler un service. Si elle fuit (dans du JavaScript, un dépôt GitHub, un plugin exporté), quelqu’un peut consommer votre quota à votre place.
Stockez-la dans wp-config.php, côté serveur :
define( 'OPENAI_API_KEY', 'collez_votre_cle_ici' );
Collez cette ligne au-dessus de /* That's all, stop editing! */. Ne mettez jamais cette clé dans un thème, un plugin public, ni dans un champ ACF.
Comprendre l’appel HTTP avant le code
Quand WordPress appelle une API, il envoie une requête HTTP (souvent POST) vers une URL, avec :
- des en-têtes (headers) comme
Authorization: Bearer ..., - un corps JSON (vos instructions et paramètres),
- un délai (timeout) pour éviter de bloquer le serveur.
Dans WordPress, on fait ça avec wp_remote_post(), qui s’appuie sur l’API HTTP interne. Doc officielle : wp_remote_post().
Où mettre le code : mu-plugin
Un mu-plugin est chargé automatiquement, sans écran d’activation. C’est pratique pour une fonctionnalité “infrastructure” comme celle-ci.
- Créez le dossier
wp-content/mu-plugins/s’il n’existe pas. - Créez le fichier
wp-content/mu-plugins/ai-alt-generator.php.
Référence : Must-Use Plugins.
Avertissements coûts
Chaque génération d’alt appelle une API payante. Avec un modèle “mini”, ça peut rester très bas, mais si vous traitez des milliers d’images, ça se voit.
Je conseille toujours : activez d’abord sur un site de staging, puis traitez par lots, et surveillez les logs.
Architecture de la solution
Flux (schéma textuel) :
Upload image → WordPress crée la pièce jointe (attachment) → hook WordPress → vérifications (type, permissions, alt déjà présent) → cache transient + verrou → wp_remote_post() vers OpenAI → parsing JSON → nettoyage (sanitize) → update_post_meta(_wp_attachment_image_alt) → fin
Explication des briques
- Hook : point d’extension de WordPress. Une action exécute du code à un moment donné ; un filtre modifie une valeur. Ici on utilise une action déclenchée après création de la pièce jointe.
- Attachment : en WordPress, une image uploadée est un post de type
attachment. - Métadonnée : l’alt est stocké dans
postmetaavec la clé_wp_attachment_image_alt. - Transient API : cache clé/valeur avec expiration. Doc : Transients API.
Le code complet — étape par étape
Objectif : rester simple, robuste, et compatible WP 6.9.4/PHP 8.1.
Étape 1 — Déclencher au bon moment
Piège courant : utiliser un hook trop tôt, ou qui se déclenche 3 fois (et donc 3 appels API). Ici, on part sur add_attachment (action) qui reçoit l’ID de la pièce jointe.
Doc : add_attachment.
<?php
// Étape 1 : attacher notre logique à l’ajout d’une pièce jointe (image).
add_action( 'add_attachment', 'bpcab_ai_alt_on_add_attachment', 20 );
function bpcab_ai_alt_on_add_attachment( int $attachment_id ): void {
// On complétera dans les étapes suivantes.
}
Étape 2 — Vérifier que c’est bien une image et que l’alt est vide
J’ai souvent vu des sites qui génèrent des alt sur des PDF (pas utile) ou qui écrasent des alt déjà renseignés (mauvaise idée). On évite ça.
function bpcab_ai_alt_on_add_attachment( int $attachment_id ): void {
// Sécurité : on ne traite que si l’attachment existe.
$post = get_post( $attachment_id );
if ( ! $post || 'attachment' !== $post->post_type ) {
return;
}
// On ne traite que les images (jpeg, png, webp, etc.).
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return;
}
// Ne pas écraser un alt déjà présent.
$current_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
if ( is_string( $current_alt ) && trim( $current_alt ) !== '' ) {
return;
}
// On continue…
}
Étape 3 — Construire un contexte utile (sans envoyer l’image)
Version débutant : on ne fait pas de “vision”. On génère un alt à partir du contexte disponible :
- titre de l’image (souvent le nom de fichier),
- légende, description,
- nom de fichier nettoyé,
- titre du contenu parent si l’image est attachée à un article.
// Récupération de contexte éditorial.
$title = get_the_title( $attachment_id );
$caption = wp_get_attachment_caption( $attachment_id );
$description = $post->post_content; // Description média (champ "Description").
$file_path = get_attached_file( $attachment_id );
// Nom de fichier sans extension (utile si vos fichiers sont bien nommés).
$filename = '';
if ( is_string( $file_path ) && $file_path !== '' ) {
$basename = wp_basename( $file_path ); // ex: tarte-aux-pommes.webp
$filename = preg_replace( '/.[a-z0-9]+$/i', '', $basename );
$filename = str_replace( array( '-', '_' ), ' ', (string) $filename );
$filename = trim( preg_replace( '/s+/', ' ', $filename ) );
}
// Titre du post parent si présent.
$parent_title = '';
if ( ! empty( $post->post_parent ) ) {
$parent_title = get_the_title( (int) $post->post_parent );
}
// On assemble un contexte court (éviter d’envoyer des pavés).
$context_parts = array();
if ( is_string( $parent_title ) && $parent_title !== '' ) {
$context_parts[] = 'Article : ' . $parent_title;
}
if ( is_string( $caption ) && $caption !== '' ) {
$context_parts[] = 'Légende : ' . $caption;
}
if ( is_string( $title ) && $title !== '' ) {
$context_parts[] = 'Titre média : ' . $title;
}
if ( is_string( $filename ) && $filename !== '' ) {
$context_parts[] = 'Nom de fichier : ' . $filename;
}
if ( is_string( $description ) && trim( $description ) !== '' ) {
$desc = wp_strip_all_tags( $description );
$desc = mb_substr( $desc, 0, 240 );
$context_parts[] = 'Description : ' . $desc;
}
$context = implode( " | ", $context_parts );
Étape 4 — Cache + verrou anti-doublon
Deux pièges classiques :
- un import (WP All Import, migration) déclenche plusieurs hooks et vous facturez plusieurs fois,
- deux requêtes simultanées (admin + génération de miniatures) partent en même temps.
On met :
- un transient “résultat” (cache 30 jours),
- un transient “lock” (verrou 2 minutes).
$cache_key = 'bpcab_alt_' . $attachment_id;
$lock_key = 'bpcab_alt_lock_' . $attachment_id;
// Si déjà en cache, on réutilise.
$cached_alt = get_transient( $cache_key );
if ( is_string( $cached_alt ) && trim( $cached_alt ) !== '' ) {
update_post_meta( $attachment_id, '_wp_attachment_image_alt', $cached_alt );
return;
}
// Verrou : si un traitement est déjà en cours, on sort.
if ( get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, 1, 2 * MINUTE_IN_SECONDS );
Étape 5 — Appeler OpenAI avec wp_remote_post()
On utilise l’API “Responses” (moderne) via HTTP. Je reste volontairement strict :
- timeout court,
- gestion d’erreurs WordPress (
is_wp_error), - validation JSON,
- nettoyage du texte généré.
Référence API HTTP WordPress : HTTP API. Référence PHP JSON : json_decode().
// Vérification de la présence de la clé API.
if ( ! defined( 'OPENAI_API_KEY' ) || ! is_string( OPENAI_API_KEY ) || OPENAI_API_KEY === '' ) {
// On libère le verrou avant de sortir.
delete_transient( $lock_key );
return;
}
// Prompt : court, concret, et contraint (éviter les phrases longues).
$system_rules = 'Vous générez un texte alternatif (alt) pour une image sur un site WordPress. '
. 'Répondez en français. '
. 'Une seule phrase courte. '
. 'Pas de guillemets. '
. 'Pas de point final si possible. '
. 'Ne mentionnez pas "image de" ou "photo de" sauf si nécessaire. '
. 'Si le contexte est insuffisant, proposez un alt neutre basé sur le nom de fichier.';
$user_input = $context !== '' ? $context : 'Contexte : (vide)';
// Corps de requête (API Responses).
$body = array(
'model' => 'gpt-4.1-mini',
'input' => array(
array(
'role' => 'system',
'content' => array(
array(
'type' => 'text',
'text' => $system_rules,
),
),
),
array(
'role' => 'user',
'content' => array(
array(
'type' => 'text',
'text' => $user_input,
),
),
),
),
// Petite limite pour éviter les réponses verbeuses.
'max_output_tokens' => 40,
);
$args = array(
'headers' => array(
'Authorization' => 'Bearer ' . OPENAI_API_KEY,
'Content-Type' => 'application/json',
),
'timeout' => 15,
'body' => wp_json_encode( $body ),
);
$response = wp_remote_post( 'https://api.openai.com/v1/responses', $args );
if ( is_wp_error( $response ) ) {
// Erreur réseau, DNS, SSL, etc.
delete_transient( $lock_key );
return;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( $status < 200 || $status >= 300 || $raw === '' ) {
// 401 clé invalide, 429 quota, 500 erreur serveur…
delete_transient( $lock_key );
return;
}
$data = json_decode( $raw, true );
if ( ! is_array( $data ) ) {
delete_transient( $lock_key );
return;
}
Étape 6 — Extraire le texte de la réponse et enregistrer l’alt
La structure exacte peut évoluer, donc je code défensif : je cherche un texte dans les sorties, et je fallback si besoin.
// Extraction défensive du texte de sortie.
// On cherche dans output[].content[] les chunks de type "output_text".
$alt = '';
if ( isset( $data['output'] ) && is_array( $data['output'] ) ) {
foreach ( $data['output'] as $out ) {
if ( ! is_array( $out ) || empty( $out['content'] ) || ! is_array( $out['content'] ) ) {
continue;
}
foreach ( $out['content'] as $chunk ) {
if ( is_array( $chunk ) && ( $chunk['type'] ?? '' ) === 'output_text' && ! empty( $chunk['text'] ) ) {
$alt = (string) $chunk['text'];
break 2;
}
}
}
}
// Fallback : certains retours peuvent exposer un champ "text" simplifié.
if ( $alt === '' && ! empty( $data['text'] ) && is_string( $data['text'] ) ) {
$alt = $data['text'];
}
// Nettoyage : on veut une phrase courte, sans HTML.
$alt = wp_strip_all_tags( $alt );
$alt = trim( preg_replace( '/s+/', ' ', $alt ) );
$alt = sanitize_text_field( $alt );
// Dernier garde-fou : si c’est vide, on ne met rien.
if ( $alt === '' ) {
delete_transient( $lock_key );
return;
}
// Enregistrer + cache.
update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
set_transient( $cache_key, $alt, 30 * DAY_IN_SECONDS );
// Libérer le verrou.
delete_transient( $lock_key );
À ce stade, vous avez la mécanique. Il manque encore une chose : encapsuler proprement, éviter les collisions de noms, et ajouter un minimum de logs en mode debug.
Le code assemblé complet
Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/ai-alt-generator.php.
Avant : sauvegarde (fichiers + base). Ne testez pas ça en production “à l’aveugle” si vous avez un gros volume d’uploads.
<?php
/**
* Plugin Name: BPCAB - Génération IA des textes alt (mu-plugin)
* Description: Génère automatiquement le texte alternatif des images à l’upload via OpenAI, avec cache et garde-fous.
* Author: BPCAB
* Version: 1.0.0
*
* Prérequis : WordPress 6.9.4+, PHP 8.1+
*
* Installation :
* - Placez ce fichier dans wp-content/mu-plugins/ai-alt-generator.php
* - Ajoutez define('OPENAI_API_KEY', '...'); dans wp-config.php
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Action WordPress : déclenchée quand une pièce jointe est ajoutée.
* Doc : https://developer.wordpress.org/reference/hooks/add_attachment/
*/
add_action( 'add_attachment', 'bpcab_ai_alt_on_add_attachment', 20 );
/**
* Génère un alt pour une image nouvellement uploadée.
*
* @param int $attachment_id ID de la pièce jointe.
*/
function bpcab_ai_alt_on_add_attachment( int $attachment_id ): void {
$post = get_post( $attachment_id );
if ( ! $post || 'attachment' !== $post->post_type ) {
return;
}
// On ne traite que les images.
if ( ! wp_attachment_is_image( $attachment_id ) ) {
return;
}
// Ne pas écraser un alt existant.
$current_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
if ( is_string( $current_alt ) && trim( $current_alt ) !== '' ) {
return;
}
// Clé API obligatoire.
if ( ! defined( 'OPENAI_API_KEY' ) || ! is_string( OPENAI_API_KEY ) || OPENAI_API_KEY === '' ) {
bpcab_ai_alt_log( 'OPENAI_API_KEY manquante : alt non généré pour attachment_id=' . $attachment_id );
return;
}
// Cache + verrou anti-doublon.
$cache_key = 'bpcab_alt_' . $attachment_id;
$lock_key = 'bpcab_alt_lock_' . $attachment_id;
$cached_alt = get_transient( $cache_key );
if ( is_string( $cached_alt ) && trim( $cached_alt ) !== '' ) {
update_post_meta( $attachment_id, '_wp_attachment_image_alt', $cached_alt );
return;
}
if ( get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, 1, 2 * MINUTE_IN_SECONDS );
// Construire le contexte.
$context = bpcab_ai_alt_build_context( $attachment_id, $post );
// Appel IA.
$alt = bpcab_ai_alt_generate_via_openai( $context );
if ( $alt === '' ) {
delete_transient( $lock_key );
return;
}
// Enregistrement + cache.
update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
set_transient( $cache_key, $alt, 30 * DAY_IN_SECONDS );
delete_transient( $lock_key );
}
/**
* Construit un contexte texte court pour aider l’IA.
*
* @param int $attachment_id ID.
* @param WP_Post $post Objet attachment.
* @return string Contexte.
*/
function bpcab_ai_alt_build_context( int $attachment_id, WP_Post $post ): string {
$title = get_the_title( $attachment_id );
$caption = wp_get_attachment_caption( $attachment_id );
$description = $post->post_content;
$file_path = get_attached_file( $attachment_id );
$filename = '';
if ( is_string( $file_path ) && $file_path !== '' ) {
$basename = wp_basename( $file_path );
$filename = preg_replace( '/.[a-z0-9]+$/i', '', $basename );
$filename = str_replace( array( '-', '_' ), ' ', (string) $filename );
$filename = trim( preg_replace( '/s+/', ' ', (string) $filename ) );
}
$parent_title = '';
if ( ! empty( $post->post_parent ) ) {
$parent_title = get_the_title( (int) $post->post_parent );
}
$parts = array();
if ( is_string( $parent_title ) && $parent_title !== '' ) {
$parts[] = 'Article : ' . $parent_title;
}
if ( is_string( $caption ) && $caption !== '' ) {
$parts[] = 'Légende : ' . $caption;
}
if ( is_string( $title ) && $title !== '' ) {
$parts[] = 'Titre média : ' . $title;
}
if ( is_string( $filename ) && $filename !== '' ) {
$parts[] = 'Nom de fichier : ' . $filename;
}
if ( is_string( $description ) && trim( $description ) !== '' ) {
$desc = wp_strip_all_tags( $description );
$desc = mb_substr( $desc, 0, 240 );
$parts[] = 'Description : ' . $desc;
}
return trim( implode( ' | ', $parts ) );
}
/**
* Appelle l’API OpenAI (Responses) pour générer un alt.
*
* @param string $context Contexte.
* @return string Alt nettoyé, ou chaîne vide.
*/
function bpcab_ai_alt_generate_via_openai( string $context ): string {
$system_rules = 'Vous générez un texte alternatif (alt) pour une image sur un site WordPress. '
. 'Répondez en français. '
. 'Une seule phrase courte. '
. 'Pas de guillemets. '
. 'Pas de point final si possible. '
. 'Ne mentionnez pas "image de" ou "photo de" sauf si nécessaire. '
. 'Si le contexte est insuffisant, proposez un alt neutre basé sur le nom de fichier.';
$user_input = $context !== '' ? $context : 'Contexte : (vide)';
$body = array(
'model' => 'gpt-4.1-mini',
'input' => array(
array(
'role' => 'system',
'content' => array(
array(
'type' => 'text',
'text' => $system_rules,
),
),
),
array(
'role' => 'user',
'content' => array(
array(
'type' => 'text',
'text' => $user_input,
),
),
),
),
'max_output_tokens' => 40,
);
$args = array(
'headers' => array(
'Authorization' => 'Bearer ' . OPENAI_API_KEY,
'Content-Type' => 'application/json',
),
// 15s : suffisant en admin, sans bloquer trop longtemps.
'timeout' => 15,
'body' => wp_json_encode( $body ),
);
$response = wp_remote_post( 'https://api.openai.com/v1/responses', $args );
if ( is_wp_error( $response ) ) {
bpcab_ai_alt_log( 'Erreur wp_remote_post : ' . $response->get_error_message() );
return '';
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( $status < 200 || $status >= 300 ) {
bpcab_ai_alt_log( 'OpenAI HTTP ' . $status . ' : ' . mb_substr( $raw, 0, 400 ) );
return '';
}
if ( $raw === '' ) {
bpcab_ai_alt_log( 'OpenAI : réponse vide' );
return '';
}
$data = json_decode( $raw, true );
if ( ! is_array( $data ) ) {
bpcab_ai_alt_log( 'OpenAI : JSON invalide' );
return '';
}
$alt = '';
if ( isset( $data['output'] ) && is_array( $data['output'] ) ) {
foreach ( $data['output'] as $out ) {
if ( ! is_array( $out ) || empty( $out['content'] ) || ! is_array( $out['content'] ) ) {
continue;
}
foreach ( $out['content'] as $chunk ) {
if ( is_array( $chunk ) && ( $chunk['type'] ?? '' ) === 'output_text' && ! empty( $chunk['text'] ) ) {
$alt = (string) $chunk['text'];
break 2;
}
}
}
}
if ( $alt === '' && ! empty( $data['text'] ) && is_string( $data['text'] ) ) {
$alt = $data['text'];
}
// Nettoyage strict.
$alt = wp_strip_all_tags( $alt );
$alt = trim( preg_replace( '/s+/', ' ', $alt ) );
$alt = sanitize_text_field( $alt );
// Garde-fous : éviter les alt absurdes.
if ( mb_strlen( $alt ) > 180 ) {
$alt = mb_substr( $alt, 0, 180 );
$alt = trim( $alt );
}
return $alt;
}
/**
* Log uniquement si WP_DEBUG est actif.
*
* @param string $message Message.
* @return void
*/
function bpcab_ai_alt_log( string $message ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[BPCAB AI ALT] ' . $message );
}
}
Explication du code
Pourquoi un mu-plugin
Un snippet dans functions.php marche, mais je l’ai vu casser lors d’un changement de thème ou d’une mise à jour de thème parent. Le mu-plugin évite ça. Et sur des sites avec Divi 5 / Avada / Elementor, c’est fréquent que le thème soit remplacé ou “reset”.
Pourquoi l’action add_attachment et pas un filtre
Un filtre modifie une valeur (ex : le contenu d’un post). Une action déclenche une tâche (ex : générer un alt). Ici on veut “faire quelque chose” quand une image arrive : c’est une action.
Pourquoi ne pas écraser un alt existant
Sur les sites pro, une partie des alt est parfois rédigée manuellement (images clés, bannières). Écraser ça automatiquement est une régression.
Pourquoi un cache transient
Le transient évite :
- les doubles appels lors d’un import,
- les appels répétés si un plugin régénère des métadonnées d’images,
- les coûts inutiles.
Ce cache est côté serveur (base de données, ou objet cache si vous en avez un). Référence : Transients API.
Pourquoi sanitize_text_field()
Vous ne contrôlez pas la sortie d’un modèle. Même si le risque est faible sur un alt, je préfère nettoyer agressivement : pas de HTML, pas de scripts, pas de caractères invisibles bizarres. WordPress fournit sanitize_text_field().
Pourquoi un timeout à 15 secondes
En admin, un timeout trop long donne l’impression que l’upload “plante”. Trop court, vous aurez des alt vides. 15 secondes est un compromis correct sur la plupart des hébergements mutualisés que je dépanne.
Coûts API et optimisation
Le coût exact dépend du modèle et de la tarification du moment. En avril 2026, les modèles “mini” sont généralement pensés pour ce type de tâche (texte court, faible latence).
Estimation simple (ordre de grandeur)
Un alt généré à partir de quelques lignes de contexte, avec une sortie de 1 phrase, c’est typiquement :
- quelques centaines de tokens en entrée (souvent beaucoup moins),
- moins de 40 tokens en sortie (vous le forcez).
Sur 1 000 images/mois, même un coût de quelques centimes par 100 images peut finir par compter, surtout si vous avez des imports massifs.
Optimisations qui marchent vraiment
- Cache long (30 jours, voire 180 si vous ne changez pas le contexte).
- Verrou anti-doublon (sinon vous payez 2 fois).
- Limiter max_output_tokens (vous n’avez pas besoin de paragraphes).
- Traiter uniquement si l’alt est vide (le plus rentable).
- Traiter par lots en WP-CLI (variante avancée) plutôt qu’à l’upload si vous avez un import énorme.
Variantes et cas d’usage avancés
Variante 1 — Bouton “Générer l’alt” dans la médiathèque
Pour les sites sensibles, je préfère souvent un mode semi-automatique : vous uploadez, puis vous cliquez “Générer” sur certaines images seulement. Ça demande une petite UI (AJAX + nonce). Si vous le souhaitez, je peux vous fournir une version complète (c’est plus long, mais très propre).
Variante 2 — Traiter les images déjà existantes (batch)
Cas classique : vous avez 5 000 images historiques. Le hook add_attachment ne s’exécute pas rétroactivement.
Approche recommandée : une commande WP-CLI qui :
- liste les attachments sans alt,
- traite par paquets de 20,
- dort entre les paquets (rate limit),
- log les erreurs.
Je ne mets pas le code WP-CLI complet ici pour rester débutant-friendly, mais la logique de génération est déjà encapsulée dans bpcab_ai_alt_generate_via_openai().
Variante 3 — Contexte “page builder” (Divi 5 / Elementor / Avada)
Bonne nouvelle : vous n’avez rien de spécial à faire. Divi 5, Elementor et Avada utilisent les images WordPress (attachments). Une fois l’alt enregistré dans la médiathèque, il suit partout :
- modules image Divi,
- widget Image Elementor,
- éléments Image Fusion Builder (Avada).
Le seul piège : si vous avez un module qui “remplace” l’alt par un champ custom (rare, mais déjà vu), votre alt WordPress ne s’affichera pas. Dans ce cas, corrigez le module, pas la médiathèque.
Sécurité et bonnes pratiques
Ne jamais exposer la clé API côté navigateur
Ne faites pas l’appel OpenAI en JavaScript dans l’admin, même si ça semble plus simple. Sinon, la clé fuit (DevTools, source, logs). Ici, tout passe côté serveur via wp_remote_post().
Limiter le débit (rate limiting)
Si vous importez 1 000 images d’un coup, vous pouvez taper un 429 (quota / rate limit). Le verrou évite les doublons, mais pas un pic de volume. Pour de gros imports :
- désactivez temporairement la génération à l’upload,
- faites un batch (WP-CLI) avec pauses,
- ou ajoutez un throttle global (transient “compteur par minute”).
Valider et nettoyer toutes les sorties
Un modèle peut renvoyer des guillemets, des retours à la ligne, ou des caractères invisibles. Le nettoyage ici est strict. Pour des champs plus riches (ex : description), utilisez plutôt wp_kses_post() (doc : wp_kses_post()).
RGPD / confidentialité
Même sans envoyer l’image, vous envoyez potentiellement :
- un titre d’article,
- une légende,
- un nom de fichier (parfois avec un nom de personne…).
Évitez d’envoyer des données personnelles. Sur des sites à risque, je conseille :
- désactiver cette génération sur certains rôles,
- ne pas inclure le titre du post parent,
- ou basculer en génération manuelle.
Ne modifiez jamais le core
Ça paraît évident, mais je le vois encore : ne touchez pas aux fichiers de WordPress pour “ajouter une fonction”. Tout passe par plugin (mu-plugin ou plugin standard).
Comment tester et déboguer
1) Activez les logs WordPress
Dans wp-config.php (sur staging), activez :
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
Le mu-plugin écrit dans le log via error_log() uniquement si WP_DEBUG est actif. Le fichier est généralement wp-content/debug.log.
2) Testez avec une seule image
Uploadez une image avec un nom de fichier propre (tarte-aux-pommes.webp), puis vérifiez dans la médiathèque si le champ alt est rempli.
3) Vérifiez la métadonnée directement
Vous pouvez vérifier côté base via un plugin de gestion (ou via WP-CLI) : la clé est _wp_attachment_image_alt.
Doc sur les métadonnées : update_post_meta().
4) Erreur classique : code au mauvais endroit
Si vous mettez ce code dans un plugin de snippets qui s’exécute trop tard, ou dans un thème qui n’est pas chargé en admin, vous aurez un comportement aléatoire. Le mu-plugin réduit fortement ce risque.
Si ça ne marche pas
Voici un tableau de diagnostic basé sur ce que je vois en dépannage.
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Alt reste vide après upload | Clé API absente | Log: “OPENAI_API_KEY manquante” | Ajouter define('OPENAI_API_KEY','...') dans wp-config.php |
| Alt reste vide, pas de logs | WP_DEBUG désactivé | Vérifier WP_DEBUG |
Activer WP_DEBUG_LOG sur staging |
| Alt généré une fois sur deux | Timeout / latence hébergeur | Logs: erreur réseau / délai | Augmenter timeout à 20s, ou traiter en batch |
| HTTP 401 dans les logs | Clé invalide / mal copiée | Log “OpenAI HTTP 401” | Regénérer la clé, vérifier espaces, guillemets |
| HTTP 429 dans les logs | Quota/rate limit | Log “OpenAI HTTP 429” | Réduire volume, ajouter pause, batch WP-CLI |
| Erreur fatale PHP | Point-virgule oublié / parenthèse | Écran blanc + log PHP | Restaurer sauvegarde, corriger la syntaxe, utiliser un éditeur avec coloration |
| Le code “ne se charge pas” | Fichier mu-plugin mal placé | Le fichier n’est pas dans mu-plugins |
Créer wp-content/mu-plugins/ et y mettre le fichier |
Erreurs réalistes et corrections rapides
- Vous avez collé la clé API dans le mu-plugin : ça marche, puis vous l’oubliez, puis vous partagez le fichier… et la clé fuit. Remettez-la dans
wp-config.php. - Vous avez testé sur production sans sauvegarde : si un plugin de sécurité bloque les requêtes sortantes, vous perdez du temps en plein trafic. Testez sur staging.
- Hook inadapté : si vous utilisez
save_post, vous déclenchez sur plein de contenus, pas seulement les images. Restez suradd_attachment. - Conflit cache serveur : rare ici (c’est une mise à jour de meta), mais si vous avez un cache objet agressif, videz-le après test.
- Version PHP trop ancienne : si votre hébergeur est en PHP 7.4, vous aurez des erreurs de typage. Passez en PHP 8.1+ (recommandé en 2026).
Ressources
- WordPress — Must-Use Plugins
- WordPress — wp_remote_post()
- WordPress — HTTP API (Plugins)
- WordPress — Transients API
- WordPress — sanitize_text_field()
- PHP — json_decode()
- GitHub — WordPress Core (wordpress-develop)
- OpenAI — Documentation API
FAQ
Est-ce que ce mu-plugin fonctionne avec Divi 5, Elementor et Avada ?
Oui. Il agit au niveau de la médiathèque (attachments). Les page builders réutilisent ces métadonnées, donc l’alt sera disponible partout où l’image WordPress est utilisée.
Pourquoi l’alt ne se met pas à jour sur des images déjà présentes ?
Parce que le hook add_attachment ne se déclenche que lors de l’ajout. Pour l’existant, il faut un traitement par lot (WP-CLI ou outil admin dédié).
Est-ce que je peux générer l’alt à partir de l’image (vision) ?
Oui, mais ce n’est plus “débutant” : il faut envoyer l’image (ou une URL accessible) au modèle compatible vision, gérer la taille, et accepter un coût plus élevé. Pour beaucoup de blogs, le contexte texte suffit si vos noms de fichiers et légendes sont propres.
Mon alt généré est parfois faux. Normal ?
Oui. Sans vision, le modèle devine à partir du contexte. Réduisez le risque en améliorant :
- le nom de fichier,
- la légende,
- le titre de l’image,
- ou en passant en mode semi-automatique (bouton “Générer”).
Est-ce que ça peut ralentir l’upload d’images ?
Oui : l’upload attend la requête HTTP. Si votre serveur est lent ou si l’API répond lentement, vous le sentirez. Pour les gros volumes, préférez un batch asynchrone.
Je vois des erreurs HTTP 429, que faire ?
Vous tapez un rate limit ou un quota. Réduisez la cadence (batch + pauses), vérifiez votre plan API, et gardez le cache activé.
Est-ce que je peux changer le modèle ?
Oui : remplacez gpt-4.1-mini par un autre modèle disponible sur votre compte. Gardez max_output_tokens bas pour éviter les réponses longues.
Pourquoi utiliser un transient alors que l’alt est déjà stocké ?
Le transient sert surtout à éviter des appels doubles pendant un import ou des traitements concurrents, et à permettre une réécriture rapide si une première écriture échoue puis réussit plus tard.
Est-ce que ce code peut casser mon site ?
Comme tout code PHP, oui si vous introduisez une erreur de syntaxe (point-virgule oublié) ou si vous le collez au mauvais endroit. D’où : sauvegarde, staging, et logs.
Je n’ai pas de dossier mu-plugins, c’est normal ?
Oui. WordPress ne le crée pas toujours. Créez wp-content/mu-plugins/ manuellement, puis ajoutez le fichier.
Est-ce que je dois régénérer les permaliens ?
Non. Ce mu-plugin ne touche pas aux URLs ni aux règles de réécriture. Si quelqu’un vous conseille ça pour ce cas précis, c’est un réflexe hors sujet.