Si vous avez déjà publié un article solide… puis réalisé trois mois plus tard qu’il n’a aucun lien interne entrant, vous connaissez le vrai problème : ce n’est pas l’écriture, c’est la circulation. L’IA est utile ici non pas pour “faire du SEO”, mais pour proposer rapidement des liens internes pertinents, avec des ancres propres, sans passer 45 minutes à fouiller votre propre site.

Le besoin / Le cas d’usage

Le maillage interne “à la main” fonctionne, mais il casse à l’échelle. Dès que vous dépassez 50–100 articles, vous perdez la mémoire de ce que vous avez déjà publié, et vous finissez par linker toujours les mêmes pages (catégorie “piliers”) au lieu de faire circuler le PageRank et les visiteurs vers les contenus longue traîne.

Ce que l’IA résout bien : proposer une shortlist de contenus internes qui “matchent” le sujet de l’article en cours, avec une ancre suggérée et une justification. Ce que l’IA ne doit pas faire : injecter automatiquement des liens en masse sans contrôle éditorial.

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

  • analyse le contenu d’un article (titre + extrait + premiers paragraphes),
  • récupère une liste de candidats internes via WP_Query,
  • demande à une API IA (OpenAI, via wp_remote_post()) de sélectionner les meilleurs liens,
  • met en cache la réponse (Transients API),
  • affiche des suggestions dans la sidebar de l’éditeur (metabox) et expose un endpoint REST pour l’automatisation.

Résumé rapide

  • Vous générez des suggestions de liens internes “éditoriales” (pas un auto-linker agressif).
  • La clé API est stockée dans wp-config.php (jamais en dur, jamais côté navigateur).
  • L’appel IA passe par wp_remote_post() avec timeout, retries légers, et gestion d’erreurs.
  • Les réponses sont cachées avec set_transient() et invalidées quand l’article change.
  • Vous nettoyez la sortie (sanitization) avant affichage dans wp-admin.
  • Une route REST sécurisée (capability + nonce) permet d’intégrer Divi 5 / Elementor / Avada via action “bouton” ou module.

Quand utiliser l’IA pour ça

Dans mon expérience, l’IA est rentable sur trois scénarios précis :

  • Sites éditoriaux (blog, magazine, niche) avec beaucoup d’archives : l’IA retrouve des contenus oubliés que vous ne pensez plus à linker.
  • Sites multi-auteurs : chaque auteur a sa “bulle” de contenus. L’IA aide à croiser les silos.
  • Refonte / migration : quand vous réécrivez des pages et que les anciens liens internes ne sont plus cohérents, l’IA propose une base de remaillage rapide.

Vous obtiendrez de meilleurs résultats si vous avez :

  • des titres d’articles explicites,
  • des extraits (excerpts) renseignés,
  • des catégories/tags relativement propres (pas 80 tags “one-shot”).

Quand ne PAS utiliser l’IA

Il y a des cas où une solution classique (sans IA) est plus simple, plus fiable, et moins chère.

  • Vous voulez juste “les 5 derniers articles de la même catégorie”. Faites un WP_Query et stop. L’IA n’apporte rien.
  • Votre site a moins de 30 articles. Le maillage interne manuel est plus rapide et vous gardez la main.
  • Vous devez respecter une contrainte stricte RGPD / confidentialité. Si le contenu envoyé à l’API contient des données sensibles, évitez l’IA externe ou utilisez un modèle on-premise (hors scope ici).
  • Vous cherchez à auto-injecter des liens dans le contenu. J’ai souvent vu ce type d’automatisation dégrader l’UX (ancres absurdes, sur-optimisation, duplication). Restez sur des suggestions validées par un humain.

Prérequis

En April 2026, je pars sur :

  • WordPress 6.9.4 (ou supérieur)
  • PHP 8.1+
  • Extension PHP curl recommandée (WordPress peut fonctionner sans, mais les appels HTTP sont plus stables avec)
  • Une clé API OpenAI (ou autre fournisseur, je donne une variante plus bas)

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

Ajoutez ceci dans wp-config.php (ou via variables d’environnement si vous déployez en CI/CD). Ne mettez pas la clé dans un plugin, ni dans un snippet, ni dans un champ ACF.

/**
 * Clé API OpenAI (ne jamais commiter cette valeur).
 * Vous pouvez aussi utiliser une variable d'environnement et lire getenv().
 */
define('BPCAB_OPENAI_API_KEY', 'sk-proj-xxxxxxxxxxxxxxxxxxxxxxxx');

Référence utile : la structure de wp-config.php et les constantes côté WP Core sur developer.wordpress.org.

Sources officielles à garder sous la main

Architecture de la solution

Flux (schéma textuel) :

WP Admin (édition d’article) → récupère contexte (titre + extrait + contenu tronqué) → WP_Query (candidats internes) → wp_remote_post() vers l’API IA → parse JSON → sanitize → cache transient → affiche suggestions (metabox) + endpoint REST optionnel

Pourquoi cette architecture marche bien

  • Vous limitez le coût IA en pré-filtrant les candidats côté WordPress (ex: 30–60 articles max).
  • Vous évitez d’envoyer tout votre site à l’IA : vous envoyez un échantillon contrôlé.
  • Vous cachez par post + “hash du contenu” : si l’article ne change pas, vous ne repayez pas.
  • Vous gardez l’IA côté serveur : aucune clé exposée, pas de JS qui appelle l’API directement.

Le code complet — étape par étape

Je vous propose une implémentation sous forme de mu-plugin (recommandé pour éviter qu’un client désactive le plugin “par erreur”). Vous pouvez aussi en faire un plugin classique.

Étape 1 — créer le fichier mu-plugin

Créez wp-content/mu-plugins/bpcab-ai-internal-links.php. Si le dossier mu-plugins n’existe pas, créez-le.

