Si vous avez déjà collé un JSON-LD Schema.org “à la main” dans un article, vous avez vu le vrai problème : au bout de 30 publications, c’est incohérent, incomplet, et personne n’ose le maintenir.

Le besoin / Le cas d’usage

Les données structurées (JSON-LD) aident Google et les autres moteurs à comprendre précisément un contenu : type d’article, auteur, date de publication, image principale, entité “about”, FAQ, etc. Sur WordPress, vous avez déjà beaucoup d’informations fiables (titre, excerpt, image mise en avant). Le trou dans la raquette, c’est le “sens” : sujets, entités, intentions, et parfois le bon type Schema.org (Article vs TechArticle vs NewsArticle, etc.).

L’IA est utile ici pour produire une couche sémantique à partir du contenu, sans que vous passiez 10 minutes par article à choisir des mots-clés, des entités ou des sections “about”. Dans mon expérience, c’est particulièrement rentable sur :

  • Blogs techniques (WordPress, dev, data) : l’IA extrait bien les technologies, versions, concepts.
  • Sites éditoriaux avec beaucoup de rédacteurs : standardiser le balisage sans former tout le monde à Schema.org.
  • Sites e-commerce “contenu” (guides, comparatifs) : enrichir les articles sans toucher aux fiches produits.

À la fin, vous saurez implémenter un plugin (compatible WordPress 6.9.4 / PHP 8.1+) qui :

  • génère un JSON-LD Schema.org par article via une API IA (appel wp_remote_post())
  • met en cache la réponse (Transients API)
  • ne régénère que quand c’est nécessaire (publication / mise à jour)
  • injecte le JSON-LD dans le <head> côté front
  • gère les erreurs (timeouts, quota, JSON invalide) avec des fallbacks propres

Résumé rapide

  • On génère un JSON-LD par post via IA, puis on le stocke en post meta (et on met un transient en plus pour éviter les appels répétitifs).
  • La clé API est dans wp-config.php via define(), jamais en dur.
  • On appelle l’API IA avec wp_remote_post() + timeout + gestion d’erreurs.
  • On force l’IA à renvoyer un JSON strict (et on le valide côté PHP).
  • On injecte le script JSON-LD via wp_head (front) et on évite l’admin.
  • On ajoute un endpoint REST admin-only pour régénérer à la demande (pratique en QA).

Quand utiliser l’IA pour ça

Utilisez l’IA si vous avez un vrai besoin d’enrichissement sémantique, pas juste “mettre Article partout”. Les bons cas :

  • Contenus longs (1000+ mots) où l’extraction d’entités (marques, outils, concepts) apporte de la précision.
  • Taxonomies incomplètes (catégories/tags peu fiables) et vous voulez des champs “about/mentions” plus propres.
  • Rédaction en équipe avec des styles hétérogènes : l’IA normalise.
  • Migration SEO (nouveau thème, nouveau plugin SEO) : vous pouvez générer un schéma cohérent sans réécrire les posts.

J’ai souvent vu un gain sur les sites où l’extrait WordPress est vide et où les auteurs changent beaucoup : l’IA produit une description cohérente qui évite le “snippet” aléatoire.

Quand ne PAS utiliser l’IA

Évitez l’IA si votre schéma est purement mécanique et déjà déterministe.

  • Sites vitrine avec 10 pages : faites-le à la main ou via un plugin SEO.
  • Schémas simples (Organization, WebSite, BreadcrumbList) déjà gérés par votre plugin SEO.
  • Contenu sensible (santé, juridique) si vous comptez sur l’IA pour “inventer” des propriétés. Ici, l’IA doit rester extractive, pas créative.
  • Budget serré et gros volume de posts publiés chaque jour : les coûts API peuvent monter si vous régénérez trop souvent.

Anti-pattern classique : appeler l’IA à chaque affichage de page. Ça finit en timeouts, factures inutiles, et parfois pages blanches si le code est mal protégé.

Prérequis

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

Clé API et stockage

Vous pouvez utiliser OpenAI, Anthropic, Mistral, Google. Je vais fournir un exemple OpenAI (Responses API) car c’est très stable côté JSON strict, mais la structure du plugin permet de remplacer facilement le provider.

Stockez la clé dans wp-config.php (ou mieux, une variable d’environnement injectée par votre hébergeur). Exemple :

/**
 * Clé API IA (ne jamais commiter ce fichier).
 * Idéalement, utilisez une variable d'environnement et fallback sur define().
 */
define('BPCAB_AI_OPENAI_API_KEY', 'REMPPLACEZ-MOI');

Extensions PHP

  • cURL (souvent activé) ou allow_url_fopen (WordPress utilise Requests, qui s’appuie sur cURL si dispo).
  • JSON (standard).

Sources officielles utiles

Architecture de la solution

Flux (version “texte”) utilisé par le plugin :

Éditeur WordPress (save_post) → préparation des données (titre, contenu, extrait, image, auteur) → wp_remote_post() vers API IA → réponse JSON → validation/sanitation → stockage post meta + transient → front (wp_head) injecte <script type= »application/ld+json »>…

Pourquoi ce flux marche bien en production

  • Génération au moment de la sauvegarde (ou à la demande), pas à l’affichage : vous ne bloquez pas le rendu front si l’API IA est lente.
  • Cache : un transient court évite les régénérations en boucle quand un rédacteur clique “Mettre à jour” 5 fois.
  • Post meta : persistant, exportable, et versionnable (si vous avez un système de staging).
  • Validation JSON : si l’IA renvoie du texte ou du JSON cassé, on n’injecte rien (fallback).

Point d’attention : plugins SEO et doublons

Yoast, Rank Math, SEOPress, etc. injectent déjà des JSON-LD. Si vous ajoutez le vôtre, vous risquez :

  • des doublons (deux Article)
  • des incohérences (deux auteurs, deux images)

