| @@ -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,10 +300,9 @@ | ||
| 273 | 300 | $checks = []; |
| 274 | 301 | foreach ($haystacks as $location => $haystack) { |
| 275 | 302 | $matched = []; |
| 276 | 303 | foreach ($keywords as $keyword) { |
| 277 | - $needle = strtolower(trim($keyword)); | |
| 278 | - if ($needle !== '' && $haystack !== '' && strpos($haystack, $needle) !== false) { | |
| 304 | + if ($this->keyword_matches($haystack, self::lower(trim($keyword)))) { | |
| 279 | 305 | $matched[] = $keyword; |
| 280 | 306 | } |
| 281 | 307 | } |
| 282 | 308 | $checks[$location] = [ |
| @@ -288,8 +314,288 @@ | ||
| 288 | 314 | return $checks; |
| 289 | 315 | } |
| 290 | 316 | |
| 291 | 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 | + /** | |
| 292 | 598 | * Compute the SEO score for a single target keyword. |
| 293 | 599 | * |
| 294 | 600 | * @param array $content_data Content analysis data |
| 295 | 601 | * @param array $metadata Post metadata |
| @@ -552,9 +858,9 @@ | ||
| 552 | 858 | $title_length = mb_strlen($title); |
| 553 | 859 | |
| 554 | 860 | // 2025 length optimization (6 points). 60 characters is the recommended |
| 555 | 861 | // maximum for best SERP visibility before Google truncates the title. |
| 556 | - if ($title_length >= 35 && $title_length <= 60) { | |
| 862 | + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) { | |
| 557 | 863 | $score += 6; |
| 558 | 864 | } elseif ($title_length >= 25 && $title_length <= 75) { |
| 559 | 865 | $score += 4; |
| 560 | 866 | $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility'; |
| @@ -1851,20 +2157,101 @@ | ||
| 1851 | 2157 | * @param array $content_data Content analysis data |
| 1852 | 2158 | * @return array Scoring result |
| 1853 | 2159 | */ |
| 1854 | 2160 | 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. | |
| 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 | + | |
| 1858 | 2180 | 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'], | |
| 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 | + ], | |
| 1863 | 2191 | ]; |
| 1864 | 2192 | } |
| 1865 | 2193 | |
| 1866 | 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 | + /** | |
| 1867 | 2254 | * Score core web vitals - 2025 version (3 points) |
| 1868 | 2255 | * |
| 1869 | 2256 | * @param array $content_data Content analysis data |
| 1870 | 2257 | * @return array Scoring result |
| @@ -1869,20 +2256,98 @@ | ||
| 1869 | 2256 | * @param array $content_data Content analysis data |
| 1870 | 2257 | * @return array Scoring result |
| 1871 | 2258 | */ |
| 1872 | 2259 | 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. | |
| 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 | + | |
| 1876 | 2278 | 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'], | |
| 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 | + ], | |
| 1881 | 2293 | ]; |
| 1882 | 2294 | } |
| 1883 | 2295 | |
| 1884 | 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 | + /** | |
| 1885 | 2350 | * Score internal linking - declining importance (1 point) |
| 1886 | 2351 | * |
| 1887 | 2352 | * @param array $content_data Content analysis data |
| 1888 | 2353 | * @return array Scoring result |
| @@ -1912,9 +2377,9 @@ | ||
| 1912 | 2377 | $suggestions = []; |
| 1913 | 2378 | |
| 1914 | 2379 | // Meta description check |
| 1915 | 2380 | $meta_desc = $metadata['description'] ?? ''; |
| 1916 | - 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) { | |
| 1917 | 2382 | $score += 0.5; |
| 1918 | 2383 | } else { |
| 1919 | 2384 | $suggestions[] = 'Add a compelling meta description (120-160 characters)'; |
| 1920 | 2385 | } |