Si vous avez déjà passé 40 minutes à rédiger une description produit pour finir par une version tiède, vous allez aimer ce workflow : WooCommerce génère une description “longue” et un extrait “court” à partir du titre, des attributs et de quelques notes métier — et vous gardez la main sur la validation.

Le besoin / Le cas d’usage

Sur des catalogues WooCommerce, la rédaction est souvent le goulot d’étranglement. J’ai vu des boutiques avec 300 produits “en attente” uniquement parce qu’il manquait des descriptions, alors que les photos et les prix étaient prêts. Résultat : SEO faible, taux de conversion en berne, et une équipe qui repousse la mise en ligne.

L’IA est utile ici pour produire un premier jet cohérent, structuré et orienté bénéfices, à partir de données déjà disponibles dans WordPress : titre, catégories, attributs, marque, dimensions, matière, etc. L’objectif n’est pas d’automatiser aveuglément, mais de réduire le temps de rédaction par produit, tout en gardant un contrôle éditorial.

À la fin, vous saurez implémenter :

  • Un bouton “Générer avec IA” dans l’éditeur produit (admin WooCommerce).
  • Un appel API IA via wp_remote_post() (sans SDK) avec timeout et gestion d’erreurs.
  • Un cache par transient pour éviter de repayer la même génération.
  • La mise à jour sécurisée de description longue et description courte (extrait) du produit.
  • Un rate limiting simple pour éviter les rafales (et les factures surprises).

Résumé rapide

  • On ajoute un meta box WooCommerce avec un bouton qui déclenche une requête AJAX admin.
  • Le serveur compose un prompt à partir des données produit (attributs, catégories, etc.).
  • Appel IA avec wp_remote_post(), timeout court, retries limités, erreurs loguées.
  • Réponse nettoyée avec wp_kses_post() avant insertion dans post_content et post_excerpt.
  • Cache via Transients API pour éviter de régénérer si rien n’a changé.
  • Clé API stockée dans wp-config.php (jamais en dur, jamais côté JS).

Quand utiliser l’IA pour ça

Utilisez l’IA quand vous avez un volume significatif, une structure de données fiable, et un besoin de cohérence. Typiquement :

  • Boutiques multi-fournisseurs où les fiches arrivent sous forme de données brutes (CSV, ERP) avec peu de texte.
  • Catalogues à variantes (taille/couleur) où vous voulez des descriptions uniformes, et des différences gérées par attributs.
  • SEO “baseline” : produire un texte propre, lisible, avec une structure stable (paragraphes, listes), puis retoucher les produits stratégiques.
  • Internationalisation (variante avancée) : générer une version EN/DE/ES à partir d’une base FR, avec validation humaine.

Ce qui marche bien dans la vraie vie : générer à la création du produit, puis régénérer uniquement si certains champs changent (attributs, catégorie, marque). Le cache par “empreinte” du produit aide beaucoup.

Quand ne PAS utiliser l’IA

Évitez l’IA si une solution classique est plus robuste ou moins risquée :

  • Descriptions strictement légales (cosmétique, compléments, médical) : le risque d’hallucination est réel. Préférez des templates PHP + champs ACF/attributs, ou une base légale validée.
  • Produits très techniques où la moindre erreur crée des retours SAV (compatibilités, normes). Vous pouvez quand même utiliser l’IA, mais uniquement sur un prompt verrouillé + validation obligatoire.
  • Sites très contraints en coûts : si vous avez 20 000 produits et que vous régénérez souvent, la facture API grimpe vite si vous ne mettez pas de cache et de batch.
  • Quand vos données source sont mauvaises (titres incohérents, attributs vides). L’IA va “inventer” pour combler. Dans ce cas, commencez par nettoyer le catalogue.

Anti-pattern fréquent : déclencher une génération à chaque sauvegarde automatique. Vous finissez avec des dizaines d’appels API pour un seul produit parce que Gutenberg autosave, WooCommerce met à jour des metas, et votre hook se déclenche en cascade.

Prérequis

En avril 2026, ciblez au minimum :

  • WordPress 6.9.4 (votre contexte) et PHP 8.1+.
  • WooCommerce à jour (branche 2026). Le code ci-dessous s’appuie sur des APIs WordPress stables, pas sur des internes WooCommerce fragiles.
  • Accès à wp-config.php (ou variables d’environnement) pour stocker la clé API.

Stocker la clé API (wp-config.php)

Ajoutez ceci dans wp-config.php (idéalement via votre gestion de secrets côté hébergeur, mais la constante reste un bon baseline).

/**
 * Clé API OpenAI (exemple).
 * Ne la commitez jamais dans Git. Ne la mettez jamais dans un plugin.
 */
define('BPCAB_OPENAI_API_KEY', 'sk-proj-...');

Si vous préférez les variables d’environnement, vous pouvez faire :

define('BPCAB_OPENAI_API_KEY', getenv('BPCAB_OPENAI_API_KEY') ?: '');

Références officielles utiles

Architecture de la solution

Flux (schéma textuel) :

Admin produit (bouton “Générer”) → AJAX admin (nonce + capabilities) → récupération des données produit → calcul d’une empreinte (hash) → cache transient ? → sinon wp_remote_post() → API IA → parsing JSON → sanitation (wp_kses_post()) → mise à jour du produit (content + excerpt) → retour JSON à l’admin

Ce qui se passe en coulisses

  • UI admin : un meta box avec un bouton + un champ “notes” optionnel. Le JS n’a jamais la clé API.
  • Endpoint AJAX : sécurisé par nonce + current_user_can('edit_product', $product_id).
  • Prompt : construit à partir d’éléments fiables (attributs WooCommerce, catégories, tags, prix si vous voulez).
  • Cache : transient basé sur un hash des données. Si rien n’a changé, on renvoie la description déjà générée.
  • Sanitization : l’IA peut renvoyer du HTML. On autorise seulement ce que WordPress autorise dans un contenu post via wp_kses_post().

Le code complet — étape par étape

Je vous conseille de partir sur un mu-plugin pour éviter qu’un thème enfant ou un plugin de snippets ne “saute” lors d’une mise à jour. Créez : wp-content/mu-plugins/bpcab-ai-woo-descriptions.php.

1) Enregistrer un meta box dans l’admin produit