La stratégie que je recommande : injecter un schéma “complémentaire” (par ex. about, mentions, keywords, audience) dans un seul Article que vous contrôlez, ou alors produire un @graph propre. Le code ci-dessous génère un @graph minimal et évite de “réinventer” Organization/WebSite.

Le code complet — étape par étape

Je vous conseille de le mettre en mu-plugin si vous voulez que ça survive aux changements de thème et aux “désactivations accidentelles”. Sinon, plugin classique.

Étape 1 — structure minimale du plugin

Créez un fichier : wp-content/mu-plugins/bpcab-ai-schema.php (créez le dossier si besoin).

<?php
/**
 * Plugin Name: BPCAB AI Schema (JSON-LD)
 * Description: Génère et injecte des données structurées Schema.org via IA par article.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 *
 * Conseil : placez ce fichier en mu-plugin pour éviter la désactivation accidentelle.
 */

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

Étape 2 — constantes, options et garde-fous

On sécurise dès le départ : si la clé n’existe pas, on ne tente rien. J’ai souvent vu des sites partir en erreurs 401 en boucle parce que le code “insistait” malgré une clé absente.

/**
 * Retourne la clé API OpenAI depuis wp-config.php.
 */
function bpcab_ai_schema_get_openai_key(): string {
	if (defined('BPCAB_AI_OPENAI_API_KEY') && is_string(BPCAB_AI_OPENAI_API_KEY) && BPCAB_AI_OPENAI_API_KEY !== '') {
		return BPCAB_AI_OPENAI_API_KEY;
	}
	return '';
}

/**
 * Petite liste de post types autorisés.
 * Ajustez selon votre site (ex: 'post', 'page', 'guide', etc.).
 */
function bpcab_ai_schema_allowed_post_types(): array {
	return array('post');
}

Étape 3 — extraction des données WordPress “fiables”

L’IA ne doit pas inventer des dates, des auteurs ou des URLs. On les prend depuis WordPress, puis on demande à l’IA uniquement l’enrichissement sémantique.

/**
 * Construit un paquet de données "source of truth" depuis WordPress.
 * On évite d'envoyer des données inutiles (coût + confidentialité).
 */
function bpcab_ai_schema_build_post_payload(int $post_id): array {
	$post = get_post($post_id);
	if (!$post) {
		return array();
	}

	$title   = get_the_title($post);
	$content = $post->post_content;

	// Option : limiter la taille envoyée à l'API (coût + latence).
	// Ici, on garde le contenu brut, mais vous pouvez préférer wp_strip_all_tags().
	$content_plain = wp_strip_all_tags($content);
	$content_plain = mb_substr($content_plain, 0, 12000); // garde-fou

	$excerpt = has_excerpt($post) ? $post->post_excerpt : wp_trim_words($content_plain, 55, '…');

	$author_id = (int) $post->post_author;
	$author_name = $author_id ? get_the_author_meta('display_name', $author_id) : '';

	$permalink = get_permalink($post);
	$published = get_the_date(DATE_W3C, $post);
	$modified  = get_the_modified_date(DATE_W3C, $post);

	$image_id = get_post_thumbnail_id($post);
	$image_url = '';
	if ($image_id) {
		$image = wp_get_attachment_image_src($image_id, 'full');
		if (is_array($image) && !empty($image[0])) {
			$image_url = $image[0];
		}
	}

	return array(
		'post_id'      => $post_id,
		'post_type'    => $post->post_type,
		'title'        => $title,
		'excerpt'      => $excerpt,
		'content'      => $content_plain,
		'permalink'    => $permalink,
		'datePublished'=> $published,
		'dateModified' => $modified,
		'authorName'   => $author_name,
		'image'        => $image_url,
		'language'     => get_bloginfo('language'),
	);
}

Étape 4 — prompt IA “strict JSON” + appel API via wp_remote_post()

Le point qui casse la plupart des implémentations : l’IA renvoie du texte autour du JSON, ou des champs non conformes. On force un format strict, et on valide ensuite.

Exemple avec OpenAI (endpoint Responses). Référence API officielle : OpenAI Responses API.

/**
 * Appelle OpenAI pour générer un JSON Schema.org (ou un fragment) basé sur le contenu.
 * Retourne un tableau PHP (décodé) ou WP_Error.
 */
