PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.30.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.30.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 1.30.0, at includes/seo/class-seo-scoring-engine.php

754 lines 25.1 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
54 *
55 * @var array
56 */
57 private const CTR_BENCHMARKS = [
58 1 => 31.7,
59 2 => 24.7,
60 3 => 18.7,
61 4 => 13.7,
62 5 => 9.5,
63 6 => 6.8,
64 7 => 4.8,
65 8 => 3.5,
66 9 => 2.5,
67 10 => 2.2
68 ];
69
70 /**
71 * Calculate overall SEO health score
72 *
73 * @param array $analytics_data Google Analytics data
74 * @param array $search_data Search Console data
75 * @return array SEO health score analysis
76 */
77 public function calculate_seo_health_score(array $analytics_data, array $search_data): array {
78 $scores = [
79 'traffic_growth' => $this->score_traffic_growth($analytics_data),
80 'keyword_performance' => $this->score_keyword_performance($search_data),
81 'content_engagement' => $this->score_content_engagement($analytics_data),
82 'technical_performance' => $this->score_technical_performance($analytics_data)
83 ];
84
85 $overall_score = $this->calculate_weighted_score($scores, self::HEALTH_SCORE_WEIGHTS);
86 $grade = $this->determine_grade($overall_score);
87
88 return [
89 'overall_score' => $overall_score,
90 'grade' => $grade,
91 'component_scores' => $scores,
92 'interpretation' => $this->generate_score_interpretation($overall_score, $grade),
93 'recommendations' => $this->generate_score_recommendations($scores),
94 'last_calculated' => current_time('mysql')
95 ];
96 }
97
98 /**
99 * Score individual page performance
100 *
101 * @param array $page_data Page performance data
102 * @param array $benchmarks Industry benchmarks
103 * @return array Page performance score
104 */
105 public function score_page_performance(array $page_data, array $benchmarks): array {
106 $scores = [
107 'traffic_contribution' => $this->score_traffic_contribution($page_data),
108 'search_visibility' => $this->score_search_visibility($page_data),
109 'engagement_quality' => $this->score_engagement_quality($page_data),
110 'technical_health' => $this->score_technical_health($page_data)
111 ];
112
113 $overall_score = array_sum($scores) / count($scores);
114 $grade = $this->determine_grade($overall_score);
115
116 return [
117 'overall_score' => round($overall_score, 1),
118 'grade' => $grade,
119 'component_scores' => $scores,
120 'page_path' => $page_data['path'] ?? '',
121 'recommendations' => $this->generate_page_recommendations($scores, $page_data)
122 ];
123 }
124
125 /**
126 * Score keyword opportunities
127 *
128 * @param array $keyword_data Keyword performance data
129 * @return array Keyword opportunity scores
130 */
131 public function score_keyword_opportunities(array $keyword_data): array {
132 $opportunities = [];
133 $rows = $keyword_data['rows'] ?? [];
134
135 foreach ($rows as $row) {
136 $keyword = $row['keys'][0] ?? '';
137 $clicks = $row['clicks'] ?? 0;
138 $impressions = $row['impressions'] ?? 0;
139 $position = $row['position'] ?? 0;
140 $ctr = $impressions > 0 ? ($clicks / $impressions) * 100 : 0;
141
142 $opportunity_score = $this->calculate_keyword_opportunity_score($clicks, $impressions, $position, $ctr);
143
144 if ($opportunity_score > 0) {
145 $opportunities[] = [
146 'keyword' => $keyword,
147 'opportunity_score' => $opportunity_score,
148 'current_position' => round($position, 1),
149 'current_ctr' => round($ctr, 2),
150 'expected_ctr' => $this->get_expected_ctr($position),
151 'clicks' => $clicks,
152 'impressions' => $impressions,
153 'potential_impact' => $this->calculate_potential_impact($impressions, $position, $ctr)
154 ];
155 }
156 }
157
158 // Sort by opportunity score descending
159 usort($opportunities, function($a, $b) {
160 return $b['opportunity_score'] <=> $a['opportunity_score'];
161 });
162
163 return [
164 'opportunities' => array_slice($opportunities, 0, 20),
165 'total_opportunities' => count($opportunities),
166 'high_impact_count' => count(array_filter($opportunities, function($opp) {
167 return $opp['opportunity_score'] >= 70;
168 }))
169 ];
170 }
171
172 /**
173 * Generate priority matrix for opportunities
174 *
175 * @param array $scores Various performance scores
176 * @return array Priority matrix
177 */
178 public function generate_priority_matrix(array $scores): array {
179 $matrix = [
180 'high_impact_low_effort' => [],
181 'high_impact_high_effort' => [],
182 'low_impact_low_effort' => [],
183 'low_impact_high_effort' => []
184 ];
185
186 foreach ($scores as $item) {
187 $impact = $this->determine_impact_level($item);
188 $effort = $this->determine_effort_level($item);
189
190 $category = $impact . '_impact_' . $effort . '_effort';
191 if (isset($matrix[$category])) {
192 $matrix[$category][] = $item;
193 }
194 }
195
196 return $matrix;
197 }
198
199 /**
200 * Get industry benchmarks for comparison
201 *
202 * @param string $industry Industry type
203 * @return array Industry benchmarks
204 */
205 public function get_industry_benchmarks(string $industry = 'general'): array {
206 // Default benchmarks - could be expanded with industry-specific data
207 return [
208 'average_ctr' => 2.5,
209 'average_position' => 15.0,
210 'bounce_rate_threshold' => 70.0,
211 'session_duration_threshold' => 120, // seconds
212 'pages_per_session_threshold' => 2.0,
213 'core_web_vitals' => [
214 'lcp_threshold' => 2.5, // seconds
215 'fid_threshold' => 100, // milliseconds
216 'cls_threshold' => 0.1
217 ]
218 ];
219 }
220
221 /**
222 * Score traffic growth component
223 *
224 * @param array $analytics_data Analytics data
225 * @return float Traffic growth score
226 */
227 private function score_traffic_growth(array $analytics_data): float {
228 $traffic = $analytics_data['traffic'] ?? [];
229 $organic_traffic = $analytics_data['organic_traffic'] ?? [];
230
231 $sessions = $traffic['sessions'] ?? 0;
232 $organic_sessions = $organic_traffic['organic_traffic']['sessions'] ?? 0;
233
234 // Score based on organic traffic percentage and absolute numbers
235 $organic_percentage = $sessions > 0 ? ($organic_sessions / $sessions) * 100 : 0;
236
237 if ($organic_percentage >= 60) {
238 return 25.0; // Excellent organic traffic share
239 } elseif ($organic_percentage >= 40) {
240 return 20.0; // Good organic traffic share
241 } elseif ($organic_percentage >= 20) {
242 return 15.0; // Fair organic traffic share
243 } else {
244 return 10.0; // Poor organic traffic share
245 }
246 }
247
248 /**
249 * Score keyword performance component
250 *
251 * @param array $search_data Search Console data
252 * @return float Keyword performance score
253 */
254 private function score_keyword_performance(array $search_data): float {
255 $search_performance = $search_data['search_performance'] ?? [];
256 $rows = $search_performance['rows'] ?? [];
257
258 if (empty($rows)) {
259 return 0.0;
260 }
261
262 $total_clicks = 0;
263 $total_impressions = 0;
264 $position_sum = 0;
265 $keyword_count = 0;
266
267 foreach ($rows as $row) {
268 $total_clicks += $row['clicks'] ?? 0;
269 $total_impressions += $row['impressions'] ?? 0;
270 $position_sum += $row['position'] ?? 0;
271 $keyword_count++;
272 }
273
274 $average_position = $keyword_count > 0 ? $position_sum / $keyword_count : 0;
275 $overall_ctr = $total_impressions > 0 ? ($total_clicks / $total_impressions) * 100 : 0;
276
277 // Score based on average position and CTR
278 $position_score = $this->score_average_position($average_position);
279 $ctr_score = $this->score_ctr_performance($overall_ctr);
280
281 return ($position_score + $ctr_score) / 2;
282 }
283
284 /**
285 * Score content engagement component
286 *
287 * @param array $analytics_data Analytics data
288 * @return float Content engagement score
289 */
290 private function score_content_engagement(array $analytics_data): float {
291 $traffic = $analytics_data['traffic'] ?? [];
292
293 $bounce_rate = $traffic['bounce_rate'] ?? 0;
294 $avg_session_duration = $traffic['avg_session_duration'] ?? 0;
295
296 // Score based on engagement metrics (lower bounce rate and higher session duration is better)
297 $bounce_score = $this->score_bounce_rate($bounce_rate);
298 $duration_score = $this->score_session_duration($avg_session_duration);
299
300 return ($bounce_score + $duration_score) / 2;
301 }
302
303 /**
304 * Score technical performance component
305 *
306 * @param array $analytics_data Analytics data
307 * @return float Technical performance score
308 */
309 private function score_technical_performance(array $analytics_data): float {
310 $core_web_vitals = $analytics_data['core_web_vitals'] ?? [];
311
312 if (empty($core_web_vitals)) {
313 return 15.0; // Default score when no Core Web Vitals data available
314 }
315
316 // Score based on Core Web Vitals metrics
317 $lcp_score = $this->score_lcp($core_web_vitals['lcp'] ?? 0);
318 $fid_score = $this->score_fid($core_web_vitals['fid'] ?? 0);
319 $cls_score = $this->score_cls($core_web_vitals['cls'] ?? 0);
320
321 return ($lcp_score + $fid_score + $cls_score) / 3;
322 }
323
324 /**
325 * Calculate weighted score from component scores
326 *
327 * @param array $scores Component scores
328 * @param array $weights Score weights
329 * @return float Weighted overall score
330 */
331 private function calculate_weighted_score(array $scores, array $weights): float {
332 $total_score = 0;
333 $total_weight = 0;
334
335 foreach ($scores as $component => $score) {
336 $weight = $weights[$component] ?? 0;
337 $total_score += $score * ($weight / 100);
338 $total_weight += $weight;
339 }
340
341 return $total_weight > 0 ? ($total_score / $total_weight) * 100 : 0;
342 }
343
344 /**
345 * Determine grade from score
346 *
347 * @param float $score Numeric score
348 * @return string Letter grade
349 */
350 private function determine_grade(float $score): string {
351 if ($score >= 90) {
352 return 'A+';
353 } elseif ($score >= 80) {
354 return 'A';
355 } elseif ($score >= 70) {
356 return 'B';
357 } elseif ($score >= 60) {
358 return 'C';
359 } elseif ($score >= 50) {
360 return 'D';
361 } else {
362 return 'F';
363 }
364 }
365
366 /**
367 * Generate score interpretation
368 *
369 * @param float $score Overall score
370 * @param string $grade Letter grade
371 * @return string Score interpretation
372 */
373 private function generate_score_interpretation(float $score, string $grade): string {
374 switch ($grade) {
375 case 'A+':
376 return 'Excellent SEO performance with strong metrics across all areas.';
377 case 'A':
378 return 'Very good SEO performance with minor areas for improvement.';
379 case 'B':
380 return 'Good SEO performance with some optimization opportunities.';
381 case 'C':
382 return 'Fair SEO performance with several areas needing attention.';
383 case 'D':
384 return 'Poor SEO performance requiring significant improvements.';
385 case 'F':
386 return 'Critical SEO issues requiring immediate attention.';
387 default:
388 return sprintf('SEO performance score: %.1f/100', $score);
389 }
390 }
391
392 /**
393 * Generate score-based recommendations
394 *
395 * @param array $scores Component scores
396 * @return array Recommendations
397 */
398 private function generate_score_recommendations(array $scores): array {
399 $recommendations = [];
400
401 foreach ($scores as $component => $score) {
402 if ($score < 15) {
403 $recommendations[] = $this->get_component_recommendation($component, 'critical');
404 } elseif ($score < 20) {
405 $recommendations[] = $this->get_component_recommendation($component, 'high');
406 } elseif ($score < 22) {
407 $recommendations[] = $this->get_component_recommendation($component, 'medium');
408 }
409 }
410
411 return $recommendations;
412 }
413
414 /**
415 * Get component-specific recommendation
416 *
417 * @param string $component Component name
418 * @param string $priority Priority level
419 * @return array Recommendation
420 */
421 private function get_component_recommendation(string $component, string $priority): array {
422 $recommendations = [
423 'traffic_growth' => [
424 'critical' => 'Focus on organic traffic growth through content optimization and keyword targeting.',
425 'high' => 'Improve organic traffic share by optimizing existing content and building quality backlinks.',
426 'medium' => 'Continue growing organic traffic through consistent content creation and SEO optimization.'
427 ],
428 'keyword_performance' => [
429 'critical' => 'Urgent keyword optimization needed - focus on improving rankings for target keywords.',
430 'high' => 'Optimize keyword targeting and improve content relevance for better rankings.',
431 'medium' => 'Fine-tune keyword strategy and monitor ranking improvements.'
432 ],
433 'content_engagement' => [
434 'critical' => 'Critical engagement issues - review content quality and user experience immediately.',
435 'high' => 'Improve content engagement through better formatting, internal linking, and user experience.',
436 'medium' => 'Enhance content engagement with multimedia elements and improved readability.'
437 ],
438 'technical_performance' => [
439 'critical' => 'Critical technical issues affecting SEO - address Core Web Vitals and site speed immediately.',
440 'high' => 'Improve technical SEO by optimizing page speed and Core Web Vitals.',
441 'medium' => 'Fine-tune technical performance for better user experience and SEO.'
442 ]
443 ];
444
445 return [
446 'component' => $component,
447 'priority' => $priority,
448 'recommendation' => $recommendations[$component][$priority] ?? 'Optimize this component for better SEO performance.'
449 ];
450 }
451
452 /**
453 * Calculate keyword opportunity score
454 *
455 * @param int $clicks Current clicks
456 * @param int $impressions Current impressions
457 * @param float $position Current position
458 * @param float $ctr Current CTR
459 * @return float Opportunity score (0-100)
460 */
461 private function calculate_keyword_opportunity_score(int $clicks, int $impressions, float $position, float $ctr): float {
462 // No opportunity if no impressions
463 if ($impressions < 10) {
464 return 0;
465 }
466
467 $expected_ctr = $this->get_expected_ctr($position);
468 $ctr_gap = max(0, $expected_ctr - $ctr);
469
470 // Higher score for keywords with:
471 // 1. High impressions (more potential)
472 // 2. Position 4-10 (page 1 potential)
473 // 3. CTR below expected (optimization opportunity)
474
475 $impression_score = min(40, $impressions / 100); // Max 40 points for impressions
476 $position_score = $position <= 10 ? (11 - $position) * 3 : 0; // Max 30 points for position
477 $ctr_opportunity_score = min(30, $ctr_gap * 10); // Max 30 points for CTR gap
478
479 return min(100, $impression_score + $position_score + $ctr_opportunity_score);
480 }
481
482 /**
483 * Get expected CTR for position
484 *
485 * @param float $position Search position
486 * @return float Expected CTR percentage
487 */
488 private function get_expected_ctr(float $position): float {
489 $pos = (int) round($position);
490
491 if ($pos <= 10) {
492 return self::CTR_BENCHMARKS[$pos] ?? 1.0;
493 } elseif ($pos <= 20) {
494 return 1.0;
495 } else {
496 return 0.5;
497 }
498 }
499
500 /**
501 * Calculate potential impact of optimization
502 *
503 * @param int $impressions Current impressions
504 * @param float $position Current position
505 * @param float $current_ctr Current CTR
506 * @return array Potential impact analysis
507 */
508 private function calculate_potential_impact(int $impressions, float $position, float $current_ctr): array {
509 $expected_ctr = $this->get_expected_ctr($position);
510 $potential_additional_clicks = $impressions * (($expected_ctr - $current_ctr) / 100);
511
512 return [
513 'additional_clicks_potential' => max(0, round($potential_additional_clicks)),
514 'ctr_improvement_potential' => max(0, round($expected_ctr - $current_ctr, 2)),
515 'impact_level' => $potential_additional_clicks > 50 ? 'high' : ($potential_additional_clicks > 10 ? 'medium' : 'low')
516 ];
517 }
518
519 /**
520 * Score average position performance
521 *
522 * @param float $average_position Average search position
523 * @return float Position score
524 */
525 private function score_average_position(float $average_position): float {
526 if ($average_position <= 3) {
527 return 12.5; // Excellent - top 3 positions
528 } elseif ($average_position <= 10) {
529 return 10.0; // Good - page 1
530 } elseif ($average_position <= 20) {
531 return 7.5; // Fair - page 2
532 } else {
533 return 5.0; // Poor - beyond page 2
534 }
535 }
536
537 /**
538 * Score CTR performance
539 *
540 * @param float $ctr Click-through rate percentage
541 * @return float CTR score
542 */
543 private function score_ctr_performance(float $ctr): float {
544 if ($ctr >= 5.0) {
545 return 12.5; // Excellent CTR
546 } elseif ($ctr >= 3.0) {
547 return 10.0; // Good CTR
548 } elseif ($ctr >= 1.5) {
549 return 7.5; // Fair CTR
550 } else {
551 return 5.0; // Poor CTR
552 }
553 }
554
555 /**
556 * Score bounce rate performance
557 *
558 * @param float $bounce_rate Bounce rate percentage
559 * @return float Bounce rate score
560 */
561 private function score_bounce_rate(float $bounce_rate): float {
562 if ($bounce_rate <= 40) {
563 return 12.5; // Excellent engagement
564 } elseif ($bounce_rate <= 55) {
565 return 10.0; // Good engagement
566 } elseif ($bounce_rate <= 70) {
567 return 7.5; // Fair engagement
568 } else {
569 return 5.0; // Poor engagement
570 }
571 }
572
573 /**
574 * Score session duration performance
575 *
576 * @param float $duration Average session duration in seconds
577 * @return float Duration score
578 */
579 private function score_session_duration(float $duration): float {
580 if ($duration >= 180) { // 3+ minutes
581 return 12.5; // Excellent engagement
582 } elseif ($duration >= 120) { // 2+ minutes
583 return 10.0; // Good engagement
584 } elseif ($duration >= 60) { // 1+ minute
585 return 7.5; // Fair engagement
586 } else {
587 return 5.0; // Poor engagement
588 }
589 }
590
591 /**
592 * Score Largest Contentful Paint (LCP)
593 *
594 * @param float $lcp LCP value in seconds
595 * @return float LCP score
596 */
597 private function score_lcp(float $lcp): float {
598 if ($lcp <= 2.5) {
599 return 8.33; // Good
600 } elseif ($lcp <= 4.0) {
601 return 6.0; // Needs improvement
602 } else {
603 return 3.0; // Poor
604 }
605 }
606
607 /**
608 * Score First Input Delay (FID)
609 *
610 * @param float $fid FID value in milliseconds
611 * @return float FID score
612 */
613 private function score_fid(float $fid): float {
614 if ($fid <= 100) {
615 return 8.33; // Good
616 } elseif ($fid <= 300) {
617 return 6.0; // Needs improvement
618 } else {
619 return 3.0; // Poor
620 }
621 }
622
623 /**
624 * Score Cumulative Layout Shift (CLS)
625 *
626 * @param float $cls CLS value
627 * @return float CLS score
628 */
629 private function score_cls(float $cls): float {
630 if ($cls <= 0.1) {
631 return 8.33; // Good
632 } elseif ($cls <= 0.25) {
633 return 6.0; // Needs improvement
634 } else {
635 return 3.0; // Poor
636 }
637 }
638
639 /**
640 * Score traffic contribution for a page
641 *
642 * @param array $page_data Page data
643 * @return float Traffic contribution score
644 */
645 private function score_traffic_contribution(array $page_data): float {
646 $pageviews = $page_data['pageviews'] ?? 0;
647
648 if ($pageviews >= 1000) {
649 return 25.0; // High traffic page
650 } elseif ($pageviews >= 500) {
651 return 20.0; // Medium-high traffic
652 } elseif ($pageviews >= 100) {
653 return 15.0; // Medium traffic
654 } else {
655 return 10.0; // Low traffic
656 }
657 }
658
659 /**
660 * Score search visibility for a page
661 *
662 * @param array $page_data Page data
663 * @return float Search visibility score
664 */
665 private function score_search_visibility(array $page_data): float {
666 // This would be enhanced with actual search console data for the specific page
667 // For now, return a default score
668 return 15.0;
669 }
670
671 /**
672 * Score engagement quality for a page
673 *
674 * @param array $page_data Page data
675 * @return float Engagement quality score
676 */
677 private function score_engagement_quality(array $page_data): float {
678 $sessions = $page_data['sessions'] ?? 0;
679 $pageviews = $page_data['pageviews'] ?? 0;
680
681 $pages_per_session = $sessions > 0 ? $pageviews / $sessions : 1;
682
683 if ($pages_per_session >= 3.0) {
684 return 25.0; // Excellent engagement
685 } elseif ($pages_per_session >= 2.0) {
686 return 20.0; // Good engagement
687 } elseif ($pages_per_session >= 1.5) {
688 return 15.0; // Fair engagement
689 } else {
690 return 10.0; // Poor engagement
691 }
692 }
693
694 /**
695 * Score technical health for a page
696 *
697 * @param array $page_data Page data
698 * @return float Technical health score
699 */
700 private function score_technical_health(array $page_data): float {
701 // This would be enhanced with actual Core Web Vitals data for the specific page
702 // For now, return a default score
703 return 20.0;
704 }
705
706 /**
707 * Generate page-specific recommendations
708 *
709 * @param array $scores Component scores
710 * @param array $page_data Page data
711 * @return array Page recommendations
712 */
713 private function generate_page_recommendations(array $scores, array $page_data): array {
714 $recommendations = [];
715
716 if ($scores['traffic_contribution'] < 15) {
717 $recommendations[] = 'Improve content quality and SEO optimization to increase traffic';
718 }
719
720 if ($scores['engagement_quality'] < 15) {
721 $recommendations[] = 'Enhance content engagement with better formatting and internal links';
722 }
723
724 if ($scores['technical_health'] < 15) {
725 $recommendations[] = 'Optimize page speed and Core Web Vitals performance';
726 }
727
728 return $recommendations;
729 }
730
731 /**
732 * Determine impact level for priority matrix
733 *
734 * @param array $item Item to evaluate
735 * @return string Impact level (high/low)
736 */
737 private function determine_impact_level(array $item): string {
738 $score = $item['opportunity_score'] ?? 0;
739 return $score >= 50 ? 'high' : 'low';
740 }
741
742 /**
743 * Determine effort level for priority matrix
744 *
745 * @param array $item Item to evaluate
746 * @return string Effort level (high/low)
747 */
748 private function determine_effort_level(array $item): string {
749 $position = $item['current_position'] ?? 100;
750 // Assume lower positions require more effort to improve
751 return $position <= 20 ? 'low' : 'high';
752 }
753 }
754