On utilise l’écran product. Ce meta box reste compatible quel que soit votre builder front (Divi/Elementor/Avada), puisqu’on agit côté admin WooCommerce.

<?php
/**
 * Plugin Name: BPCAB - IA descriptions produits WooCommerce
 * Description: Génère des descriptions produits via IA depuis l'admin WooCommerce (WP 6.9.4+, PHP 8.1+).
 * Version: 1.0.0
 */

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

add_action('add_meta_boxes', function () {
    add_meta_box(
        'bpcab_ai_product_desc',
        'Descriptions IA',
        'bpcab_render_ai_metabox',
        'product',
        'side',
        'high'
    );
});

function bpcab_render_ai_metabox(WP_Post $post): void {
    $product_id = (int) $post->ID;

    if (!current_user_can('edit_product', $product_id)) {
        echo '<p>Vous n’avez pas les droits pour modifier ce produit.</p>';
        return;
    }

    wp_nonce_field('bpcab_ai_generate_desc', 'bpcab_ai_nonce');

    echo '<p>
        <label for="bpcab_ai_notes"><strong>Notes internes (optionnel)</strong></label>
        <textarea id="bpcab_ai_notes" style="width:100%;min-height:70px;" placeholder="Ex: ton premium, mentionner la garantie 2 ans, éviter les superlatifs..."></textarea>
    </p>';

    echo '<p>
        <button type="button" class="button button-primary" id="bpcab-ai-generate" data-product-id="' . esc_attr((string) $product_id) . '">
            Générer avec IA
        </button>
    </p>';

    echo '<div id="bpcab-ai-status" style="margin-top:8px;"></div>';
}

2) Charger un script JS uniquement sur l’écran produit

Erreur que je vois souvent : enqueuer le script partout dans l’admin. Ça ralentit inutilement et peut créer des conflits avec d’autres plugins.

add_action('admin_enqueue_scripts', function (string $hook_suffix) {
    // Écrans typiques : post.php (édition), post-new.php (création)
    if (!in_array($hook_suffix, ['post.php', 'post-new.php'], true)) {
        return;
    }

    $screen = get_current_screen();
    if (!$screen || $screen->post_type !== 'product') {
        return;
    }

    wp_enqueue_script(
        'bpcab-ai-woo-admin',
        plugins_url('bpcab-ai-woo-admin.js', __FILE__),
        ['jquery'],
        '1.0.0',
        true
    );

    wp_localize_script('bpcab-ai-woo-admin', 'BPCAB_AI', [
        'ajaxUrl' => admin_url('admin-ajax.php'),
        'nonce'   => wp_create_nonce('bpcab_ai_generate_desc'),
    ]);
});

Créez ensuite le fichier wp-content/mu-plugins/bpcab-ai-woo-admin.js. Oui, un mu-plugin peut charger un fichier JS à côté, tant que le chemin est correct. Si votre infra bloque ça, mettez le JS dans un plugin classique (c’est souvent plus simple).

(function ($) {
  function setStatus(html) {
    $('#bpcab-ai-status').html(html);
  }

  $(document).on('click', '#bpcab-ai-generate', function () {
    var productId = $(this).data('product-id');
    var notes = $('#bpcab_ai_notes').val() || '';

    setStatus('<p>Génération en cours… (ne fermez pas cet onglet)</p>');

    $.ajax({
      url: BPCAB_AI.ajaxUrl,
      method: 'POST',
      dataType: 'json',
      data: {
        action: 'bpcab_ai_generate_product_desc',
        nonce: BPCAB_AI.nonce,
        product_id: productId,
        notes: notes
      }
    })
    .done(function (resp) {
      if (!resp || !resp.success) {
        var msg = (resp && resp.data && resp.data.message) ? resp.data.message : 'Erreur inconnue.';
        setStatus('<p style="color:#b32d2e;"><strong>Échec:</strong> ' + msg + '</p>');
        return;
      }

      setStatus(
        '<p style="color:#1e7e34;"><strong>OK:</strong> Descriptions mises à jour.</p>' +
        '<p>Astuce: cliquez sur “Mettre à jour” pour déclencher les hooks habituels de votre stack (cache, SEO, etc.).</p>'
      );
    })
    .fail(function (xhr) {
      setStatus('<p style="color:#b32d2e;"><strong>Erreur AJAX:</strong> vérifiez la console et vos logs PHP.</p>');
    });
  });
})(jQuery);

3) Créer l’endpoint AJAX (sécurité + validation)

On vérifie nonce + droits + ID produit. Et on évite les appels si la clé API n’est pas configurée.

add_action('wp_ajax_bpcab_ai_generate_product_desc', function () {
    // Vérification nonce
    $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) $_POST['nonce']) : '';
    if (!wp_verify_nonce($nonce, 'bpcab_ai_generate_desc')) {
        wp_send_json_error(['message' => 'Nonce invalide. Rechargez la page produit.'], 403);
    }

    $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
    if ($product_id <= 0) {
        wp_send_json_error(['message' => 'ID produit invalide.'], 400);
    }

    if (!current_user_can('edit_product', $product_id)) {
        wp_send_json_error(['message' => 'Droits insuffisants.'], 403);
    }

    if (!defined('BPCAB_OPENAI_API_KEY') || !BPCAB_OPENAI_API_KEY) {
        wp_send_json_error(['message' => 'Clé API manquante. Définissez BPCAB_OPENAI_API_KEY dans wp-config.php.'], 500);
    }

    $notes = isset($_POST['notes']) ? wp_kses_post((string) $_POST['notes']) : '';

    $result = bpcab_ai_generate_and_update_product($product_id, $notes);

    if (is_wp_error($result)) {
        wp_send_json_error([
            'message' => $result->get_error_message(),
            'code'    => $result->get_error_code(),
        ], 500);
    }

    wp_send_json_success(['updated' => true]);
});

4) Récupérer les données produit et construire le prompt

On s’appuie sur WooCommerce si disponible, sinon on renvoie une erreur claire. Ne mélangez pas ça avec un hook save_post tant que vous n’avez pas stabilisé le flux : l’AJAX est plus prévisible.