function bpcab_ai_schema_call_openai(array $payload) {
	$api_key = bpcab_ai_schema_get_openai_key();
	if ($api_key === '') {
		return new WP_Error('bpcab_no_api_key', 'Clé API OpenAI manquante (BPCAB_AI_OPENAI_API_KEY).');
	}

	// Prompt : on demande un JSON STRICT, sans texte.
	$system = "Vous êtes un assistant spécialisé en SEO technique. Vous produisez uniquement du JSON strict, sans commentaire ni markdown.";
	$user = array(
		"Objectif: Générer un JSON-LD Schema.org pour un article WordPress.n"
		. "Contraintes:n"
		. "- Répondre uniquement avec un objet JSON valide.n"
		. "- Ne pas inventer d'URL, de dates, d'auteur.n"
		. "- Utiliser EXACTEMENT les valeurs fournies pour headline, url, datePublished, dateModified, author.name, image.n"
		. "- Ajouter des champs sémantiques utiles: keywords (array), about (array of Thing), mentions (array of Thing), articleSection (string), inLanguage.n"
		. "- Type recommandé: Article (ou TechArticle si le texte est technique).n"
		. "- Produire un JSON-LD avec @context et @graph.n"
		. "- Limiter keywords à 12 max. about/mentions: 8 max chacun.n"
		. "- Ne pas inclure Organization/WebSite si vous n'avez pas les données.nn"
		. "Données fiables (à utiliser telles quelles):n"
		. wp_json_encode(array(
			"headline" => $payload['title'] ?? '',
			"description" => $payload['excerpt'] ?? '',
			"url" => $payload['permalink'] ?? '',
			"datePublished" => $payload['datePublished'] ?? '',
			"dateModified" => $payload['dateModified'] ?? '',
			"authorName" => $payload['authorName'] ?? '',
			"image" => $payload['image'] ?? '',
			"inLanguage" => $payload['language'] ?? 'fr-FR',
		)) . "nn"
		. "Contenu (extrait):n"
		. ($payload['content'] ?? '')
	);

	$body = array(
		'model' => 'gpt-4.1-mini',
		'input' => array(
			array('role' => 'system', 'content' => $system),
			array('role' => 'user', 'content' => $user),
		),
		// Paramètres prudents : on veut du factuel, pas de créativité.
		'temperature' => 0.2,
		'max_output_tokens' => 900,
		// Demande explicite de sortie JSON. Selon l'API, ce champ peut évoluer.
		// Si OpenAI change, gardez la validation JSON côté PHP comme filet de sécurité.
		'text' => array('format' => array('type' => 'json_object')),
	);

	$args = array(
		'headers' => array(
			'Authorization' => 'Bearer ' . $api_key,
			'Content-Type'  => 'application/json',
		),
		'body' => wp_json_encode($body),
		'timeout' => 20, // évitez 60s : en front, c'est mort. Ici on est en save_post, mais restons raisonnables.
	);

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

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

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

	if ($code < 200 || $code >= 300) {
		return new WP_Error('bpcab_openai_http_error', 'Erreur HTTP OpenAI: ' . $code, array('body' => $raw));
	}

	$data = json_decode($raw, true);
	if (!is_array($data)) {
		return new WP_Error('bpcab_openai_bad_json', 'Réponse OpenAI non JSON (impossible à décoder).', array('body' => $raw));
	}

	// Selon le format de Responses API, le texte peut être dans output[...].
	// On essaie d'extraire un bloc texte puis de décoder ce JSON.
	$json_text = '';

	// Extraction robuste (évite de dépendre d'un seul chemin).
	if (!empty($data['output']) && is_array($data['output'])) {
		foreach ($data['output'] as $item) {
			if (!is_array($item) || empty($item['content']) || !is_array($item['content'])) {
				continue;
			}
			foreach ($item['content'] as $content_item) {
				if (is_array($content_item) && ($content_item['type'] ?? '') === 'output_text' && isset($content_item['text'])) {
					$json_text .= $content_item['text'];
				}
			}
		}
	}

	$json_text = trim($json_text);
	if ($json_text === '') {
		// Fallback : parfois l'API peut renvoyer directement un champ text.
		if (isset($data['text']) && is_string($data['text'])) {
			$json_text = trim($data['text']);
		}
	}

	if ($json_text === '') {
		return new WP_Error('bpcab_openai_empty_output', 'Sortie OpenAI vide ou non trouvée.', array('body' => $raw));
	}

	$schema = json_decode($json_text, true);
	if (!is_array($schema)) {
		return new WP_Error('bpcab_schema_not_json', 'Le contenu généré n’est pas un JSON valide.', array('generated' => $json_text));
	}

	return $schema;
}

Étape 5 — validation + sanitation du JSON-LD

On ne “sanitize” pas un JSON comme du HTML. La bonne approche : valider la structure minimale, supprimer ce qui est dangereux (scripts), et encoder proprement au moment de l’affichage.

Piège fréquent : utiliser wp_kses_post() sur un JSON. Ça casse les guillemets et rend le JSON invalide. Ici, on valide en tableau, puis on wp_json_encode().

/**
 * Validation minimale du schéma.
 * On vérifie @context et @graph. On peut être plus strict selon vos besoins.
 */
function bpcab_ai_schema_validate(array $schema) {
	if (!isset($schema['@context']) || !is_string($schema['@context'])) {
		return new WP_Error('bpcab_schema_missing_context', 'Schema invalide: @context manquant.');
	}
	if (!isset($schema['@graph']) || !is_array($schema['@graph'])) {
		return new WP_Error('bpcab_schema_missing_graph', 'Schema invalide: @graph manquant.');
	}

	// Protection basique : on refuse toute tentative d'injection de balises.
	$encoded = wp_json_encode($schema);
	if ($encoded === false) {
		return new WP_Error('bpcab_schema_encode_failed', 'Impossible d’encoder le schéma en JSON.');
	}
	if (stripos($encoded, '<script') !== false || stripos($encoded, '</script') !== false) {
		return new WP_Error('bpcab_schema_script_detected', 'Contenu suspect détecté dans le schéma.');
	}

	return true;
}

/**
 * Nettoyage "pragmatique" : on limite certaines longueurs et on force des types.
 */
function bpcab_ai_schema_normalize(array $schema): array {
	// Limite de taille pour éviter un JSON-LD énorme (performance + crawl).
	$max_graph_items = 12;
	if (isset($schema['@graph']) && is_array($schema['@graph']) && count($schema['@graph']) > $max_graph_items) {
		$schema['@graph'] = array_slice($schema['@graph'], 0, $max_graph_items);
	}

	return $schema;
}

Étape 6 — cache transient + stockage post meta

On combine deux niveaux :

  • post meta (persistant) pour l’affichage front
  • transient (court) pour éviter de régénérer trop vite
/**
 * Clés de stockage.
 */
function bpcab_ai_schema_meta_key(): string {
	return '_bpcab_ai_schema_jsonld';
}
function bpcab_ai_schema_transient_key(int $post_id): string {
	return 'bpcab_ai_schema_lock_' . $post_id;
}

/**
 * Génère et stocke le schéma pour un post.
 */