Étape 2 — utilitaires (hash, cache, helpers)

Le piège classique ici : cacher “par ID de post” uniquement. Vous modifiez l’article, mais vous gardez des suggestions basées sur l’ancienne version. Je préfère inclure un hash du contenu.

<?php
/**
 * Plugin Name: BPCAB - Suggestions de maillage interne (IA)
 * Description: Suggère des liens internes pertinents via IA, avec cache et endpoint REST.
 * Author: BPCAB
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

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

final class BPCAB_AI_Internal_Links {
	const TRANSIENT_TTL = 7 * DAY_IN_SECONDS;
	const MAX_CANDIDATES = 50;

	public static function init(): void {
		add_action('add_meta_boxes', [__CLASS__, 'register_metabox']);
		add_action('admin_enqueue_scripts', [__CLASS__, 'admin_assets']);
		add_action('wp_ajax_bpcab_ai_links_refresh', [__CLASS__, 'ajax_refresh']);
		add_action('rest_api_init', [__CLASS__, 'register_rest_routes']);

		// Invalidation simple : quand le post est sauvegardé, on supprime le transient correspondant.
		add_action('save_post', [__CLASS__, 'invalidate_cache_on_save'], 10, 2);
	}

	private static function is_supported_post_type(string $post_type): bool {
		// Ajustez si vous voulez supporter des CPT.
		return in_array($post_type, ['post', 'page'], true);
	}

	private static function get_content_fingerprint(WP_Post $post): string {
		// On évite de hasher tout le contenu si énorme : on prend un échantillon stable.
		$title = (string) get_the_title($post);
		$excerpt = (string) get_the_excerpt($post);
		$content = (string) $post->post_content;

		$sample = $title . "n" . $excerpt . "n" . wp_strip_all_tags(mb_substr($content, 0, 4000));
		return hash('sha256', $sample);
	}

	private static function transient_key(int $post_id, string $fingerprint): string {
		// Les transients ont une limite de longueur de clé, on compacte.
		return 'bpcab_ai_links_' . $post_id . '_' . substr($fingerprint, 0, 12);
	}

	public static function invalidate_cache_on_save(int $post_id, WP_Post $post): void {
		if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
			return;
		}
		if (!self::is_supported_post_type($post->post_type)) {
			return;
		}

		// On ne connaît pas le fingerprint précédent : on nettoie "large" via option_name LIKE est coûteux.
		// Stratégie pragmatique : stocker la dernière clé dans un post_meta.
		delete_post_meta($post_id, '_bpcab_ai_links_last_transient');
	}

Étape 3 — récupérer des candidats internes (WP_Query)

Le but : donner à l’IA une liste de candidats raisonnable (titre + URL + extrait). Si vous envoyez 500 posts, vous payez cher et vous augmentez le risque de réponses “floues”.

	private static function get_internal_link_candidates(WP_Post $post): array {
		$args = [
			'post_type'           => $post->post_type,
			'post_status'         => 'publish',
			'posts_per_page'      => self::MAX_CANDIDATES,
			'post__not_in'        => [$post->ID],
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
			'orderby'             => 'date',
			'order'               => 'DESC',
		];

		// Bonus simple : si le post a des catégories, on filtre (souvent plus pertinent).
		$cats = wp_get_post_categories($post->ID);
		if (!empty($cats) && $post->post_type === 'post') {
			$args['category__in'] = array_slice($cats, 0, 3);
		}

		$q = new WP_Query($args);

		$candidates = [];
		foreach ($q->posts as $p) {
			/** @var WP_Post $p */
			$candidates[] = [
				'id'      => (int) $p->ID,
				'title'   => html_entity_decode(get_the_title($p), ENT_QUOTES, get_bloginfo('charset')),
				'url'     => get_permalink($p),
				'excerpt' => wp_strip_all_tags(get_the_excerpt($p)),
			];
		}

		return $candidates;
	}

Étape 4 — construire le prompt et appeler l’API (wp_remote_post)

Je passe ici par l’API “Responses” d’OpenAI (format JSON), parce qu’elle est adaptée aux sorties structurées. Si votre fournisseur change, la structure est facile à adapter tant que vous renvoyez du JSON propre.

Référence OpenAI : API reference. Côté WP, la base reste wp_remote_post().

	private static function openai_request(array $payload): array {
		if (!defined('BPCAB_OPENAI_API_KEY') || !is_string(BPCAB_OPENAI_API_KEY) || BPCAB_OPENAI_API_KEY === '') {
			return [
				'ok'    => false,
				'error' => 'Clé API manquante (BPCAB_OPENAI_API_KEY).',
			];
		}

		$resp = wp_remote_post(
			'https://api.openai.com/v1/responses',
			[
				'timeout' => 25,
				'headers' => [
					'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
					'Content-Type'  => 'application/json',
				],
				'body'    => wp_json_encode($payload),
			]
		);

		if (is_wp_error($resp)) {
			return [
				'ok'    => false,
				'error' => $resp->get_error_message(),
			];
		}

		$code = (int) wp_remote_retrieve_response_code($resp);
		$body = (string) wp_remote_retrieve_body($resp);

		if ($code < 200 || $code >= 300) {
			return [
				'ok'    => false,
				'error' => 'HTTP ' . $code . ' : ' . substr($body, 0, 300),
			];
		}

		$data = json_decode($body, true);
		if (!is_array($data)) {
			return [
				'ok'    => false,
				'error' => 'Réponse JSON invalide (impossible à parser).',
			];
		}

		return [
			'ok'   => true,
			'data' => $data,
		];
	}

	private static function build_prompt(WP_Post $post, array $candidates): array {
		$title = (string) get_the_title($post);
		$excerpt = (string) get_the_excerpt($post);
		$content = (string) $post->post_content;

		$context = [
			'title'   => $title,
			'excerpt' => $excerpt,
			'content' => wp_strip_all_tags(mb_substr($content, 0, 4500)),
		];

		return [
			'role'    => 'user',
			'content' => [
				[
					'type' => 'input_text',
					'text' => "Vous êtes un assistant SEO/editorial. Objectif: proposer des liens internes pertinents.n"
						. "Contraintes:n"
						. "- Répondez UNIQUEMENT en JSON valide.n"
						. "- Proposez 5 suggestions maximum.n"
						. "- Chaque suggestion: {"url":"...","title":"...","anchor":"...","reason":"..."}n"
						. "- L'ancre doit être naturelle (2 à 6 mots), pas sur-optimisée.n"
						. "- Ne proposez que des URLs présentes dans la liste de candidats.nn"
						. "Article en cours (contexte):n" . wp_json_encode($context) . "nn"
						. "Candidats internes:n" . wp_json_encode($candidates),
				],
			],
		];
	}

