PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
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 / 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 2.0.2, at includes/ai/class-seo-score-calculator.php

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