40, 'summary_length' => 20, 'faq_items' => 20, 'service_metadata' => 20, ); /** * Optimal summary length range. */ const OPTIMAL_MIN_LENGTH = 300; const OPTIMAL_MAX_LENGTH = 500; /** * Calculate AEO score for a post. * * @param int $post_id Post ID. * @return array Score data with breakdown. */ public static function calculate_score( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return array( 'score' => 0, 'breakdown' => array(), 'issues' => array( 'Post not found' ), 'color' => 'red', ); } $weights = self::get_weights(); $breakdown = array(); $issues = array(); $total_score = 0; // 1. Manual Summary Check (40%) $manual_summary_score = self::score_manual_summary( $post_id ); $breakdown['manual_summary'] = $manual_summary_score; $total_score += ( $manual_summary_score / 100 ) * $weights['manual_summary']; if ( $manual_summary_score < 100 ) { $issues[] = __( 'No manual summary provided', 'ai-answer-toolkit' ); } // 2. Summary Length Check (20%) $summary = AI_Answer_Toolkit_Summary::get_final_summary( $post_id ); $length_score = self::score_summary_length( $summary ); $breakdown['summary_length'] = $length_score; $total_score += ( $length_score / 100 ) * $weights['summary_length']; if ( empty( $summary ) ) { $issues[] = __( 'No summary available (manual or auto)', 'ai-answer-toolkit' ); } elseif ( $length_score < 100 ) { $length = strlen( wp_strip_all_tags( $summary ) ); if ( $length < self::OPTIMAL_MIN_LENGTH ) { // translators: %1$d is current character count, %2$d is minimum optimal length, %3$d is maximum optimal length. $issues[] = sprintf( __( 'Summary too short (%1$d chars, optimal: %2$d-%3$d)', 'ai-answer-toolkit' ), $length, self::OPTIMAL_MIN_LENGTH, self::OPTIMAL_MAX_LENGTH ); } elseif ( $length > self::OPTIMAL_MAX_LENGTH ) { // translators: %1$d is current character count, %2$d is minimum optimal length, %3$d is maximum optimal length. $issues[] = sprintf( __( 'Summary too long (%1$d chars, optimal: %2$d-%3$d)', 'ai-answer-toolkit' ), $length, self::OPTIMAL_MIN_LENGTH, self::OPTIMAL_MAX_LENGTH ); } } // 3. FAQ Items Check (20%) $faq_score = self::score_faq_items( $post ); $breakdown['faq_items'] = $faq_score; $total_score += ( $faq_score / 100 ) * $weights['faq_items']; if ( $faq_score === 0 ) { $issues[] = __( 'No FAQ items detected', 'ai-answer-toolkit' ); } // 4. Service Metadata Check (20% - only for service post type) if ( $post->post_type === 'service' ) { $service_score = self::score_service_metadata( $post_id ); $breakdown['service_metadata'] = $service_score; $total_score += ( $service_score / 100 ) * $weights['service_metadata']; if ( $service_score < 100 ) { $missing = array(); if ( ! get_post_meta( $post_id, '_aitk_service_area', true ) ) { $missing[] = 'area served'; } if ( ! get_post_meta( $post_id, '_aitk_service_type', true ) ) { $missing[] = 'service type'; } if ( ! empty( $missing ) ) { // translators: %s is a comma-separated list of missing metadata fields. $issues[] = sprintf( __( 'Missing service metadata: %s', 'ai-answer-toolkit' ), implode( ', ', $missing ) ); } } } else { // Redistribute service weight to other factors for non-service posts $redistribute = $weights['service_metadata'] / 3; $total_score = ( $total_score / ( 100 - $weights['service_metadata'] ) ) * 100; } // Round to integer $total_score = round( $total_score ); // Get color based on score $color = self::get_score_color( $total_score ); return array( 'score' => $total_score, 'breakdown' => $breakdown, 'issues' => $issues, 'color' => $color, 'summary_type' => AI_Answer_Toolkit_Summary::get_summary_type( $post_id ), ); } /** * Score manual summary presence. * * @param int $post_id Post ID. * @return int Score (0 or 100). */ private static function score_manual_summary( $post_id ) { $manual_summary = get_post_meta( $post_id, '_aitk_summary', true ); return ! empty( $manual_summary ) ? 100 : 0; } /** * Score summary length optimization. * * @param string $summary Summary text. * @return int Score (0-100). */ private static function score_summary_length( $summary ) { if ( empty( $summary ) ) { return 0; } $length = strlen( wp_strip_all_tags( $summary ) ); // Optimal range if ( $length >= self::OPTIMAL_MIN_LENGTH && $length <= self::OPTIMAL_MAX_LENGTH ) { return 100; } // Too short if ( $length < self::OPTIMAL_MIN_LENGTH ) { // Score decreases as it gets shorter $ratio = $length / self::OPTIMAL_MIN_LENGTH; return max( 0, round( $ratio * 100 ) ); } // Too long if ( $length > self::OPTIMAL_MAX_LENGTH ) { // Score decreases as it gets longer (with some tolerance) $excess = $length - self::OPTIMAL_MAX_LENGTH; $tolerance = 100; // Allow 100 chars over before heavy penalty if ( $excess <= $tolerance ) { return max( 50, 100 - ( $excess / $tolerance * 50 ) ); } return max( 0, 50 - ( ( $excess - $tolerance ) / 10 ) ); } return 0; } /** * Score FAQ items presence. * * @param WP_Post $post Post object. * @return int Score (0-100). */ private static function score_faq_items( $post ) { $content = get_post_field( 'post_content', $post->ID ); if ( empty( $content ) ) { return 0; } $blocks = parse_blocks( $content ); if ( empty( $blocks ) ) { return 0; } $faq_count = self::count_faq_items( $blocks ); if ( $faq_count === 0 ) { return 0; } elseif ( $faq_count >= 3 ) { return 100; } else { // 1-2 FAQs get partial score return round( ( $faq_count / 3 ) * 100 ); } } /** * Count FAQ items in blocks. * * @param array $blocks Parsed Gutenberg blocks. * @return int FAQ count. */ private static function count_faq_items( $blocks ) { $count = 0; for ( $i = 0; $i < count( $blocks ); $i++ ) { $block = $blocks[ $i ]; if ( empty( $block['blockName'] ) ) { continue; } // Check for heading blocks ending with ? if ( $block['blockName'] === 'core/heading' ) { $question = self::get_block_text( $block ); if ( ! empty( $question ) && substr( trim( $question ), -1 ) === '?' ) { // Check if next block is a paragraph (answer) if ( isset( $blocks[ $i + 1 ] ) && $blocks[ $i + 1 ]['blockName'] === 'core/paragraph' ) { $count++; } } } // Recursively check inner blocks if ( ! empty( $block['innerBlocks'] ) ) { $count += self::count_faq_items( $block['innerBlocks'] ); } } return $count; } /** * Score service metadata completeness. * * @param int $post_id Post ID. * @return int Score (0-100). */ private static function score_service_metadata( $post_id ) { $fields = array( '_aitk_service_area', '_aitk_service_type', ); $filled = 0; foreach ( $fields as $field ) { if ( ! empty( get_post_meta( $post_id, $field, true ) ) ) { $filled++; } } return round( ( $filled / count( $fields ) ) * 100 ); } /** * Get block text content. * * @param array $block Gutenberg block. * @return string Block text. */ private static function get_block_text( $block ) { if ( empty( $block['innerHTML'] ) ) { return ''; } return wp_strip_all_tags( $block['innerHTML'] ); } /** * Get score weights (with filter support). * * @return array Weights. */ public static function get_weights() { return apply_filters( 'ai_answer_toolkit_score_weights', self::$default_weights ); } /** * Get color based on score. * * @param int $score Score (0-100). * @return string Color (green, yellow, red). */ public static function get_score_color( $score ) { $thresholds = apply_filters( 'ai_answer_toolkit_score_color', array( 'green' => 80, 'yellow' => 50, ) ); if ( $score >= $thresholds['green'] ) { return 'green'; } elseif ( $score >= $thresholds['yellow'] ) { return 'yellow'; } return 'red'; } /** * Get score label based on color. * * @param string $color Score color. * @return string Score label. */ public static function get_score_label( $color ) { $labels = array( 'green' => __( 'Excellent', 'ai-answer-toolkit' ), 'yellow' => __( 'Needs Improvement', 'ai-answer-toolkit' ), 'red' => __( 'Poor', 'ai-answer-toolkit' ), ); return isset( $labels[ $color ] ) ? $labels[ $color ] : __( 'Unknown', 'ai-answer-toolkit' ); } /** * Calculate unified AEO score combining content quality and technical visibility. * * @param int $post_id Post ID. * @return array Unified score data with breakdowns. */ public static function calculate_unified_score( $post_id ) { // Get content quality score (existing score) $content_score_data = self::calculate_score( $post_id ); $content_score = $content_score_data['score']; // Get technical visibility score $visibility_data = AI_Answer_Toolkit_Visibility::check_visibility( $post_id ); $visibility_score = $visibility_data['score']; // Calculate unified score (weighted average) // Content Quality: 70% weight (more important) // Technical Visibility: 30% weight $unified_score = round( ( $content_score * 0.7 ) + ( $visibility_score * 0.3 ) ); // Determine color and label if ( $unified_score >= 80 ) { $color = 'green'; $label = __( 'Excellent', 'ai-answer-toolkit' ); $status = __( 'Your content is fully optimized for AI answer engines', 'ai-answer-toolkit' ); } elseif ( $unified_score >= 50 ) { $color = 'yellow'; $label = __( 'Good', 'ai-answer-toolkit' ); $status = __( 'Your content needs some improvements', 'ai-answer-toolkit' ); } else { $color = 'red'; $label = __( 'Needs Work', 'ai-answer-toolkit' ); $status = __( 'Your content requires immediate attention', 'ai-answer-toolkit' ); } return array( 'unified_score' => $unified_score, 'color' => $color, 'label' => $label, 'status' => $status, 'content_score' => $content_score, 'content_data' => $content_score_data, 'visibility_score' => $visibility_score, 'visibility_data' => $visibility_data, 'content_weight' => 70, 'visibility_weight' => 30, ); } /** * Get score percentage for progress bars. * * @param int $score Score value. * @return int Percentage (0-100). */ public static function get_score_percentage( $score ) { return max( 0, min( 100, (int) $score ) ); } /** * Get FAQ count for a post. * * @param int $post_id Post ID. * @return int FAQ count. */ public static function get_faq_count( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return 0; } $content = get_post_field( 'post_content', $post->ID ); if ( empty( $content ) ) { return 0; } $blocks = parse_blocks( $content ); if ( empty( $blocks ) ) { return 0; } return self::count_faq_items( $blocks ); } }