function bpcab_ai_schema_generate_for_post(int $post_id) {
	$payload = bpcab_ai_schema_build_post_payload($post_id);
	if (empty($payload)) {
		return new WP_Error('bpcab_no_payload', 'Payload vide, post introuvable ?');
	}

	// Lock anti-boucle (ex: autosave + update en rafale).
	if (get_transient(bpcab_ai_schema_transient_key($post_id))) {
		return new WP_Error('bpcab_locked', 'Génération déjà en cours ou trop récente (lock transient).');
	}
	set_transient(bpcab_ai_schema_transient_key($post_id), 1, 2 * MINUTE_IN_SECONDS);

	$schema = bpcab_ai_schema_call_openai($payload);
	if (is_wp_error($schema)) {
		return $schema;
	}

	$valid = bpcab_ai_schema_validate($schema);
	if (is_wp_error($valid)) {
		return $valid;
	}

	$schema = bpcab_ai_schema_normalize($schema);

	// Stockage en post meta (tableau encodé JSON).
	$json = wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($json === false) {
		return new WP_Error('bpcab_encode_failed', 'Encodage JSON final impossible.');
	}

	update_post_meta($post_id, bpcab_ai_schema_meta_key(), $json);

	// On relâche le lock un peu plus tôt si tout s'est bien passé.
	delete_transient(bpcab_ai_schema_transient_key($post_id));

	return true;
}

Étape 7 — hook save_post (sans casser l’éditeur)

Le mauvais hook ou la mauvaise condition, et vous appelez l’IA sur les autosaves, les révisions, ou les previews Elementor. Je vois ça tout le temps.

/**
 * Déclenchement à la sauvegarde.
 */
function bpcab_ai_schema_on_save_post(int $post_id, WP_Post $post, bool $update): void {
	// Éviter autosave, révisions, et contexte non pertinent.
	if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
		return;
	}

	// Éviter l'exécution sur les types non autorisés.
	if (!in_array($post->post_type, bpcab_ai_schema_allowed_post_types(), true)) {
		return;
	}

	// Éviter les brouillons: souvent le contenu est incomplet.
	// Ajustez selon votre workflow.
	if ($post->post_status !== 'publish') {
		return;
	}

	// Option : ne régénérer que si le contenu/titre a changé.
	// Ici, on régénère à chaque update publié (simple et fiable).
	$result = bpcab_ai_schema_generate_for_post($post_id);

	// On log en debug uniquement.
	if (is_wp_error($result) && defined('WP_DEBUG') && WP_DEBUG) {
		error_log('[BPCAB AI Schema] save_post error: ' . $result->get_error_code() . ' - ' . $result->get_error_message());
	}
}
add_action('save_post', 'bpcab_ai_schema_on_save_post', 20, 3);

Étape 8 — injection du JSON-LD dans wp_head

On injecte uniquement sur le front, sur les single, et uniquement si le meta existe. Pas d’appel IA ici.

/**
 * Injecte le JSON-LD dans le head.
 */
function bpcab_ai_schema_print_jsonld(): void {
	if (is_admin()) {
		return;
	}
	if (!is_singular(bpcab_ai_schema_allowed_post_types())) {
		return;
	}

	$post_id = get_queried_object_id();
	if (!$post_id) {
		return;
	}

	$json = get_post_meta($post_id, bpcab_ai_schema_meta_key(), true);
	if (!is_string($json) || $json === '') {
		return;
	}

	// Vérification finale : JSON valide.
	$decoded = json_decode($json, true);
	if (!is_array($decoded)) {
		return;
	}

	// Encodage propre pour éviter les surprises.
	$out = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($out === false) {
		return;
	}

	echo "<script type="application/ld+json">n";
	echo $out;
	echo "n</script>n";
}
add_action('wp_head', 'bpcab_ai_schema_print_jsonld', 99);

Étape 9 — endpoint REST pour régénérer à la demande (admin-only)

Très pratique quand un rédacteur dit “le schéma n’apparaît pas” et que vous voulez régénérer sans re-sauver le post (et sans donner accès au code). On protège avec capacités + nonce.

/**
 * Enregistre une route REST pour régénérer le schéma.
 */
function bpcab_ai_schema_register_rest_route(): void {
	register_rest_route('bpcab/v1', '/schema/regenerate/(?P<id>d+)', array(
		'methods' => 'POST',
		'permission_callback' => function (WP_REST_Request $request) {
			// Nonce REST standard: X-WP-Nonce (wp_create_nonce('wp_rest')).
			if (!is_user_logged_in()) {
				return false;
			}
			return current_user_can('edit_posts');
		},
		'callback' => function (WP_REST_Request $request) {
			$post_id = (int) $request['id'];
			if ($post_id <= 0) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'ID invalide'), 400);
			}

			$post = get_post($post_id);
			if (!$post) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Post introuvable'), 404);
			}

			if (!current_user_can('edit_post', $post_id)) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Accès refusé'), 403);
			}

			$result = bpcab_ai_schema_generate_for_post($post_id);
			if (is_wp_error($result)) {
				return new WP_REST_Response(array(
					'ok' => false,
					'error' => $result->get_error_message(),
					'code' => $result->get_error_code(),
					'data' => $result->get_error_data(),
				), 500);
			}

			return new WP_REST_Response(array('ok' => true), 200);
		},
	));
}
add_action('rest_api_init', 'bpcab_ai_schema_register_rest_route');

Le code assemblé complet

Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/bpcab-ai-schema.php. Puis ajoutez la constante dans wp-config.php. Ne testez pas ça d’abord en production sans sauvegarde : une parenthèse oubliée et c’est le site en écran blanc.

