PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-seo-scoring-engine.php

class-seo-scoring-engine.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.5.0, at includes/seo/class-seo-scoring-engine.php

906 lines 32.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SEO Scoring Engine Class
4 *
5 * Calculates SEO health scores and performance ratings based on Google API data.
6 * Provides quantified SEO assessment for overall site health, page performance,
7 * and keyword opportunities with industry benchmarks.
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 * SEO Scoring Engine Class
25 *
26 * Single Responsibility: Calculate SEO performance scores and ratings
27 * Following ThinkRank scoring patterns from existing SEO_Score_Calculator
28 *
29 * @since 1.0.0
30 */
31 class SEO_Scoring_Engine {
32
33 /**
34 * Maximum SEO health score
35 *
36 * @var int
37 */
38 private const MAX_HEALTH_SCORE = 100;
39
40 /**
41 * Score weight distribution for SEO health calculation
42 *
43 * @var array
44 */
45 private const HEALTH_SCORE_WEIGHTS = [
46 'traffic_growth' => 25,
47 'keyword_performance' => 25,
48 'content_engagement' => 25,
49 'technical_performance' => 25
50 ];
51
52 /**
53 * Industry benchmark CTR values by position, as percentages (0-100).
54 *
55 * Anything compared against these has to be in the same units: Search
56 * Console reports `ctr` as a fraction, so a caller sourcing it there
57 * multiplies by 100 before handing it to score_page_performance().
58 *
59 * @var array
60 */
61 private const CTR_BENCHMARKS = [
62 1 => 31.7,
63 2 => 24.7,
64 3 => 18.7,
65 4 => 13.7,
66 5 => 9.5,
67 6 => 6.8,
68 7 => 4.8,
69 8 => 3.5,
70 9 => 2.5,
71 10 => 2.2
72 ];
73
74 /**
75 * Calculate overall SEO health score
76 *
77 * @param array $analytics_data Google Analytics data
78 * @param array $search_data Search Console data
79 * @return array SEO health score analysis
80 */
81 public function calculate_seo_health_score(array $analytics_data, array $search_data): array {
82 $scores = [
83 'traffic_growth' => $this->score_traffic_growth($analytics_data),
84 'keyword_performance' => $this->score_keyword_performance($search_data),
85 'content_engagement' => $this->score_content_engagement($analytics_data),
86 'technical_performance' => $this->score_technical_performance($analytics_data)
87 ];
88
89 $overall_score = $this->calculate_weighted_score($scores, self::HEALTH_SCORE_WEIGHTS);
90 $grade = $this->determine_grade($overall_score);
91
92 return [
93 'overall_score' => $overall_score,
94 'grade' => $grade,
95 'component_scores' => $scores,
96 'interpretation' => $this->generate_score_interpretation($overall_score, $grade),
97 'recommendations' => $this->generate_score_recommendations($scores),
98 'last_calculated' => current_time('mysql')
99 ];
100 }
101
102 /**
103 * Score individual page performance
104 *
105 * Each of the four dimensions is worth 25 and returns null when the data it
106 * scores is absent, so `overall_score` is the mean of what was actually
107 * measured, `measured` says how many that was, and `unmeasured` names the
108 * rest. Below two measured dimensions there is no `grade` at all: one
109 * dimension scaled to 100 would read as a perfect page.
110 *
111 * @param array $page_data Page performance data. Recognised keys:
112 * `pageviews`, `sessions`, `position`,
113 * `impressions`, `ctr`, `index_verdict`, `path`.
114 * `ctr` is a PERCENTAGE (0-100), matching
115 * CTR_BENCHMARKS — Search Console returns a
116 * fraction, so multiply by 100 before passing it.
117 * @param array $benchmarks Industry benchmarks.
118 * @return array Page performance score: overall_score, grade, measured,
119 * component_scores, unmeasured, page_path, recommendations.
120 */
121 public function score_page_performance(array $page_data, array $benchmarks): array {
122 // A component returns null when the data it scores is absent. Averaging
123 // a placeholder in its place is what makes a dashboard lie: two of these
124 // four used to return a flat constant whatever they were handed, so a
125 // page with no analytics and no Search Console connection still scored
126 // 35 of a possible 100 for dimensions nobody had measured. Absent is
127 // reported as absent, and the overall score is the mean of what was
128 // actually measured.
129 $scores = array_filter(
130 [
131 'traffic_contribution' => $this->score_traffic_contribution($page_data),
132 'search_visibility' => $this->score_search_visibility($page_data),
133 'engagement_quality' => $this->score_engagement_quality($page_data),
134 'technical_health' => $this->score_technical_health($page_data)
135 ],
136 static function ($score) {
137 return null !== $score;
138 }
139 );
140
141 $measured = count($scores);
142
143 // Each component is worth 25 of the 100, so the mean of the measured
144 // ones scales back to the same 0-100 range no matter how many there are.
145 $overall_score = $measured > 0 ? (array_sum($scores) / $measured) * 4 : 0.0;
146
147 // A single measured dimension scaled to 100 reads as a perfect page,
148 // which is the same lie the flat constants told in a different shape.
149 // Below half the dimensions there is no headline number, only the
150 // per-dimension detail — and the caller is told how many were measured
151 // so it can say so.
152 $grade = $measured >= 2 ? $this->determine_grade($overall_score) : '';
153
154 return [
155 'overall_score' => round($overall_score, 1),
156 'grade' => $grade,
157 'measured' => $measured,
158 'component_scores' => $scores,
159 // What could not be measured, so a caller can say so rather than
160 // showing a dimension as passing.
161 'unmeasured' => array_values(array_diff(
162 ['traffic_contribution', 'search_visibility', 'engagement_quality', 'technical_health'],
163 array_keys($scores)
164 )),
165 'page_path' => $page_data['path'] ?? '',
166 'recommendations' => $this->generate_page_recommendations($scores, $page_data)
167 ];
168 }
169
170 /**
171 * Score keyword opportunities
172 *
173 * @param array $keyword_data Keyword performance data
174 * @return array Keyword opportunity scores
175 */
176 public function score_keyword_opportunities(array $keyword_data): array {
177 $opportunities = [];
178 $rows = $keyword_data['rows'] ?? [];
179
180 foreach ($rows as $row) {
181 $keyword = $row['keys'][0] ?? '';
182 $clicks = $row['clicks'] ?? 0;
183 $impressions = $row['impressions'] ?? 0;
184 $position = $row['position'] ?? 0;
185 $ctr = $impressions > 0 ? ($clicks / $impressions) * 100 : 0;
186
187 $opportunity_score = $this->calculate_keyword_opportunity_score($clicks, $impressions, $position, $ctr);
188
189 if ($opportunity_score > 0) {
190 $opportunities[] = [
191 'keyword' => $keyword,
192 'opportunity_score' => $opportunity_score,
193 'current_position' => round($position, 1),
194 'current_ctr' => round($ctr, 2),
195 'expected_ctr' => $this->get_expected_ctr($position),
196 'clicks' => $clicks,
197 'impressions' => $impressions,
198 'potential_impact' => $this->calculate_potential_impact($impressions, $position, $ctr)
199 ];
200 }
201 }
202
203 // Sort by opportunity score descending
204 usort($opportunities, function($a, $b) {
205 return $b['opportunity_score'] <=> $a['opportunity_score'];
206 });
207
208 return [
209 'opportunities' => array_slice($opportunities, 0, 20),
210 'total_opportunities' => count($opportunities),
211 'high_impact_count' => count(array_filter($opportunities, function($opp) {
212 return $opp['opportunity_score'] >= 70;
213 }))
214 ];
215 }
216
217 /**
218 * Generate priority matrix for opportunities
219 *
220 * @param array $scores Various performance scores
221 * @return array Priority matrix
222 */
223 public function generate_priority_matrix(array $scores): array {
224 $matrix = [
225 'high_impact_low_effort' => [],
226 'high_impact_high_effort' => [],
227 'low_impact_low_effort' => [],
228 'low_impact_high_effort' => []
229 ];
230
231 foreach ($scores as $item) {
232 $impact = $this->determine_impact_level($item);
233 $effort = $this->determine_effort_level($item);
234
235 $category = $impact . '_impact_' . $effort . '_effort';
236 if (isset($matrix[$category])) {
237 $matrix[$category][] = $item;
238 }
239 }
240
241 return $matrix;
242 }
243
244 /**
245 * Get industry benchmarks for comparison
246 *
247 * @param string $industry Industry type
248 * @return array Industry benchmarks
249 */
250 public function get_industry_benchmarks(string $industry = 'general'): array {
251 // Default benchmarks - could be expanded with industry-specific data
252 return [
253 'average_ctr' => 2.5,
254 'average_position' => 15.0,
255 'bounce_rate_threshold' => 70.0,
256 'session_duration_threshold' => 120, // seconds
257 'pages_per_session_threshold' => 2.0,
258 'core_web_vitals' => [
259 'lcp_threshold' => 2.5, // seconds
260 'inp_threshold' => 200, // milliseconds
261 'cls_threshold' => 0.1
262 ]
263 ];
264 }
265
266 /**
267 * Score traffic growth component
268 *
269 * @param array $analytics_data Analytics data
270 * @return float Traffic growth score
271 */
272 private function score_traffic_growth(array $analytics_data): float {
273 $traffic = $analytics_data['traffic'] ?? [];
274 $organic_traffic = $analytics_data['organic_traffic'] ?? [];
275
276 $sessions = $traffic['sessions'] ?? 0;
277 $organic_sessions = $organic_traffic['organic_traffic']['sessions'] ?? 0;
278
279 // Score based on organic traffic percentage and absolute numbers
280 $organic_percentage = $sessions > 0 ? ($organic_sessions / $sessions) * 100 : 0;
281
282 if ($organic_percentage >= 60) {
283 return 25.0; // Excellent organic traffic share
284 } elseif ($organic_percentage >= 40) {
285 return 20.0; // Good organic traffic share
286 } elseif ($organic_percentage >= 20) {
287 return 15.0; // Fair organic traffic share
288 } else {
289 return 10.0; // Poor organic traffic share
290 }
291 }
292
293 /**
294 * Score keyword performance component
295 *
296 * @param array $search_data Search Console data
297 * @return float Keyword performance score
298 */
299 private function score_keyword_performance(array $search_data): float {
300 $search_performance = $search_data['search_performance'] ?? [];
301 $rows = $search_performance['rows'] ?? [];
302
303 if (empty($rows)) {
304 return 0.0;
305 }
306
307 $total_clicks = 0;
308 $total_impressions = 0;
309 $position_sum = 0;
310 $keyword_count = 0;
311
312 foreach ($rows as $row) {
313 $total_clicks += $row['clicks'] ?? 0;
314 $total_impressions += $row['impressions'] ?? 0;
315 $position_sum += $row['position'] ?? 0;
316 $keyword_count++;
317 }
318
319 $average_position = $keyword_count > 0 ? $position_sum / $keyword_count : 0;
320 $overall_ctr = $total_impressions > 0 ? ($total_clicks / $total_impressions) * 100 : 0;
321
322 // Score based on average position and CTR
323 $position_score = $this->score_average_position($average_position);
324 $ctr_score = $this->score_ctr_performance($overall_ctr);
325
326 return ($position_score + $ctr_score) / 2;
327 }
328
329 /**
330 * Score content engagement component
331 *
332 * @param array $analytics_data Analytics data
333 * @return float Content engagement score
334 */
335 private function score_content_engagement(array $analytics_data): float {
336 $traffic = $analytics_data['traffic'] ?? [];
337
338 $bounce_rate = $traffic['bounce_rate'] ?? 0;
339 $avg_session_duration = $traffic['avg_session_duration'] ?? 0;
340
341 // Score based on engagement metrics (lower bounce rate and higher session duration is better)
342 $bounce_score = $this->score_bounce_rate($bounce_rate);
343 $duration_score = $this->score_session_duration($avg_session_duration);
344
345 return ($bounce_score + $duration_score) / 2;
346 }
347
348 /**
349 * Score technical performance component
350 *
351 * @param array $analytics_data Analytics data
352 * @return float Technical performance score
353 */
354 private function score_technical_performance(array $analytics_data): float {
355 $core_web_vitals = $analytics_data['core_web_vitals'] ?? [];
356
357 if (empty($core_web_vitals)) {
358 return 15.0; // Default score when no Core Web Vitals data available
359 }
360
361 // Score based on Core Web Vitals metrics
362 // Both sides of this merge are needed: extract_vital_value() unwraps the
363 // structured vital payload, and the metric is INP rather than the
364 // retired FID.
365 $lcp_score = $this->score_lcp($this->extract_vital_value($core_web_vitals['lcp'] ?? 0));
366 $inp_score = $this->score_inp($this->extract_vital_value($core_web_vitals['inp'] ?? 0));
367 $cls_score = $this->score_cls($this->extract_vital_value($core_web_vitals['cls'] ?? 0));
368
369 return ($lcp_score + $inp_score + $cls_score) / 3;
370 }
371
372 /**
373 * Extract the numeric value from a Core Web Vital entry
374 *
375 * The PageSpeed client represents each vital as a structured array
376 * (['value' => 14.7652, 'unit' => 's', 'score' => 30, ...]); older or
377 * cached payloads may carry a bare scalar. Accept both.
378 *
379 * @param mixed $vital Structured vital array or scalar value
380 * @return float Numeric metric value
381 */
382 private function extract_vital_value($vital): float {
383 if (is_array($vital)) {
384 $vital = $vital['value'] ?? 0;
385 }
386
387 return is_numeric($vital) ? (float) $vital : 0.0;
388 }
389
390 /**
391 * Calculate weighted score from component scores
392 *
393 * @param array $scores Component scores
394 * @param array $weights Score weights
395 * @return float Weighted overall score
396 */
397 private function calculate_weighted_score(array $scores, array $weights): float {
398 $total_score = 0;
399 $total_weight = 0;
400
401 foreach ($scores as $component => $score) {
402 $weight = $weights[$component] ?? 0;
403 $total_score += $score * ($weight / 100);
404 $total_weight += $weight;
405 }
406
407 return $total_weight > 0 ? ($total_score / $total_weight) * 100 : 0;
408 }
409
410 /**
411 * Determine grade from score
412 *
413 * @param float $score Numeric score
414 * @return string Letter grade
415 */
416 private function determine_grade(float $score): string {
417 if ($score >= 90) {
418 return 'A+';
419 } elseif ($score >= 80) {
420 return 'A';
421 } elseif ($score >= 70) {
422 return 'B';
423 } elseif ($score >= 60) {
424 return 'C';
425 } elseif ($score >= 50) {
426 return 'D';
427 } else {
428 return 'F';
429 }
430 }
431
432 /**
433 * Generate score interpretation
434 *
435 * @param float $score Overall score
436 * @param string $grade Letter grade
437 * @return string Score interpretation
438 */
439 private function generate_score_interpretation(float $score, string $grade): string {
440 switch ($grade) {
441 case 'A+':
442 return 'Excellent SEO performance with strong metrics across all areas.';
443 case 'A':
444 return 'Very good SEO performance with minor areas for improvement.';
445 case 'B':
446 return 'Good SEO performance with some optimization opportunities.';
447 case 'C':
448 return 'Fair SEO performance with several areas needing attention.';
449 case 'D':
450 return 'Poor SEO performance requiring significant improvements.';
451 case 'F':
452 return 'Critical SEO issues requiring immediate attention.';
453 default:
454 return sprintf('SEO performance score: %.1f/100', $score);
455 }
456 }
457
458 /**
459 * Generate score-based recommendations
460 *
461 * @param array $scores Component scores
462 * @return array Recommendations
463 */
464 private function generate_score_recommendations(array $scores): array {
465 $recommendations = [];
466
467 foreach ($scores as $component => $score) {
468 if ($score < 15) {
469 $recommendations[] = $this->get_component_recommendation($component, 'critical');
470 } elseif ($score < 20) {
471 $recommendations[] = $this->get_component_recommendation($component, 'high');
472 } elseif ($score < 22) {
473 $recommendations[] = $this->get_component_recommendation($component, 'medium');
474 }
475 }
476
477 return $recommendations;
478 }
479
480 /**
481 * Get component-specific recommendation
482 *
483 * @param string $component Component name
484 * @param string $priority Priority level
485 * @return array Recommendation
486 */
487 private function get_component_recommendation(string $component, string $priority): array {
488 $recommendations = [
489 'traffic_growth' => [
490 'critical' => 'Focus on organic traffic growth through content optimization and keyword targeting.',
491 'high' => 'Improve organic traffic share by optimizing existing content and building quality backlinks.',
492 'medium' => 'Continue growing organic traffic through consistent content creation and SEO optimization.'
493 ],
494 'keyword_performance' => [
495 'critical' => 'Urgent keyword optimization needed - focus on improving rankings for target keywords.',
496 'high' => 'Optimize keyword targeting and improve content relevance for better rankings.',
497 'medium' => 'Fine-tune keyword strategy and monitor ranking improvements.'
498 ],
499 'content_engagement' => [
500 'critical' => 'Critical engagement issues - review content quality and user experience immediately.',
501 'high' => 'Improve content engagement through better formatting, internal linking, and user experience.',
502 'medium' => 'Enhance content engagement with multimedia elements and improved readability.'
503 ],
504 'technical_performance' => [
505 'critical' => 'Critical technical issues affecting SEO - address Core Web Vitals and site speed immediately.',
506 'high' => 'Improve technical SEO by optimizing page speed and Core Web Vitals.',
507 'medium' => 'Fine-tune technical performance for better user experience and SEO.'
508 ]
509 ];
510
511 return [
512 'component' => $component,
513 'priority' => $priority,
514 'recommendation' => $recommendations[$component][$priority] ?? 'Optimize this component for better SEO performance.'
515 ];
516 }
517
518 /**
519 * Calculate keyword opportunity score
520 *
521 * @param int $clicks Current clicks
522 * @param int $impressions Current impressions
523 * @param float $position Current position
524 * @param float $ctr Current CTR
525 * @return float Opportunity score (0-100)
526 */
527 private function calculate_keyword_opportunity_score(int $clicks, int $impressions, float $position, float $ctr): float {
528 // No opportunity if no impressions
529 if ($impressions < 10) {
530 return 0;
531 }
532
533 $expected_ctr = $this->get_expected_ctr($position);
534 $ctr_gap = max(0, $expected_ctr - $ctr);
535
536 // Higher score for keywords with:
537 // 1. High impressions (more potential)
538 // 2. Position 4-10 (page 1 potential)
539 // 3. CTR below expected (optimization opportunity)
540
541 $impression_score = min(40, $impressions / 100); // Max 40 points for impressions
542 $position_score = $position <= 10 ? (11 - $position) * 3 : 0; // Max 30 points for position
543 $ctr_opportunity_score = min(30, $ctr_gap * 10); // Max 30 points for CTR gap
544
545 return min(100, $impression_score + $position_score + $ctr_opportunity_score);
546 }
547
548 /**
549 * Get expected CTR for position
550 *
551 * @param float $position Search position
552 * @return float Expected CTR percentage
553 */
554 private function get_expected_ctr(float $position): float {
555 $pos = (int) round($position);
556
557 if ($pos <= 10) {
558 return self::CTR_BENCHMARKS[$pos] ?? 1.0;
559 } elseif ($pos <= 20) {
560 return 1.0;
561 } else {
562 return 0.5;
563 }
564 }
565
566 /**
567 * Calculate potential impact of optimization
568 *
569 * @param int $impressions Current impressions
570 * @param float $position Current position
571 * @param float $current_ctr Current CTR
572 * @return array Potential impact analysis
573 */
574 private function calculate_potential_impact(int $impressions, float $position, float $current_ctr): array {
575 $expected_ctr = $this->get_expected_ctr($position);
576 $potential_additional_clicks = $impressions * (($expected_ctr - $current_ctr) / 100);
577
578 return [
579 'additional_clicks_potential' => max(0, round($potential_additional_clicks)),
580 'ctr_improvement_potential' => max(0, round($expected_ctr - $current_ctr, 2)),
581 'impact_level' => $potential_additional_clicks > 50 ? 'high' : ($potential_additional_clicks > 10 ? 'medium' : 'low')
582 ];
583 }
584
585 /**
586 * Score average position performance
587 *
588 * @param float $average_position Average search position
589 * @return float Position score
590 */
591 private function score_average_position(float $average_position): float {
592 if ($average_position <= 3) {
593 return 12.5; // Excellent - top 3 positions
594 } elseif ($average_position <= 10) {
595 return 10.0; // Good - page 1
596 } elseif ($average_position <= 20) {
597 return 7.5; // Fair - page 2
598 } else {
599 return 5.0; // Poor - beyond page 2
600 }
601 }
602
603 /**
604 * Score CTR performance
605 *
606 * @param float $ctr Click-through rate percentage
607 * @return float CTR score
608 */
609 private function score_ctr_performance(float $ctr): float {
610 if ($ctr >= 5.0) {
611 return 12.5; // Excellent CTR
612 } elseif ($ctr >= 3.0) {
613 return 10.0; // Good CTR
614 } elseif ($ctr >= 1.5) {
615 return 7.5; // Fair CTR
616 } else {
617 return 5.0; // Poor CTR
618 }
619 }
620
621 /**
622 * Score bounce rate performance
623 *
624 * @param float $bounce_rate Bounce rate percentage
625 * @return float Bounce rate score
626 */
627 private function score_bounce_rate(float $bounce_rate): float {
628 if ($bounce_rate <= 40) {
629 return 12.5; // Excellent engagement
630 } elseif ($bounce_rate <= 55) {
631 return 10.0; // Good engagement
632 } elseif ($bounce_rate <= 70) {
633 return 7.5; // Fair engagement
634 } else {
635 return 5.0; // Poor engagement
636 }
637 }
638
639 /**
640 * Score session duration performance
641 *
642 * @param float $duration Average session duration in seconds
643 * @return float Duration score
644 */
645 private function score_session_duration(float $duration): float {
646 if ($duration >= 180) { // 3+ minutes
647 return 12.5; // Excellent engagement
648 } elseif ($duration >= 120) { // 2+ minutes
649 return 10.0; // Good engagement
650 } elseif ($duration >= 60) { // 1+ minute
651 return 7.5; // Fair engagement
652 } else {
653 return 5.0; // Poor engagement
654 }
655 }
656
657 /**
658 * Score Largest Contentful Paint (LCP)
659 *
660 * @param float $lcp LCP value in seconds
661 * @return float LCP score
662 */
663 private function score_lcp(float $lcp): float {
664 if ($lcp <= 2.5) {
665 return 8.33; // Good
666 } elseif ($lcp <= 4.0) {
667 return 6.0; // Needs improvement
668 } else {
669 return 3.0; // Poor
670 }
671 }
672
673 /**
674 * Score Interaction to Next Paint (INP)
675 *
676 * INP replaced FID as a Core Web Vital in March 2024; its thresholds are
677 * 200ms (good) and 500ms (needs improvement).
678 *
679 * @param float $inp INP value in milliseconds
680 * @return float INP score
681 */
682 private function score_inp(float $inp): float {
683 if ($inp <= 200) {
684 return 8.33; // Good
685 } elseif ($inp <= 500) {
686 return 6.0; // Needs improvement
687 } else {
688 return 3.0; // Poor
689 }
690 }
691
692 /**
693 * Score Cumulative Layout Shift (CLS)
694 *
695 * @param float $cls CLS value
696 * @return float CLS score
697 */
698 private function score_cls(float $cls): float {
699 if ($cls <= 0.1) {
700 return 8.33; // Good
701 } elseif ($cls <= 0.25) {
702 return 6.0; // Needs improvement
703 } else {
704 return 3.0; // Poor
705 }
706 }
707
708 /**
709 * Score traffic contribution for a page
710 *
711 * @param array $page_data Page data. Reads `pageviews`.
712 * @return float|null Traffic contribution score out of 25, or null when
713 * there is no analytics data to score.
714 */
715 private function score_traffic_contribution(array $page_data): ?float {
716 // Not the same question as "zero pageviews": a page with no analytics
717 // connected has no traffic data, and scoring it 10 would present the
718 // absence as a measurement.
719 if (!isset($page_data['pageviews'])) {
720 return null;
721 }
722
723 $pageviews = (int) $page_data['pageviews'];
724
725 if ($pageviews >= 1000) {
726 return 25.0; // High traffic page
727 } elseif ($pageviews >= 500) {
728 return 20.0; // Medium-high traffic
729 } elseif ($pageviews >= 100) {
730 return 15.0; // Medium traffic
731 } else {
732 return 10.0; // Low traffic
733 }
734 }
735
736 /**
737 * Score search visibility for a page
738 *
739 * @param array $page_data Page data. Reads `position`, `impressions` and
740 * `ctr` — `ctr` as a PERCENTAGE (0-100), because it
741 * is compared against CTR_BENCHMARKS, which are
742 * percentages. Search Console's API returns a
743 * fraction, so a caller reading it must multiply by
744 * 100 first: passing 0.30 for a healthy 30% CTR
745 * would penalise the page silently.
746 * @return float|null Search visibility score out of 25, or null when the
747 * page has no Search Console position to score.
748 */
749 private function score_search_visibility(array $page_data): ?float {
750 // Search Console's average position for this page. Absent when the
751 // property is not connected, or when the page has never appeared in
752 // results — in which case there is nothing to score.
753 $position = isset($page_data['position']) ? (float) $page_data['position'] : 0.0;
754 if ($position <= 0) {
755 return null;
756 }
757
758 // Position bands rather than a linear scale: the difference between 1
759 // and 3 matters far more than the difference between 40 and 42.
760 if ($position <= 3) {
761 $score = 25.0;
762 } elseif ($position <= 10) {
763 $score = 20.0;
764 } elseif ($position <= 20) {
765 $score = 15.0;
766 } elseif ($position <= 50) {
767 $score = 10.0;
768 } else {
769 $score = 5.0;
770 }
771
772 // A page ranking well that nobody clicks is a title/description problem,
773 // and the score should show it. Only applied where there are enough
774 // impressions for the rate to mean anything.
775 $impressions = isset($page_data['impressions']) ? (int) $page_data['impressions'] : 0;
776 $ctr = isset($page_data['ctr']) ? (float) $page_data['ctr'] : null;
777
778 if ($impressions >= 100 && null !== $ctr) {
779 $expected = $this->get_expected_ctr($position);
780 if ($expected > 0 && $ctr < ($expected / 2)) {
781 $score = max(5.0, $score - 5.0);
782 }
783 }
784
785 return $score;
786 }
787
788 /**
789 * Score engagement quality for a page
790 *
791 * @param array $page_data Page data. Reads `sessions` and `pageviews`.
792 * @return float|null Engagement quality score out of 25, or null when
793 * there is no session data to score.
794 */
795 private function score_engagement_quality(array $page_data): ?float {
796 if (!isset($page_data['sessions']) || !isset($page_data['pageviews'])) {
797 return null;
798 }
799
800 $sessions = (int) $page_data['sessions'];
801 $pageviews = (int) $page_data['pageviews'];
802
803 if ($sessions <= 0) {
804 // No sessions is no engagement signal, not poor engagement.
805 return null;
806 }
807
808 $pages_per_session = $pageviews / $sessions;
809
810 if ($pages_per_session >= 3.0) {
811 return 25.0; // Excellent engagement
812 } elseif ($pages_per_session >= 2.0) {
813 return 20.0; // Good engagement
814 } elseif ($pages_per_session >= 1.5) {
815 return 15.0; // Fair engagement
816 } else {
817 return 10.0; // Poor engagement
818 }
819 }
820
821 /**
822 * Score technical health for a page
823 *
824 * @param array $page_data Page data. Reads `index_verdict`.
825 * @return float|null Technical health score out of 25, or null when Search
826 * Console has no index verdict for the URL.
827 */
828 private function score_technical_health(array $page_data): ?float {
829 // Search Console's index verdict for the URL, which is the technical
830 // signal ThinkRank can actually source per page. Core Web Vitals would
831 // be the other half and has no per-URL source here, so it is absent
832 // rather than assumed.
833 $verdict = isset($page_data['index_verdict'])
834 ? strtoupper((string) $page_data['index_verdict'])
835 : '';
836
837 switch ($verdict) {
838 case 'PASS':
839 return 25.0;
840 case 'PARTIAL':
841 return 15.0;
842 case 'FAIL':
843 return 0.0;
844 case 'EXCLUDED':
845 return 5.0;
846 default:
847 return null;
848 }
849 }
850
851 /**
852 * Generate page-specific recommendations
853 *
854 * @param array $scores Component scores
855 * @param array $page_data Page data
856 * @return array Page recommendations
857 */
858 private function generate_page_recommendations(array $scores, array $page_data): array {
859 $recommendations = [];
860
861 // isset(), not a bare index: an unmeasured component is absent from the
862 // map now, and advising someone to fix a dimension nobody measured is
863 // exactly the fabrication the null returns exist to prevent.
864 if (isset($scores['traffic_contribution']) && $scores['traffic_contribution'] < 15) {
865 $recommendations[] = 'Improve content quality and SEO optimization to increase traffic';
866 }
867
868 if (isset($scores['search_visibility']) && $scores['search_visibility'] < 15) {
869 $recommendations[] = 'This page ranks outside the first two pages — revisit its target query, title and depth';
870 }
871
872 if (isset($scores['engagement_quality']) && $scores['engagement_quality'] < 15) {
873 $recommendations[] = 'Enhance content engagement with better formatting and internal links';
874 }
875
876 if (isset($scores['technical_health']) && $scores['technical_health'] < 15) {
877 $recommendations[] = 'Search Console cannot fully index this URL — check its index status';
878 }
879
880 return $recommendations;
881 }
882
883 /**
884 * Determine impact level for priority matrix
885 *
886 * @param array $item Item to evaluate
887 * @return string Impact level (high/low)
888 */
889 private function determine_impact_level(array $item): string {
890 $score = $item['opportunity_score'] ?? 0;
891 return $score >= 50 ? 'high' : 'low';
892 }
893
894 /**
895 * Determine effort level for priority matrix
896 *
897 * @param array $item Item to evaluate
898 * @return string Effort level (high/low)
899 */
900 private function determine_effort_level(array $item): string {
901 $position = $item['current_position'] ?? 100;
902 // Assume lower positions require more effort to improve
903 return $position <= 20 ? 'low' : 'high';
904 }
905 }
906