function bpcab_ai_generate_and_update_product(int $product_id, string $notes = '') {
    if (!function_exists('wc_get_product')) {
        return new WP_Error('woocommerce_missing', 'WooCommerce ne semble pas actif.');
    }

    $product = wc_get_product($product_id);
    if (!$product) {
        return new WP_Error('product_missing', 'Produit introuvable.');
    }

    // Rate limiting basique (par produit + utilisateur) pour éviter les rafales.
    $user_id = get_current_user_id();
    $rl_key = 'bpcab_ai_rl_' . $user_id . '_' . $product_id;
    if (get_transient($rl_key)) {
        return new WP_Error('rate_limited', 'Vous générez trop vite. Attendez 30 secondes et réessayez.');
    }
    set_transient($rl_key, 1, 30);

    $payload = bpcab_build_product_payload_for_prompt($product);

    // Empreinte: si les données n'ont pas changé, on peut servir depuis cache.
    $fingerprint = hash('sha256', wp_json_encode([
        'payload' => $payload,
        'notes'   => $notes,
        'v'       => '1.0.0', // incrémentez si vous changez la logique de prompt
    ]));

    $cache_key = 'bpcab_ai_desc_' . $product_id . '_' . substr($fingerprint, 0, 12);
    $cached = get_transient($cache_key);
    if (is_array($cached) && isset($cached['long'], $cached['short'])) {
        return bpcab_update_product_descriptions($product_id, $cached['long'], $cached['short']);
    }

    $prompt = bpcab_build_prompt($payload, $notes);

    $ai = bpcab_call_openai_responses_api($prompt);
    if (is_wp_error($ai)) {
        return $ai;
    }

    // Sanitation: on autorise un HTML "post" standard, pas de scripts, pas d'iframes.
    $long = wp_kses_post($ai['long'] ?? '');
    $short = wp_kses_post($ai['short'] ?? '');

    // Fallback si l'IA renvoie vide (ça arrive avec des prompts trop stricts).
    if (mb_strlen(wp_strip_all_tags($long)) < 80) {
        return new WP_Error('ai_empty', 'La réponse IA est trop courte ou vide. Vérifiez vos données produit et le prompt.');
    }
    if (mb_strlen(wp_strip_all_tags($short)) < 30) {
        $short = wp_trim_words(wp_strip_all_tags($long), 35, '…');
    }

    // Cache 30 jours (vous pouvez réduire si votre catalogue change souvent).
    set_transient($cache_key, ['long' => $long, 'short' => $short], 30 * DAY_IN_SECONDS);

    return bpcab_update_product_descriptions($product_id, $long, $short);
}

function bpcab_build_product_payload_for_prompt(WC_Product $product): array {
    $product_id = $product->get_id();

    $cats = wp_get_post_terms($product_id, 'product_cat', ['fields' => 'names']);
    $tags = wp_get_post_terms($product_id, 'product_tag', ['fields' => 'names']);

    // Attributs WooCommerce (pa_*) et attributs custom.
    $attributes_out = [];
    foreach ($product->get_attributes() as $attr) {
        if ($attr->is_taxonomy()) {
            $taxonomy = $attr->get_name();
            $label = wc_attribute_label($taxonomy);
            $terms = wp_get_post_terms($product_id, $taxonomy, ['fields' => 'names']);
            $attributes_out[] = [
                'label' => $label,
                'values' => array_values(array_filter(array_map('sanitize_text_field', $terms))),
            ];
        } else {
            $attributes_out[] = [
                'label' => sanitize_text_field($attr->get_name()),
                'values' => array_values(array_filter(array_map('sanitize_text_field', $attr->get_options()))),
            ];
        }
    }

    $sku = (string) $product->get_sku();
    $price = $product->get_price();
    $regular = $product->get_regular_price();
    $sale = $product->get_sale_price();

    return [
        'title' => sanitize_text_field($product->get_name()),
        'sku' => sanitize_text_field($sku),
        'categories' => array_values(array_filter(array_map('sanitize_text_field', (array) $cats))),
        'tags' => array_values(array_filter(array_map('sanitize_text_field', (array) $tags))),
        'attributes' => $attributes_out,
        'price' => $price !== '' ? (string) $price : '',
        'regular_price' => $regular !== '' ? (string) $regular : '',
        'sale_price' => $sale !== '' ? (string) $sale : '',
        'short_description_existing' => wp_strip_all_tags((string) $product->get_short_description()),
        'description_existing' => wp_strip_all_tags((string) $product->get_description()),
    ];
}

function bpcab_build_prompt(array $payload, string $notes = ''): string {
    $attrs_lines = [];
    foreach (($payload['attributes'] ?? []) as $attr) {
        $label = $attr['label'] ?? '';
        $values = $attr['values'] ?? [];
        if (!$label || empty($values)) {
            continue;
        }
        $attrs_lines[] = '- ' . $label . ' : ' . implode(', ', $values);
    }

    $notes_clean = trim(wp_strip_all_tags($notes));

    // Prompt orienté e-commerce: bénéfices, usage, contraintes, pas de promesses non vérifiées.
    $prompt = "Vous êtes un rédacteur e-commerce senior. Rédigez pour WooCommerce une description produit en FR.nn";
    $prompt .= "Règles STRICTES:n";
    $prompt .= "1) N'inventez aucune caractéristique non fournie.n";
    $prompt .= "2) Pas de superlatifs gratuits ("le meilleur", "incroyable").n";
    $prompt .= "3) Style clair, concret, orienté bénéfices et usages.n";
    $prompt .= "4) HTML autorisé: <p>, <ul>, <li>, <strong>, <em>. Pas de titres H1/H2.n";
    $prompt .= "5) Retournez STRICTEMENT un JSON valide avec les clés: long_html, short_html.nn";

    $prompt .= "Données produit:n";
    $prompt .= "- Titre: " . ($payload['title'] ?? '') . "n";
    if (!empty($payload['sku'])) {
        $prompt .= "- SKU: " . $payload['sku'] . "n";
    }
    if (!empty($payload['categories'])) {
        $prompt .= "- Catégories: " . implode(', ', (array) $payload['categories']) . "n";
    }
    if (!empty($payload['tags'])) {
        $prompt .= "- Tags: " . implode(', ', (array) $payload['tags']) . "n";
    }
    if (!empty($attrs_lines)) {
        $prompt .= "- Attributs:n" . implode("n", $attrs_lines) . "n";
    }

    // Le prix est optionnel: utile si vous voulez positionnement "entrée de gamme/premium".
    if (!empty($payload['price'])) {
        $prompt .= "- Prix actuel (indicatif): " . $payload['price'] . "n";
    }

    if ($notes_clean !== '') {
        $prompt .= "nNotes internes:n" . $notes_clean . "n";
    }

    // Si une description existe déjà, on peut demander une amélioration plutôt qu'une réécriture totale.
    $existing = trim((string) ($payload['description_existing'] ?? ''));
    if ($existing !== '' && mb_strlen($existing) > 80) {
        $prompt .= "nTexte existant (à améliorer sans changer le sens):n" . $existing . "n";
    }

    $prompt .= "nFormat attendu (exemple):n";
    $prompt .= "{"long_html":"<p>...</p>","short_html":"<p>...</p>"}n";

    return $prompt;
}

