get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_status = 'publish' AND post_type IN ('post','page','product')", $wpdb->posts ) ); $schemas_table = $wpdb->prefix . 'aiforya_sm_schemas'; $posts_with_schema = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM %i WHERE status = 'active'", $schemas_table ) ); $coverage = $total_posts > 0 ? min( 100, (int) round( ( $posts_with_schema / $total_posts ) * 100 ) ) : 0; // 2. Qualité moyenne via Predictor $rows = $wpdb->get_results( $wpdb->prepare( "SELECT schema_json FROM %i WHERE status = 'active' LIMIT 200", $schemas_table ), ARRAY_A ); $quality = 0; if ( ! empty( $rows ) && class_exists( 'Aiforya_Schema_Markup_Predictor' ) ) { $predictor = Aiforya_Schema_Markup_Predictor::get_instance(); $sum = 0; $count = 0; foreach ( $rows as $r ) { if ( empty( $r['schema_json'] ) ) continue; $report = $predictor->predict( (string) $r['schema_json'] ); $sum += (int) $report['score']; $count++; } $quality = $count > 0 ? (int) round( $sum / $count ) : 0; } // 3. Diversité $types_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT schema_type) FROM %i WHERE status = 'active'", $schemas_table ) ); // 10 types distincts = 100%, 1 type = 10%, etc. $diversity = min( 100, (int) round( ( $types_count / 10 ) * 100 ) ); // 4. Validation : % schemas avec status = 'valid' dans validation_log (latest per schema) $val_table = $wpdb->prefix . 'aiforya_sm_validation_log'; $validation = 0; if ( $posts_with_schema > 0 ) { $valid_latest = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i v INNER JOIN ( SELECT post_id, MAX(checked_at) AS latest FROM %i GROUP BY post_id ) lm ON lm.post_id = v.post_id AND lm.latest = v.checked_at WHERE v.validation_status = 'valid'", $val_table, $val_table ) ); $validation = (int) round( ( $valid_latest / $posts_with_schema ) * 100 ); } // 5. Search Console — axe CONDITIONNEL. // // ⛔ CORRECTION DU 13/08/2026. Cet axe était calculé et affiché DANS TOUTES LES // VARIANTES. Or l'intégration Search Console n'existe que dans la version avancée : // dans la version gratuite, la table est créée puis jamais alimentée, donc // $gsc_open valait 0, donc le score valait 100 EN PERMANENCE — et le tableau de bord // annonçait « 0 open error(s) — daily repair active » pour une fonction absente. // Il gonflait en plus le score global de 10 points. // Un axe qu'on ne mesure pas ne doit ni noter ni s'afficher : on le calcule // seulement quand l'intégration est réellement présente. Le marqueur est celui que // l'activateur utilise déjà pour la récupération OAuth — pas un nouveau. $gsc_actif = class_exists( 'Aiforya_Schema_Markup_GSC' ); $gsc_score = null; $gsc_open = null; if ( $gsc_actif ) { $gsc_table = $wpdb->prefix . 'aiforya_sm_gsc_errors'; $gsc_open = 0; $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $gsc_table ) ); if ( $exists ) { $gsc_open = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'open'", $gsc_table ) ); } // Score GSC : 100 sans erreur, -5 par erreur ouverte (cap 0). $gsc_score = max( 0, 100 - ( $gsc_open * 5 ) ); } // Pondération du score global. Avec Search Console : // couverture 25% + qualité 30% + diversité 15% + validation 20% + gsc 10%. // Sans, les 10 % de l'axe absent sont RÉPARTIS sur les quatre axes réellement // mesurés, au prorata de leur poids d'origine (25/30/15/20 sur 90) — et non // ignorés, ce qui plafonnerait le score à 90. if ( $gsc_actif ) { $score = (int) round( ( $coverage * 0.25 ) + ( $quality * 0.30 ) + ( $diversity * 0.15 ) + ( $validation * 0.20 ) + ( $gsc_score * 0.10 ) ); } else { $score = (int) round( ( $coverage * ( 25 / 90 ) ) + ( $quality * ( 30 / 90 ) ) + ( $diversity * ( 15 / 90 ) ) + ( $validation * ( 20 / 90 ) ) ); } $rapport = array( 'score' => $score, 'coverage' => $coverage, 'quality' => $quality, 'diversity' => $diversity, 'validation' => $validation, 'gsc_actif' => $gsc_actif, 'breakdown' => array( 'total_posts' => $total_posts, 'posts_with_schema' => $posts_with_schema, 'distinct_types' => $types_count, ), ); // Les clés GSC n'existent QUE si l'axe est réel — un affichage qui les lit sans // vérifier tombera donc bruyamment plutôt que d'inventer un 100 %. if ( $gsc_actif ) { $rapport['gsc_score'] = $gsc_score; $rapport['gsc_errors'] = $gsc_open; } return $rapport; } /** * Cron daily — snapshot du score dans aiforya_sm_health_history. */ public function daily_snapshot(): void { global $wpdb; $report = $this->compute_score(); $table = $wpdb->prefix . 'aiforya_sm_health_history'; $today = current_time( 'Y-m-d' ); // INSERT … ON DUPLICATE KEY UPDATE — si un snapshot existe déjà aujourd'hui, on l'écrase. $wpdb->query( $wpdb->prepare( "INSERT INTO %i (snapshot_date, score, coverage, quality, diversity, validation, gsc_errors, breakdown, created_at) VALUES (%s, %d, %d, %d, %d, %d, %d, %s, %s) ON DUPLICATE KEY UPDATE score = VALUES(score), coverage = VALUES(coverage), quality = VALUES(quality), diversity = VALUES(diversity), validation = VALUES(validation), gsc_errors = VALUES(gsc_errors), breakdown = VALUES(breakdown)", $table, $today, $report['score'], $report['coverage'], $report['quality'], $report['diversity'], // ⚠ `gsc_errors` N'EXISTE PLUS quand l'axe Search Console est inactif (cf. // compute_score). Sans ce défaut, le snapshot quotidien émettrait un avis // « Undefined array key » sur chaque site de la version gratuite — une // régression introduite par le correctif du 13/08 et attrapée en relisant // TOUS les lecteurs de la clé, pas seulement celui qu'on venait d'éditer. $report['validation'], $report['gsc_errors'] ?? 0, wp_json_encode( $report['breakdown'] ), current_time( 'mysql' ) ) ); } /** * Récupère l'historique des N derniers jours pour la sparkline tendance. */ public function get_history( int $days = 90 ): array { global $wpdb; $table = $wpdb->prefix . 'aiforya_sm_health_history'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT snapshot_date, score, coverage, quality, diversity, validation, gsc_errors FROM %i WHERE snapshot_date >= DATE_SUB(CURDATE(), INTERVAL %d DAY) ORDER BY snapshot_date ASC", $table, $days ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } }