PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.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 1.1.0 All 49 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.29.0, at includes/ai/class-seo-score-calculator.php

1,983 lines 75.8 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 * Supports multiple focus keywords: when `$options['target_keywords']` holds
79 * more than one keyword the score is computed independently for each and the
80 * HIGHEST overall score is returned as the final result, with per-keyword
81 * results (`keyword_results`) and OR-combined per-check matches
82 * (`keyword_checks`) attached. A single keyword (or the legacy
83 * `target_keyword` option) falls through to the single-keyword path.
84 *
85 * @param array $content_data Content analysis data
86 * @param array $metadata Post metadata
87 * @param array $options Additional options
88 * @return array Complete scoring result
89 */
90 public function calculate_score(array $content_data, array $metadata, array $options = []): array {
91 $keywords = $this->resolve_target_keywords($options, $metadata);
92
93 if (count($keywords) > 1) {
94 return $this->calculate_score_multi($content_data, $metadata, $keywords, $options);
95 }
96
97 $options['target_keyword'] = $keywords[0] ?? '';
98 $result = $this->compute_score($content_data, $metadata, $options);
99
100 // Expose the keyword surface uniformly so consumers can rely on it
101 // regardless of how many keywords were supplied.
102 $result['target_keywords'] = $keywords;
103 if (!empty($keywords)) {
104 $result['keyword_results'] = [[
105 'keyword' => $keywords[0],
106 'overall_score' => $result['overall_score'],
107 'grade' => $result['grade'],
108 ]];
109 $result['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
110 }
111
112 return $result;
113 }
114
115 /**
116 * Resolve the target keyword list from the options array, falling back to
117 * the focus keywords carried on the post's metadata.
118 *
119 * Accepts `target_keywords` (array) or the legacy `target_keyword` (string),
120 * trims, drops empties and removes case-insensitive duplicates.
121 *
122 * Options win over metadata on purpose: the editor scores unsaved keyword
123 * edits by passing them explicitly, and that live value must beat whatever
124 * is currently persisted. The metadata fallback applies only when the
125 * caller mentions no keyword option AT ALL — a caller that passes an empty
126 * keyword is deliberately clearing it (the editor does exactly this when
127 * the field is emptied), so the stored value must not resurrect it.
128 *
129 * Without the fallback, every caller that hands over
130 * `Metabox_Manager::get_post_metadata()` (the metabox and the MCP scoring
131 * abilities) silently scored as if no keyword were set.
132 *
133 * @param array $options Scoring options.
134 * @param array $metadata Post metadata (may carry focus_keyword(s)).
135 * @return string[] Normalized keyword list.
136 */
137 private function resolve_target_keywords(array $options, array $metadata = []): array {
138 $raw = [];
139 if (!empty($options['target_keywords']) && is_array($options['target_keywords'])) {
140 $raw = $options['target_keywords'];
141 } elseif (isset($options['target_keyword']) && $options['target_keyword'] !== '') {
142 $raw = [$options['target_keyword']];
143 } elseif (!$this->options_mention_keywords($options)) {
144 if (!empty($metadata['focus_keywords']) && is_array($metadata['focus_keywords'])) {
145 $raw = $metadata['focus_keywords'];
146 } elseif (isset($metadata['focus_keyword']) && is_string($metadata['focus_keyword']) && $metadata['focus_keyword'] !== '') {
147 $raw = [$metadata['focus_keyword']];
148 }
149 }
150
151 $seen = [];
152 $keywords = [];
153 foreach ($raw as $keyword) {
154 $keyword = trim((string) $keyword);
155 if ($keyword === '') {
156 continue;
157 }
158 $key = strtolower($keyword);
159 if (isset($seen[$key])) {
160 continue;
161 }
162 $seen[$key] = true;
163 $keywords[] = $keyword;
164 }
165
166 return $keywords;
167 }
168
169 /**
170 * Whether the caller said anything about keywords — including saying
171 * "none". Distinguishes an intentional clear (score with no keyword) from
172 * silence (fall back to the post's stored focus keywords).
173 *
174 * @param array $options Scoring options.
175 * @return bool True when a keyword option key is present.
176 */
177 private function options_mention_keywords(array $options): bool {
178 return array_key_exists('target_keywords', $options)
179 || array_key_exists('target_keyword', $options);
180 }
181
182 /**
183 * Score each keyword independently and return the highest-scoring result.
184 *
185 * @param array $content_data Content analysis data.
186 * @param array $metadata Post metadata.
187 * @param string[] $keywords Target keywords (already normalized, 2+).
188 * @param array $options Additional options.
189 * @return array Best scoring result, with per-keyword data attached.
190 */
191 private function calculate_score_multi(array $content_data, array $metadata, array $keywords, array $options): array {
192 $per_keyword = [];
193 $best = null;
194 $best_keyword = $keywords[0];
195
196 foreach ($keywords as $keyword) {
197 $opts = $options;
198 unset($opts['target_keywords']);
199 $opts['target_keyword'] = $keyword;
200
201 $result = $this->compute_score($content_data, $metadata, $opts);
202
203 $per_keyword[] = [
204 'keyword' => $keyword,
205 'overall_score' => $result['overall_score'],
206 'grade' => $result['grade'],
207 'score_breakdown' => $result['score_breakdown'],
208 ];
209
210 if ($best === null || $result['overall_score'] > $best['overall_score']) {
211 $best = $result;
212 $best_keyword = $keyword;
213 }
214 }
215
216 // Final score = highest individual keyword score. Retain per-keyword
217 // results and OR-combined checks so the UI can show both.
218 $best['target_keyword'] = $best_keyword;
219 $best['target_keywords'] = $keywords;
220 $best['keyword_results'] = $per_keyword;
221 $best['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
222
223 return $best;
224 }
225
226 /**
227 * Evaluate per-location keyword checks across ALL focus keywords.
228 *
229 * Each check (title, meta description, content, image alt, slug) passes when
230 * ANY of the focus keywords matches that location.
231 *
232 * @param array $content_data Content analysis data.
233 * @param array $metadata Post metadata.
234 * @param string[] $keywords Target keywords.
235 * @return array<string,array{passed:bool,matched_keywords:string[]}>
236 */
237 private function analyze_keyword_checks(array $content_data, array $metadata, array $keywords): array {
238 $title = strtolower((string) ($metadata['title'] ?? $content_data['title'] ?? ''));
239 $description = strtolower((string) ($metadata['description'] ?? ''));
240 $content = strtolower(wp_strip_all_tags((string) ($content_data['content'] ?? '')));
241
242 $alts = '';
243 foreach ((array) ($content_data['images'] ?? []) as $image) {
244 $alts .= ' ' . strtolower((string) ($image['alt'] ?? ''));
245 }
246
247 // Build a searchable slug haystack from the URL path (hyphens/underscores
248 // become spaces so multi-word keywords can match).
249 $path = (string) (wp_parse_url((string) ($content_data['url'] ?? ''), PHP_URL_PATH) ?? '');
250 $slug = strtolower(str_replace(['-', '_', '/'], ' ', trim($path, '/')));
251
252 $haystacks = [
253 'title' => trim($title),
254 'meta_description' => trim($description),
255 'content' => trim($content),
256 'image_alt' => trim($alts),
257 'slug' => trim($slug),
258 ];
259
260 $checks = [];
261 foreach ($haystacks as $location => $haystack) {
262 $matched = [];
263 foreach ($keywords as $keyword) {
264 $needle = strtolower(trim($keyword));
265 if ($needle !== '' && $haystack !== '' && strpos($haystack, $needle) !== false) {
266 $matched[] = $keyword;
267 }
268 }
269 $checks[$location] = [
270 'passed' => !empty($matched),
271 'matched_keywords' => $matched,
272 ];
273 }
274
275 return $checks;
276 }
277
278 /**
279 * Compute the SEO score for a single target keyword.
280 *
281 * @param array $content_data Content analysis data
282 * @param array $metadata Post metadata
283 * @param array $options Additional options (expects scalar target_keyword)
284 * @return array Complete scoring result
285 *
286 * @throws \Exception On failure.
287 */
288 private function compute_score(array $content_data, array $metadata, array $options = []): array {
289 $scores = [];
290 $suggestions = [];
291 $total_score = 0;
292
293 try {
294 // 1. Satisfying Content (23 points) - #1 factor in 2025
295 $satisfying_result = $this->score_satisfying_content($content_data, $options['target_keyword'] ?? '', $metadata);
296 $scores['satisfying_content'] = $satisfying_result;
297 $total_score += $satisfying_result['score'];
298 $suggestions = array_merge($suggestions, $satisfying_result['suggestions']);
299 } catch (\Exception $e) {
300 throw $e;
301 }
302
303 try {
304 // 2. Title Optimization (14 points) - Looser keyword matching in 2025
305 $title_result = $this->score_2025_title_optimization($metadata['title'] ?? '', $options['target_keyword'] ?? '');
306 $scores['title_optimization'] = $title_result;
307 $total_score += $title_result['score'];
308 $suggestions = array_merge($suggestions, $title_result['suggestions']);
309 } catch (\Exception $e) {
310 throw $e;
311 }
312
313 try {
314 // 3. Niche Expertise (13 points) - Hub & spoke content clusters
315 $expertise_result = $this->score_niche_expertise($content_data, $options['target_keyword'] ?? '');
316 $scores['niche_expertise'] = $expertise_result;
317 $total_score += $expertise_result['score'];
318 $suggestions = array_merge($suggestions, $expertise_result['suggestions']);
319 } catch (\Exception $e) {
320 throw $e;
321 }
322
323 try {
324 // 4. Searcher Engagement (12 points) - Dwell time, bounce rate, pages/session
325 $engagement_result = $this->score_searcher_engagement($content_data);
326 $scores['searcher_engagement'] = $engagement_result;
327 $total_score += $engagement_result['score'];
328 $suggestions = array_merge($suggestions, $engagement_result['suggestions']);
329 } catch (\Exception $e) {
330 throw $e;
331 }
332
333 try {
334 // 5. Backlink Authority (13 points) - Quality backlinks
335 $backlink_result = $this->score_backlink_authority($content_data);
336 $scores['backlink_authority'] = $backlink_result;
337 $total_score += $backlink_result['score'];
338 $suggestions = array_merge($suggestions, $backlink_result['suggestions']);
339 } catch (\Exception $e) {
340 throw $e;
341 }
342
343 // 6. Content Freshness (6 points) - Quarterly updates priority
344 $freshness_result = $this->score_content_freshness($content_data);
345 $scores['content_freshness'] = $freshness_result;
346 $total_score += $freshness_result['score'];
347 $suggestions = array_merge($suggestions, $freshness_result['suggestions']);
348
349 // 7. Mobile Experience (5 points) - NEW: Mobile Experience Score (MES)
350 $mobile_result = $this->score_mobile_experience($content_data);
351 $scores['mobile_experience'] = $mobile_result;
352 $total_score += $mobile_result['score'];
353 $suggestions = array_merge($suggestions, $mobile_result['suggestions']);
354
355 // 8. Trustworthiness (4 points) - E-E-A-T verification
356 $trust_result = $this->score_trustworthiness($content_data, $metadata);
357 $scores['trustworthiness'] = $trust_result;
358 $total_score += $trust_result['score'];
359 $suggestions = array_merge($suggestions, $trust_result['suggestions']);
360
361 // 9. Link Diversity (3 points) - Multiple pages with backlinks
362 $diversity_result = $this->score_link_diversity($content_data);
363 $scores['link_diversity'] = $diversity_result;
364 $total_score += $diversity_result['score'];
365 $suggestions = array_merge($suggestions, $diversity_result['suggestions']);
366
367 // 10. Core Web Vitals (3 points) - Interaction Readiness + CLS 2.0
368 $vitals_result = $this->score_core_web_vitals($content_data);
369 $scores['core_web_vitals'] = $vitals_result;
370 $total_score += $vitals_result['score'];
371 $suggestions = array_merge($suggestions, $vitals_result['suggestions']);
372
373 // 11. Site Security (2 points) - SSL certificate
374 $security_result = $this->score_site_security($content_data);
375 $scores['site_security'] = $security_result;
376 $total_score += $security_result['score'];
377 $suggestions = array_merge($suggestions, $security_result['suggestions']);
378
379 // 12. Internal Linking (1 point) - Declining importance
380 $internal_result = $this->score_internal_linking($content_data);
381 $scores['internal_linking'] = $internal_result;
382 $total_score += $internal_result['score'];
383 $suggestions = array_merge($suggestions, $internal_result['suggestions']);
384
385 // 13. Technical Factors (1 point) - Meta descriptions, schema, etc.
386 $technical_result = $this->score_technical_factors($content_data, $metadata, $options);
387 $scores['technical_factors'] = $technical_result;
388 $total_score += $technical_result['score'];
389 $suggestions = array_merge($suggestions, $technical_result['suggestions']);
390
391 try {
392 $prioritized_suggestions = $this->prioritize_suggestions($suggestions);
393 $grade = $this->get_grade_from_score($total_score);
394
395 return [
396 'overall_score' => min(100, $total_score),
397 'score_breakdown' => $scores,
398 'suggestions' => $prioritized_suggestions,
399 'grade' => $grade,
400 // Readability + content-quality labels so persisted scores
401 // (e.g. bulk-analyzed on import) populate the post-list columns
402 // without a manual re-analyze. The REST endpoint still overrides
403 // these with live-editor values when the metabox provides them.
404 'readability_score' => $this->format_readability_label($content_data),
405 'content_quality' => $this->derive_content_quality($content_data),
406 'calculated_at' => current_time('mysql'),
407 'algorithm_version' => '2025.2',
408 'algorithm_source' => 'First Page Sage Q1 2025 Research',
409 'factors_count' => count($scores),
410 ];
411 } catch (\Exception $e) {
412 throw $e;
413 }
414 }
415
416 /**
417 * Score satisfying content - #1 factor in 2025 (23 points)
418 * Google tests content to see if it satisfies search intent
419 *
420 * @param array $content_data Content analysis data
421 * @param string $target_keyword Target keyword
422 * @param array $metadata Post metadata (title, description)
423 * @return array Scoring result
424 */
425 private function score_satisfying_content(array $content_data, string $target_keyword, array $metadata = []): array {
426 $score = 0;
427 $max_score = $this->scoring_factors['satisfying_content'];
428 $suggestions = [];
429
430 $content = $content_data['content'] ?? '';
431 $word_count = $content_data['word_count'] ?? 0;
432 $meta_description = (string) ($metadata['description'] ?? '');
433
434 // Content depth and comprehensiveness (8 points). Tiers softened so a
435 // genuinely useful post is not capped the way the old 2000-word gate did
436 // (Rank Math awards full content credit well below 2000 words).
437 if ($word_count >= 1500) {
438 $score += 8;
439 } elseif ($word_count >= 1000) {
440 $score += 7;
441 $suggestions[] = 'Consider expanding content to 1500+ words for more comprehensive coverage';
442 } elseif ($word_count >= 600) {
443 $score += 5;
444 $suggestions[] = 'Content is adequate - aim for 1000+ words for stronger topic depth';
445 } elseif ($word_count >= 300) {
446 $score += 3;
447 $suggestions[] = 'Content is thin - aim for 600+ words minimum';
448 } else {
449 $score++;
450 $suggestions[] = 'Content too shallow - Google prioritizes comprehensive, satisfying content';
451 }
452
453 // Keyword presence & placement (8 points) - deterministic, replaces the
454 // old literal-phrase intent heuristic ("what is"/"because"). Measures
455 // signals the editor actually controls: body (3), first paragraph (3),
456 // meta description (2) - the last mirrors Rank Math's "keyword in meta
457 // description" basic-SEO check.
458 if (empty($target_keyword)) {
459 $score += 4; // Benefit of the doubt when no focus keyword is set.
460 $suggestions[] = 'Set a focus keyword so content relevance can be measured';
461 } else {
462 if ($this->keyword_in_content($content, $target_keyword)) {
463 $score += 3;
464 } else {
465 $suggestions[] = "Use the focus keyword '{$target_keyword}' in the body content";
466 }
467 if ($this->keyword_in_first_paragraph($content, $target_keyword)) {
468 $score += 3;
469 } else {
470 $suggestions[] = "Mention '{$target_keyword}' near the start of the content (first paragraph)";
471 }
472 if ($this->keyword_in_meta($meta_description, $target_keyword)) {
473 $score += 2;
474 } else {
475 $suggestions[] = "Include the focus keyword '{$target_keyword}' in the meta description";
476 }
477 }
478
479 // Content structure & value (7 points) - reuses the deterministic
480 // content-quality signal (length, paragraph length, subheading
481 // distribution) instead of the noisy sentence-length variety heuristic.
482 $quality = $this->derive_content_quality_score($content_data); // 0-100
483 $score += (int) round(($quality / 100) * 7);
484
485 if ($quality < 60) {
486 $suggestions[] = 'Improve content structure - break up long paragraphs and add subheadings';
487 }
488
489 return [
490 'score' => $score,
491 'max_score' => $max_score,
492 'suggestions' => $suggestions,
493 'details' => [
494 'word_count' => $word_count,
495 'keyword_in_content' => !empty($target_keyword) && $this->keyword_in_content($content, $target_keyword),
496 'keyword_in_first_paragraph' => !empty($target_keyword) && $this->keyword_in_first_paragraph($content, $target_keyword),
497 'keyword_in_meta_description' => !empty($target_keyword) && $this->keyword_in_meta($meta_description, $target_keyword),
498 'content_quality_score' => $quality,
499 'content_depth' => $this->assess_content_depth_2025($word_count),
500 ]
501 ];
502 }
503
504 /**
505 * Assess content depth for 2025 standards
506 *
507 * @param int $word_count Word count
508 * @return string Depth assessment
509 */
510 private function assess_content_depth_2025(int $word_count): string {
511 if ($word_count >= 3000) { return 'Comprehensive';
512 }
513 if ($word_count >= 2000) { return 'Detailed';
514 }
515 if ($word_count >= 1200) { return 'Adequate';
516 }
517 if ($word_count >= 800) { return 'Basic';
518 }
519 return 'Insufficient';
520 }
521
522 /**
523 * Score 2025 title optimization with looser keyword matching
524 *
525 * @param string $title Post title
526 * @param string $target_keyword Target keyword
527 * @return array Scoring result
528 */
529 private function score_2025_title_optimization(string $title, string $target_keyword): array {
530 $score = 0;
531 $max_score = $this->scoring_factors['title_optimization'];
532 $suggestions = [];
533
534 if (empty($title)) {
535 $suggestions[] = 'Add a compelling, click-worthy title that matches search intent';
536 return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
537 }
538
539 $title_length = mb_strlen($title);
540
541 // 2025 length optimization (6 points). 60 characters is the recommended
542 // maximum for best SERP visibility before Google truncates the title.
543 if ($title_length >= 35 && $title_length <= 60) {
544 $score += 6;
545 } elseif ($title_length >= 25 && $title_length <= 75) {
546 $score += 4;
547 $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility';
548 } else {
549 $score++;
550 $suggestions[] = $title_length < 25 ?
551 'Title too short - aim for 35-60 characters' :
552 'Title too long - risk truncation in search results';
553 }
554
555 // Looser keyword matching (6 points) - 2025 update
556 if (!empty($target_keyword)) {
557 $title_lower = strtolower($title);
558 $keyword_lower = strtolower($target_keyword);
559
560 // Exact match
561 if (strpos($title_lower, $keyword_lower) !== false) {
562 $score += 6;
563 } else {
564 // Check for semantic variations (2025 improvement)
565 $semantic_match = $this->check_semantic_keyword_match($title, $target_keyword);
566 if ($semantic_match) {
567 $score += 5; // Almost full credit for semantic match
568 $suggestions[] = 'Good semantic keyword usage - Google now recognizes keyword variations';
569 } else {
570 // Check for partial keyword match
571 $keyword_parts = explode(' ', $keyword_lower);
572 $partial_matches = 0;
573 foreach ($keyword_parts as $part) {
574 if (strpos($title_lower, $part) !== false) {
575 $partial_matches++;
576 }
577 }
578
579 if ($partial_matches > 0) {
580 $score += round(($partial_matches / count($keyword_parts)) * 4);
581 $suggestions[] = "Include more parts of target keyword '{$target_keyword}' in title";
582 } else {
583 $suggestions[] = "Include target keyword '{$target_keyword}' or related terms in title";
584 }
585 }
586 }
587 } else {
588 $score += 2; // Partial credit
589 $suggestions[] = 'Set a target keyword to optimize title effectiveness';
590 }
591
592 // Title readability (2 points) - mirrors Rank Math's title checks for a
593 // number/power word (drives CTR) and emotional sentiment.
594 $has_number = (bool) preg_match('/\d/', $title);
595 $has_power_word = $this->title_has_power_word($title);
596 $has_sentiment = $this->title_has_sentiment_word($title);
597
598 if ($has_number || $has_power_word) {
599 $score++;
600 } else {
601 $suggestions[] = 'Add a number or a power word to the title to boost click-through rate';
602 }
603 if ($has_sentiment) {
604 $score++;
605 } else {
606 $suggestions[] = 'Use an emotional/sentiment word in the title to make it more compelling';
607 }
608
609 return [
610 'score' => $score,
611 'max_score' => $max_score,
612 'suggestions' => $suggestions,
613 'details' => [
614 'title_length' => $title_length,
615 'optimal_range' => '35-60 characters',
616 'keyword_present' => !empty($target_keyword) && strpos(strtolower($title), strtolower($target_keyword)) !== false,
617 'semantic_match' => !empty($target_keyword) ? $this->check_semantic_keyword_match($title, $target_keyword) : false,
618 'has_number_or_power_word' => $has_number || $has_power_word,
619 'has_sentiment_word' => $has_sentiment,
620 ]
621 ];
622 }
623
624 // Placeholder methods for remaining 2025 factors
625
626 private function score_niche_expertise(array $content_data, string $target_keyword): array {
627 $max_score = $this->scoring_factors['niche_expertise'];
628 $suggestions = [];
629
630 // Without a focus keyword we cannot measure topical coverage; award
631 // partial credit rather than capping the ceiling with a placeholder.
632 if (empty($target_keyword)) {
633 return [
634 'score' => 7,
635 'max_score' => $max_score,
636 'suggestions' => ['Set a focus keyword and use it in subheadings and the URL for stronger topical signals'],
637 'details' => ['expertise_level' => 'Unmeasured (no focus keyword)'],
638 ];
639 }
640
641 $score = 0;
642 $content = (string) ($content_data['content'] ?? '');
643 $headings = (array) ($content_data['headings'] ?? []);
644 $images = (array) ($content_data['images'] ?? []);
645 $slug = strtolower((string) ($content_data['slug'] ?? ''));
646
647 // Keyword in a subheading (4 points).
648 if ($this->keyword_in_subheadings($headings, $target_keyword)) {
649 $score += 4;
650 } else {
651 $suggestions[] = "Include '{$target_keyword}' in at least one subheading (H2-H6)";
652 }
653
654 // Keyword density in a healthy band (4 points). Rank Math treats
655 // ~0.5%-2.5% as optimal; reward in-band, partial when present but thin.
656 $density = $this->keyword_density($content, $target_keyword);
657 if ($density >= 0.5 && $density <= 2.5) {
658 $score += 4;
659 } elseif ($density > 0) {
660 $score += 2;
661 $suggestions[] = $density > 2.5
662 ? 'Keyword density is high - reduce repetition to avoid over-optimization'
663 : 'Keyword density is low - use the focus keyword a little more often';
664 } else {
665 $suggestions[] = "Use the focus keyword '{$target_keyword}' in the content";
666 }
667
668 // URL optimization (3 points): keyword in slug (2) + a reasonably short
669 // URL (1). Rank Math flags overly long URLs, so reward concise slugs.
670 $keyword_slug = str_replace(' ', '-', strtolower($target_keyword));
671 $keyword_in_slug = $slug !== '' && (strpos($slug, $keyword_slug) !== false || strpos(str_replace('-', '', $slug), str_replace('-', '', $keyword_slug)) !== false);
672 if ($keyword_in_slug) {
673 $score += 2;
674 } else {
675 $suggestions[] = 'Include the focus keyword in the URL slug';
676 }
677
678 // A slug under ~75 chars keeps the URL clean and fully visible in SERPs.
679 $slug_length = strlen($slug);
680 if ($slug === '' || $slug_length <= 75) {
681 $score++;
682 } else {
683 $suggestions[] = 'Shorten the URL slug - long URLs are harder to read and share';
684 }
685
686 // Keyword in image alt text (2 points) - mirrors Rank Math's
687 // "keyword in image alt" check. When the post has no images the check
688 // does not apply, so award the points (benefit of the doubt) rather
689 // than capping the ceiling for legitimately image-less posts.
690 if (empty($images)) {
691 $score += 2;
692 $suggestions[] = 'Add a relevant image with the focus keyword in its alt text';
693 } elseif ($this->keyword_in_alt($images, $target_keyword)) {
694 $score += 2;
695 } else {
696 $suggestions[] = 'Include the focus keyword in at least one image alt attribute';
697 }
698
699 return [
700 'score' => $score,
701 'max_score' => $max_score,
702 'suggestions' => $suggestions,
703 'details' => [
704 'keyword_in_subheading' => $this->keyword_in_subheadings($headings, $target_keyword),
705 'keyword_density' => round($density, 2),
706 'keyword_in_slug' => $keyword_in_slug,
707 'slug_length' => $slug_length,
708 'keyword_in_image_alt' => $this->keyword_in_alt($images, $target_keyword),
709 ],
710 ];
711 }
712
713 private function score_searcher_engagement(array $content_data): array {
714 $max_score = $this->scoring_factors['searcher_engagement'];
715
716 // Engagement (dwell time / bounce) is off-page, so estimate it from the
717 // on-page signals that drive it: readability (half) + content structure
718 // (half). This raises the old readability-only floor.
719 $readability = (float) ($content_data['readability_score'] ?? 50);
720 $structure = (float) $this->derive_content_quality_score($content_data);
721
722 $readability_pts = ($readability / 100) * ($max_score / 2);
723 $structure_pts = ($structure / 100) * ($max_score / 2);
724 $score = (int) round($readability_pts + $structure_pts);
725
726 return [
727 'score' => $score,
728 'max_score' => $max_score,
729 'suggestions' => $score < ($max_score * 0.7)
730 ? ['Improve readability and structure (shorter sentences, subheadings, shorter paragraphs)']
731 : [],
732 'details' => ['engagement_estimate' => round(($score / $max_score) * 100, 1) . '%'],
733 ];
734 }
735
736 private function score_backlink_authority(array $content_data): array {
737 $max_score = $this->scoring_factors['backlink_authority'];
738 $external_links = (int) ($content_data['external_links'] ?? 0);
739 $dofollow_links = (int) ($content_data['external_dofollow_links'] ?? 0);
740
741 // Backlinks are off-page and cannot be read from post content. We use
742 // the only on-page proxy available - whether the content cites external
743 // sources - and avoid hard-penalizing posts for something outside the
744 // editor's control (the old external_links*3 formula needed 5 outbound
745 // links just to reach full marks, dragging nearly every post down).
746 // Dofollow links pass equity, so they earn full credit (Rank Math's
747 // "external dofollow link" check); nofollow-only citations earn less.
748 $suggestions = [];
749 if ($dofollow_links >= 2) {
750 $score = $max_score;
751 } elseif ($dofollow_links === 1) {
752 $score = (int) round($max_score * 0.85);
753 } elseif ($external_links > 0) {
754 // Cites sources but every external link is nofollow.
755 $score = (int) round($max_score * 0.75);
756 $suggestions[] = 'Add at least one dofollow link to an authoritative external source';
757 } else {
758 $score = (int) round($max_score * 0.6);
759 $suggestions[] = 'Cite authoritative external sources, and build quality backlinks to this page';
760 }
761
762 return [
763 'score' => $score,
764 'max_score' => $max_score,
765 'suggestions' => $suggestions,
766 'details' => [
767 'external_links' => $external_links,
768 'external_dofollow_links' => $dofollow_links,
769 ],
770 ];
771 }
772 private function score_trustworthiness(array $content_data, array $metadata): array {
773 // E-E-A-T is an off-page/site-wide signal we cannot reliably measure
774 // from a single post. Award full credit (benefit of the doubt) rather
775 // than a fixed partial that silently caps every post's ceiling.
776 return [
777 'score' => $this->scoring_factors['trustworthiness'],
778 'max_score' => $this->scoring_factors['trustworthiness'],
779 'suggestions' => ['Add author credentials, citations, and contact information to reinforce trustworthiness'],
780 'details' => ['trust_level' => 'Assumed adequate'],
781 ];
782 }
783
784 private function score_link_diversity(array $content_data): array {
785 // Off-page link distribution; not measurable per post. Full credit.
786 return [
787 'score' => $this->scoring_factors['link_diversity'],
788 'max_score' => $this->scoring_factors['link_diversity'],
789 'suggestions' => [],
790 'details' => ['diversity_level' => 'Assumed adequate'],
791 ];
792 }
793 private function score_site_security(array $content_data): array {
794 $max_score = $this->scoring_factors['site_security'];
795 $ssl = function_exists('is_ssl') ? is_ssl() : true;
796
797 return [
798 'score' => $ssl ? $max_score : 0,
799 'max_score' => $max_score,
800 'suggestions' => $ssl ? [] : ['Serve the site over HTTPS (install an SSL certificate)'],
801 'details' => ['ssl_enabled' => $ssl, 'security_level' => $ssl ? 'Good' : 'Insecure'],
802 ];
803 }
804 private function check_semantic_keyword_match(string $text, string $keyword): bool {
805 // Simple semantic matching - can be enhanced with AI/NLP
806 $keyword_parts = explode(' ', strtolower($keyword));
807 $text_lower = strtolower($text);
808
809 $matches = 0;
810 foreach ($keyword_parts as $part) {
811 if (strpos($text_lower, $part) !== false) {
812 $matches++;
813 }
814 }
815
816 // Consider it a semantic match if 70% of keyword parts are present
817 return ($matches / count($keyword_parts)) >= 0.7;
818 }
819 private function prioritize_suggestions(array $suggestions): array {
820 $prioritized = [];
821
822 foreach ($suggestions as $suggestion) {
823 $priority = $this->determine_suggestion_priority($suggestion);
824 $prioritized[] = [
825 'text' => $suggestion,
826 'priority' => $priority,
827 'impact' => $this->estimate_impact($suggestion),
828 'effort' => $this->estimate_effort($suggestion),
829 ];
830 }
831
832 // Sort by priority (High > Medium > Low)
833 usort($prioritized, function($a, $b) {
834 $priority_order = ['High' => 3, 'Medium' => 2, 'Low' => 1];
835 return $priority_order[$b['priority']] - $priority_order[$a['priority']];
836 });
837
838 return $prioritized;
839 }
840
841 /**
842 * Determine suggestion priority based on content
843 *
844 * @param string $suggestion Suggestion text
845 * @return string Priority level
846 */
847 private function determine_suggestion_priority(string $suggestion): string {
848 $high_priority_keywords = ['title', 'keyword', 'content quality', 'heading'];
849 $medium_priority_keywords = ['meta description', 'internal link', 'readability'];
850
851 $suggestion_lower = strtolower($suggestion);
852
853 foreach ($high_priority_keywords as $keyword) {
854 if (strpos($suggestion_lower, $keyword) !== false) {
855 return 'High';
856 }
857 }
858
859 foreach ($medium_priority_keywords as $keyword) {
860 if (strpos($suggestion_lower, $keyword) !== false) {
861 return 'Medium';
862 }
863 }
864
865 return 'Low';
866 }
867
868 /**
869 * Estimate impact of implementing suggestion
870 *
871 * @param string $suggestion Suggestion text
872 * @return string Impact level
873 */
874 private function estimate_impact(string $suggestion): string {
875 // Simple heuristic - can be enhanced with ML
876 if (strpos(strtolower($suggestion), 'title') !== false) { return 'High';
877 }
878 if (strpos(strtolower($suggestion), 'content') !== false) { return 'High';
879 }
880 if (strpos(strtolower($suggestion), 'keyword') !== false) { return 'Medium';
881 }
882 return 'Low';
883 }
884
885 /**
886 * Estimate effort required to implement suggestion
887 *
888 * @param string $suggestion Suggestion text
889 * @return string Effort level
890 */
891 private function estimate_effort(string $suggestion): string {
892 // Simple heuristic - can be enhanced with ML
893 if (strpos(strtolower($suggestion), 'rewrite') !== false) { return 'High';
894 }
895 if (strpos(strtolower($suggestion), 'add') !== false) { return 'Medium';
896 }
897 if (strpos(strtolower($suggestion), 'optimize') !== false) { return 'Medium';
898 }
899 return 'Low';
900 }
901 private function calculate_topic_relevance(string $content, string $target_keyword): float {
902 if (empty($content) || empty($target_keyword)) {
903 return 0.0;
904 }
905
906 $content_lower = strtolower(wp_strip_all_tags($content));
907 $keyword_lower = strtolower($target_keyword);
908
909 // Calculate keyword and semantic term frequency
910 $keyword_count = substr_count($content_lower, $keyword_lower);
911 $word_count = $this->calculate_word_count_js_style($content_lower);
912
913 if ($word_count === 0) {
914 return 0.0;
915 }
916
917 // Base relevance from keyword presence
918 $keyword_density = ($keyword_count / $word_count) * 100;
919 $base_relevance = min(1.0, $keyword_density / 2.0); // Optimal around 1-2%
920
921 // Boost for semantic variations
922 $semantic_boost = $this->calculate_semantic_boost($content_lower, $keyword_lower);
923
924 return min(1.0, $base_relevance + $semantic_boost);
925 }
926
927 /**
928 * Calculate semantic boost for related terms
929 *
930 * @param string $content Content text (lowercase)
931 * @param string $keyword Target keyword (lowercase)
932 * @return float Semantic boost (0-0.3)
933 */
934 private function calculate_semantic_boost(string $content, string $keyword): float {
935 // Simple semantic term detection - can be enhanced with NLP
936 $semantic_terms = $this->get_semantic_terms($keyword);
937 $boost = 0.0;
938
939 foreach ($semantic_terms as $term) {
940 if (strpos($content, $term) !== false) {
941 $boost += 0.05; // Small boost per semantic term
942 }
943 }
944
945 return min(0.3, $boost); // Cap at 30% boost
946 }
947
948 /**
949 * Get semantic terms for a keyword
950 *
951 * @param string $keyword Target keyword
952 * @return array Semantic terms
953 */
954 private function get_semantic_terms(string $keyword): array {
955 // Simple semantic term generation - can be enhanced with AI/NLP
956 $terms = [];
957
958 // Add plural/singular variations
959 if (substr($keyword, -1) === 's') {
960 $terms[] = rtrim($keyword, 's');
961 } else {
962 $terms[] = $keyword . 's';
963 }
964
965 // Add common related terms based on keyword
966 $keyword_lower = strtolower($keyword);
967
968 // SEO-related terms
969 if (strpos($keyword_lower, 'seo') !== false) {
970 $terms = array_merge($terms, ['optimization', 'search engine', 'ranking', 'visibility']);
971 }
972
973 // WordPress-related terms
974 if (strpos($keyword_lower, 'wordpress') !== false) { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: the haystack is strtolower()ed.
975 $terms = array_merge($terms, ['wp', 'plugin', 'theme', 'cms']);
976 }
977
978 return $terms;
979 }
980
981 /**
982 * Keyword density (%) of the target keyword across the plain-text body.
983 *
984 * @param string $content Raw/HTML content.
985 * @param string $target_keyword Target keyword.
986 * @return float Density percentage (0 when no keyword/content).
987 */
988 private function keyword_density(string $content, string $target_keyword): float {
989 if (empty($content) || empty($target_keyword)) {
990 return 0.0;
991 }
992 $plain = strtolower(wp_strip_all_tags($content));
993 $word_count = $this->calculate_word_count_js_style($plain);
994 if ($word_count === 0) {
995 return 0.0;
996 }
997 $occurrences = substr_count($plain, strtolower($target_keyword));
998 return ($occurrences / $word_count) * 100;
999 }
1000
1001 /**
1002 * Whether the target keyword (or a semantic variation) appears in the body.
1003 *
1004 * @param string $content Raw/HTML content.
1005 * @param string $target_keyword Target keyword.
1006 * @return bool
1007 */
1008 private function keyword_in_content(string $content, string $target_keyword): bool {
1009 if (empty($content) || empty($target_keyword)) {
1010 return false;
1011 }
1012 $plain = strtolower(wp_strip_all_tags($content));
1013 if (strpos($plain, strtolower($target_keyword)) !== false) {
1014 return true;
1015 }
1016 return $this->check_semantic_keyword_match($plain, $target_keyword);
1017 }
1018
1019 /**
1020 * Whether the keyword appears early (first paragraph / first ~10% of words).
1021 *
1022 * @param string $content Raw/HTML content.
1023 * @param string $target_keyword Target keyword.
1024 * @return bool
1025 */
1026 private function keyword_in_first_paragraph(string $content, string $target_keyword): bool {
1027 if (empty($content) || empty($target_keyword)) {
1028 return false;
1029 }
1030 $plain = strtolower(wp_strip_all_tags($content));
1031 $words = preg_split('/\s+/', trim($plain), -1, PREG_SPLIT_NO_EMPTY) ?: [];
1032 $window = array_slice($words, 0, max(50, (int) ceil(count($words) * 0.1)));
1033 return strpos(implode(' ', $window), strtolower($target_keyword)) !== false;
1034 }
1035
1036 /**
1037 * Whether the keyword appears in any subheading (H2–H6).
1038 *
1039 * @param array $headings Extracted headings (each with 'level' + 'text').
1040 * @param string $target_keyword Target keyword.
1041 * @return bool
1042 */
1043 private function keyword_in_subheadings(array $headings, string $target_keyword): bool {
1044 if (empty($target_keyword)) {
1045 return false;
1046 }
1047 $keyword_lower = strtolower($target_keyword);
1048 foreach ($headings as $heading) {
1049 if ((int) ($heading['level'] ?? 0) < 2) {
1050 continue;
1051 }
1052 $text = strtolower((string) ($heading['text'] ?? ''));
1053 if ($text !== '' && strpos($text, $keyword_lower) !== false) {
1054 return true;
1055 }
1056 }
1057 return false;
1058 }
1059
1060 /**
1061 * Days elapsed since a MySQL datetime string, or null when unparseable.
1062 *
1063 * @param string $datetime MySQL datetime (e.g. post_modified).
1064 * @return int|null
1065 */
1066 private function days_since(string $datetime): ?int {
1067 $datetime = trim($datetime);
1068 if ($datetime === '' || strpos($datetime, '0000-00-00') === 0) {
1069 return null;
1070 }
1071 $ts = strtotime($datetime);
1072 if ($ts === false) {
1073 return null;
1074 }
1075 // strtotime() returns a real unix timestamp, so this must compare against
1076 // one: current_time('timestamp') is offset by the site timezone and made
1077 // every "days ago" figure wrong by that offset.
1078 $now = time();
1079 return (int) floor(($now - $ts) / 86400);
1080 }
1081
1082 /**
1083 * Whether the keyword appears in the meta description.
1084 *
1085 * @param string $meta_description Meta description text.
1086 * @param string $target_keyword Target keyword.
1087 * @return bool
1088 */
1089 private function keyword_in_meta(string $meta_description, string $target_keyword): bool {
1090 if ($meta_description === '' || $target_keyword === '') {
1091 return false;
1092 }
1093 return strpos(strtolower($meta_description), strtolower($target_keyword)) !== false;
1094 }
1095
1096 /**
1097 * Whether the keyword appears in any image alt text.
1098 *
1099 * @param array $images Images (each with an 'alt' key).
1100 * @param string $target_keyword Target keyword.
1101 * @return bool
1102 */
1103 private function keyword_in_alt(array $images, string $target_keyword): bool {
1104 if ($target_keyword === '') {
1105 return false;
1106 }
1107 $keyword_lower = strtolower($target_keyword);
1108 foreach ($images as $image) {
1109 $alt = strtolower((string) ($image['alt'] ?? ''));
1110 if ($alt !== '' && strpos($alt, $keyword_lower) !== false) {
1111 return true;
1112 }
1113 }
1114 return false;
1115 }
1116
1117 /**
1118 * Whether the title contains a common power word (CTR booster).
1119 *
1120 * @param string $title Post title.
1121 * @return bool
1122 */
1123 private function title_has_power_word(string $title): bool {
1124 $title_lower = strtolower($title);
1125 foreach (self::get_title_power_words() as $word) {
1126 if (strpos($title_lower, $word) !== false) {
1127 return true;
1128 }
1129 }
1130 return false;
1131 }
1132
1133 /**
1134 * The power words rewarded by the title check. Single source of truth so the
1135 * AI title improver can require the generated title to actually contain one.
1136 *
1137 * @return string[]
1138 */
1139 public static function get_title_power_words(): array {
1140 return [
1141 'ultimate', 'essential', 'complete', 'proven', 'guide', 'best', 'top',
1142 'free', 'easy', 'simple', 'quick', 'fast', 'powerful', 'secret', 'expert',
1143 'effective', 'amazing', 'incredible', 'exclusive', 'definitive', 'step-by-step',
1144 ];
1145 }
1146
1147 /**
1148 * The emotion/sentiment words rewarded by the title check. Single source of
1149 * truth shared with the AI title improver so a generated title satisfies the
1150 * same validation.
1151 *
1152 * @return string[]
1153 */
1154 public static function get_title_sentiment_words(): array {
1155 return [
1156 // Positive
1157 'great', 'good', 'better', 'awesome', 'love', 'win', 'boost', 'improve',
1158 'success', 'smart', 'brilliant', 'perfect', 'happy', 'beautiful',
1159 // Negative (drives clicks too)
1160 'avoid', 'mistake', 'worst', 'stop', 'never', 'bad', 'wrong', 'fail',
1161 'danger', 'warning', 'painful', 'ugly',
1162 ];
1163 }
1164
1165 /**
1166 * Whether the title carries an emotional/sentiment word (positive or
1167 * negative), which Rank Math rewards for higher engagement.
1168 *
1169 * @param string $title Post title.
1170 * @return bool
1171 */
1172 private function title_has_sentiment_word(string $title): bool {
1173 $title_lower = strtolower($title);
1174 foreach (self::get_title_sentiment_words() as $word) {
1175 if (strpos($title_lower, $word) !== false) {
1176 return true;
1177 }
1178 }
1179 return false;
1180 }
1181
1182 /**
1183 * Assess content depth based on word count
1184 *
1185 * @param int $word_count Word count
1186 * @return string Depth assessment
1187 */
1188 private function assess_content_depth(int $word_count): string {
1189 if ($word_count >= 2000) { return 'Comprehensive';
1190 }
1191 if ($word_count >= 1000) { return 'Detailed';
1192 }
1193 if ($word_count >= 500) { return 'Moderate';
1194 }
1195 if ($word_count >= 300) { return 'Basic';
1196 }
1197 return 'Insufficient';
1198 }
1199 private function score_content_freshness(array $content_data): array {
1200 $max_score = $this->scoring_factors['content_freshness'];
1201 $suggestions = [];
1202
1203 // Derive freshness from the real last-modified date when available
1204 // (live editing has no stored date yet -> treat as fresh).
1205 $days = $this->days_since((string) ($content_data['post_modified'] ?? ''));
1206
1207 if ($days === null || $days <= 180) {
1208 $score = $max_score;
1209 $status = 'Current';
1210 } elseif ($days <= 365) {
1211 $score = (int) round($max_score * 0.66);
1212 $status = 'Aging';
1213 $suggestions[] = 'Content is 6-12 months old - review and refresh it for better freshness signals';
1214 } else {
1215 $score = (int) round($max_score * 0.33);
1216 $status = 'Stale';
1217 $suggestions[] = 'Content is over a year old - update it to maintain freshness signals';
1218 }
1219
1220 return [
1221 'score' => $score,
1222 'max_score' => $max_score,
1223 'suggestions' => $suggestions,
1224 'details' => [
1225 'freshness_status' => $status,
1226 'days_since_modified' => $days,
1227 ],
1228 ];
1229 }
1230 /**
1231 * Resolve a post's content into something worth analyzing.
1232 *
1233 * Delegates to Builder_Content, which knows where each page builder keeps
1234 * its text. Kept as the historical entry point for existing callers.
1235 *
1236 * @since 1.23.0
1237 *
1238 * @param \WP_Post $post Post being analyzed.
1239 * @return string Content to analyze.
1240 */
1241 public static function resolve_analyzable_content(\WP_Post $post): string {
1242 if (!class_exists('\ThinkRank\SEO\Builder_Content')) {
1243 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1244 }
1245
1246 return \ThinkRank\SEO\Builder_Content::resolve($post);
1247 }
1248
1249 /**
1250 * Resolve editor-supplied live content into something worth analyzing.
1251 *
1252 * @since 1.23.0
1253 *
1254 * @param string $live_content Markup supplied by the editor.
1255 * @param \WP_Post $post Post the markup belongs to.
1256 * @return string Content to analyze.
1257 */
1258 public static function resolve_live_content(string $live_content, \WP_Post $post): string {
1259 if (!class_exists('\ThinkRank\SEO\Builder_Content')) {
1260 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1261 }
1262
1263 return \ThinkRank\SEO\Builder_Content::resolve_markup($live_content, $post);
1264 }
1265
1266 public function analyze_post_content(int $post_id): array {
1267 $post = get_post($post_id);
1268 if (!$post) {
1269 return [];
1270 }
1271
1272 $content = self::resolve_analyzable_content($post);
1273 $title = $post->post_title;
1274
1275 // Extract headings from content
1276 $headings = $this->extract_headings($content);
1277
1278 // Count words using JavaScript-compatible method
1279 $plain_text = wp_strip_all_tags($content);
1280 $word_count = $this->calculate_word_count_js_style($plain_text);
1281
1282 // Calculate readability
1283 $readability_score = $this->calculate_readability_score($content);
1284
1285 // Count links
1286 $internal_links = $this->count_internal_links($content);
1287 $external_links = $this->count_external_links($content);
1288 $external_dofollow_links = $this->count_external_dofollow_links($content);
1289
1290 // Analyze images
1291 $images = $this->analyze_images($content);
1292
1293 // Get URL
1294 $url = get_permalink($post_id);
1295
1296 return [
1297 'content' => $content,
1298 'title' => $title,
1299 'headings' => $headings,
1300 'word_count' => $word_count,
1301 'readability_score' => $readability_score,
1302 'internal_links' => $internal_links,
1303 'external_links' => $external_links,
1304 'external_dofollow_links' => $external_dofollow_links,
1305 'images' => $images,
1306 'url' => $url,
1307 'slug' => $post->post_name,
1308 'post_modified' => $post->post_modified,
1309 'schema_present' => $this->detect_schema_present($content) || $this->thinkrank_global_schema_active($post->post_type),
1310 ];
1311 }
1312
1313 /**
1314 * Build the human-readable readability label (mirrors the editor's
1315 * calculateReadabilityScore: "<level> (<flesch>)") from analyzed content.
1316 *
1317 * @param array $content_data Output of analyze_post_content()/analyze_live_content()
1318 * @return string Readability label, e.g. "Standard (62)"
1319 */
1320 private function format_readability_label(array $content_data): string {
1321 if ((int) ($content_data['word_count'] ?? 0) === 0) {
1322 return 'No content';
1323 }
1324
1325 $rounded = (int) round((float) ($content_data['readability_score'] ?? 0));
1326
1327 if ($rounded >= 90) {
1328 $level = 'Very Easy';
1329 } elseif ($rounded >= 80) {
1330 $level = 'Easy';
1331 } elseif ($rounded >= 70) {
1332 $level = 'Fairly Easy';
1333 } elseif ($rounded >= 60) {
1334 $level = 'Standard';
1335 } elseif ($rounded >= 50) {
1336 $level = 'Fairly Difficult';
1337 } elseif ($rounded >= 30) {
1338 $level = 'Difficult';
1339 } else {
1340 $level = 'Very Difficult';
1341 }
1342
1343 return "{$level} ({$rounded})";
1344 }
1345
1346 /**
1347 * Derive the content-quality label (mirrors the editor's
1348 * calculateContentQuality: word count + long-paragraph + subheading scoring)
1349 * so persisted scores carry a non-null quality value.
1350 *
1351 * @param array $content_data Output of analyze_post_content()/analyze_live_content()
1352 * @return string One of: No content, Good, OK, Needs improvement
1353 */
1354 private function derive_content_quality(array $content_data): string {
1355 $word_count = (int) ($content_data['word_count'] ?? 0);
1356 if ($word_count === 0) {
1357 return 'No content';
1358 }
1359
1360 $final = $this->derive_content_quality_score($content_data);
1361
1362 if ($final >= 80) {
1363 return 'Good';
1364 }
1365 if ($final >= 50) {
1366 return 'OK';
1367 }
1368
1369 return 'Needs improvement';
1370 }
1371
1372 /**
1373 * Numeric content-quality score (0-100): word count + long-paragraph +
1374 * subheading distribution. Shared by the quality label and the
1375 * satisfying-content factor so both stay in sync.
1376 *
1377 * @param array $content_data Output of analyze_post_content()/analyze_live_content()
1378 * @return int Quality score 0-100.
1379 */
1380 private function derive_content_quality_score(array $content_data): int {
1381 $word_count = (int) ($content_data['word_count'] ?? 0);
1382 if ($word_count === 0) {
1383 return 0;
1384 }
1385
1386 $content = (string) ($content_data['content'] ?? '');
1387 $score = 0;
1388
1389 // 1. Word count (industry standard: 300+ words).
1390 if ($word_count >= 600) {
1391 $score += 100;
1392 } elseif ($word_count >= 300) {
1393 $score += 50;
1394 }
1395
1396 // 2. Long paragraphs (flag paragraphs over 150 words).
1397 $long_paragraphs = 0;
1398 if (preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $content, $matches)) {
1399 foreach ($matches[1] as $paragraph) {
1400 if ($this->calculate_word_count_js_style(wp_strip_all_tags($paragraph)) > 150) {
1401 $long_paragraphs++;
1402 }
1403 }
1404 }
1405 if ($long_paragraphs === 0) {
1406 $score += 100;
1407 } elseif ($long_paragraphs <= 2) {
1408 $score += 50;
1409 }
1410
1411 // 3. Subheading distribution (H2–H6, ~one per 300 words).
1412 $subheadings = 0;
1413 foreach ((array) ($content_data['headings'] ?? []) as $heading) {
1414 if ((int) ($heading['level'] ?? 0) >= 2) {
1415 $subheadings++;
1416 }
1417 }
1418 $expected = (int) floor($word_count / 300);
1419 if ($subheadings > 0 && $subheadings >= $expected) {
1420 $score += 100;
1421 } elseif ($subheadings > 0) {
1422 $score += 50;
1423 }
1424
1425 return (int) round($score / 3);
1426 }
1427
1428 /**
1429 * Extract headings from content
1430 *
1431 * @param string $content Content HTML
1432 * @return array Array of headings with levels
1433 */
1434 private function extract_headings(string $content): array {
1435 $headings = [];
1436
1437 // Match H1-H6 tags
1438 if (preg_match_all('/<h([1-6])[^>]*>(.*?)<\/h[1-6]>/i', $content, $matches, PREG_SET_ORDER)) {
1439 foreach ($matches as $match) {
1440 $headings[] = [
1441 'level' => (int)$match[1],
1442 'text' => wp_strip_all_tags($match[2]),
1443 ];
1444 }
1445 }
1446
1447 return $headings;
1448 }
1449
1450 /**
1451 * Calculate readability score using Flesch Reading Ease
1452 *
1453 * @param string $content Content text
1454 * @return float Readability score
1455 */
1456 private function calculate_readability_score(string $content): float {
1457 $text = wp_strip_all_tags($content);
1458
1459 if (empty($text)) {
1460 return 0;
1461 }
1462
1463 // Count sentences (approximate)
1464 $sentences = preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY);
1465 $sentence_count = count($sentences);
1466
1467 // Count words
1468 $word_count = $this->calculate_word_count_js_style(wp_strip_all_tags($text));
1469
1470 // Count syllables (approximate)
1471 $syllable_count = $this->count_syllables($text);
1472
1473 if ($sentence_count === 0 || $word_count === 0) {
1474 return 0;
1475 }
1476
1477 // Flesch Reading Ease formula
1478 $score = 206.835 - (1.015 * ($word_count / $sentence_count)) - (84.6 * ($syllable_count / $word_count));
1479
1480 return max(0, min(100, $score));
1481 }
1482
1483 /**
1484 * Count syllables in text (approximate)
1485 *
1486 * @param string $text Text to analyze
1487 * @return int Syllable count
1488 */
1489 private function count_syllables(string $text): int {
1490 $words = preg_split('/\s+/', trim(strtolower(wp_strip_all_tags($text))), -1, PREG_SPLIT_NO_EMPTY);
1491 $syllables = 0;
1492
1493 foreach ($words as $word) {
1494 $syllables += max(1, preg_match_all('/[aeiouy]+/', $word));
1495 }
1496
1497 return $syllables;
1498 }
1499
1500 /**
1501 * Count internal links in content
1502 *
1503 * @param string $content Content HTML
1504 * @return int Internal link count
1505 */
1506 private function count_internal_links(string $content): int {
1507 $site_url = get_site_url();
1508 $count = 0;
1509
1510 if (preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
1511 foreach ($matches[1] as $url) {
1512 if (strpos($url, $site_url) !== false || strpos($url, '/') === 0) {
1513 $count++;
1514 }
1515 }
1516 }
1517
1518 return $count;
1519 }
1520
1521 /**
1522 * Count external links in content
1523 *
1524 * @param string $content Content HTML
1525 * @return int External link count
1526 */
1527 private function count_external_links(string $content): int {
1528 $site_url = get_site_url();
1529 $count = 0;
1530
1531 if (preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
1532 foreach ($matches[1] as $url) {
1533 if (strpos($url, 'http') === 0 && strpos($url, $site_url) === false) {
1534 $count++;
1535 }
1536 }
1537 }
1538
1539 return $count;
1540 }
1541
1542 /**
1543 * Count external links that pass link equity (not rel="nofollow").
1544 * Mirrors Rank Math's "external dofollow link" check.
1545 *
1546 * @param string $content Content HTML
1547 * @return int External dofollow link count
1548 */
1549 private function count_external_dofollow_links(string $content): int {
1550 $site_url = get_site_url();
1551 $count = 0;
1552
1553 if (preg_match_all('/<a\b[^>]*>/i', $content, $matches)) {
1554 foreach ($matches[0] as $tag) {
1555 if (!preg_match('/href=["\']([^"\']+)["\']/i', $tag, $href)) {
1556 continue;
1557 }
1558 $url = $href[1];
1559 $is_external = strpos($url, 'http') === 0 && strpos($url, $site_url) === false;
1560 if (!$is_external) {
1561 continue;
1562 }
1563 if (preg_match('/rel=["\'][^"\']*\bnofollow\b[^"\']*["\']/i', $tag)) {
1564 continue;
1565 }
1566 $count++;
1567 }
1568 }
1569
1570 return $count;
1571 }
1572
1573 /**
1574 * Analyze images in content
1575 *
1576 * @param string $content Content HTML
1577 * @return array Image analysis data
1578 */
1579 /**
1580 * Detect structured data embedded directly in the content (JSON-LD script
1581 * blocks or microdata attributes). Site-wide schema injected at render time
1582 * is a separate feature and intentionally out of scope here.
1583 *
1584 * @param string $content Raw/HTML content.
1585 * @return bool
1586 */
1587 private function detect_schema_present(string $content): bool {
1588 if ($content === '') {
1589 return false;
1590 }
1591 return stripos($content, 'application/ld+json') !== false
1592 || stripos($content, 'itemscope') !== false
1593 || stripos($content, 'itemtype') !== false;
1594 }
1595
1596 /**
1597 * Whether ThinkRank's Global SEO schema output is active for a post type.
1598 *
1599 * ThinkRank injects JSON-LD at render time (wp_head) when a schema type is
1600 * configured for the post type, so a post can have valid structured data
1601 * even when none is embedded in the post body. The score credits this so the
1602 * "add structured data" suggestion reflects ThinkRank's own schema engine.
1603 *
1604 * @param string $post_type Post type slug.
1605 * @return bool
1606 */
1607 private function thinkrank_global_schema_active(string $post_type): bool {
1608 if ($post_type === '') {
1609 return false;
1610 }
1611 $all_settings = get_option('thinkrank_global_seo_settings', []);
1612 return !empty($all_settings[$post_type]['schema_type']);
1613 }
1614
1615 private function analyze_images(string $content): array {
1616 $images = [];
1617
1618 if (preg_match_all('/<img[^>]+>/i', $content, $matches)) {
1619 foreach ($matches[0] as $img_tag) {
1620 $alt = '';
1621 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
1622 $alt = $alt_match[1];
1623 }
1624
1625 $src = '';
1626 if (preg_match('/src=["\']([^"\']*)["\']/', $img_tag, $src_match)) {
1627 $src = $src_match[1];
1628 }
1629
1630 $images[] = [
1631 'src' => $src,
1632 'alt' => $alt,
1633 ];
1634 }
1635 }
1636
1637 return $images;
1638 }
1639
1640 /**
1641 * Save SEO score to database
1642 *
1643 * @param int $post_id Post ID
1644 * @param int $user_id User ID
1645 * @param array $score_data Score data
1646 * @return int|false Score ID or false on failure
1647 */
1648 public function save_score(int $post_id, int $user_id, array $score_data) {
1649 global $wpdb;
1650
1651 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
1652
1653 // Prepare data for insertion
1654 $insert_data = [
1655 'post_id' => $post_id,
1656 'user_id' => $user_id,
1657 'overall_score' => $score_data['overall_score'],
1658 'score_breakdown' => wp_json_encode($score_data['score_breakdown']),
1659 'suggestions' => wp_json_encode($score_data['suggestions']),
1660 'grade' => $score_data['grade'],
1661 'algorithm_version' => $score_data['algorithm_version'] ?? '2024.1',
1662 'calculated_at' => $score_data['calculated_at'],
1663 'created_at' => current_time('mysql'),
1664 ];
1665
1666 $format = [
1667 '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s'
1668 ];
1669
1670 // Add readability_score if provided
1671 if (isset($score_data['readability_score'])) {
1672 $insert_data['readability_score'] = $score_data['readability_score'];
1673 $format[] = '%s';
1674 }
1675
1676 // Add content_quality if provided
1677 if (isset($score_data['content_quality'])) {
1678 $insert_data['content_quality'] = $score_data['content_quality'];
1679 $format[] = '%s';
1680 }
1681
1682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score storage requires direct database access
1683 $result = $wpdb->insert(
1684 $table_name,
1685 $insert_data,
1686 $format
1687 );
1688
1689 return $result ? $wpdb->insert_id : false;
1690 }
1691
1692 /**
1693 * Get score history for a post
1694 *
1695 * @param int $post_id Post ID
1696 * @param int $limit Number of scores to retrieve
1697 * @return array Score history
1698 */
1699 public function get_score_history(int $post_id, int $limit = 10): array {
1700 global $wpdb;
1701
1702 // Get table name and escape it properly (table names cannot be parameterized)
1703 $table_name = esc_sql($wpdb->prefix . 'thinkrank_seo_scores');
1704
1705 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score history requires direct database access
1706 $results = $wpdb->get_results(
1707 $wpdb->prepare(
1708 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1709 "SELECT * FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT %d",
1710 $post_id,
1711 $limit
1712 ),
1713 ARRAY_A
1714 );
1715
1716 // Decode JSON fields
1717 foreach ($results as &$result) {
1718 $result['score_breakdown'] = json_decode($result['score_breakdown'], true);
1719 $result['suggestions'] = json_decode($result['suggestions'], true);
1720 }
1721
1722 return $results ?: [];
1723 }
1724
1725 /**
1726 * Get latest score for a post
1727 *
1728 * @param int $post_id Post ID
1729 * @return array|null Latest score data
1730 */
1731 public function get_latest_score(int $post_id): ?array {
1732 $history = $this->get_score_history($post_id, 1);
1733 return !empty($history) ? $history[0] : null;
1734 }
1735
1736 /**
1737 * Score mobile experience - NEW 2025 factor (5 points)
1738 *
1739 * @param array $content_data Content analysis data
1740 * @return array Scoring result
1741 */
1742 private function score_mobile_experience(array $content_data): array {
1743 // Mobile experience is theme/site-level, not controlled by post content.
1744 // Award full credit (benefit of the doubt) instead of a fixed partial
1745 // that caps every post's ceiling.
1746 return [
1747 'score' => $this->scoring_factors['mobile_experience'],
1748 'max_score' => $this->scoring_factors['mobile_experience'],
1749 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1750 'details' => ['mobile_score' => 'Assumed adequate'],
1751 ];
1752 }
1753
1754 /**
1755 * Score core web vitals - 2025 version (3 points)
1756 *
1757 * @param array $content_data Content analysis data
1758 * @return array Scoring result
1759 */
1760 private function score_core_web_vitals(array $content_data): array {
1761 // Core Web Vitals are a runtime/performance signal, not derivable from
1762 // post content. Award full credit (benefit of the doubt) rather than a
1763 // fixed partial that caps every post's ceiling.
1764 return [
1765 'score' => $this->scoring_factors['core_web_vitals'],
1766 'max_score' => $this->scoring_factors['core_web_vitals'],
1767 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'],
1768 'details' => ['vitals_status' => 'Assumed adequate'],
1769 ];
1770 }
1771
1772 /**
1773 * Score internal linking - declining importance (1 point)
1774 *
1775 * @param array $content_data Content analysis data
1776 * @return array Scoring result
1777 */
1778 private function score_internal_linking(array $content_data): array {
1779 $internal_links = $content_data['internal_links'] ?? 0;
1780 $score = $internal_links > 0 ? 1 : 0;
1781
1782 return [
1783 'score' => $score,
1784 'max_score' => $this->scoring_factors['internal_linking'],
1785 'suggestions' => $score === 0 ? ['Add relevant internal links to other pages on your site'] : [],
1786 'details' => ['internal_links_count' => $internal_links]
1787 ];
1788 }
1789
1790 /**
1791 * Score technical factors (1 point)
1792 *
1793 * @param array $content_data Content analysis data
1794 * @param array $metadata Post metadata
1795 * @param array $options Additional options
1796 * @return array Scoring result
1797 */
1798 private function score_technical_factors(array $content_data, array $metadata, array $options): array {
1799 $score = 0;
1800 $suggestions = [];
1801
1802 // Meta description check
1803 $meta_desc = $metadata['description'] ?? '';
1804 if (!empty($meta_desc) && mb_strlen($meta_desc) >= 120 && mb_strlen($meta_desc) <= 160) {
1805 $score += 0.5;
1806 } else {
1807 $suggestions[] = 'Add a compelling meta description (120-160 characters)';
1808 }
1809
1810 // Schema markup check (simplified)
1811 if (!empty($content_data['schema_present'])) {
1812 $score += 0.5;
1813 } else {
1814 $suggestions[] = 'Consider adding structured data (schema markup)';
1815 }
1816
1817 return [
1818 'score' => $score,
1819 'max_score' => $this->scoring_factors['technical_factors'],
1820 'suggestions' => $suggestions,
1821 'details' => [
1822 'meta_description_length' => mb_strlen($meta_desc),
1823 'schema_present' => !empty($content_data['schema_present'])
1824 ]
1825 ];
1826 }
1827
1828 /**
1829 * Get grade from score
1830 *
1831 * @param mixed $score Numeric score
1832 * @return string Letter grade
1833 */
1834 private function get_grade_from_score($score): string {
1835 $score = (int) $score; // Ensure it's an integer
1836
1837 if ($score >= 95) { return 'A+';
1838 }
1839 if ($score >= 90) { return 'A';
1840 }
1841 if ($score >= 85) { return 'A-';
1842 }
1843 if ($score >= 80) { return 'B+';
1844 }
1845 if ($score >= 75) { return 'B';
1846 }
1847 if ($score >= 70) { return 'B-';
1848 }
1849 if ($score >= 65) { return 'C+';
1850 }
1851 if ($score >= 60) { return 'C';
1852 }
1853 if ($score >= 55) { return 'C-';
1854 }
1855 if ($score >= 45) { return 'D+';
1856 }
1857 if ($score >= 35) { return 'D';
1858 }
1859 return 'F';
1860 }
1861
1862 /**
1863 * Analyze live content from editor (not saved to database yet)
1864 * Same as analyze_post_content but uses provided content instead of saved content
1865 *
1866 * @param string $live_content Live content from editor
1867 * @param int $post_id Post ID for metadata
1868 * @return array Content analysis data
1869 */
1870 public function analyze_live_content(string $live_content, int $post_id): array {
1871 $post = get_post($post_id);
1872 if (!$post) {
1873 return [];
1874 }
1875
1876 // Resolve the live string the same way stored content is resolved. On a
1877 // builder page the editor hands over raw builder markup (the block
1878 // editor cannot render blocks it has no client-side registration for),
1879 // which analyzed as-is reads as zero words — the reason a Divi page
1880 // could show a correct saved score beside a live panel still claiming
1881 // "No content".
1882 $content = self::resolve_live_content($live_content, $post);
1883 $title = $post->post_title;
1884
1885 // Extract headings from content
1886 $headings = $this->extract_headings($content);
1887
1888 // Count words using JavaScript-compatible method
1889 $plain_text = wp_strip_all_tags($content);
1890 $word_count = $this->calculate_word_count_js_style($plain_text);
1891
1892 // Calculate readability
1893 $readability_score = $this->calculate_readability_score($content);
1894
1895 // Count links
1896 $internal_links = $this->count_internal_links($content);
1897 $external_links = $this->count_external_links($content);
1898 $external_dofollow_links = $this->count_external_dofollow_links($content);
1899
1900 // Analyze images
1901 $images = $this->analyze_images($content);
1902
1903 // Get URL
1904 $url = get_permalink($post_id);
1905
1906 return [
1907 'content' => $content,
1908 'title' => $title,
1909 'headings' => $headings,
1910 'word_count' => $word_count,
1911 'readability_score' => $readability_score,
1912 'internal_links' => $internal_links,
1913 'external_links' => $external_links,
1914 'external_dofollow_links' => $external_dofollow_links,
1915 'images' => $images,
1916 'url' => $url,
1917 'slug' => $post->post_name,
1918 'post_modified' => $post->post_modified,
1919 'schema_present' => $this->detect_schema_present($content) || $this->thinkrank_global_schema_active($post->post_type),
1920 ];
1921 }
1922
1923 /**
1924 * Calculate word count using JavaScript-compatible method
1925 * Matches the logic in contentAnalysis.js for consistency
1926 *
1927 * @param string $text Text to count words in
1928 * @return int Word count
1929 */
1930 private function calculate_word_count_js_style(string $text): int {
1931 if (empty($text)) {
1932 return 0;
1933 }
1934
1935 // Match JavaScript: trim, split by whitespace, filter empty
1936 $words = preg_split('/\s+/', trim($text), -1, PREG_SPLIT_NO_EMPTY);
1937 return count($words);
1938 }
1939
1940 /**
1941 * Get existing score data for a post from database
1942 *
1943 * @param int $post_id Post ID
1944 * @return array|null Existing score data or null if not found
1945 */
1946 public function get_existing_score_data(int $post_id): ?array {
1947 global $wpdb;
1948
1949 // Get table name and escape it properly (table names cannot be parameterized)
1950 $table_name = esc_sql($wpdb->prefix . 'thinkrank_seo_scores');
1951
1952 // Get the most recent score for this post
1953 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
1954 $result = $wpdb->get_row($wpdb->prepare(
1955 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1956 "SELECT * FROM `{$table_name}`
1957 WHERE post_id = %d
1958 ORDER BY calculated_at DESC
1959 LIMIT 1",
1960 $post_id
1961 ), ARRAY_A);
1962
1963 if (!$result) {
1964 return null;
1965 }
1966
1967 // Decode JSON data (stored with json_encode)
1968 $score_breakdown = json_decode($result['score_breakdown'], true);
1969 $suggestions = json_decode($result['suggestions'], true);
1970
1971 // Format the data to match the expected structure
1972 return [
1973 'overall_score' => (int) $result['overall_score'],
1974 'grade' => $result['grade'],
1975 'score_breakdown' => $score_breakdown,
1976 'suggestions' => $suggestions ?: [],
1977 'target_keyword' => null, // Not stored in database, will be provided by frontend
1978 'calculated_at' => $result['calculated_at'],
1979 'score_id' => $result['id']
1980 ];
1981 }
1982 }
1983