5) Appeler l’API IA via wp_remote_post()

Exemple avec l’API “Responses” d’OpenAI. Si vous utilisez un autre fournisseur (Anthropic, Mistral, Google), la structure change, mais les principes restent identiques : timeout, gestion d’erreurs, parsing strict.

Je force un retour JSON dans le prompt, puis je parse côté serveur. Ça évite 80% des réponses “bavardes” qui cassent l’insertion.

function bpcab_call_openai_responses_api(string $prompt) {
    $endpoint = 'https://api.openai.com/v1/responses';

    $body = [
        // Modèle: choisissez un modèle "mini" pour réduire les coûts si la qualité vous suffit.
        // Adaptez selon votre compte et les modèles disponibles.
        'model' => 'gpt-4.1-mini',
        'input' => [
            [
                'role' => 'user',
                'content' => [
                    [
                        'type' => 'input_text',
                        'text' => $prompt,
                    ],
                ],
            ],
        ],
        // Limite raisonnable: descriptions produit, pas un roman.
        'max_output_tokens' => 700,
        'temperature' => 0.6,
    ];

    $args = [
        'timeout' => 25, // Évitez 60s: en admin, ça se ressent vite.
        'headers' => [
            'Content-Type' => 'application/json',
            'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
        ],
        'body' => wp_json_encode($body),
    ];

    $res = wp_remote_post($endpoint, $args);

    if (is_wp_error($res)) {
        return new WP_Error('http_error', 'Erreur HTTP vers l’API IA: ' . $res->get_error_message());
    }

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

    if ($code < 200 || $code >= 300) {
        // Log minimal (évitez de logger des données sensibles).
        error_log('[BPCAB AI] API non-200: ' . $code . ' body=' . substr($raw, 0, 500));
        return new WP_Error('api_non_200', 'Réponse IA invalide (HTTP ' . $code . '). Vérifiez votre clé/quota.');
    }

    $json = json_decode($raw, true);
    if (!is_array($json)) {
        return new WP_Error('json_decode', 'Réponse IA non JSON (json_decode a échoué).');
    }

    // Récupération du texte: selon l’API, la sortie peut être structurée.
    // On essaie plusieurs chemins connus, puis on échoue proprement.
    $text = '';

    // Chemin courant: output_text agrégé
    if (isset($json['output_text']) && is_string($json['output_text'])) {
        $text = $json['output_text'];
    }

    // Fallback: certaines réponses contiennent output[] avec content[]
    if ($text === '' && !empty($json['output']) && is_array($json['output'])) {
        foreach ($json['output'] as $item) {
            if (!is_array($item) || empty($item['content']) || !is_array($item['content'])) {
                continue;
            }
            foreach ($item['content'] as $c) {
                if (is_array($c) && ($c['type'] ?? '') === 'output_text' && isset($c['text'])) {
                    $text .= (string) $c['text'];
                }
            }
        }
    }

    $text = trim($text);
    if ($text === '') {
        error_log('[BPCAB AI] Sortie vide. Raw=' . substr($raw, 0, 500));
        return new WP_Error('empty_output', 'L’API IA a renvoyé une sortie vide.');
    }

    // On attend un JSON strict dans $text
    $out = json_decode($text, true);
    if (!is_array($out)) {
        error_log('[BPCAB AI] JSON attendu mais non parsable. Text=' . substr($text, 0, 500));
        return new WP_Error('bad_format', 'Format IA inattendu. Ajustez le prompt (JSON strict).');
    }

    $long = isset($out['long_html']) ? (string) $out['long_html'] : '';
    $short = isset($out['short_html']) ? (string) $out['short_html'] : '';

    return [
        'long' => $long,
        'short' => $short,
    ];
}

6) Mettre à jour le produit (content + excerpt)

On met à jour le post WordPress sous-jacent. WooCommerce lit ensuite ces champs pour afficher la description. J’utilise wp_update_post() pour rester dans le flux WordPress standard (hooks, révisions si activées, etc.).

function bpcab_update_product_descriptions(int $product_id, string $long_html, string $short_html) {
    $update = [
        'ID' => $product_id,
        'post_content' => $long_html,
        'post_excerpt' => $short_html,
    ];

    $res = wp_update_post(wp_slash($update), true, false);

    if (is_wp_error($res)) {
        return new WP_Error('update_failed', 'Échec mise à jour produit: ' . $res->get_error_message());
    }

    // Optionnel: forcer une mise à jour du produit WooCommerce (index, lookup tables, etc.)
    if (function_exists('wc_get_product')) {
        $product = wc_get_product($product_id);
        if ($product) {
            $product->save();
        }
    }

    return true;
}

Le code assemblé complet

Copiez-collez ce fichier dans wp-content/mu-plugins/bpcab-ai-woo-descriptions.php. Créez aussi le JS bpcab-ai-woo-admin.js à côté (fourni plus haut).

<?php
/**
 * Plugin Name: BPCAB - IA descriptions produits WooCommerce
 * Description: Génère des descriptions produits via IA depuis l'admin WooCommerce (WP 6.9.4+, PHP 8.1+).
 * Version: 1.0.0
 */

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

/**
 * Meta box sur l'écran produit.
 */
add_action('add_meta_boxes', function () {
    add_meta_box(
        'bpcab_ai_product_desc',
        'Descriptions IA',
        'bpcab_render_ai_metabox',
        'product',
        'side',
        'high'
    );
});