Étape 5 — extraire une sortie JSON propre et la sanitizer

Erreur réaliste : afficher tel quel ce que renvoie l’IA. Même si vous faites confiance au fournisseur, vous devez traiter ça comme une entrée externe. Je nettoie title, anchor en texte, et reason en HTML limité (ou texte si vous préférez).

	private static function extract_json_suggestions(array $openai_data): array {
		// L'API peut renvoyer du texte dans différents champs selon le format.
		// On cherche un bloc texte exploitable, puis on tente json_decode dessus.
		$text = '';

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

		$text = trim($text);
		if ($text === '') {
			return [];
		}

		// Certains modèles entourent le JSON de backticks : on retire.
		$text = preg_replace('/^```(json)?/i', '', $text);
		$text = preg_replace('/```$/', '', $text);
		$text = trim((string) $text);

		$decoded = json_decode($text, true);
		if (!is_array($decoded)) {
			return [];
		}

		// On accepte soit une liste directe, soit un objet { suggestions: [...] }
		$suggestions = $decoded;
		if (isset($decoded['suggestions']) && is_array($decoded['suggestions'])) {
			$suggestions = $decoded['suggestions'];
		}

		if (!is_array($suggestions)) {
			return [];
		}

		$clean = [];
		$allowed_reason = [
			'em' => [],
			'strong' => [],
			'br' => [],
			'a' => [
				'href' => true,
				'target' => true,
				'rel' => true,
			],
		];

		foreach (array_slice($suggestions, 0, 5) as $s) {
			if (!is_array($s)) {
				continue;
			}
			$url = isset($s['url']) ? esc_url_raw((string) $s['url']) : '';
			$title = isset($s['title']) ? sanitize_text_field((string) $s['title']) : '';
			$anchor = isset($s['anchor']) ? sanitize_text_field((string) $s['anchor']) : '';
			$reason = isset($s['reason']) ? wp_kses((string) $s['reason'], $allowed_reason) : '';

			if ($url === '' || $title === '' || $anchor === '') {
				continue;
			}

			$clean[] = [
				'url'    => $url,
				'title'  => $title,
				'anchor' => $anchor,
				'reason' => $reason,
			];
		}

		return $clean;
	}

Étape 6 — orchestrer : cache transient + fallback

Je fais un cache par post + fingerprint, et je stocke la clé transient dans un post_meta. Ça évite de faire des requêtes SQL “LIKE” coûteuses pour nettoyer.

	private static function get_suggestions(WP_Post $post, bool $force_refresh = false): array {
		$fingerprint = self::get_content_fingerprint($post);
		$key = self::transient_key($post->ID, $fingerprint);

		if (!$force_refresh) {
			$cached = get_transient($key);
			if (is_array($cached)) {
				return $cached;
			}
		}

		$candidates = self::get_internal_link_candidates($post);
		if (count($candidates) < 5) {
			// Fallback simple : pas assez de matière, on évite l'appel IA.
			return [];
		}

		$payload = [
			'model' => 'gpt-4.1-mini',
			'input' => [
				self::build_prompt($post, $candidates),
			],
			'temperature' => 0.2,
		];

		$res = self::openai_request($payload);
		if (!$res['ok']) {
			// On ne cache pas une erreur : sinon vous restez bloqué jusqu'au TTL.
			return [
				'_error' => sanitize_text_field((string) $res['error']),
			];
		}

		$suggestions = self::extract_json_suggestions($res['data']);
		if (empty($suggestions)) {
			return [
				'_error' => 'Aucune suggestion exploitable (réponse IA vide ou non-JSON).',
			];
		}

		set_transient($key, $suggestions, self::TRANSIENT_TTL);
		update_post_meta($post->ID, '_bpcab_ai_links_last_transient', $key);

		return $suggestions;
	}

Étape 7 — Metabox wp-admin + bouton “Rafraîchir” (AJAX sécurisé)

