>> et <<>>. */ if ( ! defined( 'ABSPATH' ) ) { exit; } if ( class_exists( 'Aiforya_Schema_Markup_Generator', false ) ) { return; } class Aiforya_Schema_Markup_Generator { /** @var self|null Singleton instance (pattern AIFORYA canonical). */ private static ?self $instance = null; /** * Singleton accessor — convention AIFORYA canonical. * Tous les autres services et tabs accèdent à cette classe via X::get_instance(). */ public static function get_instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } public function __construct() { // PIVOT scaffold — constructor public pour DI/test, mais convention = get_instance(). } /** * Orchestre la génération et la sauvegarde d'un schéma pour un post. * @param int $post_id ID du post. * @param bool $force_regeneration Forcer la regénération même si un schéma existe. * @return mixed ID du schéma ou erreur. */ public function generate_and_save_schema_for_post( int $post_id = 0, bool $force_regeneration = false ) { // Vérifier que le post existe $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'invalid_post', __( 'The specified post does not exist.', 'aiforya-schema-markup' ) ); } // Vérifier si un schéma existe déjà $db = Aiforya_Schema_Markup_Db::get_instance(); $existing_schema = $db->get_schema_by_post_id( $post_id ); if ( $existing_schema && ! $force_regeneration ) { // Un schéma existe déjà et on ne force pas la regénération return (int) $existing_schema->id; } // Détecter le type de schéma $detector = Aiforya_Schema_Markup_Detector::get_instance(); $schema_type = $detector->detect_schema_type_for_post( $post_id ); if ( is_wp_error( $schema_type ) ) { // Enregistrer l'échec de détection $db->log_detection( array( 'post_id' => $post_id, 'detected_type' => '', 'confidence' => 0.0, 'ai_response' => $schema_type->get_error_message(), ) ); return $schema_type; } // Enregistrer la détection réussie $db->log_detection( array( 'post_id' => $post_id, 'detected_type' => $schema_type, 'confidence' => 0.95, 'ai_response' => wp_json_encode( array( 'type' => $schema_type ) ), ) ); // Générer le JSON-LD $json_ld = $this->generate_json_ld( $post_id, $schema_type ); if ( is_wp_error( $json_ld ) ) { return $json_ld; } // Sauvegarder le schéma dans la base $schema_data = array( 'post_id' => $post_id, 'schema_type' => $schema_type, 'schema_json' => $json_ld, 'is_active' => 1, ); $result = $db->save_or_update_schema( $schema_data ); if ( ! is_wp_error( $result ) ) { // Invalider le cache $this->clear_cache_for_post( $post_id ); } return $result; } /** * Génère le JSON-LD pour un post et un type de schéma donné. * @param int $post_id ID du post. * @param string $schema_type Type de schéma à générer (ex: 'Article'). * @return mixed Le JSON-LD généré ou une erreur. */ public function generate_json_ld( int $post_id = 0, string $schema_type = '' ) { // Vérifier que le post existe $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'invalid_post', __( 'The specified post does not exist.', 'aiforya-schema-markup' ) ); } // Vérifier que le type de schéma est fourni if ( empty( $schema_type ) ) { return new WP_Error( 'missing_schema_type', __( 'The schema type is not specified.', 'aiforya-schema-markup' ) ); } // Récupérer les informations de base du site $site_name = get_bloginfo( 'name' ); $site_url = home_url( '/' ); // Construire le schéma de base $schema = array( '@context' => 'https://schema.org', '@type' => $schema_type, ); // Ajouter les propriétés communes $schema['headline'] = get_the_title( $post_id ); $schema['url'] = get_permalink( $post_id ); $schema['datePublished'] = get_the_date( 'c', $post_id ); $schema['dateModified'] = get_the_modified_date( 'c', $post_id ); // Description $excerpt = get_the_excerpt( $post_id ); if ( ! empty( $excerpt ) ) { $schema['description'] = wp_strip_all_tags( $excerpt ); } // Image principale $thumbnail_id = get_post_thumbnail_id( $post_id ); if ( $thumbnail_id ) { $image_url = wp_get_attachment_image_url( $thumbnail_id, 'full' ); if ( $image_url ) { $schema['image'] = array( '@type' => 'ImageObject', 'url' => $image_url, ); $image_meta = wp_get_attachment_metadata( $thumbnail_id ); if ( isset( $image_meta['width'] ) && isset( $image_meta['height'] ) ) { $schema['image']['width'] = $image_meta['width']; $schema['image']['height'] = $image_meta['height']; } } } // Auteur $author_id = $post->post_author; $author_name = get_the_author_meta( 'display_name', $author_id ); $schema['author'] = array( '@type' => 'Person', 'name' => $author_name, ); $author_url = get_author_posts_url( $author_id ); if ( $author_url ) { $schema['author']['url'] = $author_url; } // Publisher (organisation) $schema['publisher'] = array( '@type' => 'Organization', 'name' => $site_name, 'url' => $site_url, ); // Logo du site si disponible $custom_logo_id = get_theme_mod( 'custom_logo' ); if ( $custom_logo_id ) { $logo_url = wp_get_attachment_image_url( $custom_logo_id, 'full' ); if ( $logo_url ) { $schema['publisher']['logo'] = array( '@type' => 'ImageObject', 'url' => $logo_url, ); } } // Propriétés spécifiques selon le type switch ( $schema_type ) { case 'Article': case 'NewsArticle': case 'BlogPosting': $schema['mainEntityOfPage'] = array( '@type' => 'WebPage', '@id' => get_permalink( $post_id ), ); // Nombre de mots comme wordCount $content = wp_strip_all_tags( $post->post_content ); $word_count = str_word_count( $content ); if ( $word_count > 0 ) { $schema['wordCount'] = $word_count; } break; case 'Product': // Pour WooCommerce if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $post_id ); if ( $product ) { $schema['name'] = $product->get_name(); $schema['offers'] = array( '@type' => 'Offer', 'price' => $product->get_price(), 'priceCurrency' => get_woocommerce_currency(), 'availability' => $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', 'url' => get_permalink( $post_id ), ); // SKU $sku = $product->get_sku(); if ( $sku ) { $schema['sku'] = $sku; } // Notes / avis $rating_count = $product->get_rating_count(); $average_rating = $product->get_average_rating(); if ( $rating_count > 0 ) { $schema['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => $average_rating, 'reviewCount' => $rating_count, ); } } } break; case 'Event': // Propriétés de base pour un événement $schema['name'] = get_the_title( $post_id ); // Les dates doivent être enrichies par métadonnées custom break; case 'Recipe': $schema['name'] = get_the_title( $post_id ); // Les propriétés recipe nécessitent des métadonnées custom break; } // Enrichissement des champs spécifiques par type (FAQ/HowTo depuis les blocs // Gutenberg, champs personnalisés). Extensible via le filtre ci-dessous. $schema = $this->enrich_schema_fields( $schema, $post, $schema_type ); // Appliquer un filtre pour permettre la personnalisation $schema = apply_filters( 'aiforya_sm_schema_data', $schema, $post_id, $schema_type ); // Encoder en JSON-LD avec options de formatage $json_ld = wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); if ( $json_ld === false ) { return new WP_Error( 'json_encode_failed', __( 'JSON encoding failed.', 'aiforya-schema-markup' ) ); } return $json_ld; } /** * Enrichissement des champs par type de schéma, 100% local (sans IA). * - Article-like : mots-clés depuis les étiquettes du post. * - FAQPage : extraction des paires question/réponse depuis les blocs Gutenberg. */ private function enrich_schema_fields( array $schema, $post, string $schema_type ): array { if ( in_array( $schema_type, array( 'Article', 'BlogPosting', 'NewsArticle' ), true ) ) { $tags = get_the_tags( $post->ID ); if ( is_array( $tags ) && ! empty( $tags ) ) { $keywords = array(); foreach ( $tags as $tag ) { $keywords[] = $tag->name; } if ( ! empty( $keywords ) ) { $schema['keywords'] = implode( ', ', $keywords ); } } } if ( $schema_type === 'FAQPage' ) { $faq = $this->extract_faq_from_content( (string) $post->post_content ); if ( ! empty( $faq ) ) { $schema['mainEntity'] = $faq; } } return $schema; } /** * Extrait des paires Q/R depuis les blocs Gutenberg « Détails » (core/details) * ou une suite titre + paragraphe. 100% local. * * @return array */ private function extract_faq_from_content( string $content ): array { $out = array(); if ( function_exists( 'parse_blocks' ) && has_blocks( $content ) ) { foreach ( parse_blocks( $content ) as $block ) { if ( ( $block['blockName'] ?? '' ) !== 'core/details' ) { continue; } $summary = isset( $block['attrs']['summary'] ) ? wp_strip_all_tags( (string) $block['attrs']['summary'] ) : ''; $answer = wp_strip_all_tags( (string) render_block( $block ) ); if ( $summary !== '' ) { $answer = trim( str_replace( $summary, '', $answer ) ); } if ( $summary !== '' && $answer !== '' ) { $out[] = array( '@type' => 'Question', 'name' => $summary, 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $answer ), ); } } } return $out; } /** * Récupère le JSON-LD d'un post depuis le cache d'objet. * @param int $post_id ID du post. * @return ?string Le JSON-LD en cache ou null. */ public function get_schema_from_cache( int $post_id = 0 ): ?string { $cache_key = 'aiforya_sm_schema_' . $post_id; $cached = wp_cache_get( $cache_key, 'aiforya_sm' ); if ( $cached !== false ) { return $cached; } // Récupérer depuis la base et mettre en cache $db = Aiforya_Schema_Markup_Db::get_instance(); $schema = $db->get_schema_by_post_id( $post_id ); if ( $schema && ! empty( $schema->schema_json ) && $schema->is_active ) { wp_cache_set( $cache_key, $schema->schema_json, 'aiforya_sm', 3600 ); return $schema->schema_json; } return null; } /** * Invalide le cache pour un post spécifique. * @param int $post_id ID du post. */ public function clear_cache_for_post( int $post_id = 0 ): void { $cache_key = 'aiforya_sm_schema_' . $post_id; wp_cache_delete( $cache_key, 'aiforya_sm' ); } /** * Callback pour le hook wp_head, injecte le script JSON-LD. */ public function inject_schema_in_head( ): void { // Vérifier si on est sur une page singulière (post, page, custom post type) if ( ! is_singular() ) { return; } // Récupérer l'ID du post courant $post_id = get_queried_object_id(); if ( ! $post_id ) { return; } // Vérifier si l'injection est activée dans les options $enable_injection = get_option( 'aiforya_sm_enable_injection', true ); if ( ! $enable_injection ) { return; } // Récupérer le schéma depuis le cache $json_ld = $this->get_schema_from_cache( $post_id ); if ( empty( $json_ld ) ) { return; } // Valider que c'est du JSON valide $decoded = json_decode( $json_ld ); if ( json_last_error() !== JSON_ERROR_NONE ) { return; } // Injecter le script JSON-LD. On ré-encode $decoded plutôt que de recracher la chaîne // du cache : wp_json_encode échappe les « / », donc un « » présent dans une // valeur ressort en « <\/script> » et ne peut pas fermer la balise. echo '' . "\n"; } }