function bpcab_render_ai_metabox(WP_Post $post): void {
    $product_id = (int) $post->ID;

    if (!current_user_can('edit_product', $product_id)) {
        echo '<p>Vous n’avez pas les droits pour modifier ce produit.</p>';
        return;
    }

    wp_nonce_field('bpcab_ai_generate_desc', 'bpcab_ai_nonce');

    echo '<p>
        <label for="bpcab_ai_notes"><strong>Notes internes (optionnel)</strong></label>
        <textarea id="bpcab_ai_notes" style="width:100%;min-height:70px;" placeholder="Ex: ton premium, mentionner la garantie 2 ans, éviter les superlatifs..."></textarea>
    </p>';

    echo '<p>
        <button type="button" class="button button-primary" id="bpcab-ai-generate" data-product-id="' . esc_attr((string) $product_id) . '">
            Générer avec IA
        </button>
    </p>';

    echo '<div id="bpcab-ai-status" style="margin-top:8px;"></div>';
}

/**
 * JS admin (uniquement sur l'écran produit).
 */
add_action('admin_enqueue_scripts', function (string $hook_suffix) {
    if (!in_array($hook_suffix, ['post.php', 'post-new.php'], true)) {
        return;
    }

    $screen = get_current_screen();
    if (!$screen || $screen->post_type !== 'product') {
        return;
    }

    wp_enqueue_script(
        'bpcab-ai-woo-admin',
        plugins_url('bpcab-ai-woo-admin.js', __FILE__),
        ['jquery'],
        '1.0.0',
        true
    );

    wp_localize_script('bpcab-ai-woo-admin', 'BPCAB_AI', [
        'ajaxUrl' => admin_url('admin-ajax.php'),
        'nonce'   => wp_create_nonce('bpcab_ai_generate_desc'),
    ]);
});

/**
 * Endpoint AJAX sécurisé.
 */
add_action('wp_ajax_bpcab_ai_generate_product_desc', function () {
    $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) $_POST['nonce']) : '';
    if (!wp_verify_nonce($nonce, 'bpcab_ai_generate_desc')) {
        wp_send_json_error(['message' => 'Nonce invalide. Rechargez la page produit.'], 403);
    }

    $product_id = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
    if ($product_id <= 0) {
        wp_send_json_error(['message' => 'ID produit invalide.'], 400);
    }

    if (!current_user_can('edit_product', $product_id)) {
        wp_send_json_error(['message' => 'Droits insuffisants.'], 403);
    }

    if (!defined('BPCAB_OPENAI_API_KEY') || !BPCAB_OPENAI_API_KEY) {
        wp_send_json_error(['message' => 'Clé API manquante. Définissez BPCAB_OPENAI_API_KEY dans wp-config.php.'], 500);
    }

    $notes = isset($_POST['notes']) ? wp_kses_post((string) $_POST['notes']) : '';

    $result = bpcab_ai_generate_and_update_product($product_id, $notes);

    if (is_wp_error($result)) {
        wp_send_json_error([
            'message' => $result->get_error_message(),
            'code'    => $result->get_error_code(),
        ], 500);
    }

    wp_send_json_success(['updated' => true]);
});

function bpcab_ai_generate_and_update_product(int $product_id, string $notes = '') {
    if (!function_exists('wc_get_product')) {
        return new WP_Error('woocommerce_missing', 'WooCommerce ne semble pas actif.');
    }

    $product = wc_get_product($product_id);
    if (!$product) {
        return new WP_Error('product_missing', 'Produit introuvable.');
    }

    $user_id = get_current_user_id();
    $rl_key = 'bpcab_ai_rl_' . $user_id . '_' . $product_id;
    if (get_transient($rl_key)) {
        return new WP_Error('rate_limited', 'Vous générez trop vite. Attendez 30 secondes et réessayez.');
    }
    set_transient($rl_key, 1, 30);

    $payload = bpcab_build_product_payload_for_prompt($product);

    $fingerprint = hash('sha256', wp_json_encode([
        'payload' => $payload,
        'notes'   => $notes,
        'v'       => '1.0.0',
    ]));

    $cache_key = 'bpcab_ai_desc_' . $product_id . '_' . substr($fingerprint, 0, 12);
    $cached = get_transient($cache_key);
    if (is_array($cached) && isset($cached['long'], $cached['short'])) {
        return bpcab_update_product_descriptions($product_id, $cached['long'], $cached['short']);
    }

    $prompt = bpcab_build_prompt($payload, $notes);

    $ai = bpcab_call_openai_responses_api($prompt);
    if (is_wp_error($ai)) {
        return $ai;
    }

    $long = wp_kses_post($ai['long'] ?? '');
    $short = wp_kses_post($ai['short'] ?? '');

    if (mb_strlen(wp_strip_all_tags($long)) < 80) {
        return new WP_Error('ai_empty', 'La réponse IA est trop courte ou vide. Vérifiez vos données produit et le prompt.');
    }
    if (mb_strlen(wp_strip_all_tags($short)) < 30) {
        $short = wp_trim_words(wp_strip_all_tags($long), 35, '…');
    }

    set_transient($cache_key, ['long' => $long, 'short' => $short], 30 * DAY_IN_SECONDS);

    return bpcab_update_product_descriptions($product_id, $long, $short);
}

function bpcab_build_product_payload_for_prompt(WC_Product $product): array {
    $product_id = $product->get_id();

    $cats = wp_get_post_terms($product_id, 'product_cat', ['fields' => 'names']);
    $tags = wp_get_post_terms($product_id, 'product_tag', ['fields' => 'names']);

    $attributes_out = [];
    foreach ($product->get_attributes() as $attr) {
        if ($attr->is_taxonomy()) {
            $taxonomy = $attr->get_name();
            $label = wc_attribute_label($taxonomy);
            $terms = wp_get_post_terms($product_id, $taxonomy, ['fields' => 'names']);
            $attributes_out[] = [
                'label' => $label,
                'values' => array_values(array_filter(array_map('sanitize_text_field', $terms))),
            ];
        } else {
            $attributes_out[] = [
                'label' => sanitize_text_field($attr->get_name()),
                'values' => array_values(array_filter(array_map('sanitize_text_field', $attr->get_options()))),
            ];
        }
    }

    $sku = (string) $product->get_sku();
    $price = $product->get_price();
    $regular = $product->get_regular_price();
    $sale = $product->get_sale_price();

    return [
        'title' => sanitize_text_field($product->get_name()),
        'sku' => sanitize_text_field($sku),
        'categories' => array_values(array_filter(array_map('sanitize_text_field', (array) $cats))),
        'tags' => array_values(array_filter(array_map('sanitize_text_field', (array) $tags))),
        'attributes' => $attributes_out,
        'price' => $price !== '' ? (string) $price : '',
        'regular_price' => $regular !== '' ? (string) $regular : '',
        'sale_price' => $sale !== '' ? (string) $sale : '',
        'short_description_existing' => wp_strip_all_tags((string) $product->get_short_description()),
        'description_existing' => wp_strip_all_tags((string) $product->get_description()),
    ];
}

