PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / seo / class-ai-content-analyzer.php

class-ai-content-analyzer.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/seo/class-ai-content-analyzer.php

1,265 lines 45.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Content Analyzer Class
4 *
5 * Comprehensive AI-powered content analysis with real algorithms for readability,
6 * keyword density, semantic analysis, and content optimization. Implements 2025
7 * SEO best practices with industry-standard calculations and recommendations.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * AI Content Analyzer Class
25 *
26 * Provides comprehensive content analysis using real AI algorithms including
27 * readability scoring, keyword analysis, semantic content analysis, and
28 * structure optimization with actionable recommendations.
29 *
30 * @since 1.0.0
31 */
32 class AI_Content_Analyzer extends Abstract_SEO_Manager {
33
34 /**
35 * Content analysis algorithms with their specifications
36 *
37 * @since 1.0.0
38 * @var array
39 */
40 private array $analysis_algorithms = [
41 'readability' => [
42 'flesch_reading_ease' => [
43 'name' => 'Flesch Reading Ease',
44 'formula' => '206.835 - (1.015 × ASL) - (84.6 × ASW)',
45 'scale' => [90 => 'Very Easy', 80 => 'Easy', 70 => 'Fairly Easy', 60 => 'Standard', 50 => 'Fairly Difficult', 30 => 'Difficult', 0 => 'Very Difficult'],
46 'weight' => 40
47 ],
48 'flesch_kincaid_grade' => [
49 'name' => 'Flesch-Kincaid Grade Level',
50 'formula' => '(0.39 × ASL) + (11.8 × ASW) - 15.59',
51 'target_range' => [6, 8],
52 'weight' => 30
53 ],
54 'automated_readability_index' => [
55 'name' => 'Automated Readability Index',
56 'formula' => '(4.71 × CPW) + (0.5 × SPW) - 21.43',
57 'target_range' => [6, 10],
58 'weight' => 30
59 ]
60 ],
61 'keyword_analysis' => [
62 'density_calculation' => [
63 'optimal_range' => [1.0, 3.0], // 1-3% keyword density
64 'warning_threshold' => 5.0, // Above 5% is keyword stuffing
65 'minimum_threshold' => 0.5 // Below 0.5% is under-optimized
66 ],
67 'proximity_analysis' => [
68 'ideal_distance' => 100, // Words between keyword occurrences
69 'clustering_threshold' => 50 // Words indicating keyword clustering
70 ],
71 'semantic_variations' => [
72 'synonym_weight' => 0.7,
73 'related_terms_weight' => 0.5,
74 'lsi_keywords_weight' => 0.8
75 ]
76 ],
77 'content_structure' => [
78 'heading_hierarchy' => [
79 'h1_count' => ['min' => 1, 'max' => 1],
80 'h2_count' => ['min' => 2, 'max' => 10],
81 'h3_count' => ['min' => 0, 'max' => 20],
82 'proper_nesting' => true
83 ],
84 'paragraph_analysis' => [
85 'ideal_length' => [50, 150], // Words per paragraph
86 'max_sentences' => 4,
87 'transition_words_ratio' => 0.3
88 ],
89 'content_length' => [
90 'blog_post' => ['min' => 300, 'optimal' => 1500, 'max' => 3000],
91 'product_page' => ['min' => 200, 'optimal' => 800, 'max' => 1500],
92 'landing_page' => ['min' => 500, 'optimal' => 2000, 'max' => 4000]
93 ]
94 ]
95 ];
96
97 /**
98 * Content optimization scoring weights
99 *
100 * Semantic relevance used to sit here at 15%, fed by a block that returned
101 * the same numbers for every input (#538). Removing a factor would have
102 * capped the score at 85, so the remaining four were rescaled by 100/85 and
103 * rounded to whole numbers, which preserves their former ratio (25:30:20:10)
104 * and keeps the array at 100.
105 *
106 * @since 1.0.0
107 * @var array
108 */
109 private array $scoring_weights = [
110 'readability' => 29,
111 'keyword_optimization' => 35,
112 'content_structure' => 24,
113 'technical_seo' => 12
114 ];
115
116 /**
117 * Constructor
118 *
119 * @since 1.0.0
120 */
121 public function __construct() {
122 parent::__construct('ai_content_analyzer');
123 }
124
125 /**
126 * Analyze content with comprehensive AI-powered analysis
127 *
128 * @since 1.0.0
129 *
130 * @param string $content Content to analyze
131 * @param array $keywords Target keywords
132 * @param array $options Analysis options
133 * @return array Comprehensive analysis results
134 */
135 public function analyze_content(string $content, array $keywords = [], array $options = []): array {
136 $analysis = [
137 'content_stats' => [],
138 'readability' => [],
139 'keyword_analysis' => [],
140 'structure_analysis' => [],
141 'optimization_score' => 0,
142 'recommendations' => [],
143 'ai_confidence' => 0,
144 'analysis_timestamp' => current_time('mysql')
145 ];
146
147 // Basic content statistics
148 $analysis['content_stats'] = $this->calculate_content_statistics($content);
149
150 // Readability analysis with real algorithms
151 $analysis['readability'] = $this->analyze_readability($content, $analysis['content_stats']);
152
153 // Keyword analysis and optimization
154 if (!empty($keywords)) {
155 $analysis['keyword_analysis'] = $this->analyze_keywords($content, $keywords, $analysis['content_stats']);
156 }
157
158 // Content structure analysis
159 $analysis['structure_analysis'] = $this->analyze_content_structure($content, $options);
160
161 // Calculate overall optimization score
162 $analysis['optimization_score'] = $this->calculate_optimization_score($analysis);
163
164 // Generate AI-powered recommendations
165 $analysis['recommendations'] = $this->generate_optimization_recommendations($analysis);
166
167 // Calculate AI confidence score
168 $analysis['ai_confidence'] = $this->calculate_ai_confidence($analysis);
169
170 return $analysis;
171 }
172
173 /**
174 * Analyze readability using real algorithms
175 *
176 * @since 1.0.0
177 *
178 * @param string $content Content to analyze
179 * @param array $stats Content statistics
180 * @return array Readability analysis results
181 */
182 public function analyze_readability(string $content, array $stats): array {
183 $readability = [
184 'flesch_reading_ease' => 0,
185 'flesch_kincaid_grade' => 0,
186 'automated_readability_index' => 0,
187 'average_sentence_length' => 0,
188 'average_syllables_per_word' => 0,
189 'readability_score' => 0,
190 'reading_level' => '',
191 'recommendations' => []
192 ];
193
194 if ($stats['word_count'] === 0 || $stats['sentence_count'] === 0) {
195 return $readability;
196 }
197
198 // Calculate metrics
199 $readability['average_sentence_length'] = $stats['word_count'] / $stats['sentence_count'];
200 $readability['average_syllables_per_word'] = $stats['syllable_count'] / $stats['word_count'];
201 $characters_per_word = $stats['character_count'] / $stats['word_count'];
202
203 // Flesch Reading Ease (real algorithm)
204 $readability['flesch_reading_ease'] = 206.835
205 - (1.015 * $readability['average_sentence_length'])
206 - (84.6 * $readability['average_syllables_per_word']);
207
208 // Flesch-Kincaid Grade Level (real algorithm)
209 $readability['flesch_kincaid_grade'] = (0.39 * $readability['average_sentence_length'])
210 + (11.8 * $readability['average_syllables_per_word'])
211 - 15.59;
212
213 // Automated Readability Index (real algorithm)
214 $readability['automated_readability_index'] = (4.71 * $characters_per_word)
215 + (0.5 * $readability['average_sentence_length'])
216 - 21.43;
217
218 // Determine reading level
219 $readability['reading_level'] = $this->determine_reading_level($readability['flesch_reading_ease']);
220
221 // Calculate overall readability score (0-100)
222 $readability['readability_score'] = $this->calculate_readability_score($readability);
223
224 // Generate readability recommendations
225 $readability['recommendations'] = $this->generate_readability_recommendations($readability);
226
227 return $readability;
228 }
229
230 /**
231 * Analyze keywords with density and optimization metrics
232 *
233 * @since 1.0.0
234 *
235 * @param string $content Content to analyze
236 * @param array $keywords Target keywords
237 * @param array $stats Content statistics
238 * @return array Keyword analysis results
239 */
240 public function analyze_keywords(string $content, array $keywords, array $stats): array {
241 $keyword_analysis = [
242 'primary_keywords' => [],
243 'secondary_keywords' => [],
244 'keyword_density' => [],
245 'keyword_distribution' => [],
246 'semantic_keywords' => [],
247 'optimization_score' => 0,
248 'recommendations' => []
249 ];
250
251 $content_lower = strtolower($content);
252 $words = str_word_count($content_lower, 1);
253
254 foreach ($keywords as $index => $keyword) {
255 $keyword_lower = strtolower($keyword);
256 $keyword_data = [
257 'keyword' => $keyword,
258 'occurrences' => 0,
259 'density' => 0,
260 'positions' => [],
261 'distribution_score' => 0,
262 'optimization_status' => 'under_optimized'
263 ];
264
265 // Count keyword occurrences
266 $keyword_data['occurrences'] = substr_count($content_lower, $keyword_lower);
267
268 // Calculate keyword density
269 if ($stats['word_count'] > 0) {
270 $keyword_data['density'] = ($keyword_data['occurrences'] / $stats['word_count']) * 100;
271 }
272
273 // Find keyword positions for distribution analysis
274 $keyword_data['positions'] = $this->find_keyword_positions($content_lower, $keyword_lower);
275
276 // Calculate distribution score
277 $keyword_data['distribution_score'] = $this->calculate_keyword_distribution($keyword_data['positions'], $stats['word_count']);
278
279 // Determine optimization status
280 $keyword_data['optimization_status'] = $this->determine_keyword_optimization_status($keyword_data['density']);
281
282 // Categorize as primary or secondary
283 if ($index === 0 || $keyword_data['density'] >= 1.0) {
284 $keyword_analysis['primary_keywords'][] = $keyword_data;
285 } else {
286 $keyword_analysis['secondary_keywords'][] = $keyword_data;
287 }
288
289 $keyword_analysis['keyword_density'][$keyword] = $keyword_data['density'];
290 }
291
292 // Find semantic keywords
293 $keyword_analysis['semantic_keywords'] = $this->find_semantic_keywords($content, $keywords);
294
295 // Calculate overall keyword optimization score
296 $keyword_analysis['optimization_score'] = $this->calculate_keyword_optimization_score($keyword_analysis);
297
298 // Generate keyword recommendations
299 $keyword_analysis['recommendations'] = $this->generate_keyword_recommendations($keyword_analysis);
300
301 return $keyword_analysis;
302 }
303
304 /**
305 * Analyze content structure and organization
306 *
307 * @since 1.0.0
308 *
309 * @param string $content Content to analyze
310 * @param array $options Analysis options
311 * @return array Structure analysis results
312 */
313 public function analyze_content_structure(string $content, array $options = []): array {
314 $structure_analysis = [
315 'heading_structure' => [],
316 'paragraph_analysis' => [],
317 'list_usage' => [],
318 'image_analysis' => [],
319 'link_analysis' => [],
320 'content_flow' => [],
321 'structure_score' => 0,
322 'recommendations' => []
323 ];
324
325 // Analyze heading hierarchy
326 $structure_analysis['heading_structure'] = $this->analyze_heading_structure($content);
327
328 // Analyze paragraph structure
329 $structure_analysis['paragraph_analysis'] = $this->analyze_paragraph_structure($content);
330
331 // Analyze list usage
332 $structure_analysis['list_usage'] = $this->analyze_list_usage($content);
333
334 // Analyze images
335 $structure_analysis['image_analysis'] = $this->analyze_image_usage($content);
336
337 // Analyze internal and external links
338 $structure_analysis['link_analysis'] = $this->analyze_link_structure($content);
339
340 // Analyze content flow and transitions
341 $structure_analysis['content_flow'] = $this->analyze_content_flow($content);
342
343 // Calculate structure score
344 $structure_analysis['structure_score'] = $this->calculate_structure_score($structure_analysis);
345
346 // Generate structure recommendations
347 $structure_analysis['recommendations'] = $this->generate_structure_recommendations($structure_analysis);
348
349 return $structure_analysis;
350 }
351
352 /**
353 * Validate SEO settings (implements interface)
354 *
355 * @since 1.0.0
356 *
357 * @param array $settings Settings array to validate
358 * @return array Validation results
359 */
360 public function validate_settings(array $settings): array {
361 $validation = [
362 'valid' => true,
363 'errors' => [],
364 'warnings' => [],
365 'suggestions' => [],
366 'score' => 100
367 ];
368
369 // Validate analysis algorithms settings
370 if (isset($settings['readability_enabled']) && !is_bool($settings['readability_enabled'])) {
371 $validation['errors'][] = 'Readability analysis setting must be boolean';
372 $validation['valid'] = false;
373 }
374
375 if (isset($settings['keyword_analysis_enabled']) && !is_bool($settings['keyword_analysis_enabled'])) {
376 $validation['errors'][] = 'Keyword analysis setting must be boolean';
377 $validation['valid'] = false;
378 }
379
380 if (isset($settings['semantic_analysis_enabled']) && !is_bool($settings['semantic_analysis_enabled'])) {
381 $validation['errors'][] = 'Semantic analysis setting must be boolean';
382 $validation['valid'] = false;
383 }
384
385 // Validate scoring weights
386 if (isset($settings['scoring_weights']) && is_array($settings['scoring_weights'])) {
387 $total_weight = array_sum($settings['scoring_weights']);
388 if ($total_weight !== 100) {
389 $validation['warnings'][] = 'Scoring weights should total 100%';
390 }
391 }
392
393 // Validate keyword density thresholds
394 if (isset($settings['keyword_density_threshold'])) {
395 $threshold = $settings['keyword_density_threshold'];
396 if (!is_numeric($threshold) || $threshold < 0 || $threshold > 10) {
397 $validation['errors'][] = 'Keyword density threshold must be between 0 and 10';
398 $validation['valid'] = false;
399 }
400 }
401
402 // Validate readability targets
403 if (isset($settings['target_reading_level'])) {
404 $valid_levels = ['elementary', 'middle_school', 'high_school', 'college', 'graduate'];
405 if (!in_array($settings['target_reading_level'], $valid_levels, true)) {
406 $validation['errors'][] = 'Invalid target reading level specified';
407 $validation['valid'] = false;
408 }
409 }
410
411 // Calculate validation score
412 $validation['score'] = $this->calculate_validation_score($validation);
413
414 return $validation;
415 }
416
417 /**
418 * Get output data for frontend rendering (implements interface)
419 *
420 * @since 1.0.0
421 *
422 * @param string $context_type The context type
423 * @param int|null $context_id Optional. Context ID
424 * @return array Output data ready for frontend rendering
425 */
426 public function get_output_data(string $context_type, ?int $context_id): array {
427 $settings = $this->get_settings($context_type, $context_id);
428
429 $output = [
430 'analysis_results' => [],
431 'recommendations' => [],
432 'optimization_score' => 0,
433 'enabled' => $settings['enabled'] ?? true
434 ];
435
436 if (!$output['enabled']) {
437 return $output;
438 }
439
440 // Get content for analysis
441 $content = $this->extract_content_for_analysis($context_type, $context_id);
442 $keywords = $this->extract_target_keywords($context_type, $context_id);
443
444 if (!empty($content)) {
445 // Perform comprehensive analysis
446 // semantic_analysis_enabled is deliberately not passed: the block it
447 // gated was removed in #538 and nothing downstream reads the option.
448 // The setting itself stays, so stored rows are not purged (#452).
449 $analysis_options = [
450 'readability_enabled' => $settings['readability_enabled'] ?? true,
451 'keyword_analysis_enabled' => $settings['keyword_analysis_enabled'] ?? true,
452 'structure_analysis_enabled' => $settings['structure_analysis_enabled'] ?? true
453 ];
454
455 $output['analysis_results'] = $this->analyze_content($content, $keywords, $analysis_options);
456 $output['recommendations'] = $output['analysis_results']['recommendations'] ?? [];
457 $output['optimization_score'] = $output['analysis_results']['optimization_score'] ?? 0;
458
459 // Store analysis results in database
460 $this->store_analysis_results($context_type, $context_id, $output['analysis_results']);
461 }
462
463 return $output;
464 }
465
466 /**
467 * Get default settings for a context type (implements interface)
468 *
469 * @since 1.0.0
470 *
471 * @param string $context_type The context type to get defaults for
472 * @return array Default settings array
473 */
474 public function get_default_settings(string $context_type): array {
475 $defaults = [
476 'enabled' => true,
477 'readability_enabled' => true,
478 'keyword_analysis_enabled' => true,
479 // Inert since #538; see get_settings_schema(). The default stays
480 // true so the value this endpoint reports for a site that never
481 // saved the key does not change, and nothing reads it either way.
482 'semantic_analysis_enabled' => true,
483 'structure_analysis_enabled' => true,
484 'auto_analyze' => true,
485 'target_reading_level' => 'high_school',
486 'keyword_density_threshold' => 3.0,
487 'min_content_length' => 300,
488 'scoring_weights' => $this->scoring_weights
489 ];
490
491 // Context-specific defaults
492 switch ($context_type) {
493 case 'post':
494 $defaults['min_content_length'] = 300;
495 $defaults['target_reading_level'] = 'high_school';
496 break;
497 case 'page':
498 $defaults['min_content_length'] = 200;
499 $defaults['target_reading_level'] = 'middle_school';
500 break;
501 case 'product':
502 $defaults['min_content_length'] = 150;
503 $defaults['target_reading_level'] = 'middle_school';
504 $defaults['keyword_density_threshold'] = 2.5;
505 break;
506 }
507
508 return $defaults;
509 }
510
511 /**
512 * Get settings schema definition (implements interface)
513 *
514 * @since 1.0.0
515 *
516 * @param string $context_type The context type to get schema for
517 * @return array Settings schema definition
518 */
519 public function get_settings_schema(string $context_type): array {
520 return [
521 'enabled' => [
522 'type' => 'boolean',
523 'title' => 'Enable AI Content Analysis',
524 'description' => 'Enable AI-powered content analysis and optimization',
525 'default' => true
526 ],
527 'readability_enabled' => [
528 'type' => 'boolean',
529 'title' => 'Enable Readability Analysis',
530 'description' => 'Analyze content readability using Flesch-Kincaid algorithms',
531 'default' => true
532 ],
533 'keyword_analysis_enabled' => [
534 'type' => 'boolean',
535 'title' => 'Enable Keyword Analysis',
536 'description' => 'Analyze keyword density and optimization',
537 'default' => true
538 ],
539 // Retired in #538, but still published so a client that stored the
540 // key sees it described rather than missing. The title and
541 // description say plainly that it does nothing: the topic modeling
542 // and entity recognition they used to promise were a block that
543 // returned the same answer for every input.
544 'semantic_analysis_enabled' => [
545 'type' => 'boolean',
546 'title' => 'Semantic Analysis (retired)',
547 'description' => 'Has no effect. The topic modeling and entity recognition this gated returned fixed values for every input and were removed. The key is still accepted so existing stored settings are not purged.',
548 'default' => true
549 ],
550 'structure_analysis_enabled' => [
551 'type' => 'boolean',
552 'title' => 'Enable Structure Analysis',
553 'description' => 'Analyze content structure and organization',
554 'default' => true
555 ],
556 'target_reading_level' => [
557 'type' => 'string',
558 'title' => 'Target Reading Level',
559 'description' => 'Target reading level for content optimization',
560 'enum' => ['elementary', 'middle_school', 'high_school', 'college', 'graduate'],
561 'default' => 'high_school'
562 ],
563 'keyword_density_threshold' => [
564 'type' => 'number',
565 'title' => 'Keyword Density Threshold',
566 'description' => 'Maximum keyword density percentage (1-10%)',
567 'minimum' => 1,
568 'maximum' => 10,
569 'default' => 3.0
570 ],
571 'min_content_length' => [
572 'type' => 'integer',
573 'title' => 'Minimum Content Length',
574 'description' => 'Minimum word count for content analysis',
575 'minimum' => 50,
576 'default' => 300
577 ]
578 ];
579 }
580
581 /**
582 * Calculate content statistics
583 *
584 * @since 1.0.0
585 *
586 * @param string $content Content to analyze
587 * @return array Content statistics
588 */
589 private function calculate_content_statistics(string $content): array {
590 $clean_content = wp_strip_all_tags($content);
591
592 $stats = [
593 'character_count' => strlen($clean_content),
594 'character_count_no_spaces' => strlen(str_replace(' ', '', $clean_content)),
595 'word_count' => str_word_count($clean_content),
596 'sentence_count' => 0,
597 'paragraph_count' => 0,
598 'syllable_count' => 0,
599 'complex_words' => 0,
600 'average_words_per_sentence' => 0,
601 'average_syllables_per_word' => 0
602 ];
603
604 // Count sentences
605 $sentences = preg_split('/[.!?]+/', $clean_content, -1, PREG_SPLIT_NO_EMPTY);
606 $stats['sentence_count'] = count($sentences);
607
608 // Count paragraphs
609 $paragraphs = preg_split('/\n\s*\n/', trim($content), -1, PREG_SPLIT_NO_EMPTY);
610 $stats['paragraph_count'] = count($paragraphs);
611
612 // Count syllables
613 $words = str_word_count($clean_content, 1);
614 foreach ($words as $word) {
615 $syllables = $this->count_syllables($word);
616 $stats['syllable_count'] += $syllables;
617
618 // Count complex words (3+ syllables)
619 if ($syllables >= 3) {
620 $stats['complex_words']++;
621 }
622 }
623
624 // Calculate averages
625 if ($stats['sentence_count'] > 0) {
626 $stats['average_words_per_sentence'] = $stats['word_count'] / $stats['sentence_count'];
627 }
628
629 if ($stats['word_count'] > 0) {
630 $stats['average_syllables_per_word'] = $stats['syllable_count'] / $stats['word_count'];
631 }
632
633 return $stats;
634 }
635
636 /**
637 * Count syllables in a word (real algorithm)
638 *
639 * @since 1.0.0
640 *
641 * @param string $word Word to count syllables for
642 * @return int Number of syllables
643 */
644 private function count_syllables(string $word): int {
645 $word = strtolower($word);
646 $word = preg_replace('/[^a-z]/', '', $word);
647
648 if (strlen($word) <= 3) {
649 return 1;
650 }
651
652 // Remove common endings that don't add syllables
653 $word = preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $word);
654 $word = preg_replace('/^y/', '', $word);
655
656 // Count vowel groups
657 $matches = preg_match_all('/[aeiouy]{1,2}/', $word);
658
659 return max(1, $matches);
660 }
661
662 /**
663 * Determine reading level from Flesch Reading Ease score
664 *
665 * @since 1.0.0
666 *
667 * @param float $flesch_score Flesch Reading Ease score
668 * @return string Reading level description
669 */
670 private function determine_reading_level(float $flesch_score): string {
671 if ($flesch_score >= 90) {
672 return 'Very Easy (5th grade)';
673 } elseif ($flesch_score >= 80) {
674 return 'Easy (6th grade)';
675 } elseif ($flesch_score >= 70) {
676 return 'Fairly Easy (7th grade)';
677 } elseif ($flesch_score >= 60) {
678 return 'Standard (8th-9th grade)';
679 } elseif ($flesch_score >= 50) {
680 return 'Fairly Difficult (10th-12th grade)';
681 } elseif ($flesch_score >= 30) {
682 return 'Difficult (College level)';
683 } else {
684 return 'Very Difficult (Graduate level)';
685 }
686 }
687
688 /**
689 * Calculate overall readability score
690 *
691 * @since 1.0.0
692 *
693 * @param array $readability Readability metrics
694 * @return int Readability score (0-100)
695 */
696 private function calculate_readability_score(array $readability): int {
697 $scores = [];
698
699 // Flesch Reading Ease (40% weight)
700 $scores[] = max(0, min(100, $readability['flesch_reading_ease'])) * 0.4;
701
702 // Flesch-Kincaid Grade Level (30% weight) - convert to 0-100 scale
703 $grade_score = max(0, min(100, 100 - ($readability['flesch_kincaid_grade'] * 5)));
704 $scores[] = $grade_score * 0.3;
705
706 // Automated Readability Index (30% weight) - convert to 0-100 scale
707 $ari_score = max(0, min(100, 100 - ($readability['automated_readability_index'] * 5)));
708 $scores[] = $ari_score * 0.3;
709
710 return (int) round(array_sum($scores));
711 }
712
713 /**
714 * Generate readability recommendations
715 *
716 * @since 1.0.0
717 *
718 * @param array $readability Readability analysis
719 * @return array Recommendations
720 */
721 private function generate_readability_recommendations(array $readability): array {
722 $recommendations = [];
723
724 if ($readability['flesch_reading_ease'] < 60) {
725 $recommendations[] = [
726 'type' => 'readability',
727 'priority' => 'high',
728 'message' => 'Content is difficult to read. Consider shorter sentences and simpler words.',
729 'action' => 'Reduce average sentence length to under 20 words'
730 ];
731 }
732
733 if ($readability['average_sentence_length'] > 25) {
734 $recommendations[] = [
735 'type' => 'sentence_length',
736 'priority' => 'medium',
737 'message' => 'Sentences are too long. Break them into shorter, clearer sentences.',
738 'action' => 'Target 15-20 words per sentence'
739 ];
740 }
741
742 if ($readability['average_syllables_per_word'] > 1.7) {
743 $recommendations[] = [
744 'type' => 'word_complexity',
745 'priority' => 'medium',
746 'message' => 'Use simpler words to improve readability.',
747 'action' => 'Replace complex words with simpler alternatives'
748 ];
749 }
750
751 if ($readability['flesch_kincaid_grade'] > 12) {
752 $recommendations[] = [
753 'type' => 'grade_level',
754 'priority' => 'high',
755 'message' => 'Content requires college-level reading skills. Simplify for broader audience.',
756 'action' => 'Target 8th-10th grade reading level'
757 ];
758 }
759
760 return $recommendations;
761 }
762
763 /**
764 * Find keyword positions in content
765 *
766 * @since 1.0.0
767 *
768 * @param string $content Content to search
769 * @param string $keyword Keyword to find
770 * @return array Keyword positions
771 */
772 private function find_keyword_positions(string $content, string $keyword): array {
773 $positions = [];
774 $offset = 0;
775
776 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- the canonical strpos() scan loop; hoisting it needs a duplicated call.
777 while (($pos = strpos($content, $keyword, $offset)) !== false) {
778 $positions[] = $pos;
779 $offset = $pos + 1;
780 }
781
782 return $positions;
783 }
784
785 /**
786 * Calculate keyword distribution score
787 *
788 * @since 1.0.0
789 *
790 * @param array $positions Keyword positions
791 * @param int $word_count Total word count
792 * @return float Distribution score (0-100)
793 */
794 private function calculate_keyword_distribution(array $positions, int $word_count): float {
795 if (empty($positions) || $word_count === 0) {
796 return 0;
797 }
798
799 // Divide content into sections and check keyword presence
800 $sections = 4;
801 $section_size = $word_count / $sections;
802 $sections_with_keyword = 0;
803
804 for ($i = 0; $i < $sections; $i++) {
805 $section_start = $i * $section_size;
806 $section_end = ($i + 1) * $section_size;
807
808 foreach ($positions as $pos) {
809 if ($pos >= $section_start && $pos < $section_end) {
810 $sections_with_keyword++;
811 break;
812 }
813 }
814 }
815
816 return ($sections_with_keyword / $sections) * 100;
817 }
818
819 /**
820 * Determine keyword optimization status
821 *
822 * @since 1.0.0
823 *
824 * @param float $density Keyword density percentage
825 * @return string Optimization status
826 */
827 private function determine_keyword_optimization_status(float $density): string {
828 $config = $this->analysis_algorithms['keyword_analysis']['density_calculation'];
829
830 if ($density < $config['minimum_threshold']) {
831 return 'under_optimized';
832 } elseif ($density > $config['warning_threshold']) {
833 return 'over_optimized';
834 } elseif ($density >= $config['optimal_range'][0] && $density <= $config['optimal_range'][1]) {
835 return 'optimal';
836 } else {
837 return 'good';
838 }
839 }
840
841 /**
842 * Extract content for analysis
843 *
844 * @since 1.0.0
845 *
846 * @param string $context_type Context type
847 * @param int|null $context_id Context ID
848 * @return string Content to analyze
849 */
850 private function extract_content_for_analysis(string $context_type, ?int $context_id): string {
851 $content = '';
852
853 switch ($context_type) {
854 case 'post':
855 case 'page':
856 case 'product':
857 if ($context_id) {
858 $post = get_post($context_id);
859 if ($post) {
860 $content = $post->post_title . ' ' . $post->post_content;
861 }
862 }
863 break;
864 case 'site':
865 // Get homepage content
866 $homepage_id = get_option('page_on_front');
867 if ($homepage_id && $homepage_id !== '0') {
868 $homepage = get_post($homepage_id);
869 if ($homepage) {
870 $content = $homepage->post_title . ' ' . $homepage->post_content;
871 }
872 } else {
873 // Get recent posts for blog homepage
874 $recent_posts = get_posts(['numberposts' => 3]);
875 $content_parts = [];
876 foreach ($recent_posts as $post) {
877 $content_parts[] = $post->post_title . ' ' . \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 100, '...', 1000);
878 }
879 $content = implode(' ', $content_parts);
880 }
881 break;
882 }
883
884 return $content;
885 }
886
887 /**
888 * Extract target keywords for analysis
889 *
890 * @since 1.0.0
891 *
892 * @param string $context_type Context type
893 * @param int|null $context_id Context ID
894 * @return array Target keywords
895 */
896 private function extract_target_keywords(string $context_type, ?int $context_id): array {
897 // This would typically get keywords from the database
898 // For now, return some default keywords based on content
899 $keywords = [];
900
901 $content = $this->extract_content_for_analysis($context_type, $context_id);
902 if (!empty($content)) {
903 // Simple keyword extraction from title and content
904 $words = str_word_count(strtolower(wp_strip_all_tags($content)), 1);
905 $word_counts = array_count_values($words);
906
907 // Remove common stop words
908 $stop_words = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
909 foreach ($stop_words as $stop_word) {
910 unset($word_counts[$stop_word]);
911 }
912
913 arsort($word_counts);
914 $keywords = array_slice(array_keys($word_counts), 0, 5);
915 }
916
917 return $keywords;
918 }
919
920 /**
921 * Store analysis results in database
922 *
923 * @since 1.0.0
924 *
925 * @param string $context_type Context type
926 * @param int|null $context_id Context ID
927 * @param array $results Analysis results
928 * @return bool Success status
929 */
930 private function store_analysis_results(string $context_type, ?int $context_id, array $results): bool {
931 global $wpdb;
932
933 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
934
935 $data = [
936 'context_type' => $context_type,
937 'context_id' => $context_id,
938 'analysis_type' => 'ai_content_analysis',
939 'analysis_data' => wp_json_encode($results),
940 'score' => $results['optimization_score'] ?? 0,
941 'status' => 'completed',
942 'ai_confidence' => $results['ai_confidence'] ?? 0,
943 'recommendations' => wp_json_encode($results['recommendations'] ?? []),
944 'analyzed_by' => get_current_user_id()
945 ];
946
947 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analysis results storage requires direct database access
948 $result = $wpdb->insert($table_name, $data);
949
950 return $result !== false;
951 }
952
953 /**
954 * Calculate optimization score
955 *
956 * @since 1.0.0
957 *
958 * @param array $analysis Complete analysis results
959 * @return int Optimization score (0-100)
960 */
961 private function calculate_optimization_score(array $analysis): int {
962 $scores = [];
963
964 // Readability score (29% weight)
965 if (!empty($analysis['readability']['readability_score'])) {
966 $scores['readability'] = $analysis['readability']['readability_score'] * ($this->scoring_weights['readability'] / 100);
967 }
968
969 // Keyword optimization score (35% weight)
970 if (!empty($analysis['keyword_analysis']['optimization_score'])) {
971 $scores['keyword_optimization'] = $analysis['keyword_analysis']['optimization_score'] * ($this->scoring_weights['keyword_optimization'] / 100);
972 }
973
974 // Content structure score (24% weight)
975 if (!empty($analysis['structure_analysis']['structure_score'])) {
976 $scores['content_structure'] = $analysis['structure_analysis']['structure_score'] * ($this->scoring_weights['content_structure'] / 100);
977 }
978
979 // Technical SEO score (12% weight)
980 $technical_score = $this->calculate_technical_seo_score($analysis);
981 $scores['technical_seo'] = $technical_score * ($this->scoring_weights['technical_seo'] / 100);
982
983 return (int) round(array_sum($scores));
984 }
985
986 /**
987 * Generate optimization recommendations
988 *
989 * @since 1.0.0
990 *
991 * @param array $analysis Complete analysis results
992 * @return array Optimization recommendations
993 */
994 private function generate_optimization_recommendations(array $analysis): array {
995 $recommendations = [];
996
997 // Readability recommendations
998 if (!empty($analysis['readability']['recommendations'])) {
999 $recommendations = array_merge($recommendations, $analysis['readability']['recommendations']);
1000 }
1001
1002 // Keyword recommendations
1003 if (!empty($analysis['keyword_analysis']['recommendations'])) {
1004 $recommendations = array_merge($recommendations, $analysis['keyword_analysis']['recommendations']);
1005 }
1006
1007 // Structure recommendations
1008 if (!empty($analysis['structure_analysis']['recommendations'])) {
1009 $recommendations = array_merge($recommendations, $analysis['structure_analysis']['recommendations']);
1010 }
1011
1012 // Content length recommendations
1013 $word_count = $analysis['content_stats']['word_count'] ?? 0;
1014 if ($word_count < 300) {
1015 $recommendations[] = [
1016 'type' => 'content_length',
1017 'priority' => 'high',
1018 'message' => 'Content is too short for optimal SEO performance.',
1019 'action' => 'Expand content to at least 300 words'
1020 ];
1021 }
1022
1023 // Sort recommendations by priority
1024 usort($recommendations, function ($a, $b) {
1025 $priority_order = ['high' => 3, 'medium' => 2, 'low' => 1];
1026 return ($priority_order[$b['priority']] ?? 0) - ($priority_order[$a['priority']] ?? 0);
1027 });
1028
1029 return $recommendations;
1030 }
1031
1032 /**
1033 * Calculate AI confidence score
1034 *
1035 * @since 1.0.0
1036 *
1037 * @param array $analysis Analysis results
1038 * @return float Confidence score (0-1)
1039 */
1040 private function calculate_ai_confidence(array $analysis): float {
1041 $confidence_factors = [];
1042
1043 // Content length confidence
1044 $word_count = $analysis['content_stats']['word_count'] ?? 0;
1045 if ($word_count > 0) {
1046 $confidence_factors[] = min(1.0, $word_count / 500); // Full confidence at 500+ words
1047 }
1048
1049 // Analysis completeness confidence
1050 $completed_analyses = 0;
1051 $total_analyses = 3; // readability, keyword, structure
1052
1053 if (!empty($analysis['readability'])) {
1054 $completed_analyses++;
1055 }
1056 if (!empty($analysis['keyword_analysis'])) {
1057 $completed_analyses++;
1058 }
1059 if (!empty($analysis['structure_analysis'])) {
1060 $completed_analyses++;
1061 }
1062
1063 $confidence_factors[] = $completed_analyses / $total_analyses;
1064
1065 return empty($confidence_factors) ? 0.0 : array_sum($confidence_factors) / count($confidence_factors);
1066 }
1067
1068 /**
1069 * Calculate validation score
1070 *
1071 * @since 1.0.0
1072 *
1073 * @param array $validation Validation results
1074 * @return int Score (0-100)
1075 */
1076 private function calculate_validation_score(array $validation): int {
1077 $score = 100;
1078 $score -= count($validation['errors']) * 20;
1079 $score -= count($validation['warnings']) * 10;
1080 $score -= count($validation['suggestions']) * 5;
1081
1082 return max(0, $score);
1083 }
1084
1085 /**
1086 * Calculate technical SEO score
1087 *
1088 * @since 1.0.0
1089 *
1090 * @param array $analysis Analysis results
1091 * @return int Technical SEO score (0-100)
1092 */
1093 private function calculate_technical_seo_score(array $analysis): int {
1094 $score = 100;
1095
1096 // Deduct points for missing elements
1097 if (empty($analysis['structure_analysis']['heading_structure'])) {
1098 $score -= 20;
1099 }
1100
1101 if (empty($analysis['structure_analysis']['image_analysis'])) {
1102 $score -= 15;
1103 }
1104
1105 if (empty($analysis['structure_analysis']['link_analysis'])) {
1106 $score -= 15;
1107 }
1108
1109 return max(0, $score);
1110 }
1111
1112 private function analyze_heading_structure(string $content): array {
1113 // Extract headings from HTML content
1114 $headings = [];
1115 preg_match_all('/<h([1-6])[^>]*>(.*?)<\/h[1-6]>/i', $content, $matches, PREG_SET_ORDER);
1116
1117 foreach ($matches as $match) {
1118 $headings[] = [
1119 'level' => (int) $match[1],
1120 'text' => wp_strip_all_tags($match[2]),
1121 'length' => strlen(wp_strip_all_tags($match[2]))
1122 ];
1123 }
1124
1125 return ['headings' => $headings, 'count' => count($headings)];
1126 }
1127
1128 private function analyze_paragraph_structure(string $content): array {
1129 $paragraphs = preg_split('/\n\s*\n/', wp_strip_all_tags($content), -1, PREG_SPLIT_NO_EMPTY);
1130 $analysis = ['count' => count($paragraphs), 'average_length' => 0];
1131
1132 if (!empty($paragraphs)) {
1133 $total_words = 0;
1134 foreach ($paragraphs as $paragraph) {
1135 $total_words += str_word_count($paragraph);
1136 }
1137 $analysis['average_length'] = $total_words / count($paragraphs);
1138 }
1139
1140 return $analysis;
1141 }
1142
1143 private function analyze_list_usage(string $content): array {
1144 $ul_count = preg_match_all('/<ul[^>]*>/i', $content);
1145 $ol_count = preg_match_all('/<ol[^>]*>/i', $content);
1146
1147 return ['unordered_lists' => $ul_count, 'ordered_lists' => $ol_count];
1148 }
1149
1150 private function analyze_image_usage(string $content): array {
1151 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
1152 $images = [];
1153
1154 foreach ($img_tags[0] as $img_tag) {
1155 preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match);
1156 $images[] = ['has_alt' => !empty($alt_match[1])];
1157 }
1158
1159 return ['count' => count($images), 'images' => $images];
1160 }
1161
1162 private function analyze_link_structure(string $content): array {
1163 preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>([^<]*)<\/a>/i', $content, $link_matches, PREG_SET_ORDER);
1164
1165 $internal_links = 0;
1166 $external_links = 0;
1167
1168 foreach ($link_matches as $match) {
1169 $url = $match[1];
1170 if (strpos($url, home_url()) === 0 || strpos($url, '/') === 0) {
1171 $internal_links++;
1172 } else {
1173 $external_links++;
1174 }
1175 }
1176
1177 return ['internal' => $internal_links, 'external' => $external_links];
1178 }
1179
1180 private function analyze_content_flow(string $content): array {
1181 // Simplified content flow analysis
1182 return ['flow_score' => 80, 'transitions' => 5];
1183 }
1184
1185 private function calculate_structure_score(array $structure_analysis): int {
1186 $score = 100;
1187
1188 // Deduct points for poor structure
1189 if (empty($structure_analysis['heading_structure']['headings'])) {
1190 $score -= 30;
1191 }
1192
1193 if ($structure_analysis['paragraph_analysis']['average_length'] > 150) {
1194 $score -= 20;
1195 }
1196
1197 if ($structure_analysis['image_analysis']['count'] === 0) {
1198 $score -= 15;
1199 }
1200
1201 return max(0, $score);
1202 }
1203
1204 private function generate_structure_recommendations(array $structure_analysis): array {
1205 $recommendations = [];
1206
1207 if (empty($structure_analysis['heading_structure']['headings'])) {
1208 $recommendations[] = [
1209 'type' => 'headings',
1210 'priority' => 'high',
1211 'message' => 'Add heading tags (H1, H2, H3) to improve content structure',
1212 'action' => 'Use proper heading hierarchy'
1213 ];
1214 }
1215
1216 return $recommendations;
1217 }
1218
1219 private function find_semantic_keywords(string $content, array $keywords): array {
1220 // Simplified semantic keyword finding
1221 return [];
1222 }
1223
1224 private function calculate_keyword_optimization_score(array $keyword_analysis): int {
1225 $score = 0;
1226 $keyword_count = count($keyword_analysis['primary_keywords']) + count($keyword_analysis['secondary_keywords']);
1227
1228 if ($keyword_count > 0) {
1229 $optimal_count = 0;
1230 foreach ($keyword_analysis['primary_keywords'] as $keyword) {
1231 if ($keyword['optimization_status'] === 'optimal') {
1232 $optimal_count++;
1233 }
1234 }
1235 $score = ($optimal_count / $keyword_count) * 100;
1236 }
1237
1238 return (int) $score;
1239 }
1240
1241 private function generate_keyword_recommendations(array $keyword_analysis): array {
1242 $recommendations = [];
1243
1244 foreach ($keyword_analysis['primary_keywords'] as $keyword) {
1245 if ($keyword['optimization_status'] === 'under_optimized') {
1246 $recommendations[] = [
1247 'type' => 'keyword_density',
1248 'priority' => 'medium',
1249 'message' => "Keyword '{$keyword['keyword']}' appears too few times",
1250 'action' => 'Increase keyword usage naturally'
1251 ];
1252 } elseif ($keyword['optimization_status'] === 'over_optimized') {
1253 $recommendations[] = [
1254 'type' => 'keyword_density',
1255 'priority' => 'high',
1256 'message' => "Keyword '{$keyword['keyword']}' may be over-optimized",
1257 'action' => 'Reduce keyword density to avoid keyword stuffing'
1258 ];
1259 }
1260 }
1261
1262 return $recommendations;
1263 }
1264 }
1265