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/seo/class-seo-analyzer.php +258 -26 2.14.0 → 2.14.2 View file →
@@ -67,8 +67,37 @@
67 67 'toggle', // Elementor
68 68 'faq', // Widely used in third-party add-on element names
69 69 ];
70 70
71 + /**
72 + * Builder meta keys that never describe the published page.
73 + *
74 + * `_fl_builder_draft` holds Beaver Builder changes that were never
75 + * published, so a FAQ that exists only there is not on the page (#945).
76 + *
77 + * @since 2.14.2
78 + * @var string[]
79 + */
80 + private const UNPUBLISHED_BUILDER_META_KEYS = ['_fl_builder_draft'];
81 +
82 + /**
83 + * Builder layouts that only count while their builder renders the post.
84 + *
85 + * Elementor and Beaver Builder both keep their stored layout after the
86 + * author switches the page back to the block editor, so the layout alone
87 + * does not mean the builder renders it. Each key maps to the builder's own
88 + * flag and the value that means "on": Elementor's `_elementor_edit_mode`
89 + * is `builder`, Beaver's `_fl_builder_enabled` is any non-empty value
90 + * (null here). The same tests as FAQ_Content::builder() (#945).
91 + *
92 + * @since 2.14.2
93 + * @var array<string,array{0:string,1:string|null}>
94 + */
95 + private const FLAGGED_BUILDER_LAYOUTS = [
96 + '_elementor_data' => ['_elementor_edit_mode', 'builder'],
97 + '_fl_builder_data' => ['_fl_builder_enabled', null],
98 + ];
99 +
71 100 // Check result statuses.
72 101 public const PASSED = 'passed';
73 102 public const WARNING = 'warning';
74 103 public const FAILED = 'failed';
@@ -1245,8 +1274,13 @@
1245 1274 * REST request would fire every shortcode and block renderer on the site.
1246 1275 * The structural signals these checks look for (headings, lists, tables,
1247 1276 * the opening passage) survive in the stored markup.
1248 1277 *
1278 + * Except on a builder page, where they do not: the words and headings are
1279 + * in builder meta and `post_content` is empty, or, on a Bricks page, holds
1280 + * blocks Bricks never renders. Those rows read from builder_content(), so
1281 + * a builder-built site is graded on its pages rather than skipped (#892).
1282 + *
1249 1283 * @since 2.5.0
1250 1284 * @return array<int,array{id: int, content: string, text: string, modified: int}>
1251 1285 */
1252 1286 private function get_content_sample(): array {
@@ -1255,10 +1289,12 @@
1255 1289 }
1256 1290
1257 1291 $post_ids = $this->sample_post_ids();
1258 1292
1293 + // Meta too: builder_content() asks every sampled post whether Bricks
1294 + // owns it, and one query beats a hundred.
1259 1295 if (function_exists('_prime_post_caches')) {
1260 - _prime_post_caches($post_ids, false, false);
1296 + _prime_post_caches($post_ids, false, true);
1261 1297 }
1262 1298
1263 1299 $sample = [];
1264 1300
@@ -1269,9 +1305,9 @@
1269 1305 }
1270 1306
1271 1307 $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false;
1272 1308
1273 - $content = (string) $post->post_content;
1309 + $content = $this->builder_content($post) ?? (string) $post->post_content;
1274 1310
1275 1311 $sample[] = [
1276 1312 'id' => (int) $post->ID,
1277 1313 'content' => $content,
@@ -1285,8 +1321,45 @@
1285 1321 return $sample;
1286 1322 }
1287 1323
1288 1324 /**
1325 + * A sampled post's content from its page builder, when that is where it is.
1326 + *
1327 + * Null for every post whose `post_content` is what the visitor reads, so
1328 + * those keep the raw-markup path and never pay for rendering. A post with
1329 + * an empty `post_content`, or a Bricks page that discards it, resolves
1330 + * through Builder_Content, the extractor every other server-side scoring
1331 + * path reads through (#617). With nothing in `post_content` to render, the
1332 + * resolution only reads stored builder meta: no shortcode or block
1333 + * renderer runs.
1334 + *
1335 + * Guarded like builder_meta_keys(), so a partial checkout degrades to the
1336 + * raw markup instead of fataling mid-audit.
1337 + *
1338 + * @since 2.15.0
1339 + *
1340 + * @param \WP_Post $post Sampled post.
1341 + * @return string|null Resolved content, or null to use `post_content`.
1342 + */
1343 + private function builder_content(\WP_Post $post): ?string {
1344 + if ([] === self::builder_meta_keys()) {
1345 + return null;
1346 + }
1347 +
1348 + if ('' !== trim((string) $post->post_content)
1349 + && !Builder_Content::bricks_supersedes_post_content((int) $post->ID)
1350 + ) {
1351 + return null;
1352 + }
1353 +
1354 + try {
1355 + return Builder_Content::resolve($post);
1356 + } catch (\Throwable $e) {
1357 + return null;
1358 + }
1359 + }
1360 +
1361 + /**
1289 1362 * Shared shape for the content-sampled GEO checks: count how many posts in
1290 1363 * the sample satisfy a predicate and grade it on the coverage thresholds
1291 1364 * the Content category already uses.
1292 1365 *
@@ -1307,23 +1380,30 @@
1307 1380 string $fail_text,
1308 1381 string $how_to_fix,
1309 1382 string $empty_text
1310 1383 ): array {
1311 - // A page whose body is a shortcode or a builder layout leaves no
1312 - // extractable text, so every prose-shaped question here answers "no"
1313 - // for it — Cart, Checkout, My account and Shop would drag the category
1314 - // down over content nobody wants quoted in an AI answer. Skipping them
1315 - // is deliberate: this grades the pages that could be cited.
1316 - $sample = [];
1317 - foreach ($this->get_content_sample() as $row) {
1384 + // A page whose body is only a shortcode or a commerce block (Checkout,
1385 + // My account, Shop) leaves no extractable text, so every prose-shaped
1386 + // question here answers "no" for it and would drag the category down
1387 + // over content nobody wants quoted in an AI answer. Skipping them is
1388 + // deliberate: this grades the pages that could be cited. Builder pages
1389 + // are not in this group: get_content_sample() reads their builder
1390 + // storage, so they have text and are graded (#892).
1391 + $sampled = $this->get_content_sample();
1392 + $sample = [];
1393 + $textless = [];
1394 + foreach ($sampled as $row) {
1318 1395 if ('' !== $row['text']) {
1319 1396 $sample[] = $row;
1397 + } else {
1398 + $textless[] = $row['id'];
1320 1399 }
1321 1400 }
1322 1401
1323 1402 $total = count($sample);
1324 1403
1325 - if (0 === $total) {
1404 + // Nothing published: nothing to report.
1405 + if ([] === $sampled) {
1326 1406 return [
1327 1407 'label' => $label,
1328 1408 'status' => self::PASSED,
1329 1409 'message' => $empty_text,
@@ -1329,8 +1409,32 @@
1329 1409 'message' => $empty_text,
1330 1410 ];
1331 1411 }
1332 1412
1413 + // Pages exist, but none yielded text. That is a sample this check
1414 + // could not read, not an empty site, and it used to pass with "No
1415 + // published content to check yet", awarding the weight for content
1416 + // nobody examined (#892). Say so instead.
1417 + if (0 === $total) {
1418 + return [
1419 + 'label' => $label,
1420 + 'status' => self::WARNING,
1421 + 'message' => sprintf(
1422 + /* translators: %d: number of sampled pages. */
1423 + _n(
1424 + 'ThinkRank found no readable text on the %d page it sampled, so this could not be checked.',
1425 + 'ThinkRank found no readable text on any of the %d pages it sampled, so this could not be checked.',
1426 + count($textless),
1427 + 'thinkrank'
1428 + ),
1429 + count($textless)
1430 + ),
1431 + 'how_to_fix' => __('Pages built only from shortcodes, or with a page builder ThinkRank does not support, have no text it can read. Publish pages with written content, or check that your page builder is supported.', 'thinkrank'),
1432 + 'value' => sprintf('0/%d', count($textless)),
1433 + 'affected_posts' => $this->affected_posts($textless),
1434 + ];
1435 + }
1436 +
1333 1437 $matching = 0;
1334 1438 $failing = [];
1335 1439 foreach ($sample as $row) {
1336 1440 if ($predicate($row)) {
@@ -1617,12 +1721,9 @@
1617 1721 }
1618 1722
1619 1723 $collecting = false;
1620 1724
1621 - // `/` is the canonical full block; `/*` is the same instruction
1622 - // written for a wildcard-aware crawler, and every answer engine on
1623 - // the list is one.
1624 - if ('disallow' === $field && ('/' === $value || '/*' === $value)) {
1725 + if ('disallow' === $field && self::disallow_blocks_everything($value)) {
1625 1726 foreach ($current as $agent) {
1626 1727 $groups[$agent] = true;
1627 1728 }
1628 1729 }
@@ -1631,8 +1732,46 @@
1631 1732 return $groups;
1632 1733 }
1633 1734
1634 1735 /**
1736 + * Whether a `Disallow:` value matches every URL on the site.
1737 + *
1738 + * `/` is the canonical full block; `/*` is the same instruction written
1739 + * for a wildcard-aware crawler, and every answer engine on the list is
1740 + * one. A value is a path pattern in which `*` matches any run of
1741 + * characters, so a bare `*` (and `**`, `/**`) matches every path too, and
1742 + * Google's parser treats it that way. Recognising only `/` and `/*`
1743 + * reported a site that blocked everyone with `Disallow: *` as reachable
1744 + * by every answer engine (#890).
1745 + *
1746 + * A trailing `$` anchors the pattern to the end of the URL. After a `*`
1747 + * it changes nothing, since "any run of characters, then the end" still
1748 + * matches every path, so `*$` and `/*$` are full blocks as well. Without
1749 + * a `*` it narrows the match: `/$` is the home page only, and `/*.pdf$`
1750 + * is PDFs only, so neither counts.
1751 + *
1752 + * An empty value means "allow everything" and must never count as a
1753 + * block, so it is rejected here rather than left to the caller.
1754 + *
1755 + * `Allow:` lines are deliberately not weighed against this: a full
1756 + * robots.txt evaluator with longest-match precedence is a separate change.
1757 + *
1758 + * @since 2.14.2
1759 + * @param string $value Trimmed `Disallow:` value, comment already removed.
1760 + * @return bool
1761 + */
1762 + private static function disallow_blocks_everything(string $value): bool {
1763 + if ('' === $value) {
1764 + return false;
1765 + }
1766 +
1767 + // Optional leading slash, then either nothing (`/`) or a run of `*`
1768 + // with an optional end anchor (`*`, `/*`, `/*$`, `**$`). A `$` with
1769 + // no `*` before it never matches here, so `/$` stays a partial block.
1770 + return 1 === preg_match('#^/?(?:\*+\$?)?$#', $value);
1771 + }
1772 +
1773 + /**
1635 1774 * /llms.txt should be published — it is the one file whose entire purpose
1636 1775 * is telling an AI assistant what this site is and what to read.
1637 1776 *
1638 1777 * @since 2.5.0
@@ -1787,10 +1926,10 @@
1787 1926 }
1788 1927
1789 1928 // Builder trees: ThinkRank's own elements, and the builders' generic
1790 1929 // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ
1791 - // is actually built from.
1792 - $meta_keys = self::builder_meta_keys();
1930 + // is actually built from. Only layouts the visitor is served count.
1931 + $meta_keys = self::rendered_builder_meta_keys();
1793 1932
1794 1933 if (empty($meta_keys)) {
1795 1934 return false;
1796 1935 }
@@ -1808,8 +1947,10 @@
1808 1947 $marker_clauses[] = 'pm.meta_value LIKE %s';
1809 1948 $marker_values[] = '%' . $wpdb->esc_like($marker) . '%';
1810 1949 }
1811 1950
1951 + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm');
1952 +
1812 1953 // Same exemption: both placeholder runs are sized from fixed lists —
1813 1954 // the builder meta keys and the marker list — and every value is
1814 1955 // prepared.
1815 1956 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -1818,11 +1959,12 @@
1818 1959 "SELECT 1 FROM {$wpdb->postmeta} pm
1819 1960 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1820 1961 WHERE p.post_status = 'publish'
1821 1962 AND pm.meta_key IN ({$key_placeholders})
1822 - AND (" . implode(' OR ', $marker_clauses) . ')
1823 - LIMIT 1',
1824 - ...array_merge($meta_keys, $marker_values)
1963 + AND (" . implode(' OR ', $marker_clauses) . ")
1964 + {$gate_sql}
1965 + LIMIT 1",
1966 + ...array_merge($meta_keys, $marker_values, $gate_values)
1825 1967 )
1826 1968 );
1827 1969 // phpcs:enable
1828 1970
@@ -1939,9 +2081,9 @@
1939 2081 $content_clauses[] = 'p.post_content LIKE %s';
1940 2082 $content_values[] = '%' . $wpdb->esc_like($marker) . '%';
1941 2083 }
1942 2084
1943 - $meta_keys = self::builder_meta_keys();
2085 + $meta_keys = self::rendered_builder_meta_keys();
1944 2086 $meta_sql = '';
1945 2087 $values = $content_values;
1946 2088
1947 2089 if (!empty($meta_keys)) {
@@ -1951,16 +2093,19 @@
1951 2093 $meta_clauses[] = 'pm.meta_value LIKE %s';
1952 2094 $meta_values[] = '%' . $wpdb->esc_like($marker) . '%';
1953 2095 }
1954 2096
2097 + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm');
2098 +
1955 2099 $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s'));
1956 2100 $meta_sql = " OR EXISTS (
1957 2101 SELECT 1 FROM {$wpdb->postmeta} pm
1958 2102 WHERE pm.post_id = p.ID
1959 2103 AND pm.meta_key IN ({$key_placeholders})
1960 - AND (" . implode(' OR ', $meta_clauses) . ')
1961 - )';
1962 - $values = array_merge($content_values, $meta_keys, $meta_values);
2104 + AND (" . implode(' OR ', $meta_clauses) . ")
2105 + {$gate_sql}
2106 + )";
2107 + $values = array_merge($content_values, $meta_keys, $meta_values, $gate_values);
1963 2108 }
1964 2109
1965 2110 $values[] = self::CONTENT_SAMPLE_SIZE;
1966 2111
@@ -2014,9 +2159,13 @@
2014 2159 return false;
2015 2160 }
2016 2161
2017 2162 // Elementor stores its tree as JSON, so the widget name appears verbatim.
2018 - $elementor = get_post_meta($post_id, '_elementor_data', true);
2163 + // Read only while Elementor renders the post: the tree survives
2164 + // switching the page back to the block editor (#945).
2165 + $elementor = self::builder_layout_renders($post_id, '_elementor_data')
2166 + ? get_post_meta($post_id, '_elementor_data', true)
2167 + : '';
2019 2168 if (is_string($elementor)
2020 2169 && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"')
2021 2170 || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) {
2022 2171 return true;
@@ -2028,9 +2177,12 @@
2028 2177 return true;
2029 2178 }
2030 2179
2031 2180 // Beaver Builder keeps its layout in postmeta as a map of node objects.
2032 - $layout = get_post_meta($post_id, '_fl_builder_data', true);
2181 + // Read only while Beaver renders the post, for the same reason (#945).
2182 + $layout = self::builder_layout_renders($post_id, '_fl_builder_data')
2183 + ? get_post_meta($post_id, '_fl_builder_data', true)
2184 + : [];
2033 2185 if (is_array($layout)) {
2034 2186 foreach ($layout as $node) {
2035 2187 $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
2036 2188 $settings = is_object($settings) ? get_object_vars($settings) : $settings;
@@ -2047,9 +2199,9 @@
2047 2199 // while the page was publishing exactly that (#686). Builder_Content
2048 2200 // already knows every key involved — Oxygen 6 is Breakdance under the
2049 2201 // hood, and older releases used two other keys — so ask it rather than
2050 2202 // keeping a second list that can drift.
2051 - foreach (self::builder_meta_keys() as $meta_key) {
2203 + foreach (self::rendered_builder_meta_keys() as $meta_key) {
2052 2204 if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) {
2053 2205 continue; // Handled above, in their own storage shapes.
2054 2206 }
2055 2207
@@ -2082,8 +2234,88 @@
2082 2234 require_once $file;
2083 2235 }
2084 2236
2085 2237 return Builder_Content::builder_meta_keys();
2238 + }
2239 +
2240 + /**
2241 + * Builder meta keys that can hold the published page.
2242 + *
2243 + * {@see builder_meta_keys()} without the unpublished draft keys, which
2244 + * Builder_Content lists because the word-count index watches them, not
2245 + * because they are ever served (#945).
2246 + *
2247 + * @since 2.14.2
2248 + * @return string[]
2249 + */
2250 + private static function rendered_builder_meta_keys(): array {
2251 + return array_values(array_diff(self::builder_meta_keys(), self::UNPUBLISHED_BUILDER_META_KEYS));
2252 + }
2253 +
2254 + /**
2255 + * Whether a builder layout key holds what the visitor is served.
2256 + *
2257 + * The answer-content readers' guard for #945. Builder_Content gains the
2258 + * same guards for its resolver in #910, and once that lands this can ask
2259 + * it instead; until then it reads the builders' own flags, exactly as
2260 + * FAQ_Content::builder() does.
2261 + *
2262 + * @since 2.14.2
2263 + * @param int $post_id Post being inspected.
2264 + * @param string $meta_key Builder meta key about to be read.
2265 + * @return bool
2266 + */
2267 + private static function builder_layout_renders(int $post_id, string $meta_key): bool {
2268 + if (in_array($meta_key, self::UNPUBLISHED_BUILDER_META_KEYS, true)) {
2269 + return false;
2270 + }
2271 +
2272 + if (!isset(self::FLAGGED_BUILDER_LAYOUTS[$meta_key])) {
2273 + return true;
2274 + }
2275 +
2276 + [$flag, $on] = self::FLAGGED_BUILDER_LAYOUTS[$meta_key];
2277 + $value = get_post_meta($post_id, $flag, true);
2278 +
2279 + return null === $on ? !empty($value) : $on === (string) $value;
2280 + }
2281 +
2282 + /**
2283 + * {@see builder_layout_renders()} as an SQL condition on a postmeta row.
2284 + *
2285 + * The site-wide queries match markers in SQL with nothing confirming each
2286 + * row in PHP afterwards, so the flag test has to happen there too. Rows
2287 + * under any other key pass untouched. `!empty()` in PHP is false for '' and
2288 + * '0', which is what the "any non-empty value" branch excludes.
2289 + *
2290 + * @since 2.14.2
2291 + * @param string $alias Alias of the postmeta row being tested.
2292 + * @return array{0:string,1:array<int,string>} ` AND (...)` clause and its values.
2293 + */
2294 + private static function builder_layout_renders_sql(string $alias): array {
2295 + global $wpdb;
2296 +
2297 + $flagged = array_keys(self::FLAGGED_BUILDER_LAYOUTS);
2298 + $clauses = [$alias . '.meta_key NOT IN (' . implode(', ', array_fill(0, count($flagged), '%s')) . ')'];
2299 + $values = $flagged;
2300 +
2301 + foreach (self::FLAGGED_BUILDER_LAYOUTS as $meta_key => [$flag, $on]) {
2302 + $test = null === $on ? "flag.meta_value NOT IN ('', '0')" : 'flag.meta_value = %s';
2303 + $clauses[] = "({$alias}.meta_key = %s AND EXISTS (
2304 + SELECT 1 FROM {$wpdb->postmeta} flag
2305 + WHERE flag.post_id = {$alias}.post_id
2306 + AND flag.meta_key = %s
2307 + AND {$test}
2308 + ))";
2309 + $values[] = $meta_key;
2310 + $values[] = $flag;
2311 +
2312 + if (null !== $on) {
2313 + $values[] = $on;
2314 + }
2315 + }
2316 +
2317 + return [' AND (' . implode(' OR ', $clauses) . ')', $values];
2086 2318 }
2087 2319
2088 2320 /**
2089 2321 * Whether a stored builder blob names a ThinkRank FAQ or How-To.