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 +287 -18 2.0.2 → 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
@@ -273,10 +306,9 @@
273 306 $checks = [];
274 307 foreach ($haystacks as $location => $haystack) {
275 308 $matched = [];
276 309 foreach ($keywords as $keyword) {
277 - $needle = strtolower(trim($keyword));
278 - if ($needle !== '' && $haystack !== '' && strpos($haystack, $needle) !== false) {
310 + if ($this->keyword_matches($haystack, strtolower(trim($keyword)))) {
279 311 $matched[] = $keyword;
280 312 }
281 313 }
282 314 $checks[$location] = [
@@ -288,8 +320,86 @@
288 320 return $checks;
289 321 }
290 322
291 323 /**
324 + * Scripts written without spaces between words.
325 + *
326 + * @since 2.1.0
327 + * @var string
328 + */
329 + private const SCRIPTIO_CONTINUA = '/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Lao}\p{Khmer}\p{Myanmar}]/u';
330 +
331 + /**
332 + * Whether a keyword appears in a haystack as a word rather than as a
333 + * fragment of a longer one.
334 + *
335 + * The five keyword checks used a plain strpos(), so any substring hit
336 + * counted: "test coronavirus" matched "la|test coronavirus|news", "art"
337 + * matched "start", "cat" matched "category". The panel then confidently
338 + * reported a keyword placement that does not exist (#416). Same class of
339 + * problem #71 fixed in the Image SEO rewriter, and the same remedy.
340 + *
341 + * Both arguments are expected lowercased already.
342 + *
343 + * @since 2.1.0
344 + *
345 + * @param string $haystack Text to search.
346 + * @param string $needle Keyword, lowercased and trimmed.
347 + * @return bool
348 + */
349 + private function keyword_matches(string $haystack, string $needle): bool {
350 + if ($needle === '' || $haystack === '') {
351 + return false;
352 + }
353 +
354 + if (!$this->supports_word_boundaries($needle)) {
355 + return strpos($haystack, $needle) !== false;
356 + }
357 +
358 + $matched = preg_match('/\b' . preg_quote($needle, '/') . '\b/u', $haystack);
359 +
360 + // PCRE refusing the pattern — invalid UTF-8 in the keyword, a
361 + // backtrack limit — must not be reported as a confident "no match".
362 + // Fall back to the behaviour this replaced rather than invent a
363 + // negative the user cannot explain.
364 + if ($matched === false) {
365 + return strpos($haystack, $needle) !== false;
366 + }
367 +
368 + return $matched === 1;
369 + }
370 +
371 + /**
372 + * Whether \b can express "this keyword, as a word" for this keyword.
373 + *
374 + * It asserts a transition between a word and a non-word character, which
375 + * only means something where words are separated. Two cases where it is
376 + * not, both verified against PCRE rather than assumed:
377 + *
378 + * - the keyword's own edges are not word characters ("c++", "#seo"), so
379 + * no boundary can assert there and a real match is lost;
380 + * - scripts written without spaces, where the neighbouring characters
381 + * are word characters too — "冠状病毒" inside "最新冠状病毒新闻" is a
382 + * legitimate match that \b never sees.
383 + *
384 + * Accented Latin and Cyrillic need no special handling: PHP's /u modifier
385 + * turns on Unicode character properties, so "café" correctly does not
386 + * match "cafés" and "коронавирус" does not match "коронавирусный".
387 + *
388 + * @since 2.1.0
389 + *
390 + * @param string $needle Keyword, lowercased and trimmed.
391 + * @return bool
392 + */
393 + private function supports_word_boundaries(string $needle): bool {
394 + if (preg_match(self::SCRIPTIO_CONTINUA, $needle)) {
395 + return false;
396 + }
397 +
398 + return preg_match('/^\w/u', $needle) === 1 && preg_match('/\w$/u', $needle) === 1;
399 + }
400 +
401 + /**
292 402 * Compute the SEO score for a single target keyword.
293 403 *
294 404 * @param array $content_data Content analysis data
295 405 * @param array $metadata Post metadata
@@ -552,9 +662,9 @@
552 662 $title_length = mb_strlen($title);
553 663
554 664 // 2025 length optimization (6 points). 60 characters is the recommended
555 665 // maximum for best SERP visibility before Google truncates the title.
556 - if ($title_length >= 35 && $title_length <= 60) {
666 + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) {
557 667 $score += 6;
558 668 } elseif ($title_length >= 25 && $title_length <= 75) {
559 669 $score += 4;
560 670 $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility';
@@ -1851,20 +1961,101 @@
1851 1961 * @param array $content_data Content analysis data
1852 1962 * @return array Scoring result
1853 1963 */
1854 1964 private function score_mobile_experience(array $content_data): array {
1855 - // Mobile experience is theme/site-level, not controlled by post content.
1856 - // Award full credit (benefit of the doubt) instead of a fixed partial
1857 - // 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 +
1858 1984 return [
1859 - 'score' => $this->scoring_factors['mobile_experience'],
1860 - 'max_score' => $this->scoring_factors['mobile_experience'],
1861 - 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1862 - '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 + ],
1863 1995 ];
1864 1996 }
1865 1997
1866 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 + /**
1867 2058 * Score core web vitals - 2025 version (3 points)
1868 2059 *
1869 2060 * @param array $content_data Content analysis data
1870 2061 * @return array Scoring result
@@ -1869,20 +2060,98 @@
1869 2060 * @param array $content_data Content analysis data
1870 2061 * @return array Scoring result
1871 2062 */
1872 2063 private function score_core_web_vitals(array $content_data): array {
1873 - // Core Web Vitals are a runtime/performance signal, not derivable from
1874 - // post content. Award full credit (benefit of the doubt) rather than a
1875 - // 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 +
1876 2082 return [
1877 - 'score' => $this->scoring_factors['core_web_vitals'],
1878 - 'max_score' => $this->scoring_factors['core_web_vitals'],
1879 - 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'],
1880 - '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 + ],
1881 2097 ];
1882 2098 }
1883 2099
1884 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 + /**
1885 2154 * Score internal linking - declining importance (1 point)
1886 2155 *
1887 2156 * @param array $content_data Content analysis data
1888 2157 * @return array Scoring result
@@ -1912,9 +2181,9 @@
1912 2181 $suggestions = [];
1913 2182
1914 2183 // Meta description check
1915 2184 $meta_desc = $metadata['description'] ?? '';
1916 - 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) {
1917 2186 $score += 0.5;
1918 2187 } else {
1919 2188 $suggestions[] = 'Add a compelling meta description (120-160 characters)';
1920 2189 }