Le piège que je vois le plus : coder l’AJAX sans nonce, ou vérifier uniquement is_admin(). Ici, on vérifie capability + nonce + post ID.

	public static function register_metabox(): void {
		$screen = get_current_screen();
		if (!$screen) {
			return;
		}
		if (!self::is_supported_post_type($screen->post_type)) {
			return;
		}

		add_meta_box(
			'bpcab_ai_internal_links',
			'Suggestions de liens internes (IA)',
			[__CLASS__, 'render_metabox'],
			$screen->post_type,
			'side',
			'high'
		);
	}

	public static function admin_assets(string $hook): void {
		// On charge seulement sur l'écran d'édition.
		if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
			return;
		}

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

		wp_localize_script('bpcab-ai-links-admin', 'BPCAB_AI_LINKS', [
			'ajaxUrl' => admin_url('admin-ajax.php'),
			'nonce'   => wp_create_nonce('bpcab_ai_links'),
		]);
	}

	public static function render_metabox(WP_Post $post): void {
		if (!current_user_can('edit_post', $post->ID)) {
			echo '<p>Permissions insuffisantes.</p>';
			return;
		}

		$suggestions = self::get_suggestions($post, false);

		echo '<div id="bpcab-ai-links-box" data-post-id="' . esc_attr((string) $post->ID) . '">';

		echo '<p><button type="button" class="button" id="bpcab-ai-links-refresh">Rafraîchir</button></p>';

		if (isset($suggestions['_error'])) {
			echo '<p><strong>Erreur :</strong> ' . esc_html((string) $suggestions['_error']) . '</p>';
			echo '</div>';
			return;
		}

		if (empty($suggestions)) {
			echo '<p>Aucune suggestion pour le moment.</p>';
			echo '</div>';
			return;
		}

		echo '<ol>';
		foreach ($suggestions as $s) {
			$url = esc_url((string) $s['url']);
			$title = esc_html((string) $s['title']);
			$anchor = esc_html((string) $s['anchor']);
			$reason = (string) $s['reason'];

			echo '<li>';
			echo '<p><a href="' . $url . '" target="_blank" rel="noopener">' . $title . '</a><br>';
			echo '<strong>Ancre suggérée :</strong> <code>' . $anchor . '</code></p>';
			if ($reason !== '') {
				echo '<p><em>' . $reason . '</em></p>';
			}
			echo '</li>';
		}
		echo '</ol>';
		echo '</div>';
	}

	public static function ajax_refresh(): void {
		check_ajax_referer('bpcab_ai_links', 'nonce');

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

		if (!current_user_can('edit_post', $post_id)) {
			wp_send_json_error(['message' => 'Permissions insuffisantes.'], 403);
		}

		$post = get_post($post_id);
		if (!$post instanceof WP_Post) {
			wp_send_json_error(['message' => 'Post introuvable.'], 404);
		}

		$suggestions = self::get_suggestions($post, true);

		wp_send_json_success([
			'suggestions' => $suggestions,
		]);
	}

Étape 8 — JavaScript admin (AJAX)

Oui, c’est du JS, mais il ne parle pas à l’API IA. Il parle à WordPress (admin-ajax), donc pas de fuite de clé.

jQuery(function ($) {
	'use strict';

	const box = $('#bpcab-ai-links-box');
	if (!box.length) return;

	$('#bpcab-ai-links-refresh').on('click', function () {
		const postId = box.data('post-id');

		$(this).prop('disabled', true).text('Rafraîchissement…');

		$.post(BPCAB_AI_LINKS.ajaxUrl, {
			action: 'bpcab_ai_links_refresh',
			nonce: BPCAB_AI_LINKS.nonce,
			postId: postId
		}).done(function () {
			// Pour rester simple, on reload la page.
			// Variante: re-render DOM avec les suggestions.
			window.location.reload();
		}).fail(function (xhr) {
			console.error(xhr.responseText || xhr.statusText);
			alert('Erreur AJAX. Vérifiez la console et les logs PHP.');
		}).always(function () {
			$('#bpcab-ai-links-refresh').prop('disabled', false).text('Rafraîchir');
		});
	});
});

Étape 9 — Endpoint REST (optionnel, mais pratique)

Ce endpoint est utile si vous voulez déclencher les suggestions depuis un outil externe, ou depuis un module Divi/Elementor qui appelle votre back-end (toujours sans exposer la clé IA).

	public static function register_rest_routes(): void {
		register_rest_route('bpcab/v1', '/internal-links/(?P<id>d+)', [
			'methods'             => 'POST',
			'permission_callback' => function (WP_REST_Request $req) {
				$post_id = (int) $req['id'];
				return current_user_can('edit_post', $post_id);
			},
			'callback'            => function (WP_REST_Request $req) {
				$post_id = (int) $req['id'];
				$force = (bool) $req->get_param('force');

				$post = get_post($post_id);
				if (!$post instanceof WP_Post) {
					return new WP_REST_Response(['message' => 'Post introuvable.'], 404);
				}

				$suggestions = self::get_suggestions($post, $force);

				return new WP_REST_Response([
					'postId'       => $post_id,
					'force'        => $force,
					'suggestions'  => $suggestions,
				], 200);
			},
			'args'                => [
				'force' => [
					'type'    => 'boolean',
					'default' => false,
				],
			],
		]);
	}
}

BPCAB_AI_Internal_Links::init();

Référence REST officielle : Adding Custom Endpoints.


Le code assemblé complet

Copiez-collez ceci tel quel dans wp-content/mu-plugins/bpcab-ai-internal-links.php, puis créez le fichier JS à côté (bpcab-ai-links-admin.js). Si vous le collez dans un plugin de snippets, vous augmentez les risques de “snippet cassé” lors d’une mise à jour ou d’une désactivation accidentelle.

