| 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 |
|