PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.0.1
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 1.0.2 All 50 releases
thinkrank / includes / ai / class-seo-score-calculator.php

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

1,982 lines 71.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SEO Score Calculator
4 *
5 * Advanced SEO scoring system based on 2025 Google ranking factors
6 * Implements AI-driven content analysis, searcher engagement signals, and current SEO best practices
7 *
8 * @package ThinkRank\AI
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\AI;
15
16 use ThinkRank\Core\Database;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * SEO Score Calculator Class
25 *
26 * Implements 2025 SEO scoring algorithm based on:
27 * - Google's Q1 2025 algorithm updates (First Page Sage research)
28 * - Satisfying content as #1 ranking factor (23%)
29 * - Searcher engagement and intent satisfaction (12%)
30 * - Mobile Experience Score (MES) and Core Web Vitals 2.0
31 * - Content freshness and niche expertise signals
32 *
33 * @since 1.0.0
34 */
35 class SEOScoreCalculator {
36
37 /**
38 * Database instance
39 *
40 * @var Database
41 */
42 private Database $database;
43
44 /**
45 * 2025 SEO scoring factors (Q1 2025 Google Algorithm)
46 * Based on First Page Sage research and Google's latest updates
47 *
48 * @var array
49 */
50 private array $scoring_factors = [
51 'satisfying_content' => 23, // #1: Consistent publication of satisfying content
52 'title_optimization' => 14, // #2: Keyword in meta title (looser matching)
53 'niche_expertise' => 13, // #3: Hub & spoke content clusters
54 'searcher_engagement' => 12, // #4: Dwell time, bounce rate, pages/session
55 'backlink_authority' => 13, // #5: Quality backlinks (declining but important)
56 'content_freshness' => 6, // #6: Quarterly content updates
57 'mobile_experience' => 5, // #7: Mobile Experience Score (MES) - NEW 2025
58 'trustworthiness' => 4, // #8: E-E-A-T verification
59 'link_diversity' => 3, // #9: Link distribution across multiple pages
60 'core_web_vitals' => 3, // #10: Page speed + Interaction Readiness
61 'site_security' => 2, // #11: SSL certificate
62 'internal_linking' => 1, // #12: Declining importance
63 'technical_factors' => 1, // #13: Meta descriptions, schema, etc.
64 ];
65
66 /**
67 * Constructor
68 *
69 * @param Database $database Database instance
70 */
71 public function __construct(Database $database) {
72 $this->database = $database;
73 }
74
75 /**
76 * Calculate comprehensive modern SEO score
77 *
78 * @param array $content_data Content analysis data
79 * @param array $metadata Post metadata
80 * @param array $options Additional options
81 * @return array Complete scoring result
82 */
83 public function calculate_score(array $content_data, array $metadata, array $options = []): array {
84 $scores = [];
85 $suggestions = [];
86 $total_score = 0;
87
88 try {
89 // 1. Satisfying Content (23 points) - #1 factor in 2025
90 $satisfying_result = $this->score_satisfying_content($content_data, $options['target_keyword'] ?? '');
91 $scores['satisfying_content'] = $satisfying_result;
92 $total_score += $satisfying_result['score'];
93 $suggestions = array_merge($suggestions, $satisfying_result['suggestions']);
94 } catch (\Exception $e) {
95 throw $e;
96 }
97
98 try {
99 // 2. Title Optimization (14 points) - Looser keyword matching in 2025
100 $title_result = $this->score_2025_title_optimization($metadata['title'] ?? '', $options['target_keyword'] ?? '');
101 $scores['title_optimization'] = $title_result;
102 $total_score += $title_result['score'];
103 $suggestions = array_merge($suggestions, $title_result['suggestions']);
104 } catch (\Exception $e) {
105 throw $e;
106 }
107
108 try {
109 // 3. Niche Expertise (13 points) - Hub & spoke content clusters
110 $expertise_result = $this->score_niche_expertise($content_data, $options['target_keyword'] ?? '');
111 $scores['niche_expertise'] = $expertise_result;
112 $total_score += $expertise_result['score'];
113 $suggestions = array_merge($suggestions, $expertise_result['suggestions']);
114 } catch (\Exception $e) {
115 throw $e;
116 }
117
118 try {
119 // 4. Searcher Engagement (12 points) - Dwell time, bounce rate, pages/session
120 $engagement_result = $this->score_searcher_engagement($content_data);
121 $scores['searcher_engagement'] = $engagement_result;
122 $total_score += $engagement_result['score'];
123 $suggestions = array_merge($suggestions, $engagement_result['suggestions']);
124 } catch (\Exception $e) {
125 throw $e;
126 }
127
128 try {
129 // 5. Backlink Authority (13 points) - Quality backlinks
130 $backlink_result = $this->score_backlink_authority($content_data);
131 $scores['backlink_authority'] = $backlink_result;
132 $total_score += $backlink_result['score'];
133 $suggestions = array_merge($suggestions, $backlink_result['suggestions']);
134 } catch (\Exception $e) {
135 throw $e;
136 }
137
138 // 6. Content Freshness (6 points) - Quarterly updates priority
139 $freshness_result = $this->score_content_freshness($content_data);
140 $scores['content_freshness'] = $freshness_result;
141 $total_score += $freshness_result['score'];
142 $suggestions = array_merge($suggestions, $freshness_result['suggestions']);
143
144 // 7. Mobile Experience (5 points) - NEW: Mobile Experience Score (MES)
145 $mobile_result = $this->score_mobile_experience($content_data);
146 $scores['mobile_experience'] = $mobile_result;
147 $total_score += $mobile_result['score'];
148 $suggestions = array_merge($suggestions, $mobile_result['suggestions']);
149
150 // 8. Trustworthiness (4 points) - E-E-A-T verification
151 $trust_result = $this->score_trustworthiness($content_data, $metadata);
152 $scores['trustworthiness'] = $trust_result;
153 $total_score += $trust_result['score'];
154 $suggestions = array_merge($suggestions, $trust_result['suggestions']);
155
156 // 9. Link Diversity (3 points) - Multiple pages with backlinks
157 $diversity_result = $this->score_link_diversity($content_data);
158 $scores['link_diversity'] = $diversity_result;
159 $total_score += $diversity_result['score'];
160 $suggestions = array_merge($suggestions, $diversity_result['suggestions']);
161
162 // 10. Core Web Vitals (3 points) - Interaction Readiness + CLS 2.0
163 $vitals_result = $this->score_core_web_vitals($content_data);
164 $scores['core_web_vitals'] = $vitals_result;
165 $total_score += $vitals_result['score'];
166 $suggestions = array_merge($suggestions, $vitals_result['suggestions']);
167
168 // 11. Site Security (2 points) - SSL certificate
169 $security_result = $this->score_site_security($content_data);
170 $scores['site_security'] = $security_result;
171 $total_score += $security_result['score'];
172 $suggestions = array_merge($suggestions, $security_result['suggestions']);
173
174 // 12. Internal Linking (1 point) - Declining importance
175 $internal_result = $this->score_internal_linking($content_data);
176 $scores['internal_linking'] = $internal_result;
177 $total_score += $internal_result['score'];
178 $suggestions = array_merge($suggestions, $internal_result['suggestions']);
179
180 // 13. Technical Factors (1 point) - Meta descriptions, schema, etc.
181 $technical_result = $this->score_technical_factors($content_data, $metadata, $options);
182 $scores['technical_factors'] = $technical_result;
183 $total_score += $technical_result['score'];
184 $suggestions = array_merge($suggestions, $technical_result['suggestions']);
185
186 try {
187 $prioritized_suggestions = $this->prioritize_suggestions($suggestions);
188 $grade = $this->get_grade_from_score($total_score);
189
190 return [
191 'overall_score' => min(100, $total_score),
192 'score_breakdown' => $scores,
193 'suggestions' => $prioritized_suggestions,
194 'grade' => $grade,
195 'calculated_at' => current_time('mysql'),
196 'algorithm_version' => '2025.1',
197 'algorithm_source' => 'First Page Sage Q1 2025 Research',
198 'factors_count' => count($scores),
199 ];
200 } catch (\Exception $e) {
201 throw $e;
202 }
203 }
204
205 /**
206 * Score satisfying content - #1 factor in 2025 (23 points)
207 * Google tests content to see if it satisfies search intent
208 *
209 * @param array $content_data Content analysis data
210 * @param string $target_keyword Target keyword
211 * @return array Scoring result
212 */
213 private function score_satisfying_content(array $content_data, string $target_keyword): array {
214 $score = 0;
215 $max_score = $this->scoring_factors['satisfying_content'];
216 $suggestions = [];
217
218 $content = $content_data['content'] ?? '';
219 $word_count = $content_data['word_count'] ?? 0;
220
221 // Content depth and comprehensiveness (8 points)
222 if ($word_count >= 2000) {
223 $score += 8;
224 } elseif ($word_count >= 1200) {
225 $score += 6;
226 $suggestions[] = 'Consider expanding content to 2000+ words for comprehensive topic coverage';
227 } elseif ($word_count >= 800) {
228 $score += 4;
229 $suggestions[] = 'Content needs more depth - aim for 1200+ words for better satisfaction signals';
230 } else {
231 $score += 1;
232 $suggestions[] = 'Content too shallow - Google prioritizes comprehensive, satisfying content (800+ words minimum)';
233 }
234
235 // Search intent satisfaction (8 points)
236 $intent_satisfaction = $this->calculate_intent_satisfaction($content, $target_keyword);
237 $score += round($intent_satisfaction * 8);
238
239 if ($intent_satisfaction < 0.7) {
240 $suggestions[] = 'Improve content to better satisfy search intent - focus on answering user questions completely';
241 }
242
243 // Content uniqueness and value (7 points)
244 $uniqueness_score = $this->assess_content_uniqueness($content);
245 $score += round($uniqueness_score * 7);
246
247 if ($uniqueness_score < 0.6) {
248 $suggestions[] = 'Add unique insights, examples, or perspectives to stand out from competitors';
249 }
250
251 return [
252 'score' => $score,
253 'max_score' => $max_score,
254 'suggestions' => $suggestions,
255 'details' => [
256 'word_count' => $word_count,
257 'intent_satisfaction' => round($intent_satisfaction * 100, 1),
258 'uniqueness_score' => round($uniqueness_score * 100, 1),
259 'content_depth' => $this->assess_content_depth_2025($word_count),
260 ]
261 ];
262 }
263
264 /**
265 * Calculate intent satisfaction score
266 *
267 * @param string $content Content text
268 * @param string $target_keyword Target keyword
269 * @return float Satisfaction score (0-1)
270 */
271 private function calculate_intent_satisfaction(string $content, string $target_keyword): float {
272 if (empty($content) || empty($target_keyword)) {
273 return 0.3; // Partial credit for content without keyword focus
274 }
275
276 $content_lower = strtolower(wp_strip_all_tags($content));
277 $keyword_lower = strtolower($target_keyword);
278
279 // Check for question answering patterns
280 $question_patterns = ['what is', 'how to', 'why', 'when', 'where', 'who'];
281 $answer_patterns = ['because', 'therefore', 'as a result', 'in conclusion', 'to summarize'];
282
283 $question_score = 0;
284 $answer_score = 0;
285
286 foreach ($question_patterns as $pattern) {
287 if (strpos($content_lower, $pattern) !== false) {
288 $question_score += 0.1;
289 }
290 }
291
292 foreach ($answer_patterns as $pattern) {
293 if (strpos($content_lower, $pattern) !== false) {
294 $answer_score += 0.1;
295 }
296 }
297
298 // Base satisfaction from keyword relevance
299 $keyword_relevance = $this->calculate_topic_relevance($content, $target_keyword);
300
301 // Combine factors
302 $satisfaction = ($keyword_relevance * 0.6) + (min(1.0, $question_score) * 0.2) + (min(1.0, $answer_score) * 0.2);
303
304 return min(1.0, $satisfaction);
305 }
306
307 /**
308 * Assess content uniqueness
309 *
310 * @param string $content Content text
311 * @return float Uniqueness score (0-1)
312 */
313 private function assess_content_uniqueness(string $content): float {
314 if (empty($content)) {
315 return 0.0;
316 }
317
318 // Simple heuristics for uniqueness assessment
319 $sentences = preg_split('/[.!?]+/', wp_strip_all_tags($content), -1, PREG_SPLIT_NO_EMPTY);
320 $sentence_count = count($sentences);
321
322 if ($sentence_count === 0) {
323 return 0.0;
324 }
325
326 // Check for varied sentence structures
327 $short_sentences = 0;
328 $medium_sentences = 0;
329 $long_sentences = 0;
330
331 foreach ($sentences as $sentence) {
332 $word_count = $this->calculate_word_count_js_style(trim($sentence));
333 if ($word_count <= 10) {
334 $short_sentences++;
335 } elseif ($word_count <= 20) {
336 $medium_sentences++;
337 } else {
338 $long_sentences++;
339 }
340 }
341
342 // Variety in sentence length indicates more natural, unique content
343 $variety_score = 0;
344 if ($short_sentences > 0) $variety_score += 0.3;
345 if ($medium_sentences > 0) $variety_score += 0.4;
346 if ($long_sentences > 0) $variety_score += 0.3;
347
348 return min(1.0, $variety_score);
349 }
350
351 /**
352 * Assess content depth for 2025 standards
353 *
354 * @param int $word_count Word count
355 * @return string Depth assessment
356 */
357 private function assess_content_depth_2025(int $word_count): string {
358 if ($word_count >= 3000) return 'Comprehensive';
359 if ($word_count >= 2000) return 'Detailed';
360 if ($word_count >= 1200) return 'Adequate';
361 if ($word_count >= 800) return 'Basic';
362 return 'Insufficient';
363 }
364
365 /**
366 * Score 2025 title optimization with looser keyword matching
367 *
368 * @param string $title Post title
369 * @param string $target_keyword Target keyword
370 * @return array Scoring result
371 */
372 private function score_2025_title_optimization(string $title, string $target_keyword): array {
373 $score = 0;
374 $max_score = $this->scoring_factors['title_optimization'];
375 $suggestions = [];
376
377 if (empty($title)) {
378 $suggestions[] = 'Add a compelling, click-worthy title that matches search intent';
379 return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
380 }
381
382 $title_length = strlen($title);
383
384 // 2025 length optimization (7 points) - Slightly more flexible
385 if ($title_length >= 35 && $title_length <= 65) {
386 $score += 7;
387 } elseif ($title_length >= 25 && $title_length <= 75) {
388 $score += 5;
389 $suggestions[] = 'Optimize title length to 35-65 characters for better SERP visibility';
390 } else {
391 $score += 2;
392 $suggestions[] = $title_length < 25 ?
393 'Title too short - aim for 35-65 characters' :
394 'Title too long - risk truncation in search results';
395 }
396
397 // Looser keyword matching (7 points) - 2025 update
398 if (!empty($target_keyword)) {
399 $title_lower = strtolower($title);
400 $keyword_lower = strtolower($target_keyword);
401
402 // Exact match
403 if (strpos($title_lower, $keyword_lower) !== false) {
404 $score += 7;
405 } else {
406 // Check for semantic variations (2025 improvement)
407 $semantic_match = $this->check_semantic_keyword_match($title, $target_keyword);
408 if ($semantic_match) {
409 $score += 6; // Almost full credit for semantic match
410 $suggestions[] = 'Good semantic keyword usage - Google now recognizes keyword variations';
411 } else {
412 // Check for partial keyword match
413 $keyword_parts = explode(' ', $keyword_lower);
414 $partial_matches = 0;
415 foreach ($keyword_parts as $part) {
416 if (strpos($title_lower, $part) !== false) {
417 $partial_matches++;
418 }
419 }
420
421 if ($partial_matches > 0) {
422 $score += round(($partial_matches / count($keyword_parts)) * 5);
423 $suggestions[] = "Include more parts of target keyword '{$target_keyword}' in title";
424 } else {
425 $score += 1;
426 $suggestions[] = "Include target keyword '{$target_keyword}' or related terms in title";
427 }
428 }
429 }
430 } else {
431 $score += 3; // Partial credit
432 $suggestions[] = 'Set a target keyword to optimize title effectiveness';
433 }
434
435 return [
436 'score' => $score,
437 'max_score' => $max_score,
438 'suggestions' => $suggestions,
439 'details' => [
440 'title_length' => $title_length,
441 'optimal_range' => '35-65 characters',
442 'keyword_present' => !empty($target_keyword) && strpos(strtolower($title), strtolower($target_keyword)) !== false,
443 'semantic_match' => !empty($target_keyword) ? $this->check_semantic_keyword_match($title, $target_keyword) : false,
444 ]
445 ];
446 }
447
448 // Placeholder methods for remaining 2025 factors
449
450 private function score_niche_expertise(array $content_data, string $target_keyword): array {
451 return [
452 'score' => 8, // Partial score for basic implementation
453 'max_score' => $this->scoring_factors['niche_expertise'],
454 'suggestions' => ['Develop hub and spoke content clusters around your main topics'],
455 'details' => ['expertise_level' => 'Developing']
456 ];
457 }
458
459 private function score_searcher_engagement(array $content_data): array {
460 $readability = $content_data['readability_score'] ?? 50;
461 $score = round(($readability / 100) * $this->scoring_factors['searcher_engagement']);
462
463 return [
464 'score' => $score,
465 'max_score' => $this->scoring_factors['searcher_engagement'],
466 'suggestions' => $score < 8 ? ['Improve content engagement with better readability and structure'] : [],
467 'details' => ['engagement_estimate' => round(($score / $this->scoring_factors['searcher_engagement']) * 100, 1) . '%']
468 ];
469 }
470
471 private function score_backlink_authority(array $content_data): array {
472 $external_links = $content_data['external_links'] ?? 0;
473 $score = min($this->scoring_factors['backlink_authority'], $external_links * 3);
474
475 return [
476 'score' => $score,
477 'max_score' => $this->scoring_factors['backlink_authority'],
478 'suggestions' => $score < 8 ? ['Build high-quality backlinks from authoritative sources'] : [],
479 'details' => ['authority_estimate' => 'Moderate']
480 ];
481 }
482
483 private function score_2025_content_freshness(array $content_data): array {
484 return [
485 'score' => 6, // Full score for new content
486 'max_score' => $this->scoring_factors['content_freshness'],
487 'suggestions' => ['Update content quarterly to maintain freshness signals'],
488 'details' => ['freshness_status' => 'Current', 'last_updated' => current_time('mysql')]
489 ];
490 }
491
492 private function score_mobile_experience_score(array $content_data): array {
493 return [
494 'score' => 4, // Good default score
495 'max_score' => $this->scoring_factors['mobile_experience'],
496 'suggestions' => ['Ensure optimal mobile experience with fast loading and easy navigation'],
497 'details' => ['mes_score' => 'Good', 'mobile_friendly' => true]
498 ];
499 }
500
501 private function score_trustworthiness(array $content_data, array $metadata): array {
502 return [
503 'score' => 3, // Moderate trust score
504 'max_score' => $this->scoring_factors['trustworthiness'],
505 'suggestions' => ['Add author credentials, citations, and contact information to improve trustworthiness'],
506 'details' => ['trust_level' => 'Moderate']
507 ];
508 }
509
510 private function score_link_diversity(array $content_data): array {
511 return [
512 'score' => 2, // Basic score
513 'max_score' => $this->scoring_factors['link_diversity'],
514 'suggestions' => ['Build backlinks to multiple pages across your site'],
515 'details' => ['diversity_level' => 'Basic']
516 ];
517 }
518
519 private function score_core_web_vitals_2025(array $content_data): array {
520 return [
521 'score' => 2, // Assume decent performance
522 'max_score' => $this->scoring_factors['core_web_vitals'],
523 'suggestions' => ['Optimize for Interaction Readiness and Cumulative Layout Shift 2.0'],
524 'details' => ['vitals_status' => 'Good', 'interaction_readiness' => 'Optimized']
525 ];
526 }
527
528 private function score_site_security(array $content_data): array {
529 return [
530 'score' => 2, // Full score for SSL
531 'max_score' => $this->scoring_factors['site_security'],
532 'suggestions' => [],
533 'details' => ['ssl_enabled' => true, 'security_level' => 'Good']
534 ];
535 }
536
537 private function score_internal_linking_2025(array $content_data): array {
538 $internal_links = $content_data['internal_links'] ?? 0;
539 $score = min($this->scoring_factors['internal_linking'], $internal_links > 0 ? 1 : 0);
540
541 return [
542 'score' => $score,
543 'max_score' => $this->scoring_factors['internal_linking'],
544 'suggestions' => $score < 1 ? ['Add internal links to related content'] : [],
545 'details' => ['internal_links' => $internal_links]
546 ];
547 }
548
549 private function score_technical_factors_2025(array $content_data, array $metadata, array $options): array {
550 $meta_desc = $metadata['description'] ?? '';
551 $score = !empty($meta_desc) ? 1 : 0;
552
553 return [
554 'score' => $score,
555 'max_score' => $this->scoring_factors['technical_factors'],
556 'suggestions' => $score < 1 ? ['Add meta description and schema markup'] : [],
557 'details' => ['meta_description' => !empty($meta_desc), 'schema_markup' => false]
558 ];
559 }
560
561 /**
562 * Prioritize suggestions for 2025
563 *
564 * @param array $suggestions Raw suggestions
565 * @return array Prioritized suggestions
566 */
567 private function prioritize_suggestions_2025(array $suggestions): array {
568 $prioritized = [];
569
570 foreach ($suggestions as $suggestion) {
571 $priority = $this->determine_2025_priority($suggestion);
572 $prioritized[] = [
573 'text' => $suggestion,
574 'priority' => $priority,
575 'impact' => $this->estimate_2025_impact($suggestion),
576 'effort' => $this->estimate_effort($suggestion),
577 ];
578 }
579
580 // Sort by 2025 priorities
581 usort($prioritized, function($a, $b) {
582 $priority_order = ['Critical' => 4, 'High' => 3, 'Medium' => 2, 'Low' => 1];
583 return $priority_order[$b['priority']] - $priority_order[$a['priority']];
584 });
585
586 return $prioritized;
587 }
588
589 /**
590 * Determine 2025 suggestion priority
591 *
592 * @param string $suggestion Suggestion text
593 * @return string Priority level
594 */
595 private function determine_2025_priority(string $suggestion): string {
596 $critical_keywords = ['satisfying content', 'search intent', 'comprehensive'];
597 $high_priority_keywords = ['title', 'engagement', 'freshness'];
598 $medium_priority_keywords = ['mobile', 'trust', 'backlink'];
599
600 $suggestion_lower = strtolower($suggestion);
601
602 foreach ($critical_keywords as $keyword) {
603 if (strpos($suggestion_lower, $keyword) !== false) {
604 return 'Critical';
605 }
606 }
607
608 foreach ($high_priority_keywords as $keyword) {
609 if (strpos($suggestion_lower, $keyword) !== false) {
610 return 'High';
611 }
612 }
613
614 foreach ($medium_priority_keywords as $keyword) {
615 if (strpos($suggestion_lower, $keyword) !== false) {
616 return 'Medium';
617 }
618 }
619
620 return 'Low';
621 }
622
623 /**
624 * Estimate 2025 impact
625 *
626 * @param string $suggestion Suggestion text
627 * @return string Impact level
628 */
629 private function estimate_2025_impact(string $suggestion): string {
630 if (strpos(strtolower($suggestion), 'satisfying') !== false) return 'Critical';
631 if (strpos(strtolower($suggestion), 'engagement') !== false) return 'High';
632 if (strpos(strtolower($suggestion), 'title') !== false) return 'High';
633 if (strpos(strtolower($suggestion), 'freshness') !== false) return 'Medium';
634 return 'Low';
635 }
636
637 /**
638 * Get 2025 grade from score
639 *
640 * @param int $score Numeric score
641 * @return string Letter grade
642 */
643 private function get_2025_grade(int $score): string {
644 if ($score >= 95) return 'A+';
645 if ($score >= 90) return 'A';
646 if ($score >= 85) return 'A-';
647 if ($score >= 80) return 'B+';
648 if ($score >= 75) return 'B';
649 if ($score >= 70) return 'B-';
650 if ($score >= 65) return 'C+';
651 if ($score >= 60) return 'C';
652 if ($score >= 55) return 'C-';
653 if ($score >= 45) return 'D+';
654 if ($score >= 35) return 'D';
655 return 'F';
656 }
657
658 /**
659 * Score title optimization - #2 factor in 2025 (14 points)
660 * Looser keyword matching, focus on click-through rate and intent satisfaction
661 *
662 * @param string $title Page title
663 * @param string $target_keyword Target keyword
664 * @return array Scoring result
665 */
666 private function score_title_optimization(string $title, string $target_keyword): array {
667 $score = 0;
668 $max_score = $this->scoring_factors['title_optimization'];
669 $suggestions = [];
670
671 if (empty($title)) {
672 $suggestions[] = 'Add a compelling, click-worthy title that matches search intent';
673 return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
674 }
675
676 $title_length = strlen($title);
677
678 // Modern length optimization (8 points) - Updated for 2024
679 if ($title_length >= 40 && $title_length <= 60) {
680 $score += 8;
681 } elseif ($title_length >= 30 && $title_length <= 70) {
682 $score += 6;
683 $suggestions[] = 'Optimize title length to 40-60 characters for better SERP visibility';
684 } else {
685 $score += 2;
686 $suggestions[] = $title_length < 30 ?
687 'Title too short - aim for 40-60 characters to maximize SERP real estate' :
688 'Title too long - risk truncation in search results';
689 }
690
691 // Semantic keyword presence (10 points) - Modern approach
692 if (!empty($target_keyword)) {
693 $title_lower = strtolower($title);
694 $keyword_lower = strtolower($target_keyword);
695
696 if (strpos($title_lower, $keyword_lower) !== false) {
697 $score += 10;
698
699 // Bonus for keyword placement
700 if (strpos($title_lower, $keyword_lower) === 0) {
701 $suggestions[] = 'Excellent: Target keyword at beginning of title for maximum impact';
702 }
703 } else {
704 // Check for semantic variations
705 $semantic_match = $this->check_semantic_keyword_match($title, $target_keyword);
706 if ($semantic_match) {
707 $score += 7;
708 $suggestions[] = 'Good semantic keyword usage - consider including exact keyword for clarity';
709 } else {
710 $score += 2;
711 $suggestions[] = "Include target keyword '{$target_keyword}' or semantic variations in title";
712 }
713 }
714 } else {
715 $score += 5; // Partial credit
716 $suggestions[] = 'Set a target keyword to optimize title effectiveness';
717 }
718
719 return [
720 'score' => $score,
721 'max_score' => $max_score,
722 'suggestions' => $suggestions,
723 'details' => [
724 'title_length' => $title_length,
725 'optimal_range' => '40-60 characters',
726 'keyword_present' => !empty($target_keyword) && strpos(strtolower($title), strtolower($target_keyword)) !== false,
727 ]
728 ];
729 }
730
731 /**
732 * Check for semantic keyword matching
733 *
734 * @param string $text Text to analyze
735 * @param string $keyword Target keyword
736 * @return bool True if semantic match found
737 */
738 private function check_semantic_keyword_match(string $text, string $keyword): bool {
739 // Simple semantic matching - can be enhanced with AI/NLP
740 $keyword_parts = explode(' ', strtolower($keyword));
741 $text_lower = strtolower($text);
742
743 $matches = 0;
744 foreach ($keyword_parts as $part) {
745 if (strpos($text_lower, $part) !== false) {
746 $matches++;
747 }
748 }
749
750 // Consider it a semantic match if 70% of keyword parts are present
751 return ($matches / count($keyword_parts)) >= 0.7;
752 }
753
754 /**
755 * Get modern grade from score (updated scale)
756 *
757 * @param int $score Numeric score
758 * @return string Letter grade
759 */
760 private function get_modern_grade(int $score): string {
761 if ($score >= 95) return 'A+';
762 if ($score >= 90) return 'A';
763 if ($score >= 85) return 'A-';
764 if ($score >= 80) return 'B+';
765 if ($score >= 75) return 'B';
766 if ($score >= 70) return 'B-';
767 if ($score >= 65) return 'C+';
768 if ($score >= 60) return 'C';
769 if ($score >= 55) return 'C-';
770 if ($score >= 45) return 'D+';
771 if ($score >= 35) return 'D';
772 return 'F';
773 }
774
775 /**
776 * Prioritize suggestions by impact and effort
777 *
778 * @param array $suggestions Raw suggestions
779 * @return array Prioritized suggestions with metadata
780 */
781 private function prioritize_suggestions(array $suggestions): array {
782 $prioritized = [];
783
784 foreach ($suggestions as $suggestion) {
785 $priority = $this->determine_suggestion_priority($suggestion);
786 $prioritized[] = [
787 'text' => $suggestion,
788 'priority' => $priority,
789 'impact' => $this->estimate_impact($suggestion),
790 'effort' => $this->estimate_effort($suggestion),
791 ];
792 }
793
794 // Sort by priority (High > Medium > Low)
795 usort($prioritized, function($a, $b) {
796 $priority_order = ['High' => 3, 'Medium' => 2, 'Low' => 1];
797 return $priority_order[$b['priority']] - $priority_order[$a['priority']];
798 });
799
800 return $prioritized;
801 }
802
803 /**
804 * Determine suggestion priority based on content
805 *
806 * @param string $suggestion Suggestion text
807 * @return string Priority level
808 */
809 private function determine_suggestion_priority(string $suggestion): string {
810 $high_priority_keywords = ['title', 'keyword', 'content quality', 'heading'];
811 $medium_priority_keywords = ['meta description', 'internal link', 'readability'];
812
813 $suggestion_lower = strtolower($suggestion);
814
815 foreach ($high_priority_keywords as $keyword) {
816 if (strpos($suggestion_lower, $keyword) !== false) {
817 return 'High';
818 }
819 }
820
821 foreach ($medium_priority_keywords as $keyword) {
822 if (strpos($suggestion_lower, $keyword) !== false) {
823 return 'Medium';
824 }
825 }
826
827 return 'Low';
828 }
829
830 /**
831 * Estimate impact of implementing suggestion
832 *
833 * @param string $suggestion Suggestion text
834 * @return string Impact level
835 */
836 private function estimate_impact(string $suggestion): string {
837 // Simple heuristic - can be enhanced with ML
838 if (strpos(strtolower($suggestion), 'title') !== false) return 'High';
839 if (strpos(strtolower($suggestion), 'content') !== false) return 'High';
840 if (strpos(strtolower($suggestion), 'keyword') !== false) return 'Medium';
841 return 'Low';
842 }
843
844 /**
845 * Estimate effort required to implement suggestion
846 *
847 * @param string $suggestion Suggestion text
848 * @return string Effort level
849 */
850 private function estimate_effort(string $suggestion): string {
851 // Simple heuristic - can be enhanced with ML
852 if (strpos(strtolower($suggestion), 'rewrite') !== false) return 'High';
853 if (strpos(strtolower($suggestion), 'add') !== false) return 'Medium';
854 if (strpos(strtolower($suggestion), 'optimize') !== false) return 'Medium';
855 return 'Low';
856 }
857
858 /**
859 * Score content quality using modern E-A-T principles
860 *
861 * @param array $content_data Content analysis data
862 * @param string $target_keyword Target keyword
863 * @return array Scoring result
864 */
865 private function score_content_quality(array $content_data, string $target_keyword): array {
866 $score = 0;
867 $max_score = $this->scoring_factors['content_quality'];
868 $suggestions = [];
869
870 $word_count = $content_data['word_count'] ?? 0;
871 $content = $content_data['content'] ?? '';
872
873 // Content depth and comprehensiveness (8 points)
874 if ($word_count >= 1500) {
875 $score += 8;
876 } elseif ($word_count >= 800) {
877 $score += 6;
878 $suggestions[] = 'Consider expanding content for more comprehensive coverage (1500+ words ideal)';
879 } elseif ($word_count >= 300) {
880 $score += 4;
881 $suggestions[] = 'Content needs more depth - aim for 800+ words for better topic coverage';
882 } else {
883 $suggestions[] = 'Content too thin - add substantial value with detailed information (minimum 300 words)';
884 }
885
886 // Content structure and organization (6 points)
887 $headings = $content_data['headings'] ?? [];
888 if (count($headings) >= 3) {
889 $score += 6;
890 } elseif (count($headings) >= 1) {
891 $score += 3;
892 $suggestions[] = 'Add more headings to improve content structure and scannability';
893 } else {
894 $suggestions[] = 'Add headings (H2, H3) to organize content and improve user experience';
895 }
896
897 // Topic relevance and focus (6 points)
898 if (!empty($target_keyword) && !empty($content)) {
899 $relevance_score = $this->calculate_topic_relevance($content, $target_keyword);
900 $score += round($relevance_score * 6);
901
902 if ($relevance_score < 0.5) {
903 $suggestions[] = "Improve content relevance to target topic '{$target_keyword}'";
904 }
905 } else {
906 $score += 3; // Partial credit
907 $suggestions[] = 'Define target keyword to optimize content focus and relevance';
908 }
909
910 return [
911 'score' => $score,
912 'max_score' => $max_score,
913 'suggestions' => $suggestions,
914 'details' => [
915 'word_count' => $word_count,
916 'heading_count' => count($headings),
917 'content_depth' => $this->assess_content_depth($word_count),
918 'topic_coverage' => $this->assess_topic_coverage($content, $target_keyword),
919 ]
920 ];
921 }
922
923 /**
924 * Calculate topic relevance score
925 *
926 * @param string $content Content text
927 * @param string $target_keyword Target keyword
928 * @return float Relevance score (0-1)
929 */
930 private function calculate_topic_relevance(string $content, string $target_keyword): float {
931 if (empty($content) || empty($target_keyword)) {
932 return 0.0;
933 }
934
935 $content_lower = strtolower(wp_strip_all_tags($content));
936 $keyword_lower = strtolower($target_keyword);
937
938 // Calculate keyword and semantic term frequency
939 $keyword_count = substr_count($content_lower, $keyword_lower);
940 $word_count = $this->calculate_word_count_js_style($content_lower);
941
942 if ($word_count === 0) {
943 return 0.0;
944 }
945
946 // Base relevance from keyword presence
947 $keyword_density = ($keyword_count / $word_count) * 100;
948 $base_relevance = min(1.0, $keyword_density / 2.0); // Optimal around 1-2%
949
950 // Boost for semantic variations
951 $semantic_boost = $this->calculate_semantic_boost($content_lower, $keyword_lower);
952
953 return min(1.0, $base_relevance + $semantic_boost);
954 }
955
956 /**
957 * Calculate semantic boost for related terms
958 *
959 * @param string $content Content text (lowercase)
960 * @param string $keyword Target keyword (lowercase)
961 * @return float Semantic boost (0-0.3)
962 */
963 private function calculate_semantic_boost(string $content, string $keyword): float {
964 // Simple semantic term detection - can be enhanced with NLP
965 $semantic_terms = $this->get_semantic_terms($keyword);
966 $boost = 0.0;
967
968 foreach ($semantic_terms as $term) {
969 if (strpos($content, $term) !== false) {
970 $boost += 0.05; // Small boost per semantic term
971 }
972 }
973
974 return min(0.3, $boost); // Cap at 30% boost
975 }
976
977 /**
978 * Get semantic terms for a keyword
979 *
980 * @param string $keyword Target keyword
981 * @return array Semantic terms
982 */
983 private function get_semantic_terms(string $keyword): array {
984 // Simple semantic term generation - can be enhanced with AI/NLP
985 $terms = [];
986
987 // Add plural/singular variations
988 if (substr($keyword, -1) === 's') {
989 $terms[] = rtrim($keyword, 's');
990 } else {
991 $terms[] = $keyword . 's';
992 }
993
994 // Add common related terms based on keyword
995 $keyword_lower = strtolower($keyword);
996
997 // SEO-related terms
998 if (strpos($keyword_lower, 'seo') !== false) {
999 $terms = array_merge($terms, ['optimization', 'search engine', 'ranking', 'visibility']);
1000 }
1001
1002 // WordPress-related terms
1003 if (strpos($keyword_lower, 'wordpress') !== false) {
1004 $terms = array_merge($terms, ['wp', 'plugin', 'theme', 'cms']);
1005 }
1006
1007 return $terms;
1008 }
1009
1010 /**
1011 * Assess content depth based on word count
1012 *
1013 * @param int $word_count Word count
1014 * @return string Depth assessment
1015 */
1016 private function assess_content_depth(int $word_count): string {
1017 if ($word_count >= 2000) return 'Comprehensive';
1018 if ($word_count >= 1000) return 'Detailed';
1019 if ($word_count >= 500) return 'Moderate';
1020 if ($word_count >= 300) return 'Basic';
1021 return 'Insufficient';
1022 }
1023
1024 /**
1025 * Assess topic coverage
1026 *
1027 * @param string $content Content text
1028 * @param string $target_keyword Target keyword
1029 * @return string Coverage assessment
1030 */
1031 private function assess_topic_coverage(string $content, string $target_keyword): string {
1032 $relevance = $this->calculate_topic_relevance($content, $target_keyword);
1033
1034 if ($relevance >= 0.8) return 'Excellent';
1035 if ($relevance >= 0.6) return 'Good';
1036 if ($relevance >= 0.4) return 'Fair';
1037 if ($relevance >= 0.2) return 'Poor';
1038 return 'Off-topic';
1039 }
1040
1041 /**
1042 * Score modern heading structure
1043 *
1044 * @param array $headings Array of headings
1045 * @return array Scoring result
1046 */
1047 private function score_modern_headings(array $headings): array {
1048 $score = 0;
1049 $max_score = $this->scoring_factors['heading_structure'];
1050 $suggestions = [];
1051
1052 if (empty($headings)) {
1053 $suggestions[] = 'Add headings (H1, H2, H3) to structure content and improve readability';
1054 return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
1055 }
1056
1057 $h1_count = 0;
1058 $h2_count = 0;
1059 $h3_count = 0;
1060
1061 foreach ($headings as $heading) {
1062 $level = $heading['level'] ?? 1;
1063 switch ($level) {
1064 case 1: $h1_count++; break;
1065 case 2: $h2_count++; break;
1066 case 3: $h3_count++; break;
1067 }
1068 }
1069
1070 // H1 optimization (4 points)
1071 if ($h1_count === 1) {
1072 $score += 4;
1073 } elseif ($h1_count === 0) {
1074 $suggestions[] = 'Add exactly one H1 heading for your main topic';
1075 } else {
1076 $suggestions[] = 'Use only one H1 heading per page for better SEO';
1077 }
1078
1079 // H2 structure (4 points)
1080 if ($h2_count >= 2 && $h2_count <= 8) {
1081 $score += 4;
1082 } elseif ($h2_count === 1) {
1083 $score += 2;
1084 $suggestions[] = 'Add more H2 headings to break content into logical sections';
1085 } elseif ($h2_count === 0) {
1086 $suggestions[] = 'Add H2 headings to organize content into main sections';
1087 } else {
1088 $suggestions[] = 'Too many H2 headings - consider consolidating some sections';
1089 }
1090
1091 // H3 usage (4 points)
1092 if ($h3_count >= 1 && $h3_count <= 12) {
1093 $score += 4;
1094 } elseif ($h3_count === 0 && $h2_count >= 2) {
1095 $score += 2;
1096 $suggestions[] = 'Consider adding H3 subheadings for better content organization';
1097 }
1098
1099 return [
1100 'score' => $score,
1101 'max_score' => $max_score,
1102 'suggestions' => $suggestions,
1103 'details' => [
1104 'h1_count' => $h1_count,
1105 'h2_count' => $h2_count,
1106 'h3_count' => $h3_count,
1107 'total_headings' => count($headings),
1108 'structure_quality' => $this->assess_heading_structure($h1_count, $h2_count, $h3_count),
1109 ]
1110 ];
1111 }
1112
1113 /**
1114 * Assess heading structure quality
1115 *
1116 * @param int $h1_count H1 count
1117 * @param int $h2_count H2 count
1118 * @param int $h3_count H3 count
1119 * @return string Structure quality
1120 */
1121 private function assess_heading_structure(int $h1_count, int $h2_count, int $h3_count): string {
1122 if ($h1_count === 1 && $h2_count >= 2 && $h3_count >= 1) return 'Excellent';
1123 if ($h1_count === 1 && $h2_count >= 2) return 'Good';
1124 if ($h1_count === 1 && $h2_count >= 1) return 'Fair';
1125 if ($h1_count === 1) return 'Basic';
1126 return 'Poor';
1127 }
1128
1129 /**
1130 * Score semantic relevance using modern NLP principles
1131 *
1132 * @param string $content Content text
1133 * @param string $target_keyword Target keyword
1134 * @return array Scoring result
1135 */
1136 private function score_semantic_relevance(string $content, string $target_keyword): array {
1137 $score = 0;
1138 $max_score = $this->scoring_factors['semantic_relevance'];
1139 $suggestions = [];
1140
1141 if (empty($content)) {
1142 $suggestions[] = 'Add content to analyze semantic relevance';
1143 return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
1144 }
1145
1146 if (empty($target_keyword)) {
1147 $score += 7; // Partial credit
1148 $suggestions[] = 'Set a target keyword to optimize semantic relevance';
1149 return ['score' => $score, 'max_score' => $max_score, 'suggestions' => $suggestions];
1150 }
1151
1152 // Calculate semantic relevance
1153 $relevance = $this->calculate_topic_relevance($content, $target_keyword);
1154 $score = round($relevance * $max_score);
1155
1156 // Provide specific suggestions based on relevance score
1157 if ($relevance >= 0.8) {
1158 $suggestions[] = 'Excellent semantic relevance - content strongly matches target topic';
1159 } elseif ($relevance >= 0.6) {
1160 $suggestions[] = 'Good semantic relevance - consider adding more related terms';
1161 } elseif ($relevance >= 0.4) {
1162 $suggestions[] = "Improve content relevance to '{$target_keyword}' with related terms and concepts";
1163 } else {
1164 $suggestions[] = "Content lacks focus on '{$target_keyword}' - add more relevant information";
1165 }
1166
1167 return [
1168 'score' => $score,
1169 'max_score' => $max_score,
1170 'suggestions' => $suggestions,
1171 'details' => [
1172 'relevance_score' => round($relevance * 100, 1),
1173 'keyword_density' => $this->calculate_keyword_density($content, $target_keyword),
1174 'semantic_terms_found' => $this->count_semantic_terms($content, $target_keyword),
1175 ]
1176 ];
1177 }
1178
1179 /**
1180 * Calculate keyword density
1181 *
1182 * @param string $content Content text
1183 * @param string $keyword Target keyword
1184 * @return float Keyword density percentage
1185 */
1186 private function calculate_keyword_density(string $content, string $keyword): float {
1187 if (empty($content) || empty($keyword)) {
1188 return 0.0;
1189 }
1190
1191 $content_lower = strtolower(wp_strip_all_tags($content));
1192 $keyword_lower = strtolower($keyword);
1193
1194 $keyword_count = substr_count($content_lower, $keyword_lower);
1195 $word_count = $this->calculate_word_count_js_style($content_lower);
1196
1197 return $word_count > 0 ? round(($keyword_count / $word_count) * 100, 2) : 0.0;
1198 }
1199
1200 /**
1201 * Count semantic terms found in content
1202 *
1203 * @param string $content Content text
1204 * @param string $keyword Target keyword
1205 * @return int Number of semantic terms found
1206 */
1207 private function count_semantic_terms(string $content, string $keyword): int {
1208 $semantic_terms = $this->get_semantic_terms($keyword);
1209 $content_lower = strtolower($content);
1210 $found = 0;
1211
1212 foreach ($semantic_terms as $term) {
1213 if (strpos($content_lower, strtolower($term)) !== false) {
1214 $found++;
1215 }
1216 }
1217
1218 return $found;
1219 }
1220
1221 /**
1222 * Score user experience factors
1223 *
1224 * @param array $content_data Content analysis data
1225 * @return array Scoring result
1226 */
1227 private function score_user_experience(array $content_data): array {
1228 $score = 0;
1229 $max_score = $this->scoring_factors['user_experience'];
1230 $suggestions = [];
1231
1232 // Readability score (6 points)
1233 $readability = $content_data['readability_score'] ?? 0;
1234 if ($readability >= 60) {
1235 $score += 6;
1236 } elseif ($readability >= 40) {
1237 $score += 4;
1238 $suggestions[] = 'Improve readability with shorter sentences and simpler words';
1239 } else {
1240 $score += 2;
1241 $suggestions[] = 'Content is difficult to read - use shorter sentences and common words';
1242 }
1243
1244 // Content scannability (4 points)
1245 $headings = $content_data['headings'] ?? [];
1246 $word_count = $content_data['word_count'] ?? 0;
1247
1248 if (count($headings) >= 3 && $word_count > 500) {
1249 $score += 4;
1250 } elseif (count($headings) >= 1) {
1251 $score += 2;
1252 $suggestions[] = 'Add more headings to make content easier to scan';
1253 } else {
1254 $suggestions[] = 'Add headings to break up text and improve scannability';
1255 }
1256
1257 return [
1258 'score' => $score,
1259 'max_score' => $max_score,
1260 'suggestions' => $suggestions,
1261 'details' => [
1262 'readability_score' => $readability,
1263 'readability_level' => $this->get_readability_level($readability),
1264 'scannability' => count($headings) >= 3 ? 'Good' : 'Needs improvement',
1265 ]
1266 ];
1267 }
1268
1269 /**
1270 * Get readability level description
1271 *
1272 * @param float $score Readability score
1273 * @return string Readability level
1274 */
1275 private function get_readability_level(float $score): string {
1276 if ($score >= 90) return 'Very Easy';
1277 if ($score >= 80) return 'Easy';
1278 if ($score >= 70) return 'Fairly Easy';
1279 if ($score >= 60) return 'Standard';
1280 if ($score >= 50) return 'Fairly Difficult';
1281 if ($score >= 30) return 'Difficult';
1282 return 'Very Difficult';
1283 }
1284
1285 /**
1286 * Score strategic internal linking
1287 *
1288 * @param array $content_data Content analysis data
1289 * @return array Scoring result
1290 */
1291 private function score_strategic_linking(array $content_data): array {
1292 $score = 0;
1293 $max_score = $this->scoring_factors['internal_linking'];
1294 $suggestions = [];
1295
1296 $internal_links = $content_data['internal_links'] ?? 0;
1297 $word_count = $content_data['word_count'] ?? 0;
1298
1299 // Calculate optimal link ratio (1 link per 150-200 words)
1300 $optimal_links = max(1, round($word_count / 175));
1301
1302 if ($internal_links >= $optimal_links) {
1303 $score += 8;
1304 } elseif ($internal_links >= max(1, $optimal_links - 1)) {
1305 $score += 6;
1306 $suggestions[] = 'Consider adding 1-2 more internal links to related content';
1307 } elseif ($internal_links >= 1) {
1308 $score += 4;
1309 $suggestions[] = 'Add more internal links to improve site navigation and SEO';
1310 } else {
1311 $suggestions[] = 'Add internal links to related pages to improve site structure';
1312 }
1313
1314 return [
1315 'score' => $score,
1316 'max_score' => $max_score,
1317 'suggestions' => $suggestions,
1318 'details' => [
1319 'current_links' => $internal_links,
1320 'optimal_links' => $optimal_links,
1321 'link_ratio' => $word_count > 0 ? round($word_count / max(1, $internal_links)) : 0,
1322 ]
1323 ];
1324 }
1325
1326 /**
1327 * Score external authority signals
1328 *
1329 * @param array $content_data Content analysis data
1330 * @return array Scoring result
1331 */
1332 private function score_external_authority(array $content_data): array {
1333 $score = 0;
1334 $max_score = $this->scoring_factors['external_authority'];
1335 $suggestions = [];
1336
1337 $external_links = $content_data['external_links'] ?? 0;
1338
1339 if ($external_links >= 2 && $external_links <= 5) {
1340 $score += 7;
1341 } elseif ($external_links === 1) {
1342 $score += 5;
1343 $suggestions[] = 'Consider adding 1-2 more high-quality external links to authoritative sources';
1344 } elseif ($external_links === 0) {
1345 $score += 2;
1346 $suggestions[] = 'Add external links to authoritative sources to support your content';
1347 } else {
1348 $score += 4;
1349 $suggestions[] = 'Too many external links - focus on 2-5 high-quality authoritative sources';
1350 }
1351
1352 return [
1353 'score' => $score,
1354 'max_score' => $max_score,
1355 'suggestions' => $suggestions,
1356 'details' => [
1357 'external_links' => $external_links,
1358 'optimal_range' => '2-5 links',
1359 'authority_signal' => $external_links > 0 ? 'Present' : 'Missing',
1360 ]
1361 ];
1362 }
1363
1364 /**
1365 * Score technical SEO factors
1366 *
1367 * @param array $content_data Content analysis data
1368 * @param array $metadata Post metadata
1369 * @param array $options Additional options
1370 * @return array Scoring result
1371 */
1372 private function score_technical_seo(array $content_data, array $metadata, array $options): array {
1373 $score = 0;
1374 $max_score = $this->scoring_factors['technical_seo'];
1375 $suggestions = [];
1376
1377 // Meta description (2 points)
1378 $meta_desc = $metadata['description'] ?? '';
1379 $meta_length = strlen($meta_desc);
1380
1381 if ($meta_length >= 120 && $meta_length <= 160) {
1382 $score += 2;
1383 } elseif ($meta_length >= 100 && $meta_length <= 180) {
1384 $score += 1;
1385 $suggestions[] = 'Optimize meta description length to 120-160 characters';
1386 } else {
1387 $suggestions[] = empty($meta_desc) ?
1388 'Add a compelling meta description (120-160 characters)' :
1389 'Adjust meta description length to 120-160 characters';
1390 }
1391
1392 // Image optimization (2 points)
1393 $images = $content_data['images'] ?? [];
1394 if (!empty($images)) {
1395 $images_with_alt = array_filter($images, function($img) {
1396 return !empty($img['alt']);
1397 });
1398
1399 $alt_ratio = count($images_with_alt) / count($images);
1400 if ($alt_ratio >= 0.9) {
1401 $score += 2;
1402 } elseif ($alt_ratio >= 0.7) {
1403 $score += 1;
1404 $suggestions[] = 'Add alt text to remaining images for better accessibility';
1405 } else {
1406 $suggestions[] = 'Add descriptive alt text to all images';
1407 }
1408 } else {
1409 $score += 1; // Partial credit if no images
1410 }
1411
1412 // URL structure (1 point)
1413 $url = $content_data['url'] ?? '';
1414 if (!empty($url)) {
1415 $slug = basename(wp_parse_url($url, PHP_URL_PATH));
1416 if (strlen($slug) <= 60 && !empty($options['target_keyword'])) {
1417 if (strpos(strtolower($slug), strtolower($options['target_keyword'])) !== false) {
1418 $score += 1;
1419 } else {
1420 $suggestions[] = 'Include target keyword in URL slug for better SEO';
1421 }
1422 } else {
1423 $suggestions[] = 'Optimize URL structure - keep it short and descriptive';
1424 }
1425 }
1426
1427 return [
1428 'score' => $score,
1429 'max_score' => $max_score,
1430 'suggestions' => $suggestions,
1431 'details' => [
1432 'meta_description_length' => $meta_length,
1433 'images_with_alt' => !empty($images) ? count(array_filter($images, function($img) { return !empty($img['alt']); })) : 0,
1434 'total_images' => count($images),
1435 'url_optimized' => !empty($url) && !empty($options['target_keyword']) ?
1436 strpos(strtolower(basename(wp_parse_url($url, PHP_URL_PATH))), strtolower($options['target_keyword'])) !== false : false,
1437 ]
1438 ];
1439 }
1440
1441 /**
1442 * Score content freshness
1443 *
1444 * @param array $content_data Content analysis data
1445 * @return array Scoring result
1446 */
1447 private function score_content_freshness(array $content_data): array {
1448 $score = 3; // Default full score for new content
1449 $max_score = $this->scoring_factors['content_freshness'];
1450 $suggestions = [];
1451
1452 // For now, give full score - can be enhanced with post date analysis
1453 $suggestions[] = 'Keep content updated regularly for better freshness signals';
1454
1455 return [
1456 'score' => $score,
1457 'max_score' => $max_score,
1458 'suggestions' => $suggestions,
1459 'details' => [
1460 'freshness_status' => 'Current',
1461 'last_updated' => current_time('mysql'),
1462 ]
1463 ];
1464 }
1465
1466 /**
1467 * Score mobile optimization
1468 *
1469 * @param array $content_data Content analysis data
1470 * @return array Scoring result
1471 */
1472 private function score_mobile_optimization(array $content_data): array {
1473 $score = 2; // Default full score - basic mobile checks
1474 $max_score = $this->scoring_factors['mobile_optimization'];
1475 $suggestions = [];
1476
1477 // For now, give full score - can be enhanced with actual mobile testing
1478 $suggestions[] = 'Ensure content displays well on mobile devices';
1479
1480 return [
1481 'score' => $score,
1482 'max_score' => $max_score,
1483 'suggestions' => $suggestions,
1484 'details' => [
1485 'mobile_friendly' => 'Assumed',
1486 'responsive_design' => 'Theme dependent',
1487 ]
1488 ];
1489 }
1490
1491 /**
1492 * Analyze post content and extract data for scoring
1493 *
1494 * @param int $post_id Post ID
1495 * @return array Content analysis data
1496 */
1497 public function analyze_post_content(int $post_id): array {
1498 $post = get_post($post_id);
1499 if (!$post) {
1500 return [];
1501 }
1502
1503 $content = $post->post_content;
1504 $title = $post->post_title;
1505
1506 // Extract headings from content
1507 $headings = $this->extract_headings($content);
1508
1509 // Count words using JavaScript-compatible method
1510 $plain_text = wp_strip_all_tags($content);
1511 $word_count = $this->calculate_word_count_js_style($plain_text);
1512
1513 // Calculate readability
1514 $readability_score = $this->calculate_readability_score($content);
1515
1516 // Count links
1517 $internal_links = $this->count_internal_links($content);
1518 $external_links = $this->count_external_links($content);
1519
1520 // Analyze images
1521 $images = $this->analyze_images($content);
1522
1523 // Get URL
1524 $url = get_permalink($post_id);
1525
1526 return [
1527 'content' => $content,
1528 'title' => $title,
1529 'headings' => $headings,
1530 'word_count' => $word_count,
1531 'readability_score' => $readability_score,
1532 'internal_links' => $internal_links,
1533 'external_links' => $external_links,
1534 'images' => $images,
1535 'url' => $url,
1536 ];
1537 }
1538
1539 /**
1540 * Extract headings from content
1541 *
1542 * @param string $content Content HTML
1543 * @return array Array of headings with levels
1544 */
1545 private function extract_headings(string $content): array {
1546 $headings = [];
1547
1548 // Match H1-H6 tags
1549 if (preg_match_all('/<h([1-6])[^>]*>(.*?)<\/h[1-6]>/i', $content, $matches, PREG_SET_ORDER)) {
1550 foreach ($matches as $match) {
1551 $headings[] = [
1552 'level' => (int)$match[1],
1553 'text' => wp_strip_all_tags($match[2]),
1554 ];
1555 }
1556 }
1557
1558 return $headings;
1559 }
1560
1561 /**
1562 * Calculate readability score using Flesch Reading Ease
1563 *
1564 * @param string $content Content text
1565 * @return float Readability score
1566 */
1567 private function calculate_readability_score(string $content): float {
1568 $text = wp_strip_all_tags($content);
1569
1570 if (empty($text)) {
1571 return 0;
1572 }
1573
1574 // Count sentences (approximate)
1575 $sentences = preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY);
1576 $sentence_count = count($sentences);
1577
1578 // Count words
1579 $word_count = $this->calculate_word_count_js_style(wp_strip_all_tags($text));
1580
1581 // Count syllables (approximate)
1582 $syllable_count = $this->count_syllables($text);
1583
1584 if ($sentence_count === 0 || $word_count === 0) {
1585 return 0;
1586 }
1587
1588 // Flesch Reading Ease formula
1589 $score = 206.835 - (1.015 * ($word_count / $sentence_count)) - (84.6 * ($syllable_count / $word_count));
1590
1591 return max(0, min(100, $score));
1592 }
1593
1594 /**
1595 * Count syllables in text (approximate)
1596 *
1597 * @param string $text Text to analyze
1598 * @return int Syllable count
1599 */
1600 private function count_syllables(string $text): int {
1601 $words = preg_split('/\s+/', trim(strtolower(wp_strip_all_tags($text))), -1, PREG_SPLIT_NO_EMPTY);
1602 $syllables = 0;
1603
1604 foreach ($words as $word) {
1605 $syllables += max(1, preg_match_all('/[aeiouy]+/', $word));
1606 }
1607
1608 return $syllables;
1609 }
1610
1611 /**
1612 * Count internal links in content
1613 *
1614 * @param string $content Content HTML
1615 * @return int Internal link count
1616 */
1617 private function count_internal_links(string $content): int {
1618 $site_url = get_site_url();
1619 $count = 0;
1620
1621 if (preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
1622 foreach ($matches[1] as $url) {
1623 if (strpos($url, $site_url) !== false || strpos($url, '/') === 0) {
1624 $count++;
1625 }
1626 }
1627 }
1628
1629 return $count;
1630 }
1631
1632 /**
1633 * Count external links in content
1634 *
1635 * @param string $content Content HTML
1636 * @return int External link count
1637 */
1638 private function count_external_links(string $content): int {
1639 $site_url = get_site_url();
1640 $count = 0;
1641
1642 if (preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
1643 foreach ($matches[1] as $url) {
1644 if (strpos($url, 'http') === 0 && strpos($url, $site_url) === false) {
1645 $count++;
1646 }
1647 }
1648 }
1649
1650 return $count;
1651 }
1652
1653 /**
1654 * Analyze images in content
1655 *
1656 * @param string $content Content HTML
1657 * @return array Image analysis data
1658 */
1659 private function analyze_images(string $content): array {
1660 $images = [];
1661
1662 if (preg_match_all('/<img[^>]+>/i', $content, $matches)) {
1663 foreach ($matches[0] as $img_tag) {
1664 $alt = '';
1665 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
1666 $alt = $alt_match[1];
1667 }
1668
1669 $src = '';
1670 if (preg_match('/src=["\']([^"\']*)["\']/', $img_tag, $src_match)) {
1671 $src = $src_match[1];
1672 }
1673
1674 $images[] = [
1675 'src' => $src,
1676 'alt' => $alt,
1677 ];
1678 }
1679 }
1680
1681 return $images;
1682 }
1683
1684 /**
1685 * Save SEO score to database
1686 *
1687 * @param int $post_id Post ID
1688 * @param int $user_id User ID
1689 * @param array $score_data Score data
1690 * @return int|false Score ID or false on failure
1691 */
1692 public function save_score(int $post_id, int $user_id, array $score_data) {
1693 global $wpdb;
1694
1695 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
1696
1697 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score storage requires direct database access
1698 $result = $wpdb->insert(
1699 $table_name,
1700 [
1701 'post_id' => $post_id,
1702 'user_id' => $user_id,
1703 'overall_score' => $score_data['overall_score'],
1704 'score_breakdown' => json_encode($score_data['score_breakdown']),
1705 'suggestions' => json_encode($score_data['suggestions']),
1706 'grade' => $score_data['grade'],
1707 'algorithm_version' => $score_data['algorithm_version'] ?? '2024.1',
1708 'calculated_at' => $score_data['calculated_at'],
1709 'created_at' => current_time('mysql'),
1710 ],
1711 [
1712 '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s'
1713 ]
1714 );
1715
1716 return $result ? $wpdb->insert_id : false;
1717 }
1718
1719 /**
1720 * Get score history for a post
1721 *
1722 * @param int $post_id Post ID
1723 * @param int $limit Number of scores to retrieve
1724 * @return array Score history
1725 */
1726 public function get_score_history(int $post_id, int $limit = 10): array {
1727 global $wpdb;
1728
1729 // Get table name and escape it properly (table names cannot be parameterized)
1730 $table_name = esc_sql($wpdb->prefix . 'thinkrank_seo_scores');
1731
1732 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score history requires direct database access
1733 $results = $wpdb->get_results(
1734 $wpdb->prepare(
1735 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped using esc_sql()
1736 "SELECT * FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT %d",
1737 $post_id,
1738 $limit
1739 ),
1740 ARRAY_A
1741 );
1742
1743 // Decode JSON fields
1744 foreach ($results as &$result) {
1745 $result['score_breakdown'] = json_decode($result['score_breakdown'], true);
1746 $result['suggestions'] = json_decode($result['suggestions'], true);
1747 }
1748
1749 return $results ?: [];
1750 }
1751
1752 /**
1753 * Get latest score for a post
1754 *
1755 * @param int $post_id Post ID
1756 * @return array|null Latest score data
1757 */
1758 public function get_latest_score(int $post_id): ?array {
1759 $history = $this->get_score_history($post_id, 1);
1760 return !empty($history) ? $history[0] : null;
1761 }
1762
1763 /**
1764 * Score mobile experience - NEW 2025 factor (5 points)
1765 *
1766 * @param array $content_data Content analysis data
1767 * @return array Scoring result
1768 */
1769 private function score_mobile_experience(array $content_data): array {
1770 return [
1771 'score' => 4, // Default good score for mobile
1772 'max_score' => $this->scoring_factors['mobile_experience'],
1773 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1774 'details' => ['mobile_score' => 'Good']
1775 ];
1776 }
1777
1778 /**
1779 * Score core web vitals - 2025 version (3 points)
1780 *
1781 * @param array $content_data Content analysis data
1782 * @return array Scoring result
1783 */
1784 private function score_core_web_vitals(array $content_data): array {
1785 return [
1786 'score' => 2, // Default partial score
1787 'max_score' => $this->scoring_factors['core_web_vitals'],
1788 'suggestions' => ['Optimize Core Web Vitals: LCP, FID, and CLS for better user experience'],
1789 'details' => ['vitals_status' => 'Needs improvement']
1790 ];
1791 }
1792
1793 /**
1794 * Score internal linking - declining importance (1 point)
1795 *
1796 * @param array $content_data Content analysis data
1797 * @return array Scoring result
1798 */
1799 private function score_internal_linking(array $content_data): array {
1800 $internal_links = $content_data['internal_links'] ?? 0;
1801 $score = $internal_links > 0 ? 1 : 0;
1802
1803 return [
1804 'score' => $score,
1805 'max_score' => $this->scoring_factors['internal_linking'],
1806 'suggestions' => $score === 0 ? ['Add relevant internal links to other pages on your site'] : [],
1807 'details' => ['internal_links_count' => $internal_links]
1808 ];
1809 }
1810
1811 /**
1812 * Score technical factors (1 point)
1813 *
1814 * @param array $content_data Content analysis data
1815 * @param array $metadata Post metadata
1816 * @param array $options Additional options
1817 * @return array Scoring result
1818 */
1819 private function score_technical_factors(array $content_data, array $metadata, array $options): array {
1820 $score = 0;
1821 $suggestions = [];
1822
1823 // Meta description check
1824 $meta_desc = $metadata['description'] ?? '';
1825 if (!empty($meta_desc) && strlen($meta_desc) >= 120 && strlen($meta_desc) <= 160) {
1826 $score += 0.5;
1827 } else {
1828 $suggestions[] = 'Add a compelling meta description (120-160 characters)';
1829 }
1830
1831 // Schema markup check (simplified)
1832 if (!empty($content_data['schema_present'])) {
1833 $score += 0.5;
1834 } else {
1835 $suggestions[] = 'Consider adding structured data (schema markup)';
1836 }
1837
1838 return [
1839 'score' => $score,
1840 'max_score' => $this->scoring_factors['technical_factors'],
1841 'suggestions' => $suggestions,
1842 'details' => [
1843 'meta_description_length' => strlen($meta_desc),
1844 'schema_present' => !empty($content_data['schema_present'])
1845 ]
1846 ];
1847 }
1848
1849 /**
1850 * Get grade from score
1851 *
1852 * @param mixed $score Numeric score
1853 * @return string Letter grade
1854 */
1855 private function get_grade_from_score($score): string {
1856 $score = (int) $score; // Ensure it's an integer
1857
1858 if ($score >= 95) return 'A+';
1859 if ($score >= 90) return 'A';
1860 if ($score >= 85) return 'A-';
1861 if ($score >= 80) return 'B+';
1862 if ($score >= 75) return 'B';
1863 if ($score >= 70) return 'B-';
1864 if ($score >= 65) return 'C+';
1865 if ($score >= 60) return 'C';
1866 if ($score >= 55) return 'C-';
1867 if ($score >= 45) return 'D+';
1868 if ($score >= 35) return 'D';
1869 return 'F';
1870 }
1871
1872 /**
1873 * Analyze live content from editor (not saved to database yet)
1874 * Same as analyze_post_content but uses provided content instead of saved content
1875 *
1876 * @param string $live_content Live content from editor
1877 * @param int $post_id Post ID for metadata
1878 * @return array Content analysis data
1879 */
1880 public function analyze_live_content(string $live_content, int $post_id): array {
1881 $post = get_post($post_id);
1882 if (!$post) {
1883 return [];
1884 }
1885
1886 $content = $live_content; // Use live content instead of $post->post_content
1887 $title = $post->post_title;
1888
1889 // Extract headings from content
1890 $headings = $this->extract_headings($content);
1891
1892 // Count words using JavaScript-compatible method
1893 $plain_text = wp_strip_all_tags($content);
1894 $word_count = $this->calculate_word_count_js_style($plain_text);
1895
1896 // Calculate readability
1897 $readability_score = $this->calculate_readability_score($content);
1898
1899 // Count links
1900 $internal_links = $this->count_internal_links($content);
1901 $external_links = $this->count_external_links($content);
1902
1903 // Analyze images
1904 $images = $this->analyze_images($content);
1905
1906 // Get URL
1907 $url = get_permalink($post_id);
1908
1909 return [
1910 'content' => $content,
1911 'title' => $title,
1912 'headings' => $headings,
1913 'word_count' => $word_count,
1914 'readability_score' => $readability_score,
1915 'internal_links' => $internal_links,
1916 'external_links' => $external_links,
1917 'images' => $images,
1918 'url' => $url,
1919 ];
1920 }
1921
1922 /**
1923 * Calculate word count using JavaScript-compatible method
1924 * Matches the logic in contentAnalysis.js for consistency
1925 *
1926 * @param string $text Text to count words in
1927 * @return int Word count
1928 */
1929 private function calculate_word_count_js_style(string $text): int {
1930 if (empty($text)) {
1931 return 0;
1932 }
1933
1934 // Match JavaScript: trim, split by whitespace, filter empty
1935 $words = preg_split('/\s+/', trim($text), -1, PREG_SPLIT_NO_EMPTY);
1936 return count($words);
1937 }
1938
1939 /**
1940 * Get existing score data for a post from database
1941 *
1942 * @param int $post_id Post ID
1943 * @return array|null Existing score data or null if not found
1944 */
1945 public function get_existing_score_data(int $post_id): ?array {
1946 global $wpdb;
1947
1948 // Get table name and escape it properly (table names cannot be parameterized)
1949 $table_name = esc_sql($wpdb->prefix . 'thinkrank_seo_scores');
1950
1951 // Get the most recent score for this post
1952 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score retrieval requires direct database access
1953 $result = $wpdb->get_row($wpdb->prepare(
1954 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped using esc_sql()
1955 "SELECT * FROM `{$table_name}`
1956 WHERE post_id = %d
1957 ORDER BY calculated_at DESC
1958 LIMIT 1",
1959 $post_id
1960 ), ARRAY_A);
1961
1962 if (!$result) {
1963 return null;
1964 }
1965
1966 // Decode JSON data (stored with json_encode)
1967 $score_breakdown = json_decode($result['score_breakdown'], true);
1968 $suggestions = json_decode($result['suggestions'], true);
1969
1970 // Format the data to match the expected structure
1971 return [
1972 'overall_score' => (int) $result['overall_score'],
1973 'grade' => $result['grade'],
1974 'score_breakdown' => $score_breakdown,
1975 'suggestions' => $suggestions ?: [],
1976 'target_keyword' => null, // Not stored in database, will be provided by frontend
1977 'calculated_at' => $result['calculated_at'],
1978 'score_id' => $result['id']
1979 ];
1980 }
1981 }
1982