PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 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.10.0, at includes/ai/class-seo-score-calculator.php

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