PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/ai/class-seo-score-calculator.php +1516 -936 1.0.0 → 2.11.0 View file →
@@ -41,8 +41,41 @@
41 41 */
42 42 private Database $database;
43 43
44 44 /**
45 + * Memoised collected performance measurement, and whether it was resolved.
46 + *
47 + * Two factors read it and both may be asked for on every post in a list, so
48 + * the lookup happens once per calculator. `null` is a real answer here — the
49 + * separate flag keeps "not looked up yet" distinct from "nothing measured".
50 + *
51 + * @since 2.3.1
52 + * @var array|null
53 + */
54 + private ?array $measured_performance = null;
55 +
56 + /**
57 + * @since 2.3.1
58 + * @var bool
59 + */
60 + private bool $measured_performance_resolved = false;
61 +
62 + /**
63 + * Length bands the editor scores against, in characters.
64 + *
65 + * Public so every surface that judges a title or description — the editor
66 + * score and the Bulk Snippets problem filter — reads one set of numbers.
67 + * Before these existed the bands were literals inside the scoring methods,
68 + * and a second screen would have had to copy them and drift (#727).
69 + *
70 + * @since 2.8.0
71 + */
72 + public const TITLE_OPTIMAL_MIN = 35;
73 + public const TITLE_OPTIMAL_MAX = 60;
74 + public const DESCRIPTION_OPTIMAL_MIN = 120;
75 + public const DESCRIPTION_OPTIMAL_MAX = 160;
76 +
77 + /**
45 78 * 2025 SEO scoring factors (Q1 2025 Google Algorithm)
46 79 * Based on First Page Sage research and Google's latest updates
47 80 *
48 81 * @var array
@@ -72,10 +105,17 @@
72 105 $this->database = $database;
73 106 }
74 107
75 108 /**
76 - * Calculate comprehensive modern SEO score
77 - *
109 + * Calculate comprehensive modern SEO score.
110 + *
111 + * Supports multiple focus keywords: when `$options['target_keywords']` holds
112 + * more than one keyword the score is computed independently for each and the
113 + * HIGHEST overall score is returned as the final result, with per-keyword
114 + * results (`keyword_results`) and OR-combined per-check matches
115 + * (`keyword_checks`) attached. A single keyword (or the legacy
116 + * `target_keyword` option) falls through to the single-keyword path.
117 + *
78 118 * @param array $content_data Content analysis data
79 119 * @param array $metadata Post metadata
80 120 * @param array $options Additional options
81 121 * @return array Complete scoring result
@@ -80,8 +120,492 @@
80 120 * @param array $options Additional options
81 121 * @return array Complete scoring result
82 122 */
83 123 public function calculate_score(array $content_data, array $metadata, array $options = []): array {
124 + $keywords = $this->resolve_target_keywords($options, $metadata);
125 +
126 + if (count($keywords) > 1) {
127 + return $this->calculate_score_multi($content_data, $metadata, $keywords, $options);
128 + }
129 +
130 + $options['target_keyword'] = $keywords[0] ?? '';
131 + $result = $this->compute_score($content_data, $metadata, $options);
132 +
133 + // Expose the keyword surface uniformly so consumers can rely on it
134 + // regardless of how many keywords were supplied.
135 + $result['target_keywords'] = $keywords;
136 + if (!empty($keywords)) {
137 + $result['keyword_results'] = [[
138 + 'keyword' => $keywords[0],
139 + 'overall_score' => $result['overall_score'],
140 + 'grade' => $result['grade'],
141 + ]];
142 + $result['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
143 + }
144 + $result['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
145 +
146 + return $result;
147 + }
148 +
149 + /**
150 + * Resolve the target keyword list from the options array, falling back to
151 + * the focus keywords carried on the post's metadata.
152 + *
153 + * Accepts `target_keywords` (array) or the legacy `target_keyword` (string),
154 + * trims, drops empties and removes case-insensitive duplicates.
155 + *
156 + * Options win over metadata on purpose: the editor scores unsaved keyword
157 + * edits by passing them explicitly, and that live value must beat whatever
158 + * is currently persisted. The metadata fallback applies only when the
159 + * caller mentions no keyword option AT ALL — a caller that passes an empty
160 + * keyword is deliberately clearing it (the editor does exactly this when
161 + * the field is emptied), so the stored value must not resurrect it.
162 + *
163 + * Without the fallback, every caller that hands over
164 + * `Metabox_Manager::get_post_metadata()` (the metabox and the MCP scoring
165 + * abilities) silently scored as if no keyword were set.
166 + *
167 + * @param array $options Scoring options.
168 + * @param array $metadata Post metadata (may carry focus_keyword(s)).
169 + * @return string[] Normalized keyword list.
170 + */
171 + private function resolve_target_keywords(array $options, array $metadata = []): array {
172 + $raw = [];
173 + if (!empty($options['target_keywords']) && is_array($options['target_keywords'])) {
174 + $raw = $options['target_keywords'];
175 + } elseif (isset($options['target_keyword']) && $options['target_keyword'] !== '') {
176 + $raw = [$options['target_keyword']];
177 + } elseif (!$this->options_mention_keywords($options)) {
178 + if (!empty($metadata['focus_keywords']) && is_array($metadata['focus_keywords'])) {
179 + $raw = $metadata['focus_keywords'];
180 + } elseif (isset($metadata['focus_keyword']) && is_string($metadata['focus_keyword']) && $metadata['focus_keyword'] !== '') {
181 + $raw = [$metadata['focus_keyword']];
182 + }
183 + }
184 +
185 + $seen = [];
186 + $keywords = [];
187 + foreach ($raw as $keyword) {
188 + $keyword = trim((string) $keyword);
189 + if ($keyword === '') {
190 + continue;
191 + }
192 + $key = strtolower($keyword);
193 + if (isset($seen[$key])) {
194 + continue;
195 + }
196 + $seen[$key] = true;
197 + $keywords[] = $keyword;
198 + }
199 +
200 + return $keywords;
201 + }
202 +
203 + /**
204 + * Whether the caller said anything about keywords — including saying
205 + * "none". Distinguishes an intentional clear (score with no keyword) from
206 + * silence (fall back to the post's stored focus keywords).
207 + *
208 + * @param array $options Scoring options.
209 + * @return bool True when a keyword option key is present.
210 + */
211 + private function options_mention_keywords(array $options): bool {
212 + return array_key_exists('target_keywords', $options)
213 + || array_key_exists('target_keyword', $options);
214 + }
215 +
216 + /**
217 + * Score each keyword independently and return the highest-scoring result.
218 + *
219 + * @param array $content_data Content analysis data.
220 + * @param array $metadata Post metadata.
221 + * @param string[] $keywords Target keywords (already normalized, 2+).
222 + * @param array $options Additional options.
223 + * @return array Best scoring result, with per-keyword data attached.
224 + */
225 + private function calculate_score_multi(array $content_data, array $metadata, array $keywords, array $options): array {
226 + $per_keyword = [];
227 + $best = null;
228 + $best_keyword = $keywords[0];
229 +
230 + foreach ($keywords as $keyword) {
231 + $opts = $options;
232 + unset($opts['target_keywords']);
233 + $opts['target_keyword'] = $keyword;
234 +
235 + $result = $this->compute_score($content_data, $metadata, $opts);
236 +
237 + $per_keyword[] = [
238 + 'keyword' => $keyword,
239 + 'overall_score' => $result['overall_score'],
240 + 'grade' => $result['grade'],
241 + 'score_breakdown' => $result['score_breakdown'],
242 + ];
243 +
244 + if ($best === null || $result['overall_score'] > $best['overall_score']) {
245 + $best = $result;
246 + $best_keyword = $keyword;
247 + }
248 + }
249 +
250 + // Final score = highest individual keyword score. Retain per-keyword
251 + // results and OR-combined checks so the UI can show both.
252 + $best['target_keyword'] = $best_keyword;
253 + $best['target_keywords'] = $keywords;
254 + $best['keyword_results'] = $per_keyword;
255 + $best['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
256 + $best['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
257 +
258 + return $best;
259 + }
260 +
261 + /**
262 + * Evaluate per-location keyword checks across ALL focus keywords.
263 + *
264 + * Each check (title, meta description, content, image alt, slug) passes when
265 + * ANY of the focus keywords matches that location.
266 + *
267 + * @param array $content_data Content analysis data.
268 + * @param array $metadata Post metadata.
269 + * @param string[] $keywords Target keywords.
270 + * @return array<string,array{passed:bool,matched_keywords:string[]}>
271 + */
272 + private function analyze_keyword_checks(array $content_data, array $metadata, array $keywords): array {
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'] ?? '')));
276 +
277 + $alts = '';
278 + foreach ((array) ($content_data['images'] ?? []) as $image) {
279 + $alts .= ' ' . self::lower((string) ($image['alt'] ?? ''));
280 + }
281 +
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));
291 +
292 + $haystacks = [
293 + 'title' => trim($title),
294 + 'meta_description' => trim($description),
295 + 'content' => trim($content),
296 + 'image_alt' => trim($alts),
297 + 'slug' => trim($slug),
298 + ];
299 +
300 + $checks = [];
301 + foreach ($haystacks as $location => $haystack) {
302 + $matched = [];
303 + foreach ($keywords as $keyword) {
304 + if ($this->keyword_matches($haystack, self::lower(trim($keyword)))) {
305 + $matched[] = $keyword;
306 + }
307 + }
308 + $checks[$location] = [
309 + 'passed' => !empty($matched),
310 + 'matched_keywords' => $matched,
311 + ];
312 + }
313 +
314 + return $checks;
315 + }
316 +
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 + /**
598 + * Compute the SEO score for a single target keyword.
599 + *
600 + * @param array $content_data Content analysis data
601 + * @param array $metadata Post metadata
602 + * @param array $options Additional options (expects scalar target_keyword)
603 + * @return array Complete scoring result
604 + *
605 + * @throws \Exception On failure.
606 + */
607 + private function compute_score(array $content_data, array $metadata, array $options = []): array {
84 608 $scores = [];
85 609 $suggestions = [];
86 610 $total_score = 0;
87 611
@@ -86,9 +610,9 @@
86 610 $total_score = 0;
87 611
88 612 try {
89 613 // 1. Satisfying Content (23 points) - #1 factor in 2025
90 - $satisfying_result = $this->score_satisfying_content($content_data, $options['target_keyword'] ?? '');
614 + $satisfying_result = $this->score_satisfying_content($content_data, $options['target_keyword'] ?? '', $metadata);
91 615 $scores['satisfying_content'] = $satisfying_result;
92 616 $total_score += $satisfying_result['score'];
93 617 $suggestions = array_merge($suggestions, $satisfying_result['suggestions']);
94 618 } catch (\Exception $e) {
@@ -183,9 +707,9 @@
183 707 $total_score += $technical_result['score'];
184 708 $suggestions = array_merge($suggestions, $technical_result['suggestions']);
185 709
186 710 try {
187 - $prioritized_suggestions = $this->prioritize_suggestions($suggestions);
711 + $prioritized_suggestions = $this->prioritize_suggestions($suggestions, $scores);
188 712 $grade = $this->get_grade_from_score($total_score);
189 713
190 714 return [
191 715 'overall_score' => min(100, $total_score),
@@ -191,10 +715,16 @@
191 715 'overall_score' => min(100, $total_score),
192 716 'score_breakdown' => $scores,
193 717 'suggestions' => $prioritized_suggestions,
194 718 'grade' => $grade,
719 + // Readability + content-quality labels so persisted scores
720 + // (e.g. bulk-analyzed on import) populate the post-list columns
721 + // without a manual re-analyze. The REST endpoint still overrides
722 + // these with live-editor values when the metabox provides them.
723 + 'readability_score' => $this->format_readability_label($content_data),
724 + 'content_quality' => $this->derive_content_quality($content_data),
195 725 'calculated_at' => current_time('mysql'),
196 - 'algorithm_version' => '2025.1',
726 + 'algorithm_version' => '2025.2',
197 727 'algorithm_source' => 'First Page Sage Q1 2025 Research',
198 728 'factors_count' => count($scores),
199 729 ];
200 730 } catch (\Exception $e) {
@@ -207,11 +737,12 @@
207 737 * Google tests content to see if it satisfies search intent
208 738 *
209 739 * @param array $content_data Content analysis data
210 740 * @param string $target_keyword Target keyword
741 + * @param array $metadata Post metadata (title, description)
211 742 * @return array Scoring result
212 743 */
213 - private function score_satisfying_content(array $content_data, string $target_keyword): array {
744 + private function score_satisfying_content(array $content_data, string $target_keyword, array $metadata = []): array {
214 745 $score = 0;
215 746 $max_score = $this->scoring_factors['satisfying_content'];
216 747 $suggestions = [];
217 748
@@ -216,37 +747,63 @@
216 747 $suggestions = [];
217 748
218 749 $content = $content_data['content'] ?? '';
219 750 $word_count = $content_data['word_count'] ?? 0;
751 + $meta_description = (string) ($metadata['description'] ?? '');
220 752
221 - // Content depth and comprehensiveness (8 points)
222 - if ($word_count >= 2000) {
753 + // Content depth and comprehensiveness (8 points). Tiers softened so a
754 + // genuinely useful post is not capped the way the old 2000-word gate did
755 + // (Rank Math awards full content credit well below 2000 words).
756 + if ($word_count >= 1500) {
223 757 $score += 8;
224 - } elseif ($word_count >= 1200) {
225 - $score += 6;
226 - $suggestions[] = 'Consider expanding content to 2000+ words for comprehensive topic coverage';
227 - } elseif ($word_count >= 800) {
228 - $score += 4;
229 - $suggestions[] = 'Content needs more depth - aim for 1200+ words for better satisfaction signals';
758 + } elseif ($word_count >= 1000) {
759 + $score += 7;
760 + $suggestions[] = 'Consider expanding content to 1500+ words for more comprehensive coverage';
761 + } elseif ($word_count >= 600) {
762 + $score += 5;
763 + $suggestions[] = 'Content is adequate - aim for 1000+ words for stronger topic depth';
764 + } elseif ($word_count >= 300) {
765 + $score += 3;
766 + $suggestions[] = 'Content is thin - aim for 600+ words minimum';
230 767 } else {
231 - $score += 1;
232 - $suggestions[] = 'Content too shallow - Google prioritizes comprehensive, satisfying content (800+ words minimum)';
768 + $score++;
769 + $suggestions[] = 'Content too shallow - Google prioritizes comprehensive, satisfying content';
233 770 }
234 771
235 - // Search intent satisfaction (8 points)
236 - $intent_satisfaction = $this->calculate_intent_satisfaction($content, $target_keyword);
237 - $score += round($intent_satisfaction * 8);
238 -
239 - if ($intent_satisfaction < 0.7) {
240 - $suggestions[] = 'Improve content to better satisfy search intent - focus on answering user questions completely';
772 + // Keyword presence & placement (8 points) - deterministic, replaces the
773 + // old literal-phrase intent heuristic ("what is"/"because"). Measures
774 + // signals the editor actually controls: body (3), first paragraph (3),
775 + // meta description (2) - the last mirrors Rank Math's "keyword in meta
776 + // description" basic-SEO check.
777 + if (empty($target_keyword)) {
778 + $score += 4; // Benefit of the doubt when no focus keyword is set.
779 + $suggestions[] = 'Set a focus keyword so content relevance can be measured';
780 + } else {
781 + if ($this->keyword_in_content($content, $target_keyword)) {
782 + $score += 3;
783 + } else {
784 + $suggestions[] = "Use the focus keyword '{$target_keyword}' in the body content";
785 + }
786 + if ($this->keyword_in_first_paragraph($content, $target_keyword)) {
787 + $score += 3;
788 + } else {
789 + $suggestions[] = "Mention '{$target_keyword}' near the start of the content (first paragraph)";
790 + }
791 + if ($this->keyword_in_meta($meta_description, $target_keyword)) {
792 + $score += 2;
793 + } else {
794 + $suggestions[] = "Include the focus keyword '{$target_keyword}' in the meta description";
795 + }
241 796 }
242 797
243 - // Content uniqueness and value (7 points)
244 - $uniqueness_score = $this->assess_content_uniqueness($content);
245 - $score += round($uniqueness_score * 7);
798 + // Content structure & value (7 points) - reuses the deterministic
799 + // content-quality signal (length, paragraph length, subheading
800 + // distribution) instead of the noisy sentence-length variety heuristic.
801 + $quality = $this->derive_content_quality_score($content_data); // 0-100
802 + $score += (int) round(($quality / 100) * 7);
246 803
247 - if ($uniqueness_score < 0.6) {
248 - $suggestions[] = 'Add unique insights, examples, or perspectives to stand out from competitors';
804 + if ($quality < 60) {
805 + $suggestions[] = 'Improve content structure - break up long paragraphs and add subheadings';
249 806 }
250 807
251 808 return [
252 809 'score' => $score,
@@ -253,10 +810,12 @@
253 810 'max_score' => $max_score,
254 811 'suggestions' => $suggestions,
255 812 'details' => [
256 813 'word_count' => $word_count,
257 - 'intent_satisfaction' => round($intent_satisfaction * 100, 1),
258 - 'uniqueness_score' => round($uniqueness_score * 100, 1),
814 + 'keyword_in_content' => !empty($target_keyword) && $this->keyword_in_content($content, $target_keyword),
815 + 'keyword_in_first_paragraph' => !empty($target_keyword) && $this->keyword_in_first_paragraph($content, $target_keyword),
816 + 'keyword_in_meta_description' => !empty($target_keyword) && $this->keyword_in_meta($meta_description, $target_keyword),
817 + 'content_quality_score' => $quality,
259 818 'content_depth' => $this->assess_content_depth_2025($word_count),
260 819 ]
261 820 ];
262 821 }
@@ -261,105 +820,22 @@
261 820 ];
262 821 }
263 822
264 823 /**
265 - * Calculate intent satisfaction score
824 + * Assess content depth for 2025 standards
266 825 *
267 - * @param string $content Content text
268 - * @param string $target_keyword Target keyword
269 - * @return float Satisfaction score (0-1)
826 + * @param int $word_count Word count
827 + * @return string Depth assessment
270 828 */
271 - private function calculate_intent_satisfaction(string $content, string $target_keyword): float {
272 - if (empty($content) || empty($target_keyword)) {
273 - return 0.3; // Partial credit for content without keyword focus
829 + private function assess_content_depth_2025(int $word_count): string {
830 + if ($word_count >= 3000) { return 'Comprehensive';
274 831 }
275 -
276 - $content_lower = strtolower(wp_strip_all_tags($content));
277 - $keyword_lower = strtolower($target_keyword);
278 -
279 - // Check for question answering patterns
280 - $question_patterns = ['what is', 'how to', 'why', 'when', 'where', 'who'];
281 - $answer_patterns = ['because', 'therefore', 'as a result', 'in conclusion', 'to summarize'];
282 -
283 - $question_score = 0;
284 - $answer_score = 0;
285 -
286 - foreach ($question_patterns as $pattern) {
287 - if (strpos($content_lower, $pattern) !== false) {
288 - $question_score += 0.1;
289 - }
832 + if ($word_count >= 2000) { return 'Detailed';
290 833 }
291 -
292 - foreach ($answer_patterns as $pattern) {
293 - if (strpos($content_lower, $pattern) !== false) {
294 - $answer_score += 0.1;
295 - }
834 + if ($word_count >= 1200) { return 'Adequate';
296 835 }
297 -
298 - // Base satisfaction from keyword relevance
299 - $keyword_relevance = $this->calculate_topic_relevance($content, $target_keyword);
300 -
301 - // Combine factors
302 - $satisfaction = ($keyword_relevance * 0.6) + (min(1.0, $question_score) * 0.2) + (min(1.0, $answer_score) * 0.2);
303 -
304 - return min(1.0, $satisfaction);
305 - }
306 -
307 - /**
308 - * Assess content uniqueness
309 - *
310 - * @param string $content Content text
311 - * @return float Uniqueness score (0-1)
312 - */
313 - private function assess_content_uniqueness(string $content): float {
314 - if (empty($content)) {
315 - return 0.0;
836 + if ($word_count >= 800) { return 'Basic';
316 837 }
317 -
318 - // Simple heuristics for uniqueness assessment
319 - $sentences = preg_split('/[.!?]+/', wp_strip_all_tags($content), -1, PREG_SPLIT_NO_EMPTY);
320 - $sentence_count = count($sentences);
321 -
322 - if ($sentence_count === 0) {
323 - return 0.0;
324 - }
325 -
326 - // Check for varied sentence structures
327 - $short_sentences = 0;
328 - $medium_sentences = 0;
329 - $long_sentences = 0;
330 -
331 - foreach ($sentences as $sentence) {
332 - $word_count = $this->calculate_word_count_js_style(trim($sentence));
333 - if ($word_count <= 10) {
334 - $short_sentences++;
335 - } elseif ($word_count <= 20) {
336 - $medium_sentences++;
337 - } else {
338 - $long_sentences++;
339 - }
340 - }
341 -
342 - // Variety in sentence length indicates more natural, unique content
343 - $variety_score = 0;
344 - if ($short_sentences > 0) $variety_score += 0.3;
345 - if ($medium_sentences > 0) $variety_score += 0.4;
346 - if ($long_sentences > 0) $variety_score += 0.3;
347 -
348 - return min(1.0, $variety_score);
349 - }
350 -
351 - /**
352 - * Assess content depth for 2025 standards
353 - *
354 - * @param int $word_count Word count
355 - * @return string Depth assessment
356 - */
357 - private function assess_content_depth_2025(int $word_count): string {
358 - if ($word_count >= 3000) return 'Comprehensive';
359 - if ($word_count >= 2000) return 'Detailed';
360 - if ($word_count >= 1200) return 'Adequate';
361 - if ($word_count >= 800) return 'Basic';
362 838 return 'Insufficient';
363 839 }
364 840
365 841 /**
@@ -378,24 +854,25 @@
378 854 $suggestions[] = 'Add a compelling, click-worthy title that matches search intent';
379 855 return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
380 856 }
381 857
382 - $title_length = strlen($title);
858 + $title_length = mb_strlen($title);
383 859
384 - // 2025 length optimization (7 points) - Slightly more flexible
385 - if ($title_length >= 35 && $title_length <= 65) {
386 - $score += 7;
860 + // 2025 length optimization (6 points). 60 characters is the recommended
861 + // maximum for best SERP visibility before Google truncates the title.
862 + if ($title_length >= self::TITLE_OPTIMAL_MIN && $title_length <= self::TITLE_OPTIMAL_MAX) {
863 + $score += 6;
387 864 } elseif ($title_length >= 25 && $title_length <= 75) {
388 - $score += 5;
389 - $suggestions[] = 'Optimize title length to 35-65 characters for better SERP visibility';
865 + $score += 4;
866 + $suggestions[] = 'Optimize title length to 35-60 characters for better SERP visibility';
390 867 } else {
391 - $score += 2;
868 + $score++;
392 869 $suggestions[] = $title_length < 25 ?
393 - 'Title too short - aim for 35-65 characters' :
870 + 'Title too short - aim for 35-60 characters' :
394 871 'Title too long - risk truncation in search results';
395 872 }
396 873
397 - // Looser keyword matching (7 points) - 2025 update
874 + // Looser keyword matching (6 points) - 2025 update
398 875 if (!empty($target_keyword)) {
399 876 $title_lower = strtolower($title);
400 877 $keyword_lower = strtolower($target_keyword);
401 878
@@ -400,14 +877,14 @@
400 877 $keyword_lower = strtolower($target_keyword);
401 878
402 879 // Exact match
403 880 if (strpos($title_lower, $keyword_lower) !== false) {
404 - $score += 7;
881 + $score += 6;
405 882 } else {
406 883 // Check for semantic variations (2025 improvement)
407 884 $semantic_match = $this->check_semantic_keyword_match($title, $target_keyword);
408 885 if ($semantic_match) {
409 - $score += 6; // Almost full credit for semantic match
886 + $score += 5; // Almost full credit for semantic match
410 887 $suggestions[] = 'Good semantic keyword usage - Google now recognizes keyword variations';
411 888 } else {
412 889 // Check for partial keyword match
413 890 $keyword_parts = explode(' ', $keyword_lower);
@@ -418,21 +895,37 @@
418 895 }
419 896 }
420 897
421 898 if ($partial_matches > 0) {
422 - $score += round(($partial_matches / count($keyword_parts)) * 5);
899 + $score += round(($partial_matches / count($keyword_parts)) * 4);
423 900 $suggestions[] = "Include more parts of target keyword '{$target_keyword}' in title";
424 901 } else {
425 - $score += 1;
426 902 $suggestions[] = "Include target keyword '{$target_keyword}' or related terms in title";
427 903 }
428 904 }
429 905 }
430 906 } else {
431 - $score += 3; // Partial credit
907 + $score += 2; // Partial credit
432 908 $suggestions[] = 'Set a target keyword to optimize title effectiveness';
433 909 }
434 910
911 + // Title readability (2 points) - mirrors Rank Math's title checks for a
912 + // number/power word (drives CTR) and emotional sentiment.
913 + $has_number = (bool) preg_match('/\d/', $title);
914 + $has_power_word = $this->title_has_power_word($title);
915 + $has_sentiment = $this->title_has_sentiment_word($title);
916 +
917 + if ($has_number || $has_power_word) {
918 + $score++;
919 + } else {
920 + $suggestions[] = 'Add a number or a power word to the title to boost click-through rate';
921 + }
922 + if ($has_sentiment) {
923 + $score++;
924 + } else {
925 + $suggestions[] = 'Use an emotional/sentiment word in the title to make it more compelling';
926 + }
927 +
435 928 return [
436 929 'score' => $score,
437 930 'max_score' => $max_score,
438 931 'suggestions' => $suggestions,
@@ -437,11 +930,13 @@
437 930 'max_score' => $max_score,
438 931 'suggestions' => $suggestions,
439 932 'details' => [
440 933 'title_length' => $title_length,
441 - 'optimal_range' => '35-65 characters',
934 + 'optimal_range' => '35-60 characters',
442 935 'keyword_present' => !empty($target_keyword) && strpos(strtolower($title), strtolower($target_keyword)) !== false,
443 936 'semantic_match' => !empty($target_keyword) ? $this->check_semantic_keyword_match($title, $target_keyword) : false,
937 + 'has_number_or_power_word' => $has_number || $has_power_word,
938 + 'has_sentiment_word' => $has_sentiment,
444 939 ]
445 940 ];
446 941 }
447 942
@@ -447,295 +942,185 @@
447 942
448 943 // Placeholder methods for remaining 2025 factors
449 944
450 945 private function score_niche_expertise(array $content_data, string $target_keyword): array {
946 + $max_score = $this->scoring_factors['niche_expertise'];
947 + $suggestions = [];
948 +
949 + // Without a focus keyword we cannot measure topical coverage; award
950 + // partial credit rather than capping the ceiling with a placeholder.
951 + if (empty($target_keyword)) {
952 + return [
953 + 'score' => 7,
954 + 'max_score' => $max_score,
955 + 'suggestions' => ['Set a focus keyword and use it in subheadings and the URL for stronger topical signals'],
956 + 'details' => ['expertise_level' => 'Unmeasured (no focus keyword)'],
957 + ];
958 + }
959 +
960 + $score = 0;
961 + $content = (string) ($content_data['content'] ?? '');
962 + $headings = (array) ($content_data['headings'] ?? []);
963 + $images = (array) ($content_data['images'] ?? []);
964 + $slug = strtolower((string) ($content_data['slug'] ?? ''));
965 +
966 + // Keyword in a subheading (4 points).
967 + if ($this->keyword_in_subheadings($headings, $target_keyword)) {
968 + $score += 4;
969 + } else {
970 + $suggestions[] = "Include '{$target_keyword}' in at least one subheading (H2-H6)";
971 + }
972 +
973 + // Keyword density in a healthy band (4 points). Rank Math treats
974 + // ~0.5%-2.5% as optimal; reward in-band, partial when present but thin.
975 + $density = $this->keyword_density($content, $target_keyword);
976 + if ($density >= 0.5 && $density <= 2.5) {
977 + $score += 4;
978 + } elseif ($density > 0) {
979 + $score += 2;
980 + $suggestions[] = $density > 2.5
981 + ? 'Keyword density is high - reduce repetition to avoid over-optimization'
982 + : 'Keyword density is low - use the focus keyword a little more often';
983 + } else {
984 + $suggestions[] = "Use the focus keyword '{$target_keyword}' in the content";
985 + }
986 +
987 + // URL optimization (3 points): keyword in slug (2) + a reasonably short
988 + // URL (1). Rank Math flags overly long URLs, so reward concise slugs.
989 + $keyword_slug = str_replace(' ', '-', strtolower($target_keyword));
990 + $keyword_in_slug = $slug !== '' && (strpos($slug, $keyword_slug) !== false || strpos(str_replace('-', '', $slug), str_replace('-', '', $keyword_slug)) !== false);
991 + if ($keyword_in_slug) {
992 + $score += 2;
993 + } else {
994 + $suggestions[] = 'Include the focus keyword in the URL slug';
995 + }
996 +
997 + // A slug under ~75 chars keeps the URL clean and fully visible in SERPs.
998 + $slug_length = strlen($slug);
999 + if ($slug === '' || $slug_length <= 75) {
1000 + $score++;
1001 + } else {
1002 + $suggestions[] = 'Shorten the URL slug - long URLs are harder to read and share';
1003 + }
1004 +
1005 + // Keyword in image alt text (2 points) - mirrors Rank Math's
1006 + // "keyword in image alt" check. When the post has no images the check
1007 + // does not apply, so award the points (benefit of the doubt) rather
1008 + // than capping the ceiling for legitimately image-less posts.
1009 + if (empty($images)) {
1010 + $score += 2;
1011 + $suggestions[] = 'Add a relevant image with the focus keyword in its alt text';
1012 + } elseif ($this->keyword_in_alt($images, $target_keyword)) {
1013 + $score += 2;
1014 + } else {
1015 + $suggestions[] = 'Include the focus keyword in at least one image alt attribute';
1016 + }
1017 +
451 1018 return [
452 - 'score' => 8, // Partial score for basic implementation
453 - 'max_score' => $this->scoring_factors['niche_expertise'],
454 - 'suggestions' => ['Develop hub and spoke content clusters around your main topics'],
455 - 'details' => ['expertise_level' => 'Developing']
1019 + 'score' => $score,
1020 + 'max_score' => $max_score,
1021 + 'suggestions' => $suggestions,
1022 + 'details' => [
1023 + 'keyword_in_subheading' => $this->keyword_in_subheadings($headings, $target_keyword),
1024 + 'keyword_density' => round($density, 2),
1025 + 'keyword_in_slug' => $keyword_in_slug,
1026 + 'slug_length' => $slug_length,
1027 + 'keyword_in_image_alt' => $this->keyword_in_alt($images, $target_keyword),
1028 + ],
456 1029 ];
457 1030 }
458 1031
459 1032 private function score_searcher_engagement(array $content_data): array {
460 - $readability = $content_data['readability_score'] ?? 50;
461 - $score = round(($readability / 100) * $this->scoring_factors['searcher_engagement']);
1033 + $max_score = $this->scoring_factors['searcher_engagement'];
462 1034
1035 + // Engagement (dwell time / bounce) is off-page, so estimate it from the
1036 + // on-page signals that drive it: readability (half) + content structure
1037 + // (half). This raises the old readability-only floor.
1038 + $readability = (float) ($content_data['readability_score'] ?? 50);
1039 + $structure = (float) $this->derive_content_quality_score($content_data);
1040 +
1041 + $readability_pts = ($readability / 100) * ($max_score / 2);
1042 + $structure_pts = ($structure / 100) * ($max_score / 2);
1043 + $score = (int) round($readability_pts + $structure_pts);
1044 +
463 1045 return [
464 1046 'score' => $score,
465 - 'max_score' => $this->scoring_factors['searcher_engagement'],
466 - 'suggestions' => $score < 8 ? ['Improve content engagement with better readability and structure'] : [],
467 - 'details' => ['engagement_estimate' => round(($score / $this->scoring_factors['searcher_engagement']) * 100, 1) . '%']
1047 + 'max_score' => $max_score,
1048 + 'suggestions' => $score < ($max_score * 0.7)
1049 + ? ['Improve readability and structure (shorter sentences, subheadings, shorter paragraphs)']
1050 + : [],
1051 + 'details' => ['engagement_estimate' => round(($score / $max_score) * 100, 1) . '%'],
468 1052 ];
469 1053 }
470 1054
471 1055 private function score_backlink_authority(array $content_data): array {
472 - $external_links = $content_data['external_links'] ?? 0;
473 - $score = min($this->scoring_factors['backlink_authority'], $external_links * 3);
1056 + $max_score = $this->scoring_factors['backlink_authority'];
1057 + $external_links = (int) ($content_data['external_links'] ?? 0);
1058 + $dofollow_links = (int) ($content_data['external_dofollow_links'] ?? 0);
474 1059
1060 + // Backlinks are off-page and cannot be read from post content. We use
1061 + // the only on-page proxy available - whether the content cites external
1062 + // sources - and avoid hard-penalizing posts for something outside the
1063 + // editor's control (the old external_links*3 formula needed 5 outbound
1064 + // links just to reach full marks, dragging nearly every post down).
1065 + // Dofollow links pass equity, so they earn full credit (Rank Math's
1066 + // "external dofollow link" check); nofollow-only citations earn less.
1067 + $suggestions = [];
1068 + if ($dofollow_links >= 2) {
1069 + $score = $max_score;
1070 + } elseif ($dofollow_links === 1) {
1071 + $score = (int) round($max_score * 0.85);
1072 + } elseif ($external_links > 0) {
1073 + // Cites sources but every external link is nofollow.
1074 + $score = (int) round($max_score * 0.75);
1075 + $suggestions[] = 'Add at least one dofollow link to an authoritative external source';
1076 + } else {
1077 + $score = (int) round($max_score * 0.6);
1078 + $suggestions[] = 'Cite authoritative external sources, and build quality backlinks to this page';
1079 + }
1080 +
475 1081 return [
476 1082 'score' => $score,
477 - 'max_score' => $this->scoring_factors['backlink_authority'],
478 - 'suggestions' => $score < 8 ? ['Build high-quality backlinks from authoritative sources'] : [],
479 - 'details' => ['authority_estimate' => 'Moderate']
1083 + 'max_score' => $max_score,
1084 + 'suggestions' => $suggestions,
1085 + 'details' => [
1086 + 'external_links' => $external_links,
1087 + 'external_dofollow_links' => $dofollow_links,
1088 + ],
480 1089 ];
481 1090 }
482 -
483 - private function score_2025_content_freshness(array $content_data): array {
484 - return [
485 - 'score' => 6, // Full score for new content
486 - 'max_score' => $this->scoring_factors['content_freshness'],
487 - 'suggestions' => ['Update content quarterly to maintain freshness signals'],
488 - 'details' => ['freshness_status' => 'Current', 'last_updated' => current_time('mysql')]
489 - ];
490 - }
491 -
492 - private function score_mobile_experience_score(array $content_data): array {
493 - return [
494 - 'score' => 4, // Good default score
495 - 'max_score' => $this->scoring_factors['mobile_experience'],
496 - 'suggestions' => ['Ensure optimal mobile experience with fast loading and easy navigation'],
497 - 'details' => ['mes_score' => 'Good', 'mobile_friendly' => true]
498 - ];
499 - }
500 -
501 1091 private function score_trustworthiness(array $content_data, array $metadata): array {
1092 + // E-E-A-T is an off-page/site-wide signal we cannot reliably measure
1093 + // from a single post. Award full credit (benefit of the doubt) rather
1094 + // than a fixed partial that silently caps every post's ceiling.
502 1095 return [
503 - 'score' => 3, // Moderate trust score
1096 + 'score' => $this->scoring_factors['trustworthiness'],
504 1097 'max_score' => $this->scoring_factors['trustworthiness'],
505 - 'suggestions' => ['Add author credentials, citations, and contact information to improve trustworthiness'],
506 - 'details' => ['trust_level' => 'Moderate']
1098 + 'suggestions' => ['Add author credentials, citations, and contact information to reinforce trustworthiness'],
1099 + 'details' => ['trust_level' => 'Assumed adequate'],
507 1100 ];
508 1101 }
509 1102
510 1103 private function score_link_diversity(array $content_data): array {
1104 + // Off-page link distribution; not measurable per post. Full credit.
511 1105 return [
512 - 'score' => 2, // Basic score
1106 + 'score' => $this->scoring_factors['link_diversity'],
513 1107 'max_score' => $this->scoring_factors['link_diversity'],
514 - 'suggestions' => ['Build backlinks to multiple pages across your site'],
515 - 'details' => ['diversity_level' => 'Basic']
1108 + 'suggestions' => [],
1109 + 'details' => ['diversity_level' => 'Assumed adequate'],
516 1110 ];
517 1111 }
518 -
519 - private function score_core_web_vitals_2025(array $content_data): array {
520 - return [
521 - 'score' => 2, // Assume decent performance
522 - 'max_score' => $this->scoring_factors['core_web_vitals'],
523 - 'suggestions' => ['Optimize for Interaction Readiness and Cumulative Layout Shift 2.0'],
524 - 'details' => ['vitals_status' => 'Good', 'interaction_readiness' => 'Optimized']
525 - ];
526 - }
527 -
528 1112 private function score_site_security(array $content_data): array {
529 - return [
530 - 'score' => 2, // Full score for SSL
531 - 'max_score' => $this->scoring_factors['site_security'],
532 - 'suggestions' => [],
533 - 'details' => ['ssl_enabled' => true, 'security_level' => 'Good']
534 - ];
535 - }
1113 + $max_score = $this->scoring_factors['site_security'];
1114 + $ssl = function_exists('is_ssl') ? is_ssl() : true;
536 1115
537 - private function score_internal_linking_2025(array $content_data): array {
538 - $internal_links = $content_data['internal_links'] ?? 0;
539 - $score = min($this->scoring_factors['internal_linking'], $internal_links > 0 ? 1 : 0);
540 -
541 1116 return [
542 - 'score' => $score,
543 - 'max_score' => $this->scoring_factors['internal_linking'],
544 - 'suggestions' => $score < 1 ? ['Add internal links to related content'] : [],
545 - 'details' => ['internal_links' => $internal_links]
546 - ];
547 - }
548 -
549 - private function score_technical_factors_2025(array $content_data, array $metadata, array $options): array {
550 - $meta_desc = $metadata['description'] ?? '';
551 - $score = !empty($meta_desc) ? 1 : 0;
552 -
553 - return [
554 - 'score' => $score,
555 - 'max_score' => $this->scoring_factors['technical_factors'],
556 - 'suggestions' => $score < 1 ? ['Add meta description and schema markup'] : [],
557 - 'details' => ['meta_description' => !empty($meta_desc), 'schema_markup' => false]
558 - ];
559 - }
560 -
561 - /**
562 - * Prioritize suggestions for 2025
563 - *
564 - * @param array $suggestions Raw suggestions
565 - * @return array Prioritized suggestions
566 - */
567 - private function prioritize_suggestions_2025(array $suggestions): array {
568 - $prioritized = [];
569 -
570 - foreach ($suggestions as $suggestion) {
571 - $priority = $this->determine_2025_priority($suggestion);
572 - $prioritized[] = [
573 - 'text' => $suggestion,
574 - 'priority' => $priority,
575 - 'impact' => $this->estimate_2025_impact($suggestion),
576 - 'effort' => $this->estimate_effort($suggestion),
577 - ];
578 - }
579 -
580 - // Sort by 2025 priorities
581 - usort($prioritized, function($a, $b) {
582 - $priority_order = ['Critical' => 4, 'High' => 3, 'Medium' => 2, 'Low' => 1];
583 - return $priority_order[$b['priority']] - $priority_order[$a['priority']];
584 - });
585 -
586 - return $prioritized;
587 - }
588 -
589 - /**
590 - * Determine 2025 suggestion priority
591 - *
592 - * @param string $suggestion Suggestion text
593 - * @return string Priority level
594 - */
595 - private function determine_2025_priority(string $suggestion): string {
596 - $critical_keywords = ['satisfying content', 'search intent', 'comprehensive'];
597 - $high_priority_keywords = ['title', 'engagement', 'freshness'];
598 - $medium_priority_keywords = ['mobile', 'trust', 'backlink'];
599 -
600 - $suggestion_lower = strtolower($suggestion);
601 -
602 - foreach ($critical_keywords as $keyword) {
603 - if (strpos($suggestion_lower, $keyword) !== false) {
604 - return 'Critical';
605 - }
606 - }
607 -
608 - foreach ($high_priority_keywords as $keyword) {
609 - if (strpos($suggestion_lower, $keyword) !== false) {
610 - return 'High';
611 - }
612 - }
613 -
614 - foreach ($medium_priority_keywords as $keyword) {
615 - if (strpos($suggestion_lower, $keyword) !== false) {
616 - return 'Medium';
617 - }
618 - }
619 -
620 - return 'Low';
621 - }
622 -
623 - /**
624 - * Estimate 2025 impact
625 - *
626 - * @param string $suggestion Suggestion text
627 - * @return string Impact level
628 - */
629 - private function estimate_2025_impact(string $suggestion): string {
630 - if (strpos(strtolower($suggestion), 'satisfying') !== false) return 'Critical';
631 - if (strpos(strtolower($suggestion), 'engagement') !== false) return 'High';
632 - if (strpos(strtolower($suggestion), 'title') !== false) return 'High';
633 - if (strpos(strtolower($suggestion), 'freshness') !== false) return 'Medium';
634 - return 'Low';
635 - }
636 -
637 - /**
638 - * Get 2025 grade from score
639 - *
640 - * @param int $score Numeric score
641 - * @return string Letter grade
642 - */
643 - private function get_2025_grade(int $score): string {
644 - if ($score >= 95) return 'A+';
645 - if ($score >= 90) return 'A';
646 - if ($score >= 85) return 'A-';
647 - if ($score >= 80) return 'B+';
648 - if ($score >= 75) return 'B';
649 - if ($score >= 70) return 'B-';
650 - if ($score >= 65) return 'C+';
651 - if ($score >= 60) return 'C';
652 - if ($score >= 55) return 'C-';
653 - if ($score >= 45) return 'D+';
654 - if ($score >= 35) return 'D';
655 - return 'F';
656 - }
657 -
658 - /**
659 - * Score title optimization - #2 factor in 2025 (14 points)
660 - * Looser keyword matching, focus on click-through rate and intent satisfaction
661 - *
662 - * @param string $title Page title
663 - * @param string $target_keyword Target keyword
664 - * @return array Scoring result
665 - */
666 - private function score_title_optimization(string $title, string $target_keyword): array {
667 - $score = 0;
668 - $max_score = $this->scoring_factors['title_optimization'];
669 - $suggestions = [];
670 -
671 - if (empty($title)) {
672 - $suggestions[] = 'Add a compelling, click-worthy title that matches search intent';
673 - return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
674 - }
675 -
676 - $title_length = strlen($title);
677 -
678 - // Modern length optimization (8 points) - Updated for 2024
679 - if ($title_length >= 40 && $title_length <= 60) {
680 - $score += 8;
681 - } elseif ($title_length >= 30 && $title_length <= 70) {
682 - $score += 6;
683 - $suggestions[] = 'Optimize title length to 40-60 characters for better SERP visibility';
684 - } else {
685 - $score += 2;
686 - $suggestions[] = $title_length < 30 ?
687 - 'Title too short - aim for 40-60 characters to maximize SERP real estate' :
688 - 'Title too long - risk truncation in search results';
689 - }
690 -
691 - // Semantic keyword presence (10 points) - Modern approach
692 - if (!empty($target_keyword)) {
693 - $title_lower = strtolower($title);
694 - $keyword_lower = strtolower($target_keyword);
695 -
696 - if (strpos($title_lower, $keyword_lower) !== false) {
697 - $score += 10;
698 -
699 - // Bonus for keyword placement
700 - if (strpos($title_lower, $keyword_lower) === 0) {
701 - $suggestions[] = 'Excellent: Target keyword at beginning of title for maximum impact';
702 - }
703 - } else {
704 - // Check for semantic variations
705 - $semantic_match = $this->check_semantic_keyword_match($title, $target_keyword);
706 - if ($semantic_match) {
707 - $score += 7;
708 - $suggestions[] = 'Good semantic keyword usage - consider including exact keyword for clarity';
709 - } else {
710 - $score += 2;
711 - $suggestions[] = "Include target keyword '{$target_keyword}' or semantic variations in title";
712 - }
713 - }
714 - } else {
715 - $score += 5; // Partial credit
716 - $suggestions[] = 'Set a target keyword to optimize title effectiveness';
717 - }
718 -
719 - return [
720 - 'score' => $score,
1117 + 'score' => $ssl ? $max_score : 0,
721 1118 'max_score' => $max_score,
722 - 'suggestions' => $suggestions,
723 - 'details' => [
724 - 'title_length' => $title_length,
725 - 'optimal_range' => '40-60 characters',
726 - 'keyword_present' => !empty($target_keyword) && strpos(strtolower($title), strtolower($target_keyword)) !== false,
727 - ]
1119 + 'suggestions' => $ssl ? [] : ['Serve the site over HTTPS (install an SSL certificate)'],
1120 + 'details' => ['ssl_enabled' => $ssl, 'security_level' => $ssl ? 'Good' : 'Insecure'],
728 1121 ];
729 1122 }
730 -
731 - /**
732 - * Check for semantic keyword matching
733 - *
734 - * @param string $text Text to analyze
735 - * @param string $keyword Target keyword
736 - * @return bool True if semantic match found
737 - */
738 1123 private function check_semantic_keyword_match(string $text, string $keyword): bool {
739 1124 // Simple semantic matching - can be enhanced with AI/NLP
740 1125 $keyword_parts = explode(' ', strtolower($keyword));
741 1126 $text_lower = strtolower($text);
@@ -749,55 +1134,69 @@
749 1134
750 1135 // Consider it a semantic match if 70% of keyword parts are present
751 1136 return ($matches / count($keyword_parts)) >= 0.7;
752 1137 }
753 -
754 - /**
755 - * Get modern grade from score (updated scale)
756 - *
757 - * @param int $score Numeric score
758 - * @return string Letter grade
759 - */
760 - private function get_modern_grade(int $score): string {
761 - if ($score >= 95) return 'A+';
762 - if ($score >= 90) return 'A';
763 - if ($score >= 85) return 'A-';
764 - if ($score >= 80) return 'B+';
765 - if ($score >= 75) return 'B';
766 - if ($score >= 70) return 'B-';
767 - if ($score >= 65) return 'C+';
768 - if ($score >= 60) return 'C';
769 - if ($score >= 55) return 'C-';
770 - if ($score >= 45) return 'D+';
771 - if ($score >= 35) return 'D';
772 - return 'F';
773 - }
774 -
775 - /**
776 - * Prioritize suggestions by impact and effort
777 - *
778 - * @param array $suggestions Raw suggestions
779 - * @return array Prioritized suggestions with metadata
780 - */
781 - 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 +
782 1157 $prioritized = [];
783 -
1158 +
784 1159 foreach ($suggestions as $suggestion) {
785 - $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 +
786 1177 $prioritized[] = [
787 1178 'text' => $suggestion,
788 1179 'priority' => $priority,
789 1180 'impact' => $this->estimate_impact($suggestion),
790 1181 'effort' => $this->estimate_effort($suggestion),
1182 + 'factor' => $origin['factor'] ?? null,
1183 + 'points_recoverable' => $origin['lost'] ?? null,
791 1184 ];
792 1185 }
793 -
794 - // 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.
795 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 + }
796 1195 $priority_order = ['High' => 3, 'Medium' => 2, 'Low' => 1];
797 1196 return $priority_order[$b['priority']] - $priority_order[$a['priority']];
798 1197 });
799 -
1198 +
800 1199 return $prioritized;
801 1200 }
802 1201
803 1202 /**
@@ -834,11 +1233,14 @@
834 1233 * @return string Impact level
835 1234 */
836 1235 private function estimate_impact(string $suggestion): string {
837 1236 // Simple heuristic - can be enhanced with ML
838 - if (strpos(strtolower($suggestion), 'title') !== false) return 'High';
839 - if (strpos(strtolower($suggestion), 'content') !== false) return 'High';
840 - 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 + }
841 1243 return 'Low';
842 1244 }
843 1245
844 1246 /**
@@ -848,86 +1250,16 @@
848 1250 * @return string Effort level
849 1251 */
850 1252 private function estimate_effort(string $suggestion): string {
851 1253 // Simple heuristic - can be enhanced with ML
852 - if (strpos(strtolower($suggestion), 'rewrite') !== false) return 'High';
853 - if (strpos(strtolower($suggestion), 'add') !== false) return 'Medium';
854 - if (strpos(strtolower($suggestion), 'optimize') !== false) return 'Medium';
855 - return 'Low';
856 - }
857 -
858 - /**
859 - * Score content quality using modern E-A-T principles
860 - *
861 - * @param array $content_data Content analysis data
862 - * @param string $target_keyword Target keyword
863 - * @return array Scoring result
864 - */
865 - private function score_content_quality(array $content_data, string $target_keyword): array {
866 - $score = 0;
867 - $max_score = $this->scoring_factors['content_quality'];
868 - $suggestions = [];
869 -
870 - $word_count = $content_data['word_count'] ?? 0;
871 - $content = $content_data['content'] ?? '';
872 -
873 - // Content depth and comprehensiveness (8 points)
874 - if ($word_count >= 1500) {
875 - $score += 8;
876 - } elseif ($word_count >= 800) {
877 - $score += 6;
878 - $suggestions[] = 'Consider expanding content for more comprehensive coverage (1500+ words ideal)';
879 - } elseif ($word_count >= 300) {
880 - $score += 4;
881 - $suggestions[] = 'Content needs more depth - aim for 800+ words for better topic coverage';
882 - } else {
883 - $suggestions[] = 'Content too thin - add substantial value with detailed information (minimum 300 words)';
1254 + if (strpos(strtolower($suggestion), 'rewrite') !== false) { return 'High';
884 1255 }
885 -
886 - // Content structure and organization (6 points)
887 - $headings = $content_data['headings'] ?? [];
888 - if (count($headings) >= 3) {
889 - $score += 6;
890 - } elseif (count($headings) >= 1) {
891 - $score += 3;
892 - $suggestions[] = 'Add more headings to improve content structure and scannability';
893 - } else {
894 - $suggestions[] = 'Add headings (H2, H3) to organize content and improve user experience';
1256 + if (strpos(strtolower($suggestion), 'add') !== false) { return 'Medium';
895 1257 }
896 -
897 - // Topic relevance and focus (6 points)
898 - if (!empty($target_keyword) && !empty($content)) {
899 - $relevance_score = $this->calculate_topic_relevance($content, $target_keyword);
900 - $score += round($relevance_score * 6);
901 -
902 - if ($relevance_score < 0.5) {
903 - $suggestions[] = "Improve content relevance to target topic '{$target_keyword}'";
904 - }
905 - } else {
906 - $score += 3; // Partial credit
907 - $suggestions[] = 'Define target keyword to optimize content focus and relevance';
1258 + if (strpos(strtolower($suggestion), 'optimize') !== false) { return 'Medium';
908 1259 }
909 -
910 - return [
911 - 'score' => $score,
912 - 'max_score' => $max_score,
913 - 'suggestions' => $suggestions,
914 - 'details' => [
915 - 'word_count' => $word_count,
916 - 'heading_count' => count($headings),
917 - 'content_depth' => $this->assess_content_depth($word_count),
918 - 'topic_coverage' => $this->assess_topic_coverage($content, $target_keyword),
919 - ]
920 - ];
1260 + return 'Low';
921 1261 }
922 -
923 - /**
924 - * Calculate topic relevance score
925 - *
926 - * @param string $content Content text
927 - * @param string $target_keyword Target keyword
928 - * @return float Relevance score (0-1)
929 - */
930 1262 private function calculate_topic_relevance(string $content, string $target_keyword): float {
931 1263 if (empty($content) || empty($target_keyword)) {
932 1264 return 0.0;
933 1265 }
@@ -999,9 +1331,9 @@
999 1331 $terms = array_merge($terms, ['optimization', 'search engine', 'ranking', 'visibility']);
1000 1332 }
1001 1333
1002 1334 // WordPress-related terms
1003 - 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.
1004 1336 $terms = array_merge($terms, ['wp', 'plugin', 'theme', 'cms']);
1005 1337 }
1006 1338
1007 1339 return $terms;
@@ -1007,494 +1339,292 @@
1007 1339 return $terms;
1008 1340 }
1009 1341
1010 1342 /**
1011 - * Assess content depth based on word count
1343 + * Keyword density (%) of the target keyword across the plain-text body.
1012 1344 *
1013 - * @param int $word_count Word count
1014 - * @return string Depth assessment
1345 + * @param string $content Raw/HTML content.
1346 + * @param string $target_keyword Target keyword.
1347 + * @return float Density percentage (0 when no keyword/content).
1015 1348 */
1016 - private function assess_content_depth(int $word_count): string {
1017 - if ($word_count >= 2000) return 'Comprehensive';
1018 - if ($word_count >= 1000) return 'Detailed';
1019 - if ($word_count >= 500) return 'Moderate';
1020 - if ($word_count >= 300) return 'Basic';
1021 - return 'Insufficient';
1349 + private function keyword_density(string $content, string $target_keyword): float {
1350 + if (empty($content) || empty($target_keyword)) {
1351 + return 0.0;
1352 + }
1353 + $plain = strtolower(wp_strip_all_tags($content));
1354 + $word_count = $this->calculate_word_count_js_style($plain);
1355 + if ($word_count === 0) {
1356 + return 0.0;
1357 + }
1358 + $occurrences = substr_count($plain, strtolower($target_keyword));
1359 + return ($occurrences / $word_count) * 100;
1022 1360 }
1023 1361
1024 1362 /**
1025 - * Assess topic coverage
1363 + * Whether the target keyword (or a semantic variation) appears in the body.
1026 1364 *
1027 - * @param string $content Content text
1028 - * @param string $target_keyword Target keyword
1029 - * @return string Coverage assessment
1365 + * @param string $content Raw/HTML content.
1366 + * @param string $target_keyword Target keyword.
1367 + * @return bool
1030 1368 */
1031 - private function assess_topic_coverage(string $content, string $target_keyword): string {
1032 - $relevance = $this->calculate_topic_relevance($content, $target_keyword);
1033 -
1034 - if ($relevance >= 0.8) return 'Excellent';
1035 - if ($relevance >= 0.6) return 'Good';
1036 - if ($relevance >= 0.4) return 'Fair';
1037 - if ($relevance >= 0.2) return 'Poor';
1038 - return 'Off-topic';
1039 - }
1040 -
1041 - /**
1042 - * Score modern heading structure
1043 - *
1044 - * @param array $headings Array of headings
1045 - * @return array Scoring result
1046 - */
1047 - private function score_modern_headings(array $headings): array {
1048 - $score = 0;
1049 - $max_score = $this->scoring_factors['heading_structure'];
1050 - $suggestions = [];
1051 -
1052 - if (empty($headings)) {
1053 - $suggestions[] = 'Add headings (H1, H2, H3) to structure content and improve readability';
1054 - return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
1369 + private function keyword_in_content(string $content, string $target_keyword): bool {
1370 + if (empty($content) || empty($target_keyword)) {
1371 + return false;
1055 1372 }
1056 -
1057 - $h1_count = 0;
1058 - $h2_count = 0;
1059 - $h3_count = 0;
1060 -
1061 - foreach ($headings as $heading) {
1062 - $level = $heading['level'] ?? 1;
1063 - switch ($level) {
1064 - case 1: $h1_count++; break;
1065 - case 2: $h2_count++; break;
1066 - case 3: $h3_count++; break;
1067 - }
1373 + $plain = strtolower(wp_strip_all_tags($content));
1374 + if (strpos($plain, strtolower($target_keyword)) !== false) {
1375 + return true;
1068 1376 }
1069 -
1070 - // H1 optimization (4 points)
1071 - if ($h1_count === 1) {
1072 - $score += 4;
1073 - } elseif ($h1_count === 0) {
1074 - $suggestions[] = 'Add exactly one H1 heading for your main topic';
1075 - } else {
1076 - $suggestions[] = 'Use only one H1 heading per page for better SEO';
1077 - }
1078 -
1079 - // H2 structure (4 points)
1080 - if ($h2_count >= 2 && $h2_count <= 8) {
1081 - $score += 4;
1082 - } elseif ($h2_count === 1) {
1083 - $score += 2;
1084 - $suggestions[] = 'Add more H2 headings to break content into logical sections';
1085 - } elseif ($h2_count === 0) {
1086 - $suggestions[] = 'Add H2 headings to organize content into main sections';
1087 - } else {
1088 - $suggestions[] = 'Too many H2 headings - consider consolidating some sections';
1089 - }
1090 -
1091 - // H3 usage (4 points)
1092 - if ($h3_count >= 1 && $h3_count <= 12) {
1093 - $score += 4;
1094 - } elseif ($h3_count === 0 && $h2_count >= 2) {
1095 - $score += 2;
1096 - $suggestions[] = 'Consider adding H3 subheadings for better content organization';
1097 - }
1098 -
1099 - return [
1100 - 'score' => $score,
1101 - 'max_score' => $max_score,
1102 - 'suggestions' => $suggestions,
1103 - 'details' => [
1104 - 'h1_count' => $h1_count,
1105 - 'h2_count' => $h2_count,
1106 - 'h3_count' => $h3_count,
1107 - 'total_headings' => count($headings),
1108 - 'structure_quality' => $this->assess_heading_structure($h1_count, $h2_count, $h3_count),
1109 - ]
1110 - ];
1377 + return $this->check_semantic_keyword_match($plain, $target_keyword);
1111 1378 }
1112 1379
1113 1380 /**
1114 - * Assess heading structure quality
1381 + * Whether the keyword appears early (first paragraph / first ~10% of words).
1115 1382 *
1116 - * @param int $h1_count H1 count
1117 - * @param int $h2_count H2 count
1118 - * @param int $h3_count H3 count
1119 - * @return string Structure quality
1383 + * @param string $content Raw/HTML content.
1384 + * @param string $target_keyword Target keyword.
1385 + * @return bool
1120 1386 */
1121 - private function assess_heading_structure(int $h1_count, int $h2_count, int $h3_count): string {
1122 - if ($h1_count === 1 && $h2_count >= 2 && $h3_count >= 1) return 'Excellent';
1123 - if ($h1_count === 1 && $h2_count >= 2) return 'Good';
1124 - if ($h1_count === 1 && $h2_count >= 1) return 'Fair';
1125 - if ($h1_count === 1) return 'Basic';
1126 - return 'Poor';
1387 + private function keyword_in_first_paragraph(string $content, string $target_keyword): bool {
1388 + if (empty($content) || empty($target_keyword)) {
1389 + return false;
1390 + }
1391 + $plain = strtolower(wp_strip_all_tags($content));
1392 + $words = preg_split('/\s+/', trim($plain), -1, PREG_SPLIT_NO_EMPTY) ?: [];
1393 + $window = array_slice($words, 0, max(50, (int) ceil(count($words) * 0.1)));
1394 + return strpos(implode(' ', $window), strtolower($target_keyword)) !== false;
1127 1395 }
1128 1396
1129 1397 /**
1130 - * Score semantic relevance using modern NLP principles
1398 + * Whether the keyword appears in any subheading (H2–H6).
1131 1399 *
1132 - * @param string $content Content text
1133 - * @param string $target_keyword Target keyword
1134 - * @return array Scoring result
1400 + * @param array $headings Extracted headings (each with 'level' + 'text').
1401 + * @param string $target_keyword Target keyword.
1402 + * @return bool
1135 1403 */
1136 - private function score_semantic_relevance(string $content, string $target_keyword): array {
1137 - $score = 0;
1138 - $max_score = $this->scoring_factors['semantic_relevance'];
1139 - $suggestions = [];
1140 -
1141 - if (empty($content)) {
1142 - $suggestions[] = 'Add content to analyze semantic relevance';
1143 - return ['score' => 0, 'max_score' => $max_score, 'suggestions' => $suggestions];
1144 - }
1145 -
1404 + private function keyword_in_subheadings(array $headings, string $target_keyword): bool {
1146 1405 if (empty($target_keyword)) {
1147 - $score += 7; // Partial credit
1148 - $suggestions[] = 'Set a target keyword to optimize semantic relevance';
1149 - return ['score' => $score, 'max_score' => $max_score, 'suggestions' => $suggestions];
1406 + return false;
1150 1407 }
1151 -
1152 - // Calculate semantic relevance
1153 - $relevance = $this->calculate_topic_relevance($content, $target_keyword);
1154 - $score = round($relevance * $max_score);
1155 -
1156 - // Provide specific suggestions based on relevance score
1157 - if ($relevance >= 0.8) {
1158 - $suggestions[] = 'Excellent semantic relevance - content strongly matches target topic';
1159 - } elseif ($relevance >= 0.6) {
1160 - $suggestions[] = 'Good semantic relevance - consider adding more related terms';
1161 - } elseif ($relevance >= 0.4) {
1162 - $suggestions[] = "Improve content relevance to '{$target_keyword}' with related terms and concepts";
1163 - } else {
1164 - $suggestions[] = "Content lacks focus on '{$target_keyword}' - add more relevant information";
1408 + $keyword_lower = strtolower($target_keyword);
1409 + foreach ($headings as $heading) {
1410 + if ((int) ($heading['level'] ?? 0) < 2) {
1411 + continue;
1412 + }
1413 + $text = strtolower((string) ($heading['text'] ?? ''));
1414 + if ($text !== '' && strpos($text, $keyword_lower) !== false) {
1415 + return true;
1416 + }
1165 1417 }
1166 -
1167 - return [
1168 - 'score' => $score,
1169 - 'max_score' => $max_score,
1170 - 'suggestions' => $suggestions,
1171 - 'details' => [
1172 - 'relevance_score' => round($relevance * 100, 1),
1173 - 'keyword_density' => $this->calculate_keyword_density($content, $target_keyword),
1174 - 'semantic_terms_found' => $this->count_semantic_terms($content, $target_keyword),
1175 - ]
1176 - ];
1418 + return false;
1177 1419 }
1178 1420
1179 1421 /**
1180 - * Calculate keyword density
1422 + * Days elapsed since a MySQL datetime string, or null when unparseable.
1181 1423 *
1182 - * @param string $content Content text
1183 - * @param string $keyword Target keyword
1184 - * @return float Keyword density percentage
1424 + * @param string $datetime MySQL datetime (e.g. post_modified).
1425 + * @return int|null
1185 1426 */
1186 - private function calculate_keyword_density(string $content, string $keyword): float {
1187 - if (empty($content) || empty($keyword)) {
1188 - return 0.0;
1427 + private function days_since(string $datetime): ?int {
1428 + $datetime = trim($datetime);
1429 + if ($datetime === '' || strpos($datetime, '0000-00-00') === 0) {
1430 + return null;
1189 1431 }
1190 -
1191 - $content_lower = strtolower(wp_strip_all_tags($content));
1192 - $keyword_lower = strtolower($keyword);
1193 -
1194 - $keyword_count = substr_count($content_lower, $keyword_lower);
1195 - $word_count = $this->calculate_word_count_js_style($content_lower);
1196 -
1197 - return $word_count > 0 ? round(($keyword_count / $word_count) * 100, 2) : 0.0;
1432 + $ts = strtotime($datetime);
1433 + if ($ts === false) {
1434 + return null;
1435 + }
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();
1440 + return (int) floor(($now - $ts) / 86400);
1198 1441 }
1199 1442
1200 1443 /**
1201 - * Count semantic terms found in content
1444 + * Whether the keyword appears in the meta description.
1202 1445 *
1203 - * @param string $content Content text
1204 - * @param string $keyword Target keyword
1205 - * @return int Number of semantic terms found
1446 + * @param string $meta_description Meta description text.
1447 + * @param string $target_keyword Target keyword.
1448 + * @return bool
1206 1449 */
1207 - private function count_semantic_terms(string $content, string $keyword): int {
1208 - $semantic_terms = $this->get_semantic_terms($keyword);
1209 - $content_lower = strtolower($content);
1210 - $found = 0;
1211 -
1212 - foreach ($semantic_terms as $term) {
1213 - if (strpos($content_lower, strtolower($term)) !== false) {
1214 - $found++;
1215 - }
1450 + private function keyword_in_meta(string $meta_description, string $target_keyword): bool {
1451 + if ($meta_description === '' || $target_keyword === '') {
1452 + return false;
1216 1453 }
1217 -
1218 - return $found;
1454 + return strpos(strtolower($meta_description), strtolower($target_keyword)) !== false;
1219 1455 }
1220 1456
1221 1457 /**
1222 - * Score user experience factors
1458 + * Whether the keyword appears in any image alt text.
1223 1459 *
1224 - * @param array $content_data Content analysis data
1225 - * @return array Scoring result
1460 + * @param array $images Images (each with an 'alt' key).
1461 + * @param string $target_keyword Target keyword.
1462 + * @return bool
1226 1463 */
1227 - private function score_user_experience(array $content_data): array {
1228 - $score = 0;
1229 - $max_score = $this->scoring_factors['user_experience'];
1230 - $suggestions = [];
1231 -
1232 - // Readability score (6 points)
1233 - $readability = $content_data['readability_score'] ?? 0;
1234 - if ($readability >= 60) {
1235 - $score += 6;
1236 - } elseif ($readability >= 40) {
1237 - $score += 4;
1238 - $suggestions[] = 'Improve readability with shorter sentences and simpler words';
1239 - } else {
1240 - $score += 2;
1241 - $suggestions[] = 'Content is difficult to read - use shorter sentences and common words';
1464 + private function keyword_in_alt(array $images, string $target_keyword): bool {
1465 + if ($target_keyword === '') {
1466 + return false;
1242 1467 }
1243 -
1244 - // Content scannability (4 points)
1245 - $headings = $content_data['headings'] ?? [];
1246 - $word_count = $content_data['word_count'] ?? 0;
1247 -
1248 - if (count($headings) >= 3 && $word_count > 500) {
1249 - $score += 4;
1250 - } elseif (count($headings) >= 1) {
1251 - $score += 2;
1252 - $suggestions[] = 'Add more headings to make content easier to scan';
1253 - } else {
1254 - $suggestions[] = 'Add headings to break up text and improve scannability';
1468 + $keyword_lower = strtolower($target_keyword);
1469 + foreach ($images as $image) {
1470 + $alt = strtolower((string) ($image['alt'] ?? ''));
1471 + if ($alt !== '' && strpos($alt, $keyword_lower) !== false) {
1472 + return true;
1473 + }
1255 1474 }
1256 -
1257 - return [
1258 - 'score' => $score,
1259 - 'max_score' => $max_score,
1260 - 'suggestions' => $suggestions,
1261 - 'details' => [
1262 - 'readability_score' => $readability,
1263 - 'readability_level' => $this->get_readability_level($readability),
1264 - 'scannability' => count($headings) >= 3 ? 'Good' : 'Needs improvement',
1265 - ]
1266 - ];
1475 + return false;
1267 1476 }
1268 1477
1269 1478 /**
1270 - * Get readability level description
1479 + * Whether the title contains a common power word (CTR booster).
1271 1480 *
1272 - * @param float $score Readability score
1273 - * @return string Readability level
1481 + * @param string $title Post title.
1482 + * @return bool
1274 1483 */
1275 - private function get_readability_level(float $score): string {
1276 - if ($score >= 90) return 'Very Easy';
1277 - if ($score >= 80) return 'Easy';
1278 - if ($score >= 70) return 'Fairly Easy';
1279 - if ($score >= 60) return 'Standard';
1280 - if ($score >= 50) return 'Fairly Difficult';
1281 - if ($score >= 30) return 'Difficult';
1282 - return 'Very Difficult';
1484 + private function title_has_power_word(string $title): bool {
1485 + $title_lower = strtolower($title);
1486 + foreach (self::get_title_power_words() as $word) {
1487 + if (strpos($title_lower, $word) !== false) {
1488 + return true;
1489 + }
1490 + }
1491 + return false;
1283 1492 }
1284 1493
1285 1494 /**
1286 - * Score strategic internal linking
1495 + * The power words rewarded by the title check. Single source of truth so the
1496 + * AI title improver can require the generated title to actually contain one.
1287 1497 *
1288 - * @param array $content_data Content analysis data
1289 - * @return array Scoring result
1498 + * @return string[]
1290 1499 */
1291 - private function score_strategic_linking(array $content_data): array {
1292 - $score = 0;
1293 - $max_score = $this->scoring_factors['internal_linking'];
1294 - $suggestions = [];
1295 -
1296 - $internal_links = $content_data['internal_links'] ?? 0;
1297 - $word_count = $content_data['word_count'] ?? 0;
1298 -
1299 - // Calculate optimal link ratio (1 link per 150-200 words)
1300 - $optimal_links = max(1, round($word_count / 175));
1301 -
1302 - if ($internal_links >= $optimal_links) {
1303 - $score += 8;
1304 - } elseif ($internal_links >= max(1, $optimal_links - 1)) {
1305 - $score += 6;
1306 - $suggestions[] = 'Consider adding 1-2 more internal links to related content';
1307 - } elseif ($internal_links >= 1) {
1308 - $score += 4;
1309 - $suggestions[] = 'Add more internal links to improve site navigation and SEO';
1310 - } else {
1311 - $suggestions[] = 'Add internal links to related pages to improve site structure';
1312 - }
1313 -
1500 + public static function get_title_power_words(): array {
1314 1501 return [
1315 - 'score' => $score,
1316 - 'max_score' => $max_score,
1317 - 'suggestions' => $suggestions,
1318 - 'details' => [
1319 - 'current_links' => $internal_links,
1320 - 'optimal_links' => $optimal_links,
1321 - 'link_ratio' => $word_count > 0 ? round($word_count / max(1, $internal_links)) : 0,
1322 - ]
1502 + 'ultimate', 'essential', 'complete', 'proven', 'guide', 'best', 'top',
1503 + 'free', 'easy', 'simple', 'quick', 'fast', 'powerful', 'secret', 'expert',
1504 + 'effective', 'amazing', 'incredible', 'exclusive', 'definitive', 'step-by-step',
1323 1505 ];
1324 1506 }
1325 1507
1326 1508 /**
1327 - * Score external authority signals
1509 + * The emotion/sentiment words rewarded by the title check. Single source of
1510 + * truth shared with the AI title improver so a generated title satisfies the
1511 + * same validation.
1328 1512 *
1329 - * @param array $content_data Content analysis data
1330 - * @return array Scoring result
1513 + * @return string[]
1331 1514 */
1332 - private function score_external_authority(array $content_data): array {
1333 - $score = 0;
1334 - $max_score = $this->scoring_factors['external_authority'];
1335 - $suggestions = [];
1336 -
1337 - $external_links = $content_data['external_links'] ?? 0;
1338 -
1339 - if ($external_links >= 2 && $external_links <= 5) {
1340 - $score += 7;
1341 - } elseif ($external_links === 1) {
1342 - $score += 5;
1343 - $suggestions[] = 'Consider adding 1-2 more high-quality external links to authoritative sources';
1344 - } elseif ($external_links === 0) {
1345 - $score += 2;
1346 - $suggestions[] = 'Add external links to authoritative sources to support your content';
1347 - } else {
1348 - $score += 4;
1349 - $suggestions[] = 'Too many external links - focus on 2-5 high-quality authoritative sources';
1350 - }
1351 -
1515 + public static function get_title_sentiment_words(): array {
1352 1516 return [
1353 - 'score' => $score,
1354 - 'max_score' => $max_score,
1355 - 'suggestions' => $suggestions,
1356 - 'details' => [
1357 - 'external_links' => $external_links,
1358 - 'optimal_range' => '2-5 links',
1359 - 'authority_signal' => $external_links > 0 ? 'Present' : 'Missing',
1360 - ]
1517 + // Positive
1518 + 'great', 'good', 'better', 'awesome', 'love', 'win', 'boost', 'improve',
1519 + 'success', 'smart', 'brilliant', 'perfect', 'happy', 'beautiful',
1520 + // Negative (drives clicks too)
1521 + 'avoid', 'mistake', 'worst', 'stop', 'never', 'bad', 'wrong', 'fail',
1522 + 'danger', 'warning', 'painful', 'ugly',
1361 1523 ];
1362 1524 }
1363 1525
1364 1526 /**
1365 - * Score technical SEO factors
1527 + * Whether the title carries an emotional/sentiment word (positive or
1528 + * negative), which Rank Math rewards for higher engagement.
1366 1529 *
1367 - * @param array $content_data Content analysis data
1368 - * @param array $metadata Post metadata
1369 - * @param array $options Additional options
1370 - * @return array Scoring result
1530 + * @param string $title Post title.
1531 + * @return bool
1371 1532 */
1372 - private function score_technical_seo(array $content_data, array $metadata, array $options): array {
1373 - $score = 0;
1374 - $max_score = $this->scoring_factors['technical_seo'];
1375 - $suggestions = [];
1376 -
1377 - // Meta description (2 points)
1378 - $meta_desc = $metadata['description'] ?? '';
1379 - $meta_length = strlen($meta_desc);
1380 -
1381 - if ($meta_length >= 120 && $meta_length <= 160) {
1382 - $score += 2;
1383 - } elseif ($meta_length >= 100 && $meta_length <= 180) {
1384 - $score += 1;
1385 - $suggestions[] = 'Optimize meta description length to 120-160 characters';
1386 - } else {
1387 - $suggestions[] = empty($meta_desc) ?
1388 - 'Add a compelling meta description (120-160 characters)' :
1389 - 'Adjust meta description length to 120-160 characters';
1390 - }
1391 -
1392 - // Image optimization (2 points)
1393 - $images = $content_data['images'] ?? [];
1394 - if (!empty($images)) {
1395 - $images_with_alt = array_filter($images, function($img) {
1396 - return !empty($img['alt']);
1397 - });
1398 -
1399 - $alt_ratio = count($images_with_alt) / count($images);
1400 - if ($alt_ratio >= 0.9) {
1401 - $score += 2;
1402 - } elseif ($alt_ratio >= 0.7) {
1403 - $score += 1;
1404 - $suggestions[] = 'Add alt text to remaining images for better accessibility';
1405 - } else {
1406 - $suggestions[] = 'Add descriptive alt text to all images';
1533 + private function title_has_sentiment_word(string $title): bool {
1534 + $title_lower = strtolower($title);
1535 + foreach (self::get_title_sentiment_words() as $word) {
1536 + if (strpos($title_lower, $word) !== false) {
1537 + return true;
1407 1538 }
1408 - } else {
1409 - $score += 1; // Partial credit if no images
1410 1539 }
1411 -
1412 - // URL structure (1 point)
1413 - $url = $content_data['url'] ?? '';
1414 - if (!empty($url)) {
1415 - $slug = basename(wp_parse_url($url, PHP_URL_PATH));
1416 - if (strlen($slug) <= 60 && !empty($options['target_keyword'])) {
1417 - if (strpos(strtolower($slug), strtolower($options['target_keyword'])) !== false) {
1418 - $score += 1;
1419 - } else {
1420 - $suggestions[] = 'Include target keyword in URL slug for better SEO';
1421 - }
1422 - } else {
1423 - $suggestions[] = 'Optimize URL structure - keep it short and descriptive';
1424 - }
1425 - }
1426 -
1427 - return [
1428 - 'score' => $score,
1429 - 'max_score' => $max_score,
1430 - 'suggestions' => $suggestions,
1431 - 'details' => [
1432 - 'meta_description_length' => $meta_length,
1433 - 'images_with_alt' => !empty($images) ? count(array_filter($images, function($img) { return !empty($img['alt']); })) : 0,
1434 - 'total_images' => count($images),
1435 - 'url_optimized' => !empty($url) && !empty($options['target_keyword']) ?
1436 - strpos(strtolower(basename(wp_parse_url($url, PHP_URL_PATH))), strtolower($options['target_keyword'])) !== false : false,
1437 - ]
1438 - ];
1540 + return false;
1439 1541 }
1440 1542
1441 1543 /**
1442 - * Score content freshness
1544 + * Assess content depth based on word count
1443 1545 *
1444 - * @param array $content_data Content analysis data
1445 - * @return array Scoring result
1546 + * @param int $word_count Word count
1547 + * @return string Depth assessment
1446 1548 */
1549 + private function assess_content_depth(int $word_count): string {
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 + }
1558 + return 'Insufficient';
1559 + }
1447 1560 private function score_content_freshness(array $content_data): array {
1448 - $score = 3; // Default full score for new content
1449 1561 $max_score = $this->scoring_factors['content_freshness'];
1450 1562 $suggestions = [];
1451 1563
1452 - // For now, give full score - can be enhanced with post date analysis
1453 - $suggestions[] = 'Keep content updated regularly for better freshness signals';
1564 + // Derive freshness from the real last-modified date when available
1565 + // (live editing has no stored date yet -> treat as fresh).
1566 + $days = $this->days_since((string) ($content_data['post_modified'] ?? ''));
1454 1567
1568 + if ($days === null || $days <= 180) {
1569 + $score = $max_score;
1570 + $status = 'Current';
1571 + } elseif ($days <= 365) {
1572 + $score = (int) round($max_score * 0.66);
1573 + $status = 'Aging';
1574 + $suggestions[] = 'Content is 6-12 months old - review and refresh it for better freshness signals';
1575 + } else {
1576 + $score = (int) round($max_score * 0.33);
1577 + $status = 'Stale';
1578 + $suggestions[] = 'Content is over a year old - update it to maintain freshness signals';
1579 + }
1580 +
1455 1581 return [
1456 1582 'score' => $score,
1457 1583 'max_score' => $max_score,
1458 1584 'suggestions' => $suggestions,
1459 1585 'details' => [
1460 - 'freshness_status' => 'Current',
1461 - 'last_updated' => current_time('mysql'),
1462 - ]
1586 + 'freshness_status' => $status,
1587 + 'days_since_modified' => $days,
1588 + ],
1463 1589 ];
1464 1590 }
1465 -
1466 1591 /**
1467 - * Score mobile optimization
1592 + * Resolve a post's content into something worth analyzing.
1468 1593 *
1469 - * @param array $content_data Content analysis data
1470 - * @return array Scoring result
1594 + * Delegates to Builder_Content, which knows where each page builder keeps
1595 + * its text. Kept as the historical entry point for existing callers.
1596 + *
1597 + * @since 1.23.0
1598 + *
1599 + * @param \WP_Post $post Post being analyzed.
1600 + * @return string Content to analyze.
1471 1601 */
1472 - private function score_mobile_optimization(array $content_data): array {
1473 - $score = 2; // Default full score - basic mobile checks
1474 - $max_score = $this->scoring_factors['mobile_optimization'];
1475 - $suggestions = [];
1602 + public static function resolve_analyzable_content(\WP_Post $post): string {
1603 + if (!class_exists('\ThinkRank\SEO\Builder_Content')) {
1604 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1605 + }
1476 1606
1477 - // For now, give full score - can be enhanced with actual mobile testing
1478 - $suggestions[] = 'Ensure content displays well on mobile devices';
1479 -
1480 - return [
1481 - 'score' => $score,
1482 - 'max_score' => $max_score,
1483 - 'suggestions' => $suggestions,
1484 - 'details' => [
1485 - 'mobile_friendly' => 'Assumed',
1486 - 'responsive_design' => 'Theme dependent',
1487 - ]
1488 - ];
1607 + return \ThinkRank\SEO\Builder_Content::resolve($post);
1489 1608 }
1490 1609
1491 1610 /**
1492 - * Analyze post content and extract data for scoring
1611 + * Resolve editor-supplied live content into something worth analyzing.
1493 1612 *
1494 - * @param int $post_id Post ID
1495 - * @return array Content analysis data
1613 + * @since 1.23.0
1614 + *
1615 + * @param string $live_content Markup supplied by the editor.
1616 + * @param \WP_Post $post Post the markup belongs to.
1617 + * @return string Content to analyze.
1496 1618 */
1619 + public static function resolve_live_content(string $live_content, \WP_Post $post): string {
1620 + if (!class_exists('\ThinkRank\SEO\Builder_Content')) {
1621 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1622 + }
1623 +
1624 + return \ThinkRank\SEO\Builder_Content::resolve_markup($live_content, $post);
1625 + }
1626 +
1497 1627 public function analyze_post_content(int $post_id): array {
1498 1628 $post = get_post($post_id);
1499 1629 if (!$post) {
1500 1630 return [];
@@ -1499,9 +1629,9 @@
1499 1629 if (!$post) {
1500 1630 return [];
1501 1631 }
1502 1632
1503 - $content = $post->post_content;
1633 + $content = self::resolve_analyzable_content($post);
1504 1634 $title = $post->post_title;
1505 1635
1506 1636 // Extract headings from content
1507 1637 $headings = $this->extract_headings($content);
@@ -1515,8 +1645,9 @@
1515 1645
1516 1646 // Count links
1517 1647 $internal_links = $this->count_internal_links($content);
1518 1648 $external_links = $this->count_external_links($content);
1649 + $external_dofollow_links = $this->count_external_dofollow_links($content);
1519 1650
1520 1651 // Analyze images
1521 1652 $images = $this->analyze_images($content);
1522 1653
@@ -1530,14 +1661,135 @@
1530 1661 'word_count' => $word_count,
1531 1662 'readability_score' => $readability_score,
1532 1663 'internal_links' => $internal_links,
1533 1664 'external_links' => $external_links,
1665 + 'external_dofollow_links' => $external_dofollow_links,
1534 1666 'images' => $images,
1535 1667 'url' => $url,
1668 + 'slug' => $post->post_name,
1669 + 'post_modified' => $post->post_modified,
1670 + 'schema_present' => $this->detect_schema_present($content)
1671 + || $this->thinkrank_global_schema_active($post->post_type)
1672 + || $this->thinkrank_deployed_schema_active($post),
1536 1673 ];
1537 1674 }
1538 1675
1539 1676 /**
1677 + * Build the human-readable readability label (mirrors the editor's
1678 + * calculateReadabilityScore: "<level> (<flesch>)") from analyzed content.
1679 + *
1680 + * @param array $content_data Output of analyze_post_content()/analyze_live_content()
1681 + * @return string Readability label, e.g. "Standard (62)"
1682 + */
1683 + private function format_readability_label(array $content_data): string {
1684 + if ((int) ($content_data['word_count'] ?? 0) === 0) {
1685 + return 'No content';
1686 + }
1687 +
1688 + $rounded = (int) round((float) ($content_data['readability_score'] ?? 0));
1689 +
1690 + if ($rounded >= 90) {
1691 + $level = 'Very Easy';
1692 + } elseif ($rounded >= 80) {
1693 + $level = 'Easy';
1694 + } elseif ($rounded >= 70) {
1695 + $level = 'Fairly Easy';
1696 + } elseif ($rounded >= 60) {
1697 + $level = 'Standard';
1698 + } elseif ($rounded >= 50) {
1699 + $level = 'Fairly Difficult';
1700 + } elseif ($rounded >= 30) {
1701 + $level = 'Difficult';
1702 + } else {
1703 + $level = 'Very Difficult';
1704 + }
1705 +
1706 + return "{$level} ({$rounded})";
1707 + }
1708 +
1709 + /**
1710 + * Derive the content-quality label (mirrors the editor's
1711 + * calculateContentQuality: word count + long-paragraph + subheading scoring)
1712 + * so persisted scores carry a non-null quality value.
1713 + *
1714 + * @param array $content_data Output of analyze_post_content()/analyze_live_content()
1715 + * @return string One of: No content, Good, OK, Needs improvement
1716 + */
1717 + private function derive_content_quality(array $content_data): string {
1718 + $word_count = (int) ($content_data['word_count'] ?? 0);
1719 + if ($word_count === 0) {
1720 + return 'No content';
1721 + }
1722 +
1723 + $final = $this->derive_content_quality_score($content_data);
1724 +
1725 + if ($final >= 80) {
1726 + return 'Good';
1727 + }
1728 + if ($final >= 50) {
1729 + return 'OK';
1730 + }
1731 +
1732 + return 'Needs improvement';
1733 + }
1734 +
1735 + /**
1736 + * Numeric content-quality score (0-100): word count + long-paragraph +
1737 + * subheading distribution. Shared by the quality label and the
1738 + * satisfying-content factor so both stay in sync.
1739 + *
1740 + * @param array $content_data Output of analyze_post_content()/analyze_live_content()
1741 + * @return int Quality score 0-100.
1742 + */
1743 + private function derive_content_quality_score(array $content_data): int {
1744 + $word_count = (int) ($content_data['word_count'] ?? 0);
1745 + if ($word_count === 0) {
1746 + return 0;
1747 + }
1748 +
1749 + $content = (string) ($content_data['content'] ?? '');
1750 + $score = 0;
1751 +
1752 + // 1. Word count (industry standard: 300+ words).
1753 + if ($word_count >= 600) {
1754 + $score += 100;
1755 + } elseif ($word_count >= 300) {
1756 + $score += 50;
1757 + }
1758 +
1759 + // 2. Long paragraphs (flag paragraphs over 150 words).
1760 + $long_paragraphs = 0;
1761 + if (preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $content, $matches)) {
1762 + foreach ($matches[1] as $paragraph) {
1763 + if ($this->calculate_word_count_js_style(wp_strip_all_tags($paragraph)) > 150) {
1764 + $long_paragraphs++;
1765 + }
1766 + }
1767 + }
1768 + if ($long_paragraphs === 0) {
1769 + $score += 100;
1770 + } elseif ($long_paragraphs <= 2) {
1771 + $score += 50;
1772 + }
1773 +
1774 + // 3. Subheading distribution (H2–H6, ~one per 300 words).
1775 + $subheadings = 0;
1776 + foreach ((array) ($content_data['headings'] ?? []) as $heading) {
1777 + if ((int) ($heading['level'] ?? 0) >= 2) {
1778 + $subheadings++;
1779 + }
1780 + }
1781 + $expected = (int) floor($word_count / 300);
1782 + if ($subheadings > 0 && $subheadings >= $expected) {
1783 + $score += 100;
1784 + } elseif ($subheadings > 0) {
1785 + $score += 50;
1786 + }
1787 +
1788 + return (int) round($score / 3);
1789 + }
1790 +
1791 + /**
1540 1792 * Extract headings from content
1541 1793 *
1542 1794 * @param string $content Content HTML
1543 1795 * @return array Array of headings with levels
@@ -1601,9 +1853,27 @@
1601 1853 $words = preg_split('/\s+/', trim(strtolower(wp_strip_all_tags($text))), -1, PREG_SPLIT_NO_EMPTY);
1602 1854 $syllables = 0;
1603 1855
1604 1856 foreach ($words as $word) {
1605 - $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);
1606 1876 }
1607 1877
1608 1878 return $syllables;
1609 1879 }
@@ -1650,13 +1920,117 @@
1650 1920 return $count;
1651 1921 }
1652 1922
1653 1923 /**
1924 + * Count external links that pass link equity (not rel="nofollow").
1925 + * Mirrors Rank Math's "external dofollow link" check.
1926 + *
1927 + * @param string $content Content HTML
1928 + * @return int External dofollow link count
1929 + */
1930 + private function count_external_dofollow_links(string $content): int {
1931 + $site_url = get_site_url();
1932 + $count = 0;
1933 +
1934 + if (preg_match_all('/<a\b[^>]*>/i', $content, $matches)) {
1935 + foreach ($matches[0] as $tag) {
1936 + if (!preg_match('/href=["\']([^"\']+)["\']/i', $tag, $href)) {
1937 + continue;
1938 + }
1939 + $url = $href[1];
1940 + $is_external = strpos($url, 'http') === 0 && strpos($url, $site_url) === false;
1941 + if (!$is_external) {
1942 + continue;
1943 + }
1944 + if (preg_match('/rel=["\'][^"\']*\bnofollow\b[^"\']*["\']/i', $tag)) {
1945 + continue;
1946 + }
1947 + $count++;
1948 + }
1949 + }
1950 +
1951 + return $count;
1952 + }
1953 +
1954 + /**
1654 1955 * Analyze images in content
1655 1956 *
1656 1957 * @param string $content Content HTML
1657 1958 * @return array Image analysis data
1658 1959 */
1960 + /**
1961 + * Detect structured data embedded directly in the content (JSON-LD script
1962 + * blocks or microdata attributes). Site-wide schema injected at render time
1963 + * is a separate feature and intentionally out of scope here.
1964 + *
1965 + * @param string $content Raw/HTML content.
1966 + * @return bool
1967 + */
1968 + private function detect_schema_present(string $content): bool {
1969 + if ($content === '') {
1970 + return false;
1971 + }
1972 + return stripos($content, 'application/ld+json') !== false
1973 + || stripos($content, 'itemscope') !== false
1974 + || stripos($content, 'itemtype') !== false;
1975 + }
1976 +
1977 + /**
1978 + * Whether ThinkRank's Global SEO schema output is active for a post type.
1979 + *
1980 + * ThinkRank injects JSON-LD at render time (wp_head) when a schema type is
1981 + * configured for the post type, so a post can have valid structured data
1982 + * even when none is embedded in the post body. The score credits this so the
1983 + * "add structured data" suggestion reflects ThinkRank's own schema engine.
1984 + *
1985 + * @param string $post_type Post type slug.
1986 + * @return bool
1987 + */
1988 + private function thinkrank_global_schema_active(string $post_type): bool {
1989 + if ($post_type === '') {
1990 + return false;
1991 + }
1992 + $all_settings = get_option('thinkrank_global_seo_settings', []);
1993 + return !empty($all_settings[$post_type]['schema_type']);
1994 + }
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 +
1659 2033 private function analyze_images(string $content): array {
1660 2034 $images = [];
1661 2035
1662 2036 if (preg_match_all('/<img[^>]+>/i', $content, $matches)) {
@@ -1693,25 +2067,42 @@
1693 2067 global $wpdb;
1694 2068
1695 2069 $table_name = $wpdb->prefix . 'thinkrank_seo_scores';
1696 2070
1697 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score storage requires direct database access
2071 + // Prepare data for insertion
2072 + $insert_data = [
2073 + 'post_id' => $post_id,
2074 + 'user_id' => $user_id,
2075 + 'overall_score' => $score_data['overall_score'],
2076 + 'score_breakdown' => wp_json_encode($score_data['score_breakdown']),
2077 + 'suggestions' => wp_json_encode($score_data['suggestions']),
2078 + 'grade' => $score_data['grade'],
2079 + 'algorithm_version' => $score_data['algorithm_version'] ?? '2024.1',
2080 + 'calculated_at' => $score_data['calculated_at'],
2081 + 'created_at' => current_time('mysql'),
2082 + ];
2083 +
2084 + $format = [
2085 + '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s'
2086 + ];
2087 +
2088 + // Add readability_score if provided
2089 + if (isset($score_data['readability_score'])) {
2090 + $insert_data['readability_score'] = $score_data['readability_score'];
2091 + $format[] = '%s';
2092 + }
2093 +
2094 + // Add content_quality if provided
2095 + if (isset($score_data['content_quality'])) {
2096 + $insert_data['content_quality'] = $score_data['content_quality'];
2097 + $format[] = '%s';
2098 + }
2099 +
2100 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score storage requires direct database access
1698 2101 $result = $wpdb->insert(
1699 2102 $table_name,
1700 - [
1701 - 'post_id' => $post_id,
1702 - 'user_id' => $user_id,
1703 - 'overall_score' => $score_data['overall_score'],
1704 - 'score_breakdown' => json_encode($score_data['score_breakdown']),
1705 - 'suggestions' => json_encode($score_data['suggestions']),
1706 - 'grade' => $score_data['grade'],
1707 - 'algorithm_version' => $score_data['algorithm_version'] ?? '2024.1',
1708 - 'calculated_at' => $score_data['calculated_at'],
1709 - 'created_at' => current_time('mysql'),
1710 - ],
1711 - [
1712 - '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s'
1713 - ]
2103 + $insert_data,
2104 + $format
1714 2105 );
1715 2106
1716 2107 return $result ? $wpdb->insert_id : false;
1717 2108 }
@@ -1728,12 +2119,12 @@
1728 2119
1729 2120 // Get table name and escape it properly (table names cannot be parameterized)
1730 2121 $table_name = esc_sql($wpdb->prefix . 'thinkrank_seo_scores');
1731 2122
1732 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score history requires direct database access
2123 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score history requires direct database access
1733 2124 $results = $wpdb->get_results(
1734 2125 $wpdb->prepare(
1735 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped using esc_sql()
2126 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1736 2127 "SELECT * FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT %d",
1737 2128 $post_id,
1738 2129 $limit
1739 2130 ),
@@ -1766,17 +2157,101 @@
1766 2157 * @param array $content_data Content analysis data
1767 2158 * @return array Scoring result
1768 2159 */
1769 2160 private function score_mobile_experience(array $content_data): array {
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 +
1770 2180 return [
1771 - 'score' => 4, // Default good score for mobile
1772 - 'max_score' => $this->scoring_factors['mobile_experience'],
1773 - 'suggestions' => ['Ensure mobile-first design and fast loading on mobile devices'],
1774 - 'details' => ['mobile_score' => 'Good']
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 + ],
1775 2191 ];
1776 2192 }
1777 2193
1778 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 + /**
1779 2254 * Score core web vitals - 2025 version (3 points)
1780 2255 *
1781 2256 * @param array $content_data Content analysis data
1782 2257 * @return array Scoring result
@@ -1781,17 +2256,98 @@
1781 2256 * @param array $content_data Content analysis data
1782 2257 * @return array Scoring result
1783 2258 */
1784 2259 private function score_core_web_vitals(array $content_data): array {
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 +
1785 2278 return [
1786 - 'score' => 2, // Default partial score
1787 - 'max_score' => $this->scoring_factors['core_web_vitals'],
1788 - 'suggestions' => ['Optimize Core Web Vitals: LCP, FID, and CLS for better user experience'],
1789 - 'details' => ['vitals_status' => 'Needs improvement']
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 + ],
1790 2293 ];
1791 2294 }
1792 2295
1793 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 + /**
1794 2350 * Score internal linking - declining importance (1 point)
1795 2351 *
1796 2352 * @param array $content_data Content analysis data
1797 2353 * @return array Scoring result
@@ -1821,9 +2377,9 @@
1821 2377 $suggestions = [];
1822 2378
1823 2379 // Meta description check
1824 2380 $meta_desc = $metadata['description'] ?? '';
1825 - if (!empty($meta_desc) && strlen($meta_desc) >= 120 && 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) {
1826 2382 $score += 0.5;
1827 2383 } else {
1828 2384 $suggestions[] = 'Add a compelling meta description (120-160 characters)';
1829 2385 }
@@ -1839,9 +2395,9 @@
1839 2395 'score' => $score,
1840 2396 'max_score' => $this->scoring_factors['technical_factors'],
1841 2397 'suggestions' => $suggestions,
1842 2398 'details' => [
1843 - 'meta_description_length' => strlen($meta_desc),
2399 + 'meta_description_length' => mb_strlen($meta_desc),
1844 2400 'schema_present' => !empty($content_data['schema_present'])
1845 2401 ]
1846 2402 ];
1847 2403 }
@@ -1854,19 +2410,30 @@
1854 2410 */
1855 2411 private function get_grade_from_score($score): string {
1856 2412 $score = (int) $score; // Ensure it's an integer
1857 2413
1858 - if ($score >= 95) return 'A+';
1859 - if ($score >= 90) return 'A';
1860 - if ($score >= 85) return 'A-';
1861 - if ($score >= 80) return 'B+';
1862 - if ($score >= 75) return 'B';
1863 - if ($score >= 70) return 'B-';
1864 - if ($score >= 65) return 'C+';
1865 - if ($score >= 60) return 'C';
1866 - if ($score >= 55) return 'C-';
1867 - if ($score >= 45) return 'D+';
1868 - 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 + }
1869 2436 return 'F';
1870 2437 }
1871 2438
1872 2439 /**
@@ -1882,9 +2449,15 @@
1882 2449 if (!$post) {
1883 2450 return [];
1884 2451 }
1885 2452
1886 - $content = $live_content; // Use live content instead of $post->post_content
2453 + // Resolve the live string the same way stored content is resolved. On a
2454 + // builder page the editor hands over raw builder markup (the block
2455 + // editor cannot render blocks it has no client-side registration for),
2456 + // which analyzed as-is reads as zero words — the reason a Divi page
2457 + // could show a correct saved score beside a live panel still claiming
2458 + // "No content".
2459 + $content = self::resolve_live_content($live_content, $post);
1887 2460 $title = $post->post_title;
1888 2461
1889 2462 // Extract headings from content
1890 2463 $headings = $this->extract_headings($content);
@@ -1898,8 +2471,9 @@
1898 2471
1899 2472 // Count links
1900 2473 $internal_links = $this->count_internal_links($content);
1901 2474 $external_links = $this->count_external_links($content);
2475 + $external_dofollow_links = $this->count_external_dofollow_links($content);
1902 2476
1903 2477 // Analyze images
1904 2478 $images = $this->analyze_images($content);
1905 2479
@@ -1913,10 +2487,16 @@
1913 2487 'word_count' => $word_count,
1914 2488 'readability_score' => $readability_score,
1915 2489 'internal_links' => $internal_links,
1916 2490 'external_links' => $external_links,
2491 + 'external_dofollow_links' => $external_dofollow_links,
1917 2492 'images' => $images,
1918 2493 'url' => $url,
2494 + 'slug' => $post->post_name,
2495 + 'post_modified' => $post->post_modified,
2496 + 'schema_present' => $this->detect_schema_present($content)
2497 + || $this->thinkrank_global_schema_active($post->post_type)
2498 + || $this->thinkrank_deployed_schema_active($post),
1919 2499 ];
1920 2500 }
1921 2501
1922 2502 /**
@@ -1948,11 +2528,11 @@
1948 2528 // Get table name and escape it properly (table names cannot be parameterized)
1949 2529 $table_name = esc_sql($wpdb->prefix . 'thinkrank_seo_scores');
1950 2530
1951 2531 // Get the most recent score for this post
1952 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO score retrieval requires direct database access
2532 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access
1953 2533 $result = $wpdb->get_row($wpdb->prepare(
1954 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped using esc_sql()
2534 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1955 2535 "SELECT * FROM `{$table_name}`
1956 2536 WHERE post_id = %d
1957 2537 ORDER BY calculated_at DESC
1958 2538 LIMIT 1",