| @@ -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,15 +269,15 @@ | ||
| 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 | 282 | // Build a searchable slug haystack from the post's OWN slug — never the |
| 248 | 283 | // full URL path. The path carries ancestors, category bases and date |
| @@ -251,17 +286,9 @@ | ||
| 251 | 286 | // way: an unpublished post has no pretty permalink (get_permalink() |
| 252 | 287 | // returns ?p=123), so the path held no slug at all and every draft |
| 253 | 288 | // scored "no match" until it was published. Hyphens/underscores become |
| 254 | 289 | // spaces so multi-word keywords can match. |
| 255 | - $slug_source = (string) ($content_data['slug'] ?? ''); | |
| 256 | - if ($slug_source === '') { | |
| 257 | - // Draft with no slug assigned yet: score what WordPress would | |
| 258 | - // generate from the title, which is what the editor shows as the | |
| 259 | - // proposed URL — so the check reads the same before and after | |
| 260 | - // publishing instead of flipping. | |
| 261 | - $slug_source = sanitize_title((string) ($content_data['title'] ?? '')); | |
| 262 | - } | |
| 263 | - $slug = strtolower(str_replace(['-', '_'], ' ', $slug_source)); | |
| 290 | + $slug = self::lower(self::slug_haystack($content_data)); | |
| 264 | 291 | |
| 265 | 292 | $haystacks = [ |
| 266 | 293 | 'title' => trim($title), |
| 267 | 294 | 'meta_description' => trim($description), |
| @@ -273,9 +300,9 @@ | ||
| 273 | 300 | $checks = []; |
| 274 | 301 | foreach ($haystacks as $location => $haystack) { |
| 275 | 302 | $matched = []; |
| 276 | 303 | foreach ($keywords as $keyword) { |
| 277 | - if ($this->keyword_matches($haystack, strtolower(trim($keyword)))) { | |
| 304 | + if ($this->keyword_matches($haystack, self::lower(trim($keyword)))) { | |
| 278 | 305 | $matched[] = $keyword; |
| 279 | 306 | } |
| 280 | 307 | } |
| 281 | 308 | $checks[$location] = [ |
| @@ -287,8 +314,210 @@ | ||
| 287 | 314 | return $checks; |
| 288 | 315 | } |
| 289 | 316 | |
| 290 | 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 | + /** | |
| 291 | 520 | * Scripts written without spaces between words. |
| 292 | 521 | * |
| 293 | 522 | * @since 2.1.0 |
| 294 | 523 | * @var string |
| @@ -629,9 +858,9 @@ | ||
| 629 | 858 | $title_length = mb_strlen($title); |
| 630 | 859 | |
| 631 | 860 | // 2025 length optimization (6 points). 60 characters is the recommended |
| 632 | 861 | // maximum for best SERP visibility before Google truncates the title. |
| 633 | - if ($title_length >= 35 && $title_length <= 60) { | |
| 862 | + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) { | |
| 634 | 863 | $score += 6; |
| 635 | 864 | } elseif ($title_length >= 25 && $title_length <= 75) { |
| 636 | 865 | $score += 4; |
| 637 | 866 | $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility'; |
| @@ -1928,20 +2157,101 @@ | ||
| 1928 | 2157 | * @param array $content_data Content analysis data |
| 1929 | 2158 | * @return array Scoring result |
| 1930 | 2159 | */ |
| 1931 | 2160 | 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. | |
| 2161 | + $max = $this->scoring_factors['mobile_experience']; | |
| 2162 | + | |
| 2163 | + // Mobile experience is theme/site-level, not controlled by post content | |
| 2164 | + // — but the plugin already measures it. When a mobile Lighthouse score | |
| 2165 | + // has been collected, score against it; the "benefit of the doubt" below | |
| 2166 | + // is for sites nobody has measured, not for sites measured as slow. | |
| 2167 | + $performance_score = $this->measured_performance_score(); | |
| 2168 | + | |
| 2169 | + if ($performance_score === null) { | |
| 2170 | + return [ | |
| 2171 | + 'score' => $max, | |
| 2172 | + 'max_score' => $max, | |
| 2173 | + 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'], | |
| 2174 | + 'details' => ['mobile_score' => 'Assumed adequate', 'measured' => false], | |
| 2175 | + ]; | |
| 2176 | + } | |
| 2177 | + | |
| 2178 | + $score = (int) round($max * $performance_score / 100); | |
| 2179 | + | |
| 1935 | 2180 | 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'], | |
| 2181 | + 'score' => $score, | |
| 2182 | + 'max_score' => $max, | |
| 2183 | + 'suggestions' => $score < $max | |
| 2184 | + ? ['Improve mobile page speed: the last PageSpeed run scored ' . $performance_score . '/100 on mobile'] | |
| 2185 | + : [], | |
| 2186 | + 'details' => [ | |
| 2187 | + 'mobile_score' => $performance_score, | |
| 2188 | + 'measured' => true, | |
| 2189 | + 'source' => 'pagespeed_mobile', | |
| 2190 | + ], | |
| 1940 | 2191 | ]; |
| 1941 | 2192 | } |
| 1942 | 2193 | |
| 1943 | 2194 | /** |
| 2195 | + * The last collected mobile Lighthouse score, or null when unmeasured. | |
| 2196 | + * | |
| 2197 | + * Memoised per instance: compute_score() asks twice, and a post-list screen | |
| 2198 | + * scores a page of posts at a time. | |
| 2199 | + * | |
| 2200 | + * Every failure — no performance module, no collected row, an unreadable | |
| 2201 | + * table — resolves to null, which the callers read as "not measured" and | |
| 2202 | + * answer with the full-credit fallback. A site is never penalised for | |
| 2203 | + * ThinkRank being unable to look. | |
| 2204 | + * | |
| 2205 | + * @since 2.3.1 | |
| 2206 | + * @return int|null Score 0-100, or null when nothing has been collected. | |
| 2207 | + */ | |
| 2208 | + private function measured_performance_score(): ?int { | |
| 2209 | + $measurement = $this->measured_performance(); | |
| 2210 | + | |
| 2211 | + if ($measurement === null || !isset($measurement['performance_score'])) { | |
| 2212 | + return null; | |
| 2213 | + } | |
| 2214 | + | |
| 2215 | + $score = $measurement['performance_score']; | |
| 2216 | + | |
| 2217 | + if (!is_numeric($score)) { | |
| 2218 | + return null; | |
| 2219 | + } | |
| 2220 | + | |
| 2221 | + return (int) round(max(0, min(100, (float) $score))); | |
| 2222 | + } | |
| 2223 | + | |
| 2224 | + /** | |
| 2225 | + * The last collected mobile measurement, or null when there is none. | |
| 2226 | + * | |
| 2227 | + * @since 2.3.1 | |
| 2228 | + * @return array|null { core_web_vitals: array, performance_score: float|null } | |
| 2229 | + */ | |
| 2230 | + private function measured_performance(): ?array { | |
| 2231 | + if ($this->measured_performance_resolved) { | |
| 2232 | + return $this->measured_performance; | |
| 2233 | + } | |
| 2234 | + | |
| 2235 | + $this->measured_performance_resolved = true; | |
| 2236 | + | |
| 2237 | + if (!class_exists('ThinkRank\\SEO\\Performance_Monitoring_Manager')) { | |
| 2238 | + return null; | |
| 2239 | + } | |
| 2240 | + | |
| 2241 | + try { | |
| 2242 | + $manager = new \ThinkRank\SEO\Performance_Monitoring_Manager(); | |
| 2243 | + // Mobile deliberately: Google indexes mobile-first, and it is the | |
| 2244 | + // device the mobile_experience factor is named after. | |
| 2245 | + $this->measured_performance = $manager->get_stored_performance_measurement('mobile'); | |
| 2246 | + } catch (\Throwable $e) { | |
| 2247 | + $this->measured_performance = null; | |
| 2248 | + } | |
| 2249 | + | |
| 2250 | + return $this->measured_performance; | |
| 2251 | + } | |
| 2252 | + | |
| 2253 | + /** | |
| 1944 | 2254 | * Score core web vitals - 2025 version (3 points) |
| 1945 | 2255 | * |
| 1946 | 2256 | * @param array $content_data Content analysis data |
| 1947 | 2257 | * @return array Scoring result |
| @@ -1946,20 +2256,98 @@ | ||
| 1946 | 2256 | * @param array $content_data Content analysis data |
| 1947 | 2257 | * @return array Scoring result |
| 1948 | 2258 | */ |
| 1949 | 2259 | 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. | |
| 2260 | + $max = $this->scoring_factors['core_web_vitals']; | |
| 2261 | + | |
| 2262 | + // Not derivable from post content — but it is measured, and the audit | |
| 2263 | + // stores LCP, INP and CLS with a rating each. Score against those when | |
| 2264 | + // they exist; fall back to the benefit of the doubt when they do not. | |
| 2265 | + $rated = $this->measured_vitals_score(); | |
| 2266 | + | |
| 2267 | + if ($rated === null) { | |
| 2268 | + return [ | |
| 2269 | + 'score' => $max, | |
| 2270 | + 'max_score' => $max, | |
| 2271 | + 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'], | |
| 2272 | + 'details' => ['vitals_status' => 'Assumed adequate', 'measured' => false], | |
| 2273 | + ]; | |
| 2274 | + } | |
| 2275 | + | |
| 2276 | + $score = (int) round($max * $rated['average'] / 100); | |
| 2277 | + | |
| 1953 | 2278 | 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'], | |
| 2279 | + 'score' => $score, | |
| 2280 | + 'max_score' => $max, | |
| 2281 | + // Gated on the measurement, not the rounded score: two good metrics | |
| 2282 | + // and one needing improvement averages 88.33, which rounds to the | |
| 2283 | + // full 3 of 3 and used to swallow the suggestion naming the metric | |
| 2284 | + // that is actually failing. | |
| 2285 | + 'suggestions' => !empty($rated['failing']) | |
| 2286 | + ? ['Optimize Core Web Vitals: ' . implode(', ', $rated['failing']) . ' below target on mobile'] | |
| 2287 | + : [], | |
| 2288 | + 'details' => [ | |
| 2289 | + 'vitals_status' => $rated['statuses'], | |
| 2290 | + 'measured' => true, | |
| 2291 | + 'source' => 'pagespeed_mobile', | |
| 2292 | + ], | |
| 1958 | 2293 | ]; |
| 1959 | 2294 | } |
| 1960 | 2295 | |
| 1961 | 2296 | /** |
| 2297 | + * Rate the collected Core Web Vitals, or null when none were measured. | |
| 2298 | + * | |
| 2299 | + * Reuses the per-metric score the performance module already assigns | |
| 2300 | + * (good 100, needs improvement 65, poor 30) rather than inventing a second | |
| 2301 | + * scale, so the SEO score and the performance card cannot disagree about | |
| 2302 | + * whether a metric is healthy. | |
| 2303 | + * | |
| 2304 | + * Metrics with no stored value — fcp is not always collected — are skipped | |
| 2305 | + * rather than counted as failures. | |
| 2306 | + * | |
| 2307 | + * @since 2.3.1 | |
| 2308 | + * @return array|null { average: float, statuses: array, failing: string[] } | |
| 2309 | + */ | |
| 2310 | + private function measured_vitals_score(): ?array { | |
| 2311 | + $measurement = $this->measured_performance(); | |
| 2312 | + $vitals = $measurement['core_web_vitals'] ?? null; | |
| 2313 | + | |
| 2314 | + if (!is_array($vitals)) { | |
| 2315 | + return null; | |
| 2316 | + } | |
| 2317 | + | |
| 2318 | + $scores = []; | |
| 2319 | + $statuses = []; | |
| 2320 | + $failing = []; | |
| 2321 | + | |
| 2322 | + // The three Google ranks on. fcp is diagnostic and not a Core Web Vital. | |
| 2323 | + foreach (['lcp', 'inp', 'cls'] as $metric) { | |
| 2324 | + $data = $vitals[$metric] ?? null; | |
| 2325 | + | |
| 2326 | + if (!is_array($data) || !isset($data['value'], $data['score']) || $data['value'] === null) { | |
| 2327 | + continue; | |
| 2328 | + } | |
| 2329 | + | |
| 2330 | + $scores[] = (float) $data['score']; | |
| 2331 | + $statuses[$metric] = $data['status'] ?? 'unknown'; | |
| 2332 | + | |
| 2333 | + if (($data['status'] ?? '') !== 'good') { | |
| 2334 | + $failing[] = strtoupper($metric); | |
| 2335 | + } | |
| 2336 | + } | |
| 2337 | + | |
| 2338 | + if (empty($scores)) { | |
| 2339 | + return null; | |
| 2340 | + } | |
| 2341 | + | |
| 2342 | + return [ | |
| 2343 | + 'average' => array_sum($scores) / count($scores), | |
| 2344 | + 'statuses' => $statuses, | |
| 2345 | + 'failing' => $failing, | |
| 2346 | + ]; | |
| 2347 | + } | |
| 2348 | + | |
| 2349 | + /** | |
| 1962 | 2350 | * Score internal linking - declining importance (1 point) |
| 1963 | 2351 | * |
| 1964 | 2352 | * @param array $content_data Content analysis data |
| 1965 | 2353 | * @return array Scoring result |
| @@ -1989,9 +2377,9 @@ | ||
| 1989 | 2377 | $suggestions = []; |
| 1990 | 2378 | |
| 1991 | 2379 | // Meta description check |
| 1992 | 2380 | $meta_desc = $metadata['description'] ?? ''; |
| 1993 | - if (!empty($meta_desc) && mb_strlen($meta_desc) >= 120 && mb_strlen($meta_desc) <= 160) { | |
| 2381 | + if (!empty($meta_desc) && mb_strlen($meta_desc) >= self::DESCRIPTION_OPTIMAL_MIN && mb_strlen($meta_desc) <= self::DESCRIPTION_OPTIMAL_MAX) { | |
| 1994 | 2382 | $score += 0.5; |
| 1995 | 2383 | } else { |
| 1996 | 2384 | $suggestions[] = 'Add a compelling meta description (120-160 characters)'; |
| 1997 | 2385 | } |