PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 All 56 releases
thinkrank / includes / seo / class-seo-analyzer.php

class-seo-analyzer.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.1, at includes/seo/class-seo-analyzer.php

2,425 lines 99.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site SEO Analyzer
4 *
5 * Runs a crawl-free, site-wide SEO audit: a registry of individual checks is
6 * evaluated against the site's own configuration and a bounded sample of its
7 * published content, then aggregated into one overall 0–100 score, a letter
8 * grade, and per-category subtotals. Unlike the analytics-based "SEO health
9 * score", this requires no Google connection — it works out of the box.
10 *
11 * The result is cached in a transient; callers force a fresh run to bust it.
12 * Checks are registered through the `thinkrank_seo_analyzer_checks` filter so
13 * Pro/add-ons can contribute more without touching this class.
14 *
15 * @package ThinkRank\SEO
16 * @since 1.18.0
17 */
18
19 declare(strict_types=1);
20
21 namespace ThinkRank\SEO;
22
23 // Prevent direct access
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * SEO Analyzer Class
30 *
31 * @since 1.18.0
32 */
33 class SEO_Analyzer {
34
35 /**
36 * Transient key holding the last full analysis.
37 */
38 private const CACHE_KEY = 'thinkrank_site_seo_analysis';
39
40 /**
41 * How long a computed analysis stays cached (seconds).
42 */
43 private const CACHE_TTL = HOUR_IN_SECONDS;
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
71 // Check result statuses.
72 public const PASSED = 'passed';
73 public const WARNING = 'warning';
74 public const FAILED = 'failed';
75
76 /**
77 * Human-readable labels for each category id.
78 *
79 * @return array<string,string>
80 */
81 private function get_category_labels(): array {
82 return [
83 'basic' => __('Basic SEO', 'thinkrank'),
84 'advanced' => __('Advanced SEO', 'thinkrank'),
85 'content' => __('Content', 'thinkrank'),
86 'performance' => __('Performance & Technical', 'thinkrank'),
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'),
92 ];
93 }
94
95 /**
96 * WordPress options whose value the analyzer reports on directly.
97 *
98 * @since 2.2.0
99 * @var string[]
100 */
101 private const WATCHED_OPTIONS = [
102 'blog_public',
103 'permalink_structure',
104 'blogname',
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',
110 ];
111
112 /**
113 * Register cache invalidation.
114 *
115 * The analysis is cached for an hour, and until now only the image alt-text
116 * bulk writer ever busted it — so changing any other setting the audit
117 * reports on left the screen confidently wrong for up to 60 minutes. The
118 * audit's whole job is to describe the site's current configuration, so it
119 * invalidates on every write it could possibly be reading.
120 *
121 * @since 2.2.0
122 * @return void
123 */
124 public function init(): void {
125 foreach (self::WATCHED_OPTIONS as $option) {
126 add_action("update_option_{$option}", [$this, 'flush_cache']);
127 add_action("add_option_{$option}", [$this, 'flush_cache']);
128 }
129
130 // Any ThinkRank settings category can feed a check (sitemap, schema,
131 // image SEO today; more later). Flushing on all of them is cheaper than
132 // a list that silently rots as checks are added.
133 add_action('thinkrank_seo_settings_saved', [$this, 'flush_cache']);
134 }
135
136 /**
137 * Return the cached analysis, computing (and caching) it when missing or
138 * when a fresh run is forced.
139 *
140 * @param bool $force When true, ignore and overwrite the cached result.
141 * @return array The analysis payload (see analyze()).
142 */
143 public function run(bool $force = false): array {
144 if (!$force) {
145 $cached = get_transient(self::CACHE_KEY);
146 if (is_array($cached) && isset($cached['overall_score'])) {
147 return $cached;
148 }
149 }
150
151 $result = $this->analyze();
152 set_transient(self::CACHE_KEY, $result, self::CACHE_TTL);
153
154 return $result;
155 }
156
157 /**
158 * Clear the cached analysis so the next run() recomputes.
159 *
160 * @return void
161 */
162 public function flush_cache(): void {
163 delete_transient(self::CACHE_KEY);
164 }
165
166 /**
167 * Run every registered check and aggregate the results.
168 *
169 * @return array {
170 * @type int $overall_score Weighted 0–100 site score.
171 * @type string $grade Letter grade A–F.
172 * @type array $summary passed/warning/failed/total counts.
173 * @type array $categories Per-category subtotal + its checks.
174 * @type array $checks Flat list of every check result.
175 * @type string $generated_at ISO-8601 UTC timestamp.
176 * }
177 */
178 public function analyze(): array {
179 $checks = $this->run_checks();
180 $category_labels = $this->get_category_labels();
181
182 $fraction = [
183 self::PASSED => 1.0,
184 self::WARNING => 0.5,
185 self::FAILED => 0.0,
186 ];
187
188 $total_weight = 0.0;
189 $earned = 0.0;
190 $summary = [self::PASSED => 0, self::WARNING => 0, self::FAILED => 0, 'total' => 0];
191 $categories = [];
192
193 foreach ($checks as $check) {
194 $weight = (float) $check['weight'];
195 $status = $check['status'];
196 $frac = $fraction[$status] ?? 0.0;
197
198 $total_weight += $weight;
199 $earned += $weight * $frac;
200
201 $summary[$status] = ($summary[$status] ?? 0) + 1;
202 $summary['total']++;
203
204 $cat = $check['category'];
205 if (!isset($categories[$cat])) {
206 $categories[$cat] = [
207 'id' => $cat,
208 'label' => $category_labels[$cat] ?? ucfirst($cat),
209 'score' => 0,
210 'weight' => 0.0,
211 'earned' => 0.0,
212 self::PASSED => 0,
213 self::WARNING => 0,
214 self::FAILED => 0,
215 'checks' => [],
216 ];
217 }
218 $categories[$cat]['weight'] += $weight;
219 $categories[$cat]['earned'] += $weight * $frac;
220 $categories[$cat][$status] = ($categories[$cat][$status] ?? 0) + 1;
221 $categories[$cat]['checks'][] = $check;
222 }
223
224 // Finalize per-category scores and drop the internal accumulators.
225 foreach ($categories as $cat => &$data) {
226 $data['score'] = $data['weight'] > 0
227 ? (int) round(($data['earned'] / $data['weight']) * 100)
228 : 0;
229 unset($data['weight'], $data['earned']);
230 }
231 unset($data);
232
233 $overall = $total_weight > 0 ? (int) round(($earned / $total_weight) * 100) : 0;
234
235 $result = [
236 'overall_score' => $overall,
237 'grade' => $this->score_to_grade($overall),
238 'summary' => $summary,
239 'categories' => array_values($categories),
240 'checks' => $checks,
241 'generated_at' => gmdate('c'),
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;
263 }
264
265 /**
266 * Map a 0–100 score to a letter grade.
267 *
268 * @param int $score The overall score.
269 * @return string Letter grade.
270 */
271 private function score_to_grade(int $score): string {
272 if ($score >= 90) {
273 return 'A';
274 }
275 if ($score >= 80) {
276 return 'B';
277 }
278 if ($score >= 70) {
279 return 'C';
280 }
281 if ($score >= 60) {
282 return 'D';
283 }
284 return 'F';
285 }
286
287 /**
288 * Evaluate every registered check, normalizing each result.
289 *
290 * A check whose callback throws or returns a malformed value is skipped so
291 * one broken check can't take down the whole analysis.
292 *
293 * @return array<int,array> Normalized check results.
294 */
295 private function run_checks(): array {
296 $results = [];
297
298 foreach ($this->get_check_definitions() as $def) {
299 if (empty($def['callback']) || !is_callable($def['callback'])) {
300 continue;
301 }
302
303 try {
304 $outcome = call_user_func($def['callback']);
305 } catch (\Throwable $e) {
306 continue;
307 }
308
309 if (!is_array($outcome) || empty($outcome['status'])) {
310 continue;
311 }
312
313 $id = (string) ($def['id'] ?? '');
314 $status = (string) $outcome['status'];
315
316 // Only offer a fix on a finding that still needs one — a passing
317 // check with a Fix button reads as "did this even work?".
318 $fixable = self::PASSED !== $status && SEO_Analyzer_Fixer::can_fix($id);
319 $fix = $fixable ? (SEO_Analyzer_Fixer::fixable()[$id] ?? []) : [];
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
325 $results[] = [
326 'id' => $id,
327 'category' => (string) ($def['category'] ?? 'basic'),
328 'weight' => isset($def['weight']) ? (float) $def['weight'] : 1.0,
329 'label' => (string) ($outcome['label'] ?? $def['label'] ?? ''),
330 'status' => $status,
331 'message' => (string) ($outcome['message'] ?? ''),
332 'how_to_fix' => (string) ($outcome['how_to_fix'] ?? ''),
333 'value' => $outcome['value'] ?? null,
334 'affected_posts' => $affected,
335 'affected_total' => max(count($affected), absint($outcome['affected_total'] ?? 0)),
336 'can_auto_fix' => $fixable,
337 'fix_label' => (string) ($fix['label'] ?? ''),
338 'fix_warning' => (string) ($fix['warning'] ?? ''),
339 ];
340 }
341
342 return $results;
343 }
344
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 /**
381 * The registry of checks: id, category, weight, and the callback that
382 * evaluates it. Filterable so Pro/add-ons can register additional checks.
383 *
384 * @return array<int,array>
385 */
386 private function get_check_definitions(): array {
387 $definitions = [
388 // Basic SEO
389 ['id' => 'site_title', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_site_title']],
390 ['id' => 'tagline', 'category' => 'basic', 'weight' => 1, 'callback' => [$this, 'check_tagline']],
391 ['id' => 'search_visibility', 'category' => 'basic', 'weight' => 3, 'callback' => [$this, 'check_search_visibility']],
392 ['id' => 'permalinks', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_permalinks']],
393
394 // Advanced SEO
395 ['id' => 'xml_sitemap', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_sitemap']],
396 ['id' => 'schema', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_schema']],
397
398 // Content (bounded sample of published content)
399 ['id' => 'meta_descriptions', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_meta_descriptions']],
400 ['id' => 'image_alt_text', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_image_alt_text']],
401
402 // Performance & Technical
403 ['id' => 'php_version', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_php_version']],
404 ['id' => 'object_cache', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_object_cache']],
405
406 // Security
407 ['id' => 'https', 'category' => 'security', 'weight' => 3, 'callback' => [$this, 'check_https']],
408 ['id' => 'file_editing', 'category' => 'security', 'weight' => 2, 'callback' => [$this, 'check_file_editing']],
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']],
423 ];
424
425 /**
426 * Filter the Site SEO Analyzer check registry.
427 *
428 * Each entry is an array with keys: id, category (basic|advanced|
429 * content|performance|security|geo), weight (float), and callback (callable
430 * returning ['status' => passed|warning|failed, 'label', 'message',
431 * 'how_to_fix']).
432 *
433 * @since 1.18.0
434 *
435 * @param array $definitions Registered checks.
436 * @param SEO_Analyzer $analyzer The analyzer instance.
437 */
438 $definitions = apply_filters('thinkrank_seo_analyzer_checks', $definitions, $this);
439
440 return is_array($definitions) ? $definitions : [];
441 }
442
443 // ─────────────────────────────────────────────────────────────────────
444 // Basic SEO checks
445 // ─────────────────────────────────────────────────────────────────────
446
447 /**
448 * The site must have a name/title configured.
449 *
450 * @return array
451 */
452 public function check_site_title(): array {
453 $title = trim((string) get_bloginfo('name'));
454
455 if ($title === '') {
456 return [
457 'label' => __('Site title is set', 'thinkrank'),
458 'status' => self::FAILED,
459 'message' => __('Your site has no title. Search engines and browsers use it as your brand name.', 'thinkrank'),
460 'how_to_fix' => __('Set a site title under Settings → General → Site Title.', 'thinkrank'),
461 ];
462 }
463
464 return [
465 'label' => __('Site title is set', 'thinkrank'),
466 'status' => self::PASSED,
467 'message' => __('Your site title is configured.', 'thinkrank'),
468 'value' => $title,
469 ];
470 }
471
472 /**
473 * The tagline should be set and not left at the WordPress default.
474 *
475 * @return array
476 */
477 public function check_tagline(): array {
478 $tagline = trim((string) get_bloginfo('description'));
479
480 $is_default = $this->is_default_tagline($tagline);
481
482 if ($tagline === '' || $is_default) {
483 return [
484 'label' => __('Tagline is customized', 'thinkrank'),
485 'status' => self::WARNING,
486 'message' => __('Your tagline is blank or still the WordPress default. Search engines may use it as your homepage description.', 'thinkrank'),
487 'how_to_fix' => __('Write a descriptive tagline under Settings → General → Tagline.', 'thinkrank'),
488 ];
489 }
490
491 return [
492 'label' => __('Tagline is customized', 'thinkrank'),
493 'status' => self::PASSED,
494 'message' => __('Your tagline is set and ready to describe your site.', 'thinkrank'),
495 'value' => $tagline,
496 ];
497 }
498
499 /**
500 * Whether a tagline is still WordPress' shipped default.
501 *
502 * The installer writes the TRANSLATED default into blogdescription, so an
503 * English-only literal silently passed an untouched tagline on every
504 * non-English install. The string lives in core's `admin-{locale}.mo`,
505 * which a REST request (how this analyzer runs) does not load — so the
506 * catalogue is loaded on demand for the comparison when the site is not
507 * running in English.
508 *
509 * @since 2.2.0
510 * @param string $tagline Trimmed tagline.
511 * @return bool
512 */
513 private function is_default_tagline(string $tagline): bool {
514 $candidates = ['Just another WordPress site'];
515
516 $locale = get_locale();
517 if ('en_US' !== $locale) {
518 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch,WordPress.WP.I18n.LowLevelTranslationFunction -- core's own string in the `default` domain, read at runtime.
519 $translated = translate('Just another WordPress site', 'default');
520
521 if ($translated === 'Just another WordPress site') {
522 // Not in the loaded catalogue — pull in the admin one, which is
523 // where core ships this string, then ask again.
524 $mofile = WP_LANG_DIR . '/admin-' . $locale . '.mo';
525 if (is_readable($mofile)) {
526 load_textdomain('default', $mofile, $locale);
527 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch,WordPress.WP.I18n.LowLevelTranslationFunction -- as above.
528 $translated = translate('Just another WordPress site', 'default');
529 }
530 }
531
532 $candidates[] = $translated;
533 }
534
535 foreach ($candidates as $candidate) {
536 if (strtolower($tagline) === strtolower($candidate)) {
537 return true;
538 }
539 }
540
541 return false;
542 }
543
544 /**
545 * "Discourage search engines from indexing this site" must be OFF.
546 *
547 * @return array
548 */
549 public function check_search_visibility(): array {
550 // blog_public = 0 means the WP "Discourage search engines" box is ticked.
551 if (!get_option('blog_public')) {
552 return [
553 'label' => __('Site is visible to search engines', 'thinkrank'),
554 'status' => self::FAILED,
555 'message' => __('Your site is telling search engines not to index it, so it will not appear in search results.', 'thinkrank'),
556 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'),
557 ];
558 }
559
560 return [
561 'label' => __('Site is visible to search engines', 'thinkrank'),
562 'status' => self::PASSED,
563 'message' => __('Your site allows search engines to index it.', 'thinkrank'),
564 ];
565 }
566
567 /**
568 * Permalinks should be pretty (not the default plain ?p=123 structure).
569 *
570 * @return array
571 */
572 public function check_permalinks(): array {
573 $structure = (string) get_option('permalink_structure');
574
575 if ($structure === '') {
576 return [
577 'label' => __('Search-friendly permalinks', 'thinkrank'),
578 'status' => self::WARNING,
579 'message' => __('Your site uses plain, numeric URLs (e.g. ?p=123). Descriptive URLs are easier for search engines and users.', 'thinkrank'),
580 'how_to_fix' => __('Choose a pretty permalink structure (e.g. Post name) under Settings → Permalinks.', 'thinkrank'),
581 ];
582 }
583
584 return [
585 'label' => __('Search-friendly permalinks', 'thinkrank'),
586 'status' => self::PASSED,
587 'message' => __('Your permalinks are search-friendly.', 'thinkrank'),
588 'value' => $structure,
589 ];
590 }
591
592 // ─────────────────────────────────────────────────────────────────────
593 // Advanced SEO checks
594 // ─────────────────────────────────────────────────────────────────────
595
596 /**
597 * The ThinkRank XML sitemap should be enabled.
598 *
599 * @return array
600 */
601 public function check_sitemap(): array {
602 $enabled = true;
603 try {
604 $generator = new Sitemap_Generator();
605 // 'site' is the stored context; 'global' is unsupported and
606 // returns DEFAULTS (enabled=true), which made this check unable
607 // to fail no matter what the user configured.
608 $data = $generator->get_output_data('site', null);
609 $enabled = !empty($data['enabled']);
610 } catch (\Throwable $e) {
611 // Fall back to "enabled" — the default state — on any lookup error.
612 $enabled = true;
613 }
614
615 if (!$enabled) {
616 return [
617 'label' => __('XML sitemap is enabled', 'thinkrank'),
618 'status' => self::WARNING,
619 'message' => __('Your XML sitemap is turned off. Search engines rely on it to discover new pages quickly.', 'thinkrank'),
620 'how_to_fix' => __('Enable the XML sitemap under Essential SEO → Crawling & AI Indexing → XML Sitemap.', 'thinkrank'),
621 ];
622 }
623
624 return [
625 'label' => __('XML sitemap is enabled', 'thinkrank'),
626 'status' => self::PASSED,
627 'message' => __('Your XML sitemap is enabled and pointing crawlers to your content.', 'thinkrank'),
628 ];
629 }
630
631 /**
632 * Structured data (schema) should be configured for at least one post type.
633 *
634 * @return array
635 */
636 public function check_schema(): array {
637 $label = __('Structured data configured', 'thinkrank');
638
639 if ($this->schema_is_configured()) {
640 return [
641 'label' => $label,
642 'status' => self::PASSED,
643 'message' => __('Structured data is configured for your content.', 'thinkrank'),
644 ];
645 }
646
647 // Nothing is configured, but ThinkRank still emits JSON-LD from its
648 // built-in per-post-type defaults. Saying "no schema" there would be
649 // false; the actionable point is that nobody has reviewed it.
650 if ($this->schema_is_output()) {
651 return [
652 'label' => $label,
653 'status' => self::WARNING,
654 'message' => __('Structured data is running on ThinkRank\'s built-in defaults. Reviewing the schema type for each post type gives you control over how rich results appear.', 'thinkrank'),
655 'how_to_fix' => __('Choose a schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
656 ];
657 }
658
659 return [
660 'label' => $label,
661 'status' => self::FAILED,
662 'message' => __('No schema/structured data is configured or emitted. Schema powers rich results in search.', 'thinkrank'),
663 'how_to_fix' => __('Turn on automatic structured data, or choose a schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
664 ];
665 }
666
667 /**
668 * Whether the user has EXPLICITLY configured structured data.
669 *
670 * Distinct from schema_is_output(): the Global SEO layer falls back to a
671 * built-in schema type for every public post type, so "something is
672 * emitted" is true on every site and made this check impossible to fail
673 * (its weight was earned unconditionally and its one-click fix was
674 * unreachable). This asks the question the check's copy actually claims to
675 * answer.
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 *
684 * @since 2.2.0
685 * @return bool
686 */
687 private function schema_is_configured(): bool {
688 // 1) Schema Management System — an explicit opt-in. Read the SAVED rows
689 // only; the defaults-merged view is truthy on every site.
690 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
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) {
705 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
706 return true;
707 }
708 if (!empty($settings['auto_generate_schema'])) {
709 return true;
710 }
711 }
712 }
713
714 // 2) A saved per-post-type schema_type in the Global SEO layer. The
715 // built-in default deliberately does not count here.
716 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
717 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
718 foreach (get_post_types(['public' => true], 'names') as $post_type) {
719 if ($output->has_explicit_schema_type((string) $post_type)) {
720 return true;
721 }
722 }
723 }
724
725 return false;
726 }
727
728 /**
729 * Whether ThinkRank actually emits structured data for this site.
730 *
731 * The audit must reflect what is rendered, not a single legacy option.
732 * ThinkRank outputs schema from two current sources, so this check consults
733 * both rather than the deprecated thinkrank_global_seo_settings['schema_type']
734 * opt-in (which most sites never set even though schema is emitted):
735 *
736 * 1. The Schema Management System — its configuration lives in the
737 * thinkrank_seo_settings table (context "schema_management_system"),
738 * read through the manager's settings abstraction.
739 * 2. The Global SEO output layer — an explicit saved schema_type OR the
740 * built-in per-post-type default both cause JSON-LD to be emitted on
741 * the frontend. We ask that layer directly (would_output_schema) so the
742 * audit and the rendered page can never diverge.
743 *
744 * @return bool True when structured data is emitted for the site's content.
745 */
746 private function schema_is_output(): bool {
747 // 1) Newer Schema Management System (thinkrank_seo_settings table).
748 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
749 // 'site' is the context type; 'schema_management_system' is the manager
750 // NAME, which get_settings() rejects as an unsupported context and
751 // answers with bare defaults — where auto_generate_schema is true, so
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.
759 $settings = (new Schema_Management_System())->get_settings('site', null);
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'])) {
765 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
766 return true;
767 }
768 if (!empty($settings['auto_generate_schema'])) {
769 return true;
770 }
771 }
772 }
773
774 // 2) Global SEO output layer — explicit schema_type or per-post-type
775 // default. Reuse the output layer's own decision so audit == output.
776 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
777 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
778 foreach (get_post_types(['public' => true], 'names') as $post_type) {
779 if ($output->would_output_schema((string) $post_type)) {
780 return true;
781 }
782 }
783 }
784
785 return false;
786 }
787
788 // ─────────────────────────────────────────────────────────────────────
789 // Content checks (bounded sample of published content)
790 // ─────────────────────────────────────────────────────────────────────
791
792 /**
793 * How many recent published posts/pages the content checks sample.
794 */
795 private const CONTENT_SAMPLE_SIZE = 100;
796
797 /**
798 * Coverage thresholds shared by the content checks: at or above the first
799 * is a pass, at or above the second is a warning, below it a fail.
800 */
801 private const COVERAGE_PASS = 90;
802 private const COVERAGE_WARN = 50;
803
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 /**
814 * Recent published posts/pages should have meta descriptions.
815 *
816 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
817 * check stays fast on large sites.
818 *
819 * @return array
820 */
821 public function check_meta_descriptions(): array {
822 $label = __('Posts have meta descriptions', 'thinkrank');
823
824 $post_ids = $this->sample_post_ids();
825
826 $total = count($post_ids);
827 if (0 === $total) {
828 return [
829 'label' => $label,
830 'status' => self::PASSED,
831 'message' => __('No published content to check yet.', 'thinkrank'),
832 ];
833 }
834
835 // Count posts with an *effective* meta description, the same way the
836 // frontend resolves it: a custom _thinkrank_meta_description when set,
837 // otherwise the global SEO pattern fallback (Pattern_Resolver). Counting
838 // only the custom post-meta produced false negatives — posts that output
839 // a valid description via the pattern fallback were wrongly reported as
840 // missing. The sample is bounded (CONTENT_SAMPLE_SIZE) so the per-post
841 // resolution stays cheap, and the whole analysis is cached for an hour.
842 // 'fields' => 'ids' skips WP_Query's meta priming, so the first
843 // get_post_meta() below would issue a query per post. Warm the whole
844 // sample once instead — 100 posts went from ~200 queries to a handful.
845 _prime_post_caches($post_ids, false, true);
846
847 $with_description = 0;
848 $without = [];
849 foreach ($post_ids as $post_id) {
850 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
851 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
852 if ('' !== trim($resolved)) {
853 $with_description++;
854 } else {
855 $without[] = (int) $post_id;
856 }
857 }
858
859 $coverage = (int) round(($with_description / $total) * 100);
860 $missing = $total - $with_description;
861 $value = sprintf('%d/%d', $with_description, $total);
862
863 if ($coverage >= self::COVERAGE_PASS) {
864 return [
865 'label' => $label,
866 'status' => self::PASSED,
867 /* translators: 1: posts with meta description, 2: sampled posts. */
868 'message' => sprintf(__('%1$d of your %2$d most recent posts have a meta description.', 'thinkrank'), $with_description, $total),
869 'value' => $value,
870 ];
871 }
872
873 return [
874 'label' => $label,
875 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
876 /* translators: 1: posts missing a meta description, 2: sampled posts. */
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),
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),
881 ];
882 }
883
884 /**
885 * Uploaded images should have alt text — it is an accessibility
886 * requirement and how image search understands your media.
887 *
888 * @return array
889 */
890 public function check_image_alt_text(): array {
891 $label = __('Images have alt text', 'thinkrank');
892
893 global $wpdb;
894 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- two indexed COUNTs; results are cached at the analysis level
895 $total = (int) $wpdb->get_var(
896 "SELECT COUNT(*) FROM {$wpdb->posts}
897 WHERE post_type = 'attachment'
898 AND post_mime_type LIKE 'image/%'
899 AND post_status != 'trash'"
900 );
901
902 if (0 === $total) {
903 return [
904 'label' => $label,
905 'status' => self::PASSED,
906 'message' => __('No images in your media library to check yet.', 'thinkrank'),
907 ];
908 }
909
910 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index
911 $with_alt = (int) $wpdb->get_var(
912 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
913 INNER JOIN {$wpdb->postmeta} pm
914 ON pm.post_id = p.ID
915 AND pm.meta_key = '_wp_attachment_image_alt'
916 AND pm.meta_value != ''
917 WHERE p.post_type = 'attachment'
918 AND p.post_mime_type LIKE 'image/%'
919 AND p.post_status != 'trash'"
920 );
921
922 $coverage = (int) round(($with_alt / $total) * 100);
923 $missing = $total - $with_alt;
924 $value = sprintf('%d/%d', $with_alt, $total);
925
926 if ($coverage >= self::COVERAGE_PASS) {
927 return [
928 'label' => $label,
929 'status' => self::PASSED,
930 /* translators: 1: images with alt text, 2: total images. */
931 'message' => sprintf(__('%1$d of your %2$d images have alt text.', 'thinkrank'), $with_alt, $total),
932 'value' => $value,
933 ];
934 }
935
936 return [
937 'label' => $label,
938 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
939 /* translators: 1: images missing alt text, 2: total images. */
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),
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,
945 ];
946 }
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
983 // ─────────────────────────────────────────────────────────────────────
984 // Performance & Technical checks
985 // ─────────────────────────────────────────────────────────────────────
986
987 /**
988 * The site should run a supported PHP version.
989 *
990 * @return array
991 */
992 public function check_php_version(): array {
993 $current = PHP_VERSION;
994 // ThinkRank itself requires PHP 8.0 to run, so anything below 8.1 (the
995 // oldest actively-supported branch) is the meaningful warning line —
996 // a 7.x threshold here could never fire.
997 $supported = version_compare($current, '8.1', '>=');
998
999 if (!$supported) {
1000 return [
1001 'label' => __('Supported PHP version', 'thinkrank'),
1002 'status' => self::WARNING,
1003 /* translators: %s: current PHP version. */
1004 'message' => sprintf(__('You are running PHP %s, which no longer receives active support. Newer PHP is faster and more secure.', 'thinkrank'), $current),
1005 'how_to_fix' => __('Ask your host to upgrade to PHP 8.1 or newer.', 'thinkrank'),
1006 'value' => $current,
1007 ];
1008 }
1009
1010 return [
1011 'label' => __('Supported PHP version', 'thinkrank'),
1012 'status' => self::PASSED,
1013 /* translators: %s: current PHP version. */
1014 'message' => sprintf(__('You are running a supported PHP version (%s).', 'thinkrank'), $current),
1015 'value' => $current,
1016 ];
1017 }
1018
1019 /**
1020 * A persistent object cache should be active for a faster, less DB-bound
1021 * site.
1022 *
1023 * @return array
1024 */
1025 public function check_object_cache(): array {
1026 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
1027 return [
1028 'label' => __('Persistent object cache', 'thinkrank'),
1029 'status' => self::PASSED,
1030 'message' => __('A persistent object cache is active, reducing database load.', 'thinkrank'),
1031 ];
1032 }
1033
1034 return [
1035 'label' => __('Persistent object cache', 'thinkrank'),
1036 'status' => self::WARNING,
1037 'message' => __('No persistent object cache is active. On busier sites this means more database queries per request.', 'thinkrank'),
1038 'how_to_fix' => __('Enable a persistent object cache (e.g. Redis or Memcached) via your host or a caching plugin.', 'thinkrank'),
1039 ];
1040 }
1041
1042 // ─────────────────────────────────────────────────────────────────────
1043 // Security checks
1044 // ─────────────────────────────────────────────────────────────────────
1045
1046 /**
1047 * The site should be served over HTTPS (SSL).
1048 *
1049 * @return array
1050 */
1051 public function check_https(): array {
1052 $home = (string) get_option('home');
1053 $uses_https = strpos($home, 'https://') === 0;
1054
1055 // WP 5.7+ can tell us the site is fully HTTPS-capable.
1056 if (function_exists('wp_is_using_https')) {
1057 $uses_https = $uses_https && wp_is_using_https();
1058 }
1059
1060 if (!$uses_https) {
1061 return [
1062 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
1063 'status' => self::FAILED,
1064 'message' => __('Your site URL is not served over HTTPS. HTTPS is a confirmed ranking signal and required for user trust.', 'thinkrank'),
1065 'how_to_fix' => __('Install an SSL certificate and set your WordPress Address / Site Address to https:// under Settings → General.', 'thinkrank'),
1066 ];
1067 }
1068
1069 return [
1070 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
1071 'status' => self::PASSED,
1072 'message' => __('Your site is served securely over HTTPS.', 'thinkrank'),
1073 ];
1074 }
1075
1076 /**
1077 * The built-in plugin/theme file editor should be disabled
1078 * (DISALLOW_FILE_EDIT) so a compromised admin cannot edit PHP from wp-admin.
1079 *
1080 * @return array
1081 */
1082 public function check_file_editing(): array {
1083 if (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
1084 return [
1085 'label' => __('File editing disabled', 'thinkrank'),
1086 'status' => self::PASSED,
1087 'message' => __('The dashboard plugin/theme file editor is disabled, reducing your attack surface.', 'thinkrank'),
1088 ];
1089 }
1090
1091 return [
1092 'label' => __('File editing disabled', 'thinkrank'),
1093 'status' => self::WARNING,
1094 'message' => __('The built-in file editor is enabled. If an admin account is compromised, an attacker could edit your PHP files from wp-admin.', 'thinkrank'),
1095 'how_to_fix' => __('Add define(\'DISALLOW_FILE_EDIT\', true); to your wp-config.php.', 'thinkrank'),
1096 ];
1097 }
1098
1099 /**
1100 * The site should not publicly display PHP errors (WP_DEBUG_DISPLAY),
1101 * which can leak server paths and internals.
1102 *
1103 * @return array
1104 */
1105 public function check_debug_display(): array {
1106 $debug = defined('WP_DEBUG') && WP_DEBUG;
1107 // WP_DEBUG_DISPLAY only shows errors when it is on (its default) AND
1108 // WP_DEBUG is enabled.
1109 $display = !defined('WP_DEBUG_DISPLAY') || WP_DEBUG_DISPLAY;
1110 $exposing = $debug && $display;
1111
1112 if ($exposing) {
1113 return [
1114 'label' => __('Errors not shown publicly', 'thinkrank'),
1115 'status' => self::WARNING,
1116 'message' => __('Debug output is displayed on the front end. Visible PHP errors can leak server paths and internals.', 'thinkrank'),
1117 'how_to_fix' => __('Set define(\'WP_DEBUG_DISPLAY\', false); (or turn off WP_DEBUG) in wp-config.php on production.', 'thinkrank'),
1118 ];
1119 }
1120
1121 return [
1122 'label' => __('Errors not shown publicly', 'thinkrank'),
1123 'status' => self::PASSED,
1124 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
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 * Except on a builder page, where they do not: the words and headings are
1250 * in builder meta and `post_content` is empty, or, on a Bricks page, holds
1251 * blocks Bricks never renders. Those rows read from builder_content(), so
1252 * a builder-built site is graded on its pages rather than skipped (#892).
1253 *
1254 * @since 2.5.0
1255 * @return array<int,array{id: int, content: string, text: string, modified: int}>
1256 */
1257 private function get_content_sample(): array {
1258 if (null !== $this->content_sample) {
1259 return $this->content_sample;
1260 }
1261
1262 $post_ids = $this->sample_post_ids();
1263
1264 // Meta too: builder_content() asks every sampled post whether Bricks
1265 // owns it, and one query beats a hundred.
1266 if (function_exists('_prime_post_caches')) {
1267 _prime_post_caches($post_ids, false, true);
1268 }
1269
1270 $sample = [];
1271
1272 foreach ($post_ids as $post_id) {
1273 $post = get_post($post_id);
1274 if (!$post) {
1275 continue;
1276 }
1277
1278 $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false;
1279
1280 $content = $this->builder_content($post) ?? (string) $post->post_content;
1281
1282 $sample[] = [
1283 'id' => (int) $post->ID,
1284 'content' => $content,
1285 'text' => $this->content_to_text($content),
1286 'modified' => is_int($modified) ? $modified : 0,
1287 ];
1288 }
1289
1290 $this->content_sample = $sample;
1291
1292 return $sample;
1293 }
1294
1295 /**
1296 * A sampled post's content from its page builder, when that is where it is.
1297 *
1298 * Null for every post whose `post_content` is what the visitor reads, so
1299 * those keep the raw-markup path and never pay for rendering. A post with
1300 * an empty `post_content`, or a Bricks page that discards it, resolves
1301 * through Builder_Content, the extractor every other server-side scoring
1302 * path reads through (#617). With nothing in `post_content` to render, the
1303 * resolution only reads stored builder meta: no shortcode or block
1304 * renderer runs.
1305 *
1306 * Guarded like builder_meta_keys(), so a partial checkout degrades to the
1307 * raw markup instead of fataling mid-audit.
1308 *
1309 * @since 2.15.0
1310 *
1311 * @param \WP_Post $post Sampled post.
1312 * @return string|null Resolved content, or null to use `post_content`.
1313 */
1314 private function builder_content(\WP_Post $post): ?string {
1315 if ([] === self::builder_meta_keys()) {
1316 return null;
1317 }
1318
1319 if ('' !== trim((string) $post->post_content)
1320 && !Builder_Content::bricks_supersedes_post_content((int) $post->ID)
1321 ) {
1322 return null;
1323 }
1324
1325 try {
1326 return Builder_Content::resolve($post);
1327 } catch (\Throwable $e) {
1328 return null;
1329 }
1330 }
1331
1332 /**
1333 * Shared shape for the content-sampled GEO checks: count how many posts in
1334 * the sample satisfy a predicate and grade it on the coverage thresholds
1335 * the Content category already uses.
1336 *
1337 * @since 2.5.0
1338 *
1339 * @param string $label Check label.
1340 * @param callable $predicate Receives one sample row, returns bool.
1341 * @param string $pass_text sprintf template: 1 = matching, 2 = sampled.
1342 * @param string $fail_text sprintf template: 1 = missing, 2 = sampled.
1343 * @param string $how_to_fix Advice shown on a warning/fail.
1344 * @param string $empty_text Message when the site has no content yet.
1345 * @return array
1346 */
1347 private function coverage_check(
1348 string $label,
1349 callable $predicate,
1350 string $pass_text,
1351 string $fail_text,
1352 string $how_to_fix,
1353 string $empty_text
1354 ): array {
1355 // A page whose body is only a shortcode or a commerce block (Checkout,
1356 // My account, Shop) leaves no extractable text, so every prose-shaped
1357 // question here answers "no" for it and would drag the category down
1358 // over content nobody wants quoted in an AI answer. Skipping them is
1359 // deliberate: this grades the pages that could be cited. Builder pages
1360 // are not in this group: get_content_sample() reads their builder
1361 // storage, so they have text and are graded (#892).
1362 $sampled = $this->get_content_sample();
1363 $sample = [];
1364 $textless = [];
1365 foreach ($sampled as $row) {
1366 if ('' !== $row['text']) {
1367 $sample[] = $row;
1368 } else {
1369 $textless[] = $row['id'];
1370 }
1371 }
1372
1373 $total = count($sample);
1374
1375 // Nothing published: nothing to report.
1376 if ([] === $sampled) {
1377 return [
1378 'label' => $label,
1379 'status' => self::PASSED,
1380 'message' => $empty_text,
1381 ];
1382 }
1383
1384 // Pages exist, but none yielded text. That is a sample this check
1385 // could not read, not an empty site, and it used to pass with "No
1386 // published content to check yet", awarding the weight for content
1387 // nobody examined (#892). Say so instead.
1388 if (0 === $total) {
1389 return [
1390 'label' => $label,
1391 'status' => self::WARNING,
1392 'message' => sprintf(
1393 /* translators: %d: number of sampled pages. */
1394 _n(
1395 'ThinkRank found no readable text on the %d page it sampled, so this could not be checked.',
1396 'ThinkRank found no readable text on any of the %d pages it sampled, so this could not be checked.',
1397 count($textless),
1398 'thinkrank'
1399 ),
1400 count($textless)
1401 ),
1402 'how_to_fix' => __('Pages built only from shortcodes, or with a page builder ThinkRank does not support, have no text it can read. Publish pages with written content, or check that your page builder is supported.', 'thinkrank'),
1403 'value' => sprintf('0/%d', count($textless)),
1404 'affected_posts' => $this->affected_posts($textless),
1405 ];
1406 }
1407
1408 $matching = 0;
1409 $failing = [];
1410 foreach ($sample as $row) {
1411 if ($predicate($row)) {
1412 $matching++;
1413 } else {
1414 $failing[] = $row['id'];
1415 }
1416 }
1417
1418 $coverage = (int) round(($matching / $total) * 100);
1419 $value = sprintf('%d/%d', $matching, $total);
1420
1421 if ($coverage >= self::COVERAGE_PASS) {
1422 return [
1423 'label' => $label,
1424 'status' => self::PASSED,
1425 'message' => sprintf($pass_text, $matching, $total),
1426 'value' => $value,
1427 ];
1428 }
1429
1430 // A count alone leaves the user to guess which pages it means, so the
1431 // finding names them.
1432 return [
1433 'label' => $label,
1434 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
1435 'message' => sprintf($fail_text, $total - $matching, $total),
1436 'how_to_fix' => $how_to_fix,
1437 'value' => $value,
1438 'affected_posts' => $this->affected_posts($failing),
1439 ];
1440 }
1441
1442 /**
1443 * The posts a finding is about, with where to fix and where to view each.
1444 *
1445 * The edit link is built rather than taken from get_edit_post_link(), which
1446 * returns nothing without a user who can edit — and the analysis is cached
1447 * and can be computed outside a request.
1448 *
1449 * @since 2.6.0
1450 * @param int[] $post_ids Post ids, in sample order.
1451 * @return array<int,array{id: int, title: string, type: string, edit_url: string, url: string}>
1452 */
1453 private function affected_posts(array $post_ids): array {
1454 $posts = [];
1455
1456 if ($post_ids && function_exists('_prime_post_caches')) {
1457 _prime_post_caches($post_ids, false, false);
1458 }
1459
1460 foreach ($post_ids as $post_id) {
1461 $post = get_post($post_id);
1462 if (!$post) {
1463 continue;
1464 }
1465
1466 $type_object = get_post_type_object($post->post_type);
1467 $title = html_entity_decode((string) get_the_title($post), ENT_QUOTES, 'UTF-8');
1468
1469 // An attachment's permalink is its attachment page, which core
1470 // redirects to the file on most sites; link the file itself.
1471 $url = 'attachment' === $post->post_type && function_exists('wp_get_attachment_url')
1472 ? (string) wp_get_attachment_url((int) $post->ID)
1473 : (string) get_permalink($post);
1474
1475 $posts[] = [
1476 'id' => (int) $post->ID,
1477 'title' => '' !== trim($title) ? $title : __('(no title)', 'thinkrank'),
1478 'type' => $type_object ? (string) $type_object->labels->singular_name : (string) $post->post_type,
1479 'edit_url' => admin_url('post.php?post=' . (int) $post->ID . '&action=edit'),
1480 'url' => $url,
1481 ];
1482 }
1483
1484 return $posts;
1485 }
1486
1487 /**
1488 * The plain text of a post's content, with markup, blocks and shortcodes
1489 * reduced to the words a language model would actually read.
1490 *
1491 * @since 2.5.0
1492 * @param string $content Raw post content.
1493 * @return string
1494 */
1495 private function content_to_text(string $content): string {
1496 // Block delimiters are HTML comments, so strip_tags leaves their
1497 // attribute JSON behind as text and inflates every word count.
1498 $text = preg_replace('/<!--.*?-->/s', ' ', $content);
1499 $text = preg_replace('/\[[^\]]*\]/', ' ', (string) $text);
1500 $text = wp_strip_all_tags((string) $text);
1501
1502 // `/u` makes preg_replace() return NULL on content that is not valid
1503 // UTF-8 — a latin1 install, a raw SQL import, a migrated dump — and
1504 // trim(null) is a TypeError under strict_types. run_checks() catches
1505 // Throwable and continues, so the check did not fail: it DISAPPEARED
1506 // from the audit, taking its weight with it and pushing the overall
1507 // score UP because a failing check had been removed rather than scored.
1508 // Fall back to the byte-wise collapse when the Unicode pass cannot read
1509 // the bytes; a slightly coarser word split is worth far more than a
1510 // check that silently deletes itself.
1511 $collapsed = preg_replace('/\s+/u', ' ', (string) $text);
1512
1513 if (null === $collapsed) {
1514 $collapsed = preg_replace('/\s+/', ' ', (string) $text);
1515 }
1516
1517 return trim((string) $collapsed);
1518 }
1519
1520 /**
1521 * Word count of a string of plain text.
1522 *
1523 * @since 2.5.0
1524 * @param string $text Plain text.
1525 * @return int
1526 */
1527 private function word_count(string $text): int {
1528 if ('' === $text) {
1529 return 0;
1530 }
1531
1532 return count(preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []);
1533 }
1534
1535 /**
1536 * Whether AI answer engines are allowed to reach this site.
1537 *
1538 * Reads the robots.txt that is ACTUALLY served — physical file, custom
1539 * body or generated defaults — rather than the per-agent rule map alone,
1540 * because a hand-written `Disallow: /` for GPTBot blocks it just as
1541 * effectively as the toggle does, and a `User-agent: *` block reaches
1542 * every crawler on the list at once.
1543 *
1544 * @since 2.5.0
1545 * @return array
1546 */
1547 public function check_ai_crawler_access(): array {
1548 $label = __('AI answer engines can crawl your site', 'thinkrank');
1549 $blocked = $this->blocked_answer_agents();
1550
1551 if (empty($blocked)) {
1552 return [
1553 'label' => $label,
1554 'status' => self::PASSED,
1555 'message' => __('ChatGPT, Claude, Perplexity and Google AI Overviews can all reach your content and cite it.', 'thinkrank'),
1556 ];
1557 }
1558
1559 $names = implode(', ', $blocked);
1560 // Every one of them blocked is a different situation from one stray
1561 // rule: the site cannot appear in AI answers at all. Counted against
1562 // the agents the registry actually knows, not the slug list: a slug
1563 // leaving AI_Crawlers can never be collected as blocked, so comparing
1564 // with the list would make this branch quietly unreachable.
1565 $known = count($this->known_answer_agents());
1566 $all = $known > 0 && count($blocked) >= $known;
1567
1568 return [
1569 'label' => $label,
1570 'status' => $all ? self::FAILED : self::WARNING,
1571 'message' => $all
1572 /* translators: %s: comma-separated crawler names. */
1573 ? sprintf(__('Your robots.txt blocks every AI answer engine (%s), so your pages cannot be cited in AI answers at all.', 'thinkrank'), $names)
1574 /* translators: %s: comma-separated crawler names. */
1575 : sprintf(__('Your robots.txt blocks these AI answer engines: %s. Their assistants cannot read or cite your pages.', 'thinkrank'), $names),
1576 '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'),
1577 'value' => $names,
1578 ];
1579 }
1580
1581 /**
1582 * The answer-engine crawlers the served robots.txt disallows entirely.
1583 *
1584 * Public so the one-click fix can re-ask the question after writing the
1585 * rules, rather than reporting a success the served file contradicts.
1586 *
1587 * @since 2.5.0
1588 * @return string[] Crawler labels, empty when all are allowed.
1589 */
1590 public function blocked_answer_agents(): array {
1591 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager') || !class_exists('ThinkRank\\SEO\\AI_Crawlers')) {
1592 return [];
1593 }
1594
1595 $effective = (new Site_Identity_Manager())->get_effective_robots_txt();
1596 $groups = $this->parse_robots_disallow_all((string) ($effective['content'] ?? ''));
1597
1598 if (empty($groups)) {
1599 return [];
1600 }
1601
1602 $blocked = [];
1603
1604 foreach ($this->known_answer_agents() as $agent) {
1605 $token = strtolower((string) $agent['token']);
1606 // A group naming the agent wins over the wildcard group, which is
1607 // how robots.txt precedence works: the most specific matching
1608 // group is the only one that applies.
1609 $denied = array_key_exists($token, $groups) ? $groups[$token] : ($groups['*'] ?? false);
1610
1611 if ($denied) {
1612 $blocked[] = $agent['label'];
1613 }
1614 }
1615
1616 return $blocked;
1617 }
1618
1619 /**
1620 * The answer-engine crawlers the AI_Crawlers registry actually knows.
1621 *
1622 * The slug list is ThinkRank's editorial position on which crawlers decide
1623 * whether a site can be cited; the registry is what carries their tokens
1624 * and labels. Everything that counts answer engines counts these, so a slug
1625 * that ever leaves the registry drops out of the check and its totals
1626 * together instead of skewing one against the other.
1627 *
1628 * @since 2.5.0
1629 * @return array<string,array> Registry entries keyed by slug.
1630 */
1631 private function known_answer_agents(): array {
1632 if (!class_exists('ThinkRank\\SEO\\AI_Crawlers')) {
1633 return [];
1634 }
1635
1636 $agents = AI_Crawlers::all();
1637 $known = [];
1638
1639 foreach (self::GEO_ANSWER_AGENTS as $slug) {
1640 if (isset($agents[$slug]['token'], $agents[$slug]['label'])) {
1641 $known[$slug] = $agents[$slug];
1642 }
1643 }
1644
1645 return $known;
1646 }
1647
1648 /**
1649 * Map a robots.txt body to `user-agent => disallows everything`.
1650 *
1651 * Consecutive `User-agent:` lines open one shared group, so the agents
1652 * listed above a `Disallow: /` all inherit it. An agent that appears with
1653 * narrower rules is recorded as false rather than omitted — otherwise it
1654 * would fall through to the wildcard group it is meant to override.
1655 *
1656 * @since 2.5.0
1657 * @param string $body Robots.txt content.
1658 * @return array<string,bool>
1659 */
1660 private function parse_robots_disallow_all(string $body): array {
1661 $groups = [];
1662 $current = [];
1663 // Whether the next `User-agent:` continues this group or opens a new
1664 // one. Rules close a group; another agent line before any rule does not.
1665 $collecting = true;
1666
1667 foreach (preg_split('/\R/', $body) ?: [] as $line) {
1668 $line = trim((string) preg_replace('/#.*/', '', $line));
1669
1670 if ('' === $line || false === strpos($line, ':')) {
1671 continue;
1672 }
1673
1674 [$field, $value] = array_map('trim', explode(':', $line, 2));
1675 $field = strtolower($field);
1676
1677 if ('user-agent' === $field) {
1678 if (!$collecting) {
1679 $current = [];
1680 $collecting = true;
1681 }
1682
1683 $agent = strtolower($value);
1684 if ('' !== $agent) {
1685 $current[] = $agent;
1686 if (!isset($groups[$agent])) {
1687 $groups[$agent] = false;
1688 }
1689 }
1690
1691 continue;
1692 }
1693
1694 $collecting = false;
1695
1696 // `/` is the canonical full block; `/*` is the same instruction
1697 // written for a wildcard-aware crawler, and every answer engine on
1698 // the list is one.
1699 if ('disallow' === $field && ('/' === $value || '/*' === $value)) {
1700 foreach ($current as $agent) {
1701 $groups[$agent] = true;
1702 }
1703 }
1704 }
1705
1706 return $groups;
1707 }
1708
1709 /**
1710 * /llms.txt should be published — it is the one file whose entire purpose
1711 * is telling an AI assistant what this site is and what to read.
1712 *
1713 * @since 2.5.0
1714 * @return array
1715 */
1716 public function check_llms_txt(): array {
1717 $label = __('llms.txt is published', 'thinkrank');
1718
1719 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
1720 return [
1721 'label' => $label,
1722 'status' => self::WARNING,
1723 'message' => __('The llms.txt module is unavailable, so this could not be checked.', 'thinkrank'),
1724 ];
1725 }
1726
1727 $manager = new LLMs_Txt_Manager();
1728
1729 if ($manager->is_published()) {
1730 return [
1731 'label' => $label,
1732 'status' => self::PASSED,
1733 'message' => __('Your llms.txt is published, so AI assistants have a summary of your site and its key pages.', 'thinkrank'),
1734 'value' => home_url('/llms.txt'),
1735 ];
1736 }
1737
1738 return [
1739 'label' => $label,
1740 'status' => self::FAILED,
1741 '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'),
1742 'how_to_fix' => __('Fill in the fields under Essential SEO → Crawling & AI Indexing → LLMs.txt and publish it.', 'thinkrank'),
1743 ];
1744 }
1745
1746 /**
1747 * Answer-shaped schema — FAQ, HowTo or Q&A — is what lets an engine lift a
1748 * question and its answer as a pair instead of guessing at prose.
1749 *
1750 * @since 2.5.0
1751 * @return array
1752 */
1753 public function check_answer_ready_schema(): array {
1754 $label = __('Answer-ready structured data', 'thinkrank');
1755
1756 if ($this->has_answer_schema()) {
1757 return [
1758 'label' => $label,
1759 'status' => self::PASSED,
1760 'message' => __('Your site publishes FAQ, How-To or Q&A structured data, which AI answers can quote question-and-answer pairs from directly.', 'thinkrank'),
1761 ];
1762 }
1763
1764 // Only advise FAQ/HowTo markup where there is question-and-answer
1765 // content to mark up. This used to be assigned unconditionally and
1766 // reused by both branches below, so a site of ordinary articles was
1767 // told to enable FAQPage — and a user who follows that gets FAQPage
1768 // schema with an empty or invented mainEntity, which is the failure
1769 // #494 documents. The check is allowed to report the gap; it is not
1770 // allowed to recommend manufacturing the content (#686).
1771 $how_to_fix = $this->site_has_qa_content()
1772 ? __('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')
1773 : __('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');
1774
1775 // Article/WebPage schema still tells an engine what the page is; the
1776 // gap is the answer pairing, not structured data as a whole.
1777 if ($this->schema_is_output()) {
1778 return [
1779 'label' => $label,
1780 'status' => self::WARNING,
1781 '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'),
1782 'how_to_fix' => $how_to_fix,
1783 ];
1784 }
1785
1786 return [
1787 'label' => $label,
1788 'status' => self::FAILED,
1789 'message' => __('Your pages publish no structured data at all, so an AI assistant has to infer what each page is from its prose.', 'thinkrank'),
1790 'how_to_fix' => $how_to_fix,
1791 ];
1792 }
1793
1794 /**
1795 * Whether the site plausibly publishes question-and-answer content.
1796 *
1797 * Deliberately a different question from has_answer_schema(). That one asks
1798 * "does ThinkRank emit answer markup?", which is what the check reports on.
1799 * This one asks "is there anything here that answer markup would describe?",
1800 * which is what decides whether recommending it is sound advice — and it has
1801 * to see content ThinkRank did not author, because an FAQ built with a page
1802 * builder's own accordion is still an FAQ (#686).
1803 *
1804 * Any one signal is enough; all of them are bounded.
1805 *
1806 * @since 2.7.0
1807 * @return bool
1808 */
1809 private function site_has_qa_content(): bool {
1810 if (null !== $this->site_has_qa_content) {
1811 return $this->site_has_qa_content;
1812 }
1813
1814 $this->site_has_qa_content = $this->qa_content_markers_exist()
1815 || $this->sample_has_question_headings();
1816
1817 return $this->site_has_qa_content;
1818 }
1819
1820 /**
1821 * Q&A markers in stored content or builder trees, site-wide.
1822 *
1823 * Two bounded queries rather than a walk over the content sample: the
1824 * sample is the 100 most recent posts and pages, so a site whose only FAQ
1825 * lives in a custom post type, or further back than that, answered "no
1826 * answer content" while publishing exactly that (#686).
1827 *
1828 * @since 2.7.0
1829 * @return bool
1830 */
1831 private function qa_content_markers_exist(): bool {
1832 global $wpdb;
1833
1834 // Block markup and core's details/summary block, in any public type.
1835 $content_markers = ['wp:thinkrank/faq', 'wp:thinkrank/howto', 'wp:details'];
1836 $clauses = [];
1837 $values = [];
1838
1839 foreach ($content_markers as $marker) {
1840 $clauses[] = 'p.post_content LIKE %s';
1841 $values[] = '%' . $wpdb->esc_like($marker) . '%';
1842 }
1843
1844 // The OR list is one '%s' per entry in a fixed class-level marker list,
1845 // so its length varies but its content never comes from input; every
1846 // value goes through prepare(). phpcs cannot see that, and this is the
1847 // usual variable-length-IN exemption.
1848 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1849 $found = (int) $wpdb->get_var(
1850 $wpdb->prepare(
1851 "SELECT 1 FROM {$wpdb->posts} p
1852 WHERE p.post_status = 'publish'
1853 AND (" . implode(' OR ', $clauses) . ')
1854 LIMIT 1',
1855 ...$values
1856 )
1857 );
1858 // phpcs:enable
1859
1860 if (1 === $found) {
1861 return true;
1862 }
1863
1864 // Builder trees: ThinkRank's own elements, and the builders' generic
1865 // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ
1866 // is actually built from.
1867 $meta_keys = self::builder_meta_keys();
1868
1869 if (empty($meta_keys)) {
1870 return false;
1871 }
1872
1873 $markers = array_merge(
1874 [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME],
1875 self::GENERIC_QA_ELEMENT_MARKERS
1876 );
1877
1878 $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s'));
1879 $marker_clauses = [];
1880 $marker_values = [];
1881
1882 foreach ($markers as $marker) {
1883 $marker_clauses[] = 'pm.meta_value LIKE %s';
1884 $marker_values[] = '%' . $wpdb->esc_like($marker) . '%';
1885 }
1886
1887 // Same exemption: both placeholder runs are sized from fixed lists —
1888 // the builder meta keys and the marker list — and every value is
1889 // prepared.
1890 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1891 $found = (int) $wpdb->get_var(
1892 $wpdb->prepare(
1893 "SELECT 1 FROM {$wpdb->postmeta} pm
1894 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1895 WHERE p.post_status = 'publish'
1896 AND pm.meta_key IN ({$key_placeholders})
1897 AND (" . implode(' OR ', $marker_clauses) . ')
1898 LIMIT 1',
1899 ...array_merge($meta_keys, $marker_values)
1900 )
1901 );
1902 // phpcs:enable
1903
1904 return 1 === $found;
1905 }
1906
1907 /**
1908 * Whether the sampled content asks questions in its headings.
1909 *
1910 * The weakest signal and the last one tried: prose that poses questions is
1911 * content answer markup could describe, even where nothing has been marked
1912 * up yet. Runs over the existing sample, so it costs nothing extra.
1913 *
1914 * @since 2.7.0
1915 * @return bool
1916 */
1917 private function sample_has_question_headings(): bool {
1918 foreach ($this->get_content_sample() as $row) {
1919 $content = (string) ($row['content'] ?? '');
1920
1921 if ('' === $content) {
1922 continue;
1923 }
1924
1925 if (preg_match('/<h[2-4][^>]*>\s*[^<]*\?\s*<\/h[2-4]>/i', $content)) {
1926 return true;
1927 }
1928 }
1929
1930 return false;
1931 }
1932
1933 /**
1934 * Whether the site publishes FAQ / HowTo / Q&A structured data.
1935 *
1936 * Three sources, because three things emit it: the Schema Management
1937 * System's enabled types, a per-post-type schema_type in Global SEO, and
1938 * ThinkRank's own FAQ/HowTo blocks, which output their schema from the
1939 * block itself with nothing to configure.
1940 *
1941 * @since 2.5.0
1942 * @return bool
1943 */
1944 private function has_answer_schema(): bool {
1945 $answer_types = ['faqpage', 'faq', 'howto', 'qapage'];
1946
1947 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
1948 $settings = (new Schema_Management_System())->get_settings('site', null);
1949
1950 if (is_array($settings) && !empty($settings['enabled'])) {
1951 $enabled = $settings['enabled_schema_types'] ?? [];
1952 if (is_array($enabled)) {
1953 foreach ($enabled as $type) {
1954 if (in_array(strtolower((string) $type), $answer_types, true)) {
1955 return true;
1956 }
1957 }
1958 }
1959 }
1960 }
1961
1962 $global = get_option('thinkrank_global_seo_settings', []);
1963 if (is_array($global)) {
1964 foreach ($global as $per_type) {
1965 $type = is_array($per_type) ? strtolower((string) ($per_type['schema_type'] ?? '')) : '';
1966 if ('' !== $type && in_array($type, $answer_types, true)) {
1967 return true;
1968 }
1969 }
1970 }
1971
1972 // The blocks carry their own schema, so a single page using one is a
1973 // true positive even with every schema setting untouched.
1974 foreach ($this->get_content_sample() as $row) {
1975 if ($this->has_answer_content((int) ($row['id'] ?? 0), (string) $row['content'])) {
1976 return true;
1977 }
1978 }
1979
1980 // ...and again beyond the sample, which is the 100 most recent posts
1981 // and pages. A site whose only FAQ lives in a custom post type, or
1982 // simply further back than that, reported no answer schema while
1983 // ThinkRank was publishing exactly that (#686). Added alongside the
1984 // walk above rather than replacing it: the sample is already loaded and
1985 // resolves Bricks trees, so it stays the primary source and this only
1986 // extends the reach.
1987 foreach ($this->answer_content_candidates() as $post_id => $content) {
1988 if ($this->has_answer_content($post_id, $content)) {
1989 return true;
1990 }
1991 }
1992
1993 return false;
1994 }
1995
1996 /**
1997 * Posts that might carry a ThinkRank FAQ / How-To, from anywhere on the site.
1998 *
1999 * Narrowed in SQL to posts whose content or builder meta names one of
2000 * ThinkRank's answer surfaces, so the per-post confirmation below runs over
2001 * a handful of rows rather than the whole site.
2002 *
2003 * @since 2.7.0
2004 * @return array<int,string> Post id => raw content.
2005 */
2006 private function answer_content_candidates(): array {
2007 global $wpdb;
2008
2009 $markers = [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME, 'wp:thinkrank/faq', 'wp:thinkrank/howto'];
2010
2011 $content_clauses = [];
2012 $content_values = [];
2013 foreach ($markers as $marker) {
2014 $content_clauses[] = 'p.post_content LIKE %s';
2015 $content_values[] = '%' . $wpdb->esc_like($marker) . '%';
2016 }
2017
2018 $meta_keys = self::builder_meta_keys();
2019 $meta_sql = '';
2020 $values = $content_values;
2021
2022 if (!empty($meta_keys)) {
2023 $meta_clauses = [];
2024 $meta_values = [];
2025 foreach ($markers as $marker) {
2026 $meta_clauses[] = 'pm.meta_value LIKE %s';
2027 $meta_values[] = '%' . $wpdb->esc_like($marker) . '%';
2028 }
2029
2030 $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s'));
2031 $meta_sql = " OR EXISTS (
2032 SELECT 1 FROM {$wpdb->postmeta} pm
2033 WHERE pm.post_id = p.ID
2034 AND pm.meta_key IN ({$key_placeholders})
2035 AND (" . implode(' OR ', $meta_clauses) . ')
2036 )';
2037 $values = array_merge($content_values, $meta_keys, $meta_values);
2038 }
2039
2040 $values[] = self::CONTENT_SAMPLE_SIZE;
2041
2042 // Same exemption as qa_content_markers_exist(): the clause lists are
2043 // sized from fixed marker and meta-key lists, and every value is
2044 // prepared.
2045 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2046 $rows = $wpdb->get_results(
2047 $wpdb->prepare(
2048 "SELECT p.ID, p.post_content FROM {$wpdb->posts} p
2049 WHERE p.post_status = 'publish'
2050 AND ((" . implode(' OR ', $content_clauses) . ")
2051 {$meta_sql})
2052 ORDER BY p.post_date DESC
2053 LIMIT %d",
2054 ...$values
2055 ),
2056 ARRAY_A
2057 );
2058 // phpcs:enable
2059
2060 $candidates = [];
2061 foreach ((array) $rows as $row) {
2062 $candidates[(int) $row['ID']] = (string) $row['post_content'];
2063 }
2064
2065 return $candidates;
2066 }
2067
2068 /**
2069 * Whether one post carries a ThinkRank FAQ or How-To that emits schema.
2070 *
2071 * Four surfaces, because `Schema_Graph` collects from four: the Gutenberg
2072 * block in `post_content`, the Elementor widget, the Bricks element and the
2073 * Beaver Builder module — the last three living in postmeta. Reading only
2074 * the block would tell a site whose FAQs are built in a page builder that it
2075 * publishes no FAQ schema while the graph is publishing exactly that.
2076 *
2077 * @since 2.5.0
2078 * @param int $post_id Post to inspect; 0 skips the builder surfaces.
2079 * @param string $content Raw post content.
2080 * @return bool
2081 */
2082 private function has_answer_content(int $post_id, string $content): bool {
2083 if (false !== strpos($content, 'wp:thinkrank/faq')
2084 || false !== strpos($content, 'wp:thinkrank/howto')) {
2085 return true;
2086 }
2087
2088 if ($post_id <= 0) {
2089 return false;
2090 }
2091
2092 // Elementor stores its tree as JSON, so the widget name appears verbatim.
2093 $elementor = get_post_meta($post_id, '_elementor_data', true);
2094 if (is_string($elementor)
2095 && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"')
2096 || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) {
2097 return true;
2098 }
2099
2100 // Bricks: the tree it will actually render, resolved the same way
2101 // Schema_Graph resolves it, so templates and components are covered.
2102 if ($this->bricks_has_answer_element($post_id)) {
2103 return true;
2104 }
2105
2106 // Beaver Builder keeps its layout in postmeta as a map of node objects.
2107 $layout = get_post_meta($post_id, '_fl_builder_data', true);
2108 if (is_array($layout)) {
2109 foreach ($layout as $node) {
2110 $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
2111 $settings = is_object($settings) ? get_object_vars($settings) : $settings;
2112 $type = is_array($settings) ? (string) ($settings['type'] ?? '') : '';
2113
2114 if (self::ANSWER_FAQ_NAME === $type || self::ANSWER_HOWTO_NAME === $type) {
2115 return true;
2116 }
2117 }
2118 }
2119
2120 // Oxygen and Breakdance were missing entirely, so a ThinkRank FAQ
2121 // element placed inside one of those pages reported no answer schema
2122 // while the page was publishing exactly that (#686). Builder_Content
2123 // already knows every key involved — Oxygen 6 is Breakdance under the
2124 // hood, and older releases used two other keys — so ask it rather than
2125 // keeping a second list that can drift.
2126 foreach (self::builder_meta_keys() as $meta_key) {
2127 if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) {
2128 continue; // Handled above, in their own storage shapes.
2129 }
2130
2131 $stored = get_post_meta($post_id, $meta_key, true);
2132
2133 if (self::blob_names_answer_element($stored)) {
2134 return true;
2135 }
2136 }
2137
2138 return false;
2139 }
2140
2141 /**
2142 * Builder meta keys, or [] when Builder_Content is unavailable.
2143 *
2144 * Mirrors the defensive load in bricks_has_answer_element(): the analyzer
2145 * must degrade to "no answer content found" on a partial checkout rather
2146 * than fatal mid-audit.
2147 *
2148 * @since 2.7.0
2149 * @return string[]
2150 */
2151 private static function builder_meta_keys(): array {
2152 if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
2153 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
2154 if (!file_exists($file)) {
2155 return [];
2156 }
2157 require_once $file;
2158 }
2159
2160 return Builder_Content::builder_meta_keys();
2161 }
2162
2163 /**
2164 * Whether a stored builder blob names a ThinkRank FAQ or How-To.
2165 *
2166 * The blob is a JSON tree for Breakdance/Oxygen 6 and a shortcode string
2167 * for Oxygen classic, so this matches on the element name appearing in the
2168 * serialized form rather than parsing each dialect.
2169 *
2170 * @since 2.7.0
2171 * @param mixed $stored Raw meta value.
2172 * @return bool
2173 */
2174 private static function blob_names_answer_element($stored): bool {
2175 if (is_array($stored)) {
2176 $stored = wp_json_encode($stored);
2177 }
2178
2179 if (!is_string($stored) || '' === $stored) {
2180 return false;
2181 }
2182
2183 return false !== strpos($stored, self::ANSWER_FAQ_NAME)
2184 || false !== strpos($stored, self::ANSWER_HOWTO_NAME);
2185 }
2186
2187 /**
2188 * Whether a Bricks-rendered post holds a ThinkRank FAQ or How-To element.
2189 *
2190 * @since 2.5.0
2191 * @param int $post_id Post to inspect.
2192 * @return bool
2193 */
2194 private function bricks_has_answer_element(int $post_id): bool {
2195 if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
2196 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
2197 if (!file_exists($file)) {
2198 return false;
2199 }
2200 require_once $file;
2201 }
2202
2203 foreach (Builder_Content::bricks_tree($post_id) as $element) {
2204 $name = is_array($element) ? (string) ($element['name'] ?? '') : '';
2205
2206 if (self::ANSWER_FAQ_NAME === $name || self::ANSWER_HOWTO_NAME === $name) {
2207 return true;
2208 }
2209 }
2210
2211 return false;
2212 }
2213
2214 /**
2215 * Pages should open with a short, self-contained answer an engine can lift.
2216 *
2217 * @since 2.5.0
2218 * @return array
2219 */
2220 public function check_direct_answer(): array {
2221 return $this->coverage_check(
2222 __('Pages open with a direct answer', 'thinkrank'),
2223 function (array $row): bool {
2224 $words = $this->word_count($this->opening_passage($row['content']));
2225
2226 return $words >= self::GEO_ANSWER_MIN_WORDS && $words <= self::GEO_ANSWER_MAX_WORDS;
2227 },
2228 /* translators: 1: matching posts, 2: sampled posts. */
2229 __('%1$d of your %2$d most recent pages open with a concise, quotable answer.', 'thinkrank'),
2230 /* translators: 1: posts without one, 2: sampled posts. */
2231 __('%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'),
2232 __('Open each page with a 40–60 word paragraph that answers its title directly, before any background or introduction.', 'thinkrank'),
2233 __('No published content to check yet.', 'thinkrank')
2234 );
2235 }
2236
2237 /**
2238 * The first block of prose in a post, before any heading.
2239 *
2240 * @since 2.5.0
2241 * @param string $content Raw post content.
2242 * @return string Plain text of the opening passage.
2243 */
2244 private function opening_passage(string $content): string {
2245 // Cut at the first heading: everything above it is the intro, and an
2246 // intro that runs past a heading is not a direct answer either way.
2247 $chunks = preg_split('/<h[1-6][^>]*>/i', $content, 3) ?: [];
2248 $intro = (string) ($chunks[0] ?? $content);
2249
2250 $passage = $this->first_paragraph_text($intro);
2251 if ('' !== $passage) {
2252 return $passage;
2253 }
2254
2255 $text = $this->content_to_text($intro);
2256
2257 // A page that opens with its heading has nothing above it, so the chunk
2258 // read so far is empty and the answer sits directly under that heading.
2259 // Scoring it as a missing opening would fail exactly the pages written
2260 // as "question, then answer" — the shape this check exists to reward.
2261 if ('' === trim($text) && isset($chunks[1])) {
2262 // Drop the heading's own text, which the split left at the head of
2263 // the next chunk, so a long heading cannot pose as the answer.
2264 $after = preg_replace('/^.*?<\/h[1-6]>/is', '', (string) $chunks[1], 1);
2265 $after = null === $after ? (string) $chunks[1] : $after;
2266
2267 $passage = $this->first_paragraph_text($after);
2268 if ('' !== $passage) {
2269 return $passage;
2270 }
2271
2272 return $this->content_to_text($after);
2273 }
2274
2275 return $text;
2276 }
2277
2278 /**
2279 * The first paragraph of a chunk of content with enough words to be a
2280 * passage rather than a caption or a stray line.
2281 *
2282 * Only the first one counts — a three-paragraph intro is exactly the
2283 * preamble the direct-answer check is looking for.
2284 *
2285 * @since 2.5.0
2286 * @param string $chunk Raw content fragment.
2287 * @return string Plain text, or '' when the chunk holds no paragraph.
2288 */
2289 private function first_paragraph_text(string $chunk): string {
2290 $paragraphs = preg_split('/<\/p>|\R{2,}/', $chunk) ?: [];
2291
2292 foreach ($paragraphs as $paragraph) {
2293 $candidate = $this->content_to_text((string) $paragraph);
2294 if ($this->word_count($candidate) >= 5) {
2295 return $candidate;
2296 }
2297 }
2298
2299 return '';
2300 }
2301
2302 /**
2303 * Question-shaped H2/H3 headings map a page onto the questions people
2304 * actually ask an assistant.
2305 *
2306 * @since 2.5.0
2307 * @return array
2308 */
2309 public function check_question_headings(): array {
2310 return $this->coverage_check(
2311 __('Headings phrased as questions', 'thinkrank'),
2312 function (array $row): bool {
2313 return $this->has_question_heading($row['content']);
2314 },
2315 /* translators: 1: matching posts, 2: sampled posts. */
2316 __('%1$d of your %2$d most recent pages use question-style headings.', 'thinkrank'),
2317 /* translators: 1: posts without one, 2: sampled posts. */
2318 __('%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'),
2319 __('Phrase at least one H2 or H3 per page as the question it answers: "How does X work?" rather than "Overview".', 'thinkrank'),
2320 __('No published content to check yet.', 'thinkrank')
2321 );
2322 }
2323
2324 /**
2325 * Whether any H2/H3 in the content reads as a question.
2326 *
2327 * @since 2.5.0
2328 * @param string $content Raw post content.
2329 * @return bool
2330 */
2331 private function has_question_heading(string $content): bool {
2332 if (!preg_match_all('/<h[23][^>]*>(.*?)<\/h[23]>/is', $content, $matches)) {
2333 return false;
2334 }
2335
2336 $starters = ['what', 'why', 'how', 'when', 'where', 'who', 'which', 'can', 'do', 'does', 'is', 'are', 'should', 'will'];
2337
2338 foreach ($matches[1] as $heading) {
2339 $text = $this->content_to_text((string) $heading);
2340
2341 if ('' === $text) {
2342 continue;
2343 }
2344
2345 if ('?' === substr($text, -1)) {
2346 return true;
2347 }
2348
2349 // A question mark is the reliable signal, but plenty of good
2350 // question headings drop it ("How image search works").
2351 $first = strtolower((string) strtok($text, " \t\n"));
2352 if (in_array($first, $starters, true)) {
2353 return true;
2354 }
2355 }
2356
2357 return false;
2358 }
2359
2360 /**
2361 * Lists and tables are the shapes an engine extracts most reliably.
2362 *
2363 * @since 2.5.0
2364 * @return array
2365 */
2366 public function check_structured_content(): array {
2367 return $this->coverage_check(
2368 __('Content uses lists or tables', 'thinkrank'),
2369 static function (array $row): bool {
2370 return (bool) preg_match('/<(ul|ol|table)[\s>]/i', $row['content']);
2371 },
2372 /* translators: 1: matching posts, 2: sampled posts. */
2373 __('%1$d of your %2$d most recent pages present information in lists or tables.', 'thinkrank'),
2374 /* translators: 1: posts without one, 2: sampled posts. */
2375 __('%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'),
2376 __('Break steps, comparisons and specifications out of the paragraphs into list or table blocks.', 'thinkrank'),
2377 __('No published content to check yet.', 'thinkrank')
2378 );
2379 }
2380
2381 /**
2382 * Thin pages are not cited: there is nothing in them worth quoting.
2383 *
2384 * @since 2.5.0
2385 * @return array
2386 */
2387 public function check_content_depth(): array {
2388 return $this->coverage_check(
2389 __('Pages have enough depth to cite', 'thinkrank'),
2390 function (array $row): bool {
2391 return $this->word_count($row['text']) >= self::GEO_DEPTH_MIN_WORDS;
2392 },
2393 /* translators: 1: matching posts, 2: sampled posts. */
2394 __('%1$d of your %2$d most recent pages have enough substance for an assistant to cite.', 'thinkrank'),
2395 /* translators: 1: thin posts, 2: sampled posts. */
2396 __('%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'),
2397 __('Expand thin pages so each one answers its topic completely, or merge them into a page that does.', 'thinkrank'),
2398 __('No published content to check yet.', 'thinkrank')
2399 );
2400 }
2401
2402 /**
2403 * Answer engines strongly prefer recently-revised sources.
2404 *
2405 * @since 2.5.0
2406 * @return array
2407 */
2408 public function check_content_freshness(): array {
2409 $cutoff = time() - self::GEO_FRESHNESS_MAX_AGE;
2410
2411 return $this->coverage_check(
2412 __('Content has been updated recently', 'thinkrank'),
2413 static function (array $row) use ($cutoff): bool {
2414 return $row['modified'] > 0 && $row['modified'] >= $cutoff;
2415 },
2416 /* translators: 1: matching posts, 2: sampled posts. */
2417 __('%1$d of your %2$d most recent pages were revised within the last year.', 'thinkrank'),
2418 /* translators: 1: stale posts, 2: sampled posts. */
2419 __('%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'),
2420 __('Review your most important pages, update what has changed, and save them so their modified date reflects the revision.', 'thinkrank'),
2421 __('No published content to check yet.', 'thinkrank')
2422 );
2423 }
2424 }
2425