PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/ai/class-seo-score-calculator.php +14 -210 trunk → 2.10.0 View file →
@@ -140,9 +140,8 @@
140 140 'grade' => $result['grade'],
141 141 ]];
142 142 $result['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
143 143 }
144 - $result['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
145 144
146 145 return $result;
147 146 }
148 147
@@ -252,9 +251,8 @@
252 251 $best['target_keyword'] = $best_keyword;
253 252 $best['target_keywords'] = $keywords;
254 253 $best['keyword_results'] = $per_keyword;
255 254 $best['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
256 - $best['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
257 255
258 256 return $best;
259 257 }
260 258
@@ -269,15 +267,15 @@
269 267 * @param string[] $keywords Target keywords.
270 268 * @return array<string,array{passed:bool,matched_keywords:string[]}>
271 269 */
272 270 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'] ?? '')));
271 + $title = strtolower((string) ($metadata['title'] ?? $content_data['title'] ?? ''));
272 + $description = strtolower((string) ($metadata['description'] ?? ''));
273 + $content = strtolower(wp_strip_all_tags((string) ($content_data['content'] ?? '')));
276 274
277 275 $alts = '';
278 276 foreach ((array) ($content_data['images'] ?? []) as $image) {
279 - $alts .= ' ' . self::lower((string) ($image['alt'] ?? ''));
277 + $alts .= ' ' . strtolower((string) ($image['alt'] ?? ''));
280 278 }
281 279
282 280 // Build a searchable slug haystack from the post's OWN slug — never the
283 281 // full URL path. The path carries ancestors, category bases and date
@@ -286,9 +284,17 @@
286 284 // way: an unpublished post has no pretty permalink (get_permalink()
287 285 // returns ?p=123), so the path held no slug at all and every draft
288 286 // scored "no match" until it was published. Hyphens/underscores become
289 287 // spaces so multi-word keywords can match.
290 - $slug = self::lower(self::slug_haystack($content_data));
288 + $slug_source = (string) ($content_data['slug'] ?? '');
289 + if ($slug_source === '') {
290 + // Draft with no slug assigned yet: score what WordPress would
291 + // generate from the title, which is what the editor shows as the
292 + // proposed URL — so the check reads the same before and after
293 + // publishing instead of flipping.
294 + $slug_source = sanitize_title((string) ($content_data['title'] ?? ''));
295 + }
296 + $slug = strtolower(str_replace(['-', '_'], ' ', $slug_source));
291 297
292 298 $haystacks = [
293 299 'title' => trim($title),
294 300 'meta_description' => trim($description),
@@ -300,9 +306,9 @@
300 306 $checks = [];
301 307 foreach ($haystacks as $location => $haystack) {
302 308 $matched = [];
303 309 foreach ($keywords as $keyword) {
304 - if ($this->keyword_matches($haystack, self::lower(trim($keyword)))) {
310 + if ($this->keyword_matches($haystack, strtolower(trim($keyword)))) {
305 311 $matched[] = $keyword;
306 312 }
307 313 }
308 314 $checks[$location] = [
@@ -311,210 +317,8 @@
311 317 ];
312 318 }
313 319
314 320 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 321 }
518 322
519 323 /**
520 324 * Scripts written without spaces between words.