PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / score.php

score.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/score.php

2,222 lines 78.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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