<?php
/**
 * Plugin Name: BPCAB AI Schema (JSON-LD)
 * Description: Génère et injecte des données structurées Schema.org via IA par article.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

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

function bpcab_ai_schema_get_openai_key(): string {
	if (defined('BPCAB_AI_OPENAI_API_KEY') && is_string(BPCAB_AI_OPENAI_API_KEY) && BPCAB_AI_OPENAI_API_KEY !== '') {
		return BPCAB_AI_OPENAI_API_KEY;
	}
	return '';
}

function bpcab_ai_schema_allowed_post_types(): array {
	return array('post');
}

function bpcab_ai_schema_meta_key(): string {
	return '_bpcab_ai_schema_jsonld';
}

function bpcab_ai_schema_transient_key(int $post_id): string {
	return 'bpcab_ai_schema_lock_' . $post_id;
}

function bpcab_ai_schema_build_post_payload(int $post_id): array {
	$post = get_post($post_id);
	if (!$post) {
		return array();
	}

	$title   = get_the_title($post);
	$content = $post->post_content;

	$content_plain = wp_strip_all_tags($content);
	$content_plain = mb_substr($content_plain, 0, 12000);

	$excerpt = has_excerpt($post) ? $post->post_excerpt : wp_trim_words($content_plain, 55, '…');

	$author_id = (int) $post->post_author;
	$author_name = $author_id ? get_the_author_meta('display_name', $author_id) : '';

	$permalink = get_permalink($post);
	$published = get_the_date(DATE_W3C, $post);
	$modified  = get_the_modified_date(DATE_W3C, $post);

	$image_id = get_post_thumbnail_id($post);
	$image_url = '';
	if ($image_id) {
		$image = wp_get_attachment_image_src($image_id, 'full');
		if (is_array($image) && !empty($image[0])) {
			$image_url = $image[0];
		}
	}

	return array(
		'post_id'       => $post_id,
		'post_type'     => $post->post_type,
		'title'         => $title,
		'excerpt'       => $excerpt,
		'content'       => $content_plain,
		'permalink'     => $permalink,
		'datePublished' => $published,
		'dateModified'  => $modified,
		'authorName'    => $author_name,
		'image'         => $image_url,
		'language'      => get_bloginfo('language'),
	);
}

function bpcab_ai_schema_call_openai(array $payload) {
	$api_key = bpcab_ai_schema_get_openai_key();
	if ($api_key === '') {
		return new WP_Error('bpcab_no_api_key', 'Clé API OpenAI manquante (BPCAB_AI_OPENAI_API_KEY).');
	}

	$system = "Vous êtes un assistant spécialisé en SEO technique. Vous produisez uniquement du JSON strict, sans commentaire ni markdown.";
	$user = array(
		"Objectif: Générer un JSON-LD Schema.org pour un article WordPress.n"
		. "Contraintes:n"
		. "- Répondre uniquement avec un objet JSON valide.n"
		. "- Ne pas inventer d'URL, de dates, d'auteur.n"
		. "- Utiliser EXACTEMENT les valeurs fournies pour headline, url, datePublished, dateModified, author.name, image.n"
		. "- Ajouter des champs sémantiques utiles: keywords (array), about (array of Thing), mentions (array of Thing), articleSection (string), inLanguage.n"
		. "- Type recommandé: Article (ou TechArticle si le texte est technique).n"
		. "- Produire un JSON-LD avec @context et @graph.n"
		. "- Limiter keywords à 12 max. about/mentions: 8 max chacun.n"
		. "- Ne pas inclure Organization/WebSite si vous n'avez pas les données.nn"
		. "Données fiables (à utiliser telles quelles):n"
		. wp_json_encode(array(
			"headline" => $payload['title'] ?? '',
			"description" => $payload['excerpt'] ?? '',
			"url" => $payload['permalink'] ?? '',
			"datePublished" => $payload['datePublished'] ?? '',
			"dateModified" => $payload['dateModified'] ?? '',
			"authorName" => $payload['authorName'] ?? '',
			"image" => $payload['image'] ?? '',
			"inLanguage" => $payload['language'] ?? 'fr-FR',
		)) . "nn"
		. "Contenu (extrait):n"
		. ($payload['content'] ?? '')
	);

	$body = array(
		'model' => 'gpt-4.1-mini',
		'input' => array(
			array('role' => 'system', 'content' => $system),
			array('role' => 'user', 'content' => $user),
		),
		'temperature' => 0.2,
		'max_output_tokens' => 900,
		'text' => array('format' => array('type' => 'json_object')),
	);

	$args = array(
		'headers' => array(
			'Authorization' => 'Bearer ' . $api_key,
			'Content-Type'  => 'application/json',
		),
		'body' => wp_json_encode($body),
		'timeout' => 20,
	);

	$response = wp_remote_post('https://api.openai.com/v1/responses', $args);
	if (is_wp_error($response)) {
		return $response;
	}

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

	if ($code < 200 || $code >= 300) {
		return new WP_Error('bpcab_openai_http_error', 'Erreur HTTP OpenAI: ' . $code, array('body' => $raw));
	}

	$data = json_decode($raw, true);
	if (!is_array($data)) {
		return new WP_Error('bpcab_openai_bad_json', 'Réponse OpenAI non JSON (impossible à décoder).', array('body' => $raw));
	}

	$json_text = '';
	if (!empty($data['output']) && is_array($data['output'])) {
		foreach ($data['output'] as $item) {
			if (!is_array($item) || empty($item['content']) || !is_array($item['content'])) {
				continue;
			}
			foreach ($item['content'] as $content_item) {
				if (is_array($content_item) && ($content_item['type'] ?? '') === 'output_text' && isset($content_item['text'])) {
					$json_text .= $content_item['text'];
				}
			}
		}
	}
	$json_text = trim($json_text);
	if ($json_text === '' && isset($data['text']) && is_string($data['text'])) {
		$json_text = trim($data['text']);
	}

	if ($json_text === '') {
		return new WP_Error('bpcab_openai_empty_output', 'Sortie OpenAI vide ou non trouvée.', array('body' => $raw));
	}

	$schema = json_decode($json_text, true);
	if (!is_array($schema)) {
		return new WP_Error('bpcab_schema_not_json', 'Le contenu généré n’est pas un JSON valide.', array('generated' => $json_text));
	}

	return $schema;
}

function bpcab_ai_schema_validate(array $schema) {
	if (!isset($schema['@context']) || !is_string($schema['@context'])) {
		return new WP_Error('bpcab_schema_missing_context', 'Schema invalide: @context manquant.');
	}
	if (!isset($schema['@graph']) || !is_array($schema['@graph'])) {
		return new WP_Error('bpcab_schema_missing_graph', 'Schema invalide: @graph manquant.');
	}

	$encoded = wp_json_encode($schema);
	if ($encoded === false) {
		return new WP_Error('bpcab_schema_encode_failed', 'Impossible d’encoder le schéma en JSON.');
	}
	if (stripos($encoded, '<script') !== false || stripos($encoded, '</script') !== false) {
		return new WP_Error('bpcab_schema_script_detected', 'Contenu suspect détecté dans le schéma.');
	}

	return true;
}

function bpcab_ai_schema_normalize(array $schema): array {
	$max_graph_items = 12;
	if (isset($schema['@graph']) && is_array($schema['@graph']) && count($schema['@graph']) > $max_graph_items) {
		$schema['@graph'] = array_slice($schema['@graph'], 0, $max_graph_items);
	}
	return $schema;
}

function bpcab_ai_schema_generate_for_post(int $post_id) {
	$payload = bpcab_ai_schema_build_post_payload($post_id);
	if (empty($payload)) {
		return new WP_Error('bpcab_no_payload', 'Payload vide, post introuvable ?');
	}

	if (get_transient(bpcab_ai_schema_transient_key($post_id))) {
		return new WP_Error('bpcab_locked', 'Génération déjà en cours ou trop récente (lock transient).');
	}
	set_transient(bpcab_ai_schema_transient_key($post_id), 1, 2 * MINUTE_IN_SECONDS);

	$schema = bpcab_ai_schema_call_openai($payload);
	if (is_wp_error($schema)) {
		return $schema;
	}

	$valid = bpcab_ai_schema_validate($schema);
	if (is_wp_error($valid)) {
		return $valid;
	}

	$schema = bpcab_ai_schema_normalize($schema);

	$json = wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($json === false) {
		return new WP_Error('bpcab_encode_failed', 'Encodage JSON final impossible.');
	}

	update_post_meta($post_id, bpcab_ai_schema_meta_key(), $json);
	delete_transient(bpcab_ai_schema_transient_key($post_id));

	return true;
}

function bpcab_ai_schema_on_save_post(int $post_id, WP_Post $post, bool $update): void {
	if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
		return;
	}
	if (!in_array($post->post_type, bpcab_ai_schema_allowed_post_types(), true)) {
		return;
	}
	if ($post->post_status !== 'publish') {
		return;
	}

	$result = bpcab_ai_schema_generate_for_post($post_id);
	if (is_wp_error($result) && defined('WP_DEBUG') && WP_DEBUG) {
		error_log('[BPCAB AI Schema] save_post error: ' . $result->get_error_code() . ' - ' . $result->get_error_message());
	}
}
add_action('save_post', 'bpcab_ai_schema_on_save_post', 20, 3);

function bpcab_ai_schema_print_jsonld(): void {
	if (is_admin()) {
		return;
	}
	if (!is_singular(bpcab_ai_schema_allowed_post_types())) {
		return;
	}

	$post_id = get_queried_object_id();
	if (!$post_id) {
		return;
	}

	$json = get_post_meta($post_id, bpcab_ai_schema_meta_key(), true);
	if (!is_string($json) || $json === '') {
		return;
	}

	$decoded = json_decode($json, true);
	if (!is_array($decoded)) {
		return;
	}

	$out = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($out === false) {
		return;
	}

	echo "<script type="application/ld+json">n";
	echo $out;
	echo "n</script>n";
}
add_action('wp_head', 'bpcab_ai_schema_print_jsonld', 99);

function bpcab_ai_schema_register_rest_route(): void {
	register_rest_route('bpcab/v1', '/schema/regenerate/(?P<id>d+)', array(
		'methods' => 'POST',
		'permission_callback' => function (WP_REST_Request $request) {
			if (!is_user_logged_in()) {
				return false;
			}
			return current_user_can('edit_posts');
		},
		'callback' => function (WP_REST_Request $request) {
			$post_id = (int) $request['id'];
			if ($post_id <= 0) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'ID invalide'), 400);
			}

			$post = get_post($post_id);
			if (!$post) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Post introuvable'), 404);
			}

			if (!current_user_can('edit_post', $post_id)) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Accès refusé'), 403);
			}

			$result = bpcab_ai_schema_generate_for_post($post_id);
			if (is_wp_error($result)) {
				return new WP_REST_Response(array(
					'ok' => false,
					'error' => $result->get_error_message(),
					'code' => $result->get_error_code(),
					'data' => $result->get_error_data(),
				), 500);
			}

			return new WP_REST_Response(array('ok' => true), 200);
		},
	));
}
add_action('rest_api_init', 'bpcab_ai_schema_register_rest_route');

Explication du code

Pourquoi stocker en post meta

Le post meta vous donne un état stable. Si l’API IA tombe, votre JSON-LD reste servi. Et si vous avez un cache page (Varnish, plugin cache), vous évitez des variations.

Pourquoi un transient “lock” en plus

Sur des sites avec Elementor ou Divi, une action “Mettre à jour” peut déclencher plusieurs sauvegardes (autosave, révision, update). Même si on filtre autosave/révision, j’ai déjà vu des double-calls via des plugins qui “resauvent” le post. Le transient évite de payer deux fois.

Pourquoi la validation est volontairement minimale

Schema.org est vaste. Si vous validez trop strict, vous cassez des améliorations utiles (ex: about en Thing vs DefinedTerm). Ici, on vérifie juste les invariants (@context, @graph) et on refuse les contenus suspects.

Pourquoi on n’utilise pas wp_kses_post() sur le JSON

wp_kses_post() est un filtre HTML. Appliqué à du JSON, il casse des caractères et rend le JSON invalide. À la place, on garde un tableau PHP, on contrôle la structure, puis on encode avec wp_json_encode().

Erreurs réalistes que je vois souvent

  • Code collé dans functions.php d’un thème parent : mise à jour du thème = code perdu. Utilisez mu-plugin.
  • Oubli du point-virgule dans wp-config.php après define() → fatal error immédiate.
  • Hook inadapté (ex: the_content) → appel IA au rendu → latence + coûts.
  • Test en production sans limiter le post type → vous régénérez 2000 posts d’un coup via une boucle de sauvegarde.

Coûts API et optimisation

Le coût dépend du modèle et de la taille du contenu envoyé. Avec une limite à 12 000 caractères de texte (ce qui représente souvent 2000–3000 mots sans HTML), vous êtes sur une requête modérée.

Estimation réaliste (ordre de grandeur)

  • 1 article = 1 appel IA à la publication + 1 appel par mise à jour significative.
  • Si vous publiez 30 articles/mois et mettez à jour chacun 2 fois en moyenne : ~90 appels/mois.

Pour le pricing exact, référez-vous aux pages officielles (ça change). OpenAI : Pricing OpenAI.

Optimisations qui marchent vraiment

  • Réduire l’input : envoyez l’extrait + les titres H2/H3 plutôt que tout le contenu (si votre contenu est très long).
  • Modèle “mini” : largement suffisant pour extraire keywords/about/mentions.
  • Régénération conditionnelle : comparez un hash du contenu (post meta) et ne régénérez que si le hash change.
  • Batch offline (WP-CLI) pour les migrations au lieu de save_post en masse.

Variantes et cas d’usage avancés

Variante 1 — ne régénérer que si le contenu a changé (hash)

Pour éviter de payer quand quelqu’un corrige une virgule dans le titre, stockez un hash.

function bpcab_ai_schema_hash_meta_key(): string {
	return '_bpcab_ai_schema_content_hash';
}

function bpcab_ai_schema_should_regenerate(int $post_id, array $payload): bool {
	$hash = hash('sha256', ($payload['title'] ?? '') . '|' . ($payload['excerpt'] ?? '') . '|' . ($payload['content'] ?? ''));
	$old  = get_post_meta($post_id, bpcab_ai_schema_hash_meta_key(), true);

	if (!is_string($old) || $old === '') {
		update_post_meta($post_id, bpcab_ai_schema_hash_meta_key(), $hash);
		return true;
	}

	if (!hash_equals($old, $hash)) {
		update_post_meta($post_id, bpcab_ai_schema_hash_meta_key(), $hash);
		return true;
	}

	return false;
}

Variante 2 — compatibilité Divi 5 / Elementor / Avada

Ces builders stockent souvent le contenu dans post_content avec des shortcodes/JSON internes. Si vous envoyez ça tel quel à l’IA, elle peut extraire des artefacts.

  • Divi 5 : vous verrez parfois des structures internes. Le wp_strip_all_tags() aide, mais pas toujours.
  • Elementor : une partie du contenu est dans des meta (données Elementor). Le rendu final est plus fidèle que le brut.
  • Avada : shortcodes Fusion Builder, même problème.

Deux approches :

  • Approche “safe” (recommandée) : extraire uniquement le texte visible via the_content filtré, puis strip tags.
  • Approche “rapide” : garder post_content et accepter du bruit.

Version “safe” (attention à ne pas faire ça en boucle sur des listes, gardez-le pour save_post) :

function bpcab_ai_schema_get_rendered_text(WP_Post $post): string {
	// Applique les filtres (shortcodes, blocs, builders) pour obtenir un HTML proche du front.
	$html = apply_filters('the_content', $post->post_content);

	// Supprime scripts/styles éventuels.
	$html = preg_replace('#<scriptb[^>]*>.*?</script>#is', '', $html ?? '');
	$html = preg_replace('#<styleb[^>]*>.*?</style>#is', '', $html ?? '');

	$text = wp_strip_all_tags($html);
	return mb_substr($text, 0, 12000);
}

Variante 3 — ajouter FAQPage si l’article contient une section FAQ

Si vos articles finissent souvent par “FAQ”, l’IA peut détecter des paires Q/R. Mais soyez strict : une FAQ inventée, c’est un risque SEO et éditorial. Je recommande de ne générer une FAQ que si le contenu contient déjà des questions explicites.

Vous pouvez ajouter une contrainte au prompt : “n’extraire que des questions déjà présentes mot pour mot”.

Sécurité et bonnes pratiques

Ne jamais exposer la clé côté client

Évitez absolument un appel JavaScript depuis le navigateur vers l’API IA. La clé fuitera (DevTools, source, logs). Ici, tout passe par PHP via wp_remote_post().

Rate limiting

Le transient “lock” est un début. Si vous avez un site multi-auteurs, ajoutez un rate limit par user (ex: un transient par user ID) sur l’endpoint REST.

Validation des entrées

Ne laissez pas un utilisateur injecter du texte arbitraire envoyé à l’IA via un paramètre REST non contrôlé. Ici, l’endpoint prend un post_id et reconstruit le payload depuis WordPress.

RGPD / confidentialité

  • N’envoyez pas de données personnelles inutiles (emails, IP, champs privés).
  • Évitez d’envoyer les commentaires, ou des formulaires, sans base légale claire.
  • Documentez le sous-traitant (OpenAI/Anthropic/etc.) dans votre registre et votre politique de confidentialité si nécessaire.

Compatibilité cache

Si vous utilisez un cache de page agressif, le JSON-LD injecté via wp_head sera mis en cache comme le reste. C’est souhaité. Le piège, c’est de régénérer le meta et d’oublier de purger le cache (plugin cache/CDN). Dans ce cas, vous verrez l’ancien schéma pendant des heures.

Comment tester et déboguer

1) Test local d’abord

Activez WP_DEBUG et WP_DEBUG_LOG dans wp-config.php. Référence : Debugging in WordPress.

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

2) Vérifier que le JSON-LD sort bien

  • Ouvrez une page article.
  • Affichez le code source.
  • Cherchez application/ld+json.

3) Tester l’endpoint REST (régénération)

Depuis votre navigateur (admin connecté), vous pouvez appeler via fetch dans la console, ou via curl avec le nonce. Exemple curl (si vous récupérez le nonce dans l’admin) :

curl -X POST "https://example.com/wp-json/bpcab/v1/schema/regenerate/123" 
  -H "X-WP-Nonce: VOTRE_NONCE" 
  -H "Content-Type: application/json"

4) Valider le JSON-LD

Utilisez le validateur Schema.org ou les outils de test de résultats enrichis. Je ne vous mets pas un lien “SEO blog”, mais des références structurées :

Si ça ne marche pas

Quand ça casse, c’est presque toujours l’un de ces points : clé, quota, JSON invalide, ou hook déclenché trop souvent.

Symptôme Cause probable Vérification Solution
Pas de script JSON-LD dans le source Meta vide (génération jamais faite) ou post_status ≠ publish Regardez la meta _bpcab_ai_schema_jsonld (via un plugin de debug) Publiez l’article, puis régénérez via l’endpoint REST
Logs: HTTP 401 / 403 Clé API absente/incorrecte WP_DEBUG_LOG, code d’erreur dans debug.log Corrigez BPCAB_AI_OPENAI_API_KEY dans wp-config.php
Logs: timeout API lente / hébergeur bloque les requêtes sortantes Testez un wp_remote_get() vers un site public Augmentez légèrement timeout, vérifiez firewall, autorisez api.openai.com
Erreur “Le contenu généré n’est pas un JSON valide” L’IA a renvoyé du texte autour du JSON Inspectez generated dans l’erreur (debug) Rendez le prompt plus strict, gardez text.format, réduisez temperature
Deux schémas Article dans la page Plugin SEO injecte déjà Article Source HTML: cherchez plusieurs "@type":"Article" Changez votre schéma en “fragment” ou désactivez la sortie Article du plugin SEO si possible

Pièges spécifiques WordPress

  • Snippet cassé par un plugin de snippets : certains plugins modifient l’ordre de chargement. En mu-plugin, vous réduisez ce risque.
  • Version PHP trop ancienne : si le site tourne encore en PHP 7.x, vous aurez des erreurs de typage. Visez PHP 8.1+.
  • Priorité de hook : si un autre plugin modifie le contenu après votre save_post, votre schéma peut être “en retard”. Ajustez la priorité (20 → 30) ou régénérez via endpoint.

Ressources

FAQ

Est-ce que Google “récompense” automatiquement les données structurées générées par IA ?

Non. Le balisage aide à l’interprétation, mais si le contenu n’est pas cohérent, vous n’aurez pas de bénéfice. Le vrai gain, c’est la cohérence et la précision à grande échelle.

Est-ce risqué d’injecter du JSON-LD produit par un modèle ?

Oui si vous laissez l’IA inventer. C’est pour ça que le code force l’utilisation de valeurs WordPress pour les champs critiques et valide la structure avant injection.

Puis-je utiliser Anthropic ou Mistral à la place ?

Oui. Gardez la même architecture : prompt strict JSON, wp_remote_post(), validation json_decode(). Seul le format de requête/réponse change.

Pourquoi ne pas générer un schéma complet Organization/WebSite/BreadcrumbList ?

Parce que ces éléments sont souvent déjà gérés par un plugin SEO et parce qu’ils sont “site-wide” (pas par article). Mélanger deux sources crée des incohérences.

Comment éviter les doublons avec Yoast/Rank Math/SEOPress ?

Deux options réalistes :

  • générer un schéma qui ne duplique pas Article (par ex. un DefinedTermSet ou des Thing liés)
  • désactiver la sortie Schema du plugin SEO (si l’option existe) et laisser votre plugin gérer l’Article

Est-ce que je peux générer le schéma pour les pages (post type page) ?

Oui, mais ne forcez pas “Article”. Sur des pages, vous pouvez demander à l’IA de choisir entre WebPage, AboutPage, ContactPage. Ajoutez page dans bpcab_ai_schema_allowed_post_types() et adaptez le prompt.

Pourquoi mon schéma n’apparaît pas sur une page Elementor ?

Souvent parce que vous testez une preview ou un template, pas un is_singular() classique. Testez l’URL publique finale, puis vérifiez le source. Si le contenu est “vide” côté post_content, utilisez la variante “rendered text” basée sur apply_filters('the_content', ...).

Est-ce que je peux afficher le schéma dans l’admin pour vérification ?

Oui. Ajoutez une metabox en lecture seule qui affiche le JSON. Évitez de rendre ce champ éditable, sinon vous perdez le contrôle de la validation.

Que faire si l’API renvoie parfois un JSON invalide ?

Réduisez temperature, renforcez le prompt (“répondre uniquement avec un objet JSON”), et gardez la validation côté PHP. En production, je préfère “pas de schéma” plutôt qu’un schéma cassé.

Comment migrer un ancien site avec 2000 articles ?

Ne déclenchez pas 2000 sauvegardes. Ajoutez une commande WP-CLI ou un script batch qui traite par lots (50 posts), avec pause, et qui respecte le rate limit. Si vous le souhaitez, je peux vous fournir une version WP-CLI basée sur WP_CLI::add_command() adaptée à ce plugin.