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

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