| @@ -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 |
| @@ -281,8 +600,10 @@ | ||
| 281 | 600 | * @param array $content_data Content analysis data |
| 282 | 601 | * @param array $metadata Post metadata |
| 283 | 602 | * @param array $options Additional options (expects scalar target_keyword) |
| 284 | 603 | * @return array Complete scoring result |
| 604 | + * | |
| 605 | + * @throws \Exception On failure. | |
| 285 | 606 | */ |
| 286 | 607 | private function compute_score(array $content_data, array $metadata, array $options = []): array { |
| 287 | 608 | $scores = []; |
| 288 | 609 | $suggestions = []; |
| @@ -386,9 +707,9 @@ | ||
| 386 | 707 | $total_score += $technical_result['score']; |
| 387 | 708 | $suggestions = array_merge($suggestions, $technical_result['suggestions']); |
| 388 | 709 | |
| 389 | 710 | try { |
| 390 | - $prioritized_suggestions = $this->prioritize_suggestions($suggestions); | |
| 711 | + $prioritized_suggestions = $this->prioritize_suggestions($suggestions, $scores); | |
| 391 | 712 | $grade = $this->get_grade_from_score($total_score); |
| 392 | 713 | |
| 393 | 714 | return [ |
| 394 | 715 | 'overall_score' => min(100, $total_score), |
| @@ -443,9 +764,9 @@ | ||
| 443 | 764 | } elseif ($word_count >= 300) { |
| 444 | 765 | $score += 3; |
| 445 | 766 | $suggestions[] = 'Content is thin - aim for 600+ words minimum'; |
| 446 | 767 | } else { |
| 447 | - $score += 1; | |
| 768 | + $score++; | |
| 448 | 769 | $suggestions[] = 'Content too shallow - Google prioritizes comprehensive, satisfying content'; |
| 449 | 770 | } |
| 450 | 771 | |
| 451 | 772 | // Keyword presence & placement (8 points) - deterministic, replaces the |
| @@ -505,12 +826,16 @@ | ||
| 505 | 826 | * @param int $word_count Word count |
| 506 | 827 | * @return string Depth assessment |
| 507 | 828 | */ |
| 508 | 829 | private function assess_content_depth_2025(int $word_count): string { |
| 509 | - if ($word_count >= 3000) return 'Comprehensive'; | |
| 510 | - if ($word_count >= 2000) return 'Detailed'; | |
| 511 | - if ($word_count >= 1200) return 'Adequate'; | |
| 512 | - if ($word_count >= 800) return 'Basic'; | |
| 830 | + if ($word_count >= 3000) { return 'Comprehensive'; | |
| 831 | + } | |
| 832 | + if ($word_count >= 2000) { return 'Detailed'; | |
| 833 | + } | |
| 834 | + if ($word_count >= 1200) { return 'Adequate'; | |
| 835 | + } | |
| 836 | + if ($word_count >= 800) { return 'Basic'; | |
| 837 | + } | |
| 513 | 838 | return 'Insufficient'; |
| 514 | 839 | } |
| 515 | 840 | |
| 516 | 841 | /** |
| @@ -533,15 +858,15 @@ | ||
| 533 | 858 | $title_length = mb_strlen($title); |
| 534 | 859 | |
| 535 | 860 | // 2025 length optimization (6 points). 60 characters is the recommended |
| 536 | 861 | // maximum for best SERP visibility before Google truncates the title. |
| 537 | - if ($title_length >= 35 && $title_length <= 60) { | |
| 862 | + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) { | |
| 538 | 863 | $score += 6; |
| 539 | 864 | } elseif ($title_length >= 25 && $title_length <= 75) { |
| 540 | 865 | $score += 4; |
| 541 | 866 | $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility'; |
| 542 | 867 | } else { |
| 543 | - $score += 1; | |
| 868 | + $score++; | |
| 544 | 869 | $suggestions[] = $title_length < 25 ? |
| 545 | 870 | 'Title too short - aim for 35-60 characters' : |
| 546 | 871 | 'Title too long - risk truncation in search results'; |
| 547 | 872 | } |
| @@ -589,14 +914,14 @@ | ||
| 589 | 914 | $has_power_word = $this->title_has_power_word($title); |
| 590 | 915 | $has_sentiment = $this->title_has_sentiment_word($title); |
| 591 | 916 | |
| 592 | 917 | if ($has_number || $has_power_word) { |
| 593 | - $score += 1; | |
| 918 | + $score++; | |
| 594 | 919 | } else { |
| 595 | 920 | $suggestions[] = 'Add a number or a power word to the title to boost click-through rate'; |
| 596 | 921 | } |
| 597 | 922 | if ($has_sentiment) { |
| 598 | - $score += 1; | |
| 923 | + $score++; | |
| 599 | 924 | } else { |
| 600 | 925 | $suggestions[] = 'Use an emotional/sentiment word in the title to make it more compelling'; |
| 601 | 926 | } |
| 602 | 927 | |
| @@ -671,9 +996,9 @@ | ||
| 671 | 996 | |
| 672 | 997 | // A slug under ~75 chars keeps the URL clean and fully visible in SERPs. |
| 673 | 998 | $slug_length = strlen($slug); |
| 674 | 999 | if ($slug === '' || $slug_length <= 75) { |
| 675 | - $score += 1; | |
| 1000 | + $score++; | |
| 676 | 1001 | } else { |
| 677 | 1002 | $suggestions[] = 'Shorten the URL slug - long URLs are harder to read and share'; |
| 678 | 1003 | } |
| 679 | 1004 | |
| @@ -809,27 +1134,69 @@ | ||
| 809 | 1134 | |
| 810 | 1135 | // Consider it a semantic match if 70% of keyword parts are present |
| 811 | 1136 | return ($matches / count($keyword_parts)) >= 0.7; |
| 812 | 1137 | } |
| 813 | - 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 | + | |
| 814 | 1157 | $prioritized = []; |
| 815 | - | |
| 1158 | + | |
| 816 | 1159 | foreach ($suggestions as $suggestion) { |
| 817 | - $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 | + | |
| 818 | 1177 | $prioritized[] = [ |
| 819 | 1178 | 'text' => $suggestion, |
| 820 | 1179 | 'priority' => $priority, |
| 821 | 1180 | 'impact' => $this->estimate_impact($suggestion), |
| 822 | 1181 | 'effort' => $this->estimate_effort($suggestion), |
| 1182 | + 'factor' => $origin['factor'] ?? null, | |
| 1183 | + 'points_recoverable' => $origin['lost'] ?? null, | |
| 823 | 1184 | ]; |
| 824 | 1185 | } |
| 825 | - | |
| 826 | - // 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. | |
| 827 | 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 | + } | |
| 828 | 1195 | $priority_order = ['High' => 3, 'Medium' => 2, 'Low' => 1]; |
| 829 | 1196 | return $priority_order[$b['priority']] - $priority_order[$a['priority']]; |
| 830 | 1197 | }); |
| 831 | - | |
| 1198 | + | |
| 832 | 1199 | return $prioritized; |
| 833 | 1200 | } |
| 834 | 1201 | |
| 835 | 1202 | /** |
| @@ -866,11 +1233,14 @@ | ||
| 866 | 1233 | * @return string Impact level |
| 867 | 1234 | */ |
| 868 | 1235 | private function estimate_impact(string $suggestion): string { |
| 869 | 1236 | // Simple heuristic - can be enhanced with ML |
| 870 | - if (strpos(strtolower($suggestion), 'title') !== false) return 'High'; | |
| 871 | - if (strpos(strtolower($suggestion), 'content') !== false) return 'High'; | |
| 872 | - if (strpos(strtolower($suggestion), 'keyword') !== false) return 'Medium'; | |
| 1237 | + if (strpos(strtolower($suggestion), 'title') !== false) { return 'High'; | |
| 1238 | + } | |
| 1239 | + if (strpos(strtolower($suggestion), 'content') !== false) { return 'High'; | |
| 1240 | + } | |
| 1241 | + if (strpos(strtolower($suggestion), 'keyword') !== false) { return 'Medium'; | |
| 1242 | + } | |
| 873 | 1243 | return 'Low'; |
| 874 | 1244 | } |
| 875 | 1245 | |
| 876 | 1246 | /** |
| @@ -880,11 +1250,14 @@ | ||
| 880 | 1250 | * @return string Effort level |
| 881 | 1251 | */ |
| 882 | 1252 | private function estimate_effort(string $suggestion): string { |
| 883 | 1253 | // Simple heuristic - can be enhanced with ML |
| 884 | - if (strpos(strtolower($suggestion), 'rewrite') !== false) return 'High'; | |
| 885 | - if (strpos(strtolower($suggestion), 'add') !== false) return 'Medium'; | |
| 886 | - if (strpos(strtolower($suggestion), 'optimize') !== false) return 'Medium'; | |
| 1254 | + if (strpos(strtolower($suggestion), 'rewrite') !== false) { return 'High'; | |
| 1255 | + } | |
| 1256 | + if (strpos(strtolower($suggestion), 'add') !== false) { return 'Medium'; | |
| 1257 | + } | |
| 1258 | + if (strpos(strtolower($suggestion), 'optimize') !== false) { return 'Medium'; | |
| 1259 | + } | |
| 887 | 1260 | return 'Low'; |
| 888 | 1261 | } |
| 889 | 1262 | private function calculate_topic_relevance(string $content, string $target_keyword): float { |
| 890 | 1263 | if (empty($content) || empty($target_keyword)) { |
| @@ -958,9 +1331,9 @@ | ||
| 958 | 1331 | $terms = array_merge($terms, ['optimization', 'search engine', 'ranking', 'visibility']); |
| 959 | 1332 | } |
| 960 | 1333 | |
| 961 | 1334 | // WordPress-related terms |
| 962 | - if (strpos($keyword_lower, 'wordpress') !== false) { | |
| 1335 | + if (strpos($keyword_lower, 'wordpress') !== false) { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: the haystack is strtolower()ed. | |
| 963 | 1336 | $terms = array_merge($terms, ['wp', 'plugin', 'theme', 'cms']); |
| 964 | 1337 | } |
| 965 | 1338 | |
| 966 | 1339 | return $terms; |
| @@ -1059,9 +1432,12 @@ | ||
| 1059 | 1432 | $ts = strtotime($datetime); |
| 1060 | 1433 | if ($ts === false) { |
| 1061 | 1434 | return null; |
| 1062 | 1435 | } |
| 1063 | - $now = function_exists('current_time') ? (int) current_time('timestamp') : time(); | |
| 1436 | + // strtotime() returns a real unix timestamp, so this must compare against | |
| 1437 | + // one: current_time('timestamp') is offset by the site timezone and made | |
| 1438 | + // every "days ago" figure wrong by that offset. | |
| 1439 | + $now = time(); | |
| 1064 | 1440 | return (int) floor(($now - $ts) / 86400); |
| 1065 | 1441 | } |
| 1066 | 1442 | |
| 1067 | 1443 | /** |
| @@ -1170,12 +1546,16 @@ | ||
| 1170 | 1546 | * @param int $word_count Word count |
| 1171 | 1547 | * @return string Depth assessment |
| 1172 | 1548 | */ |
| 1173 | 1549 | private function assess_content_depth(int $word_count): string { |
| 1174 | - if ($word_count >= 2000) return 'Comprehensive'; | |
| 1175 | - if ($word_count >= 1000) return 'Detailed'; | |
| 1176 | - if ($word_count >= 500) return 'Moderate'; | |
| 1177 | - if ($word_count >= 300) return 'Basic'; | |
| 1550 | + if ($word_count >= 2000) { return 'Comprehensive'; | |
| 1551 | + } | |
| 1552 | + if ($word_count >= 1000) { return 'Detailed'; | |
| 1553 | + } | |
| 1554 | + if ($word_count >= 500) { return 'Moderate'; | |
| 1555 | + } | |
| 1556 | + if ($word_count >= 300) { return 'Basic'; | |
| 1557 | + } | |
| 1178 | 1558 | return 'Insufficient'; |
| 1179 | 1559 | } |
| 1180 | 1560 | private function score_content_freshness(array $content_data): array { |
| 1181 | 1561 | $max_score = $this->scoring_factors['content_freshness']; |
| @@ -1286,9 +1666,11 @@ | ||
| 1286 | 1666 | 'images' => $images, |
| 1287 | 1667 | 'url' => $url, |
| 1288 | 1668 | 'slug' => $post->post_name, |
| 1289 | 1669 | 'post_modified' => $post->post_modified, |
| 1290 | - 'schema_present' => $this->detect_schema_present($content) || $this->thinkrank_global_schema_active($post->post_type), | |
| 1670 | + 'schema_present' => $this->detect_schema_present($content) | |
| 1671 | + || $this->thinkrank_global_schema_active($post->post_type) | |
| 1672 | + || $this->thinkrank_deployed_schema_active($post), | |
| 1291 | 1673 | ]; |
| 1292 | 1674 | } |
| 1293 | 1675 | |
| 1294 | 1676 | /** |
| @@ -1471,9 +1853,27 @@ | ||
| 1471 | 1853 | $words = preg_split('/\s+/', trim(strtolower(wp_strip_all_tags($text))), -1, PREG_SPLIT_NO_EMPTY); |
| 1472 | 1854 | $syllables = 0; |
| 1473 | 1855 | |
| 1474 | 1856 | foreach ($words as $word) { |
| 1475 | - $syllables += max(1, preg_match_all('/[aeiouy]+/', $word)); | |
| 1857 | + $word = preg_replace('/[^a-z]/', '', $word); | |
| 1858 | + if ($word === '') { | |
| 1859 | + continue; | |
| 1860 | + } | |
| 1861 | + | |
| 1862 | + $groups = preg_match_all('/[aeiouy]+/', $word); | |
| 1863 | + | |
| 1864 | + // Standard Flesch heuristic: a trailing silent e does not form a | |
| 1865 | + // syllable ("make", "time", "these") — but only when a consonant | |
| 1866 | + // precedes it (a vowel+e ending like "movie" already shares its | |
| 1867 | + // group) and never for consonant-le ("table"), which does count. | |
| 1868 | + // Without this the counter inflated syllables/word by ~0.2-0.3 on | |
| 1869 | + // ordinary prose, driving raw Flesch negative and the UI to a | |
| 1870 | + // clamped "Very Difficult (0)" (#407). | |
| 1871 | + if ($groups > 1 && preg_match('/[^aeiouy]e$/', $word) && !str_ends_with($word, 'le')) { | |
| 1872 | + $groups--; | |
| 1873 | + } | |
| 1874 | + | |
| 1875 | + $syllables += max(1, $groups); | |
| 1476 | 1876 | } |
| 1477 | 1877 | |
| 1478 | 1878 | return $syllables; |
| 1479 | 1879 | } |
| @@ -1592,8 +1992,45 @@ | ||
| 1592 | 1992 | $all_settings = get_option('thinkrank_global_seo_settings', []); |
| 1593 | 1993 | return !empty($all_settings[$post_type]['schema_type']); |
| 1594 | 1994 | } |
| 1595 | 1995 | |
| 1996 | + /** | |
| 1997 | + * Whether the Schema Manager has an active deployed schema for this post. | |
| 1998 | + * | |
| 1999 | + * Per-post schema deployed from the editor's Schema tab is stored in the | |
| 2000 | + * Schema Manager's own table and emitted at wp_head by | |
| 2001 | + * Frontend\SEO_Manager::output_site_schema_markup(). Neither | |
| 2002 | + * detect_schema_present() (body scan) nor thinkrank_global_schema_active() | |
| 2003 | + * (post-type option) sees it, so without this the score reported "no | |
| 2004 | + * structured data" for posts that do emit it. | |
| 2005 | + * | |
| 2006 | + * Mirrors the context_type whitelist the emitter and the metabox both use, so | |
| 2007 | + * the lookup targets the same row the front end reads. | |
| 2008 | + * | |
| 2009 | + * @param \WP_Post $post Post being scored. | |
| 2010 | + * @return bool | |
| 2011 | + */ | |
| 2012 | + private function thinkrank_deployed_schema_active(\WP_Post $post): bool { | |
| 2013 | + if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) { | |
| 2014 | + $manager_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-management-system.php'; | |
| 2015 | + if (!file_exists($manager_file)) { | |
| 2016 | + return false; | |
| 2017 | + } | |
| 2018 | + require_once $manager_file; | |
| 2019 | + } | |
| 2020 | + | |
| 2021 | + $context_type = in_array($post->post_type, ['site', 'post', 'page', 'product'], true) | |
| 2022 | + ? $post->post_type | |
| 2023 | + : 'post'; | |
| 2024 | + | |
| 2025 | + try { | |
| 2026 | + $manager = new \ThinkRank\SEO\Schema_Management_System(); | |
| 2027 | + return !empty($manager->get_deployed_schemas($context_type, (int) $post->ID)); | |
| 2028 | + } catch (\Throwable $e) { | |
| 2029 | + return false; | |
| 2030 | + } | |
| 2031 | + } | |
| 2032 | + | |
| 1596 | 2033 | private function analyze_images(string $content): array { |
| 1597 | 2034 | $images = []; |
| 1598 | 2035 | |
| 1599 | 2036 | if (preg_match_all('/<img[^>]+>/i', $content, $matches)) { |
| @@ -1635,10 +2072,10 @@ | ||
| 1635 | 2072 | $insert_data = [ |
| 1636 | 2073 | 'post_id' => $post_id, |
| 1637 | 2074 | 'user_id' => $user_id, |
| 1638 | 2075 | 'overall_score' => $score_data['overall_score'], |
| 1639 | - 'score_breakdown' => json_encode($score_data['score_breakdown']), | |
| 1640 | - 'suggestions' => json_encode($score_data['suggestions']), | |
| 2076 | + 'score_breakdown' => wp_json_encode($score_data['score_breakdown']), | |
| 2077 | + 'suggestions' => wp_json_encode($score_data['suggestions']), | |
| 1641 | 2078 | 'grade' => $score_data['grade'], |
| 1642 | 2079 | 'algorithm_version' => $score_data['algorithm_version'] ?? '2024.1', |
| 1643 | 2080 | 'calculated_at' => $score_data['calculated_at'], |
| 1644 | 2081 | 'created_at' => current_time('mysql'), |
| @@ -1720,20 +2157,101 @@ | ||
| 1720 | 2157 | * @param array $content_data Content analysis data |
| 1721 | 2158 | * @return array Scoring result |
| 1722 | 2159 | */ |
| 1723 | 2160 | private function score_mobile_experience(array $content_data): array { |
| 1724 | - // Mobile experience is theme/site-level, not controlled by post content. | |
| 1725 | - // Award full credit (benefit of the doubt) instead of a fixed partial | |
| 1726 | - // 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 | + | |
| 1727 | 2180 | return [ |
| 1728 | - 'score' => $this->scoring_factors['mobile_experience'], | |
| 1729 | - 'max_score' => $this->scoring_factors['mobile_experience'], | |
| 1730 | - 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'], | |
| 1731 | - '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 | + ], | |
| 1732 | 2191 | ]; |
| 1733 | 2192 | } |
| 1734 | 2193 | |
| 1735 | 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 | + /** | |
| 1736 | 2254 | * Score core web vitals - 2025 version (3 points) |
| 1737 | 2255 | * |
| 1738 | 2256 | * @param array $content_data Content analysis data |
| 1739 | 2257 | * @return array Scoring result |
| @@ -1738,20 +2256,98 @@ | ||
| 1738 | 2256 | * @param array $content_data Content analysis data |
| 1739 | 2257 | * @return array Scoring result |
| 1740 | 2258 | */ |
| 1741 | 2259 | private function score_core_web_vitals(array $content_data): array { |
| 1742 | - // Core Web Vitals are a runtime/performance signal, not derivable from | |
| 1743 | - // post content. Award full credit (benefit of the doubt) rather than a | |
| 1744 | - // 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 | + | |
| 1745 | 2278 | return [ |
| 1746 | - 'score' => $this->scoring_factors['core_web_vitals'], | |
| 1747 | - 'max_score' => $this->scoring_factors['core_web_vitals'], | |
| 1748 | - 'suggestions' => ['Optimize Core Web Vitals: LCP, INP, and CLS for better user experience'], | |
| 1749 | - '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 | + ], | |
| 1750 | 2293 | ]; |
| 1751 | 2294 | } |
| 1752 | 2295 | |
| 1753 | 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 | + /** | |
| 1754 | 2350 | * Score internal linking - declining importance (1 point) |
| 1755 | 2351 | * |
| 1756 | 2352 | * @param array $content_data Content analysis data |
| 1757 | 2353 | * @return array Scoring result |
| @@ -1781,9 +2377,9 @@ | ||
| 1781 | 2377 | $suggestions = []; |
| 1782 | 2378 | |
| 1783 | 2379 | // Meta description check |
| 1784 | 2380 | $meta_desc = $metadata['description'] ?? ''; |
| 1785 | - 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) { | |
| 1786 | 2382 | $score += 0.5; |
| 1787 | 2383 | } else { |
| 1788 | 2384 | $suggestions[] = 'Add a compelling meta description (120-160 characters)'; |
| 1789 | 2385 | } |
| @@ -1814,19 +2410,30 @@ | ||
| 1814 | 2410 | */ |
| 1815 | 2411 | private function get_grade_from_score($score): string { |
| 1816 | 2412 | $score = (int) $score; // Ensure it's an integer |
| 1817 | 2413 | |
| 1818 | - if ($score >= 95) return 'A+'; | |
| 1819 | - if ($score >= 90) return 'A'; | |
| 1820 | - if ($score >= 85) return 'A-'; | |
| 1821 | - if ($score >= 80) return 'B+'; | |
| 1822 | - if ($score >= 75) return 'B'; | |
| 1823 | - if ($score >= 70) return 'B-'; | |
| 1824 | - if ($score >= 65) return 'C+'; | |
| 1825 | - if ($score >= 60) return 'C'; | |
| 1826 | - if ($score >= 55) return 'C-'; | |
| 1827 | - if ($score >= 45) return 'D+'; | |
| 1828 | - if ($score >= 35) return 'D'; | |
| 2414 | + if ($score >= 95) { return 'A+'; | |
| 2415 | + } | |
| 2416 | + if ($score >= 90) { return 'A'; | |
| 2417 | + } | |
| 2418 | + if ($score >= 85) { return 'A-'; | |
| 2419 | + } | |
| 2420 | + if ($score >= 80) { return 'B+'; | |
| 2421 | + } | |
| 2422 | + if ($score >= 75) { return 'B'; | |
| 2423 | + } | |
| 2424 | + if ($score >= 70) { return 'B-'; | |
| 2425 | + } | |
| 2426 | + if ($score >= 65) { return 'C+'; | |
| 2427 | + } | |
| 2428 | + if ($score >= 60) { return 'C'; | |
| 2429 | + } | |
| 2430 | + if ($score >= 55) { return 'C-'; | |
| 2431 | + } | |
| 2432 | + if ($score >= 45) { return 'D+'; | |
| 2433 | + } | |
| 2434 | + if ($score >= 35) { return 'D'; | |
| 2435 | + } | |
| 1829 | 2436 | return 'F'; |
| 1830 | 2437 | } |
| 1831 | 2438 | |
| 1832 | 2439 | /** |
| @@ -1885,9 +2492,11 @@ | ||
| 1885 | 2492 | 'images' => $images, |
| 1886 | 2493 | 'url' => $url, |
| 1887 | 2494 | 'slug' => $post->post_name, |
| 1888 | 2495 | 'post_modified' => $post->post_modified, |
| 1889 | - 'schema_present' => $this->detect_schema_present($content) || $this->thinkrank_global_schema_active($post->post_type), | |
| 2496 | + 'schema_present' => $this->detect_schema_present($content) | |
| 2497 | + || $this->thinkrank_global_schema_active($post->post_type) | |
| 2498 | + || $this->thinkrank_deployed_schema_active($post), | |
| 1890 | 2499 | ]; |
| 1891 | 2500 | } |
| 1892 | 2501 | |
| 1893 | 2502 | /** |