<?php
/**
 * Plugin Name: BPCAB - Suggestions de maillage interne (IA)
 * Description: Suggère des liens internes pertinents via IA, avec cache (transients), metabox et endpoint REST.
 * Author: BPCAB
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

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

final class BPCAB_AI_Internal_Links {
	const TRANSIENT_TTL = 7 * DAY_IN_SECONDS;
	const MAX_CANDIDATES = 50;

	public static function init(): void {
		add_action('add_meta_boxes', [__CLASS__, 'register_metabox']);
		add_action('admin_enqueue_scripts', [__CLASS__, 'admin_assets']);
		add_action('wp_ajax_bpcab_ai_links_refresh', [__CLASS__, 'ajax_refresh']);
		add_action('rest_api_init', [__CLASS__, 'register_rest_routes']);
		add_action('save_post', [__CLASS__, 'invalidate_cache_on_save'], 10, 2);
	}

	private static function is_supported_post_type(string $post_type): bool {
		return in_array($post_type, ['post', 'page'], true);
	}

	private static function get_content_fingerprint(WP_Post $post): string {
		$title = (string) get_the_title($post);
		$excerpt = (string) get_the_excerpt($post);
		$content = (string) $post->post_content;

		$sample = $title . "n" . $excerpt . "n" . wp_strip_all_tags(mb_substr($content, 0, 4000));
		return hash('sha256', $sample);
	}

	private static function transient_key(int $post_id, string $fingerprint): string {
		return 'bpcab_ai_links_' . $post_id . '_' . substr($fingerprint, 0, 12);
	}

	public static function invalidate_cache_on_save(int $post_id, WP_Post $post): void {
		if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
			return;
		}
		if (!self::is_supported_post_type($post->post_type)) {
			return;
		}
		delete_post_meta($post_id, '_bpcab_ai_links_last_transient');
	}

	private static function get_internal_link_candidates(WP_Post $post): array {
		$args = [
			'post_type'           => $post->post_type,
			'post_status'         => 'publish',
			'posts_per_page'      => self::MAX_CANDIDATES,
			'post__not_in'        => [$post->ID],
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
			'orderby'             => 'date',
			'order'               => 'DESC',
		];

		$cats = wp_get_post_categories($post->ID);
		if (!empty($cats) && $post->post_type === 'post') {
			$args['category__in'] = array_slice($cats, 0, 3);
		}

		$q = new WP_Query($args);

		$candidates = [];
		foreach ($q->posts as $p) {
			/** @var WP_Post $p */
			$candidates[] = [
				'id'      => (int) $p->ID,
				'title'   => html_entity_decode(get_the_title($p), ENT_QUOTES, get_bloginfo('charset')),
				'url'     => get_permalink($p),
				'excerpt' => wp_strip_all_tags(get_the_excerpt($p)),
			];
		}

		return $candidates;
	}

	private static function openai_request(array $payload): array {
		if (!defined('BPCAB_OPENAI_API_KEY') || !is_string(BPCAB_OPENAI_API_KEY) || BPCAB_OPENAI_API_KEY === '') {
			return [
				'ok'    => false,
				'error' => 'Clé API manquante (BPCAB_OPENAI_API_KEY).',
			];
		}

		$resp = wp_remote_post(
			'https://api.openai.com/v1/responses',
			[
				'timeout' => 25,
				'headers' => [
					'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
					'Content-Type'  => 'application/json',
				],
				'body'    => wp_json_encode($payload),
			]
		);

		if (is_wp_error($resp)) {
			return [
				'ok'    => false,
				'error' => $resp->get_error_message(),
			];
		}

		$code = (int) wp_remote_retrieve_response_code($resp);
		$body = (string) wp_remote_retrieve_body($resp);

		if ($code < 200 || $code >= 300) {
			return [
				'ok'    => false,
				'error' => 'HTTP ' . $code . ' : ' . substr($body, 0, 300),
			];
		}

		$data = json_decode($body, true);
		if (!is_array($data)) {
			return [
				'ok'    => false,
				'error' => 'Réponse JSON invalide (impossible à parser).',
			];
		}

		return [
			'ok'   => true,
			'data' => $data,
		];
	}

	private static function build_prompt(WP_Post $post, array $candidates): array {
		$title = (string) get_the_title($post);
		$excerpt = (string) get_the_excerpt($post);
		$content = (string) $post->post_content;

		$context = [
			'title'   => $title,
			'excerpt' => $excerpt,
			'content' => wp_strip_all_tags(mb_substr($content, 0, 4500)),
		];

		return [
			'role'    => 'user',
			'content' => [
				[
					'type' => 'input_text',
					'text' => "Vous êtes un assistant SEO/editorial. Objectif: proposer des liens internes pertinents.n"
						. "Contraintes:n"
						. "- Répondez UNIQUEMENT en JSON valide.n"
						. "- Proposez 5 suggestions maximum.n"
						. "- Chaque suggestion: {"url":"...","title":"...","anchor":"...","reason":"..."}n"
						. "- L'ancre doit être naturelle (2 à 6 mots), pas sur-optimisée.n"
						. "- Ne proposez que des URLs présentes dans la liste de candidats.nn"
						. "Article en cours (contexte):n" . wp_json_encode($context) . "nn"
						. "Candidats internes:n" . wp_json_encode($candidates),
				],
			],
		];
	}

	private static function extract_json_suggestions(array $openai_data): array {
		$text = '';

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

		$text = trim($text);
		if ($text === '') {
			return [];
		}

		$text = preg_replace('/^```(json)?/i', '', $text);
		$text = preg_replace('/```$/', '', $text);
		$text = trim((string) $text);

		$decoded = json_decode($text, true);
		if (!is_array($decoded)) {
			return [];
		}

		$suggestions = $decoded;
		if (isset($decoded['suggestions']) && is_array($decoded['suggestions'])) {
			$suggestions = $decoded['suggestions'];
		}

		if (!is_array($suggestions)) {
			return [];
		}

		$clean = [];
		$allowed_reason = [
			'em' => [],
			'strong' => [],
			'br' => [],
			'a' => [
				'href' => true,
				'target' => true,
				'rel' => true,
			],
		];

		foreach (array_slice($suggestions, 0, 5) as $s) {
			if (!is_array($s)) {
				continue;
			}
			$url = isset($s['url']) ? esc_url_raw((string) $s['url']) : '';
			$title = isset($s['title']) ? sanitize_text_field((string) $s['title']) : '';
			$anchor = isset($s['anchor']) ? sanitize_text_field((string) $s['anchor']) : '';
			$reason = isset($s['reason']) ? wp_kses((string) $s['reason'], $allowed_reason) : '';

			if ($url === '' || $title === '' || $anchor === '') {
				continue;
			}

			$clean[] = [
				'url'    => $url,
				'title'  => $title,
				'anchor' => $anchor,
				'reason' => $reason,
			];
		}

		return $clean;
	}

	private static function get_suggestions(WP_Post $post, bool $force_refresh = false): array {
		$fingerprint = self::get_content_fingerprint($post);
		$key = self::transient_key($post->ID, $fingerprint);

		if (!$force_refresh) {
			$cached = get_transient($key);
			if (is_array($cached)) {
				return $cached;
			}
		}

		$candidates = self::get_internal_link_candidates($post);
		if (count($candidates) < 5) {
			return [];
		}

		$payload = [
			'model' => 'gpt-4.1-mini',
			'input' => [
				self::build_prompt($post, $candidates),
			],
			'temperature' => 0.2,
		];

		$res = self::openai_request($payload);
		if (!$res['ok']) {
			return [
				'_error' => sanitize_text_field((string) $res['error']),
			];
		}

		$suggestions = self::extract_json_suggestions($res['data']);
		if (empty($suggestions)) {
			return [
				'_error' => 'Aucune suggestion exploitable (réponse IA vide ou non-JSON).',
			];
		}

		set_transient($key, $suggestions, self::TRANSIENT_TTL);
		update_post_meta($post->ID, '_bpcab_ai_links_last_transient', $key);

		return $suggestions;
	}

	public static function register_metabox(): void {
		$screen = get_current_screen();
		if (!$screen) {
			return;
		}
		if (!self::is_supported_post_type($screen->post_type)) {
			return;
		}

		add_meta_box(
			'bpcab_ai_internal_links',
			'Suggestions de liens internes (IA)',
			[__CLASS__, 'render_metabox'],
			$screen->post_type,
			'side',
			'high'
		);
	}

	public static function admin_assets(string $hook): void {
		if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
			return;
		}

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

		wp_localize_script('bpcab-ai-links-admin', 'BPCAB_AI_LINKS', [
			'ajaxUrl' => admin_url('admin-ajax.php'),
			'nonce'   => wp_create_nonce('bpcab_ai_links'),
		]);
	}

	public static function render_metabox(WP_Post $post): void {
		if (!current_user_can('edit_post', $post->ID)) {
			echo '<p>Permissions insuffisantes.</p>';
			return;
		}

		$suggestions = self::get_suggestions($post, false);

		echo '<div id="bpcab-ai-links-box" data-post-id="' . esc_attr((string) $post->ID) . '">';
		echo '<p><button type="button" class="button" id="bpcab-ai-links-refresh">Rafraîchir</button></p>';

		if (isset($suggestions['_error'])) {
			echo '<p><strong>Erreur :</strong> ' . esc_html((string) $suggestions['_error']) . '</p>';
			echo '</div>';
			return;
		}

		if (empty($suggestions)) {
			echo '<p>Aucune suggestion pour le moment.</p>';
			echo '</div>';
			return;
		}

		echo '<ol>';
		foreach ($suggestions as $s) {
			$url = esc_url((string) $s['url']);
			$title = esc_html((string) $s['title']);
			$anchor = esc_html((string) $s['anchor']);
			$reason = (string) $s['reason'];

			echo '<li>';
			echo '<p><a href="' . $url . '" target="_blank" rel="noopener">' . $title . '</a><br>';
			echo '<strong>Ancre suggérée :</strong> <code>' . $anchor . '</code></p>';
			if ($reason !== '') {
				echo '<p><em>' . $reason . '</em></p>';
			}
			echo '</li>';
		}
		echo '</ol>';
		echo '</div>';
	}

	public static function ajax_refresh(): void {
		check_ajax_referer('bpcab_ai_links', 'nonce');

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

		if (!current_user_can('edit_post', $post_id)) {
			wp_send_json_error(['message' => 'Permissions insuffisantes.'], 403);
		}

		$post = get_post($post_id);
		if (!$post instanceof WP_Post) {
			wp_send_json_error(['message' => 'Post introuvable.'], 404);
		}

		$suggestions = self::get_suggestions($post, true);

		wp_send_json_success([
			'suggestions' => $suggestions,
		]);
	}

	public static function register_rest_routes(): void {
		register_rest_route('bpcab/v1', '/internal-links/(?P<id>d+)', [
			'methods'             => 'POST',
			'permission_callback' => function (WP_REST_Request $req) {
				$post_id = (int) $req['id'];
				return current_user_can('edit_post', $post_id);
			},
			'callback'            => function (WP_REST_Request $req) {
				$post_id = (int) $req['id'];
				$force = (bool) $req->get_param('force');

				$post = get_post($post_id);
				if (!$post instanceof WP_Post) {
					return new WP_REST_Response(['message' => 'Post introuvable.'], 404);
				}

				$suggestions = self::get_suggestions($post, $force);

				return new WP_REST_Response([
					'postId'      => $post_id,
					'force'       => $force,
					'suggestions' => $suggestions,
				], 200);
			},
			'args'                => [
				'force' => [
					'type'    => 'boolean',
					'default' => false,
				],
			],
		]);
	}
}