function bpcab_build_prompt(array $payload, string $notes = ''): string {
    $attrs_lines = [];
    foreach (($payload['attributes'] ?? []) as $attr) {
        $label = $attr['label'] ?? '';
        $values = $attr['values'] ?? [];
        if (!$label || empty($values)) {
            continue;
        }
        $attrs_lines[] = '- ' . $label . ' : ' . implode(', ', $values);
    }

    $notes_clean = trim(wp_strip_all_tags($notes));

    $prompt = "Vous êtes un rédacteur e-commerce senior. Rédigez pour WooCommerce une description produit en FR.nn";
    $prompt .= "Règles STRICTES:n";
    $prompt .= "1) N'inventez aucune caractéristique non fournie.n";
    $prompt .= "2) Pas de superlatifs gratuits ("le meilleur", "incroyable").n";
    $prompt .= "3) Style clair, concret, orienté bénéfices et usages.n";
    $prompt .= "4) HTML autorisé: <p>, <ul>, <li>, <strong>, <em>. Pas de titres H1/H2.n";
    $prompt .= "5) Retournez STRICTEMENT un JSON valide avec les clés: long_html, short_html.nn";

    $prompt .= "Données produit:n";
    $prompt .= "- Titre: " . ($payload['title'] ?? '') . "n";
    if (!empty($payload['sku'])) {
        $prompt .= "- SKU: " . $payload['sku'] . "n";
    }
    if (!empty($payload['categories'])) {
        $prompt .= "- Catégories: " . implode(', ', (array) $payload['categories']) . "n";
    }
    if (!empty($payload['tags'])) {
        $prompt .= "- Tags: " . implode(', ', (array) $payload['tags']) . "n";
    }
    if (!empty($attrs_lines)) {
        $prompt .= "- Attributs:n" . implode("n", $attrs_lines) . "n";
    }
    if (!empty($payload['price'])) {
        $prompt .= "- Prix actuel (indicatif): " . $payload['price'] . "n";
    }

    if ($notes_clean !== '') {
        $prompt .= "nNotes internes:n" . $notes_clean . "n";
    }

    $existing = trim((string) ($payload['description_existing'] ?? ''));
    if ($existing !== '' && mb_strlen($existing) > 80) {
        $prompt .= "nTexte existant (à améliorer sans changer le sens):n" . $existing . "n";
    }

    $prompt .= "nFormat attendu (exemple):n";
    $prompt .= "{"long_html":"<p>...</p>","short_html":"<p>...</p>"}n";

    return $prompt;
}

function bpcab_call_openai_responses_api(string $prompt) {
    $endpoint = 'https://api.openai.com/v1/responses';

    $body = [
        'model' => 'gpt-4.1-mini',
        'input' => [
            [
                'role' => 'user',
                'content' => [
                    [
                        'type' => 'input_text',
                        'text' => $prompt,
                    ],
                ],
            ],
        ],
        'max_output_tokens' => 700,
        'temperature' => 0.6,
    ];

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

    $res = wp_remote_post($endpoint, $args);

    if (is_wp_error($res)) {
        return new WP_Error('http_error', 'Erreur HTTP vers l’API IA: ' . $res->get_error_message());
    }

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

    if ($code < 200 || $code >= 300) {
        error_log('[BPCAB AI] API non-200: ' . $code . ' body=' . substr($raw, 0, 500));
        return new WP_Error('api_non_200', 'Réponse IA invalide (HTTP ' . $code . '). Vérifiez votre clé/quota.');
    }

    $json = json_decode($raw, true);
    if (!is_array($json)) {
        return new WP_Error('json_decode', 'Réponse IA non JSON (json_decode a échoué).');
    }

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

    if ($text === '' && !empty($json['output']) && is_array($json['output'])) {
        foreach ($json['output'] as $item) {
            if (!is_array($item) || empty($item['content']) || !is_array($item['content'])) {
                continue;
            }
            foreach ($item['content'] as $c) {
                if (is_array($c) && ($c['type'] ?? '') === 'output_text' && isset($c['text'])) {
                    $text .= (string) $c['text'];
                }
            }
        }
    }

    $text = trim($text);
    if ($text === '') {
        error_log('[BPCAB AI] Sortie vide. Raw=' . substr($raw, 0, 500));
        return new WP_Error('empty_output', 'L’API IA a renvoyé une sortie vide.');
    }

    $out = json_decode($text, true);
    if (!is_array($out)) {
        error_log('[BPCAB AI] JSON attendu mais non parsable. Text=' . substr($text, 0, 500));
        return new WP_Error('bad_format', 'Format IA inattendu. Ajustez le prompt (JSON strict).');
    }

    return [
        'long' => isset($out['long_html']) ? (string) $out['long_html'] : '',
        'short' => isset($out['short_html']) ? (string) $out['short_html'] : '',
    ];
}

function bpcab_update_product_descriptions(int $product_id, string $long_html, string $short_html) {
    $update = [
        'ID' => $product_id,
        'post_content' => $long_html,
        'post_excerpt' => $short_html,
    ];

    $res = wp_update_post(wp_slash($update), true, false);

    if (is_wp_error($res)) {
        return new WP_Error('update_failed', 'Échec mise à jour produit: ' . $res->get_error_message());
    }

    if (function_exists('wc_get_product')) {
        $product = wc_get_product($product_id);
        if ($product) {
            $product->save();
        }
    }

    return true;
}

Explication du code

Pourquoi un bouton (AJAX) plutôt qu’un hook automatique

