PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.25.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.25.0
2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
thinkrank / includes / ai / class-seo-score-calculator.php

class-seo-score-calculator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.25.0, at includes/ai/class-seo-score-calculator.php

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