BPCAB_AI_Internal_Links::init();
jQuery(function ($) {
	'use strict';

	const box = $('#bpcab-ai-links-box');
	if (!box.length) return;

	$('#bpcab-ai-links-refresh').on('click', function () {
		const postId = box.data('post-id');

		$(this).prop('disabled', true).text('Rafraîchissement…');

		$.post(BPCAB_AI_LINKS.ajaxUrl, {
			action: 'bpcab_ai_links_refresh',
			nonce: BPCAB_AI_LINKS.nonce,
			postId: postId
		}).done(function () {
			window.location.reload();
		}).fail(function (xhr) {
			console.error(xhr.responseText || xhr.statusText);
			alert('Erreur AJAX. Vérifiez la console et les logs PHP.');
		}).always(function () {
			$('#bpcab-ai-links-refresh').prop('disabled', false).text('Rafraîchir');
		});
	});
});

Explication du code

Pré-filtrage des candidats : la clé pour maîtriser la qualité

La partie la plus “SEO” de ce plugin n’est pas l’IA. C’est le pré-filtrage côté WordPress. En passant par WP_Query et des heuristiques simples (même type de contenu, mêmes catégories, exclusion du post courant), vous donnez à l’IA une liste plus cohérente.

Si vous avez un site très ancien, vous pouvez aussi trier par popularité (meta “views”, ou “comment_count”). Évitez juste de faire une requête SQL custom non indexée : c’est le genre de détail qui plombe l’admin sur des gros sites.