Sur WooCommerce, un produit peut être sauvegardé plusieurs fois sans que vous cliquiez “Mettre à jour” : autosave, révisions, mise à jour de metas par d’autres plugins, sync inventaire, etc. Si vous accrochez l’IA sur save_post_product sans garde-fous, vous allez déclencher des appels API fantômes.

Le bouton force un acte volontaire. Et ça rend le débogage plus simple : vous cliquez, vous voyez le statut, vous corrigez.

Pourquoi un transient basé sur un hash

Un cache “par produit” simple (ex : bpcab_ai_desc_123) est vite faux, parce que le produit change. L’empreinte SHA-256 du payload + notes vous garantit : mêmes entrées → même sortie → pas de repaiement.

Edge case réel : si vous modifiez le prompt (structure, règles), vous voulez invalider le cache. D’où le petit champ v dans le hash.

Pourquoi wp_kses_post() et pas sanitize_text_field()

sanitize_text_field() détruit le HTML. Or une description WooCommerce a besoin de listes, gras, paragraphes. En pratique, wp_kses_post() est le bon compromis : HTML autorisé pour un post, scripts retirés.

Je vois encore des sites qui insèrent la réponse IA “brute” dans post_content. Si l’IA renvoie un lien douteux ou du HTML exotique, vous venez d’ouvrir une porte inutile. Ce n’est pas un XSS “automatique” (WordPress filtre déjà à certains endroits), mais vous vous compliquez la vie.

Pourquoi un timeout court

En admin, un timeout à 60 secondes bloque l’UI et donne l’impression que WordPress “freeze”. 20–30 secondes est un bon plafond. Si vous avez des produits très complexes, passez plutôt sur une génération asynchrone (cron/queue), voir variantes avancées.

Coûts API et optimisation

Le coût dépend du modèle et du volume de tokens. Une fiche produit typique (prompt + réponse) peut faire, en ordre de grandeur, 800 à 2000 tokens selon la quantité d’attributs et la longueur demandée. Avec un modèle “mini”, vous êtes souvent sur un coût très bas par génération, mais à grande échelle ça compte.

Estimation simple (à adapter à votre modèle)

  • Supposons 1200 tokens par produit (entrée+sortie).
  • 1000 produits/mois → 1,2M tokens/mois.
  • Avec un modèle “mini”, c’est généralement une facture raisonnable, mais si vous régénérez (sans cache), vous multipliez vite par 3 ou 10.

Optimisations qui marchent vraiment

  • Cache par empreinte (déjà en place) : c’est le meilleur ROI.
  • Limiter la sortie avec max_output_tokens : évite les réponses fleuves.
  • Modèle plus petit pour le bulk, modèle plus “fort” uniquement pour les produits premium.
  • Batch (variante avancée) : traiter 50 produits en cron nocturne, avec un budget quotidien.
  • Réduire le prompt : n’envoyez pas des attributs vides, ni la description existante si elle est vide.

Variantes et cas d’usage avancés

Variante 1 : générer uniquement la description courte (excerpt)

Utile quand votre description longue est déjà écrite (ou fournie par un fabricant), mais que vous voulez un extrait vendeur et homogène pour les pages catégories.

  • Modifiez le prompt pour ne demander que short_html.
  • Dans bpcab_update_product_descriptions(), ne mettez à jour que post_excerpt.

Variante 2 : génération asynchrone (éviter les timeouts)

Sur des hébergements lents, l’appel externe peut dépasser 25 secondes. Dans ce cas, déclenchez une tâche différée :

  • AJAX: enregistre une “demande” (post meta) et répond immédiatement.
  • Un cron (WP-Cron ou cron serveur) traite les demandes en file, 5 par minute.

Je ne mets pas tout le code ici pour rester copiable, mais le point clé est de ne pas faire l’appel IA dans la requête admin si votre infra est instable.

Variante 3 : compatibilité Divi 5 / Elementor / Avada

La génération se fait côté WooCommerce, donc elle reste compatible. Là où ça devient intéressant : afficher un “badge” ou un bloc “Points forts” sur la fiche produit via votre builder.

  • Divi 5 : vous pouvez créer un module qui lit post_excerpt ou un champ meta “points_forts” généré par IA. Si vous restez sur post_content/post_excerpt, Divi n’a rien de spécial à faire.
  • Elementor : utilisez le widget “Product Short Description” et il affichera automatiquement l’extrait mis à jour.
  • Avada : le composant WooCommerce “Product Content” / “Product Excerpt” reflète ces champs sans adaptation.

Astuce que j’applique souvent : demander à l’IA une liste <ul> de bénéfices dans la description longue, puis styler cette liste via le thème/builder. Vous gagnez en cohérence visuelle sans coder plus.

Sécurité et bonnes pratiques

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

Le JS admin ne fait qu’appeler admin-ajax.php. La clé reste dans wp-config.php. Si vous mettez la clé dans un script, même dans l’admin, elle finira copiée quelque part (cache, extensions navigateur, proxy).

Valider et limiter

  • Capabilities : edit_product minimum. Ne laissez pas un rôle “shop_manager” non maîtrisé déclencher 10 000 générations.
  • Nonce : déjà en place pour éviter les requêtes CSRF.
  • Rate limiting : transient 30 secondes par user+produit. Pour aller plus loin, ajoutez un quota par jour (transient “compteur”).

Sanitizer la réponse IA

wp_kses_post() est un minimum. Si vous voulez être plus strict, vous pouvez passer une liste de tags autorisés à wp_kses() et refuser tout lien externe.

RGPD / données envoyées

N’envoyez pas de données personnelles (nom client, adresse, etc.). Ici, on envoie uniquement des données produit. Si vous envisagez des descriptions personnalisées selon un utilisateur, vous entrez dans un périmètre RGPD plus sensible (base légale, sous-traitant, DPA, etc.).

Erreurs réalistes à éviter

  • Copier le code au mauvais endroit : un snippet collé dans functions.php d’un thème parent sera perdu à la prochaine mise à jour.
  • Oublier le point-virgule : une seule erreur PHP → écran blanc en admin. Testez sur staging.
  • Hook inadapté : déclencher sur init ou save_post sans garde-fou → appels IA involontaires.
  • Tester en production sans sauvegarde : vous pouvez écraser des descriptions existantes en masse.
  • PHP trop ancien : certaines syntaxes/typages ci-dessus supposent PHP 8.1+. Sur 7.4, ça casse.

