PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
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 1.28.0 All 57 releases
← All changes | includes/ai/class-seo-score-calculator.php +323 -46 2.9.0 → 2.14.2 View file →
@@ -140,8 +140,9 @@
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);
144 145
145 146 return $result;
146 147 }
147 148
@@ -251,8 +252,9 @@
251 252 $best['target_keyword'] = $best_keyword;
252 253 $best['target_keywords'] = $keywords;
253 254 $best['keyword_results'] = $per_keyword;
254 255 $best['keyword_checks'] = $this->analyze_keyword_checks($content_data, $metadata, $keywords);
256 + $best['keywords'] = $this->keyword_placements($content_data, $metadata, $keywords);
255 257
256 258 return $best;
257 259 }
258 260
@@ -267,15 +269,15 @@
267 269 * @param string[] $keywords Target keywords.
268 270 * @return array<string,array{passed:bool,matched_keywords:string[]}>
269 271 */
270 272 private function analyze_keyword_checks(array $content_data, array $metadata, array $keywords): array {
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'] ?? '')));
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'] ?? '')));
274 276
275 277 $alts = '';
276 278 foreach ((array) ($content_data['images'] ?? []) as $image) {
277 - $alts .= ' ' . strtolower((string) ($image['alt'] ?? ''));
279 + $alts .= ' ' . self::lower((string) ($image['alt'] ?? ''));
278 280 }
279 281
280 282 // Build a searchable slug haystack from the post's OWN slug — never the
281 283 // full URL path. The path carries ancestors, category bases and date
@@ -284,17 +286,9 @@
284 286 // way: an unpublished post has no pretty permalink (get_permalink()
285 287 // returns ?p=123), so the path held no slug at all and every draft
286 288 // scored "no match" until it was published. Hyphens/underscores become
287 289 // spaces so multi-word keywords can match.
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));
290 + $slug = self::lower(self::slug_haystack($content_data));
297 291
298 292 $haystacks = [
299 293 'title' => trim($title),
300 294 'meta_description' => trim($description),
@@ -306,9 +300,9 @@
306 300 $checks = [];
307 301 foreach ($haystacks as $location => $haystack) {
308 302 $matched = [];
309 303 foreach ($keywords as $keyword) {
310 - if ($this->keyword_matches($haystack, strtolower(trim($keyword)))) {
304 + if ($this->keyword_matches($haystack, self::lower(trim($keyword)))) {
311 305 $matched[] = $keyword;
312 306 }
313 307 }
314 308 $checks[$location] = [
@@ -320,8 +314,210 @@
320 314 return $checks;
321 315 }
322 316
323 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 + /**
324 520 * Scripts written without spaces between words.
325 521 *
326 522 * @since 2.1.0
327 523 * @var string
@@ -1067,14 +1263,16 @@
1067 1263 if (empty($content) || empty($target_keyword)) {
1068 1264 return 0.0;
1069 1265 }
1070 1266
1071 - $content_lower = strtolower(wp_strip_all_tags($content));
1267 + // Occurrences and the word count both come from the reading text, so
1268 + // shortcode syntax is in neither.
1269 + $content_lower = strtolower(self::reading_text_of($content));
1072 1270 $keyword_lower = strtolower($target_keyword);
1073 1271
1074 1272 // Calculate keyword and semantic term frequency
1075 1273 $keyword_count = substr_count($content_lower, $keyword_lower);
1076 - $word_count = $this->calculate_word_count_js_style($content_lower);
1274 + $word_count = \ThinkRank\SEO\Word_Count_Index::count_words($content_lower);
1077 1275
1078 1276 if ($word_count === 0) {
1079 1277 return 0.0;
1080 1278 }
@@ -1153,10 +1351,13 @@
1153 1351 private function keyword_density(string $content, string $target_keyword): float {
1154 1352 if (empty($content) || empty($target_keyword)) {
1155 1353 return 0.0;
1156 1354 }
1157 - $plain = strtolower(wp_strip_all_tags($content));
1158 - $word_count = $this->calculate_word_count_js_style($plain);
1355 + // Numerator and denominator from the same reading text. Counting the
1356 + // keyword in wp_strip_all_tags() output found it inside shortcode
1357 + // attributes the word count no longer includes.
1358 + $plain = strtolower(self::reading_text_of($content));
1359 + $word_count = \ThinkRank\SEO\Word_Count_Index::count_words($plain);
1159 1360 if ($word_count === 0) {
1160 1361 return 0.0;
1161 1362 }
1162 1363 $occurrences = substr_count($plain, strtolower($target_keyword));
@@ -1191,10 +1392,10 @@
1191 1392 private function keyword_in_first_paragraph(string $content, string $target_keyword): bool {
1192 1393 if (empty($content) || empty($target_keyword)) {
1193 1394 return false;
1194 1395 }
1195 - $plain = strtolower(wp_strip_all_tags($content));
1196 - $words = preg_split('/\s+/', trim($plain), -1, PREG_SPLIT_NO_EMPTY) ?: [];
1396 + $plain = strtolower(self::reading_text_of($content));
1397 + $words = preg_split('/\s+/', $plain, -1, PREG_SPLIT_NO_EMPTY) ?: [];
1197 1398 $window = array_slice($words, 0, max(50, (int) ceil(count($words) * 0.1)));
1198 1399 return strpos(implode(' ', $window), strtolower($target_keyword)) !== false;
1199 1400 }
1200 1401
@@ -1424,9 +1625,24 @@
1424 1625 if (!class_exists('\ThinkRank\SEO\Builder_Content')) {
1425 1626 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1426 1627 }
1427 1628
1428 - return \ThinkRank\SEO\Builder_Content::resolve_markup($live_content, $post);
1629 + // Bind the live markup to the post the render makes current. Since #862
1630 + // the resolver runs setup_postdata() on this post, so a shortcode that
1631 + // builds its output from the current post's content — get_the_content(),
1632 + // get_post()->post_content, as a table of contents or a reading-time
1633 + // shortcode does — otherwise read the last saved body while the unsaved
1634 + // markup rendered around it, and lagged a save behind (#864).
1635 + //
1636 + // A clone, not the caller's object: the post is current only for the
1637 + // duration of the render and the caller's $post must come back
1638 + // unchanged. `thinkrank_analyzable_content` receives the clone too,
1639 + // which is what makes $post->post_content there agree with the markup
1640 + // being analyzed on the live path.
1641 + $bound = clone $post;
1642 + $bound->post_content = $live_content;
1643 +
1644 + return \ThinkRank\SEO\Builder_Content::resolve_markup($live_content, $bound);
1429 1645 }
1430 1646
1431 1647 public function analyze_post_content(int $post_id): array {
1432 1648 $post = get_post($post_id);
@@ -1439,11 +1655,12 @@
1439 1655
1440 1656 // Extract headings from content
1441 1657 $headings = $this->extract_headings($content);
1442 1658
1443 - // Count words using JavaScript-compatible method
1444 - $plain_text = wp_strip_all_tags($content);
1445 - $word_count = $this->calculate_word_count_js_style($plain_text);
1659 + // Count the markup, not wp_strip_all_tags() output: stripping deletes
1660 + // tags without a space, so "five</p><p>six" would already be one word
1661 + // before the counter saw it.
1662 + $word_count = $this->calculate_word_count_js_style($content);
1446 1663
1447 1664 // Calculate readability
1448 1665 $readability_score = $this->calculate_readability_score($content);
1449 1666
@@ -1563,9 +1780,9 @@
1563 1780 // 2. Long paragraphs (flag paragraphs over 150 words).
1564 1781 $long_paragraphs = 0;
1565 1782 if (preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $content, $matches)) {
1566 1783 foreach ($matches[1] as $paragraph) {
1567 - if ($this->calculate_word_count_js_style(wp_strip_all_tags($paragraph)) > 150) {
1784 + if ($this->calculate_word_count_js_style($paragraph) > 150) {
1568 1785 $long_paragraphs++;
1569 1786 }
1570 1787 }
1571 1788 }
@@ -1620,22 +1837,22 @@
1620 1837 * @param string $content Content text
1621 1838 * @return float Readability score
1622 1839 */
1623 1840 private function calculate_readability_score(string $content): float {
1624 - $text = wp_strip_all_tags($content);
1841 + // Sentences, words and syllables all come from one reading text. The
1842 + // word count drops shortcode syntax and punctuation-only tokens; when
1843 + // sentences and syllables were still read off wp_strip_all_tags()
1844 + // output, "[vc_column width="1/2"]" added syllables (and, with a "." in
1845 + // an attribute, sentences) to a word count that did not include it,
1846 + // and a WPBakery page's Flesch fell from 65 to 46.
1847 + $text = self::reading_text_of($content);
1625 1848
1626 - if (empty($text)) {
1849 + if ('' === $text) {
1627 1850 return 0;
1628 1851 }
1629 1852
1630 - // Count sentences (approximate)
1631 - $sentences = preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY);
1632 - $sentence_count = count($sentences);
1633 -
1634 - // Count words
1635 - $word_count = $this->calculate_word_count_js_style(wp_strip_all_tags($text));
1636 -
1637 - // Count syllables (approximate)
1853 + $sentence_count = self::count_sentences($text);
1854 + $word_count = \ThinkRank\SEO\Word_Count_Index::count_words($text);
1638 1855 $syllable_count = $this->count_syllables($text);
1639 1856
1640 1857 if ($sentence_count === 0 || $word_count === 0) {
1641 1858 return 0;
@@ -1647,15 +1864,37 @@
1647 1864 return max(0, min(100, $score));
1648 1865 }
1649 1866
1650 1867 /**
1868 + * Count the sentences in reading text.
1869 + *
1870 + * A sentence is a run of text between terminal punctuation that holds at
1871 + * least one letter or digit. A fragment with no word in it (the space
1872 + * after the final full stop, a stray "!" between two "?") is not a
1873 + * sentence. contentAnalysis.js countSentences() applies the same rule.
1874 + *
1875 + * @since 2.14.2
1876 + *
1877 + * @param string $text Text as returned by {@see self::reading_text_of()}.
1878 + * @return int
1879 + */
1880 + private static function count_sentences(string $text): int {
1881 + $fragments = preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
1882 +
1883 + return count(preg_grep('/[\p{L}\p{N}]/u', $fragments) ?: []);
1884 + }
1885 +
1886 + /**
1651 1887 * Count syllables in text (approximate)
1652 1888 *
1889 + * Expects reading text ({@see self::reading_text_of()}); tags are not
1890 + * stripped here, so a decoded "<" in prose is not read as a tag.
1891 + *
1653 1892 * @param string $text Text to analyze
1654 1893 * @return int Syllable count
1655 1894 */
1656 1895 private function count_syllables(string $text): int {
1657 - $words = preg_split('/\s+/', trim(strtolower(wp_strip_all_tags($text))), -1, PREG_SPLIT_NO_EMPTY);
1896 + $words = preg_split('/\s+/', trim(strtolower($text)), -1, PREG_SPLIT_NO_EMPTY) ?: [];
1658 1897 $syllables = 0;
1659 1898
1660 1899 foreach ($words as $word) {
1661 1900 $word = preg_replace('/[^a-z]/', '', $word);
@@ -2265,11 +2504,12 @@
2265 2504
2266 2505 // Extract headings from content
2267 2506 $headings = $this->extract_headings($content);
2268 2507
2269 - // Count words using JavaScript-compatible method
2270 - $plain_text = wp_strip_all_tags($content);
2271 - $word_count = $this->calculate_word_count_js_style($plain_text);
2508 + // Count the markup, not wp_strip_all_tags() output: stripping deletes
2509 + // tags without a space, so "five</p><p>six" would already be one word
2510 + // before the counter saw it.
2511 + $word_count = $this->calculate_word_count_js_style($content);
2272 2512
2273 2513 // Calculate readability
2274 2514 $readability_score = $this->calculate_readability_score($content);
2275 2515
@@ -2303,22 +2543,59 @@
2303 2543 ];
2304 2544 }
2305 2545
2306 2546 /**
2307 - * Calculate word count using JavaScript-compatible method
2308 - * Matches the logic in contentAnalysis.js for consistency
2547 + * Count the words a reader reads in HTML or text.
2309 2548 *
2310 - * @param string $text Text to count words in
2549 + * The same extraction and counting as the thin content report
2550 + * ({@see \ThinkRank\SEO\Word_Count_Index::reading_text()} and
2551 + * {@see \ThinkRank\SEO\Word_Count_Index::count_words()}), so the editor
2552 + * score and the report give one number for one page. Splitting on
2553 + * whitespace counted shortcode syntax as words: a WPBakery page with 216
2554 + * words of prose scored a word count of 325 while the report said 216
2555 + * (#893 fixed the report only). It also counted tokens of punctuation
2556 + * alone, such as a full stop after a link.
2557 + *
2558 + * Always words, whatever the locale's unit, because every threshold that
2559 + * reads this value (content length, long paragraphs, headings per 300
2560 + * words, readability, keyword density) is in words.
2561 + *
2562 + * contentAnalysis.js calculateWordCount() applies the same two rules in
2563 + * the editor.
2564 + *
2565 + * @param string $text HTML or text to count words in.
2311 2566 * @return int Word count
2312 2567 */
2313 2568 private function calculate_word_count_js_style(string $text): int {
2314 - if (empty($text)) {
2569 + if ('' === $text) {
2315 2570 return 0;
2316 2571 }
2317 2572
2318 - // Match JavaScript: trim, split by whitespace, filter empty
2319 - $words = preg_split('/\s+/', trim($text), -1, PREG_SPLIT_NO_EMPTY);
2320 - return count($words);
2573 + return \ThinkRank\SEO\Word_Count_Index::count_words(self::reading_text_of($text));
2574 + }
2575 +
2576 + /**
2577 + * The text a reader reads in HTML or text, as the word count sees it.
2578 + *
2579 + * Every check that divides by the word count (readability, keyword
2580 + * density, topic relevance) reads its numerator from this same text, so
2581 + * shortcode syntax is never on one side of a ratio and not the other.
2582 + *
2583 + * @since 2.14.2
2584 + *
2585 + * @param string $content HTML or text.
2586 + * @return string Plain text, whitespace collapsed.
2587 + */
2588 + private static function reading_text_of(string $content): string {
2589 + if ('' === $content) {
2590 + return '';
2591 + }
2592 +
2593 + if (!class_exists('\ThinkRank\SEO\Word_Count_Index')) {
2594 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-word-count-index.php';
2595 + }
2596 +
2597 + return \ThinkRank\SEO\Word_Count_Index::reading_text($content);
2321 2598 }
2322 2599
2323 2600 /**
2324 2601 * Get existing score data for a post from database