Cache par fingerprint : éviter de payer deux fois

Le fingerprint (hash) évite le “cache faux” : vous modifiez le titre ou l’intro, vous voulez que les suggestions changent. Avec un cache par ID uniquement, vous seriez obligé de vider manuellement.

Le transient TTL à 7 jours est un compromis. Sur un site très actif, je descends souvent à 24–72h. Sur un site stable, je monte à 30 jours.

Sanitization : traiter la réponse IA comme une entrée utilisateur

Le plugin nettoie :

  • sanitize_text_field() pour les champs courts (titre, ancre),
  • wp_kses() pour la justification (raison) si vous acceptez un mini HTML,
  • esc_url_raw() pour l’URL.

Référence officielle : Sanitizing Data et Escaping Data.

Pourquoi une metabox (et pas un bloc Gutenberg custom)

Une metabox est rapide à déployer et marche quel que soit l’éditeur (Gutenberg, Classic Editor, certains workflows page builders). Sur des sites Avada/Elementor “hybrides”, c’est souvent le choix le plus robuste.

Si vous voulez une intégration Gutenberg native (sidebar plugin), vous pouvez réutiliser le endpoint REST et construire une UI React. Mais gardez la logique IA côté PHP.

Coûts API et optimisation

Le coût dépend surtout du volume de texte envoyé (candidats + contexte). Avec MAX_CANDIDATES=50, vous envoyez souvent quelques dizaines de milliers de caractères. Si vous utilisez un modèle “mini”, le coût par requête reste généralement bas, mais il peut grimper vite si vous rafraîchissez souvent.

Estimation pragmatique

  • Site avec 200 articles, 20 nouveaux articles/mois.
  • Vous générez des suggestions 2 fois par article (brouillon + version finale) = 40 appels/mois.
  • Ajoutez 20 appels “ré-optimisation” = 60 appels/mois.

Sur un modèle économique (type “mini”), vous restez souvent dans une enveloppe faible. Sur un modèle plus “premium”, le coût peut devenir sensible. Je recommande de commencer petit, mesurer, puis ajuster.

Optimisations qui marchent vraiment

  • Réduire les candidats à 20–30 si votre taxonomie est propre.
  • Limiter le contenu envoyé : 2–3 premiers paragraphes au lieu de 4500 caractères.
  • Cache agressif (TTL plus long) + refresh manuel uniquement.
  • Batch (avancé) : générer des suggestions pour 10 posts en cron nocturne, plutôt que “au clic” en pleine journée.

Variantes et cas d’usage avancés

Variante 1 — suggestions pour un Custom Post Type (CPT)

Si vous avez un CPT portfolio (classique avec Avada/Elementor), ajoutez-le dans is_supported_post_type(). Ensuite, adaptez get_internal_link_candidates() pour filtrer sur des taxonomies custom.

Variante 2 — intégrer Divi 5 / Elementor / Avada (sans exposer la clé)

Le pattern que je recommande : un bouton dans votre builder qui appelle votre endpoint REST WordPress, pas l’API IA.

  • Divi 5 : un module custom peut déclencher un call REST côté admin (ou via une action serveur si votre setup le permet). Le module affiche ensuite la liste pour insertion manuelle.
  • Elementor : un widget admin (ou un panel) peut appeler /wp-json/bpcab/v1/internal-links/{id} et afficher les suggestions.
  • Avada (Fusion Builder) : même logique, via un “admin tool” ou un élément custom qui consomme l’endpoint.

Le point commun : vous gardez la génération IA côté PHP. Le builder ne voit jamais la clé.

Variante 3 — auto-sauvegarder les suggestions (post_meta) pour workflow éditorial

Si vous travaillez en équipe, vous pouvez stocker la dernière liste de suggestions dans post_meta (ex: _bpcab_ai_links_suggestions) et afficher ça dans un tableau éditorial. Attention : ne stockez pas des tonnes de texte, et purgez quand c’est obsolète.

Sécurité et bonnes pratiques

Les risques concrets sur ce type de feature :

  • Fuite de clé API : si vous appelez l’API depuis le navigateur, la clé finira exposée.
  • Injection HTML : si vous affichez la réponse IA sans wp_kses/esc_html.
  • Abus de quota : si n’importe quel utilisateur peut déclencher des refresh en boucle.
  • Envoi de données sensibles : si votre contenu contient des infos perso (RGPD).

Mesures à appliquer (et pourquoi)

  • Clé dans wp-config.php : pas dans la DB, pas dans Git.
  • Capability checks : current_user_can('edit_post') au minimum.
  • Nonce pour l’AJAX : évite les CSRF basiques.
  • Timeout HTTP : si l’API rame, votre admin ne doit pas freezer 2 minutes.
  • Rate limiting (à ajouter si besoin) : par exemple, un transient “cooldown” de 60 secondes par post.

Note RGPD (pragmatique)

Si vous envoyez du contenu éditorial public, le risque est souvent acceptable. Si vous envoyez des données utilisateurs (commentaires, formulaires, infos clients), vous devez cadrer : base légale, DPA, minimisation, et idéalement anonymisation avant envoi.

