| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* SEO Engine Pro - Score |
| 5 |
* Two-pillar scoring system: Content Quality + Technical |
| 6 |
* Implements normalized 0-100 scoring with context-aware targets |
| 7 |
*/ |
| 8 |
class Meow_MWSEO_Score { |
| 9 |
// Bumped whenever the score caches are cleared; see cache_version(). |
| 10 |
const CACHE_VERSION_OPTION = 'mwseo_score_cache_version'; |
| 11 |
|
| 12 |
private $core; |
| 13 |
private $options; |
| 14 |
private $ai_enabled = false; |
| 15 |
|
| 16 |
// Default configuration |
| 17 |
private $defaults = [ |
| 18 |
'bands' => ['good' => [70, 100], 'warn' => [40, 69], 'bad' => [0, 39]], |
| 19 |
// Penalty points: Start with 100 points, each failed check deducts points |
| 20 |
// Total possible penalties FAR EXCEED 100, so multiple issues drive score to 0 |
| 21 |
// Higher penalty = more critical issue |
| 22 |
'penalties' => [ |
| 23 |
// CRITICAL Intelligence Checks (AI-powered quality) |
| 24 |
'semantic_alignment' => 25, // Title must match content meaning |
| 25 |
'grammar_typos' => 20, // Clean writing is essential |
| 26 |
'authenticity_originality' => 20, // Original, not AI-like content |
| 27 |
|
| 28 |
// CRITICAL Basics Checks (rule-based technical) |
| 29 |
'title_exists' => 40, // Absolutely required |
| 30 |
'title_unique_sitewide' => 20, // Duplicate titles are terrible |
| 31 |
'schema_integrity' => 18, // Schema present + required fields complete |
| 32 |
'internal_links' => 20, // Essential for site structure |
| 33 |
'not_orphaned' => 15, // Must be linked from other pages |
| 34 |
'structure_quality' => 15, // Heading hierarchy, paragraph length, scannability |
| 35 |
'topic_completeness' => 15, // AI analyzes if key subtopics are covered |
| 36 |
|
| 37 |
// VERY IMPORTANT Checks |
| 38 |
'alt_coverage' => 15, // Accessibility & SEO |
| 39 |
'js_rendered_content' => 15, // Main content must be in HTML, not JS-injected |
| 40 |
'intent_fit' => 12, // Content length must fit purpose |
| 41 |
'readability_score' => 12, // Content Clarity: structure + lists + sentence clarity |
| 42 |
'excerpt_exists' => 10, // Meta description needed |
| 43 |
'featured_image' => 10, // Visual presence matters |
| 44 |
'personality_engagement' => 10, // Human voice and personal touch |
| 45 |
|
| 46 |
// IMPORTANT Checks |
| 47 |
'excerpt_length' => 8, // Proper meta description length |
| 48 |
'title_length' => 8, // Title must fit in search results |
| 49 |
'content_depth' => 8, // Adequate content needed (post-type based) |
| 50 |
|
| 51 |
// MODERATE Checks |
| 52 |
'slug_structure' => 5, // Length + word count combined |
| 53 |
'external_link_present' => 5, // Has at least one external link |
| 54 |
'meta_robots_tag' => 5, // No accidental noindex/nofollow |
| 55 |
'author_visible' => 3, |
| 56 |
], |
| 57 |
'comfort_zones' => [ |
| 58 |
'quick' => [150, 500], |
| 59 |
'guide' => [700, 1600], |
| 60 |
'product' => [300, 900], |
| 61 |
'local' => [300, 900], |
| 62 |
], |
| 63 |
'safeguards' => [ |
| 64 |
'require_title' => true, |
| 65 |
'require_unique_title' => true, |
| 66 |
'prevent_unintended_noindex' => true, |
| 67 |
'require_schema_when_expected' => true, |
| 68 |
'min_semantic_alignment' => 0.35 |
| 69 |
], |
| 70 |
'quality_safeguards_enabled' => true, |
| 71 |
]; |
| 72 |
|
| 73 |
public function __construct( $core ) { |
| 74 |
$this->core = $core; |
| 75 |
$this->options = $core->get_all_options(); |
| 76 |
|
| 77 |
// Note: Don't check for $mwai here - it's created during plugins_loaded hook |
| 78 |
// We'll check at runtime in analyze_ai() |
| 79 |
$this->ai_enabled = true; // Will be validated at runtime |
| 80 |
|
| 81 |
// Merge user options with defaults |
| 82 |
$this->defaults = apply_filters( 'seo_engine_v2_defaults', $this->defaults ); |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Main calculation function - analyzes and scores a post |
| 87 |
* @param object $post WordPress post object |
| 88 |
* @param string $analysis_type 'quick' for basic checks only, 'full' for AI analysis, 'baseline' for technical only |
| 89 |
*/ |
| 90 |
public function calculate( $post, $analysis_type = 'full' ) { |
| 91 |
// Collect content signals |
| 92 |
$analysis = $this->analyze_post( $post ); |
| 93 |
|
| 94 |
// AI stage (only run for 'full' analysis and if AI is enabled) |
| 95 |
if ( $analysis_type === 'full' && $this->ai_enabled ) { |
| 96 |
$analysis['ai'] = $this->analyze_ai( $post, $analysis ); |
| 97 |
} else if ( $analysis_type === 'baseline' ) { |
| 98 |
// For baseline (tech-step), always use default AI data - don't preserve old AI scores |
| 99 |
// This ensures AI steps will properly trigger penalty animations when they run |
| 100 |
$analysis['ai'] = $this->get_default_ai_data(); |
| 101 |
} else { |
| 102 |
if ( $analysis_type === 'full' ) { |
| 103 |
$analysis['ai'] = $this->get_default_ai_data(); |
| 104 |
} else { |
| 105 |
// Preserve existing AI data from previous Full Analysis |
| 106 |
$existing_data = get_post_meta( $post->ID, '_mwseo_analysis', true ); |
| 107 |
if ( $existing_data && isset( $existing_data['ai'] ) ) { |
| 108 |
$analysis['ai'] = $existing_data['ai']; |
| 109 |
} else { |
| 110 |
$analysis['ai'] = $this->get_default_ai_data(); |
| 111 |
} |
| 112 |
} |
| 113 |
} |
| 114 |
|
| 115 |
// Compute test scores |
| 116 |
$tests = $this->score_tests( $post, $analysis ); |
| 117 |
|
| 118 |
// Detect flags |
| 119 |
$flags = $this->detect_flags( $post, $tests ); |
| 120 |
|
| 121 |
// Calculate overall score using penalty system |
| 122 |
$overall = $this->calculate_score_from_penalties( $tests, $flags ); |
| 123 |
|
| 124 |
// Build result |
| 125 |
$result = [ |
| 126 |
'overall' => $overall, |
| 127 |
'tests' => $tests, |
| 128 |
'ai' => $analysis['ai'], |
| 129 |
'flags' => $flags, |
| 130 |
'penalties' => $this->calculate_applied_penalties( $tests ), |
| 131 |
'max_penalties' => $this->defaults['penalties'], // Max penalty for each test |
| 132 |
'word_count' => $analysis['word_count'], |
| 133 |
'title_length' => $analysis['title_length'], |
| 134 |
'excerpt_length' => $analysis['excerpt_length'], |
| 135 |
'version' => 3, // Bumped version for new scoring system |
| 136 |
'timestamp' => time(), |
| 137 |
'cache_hit' => isset( $analysis['ai']['_cache_hit'] ) ? true : false |
| 138 |
]; |
| 139 |
|
| 140 |
// Add AI feedback fields to top level for easy frontend access |
| 141 |
if ( isset( $analysis['ai'] ) ) { |
| 142 |
if ( isset( $analysis['ai']['grammar_feedback'] ) ) { |
| 143 |
$result['grammar_feedback'] = $analysis['ai']['grammar_feedback']; |
| 144 |
} |
| 145 |
if ( isset( $analysis['ai']['topic_feedback'] ) ) { |
| 146 |
$result['topic_feedback'] = $analysis['ai']['topic_feedback']; |
| 147 |
} |
| 148 |
if ( isset( $analysis['ai']['readability_feedback'] ) ) { |
| 149 |
$result['readability_feedback'] = $analysis['ai']['readability_feedback']; |
| 150 |
} |
| 151 |
if ( isset( $analysis['ai']['authenticity_feedback'] ) ) { |
| 152 |
$result['authenticity_feedback'] = $analysis['ai']['authenticity_feedback']; |
| 153 |
} |
| 154 |
if ( isset( $analysis['ai']['personality_feedback'] ) ) { |
| 155 |
$result['personality_feedback'] = $analysis['ai']['personality_feedback']; |
| 156 |
} |
| 157 |
} |
| 158 |
|
| 159 |
return $result; |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* The title as it appears in search results: the SEO title override when set, |
| 164 |
* otherwise the post title with the site name appended. Must stay in sync with |
| 165 |
* Meow_MWSEO_Core::build_title(), which is what actually renders on the frontend — |
| 166 |
* including the meta key it reads, so the score matches the rendered title. |
| 167 |
*/ |
| 168 |
private function full_page_title( $post ) { |
| 169 |
$seo_title = get_post_meta( $post->ID, $this->core->meta_key_seo_title, true ); |
| 170 |
|
| 171 |
if ( !empty( $seo_title ) ) { |
| 172 |
return $seo_title; |
| 173 |
} |
| 174 |
|
| 175 |
return $post->post_title . " | " . trim( get_bloginfo( 'name' ) ); |
| 176 |
} |
| 177 |
|
| 178 |
/** |
| 179 |
* Analyze post content and extract signals |
| 180 |
*/ |
| 181 |
private function analyze_post( $post ) { |
| 182 |
$excerpt = get_post_meta( $post->ID, $this->core->meta_key_seo_excerpt, true ) ?: $post->post_excerpt; |
| 183 |
|
| 184 |
$analysis = [ |
| 185 |
'title' => $post->post_title, |
| 186 |
'slug' => $post->post_name, |
| 187 |
'excerpt' => $excerpt, |
| 188 |
'content' => apply_filters( 'mwseo_post_content', wp_strip_all_tags( $post->post_content ), $post, true ), |
| 189 |
'content_html' => apply_filters( 'mwseo_post_content', $post->post_content, $post, false ), |
| 190 |
'word_count' => 0, |
| 191 |
'title_length' => mb_strlen( $this->full_page_title( $post ) ), |
| 192 |
'excerpt_length' => mb_strlen( $excerpt ), |
| 193 |
'images' => [], |
| 194 |
'links' => ['internal' => [], 'external' => []], |
| 195 |
]; |
| 196 |
|
| 197 |
// Word count with CJK fallback |
| 198 |
$word_count = str_word_count( $analysis['content'] ); |
| 199 |
|
| 200 |
// CJK override: If content is mostly CJK and word count is near zero, estimate from character length |
| 201 |
if ( $word_count < 10 && $this->core->is_mostly_cjk( $analysis['content'], 0.9 ) ) { |
| 202 |
// Estimate word count: each CJK character ≈ 0.6 words (conservative multiplier) |
| 203 |
$char_length = mb_strlen( $analysis['content'], 'UTF-8' ); |
| 204 |
$word_count = max( 1, floor( $char_length * 0.6 ) ); |
| 205 |
} |
| 206 |
|
| 207 |
$analysis['word_count'] = $word_count; |
| 208 |
|
| 209 |
// Extract images |
| 210 |
preg_match_all( '/<img[^>]+>/i', $analysis['content_html'], $images ); |
| 211 |
if ( !empty( $images[0] ) ) { |
| 212 |
foreach ( $images[0] as $img ) { |
| 213 |
$has_alt = preg_match( '/alt=[\'"]([^\'"]*)[\'"]/', $img, $alt_match ); |
| 214 |
$analysis['images'][] = [ |
| 215 |
'tag' => $img, |
| 216 |
'alt' => $has_alt ? $alt_match[1] : '', |
| 217 |
'has_alt' => $has_alt && !empty( $alt_match[1] ) |
| 218 |
]; |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
// Extract links |
| 223 |
if ( preg_match_all( '/<a[^>]+href=[\'"]([^\'"]+)[\'"][^>]*>/i', $analysis['content_html'], $matches ) ) { |
| 224 |
// Use the post's permalink to derive the site domain, so that |
| 225 |
// multilingual setups (e.g. Polylang with separate domains) correctly |
| 226 |
// detect internal links based on the post's own language domain. |
| 227 |
$permalink = get_permalink( $post->ID ); |
| 228 |
$parsed = parse_url( $permalink ); |
| 229 |
$site_host = isset( $parsed['host'] ) ? $parsed['host'] : parse_url( get_site_url(), PHP_URL_HOST ); |
| 230 |
|
| 231 |
foreach ( $matches[1] as $link ) { |
| 232 |
$link_trimmed = trim( $link ); |
| 233 |
|
| 234 |
// Skip empty or anchor links |
| 235 |
if ( empty( $link_trimmed ) || $link_trimmed === '#' || strpos( $link_trimmed, '#' ) === 0 ) { |
| 236 |
continue; |
| 237 |
} |
| 238 |
|
| 239 |
// Internal: relative links (/, ./, ../), or same domain as the post |
| 240 |
$is_relative = ( strpos( $link_trimmed, '/' ) === 0 && strpos( $link_trimmed, '//' ) !== 0 ) |
| 241 |
|| strpos( $link_trimmed, './' ) === 0 |
| 242 |
|| strpos( $link_trimmed, '../' ) === 0; |
| 243 |
$link_host = parse_url( $link_trimmed, PHP_URL_HOST ); |
| 244 |
$is_same_domain = $link_host && $link_host === $site_host; |
| 245 |
|
| 246 |
if ( $is_relative || $is_same_domain ) { |
| 247 |
$analysis['links']['internal'][] = $link_trimmed; |
| 248 |
} elseif ( preg_match( '#^https?://#', $link_trimmed ) ) { |
| 249 |
$analysis['links']['external'][] = $link_trimmed; |
| 250 |
} |
| 251 |
} |
| 252 |
} |
| 253 |
|
| 254 |
return $analysis; |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Salt mixed into every score cache key. Clearing the cache bumps it, which makes |
| 259 |
* all existing keys unreachable at once. The delete pass in the REST handler only |
| 260 |
* sees transients stored in the options table, so on sites running an external |
| 261 |
* object cache (Redis, Memcached) this salt is what actually invalidates them. |
| 262 |
*/ |
| 263 |
public static function cache_version() { |
| 264 |
return (int) get_option( self::CACHE_VERSION_OPTION, 0 ); |
| 265 |
} |
| 266 |
|
| 267 |
public static function bump_cache_version() { |
| 268 |
$version = self::cache_version() + 1; |
| 269 |
update_option( self::CACHE_VERSION_OPTION, $version, false ); |
| 270 |
return $version; |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* AI analysis - semantic alignment, intent, summary, entities |
| 275 |
*/ |
| 276 |
private function analyze_ai( $post, $analysis ) { |
| 277 |
global $mwai; |
| 278 |
|
| 279 |
$ai_data = [ |
| 280 |
'summary' => '', |
| 281 |
'confidence' => 0.0, |
| 282 |
'intent' => 'unknown', |
| 283 |
'entities' => [], |
| 284 |
'semantic_alignment' => 0.0, |
| 285 |
]; |
| 286 |
|
| 287 |
if ( !$mwai ) { |
| 288 |
return $ai_data; |
| 289 |
} |
| 290 |
|
| 291 |
if ( !method_exists( $mwai, 'simpleTextQuery' ) ) { |
| 292 |
return $ai_data; |
| 293 |
} |
| 294 |
|
| 295 |
// The AI "Content Intelligence" checks (uniqueness / point-of-view, grammar, etc.) are a |
| 296 |
// Pro feature. Even when the options are enabled, they only run when the premium add-on is |
| 297 |
// active. Resolve the effective booleans once and reuse them for the cache key + execution. |
| 298 |
$is_pro = (bool) $this->core->pro; |
| 299 |
$check_grammar = $is_pro && $this->core->get_option( 'check_grammar_typos', false ); |
| 300 |
$check_authenticity = $is_pro && $this->core->get_option( 'check_authenticity_originality', false ); |
| 301 |
$check_personality = $is_pro && $this->core->get_option( 'check_personality_engagement', false ); |
| 302 |
$check_structure = $is_pro && $this->core->get_option( 'check_structure_quality', false ); |
| 303 |
$check_readability = $is_pro && $this->core->get_option( 'check_readability_score', false ); |
| 304 |
$check_topic = $is_pro && $this->core->get_option( 'check_topic_completeness', false ); |
| 305 |
|
| 306 |
// PERFORMANCE FIX: Cache AI analysis based on content hash |
| 307 |
// Generate cache key based on full raw content + enabled checks |
| 308 |
// Using full content ensures any change (even adding a link) invalidates cache |
| 309 |
$cache_key_data = [ |
| 310 |
'title' => $analysis['title'], |
| 311 |
'content' => $analysis['content'], // Full content for accurate change detection |
| 312 |
'content_html' => $analysis['content_html'], // Include HTML to detect link changes |
| 313 |
'checks' => [ |
| 314 |
'grammar' => $check_grammar, |
| 315 |
'authenticity' => $check_authenticity, |
| 316 |
'personality' => $check_personality, |
| 317 |
'structure' => $check_structure, |
| 318 |
'readability' => $check_readability, |
| 319 |
'topic' => $check_topic, |
| 320 |
], |
| 321 |
// Bumped by Clear Score Cache, which retires every existing key at once. |
| 322 |
'cache_version' => self::cache_version(), |
| 323 |
]; |
| 324 |
$content_hash = md5( json_encode( $cache_key_data ) ); |
| 325 |
$cache_key = 'seo_engine_ai_' . $post->ID . '_' . $content_hash; |
| 326 |
|
| 327 |
// Try to get cached results (7 day expiration) |
| 328 |
$cached_ai_data = get_transient( $cache_key ); |
| 329 |
if ( $cached_ai_data !== false && is_array( $cached_ai_data ) ) { |
| 330 |
$cached_ai_data['_cache_hit'] = true; |
| 331 |
return $cached_ai_data; |
| 332 |
} |
| 333 |
|
| 334 |
try { |
| 335 |
// Generate summary and detect intent |
| 336 |
$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}"; |
| 337 |
|
| 338 |
$response = $mwai->simpleTextQuery( $summary_prompt ); |
| 339 |
|
| 340 |
// Remove markdown code blocks if present |
| 341 |
$response = preg_replace( '/```json\s*/', '', $response ); |
| 342 |
$response = preg_replace( '/```\s*$/', '', $response ); |
| 343 |
$response = trim( $response ); |
| 344 |
|
| 345 |
// Try to parse JSON from response |
| 346 |
$summary_result = json_decode( $response, true ); |
| 347 |
|
| 348 |
if ( $summary_result && is_array( $summary_result ) ) { |
| 349 |
$ai_data['summary'] = $summary_result['summary'] ?? ''; |
| 350 |
$ai_data['intent'] = $summary_result['intent'] ?? 'General'; |
| 351 |
$ai_data['entities'] = $summary_result['entities'] ?? []; |
| 352 |
$ai_data['confidence'] = floatval( $summary_result['confidence'] ?? 0.5 ); |
| 353 |
} |
| 354 |
|
| 355 |
// Calculate semantic alignment (title/excerpt vs content) |
| 356 |
$ai_data['semantic_alignment'] = $this->calculate_semantic_alignment( $post, $analysis ); |
| 357 |
|
| 358 |
// Calculate recommended content length based on intent |
| 359 |
$ai_data['recommended_length'] = $this->calculate_recommended_length( $ai_data['intent'], $analysis['word_count'] ); |
| 360 |
|
| 361 |
// Check grammar and typos if enabled (Pro) |
| 362 |
if ( $check_grammar ) { |
| 363 |
$grammar_result = $this->analyze_grammar( $analysis ); |
| 364 |
$ai_data['grammar_score'] = $grammar_result['score']; |
| 365 |
$ai_data['grammar_feedback'] = $grammar_result['feedback']; |
| 366 |
} |
| 367 |
|
| 368 |
// Check authenticity & originality if enabled (Pro) |
| 369 |
if ( $check_authenticity ) { |
| 370 |
$authenticity_result = $this->analyze_authenticity_originality( $analysis ); |
| 371 |
$ai_data['authenticity_score'] = $authenticity_result['score']; |
| 372 |
$ai_data['authenticity_feedback'] = $authenticity_result['feedback']; |
| 373 |
} |
| 374 |
|
| 375 |
// Check personality & engagement if enabled (Pro) |
| 376 |
if ( $check_personality ) { |
| 377 |
$personality_result = $this->analyze_personality_engagement( $analysis ); |
| 378 |
$ai_data['personality_score'] = $personality_result['score']; |
| 379 |
$ai_data['personality_feedback'] = $personality_result['feedback']; |
| 380 |
} |
| 381 |
|
| 382 |
// Check structure quality if enabled (Pro) |
| 383 |
if ( $check_structure ) { |
| 384 |
$ai_data['structure_score'] = $this->analyze_structure_quality( $analysis ); |
| 385 |
} |
| 386 |
|
| 387 |
// Check readability score if enabled (Pro) |
| 388 |
if ( $check_readability ) { |
| 389 |
$readability_result = $this->analyze_readability( $analysis ); |
| 390 |
$ai_data['readability_score'] = $readability_result['score']; |
| 391 |
$ai_data['readability_feedback'] = $readability_result['feedback']; |
| 392 |
} |
| 393 |
|
| 394 |
// Check topic completeness if enabled (Pro) |
| 395 |
if ( $check_topic ) { |
| 396 |
$topic_result = $this->analyze_topic_completeness( $analysis ); |
| 397 |
$ai_data['topic_completeness'] = $topic_result['score']; |
| 398 |
$ai_data['topic_feedback'] = $topic_result['feedback']; |
| 399 |
} |
| 400 |
|
| 401 |
} catch ( Exception $e ) { |
| 402 |
error_log( 'SEO Engine AI Analysis Error: ' . $e->getMessage() ); |
| 403 |
} |
| 404 |
|
| 405 |
// Cache the AI analysis results for 7 days (604800 seconds) |
| 406 |
// Allow filtering the cache duration |
| 407 |
$cache_duration = apply_filters( 'seo_engine_ai_cache_duration', 7 * DAY_IN_SECONDS ); |
| 408 |
set_transient( $cache_key, $ai_data, $cache_duration ); |
| 409 |
|
| 410 |
return $ai_data; |
| 411 |
} |
| 412 |
|
| 413 |
/** |
| 414 |
* Calculate semantic alignment between title/meta and content |
| 415 |
* Uses AI to determine if title accurately represents content |
| 416 |
*/ |
| 417 |
private function calculate_semantic_alignment( $post, $analysis ) { |
| 418 |
global $mwai; |
| 419 |
|
| 420 |
try { |
| 421 |
$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."; |
| 422 |
|
| 423 |
$result = $mwai->simpleFastTextQuery( $prompt ); |
| 424 |
$score = floatval( trim( $result ) ); |
| 425 |
|
| 426 |
|
| 427 |
// Ensure it's between 0 and 1 |
| 428 |
return max( 0.0, min( 1.0, $score ) ); |
| 429 |
|
| 430 |
} catch ( Exception $e ) { |
| 431 |
// Fallback: simple keyword overlap |
| 432 |
return $this->simple_semantic_alignment( $analysis ); |
| 433 |
} |
| 434 |
} |
| 435 |
|
| 436 |
/** |
| 437 |
* Simple fallback semantic alignment (keyword overlap) |
| 438 |
*/ |
| 439 |
private function simple_semantic_alignment( $analysis ) { |
| 440 |
$title_words = array_filter( explode( ' ', strtolower( $analysis['title'] ) ), function( $w ) { |
| 441 |
return strlen( $w ) > 3; // Only words longer than 3 chars |
| 442 |
}); |
| 443 |
|
| 444 |
$content_lower = strtolower( $analysis['content'] ); |
| 445 |
$matches = 0; |
| 446 |
|
| 447 |
foreach ( $title_words as $word ) { |
| 448 |
if ( strpos( $content_lower, $word ) !== false ) { |
| 449 |
$matches++; |
| 450 |
} |
| 451 |
} |
| 452 |
|
| 453 |
return count( $title_words ) > 0 ? $matches / count( $title_words ) : 0.5; |
| 454 |
} |
| 455 |
|
| 456 |
/** |
| 457 |
* Format AI feedback: clean up and limit length (keep markdown) |
| 458 |
*/ |
| 459 |
private function format_ai_feedback( $text ) { |
| 460 |
if ( empty( $text ) ) { |
| 461 |
return ''; |
| 462 |
} |
| 463 |
|
| 464 |
if ( is_array( $text ) ) { |
| 465 |
$text = implode( ' ', $text ); |
| 466 |
} |
| 467 |
|
| 468 |
// Ensure $text is a string before processing |
| 469 |
$text = (string) $text; |
| 470 |
|
| 471 |
// Remove markdown headers (###, ##, #) |
| 472 |
$text = preg_replace( '/^#{1,6}\s+/m', '', $text ); |
| 473 |
|
| 474 |
// Remove bullet points/list markers (-, *, •) but keep the text |
| 475 |
$text = preg_replace( '/^[\-\*•]\s+/m', '', $text ); |
| 476 |
|
| 477 |
// Remove numbered list markers (1., 2., etc.) |
| 478 |
$text = preg_replace( '/^\d+[\.)]\s+/m', '', $text ); |
| 479 |
|
| 480 |
// Clean up excessive whitespace |
| 481 |
$text = preg_replace( '/\s+/', ' ', $text ); |
| 482 |
|
| 483 |
// Limit to approximately 150 characters to ensure complete sentences |
| 484 |
if ( strlen( $text ) > 150 ) { |
| 485 |
$text = substr( $text, 0, 150 ); |
| 486 |
// Try to cut at last period |
| 487 |
$last_period = strrpos( $text, '.' ); |
| 488 |
if ( $last_period !== false && $last_period > 80 ) { |
| 489 |
$text = substr( $text, 0, $last_period + 1 ); |
| 490 |
} else { |
| 491 |
// Try to cut at last comma |
| 492 |
$last_comma = strrpos( $text, ',' ); |
| 493 |
if ( $last_comma !== false && $last_comma > 80 ) { |
| 494 |
$text = substr( $text, 0, $last_comma ) . '...'; |
| 495 |
} else { |
| 496 |
$text .= '...'; |
| 497 |
} |
| 498 |
} |
| 499 |
} |
| 500 |
|
| 501 |
return trim( $text ); |
| 502 |
} |
| 503 |
|
| 504 |
/** |
| 505 |
* Truncate content at a sentence boundary to avoid feeding incomplete sentences to AI. |
| 506 |
*/ |
| 507 |
private function truncate_at_sentence( $text, $max_chars = 1500 ) { |
| 508 |
if ( mb_strlen( $text ) <= $max_chars ) { |
| 509 |
return $text; |
| 510 |
} |
| 511 |
$truncated = mb_substr( $text, 0, $max_chars ); |
| 512 |
// Find last sentence-ending punctuation (.!?。)) |
| 513 |
$last = max( |
| 514 |
mb_strrpos( $truncated, '.' ) ?: 0, |
| 515 |
mb_strrpos( $truncated, '!' ) ?: 0, |
| 516 |
mb_strrpos( $truncated, '?' ) ?: 0, |
| 517 |
mb_strrpos( $truncated, '。' ) ?: 0 |
| 518 |
); |
| 519 |
// Only cut at sentence boundary if we keep at least 60% of the text |
| 520 |
if ( $last > $max_chars * 0.6 ) { |
| 521 |
return mb_substr( $truncated, 0, $last + 1 ); |
| 522 |
} |
| 523 |
return $truncated; |
| 524 |
} |
| 525 |
|
| 526 |
/** |
| 527 |
* Analyze grammar and typos in content using AI |
| 528 |
*/ |
| 529 |
private function analyze_grammar( $analysis ) { |
| 530 |
global $mwai; |
| 531 |
|
| 532 |
if ( !$mwai ) { |
| 533 |
return ['score' => 'NA', 'feedback' => '']; |
| 534 |
} |
| 535 |
|
| 536 |
try { |
| 537 |
// Sample content for analysis, truncated at sentence boundary |
| 538 |
$content_sample = $this->truncate_at_sentence( $analysis['content'], 1500 ); |
| 539 |
|
| 540 |
if ( empty( $content_sample ) ) { |
| 541 |
return ['score' => 100, 'feedback' => '']; // No content to analyze |
| 542 |
} |
| 543 |
|
| 544 |
$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\": \"...\"}"; |
| 545 |
|
| 546 |
$response = $mwai->simpleFastTextQuery( $prompt ); |
| 547 |
$response = trim( $response ); |
| 548 |
|
| 549 |
// Remove markdown code blocks if present |
| 550 |
$response = preg_replace( '/```json\s*/', '', $response ); |
| 551 |
$response = preg_replace( '/```\s*$/', '', $response ); |
| 552 |
$response = trim( $response ); |
| 553 |
|
| 554 |
$result = json_decode( $response, true ); |
| 555 |
|
| 556 |
if ( $result && is_array( $result ) && isset( $result['score'] ) ) { |
| 557 |
$score = intval( $result['score'] ); |
| 558 |
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' ); |
| 559 |
|
| 560 |
if ( $score < 0 || $score > 100 ) { |
| 561 |
return ['score' => 'NA', 'feedback' => '']; |
| 562 |
} |
| 563 |
|
| 564 |
return ['score' => $score, 'feedback' => $feedback]; |
| 565 |
} |
| 566 |
|
| 567 |
return ['score' => 'NA', 'feedback' => '']; |
| 568 |
|
| 569 |
} catch ( Exception $e ) { |
| 570 |
return ['score' => 'NA', 'feedback' => '']; |
| 571 |
} |
| 572 |
} |
| 573 |
|
| 574 |
/** |
| 575 |
* Analyze authenticity & originality using AI. |
| 576 |
* |
| 577 |
* Google's May 2026 AI Optimization Guide is explicit: "non-commodity" |
| 578 |
* content with a "unique point of view that stands out" is what wins. |
| 579 |
* The contrast Google gives is "7 Tips for First-Time Homebuyers" |
| 580 |
* (commodity, recycled) vs. "Why We Waived the Inspection & Saved Money" |
| 581 |
* (first-hand experience). This prompt scores on that exact axis, with |
| 582 |
* AI-template phrasing as a secondary penalty. |
| 583 |
*/ |
| 584 |
private function analyze_authenticity_originality( $analysis ) { |
| 585 |
global $mwai; |
| 586 |
|
| 587 |
if ( !$mwai ) { |
| 588 |
return ['score' => 'NA', 'feedback' => '']; |
| 589 |
} |
| 590 |
|
| 591 |
try { |
| 592 |
$content_sample = $this->truncate_at_sentence( $analysis['content'], 1500 ); |
| 593 |
|
| 594 |
if ( empty( $content_sample ) ) { |
| 595 |
return ['score' => 100, 'feedback' => '']; |
| 596 |
} |
| 597 |
|
| 598 |
$prompt = "Score this post 0-100 on the commodity ↔ first-hand axis defined by Google's AI Optimization Guide:\n" |
| 599 |
. "- 100: unique point of view, first-hand experience, specific details, real anecdotes (e.g. 'Why We Waived the Inspection & Saved Money')\n" |
| 600 |
. "- 50: solid general advice, but mostly things anyone could write from research (e.g. '7 Tips for First-Time Homebuyers')\n" |
| 601 |
. "- 0: recycled commodity content, full of AI-template phrases like 'In today's fast-paced world' or 'It's important to note that'\n" |
| 602 |
. "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" |
| 603 |
. "If < 70, give one sentence of feedback naming the specific weakness (commodity framing, missing personal angle, AI-template phrases, etc).\n\n" |
| 604 |
. "Text:\n{$content_sample}\n\nJSON: {\"score\": X, \"feedback\": \"...\"}"; |
| 605 |
|
| 606 |
$response = $mwai->simpleFastTextQuery( $prompt ); |
| 607 |
$response = trim( $response ); |
| 608 |
|
| 609 |
// Remove markdown code blocks if present |
| 610 |
$response = preg_replace( '/```json\s*/', '', $response ); |
| 611 |
$response = preg_replace( '/```\s*$/', '', $response ); |
| 612 |
$response = trim( $response ); |
| 613 |
|
| 614 |
$result = json_decode( $response, true ); |
| 615 |
|
| 616 |
if ( $result && is_array( $result ) && isset( $result['score'] ) ) { |
| 617 |
$score = intval( $result['score'] ); |
| 618 |
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' ); |
| 619 |
|
| 620 |
if ( $score < 0 || $score > 100 ) { |
| 621 |
return ['score' => 'NA', 'feedback' => '']; |
| 622 |
} |
| 623 |
|
| 624 |
return ['score' => $score, 'feedback' => $feedback]; |
| 625 |
} |
| 626 |
|
| 627 |
return ['score' => 'NA', 'feedback' => '']; |
| 628 |
|
| 629 |
} catch ( Exception $e ) { |
| 630 |
return ['score' => 'NA', 'feedback' => '']; |
| 631 |
} |
| 632 |
} |
| 633 |
|
| 634 |
/** |
| 635 |
* Analyze personality & engagement using AI |
| 636 |
* Checks for human voice, personal touch, emotional connection |
| 637 |
*/ |
| 638 |
private function analyze_personality_engagement( $analysis ) { |
| 639 |
global $mwai; |
| 640 |
|
| 641 |
if ( !$mwai ) { |
| 642 |
return ['score' => 'NA', 'feedback' => '']; |
| 643 |
} |
| 644 |
|
| 645 |
try { |
| 646 |
$content_sample = $this->truncate_at_sentence( $analysis['content'], 1500 ); |
| 647 |
|
| 648 |
if ( empty( $content_sample ) ) { |
| 649 |
return ['score' => 100, 'feedback' => '']; |
| 650 |
} |
| 651 |
|
| 652 |
$prompt = "Personality check. Score 0-100. If < 70, list 2 suggestions max. Very brief.\n\nText:\n{$content_sample}\n\nJSON: {\"score\": X, \"feedback\": \"...\"}"; |
| 653 |
|
| 654 |
$response = $mwai->simpleFastTextQuery( $prompt ); |
| 655 |
$response = trim( $response ); |
| 656 |
|
| 657 |
// Remove markdown code blocks if present |
| 658 |
$response = preg_replace( '/```json\s*/', '', $response ); |
| 659 |
$response = preg_replace( '/```\s*$/', '', $response ); |
| 660 |
$response = trim( $response ); |
| 661 |
|
| 662 |
$result = json_decode( $response, true ); |
| 663 |
|
| 664 |
if ( $result && is_array( $result ) && isset( $result['score'] ) ) { |
| 665 |
$score = intval( $result['score'] ); |
| 666 |
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' ); |
| 667 |
|
| 668 |
if ( $score < 0 || $score > 100 ) { |
| 669 |
return ['score' => 'NA', 'feedback' => '']; |
| 670 |
} |
| 671 |
|
| 672 |
return ['score' => $score, 'feedback' => $feedback]; |
| 673 |
} |
| 674 |
|
| 675 |
return ['score' => 'NA', 'feedback' => '']; |
| 676 |
|
| 677 |
} catch ( Exception $e ) { |
| 678 |
return ['score' => 'NA', 'feedback' => '']; |
| 679 |
} |
| 680 |
} |
| 681 |
|
| 682 |
/** |
| 683 |
* Analyze content structure quality using AI |
| 684 |
* Checks for heading hierarchy, paragraph length, scannability |
| 685 |
*/ |
| 686 |
private function analyze_structure_quality( $analysis ) { |
| 687 |
global $mwai; |
| 688 |
|
| 689 |
if ( !$mwai ) { |
| 690 |
return 'NA'; |
| 691 |
} |
| 692 |
|
| 693 |
try { |
| 694 |
// Use HTML content to analyze structure |
| 695 |
$content_html = $analysis['content_html']; |
| 696 |
|
| 697 |
if ( empty( $content_html ) ) { |
| 698 |
return 100; |
| 699 |
} |
| 700 |
|
| 701 |
// Strip Gutenberg block comments (<!-- wp:xxx --> / <!-- /wp:xxx -->) |
| 702 |
// so the AI sees actual HTML structure, not editor noise. |
| 703 |
$clean_html = preg_replace( '/<!--\s*\/?wp:[^>]*-->\s*/', '', $content_html ); |
| 704 |
$clean_html = trim( $clean_html ); |
| 705 |
|
| 706 |
if ( empty( $clean_html ) ) { |
| 707 |
return 100; |
| 708 |
} |
| 709 |
|
| 710 |
$content_sample = mb_substr( $clean_html, 0, 3000, 'UTF-8' ); |
| 711 |
|
| 712 |
$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."; |
| 713 |
|
| 714 |
$result = $mwai->simpleFastTextQuery( $prompt ); |
| 715 |
$result = trim( $result ); |
| 716 |
|
| 717 |
$score = intval( $result ); |
| 718 |
|
| 719 |
if ( $score < 0 || $score > 100 ) { |
| 720 |
return 'NA'; |
| 721 |
} |
| 722 |
|
| 723 |
return $score; |
| 724 |
|
| 725 |
} catch ( Exception $e ) { |
| 726 |
return 'NA'; |
| 727 |
} |
| 728 |
} |
| 729 |
|
| 730 |
/** |
| 731 |
* Score Content Clarity (human skimmability). |
| 732 |
* |
| 733 |
* Delegates to Meow_MWSEO_Modules_Readability. The class name there is kept |
| 734 |
* for backwards compatibility (it used to be Flesch), but the actual scoring |
| 735 |
* is now structure + lists + sentence clarity — written for human readers |
| 736 |
* per Google's May 2026 AI guidance (which explicitly says no AI-specific |
| 737 |
* chunking is needed). Optional AI feedback appended on low scores when |
| 738 |
* AI Engine is available. |
| 739 |
*/ |
| 740 |
private function analyze_readability( $analysis ) { |
| 741 |
global $mwseo_readability, $mwai; |
| 742 |
|
| 743 |
$content = $analysis['content']; |
| 744 |
$content_html = $analysis['content_html']; |
| 745 |
|
| 746 |
if ( empty( $content ) ) { |
| 747 |
return [ 'score' => 100, 'feedback' => '' ]; |
| 748 |
} |
| 749 |
|
| 750 |
// Defensive fallback if the global isn't wired (shouldn't happen in practice). |
| 751 |
if ( !$mwseo_readability ) { |
| 752 |
require_once dirname( __FILE__ ) . '/modules/readability.php'; |
| 753 |
$mwseo_readability = new Meow_MWSEO_Modules_Readability(); |
| 754 |
} |
| 755 |
|
| 756 |
$result = $mwseo_readability->calculate_readability( $content_html ); |
| 757 |
$score = (int) ( $result['score'] ?? 0 ); |
| 758 |
|
| 759 |
// Build feedback from the suggestions the module produced. |
| 760 |
$feedback = ''; |
| 761 |
if ( !empty( $result['suggestions'] ) ) { |
| 762 |
$feedback = implode( ' ', array_slice( $result['suggestions'], 0, 2 ) ); |
| 763 |
} |
| 764 |
|
| 765 |
// Layer AI elaboration on top only when the score is genuinely weak. |
| 766 |
// The module already explains "what to fix"; AI adds a sentence on "how". |
| 767 |
if ( $score < 60 && $mwai ) { |
| 768 |
try { |
| 769 |
$content_sample = mb_substr( $content, 0, 1500, 'UTF-8' ); |
| 770 |
$baseline = $feedback ? "Specific issues found: {$feedback}" : ''; |
| 771 |
$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}"; |
| 772 |
$response = $mwai->simpleFastTextQuery( $prompt ); |
| 773 |
$ai_line = trim( $response ); |
| 774 |
if ( $ai_line !== '' ) { |
| 775 |
$feedback = $this->format_ai_feedback( trim( ( $feedback ? $feedback . ' ' : '' ) . $ai_line ) ); |
| 776 |
} |
| 777 |
} catch ( Exception $e ) { |
| 778 |
// Fall back to module suggestions; non-fatal. |
| 779 |
} |
| 780 |
} |
| 781 |
|
| 782 |
return [ |
| 783 |
'score' => $score, |
| 784 |
'feedback' => $feedback, |
| 785 |
'breakdown' => $result['breakdown'] ?? null, |
| 786 |
]; |
| 787 |
} |
| 788 |
|
| 789 |
/** |
| 790 |
* Analyze topic completeness using AI |
| 791 |
* Checks if key subtopics and questions are covered |
| 792 |
*/ |
| 793 |
private function analyze_topic_completeness( $analysis ) { |
| 794 |
global $mwai; |
| 795 |
|
| 796 |
if ( !$mwai ) { |
| 797 |
return ['score' => 'NA', 'feedback' => '']; |
| 798 |
} |
| 799 |
|
| 800 |
try { |
| 801 |
$title = $analysis['title']; |
| 802 |
$content_sample = $this->truncate_at_sentence( $analysis['content'], 6000 ); |
| 803 |
$this->core->log( "Analyzing topic completeness for '{$title}' with content sample length: " . mb_strlen( $content_sample ) ); |
| 804 |
|
| 805 |
if ( empty( $title ) || empty( $content_sample ) ) { |
| 806 |
return ['score' => 'NA', 'feedback' => '']; |
| 807 |
} |
| 808 |
|
| 809 |
// The sample is capped, so say so: otherwise the model reads a partial post |
| 810 |
// and reports the sections it never saw as missing. |
| 811 |
$truncated = mb_strlen( $analysis['content'], 'UTF-8' ) > mb_strlen( $content_sample, 'UTF-8' ); |
| 812 |
$note = $truncated ? " Only the beginning of the post is shown, so never assume a later section is absent." : ""; |
| 813 |
|
| 814 |
$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\": \"...\"}"; |
| 815 |
|
| 816 |
$response = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] ); |
| 817 |
$response = trim( $response ); |
| 818 |
|
| 819 |
// Remove markdown code blocks if present |
| 820 |
$response = preg_replace( '/```json\s*/', '', $response ); |
| 821 |
$response = preg_replace( '/```\s*$/', '', $response ); |
| 822 |
$response = trim( $response ); |
| 823 |
|
| 824 |
$result = json_decode( $response, true ); |
| 825 |
|
| 826 |
if ( $result && is_array( $result ) && isset( $result['score'] ) ) { |
| 827 |
$score = intval( $result['score'] ); |
| 828 |
$feedback = $this->format_ai_feedback( $result['feedback'] ?? '' ); |
| 829 |
|
| 830 |
if ( $score < 0 || $score > 100 ) { |
| 831 |
return ['score' => 'NA', 'feedback' => '']; |
| 832 |
} |
| 833 |
|
| 834 |
return ['score' => $score, 'feedback' => $feedback]; |
| 835 |
} |
| 836 |
|
| 837 |
return ['score' => 'NA', 'feedback' => '']; |
| 838 |
|
| 839 |
} catch ( Exception $e ) { |
| 840 |
return ['score' => 'NA', 'feedback' => '']; |
| 841 |
} |
| 842 |
} |
| 843 |
|
| 844 |
/** |
| 845 |
* Default AI data when AI is disabled |
| 846 |
*/ |
| 847 |
private function get_default_ai_data() { |
| 848 |
return [ |
| 849 |
'summary' => '', |
| 850 |
'confidence' => 0.0, |
| 851 |
'intent' => 'unknown', |
| 852 |
'entities' => [], |
| 853 |
'semantic_alignment' => 0.0, |
| 854 |
]; |
| 855 |
} |
| 856 |
|
| 857 |
/** |
| 858 |
* Score all tests (0-100 or "NA") |
| 859 |
*/ |
| 860 |
private function score_tests( $post, $analysis ) { |
| 861 |
$tests = []; |
| 862 |
|
| 863 |
// Run content_depth first (needed by intent_fit to avoid redundancy) |
| 864 |
$tests['content_depth'] = $this->test_content_depth( $post, $analysis ); |
| 865 |
|
| 866 |
// CONTENT QUALITY TESTS |
| 867 |
$tests['excerpt_exists'] = $this->test_excerpt_exists( $analysis ); |
| 868 |
$tests['excerpt_length'] = $this->test_excerpt_length( $analysis ); |
| 869 |
$tests['alt_coverage'] = $this->test_alt_coverage( $analysis ); |
| 870 |
$tests['semantic_alignment'] = $this->test_semantic_alignment( $analysis ); |
| 871 |
$tests['intent_fit'] = $this->test_intent_fit( $analysis, $tests['content_depth'] ); |
| 872 |
$tests['author_visible'] = $this->test_author_visible( $post ); |
| 873 |
$tests['grammar_typos'] = $this->test_grammar_typos( $analysis ); |
| 874 |
$tests['authenticity_originality'] = $this->test_authenticity_originality( $analysis ); |
| 875 |
$tests['personality_engagement'] = $this->test_personality_engagement( $analysis ); |
| 876 |
$tests['readability_score'] = $this->test_readability_score( $analysis ); |
| 877 |
$tests['topic_completeness'] = $this->test_topic_completeness( $analysis ); |
| 878 |
|
| 879 |
// TECHNICAL TESTS |
| 880 |
$tests['title_exists'] = $this->test_title_exists( $analysis ); |
| 881 |
$tests['title_unique_sitewide'] = $this->test_title_unique( $post, $analysis ); |
| 882 |
$tests['title_length'] = $this->test_title_length( $post, $analysis ); |
| 883 |
$tests['slug_structure'] = $this->test_slug_structure( $post, $analysis ); |
| 884 |
$tests['internal_links'] = $this->test_internal_links( $analysis ); |
| 885 |
$tests['external_link_present'] = $this->test_external_link_present( $analysis ); |
| 886 |
$tests['not_orphaned'] = $this->test_not_orphaned( $post, $analysis ); |
| 887 |
$tests['featured_image'] = $this->test_featured_image( $post ); |
| 888 |
$tests['schema_integrity'] = $this->test_schema_integrity( $post, $analysis ); |
| 889 |
// content_depth already calculated above |
| 890 |
$tests['structure_quality'] = $this->test_structure_quality( $analysis ); |
| 891 |
$tests['meta_robots_tag'] = $this->test_meta_robots_tag( $post ); |
| 892 |
$tests['js_rendered_content'] = $this->test_js_rendered_content( $post, $analysis ); |
| 893 |
|
| 894 |
// Override ignored tests with perfect scores |
| 895 |
$ignored_tests = get_post_meta( $post->ID, '_mwseo_ignored_tests', true ); |
| 896 |
if ( is_array( $ignored_tests ) && !empty( $ignored_tests ) ) { |
| 897 |
foreach ( $ignored_tests as $ignored_test ) { |
| 898 |
if ( isset( $tests[$ignored_test] ) ) { |
| 899 |
// Set to 100 (perfect score) to exclude from issues |
| 900 |
$tests[$ignored_test] = 100; |
| 901 |
} |
| 902 |
} |
| 903 |
} |
| 904 |
|
| 905 |
return $tests; |
| 906 |
} |
| 907 |
|
| 908 |
// ======================================== |
| 909 |
// CONTENT QUALITY TEST IMPLEMENTATIONS |
| 910 |
// ======================================== |
| 911 |
|
| 912 |
/** |
| 913 |
* Helper: Score a value against a target range |
| 914 |
* ±30% deviation = good (80), ±50% = warning (50), beyond = error (20) |
| 915 |
*/ |
| 916 |
private function score_length_range( $actual, $min, $max ) { |
| 917 |
if ( $actual >= $min && $actual <= $max ) { |
| 918 |
return 100; // Perfect - within range |
| 919 |
} |
| 920 |
|
| 921 |
$range_size = $max - $min; |
| 922 |
|
| 923 |
// Below minimum |
| 924 |
if ( $actual < $min ) { |
| 925 |
$deviation = $min - $actual; |
| 926 |
$threshold_30 = $range_size * 0.3; |
| 927 |
$threshold_50 = $range_size * 0.5; |
| 928 |
|
| 929 |
if ( $deviation <= $threshold_30 ) { |
| 930 |
return 80; // Good - within 30% |
| 931 |
} elseif ( $deviation <= $threshold_50 ) { |
| 932 |
return 50; // Warning - within 50% |
| 933 |
} else { |
| 934 |
return 20; // Error - beyond 50% |
| 935 |
} |
| 936 |
} |
| 937 |
|
| 938 |
// Above maximum |
| 939 |
if ( $actual > $max ) { |
| 940 |
$deviation = $actual - $max; |
| 941 |
$threshold_30 = $range_size * 0.3; |
| 942 |
$threshold_50 = $range_size * 0.5; |
| 943 |
|
| 944 |
if ( $deviation <= $threshold_30 ) { |
| 945 |
return 80; // Good - within 30% |
| 946 |
} elseif ( $deviation <= $threshold_50 ) { |
| 947 |
return 50; // Warning - within 50% |
| 948 |
} else { |
| 949 |
return 20; // Error - beyond 50% |
| 950 |
} |
| 951 |
} |
| 952 |
|
| 953 |
return 50; // Fallback |
| 954 |
} |
| 955 |
|
| 956 |
private function test_excerpt_exists( $analysis ) { |
| 957 |
return !empty( $analysis['excerpt'] ) ? 100 : 0; |
| 958 |
} |
| 959 |
|
| 960 |
private function test_excerpt_length( $analysis ) { |
| 961 |
if ( empty( $analysis['excerpt'] ) ) return 'NA'; // Can't check length if excerpt doesn't exist |
| 962 |
|
| 963 |
// Use display width (CJK chars count as 2) for consistent SERP measurement |
| 964 |
$width = $this->core->get_display_width( $analysis['excerpt'] ); |
| 965 |
|
| 966 |
// Perfect: 80-160 display units |
| 967 |
if ( $width >= 80 && $width <= 160 ) { |
| 968 |
return 100; // No penalty |
| 969 |
} |
| 970 |
|
| 971 |
// Partial penalty: 50-79 or 161-220 display units |
| 972 |
if ( ( $width >= 50 && $width < 80 ) || ( $width > 160 && $width <= 220 ) ) { |
| 973 |
return 60; // Partial -3 pts penalty (60% = -3.2 of max 8) |
| 974 |
} |
| 975 |
|
| 976 |
// Full penalty: <50 or >220 display units |
| 977 |
return 0; // Full -8 pts penalty |
| 978 |
} |
| 979 |
|
| 980 |
private function test_alt_coverage( $analysis ) { |
| 981 |
if( !$this->core->get_option( 'check_missing_alt_text', true ) ) { |
| 982 |
return 'NA'; |
| 983 |
} |
| 984 |
|
| 985 |
if ( empty( $analysis['images'] ) || !is_array( $analysis['images'] ) ) { |
| 986 |
return 'NA'; |
| 987 |
} |
| 988 |
|
| 989 |
$total_images = count( $analysis['images'] ); |
| 990 |
|
| 991 |
if ( $total_images === 0 ) return 'NA'; |
| 992 |
|
| 993 |
$with_alt = 0; |
| 994 |
foreach ( $analysis['images'] as $img ) { |
| 995 |
if ( $img['has_alt'] ) $with_alt++; |
| 996 |
} |
| 997 |
|
| 998 |
$coverage = $total_images > 0 ? ($with_alt / $total_images) * 100 : 0; |
| 999 |
|
| 1000 |
// Soft floor: if some but not all have alt, minimum 60 |
| 1001 |
if ( $coverage > 0 && $coverage < 90 ) { |
| 1002 |
return max( 60, $coverage ); |
| 1003 |
} |
| 1004 |
|
| 1005 |
return round( $coverage ); |
| 1006 |
} |
| 1007 |
|
| 1008 |
private function test_semantic_alignment( $analysis ) { |
| 1009 |
if ( !$this->ai_enabled ) return 'NA'; |
| 1010 |
|
| 1011 |
$alignment = $analysis['ai']['semantic_alignment'] ?? 0; |
| 1012 |
|
| 1013 |
// Return NA if no real AI data (default data has 0.0 alignment) |
| 1014 |
if ( $alignment == 0 ) return 'NA'; |
| 1015 |
|
| 1016 |
return round( $alignment * 100 ); |
| 1017 |
} |
| 1018 |
|
| 1019 |
private function test_intent_fit( $analysis, $content_depth_score = 100 ) { |
| 1020 |
if ( !$this->ai_enabled ) return 'NA'; |
| 1021 |
|
| 1022 |
// Skip if content_depth is already failing - no point checking AI intent fit |
| 1023 |
// when the basic technical minimum isn't met |
| 1024 |
if ( $content_depth_score !== 'NA' && $content_depth_score < 100 ) { |
| 1025 |
return 'NA'; |
| 1026 |
} |
| 1027 |
|
| 1028 |
$intent = $analysis['ai']['intent'] ?? 'unknown'; |
| 1029 |
|
| 1030 |
// Return NA if no real AI data (default data has 'unknown' intent) |
| 1031 |
if ( $intent === 'unknown' ) return 'NA'; |
| 1032 |
|
| 1033 |
$word_count = $analysis['word_count']; |
| 1034 |
|
| 1035 |
// Define ideal ranges for each intent type |
| 1036 |
$ranges = [ |
| 1037 |
'QuickAnswer' => ['min' => 150, 'ideal_min' => 200, 'ideal_max' => 400, 'max' => 600], |
| 1038 |
'News' => ['min' => 200, 'ideal_min' => 300, 'ideal_max' => 500, 'max' => 800], |
| 1039 |
'Guide' => ['min' => 400, 'ideal_min' => 600, 'ideal_max' => 1200, 'max' => 2000], |
| 1040 |
'HowTo' => ['min' => 400, 'ideal_min' => 600, 'ideal_max' => 1200, 'max' => 2000], |
| 1041 |
'Product' => ['min' => 200, 'ideal_min' => 300, 'ideal_max' => 600, 'max' => 1000], |
| 1042 |
'default' => ['min' => 300, 'ideal_min' => 400, 'ideal_max' => 800, 'max' => 1200] |
| 1043 |
]; |
| 1044 |
|
| 1045 |
$range = $ranges[$intent] ?? $ranges['default']; |
| 1046 |
|
| 1047 |
// Score based on where content falls in the range |
| 1048 |
if ( $word_count >= $range['ideal_min'] && $word_count <= $range['ideal_max'] ) { |
| 1049 |
return 100; // Perfect fit |
| 1050 |
} else if ( $word_count >= $range['min'] && $word_count < $range['ideal_min'] ) { |
| 1051 |
// A bit short, score proportionally (70-95) |
| 1052 |
$ratio = ($word_count - $range['min']) / ($range['ideal_min'] - $range['min']); |
| 1053 |
return max(70, round(70 + ($ratio * 25))); |
| 1054 |
} else if ( $word_count > $range['ideal_max'] && $word_count <= $range['max'] ) { |
| 1055 |
// A bit long, score proportionally (80-95) |
| 1056 |
$ratio = ($range['max'] - $word_count) / ($range['max'] - $range['ideal_max']); |
| 1057 |
return max(80, round(80 + ($ratio * 15))); |
| 1058 |
} else if ( $word_count < $range['min'] ) { |
| 1059 |
// Too short |
| 1060 |
$ratio = $word_count / $range['min']; |
| 1061 |
return max(40, round($ratio * 70)); |
| 1062 |
} else { |
| 1063 |
// Too long but acceptable |
| 1064 |
return 70; |
| 1065 |
} |
| 1066 |
} |
| 1067 |
|
| 1068 |
private function test_author_visible( $post ) { |
| 1069 |
$author_id = $post->post_author; |
| 1070 |
return $author_id > 0 ? 100 : 0; |
| 1071 |
} |
| 1072 |
|
| 1073 |
private function test_grammar_typos( $analysis ) { |
| 1074 |
// Check if this test is enabled |
| 1075 |
$check_enabled = $this->core->get_option( 'check_grammar_typos', false ); |
| 1076 |
if ( !$check_enabled || !$this->ai_enabled ) { |
| 1077 |
return 'NA'; |
| 1078 |
} |
| 1079 |
|
| 1080 |
// Get grammar analysis from AI data |
| 1081 |
if ( isset( $analysis['ai']['grammar_score'] ) ) { |
| 1082 |
return $analysis['ai']['grammar_score']; |
| 1083 |
} |
| 1084 |
|
| 1085 |
return 'NA'; |
| 1086 |
} |
| 1087 |
|
| 1088 |
private function test_authenticity_originality( $analysis ) { |
| 1089 |
// Check if this test is enabled |
| 1090 |
$check_enabled = $this->core->get_option( 'check_authenticity_originality', false ); |
| 1091 |
if ( !$check_enabled || !$this->ai_enabled ) { |
| 1092 |
return 'NA'; |
| 1093 |
} |
| 1094 |
|
| 1095 |
// Get authenticity analysis from AI data |
| 1096 |
if ( isset( $analysis['ai']['authenticity_score'] ) ) { |
| 1097 |
return $analysis['ai']['authenticity_score']; |
| 1098 |
} |
| 1099 |
|
| 1100 |
return 'NA'; |
| 1101 |
} |
| 1102 |
|
| 1103 |
private function test_personality_engagement( $analysis ) { |
| 1104 |
// Check if this test is enabled |
| 1105 |
$check_enabled = $this->core->get_option( 'check_personality_engagement', false ); |
| 1106 |
if ( !$check_enabled || !$this->ai_enabled ) { |
| 1107 |
return 'NA'; |
| 1108 |
} |
| 1109 |
|
| 1110 |
// Get personality analysis from AI data |
| 1111 |
if ( isset( $analysis['ai']['personality_score'] ) ) { |
| 1112 |
return $analysis['ai']['personality_score']; |
| 1113 |
} |
| 1114 |
|
| 1115 |
return 'NA'; |
| 1116 |
} |
| 1117 |
|
| 1118 |
private function test_structure_quality( $analysis ) { |
| 1119 |
// Check if this test is enabled |
| 1120 |
$check_enabled = $this->core->get_option( 'check_structure_quality', false ); |
| 1121 |
if ( !$check_enabled || !$this->ai_enabled ) { |
| 1122 |
return 'NA'; |
| 1123 |
} |
| 1124 |
|
| 1125 |
// Get structure quality analysis from AI data |
| 1126 |
if ( isset( $analysis['ai']['structure_score'] ) ) { |
| 1127 |
return $analysis['ai']['structure_score']; |
| 1128 |
} |
| 1129 |
|
| 1130 |
return 'NA'; |
| 1131 |
} |
| 1132 |
|
| 1133 |
private function test_readability_score( $analysis ) { |
| 1134 |
// Check if this test is enabled |
| 1135 |
$check_enabled = $this->core->get_option( 'check_readability_score', false ); |
| 1136 |
if ( !$check_enabled || !$this->ai_enabled ) { |
| 1137 |
return 'NA'; |
| 1138 |
} |
| 1139 |
|
| 1140 |
// Get readability analysis from AI data |
| 1141 |
if ( isset( $analysis['ai']['readability_score'] ) ) { |
| 1142 |
return $analysis['ai']['readability_score']; |
| 1143 |
} |
| 1144 |
|
| 1145 |
return 'NA'; |
| 1146 |
} |
| 1147 |
|
| 1148 |
private function test_topic_completeness( $analysis ) { |
| 1149 |
// Check if this test is enabled |
| 1150 |
$check_enabled = $this->core->get_option( 'check_topic_completeness', false ); |
| 1151 |
if ( !$check_enabled || !$this->ai_enabled ) { |
| 1152 |
return 'NA'; |
| 1153 |
} |
| 1154 |
|
| 1155 |
// Get topic completeness analysis from AI data |
| 1156 |
if ( isset( $analysis['ai']['topic_completeness'] ) ) { |
| 1157 |
return $analysis['ai']['topic_completeness']; |
| 1158 |
} |
| 1159 |
|
| 1160 |
return 'NA'; |
| 1161 |
} |
| 1162 |
|
| 1163 |
// ======================================== |
| 1164 |
// TECHNICAL TEST IMPLEMENTATIONS |
| 1165 |
// ======================================== |
| 1166 |
|
| 1167 |
private function test_title_exists( $analysis ) { |
| 1168 |
return !empty( $analysis['title'] ) ? 100 : 0; |
| 1169 |
} |
| 1170 |
|
| 1171 |
private function test_title_unique( $post, $analysis ) { |
| 1172 |
global $wpdb; |
| 1173 |
|
| 1174 |
// Check if Polylang is active and get the post's language |
| 1175 |
if ( function_exists( 'pll_get_post_language' ) ) { |
| 1176 |
$post_language = pll_get_post_language( $post->ID, 'slug' ); |
| 1177 |
|
| 1178 |
if ( $post_language ) { |
| 1179 |
// Only check for duplicates within the same language |
| 1180 |
$count = $wpdb->get_var( $wpdb->prepare( |
| 1181 |
"SELECT COUNT(DISTINCT p.ID) |
| 1182 |
FROM $wpdb->posts p |
| 1183 |
INNER JOIN $wpdb->term_relationships tr ON p.ID = tr.object_id |
| 1184 |
INNER JOIN $wpdb->term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id |
| 1185 |
INNER JOIN $wpdb->terms t ON tt.term_id = t.term_id |
| 1186 |
WHERE p.post_title = %s |
| 1187 |
AND p.ID != %d |
| 1188 |
AND p.post_status = 'publish' |
| 1189 |
AND tt.taxonomy = 'language' |
| 1190 |
AND t.slug = %s", |
| 1191 |
$analysis['title'], $post->ID, $post_language |
| 1192 |
) ); |
| 1193 |
|
| 1194 |
return $count == 0 ? 100 : 0; |
| 1195 |
} |
| 1196 |
} |
| 1197 |
|
| 1198 |
// Bogo keeps the language in the _locale meta rather than a taxonomy, so |
| 1199 |
// duplicates are scoped with a meta join instead. |
| 1200 |
if ( function_exists( 'bogo_get_post_locale' ) ) { |
| 1201 |
$locale = bogo_get_post_locale( $post->ID ); |
| 1202 |
$default = function_exists( 'bogo_get_default_locale' ) ? bogo_get_default_locale() : null; |
| 1203 |
if ( $locale ) { |
| 1204 |
// Posts in the default language may have no _locale row at all. |
| 1205 |
$missing_is_default = ( $default && $locale === $default ); |
| 1206 |
$count = $wpdb->get_var( $wpdb->prepare( |
| 1207 |
"SELECT COUNT(DISTINCT p.ID) |
| 1208 |
FROM $wpdb->posts p |
| 1209 |
LEFT JOIN $wpdb->postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_locale' |
| 1210 |
WHERE p.post_title = %s |
| 1211 |
AND p.ID != %d |
| 1212 |
AND p.post_status = 'publish' |
| 1213 |
AND ( pm.meta_value = %s" . ( $missing_is_default ? " OR pm.meta_id IS NULL" : "" ) . " )", |
| 1214 |
$analysis['title'], $post->ID, $locale |
| 1215 |
) ); |
| 1216 |
return $count == 0 ? 100 : 0; |
| 1217 |
} |
| 1218 |
} |
| 1219 |
|
| 1220 |
// Fallback: check sitewide if no language plugin or post has no language |
| 1221 |
$count = $wpdb->get_var( $wpdb->prepare( |
| 1222 |
"SELECT COUNT(*) FROM $wpdb->posts WHERE post_title = %s AND ID != %d AND post_status = 'publish'", |
| 1223 |
$analysis['title'], $post->ID |
| 1224 |
) ); |
| 1225 |
|
| 1226 |
return $count == 0 ? 100 : 0; |
| 1227 |
} |
| 1228 |
|
| 1229 |
private function test_title_length( $post, $analysis ) { |
| 1230 |
// Use display width (CJK chars count as 2) for consistent SERP measurement |
| 1231 |
$width = $this->core->get_display_width( $this->full_page_title( $post ) ); |
| 1232 |
|
| 1233 |
if ( $width === 0 ) return 'NA'; // Can't check length if title doesn't exist |
| 1234 |
|
| 1235 |
// Thresholds in display units (works for all languages) |
| 1236 |
$min = $this->get_option( 'title_length_min', 30 ); |
| 1237 |
$max = $this->get_option( 'title_length_max', 75 ); |
| 1238 |
|
| 1239 |
return $this->score_length_range( $width, $min, $max ); |
| 1240 |
} |
| 1241 |
|
| 1242 |
private function test_slug_structure( $post, $analysis ) { |
| 1243 |
// The home page (static front page or blog index) has no meaningful slug |
| 1244 |
// to optimize, so it always passes. |
| 1245 |
if ( (int) get_option( 'page_on_front' ) === (int) $post->ID |
| 1246 |
|| (int) get_option( 'page_for_posts' ) === (int) $post->ID ) { |
| 1247 |
return 100; |
| 1248 |
} |
| 1249 |
|
| 1250 |
$slug = $analysis['slug']; |
| 1251 |
$len = mb_strlen( $slug ); |
| 1252 |
$words = explode( '-', $slug ); |
| 1253 |
$word_count = count( $words ); |
| 1254 |
|
| 1255 |
// CJK override: For overwhelmingly CJK slugs, treat character count as word count |
| 1256 |
if ( $this->core->is_mostly_cjk( $slug, 0.9 ) ) { |
| 1257 |
// Skip word-count rule for CJK slugs, just check display width |
| 1258 |
$width = $this->core->get_display_width( $slug ); |
| 1259 |
return $width <= 30 ? 100 : 60; |
| 1260 |
} |
| 1261 |
|
| 1262 |
// Standard English path: 2–6 hyphenated words |
| 1263 |
// Full penalty: length > 50 OR words < 2 OR words > 6 |
| 1264 |
if ( $len > 50 || $word_count < 2 || $word_count > 6 ) { |
| 1265 |
// Partial penalty: length 41-50 OR words = 7 |
| 1266 |
if ( ($len >= 41 && $len <= 50) || $word_count === 7 ) { |
| 1267 |
return 60; // Partial -2 pts penalty (40% of max) |
| 1268 |
} |
| 1269 |
return 0; // Full -5 pts penalty |
| 1270 |
} |
| 1271 |
|
| 1272 |
// Perfect: length ≤ 40 AND words 2-6 |
| 1273 |
return 100; // No penalty |
| 1274 |
} |
| 1275 |
|
| 1276 |
private function test_internal_links( $analysis ) { |
| 1277 |
// Check if internal links checking is enabled |
| 1278 |
if ( !$this->get_option( 'check_internal_links', true ) ) { |
| 1279 |
return 'NA'; |
| 1280 |
} |
| 1281 |
|
| 1282 |
$actual = count( $analysis['links']['internal'] ); |
| 1283 |
$word_count = $analysis['word_count']; |
| 1284 |
|
| 1285 |
// For substantial posts (300+ words), require at least 1 internal link |
| 1286 |
if ( $word_count >= 300 ) { |
| 1287 |
return $actual >= 1 ? 100 : 30; |
| 1288 |
} |
| 1289 |
|
| 1290 |
// For shorter posts, internal links are optional but recommended |
| 1291 |
return $actual >= 1 ? 100 : 70; |
| 1292 |
} |
| 1293 |
|
| 1294 |
private function test_external_link_present( $analysis ) { |
| 1295 |
// Check if external links checking is enabled |
| 1296 |
if ( !$this->get_option( 'check_external_links', false ) ) { |
| 1297 |
return 'NA'; |
| 1298 |
} |
| 1299 |
|
| 1300 |
// Simple presence check - has at least one external link |
| 1301 |
return count( $analysis['links']['external'] ) > 0 ? 100 : 0; |
| 1302 |
} |
| 1303 |
|
| 1304 |
private function test_not_orphaned( $post, $analysis ) { |
| 1305 |
// Check if orphaned content checking is enabled |
| 1306 |
if ( !$this->get_option( 'check_orphaned_content', true ) ) { |
| 1307 |
return 'NA'; |
| 1308 |
} |
| 1309 |
|
| 1310 |
// Check if post has incoming internal links from other pages |
| 1311 |
$is_orphaned = $this->is_orphaned( $post ); |
| 1312 |
return $is_orphaned ? 0 : 100; // 0 if orphaned (full -15 pts penalty), 100 if linked |
| 1313 |
} |
| 1314 |
|
| 1315 |
private function test_featured_image( $post ) { |
| 1316 |
// Check if post has a featured image set |
| 1317 |
$has_thumbnail = has_post_thumbnail( $post->ID ); |
| 1318 |
return $has_thumbnail ? 100 : 0; |
| 1319 |
} |
| 1320 |
|
| 1321 |
private function test_schema_integrity( $post, $analysis ) { |
| 1322 |
// Check for JSON-LD schema in content or via filters |
| 1323 |
$content = get_post_field( 'post_content', $post->ID ); |
| 1324 |
|
| 1325 |
// Check for JSON-LD in content |
| 1326 |
$has_schema = strpos( $content, '"@type"' ) !== false || strpos( $content, 'schema.org' ) !== false; |
| 1327 |
|
| 1328 |
// Check if a plugin is adding schema |
| 1329 |
if ( !$has_schema ) { |
| 1330 |
$has_schema = apply_filters( 'seo_engine_has_schema', false, $post ); |
| 1331 |
} |
| 1332 |
|
| 1333 |
// No schema found — skip check if our own schema module is disabled |
| 1334 |
if ( !$has_schema ) { |
| 1335 |
$auto_schema = $this->get_option( 'auto_schema_enabled', true ); |
| 1336 |
if ( !$auto_schema ) { |
| 1337 |
return 'NA'; |
| 1338 |
} |
| 1339 |
return 0; |
| 1340 |
} |
| 1341 |
|
| 1342 |
// Schema is present, now check for required fields |
| 1343 |
// Required fields for Article/BlogPosting: headline, datePublished, author, image |
| 1344 |
$required_fields = ['headline', 'datePublished', 'author', 'image']; |
| 1345 |
$missing_fields = []; |
| 1346 |
|
| 1347 |
// Try to extract JSON-LD and check for required fields |
| 1348 |
if ( preg_match('/<script[^>]*type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $content, $matches) ) { |
| 1349 |
$schema_json = json_decode( $matches[1], true ); |
| 1350 |
|
| 1351 |
if ( $schema_json && isset( $schema_json['@type'] ) ) { |
| 1352 |
// Check if it's an Article or BlogPosting |
| 1353 |
$type = $schema_json['@type']; |
| 1354 |
if ( $type === 'Article' || $type === 'BlogPosting' || $type === 'NewsArticle' ) { |
| 1355 |
foreach ( $required_fields as $field ) { |
| 1356 |
if ( !isset( $schema_json[$field] ) || empty( $schema_json[$field] ) ) { |
| 1357 |
$missing_fields[] = $field; |
| 1358 |
} |
| 1359 |
} |
| 1360 |
} |
| 1361 |
} |
| 1362 |
} |
| 1363 |
|
| 1364 |
// If we found missing required fields → partial -10 pts penalty (score ≈ 44) |
| 1365 |
if ( !empty( $missing_fields ) ) { |
| 1366 |
return 44; // Partial penalty |
| 1367 |
} |
| 1368 |
|
| 1369 |
// Schema present and complete → no penalty |
| 1370 |
return 100; |
| 1371 |
} |
| 1372 |
|
| 1373 |
private function test_content_depth( $post, $analysis ) { |
| 1374 |
$word_count = $analysis['word_count']; |
| 1375 |
$post_type = $post->post_type; |
| 1376 |
|
| 1377 |
// More reasonable thresholds (configurable via filters) |
| 1378 |
// Default: post ≥ 400 words; page ≥ 200; product ≥ 150 |
| 1379 |
$default_thresholds = [ |
| 1380 |
'post' => 400, |
| 1381 |
'page' => 200, |
| 1382 |
'product' => 150, |
| 1383 |
'default' => 250, |
| 1384 |
]; |
| 1385 |
|
| 1386 |
// Allow filtering thresholds per post type |
| 1387 |
$threshold = apply_filters( |
| 1388 |
'seo_engine_content_depth_threshold', |
| 1389 |
$default_thresholds[$post_type] ?? $default_thresholds['default'], |
| 1390 |
$post_type, |
| 1391 |
$post |
| 1392 |
); |
| 1393 |
|
| 1394 |
// Calculate percentage of threshold met |
| 1395 |
$percentage = $threshold > 0 ? ($word_count / $threshold) * 100 : 100; |
| 1396 |
|
| 1397 |
// Scoring (more lenient): |
| 1398 |
// -8 pts (score = 0) if below 50% of threshold (very short) |
| 1399 |
// -4 pts (score = 50) if 50-99% of threshold (could be longer) |
| 1400 |
// 0 pts (score = 100) if ≥ threshold (meets expectation) |
| 1401 |
|
| 1402 |
if ( $percentage < 50 ) { |
| 1403 |
return 0; // Full -8 pts penalty - very short |
| 1404 |
} elseif ( $percentage < 100 ) { |
| 1405 |
return 50; // Partial -4 pts penalty - could be longer |
| 1406 |
} |
| 1407 |
|
| 1408 |
return 100; // No penalty, meets threshold |
| 1409 |
} |
| 1410 |
|
| 1411 |
private function test_meta_robots_tag( $post ) { |
| 1412 |
// Check for accidental noindex/nofollow directives in meta robots |
| 1413 |
$robots = get_post_meta( $post->ID, '_mwseo_robots', true ); |
| 1414 |
|
| 1415 |
// If no robots meta is set, that's good (defaults to index,follow) |
| 1416 |
if ( empty( $robots ) ) { |
| 1417 |
return 100; |
| 1418 |
} |
| 1419 |
|
| 1420 |
// Check for problematic directives |
| 1421 |
$has_noindex = strpos( $robots, 'noindex' ) !== false; |
| 1422 |
$has_nofollow = strpos( $robots, 'nofollow' ) !== false; |
| 1423 |
|
| 1424 |
// Either directive is a problem for SEO |
| 1425 |
if ( $has_noindex || $has_nofollow ) { |
| 1426 |
return 0; // Full -5 pts penalty |
| 1427 |
} |
| 1428 |
|
| 1429 |
return 100; // No penalty |
| 1430 |
} |
| 1431 |
|
| 1432 |
/** |
| 1433 |
* Test whether the post's main content is present in the raw HTML response, |
| 1434 |
* or whether it only appears after JavaScript runs. |
| 1435 |
* |
| 1436 |
* Google's May 2026 AI Optimization Guide is explicit: JavaScript-rendered |
| 1437 |
* content "isn't blocked from crawlers" is acknowledged as more complex and |
| 1438 |
* a real risk — content that only renders client-side may not be indexed |
| 1439 |
* reliably. This check fetches the post's permalink with wp_remote_get and |
| 1440 |
* verifies that a sample of the post body actually appears in the raw HTML. |
| 1441 |
* |
| 1442 |
* Result is cached per-post for 24h via transient to avoid hammering the |
| 1443 |
* server on bulk scans. |
| 1444 |
* |
| 1445 |
* Returns 100 (pass), 0 (fail), or 'NA' (skipped — draft, password-protected, |
| 1446 |
* empty content, fetch failed, etc). |
| 1447 |
*/ |
| 1448 |
private function test_js_rendered_content( $post, $analysis ) { |
| 1449 |
// Only check published posts on a public URL. |
| 1450 |
if ( $post->post_status !== 'publish' ) { |
| 1451 |
return 'NA'; |
| 1452 |
} |
| 1453 |
if ( !empty( $post->post_password ) ) { |
| 1454 |
return 'NA'; |
| 1455 |
} |
| 1456 |
|
| 1457 |
$content = $analysis['content'] ?? ''; |
| 1458 |
// Strip shortcodes BEFORE stripping tags — otherwise shortcode tokens |
| 1459 |
// (which expand to different HTML when rendered) would pollute the sample |
| 1460 |
// and cause false positives on shortcode-heavy posts. |
| 1461 |
$plain = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( strip_shortcodes( $content ) ) ) ); |
| 1462 |
if ( mb_strlen( $plain, 'UTF-8' ) < 80 ) { |
| 1463 |
// Not enough text to build a reliable signature. |
| 1464 |
return 'NA'; |
| 1465 |
} |
| 1466 |
|
| 1467 |
$cache_key = 'mwseo_js_render_' . $post->ID . '_' |
| 1468 |
. md5( self::cache_version() . '|' . $post->post_modified . $post->post_content ); |
| 1469 |
$cached = get_transient( $cache_key ); |
| 1470 |
if ( $cached === 'pass' ) return 100; |
| 1471 |
if ( $cached === 'fail' ) return 0; |
| 1472 |
if ( $cached === 'na' ) return 'NA'; |
| 1473 |
|
| 1474 |
$permalink = get_permalink( $post ); |
| 1475 |
if ( empty( $permalink ) ) { |
| 1476 |
set_transient( $cache_key, 'na', DAY_IN_SECONDS ); |
| 1477 |
return 'NA'; |
| 1478 |
} |
| 1479 |
|
| 1480 |
$response = wp_remote_get( $permalink, [ |
| 1481 |
'timeout' => 8, |
| 1482 |
'sslverify' => false, |
| 1483 |
'headers' => [ 'User-Agent' => 'Mozilla/5.0 (compatible; SEOEngineBot/1.0; +https://meowapps.com/seo-engine)' ], |
| 1484 |
] ); |
| 1485 |
|
| 1486 |
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { |
| 1487 |
set_transient( $cache_key, 'na', HOUR_IN_SECONDS ); // Retry sooner if fetch failed. |
| 1488 |
return 'NA'; |
| 1489 |
} |
| 1490 |
|
| 1491 |
$html = wp_remote_retrieve_body( $response ); |
| 1492 |
if ( empty( $html ) ) { |
| 1493 |
set_transient( $cache_key, 'na', HOUR_IN_SECONDS ); |
| 1494 |
return 'NA'; |
| 1495 |
} |
| 1496 |
|
| 1497 |
// Strip the rendered HTML to plain text so we compare apples to apples |
| 1498 |
// (Gutenberg / shortcodes / theme wrappers all get unwrapped). |
| 1499 |
$rendered_plain = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $html ) ) ); |
| 1500 |
|
| 1501 |
// Measure how much of the post's text actually appears in the rendered page. |
| 1502 |
// We do NOT compare exact character windows: the_content applies wptexturize |
| 1503 |
// (straight quotes -> curly, -- -> –), decodes/encodes entities, and joins blocks |
| 1504 |
// tag-adjacently (no space), so verbatim windows spuriously mismatch on |
| 1505 |
// content-dense server-rendered pages. Comparing normalized *words* instead is |
| 1506 |
// immune to all of that while still catching a genuinely empty SPA <body>. |
| 1507 |
$post_norm = mb_strtolower( html_entity_decode( $plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'UTF-8' ); |
| 1508 |
$rendered_norm = mb_strtolower( html_entity_decode( $rendered_plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'UTF-8' ); |
| 1509 |
|
| 1510 |
preg_match_all( '/[\p{L}\p{N}]{2,}/u', $post_norm, $m_post ); |
| 1511 |
$post_tokens = array_unique( $m_post[0] ); |
| 1512 |
|
| 1513 |
if ( count( $post_tokens ) >= 12 ) { |
| 1514 |
// Space-delimited languages: fraction of the post's distinct words present. |
| 1515 |
preg_match_all( '/[\p{L}\p{N}]{2,}/u', $rendered_norm, $m_rendered ); |
| 1516 |
$rendered_tokens = array_flip( $m_rendered[0] ); |
| 1517 |
$found = 0; |
| 1518 |
foreach ( $post_tokens as $token ) { |
| 1519 |
if ( isset( $rendered_tokens[ $token ] ) ) { |
| 1520 |
$found++; |
| 1521 |
} |
| 1522 |
} |
| 1523 |
$coverage = $found / count( $post_tokens ); |
| 1524 |
} |
| 1525 |
else { |
| 1526 |
// Token-sparse text (e.g. CJK, which has no word boundaries): compare the set |
| 1527 |
// of adjacent-character pairs over the whitespace-stripped text instead. |
| 1528 |
$post_chars = preg_split( '//u', preg_replace( '/\s+/u', '', $post_norm ), -1, PREG_SPLIT_NO_EMPTY ); |
| 1529 |
$rendered_chars = preg_split( '//u', preg_replace( '/\s+/u', '', $rendered_norm ), -1, PREG_SPLIT_NO_EMPTY ); |
| 1530 |
if ( count( $post_chars ) < 20 ) { |
| 1531 |
set_transient( $cache_key, 'na', DAY_IN_SECONDS ); |
| 1532 |
return 'NA'; |
| 1533 |
} |
| 1534 |
$make_bigrams = function( $chars ) { |
| 1535 |
$bigrams = []; |
| 1536 |
$n = count( $chars ); |
| 1537 |
for ( $i = 0; $i < $n - 1; $i++ ) { |
| 1538 |
$bigrams[ $chars[ $i ] . $chars[ $i + 1 ] ] = true; |
| 1539 |
} |
| 1540 |
return $bigrams; |
| 1541 |
}; |
| 1542 |
$post_bigrams = $make_bigrams( $post_chars ); |
| 1543 |
$rendered_bigrams = $make_bigrams( $rendered_chars ); |
| 1544 |
$found = 0; |
| 1545 |
foreach ( array_keys( $post_bigrams ) as $bigram ) { |
| 1546 |
if ( isset( $rendered_bigrams[ $bigram ] ) ) { |
| 1547 |
$found++; |
| 1548 |
} |
| 1549 |
} |
| 1550 |
$coverage = $found / count( $post_bigrams ); |
| 1551 |
} |
| 1552 |
|
| 1553 |
// A server-rendered page scores ~0.9+; a JS-injected SPA body scores near 0. |
| 1554 |
// Require at least half the content present, which tolerates cache drift and |
| 1555 |
// minor edits while still flagging content that only appears after JS runs. |
| 1556 |
$pass = ( $coverage >= 0.5 ); |
| 1557 |
set_transient( $cache_key, $pass ? 'pass' : 'fail', DAY_IN_SECONDS ); |
| 1558 |
return $pass ? 100 : 0; |
| 1559 |
} |
| 1560 |
|
| 1561 |
// ======================================== |
| 1562 |
// PILLAR CALCULATIONS |
| 1563 |
// ======================================== |
| 1564 |
|
| 1565 |
/** |
| 1566 |
* Calculate overall score using penalty-based system |
| 1567 |
* Start with 100 points, subtract penalties for each failing check |
| 1568 |
*/ |
| 1569 |
private function calculate_score_from_penalties( $tests, $flags ) { |
| 1570 |
$penalties = $this->defaults['penalties']; |
| 1571 |
$total_penalty = 0; |
| 1572 |
|
| 1573 |
foreach ( $penalties as $test_name => $max_penalty ) { |
| 1574 |
if ( !isset( $tests[$test_name] ) ) continue; |
| 1575 |
|
| 1576 |
$score = $tests[$test_name]; |
| 1577 |
|
| 1578 |
// Skip NA tests (disabled or not applicable) |
| 1579 |
if ( $score === 'NA' ) continue; |
| 1580 |
|
| 1581 |
// Calculate penalty based on test score |
| 1582 |
// Score of 100 = no penalty (perfect) |
| 1583 |
// Score of 0 = full penalty (complete failure) |
| 1584 |
// Score of 50 = half penalty (partial pass) |
| 1585 |
$penalty = $max_penalty * ( 100 - $score ) / 100; |
| 1586 |
$total_penalty += $penalty; |
| 1587 |
} |
| 1588 |
|
| 1589 |
// Apply quality safeguards - cap score if critical issues exist |
| 1590 |
$final_score = 100 - $total_penalty; |
| 1591 |
if ( $this->defaults['quality_safeguards_enabled'] && $this->has_critical_issues( $tests, $flags ) ) { |
| 1592 |
$final_score = min( 50, $final_score ); |
| 1593 |
} |
| 1594 |
|
| 1595 |
// Clamp to 0-100 range (never show negative scores) |
| 1596 |
return max( 0, min( 100, round( $final_score ) ) ); |
| 1597 |
} |
| 1598 |
|
| 1599 |
/** |
| 1600 |
* Calculate detailed penalty breakdown for debugging/display |
| 1601 |
*/ |
| 1602 |
private function calculate_applied_penalties( $tests ) { |
| 1603 |
$penalties = $this->defaults['penalties']; |
| 1604 |
$applied = []; |
| 1605 |
|
| 1606 |
foreach ( $penalties as $test_name => $max_penalty ) { |
| 1607 |
if ( !isset( $tests[$test_name] ) ) continue; |
| 1608 |
|
| 1609 |
$score = $tests[$test_name]; |
| 1610 |
|
| 1611 |
// Skip NA tests |
| 1612 |
if ( $score === 'NA' ) continue; |
| 1613 |
|
| 1614 |
$penalty = $max_penalty * ( 100 - $score ) / 100; |
| 1615 |
|
| 1616 |
// Only include if penalty was applied |
| 1617 |
if ( $penalty > 0 ) { |
| 1618 |
$applied[$test_name] = round( $penalty, 1 ); |
| 1619 |
} |
| 1620 |
} |
| 1621 |
|
| 1622 |
return $applied; |
| 1623 |
} |
| 1624 |
|
| 1625 |
/** |
| 1626 |
* Maps each issue type to a fix tier, used by the Bulk SEO experience. |
| 1627 |
* ai_tier1 = cheap & safe AI fixes (the v1 bulk set) |
| 1628 |
* ai_tier2 = heavier / costlier AI fixes (internal links, image generation) |
| 1629 |
* anything not listed = 'manual' (detected, not auto-fixable in bulk yet) |
| 1630 |
* Keep ai_tier1 + ai_tier2 in sync with the implemented Magic Fix types. |
| 1631 |
*/ |
| 1632 |
public function get_fix_tiers() { |
| 1633 |
return [ |
| 1634 |
'excerpt_exists' => 'ai_tier1', |
| 1635 |
'excerpt_length' => 'ai_tier1', |
| 1636 |
'title_length' => 'ai_tier1', |
| 1637 |
'grammar_typos' => 'ai_tier1', |
| 1638 |
'alt_coverage' => 'ai_tier1', |
| 1639 |
'internal_links' => 'ai_tier2', |
| 1640 |
'featured_image' => 'ai_tier2', |
| 1641 |
'external_link_present' => 'ai_tier2', |
| 1642 |
]; |
| 1643 |
} |
| 1644 |
|
| 1645 |
/** |
| 1646 |
* Site-wide aggregation of failing tests across already-analyzed posts. |
| 1647 |
* Shared by the MCP get_issues tool and the /aggregate_issues REST endpoint so |
| 1648 |
* both stay in sync. Reuses the applied penalties already stored per post |
| 1649 |
* (_mwseo_analysis['penalties']) so projected score lift needs no recompute. |
| 1650 |
* |
| 1651 |
* @param array $args { post_type[], status, language, sample_size } |
| 1652 |
* @return array { posts_scanned, posts_with_issues, average_score, sample_size, top_failing_tests[] } |
| 1653 |
*/ |
| 1654 |
public function aggregate_issues( $args = [] ) { |
| 1655 |
global $wpdb; |
| 1656 |
|
| 1657 |
$post_types = !empty( $args['post_type'] ) ? (array) $args['post_type'] |
| 1658 |
: (array) $this->get_option( 'select_post_types', ['post', 'page'] ); |
| 1659 |
$status = !empty( $args['status'] ) ? $args['status'] : 'any'; |
| 1660 |
$lang = isset( $args['language'] ) ? (string) $args['language'] : ''; |
| 1661 |
$sample_size = isset( $args['sample_size'] ) ? max( 100, min( 50000, (int) $args['sample_size'] ) ) : 5000; |
| 1662 |
|
| 1663 |
$query_args = [ |
| 1664 |
'post_type' => $post_types, |
| 1665 |
'post_status' => $status === 'any' ? ['publish', 'future', 'draft', 'pending', 'private'] : (array) $status, |
| 1666 |
'posts_per_page' => $sample_size, |
| 1667 |
'fields' => 'ids', |
| 1668 |
'no_found_rows' => true, |
| 1669 |
'orderby' => 'ID', |
| 1670 |
'order' => 'DESC', |
| 1671 |
// Only posts that have actually been analyzed. |
| 1672 |
'meta_query' => [ [ 'key' => '_mwseo_analysis', 'compare' => 'EXISTS' ] ], |
| 1673 |
]; |
| 1674 |
$query_args = $this->core->apply_language_filter( $query_args, $lang ); |
| 1675 |
|
| 1676 |
$ids = get_posts( $query_args ); |
| 1677 |
|
| 1678 |
$tiers = $this->get_fix_tiers(); |
| 1679 |
$max_penalties = $this->defaults['penalties']; |
| 1680 |
$test_data = []; |
| 1681 |
$posts_scanned = 0; |
| 1682 |
$posts_with_issues = 0; |
| 1683 |
$score_sum = 0; |
| 1684 |
$last_analyzed = 0; |
| 1685 |
$distribution = [ 'excellent' => 0, 'great' => 0, 'fine' => 0, 'poor' => 0, 'weak' => 0 ]; |
| 1686 |
|
| 1687 |
if ( !empty( $ids ) ) { |
| 1688 |
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); |
| 1689 |
$rows = $wpdb->get_results( $wpdb->prepare( |
| 1690 |
"SELECT post_id, meta_value FROM {$wpdb->postmeta} |
| 1691 |
WHERE meta_key = '_mwseo_analysis' AND post_id IN ($placeholders)", |
| 1692 |
$ids |
| 1693 |
) ); |
| 1694 |
|
| 1695 |
foreach ( $rows as $row ) { |
| 1696 |
$analysis = maybe_unserialize( $row->meta_value ); |
| 1697 |
if ( !is_array( $analysis ) || !isset( $analysis['tests'] ) ) continue; |
| 1698 |
|
| 1699 |
$posts_scanned++; |
| 1700 |
if ( isset( $analysis['timestamp'] ) ) $last_analyzed = max( $last_analyzed, (int) $analysis['timestamp'] ); |
| 1701 |
$overall = isset( $analysis['overall'] ) ? (float) $analysis['overall'] : 0; |
| 1702 |
$score_sum += $overall; |
| 1703 |
if ( $overall >= 90 ) $distribution['excellent']++; |
| 1704 |
else if ( $overall >= 75 ) $distribution['great']++; |
| 1705 |
else if ( $overall >= 50 ) $distribution['fine']++; |
| 1706 |
else if ( $overall >= 26 ) $distribution['poor']++; |
| 1707 |
else $distribution['weak']++; |
| 1708 |
$applied = ( isset( $analysis['penalties'] ) && is_array( $analysis['penalties'] ) ) ? $analysis['penalties'] : []; |
| 1709 |
$had_issue = false; |
| 1710 |
|
| 1711 |
foreach ( $analysis['tests'] as $test_name => $score ) { |
| 1712 |
if ( $score === 'NA' || !is_numeric( $score ) || $score >= 70 ) continue; |
| 1713 |
$had_issue = true; |
| 1714 |
$severity = $score < 40 ? 'high' : 'medium'; |
| 1715 |
|
| 1716 |
if ( !isset( $test_data[ $test_name ] ) ) { |
| 1717 |
$test_data[ $test_name ] = [ |
| 1718 |
'test' => $test_name, |
| 1719 |
'count' => 0, |
| 1720 |
'high' => 0, |
| 1721 |
'medium' => 0, |
| 1722 |
'sample_post_ids' => [], |
| 1723 |
'sum_applied_penalty' => 0.0, |
| 1724 |
'tier' => $tiers[ $test_name ] ?? 'manual', |
| 1725 |
]; |
| 1726 |
} |
| 1727 |
$test_data[ $test_name ]['count']++; |
| 1728 |
$test_data[ $test_name ][ $severity ]++; |
| 1729 |
if ( count( $test_data[ $test_name ]['sample_post_ids'] ) < 5 ) { |
| 1730 |
$test_data[ $test_name ]['sample_post_ids'][] = (int) $row->post_id; |
| 1731 |
} |
| 1732 |
// Reuse stored applied penalty; fall back to the formula for older analyses. |
| 1733 |
$pen = isset( $applied[ $test_name ] ) ? (float) $applied[ $test_name ] |
| 1734 |
: ( ( $max_penalties[ $test_name ] ?? 0 ) * ( 100 - $score ) / 100 ); |
| 1735 |
$test_data[ $test_name ]['sum_applied_penalty'] += $pen; |
| 1736 |
} |
| 1737 |
|
| 1738 |
if ( $had_issue ) $posts_with_issues++; |
| 1739 |
} |
| 1740 |
} |
| 1741 |
|
| 1742 |
foreach ( $test_data as &$t ) { |
| 1743 |
$t['sum_applied_penalty'] = round( $t['sum_applied_penalty'], 1 ); |
| 1744 |
$t['projected_avg_lift'] = $posts_scanned > 0 ? round( $t['sum_applied_penalty'] / $posts_scanned, 1 ) : 0; |
| 1745 |
$t['fixable'] = in_array( $t['tier'], ['ai_tier1', 'ai_tier2'], true ); |
| 1746 |
} |
| 1747 |
unset( $t ); |
| 1748 |
|
| 1749 |
usort( $test_data, function ( $a, $b ) { return $b['count'] - $a['count']; } ); |
| 1750 |
|
| 1751 |
$total_issues = 0; |
| 1752 |
foreach ( $test_data as $t ) { $total_issues += $t['count']; } |
| 1753 |
|
| 1754 |
// Total posts of these types (any non-trash status), so the UI can show "analyzed X of Y". |
| 1755 |
$posts_total = 0; |
| 1756 |
foreach ( $post_types as $pt ) { |
| 1757 |
$counts = wp_count_posts( $pt ); |
| 1758 |
if ( $counts ) { |
| 1759 |
$posts_total += (int) $counts->publish + (int) $counts->future + (int) $counts->draft |
| 1760 |
+ (int) $counts->pending + (int) $counts->private; |
| 1761 |
} |
| 1762 |
} |
| 1763 |
|
| 1764 |
return [ |
| 1765 |
'posts_scanned' => $posts_scanned, |
| 1766 |
'posts_total' => $posts_total, |
| 1767 |
'posts_with_issues' => $posts_with_issues, |
| 1768 |
'total_issues' => $total_issues, |
| 1769 |
'average_score' => $posts_scanned > 0 ? round( $score_sum / $posts_scanned, 1 ) : 0, |
| 1770 |
'last_analyzed' => $last_analyzed ?: null, |
| 1771 |
'distribution' => $distribution, |
| 1772 |
'sample_size' => $sample_size, |
| 1773 |
'top_failing_tests' => array_values( $test_data ), |
| 1774 |
]; |
| 1775 |
} |
| 1776 |
|
| 1777 |
/** |
| 1778 |
* Returns the analyzed posts that are failing a specific test, with each post's |
| 1779 |
* applied penalty (= the per-post score it would recover). Used by the Bulk SEO |
| 1780 |
* "fix this issue everywhere" flow to build its work-list and review deltas. |
| 1781 |
* |
| 1782 |
* @param string $test The test/issue key (e.g. excerpt_length) |
| 1783 |
* @param array $args { post_type[], status, language, limit } |
| 1784 |
* @return array { test, total, posts[] } |
| 1785 |
*/ |
| 1786 |
public function get_posts_failing_test( $test, $args = [] ) { |
| 1787 |
$post_types = !empty( $args['post_type'] ) ? (array) $args['post_type'] |
| 1788 |
: (array) $this->get_option( 'select_post_types', ['post', 'page'] ); |
| 1789 |
$status = !empty( $args['status'] ) ? $args['status'] : 'any'; |
| 1790 |
$lang = isset( $args['language'] ) ? (string) $args['language'] : ''; |
| 1791 |
$limit = isset( $args['limit'] ) ? max( 1, min( 2000, (int) $args['limit'] ) ) : 1000; |
| 1792 |
|
| 1793 |
$query_args = [ |
| 1794 |
'post_type' => $post_types, |
| 1795 |
'post_status' => $status === 'any' ? ['publish', 'future', 'draft', 'pending', 'private'] : (array) $status, |
| 1796 |
'posts_per_page' => $limit, |
| 1797 |
'fields' => 'ids', |
| 1798 |
'no_found_rows' => true, |
| 1799 |
'orderby' => 'ID', |
| 1800 |
'order' => 'DESC', |
| 1801 |
'meta_query' => [ [ 'key' => '_mwseo_analysis', 'compare' => 'EXISTS' ] ], |
| 1802 |
]; |
| 1803 |
$query_args = $this->core->apply_language_filter( $query_args, $lang ); |
| 1804 |
|
| 1805 |
$ids = get_posts( $query_args ); |
| 1806 |
$posts = []; |
| 1807 |
|
| 1808 |
if ( !empty( $ids ) ) { |
| 1809 |
update_meta_cache( 'post', $ids ); |
| 1810 |
foreach ( $ids as $pid ) { |
| 1811 |
$analysis = get_post_meta( $pid, '_mwseo_analysis', true ); |
| 1812 |
if ( !is_array( $analysis ) || !isset( $analysis['tests'][ $test ] ) ) continue; |
| 1813 |
$score = $analysis['tests'][ $test ]; |
| 1814 |
if ( $score === 'NA' || !is_numeric( $score ) || $score >= 70 ) continue; |
| 1815 |
|
| 1816 |
$applied = isset( $analysis['penalties'][ $test ] ) ? (float) $analysis['penalties'][ $test ] |
| 1817 |
: ( ( $this->defaults['penalties'][ $test ] ?? 0 ) * ( 100 - $score ) / 100 ); |
| 1818 |
$p = get_post( $pid ); |
| 1819 |
|
| 1820 |
// Current value for the field this test targets, so the proposals table can |
| 1821 |
// show "before → after" inline (title and meta-description fixes). |
| 1822 |
$current = ''; |
| 1823 |
if ( in_array( $test, ['title_length', 'title_exists', 'title_unique_sitewide'], true ) ) { |
| 1824 |
$seo_title = get_post_meta( $pid, $this->core->meta_key_seo_title, true ); |
| 1825 |
$current = ( $seo_title !== '' && $seo_title !== false ) ? $seo_title : ( $p ? $p->post_title : '' ); |
| 1826 |
} else if ( in_array( $test, ['excerpt_exists', 'excerpt_length'], true ) ) { |
| 1827 |
$seo_ex = get_post_meta( $pid, $this->core->meta_key_seo_excerpt, true ); |
| 1828 |
$current = ( $seo_ex !== '' && $seo_ex !== false ) ? $seo_ex : ( $p ? $p->post_excerpt : '' ); |
| 1829 |
} |
| 1830 |
|
| 1831 |
$posts[] = [ |
| 1832 |
'id' => (int) $pid, |
| 1833 |
'title' => $p ? $p->post_title : ( '#' . $pid ), |
| 1834 |
'current' => $current, |
| 1835 |
'score' => isset( $analysis['overall'] ) ? (int) $analysis['overall'] : null, |
| 1836 |
'test_score' => (int) $score, |
| 1837 |
'penalty' => round( $applied, 1 ), |
| 1838 |
'edit_url' => get_edit_post_link( $pid, 'raw' ), |
| 1839 |
]; |
| 1840 |
} |
| 1841 |
} |
| 1842 |
|
| 1843 |
return [ 'test' => $test, 'total' => count( $posts ), 'posts' => $posts ]; |
| 1844 |
} |
| 1845 |
|
| 1846 |
// ======================================== |
| 1847 |
// HELPER FUNCTIONS |
| 1848 |
// ======================================== |
| 1849 |
|
| 1850 |
private function calculate_recommended_length( $intent, $current_word_count ) { |
| 1851 |
// Define ideal ranges for each intent type |
| 1852 |
$ranges = [ |
| 1853 |
'QuickAnswer' => ['ideal_min' => 200, 'ideal_max' => 400], |
| 1854 |
'News' => ['ideal_min' => 300, 'ideal_max' => 500], |
| 1855 |
'Guide' => ['ideal_min' => 600, 'ideal_max' => 1200], |
| 1856 |
'HowTo' => ['ideal_min' => 600, 'ideal_max' => 1200], |
| 1857 |
'Product' => ['ideal_min' => 300, 'ideal_max' => 600], |
| 1858 |
'default' => ['ideal_min' => 400, 'ideal_max' => 800] |
| 1859 |
]; |
| 1860 |
|
| 1861 |
$range = $ranges[$intent] ?? $ranges['default']; |
| 1862 |
|
| 1863 |
// If content is already in ideal range, return null (no recommendation needed) |
| 1864 |
if ( $current_word_count >= $range['ideal_min'] && $current_word_count <= $range['ideal_max'] ) { |
| 1865 |
return null; |
| 1866 |
} |
| 1867 |
|
| 1868 |
// Return the appropriate target |
| 1869 |
if ( $current_word_count < $range['ideal_min'] ) { |
| 1870 |
return $range['ideal_min']; // Too short, recommend minimum |
| 1871 |
} else { |
| 1872 |
return $range['ideal_max']; // Too long, recommend maximum |
| 1873 |
} |
| 1874 |
} |
| 1875 |
|
| 1876 |
private function is_short_and_strong_mode( $analysis ) { |
| 1877 |
if ( !$this->ai_enabled ) return false; |
| 1878 |
|
| 1879 |
$intent = $analysis['ai']['intent'] ?? 'unknown'; |
| 1880 |
$word_count = $analysis['word_count']; |
| 1881 |
$semantic_alignment = $analysis['ai']['semantic_alignment'] ?? 0; |
| 1882 |
|
| 1883 |
return (in_array( $intent, ['QuickAnswer', 'News'] ) || $word_count < 400) |
| 1884 |
&& $semantic_alignment >= 0.65; |
| 1885 |
} |
| 1886 |
|
| 1887 |
private function has_critical_issues( $tests, $flags ) { |
| 1888 |
$safeguards = $this->defaults['safeguards']; |
| 1889 |
|
| 1890 |
// Missing or non-unique title (truly critical for SEO) |
| 1891 |
if ( $safeguards['require_title'] && $tests['title_exists'] === 0 ) return true; |
| 1892 |
if ( $safeguards['require_unique_title'] && $tests['title_unique_sitewide'] === 0 ) return true; |
| 1893 |
|
| 1894 |
// Unintended noindex (blocks search engines) |
| 1895 |
if ( $safeguards['prevent_unintended_noindex'] && $flags['noindex'] ) return true; |
| 1896 |
|
| 1897 |
// Schema removed - it's important but not critical enough to cap scores at 50% |
| 1898 |
|
| 1899 |
// Poor semantic alignment (title doesn't match content) |
| 1900 |
if ( $safeguards['min_semantic_alignment'] > 0 ) { |
| 1901 |
$alignment = $tests['semantic_alignment']; |
| 1902 |
if ( $alignment !== 'NA' && $alignment < ($safeguards['min_semantic_alignment'] * 100) ) { |
| 1903 |
return true; |
| 1904 |
} |
| 1905 |
} |
| 1906 |
|
| 1907 |
return false; |
| 1908 |
} |
| 1909 |
|
| 1910 |
private function detect_flags( $post, $tests ) { |
| 1911 |
$flags = [ |
| 1912 |
'noindex' => $this->is_noindexed( $post ), |
| 1913 |
'orphaned' => $this->get_option( 'check_orphaned_content', true ) ? $this->is_orphaned( $post ) : false, |
| 1914 |
'missing_schema' => isset( $tests['schema_integrity'] ) && $tests['schema_integrity'] === 0, |
| 1915 |
'redirected' => false, // TODO: implement redirect detection |
| 1916 |
'canonical_set' => $this->has_canonical( $post ), |
| 1917 |
]; |
| 1918 |
|
| 1919 |
return $flags; |
| 1920 |
} |
| 1921 |
|
| 1922 |
private function is_noindexed( $post ) { |
| 1923 |
// Check if post has noindex meta |
| 1924 |
$robots = get_post_meta( $post->ID, '_mwseo_robots', true ); |
| 1925 |
return strpos( $robots, 'noindex' ) !== false; |
| 1926 |
} |
| 1927 |
|
| 1928 |
private function is_orphaned( $post ) { |
| 1929 |
// Check if post has any incoming internal links |
| 1930 |
// Simplified: check if post appears in any other post's content |
| 1931 |
global $wpdb; |
| 1932 |
|
| 1933 |
static $cache = []; |
| 1934 |
if ( isset( $cache[$post->ID] ) ) { |
| 1935 |
return $cache[$post->ID]; |
| 1936 |
} |
| 1937 |
|
| 1938 |
$permalink = get_permalink( $post->ID ); |
| 1939 |
$patterns = [ $permalink ]; |
| 1940 |
|
| 1941 |
// Also check common relative URL forms: root-relative paths and query strings. |
| 1942 |
$parsed = parse_url( $permalink ); |
| 1943 |
if ( !empty( $parsed['path'] ) && $parsed['path'] !== '/' ) { |
| 1944 |
$patterns[] = $parsed['path']; |
| 1945 |
$path_without_trailing_slash = rtrim( $parsed['path'], '/' ); |
| 1946 |
if ( $path_without_trailing_slash !== $parsed['path'] ) { |
| 1947 |
$patterns[] = $path_without_trailing_slash; |
| 1948 |
} |
| 1949 |
} |
| 1950 |
|
| 1951 |
$patterns[] = '?p=' . $post->ID; |
| 1952 |
|
| 1953 |
$patterns = array_unique( array_filter( $patterns ) ); |
| 1954 |
|
| 1955 |
$likes = []; |
| 1956 |
$args = [ $post->ID ]; |
| 1957 |
foreach ( $patterns as $pattern ) { |
| 1958 |
$likes[] = 'post_content LIKE %s'; |
| 1959 |
$args[] = '%' . $wpdb->esc_like( $pattern ) . '%'; |
| 1960 |
} |
| 1961 |
|
| 1962 |
$count = $wpdb->get_var( $wpdb->prepare( |
| 1963 |
"SELECT COUNT(*) FROM $wpdb->posts WHERE ID != %d AND post_status = 'publish' AND (" . implode( ' OR ', $likes ) . ")", |
| 1964 |
...$args |
| 1965 |
) ); |
| 1966 |
|
| 1967 |
$is_orphaned = ( $count == 0 ); |
| 1968 |
$cache[$post->ID] = $is_orphaned; |
| 1969 |
|
| 1970 |
return $is_orphaned; |
| 1971 |
} |
| 1972 |
|
| 1973 |
private function has_canonical( $post ) { |
| 1974 |
$canonical = get_post_meta( $post->ID, '_mwseo_canonical', true ); |
| 1975 |
return !empty( $canonical ); |
| 1976 |
} |
| 1977 |
|
| 1978 |
private function get_option( $key, $default = null ) { |
| 1979 |
return isset( $this->options[$key] ) ? $this->options[$key] : $default; |
| 1980 |
} |
| 1981 |
|
| 1982 |
/** |
| 1983 |
* Get color band for a score |
| 1984 |
*/ |
| 1985 |
public function get_score_band( $score ) { |
| 1986 |
if ( $score >= 70 ) return 'good'; |
| 1987 |
if ( $score >= 40 ) return 'warn'; |
| 1988 |
return 'bad'; |
| 1989 |
} |
| 1990 |
|
| 1991 |
/** |
| 1992 |
* Get top fixes for a post based on test results |
| 1993 |
*/ |
| 1994 |
public function get_top_fixes( $tests, $analysis ) { |
| 1995 |
$fixes = []; |
| 1996 |
|
| 1997 |
// Calculate impact for each failed test |
| 1998 |
foreach ( $tests as $test_name => $score ) { |
| 1999 |
if ( $score === 'NA' ) continue; |
| 2000 |
if ( $score >= 70 ) continue; // Only show fixes for low scores |
| 2001 |
|
| 2002 |
$impact = 100 - $score; |
| 2003 |
$fixes[] = [ |
| 2004 |
'test' => $test_name, |
| 2005 |
'score' => $score, |
| 2006 |
'impact' => $impact, |
| 2007 |
'action' => $this->get_fix_action( $test_name, $analysis ) |
| 2008 |
]; |
| 2009 |
} |
| 2010 |
|
| 2011 |
// Sort by impact (highest first) |
| 2012 |
usort( $fixes, function( $a, $b ) { |
| 2013 |
return $b['impact'] - $a['impact']; |
| 2014 |
}); |
| 2015 |
|
| 2016 |
// Return top 3 |
| 2017 |
return array_slice( $fixes, 0, 3 ); |
| 2018 |
} |
| 2019 |
|
| 2020 |
private function get_fix_action( $test_name, $analysis ) { |
| 2021 |
$actions = [ |
| 2022 |
'title_length' => 'Shorten title to ~58 chars', |
| 2023 |
'excerpt_exists' => 'Add meta description', |
| 2024 |
'excerpt_length' => 'Adjust excerpt to 80-160 chars', |
| 2025 |
'alt_coverage' => 'Add alt text to ' . count( array_filter( $analysis['images'], function($i) { return !$i['has_alt']; }) ) . ' images', |
| 2026 |
'internal_links' => 'Add at least 1 internal link', |
| 2027 |
'external_links' => 'Add external links', |
| 2028 |
'slug_length' => 'Shorten URL slug', |
| 2029 |
'slug_words' => 'Simplify URL slug', |
| 2030 |
'content_depth' => 'Expand content', |
| 2031 |
'schema_present' => 'Add schema markup', |
| 2032 |
'title_exists' => 'Add a title', |
| 2033 |
'title_unique_sitewide' => 'Make title unique', |
| 2034 |
'featured_image' => 'Set a featured image', |
| 2035 |
'grammar_typos' => 'Review and fix grammar/typos', |
| 2036 |
]; |
| 2037 |
|
| 2038 |
return $actions[$test_name] ?? 'Review ' . str_replace( '_', ' ', $test_name ); |
| 2039 |
} |
| 2040 |
|
| 2041 |
/** |
| 2042 |
* Get list of enabled AI steps for progressive analysis |
| 2043 |
* Returns array of step names that should be run |
| 2044 |
*/ |
| 2045 |
public function get_enabled_ai_steps() { |
| 2046 |
$steps = []; |
| 2047 |
|
| 2048 |
// Optional AI checks based on settings |
| 2049 |
if ( $this->core->get_option( 'check_semantic_alignment', false ) ) { |
| 2050 |
$steps[] = 'summary'; // Includes intent, entities, semantic alignment |
| 2051 |
} |
| 2052 |
if ( $this->core->get_option( 'check_grammar_typos', false ) ) { |
| 2053 |
$steps[] = 'grammar'; |
| 2054 |
} |
| 2055 |
if ( $this->core->get_option( 'check_authenticity_originality', false ) ) { |
| 2056 |
$steps[] = 'authenticity'; |
| 2057 |
} |
| 2058 |
if ( $this->core->get_option( 'check_personality_engagement', false ) ) { |
| 2059 |
$steps[] = 'personality'; |
| 2060 |
} |
| 2061 |
if ( $this->core->get_option( 'check_structure_quality', false ) ) { |
| 2062 |
$steps[] = 'structure'; |
| 2063 |
} |
| 2064 |
if ( $this->core->get_option( 'check_readability_score', false ) ) { |
| 2065 |
$steps[] = 'readability'; |
| 2066 |
} |
| 2067 |
if ( $this->core->get_option( 'check_topic_completeness', false ) ) { |
| 2068 |
$steps[] = 'topic'; |
| 2069 |
} |
| 2070 |
|
| 2071 |
return $steps; |
| 2072 |
} |
| 2073 |
|
| 2074 |
/** |
| 2075 |
* Run a single AI analysis step |
| 2076 |
* @param object $post WordPress post object |
| 2077 |
* @param string $step Step name (summary, grammar, authenticity, etc.) |
| 2078 |
* @return array|false Step result data or false on error |
| 2079 |
*/ |
| 2080 |
public function run_ai_step( $post, $step ) { |
| 2081 |
global $mwai; |
| 2082 |
|
| 2083 |
if ( !$mwai || !method_exists( $mwai, 'simpleTextQuery' ) ) { |
| 2084 |
return false; |
| 2085 |
} |
| 2086 |
|
| 2087 |
// Get current analysis data |
| 2088 |
$analysis = $this->analyze_post( $post ); |
| 2089 |
|
| 2090 |
try { |
| 2091 |
switch ( $step ) { |
| 2092 |
case 'summary': |
| 2093 |
return $this->run_summary_step( $post, $analysis ); |
| 2094 |
|
| 2095 |
case 'grammar': |
| 2096 |
$result = $this->analyze_grammar( $analysis ); |
| 2097 |
return [ |
| 2098 |
'grammar_score' => $result['score'], |
| 2099 |
'grammar_feedback' => $result['feedback'] |
| 2100 |
]; |
| 2101 |
|
| 2102 |
case 'authenticity': |
| 2103 |
$result = $this->analyze_authenticity_originality( $analysis ); |
| 2104 |
return [ |
| 2105 |
'authenticity_score' => $result['score'], |
| 2106 |
'authenticity_feedback' => $result['feedback'] |
| 2107 |
]; |
| 2108 |
|
| 2109 |
case 'personality': |
| 2110 |
$result = $this->analyze_personality_engagement( $analysis ); |
| 2111 |
return [ |
| 2112 |
'personality_score' => $result['score'], |
| 2113 |
'personality_feedback' => $result['feedback'] |
| 2114 |
]; |
| 2115 |
|
| 2116 |
case 'structure': |
| 2117 |
return [ |
| 2118 |
'structure_score' => $this->analyze_structure_quality( $analysis ) |
| 2119 |
]; |
| 2120 |
|
| 2121 |
case 'readability': |
| 2122 |
$result = $this->analyze_readability( $analysis ); |
| 2123 |
return [ |
| 2124 |
'readability_score' => $result['score'], |
| 2125 |
'readability_feedback' => $result['feedback'] |
| 2126 |
]; |
| 2127 |
|
| 2128 |
case 'topic': |
| 2129 |
$result = $this->analyze_topic_completeness( $analysis ); |
| 2130 |
return [ |
| 2131 |
'topic_completeness' => $result['score'], |
| 2132 |
'topic_feedback' => $result['feedback'] |
| 2133 |
]; |
| 2134 |
|
| 2135 |
default: |
| 2136 |
return false; |
| 2137 |
} |
| 2138 |
} catch ( Exception $e ) { |
| 2139 |
error_log( 'SEO Engine AI Step Error (' . $step . '): ' . $e->getMessage() ); |
| 2140 |
return false; |
| 2141 |
} |
| 2142 |
} |
| 2143 |
|
| 2144 |
/** |
| 2145 |
* Run the summary step (includes intent, entities, semantic alignment) |
| 2146 |
*/ |
| 2147 |
private function run_summary_step( $post, $analysis ) { |
| 2148 |
global $mwai; |
| 2149 |
|
| 2150 |
$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}"; |
| 2151 |
|
| 2152 |
$response = $mwai->simpleTextQuery( $summary_prompt, [ 'scope' => 'seo' ] ); |
| 2153 |
|
| 2154 |
// Remove markdown code blocks if present |
| 2155 |
$response = preg_replace( '/```json\s*/', '', $response ); |
| 2156 |
$response = preg_replace( '/```\s*$/', '', $response ); |
| 2157 |
$response = trim( $response ); |
| 2158 |
|
| 2159 |
// Try to parse JSON from response |
| 2160 |
$summary_result = json_decode( $response, true ); |
| 2161 |
|
| 2162 |
$result = [ |
| 2163 |
'summary' => '', |
| 2164 |
'intent' => 'General', |
| 2165 |
'entities' => [], |
| 2166 |
'confidence' => 0.5, |
| 2167 |
'semantic_alignment' => 0.0, |
| 2168 |
'recommended_length' => [] |
| 2169 |
]; |
| 2170 |
|
| 2171 |
if ( $summary_result && is_array( $summary_result ) ) { |
| 2172 |
$result['summary'] = $summary_result['summary'] ?? ''; |
| 2173 |
$result['intent'] = $summary_result['intent'] ?? 'General'; |
| 2174 |
$result['entities'] = $summary_result['entities'] ?? []; |
| 2175 |
$result['confidence'] = floatval( $summary_result['confidence'] ?? 0.5 ); |
| 2176 |
} |
| 2177 |
|
| 2178 |
// Calculate semantic alignment |
| 2179 |
$result['semantic_alignment'] = $this->calculate_semantic_alignment( $post, $analysis ); |
| 2180 |
|
| 2181 |
// Calculate recommended content length based on intent |
| 2182 |
$result['recommended_length'] = $this->calculate_recommended_length( $result['intent'], $analysis['word_count'] ); |
| 2183 |
|
| 2184 |
return $result; |
| 2185 |
} |
| 2186 |
|
| 2187 |
/** |
| 2188 |
* Merge AI step result into existing analysis data |
| 2189 |
* @param int $post_id Post ID |
| 2190 |
* @param array $step_data Step result from run_ai_step() |
| 2191 |
* @return bool Success |
| 2192 |
*/ |
| 2193 |
public function merge_ai_step( $post_id, $step_data ) { |
| 2194 |
$existing_data = get_post_meta( $post_id, '_mwseo_analysis', true ); |
| 2195 |
|
| 2196 |
if ( !$existing_data || !isset( $existing_data['ai'] ) ) { |
| 2197 |
return false; |
| 2198 |
} |
| 2199 |
|
| 2200 |
// Merge the step data into AI section |
| 2201 |
$existing_data['ai'] = array_merge( $existing_data['ai'], $step_data ); |
| 2202 |
|
| 2203 |
// Update the post meta with merged AI data |
| 2204 |
update_post_meta( $post_id, '_mwseo_analysis', $existing_data ); |
| 2205 |
|
| 2206 |
// Recalculate overall score with new AI data |
| 2207 |
// Use 'quick' mode which preserves existing AI data from post meta |
| 2208 |
$post = get_post( $post_id ); |
| 2209 |
if ( $post ) { |
| 2210 |
$result = $this->calculate( $post, 'quick' ); |
| 2211 |
update_post_meta( $post_id, '_mwseo_overall', $result['overall'] ); |
| 2212 |
update_post_meta( $post_id, '_mwseo_score', $result['overall'] ); |
| 2213 |
update_post_meta( $post_id, '_mwseo_analysis', $result ); |
| 2214 |
} |
| 2215 |
|
| 2216 |
// A fresh analysis means fresh diagnoses; cached AI improvement plans are stale now. |
| 2217 |
delete_post_meta( $post_id, '_mwseo_improve_plans' ); |
| 2218 |
|
| 2219 |
return true; |
| 2220 |
} |
| 2221 |
} |
| 2222 |
|