15, // Direct Answer/Lead (0-15) 'headings_structure' => 15, // Headings & Structure (0-15) 'snippetability' => 10, // Snippetability (0-10) 'faq_presence' => 10, // FAQ Presence (0-10) 'readability' => 15, // Readability (0-15) 'paragraph_length' => 10, // Paragraph Length (0-10) 'sentence_variance' => 10, // Sentence Length variance (0-10) 'entity_coverage' => 10, // Entity/Keyword Coverage (0-10) 'meta_hints' => 5, // Meta Hints (0-5) 'structured_data' => 15 // Structured Data Bonus (0-15) - Can exceed 100 ]; /** * Analyze content and return AEO score with breakdown * * @param string $content Raw post content (HTML) * @param array $meta Meta data (title, description, etc.) * @return array Score analysis with breakdown and issues */ public function analyze($content, $meta = []) { // Clean and prepare content $clean_content = $this->clean_content($content); $text_content = $this->extract_text($clean_content); // Initialize results $breakdown = []; $issues = []; $total_score = 0; // Analyze each factor $direct_answer = $this->analyze_direct_answer($text_content); $breakdown[] = $direct_answer; $total_score += $direct_answer['points']; $headings = $this->analyze_headings_structure($clean_content); $breakdown[] = $headings; $total_score += $headings['points']; $snippetability = $this->analyze_snippetability($clean_content, $text_content); $breakdown[] = $snippetability; $total_score += $snippetability['points']; $faq = $this->analyze_faq_presence($clean_content); $breakdown[] = $faq; $total_score += $faq['points']; $readability = $this->analyze_readability($text_content); $breakdown[] = $readability; $total_score += $readability['points']; $paragraphs = $this->analyze_paragraph_length($clean_content); $breakdown[] = $paragraphs; $total_score += $paragraphs['points']; $sentences = $this->analyze_sentence_variance($text_content); $breakdown[] = $sentences; $total_score += $sentences['points']; $entities = $this->analyze_entity_coverage($text_content, $meta); $breakdown[] = $entities; $total_score += $entities['points']; $meta_hints = $this->analyze_meta_hints($meta); $breakdown[] = $meta_hints; $total_score += $meta_hints['points']; // Analyze structured data (bonus points - can exceed 100) $structured_data = $this->analyze_structured_data($content, $meta); $breakdown[] = $structured_data; $total_score += $structured_data['points']; // Collect issues $issues = $this->collect_issues($breakdown); // Allow exceeding 100 with structured data bonus $final_score = max(0, $total_score); return [ 'score' => $final_score, 'breakdown' => $breakdown, 'issues' => $issues ]; } /** * Clean content by removing Gutenberg blocks and normalizing */ private function clean_content($content) { // Remove Gutenberg block comments $content = preg_replace('//s', '', $content); $content = preg_replace('//s', '', $content); // Remove HTML comments $content = preg_replace('//s', '', $content); // Normalize whitespace $content = preg_replace('/\s+/', ' ', $content); return trim($content); } /** * Extract plain text from HTML content */ private function extract_text($content) { // Remove HTML tags but preserve structure $text = wp_strip_all_tags($content); // Normalize whitespace $text = preg_replace('/\s+/', ' ', $text); return trim($text); } /** * Extract plain text from HTML content while preserving paragraph structure */ private function extract_text_preserve_paragraphs($content) { // Replace paragraph tags with newlines $text = preg_replace('/<\/p>\s*]*>/i', "\n\n", $content); $text = preg_replace('/]*>/i', '', $text); $text = preg_replace('/<\/p>/i', '', $text); // Replace other block elements with newlines $text = preg_replace('/<\/(div|section|article|header|footer|main|aside)[^>]*>/i', "\n\n", $text); $text = preg_replace('/<(div|section|article|header|footer|main|aside)[^>]*>/i', '', $text); // Replace
tags with newlines $text = preg_replace('/]*\/?>/i', "\n", $text); // Remove remaining HTML tags $text = wp_strip_all_tags($text); // Normalize multiple newlines to double newlines $text = preg_replace('/\n{3,}/', "\n\n", $text); return trim($text); } /** * Analyze direct answer in first ~120 words */ private function analyze_direct_answer($text) { $words = explode(' ', $text); $first_120 = array_slice($words, 0, 120); $lead_text = implode(' ', $first_120); $score = 0; $notes = []; // Check for question-answering patterns if (preg_match('/\b(what|how|why|when|where|which|who)\b/i', $lead_text)) { $score += 5; $notes[] = 'Contains question words in lead'; } // Check for direct statements if (preg_match('/\b(is|are|means|refers to|defined as)\b/i', $lead_text)) { $score += 5; $notes[] = 'Contains definitional language'; } // Check for value proposition if (preg_match('/\b(benefits?|advantages?|features?|solutions?)\b/i', $lead_text)) { $score += 5; $notes[] = 'Promises value in lead'; } $max = self::$AEO_WEIGHTS['direct_answer']; $points = min($max, $score); return [ 'key' => 'direct_answer', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Analyze headings and structure */ private function analyze_headings_structure($content) { $score = 0; $notes = []; // Count headings $h1_count = preg_match_all('/]*>/i', $content); $h1_count = ($h1_count === false) ? 0 : (int)$h1_count; $h2_count = preg_match_all('/]*>/i', $content); $h2_count = ($h2_count === false) ? 0 : (int)$h2_count; $h3_count = preg_match_all('/]*>/i', $content); $h3_count = ($h3_count === false) ? 0 : (int)$h3_count; $list_count = preg_match_all('/<(ul|ol)[^>]*>/i', $content); $list_count = ($list_count === false) ? 0 : (int)$list_count; // H1 presence if ($h1_count > 0) { $score += 5; $notes[] = 'H1 heading present'; } else { $notes[] = 'No H1 heading found'; } // H2/H3 density with hierarchy check $total_headings = $h2_count + $h3_count; $hierarchy_valid = $this->check_heading_hierarchy($content); if ($total_headings >= 3 && $hierarchy_valid) { $score += 5; $notes[] = "Excellent heading structure ($total_headings H2/H3, proper hierarchy)"; } elseif ($total_headings >= 3) { $score += 4; $notes[] = "Good heading count ($total_headings H2/H3, improve hierarchy)"; } elseif ($total_headings > 0) { $score += 2; $notes[] = "Some headings present ($total_headings H2/H3, add more)"; } else { $notes[] = 'No H2/H3 headings found'; } // Lists usage if ($list_count >= 2) { $score += 5; $notes[] = "Good list usage ($list_count lists)"; } elseif ($list_count > 0) { $score += 3; $notes[] = "Some lists present ($list_count lists)"; } else { $notes[] = 'No lists found'; } $max = self::$AEO_WEIGHTS['headings_structure']; $points = min($max, $score); return [ 'key' => 'headings_structure', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Check if heading hierarchy is logical (H2 before H3, no skipping levels) */ private function check_heading_hierarchy($content) { // Extract all headings with their level preg_match_all('/]*>.*?<\/h\1>/is', $content, $matches); if (empty($matches[1])) { return true; // No headings = no hierarchy issues } $levels = array_map('intval', $matches[1]); $previous_level = 1; // Start assuming H1 exists foreach ($levels as $current_level) { // Check if we're skipping levels (e.g., H2 → H4) if ($current_level > $previous_level + 1) { return false; // Bad hierarchy: skipped a level } $previous_level = min($current_level, $previous_level + 1); } return true; // Hierarchy is valid } /** * Analyze snippetability (TL;DR presence) - Enhanced detection */ private function analyze_snippetability($html_content, $text_content) { $score = 0; $notes = []; // Enhanced TL;DR and summary patterns $summary_patterns = [ '/\b(tl;?dr|summary|in summary|to summarize|key takeaway|bottom line|main points|overview)\b/i', '/\b(here\'s what you need to know|the bottom line is|in a nutshell|to sum up)\b/i', '/\b(key findings|main takeaways|essential points|quick summary)\b/i' ]; $summary_found = false; foreach ($summary_patterns as $pattern) { if (preg_match($pattern, $text_content)) { $summary_found = true; break; } } if ($summary_found) { $score += 6; $notes[] = 'Contains clear summary indicators'; } // Check for short, punchy sentences at beginning (enhanced) $sentences = preg_split('/[.!?]+/', $text_content); $short_sentences = 0; $concise_sentences = 0; foreach (array_slice($sentences, 0, 5) as $sentence) { $word_count = str_word_count(trim($sentence)); if ($word_count >= 5 && $word_count <= 20) { $short_sentences++; } if ($word_count >= 3 && $word_count <= 15) { $concise_sentences++; } } if ($concise_sentences >= 3) { $score += 4; $notes[] = 'Excellent opening sentence structure (concise)'; } elseif ($short_sentences >= 2) { $score += 3; $notes[] = 'Good opening sentence structure'; } else { $notes[] = 'Opening sentences could be more concise'; } // Check for bullet points or numbered lists (snippetable content) // Check HTML lists in original content $has_html_lists = preg_match('/]*>|]*>/i', $html_content); // Check plaintext list markers in text content $has_text_lists = preg_match('/^\s*[-*•]\s|^\s*\d+\.\s/m', $text_content); if ($has_html_lists || $has_text_lists) { $score += 2; $notes[] = 'Contains list formatting for easy scanning'; } $max = self::$AEO_WEIGHTS['snippetability']; $points = min($max, $score); return [ 'key' => 'snippetability', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Analyze FAQ presence */ private function analyze_faq_presence($content) { $score = 0; $notes = []; // Enhanced FAQ detection patterns with metadata $faq_patterns = [ // H2 FAQ section ['pattern' => '/]*>.*?faq.*?<\/h2>/i', 'type' => 'faq_section'], // H3 Q: patterns ['pattern' => '/]*>.*?q:\s*.*?<\/h3>/i', 'type' => 'qa_pair'], // Q: and A: patterns ['pattern' => '/]*>.*?q:\s*.*?<\/h[1-6]>/i', 'type' => 'qa_pair'], ['pattern' => '/]*>.*?a:\s*.*?<\/p>/i', 'type' => 'qa_pair'], // Question patterns in headings ['pattern' => '/]*>.*\?.*<\/h[1-6]>/i', 'type' => 'question'], // FAQ Schema detection ['pattern' => '/]*type=["\']application\/ld\+json["\'][^>]*>.*?"@type":\s*"FAQPage".*?<\/script>/is', 'type' => 'faq_section'] ]; $faq_count = 0; $has_faq_section = false; $has_qa_pairs = false; foreach ($faq_patterns as $pattern_data) { $pattern = $pattern_data['pattern']; $type = $pattern_data['type']; $matches = preg_match_all($pattern, $content); $faq_count += $matches; if ($type === 'faq_section' && $matches > 0) { $has_faq_section = true; } if ($type === 'qa_pair' && $matches > 0) { $has_qa_pairs = true; } } // Calculate content length to scale FAQ requirements $word_count = str_word_count(wp_strip_all_tags($content)); $required_faqs = $this->calculate_required_faq_count($word_count); // Enhanced scoring based on FAQ structure and count relative to content length if ($has_faq_section && $has_qa_pairs && $faq_count >= $required_faqs) { $score = 10; $notes[] = "Excellent FAQ section with proper Q&A structure ($faq_count elements for {$word_count} words)"; } elseif ($has_faq_section && $faq_count >= ceil($required_faqs * 0.75)) { $score = 8; $notes[] = "Good FAQ section ($faq_count elements, {$required_faqs} recommended for {$word_count} words)"; } elseif ($has_qa_pairs && $faq_count >= ceil($required_faqs * 0.5)) { $score = 6; $notes[] = "Some FAQ Q&A pairs ($faq_count elements, {$required_faqs} recommended)"; } elseif ($faq_count >= 2) { $score = 4; $notes[] = "Basic FAQ elements ($faq_count patterns, add more for {$word_count} words)"; } elseif ($faq_count >= 1) { $score = 2; $notes[] = "Limited FAQ content ($faq_count patterns)"; } else { $notes[] = 'No FAQ content detected'; } $max = self::$AEO_WEIGHTS['faq_presence']; $points = min($max, $score); return [ 'key' => 'faq_presence', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Calculate required FAQ count based on content length */ private function calculate_required_faq_count($word_count) { if ($word_count < 800) { return 4; // Short posts: 4 FAQs for full points } elseif ($word_count < 2000) { return 6; // Medium posts: 6 FAQs for full points } else { return 10; // Long posts: 10 FAQs for full points } } /** * Analyze structured data presence (JSON-LD schema) * This is a BONUS scoring factor that can push scores above 100 */ private function analyze_structured_data($content, $meta) { $score = 0; $notes = []; // Check for Article/BlogPosting schema if (preg_match('/"@type":\s*"(?:Article|BlogPosting)"/i', $content)) { $score += 5; $notes[] = 'Article schema present (+5 bonus)'; } // Check for FAQ schema if (preg_match('/"@type":\s*"FAQPage"/i', $content)) { $score += 5; $notes[] = 'FAQ schema present (+5 bonus)'; } // Check for How-To schema if (preg_match('/"@type":\s*"HowTo"/i', $content)) { $score += 5; $notes[] = 'HowTo schema present (+5 bonus)'; } // Check for author/publisher info in schema if (preg_match('/"author":\s*\{/i', $content) && preg_match('/"publisher":\s*\{/i', $content)) { $score += 3; $notes[] = 'Complete author/publisher info (+3 bonus)'; } elseif (preg_match('/"author":\s*\{/i', $content) || preg_match('/"publisher":\s*\{/i', $content)) { $score += 1; $notes[] = 'Partial author/publisher info (+1 bonus)'; } // Check for image object in schema if (preg_match('/"image":\s*(?:\[|"|\{)/i', $content)) { $score += 1; $notes[] = 'Image schema present (+1 bonus)'; } // Check for video schema if (preg_match('/"@type":\s*"VideoObject"/i', $content)) { $score += 3; $notes[] = 'Video schema present (+3 bonus)'; } if ($score === 0) { $notes[] = 'No structured data detected (add JSON-LD schema for bonus points)'; } $max = self::$AEO_WEIGHTS['structured_data']; $points = min($max, $score); return [ 'key' => 'structured_data', 'points' => $points, 'max' => $max, 'notes' => $notes, 'is_bonus' => true // Flag to indicate this is bonus scoring ]; } /** * Analyze readability (Flesch-Kincaid Grade Level) */ private function analyze_readability($text) { $score = 0; $notes = []; $fkgl = $this->calculate_fkgl($text); if ($fkgl >= 7 && $fkgl <= 10) { $score += 15; $notes[] = "Optimal readability (FKGL: $fkgl)"; } elseif ($fkgl >= 5 && $fkgl <= 12) { $score += 10; $notes[] = "Good readability (FKGL: $fkgl)"; } elseif ($fkgl > 18) { $score += 2; $notes[] = "Too complex (FKGL: $fkgl)"; } elseif ($fkgl < 4) { $score += 3; $notes[] = "Too simple (FKGL: $fkgl)"; } else { $score += 7; $notes[] = "Acceptable readability (FKGL: $fkgl)"; } $max = self::$AEO_WEIGHTS['readability']; $points = min($max, $score); return [ 'key' => 'readability', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Calculate Flesch-Kincaid Grade Level */ private function calculate_fkgl($text) { $sentences = preg_split('/[.!?]+/', $text); $sentences = array_filter($sentences, function($s) { return trim($s) !== ''; }); $sentence_count = count($sentences); if ($sentence_count === 0) return 0; $words = str_word_count($text); $syllables = $this->count_syllables($text); if ($words === 0) return 0; $fkgl = 0.39 * ($words / $sentence_count) + 11.8 * ($syllables / $words) - 15.59; return max(0, round($fkgl, 1)); } /** * Count syllables in text (approximation) */ private function count_syllables($text) { $words = str_word_count($text, 1); $syllables = 0; foreach ($words as $word) { $syllables += $this->count_word_syllables($word); } return $syllables; } /** * Count syllables in a single word */ private function count_word_syllables($word) { $word = strtolower($word); $vowels = 'aeiouy'; $syllables = 0; $previous_was_vowel = false; for ($i = 0; $i < strlen($word); $i++) { $is_vowel = strpos($vowels, $word[$i]) !== false; if ($is_vowel && !$previous_was_vowel) { $syllables++; } $previous_was_vowel = $is_vowel; } // Handle silent 'e' if (substr($word, -1) === 'e') { $syllables--; } return max(1, $syllables); } /** * Analyze paragraph length */ private function analyze_paragraph_length($content) { $score = 0; $notes = []; // Extract text while preserving paragraph structure $text = $this->extract_text_preserve_paragraphs($content); // Split into paragraphs $paragraphs = preg_split('/\n\s*\n/', $text); $paragraphs = array_filter($paragraphs, function($p) { return trim($p) !== ''; }); if (empty($paragraphs)) { $notes[] = 'No paragraphs found'; return [ 'key' => 'paragraph_length', 'points' => 0, 'max' => self::$AEO_WEIGHTS['paragraph_length'], 'notes' => $notes ]; } $word_counts = array_map('str_word_count', $paragraphs); $avg_length = array_sum($word_counts) / count($word_counts); if ($avg_length >= 60 && $avg_length <= 140) { $score += 10; $notes[] = "Optimal paragraph length (avg: " . round($avg_length) . " words)"; } elseif ($avg_length >= 40 && $avg_length <= 180) { $score += 7; $notes[] = "Good paragraph length (avg: " . round($avg_length) . " words)"; } elseif ($avg_length > 180) { $score += 3; $notes[] = "Paragraphs too long (avg: " . round($avg_length) . " words)"; } else { $score += 5; $notes[] = "Paragraphs too short (avg: " . round($avg_length) . " words)"; } $max = self::$AEO_WEIGHTS['paragraph_length']; $points = min($max, $score); return [ 'key' => 'paragraph_length', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Analyze sentence length variance */ private function analyze_sentence_variance($text) { $score = 0; $notes = []; $sentences = preg_split('/[.!?]+/', $text); $sentences = array_filter($sentences, function($s) { return trim($s) !== ''; }); if (count($sentences) < 3) { $notes[] = 'Not enough sentences to analyze variance'; return [ 'key' => 'sentence_variance', 'points' => 0, 'max' => self::$AEO_WEIGHTS['sentence_variance'], 'notes' => $notes ]; } $word_counts = array_map('str_word_count', $sentences); $short = 0; $medium = 0; $long = 0; foreach ($word_counts as $count) { if ($count <= 10) $short++; elseif ($count <= 20) $medium++; else $long++; } $total = count($word_counts); $variance_score = 0; if ($short > 0) $variance_score += 3; if ($medium > 0) $variance_score += 3; if ($long > 0) $variance_score += 4; $score = $variance_score; $notes[] = "Sentence mix: $short short, $medium medium, $long long"; $max = self::$AEO_WEIGHTS['sentence_variance']; $points = min($max, $score); return [ 'key' => 'sentence_variance', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Analyze entity/keyword coverage */ private function analyze_entity_coverage($text, $meta) { $score = 0; $notes = []; // Extract potential keywords from title and meta $target_terms = []; if (!empty($meta['title'])) { $title_words = str_word_count(strtolower($meta['title']), 1); $target_terms = array_merge($target_terms, $title_words); } if (!empty($meta['keywords'])) { $keyword_words = explode(',', strtolower($meta['keywords'])); $target_terms = array_merge($target_terms, array_map('trim', $keyword_words)); } $text_lower = strtolower($text); $coverage_count = 0; foreach ($target_terms as $term) { // Ensure term is a non-empty string if (!is_string($term) || empty(trim($term))) { continue; } // Use multibyte-safe string length and word boundary matching if (mb_strlen($term) > 3) { $escaped_term = preg_quote($term, '/'); if (preg_match('/\b' . $escaped_term . '\b/i', $text_lower)) { $coverage_count++; } } } if (!empty($target_terms)) { $coverage_ratio = $coverage_count / count($target_terms); if ($coverage_ratio >= 0.8) { $score += 10; $notes[] = "Excellent keyword coverage (" . round($coverage_ratio * 100) . "%)"; } elseif ($coverage_ratio >= 0.6) { $score += 7; $notes[] = "Good keyword coverage (" . round($coverage_ratio * 100) . "%)"; } elseif ($coverage_ratio >= 0.4) { $score += 5; $notes[] = "Moderate keyword coverage (" . round($coverage_ratio * 100) . "%)"; } else { $notes[] = "Limited keyword coverage (" . round($coverage_ratio * 100) . "%)"; } } else { $notes[] = 'No target keywords identified'; } $max = self::$AEO_WEIGHTS['entity_coverage']; $points = min($max, $score); return [ 'key' => 'entity_coverage', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Analyze meta hints (title and description length) */ private function analyze_meta_hints($meta) { $score = 0; $notes = []; // Title length if (!empty($meta['title'])) { $title_length = strlen($meta['title']); if ($title_length >= 35 && $title_length <= 65) { $score += 3; $notes[] = "Optimal title length ($title_length chars)"; } elseif ($title_length >= 25 && $title_length <= 75) { $score += 2; $notes[] = "Good title length ($title_length chars)"; } else { $notes[] = "Title length needs adjustment ($title_length chars)"; } } else { $notes[] = 'No title provided'; } // Meta description length if (!empty($meta['description'])) { $desc_length = strlen($meta['description']); if ($desc_length >= 120 && $desc_length <= 160) { $score += 2; $notes[] = "Optimal meta description length ($desc_length chars)"; } elseif ($desc_length >= 100 && $desc_length <= 180) { $score += 1; $notes[] = "Good meta description length ($desc_length chars)"; } else { $notes[] = "Meta description length needs adjustment ($desc_length chars)"; } } else { $notes[] = 'No meta description provided'; } $max = self::$AEO_WEIGHTS['meta_hints']; $points = min($max, $score); return [ 'key' => 'meta_hints', 'points' => $points, 'max' => $max, 'notes' => $notes ]; } /** * Collect issues from breakdown */ private function collect_issues($breakdown) { $issues = []; foreach ($breakdown as $factor) { $key = $factor['key']; $points = $factor['points']; $max = $factor['max']; // Add issues based on low scores if ($points < $max * 0.5) { switch ($key) { case 'direct_answer': $issues[] = 'weak_lead'; break; case 'headings_structure': $issues[] = 'poor_structure'; break; case 'snippetability': $issues[] = 'no_tldr'; break; case 'faq_presence': $issues[] = 'no_faq'; break; case 'readability': $issues[] = 'readability_issues'; break; case 'paragraph_length': $issues[] = 'long_paras'; break; case 'sentence_variance': $issues[] = 'monotonous_sentences'; break; case 'entity_coverage': $issues[] = 'keyword_gaps'; break; case 'meta_hints': $issues[] = 'meta_issues'; break; } } } return $issues; } }