Comment tester et déboguer

Je teste toujours en 3 passes :

  1. Sans IA : vérifiez que la metabox apparaît et que WP_Query renvoie bien des candidats.
  2. Avec IA, mais logs activés : vous déclenchez un refresh et vous surveillez les erreurs.
  3. Test de charge léger : 10 refresh d’affilée (pour voir rate/timeout/cache).

Activer les logs WordPress

Dans wp-config.php (sur un environnement de staging, pas en prod si vous ne maîtrisez pas l’accès aux logs) :

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

Référence : Debugging in WordPress.

Vérifier la réponse HTTP

Quand ça échoue, c’est souvent :

  • un timeout (hébergement lent, DNS, firewall),
  • une clé invalide,
  • un quota dépassé,
  • un JSON non parseable (l’IA a “parlé” au lieu de répondre en JSON).

Si ça ne marche pas

Voici un tableau de diagnostic basé sur des incidents que j’ai réellement vus sur des sites WordPress (souvent avec cache agressif, ou snippets collés au mauvais endroit).

Symptôme Cause probable Vérification Solution
La metabox n’apparaît pas Code copié au mauvais endroit / mu-plugin non chargé Vérifiez wp-content/mu-plugins/ et l’admin “Extensions” (les mu-plugins n’y apparaissent pas toujours selon UI) Créer le dossier mu-plugins, mettre le fichier au bon endroit, vérifier les permissions
Erreur 500 après copie du code Point-virgule manquant / parenthèse oubliée Consultez wp-content/debug.log Re-copier le fichier, valider avec php -l en SSH
“Clé API manquante” Constante non définie Vérifiez wp-config.php et l’environnement Définir BPCAB_OPENAI_API_KEY (sans espaces, sans guillemets typographiques)
HTTP 401/403 Clé invalide / projet non autorisé Regardez le body tronqué dans l’erreur Regénérer la clé, vérifier les droits côté fournisseur
HTTP 429 Rate limit / quota Logs + console fournisseur Augmenter TTL cache, réduire refresh, ajouter cooldown par transient
Suggestions vides Réponse non-JSON (le modèle “discute”) Logguez $body temporairement Rendre le prompt plus strict, baisser temperature, forcer format JSON
Les suggestions ne changent jamais Cache trop long / fingerprint stable Changez franchement le début du contenu, puis refresh Réduire TTL, augmenter la portion de contenu hashée, ou forcer refresh
Conflit avec plugin de cache admin Admin-ajax mis en cache (anti-pattern) Vérifiez headers et règles cache Exclure admin-ajax.php du cache

Erreurs “bêtes” mais fréquentes

  • Tester sur production sans sauvegarde : déployez d’abord en staging.
  • Mettre la clé API dans le JS “pour aller plus vite” : c’est une fuite garantie.
  • Utiliser un hook inadapté (ex: charger la metabox sur le front) : vous alourdissez tout le site.
  • Oublier de régénérer les permaliens après une refonte : vos candidats ont des URLs qui redirigent.
  • Code d’un ancien tutoriel incompatible : sur WP 6.9.4, évitez les patterns obsolètes et validez vos fonctions sur le Handbook.

Ressources

FAQ

Est-ce que ce plugin ajoute automatiquement des liens dans le contenu ?

Non. Il propose des suggestions. Dans la pratique, c’est ce qui évite les catastrophes éditoriales (ancres absurdes, sur-linking, liens hors contexte).

Pourquoi ne pas appeler l’API IA directement en JavaScript depuis l’éditeur ?

Parce que vous exposeriez la clé API. Même si vous “obfusquez”, elle finit récupérable. La bonne approche : appel JS → endpoint WordPress → appel IA côté serveur.

Pourquoi utiliser un transient plutôt qu’une option ou un post_meta ?

Le transient est fait pour du cache expirant, et WordPress sait le gérer proprement. Le post_meta est mieux pour des données “durables” de workflow.

Le cache ne se vide pas quand je mets à jour l’article, c’est normal ?

Avec le fingerprint, un refresh recalculera une nouvelle clé. Si vous voyez toujours les mêmes suggestions, c’est souvent que vous n’avez pas changé la partie du contenu qui entre dans le hash (titre/extrait/début).

Je reçois “Aucune suggestion exploitable”, que faire ?

Logguez temporairement le body de réponse et regardez si l’IA renvoie du texte non-JSON. Ensuite, rendez le prompt plus strict, ou baissez la température.

Est-ce compatible avec Elementor / Divi 5 / Avada ?

Oui, parce que la feature est côté WordPress (metabox + REST). Pour une UX “dans le builder”, consommez l’endpoint REST et affichez la liste dans votre module/widget.

Puis-je utiliser Anthropic ou Mistral à la place ?

Oui, mais vous devrez adapter la fonction HTTP et le parsing de réponse. Gardez le même contrat de sortie : une liste JSON {url,title,anchor,reason}.

Comment éviter que les auteurs spamment le bouton “Rafraîchir” ?

Ajoutez un rate limit simple : un transient “cooldown” par post et par user (ex: 60 secondes). Et surveillez les 429 côté fournisseur.

Peut-on restreindre les candidats à une taxonomie spécifique ?

Oui. Modifiez get_internal_link_candidates() pour utiliser tax_query sur vos taxonomies. C’est souvent la meilleure amélioration qualité/coût.

Pourquoi limiter à 50 candidats ?

Parce que c’est un bon compromis pour la pertinence et le coût. Au-delà, vous envoyez trop de texte, et l’IA finit par “survoler” au lieu de choisir.

Que faire si mon hébergeur bloque les requêtes sortantes ?

Testez un wp_remote_get() simple vers https://api.openai.com/ et vérifiez les erreurs. Sur certains hébergements, il faut autoriser les connexions sortantes (firewall) ou activer cURL.