PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-seo-analyzer.php +1421 -20 2.3.0 → 2.9.0 View file →
@@ -41,8 +41,34 @@
41 41 * How long a computed analysis stays cached (seconds).
42 42 */
43 43 private const CACHE_TTL = HOUR_IN_SECONDS;
44 44
45 + /**
46 + * Names ThinkRank's FAQ / How-To producers share across Elementor widgets,
47 + * Bricks elements and Beaver Builder modules.
48 + */
49 + private const ANSWER_FAQ_NAME = 'thinkrank-faq';
50 + private const ANSWER_HOWTO_NAME = 'thinkrank-howto';
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 +
45 71 // Check result statuses.
46 72 public const PASSED = 'passed';
47 73 public const WARNING = 'warning';
48 74 public const FAILED = 'failed';
@@ -58,8 +84,12 @@
58 84 'advanced' => __('Advanced SEO', 'thinkrank'),
59 85 'content' => __('Content', 'thinkrank'),
60 86 'performance' => __('Performance & Technical', 'thinkrank'),
61 87 'security' => __('Security', 'thinkrank'),
88 + // Generative/answer engine optimization. Spelled out because the
89 + // acronym alone reads as geography, and ucfirst()'d "Geo" — what a
90 + // filter-registered category falls back to — reads as nothing.
91 + 'geo' => __('AI Search (GEO)', 'thinkrank'),
62 92 ];
63 93 }
64 94
65 95 /**
@@ -72,8 +102,12 @@
72 102 'blog_public',
73 103 'permalink_structure',
74 104 'blogname',
75 105 'blogdescription',
106 + // The published llms.txt document. Publishing or clearing it flips a
107 + // GEO check, and it is written as an option rather than through the
108 + // settings manager, so the settings-saved hook never sees it.
109 + 'thinkrank_llms_txt_content',
76 110 ];
77 111
78 112 /**
79 113 * Register cache invalidation.
@@ -197,9 +231,9 @@
197 231 unset($data);
198 232
199 233 $overall = $total_weight > 0 ? (int) round(($earned / $total_weight) * 100) : 0;
200 234
201 - return [
235 + $result = [
202 236 'overall_score' => $overall,
203 237 'grade' => $this->score_to_grade($overall),
204 238 'summary' => $summary,
205 239 'categories' => array_values($categories),
@@ -205,8 +239,28 @@
205 239 'categories' => array_values($categories),
206 240 'checks' => $checks,
207 241 'generated_at' => gmdate('c'),
208 242 ];
243 +
244 + /**
245 + * Fires after a site audit has been computed.
246 + *
247 + * Every path that produces a fresh analysis passes through here — the
248 + * REST run route, the one-click fixer's re-run, and a cold cache — so a
249 + * listener sees every run exactly once and never sees a cache hit.
250 + * ThinkRank Pro uses this to persist a dated snapshot for the score
251 + * trend and the run comparison.
252 + *
253 + * The payload is the analysis as returned to the caller; a listener
254 + * must treat it as read-only.
255 + *
256 + * @since 2.5.0
257 + *
258 + * @param array $result The completed analysis (see the return docblock).
259 + */
260 + do_action('thinkrank_seo_analysis_completed', $result);
261 +
262 + return $result;
209 263 }
210 264
211 265 /**
212 266 * Map a 0–100 score to a letter grade.
@@ -263,8 +317,12 @@
263 317 // check with a Fix button reads as "did this even work?".
264 318 $fixable = self::PASSED !== $status && SEO_Analyzer_Fixer::can_fix($id);
265 319 $fix = $fixable ? (SEO_Analyzer_Fixer::fixable()[$id] ?? []) : [];
266 320
321 + // The list can be capped below the number of items the finding is
322 + // about, so the total travels with it.
323 + $affected = $this->normalize_affected_posts($outcome['affected_posts'] ?? []);
324 +
267 325 $results[] = [
268 326 'id' => $id,
269 327 'category' => (string) ($def['category'] ?? 'basic'),
270 328 'weight' => isset($def['weight']) ? (float) $def['weight'] : 1.0,
@@ -272,8 +330,10 @@
272 330 'status' => $status,
273 331 'message' => (string) ($outcome['message'] ?? ''),
274 332 'how_to_fix' => (string) ($outcome['how_to_fix'] ?? ''),
275 333 'value' => $outcome['value'] ?? null,
334 + 'affected_posts' => $affected,
335 + 'affected_total' => max(count($affected), absint($outcome['affected_total'] ?? 0)),
276 336 'can_auto_fix' => $fixable,
277 337 'fix_label' => (string) ($fix['label'] ?? ''),
278 338 'fix_warning' => (string) ($fix['warning'] ?? ''),
279 339 ];
@@ -282,8 +342,43 @@
282 342 return $results;
283 343 }
284 344
285 345 /**
346 + * Reduce a check's list of posts to fix to a known, escaped shape.
347 + *
348 + * The list reaches the audit UI as links, and a check registered through
349 + * `thinkrank_seo_analyzer_checks` can put anything in it, so every entry is
350 + * rebuilt here rather than passed through.
351 + *
352 + * @since 2.6.0
353 + * @param mixed $posts Raw `affected_posts` from a check result.
354 + * @return array<int,array{id: int, title: string, type: string, edit_url: string, url: string}>
355 + */
356 + private function normalize_affected_posts($posts): array {
357 + if (!is_array($posts)) {
358 + return [];
359 + }
360 +
361 + $out = [];
362 +
363 + foreach ($posts as $post) {
364 + if (!is_array($post) || empty($post['id'])) {
365 + continue;
366 + }
367 +
368 + $out[] = [
369 + 'id' => absint($post['id']),
370 + 'title' => sanitize_text_field((string) ($post['title'] ?? '')),
371 + 'type' => sanitize_text_field((string) ($post['type'] ?? '')),
372 + 'edit_url' => esc_url_raw((string) ($post['edit_url'] ?? '')),
373 + 'url' => esc_url_raw((string) ($post['url'] ?? '')),
374 + ];
375 + }
376 +
377 + return $out;
378 + }
379 +
380 + /**
286 381 * The registry of checks: id, category, weight, and the callback that
287 382 * evaluates it. Filterable so Pro/add-ons can register additional checks.
288 383 *
289 384 * @return array<int,array>
@@ -311,8 +406,21 @@
311 406 // Security
312 407 ['id' => 'https', 'category' => 'security', 'weight' => 3, 'callback' => [$this, 'check_https']],
313 408 ['id' => 'file_editing', 'category' => 'security', 'weight' => 2, 'callback' => [$this, 'check_file_editing']],
314 409 ['id' => 'debug_display', 'category' => 'security', 'weight' => 1, 'callback' => [$this, 'check_debug_display']],
410 +
411 + // GEO / AEO — AI answer-engine readiness. The three configuration
412 + // checks carry the weight of a normal check; the five sampled
413 + // content ones are half that, so the category as a whole sits
414 + // beside the existing ones rather than dominating the score.
415 + ['id' => 'ai_crawler_access', 'category' => 'geo', 'weight' => 1.5, 'callback' => [$this, 'check_ai_crawler_access']],
416 + ['id' => 'llms_txt', 'category' => 'geo', 'weight' => 1.5, 'callback' => [$this, 'check_llms_txt']],
417 + ['id' => 'answer_ready_schema', 'category' => 'geo', 'weight' => 1.5, 'callback' => [$this, 'check_answer_ready_schema']],
418 + ['id' => 'direct_answer', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_direct_answer']],
419 + ['id' => 'question_headings', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_question_headings']],
420 + ['id' => 'structured_content', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_structured_content']],
421 + ['id' => 'content_depth', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_content_depth']],
422 + ['id' => 'content_freshness', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_content_freshness']],
315 423 ];
316 424
317 425 /**
318 426 * Filter the Site SEO Analyzer check registry.
@@ -317,9 +425,9 @@
317 425 /**
318 426 * Filter the Site SEO Analyzer check registry.
319 427 *
320 428 * Each entry is an array with keys: id, category (basic|advanced|
321 - * content|performance|security), weight (float), and callback (callable
429 + * content|performance|security|geo), weight (float), and callback (callable
322 430 * returning ['status' => passed|warning|failed, 'label', 'message',
323 431 * 'how_to_fix']).
324 432 *
325 433 * @since 1.18.0
@@ -565,16 +673,36 @@
565 673 * (its weight was earned unconditionally and its one-click fix was
566 674 * unreachable). This asks the question the check's copy actually claims to
567 675 * answer.
568 676 *
677 + * Both layers must be read WITHOUT their defaults, or the same trap closes
678 + * again one level down: get_settings() merges the context defaults under
679 + * the saved rows, and Schema_Settings_Config's 'site' defaults set both
680 + * enabled_schema_types and auto_generate_schema — so an untouched site came
681 + * back looking configured and this method still could not return false
682 + * (#586). get_stored_settings() answers with only what was actually saved.
683 + *
569 684 * @since 2.2.0
570 685 * @return bool
571 686 */
572 687 private function schema_is_configured(): bool {
573 - // 1) Schema Management System — an explicit opt-in.
688 + // 1) Schema Management System — an explicit opt-in. Read the SAVED rows
689 + // only; the defaults-merged view is truthy on every site.
574 690 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
575 - $settings = (new Schema_Management_System())->get_settings('site', null);
576 - if (is_array($settings)) {
691 + $settings = (new Schema_Management_System())->get_stored_settings('site', null);
692 + // The master switch gates these the same way it gates
693 + // schema_is_output(). Saving the settings form persists the whole
694 + // payload, so turning the feature off stores enabled = '0' while
695 + // auto_generate_schema stays '1' — and reading past the switch then
696 + // reported "structured data is configured" for a site emitting
697 + // none, with the one-click fix withheld. array_key_exists rather
698 + // than a bare !empty so an untouched site, where 'enabled' was
699 + // never saved at all, still falls through to the Global SEO layer
700 + // below instead of short-circuiting to false.
701 + $master_on = is_array($settings)
702 + && (!array_key_exists('enabled', $settings) || !empty($settings['enabled']));
703 +
704 + if ($master_on) {
577 705 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
578 706 return true;
579 707 }
580 708 if (!empty($settings['auto_generate_schema'])) {
@@ -621,10 +749,20 @@
621 749 // 'site' is the context type; 'schema_management_system' is the manager
622 750 // NAME, which get_settings() rejects as an unsupported context and
623 751 // answers with bare defaults — where auto_generate_schema is true, so
624 752 // this always returned true and never read the site's real settings (#473).
753 + //
754 + // This one KEEPS the defaults-merged view on purpose, unlike
755 + // schema_is_configured() (#586). The question here is "does JSON-LD
756 + // reach the page?", and an untouched site answers yes: the 'site'
757 + // defaults leave the system enabled with auto_generate_schema on, so
758 + // the merged value is the emitted behaviour, not a mask over it.
625 759 $settings = (new Schema_Management_System())->get_settings('site', null);
626 - if (is_array($settings)) {
760 + // The master switch gates everything below it: with 'enabled' off,
761 + // get_output_data() reports the feature as off and nothing is
762 + // emitted, so reading auto_generate_schema past it told the audit
763 + // schema was on the page when it was not (the same shape as #461).
764 + if (is_array($settings) && !empty($settings['enabled'])) {
627 765 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
628 766 return true;
629 767 }
630 768 if (!empty($settings['auto_generate_schema'])) {
@@ -663,8 +801,17 @@
663 801 private const COVERAGE_PASS = 90;
664 802 private const COVERAGE_WARN = 50;
665 803
666 804 /**
805 + * Most items a finding names when it is not drawn from the bounded sample.
806 + *
807 + * The image check counts the whole media library, which can run to tens of
808 + * thousands of rows; a list that long would bloat the cached analysis and
809 + * every stored audit snapshot while telling the user nothing more.
810 + */
811 + private const AFFECTED_LIMIT = 100;
812 +
813 + /**
667 814 * Recent published posts/pages should have meta descriptions.
668 815 *
669 816 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
670 817 * check stays fast on large sites.
@@ -673,18 +820,9 @@
673 820 */
674 821 public function check_meta_descriptions(): array {
675 822 $label = __('Posts have meta descriptions', 'thinkrank');
676 823
677 - $post_ids = get_posts([
678 - 'post_type' => ['post', 'page'],
679 - 'post_status' => 'publish',
680 - 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
681 - 'orderby' => 'date',
682 - 'order' => 'DESC',
683 - 'fields' => 'ids',
684 - 'no_found_rows' => true,
685 - 'suppress_filters' => false,
686 - ]);
824 + $post_ids = $this->sample_post_ids();
687 825
688 826 $total = count($post_ids);
689 827 if (0 === $total) {
690 828 return [
@@ -706,13 +844,16 @@
706 844 // sample once instead — 100 posts went from ~200 queries to a handful.
707 845 _prime_post_caches($post_ids, false, true);
708 846
709 847 $with_description = 0;
848 + $without = [];
710 849 foreach ($post_ids as $post_id) {
711 850 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
712 851 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
713 852 if ('' !== trim($resolved)) {
714 853 $with_description++;
854 + } else {
855 + $without[] = (int) $post_id;
715 856 }
716 857 }
717 858
718 859 $coverage = (int) round(($with_description / $total) * 100);
@@ -733,10 +874,11 @@
733 874 'label' => $label,
734 875 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
735 876 /* translators: 1: posts missing a meta description, 2: sampled posts. */
736 877 'message' => sprintf(__('%1$d of your %2$d most recent posts are missing a meta description. Search engines fall back to arbitrary page text for their snippets.', 'thinkrank'), $missing, $total),
737 - 'how_to_fix' => __('Add meta descriptions in the ThinkRank SEO panel when editing a post — or use Bulk SEO Optimization to generate them with AI.', 'thinkrank'),
738 - 'value' => $value,
878 + 'how_to_fix' => __('Add meta descriptions in the ThinkRank SEO panel when editing a post — or use Bulk SEO Optimization to generate them with AI.', 'thinkrank'),
879 + 'value' => $value,
880 + 'affected_posts' => $this->affected_posts($without),
739 881 ];
740 882 }
741 883
742 884 /**
@@ -795,13 +937,50 @@
795 937 'label' => $label,
796 938 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
797 939 /* translators: 1: images missing alt text, 2: total images. */
798 940 'message' => sprintf(__('%1$d of your %2$d images are missing alt text. Alt text drives image search rankings and is an accessibility requirement.', 'thinkrank'), $missing, $total),
799 - 'how_to_fix' => __('Under Essential SEO → Image SEO, turn on "Save alt text to the Media Library" and run "Fill missing alt text" to populate them from your format, or add alt text manually in the Media Library.', 'thinkrank'),
800 - 'value' => $value,
941 + 'how_to_fix' => __('Under Essential SEO → Image SEO, turn on "Save alt text to the Media Library" and run "Fill missing alt text" to populate them from your format, or add alt text manually in the Media Library.', 'thinkrank'),
942 + 'value' => $value,
943 + 'affected_posts' => $this->affected_posts($this->image_ids_without_alt()),
944 + 'affected_total' => $missing,
801 945 ];
802 946 }
803 947
948 + /**
949 + * The most recent images with no alt text, capped at AFFECTED_LIMIT.
950 + *
951 + * Mirrors the counts above: an image counts as having alt text when any
952 + * `_wp_attachment_image_alt` row for it is non-empty.
953 + *
954 + * @since 2.6.0
955 + * @return int[]
956 + */
957 + private function image_ids_without_alt(): array {
958 + global $wpdb;
959 +
960 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded id list for a cached analysis; no core API filters on a missing meta value
961 + $ids = $wpdb->get_col(
962 + $wpdb->prepare(
963 + "SELECT p.ID FROM {$wpdb->posts} p
964 + WHERE p.post_type = 'attachment'
965 + AND p.post_mime_type LIKE %s
966 + AND p.post_status != 'trash'
967 + AND NOT EXISTS (
968 + SELECT 1 FROM {$wpdb->postmeta} pm
969 + WHERE pm.post_id = p.ID
970 + AND pm.meta_key = '_wp_attachment_image_alt'
971 + AND pm.meta_value != ''
972 + )
973 + ORDER BY p.post_date DESC
974 + LIMIT %d",
975 + $wpdb->esc_like('image/') . '%',
976 + self::AFFECTED_LIMIT
977 + )
978 + );
979 +
980 + return array_map('intval', (array) $ids);
981 + }
982 +
804 983 // ─────────────────────────────────────────────────────────────────────
805 984 // Performance & Technical checks
806 985 // ─────────────────────────────────────────────────────────────────────
807 986
@@ -943,6 +1122,1228 @@
943 1122 'label' => __('Errors not shown publicly', 'thinkrank'),
944 1123 'status' => self::PASSED,
945 1124 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
946 1125 ];
1126 + }
1127 + // ─────────────────────────────────────────────────────────────────────
1128 + // GEO / AEO checks (AI answer-engine readiness)
1129 + // ─────────────────────────────────────────────────────────────────────
1130 +
1131 + /**
1132 + * The crawlers that decide whether a site can be CITED by an AI answer
1133 + * engine, as opposed to the ones that only collect training data.
1134 + *
1135 + * Blocking a training crawler (GPTBot, ClaudeBot, CCBot…) is a legitimate
1136 + * editorial choice and is deliberately not marked down here: it costs the
1137 + * site nothing in ChatGPT, Claude, Perplexity or AI Overviews. Blocking
1138 + * the agents below is what makes a site invisible to those answers, so
1139 + * they are the only ones this check reports on.
1140 + *
1141 + * Public because the one-click fix writes exactly this set to `allow`;
1142 + * a second copy in the fixer is how the check and its remedy drift into
1143 + * disagreeing about which crawlers matter.
1144 + *
1145 + * @since 2.5.0
1146 + * @var string[]
1147 + */
1148 + public const GEO_ANSWER_AGENTS = [
1149 + 'oai-searchbot',
1150 + 'chatgpt-user',
1151 + 'perplexitybot',
1152 + 'perplexity-user',
1153 + 'claude-searchbot',
1154 + 'claude-user',
1155 + 'google-extended',
1156 + 'mistral-user',
1157 + ];
1158 +
1159 + /**
1160 + * Word-count window for an opening passage that reads as a direct answer.
1161 + *
1162 + * The AEO convention is a self-contained 40–60 word answer directly under
1163 + * the title. The window is widened at both ends so ordinary good writing
1164 + * passes: below the floor there is no answer to quote, and well above the
1165 + * ceiling the passage is a preamble an engine has to summarize rather than
1166 + * a sentence it can lift.
1167 + */
1168 + private const GEO_ANSWER_MIN_WORDS = 20;
1169 + private const GEO_ANSWER_MAX_WORDS = 120;
1170 +
1171 + /**
1172 + * Words below which a page has too little substance to be cited.
1173 + */
1174 + private const GEO_DEPTH_MIN_WORDS = 300;
1175 +
1176 + /**
1177 + * How long a page can go unrevised before it reads as stale to an engine
1178 + * that prefers recent sources.
1179 + */
1180 + private const GEO_FRESHNESS_MAX_AGE = 365 * DAY_IN_SECONDS;
1181 +
1182 + /**
1183 + * The content sample the GEO checks share, memoized for one analysis.
1184 + *
1185 + * Five checks read the same 100 posts. Sampling once turns five queries
1186 + * (and five cache-priming passes) into one, and guarantees the five
1187 + * results describe the same set of posts.
1188 + *
1189 + * @since 2.5.0
1190 + * @var array<int,array{id: int, content: string, text: string, modified: int}>|null
1191 + */
1192 + private $content_sample = null;
1193 +
1194 + /**
1195 + * The sampled post ids, memoized so the Content and GEO categories run one
1196 + * query between them rather than one each over the same CONTENT_SAMPLE_SIZE
1197 + * posts. Both want the same slice — the most recent published posts and
1198 + * pages — so two queries only guaranteed they could disagree after a
1199 + * publish mid-analysis.
1200 + *
1201 + * @since 2.5.0
1202 + * @var int[]|null
1203 + */
1204 + private $sample_post_ids = null;
1205 +
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 + /**
1215 + * The sampled post ids, fetched once and shared by every check that reads
1216 + * the same slice.
1217 + *
1218 + * @since 2.5.0
1219 + * @return int[]
1220 + */
1221 + private function sample_post_ids(): array {
1222 + if (null !== $this->sample_post_ids) {
1223 + return $this->sample_post_ids;
1224 + }
1225 +
1226 + $this->sample_post_ids = get_posts([
1227 + 'post_type' => ['post', 'page'],
1228 + 'post_status' => 'publish',
1229 + 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
1230 + 'orderby' => 'date',
1231 + 'order' => 'DESC',
1232 + 'fields' => 'ids',
1233 + 'no_found_rows' => true,
1234 + 'suppress_filters' => false,
1235 + ]);
1236 +
1237 + return $this->sample_post_ids;
1238 + }
1239 +
1240 + /**
1241 + * The most recent published posts/pages, as raw content, its plain-text
1242 + * rendering and the modified time.
1243 + *
1244 + * Raw `post_content` on purpose: running `the_content` over 100 posts in a
1245 + * REST request would fire every shortcode and block renderer on the site.
1246 + * The structural signals these checks look for (headings, lists, tables,
1247 + * the opening passage) survive in the stored markup.
1248 + *
1249 + * @since 2.5.0
1250 + * @return array<int,array{id: int, content: string, text: string, modified: int}>
1251 + */
1252 + private function get_content_sample(): array {
1253 + if (null !== $this->content_sample) {
1254 + return $this->content_sample;
1255 + }
1256 +
1257 + $post_ids = $this->sample_post_ids();
1258 +
1259 + if (function_exists('_prime_post_caches')) {
1260 + _prime_post_caches($post_ids, false, false);
1261 + }
1262 +
1263 + $sample = [];
1264 +
1265 + foreach ($post_ids as $post_id) {
1266 + $post = get_post($post_id);
1267 + if (!$post) {
1268 + continue;
1269 + }
1270 +
1271 + $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false;
1272 +
1273 + $content = (string) $post->post_content;
1274 +
1275 + $sample[] = [
1276 + 'id' => (int) $post->ID,
1277 + 'content' => $content,
1278 + 'text' => $this->content_to_text($content),
1279 + 'modified' => is_int($modified) ? $modified : 0,
1280 + ];
1281 + }
1282 +
1283 + $this->content_sample = $sample;
1284 +
1285 + return $sample;
1286 + }
1287 +
1288 + /**
1289 + * Shared shape for the content-sampled GEO checks: count how many posts in
1290 + * the sample satisfy a predicate and grade it on the coverage thresholds
1291 + * the Content category already uses.
1292 + *
1293 + * @since 2.5.0
1294 + *
1295 + * @param string $label Check label.
1296 + * @param callable $predicate Receives one sample row, returns bool.
1297 + * @param string $pass_text sprintf template: 1 = matching, 2 = sampled.
1298 + * @param string $fail_text sprintf template: 1 = missing, 2 = sampled.
1299 + * @param string $how_to_fix Advice shown on a warning/fail.
1300 + * @param string $empty_text Message when the site has no content yet.
1301 + * @return array
1302 + */
1303 + private function coverage_check(
1304 + string $label,
1305 + callable $predicate,
1306 + string $pass_text,
1307 + string $fail_text,
1308 + string $how_to_fix,
1309 + string $empty_text
1310 + ): 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) {
1318 + if ('' !== $row['text']) {
1319 + $sample[] = $row;
1320 + }
1321 + }
1322 +
1323 + $total = count($sample);
1324 +
1325 + if (0 === $total) {
1326 + return [
1327 + 'label' => $label,
1328 + 'status' => self::PASSED,
1329 + 'message' => $empty_text,
1330 + ];
1331 + }
1332 +
1333 + $matching = 0;
1334 + $failing = [];
1335 + foreach ($sample as $row) {
1336 + if ($predicate($row)) {
1337 + $matching++;
1338 + } else {
1339 + $failing[] = $row['id'];
1340 + }
1341 + }
1342 +
1343 + $coverage = (int) round(($matching / $total) * 100);
1344 + $value = sprintf('%d/%d', $matching, $total);
1345 +
1346 + if ($coverage >= self::COVERAGE_PASS) {
1347 + return [
1348 + 'label' => $label,
1349 + 'status' => self::PASSED,
1350 + 'message' => sprintf($pass_text, $matching, $total),
1351 + 'value' => $value,
1352 + ];
1353 + }
1354 +
1355 + // A count alone leaves the user to guess which pages it means, so the
1356 + // finding names them.
1357 + return [
1358 + 'label' => $label,
1359 + 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
1360 + 'message' => sprintf($fail_text, $total - $matching, $total),
1361 + 'how_to_fix' => $how_to_fix,
1362 + 'value' => $value,
1363 + 'affected_posts' => $this->affected_posts($failing),
1364 + ];
1365 + }
1366 +
1367 + /**
1368 + * The posts a finding is about, with where to fix and where to view each.
1369 + *
1370 + * The edit link is built rather than taken from get_edit_post_link(), which
1371 + * returns nothing without a user who can edit — and the analysis is cached
1372 + * and can be computed outside a request.
1373 + *
1374 + * @since 2.6.0
1375 + * @param int[] $post_ids Post ids, in sample order.
1376 + * @return array<int,array{id: int, title: string, type: string, edit_url: string, url: string}>
1377 + */
1378 + private function affected_posts(array $post_ids): array {
1379 + $posts = [];
1380 +
1381 + if ($post_ids && function_exists('_prime_post_caches')) {
1382 + _prime_post_caches($post_ids, false, false);
1383 + }
1384 +
1385 + foreach ($post_ids as $post_id) {
1386 + $post = get_post($post_id);
1387 + if (!$post) {
1388 + continue;
1389 + }
1390 +
1391 + $type_object = get_post_type_object($post->post_type);
1392 + $title = html_entity_decode((string) get_the_title($post), ENT_QUOTES, 'UTF-8');
1393 +
1394 + // An attachment's permalink is its attachment page, which core
1395 + // redirects to the file on most sites; link the file itself.
1396 + $url = 'attachment' === $post->post_type && function_exists('wp_get_attachment_url')
1397 + ? (string) wp_get_attachment_url((int) $post->ID)
1398 + : (string) get_permalink($post);
1399 +
1400 + $posts[] = [
1401 + 'id' => (int) $post->ID,
1402 + 'title' => '' !== trim($title) ? $title : __('(no title)', 'thinkrank'),
1403 + 'type' => $type_object ? (string) $type_object->labels->singular_name : (string) $post->post_type,
1404 + 'edit_url' => admin_url('post.php?post=' . (int) $post->ID . '&action=edit'),
1405 + 'url' => $url,
1406 + ];
1407 + }
1408 +
1409 + return $posts;
1410 + }
1411 +
1412 + /**
1413 + * The plain text of a post's content, with markup, blocks and shortcodes
1414 + * reduced to the words a language model would actually read.
1415 + *
1416 + * @since 2.5.0
1417 + * @param string $content Raw post content.
1418 + * @return string
1419 + */
1420 + private function content_to_text(string $content): string {
1421 + // Block delimiters are HTML comments, so strip_tags leaves their
1422 + // attribute JSON behind as text and inflates every word count.
1423 + $text = preg_replace('/<!--.*?-->/s', ' ', $content);
1424 + $text = preg_replace('/\[[^\]]*\]/', ' ', (string) $text);
1425 + $text = wp_strip_all_tags((string) $text);
1426 +
1427 + // `/u` makes preg_replace() return NULL on content that is not valid
1428 + // UTF-8 — a latin1 install, a raw SQL import, a migrated dump — and
1429 + // trim(null) is a TypeError under strict_types. run_checks() catches
1430 + // Throwable and continues, so the check did not fail: it DISAPPEARED
1431 + // from the audit, taking its weight with it and pushing the overall
1432 + // score UP because a failing check had been removed rather than scored.
1433 + // Fall back to the byte-wise collapse when the Unicode pass cannot read
1434 + // the bytes; a slightly coarser word split is worth far more than a
1435 + // check that silently deletes itself.
1436 + $collapsed = preg_replace('/\s+/u', ' ', (string) $text);
1437 +
1438 + if (null === $collapsed) {
1439 + $collapsed = preg_replace('/\s+/', ' ', (string) $text);
1440 + }
1441 +
1442 + return trim((string) $collapsed);
1443 + }
1444 +
1445 + /**
1446 + * Word count of a string of plain text.
1447 + *
1448 + * @since 2.5.0
1449 + * @param string $text Plain text.
1450 + * @return int
1451 + */
1452 + private function word_count(string $text): int {
1453 + if ('' === $text) {
1454 + return 0;
1455 + }
1456 +
1457 + return count(preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []);
1458 + }
1459 +
1460 + /**
1461 + * Whether AI answer engines are allowed to reach this site.
1462 + *
1463 + * Reads the robots.txt that is ACTUALLY served — physical file, custom
1464 + * body or generated defaults — rather than the per-agent rule map alone,
1465 + * because a hand-written `Disallow: /` for GPTBot blocks it just as
1466 + * effectively as the toggle does, and a `User-agent: *` block reaches
1467 + * every crawler on the list at once.
1468 + *
1469 + * @since 2.5.0
1470 + * @return array
1471 + */
1472 + public function check_ai_crawler_access(): array {
1473 + $label = __('AI answer engines can crawl your site', 'thinkrank');
1474 + $blocked = $this->blocked_answer_agents();
1475 +
1476 + if (empty($blocked)) {
1477 + return [
1478 + 'label' => $label,
1479 + 'status' => self::PASSED,
1480 + 'message' => __('ChatGPT, Claude, Perplexity and Google AI Overviews can all reach your content and cite it.', 'thinkrank'),
1481 + ];
1482 + }
1483 +
1484 + $names = implode(', ', $blocked);
1485 + // Every one of them blocked is a different situation from one stray
1486 + // rule: the site cannot appear in AI answers at all. Counted against
1487 + // the agents the registry actually knows, not the slug list: a slug
1488 + // leaving AI_Crawlers can never be collected as blocked, so comparing
1489 + // with the list would make this branch quietly unreachable.
1490 + $known = count($this->known_answer_agents());
1491 + $all = $known > 0 && count($blocked) >= $known;
1492 +
1493 + return [
1494 + 'label' => $label,
1495 + 'status' => $all ? self::FAILED : self::WARNING,
1496 + 'message' => $all
1497 + /* translators: %s: comma-separated crawler names. */
1498 + ? sprintf(__('Your robots.txt blocks every AI answer engine (%s), so your pages cannot be cited in AI answers at all.', 'thinkrank'), $names)
1499 + /* translators: %s: comma-separated crawler names. */
1500 + : sprintf(__('Your robots.txt blocks these AI answer engines: %s. Their assistants cannot read or cite your pages.', 'thinkrank'), $names),
1501 + 'how_to_fix' => __('Under Essential SEO → Crawling & AI Indexing → Robots.txt, set the answer-engine crawlers to Allow. Blocking the training-only crawlers (GPTBot, ClaudeBot, CCBot) is a separate choice and does not cost you citations.', 'thinkrank'),
1502 + 'value' => $names,
1503 + ];
1504 + }
1505 +
1506 + /**
1507 + * The answer-engine crawlers the served robots.txt disallows entirely.
1508 + *
1509 + * Public so the one-click fix can re-ask the question after writing the
1510 + * rules, rather than reporting a success the served file contradicts.
1511 + *
1512 + * @since 2.5.0
1513 + * @return string[] Crawler labels, empty when all are allowed.
1514 + */
1515 + public function blocked_answer_agents(): array {
1516 + if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager') || !class_exists('ThinkRank\\SEO\\AI_Crawlers')) {
1517 + return [];
1518 + }
1519 +
1520 + $effective = (new Site_Identity_Manager())->get_effective_robots_txt();
1521 + $groups = $this->parse_robots_disallow_all((string) ($effective['content'] ?? ''));
1522 +
1523 + if (empty($groups)) {
1524 + return [];
1525 + }
1526 +
1527 + $blocked = [];
1528 +
1529 + foreach ($this->known_answer_agents() as $agent) {
1530 + $token = strtolower((string) $agent['token']);
1531 + // A group naming the agent wins over the wildcard group, which is
1532 + // how robots.txt precedence works: the most specific matching
1533 + // group is the only one that applies.
1534 + $denied = array_key_exists($token, $groups) ? $groups[$token] : ($groups['*'] ?? false);
1535 +
1536 + if ($denied) {
1537 + $blocked[] = $agent['label'];
1538 + }
1539 + }
1540 +
1541 + return $blocked;
1542 + }
1543 +
1544 + /**
1545 + * The answer-engine crawlers the AI_Crawlers registry actually knows.
1546 + *
1547 + * The slug list is ThinkRank's editorial position on which crawlers decide
1548 + * whether a site can be cited; the registry is what carries their tokens
1549 + * and labels. Everything that counts answer engines counts these, so a slug
1550 + * that ever leaves the registry drops out of the check and its totals
1551 + * together instead of skewing one against the other.
1552 + *
1553 + * @since 2.5.0
1554 + * @return array<string,array> Registry entries keyed by slug.
1555 + */
1556 + private function known_answer_agents(): array {
1557 + if (!class_exists('ThinkRank\\SEO\\AI_Crawlers')) {
1558 + return [];
1559 + }
1560 +
1561 + $agents = AI_Crawlers::all();
1562 + $known = [];
1563 +
1564 + foreach (self::GEO_ANSWER_AGENTS as $slug) {
1565 + if (isset($agents[$slug]['token'], $agents[$slug]['label'])) {
1566 + $known[$slug] = $agents[$slug];
1567 + }
1568 + }
1569 +
1570 + return $known;
1571 + }
1572 +
1573 + /**
1574 + * Map a robots.txt body to `user-agent => disallows everything`.
1575 + *
1576 + * Consecutive `User-agent:` lines open one shared group, so the agents
1577 + * listed above a `Disallow: /` all inherit it. An agent that appears with
1578 + * narrower rules is recorded as false rather than omitted — otherwise it
1579 + * would fall through to the wildcard group it is meant to override.
1580 + *
1581 + * @since 2.5.0
1582 + * @param string $body Robots.txt content.
1583 + * @return array<string,bool>
1584 + */
1585 + private function parse_robots_disallow_all(string $body): array {
1586 + $groups = [];
1587 + $current = [];
1588 + // Whether the next `User-agent:` continues this group or opens a new
1589 + // one. Rules close a group; another agent line before any rule does not.
1590 + $collecting = true;
1591 +
1592 + foreach (preg_split('/\R/', $body) ?: [] as $line) {
1593 + $line = trim((string) preg_replace('/#.*/', '', $line));
1594 +
1595 + if ('' === $line || false === strpos($line, ':')) {
1596 + continue;
1597 + }
1598 +
1599 + [$field, $value] = array_map('trim', explode(':', $line, 2));
1600 + $field = strtolower($field);
1601 +
1602 + if ('user-agent' === $field) {
1603 + if (!$collecting) {
1604 + $current = [];
1605 + $collecting = true;
1606 + }
1607 +
1608 + $agent = strtolower($value);
1609 + if ('' !== $agent) {
1610 + $current[] = $agent;
1611 + if (!isset($groups[$agent])) {
1612 + $groups[$agent] = false;
1613 + }
1614 + }
1615 +
1616 + continue;
1617 + }
1618 +
1619 + $collecting = false;
1620 +
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)) {
1625 + foreach ($current as $agent) {
1626 + $groups[$agent] = true;
1627 + }
1628 + }
1629 + }
1630 +
1631 + return $groups;
1632 + }
1633 +
1634 + /**
1635 + * /llms.txt should be published — it is the one file whose entire purpose
1636 + * is telling an AI assistant what this site is and what to read.
1637 + *
1638 + * @since 2.5.0
1639 + * @return array
1640 + */
1641 + public function check_llms_txt(): array {
1642 + $label = __('llms.txt is published', 'thinkrank');
1643 +
1644 + if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
1645 + return [
1646 + 'label' => $label,
1647 + 'status' => self::WARNING,
1648 + 'message' => __('The llms.txt module is unavailable, so this could not be checked.', 'thinkrank'),
1649 + ];
1650 + }
1651 +
1652 + $manager = new LLMs_Txt_Manager();
1653 +
1654 + if ($manager->is_published()) {
1655 + return [
1656 + 'label' => $label,
1657 + 'status' => self::PASSED,
1658 + 'message' => __('Your llms.txt is published, so AI assistants have a summary of your site and its key pages.', 'thinkrank'),
1659 + 'value' => home_url('/llms.txt'),
1660 + ];
1661 + }
1662 +
1663 + return [
1664 + 'label' => $label,
1665 + 'status' => self::FAILED,
1666 + 'message' => __('No llms.txt is published. It is the file AI assistants read to learn what your site is about and which pages matter.', 'thinkrank'),
1667 + 'how_to_fix' => __('Fill in the fields under Essential SEO → Crawling & AI Indexing → LLMs.txt and publish it.', 'thinkrank'),
1668 + ];
1669 + }
1670 +
1671 + /**
1672 + * Answer-shaped schema — FAQ, HowTo or Q&A — is what lets an engine lift a
1673 + * question and its answer as a pair instead of guessing at prose.
1674 + *
1675 + * @since 2.5.0
1676 + * @return array
1677 + */
1678 + public function check_answer_ready_schema(): array {
1679 + $label = __('Answer-ready structured data', 'thinkrank');
1680 +
1681 + if ($this->has_answer_schema()) {
1682 + return [
1683 + 'label' => $label,
1684 + 'status' => self::PASSED,
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'),
1686 + ];
1687 + }
1688 +
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');
1699 +
1700 + // Article/WebPage schema still tells an engine what the page is; the
1701 + // gap is the answer pairing, not structured data as a whole.
1702 + if ($this->schema_is_output()) {
1703 + return [
1704 + 'label' => $label,
1705 + 'status' => self::WARNING,
1706 + 'message' => __('Your pages publish Article or WebPage structured data, but no FAQ, How-To or Q&A schema. Those are the types AI answers quote from.', 'thinkrank'),
1707 + 'how_to_fix' => $how_to_fix,
1708 + ];
1709 + }
1710 +
1711 + return [
1712 + 'label' => $label,
1713 + 'status' => self::FAILED,
1714 + 'message' => __('Your pages publish no structured data at all, so an AI assistant has to infer what each page is from its prose.', 'thinkrank'),
1715 + 'how_to_fix' => $how_to_fix,
1716 + ];
1717 + }
1718 +
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 + /**
1859 + * Whether the site publishes FAQ / HowTo / Q&A structured data.
1860 + *
1861 + * Three sources, because three things emit it: the Schema Management
1862 + * System's enabled types, a per-post-type schema_type in Global SEO, and
1863 + * ThinkRank's own FAQ/HowTo blocks, which output their schema from the
1864 + * block itself with nothing to configure.
1865 + *
1866 + * @since 2.5.0
1867 + * @return bool
1868 + */
1869 + private function has_answer_schema(): bool {
1870 + $answer_types = ['faqpage', 'faq', 'howto', 'qapage'];
1871 +
1872 + if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
1873 + $settings = (new Schema_Management_System())->get_settings('site', null);
1874 +
1875 + if (is_array($settings) && !empty($settings['enabled'])) {
1876 + $enabled = $settings['enabled_schema_types'] ?? [];
1877 + if (is_array($enabled)) {
1878 + foreach ($enabled as $type) {
1879 + if (in_array(strtolower((string) $type), $answer_types, true)) {
1880 + return true;
1881 + }
1882 + }
1883 + }
1884 + }
1885 + }
1886 +
1887 + $global = get_option('thinkrank_global_seo_settings', []);
1888 + if (is_array($global)) {
1889 + foreach ($global as $per_type) {
1890 + $type = is_array($per_type) ? strtolower((string) ($per_type['schema_type'] ?? '')) : '';
1891 + if ('' !== $type && in_array($type, $answer_types, true)) {
1892 + return true;
1893 + }
1894 + }
1895 + }
1896 +
1897 + // The blocks carry their own schema, so a single page using one is a
1898 + // true positive even with every schema setting untouched.
1899 + foreach ($this->get_content_sample() as $row) {
1900 + if ($this->has_answer_content((int) ($row['id'] ?? 0), (string) $row['content'])) {
1901 + return true;
1902 + }
1903 + }
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 +
1918 + return false;
1919 + }
1920 +
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 + /**
1994 + * Whether one post carries a ThinkRank FAQ or How-To that emits schema.
1995 + *
1996 + * Four surfaces, because `Schema_Graph` collects from four: the Gutenberg
1997 + * block in `post_content`, the Elementor widget, the Bricks element and the
1998 + * Beaver Builder module — the last three living in postmeta. Reading only
1999 + * the block would tell a site whose FAQs are built in a page builder that it
2000 + * publishes no FAQ schema while the graph is publishing exactly that.
2001 + *
2002 + * @since 2.5.0
2003 + * @param int $post_id Post to inspect; 0 skips the builder surfaces.
2004 + * @param string $content Raw post content.
2005 + * @return bool
2006 + */
2007 + private function has_answer_content(int $post_id, string $content): bool {
2008 + if (false !== strpos($content, 'wp:thinkrank/faq')
2009 + || false !== strpos($content, 'wp:thinkrank/howto')) {
2010 + return true;
2011 + }
2012 +
2013 + if ($post_id <= 0) {
2014 + return false;
2015 + }
2016 +
2017 + // Elementor stores its tree as JSON, so the widget name appears verbatim.
2018 + $elementor = get_post_meta($post_id, '_elementor_data', true);
2019 + if (is_string($elementor)
2020 + && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"')
2021 + || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) {
2022 + return true;
2023 + }
2024 +
2025 + // Bricks: the tree it will actually render, resolved the same way
2026 + // Schema_Graph resolves it, so templates and components are covered.
2027 + if ($this->bricks_has_answer_element($post_id)) {
2028 + return true;
2029 + }
2030 +
2031 + // Beaver Builder keeps its layout in postmeta as a map of node objects.
2032 + $layout = get_post_meta($post_id, '_fl_builder_data', true);
2033 + if (is_array($layout)) {
2034 + foreach ($layout as $node) {
2035 + $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
2036 + $settings = is_object($settings) ? get_object_vars($settings) : $settings;
2037 + $type = is_array($settings) ? (string) ($settings['type'] ?? '') : '';
2038 +
2039 + if (self::ANSWER_FAQ_NAME === $type || self::ANSWER_HOWTO_NAME === $type) {
2040 + return true;
2041 + }
2042 + }
2043 + }
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 +
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);
2110 + }
2111 +
2112 + /**
2113 + * Whether a Bricks-rendered post holds a ThinkRank FAQ or How-To element.
2114 + *
2115 + * @since 2.5.0
2116 + * @param int $post_id Post to inspect.
2117 + * @return bool
2118 + */
2119 + private function bricks_has_answer_element(int $post_id): bool {
2120 + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
2121 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
2122 + if (!file_exists($file)) {
2123 + return false;
2124 + }
2125 + require_once $file;
2126 + }
2127 +
2128 + foreach (Builder_Content::bricks_tree($post_id) as $element) {
2129 + $name = is_array($element) ? (string) ($element['name'] ?? '') : '';
2130 +
2131 + if (self::ANSWER_FAQ_NAME === $name || self::ANSWER_HOWTO_NAME === $name) {
2132 + return true;
2133 + }
2134 + }
2135 +
2136 + return false;
2137 + }
2138 +
2139 + /**
2140 + * Pages should open with a short, self-contained answer an engine can lift.
2141 + *
2142 + * @since 2.5.0
2143 + * @return array
2144 + */
2145 + public function check_direct_answer(): array {
2146 + return $this->coverage_check(
2147 + __('Pages open with a direct answer', 'thinkrank'),
2148 + function (array $row): bool {
2149 + $words = $this->word_count($this->opening_passage($row['content']));
2150 +
2151 + return $words >= self::GEO_ANSWER_MIN_WORDS && $words <= self::GEO_ANSWER_MAX_WORDS;
2152 + },
2153 + /* translators: 1: matching posts, 2: sampled posts. */
2154 + __('%1$d of your %2$d most recent pages open with a concise, quotable answer.', 'thinkrank'),
2155 + /* translators: 1: posts without one, 2: sampled posts. */
2156 + __('%1$d of your %2$d most recent pages do not open with a concise answer. AI assistants quote the first self-contained passage they find, and a long preamble gives them nothing to lift.', 'thinkrank'),
2157 + __('Open each page with a 40–60 word paragraph that answers its title directly, before any background or introduction.', 'thinkrank'),
2158 + __('No published content to check yet.', 'thinkrank')
2159 + );
2160 + }
2161 +
2162 + /**
2163 + * The first block of prose in a post, before any heading.
2164 + *
2165 + * @since 2.5.0
2166 + * @param string $content Raw post content.
2167 + * @return string Plain text of the opening passage.
2168 + */
2169 + private function opening_passage(string $content): string {
2170 + // Cut at the first heading: everything above it is the intro, and an
2171 + // intro that runs past a heading is not a direct answer either way.
2172 + $chunks = preg_split('/<h[1-6][^>]*>/i', $content, 3) ?: [];
2173 + $intro = (string) ($chunks[0] ?? $content);
2174 +
2175 + $passage = $this->first_paragraph_text($intro);
2176 + if ('' !== $passage) {
2177 + return $passage;
2178 + }
2179 +
2180 + $text = $this->content_to_text($intro);
2181 +
2182 + // A page that opens with its heading has nothing above it, so the chunk
2183 + // read so far is empty and the answer sits directly under that heading.
2184 + // Scoring it as a missing opening would fail exactly the pages written
2185 + // as "question, then answer" — the shape this check exists to reward.
2186 + if ('' === trim($text) && isset($chunks[1])) {
2187 + // Drop the heading's own text, which the split left at the head of
2188 + // the next chunk, so a long heading cannot pose as the answer.
2189 + $after = preg_replace('/^.*?<\/h[1-6]>/is', '', (string) $chunks[1], 1);
2190 + $after = null === $after ? (string) $chunks[1] : $after;
2191 +
2192 + $passage = $this->first_paragraph_text($after);
2193 + if ('' !== $passage) {
2194 + return $passage;
2195 + }
2196 +
2197 + return $this->content_to_text($after);
2198 + }
2199 +
2200 + return $text;
2201 + }
2202 +
2203 + /**
2204 + * The first paragraph of a chunk of content with enough words to be a
2205 + * passage rather than a caption or a stray line.
2206 + *
2207 + * Only the first one counts — a three-paragraph intro is exactly the
2208 + * preamble the direct-answer check is looking for.
2209 + *
2210 + * @since 2.5.0
2211 + * @param string $chunk Raw content fragment.
2212 + * @return string Plain text, or '' when the chunk holds no paragraph.
2213 + */
2214 + private function first_paragraph_text(string $chunk): string {
2215 + $paragraphs = preg_split('/<\/p>|\R{2,}/', $chunk) ?: [];
2216 +
2217 + foreach ($paragraphs as $paragraph) {
2218 + $candidate = $this->content_to_text((string) $paragraph);
2219 + if ($this->word_count($candidate) >= 5) {
2220 + return $candidate;
2221 + }
2222 + }
2223 +
2224 + return '';
2225 + }
2226 +
2227 + /**
2228 + * Question-shaped H2/H3 headings map a page onto the questions people
2229 + * actually ask an assistant.
2230 + *
2231 + * @since 2.5.0
2232 + * @return array
2233 + */
2234 + public function check_question_headings(): array {
2235 + return $this->coverage_check(
2236 + __('Headings phrased as questions', 'thinkrank'),
2237 + function (array $row): bool {
2238 + return $this->has_question_heading($row['content']);
2239 + },
2240 + /* translators: 1: matching posts, 2: sampled posts. */
2241 + __('%1$d of your %2$d most recent pages use question-style headings.', 'thinkrank'),
2242 + /* translators: 1: posts without one, 2: sampled posts. */
2243 + __('%1$d of your %2$d most recent pages have no question-style heading. Assistants match a user\'s question against your headings first.', 'thinkrank'),
2244 + __('Phrase at least one H2 or H3 per page as the question it answers — "How does X work?" rather than "Overview".', 'thinkrank'),
2245 + __('No published content to check yet.', 'thinkrank')
2246 + );
2247 + }
2248 +
2249 + /**
2250 + * Whether any H2/H3 in the content reads as a question.
2251 + *
2252 + * @since 2.5.0
2253 + * @param string $content Raw post content.
2254 + * @return bool
2255 + */
2256 + private function has_question_heading(string $content): bool {
2257 + if (!preg_match_all('/<h[23][^>]*>(.*?)<\/h[23]>/is', $content, $matches)) {
2258 + return false;
2259 + }
2260 +
2261 + $starters = ['what', 'why', 'how', 'when', 'where', 'who', 'which', 'can', 'do', 'does', 'is', 'are', 'should', 'will'];
2262 +
2263 + foreach ($matches[1] as $heading) {
2264 + $text = $this->content_to_text((string) $heading);
2265 +
2266 + if ('' === $text) {
2267 + continue;
2268 + }
2269 +
2270 + if ('?' === substr($text, -1)) {
2271 + return true;
2272 + }
2273 +
2274 + // A question mark is the reliable signal, but plenty of good
2275 + // question headings drop it ("How image search works").
2276 + $first = strtolower((string) strtok($text, " \t\n"));
2277 + if (in_array($first, $starters, true)) {
2278 + return true;
2279 + }
2280 + }
2281 +
2282 + return false;
2283 + }
2284 +
2285 + /**
2286 + * Lists and tables are the shapes an engine extracts most reliably.
2287 + *
2288 + * @since 2.5.0
2289 + * @return array
2290 + */
2291 + public function check_structured_content(): array {
2292 + return $this->coverage_check(
2293 + __('Content uses lists or tables', 'thinkrank'),
2294 + static function (array $row): bool {
2295 + return (bool) preg_match('/<(ul|ol|table)[\s>]/i', $row['content']);
2296 + },
2297 + /* translators: 1: matching posts, 2: sampled posts. */
2298 + __('%1$d of your %2$d most recent pages present information in lists or tables.', 'thinkrank'),
2299 + /* translators: 1: posts without one, 2: sampled posts. */
2300 + __('%1$d of your %2$d most recent pages are unbroken prose. Steps, comparisons and specifications are extracted far more reliably from a list or table.', 'thinkrank'),
2301 + __('Break steps, comparisons and specifications out of the paragraphs into list or table blocks.', 'thinkrank'),
2302 + __('No published content to check yet.', 'thinkrank')
2303 + );
2304 + }
2305 +
2306 + /**
2307 + * Thin pages are not cited: there is nothing in them worth quoting.
2308 + *
2309 + * @since 2.5.0
2310 + * @return array
2311 + */
2312 + public function check_content_depth(): array {
2313 + return $this->coverage_check(
2314 + __('Pages have enough depth to cite', 'thinkrank'),
2315 + function (array $row): bool {
2316 + return $this->word_count($row['text']) >= self::GEO_DEPTH_MIN_WORDS;
2317 + },
2318 + /* translators: 1: matching posts, 2: sampled posts. */
2319 + __('%1$d of your %2$d most recent pages have enough substance for an assistant to cite.', 'thinkrank'),
2320 + /* translators: 1: thin posts, 2: sampled posts. */
2321 + __('%1$d of your %2$d most recent pages are under 300 words. An assistant with a choice of sources rarely quotes the thinnest one.', 'thinkrank'),
2322 + __('Expand thin pages so each one answers its topic completely, or merge them into a page that does.', 'thinkrank'),
2323 + __('No published content to check yet.', 'thinkrank')
2324 + );
2325 + }
2326 +
2327 + /**
2328 + * Answer engines strongly prefer recently-revised sources.
2329 + *
2330 + * @since 2.5.0
2331 + * @return array
2332 + */
2333 + public function check_content_freshness(): array {
2334 + $cutoff = time() - self::GEO_FRESHNESS_MAX_AGE;
2335 +
2336 + return $this->coverage_check(
2337 + __('Content has been updated recently', 'thinkrank'),
2338 + static function (array $row) use ($cutoff): bool {
2339 + return $row['modified'] > 0 && $row['modified'] >= $cutoff;
2340 + },
2341 + /* translators: 1: matching posts, 2: sampled posts. */
2342 + __('%1$d of your %2$d most recent pages were revised within the last year.', 'thinkrank'),
2343 + /* translators: 1: stale posts, 2: sampled posts. */
2344 + __('%1$d of your %2$d most recent pages have not been revised in over a year. AI answers favour sources that look current.', 'thinkrank'),
2345 + __('Review your most important pages, update what has changed, and save them so their modified date reflects the revision.', 'thinkrank'),
2346 + __('No published content to check yet.', 'thinkrank')
2347 + );
947 2348 }
948 2349 }