PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.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 +208 -16 2.3.0 → 2.10.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
@@ -629,9 +662,9 @@
629 662 $title_length = mb_strlen($title);
630 663
631 664 // 2025 length optimization (6 points). 60 characters is the recommended
632 665 // maximum for best SERP visibility before Google truncates the title.
633 - if ($title_length >= 35 && $title_length <= 60) {
666 + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) {
634 667 $score += 6;
635 668 } elseif ($title_length >= 25 && $title_length <= 75) {
636 669 $score += 4;
637 670 $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility';
@@ -1928,20 +1961,101 @@
1928 1961 * @param array $content_data Content analysis data
1929 1962 * @return array Scoring result
1930 1963 */
1931 1964 private function score_mobile_experience(array $content_data): array {
1932 - // Mobile experience is theme/site-level, not controlled by post content.
1933 - // Award full credit (benefit of the doubt) instead of a fixed partial
1934 - // that caps every post's ceiling.
1965 + $max = $this->scoring_factors['mobile_experience'];
1966 +
1967 + // Mobile experience is theme/site-level, not controlled by post content
1968 + // — but the plugin already measures it. When a mobile Lighthouse score
1969 + // has been collected, score against it; the "benefit of the doubt" below
1970 + // is for sites nobody has measured, not for sites measured as slow.
1971 + $performance_score = $this->measured_performance_score();
1972 +
1973 + if ($performance_score === null) {
1974 + return [
1975 + 'score' => $max,
1976 + 'max_score' => $max,
1977 + 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1978 + 'details' => ['mobile_score' => 'Assumed adequate', 'measured' => false],
1979 + ];
1980 + }
1981 +
1982 + $score = (int) round($max * $performance_score / 100);
1983 +
1935 1984 return [
1936 - 'score' => $this->scoring_factors['mobile_experience'],
1937 - 'max_score' => $this->scoring_factors['mobile_experience'],
1938 - 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1939 - 'details' => ['mobile_score' => 'Assumed adequate'],
1985 + 'score' => $score,
1986 + 'max_score' => $max,
1987 + 'suggestions' => $score < $max
1988 + ? ['Improve mobile page speed: the last PageSpeed run scored ' . $performance_score . '/100 on mobile']
1989 + : [],
1990 + 'details' => [
1991 + 'mobile_score' => $performance_score,
1992 + 'measured' => true,
1993 + 'source' => 'pagespeed_mobile',
1994 + ],
1940 1995 ];
1941 1996 }
1942 1997
1943 1998 /**
1999 + * The last collected mobile Lighthouse score, or null when unmeasured.
2000 + *
2001 + * Memoised per instance: compute_score() asks twice, and a post-list screen
2002 + * scores a page of posts at a time.
2003 + *
2004 + * Every failure — no performance module, no collected row, an unreadable
2005 + * table — resolves to null, which the callers read as "not measured" and
2006 + * answer with the full-credit fallback. A site is never penalised for
2007 + * ThinkRank being unable to look.
2008 + *
2009 + * @since 2.3.1
2010 + * @return int|null Score 0-100, or null when nothing has been collected.
2011 + */
2012 + private function measured_performance_score(): ?int {
2013 + $measurement = $this->measured_performance();
2014 +
2015 + if ($measurement === null || !isset($measurement['performance_score'])) {
2016 + return null;
2017 + }
2018 +
2019 + $score = $measurement['performance_score'];
2020 +
2021 + if (!is_numeric($score)) {
2022 + return null;
2023 + }
2024 +
2025 + return (int) round(max(0, min(100, (float) $score)));
2026 + }
2027 +
2028 + /**
2029 + * The last collected mobile measurement, or null when there is none.
2030 + *
2031 + * @since 2.3.1
2032 + * @return array|null { core_web_vitals: array, performance_score: float|null }
2033 + */
2034 + private function measured_performance(): ?array {
2035 + if ($this->measured_performance_resolved) {
2036 + return $this->measured_performance;
2037 + }
2038 +
2039 + $this->measured_performance_resolved = true;
2040 +
2041 + if (!class_exists('ThinkRank\\SEO\\Performance_Monitoring_Manager')) {
2042 + return null;
2043 + }
2044 +
2045 + try {
2046 + $manager = new \ThinkRank\SEO\Performance_Monitoring_Manager();
2047 + // Mobile deliberately: Google indexes mobile-first, and it is the
2048 + // device the mobile_experience factor is named after.
2049 + $this->measured_performance = $manager->get_stored_performance_measurement('mobile');
2050 + } catch (\Throwable $e) {
2051 + $this->measured_performance = null;
2052 + }
2053 +
2054 + return $this->measured_performance;
2055 + }
2056 +
2057 + /**
1944 2058 * Score core web vitals - 2025 version (3 points)
1945 2059 *
1946 2060 * @param array $content_data Content analysis data
1947 2061 * @return array Scoring result
@@ -1946,20 +2060,98 @@
1946 2060 * @param array $content_data Content analysis data
1947 2061 * @return array Scoring result
1948 2062 */
1949 2063 private function score_core_web_vitals(array $content_data): array {
1950 - // Core Web Vitals are a runtime/performance signal, not derivable from
1951 - // post content. Award full credit (benefit of the doubt) rather than a
1952 - // fixed partial that caps every post's ceiling.
2064 + $max = $this->scoring_factors['core_web_vitals'];
2065 +
2066 + // Not derivable from post content — but it is measured, and the audit
2067 + // stores LCP, INP and CLS with a rating each. Score against those when
2068 + // they exist; fall back to the benefit of the doubt when they do not.
2069 + $rated = $this->measured_vitals_score();
2070 +
2071 + if ($rated === null) {
2072 + return [
2073 + 'score' => $max,
2074 + 'max_score' => $max,
2075 + 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'],
2076 + 'details' => ['vitals_status' => 'Assumed adequate', 'measured' => false],
2077 + ];
2078 + }
2079 +
2080 + $score = (int) round($max * $rated['average'] / 100);
2081 +
1953 2082 return [
1954 - 'score' => $this->scoring_factors['core_web_vitals'],
1955 - 'max_score' => $this->scoring_factors['core_web_vitals'],
1956 - 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'],
1957 - 'details' => ['vitals_status' => 'Assumed adequate'],
2083 + 'score' => $score,
2084 + 'max_score' => $max,
2085 + // Gated on the measurement, not the rounded score: two good metrics
2086 + // and one needing improvement averages 88.33, which rounds to the
2087 + // full 3 of 3 and used to swallow the suggestion naming the metric
2088 + // that is actually failing.
2089 + 'suggestions' => !empty($rated['failing'])
2090 + ? ['Optimize Core Web Vitals: ' . implode(', ', $rated['failing']) . ' below target on mobile']
2091 + : [],
2092 + 'details' => [
2093 + 'vitals_status' => $rated['statuses'],
2094 + 'measured' => true,
2095 + 'source' => 'pagespeed_mobile',
2096 + ],
1958 2097 ];
1959 2098 }
1960 2099
1961 2100 /**
2101 + * Rate the collected Core Web Vitals, or null when none were measured.
2102 + *
2103 + * Reuses the per-metric score the performance module already assigns
2104 + * (good 100, needs improvement 65, poor 30) rather than inventing a second
2105 + * scale, so the SEO score and the performance card cannot disagree about
2106 + * whether a metric is healthy.
2107 + *
2108 + * Metrics with no stored value — fcp is not always collected — are skipped
2109 + * rather than counted as failures.
2110 + *
2111 + * @since 2.3.1
2112 + * @return array|null { average: float, statuses: array, failing: string[] }
2113 + */
2114 + private function measured_vitals_score(): ?array {
2115 + $measurement = $this->measured_performance();
2116 + $vitals = $measurement['core_web_vitals'] ?? null;
2117 +
2118 + if (!is_array($vitals)) {
2119 + return null;
2120 + }
2121 +
2122 + $scores = [];
2123 + $statuses = [];
2124 + $failing = [];
2125 +
2126 + // The three Google ranks on. fcp is diagnostic and not a Core Web Vital.
2127 + foreach (['lcp', 'inp', 'cls'] as $metric) {
2128 + $data = $vitals[$metric] ?? null;
2129 +
2130 + if (!is_array($data) || !isset($data['value'], $data['score']) || $data['value'] === null) {
2131 + continue;
2132 + }
2133 +
2134 + $scores[] = (float) $data['score'];
2135 + $statuses[$metric] = $data['status'] ?? 'unknown';
2136 +
2137 + if (($data['status'] ?? '') !== 'good') {
2138 + $failing[] = strtoupper($metric);
2139 + }
2140 + }
2141 +
2142 + if (empty($scores)) {
2143 + return null;
2144 + }
2145 +
2146 + return [
2147 + 'average' => array_sum($scores) / count($scores),
2148 + 'statuses' => $statuses,
2149 + 'failing' => $failing,
2150 + ];
2151 + }
2152 +
2153 + /**
1962 2154 * Score internal linking - declining importance (1 point)
1963 2155 *
1964 2156 * @param array $content_data Content analysis data
1965 2157 * @return array Scoring result
@@ -1989,9 +2181,9 @@
1989 2181 $suggestions = [];
1990 2182
1991 2183 // Meta description check
1992 2184 $meta_desc = $metadata['description'] ?? '';
1993 - if (!empty($meta_desc) && mb_strlen($meta_desc) >= 120 && mb_strlen($meta_desc) <= 160) {
2185 + if (!empty($meta_desc) && mb_strlen($meta_desc) >= self::DESCRIPTION_OPTIMAL_MIN && mb_strlen($meta_desc) <= self::DESCRIPTION_OPTIMAL_MAX) {
1994 2186 $score += 0.5;
1995 2187 } else {
1996 2188 $suggestions[] = 'Add a compelling meta description (120-160 characters)';
1997 2189 }