PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 All 55 releases
← All changes | includes/ai/class-seo-score-calculator.php +588 -35 1.32.0 → 2.14.0 View file →
@@ -41,8 +41,41 @@
41 41 */
42 42 private Database $database;
43 43
44 44 /**
45 + * Memoised collected performance measurement, and whether it was resolved.
46 + *
47 + * Two factors read it and both may be asked for on every post in a list, so
48 + * the lookup happens once per calculator. `null` is a real answer here — the
49 + * separate flag keeps "not looked up yet" distinct from "nothing measured".
50 + *
51 + * @since 2.3.1
52 + * @var array|null
53 + */
54 + private ?array $measured_performance = null;
55 +
56 + /**
57 + * @since 2.3.1
58 + * @var bool
59 + */
60 + private bool $measured_performance_resolved = false;
61 +
62 + /**
63 + * Length bands the editor scores against, in characters.
64 + *
65 + * Public so every surface that judges a title or description — the editor
66 + * score and the Bulk Snippets problem filter — reads one set of numbers.
67 + * Before these existed the bands were literals inside the scoring methods,
68 + * and a second screen would have had to copy them and drift (#727).
69 + *
70 + * @since 2.8.0
71 + */
72 + public const TITLE_OPTIMAL_MIN = 35;
73 + public const TITLE_OPTIMAL_MAX = 60;
74 + public const DESCRIPTION_OPTIMAL_MIN = 120;
75 + public const DESCRIPTION_OPTIMAL_MAX = 160;
76 +
77 + /**
45 78 * 2025 SEO scoring factors (Q1 2025 Google Algorithm)
46 79 * Based on First Page Sage research and Google's latest updates
47 80 *
48 81 * @var array
@@ -107,8 +140,9 @@
107 140 'grade' => $result['grade'],
108 141 ]];
109 142 $result['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
110 143 }
144 + $result['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
111 145
112 146 return $result;
113 147 }
114 148
@@ -218,8 +252,9 @@
218 252 $best['target_keyword'] = $best_keyword;
219 253 $best['target_keywords'] = $keywords;
220 254 $best['keyword_results'] = $per_keyword;
221 255 $best['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
256 + $best['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
222 257
223 258 return $best;
224 259 }
225 260
@@ -234,21 +269,26 @@
234 269 * @param string[] $keywords Target keywords.
235 270 * @return array<string,array{passed:bool,matched_keywords:string[]}>
236 271 */
237 272 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'] ?? '')));
273 + $title = self::lower((string) ($metadata['title'] ?? $content_data['title'] ?? ''));
274 + $description = self::lower((string) ($metadata['description'] ?? ''));
275 + $content = self::lower(self::plain_text((string) ($content_data['content'] ?? '')));
241 276
242 277 $alts = '';
243 278 foreach ((array) ($content_data['images'] ?? []) as $image) {
244 - $alts .= ' ' . strtolower((string) ($image['alt'] ?? ''));
279 + $alts .= ' ' . self::lower((string) ($image['alt'] ?? ''));
245 280 }
246 281
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, '/')));
282 + // Build a searchable slug haystack from the post's OWN slug — never the
283 + // full URL path. The path carries ancestors, category bases and date
284 + // segments, so a child of /clinical-trials/ reported "keyword in slug"
285 + // for a page actually slugged `contact-us`. It also breaks the other
286 + // way: an unpublished post has no pretty permalink (get_permalink()
287 + // returns ?p=123), so the path held no slug at all and every draft
288 + // scored "no match" until it was published. Hyphens/underscores become
289 + // spaces so multi-word keywords can match.
290 + $slug = self::lower(self::slug_haystack($content_data));
251 291
252 292 $haystacks = [
253 293 'title' => trim($title),
254 294 'meta_description' => trim($description),
@@ -260,10 +300,9 @@
260 300 $checks = [];
261 301 foreach ($haystacks as $location => $haystack) {
262 302 $matched = [];
263 303 foreach ($keywords as $keyword) {
264 - $needle = strtolower(trim($keyword));
265 - if ($needle !== '' && $haystack !== '' && strpos($haystack, $needle) !== false) {
304 + if ($this->keyword_matches($haystack, self::lower(trim($keyword)))) {
266 305 $matched[] = $keyword;
267 306 }
268 307 }
269 308 $checks[$location] = [
@@ -275,8 +314,288 @@
275 314 return $checks;
276 315 }
277 316
278 317 /**
318 + * The keyword placements the editor draws one gauge segment for, in the
319 + * order a reader meets them (#729).
320 + *
321 + * @since 2.11.0
322 + * @var string[]
323 + */
324 + public const PLACEMENTS = ['title', 'meta_description', 'slug', 'first_paragraph', 'subheading', 'content', 'image_alt'];
325 +
326 + /**
327 + * Characters of plain text read as the opening when the content has no
328 + * paragraph tag.
329 + *
330 + * @since 2.11.0
331 + */
332 + private const OPENING_CHARS = 300;
333 +
334 + /**
335 + * Where each focus keyword is placed, keyword by keyword (#729).
336 + *
337 + * analyze_keyword_checks() answers "does ANY keyword appear here" for
338 + * five places; this answers "where does THIS keyword appear" for seven,
339 + * so the editor can show each keyword's own gauge. Same matcher, so a
340 + * keyword counts as a word (not a fragment) and a keyword in a script
341 + * written without spaces (Thai, Chinese, Japanese) still matches.
342 + *
343 + * `where` names the heading or alt text that matched, so the editor can
344 + * say which one.
345 + *
346 + * @since 2.11.0
347 + *
348 + * @param array $content_data Content analysis data.
349 + * @param array $metadata Post metadata (title, description).
350 + * @param string[] $keywords Focus keywords.
351 + * @return array<int, array{keyword: string, passed: int, total: int, placements: array<string, array{passed: bool, where: string}>}>
352 + */
353 + public function keyword_placements(array $content_data, array $metadata, array $keywords): array {
354 + $html = (string) ($content_data['content'] ?? '');
355 + $plain = self::lower(self::plain_text($html));
356 +
357 + $headings = [];
358 + $source = isset($content_data['headings']) && is_array($content_data['headings']) ? $content_data['headings'] : $this->extract_headings($html);
359 + foreach ($source as $heading) {
360 + $text = trim((string) ($heading['text'] ?? ''));
361 + if ((int) ($heading['level'] ?? 0) >= 2 && '' !== $text) {
362 + $headings[] = $text;
363 + }
364 + }
365 +
366 + $alts = [];
367 + foreach ((array) ($content_data['images'] ?? []) as $image) {
368 + $alt = trim((string) ($image['alt'] ?? ''));
369 + if ('' !== $alt) {
370 + $alts[] = $alt;
371 + }
372 + }
373 +
374 + $single = [
375 + 'title' => self::lower((string) ($metadata['title'] ?? $content_data['title'] ?? '')),
376 + 'meta_description' => self::lower((string) ($metadata['description'] ?? '')),
377 + 'slug' => self::lower(self::slug_haystack($content_data)),
378 + 'first_paragraph' => self::lower(self::opening($html)),
379 + 'content' => $plain,
380 + ];
381 +
382 + $out = [];
383 + foreach ($keywords as $keyword) {
384 + $needle = self::lower(trim((string) $keyword));
385 + $placements = [];
386 +
387 + foreach (self::PLACEMENTS as $placement) {
388 + if ('subheading' === $placement || 'image_alt' === $placement) {
389 + $where = '';
390 + foreach ('subheading' === $placement ? $headings : $alts as $text) {
391 + if ($this->keyword_matches(self::lower($text), $needle)) {
392 + $where = $text;
393 + break;
394 + }
395 + }
396 + $placements[$placement] = ['passed' => '' !== $where, 'where' => $where];
397 + continue;
398 + }
399 +
400 + $placements[$placement] = ['passed' => $this->keyword_matches($single[$placement], $needle), 'where' => ''];
401 + }
402 +
403 + $out[] = [
404 + 'keyword' => (string) $keyword,
405 + 'passed' => count(array_filter(array_column($placements, 'passed'))),
406 + 'total' => count(self::PLACEMENTS),
407 + 'placements' => $placements,
408 + ];
409 + }
410 +
411 + return $out;
412 + }
413 +
414 + /**
415 + * The post's own slug as searchable text: hyphens and underscores become
416 + * spaces so a multi-word keyword can match, and a slug WordPress
417 + * percent-encoded (Thai, Cyrillic, Chinese) is decoded, or it could never
418 + * match a keyword typed in that script.
419 + *
420 + * Never the full URL path: the path carries ancestors, category bases and
421 + * date segments, so a child of /clinical-trials/ reported "keyword in slug"
422 + * for a page actually slugged `contact-us`. An unpublished post has no
423 + * pretty permalink either, so the path held no slug at all.
424 + *
425 + * @param array $content_data Content analysis data.
426 + * @return string
427 + */
428 + private static function slug_haystack(array $content_data): string {
429 + $slug = (string) ($content_data['slug'] ?? '');
430 + if ('' === $slug) {
431 + // Draft with no slug assigned yet: score what WordPress would
432 + // generate from the title, which is what the editor shows as the
433 + // proposed URL — so the check reads the same before and after
434 + // publishing instead of flipping.
435 + $slug = sanitize_title((string) ($content_data['title'] ?? ''));
436 + }
437 +
438 + return trim(str_replace(['-', '_'], ' ', rawurldecode($slug)));
439 + }
440 +
441 + /**
442 + * The opening of the content: its first paragraph with text, or the first
443 + * few hundred characters when it has none. Counted in characters, not
444 + * words, so a language written without spaces is not read as one word.
445 + *
446 + * @param string $html Content HTML.
447 + * @return string Plain text.
448 + */
449 + private static function opening(string $html): string {
450 + if (preg_match_all('/<p\b[^>]*>(.*?)<\/p>/isu', $html, $matches)) {
451 + foreach ($matches[1] as $paragraph) {
452 + $text = self::collapse_whitespace(wp_strip_all_tags($paragraph));
453 + if ('' !== $text) {
454 + return $text;
455 + }
456 + }
457 + }
458 +
459 + $text = self::plain_text($html);
460 +
461 + return function_exists('mb_substr') ? mb_substr($text, 0, self::OPENING_CHARS) : substr($text, 0, self::OPENING_CHARS);
462 + }
463 +
464 + /**
465 + * Content as plain text, with a space where each tag was. wp_strip_all_tags()
466 + * alone joins neighbouring blocks — "…coffee grinder</h3><p>A good…" became
467 + * "coffee grinderA good" — so a keyword at the end of a heading or a
468 + * paragraph was no longer a word and did not match.
469 + *
470 + * @param string $html Content HTML.
471 + * @return string
472 + */
473 + private static function plain_text(string $html): string {
474 + $spaced = preg_replace('/<[^>]+>/', ' $0 ', $html);
475 +
476 + return self::collapse_whitespace(wp_strip_all_tags(null === $spaced ? $html : (string) $spaced));
477 + }
478 +
479 + /**
480 + * Runs of whitespace down to one space.
481 + *
482 + * The `/u` pass is the one that understands a multibyte space, but
483 + * preg_replace() answers null on bytes that are not valid UTF-8 rather than
484 + * throwing — and casting that null to a string blanked the haystack, so a
485 + * post carrying one mojibake byte (a Latin-1 paste, an old import) reported
486 + * every keyword as missing from its body, its opening, and every
487 + * subheading. The gauge said 0/7 and told the author to add a keyword that
488 + * was already there.
489 + *
490 + * Falls back to the byte-wise collapse, which is what this did before the
491 + * multibyte work added the modifier. Same reasoning keyword_matches()
492 + * already records for its own PCRE failure: a pattern PCRE refuses must not
493 + * be reported as a confident "no match".
494 + *
495 + * @param string $text Text to collapse.
496 + * @return string
497 + */
498 + private static function collapse_whitespace(string $text): string {
499 + $collapsed = preg_replace('/\s+/u', ' ', $text);
500 +
501 + if (null === $collapsed) {
502 + $collapsed = preg_replace('/\s+/', ' ', $text);
503 + }
504 +
505 + return trim(null === $collapsed ? $text : (string) $collapsed);
506 + }
507 +
508 + /**
509 + * Lowercase in any script. strtolower() only folds ASCII, so "Кофе" never
510 + * matched "кофе".
511 + *
512 + * @param string $text Text.
513 + * @return string
514 + */
515 + private static function lower(string $text): string {
516 + return function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
517 + }
518 +
519 + /**
520 + * Scripts written without spaces between words.
521 + *
522 + * @since 2.1.0
523 + * @var string
524 + */
525 + private const SCRIPTIO_CONTINUA = '/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}]/u';
526 +
527 + /**
528 + * Whether a keyword appears in a haystack as a word rather than as a
529 + * fragment of a longer one.
530 + *
531 + * The five keyword checks used a plain strpos(), so any substring hit
532 + * counted: "test coronavirus" matched "la|test coronavirus|news", "art"
533 + * matched "start", "cat" matched "category". The panel then confidently
534 + * reported a keyword placement that does not exist (#416). Same class of
535 + * problem #71 fixed in the Image SEO rewriter, and the same remedy.
536 + *
537 + * Both arguments are expected lowercased already.
538 + *
539 + * @since 2.1.0
540 + *
541 + * @param string $haystack Text to search.
542 + * @param string $needle Keyword, lowercased and trimmed.
543 + * @return bool
544 + */
545 + private function keyword_matches(string $haystack, string $needle): bool {
546 + if ($needle === '' || $haystack === '') {
547 + return false;
548 + }
549 +
550 + if (!$this->supports_word_boundaries($needle)) {
551 + return strpos($haystack, $needle) !== false;
552 + }
553 +
554 + $matched = preg_match('/\b' . preg_quote($needle, '/') . '\b/u', $haystack);
555 +
556 + // PCRE refusing the pattern — invalid UTF-8 in the keyword, a
557 + // backtrack limit — must not be reported as a confident "no match".
558 + // Fall back to the behaviour this replaced rather than invent a
559 + // negative the user cannot explain.
560 + if ($matched === false) {
561 + return strpos($haystack, $needle) !== false;
562 + }
563 +
564 + return $matched === 1;
565 + }
566 +
567 + /**
568 + * Whether \b can express "this keyword, as a word" for this keyword.
569 + *
570 + * It asserts a transition between a word and a non-word character, which
571 + * only means something where words are separated. Two cases where it is
572 + * not, both verified against PCRE rather than assumed:
573 + *
574 + * - the keyword's own edges are not word characters ("c++", "#seo"), so
575 + * no boundary can assert there and a real match is lost;
576 + * - scripts written without spaces, where the neighbouring characters
577 + * are word characters too — "冠状病毒" inside "最新冠状病毒新闻" is a
578 + * legitimate match that \b never sees.
579 + *
580 + * Accented Latin and Cyrillic need no special handling: PHP's /u modifier
581 + * turns on Unicode character properties, so "café" correctly does not
582 + * match "cafés" and "коронавирус" does not match "коронавирусный".
583 + *
584 + * @since 2.1.0
585 + *
586 + * @param string $needle Keyword, lowercased and trimmed.
587 + * @return bool
588 + */
589 + private function supports_word_boundaries(string $needle): bool {
590 + if (preg_match(self::SCRIPTIO_CONTINUA, $needle)) {
591 + return false;
592 + }
593 +
594 + return preg_match('/^\w/u', $needle) === 1 && preg_match('/\w$/u', $needle) === 1;
595 + }
596 +
597 + /**
279 598 * Compute the SEO score for a single target keyword.
280 599 *
281 600 * @param array $content_data Content analysis data
282 601 * @param array $metadata Post metadata
@@ -388,9 +707,9 @@
388 707 $total_score += $technical_result['score'];
389 708 $suggestions = array_merge($suggestions, $technical_result['suggestions']);
390 709
391 710 try {
392 - $prioritized_suggestions = $this->prioritize_suggestions($suggestions);
711 + $prioritized_suggestions = $this->prioritize_suggestions($suggestions, $scores);
393 712 $grade = $this->get_grade_from_score($total_score);
394 713
395 714 return [
396 715 'overall_score' => min(100, $total_score),
@@ -539,9 +858,9 @@
539 858 $title_length = mb_strlen($title);
540 859
541 860 // 2025 length optimization (6 points). 60 characters is the recommended
542 861 // maximum for best SERP visibility before Google truncates the title.
543 - if ($title_length >= 35 && $title_length <= 60) {
862 + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) {
544 863 $score += 6;
545 864 } elseif ($title_length >= 25 && $title_length <= 75) {
546 865 $score += 4;
547 866 $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility';
@@ -815,27 +1134,69 @@
815 1134
816 1135 // Consider it a semantic match if 70% of keyword parts are present
817 1136 return ($matches / count($keyword_parts)) >= 0.7;
818 1137 }
819 - private function prioritize_suggestions(array $suggestions): array {
1138 + private function prioritize_suggestions(array $suggestions, array $scores = []): array {
1139 + // Map each suggestion back to the factor that emitted it, so priority
1140 + // can rank by the points the factor actually lost instead of keyword-
1141 + // matching the advice text — which sorted a 2-point title tweak above
1142 + // a 6-point thin-content loss and contradicted the row's own impact
1143 + // tag (#408).
1144 + $by_text = [];
1145 + foreach ($scores as $factor => $result) {
1146 + if (!is_array($result) || empty($result['suggestions']) || !is_array($result['suggestions'])) {
1147 + continue;
1148 + }
1149 + $lost = max(0, (float) ($result['max_score'] ?? 0) - (float) ($result['score'] ?? 0));
1150 + foreach ($result['suggestions'] as $text) {
1151 + if (is_string($text) && !isset($by_text[$text])) {
1152 + $by_text[$text] = ['factor' => (string) $factor, 'lost' => $lost];
1153 + }
1154 + }
1155 + }
1156 +
820 1157 $prioritized = [];
821 -
1158 +
822 1159 foreach ($suggestions as $suggestion) {
823 - $priority = $this->determine_suggestion_priority($suggestion);
1160 + $origin = $by_text[$suggestion] ?? null;
1161 +
1162 + // A factor already at full marks loses nothing to this advice —
1163 + // it was occupying list positions (sometimes at "High") while
1164 + // recovering zero points. Dropped rather than sorted last.
1165 + if (null !== $origin && $origin['lost'] <= 0) {
1166 + continue;
1167 + }
1168 +
1169 + if (null !== $origin) {
1170 + $priority = $origin['lost'] >= 4 ? 'High' : ($origin['lost'] >= 2 ? 'Medium' : 'Low');
1171 + } else {
1172 + // No factor attached (defensive: a filter-added or legacy
1173 + // suggestion) — the old keyword map is the fallback.
1174 + $priority = $this->determine_suggestion_priority($suggestion);
1175 + }
1176 +
824 1177 $prioritized[] = [
825 1178 'text' => $suggestion,
826 1179 'priority' => $priority,
827 1180 'impact' => $this->estimate_impact($suggestion),
828 1181 'effort' => $this->estimate_effort($suggestion),
1182 + 'factor' => $origin['factor'] ?? null,
1183 + 'points_recoverable' => $origin['lost'] ?? null,
829 1184 ];
830 1185 }
831 -
832 - // Sort by priority (High > Medium > Low)
1186 +
1187 + // Biggest recoverable loss first; keyword-mapped stragglers (no
1188 + // factor) sort within their priority band after the measured rows.
833 1189 usort($prioritized, function($a, $b) {
1190 + $al = $a['points_recoverable'] ?? -1;
1191 + $bl = $b['points_recoverable'] ?? -1;
1192 + if ($al !== $bl) {
1193 + return $bl <=> $al;
1194 + }
834 1195 $priority_order = ['High' => 3, 'Medium' => 2, 'Low' => 1];
835 1196 return $priority_order[$b['priority']] - $priority_order[$a['priority']];
836 1197 });
837 -
1198 +
838 1199 return $prioritized;
839 1200 }
840 1201
841 1202 /**
@@ -1259,9 +1620,24 @@
1259 1620 if (!class_exists('\ThinkRank\SEO\Builder_Content')) {
1260 1621 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1261 1622 }
1262 1623
1263 - return \ThinkRank\SEO\Builder_Content::resolve_markup($live_content, $post);
1624 + // Bind the live markup to the post the render makes current. Since #862
1625 + // the resolver runs setup_postdata() on this post, so a shortcode that
1626 + // builds its output from the current post's content — get_the_content(),
1627 + // get_post()->post_content, as a table of contents or a reading-time
1628 + // shortcode does — otherwise read the last saved body while the unsaved
1629 + // markup rendered around it, and lagged a save behind (#864).
1630 + //
1631 + // A clone, not the caller's object: the post is current only for the
1632 + // duration of the render and the caller's $post must come back
1633 + // unchanged. `thinkrank_analyzable_content` receives the clone too,
1634 + // which is what makes $post->post_content there agree with the markup
1635 + // being analyzed on the live path.
1636 + $bound = clone $post;
1637 + $bound->post_content = $live_content;
1638 +
1639 + return \ThinkRank\SEO\Builder_Content::resolve_markup($live_content, $bound);
1264 1640 }
1265 1641
1266 1642 public function analyze_post_content(int $post_id): array {
1267 1643 $post = get_post($post_id);
@@ -1492,9 +1868,27 @@
1492 1868 $words = preg_split('/\s+/', trim(strtolower(wp_strip_all_tags($text))), -1, PREG_SPLIT_NO_EMPTY);
1493 1869 $syllables = 0;
1494 1870
1495 1871 foreach ($words as $word) {
1496 - $syllables += max(1, preg_match_all('/[aeiouy]+/', $word));
1872 + $word = preg_replace('/[^a-z]/', '', $word);
1873 + if ($word === '') {
1874 + continue;
1875 + }
1876 +
1877 + $groups = preg_match_all('/[aeiouy]+/', $word);
1878 +
1879 + // Standard Flesch heuristic: a trailing silent e does not form a
1880 + // syllable ("make", "time", "these") — but only when a consonant
1881 + // precedes it (a vowel+e ending like "movie" already shares its
1882 + // group) and never for consonant-le ("table"), which does count.
1883 + // Without this the counter inflated syllables/word by ~0.2-0.3 on
1884 + // ordinary prose, driving raw Flesch negative and the UI to a
1885 + // clamped "Very Difficult (0)" (#407).
1886 + if ($groups > 1 && preg_match('/[^aeiouy]e$/', $word) && !str_ends_with($word, 'le')) {
1887 + $groups--;
1888 + }
1889 +
1890 + $syllables += max(1, $groups);
1497 1891 }
1498 1892
1499 1893 return $syllables;
1500 1894 }
@@ -1778,20 +2172,101 @@
1778 2172 * @param array $content_data Content analysis data
1779 2173 * @return array Scoring result
1780 2174 */
1781 2175 private function score_mobile_experience(array $content_data): array {
1782 - // Mobile experience is theme/site-level, not controlled by post content.
1783 - // Award full credit (benefit of the doubt) instead of a fixed partial
1784 - // that caps every post's ceiling.
2176 + $max = $this->scoring_factors['mobile_experience'];
2177 +
2178 + // Mobile experience is theme/site-level, not controlled by post content
2179 + // — but the plugin already measures it. When a mobile Lighthouse score
2180 + // has been collected, score against it; the "benefit of the doubt" below
2181 + // is for sites nobody has measured, not for sites measured as slow.
2182 + $performance_score = $this->measured_performance_score();
2183 +
2184 + if ($performance_score === null) {
2185 + return [
2186 + 'score' => $max,
2187 + 'max_score' => $max,
2188 + 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
2189 + 'details' => ['mobile_score' => 'Assumed adequate', 'measured' => false],
2190 + ];
2191 + }
2192 +
2193 + $score = (int) round($max * $performance_score / 100);
2194 +
1785 2195 return [
1786 - 'score' => $this->scoring_factors['mobile_experience'],
1787 - 'max_score' => $this->scoring_factors['mobile_experience'],
1788 - 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1789 - 'details' => ['mobile_score' => 'Assumed adequate'],
2196 + 'score' => $score,
2197 + 'max_score' => $max,
2198 + 'suggestions' => $score < $max
2199 + ? ['Improve mobile page speed: the last PageSpeed run scored ' . $performance_score . '/100 on mobile']
2200 + : [],
2201 + 'details' => [
2202 + 'mobile_score' => $performance_score,
2203 + 'measured' => true,
2204 + 'source' => 'pagespeed_mobile',
2205 + ],
1790 2206 ];
1791 2207 }
1792 2208
1793 2209 /**
2210 + * The last collected mobile Lighthouse score, or null when unmeasured.
2211 + *
2212 + * Memoised per instance: compute_score() asks twice, and a post-list screen
2213 + * scores a page of posts at a time.
2214 + *
2215 + * Every failure — no performance module, no collected row, an unreadable
2216 + * table — resolves to null, which the callers read as "not measured" and
2217 + * answer with the full-credit fallback. A site is never penalised for
2218 + * ThinkRank being unable to look.
2219 + *
2220 + * @since 2.3.1
2221 + * @return int|null Score 0-100, or null when nothing has been collected.
2222 + */
2223 + private function measured_performance_score(): ?int {
2224 + $measurement = $this->measured_performance();
2225 +
2226 + if ($measurement === null || !isset($measurement['performance_score'])) {
2227 + return null;
2228 + }
2229 +
2230 + $score = $measurement['performance_score'];
2231 +
2232 + if (!is_numeric($score)) {
2233 + return null;
2234 + }
2235 +
2236 + return (int) round(max(0, min(100, (float) $score)));
2237 + }
2238 +
2239 + /**
2240 + * The last collected mobile measurement, or null when there is none.
2241 + *
2242 + * @since 2.3.1
2243 + * @return array|null { core_web_vitals: array, performance_score: float|null }
2244 + */
2245 + private function measured_performance(): ?array {
2246 + if ($this->measured_performance_resolved) {
2247 + return $this->measured_performance;
2248 + }
2249 +
2250 + $this->measured_performance_resolved = true;
2251 +
2252 + if (!class_exists('ThinkRank\\SEO\\Performance_Monitoring_Manager')) {
2253 + return null;
2254 + }
2255 +
2256 + try {
2257 + $manager = new \ThinkRank\SEO\Performance_Monitoring_Manager();
2258 + // Mobile deliberately: Google indexes mobile-first, and it is the
2259 + // device the mobile_experience factor is named after.
2260 + $this->measured_performance = $manager->get_stored_performance_measurement('mobile');
2261 + } catch (\Throwable $e) {
2262 + $this->measured_performance = null;
2263 + }
2264 +
2265 + return $this->measured_performance;
2266 + }
2267 +
2268 + /**
1794 2269 * Score core web vitals - 2025 version (3 points)
1795 2270 *
1796 2271 * @param array $content_data Content analysis data
1797 2272 * @return array Scoring result
@@ -1796,20 +2271,98 @@
1796 2271 * @param array $content_data Content analysis data
1797 2272 * @return array Scoring result
1798 2273 */
1799 2274 private function score_core_web_vitals(array $content_data): array {
1800 - // Core Web Vitals are a runtime/performance signal, not derivable from
1801 - // post content. Award full credit (benefit of the doubt) rather than a
1802 - // fixed partial that caps every post's ceiling.
2275 + $max = $this->scoring_factors['core_web_vitals'];
2276 +
2277 + // Not derivable from post content — but it is measured, and the audit
2278 + // stores LCP, INP and CLS with a rating each. Score against those when
2279 + // they exist; fall back to the benefit of the doubt when they do not.
2280 + $rated = $this->measured_vitals_score();
2281 +
2282 + if ($rated === null) {
2283 + return [
2284 + 'score' => $max,
2285 + 'max_score' => $max,
2286 + 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'],
2287 + 'details' => ['vitals_status' => 'Assumed adequate', 'measured' => false],
2288 + ];
2289 + }
2290 +
2291 + $score = (int) round($max * $rated['average'] / 100);
2292 +
1803 2293 return [
1804 - 'score' => $this->scoring_factors['core_web_vitals'],
1805 - 'max_score' => $this->scoring_factors['core_web_vitals'],
1806 - 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'],
1807 - 'details' => ['vitals_status' => 'Assumed adequate'],
2294 + 'score' => $score,
2295 + 'max_score' => $max,
2296 + // Gated on the measurement, not the rounded score: two good metrics
2297 + // and one needing improvement averages 88.33, which rounds to the
2298 + // full 3 of 3 and used to swallow the suggestion naming the metric
2299 + // that is actually failing.
2300 + 'suggestions' => !empty($rated['failing'])
2301 + ? ['Optimize Core Web Vitals: ' . implode(', ', $rated['failing']) . ' below target on mobile']
2302 + : [],
2303 + 'details' => [
2304 + 'vitals_status' => $rated['statuses'],
2305 + 'measured' => true,
2306 + 'source' => 'pagespeed_mobile',
2307 + ],
1808 2308 ];
1809 2309 }
1810 2310
1811 2311 /**
2312 + * Rate the collected Core Web Vitals, or null when none were measured.
2313 + *
2314 + * Reuses the per-metric score the performance module already assigns
2315 + * (good 100, needs improvement 65, poor 30) rather than inventing a second
2316 + * scale, so the SEO score and the performance card cannot disagree about
2317 + * whether a metric is healthy.
2318 + *
2319 + * Metrics with no stored value — fcp is not always collected — are skipped
2320 + * rather than counted as failures.
2321 + *
2322 + * @since 2.3.1
2323 + * @return array|null { average: float, statuses: array, failing: string[] }
2324 + */
2325 + private function measured_vitals_score(): ?array {
2326 + $measurement = $this->measured_performance();
2327 + $vitals = $measurement['core_web_vitals'] ?? null;
2328 +
2329 + if (!is_array($vitals)) {
2330 + return null;
2331 + }
2332 +
2333 + $scores = [];
2334 + $statuses = [];
2335 + $failing = [];
2336 +
2337 + // The three Google ranks on. fcp is diagnostic and not a Core Web Vital.
2338 + foreach (['lcp', 'inp', 'cls'] as $metric) {
2339 + $data = $vitals[$metric] ?? null;
2340 +
2341 + if (!is_array($data) || !isset($data['value'], $data['score']) || $data['value'] === null) {
2342 + continue;
2343 + }
2344 +
2345 + $scores[] = (float) $data['score'];
2346 + $statuses[$metric] = $data['status'] ?? 'unknown';
2347 +
2348 + if (($data['status'] ?? '') !== 'good') {
2349 + $failing[] = strtoupper($metric);
2350 + }
2351 + }
2352 +
2353 + if (empty($scores)) {
2354 + return null;
2355 + }
2356 +
2357 + return [
2358 + 'average' => array_sum($scores) / count($scores),
2359 + 'statuses' => $statuses,
2360 + 'failing' => $failing,
2361 + ];
2362 + }
2363 +
2364 + /**
1812 2365 * Score internal linking - declining importance (1 point)
1813 2366 *
1814 2367 * @param array $content_data Content analysis data
1815 2368 * @return array Scoring result
@@ -1839,9 +2392,9 @@
1839 2392 $suggestions = [];
1840 2393
1841 2394 // Meta description check
1842 2395 $meta_desc = $metadata['description'] ?? '';
1843 - if (!empty($meta_desc) && mb_strlen($meta_desc) >= 120 && mb_strlen($meta_desc) <= 160) {
2396 + if (!empty($meta_desc) && mb_strlen($meta_desc) >= self::DESCRIPTION_OPTIMAL_MIN && mb_strlen($meta_desc) <= self::DESCRIPTION_OPTIMAL_MAX) {
1844 2397 $score += 0.5;
1845 2398 } else {
1846 2399 $suggestions[] = 'Add a compelling meta description (120-160 characters)';
1847 2400 }