['good' => [70, 100], 'warn' => [40, 69], 'bad' => [0, 39]],
// Penalty points: Start with 100 points, each failed check deducts points
// Total possible penalties FAR EXCEED 100, so multiple issues drive score to 0
// Higher penalty = more critical issue
'penalties' => [
// CRITICAL Intelligence Checks (AI-powered quality)
'semantic_alignment' => 25, // Title must match content meaning
'grammar_typos' => 20, // Clean writing is essential
'authenticity_originality' => 20, // Original, not AI-like content
// CRITICAL Basics Checks (rule-based technical)
'title_exists' => 40, // Absolutely required
'title_unique_sitewide' => 20, // Duplicate titles are terrible
'schema_integrity' => 18, // Schema present + required fields complete
'internal_links' => 20, // Essential for site structure
'not_orphaned' => 15, // Must be linked from other pages
'structure_quality' => 15, // Heading hierarchy, paragraph length, scannability
'topic_completeness' => 15, // AI analyzes if key subtopics are covered
// VERY IMPORTANT Checks
'alt_coverage' => 15, // Accessibility & SEO
'js_rendered_content' => 15, // Main content must be in HTML, not JS-injected
'intent_fit' => 12, // Content length must fit purpose
'readability_score' => 12, // Content Clarity: structure + lists + sentence clarity
'excerpt_exists' => 10, // Meta description needed
'featured_image' => 10, // Visual presence matters
'personality_engagement' => 10, // Human voice and personal touch
// IMPORTANT Checks
'excerpt_length' => 8, // Proper meta description length
'title_length' => 8, // Title must fit in search results
'content_depth' => 8, // Adequate content needed (post-type based)
// MODERATE Checks
'slug_structure' => 5, // Length + word count combined
'external_link_present' => 5, // Has at least one external link
'meta_robots_tag' => 5, // No accidental noindex/nofollow
'author_visible' => 3,
],
'comfort_zones' => [
'quick' => [150, 500],
'guide' => [700, 1600],
'product' => [300, 900],
'local' => [300, 900],
],
'safeguards' => [
'require_title' => true,
'require_unique_title' => true,
'prevent_unintended_noindex' => true,
'require_schema_when_expected' => true,
'min_semantic_alignment' => 0.35
],
'quality_safeguards_enabled' => true,
];
public function __construct( $core ) {
$this->core = $core;
$this->options = $core->get_all_options();
// Note: Don't check for $mwai here - it's created during plugins_loaded hook
// We'll check at runtime in analyze_ai()
$this->ai_enabled = true; // Will be validated at runtime
// Merge user options with defaults
$this->defaults = apply_filters( 'seo_engine_v2_defaults', $this->defaults );
}
/**
* Main calculation function - analyzes and scores a post
* @param object $post WordPress post object
* @param string $analysis_type 'quick' for basic checks only, 'full' for AI analysis, 'baseline' for technical only
*/
public function calculate( $post, $analysis_type = 'full' ) {
// Collect content signals
$analysis = $this->analyze_post( $post );
// AI stage (only run for 'full' analysis and if AI is enabled)
if ( $analysis_type === 'full' && $this->ai_enabled ) {
$analysis['ai'] = $this->analyze_ai( $post, $analysis );
} else if ( $analysis_type === 'baseline' ) {
// For baseline (tech-step), always use default AI data - don't preserve old AI scores
// This ensures AI steps will properly trigger penalty animations when they run
$analysis['ai'] = $this->get_default_ai_data();
} else {
if ( $analysis_type === 'full' ) {
$analysis['ai'] = $this->get_default_ai_data();
} else {
// Preserve existing AI data from previous Full Analysis
$existing_data = get_post_meta( $post->ID, '_mwseo_analysis', true );
if ( $existing_data && isset( $existing_data['ai'] ) ) {
$analysis['ai'] = $existing_data['ai'];
} else {
$analysis['ai'] = $this->get_default_ai_data();
}
}
}
// Compute test scores
$tests = $this->score_tests( $post, $analysis );
// Detect flags
$flags = $this->detect_flags( $post, $tests );
// Calculate overall score using penalty system
$overall = $this->calculate_score_from_penalties( $tests, $flags );
// Build result
$result = [
'overall' => $overall,
'tests' => $tests,
'ai' => $analysis['ai'],
'flags' => $flags,
'penalties' => $this->calculate_applied_penalties( $tests ),
'max_penalties' => $this->defaults['penalties'], // Max penalty for each test
'word_count' => $analysis['word_count'],
'title_length' => $analysis['title_length'],
'excerpt_length' => $analysis['excerpt_length'],
'version' => 3, // Bumped version for new scoring system
'timestamp' => time(),
'cache_hit' => isset( $analysis['ai']['_cache_hit'] ) ? true : false
];
// Add AI feedback fields to top level for easy frontend access
if ( isset( $analysis['ai'] ) ) {
if ( isset( $analysis['ai']['grammar_feedback'] ) ) {
$result['grammar_feedback'] = $analysis['ai']['grammar_feedback'];
}
if ( isset( $analysis['ai']['topic_feedback'] ) ) {
$result['topic_feedback'] = $analysis['ai']['topic_feedback'];
}
if ( isset( $analysis['ai']['readability_feedback'] ) ) {
$result['readability_feedback'] = $analysis['ai']['readability_feedback'];
}
if ( isset( $analysis['ai']['authenticity_feedback'] ) ) {
$result['authenticity_feedback'] = $analysis['ai']['authenticity_feedback'];
}
if ( isset( $analysis['ai']['personality_feedback'] ) ) {
$result['personality_feedback'] = $analysis['ai']['personality_feedback'];
}
}
return $result;
}
/**
* The title as it appears in search results: the SEO title override when set,
* otherwise the post title with the site name appended. Must stay in sync with
* Meow_MWSEO_Core::build_title(), which is what actually renders on the frontend —
* including the meta key it reads, so the score matches the rendered title.
*/
private function full_page_title( $post ) {
$seo_title = get_post_meta( $post->ID, $this->core->meta_key_seo_title, true );
if ( !empty( $seo_title ) ) {
return $seo_title;
}
return $post->post_title . " | " . trim( get_bloginfo( 'name' ) );
}
/**
* Analyze post content and extract signals
*/
private function analyze_post( $post ) {
$excerpt = get_post_meta( $post->ID, $this->core->meta_key_seo_excerpt, true ) ?: $post->post_excerpt;
$analysis = [
'title' => $post->post_title,
'slug' => $post->post_name,
'excerpt' => $excerpt,
'content' => apply_filters( 'mwseo_post_content', wp_strip_all_tags( $post->post_content ), $post, true ),
'content_html' => apply_filters( 'mwseo_post_content', $post->post_content, $post, false ),
'word_count' => 0,
'title_length' => mb_strlen( $this->full_page_title( $post ) ),
'excerpt_length' => mb_strlen( $excerpt ),
'images' => [],
'links' => ['internal' => [], 'external' => []],
];
// Word count with CJK fallback
$word_count = str_word_count( $analysis['content'] );
// CJK override: If content is mostly CJK and word count is near zero, estimate from character length
if ( $word_count < 10 && $this->core->is_mostly_cjk( $analysis['content'], 0.9 ) ) {
// Estimate word count: each CJK character ≈ 0.6 words (conservative multiplier)
$char_length = mb_strlen( $analysis['content'], 'UTF-8' );
$word_count = max( 1, floor( $char_length * 0.6 ) );
}
$analysis['word_count'] = $word_count;
// Extract images
preg_match_all( '/
]+>/i', $analysis['content_html'], $images );
if ( !empty( $images[0] ) ) {
foreach ( $images[0] as $img ) {
$has_alt = preg_match( '/alt=[\'"]([^\'"]*)[\'"]/', $img, $alt_match );
$analysis['images'][] = [
'tag' => $img,
'alt' => $has_alt ? $alt_match[1] : '',
'has_alt' => $has_alt && !empty( $alt_match[1] )
];
}
}
// Extract links
if ( preg_match_all( '/]+href=[\'"]([^\'"]+)[\'"][^>]*>/i', $analysis['content_html'], $matches ) ) {
// Use the post's permalink to derive the site domain, so that
// multilingual setups (e.g. Polylang with separate domains) correctly
// detect internal links based on the post's own language domain.
$permalink = get_permalink( $post->ID );
$parsed = parse_url( $permalink );
$site_host = isset( $parsed['host'] ) ? $parsed['host'] : parse_url( get_site_url(), PHP_URL_HOST );
foreach ( $matches[1] as $link ) {
$link_trimmed = trim( $link );
// Skip empty or anchor links
if ( empty( $link_trimmed ) || $link_trimmed === '#' || strpos( $link_trimmed, '#' ) === 0 ) {
continue;
}
// Internal: relative links (/, ./, ../), or same domain as the post
$is_relative = ( strpos( $link_trimmed, '/' ) === 0 && strpos( $link_trimmed, '//' ) !== 0 )
|| strpos( $link_trimmed, './' ) === 0
|| strpos( $link_trimmed, '../' ) === 0;
$link_host = parse_url( $link_trimmed, PHP_URL_HOST );
$is_same_domain = $link_host && $link_host === $site_host;
if ( $is_relative || $is_same_domain ) {
$analysis['links']['internal'][] = $link_trimmed;
} elseif ( preg_match( '#^https?://#', $link_trimmed ) ) {
$analysis['links']['external'][] = $link_trimmed;
}
}
}
return $analysis;
}
/**
* Salt mixed into every score cache key. Clearing the cache bumps it, which makes
* all existing keys unreachable at once. The delete pass in the REST handler only
* sees transients stored in the options table, so on sites running an external
* object cache (Redis, Memcached) this salt is what actually invalidates them.
*/
public static function cache_version() {
return (int) get_option( self::CACHE_VERSION_OPTION, 0 );
}
public static function bump_cache_version() {
$version = self::cache_version() + 1;
update_option( self::CACHE_VERSION_OPTION, $version, false );
return $version;
}
/**
* AI analysis - semantic alignment, intent, summary, entities
*/
private function analyze_ai( $post, $analysis ) {
global $mwai;
$ai_data = [
'summary' => '',
'confidence' => 0.0,
'intent' => 'unknown',
'entities' => [],
'semantic_alignment' => 0.0,
];
if ( !$mwai ) {
return $ai_data;
}
if ( !method_exists( $mwai, 'simpleTextQuery' ) ) {
return $ai_data;
}
// The AI "Content Intelligence" checks (uniqueness / point-of-view, grammar, etc.) are a
// Pro feature. Even when the options are enabled, they only run when the premium add-on is
// active. Resolve the effective booleans once and reuse them for the cache key + execution.
$is_pro = (bool) $this->core->pro;
$check_grammar = $is_pro && $this->core->get_option( 'check_grammar_typos', false );
$check_authenticity = $is_pro && $this->core->get_option( 'check_authenticity_originality', false );
$check_personality = $is_pro && $this->core->get_option( 'check_personality_engagement', false );
$check_structure = $is_pro && $this->core->get_option( 'check_structure_quality', false );
$check_readability = $is_pro && $this->core->get_option( 'check_readability_score', false );
$check_topic = $is_pro && $this->core->get_option( 'check_topic_completeness', false );
// PERFORMANCE FIX: Cache AI analysis based on content hash
// Generate cache key based on full raw content + enabled checks
// Using full content ensures any change (even adding a link) invalidates cache
$cache_key_data = [
'title' => $analysis['title'],
'content' => $analysis['content'], // Full content for accurate change detection
'content_html' => $analysis['content_html'], // Include HTML to detect link changes
'checks' => [
'grammar' => $check_grammar,
'authenticity' => $check_authenticity,
'personality' => $check_personality,
'structure' => $check_structure,
'readability' => $check_readability,
'topic' => $check_topic,
],
// Bumped by Clear Score Cache, which retires every existing key at once.
'cache_version' => self::cache_version(),
];
$content_hash = md5( json_encode( $cache_key_data ) );
$cache_key = 'seo_engine_ai_' . $post->ID . '_' . $content_hash;
// Try to get cached results (7 day expiration)
$cached_ai_data = get_transient( $cache_key );
if ( $cached_ai_data !== false && is_array( $cached_ai_data ) ) {
$cached_ai_data['_cache_hit'] = true;
return $cached_ai_data;
}
try {
// Generate summary and detect intent
$summary_prompt = "Analyze this content and provide:\n1. A one-sentence summary (max 90 chars)\n2. Content intent (choose one: QuickAnswer, Guide, HowTo, Product, Local, News, or General)\n3. Key entities/topics (max 5)\n\nTitle: {$analysis['title']}\nContent: " . $this->truncate_at_sentence( $analysis['content'], 1000 ) . "\n\nRespond in JSON format: {\"summary\": \"...\", \"intent\": \"...\", \"entities\": [...], \"confidence\": 0.0-1.0}";
$response = $mwai->simpleTextQuery( $summary_prompt );
// Remove markdown code blocks if present
$response = preg_replace( '/```json\s*/', '', $response );
$response = preg_replace( '/```\s*$/', '', $response );
$response = trim( $response );
// Try to parse JSON from response
$summary_result = json_decode( $response, true );
if ( $summary_result && is_array( $summary_result ) ) {
$ai_data['summary'] = $summary_result['summary'] ?? '';
$ai_data['intent'] = $summary_result['intent'] ?? 'General';
$ai_data['entities'] = $summary_result['entities'] ?? [];
$ai_data['confidence'] = floatval( $summary_result['confidence'] ?? 0.5 );
}
// Calculate semantic alignment (title/excerpt vs content)
$ai_data['semantic_alignment'] = $this->calculate_semantic_alignment( $post, $analysis );
// Calculate recommended content length based on intent
$ai_data['recommended_length'] = $this->calculate_recommended_length( $ai_data['intent'], $analysis['word_count'] );
// Check grammar and typos if enabled (Pro)
if ( $check_grammar ) {
$grammar_result = $this->analyze_grammar( $analysis );
$ai_data['grammar_score'] = $grammar_result['score'];
$ai_data['grammar_feedback'] = $grammar_result['feedback'];
}
// Check authenticity & originality if enabled (Pro)
if ( $check_authenticity ) {
$authenticity_result = $this->analyze_authenticity_originality( $analysis );
$ai_data['authenticity_score'] = $authenticity_result['score'];
$ai_data['authenticity_feedback'] = $authenticity_result['feedback'];
}
// Check personality & engagement if enabled (Pro)
if ( $check_personality ) {
$personality_result = $this->analyze_personality_engagement( $analysis );
$ai_data['personality_score'] = $personality_result['score'];
$ai_data['personality_feedback'] = $personality_result['feedback'];
}
// Check structure quality if enabled (Pro)
if ( $check_structure ) {
$ai_data['structure_score'] = $this->analyze_structure_quality( $analysis );
}
// Check readability score if enabled (Pro)
if ( $check_readability ) {
$readability_result = $this->analyze_readability( $analysis );
$ai_data['readability_score'] = $readability_result['score'];
$ai_data['readability_feedback'] = $readability_result['feedback'];
}
// Check topic completeness if enabled (Pro)
if ( $check_topic ) {
$topic_result = $this->analyze_topic_completeness( $analysis );
$ai_data['topic_completeness'] = $topic_result['score'];
$ai_data['topic_feedback'] = $topic_result['feedback'];
}
} catch ( Exception $e ) {
error_log( 'SEO Engine AI Analysis Error: ' . $e->getMessage() );
}
// Cache the AI analysis results for 7 days (604800 seconds)
// Allow filtering the cache duration
$cache_duration = apply_filters( 'seo_engine_ai_cache_duration', 7 * DAY_IN_SECONDS );
set_transient( $cache_key, $ai_data, $cache_duration );
return $ai_data;
}
/**
* Calculate semantic alignment between title/meta and content
* Uses AI to determine if title accurately represents content
*/
private function calculate_semantic_alignment( $post, $analysis ) {
global $mwai;
try {
$prompt = "Rate how well this title matches the main topic and content (0.0 to 1.0):\n\nTitle: {$analysis['title']}\n\nContent preview: " . $this->truncate_at_sentence( $analysis['content'], 1000 ) . "\n\nRate from 0.0 (completely unrelated) to 1.0 (perfectly aligned). Be generous - if the title accurately describes what the content is about, give 0.9 or higher. Only give low scores if the title is misleading or about a different topic. Respond with ONLY a number.";
$result = $mwai->simpleFastTextQuery( $prompt );
$score = floatval( trim( $result ) );
// Ensure it's between 0 and 1
return max( 0.0, min( 1.0, $score ) );
} catch ( Exception $e ) {
// Fallback: simple keyword overlap
return $this->simple_semantic_alignment( $analysis );
}
}
/**
* Simple fallback semantic alignment (keyword overlap)
*/
private function simple_semantic_alignment( $analysis ) {
$title_words = array_filter( explode( ' ', strtolower( $analysis['title'] ) ), function( $w ) {
return strlen( $w ) > 3; // Only words longer than 3 chars
});
$content_lower = strtolower( $analysis['content'] );
$matches = 0;
foreach ( $title_words as $word ) {
if ( strpos( $content_lower, $word ) !== false ) {
$matches++;
}
}
return count( $title_words ) > 0 ? $matches / count( $title_words ) : 0.5;
}
/**
* Format AI feedback: clean up and limit length (keep markdown)
*/
private function format_ai_feedback( $text ) {
if ( empty( $text ) ) {
return '';
}
if ( is_array( $text ) ) {
$text = implode( ' ', $text );
}
// Ensure $text is a string before processing
$text = (string) $text;
// Remove markdown headers (###, ##, #)
$text = preg_replace( '/^#{1,6}\s+/m', '', $text );
// Remove bullet points/list markers (-, *, •) but keep the text
$text = preg_replace( '/^[\-\*•]\s+/m', '', $text );
// Remove numbered list markers (1., 2., etc.)
$text = preg_replace( '/^\d+[\.)]\s+/m', '', $text );
// Clean up excessive whitespace
$text = preg_replace( '/\s+/', ' ', $text );
// Limit to approximately 150 characters to ensure complete sentences
if ( strlen( $text ) > 150 ) {
$text = substr( $text, 0, 150 );
// Try to cut at last period
$last_period = strrpos( $text, '.' );
if ( $last_period !== false && $last_period > 80 ) {
$text = substr( $text, 0, $last_period + 1 );
} else {
// Try to cut at last comma
$last_comma = strrpos( $text, ',' );
if ( $last_comma !== false && $last_comma > 80 ) {
$text = substr( $text, 0, $last_comma ) . '...';
} else {
$text .= '...';
}
}
}
return trim( $text );
}
/**
* Truncate content at a sentence boundary to avoid feeding incomplete sentences to AI.
*/
private function truncate_at_sentence( $text, $max_chars = 1500 ) {
if ( mb_strlen( $text ) <= $max_chars ) {
return $text;
}
$truncated = mb_substr( $text, 0, $max_chars );
// Find last sentence-ending punctuation (.!?。))
$last = max(
mb_strrpos( $truncated, '.' ) ?: 0,
mb_strrpos( $truncated, '!' ) ?: 0,
mb_strrpos( $truncated, '?' ) ?: 0,
mb_strrpos( $truncated, '。' ) ?: 0
);
// Only cut at sentence boundary if we keep at least 60% of the text
if ( $last > $max_chars * 0.6 ) {
return mb_substr( $truncated, 0, $last + 1 );
}
return $truncated;
}
/**
* Analyze grammar and typos in content using AI
*/
private function analyze_grammar( $analysis ) {
global $mwai;
if ( !$mwai ) {
return ['score' => 'NA', 'feedback' => ''];
}
try {
// Sample content for analysis, truncated at sentence boundary
$content_sample = $this->truncate_at_sentence( $analysis['content'], 1500 );
if ( empty( $content_sample ) ) {
return ['score' => 100, 'feedback' => '']; // No content to analyze
}
$prompt = "Grammar check. Score 0-100. Only flag clear mistakes: misspellings, broken syntax, wrong conjugations, missing words. Ignore stylistic choices, hyphenation preferences, word choice opinions, and formatting. The text may be truncated — do NOT flag the last sentence as incomplete. If < 80, list 2 issues max (e.g., 'Typo: teh→the'). Very brief.\n\nText:\n{$content_sample}\n\nJSON: {\"score\": X, \"feedback\": \"...\"}";
$response = $mwai->simpleFastTextQuery( $prompt );
$response = trim( $response );
// Remove markdown code blocks if present
$response = preg_replace( '/```json\s*/', '', $response );
$response = preg_replace( '/```\s*$/', '', $response );
$response = trim( $response );
$result = json_decode( $response, true );
if ( $result && is_array( $result ) && isset( $result['score'] ) ) {
$score = intval( $result['score'] );
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' );
if ( $score < 0 || $score > 100 ) {
return ['score' => 'NA', 'feedback' => ''];
}
return ['score' => $score, 'feedback' => $feedback];
}
return ['score' => 'NA', 'feedback' => ''];
} catch ( Exception $e ) {
return ['score' => 'NA', 'feedback' => ''];
}
}
/**
* Analyze authenticity & originality using AI.
*
* Google's May 2026 AI Optimization Guide is explicit: "non-commodity"
* content with a "unique point of view that stands out" is what wins.
* The contrast Google gives is "7 Tips for First-Time Homebuyers"
* (commodity, recycled) vs. "Why We Waived the Inspection & Saved Money"
* (first-hand experience). This prompt scores on that exact axis, with
* AI-template phrasing as a secondary penalty.
*/
private function analyze_authenticity_originality( $analysis ) {
global $mwai;
if ( !$mwai ) {
return ['score' => 'NA', 'feedback' => ''];
}
try {
$content_sample = $this->truncate_at_sentence( $analysis['content'], 1500 );
if ( empty( $content_sample ) ) {
return ['score' => 100, 'feedback' => ''];
}
$prompt = "Score this post 0-100 on the commodity ↔ first-hand axis defined by Google's AI Optimization Guide:\n"
. "- 100: unique point of view, first-hand experience, specific details, real anecdotes (e.g. 'Why We Waived the Inspection & Saved Money')\n"
. "- 50: solid general advice, but mostly things anyone could write from research (e.g. '7 Tips for First-Time Homebuyers')\n"
. "- 0: recycled commodity content, full of AI-template phrases like 'In today's fast-paced world' or 'It's important to note that'\n"
. "Creative, literary, or poetic writing counts as first-hand. Be honest, not generous — generic content is the norm and should score in the 40-60 range.\n"
. "If < 70, give one sentence of feedback naming the specific weakness (commodity framing, missing personal angle, AI-template phrases, etc).\n\n"
. "Text:\n{$content_sample}\n\nJSON: {\"score\": X, \"feedback\": \"...\"}";
$response = $mwai->simpleFastTextQuery( $prompt );
$response = trim( $response );
// Remove markdown code blocks if present
$response = preg_replace( '/```json\s*/', '', $response );
$response = preg_replace( '/```\s*$/', '', $response );
$response = trim( $response );
$result = json_decode( $response, true );
if ( $result && is_array( $result ) && isset( $result['score'] ) ) {
$score = intval( $result['score'] );
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' );
if ( $score < 0 || $score > 100 ) {
return ['score' => 'NA', 'feedback' => ''];
}
return ['score' => $score, 'feedback' => $feedback];
}
return ['score' => 'NA', 'feedback' => ''];
} catch ( Exception $e ) {
return ['score' => 'NA', 'feedback' => ''];
}
}
/**
* Analyze personality & engagement using AI
* Checks for human voice, personal touch, emotional connection
*/
private function analyze_personality_engagement( $analysis ) {
global $mwai;
if ( !$mwai ) {
return ['score' => 'NA', 'feedback' => ''];
}
try {
$content_sample = $this->truncate_at_sentence( $analysis['content'], 1500 );
if ( empty( $content_sample ) ) {
return ['score' => 100, 'feedback' => ''];
}
$prompt = "Personality check. Score 0-100. If < 70, list 2 suggestions max. Very brief.\n\nText:\n{$content_sample}\n\nJSON: {\"score\": X, \"feedback\": \"...\"}";
$response = $mwai->simpleFastTextQuery( $prompt );
$response = trim( $response );
// Remove markdown code blocks if present
$response = preg_replace( '/```json\s*/', '', $response );
$response = preg_replace( '/```\s*$/', '', $response );
$response = trim( $response );
$result = json_decode( $response, true );
if ( $result && is_array( $result ) && isset( $result['score'] ) ) {
$score = intval( $result['score'] );
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' );
if ( $score < 0 || $score > 100 ) {
return ['score' => 'NA', 'feedback' => ''];
}
return ['score' => $score, 'feedback' => $feedback];
}
return ['score' => 'NA', 'feedback' => ''];
} catch ( Exception $e ) {
return ['score' => 'NA', 'feedback' => ''];
}
}
/**
* Analyze content structure quality using AI
* Checks for heading hierarchy, paragraph length, scannability
*/
private function analyze_structure_quality( $analysis ) {
global $mwai;
if ( !$mwai ) {
return 'NA';
}
try {
// Use HTML content to analyze structure
$content_html = $analysis['content_html'];
if ( empty( $content_html ) ) {
return 100;
}
// Strip Gutenberg block comments ( / )
// so the AI sees actual HTML structure, not editor noise.
$clean_html = preg_replace( '/\s*/', '', $content_html );
$clean_html = trim( $clean_html );
if ( empty( $clean_html ) ) {
return 100;
}
$content_sample = mb_substr( $clean_html, 0, 3000, 'UTF-8' );
$prompt = "Analyze this HTML content structure. Rate from 0-100. Only penalize real problems:\n\n- Wall of text: no paragraphs or headings at all\n- Extremely long paragraphs (300+ words without a break)\n- Completely missing subheadings in long content (1000+ words)\n\nDo NOT penalize:\n- Articles that use normal paragraph lengths (even 100-200 words)\n- Content without bullet lists (lists are not required)\n- Literary, editorial, or photo-essay writing styles\n- Content that simply has fewer headings if paragraphs are reasonable\n\nMost well-structured articles should score 80+. Only give below 60 for genuinely hard-to-read walls of text.\n\nHTML:\n{$content_sample}\n\nRespond with ONLY a number between 0 and 100.";
$result = $mwai->simpleFastTextQuery( $prompt );
$result = trim( $result );
$score = intval( $result );
if ( $score < 0 || $score > 100 ) {
return 'NA';
}
return $score;
} catch ( Exception $e ) {
return 'NA';
}
}
/**
* Score Content Clarity (human skimmability).
*
* Delegates to Meow_MWSEO_Modules_Readability. The class name there is kept
* for backwards compatibility (it used to be Flesch), but the actual scoring
* is now structure + lists + sentence clarity — written for human readers
* per Google's May 2026 AI guidance (which explicitly says no AI-specific
* chunking is needed). Optional AI feedback appended on low scores when
* AI Engine is available.
*/
private function analyze_readability( $analysis ) {
global $mwseo_readability, $mwai;
$content = $analysis['content'];
$content_html = $analysis['content_html'];
if ( empty( $content ) ) {
return [ 'score' => 100, 'feedback' => '' ];
}
// Defensive fallback if the global isn't wired (shouldn't happen in practice).
if ( !$mwseo_readability ) {
require_once dirname( __FILE__ ) . '/modules/readability.php';
$mwseo_readability = new Meow_MWSEO_Modules_Readability();
}
$result = $mwseo_readability->calculate_readability( $content_html );
$score = (int) ( $result['score'] ?? 0 );
// Build feedback from the suggestions the module produced.
$feedback = '';
if ( !empty( $result['suggestions'] ) ) {
$feedback = implode( ' ', array_slice( $result['suggestions'], 0, 2 ) );
}
// Layer AI elaboration on top only when the score is genuinely weak.
// The module already explains "what to fix"; AI adds a sentence on "how".
if ( $score < 60 && $mwai ) {
try {
$content_sample = mb_substr( $content, 0, 1500, 'UTF-8' );
$baseline = $feedback ? "Specific issues found: {$feedback}" : '';
$prompt = "Suggest one concrete way to improve this post's structure or sentence clarity for human readers. One sentence. {$baseline}\n\nText:\n{$content_sample}";
$response = $mwai->simpleFastTextQuery( $prompt );
$ai_line = trim( $response );
if ( $ai_line !== '' ) {
$feedback = $this->format_ai_feedback( trim( ( $feedback ? $feedback . ' ' : '' ) . $ai_line ) );
}
} catch ( Exception $e ) {
// Fall back to module suggestions; non-fatal.
}
}
return [
'score' => $score,
'feedback' => $feedback,
'breakdown' => $result['breakdown'] ?? null,
];
}
/**
* Analyze topic completeness using AI
* Checks if key subtopics and questions are covered
*/
private function analyze_topic_completeness( $analysis ) {
global $mwai;
if ( !$mwai ) {
return ['score' => 'NA', 'feedback' => ''];
}
try {
$title = $analysis['title'];
$content_sample = $this->truncate_at_sentence( $analysis['content'], 6000 );
$this->core->log( "Analyzing topic completeness for '{$title}' with content sample length: " . mb_strlen( $content_sample ) );
if ( empty( $title ) || empty( $content_sample ) ) {
return ['score' => 'NA', 'feedback' => ''];
}
// The sample is capped, so say so: otherwise the model reads a partial post
// and reports the sections it never saw as missing.
$truncated = mb_strlen( $analysis['content'], 'UTF-8' ) > mb_strlen( $content_sample, 'UTF-8' );
$note = $truncated ? " Only the beginning of the post is shown, so never assume a later section is absent." : "";
$prompt = "Judge whether this post covers what a reader searching for '{$title}' would expect. Score 0-100.{$note} Be generous: a post that delivers on its title is 85 or higher. Go below 80 only if an essential subtopic is genuinely absent, not merely short. If coverage is fine, return an empty feedback string - do not invent gaps. Otherwise name at most 2 truly missing subtopics in one short sentence.\n\nContent:\n{$content_sample}\n\nJSON: {\"score\": X, \"feedback\": \"...\"}";
$response = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] );
$response = trim( $response );
// Remove markdown code blocks if present
$response = preg_replace( '/```json\s*/', '', $response );
$response = preg_replace( '/```\s*$/', '', $response );
$response = trim( $response );
$result = json_decode( $response, true );
if ( $result && is_array( $result ) && isset( $result['score'] ) ) {
$score = intval( $result['score'] );
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' );
if ( $score < 0 || $score > 100 ) {
return ['score' => 'NA', 'feedback' => ''];
}
return ['score' => $score, 'feedback' => $feedback];
}
return ['score' => 'NA', 'feedback' => ''];
} catch ( Exception $e ) {
return ['score' => 'NA', 'feedback' => ''];
}
}
/**
* Default AI data when AI is disabled
*/
private function get_default_ai_data() {
return [
'summary' => '',
'confidence' => 0.0,
'intent' => 'unknown',
'entities' => [],
'semantic_alignment' => 0.0,
];
}
/**
* Score all tests (0-100 or "NA")
*/
private function score_tests( $post, $analysis ) {
$tests = [];
// Run content_depth first (needed by intent_fit to avoid redundancy)
$tests['content_depth'] = $this->test_content_depth( $post, $analysis );
// CONTENT QUALITY TESTS
$tests['excerpt_exists'] = $this->test_excerpt_exists( $analysis );
$tests['excerpt_length'] = $this->test_excerpt_length( $analysis );
$tests['alt_coverage'] = $this->test_alt_coverage( $analysis );
$tests['semantic_alignment'] = $this->test_semantic_alignment( $analysis );
$tests['intent_fit'] = $this->test_intent_fit( $analysis, $tests['content_depth'] );
$tests['author_visible'] = $this->test_author_visible( $post );
$tests['grammar_typos'] = $this->test_grammar_typos( $analysis );
$tests['authenticity_originality'] = $this->test_authenticity_originality( $analysis );
$tests['personality_engagement'] = $this->test_personality_engagement( $analysis );
$tests['readability_score'] = $this->test_readability_score( $analysis );
$tests['topic_completeness'] = $this->test_topic_completeness( $analysis );
// TECHNICAL TESTS
$tests['title_exists'] = $this->test_title_exists( $analysis );
$tests['title_unique_sitewide'] = $this->test_title_unique( $post, $analysis );
$tests['title_length'] = $this->test_title_length( $post, $analysis );
$tests['slug_structure'] = $this->test_slug_structure( $post, $analysis );
$tests['internal_links'] = $this->test_internal_links( $analysis );
$tests['external_link_present'] = $this->test_external_link_present( $analysis );
$tests['not_orphaned'] = $this->test_not_orphaned( $post, $analysis );
$tests['featured_image'] = $this->test_featured_image( $post );
$tests['schema_integrity'] = $this->test_schema_integrity( $post, $analysis );
// content_depth already calculated above
$tests['structure_quality'] = $this->test_structure_quality( $analysis );
$tests['meta_robots_tag'] = $this->test_meta_robots_tag( $post );
$tests['js_rendered_content'] = $this->test_js_rendered_content( $post, $analysis );
// Override ignored tests with perfect scores
$ignored_tests = get_post_meta( $post->ID, '_mwseo_ignored_tests', true );
if ( is_array( $ignored_tests ) && !empty( $ignored_tests ) ) {
foreach ( $ignored_tests as $ignored_test ) {
if ( isset( $tests[$ignored_test] ) ) {
// Set to 100 (perfect score) to exclude from issues
$tests[$ignored_test] = 100;
}
}
}
return $tests;
}
// ========================================
// CONTENT QUALITY TEST IMPLEMENTATIONS
// ========================================
/**
* Helper: Score a value against a target range
* ±30% deviation = good (80), ±50% = warning (50), beyond = error (20)
*/
private function score_length_range( $actual, $min, $max ) {
if ( $actual >= $min && $actual <= $max ) {
return 100; // Perfect - within range
}
$range_size = $max - $min;
// Below minimum
if ( $actual < $min ) {
$deviation = $min - $actual;
$threshold_30 = $range_size * 0.3;
$threshold_50 = $range_size * 0.5;
if ( $deviation <= $threshold_30 ) {
return 80; // Good - within 30%
} elseif ( $deviation <= $threshold_50 ) {
return 50; // Warning - within 50%
} else {
return 20; // Error - beyond 50%
}
}
// Above maximum
if ( $actual > $max ) {
$deviation = $actual - $max;
$threshold_30 = $range_size * 0.3;
$threshold_50 = $range_size * 0.5;
if ( $deviation <= $threshold_30 ) {
return 80; // Good - within 30%
} elseif ( $deviation <= $threshold_50 ) {
return 50; // Warning - within 50%
} else {
return 20; // Error - beyond 50%
}
}
return 50; // Fallback
}
private function test_excerpt_exists( $analysis ) {
return !empty( $analysis['excerpt'] ) ? 100 : 0;
}
private function test_excerpt_length( $analysis ) {
if ( empty( $analysis['excerpt'] ) ) return 'NA'; // Can't check length if excerpt doesn't exist
// Use display width (CJK chars count as 2) for consistent SERP measurement
$width = $this->core->get_display_width( $analysis['excerpt'] );
// Perfect: 80-160 display units
if ( $width >= 80 && $width <= 160 ) {
return 100; // No penalty
}
// Partial penalty: 50-79 or 161-220 display units
if ( ( $width >= 50 && $width < 80 ) || ( $width > 160 && $width <= 220 ) ) {
return 60; // Partial -3 pts penalty (60% = -3.2 of max 8)
}
// Full penalty: <50 or >220 display units
return 0; // Full -8 pts penalty
}
private function test_alt_coverage( $analysis ) {
if( !$this->core->get_option( 'check_missing_alt_text', true ) ) {
return 'NA';
}
if ( empty( $analysis['images'] ) || !is_array( $analysis['images'] ) ) {
return 'NA';
}
$total_images = count( $analysis['images'] );
if ( $total_images === 0 ) return 'NA';
$with_alt = 0;
foreach ( $analysis['images'] as $img ) {
if ( $img['has_alt'] ) $with_alt++;
}
$coverage = $total_images > 0 ? ($with_alt / $total_images) * 100 : 0;
// Soft floor: if some but not all have alt, minimum 60
if ( $coverage > 0 && $coverage < 90 ) {
return max( 60, $coverage );
}
return round( $coverage );
}
private function test_semantic_alignment( $analysis ) {
if ( !$this->ai_enabled ) return 'NA';
$alignment = $analysis['ai']['semantic_alignment'] ?? 0;
// Return NA if no real AI data (default data has 0.0 alignment)
if ( $alignment == 0 ) return 'NA';
return round( $alignment * 100 );
}
private function test_intent_fit( $analysis, $content_depth_score = 100 ) {
if ( !$this->ai_enabled ) return 'NA';
// Skip if content_depth is already failing - no point checking AI intent fit
// when the basic technical minimum isn't met
if ( $content_depth_score !== 'NA' && $content_depth_score < 100 ) {
return 'NA';
}
$intent = $analysis['ai']['intent'] ?? 'unknown';
// Return NA if no real AI data (default data has 'unknown' intent)
if ( $intent === 'unknown' ) return 'NA';
$word_count = $analysis['word_count'];
// Define ideal ranges for each intent type
$ranges = [
'QuickAnswer' => ['min' => 150, 'ideal_min' => 200, 'ideal_max' => 400, 'max' => 600],
'News' => ['min' => 200, 'ideal_min' => 300, 'ideal_max' => 500, 'max' => 800],
'Guide' => ['min' => 400, 'ideal_min' => 600, 'ideal_max' => 1200, 'max' => 2000],
'HowTo' => ['min' => 400, 'ideal_min' => 600, 'ideal_max' => 1200, 'max' => 2000],
'Product' => ['min' => 200, 'ideal_min' => 300, 'ideal_max' => 600, 'max' => 1000],
'default' => ['min' => 300, 'ideal_min' => 400, 'ideal_max' => 800, 'max' => 1200]
];
$range = $ranges[$intent] ?? $ranges['default'];
// Score based on where content falls in the range
if ( $word_count >= $range['ideal_min'] && $word_count <= $range['ideal_max'] ) {
return 100; // Perfect fit
} else if ( $word_count >= $range['min'] && $word_count < $range['ideal_min'] ) {
// A bit short, score proportionally (70-95)
$ratio = ($word_count - $range['min']) / ($range['ideal_min'] - $range['min']);
return max(70, round(70 + ($ratio * 25)));
} else if ( $word_count > $range['ideal_max'] && $word_count <= $range['max'] ) {
// A bit long, score proportionally (80-95)
$ratio = ($range['max'] - $word_count) / ($range['max'] - $range['ideal_max']);
return max(80, round(80 + ($ratio * 15)));
} else if ( $word_count < $range['min'] ) {
// Too short
$ratio = $word_count / $range['min'];
return max(40, round($ratio * 70));
} else {
// Too long but acceptable
return 70;
}
}
private function test_author_visible( $post ) {
$author_id = $post->post_author;
return $author_id > 0 ? 100 : 0;
}
private function test_grammar_typos( $analysis ) {
// Check if this test is enabled
$check_enabled = $this->core->get_option( 'check_grammar_typos', false );
if ( !$check_enabled || !$this->ai_enabled ) {
return 'NA';
}
// Get grammar analysis from AI data
if ( isset( $analysis['ai']['grammar_score'] ) ) {
return $analysis['ai']['grammar_score'];
}
return 'NA';
}
private function test_authenticity_originality( $analysis ) {
// Check if this test is enabled
$check_enabled = $this->core->get_option( 'check_authenticity_originality', false );
if ( !$check_enabled || !$this->ai_enabled ) {
return 'NA';
}
// Get authenticity analysis from AI data
if ( isset( $analysis['ai']['authenticity_score'] ) ) {
return $analysis['ai']['authenticity_score'];
}
return 'NA';
}
private function test_personality_engagement( $analysis ) {
// Check if this test is enabled
$check_enabled = $this->core->get_option( 'check_personality_engagement', false );
if ( !$check_enabled || !$this->ai_enabled ) {
return 'NA';
}
// Get personality analysis from AI data
if ( isset( $analysis['ai']['personality_score'] ) ) {
return $analysis['ai']['personality_score'];
}
return 'NA';
}
private function test_structure_quality( $analysis ) {
// Check if this test is enabled
$check_enabled = $this->core->get_option( 'check_structure_quality', false );
if ( !$check_enabled || !$this->ai_enabled ) {
return 'NA';
}
// Get structure quality analysis from AI data
if ( isset( $analysis['ai']['structure_score'] ) ) {
return $analysis['ai']['structure_score'];
}
return 'NA';
}
private function test_readability_score( $analysis ) {
// Check if this test is enabled
$check_enabled = $this->core->get_option( 'check_readability_score', false );
if ( !$check_enabled || !$this->ai_enabled ) {
return 'NA';
}
// Get readability analysis from AI data
if ( isset( $analysis['ai']['readability_score'] ) ) {
return $analysis['ai']['readability_score'];
}
return 'NA';
}
private function test_topic_completeness( $analysis ) {
// Check if this test is enabled
$check_enabled = $this->core->get_option( 'check_topic_completeness', false );
if ( !$check_enabled || !$this->ai_enabled ) {
return 'NA';
}
// Get topic completeness analysis from AI data
if ( isset( $analysis['ai']['topic_completeness'] ) ) {
return $analysis['ai']['topic_completeness'];
}
return 'NA';
}
// ========================================
// TECHNICAL TEST IMPLEMENTATIONS
// ========================================
private function test_title_exists( $analysis ) {
return !empty( $analysis['title'] ) ? 100 : 0;
}
private function test_title_unique( $post, $analysis ) {
global $wpdb;
// Check if Polylang is active and get the post's language
if ( function_exists( 'pll_get_post_language' ) ) {
$post_language = pll_get_post_language( $post->ID, 'slug' );
if ( $post_language ) {
// Only check for duplicates within the same language
$count = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM $wpdb->posts p
INNER JOIN $wpdb->term_relationships tr ON p.ID = tr.object_id
INNER JOIN $wpdb->term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN $wpdb->terms t ON tt.term_id = t.term_id
WHERE p.post_title = %s
AND p.ID != %d
AND p.post_status = 'publish'
AND tt.taxonomy = 'language'
AND t.slug = %s",
$analysis['title'], $post->ID, $post_language
) );
return $count == 0 ? 100 : 0;
}
}
// Bogo keeps the language in the _locale meta rather than a taxonomy, so
// duplicates are scoped with a meta join instead.
if ( function_exists( 'bogo_get_post_locale' ) ) {
$locale = bogo_get_post_locale( $post->ID );
$default = function_exists( 'bogo_get_default_locale' ) ? bogo_get_default_locale() : null;
if ( $locale ) {
// Posts in the default language may have no _locale row at all.
$missing_is_default = ( $default && $locale === $default );
$count = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID)
FROM $wpdb->posts p
LEFT JOIN $wpdb->postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_locale'
WHERE p.post_title = %s
AND p.ID != %d
AND p.post_status = 'publish'
AND ( pm.meta_value = %s" . ( $missing_is_default ? " OR pm.meta_id IS NULL" : "" ) . " )",
$analysis['title'], $post->ID, $locale
) );
return $count == 0 ? 100 : 0;
}
}
// Fallback: check sitewide if no language plugin or post has no language
$count = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->posts WHERE post_title = %s AND ID != %d AND post_status = 'publish'",
$analysis['title'], $post->ID
) );
return $count == 0 ? 100 : 0;
}
private function test_title_length( $post, $analysis ) {
// Use display width (CJK chars count as 2) for consistent SERP measurement
$width = $this->core->get_display_width( $this->full_page_title( $post ) );
if ( $width === 0 ) return 'NA'; // Can't check length if title doesn't exist
// Thresholds in display units (works for all languages)
$min = $this->get_option( 'title_length_min', 30 );
$max = $this->get_option( 'title_length_max', 75 );
return $this->score_length_range( $width, $min, $max );
}
private function test_slug_structure( $post, $analysis ) {
// The home page (static front page or blog index) has no meaningful slug
// to optimize, so it always passes.
if ( (int) get_option( 'page_on_front' ) === (int) $post->ID
|| (int) get_option( 'page_for_posts' ) === (int) $post->ID ) {
return 100;
}
$slug = $analysis['slug'];
$len = mb_strlen( $slug );
$words = explode( '-', $slug );
$word_count = count( $words );
// CJK override: For overwhelmingly CJK slugs, treat character count as word count
if ( $this->core->is_mostly_cjk( $slug, 0.9 ) ) {
// Skip word-count rule for CJK slugs, just check display width
$width = $this->core->get_display_width( $slug );
return $width <= 30 ? 100 : 60;
}
// Standard English path: 2–6 hyphenated words
// Full penalty: length > 50 OR words < 2 OR words > 6
if ( $len > 50 || $word_count < 2 || $word_count > 6 ) {
// Partial penalty: length 41-50 OR words = 7
if ( ($len >= 41 && $len <= 50) || $word_count === 7 ) {
return 60; // Partial -2 pts penalty (40% of max)
}
return 0; // Full -5 pts penalty
}
// Perfect: length ≤ 40 AND words 2-6
return 100; // No penalty
}
private function test_internal_links( $analysis ) {
// Check if internal links checking is enabled
if ( !$this->get_option( 'check_internal_links', true ) ) {
return 'NA';
}
$actual = count( $analysis['links']['internal'] );
$word_count = $analysis['word_count'];
// For substantial posts (300+ words), require at least 1 internal link
if ( $word_count >= 300 ) {
return $actual >= 1 ? 100 : 30;
}
// For shorter posts, internal links are optional but recommended
return $actual >= 1 ? 100 : 70;
}
private function test_external_link_present( $analysis ) {
// Check if external links checking is enabled
if ( !$this->get_option( 'check_external_links', false ) ) {
return 'NA';
}
// Simple presence check - has at least one external link
return count( $analysis['links']['external'] ) > 0 ? 100 : 0;
}
private function test_not_orphaned( $post, $analysis ) {
// Check if orphaned content checking is enabled
if ( !$this->get_option( 'check_orphaned_content', true ) ) {
return 'NA';
}
// Check if post has incoming internal links from other pages
$is_orphaned = $this->is_orphaned( $post );
return $is_orphaned ? 0 : 100; // 0 if orphaned (full -15 pts penalty), 100 if linked
}
private function test_featured_image( $post ) {
// Check if post has a featured image set
$has_thumbnail = has_post_thumbnail( $post->ID );
return $has_thumbnail ? 100 : 0;
}
private function test_schema_integrity( $post, $analysis ) {
// Check for JSON-LD schema in content or via filters
$content = get_post_field( 'post_content', $post->ID );
// Check for JSON-LD in content
$has_schema = strpos( $content, '"@type"' ) !== false || strpos( $content, 'schema.org' ) !== false;
// Check if a plugin is adding schema
if ( !$has_schema ) {
$has_schema = apply_filters( 'seo_engine_has_schema', false, $post );
}
// No schema found — skip check if our own schema module is disabled
if ( !$has_schema ) {
$auto_schema = $this->get_option( 'auto_schema_enabled', true );
if ( !$auto_schema ) {
return 'NA';
}
return 0;
}
// Schema is present, now check for required fields
// Required fields for Article/BlogPosting: headline, datePublished, author, image
$required_fields = ['headline', 'datePublished', 'author', 'image'];
$missing_fields = [];
// Try to extract JSON-LD and check for required fields
if ( preg_match('/