| @@ -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. |