PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.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 1.0.1 1.0.2 1.1.0 All 49 releases
← All changes | includes/seo/class-seo-analyzer.php +325 -1 2.6.02.8.0 View file →
@@ -48,8 +48,27 @@
48 48 */
49 49 private const ANSWER_FAQ_NAME = 'thinkrank-faq';
50 50 private const ANSWER_HOWTO_NAME = 'thinkrank-howto';
51 51
52 + /**
53 + * Element names page builders give their own accordion / FAQ widgets.
54 + *
55 + * Detection used to recognise ThinkRank's surfaces only, so an FAQ built
56 + * with the builder's own accordion was invisible — and the check then
57 + * advised adding FAQ content to a site that already had it (#686). Used
58 + * for the "is there Q&A content here?" question only; whether ThinkRank
59 + * *emits* schema for it is a separate question, still answered by
60 + * ThinkRank's own element names.
61 + *
62 + * @since 2.7.0
63 + * @var string[]
64 + */
65 + private const GENERIC_QA_ELEMENT_MARKERS = [
66 + 'accordion', // Elementor, Bricks, Beaver Builder, Breakdance, Oxygen
67 + 'toggle', // Elementor
68 + 'faq', // Widely used in third-party add-on element names
69 + ];
70 +
52 71 // Check result statuses.
53 72 public const PASSED = 'passed';
54 73 public const WARNING = 'warning';
55 74 public const FAILED = 'failed';
@@ -1184,8 +1203,16 @@
1184 1203 */
1185 1204 private $sample_post_ids = null;
1186 1205
1187 1206 /**
1207 + * Memoised answer to "does this site publish Q&A content?".
1208 + *
1209 + * @since 2.7.0
1210 + * @var bool|null
1211 + */
1212 + private $site_has_qa_content = null;
1213 +
1214 + /**
1188 1215 * The sampled post ids, fetched once and shared by every check that reads
1189 1216 * the same slice.
1190 1217 *
1191 1218 * @since 2.5.0
@@ -1658,9 +1685,18 @@
1658 1685 'message' => __('Your site publishes FAQ, How-To or Q&A structured data, which AI answers can quote question-and-answer pairs from directly.', 'thinkrank'),
1659 1686 ];
1660 1687 }
1661 1688
1662 - $how_to_fix = __('Add a ThinkRank FAQ or How-To block, widget or element to your key pages, or enable the FAQPage / HowTo schema types under Essential SEO → Schema.', 'thinkrank');
1689 + // Only advise FAQ/HowTo markup where there is question-and-answer
1690 + // content to mark up. This used to be assigned unconditionally and
1691 + // reused by both branches below, so a site of ordinary articles was
1692 + // told to enable FAQPage — and a user who follows that gets FAQPage
1693 + // schema with an empty or invented mainEntity, which is the failure
1694 + // #494 documents. The check is allowed to report the gap; it is not
1695 + // allowed to recommend manufacturing the content (#686).
1696 + $how_to_fix = $this->site_has_qa_content()
1697 + ? __('Add a ThinkRank FAQ or How-To block, widget or element to the pages that answer questions, or enable the FAQPage / HowTo schema types under Essential SEO → Schema.', 'thinkrank')
1698 + : __('Only mark up question-and-answer content you already publish. If this site does not answer discrete questions, answer-shaped schema does not apply and there is nothing to fix here.', 'thinkrank');
1663 1699
1664 1700 // Article/WebPage schema still tells an engine what the page is; the
1665 1701 // gap is the answer pairing, not structured data as a whole.
1666 1702 if ($this->schema_is_output()) {
@@ -1680,8 +1716,147 @@
1680 1716 ];
1681 1717 }
1682 1718
1683 1719 /**
1720 + * Whether the site plausibly publishes question-and-answer content.
1721 + *
1722 + * Deliberately a different question from has_answer_schema(). That one asks
1723 + * "does ThinkRank emit answer markup?", which is what the check reports on.
1724 + * This one asks "is there anything here that answer markup would describe?",
1725 + * which is what decides whether recommending it is sound advice — and it has
1726 + * to see content ThinkRank did not author, because an FAQ built with a page
1727 + * builder's own accordion is still an FAQ (#686).
1728 + *
1729 + * Any one signal is enough; all of them are bounded.
1730 + *
1731 + * @since 2.7.0
1732 + * @return bool
1733 + */
1734 + private function site_has_qa_content(): bool {
1735 + if (null !== $this->site_has_qa_content) {
1736 + return $this->site_has_qa_content;
1737 + }
1738 +
1739 + $this->site_has_qa_content = $this->qa_content_markers_exist()
1740 + || $this->sample_has_question_headings();
1741 +
1742 + return $this->site_has_qa_content;
1743 + }
1744 +
1745 + /**
1746 + * Q&A markers in stored content or builder trees, site-wide.
1747 + *
1748 + * Two bounded queries rather than a walk over the content sample: the
1749 + * sample is the 100 most recent posts and pages, so a site whose only FAQ
1750 + * lives in a custom post type, or further back than that, answered "no
1751 + * answer content" while publishing exactly that (#686).
1752 + *
1753 + * @since 2.7.0
1754 + * @return bool
1755 + */
1756 + private function qa_content_markers_exist(): bool {
1757 + global $wpdb;
1758 +
1759 + // Block markup and core's details/summary block, in any public type.
1760 + $content_markers = ['wp:thinkrank/faq', 'wp:thinkrank/howto', 'wp:details'];
1761 + $clauses = [];
1762 + $values = [];
1763 +
1764 + foreach ($content_markers as $marker) {
1765 + $clauses[] = 'p.post_content LIKE %s';
1766 + $values[] = '%' . $wpdb->esc_like($marker) . '%';
1767 + }
1768 +
1769 + // The OR list is one '%s' per entry in a fixed class-level marker list,
1770 + // so its length varies but its content never comes from input; every
1771 + // value goes through prepare(). phpcs cannot see that, and this is the
1772 + // usual variable-length-IN exemption.
1773 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1774 + $found = (int) $wpdb->get_var(
1775 + $wpdb->prepare(
1776 + "SELECT 1 FROM {$wpdb->posts} p
1777 + WHERE p.post_status = 'publish'
1778 + AND (" . implode(' OR ', $clauses) . ')
1779 + LIMIT 1',
1780 + ...$values
1781 + )
1782 + );
1783 + // phpcs:enable
1784 +
1785 + if (1 === $found) {
1786 + return true;
1787 + }
1788 +
1789 + // Builder trees: ThinkRank's own elements, and the builders' generic
1790 + // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ
1791 + // is actually built from.
1792 + $meta_keys = self::builder_meta_keys();
1793 +
1794 + if (empty($meta_keys)) {
1795 + return false;
1796 + }
1797 +
1798 + $markers = array_merge(
1799 + [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME],
1800 + self::GENERIC_QA_ELEMENT_MARKERS
1801 + );
1802 +
1803 + $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s'));
1804 + $marker_clauses = [];
1805 + $marker_values = [];
1806 +
1807 + foreach ($markers as $marker) {
1808 + $marker_clauses[] = 'pm.meta_value LIKE %s';
1809 + $marker_values[] = '%' . $wpdb->esc_like($marker) . '%';
1810 + }
1811 +
1812 + // Same exemption: both placeholder runs are sized from fixed lists —
1813 + // the builder meta keys and the marker list — and every value is
1814 + // prepared.
1815 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1816 + $found = (int) $wpdb->get_var(
1817 + $wpdb->prepare(
1818 + "SELECT 1 FROM {$wpdb->postmeta} pm
1819 + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1820 + WHERE p.post_status = 'publish'
1821 + AND pm.meta_key IN ({$key_placeholders})
1822 + AND (" . implode(' OR ', $marker_clauses) . ')
1823 + LIMIT 1',
1824 + ...array_merge($meta_keys, $marker_values)
1825 + )
1826 + );
1827 + // phpcs:enable
1828 +
1829 + return 1 === $found;
1830 + }
1831 +
1832 + /**
1833 + * Whether the sampled content asks questions in its headings.
1834 + *
1835 + * The weakest signal and the last one tried: prose that poses questions is
1836 + * content answer markup could describe, even where nothing has been marked
1837 + * up yet. Runs over the existing sample, so it costs nothing extra.
1838 + *
1839 + * @since 2.7.0
1840 + * @return bool
1841 + */
1842 + private function sample_has_question_headings(): bool {
1843 + foreach ($this->get_content_sample() as $row) {
1844 + $content = (string) ($row['content'] ?? '');
1845 +
1846 + if ('' === $content) {
1847 + continue;
1848 + }
1849 +
1850 + if (preg_match('/<h[2-4][^>]*>\s*[^<]*\?\s*<\/h[2-4]>/i', $content)) {
1851 + return true;
1852 + }
1853 + }
1854 +
1855 + return false;
1856 + }
1857 +
1858 + /**
1684 1859 * Whether the site publishes FAQ / HowTo / Q&A structured data.
1685 1860 *
1686 1861 * Three sources, because three things emit it: the Schema Management
1687 1862 * System's enabled types, a per-post-type schema_type in Global SEO, and
@@ -1726,12 +1901,97 @@
1726 1901 return true;
1727 1902 }
1728 1903 }
1729 1904
1905 + // ...and again beyond the sample, which is the 100 most recent posts
1906 + // and pages. A site whose only FAQ lives in a custom post type, or
1907 + // simply further back than that, reported no answer schema while
1908 + // ThinkRank was publishing exactly that (#686). Added alongside the
1909 + // walk above rather than replacing it: the sample is already loaded and
1910 + // resolves Bricks trees, so it stays the primary source and this only
1911 + // extends the reach.
1912 + foreach ($this->answer_content_candidates() as $post_id => $content) {
1913 + if ($this->has_answer_content($post_id, $content)) {
1914 + return true;
1915 + }
1916 + }
1917 +
1730 1918 return false;
1731 1919 }
1732 1920
1733 1921 /**
1922 + * Posts that might carry a ThinkRank FAQ / How-To, from anywhere on the site.
1923 + *
1924 + * Narrowed in SQL to posts whose content or builder meta names one of
1925 + * ThinkRank's answer surfaces, so the per-post confirmation below runs over
1926 + * a handful of rows rather than the whole site.
1927 + *
1928 + * @since 2.7.0
1929 + * @return array<int,string> Post id => raw content.
1930 + */
1931 + private function answer_content_candidates(): array {
1932 + global $wpdb;
1933 +
1934 + $markers = [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME, 'wp:thinkrank/faq', 'wp:thinkrank/howto'];
1935 +
1936 + $content_clauses = [];
1937 + $content_values = [];
1938 + foreach ($markers as $marker) {
1939 + $content_clauses[] = 'p.post_content LIKE %s';
1940 + $content_values[] = '%' . $wpdb->esc_like($marker) . '%';
1941 + }
1942 +
1943 + $meta_keys = self::builder_meta_keys();
1944 + $meta_sql = '';
1945 + $values = $content_values;
1946 +
1947 + if (!empty($meta_keys)) {
1948 + $meta_clauses = [];
1949 + $meta_values = [];
1950 + foreach ($markers as $marker) {
1951 + $meta_clauses[] = 'pm.meta_value LIKE %s';
1952 + $meta_values[] = '%' . $wpdb->esc_like($marker) . '%';
1953 + }
1954 +
1955 + $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s'));
1956 + $meta_sql = " OR EXISTS (
1957 + SELECT 1 FROM {$wpdb->postmeta} pm
1958 + WHERE pm.post_id = p.ID
1959 + AND pm.meta_key IN ({$key_placeholders})
1960 + AND (" . implode(' OR ', $meta_clauses) . ')
1961 + )';
1962 + $values = array_merge($content_values, $meta_keys, $meta_values);
1963 + }
1964 +
1965 + $values[] = self::CONTENT_SAMPLE_SIZE;
1966 +
1967 + // Same exemption as qa_content_markers_exist(): the clause lists are
1968 + // sized from fixed marker and meta-key lists, and every value is
1969 + // prepared.
1970 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1971 + $rows = $wpdb->get_results(
1972 + $wpdb->prepare(
1973 + "SELECT p.ID, p.post_content FROM {$wpdb->posts} p
1974 + WHERE p.post_status = 'publish'
1975 + AND ((" . implode(' OR ', $content_clauses) . ")
1976 + {$meta_sql})
1977 + ORDER BY p.post_date DESC
1978 + LIMIT %d",
1979 + ...$values
1980 + ),
1981 + ARRAY_A
1982 + );
1983 + // phpcs:enable
1984 +
1985 + $candidates = [];
1986 + foreach ((array) $rows as $row) {
1987 + $candidates[(int) $row['ID']] = (string) $row['post_content'];
1988 + }
1989 +
1990 + return $candidates;
1991 + }
1992 +
1993 + /**
1734 1994 * Whether one post carries a ThinkRank FAQ or How-To that emits schema.
1735 1995 *
1736 1996 * Four surfaces, because `Schema_Graph` collects from four: the Gutenberg
1737 1997 * block in `post_content`, the Elementor widget, the Bricks element and the
@@ -1781,9 +2041,73 @@
1781 2041 }
1782 2042 }
1783 2043 }
1784 2044
2045 + // Oxygen and Breakdance were missing entirely, so a ThinkRank FAQ
2046 + // element placed inside one of those pages reported no answer schema
2047 + // while the page was publishing exactly that (#686). Builder_Content
2048 + // already knows every key involved — Oxygen 6 is Breakdance under the
2049 + // hood, and older releases used two other keys — so ask it rather than
2050 + // keeping a second list that can drift.
2051 + foreach (self::builder_meta_keys() as $meta_key) {
2052 + if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) {
2053 + continue; // Handled above, in their own storage shapes.
2054 + }
2055 +
2056 + $stored = get_post_meta($post_id, $meta_key, true);
2057 +
2058 + if (self::blob_names_answer_element($stored)) {
2059 + return true;
2060 + }
2061 + }
2062 +
1785 2063 return false;
2064 + }
2065 +
2066 + /**
2067 + * Builder meta keys, or [] when Builder_Content is unavailable.
2068 + *
2069 + * Mirrors the defensive load in bricks_has_answer_element(): the analyzer
2070 + * must degrade to "no answer content found" on a partial checkout rather
2071 + * than fatal mid-audit.
2072 + *
2073 + * @since 2.7.0
2074 + * @return string[]
2075 + */
2076 + private static function builder_meta_keys(): array {
2077 + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
2078 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
2079 + if (!file_exists($file)) {
2080 + return [];
2081 + }
2082 + require_once $file;
2083 + }
2084 +
2085 + return Builder_Content::builder_meta_keys();
2086 + }
2087 +
2088 + /**
2089 + * Whether a stored builder blob names a ThinkRank FAQ or How-To.
2090 + *
2091 + * The blob is a JSON tree for Breakdance/Oxygen 6 and a shortcode string
2092 + * for Oxygen classic, so this matches on the element name appearing in the
2093 + * serialized form rather than parsing each dialect.
2094 + *
2095 + * @since 2.7.0
2096 + * @param mixed $stored Raw meta value.
2097 + * @return bool
2098 + */
2099 + private static function blob_names_answer_element($stored): bool {
2100 + if (is_array($stored)) {
2101 + $stored = wp_json_encode($stored);
2102 + }
2103 +
2104 + if (!is_string($stored) || '' === $stored) {
2105 + return false;
2106 + }
2107 +
2108 + return false !== strpos($stored, self::ANSWER_FAQ_NAME)
2109 + || false !== strpos($stored, self::ANSWER_HOWTO_NAME);
1786 2110 }
1787 2111
1788 2112 /**
1789 2113 * Whether a Bricks-rendered post holds a ThinkRank FAQ or How-To element.