Comment tester et déboguer

1) Activez les logs proprement

Dans wp-config.php (sur staging), activez :

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

Vous lirez ensuite wp-content/debug.log. Documentation officielle : Debugging in WordPress.

2) Testez d’abord sur un produit “simple”

  • Un titre clair
  • 2–3 attributs remplis
  • Une catégorie

Si ça marche, passez à un produit variable avec beaucoup d’attributs. Les prompts trop longs augmentent le risque de sortie non conforme (ou tronquée).

3) Vérifiez la requête AJAX

  • Ouvrez les DevTools navigateur → Network → admin-ajax.php.
  • Regardez le code HTTP (200/403/500) et le JSON renvoyé.

4) Vérifiez la réponse API

Si vous avez des erreurs de format JSON, loguez seulement des extraits (déjà fait avec substr()). Ne loguez pas tout le prompt si vous y mettez des infos sensibles.

Si ça ne marche pas

Voici un tableau de diagnostic que j’utilise vraiment quand je dépanne ce type d’intégration.

Symptôme Cause probable Vérification Solution
Bouton “Générer” ne fait rien JS non chargé sur l’écran produit DevTools Console/Network, absence de bpcab-ai-woo-admin.js Vérifiez admin_enqueue_scripts, le chemin plugins_url(), et que le fichier JS existe
Erreur 403 “Nonce invalide” Nonce non envoyé ou page trop ancienne Network → payload POST contient nonce ? Rechargez la page produit, vérifiez wp_localize_script et l’action du nonce
Erreur 500 “Clé API manquante” Constante absente / vide Vérifiez wp-config.php et l’environnement Définissez BPCAB_OPENAI_API_KEY, purgez l’opcache si nécessaire
HTTP 401/403 côté API IA Clé invalide, projet non autorisé Logs: “API non-200” + code Regénérez une clé, vérifiez les permissions du projet côté fournisseur
HTTP 429 Quota dépassé / rate limit fournisseur Logs + dashboard fournisseur Ajoutez backoff/retry, réduisez la cadence, activez le cache, utilisez un modèle plus léger
“Format IA inattendu” L’IA renvoie du texte non JSON Log partiel de la sortie Rendez le prompt plus strict, baissez temperature, réduisez la longueur demandée
Descriptions écrasées “au hasard” Test sur production + clics multiples + autosave Historique révisions / logs Travaillez sur staging, ajoutez un verrou (transient) plus long, exigez une confirmation

Deux pièges fréquents

  • Snippet cassé par un plugin de snippets : si vous utilisez un plugin type “Code Snippets”, un parse error désactive parfois le snippet mais laisse l’admin dans un état bancal. Le mu-plugin est plus stable pour ce genre de code.
  • Conflit cache : certains sites ont un cache objet persistant agressif. Les transients peuvent être partagés entre environnements si mal configurés. Si vous voyez des descriptions “qui ne correspondent pas”, commencez par désactiver temporairement l’object cache sur staging.

Ressources

FAQ

Est-ce que ça marche avec des produits variables (variations) ?

Oui, mais le code ci-dessus génère la description du produit parent. Si vous voulez des textes par variation, il faut itérer sur les variations et stocker dans des metas dédiées (et adapter l’affichage). Je le fais rarement : c’est coûteux et souvent inutile côté SEO.

Est-ce que je peux empêcher l’IA de mentionner le prix ?

Oui : retirez le champ price du payload et la ligne correspondante dans le prompt. Je recommande de ne pas inclure de prix si vous faites souvent des promotions, sinon vous aurez des textes obsolètes.

Pourquoi demander un JSON en sortie plutôt qu’un simple HTML ?

Parce que vous voulez deux champs (long + court) et un parsing robuste. Le JSON réduit les cas où l’IA ajoute des phrases hors format. Et quand ça casse, vous le voyez tout de suite (erreur “bad_format”).

Puis-je utiliser Anthropic, Mistral ou Google à la place ?

Oui. Gardez la même architecture (AJAX admin → wp_remote_post() → parse → wp_kses_post() → update). Seuls l’endpoint et la forme du JSON changent. Si vous me donnez le fournisseur exact, je peux vous fournir une fonction bpcab_call_* équivalente.

Est-ce que ça risque de créer du contenu dupliqué ?

Si vos produits sont très proches (mêmes attributs, titres quasi identiques), l’IA peut produire des textes similaires. Pour limiter ça : ajoutez une contrainte “différencier par usage” dans le prompt, et injectez un élément unique (marque, matière, bénéfice principal). Mais sur des catalogues homogènes, un peu de similarité est inévitable.

Comment éviter que l’IA invente des caractéristiques ?

Vous ne l’éviterez jamais à 0%. Vous réduisez fortement le risque en :

  • Interdisant explicitement l’invention (déjà fait).
  • Baissant la temperature (0.3–0.6).
  • Ne fournissant que des données structurées (attributs) et en évitant les “notes” ambiguës.

Je reçois “Format IA inattendu”. Que faire en premier ?

Réduisez la complexité : moins d’attributs, max_output_tokens plus haut (si ça tronque), et un prompt encore plus strict (“Retournez uniquement le JSON, sans texte avant/après”). Dans mon expérience, 9 fois sur 10, c’est juste une sortie non JSON.

Pourquoi le bouton dit “OK” mais je ne vois rien côté front ?

Souvent, c’est un cache de page (ou un cache objet) qui sert une ancienne version. Purgez le cache, et vérifiez que votre thème affiche bien the_content() et l’extrait WooCommerce standard. Avec Elementor/Avada/Divi, vérifiez que vous n’affichez pas un champ personnalisé à la place.

Est-ce que je peux prévisualiser avant d’écraser la description ?

Oui : au lieu d’appeler wp_update_post(), renvoyez long_html et short_html dans la réponse AJAX, affichez-les dans une modale, puis ajoutez un second bouton “Appliquer”. C’est la version que je déploie sur les boutiques où plusieurs personnes éditent.

Est-ce que ça peut casser mon SEO si je génère en masse ?

Si vous publiez 500 fiches générées sans relecture, vous prenez un risque qualité (et donc SEO). Le workflow sain : génération IA → validation humaine → publication. Et pour les produits stratégiques, vous réécrivez réellement.